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:
Worph
2026-10-04 20:33:16 +00:00
parent 5227a77477
commit b65bb867c9
14 changed files with 1641 additions and 34 deletions
+70 -16
View File
@@ -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: /
+54 -6
View File
@@ -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}"
+86 -8
View File
@@ -96,14 +96,55 @@ code checks visibility again anyway.
- **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a
distribution channel here, not the trust root. The `/rad/<rid>/…` image-naming idea in
[naming.md](naming.md) is weaker for it.
- **Anyone who can push to a repository can publish images.** A job can push any name to the
unauthenticated `localhost:5000`, and IPCR publishes whatever it finds there under the forge's IPNS
key, including images built from private repositories. Registration is off, so every user was
created by the admin.
- **Only allowed names are published.** IPCR publishes an image name only if it is on the allowlist
the bridge keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus
those switched off in the admin pages. Images built from private repositories are therefore not
published.
- **What the allowlist does not cover:** a job can still push any name to the unauthenticated
`localhost:5000`. So anyone who can push code here can publish under an allowed name.
Registration is off, so every user was created by the admin.
- **Fork pull requests** from users without write access wait for "Approve and run" in Gitea 1.27.
- The page, the tips and `rationale.md` say all of this plainly.
- **Follow-up:** per-repository push credentials, with the bridge acting as a registry token
server, would tie an image name to its repository.
server, would tie each image name to the one repository allowed to build it.
## Admin
`https://ipcr-forge-<domain>/admin`. It is served by the bridge and only lets in Gitea site
administrators.
**Sign-in.** It uses an OAuth2 app that the `setup` step registers, with one redirect URI per
host. The flow is the authorization code with PKCE and the `read:user` scope. The bridge reads
`is_admin` from `/api/v1/user`, drops the Gitea token, and sets its own HMAC-signed session (8 h;
the key lives in `bridge/state`). Writes require the `X-IPCR-Admin` header and a same-origin
request.
**Behind the page.** The page calls the bridge, and the bridge calls ipcrd's admin API
(`gateway/admin.go`). That API listens on `:4769`, on the `ipcr-admin` network (internal, bridge
and gateway only), and needs a Bearer token from `ipcr/state/admin-token`.
| Section | What it does |
| --- | --- |
| Publisher name | The key and its `k51…`. You enter your ENS or DNSLink name; it is checked by resolving it through Kubo. While it points here, pull lines (page and admin) use it. The check runs again every 30 min. |
| Health | Shows the IPNS record held by this node and the one the network serves (delegated routing): sequence, expiry, whether it is the current tree. "Republish" refreshes it. Also: peers, the addresses the network sees, and storage against `StorageMax`. |
| Published images | For each tag: "Findable?" (is this node a listed provider), "Announce", "Make latest", "Unpublish". Unpublish removes the tag from the tree, unpins the objects no other image uses, and leaves a tombstone so the tag is not re-imported. Staging tags that are not allowed, or not imported yet, offer "Import now". |
| Which repositories may publish | One toggle per public repository. These toggles make up IPCR's allowlist. |
| Key backup | "Download" encrypts the publisher key with a passphrase. "Restore" makes a backup the publisher key: the current key is kept under `<key>-replaced-<time>`, and the new record's sequence is set above both the network's and this node's. |
**Manual restore** without the page:
```
ipcrd key decrypt backup.ipcrkey.json > key
ipfs key import forge key
```
Then publish with a sequence number above the network's.
**Backup format** (`gateway/keybackup.go`, copied in `bridge/`):
- JSON containing PBKDF2-SHA256 (600k iterations) and AES-256-GCM;
- the additional authenticated data binds the key's name and its `k51…`;
- the plaintext is exactly what `ipfs key export` writes.
The bridge seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the
network.
## Workflow contract
@@ -159,6 +200,25 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
14. **Right after `up`, new domains answer with the gateway's SSO redirect** until Caddy picks up the
labels (about a minute). The runner's registration retries through that window.
15. **`key/export` is CLI-only** in Kubo (`NoRemote`), and ipcrd, which runs as root without
capabilities, cannot read the `$PUID` 0700 keystore. The bridge reads `key_<base32 name>`
(`forge` → `key_mzxxez3f`, mode 0400) through a read-only mount. Those bytes are what
`ipfs key export` would write.
16. **Delegated routing does not use the spec's 404.** For a name it doesn't know, delegated-ipfs.dev
answers `200 text/plain` "delegate error: routing: not found". The first restore read that as
"network unreachable" and refused, safely.
17. **A restore must outbid this node's own record, not only the network's.** Kubo refuses to
publish below the sequence it holds. The network showed 3 while Kubo held 5, so the first
restore failed. The sequence is now `max(network, local) + 1`.
18. **Cloudflare replaces a 502's body with its own page.** The admin API reports Kubo errors as
500, so the message reaches the page.
19. **Kubo 0.43 queues provides.** "Announce" returns at once, and the node shows up as a provider
on delegated routing a minute or two later.
20. **Republishing an unchanged tree keeps the sequence number** and only extends the expiry. The
lifetime went from 48 h to 7 days on the first republish.
21. **Gitea skips its consent screen only after a first grant,** even with
`skip_secondary_authorization`. Each admin clicks "Authorize" once.
## Verified
**Locally** (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the bridge image, one Docker network):
@@ -181,13 +241,31 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
| `docker pull` of that name, then `docker run` | page served (`Hello from IPFS`) |
| forge page `ipcr-forge-holyhorse.nsl.sh` | 200; `/images.json` and `/repos.json` filled |
**Admin, on holyhorse** (ipcr 1.3.0, bridge 0.2.0, 2026-10-04):
| Check | Result |
| --- | --- |
| Login as `gitea_admin` / as a non-admin user | session and page / 403 |
| Writes without `X-IPCR-Admin`, or from another origin | 403; the admin API without a token, also from the CI network: 401 |
| Status | local and network record agree; republish → expiry 7 days out; 18 peers |
| `ipcr-hello` root findable | after "Announce": this node listed by delegated-ipfs.dev |
| `metadec.eth` | "points elsewhere" (it is wisera's), with the value to set |
| Repository switched off, tag `v1.0.2` built | stays in staging, not published; switched on → imported, then deleted from staging |
| `latest` → `1.0.1`, unpublish `1.0.2` | resolves accordingly; `1.0.2` gone for Docker, its objects unpinned, not re-imported |
| Key export → `ipcrd key decrypt` | byte-identical to `key_mzxxez3f`, same `k51…` |
| Restore a throwaway key, then the original | name switches and pulls under each; original back with sequence 7 (network had 5); kept copy reused, no duplicate key |
| `forge-registry` restart | garbage collection frees the deleted tags' blobs |
## Open items
- **Pull from a second server.** Verified on holyhorse itself only; v2's images pulled across
servers the same way.
- **Publish the images**: `ghcr.io/yundera/ipcr:1.2.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.1.0`
(workflows `image.yml` and `bridge.yml`, tag `bridge-v0.1.0`).
- **Per-repository image names:** the bridge as a registry token server (see Trust).
- **Publish the images**: `ghcr.io/yundera/ipcr:1.3.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.2.0`
(workflows `image.yml` and `bridge.yml`, tags `v1.3.0` and `bridge-v0.2.0`).
- **Image provenance:** the bridge as a registry token server, so only a repository's own CI can
push its name (see Trust).
- **Old keys:** `<key>-replaced-*` keys are kept forever, and Kubo keeps republishing them. A
"delete" in the admin page, once the new key is confirmed.
- **act_runner** is pinned at 0.6.1, proven with Gitea 1.27.3. Upstream has moved on.
- **Radicle → Gitea:** patches opened on Radicle could become pull requests. Not built.
- **Store install:** test through the store, not only as a hand install.
+257
View File
@@ -0,0 +1,257 @@
package main
import (
"bytes"
"encoding/base32"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// adminAPI is what the admin page calls, behind auth.requireAdmin:
//
// /admin/api/me who is signed in
// /admin/api/ipcr/<path> ipcrd's admin API (gateway/admin.go), with its token
// POST /admin/api/key/export the publisher key, encrypted with the given passphrase
// GET /admin/api/repos Gitea's public repositories and whether each may publish images
// PUT /admin/api/repos {full_name, publish}
//
// It also keeps ipcrd's allowlist equal to "public Gitea repositories, minus those switched off":
// a build can push any image name to the staging registry, and only those names are published.
type adminAPI struct {
ipcr string // ipcrd's admin API, e.g. http://ipcr-gateway:4769
tokenFile string // ipcrd's admin-token, read through the read-only ipcr/state mount
keystore string // Kubo's keystore folder, read-only
publisher string // the publisher key's name (IMPORT_PUBLISHER)
settings string // admin.json: the repositories switched off
gitea *gitea
au *auth
http *http.Client
mu sync.Mutex
}
type adminSettings struct {
// Repositories (owner/name, lowercase) whose images are not published.
Disabled []string `json:"disabled"`
}
func (a *adminAPI) load() adminSettings {
var s adminSettings
if b, err := os.ReadFile(a.settings); err == nil {
json.Unmarshal(b, &s)
}
return s
}
func (a *adminAPI) token() string {
b, _ := os.ReadFile(a.tokenFile)
return strings.TrimSpace(string(b))
}
func (a *adminAPI) routes(mux *http.ServeMux, au *auth) {
a.au = au
guard := au.requireAdmin
mux.Handle("GET /admin/api/me", guard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, _ := au.user(r)
writeJSONResponse(w, map[string]string{"user": u, "publisher": a.publisher})
})))
mux.Handle("/admin/api/ipcr/{path...}", guard(http.HandlerFunc(a.proxy)))
mux.Handle("POST /admin/api/key/export", guard(http.HandlerFunc(a.export)))
mux.Handle("GET /admin/api/repos", guard(http.HandlerFunc(a.listRepos)))
mux.Handle("PUT /admin/api/repos", guard(http.HandlerFunc(a.setRepo)))
}
func writeJSONResponse(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(v)
}
// proxy forwards to ipcrd's admin API. Only its own paths: /admin/<path>.
func (a *adminAPI) proxy(w http.ResponseWriter, r *http.Request) {
p := r.PathValue("path")
if strings.Contains(p, "..") {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
u := a.ipcr + "/admin/" + p
if r.URL.RawQuery != "" {
u += "?" + r.URL.RawQuery
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, u, io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.token())
resp, err := a.http.Do(req)
if err != nil {
// Not 502: a proxy in front (Cloudflare) replaces a 502's body with its own page.
http.Error(w, "IPCR unreachable: "+err.Error(), http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
// keystoreFile is where Kubo keeps the key <name>: "key_" + lowercase unpadded base32 of the name.
func keystoreFile(dir, name string) string {
return filepath.Join(dir, "key_"+strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(name))))
}
// export reads the publisher key from Kubo's keystore and returns it sealed with the passphrase.
// The cleartext key exists only in this process's memory, for the length of the request.
func (a *adminAPI) export(w http.ResponseWriter, r *http.Request) {
var req struct {
Passphrase string `json:"passphrase"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
http.Error(w, "{passphrase}?", http.StatusBadRequest)
return
}
key, err := os.ReadFile(keystoreFile(a.keystore, a.publisher))
if err != nil {
log.Printf("admin: key export: %v", err)
http.Error(w, "cannot read the publisher key "+a.publisher+" (has anything been published yet?)", http.StatusNotFound)
return
}
b, err := sealKey(a.publisher, key, req.Passphrase)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
u, _ := a.au.user(r)
log.Printf("admin: publisher key %s (%s) exported by %s", a.publisher, b.ID, u)
name := fmt.Sprintf("%s-%s-%s.ipcrkey.json", a.publisher, b.ID[len(b.ID)-8:], time.Now().UTC().Format("20060102"))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(b)
}
type repoToggle struct {
FullName string `json:"full_name"`
Publish bool `json:"publish"`
}
func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) {
repos, err := a.gitea.publicRepos()
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
disabled := map[string]bool{}
for _, d := range a.load().Disabled {
disabled[d] = true
}
out := []repoToggle{}
for _, rp := range repos {
if eligible(&rp) {
n := strings.ToLower(rp.FullName)
out = append(out, repoToggle{FullName: n, Publish: !disabled[n]})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].FullName < out[j].FullName })
writeJSONResponse(w, out)
}
func (a *adminAPI) setRepo(w http.ResponseWriter, r *http.Request) {
var req repoToggle
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil || req.FullName == "" {
http.Error(w, "{full_name, publish}?", http.StatusBadRequest)
return
}
n := strings.ToLower(req.FullName)
a.mu.Lock()
s := a.load()
var keep []string
for _, d := range s.Disabled {
if d != n {
keep = append(keep, d)
}
}
if !req.Publish {
keep = append(keep, n)
}
sort.Strings(keep)
s.Disabled = keep
b, _ := json.MarshalIndent(s, "", " ")
err := os.WriteFile(a.settings+".new", b, 0o644)
if err == nil {
err = os.Rename(a.settings+".new", a.settings)
}
a.mu.Unlock()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := a.pushAllowlist(); err != nil {
http.Error(w, "saved, but IPCR did not take the new list: "+err.Error(), http.StatusServiceUnavailable)
return
}
writeJSONResponse(w, req)
}
// eligible: the repositories whose images may be published — the same that are mirrored.
func eligible(r *repo) bool { return r.public() && !r.Fork && !r.Mirror }
// allowlist is what ipcrd publishes: the image names CI builds are pushed under
// (localhost:5000/<owner>/<repo>, lowercased by docker/metadata-action).
func allowlist(repos []repo, disabled []string) []string {
off := map[string]bool{}
for _, d := range disabled {
off[d] = true
}
var out []string
for i := range repos {
n := strings.ToLower(repos[i].FullName)
if eligible(&repos[i]) && !off[n] {
out = append(out, n)
}
}
sort.Strings(out)
return out
}
// pushAllowlist sends ipcrd the current list. Called on every reconcile and on every toggle.
func (a *adminAPI) pushAllowlist() error {
repos, err := a.gitea.publicRepos()
if err != nil {
return err
}
return a.sendAllowlist(allowlist(repos, a.load().Disabled))
}
func (a *adminAPI) sendAllowlist(list []string) error {
if list == nil {
list = []string{}
}
body, _ := json.Marshal(map[string]any{"allow": map[string]any{"required": true, "repos": list}})
req, _ := http.NewRequest("PATCH", a.ipcr+"/admin/config", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.token())
resp, err := a.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
return errors.New(resp.Status + " " + strings.TrimSpace(string(b)))
}
return nil
}
+239
View File
@@ -0,0 +1,239 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Admin login: Gitea is the forge's one identity system, so the admin pages sign in with it
// (OAuth2 authorization code + PKCE, an app the setup step registers) and let in Gitea site admins
// only. The Gitea token is used once, to read who signed in, and dropped; what remains is this
// service's own session cookie, HMAC-signed with a key kept in STATE_DIR.
//
// The browser goes to Gitea's public address, matching the host the page was opened on
// (ipcr-forge-<suffix> → gitea-<suffix>); the code is exchanged over the internal one.
type auth struct {
hosts map[string]bool // ipcr-forge-… hosts the page is served on (PUBLIC_HOSTS)
gitea string // internal address, for the code exchange and /api/v1/user
secretsDir string // oauth-client-id, oauth-client-secret (written by setup)
key []byte // session signing key
http *http.Client
}
const (
sessionCookie = "ipcr_admin"
oauthCookie = "ipcr_oauth"
sessionTTL = 8 * time.Hour
)
func newAuth(hosts []string, gitea, secretsDir, keyFile string) (*auth, error) {
key, err := loadOrCreateKey(keyFile)
if err != nil {
return nil, err
}
a := &auth{hosts: map[string]bool{}, gitea: strings.TrimRight(gitea, "/"), secretsDir: secretsDir, key: key,
http: &http.Client{Timeout: 30 * time.Second}}
for _, h := range hosts {
if h = strings.TrimSpace(h); h != "" {
a.hosts[h] = true
}
}
return a, nil
}
// loadOrCreateKey keeps the session key across restarts, so a restart does not sign everyone out.
func loadOrCreateKey(path string) ([]byte, error) {
if b, err := os.ReadFile(path); err == nil && len(b) >= 32 {
return b, nil
}
b := make([]byte, 32)
rand.Read(b)
return b, writeSecret(path, string(b))
}
func (a *auth) secret(name string) string {
b, _ := os.ReadFile(a.secretsDir + "/" + name)
return strings.TrimSpace(string(b))
}
// configured reports whether the setup step has registered the OAuth app yet.
func (a *auth) configured() bool {
return a.secret("oauth-client-id") != "" && a.secret("oauth-client-secret") != ""
}
func randomString(n int) string {
b := make([]byte, n)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// giteaPublic is Gitea's address for a browser on host: same suffix, gitea- instead of ipcr-forge-.
func giteaPublic(host string) string {
return "https://gitea-" + strings.TrimPrefix(host, "ipcr-forge-")
}
func (a *auth) login(w http.ResponseWriter, r *http.Request) {
if !a.hosts[r.Host] {
http.Error(w, "unknown host "+r.Host, http.StatusBadRequest)
return
}
if !a.configured() {
http.Error(w, "admin login is not set up yet: the forge-setup install step registers it on the next start", http.StatusServiceUnavailable)
return
}
state, verifier := randomString(16), randomString(32)
sum := sha256.Sum256([]byte(verifier))
http.SetCookie(w, &http.Cookie{Name: oauthCookie, Value: state + "." + verifier, Path: "/admin",
MaxAge: 600, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
q := url.Values{
"client_id": {a.secret("oauth-client-id")}, "redirect_uri": {"https://" + r.Host + "/admin/callback"},
"response_type": {"code"}, "state": {state}, "scope": {"read:user"},
"code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])}, "code_challenge_method": {"S256"},
}
http.Redirect(w, r, giteaPublic(r.Host)+"/login/oauth/authorize?"+q.Encode(), http.StatusFound)
}
func (a *auth) callback(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(oauthCookie)
state, verifier, _ := strings.Cut(func() string {
if err != nil {
return ""
}
return c.Value
}(), ".")
if state == "" || subtle.ConstantTimeCompare([]byte(state), []byte(r.FormValue("state"))) != 1 || !a.hosts[r.Host] {
http.Error(w, "login expired or not started here: open /admin again", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{Name: oauthCookie, Path: "/admin", MaxAge: -1})
if e := r.FormValue("error"); e != "" {
http.Error(w, "Gitea refused: "+e, http.StatusForbidden)
return
}
user, admin, err := a.exchange(r.FormValue("code"), verifier, "https://"+r.Host+"/admin/callback")
if err != nil {
log.Printf("admin login: %v", err)
http.Error(w, "login failed", http.StatusInternalServerError)
return
}
if !admin {
log.Printf("admin login: %s is not a Gitea administrator", user)
http.Error(w, user+" is not a Gitea administrator. Only Gitea site administrators can use these pages.", http.StatusForbidden)
return
}
log.Printf("admin login: %s", user)
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: a.sign(user, time.Now().Add(sessionTTL)), Path: "/",
MaxAge: int(sessionTTL.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
http.Redirect(w, r, "/admin", http.StatusFound)
}
// exchange trades the code for a token, then asks Gitea who that is. The token is not kept.
func (a *auth) exchange(code, verifier, redirect string) (string, bool, error) {
resp, err := a.http.PostForm(a.gitea+"/login/oauth/access_token", url.Values{
"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirect}, "code_verifier": {verifier},
"client_id": {a.secret("oauth-client-id")}, "client_secret": {a.secret("oauth-client-secret")},
})
if err != nil {
return "", false, err
}
var tok struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&tok)
resp.Body.Close()
if tok.AccessToken == "" {
return "", false, fmt.Errorf("token exchange: %s %s", resp.Status, tok.Error)
}
req, _ := http.NewRequest("GET", a.gitea+"/api/v1/user", nil)
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
resp, err = a.http.Do(req)
if err != nil {
return "", false, err
}
defer resp.Body.Close()
var u struct {
Login string `json:"login"`
IsAdmin bool `json:"is_admin"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&u) != nil || u.Login == "" {
return "", false, fmt.Errorf("/api/v1/user: %s", resp.Status)
}
return u.Login, u.IsAdmin, nil
}
func (a *auth) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// sign makes a session value: base64url(user|expiry).base64url(HMAC).
func (a *auth) sign(user string, exp time.Time) string {
payload := base64.RawURLEncoding.EncodeToString([]byte(user + "|" + fmt.Sprint(exp.Unix())))
m := hmac.New(sha256.New, a.key)
m.Write([]byte(payload))
return payload + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
var errNoSession = errors.New("not signed in")
// user returns who the session cookie belongs to, if it is valid and current.
func (a *auth) user(r *http.Request) (string, error) {
c, err := r.Cookie(sessionCookie)
if err != nil {
return "", errNoSession
}
payload, sig, _ := strings.Cut(c.Value, ".")
m := hmac.New(sha256.New, a.key)
m.Write([]byte(payload))
got, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil || !hmac.Equal(got, m.Sum(nil)) {
return "", errNoSession
}
raw, _ := base64.RawURLEncoding.DecodeString(payload)
user, exp, _ := strings.Cut(string(raw), "|")
var unix int64
if _, err := fmt.Sscan(exp, &unix); err != nil || time.Now().Unix() > unix || user == "" {
return "", errNoSession
}
return user, nil
}
// requireAdmin guards the admin API: a valid session, and for anything that changes state, a
// same-origin request carrying X-IPCR-Admin (a header a cross-site form cannot send, and a
// cross-site script cannot without a CORS preflight this service never answers).
func (a *auth) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := a.user(r); err != nil {
http.Error(w, "sign in at /admin", http.StatusUnauthorized)
return
}
if r.Method != "GET" && r.Method != "HEAD" && !sameOrigin(r) {
http.Error(w, "cross-site request refused", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func sameOrigin(r *http.Request) bool {
if r.Header.Get("X-IPCR-Admin") != "1" {
return false
}
if o := r.Header.Get("Origin"); o != "" {
return o == "https://"+r.Host
}
return r.Header.Get("Sec-Fetch-Site") == "same-origin"
}
+231
View File
@@ -1,15 +1,20 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func sign(secret, body string) string {
@@ -197,3 +202,229 @@ func TestWeb(t *testing.T) {
t.Errorf("/nothing: %d", c)
}
}
// The same vector as gateway/admin_test.go: a Kubo key and its name, and a backup of it that ipcrd
// must be able to open.
const (
vectorKey = "CAESQEgAFnJaC//4NMPXmGjBx4fMcn1cRGFhAV2reN7GqcyS+z5e/JsuHUwhZneaa52YACcp2T6p2ri/FfdyILTLfWo="
vectorID = "k51qzi5uqu5dmg0qkz1yhcmegr2nf4jc7q8v9xw1mqv2p6211xzntxps7n6dm2"
vectorPass = "correct horse battery staple"
vectorBackup = `{"v":1,"kdf":"pbkdf2-sha256","iter":600000,"salt":"5mqcBpjiFkE3IVdNeqL6Bw==","nonce":"xKVdz0QswnMpkRz3","name":"forge","id":"k51qzi5uqu5dmg0qkz1yhcmegr2nf4jc7q8v9xw1mqv2p6211xzntxps7n6dm2","format":"libp2p-protobuf-cleartext","ct":"k9Hq6zuS1jmKA13OeIZpGuK4M5pUIKA0F4n0mQrFq5J8E/Q+RlRzu91PoTJGiwR/zUeOG14uO138MG3pPVqTzrFklRcN21yEGyeImaUf0EdWJPUX"}`
)
func TestKeyBackupVector(t *testing.T) {
key, _ := base64.StdEncoding.DecodeString(vectorKey)
var fixed keyBackup
json.Unmarshal([]byte(vectorBackup), &fixed)
if got, err := openKey(&fixed, vectorPass); err != nil || !bytes.Equal(got, key) {
t.Fatalf("fixed vector: %v", err)
}
b, err := sealKey("forge", key, "a passphrase long enough")
if err != nil || b.ID != vectorID {
t.Fatalf("seal: %v %s", err, b.ID)
}
if got, err := openKey(b, "a passphrase long enough"); err != nil || !bytes.Equal(got, key) {
t.Fatalf("round trip: %v", err)
}
}
func TestKeystoreFile(t *testing.T) {
if got := keystoreFile("/ks", "forge"); got != "/ks/key_mzxxez3f" {
t.Errorf("forge → %s, want /ks/key_mzxxez3f", got)
}
}
func TestExport(t *testing.T) {
dir := t.TempDir()
key, _ := base64.StdEncoding.DecodeString(vectorKey)
os.WriteFile(keystoreFile(dir, "forge"), key, 0o400)
au, _ := newAuth([]string{"ipcr-forge-x.example"}, "http://gitea", dir, filepath.Join(dir, "session-key"))
a := &adminAPI{keystore: dir, publisher: "forge", au: au}
r := httptest.NewRequest("POST", "/admin/api/key/export", strings.NewReader(`{"passphrase":"long enough passphrase"}`))
w := httptest.NewRecorder()
a.export(w, r)
if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Disposition"), ".ipcrkey.json") {
t.Fatalf("export: %d %s", w.Code, w.Body)
}
var b keyBackup
json.Unmarshal(w.Body.Bytes(), &b)
if got, err := openKey(&b, "long enough passphrase"); err != nil || !bytes.Equal(got, key) || b.ID != vectorID {
t.Fatalf("exported backup does not open: %v", err)
}
w = httptest.NewRecorder()
a.export(w, httptest.NewRequest("POST", "/", strings.NewReader(`{"passphrase":"short"}`)))
if w.Code != 400 {
t.Errorf("short passphrase: %d", w.Code)
}
}
func TestSession(t *testing.T) {
dir := t.TempDir()
au, err := newAuth(nil, "", dir, filepath.Join(dir, "k"))
if err != nil {
t.Fatal(err)
}
req := func(v string) *http.Request {
r := httptest.NewRequest("GET", "/admin", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: v})
return r
}
good := au.sign("root", time.Now().Add(time.Hour))
if u, err := au.user(req(good)); err != nil || u != "root" {
t.Errorf("good session: %q %v", u, err)
}
if _, err := au.user(req(au.sign("root", time.Now().Add(-time.Minute)))); err == nil {
t.Error("expired session accepted")
}
forged := base64.RawURLEncoding.EncodeToString([]byte("eve|99999999999")) + good[strings.Index(good, "."):]
if _, err := au.user(req(forged)); err == nil {
t.Error("edited session accepted")
}
// The key survives a restart.
au2, _ := newAuth(nil, "", dir, filepath.Join(dir, "k"))
if _, err := au2.user(req(good)); err != nil {
t.Error("session lost after restart")
}
}
func TestRequireAdmin(t *testing.T) {
dir := t.TempDir()
au, _ := newAuth(nil, "", dir, filepath.Join(dir, "k"))
h := au.requireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
cookie := &http.Cookie{Name: sessionCookie, Value: au.sign("root", time.Now().Add(time.Hour))}
try := func(method string, hdr map[string]string, withCookie bool) int {
r := httptest.NewRequest(method, "https://ipcr-forge-x.example/admin/api/repos", nil)
r.Host = "ipcr-forge-x.example"
for k, v := range hdr {
r.Header.Set(k, v)
}
if withCookie {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
same := map[string]string{"X-IPCR-Admin": "1", "Origin": "https://ipcr-forge-x.example"}
for _, c := range []struct {
name string
method string
hdr map[string]string
cookie bool
want int
}{
{"no session", "GET", nil, false, 401},
{"read", "GET", nil, true, 200},
{"write, same origin", "PUT", same, true, 200},
{"write, no header", "PUT", map[string]string{"Origin": "https://ipcr-forge-x.example"}, true, 403},
{"write, other origin", "PUT", map[string]string{"X-IPCR-Admin": "1", "Origin": "https://evil.example"}, true, 403},
{"write, fetch metadata", "POST", map[string]string{"X-IPCR-Admin": "1", "Sec-Fetch-Site": "same-origin"}, true, 200},
} {
if got := try(c.method, c.hdr, c.cookie); got != c.want {
t.Errorf("%s: %d, want %d", c.name, got, c.want)
}
}
}
// The login round trip against a fake Gitea: an administrator gets a session, anyone else a 403.
func TestCallback(t *testing.T) {
admin := true
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login/oauth/access_token":
r.ParseForm()
if r.Form.Get("code") != "the-code" || r.Form.Get("code_verifier") == "" || r.Form.Get("client_secret") != "s3cret" ||
r.Form.Get("redirect_uri") != "https://ipcr-forge-x.example/admin/callback" {
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
return
}
json.NewEncoder(w).Encode(map[string]string{"access_token": "tok"})
case "/api/v1/user":
if r.Header.Get("Authorization") != "Bearer tok" {
w.WriteHeader(401)
return
}
json.NewEncoder(w).Encode(map[string]any{"login": "root", "is_admin": admin})
}
}))
defer gitea.Close()
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "oauth-client-id"), []byte("cid"), 0o600)
os.WriteFile(filepath.Join(dir, "oauth-client-secret"), []byte("s3cret"), 0o600)
au, _ := newAuth([]string{"ipcr-forge-x.example"}, gitea.URL, dir, filepath.Join(dir, "k"))
login := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/admin/login", nil)
r.Host = "ipcr-forge-x.example"
au.login(login, r)
loc, _ := url.Parse(login.Header().Get("Location"))
if login.Code != 302 || loc.Host != "gitea-x.example" || loc.Query().Get("code_challenge_method") != "S256" {
t.Fatalf("login redirect: %d %s", login.Code, loc)
}
state := loc.Query().Get("state")
oauth := login.Result().Cookies()[0]
call := func(state string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/admin/callback?code=the-code&state="+state, nil)
r.Host = "ipcr-forge-x.example"
r.AddCookie(oauth)
w := httptest.NewRecorder()
au.callback(w, r)
return w
}
if w := call("wrong"); w.Code != 400 {
t.Errorf("wrong state: %d", w.Code)
}
w := call(state)
if w.Code != 302 || w.Header().Get("Location") != "/admin" {
t.Fatalf("admin callback: %d %s", w.Code, w.Body)
}
var session string
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookie {
session = c.Value
}
}
if u, err := au.user(func() *http.Request {
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: session})
return r
}()); err != nil || u != "root" {
t.Errorf("session after login: %q %v", u, err)
}
admin = false
if w := call(state); w.Code != 403 {
t.Errorf("non-admin: %d", w.Code)
}
// Unknown host: no redirect to Gitea.
r = httptest.NewRequest("GET", "/admin/login", nil)
r.Host = "evil.example"
w = httptest.NewRecorder()
au.login(w, r)
if w.Code != 400 {
t.Errorf("unknown host: %d", w.Code)
}
}
func TestAllowlist(t *testing.T) {
mk := func(name string, f func(*repo)) repo {
r := repo{FullName: name}
if f != nil {
f(&r)
}
return r
}
repos := []repo{
mk("Owner/App", nil),
mk("owner/off", nil),
mk("owner/secret", func(r *repo) { r.Private = true }),
mk("owner/fork", func(r *repo) { r.Fork = true }),
mk("org/thing", nil),
}
got := strings.Join(allowlist(repos, []string{"owner/off"}), ",")
if got != "org/thing,owner/app" {
t.Errorf("allowlist = %s", got)
}
}
+130
View File
@@ -0,0 +1,130 @@
package main
// Publisher key backups. The IPNS key a publisher's name belongs to (e.g. the forge's `forge` key)
// exists only in Kubo's keystore: lose the disk and the name is gone for good. A backup is that
// key, encrypted with a passphrase, in a small JSON file:
//
// {"v":1, "kdf":"pbkdf2-sha256", "iter":600000, "salt":…, "nonce":…,
// "name":"forge", "id":"k51…", "format":"libp2p-protobuf-cleartext", "ct":…}
//
// ct = AES-256-GCM(key = PBKDF2-SHA256(passphrase, salt, iter, 32), nonce, plaintext,
// additional data = "ipcrkey/v1|<name>|<id>"), the plaintext being the key exactly as Kubo stores
// and exports it (`ipfs key export`, libp2p-protobuf-cleartext). The additional data binds name and
// id to the ciphertext, so neither can be edited. Byte slices are base64 (encoding/json's default).
//
// A copy of gateway/keybackup.go (ipcrd), without its CLI: the bridge writes backups (it can read
// Kubo's keystore; ipcrd cannot) and ipcrd reads them, so the cleartext key never crosses the
// network. Keep the two identical; both test the same vector.
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"math/big"
)
type keyBackup struct {
V int `json:"v"`
KDF string `json:"kdf"`
Iter int `json:"iter"`
Salt []byte `json:"salt"`
Nonce []byte `json:"nonce"`
Name string `json:"name"`
ID string `json:"id"`
Format string `json:"format"`
CT []byte `json:"ct"`
}
const (
backupIter = 600_000
backupFormat = "libp2p-protobuf-cleartext"
)
func (b *keyBackup) aad() []byte { return []byte("ipcrkey/v1|" + b.Name + "|" + b.ID) }
func backupCipher(pass string, salt []byte, iter int) (cipher.AEAD, error) {
k, err := pbkdf2.Key(sha256.New, pass, salt, iter, 32)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(k)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
// sealKey encrypts the Kubo key `key` (libp2p protobuf), named `name` in the keystore.
func sealKey(name string, key []byte, pass string) (*keyBackup, error) {
if len(pass) < 12 {
return nil, errors.New("passphrase: at least 12 characters")
}
id, err := ipnsName(key)
if err != nil {
return nil, err
}
b := &keyBackup{V: 1, KDF: "pbkdf2-sha256", Iter: backupIter, Name: name, ID: id, Format: backupFormat,
Salt: make([]byte, 16), Nonce: make([]byte, 12)}
rand.Read(b.Salt)
rand.Read(b.Nonce)
gcm, err := backupCipher(pass, b.Salt, b.Iter)
if err != nil {
return nil, err
}
b.CT = gcm.Seal(nil, b.Nonce, key, b.aad())
return b, nil
}
// openKey decrypts a backup and checks that the key is the one its id names.
func openKey(b *keyBackup, pass string) ([]byte, error) {
if b.V != 1 || b.KDF != "pbkdf2-sha256" || b.Format != backupFormat {
return nil, fmt.Errorf("not an IPCR key backup (v%d, %s, %s)", b.V, b.KDF, b.Format)
}
if b.Iter < 100_000 || b.Iter > 10_000_000 || len(b.Salt) < 16 || len(b.Nonce) != 12 {
return nil, errors.New("key backup: bad parameters")
}
gcm, err := backupCipher(pass, b.Salt, b.Iter)
if err != nil {
return nil, err
}
key, err := gcm.Open(nil, b.Nonce, b.CT, b.aad())
if err != nil {
return nil, errors.New("key backup: wrong passphrase, or the file was altered")
}
id, err := ipnsName(key)
if err != nil {
return nil, err
}
if id != b.ID {
return nil, fmt.Errorf("key backup: the key is %s, the file says %s", id, b.ID)
}
return key, nil
}
// ipnsName returns the IPNS name (k51…) of an ed25519 key in libp2p's private-key protobuf:
// field 1 (type, 1 = Ed25519), field 2 (seed ‖ public key; 64 bytes, or 96 in keys from old
// libp2p versions, which repeat the public key). The name is the base36 CIDv1 (codec libp2p-key)
// of the identity multihash of the public key's protobuf — what Kubo prints with
// `--ipns-base base36`.
func ipnsName(priv []byte) (string, error) {
if len(priv) < 4 || priv[0] != 0x08 || priv[1] != 0x01 || priv[2] != 0x12 || int(priv[3]) != len(priv)-4 {
return "", errors.New("not an ed25519 libp2p private key (only ed25519 IPNS keys are supported)")
}
data := priv[4:]
if len(data) != 64 && len(data) != 96 {
return "", fmt.Errorf("ed25519 key: %d bytes", len(data))
}
seed, pub := data[:32], data[32:64]
if !bytes.Equal(ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey), pub) {
return "", errors.New("ed25519 key: public half does not match")
}
pubProto := append([]byte{0x08, 0x01, 0x12, 0x20}, pub...)
cid := append([]byte{0x01, 0x72, 0x00, byte(len(pubProto))}, pubProto...)
return "k" + new(big.Int).SetBytes(cid).Text(36), nil
}
+51 -2
View File
@@ -30,6 +30,15 @@
// IPCR_PUBLISHED IPCR's published.json, served as /images.json
// (default /srv/ipcr-state/published.json)
// RECONCILE full pass interval (default 10m)
//
// Admin pages (/admin, auth.go and admin.go), on when IPCR_ADMIN is set:
//
// PUBLIC_HOSTS the hosts the page is served on (ipcr-forge-<domain>, …), comma separated;
// setup registers one OAuth2 redirect URI per host
// IPCR_ADMIN ipcrd's admin API (e.g. http://ipcr-gateway:4769)
// IPCR_ADMIN_TOKEN_FILE its token (default /srv/ipcr-state/admin-token)
// KUBO_KEYSTORE Kubo's keystore, read-only, for key backups (default /srv/ipcr-keystore)
// IMPORT_PUBLISHER the publisher key's name (default forge)
package main
import (
@@ -37,6 +46,7 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
)
@@ -104,7 +114,6 @@ func serve() error {
}
q := newQueue()
go q.run(m.sync)
go reconcileLoop(m, q, every)
hook := &hookHandler{secretFile: secrets + "/hook-secret", enqueue: q.add}
hmux := http.NewServeMux()
@@ -113,8 +122,45 @@ func serve() error {
log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux))
}()
mux := webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json"))
if ipcr := os.Getenv("IPCR_ADMIN"); ipcr != "" {
au, err := newAuth(splitList(env("PUBLIC_HOSTS", "")), m.gitea.base, secrets, stateDir+"/session-key")
if err != nil {
return err
}
adm := &adminAPI{
ipcr: strings.TrimRight(ipcr, "/"), tokenFile: env("IPCR_ADMIN_TOKEN_FILE", "/srv/ipcr-state/admin-token"),
keystore: env("KUBO_KEYSTORE", "/srv/ipcr-keystore"), publisher: env("IMPORT_PUBLISHER", "forge"),
settings: stateDir + "/admin.json", gitea: m.gitea, http: &http.Client{Timeout: 3 * time.Minute},
}
adminRoutes(mux, au, adm)
// IPCR publishes nothing until it has the list (IMPORT_ALLOW_REQUIRED): send it as soon as
// both ends are up, then after every reconcile.
go func() {
for {
if !m.gitea.hasToken() {
time.Sleep(15 * time.Second)
continue
}
err := adm.pushAllowlist()
if err == nil {
return
}
log.Printf("allowlist: %v (retrying)", err)
time.Sleep(30 * time.Second)
}
}()
m.onReconcile = func() {
if err := adm.pushAllowlist(); err != nil {
log.Printf("allowlist: %v", err)
}
}
log.Printf("bridge: admin pages on, IPCR admin API %s", ipcr)
}
go reconcileLoop(m, q, every)
log.Printf("bridge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every)
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json")))
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), mux)
}
// reconcileLoop queues every repository Gitea shows as public, plus every one already known (so one
@@ -144,6 +190,9 @@ func reconcileLoop(m *mirror, q *queue, every time.Duration) {
q.add(id)
}
}
if m.onReconcile != nil {
m.onReconcile()
}
time.Sleep(every)
}
}
+2
View File
@@ -21,6 +21,8 @@ type mirror struct {
state *state
repoDir string // one bare repository per Gitea repository: <id>.git
radHome string
// Called after each reconcile pass (the admin pages keep IPCR's allowlist in step).
onReconcile func()
}
// sync brings one Gitea repository's Radicle copy up to date, and records the outcome.
+75
View File
@@ -31,6 +31,8 @@ const tokenName = "ipcr-forge-bridge"
// a system webhook every repository's push/create/delete/repository events → HOOK_URL,
// created or repaired (matched by URL), never duplicated.
// EXAMPLE_REPO a public example repository with a workflow, created when absent.
// an OAuth2 application the admin pages' Gitea login (PUBLIC_HOSTS → its redirect URIs);
// SECRETS_DIR/oauth-client-id and oauth-client-secret.
//
// The admin password is used here only and never stored. If it was changed since install, the
// step leaves the existing token and hook alone instead of failing the start.
@@ -90,6 +92,12 @@ func setup() error {
return fmt.Errorf("webhook: %w", err)
}
if hosts := splitList(env("PUBLIC_HOSTS", "")); len(hosts) > 0 {
if err := ensureOAuthApp(g, hosts, secrets); err != nil {
return fmt.Errorf("admin login (OAuth2 app): %w", err)
}
}
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
// Not fatal: the forge works without its example.
if err := ensureExample(g, name); err != nil {
@@ -178,3 +186,70 @@ func ensureExample(g *gitea, name string) error {
}, nil)
return err
}
const oauthAppName = "ipcr-forge-admin"
func splitList(s string) []string {
var out []string
for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) {
out = append(out, f)
}
return out
}
// ensureOAuthApp registers the admin pages as a Gitea OAuth2 application, one redirect URI per host
// the page is served on. Gitea shows a client secret only when the app is created, so a missing
// secret file means re-creating the app.
func ensureOAuthApp(g *gitea, hosts []string, secrets string) error {
var redirects []string
for _, h := range hosts {
redirects = append(redirects, "https://"+h+"/admin/callback")
}
body := map[string]any{"name": oauthAppName, "redirect_uris": redirects,
"confidential_client": true, "skip_secondary_authorization": true}
var apps []struct {
ID int64 `json:"id"`
Name string `json:"name"`
ClientID string `json:"client_id"`
RedirectURIs []string `json:"redirect_uris"`
}
if _, err := g.call("GET", "/user/applications/oauth2?limit=50", nil, &apps); err != nil {
return err
}
idFile, secretFile := filepath.Join(secrets, "oauth-client-id"), filepath.Join(secrets, "oauth-client-secret")
haveSecret := false
if b, err := os.ReadFile(secretFile); err == nil && len(strings.TrimSpace(string(b))) > 0 {
haveSecret = true
}
for _, app := range apps {
if app.Name != oauthAppName {
continue
}
if haveSecret {
// Keep it (and its secret); only follow a change of hosts.
_, err := g.call("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), body, nil)
return err
}
if _, err := g.call("DELETE", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), nil, nil); err != nil {
return err
}
}
var created struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
if _, err := g.call("POST", "/user/applications/oauth2", body, &created); err != nil {
return err
}
if created.ClientID == "" || created.ClientSecret == "" {
return errors.New("Gitea returned no client credentials")
}
if err := writeSecret(idFile, created.ClientID); err != nil {
return err
}
if err := writeSecret(secretFile, created.ClientSecret); err != nil {
return err
}
log.Printf("setup: OAuth2 app %s for %s", oauthAppName, strings.Join(redirects, ", "))
return nil
}
+27 -1
View File
@@ -13,7 +13,7 @@ var webFiles embed.FS
// webHandler serves the forge's public page. Everything on it is public anyway: links, the
// documentation, IPCR's list of published images (public on IPFS) and the list of mirrored
// repositories (public on Radicle). Nothing here changes anything.
func webHandler(st *state, publishedFile string) http.Handler {
func webHandler(st *state, publishedFile string) *http.ServeMux {
mux := http.NewServeMux()
page, _ := webFiles.ReadFile("web/index.html")
icon, _ := webFiles.ReadFile("web/icon.png")
@@ -47,3 +47,29 @@ func webHandler(st *state, publishedFile string) http.Handler {
})
return mux
}
// adminRoutes adds the admin pages: the page itself (signed-in Gitea admins only), the login, and
// the API it calls.
func adminRoutes(mux *http.ServeMux, au *auth, adm *adminAPI) {
page, _ := webFiles.ReadFile("web/admin.html")
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
if _, err := au.user(r); err != nil {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Frame-Options", "DENY")
w.Write(page)
})
mux.HandleFunc("GET /admin/login", au.login)
mux.HandleFunc("GET /admin/callback", au.callback)
mux.HandleFunc("POST /admin/logout", func(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "cross-site request refused", http.StatusForbidden)
return
}
au.logout(w, r)
})
adm.routes(mux, au)
}
+411
View File
@@ -0,0 +1,411 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IPCR Forge admin</title>
<link rel="icon" href="icon.png">
<!--
The forge's admin page: Gitea site administrators only (the bridge checks the session for the
page and for every API call). Everything goes through /admin/api/: the bridge's own endpoints
(repositories, key export) and IPCR's admin API behind /admin/api/ipcr/.
-->
<style>
:root {
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
--accent: #0f766e; --code: #f1f4f8; --ok: #15803d; --warn: #b45309; --bad: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
--accent: #2dd4bf; --code: #0d1626; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 1080px; margin: 0 auto; padding: 24px 16px 64px; }
header { display: flex; gap: 14px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
header .title { display: flex; gap: 12px; align-items: center; }
header img { width: 40px; height: 40px; }
h1 { font-size: 24px; margin: 0; }
h2 { font-size: 19px; margin: 32px 0 10px; }
h3 { font-size: 15px; margin: 14px 0 6px; }
p { margin: 6px 0; }
a { color: var(--accent); }
.muted { color: var(--muted); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; min-width: 0; }
.card h3 { margin-top: 0; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--muted); }
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12.5px; background: var(--code);
padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
.ok { color: var(--ok); font-weight: 600; } .warn { color: var(--warn); font-weight: 600; } .bad { color: var(--bad); font-weight: 600; }
button, input[type=submit] { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
font: inherit; font-size: 13px; padding: 4px 10px; cursor: pointer; }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.danger { color: var(--bad); }
button:disabled { opacity: .5; cursor: default; }
input[type=text], input[type=password], input[type=number], input[type=file] { font: inherit; font-size: 14px; padding: 5px 8px;
border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); max-width: 100%; }
form.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 12.5px; }
.scroll { overflow-x: auto; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.bar { height: 8px; background: var(--code); border-radius: 4px; overflow: hidden; margin: 6px 0; }
.bar span { display: block; height: 100%; background: var(--accent); }
.note { border-left: 3px solid var(--warn); padding: 4px 12px; color: var(--muted); margin: 8px 0; }
#toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--ink); color: var(--bg);
padding: 8px 14px; border-radius: 8px; font-size: 14px; display: none; max-width: calc(100% - 32px); }
</style>
</head>
<body>
<main>
<header>
<div class="title"><img src="icon.png" alt=""><div><h1>IPCR Forge admin</h1>
<div class="muted"><a href="/">Forge page</a> · signed in as <b id="me">…</b></div></div></div>
<button id="logout">Sign out</button>
</header>
<h2>Publisher name</h2>
<div class="grid">
<div class="card">
<h3>Key</h3>
<dl>
<dt>Key</dt><dd><code id="key-name">…</code></dd>
<dt>IPNS name</dt><dd><code id="key-id">…</code></dd>
<dt>Pull as</dt><dd><code id="pull-as">…</code></dd>
</dl>
<p class="muted">Every image is published under this one name. Back the key up (below): it exists only on this server.</p>
</div>
<div class="card">
<h3>Your domain or ENS name</h3>
<p class="muted">Point a name you own at the IPNS name, then enter it here. Once it checks out, pull lines use it.</p>
<form class="row" id="name-form">
<input type="text" id="name" placeholder="example.eth or example.com" size="26" autocomplete="off">
<input type="submit" value="Save and check">
</form>
<p id="name-check" class="muted"></p>
<details><summary class="muted">How to point it here</summary>
<p><b>ENS:</b> in the ENS manager, Records → Content hash = <code>ipns://<span class="kid">k51…</span></code>.</p>
<p><b>DNS:</b> a TXT record <code>_dnslink.example.com</code> = <code>dnslink=/ipns/<span class="kid">k51…</span></code>.</p>
</details>
</div>
</div>
<h2>Health <button id="refresh">Check again</button></h2>
<div class="grid">
<div class="card">
<h3>IPNS record</h3>
<dl id="record"></dl>
<div class="actions" style="margin-top:8px"><button id="republish">Republish now</button></div>
<p class="muted">The record must be renewed by this node while it is online (Kubo does it every 4 h). Lifetime is how long a
record stays valid if this node goes offline; TTL is how long resolvers cache it.</p>
</div>
<div class="card">
<h3>Node</h3>
<dl id="node"></dl>
<div class="actions" style="margin-top:8px"><button id="peers">What the network sees</button></div>
<div id="peers-out" class="muted"></div>
</div>
<div class="card">
<h3>Storage</h3>
<div id="storage"></div>
<p class="muted">Published images are pinned and never collected. Unpublishing an image frees the blocks no other image uses.</p>
</div>
</div>
<h2>Published images</h2>
<div id="images" class="muted">Loading…</div>
<h2>Which repositories may publish</h2>
<p class="muted">A build can push any image name to the staging registry; IPCR only publishes the names of the public
Gitea repositories switched on here (<code>&lt;owner&gt;/&lt;repo&gt;</code>). New public repositories are on.</p>
<div id="repos" class="muted">Loading…</div>
<h2>Key backup</h2>
<div class="grid">
<div class="card">
<h3>Download a backup</h3>
<p class="muted">The key, encrypted with your passphrase (at least 12 characters). Keep the file and the passphrase apart.
Without a backup, losing this server loses the name for good.</p>
<form class="row" id="export-form">
<input type="password" id="ex-pass" placeholder="passphrase" autocomplete="new-password" required>
<input type="password" id="ex-pass2" placeholder="again" autocomplete="new-password" required>
<input type="submit" value="Download">
</form>
</div>
<div class="card">
<h3>Restore a backup</h3>
<p class="muted">Makes the backed-up key this forge's publisher key: to move a forge to a new server, or recover one.
The current key is kept (renamed), not deleted.</p>
<form class="row" id="import-form">
<input type="file" id="im-file" accept=".json,application/json" required>
<input type="password" id="im-pass" placeholder="passphrase" autocomplete="off" required>
<input type="number" id="im-seq" placeholder="sequence (only if asked)" min="1">
<input type="submit" value="Restore">
</form>
<p class="note">Every image name then points at this server's images under the restored name. On a fresh server, the
name's last published images are adopted.</p>
</div>
</div>
</main>
<div id="toast"></div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
var publisher = "", pubName = "";
function toast(msg, ms) {
var t = $("toast"); t.textContent = msg; t.style.display = "block";
clearTimeout(toast.t); toast.t = setTimeout(function () { t.style.display = "none"; }, ms || 4000);
}
// Every call: same origin, with the header the bridge requires on writes.
function api(method, path, body) {
var opt = { method: method, headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin", cache: "no-store" };
if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); }
return fetch("/admin/api/" + path, opt).then(function (r) {
if (r.status === 401) { location.href = "/admin/login"; throw new Error("signed out"); }
return r.text().then(function (t) {
var j = null; try { j = JSON.parse(t); } catch (e) {}
if (!r.ok) throw new Error((j && j.error) || t || r.statusText);
return j;
});
});
}
function el(tag, text, cls) { var e = document.createElement(tag); if (text != null) e.textContent = text; if (cls) e.className = cls; return e; }
function dl(target, rows) {
target.textContent = "";
rows.forEach(function (r) {
target.appendChild(el("dt", r[0]));
var dd = el("dd");
if (r[1] instanceof Node) dd.appendChild(r[1]); else dd.textContent = r[1] == null ? "—" : r[1];
target.appendChild(dd);
});
}
function badge(text, cls) { return el("span", text, cls); }
function size(n) {
if (!n) return "0";
var u = ["B", "KB", "MB", "GB", "TB"], i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(i ? 1 : 0) + " " + u[i];
}
function until(iso) {
if (!iso) return "";
var h = (Date.parse(iso) - Date.now()) / 3600000;
if (h < 0) return "expired";
return h > 48 ? Math.round(h / 24) + " days left" : Math.round(h) + " h left";
}
function busy(btn, p) {
btn.disabled = true;
return p.then(function (v) { btn.disabled = false; return v; }, function (e) { btn.disabled = false; toast(e.message, 8000); throw e; });
}
api("GET", "me").then(function (me) { $("me").textContent = me.user; });
$("logout").onclick = function () {
fetch("/admin/logout", { method: "POST", headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin" })
.then(function () { location.href = "/"; });
};
// ---- status
function recordView(r, err, tree) {
if (!r) return badge(err || "none", "bad");
var s = el("span");
s.appendChild(badge(r.valid ? "valid" : "invalid", r.valid ? "ok" : "bad"));
s.appendChild(document.createTextNode(" · sequence " + r.sequence + " · " + until(r.validity)));
if (tree && r.value !== tree) { s.appendChild(document.createTextNode(" · ")); s.appendChild(badge("older tree", "warn")); }
return s;
}
function loadStatus(refresh) {
return api("GET", "ipcr/status" + (refresh ? "?refresh=1" : "")).then(function (s) {
publisher = s.key.id || "";
$("key-name").textContent = s.key.name || "—";
$("key-id").textContent = publisher || "not created yet (first import creates it)";
Array.prototype.forEach.call(document.querySelectorAll(".kid"), function (e) { e.textContent = publisher || "k51…"; });
pubName = s.name && s.name.ok ? s.name.name : "";
$("pull-as").textContent = "ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "/<owner>/<repo>:<tag>";
if (s.name && s.name.name) $("name").value = s.name.name;
var nc = $("name-check"); nc.textContent = "";
if (s.name && s.name.name) {
nc.appendChild(s.name.ok ? badge("✓ " + s.name.name + " → this forge", "ok") : badge("✗ " + (s.name.error || "does not resolve"), "bad"));
if (!s.name.ok && s.name.resolves) nc.appendChild(el("div", "resolves to " + s.name.resolves, "muted"));
} else nc.textContent = "No name set: pull lines use the IPNS name.";
var rec = s.record;
dl($("record"), [
["Here", recordView(rec.local, rec.localError, rec.tree)],
["Network", recordView(rec.network, rec.networkError, rec.tree)],
["Lifetime", rec.lifetime], ["TTL", rec.ttl],
["Checked", s.checkedAt.replace("T", " ").replace("Z", " UTC")]
]);
dl($("node"), [["Peer ID", s.node.id], ["Connected peers", s.node.peers],
["Listening", (s.node.addresses || []).filter(function (a) { return a.indexOf("/127.") < 0 && a.indexOf("/::1/") < 0; }).slice(0, 4).join("\n")]]);
var st = s.storage, pct = st.storageMax ? Math.min(100, 100 * st.repoSize / st.storageMax) : 0;
$("storage").innerHTML = "";
$("storage").appendChild(el("div", size(st.repoSize) + " of " + size(st.storageMax) + " · " + st.pins + " pins"));
var bar = el("div", null, "bar"), fill = el("span"); fill.style.width = pct.toFixed(1) + "%"; bar.appendChild(fill);
$("storage").appendChild(bar);
}).catch(function (e) { toast("Status: " + e.message, 8000); });
}
$("refresh").onclick = function () { busy(this, loadStatus(true)); };
$("republish").onclick = function () {
busy(this, api("POST", "ipcr/republish")).then(function () { toast("Published again"); loadStatus(true); });
};
$("peers").onclick = function () {
busy(this, api("GET", "ipcr/peers")).then(function (p) {
var o = $("peers-out"); o.textContent = "";
if (p.error) o.appendChild(badge(p.error, "bad"));
else if (!p.network || !p.network.length) o.appendChild(badge("The network does not know this node's addresses yet: check that port 4768 (TCP and UDP) is reachable.", "warn"));
else { o.appendChild(badge("Reachable via:", "ok")); o.appendChild(el("pre", p.network.join("\n"))); }
});
};
$("name-form").onsubmit = function (e) {
e.preventDefault();
var btn = this.querySelector("input[type=submit]");
busy(btn, api("PATCH", "ipcr/config", { name: $("name").value })).then(function () { return loadStatus(true); });
};
// ---- images
function loadImages() {
return api("GET", "ipcr/images").then(function (d) {
var box = $("images"); box.textContent = ""; box.className = "";
if (d.stagingError) box.appendChild(el("p", "Staging registry: " + d.stagingError, "warn"));
if (!d.images || !d.images.length) { box.appendChild(el("p", "Nothing published yet.", "muted")); return; }
d.images.forEach(function (img) {
var h = el("h3", img.repo + " ");
h.appendChild(img.allowed ? badge("publishing", "ok") : badge("not allowed: new tags are not published", "warn"));
box.appendChild(h);
var wrap = el("div", null, "scroll"), t = el("table"), head = el("tr");
["Tag", "Image", "", ""].forEach(function (x) { head.appendChild(el("th", x)); });
t.appendChild(head);
img.tags.forEach(function (tg) {
var tr = el("tr");
var td1 = el("td"); td1.appendChild(el("code", tg.tag));
if (tg.at) td1.appendChild(el("div", tg.at.replace("T", " ").replace("Z", ""), "muted"));
var td2 = el("td"); td2.appendChild(el("code", "/ipfs/" + tg.cid));
var td3 = el("td"), prov = el("span", "", "muted"); td3.appendChild(prov);
var td4 = el("td"), acts = el("div", null, "actions");
var chk = el("button", "Findable?");
chk.onclick = function () {
busy(chk, api("GET", "ipcr/providers?cid=" + encodeURIComponent(tg.cid))).then(function (p) {
prov.textContent = "";
prov.appendChild(p.self ? badge("✓ this node is listed", "ok") : badge("✗ not listed (" + p.providers + " other providers)", "warn"));
if (!p.self) {
var ann = el("button", "Announce");
ann.onclick = function () { busy(ann, api("POST", "ipcr/announce", { cid: tg.cid })).then(function () { toast("Queued: Kubo announces it within a minute or two; check again then.", 6000); }); };
prov.appendChild(document.createTextNode(" ")); prov.appendChild(ann);
}
});
};
acts.appendChild(chk);
if (tg.tag !== "latest") {
var lat = el("button", "Make latest");
lat.onclick = function () {
busy(lat, api("POST", "ipcr/retag", { repo: img.repo, tag: "latest", from: tg.tag })).then(function () { toast(img.repo + ":latest → " + tg.tag); loadImages(); });
};
acts.appendChild(lat);
}
var un = el("button", "Unpublish", "danger");
un.onclick = function () {
if (!confirm("Unpublish " + img.repo + ":" + tg.tag + "?\n\nThe name stops resolving to it. Servers that already pulled it keep their copy, and its CID keeps working wherever it is still pinned.")) return;
busy(un, api("POST", "ipcr/unpublish", { repo: img.repo, tag: tg.tag })).then(function (r) {
toast("Unpublished (" + r.unpinned + " objects unpinned)"); loadImages(); loadStatus(true);
});
};
acts.appendChild(un);
td4.appendChild(acts);
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
t.appendChild(tr);
});
(img.staging || []).filter(function (s) { return s.state !== "published"; }).forEach(function (s) {
var tr = el("tr");
var td1 = el("td"); td1.appendChild(el("code", s.tag));
var td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "in staging, not imported yet" : "in staging, not allowed"), "muted");
var td3 = el("td"), td4 = el("td");
if (img.allowed) {
var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now");
re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued"); setTimeout(loadImages, 5000); }); };
td4.appendChild(re);
}
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
t.appendChild(tr);
});
wrap.appendChild(t); box.appendChild(wrap);
});
}).catch(function (e) { $("images").textContent = "Images: " + e.message; });
}
// ---- repositories
function loadRepos() {
return api("GET", "repos").then(function (repos) {
var box = $("repos"); box.textContent = ""; box.className = "";
if (!repos.length) { box.appendChild(el("p", "No public repository yet.", "muted")); return; }
var t = el("table"), head = el("tr");
["Repository", "Publish images"].forEach(function (x) { head.appendChild(el("th", x)); });
t.appendChild(head);
repos.forEach(function (r) {
var tr = el("tr"), td1 = el("td"), td2 = el("td"), cb = document.createElement("input");
td1.appendChild(el("code", r.full_name));
cb.type = "checkbox"; cb.checked = r.publish;
cb.onchange = function () {
cb.disabled = true;
api("PUT", "repos", { full_name: r.full_name, publish: cb.checked })
.then(function () { cb.disabled = false; toast(r.full_name + (cb.checked ? ": publishing" : ": not publishing")); loadImages(); },
function (e) { cb.disabled = false; cb.checked = !cb.checked; toast(e.message, 8000); });
};
td2.appendChild(cb);
tr.appendChild(td1); tr.appendChild(td2); t.appendChild(tr);
});
var wrap = el("div", null, "scroll"); wrap.appendChild(t); box.appendChild(wrap);
}).catch(function (e) { $("repos").textContent = "Repositories: " + e.message; });
}
// ---- key backup
$("export-form").onsubmit = function (e) {
e.preventDefault();
var p = $("ex-pass").value;
if (p !== $("ex-pass2").value) { toast("The passphrases differ"); return; }
if (p.length < 12) { toast("At least 12 characters"); return; }
var btn = this.querySelector("input[type=submit]");
btn.disabled = true;
fetch("/admin/api/key/export", { method: "POST", credentials: "same-origin",
headers: { "X-IPCR-Admin": "1", "Content-Type": "application/json" }, body: JSON.stringify({ passphrase: p }) })
.then(function (r) {
if (!r.ok) return r.text().then(function (t) { throw new Error(t); });
var name = (r.headers.get("Content-Disposition") || "").replace(/.*filename="([^"]+)".*/, "$1") || "publisher.ipcrkey.json";
return r.blob().then(function (b) {
var a = document.createElement("a"); a.href = URL.createObjectURL(b); a.download = name;
document.body.appendChild(a); a.click(); a.remove();
$("ex-pass").value = $("ex-pass2").value = "";
toast("Saved " + name);
});
})
.catch(function (e) { toast(e.message, 8000); })
.then(function () { btn.disabled = false; });
};
$("import-form").onsubmit = function (e) {
e.preventDefault();
var f = $("im-file").files[0], btn = this.querySelector("input[type=submit]");
if (!f) return;
f.text().then(function (txt) {
var backup; try { backup = JSON.parse(txt); } catch (x) { throw new Error("Not a key backup file"); }
if (!confirm("Make " + backup.id + " this forge's publisher key?\n\nThe current key (" + (publisher || "none") + ") is kept, renamed. Pull lines with the current IPNS name stop following new releases.")) return;
var body = { backup: backup, passphrase: $("im-pass").value };
if ($("im-seq").value) body.sequence = parseInt($("im-seq").value, 10);
return busy(btn, api("POST", "ipcr/key/import", body)).then(function (r) {
toast(r.status || ("Publisher key is now " + r.id + (r.adopted ? " (network tree adopted)" : "")), 8000);
$("im-pass").value = ""; loadStatus(true); loadImages();
});
}).catch(function (e) { toast(e.message, 8000); });
};
loadStatus(false); loadImages(); loadRepos();
})();
</script>
</body>
</html>
+3 -1
View File
@@ -315,7 +315,9 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
var tb = table(["Tag", "Compose line", ""]);
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
var info = r.tags[tag];
var line = "image: " + reg + "/ipns/" + r.ipns + ":" + tag;
// The configured domain or ENS name, while IPCR's check says it points here.
var path = pub.name && pub.publisher && r.ipns.indexOf(pub.publisher) === 0 ? pub.name + r.ipns.slice(pub.publisher.length) : r.ipns;
var line = "image: " + reg + "/ipns/" + path + ":" + tag;
var tr = el("tr");
var td1 = el("td"); td1.appendChild(el("code", tag));
if (info.at) td1.appendChild(el("div", when(info.at), "muted"));