IPCR Forge admin: name, health, unpublish, allowlist, key backup

ipcr 1.3.0 (gateway/):
- admin API on ADMIN_LISTEN (Bearer token in STATE_DIR/admin-token):
  status (key, node, storage, IPNS record here and on the network via
  delegated routing, ENS/DNSLink name check), providers/announce, peers,
  images, unpublish (tombstone + unpin unused objects), retag, reimport,
  config (name, allowlist), republish, key import (restore with
  sequence = max(network, local) + 1; previous key kept, kept copies reused)
- watcher refactored into shared state; config.json with an allowlist
  (IMPORT_ALLOW_REQUIRED); staging cleanup after publish (IMPORT_CLEANUP)
- IPNS records published with IPNS_LIFETIME / IPNS_TTL; superseded trees
  unpinned
- encrypted publisher-key backups (PBKDF2 + AES-256-GCM), ipcrd key decrypt

bridge 0.2.0 (bridge/):
- /admin: Gitea OAuth2 login (PKCE, site admins only), signed session,
  same-origin + X-IPCR-Admin on writes; setup registers the OAuth2 app
- admin page: publisher name and ENS/DNS check, health, published images,
  per-repository publish toggles (pushed to IPCR as its allowlist), key
  export (from Kubo's keystore, encrypted) and restore
- public page pull lines use the verified name

Compose: ipcr-admin internal network, keystore mount, 7-day IPNS lifetime,
staging GC at registry start. Verified on holyhorse (docs/forge.md).
This commit is contained in:
Worph
2026-10-04 20:33:16 +00:00
parent 5227a77477
commit b65bb867c9
14 changed files with 1641 additions and 34 deletions
+70 -16
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.1.0
image: ghcr.io/yundera/ipcr-forge-bridge:0.2.0
container_name: ipcr-forge
user: $PUID:$PGID
read_only: true
@@ -57,6 +57,14 @@ services:
RAD_HOME: /radicle-home
IPCR_PUBLISHED: /srv/ipcr-state/published.json
RECONCILE: 10m
# Admin pages (/admin): Gitea site admins only, signed in through Gitea (an OAuth2 app the
# forge-setup step registers, one redirect URI per host below). They manage IPCR through its
# admin API (on ipcr-admin) and back up the publisher key, read from Kubo's keystore.
PUBLIC_HOSTS: ipcr-forge-${APP_DOMAIN},ipcr-forge-${APP_PUBLIC_IP_DASH}.nip.io,ipcr-forge-${APP_PUBLIC_IP_DASH}.sslip.io
IPCR_ADMIN: http://ipcr-gateway:4769
IPCR_ADMIN_TOKEN_FILE: /srv/ipcr-state/admin-token
KUBO_KEYSTORE: /srv/ipcr-keystore
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
tmpfs:
# HOME for git and rad.
- /tmp
@@ -69,8 +77,11 @@ services:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge
# The Gitea token and the webhook secret the install step writes. Read-only here.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro
# published.json → /images.json. Read-only.
# published.json → /images.json, and the admin API's token. Read-only.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro
# Kubo's keystore, read-only: the publisher key, for encrypted backups (Kubo exports keys
# from its CLI only, and ipcrd, root without capabilities, cannot read a $PUID 0700 folder).
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/kubo/keystore:/srv/ipcr-keystore:ro
depends_on:
- gitea
- radicle-node
@@ -94,6 +105,8 @@ services:
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# To IPCR's admin API only.
- ipcr-admin
# ═══ Gitea — the source of truth ═══════════════════════════════════════════════
# From Apps/Gitea; its data folder moves to $AppID/gitea/.
@@ -271,6 +284,9 @@ services:
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
network_mode: service:forge-docker
# 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"]
cpu_shares: 20
deploy:
resources:
@@ -279,7 +295,8 @@ services:
environment:
TZ: $TZ
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
# Staging only: IPCR has its own copy once imported, so old tags may be deleted.
# 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:
@@ -421,7 +438,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.2.0
image: ghcr.io/yundera/ipcr:1.3.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.
@@ -450,6 +467,18 @@ services:
# /ipns/<forge key>/<repo>:<tag>. One name for the whole forge, which a DNSLink domain or an
# ENS name can point at: ipcr.localhost:4767/ipns/example.eth/<repo>:<tag>.
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
# Forge: publish only the repositories the bridge allows (public Gitea repositories, minus
# those switched off in the admin pages). Until the bridge sends its list, nothing is.
IMPORT_ALLOW_REQUIRED: "true"
# Forge: delete each tag from the staging registry once published (its blobs go at the
# registry's next start, see forge-registry).
IMPORT_CLEANUP: "true"
# IPNS records valid 7 days instead of Kubo's 48 h, so a forge offline for a weekend keeps
# its name; resolvers cache them 5 minutes, so a new tag shows up quickly.
IPNS_LIFETIME: ${IPCR_IPNS_LIFETIME:-168h}
IPNS_TTL: 5m
# Forge: the admin API, for the bridge, on ipcr-admin. Bearer token in state/admin-token.
ADMIN_LISTEN: ":4769"
IMPORT_INTERVAL: 60s
STATE_DIR: /data/state
volumes:
@@ -477,6 +506,8 @@ services:
- ipcr-internal
# Forge only: reaches the staging registry for the import poller above.
- ci-internal
# Forge only: the admin API, reached by the bridge.
- ipcr-admin
cpu_shares: 50
deploy:
resources:
@@ -486,7 +517,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.2.0
image: ghcr.io/yundera/ipcr:1.3.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
@@ -573,6 +604,10 @@ networks:
# From Apps/IPCR: gateway ↔ registry ↔ kubo, nothing else.
ipcr-internal:
driver: bridge
# Bridge → IPCR's admin API, nothing else; no egress.
ipcr-admin:
driver: bridge
internal: true
x-casaos:
architectures:
@@ -636,6 +671,12 @@ x-casaos:
**Start here: [the forge's page](https://ipcr-forge-$APP_DOMAIN)**: how to publish, the
published images, the Radicle mirrors.
**Admin: [ipcr-forge-$APP_DOMAIN/admin](https://ipcr-forge-$APP_DOMAIN/admin)**, signed in
with Gitea (site administrators only): your ENS or DNS name, health of the published name,
unpublishing images, which repositories may publish, and the **publisher key backup**.
Download one after the first release: the key exists only on this server, and losing it
loses every image name.
**Replaces three apps.** Uninstall Gitea, Radicle and IPCR first: this app uses the same
addresses and ports.
@@ -654,14 +695,20 @@ x-casaos:
private later stops the copy, and what was published stays on the network. Private
repositories are never copied.
**Who can publish images.** Anyone who can push to a repository here: a build can push any
image name to the staging registry, and IPCR publishes what it finds there under this
forge's name. Images are public once on IPFS, even from a private repository. Read a
fork's workflow before approving its run.
**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.
fr_fr: |
**Commencez ici : [la page de la forge](https://ipcr-forge-$APP_DOMAIN)** : publier, les
images publiées, les copies Radicle.
**Administration : [ipcr-forge-$APP_DOMAIN/admin](https://ipcr-forge-$APP_DOMAIN/admin)**,
connexion via Gitea (administrateurs du site uniquement) : votre nom ENS ou DNS, l'état du
nom publié, le retrait d'images, les dépôts autorisés à publier, et la **sauvegarde de la
clé de publication**. Téléchargez-en une après la première version : la clé n'existe que
sur ce serveur, et la perdre fait perdre tous les noms d'images.
**Remplace trois applications.** Désinstallez d'abord Gitea, Radicle et IPCR : cette
application utilise les mêmes adresses et ports.
@@ -680,10 +727,11 @@ 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.** Toute personne qui peut pousser sur un dépôt d'ici : un
build peut pousser n'importe quel nom d'image vers le registre de transit, et IPCR publie ce
qu'il y trouve sous le nom de cette forge. Une image est publique une fois sur IPFS, même
si elle vient d'un dépôt privé. Lisez le workflow d'un fork avant d'approuver son exécution.
**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.
index: /
webui_port: 3000
store_app_id: ipcr-forge
@@ -739,6 +787,12 @@ x-compose-app:
user: $PUID
group: $PGID
mode: "0700"
# Created ahead of Kubo's first start: the bridge mounts it, and Docker would otherwise create
# it as root, where Kubo cannot write its keys.
- path: /DATA/AppData/$AppID/ipcr/kubo/keystore
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/ipcr/init.d
user: $PUID
group: $PGID
@@ -806,10 +860,10 @@ x-compose-app:
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, and the example repository. Idempotent, so on every start: it repairs the webhook.
# webhook, the admin pages' OAuth2 app, and the example repository. 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.1.0
image: ghcr.io/yundera/ipcr-forge-bridge:0.2.0
user: $PUID:$PGID
phase: post_up
network: $APP_NET
@@ -818,7 +872,7 @@ x-compose-app:
command:
- sh
- -c
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 ipcr-forge-bridge setup
- 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
when: always
webui-host: gitea-${domain}
webui-path: /
+54 -6
View File
@@ -35,6 +35,11 @@ with. See docs/forge.md.
7. **Public repositories are published to Radicle automatically**, and that cannot be undone.
8. **It replaces three apps** and cannot be installed beside them: it uses the same container names,
domains and host ports.
9. **Admin pages with their own login** (`/admin`): Gitea OAuth2, Gitea site admins only. The
install step registers the OAuth2 app.
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.
## Why it is necessary
@@ -82,6 +87,22 @@ be read by anyone, and Radicle is one more place to read it.
every container, domain and port so they could coexist would break the defaults the services rely
on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
**9. Admin pages.** The operator needs to manage the IPCR side: the ENS or DNS name; whether the
name and the images resolve from outside; unpublishing; which repositories may publish; and, most
of all, backing up the publisher key, which is the only copy of every image name. Gitea is the
forge's identity system, so the admin pages sign in with it rather than adding accounts or relying
on the PCS gate, which is not on every host.
**10. The keystore mount.** Kubo exports keys from its CLI only (`key/export` is not available
over its HTTP API). ipcrd runs as root without capabilities, so it cannot read a `$PUID` 0700
folder; giving it `DAC_READ_SEARCH` was rejected once already. The bridge runs as `$PUID` and can.
**11. The admin API and its token.** Unpublishing, moving tags, key restore and the allowlist
change ipcrd's own state (the published tree, `published.json`), so they live in ipcrd. The token is
needed because CI jobs on `ci-internal` can reach `ipcr-gateway`. The file is world-readable
because ipcrd, root without `CAP_CHOWN`, cannot hand a file to `$PUID` any other way. It sits in
the app's own state folder.
## Security mitigations in place
**The bridge**
@@ -117,8 +138,26 @@ on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
public on IPFS.
**The page**
- **It cannot change anything.** It is served by the bridge's own read-only HTTP handler: the
embedded page, IPCR's `published.json` (mounted read-only) and the bridge's repository list.
- **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.
**The admin pages**
- **Only Gitea site admins get in.** The check is `is_admin`, read from Gitea's own API through the
internal address. The Gitea token is dropped right after.
- **The session cookie:**
- HMAC-signed, `HttpOnly`, `Secure`, valid 8 h;
- the login uses PKCE and `state`, and is only accepted on the hosts in `PUBLIC_HOSTS`.
- **Every write must be same-origin.** The `Origin` header (or Fetch Metadata) must match, and the
custom `X-IPCR-Admin` header is required. A cross-site form cannot send that header, and a
cross-site script cannot without a CORS preflight, which is never answered.
- **The admin API is only on `ipcr-admin`.** That network is `internal: true` and shared only by
the bridge and `ipcr-gateway`; every call needs the Bearer token, compared in constant time. Kubo's
own API stays on `ipcr-internal`.
- **The cleartext key never crosses the network.** The bridge encrypts it in memory (AES-256-GCM,
PBKDF2 at 600k iterations, at least 12 characters of passphrase) and ipcrd decrypts it. A
restore never deletes a key: the previous one is kept, renamed.
- **The allowlist is closed by default.** Until the bridge sends its list, nothing is published
(`IMPORT_ALLOW_REQUIRED`). Private repositories are never on it.
## Alternatives considered and rejected
@@ -141,8 +180,17 @@ on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
namespace only.
- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only
delegate.
- **Image names are not tied to repositories.** A build may push any name to the staging registry.
Per-repository credentials, issued by the bridge, are a planned follow-up.
- **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 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.
- **Old keys accumulate.** `<key>-replaced-*` keys are kept and republished by Kubo until removed by
hand (`docker exec ipcr-kubo ipfs key rm <name>`).
- **Re-importing a tag needs it in staging,** and staging is cleaned once a tag is published. A new
CI run brings it back.
## Data protection
@@ -152,9 +200,9 @@ 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 and one bare mirror per repository (disposable); `secrets/` (0700): its Gitea token and the webhook secret |
| `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) |
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state |
| `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
IPCR's `/etc/docker/certs.d/ipcr.localhost:4767/ca.crt`, as in the standalone app. Identities that
@@ -17,3 +17,8 @@ ipfs config --json Addresses.Swarm "[
# Soft cap on the block store. Pinned images are never collected; with --enable-gc everything
# else (blocks fetched but not pinned) is swept every Datastore.GCPeriod.
ipfs config Datastore.StorageMax "${IPFS_STORAGE_MAX:-20GB}"
# How long an IPNS record stays valid when Kubo republishes it (every 4 h, while online). Kubo's
# default is 48 h: a node offline for a weekend would lose every name it publishes. ipcrd publishes
# with the same lifetime (IPNS_LIFETIME).
ipfs config Ipns.RecordLifetime "${IPCR_IPNS_LIFETIME:-168h}"