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.
|
# 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.
|
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
|
||||||
ipcr-forge:
|
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
|
container_name: ipcr-forge
|
||||||
user: $PUID:$PGID
|
user: $PUID:$PGID
|
||||||
read_only: true
|
read_only: true
|
||||||
@@ -57,6 +57,14 @@ services:
|
|||||||
RAD_HOME: /radicle-home
|
RAD_HOME: /radicle-home
|
||||||
IPCR_PUBLISHED: /srv/ipcr-state/published.json
|
IPCR_PUBLISHED: /srv/ipcr-state/published.json
|
||||||
RECONCILE: 10m
|
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:
|
tmpfs:
|
||||||
# HOME for git and rad.
|
# HOME for git and rad.
|
||||||
- /tmp
|
- /tmp
|
||||||
@@ -69,8 +77,11 @@ services:
|
|||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge
|
||||||
# The Gitea token and the webhook secret the install step writes. Read-only here.
|
# The Gitea token and the webhook secret the install step writes. Read-only here.
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro
|
- ${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
|
- ${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:
|
depends_on:
|
||||||
- gitea
|
- gitea
|
||||||
- radicle-node
|
- radicle-node
|
||||||
@@ -94,6 +105,8 @@ services:
|
|||||||
caddy_2.reverse_proxy: "{{upstreams 8080}}"
|
caddy_2.reverse_proxy: "{{upstreams 8080}}"
|
||||||
networks:
|
networks:
|
||||||
- pcs
|
- pcs
|
||||||
|
# To IPCR's admin API only.
|
||||||
|
- ipcr-admin
|
||||||
|
|
||||||
# ═══ Gitea — the source of truth ═══════════════════════════════════════════════
|
# ═══ Gitea — the source of truth ═══════════════════════════════════════════════
|
||||||
# From Apps/Gitea; its data folder moves to $AppID/gitea/.
|
# From Apps/Gitea; its data folder moves to $AppID/gitea/.
|
||||||
@@ -271,6 +284,9 @@ services:
|
|||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: service:forge-docker
|
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
|
cpu_shares: 20
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -279,7 +295,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TZ: $TZ
|
TZ: $TZ
|
||||||
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
|
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"
|
REGISTRY_STORAGE_DELETE_ENABLED: "true"
|
||||||
OTEL_TRACES_EXPORTER: none
|
OTEL_TRACES_EXPORTER: none
|
||||||
volumes:
|
volumes:
|
||||||
@@ -421,7 +438,7 @@ services:
|
|||||||
# ipcrd: TLS front door on 127.0.0.1:4767. Passes /ipfs/<cid> through to the stock nerdctl
|
# 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.
|
# registry below, resolves /ipns/<name>:<tag> itself, and pins every image it serves.
|
||||||
ipcr-gateway:
|
ipcr-gateway:
|
||||||
image: ghcr.io/yundera/ipcr:1.2.0
|
image: ghcr.io/yundera/ipcr:1.3.0
|
||||||
container_name: ipcr-gateway
|
container_name: ipcr-gateway
|
||||||
# Root for one reason: it writes its CA into the host's Docker trust store (the narrow
|
# 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.
|
# 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
|
# /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>.
|
# ENS name can point at: ipcr.localhost:4767/ipns/example.eth/<repo>:<tag>.
|
||||||
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
|
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
|
IMPORT_INTERVAL: 60s
|
||||||
STATE_DIR: /data/state
|
STATE_DIR: /data/state
|
||||||
volumes:
|
volumes:
|
||||||
@@ -477,6 +506,8 @@ services:
|
|||||||
- ipcr-internal
|
- ipcr-internal
|
||||||
# Forge only: reaches the staging registry for the import poller above.
|
# Forge only: reaches the staging registry for the import poller above.
|
||||||
- ci-internal
|
- ci-internal
|
||||||
|
# Forge only: the admin API, reached by the bridge.
|
||||||
|
- ipcr-admin
|
||||||
cpu_shares: 50
|
cpu_shares: 50
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -486,7 +517,7 @@ services:
|
|||||||
# ── Stock nerdctl IPFS registry ─────────────────────────────────────────────
|
# ── Stock nerdctl IPFS registry ─────────────────────────────────────────────
|
||||||
# `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads.
|
# `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads.
|
||||||
ipcr-registry:
|
ipcr-registry:
|
||||||
image: ghcr.io/yundera/ipcr:1.2.0
|
image: ghcr.io/yundera/ipcr:1.3.0
|
||||||
container_name: ipcr-registry
|
container_name: ipcr-registry
|
||||||
# Root because nerdctl run as any other uid assumes rootless mode and refuses to start
|
# 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
|
# 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.
|
# From Apps/IPCR: gateway ↔ registry ↔ kubo, nothing else.
|
||||||
ipcr-internal:
|
ipcr-internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
# Bridge → IPCR's admin API, nothing else; no egress.
|
||||||
|
ipcr-admin:
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
|
|
||||||
x-casaos:
|
x-casaos:
|
||||||
architectures:
|
architectures:
|
||||||
@@ -636,6 +671,12 @@ x-casaos:
|
|||||||
**Start here: [the forge's page](https://ipcr-forge-$APP_DOMAIN)**: how to publish, the
|
**Start here: [the forge's page](https://ipcr-forge-$APP_DOMAIN)**: how to publish, the
|
||||||
published images, the Radicle mirrors.
|
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
|
**Replaces three apps.** Uninstall Gitea, Radicle and IPCR first: this app uses the same
|
||||||
addresses and ports.
|
addresses and ports.
|
||||||
|
|
||||||
@@ -654,14 +695,20 @@ x-casaos:
|
|||||||
private later stops the copy, and what was published stays on the network. Private
|
private later stops the copy, and what was published stays on the network. Private
|
||||||
repositories are never copied.
|
repositories are never copied.
|
||||||
|
|
||||||
**Who can publish images.** Anyone who can push to a repository here: a build can push any
|
**Who can publish images.** Only the names of public repositories that are switched on in the
|
||||||
image name to the staging registry, and IPCR publishes what it finds there under this
|
admin pages (all are, by default): `<owner>/<repo>`. A build in any repository can still
|
||||||
forge's name. Images are public once on IPFS, even from a private repository. Read a
|
push any name to the staging registry, so whoever can push code here can publish under an
|
||||||
fork's workflow before approving its run.
|
allowed name. Read a fork's workflow before approving its run.
|
||||||
fr_fr: |
|
fr_fr: |
|
||||||
**Commencez ici : [la page de la forge](https://ipcr-forge-$APP_DOMAIN)** : publier, les
|
**Commencez ici : [la page de la forge](https://ipcr-forge-$APP_DOMAIN)** : publier, les
|
||||||
images publiées, les copies Radicle.
|
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
|
**Remplace trois applications.** Désinstallez d'abord Gitea, Radicle et IPCR : cette
|
||||||
application utilise les mêmes adresses et ports.
|
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
|
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.
|
ne sont jamais copiés.
|
||||||
|
|
||||||
**Qui peut publier des images.** Toute personne qui peut pousser sur un dépôt d'ici : un
|
**Qui peut publier des images.** Seuls les noms des dépôts publics activés dans
|
||||||
build peut pousser n'importe quel nom d'image vers le registre de transit, et IPCR publie ce
|
l'administration (tous, par défaut) : `<propriétaire>/<dépôt>`. Un build de n'importe quel
|
||||||
qu'il y trouve sous le nom de cette forge. Une image est publique une fois sur IPFS, même
|
dépôt peut tout de même pousser n'importe quel nom vers le registre de transit : qui peut
|
||||||
si elle vient d'un dépôt privé. Lisez le workflow d'un fork avant d'approuver son exécution.
|
pousser du code ici peut publier sous un nom autorisé. Lisez le workflow d'un fork avant
|
||||||
|
d'approuver son exécution.
|
||||||
index: /
|
index: /
|
||||||
webui_port: 3000
|
webui_port: 3000
|
||||||
store_app_id: ipcr-forge
|
store_app_id: ipcr-forge
|
||||||
@@ -739,6 +787,12 @@ x-compose-app:
|
|||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
mode: "0700"
|
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
|
- path: /DATA/AppData/$AppID/ipcr/init.d
|
||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
@@ -806,10 +860,10 @@ x-compose-app:
|
|||||||
chmod 600 /runner/token
|
chmod 600 /runner/token
|
||||||
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
|
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
|
||||||
# The bridge's Gitea token (public-only, read:repository), the webhook secret, the system
|
# 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.
|
# The admin password is used here only, never stored.
|
||||||
- name: forge-setup
|
- 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
|
user: $PUID:$PGID
|
||||||
phase: post_up
|
phase: post_up
|
||||||
network: $APP_NET
|
network: $APP_NET
|
||||||
@@ -818,7 +872,7 @@ x-compose-app:
|
|||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -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
|
when: always
|
||||||
webui-host: gitea-${domain}
|
webui-host: gitea-${domain}
|
||||||
webui-path: /
|
webui-path: /
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ with. See docs/forge.md.
|
|||||||
7. **Public repositories are published to Radicle automatically**, and that cannot be undone.
|
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,
|
8. **It replaces three apps** and cannot be installed beside them: it uses the same container names,
|
||||||
domains and host ports.
|
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
|
## 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
|
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.
|
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
|
## Security mitigations in place
|
||||||
|
|
||||||
**The bridge**
|
**The bridge**
|
||||||
@@ -117,8 +138,26 @@ on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
|
|||||||
public on IPFS.
|
public on IPFS.
|
||||||
|
|
||||||
**The page**
|
**The page**
|
||||||
- **It cannot change anything.** It is served by the bridge's own read-only HTTP handler: the
|
- **The public page cannot change anything.** The bridge serves it read-only: the embedded page,
|
||||||
embedded page, IPCR's `published.json` (mounted read-only) and the bridge's repository list.
|
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
|
## Alternatives considered and rejected
|
||||||
|
|
||||||
@@ -141,8 +180,17 @@ on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
|
|||||||
namespace only.
|
namespace only.
|
||||||
- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only
|
- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only
|
||||||
delegate.
|
delegate.
|
||||||
- **Image names are not tied to repositories.** A build may push any name to the staging registry.
|
- **Image names are limited, but their source is not proven.** Only allowed names (public
|
||||||
Per-repository credentials, issued by the bridge, are a planned follow-up.
|
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
|
## 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 |
|
| `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config |
|
||||||
| `radicle/` | node identity (key pair), seeded repositories, Caddy 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) |
|
| `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
|
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
|
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
|
# 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.
|
# else (blocks fetched but not pinned) is swept every Datastore.GCPeriod.
|
||||||
ipfs config Datastore.StorageMax "${IPFS_STORAGE_MAX:-20GB}"
|
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
@@ -96,14 +96,55 @@ code checks visibility again anyway.
|
|||||||
- **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a
|
- **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
|
distribution channel here, not the trust root. The `/rad/<rid>/…` image-naming idea in
|
||||||
[naming.md](naming.md) is weaker for it.
|
[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
|
- **Only allowed names are published.** IPCR publishes an image name only if it is on the allowlist
|
||||||
unauthenticated `localhost:5000`, and IPCR publishes whatever it finds there under the forge's IPNS
|
the bridge keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus
|
||||||
key, including images built from private repositories. Registration is off, so every user was
|
those switched off in the admin pages. Images built from private repositories are therefore not
|
||||||
created by the admin.
|
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.
|
- **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.
|
- 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
|
- **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
|
## 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
|
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.
|
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
|
## Verified
|
||||||
|
|
||||||
**Locally** (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the bridge image, one Docker network):
|
**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`) |
|
| `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 |
|
| 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
|
## Open items
|
||||||
|
|
||||||
- **Pull from a second server.** Verified on holyhorse itself only; v2's images pulled across
|
- **Pull from a second server.** Verified on holyhorse itself only; v2's images pulled across
|
||||||
servers the same way.
|
servers the same way.
|
||||||
- **Publish the images**: `ghcr.io/yundera/ipcr:1.2.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.1.0`
|
- **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`, tag `bridge-v0.1.0`).
|
(workflows `image.yml` and `bridge.yml`, tags `v1.3.0` and `bridge-v0.2.0`).
|
||||||
- **Per-repository image names:** the bridge as a registry token server (see Trust).
|
- **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.
|
- **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.
|
- **Radicle → Gitea:** patches opened on Radicle could become pull requests. Not built.
|
||||||
- **Store install:** test through the store, not only as a hand install.
|
- **Store install:** test through the store, not only as a hand install.
|
||||||
|
|||||||
+257
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func sign(secret, body string) string {
|
func sign(secret, body string) string {
|
||||||
@@ -197,3 +202,229 @@ func TestWeb(t *testing.T) {
|
|||||||
t.Errorf("/nothing: %d", c)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -30,6 +30,15 @@
|
|||||||
// IPCR_PUBLISHED IPCR's published.json, served as /images.json
|
// IPCR_PUBLISHED IPCR's published.json, served as /images.json
|
||||||
// (default /srv/ipcr-state/published.json)
|
// (default /srv/ipcr-state/published.json)
|
||||||
// RECONCILE full pass interval (default 10m)
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -37,6 +46,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,7 +114,6 @@ func serve() error {
|
|||||||
}
|
}
|
||||||
q := newQueue()
|
q := newQueue()
|
||||||
go q.run(m.sync)
|
go q.run(m.sync)
|
||||||
go reconcileLoop(m, q, every)
|
|
||||||
|
|
||||||
hook := &hookHandler{secretFile: secrets + "/hook-secret", enqueue: q.add}
|
hook := &hookHandler{secretFile: secrets + "/hook-secret", enqueue: q.add}
|
||||||
hmux := http.NewServeMux()
|
hmux := http.NewServeMux()
|
||||||
@@ -113,8 +122,45 @@ func serve() error {
|
|||||||
log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux))
|
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)
|
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
|
// 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)
|
q.add(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m.onReconcile != nil {
|
||||||
|
m.onReconcile()
|
||||||
|
}
|
||||||
time.Sleep(every)
|
time.Sleep(every)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type mirror struct {
|
|||||||
state *state
|
state *state
|
||||||
repoDir string // one bare repository per Gitea repository: <id>.git
|
repoDir string // one bare repository per Gitea repository: <id>.git
|
||||||
radHome string
|
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.
|
// sync brings one Gitea repository's Radicle copy up to date, and records the outcome.
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const tokenName = "ipcr-forge-bridge"
|
|||||||
// a system webhook every repository's push/create/delete/repository events → HOOK_URL,
|
// a system webhook every repository's push/create/delete/repository events → HOOK_URL,
|
||||||
// created or repaired (matched by URL), never duplicated.
|
// created or repaired (matched by URL), never duplicated.
|
||||||
// EXAMPLE_REPO a public example repository with a workflow, created when absent.
|
// 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
|
// 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.
|
// 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)
|
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 != "" {
|
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
|
||||||
// Not fatal: the forge works without its example.
|
// Not fatal: the forge works without its example.
|
||||||
if err := ensureExample(g, name); err != nil {
|
if err := ensureExample(g, name); err != nil {
|
||||||
@@ -178,3 +186,70 @@ func ensureExample(g *gitea, name string) error {
|
|||||||
}, nil)
|
}, nil)
|
||||||
return err
|
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
@@ -13,7 +13,7 @@ var webFiles embed.FS
|
|||||||
// webHandler serves the forge's public page. Everything on it is public anyway: links, the
|
// 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
|
// documentation, IPCR's list of published images (public on IPFS) and the list of mirrored
|
||||||
// repositories (public on Radicle). Nothing here changes anything.
|
// 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()
|
mux := http.NewServeMux()
|
||||||
page, _ := webFiles.ReadFile("web/index.html")
|
page, _ := webFiles.ReadFile("web/index.html")
|
||||||
icon, _ := webFiles.ReadFile("web/icon.png")
|
icon, _ := webFiles.ReadFile("web/icon.png")
|
||||||
@@ -47,3 +47,29 @@ func webHandler(st *state, publishedFile string) http.Handler {
|
|||||||
})
|
})
|
||||||
return mux
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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><owner>/<repo></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>
|
||||||
@@ -315,7 +315,9 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
|
|||||||
var tb = table(["Tag", "Compose line", ""]);
|
var tb = table(["Tag", "Compose line", ""]);
|
||||||
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
|
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
|
||||||
var info = r.tags[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 tr = el("tr");
|
||||||
var td1 = el("td"); td1.appendChild(el("code", tag));
|
var td1 = el("td"); td1.appendChild(el("code", tag));
|
||||||
if (info.at) td1.appendChild(el("div", when(info.at), "muted"));
|
if (info.at) td1.appendChild(el("div", when(info.at), "muted"));
|
||||||
|
|||||||
Reference in New Issue
Block a user