diff --git a/Apps/IPCR-Forge/docker-compose.yml b/Apps/IPCR-Forge/docker-compose.yml index 4cd6a49..7df0a94 100644 --- a/Apps/IPCR-Forge/docker-compose.yml +++ b/Apps/IPCR-Forge/docker-compose.yml @@ -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/ through to the stock nerdctl # registry below, resolves /ipns/: 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//:. One name for the whole forge, which a DNSLink domain or an # ENS name can point at: ipcr.localhost:4767/ipns/example.eth/:. 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): `/`. 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) : `/`. 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: / diff --git a/Apps/IPCR-Forge/rationale.md b/Apps/IPCR-Forge/rationale.md index f29934a..525a68a 100644 --- a/Apps/IPCR-Forge/rationale.md +++ b/Apps/IPCR-Forge/rationale.md @@ -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.** `-replaced-*` keys are kept and republished by Kubo until removed by + hand (`docker exec ipcr-kubo ipfs key rm `). +- **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 diff --git a/Apps/IPCR-Forge/seed/ipcr/init.d/01-ipcr-node.sh b/Apps/IPCR-Forge/seed/ipcr/init.d/01-ipcr-node.sh index 12fc284..629c75c 100644 --- a/Apps/IPCR-Forge/seed/ipcr/init.d/01-ipcr-node.sh +++ b/Apps/IPCR-Forge/seed/ipcr/init.d/01-ipcr-node.sh @@ -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}" diff --git a/docs/forge.md b/docs/forge.md index 11825d6..feabad1 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -96,14 +96,55 @@ code checks visibility again anyway. - **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a distribution channel here, not the trust root. The `/rad//…` image-naming idea in [naming.md](naming.md) is weaker for it. -- **Anyone who can push to a repository can publish images.** A job can push any name to the - unauthenticated `localhost:5000`, and IPCR publishes whatever it finds there under the forge's IPNS - key, including images built from private repositories. Registration is off, so every user was - created by the admin. +- **Only allowed names are published.** IPCR publishes an image name only if it is on the allowlist + the bridge keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus + those switched off in the admin pages. Images built from private repositories are therefore not + published. +- **What the allowlist does not cover:** a job can still push any name to the unauthenticated + `localhost:5000`. So anyone who can push code here can publish under an allowed name. + Registration is off, so every user was created by the admin. - **Fork pull requests** from users without write access wait for "Approve and run" in Gitea 1.27. - The page, the tips and `rationale.md` say all of this plainly. - **Follow-up:** per-repository push credentials, with the bridge acting as a registry token - server, would tie an image name to its repository. + server, would tie each image name to the one repository allowed to build it. + +## Admin + +`https://ipcr-forge-/admin`. It is served by the bridge and only lets in Gitea site +administrators. + +**Sign-in.** It uses an OAuth2 app that the `setup` step registers, with one redirect URI per +host. The flow is the authorization code with PKCE and the `read:user` scope. The bridge reads +`is_admin` from `/api/v1/user`, drops the Gitea token, and sets its own HMAC-signed session (8 h; +the key lives in `bridge/state`). Writes require the `X-IPCR-Admin` header and a same-origin +request. + +**Behind the page.** The page calls the bridge, and the bridge calls ipcrd's admin API +(`gateway/admin.go`). That API listens on `:4769`, on the `ipcr-admin` network (internal, bridge +and gateway only), and needs a Bearer token from `ipcr/state/admin-token`. + +| Section | What it does | +| --- | --- | +| Publisher name | The key and its `k51…`. You enter your ENS or DNSLink name; it is checked by resolving it through Kubo. While it points here, pull lines (page and admin) use it. The check runs again every 30 min. | +| Health | Shows the IPNS record held by this node and the one the network serves (delegated routing): sequence, expiry, whether it is the current tree. "Republish" refreshes it. Also: peers, the addresses the network sees, and storage against `StorageMax`. | +| Published images | For each tag: "Findable?" (is this node a listed provider), "Announce", "Make latest", "Unpublish". Unpublish removes the tag from the tree, unpins the objects no other image uses, and leaves a tombstone so the tag is not re-imported. Staging tags that are not allowed, or not imported yet, offer "Import now". | +| Which repositories may publish | One toggle per public repository. These toggles make up IPCR's allowlist. | +| Key backup | "Download" encrypts the publisher key with a passphrase. "Restore" makes a backup the publisher key: the current key is kept under `-replaced-