IPCR Forge admin: name, health, unpublish, allowlist, key backup
ipcr 1.3.0 (gateway/): - admin API on ADMIN_LISTEN (Bearer token in STATE_DIR/admin-token): status (key, node, storage, IPNS record here and on the network via delegated routing, ENS/DNSLink name check), providers/announce, peers, images, unpublish (tombstone + unpin unused objects), retag, reimport, config (name, allowlist), republish, key import (restore with sequence = max(network, local) + 1; previous key kept, kept copies reused) - watcher refactored into shared state; config.json with an allowlist (IMPORT_ALLOW_REQUIRED); staging cleanup after publish (IMPORT_CLEANUP) - IPNS records published with IPNS_LIFETIME / IPNS_TTL; superseded trees unpinned - encrypted publisher-key backups (PBKDF2 + AES-256-GCM), ipcrd key decrypt bridge 0.2.0 (bridge/): - /admin: Gitea OAuth2 login (PKCE, site admins only), signed session, same-origin + X-IPCR-Admin on writes; setup registers the OAuth2 app - admin page: publisher name and ENS/DNS check, health, published images, per-repository publish toggles (pushed to IPCR as its allowlist), key export (from Kubo's keystore, encrypted) and restore - public page pull lines use the verified name Compose: ipcr-admin internal network, keystore mount, 7-day IPNS lifetime, staging GC at registry start. Verified on holyhorse (docs/forge.md).
This commit is contained in:
@@ -35,7 +35,7 @@ services:
|
||||
# with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080.
|
||||
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
|
||||
ipcr-forge:
|
||||
image: ghcr.io/yundera/ipcr-forge-bridge:0.1.0
|
||||
image: ghcr.io/yundera/ipcr-forge-bridge:0.2.0
|
||||
container_name: ipcr-forge
|
||||
user: $PUID:$PGID
|
||||
read_only: true
|
||||
@@ -57,6 +57,14 @@ services:
|
||||
RAD_HOME: /radicle-home
|
||||
IPCR_PUBLISHED: /srv/ipcr-state/published.json
|
||||
RECONCILE: 10m
|
||||
# Admin pages (/admin): Gitea site admins only, signed in through Gitea (an OAuth2 app the
|
||||
# forge-setup step registers, one redirect URI per host below). They manage IPCR through its
|
||||
# admin API (on ipcr-admin) and back up the publisher key, read from Kubo's keystore.
|
||||
PUBLIC_HOSTS: ipcr-forge-${APP_DOMAIN},ipcr-forge-${APP_PUBLIC_IP_DASH}.nip.io,ipcr-forge-${APP_PUBLIC_IP_DASH}.sslip.io
|
||||
IPCR_ADMIN: http://ipcr-gateway:4769
|
||||
IPCR_ADMIN_TOKEN_FILE: /srv/ipcr-state/admin-token
|
||||
KUBO_KEYSTORE: /srv/ipcr-keystore
|
||||
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
|
||||
tmpfs:
|
||||
# HOME for git and rad.
|
||||
- /tmp
|
||||
@@ -69,8 +77,11 @@ services:
|
||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge
|
||||
# The Gitea token and the webhook secret the install step writes. Read-only here.
|
||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro
|
||||
# published.json → /images.json. Read-only.
|
||||
# published.json → /images.json, and the admin API's token. Read-only.
|
||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro
|
||||
# Kubo's keystore, read-only: the publisher key, for encrypted backups (Kubo exports keys
|
||||
# from its CLI only, and ipcrd, root without capabilities, cannot read a $PUID 0700 folder).
|
||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/kubo/keystore:/srv/ipcr-keystore:ro
|
||||
depends_on:
|
||||
- gitea
|
||||
- radicle-node
|
||||
@@ -94,6 +105,8 @@ services:
|
||||
caddy_2.reverse_proxy: "{{upstreams 8080}}"
|
||||
networks:
|
||||
- pcs
|
||||
# To IPCR's admin API only.
|
||||
- ipcr-admin
|
||||
|
||||
# ═══ Gitea — the source of truth ═══════════════════════════════════════════════
|
||||
# From Apps/Gitea; its data folder moves to $AppID/gitea/.
|
||||
@@ -271,6 +284,9 @@ services:
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
restart: unless-stopped
|
||||
network_mode: service:forge-docker
|
||||
# Garbage collection frees the blobs of deleted tags, and must not run while the registry
|
||||
# serves: so it runs at every start, before serving.
|
||||
entrypoint: ["sh", "-c", "registry garbage-collect --delete-untagged /etc/distribution/config.yml; exec registry serve /etc/distribution/config.yml"]
|
||||
cpu_shares: 20
|
||||
deploy:
|
||||
resources:
|
||||
@@ -279,7 +295,8 @@ services:
|
||||
environment:
|
||||
TZ: $TZ
|
||||
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
|
||||
# Staging only: IPCR has its own copy once imported, so old tags may be deleted.
|
||||
# Staging only: IPCR has its own copy once imported, and deletes each tag it has published
|
||||
# (IMPORT_CLEANUP).
|
||||
REGISTRY_STORAGE_DELETE_ENABLED: "true"
|
||||
OTEL_TRACES_EXPORTER: none
|
||||
volumes:
|
||||
@@ -421,7 +438,7 @@ services:
|
||||
# ipcrd: TLS front door on 127.0.0.1:4767. Passes /ipfs/<cid> through to the stock nerdctl
|
||||
# registry below, resolves /ipns/<name>:<tag> itself, and pins every image it serves.
|
||||
ipcr-gateway:
|
||||
image: ghcr.io/yundera/ipcr:1.2.0
|
||||
image: ghcr.io/yundera/ipcr:1.3.0
|
||||
container_name: ipcr-gateway
|
||||
# Root for one reason: it writes its CA into the host's Docker trust store (the narrow
|
||||
# bind below), which is root-owned. See rationale.md.
|
||||
@@ -450,6 +467,18 @@ services:
|
||||
# /ipns/<forge key>/<repo>:<tag>. One name for the whole forge, which a DNSLink domain or an
|
||||
# ENS name can point at: ipcr.localhost:4767/ipns/example.eth/<repo>:<tag>.
|
||||
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
|
||||
# Forge: publish only the repositories the bridge allows (public Gitea repositories, minus
|
||||
# those switched off in the admin pages). Until the bridge sends its list, nothing is.
|
||||
IMPORT_ALLOW_REQUIRED: "true"
|
||||
# Forge: delete each tag from the staging registry once published (its blobs go at the
|
||||
# registry's next start, see forge-registry).
|
||||
IMPORT_CLEANUP: "true"
|
||||
# IPNS records valid 7 days instead of Kubo's 48 h, so a forge offline for a weekend keeps
|
||||
# its name; resolvers cache them 5 minutes, so a new tag shows up quickly.
|
||||
IPNS_LIFETIME: ${IPCR_IPNS_LIFETIME:-168h}
|
||||
IPNS_TTL: 5m
|
||||
# Forge: the admin API, for the bridge, on ipcr-admin. Bearer token in state/admin-token.
|
||||
ADMIN_LISTEN: ":4769"
|
||||
IMPORT_INTERVAL: 60s
|
||||
STATE_DIR: /data/state
|
||||
volumes:
|
||||
@@ -477,6 +506,8 @@ services:
|
||||
- ipcr-internal
|
||||
# Forge only: reaches the staging registry for the import poller above.
|
||||
- ci-internal
|
||||
# Forge only: the admin API, reached by the bridge.
|
||||
- ipcr-admin
|
||||
cpu_shares: 50
|
||||
deploy:
|
||||
resources:
|
||||
@@ -486,7 +517,7 @@ services:
|
||||
# ── Stock nerdctl IPFS registry ─────────────────────────────────────────────
|
||||
# `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads.
|
||||
ipcr-registry:
|
||||
image: ghcr.io/yundera/ipcr:1.2.0
|
||||
image: ghcr.io/yundera/ipcr:1.3.0
|
||||
container_name: ipcr-registry
|
||||
# Root because nerdctl run as any other uid assumes rootless mode and refuses to start
|
||||
# without a rootless containerd — which `ipfs registry serve` never uses. The service has
|
||||
@@ -573,6 +604,10 @@ networks:
|
||||
# From Apps/IPCR: gateway ↔ registry ↔ kubo, nothing else.
|
||||
ipcr-internal:
|
||||
driver: bridge
|
||||
# Bridge → IPCR's admin API, nothing else; no egress.
|
||||
ipcr-admin:
|
||||
driver: bridge
|
||||
internal: true
|
||||
|
||||
x-casaos:
|
||||
architectures:
|
||||
@@ -636,6 +671,12 @@ x-casaos:
|
||||
**Start here: [the forge's page](https://ipcr-forge-$APP_DOMAIN)**: how to publish, the
|
||||
published images, the Radicle mirrors.
|
||||
|
||||
**Admin: [ipcr-forge-$APP_DOMAIN/admin](https://ipcr-forge-$APP_DOMAIN/admin)**, signed in
|
||||
with Gitea (site administrators only): your ENS or DNS name, health of the published name,
|
||||
unpublishing images, which repositories may publish, and the **publisher key backup**.
|
||||
Download one after the first release: the key exists only on this server, and losing it
|
||||
loses every image name.
|
||||
|
||||
**Replaces three apps.** Uninstall Gitea, Radicle and IPCR first: this app uses the same
|
||||
addresses and ports.
|
||||
|
||||
@@ -654,14 +695,20 @@ x-casaos:
|
||||
private later stops the copy, and what was published stays on the network. Private
|
||||
repositories are never copied.
|
||||
|
||||
**Who can publish images.** Anyone who can push to a repository here: a build can push any
|
||||
image name to the staging registry, and IPCR publishes what it finds there under this
|
||||
forge's name. Images are public once on IPFS, even from a private repository. Read a
|
||||
fork's workflow before approving its run.
|
||||
**Who can publish images.** Only the names of public repositories that are switched on in the
|
||||
admin pages (all are, by default): `<owner>/<repo>`. A build in any repository can still
|
||||
push any name to the staging registry, so whoever can push code here can publish under an
|
||||
allowed name. Read a fork's workflow before approving its run.
|
||||
fr_fr: |
|
||||
**Commencez ici : [la page de la forge](https://ipcr-forge-$APP_DOMAIN)** : publier, les
|
||||
images publiées, les copies Radicle.
|
||||
|
||||
**Administration : [ipcr-forge-$APP_DOMAIN/admin](https://ipcr-forge-$APP_DOMAIN/admin)**,
|
||||
connexion via Gitea (administrateurs du site uniquement) : votre nom ENS ou DNS, l'état du
|
||||
nom publié, le retrait d'images, les dépôts autorisés à publier, et la **sauvegarde de la
|
||||
clé de publication**. Téléchargez-en une après la première version : la clé n'existe que
|
||||
sur ce serveur, et la perdre fait perdre tous les noms d'images.
|
||||
|
||||
**Remplace trois applications.** Désinstallez d'abord Gitea, Radicle et IPCR : cette
|
||||
application utilise les mêmes adresses et ports.
|
||||
|
||||
@@ -680,10 +727,11 @@ x-casaos:
|
||||
privé ensuite arrête la copie, et ce qui a été publié reste sur le réseau. Les dépôts privés
|
||||
ne sont jamais copiés.
|
||||
|
||||
**Qui peut publier des images.** Toute personne qui peut pousser sur un dépôt d'ici : un
|
||||
build peut pousser n'importe quel nom d'image vers le registre de transit, et IPCR publie ce
|
||||
qu'il y trouve sous le nom de cette forge. Une image est publique une fois sur IPFS, même
|
||||
si elle vient d'un dépôt privé. Lisez le workflow d'un fork avant d'approuver son exécution.
|
||||
**Qui peut publier des images.** Seuls les noms des dépôts publics activés dans
|
||||
l'administration (tous, par défaut) : `<propriétaire>/<dépôt>`. Un build de n'importe quel
|
||||
dépôt peut tout de même pousser n'importe quel nom vers le registre de transit : qui peut
|
||||
pousser du code ici peut publier sous un nom autorisé. Lisez le workflow d'un fork avant
|
||||
d'approuver son exécution.
|
||||
index: /
|
||||
webui_port: 3000
|
||||
store_app_id: ipcr-forge
|
||||
@@ -739,6 +787,12 @@ x-compose-app:
|
||||
user: $PUID
|
||||
group: $PGID
|
||||
mode: "0700"
|
||||
# Created ahead of Kubo's first start: the bridge mounts it, and Docker would otherwise create
|
||||
# it as root, where Kubo cannot write its keys.
|
||||
- path: /DATA/AppData/$AppID/ipcr/kubo/keystore
|
||||
user: $PUID
|
||||
group: $PGID
|
||||
mode: "0700"
|
||||
- path: /DATA/AppData/$AppID/ipcr/init.d
|
||||
user: $PUID
|
||||
group: $PGID
|
||||
@@ -806,10 +860,10 @@ x-compose-app:
|
||||
chmod 600 /runner/token
|
||||
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
|
||||
# The bridge's Gitea token (public-only, read:repository), the webhook secret, the system
|
||||
# webhook, and the example repository. Idempotent, so on every start: it repairs the webhook.
|
||||
# webhook, the admin pages' OAuth2 app, and the example repository. Idempotent, so on every start: it repairs the webhook.
|
||||
# The admin password is used here only, never stored.
|
||||
- name: forge-setup
|
||||
image: ghcr.io/yundera/ipcr-forge-bridge:0.1.0
|
||||
image: ghcr.io/yundera/ipcr-forge-bridge:0.2.0
|
||||
user: $PUID:$PGID
|
||||
phase: post_up
|
||||
network: $APP_NET
|
||||
@@ -818,7 +872,7 @@ x-compose-app:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 ipcr-forge-bridge setup
|
||||
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 PUBLIC_HOSTS="ipcr-forge-$APP_DOMAIN,ipcr-forge-$APP_PUBLIC_IP_DASH.nip.io,ipcr-forge-$APP_PUBLIC_IP_DASH.sslip.io" ipcr-forge-bridge setup
|
||||
when: always
|
||||
webui-host: gitea-${domain}
|
||||
webui-path: /
|
||||
|
||||
@@ -35,6 +35,11 @@ with. See docs/forge.md.
|
||||
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.
|
||||
9. **Admin pages with their own login** (`/admin`): Gitea OAuth2, Gitea site admins only. The
|
||||
install step registers the OAuth2 app.
|
||||
10. **The bridge mounts Kubo's keystore read-only**, so it can export the publisher key, encrypted.
|
||||
11. **An admin API in `ipcr-gateway`** (`:4769`) on a new internal network, `ipcr-admin`. Its Bearer
|
||||
token is in `ipcr/state/admin-token`, mode 0644.
|
||||
|
||||
## Why it is necessary
|
||||
|
||||
@@ -82,6 +87,22 @@ be read by anyone, and Radicle is one more place to read it.
|
||||
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.
|
||||
|
||||
**9. Admin pages.** The operator needs to manage the IPCR side: the ENS or DNS name; whether the
|
||||
name and the images resolve from outside; unpublishing; which repositories may publish; and, most
|
||||
of all, backing up the publisher key, which is the only copy of every image name. Gitea is the
|
||||
forge's identity system, so the admin pages sign in with it rather than adding accounts or relying
|
||||
on the PCS gate, which is not on every host.
|
||||
|
||||
**10. The keystore mount.** Kubo exports keys from its CLI only (`key/export` is not available
|
||||
over its HTTP API). ipcrd runs as root without capabilities, so it cannot read a `$PUID` 0700
|
||||
folder; giving it `DAC_READ_SEARCH` was rejected once already. The bridge runs as `$PUID` and can.
|
||||
|
||||
**11. The admin API and its token.** Unpublishing, moving tags, key restore and the allowlist
|
||||
change ipcrd's own state (the published tree, `published.json`), so they live in ipcrd. The token is
|
||||
needed because CI jobs on `ci-internal` can reach `ipcr-gateway`. The file is world-readable
|
||||
because ipcrd, root without `CAP_CHOWN`, cannot hand a file to `$PUID` any other way. It sits in
|
||||
the app's own state folder.
|
||||
|
||||
## Security mitigations in place
|
||||
|
||||
**The bridge**
|
||||
@@ -117,8 +138,26 @@ on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
|
||||
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.
|
||||
- **The public page cannot change anything.** The bridge serves it read-only: the embedded page,
|
||||
IPCR's `published.json` (mounted read-only) and the bridge's repository list.
|
||||
|
||||
**The admin pages**
|
||||
- **Only Gitea site admins get in.** The check is `is_admin`, read from Gitea's own API through the
|
||||
internal address. The Gitea token is dropped right after.
|
||||
- **The session cookie:**
|
||||
- HMAC-signed, `HttpOnly`, `Secure`, valid 8 h;
|
||||
- the login uses PKCE and `state`, and is only accepted on the hosts in `PUBLIC_HOSTS`.
|
||||
- **Every write must be same-origin.** The `Origin` header (or Fetch Metadata) must match, and the
|
||||
custom `X-IPCR-Admin` header is required. A cross-site form cannot send that header, and a
|
||||
cross-site script cannot without a CORS preflight, which is never answered.
|
||||
- **The admin API is only on `ipcr-admin`.** That network is `internal: true` and shared only by
|
||||
the bridge and `ipcr-gateway`; every call needs the Bearer token, compared in constant time. Kubo's
|
||||
own API stays on `ipcr-internal`.
|
||||
- **The cleartext key never crosses the network.** The bridge encrypts it in memory (AES-256-GCM,
|
||||
PBKDF2 at 600k iterations, at least 12 characters of passphrase) and ipcrd decrypts it. A
|
||||
restore never deletes a key: the previous one is kept, renamed.
|
||||
- **The allowlist is closed by default.** Until the bridge sends its list, nothing is published
|
||||
(`IMPORT_ALLOW_REQUIRED`). Private repositories are never on it.
|
||||
|
||||
## Alternatives considered and rejected
|
||||
|
||||
@@ -141,8 +180,17 @@ on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
|
||||
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.
|
||||
- **Image names are limited, but their source is not proven.** Only allowed names (public
|
||||
repositories that are switched on) are published. A build in any repository can still push an
|
||||
allowed name to the staging registry. Per-repository credentials, issued by the bridge, are a
|
||||
planned follow-up.
|
||||
- **The bridge can read the publisher key.** A compromise of the public-facing bridge leaks it. That
|
||||
is the trust it already has with the Radicle node key.
|
||||
- **The admin token is world-readable** inside the app's state folder on the host.
|
||||
- **Old keys accumulate.** `<key>-replaced-*` keys are kept and republished by Kubo until removed by
|
||||
hand (`docker exec ipcr-kubo ipfs key rm <name>`).
|
||||
- **Re-importing a tag needs it in staging,** and staging is cleaned once a tag is published. A new
|
||||
CI run brings it back.
|
||||
|
||||
## Data protection
|
||||
|
||||
@@ -152,9 +200,9 @@ Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
| `bridge/` | `state/`: the Gitea → Radicle mapping, one bare mirror per repository (disposable), the admin session key and the repository toggles (`admin.json`); `secrets/` (0700): its Gitea token, the webhook secret, the OAuth2 client credentials |
|
||||
| `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 |
|
||||
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `config.json` (name, allowlist), the admin token |
|
||||
|
||||
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
|
||||
|
||||
@@ -17,3 +17,8 @@ ipfs config --json Addresses.Swarm "[
|
||||
# Soft cap on the block store. Pinned images are never collected; with --enable-gc everything
|
||||
# else (blocks fetched but not pinned) is swept every Datastore.GCPeriod.
|
||||
ipfs config Datastore.StorageMax "${IPFS_STORAGE_MAX:-20GB}"
|
||||
|
||||
# How long an IPNS record stays valid when Kubo republishes it (every 4 h, while online). Kubo's
|
||||
# default is 48 h: a node offline for a weekend would lose every name it publishes. ipcrd publishes
|
||||
# with the same lifetime (IPNS_LIFETIME).
|
||||
ipfs config Ipns.RecordLifetime "${IPCR_IPNS_LIFETIME:-168h}"
|
||||
|
||||
Reference in New Issue
Block a user