IPCR Forge v2: Radicle-only with rad-actions
Snapshot of the Radicle-only forge (cib + act, DinD staging registry) before the Gitea-centric v3 POC replaces it. Also carries the gateway's IMPORT_PUBLISHER mode, multi-segment IPNS paths and IPCR 1.2.0.
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
# 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.
|
||||
IPCR Forge bundles two store apps (Radicle, IPCR), the node's own CI (`rad-actions`) and a static
|
||||
landing page. The Radicle and IPCR services are copied verbatim, so each keeps the deviations its
|
||||
standalone listing already argues. This document lists them, then covers only what the bundle adds.
|
||||
|
||||
Earlier versions built with Gitea Actions, fed by a bridge that copied every Radicle repository
|
||||
into Gitea. That made two places where state could start (Gitea held a second copy of each
|
||||
repository, with its own PRs, issues and registry) and needed a glue service to keep them in step.
|
||||
Gitea, its runner and the bridge are gone; the CI now runs on the Radicle node itself.
|
||||
|
||||
## What deviation / exception is being requested
|
||||
|
||||
@@ -12,98 +16,97 @@ bundle adds.
|
||||
| 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]`.
|
||||
1. **`rad-actions-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did.
|
||||
2. **`rad-actions` mounts the Radicle node's home read-write**, including the node's key.
|
||||
3. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry is.
|
||||
4. **A public landing page, including the CI's pages.** `ipcr-forge-<domain>` and its `/ci/`
|
||||
(run reports and logs) are not behind the SSO gate.
|
||||
5. **It replaces two apps** and cannot be installed beside them: same container names, domains
|
||||
and host ports.
|
||||
|
||||
## 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.
|
||||
**1. Privileged Docker-in-Docker.** Building an image needs a Docker daemon. The two ways to give
|
||||
a CI job one are the host's socket (root on the PCS for every workflow) or a daemon of its own,
|
||||
which needs `privileged`. The second confines a build to its own container and image store. The
|
||||
rootless variant does not start on this platform (`kernel.apparmor_restrict_unprivileged_userns=1`;
|
||||
see Apps/Gitea/rationale.md, where it was tried first).
|
||||
|
||||
**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.
|
||||
**2. The node's home.** The CI broker (`cib`, upstream `radicle-ci-broker`) is a client of the
|
||||
node: it subscribes to the node's events over the control socket in that folder, reads repository
|
||||
storage to check out a commit, and writes each run's result back into the repository as a job COB,
|
||||
which the node's key signs. This is the mount `radicle-api` already has. It is what removes the
|
||||
mirror: the build reads the commit where it lives, so there is no copy to drift.
|
||||
|
||||
**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.
|
||||
**3. The network link.** IPCR publishes images by *pulling* them: it polls a registry and imports
|
||||
every new tag into IPFS. Jobs run in the CI's own daemon, which cannot reach `ipcr.localhost` (that
|
||||
name is the host's loopback), so a workflow pushes to a staging registry instead. That registry
|
||||
shares the daemon's network namespace, so it is `localhost:5000` to every job — the only address
|
||||
Docker and BuildKit accept plain HTTP for, so no TLS or credentials are involved. `ipcr-gateway`
|
||||
reaches it as `rad-actions-docker:5000` over `ci-internal`.
|
||||
|
||||
**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.
|
||||
**4. The public pages.** The landing page holds links, documentation and image names that are
|
||||
public on IPFS anyway. `/ci/` shows `cib`'s report pages and the run logs. Radicle repositories are
|
||||
public by design, and the adapter refuses to run a private repository, so a log never shows code
|
||||
that is not already public. A login in front would stand between a developer and their build log.
|
||||
|
||||
**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.
|
||||
**5. Replacing two apps.** The bundle is the same services, not a different product. Renaming every
|
||||
container, domain and port to coexist would break the defaults the services rely on
|
||||
(`radicle-api:8080`) and the documented addresses users type.
|
||||
|
||||
## 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.
|
||||
- **Only delegates can start a build.** `cib`'s filter (ci/ci-broker.yaml) runs CI only for changes
|
||||
that come from a delegate of the repository (`AnyDelegate`): release tags, the default branch and
|
||||
their patches. A stranger's patch is never run. This matters because a build can publish: what a
|
||||
job pushes to the staging registry ends up on IPFS under the repository's IPNS name. A build
|
||||
therefore gets exactly the trust of a release.
|
||||
- **The privileged daemon has no network listener.** `dockerd` gets an explicit
|
||||
`--host=unix:///run/dind/docker.sock` and `DOCKER_TLS_CERTDIR` is empty, so the entrypoint adds no
|
||||
TCP socket. The unix socket sits in a folder shared only with `rad-actions`, group `$PGID`.
|
||||
- **`rad-actions` itself runs unprivileged:** `$PUID`, every capability dropped,
|
||||
`no-new-privileges`, 512 MB. Whoever controls it controls the daemon (that is the point of the
|
||||
socket), so it accepts no input but the node's events and a workflow from a delegate.
|
||||
- **The node's key never enters a job.** Jobs run in the nested daemon from a self-contained,
|
||||
depth-1 checkout that the adapter fetched; the Radicle home is not mounted into them.
|
||||
- **The staging registry is unreachable from outside.** No port, no Caddy route, on `ci-internal`
|
||||
only (`rad-actions`, the daemon and `ipcr-gateway`). It runs as `$PUID` with no capabilities.
|
||||
- **Bounded.** One run at a time (`concurrent_adapters: 1`), 60 minutes per run, 2 GB for the daemon.
|
||||
- **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.
|
||||
read-only root filesystem, no capabilities, `no-new-privileges` and 32 MB. It mounts the page,
|
||||
its config, IPCR's `state/` and the CI's `html/`, all read-only, and serves static files only.
|
||||
From `state/` it maps exactly one file (`/images.json` → `published.json`).
|
||||
- **The CI is severable.** Stopping `rad-actions` and `rad-actions-docker` removes the privileged
|
||||
container and leaves a working Radicle node and IPCR registry.
|
||||
- **Smaller than what it replaces.** Gone: an admin account with the default password, two minted
|
||||
tokens, the `DAC_READ_SEARCH` capability IPCR needed to read one of them, and a second git server.
|
||||
|
||||
## 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. |
|
||||
| Keep Gitea, fed by a bridge (previous version) | Two sources of state (a second copy of each repository, with its own PRs and registry) and a glue service to keep them in step. |
|
||||
| Gitea as a strict read-only mirror | Still a second git server and a sync to babysit, for one thing it adds: a web view of builds, which `cib`'s pages now provide. |
|
||||
| Gitea's `act_runner` in `exec` mode | Same engine as `act` (a fork), but built for Gitea; nektos `act` is what people run on their laptops. |
|
||||
| `cib`'s native adapter, or Ambient | Neither runs GitHub-style workflows; Ambient needs KVM. |
|
||||
| `ipcrd` accepting pushes itself | A push endpoint would be a second registry implementation. The staging registry plus IPCR's existing import is byte-identical to `nerdctl push` and needed no change. |
|
||||
| Host Docker socket for jobs | Any workflow would get root on the host. |
|
||||
| Run patches from anyone | Their build could publish under the repository's IPNS name. Needs a separate daemon with no registry first. |
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **No job COB for tag builds.** cib 0.32.1 keys a job by the event's tip, which for an annotated
|
||||
tag is the tag object rather than a commit, and creating that job fails (silently; the log only
|
||||
shows `NoJob` when the run finishes). Branch and patch builds get their job COB. Tag builds still
|
||||
appear in `/ci/` with their log. Upstream fix: peel the tag before creating the job.
|
||||
- **Tags arrive as pushes.** cib sends a tag event as `event_type: push` with the tag's name in
|
||||
`branch`. The adapter tells them apart by looking for that name under `refs/tags/` in storage.
|
||||
- **`HasFile` matches files only**, not folders, so cib cannot pre-filter on a workflow folder; the
|
||||
adapter answers "no workflow" itself.
|
||||
|
||||
## Data protection
|
||||
|
||||
@@ -111,13 +114,12 @@ 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` |
|
||||
| `radicle/` | node identity (key pair), seeded repositories (including job COBs), Caddy config |
|
||||
| `ci/` | `cib`'s database (`state/`), report pages and run logs (`html/`), act's action cache, the daemon's image store (`docker/`), the staging registry (`registry/`, disposable) |
|
||||
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state |
|
||||
| `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.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user