IPCR Forge: org-root image names and per-repository push credentials

Short names: the public repositories of one Gitea organisation (IPCR_ROOT_ORG,
then the admin page; metadec on our boxes) are also published at the root of
the forge's name, so ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0
works. Collisions (a user/org of that name, another owner's folder) are
skipped and explained.

Push credentials: the staging registry now sits behind forge-gate
(ipcr-forge-bridge gate), in the CI daemon's network namespace, with the
registry on a unix socket and no network. Each publishing repository logs in
as <owner>/<repo> with an IPCR_PUSH_TOKEN Actions secret the bridge sets, and
may push its own name only; IPCR logs in with a credential it generates.
Workflows need a docker/login-action step (example and page updated).

ipcr 1.4.0: allow.aliases with per-pass alias sync; unpublish/retag on every
path; IMPORT_AUTH_GENERATE; names this node publishes resolve via
name/resolve --nocache (Kubo's resolve cache lagged, and the generic resolve
ignores nocache).

bridge 0.3.0: gate, push credentials (rotate in the admin page), root
organisation setting, setup creates the org and the example in it; the
Radicle description carries the Gitea path (identical documents gave the same
RID); OAuth2 app updated only when hosts change, keeping the new secret.
Admin page: tabs, one-line keys, name-check feedback, per-tag docker pull
commands (IPFS, IPNS, ENS, short path first).

Verified on holyhorse (docs/forge.md).
This commit is contained in:
Worph
2026-10-05 11:37:33 +00:00
parent b65bb867c9
commit c961d74e0e
15 changed files with 1275 additions and 189 deletions
+81 -25
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.
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
ipcr-forge:
image: ghcr.io/yundera/ipcr-forge-bridge:0.2.0
image: ghcr.io/yundera/ipcr-forge-bridge:0.3.0
container_name: ipcr-forge
user: $PUID:$PGID
read_only: true
@@ -65,6 +65,9 @@ services:
IPCR_ADMIN_TOKEN_FILE: /srv/ipcr-state/admin-token
KUBO_KEYSTORE: /srv/ipcr-keystore
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
# The organisation published at the root of the forge's name (metadec/app → …/app), until
# changed in the admin pages. Its members, through Gitea teams, are who can publish there.
IPCR_ROOT_ORG: ${IPCR_ROOT_ORG:-}
tmpfs:
# HOME for git and rad.
- /tmp
@@ -271,11 +274,43 @@ services:
networks:
- ci-internal
# The staging registry, in the daemon's network namespace: `localhost:5000` to the daemon and to
# every job (act runs jobs on the host network, which is this namespace), so a plain
# `docker push` or buildx with `network=host` works without TLS or credentials — Docker allows
# plain HTTP to localhost only. ipcr-gateway reaches it as forge-docker:5000. Nothing
# outside ci-internal can.
# The staging registry's front door: `localhost:5000` to the daemon and to every job (act runs
# jobs on the host network, which is this namespace), forge-docker:5000 to ipcr-gateway. Plain
# HTTP, which Docker allows to localhost only. It checks a login on every request: a job logs in
# as its repository (<owner>/<repo>, the IPCR_PUSH_TOKEN secret the bridge sets on it) and may
# 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.
forge-gate:
image: ghcr.io/yundera/ipcr-forge-bridge:0.3.0
container_name: forge-gate
user: $PUID:$PGID
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
network_mode: service:forge-docker
command: ["ipcr-forge-bridge", "gate"]
cpu_shares: 20
deploy:
resources:
limits:
memory: 64M
environment:
TZ: $TZ
GATE_LISTEN: ":5000"
GATE_UPSTREAM: unix:///run/staging/registry.sock
GATE_CREDENTIALS: /gate/credentials.json
volumes:
# Hashes only, written by the bridge.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state/gate:/gate:ro
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
depends_on:
- forge-docker
- forge-registry
# The staging registry itself: no network at all. It listens on a unix socket in a folder only the
# gate also mounts, so nothing reaches it but through the gate: not a job, which runs in the
# daemon above and sees neither the folder nor a port.
forge-registry:
image: registry:3.0.0
container_name: forge-registry
@@ -283,7 +318,7 @@ services:
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
network_mode: service:forge-docker
network_mode: none
# Garbage collection frees the blobs of deleted tags, and must not run while the registry
# serves: so it runs at every start, before serving.
entrypoint: ["sh", "-c", "registry garbage-collect --delete-untagged /etc/distribution/config.yml; exec registry serve /etc/distribution/config.yml"]
@@ -294,15 +329,17 @@ services:
memory: 128M
environment:
TZ: $TZ
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
REGISTRY_HTTP_NET: unix
REGISTRY_HTTP_ADDR: /run/staging/registry.sock
# Upload URLs relative to the request, whatever Host the gate passes on.
REGISTRY_HTTP_RELATIVEURLS: "true"
# Staging only: IPCR has its own copy once imported, and deletes each tag it has published
# (IMPORT_CLEANUP).
REGISTRY_STORAGE_DELETE_ENABLED: "true"
OTEL_TRACES_EXPORTER: none
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry:/var/lib/registry
depends_on:
- forge-docker
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
# ═══ Radicle — the public mirror ═══════════════════════════════════════════════
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The bridge
@@ -438,7 +475,7 @@ services:
# 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.
ipcr-gateway:
image: ghcr.io/yundera/ipcr:1.3.0
image: ghcr.io/yundera/ipcr:1.4.0
container_name: ipcr-gateway
# 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.
@@ -462,6 +499,10 @@ services:
# Forge: watch the CI's staging registry, over ci-internal. Internal, so no credentials.
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000}
IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-}
# Forge: IPCR's login at the staging registry's gate, generated on first start; the bridge
# reads it (read-only ipcr/state) and gives it the importer's rights at the gate.
IMPORT_AUTH_FILE: /data/state/staging-auth
IMPORT_AUTH_GENERATE: "true"
IMPORT_REPOS: ${IPCR_IMPORT_REPOS:-}
# Forge: every repository this node builds is published under one IPNS key, "forge", as
# /ipns/<forge key>/<repo>:<tag>. One name for the whole forge, which a DNSLink domain or an
@@ -517,7 +558,7 @@ services:
# ── Stock nerdctl IPFS registry ─────────────────────────────────────────────
# `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads.
ipcr-registry:
image: ghcr.io/yundera/ipcr:1.3.0
image: ghcr.io/yundera/ipcr:1.4.0
container_name: ipcr-registry
# 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
@@ -695,10 +736,13 @@ x-casaos:
private later stops the copy, and what was published stays on the network. Private
repositories are never copied.
**Who can publish images.** Only the names of public repositories that are switched on in the
admin pages (all are, by default): `<owner>/<repo>`. A build in any repository can still
push any name to the staging registry, so whoever can push code here can publish under an
allowed name. Read a fork's workflow before approving its run.
**Who can publish images.** Each public repository (switched on in the admin pages: all are,
by default) publishes under its own name, `<owner>/<repo>`: its workflow logs in to the
staging registry with the `IPCR_PUSH_TOKEN` secret the forge sets on it. The repositories of
the root organisation (`IPCR_ROOT_ORG`, then the admin pages) are also published at the root,
`<repo>`: its members decide who publishes there. Workflows from forks get no secrets.
CI runs on a privileged Docker daemon: only give write access to people you trust with the
server.
fr_fr: |
**Commencez ici : [la page de la forge](https://ipcr-forge-$APP_DOMAIN)** : publier, les
images publiées, les copies Radicle.
@@ -727,11 +771,13 @@ x-casaos:
privé ensuite arrête la copie, et ce qui a été publié reste sur le réseau. Les dépôts privés
ne sont jamais copiés.
**Qui peut publier des images.** Seuls les noms des dépôts publics activés dans
l'administration (tous, par défaut) : `<propriétaire>/<dépôt>`. Un build de n'importe quel
dépôt peut tout de même pousser n'importe quel nom vers le registre de transit : qui peut
pousser du code ici peut publier sous un nom autorisé. Lisez le workflow d'un fork avant
d'approuver son exécution.
**Qui peut publier des images.** Chaque dépôt public (activé dans l'administration : tous,
par défaut) publie sous son propre nom, `<propriétaire>/<dépôt>` : son workflow se connecte
au registre de transit avec le secret `IPCR_PUSH_TOKEN` que la forge y dépose. Les dépôts de
l'organisation racine (`IPCR_ROOT_ORG`, puis l'administration) sont aussi publiés à la
racine, `<dépôt>` : ses membres décident qui y publie. Les workflows des forks n'ont pas de
secrets. La CI tourne sur un démon Docker privilégié : ne donnez l'accès en écriture qu'à des
personnes à qui vous confiez le serveur.
index: /
webui_port: 3000
store_app_id: ipcr-forge
@@ -747,6 +793,10 @@ x-compose-app:
user: $PUID
group: $PGID
mode: "0700"
# The gate's credentials (hashes), written by the bridge, read by the gate.
- path: /DATA/AppData/$AppID/bridge/state/gate
user: $PUID
group: $PGID
# ── Gitea ──
- path: /DATA/AppData/$AppID/gitea/data
user: $PUID
@@ -782,6 +832,11 @@ x-compose-app:
user: $PUID
group: $PGID
mode: "0700"
# The staging registry's socket: the registry and the gate only.
- path: /DATA/AppData/$AppID/ci/registry-socket
user: $PUID
group: $PGID
mode: "0700"
# ── IPCR ──
- path: /DATA/AppData/$AppID/ipcr/kubo
user: $PUID
@@ -859,11 +914,12 @@ x-compose-app:
mv /runner/token.new /runner/token
chmod 600 /runner/token
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
# The bridge's Gitea token (public-only, read:repository), the webhook secret, the system
# webhook, the admin pages' OAuth2 app, and the example repository. Idempotent, so on every start: it repairs the webhook.
# The bridge's Gitea tokens (public-only read; and write:repository for push secrets), the
# webhook secret, the system webhook, the admin pages' OAuth2 app, the root organisation and
# the example repository (in that organisation). Idempotent, so on every start: it repairs the webhook.
# The admin password is used here only, never stored.
- name: forge-setup
image: ghcr.io/yundera/ipcr-forge-bridge:0.2.0
image: ghcr.io/yundera/ipcr-forge-bridge:0.3.0
user: $PUID:$PGID
phase: post_up
network: $APP_NET
@@ -872,7 +928,7 @@ x-compose-app:
command:
- sh
- -c
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 PUBLIC_HOSTS="ipcr-forge-$APP_DOMAIN,ipcr-forge-$APP_PUBLIC_IP_DASH.nip.io,ipcr-forge-$APP_PUBLIC_IP_DASH.sslip.io" ipcr-forge-bridge setup
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 IPCR_ROOT_ORG="$IPCR_ROOT_ORG" PUBLIC_HOSTS="ipcr-forge-$APP_DOMAIN,ipcr-forge-$APP_PUBLIC_IP_DASH.nip.io,ipcr-forge-$APP_PUBLIC_IP_DASH.sslip.io" ipcr-forge-bridge setup
when: always
webui-host: gitea-${domain}
webui-path: /
+55 -6
View File
@@ -40,6 +40,14 @@ with. See docs/forge.md.
10. **The bridge mounts Kubo's keystore read-only**, so it can export the publisher key, encrypted.
11. **An admin API in `ipcr-gateway`** (`:4769`) on a new internal network, `ipcr-admin`. Its Bearer
token is in `ipcr/state/admin-token`, mode 0644.
12. **A staging gate** (`forge-gate`), running in the CI daemon's network namespace. The registry
behind it has no network and listens on a unix socket.
13. **A second Gitea token for the bridge,** with `write:repository` and `read:user`. It sets each
publishing repository's `IPCR_PUSH_TOKEN` Actions secret and checks name collisions.
14. **A credential IPCR generates for itself** (`ipcr/state/staging-auth`, mode 0644), read by the
bridge.
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.
## Why it is necessary
@@ -103,6 +111,27 @@ needed because CI jobs on `ci-internal` can reach `ipcr-gateway`. The file is wo
because ipcrd, root without `CAP_CHOWN`, cannot hand a file to `$PUID` any other way. It sits in
the app's own state folder.
**12. The gate.** Before it, any CI job could push any image name to the staging registry, and IPCR
published what it found. With a short organisation address (`metadec.eth/app`), that would let
anyone with write access to any repository publish under the organisation's name. The gate makes
each repository's credential good for its own name only. It is a proxy rather than a registry
token server: Docker, BuildKit and ipcrd all answer a Basic challenge, so no JWT signing or second
realm is needed. The registry moves to a unix socket so the gate cannot be bypassed: the jobs'
daemon sees neither the socket's folder nor a port.
**13. The secrets token.** Gitea Actions secrets are the one credential Gitea gives a repository's
own workflows and withholds from forks. The job's automatic `GITEA_TOKEN` cannot prove which
repository it belongs to. Setting a secret takes `write:repository`. The token is not
`public-only`, so collision checks also see private users and organisations.
**14. IPCR's own credential.** Same handoff as the admin token: ipcrd (root without capabilities)
cannot write into another uid's folder, so it writes into its own state folder, which the bridge
already mounts read-only.
**15. The root organisation.** Short names (`metadec.eth/ipcr-hello`) need one owner whose
repositories may use them. A Gitea organisation makes "who may publish there" a matter of team
membership, managed in Gitea.
## Security mitigations in place
**The bridge**
@@ -137,6 +166,22 @@ the app's own state folder.
being able to publish images under the forge's name. Images from private repositories become
public on IPFS.
**The staging gate**
- **Least privilege per credential.**
- A repository's credential reads and writes only `/v2/<owner>/<repo>/{blobs,manifests,tags}/`:
no catalog, no delete, no other name.
- Cross-repository blob mounts are stripped.
- The importer can read, list and delete, but not push.
- Paths with `..`, `//` or encoded segments are refused.
- **The gate holds no secrets.** It stores SHA-256 hashes of random 32-byte tokens and compares
them in constant time. The tokens themselves exist only in Gitea's secret store and in the jobs
that use them.
- **The registry cannot be reached directly.** It runs with `network_mode: none`, and its socket
folder (0700, `$PUID`) is mounted only into the gate.
- **Credentials follow the list.** A repository switched off or frozen loses its gate credential
and its secret at once. "Reissue" rotates a token.
- **Gate container:** `$PUID`, no capabilities, read-only root filesystem, 64 MB.
**The 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.
@@ -180,10 +225,14 @@ the app's own state folder.
namespace only.
- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only
delegate.
- **Image names are limited, but their source is not proven.** Only allowed names (public
repositories that are switched on) are published. A build in any repository can still push an
allowed name to the staging registry. Per-repository credentials, issued by the bridge, are a
planned follow-up.
- **The gate is not a sandbox.** Jobs run on a privileged Docker-in-Docker daemon (deviation 1).
Anyone who can run a workflow can break out of it to the host, and from there do anything,
including publish under any name. The gate removes the ordinary path, a plain `docker push` of
someone else's name. It cannot contain a hostile CI user. Real isolation needs a VM-based runner.
- **Repository writers can read the push token,** since a workflow can print it. Reissue it when a
repository changes hands.
- **Existing workflows must add a login step,** or their pushes fail with 401.
- **Private repositories cannot publish:** they get no push credential.
- **The bridge can read the publisher key.** A compromise of the public-facing bridge leaks it. That
is the trust it already has with the Radicle node key.
- **The admin token is world-readable** inside the app's state folder on the host.
@@ -200,8 +249,8 @@ Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
| --- | --- |
| `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config |
| `radicle/` | node identity (key pair), seeded repositories, Caddy config |
| `bridge/` | `state/`: the Gitea → Radicle mapping, one bare mirror per repository (disposable), the admin session key and the repository toggles (`admin.json`); `secrets/` (0700): its Gitea token, the webhook secret, the OAuth2 client credentials |
| `ci/` | the daemon's socket, its image store (`docker/`), the staging registry (`registry/`, disposable) |
| `bridge/` | `state/`: the Gitea → Radicle mapping, one bare mirror per repository (disposable), the admin session key and the repository toggles (`admin.json`); `gate/credentials.json` (token hashes); `secrets/` (0700): its two Gitea tokens, the webhook secret, the OAuth2 client credentials |
| `ci/` | the daemon's socket, its image store (`docker/`), the staging registry (`registry/`, disposable) and its socket (`registry-socket/`) |
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `config.json` (name, allowlist), the admin token |
No user directory (`/DATA/Documents`, `Media`, …) is mounted. The one file outside `/DATA` is