IPCR Forge: public front door (docker pull without IPCR)

ipcr-public: a second ipcrd behind Caddy at ipcr-<domain>, plain HTTP, no
pinning, off the admin and CI networks. With PUBLIC_POLICY it serves only
this forge's publisher name, its verified ENS/DNS name and its published
image roots; everything else is 404, pushes 405. So

  docker pull ipcr-<domain>/ipns/metadec.eth/ipcr-hello:1.0.0

works on any machine with plain Docker. Switch in the admin page
(config.json "public"), mirrored into published.json for the pages, which
show the public pull lines while it is on.

ipcr 1.5.0, bridge 0.4.0. Verified from a second machine (docs/forge.md).
This commit is contained in:
Worph
2026-10-05 12:06:45 +00:00
parent c961d74e0e
commit 241462cb76
5 changed files with 166 additions and 10 deletions
+60 -5
View File
@@ -35,7 +35,7 @@ services:
# with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080. # with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080.
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook. # Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
ipcr-forge: ipcr-forge:
image: ghcr.io/yundera/ipcr-forge-bridge:0.3.0 image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0
container_name: ipcr-forge container_name: ipcr-forge
user: $PUID:$PGID user: $PUID:$PGID
read_only: true read_only: true
@@ -281,7 +281,7 @@ services:
# push that name only; IPCR logs in as the importer (state/staging-auth, which it generates). # push that name only; IPCR logs in as the importer (state/staging-auth, which it generates).
# Same image as the bridge (`ipcr-forge-bridge gate`, bridge/gate.go). See rationale.md. # Same image as the bridge (`ipcr-forge-bridge gate`, bridge/gate.go). See rationale.md.
forge-gate: forge-gate:
image: ghcr.io/yundera/ipcr-forge-bridge:0.3.0 image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0
container_name: forge-gate container_name: forge-gate
user: $PUID:$PGID user: $PUID:$PGID
read_only: true read_only: true
@@ -475,7 +475,7 @@ services:
# ipcrd: TLS front door on 127.0.0.1:4767. Passes /ipfs/<cid> through to the stock nerdctl # ipcrd: TLS front door on 127.0.0.1:4767. Passes /ipfs/<cid> through to the stock nerdctl
# registry below, resolves /ipns/<name>:<tag> itself, and pins every image it serves. # registry below, resolves /ipns/<name>:<tag> itself, and pins every image it serves.
ipcr-gateway: ipcr-gateway:
image: ghcr.io/yundera/ipcr:1.4.0 image: ghcr.io/yundera/ipcr:1.5.0
container_name: ipcr-gateway container_name: ipcr-gateway
# Root for one reason: it writes its CA into the host's Docker trust store (the narrow # Root for one reason: it writes its CA into the host's Docker trust store (the narrow
# bind below), which is root-owned. See rationale.md. # bind below), which is root-owned. See rationale.md.
@@ -555,10 +555,63 @@ services:
limits: limits:
memory: 128M memory: 128M
# ── Public front door ───────────────────────────────────────────────────────
# Forge only: the same ipcrd, a second process, serving the registry API to anyone at
# ipcr-<domain>, read-only, for this forge's own images only (gateway/public.go): its publisher
# name, its verified ENS/DNS name, its published image roots. Everything else is 404, so nobody
# can make this node fetch other IPFS content. `docker pull ipcr-<domain>/ipns/<name>/<repo>:<tag>`
# then works on any machine, without IPCR. A pull here pins nothing. The admin pages switch it
# off (config.json "public": false). Not on the admin or CI networks. See rationale.md.
ipcr-public:
image: ghcr.io/yundera/ipcr:1.5.0
container_name: ipcr-public
user: $PUID:$PGID
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["gateway"]
environment:
TZ: $TZ
# Plain HTTP: Caddy terminates TLS (no TLS_DIR, no CA to export).
LISTEN: ":4770"
AUTO_PIN: "false"
PUBLIC_POLICY: /data/state
volumes:
# published.json and config.json, written by ipcr-gateway. Read-only.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/data/state:ro
depends_on:
- ipcr-registry
healthcheck:
test: ["CMD", "ipcrd", "health", "http://127.0.0.1:4770/v2/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
expose:
- 4770
labels:
caddy_0: ipcr-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 4770}}"
caddy_1: ipcr-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 4770}}"
caddy_2: ipcr-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 4770}}"
networks:
- pcs
- ipcr-internal
cpu_shares: 30
deploy:
resources:
limits:
memory: 256M
# ── Stock nerdctl IPFS registry ───────────────────────────────────────────── # ── Stock nerdctl IPFS registry ─────────────────────────────────────────────
# `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads. # `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads.
ipcr-registry: ipcr-registry:
image: ghcr.io/yundera/ipcr:1.4.0 image: ghcr.io/yundera/ipcr:1.5.0
container_name: ipcr-registry container_name: ipcr-registry
# Root because nerdctl run as any other uid assumes rootless mode and refuses to start # Root because nerdctl run as any other uid assumes rootless mode and refuses to start
# without a rootless containerd — which `ipfs registry serve` never uses. The service has # without a rootless containerd — which `ipfs registry serve` never uses. The service has
@@ -725,6 +778,7 @@ x-casaos:
| --- | --- | | --- | --- |
| `https://gitea-$APP_DOMAIN` | Gitea (opens from the tile). Sign in as `gitea_admin` with your default app password | | `https://gitea-$APP_DOMAIN` | Gitea (opens from the tile). Sign in as `gitea_admin` with your default app password |
| `https://ipcr-forge-$APP_DOMAIN` | The forge's page (public) | | `https://ipcr-forge-$APP_DOMAIN` | The forge's page (public) |
| `ipcr-$APP_DOMAIN` | `docker pull` this forge's images from anywhere, without IPCR (read-only) |
| `https://radicle-$APP_DOMAIN` | Radicle explorer (public) | | `https://radicle-$APP_DOMAIN` | Radicle explorer (public) |
| `https://radicle-api-$APP_DOMAIN` | Radicle API and read-only git (public) | | `https://radicle-api-$APP_DOMAIN` | Radicle API and read-only git (public) |
| `ipcr.localhost:4767` | IPCR registry, this server's Docker only | | `ipcr.localhost:4767` | IPCR registry, this server's Docker only |
@@ -760,6 +814,7 @@ x-casaos:
| --- | --- | | --- | --- |
| `https://gitea-$APP_DOMAIN` | Gitea (s'ouvre depuis la tuile). Connexion : `gitea_admin` et votre mot de passe d'applications par défaut | | `https://gitea-$APP_DOMAIN` | Gitea (s'ouvre depuis la tuile). Connexion : `gitea_admin` et votre mot de passe d'applications par défaut |
| `https://ipcr-forge-$APP_DOMAIN` | La page de la forge (publique) | | `https://ipcr-forge-$APP_DOMAIN` | La page de la forge (publique) |
| `ipcr-$APP_DOMAIN` | `docker pull` des images de cette forge depuis n'importe où, sans IPCR (lecture seule) |
| `https://radicle-$APP_DOMAIN` | Explorateur Radicle (public) | | `https://radicle-$APP_DOMAIN` | Explorateur Radicle (public) |
| `https://radicle-api-$APP_DOMAIN` | API Radicle et git en lecture seule (public) | | `https://radicle-api-$APP_DOMAIN` | API Radicle et git en lecture seule (public) |
| `ipcr.localhost:4767` | Registre IPCR, pour le Docker de ce serveur uniquement | | `ipcr.localhost:4767` | Registre IPCR, pour le Docker de ce serveur uniquement |
@@ -919,7 +974,7 @@ x-compose-app:
# the example repository (in that organisation). Idempotent, so on every start: it repairs the webhook. # the example repository (in that organisation). Idempotent, so on every start: it repairs the webhook.
# The admin password is used here only, never stored. # The admin password is used here only, never stored.
- name: forge-setup - name: forge-setup
image: ghcr.io/yundera/ipcr-forge-bridge:0.3.0 image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0
user: $PUID:$PGID user: $PUID:$PGID
phase: post_up phase: post_up
network: $APP_NET network: $APP_NET
+19
View File
@@ -48,6 +48,8 @@ with. See docs/forge.md.
bridge. bridge.
15. **The root organisation** (`IPCR_ROOT_ORG`): setup creates it if missing, and its repositories 15. **The root organisation** (`IPCR_ROOT_ORG`): setup creates it if missing, and its repositories
are also published at the root of the forge's name. are also published at the root of the forge's name.
16. **A public, unauthenticated, read-only registry route** (`ipcr-<domain>` → `ipcr-public`), so this
forge's images can be pulled without IPCR.
## Why it is necessary ## Why it is necessary
@@ -132,6 +134,11 @@ already mounts read-only.
repositories may use them. A Gitea organisation makes "who may publish there" a matter of team repositories may use them. A Gitea organisation makes "who may publish there" a matter of team
membership, managed in Gitea. membership, managed in Gitea.
**16. The public front door.** The IPFS address only works on machines running IPCR. People trying
an image elsewhere (a laptop, a CI on another platform) need a registry that plain Docker can pull
from. Public IPFS gateways cannot do it, since they have no registry API. A login would defeat the
purpose: the images are public on IPFS anyway.
## Security mitigations in place ## Security mitigations in place
**The bridge** **The bridge**
@@ -182,6 +189,18 @@ membership, managed in Gitea.
and its secret at once. "Reissue" rotates a token. and its secret at once. "Reissue" rotates a token.
- **Gate container:** `$PUID`, no capabilities, read-only root filesystem, 64 MB. - **Gate container:** `$PUID`, no capabilities, read-only root filesystem, 64 MB.
**The public front door**
- **This forge's images only.** It answers only for the publisher's own IPNS name, its verified
ENS/DNS name, and the root CIDs of published images. Anything else is 404 before any resolution,
so strangers cannot use it to fetch or serve other IPFS content.
- **Read-only and storage-neutral.** GET and HEAD only, and `AUTO_PIN=false`: a pull never pins.
- **Isolated.**
- It is a separate process (`ipcr-public`) on `pcs` and `ipcr-internal` only, not on the admin
or CI networks.
- It runs as `$PUID` with no capabilities and a read-only root filesystem, capped at 256 MB.
- It reads the main gateway's state read-only.
- **Can be switched off** in the admin pages (`config.json` `"public": false`).
**The page** **The page**
- **The public page cannot change anything.** The bridge serves it read-only: the embedded page, - **The public page cannot change anything.** The bridge serves it read-only: the embedded page,
IPCR's `published.json` (mounted read-only) and the bridge's repository list. IPCR's `published.json` (mounted read-only) and the bridge's repository list.
+41 -4
View File
@@ -177,6 +177,32 @@ ipcr.localhost:4767/ipns/k51…/ipcr-hello:1.0.0
- **Setup:** creates the organisation (public, owned by `gitea_admin`) if it is missing, and puts - **Setup:** creates the organisation (public, owned by `gitea_admin`) if it is missing, and puts
the example repository in it. the example repository in it.
## Public front door
`docker pull ipcr-<domain>/ipns/metadec.eth/ipcr-hello:1.0.0` works on any machine with plain
Docker: no IPCR, no CA to trust, no IPFS node.
**Why a public IPFS gateway can't do this:** `docker pull ipfs.io/ipns/…` asks for
`/v2/ipns/…/manifests/…`, a registry API that file gateways don't have. Blobs are then asked for by
digest, which only something that walks the image's descriptors can turn into CIDs.
**How it works.** `ipcr-public` is the same ipcrd, a second process, behind Caddy.
- **Plain HTTP:** Caddy terminates TLS.
- **No pinning:** `AUTO_PIN=false`.
- **Off the admin and CI networks.**
- **`PUBLIC_POLICY` points it at the main gateway's state** (read-only). It then serves only:
- this forge's publisher name;
- its ENS/DNS name, while verified;
- the root CIDs of its published images.
Everything else is 404, so nobody can make the node fetch other IPFS content. Pushes get 405.
- **Off switch:** the admin page's "Public pulls" switch (`config.json` `"public"`), picked up within
10 s. Pages show the "Public" / "without IPCR" pull lines while it is on.
**It's a convenience, not the main way to pull.** It depends on this server; `ipcr.localhost:4767`
works from any IPCR node that has, or can find, the image. Docker still checks every layer by
digest, so the front door cannot swap content.
## Staging gate and push credentials ## Staging gate and push credentials
``` ```
@@ -349,12 +375,23 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
| a user named `ipcr-hello` | short path skipped, reason shown; back when the user is gone | | a user named `ipcr-hello` | short path skipped, reason shown; back when the user is gone |
| repository switched off | gate credential and Gitea secret removed; restored when switched on | | repository switched off | gate credential and Gitea secret removed; restored when switched on |
**Public front door, on holyhorse** (ipcr 1.5.0, bridge 0.4.0, 2026-10-05):
| Check | Result |
| --- | --- |
| from another machine (Docker Desktop, no IPCR): `docker pull ipcr-holyhorse.nsl.sh/ipns/metadec.eth/ipcr-hello:1.0.0` | pulled, ran, served the page |
| same by `k51…/metadec/ipcr-hello:latest` and by `/ipfs/<cid>` | pulled |
| pins on the server after those pulls | unchanged (16) |
| wisera's k51, `vitalik.eth`, an unpublished CID, `/v2/_catalog` | 404 |
| a `PUT` | 405 |
| switch off / on | pull "not found", page hides the line / pulls again within 10 s |
## Open items ## Open items
- **Pull from a second server.** Verified on holyhorse itself only; v2's images pulled across - **Pull from a second IPCR server** (`ipcr.localhost`): verified on holyhorse itself only, though
servers the same way. the public front door was pulled from another machine; v2's images pulled across IPCR servers.
- **Publish the images**: `ghcr.io/yundera/ipcr:1.4.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.3.0` - **Publish the images**: `ghcr.io/yundera/ipcr:1.5.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.4.0`
(workflows `image.yml` and `bridge.yml`, tags `v1.4.0` and `bridge-v0.3.0`). (workflows `image.yml` and `bridge.yml`, tags `v1.5.0` and `bridge-v0.4.0`).
- **CI isolation:** jobs on a privileged daemon can reach the host. Rootless DinD does not start - **CI isolation:** jobs on a privileged daemon can reach the host. Rootless DinD does not start
on this platform (AppArmor); a VM-based runner would be the real fix. on this platform (AppArmor); a VM-based runner would be the real fix.
- **Private repositories** cannot publish: the bridge only sees public ones. If wanted, give them - **Private repositories** cannot publish: the bridge only sees public ones. If wanted, give them
+30 -1
View File
@@ -126,6 +126,16 @@
<div class="copyrow"><code id="dns-value">dnslink=/ipns/k51…</code><button class="copy" data-copy="dns-value">Copy</button></div> <div class="copyrow"><code id="dns-value">dnslink=/ipns/k51…</code><button class="copy" data-copy="dns-value">Copy</button></div>
</details> </details>
</div> </div>
<div class="card wide">
<h3>Public pulls</h3>
<p class="muted">This forge's images, and only those, can also be pulled from any machine with plain Docker, without IPCR,
through <code id="public-host">ipcr-…</code>. Read-only; a pull there pins nothing. Layers are still checked by digest.</p>
<form class="row" id="public-form">
<label><input type="checkbox" id="public-on"> Serve public pulls</label>
<span id="public-state" class="muted"></span>
</form>
<div class="copyrow"><code id="public-try">docker pull …</code><button class="copy" data-copy="public-try">Copy</button></div>
</div>
</div> </div>
<h2>Health <button id="refresh">Check again</button> <span id="refresh-state" class="muted"></span></h2> <h2>Health <button id="refresh">Check again</button> <span id="refresh-state" class="muted"></span></h2>
@@ -216,6 +226,8 @@
"use strict"; "use strict";
var $ = function (id) { return document.getElementById(id); }; var $ = function (id) { return document.getElementById(id); };
var publisher = "", pubName = ""; var publisher = "", pubName = "";
// The public front door's host: same suffix as this page, ipcr- instead of ipcr-forge-.
var publicHost = location.hostname.indexOf("ipcr-forge-") === 0 ? "ipcr-" + location.hostname.slice("ipcr-forge-".length) : "";
function toast(msg, ms) { function toast(msg, ms) {
var t = $("toast"); t.textContent = msg; t.style.display = "block"; var t = $("toast"); t.textContent = msg; t.style.display = "block";
@@ -348,6 +360,22 @@
$("storage").appendChild(bar); $("storage").appendChild(bar);
}).catch(function (e) { st8.textContent = ""; toast("Status: " + e.message, 8000); }); }).catch(function (e) { st8.textContent = ""; toast("Status: " + e.message, 8000); });
} }
function loadPublic() {
$("public-host").textContent = publicHost || "ipcr-<domain>";
return api("GET", "ipcr/config").then(function (c) {
$("public-on").checked = !!c.public;
$("public-state").textContent = c.public ? "on" : "off";
$("public-try").textContent = "docker pull " + (publicHost || "ipcr-<domain>") + "/ipns/" + (pubName || publisher || "k51…") + "/<repo>:<tag>";
});
}
$("public-on").onchange = function () {
var cb = this, st = $("public-state");
cb.disabled = true; st.textContent = ""; st.appendChild(spinner("saving…"));
api("PATCH", "ipcr/config", { public: cb.checked })
.then(function (c) { toast(c.public ? "Public pulls on (within 10 s)" : "Public pulls off (within 10 s)"); return loadPublic(); },
function (e) { cb.checked = !cb.checked; toast(e.message, 8000); })
.then(function () { cb.disabled = false; loadImages(); });
};
$("refresh").onclick = function () { busy(this, loadStatus(true)); }; $("refresh").onclick = function () { busy(this, loadStatus(true)); };
$("republish").onclick = function () { $("republish").onclick = function () {
busy(this, api("POST", "ipcr/republish")).then(function () { toast("Published again"); loadStatus(true); }); busy(this, api("POST", "ipcr/republish")).then(function () { toast("Published again"); loadStatus(true); });
@@ -390,6 +418,7 @@
var path = (short || img.repo) + ":" + tg.tag, rows = [["IPFS", reg + "/ipfs/" + tg.cid]]; var path = (short || img.repo) + ":" + tg.tag, rows = [["IPFS", reg + "/ipfs/" + tg.cid]];
if (d.publisher) rows.push(["IPNS", reg + "/ipns/" + d.publisher + "/" + path]); if (d.publisher) rows.push(["IPNS", reg + "/ipns/" + d.publisher + "/" + path]);
if (d.name) rows.push([/\.eth$/.test(d.name) ? "ENS" : "DNS", reg + "/ipns/" + d.name + "/" + path]); if (d.name) rows.push([/\.eth$/.test(d.name) ? "ENS" : "DNS", reg + "/ipns/" + d.name + "/" + path]);
if (d.public && publicHost && (d.name || d.publisher)) rows.push(["Public", publicHost + "/ipns/" + (d.name || d.publisher) + "/" + path]);
if (short && (d.name || d.publisher)) rows.push(["Also", reg + "/ipns/" + (d.name || d.publisher) + "/" + img.repo + ":" + tg.tag]); if (short && (d.name || d.publisher)) rows.push(["Also", reg + "/ipns/" + (d.name || d.publisher) + "/" + img.repo + ":" + tg.tag]);
var box = el("div"); var box = el("div");
rows.forEach(function (r) { rows.forEach(function (r) {
@@ -567,7 +596,7 @@
}).catch(function (e) { toast(e.message, 8000); }); }).catch(function (e) { toast(e.message, 8000); });
}; };
loadStatus(false); loadImages(); loadRepos(); loadSettings(); loadStatus(false).then(loadPublic); loadImages(); loadRepos(); loadSettings();
})(); })();
</script> </script>
</body> </body>
+16
View File
@@ -206,6 +206,13 @@ image's content address and can never change.</p>
<p class="muted">A repository that turns private or is deleted in Gitea is no longer synced <p class="muted">A repository that turns private or is deleted in Gitea is no longer synced
(<i>frozen</i>). What was already published stays on the Radicle network: it cannot be taken back.</p> (<i>frozen</i>). What was already published stays on the Radicle network: it cannot be taken back.</p>
<h2 id="anywhere">Pull without IPCR</h2>
<p>To try an image on a machine without IPCR, pull it through this forge's public front door, with plain Docker
and nothing to install:</p>
<pre><code id="anywhere-cmd">docker pull ipcr-&lt;domain&gt;/ipns/&lt;name&gt;/&lt;repo&gt;:1.0.0</code></pre>
<p class="muted">It serves this forge's images only, read-only, and depends on this server being online. For
anything that should keep working without it, use IPCR's local address (below): any IPCR node can serve the image.</p>
<h2 id="install">Pull from another server</h2> <h2 id="install">Pull from another server</h2>
<p>Any server with IPCR resolves the same lines and fetches the image from the IPFS network, <p>Any server with IPCR resolves the same lines and fetches the image from the IPFS network,
from this server and from every other server that already pulled it.</p> from this server and from every other server that already pulled it.</p>
@@ -231,6 +238,7 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
<tr><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr> <tr><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr>
<tr><td><a id="t-radicle-api" href="#"></a></td><td>Radicle API and read-only git over HTTPS (public)</td></tr> <tr><td><a id="t-radicle-api" href="#"></a></td><td>Radicle API and read-only git over HTTPS (public)</td></tr>
<tr><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr> <tr><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr>
<tr><td><a id="t-ipcr" href="#"></a></td><td>Public front door: <code>docker pull</code> this forge's images from anywhere (read-only)</td></tr>
<tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr> <tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr>
</table></div> </table></div>
@@ -263,6 +271,8 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
setLink("t-forge", "ipcr-forge", true); setLink("t-forge", "ipcr-forge", true);
setLink("t-radicle", "radicle", true); setLink("t-radicle", "radicle", true);
setLink("t-radicle-api", "radicle-api", true); setLink("t-radicle-api", "radicle-api", true);
setLink("t-ipcr", "ipcr", true);
if (base) document.getElementById("anywhere-cmd").textContent = "docker pull ipcr-" + base + "/ipns/<name>/<repo>:1.0.0";
function el(tag, text, cls) { function el(tag, text, cls) {
var e = document.createElement(tag); var e = document.createElement(tag);
@@ -335,6 +345,12 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
pin.appendChild(document.createTextNode("this build only: ")); pin.appendChild(document.createTextNode("this build only: "));
pin.appendChild(el("code", reg + "/ipfs/" + info.cid)); pin.appendChild(el("code", reg + "/ipfs/" + info.cid));
td2.appendChild(pin); td2.appendChild(pin);
if (pub.public && base) {
var anywhere = el("div", null, "muted");
anywhere.appendChild(document.createTextNode("without IPCR: "));
anywhere.appendChild(el("code", "docker pull ipcr-" + base + "/ipns/" + path + ":" + tag));
td2.appendChild(anywhere);
}
var td3 = el("td"); td3.appendChild(copyButton(line)); var td3 = el("td"); td3.appendChild(copyButton(line));
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
tb.t.appendChild(tr); tb.t.appendChild(tr);