diff --git a/Apps/IPCR-Forge/docker-compose.yml b/Apps/IPCR-Forge/docker-compose.yml index 7df0a94..af28002 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.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 (/, 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/ through to the stock nerdctl # registry below, resolves /ipns/: 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//:. 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): `/`. 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, `/`: 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, + ``: 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) : `/`. 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, `/` : 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, `` : 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: / diff --git a/Apps/IPCR-Forge/rationale.md b/Apps/IPCR-Forge/rationale.md index 525a68a..e07c2f9 100644 --- a/Apps/IPCR-Forge/rationale.md +++ b/Apps/IPCR-Forge/rationale.md @@ -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///{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 diff --git a/docs/forge.md b/docs/forge.md index feabad1..921d2f9 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -100,13 +100,20 @@ code checks visibility again anyway. 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. +- **Each repository publishes its own name only.** The staging registry sits behind a gate (see + [Staging gate](#staging-gate-and-push-credentials)). A job logs in with its repository's + `IPCR_PUSH_TOKEN`, and may push `/` and nothing else. Gitea does not give secrets to + workflows from forks, so a fork's pull request cannot publish. +- **The root organisation's short names belong to its members.** Gitea teams decide who can push + to `metadec/*`, so who can publish `metadec.eth/`. +- **Not a sandbox.** Jobs run on a privileged Docker-in-Docker daemon. Anyone who can run a + workflow here can break out of it to the host, and from there to everything, including the + gate. The gate stops the ordinary way to publish someone else's name; it does not contain a + hostile CI user. 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 each image name to the one repository allowed to build it. +- **Who can read a push token:** anyone with write access to its repository (a workflow can print + it). Reissue it in the admin pages when a repository changes hands. ## Admin @@ -146,13 +153,68 @@ Then publish with a sequence number above the network's. The bridge seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the network. +## Root organisation (short names) + +The public repositories of one Gitea organisation are also published at the root of the forge's +name. The organisation is `metadec` on our boxes (`IPCR_ROOT_ORG`, then the admin page's +Repositories tab). So `metadec/ipcr-hello` pulls as any of: +``` +ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0 +ipcr.localhost:4767/ipns/metadec.eth/metadec/ipcr-hello:1.0.0 +ipcr.localhost:4767/ipns/k51…/ipcr-hello:1.0.0 +``` +- **How it is passed on:** the bridge sends IPCR `allow.aliases` (`{"metadec/ipcr-hello": + ["ipcr-hello"]}`). IPCR publishes each tag under the full path and every alias in one tree + update. +- **Alias sync:** on each pass IPCR adds and removes alias folders to match. Switching the + organisation off or changing it moves the short paths within a minute. +- **Collisions:** a short path is one folder at the root of the tree, so it is skipped (and the + Repositories tab says why) when its name is: + - a Gitea user or organisation, whose repositories would share that folder; + - the owner of another allowed repository. + + IPCR checks the owner case again on its side. +- **Setup:** creates the organisation (public, owned by `gitea_admin`) if it is missing, and puts + the example repository in it. + +## Staging gate and push credentials + +``` +job ── docker login localhost:5000 (owner/repo + IPCR_PUSH_TOKEN) ──▶ forge-gate :5000 (in the CI +IPCR ── Basic ipcr: ── forge-docker:5000 ───────────────▶ daemon's network namespace) + │ unix socket + ▼ + forge-registry (no network) +``` +- **`forge-gate`** is `ipcr-forge-bridge gate`, built from the same image. + - It checks Basic auth against `bridge/state/gate/credentials.json` (hashes only), re-read when + it changes. + - **A repository** may GET, HEAD, POST, PUT and PATCH under `/v2///{blobs,manifests,tags}/`. + Cross-repository blob mounts are stripped, so the client uploads instead. + - **The importer** may read everything, list the catalog and delete manifests. + - Anything else gets 401 or 403. +- **`forge-registry`** listens on a unix socket in a folder only it and the gate mount. Jobs run in + the daemon, which sees neither that folder nor a port. So the gate cannot be bypassed short of a + breakout (see Trust). +- **Credentials:** + - **Per repository:** the bridge generates a token for each repository that may publish. It + sets the repository's `IPCR_PUSH_TOKEN` Actions secret through a `write:repository` token + (`ipcr-forge-secrets`, minted at setup) and writes the token's hash for the gate. A + repository switched off or frozen loses both. "Reissue" in the admin page rotates the token. + - **IPCR:** IPCR generates its own credential at its first start (`IMPORT_AUTH_GENERATE`, + `state/staging-auth`), and the bridge passes its hash to the gate. +- **Migration:** existing workflows must add the login step (the template below). Without it, a + push fails with `401 Unauthorized` at its first blob check. + ## Workflow contract A repository opts in by committing a workflow under `.gitea/workflows/`. The template is [bridge/example/.gitea/workflows/build.yml](../bridge/example/.gitea/workflows/build.yml): -- `images: localhost:5000/${{ github.repository }}`: the staging registry. No login, no secret. - `docker/metadata-action` lowercases the name. +- `docker/login-action` to `localhost:5000`, `username: ${{ github.repository }}`, + `password: ${{ secrets.IPCR_PUSH_TOKEN }}`. +- `images: localhost:5000/${{ github.repository }}`: the repository's own name at the staging + registry, the only one its token may push. `docker/metadata-action` lowercases it. - `context: .` and `driver-opts: network=host`: see findings 2 and 3. - The image is published as `/ipns///:`. @@ -219,6 +281,24 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04): 21. **Gitea skips its consent screen only after a first grant,** even with `skip_secondary_authorization`. Each admin clicks "Authorize" once. +22. **Radicle IDs are derived from the identity document.** `metadec/ipcr-hello` (the example, + created again in the organisation) had the same name, description and delegate as + `gitea_admin/ipcr-hello`, so the same RID. `rad init` failed with "attempt to reinitialize". The + bridge now puts the Gitea path in the Radicle description, which makes the document unique per + forge. +23. **Gitea regenerates an OAuth2 app's secret on every update.** `setup` used to update the app at + every start (to follow the hosts) and kept the old secret, which broke the admin login + (`unauthorized_client`). It now updates only when the hosts change, and saves the secret Gitea + returns. +24. **Kubo's resolve cache can lag the node's own publishes.** A tree published again after a change + in between kept resolving to the in-between tree for the record's TTL. The generic `resolve` RPC + ignores `nocache` (only `name/resolve` honours it). ipcrd now resolves names it publishes, + directly or through an ENS/DNSLink name pointing at its key, with `name/resolve --nocache` and + then the path under `/ipfs/`. +25. **A gate needs no registry token server.** Docker and BuildKit answer a `Basic` challenge from + a plain-HTTP `localhost` registry with the credentials from `docker login`, and ipcrd's client + already did the same. + ## Verified **Locally** (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the bridge image, one Docker network): @@ -256,14 +336,29 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04): | Restore a throwaway key, then the original | name switches and pulls under each; original back with sequence 7 (network had 5); kept copy reused, no duplicate key | | `forge-registry` restart | garbage collection frees the deleted tags' blobs | +**Org root and gate, on holyhorse** (ipcr 1.4.0, bridge 0.3.0, 2026-10-05): + +| Check | Result | +| --- | --- | +| setup with `IPCR_ROOT_ORG=metadec` | org and `metadec/ipcr-hello` created; `IPCR_PUSH_TOKEN` set on both public repositories; gate knows them and IPCR | +| tag `v1.0.0` on `metadec/ipcr-hello` (workflow with login) | built, imported, published as `ipcr-hello` and `metadec/ipcr-hello` | +| `docker pull ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0` | works (`metadec.eth` → this forge's key); also by `k51…/ipcr-hello` and `k51…/metadec/ipcr-hello` | +| a workflow without login | push refused, 401 | +| probe job in `gitea_admin/ipcr-hello` | no login 401; own token: catalog 403, `metadec/ipcr-hello` push or read 403, own repository 202; wrong token 401; `/run/staging` from the daemon: empty, no socket | +| root organisation off / on (twice) | short path gone / back on the next pass, by k51 and by ENS | +| 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 | + ## Open items - **Pull from a second server.** Verified on holyhorse itself only; v2's images pulled across servers the same way. -- **Publish the images**: `ghcr.io/yundera/ipcr:1.3.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.2.0` - (workflows `image.yml` and `bridge.yml`, tags `v1.3.0` and `bridge-v0.2.0`). -- **Image provenance:** the bridge as a registry token server, so only a repository's own CI can - push its name (see Trust). +- **Publish the images**: `ghcr.io/yundera/ipcr:1.4.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.3.0` + (workflows `image.yml` and `bridge.yml`, tags `v1.4.0` and `bridge-v0.3.0`). +- **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. +- **Private repositories** cannot publish: the bridge only sees public ones. If wanted, give them + push credentials too (the images would still be public on IPFS). - **Old keys:** `-replaced-*` keys are kept forever, and Kubo keeps republishing them. A "delete" in the admin page, once the new key is confirmed. - **act_runner** is pinned at 0.6.1, proven with Gitea 1.27.3. Upstream has moved on. diff --git a/forge/admin.go b/forge/admin.go index c7d58a7..5701915 100644 --- a/forge/admin.go +++ b/forge/admin.go @@ -32,8 +32,11 @@ type adminAPI struct { tokenFile string // ipcrd's admin-token, read through the read-only ipcr/state mount keystore string // Kubo's keystore folder, read-only publisher string // the publisher key's name (IMPORT_PUBLISHER) - settings string // admin.json: the repositories switched off - gitea *gitea + settings string // admin.json: the repositories switched off, the root organisation + gitea *gitea // public-only, read: lists the repositories + admin *gitea // admin-scoped: name collisions (any user or org), push secrets + creds *pushCreds + rootOrg string // default root organisation (IPCR_ROOT_ORG), until set in the admin page au *auth http *http.Client @@ -43,6 +46,24 @@ type adminAPI struct { type adminSettings struct { // Repositories (owner/name, lowercase) whose images are not published. Disabled []string `json:"disabled"` + // The organisation whose repositories are also published at the root of the forge's name + // (metadec/app → /ipns//app). nil: IPCR_ROOT_ORG; "": none. + RootOrg *string `json:"rootOrg,omitempty"` +} + +func (a *adminAPI) root() string { + if s := a.load(); s.RootOrg != nil { + return strings.ToLower(*s.RootOrg) + } + return strings.ToLower(a.rootOrg) +} + +func (a *adminAPI) save(s adminSettings) error { + b, _ := json.MarshalIndent(s, "", " ") + if err := os.WriteFile(a.settings+".new", b, 0o644); err != nil { + return err + } + return os.Rename(a.settings+".new", a.settings) } func (a *adminAPI) load() adminSettings { @@ -69,6 +90,9 @@ func (a *adminAPI) routes(mux *http.ServeMux, au *auth) { mux.Handle("POST /admin/api/key/export", guard(http.HandlerFunc(a.export))) mux.Handle("GET /admin/api/repos", guard(http.HandlerFunc(a.listRepos))) mux.Handle("PUT /admin/api/repos", guard(http.HandlerFunc(a.setRepo))) + mux.Handle("POST /admin/api/repos/rotate", guard(http.HandlerFunc(a.rotate))) + mux.Handle("GET /admin/api/settings", guard(http.HandlerFunc(a.getSettings))) + mux.Handle("PUT /admin/api/settings", guard(http.HandlerFunc(a.putSettings))) } func writeJSONResponse(w http.ResponseWriter, v any) { @@ -148,8 +172,69 @@ type repoToggle struct { Publish bool `json:"publish"` } -func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) { +// repoView is a row of the admin page's Repositories tab. +type repoView struct { + repoToggle + PublishedAs []string `json:"published_as,omitempty"` // short path first + Credential bool `json:"credential"` // the gate knows its IPCR_PUSH_TOKEN + Collision string `json:"collision,omitempty"` // why its short path is not used +} + +// pubPlan is what IPCR is told to publish, and why some short paths are not used. +type pubPlan struct { + Repos []string // owner/repo, lowercase + Aliases map[string][]string // owner/repo → short paths + Collisions map[string]string // owner/repo → reason +} + +// plan computes the allowlist: the eligible public repositories not switched off, and for the root +// organisation's, their short path, unless it would collide with an owner's folder: the name of +// any Gitea user or organisation (exists), or the owner of another allowed repository. +func plan(repos []repo, disabled []string, root string, exists func(string) bool) pubPlan { + p := pubPlan{Aliases: map[string][]string{}, Collisions: map[string]string{}} + off := map[string]bool{} + for _, d := range disabled { + off[d] = true + } + owners := map[string]bool{} + for i := range repos { + n := strings.ToLower(repos[i].FullName) + if eligible(&repos[i]) && !off[n] { + p.Repos = append(p.Repos, n) + owners[strings.SplitN(n, "/", 2)[0]] = true + } + } + sort.Strings(p.Repos) + if root == "" { + return p + } + for _, n := range p.Repos { + owner, name, _ := strings.Cut(n, "/") + if owner != root { + continue + } + switch { + case owners[name]: + p.Collisions[n] = "short path /" + name + " is the folder of " + name + "'s own repositories" + case exists != nil && exists(name): + p.Collisions[n] = "a Gitea user or organisation is named " + name + ": its repositories would share /" + name + default: + p.Aliases[n] = []string{name} + } + } + return p +} + +func (a *adminAPI) currentPlan() (pubPlan, []repo, error) { repos, err := a.gitea.publicRepos() + if err != nil { + return pubPlan{}, nil, err + } + return plan(repos, a.load().Disabled, a.root(), a.admin.exists), repos, nil +} + +func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) { + p, repos, err := a.currentPlan() if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return @@ -158,12 +243,18 @@ func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) { for _, d := range a.load().Disabled { disabled[d] = true } - out := []repoToggle{} + out := []repoView{} for _, rp := range repos { - if eligible(&rp) { - n := strings.ToLower(rp.FullName) - out = append(out, repoToggle{FullName: n, Publish: !disabled[n]}) + if !eligible(&rp) { + continue } + n := strings.ToLower(rp.FullName) + v := repoView{repoToggle: repoToggle{FullName: n, Publish: !disabled[n]}, Collision: p.Collisions[n]} + if v.Publish { + v.PublishedAs = append(append([]string{}, p.Aliases[n]...), n) + v.Credential = a.creds != nil && a.creds.has(n) + } + out = append(out, v) } sort.Slice(out, func(i, j int) bool { return out[i].FullName < out[j].FullName }) writeJSONResponse(w, out) @@ -189,58 +280,95 @@ func (a *adminAPI) setRepo(w http.ResponseWriter, r *http.Request) { } sort.Strings(keep) s.Disabled = keep - b, _ := json.MarshalIndent(s, "", " ") - err := os.WriteFile(a.settings+".new", b, 0o644) - if err == nil { - err = os.Rename(a.settings+".new", a.settings) - } + err := a.save(s) a.mu.Unlock() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := a.pushAllowlist(); err != nil { - http.Error(w, "saved, but IPCR did not take the new list: "+err.Error(), http.StatusServiceUnavailable) + http.Error(w, "saved, but not fully applied: "+err.Error(), http.StatusServiceUnavailable) return } writeJSONResponse(w, req) } +// rotate gives a repository a new IPCR_PUSH_TOKEN: when it changed hands, or the old one leaked. +func (a *adminAPI) rotate(w http.ResponseWriter, r *http.Request) { + var req repoToggle + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil || req.FullName == "" { + http.Error(w, "{full_name}?", http.StatusBadRequest) + return + } + p, _, err := a.currentPlan() + if err == nil { + err = a.creds.sync(p.Repos, req.FullName) + } + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + writeJSONResponse(w, map[string]string{"rotated": req.FullName}) +} + +func (a *adminAPI) getSettings(w http.ResponseWriter, r *http.Request) { + writeJSONResponse(w, map[string]string{"rootOrg": a.root(), "rootOrgDefault": strings.ToLower(a.rootOrg)}) +} + +func (a *adminAPI) putSettings(w http.ResponseWriter, r *http.Request) { + var req struct { + RootOrg string `json:"rootOrg"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil { + http.Error(w, "{rootOrg}?", http.StatusBadRequest) + return + } + org := strings.ToLower(strings.TrimSpace(req.RootOrg)) + if org != "" && !a.admin.exists(org) { + http.Error(w, "no Gitea organisation named "+org, http.StatusBadRequest) + return + } + a.mu.Lock() + s := a.load() + s.RootOrg = &org + err := a.save(s) + a.mu.Unlock() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := a.pushAllowlist(); err != nil { + http.Error(w, "saved, but not fully applied: "+err.Error(), http.StatusServiceUnavailable) + return + } + writeJSONResponse(w, map[string]string{"rootOrg": org}) +} + // eligible: the repositories whose images may be published — the same that are mirrored. func eligible(r *repo) bool { return r.public() && !r.Fork && !r.Mirror } -// allowlist is what ipcrd publishes: the image names CI builds are pushed under -// (localhost:5000//, lowercased by docker/metadata-action). -func allowlist(repos []repo, disabled []string) []string { - off := map[string]bool{} - for _, d := range disabled { - off[d] = true - } - var out []string - for i := range repos { - n := strings.ToLower(repos[i].FullName) - if eligible(&repos[i]) && !off[n] { - out = append(out, n) - } - } - sort.Strings(out) - return out -} - -// pushAllowlist sends ipcrd the current list. Called on every reconcile and on every toggle. +// pushAllowlist sends ipcrd the current plan and gives the gate the matching credentials. Called +// on every reconcile, toggle and root-organisation change. func (a *adminAPI) pushAllowlist() error { - repos, err := a.gitea.publicRepos() + p, _, err := a.currentPlan() if err != nil { return err } - return a.sendAllowlist(allowlist(repos, a.load().Disabled)) + if err := a.sendAllowlist(p); err != nil { + return err + } + if a.creds != nil { + return a.creds.sync(p.Repos, "") + } + return nil } -func (a *adminAPI) sendAllowlist(list []string) error { +func (a *adminAPI) sendAllowlist(p pubPlan) error { + list := p.Repos if list == nil { list = []string{} } - body, _ := json.Marshal(map[string]any{"allow": map[string]any{"required": true, "repos": list}}) + body, _ := json.Marshal(map[string]any{"allow": map[string]any{"required": true, "repos": list, "aliases": p.Aliases}}) req, _ := http.NewRequest("PATCH", a.ipcr+"/admin/config", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+a.token()) diff --git a/forge/bridge_test.go b/forge/bridge_test.go index 3736e45..b175dfe 100644 --- a/forge/bridge_test.go +++ b/forge/bridge_test.go @@ -171,7 +171,8 @@ func TestExampleEmbedded(t *testing.T) { } } b, _ := exampleFiles.ReadFile("example/.gitea/workflows/build.yml") - if !strings.Contains(string(b), "localhost:5000/${{ github.repository }}") || !strings.Contains(string(b), "network=host") { + if !strings.Contains(string(b), "localhost:5000/${{ github.repository }}") || !strings.Contains(string(b), "network=host") || + !strings.Contains(string(b), "secrets.IPCR_PUSH_TOKEN") { t.Error("example workflow lost its staging registry lines") } } @@ -408,7 +409,7 @@ func TestCallback(t *testing.T) { } } -func TestAllowlist(t *testing.T) { +func TestPlan(t *testing.T) { mk := func(name string, f func(*repo)) repo { r := repo{FullName: name} if f != nil { @@ -421,10 +422,22 @@ func TestAllowlist(t *testing.T) { mk("owner/off", nil), mk("owner/secret", func(r *repo) { r.Private = true }), mk("owner/fork", func(r *repo) { r.Fork = true }), - mk("org/thing", nil), + mk("Metadec/Hello", nil), + mk("metadec/owner", nil), // short path /owner is owner/'s folder + mk("metadec/bob", nil), // a Gitea user is named bob + mk("metadec/off2", nil), } - got := strings.Join(allowlist(repos, []string{"owner/off"}), ",") - if got != "org/thing,owner/app" { - t.Errorf("allowlist = %s", got) + p := plan(repos, []string{"owner/off", "metadec/off2"}, "metadec", func(n string) bool { return n == "bob" }) + if got := strings.Join(p.Repos, ","); got != "metadec/bob,metadec/hello,metadec/owner,owner/app" { + t.Errorf("repos = %s", got) + } + if len(p.Aliases) != 1 || strings.Join(p.Aliases["metadec/hello"], ",") != "hello" { + t.Errorf("aliases = %v", p.Aliases) + } + if p.Collisions["metadec/owner"] == "" || p.Collisions["metadec/bob"] == "" || len(p.Collisions) != 2 { + t.Errorf("collisions = %v", p.Collisions) + } + if q := plan(repos, nil, "", nil); len(q.Aliases) != 0 { + t.Errorf("no root organisation, aliases %v", q.Aliases) } } diff --git a/forge/example/.gitea/workflows/build.yml b/forge/example/.gitea/workflows/build.yml index 0716071..5dc8427 100644 --- a/forge/example/.gitea/workflows/build.yml +++ b/forge/example/.gitea/workflows/build.yml @@ -2,8 +2,10 @@ # IPCR picks it up from there within a minute and publishes it on IPFS; the forge's page then # lists the exact `image:` line to use. # -# Differences from a GitHub-hosted workflow, both needed on this forge: -# images: localhost:5000/… the staging registry (no login, no secret) +# Differences from a GitHub-hosted workflow, all needed on this forge: +# login to localhost:5000 the staging registry, as this repository: IPCR_PUSH_TOKEN is a secret +# the forge sets on every repository allowed to publish +# images: localhost:5000/… this repository's own name there (no other is accepted) # network=host so buildx's builder can reach that registry name: image on: @@ -14,6 +16,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: localhost:5000 + username: ${{ github.repository }} + password: ${{ secrets.IPCR_PUSH_TOKEN }} - uses: docker/metadata-action@v5 id: meta with: diff --git a/forge/gate.go b/forge/gate.go new file mode 100644 index 0000000..bf4297b --- /dev/null +++ b/forge/gate.go @@ -0,0 +1,189 @@ +package main + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "sync" + "time" +) + +// The gate is the staging registry's front door (`ipcr-forge-bridge gate`). CI jobs push to +// localhost:5000 and IPCR imports from it; before the gate, anyone who could run a job could push +// any image name, and IPCR published what it found. Now: +// +// - a job logs in as its repository: user /, password the IPCR_PUSH_TOKEN secret the +// bridge set on that repository (pushcreds.go). It may read and write that repository's +// manifests, blobs and tags, and nothing else: no other name, no catalog, no delete, no +// cross-repository blob mount; +// - IPCR logs in as the importer (the credential it generated itself): read everything, list the +// catalog, delete manifests it has published. +// +// It runs in the CI daemon's network namespace, where jobs see it as localhost:5000. The registry +// behind it listens on a unix socket shared with the gate only, so a job cannot go around it. +// +// Environment: GATE_LISTEN (default :5000), GATE_UPSTREAM (default +// unix:///run/staging/registry.sock), GATE_CREDENTIALS (default /gate/credentials.json). + +// gateCreds is credentials.json, written by the bridge: SHA-256 (hex) of each password. +type gateCreds struct { + Push map[string]string `json:"push"` // "/" (lowercase) → hash + Importer map[string]string `json:"importer"` // user → hash +} + +type gate struct { + credFile string + upstream http.Handler + + mu sync.Mutex + mtime time.Time + creds gateCreds +} + +func gateMain() error { + g := &gate{credFile: env("GATE_CREDENTIALS", "/gate/credentials.json")} + up, err := upstreamProxy(env("GATE_UPSTREAM", "unix:///run/staging/registry.sock")) + if err != nil { + return err + } + g.upstream = up + addr := env("GATE_LISTEN", ":5000") + log.Printf("gate: %s → %s, credentials %s", addr, env("GATE_UPSTREAM", "unix:///run/staging/registry.sock"), g.credFile) + return http.ListenAndServe(addr, g) +} + +// upstreamProxy forwards to the registry, keeping the client's Host (the registry builds upload +// URLs from it) and dropping the client's credentials (the registry has none of its own). +func upstreamProxy(target string) (http.Handler, error) { + u, err := url.Parse(target) + if err != nil { + return nil, err + } + tr := http.DefaultTransport.(*http.Transport).Clone() + dest := &url.URL{Scheme: u.Scheme, Host: u.Host} + if u.Scheme == "unix" { + sock := u.Path + tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", sock) + } + dest = &url.URL{Scheme: "http", Host: "registry"} + } + return &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(dest) + pr.Out.Host = pr.In.Host + pr.Out.Header.Del("Authorization") + }, + Transport: tr, + FlushInterval: -1, + }, nil +} + +func (g *gate) load() gateCreds { + g.mu.Lock() + defer g.mu.Unlock() + st, err := os.Stat(g.credFile) + if err != nil { + return g.creds + } + if !st.ModTime().Equal(g.mtime) { + var c gateCreds + if b, err := os.ReadFile(g.credFile); err == nil && json.Unmarshal(b, &c) == nil { + g.creds, g.mtime = c, st.ModTime() + } + } + return g.creds +} + +func hashSecret(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} + +func matches(stored, pass string) bool { + return stored != "" && subtle.ConstantTimeCompare([]byte(stored), []byte(hashSecret(pass))) == 1 +} + +func registryError(w http.ResponseWriter, status int, code, msg string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") + if status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", `Basic realm="ipcr-staging"`) + } + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]any{"errors": []map[string]string{{"code": code, "message": msg}}}) +} + +func (g *gate) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + if r.URL.RawPath != "" || strings.Contains(p, "..") || strings.Contains(p, "//") || !strings.HasPrefix(p, "/v2") { + registryError(w, http.StatusBadRequest, "NAME_INVALID", "bad path") + return + } + user, pass, _ := r.BasicAuth() + user = strings.ToLower(user) + creds := g.load() + role := "" + switch { + case matches(creds.Importer[user], pass): + role = "importer" + case matches(creds.Push[user], pass): + role = "push" + } + if role == "" { + registryError(w, http.StatusUnauthorized, "UNAUTHORIZED", "log in as / with the repository's IPCR_PUSH_TOKEN secret") + return + } + if !allowed(role, user, r) { + log.Printf("gate: %s refused %s %s", user, r.Method, p) + registryError(w, http.StatusForbidden, "DENIED", user+" may only push "+user) + return + } + if role == "push" { + // A cross-repository mount would copy a blob from a repository this user may not read. + // Without it the client simply uploads the blob. + q := r.URL.Query() + if q.Has("mount") || q.Has("from") { + q.Del("mount") + q.Del("from") + r.URL.RawQuery = q.Encode() + } + } + g.upstream.ServeHTTP(w, r) +} + +// allowed applies the two roles to a request. +func allowed(role, user string, r *http.Request) bool { + p, m := r.URL.Path, r.Method + read := m == "GET" || m == "HEAD" + if p == "/v2/" || p == "/v2" { + return read + } + if role == "importer" { + if read { + return true + } + return m == "DELETE" && strings.Contains(p, "/manifests/") + } + rest, ok := strings.CutPrefix(p, "/v2/"+user+"/") + if !ok || user == "" { + return false + } + if !strings.HasPrefix(rest, "blobs/") && !strings.HasPrefix(rest, "manifests/") && !strings.HasPrefix(rest, "tags/") { + return false + } + switch m { + case "GET", "HEAD", "POST", "PUT", "PATCH": + return true + } + return false +} diff --git a/forge/gate_test.go b/forge/gate_test.go new file mode 100644 index 0000000..e2536fb --- /dev/null +++ b/forge/gate_test.go @@ -0,0 +1,175 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func newTestGate(t *testing.T) (*gate, *[]*http.Request) { + t.Helper() + var seen []*http.Request + var mu sync.Mutex + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seen = append(seen, r.Clone(r.Context())) + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(up.Close) + proxy, err := upstreamProxy(up.URL) + if err != nil { + t.Fatal(err) + } + file := filepath.Join(t.TempDir(), "credentials.json") + writeCreds(file, gateCreds{ + Push: map[string]string{"metadec/app": hashSecret("app-token"), "alice/tool": hashSecret("alice-token")}, + Importer: map[string]string{"ipcr": hashSecret("importer-pass")}, + }) + return &gate{credFile: file, upstream: proxy}, &seen +} + +func TestGateAuthorization(t *testing.T) { + g, _ := newTestGate(t) + for _, c := range []struct { + name, method, path, user, pass string + want int + }{ + {"no credentials", "GET", "/v2/", "", "", 401}, + {"wrong password", "GET", "/v2/", "metadec/app", "nope", 401}, + {"login check", "GET", "/v2/", "metadec/app", "app-token", 200}, + {"login, uppercase user", "GET", "/v2/", "Metadec/App", "app-token", 200}, + {"own manifest", "PUT", "/v2/metadec/app/manifests/1.0.0", "metadec/app", "app-token", 200}, + {"own blob upload", "POST", "/v2/metadec/app/blobs/uploads/", "metadec/app", "app-token", 200}, + {"own upload chunk", "PATCH", "/v2/metadec/app/blobs/uploads/abc", "metadec/app", "app-token", 200}, + {"own blob check", "HEAD", "/v2/metadec/app/blobs/sha256:00", "metadec/app", "app-token", 200}, + {"other repository", "PUT", "/v2/alice/tool/manifests/1.0.0", "metadec/app", "app-token", 403}, + {"prefix of another name", "PUT", "/v2/metadec/app2/manifests/1", "metadec/app", "app-token", 403}, + {"nested name", "PUT", "/v2/metadec/app/sub/manifests/1", "metadec/app", "app-token", 403}, + {"catalog as pusher", "GET", "/v2/_catalog", "metadec/app", "app-token", 403}, + {"delete as pusher", "DELETE", "/v2/metadec/app/manifests/sha256:00", "metadec/app", "app-token", 403}, + {"dot-dot", "GET", "/v2/metadec/app/../alice/tool/tags/list", "metadec/app", "app-token", 400}, + {"importer catalog", "GET", "/v2/_catalog", "ipcr", "importer-pass", 200}, + {"importer pull", "GET", "/v2/alice/tool/manifests/1.0.0", "ipcr", "importer-pass", 200}, + {"importer delete", "DELETE", "/v2/alice/tool/manifests/sha256:00", "ipcr", "importer-pass", 200}, + {"importer push", "PUT", "/v2/alice/tool/manifests/1.0.0", "ipcr", "importer-pass", 403}, + } { + r := httptest.NewRequest(c.method, "http://localhost:5000"+c.path, nil) + if c.user != "" { + r.SetBasicAuth(c.user, c.pass) + } + w := httptest.NewRecorder() + g.ServeHTTP(w, r) + if w.Code != c.want { + t.Errorf("%s: %d, want %d", c.name, w.Code, c.want) + } + if w.Code == 401 && !strings.HasPrefix(w.Header().Get("WWW-Authenticate"), "Basic ") { + t.Errorf("%s: no Basic challenge", c.name) + } + } +} + +func TestGateForwarding(t *testing.T) { + g, seen := newTestGate(t) + r := httptest.NewRequest("POST", "http://localhost:5000/v2/metadec/app/blobs/uploads/?mount=sha256:00&from=alice/tool", nil) + r.Host = "localhost:5000" + r.SetBasicAuth("metadec/app", "app-token") + w := httptest.NewRecorder() + g.ServeHTTP(w, r) + if w.Code != 200 || len(*seen) != 1 { + t.Fatalf("%d, %d upstream requests", w.Code, len(*seen)) + } + up := (*seen)[0] + if up.URL.RawQuery != "" { + t.Errorf("cross-repository mount forwarded: %q", up.URL.RawQuery) + } + if up.Host != "localhost:5000" { + t.Errorf("Host %q: the registry builds upload URLs from it", up.Host) + } + if up.Header.Get("Authorization") != "" { + t.Error("credentials forwarded to the registry") + } +} + +func TestGateReloadsCredentials(t *testing.T) { + g, _ := newTestGate(t) + try := func() int { + r := httptest.NewRequest("GET", "http://localhost:5000/v2/", nil) + r.SetBasicAuth("new/repo", "t") + w := httptest.NewRecorder() + g.ServeHTTP(w, r) + return w.Code + } + if try() != 401 { + t.Fatal("unknown repository accepted") + } + c := gateCreds{Push: map[string]string{"new/repo": hashSecret("t")}} + writeCreds(g.credFile, c) + // A different modification time, as a later write would have. + st, _ := os.Stat(g.credFile) + os.Chtimes(g.credFile, st.ModTime().Add(1e9), st.ModTime().Add(1e9)) + if try() != 200 { + t.Error("new credentials not picked up") + } +} + +// sync against a fake Gitea: secrets set for new repositories, kept for known ones, deleted for +// removed ones, the importer taken from IPCR's file. +func TestPushCredsSync(t *testing.T) { + var mu sync.Mutex + secrets := map[string]string{} + var deleted []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + repo := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/repos/"), "/actions/secrets/"+pushSecret) + switch r.Method { + case "PUT": + var b struct{ Data string } + json.NewDecoder(r.Body).Decode(&b) + secrets[repo] = b.Data + w.WriteHeader(201) + case "DELETE": + deleted = append(deleted, repo) + w.WriteHeader(204) + } + })) + defer srv.Close() + dir := t.TempDir() + tok := filepath.Join(dir, "token") + os.WriteFile(tok, []byte("admin-token"), 0o600) + auth := filepath.Join(dir, "staging-auth") + os.WriteFile(auth, []byte("ipcr:importer-pass\n"), 0o644) + p := &pushCreds{file: filepath.Join(dir, "gate", "credentials.json"), stagingAuth: auth, gitea: newGitea(srv.URL, "gitea_admin", tok)} + + if err := p.sync([]string{"Metadec/App", "alice/tool"}, ""); err != nil { + t.Fatal(err) + } + c := p.read() + if !matches(c.Push["metadec/app"], secrets["Metadec/App"]) || !matches(c.Push["alice/tool"], secrets["alice/tool"]) { + t.Fatalf("credentials do not match the secrets: %v %v", c.Push, secrets) + } + if !matches(c.Importer["ipcr"], "importer-pass") { + t.Error("importer credential missing") + } + first := secrets["alice/tool"] + + // alice/tool keeps its token; Metadec/App is rotated; a removed repository loses it. + if err := p.sync([]string{"Metadec/App", "alice/tool"}, "metadec/app"); err != nil { + t.Fatal(err) + } + if secrets["alice/tool"] != first || !matches(p.read().Push["metadec/app"], secrets["Metadec/App"]) { + t.Error("rotation") + } + if err := p.sync([]string{"Metadec/App"}, ""); err != nil { + t.Fatal(err) + } + if _, ok := p.read().Push["alice/tool"]; ok || len(deleted) != 1 || deleted[0] != "alice/tool" { + t.Errorf("removal: %v, deleted %v", p.read().Push, deleted) + } +} diff --git a/forge/gitea.go b/forge/gitea.go index dbdb618..193fe54 100644 --- a/forge/gitea.go +++ b/forge/gitea.go @@ -140,3 +140,13 @@ func (g *gitea) repoByID(id int64) (*repo, error) { } return &r, nil } + +// exists reports whether a Gitea user or organisation has this name (an admin-scoped token sees +// private ones too). On doubt (Gitea unreachable) it answers yes: a short path is then not used. +func (g *gitea) exists(name string) bool { + if g == nil { + return false + } + code, err := g.call("GET", "/users/"+url.PathEscape(name), nil, nil) + return code == http.StatusOK || (err != nil && code != http.StatusNotFound) +} diff --git a/forge/main.go b/forge/main.go index 1bd92a1..eafd9be 100644 --- a/forge/main.go +++ b/forge/main.go @@ -4,6 +4,7 @@ // serve (default) webhook receiver + mirror worker + the forge's page // setup install step: Gitea token, system webhook, example repository (idempotent) // health exit 0 when `serve` answers, for the container healthcheck +// gate the staging registry's front door: per-repository push credentials (gate.go) // // What gets mirrored: every public, non-fork, non-mirror Gitea repository, as seen by a token // scoped `public-only` — a private repository is invisible to the service, not filtered by it. @@ -39,6 +40,10 @@ // IPCR_ADMIN_TOKEN_FILE its token (default /srv/ipcr-state/admin-token) // KUBO_KEYSTORE Kubo's keystore, read-only, for key backups (default /srv/ipcr-keystore) // IMPORT_PUBLISHER the publisher key's name (default forge) +// IPCR_ROOT_ORG organisation published at the root of the forge's name, until changed in +// the admin page (default: none) +// GATE_CREDENTIALS the gate's credentials.json (default /bridge/gate/credentials.json) +// IPCR_STAGING_AUTH IPCR's own staging credential (default /srv/ipcr-state/staging-auth) package main import ( @@ -71,8 +76,10 @@ func main() { err = setup() case "health": err = health() + case "gate": + err = gateMain() default: - err = fmt.Errorf("usage: %s serve | setup | health", os.Args[0]) + err = fmt.Errorf("usage: %s serve | setup | health | gate", os.Args[0]) } if err != nil { log.Fatal(err) @@ -128,10 +135,14 @@ func serve() error { if err != nil { return err } + adminGitea := newGitea(m.gitea.base, m.gitea.owner, secrets+"/gitea-admin-token") adm := &adminAPI{ ipcr: strings.TrimRight(ipcr, "/"), tokenFile: env("IPCR_ADMIN_TOKEN_FILE", "/srv/ipcr-state/admin-token"), keystore: env("KUBO_KEYSTORE", "/srv/ipcr-keystore"), publisher: env("IMPORT_PUBLISHER", "forge"), - settings: stateDir + "/admin.json", gitea: m.gitea, http: &http.Client{Timeout: 3 * time.Minute}, + settings: stateDir + "/admin.json", gitea: m.gitea, admin: adminGitea, rootOrg: os.Getenv("IPCR_ROOT_ORG"), + creds: &pushCreds{file: env("GATE_CREDENTIALS", stateDir+"/gate/credentials.json"), + stagingAuth: env("IPCR_STAGING_AUTH", "/srv/ipcr-state/staging-auth"), gitea: adminGitea}, + http: &http.Client{Timeout: 3 * time.Minute}, } adminRoutes(mux, au, adm) // IPCR publishes nothing until it has the list (IMPORT_ALLOW_REQUIRED): send it as soon as diff --git a/forge/pushcreds.go b/forge/pushcreds.go new file mode 100644 index 0000000..528a4b7 --- /dev/null +++ b/forge/pushcreds.go @@ -0,0 +1,103 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +// pushSecret is the Actions secret each publishing repository gets: its password at the gate. +const pushSecret = "IPCR_PUSH_TOKEN" + +// pushCreds hands out the gate's credentials. Every repository allowed to publish gets a random +// token, set as its IPCR_PUSH_TOKEN Actions secret (Gitea gives secrets to the repository's own +// workflows, never to pull requests from forks); the gate only learns the token's hash. A +// repository that may no longer publish loses both. The importer's credential is the one IPCR +// generated (IMPORT_AUTH_GENERATE), read from its state folder. +type pushCreds struct { + file string // the gate's credentials.json + stagingAuth string // IPCR's "user:password" + gitea *gitea // admin-scoped (write:repository): sets and deletes secrets + + mu sync.Mutex +} + +func (p *pushCreds) read() gateCreds { + var c gateCreds + if b, err := os.ReadFile(p.file); err == nil { + json.Unmarshal(b, &c) + } + if c.Push == nil { + c.Push = map[string]string{} + } + return c +} + +func (p *pushCreds) has(repo string) bool { + _, ok := p.read().Push[strings.ToLower(repo)] + return ok +} + +// sync makes the gate's credentials match repos (owner/repo, as Gitea names them): new ones get a +// token, gone ones lose it. rotate, when set, gets a new token even if it has one. +func (p *pushCreds) sync(repos []string, rotate string) error { + p.mu.Lock() + defer p.mu.Unlock() + cur := p.read() + next := gateCreds{Push: map[string]string{}, Importer: map[string]string{}} + var errs []string + for _, repo := range repos { + key := strings.ToLower(repo) + if h, ok := cur.Push[key]; ok && !strings.EqualFold(repo, rotate) { + next.Push[key] = h + continue + } + tok := randomString(32) + if _, err := p.gitea.call("PUT", "/repos/"+repo+"/actions/secrets/"+pushSecret, map[string]string{"data": tok}, nil); err != nil { + errs = append(errs, repo+": "+err.Error()) + if h, ok := cur.Push[key]; ok { + next.Push[key] = h // keep the old one working + } + continue + } + next.Push[key] = hashSecret(tok) + log.Printf("push credentials: %s gets a new %s", repo, pushSecret) + } + for key := range cur.Push { + if _, ok := next.Push[key]; !ok { + // Best effort: without its hash at the gate the secret is useless anyway. + p.gitea.call("DELETE", "/repos/"+key+"/actions/secrets/"+pushSecret, nil, nil) + log.Printf("push credentials: %s may no longer publish", key) + } + } + if b, err := os.ReadFile(p.stagingAuth); err == nil { + if user, pass, ok := strings.Cut(strings.TrimSpace(string(b)), ":"); ok && user != "" && pass != "" { + next.Importer[strings.ToLower(user)] = hashSecret(pass) + } + } + if err := writeCreds(p.file, next); err != nil { + return err + } + if len(errs) > 0 { + sort.Strings(errs) + return fmt.Errorf("could not set %s: %s", pushSecret, strings.Join(errs, "; ")) + } + return nil +} + +func writeCreds(path string, c gateCreds) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, _ := json.MarshalIndent(c, "", " ") + // Hashes only: readable by the gate (same uid), useless to anyone else. + if err := os.WriteFile(path+".new", b, 0o644); err != nil { + return err + } + return os.Rename(path+".new", path) +} diff --git a/forge/rad.go b/forge/rad.go index bfb63fb..dabbdd8 100644 --- a/forge/rad.go +++ b/forge/rad.go @@ -68,9 +68,13 @@ func (m *mirror) rad(dir string, args ...string) (string, error) { // makes this node seed it (the node's policy is `block`: nothing is kept unless asked) and adds // the `rad` remote that pushes go to. func (m *mirror) radInit(dir string, r *repo) (string, error) { - desc := r.Description - if desc == "" { - desc = "Mirrored from Gitea: " + r.FullName + // A repository's ID is a hash of its first identity document (name, description, default + // branch, delegates). Two Gitea repositories with the same name and description (an example + // copied into an organisation) would get the same ID on this node, and the second rad init + // fails ("attempt to reinitialize"). The Gitea path makes the document unique per forge. + desc := "Mirrored from Gitea: " + r.FullName + if r.Description != "" { + desc = r.Description + " (Gitea: " + r.FullName + ")" } out, err := m.rad(dir, "init", dir, "--name", r.Name, "--description", desc, "--default-branch", r.DefaultBranch, "--public", "--no-confirm") diff --git a/forge/setup.go b/forge/setup.go index ce7b6ea..4329e13 100644 --- a/forge/setup.go +++ b/forge/setup.go @@ -19,7 +19,29 @@ import ( //go:embed all:example var exampleFiles embed.FS -const tokenName = "ipcr-forge-bridge" +const ( + tokenName = "ipcr-forge-bridge" + adminTokenName = "ipcr-forge-secrets" +) + +// ensureOrg creates the public organisation org, owned by the admin, unless it exists. +func ensureOrg(g *gitea, org string) error { + code, err := g.call("GET", "/orgs/"+org, nil, nil) + if code == http.StatusOK { + return nil + } + if code != http.StatusNotFound { + return err + } + if _, err := g.call("POST", "/admin/users/"+g.owner+"/orgs", map[string]any{ + "username": org, "visibility": "public", + "description": "Its public repositories publish images at the root of the forge's name.", + }, nil); err != nil { + return err + } + log.Printf("setup: created organisation %s", org) + return nil +} // setup is the install step, run after the stack is up and on every start (each action is // idempotent). It signs in once as the admin with the server's default app password, because @@ -30,7 +52,10 @@ const tokenName = "ipcr-forge-bridge" // SECRETS_DIR/hook-secret the webhook's HMAC key. Generated only when missing. // a system webhook every repository's push/create/delete/repository events → HOOK_URL, // created or repaired (matched by URL), never duplicated. -// EXAMPLE_REPO a public example repository with a workflow, created when absent. +// SECRETS_DIR/gitea-admin-token write:repository + read:user: push secrets, name collisions. +// IPCR_ROOT_ORG the root organisation, created when absent (public, owned by the admin). +// EXAMPLE_REPO a public example repository with a workflow, created when absent, in +// the root organisation when there is one. // an OAuth2 application the admin pages' Gitea login (PUBLIC_HOSTS → its redirect URIs); // SECRETS_DIR/oauth-client-id and oauth-client-secret. // @@ -78,6 +103,36 @@ func setup() error { log.Printf("setup: token %s written", tokenName) } + // The admin-scoped token: sets each publishing repository's IPCR_PUSH_TOKEN secret, and checks + // short-path collisions against every user and organisation. Not public-only: an organisation + // may be private and still own the name. + adminTokenFile := filepath.Join(secrets, "gitea-admin-token") + if b, _ := os.ReadFile(adminTokenFile); len(strings.TrimSpace(string(b))) == 0 { + g.call("DELETE", "/users/"+g.owner+"/tokens/"+adminTokenName, nil, nil) + var t struct { + SHA1 string `json:"sha1"` + } + if _, err := g.call("POST", "/users/"+g.owner+"/tokens", map[string]any{ + "name": adminTokenName, "scopes": []string{"write:repository", "read:user", "read:organization"}, + }, &t); err != nil { + return fmt.Errorf("token: %w", err) + } + if err := writeSecret(adminTokenFile, t.SHA1); err != nil { + return err + } + log.Printf("setup: token %s written", adminTokenName) + } + + // The root organisation: its repositories are also published at the root of the forge's name. + owner := g.owner + if org := strings.ToLower(env("IPCR_ROOT_ORG", "")); org != "" { + if err := ensureOrg(g, org); err != nil { + log.Printf("setup: organisation %s: %v", org, err) + } else { + owner = org + } + } + secretFile := filepath.Join(secrets, "hook-secret") secret, _ := os.ReadFile(secretFile) if strings.TrimSpace(string(secret)) == "" { @@ -100,7 +155,7 @@ func setup() error { if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" { // Not fatal: the forge works without its example. - if err := ensureExample(g, name); err != nil { + if err := ensureExample(g, owner, name); err != nil { log.Printf("setup: example repository: %v", err) } } @@ -146,18 +201,22 @@ func ensureHook(g *gitea, url, secret string) error { // ensureExample creates /, public, with the files under example/ in one commit. A // repository that already has content is left as it is: it may be the user's by now. -func ensureExample(g *gitea, name string) error { +func ensureExample(g *gitea, owner, name string) error { var r repo - code, err := g.call("GET", "/repos/"+g.owner+"/"+name, nil, &r) + code, err := g.call("GET", "/repos/"+owner+"/"+name, nil, &r) switch { case code == http.StatusNotFound: - if _, err := g.call("POST", "/admin/users/"+g.owner+"/repos", map[string]any{ + create := "/admin/users/" + owner + "/repos" + if owner != g.owner { + create = "/orgs/" + owner + "/repos" + } + if _, err := g.call("POST", create, map[string]any{ "name": name, "private": false, "default_branch": "main", "description": "Example: a tag here builds an image, published on IPFS by IPCR and mirrored to Radicle.", }, &r); err != nil { return err } - log.Printf("setup: created %s/%s", g.owner, name) + log.Printf("setup: created %s/%s", owner, name) case err != nil: return err case !r.Empty: @@ -181,7 +240,7 @@ func ensureExample(g *gitea, name string) error { if err != nil { return err } - _, err = g.call("POST", "/repos/"+g.owner+"/"+name+"/contents", map[string]any{ + _, err = g.call("POST", "/repos/"+owner+"/"+name+"/contents", map[string]any{ "branch": "main", "message": "Example: a Dockerfile and the workflow that publishes it", "files": files, }, nil) return err @@ -226,9 +285,24 @@ func ensureOAuthApp(g *gitea, hosts []string, secrets string) error { continue } if haveSecret { - // Keep it (and its secret); only follow a change of hosts. - _, err := g.call("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), body, nil) - return err + // Keep it, and its secret. Gitea regenerates the secret on every update, so only + // update when the hosts changed, and keep the secret it hands back. + if strings.Join(app.RedirectURIs, ",") == strings.Join(redirects, ",") { + return nil + } + var updated struct { + ClientSecret string `json:"client_secret"` + } + if _, err := g.call("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), body, &updated); err != nil { + return err + } + if updated.ClientSecret != "" { + if err := writeSecret(secretFile, updated.ClientSecret); err != nil { + return err + } + } + log.Printf("setup: OAuth2 app %s now for %s", oauthAppName, strings.Join(redirects, ", ")) + return nil } if _, err := g.call("DELETE", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), nil, nil); err != nil { return err diff --git a/forge/web/admin.html b/forge/web/admin.html index d2957f3..91571ea 100644 --- a/forge/web/admin.html +++ b/forge/web/admin.html @@ -59,6 +59,27 @@ .bar { height: 8px; background: var(--code); border-radius: 4px; overflow: hidden; margin: 6px 0; } .bar span { display: block; height: 100%; background: var(--accent); } .note { border-left: 3px solid var(--warn); padding: 4px 12px; color: var(--muted); margin: 8px 0; } + .wide { grid-column: 1 / -1; } + /* Names, CIDs and commands stay on one line; their box scrolls instead. */ + .line { display: block; white-space: nowrap; overflow-x: auto; overflow-wrap: normal; } + .copyrow { display: flex; gap: 8px; align-items: center; min-width: 0; } + .copyrow code { flex: 1; min-width: 0; white-space: nowrap; overflow-x: auto; overflow-wrap: normal; padding: 3px 6px; } + .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin: 20px 0 4px; overflow-x: auto; } + .tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; color: var(--muted); + padding: 8px 14px; font-size: 14px; white-space: nowrap; } + .tabs button[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--accent); font-weight: 600; } + .count { font-size: 12px; background: var(--code); border-radius: 10px; padding: 0 7px; margin-left: 4px; } + .addrs { list-style: none; padding: 0; margin: 4px 0 0; font-size: 13px; } + .addrs li { margin: 2px 0; } + .tag { border-top: 1px solid var(--line); padding: 10px 0; } + .tag:first-of-type { border-top: 0; } + .taghead { display: flex; gap: 10px; align-items: baseline; justify-content: space-between; flex-wrap: wrap; margin-bottom: 6px; } + .cmd { display: grid; grid-template-columns: 44px 1fr auto; gap: 8px; align-items: center; margin: 3px 0; } + .cmd .k { font-size: 11.5px; font-weight: 600; color: var(--muted); text-transform: uppercase; } + .cmd code { white-space: nowrap; overflow-x: auto; overflow-wrap: normal; padding: 3px 6px; min-width: 0; } + .spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--line); border-top-color: var(--accent); + border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -1px; margin-right: 6px; } + @keyframes spin { to { transform: rotate(360deg); } } #toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--ink); color: var(--bg); padding: 8px 14px; border-radius: 8px; font-size: 14px; display: none; max-width: calc(100% - 32px); } @@ -71,33 +92,43 @@ + + +

Publisher name

-
+

Key

Key
…
-
IPNS name
…
-
Pull as
…
+
IPNS name
…
+
Pull as
…
-

Every image is published under this one name. Back the key up (below): it exists only on this server.

+

Every image is published under this one name. Back the key up (Key backup tab): it exists only on this server.

-
+

Your domain or ENS name

Point a name you own at the IPNS name, then enter it here. Once it checks out, pull lines use it.

- +

How to point it here -

ENS: in the ENS manager, Records → Content hash = ipns://k51….

-

DNS: a TXT record _dnslink.example.com = dnslink=/ipns/k51….

+

ENS: in the ENS manager, Records → Content hash =

+
ipns://k51…
+

DNS: a TXT record _dnslink.example.com =

+
dnslink=/ipns/k51…
-

Health

+

Health

IPNS record

@@ -106,27 +137,50 @@

The record must be renewed by this node while it is online (Kubo does it every 4 h). Lifetime is how long a record stays valid if this node goes offline; TTL is how long resolvers cache it.

-
-

Node

-
-
-
-

Storage

Published images are pinned and never collected. Unpublishing an image frees the blocks no other image uses.

+
+

Node

+
+

Listening on

+
    +
    +
    +
    +
    +
    + + + + +
    @@ -213,6 +268,34 @@ .then(function () { location.href = "/"; }); }; + // ---- tabs (the open one is kept in the URL, so a reload stays on it) + var tabs = document.querySelectorAll(".tabs button"); + function showTab(name) { + if (!$("tab-" + name)) name = "overview"; + Array.prototype.forEach.call(tabs, function (b) { + var on = b.getAttribute("data-tab") === name; + b.setAttribute("aria-selected", on ? "true" : "false"); + $("tab-" + b.getAttribute("data-tab")).hidden = !on; + }); + } + Array.prototype.forEach.call(tabs, function (b) { + b.onclick = function () { history.replaceState(null, "", "#" + b.getAttribute("data-tab")); showTab(b.getAttribute("data-tab")); }; + }); + showTab(location.hash.slice(1)); + + // ---- copy + function copy(text, btn) { + var done = function () { if (btn) { btn.textContent = "Copied"; setTimeout(function () { btn.textContent = "Copy"; }, 1500); } }; + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { window.prompt("Copy:", text); }); + else window.prompt("Copy:", text); + } + function copyButton(text) { var b = el("button", "Copy", "copy"); b.type = "button"; b.onclick = function () { copy(text, b); }; return b; } + Array.prototype.forEach.call(document.querySelectorAll("button[data-copy]"), function (b) { + b.type = "button"; + b.onclick = function () { copy($(b.getAttribute("data-copy")).textContent, b); }; + }); + function spinner(text) { var s = el("span"); s.appendChild(el("span", null, "spin")); s.appendChild(document.createTextNode(text)); return s; } + // ---- status function recordView(r, err, tree) { if (!r) return badge(err || "none", "bad"); @@ -222,20 +305,28 @@ if (tree && r.value !== tree) { s.appendChild(document.createTextNode(" · ")); s.appendChild(badge("older tree", "warn")); } return s; } + function showName(n) { + var nc = $("name-check"); nc.textContent = ""; + if (n && n.name) { + nc.appendChild(n.ok ? badge("✓ " + n.name + " points at this forge", "ok") : badge("✗ " + (n.error || "does not resolve"), "bad")); + if (!n.ok && n.resolves) nc.appendChild(el("div", n.name + " currently resolves to " + n.resolves, "muted")); + if (n.checkedAt) nc.appendChild(el("div", "Checked " + n.checkedAt.replace("T", " ").replace("Z", " UTC"), "muted")); + } else nc.textContent = "No name set: pull lines use the IPNS name."; + } function loadStatus(refresh) { + var st8 = $("refresh-state"); + st8.textContent = ""; st8.appendChild(spinner(refresh ? "Checking the network…" : "Loading…")); return api("GET", "ipcr/status" + (refresh ? "?refresh=1" : "")).then(function (s) { + st8.textContent = ""; publisher = s.key.id || ""; $("key-name").textContent = s.key.name || "—"; - $("key-id").textContent = publisher || "not created yet (first import creates it)"; - Array.prototype.forEach.call(document.querySelectorAll(".kid"), function (e) { e.textContent = publisher || "k51…"; }); + $("key-id").textContent = publisher || "not created yet (the first import creates it)"; + $("ens-value").textContent = "ipns://" + (publisher || "k51…"); + $("dns-value").textContent = "dnslink=/ipns/" + (publisher || "k51…"); pubName = s.name && s.name.ok ? s.name.name : ""; - $("pull-as").textContent = "ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "//:"; - if (s.name && s.name.name) $("name").value = s.name.name; - var nc = $("name-check"); nc.textContent = ""; - if (s.name && s.name.name) { - nc.appendChild(s.name.ok ? badge("✓ " + s.name.name + " → this forge", "ok") : badge("✗ " + (s.name.error || "does not resolve"), "bad")); - if (!s.name.ok && s.name.resolves) nc.appendChild(el("div", "resolves to " + s.name.resolves, "muted")); - } else nc.textContent = "No name set: pull lines use the IPNS name."; + $("pull-as").textContent = "docker pull ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "//:"; + if (s.name && s.name.name && document.activeElement !== $("name")) $("name").value = s.name.name; + showName(s.name); var rec = s.record; dl($("record"), [ ["Here", recordView(rec.local, rec.localError, rec.tree)], @@ -243,64 +334,101 @@ ["Lifetime", rec.lifetime], ["TTL", rec.ttl], ["Checked", s.checkedAt.replace("T", " ").replace("Z", " UTC")] ]); - dl($("node"), [["Peer ID", s.node.id], ["Connected peers", s.node.peers], - ["Listening", (s.node.addresses || []).filter(function (a) { return a.indexOf("/127.") < 0 && a.indexOf("/::1/") < 0; }).slice(0, 4).join("\n")]]); - var st = s.storage, pct = st.storageMax ? Math.min(100, 100 * st.repoSize / st.storageMax) : 0; - $("storage").innerHTML = ""; - $("storage").appendChild(el("div", size(st.repoSize) + " of " + size(st.storageMax) + " · " + st.pins + " pins")); + var pid = el("div", null, "copyrow"); pid.appendChild(el("code", s.node.id)); pid.appendChild(copyButton(s.node.id)); + dl($("node"), [["Peer ID", pid], ["Connected peers", s.node.peers]]); + // Public addresses first; loopback and the container's own network are of no use to anyone else. + var addrs = (s.node.addresses || []).filter(function (a) { return !/^\/ip4\/(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)|^\/ip6\/(::1|f[cd])/.test(a); }); + var ul = $("addrs"); ul.textContent = ""; + if (!addrs.length) ul.appendChild(el("li", "no public address", "warn")); + addrs.forEach(function (a) { var li = el("li"); li.appendChild(el("code", a.replace(/\/p2p\/[^/]+$/, ""), "line")); ul.appendChild(li); }); + var sto = s.storage, pct = sto.storageMax ? Math.min(100, 100 * sto.repoSize / sto.storageMax) : 0; + $("storage").textContent = ""; + $("storage").appendChild(el("div", size(sto.repoSize) + " of " + size(sto.storageMax) + " · " + sto.pins + " pins")); var bar = el("div", null, "bar"), fill = el("span"); fill.style.width = pct.toFixed(1) + "%"; bar.appendChild(fill); $("storage").appendChild(bar); - }).catch(function (e) { toast("Status: " + e.message, 8000); }); + }).catch(function (e) { st8.textContent = ""; toast("Status: " + e.message, 8000); }); } $("refresh").onclick = function () { busy(this, loadStatus(true)); }; $("republish").onclick = function () { busy(this, api("POST", "ipcr/republish")).then(function () { toast("Published again"); loadStatus(true); }); }; $("peers").onclick = function () { + var o = $("peers-out"); o.textContent = ""; o.appendChild(spinner("Asking the network…")); busy(this, api("GET", "ipcr/peers")).then(function (p) { - var o = $("peers-out"); o.textContent = ""; + o.textContent = ""; if (p.error) o.appendChild(badge(p.error, "bad")); else if (!p.network || !p.network.length) o.appendChild(badge("The network does not know this node's addresses yet: check that port 4768 (TCP and UDP) is reachable.", "warn")); - else { o.appendChild(badge("Reachable via:", "ok")); o.appendChild(el("pre", p.network.join("\n"))); } - }); + else { + o.appendChild(badge("✓ Other nodes can reach this one via:", "ok")); + var ul = el("ul", null, "addrs"); + p.network.forEach(function (a) { var li = el("li"); li.appendChild(el("code", a, "line")); ul.appendChild(li); }); + o.appendChild(ul); + } + }, function () { o.textContent = ""; }); }; $("name-form").onsubmit = function (e) { e.preventDefault(); - var btn = this.querySelector("input[type=submit]"); - busy(btn, api("PATCH", "ipcr/config", { name: $("name").value })).then(function () { return loadStatus(true); }); + var btn = this.querySelector("input[type=submit]"), input = $("name"), name = input.value.trim(); + var nc = $("name-check"); + nc.textContent = ""; + nc.appendChild(spinner(name ? "Saving, then resolving " + name + " (up to a minute)…" : "Clearing the name…")); + btn.disabled = input.disabled = true; btn.value = "Checking…"; + api("PATCH", "ipcr/config", { name: name }) + .then(function () { return loadStatus(true); }) + .then(function () { + toast(name ? (pubName ? name + " checks out: pull lines use it" : "Saved: " + name + " does not point here yet") : "Name cleared"); + loadImages(); + }, function (err) { showName(null); toast(err.message, 8000); }) + .then(function () { btn.disabled = input.disabled = false; btn.value = "Save and check"; }); }; // ---- images + // The pull commands for one tag: by CID, by IPNS name, by the configured name when it checks out. + // The root organisation's repositories have a short path too: it comes first. + function pullCommands(reg, d, img, tg) { + var short = img.aliases && img.aliases.length ? img.aliases[0] : ""; + 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.name) rows.push([/\.eth$/.test(d.name) ? "ENS" : "DNS", reg + "/ipns/" + d.name + "/" + path]); + if (short && (d.name || d.publisher)) rows.push(["Also", reg + "/ipns/" + (d.name || d.publisher) + "/" + img.repo + ":" + tg.tag]); + var box = el("div"); + rows.forEach(function (r) { + var cmd = "docker pull " + r[1], row = el("div", null, "cmd"); + row.appendChild(el("span", r[0], "k")); row.appendChild(el("code", cmd)); row.appendChild(copyButton(cmd)); + box.appendChild(row); + }); + return box; + } function loadImages() { return api("GET", "ipcr/images").then(function (d) { var box = $("images"); box.textContent = ""; box.className = ""; + var reg = d.registry || "ipcr.localhost:4767", n = 0; if (d.stagingError) box.appendChild(el("p", "Staging registry: " + d.stagingError, "warn")); - if (!d.images || !d.images.length) { box.appendChild(el("p", "Nothing published yet.", "muted")); return; } + if (!d.images || !d.images.length) { box.appendChild(el("p", "Nothing published yet.", "muted")); $("images-count").textContent = "0"; return; } d.images.forEach(function (img) { - var h = el("h3", img.repo + " "); + n += img.tags.length; + var card = el("div", null, "card"); card.style.marginBottom = "12px"; + var h = el("h3", (img.aliases && img.aliases.length ? img.aliases[0] + " · " : "") + img.repo + " "); h.appendChild(img.allowed ? badge("publishing", "ok") : badge("not allowed: new tags are not published", "warn")); - box.appendChild(h); - var wrap = el("div", null, "scroll"), t = el("table"), head = el("tr"); - ["Tag", "Image", "", ""].forEach(function (x) { head.appendChild(el("th", x)); }); - t.appendChild(head); + card.appendChild(h); img.tags.forEach(function (tg) { - var tr = el("tr"); - var td1 = el("td"); td1.appendChild(el("code", tg.tag)); - if (tg.at) td1.appendChild(el("div", tg.at.replace("T", " ").replace("Z", ""), "muted")); - var td2 = el("td"); td2.appendChild(el("code", "/ipfs/" + tg.cid)); - var td3 = el("td"), prov = el("span", "", "muted"); td3.appendChild(prov); - var td4 = el("td"), acts = el("div", null, "actions"); + var t = el("div", null, "tag"), head = el("div", null, "taghead"), left = el("div"); + left.appendChild(el("code", tg.tag)); + if (tg.at) left.appendChild(el("span", " " + tg.at.replace("T", " ").replace("Z", " UTC"), "muted")); + var prov = el("span", "", "muted"); prov.style.marginLeft = "10px"; left.appendChild(prov); + var acts = el("div", null, "actions"); var chk = el("button", "Findable?"); chk.onclick = function () { + prov.textContent = ""; prov.appendChild(spinner("asking the network…")); busy(chk, api("GET", "ipcr/providers?cid=" + encodeURIComponent(tg.cid))).then(function (p) { prov.textContent = ""; - prov.appendChild(p.self ? badge("✓ this node is listed", "ok") : badge("✗ not listed (" + p.providers + " other providers)", "warn")); + prov.appendChild(p.self ? badge("✓ findable: this node is listed", "ok") : badge("✗ not listed yet (" + p.providers + " other providers)", "warn")); if (!p.self) { var ann = el("button", "Announce"); ann.onclick = function () { busy(ann, api("POST", "ipcr/announce", { cid: tg.cid })).then(function () { toast("Queued: Kubo announces it within a minute or two; check again then.", 6000); }); }; prov.appendChild(document.createTextNode(" ")); prov.appendChild(ann); } - }); + }, function () { prov.textContent = ""; }); }; acts.appendChild(chk); if (tg.tag !== "latest") { @@ -318,48 +446,83 @@ }); }; acts.appendChild(un); - td4.appendChild(acts); - [td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); }); - t.appendChild(tr); + head.appendChild(left); head.appendChild(acts); + t.appendChild(head); + t.appendChild(pullCommands(reg, d, img, tg)); + card.appendChild(t); }); - (img.staging || []).filter(function (s) { return s.state !== "published"; }).forEach(function (s) { - var tr = el("tr"); - var td1 = el("td"); td1.appendChild(el("code", s.tag)); - var td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "in staging, not imported yet" : "in staging, not allowed"), "muted"); - var td3 = el("td"), td4 = el("td"); - if (img.allowed) { - var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now"); - re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued"); setTimeout(loadImages, 5000); }); }; - td4.appendChild(re); - } - [td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); }); - t.appendChild(tr); - }); - wrap.appendChild(t); box.appendChild(wrap); + var pending = (img.staging || []).filter(function (s) { return s.state !== "published"; }); + if (pending.length) { + card.appendChild(el("h3", "In staging, not published")); + var tbl = el("table"); + pending.forEach(function (s) { + var tr = el("tr"), td1 = el("td"), td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "not imported yet" : "repository not allowed"), "muted"), td3 = el("td"); + td1.appendChild(el("code", s.tag)); + if (img.allowed) { + var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now"); + re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued: imported on the next pass"); setTimeout(loadImages, 5000); }); }; + td3.appendChild(re); + } + tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tbl.appendChild(tr); + }); + card.appendChild(tbl); + } + box.appendChild(card); }); + $("images-count").textContent = String(n); }).catch(function (e) { $("images").textContent = "Images: " + e.message; }); } // ---- repositories + function loadSettings() { + return api("GET", "settings").then(function (st) { + if (document.activeElement !== $("org")) $("org").value = st.rootOrg || ""; + $("org-state").textContent = st.rootOrg ? "Publishing " + st.rootOrg + "'s repositories at the root." : "No root organisation."; + }).catch(function (e) { $("org-state").textContent = e.message; }); + } + $("org-form").onsubmit = function (e) { + e.preventDefault(); + var btn = this.querySelector("input[type=submit]"), st = $("org-state"); + st.textContent = ""; st.appendChild(spinner("Saving, then updating credentials and IPCR's list…")); + btn.disabled = true; + api("PUT", "settings", { rootOrg: $("org").value.trim() }) + .then(function (r) { toast(r.rootOrg ? r.rootOrg + " is the root organisation; short paths appear on IPCR's next pass" : "No root organisation"); return Promise.all([loadSettings(), loadRepos()]); }, + function (err) { toast(err.message, 8000); return loadSettings(); }) + .then(function () { btn.disabled = false; setTimeout(loadImages, 70000); }); + }; function loadRepos() { return api("GET", "repos").then(function (repos) { var box = $("repos"); box.textContent = ""; box.className = ""; if (!repos.length) { box.appendChild(el("p", "No public repository yet.", "muted")); return; } var t = el("table"), head = el("tr"); - ["Repository", "Publish images"].forEach(function (x) { head.appendChild(el("th", x)); }); + ["Repository", "Published as", "Push credential", "Publish"].forEach(function (x) { head.appendChild(el("th", x)); }); t.appendChild(head); repos.forEach(function (r) { - var tr = el("tr"), td1 = el("td"), td2 = el("td"), cb = document.createElement("input"); + var tr = el("tr"), td1 = el("td"), td2 = el("td"), td3 = el("td"), td4 = el("td"), cb = document.createElement("input"); td1.appendChild(el("code", r.full_name)); + (r.published_as || []).forEach(function (p, i) { var d = el("div"); d.appendChild(el("code", p + ":", i ? "muted" : "")); td2.appendChild(d); }); + if (!r.publish) td2.appendChild(el("span", "—", "muted")); + if (r.collision) td2.appendChild(el("div", "no short path: " + r.collision, "warn")); + if (r.publish) { + td3.appendChild(r.credential ? badge("✓ IPCR_PUSH_TOKEN set", "ok") : badge("✗ missing", "bad")); + var rot = el("button", "Reissue"); rot.style.marginLeft = "8px"; + rot.onclick = function () { + if (!confirm("Give " + r.full_name + " a new IPCR_PUSH_TOKEN? The old one stops working at once; running builds may fail.")) return; + busy(rot, api("POST", "repos/rotate", { full_name: r.full_name })).then(function () { toast("New token set on " + r.full_name); loadRepos(); }); + }; + td3.appendChild(rot); + } else td3.appendChild(el("span", "—", "muted")); cb.type = "checkbox"; cb.checked = r.publish; cb.onchange = function () { cb.disabled = true; api("PUT", "repos", { full_name: r.full_name, publish: cb.checked }) - .then(function () { cb.disabled = false; toast(r.full_name + (cb.checked ? ": publishing" : ": not publishing")); loadImages(); }, - function (e) { cb.disabled = false; cb.checked = !cb.checked; toast(e.message, 8000); }); + .then(function () { toast(r.full_name + (cb.checked ? ": publishing" : ": not publishing")); return loadRepos(); }, + function (e) { cb.checked = !cb.checked; toast(e.message, 8000); }) + .then(function () { cb.disabled = false; loadImages(); }); }; - td2.appendChild(cb); - tr.appendChild(td1); tr.appendChild(td2); t.appendChild(tr); + td4.appendChild(cb); + [td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); }); + t.appendChild(tr); }); var wrap = el("div", null, "scroll"); wrap.appendChild(t); box.appendChild(wrap); }).catch(function (e) { $("repos").textContent = "Repositories: " + e.message; }); @@ -404,7 +567,7 @@ }).catch(function (e) { toast(e.message, 8000); }); }; - loadStatus(false); loadImages(); loadRepos(); + loadStatus(false); loadImages(); loadRepos(); loadSettings(); })(); diff --git a/forge/web/index.html b/forge/web/index.html index 290b46f..947dc0b 100644 --- a/forge/web/index.html +++ b/forge/web/index.html @@ -131,9 +131,10 @@ git push -u origin main Applications).
  • Add a workflow at .gitea/workflows/build.yml. This one builds the Dockerfile at the root for every v* tag. It is a GitHub-style - workflow with two differences: the image goes to localhost:5000, this forge's - staging registry (no login, no secret), and buildx's builder runs with network=host - so it can reach that registry. + workflow with three differences: it logs in to localhost:5000, this forge's staging + registry, as the repository itself (the forge sets the IPCR_PUSH_TOKEN secret on + every public repository allowed to publish), the image goes there under the repository's own + name, and buildx's builder runs with network=host so it can reach that registry.
    name: image
     on:
       push:
    @@ -143,6 +144,11 @@ jobs:
         runs-on: ubuntu-latest
         steps:
           - uses: actions/checkout@v4
    +      - uses: docker/login-action@v3
    +        with:
    +          registry: localhost:5000
    +          username: ${{ github.repository }}
    +          password: ${{ secrets.IPCR_PUSH_TOKEN }}
           - uses: docker/metadata-action@v5
             id: meta
             with:
    @@ -163,12 +169,13 @@ jobs:
     
    git add .gitea && git commit -m "Build the image"
     git tag v1.0.0
     git push origin main v1.0.0
    - The image is published as <owner>/<repo>:1.0.0 under this forge's name.
  • + The image is published as <owner>/<repo>:1.0.0 under this forge's name. + Repositories of the forge's own organisation are also published at the root: + <repo>:1.0.0. -

    Who can publish: anyone who can push to a repository on this forge, since a build -can push any image name to the staging registry. Images are public once on IPFS, even when they come from -a private repository. Gitea holds a pull request from a fork until someone with write access approves -its workflow run: read the workflow before you approve.

    +

    Who can publish: each public repository publishes under its own name only, with its +own push credential; the short root names belong to the forge's organisation, so to its members. +Workflows from forks get no secrets, so they cannot publish. Private repositories never publish.

    Use an image

    Copy the line from Published images into a compose file. It works on this @@ -316,7 +323,9 @@ for IPFS peers. Details: