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.
124 lines
8.1 KiB
Markdown
124 lines
8.1 KiB
Markdown
# 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.
|