IPCR Forge v3: Gitea as the source of truth, public repos mirrored to Radicle

Users found Radicle-as-source unfriendly (v1, v2). Gitea is now where
people work; its Actions build images into the staging registry IPCR
follows, as in v2.

- bridge/: ipcr-forge-bridge (Go). Install step mints a public-only
  token, registers one system webhook and creates the ipcr-hello example;
  the service mirrors every public repo one way to Radicle (rad init,
  canonical tags rule, git push rad) and serves the forge's page.
- Compose: gitea + act_runner on a separate dind; tile opens Gitea;
  tips link the forge page.
- Removed the Radicle CI broker (ci/) and the nginx landing page.
This commit is contained in:
Worph
2026-10-04 18:34:26 +00:00
parent f82543f518
commit 6fe4c40cbe
24 changed files with 2275 additions and 594 deletions
+121 -82
View File
@@ -1,112 +1,148 @@
# IPCR Forge — Rationale
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.
IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon,
and the bridge (`ipcr-forge-bridge`), which mirrors public Gitea repositories to Radicle and
serves the forge's page. The Gitea, Radicle and IPCR services are copied from their listings,
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.
Gitea is the source of truth. Earlier versions made Radicle the source: first with a read-only
Gitea fed from it (v1), then with CI on the Radicle node alone (v2). Users found both hard to work
with. See docs/forge.md.
## What deviation / exception is being requested
**Inherited, unchanged** (the full argument is in each listing's `rationale.md`):
**Inherited, unchanged.** The full argument for each is in that listing's `rationale.md`.
| From | Deviation |
| --- | --- |
| Gitea | An admin account (`gitea_admin`) created with the server's default app password |
| Radicle | No authentication gate on `radicle-` and `radicle-api-<domain>`; host port `8776/tcp` |
| 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. **`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.
1. **`forge-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did.
2. **The bridge (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's
key. It publishes repositories signed by that key.
3. **A system webhook and its allow-list.** Gitea may deliver webhooks to `ipcr-forge` on the
server's network (`GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge`).
4. **An install step that uses the admin password** to mint the bridge's token and to register
that webhook.
5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry
is.
6. **A public page.** `ipcr-forge-<domain>` is not behind the SSO gate.
7. **Public repositories are published to Radicle automatically**, and that cannot be undone.
8. **It replaces three apps** and cannot be installed beside them: it uses the same container names,
domains and host ports.
## Why it is necessary
**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).
**1. Privileged Docker-in-Docker.** Building an image needs a Docker daemon. A CI job can get one
in two ways:
- the host's socket, which gives every workflow root on the PCS;
- a daemon of its own, which needs `privileged`.
**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.
The second keeps a build inside 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). The runner itself is a separate, unprivileged
container that reaches the daemon over a unix socket.
**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`.
**2. The node's home.** Publishing to Radicle means three things: creating a repository in the
node's storage, signing its refs with the node's key, and announcing them through the node's
control socket. Those live in that folder. This is the mount `radicle-api` already has. The bridge
uses the stock `rad` CLI (1.10.1, the node's version) rather than reimplementing any of it.
**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.
**3. The webhook.** Pushes reach Radicle within seconds instead of at the next 10-minute
reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the bridge sits
on one. The list adds exactly one host name. `private` would let any user's webhook reach every
container on the shared network.
**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.
**4. The admin password at install.** Gitea's token API and its system-webhook API accept only an
administrator signing in. The step uses the password once per start, never stores it, and
leaves the running service holding nothing but a `public-only`, `read:repository` token. If the
password has been changed, the step keeps the existing token and webhook instead of failing.
**5. 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`, because 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 every job sees it as
`localhost:5000`. That is the only address Docker and BuildKit accept plain HTTP for, so no TLS
or credentials are involved. `ipcr-gateway` reaches it as `forge-docker:5000` over `ci-internal`.
**6. The public page.** It holds links, documentation, image names that are public on IPFS anyway,
and the list of repositories already public on Radicle. A login in front of it would stand between
a stranger and the instructions for pulling an image.
**7. Publishing to Radicle.** That is the point of the mirror: the code of a released image stays
available without this server. Only public repositories go. A public Gitea repository is meant to
be read by anyone, and Radicle is one more place to read it.
**8. Replacing three apps.** The bundle runs the same services, not a different product. Renaming
every container, domain and port so they could coexist would break the defaults the services rely
on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
## Security mitigations in place
- **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, 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.
**The bridge**
- **It sees public repositories only.** Its Gitea token is scoped `public-only` and
`read:repository`. Gitea hides every private or limited repository from it, and it can write
nothing in Gitea. The code checks visibility a second time. Forks and pull mirrors are skipped.
- **Webhooks are authenticated and carry no content.** Each delivery must pass the HMAC-SHA256
check against a random secret written at install (`bridge/secrets`, 0700). A delivery only says
*which* repository changed. The bridge re-reads that repository from Gitea, so a forged payload
can at most cause a pointless sync. Port 8081 has no Caddy route.
- **It runs unprivileged:** `$PUID`, every capability dropped, a read-only root filesystem,
`no-new-privileges`, 256 MB. One worker does one sync at a time.
- **The node's key stays in the bridge and the node.** No CI job mounts the Radicle home.
**The CI daemon**
- **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an
empty `DOCKER_TLS_CERTDIR`. The unix socket sits in a folder shared only with `forge-runner`,
group `$PGID`.
- **`forge-runner` runs unprivileged:** `$PUID`, no capabilities, `no-new-privileges`. Jobs run in
the nested daemon, never on the host's.
- **The staging registry is unreachable from outside.** It has no port and no Caddy route and sits
on `ci-internal` only. It runs as `$PUID` with no capabilities.
- **Bounded.** One job at a time, at most 1 hour per job, 2 GB for the daemon.
- **Severable.** Stopping `forge-runner` and `forge-docker` removes the privileged container and
leaves a working Gitea, Radicle node and IPCR registry.
**Gitea**
- **Fork pull requests wait for approval.** In Gitea 1.27, a run from a user without write access
waits for "Approve and run".
- **Registration is off.** Every account is created by the admin.
- **Who can publish is stated** in the tips and on the page. Write access to any repository means
being able to publish images under the forge's name. Images from private repositories become
public on IPFS.
**The page**
- **It cannot change anything.** It is served by the bridge's own read-only HTTP handler: the
embedded page, IPCR's `published.json` (mounted read-only) and the bridge's repository list.
## Alternatives considered and rejected
| Alternative | Why not |
| --- | --- |
| 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. |
| Radicle as the source, Gitea read-only (v1) | Users had to work in Radicle and found a read-only Gitea confusing |
| Radicle only, CI on the node (v2) | Not user-friendly enough: every step went through `rad` |
| Gitea push mirror to Radicle | Gitea cannot push to `rad://` (no `git-remote-rad`), and mirror pushes run on a timer |
| Publishing to Radicle from a workflow job | It would put the node's key inside CI jobs |
| A per-repository webhook | Every repository would need setup; one system webhook covers all of them, including new ones |
| Gitea's package registry as the staging registry (v1) | Needs tokens for the workflow and for IPCR, plus the `DAC_READ_SEARCH` capability for IPCR to read its token. It would also be a second place images get published from |
| Host Docker socket for jobs | Any workflow would get root on the host |
| A long-lived admin token for the bridge | The webhook is repaired by the install step on every start instead, so the running service never holds admin rights |
## 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.
- **Radicle cannot unpublish.** A repository made private or deleted in Gitea is *frozen* (no
further sync). What already reached the network stays there.
- **A deleted tag stays in Radicle's canonical `refs/tags/`.** It is removed from the node's own
namespace only.
- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only
delegate.
- **Image names are not tied to repositories.** A build may push any name to the staging registry.
Per-repository credentials, issued by the bridge, are a planned follow-up.
## Data protection
@@ -114,12 +150,15 @@ Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
| Folder | Holds |
| --- | --- |
| `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) |
| `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config |
| `radicle/` | node identity (key pair), seeded repositories, Caddy config |
| `bridge/` | `state/`: the Gitea → Radicle mapping and one bare mirror per repository (disposable); `secrets/` (0700): its Gitea token and the webhook secret |
| `ci/` | the daemon's socket, its 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.
matter on the network survive uninstall and reinstall, so published repositories and image names
stay valid:
- Radicle's node key;
- Kubo's node key and IPNS keys.