diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 1258312..9b8f3ca 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -1,13 +1,14 @@ -name: bridge +name: image -# IPCR Forge's bridge image (bridge/): Gitea → Radicle mirror + the forge's page. +# IPCR Forge's service image (forge/): Gitea → Radicle mirror, the forge's page and admin, the +# staging gate. ghcr.io/yundera/ipcr-forge; a `vX.Y.Z` tag publishes X.Y.Z. on: push: branches: [main] - tags: ["bridge-v*"] - paths: ["bridge/**", ".github/workflows/bridge.yml"] + tags: ["v*"] + paths: ["forge/**", ".github/workflows/image.yml"] pull_request: - paths: ["bridge/**", ".github/workflows/bridge.yml"] + paths: ["forge/**", ".github/workflows/image.yml"] workflow_dispatch: permissions: @@ -15,7 +16,7 @@ permissions: packages: write env: - IMAGE: ghcr.io/yundera/ipcr-forge-bridge + IMAGE: ghcr.io/yundera/ipcr-forge jobs: vet: @@ -24,9 +25,9 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: bridge/go.mod + go-version-file: forge/go.mod - run: test -z "$(gofmt -l .)" && go vet ./... && go test ./... - working-directory: bridge + working-directory: forge image: needs: vet @@ -45,17 +46,17 @@ jobs: with: images: ${{ env.IMAGE }} tags: | - type=match,pattern=bridge-v(.*),group=1 + type=semver,pattern={{version}} type=ref,event=branch type=sha # amd64 only: the Radicle CLI is fetched as its x86_64 release, and the Forge listing is # amd64 only. - uses: docker/build-push-action@v6 with: - context: bridge + context: forge push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: VERSION=${{ steps.meta.outputs.version }} - cache-from: type=gha,scope=bridge - cache-to: type=gha,mode=max,scope=bridge + cache-from: type=gha,scope=forge + cache-to: type=gha,mode=max,scope=forge diff --git a/Apps/IPCR-Forge/docker-compose.yml b/Apps/IPCR-Forge/docker-compose.yml index f69e9d1..0b3ee1c 100644 --- a/Apps/IPCR-Forge/docker-compose.yml +++ b/Apps/IPCR-Forge/docker-compose.yml @@ -10,15 +10,15 @@ name: ipcr-forge # → ipcr-kubo / ipcr-registry # docker pull ipcr.localhost:4767/ipns///:1.2.3 (anyone) # -# gitea ──system webhook──▶ ipcr-forge (bridge) ──rad init / git push rad──▶ radicle-node +# gitea ──system webhook──▶ ipcr-forge ──rad init / git push rad──▶ radicle-node # every PUBLIC repository, one way: Radicle is a distribution mirror signed by this node. # -# The tile opens Gitea. ipcr-forge- is the forge's own page, served by the bridge: how to +# The tile opens Gitea. ipcr-forge- is the forge's own page, served by the forge service: how to # publish, the published images with the `image:` line to use, and the Radicle mirrors. # # Gitea, Radicle and IPCR services are copied from their standalone listings (Apps/Gitea, # Apps/Radicle, Apps/IPCR), so each keeps its own comments and rationale.md; the differences are -# marked "Forge:". Data folders move, one subfolder per part: $AppID/{gitea,radicle,bridge,ci,ipcr}/. +# marked "Forge:". Data folders move, one subfolder per part: $AppID/{gitea,radicle,forge,ci,ipcr}/. # # Replaces the Gitea, Radicle and IPCR apps; it cannot sit beside them. It uses the same container # names, domains (gitea-, radicle-, radicle-api-) and host ports (8776, 4768, 127.0.0.1:4767). @@ -30,19 +30,19 @@ name: ipcr-forge services: # ═══ Bridge — the forge's page, and Gitea → Radicle ════════════════════════════ - # ipcr-forge-bridge (bridge/ in the IPCR repository). Receives Gitea's system webhook on :8081 + # ipcr-forge (forge/ in the ipcr-forge repository). Receives Gitea's system webhook on :8081 # (internal: no Caddy route) and mirrors every public repository to the Radicle node below, # 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.4.0 + image: ghcr.io/yundera/ipcr-forge:0.4.0 container_name: ipcr-forge user: $PUID:$PGID read_only: true cap_drop: [ALL] security_opt: ["no-new-privileges:true"] restart: unless-stopped - command: ["ipcr-forge-bridge", "serve"] + command: ["ipcr-forge", "serve"] cpu_shares: 30 deploy: resources: @@ -53,7 +53,7 @@ services: GITEA_URL: http://gitea:3000 GITEA_OWNER: gitea_admin SECRETS_DIR: /secrets - STATE_DIR: /bridge + STATE_DIR: /forge RAD_HOME: /radicle-home IPCR_PUBLISHED: /srv/ipcr-state/published.json RECONCILE: 10m @@ -77,9 +77,9 @@ services: # radicle-api has. - ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/ # repos.json (Gitea repository → Radicle ID) and one bare mirror per repository. - - ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge + - ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/state:/forge # The Gitea token and the webhook secret the install step writes. Read-only here. - - ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro + - ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/secrets:/secrets:ro # 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 @@ -89,7 +89,7 @@ services: - gitea - radicle-node healthcheck: - test: ["CMD", "ipcr-forge-bridge", "health"] + test: ["CMD", "ipcr-forge", "health"] interval: 30s timeout: 5s retries: 3 @@ -160,7 +160,7 @@ services: # Forge: off. Images go to the staging registry that IPCR follows; a second registry would # be a second place an image could be published from. GITEA__packages__ENABLED: "false" - # Forge: webhooks may reach the bridge (ipcr-forge, on this server's network) and, as by + # Forge: webhooks may reach the forge service (ipcr-forge, on this server's network) and, as by # default, any public address. Not `private`: that would let any user's webhook reach every # container on the shared network. GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge @@ -277,11 +277,11 @@ services: # 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 + # as its repository (/, the IPCR_PUSH_TOKEN secret the forge service 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. + # Same image as the forge service (`ipcr-forge gate`, forge/gate.go). See rationale.md. forge-gate: - image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0 + image: ghcr.io/yundera/ipcr-forge:0.4.0 container_name: forge-gate user: $PUID:$PGID read_only: true @@ -289,7 +289,7 @@ services: security_opt: ["no-new-privileges:true"] restart: unless-stopped network_mode: service:forge-docker - command: ["ipcr-forge-bridge", "gate"] + command: ["ipcr-forge", "gate"] cpu_shares: 20 deploy: resources: @@ -301,8 +301,8 @@ services: 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 + # Hashes only, written by the forge service. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/state/gate:/gate:ro - ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging depends_on: - forge-docker @@ -342,7 +342,7 @@ services: - ${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 + # Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The forge service # creates and pushes repositories in this node's storage; the node replicates them. # The web UI: the Radicle Explorer bundle served as static files by Caddy. @@ -499,7 +499,7 @@ 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 + # Forge: IPCR's login at the staging registry's gate, generated on first start; the forge service # 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" @@ -508,8 +508,8 @@ 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. + # Forge: publish only the repositories the forge service allows (public Gitea repositories, minus + # those switched off in the admin pages). Until the forge service 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). @@ -518,7 +518,7 @@ services: # 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. + # Forge: the admin API, for the forge service, on ipcr-admin. Bearer token in state/admin-token. ADMIN_LISTEN: ":4769" IMPORT_INTERVAL: 60s STATE_DIR: /data/state @@ -547,7 +547,7 @@ 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. + # Forge only: the admin API, reached by the forge service. - ipcr-admin cpu_shares: 50 deploy: @@ -841,15 +841,15 @@ x-compose-app: schema_version: 2 folders: # ── Bridge ── - - path: /DATA/AppData/$AppID/bridge/state + - path: /DATA/AppData/$AppID/forge/state user: $PUID group: $PGID - - path: /DATA/AppData/$AppID/bridge/secrets + - path: /DATA/AppData/$AppID/forge/secrets 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 + # The gate's credentials (hashes), written by the forge service, read by the gate. + - path: /DATA/AppData/$AppID/forge/state/gate user: $PUID group: $PGID # ── Gitea ── @@ -897,7 +897,7 @@ 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 + # Created ahead of Kubo's first start: the forge service 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 @@ -927,7 +927,7 @@ x-compose-app: mode: "0755" init: # In order: Radicle's identity before its node starts, then Gitea's admin, then the runner - # token and the bridge's setup, which both need that admin to exist. + # token and the forge service's setup, which both need that admin to exist. - name: radicle-identity image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2 user: $PUID:$PGID @@ -969,21 +969,21 @@ 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 tokens (public-only read; and write:repository for push secrets), the + # The forge service'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.4.0 + image: ghcr.io/yundera/ipcr-forge:0.4.0 user: $PUID:$PGID phase: post_up network: $APP_NET volumes: - - /DATA/AppData/$AppID/bridge/secrets:/secrets + - /DATA/AppData/$AppID/forge/secrets:/secrets command: - sh - -c - - 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 + - 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 setup when: always webui-host: gitea-${domain} webui-path: / diff --git a/Apps/IPCR-Forge/rationale.md b/Apps/IPCR-Forge/rationale.md index bce7c60..9abec5b 100644 --- a/Apps/IPCR-Forge/rationale.md +++ b/Apps/IPCR-Forge/rationale.md @@ -1,7 +1,7 @@ # IPCR Forge — Rationale IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon, -and the bridge (`ipcr-forge-bridge`), which mirrors public Gitea repositories to Radicle and +and the forge service (`ipcr-forge`), which mirrors public Gitea repositories to Radicle and serves the forge's page. The Gitea, Radicle and IPCR services are copied from their listings, so each keeps the deviations its standalone listing already argues. This document lists them, then covers only what the bundle adds. @@ -23,11 +23,11 @@ with. See docs/forge.md. **Added by the bundle:** 1. **`forge-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did. -2. **The bridge (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's +2. **The forge service (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's key. It publishes repositories signed by that key. 3. **A system webhook and its allow-list.** Gitea may deliver webhooks to `ipcr-forge` on the server's network (`GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge`). -4. **An install step that uses the admin password** to mint the bridge's token and to register +4. **An install step that uses the admin password** to mint the forge service's token and to register that webhook. 5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry is. @@ -37,15 +37,15 @@ with. See docs/forge.md. 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. +10. **The forge service 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 +13. **A second Gitea token for the forge service,** 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. + forge service. 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. 16. **A public, unauthenticated, read-only registry route** (`ipcr-` → `ipcr-public`), so this @@ -65,11 +65,11 @@ container that reaches the daemon over a unix socket. **2. The node's home.** Publishing to Radicle means three things: creating a repository in the node's storage, signing its refs with the node's key, and announcing them through the node's -control socket. Those live in that folder. This is the mount `radicle-api` already has. The bridge +control socket. Those live in that folder. This is the mount `radicle-api` already has. The forge service uses the stock `rad` CLI (1.10.1, the node's version) rather than reimplementing any of it. **3. The webhook.** Pushes reach Radicle within seconds instead of at the next 10-minute -reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the bridge sits +reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the forge service sits on one. The list adds exactly one host name. `private` would let any user's webhook reach every container on the shared network. @@ -105,7 +105,7 @@ 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. +folder; giving it `DAC_READ_SEARCH` was rejected once already. The forge service 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 @@ -127,7 +127,7 @@ repository it belongs to. Setting a secret takes `write:repository`. The token i `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 +cannot write into another uid's folder, so it writes into its own state folder, which the forge service already mounts read-only. **15. The root organisation.** Short names (`metadec.eth/ipcr-hello`) need one owner whose @@ -141,17 +141,17 @@ purpose: the images are public on IPFS anyway. ## Security mitigations in place -**The bridge** +**The forge service** - **It sees public repositories only.** Its Gitea token is scoped `public-only` and `read:repository`. Gitea hides every private or limited repository from it, and it can write nothing in Gitea. The code checks visibility a second time. Forks and pull mirrors are skipped. - **Webhooks are authenticated and carry no content.** Each delivery must pass the HMAC-SHA256 - check against a random secret written at install (`bridge/secrets`, 0700). A delivery only says - *which* repository changed. The bridge re-reads that repository from Gitea, so a forged payload + check against a random secret written at install (`forge/secrets`, 0700). A delivery only says + *which* repository changed. The forge service re-reads that repository from Gitea, so a forged payload can at most cause a pointless sync. Port 8081 has no Caddy route. - **It runs unprivileged:** `$PUID`, every capability dropped, a read-only root filesystem, `no-new-privileges`, 256 MB. One worker does one sync at a time. -- **The node's key stays in the bridge and the node.** No CI job mounts the Radicle home. +- **The node's key stays in the forge service and the node.** No CI job mounts the Radicle home. **The CI daemon** - **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an @@ -202,8 +202,8 @@ purpose: the images are public on IPFS anyway. - **Can be switched off** in the admin pages (`config.json` `"public": false`). **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. +- **The public page cannot change anything.** The forge service serves it read-only: the embedded page, + IPCR's `published.json` (mounted read-only) and the forge service'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 @@ -215,12 +215,12 @@ purpose: the images are public on IPFS anyway. 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 + the forge service 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, +- **The cleartext key never crosses the network.** The forge service 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 +- **The allowlist is closed by default.** Until the forge service sends its list, nothing is published (`IMPORT_ALLOW_REQUIRED`). Private repositories are never on it. ## Alternatives considered and rejected @@ -234,7 +234,7 @@ purpose: the images are public on IPFS anyway. | A per-repository webhook | Every repository would need setup; one system webhook covers all of them, including new ones | | Gitea's package registry as the staging registry (v1) | Needs tokens for the workflow and for IPCR, plus the `DAC_READ_SEARCH` capability for IPCR to read its token. It would also be a second place images get published from | | Host Docker socket for jobs | Any workflow would get root on the host | -| A long-lived admin token for the bridge | The webhook is repaired by the install step on every start instead, so the running service never holds admin rights | +| A long-lived admin token for the forge service | The webhook is repaired by the install step on every start instead, so the running service never holds admin rights | ## Known limitations @@ -252,7 +252,7 @@ purpose: the images are public on IPFS anyway. 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 +- **The forge service can read the publisher key.** A compromise of the public-facing forge service 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 @@ -268,7 +268,7 @@ 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`); `gate/credentials.json` (token hashes); `secrets/` (0700): its two Gitea tokens, the webhook secret, the OAuth2 client credentials | +| `forge/` | `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 | diff --git a/README.md b/README.md index 3f1d4b3..648c617 100644 --- a/README.md +++ b/README.md @@ -1 +1,34 @@ -# ipcr-forge \ No newline at end of file +# IPCR Forge + +A self-hosted forge whose releases live on IPFS: work in **Gitea** as usual, its Actions build your +images, the [IPCR](https://github.com/Yundera/ipcr) engine publishes them under one IPNS name (and +your ENS or DNS name), and every public repository is mirrored to **Radicle**. + +``` +git push origin v1.0.0 +docker pull ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0 # any host running IPCR +docker pull ipcr-/ipns/metadec.eth/ipcr-hello:1.0.0 # anywhere, through the forge +``` + +This repository is the add-on; [Yundera/ipcr](https://github.com/Yundera/ipcr) is the engine (the +registry gateway, IPFS import, IPNS naming and admin API), used here as the image +`ghcr.io/yundera/ipcr`. + +- **[docs/forge.md](docs/forge.md)**: how it works, the trust model, findings, what was verified. +- **[Apps/IPCR-Forge/](Apps/IPCR-Forge/)**: the Yundera AppStore listing (compose, rationale, seed). +- **[forge/](forge/)**: the forge service, `ghcr.io/yundera/ipcr-forge` (Go, standard library only): + the Gitea → Radicle mirror, the forge's page and admin pages (Gitea login), the staging registry's + gate and per-repository push credentials, and the install step. +- **[Apps/Gitea/](Apps/Gitea/)**, **[Apps/Radicle/](Apps/Radicle/)**: the standalone listings the + forge is built from. + +## Develop + +```sh +cd forge && go vet ./... && go test ./... +docker build -t ghcr.io/yundera/ipcr-forge:dev forge +``` + +Releases: a `vX.Y.Z` tag publishes `ghcr.io/yundera/ipcr-forge:X.Y.Z` +([.github/workflows/image.yml](.github/workflows/image.yml)). The listing pins both images: this +one and the engine's. diff --git a/docs/forge.md b/docs/forge.md index d90aae8..d2f0250 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -1,5 +1,11 @@ # IPCR Forge — design notes +This repository is the forge, an add-on to the IPCR engine +([Yundera/ipcr](https://github.com/Yundera/ipcr): the registry gateway, the IPFS import, the IPNS +naming, the admin API). The engine knows nothing about Gitea, Radicle or this forge. The forge uses +the engine's image (`ghcr.io/yundera/ipcr`) and the contract listed in its +[design notes](https://github.com/Yundera/ipcr/blob/main/docs/design.md#contract-with-add-ons). + How the forge in [Apps/IPCR-Forge/](../Apps/IPCR-Forge/) goes from `git push` to `docker pull`, why it is shaped this way, and what was learned building it. The listing's own security argument is in [rationale.md](../Apps/IPCR-Forge/rationale.md). @@ -24,8 +30,8 @@ A forge people actually want to use, whose releases do not depend on it staying | **v3** (this) | **Gitea** | **Gitea Actions** | — | v3 keeps what worked in both: v1's Gitea + Actions setup, and v2's staging registry inside the -CI's own Docker daemon (no tokens, no registry credentials). The bridge is v1's glue reversed and -rewritten in Go ([bridge/](../bridge/)). +CI's own Docker daemon (no tokens, no registry credentials). The forge service is v1's glue reversed and +rewritten in Go ([forge/](../forge/)). The objection that removed Gitea in v2 was "two places where state can originate". It does not apply here: state originates in Gitea only. Radicle receives a copy that nobody edits; patches or issues @@ -37,7 +43,7 @@ opened on the Radicle side are not read. developer: git push origin main v1.2.3 (or: create a tag in Gitea's UI) │ ▼ -gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (bridge) +gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (forge service) │ · git fetch from Gitea │ Actions · rad init (first time) ▼ · git push rad (signed by the node) @@ -54,9 +60,9 @@ anyone: docker pull ipcr.localhost:4767/ipns///…` image-naming idea in - [naming.md](naming.md) is weaker for it. + [naming.md](https://github.com/Yundera/ipcr/blob/main/docs/naming.md) is weaker for it. - **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 + the forge service 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. - **Each repository publishes its own name only.** The staging registry sits behind a gate (see @@ -117,17 +123,17 @@ code checks visibility again anyway. ## Admin -`https://ipcr-forge-/admin`. It is served by the bridge and only lets in Gitea site +`https://ipcr-forge-/admin`. It is served by the forge service 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 +host. The flow is the authorization code with PKCE and the `read:user` scope. The forge service 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 +the key lives in `forge/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 +**Behind the page.** The page calls the forge service, and the forge service calls ipcrd's admin API +([`gateway/admin.go`](https://github.com/Yundera/ipcr/blob/main/gateway/admin.go) in the IPCR engine). That API listens on `:4769`, on the `ipcr-admin` network (internal, forge service and gateway only), and needs a Bearer token from `ipcr/state/admin-token`. | Section | What it does | @@ -145,12 +151,12 @@ ipfs key import forge key ``` Then publish with a sequence number above the network's. -**Backup format** (`gateway/keybackup.go`, copied in `bridge/`): +**Backup format** ([`gateway/keybackup.go`](https://github.com/Yundera/ipcr/blob/main/gateway/keybackup.go) in the engine, copied in `forge/`): - JSON containing PBKDF2-SHA256 (600k iterations) and AES-256-GCM; - the additional authenticated data binds the key's name and its `k51…`; - the plaintext is exactly what `ipfs key export` writes. -The bridge seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the +The forge service seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the network. ## Root organisation (short names) @@ -163,7 +169,7 @@ 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": +- **How it is passed on:** the forge service 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 @@ -212,8 +218,8 @@ IPCR ── Basic ipcr: ── forge-docker:5000 ────── ▼ 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 +- **`forge-gate`** is `ipcr-forge gate`, built from the same image. + - It checks Basic auth against `forge/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. @@ -223,19 +229,19 @@ IPCR ── Basic ipcr: ── forge-docker:5000 ────── 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 + - **Per repository:** the forge service 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. + `state/staging-auth`), and the forge service 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): +[forge/example/.gitea/workflows/build.yml](../forge/example/.gitea/workflows/build.yml): - `docker/login-action` to `localhost:5000`, `username: ${{ github.repository }}`, `password: ${{ secrets.IPCR_PUSH_TOKEN }}`. @@ -261,35 +267,35 @@ Carried over from v1 and v2; each one silently produced the wrong result rather it first. 5. **Radicle has no shared `refs/tags/`** unless the identity carries a canonical-refs rule. Without one, a tag lives under its pusher's namespace, where `rad clone` and the explorer do not show it. - The bridge adds the rule right after `rad init`. + The forge service adds the rule right after `rad init`. New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04): -6. **`rad init` works on a bare repository** (it uses `workdir().unwrap_or(path)`), so the bridge +6. **`rad init` works on a bare repository** (it uses `workdir().unwrap_or(path)`), so the forge service keeps bare mirrors only. It also seeds the repository on the node and adds the `rad` remote. 7. **A deleted tag stays canonical.** `git push --prune` removes it from the node's namespace, but `refs/tags/` keeps the last target. Tags are meant to be immutable, so this was left alone. -8. **Gitea's default webhook allow-list (`external`) blocks the bridge.** It is on the server's +8. **Gitea's default webhook allow-list (`external`) blocks the forge service.** It is on the server's private network. `ALLOWED_HOST_LIST: external,ipcr-forge` allows exactly that one container. `private` would let any user's webhook reach every container on the shared network. 9. **`rad sync --announce` takes its RID positionally** (`rad sync --announce`); there is no `--rid` flag. -10. **`rad sync` blocks for its timeout when the node has no peers.** The bridge pushes with +10. **`rad sync` blocks for its timeout when the node has no peers.** The forge service pushes with `-o no-sync` and announces separately with a 30-second bound, so a push is never held up by the network. 11. **`act_runner` needs about 1 GB.** It holds a job's checkout and actions in memory while it copies them into the job container. At 256 MB it was OOM-killed mid-job. The job container then ran on, orphaned, and Gitea showed the run as "running" until it timed out. -12. **The first `rad init` can fail if the node is still starting.** The bridge's retry adopts the +12. **The first `rad init` can fail if the node is still starting.** The forge service's retry adopts the RID that `rad init` already wrote into the mirror's `rad` remote, so no second repository is - created (seen on holyhorse). `rad` prints its errors on stdout, which the bridge now logs. + created (seen on holyhorse). `rad` prints its errors on stdout, which the forge service now logs. 13. **Gitea's API returns `html_url` built from the internal address** (`http://gitea:3000/…`) when called through it. The page builds Gitea links from its own host name instead. 14. **Right after `up`, new domains answer with the gateway's SSO redirect** until Caddy picks up the labels (about a minute). The runner's registration retries through that window. 15. **`key/export` is CLI-only** in Kubo (`NoRemote`), and ipcrd, which runs as root without - capabilities, cannot read the `$PUID` 0700 keystore. The bridge reads `key_` + capabilities, cannot read the `$PUID` 0700 keystore. The forge service reads `key_` (`forge` → `key_mzxxez3f`, mode 0400) through a read-only mount. Those bytes are what `ipfs key export` would write. 16. **Delegated routing does not use the spec's 404.** For a name it doesn't know, delegated-ipfs.dev @@ -310,7 +316,7 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04): 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 service 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 @@ -327,7 +333,7 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04): ## Verified -**Locally** (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the bridge image, one Docker network): +**Locally** (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the forge service image, one Docker network): - `setup` run twice leaves exactly one system hook. - `ipcr-hello` was created and mirrored with the crefs rule in its identity. @@ -390,11 +396,12 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04): - **Pull from a second IPCR server** (`ipcr.localhost`): verified on holyhorse itself only, though the public front door was pulled from another machine; v2's images pulled across IPCR servers. -- **Publish the images**: `ghcr.io/yundera/ipcr:1.5.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.4.0` - (workflows `image.yml` and `bridge.yml`, tags `v1.5.0` and `bridge-v0.4.0`). +- **Publish the images**: `ghcr.io/yundera/ipcr:1.5.0` and `ghcr.io/yundera/ipcr-forge:0.4.0` + (`ghcr.io/yundera/ipcr` from the engine repository, tag `v1.5.0`; `ghcr.io/yundera/ipcr-forge` + from this one, tag `v0.4.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 +- **Private repositories** cannot publish: the forge service 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. diff --git a/forge/Dockerfile b/forge/Dockerfile index a957fb2..6b081e4 100644 --- a/forge/Dockerfile +++ b/forge/Dockerfile @@ -1,10 +1,10 @@ -# ipcr-forge-bridge: the Gitea → Radicle mirror and IPCR Forge's page. Our Go binary, plus git and +# ipcr-forge: the Gitea → Radicle mirror and IPCR Forge's page. Our Go binary, plus git and # the stock Radicle CLI (`rad`, `git-remote-rad`) at the version the forge's node runs. -# Build context: this folder (bridge/). +# Build context: this folder (forge/). FROM golang:1.24-alpine AS build WORKDIR /src COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ipcr-forge-bridge . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ipcr-forge . # Radicle's official static (musl) release, checked against its published digest. FROM alpine:3.22 AS radicle @@ -21,17 +21,17 @@ RUN apk add --no-cache xz \ FROM alpine:3.22 ARG VERSION=dev -LABEL org.opencontainers.image.title="ipcr-forge-bridge" \ +LABEL org.opencontainers.image.title="ipcr-forge" \ org.opencontainers.image.description="IPCR Forge: mirrors public Gitea repositories to Radicle, serves the forge's page" \ - org.opencontainers.image.source="https://github.com/Yundera/ipcr" \ + org.opencontainers.image.source="https://github.com/Yundera/ipcr-forge" \ org.opencontainers.image.version="$VERSION" RUN apk add --no-cache ca-certificates git \ # Mirrors and the node's storage belong to the service's uid, but a bind mount can make git see # another owner ("dubious ownership"); the storage is the node's own, so trust it. && git config --system safe.directory '*' COPY --from=radicle /out/ /usr/local/bin/ -COPY --from=build /out/ipcr-forge-bridge /usr/local/bin/ipcr-forge-bridge +COPY --from=build /out/ipcr-forge /usr/local/bin/ipcr-forge ENV RAD_HOME=/radicle-home \ HOME=/tmp -# No entrypoint: the install step runs `sh -c '… ipcr-forge-bridge setup'` in this image. -CMD ["ipcr-forge-bridge", "serve"] +# No entrypoint: the install step runs `sh -c '… ipcr-forge setup'` in this image. +CMD ["ipcr-forge", "serve"] diff --git a/forge/gate.go b/forge/gate.go index bf4297b..ebdfc24 100644 --- a/forge/gate.go +++ b/forge/gate.go @@ -17,12 +17,12 @@ import ( "time" ) -// The gate is the staging registry's front door (`ipcr-forge-bridge gate`). CI jobs push to +// The gate is the staging registry's front door (`ipcr-forge 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 +// forge service 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 @@ -34,7 +34,7 @@ import ( // 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. +// gateCreds is credentials.json, written by the forge service: 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 diff --git a/forge/go.mod b/forge/go.mod index 223d697..964fece 100644 --- a/forge/go.mod +++ b/forge/go.mod @@ -1,3 +1,3 @@ -module ipcr-forge-bridge +module ipcr-forge go 1.24 diff --git a/forge/keybackup.go b/forge/keybackup.go index c01d33f..c99f269 100644 --- a/forge/keybackup.go +++ b/forge/keybackup.go @@ -12,7 +12,7 @@ package main // and exports it (`ipfs key export`, libp2p-protobuf-cleartext). The additional data binds name and // id to the ciphertext, so neither can be edited. Byte slices are base64 (encoding/json's default). // -// A copy of gateway/keybackup.go (ipcrd), without its CLI: the bridge writes backups (it can read +// A copy of gateway/keybackup.go (ipcrd), without its CLI: the forge service writes backups (it can read // Kubo's keystore; ipcrd cannot) and ipcrd reads them, so the cleartext key never crosses the // network. Keep the two identical; both test the same vector. diff --git a/forge/main.go b/forge/main.go index eafd9be..c927369 100644 --- a/forge/main.go +++ b/forge/main.go @@ -1,4 +1,4 @@ -// ipcr-forge-bridge — the glue of IPCR Forge: Gitea is where people work, Radicle is where the code +// ipcr-forge — the glue of IPCR Forge: Gitea is where people work, Radicle is where the code // is replicated, and this service keeps the second following the first. One way only. // // serve (default) webhook receiver + mirror worker + the forge's page @@ -21,7 +21,7 @@ // GITEA_OWNER the admin account (default gitea_admin) // GITEA_PASSWORD setup only: its password, used once to mint the token and the hook // SECRETS_DIR gitea-token and hook-secret (default /secrets) -// STATE_DIR repos.json and the bare mirrors (default /bridge) +// STATE_DIR repos.json and the bare mirrors (default /forge) // RAD_HOME the node's Radicle home (default /radicle-home) // WEB_LISTEN the page (default :8080) // HOOK_LISTEN the webhook, internal only (default :8081) @@ -42,7 +42,7 @@ // 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) +// GATE_CREDENTIALS the gate's credentials.json (default /forge/gate/credentials.json) // IPCR_STAGING_AUTH IPCR's own staging credential (default /srv/ipcr-state/staging-auth) package main @@ -104,7 +104,7 @@ func serve() error { if err != nil { return fmt.Errorf("RECONCILE: %w", err) } - stateDir := env("STATE_DIR", "/bridge") + stateDir := env("STATE_DIR", "/forge") st, err := loadState(stateDir + "/repos.json") if err != nil { return err @@ -166,11 +166,11 @@ func serve() error { log.Printf("allowlist: %v", err) } } - log.Printf("bridge: admin pages on, IPCR admin API %s", ipcr) + log.Printf("forge: admin pages on, IPCR admin API %s", ipcr) } go reconcileLoop(m, q, every) - log.Printf("bridge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every) + log.Printf("forge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every) return http.ListenAndServe(env("WEB_LISTEN", ":8080"), mux) } diff --git a/forge/setup.go b/forge/setup.go index 4329e13..743d98b 100644 --- a/forge/setup.go +++ b/forge/setup.go @@ -20,7 +20,7 @@ import ( var exampleFiles embed.FS const ( - tokenName = "ipcr-forge-bridge" + tokenName = "ipcr-forge" adminTokenName = "ipcr-forge-secrets" ) diff --git a/forge/state.go b/forge/state.go index ca7d182..55d9440 100644 --- a/forge/state.go +++ b/forge/state.go @@ -19,7 +19,7 @@ const ( stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted ) -// repoState is what the bridge remembers per Gitea repository, keyed by Gitea's numeric ID so a +// repoState is what the forge service remembers per Gitea repository, keyed by Gitea's numeric ID so a // rename or a transfer keeps its Radicle ID. type repoState struct { ID int64 `json:"id"` diff --git a/forge/web/admin.html b/forge/web/admin.html index 78dd02f..211df95 100644 --- a/forge/web/admin.html +++ b/forge/web/admin.html @@ -6,8 +6,8 @@ IPCR Forge admin