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.
165 lines
9.9 KiB
Markdown
165 lines
9.9 KiB
Markdown
# IPCR Forge — Rationale
|
|
|
|
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.
|
|
|
|
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 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. **`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. 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`.
|
|
|
|
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.
|
|
|
|
**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.
|
|
|
|
**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.
|
|
|
|
**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
|
|
|
|
**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 |
|
|
| --- | --- |
|
|
| 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
|
|
|
|
- **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
|
|
|
|
Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
|
|
|
|
| Folder | Holds |
|
|
| --- | --- |
|
|
| `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 |
|
|
|
|
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 survive uninstall and reinstall, so published repositories and image names
|
|
stay valid:
|
|
- Radicle's node key;
|
|
- Kubo's node key and IPNS keys.
|