Found on a fresh install (demostaging1): the Forge pinned bridge 0.1.0, which predates IPCR_AUTH_FILE, so IPCR's read-only Gitea token was never written. And ipcr-gateway (root, cap_drop ALL) could not read the token anyway: it is 0600, owned by $PUID, in a 0700 folder. Bump both bridge refs to 1.0.0 and give the gateway back DAC_READ_SEARCH (read-only), documented in rationale.md.
8.1 KiB
IPCR Forge — Rationale
IPCR Forge bundles four store apps (Radicle, Gitea, Radicle Gitea Bridge, IPCR) and a static landing page. Their services are copied verbatim, so each part keeps the deviations its standalone listing already argues. This document lists them, then covers only what the bundle adds.
What deviation / exception is being requested
Inherited, unchanged (the full argument is in each listing's rationale.md):
| From | Deviation |
|---|---|
| Radicle | No authentication gate on radicle- and radicle-api-<domain>; host port 8776/tcp |
| Gitea | gitea-runner is privileged (Docker-in-Docker); ALLOW_LOCALNETWORKS; admin account gitea_admin |
| Radicle Gitea Bridge | An install step signs in once with the default app password to mint scoped tokens |
| IPCR | ipcr-gateway and ipcr-registry run as root (no capabilities); /etc/docker/certs.d mounted; no authentication; ports 127.0.0.1:4767 and 4768/tcp+udp |
Added by the bundle:
- A cross-app network link.
ipcr-gatewayjoinsgitea-internal, the network the standalone Gitea app reserves for Gitea and its runner. - A cross-app credential. The bridge's install step writes a token into IPCR's folder
(
ipcr/secrets/registry-auth), whichipcr-gatewayreads. - A public landing page.
ipcr-forge-<domain>is not behind the SSO gate. - It replaces four apps and cannot be installed beside them: same container names, domains and host ports.
- One added capability.
ipcr-gatewaygetscap_add: [DAC_READ_SEARCH]on top of the standalone listing'scap_drop: [ALL].
Why it is necessary
1. The network link. IPCR publishes images by pulling them from Gitea's registry: it
polls the registry and imports every new tag into IPFS. The pull has to come from IPCR's
side, because Gitea's build jobs run in the runner's nested Docker, which cannot reach
ipcr.localhost (that name is the host's loopback). In the standalone apps the gateway pulls
over Gitea's public HTTPS address. In the bundle both are on the same server, so it uses
http://gitea:3000 over gitea-internal instead: no round trip through Caddy, and it keeps
working on a box with no public address. Gitea's registry hands out its bearer tokens at the
address the request came in on (http://gitea:3000/v2/token), so authentication stays
internal too.
2. The credential. Gitea's registry requires sign-in (REQUIRE_SIGNIN_VIEW), so IPCR
needs a token. Gitea mints tokens only for a caller holding the account password, and the
bridge's install step is the one place that already does that. It mints one more token,
ipcr-import, with read:package only, and writes it as a file because an install step can
produce files but not environment variables. ipcrd reads IMPORT_AUTH_FILE at the start of
every poll, so the file may appear after the gateway has started.
3. The landing page. It holds no data that isn't public already: links to Radicle (public
by design) and Gitea (behind its own login), documentation, and the list of images IPCR has
published, which are public on IPFS by definition. A login in front of it would protect
nothing and would stand between a developer and the image: line they came for.
4. Replacing the four apps. The bundle is the same services, not a different product.
Renaming every container, domain and port to coexist would break the defaults the bridge
and IPCR rely on (radicle-api:8080, gitea:3000) and the documented addresses users type,
for no use case: running two copies of the same forge on one server.
5. DAC_READ_SEARCH. The gateway runs as root (for the CA it drops into the host's Docker
trust store) with every capability dropped, and a root with no capabilities is bound by file
modes like any other user. The import token is written by the bridge's install step as $PUID,
mode 0600, in a 0700 folder, so without the capability the gateway cannot open it (verified
on a fresh install: open /data/secrets/registry-auth: permission denied, then 401 from
Gitea). DAC_READ_SEARCH restores exactly the missing half: reading files and traversing
directories regardless of their modes.
Security mitigations in place
-
The added capability is read-only and narrow.
DAC_READ_SEARCHbypasses read and directory-search checks only. It grants no write, ownership or privilege change, and reaches only what the gateway mounts: its own TLS folder and state, the token (mounted read-only) and the Docker trust store it already writes to. -
The network link is narrow.
gitea-internalholds onlygitea,gitea-runnerand nowipcr-gateway. The gateway's only listener is the read-only registry API. Kubo's unauthenticated RPC API stays onipcr-internal, which nothing from Gitea joins. Jobs run in the runner's nested daemon, on a network where none of these names resolve. -
The credential is read-only and contained.
read:packagecannot push, delete or change anything. The file is written as$PUIDinto a0700folder and mounted read-only into the gateway. It is written then renamed, so a half-written token is never read. Re-running the install step replaces the token in Gitea, which revokes the old one. -
The admin password is used once and never stored. It is passed to
bridge-initon its command line, as the Gitea app's owncreate-adminstep does, and only the scoped tokens are written to disk. -
The landing page cannot change anything.
nginx-unprivilegedruns as$PUIDwith a read-only root filesystem (a tmpfs/tmponly), no capabilities,no-new-privilegesand a 32 MB limit. It mounts the page, its config and IPCR'sstate/read-only, and serves static files only. -
It publishes one file of IPCR's state, not the folder.
state/is mounted outside the web root, anddefault.confmaps exactly one path to it:location = /images.json { alias /srv/ipcr-state/published.json; }. The watcher's own bookkeeping (imported.json, tag → digest) and anything else instate/answers 404. -
Every inherited mitigation still applies, because the services are unchanged. For example, the privileged runner can still be stopped without affecting the rest of the forge.
Alternatives considered and rejected
| Alternative | Why not |
|---|---|
| Gitea pushes to IPCR (webhook, or a workflow step) | A workflow step cannot reach ipcr.localhost. A webhook would give IPCR a write endpoint on a shared network; a pull needs no inbound access at all. |
Gateway pulls over the public gitea-<domain> address, as standalone |
Works, but routes a same-box fetch out through Caddy and back, and fails on a box with no public address. |
| An admin-password credential for IPCR | Simpler, but gives the gateway full control of Gitea for a job that only reads packages. |
IMPORT_AUTH as an environment variable |
An install step cannot set one, so it would be a manual step after every install. |
| Renaming everything so the bundle can sit beside the four apps | Breaks the defaults and the documented addresses (see 4). |
| No landing page, Radicle explorer as the tile | Leaves newcomers without the pipeline explained or the IPNS name to pull, which IPCR mints and nobody can guess. |
Data protection
Everything lives under /DATA/AppData/ipcr-forge/, one folder per part:
| Folder | Holds |
|---|---|
radicle/ |
node identity (key pair), seeded repositories, Caddy config |
gitea/ |
repositories, SQLite database, app.ini, runner registration, the runner's image store |
bridge/ |
cache/ (disposable bare repositories), secrets/gitea-token (0600) |
ipcr/ |
Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, secrets/registry-auth |
site/, web/ |
the landing page and its nginx config, re-rendered on every start |
No user directory (/DATA/Documents, Media, …) is mounted. The one file outside /DATA is
IPCR's /etc/docker/certs.d/ipcr.localhost:4767/ca.crt, as in the standalone app. Identities
that matter on the network (Radicle's node key, Kubo's node key and IPNS keys) survive
uninstall and reinstall, so published repositories and image names stay valid.