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: /