Rename the bridge to the forge service; this repository is the add-on
- bridge/ → forge/, module and binary ipcr-forge, image ghcr.io/yundera/ipcr-forge
(was ipcr-forge-bridge); releases are plain vX.Y.Z tags (workflow image.yml)
- data folders $AppID/bridge/{state,secrets} → $AppID/forge/..., in-container /forge
- docs link to the engine (Yundera/ipcr) instead of relative paths; README
This commit is contained in:
+13
-12
@@ -1,13 +1,14 @@
|
|||||||
name: bridge
|
name: image
|
||||||
|
|
||||||
# IPCR Forge's bridge image (bridge/): Gitea → Radicle mirror + the forge's page.
|
# IPCR Forge's service image (forge/): Gitea → Radicle mirror, the forge's page and admin, the
|
||||||
|
# staging gate. ghcr.io/yundera/ipcr-forge; a `vX.Y.Z` tag publishes X.Y.Z.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["bridge-v*"]
|
tags: ["v*"]
|
||||||
paths: ["bridge/**", ".github/workflows/bridge.yml"]
|
paths: ["forge/**", ".github/workflows/image.yml"]
|
||||||
pull_request:
|
pull_request:
|
||||||
paths: ["bridge/**", ".github/workflows/bridge.yml"]
|
paths: ["forge/**", ".github/workflows/image.yml"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -15,7 +16,7 @@ permissions:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: ghcr.io/yundera/ipcr-forge-bridge
|
IMAGE: ghcr.io/yundera/ipcr-forge
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
vet:
|
vet:
|
||||||
@@ -24,9 +25,9 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: bridge/go.mod
|
go-version-file: forge/go.mod
|
||||||
- run: test -z "$(gofmt -l .)" && go vet ./... && go test ./...
|
- run: test -z "$(gofmt -l .)" && go vet ./... && go test ./...
|
||||||
working-directory: bridge
|
working-directory: forge
|
||||||
|
|
||||||
image:
|
image:
|
||||||
needs: vet
|
needs: vet
|
||||||
@@ -45,17 +46,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.IMAGE }}
|
images: ${{ env.IMAGE }}
|
||||||
tags: |
|
tags: |
|
||||||
type=match,pattern=bridge-v(.*),group=1
|
type=semver,pattern={{version}}
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=sha
|
type=sha
|
||||||
# amd64 only: the Radicle CLI is fetched as its x86_64 release, and the Forge listing is
|
# amd64 only: the Radicle CLI is fetched as its x86_64 release, and the Forge listing is
|
||||||
# amd64 only.
|
# amd64 only.
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: bridge
|
context: forge
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: VERSION=${{ steps.meta.outputs.version }}
|
build-args: VERSION=${{ steps.meta.outputs.version }}
|
||||||
cache-from: type=gha,scope=bridge
|
cache-from: type=gha,scope=forge
|
||||||
cache-to: type=gha,mode=max,scope=bridge
|
cache-to: type=gha,mode=max,scope=forge
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ name: ipcr-forge
|
|||||||
# → ipcr-kubo / ipcr-registry
|
# → ipcr-kubo / ipcr-registry
|
||||||
# docker pull ipcr.localhost:4767/ipns/<forge name>/<owner>/<repo>:1.2.3 (anyone)
|
# docker pull ipcr.localhost:4767/ipns/<forge name>/<owner>/<repo>:1.2.3 (anyone)
|
||||||
#
|
#
|
||||||
# gitea ──system webhook──▶ ipcr-forge (bridge) ──rad init / git push rad──▶ radicle-node
|
# gitea ──system webhook──▶ ipcr-forge ──rad init / git push rad──▶ radicle-node
|
||||||
# every PUBLIC repository, one way: Radicle is a distribution mirror signed by this node.
|
# every PUBLIC repository, one way: Radicle is a distribution mirror signed by this node.
|
||||||
#
|
#
|
||||||
# The tile opens Gitea. ipcr-forge-<domain> is the forge's own page, served by the bridge: how to
|
# The tile opens Gitea. ipcr-forge-<domain> is the forge's own page, served by the forge service: how to
|
||||||
# publish, the published images with the `image:` line to use, and the Radicle mirrors.
|
# publish, the published images with the `image:` line to use, and the Radicle mirrors.
|
||||||
#
|
#
|
||||||
# Gitea, Radicle and IPCR services are copied from their standalone listings (Apps/Gitea,
|
# Gitea, Radicle and IPCR services are copied from their standalone listings (Apps/Gitea,
|
||||||
# Apps/Radicle, Apps/IPCR), so each keeps its own comments and rationale.md; the differences are
|
# Apps/Radicle, Apps/IPCR), so each keeps its own comments and rationale.md; the differences are
|
||||||
# marked "Forge:". Data folders move, one subfolder per part: $AppID/{gitea,radicle,bridge,ci,ipcr}/.
|
# marked "Forge:". Data folders move, one subfolder per part: $AppID/{gitea,radicle,forge,ci,ipcr}/.
|
||||||
#
|
#
|
||||||
# Replaces the Gitea, Radicle and IPCR apps; it cannot sit beside them. It uses the same container
|
# Replaces the Gitea, Radicle and IPCR apps; it cannot sit beside them. It uses the same container
|
||||||
# names, domains (gitea-, radicle-, radicle-api-) and host ports (8776, 4768, 127.0.0.1:4767).
|
# names, domains (gitea-, radicle-, radicle-api-) and host ports (8776, 4768, 127.0.0.1:4767).
|
||||||
@@ -30,19 +30,19 @@ name: ipcr-forge
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
# ═══ Bridge — the forge's page, and Gitea → Radicle ════════════════════════════
|
# ═══ Bridge — the forge's page, and Gitea → Radicle ════════════════════════════
|
||||||
# ipcr-forge-bridge (bridge/ in the IPCR repository). Receives Gitea's system webhook on :8081
|
# ipcr-forge (forge/ in the ipcr-forge repository). Receives Gitea's system webhook on :8081
|
||||||
# (internal: no Caddy route) and mirrors every public repository to the Radicle node below,
|
# (internal: no Caddy route) and mirrors every public repository to the Radicle node below,
|
||||||
# with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080.
|
# with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080.
|
||||||
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
|
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
|
||||||
ipcr-forge:
|
ipcr-forge:
|
||||||
image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0
|
image: ghcr.io/yundera/ipcr-forge:0.4.0
|
||||||
container_name: ipcr-forge
|
container_name: ipcr-forge
|
||||||
user: $PUID:$PGID
|
user: $PUID:$PGID
|
||||||
read_only: true
|
read_only: true
|
||||||
cap_drop: [ALL]
|
cap_drop: [ALL]
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["ipcr-forge-bridge", "serve"]
|
command: ["ipcr-forge", "serve"]
|
||||||
cpu_shares: 30
|
cpu_shares: 30
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
GITEA_URL: http://gitea:3000
|
GITEA_URL: http://gitea:3000
|
||||||
GITEA_OWNER: gitea_admin
|
GITEA_OWNER: gitea_admin
|
||||||
SECRETS_DIR: /secrets
|
SECRETS_DIR: /secrets
|
||||||
STATE_DIR: /bridge
|
STATE_DIR: /forge
|
||||||
RAD_HOME: /radicle-home
|
RAD_HOME: /radicle-home
|
||||||
IPCR_PUBLISHED: /srv/ipcr-state/published.json
|
IPCR_PUBLISHED: /srv/ipcr-state/published.json
|
||||||
RECONCILE: 10m
|
RECONCILE: 10m
|
||||||
@@ -77,9 +77,9 @@ services:
|
|||||||
# radicle-api has.
|
# radicle-api has.
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
|
||||||
# repos.json (Gitea repository → Radicle ID) and one bare mirror per repository.
|
# repos.json (Gitea repository → Radicle ID) and one bare mirror per repository.
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/state:/forge
|
||||||
# The Gitea token and the webhook secret the install step writes. Read-only here.
|
# The Gitea token and the webhook secret the install step writes. Read-only here.
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/secrets:/secrets:ro
|
||||||
# published.json → /images.json, and the admin API's token. Read-only.
|
# published.json → /images.json, and the admin API's token. Read-only.
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro
|
||||||
# Kubo's keystore, read-only: the publisher key, for encrypted backups (Kubo exports keys
|
# Kubo's keystore, read-only: the publisher key, for encrypted backups (Kubo exports keys
|
||||||
@@ -89,7 +89,7 @@ services:
|
|||||||
- gitea
|
- gitea
|
||||||
- radicle-node
|
- radicle-node
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "ipcr-forge-bridge", "health"]
|
test: ["CMD", "ipcr-forge", "health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -160,7 +160,7 @@ services:
|
|||||||
# Forge: off. Images go to the staging registry that IPCR follows; a second registry would
|
# Forge: off. Images go to the staging registry that IPCR follows; a second registry would
|
||||||
# be a second place an image could be published from.
|
# be a second place an image could be published from.
|
||||||
GITEA__packages__ENABLED: "false"
|
GITEA__packages__ENABLED: "false"
|
||||||
# Forge: webhooks may reach the bridge (ipcr-forge, on this server's network) and, as by
|
# Forge: webhooks may reach the forge service (ipcr-forge, on this server's network) and, as by
|
||||||
# default, any public address. Not `private`: that would let any user's webhook reach every
|
# default, any public address. Not `private`: that would let any user's webhook reach every
|
||||||
# container on the shared network.
|
# container on the shared network.
|
||||||
GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge
|
GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge
|
||||||
@@ -277,11 +277,11 @@ services:
|
|||||||
# The staging registry's front door: `localhost:5000` to the daemon and to every job (act runs
|
# 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
|
# 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
|
# 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
|
# as its repository (<owner>/<repo>, the IPCR_PUSH_TOKEN secret the forge service sets on it) and may
|
||||||
# push that name only; IPCR logs in as the importer (state/staging-auth, which it generates).
|
# 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.
|
# Same image as the forge service (`ipcr-forge gate`, forge/gate.go). See rationale.md.
|
||||||
forge-gate:
|
forge-gate:
|
||||||
image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0
|
image: ghcr.io/yundera/ipcr-forge:0.4.0
|
||||||
container_name: forge-gate
|
container_name: forge-gate
|
||||||
user: $PUID:$PGID
|
user: $PUID:$PGID
|
||||||
read_only: true
|
read_only: true
|
||||||
@@ -289,7 +289,7 @@ services:
|
|||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: service:forge-docker
|
network_mode: service:forge-docker
|
||||||
command: ["ipcr-forge-bridge", "gate"]
|
command: ["ipcr-forge", "gate"]
|
||||||
cpu_shares: 20
|
cpu_shares: 20
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -301,8 +301,8 @@ services:
|
|||||||
GATE_UPSTREAM: unix:///run/staging/registry.sock
|
GATE_UPSTREAM: unix:///run/staging/registry.sock
|
||||||
GATE_CREDENTIALS: /gate/credentials.json
|
GATE_CREDENTIALS: /gate/credentials.json
|
||||||
volumes:
|
volumes:
|
||||||
# Hashes only, written by the bridge.
|
# Hashes only, written by the forge service.
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state/gate:/gate:ro
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/state/gate:/gate:ro
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
|
||||||
depends_on:
|
depends_on:
|
||||||
- forge-docker
|
- forge-docker
|
||||||
@@ -342,7 +342,7 @@ services:
|
|||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
|
||||||
|
|
||||||
# ═══ Radicle — the public mirror ═══════════════════════════════════════════════
|
# ═══ Radicle — the public mirror ═══════════════════════════════════════════════
|
||||||
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The bridge
|
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The forge service
|
||||||
# creates and pushes repositories in this node's storage; the node replicates them.
|
# creates and pushes repositories in this node's storage; the node replicates them.
|
||||||
|
|
||||||
# The web UI: the Radicle Explorer bundle served as static files by Caddy.
|
# The web UI: the Radicle Explorer bundle served as static files by Caddy.
|
||||||
@@ -499,7 +499,7 @@ services:
|
|||||||
# Forge: watch the CI's staging registry, over ci-internal. Internal, so no credentials.
|
# Forge: watch the CI's staging registry, over ci-internal. Internal, so no credentials.
|
||||||
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000}
|
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000}
|
||||||
IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-}
|
IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-}
|
||||||
# Forge: IPCR's login at the staging registry's gate, generated on first start; the bridge
|
# Forge: IPCR's login at the staging registry's gate, generated on first start; the forge service
|
||||||
# reads it (read-only ipcr/state) and gives it the importer's rights at the gate.
|
# 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_FILE: /data/state/staging-auth
|
||||||
IMPORT_AUTH_GENERATE: "true"
|
IMPORT_AUTH_GENERATE: "true"
|
||||||
@@ -508,8 +508,8 @@ services:
|
|||||||
# /ipns/<forge key>/<repo>:<tag>. One name for the whole forge, which a DNSLink domain or an
|
# /ipns/<forge key>/<repo>:<tag>. One name for the whole forge, which a DNSLink domain or an
|
||||||
# ENS name can point at: ipcr.localhost:4767/ipns/example.eth/<repo>:<tag>.
|
# ENS name can point at: ipcr.localhost:4767/ipns/example.eth/<repo>:<tag>.
|
||||||
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
|
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
|
||||||
# Forge: publish only the repositories the bridge allows (public Gitea repositories, minus
|
# Forge: publish only the repositories the forge service allows (public Gitea repositories, minus
|
||||||
# those switched off in the admin pages). Until the bridge sends its list, nothing is.
|
# those switched off in the admin pages). Until the forge service sends its list, nothing is.
|
||||||
IMPORT_ALLOW_REQUIRED: "true"
|
IMPORT_ALLOW_REQUIRED: "true"
|
||||||
# Forge: delete each tag from the staging registry once published (its blobs go at the
|
# Forge: delete each tag from the staging registry once published (its blobs go at the
|
||||||
# registry's next start, see forge-registry).
|
# registry's next start, see forge-registry).
|
||||||
@@ -518,7 +518,7 @@ services:
|
|||||||
# its name; resolvers cache them 5 minutes, so a new tag shows up quickly.
|
# its name; resolvers cache them 5 minutes, so a new tag shows up quickly.
|
||||||
IPNS_LIFETIME: ${IPCR_IPNS_LIFETIME:-168h}
|
IPNS_LIFETIME: ${IPCR_IPNS_LIFETIME:-168h}
|
||||||
IPNS_TTL: 5m
|
IPNS_TTL: 5m
|
||||||
# Forge: the admin API, for the bridge, on ipcr-admin. Bearer token in state/admin-token.
|
# Forge: the admin API, for the forge service, on ipcr-admin. Bearer token in state/admin-token.
|
||||||
ADMIN_LISTEN: ":4769"
|
ADMIN_LISTEN: ":4769"
|
||||||
IMPORT_INTERVAL: 60s
|
IMPORT_INTERVAL: 60s
|
||||||
STATE_DIR: /data/state
|
STATE_DIR: /data/state
|
||||||
@@ -547,7 +547,7 @@ services:
|
|||||||
- ipcr-internal
|
- ipcr-internal
|
||||||
# Forge only: reaches the staging registry for the import poller above.
|
# Forge only: reaches the staging registry for the import poller above.
|
||||||
- ci-internal
|
- ci-internal
|
||||||
# Forge only: the admin API, reached by the bridge.
|
# Forge only: the admin API, reached by the forge service.
|
||||||
- ipcr-admin
|
- ipcr-admin
|
||||||
cpu_shares: 50
|
cpu_shares: 50
|
||||||
deploy:
|
deploy:
|
||||||
@@ -841,15 +841,15 @@ x-compose-app:
|
|||||||
schema_version: 2
|
schema_version: 2
|
||||||
folders:
|
folders:
|
||||||
# ── Bridge ──
|
# ── Bridge ──
|
||||||
- path: /DATA/AppData/$AppID/bridge/state
|
- path: /DATA/AppData/$AppID/forge/state
|
||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
- path: /DATA/AppData/$AppID/bridge/secrets
|
- path: /DATA/AppData/$AppID/forge/secrets
|
||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
mode: "0700"
|
mode: "0700"
|
||||||
# The gate's credentials (hashes), written by the bridge, read by the gate.
|
# The gate's credentials (hashes), written by the forge service, read by the gate.
|
||||||
- path: /DATA/AppData/$AppID/bridge/state/gate
|
- path: /DATA/AppData/$AppID/forge/state/gate
|
||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
# ── Gitea ──
|
# ── Gitea ──
|
||||||
@@ -897,7 +897,7 @@ x-compose-app:
|
|||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
mode: "0700"
|
mode: "0700"
|
||||||
# Created ahead of Kubo's first start: the bridge mounts it, and Docker would otherwise create
|
# Created ahead of Kubo's first start: the forge service mounts it, and Docker would otherwise create
|
||||||
# it as root, where Kubo cannot write its keys.
|
# it as root, where Kubo cannot write its keys.
|
||||||
- path: /DATA/AppData/$AppID/ipcr/kubo/keystore
|
- path: /DATA/AppData/$AppID/ipcr/kubo/keystore
|
||||||
user: $PUID
|
user: $PUID
|
||||||
@@ -927,7 +927,7 @@ x-compose-app:
|
|||||||
mode: "0755"
|
mode: "0755"
|
||||||
init:
|
init:
|
||||||
# In order: Radicle's identity before its node starts, then Gitea's admin, then the runner
|
# In order: Radicle's identity before its node starts, then Gitea's admin, then the runner
|
||||||
# token and the bridge's setup, which both need that admin to exist.
|
# token and the forge service's setup, which both need that admin to exist.
|
||||||
- name: radicle-identity
|
- name: radicle-identity
|
||||||
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
|
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
|
||||||
user: $PUID:$PGID
|
user: $PUID:$PGID
|
||||||
@@ -969,21 +969,21 @@ x-compose-app:
|
|||||||
mv /runner/token.new /runner/token
|
mv /runner/token.new /runner/token
|
||||||
chmod 600 /runner/token
|
chmod 600 /runner/token
|
||||||
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
|
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
|
||||||
# The bridge's Gitea tokens (public-only read; and write:repository for push secrets), the
|
# The forge service'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
|
# 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 example repository (in that organisation). Idempotent, so on every start: it repairs the webhook.
|
||||||
# The admin password is used here only, never stored.
|
# The admin password is used here only, never stored.
|
||||||
- name: forge-setup
|
- name: forge-setup
|
||||||
image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0
|
image: ghcr.io/yundera/ipcr-forge:0.4.0
|
||||||
user: $PUID:$PGID
|
user: $PUID:$PGID
|
||||||
phase: post_up
|
phase: post_up
|
||||||
network: $APP_NET
|
network: $APP_NET
|
||||||
volumes:
|
volumes:
|
||||||
- /DATA/AppData/$AppID/bridge/secrets:/secrets
|
- /DATA/AppData/$AppID/forge/secrets:/secrets
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- 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
|
- 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 setup
|
||||||
when: always
|
when: always
|
||||||
webui-host: gitea-${domain}
|
webui-host: gitea-${domain}
|
||||||
webui-path: /
|
webui-path: /
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# IPCR Forge — Rationale
|
# IPCR Forge — Rationale
|
||||||
|
|
||||||
IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon,
|
IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon,
|
||||||
and the bridge (`ipcr-forge-bridge`), which mirrors public Gitea repositories to Radicle and
|
and the forge service (`ipcr-forge`), which mirrors public Gitea repositories to Radicle and
|
||||||
serves the forge's page. The Gitea, Radicle and IPCR services are copied from their listings,
|
serves the forge's page. The Gitea, Radicle and IPCR services are copied from their listings,
|
||||||
so each keeps the deviations its standalone listing already argues. This document lists them,
|
so each keeps the deviations its standalone listing already argues. This document lists them,
|
||||||
then covers only what the bundle adds.
|
then covers only what the bundle adds.
|
||||||
@@ -23,11 +23,11 @@ with. See docs/forge.md.
|
|||||||
**Added by the bundle:**
|
**Added by the bundle:**
|
||||||
|
|
||||||
1. **`forge-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did.
|
1. **`forge-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did.
|
||||||
2. **The bridge (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's
|
2. **The forge service (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's
|
||||||
key. It publishes repositories signed by that key.
|
key. It publishes repositories signed by that key.
|
||||||
3. **A system webhook and its allow-list.** Gitea may deliver webhooks to `ipcr-forge` on the
|
3. **A system webhook and its allow-list.** Gitea may deliver webhooks to `ipcr-forge` on the
|
||||||
server's network (`GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge`).
|
server's network (`GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge`).
|
||||||
4. **An install step that uses the admin password** to mint the bridge's token and to register
|
4. **An install step that uses the admin password** to mint the forge service's token and to register
|
||||||
that webhook.
|
that webhook.
|
||||||
5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry
|
5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry
|
||||||
is.
|
is.
|
||||||
@@ -37,15 +37,15 @@ with. See docs/forge.md.
|
|||||||
domains and host ports.
|
domains and host ports.
|
||||||
9. **Admin pages with their own login** (`/admin`): Gitea OAuth2, Gitea site admins only. The
|
9. **Admin pages with their own login** (`/admin`): Gitea OAuth2, Gitea site admins only. The
|
||||||
install step registers the OAuth2 app.
|
install step registers the OAuth2 app.
|
||||||
10. **The bridge mounts Kubo's keystore read-only**, so it can export the publisher key, encrypted.
|
10. **The forge service mounts Kubo's keystore read-only**, so it can export the publisher key, encrypted.
|
||||||
11. **An admin API in `ipcr-gateway`** (`:4769`) on a new internal network, `ipcr-admin`. Its Bearer
|
11. **An admin API in `ipcr-gateway`** (`:4769`) on a new internal network, `ipcr-admin`. Its Bearer
|
||||||
token is in `ipcr/state/admin-token`, mode 0644.
|
token is in `ipcr/state/admin-token`, mode 0644.
|
||||||
12. **A staging gate** (`forge-gate`), running in the CI daemon's network namespace. The registry
|
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.
|
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
|
13. **A second Gitea token for the forge service,** with `write:repository` and `read:user`. It sets each
|
||||||
publishing repository's `IPCR_PUSH_TOKEN` Actions secret and checks name collisions.
|
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
|
14. **A credential IPCR generates for itself** (`ipcr/state/staging-auth`, mode 0644), read by the
|
||||||
bridge.
|
forge service.
|
||||||
15. **The root organisation** (`IPCR_ROOT_ORG`): setup creates it if missing, and its repositories
|
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.
|
are also published at the root of the forge's name.
|
||||||
16. **A public, unauthenticated, read-only registry route** (`ipcr-<domain>` → `ipcr-public`), so this
|
16. **A public, unauthenticated, read-only registry route** (`ipcr-<domain>` → `ipcr-public`), so this
|
||||||
@@ -65,11 +65,11 @@ container that reaches the daemon over a unix socket.
|
|||||||
|
|
||||||
**2. The node's home.** Publishing to Radicle means three things: creating a repository in the
|
**2. The node's home.** Publishing to Radicle means three things: creating a repository in the
|
||||||
node's storage, signing its refs with the node's key, and announcing them through the node's
|
node's storage, signing its refs with the node's key, and announcing them through the node's
|
||||||
control socket. Those live in that folder. This is the mount `radicle-api` already has. The bridge
|
control socket. Those live in that folder. This is the mount `radicle-api` already has. The forge service
|
||||||
uses the stock `rad` CLI (1.10.1, the node's version) rather than reimplementing any of it.
|
uses the stock `rad` CLI (1.10.1, the node's version) rather than reimplementing any of it.
|
||||||
|
|
||||||
**3. The webhook.** Pushes reach Radicle within seconds instead of at the next 10-minute
|
**3. The webhook.** Pushes reach Radicle within seconds instead of at the next 10-minute
|
||||||
reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the bridge sits
|
reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the forge service sits
|
||||||
on one. The list adds exactly one host name. `private` would let any user's webhook reach every
|
on one. The list adds exactly one host name. `private` would let any user's webhook reach every
|
||||||
container on the shared network.
|
container on the shared network.
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ on the PCS gate, which is not on every host.
|
|||||||
|
|
||||||
**10. The keystore mount.** Kubo exports keys from its CLI only (`key/export` is not available
|
**10. The keystore mount.** Kubo exports keys from its CLI only (`key/export` is not available
|
||||||
over its HTTP API). ipcrd runs as root without capabilities, so it cannot read a `$PUID` 0700
|
over its HTTP API). ipcrd runs as root without capabilities, so it cannot read a `$PUID` 0700
|
||||||
folder; giving it `DAC_READ_SEARCH` was rejected once already. The bridge runs as `$PUID` and can.
|
folder; giving it `DAC_READ_SEARCH` was rejected once already. The forge service runs as `$PUID` and can.
|
||||||
|
|
||||||
**11. The admin API and its token.** Unpublishing, moving tags, key restore and the allowlist
|
**11. The admin API and its token.** Unpublishing, moving tags, key restore and the allowlist
|
||||||
change ipcrd's own state (the published tree, `published.json`), so they live in ipcrd. The token is
|
change ipcrd's own state (the published tree, `published.json`), so they live in ipcrd. The token is
|
||||||
@@ -127,7 +127,7 @@ repository it belongs to. Setting a secret takes `write:repository`. The token i
|
|||||||
`public-only`, so collision checks also see private users and organisations.
|
`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)
|
**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
|
cannot write into another uid's folder, so it writes into its own state folder, which the forge service
|
||||||
already mounts read-only.
|
already mounts read-only.
|
||||||
|
|
||||||
**15. The root organisation.** Short names (`metadec.eth/ipcr-hello`) need one owner whose
|
**15. The root organisation.** Short names (`metadec.eth/ipcr-hello`) need one owner whose
|
||||||
@@ -141,17 +141,17 @@ purpose: the images are public on IPFS anyway.
|
|||||||
|
|
||||||
## Security mitigations in place
|
## Security mitigations in place
|
||||||
|
|
||||||
**The bridge**
|
**The forge service**
|
||||||
- **It sees public repositories only.** Its Gitea token is scoped `public-only` and
|
- **It sees public repositories only.** Its Gitea token is scoped `public-only` and
|
||||||
`read:repository`. Gitea hides every private or limited repository from it, and it can write
|
`read:repository`. Gitea hides every private or limited repository from it, and it can write
|
||||||
nothing in Gitea. The code checks visibility a second time. Forks and pull mirrors are skipped.
|
nothing in Gitea. The code checks visibility a second time. Forks and pull mirrors are skipped.
|
||||||
- **Webhooks are authenticated and carry no content.** Each delivery must pass the HMAC-SHA256
|
- **Webhooks are authenticated and carry no content.** Each delivery must pass the HMAC-SHA256
|
||||||
check against a random secret written at install (`bridge/secrets`, 0700). A delivery only says
|
check against a random secret written at install (`forge/secrets`, 0700). A delivery only says
|
||||||
*which* repository changed. The bridge re-reads that repository from Gitea, so a forged payload
|
*which* repository changed. The forge service re-reads that repository from Gitea, so a forged payload
|
||||||
can at most cause a pointless sync. Port 8081 has no Caddy route.
|
can at most cause a pointless sync. Port 8081 has no Caddy route.
|
||||||
- **It runs unprivileged:** `$PUID`, every capability dropped, a read-only root filesystem,
|
- **It runs unprivileged:** `$PUID`, every capability dropped, a read-only root filesystem,
|
||||||
`no-new-privileges`, 256 MB. One worker does one sync at a time.
|
`no-new-privileges`, 256 MB. One worker does one sync at a time.
|
||||||
- **The node's key stays in the bridge and the node.** No CI job mounts the Radicle home.
|
- **The node's key stays in the forge service and the node.** No CI job mounts the Radicle home.
|
||||||
|
|
||||||
**The CI daemon**
|
**The CI daemon**
|
||||||
- **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an
|
- **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an
|
||||||
@@ -202,8 +202,8 @@ purpose: the images are public on IPFS anyway.
|
|||||||
- **Can be switched off** in the admin pages (`config.json` `"public": false`).
|
- **Can be switched off** in the admin pages (`config.json` `"public": false`).
|
||||||
|
|
||||||
**The page**
|
**The page**
|
||||||
- **The public page cannot change anything.** The bridge serves it read-only: the embedded page,
|
- **The public page cannot change anything.** The forge service serves it read-only: the embedded page,
|
||||||
IPCR's `published.json` (mounted read-only) and the bridge's repository list.
|
IPCR's `published.json` (mounted read-only) and the forge service's repository list.
|
||||||
|
|
||||||
**The admin pages**
|
**The admin pages**
|
||||||
- **Only Gitea site admins get in.** The check is `is_admin`, read from Gitea's own API through the
|
- **Only Gitea site admins get in.** The check is `is_admin`, read from Gitea's own API through the
|
||||||
@@ -215,12 +215,12 @@ purpose: the images are public on IPFS anyway.
|
|||||||
custom `X-IPCR-Admin` header is required. A cross-site form cannot send that header, and a
|
custom `X-IPCR-Admin` header is required. A cross-site form cannot send that header, and a
|
||||||
cross-site script cannot without a CORS preflight, which is never answered.
|
cross-site script cannot without a CORS preflight, which is never answered.
|
||||||
- **The admin API is only on `ipcr-admin`.** That network is `internal: true` and shared only by
|
- **The admin API is only on `ipcr-admin`.** That network is `internal: true` and shared only by
|
||||||
the bridge and `ipcr-gateway`; every call needs the Bearer token, compared in constant time. Kubo's
|
the forge service and `ipcr-gateway`; every call needs the Bearer token, compared in constant time. Kubo's
|
||||||
own API stays on `ipcr-internal`.
|
own API stays on `ipcr-internal`.
|
||||||
- **The cleartext key never crosses the network.** The bridge encrypts it in memory (AES-256-GCM,
|
- **The cleartext key never crosses the network.** The forge service encrypts it in memory (AES-256-GCM,
|
||||||
PBKDF2 at 600k iterations, at least 12 characters of passphrase) and ipcrd decrypts it. A
|
PBKDF2 at 600k iterations, at least 12 characters of passphrase) and ipcrd decrypts it. A
|
||||||
restore never deletes a key: the previous one is kept, renamed.
|
restore never deletes a key: the previous one is kept, renamed.
|
||||||
- **The allowlist is closed by default.** Until the bridge sends its list, nothing is published
|
- **The allowlist is closed by default.** Until the forge service sends its list, nothing is published
|
||||||
(`IMPORT_ALLOW_REQUIRED`). Private repositories are never on it.
|
(`IMPORT_ALLOW_REQUIRED`). Private repositories are never on it.
|
||||||
|
|
||||||
## Alternatives considered and rejected
|
## Alternatives considered and rejected
|
||||||
@@ -234,7 +234,7 @@ purpose: the images are public on IPFS anyway.
|
|||||||
| A per-repository webhook | Every repository would need setup; one system webhook covers all of them, including new ones |
|
| A per-repository webhook | Every repository would need setup; one system webhook covers all of them, including new ones |
|
||||||
| Gitea's package registry as the staging registry (v1) | Needs tokens for the workflow and for IPCR, plus the `DAC_READ_SEARCH` capability for IPCR to read its token. It would also be a second place images get published from |
|
| Gitea's package registry as the staging registry (v1) | Needs tokens for the workflow and for IPCR, plus the `DAC_READ_SEARCH` capability for IPCR to read its token. It would also be a second place images get published from |
|
||||||
| Host Docker socket for jobs | Any workflow would get root on the host |
|
| Host Docker socket for jobs | Any workflow would get root on the host |
|
||||||
| A long-lived admin token for the bridge | The webhook is repaired by the install step on every start instead, so the running service never holds admin rights |
|
| A long-lived admin token for the forge service | The webhook is repaired by the install step on every start instead, so the running service never holds admin rights |
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ purpose: the images are public on IPFS anyway.
|
|||||||
repository changes hands.
|
repository changes hands.
|
||||||
- **Existing workflows must add a login step,** or their pushes fail with 401.
|
- **Existing workflows must add a login step,** or their pushes fail with 401.
|
||||||
- **Private repositories cannot publish:** they get no push credential.
|
- **Private repositories cannot publish:** they get no push credential.
|
||||||
- **The bridge can read the publisher key.** A compromise of the public-facing bridge leaks it. That
|
- **The forge service can read the publisher key.** A compromise of the public-facing forge service leaks it. That
|
||||||
is the trust it already has with the Radicle node key.
|
is the trust it already has with the Radicle node key.
|
||||||
- **The admin token is world-readable** inside the app's state folder on the host.
|
- **The admin token is world-readable** inside the app's state folder on the host.
|
||||||
- **Old keys accumulate.** `<key>-replaced-*` keys are kept and republished by Kubo until removed by
|
- **Old keys accumulate.** `<key>-replaced-*` keys are kept and republished by Kubo until removed by
|
||||||
@@ -268,7 +268,7 @@ Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config |
|
| `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config |
|
||||||
| `radicle/` | node identity (key pair), seeded repositories, Caddy config |
|
| `radicle/` | node identity (key pair), seeded repositories, Caddy config |
|
||||||
| `bridge/` | `state/`: the Gitea → Radicle mapping, one bare mirror per repository (disposable), the admin session key and the repository toggles (`admin.json`); `gate/credentials.json` (token hashes); `secrets/` (0700): its two Gitea tokens, the webhook secret, the OAuth2 client credentials |
|
| `forge/` | `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/`) |
|
| `ci/` | the daemon's socket, its image store (`docker/`), the staging registry (`registry/`, disposable) and its socket (`registry-socket/`) |
|
||||||
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `config.json` (name, allowlist), the admin token |
|
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `config.json` (name, allowlist), the admin token |
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,34 @@
|
|||||||
# ipcr-forge
|
# IPCR Forge
|
||||||
|
|
||||||
|
A self-hosted forge whose releases live on IPFS: work in **Gitea** as usual, its Actions build your
|
||||||
|
images, the [IPCR](https://github.com/Yundera/ipcr) engine publishes them under one IPNS name (and
|
||||||
|
your ENS or DNS name), and every public repository is mirrored to **Radicle**.
|
||||||
|
|
||||||
|
```
|
||||||
|
git push origin v1.0.0
|
||||||
|
docker pull ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0 # any host running IPCR
|
||||||
|
docker pull ipcr-<domain>/ipns/metadec.eth/ipcr-hello:1.0.0 # anywhere, through the forge
|
||||||
|
```
|
||||||
|
|
||||||
|
This repository is the add-on; [Yundera/ipcr](https://github.com/Yundera/ipcr) is the engine (the
|
||||||
|
registry gateway, IPFS import, IPNS naming and admin API), used here as the image
|
||||||
|
`ghcr.io/yundera/ipcr`.
|
||||||
|
|
||||||
|
- **[docs/forge.md](docs/forge.md)**: how it works, the trust model, findings, what was verified.
|
||||||
|
- **[Apps/IPCR-Forge/](Apps/IPCR-Forge/)**: the Yundera AppStore listing (compose, rationale, seed).
|
||||||
|
- **[forge/](forge/)**: the forge service, `ghcr.io/yundera/ipcr-forge` (Go, standard library only):
|
||||||
|
the Gitea → Radicle mirror, the forge's page and admin pages (Gitea login), the staging registry's
|
||||||
|
gate and per-repository push credentials, and the install step.
|
||||||
|
- **[Apps/Gitea/](Apps/Gitea/)**, **[Apps/Radicle/](Apps/Radicle/)**: the standalone listings the
|
||||||
|
forge is built from.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd forge && go vet ./... && go test ./...
|
||||||
|
docker build -t ghcr.io/yundera/ipcr-forge:dev forge
|
||||||
|
```
|
||||||
|
|
||||||
|
Releases: a `vX.Y.Z` tag publishes `ghcr.io/yundera/ipcr-forge:X.Y.Z`
|
||||||
|
([.github/workflows/image.yml](.github/workflows/image.yml)). The listing pins both images: this
|
||||||
|
one and the engine's.
|
||||||
|
|||||||
+39
-32
@@ -1,5 +1,11 @@
|
|||||||
# IPCR Forge — design notes
|
# IPCR Forge — design notes
|
||||||
|
|
||||||
|
This repository is the forge, an add-on to the IPCR engine
|
||||||
|
([Yundera/ipcr](https://github.com/Yundera/ipcr): the registry gateway, the IPFS import, the IPNS
|
||||||
|
naming, the admin API). The engine knows nothing about Gitea, Radicle or this forge. The forge uses
|
||||||
|
the engine's image (`ghcr.io/yundera/ipcr`) and the contract listed in its
|
||||||
|
[design notes](https://github.com/Yundera/ipcr/blob/main/docs/design.md#contract-with-add-ons).
|
||||||
|
|
||||||
How the forge in [Apps/IPCR-Forge/](../Apps/IPCR-Forge/) goes from `git push` to `docker pull`, why it
|
How the forge in [Apps/IPCR-Forge/](../Apps/IPCR-Forge/) goes from `git push` to `docker pull`, why it
|
||||||
is shaped this way, and what was learned building it. The listing's own security argument is in
|
is shaped this way, and what was learned building it. The listing's own security argument is in
|
||||||
[rationale.md](../Apps/IPCR-Forge/rationale.md).
|
[rationale.md](../Apps/IPCR-Forge/rationale.md).
|
||||||
@@ -24,8 +30,8 @@ A forge people actually want to use, whose releases do not depend on it staying
|
|||||||
| **v3** (this) | **Gitea** | **Gitea Actions** | — |
|
| **v3** (this) | **Gitea** | **Gitea Actions** | — |
|
||||||
|
|
||||||
v3 keeps what worked in both: v1's Gitea + Actions setup, and v2's staging registry inside the
|
v3 keeps what worked in both: v1's Gitea + Actions setup, and v2's staging registry inside the
|
||||||
CI's own Docker daemon (no tokens, no registry credentials). The bridge is v1's glue reversed and
|
CI's own Docker daemon (no tokens, no registry credentials). The forge service is v1's glue reversed and
|
||||||
rewritten in Go ([bridge/](../bridge/)).
|
rewritten in Go ([forge/](../forge/)).
|
||||||
|
|
||||||
The objection that removed Gitea in v2 was "two places where state can originate". It does not apply
|
The objection that removed Gitea in v2 was "two places where state can originate". It does not apply
|
||||||
here: state originates in Gitea only. Radicle receives a copy that nobody edits; patches or issues
|
here: state originates in Gitea only. Radicle receives a copy that nobody edits; patches or issues
|
||||||
@@ -37,7 +43,7 @@ opened on the Radicle side are not read.
|
|||||||
developer: git push origin main v1.2.3 (or: create a tag in Gitea's UI)
|
developer: git push origin main v1.2.3 (or: create a tag in Gitea's UI)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (bridge)
|
gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (forge service)
|
||||||
│ · git fetch from Gitea
|
│ · git fetch from Gitea
|
||||||
│ Actions · rad init (first time)
|
│ Actions · rad init (first time)
|
||||||
▼ · git push rad (signed by the node)
|
▼ · git push rad (signed by the node)
|
||||||
@@ -54,9 +60,9 @@ anyone: docker pull ipcr.localhost:4767/ipns/<forge k51… | example.eth>/<owner
|
|||||||
The two legs do not depend on each other: a repository can be built without being mirrored
|
The two legs do not depend on each other: a repository can be built without being mirrored
|
||||||
(private repositories), and mirrored without being built (no workflow).
|
(private repositories), and mirrored without being built (no workflow).
|
||||||
|
|
||||||
## The bridge
|
## The forge service
|
||||||
|
|
||||||
[bridge/](../bridge/): one Go binary, `ipcr-forge-bridge`, image `ghcr.io/yundera/ipcr-forge-bridge`.
|
[forge/](../forge/): one Go binary, `ipcr-forge`, image `ghcr.io/yundera/ipcr-forge`.
|
||||||
|
|
||||||
- **`setup`**, an install step (`post_up`, on every start, idempotent). It signs in once as
|
- **`setup`**, an install step (`post_up`, on every start, idempotent). It signs in once as
|
||||||
`gitea_admin` with the default app password and does four things:
|
`gitea_admin` with the default app password and does four things:
|
||||||
@@ -95,9 +101,9 @@ code checks visibility again anyway.
|
|||||||
|
|
||||||
- **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a
|
- **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a
|
||||||
distribution channel here, not the trust root. The `/rad/<rid>/…` image-naming idea in
|
distribution channel here, not the trust root. The `/rad/<rid>/…` image-naming idea in
|
||||||
[naming.md](naming.md) is weaker for it.
|
[naming.md](https://github.com/Yundera/ipcr/blob/main/docs/naming.md) is weaker for it.
|
||||||
- **Only allowed names are published.** IPCR publishes an image name only if it is on the allowlist
|
- **Only allowed names are published.** IPCR publishes an image name only if it is on the allowlist
|
||||||
the bridge keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus
|
the forge service keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus
|
||||||
those switched off in the admin pages. Images built from private repositories are therefore not
|
those switched off in the admin pages. Images built from private repositories are therefore not
|
||||||
published.
|
published.
|
||||||
- **Each repository publishes its own name only.** The staging registry sits behind a gate (see
|
- **Each repository publishes its own name only.** The staging registry sits behind a gate (see
|
||||||
@@ -117,17 +123,17 @@ code checks visibility again anyway.
|
|||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
`https://ipcr-forge-<domain>/admin`. It is served by the bridge and only lets in Gitea site
|
`https://ipcr-forge-<domain>/admin`. It is served by the forge service and only lets in Gitea site
|
||||||
administrators.
|
administrators.
|
||||||
|
|
||||||
**Sign-in.** It uses an OAuth2 app that the `setup` step registers, with one redirect URI per
|
**Sign-in.** It uses an OAuth2 app that the `setup` step registers, with one redirect URI per
|
||||||
host. The flow is the authorization code with PKCE and the `read:user` scope. The bridge reads
|
host. The flow is the authorization code with PKCE and the `read:user` scope. The forge service reads
|
||||||
`is_admin` from `/api/v1/user`, drops the Gitea token, and sets its own HMAC-signed session (8 h;
|
`is_admin` from `/api/v1/user`, drops the Gitea token, and sets its own HMAC-signed session (8 h;
|
||||||
the key lives in `bridge/state`). Writes require the `X-IPCR-Admin` header and a same-origin
|
the key lives in `forge/state`). Writes require the `X-IPCR-Admin` header and a same-origin
|
||||||
request.
|
request.
|
||||||
|
|
||||||
**Behind the page.** The page calls the bridge, and the bridge calls ipcrd's admin API
|
**Behind the page.** The page calls the forge service, and the forge service calls ipcrd's admin API
|
||||||
(`gateway/admin.go`). That API listens on `:4769`, on the `ipcr-admin` network (internal, bridge
|
([`gateway/admin.go`](https://github.com/Yundera/ipcr/blob/main/gateway/admin.go) in the IPCR engine). That API listens on `:4769`, on the `ipcr-admin` network (internal, forge service
|
||||||
and gateway only), and needs a Bearer token from `ipcr/state/admin-token`.
|
and gateway only), and needs a Bearer token from `ipcr/state/admin-token`.
|
||||||
|
|
||||||
| Section | What it does |
|
| Section | What it does |
|
||||||
@@ -145,12 +151,12 @@ ipfs key import forge key
|
|||||||
```
|
```
|
||||||
Then publish with a sequence number above the network's.
|
Then publish with a sequence number above the network's.
|
||||||
|
|
||||||
**Backup format** (`gateway/keybackup.go`, copied in `bridge/`):
|
**Backup format** ([`gateway/keybackup.go`](https://github.com/Yundera/ipcr/blob/main/gateway/keybackup.go) in the engine, copied in `forge/`):
|
||||||
- JSON containing PBKDF2-SHA256 (600k iterations) and AES-256-GCM;
|
- JSON containing PBKDF2-SHA256 (600k iterations) and AES-256-GCM;
|
||||||
- the additional authenticated data binds the key's name and its `k51…`;
|
- the additional authenticated data binds the key's name and its `k51…`;
|
||||||
- the plaintext is exactly what `ipfs key export` writes.
|
- the plaintext is exactly what `ipfs key export` writes.
|
||||||
|
|
||||||
The bridge seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the
|
The forge service seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the
|
||||||
network.
|
network.
|
||||||
|
|
||||||
## Root organisation (short names)
|
## Root organisation (short names)
|
||||||
@@ -163,7 +169,7 @@ 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/metadec.eth/metadec/ipcr-hello:1.0.0
|
||||||
ipcr.localhost:4767/ipns/k51…/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":
|
- **How it is passed on:** the forge service sends IPCR `allow.aliases` (`{"metadec/ipcr-hello":
|
||||||
["ipcr-hello"]}`). IPCR publishes each tag under the full path and every alias in one tree
|
["ipcr-hello"]}`). IPCR publishes each tag under the full path and every alias in one tree
|
||||||
update.
|
update.
|
||||||
- **Alias sync:** on each pass IPCR adds and removes alias folders to match. Switching the
|
- **Alias sync:** on each pass IPCR adds and removes alias folders to match. Switching the
|
||||||
@@ -212,8 +218,8 @@ IPCR ── Basic ipcr:<generated> ── forge-docker:5000 ──────
|
|||||||
▼
|
▼
|
||||||
forge-registry (no network)
|
forge-registry (no network)
|
||||||
```
|
```
|
||||||
- **`forge-gate`** is `ipcr-forge-bridge gate`, built from the same image.
|
- **`forge-gate`** is `ipcr-forge gate`, built from the same image.
|
||||||
- It checks Basic auth against `bridge/state/gate/credentials.json` (hashes only), re-read when
|
- It checks Basic auth against `forge/state/gate/credentials.json` (hashes only), re-read when
|
||||||
it changes.
|
it changes.
|
||||||
- **A repository** may GET, HEAD, POST, PUT and PATCH under `/v2/<owner>/<repo>/{blobs,manifests,tags}/`.
|
- **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.
|
Cross-repository blob mounts are stripped, so the client uploads instead.
|
||||||
@@ -223,19 +229,19 @@ IPCR ── Basic ipcr:<generated> ── forge-docker:5000 ──────
|
|||||||
the daemon, which sees neither that folder nor a port. So the gate cannot be bypassed short of a
|
the daemon, which sees neither that folder nor a port. So the gate cannot be bypassed short of a
|
||||||
breakout (see Trust).
|
breakout (see Trust).
|
||||||
- **Credentials:**
|
- **Credentials:**
|
||||||
- **Per repository:** the bridge generates a token for each repository that may publish. It
|
- **Per repository:** the forge service generates a token for each repository that may publish. It
|
||||||
sets the repository's `IPCR_PUSH_TOKEN` Actions secret through a `write:repository` token
|
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
|
(`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.
|
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`,
|
- **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.
|
`state/staging-auth`), and the forge service passes its hash to the gate.
|
||||||
- **Migration:** existing workflows must add the login step (the template below). Without it, a
|
- **Migration:** existing workflows must add the login step (the template below). Without it, a
|
||||||
push fails with `401 Unauthorized` at its first blob check.
|
push fails with `401 Unauthorized` at its first blob check.
|
||||||
|
|
||||||
## Workflow contract
|
## Workflow contract
|
||||||
|
|
||||||
A repository opts in by committing a workflow under `.gitea/workflows/`. The template is
|
A repository opts in by committing a workflow under `.gitea/workflows/`. The template is
|
||||||
[bridge/example/.gitea/workflows/build.yml](../bridge/example/.gitea/workflows/build.yml):
|
[forge/example/.gitea/workflows/build.yml](../forge/example/.gitea/workflows/build.yml):
|
||||||
|
|
||||||
- `docker/login-action` to `localhost:5000`, `username: ${{ github.repository }}`,
|
- `docker/login-action` to `localhost:5000`, `username: ${{ github.repository }}`,
|
||||||
`password: ${{ secrets.IPCR_PUSH_TOKEN }}`.
|
`password: ${{ secrets.IPCR_PUSH_TOKEN }}`.
|
||||||
@@ -261,35 +267,35 @@ Carried over from v1 and v2; each one silently produced the wrong result rather
|
|||||||
it first.
|
it first.
|
||||||
5. **Radicle has no shared `refs/tags/`** unless the identity carries a canonical-refs rule. Without
|
5. **Radicle has no shared `refs/tags/`** unless the identity carries a canonical-refs rule. Without
|
||||||
one, a tag lives under its pusher's namespace, where `rad clone` and the explorer do not show it.
|
one, a tag lives under its pusher's namespace, where `rad clone` and the explorer do not show it.
|
||||||
The bridge adds the rule right after `rad init`.
|
The forge service adds the rule right after `rad init`.
|
||||||
|
|
||||||
New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
|
New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
|
||||||
|
|
||||||
6. **`rad init` works on a bare repository** (it uses `workdir().unwrap_or(path)`), so the bridge
|
6. **`rad init` works on a bare repository** (it uses `workdir().unwrap_or(path)`), so the forge service
|
||||||
keeps bare mirrors only. It also seeds the repository on the node and adds the `rad` remote.
|
keeps bare mirrors only. It also seeds the repository on the node and adds the `rad` remote.
|
||||||
7. **A deleted tag stays canonical.** `git push --prune` removes it from the node's namespace, but
|
7. **A deleted tag stays canonical.** `git push --prune` removes it from the node's namespace, but
|
||||||
`refs/tags/<tag>` keeps the last target. Tags are meant to be immutable, so this was left alone.
|
`refs/tags/<tag>` keeps the last target. Tags are meant to be immutable, so this was left alone.
|
||||||
8. **Gitea's default webhook allow-list (`external`) blocks the bridge.** It is on the server's
|
8. **Gitea's default webhook allow-list (`external`) blocks the forge service.** It is on the server's
|
||||||
private network. `ALLOWED_HOST_LIST: external,ipcr-forge` allows exactly that one container.
|
private network. `ALLOWED_HOST_LIST: external,ipcr-forge` allows exactly that one container.
|
||||||
`private` would let any user's webhook reach every container on the shared network.
|
`private` would let any user's webhook reach every container on the shared network.
|
||||||
9. **`rad sync --announce` takes its RID positionally** (`rad sync <rid> --announce`); there is no
|
9. **`rad sync --announce` takes its RID positionally** (`rad sync <rid> --announce`); there is no
|
||||||
`--rid` flag.
|
`--rid` flag.
|
||||||
10. **`rad sync` blocks for its timeout when the node has no peers.** The bridge pushes with
|
10. **`rad sync` blocks for its timeout when the node has no peers.** The forge service pushes with
|
||||||
`-o no-sync` and announces separately with a 30-second bound, so a push is never held up by the
|
`-o no-sync` and announces separately with a 30-second bound, so a push is never held up by the
|
||||||
network.
|
network.
|
||||||
11. **`act_runner` needs about 1 GB.** It holds a job's checkout and actions in memory while it
|
11. **`act_runner` needs about 1 GB.** It holds a job's checkout and actions in memory while it
|
||||||
copies them into the job container. At 256 MB it was OOM-killed mid-job. The job container
|
copies them into the job container. At 256 MB it was OOM-killed mid-job. The job container
|
||||||
then ran on, orphaned, and Gitea showed the run as "running" until it timed out.
|
then ran on, orphaned, and Gitea showed the run as "running" until it timed out.
|
||||||
12. **The first `rad init` can fail if the node is still starting.** The bridge's retry adopts the
|
12. **The first `rad init` can fail if the node is still starting.** The forge service's retry adopts the
|
||||||
RID that `rad init` already wrote into the mirror's `rad` remote, so no second repository is
|
RID that `rad init` already wrote into the mirror's `rad` remote, so no second repository is
|
||||||
created (seen on holyhorse). `rad` prints its errors on stdout, which the bridge now logs.
|
created (seen on holyhorse). `rad` prints its errors on stdout, which the forge service now logs.
|
||||||
13. **Gitea's API returns `html_url` built from the internal address** (`http://gitea:3000/…`) when
|
13. **Gitea's API returns `html_url` built from the internal address** (`http://gitea:3000/…`) when
|
||||||
called through it. The page builds Gitea links from its own host name instead.
|
called through it. The page builds Gitea links from its own host name instead.
|
||||||
14. **Right after `up`, new domains answer with the gateway's SSO redirect** until Caddy picks up the
|
14. **Right after `up`, new domains answer with the gateway's SSO redirect** until Caddy picks up the
|
||||||
labels (about a minute). The runner's registration retries through that window.
|
labels (about a minute). The runner's registration retries through that window.
|
||||||
|
|
||||||
15. **`key/export` is CLI-only** in Kubo (`NoRemote`), and ipcrd, which runs as root without
|
15. **`key/export` is CLI-only** in Kubo (`NoRemote`), and ipcrd, which runs as root without
|
||||||
capabilities, cannot read the `$PUID` 0700 keystore. The bridge reads `key_<base32 name>`
|
capabilities, cannot read the `$PUID` 0700 keystore. The forge service reads `key_<base32 name>`
|
||||||
(`forge` → `key_mzxxez3f`, mode 0400) through a read-only mount. Those bytes are what
|
(`forge` → `key_mzxxez3f`, mode 0400) through a read-only mount. Those bytes are what
|
||||||
`ipfs key export` would write.
|
`ipfs key export` would write.
|
||||||
16. **Delegated routing does not use the spec's 404.** For a name it doesn't know, delegated-ipfs.dev
|
16. **Delegated routing does not use the spec's 404.** For a name it doesn't know, delegated-ipfs.dev
|
||||||
@@ -310,7 +316,7 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
|
|||||||
22. **Radicle IDs are derived from the identity document.** `metadec/ipcr-hello` (the example,
|
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
|
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
|
`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 service now puts the Gitea path in the Radicle description, which makes the document unique per
|
||||||
forge.
|
forge.
|
||||||
23. **Gitea regenerates an OAuth2 app's secret on every update.** `setup` used to update the app at
|
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
|
every start (to follow the hosts) and kept the old secret, which broke the admin login
|
||||||
@@ -327,7 +333,7 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
|
|||||||
|
|
||||||
## Verified
|
## Verified
|
||||||
|
|
||||||
**Locally** (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the bridge image, one Docker network):
|
**Locally** (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the forge service image, one Docker network):
|
||||||
|
|
||||||
- `setup` run twice leaves exactly one system hook.
|
- `setup` run twice leaves exactly one system hook.
|
||||||
- `ipcr-hello` was created and mirrored with the crefs rule in its identity.
|
- `ipcr-hello` was created and mirrored with the crefs rule in its identity.
|
||||||
@@ -390,11 +396,12 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
|
|||||||
|
|
||||||
- **Pull from a second IPCR server** (`ipcr.localhost`): verified on holyhorse itself only, though
|
- **Pull from a second IPCR server** (`ipcr.localhost`): verified on holyhorse itself only, though
|
||||||
the public front door was pulled from another machine; v2's images pulled across IPCR servers.
|
the public front door was pulled from another machine; v2's images pulled across IPCR servers.
|
||||||
- **Publish the images**: `ghcr.io/yundera/ipcr:1.5.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.4.0`
|
- **Publish the images**: `ghcr.io/yundera/ipcr:1.5.0` and `ghcr.io/yundera/ipcr-forge:0.4.0`
|
||||||
(workflows `image.yml` and `bridge.yml`, tags `v1.5.0` and `bridge-v0.4.0`).
|
(`ghcr.io/yundera/ipcr` from the engine repository, tag `v1.5.0`; `ghcr.io/yundera/ipcr-forge`
|
||||||
|
from this one, tag `v0.4.0`).
|
||||||
- **CI isolation:** jobs on a privileged daemon can reach the host. Rootless DinD does not start
|
- **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.
|
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
|
- **Private repositories** cannot publish: the forge service only sees public ones. If wanted, give them
|
||||||
push credentials too (the images would still be public on IPFS).
|
push credentials too (the images would still be public on IPFS).
|
||||||
- **Old keys:** `<key>-replaced-*` keys are kept forever, and Kubo keeps republishing them. A
|
- **Old keys:** `<key>-replaced-*` keys are kept forever, and Kubo keeps republishing them. A
|
||||||
"delete" in the admin page, once the new key is confirmed.
|
"delete" in the admin page, once the new key is confirmed.
|
||||||
|
|||||||
+8
-8
@@ -1,10 +1,10 @@
|
|||||||
# ipcr-forge-bridge: the Gitea → Radicle mirror and IPCR Forge's page. Our Go binary, plus git and
|
# ipcr-forge: the Gitea → Radicle mirror and IPCR Forge's page. Our Go binary, plus git and
|
||||||
# the stock Radicle CLI (`rad`, `git-remote-rad`) at the version the forge's node runs.
|
# the stock Radicle CLI (`rad`, `git-remote-rad`) at the version the forge's node runs.
|
||||||
# Build context: this folder (bridge/).
|
# Build context: this folder (forge/).
|
||||||
FROM golang:1.24-alpine AS build
|
FROM golang:1.24-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ipcr-forge-bridge .
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ipcr-forge .
|
||||||
|
|
||||||
# Radicle's official static (musl) release, checked against its published digest.
|
# Radicle's official static (musl) release, checked against its published digest.
|
||||||
FROM alpine:3.22 AS radicle
|
FROM alpine:3.22 AS radicle
|
||||||
@@ -21,17 +21,17 @@ RUN apk add --no-cache xz \
|
|||||||
|
|
||||||
FROM alpine:3.22
|
FROM alpine:3.22
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
LABEL org.opencontainers.image.title="ipcr-forge-bridge" \
|
LABEL org.opencontainers.image.title="ipcr-forge" \
|
||||||
org.opencontainers.image.description="IPCR Forge: mirrors public Gitea repositories to Radicle, serves the forge's page" \
|
org.opencontainers.image.description="IPCR Forge: mirrors public Gitea repositories to Radicle, serves the forge's page" \
|
||||||
org.opencontainers.image.source="https://github.com/Yundera/ipcr" \
|
org.opencontainers.image.source="https://github.com/Yundera/ipcr-forge" \
|
||||||
org.opencontainers.image.version="$VERSION"
|
org.opencontainers.image.version="$VERSION"
|
||||||
RUN apk add --no-cache ca-certificates git \
|
RUN apk add --no-cache ca-certificates git \
|
||||||
# Mirrors and the node's storage belong to the service's uid, but a bind mount can make git see
|
# Mirrors and the node's storage belong to the service's uid, but a bind mount can make git see
|
||||||
# another owner ("dubious ownership"); the storage is the node's own, so trust it.
|
# another owner ("dubious ownership"); the storage is the node's own, so trust it.
|
||||||
&& git config --system safe.directory '*'
|
&& git config --system safe.directory '*'
|
||||||
COPY --from=radicle /out/ /usr/local/bin/
|
COPY --from=radicle /out/ /usr/local/bin/
|
||||||
COPY --from=build /out/ipcr-forge-bridge /usr/local/bin/ipcr-forge-bridge
|
COPY --from=build /out/ipcr-forge /usr/local/bin/ipcr-forge
|
||||||
ENV RAD_HOME=/radicle-home \
|
ENV RAD_HOME=/radicle-home \
|
||||||
HOME=/tmp
|
HOME=/tmp
|
||||||
# No entrypoint: the install step runs `sh -c '… ipcr-forge-bridge setup'` in this image.
|
# No entrypoint: the install step runs `sh -c '… ipcr-forge setup'` in this image.
|
||||||
CMD ["ipcr-forge-bridge", "serve"]
|
CMD ["ipcr-forge", "serve"]
|
||||||
|
|||||||
+3
-3
@@ -17,12 +17,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The gate is the staging registry's front door (`ipcr-forge-bridge gate`). CI jobs push to
|
// The gate is the staging registry's front door (`ipcr-forge gate`). CI jobs push to
|
||||||
// localhost:5000 and IPCR imports from it; before the gate, anyone who could run a job could push
|
// 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:
|
// 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
|
// - 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
|
// forge service 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
|
// manifests, blobs and tags, and nothing else: no other name, no catalog, no delete, no
|
||||||
// cross-repository blob mount;
|
// cross-repository blob mount;
|
||||||
// - IPCR logs in as the importer (the credential it generated itself): read everything, list the
|
// - IPCR logs in as the importer (the credential it generated itself): read everything, list the
|
||||||
@@ -34,7 +34,7 @@ import (
|
|||||||
// Environment: GATE_LISTEN (default :5000), GATE_UPSTREAM (default
|
// Environment: GATE_LISTEN (default :5000), GATE_UPSTREAM (default
|
||||||
// unix:///run/staging/registry.sock), GATE_CREDENTIALS (default /gate/credentials.json).
|
// 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.
|
// gateCreds is credentials.json, written by the forge service: SHA-256 (hex) of each password.
|
||||||
type gateCreds struct {
|
type gateCreds struct {
|
||||||
Push map[string]string `json:"push"` // "<owner>/<repo>" (lowercase) → hash
|
Push map[string]string `json:"push"` // "<owner>/<repo>" (lowercase) → hash
|
||||||
Importer map[string]string `json:"importer"` // user → hash
|
Importer map[string]string `json:"importer"` // user → hash
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
module ipcr-forge-bridge
|
module ipcr-forge
|
||||||
|
|
||||||
go 1.24
|
go 1.24
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ package main
|
|||||||
// and exports it (`ipfs key export`, libp2p-protobuf-cleartext). The additional data binds name and
|
// and exports it (`ipfs key export`, libp2p-protobuf-cleartext). The additional data binds name and
|
||||||
// id to the ciphertext, so neither can be edited. Byte slices are base64 (encoding/json's default).
|
// id to the ciphertext, so neither can be edited. Byte slices are base64 (encoding/json's default).
|
||||||
//
|
//
|
||||||
// A copy of gateway/keybackup.go (ipcrd), without its CLI: the bridge writes backups (it can read
|
// A copy of gateway/keybackup.go (ipcrd), without its CLI: the forge service writes backups (it can read
|
||||||
// Kubo's keystore; ipcrd cannot) and ipcrd reads them, so the cleartext key never crosses the
|
// Kubo's keystore; ipcrd cannot) and ipcrd reads them, so the cleartext key never crosses the
|
||||||
// network. Keep the two identical; both test the same vector.
|
// network. Keep the two identical; both test the same vector.
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
// ipcr-forge-bridge — the glue of IPCR Forge: Gitea is where people work, Radicle is where the code
|
// ipcr-forge — the glue of IPCR Forge: Gitea is where people work, Radicle is where the code
|
||||||
// is replicated, and this service keeps the second following the first. One way only.
|
// is replicated, and this service keeps the second following the first. One way only.
|
||||||
//
|
//
|
||||||
// serve (default) webhook receiver + mirror worker + the forge's page
|
// serve (default) webhook receiver + mirror worker + the forge's page
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
// GITEA_OWNER the admin account (default gitea_admin)
|
// GITEA_OWNER the admin account (default gitea_admin)
|
||||||
// GITEA_PASSWORD setup only: its password, used once to mint the token and the hook
|
// GITEA_PASSWORD setup only: its password, used once to mint the token and the hook
|
||||||
// SECRETS_DIR gitea-token and hook-secret (default /secrets)
|
// SECRETS_DIR gitea-token and hook-secret (default /secrets)
|
||||||
// STATE_DIR repos.json and the bare mirrors (default /bridge)
|
// STATE_DIR repos.json and the bare mirrors (default /forge)
|
||||||
// RAD_HOME the node's Radicle home (default /radicle-home)
|
// RAD_HOME the node's Radicle home (default /radicle-home)
|
||||||
// WEB_LISTEN the page (default :8080)
|
// WEB_LISTEN the page (default :8080)
|
||||||
// HOOK_LISTEN the webhook, internal only (default :8081)
|
// HOOK_LISTEN the webhook, internal only (default :8081)
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
// IMPORT_PUBLISHER the publisher key's name (default forge)
|
// IMPORT_PUBLISHER the publisher key's name (default forge)
|
||||||
// IPCR_ROOT_ORG organisation published at the root of the forge's name, until changed in
|
// IPCR_ROOT_ORG organisation published at the root of the forge's name, until changed in
|
||||||
// the admin page (default: none)
|
// the admin page (default: none)
|
||||||
// GATE_CREDENTIALS the gate's credentials.json (default /bridge/gate/credentials.json)
|
// GATE_CREDENTIALS the gate's credentials.json (default /forge/gate/credentials.json)
|
||||||
// IPCR_STAGING_AUTH IPCR's own staging credential (default /srv/ipcr-state/staging-auth)
|
// IPCR_STAGING_AUTH IPCR's own staging credential (default /srv/ipcr-state/staging-auth)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ func serve() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("RECONCILE: %w", err)
|
return fmt.Errorf("RECONCILE: %w", err)
|
||||||
}
|
}
|
||||||
stateDir := env("STATE_DIR", "/bridge")
|
stateDir := env("STATE_DIR", "/forge")
|
||||||
st, err := loadState(stateDir + "/repos.json")
|
st, err := loadState(stateDir + "/repos.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -166,11 +166,11 @@ func serve() error {
|
|||||||
log.Printf("allowlist: %v", err)
|
log.Printf("allowlist: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("bridge: admin pages on, IPCR admin API %s", ipcr)
|
log.Printf("forge: admin pages on, IPCR admin API %s", ipcr)
|
||||||
}
|
}
|
||||||
go reconcileLoop(m, q, every)
|
go reconcileLoop(m, q, every)
|
||||||
|
|
||||||
log.Printf("bridge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every)
|
log.Printf("forge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every)
|
||||||
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), mux)
|
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ import (
|
|||||||
var exampleFiles embed.FS
|
var exampleFiles embed.FS
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tokenName = "ipcr-forge-bridge"
|
tokenName = "ipcr-forge"
|
||||||
adminTokenName = "ipcr-forge-secrets"
|
adminTokenName = "ipcr-forge-secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ const (
|
|||||||
stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted
|
stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted
|
||||||
)
|
)
|
||||||
|
|
||||||
// repoState is what the bridge remembers per Gitea repository, keyed by Gitea's numeric ID so a
|
// repoState is what the forge service remembers per Gitea repository, keyed by Gitea's numeric ID so a
|
||||||
// rename or a transfer keeps its Radicle ID.
|
// rename or a transfer keeps its Radicle ID.
|
||||||
type repoState struct {
|
type repoState struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<title>IPCR Forge admin</title>
|
<title>IPCR Forge admin</title>
|
||||||
<link rel="icon" href="icon.png">
|
<link rel="icon" href="icon.png">
|
||||||
<!--
|
<!--
|
||||||
The forge's admin page: Gitea site administrators only (the bridge checks the session for the
|
The forge's admin page: Gitea site administrators only (the forge service checks the session for the
|
||||||
page and for every API call). Everything goes through /admin/api/: the bridge's own endpoints
|
page and for every API call). Everything goes through /admin/api/: the forge service's own endpoints
|
||||||
(repositories, key export) and IPCR's admin API behind /admin/api/ipcr/.
|
(repositories, key export) and IPCR's admin API behind /admin/api/ipcr/.
|
||||||
-->
|
-->
|
||||||
<style>
|
<style>
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
var t = $("toast"); t.textContent = msg; t.style.display = "block";
|
var t = $("toast"); t.textContent = msg; t.style.display = "block";
|
||||||
clearTimeout(toast.t); toast.t = setTimeout(function () { t.style.display = "none"; }, ms || 4000);
|
clearTimeout(toast.t); toast.t = setTimeout(function () { t.style.display = "none"; }, ms || 4000);
|
||||||
}
|
}
|
||||||
// Every call: same origin, with the header the bridge requires on writes.
|
// Every call: same origin, with the header the forge service requires on writes.
|
||||||
function api(method, path, body) {
|
function api(method, path, body) {
|
||||||
var opt = { method: method, headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin", cache: "no-store" };
|
var opt = { method: method, headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin", cache: "no-store" };
|
||||||
if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); }
|
if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); }
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
<title>IPCR Forge</title>
|
<title>IPCR Forge</title>
|
||||||
<link rel="icon" href="icon.png">
|
<link rel="icon" href="icon.png">
|
||||||
<!--
|
<!--
|
||||||
Served by the forge's bridge (ipcr-forge-bridge, embedded in its binary). Every address is
|
Served by the forge service (ipcr-forge, embedded in its binary). Every address is
|
||||||
derived in the browser from this page's own hostname (ipcr-forge-<domain> → gitea-<domain>,
|
derived in the browser from this page's own hostname (ipcr-forge-<domain> → gitea-<domain>,
|
||||||
radicle-<domain>, radicle-api-<domain>), so the nip.io / sslip.io aliases work too.
|
radicle-<domain>, radicle-api-<domain>), so the nip.io / sslip.io aliases work too.
|
||||||
Live data: /images.json (IPCR's published.json), /repos.json (the bridge's Gitea → Radicle
|
Live data: /images.json (IPCR's published.json), /repos.json (the forge service's Gitea → Radicle
|
||||||
mirror state) and the Radicle API's node info.
|
mirror state) and the Radicle API's node info.
|
||||||
-->
|
-->
|
||||||
<style>
|
<style>
|
||||||
@@ -363,7 +363,7 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
|
|||||||
imagesBody.appendChild(el("p", "Nothing published yet. Tag v1.0.0 on the example repository (above), or push a v* tag from a repository with a workflow; its image appears here a few minutes later.", "muted"));
|
imagesBody.appendChild(el("p", "Nothing published yet. Tag v1.0.0 on the example repository (above), or push a v* tag from a repository with a workflow; its image appears here a few minutes later.", "muted"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Radicle mirrors: the bridge's state.
|
// Radicle mirrors: the forge service's state.
|
||||||
var reposBody = document.getElementById("repos-body");
|
var reposBody = document.getElementById("repos-body");
|
||||||
fetch("repos.json", { cache: "no-store" })
|
fetch("repos.json", { cache: "no-store" })
|
||||||
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
||||||
@@ -374,7 +374,7 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
|
|||||||
repos.forEach(function (r) {
|
repos.forEach(function (r) {
|
||||||
var tr = el("tr");
|
var tr = el("tr");
|
||||||
var td1 = el("td");
|
var td1 = el("td");
|
||||||
// Not r.html_url: the bridge talks to Gitea's internal address, which is what Gitea puts there.
|
// Not r.html_url: the forge service talks to Gitea's internal address, which is what Gitea puts there.
|
||||||
td1.appendChild(base ? link(url("gitea") + "/" + r.full_name, r.full_name) : el("span", r.full_name));
|
td1.appendChild(base ? link(url("gitea") + "/" + r.full_name, r.full_name) : el("span", r.full_name));
|
||||||
var td2 = el("td");
|
var td2 = el("td");
|
||||||
if (r.rid) {
|
if (r.rid) {
|
||||||
|
|||||||
Reference in New Issue
Block a user