IPCR Forge: org-root image names and per-repository push credentials
Short names: the public repositories of one Gitea organisation (IPCR_ROOT_ORG, then the admin page; metadec on our boxes) are also published at the root of the forge's name, so ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0 works. Collisions (a user/org of that name, another owner's folder) are skipped and explained. Push credentials: the staging registry now sits behind forge-gate (ipcr-forge-bridge gate), in the CI daemon's network namespace, with the registry on a unix socket and no network. Each publishing repository logs in as <owner>/<repo> with an IPCR_PUSH_TOKEN Actions secret the bridge sets, and may push its own name only; IPCR logs in with a credential it generates. Workflows need a docker/login-action step (example and page updated). ipcr 1.4.0: allow.aliases with per-pass alias sync; unpublish/retag on every path; IMPORT_AUTH_GENERATE; names this node publishes resolve via name/resolve --nocache (Kubo's resolve cache lagged, and the generic resolve ignores nocache). bridge 0.3.0: gate, push credentials (rotate in the admin page), root organisation setting, setup creates the org and the example in it; the Radicle description carries the Gitea path (identical documents gave the same RID); OAuth2 app updated only when hosts change, keeping the new secret. Admin page: tabs, one-line keys, name-check feedback, per-tag docker pull commands (IPFS, IPNS, ENS, short path first). 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.2.0
|
image: ghcr.io/yundera/ipcr-forge-bridge:0.3.0
|
||||||
container_name: ipcr-forge
|
container_name: ipcr-forge
|
||||||
user: $PUID:$PGID
|
user: $PUID:$PGID
|
||||||
read_only: true
|
read_only: true
|
||||||
@@ -65,6 +65,9 @@ services:
|
|||||||
IPCR_ADMIN_TOKEN_FILE: /srv/ipcr-state/admin-token
|
IPCR_ADMIN_TOKEN_FILE: /srv/ipcr-state/admin-token
|
||||||
KUBO_KEYSTORE: /srv/ipcr-keystore
|
KUBO_KEYSTORE: /srv/ipcr-keystore
|
||||||
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
|
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
|
||||||
|
# The organisation published at the root of the forge's name (metadec/app → …/app), until
|
||||||
|
# changed in the admin pages. Its members, through Gitea teams, are who can publish there.
|
||||||
|
IPCR_ROOT_ORG: ${IPCR_ROOT_ORG:-}
|
||||||
tmpfs:
|
tmpfs:
|
||||||
# HOME for git and rad.
|
# HOME for git and rad.
|
||||||
- /tmp
|
- /tmp
|
||||||
@@ -271,11 +274,43 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- ci-internal
|
- ci-internal
|
||||||
|
|
||||||
# The staging registry, in the daemon's network namespace: `localhost:5000` to the daemon and to
|
# The staging registry's front door: `localhost:5000` to the daemon and to every job (act runs
|
||||||
# every job (act runs jobs on the host network, which is this namespace), so a plain
|
# jobs on the host network, which is this namespace), forge-docker:5000 to ipcr-gateway. Plain
|
||||||
# `docker push` or buildx with `network=host` works without TLS or credentials — Docker allows
|
# HTTP, which Docker allows to localhost only. It checks a login on every request: a job logs in
|
||||||
# plain HTTP to localhost only. ipcr-gateway reaches it as forge-docker:5000. Nothing
|
# as its repository (<owner>/<repo>, the IPCR_PUSH_TOKEN secret the bridge sets on it) and may
|
||||||
# outside ci-internal can.
|
# push that name only; IPCR logs in as the importer (state/staging-auth, which it generates).
|
||||||
|
# Same image as the bridge (`ipcr-forge-bridge gate`, bridge/gate.go). See rationale.md.
|
||||||
|
forge-gate:
|
||||||
|
image: ghcr.io/yundera/ipcr-forge-bridge:0.3.0
|
||||||
|
container_name: forge-gate
|
||||||
|
user: $PUID:$PGID
|
||||||
|
read_only: true
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: service:forge-docker
|
||||||
|
command: ["ipcr-forge-bridge", "gate"]
|
||||||
|
cpu_shares: 20
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 64M
|
||||||
|
environment:
|
||||||
|
TZ: $TZ
|
||||||
|
GATE_LISTEN: ":5000"
|
||||||
|
GATE_UPSTREAM: unix:///run/staging/registry.sock
|
||||||
|
GATE_CREDENTIALS: /gate/credentials.json
|
||||||
|
volumes:
|
||||||
|
# Hashes only, written by the bridge.
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state/gate:/gate:ro
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
|
||||||
|
depends_on:
|
||||||
|
- forge-docker
|
||||||
|
- forge-registry
|
||||||
|
|
||||||
|
# The staging registry itself: no network at all. It listens on a unix socket in a folder only the
|
||||||
|
# gate also mounts, so nothing reaches it but through the gate: not a job, which runs in the
|
||||||
|
# daemon above and sees neither the folder nor a port.
|
||||||
forge-registry:
|
forge-registry:
|
||||||
image: registry:3.0.0
|
image: registry:3.0.0
|
||||||
container_name: forge-registry
|
container_name: forge-registry
|
||||||
@@ -283,7 +318,7 @@ services:
|
|||||||
cap_drop: [ALL]
|
cap_drop: [ALL]
|
||||||
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: none
|
||||||
# Garbage collection frees the blobs of deleted tags, and must not run while the registry
|
# Garbage collection frees the blobs of deleted tags, and must not run while the registry
|
||||||
# serves: so it runs at every start, before serving.
|
# 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"]
|
entrypoint: ["sh", "-c", "registry garbage-collect --delete-untagged /etc/distribution/config.yml; exec registry serve /etc/distribution/config.yml"]
|
||||||
@@ -294,15 +329,17 @@ services:
|
|||||||
memory: 128M
|
memory: 128M
|
||||||
environment:
|
environment:
|
||||||
TZ: $TZ
|
TZ: $TZ
|
||||||
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
|
REGISTRY_HTTP_NET: unix
|
||||||
|
REGISTRY_HTTP_ADDR: /run/staging/registry.sock
|
||||||
|
# Upload URLs relative to the request, whatever Host the gate passes on.
|
||||||
|
REGISTRY_HTTP_RELATIVEURLS: "true"
|
||||||
# Staging only: IPCR has its own copy once imported, and deletes each tag it has published
|
# Staging only: IPCR has its own copy once imported, and deletes each tag it has published
|
||||||
# (IMPORT_CLEANUP).
|
# (IMPORT_CLEANUP).
|
||||||
REGISTRY_STORAGE_DELETE_ENABLED: "true"
|
REGISTRY_STORAGE_DELETE_ENABLED: "true"
|
||||||
OTEL_TRACES_EXPORTER: none
|
OTEL_TRACES_EXPORTER: none
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry:/var/lib/registry
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry:/var/lib/registry
|
||||||
depends_on:
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
|
||||||
- forge-docker
|
|
||||||
|
|
||||||
# ═══ Radicle — the public mirror ═══════════════════════════════════════════════
|
# ═══ Radicle — the public mirror ═══════════════════════════════════════════════
|
||||||
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The bridge
|
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The bridge
|
||||||
@@ -438,7 +475,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.3.0
|
image: ghcr.io/yundera/ipcr:1.4.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.
|
||||||
@@ -462,6 +499,10 @@ services:
|
|||||||
# Forge: watch the CI's staging registry, over ci-internal. Internal, so no credentials.
|
# Forge: watch the CI's staging registry, over ci-internal. Internal, so no credentials.
|
||||||
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000}
|
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000}
|
||||||
IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-}
|
IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-}
|
||||||
|
# Forge: IPCR's login at the staging registry's gate, generated on first start; the bridge
|
||||||
|
# reads it (read-only ipcr/state) and gives it the importer's rights at the gate.
|
||||||
|
IMPORT_AUTH_FILE: /data/state/staging-auth
|
||||||
|
IMPORT_AUTH_GENERATE: "true"
|
||||||
IMPORT_REPOS: ${IPCR_IMPORT_REPOS:-}
|
IMPORT_REPOS: ${IPCR_IMPORT_REPOS:-}
|
||||||
# Forge: every repository this node builds is published under one IPNS key, "forge", as
|
# Forge: every repository this node builds is published under one IPNS key, "forge", as
|
||||||
# /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
|
||||||
@@ -517,7 +558,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.3.0
|
image: ghcr.io/yundera/ipcr:1.4.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
|
||||||
@@ -695,10 +736,13 @@ 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.** Only the names of public repositories that are switched on in the
|
**Who can publish images.** Each public repository (switched on in the admin pages: all are,
|
||||||
admin pages (all are, by default): `<owner>/<repo>`. A build in any repository can still
|
by default) publishes under its own name, `<owner>/<repo>`: its workflow logs in to the
|
||||||
push any name to the staging registry, so whoever can push code here can publish under an
|
staging registry with the `IPCR_PUSH_TOKEN` secret the forge sets on it. The repositories of
|
||||||
allowed name. Read a fork's workflow before approving its run.
|
the root organisation (`IPCR_ROOT_ORG`, then the admin pages) are also published at the root,
|
||||||
|
`<repo>`: its members decide who publishes there. Workflows from forks get no secrets.
|
||||||
|
CI runs on a privileged Docker daemon: only give write access to people you trust with the
|
||||||
|
server.
|
||||||
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.
|
||||||
@@ -727,11 +771,13 @@ 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.** Seuls les noms des dépôts publics activés dans
|
**Qui peut publier des images.** Chaque dépôt public (activé dans l'administration : tous,
|
||||||
l'administration (tous, par défaut) : `<propriétaire>/<dépôt>`. Un build de n'importe quel
|
par défaut) publie sous son propre nom, `<propriétaire>/<dépôt>` : son workflow se connecte
|
||||||
dépôt peut tout de même pousser n'importe quel nom vers le registre de transit : qui peut
|
au registre de transit avec le secret `IPCR_PUSH_TOKEN` que la forge y dépose. Les dépôts de
|
||||||
pousser du code ici peut publier sous un nom autorisé. Lisez le workflow d'un fork avant
|
l'organisation racine (`IPCR_ROOT_ORG`, puis l'administration) sont aussi publiés à la
|
||||||
d'approuver son exécution.
|
racine, `<dépôt>` : ses membres décident qui y publie. Les workflows des forks n'ont pas de
|
||||||
|
secrets. La CI tourne sur un démon Docker privilégié : ne donnez l'accès en écriture qu'à des
|
||||||
|
personnes à qui vous confiez le serveur.
|
||||||
index: /
|
index: /
|
||||||
webui_port: 3000
|
webui_port: 3000
|
||||||
store_app_id: ipcr-forge
|
store_app_id: ipcr-forge
|
||||||
@@ -747,6 +793,10 @@ x-compose-app:
|
|||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
mode: "0700"
|
mode: "0700"
|
||||||
|
# The gate's credentials (hashes), written by the bridge, read by the gate.
|
||||||
|
- path: /DATA/AppData/$AppID/bridge/state/gate
|
||||||
|
user: $PUID
|
||||||
|
group: $PGID
|
||||||
# ── Gitea ──
|
# ── Gitea ──
|
||||||
- path: /DATA/AppData/$AppID/gitea/data
|
- path: /DATA/AppData/$AppID/gitea/data
|
||||||
user: $PUID
|
user: $PUID
|
||||||
@@ -782,6 +832,11 @@ x-compose-app:
|
|||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
mode: "0700"
|
mode: "0700"
|
||||||
|
# The staging registry's socket: the registry and the gate only.
|
||||||
|
- path: /DATA/AppData/$AppID/ci/registry-socket
|
||||||
|
user: $PUID
|
||||||
|
group: $PGID
|
||||||
|
mode: "0700"
|
||||||
# ── IPCR ──
|
# ── IPCR ──
|
||||||
- path: /DATA/AppData/$AppID/ipcr/kubo
|
- path: /DATA/AppData/$AppID/ipcr/kubo
|
||||||
user: $PUID
|
user: $PUID
|
||||||
@@ -859,11 +914,12 @@ x-compose-app:
|
|||||||
mv /runner/token.new /runner/token
|
mv /runner/token.new /runner/token
|
||||||
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 tokens (public-only read; and write:repository for push secrets), the
|
||||||
# webhook, the admin pages' OAuth2 app, and the example repository. Idempotent, so on every start: it repairs the webhook.
|
# webhook secret, the system webhook, the admin pages' OAuth2 app, the root organisation and
|
||||||
|
# the example repository (in that organisation). 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.2.0
|
image: ghcr.io/yundera/ipcr-forge-bridge:0.3.0
|
||||||
user: $PUID:$PGID
|
user: $PUID:$PGID
|
||||||
phase: post_up
|
phase: post_up
|
||||||
network: $APP_NET
|
network: $APP_NET
|
||||||
@@ -872,7 +928,7 @@ x-compose-app:
|
|||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- 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
|
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 IPCR_ROOT_ORG="$IPCR_ROOT_ORG" 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: /
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ with. See docs/forge.md.
|
|||||||
10. **The bridge mounts Kubo's keystore read-only**, so it can export the publisher key, encrypted.
|
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
|
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.
|
token is in `ipcr/state/admin-token`, mode 0644.
|
||||||
|
12. **A staging gate** (`forge-gate`), running in the CI daemon's network namespace. The registry
|
||||||
|
behind it has no network and listens on a unix socket.
|
||||||
|
13. **A second Gitea token for the bridge,** with `write:repository` and `read:user`. It sets each
|
||||||
|
publishing repository's `IPCR_PUSH_TOKEN` Actions secret and checks name collisions.
|
||||||
|
14. **A credential IPCR generates for itself** (`ipcr/state/staging-auth`, mode 0644), read by the
|
||||||
|
bridge.
|
||||||
|
15. **The root organisation** (`IPCR_ROOT_ORG`): setup creates it if missing, and its repositories
|
||||||
|
are also published at the root of the forge's name.
|
||||||
|
|
||||||
## Why it is necessary
|
## Why it is necessary
|
||||||
|
|
||||||
@@ -103,6 +111,27 @@ needed because CI jobs on `ci-internal` can reach `ipcr-gateway`. The file is wo
|
|||||||
because ipcrd, root without `CAP_CHOWN`, cannot hand a file to `$PUID` any other way. It sits in
|
because ipcrd, root without `CAP_CHOWN`, cannot hand a file to `$PUID` any other way. It sits in
|
||||||
the app's own state folder.
|
the app's own state folder.
|
||||||
|
|
||||||
|
**12. The gate.** Before it, any CI job could push any image name to the staging registry, and IPCR
|
||||||
|
published what it found. With a short organisation address (`metadec.eth/app`), that would let
|
||||||
|
anyone with write access to any repository publish under the organisation's name. The gate makes
|
||||||
|
each repository's credential good for its own name only. It is a proxy rather than a registry
|
||||||
|
token server: Docker, BuildKit and ipcrd all answer a Basic challenge, so no JWT signing or second
|
||||||
|
realm is needed. The registry moves to a unix socket so the gate cannot be bypassed: the jobs'
|
||||||
|
daemon sees neither the socket's folder nor a port.
|
||||||
|
|
||||||
|
**13. The secrets token.** Gitea Actions secrets are the one credential Gitea gives a repository's
|
||||||
|
own workflows and withholds from forks. The job's automatic `GITEA_TOKEN` cannot prove which
|
||||||
|
repository it belongs to. Setting a secret takes `write:repository`. The token is not
|
||||||
|
`public-only`, so collision checks also see private users and organisations.
|
||||||
|
|
||||||
|
**14. IPCR's own credential.** Same handoff as the admin token: ipcrd (root without capabilities)
|
||||||
|
cannot write into another uid's folder, so it writes into its own state folder, which the bridge
|
||||||
|
already mounts read-only.
|
||||||
|
|
||||||
|
**15. The root organisation.** Short names (`metadec.eth/ipcr-hello`) need one owner whose
|
||||||
|
repositories may use them. A Gitea organisation makes "who may publish there" a matter of team
|
||||||
|
membership, managed in Gitea.
|
||||||
|
|
||||||
## Security mitigations in place
|
## Security mitigations in place
|
||||||
|
|
||||||
**The bridge**
|
**The bridge**
|
||||||
@@ -137,6 +166,22 @@ the app's own state folder.
|
|||||||
being able to publish images under the forge's name. Images from private repositories become
|
being able to publish images under the forge's name. Images from private repositories become
|
||||||
public on IPFS.
|
public on IPFS.
|
||||||
|
|
||||||
|
**The staging gate**
|
||||||
|
- **Least privilege per credential.**
|
||||||
|
- A repository's credential reads and writes only `/v2/<owner>/<repo>/{blobs,manifests,tags}/`:
|
||||||
|
no catalog, no delete, no other name.
|
||||||
|
- Cross-repository blob mounts are stripped.
|
||||||
|
- The importer can read, list and delete, but not push.
|
||||||
|
- Paths with `..`, `//` or encoded segments are refused.
|
||||||
|
- **The gate holds no secrets.** It stores SHA-256 hashes of random 32-byte tokens and compares
|
||||||
|
them in constant time. The tokens themselves exist only in Gitea's secret store and in the jobs
|
||||||
|
that use them.
|
||||||
|
- **The registry cannot be reached directly.** It runs with `network_mode: none`, and its socket
|
||||||
|
folder (0700, `$PUID`) is mounted only into the gate.
|
||||||
|
- **Credentials follow the list.** A repository switched off or frozen loses its gate credential
|
||||||
|
and its secret at once. "Reissue" rotates a token.
|
||||||
|
- **Gate container:** `$PUID`, no capabilities, read-only root filesystem, 64 MB.
|
||||||
|
|
||||||
**The page**
|
**The page**
|
||||||
- **The public page cannot change anything.** The bridge serves it read-only: the embedded page,
|
- **The public page cannot change anything.** The bridge serves it read-only: the embedded page,
|
||||||
IPCR's `published.json` (mounted read-only) and the bridge's repository list.
|
IPCR's `published.json` (mounted read-only) and the bridge's repository list.
|
||||||
@@ -180,10 +225,14 @@ the app's own state folder.
|
|||||||
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 limited, but their source is not proven.** Only allowed names (public
|
- **The gate is not a sandbox.** Jobs run on a privileged Docker-in-Docker daemon (deviation 1).
|
||||||
repositories that are switched on) are published. A build in any repository can still push an
|
Anyone who can run a workflow can break out of it to the host, and from there do anything,
|
||||||
allowed name to the staging registry. Per-repository credentials, issued by the bridge, are a
|
including publish under any name. The gate removes the ordinary path, a plain `docker push` of
|
||||||
planned follow-up.
|
someone else's name. It cannot contain a hostile CI user. Real isolation needs a VM-based runner.
|
||||||
|
- **Repository writers can read the push token,** since a workflow can print it. Reissue it when a
|
||||||
|
repository changes hands.
|
||||||
|
- **Existing workflows must add a login step,** or their pushes fail with 401.
|
||||||
|
- **Private repositories cannot publish:** they get no push credential.
|
||||||
- **The bridge can read the publisher key.** A compromise of the public-facing bridge leaks it. That
|
- **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.
|
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.
|
- **The admin token is world-readable** inside the app's state folder on the host.
|
||||||
@@ -200,8 +249,8 @@ 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, 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 |
|
| `bridge/` | `state/`: the Gitea → Radicle mapping, one bare mirror per repository (disposable), the admin session key and the repository toggles (`admin.json`); `gate/credentials.json` (token hashes); `secrets/` (0700): its two Gitea tokens, 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) and its socket (`registry-socket/`) |
|
||||||
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `config.json` (name, allowlist), the admin token |
|
| `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
|
||||||
|
|||||||
+106
-11
@@ -100,13 +100,20 @@ code checks visibility again anyway.
|
|||||||
the bridge keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus
|
the bridge keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus
|
||||||
those switched off in the admin pages. Images built from private repositories are therefore not
|
those switched off in the admin pages. Images built from private repositories are therefore not
|
||||||
published.
|
published.
|
||||||
- **What the allowlist does not cover:** a job can still push any name to the unauthenticated
|
- **Each repository publishes its own name only.** The staging registry sits behind a gate (see
|
||||||
`localhost:5000`. So anyone who can push code here can publish under an allowed name.
|
[Staging gate](#staging-gate-and-push-credentials)). A job logs in with its repository's
|
||||||
Registration is off, so every user was created by the admin.
|
`IPCR_PUSH_TOKEN`, and may push `<owner>/<repo>` and nothing else. Gitea does not give secrets to
|
||||||
|
workflows from forks, so a fork's pull request cannot publish.
|
||||||
|
- **The root organisation's short names belong to its members.** Gitea teams decide who can push
|
||||||
|
to `metadec/*`, so who can publish `metadec.eth/<repo>`.
|
||||||
|
- **Not a sandbox.** Jobs run on a privileged Docker-in-Docker daemon. Anyone who can run a
|
||||||
|
workflow here can break out of it to the host, and from there to everything, including the
|
||||||
|
gate. The gate stops the ordinary way to publish someone else's name; it does not contain a
|
||||||
|
hostile CI user. 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
|
- **Who can read a push token:** anyone with write access to its repository (a workflow can print
|
||||||
server, would tie each image name to the one repository allowed to build it.
|
it). Reissue it in the admin pages when a repository changes hands.
|
||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
@@ -146,13 +153,68 @@ Then publish with a sequence number above the network's.
|
|||||||
The bridge seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the
|
The bridge seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the
|
||||||
network.
|
network.
|
||||||
|
|
||||||
|
## Root organisation (short names)
|
||||||
|
|
||||||
|
The public repositories of one Gitea organisation are also published at the root of the forge's
|
||||||
|
name. The organisation is `metadec` on our boxes (`IPCR_ROOT_ORG`, then the admin page's
|
||||||
|
Repositories tab). So `metadec/ipcr-hello` pulls as any of:
|
||||||
|
```
|
||||||
|
ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0
|
||||||
|
ipcr.localhost:4767/ipns/metadec.eth/metadec/ipcr-hello:1.0.0
|
||||||
|
ipcr.localhost:4767/ipns/k51…/ipcr-hello:1.0.0
|
||||||
|
```
|
||||||
|
- **How it is passed on:** the bridge sends IPCR `allow.aliases` (`{"metadec/ipcr-hello":
|
||||||
|
["ipcr-hello"]}`). IPCR publishes each tag under the full path and every alias in one tree
|
||||||
|
update.
|
||||||
|
- **Alias sync:** on each pass IPCR adds and removes alias folders to match. Switching the
|
||||||
|
organisation off or changing it moves the short paths within a minute.
|
||||||
|
- **Collisions:** a short path is one folder at the root of the tree, so it is skipped (and the
|
||||||
|
Repositories tab says why) when its name is:
|
||||||
|
- a Gitea user or organisation, whose repositories would share that folder;
|
||||||
|
- the owner of another allowed repository.
|
||||||
|
|
||||||
|
IPCR checks the owner case again on its side.
|
||||||
|
- **Setup:** creates the organisation (public, owned by `gitea_admin`) if it is missing, and puts
|
||||||
|
the example repository in it.
|
||||||
|
|
||||||
|
## Staging gate and push credentials
|
||||||
|
|
||||||
|
```
|
||||||
|
job ── docker login localhost:5000 (owner/repo + IPCR_PUSH_TOKEN) ──▶ forge-gate :5000 (in the CI
|
||||||
|
IPCR ── Basic ipcr:<generated> ── forge-docker:5000 ───────────────▶ daemon's network namespace)
|
||||||
|
│ unix socket
|
||||||
|
▼
|
||||||
|
forge-registry (no network)
|
||||||
|
```
|
||||||
|
- **`forge-gate`** is `ipcr-forge-bridge gate`, built from the same image.
|
||||||
|
- It checks Basic auth against `bridge/state/gate/credentials.json` (hashes only), re-read when
|
||||||
|
it changes.
|
||||||
|
- **A repository** may GET, HEAD, POST, PUT and PATCH under `/v2/<owner>/<repo>/{blobs,manifests,tags}/`.
|
||||||
|
Cross-repository blob mounts are stripped, so the client uploads instead.
|
||||||
|
- **The importer** may read everything, list the catalog and delete manifests.
|
||||||
|
- Anything else gets 401 or 403.
|
||||||
|
- **`forge-registry`** listens on a unix socket in a folder only it and the gate mount. Jobs run in
|
||||||
|
the daemon, which sees neither that folder nor a port. So the gate cannot be bypassed short of a
|
||||||
|
breakout (see Trust).
|
||||||
|
- **Credentials:**
|
||||||
|
- **Per repository:** the bridge generates a token for each repository that may publish. It
|
||||||
|
sets the repository's `IPCR_PUSH_TOKEN` Actions secret through a `write:repository` token
|
||||||
|
(`ipcr-forge-secrets`, minted at setup) and writes the token's hash for the gate. A
|
||||||
|
repository switched off or frozen loses both. "Reissue" in the admin page rotates the token.
|
||||||
|
- **IPCR:** IPCR generates its own credential at its first start (`IMPORT_AUTH_GENERATE`,
|
||||||
|
`state/staging-auth`), and the bridge passes its hash to the gate.
|
||||||
|
- **Migration:** existing workflows must add the login step (the template below). Without it, a
|
||||||
|
push fails with `401 Unauthorized` at its first blob check.
|
||||||
|
|
||||||
## Workflow contract
|
## Workflow contract
|
||||||
|
|
||||||
A repository opts in by committing a workflow under `.gitea/workflows/`. The template is
|
A repository opts in by committing a workflow under `.gitea/workflows/`. The template is
|
||||||
[bridge/example/.gitea/workflows/build.yml](../bridge/example/.gitea/workflows/build.yml):
|
[bridge/example/.gitea/workflows/build.yml](../bridge/example/.gitea/workflows/build.yml):
|
||||||
|
|
||||||
- `images: localhost:5000/${{ github.repository }}`: the staging registry. No login, no secret.
|
- `docker/login-action` to `localhost:5000`, `username: ${{ github.repository }}`,
|
||||||
`docker/metadata-action` lowercases the name.
|
`password: ${{ secrets.IPCR_PUSH_TOKEN }}`.
|
||||||
|
- `images: localhost:5000/${{ github.repository }}`: the repository's own name at the staging
|
||||||
|
registry, the only one its token may push. `docker/metadata-action` lowercases it.
|
||||||
- `context: .` and `driver-opts: network=host`: see findings 2 and 3.
|
- `context: .` and `driver-opts: network=host`: see findings 2 and 3.
|
||||||
- The image is published as `/ipns/<forge>/<owner>/<repo>:<tag>`.
|
- The image is published as `/ipns/<forge>/<owner>/<repo>:<tag>`.
|
||||||
|
|
||||||
@@ -219,6 +281,24 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
|
|||||||
21. **Gitea skips its consent screen only after a first grant,** even with
|
21. **Gitea skips its consent screen only after a first grant,** even with
|
||||||
`skip_secondary_authorization`. Each admin clicks "Authorize" once.
|
`skip_secondary_authorization`. Each admin clicks "Authorize" once.
|
||||||
|
|
||||||
|
22. **Radicle IDs are derived from the identity document.** `metadec/ipcr-hello` (the example,
|
||||||
|
created again in the organisation) had the same name, description and delegate as
|
||||||
|
`gitea_admin/ipcr-hello`, so the same RID. `rad init` failed with "attempt to reinitialize". The
|
||||||
|
bridge now puts the Gitea path in the Radicle description, which makes the document unique per
|
||||||
|
forge.
|
||||||
|
23. **Gitea regenerates an OAuth2 app's secret on every update.** `setup` used to update the app at
|
||||||
|
every start (to follow the hosts) and kept the old secret, which broke the admin login
|
||||||
|
(`unauthorized_client`). It now updates only when the hosts change, and saves the secret Gitea
|
||||||
|
returns.
|
||||||
|
24. **Kubo's resolve cache can lag the node's own publishes.** A tree published again after a change
|
||||||
|
in between kept resolving to the in-between tree for the record's TTL. The generic `resolve` RPC
|
||||||
|
ignores `nocache` (only `name/resolve` honours it). ipcrd now resolves names it publishes,
|
||||||
|
directly or through an ENS/DNSLink name pointing at its key, with `name/resolve --nocache` and
|
||||||
|
then the path under `/ipfs/<tree>`.
|
||||||
|
25. **A gate needs no registry token server.** Docker and BuildKit answer a `Basic` challenge from
|
||||||
|
a plain-HTTP `localhost` registry with the credentials from `docker login`, and ipcrd's client
|
||||||
|
already did the same.
|
||||||
|
|
||||||
## 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):
|
||||||
@@ -256,14 +336,29 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
|
|||||||
| 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 |
|
| 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 |
|
| `forge-registry` restart | garbage collection frees the deleted tags' blobs |
|
||||||
|
|
||||||
|
**Org root and gate, on holyhorse** (ipcr 1.4.0, bridge 0.3.0, 2026-10-05):
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| setup with `IPCR_ROOT_ORG=metadec` | org and `metadec/ipcr-hello` created; `IPCR_PUSH_TOKEN` set on both public repositories; gate knows them and IPCR |
|
||||||
|
| tag `v1.0.0` on `metadec/ipcr-hello` (workflow with login) | built, imported, published as `ipcr-hello` and `metadec/ipcr-hello` |
|
||||||
|
| `docker pull ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0` | works (`metadec.eth` → this forge's key); also by `k51…/ipcr-hello` and `k51…/metadec/ipcr-hello` |
|
||||||
|
| a workflow without login | push refused, 401 |
|
||||||
|
| probe job in `gitea_admin/ipcr-hello` | no login 401; own token: catalog 403, `metadec/ipcr-hello` push or read 403, own repository 202; wrong token 401; `/run/staging` from the daemon: empty, no socket |
|
||||||
|
| root organisation off / on (twice) | short path gone / back on the next pass, by k51 and by ENS |
|
||||||
|
| a user named `ipcr-hello` | short path skipped, reason shown; back when the user is gone |
|
||||||
|
| repository switched off | gate credential and Gitea secret removed; restored when switched on |
|
||||||
|
|
||||||
## 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.3.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.2.0`
|
- **Publish the images**: `ghcr.io/yundera/ipcr:1.4.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.3.0`
|
||||||
(workflows `image.yml` and `bridge.yml`, tags `v1.3.0` and `bridge-v0.2.0`).
|
(workflows `image.yml` and `bridge.yml`, tags `v1.4.0` and `bridge-v0.3.0`).
|
||||||
- **Image provenance:** the bridge as a registry token server, so only a repository's own CI can
|
- **CI isolation:** jobs on a privileged daemon can reach the host. Rootless DinD does not start
|
||||||
push its name (see Trust).
|
on this platform (AppArmor); a VM-based runner would be the real fix.
|
||||||
|
- **Private repositories** cannot publish: the bridge only sees public ones. If wanted, give them
|
||||||
|
push credentials too (the images would still be public on IPFS).
|
||||||
- **Old keys:** `<key>-replaced-*` keys are kept forever, and Kubo keeps republishing them. A
|
- **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.
|
"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.
|
||||||
|
|||||||
+164
-36
@@ -32,8 +32,11 @@ type adminAPI struct {
|
|||||||
tokenFile string // ipcrd's admin-token, read through the read-only ipcr/state mount
|
tokenFile string // ipcrd's admin-token, read through the read-only ipcr/state mount
|
||||||
keystore string // Kubo's keystore folder, read-only
|
keystore string // Kubo's keystore folder, read-only
|
||||||
publisher string // the publisher key's name (IMPORT_PUBLISHER)
|
publisher string // the publisher key's name (IMPORT_PUBLISHER)
|
||||||
settings string // admin.json: the repositories switched off
|
settings string // admin.json: the repositories switched off, the root organisation
|
||||||
gitea *gitea
|
gitea *gitea // public-only, read: lists the repositories
|
||||||
|
admin *gitea // admin-scoped: name collisions (any user or org), push secrets
|
||||||
|
creds *pushCreds
|
||||||
|
rootOrg string // default root organisation (IPCR_ROOT_ORG), until set in the admin page
|
||||||
au *auth
|
au *auth
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
|
||||||
@@ -43,6 +46,24 @@ type adminAPI struct {
|
|||||||
type adminSettings struct {
|
type adminSettings struct {
|
||||||
// Repositories (owner/name, lowercase) whose images are not published.
|
// Repositories (owner/name, lowercase) whose images are not published.
|
||||||
Disabled []string `json:"disabled"`
|
Disabled []string `json:"disabled"`
|
||||||
|
// The organisation whose repositories are also published at the root of the forge's name
|
||||||
|
// (metadec/app → /ipns/<forge>/app). nil: IPCR_ROOT_ORG; "": none.
|
||||||
|
RootOrg *string `json:"rootOrg,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminAPI) root() string {
|
||||||
|
if s := a.load(); s.RootOrg != nil {
|
||||||
|
return strings.ToLower(*s.RootOrg)
|
||||||
|
}
|
||||||
|
return strings.ToLower(a.rootOrg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminAPI) save(s adminSettings) error {
|
||||||
|
b, _ := json.MarshalIndent(s, "", " ")
|
||||||
|
if err := os.WriteFile(a.settings+".new", b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(a.settings+".new", a.settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adminAPI) load() adminSettings {
|
func (a *adminAPI) load() adminSettings {
|
||||||
@@ -69,6 +90,9 @@ func (a *adminAPI) routes(mux *http.ServeMux, au *auth) {
|
|||||||
mux.Handle("POST /admin/api/key/export", guard(http.HandlerFunc(a.export)))
|
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("GET /admin/api/repos", guard(http.HandlerFunc(a.listRepos)))
|
||||||
mux.Handle("PUT /admin/api/repos", guard(http.HandlerFunc(a.setRepo)))
|
mux.Handle("PUT /admin/api/repos", guard(http.HandlerFunc(a.setRepo)))
|
||||||
|
mux.Handle("POST /admin/api/repos/rotate", guard(http.HandlerFunc(a.rotate)))
|
||||||
|
mux.Handle("GET /admin/api/settings", guard(http.HandlerFunc(a.getSettings)))
|
||||||
|
mux.Handle("PUT /admin/api/settings", guard(http.HandlerFunc(a.putSettings)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSONResponse(w http.ResponseWriter, v any) {
|
func writeJSONResponse(w http.ResponseWriter, v any) {
|
||||||
@@ -148,8 +172,69 @@ type repoToggle struct {
|
|||||||
Publish bool `json:"publish"`
|
Publish bool `json:"publish"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) {
|
// repoView is a row of the admin page's Repositories tab.
|
||||||
|
type repoView struct {
|
||||||
|
repoToggle
|
||||||
|
PublishedAs []string `json:"published_as,omitempty"` // short path first
|
||||||
|
Credential bool `json:"credential"` // the gate knows its IPCR_PUSH_TOKEN
|
||||||
|
Collision string `json:"collision,omitempty"` // why its short path is not used
|
||||||
|
}
|
||||||
|
|
||||||
|
// pubPlan is what IPCR is told to publish, and why some short paths are not used.
|
||||||
|
type pubPlan struct {
|
||||||
|
Repos []string // owner/repo, lowercase
|
||||||
|
Aliases map[string][]string // owner/repo → short paths
|
||||||
|
Collisions map[string]string // owner/repo → reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// plan computes the allowlist: the eligible public repositories not switched off, and for the root
|
||||||
|
// organisation's, their short path, unless it would collide with an owner's folder: the name of
|
||||||
|
// any Gitea user or organisation (exists), or the owner of another allowed repository.
|
||||||
|
func plan(repos []repo, disabled []string, root string, exists func(string) bool) pubPlan {
|
||||||
|
p := pubPlan{Aliases: map[string][]string{}, Collisions: map[string]string{}}
|
||||||
|
off := map[string]bool{}
|
||||||
|
for _, d := range disabled {
|
||||||
|
off[d] = true
|
||||||
|
}
|
||||||
|
owners := map[string]bool{}
|
||||||
|
for i := range repos {
|
||||||
|
n := strings.ToLower(repos[i].FullName)
|
||||||
|
if eligible(&repos[i]) && !off[n] {
|
||||||
|
p.Repos = append(p.Repos, n)
|
||||||
|
owners[strings.SplitN(n, "/", 2)[0]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(p.Repos)
|
||||||
|
if root == "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
for _, n := range p.Repos {
|
||||||
|
owner, name, _ := strings.Cut(n, "/")
|
||||||
|
if owner != root {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case owners[name]:
|
||||||
|
p.Collisions[n] = "short path /" + name + " is the folder of " + name + "'s own repositories"
|
||||||
|
case exists != nil && exists(name):
|
||||||
|
p.Collisions[n] = "a Gitea user or organisation is named " + name + ": its repositories would share /" + name
|
||||||
|
default:
|
||||||
|
p.Aliases[n] = []string{name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminAPI) currentPlan() (pubPlan, []repo, error) {
|
||||||
repos, err := a.gitea.publicRepos()
|
repos, err := a.gitea.publicRepos()
|
||||||
|
if err != nil {
|
||||||
|
return pubPlan{}, nil, err
|
||||||
|
}
|
||||||
|
return plan(repos, a.load().Disabled, a.root(), a.admin.exists), repos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, repos, err := a.currentPlan()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
@@ -158,12 +243,18 @@ func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, d := range a.load().Disabled {
|
for _, d := range a.load().Disabled {
|
||||||
disabled[d] = true
|
disabled[d] = true
|
||||||
}
|
}
|
||||||
out := []repoToggle{}
|
out := []repoView{}
|
||||||
for _, rp := range repos {
|
for _, rp := range repos {
|
||||||
if eligible(&rp) {
|
if !eligible(&rp) {
|
||||||
n := strings.ToLower(rp.FullName)
|
continue
|
||||||
out = append(out, repoToggle{FullName: n, Publish: !disabled[n]})
|
|
||||||
}
|
}
|
||||||
|
n := strings.ToLower(rp.FullName)
|
||||||
|
v := repoView{repoToggle: repoToggle{FullName: n, Publish: !disabled[n]}, Collision: p.Collisions[n]}
|
||||||
|
if v.Publish {
|
||||||
|
v.PublishedAs = append(append([]string{}, p.Aliases[n]...), n)
|
||||||
|
v.Credential = a.creds != nil && a.creds.has(n)
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].FullName < out[j].FullName })
|
sort.Slice(out, func(i, j int) bool { return out[i].FullName < out[j].FullName })
|
||||||
writeJSONResponse(w, out)
|
writeJSONResponse(w, out)
|
||||||
@@ -189,58 +280,95 @@ func (a *adminAPI) setRepo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
sort.Strings(keep)
|
sort.Strings(keep)
|
||||||
s.Disabled = keep
|
s.Disabled = keep
|
||||||
b, _ := json.MarshalIndent(s, "", " ")
|
err := a.save(s)
|
||||||
err := os.WriteFile(a.settings+".new", b, 0o644)
|
|
||||||
if err == nil {
|
|
||||||
err = os.Rename(a.settings+".new", a.settings)
|
|
||||||
}
|
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.pushAllowlist(); err != nil {
|
if err := a.pushAllowlist(); err != nil {
|
||||||
http.Error(w, "saved, but IPCR did not take the new list: "+err.Error(), http.StatusServiceUnavailable)
|
http.Error(w, "saved, but not fully applied: "+err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSONResponse(w, req)
|
writeJSONResponse(w, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rotate gives a repository a new IPCR_PUSH_TOKEN: when it changed hands, or the old one leaked.
|
||||||
|
func (a *adminAPI) rotate(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}?", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, _, err := a.currentPlan()
|
||||||
|
if err == nil {
|
||||||
|
err = a.creds.sync(p.Repos, req.FullName)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONResponse(w, map[string]string{"rotated": req.FullName})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminAPI) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSONResponse(w, map[string]string{"rootOrg": a.root(), "rootOrgDefault": strings.ToLower(a.rootOrg)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminAPI) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
RootOrg string `json:"rootOrg"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "{rootOrg}?", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
org := strings.ToLower(strings.TrimSpace(req.RootOrg))
|
||||||
|
if org != "" && !a.admin.exists(org) {
|
||||||
|
http.Error(w, "no Gitea organisation named "+org, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
s := a.load()
|
||||||
|
s.RootOrg = &org
|
||||||
|
err := a.save(s)
|
||||||
|
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 not fully applied: "+err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONResponse(w, map[string]string{"rootOrg": org})
|
||||||
|
}
|
||||||
|
|
||||||
// eligible: the repositories whose images may be published — the same that are mirrored.
|
// 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 }
|
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
|
// pushAllowlist sends ipcrd the current plan and gives the gate the matching credentials. Called
|
||||||
// (localhost:5000/<owner>/<repo>, lowercased by docker/metadata-action).
|
// on every reconcile, toggle and root-organisation change.
|
||||||
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 {
|
func (a *adminAPI) pushAllowlist() error {
|
||||||
repos, err := a.gitea.publicRepos()
|
p, _, err := a.currentPlan()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return a.sendAllowlist(allowlist(repos, a.load().Disabled))
|
if err := a.sendAllowlist(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.creds != nil {
|
||||||
|
return a.creds.sync(p.Repos, "")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adminAPI) sendAllowlist(list []string) error {
|
func (a *adminAPI) sendAllowlist(p pubPlan) error {
|
||||||
|
list := p.Repos
|
||||||
if list == nil {
|
if list == nil {
|
||||||
list = []string{}
|
list = []string{}
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(map[string]any{"allow": map[string]any{"required": true, "repos": list}})
|
body, _ := json.Marshal(map[string]any{"allow": map[string]any{"required": true, "repos": list, "aliases": p.Aliases}})
|
||||||
req, _ := http.NewRequest("PATCH", a.ipcr+"/admin/config", bytes.NewReader(body))
|
req, _ := http.NewRequest("PATCH", a.ipcr+"/admin/config", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+a.token())
|
req.Header.Set("Authorization", "Bearer "+a.token())
|
||||||
|
|||||||
+19
-6
@@ -171,7 +171,8 @@ func TestExampleEmbedded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
b, _ := exampleFiles.ReadFile("example/.gitea/workflows/build.yml")
|
b, _ := exampleFiles.ReadFile("example/.gitea/workflows/build.yml")
|
||||||
if !strings.Contains(string(b), "localhost:5000/${{ github.repository }}") || !strings.Contains(string(b), "network=host") {
|
if !strings.Contains(string(b), "localhost:5000/${{ github.repository }}") || !strings.Contains(string(b), "network=host") ||
|
||||||
|
!strings.Contains(string(b), "secrets.IPCR_PUSH_TOKEN") {
|
||||||
t.Error("example workflow lost its staging registry lines")
|
t.Error("example workflow lost its staging registry lines")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,7 +409,7 @@ func TestCallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAllowlist(t *testing.T) {
|
func TestPlan(t *testing.T) {
|
||||||
mk := func(name string, f func(*repo)) repo {
|
mk := func(name string, f func(*repo)) repo {
|
||||||
r := repo{FullName: name}
|
r := repo{FullName: name}
|
||||||
if f != nil {
|
if f != nil {
|
||||||
@@ -421,10 +422,22 @@ func TestAllowlist(t *testing.T) {
|
|||||||
mk("owner/off", nil),
|
mk("owner/off", nil),
|
||||||
mk("owner/secret", func(r *repo) { r.Private = true }),
|
mk("owner/secret", func(r *repo) { r.Private = true }),
|
||||||
mk("owner/fork", func(r *repo) { r.Fork = true }),
|
mk("owner/fork", func(r *repo) { r.Fork = true }),
|
||||||
mk("org/thing", nil),
|
mk("Metadec/Hello", nil),
|
||||||
|
mk("metadec/owner", nil), // short path /owner is owner/'s folder
|
||||||
|
mk("metadec/bob", nil), // a Gitea user is named bob
|
||||||
|
mk("metadec/off2", nil),
|
||||||
}
|
}
|
||||||
got := strings.Join(allowlist(repos, []string{"owner/off"}), ",")
|
p := plan(repos, []string{"owner/off", "metadec/off2"}, "metadec", func(n string) bool { return n == "bob" })
|
||||||
if got != "org/thing,owner/app" {
|
if got := strings.Join(p.Repos, ","); got != "metadec/bob,metadec/hello,metadec/owner,owner/app" {
|
||||||
t.Errorf("allowlist = %s", got)
|
t.Errorf("repos = %s", got)
|
||||||
|
}
|
||||||
|
if len(p.Aliases) != 1 || strings.Join(p.Aliases["metadec/hello"], ",") != "hello" {
|
||||||
|
t.Errorf("aliases = %v", p.Aliases)
|
||||||
|
}
|
||||||
|
if p.Collisions["metadec/owner"] == "" || p.Collisions["metadec/bob"] == "" || len(p.Collisions) != 2 {
|
||||||
|
t.Errorf("collisions = %v", p.Collisions)
|
||||||
|
}
|
||||||
|
if q := plan(repos, nil, "", nil); len(q.Aliases) != 0 {
|
||||||
|
t.Errorf("no root organisation, aliases %v", q.Aliases)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
# IPCR picks it up from there within a minute and publishes it on IPFS; the forge's page then
|
# IPCR picks it up from there within a minute and publishes it on IPFS; the forge's page then
|
||||||
# lists the exact `image:` line to use.
|
# lists the exact `image:` line to use.
|
||||||
#
|
#
|
||||||
# Differences from a GitHub-hosted workflow, both needed on this forge:
|
# Differences from a GitHub-hosted workflow, all needed on this forge:
|
||||||
# images: localhost:5000/… the staging registry (no login, no secret)
|
# login to localhost:5000 the staging registry, as this repository: IPCR_PUSH_TOKEN is a secret
|
||||||
|
# the forge sets on every repository allowed to publish
|
||||||
|
# images: localhost:5000/… this repository's own name there (no other is accepted)
|
||||||
# network=host so buildx's builder can reach that registry
|
# network=host so buildx's builder can reach that registry
|
||||||
name: image
|
name: image
|
||||||
on:
|
on:
|
||||||
@@ -14,6 +16,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: localhost:5000
|
||||||
|
username: ${{ github.repository }}
|
||||||
|
password: ${{ secrets.IPCR_PUSH_TOKEN }}
|
||||||
- uses: docker/metadata-action@v5
|
- uses: docker/metadata-action@v5
|
||||||
id: meta
|
id: meta
|
||||||
with:
|
with:
|
||||||
|
|||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The gate is the staging registry's front door (`ipcr-forge-bridge gate`). CI jobs push to
|
||||||
|
// localhost:5000 and IPCR imports from it; before the gate, anyone who could run a job could push
|
||||||
|
// any image name, and IPCR published what it found. Now:
|
||||||
|
//
|
||||||
|
// - a job logs in as its repository: user <owner>/<repo>, password the IPCR_PUSH_TOKEN secret the
|
||||||
|
// bridge set on that repository (pushcreds.go). It may read and write that repository's
|
||||||
|
// manifests, blobs and tags, and nothing else: no other name, no catalog, no delete, no
|
||||||
|
// cross-repository blob mount;
|
||||||
|
// - IPCR logs in as the importer (the credential it generated itself): read everything, list the
|
||||||
|
// catalog, delete manifests it has published.
|
||||||
|
//
|
||||||
|
// It runs in the CI daemon's network namespace, where jobs see it as localhost:5000. The registry
|
||||||
|
// behind it listens on a unix socket shared with the gate only, so a job cannot go around it.
|
||||||
|
//
|
||||||
|
// Environment: GATE_LISTEN (default :5000), GATE_UPSTREAM (default
|
||||||
|
// unix:///run/staging/registry.sock), GATE_CREDENTIALS (default /gate/credentials.json).
|
||||||
|
|
||||||
|
// gateCreds is credentials.json, written by the bridge: SHA-256 (hex) of each password.
|
||||||
|
type gateCreds struct {
|
||||||
|
Push map[string]string `json:"push"` // "<owner>/<repo>" (lowercase) → hash
|
||||||
|
Importer map[string]string `json:"importer"` // user → hash
|
||||||
|
}
|
||||||
|
|
||||||
|
type gate struct {
|
||||||
|
credFile string
|
||||||
|
upstream http.Handler
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
mtime time.Time
|
||||||
|
creds gateCreds
|
||||||
|
}
|
||||||
|
|
||||||
|
func gateMain() error {
|
||||||
|
g := &gate{credFile: env("GATE_CREDENTIALS", "/gate/credentials.json")}
|
||||||
|
up, err := upstreamProxy(env("GATE_UPSTREAM", "unix:///run/staging/registry.sock"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.upstream = up
|
||||||
|
addr := env("GATE_LISTEN", ":5000")
|
||||||
|
log.Printf("gate: %s → %s, credentials %s", addr, env("GATE_UPSTREAM", "unix:///run/staging/registry.sock"), g.credFile)
|
||||||
|
return http.ListenAndServe(addr, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstreamProxy forwards to the registry, keeping the client's Host (the registry builds upload
|
||||||
|
// URLs from it) and dropping the client's credentials (the registry has none of its own).
|
||||||
|
func upstreamProxy(target string) (http.Handler, error) {
|
||||||
|
u, err := url.Parse(target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
dest := &url.URL{Scheme: u.Scheme, Host: u.Host}
|
||||||
|
if u.Scheme == "unix" {
|
||||||
|
sock := u.Path
|
||||||
|
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", sock)
|
||||||
|
}
|
||||||
|
dest = &url.URL{Scheme: "http", Host: "registry"}
|
||||||
|
}
|
||||||
|
return &httputil.ReverseProxy{
|
||||||
|
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||||
|
pr.SetURL(dest)
|
||||||
|
pr.Out.Host = pr.In.Host
|
||||||
|
pr.Out.Header.Del("Authorization")
|
||||||
|
},
|
||||||
|
Transport: tr,
|
||||||
|
FlushInterval: -1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gate) load() gateCreds {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
st, err := os.Stat(g.credFile)
|
||||||
|
if err != nil {
|
||||||
|
return g.creds
|
||||||
|
}
|
||||||
|
if !st.ModTime().Equal(g.mtime) {
|
||||||
|
var c gateCreds
|
||||||
|
if b, err := os.ReadFile(g.credFile); err == nil && json.Unmarshal(b, &c) == nil {
|
||||||
|
g.creds, g.mtime = c, st.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g.creds
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashSecret(s string) string {
|
||||||
|
h := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func matches(stored, pass string) bool {
|
||||||
|
return stored != "" && subtle.ConstantTimeCompare([]byte(stored), []byte(hashSecret(pass))) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryError(w http.ResponseWriter, status int, code, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
|
if status == http.StatusUnauthorized {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="ipcr-staging"`)
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"errors": []map[string]string{{"code": code, "message": msg}}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := r.URL.Path
|
||||||
|
if r.URL.RawPath != "" || strings.Contains(p, "..") || strings.Contains(p, "//") || !strings.HasPrefix(p, "/v2") {
|
||||||
|
registryError(w, http.StatusBadRequest, "NAME_INVALID", "bad path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, pass, _ := r.BasicAuth()
|
||||||
|
user = strings.ToLower(user)
|
||||||
|
creds := g.load()
|
||||||
|
role := ""
|
||||||
|
switch {
|
||||||
|
case matches(creds.Importer[user], pass):
|
||||||
|
role = "importer"
|
||||||
|
case matches(creds.Push[user], pass):
|
||||||
|
role = "push"
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
registryError(w, http.StatusUnauthorized, "UNAUTHORIZED", "log in as <owner>/<repo> with the repository's IPCR_PUSH_TOKEN secret")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed(role, user, r) {
|
||||||
|
log.Printf("gate: %s refused %s %s", user, r.Method, p)
|
||||||
|
registryError(w, http.StatusForbidden, "DENIED", user+" may only push "+user)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role == "push" {
|
||||||
|
// A cross-repository mount would copy a blob from a repository this user may not read.
|
||||||
|
// Without it the client simply uploads the blob.
|
||||||
|
q := r.URL.Query()
|
||||||
|
if q.Has("mount") || q.Has("from") {
|
||||||
|
q.Del("mount")
|
||||||
|
q.Del("from")
|
||||||
|
r.URL.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.upstream.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowed applies the two roles to a request.
|
||||||
|
func allowed(role, user string, r *http.Request) bool {
|
||||||
|
p, m := r.URL.Path, r.Method
|
||||||
|
read := m == "GET" || m == "HEAD"
|
||||||
|
if p == "/v2/" || p == "/v2" {
|
||||||
|
return read
|
||||||
|
}
|
||||||
|
if role == "importer" {
|
||||||
|
if read {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return m == "DELETE" && strings.Contains(p, "/manifests/")
|
||||||
|
}
|
||||||
|
rest, ok := strings.CutPrefix(p, "/v2/"+user+"/")
|
||||||
|
if !ok || user == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rest, "blobs/") && !strings.HasPrefix(rest, "manifests/") && !strings.HasPrefix(rest, "tags/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch m {
|
||||||
|
case "GET", "HEAD", "POST", "PUT", "PATCH":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestGate(t *testing.T) (*gate, *[]*http.Request) {
|
||||||
|
t.Helper()
|
||||||
|
var seen []*http.Request
|
||||||
|
var mu sync.Mutex
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
seen = append(seen, r.Clone(r.Context()))
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
t.Cleanup(up.Close)
|
||||||
|
proxy, err := upstreamProxy(up.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := filepath.Join(t.TempDir(), "credentials.json")
|
||||||
|
writeCreds(file, gateCreds{
|
||||||
|
Push: map[string]string{"metadec/app": hashSecret("app-token"), "alice/tool": hashSecret("alice-token")},
|
||||||
|
Importer: map[string]string{"ipcr": hashSecret("importer-pass")},
|
||||||
|
})
|
||||||
|
return &gate{credFile: file, upstream: proxy}, &seen
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateAuthorization(t *testing.T) {
|
||||||
|
g, _ := newTestGate(t)
|
||||||
|
for _, c := range []struct {
|
||||||
|
name, method, path, user, pass string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"no credentials", "GET", "/v2/", "", "", 401},
|
||||||
|
{"wrong password", "GET", "/v2/", "metadec/app", "nope", 401},
|
||||||
|
{"login check", "GET", "/v2/", "metadec/app", "app-token", 200},
|
||||||
|
{"login, uppercase user", "GET", "/v2/", "Metadec/App", "app-token", 200},
|
||||||
|
{"own manifest", "PUT", "/v2/metadec/app/manifests/1.0.0", "metadec/app", "app-token", 200},
|
||||||
|
{"own blob upload", "POST", "/v2/metadec/app/blobs/uploads/", "metadec/app", "app-token", 200},
|
||||||
|
{"own upload chunk", "PATCH", "/v2/metadec/app/blobs/uploads/abc", "metadec/app", "app-token", 200},
|
||||||
|
{"own blob check", "HEAD", "/v2/metadec/app/blobs/sha256:00", "metadec/app", "app-token", 200},
|
||||||
|
{"other repository", "PUT", "/v2/alice/tool/manifests/1.0.0", "metadec/app", "app-token", 403},
|
||||||
|
{"prefix of another name", "PUT", "/v2/metadec/app2/manifests/1", "metadec/app", "app-token", 403},
|
||||||
|
{"nested name", "PUT", "/v2/metadec/app/sub/manifests/1", "metadec/app", "app-token", 403},
|
||||||
|
{"catalog as pusher", "GET", "/v2/_catalog", "metadec/app", "app-token", 403},
|
||||||
|
{"delete as pusher", "DELETE", "/v2/metadec/app/manifests/sha256:00", "metadec/app", "app-token", 403},
|
||||||
|
{"dot-dot", "GET", "/v2/metadec/app/../alice/tool/tags/list", "metadec/app", "app-token", 400},
|
||||||
|
{"importer catalog", "GET", "/v2/_catalog", "ipcr", "importer-pass", 200},
|
||||||
|
{"importer pull", "GET", "/v2/alice/tool/manifests/1.0.0", "ipcr", "importer-pass", 200},
|
||||||
|
{"importer delete", "DELETE", "/v2/alice/tool/manifests/sha256:00", "ipcr", "importer-pass", 200},
|
||||||
|
{"importer push", "PUT", "/v2/alice/tool/manifests/1.0.0", "ipcr", "importer-pass", 403},
|
||||||
|
} {
|
||||||
|
r := httptest.NewRequest(c.method, "http://localhost:5000"+c.path, nil)
|
||||||
|
if c.user != "" {
|
||||||
|
r.SetBasicAuth(c.user, c.pass)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
g.ServeHTTP(w, r)
|
||||||
|
if w.Code != c.want {
|
||||||
|
t.Errorf("%s: %d, want %d", c.name, w.Code, c.want)
|
||||||
|
}
|
||||||
|
if w.Code == 401 && !strings.HasPrefix(w.Header().Get("WWW-Authenticate"), "Basic ") {
|
||||||
|
t.Errorf("%s: no Basic challenge", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateForwarding(t *testing.T) {
|
||||||
|
g, seen := newTestGate(t)
|
||||||
|
r := httptest.NewRequest("POST", "http://localhost:5000/v2/metadec/app/blobs/uploads/?mount=sha256:00&from=alice/tool", nil)
|
||||||
|
r.Host = "localhost:5000"
|
||||||
|
r.SetBasicAuth("metadec/app", "app-token")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
g.ServeHTTP(w, r)
|
||||||
|
if w.Code != 200 || len(*seen) != 1 {
|
||||||
|
t.Fatalf("%d, %d upstream requests", w.Code, len(*seen))
|
||||||
|
}
|
||||||
|
up := (*seen)[0]
|
||||||
|
if up.URL.RawQuery != "" {
|
||||||
|
t.Errorf("cross-repository mount forwarded: %q", up.URL.RawQuery)
|
||||||
|
}
|
||||||
|
if up.Host != "localhost:5000" {
|
||||||
|
t.Errorf("Host %q: the registry builds upload URLs from it", up.Host)
|
||||||
|
}
|
||||||
|
if up.Header.Get("Authorization") != "" {
|
||||||
|
t.Error("credentials forwarded to the registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateReloadsCredentials(t *testing.T) {
|
||||||
|
g, _ := newTestGate(t)
|
||||||
|
try := func() int {
|
||||||
|
r := httptest.NewRequest("GET", "http://localhost:5000/v2/", nil)
|
||||||
|
r.SetBasicAuth("new/repo", "t")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
g.ServeHTTP(w, r)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
if try() != 401 {
|
||||||
|
t.Fatal("unknown repository accepted")
|
||||||
|
}
|
||||||
|
c := gateCreds{Push: map[string]string{"new/repo": hashSecret("t")}}
|
||||||
|
writeCreds(g.credFile, c)
|
||||||
|
// A different modification time, as a later write would have.
|
||||||
|
st, _ := os.Stat(g.credFile)
|
||||||
|
os.Chtimes(g.credFile, st.ModTime().Add(1e9), st.ModTime().Add(1e9))
|
||||||
|
if try() != 200 {
|
||||||
|
t.Error("new credentials not picked up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync against a fake Gitea: secrets set for new repositories, kept for known ones, deleted for
|
||||||
|
// removed ones, the importer taken from IPCR's file.
|
||||||
|
func TestPushCredsSync(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
secrets := map[string]string{}
|
||||||
|
var deleted []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
repo := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/repos/"), "/actions/secrets/"+pushSecret)
|
||||||
|
switch r.Method {
|
||||||
|
case "PUT":
|
||||||
|
var b struct{ Data string }
|
||||||
|
json.NewDecoder(r.Body).Decode(&b)
|
||||||
|
secrets[repo] = b.Data
|
||||||
|
w.WriteHeader(201)
|
||||||
|
case "DELETE":
|
||||||
|
deleted = append(deleted, repo)
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
tok := filepath.Join(dir, "token")
|
||||||
|
os.WriteFile(tok, []byte("admin-token"), 0o600)
|
||||||
|
auth := filepath.Join(dir, "staging-auth")
|
||||||
|
os.WriteFile(auth, []byte("ipcr:importer-pass\n"), 0o644)
|
||||||
|
p := &pushCreds{file: filepath.Join(dir, "gate", "credentials.json"), stagingAuth: auth, gitea: newGitea(srv.URL, "gitea_admin", tok)}
|
||||||
|
|
||||||
|
if err := p.sync([]string{"Metadec/App", "alice/tool"}, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := p.read()
|
||||||
|
if !matches(c.Push["metadec/app"], secrets["Metadec/App"]) || !matches(c.Push["alice/tool"], secrets["alice/tool"]) {
|
||||||
|
t.Fatalf("credentials do not match the secrets: %v %v", c.Push, secrets)
|
||||||
|
}
|
||||||
|
if !matches(c.Importer["ipcr"], "importer-pass") {
|
||||||
|
t.Error("importer credential missing")
|
||||||
|
}
|
||||||
|
first := secrets["alice/tool"]
|
||||||
|
|
||||||
|
// alice/tool keeps its token; Metadec/App is rotated; a removed repository loses it.
|
||||||
|
if err := p.sync([]string{"Metadec/App", "alice/tool"}, "metadec/app"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if secrets["alice/tool"] != first || !matches(p.read().Push["metadec/app"], secrets["Metadec/App"]) {
|
||||||
|
t.Error("rotation")
|
||||||
|
}
|
||||||
|
if err := p.sync([]string{"Metadec/App"}, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := p.read().Push["alice/tool"]; ok || len(deleted) != 1 || deleted[0] != "alice/tool" {
|
||||||
|
t.Errorf("removal: %v, deleted %v", p.read().Push, deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,3 +140,13 @@ func (g *gitea) repoByID(id int64) (*repo, error) {
|
|||||||
}
|
}
|
||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exists reports whether a Gitea user or organisation has this name (an admin-scoped token sees
|
||||||
|
// private ones too). On doubt (Gitea unreachable) it answers yes: a short path is then not used.
|
||||||
|
func (g *gitea) exists(name string) bool {
|
||||||
|
if g == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
code, err := g.call("GET", "/users/"+url.PathEscape(name), nil, nil)
|
||||||
|
return code == http.StatusOK || (err != nil && code != http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|||||||
+13
-2
@@ -4,6 +4,7 @@
|
|||||||
// serve (default) webhook receiver + mirror worker + the forge's page
|
// serve (default) webhook receiver + mirror worker + the forge's page
|
||||||
// setup install step: Gitea token, system webhook, example repository (idempotent)
|
// setup install step: Gitea token, system webhook, example repository (idempotent)
|
||||||
// health exit 0 when `serve` answers, for the container healthcheck
|
// health exit 0 when `serve` answers, for the container healthcheck
|
||||||
|
// gate the staging registry's front door: per-repository push credentials (gate.go)
|
||||||
//
|
//
|
||||||
// What gets mirrored: every public, non-fork, non-mirror Gitea repository, as seen by a token
|
// What gets mirrored: every public, non-fork, non-mirror Gitea repository, as seen by a token
|
||||||
// scoped `public-only` — a private repository is invisible to the service, not filtered by it.
|
// scoped `public-only` — a private repository is invisible to the service, not filtered by it.
|
||||||
@@ -39,6 +40,10 @@
|
|||||||
// IPCR_ADMIN_TOKEN_FILE its token (default /srv/ipcr-state/admin-token)
|
// 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)
|
// KUBO_KEYSTORE Kubo's keystore, read-only, for key backups (default /srv/ipcr-keystore)
|
||||||
// IMPORT_PUBLISHER the publisher key's name (default forge)
|
// IMPORT_PUBLISHER the publisher key's name (default forge)
|
||||||
|
// IPCR_ROOT_ORG organisation published at the root of the forge's name, until changed in
|
||||||
|
// the admin page (default: none)
|
||||||
|
// GATE_CREDENTIALS the gate's credentials.json (default /bridge/gate/credentials.json)
|
||||||
|
// IPCR_STAGING_AUTH IPCR's own staging credential (default /srv/ipcr-state/staging-auth)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -71,8 +76,10 @@ func main() {
|
|||||||
err = setup()
|
err = setup()
|
||||||
case "health":
|
case "health":
|
||||||
err = health()
|
err = health()
|
||||||
|
case "gate":
|
||||||
|
err = gateMain()
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("usage: %s serve | setup | health", os.Args[0])
|
err = fmt.Errorf("usage: %s serve | setup | health | gate", os.Args[0])
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -128,10 +135,14 @@ func serve() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
adminGitea := newGitea(m.gitea.base, m.gitea.owner, secrets+"/gitea-admin-token")
|
||||||
adm := &adminAPI{
|
adm := &adminAPI{
|
||||||
ipcr: strings.TrimRight(ipcr, "/"), tokenFile: env("IPCR_ADMIN_TOKEN_FILE", "/srv/ipcr-state/admin-token"),
|
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"),
|
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},
|
settings: stateDir + "/admin.json", gitea: m.gitea, admin: adminGitea, rootOrg: os.Getenv("IPCR_ROOT_ORG"),
|
||||||
|
creds: &pushCreds{file: env("GATE_CREDENTIALS", stateDir+"/gate/credentials.json"),
|
||||||
|
stagingAuth: env("IPCR_STAGING_AUTH", "/srv/ipcr-state/staging-auth"), gitea: adminGitea},
|
||||||
|
http: &http.Client{Timeout: 3 * time.Minute},
|
||||||
}
|
}
|
||||||
adminRoutes(mux, au, adm)
|
adminRoutes(mux, au, adm)
|
||||||
// IPCR publishes nothing until it has the list (IMPORT_ALLOW_REQUIRED): send it as soon as
|
// IPCR publishes nothing until it has the list (IMPORT_ALLOW_REQUIRED): send it as soon as
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pushSecret is the Actions secret each publishing repository gets: its password at the gate.
|
||||||
|
const pushSecret = "IPCR_PUSH_TOKEN"
|
||||||
|
|
||||||
|
// pushCreds hands out the gate's credentials. Every repository allowed to publish gets a random
|
||||||
|
// token, set as its IPCR_PUSH_TOKEN Actions secret (Gitea gives secrets to the repository's own
|
||||||
|
// workflows, never to pull requests from forks); the gate only learns the token's hash. A
|
||||||
|
// repository that may no longer publish loses both. The importer's credential is the one IPCR
|
||||||
|
// generated (IMPORT_AUTH_GENERATE), read from its state folder.
|
||||||
|
type pushCreds struct {
|
||||||
|
file string // the gate's credentials.json
|
||||||
|
stagingAuth string // IPCR's "user:password"
|
||||||
|
gitea *gitea // admin-scoped (write:repository): sets and deletes secrets
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pushCreds) read() gateCreds {
|
||||||
|
var c gateCreds
|
||||||
|
if b, err := os.ReadFile(p.file); err == nil {
|
||||||
|
json.Unmarshal(b, &c)
|
||||||
|
}
|
||||||
|
if c.Push == nil {
|
||||||
|
c.Push = map[string]string{}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pushCreds) has(repo string) bool {
|
||||||
|
_, ok := p.read().Push[strings.ToLower(repo)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync makes the gate's credentials match repos (owner/repo, as Gitea names them): new ones get a
|
||||||
|
// token, gone ones lose it. rotate, when set, gets a new token even if it has one.
|
||||||
|
func (p *pushCreds) sync(repos []string, rotate string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
cur := p.read()
|
||||||
|
next := gateCreds{Push: map[string]string{}, Importer: map[string]string{}}
|
||||||
|
var errs []string
|
||||||
|
for _, repo := range repos {
|
||||||
|
key := strings.ToLower(repo)
|
||||||
|
if h, ok := cur.Push[key]; ok && !strings.EqualFold(repo, rotate) {
|
||||||
|
next.Push[key] = h
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tok := randomString(32)
|
||||||
|
if _, err := p.gitea.call("PUT", "/repos/"+repo+"/actions/secrets/"+pushSecret, map[string]string{"data": tok}, nil); err != nil {
|
||||||
|
errs = append(errs, repo+": "+err.Error())
|
||||||
|
if h, ok := cur.Push[key]; ok {
|
||||||
|
next.Push[key] = h // keep the old one working
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next.Push[key] = hashSecret(tok)
|
||||||
|
log.Printf("push credentials: %s gets a new %s", repo, pushSecret)
|
||||||
|
}
|
||||||
|
for key := range cur.Push {
|
||||||
|
if _, ok := next.Push[key]; !ok {
|
||||||
|
// Best effort: without its hash at the gate the secret is useless anyway.
|
||||||
|
p.gitea.call("DELETE", "/repos/"+key+"/actions/secrets/"+pushSecret, nil, nil)
|
||||||
|
log.Printf("push credentials: %s may no longer publish", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(p.stagingAuth); err == nil {
|
||||||
|
if user, pass, ok := strings.Cut(strings.TrimSpace(string(b)), ":"); ok && user != "" && pass != "" {
|
||||||
|
next.Importer[strings.ToLower(user)] = hashSecret(pass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeCreds(p.file, next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
sort.Strings(errs)
|
||||||
|
return fmt.Errorf("could not set %s: %s", pushSecret, strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCreds(path string, c gateCreds) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(c, "", " ")
|
||||||
|
// Hashes only: readable by the gate (same uid), useless to anyone else.
|
||||||
|
if err := os.WriteFile(path+".new", b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(path+".new", path)
|
||||||
|
}
|
||||||
+7
-3
@@ -68,9 +68,13 @@ func (m *mirror) rad(dir string, args ...string) (string, error) {
|
|||||||
// makes this node seed it (the node's policy is `block`: nothing is kept unless asked) and adds
|
// makes this node seed it (the node's policy is `block`: nothing is kept unless asked) and adds
|
||||||
// the `rad` remote that pushes go to.
|
// the `rad` remote that pushes go to.
|
||||||
func (m *mirror) radInit(dir string, r *repo) (string, error) {
|
func (m *mirror) radInit(dir string, r *repo) (string, error) {
|
||||||
desc := r.Description
|
// A repository's ID is a hash of its first identity document (name, description, default
|
||||||
if desc == "" {
|
// branch, delegates). Two Gitea repositories with the same name and description (an example
|
||||||
desc = "Mirrored from Gitea: " + r.FullName
|
// copied into an organisation) would get the same ID on this node, and the second rad init
|
||||||
|
// fails ("attempt to reinitialize"). The Gitea path makes the document unique per forge.
|
||||||
|
desc := "Mirrored from Gitea: " + r.FullName
|
||||||
|
if r.Description != "" {
|
||||||
|
desc = r.Description + " (Gitea: " + r.FullName + ")"
|
||||||
}
|
}
|
||||||
out, err := m.rad(dir, "init", dir, "--name", r.Name, "--description", desc,
|
out, err := m.rad(dir, "init", dir, "--name", r.Name, "--description", desc,
|
||||||
"--default-branch", r.DefaultBranch, "--public", "--no-confirm")
|
"--default-branch", r.DefaultBranch, "--public", "--no-confirm")
|
||||||
|
|||||||
+85
-11
@@ -19,7 +19,29 @@ import (
|
|||||||
//go:embed all:example
|
//go:embed all:example
|
||||||
var exampleFiles embed.FS
|
var exampleFiles embed.FS
|
||||||
|
|
||||||
const tokenName = "ipcr-forge-bridge"
|
const (
|
||||||
|
tokenName = "ipcr-forge-bridge"
|
||||||
|
adminTokenName = "ipcr-forge-secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ensureOrg creates the public organisation org, owned by the admin, unless it exists.
|
||||||
|
func ensureOrg(g *gitea, org string) error {
|
||||||
|
code, err := g.call("GET", "/orgs/"+org, nil, nil)
|
||||||
|
if code == http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if code != http.StatusNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := g.call("POST", "/admin/users/"+g.owner+"/orgs", map[string]any{
|
||||||
|
"username": org, "visibility": "public",
|
||||||
|
"description": "Its public repositories publish images at the root of the forge's name.",
|
||||||
|
}, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("setup: created organisation %s", org)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// setup is the install step, run after the stack is up and on every start (each action is
|
// setup is the install step, run after the stack is up and on every start (each action is
|
||||||
// idempotent). It signs in once as the admin with the server's default app password, because
|
// idempotent). It signs in once as the admin with the server's default app password, because
|
||||||
@@ -30,7 +52,10 @@ const tokenName = "ipcr-forge-bridge"
|
|||||||
// SECRETS_DIR/hook-secret the webhook's HMAC key. Generated only when missing.
|
// SECRETS_DIR/hook-secret the webhook's HMAC key. Generated only when missing.
|
||||||
// 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.
|
// SECRETS_DIR/gitea-admin-token write:repository + read:user: push secrets, name collisions.
|
||||||
|
// IPCR_ROOT_ORG the root organisation, created when absent (public, owned by the admin).
|
||||||
|
// EXAMPLE_REPO a public example repository with a workflow, created when absent, in
|
||||||
|
// the root organisation when there is one.
|
||||||
// an OAuth2 application the admin pages' Gitea login (PUBLIC_HOSTS → its redirect URIs);
|
// an OAuth2 application the admin pages' Gitea login (PUBLIC_HOSTS → its redirect URIs);
|
||||||
// SECRETS_DIR/oauth-client-id and oauth-client-secret.
|
// SECRETS_DIR/oauth-client-id and oauth-client-secret.
|
||||||
//
|
//
|
||||||
@@ -78,6 +103,36 @@ func setup() error {
|
|||||||
log.Printf("setup: token %s written", tokenName)
|
log.Printf("setup: token %s written", tokenName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The admin-scoped token: sets each publishing repository's IPCR_PUSH_TOKEN secret, and checks
|
||||||
|
// short-path collisions against every user and organisation. Not public-only: an organisation
|
||||||
|
// may be private and still own the name.
|
||||||
|
adminTokenFile := filepath.Join(secrets, "gitea-admin-token")
|
||||||
|
if b, _ := os.ReadFile(adminTokenFile); len(strings.TrimSpace(string(b))) == 0 {
|
||||||
|
g.call("DELETE", "/users/"+g.owner+"/tokens/"+adminTokenName, nil, nil)
|
||||||
|
var t struct {
|
||||||
|
SHA1 string `json:"sha1"`
|
||||||
|
}
|
||||||
|
if _, err := g.call("POST", "/users/"+g.owner+"/tokens", map[string]any{
|
||||||
|
"name": adminTokenName, "scopes": []string{"write:repository", "read:user", "read:organization"},
|
||||||
|
}, &t); err != nil {
|
||||||
|
return fmt.Errorf("token: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeSecret(adminTokenFile, t.SHA1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("setup: token %s written", adminTokenName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The root organisation: its repositories are also published at the root of the forge's name.
|
||||||
|
owner := g.owner
|
||||||
|
if org := strings.ToLower(env("IPCR_ROOT_ORG", "")); org != "" {
|
||||||
|
if err := ensureOrg(g, org); err != nil {
|
||||||
|
log.Printf("setup: organisation %s: %v", org, err)
|
||||||
|
} else {
|
||||||
|
owner = org
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
secretFile := filepath.Join(secrets, "hook-secret")
|
secretFile := filepath.Join(secrets, "hook-secret")
|
||||||
secret, _ := os.ReadFile(secretFile)
|
secret, _ := os.ReadFile(secretFile)
|
||||||
if strings.TrimSpace(string(secret)) == "" {
|
if strings.TrimSpace(string(secret)) == "" {
|
||||||
@@ -100,7 +155,7 @@ func setup() error {
|
|||||||
|
|
||||||
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, owner, name); err != nil {
|
||||||
log.Printf("setup: example repository: %v", err)
|
log.Printf("setup: example repository: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,18 +201,22 @@ func ensureHook(g *gitea, url, secret string) error {
|
|||||||
|
|
||||||
// ensureExample creates <owner>/<name>, public, with the files under example/ in one commit. A
|
// ensureExample creates <owner>/<name>, public, with the files under example/ in one commit. A
|
||||||
// repository that already has content is left as it is: it may be the user's by now.
|
// repository that already has content is left as it is: it may be the user's by now.
|
||||||
func ensureExample(g *gitea, name string) error {
|
func ensureExample(g *gitea, owner, name string) error {
|
||||||
var r repo
|
var r repo
|
||||||
code, err := g.call("GET", "/repos/"+g.owner+"/"+name, nil, &r)
|
code, err := g.call("GET", "/repos/"+owner+"/"+name, nil, &r)
|
||||||
switch {
|
switch {
|
||||||
case code == http.StatusNotFound:
|
case code == http.StatusNotFound:
|
||||||
if _, err := g.call("POST", "/admin/users/"+g.owner+"/repos", map[string]any{
|
create := "/admin/users/" + owner + "/repos"
|
||||||
|
if owner != g.owner {
|
||||||
|
create = "/orgs/" + owner + "/repos"
|
||||||
|
}
|
||||||
|
if _, err := g.call("POST", create, map[string]any{
|
||||||
"name": name, "private": false, "default_branch": "main",
|
"name": name, "private": false, "default_branch": "main",
|
||||||
"description": "Example: a tag here builds an image, published on IPFS by IPCR and mirrored to Radicle.",
|
"description": "Example: a tag here builds an image, published on IPFS by IPCR and mirrored to Radicle.",
|
||||||
}, &r); err != nil {
|
}, &r); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("setup: created %s/%s", g.owner, name)
|
log.Printf("setup: created %s/%s", owner, name)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return err
|
return err
|
||||||
case !r.Empty:
|
case !r.Empty:
|
||||||
@@ -181,7 +240,7 @@ func ensureExample(g *gitea, name string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = g.call("POST", "/repos/"+g.owner+"/"+name+"/contents", map[string]any{
|
_, err = g.call("POST", "/repos/"+owner+"/"+name+"/contents", map[string]any{
|
||||||
"branch": "main", "message": "Example: a Dockerfile and the workflow that publishes it", "files": files,
|
"branch": "main", "message": "Example: a Dockerfile and the workflow that publishes it", "files": files,
|
||||||
}, nil)
|
}, nil)
|
||||||
return err
|
return err
|
||||||
@@ -226,9 +285,24 @@ func ensureOAuthApp(g *gitea, hosts []string, secrets string) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if haveSecret {
|
if haveSecret {
|
||||||
// Keep it (and its secret); only follow a change of hosts.
|
// Keep it, and its secret. Gitea regenerates the secret on every update, so only
|
||||||
_, err := g.call("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), body, nil)
|
// update when the hosts changed, and keep the secret it hands back.
|
||||||
return err
|
if strings.Join(app.RedirectURIs, ",") == strings.Join(redirects, ",") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var updated struct {
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
}
|
||||||
|
if _, err := g.call("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), body, &updated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if updated.ClientSecret != "" {
|
||||||
|
if err := writeSecret(secretFile, updated.ClientSecret); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("setup: OAuth2 app %s now for %s", oauthAppName, strings.Join(redirects, ", "))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if _, err := g.call("DELETE", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), nil, nil); err != nil {
|
if _, err := g.call("DELETE", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), nil, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+241
-78
@@ -59,6 +59,27 @@
|
|||||||
.bar { height: 8px; background: var(--code); border-radius: 4px; overflow: hidden; margin: 6px 0; }
|
.bar { height: 8px; background: var(--code); border-radius: 4px; overflow: hidden; margin: 6px 0; }
|
||||||
.bar span { display: block; height: 100%; background: var(--accent); }
|
.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; }
|
.note { border-left: 3px solid var(--warn); padding: 4px 12px; color: var(--muted); margin: 8px 0; }
|
||||||
|
.wide { grid-column: 1 / -1; }
|
||||||
|
/* Names, CIDs and commands stay on one line; their box scrolls instead. */
|
||||||
|
.line { display: block; white-space: nowrap; overflow-x: auto; overflow-wrap: normal; }
|
||||||
|
.copyrow { display: flex; gap: 8px; align-items: center; min-width: 0; }
|
||||||
|
.copyrow code { flex: 1; min-width: 0; white-space: nowrap; overflow-x: auto; overflow-wrap: normal; padding: 3px 6px; }
|
||||||
|
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin: 20px 0 4px; overflow-x: auto; }
|
||||||
|
.tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; color: var(--muted);
|
||||||
|
padding: 8px 14px; font-size: 14px; white-space: nowrap; }
|
||||||
|
.tabs button[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--accent); font-weight: 600; }
|
||||||
|
.count { font-size: 12px; background: var(--code); border-radius: 10px; padding: 0 7px; margin-left: 4px; }
|
||||||
|
.addrs { list-style: none; padding: 0; margin: 4px 0 0; font-size: 13px; }
|
||||||
|
.addrs li { margin: 2px 0; }
|
||||||
|
.tag { border-top: 1px solid var(--line); padding: 10px 0; }
|
||||||
|
.tag:first-of-type { border-top: 0; }
|
||||||
|
.taghead { display: flex; gap: 10px; align-items: baseline; justify-content: space-between; flex-wrap: wrap; margin-bottom: 6px; }
|
||||||
|
.cmd { display: grid; grid-template-columns: 44px 1fr auto; gap: 8px; align-items: center; margin: 3px 0; }
|
||||||
|
.cmd .k { font-size: 11.5px; font-weight: 600; color: var(--muted); text-transform: uppercase; }
|
||||||
|
.cmd code { white-space: nowrap; overflow-x: auto; overflow-wrap: normal; padding: 3px 6px; min-width: 0; }
|
||||||
|
.spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--line); border-top-color: var(--accent);
|
||||||
|
border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -1px; margin-right: 6px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
#toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--ink); color: var(--bg);
|
#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); }
|
padding: 8px 14px; border-radius: 8px; font-size: 14px; display: none; max-width: calc(100% - 32px); }
|
||||||
</style>
|
</style>
|
||||||
@@ -71,33 +92,43 @@
|
|||||||
<button id="logout">Sign out</button>
|
<button id="logout">Sign out</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs" role="tablist">
|
||||||
|
<button role="tab" data-tab="overview">Overview</button>
|
||||||
|
<button role="tab" data-tab="images">Published images<span class="count" id="images-count">…</span></button>
|
||||||
|
<button role="tab" data-tab="repos">Repositories</button>
|
||||||
|
<button role="tab" data-tab="backup">Key backup</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="panel" id="tab-overview">
|
||||||
<h2>Publisher name</h2>
|
<h2>Publisher name</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card wide">
|
||||||
<h3>Key</h3>
|
<h3>Key</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Key</dt><dd><code id="key-name">…</code></dd>
|
<dt>Key</dt><dd><code id="key-name">…</code></dd>
|
||||||
<dt>IPNS name</dt><dd><code id="key-id">…</code></dd>
|
<dt>IPNS name</dt><dd><div class="copyrow"><code id="key-id">…</code><button class="copy" data-copy="key-id">Copy</button></div></dd>
|
||||||
<dt>Pull as</dt><dd><code id="pull-as">…</code></dd>
|
<dt>Pull as</dt><dd><div class="copyrow"><code id="pull-as">…</code></div></dd>
|
||||||
</dl>
|
</dl>
|
||||||
<p class="muted">Every image is published under this one name. Back the key up (below): it exists only on this server.</p>
|
<p class="muted">Every image is published under this one name. Back the key up (Key backup tab): it exists only on this server.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card wide">
|
||||||
<h3>Your domain or ENS name</h3>
|
<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>
|
<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">
|
<form class="row" id="name-form">
|
||||||
<input type="text" id="name" placeholder="example.eth or example.com" size="26" autocomplete="off">
|
<input type="text" id="name" placeholder="example.eth or example.com" size="30" autocomplete="off">
|
||||||
<input type="submit" value="Save and check">
|
<input type="submit" value="Save and check">
|
||||||
</form>
|
</form>
|
||||||
<p id="name-check" class="muted"></p>
|
<p id="name-check" class="muted"></p>
|
||||||
<details><summary class="muted">How to point it here</summary>
|
<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>ENS:</b> in the ENS manager, Records → Content hash =</p>
|
||||||
<p><b>DNS:</b> a TXT record <code>_dnslink.example.com</code> = <code>dnslink=/ipns/<span class="kid">k51…</span></code>.</p>
|
<div class="copyrow"><code id="ens-value">ipns://k51…</code><button class="copy" data-copy="ens-value">Copy</button></div>
|
||||||
|
<p><b>DNS:</b> a TXT record <code>_dnslink.example.com</code> =</p>
|
||||||
|
<div class="copyrow"><code id="dns-value">dnslink=/ipns/k51…</code><button class="copy" data-copy="dns-value">Copy</button></div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Health <button id="refresh">Check again</button></h2>
|
<h2>Health <button id="refresh">Check again</button> <span id="refresh-state" class="muted"></span></h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>IPNS record</h3>
|
<h3>IPNS record</h3>
|
||||||
@@ -106,27 +137,50 @@
|
|||||||
<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
|
<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>
|
record stays valid if this node goes offline; TTL is how long resolvers cache it.</p>
|
||||||
</div>
|
</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">
|
<div class="card">
|
||||||
<h3>Storage</h3>
|
<h3>Storage</h3>
|
||||||
<div id="storage"></div>
|
<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>
|
<p class="muted">Published images are pinned and never collected. Unpublishing an image frees the blocks no other image uses.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card wide">
|
||||||
|
<h3>Node</h3>
|
||||||
|
<dl id="node"></dl>
|
||||||
|
<h3 style="margin-top:14px">Listening on</h3>
|
||||||
|
<ul class="addrs" id="addrs"></ul>
|
||||||
|
<div class="actions" style="margin-top:10px"><button id="peers">What the network sees</button></div>
|
||||||
|
<div id="peers-out" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="tab-images" hidden>
|
||||||
|
<h2>Published images</h2>
|
||||||
|
<p class="muted">Every tag can be pulled three ways: by <b>IPFS</b> CID (this exact image, forever), by <b>IPNS</b> name (follows the tag),
|
||||||
|
and by your ENS or DNS name once it checks out (Overview).</p>
|
||||||
|
<div id="images" class="muted">Loading…</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="tab-repos" hidden>
|
||||||
|
<h2>Root organisation</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p class="muted">The public repositories of this Gitea organisation are also published at the root of the forge's
|
||||||
|
name: <code>metadec/app</code> → <code>…/ipns/<name>/app</code>. Its members (Gitea teams) are the ones who
|
||||||
|
can publish there. Leave empty for none.</p>
|
||||||
|
<form class="row" id="org-form">
|
||||||
|
<input type="text" id="org" placeholder="organisation" size="24" autocomplete="off">
|
||||||
|
<input type="submit" value="Save">
|
||||||
|
</form>
|
||||||
|
<p id="org-state" class="muted"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Published images</h2>
|
|
||||||
<div id="images" class="muted">Loading…</div>
|
|
||||||
|
|
||||||
<h2>Which repositories may publish</h2>
|
<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
|
<p class="muted">Each public repository switched on here publishes under its own name only: the forge sets an
|
||||||
Gitea repositories switched on here (<code><owner>/<repo></code>). New public repositories are on.</p>
|
<code>IPCR_PUSH_TOKEN</code> secret on it, and the staging registry accepts that token for that name and no other.
|
||||||
|
Workflows log in with it (see the forge page's template). New public repositories are on.</p>
|
||||||
<div id="repos" class="muted">Loading…</div>
|
<div id="repos" class="muted">Loading…</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="tab-backup" hidden>
|
||||||
<h2>Key backup</h2>
|
<h2>Key backup</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -153,6 +207,7 @@
|
|||||||
name's last published images are adopted.</p>
|
name's last published images are adopted.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
|
|
||||||
@@ -213,6 +268,34 @@
|
|||||||
.then(function () { location.href = "/"; });
|
.then(function () { location.href = "/"; });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- tabs (the open one is kept in the URL, so a reload stays on it)
|
||||||
|
var tabs = document.querySelectorAll(".tabs button");
|
||||||
|
function showTab(name) {
|
||||||
|
if (!$("tab-" + name)) name = "overview";
|
||||||
|
Array.prototype.forEach.call(tabs, function (b) {
|
||||||
|
var on = b.getAttribute("data-tab") === name;
|
||||||
|
b.setAttribute("aria-selected", on ? "true" : "false");
|
||||||
|
$("tab-" + b.getAttribute("data-tab")).hidden = !on;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Array.prototype.forEach.call(tabs, function (b) {
|
||||||
|
b.onclick = function () { history.replaceState(null, "", "#" + b.getAttribute("data-tab")); showTab(b.getAttribute("data-tab")); };
|
||||||
|
});
|
||||||
|
showTab(location.hash.slice(1));
|
||||||
|
|
||||||
|
// ---- copy
|
||||||
|
function copy(text, btn) {
|
||||||
|
var done = function () { if (btn) { btn.textContent = "Copied"; setTimeout(function () { btn.textContent = "Copy"; }, 1500); } };
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { window.prompt("Copy:", text); });
|
||||||
|
else window.prompt("Copy:", text);
|
||||||
|
}
|
||||||
|
function copyButton(text) { var b = el("button", "Copy", "copy"); b.type = "button"; b.onclick = function () { copy(text, b); }; return b; }
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll("button[data-copy]"), function (b) {
|
||||||
|
b.type = "button";
|
||||||
|
b.onclick = function () { copy($(b.getAttribute("data-copy")).textContent, b); };
|
||||||
|
});
|
||||||
|
function spinner(text) { var s = el("span"); s.appendChild(el("span", null, "spin")); s.appendChild(document.createTextNode(text)); return s; }
|
||||||
|
|
||||||
// ---- status
|
// ---- status
|
||||||
function recordView(r, err, tree) {
|
function recordView(r, err, tree) {
|
||||||
if (!r) return badge(err || "none", "bad");
|
if (!r) return badge(err || "none", "bad");
|
||||||
@@ -222,20 +305,28 @@
|
|||||||
if (tree && r.value !== tree) { s.appendChild(document.createTextNode(" · ")); s.appendChild(badge("older tree", "warn")); }
|
if (tree && r.value !== tree) { s.appendChild(document.createTextNode(" · ")); s.appendChild(badge("older tree", "warn")); }
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
function showName(n) {
|
||||||
|
var nc = $("name-check"); nc.textContent = "";
|
||||||
|
if (n && n.name) {
|
||||||
|
nc.appendChild(n.ok ? badge("✓ " + n.name + " points at this forge", "ok") : badge("✗ " + (n.error || "does not resolve"), "bad"));
|
||||||
|
if (!n.ok && n.resolves) nc.appendChild(el("div", n.name + " currently resolves to " + n.resolves, "muted"));
|
||||||
|
if (n.checkedAt) nc.appendChild(el("div", "Checked " + n.checkedAt.replace("T", " ").replace("Z", " UTC"), "muted"));
|
||||||
|
} else nc.textContent = "No name set: pull lines use the IPNS name.";
|
||||||
|
}
|
||||||
function loadStatus(refresh) {
|
function loadStatus(refresh) {
|
||||||
|
var st8 = $("refresh-state");
|
||||||
|
st8.textContent = ""; st8.appendChild(spinner(refresh ? "Checking the network…" : "Loading…"));
|
||||||
return api("GET", "ipcr/status" + (refresh ? "?refresh=1" : "")).then(function (s) {
|
return api("GET", "ipcr/status" + (refresh ? "?refresh=1" : "")).then(function (s) {
|
||||||
|
st8.textContent = "";
|
||||||
publisher = s.key.id || "";
|
publisher = s.key.id || "";
|
||||||
$("key-name").textContent = s.key.name || "—";
|
$("key-name").textContent = s.key.name || "—";
|
||||||
$("key-id").textContent = publisher || "not created yet (first import creates it)";
|
$("key-id").textContent = publisher || "not created yet (the first import creates it)";
|
||||||
Array.prototype.forEach.call(document.querySelectorAll(".kid"), function (e) { e.textContent = publisher || "k51…"; });
|
$("ens-value").textContent = "ipns://" + (publisher || "k51…");
|
||||||
|
$("dns-value").textContent = "dnslink=/ipns/" + (publisher || "k51…");
|
||||||
pubName = s.name && s.name.ok ? s.name.name : "";
|
pubName = s.name && s.name.ok ? s.name.name : "";
|
||||||
$("pull-as").textContent = "ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "/<owner>/<repo>:<tag>";
|
$("pull-as").textContent = "docker pull ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "/<owner>/<repo>:<tag>";
|
||||||
if (s.name && s.name.name) $("name").value = s.name.name;
|
if (s.name && s.name.name && document.activeElement !== $("name")) $("name").value = s.name.name;
|
||||||
var nc = $("name-check"); nc.textContent = "";
|
showName(s.name);
|
||||||
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;
|
var rec = s.record;
|
||||||
dl($("record"), [
|
dl($("record"), [
|
||||||
["Here", recordView(rec.local, rec.localError, rec.tree)],
|
["Here", recordView(rec.local, rec.localError, rec.tree)],
|
||||||
@@ -243,64 +334,101 @@
|
|||||||
["Lifetime", rec.lifetime], ["TTL", rec.ttl],
|
["Lifetime", rec.lifetime], ["TTL", rec.ttl],
|
||||||
["Checked", s.checkedAt.replace("T", " ").replace("Z", " UTC")]
|
["Checked", s.checkedAt.replace("T", " ").replace("Z", " UTC")]
|
||||||
]);
|
]);
|
||||||
dl($("node"), [["Peer ID", s.node.id], ["Connected peers", s.node.peers],
|
var pid = el("div", null, "copyrow"); pid.appendChild(el("code", s.node.id)); pid.appendChild(copyButton(s.node.id));
|
||||||
["Listening", (s.node.addresses || []).filter(function (a) { return a.indexOf("/127.") < 0 && a.indexOf("/::1/") < 0; }).slice(0, 4).join("\n")]]);
|
dl($("node"), [["Peer ID", pid], ["Connected peers", s.node.peers]]);
|
||||||
var st = s.storage, pct = st.storageMax ? Math.min(100, 100 * st.repoSize / st.storageMax) : 0;
|
// Public addresses first; loopback and the container's own network are of no use to anyone else.
|
||||||
$("storage").innerHTML = "";
|
var addrs = (s.node.addresses || []).filter(function (a) { return !/^\/ip4\/(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)|^\/ip6\/(::1|f[cd])/.test(a); });
|
||||||
$("storage").appendChild(el("div", size(st.repoSize) + " of " + size(st.storageMax) + " · " + st.pins + " pins"));
|
var ul = $("addrs"); ul.textContent = "";
|
||||||
|
if (!addrs.length) ul.appendChild(el("li", "no public address", "warn"));
|
||||||
|
addrs.forEach(function (a) { var li = el("li"); li.appendChild(el("code", a.replace(/\/p2p\/[^/]+$/, ""), "line")); ul.appendChild(li); });
|
||||||
|
var sto = s.storage, pct = sto.storageMax ? Math.min(100, 100 * sto.repoSize / sto.storageMax) : 0;
|
||||||
|
$("storage").textContent = "";
|
||||||
|
$("storage").appendChild(el("div", size(sto.repoSize) + " of " + size(sto.storageMax) + " · " + sto.pins + " pins"));
|
||||||
var bar = el("div", null, "bar"), fill = el("span"); fill.style.width = pct.toFixed(1) + "%"; bar.appendChild(fill);
|
var bar = el("div", null, "bar"), fill = el("span"); fill.style.width = pct.toFixed(1) + "%"; bar.appendChild(fill);
|
||||||
$("storage").appendChild(bar);
|
$("storage").appendChild(bar);
|
||||||
}).catch(function (e) { toast("Status: " + e.message, 8000); });
|
}).catch(function (e) { st8.textContent = ""; toast("Status: " + e.message, 8000); });
|
||||||
}
|
}
|
||||||
$("refresh").onclick = function () { busy(this, loadStatus(true)); };
|
$("refresh").onclick = function () { busy(this, loadStatus(true)); };
|
||||||
$("republish").onclick = function () {
|
$("republish").onclick = function () {
|
||||||
busy(this, api("POST", "ipcr/republish")).then(function () { toast("Published again"); loadStatus(true); });
|
busy(this, api("POST", "ipcr/republish")).then(function () { toast("Published again"); loadStatus(true); });
|
||||||
};
|
};
|
||||||
$("peers").onclick = function () {
|
$("peers").onclick = function () {
|
||||||
|
var o = $("peers-out"); o.textContent = ""; o.appendChild(spinner("Asking the network…"));
|
||||||
busy(this, api("GET", "ipcr/peers")).then(function (p) {
|
busy(this, api("GET", "ipcr/peers")).then(function (p) {
|
||||||
var o = $("peers-out"); o.textContent = "";
|
o.textContent = "";
|
||||||
if (p.error) o.appendChild(badge(p.error, "bad"));
|
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 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"))); }
|
else {
|
||||||
});
|
o.appendChild(badge("✓ Other nodes can reach this one via:", "ok"));
|
||||||
|
var ul = el("ul", null, "addrs");
|
||||||
|
p.network.forEach(function (a) { var li = el("li"); li.appendChild(el("code", a, "line")); ul.appendChild(li); });
|
||||||
|
o.appendChild(ul);
|
||||||
|
}
|
||||||
|
}, function () { o.textContent = ""; });
|
||||||
};
|
};
|
||||||
$("name-form").onsubmit = function (e) {
|
$("name-form").onsubmit = function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var btn = this.querySelector("input[type=submit]");
|
var btn = this.querySelector("input[type=submit]"), input = $("name"), name = input.value.trim();
|
||||||
busy(btn, api("PATCH", "ipcr/config", { name: $("name").value })).then(function () { return loadStatus(true); });
|
var nc = $("name-check");
|
||||||
|
nc.textContent = "";
|
||||||
|
nc.appendChild(spinner(name ? "Saving, then resolving " + name + " (up to a minute)…" : "Clearing the name…"));
|
||||||
|
btn.disabled = input.disabled = true; btn.value = "Checking…";
|
||||||
|
api("PATCH", "ipcr/config", { name: name })
|
||||||
|
.then(function () { return loadStatus(true); })
|
||||||
|
.then(function () {
|
||||||
|
toast(name ? (pubName ? name + " checks out: pull lines use it" : "Saved: " + name + " does not point here yet") : "Name cleared");
|
||||||
|
loadImages();
|
||||||
|
}, function (err) { showName(null); toast(err.message, 8000); })
|
||||||
|
.then(function () { btn.disabled = input.disabled = false; btn.value = "Save and check"; });
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- images
|
// ---- images
|
||||||
|
// The pull commands for one tag: by CID, by IPNS name, by the configured name when it checks out.
|
||||||
|
// The root organisation's repositories have a short path too: it comes first.
|
||||||
|
function pullCommands(reg, d, img, tg) {
|
||||||
|
var short = img.aliases && img.aliases.length ? img.aliases[0] : "";
|
||||||
|
var path = (short || img.repo) + ":" + tg.tag, rows = [["IPFS", reg + "/ipfs/" + tg.cid]];
|
||||||
|
if (d.publisher) rows.push(["IPNS", reg + "/ipns/" + d.publisher + "/" + path]);
|
||||||
|
if (d.name) rows.push([/\.eth$/.test(d.name) ? "ENS" : "DNS", reg + "/ipns/" + d.name + "/" + path]);
|
||||||
|
if (short && (d.name || d.publisher)) rows.push(["Also", reg + "/ipns/" + (d.name || d.publisher) + "/" + img.repo + ":" + tg.tag]);
|
||||||
|
var box = el("div");
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var cmd = "docker pull " + r[1], row = el("div", null, "cmd");
|
||||||
|
row.appendChild(el("span", r[0], "k")); row.appendChild(el("code", cmd)); row.appendChild(copyButton(cmd));
|
||||||
|
box.appendChild(row);
|
||||||
|
});
|
||||||
|
return box;
|
||||||
|
}
|
||||||
function loadImages() {
|
function loadImages() {
|
||||||
return api("GET", "ipcr/images").then(function (d) {
|
return api("GET", "ipcr/images").then(function (d) {
|
||||||
var box = $("images"); box.textContent = ""; box.className = "";
|
var box = $("images"); box.textContent = ""; box.className = "";
|
||||||
|
var reg = d.registry || "ipcr.localhost:4767", n = 0;
|
||||||
if (d.stagingError) box.appendChild(el("p", "Staging registry: " + d.stagingError, "warn"));
|
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; }
|
if (!d.images || !d.images.length) { box.appendChild(el("p", "Nothing published yet.", "muted")); $("images-count").textContent = "0"; return; }
|
||||||
d.images.forEach(function (img) {
|
d.images.forEach(function (img) {
|
||||||
var h = el("h3", img.repo + " ");
|
n += img.tags.length;
|
||||||
|
var card = el("div", null, "card"); card.style.marginBottom = "12px";
|
||||||
|
var h = el("h3", (img.aliases && img.aliases.length ? img.aliases[0] + " · " : "") + img.repo + " ");
|
||||||
h.appendChild(img.allowed ? badge("publishing", "ok") : badge("not allowed: new tags are not published", "warn"));
|
h.appendChild(img.allowed ? badge("publishing", "ok") : badge("not allowed: new tags are not published", "warn"));
|
||||||
box.appendChild(h);
|
card.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) {
|
img.tags.forEach(function (tg) {
|
||||||
var tr = el("tr");
|
var t = el("div", null, "tag"), head = el("div", null, "taghead"), left = el("div");
|
||||||
var td1 = el("td"); td1.appendChild(el("code", tg.tag));
|
left.appendChild(el("code", tg.tag));
|
||||||
if (tg.at) td1.appendChild(el("div", tg.at.replace("T", " ").replace("Z", ""), "muted"));
|
if (tg.at) left.appendChild(el("span", " " + tg.at.replace("T", " ").replace("Z", " UTC"), "muted"));
|
||||||
var td2 = el("td"); td2.appendChild(el("code", "/ipfs/" + tg.cid));
|
var prov = el("span", "", "muted"); prov.style.marginLeft = "10px"; left.appendChild(prov);
|
||||||
var td3 = el("td"), prov = el("span", "", "muted"); td3.appendChild(prov);
|
var acts = el("div", null, "actions");
|
||||||
var td4 = el("td"), acts = el("div", null, "actions");
|
|
||||||
var chk = el("button", "Findable?");
|
var chk = el("button", "Findable?");
|
||||||
chk.onclick = function () {
|
chk.onclick = function () {
|
||||||
|
prov.textContent = ""; prov.appendChild(spinner("asking the network…"));
|
||||||
busy(chk, api("GET", "ipcr/providers?cid=" + encodeURIComponent(tg.cid))).then(function (p) {
|
busy(chk, api("GET", "ipcr/providers?cid=" + encodeURIComponent(tg.cid))).then(function (p) {
|
||||||
prov.textContent = "";
|
prov.textContent = "";
|
||||||
prov.appendChild(p.self ? badge("✓ this node is listed", "ok") : badge("✗ not listed (" + p.providers + " other providers)", "warn"));
|
prov.appendChild(p.self ? badge("✓ findable: this node is listed", "ok") : badge("✗ not listed yet (" + p.providers + " other providers)", "warn"));
|
||||||
if (!p.self) {
|
if (!p.self) {
|
||||||
var ann = el("button", "Announce");
|
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); }); };
|
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);
|
prov.appendChild(document.createTextNode(" ")); prov.appendChild(ann);
|
||||||
}
|
}
|
||||||
});
|
}, function () { prov.textContent = ""; });
|
||||||
};
|
};
|
||||||
acts.appendChild(chk);
|
acts.appendChild(chk);
|
||||||
if (tg.tag !== "latest") {
|
if (tg.tag !== "latest") {
|
||||||
@@ -318,48 +446,83 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
acts.appendChild(un);
|
acts.appendChild(un);
|
||||||
td4.appendChild(acts);
|
head.appendChild(left); head.appendChild(acts);
|
||||||
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
|
t.appendChild(head);
|
||||||
t.appendChild(tr);
|
t.appendChild(pullCommands(reg, d, img, tg));
|
||||||
|
card.appendChild(t);
|
||||||
});
|
});
|
||||||
(img.staging || []).filter(function (s) { return s.state !== "published"; }).forEach(function (s) {
|
var pending = (img.staging || []).filter(function (s) { return s.state !== "published"; });
|
||||||
var tr = el("tr");
|
if (pending.length) {
|
||||||
var td1 = el("td"); td1.appendChild(el("code", s.tag));
|
card.appendChild(el("h3", "In staging, not published"));
|
||||||
var td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "in staging, not imported yet" : "in staging, not allowed"), "muted");
|
var tbl = el("table");
|
||||||
var td3 = el("td"), td4 = el("td");
|
pending.forEach(function (s) {
|
||||||
if (img.allowed) {
|
var tr = el("tr"), td1 = el("td"), td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "not imported yet" : "repository not allowed"), "muted"), td3 = el("td");
|
||||||
var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now");
|
td1.appendChild(el("code", s.tag));
|
||||||
re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued"); setTimeout(loadImages, 5000); }); };
|
if (img.allowed) {
|
||||||
td4.appendChild(re);
|
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: imported on the next pass"); setTimeout(loadImages, 5000); }); };
|
||||||
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
|
td3.appendChild(re);
|
||||||
t.appendChild(tr);
|
}
|
||||||
});
|
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tbl.appendChild(tr);
|
||||||
wrap.appendChild(t); box.appendChild(wrap);
|
});
|
||||||
|
card.appendChild(tbl);
|
||||||
|
}
|
||||||
|
box.appendChild(card);
|
||||||
});
|
});
|
||||||
|
$("images-count").textContent = String(n);
|
||||||
}).catch(function (e) { $("images").textContent = "Images: " + e.message; });
|
}).catch(function (e) { $("images").textContent = "Images: " + e.message; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- repositories
|
// ---- repositories
|
||||||
|
function loadSettings() {
|
||||||
|
return api("GET", "settings").then(function (st) {
|
||||||
|
if (document.activeElement !== $("org")) $("org").value = st.rootOrg || "";
|
||||||
|
$("org-state").textContent = st.rootOrg ? "Publishing " + st.rootOrg + "'s repositories at the root." : "No root organisation.";
|
||||||
|
}).catch(function (e) { $("org-state").textContent = e.message; });
|
||||||
|
}
|
||||||
|
$("org-form").onsubmit = function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = this.querySelector("input[type=submit]"), st = $("org-state");
|
||||||
|
st.textContent = ""; st.appendChild(spinner("Saving, then updating credentials and IPCR's list…"));
|
||||||
|
btn.disabled = true;
|
||||||
|
api("PUT", "settings", { rootOrg: $("org").value.trim() })
|
||||||
|
.then(function (r) { toast(r.rootOrg ? r.rootOrg + " is the root organisation; short paths appear on IPCR's next pass" : "No root organisation"); return Promise.all([loadSettings(), loadRepos()]); },
|
||||||
|
function (err) { toast(err.message, 8000); return loadSettings(); })
|
||||||
|
.then(function () { btn.disabled = false; setTimeout(loadImages, 70000); });
|
||||||
|
};
|
||||||
function loadRepos() {
|
function loadRepos() {
|
||||||
return api("GET", "repos").then(function (repos) {
|
return api("GET", "repos").then(function (repos) {
|
||||||
var box = $("repos"); box.textContent = ""; box.className = "";
|
var box = $("repos"); box.textContent = ""; box.className = "";
|
||||||
if (!repos.length) { box.appendChild(el("p", "No public repository yet.", "muted")); return; }
|
if (!repos.length) { box.appendChild(el("p", "No public repository yet.", "muted")); return; }
|
||||||
var t = el("table"), head = el("tr");
|
var t = el("table"), head = el("tr");
|
||||||
["Repository", "Publish images"].forEach(function (x) { head.appendChild(el("th", x)); });
|
["Repository", "Published as", "Push credential", "Publish"].forEach(function (x) { head.appendChild(el("th", x)); });
|
||||||
t.appendChild(head);
|
t.appendChild(head);
|
||||||
repos.forEach(function (r) {
|
repos.forEach(function (r) {
|
||||||
var tr = el("tr"), td1 = el("td"), td2 = el("td"), cb = document.createElement("input");
|
var tr = el("tr"), td1 = el("td"), td2 = el("td"), td3 = el("td"), td4 = el("td"), cb = document.createElement("input");
|
||||||
td1.appendChild(el("code", r.full_name));
|
td1.appendChild(el("code", r.full_name));
|
||||||
|
(r.published_as || []).forEach(function (p, i) { var d = el("div"); d.appendChild(el("code", p + ":<tag>", i ? "muted" : "")); td2.appendChild(d); });
|
||||||
|
if (!r.publish) td2.appendChild(el("span", "—", "muted"));
|
||||||
|
if (r.collision) td2.appendChild(el("div", "no short path: " + r.collision, "warn"));
|
||||||
|
if (r.publish) {
|
||||||
|
td3.appendChild(r.credential ? badge("✓ IPCR_PUSH_TOKEN set", "ok") : badge("✗ missing", "bad"));
|
||||||
|
var rot = el("button", "Reissue"); rot.style.marginLeft = "8px";
|
||||||
|
rot.onclick = function () {
|
||||||
|
if (!confirm("Give " + r.full_name + " a new IPCR_PUSH_TOKEN? The old one stops working at once; running builds may fail.")) return;
|
||||||
|
busy(rot, api("POST", "repos/rotate", { full_name: r.full_name })).then(function () { toast("New token set on " + r.full_name); loadRepos(); });
|
||||||
|
};
|
||||||
|
td3.appendChild(rot);
|
||||||
|
} else td3.appendChild(el("span", "—", "muted"));
|
||||||
cb.type = "checkbox"; cb.checked = r.publish;
|
cb.type = "checkbox"; cb.checked = r.publish;
|
||||||
cb.onchange = function () {
|
cb.onchange = function () {
|
||||||
cb.disabled = true;
|
cb.disabled = true;
|
||||||
api("PUT", "repos", { full_name: r.full_name, publish: cb.checked })
|
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(); },
|
.then(function () { toast(r.full_name + (cb.checked ? ": publishing" : ": not publishing")); return loadRepos(); },
|
||||||
function (e) { cb.disabled = false; cb.checked = !cb.checked; toast(e.message, 8000); });
|
function (e) { cb.checked = !cb.checked; toast(e.message, 8000); })
|
||||||
|
.then(function () { cb.disabled = false; loadImages(); });
|
||||||
};
|
};
|
||||||
td2.appendChild(cb);
|
td4.appendChild(cb);
|
||||||
tr.appendChild(td1); tr.appendChild(td2); t.appendChild(tr);
|
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
|
||||||
|
t.appendChild(tr);
|
||||||
});
|
});
|
||||||
var wrap = el("div", null, "scroll"); wrap.appendChild(t); box.appendChild(wrap);
|
var wrap = el("div", null, "scroll"); wrap.appendChild(t); box.appendChild(wrap);
|
||||||
}).catch(function (e) { $("repos").textContent = "Repositories: " + e.message; });
|
}).catch(function (e) { $("repos").textContent = "Repositories: " + e.message; });
|
||||||
@@ -404,7 +567,7 @@
|
|||||||
}).catch(function (e) { toast(e.message, 8000); });
|
}).catch(function (e) { toast(e.message, 8000); });
|
||||||
};
|
};
|
||||||
|
|
||||||
loadStatus(false); loadImages(); loadRepos();
|
loadStatus(false); loadImages(); loadRepos(); loadSettings();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+18
-9
@@ -131,9 +131,10 @@ git push -u origin main</code></pre>
|
|||||||
Applications</b>).</li>
|
Applications</b>).</li>
|
||||||
<li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the
|
<li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the
|
||||||
<code>Dockerfile</code> at the root for every <code>v*</code> tag. It is a GitHub-style
|
<code>Dockerfile</code> at the root for every <code>v*</code> tag. It is a GitHub-style
|
||||||
workflow with two differences: the image goes to <code>localhost:5000</code>, this forge's
|
workflow with three differences: it logs in to <code>localhost:5000</code>, this forge's staging
|
||||||
staging registry (no login, no secret), and buildx's builder runs with <code>network=host</code>
|
registry, as the repository itself (the forge sets the <code>IPCR_PUSH_TOKEN</code> secret on
|
||||||
so it can reach that registry.
|
every public repository allowed to publish), the image goes there under the repository's own
|
||||||
|
name, and buildx's builder runs with <code>network=host</code> so it can reach that registry.
|
||||||
<pre><code>name: image
|
<pre><code>name: image
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -143,6 +144,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: localhost:5000
|
||||||
|
username: ${{ github.repository }}
|
||||||
|
password: ${{ secrets.IPCR_PUSH_TOKEN }}
|
||||||
- uses: docker/metadata-action@v5
|
- uses: docker/metadata-action@v5
|
||||||
id: meta
|
id: meta
|
||||||
with:
|
with:
|
||||||
@@ -163,12 +169,13 @@ jobs:
|
|||||||
<pre><code>git add .gitea && git commit -m "Build the image"
|
<pre><code>git add .gitea && git commit -m "Build the image"
|
||||||
git tag v1.0.0
|
git tag v1.0.0
|
||||||
git push origin main v1.0.0</code></pre>
|
git push origin main v1.0.0</code></pre>
|
||||||
The image is published as <code><owner>/<repo>:1.0.0</code> under this forge's name.</li>
|
The image is published as <code><owner>/<repo>:1.0.0</code> under this forge's name.
|
||||||
|
Repositories of the forge's own organisation are also published at the root:
|
||||||
|
<code><repo>:1.0.0</code>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p class="note">Who can publish: anyone who can push to a repository on this forge, since a build
|
<p class="note">Who can publish: each public repository publishes under its own name only, with its
|
||||||
can push any image name to the staging registry. Images are public once on IPFS, even when they come from
|
own push credential; the short root names belong to the forge's organisation, so to its members.
|
||||||
a private repository. Gitea holds a pull request from a fork until someone with write access approves
|
Workflows from forks get no secrets, so they cannot publish. Private repositories never publish.</p>
|
||||||
its workflow run: read the workflow before you approve.</p>
|
|
||||||
|
|
||||||
<h2 id="use">Use an image</h2>
|
<h2 id="use">Use an image</h2>
|
||||||
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
|
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
|
||||||
@@ -316,7 +323,9 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
|
|||||||
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];
|
||||||
// The configured domain or ENS name, while IPCR's check says it points here.
|
// 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;
|
// The short path (root organisation) when there is one, under the name when it checks out.
|
||||||
|
var rest = r.aliases && r.aliases.length ? "/" + r.aliases[0] : r.ipns.slice(pub.publisher ? pub.publisher.length : 0);
|
||||||
|
var path = (pub.name || pub.publisher || r.ipns.split("/")[0]) + rest;
|
||||||
var line = "image: " + reg + "/ipns/" + path + ":" + tag;
|
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));
|
||||||
|
|||||||
Reference in New Issue
Block a user