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.
|
||||
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
|
||||
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
|
||||
user: $PUID:$PGID
|
||||
read_only: true
|
||||
@@ -65,6 +65,9 @@ services:
|
||||
IPCR_ADMIN_TOKEN_FILE: /srv/ipcr-state/admin-token
|
||||
KUBO_KEYSTORE: /srv/ipcr-keystore
|
||||
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:
|
||||
# HOME for git and rad.
|
||||
- /tmp
|
||||
@@ -271,11 +274,43 @@ services:
|
||||
networks:
|
||||
- ci-internal
|
||||
|
||||
# The staging registry, in the daemon's network namespace: `localhost:5000` to the daemon and to
|
||||
# every job (act runs jobs on the host network, which is this namespace), so a plain
|
||||
# `docker push` or buildx with `network=host` works without TLS or credentials — Docker allows
|
||||
# plain HTTP to localhost only. ipcr-gateway reaches it as forge-docker:5000. Nothing
|
||||
# outside ci-internal can.
|
||||
# The staging registry's front door: `localhost:5000` to the daemon and to every job (act runs
|
||||
# jobs on the host network, which is this namespace), forge-docker:5000 to ipcr-gateway. Plain
|
||||
# HTTP, which Docker allows to localhost only. It checks a login on every request: a job logs in
|
||||
# as its repository (<owner>/<repo>, the IPCR_PUSH_TOKEN secret the bridge sets on it) and may
|
||||
# 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:
|
||||
image: registry:3.0.0
|
||||
container_name: forge-registry
|
||||
@@ -283,7 +318,7 @@ services:
|
||||
cap_drop: [ALL]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
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
|
||||
# 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"]
|
||||
@@ -294,15 +329,17 @@ services:
|
||||
memory: 128M
|
||||
environment:
|
||||
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
|
||||
# (IMPORT_CLEANUP).
|
||||
REGISTRY_STORAGE_DELETE_ENABLED: "true"
|
||||
OTEL_TRACES_EXPORTER: none
|
||||
volumes:
|
||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry:/var/lib/registry
|
||||
depends_on:
|
||||
- forge-docker
|
||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
|
||||
|
||||
# ═══ Radicle — the public mirror ═══════════════════════════════════════════════
|
||||
# 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
|
||||
# registry below, resolves /ipns/<name>:<tag> itself, and pins every image it serves.
|
||||
ipcr-gateway:
|
||||
image: ghcr.io/yundera/ipcr:1.3.0
|
||||
image: ghcr.io/yundera/ipcr:1.4.0
|
||||
container_name: ipcr-gateway
|
||||
# Root for one reason: it writes its CA into the host's Docker trust store (the narrow
|
||||
# bind below), which is root-owned. See rationale.md.
|
||||
@@ -462,6 +499,10 @@ services:
|
||||
# Forge: watch the CI's staging registry, over ci-internal. Internal, so no credentials.
|
||||
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000}
|
||||
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:-}
|
||||
# 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
|
||||
@@ -517,7 +558,7 @@ services:
|
||||
# ── Stock nerdctl IPFS registry ─────────────────────────────────────────────
|
||||
# `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads.
|
||||
ipcr-registry:
|
||||
image: ghcr.io/yundera/ipcr:1.3.0
|
||||
image: ghcr.io/yundera/ipcr:1.4.0
|
||||
container_name: ipcr-registry
|
||||
# Root because nerdctl run as any other uid assumes rootless mode and refuses to start
|
||||
# without a rootless containerd — which `ipfs registry serve` never uses. The service has
|
||||
@@ -695,10 +736,13 @@ x-casaos:
|
||||
private later stops the copy, and what was published stays on the network. Private
|
||||
repositories are never copied.
|
||||
|
||||
**Who can publish images.** Only the names of public repositories that are switched on in the
|
||||
admin pages (all are, by default): `<owner>/<repo>`. A build in any repository can still
|
||||
push any name to the staging registry, so whoever can push code here can publish under an
|
||||
allowed name. Read a fork's workflow before approving its run.
|
||||
**Who can publish images.** Each public repository (switched on in the admin pages: all are,
|
||||
by default) publishes under its own name, `<owner>/<repo>`: its workflow logs in to the
|
||||
staging registry with the `IPCR_PUSH_TOKEN` secret the forge sets on it. The repositories of
|
||||
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: |
|
||||
**Commencez ici : [la page de la forge](https://ipcr-forge-$APP_DOMAIN)** : publier, les
|
||||
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
|
||||
ne sont jamais copiés.
|
||||
|
||||
**Qui peut publier des images.** Seuls les noms des dépôts publics activés dans
|
||||
l'administration (tous, par défaut) : `<propriétaire>/<dépôt>`. Un build de n'importe quel
|
||||
dépôt peut tout de même pousser n'importe quel nom vers le registre de transit : qui peut
|
||||
pousser du code ici peut publier sous un nom autorisé. Lisez le workflow d'un fork avant
|
||||
d'approuver son exécution.
|
||||
**Qui peut publier des images.** Chaque dépôt public (activé dans l'administration : tous,
|
||||
par défaut) publie sous son propre nom, `<propriétaire>/<dépôt>` : son workflow se connecte
|
||||
au registre de transit avec le secret `IPCR_PUSH_TOKEN` que la forge y dépose. Les dépôts de
|
||||
l'organisation racine (`IPCR_ROOT_ORG`, puis l'administration) sont aussi publiés à la
|
||||
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: /
|
||||
webui_port: 3000
|
||||
store_app_id: ipcr-forge
|
||||
@@ -747,6 +793,10 @@ x-compose-app:
|
||||
user: $PUID
|
||||
group: $PGID
|
||||
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 ──
|
||||
- path: /DATA/AppData/$AppID/gitea/data
|
||||
user: $PUID
|
||||
@@ -782,6 +832,11 @@ x-compose-app:
|
||||
user: $PUID
|
||||
group: $PGID
|
||||
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 ──
|
||||
- path: /DATA/AppData/$AppID/ipcr/kubo
|
||||
user: $PUID
|
||||
@@ -859,11 +914,12 @@ x-compose-app:
|
||||
mv /runner/token.new /runner/token
|
||||
chmod 600 /runner/token
|
||||
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
|
||||
# The bridge's Gitea token (public-only, read:repository), the webhook secret, the system
|
||||
# webhook, the admin pages' OAuth2 app, and the example repository. Idempotent, so on every start: it repairs the webhook.
|
||||
# The bridge's Gitea tokens (public-only read; and write:repository for push secrets), the
|
||||
# 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.
|
||||
- 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
|
||||
phase: post_up
|
||||
network: $APP_NET
|
||||
@@ -872,7 +928,7 @@ x-compose-app:
|
||||
command:
|
||||
- sh
|
||||
- -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
|
||||
webui-host: gitea-${domain}
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
**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
|
||||
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 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.
|
||||
@@ -180,10 +225,14 @@ the app's own state folder.
|
||||
namespace only.
|
||||
- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only
|
||||
delegate.
|
||||
- **Image names are limited, but their source is not proven.** Only allowed names (public
|
||||
repositories that are switched on) are published. A build in any repository can still push an
|
||||
allowed name to the staging registry. Per-repository credentials, issued by the bridge, are a
|
||||
planned follow-up.
|
||||
- **The gate is not a sandbox.** Jobs run on a privileged Docker-in-Docker daemon (deviation 1).
|
||||
Anyone who can run a workflow can break out of it to the host, and from there do anything,
|
||||
including publish under any name. The gate removes the ordinary path, a plain `docker push` of
|
||||
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
|
||||
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.
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `ci/` | the daemon's socket, its image store (`docker/`), the staging registry (`registry/`, disposable) |
|
||||
| `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) 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 |
|
||||
|
||||
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
|
||||
those switched off in the admin pages. Images built from private repositories are therefore not
|
||||
published.
|
||||
- **What the allowlist does not cover:** a job can still push any name to the unauthenticated
|
||||
`localhost:5000`. So anyone who can push code here can publish under an allowed name.
|
||||
Registration is off, so every user was created by the admin.
|
||||
- **Each repository publishes its own name only.** The staging registry sits behind a gate (see
|
||||
[Staging gate](#staging-gate-and-push-credentials)). A job logs in with its repository's
|
||||
`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.
|
||||
- The page, the tips and `rationale.md` say all of this plainly.
|
||||
- **Follow-up:** per-repository push credentials, with the bridge acting as a registry token
|
||||
server, would tie each image name to the one repository allowed to build it.
|
||||
- **Who can read a push token:** anyone with write access to its repository (a workflow can print
|
||||
it). Reissue it in the admin pages when a repository changes hands.
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
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):
|
||||
|
||||
- `images: localhost:5000/${{ github.repository }}`: the staging registry. No login, no secret.
|
||||
`docker/metadata-action` lowercases the name.
|
||||
- `docker/login-action` to `localhost:5000`, `username: ${{ github.repository }}`,
|
||||
`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.
|
||||
- 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
|
||||
`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
|
||||
|
||||
**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 |
|
||||
| `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
|
||||
|
||||
- **Pull from a second server.** Verified on holyhorse itself only; v2's images pulled across
|
||||
servers the same way.
|
||||
- **Publish the images**: `ghcr.io/yundera/ipcr:1.3.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.2.0`
|
||||
(workflows `image.yml` and `bridge.yml`, tags `v1.3.0` and `bridge-v0.2.0`).
|
||||
- **Image provenance:** the bridge as a registry token server, so only a repository's own CI can
|
||||
push its name (see Trust).
|
||||
- **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.4.0` and `bridge-v0.3.0`).
|
||||
- **CI isolation:** jobs on a privileged daemon can reach the host. Rootless DinD does not start
|
||||
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
|
||||
"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.
|
||||
|
||||
+164
-36
@@ -32,8 +32,11 @@ type adminAPI struct {
|
||||
tokenFile string // ipcrd's admin-token, read through the read-only ipcr/state mount
|
||||
keystore string // Kubo's keystore folder, read-only
|
||||
publisher string // the publisher key's name (IMPORT_PUBLISHER)
|
||||
settings string // admin.json: the repositories switched off
|
||||
gitea *gitea
|
||||
settings string // admin.json: the repositories switched off, the root organisation
|
||||
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
|
||||
http *http.Client
|
||||
|
||||
@@ -43,6 +46,24 @@ type adminAPI struct {
|
||||
type adminSettings struct {
|
||||
// Repositories (owner/name, lowercase) whose images are not published.
|
||||
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 {
|
||||
@@ -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("GET /admin/api/repos", guard(http.HandlerFunc(a.listRepos)))
|
||||
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) {
|
||||
@@ -148,8 +172,69 @@ type repoToggle struct {
|
||||
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()
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -158,12 +243,18 @@ func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) {
|
||||
for _, d := range a.load().Disabled {
|
||||
disabled[d] = true
|
||||
}
|
||||
out := []repoToggle{}
|
||||
out := []repoView{}
|
||||
for _, rp := range repos {
|
||||
if eligible(&rp) {
|
||||
n := strings.ToLower(rp.FullName)
|
||||
out = append(out, repoToggle{FullName: n, Publish: !disabled[n]})
|
||||
if !eligible(&rp) {
|
||||
continue
|
||||
}
|
||||
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 })
|
||||
writeJSONResponse(w, out)
|
||||
@@ -189,58 +280,95 @@ func (a *adminAPI) setRepo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
sort.Strings(keep)
|
||||
s.Disabled = keep
|
||||
b, _ := json.MarshalIndent(s, "", " ")
|
||||
err := os.WriteFile(a.settings+".new", b, 0o644)
|
||||
if err == nil {
|
||||
err = os.Rename(a.settings+".new", a.settings)
|
||||
}
|
||||
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 IPCR did not take the new list: "+err.Error(), http.StatusServiceUnavailable)
|
||||
http.Error(w, "saved, but not fully applied: "+err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
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.
|
||||
func eligible(r *repo) bool { return r.public() && !r.Fork && !r.Mirror }
|
||||
|
||||
// allowlist is what ipcrd publishes: the image names CI builds are pushed under
|
||||
// (localhost:5000/<owner>/<repo>, lowercased by docker/metadata-action).
|
||||
func allowlist(repos []repo, disabled []string) []string {
|
||||
off := map[string]bool{}
|
||||
for _, d := range disabled {
|
||||
off[d] = true
|
||||
}
|
||||
var out []string
|
||||
for i := range repos {
|
||||
n := strings.ToLower(repos[i].FullName)
|
||||
if eligible(&repos[i]) && !off[n] {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// pushAllowlist sends ipcrd the current list. Called on every reconcile and on every toggle.
|
||||
// pushAllowlist sends ipcrd the current plan and gives the gate the matching credentials. Called
|
||||
// on every reconcile, toggle and root-organisation change.
|
||||
func (a *adminAPI) pushAllowlist() error {
|
||||
repos, err := a.gitea.publicRepos()
|
||||
p, _, err := a.currentPlan()
|
||||
if err != nil {
|
||||
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 {
|
||||
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.Header.Set("Content-Type", "application/json")
|
||||
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")
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
r := repo{FullName: name}
|
||||
if f != nil {
|
||||
@@ -421,10 +422,22 @@ func TestAllowlist(t *testing.T) {
|
||||
mk("owner/off", nil),
|
||||
mk("owner/secret", func(r *repo) { r.Private = true }),
|
||||
mk("owner/fork", func(r *repo) { r.Fork = true }),
|
||||
mk("org/thing", nil),
|
||||
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"}), ",")
|
||||
if got != "org/thing,owner/app" {
|
||||
t.Errorf("allowlist = %s", got)
|
||||
p := plan(repos, []string{"owner/off", "metadec/off2"}, "metadec", func(n string) bool { return n == "bob" })
|
||||
if got := strings.Join(p.Repos, ","); got != "metadec/bob,metadec/hello,metadec/owner,owner/app" {
|
||||
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
|
||||
# lists the exact `image:` line to use.
|
||||
#
|
||||
# Differences from a GitHub-hosted workflow, both needed on this forge:
|
||||
# images: localhost:5000/… the staging registry (no login, no secret)
|
||||
# Differences from a GitHub-hosted workflow, all needed on this forge:
|
||||
# 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
|
||||
name: image
|
||||
on:
|
||||
@@ -14,6 +16,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
id: meta
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// setup install step: Gitea token, system webhook, example repository (idempotent)
|
||||
// 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
|
||||
// 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)
|
||||
// KUBO_KEYSTORE Kubo's keystore, read-only, for key backups (default /srv/ipcr-keystore)
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -71,8 +76,10 @@ func main() {
|
||||
err = setup()
|
||||
case "health":
|
||||
err = health()
|
||||
case "gate":
|
||||
err = gateMain()
|
||||
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 {
|
||||
log.Fatal(err)
|
||||
@@ -128,10 +135,14 @@ func serve() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminGitea := newGitea(m.gitea.base, m.gitea.owner, secrets+"/gitea-admin-token")
|
||||
adm := &adminAPI{
|
||||
ipcr: strings.TrimRight(ipcr, "/"), tokenFile: env("IPCR_ADMIN_TOKEN_FILE", "/srv/ipcr-state/admin-token"),
|
||||
keystore: env("KUBO_KEYSTORE", "/srv/ipcr-keystore"), publisher: env("IMPORT_PUBLISHER", "forge"),
|
||||
settings: stateDir + "/admin.json", gitea: m.gitea, http: &http.Client{Timeout: 3 * time.Minute},
|
||||
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)
|
||||
// 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
|
||||
// the `rad` remote that pushes go to.
|
||||
func (m *mirror) radInit(dir string, r *repo) (string, error) {
|
||||
desc := r.Description
|
||||
if desc == "" {
|
||||
desc = "Mirrored from Gitea: " + r.FullName
|
||||
// A repository's ID is a hash of its first identity document (name, description, default
|
||||
// branch, delegates). Two Gitea repositories with the same name and description (an example
|
||||
// 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,
|
||||
"--default-branch", r.DefaultBranch, "--public", "--no-confirm")
|
||||
|
||||
+84
-10
@@ -19,7 +19,29 @@ import (
|
||||
//go:embed all:example
|
||||
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
|
||||
// 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.
|
||||
// a system webhook every repository's push/create/delete/repository events → HOOK_URL,
|
||||
// created or repaired (matched by URL), never duplicated.
|
||||
// EXAMPLE_REPO a public example repository with a workflow, created when absent.
|
||||
// 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);
|
||||
// SECRETS_DIR/oauth-client-id and oauth-client-secret.
|
||||
//
|
||||
@@ -78,6 +103,36 @@ func setup() error {
|
||||
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")
|
||||
secret, _ := os.ReadFile(secretFile)
|
||||
if strings.TrimSpace(string(secret)) == "" {
|
||||
@@ -100,7 +155,7 @@ func setup() error {
|
||||
|
||||
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// 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
|
||||
code, err := g.call("GET", "/repos/"+g.owner+"/"+name, nil, &r)
|
||||
code, err := g.call("GET", "/repos/"+owner+"/"+name, nil, &r)
|
||||
switch {
|
||||
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",
|
||||
"description": "Example: a tag here builds an image, published on IPFS by IPCR and mirrored to Radicle.",
|
||||
}, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("setup: created %s/%s", g.owner, name)
|
||||
log.Printf("setup: created %s/%s", owner, name)
|
||||
case err != nil:
|
||||
return err
|
||||
case !r.Empty:
|
||||
@@ -181,7 +240,7 @@ func ensureExample(g *gitea, name string) error {
|
||||
if err != nil {
|
||||
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,
|
||||
}, nil)
|
||||
return err
|
||||
@@ -226,10 +285,25 @@ func ensureOAuthApp(g *gitea, hosts []string, secrets string) error {
|
||||
continue
|
||||
}
|
||||
if haveSecret {
|
||||
// Keep it (and its secret); only follow a change of hosts.
|
||||
_, err := g.call("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), body, nil)
|
||||
// Keep it, and its secret. Gitea regenerates the secret on every update, so only
|
||||
// update when the hosts changed, and keep the secret it hands back.
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
+237
-74
@@ -59,6 +59,27 @@
|
||||
.bar { height: 8px; background: var(--code); border-radius: 4px; overflow: hidden; margin: 6px 0; }
|
||||
.bar span { display: block; height: 100%; background: var(--accent); }
|
||||
.note { border-left: 3px solid var(--warn); padding: 4px 12px; color: var(--muted); margin: 8px 0; }
|
||||
.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);
|
||||
padding: 8px 14px; border-radius: 8px; font-size: 14px; display: none; max-width: calc(100% - 32px); }
|
||||
</style>
|
||||
@@ -71,33 +92,43 @@
|
||||
<button id="logout">Sign out</button>
|
||||
</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>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="card wide">
|
||||
<h3>Key</h3>
|
||||
<dl>
|
||||
<dt>Key</dt><dd><code id="key-name">…</code></dd>
|
||||
<dt>IPNS name</dt><dd><code id="key-id">…</code></dd>
|
||||
<dt>Pull as</dt><dd><code id="pull-as">…</code></dd>
|
||||
<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><div class="copyrow"><code id="pull-as">…</code></div></dd>
|
||||
</dl>
|
||||
<p class="muted">Every image is published under this one name. Back the key up (below): it exists only on this server.</p>
|
||||
<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 class="card">
|
||||
<div class="card wide">
|
||||
<h3>Your domain or ENS name</h3>
|
||||
<p class="muted">Point a name you own at the IPNS name, then enter it here. Once it checks out, pull lines use it.</p>
|
||||
<form class="row" id="name-form">
|
||||
<input type="text" id="name" placeholder="example.eth or example.com" size="26" autocomplete="off">
|
||||
<input type="text" id="name" placeholder="example.eth or example.com" size="30" autocomplete="off">
|
||||
<input type="submit" value="Save and check">
|
||||
</form>
|
||||
<p id="name-check" class="muted"></p>
|
||||
<details><summary class="muted">How to point it here</summary>
|
||||
<p><b>ENS:</b> in the ENS manager, Records → Content hash = <code>ipns://<span class="kid">k51…</span></code>.</p>
|
||||
<p><b>DNS:</b> a TXT record <code>_dnslink.example.com</code> = <code>dnslink=/ipns/<span class="kid">k51…</span></code>.</p>
|
||||
<p><b>ENS:</b> in the ENS manager, Records → Content hash =</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>
|
||||
</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="card">
|
||||
<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
|
||||
record stays valid if this node goes offline; TTL is how long resolvers cache it.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Node</h3>
|
||||
<dl id="node"></dl>
|
||||
<div class="actions" style="margin-top:8px"><button id="peers">What the network sees</button></div>
|
||||
<div id="peers-out" class="muted"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Storage</h3>
|
||||
<div id="storage"></div>
|
||||
<p class="muted">Published images are pinned and never collected. Unpublishing an image frees the blocks no other image uses.</p>
|
||||
</div>
|
||||
<div 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>
|
||||
|
||||
<h2>Published images</h2>
|
||||
<div id="images" class="muted">Loading…</div>
|
||||
|
||||
<h2>Which repositories may publish</h2>
|
||||
<p class="muted">A build can push any image name to the staging registry; IPCR only publishes the names of the public
|
||||
Gitea repositories switched on here (<code><owner>/<repo></code>). New public repositories are on.</p>
|
||||
<p class="muted">Each public repository switched on here publishes under its own name only: the forge sets an
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="tab-backup" hidden>
|
||||
<h2>Key backup</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
@@ -153,6 +207,7 @@
|
||||
name's last published images are adopted.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<div id="toast"></div>
|
||||
|
||||
@@ -213,6 +268,34 @@
|
||||
.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
|
||||
function recordView(r, err, tree) {
|
||||
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")); }
|
||||
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) {
|
||||
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) {
|
||||
st8.textContent = "";
|
||||
publisher = s.key.id || "";
|
||||
$("key-name").textContent = s.key.name || "—";
|
||||
$("key-id").textContent = publisher || "not created yet (first import creates it)";
|
||||
Array.prototype.forEach.call(document.querySelectorAll(".kid"), function (e) { e.textContent = publisher || "k51…"; });
|
||||
$("key-id").textContent = publisher || "not created yet (the first import creates it)";
|
||||
$("ens-value").textContent = "ipns://" + (publisher || "k51…");
|
||||
$("dns-value").textContent = "dnslink=/ipns/" + (publisher || "k51…");
|
||||
pubName = s.name && s.name.ok ? s.name.name : "";
|
||||
$("pull-as").textContent = "ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "/<owner>/<repo>:<tag>";
|
||||
if (s.name && s.name.name) $("name").value = s.name.name;
|
||||
var nc = $("name-check"); nc.textContent = "";
|
||||
if (s.name && s.name.name) {
|
||||
nc.appendChild(s.name.ok ? badge("✓ " + s.name.name + " → this forge", "ok") : badge("✗ " + (s.name.error || "does not resolve"), "bad"));
|
||||
if (!s.name.ok && s.name.resolves) nc.appendChild(el("div", "resolves to " + s.name.resolves, "muted"));
|
||||
} else nc.textContent = "No name set: pull lines use the IPNS name.";
|
||||
$("pull-as").textContent = "docker pull ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "/<owner>/<repo>:<tag>";
|
||||
if (s.name && s.name.name && document.activeElement !== $("name")) $("name").value = s.name.name;
|
||||
showName(s.name);
|
||||
var rec = s.record;
|
||||
dl($("record"), [
|
||||
["Here", recordView(rec.local, rec.localError, rec.tree)],
|
||||
@@ -243,64 +334,101 @@
|
||||
["Lifetime", rec.lifetime], ["TTL", rec.ttl],
|
||||
["Checked", s.checkedAt.replace("T", " ").replace("Z", " UTC")]
|
||||
]);
|
||||
dl($("node"), [["Peer ID", s.node.id], ["Connected peers", s.node.peers],
|
||||
["Listening", (s.node.addresses || []).filter(function (a) { return a.indexOf("/127.") < 0 && a.indexOf("/::1/") < 0; }).slice(0, 4).join("\n")]]);
|
||||
var st = s.storage, pct = st.storageMax ? Math.min(100, 100 * st.repoSize / st.storageMax) : 0;
|
||||
$("storage").innerHTML = "";
|
||||
$("storage").appendChild(el("div", size(st.repoSize) + " of " + size(st.storageMax) + " · " + st.pins + " pins"));
|
||||
var pid = el("div", null, "copyrow"); pid.appendChild(el("code", s.node.id)); pid.appendChild(copyButton(s.node.id));
|
||||
dl($("node"), [["Peer ID", pid], ["Connected peers", s.node.peers]]);
|
||||
// Public addresses first; loopback and the container's own network are of no use to anyone else.
|
||||
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); });
|
||||
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);
|
||||
$("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)); };
|
||||
$("republish").onclick = function () {
|
||||
busy(this, api("POST", "ipcr/republish")).then(function () { toast("Published again"); loadStatus(true); });
|
||||
};
|
||||
$("peers").onclick = function () {
|
||||
var o = $("peers-out"); o.textContent = ""; o.appendChild(spinner("Asking the network…"));
|
||||
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"));
|
||||
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) {
|
||||
e.preventDefault();
|
||||
var btn = this.querySelector("input[type=submit]");
|
||||
busy(btn, api("PATCH", "ipcr/config", { name: $("name").value })).then(function () { return loadStatus(true); });
|
||||
var btn = this.querySelector("input[type=submit]"), input = $("name"), name = input.value.trim();
|
||||
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
|
||||
// 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() {
|
||||
return api("GET", "ipcr/images").then(function (d) {
|
||||
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.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) {
|
||||
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"));
|
||||
box.appendChild(h);
|
||||
var wrap = el("div", null, "scroll"), t = el("table"), head = el("tr");
|
||||
["Tag", "Image", "", ""].forEach(function (x) { head.appendChild(el("th", x)); });
|
||||
t.appendChild(head);
|
||||
card.appendChild(h);
|
||||
img.tags.forEach(function (tg) {
|
||||
var tr = el("tr");
|
||||
var td1 = el("td"); td1.appendChild(el("code", tg.tag));
|
||||
if (tg.at) td1.appendChild(el("div", tg.at.replace("T", " ").replace("Z", ""), "muted"));
|
||||
var td2 = el("td"); td2.appendChild(el("code", "/ipfs/" + tg.cid));
|
||||
var td3 = el("td"), prov = el("span", "", "muted"); td3.appendChild(prov);
|
||||
var td4 = el("td"), acts = el("div", null, "actions");
|
||||
var t = el("div", null, "tag"), head = el("div", null, "taghead"), left = el("div");
|
||||
left.appendChild(el("code", tg.tag));
|
||||
if (tg.at) left.appendChild(el("span", " " + tg.at.replace("T", " ").replace("Z", " UTC"), "muted"));
|
||||
var prov = el("span", "", "muted"); prov.style.marginLeft = "10px"; left.appendChild(prov);
|
||||
var acts = el("div", null, "actions");
|
||||
var chk = el("button", "Findable?");
|
||||
chk.onclick = function () {
|
||||
prov.textContent = ""; prov.appendChild(spinner("asking the network…"));
|
||||
busy(chk, api("GET", "ipcr/providers?cid=" + encodeURIComponent(tg.cid))).then(function (p) {
|
||||
prov.textContent = "";
|
||||
prov.appendChild(p.self ? badge("✓ this node is listed", "ok") : badge("✗ not listed (" + p.providers + " other providers)", "warn"));
|
||||
prov.appendChild(p.self ? badge("✓ findable: this node is listed", "ok") : badge("✗ not listed yet (" + p.providers + " other providers)", "warn"));
|
||||
if (!p.self) {
|
||||
var ann = el("button", "Announce");
|
||||
ann.onclick = function () { busy(ann, api("POST", "ipcr/announce", { cid: tg.cid })).then(function () { toast("Queued: Kubo announces it within a minute or two; check again then.", 6000); }); };
|
||||
prov.appendChild(document.createTextNode(" ")); prov.appendChild(ann);
|
||||
}
|
||||
});
|
||||
}, function () { prov.textContent = ""; });
|
||||
};
|
||||
acts.appendChild(chk);
|
||||
if (tg.tag !== "latest") {
|
||||
@@ -318,48 +446,83 @@
|
||||
});
|
||||
};
|
||||
acts.appendChild(un);
|
||||
td4.appendChild(acts);
|
||||
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
|
||||
t.appendChild(tr);
|
||||
head.appendChild(left); head.appendChild(acts);
|
||||
t.appendChild(head);
|
||||
t.appendChild(pullCommands(reg, d, img, tg));
|
||||
card.appendChild(t);
|
||||
});
|
||||
(img.staging || []).filter(function (s) { return s.state !== "published"; }).forEach(function (s) {
|
||||
var tr = el("tr");
|
||||
var td1 = el("td"); td1.appendChild(el("code", s.tag));
|
||||
var td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "in staging, not imported yet" : "in staging, not allowed"), "muted");
|
||||
var td3 = el("td"), td4 = el("td");
|
||||
var pending = (img.staging || []).filter(function (s) { return s.state !== "published"; });
|
||||
if (pending.length) {
|
||||
card.appendChild(el("h3", "In staging, not published"));
|
||||
var tbl = el("table");
|
||||
pending.forEach(function (s) {
|
||||
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");
|
||||
td1.appendChild(el("code", s.tag));
|
||||
if (img.allowed) {
|
||||
var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now");
|
||||
re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued"); setTimeout(loadImages, 5000); }); };
|
||||
td4.appendChild(re);
|
||||
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); }); };
|
||||
td3.appendChild(re);
|
||||
}
|
||||
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
|
||||
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; });
|
||||
}
|
||||
|
||||
// ---- 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() {
|
||||
return api("GET", "repos").then(function (repos) {
|
||||
var box = $("repos"); box.textContent = ""; box.className = "";
|
||||
if (!repos.length) { box.appendChild(el("p", "No public repository yet.", "muted")); return; }
|
||||
var t = el("table"), head = el("tr");
|
||||
["Repository", "Publish images"].forEach(function (x) { head.appendChild(el("th", x)); });
|
||||
["Repository", "Published as", "Push credential", "Publish"].forEach(function (x) { head.appendChild(el("th", x)); });
|
||||
t.appendChild(head);
|
||||
repos.forEach(function (r) {
|
||||
var tr = el("tr"), td1 = el("td"), td2 = el("td"), cb = document.createElement("input");
|
||||
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));
|
||||
(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.onchange = function () {
|
||||
cb.disabled = true;
|
||||
api("PUT", "repos", { full_name: r.full_name, publish: cb.checked })
|
||||
.then(function () { cb.disabled = false; toast(r.full_name + (cb.checked ? ": publishing" : ": not publishing")); loadImages(); },
|
||||
function (e) { cb.disabled = false; cb.checked = !cb.checked; toast(e.message, 8000); });
|
||||
.then(function () { toast(r.full_name + (cb.checked ? ": publishing" : ": not publishing")); return loadRepos(); },
|
||||
function (e) { cb.checked = !cb.checked; toast(e.message, 8000); })
|
||||
.then(function () { cb.disabled = false; loadImages(); });
|
||||
};
|
||||
td2.appendChild(cb);
|
||||
tr.appendChild(td1); tr.appendChild(td2); t.appendChild(tr);
|
||||
td4.appendChild(cb);
|
||||
[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);
|
||||
}).catch(function (e) { $("repos").textContent = "Repositories: " + e.message; });
|
||||
@@ -404,7 +567,7 @@
|
||||
}).catch(function (e) { toast(e.message, 8000); });
|
||||
};
|
||||
|
||||
loadStatus(false); loadImages(); loadRepos();
|
||||
loadStatus(false); loadImages(); loadRepos(); loadSettings();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+18
-9
@@ -131,9 +131,10 @@ git push -u origin main</code></pre>
|
||||
Applications</b>).</li>
|
||||
<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
|
||||
workflow with two differences: the image goes to <code>localhost:5000</code>, this forge's
|
||||
staging registry (no login, no secret), and buildx's builder runs with <code>network=host</code>
|
||||
so it can reach that registry.
|
||||
workflow with three differences: it logs in to <code>localhost:5000</code>, this forge's staging
|
||||
registry, as the repository itself (the forge sets the <code>IPCR_PUSH_TOKEN</code> secret on
|
||||
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
|
||||
on:
|
||||
push:
|
||||
@@ -143,6 +144,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
id: meta
|
||||
with:
|
||||
@@ -163,12 +169,13 @@ jobs:
|
||||
<pre><code>git add .gitea && git commit -m "Build the image"
|
||||
git tag v1.0.0
|
||||
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>
|
||||
<p class="note">Who can publish: anyone who can push to a repository on this forge, since a build
|
||||
can push any image name to the staging registry. Images are public once on IPFS, even when they come from
|
||||
a private repository. Gitea holds a pull request from a fork until someone with write access approves
|
||||
its workflow run: read the workflow before you approve.</p>
|
||||
<p class="note">Who can publish: each public repository publishes under its own name only, with its
|
||||
own push credential; the short root names belong to the forge's organisation, so to its members.
|
||||
Workflows from forks get no secrets, so they cannot publish. Private repositories never publish.</p>
|
||||
|
||||
<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
|
||||
@@ -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) {
|
||||
var info = r.tags[tag];
|
||||
// The configured domain or ENS name, while IPCR's check says it points here.
|
||||
var path = pub.name && pub.publisher && r.ipns.indexOf(pub.publisher) === 0 ? pub.name + r.ipns.slice(pub.publisher.length) : r.ipns;
|
||||
// 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 tr = el("tr");
|
||||
var td1 = el("td"); td1.appendChild(el("code", tag));
|
||||
|
||||
Reference in New Issue
Block a user