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:
Worph
2026-10-05 11:37:33 +00:00
parent b65bb867c9
commit c961d74e0e
15 changed files with 1275 additions and 189 deletions
+81 -25
View File
@@ -35,7 +35,7 @@ services:
# with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080.
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
ipcr-forge:
image: ghcr.io/yundera/ipcr-forge-bridge:0.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: /
+55 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+9 -2
View File
@@ -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
View File
@@ -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
}
+175
View File
@@ -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)
}
}
+10
View File
@@ -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
View File
@@ -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
+103
View File
@@ -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
View File
@@ -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")
+85 -11
View File
@@ -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,9 +285,24 @@ 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)
return err
// 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
+241 -78
View File
@@ -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/&lt;name&gt;/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>&lt;owner&gt;/&lt;repo&gt;</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");
if (img.allowed) {
var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now");
re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued"); setTimeout(loadImages, 5000); }); };
td4.appendChild(re);
}
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
t.appendChild(tr);
});
wrap.appendChild(t); box.appendChild(wrap);
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: imported on the next pass"); setTimeout(loadImages, 5000); }); };
td3.appendChild(re);
}
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tbl.appendChild(tr);
});
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
View File
@@ -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: &#36;{{ github.repository }}
password: &#36;{{ 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>&lt;owner&gt;/&lt;repo&gt;:1.0.0</code> under this forge's name.</li>
The image is published as <code>&lt;owner&gt;/&lt;repo&gt;:1.0.0</code> under this forge's name.
Repositories of the forge's own organisation are also published at the root:
<code>&lt;repo&gt;: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));