Files
ipcr-forge/Apps/IPCR-Forge/rationale.md
T
Worph 8351e2cc56 IPCR Forge: bridge 1.0.0 and DAC_READ_SEARCH so IPCR can import from Gitea
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.
2026-10-04 09:33:47 +00:00

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:

  1. A cross-app network link. ipcr-gateway joins gitea-internal, the network the standalone Gitea app reserves for Gitea and its runner.
  2. A cross-app credential. The bridge's install step writes a token into IPCR's folder (ipcr/secrets/registry-auth), which ipcr-gateway reads.
  3. A public landing page. ipcr-forge-<domain> is not behind the SSO gate.
  4. It replaces four apps and cannot be installed beside them: same container names, domains and host ports.
  5. One added capability. ipcr-gateway gets cap_add: [DAC_READ_SEARCH] on top of the standalone listing's cap_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_SEARCH bypasses 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-internal holds only gitea, gitea-runner and now ipcr-gateway. The gateway's only listener is the read-only registry API. Kubo's unauthenticated RPC API stays on ipcr-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:package cannot push, delete or change anything. The file is written as $PUID into a 0700 folder 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-init on its command line, as the Gitea app's own create-admin step does, and only the scoped tokens are written to disk.

  • The landing page cannot change anything. nginx-unprivileged runs as $PUID with a read-only root filesystem (a tmpfs /tmp only), no capabilities, no-new-privileges and a 32 MB limit. It mounts the page, its config and IPCR's state/ 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, and default.conf maps 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 in state/ 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.