IPCR Forge: org-root image names and per-repository push credentials

Short names: the public repositories of one Gitea organisation (IPCR_ROOT_ORG,
then the admin page; metadec on our boxes) are also published at the root of
the forge's name, so ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0
works. Collisions (a user/org of that name, another owner's folder) are
skipped and explained.

Push credentials: the staging registry now sits behind forge-gate
(ipcr-forge-bridge gate), in the CI daemon's network namespace, with the
registry on a unix socket and no network. Each publishing repository logs in
as <owner>/<repo> with an IPCR_PUSH_TOKEN Actions secret the bridge sets, and
may push its own name only; IPCR logs in with a credential it generates.
Workflows need a docker/login-action step (example and page updated).

ipcr 1.4.0: allow.aliases with per-pass alias sync; unpublish/retag on every
path; IMPORT_AUTH_GENERATE; names this node publishes resolve via
name/resolve --nocache (Kubo's resolve cache lagged, and the generic resolve
ignores nocache).

bridge 0.3.0: gate, push credentials (rotate in the admin page), root
organisation setting, setup creates the org and the example in it; the
Radicle description carries the Gitea path (identical documents gave the same
RID); OAuth2 app updated only when hosts change, keeping the new secret.
Admin page: tabs, one-line keys, name-check feedback, per-tag docker pull
commands (IPFS, IPNS, ENS, short path first).

Verified on holyhorse (docs/forge.md).
This commit is contained in:
Worph
2026-10-05 11:37:33 +00:00
parent b65bb867c9
commit c961d74e0e
15 changed files with 1275 additions and 189 deletions
+106 -11
View File
@@ -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 `<owner>/<repo>` 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/<repo>`.
- **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:<generated> ── 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/<owner>/<repo>/{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/<forge>/<owner>/<repo>:<tag>`.
@@ -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/<tree>`.
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:** `<key>-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.