Bring in the forge's history from the ipcr repository

bridge/ → forge/, apps/ → Apps/, .github/workflows/bridge.yml → image.yml;
history from the first IPCR Forge listing to the public front door (ipcr 097b010).
This commit is contained in:
Worph
2026-10-05 13:19:07 +00:00
55 changed files with 6985 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
name: bridge
# IPCR Forge's bridge image (bridge/): Gitea → Radicle mirror + the forge's page.
on:
push:
branches: [main]
tags: ["bridge-v*"]
paths: ["bridge/**", ".github/workflows/bridge.yml"]
pull_request:
paths: ["bridge/**", ".github/workflows/bridge.yml"]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/yundera/ipcr-forge-bridge
jobs:
vet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: bridge/go.mod
- run: test -z "$(gofmt -l .)" && go vet ./... && go test ./...
working-directory: bridge
image:
needs: vet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=match,pattern=bridge-v(.*),group=1
type=ref,event=branch
type=sha
# amd64 only: the Radicle CLI is fetched as its x86_64 release, and the Forge listing is
# amd64 only.
- uses: docker/build-push-action@v6
with:
context: bridge
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha,scope=bridge
cache-to: type=gha,mode=max,scope=bridge
+408
View File
@@ -0,0 +1,408 @@
name: gitea
services:
# Gitea itself — git hosting, the Actions controller, and the OCI package
# registry, all in one process. SQLite: a personal forge never has the write
# concurrency that would justify a second container for Postgres.
gitea:
image: gitea/gitea:1.27.3-rootless
container_name: gitea
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 70
deploy:
resources:
limits:
memory: 1G
environment:
TZ: $TZ
# Every URL Gitea hands out — clone URLs, the registry host, and the
# server_url the CI runner passes to jobs — is derived from ROOT_URL, so
# it has to be the address that works from outside as well as inside.
GITEA__server__ROOT_URL: https://gitea-${APP_DOMAIN}/
GITEA__server__DOMAIN: gitea-${APP_DOMAIN}
GITEA__server__HTTP_PORT: "3000"
GITEA__server__PROTOCOL: http
# The address Gitea's own CLI uses to reach the running server's internal
# API. It defaults to localhost, which is right for the server itself and
# useless from the init container that runs
# `gitea actions generate-runner-token`. It has to be set here rather than
# there: the CLI reads it out of the shared app.ini, and only this
# container's entrypoint renders environment into app.ini.
GITEA__server__LOCAL_ROOT_URL: http://gitea:3000/
# Git over SSH would need a published host port of its own. Everything
# here — clone, push, CI, registry — goes over HTTPS instead.
GITEA__server__DISABLE_SSH: "true"
GITEA__server__START_SSH_SERVER: "false"
# Skips the first-run install wizard: every value it would ask for is set
# here, and the admin account is created by an init step below.
GITEA__security__INSTALL_LOCK: "true"
GITEA__database__DB_TYPE: sqlite3
GITEA__database__PATH: /var/lib/gitea/data/gitea.db
GITEA__service__DISABLE_REGISTRATION: "true"
GITEA__service__REQUIRE_SIGNIN_VIEW: "true"
GITEA__actions__ENABLED: "true"
# Where a bare `uses: actions/checkout@v4` is resolved from.
GITEA__actions__DEFAULT_ACTIONS_URL: github
GITEA__packages__ENABLED: "true"
# Lets a pull mirror name a repository on this server's own Docker
# network — a Radicle node's git endpoint, for instance. See rationale.md.
GITEA__migrations__ALLOW_LOCALNETWORKS: "true"
# How often a pull mirror is re-fetched. The sync emits the same push
# event a real push would, so this interval is also how quickly CI
# reacts to a commit that arrives from the mirrored source.
GITEA__mirror__DEFAULT_INTERVAL: 10m
GITEA__mirror__MIN_INTERVAL: 10m
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/data:/var/lib/gitea
- ${DATA_ROOT:-/DATA}/AppData/$AppID/config:/etc/gitea
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/healthz"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
expose:
- 3000
labels:
caddy_0: gitea-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 3000}}"
caddy_1: gitea-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 3000}}"
caddy_2: gitea-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 3000}}"
networks:
- pcs
- gitea-internal
# The CI runner: act_runner with its own Docker daemon inside it (dind), so
# workflow jobs and the images they build never touch the host's daemon.
# See rationale.md for what `privileged` buys and what it costs.
gitea-runner:
image: gitea/act_runner:0.6.1-dind
container_name: gitea-runner
restart: unless-stopped
privileged: true
cpu_shares: 20
deploy:
resources:
limits:
memory: 2G
environment:
TZ: $TZ
# This address is not just how the runner daemon reaches Gitea — it is
# handed to every job as GITHUB_SERVER_URL, and a job runs inside the
# runner's *nested* daemon, on a network where `gitea` does not resolve.
# So it has to be an address that works from the outside: checkout and
# `docker login` from a workflow both go out and back over HTTPS.
GITEA_INSTANCE_URL: https://gitea-${APP_DOMAIN}
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token
GITEA_RUNNER_NAME: gitea-runner
# Registration-time labels. runner-config/config.yaml repeats them, and
# that copy wins on every start, so an installed runner follows store updates.
GITEA_RUNNER_LABELS: ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04,docker:docker://docker:28.5-cli
CONFIG_FILE: /config/config.yaml
# DOCKER_TLS_CERTDIR is deliberately left at the image's default. Emptying
# it does not turn the nested daemon's TCP listener off — it turns the
# listener's TLS off, and 2375 then answers unauthenticated to anything
# that can reach this container.
volumes:
# .runner (the registration this runner is identified by) and the
# registration token the init step leaves for it.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/runner:/data
- ${DATA_ROOT:-/DATA}/AppData/$AppID/runner-config:/config:ro
# Replaces the image's own start-up wait — see the comments in the
# script. It is declared under x-compose-app.files rather than left to
# the seed tree, because it has to arrive executable.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/runner-config/act_runner-run:/etc/s6/act_runner/run:ro
# The inner daemon's image store. Without this every restart re-pulls
# every base image a workflow uses.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/runner-docker:/var/lib/docker
networks:
- gitea-internal
networks:
pcs:
name: ${APP_NET:-pcs}
external: true
# The runner has no business being reachable from other apps — it only ever
# talks to gitea and to the internet.
gitea-internal:
driver: bridge
x-casaos:
architectures:
- amd64
main: gitea
developer: Gitea
category: Developer
tags: ["developer", "git", "ci"]
description:
en_us: |
Gitea — a git forge with CI and a container registry
Gitea is a lightweight, self-contained software forge: repositories, issues, pull requests, releases, a CI engine and an OCI container registry in a single service that is happy on a small machine.
What it gives you here:
• Git hosting over HTTPS, with issues, pull requests, releases and wikis
• Actions — CI workflows in GitHub Actions syntax, so existing workflow files usually run unchanged
• A built-in container registry, so a workflow can build an image and push it next to the code it came from
• Pull mirrors — point a repository at any git URL and it is re-fetched on a schedule, and each sync starts the same workflows a push would
That last pair is what makes it useful in front of a git source that has no webhooks of its own: mirror the repository, and the mirror's own updates drive the build.
A runner ships with the app, with its own Docker daemon inside it, so workflows can build container images out of the box.
fr_fr: |
Gitea — une forge git avec CI et registre de conteneurs
Gitea est une forge logicielle légère et autonome : dépôts, tickets, pull requests, versions, moteur d'intégration continue et registre de conteneurs OCI dans un seul service, à l'aise sur une petite machine.
Ce que vous obtenez ici :
• Hébergement git en HTTPS, avec tickets, pull requests, versions et wikis
• Actions — des workflows d'intégration continue en syntaxe GitHub Actions, si bien qu'un fichier de workflow existant tourne généralement tel quel
• Un registre de conteneurs intégré : un workflow peut construire une image et la publier à côté du code dont elle provient
• Miroirs — pointez un dépôt vers n'importe quelle URL git, il est re-synchronisé périodiquement, et chaque synchronisation déclenche les mêmes workflows qu'un push
Ces deux derniers points sont ce qui rend Gitea utile devant une source git dépourvue de webhooks : mettez le dépôt en miroir, et ce sont les mises à jour du miroir qui pilotent la construction.
Un runner est fourni avec l'application, avec son propre démon Docker embarqué, afin que les workflows puissent construire des images de conteneurs dès l'installation.
zh_cn: |
Gitea — 带 CI 与容器仓库的 Git 代码托管
Gitea 是轻量、自足的软件协作平台:仓库、议题、合并请求、发布、CI 引擎与 OCI 容器仓库集于一个服务,在小型机器上也能流畅运行。
它在这里提供:
• 通过 HTTPS 托管 Git,附带议题、合并请求、发布与百科
• Actions —— 采用 GitHub Actions 语法的 CI 工作流,现有的工作流文件通常无需修改即可运行
• 内置容器仓库,工作流可以构建镜像并发布在源代码旁边
• 拉取镜像同步 —— 将仓库指向任意 Git URL,它会按计划重新拉取,每次同步都会触发与推送相同的工作流
后两项让 Gitea 特别适合放在没有 webhook 的 Git 源前面:把仓库设为镜像,镜像的更新就会驱动构建。
应用自带一个运行器,内含独立的 Docker 守护进程,因此工作流开箱即可构建容器镜像。
icon: icon.png
screenshot_link:
- screenshot-1.png
- screenshot-2.png
- screenshot-3.png
thumbnail: thumbnail.png
tagline:
en_us: A git forge with CI and a container registry, on your own domain.
fr_fr: Une forge git avec intégration continue et registre de conteneurs, sur votre domaine.
zh_cn: 运行在你自己域名下的 Git 托管,内建 CI 与容器仓库。
tips:
before_install:
en_us: |
Signing in
| | |
| --- | --- |
| Address | `https://gitea-$APP_DOMAIN` |
| User | `gitea_admin` |
| Password | your server's default app password |
Gitea uses its own account system. Self-registration is off, so
`gitea_admin` is the only account until you create more under
Site Administration.
Building from a repository that has no webhooks
New Migration → check **This repository will be a mirror**, and give it
the git URL. Gitea re-fetches on a schedule, and each sync raises the
same `push` event a real push would — so a workflow with `on: push`
runs. Commit the workflow to `.gitea/workflows/` in the mirrored
repository itself (`.github/workflows/` also works; `.forgejo/` does
not).
One thing to change by hand: a migrated repository arrives with
**Actions switched off**. Repository → Settings → Advanced Settings →
tick **Actions**, or nothing will ever run, however correct the
workflow is.
Publishing images
The registry lives at the same address as the web UI. In a workflow,
log in to `gitea-$APP_DOMAIN` and push to
`gitea-$APP_DOMAIN/<user>/<image>:<tag>`. Use an access token with the
`write:package` scope, kept as a repository secret — the token Actions
injects as `secrets.GITHUB_TOKEN` is rejected by the registry.
The CI runner
The runner holds a Docker daemon of its own and therefore runs
privileged. It is what lets a workflow build container images. If you
do not intend to run CI, stop the `gitea-runner` container — Gitea
itself is unaffected.
fr_fr: |
Connexion
| | |
| --- | --- |
| Adresse | `https://gitea-$APP_DOMAIN` |
| Utilisateur | `gitea_admin` |
| Mot de passe | le mot de passe par défaut de vos applications |
Gitea gère ses propres comptes. L'auto-inscription est désactivée :
`gitea_admin` est donc le seul compte tant que vous n'en créez pas
d'autres depuis l'administration du site.
Construire depuis un dépôt sans webhooks
Nouvelle migration → cochez **Ce dépôt sera un miroir** et indiquez
l'URL git. Gitea re-synchronise périodiquement, et chaque
synchronisation lève l'événement `push` qu'un vrai push aurait levé —
un workflow `on: push` se déclenche donc. Le fichier de workflow se
place dans `.gitea/workflows/` du dépôt mis en miroir
(`.github/workflows/` fonctionne aussi ; `.forgejo/` non).
Une chose à changer à la main : un dépôt migré arrive avec **Actions
désactivé**. Dépôt → Paramètres → Paramètres avancés → cochez
**Actions**, sinon rien ne se déclenchera, si correct que soit le
workflow.
Publier des images
Le registre est à la même adresse que l'interface web. Dans un
workflow, connectez-vous à `gitea-$APP_DOMAIN` puis publiez vers
`gitea-$APP_DOMAIN/<utilisateur>/<image>:<tag>`. Utilisez un jeton
d'accès de portée `write:package`, conservé en secret de dépôt : le
jeton fourni par Actions (`secrets.GITHUB_TOKEN`) est refusé par le
registre.
Le runner d'intégration continue
Le runner embarque son propre démon Docker et tourne donc en mode
privilégié. C'est ce qui permet à un workflow de construire des images
de conteneurs. Si vous ne comptez pas utiliser l'intégration continue,
arrêtez le conteneur `gitea-runner` — Gitea lui-même n'en dépend pas.
zh_cn: |
登录
| | |
| --- | --- |
| 地址 | `https://gitea-$APP_DOMAIN` |
| 用户 | `gitea_admin` |
| 密码 | 服务器的默认应用密码 |
Gitea 使用自己的账户体系。自助注册已关闭,因此在你于站点管理中创建更多账户之前,
`gitea_admin` 是唯一的账户。
为没有 webhook 的仓库构建
新建迁移 → 勾选**将此仓库设为镜像**并填入 git URL。Gitea 会按计划重新拉取,
每次同步都会触发与真实推送相同的 `push` 事件,因此 `on: push` 工作流会运行。
工作流文件请提交到被镜像仓库自身的 `.gitea/workflows/` 目录
(`.github/workflows/` 同样有效,`.forgejo/` 无效)。
有一处需要手动调整:迁移而来的仓库默认**关闭 Actions**。
进入 仓库 → 设置 → 高级设置 → 勾选 **Actions**,否则工作流再正确也不会运行。
发布镜像
容器仓库与网页界面位于同一地址。在工作流中登录 `gitea-$APP_DOMAIN`,
并推送到 `gitea-$APP_DOMAIN/<用户>/<镜像>:<标签>`。请使用具有
`write:package` 权限的访问令牌并存为仓库机密 —— Actions 注入的
`secrets.GITHUB_TOKEN` 会被容器仓库拒绝。
CI 运行器
运行器内含独立的 Docker 守护进程,因此以特权模式运行,这正是工作流能够构建
容器镜像的原因。若不打算使用 CI,可停止 `gitea-runner` 容器 —— 这不影响 Gitea 本身。
title:
en_us: Gitea
index: /
webui_port: 3000
store_app_id: gitea
x-compose-app:
schema_version: 2
folders:
- path: /DATA/AppData/$AppID/data
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/config
user: $PUID
group: $PGID
# Holds .runner (the runner's registration) and the token file the
# init step writes for it.
- path: /DATA/AppData/$AppID/runner
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/runner-config
user: $PUID
group: $PGID
# The inner Docker daemon's image store. dockerd runs as root inside the
# runner and takes ownership of what it creates below this point.
- path: /DATA/AppData/$AppID/runner-docker
user: $PUID
group: $PGID
mode: "0710"
files:
# Declared rather than left to the seed tree so the executable bit is
# stated, not inherited: git on a Windows-hosted checkout records every
# file 100644, and s6 refuses to spawn a `run` script it cannot execute —
# the runner then loops "unable to spawn ./run: Permission denied" and no
# workflow ever runs.
- path: /DATA/AppData/$AppID/runner-config/act_runner-run
from: runner-config/act_runner-run
ensure: always
mode: "0755"
init:
# Both steps are the gitea binary acting on its own installation, and both
# have to wait for the server: post_up fires as soon as `compose up`
# returns, which is when the container has *started*, not when Gitea has
# migrated its schema. Run them any sooner and `user create` dies on
# "no such table: user".
#
# The wait is bounded by `timeout` rather than left open: a step that
# blocks forever would hold the install open forever.
- name: create-admin
image: gitea/gitea:1.27.3-rootless
user: $PUID:$PGID
phase: post_up
volumes:
- /DATA/AppData/$AppID/data:/var/lib/gitea
- /DATA/AppData/$AppID/config:/etc/gitea
command:
- sh
- -c
- |
timeout 240 sh -c 'until gitea admin user list >/dev/null 2>&1; do sleep 3; done'
gitea admin user create --admin --username gitea_admin \
--password "$APP_DEFAULT_PASSWORD" --email "$APP_EMAIL" \
--must-change-password=false \
|| echo "admin account already present, leaving it alone"
# Re-run rather than remembered: the command is idempotent, and an account
# that was deleted by accident comes back on the next start.
when: always
- name: runner-token
image: gitea/gitea:1.27.3-rootless
user: $PUID:$PGID
phase: post_up
# Unlike the account, a runner token is not a database write — it is a
# call to the running server's internal API, so this one needs to be able
# to reach it.
network: $APP_NET
volumes:
- /DATA/AppData/$AppID/data:/var/lib/gitea
- /DATA/AppData/$AppID/config:/etc/gitea
- /DATA/AppData/$AppID/runner:/runner
command:
- sh
- -c
- |
timeout 240 sh -c 'until gitea actions generate-runner-token > /runner/token.new 2>/dev/null; do sleep 3; done'
mv /runner/token.new /runner/token
chmod 600 /runner/token
# Only while the runner has no registration of its own. Once it has one
# the token is dead weight, and re-minting it on every start would leave
# a live credential lying in the app folder for no reason.
when: absent:/DATA/AppData/$AppID/runner/.runner
webui-host: gitea-${domain}
webui-path: /
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+86
View File
@@ -0,0 +1,86 @@
# Gitea — Rationale
## What deviation / exception is being requested
1. `gitea-runner` runs with `privileged: true`.
2. Gitea is configured with `[migrations] ALLOW_LOCALNETWORKS = true`.
3. The admin account is called `gitea_admin`, not the deployment's usual `admin`.
## Why it is necessary
**1. The privileged runner.** The point of shipping a runner at all is that a
workflow can build a container image. That needs a Docker daemon, and there are
only two ways to give a CI job one:
- mount the host's `/var/run/docker.sock` into the runner, which hands every
workflow — including one that arrives from a mirrored third-party repository —
full control of the host's daemon, i.e. root on the PCS; or
- give the runner a daemon of its own inside the container (`dind`), which
requires `privileged`.
The second is the smaller exposure: the blast radius is the runner's own
container and its own image store, not the host's containers, volumes and
socket. It is also what upstream ships the `-dind` tag for.
The rootless dind variant (`0.6.1-dind-rootless`) would have avoided
`privileged` altogether, and was tried first. It does not start on this
platform: Ubuntu sets `kernel.apparmor_restrict_unprivileged_userns=1`, and
rootlesskit dies with `fork/exec /proc/self/exe: operation not permitted`.
Making it work means turning that protection off for the container
(`security_opt: apparmor=unconfined`), which weakens the host more than
`privileged` on a single container does. Revisit if the platform ever ships
that sysctl relaxed, or if upstream's runner learns to use the host's
user-namespace remapping.
**2. `ALLOW_LOCALNETWORKS`.** Gitea refuses by default to migrate or mirror
from an address on a private network. The app's most useful configuration —
mirroring a repository from another app on the same server, e.g. a Radicle
node's git endpoint at `http://radicle-api:8080/<rid>.git` — is exactly that
case. Without the flag the URL is rejected before any fetch is attempted.
**3. The admin username.** `admin` is on Gitea's reserved-name list (it
collides with the `/admin` route) and account creation fails with it.
## Security mitigations in place
- The runner is not on the shared `pcs` network. It sits on an app-internal
bridge with Gitea and nothing else, so no other app can reach it, and it
publishes no port.
- The runner's daemon is nested: its image store is a directory under the
app's own AppData, and the containers it creates are invisible to, and
cannot see, the host's daemon.
- `capacity: 1` in the runner config and a 2G memory limit bound what a
runaway build can consume.
- Self-registration is disabled (`DISABLE_REGISTRATION`) and anonymous
browsing is off (`REQUIRE_SIGNIN_VIEW`), so the forge starts closed with a
single account whose password is the deployment's default app password.
- `ALLOW_LOCALNETWORKS` is reachable only by an authenticated user of this
Gitea — with registration disabled, that is the owner. It widens what the
owner can point a mirror at; it does not expose anything to an anonymous
caller.
- The runner is severable: stopping the `gitea-runner` container leaves a
fully working forge and removes the privileged container from the box. This
is called out in `tips.before_install`.
## Alternatives considered and rejected
| Alternative | Why not |
|---|---|
| Host Docker socket in the runner | Any workflow, including one from a mirrored repo, gets root on the host. Strictly worse than a privileged container. |
| `0.6.1-dind-rootless` | Does not start under Ubuntu's unprivileged-userns restriction (see above); the workaround disables AppArmor for the container. |
| Ship no runner | Gitea Actions is the reason to choose Gitea over a plain git server here, and a runner cannot be added later without the same `privileged` decision. |
| Mirror over the public HTTPS address instead of `ALLOW_LOCALNETWORKS` | Works, but routes a same-box fetch out to the gateway and back, and breaks if the deployment has no public address. |
## Data protection
Everything that survives a restart is under `/DATA/AppData/gitea/`:
repositories and the SQLite database in `data/`, the rendered `app.ini` in
`config/`, the runner's registration in `runner/`, and the nested daemon's
image store in `runner-docker/`. Nothing is written outside that tree, and no
user directory (`/DATA/Documents`, `/DATA/Media`, …) is mounted into any
container.
The only credential the app generates is the runner registration token, which
is rewritten on every start and is worthless without network access to Gitea.
The admin password is the deployment's own `APP_DEFAULT_PASSWORD`; it is never
written to disk by this app beyond Gitea's own salted hash.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Replaces the image's own /etc/s6/act_runner/run. Two waits, both of which the
# stock script gets wrong for this deployment.
#
# 1. The nested Docker daemon. Upstream waits for it through s6's readiness
# notification. On a cold start with a warm image store dockerd takes longer
# than s6-notifyoncheck's window, the notification never arrives, s6 marks
# the service permanently failed, and act_runner sits blocked in
# `s6-svwait -U` for two to four minutes before s6 gives up and lets it past
# anyway. The runner shows "offline" in the forge for that whole time and
# picks up no jobs. Asking the daemon directly is the same wait without the
# guesswork.
#
# 2. The registration token. It is minted by an init step that runs after the
# stack is up, so on a first install it does not exist yet when this
# container starts — and the stock entrypoint reads the token file exactly
# once, before its retry loop, so a token that arrives late is never seen.
while [ ! -d /etc/s6/docker/supervise ]; do
sleep 0.1
done
while ! docker info >/dev/null 2>&1; do
sleep 1
done
while [ ! -s "${GITEA_RUNNER_REGISTRATION_TOKEN_FILE:-/data/token}" ] && [ ! -s /data/.runner ]; do
echo "waiting for the runner registration token"
sleep 2
done
exec run.sh
+40
View File
@@ -0,0 +1,40 @@
# act_runner configuration. Only the settings that differ from upstream's
# defaults are written out — `act_runner generate-config` prints the full set.
log:
level: info
runner:
# The file the runner's own registration is kept in, relative to /data.
file: .runner
# One job at a time. A PCS shares its CPU with everything else installed on
# it, and an image build is the least polite thing on the box.
capacity: 1
timeout: 1h
fetch_timeout: 5s
fetch_interval: 2s
# Overrides the labels stored at registration (.runner). These are upstream
# act_runner defaults: Gitea's own job images, which carry node, bash, git
# and the docker CLI with buildx, so marketplace actions (actions/checkout,
# docker/build-push-action, ...) run as they do on GitHub. The registration
# labels pointed at node:22-bookworm, which has no docker CLI.
labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
- "docker:docker://docker:28.5-cli"
cache:
# The cache server binds an address inside the runner container, which the
# job containers — created by the Docker daemon nested inside that same
# container — cannot route to. Left on, every `actions/cache` step fails
# noisily instead of being skipped.
enabled: false
container:
# Jobs run on the runner's own nested daemon, on its default bridge: they
# reach the internet, and they reach Gitea the way anything outside would,
# through its public address.
network: bridge
privileged: false
force_pull: false
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+989
View File
@@ -0,0 +1,989 @@
name: ipcr-forge
# IPCR Forge — a forge whose images are served from IPFS and whose code is mirrored to Radicle:
#
# git push origin main v1.2.3 (developer, plain git)
# → gitea the source of truth: repositories, PRs, issues, users
# → forge-runner (act_runner) Gitea Actions: .gitea/workflows/ for the tag
# → forge-registry staging: localhost:5000/<owner>/<repo>:1.2.3 (internal only)
# → ipcr-gateway follows that registry imports every tag into IPFS, publishes IPNS
# → ipcr-kubo / ipcr-registry
# 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
# 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
# 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,
# 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}/.
#
# 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).
#
# The handoff from CI to IPFS is a pull, run by IPCR: jobs run in the forge's own Docker daemon,
# which cannot reach ipcr.localhost (the *host's* loopback). So a workflow pushes to the staging
# registry, which shares that daemon's network namespace and is therefore `localhost:5000` to
# every job, and ipcr-gateway polls it over ci-internal.
services:
# ═══ Bridge — the forge's page, and Gitea → Radicle ════════════════════════════
# ipcr-forge-bridge (bridge/ in the IPCR repository). Receives Gitea's system webhook on :8081
# (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.
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
ipcr-forge:
image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0
container_name: ipcr-forge
user: $PUID:$PGID
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["ipcr-forge-bridge", "serve"]
cpu_shares: 30
deploy:
resources:
limits:
memory: 256M
environment:
TZ: $TZ
GITEA_URL: http://gitea:3000
GITEA_OWNER: gitea_admin
SECRETS_DIR: /secrets
STATE_DIR: /bridge
RAD_HOME: /radicle-home
IPCR_PUBLISHED: /srv/ipcr-state/published.json
RECONCILE: 10m
# Admin pages (/admin): Gitea site admins only, signed in through Gitea (an OAuth2 app the
# forge-setup step registers, one redirect URI per host below). They manage IPCR through its
# admin API (on ipcr-admin) and back up the publisher key, read from Kubo's keystore.
PUBLIC_HOSTS: ipcr-forge-${APP_DOMAIN},ipcr-forge-${APP_PUBLIC_IP_DASH}.nip.io,ipcr-forge-${APP_PUBLIC_IP_DASH}.sslip.io
IPCR_ADMIN: http://ipcr-gateway:4769
IPCR_ADMIN_TOKEN_FILE: /srv/ipcr-state/admin-token
KUBO_KEYSTORE: /srv/ipcr-keystore
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
# 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
volumes:
# The node's home: its storage (where repositories are created and pushed), its control
# socket (announces) and its key, which signs everything it publishes. The same mount
# radicle-api has.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
# repos.json (Gitea repository → Radicle ID) and one bare mirror per repository.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge
# The Gitea token and the webhook secret the install step writes. Read-only here.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro
# published.json → /images.json, and the admin API's token. Read-only.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro
# Kubo's keystore, read-only: the publisher key, for encrypted backups (Kubo exports keys
# from its CLI only, and ipcrd, root without capabilities, cannot read a $PUID 0700 folder).
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/kubo/keystore:/srv/ipcr-keystore:ro
depends_on:
- gitea
- radicle-node
healthcheck:
test: ["CMD", "ipcr-forge-bridge", "health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
expose:
- 8080
- 8081
labels:
caddy_0: ipcr-forge-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 8080}}"
caddy_1: ipcr-forge-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 8080}}"
caddy_2: ipcr-forge-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# To IPCR's admin API only.
- ipcr-admin
# ═══ Gitea — the source of truth ═══════════════════════════════════════════════
# From Apps/Gitea; its data folder moves to $AppID/gitea/.
# Gitea itself — git hosting, the Actions controller, and the OCI package
# registry, all in one process. SQLite: a personal forge never has the write
# concurrency that would justify a second container for Postgres.
gitea:
image: gitea/gitea:1.27.3-rootless
container_name: gitea
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 70
deploy:
resources:
limits:
memory: 1G
environment:
TZ: $TZ
# Every URL Gitea hands out — clone URLs, the registry host, and the
# server_url the CI runner passes to jobs — is derived from ROOT_URL, so
# it has to be the address that works from outside as well as inside.
GITEA__server__ROOT_URL: https://gitea-${APP_DOMAIN}/
GITEA__server__DOMAIN: gitea-${APP_DOMAIN}
GITEA__server__HTTP_PORT: "3000"
GITEA__server__PROTOCOL: http
# The address Gitea's own CLI uses to reach the running server's internal
# API. It defaults to localhost, which is right for the server itself and
# useless from the init container that runs
# `gitea actions generate-runner-token`. It has to be set here rather than
# there: the CLI reads it out of the shared app.ini, and only this
# container's entrypoint renders environment into app.ini.
GITEA__server__LOCAL_ROOT_URL: http://gitea:3000/
# Git over SSH would need a published host port of its own. Everything
# here — clone, push, CI, registry — goes over HTTPS instead.
GITEA__server__DISABLE_SSH: "true"
GITEA__server__START_SSH_SERVER: "false"
# Skips the first-run install wizard: every value it would ask for is set
# here, and the admin account is created by an init step below.
GITEA__security__INSTALL_LOCK: "true"
GITEA__database__DB_TYPE: sqlite3
GITEA__database__PATH: /var/lib/gitea/data/gitea.db
GITEA__service__DISABLE_REGISTRATION: "true"
GITEA__service__REQUIRE_SIGNIN_VIEW: "true"
GITEA__actions__ENABLED: "true"
# Where a bare `uses: actions/checkout@v4` is resolved from.
GITEA__actions__DEFAULT_ACTIONS_URL: github
# 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.
GITEA__packages__ENABLED: "false"
# Forge: webhooks may reach the bridge (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
# container on the shared network.
GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/data:/var/lib/gitea
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/config:/etc/gitea
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/healthz"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
expose:
- 3000
labels:
caddy_0: gitea-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 3000}}"
caddy_1: gitea-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 3000}}"
caddy_2: gitea-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 3000}}"
networks:
- pcs
# ═══ CI — Gitea Actions ════════════════════════════════════════════════════════
# The runner: act_runner (plain, not its -dind variant) driving the separate daemon below over a
# unix socket. Runs unprivileged; whoever controls it controls that daemon, which is the point.
# Its config (seed/gitea/runner-config/config.yaml) puts jobs on the daemon's host network, where
# the staging registry is localhost:5000.
forge-runner:
image: gitea/act_runner:0.6.1
container_name: forge-runner
user: $PUID:$PGID
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
# The stock run.sh reads the registration token once and gives up after 10 tries; the token is
# minted by an install step that runs after the stack is up. So wait for it (or for an
# existing registration) first.
entrypoint:
- /sbin/tini
- --
- sh
- -c
- |
until [ -s /data/token ] || [ -s /data/.runner ]; do echo "waiting for the runner registration token"; sleep 2; done
until [ -S /run/dind/docker.sock ]; do sleep 1; done
exec run.sh
cpu_shares: 20
deploy:
resources:
limits:
# act_runner holds a job's checkout and actions in memory while it copies them into the
# job container: 256M was OOM-killed mid-job on the first build (holyhorse, 2026-10-04).
memory: 1G
environment:
TZ: $TZ
HOME: /data
DOCKER_HOST: unix:///run/dind/docker.sock
# Handed to every job as GITHUB_SERVER_URL, and jobs run on the daemon's network where
# `gitea` does not resolve: checkout goes out and back over HTTPS.
GITEA_INSTANCE_URL: https://gitea-${APP_DOMAIN}
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token
GITEA_RUNNER_NAME: forge-runner
# Registration-time labels; runner-config/config.yaml repeats them and wins on every start.
GITEA_RUNNER_LABELS: ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04
CONFIG_FILE: /config/config.yaml
volumes:
# .runner (this runner's registration) and the registration token the init step leaves.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner:/data
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-config:/config:ro
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
depends_on:
- gitea
- forge-docker
networks:
# Egress: it polls Gitea through its public address, like the jobs.
- ci-internal
# The daemon jobs run in: Docker-in-Docker, so jobs and the images they build never touch the
# host's daemon. See rationale.md for what `privileged` buys and what it costs. No TCP listener:
# its only API is a unix socket in a folder shared with forge-runner, owned by $PGID.
forge-docker:
image: docker:29.1-dind
container_name: forge-docker
privileged: true
restart: unless-stopped
cpu_shares: 20
command: ["--host=unix:///run/dind/docker.sock", "--group=$PGID"]
deploy:
resources:
limits:
memory: 2G
environment:
TZ: $TZ
# No certificates: with an explicit --host the entrypoint adds no TCP listener to protect.
DOCKER_TLS_CERTDIR: ""
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
# The daemon's image store. Without it every restart re-pulls every job image.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/docker:/var/lib/docker
healthcheck:
test: ["CMD", "docker", "-H", "unix:///run/dind/docker.sock", "info"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- ci-internal
# 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.4.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
user: $PUID:$PGID
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
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"]
cpu_shares: 20
deploy:
resources:
limits:
memory: 128M
environment:
TZ: $TZ
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
- ${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
# 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.
# It is a pure client-side app — every request it makes goes from the visitor's
# browser straight to the API below, which is why that one needs its own domain.
radicle:
image: dirk1980/radicle-explorer-container@sha256:0e31485c38a765e83a10b1a4bf71b12d4f9fec630881d171bba736a4a56a16ce
container_name: radicle
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 80
deploy:
resources:
limits:
memory: 128M
environment:
TZ: $TZ
# Caddy writes its autosaved config and its (unused here) certificate store
# under these two paths. The image points them at /config and /data, which are
# root-owned, so as $PUID the process cannot create them and refuses to start.
XDG_CONFIG_HOME: /tmp/caddy-config
XDG_DATA_HOME: /tmp/caddy-data
volumes:
# Replaces the image's own :80 Caddyfile — see the comments in that file.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
expose:
- 8080
labels:
caddy_0: radicle-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 8080}}"
caddy_1: radicle-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 8080}}"
caddy_2: radicle-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# radicle-httpd — the node's read-only JSON API, on its own domain.
#
# This domain is deliberately NOT behind the SSO gate: the explorer above is a
# static bundle running in the visitor's browser, so it is the browser that calls
# this API, cross-origin. The daemon serves public repositories only (private ones
# are filtered out upstream) and has no write endpoints at all. See rationale.md.
radicle-api:
image: dirk1980/radicle-httpd@sha256:5e95be30e4bde55b509a65fd56f8b825cafd0f5cd752c1a13e79aad75b958895
container_name: radicle-api
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 70
deploy:
resources:
limits:
memory: 512M
command: ["--listen", "0.0.0.0:8080"]
environment:
TZ: $TZ
volumes:
# Same Radicle home as the node: the daemon reads the repository storage
# directly and talks to the node over the control socket inside it.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
healthcheck:
# curl, not wget: this image carries curl and no busybox.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/api/v1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
expose:
- 8080
labels:
caddy_0: radicle-api-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 8080}}"
caddy_1: radicle-api-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 8080}}"
caddy_2: radicle-api-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# The node itself: speaks the Radicle peer-to-peer protocol (gossip, fetch,
# announce) on TCP 8776. No HTTP, so no Caddy route and no place on the shared
# network — it only needs egress, which the app-internal bridge provides.
radicle-node:
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
container_name: radicle-node
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 50
deploy:
resources:
limits:
memory: 1G
environment:
TZ: $TZ
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
ports:
# Radicle peer-to-peer protocol. Other nodes dial in here to fetch the
# repositories this node seeds; without it the node still syncs outbound,
# but nobody can fetch from it. Hence the needs-public-ip tag.
- "8776:8776"
healthcheck:
# `rad node status` round-trips the node's control socket, which is the only
# liveness signal this container has — but it also renders the peer table, and
# that takes 3-8s on an idle node and longer while the node is syncing. Hence
# the unusually generous timeout: a 10s one fails during the first sync and
# flaps the container between healthy and unhealthy.
test: ["CMD", "rad", "node", "status"]
interval: 60s
timeout: 30s
retries: 3
start_period: 90s
networks:
- radicle-internal
# ═══ IPCR — the IPFS container registry ════════════════════════════════════════
# Verbatim from Apps/IPCR; only its data folder moves to $AppID/ipcr/.
# ── The registry Docker talks to ────────────────────────────────────────────
# 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.5.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.
user: "0:0"
# Root needs no capabilities to write root-owned files it owns; drop them all.
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["gateway"]
environment:
TZ: $TZ
TLS_DIR: /data/tls
# Docker trusts certs.d/<host:port>/ca.crt and re-reads it on every pull.
CA_EXPORT: /certs.d/ipcr.localhost:4767/ca.crt
# Pin every image this host pulls, so the node keeps — and re-shares — what it runs.
AUTO_PIN: "true"
# Optional: follow a registry. Every tag of every repository it lists (or of
# IPCR_IMPORT_REPOS) is copied into IPFS and published as /ipns/<owner-repo>:<tag>;
# a tag is re-imported when it moves. Empty = off. IPCR_IMPORT_AUTH is user:token,
# a read-only (read:package) token is enough.
# 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
# ENS name can point at: ipcr.localhost:4767/ipns/example.eth/<repo>:<tag>.
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
# Forge: publish only the repositories the bridge allows (public Gitea repositories, minus
# those switched off in the admin pages). Until the bridge sends its list, nothing is.
IMPORT_ALLOW_REQUIRED: "true"
# Forge: delete each tag from the staging registry once published (its blobs go at the
# registry's next start, see forge-registry).
IMPORT_CLEANUP: "true"
# IPNS records valid 7 days instead of Kubo's 48 h, so a forge offline for a weekend keeps
# its name; resolvers cache them 5 minutes, so a new tag shows up quickly.
IPNS_LIFETIME: ${IPCR_IPNS_LIFETIME:-168h}
IPNS_TTL: 5m
# Forge: the admin API, for the bridge, on ipcr-admin. Bearer token in state/admin-token.
ADMIN_LISTEN: ":4769"
IMPORT_INTERVAL: 60s
STATE_DIR: /data/state
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/tls:/data/tls
# What the watcher already imported (tag → manifest digest).
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/data/state
# The host's Docker trust store, so the CA above can be dropped into it: no daemon
# reload, no daemon.json edit. The parent is mounted because Compose cannot create a
# bind source whose name contains a colon (ipcr.localhost:4767). ipcrd writes exactly
# one file here. See rationale.md.
- /etc/docker/certs.d:/certs.d
ports:
# Loopback only: the client is the host's Docker daemon, which resolves ipcr.localhost
# to 127.0.0.1 (RFC 6761). Not reachable from the network, and not a web UI.
- "127.0.0.1:4767:4767"
depends_on:
- ipcr-registry
healthcheck:
test: ["CMD", "ipcrd", "health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- ipcr-internal
# Forge only: reaches the staging registry for the import poller above.
- ci-internal
# Forge only: the admin API, reached by the bridge.
- ipcr-admin
cpu_shares: 50
deploy:
resources:
limits:
memory: 128M
# ── Public front door ───────────────────────────────────────────────────────
# Forge only: the same ipcrd, a second process, serving the registry API to anyone at
# ipcr-<domain>, read-only, for this forge's own images only (gateway/public.go): its publisher
# name, its verified ENS/DNS name, its published image roots. Everything else is 404, so nobody
# can make this node fetch other IPFS content. `docker pull ipcr-<domain>/ipns/<name>/<repo>:<tag>`
# then works on any machine, without IPCR. A pull here pins nothing. The admin pages switch it
# off (config.json "public": false). Not on the admin or CI networks. See rationale.md.
ipcr-public:
image: ghcr.io/yundera/ipcr:1.5.0
container_name: ipcr-public
user: $PUID:$PGID
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["gateway"]
environment:
TZ: $TZ
# Plain HTTP: Caddy terminates TLS (no TLS_DIR, no CA to export).
LISTEN: ":4770"
AUTO_PIN: "false"
PUBLIC_POLICY: /data/state
volumes:
# published.json and config.json, written by ipcr-gateway. Read-only.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/data/state:ro
depends_on:
- ipcr-registry
healthcheck:
test: ["CMD", "ipcrd", "health", "http://127.0.0.1:4770/v2/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
expose:
- 4770
labels:
caddy_0: ipcr-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 4770}}"
caddy_1: ipcr-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 4770}}"
caddy_2: ipcr-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 4770}}"
networks:
- pcs
- ipcr-internal
cpu_shares: 30
deploy:
resources:
limits:
memory: 256M
# ── Stock nerdctl IPFS registry ─────────────────────────────────────────────
# `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads.
ipcr-registry:
image: ghcr.io/yundera/ipcr:1.5.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
# no volumes and no host access; capabilities are all dropped. See rationale.md.
user: "0:0"
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["registry"]
environment:
TZ: $TZ
expose:
- 5050
depends_on:
- ipcr-kubo
healthcheck:
test: ["CMD", "ipcrd", "health", "http://127.0.0.1:5050/v2/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- ipcr-internal
cpu_shares: 50
deploy:
resources:
limits:
memory: 256M
# ── Kubo, the IPFS node ─────────────────────────────────────────────────────
ipcr-kubo:
image: ipfs/kubo:v0.43.1
container_name: ipcr-kubo
user: $PUID:$PGID
restart: unless-stopped
# --enable-gc: blocks nobody pinned are collected; pinned images are never touched.
command: ["daemon", "--migrate=true", "--agent-version-suffix=docker", "--enable-gc"]
environment:
TZ: $TZ
# Applied at `ipfs init` only: DHT client mode, small connection manager. Tuned for an
# always-on small box (see the Kubo store app for measurements).
IPFS_PROFILE: lowpower
GOMEMLIMIT: 768MiB
# Read by seed/init.d/01-ipcr-node.sh on every start.
IPCR_SWARM_PORT: "4768"
IPFS_STORAGE_MAX: 20GB
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/kubo/:/data/ipfs
# Kubo's entrypoint runs every *.sh here before starting the daemon.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/init.d/:/container-init.d:ro
expose:
# RPC API: no auth, so it never leaves the app's own network.
- 5001
ports:
# libp2p swarm — the peer protocol, not HTTP, so Caddy cannot carry it. Without it the
# node still pulls, but nobody can fetch the images this host pins. 4001 is left to the
# Kubo store app so both can be installed side by side.
- "4768:4768/tcp"
- "4768:4768/udp"
healthcheck:
test: ["CMD-SHELL", "ipfs --api=/ip4/127.0.0.1/tcp/5001 id > /dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- ipcr-internal
cpu_shares: 50
deploy:
resources:
limits:
memory: 1024M
networks:
pcs:
name: ${APP_NET:-pcs}
external: true
# From Apps/Radicle: egress only, for the node.
radicle-internal:
driver: bridge
# CI: the runner ↔ its daemon (and staging registry) ↔ ipcr-gateway; egress for builds.
ci-internal:
driver: bridge
# From Apps/IPCR: gateway ↔ registry ↔ kubo, nothing else.
ipcr-internal:
driver: bridge
# Bridge → IPCR's admin API, nothing else; no egress.
ipcr-admin:
driver: bridge
internal: true
x-casaos:
architectures:
- amd64
main: gitea
author: Yundera
developer: Yundera
category: Developer
tags: ["developer", "git", "ci", "needs-public-ip"]
icon: icon.png
screenshot_link:
- screenshot-1.png
- screenshot-2.png
- screenshot-3.png
title:
en_us: IPCR Forge
tagline:
en_us: Gitea with Actions, images served from IPFS, code mirrored to Radicle.
fr_fr: Gitea avec Actions, images servies depuis IPFS, code répliqué sur Radicle.
description:
en_us: |
IPCR Forge is a git forge whose releases do not depend on it staying online:
• **Gitea** is where you work: repositories, pull requests, issues, and GitHub-style Actions.
Push with plain `git`.
• **IPCR** publishes every image your workflows build on IPFS, so any server running IPCR
can `docker pull` it by name, from whichever peers hold it.
• **Radicle** receives a copy of every public repository, signed by this server, on its
peer-to-peer network: anyone can clone it, even when this server is down.
A repository publishes images by committing a workflow under `.gitea/workflows/` that
pushes to `localhost:5000`. The install creates an example, `ipcr-hello`: create a tag on it
in Gitea and watch the whole chain run.
The forge's own page explains how to publish, lists every published image with the line to
put in a compose file, and every repository mirrored to Radicle.
This app bundles the Gitea, Radicle and IPCR apps. Install it instead of them, not beside them.
fr_fr: |
IPCR Forge est une forge git dont les versions publiées ne dépendent pas de sa disponibilité :
• **Gitea** est l'endroit où l'on travaille : dépôts, pull requests, tickets, et Actions au
format GitHub. On pousse avec `git`, tout simplement.
• **IPCR** publie sur IPFS chaque image construite par vos workflows : tout serveur équipé
d'IPCR peut la `docker pull` par son nom, depuis les pairs qui la détiennent.
• **Radicle** reçoit une copie de chaque dépôt public, signée par ce serveur, sur son réseau
pair-à-pair : n'importe qui peut la cloner, même quand ce serveur est arrêté.
Un dépôt publie des images en ajoutant un workflow sous `.gitea/workflows/` qui pousse vers
`localhost:5000`. L'installation crée un exemple, `ipcr-hello` : créez-y un tag dans Gitea et
regardez toute la chaîne s'exécuter.
La page de la forge explique comment publier, liste chaque image publiée avec la ligne à
mettre dans un fichier compose, et chaque dépôt répliqué sur Radicle.
Cette application regroupe les applications Gitea, Radicle et IPCR. Installez-la à leur
place, pas à côté.
tips:
before_install:
en_us: |
**Start here: [the forge's page](https://ipcr-forge-$APP_DOMAIN)**: how to publish, the
published images, the Radicle mirrors.
**Admin: [ipcr-forge-$APP_DOMAIN/admin](https://ipcr-forge-$APP_DOMAIN/admin)**, signed in
with Gitea (site administrators only): your ENS or DNS name, health of the published name,
unpublishing images, which repositories may publish, and the **publisher key backup**.
Download one after the first release: the key exists only on this server, and losing it
loses every image name.
**Replaces three apps.** Uninstall Gitea, Radicle and IPCR first: this app uses the same
addresses and ports.
| Address | What |
| --- | --- |
| `https://gitea-$APP_DOMAIN` | Gitea (opens from the tile). Sign in as `gitea_admin` with your default app password |
| `https://ipcr-forge-$APP_DOMAIN` | The forge's page (public) |
| `ipcr-$APP_DOMAIN` | `docker pull` this forge's images from anywhere, without IPCR (read-only) |
| `https://radicle-$APP_DOMAIN` | Radicle explorer (public) |
| `https://radicle-api-$APP_DOMAIN` | Radicle API and read-only git (public) |
| `ipcr.localhost:4767` | IPCR registry, this server's Docker only |
**Ports.** TCP 8776 (Radicle peers) and 4768 TCP+UDP (IPFS peers) are published.
**Public repositories go to Radicle.** Every public Gitea repository is copied there, signed
by this server, within seconds of each push. That cannot be undone: making a repository
private later stops the copy, and what was published stays on the network. Private
repositories are never copied.
**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.
**Administration : [ipcr-forge-$APP_DOMAIN/admin](https://ipcr-forge-$APP_DOMAIN/admin)**,
connexion via Gitea (administrateurs du site uniquement) : votre nom ENS ou DNS, l'état du
nom publié, le retrait d'images, les dépôts autorisés à publier, et la **sauvegarde de la
clé de publication**. Téléchargez-en une après la première version : la clé n'existe que
sur ce serveur, et la perdre fait perdre tous les noms d'images.
**Remplace trois applications.** Désinstallez d'abord Gitea, Radicle et IPCR : cette
application utilise les mêmes adresses et ports.
| Adresse | Quoi |
| --- | --- |
| `https://gitea-$APP_DOMAIN` | Gitea (s'ouvre depuis la tuile). Connexion : `gitea_admin` et votre mot de passe d'applications par défaut |
| `https://ipcr-forge-$APP_DOMAIN` | La page de la forge (publique) |
| `ipcr-$APP_DOMAIN` | `docker pull` des images de cette forge depuis n'importe où, sans IPCR (lecture seule) |
| `https://radicle-$APP_DOMAIN` | Explorateur Radicle (public) |
| `https://radicle-api-$APP_DOMAIN` | API Radicle et git en lecture seule (public) |
| `ipcr.localhost:4767` | Registre IPCR, pour le Docker de ce serveur uniquement |
**Ports.** TCP 8776 (pairs Radicle) et 4768 TCP+UDP (pairs IPFS) sont publiés.
**Les dépôts publics partent sur Radicle.** Chaque dépôt public de Gitea y est copié, signé
par ce serveur, quelques secondes après chaque push. C'est irréversible : rendre un dépôt
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.** 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
x-compose-app:
schema_version: 2
folders:
# ── Bridge ──
- path: /DATA/AppData/$AppID/bridge/state
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/bridge/secrets
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
group: $PGID
- path: /DATA/AppData/$AppID/gitea/config
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/gitea/runner
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/gitea/runner-config
user: $PUID
group: $PGID
# ── Radicle ──
- path: /DATA/AppData/$AppID/radicle/home
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/radicle/caddy
user: $PUID
group: $PGID
# ── CI daemon ──
# The daemon's socket: created by root, group $PGID (dockerd --group), mode 0660.
- path: /DATA/AppData/$AppID/ci/socket
user: $PUID
group: $PGID
mode: "0750"
- path: /DATA/AppData/$AppID/ci/docker
user: $PUID
group: $PGID
mode: "0710"
- path: /DATA/AppData/$AppID/ci/registry
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
group: $PGID
mode: "0700"
# Created ahead of Kubo's first start: the bridge mounts it, and Docker would otherwise create
# it as root, where Kubo cannot write its keys.
- path: /DATA/AppData/$AppID/ipcr/kubo/keystore
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/ipcr/init.d
user: $PUID
group: $PGID
files:
# Each entry and its reason are in the standalone listing it comes from. All follow store
# updates (re-rendered on every up).
- path: /DATA/AppData/$AppID/gitea/runner-config/config.yaml
from: gitea/runner-config/config.yaml
ensure: always
mode: "0644"
- path: /DATA/AppData/$AppID/radicle/home/config.json
from: radicle/home/config.json.tmpl
ensure: always
mode: "0644"
- path: /DATA/AppData/$AppID/radicle/caddy/Caddyfile
from: radicle/caddy/Caddyfile.tmpl
ensure: always
mode: "0644"
- path: /DATA/AppData/$AppID/ipcr/init.d/01-ipcr-node.sh
from: ipcr/init.d/01-ipcr-node.sh
ensure: always
mode: "0755"
init:
# 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.
- name: radicle-identity
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
user: $PUID:$PGID
volumes:
- /DATA/AppData/$AppID/radicle/home:/radicle-home
command: ["sh", "-c", "yes '' | rad auth --alias radicle-${APP_PUBLIC_IP_DASH} --stdin"]
when: absent:/DATA/AppData/$AppID/radicle/home/keys/radicle
- name: gitea-admin
image: gitea/gitea:1.27.3-rootless
user: $PUID:$PGID
phase: post_up
volumes:
- /DATA/AppData/$AppID/gitea/data:/var/lib/gitea
- /DATA/AppData/$AppID/gitea/config:/etc/gitea
command:
- sh
- -c
- |
timeout 240 sh -c 'until gitea admin user list >/dev/null 2>&1; do sleep 3; done'
gitea admin user create --admin --username gitea_admin \
--password "$APP_DEFAULT_PASSWORD" --email "$APP_EMAIL" \
--must-change-password=false \
|| echo "admin account already present, leaving it alone"
when: always
- name: gitea-runner-token
image: gitea/gitea:1.27.3-rootless
user: $PUID:$PGID
phase: post_up
network: $APP_NET
volumes:
- /DATA/AppData/$AppID/gitea/data:/var/lib/gitea
- /DATA/AppData/$AppID/gitea/config:/etc/gitea
- /DATA/AppData/$AppID/gitea/runner:/runner
command:
- sh
- -c
- |
timeout 240 sh -c 'until gitea actions generate-runner-token > /runner/token.new 2>/dev/null; do sleep 3; done'
mv /runner/token.new /runner/token
chmod 600 /runner/token
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
# 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.4.0
user: $PUID:$PGID
phase: post_up
network: $APP_NET
volumes:
- /DATA/AppData/$AppID/bridge/secrets:/secrets
command:
- sh
- -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
when: always
webui-host: gitea-${domain}
webui-path: /
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

+280
View File
@@ -0,0 +1,280 @@
# IPCR Forge — Rationale
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
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,
then covers only what the bundle adds.
Gitea is the source of truth. Earlier versions made Radicle the source: first with a read-only
Gitea fed from it (v1), then with CI on the Radicle node alone (v2). Users found both hard to work
with. See docs/forge.md.
## What deviation / exception is being requested
**Inherited, unchanged.** The full argument for each is in that listing's `rationale.md`.
| From | Deviation |
| --- | --- |
| Gitea | An admin account (`gitea_admin`) created with the server's default app password |
| Radicle | No authentication gate on `radicle-` and `radicle-api-<domain>`; host port `8776/tcp` |
| IPCR | `ipcr-gateway` and `ipcr-registry` run as root (no capabilities); `/etc/docker/certs.d` mounted; no authentication; ports `127.0.0.1:4767` and `4768/tcp+udp` |
**Added by the bundle:**
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
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
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
that webhook.
5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry
is.
6. **A public page.** `ipcr-forge-<domain>` is not behind the SSO gate.
7. **Public repositories are published to Radicle automatically**, and that cannot be undone.
8. **It replaces three apps** and cannot be installed beside them: it uses the same container names,
domains and host ports.
9. **Admin pages with their own login** (`/admin`): Gitea OAuth2, Gitea site admins only. The
install step registers the OAuth2 app.
10. **The bridge mounts Kubo's keystore read-only**, so it can export the publisher key, encrypted.
11. **An admin API in `ipcr-gateway`** (`:4769`) on a new internal network, `ipcr-admin`. Its Bearer
token is in `ipcr/state/admin-token`, mode 0644.
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.
16. **A public, unauthenticated, read-only registry route** (`ipcr-<domain>` → `ipcr-public`), so this
forge's images can be pulled without IPCR.
## Why it is necessary
**1. Privileged Docker-in-Docker.** Building an image needs a Docker daemon. A CI job can get one
in two ways:
- the host's socket, which gives every workflow root on the PCS;
- a daemon of its own, which needs `privileged`.
The second keeps a build inside its own container and image store. The rootless variant does
not start on this platform (`kernel.apparmor_restrict_unprivileged_userns=1`; see
Apps/Gitea/rationale.md, where it was tried first). The runner itself is a separate, unprivileged
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
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
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
reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the bridge sits
on one. The list adds exactly one host name. `private` would let any user's webhook reach every
container on the shared network.
**4. The admin password at install.** Gitea's token API and its system-webhook API accept only an
administrator signing in. The step uses the password once per start, never stores it, and
leaves the running service holding nothing but a `public-only`, `read:repository` token. If the
password has been changed, the step keeps the existing token and webhook instead of failing.
**5. The network link.** IPCR publishes images by *pulling* them: it polls a registry and
imports every new tag into IPFS. Jobs run in the CI's own daemon, which cannot reach
`ipcr.localhost`, because that name is the host's loopback. So a workflow pushes to a staging
registry instead. That registry shares the daemon's network namespace, so every job sees it as
`localhost:5000`. That is the only address Docker and BuildKit accept plain HTTP for, so no TLS
or credentials are involved. `ipcr-gateway` reaches it as `forge-docker:5000` over `ci-internal`.
**6. The public page.** It holds links, documentation, image names that are public on IPFS anyway,
and the list of repositories already public on Radicle. A login in front of it would stand between
a stranger and the instructions for pulling an image.
**7. Publishing to Radicle.** That is the point of the mirror: the code of a released image stays
available without this server. Only public repositories go. A public Gitea repository is meant to
be read by anyone, and Radicle is one more place to read it.
**8. Replacing three apps.** The bundle runs the same services, not a different product. Renaming
every container, domain and port so they could coexist would break the defaults the services rely
on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
**9. Admin pages.** The operator needs to manage the IPCR side: the ENS or DNS name; whether the
name and the images resolve from outside; unpublishing; which repositories may publish; and, most
of all, backing up the publisher key, which is the only copy of every image name. Gitea is the
forge's identity system, so the admin pages sign in with it rather than adding accounts or relying
on the PCS gate, which is not on every host.
**10. The keystore mount.** Kubo exports keys from its CLI only (`key/export` is not available
over its HTTP API). ipcrd runs as root without capabilities, so it cannot read a `$PUID` 0700
folder; giving it `DAC_READ_SEARCH` was rejected once already. The bridge runs as `$PUID` and can.
**11. The admin API and its token.** Unpublishing, moving tags, key restore and the allowlist
change ipcrd's own state (the published tree, `published.json`), so they live in ipcrd. The token is
needed because CI jobs on `ci-internal` can reach `ipcr-gateway`. The file is world-readable
because ipcrd, root without `CAP_CHOWN`, cannot hand a file to `$PUID` any other way. It sits in
the app's own state folder.
**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.
**16. The public front door.** The IPFS address only works on machines running IPCR. People trying
an image elsewhere (a laptop, a CI on another platform) need a registry that plain Docker can pull
from. Public IPFS gateways cannot do it, since they have no registry API. A login would defeat the
purpose: the images are public on IPFS anyway.
## Security mitigations in place
**The bridge**
- **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
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
check against a random secret written at install (`bridge/secrets`, 0700). A delivery only says
*which* repository changed. The bridge re-reads that repository from Gitea, so a forged payload
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,
`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 CI daemon**
- **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an
empty `DOCKER_TLS_CERTDIR`. The unix socket sits in a folder shared only with `forge-runner`,
group `$PGID`.
- **`forge-runner` runs unprivileged:** `$PUID`, no capabilities, `no-new-privileges`. Jobs run in
the nested daemon, never on the host's.
- **The staging registry is unreachable from outside.** It has no port and no Caddy route and sits
on `ci-internal` only. It runs as `$PUID` with no capabilities.
- **Bounded.** One job at a time, at most 1 hour per job, 2 GB for the daemon.
- **Severable.** Stopping `forge-runner` and `forge-docker` removes the privileged container and
leaves a working Gitea, Radicle node and IPCR registry.
**Gitea**
- **Fork pull requests wait for approval.** In Gitea 1.27, a run from a user without write access
waits for "Approve and run".
- **Registration is off.** Every account is created by the admin.
- **Who can publish is stated** in the tips and on the page. Write access to any repository means
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 public front door**
- **This forge's images only.** It answers only for the publisher's own IPNS name, its verified
ENS/DNS name, and the root CIDs of published images. Anything else is 404 before any resolution,
so strangers cannot use it to fetch or serve other IPFS content.
- **Read-only and storage-neutral.** GET and HEAD only, and `AUTO_PIN=false`: a pull never pins.
- **Isolated.**
- It is a separate process (`ipcr-public`) on `pcs` and `ipcr-internal` only, not on the admin
or CI networks.
- It runs as `$PUID` with no capabilities and a read-only root filesystem, capped at 256 MB.
- It reads the main gateway's state read-only.
- **Can be switched off** in the admin pages (`config.json` `"public": false`).
**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.
**The admin pages**
- **Only Gitea site admins get in.** The check is `is_admin`, read from Gitea's own API through the
internal address. The Gitea token is dropped right after.
- **The session cookie:**
- HMAC-signed, `HttpOnly`, `Secure`, valid 8 h;
- the login uses PKCE and `state`, and is only accepted on the hosts in `PUBLIC_HOSTS`.
- **Every write must be same-origin.** The `Origin` header (or Fetch Metadata) must match, and the
custom `X-IPCR-Admin` header is required. A cross-site form cannot send that header, and a
cross-site script cannot without a CORS preflight, which is never answered.
- **The admin API is only on `ipcr-admin`.** That network is `internal: true` and shared only by
the bridge and `ipcr-gateway`; every call needs the Bearer token, compared in constant time. Kubo's
own API stays on `ipcr-internal`.
- **The cleartext key never crosses the network.** The bridge encrypts it in memory (AES-256-GCM,
PBKDF2 at 600k iterations, at least 12 characters of passphrase) and ipcrd decrypts it. A
restore never deletes a key: the previous one is kept, renamed.
- **The allowlist is closed by default.** Until the bridge sends its list, nothing is published
(`IMPORT_ALLOW_REQUIRED`). Private repositories are never on it.
## Alternatives considered and rejected
| Alternative | Why not |
| --- | --- |
| Radicle as the source, Gitea read-only (v1) | Users had to work in Radicle and found a read-only Gitea confusing |
| Radicle only, CI on the node (v2) | Not user-friendly enough: every step went through `rad` |
| Gitea push mirror to Radicle | Gitea cannot push to `rad://` (no `git-remote-rad`), and mirror pushes run on a timer |
| Publishing to Radicle from a workflow job | It would put the node's key inside CI jobs |
| 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 |
| 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 |
## Known limitations
- **Radicle cannot unpublish.** A repository made private or deleted in Gitea is *frozen* (no
further sync). What already reached the network stays there.
- **A deleted tag stays in Radicle's canonical `refs/tags/`.** It is removed from the node's own
namespace only.
- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only
delegate.
- **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.
- **Old keys accumulate.** `<key>-replaced-*` keys are kept and republished by Kubo until removed by
hand (`docker exec ipcr-kubo ipfs key rm <name>`).
- **Re-importing a tag needs it in staging,** and staging is cleaned once a tag is published. A new
CI run brings it back.
## Data protection
Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
| Folder | Holds |
| --- | --- |
| `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`); `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
IPCR's `/etc/docker/certs.d/ipcr.localhost:4767/ca.crt`, as in the standalone app. Identities that
matter on the network survive uninstall and reinstall, so published repositories and image names
stay valid:
- Radicle's node key;
- Kubo's node key and IPNS keys.
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

@@ -0,0 +1,42 @@
# act_runner configuration for IPCR Forge. Only the settings that differ from upstream's defaults
# are written out — `act_runner generate-config` prints the full set. From Apps/Gitea's, except
# where marked "Forge:".
log:
level: info
runner:
# The file the runner's own registration is kept in, relative to /data.
file: .runner
# One job at a time. A PCS shares its CPU with everything else installed on
# it, and an image build is the least polite thing on the box.
capacity: 1
timeout: 1h
fetch_timeout: 5s
fetch_interval: 2s
# Overrides the labels stored at registration (.runner). Gitea's own job images, which carry
# node, bash, git and the docker CLI with buildx, so marketplace actions (actions/checkout,
# docker/build-push-action, ...) run as they do on GitHub.
labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
cache:
# The cache server binds an address inside the runner container, which the
# job containers — created by the separate Docker daemon — cannot route to.
# Left on, every `actions/cache` step fails noisily instead of being skipped.
enabled: false
container:
# Forge: jobs run on the daemon's host network, which is the forge-docker container's own
# namespace — where the staging registry listens as localhost:5000. Plain HTTP to localhost is
# the one thing Docker and BuildKit accept without TLS, so a workflow needs no login and no
# certificate. Jobs still reach the internet (actions, base images) and Gitea through its public
# address.
network: host
privileged: false
# Empty: the runner's own daemon (DOCKER_HOST) is mounted into each job, so `docker build`
# and buildx work in a job, against the same daemon (and the same localhost:5000).
docker_host: ""
force_pull: false
@@ -0,0 +1,24 @@
#!/bin/sh
# Run by Kubo's entrypoint before every daemon start (after `ipfs init` on the first one),
# so a store update that changes these values reaches an installed node.
set -e
# libp2p swarm on 4768 rather than Kubo's 4001: the Kubo store app already publishes 4001 and
# two apps cannot publish the same host port. Listening on the published port itself (instead of
# mapping 4768→4001) keeps the addresses the node announces dialable.
P=${IPCR_SWARM_PORT:-4768}
ipfs config --json Addresses.Swarm "[
\"/ip4/0.0.0.0/tcp/$P\", \"/ip6/::/tcp/$P\",
\"/ip4/0.0.0.0/udp/$P/quic-v1\", \"/ip6/::/udp/$P/quic-v1\",
\"/ip4/0.0.0.0/udp/$P/quic-v1/webtransport\", \"/ip6/::/udp/$P/quic-v1/webtransport\",
\"/ip4/0.0.0.0/udp/$P/webrtc-direct\", \"/ip6/::/udp/$P/webrtc-direct\"
]"
# Soft cap on the block store. Pinned images are never collected; with --enable-gc everything
# else (blocks fetched but not pinned) is swept every Datastore.GCPeriod.
ipfs config Datastore.StorageMax "${IPFS_STORAGE_MAX:-20GB}"
# How long an IPNS record stays valid when Kubo republishes it (every 4 h, while online). Kubo's
# default is 48 h: a node offline for a weekend would lose every name it publishes. ipcrd publishes
# with the same lifetime (IPNS_LIFETIME).
ipfs config Ipns.RecordLifetime "${IPCR_IPNS_LIFETIME:-168h}"
@@ -0,0 +1,20 @@
# Serves the Radicle Explorer single-page app.
#
# Port 8080, not 80: the installer runs every service as an unprivileged user, and
# such a process cannot bind a port below 1024. The image's own Caddyfile
# listens on :80, so this file replaces it.
:8080 {
root * /usr/share/caddy
# The explorer bundle ships with the public Radicle seeds compiled into it, so
# "/" would open on the public network rather than on this server's node. This
# sends the entry point to the node page of the API served alongside it. Every
# other path is left alone, so the wider network stays browsable.
redir / /nodes/radicle-api-${APP_DOMAIN}
# The explorer is a client-side router: /nodes/..., /repos/... and friends must
# resolve to the app shell rather than to a 404.
try_files {path} /index.html
file_server
encode gzip
}
@@ -0,0 +1,15 @@
{
"preferredSeeds": [
"z6MksmpU5b1dS7oaqF2bHXhQi1DWy2hB7Mh9CuN7y1DN6QSz@seed.radicle.dev:8776",
"z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776",
"z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776"
],
"node": {
"alias": "radicle-${APP_PUBLIC_IP_DASH}",
"listen": ["0.0.0.0:8776"],
"externalAddresses": ["radicle-${APP_PUBLIC_IP_DASH}.sslip.io:8776"],
"seedingPolicy": {
"default": "block"
}
}
}
+334
View File
@@ -0,0 +1,334 @@
name: radicle
services:
# The web UI: the Radicle Explorer bundle served as static files by Caddy.
# It is a pure client-side app — every request it makes goes from the visitor's
# browser straight to the API below, which is why that one needs its own domain.
radicle:
image: dirk1980/radicle-explorer-container@sha256:0e31485c38a765e83a10b1a4bf71b12d4f9fec630881d171bba736a4a56a16ce
container_name: radicle
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 80
deploy:
resources:
limits:
memory: 128M
environment:
TZ: $TZ
# Caddy writes its autosaved config and its (unused here) certificate store
# under these two paths. The image points them at /config and /data, which are
# root-owned, so as $PUID the process cannot create them and refuses to start.
XDG_CONFIG_HOME: /tmp/caddy-config
XDG_DATA_HOME: /tmp/caddy-data
volumes:
# Replaces the image's own :80 Caddyfile — see the comments in that file.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
expose:
- 8080
labels:
caddy_0: radicle-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 8080}}"
caddy_1: radicle-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 8080}}"
caddy_2: radicle-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# radicle-httpd — the node's read-only JSON API, on its own domain.
#
# This domain is deliberately NOT behind the SSO gate: the explorer above is a
# static bundle running in the visitor's browser, so it is the browser that calls
# this API, cross-origin. The daemon serves public repositories only (private ones
# are filtered out upstream) and has no write endpoints at all. See rationale.md.
radicle-api:
image: dirk1980/radicle-httpd@sha256:5e95be30e4bde55b509a65fd56f8b825cafd0f5cd752c1a13e79aad75b958895
container_name: radicle-api
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 70
deploy:
resources:
limits:
memory: 512M
command: ["--listen", "0.0.0.0:8080"]
environment:
TZ: $TZ
volumes:
# Same Radicle home as the node: the daemon reads the repository storage
# directly and talks to the node over the control socket inside it.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/home/:/radicle-home/
healthcheck:
# curl, not wget: this image carries curl and no busybox.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/api/v1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
expose:
- 8080
labels:
caddy_0: radicle-api-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 8080}}"
caddy_1: radicle-api-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 8080}}"
caddy_2: radicle-api-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# The node itself: speaks the Radicle peer-to-peer protocol (gossip, fetch,
# announce) on TCP 8776. No HTTP, so no Caddy route and no place on the shared
# network — it only needs egress, which the app-internal bridge provides.
radicle-node:
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
container_name: radicle-node
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 50
deploy:
resources:
limits:
memory: 1G
environment:
TZ: $TZ
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/home/:/radicle-home/
ports:
# Radicle peer-to-peer protocol. Other nodes dial in here to fetch the
# repositories this node seeds; without it the node still syncs outbound,
# but nobody can fetch from it. Hence the needs-public-ip tag.
- "8776:8776"
healthcheck:
# `rad node status` round-trips the node's control socket, which is the only
# liveness signal this container has — but it also renders the peer table, and
# that takes 3-8s on an idle node and longer while the node is syncing. Hence
# the unusually generous timeout: a 10s one fails during the first sync and
# flaps the container between healthy and unhealthy.
test: ["CMD", "rad", "node", "status"]
interval: 60s
timeout: 30s
retries: 3
start_period: 90s
networks:
- radicle-internal
networks:
pcs:
name: ${APP_NET:-pcs}
external: true
# Egress-only network for the node. Nothing else joins it — the node and the
# API daemon share state through the Radicle home directory, not over TCP.
radicle-internal:
driver: bridge
x-casaos:
architectures:
- amd64
main: radicle
developer: Radicle
category: Developer
tags: ["developer", "personal", "needs-public-ip"]
description:
en_us: |
Radicle — your own peer-to-peer forge
Radicle is a sovereign alternative to GitHub and GitLab. Instead of a company's server holding the canonical copy of your code, every participant keeps a full replica and gossips changes directly to peers. Repositories, issues and patches are all Git objects, signed by their author and replicated across the network — there is no central service to go down, change its terms, or lock you out.
This app runs a complete Radicle node on your server, plus the official Radicle Explorer web interface to browse it:
• Your node stays online and keeps your repositories available even when your laptop is not
• Anything you push from your machine is replicated here and served to the network
• Issues, patches and code are browsable in your own web UI, on your own domain
• Anyone can clone your repositories over plain HTTPS, no account required
The web interface is for browsing. Creating repositories, opening issues and proposing patches happen with the `rad` command line on your own machine — the node here is what keeps them alive and reachable.
Repositories are only replicated once you seed them. A fresh node starts empty and stores nothing until you tell it what to follow.
fr_fr: |
Radicle — votre forge pair-à-pair
Radicle est une alternative souveraine à GitHub et GitLab. Plutôt qu'un serveur d'entreprise détenant la copie de référence de votre code, chaque participant conserve un réplica complet et propage ses modifications directement à ses pairs. Dépôts, tickets et patches sont tous des objets Git, signés par leur auteur et répliqués sur le réseau — aucun service central ne peut tomber, changer ses conditions ou vous verrouiller dehors.
Cette application fait tourner un nœud Radicle complet sur votre serveur, ainsi que l'interface web officielle Radicle Explorer pour le parcourir :
• Votre nœud reste en ligne et garde vos dépôts disponibles même quand votre portable ne l'est pas
• Tout ce que vous poussez depuis votre machine est répliqué ici et servi au réseau
• Tickets, patches et code se consultent dans votre propre interface web, sur votre domaine
• N'importe qui peut cloner vos dépôts en HTTPS, sans compte
L'interface web sert à consulter. Créer des dépôts, ouvrir des tickets et proposer des patches se fait avec la ligne de commande `rad` sur votre machine — le nœud installé ici est ce qui les maintient vivants et accessibles.
Les dépôts ne sont répliqués qu'une fois que vous les suivez. Un nœud neuf démarre vide et ne stocke rien tant que vous ne lui avez rien indiqué.
zh_cn: |
Radicle — 属于你自己的点对点代码托管
Radicle 是 GitHub 与 GitLab 的自主替代方案。代码的权威副本不再由某家公司的服务器保管,而是每个参与者都持有完整副本,并直接向对等节点传播更改。仓库、议题和补丁都是 Git 对象,由作者签名并在网络中复制——没有中心服务会宕机、修改条款或将你拒之门外。
此应用在你的服务器上运行一个完整的 Radicle 节点,并附带官方的 Radicle Explorer 网页界面:
• 节点持续在线,即使你的笔记本关机,仓库依然可用
• 你从本机推送的内容会复制到这里,并向网络提供服务
• 议题、补丁和代码都可以在你自己的域名下浏览
• 任何人都可以通过 HTTPS 克隆你的仓库,无需账户
网页界面用于浏览。创建仓库、提交议题和提出补丁请在本机使用 `rad` 命令行完成——这里的节点负责让它们保持在线、可被获取。
仓库只有在你选择跟随(seed)之后才会被复制。全新节点启动时是空的,在你指定之前不会存储任何内容。
icon: icon.png
screenshot_link:
- screenshot-1.png
- screenshot-2.png
- screenshot-3.png
tagline:
en_us: Your own peer-to-peer Git forge — a Radicle node and web explorer on your domain.
fr_fr: Votre forge Git pair-à-pair — un nœud Radicle et son explorateur web sur votre domaine.
zh_cn: 你自己的点对点 Git 托管——运行在你域名下的 Radicle 节点与网页浏览器。
thumbnail: thumbnail.png
tips:
before_install:
en_us: |
This app is public on purpose
A Radicle node is a participant in a public peer-to-peer network. Both of
its addresses are served directly to the public, bypassing the server's
single sign-on:
| Address | What it serves |
| ------- | -------------- |
| `https://radicle-$APP_DOMAIN` | The web interface |
| `https://radicle-api-$APP_DOMAIN/api/v1` | The read-only API the interface calls |
Only **public** repositories are served, the API has no write endpoints, and
a fresh node holds nothing until you seed something. See `rationale.md`.
Connecting your machine
Install the `rad` CLI from [radicle.dev](https://radicle.dev), then push a
repository and tell this node to seed it. Your node's identity (its Node ID)
is printed at `https://radicle-api-$APP_DOMAIN/api/v1` under `nid`.
Peer-to-peer port
The node publishes TCP **8776** so other peers can fetch from it. On a server
without a directly reachable public IP the app still works — your repositories
replicate outward to public seeds — but nobody fetches from you directly.
fr_fr: |
Cette application est publique, volontairement
Un nœud Radicle participe à un réseau pair-à-pair public. Ses deux adresses
sont servies directement au public, en contournant l'authentification
unique du serveur :
| Adresse | Ce qu'elle sert |
| ------- | --------------- |
| `https://radicle-$APP_DOMAIN` | L'interface web |
| `https://radicle-api-$APP_DOMAIN/api/v1` | L'API en lecture seule appelée par l'interface |
Seuls les dépôts **publics** sont servis, l'API n'a aucun point d'écriture, et
un nœud neuf ne contient rien tant que vous n'avez rien suivi. Voir
`rationale.md`.
Connecter votre machine
Installez la CLI `rad` depuis [radicle.dev](https://radicle.dev), puis poussez
un dépôt et demandez à ce nœud de le suivre. L'identité de votre nœud (Node ID)
est affichée sur `https://radicle-api-$APP_DOMAIN/api/v1`, champ `nid`.
Port pair-à-pair
Le nœud publie le port TCP **8776** pour que les autres pairs puissent récupérer
ses dépôts. Sur un serveur sans IP publique directement joignable, l'application
fonctionne quand même — vos dépôts se répliquent vers les seeds publics — mais
personne ne récupère directement chez vous.
zh_cn: |
此应用有意保持公开
Radicle 节点是公开点对点网络的参与者。它的两个地址都直接向公众开放,
绕过服务器的单点登录:
| 地址 | 提供的内容 |
| ---- | ---------- |
| `https://radicle-$APP_DOMAIN` | 网页界面 |
| `https://radicle-api-$APP_DOMAIN/api/v1` | 界面调用的只读 API |
仅提供**公开**仓库,API 没有任何写入接口,全新节点在你指定跟随之前不含任何内容。
详见 `rationale.md`。
连接你的机器
从 [radicle.dev](https://radicle.dev) 安装 `rad` 命令行,然后推送仓库并让此节点
跟随它。节点身份(Node ID)显示在 `https://radicle-api-$APP_DOMAIN/api/v1` 的
`nid` 字段。
点对点端口
节点公开 TCP **8776**,供其他对等节点抓取。若服务器没有可直接访问的公网 IP,应用
仍可使用——你的仓库会向公共 seed 节点复制——但别人无法直接从你这里抓取。
title:
en_us: Radicle
index: /
webui_port: 8080
store_app_id: radicle
x-compose-app:
schema_version: 2
folders:
- path: /DATA/AppData/$AppID/home
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/caddy
user: $PUID
group: $PGID
init:
# Creates the node's Ed25519 identity. Radicle has no first-run wizard: without
# a key pair in the Radicle home, radicle-node exits immediately. `rad auth`
# prompts for an optional passphrase, hence the empty line on stdin — a seed
# node's key secures peer connections and signs nothing permanent, so upstream's
# own guidance is to leave it unencrypted (a passphrase would have to be typed
# in at every start).
- name: init-identity
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
user: $PUID:$PGID
volumes:
- /DATA/AppData/$AppID/home:/radicle-home
command: ["sh", "-c", "yes '' | rad auth --alias radicle-${APP_PUBLIC_IP_DASH} --stdin"]
when: absent:/DATA/AppData/$AppID/home/keys/radicle
files:
# The node's config carries the deployment's own addresses, so it is re-rendered
# on every up rather than frozen at install. `rad auth` above writes a default
# config.json first; this replaces it with ours and keeps it in step if the PCS
# domain or public IP ever changes.
#
# It also carries the bootstrap seeds, and those are NOT the compiled-in defaults.
# radicle 1.10 ships `iris.radicle.network` / `rosa.radicle.network`, whose IPv4
# addresses refuse connections on 8776 (verified from two networks, 2026-09-04) —
# a node configured with them never finds a peer. The same two nodes answer under
# their previous hostnames, which is what this file uses; radicle logs a rename
# warning for them and keeps working. Revisit when upstream's new addresses accept
# peer traffic again.
- path: /DATA/AppData/$AppID/home/config.json
from: home/config.json.tmpl
ensure: always
mode: "0644"
# Carries this deployment's API domain in a redirect, so it is re-rendered too.
- path: /DATA/AppData/$AppID/caddy/Caddyfile
from: caddy/Caddyfile.tmpl
ensure: always
mode: "0644"
# The landing path is handled by the Caddy redirect above, not here: neither
# `webui-path` nor `x-casaos.index` is interpolated, so a ${...} written in
# either reaches the browser as a literal.
webui-host: radicle-${domain}
webui-path: /
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+85
View File
@@ -0,0 +1,85 @@
# Radicle — Rationale
## What deviation / exception is being requested
Two, both on the security checklist:
1. **No authentication gate.** Neither of the app's two domains (`radicle-<domain>`, the
web UI, and `radicle-api-<domain>`, the JSON API) is behind AppShield/Authelia or any
other login.
2. **A published host port.** `8776/tcp` is bound on the host so peers on the Radicle
network can dial in.
## Why it is necessary
**A Radicle node is a public participant in a public network.** Radicle is peer-to-peer
Git: repositories are replicated between nodes and served to anyone who asks. A node
nobody can reach is not a degraded Radicle node, it is not a Radicle node at all. The
same is true of the API: `radicle-httpd` is the read side of that participation.
**The web UI cannot be gated separately from the API.** Radicle Explorer is a static
bundle that runs entirely in the visitor's browser; every piece of data it shows is
fetched by *the browser* from `radicle-api-<domain>`, cross-origin. Putting the SSO gate
in front of the UI while the API stayed open would gate nothing — the data would remain
readable at the API domain — while adding a login page and a sidecar container. It would
be a padlock drawn on the door, not a lock.
**Port 8776 is the protocol.** Radicle's peer protocol is not HTTP, so Caddy cannot carry
it. Without the published port the node still pushes outward to public seeds, but no peer
can ever fetch from *this* node — the one thing the user installed it for. The app is
tagged `needs-public-ip` so that this is visible before install.
## Security mitigations in place
- **Only public repositories are served.** `radicle-httpd` filters its repository
listings and lookups on `visibility().is_public()`; a repository created with
`rad init --private` is not listed and not reachable through the API. Nothing the user
has not published is exposed.
- **The API is read-only.** As of `radicle-httpd` 0.25 the daemon has no session,
authentication or write endpoints at all — its whole surface is `GET` over
`/repos`, `/node`, `/delegates`, `/stats`. There is nothing to authenticate *to*,
and no request can change state on the server.
- **The node stores nothing by default.** The shipped configuration sets
`seedingPolicy.default = block`: a fresh node replicates nothing until the user
explicitly seeds a repository with `rad seed <rid>`. It cannot be used as an
unbounded drop box for whatever the network gossips at it.
- **The web UI is not a control surface.** It only browses. Creating repositories,
issues and patches requires the `rad` CLI and the user's own signing key, which never
leaves their machine.
- **Ordinary container hardening.** All three services run as `$PUID:$PGID`, never root;
each has a memory limit and a CPU share; only the two HTTP services join the shared
`pcs` network, and the node — the one with the published port — sits on an app-private
bridge with no route into the rest of the deployment.
- **No credentials to leak.** The node's key pair is generated on the server and is used
to secure peer connections; it signs nothing permanent. Upstream's own seed-node guide
recommends leaving it without a passphrase for exactly this reason, since a passphrase
would have to be supplied on every start.
## Alternatives considered and rejected
- **AppShield in front of the web UI only** — rejected: the data is served by the API
domain, which must stay open for the UI to work at all, so the gate would protect
nothing while implying that it did. Misleading security is worse than none.
- **AppShield in front of both** — rejected: it breaks the app. The browser's
cross-origin calls from the UI domain to the API domain carry no SSO session, and a
peer or a `git clone` cannot log into Authelia either.
- **Not publishing 8776** — rejected as the default: it turns the node into a
write-only client that no peer can fetch from. Users whose PCS has no reachable public
IP still get a working app (repositories replicate outward), which is what the
`needs-public-ip` tag communicates.
- **A permissive seeding policy** (`default: allow`) — rejected: it would make every
install replicate whatever the network offers onto the user's disk without them
asking.
## Data protection
All state lives in `/DATA/AppData/radicle/`: `home/` (the node's key pair, repository
storage, policy and COB databases) and `caddy/` (the web server config). Both survive
uninstall/reinstall, so the node keeps its identity — its Node ID — across the app
lifecycle; losing that key would mean losing the node's identity on the network, so
nothing in the app ever regenerates it once present.
Repository content in `storage/` is Git object data that the user chose to seed. It is
public by definition, signed by its authors, and verified on fetch: a peer cannot alter
what this node serves, because every ref is signed by the key of the delegate that
published it.
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+20
View File
@@ -0,0 +1,20 @@
# Serves the Radicle Explorer single-page app.
#
# Port 8080, not 80: the installer runs every service as an unprivileged user, and
# such a process cannot bind a port below 1024. The image's own Caddyfile
# listens on :80, so this file replaces it.
:8080 {
root * /usr/share/caddy
# The explorer bundle ships with the public Radicle seeds compiled into it, so
# "/" would open on the public network rather than on this server's node. This
# sends the entry point to the node page of the API served alongside it. Every
# other path is left alone, so the wider network stays browsable.
redir / /nodes/radicle-api-${APP_DOMAIN}
# The explorer is a client-side router: /nodes/..., /repos/... and friends must
# resolve to the app shell rather than to a 404.
try_files {path} /index.html
file_server
encode gzip
}
+15
View File
@@ -0,0 +1,15 @@
{
"preferredSeeds": [
"z6MksmpU5b1dS7oaqF2bHXhQi1DWy2hB7Mh9CuN7y1DN6QSz@seed.radicle.dev:8776",
"z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776",
"z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776"
],
"node": {
"alias": "radicle-${APP_PUBLIC_IP_DASH}",
"listen": ["0.0.0.0:8776"],
"externalAddresses": ["radicle-${APP_PUBLIC_IP_DASH}.sslip.io:8776"],
"seedingPolicy": {
"default": "block"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+403
View File
@@ -0,0 +1,403 @@
# IPCR Forge — design notes
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
[rationale.md](../Apps/IPCR-Forge/rationale.md).
## Goal
A forge people actually want to use, whose releases do not depend on it staying online:
- **Gitea is the source of truth.** Repositories, pull requests, issues, users and CI live there.
Developers use plain git and a familiar web UI.
- **Images go to IPFS** through IPCR, under one name for the whole forge, so any server running IPCR
can pull them from any peer that holds them.
- **Code goes to Radicle.** Every public repository is mirrored, one way, to the Radicle network,
so it can be cloned without this server.
## History: three versions
| Version | Source of truth | CI | Feedback |
| --- | --- | --- | --- |
| v1 (`afb1072`) | Radicle | Gitea Actions on a read-only Gitea fed by a Radicle → Gitea bridge | Radicle as the place to work was not user-friendly, and a read-only Gitea was confusing |
| v2 (`734a33f`) | Radicle | Radicle CI broker + act on the node (`ci/`, removed since) | Not user-friendly enough: everything went through `rad` |
| **v3** (this) | **Gitea** | **Gitea Actions** | — |
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
rewritten in Go ([bridge/](../bridge/)).
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
opened on the Radicle side are not read.
## Architecture
```
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)
│ · git fetch from Gitea
│ Actions · rad init (first time)
▼ · git push rad (signed by the node)
forge-runner (act_runner 0.6.1) ──unix socket──▶ forge-docker (dind) ▼
jobs on the daemon's host network → localhost:5000 = radicle-node ──▶ Radicle network
forge-registry (registry:3, shares dind's network namespace)
▲ polled every minute over ci-internal
ipcr-gateway (ipcrd): import → IPFS → publish under the forge's IPNS key
│
▼
anyone: docker pull ipcr.localhost:4767/ipns/<forge k51… | example.eth>/<owner>/<repo>:1.2.3
```
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).
## The bridge
[bridge/](../bridge/): one Go binary, `ipcr-forge-bridge`, image `ghcr.io/yundera/ipcr-forge-bridge`.
- **`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:
- mints the daemon's token, `public-only` + `read:repository`;
- generates the webhook secret;
- creates or repairs one **system webhook** (`POST /api/v1/admin/hooks`, `is_system_webhook`),
matched by URL so it is never duplicated;
- creates the example `gitea_admin/ipcr-hello`.
- **`serve`**:
- `:8081` takes the webhook. It is internal, with no Caddy route. It checks the HMAC, queues the
repository ID and answers 202 at once, because Gitea gives a delivery 5 seconds.
- One worker, with a coalescing queue: a burst of deliveries is one sync.
- A **reconcile** every 10 minutes catches what no event announces. In Gitea 1.27 that is renames
and visibility changes, plus any lost delivery.
- `:8080` serves the forge's page (`/`, `/images.json`, `/repos.json`). It is the place for future
admin pages, such as Radicle settings.
### Mirror semantics
| Gitea | Radicle |
| --- | --- |
| public repository, first sight | `rad init --public` from a bare mirror; this node is the only delegate; then a `xyz.radicle.crefs` rule `refs/tags/*` (threshold 1) so tags are canonical |
| push, force push, branch delete | `git push --prune rad +refs/heads/*:… +refs/tags/*:…`, then `rad sync <rid> --announce` |
| tag created (git or web UI) | same; becomes canonical `refs/tags/<tag>` |
| tag deleted | removed from the node's namespace; **the canonical `refs/tags/<tag>` stays** (see findings) |
| default branch changed | `rad id update … defaultBranch` |
| rename / transfer | same RID (state is keyed by Gitea's repository ID) |
| made private, deleted | **frozen**: no further sync. Nothing is retracted; it cannot be |
| private, fork, pull mirror | never mirrored (forks and mirrors did not originate here) |
| empty | waits for its first push |
The token cannot see a private repository at all (`public-only`), so a bug cannot publish one. The
code checks visibility again anyway.
## Trust
- **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
[naming.md](naming.md) is weaker for it.
- **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
those switched off in the admin pages. Images built from private repositories are therefore not
published.
- **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.
- **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
`https://ipcr-forge-<domain>/admin`. It is served by the bridge and only lets in Gitea site
administrators.
**Sign-in.** It uses an OAuth2 app that the `setup` step registers, with one redirect URI per
host. The flow is the authorization code with PKCE and the `read:user` scope. The bridge reads
`is_admin` from `/api/v1/user`, drops the Gitea token, and sets its own HMAC-signed session (8 h;
the key lives in `bridge/state`). Writes require the `X-IPCR-Admin` header and a same-origin
request.
**Behind the page.** The page calls the bridge, and the bridge calls ipcrd's admin API
(`gateway/admin.go`). That API listens on `:4769`, on the `ipcr-admin` network (internal, bridge
and gateway only), and needs a Bearer token from `ipcr/state/admin-token`.
| Section | What it does |
| --- | --- |
| Publisher name | The key and its `k51…`. You enter your ENS or DNSLink name; it is checked by resolving it through Kubo. While it points here, pull lines (page and admin) use it. The check runs again every 30 min. |
| Health | Shows the IPNS record held by this node and the one the network serves (delegated routing): sequence, expiry, whether it is the current tree. "Republish" refreshes it. Also: peers, the addresses the network sees, and storage against `StorageMax`. |
| Published images | For each tag: "Findable?" (is this node a listed provider), "Announce", "Make latest", "Unpublish". Unpublish removes the tag from the tree, unpins the objects no other image uses, and leaves a tombstone so the tag is not re-imported. Staging tags that are not allowed, or not imported yet, offer "Import now". |
| Which repositories may publish | One toggle per public repository. These toggles make up IPCR's allowlist. |
| Key backup | "Download" encrypts the publisher key with a passphrase. "Restore" makes a backup the publisher key: the current key is kept under `<key>-replaced-<time>`, and the new record's sequence is set above both the network's and this node's. |
**Manual restore** without the page:
```
ipcrd key decrypt backup.ipcrkey.json > key
ipfs key import forge key
```
Then publish with a sequence number above the network's.
**Backup format** (`gateway/keybackup.go`, copied in `bridge/`):
- JSON containing PBKDF2-SHA256 (600k iterations) and AES-256-GCM;
- the additional authenticated data binds the key's name and its `k51…`;
- the plaintext is exactly what `ipfs key export` writes.
The bridge seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the
network.
## 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.
## Public front door
`docker pull ipcr-<domain>/ipns/metadec.eth/ipcr-hello:1.0.0` works on any machine with plain
Docker: no IPCR, no CA to trust, no IPFS node.
**Why a public IPFS gateway can't do this:** `docker pull ipfs.io/ipns/…` asks for
`/v2/ipns/…/manifests/…`, a registry API that file gateways don't have. Blobs are then asked for by
digest, which only something that walks the image's descriptors can turn into CIDs.
**How it works.** `ipcr-public` is the same ipcrd, a second process, behind Caddy.
- **Plain HTTP:** Caddy terminates TLS.
- **No pinning:** `AUTO_PIN=false`.
- **Off the admin and CI networks.**
- **`PUBLIC_POLICY` points it at the main gateway's state** (read-only). It then serves only:
- this forge's publisher name;
- its ENS/DNS name, while verified;
- the root CIDs of its published images.
Everything else is 404, so nobody can make the node fetch other IPFS content. Pushes get 405.
- **Off switch:** the admin page's "Public pulls" switch (`config.json` `"public"`), picked up within
10 s. Pages show the "Public" / "without IPCR" pull lines while it is on.
**It's a convenience, not the main way to pull.** It depends on this server; `ipcr.localhost:4767`
works from any IPCR node that has, or can find, the image. Docker still checks every layer by
digest, so the front door cannot swap content.
## 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):
- `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>`.
## Findings
Carried over from v1 and v2; each one silently produced the wrong result rather than an error.
1. **Jobs must share the daemon's network namespace.** `localhost:5000` is the registry only on
forge-docker's own network, and Docker/BuildKit accept plain HTTP for `localhost` only. The runner
config sets `container.network: host`. As a consequence, compose service names do not resolve
inside jobs, so `GITEA_INSTANCE_URL` is Gitea's public address.
2. **`docker/build-push-action` defaults to a Git context** (`<server>/<repo>.git#<ref>`), which needs
credentials here. Hence `context: .`.
3. **buildx's builder container** does not share the job's network. `driver-opts: network=host` puts
it on the daemon's, where `localhost:5000` is the registry.
4. **act_runner's `run.sh` reads the registration token once** and gives up after 10 tries. The token
is minted by an install step that runs after the stack is up, so the compose entrypoint waits for
it first.
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.
The bridge 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):
6. **`rad init` works on a bare repository** (it uses `workdir().unwrap_or(path)`), so the bridge
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
`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
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.
9. **`rad sync --announce` takes its RID positionally** (`rad sync <rid> --announce`); there is no
`--rid` flag.
10. **`rad sync` blocks for its timeout when the node has no peers.** The bridge pushes with
`-o no-sync` and announces separately with a 30-second bound, so a push is never held up by the
network.
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
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
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.
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.
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.
15. **`key/export` is CLI-only** in Kubo (`NoRemote`), and ipcrd, which runs as root without
capabilities, cannot read the `$PUID` 0700 keystore. The bridge reads `key_<base32 name>`
(`forge` → `key_mzxxez3f`, mode 0400) through a read-only mount. Those bytes are what
`ipfs key export` would write.
16. **Delegated routing does not use the spec's 404.** For a name it doesn't know, delegated-ipfs.dev
answers `200 text/plain` "delegate error: routing: not found". The first restore read that as
"network unreachable" and refused, safely.
17. **A restore must outbid this node's own record, not only the network's.** Kubo refuses to
publish below the sequence it holds. The network showed 3 while Kubo held 5, so the first
restore failed. The sequence is now `max(network, local) + 1`.
18. **Cloudflare replaces a 502's body with its own page.** The admin API reports Kubo errors as
500, so the message reaches the page.
19. **Kubo 0.43 queues provides.** "Announce" returns at once, and the node shows up as a provider
on delegated routing a minute or two later.
20. **Republishing an unchanged tree keeps the sequence number** and only extends the expiry. The
lifetime went from 48 h to 7 days on the first republish.
21. **Gitea skips its consent screen only after a first grant,** even with
`skip_secondary_authorization`. Each admin clicks "Authorize" once.
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):
- `setup` run twice leaves exactly one system hook.
- `ipcr-hello` was created and mirrored with the crefs rule in its identity.
- A tag created through Gitea's API was canonical on Radicle within seconds, through the webhook.
- A private repository never appeared.
- A public repository made private was frozen.
**On holyhorse** (a test PCS, hand install that mimics Maison, 2026-10-04):
| Step | Result |
| --- | --- |
| install steps: Gitea admin, runner token, `forge-setup` | token, system hook, `ipcr-hello` created; runner registered |
| `ipcr-hello` mirrored to Radicle | `rad:z4Nc3jTG2q2rfQbio2gfuA7tWouee`, crefs rule in place |
| tag `v1.0.1` created through Gitea's API (as the web UI does) | Actions run started on `push`; tag canonical on Radicle in seconds |
| build (job image pulled cold) | success after about 4 min; `localhost:5000/gitea_admin/ipcr-hello:1.0.1` and `latest` |
| IPCR import | about a minute later: `/ipns/k51qzi5uqu5dg7urd3olssfzho23ze73n0jd9rwlqkivjlz3gspzyajk8cqfmd/gitea_admin/ipcr-hello:1.0.1` |
| `docker pull` of that name, then `docker run` | page served (`Hello from IPFS`) |
| forge page `ipcr-forge-holyhorse.nsl.sh` | 200; `/images.json` and `/repos.json` filled |
**Admin, on holyhorse** (ipcr 1.3.0, bridge 0.2.0, 2026-10-04):
| Check | Result |
| --- | --- |
| Login as `gitea_admin` / as a non-admin user | session and page / 403 |
| Writes without `X-IPCR-Admin`, or from another origin | 403; the admin API without a token, also from the CI network: 401 |
| Status | local and network record agree; republish → expiry 7 days out; 18 peers |
| `ipcr-hello` root findable | after "Announce": this node listed by delegated-ipfs.dev |
| `metadec.eth` | "points elsewhere" (it is wisera's), with the value to set |
| Repository switched off, tag `v1.0.2` built | stays in staging, not published; switched on → imported, then deleted from staging |
| `latest` → `1.0.1`, unpublish `1.0.2` | resolves accordingly; `1.0.2` gone for Docker, its objects unpinned, not re-imported |
| Key export → `ipcrd key decrypt` | byte-identical to `key_mzxxez3f`, same `k51…` |
| Restore a throwaway key, then the original | name switches and pulls under each; original back with sequence 7 (network had 5); kept copy reused, no duplicate key |
| `forge-registry` restart | garbage collection frees the deleted tags' blobs |
**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 |
**Public front door, on holyhorse** (ipcr 1.5.0, bridge 0.4.0, 2026-10-05):
| Check | Result |
| --- | --- |
| from another machine (Docker Desktop, no IPCR): `docker pull ipcr-holyhorse.nsl.sh/ipns/metadec.eth/ipcr-hello:1.0.0` | pulled, ran, served the page |
| same by `k51…/metadec/ipcr-hello:latest` and by `/ipfs/<cid>` | pulled |
| pins on the server after those pulls | unchanged (16) |
| wisera's k51, `vitalik.eth`, an unpublished CID, `/v2/_catalog` | 404 |
| a `PUT` | 405 |
| switch off / on | pull "not found", page hides the line / pulls again within 10 s |
## Open items
- **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.
- **Publish the images**: `ghcr.io/yundera/ipcr:1.5.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.4.0`
(workflows `image.yml` and `bridge.yml`, tags `v1.5.0` and `bridge-v0.4.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.
- **Radicle → Gitea:** patches opened on Radicle could become pull requests. Not built.
- **Store install:** test through the store, not only as a hand install.
+37
View File
@@ -0,0 +1,37 @@
# ipcr-forge-bridge: 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.
# Build context: this folder (bridge/).
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ipcr-forge-bridge .
# Radicle's official static (musl) release, checked against its published digest.
FROM alpine:3.22 AS radicle
ARG RADICLE_VERSION=1.10.1
ARG RADICLE_SHA256=9354f7f6241a88ddca1d41a080aa6337ddfc5dc682b8de78ab6d418b5782225a
# Retried: release downloads intermittently fail.
RUN apk add --no-cache xz \
&& url="https://files.radicle.dev/releases/${RADICLE_VERSION}/radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl.tar.xz" \
&& for i in 1 2 3 4 5; do wget -qO /tmp/radicle.tar.xz "$url" && break; sleep $((i * 5)); done \
&& echo "${RADICLE_SHA256} /tmp/radicle.tar.xz" | sha256sum -c - \
&& mkdir /out && tar -xJf /tmp/radicle.tar.xz -C /out --strip-components=2 \
"radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl/bin/rad" \
"radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl/bin/git-remote-rad"
FROM alpine:3.22
ARG VERSION=dev
LABEL org.opencontainers.image.title="ipcr-forge-bridge" \
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.version="$VERSION"
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
# another owner ("dubious ownership"); the storage is the node's own, so trust it.
&& git config --system safe.directory '*'
COPY --from=radicle /out/ /usr/local/bin/
COPY --from=build /out/ipcr-forge-bridge /usr/local/bin/ipcr-forge-bridge
ENV RAD_HOME=/radicle-home \
HOME=/tmp
# No entrypoint: the install step runs `sh -c '… ipcr-forge-bridge setup'` in this image.
CMD ["ipcr-forge-bridge", "serve"]
+385
View File
@@ -0,0 +1,385 @@
package main
import (
"bytes"
"encoding/base32"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// adminAPI is what the admin page calls, behind auth.requireAdmin:
//
// /admin/api/me who is signed in
// /admin/api/ipcr/<path> ipcrd's admin API (gateway/admin.go), with its token
// POST /admin/api/key/export the publisher key, encrypted with the given passphrase
// GET /admin/api/repos Gitea's public repositories and whether each may publish images
// PUT /admin/api/repos {full_name, publish}
//
// It also keeps ipcrd's allowlist equal to "public Gitea repositories, minus those switched off":
// a build can push any image name to the staging registry, and only those names are published.
type adminAPI struct {
ipcr string // ipcrd's admin API, e.g. http://ipcr-gateway:4769
tokenFile string // ipcrd's admin-token, read through the read-only ipcr/state mount
keystore string // Kubo's keystore folder, read-only
publisher string // the publisher key's name (IMPORT_PUBLISHER)
settings string // admin.json: the repositories switched off, 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
mu sync.Mutex
}
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 {
var s adminSettings
if b, err := os.ReadFile(a.settings); err == nil {
json.Unmarshal(b, &s)
}
return s
}
func (a *adminAPI) token() string {
b, _ := os.ReadFile(a.tokenFile)
return strings.TrimSpace(string(b))
}
func (a *adminAPI) routes(mux *http.ServeMux, au *auth) {
a.au = au
guard := au.requireAdmin
mux.Handle("GET /admin/api/me", guard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, _ := au.user(r)
writeJSONResponse(w, map[string]string{"user": u, "publisher": a.publisher})
})))
mux.Handle("/admin/api/ipcr/{path...}", guard(http.HandlerFunc(a.proxy)))
mux.Handle("POST /admin/api/key/export", guard(http.HandlerFunc(a.export)))
mux.Handle("GET /admin/api/repos", guard(http.HandlerFunc(a.listRepos)))
mux.Handle("PUT /admin/api/repos", guard(http.HandlerFunc(a.setRepo)))
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) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(v)
}
// proxy forwards to ipcrd's admin API. Only its own paths: /admin/<path>.
func (a *adminAPI) proxy(w http.ResponseWriter, r *http.Request) {
p := r.PathValue("path")
if strings.Contains(p, "..") {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
u := a.ipcr + "/admin/" + p
if r.URL.RawQuery != "" {
u += "?" + r.URL.RawQuery
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, u, io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.token())
resp, err := a.http.Do(req)
if err != nil {
// Not 502: a proxy in front (Cloudflare) replaces a 502's body with its own page.
http.Error(w, "IPCR unreachable: "+err.Error(), http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
// keystoreFile is where Kubo keeps the key <name>: "key_" + lowercase unpadded base32 of the name.
func keystoreFile(dir, name string) string {
return filepath.Join(dir, "key_"+strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(name))))
}
// export reads the publisher key from Kubo's keystore and returns it sealed with the passphrase.
// The cleartext key exists only in this process's memory, for the length of the request.
func (a *adminAPI) export(w http.ResponseWriter, r *http.Request) {
var req struct {
Passphrase string `json:"passphrase"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
http.Error(w, "{passphrase}?", http.StatusBadRequest)
return
}
key, err := os.ReadFile(keystoreFile(a.keystore, a.publisher))
if err != nil {
log.Printf("admin: key export: %v", err)
http.Error(w, "cannot read the publisher key "+a.publisher+" (has anything been published yet?)", http.StatusNotFound)
return
}
b, err := sealKey(a.publisher, key, req.Passphrase)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
u, _ := a.au.user(r)
log.Printf("admin: publisher key %s (%s) exported by %s", a.publisher, b.ID, u)
name := fmt.Sprintf("%s-%s-%s.ipcrkey.json", a.publisher, b.ID[len(b.ID)-8:], time.Now().UTC().Format("20060102"))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(b)
}
type repoToggle struct {
FullName string `json:"full_name"`
Publish bool `json:"publish"`
}
// 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
}
disabled := map[string]bool{}
for _, d := range a.load().Disabled {
disabled[d] = true
}
out := []repoView{}
for _, rp := range repos {
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)
}
func (a *adminAPI) setRepo(w http.ResponseWriter, r *http.Request) {
var req repoToggle
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil || req.FullName == "" {
http.Error(w, "{full_name, publish}?", http.StatusBadRequest)
return
}
n := strings.ToLower(req.FullName)
a.mu.Lock()
s := a.load()
var keep []string
for _, d := range s.Disabled {
if d != n {
keep = append(keep, d)
}
}
if !req.Publish {
keep = append(keep, n)
}
sort.Strings(keep)
s.Disabled = keep
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, 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 }
// 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 {
p, _, err := a.currentPlan()
if err != nil {
return err
}
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(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, "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())
resp, err := a.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
return errors.New(resp.Status + " " + strings.TrimSpace(string(b)))
}
return nil
}
+239
View File
@@ -0,0 +1,239 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Admin login: Gitea is the forge's one identity system, so the admin pages sign in with it
// (OAuth2 authorization code + PKCE, an app the setup step registers) and let in Gitea site admins
// only. The Gitea token is used once, to read who signed in, and dropped; what remains is this
// service's own session cookie, HMAC-signed with a key kept in STATE_DIR.
//
// The browser goes to Gitea's public address, matching the host the page was opened on
// (ipcr-forge-<suffix> → gitea-<suffix>); the code is exchanged over the internal one.
type auth struct {
hosts map[string]bool // ipcr-forge-… hosts the page is served on (PUBLIC_HOSTS)
gitea string // internal address, for the code exchange and /api/v1/user
secretsDir string // oauth-client-id, oauth-client-secret (written by setup)
key []byte // session signing key
http *http.Client
}
const (
sessionCookie = "ipcr_admin"
oauthCookie = "ipcr_oauth"
sessionTTL = 8 * time.Hour
)
func newAuth(hosts []string, gitea, secretsDir, keyFile string) (*auth, error) {
key, err := loadOrCreateKey(keyFile)
if err != nil {
return nil, err
}
a := &auth{hosts: map[string]bool{}, gitea: strings.TrimRight(gitea, "/"), secretsDir: secretsDir, key: key,
http: &http.Client{Timeout: 30 * time.Second}}
for _, h := range hosts {
if h = strings.TrimSpace(h); h != "" {
a.hosts[h] = true
}
}
return a, nil
}
// loadOrCreateKey keeps the session key across restarts, so a restart does not sign everyone out.
func loadOrCreateKey(path string) ([]byte, error) {
if b, err := os.ReadFile(path); err == nil && len(b) >= 32 {
return b, nil
}
b := make([]byte, 32)
rand.Read(b)
return b, writeSecret(path, string(b))
}
func (a *auth) secret(name string) string {
b, _ := os.ReadFile(a.secretsDir + "/" + name)
return strings.TrimSpace(string(b))
}
// configured reports whether the setup step has registered the OAuth app yet.
func (a *auth) configured() bool {
return a.secret("oauth-client-id") != "" && a.secret("oauth-client-secret") != ""
}
func randomString(n int) string {
b := make([]byte, n)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// giteaPublic is Gitea's address for a browser on host: same suffix, gitea- instead of ipcr-forge-.
func giteaPublic(host string) string {
return "https://gitea-" + strings.TrimPrefix(host, "ipcr-forge-")
}
func (a *auth) login(w http.ResponseWriter, r *http.Request) {
if !a.hosts[r.Host] {
http.Error(w, "unknown host "+r.Host, http.StatusBadRequest)
return
}
if !a.configured() {
http.Error(w, "admin login is not set up yet: the forge-setup install step registers it on the next start", http.StatusServiceUnavailable)
return
}
state, verifier := randomString(16), randomString(32)
sum := sha256.Sum256([]byte(verifier))
http.SetCookie(w, &http.Cookie{Name: oauthCookie, Value: state + "." + verifier, Path: "/admin",
MaxAge: 600, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
q := url.Values{
"client_id": {a.secret("oauth-client-id")}, "redirect_uri": {"https://" + r.Host + "/admin/callback"},
"response_type": {"code"}, "state": {state}, "scope": {"read:user"},
"code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])}, "code_challenge_method": {"S256"},
}
http.Redirect(w, r, giteaPublic(r.Host)+"/login/oauth/authorize?"+q.Encode(), http.StatusFound)
}
func (a *auth) callback(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(oauthCookie)
state, verifier, _ := strings.Cut(func() string {
if err != nil {
return ""
}
return c.Value
}(), ".")
if state == "" || subtle.ConstantTimeCompare([]byte(state), []byte(r.FormValue("state"))) != 1 || !a.hosts[r.Host] {
http.Error(w, "login expired or not started here: open /admin again", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{Name: oauthCookie, Path: "/admin", MaxAge: -1})
if e := r.FormValue("error"); e != "" {
http.Error(w, "Gitea refused: "+e, http.StatusForbidden)
return
}
user, admin, err := a.exchange(r.FormValue("code"), verifier, "https://"+r.Host+"/admin/callback")
if err != nil {
log.Printf("admin login: %v", err)
http.Error(w, "login failed", http.StatusInternalServerError)
return
}
if !admin {
log.Printf("admin login: %s is not a Gitea administrator", user)
http.Error(w, user+" is not a Gitea administrator. Only Gitea site administrators can use these pages.", http.StatusForbidden)
return
}
log.Printf("admin login: %s", user)
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: a.sign(user, time.Now().Add(sessionTTL)), Path: "/",
MaxAge: int(sessionTTL.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
http.Redirect(w, r, "/admin", http.StatusFound)
}
// exchange trades the code for a token, then asks Gitea who that is. The token is not kept.
func (a *auth) exchange(code, verifier, redirect string) (string, bool, error) {
resp, err := a.http.PostForm(a.gitea+"/login/oauth/access_token", url.Values{
"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirect}, "code_verifier": {verifier},
"client_id": {a.secret("oauth-client-id")}, "client_secret": {a.secret("oauth-client-secret")},
})
if err != nil {
return "", false, err
}
var tok struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&tok)
resp.Body.Close()
if tok.AccessToken == "" {
return "", false, fmt.Errorf("token exchange: %s %s", resp.Status, tok.Error)
}
req, _ := http.NewRequest("GET", a.gitea+"/api/v1/user", nil)
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
resp, err = a.http.Do(req)
if err != nil {
return "", false, err
}
defer resp.Body.Close()
var u struct {
Login string `json:"login"`
IsAdmin bool `json:"is_admin"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&u) != nil || u.Login == "" {
return "", false, fmt.Errorf("/api/v1/user: %s", resp.Status)
}
return u.Login, u.IsAdmin, nil
}
func (a *auth) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// sign makes a session value: base64url(user|expiry).base64url(HMAC).
func (a *auth) sign(user string, exp time.Time) string {
payload := base64.RawURLEncoding.EncodeToString([]byte(user + "|" + fmt.Sprint(exp.Unix())))
m := hmac.New(sha256.New, a.key)
m.Write([]byte(payload))
return payload + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
var errNoSession = errors.New("not signed in")
// user returns who the session cookie belongs to, if it is valid and current.
func (a *auth) user(r *http.Request) (string, error) {
c, err := r.Cookie(sessionCookie)
if err != nil {
return "", errNoSession
}
payload, sig, _ := strings.Cut(c.Value, ".")
m := hmac.New(sha256.New, a.key)
m.Write([]byte(payload))
got, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil || !hmac.Equal(got, m.Sum(nil)) {
return "", errNoSession
}
raw, _ := base64.RawURLEncoding.DecodeString(payload)
user, exp, _ := strings.Cut(string(raw), "|")
var unix int64
if _, err := fmt.Sscan(exp, &unix); err != nil || time.Now().Unix() > unix || user == "" {
return "", errNoSession
}
return user, nil
}
// requireAdmin guards the admin API: a valid session, and for anything that changes state, a
// same-origin request carrying X-IPCR-Admin (a header a cross-site form cannot send, and a
// cross-site script cannot without a CORS preflight this service never answers).
func (a *auth) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := a.user(r); err != nil {
http.Error(w, "sign in at /admin", http.StatusUnauthorized)
return
}
if r.Method != "GET" && r.Method != "HEAD" && !sameOrigin(r) {
http.Error(w, "cross-site request refused", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func sameOrigin(r *http.Request) bool {
if r.Header.Get("X-IPCR-Admin") != "1" {
return false
}
if o := r.Header.Get("Origin"); o != "" {
return o == "https://"+r.Host
}
return r.Header.Get("Sec-Fetch-Site") == "same-origin"
}
+443
View File
@@ -0,0 +1,443 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func sign(secret, body string) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(body))
return hex.EncodeToString(m.Sum(nil))
}
func TestValidSignature(t *testing.T) {
body := `{"repository":{"id":7}}`
if !validSignature([]byte("s3cret"), []byte(body), sign("s3cret", body)) {
t.Error("good signature rejected")
}
for _, sig := range []string{"", "zz", sign("other", body), sign("s3cret", body+" ")} {
if validSignature([]byte("s3cret"), []byte(body), sig) {
t.Errorf("bad signature %q accepted", sig)
}
}
}
func TestHook(t *testing.T) {
dir := t.TempDir()
secretFile := filepath.Join(dir, "hook-secret")
var got []int64
h := &hookHandler{secretFile: secretFile, enqueue: func(id int64) { got = append(got, id) }}
post := func(body, sig string) int {
r := httptest.NewRequest("POST", "/hooks/gitea", strings.NewReader(body))
r.Header.Set("X-Gitea-Signature", sig)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
body := `{"ref":"refs/tags/v1.0.0","repository":{"id":42,"full_name":"a/b"}}`
if c := post(body, sign("k", body)); c != http.StatusServiceUnavailable {
t.Errorf("no secret yet: %d", c)
}
os.WriteFile(secretFile, []byte("k\n"), 0o600)
if c := post(body, sign("x", body)); c != http.StatusUnauthorized {
t.Errorf("bad signature: %d", c)
}
if c := post(body, sign("k", body)); c != http.StatusAccepted {
t.Errorf("good delivery: %d", c)
}
if c := post(`{"sender":{}}`, sign("k", `{"sender":{}}`)); c != http.StatusNoContent {
t.Errorf("no repository: %d", c)
}
if len(got) != 1 || got[0] != 42 {
t.Errorf("enqueued %v, want [42]", got)
}
}
func TestQueueCoalesces(t *testing.T) {
q := newQueue()
for _, id := range []int64{3, 1, 3, 3, 2, 1} {
q.add(id)
}
var order []int64
for {
id, ok := q.next()
if !ok {
break
}
order = append(order, id)
}
if len(order) != 3 || order[0] != 3 || order[1] != 1 || order[2] != 2 {
t.Errorf("order %v, want [3 1 2]", order)
}
q.add(3) // popped, so it may be queued again
if id, ok := q.next(); !ok || id != 3 {
t.Error("re-adding a popped repository")
}
}
func TestPublic(t *testing.T) {
r := repo{}
if !r.public() {
t.Error("plain repository")
}
for _, f := range []func(*repo){
func(r *repo) { r.Private = true },
func(r *repo) { r.Internal = true },
func(r *repo) { r.Owner.Visibility = "limited" },
func(r *repo) { r.Owner.Visibility = "private" },
} {
r := repo{}
f(&r)
if r.public() {
t.Errorf("%+v counted as public", r)
}
}
}
func TestRIDFromURL(t *testing.T) {
for in, want := range map[string]string{
"rad://z2pnYhouQzbiTEmXW9bydgXFqjP1o\n": "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o",
"rad://z2pnYhouQzbiTEmXW9bydgXFqjP1o/z6MkppQoVh2QKyQGkPzGgEaqZcXmbeBVhZseQ2cgxuMzA5gR": "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o",
"https://example.com/x.git": "",
"": "",
} {
if got := ridFromURL(in); got != want {
t.Errorf("ridFromURL(%q) = %q, want %q", in, got, want)
}
}
if got := ridRe.FindString("Your Repository ID (RID) is rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o\nYou can"); got != "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o" {
t.Errorf("ridRe: %q", got)
}
}
func TestPushedRefs(t *testing.T) {
out := "To rad://z2pn/z6Mk\n" +
"=\trefs/heads/main:refs/heads/main\t[up to date]\n" +
"*\trefs/tags/v1.0.0:refs/tags/v1.0.0\t[new tag]\n" +
"-\t:refs/heads/old\t[deleted]\n" +
"+\trefs/heads/dev:refs/heads/dev\t1111111...2222222 (forced update)\n" +
"Done\n"
got := strings.Join(pushedRefs(out), "; ")
want := "tags/v1.0.0 [new tag]; old [deleted]; dev 1111111...2222222 (forced update)"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
}
func TestStateRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "repos.json")
s, err := loadState(path)
if err != nil {
t.Fatal(err)
}
if s.known(5) || s.get(5).ID != 5 {
t.Fatal("empty state")
}
s.put(repoState{ID: 5, FullName: "b/two", RID: "rad:z2", State: stSynced, Refs: "abc", Crefs: true})
s.put(repoState{ID: 9, FullName: "a/one", State: stWaiting})
s2, err := loadState(path)
if err != nil {
t.Fatal(err)
}
if r := s2.get(5); r.RID != "rad:z2" || r.Refs != "abc" || !r.Crefs || r.State != stSynced {
t.Errorf("reloaded %+v", r)
}
l := s2.list()
if len(l) != 2 || l[0].FullName != "a/one" || l[1].Refs != "" {
t.Errorf("list %+v", l)
}
if ids := s2.ids(); len(ids) != 2 || ids[0] != 5 || ids[1] != 9 {
t.Errorf("ids %v", ids)
}
}
func TestExampleEmbedded(t *testing.T) {
for _, p := range []string{"example/Dockerfile", "example/.gitea/workflows/build.yml", "example/README.md"} {
if _, err := exampleFiles.ReadFile(p); err != nil {
t.Errorf("%s: %v", p, err)
}
}
b, _ := exampleFiles.ReadFile("example/.gitea/workflows/build.yml")
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")
}
}
func TestWeb(t *testing.T) {
dir := t.TempDir()
st, _ := loadState(filepath.Join(dir, "repos.json"))
h := webHandler(st, filepath.Join(dir, "published.json"))
get := func(p string) (int, string) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", p, nil))
return w.Code, w.Body.String()
}
if c, b := get("/"); c != 200 || !strings.Contains(b, "IPCR Forge") {
t.Errorf("/: %d", c)
}
if c, _ := get("/images.json"); c != 404 {
t.Errorf("/images.json before anything is published: %d", c)
}
os.WriteFile(filepath.Join(dir, "published.json"), []byte(`{"images":{}}`), 0o644)
if c, b := get("/images.json"); c != 200 || b != `{"images":{}}` {
t.Errorf("/images.json: %d %q", c, b)
}
if c, b := get("/repos.json"); c != 200 || strings.TrimSpace(b) != "[]" {
t.Errorf("/repos.json: %d %q", c, b)
}
if c, _ := get("/nothing"); c != 404 {
t.Errorf("/nothing: %d", c)
}
}
// The same vector as gateway/admin_test.go: a Kubo key and its name, and a backup of it that ipcrd
// must be able to open.
const (
vectorKey = "CAESQEgAFnJaC//4NMPXmGjBx4fMcn1cRGFhAV2reN7GqcyS+z5e/JsuHUwhZneaa52YACcp2T6p2ri/FfdyILTLfWo="
vectorID = "k51qzi5uqu5dmg0qkz1yhcmegr2nf4jc7q8v9xw1mqv2p6211xzntxps7n6dm2"
vectorPass = "correct horse battery staple"
vectorBackup = `{"v":1,"kdf":"pbkdf2-sha256","iter":600000,"salt":"5mqcBpjiFkE3IVdNeqL6Bw==","nonce":"xKVdz0QswnMpkRz3","name":"forge","id":"k51qzi5uqu5dmg0qkz1yhcmegr2nf4jc7q8v9xw1mqv2p6211xzntxps7n6dm2","format":"libp2p-protobuf-cleartext","ct":"k9Hq6zuS1jmKA13OeIZpGuK4M5pUIKA0F4n0mQrFq5J8E/Q+RlRzu91PoTJGiwR/zUeOG14uO138MG3pPVqTzrFklRcN21yEGyeImaUf0EdWJPUX"}`
)
func TestKeyBackupVector(t *testing.T) {
key, _ := base64.StdEncoding.DecodeString(vectorKey)
var fixed keyBackup
json.Unmarshal([]byte(vectorBackup), &fixed)
if got, err := openKey(&fixed, vectorPass); err != nil || !bytes.Equal(got, key) {
t.Fatalf("fixed vector: %v", err)
}
b, err := sealKey("forge", key, "a passphrase long enough")
if err != nil || b.ID != vectorID {
t.Fatalf("seal: %v %s", err, b.ID)
}
if got, err := openKey(b, "a passphrase long enough"); err != nil || !bytes.Equal(got, key) {
t.Fatalf("round trip: %v", err)
}
}
func TestKeystoreFile(t *testing.T) {
if got := keystoreFile("/ks", "forge"); got != "/ks/key_mzxxez3f" {
t.Errorf("forge → %s, want /ks/key_mzxxez3f", got)
}
}
func TestExport(t *testing.T) {
dir := t.TempDir()
key, _ := base64.StdEncoding.DecodeString(vectorKey)
os.WriteFile(keystoreFile(dir, "forge"), key, 0o400)
au, _ := newAuth([]string{"ipcr-forge-x.example"}, "http://gitea", dir, filepath.Join(dir, "session-key"))
a := &adminAPI{keystore: dir, publisher: "forge", au: au}
r := httptest.NewRequest("POST", "/admin/api/key/export", strings.NewReader(`{"passphrase":"long enough passphrase"}`))
w := httptest.NewRecorder()
a.export(w, r)
if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Disposition"), ".ipcrkey.json") {
t.Fatalf("export: %d %s", w.Code, w.Body)
}
var b keyBackup
json.Unmarshal(w.Body.Bytes(), &b)
if got, err := openKey(&b, "long enough passphrase"); err != nil || !bytes.Equal(got, key) || b.ID != vectorID {
t.Fatalf("exported backup does not open: %v", err)
}
w = httptest.NewRecorder()
a.export(w, httptest.NewRequest("POST", "/", strings.NewReader(`{"passphrase":"short"}`)))
if w.Code != 400 {
t.Errorf("short passphrase: %d", w.Code)
}
}
func TestSession(t *testing.T) {
dir := t.TempDir()
au, err := newAuth(nil, "", dir, filepath.Join(dir, "k"))
if err != nil {
t.Fatal(err)
}
req := func(v string) *http.Request {
r := httptest.NewRequest("GET", "/admin", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: v})
return r
}
good := au.sign("root", time.Now().Add(time.Hour))
if u, err := au.user(req(good)); err != nil || u != "root" {
t.Errorf("good session: %q %v", u, err)
}
if _, err := au.user(req(au.sign("root", time.Now().Add(-time.Minute)))); err == nil {
t.Error("expired session accepted")
}
forged := base64.RawURLEncoding.EncodeToString([]byte("eve|99999999999")) + good[strings.Index(good, "."):]
if _, err := au.user(req(forged)); err == nil {
t.Error("edited session accepted")
}
// The key survives a restart.
au2, _ := newAuth(nil, "", dir, filepath.Join(dir, "k"))
if _, err := au2.user(req(good)); err != nil {
t.Error("session lost after restart")
}
}
func TestRequireAdmin(t *testing.T) {
dir := t.TempDir()
au, _ := newAuth(nil, "", dir, filepath.Join(dir, "k"))
h := au.requireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
cookie := &http.Cookie{Name: sessionCookie, Value: au.sign("root", time.Now().Add(time.Hour))}
try := func(method string, hdr map[string]string, withCookie bool) int {
r := httptest.NewRequest(method, "https://ipcr-forge-x.example/admin/api/repos", nil)
r.Host = "ipcr-forge-x.example"
for k, v := range hdr {
r.Header.Set(k, v)
}
if withCookie {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
same := map[string]string{"X-IPCR-Admin": "1", "Origin": "https://ipcr-forge-x.example"}
for _, c := range []struct {
name string
method string
hdr map[string]string
cookie bool
want int
}{
{"no session", "GET", nil, false, 401},
{"read", "GET", nil, true, 200},
{"write, same origin", "PUT", same, true, 200},
{"write, no header", "PUT", map[string]string{"Origin": "https://ipcr-forge-x.example"}, true, 403},
{"write, other origin", "PUT", map[string]string{"X-IPCR-Admin": "1", "Origin": "https://evil.example"}, true, 403},
{"write, fetch metadata", "POST", map[string]string{"X-IPCR-Admin": "1", "Sec-Fetch-Site": "same-origin"}, true, 200},
} {
if got := try(c.method, c.hdr, c.cookie); got != c.want {
t.Errorf("%s: %d, want %d", c.name, got, c.want)
}
}
}
// The login round trip against a fake Gitea: an administrator gets a session, anyone else a 403.
func TestCallback(t *testing.T) {
admin := true
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login/oauth/access_token":
r.ParseForm()
if r.Form.Get("code") != "the-code" || r.Form.Get("code_verifier") == "" || r.Form.Get("client_secret") != "s3cret" ||
r.Form.Get("redirect_uri") != "https://ipcr-forge-x.example/admin/callback" {
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
return
}
json.NewEncoder(w).Encode(map[string]string{"access_token": "tok"})
case "/api/v1/user":
if r.Header.Get("Authorization") != "Bearer tok" {
w.WriteHeader(401)
return
}
json.NewEncoder(w).Encode(map[string]any{"login": "root", "is_admin": admin})
}
}))
defer gitea.Close()
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "oauth-client-id"), []byte("cid"), 0o600)
os.WriteFile(filepath.Join(dir, "oauth-client-secret"), []byte("s3cret"), 0o600)
au, _ := newAuth([]string{"ipcr-forge-x.example"}, gitea.URL, dir, filepath.Join(dir, "k"))
login := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/admin/login", nil)
r.Host = "ipcr-forge-x.example"
au.login(login, r)
loc, _ := url.Parse(login.Header().Get("Location"))
if login.Code != 302 || loc.Host != "gitea-x.example" || loc.Query().Get("code_challenge_method") != "S256" {
t.Fatalf("login redirect: %d %s", login.Code, loc)
}
state := loc.Query().Get("state")
oauth := login.Result().Cookies()[0]
call := func(state string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/admin/callback?code=the-code&state="+state, nil)
r.Host = "ipcr-forge-x.example"
r.AddCookie(oauth)
w := httptest.NewRecorder()
au.callback(w, r)
return w
}
if w := call("wrong"); w.Code != 400 {
t.Errorf("wrong state: %d", w.Code)
}
w := call(state)
if w.Code != 302 || w.Header().Get("Location") != "/admin" {
t.Fatalf("admin callback: %d %s", w.Code, w.Body)
}
var session string
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookie {
session = c.Value
}
}
if u, err := au.user(func() *http.Request {
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: session})
return r
}()); err != nil || u != "root" {
t.Errorf("session after login: %q %v", u, err)
}
admin = false
if w := call(state); w.Code != 403 {
t.Errorf("non-admin: %d", w.Code)
}
// Unknown host: no redirect to Gitea.
r = httptest.NewRequest("GET", "/admin/login", nil)
r.Host = "evil.example"
w = httptest.NewRecorder()
au.login(w, r)
if w.Code != 400 {
t.Errorf("unknown host: %d", w.Code)
}
}
func TestPlan(t *testing.T) {
mk := func(name string, f func(*repo)) repo {
r := repo{FullName: name}
if f != nil {
f(&r)
}
return r
}
repos := []repo{
mk("Owner/App", nil),
mk("owner/off", nil),
mk("owner/secret", func(r *repo) { r.Private = true }),
mk("owner/fork", func(r *repo) { r.Fork = true }),
mk("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),
}
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)
}
}
+39
View File
@@ -0,0 +1,39 @@
# Builds the Dockerfile at the root for every v* tag and pushes it to this forge's staging registry.
# 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, 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:
push:
tags: ['v*']
jobs:
image:
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:
images: localhost:5000/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
- uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+6
View File
@@ -0,0 +1,6 @@
# A one-page web server: busybox httpd on port 8080, as an unprivileged user.
FROM busybox:1.37
COPY index.html /www/index.html
USER 1000:1000
EXPOSE 8080
CMD ["httpd", "-f", "-p", "8080", "-h", "/www"]
+34
View File
@@ -0,0 +1,34 @@
# ipcr-hello
The IPCR Forge example. A tag on this repository builds its image, IPCR publishes it on IPFS,
and the repository is mirrored to Radicle.
## Try it
1. **Tag a release.** In Gitea: *Tags → New tag* (or *Releases → New release*), name it `v1.0.0`
and target `main`. From a clone it works too:
```sh
git tag v1.0.0 && git push origin v1.0.0
```
2. **Watch the build** under *Actions*. The first build on a new server takes a few minutes more,
while it downloads its build images.
3. **Find the image** on the forge's page (`ipcr-forge-<your domain>`, *Published images*), about
a minute after the build finishes. Then on this server or on any other running IPCR:
```yaml
services:
hello:
image: ipcr.localhost:4767/ipns/<forge name>/gitea_admin/ipcr-hello:1.0.0
ports: ["8080:8080"]
```
## What makes it work
- [`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) pushes to `localhost:5000`, the
forge's staging registry. IPCR imports every tag it finds there.
- [`Dockerfile`](Dockerfile) is an ordinary Dockerfile. Anything that builds works.
Copy the workflow into any public or private repository on this forge to publish it the same way.
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ipcr-hello</title></head>
<body style="font: 18px/1.6 system-ui, sans-serif; max-width: 40em; margin: 4em auto; padding: 0 1em">
<h1>Hello from IPFS</h1>
<p>This page is served by a container whose image was built by IPCR Forge and pulled from IPFS
through IPCR.</p>
</body>
</html>
+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)
}
}
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// gitea is a minimal Gitea API client. The daemon authenticates with the token in tokenFile (read
// on every call: the setup step may write it after the service starts); the setup step with the
// admin's password instead (Gitea's token API accepts nothing else).
type gitea struct {
base string
owner string
tokenFile string
password string // setup only
http *http.Client
}
func newGitea(base, owner, tokenFile string) *gitea {
return &gitea{base: strings.TrimRight(base, "/"), owner: owner, tokenFile: tokenFile,
http: &http.Client{Timeout: 30 * time.Second}}
}
func (g *gitea) token() string {
b, _ := os.ReadFile(g.tokenFile)
return strings.TrimSpace(string(b))
}
func (g *gitea) hasToken() bool { return g.token() != "" }
// gitAuth is the header git sends to Gitea's HTTP endpoint: basic auth with the token as password,
// passed with http.extraHeader so it never appears in a URL, `ps` or a log.
func (g *gitea) gitAuth() string {
return "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(g.owner+":"+g.token()))
}
// call sends a request and decodes a JSON answer into out (when non-nil and the status is 2xx).
// It returns the status; a transport error is returned as an error with status 0.
func (g *gitea) call(method, path string, body, out any) (int, error) {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, g.base+"/api/v1"+path, rd)
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if g.password != "" {
req.SetBasicAuth(g.owner, g.password)
} else {
req.Header.Set("Authorization", "token "+g.token())
}
resp, err := g.http.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode/100 != 2 {
msg := strings.TrimSpace(string(data))
if len(msg) > 300 {
msg = msg[:300]
}
return resp.StatusCode, fmt.Errorf("%s %s: %s %s", method, path, resp.Status, msg)
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return resp.StatusCode, fmt.Errorf("%s %s: %w", method, path, err)
}
}
return resp.StatusCode, nil
}
type repo struct {
ID int64 `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Private bool `json:"private"`
Internal bool `json:"internal"`
Fork bool `json:"fork"`
Mirror bool `json:"mirror"`
Empty bool `json:"empty"`
DefaultBranch string `json:"default_branch"`
Owner struct {
Login string `json:"login"`
// public | limited | private. Only a public owner's repositories are public.
Visibility string `json:"visibility"`
} `json:"owner"`
}
// public reports whether the repository may leave this server. The token is `public-only`, so
// Gitea already hides everything else; this is the second lock on the same door.
func (r *repo) public() bool {
return !r.Private && !r.Internal && (r.Owner.Visibility == "" || r.Owner.Visibility == "public")
}
// publicRepos lists every repository the token can see.
func (g *gitea) publicRepos() ([]repo, error) {
var all []repo
for page := 1; ; page++ {
var res struct {
OK bool `json:"ok"`
Data []repo `json:"data"`
}
q := url.Values{"limit": {"50"}, "page": {fmt.Sprint(page)}}
if _, err := g.call("GET", "/repos/search?"+q.Encode(), nil, &res); err != nil {
return nil, err
}
all = append(all, res.Data...)
if len(res.Data) < 50 {
return all, nil
}
}
}
// repoByID returns the repository, or nil when the token cannot see it (private, deleted).
func (g *gitea) repoByID(id int64) (*repo, error) {
var r repo
code, err := g.call("GET", fmt.Sprintf("/repositories/%d", id), nil, &r)
if code == http.StatusNotFound || code == http.StatusForbidden {
return nil, nil
}
if err != nil {
return nil, err
}
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)
}
+3
View File
@@ -0,0 +1,3 @@
module ipcr-forge-bridge
go 1.24
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
)
// hookHandler receives Gitea's system webhook. It only learns *which* repository changed: the sync
// itself re-reads everything from Gitea, so a payload is never trusted for content, and a burst of
// deliveries for one repository collapses into one sync.
type hookHandler struct {
secretFile string
enqueue func(id int64)
}
func (h *hookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
secret, _ := os.ReadFile(h.secretFile)
if len(strings.TrimSpace(string(secret))) == 0 {
http.Error(w, "not configured yet", http.StatusServiceUnavailable)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 32<<20))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
if !validSignature([]byte(strings.TrimSpace(string(secret))), body, r.Header.Get("X-Gitea-Signature")) {
log.Printf("hook: bad signature from %s (event %q)", r.RemoteAddr, r.Header.Get("X-Gitea-Event"))
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var p struct {
Repository *struct {
ID int64 `json:"id"`
} `json:"repository"`
}
if err := json.Unmarshal(body, &p); err != nil || p.Repository == nil || p.Repository.ID == 0 {
// Signed, but about no repository (a user or organisation event): nothing to do.
w.WriteHeader(http.StatusNoContent)
return
}
h.enqueue(p.Repository.ID)
// Answered at once: Gitea gives a delivery 5 seconds, a sync can take minutes.
w.WriteHeader(http.StatusAccepted)
}
// validSignature checks Gitea's X-Gitea-Signature: hex HMAC-SHA256 of the raw body, no prefix.
func validSignature(secret, body []byte, sig string) bool {
got, err := hex.DecodeString(strings.TrimSpace(sig))
if err != nil || len(got) == 0 {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write(body)
return hmac.Equal(got, mac.Sum(nil))
}
+130
View File
@@ -0,0 +1,130 @@
package main
// Publisher key backups. The IPNS key a publisher's name belongs to (e.g. the forge's `forge` key)
// exists only in Kubo's keystore: lose the disk and the name is gone for good. A backup is that
// key, encrypted with a passphrase, in a small JSON file:
//
// {"v":1, "kdf":"pbkdf2-sha256", "iter":600000, "salt":…, "nonce":…,
// "name":"forge", "id":"k51…", "format":"libp2p-protobuf-cleartext", "ct":…}
//
// ct = AES-256-GCM(key = PBKDF2-SHA256(passphrase, salt, iter, 32), nonce, plaintext,
// additional data = "ipcrkey/v1|<name>|<id>"), the plaintext being the key exactly as Kubo stores
// and exports it (`ipfs key export`, libp2p-protobuf-cleartext). The additional data binds name and
// id to the ciphertext, so neither can be edited. Byte slices are base64 (encoding/json's default).
//
// A copy of gateway/keybackup.go (ipcrd), without its CLI: the bridge writes backups (it can read
// Kubo's keystore; ipcrd cannot) and ipcrd reads them, so the cleartext key never crosses the
// network. Keep the two identical; both test the same vector.
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"math/big"
)
type keyBackup struct {
V int `json:"v"`
KDF string `json:"kdf"`
Iter int `json:"iter"`
Salt []byte `json:"salt"`
Nonce []byte `json:"nonce"`
Name string `json:"name"`
ID string `json:"id"`
Format string `json:"format"`
CT []byte `json:"ct"`
}
const (
backupIter = 600_000
backupFormat = "libp2p-protobuf-cleartext"
)
func (b *keyBackup) aad() []byte { return []byte("ipcrkey/v1|" + b.Name + "|" + b.ID) }
func backupCipher(pass string, salt []byte, iter int) (cipher.AEAD, error) {
k, err := pbkdf2.Key(sha256.New, pass, salt, iter, 32)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(k)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
// sealKey encrypts the Kubo key `key` (libp2p protobuf), named `name` in the keystore.
func sealKey(name string, key []byte, pass string) (*keyBackup, error) {
if len(pass) < 12 {
return nil, errors.New("passphrase: at least 12 characters")
}
id, err := ipnsName(key)
if err != nil {
return nil, err
}
b := &keyBackup{V: 1, KDF: "pbkdf2-sha256", Iter: backupIter, Name: name, ID: id, Format: backupFormat,
Salt: make([]byte, 16), Nonce: make([]byte, 12)}
rand.Read(b.Salt)
rand.Read(b.Nonce)
gcm, err := backupCipher(pass, b.Salt, b.Iter)
if err != nil {
return nil, err
}
b.CT = gcm.Seal(nil, b.Nonce, key, b.aad())
return b, nil
}
// openKey decrypts a backup and checks that the key is the one its id names.
func openKey(b *keyBackup, pass string) ([]byte, error) {
if b.V != 1 || b.KDF != "pbkdf2-sha256" || b.Format != backupFormat {
return nil, fmt.Errorf("not an IPCR key backup (v%d, %s, %s)", b.V, b.KDF, b.Format)
}
if b.Iter < 100_000 || b.Iter > 10_000_000 || len(b.Salt) < 16 || len(b.Nonce) != 12 {
return nil, errors.New("key backup: bad parameters")
}
gcm, err := backupCipher(pass, b.Salt, b.Iter)
if err != nil {
return nil, err
}
key, err := gcm.Open(nil, b.Nonce, b.CT, b.aad())
if err != nil {
return nil, errors.New("key backup: wrong passphrase, or the file was altered")
}
id, err := ipnsName(key)
if err != nil {
return nil, err
}
if id != b.ID {
return nil, fmt.Errorf("key backup: the key is %s, the file says %s", id, b.ID)
}
return key, nil
}
// ipnsName returns the IPNS name (k51…) of an ed25519 key in libp2p's private-key protobuf:
// field 1 (type, 1 = Ed25519), field 2 (seed ‖ public key; 64 bytes, or 96 in keys from old
// libp2p versions, which repeat the public key). The name is the base36 CIDv1 (codec libp2p-key)
// of the identity multihash of the public key's protobuf — what Kubo prints with
// `--ipns-base base36`.
func ipnsName(priv []byte) (string, error) {
if len(priv) < 4 || priv[0] != 0x08 || priv[1] != 0x01 || priv[2] != 0x12 || int(priv[3]) != len(priv)-4 {
return "", errors.New("not an ed25519 libp2p private key (only ed25519 IPNS keys are supported)")
}
data := priv[4:]
if len(data) != 64 && len(data) != 96 {
return "", fmt.Errorf("ed25519 key: %d bytes", len(data))
}
seed, pub := data[:32], data[32:64]
if !bytes.Equal(ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey), pub) {
return "", errors.New("ed25519 key: public half does not match")
}
pubProto := append([]byte{0x08, 0x01, 0x12, 0x20}, pub...)
cid := append([]byte{0x01, 0x72, 0x00, byte(len(pubProto))}, pubProto...)
return "k" + new(big.Int).SetBytes(cid).Text(36), nil
}
+209
View File
@@ -0,0 +1,209 @@
// ipcr-forge-bridge — 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.
//
// 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.
// Each one is `rad init`ed by this node on first sight (the node's key is its only delegate) and
// then receives its branches and tags with `git push rad`. A repository that turns private or is
// deleted is frozen: no further sync, and nothing is taken back from the network (it cannot be).
//
// Triggers: Gitea's system webhook (push, create, delete, repository) for latency, and a full
// reconcile every RECONCILE for whatever a lost delivery, a rename or a visibility change missed.
//
// Environment:
//
// GITEA_URL Gitea, internal address (default http://gitea:3000)
// GITEA_OWNER the admin account (default gitea_admin)
// GITEA_PASSWORD setup only: its password, used once to mint the token and the hook
// SECRETS_DIR gitea-token and hook-secret (default /secrets)
// STATE_DIR repos.json and the bare mirrors (default /bridge)
// RAD_HOME the node's Radicle home (default /radicle-home)
// WEB_LISTEN the page (default :8080)
// HOOK_LISTEN the webhook, internal only (default :8081)
// HOOK_URL setup only: how Gitea reaches HOOK_LISTEN (default http://ipcr-forge:8081/hooks/gitea)
// EXAMPLE_REPO setup only: example repository to create under GITEA_OWNER; empty = none
// (default ipcr-hello)
// IPCR_PUBLISHED IPCR's published.json, served as /images.json
// (default /srv/ipcr-state/published.json)
// RECONCILE full pass interval (default 10m)
//
// Admin pages (/admin, auth.go and admin.go), on when IPCR_ADMIN is set:
//
// PUBLIC_HOSTS the hosts the page is served on (ipcr-forge-<domain>, …), comma separated;
// setup registers one OAuth2 redirect URI per host
// IPCR_ADMIN ipcrd's admin API (e.g. http://ipcr-gateway:4769)
// IPCR_ADMIN_TOKEN_FILE its token (default /srv/ipcr-state/admin-token)
// KUBO_KEYSTORE Kubo's keystore, read-only, for key backups (default /srv/ipcr-keystore)
// IMPORT_PUBLISHER the publisher key's name (default forge)
// 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 (
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
cmd := "serve"
if len(os.Args) > 1 {
cmd = os.Args[1]
}
var err error
switch cmd {
case "serve":
err = serve()
case "setup":
err = setup()
case "health":
err = health()
case "gate":
err = gateMain()
default:
err = fmt.Errorf("usage: %s serve | setup | health | gate", os.Args[0])
}
if err != nil {
log.Fatal(err)
}
}
func health() error {
c := &http.Client{Timeout: 5 * time.Second}
r, err := c.Get("http://127.0.0.1" + env("WEB_LISTEN", ":8080") + "/healthz")
if err != nil {
return err
}
r.Body.Close()
if r.StatusCode != http.StatusOK {
return fmt.Errorf("healthz: %s", r.Status)
}
return nil
}
func serve() error {
every, err := time.ParseDuration(env("RECONCILE", "10m"))
if err != nil {
return fmt.Errorf("RECONCILE: %w", err)
}
stateDir := env("STATE_DIR", "/bridge")
st, err := loadState(stateDir + "/repos.json")
if err != nil {
return err
}
secrets := env("SECRETS_DIR", "/secrets")
m := &mirror{
gitea: newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), secrets+"/gitea-token"),
state: st,
repoDir: stateDir + "/repos",
radHome: env("RAD_HOME", "/radicle-home"),
}
if err := os.MkdirAll(m.repoDir, 0o755); err != nil {
return err
}
q := newQueue()
go q.run(m.sync)
hook := &hookHandler{secretFile: secrets + "/hook-secret", enqueue: q.add}
hmux := http.NewServeMux()
hmux.Handle("POST /hooks/gitea", hook)
go func() {
log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux))
}()
mux := webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json"))
if ipcr := os.Getenv("IPCR_ADMIN"); ipcr != "" {
au, err := newAuth(splitList(env("PUBLIC_HOSTS", "")), m.gitea.base, secrets, stateDir+"/session-key")
if err != nil {
return err
}
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, 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
// both ends are up, then after every reconcile.
go func() {
for {
if !m.gitea.hasToken() {
time.Sleep(15 * time.Second)
continue
}
err := adm.pushAllowlist()
if err == nil {
return
}
log.Printf("allowlist: %v (retrying)", err)
time.Sleep(30 * time.Second)
}
}()
m.onReconcile = func() {
if err := adm.pushAllowlist(); err != nil {
log.Printf("allowlist: %v", err)
}
}
log.Printf("bridge: admin pages on, IPCR admin API %s", ipcr)
}
go reconcileLoop(m, q, every)
log.Printf("bridge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every)
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), mux)
}
// reconcileLoop queues every repository Gitea shows as public, plus every one already known (so one
// that turned private or was deleted is noticed and frozen). The first pass runs at start-up.
func reconcileLoop(m *mirror, q *queue, every time.Duration) {
for {
if !m.gitea.hasToken() {
// The setup step runs after the stack is up, so on a first install this is normal.
log.Printf("reconcile: waiting for the Gitea token (written by the setup step)")
time.Sleep(15 * time.Second)
continue
}
repos, err := m.gitea.publicRepos()
if err != nil {
// Gitea restarting, typically: try again soon rather than in a full interval.
log.Printf("reconcile: %v", err)
time.Sleep(time.Minute)
continue
}
seen := map[int64]bool{}
for _, r := range repos {
seen[r.ID] = true
q.add(r.ID)
}
for _, id := range m.state.ids() {
if !seen[id] {
q.add(id)
}
}
if m.onReconcile != nil {
m.onReconcile()
}
time.Sleep(every)
}
}
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
)
// What is copied, both ways of the bare mirror: branches and tags. Not refs/pull/* (Gitea's PR
// heads), not notes or anything else. Branches and tags are forced: Gitea is the truth, and a
// force push or a moved tag there is mirrored as such. --prune makes deletions follow too.
var refspecs = []string{"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
type mirror struct {
gitea *gitea
state *state
repoDir string // one bare repository per Gitea repository: <id>.git
radHome string
// Called after each reconcile pass (the admin pages keep IPCR's allowlist in step).
onReconcile func()
}
// sync brings one Gitea repository's Radicle copy up to date, and records the outcome.
func (m *mirror) sync(id int64) {
prev := m.state.get(id)
r, err := m.gitea.repoByID(id)
if err != nil {
log.Printf("sync %d: %v", id, err)
return
}
if r == nil || !r.public() {
// Private now, or deleted. Only a repository that was mirrored has anything to record.
if m.state.known(id) && prev.State != stFrozen {
prev.State = stFrozen
prev.Error = "no longer public in Gitea: not synced any more (what was published stays on the Radicle network)"
m.save(prev)
log.Printf("sync %s: frozen, no longer public", prev.FullName)
}
return
}
if r.Fork || r.Mirror {
// Did not originate here: a fork's upstream or a mirror's source is the one to publish.
return
}
cur := prev
cur.FullName = r.FullName
if r.Empty {
if cur.RID == "" {
cur.State, cur.Error = stWaiting, ""
m.save(cur)
}
return
}
if err := m.publish(r, &cur); err != nil {
cur.State, cur.Error = stError, err.Error()
log.Printf("sync %s: %v", r.FullName, err)
} else {
cur.State, cur.Error, cur.LastSync = stSynced, "", time.Now().UTC()
}
m.save(cur)
}
func (m *mirror) save(r repoState) {
if err := m.state.put(r); err != nil {
log.Printf("state: %v", err)
}
}
// publish fetches the repository from Gitea into its bare mirror, creates its Radicle repository on
// first sight, and pushes what changed.
func (m *mirror) publish(r *repo, cur *repoState) error {
dir := filepath.Join(m.repoDir, fmt.Sprintf("%d.git", r.ID))
if _, err := os.Stat(dir); err != nil {
if _, err := m.git("", "init", "--quiet", "--bare", dir); err != nil {
return err
}
}
src := m.gitea.base + "/" + r.FullName + ".git"
args := append([]string{"-c", "http.extraHeader=" + m.gitea.gitAuth(), "fetch", "--quiet", "--prune", "--no-tags", src}, refspecs...)
if _, err := m.git(dir, args...); err != nil {
return fmt.Errorf("fetch from Gitea: %w", err)
}
// rad init and the project's default branch read HEAD.
if _, err := m.git(dir, "symbolic-ref", "HEAD", "refs/heads/"+r.DefaultBranch); err != nil {
return err
}
digest, err := m.refsDigest(dir)
if err != nil {
return err
}
if cur.RID == "" {
// A previous attempt may have created it and failed afterwards: rad init leaves the remote.
if rid := m.remoteRID(dir); rid != "" {
cur.RID = rid
} else {
rid, err := m.radInit(dir, r)
if err != nil {
return fmt.Errorf("rad init: %w", err)
}
cur.RID = rid
log.Printf("sync %s: created %s", r.FullName, rid)
}
cur.DefaultBranch = r.DefaultBranch
m.save(*cur) // before anything else can fail: never init the same repository twice
} else if m.remoteRID(dir) == "" {
// The mirror was deleted (it is disposable): point a fresh one at the existing repository.
if err := m.addRemote(dir, cur.RID); err != nil {
return err
}
}
if !cur.Crefs {
if err := m.radCanonicalTags(cur.RID); err != nil {
log.Printf("sync %s: canonical tags rule: %v (tags stay under the node's namespace)", r.FullName, err)
} else {
cur.Crefs = true
}
}
if digest == cur.Refs && cur.State == stSynced && cur.DefaultBranch == r.DefaultBranch {
return nil
}
out, err := m.git(dir, append([]string{"push", "--prune", "--porcelain", "-o", "no-sync", "rad"}, refspecs...)...)
if err != nil {
return fmt.Errorf("push to Radicle: %w", err)
}
if changed := pushedRefs(out); len(changed) > 0 {
log.Printf("sync %s → %s: %s", r.FullName, cur.RID, strings.Join(changed, ", "))
}
if cur.DefaultBranch != r.DefaultBranch {
if err := m.radDefaultBranch(cur.RID, r.DefaultBranch); err != nil {
return fmt.Errorf("default branch: %w", err)
}
cur.DefaultBranch = r.DefaultBranch
}
cur.Refs = digest
m.announce(cur.RID)
return nil
}
// refsDigest summarises the mirror's branches and tags; equal digests mean nothing to push.
func (m *mirror) refsDigest(dir string) (string, error) {
out, err := m.git(dir, "for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/tags")
if err != nil {
return "", err
}
sum := sha256.Sum256([]byte(out))
return hex.EncodeToString(sum[:]), nil
}
// pushedRefs reads `git push --porcelain` output and returns the refs that changed, for the log.
// Porcelain lines are "<flag>\t<from>:<to>\t<summary>"; '=' is up to date.
func pushedRefs(out string) []string {
var changed []string
for _, line := range strings.Split(out, "\n") {
f := strings.Split(line, "\t")
if len(f) < 3 || len(f[0]) != 1 || f[0] == "=" {
continue
}
to := f[1]
if i := strings.LastIndexByte(to, ':'); i >= 0 {
to = to[i+1:]
}
changed = append(changed, strings.TrimPrefix(strings.TrimPrefix(to, "refs/heads/"), "refs/")+" "+f[2])
}
return changed
}
+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)
}
+54
View File
@@ -0,0 +1,54 @@
package main
import "sync"
// queue holds the repositories waiting for a sync, each at most once, served by one worker: a
// burst of webhooks for one repository costs one sync, and two syncs never race on a mirror or on
// the node's storage.
type queue struct {
mu sync.Mutex
order []int64
pending map[int64]bool
wake chan struct{}
}
func newQueue() *queue {
return &queue{pending: map[int64]bool{}, wake: make(chan struct{}, 1)}
}
func (q *queue) add(id int64) {
q.mu.Lock()
if !q.pending[id] {
q.pending[id] = true
q.order = append(q.order, id)
}
q.mu.Unlock()
select {
case q.wake <- struct{}{}:
default:
}
}
// next pops the oldest repository, or reports false when the queue is empty.
func (q *queue) next() (int64, bool) {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.order) == 0 {
return 0, false
}
id := q.order[0]
q.order = q.order[1:]
delete(q.pending, id)
return id, true
}
func (q *queue) run(work func(id int64)) {
for {
id, ok := q.next()
if !ok {
<-q.wake
continue
}
work(id)
}
}
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"bytes"
"context"
"fmt"
"log"
"os"
"os/exec"
"regexp"
"strings"
"time"
)
// Everything Radicle is done with the stock `rad` CLI and `git-remote-rad`, against the node's own
// home: the repository is created and signed by the node's key (its only delegate), written into
// the node's storage, and announced through the node's control socket. The key has no passphrase
// (the install step creates it that way), so nothing prompts.
var ridRe = regexp.MustCompile(`rad:z[1-9A-HJ-NP-Za-km-z]+`)
func (m *mirror) env() []string {
return append(os.Environ(),
"RAD_HOME="+m.radHome,
"GIT_TERMINAL_PROMPT=0",
// rad's output is read by people in the container log, not by a terminal.
"NO_COLOR=1",
// Identity documents are git commits; git wants a name even when rad signs them.
"GIT_AUTHOR_NAME=ipcr-forge", "GIT_AUTHOR_EMAIL=ipcr-forge@localhost",
"GIT_COMMITTER_NAME=ipcr-forge", "GIT_COMMITTER_EMAIL=ipcr-forge@localhost",
)
}
// run executes a command in dir (when set) and returns its stdout; on failure the error carries
// the end of stderr, which is where git and rad explain themselves.
func (m *mirror) run(timeout time.Duration, dir, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
cmd.Env = m.env()
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
// rad reports its errors on stdout.
msg = strings.TrimSpace(stdout.String())
}
if len(msg) > 500 {
msg = "…" + msg[len(msg)-500:]
}
// Never echo an argument list: a fetch carries the Gitea token in one.
return stdout.String(), fmt.Errorf("%s %s: %v: %s", name, args[0], err, msg)
}
return stdout.String(), nil
}
func (m *mirror) git(dir string, args ...string) (string, error) {
return m.run(10*time.Minute, dir, "git", args...)
}
func (m *mirror) rad(dir string, args ...string) (string, error) {
return m.run(2*time.Minute, dir, "rad", args...)
}
// radInit creates the Radicle repository for r from its bare mirror and returns its RID. It also
// 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) {
// 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")
if err != nil {
return "", err
}
if rid := m.remoteRID(dir); rid != "" {
return rid, nil
}
if rid := ridRe.FindString(out); rid != "" {
return rid, nil
}
return "", fmt.Errorf("no repository ID in rad init's output")
}
// remoteRID reads the RID from the mirror's `rad` remote (rad://z…), or "" when there is none.
func (m *mirror) remoteRID(dir string) string {
out, err := m.git(dir, "config", "--get", "remote.rad.url")
if err != nil {
return ""
}
return ridFromURL(out)
}
func ridFromURL(u string) string {
u = strings.TrimSpace(u)
if !strings.HasPrefix(u, "rad://") {
return ""
}
z, _, _ := strings.Cut(strings.TrimPrefix(u, "rad://"), "/")
return "rad:" + z
}
// addRemote recreates what rad init sets up: fetch from the repository, push into this node's
// namespace of it.
func (m *mirror) addRemote(dir, rid string) error {
nid, err := m.rad("", "self", "--nid")
if err != nil {
return err
}
z := strings.TrimPrefix(rid, "rad:")
if _, err := m.git(dir, "remote", "add", "rad", "rad://"+z); err != nil {
return err
}
_, err = m.git(dir, "config", "remote.rad.pushurl", "rad://"+z+"/"+strings.TrimSpace(nid))
return err
}
// radCanonicalTags adds the rule that makes a delegate's tags the repository's refs/tags/*. Without
// it a tag exists only under the node's namespace, where `rad clone` and the explorer do not show
// it. One delegate (this node), so a threshold of 1.
func (m *mirror) radCanonicalTags(rid string) error {
_, err := m.rad("", "id", "update", "--repo", rid, "--no-confirm", "--title", "Canonical tags",
"--description", "Tags pushed by the forge are the repository's tags.",
"--payload", "xyz.radicle.crefs", "rules", `{"refs/tags/*":{"threshold":1,"allow":"delegates"}}`)
return err
}
func (m *mirror) radDefaultBranch(rid, branch string) error {
_, err := m.rad("", "id", "update", "--repo", rid, "--no-confirm", "--title", "Default branch",
"--description", "Follows the Gitea repository.",
"--payload", "xyz.radicle.project", "defaultBranch", fmt.Sprintf("%q", branch))
return err
}
// announce tells the network about the new refs. Best effort: a node without peers is not an error,
// and the node announces again on its own schedule.
func (m *mirror) announce(rid string) {
if _, err := m.rad("", "sync", rid, "--announce", "--timeout", "30s"); err != nil {
log.Printf("announce %s: %v", rid, err)
}
}
+329
View File
@@ -0,0 +1,329 @@
package main
import (
"crypto/rand"
"embed"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
//go:embed all:example
var exampleFiles embed.FS
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
// Gitea's token API accepts nothing else, and leaves:
//
// SECRETS_DIR/gitea-token public-only + read:repository: the daemon lists and clones public
// repositories, and cannot see any other. Minted only when missing.
// SECRETS_DIR/hook-secret the webhook's HMAC key. Generated only when missing.
// a system webhook every repository's push/create/delete/repository events → HOOK_URL,
// created or repaired (matched by URL), never duplicated.
// 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.
//
// The admin password is used here only and never stored. If it was changed since install, the
// step leaves the existing token and hook alone instead of failing the start.
func setup() error {
secrets := env("SECRETS_DIR", "/secrets")
g := newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), filepath.Join(secrets, "gitea-token"))
g.password = os.Getenv("GITEA_PASSWORD")
if g.password == "" {
return errors.New("GITEA_PASSWORD is required")
}
// Gitea may still be migrating, and its admin account may not exist yet. Bounded: an install
// step that blocks forever holds the install open forever.
var code int
var err error
for i := 0; i < 80; i++ {
if code, err = g.call("GET", "/user", nil, nil); err == nil {
break
}
if code == http.StatusUnauthorized && g.hasToken() {
log.Printf("setup: the admin password no longer works; keeping the existing token and webhook")
return nil
}
time.Sleep(3 * time.Second)
}
if err != nil {
return fmt.Errorf("Gitea not ready after 240s: %w", err)
}
if !g.hasToken() {
g.call("DELETE", "/users/"+g.owner+"/tokens/"+tokenName, nil, nil)
var t struct {
SHA1 string `json:"sha1"`
}
if _, err := g.call("POST", "/users/"+g.owner+"/tokens", map[string]any{
"name": tokenName, "scopes": []string{"public-only", "read:repository"},
}, &t); err != nil {
return fmt.Errorf("token: %w", err)
}
if err := writeSecret(g.tokenFile, t.SHA1); err != nil {
return err
}
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)) == "" {
b := make([]byte, 32)
rand.Read(b)
secret = []byte(hex.EncodeToString(b))
if err := writeSecret(secretFile, string(secret)); err != nil {
return err
}
}
if err := ensureHook(g, env("HOOK_URL", "http://ipcr-forge:8081/hooks/gitea"), strings.TrimSpace(string(secret))); err != nil {
return fmt.Errorf("webhook: %w", err)
}
if hosts := splitList(env("PUBLIC_HOSTS", "")); len(hosts) > 0 {
if err := ensureOAuthApp(g, hosts, secrets); err != nil {
return fmt.Errorf("admin login (OAuth2 app): %w", err)
}
}
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
// Not fatal: the forge works without its example.
if err := ensureExample(g, owner, name); err != nil {
log.Printf("setup: example repository: %v", err)
}
}
return nil
}
func writeSecret(path, value string) error {
tmp := path + ".new"
if err := os.WriteFile(tmp, []byte(value), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ensureHook creates the system webhook, or rewrites the one already pointing at url (Gitea never
// returns a hook's secret, so it is set every time rather than compared).
func ensureHook(g *gitea, url, secret string) error {
config := map[string]string{"url": url, "content_type": "json", "secret": secret, "is_system_webhook": "true"}
events := []string{"push", "create", "delete", "repository"}
var hooks []struct {
ID int64 `json:"id"`
Config map[string]string `json:"config"`
}
if _, err := g.call("GET", "/admin/hooks?type=all&limit=50", nil, &hooks); err != nil {
return err
}
for _, h := range hooks {
if h.Config["url"] == url {
_, err := g.call("PATCH", fmt.Sprintf("/admin/hooks/%d", h.ID), map[string]any{
"config": config, "events": events, "active": true, "branch_filter": "*",
}, nil)
return err
}
}
_, err := g.call("POST", "/admin/hooks", map[string]any{
"type": "gitea", "config": config, "events": events, "active": true, "branch_filter": "*",
}, nil)
if err == nil {
log.Printf("setup: system webhook → %s", url)
}
return err
}
// 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, owner, name string) error {
var r repo
code, err := g.call("GET", "/repos/"+owner+"/"+name, nil, &r)
switch {
case code == http.StatusNotFound:
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", owner, name)
case err != nil:
return err
case !r.Empty:
return nil
}
var files []map[string]string
err = fs.WalkDir(exampleFiles, "example", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := exampleFiles.ReadFile(p)
if err != nil {
return err
}
files = append(files, map[string]string{
"operation": "create", "path": strings.TrimPrefix(p, "example/"),
"content": base64.StdEncoding.EncodeToString(b),
})
return nil
})
if err != nil {
return err
}
_, 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
}
const oauthAppName = "ipcr-forge-admin"
func splitList(s string) []string {
var out []string
for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) {
out = append(out, f)
}
return out
}
// ensureOAuthApp registers the admin pages as a Gitea OAuth2 application, one redirect URI per host
// the page is served on. Gitea shows a client secret only when the app is created, so a missing
// secret file means re-creating the app.
func ensureOAuthApp(g *gitea, hosts []string, secrets string) error {
var redirects []string
for _, h := range hosts {
redirects = append(redirects, "https://"+h+"/admin/callback")
}
body := map[string]any{"name": oauthAppName, "redirect_uris": redirects,
"confidential_client": true, "skip_secondary_authorization": true}
var apps []struct {
ID int64 `json:"id"`
Name string `json:"name"`
ClientID string `json:"client_id"`
RedirectURIs []string `json:"redirect_uris"`
}
if _, err := g.call("GET", "/user/applications/oauth2?limit=50", nil, &apps); err != nil {
return err
}
idFile, secretFile := filepath.Join(secrets, "oauth-client-id"), filepath.Join(secrets, "oauth-client-secret")
haveSecret := false
if b, err := os.ReadFile(secretFile); err == nil && len(strings.TrimSpace(string(b))) > 0 {
haveSecret = true
}
for _, app := range apps {
if app.Name != oauthAppName {
continue
}
if haveSecret {
// Keep it, and its secret. 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
}
}
var created struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
if _, err := g.call("POST", "/user/applications/oauth2", body, &created); err != nil {
return err
}
if created.ClientID == "" || created.ClientSecret == "" {
return errors.New("Gitea returned no client credentials")
}
if err := writeSecret(idFile, created.ClientID); err != nil {
return err
}
if err := writeSecret(secretFile, created.ClientSecret); err != nil {
return err
}
log.Printf("setup: OAuth2 app %s for %s", oauthAppName, strings.Join(redirects, ", "))
return nil
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
)
// Repository states, as shown on the page.
const (
stSynced = "synced" // Radicle has what Gitea has
stError = "error" // the last sync failed; retried on the next event or reconcile
stWaiting = "waiting" // empty repository: nothing to publish yet
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
// rename or a transfer keeps its Radicle ID.
type repoState struct {
ID int64 `json:"id"`
FullName string `json:"full_name"`
RID string `json:"rid,omitempty"`
DefaultBranch string `json:"default_branch,omitempty"`
State string `json:"state"`
Error string `json:"error,omitempty"`
LastSync time.Time `json:"last_sync,omitzero"`
// A digest of the refs last pushed: a reconcile that finds the same refs pushes nothing.
Refs string `json:"refs,omitempty"`
// The canonical-tags rule is in the repository's identity (see rad.go).
Crefs bool `json:"crefs,omitempty"`
}
type state struct {
mu sync.Mutex
path string
repos map[int64]*repoState
}
func loadState(path string) (*state, error) {
s := &state{path: path, repos: map[int64]*repoState{}}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
var m map[string]*repoState
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
for _, r := range m {
s.repos[r.ID] = r
}
return s, nil
}
// get returns a copy of the repository's state (a zero one, with the ID set, when unknown).
func (s *state) get(id int64) repoState {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.repos[id]; ok {
return *r
}
return repoState{ID: id}
}
func (s *state) known(id int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.repos[id]
return ok
}
// put stores r and writes the file (renamed into place, so a reader never sees half of it).
func (s *state) put(r repoState) error {
s.mu.Lock()
defer s.mu.Unlock()
s.repos[r.ID] = &r
m := make(map[string]*repoState, len(s.repos))
for id, v := range s.repos {
m[strconv.FormatInt(id, 10)] = v
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
tmp := filepath.Join(filepath.Dir(s.path), "."+filepath.Base(s.path)+".new")
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *state) ids() []int64 {
s.mu.Lock()
defer s.mu.Unlock()
ids := make([]int64, 0, len(s.repos))
for id := range s.repos {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
// list is the page's view: every repository, by name, without internal bookkeeping.
func (s *state) list() []repoState {
s.mu.Lock()
out := make([]repoState, 0, len(s.repos))
for _, r := range s.repos {
v := *r
v.Refs = ""
out = append(out, v)
}
s.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].FullName < out[j].FullName })
return out
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"embed"
"encoding/json"
"net/http"
"os"
)
//go:embed web
var webFiles embed.FS
// webHandler serves the forge's public page. Everything on it is public anyway: links, the
// documentation, IPCR's list of published images (public on IPFS) and the list of mirrored
// repositories (public on Radicle). Nothing here changes anything.
func webHandler(st *state, publishedFile string) *http.ServeMux {
mux := http.NewServeMux()
page, _ := webFiles.ReadFile("web/index.html")
icon, _ := webFiles.ReadFile("web/icon.png")
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Write(page)
})
mux.HandleFunc("GET /icon.png", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "max-age=3600")
w.Write(icon)
})
mux.HandleFunc("GET /images.json", func(w http.ResponseWriter, r *http.Request) {
b, err := os.ReadFile(publishedFile)
if err != nil {
http.Error(w, "nothing published yet", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Write(b)
})
mux.HandleFunc("GET /repos.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(st.list())
})
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok\n"))
})
return mux
}
// adminRoutes adds the admin pages: the page itself (signed-in Gitea admins only), the login, and
// the API it calls.
func adminRoutes(mux *http.ServeMux, au *auth, adm *adminAPI) {
page, _ := webFiles.ReadFile("web/admin.html")
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
if _, err := au.user(r); err != nil {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Frame-Options", "DENY")
w.Write(page)
})
mux.HandleFunc("GET /admin/login", au.login)
mux.HandleFunc("GET /admin/callback", au.callback)
mux.HandleFunc("POST /admin/logout", func(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "cross-site request refused", http.StatusForbidden)
return
}
au.logout(w, r)
})
adm.routes(mux, au)
}
+603
View File
@@ -0,0 +1,603 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IPCR Forge admin</title>
<link rel="icon" href="icon.png">
<!--
The forge's admin page: Gitea site administrators only (the bridge checks the session for the
page and for every API call). Everything goes through /admin/api/: the bridge's own endpoints
(repositories, key export) and IPCR's admin API behind /admin/api/ipcr/.
-->
<style>
:root {
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
--accent: #0f766e; --code: #f1f4f8; --ok: #15803d; --warn: #b45309; --bad: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
--accent: #2dd4bf; --code: #0d1626; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 1080px; margin: 0 auto; padding: 24px 16px 64px; }
header { display: flex; gap: 14px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
header .title { display: flex; gap: 12px; align-items: center; }
header img { width: 40px; height: 40px; }
h1 { font-size: 24px; margin: 0; }
h2 { font-size: 19px; margin: 32px 0 10px; }
h3 { font-size: 15px; margin: 14px 0 6px; }
p { margin: 6px 0; }
a { color: var(--accent); }
.muted { color: var(--muted); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; min-width: 0; }
.card h3 { margin-top: 0; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--muted); }
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12.5px; background: var(--code);
padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
.ok { color: var(--ok); font-weight: 600; } .warn { color: var(--warn); font-weight: 600; } .bad { color: var(--bad); font-weight: 600; }
button, input[type=submit] { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
font: inherit; font-size: 13px; padding: 4px 10px; cursor: pointer; }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.danger { color: var(--bad); }
button:disabled { opacity: .5; cursor: default; }
input[type=text], input[type=password], input[type=number], input[type=file] { font: inherit; font-size: 14px; padding: 5px 8px;
border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); max-width: 100%; }
form.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 12.5px; }
.scroll { overflow-x: auto; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.bar { height: 8px; background: var(--code); border-radius: 4px; overflow: hidden; margin: 6px 0; }
.bar span { display: block; height: 100%; background: var(--accent); }
.note { border-left: 3px solid var(--warn); padding: 4px 12px; color: var(--muted); margin: 8px 0; }
.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>
</head>
<body>
<main>
<header>
<div class="title"><img src="icon.png" alt=""><div><h1>IPCR Forge admin</h1>
<div class="muted"><a href="/">Forge page</a> · signed in as <b id="me">…</b></div></div></div>
<button id="logout">Sign out</button>
</header>
<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 wide">
<h3>Key</h3>
<dl>
<dt>Key</dt><dd><code id="key-name">…</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 (Key backup tab): it exists only on this server.</p>
</div>
<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="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 =</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 class="card wide">
<h3>Public pulls</h3>
<p class="muted">This forge's images, and only those, can also be pulled from any machine with plain Docker, without IPCR,
through <code id="public-host">ipcr-…</code>. Read-only; a pull there pins nothing. Layers are still checked by digest.</p>
<form class="row" id="public-form">
<label><input type="checkbox" id="public-on"> Serve public pulls</label>
<span id="public-state" class="muted"></span>
</form>
<div class="copyrow"><code id="public-try">docker pull …</code><button class="copy" data-copy="public-try">Copy</button></div>
</div>
</div>
<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>
<dl id="record"></dl>
<div class="actions" style="margin-top:8px"><button id="republish">Republish now</button></div>
<p class="muted">The record must be renewed by this node while it is online (Kubo does it every 4 h). Lifetime is how long a
record stays valid if this node goes offline; TTL is how long resolvers cache it.</p>
</div>
<div class="card">
<h3>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>Which repositories may publish</h2>
<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">
<h3>Download a backup</h3>
<p class="muted">The key, encrypted with your passphrase (at least 12 characters). Keep the file and the passphrase apart.
Without a backup, losing this server loses the name for good.</p>
<form class="row" id="export-form">
<input type="password" id="ex-pass" placeholder="passphrase" autocomplete="new-password" required>
<input type="password" id="ex-pass2" placeholder="again" autocomplete="new-password" required>
<input type="submit" value="Download">
</form>
</div>
<div class="card">
<h3>Restore a backup</h3>
<p class="muted">Makes the backed-up key this forge's publisher key: to move a forge to a new server, or recover one.
The current key is kept (renamed), not deleted.</p>
<form class="row" id="import-form">
<input type="file" id="im-file" accept=".json,application/json" required>
<input type="password" id="im-pass" placeholder="passphrase" autocomplete="off" required>
<input type="number" id="im-seq" placeholder="sequence (only if asked)" min="1">
<input type="submit" value="Restore">
</form>
<p class="note">Every image name then points at this server's images under the restored name. On a fresh server, the
name's last published images are adopted.</p>
</div>
</div>
</section>
</main>
<div id="toast"></div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
var publisher = "", pubName = "";
// The public front door's host: same suffix as this page, ipcr- instead of ipcr-forge-.
var publicHost = location.hostname.indexOf("ipcr-forge-") === 0 ? "ipcr-" + location.hostname.slice("ipcr-forge-".length) : "";
function toast(msg, ms) {
var t = $("toast"); t.textContent = msg; t.style.display = "block";
clearTimeout(toast.t); toast.t = setTimeout(function () { t.style.display = "none"; }, ms || 4000);
}
// Every call: same origin, with the header the bridge requires on writes.
function api(method, path, body) {
var opt = { method: method, headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin", cache: "no-store" };
if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); }
return fetch("/admin/api/" + path, opt).then(function (r) {
if (r.status === 401) { location.href = "/admin/login"; throw new Error("signed out"); }
return r.text().then(function (t) {
var j = null; try { j = JSON.parse(t); } catch (e) {}
if (!r.ok) throw new Error((j && j.error) || t || r.statusText);
return j;
});
});
}
function el(tag, text, cls) { var e = document.createElement(tag); if (text != null) e.textContent = text; if (cls) e.className = cls; return e; }
function dl(target, rows) {
target.textContent = "";
rows.forEach(function (r) {
target.appendChild(el("dt", r[0]));
var dd = el("dd");
if (r[1] instanceof Node) dd.appendChild(r[1]); else dd.textContent = r[1] == null ? "—" : r[1];
target.appendChild(dd);
});
}
function badge(text, cls) { return el("span", text, cls); }
function size(n) {
if (!n) return "0";
var u = ["B", "KB", "MB", "GB", "TB"], i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(i ? 1 : 0) + " " + u[i];
}
function until(iso) {
if (!iso) return "";
var h = (Date.parse(iso) - Date.now()) / 3600000;
if (h < 0) return "expired";
return h > 48 ? Math.round(h / 24) + " days left" : Math.round(h) + " h left";
}
function busy(btn, p) {
btn.disabled = true;
return p.then(function (v) { btn.disabled = false; return v; }, function (e) { btn.disabled = false; toast(e.message, 8000); throw e; });
}
api("GET", "me").then(function (me) { $("me").textContent = me.user; });
$("logout").onclick = function () {
fetch("/admin/logout", { method: "POST", headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin" })
.then(function () { location.href = "/"; });
};
// ---- 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");
var s = el("span");
s.appendChild(badge(r.valid ? "valid" : "invalid", r.valid ? "ok" : "bad"));
s.appendChild(document.createTextNode(" · sequence " + r.sequence + " · " + until(r.validity)));
if (tree && r.value !== tree) { s.appendChild(document.createTextNode(" · ")); s.appendChild(badge("older tree", "warn")); }
return s;
}
function 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 (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 = "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)],
["Network", recordView(rec.network, rec.networkError, rec.tree)],
["Lifetime", rec.lifetime], ["TTL", rec.ttl],
["Checked", s.checkedAt.replace("T", " ").replace("Z", " UTC")]
]);
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) { st8.textContent = ""; toast("Status: " + e.message, 8000); });
}
function loadPublic() {
$("public-host").textContent = publicHost || "ipcr-<domain>";
return api("GET", "ipcr/config").then(function (c) {
$("public-on").checked = !!c.public;
$("public-state").textContent = c.public ? "on" : "off";
$("public-try").textContent = "docker pull " + (publicHost || "ipcr-<domain>") + "/ipns/" + (pubName || publisher || "k51…") + "/<repo>:<tag>";
});
}
$("public-on").onchange = function () {
var cb = this, st = $("public-state");
cb.disabled = true; st.textContent = ""; st.appendChild(spinner("saving…"));
api("PATCH", "ipcr/config", { public: cb.checked })
.then(function (c) { toast(c.public ? "Public pulls on (within 10 s)" : "Public pulls off (within 10 s)"); return loadPublic(); },
function (e) { cb.checked = !cb.checked; toast(e.message, 8000); })
.then(function () { cb.disabled = false; loadImages(); });
};
$("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) {
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("✓ 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]"), 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 (d.public && publicHost && (d.name || d.publisher)) rows.push(["Public", publicHost + "/ipns/" + (d.name || d.publisher) + "/" + 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")); $("images-count").textContent = "0"; return; }
d.images.forEach(function (img) {
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"));
card.appendChild(h);
img.tags.forEach(function (tg) {
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("✓ 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") {
var lat = el("button", "Make latest");
lat.onclick = function () {
busy(lat, api("POST", "ipcr/retag", { repo: img.repo, tag: "latest", from: tg.tag })).then(function () { toast(img.repo + ":latest → " + tg.tag); loadImages(); });
};
acts.appendChild(lat);
}
var un = el("button", "Unpublish", "danger");
un.onclick = function () {
if (!confirm("Unpublish " + img.repo + ":" + tg.tag + "?\n\nThe name stops resolving to it. Servers that already pulled it keep their copy, and its CID keeps working wherever it is still pinned.")) return;
busy(un, api("POST", "ipcr/unpublish", { repo: img.repo, tag: tg.tag })).then(function (r) {
toast("Unpublished (" + r.unpinned + " objects unpinned)"); loadImages(); loadStatus(true);
});
};
acts.appendChild(un);
head.appendChild(left); head.appendChild(acts);
t.appendChild(head);
t.appendChild(pullCommands(reg, d, img, tg));
card.appendChild(t);
});
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", "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"), 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 () { 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(); });
};
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; });
}
// ---- key backup
$("export-form").onsubmit = function (e) {
e.preventDefault();
var p = $("ex-pass").value;
if (p !== $("ex-pass2").value) { toast("The passphrases differ"); return; }
if (p.length < 12) { toast("At least 12 characters"); return; }
var btn = this.querySelector("input[type=submit]");
btn.disabled = true;
fetch("/admin/api/key/export", { method: "POST", credentials: "same-origin",
headers: { "X-IPCR-Admin": "1", "Content-Type": "application/json" }, body: JSON.stringify({ passphrase: p }) })
.then(function (r) {
if (!r.ok) return r.text().then(function (t) { throw new Error(t); });
var name = (r.headers.get("Content-Disposition") || "").replace(/.*filename="([^"]+)".*/, "$1") || "publisher.ipcrkey.json";
return r.blob().then(function (b) {
var a = document.createElement("a"); a.href = URL.createObjectURL(b); a.download = name;
document.body.appendChild(a); a.click(); a.remove();
$("ex-pass").value = $("ex-pass2").value = "";
toast("Saved " + name);
});
})
.catch(function (e) { toast(e.message, 8000); })
.then(function () { btn.disabled = false; });
};
$("import-form").onsubmit = function (e) {
e.preventDefault();
var f = $("im-file").files[0], btn = this.querySelector("input[type=submit]");
if (!f) return;
f.text().then(function (txt) {
var backup; try { backup = JSON.parse(txt); } catch (x) { throw new Error("Not a key backup file"); }
if (!confirm("Make " + backup.id + " this forge's publisher key?\n\nThe current key (" + (publisher || "none") + ") is kept, renamed. Pull lines with the current IPNS name stop following new releases.")) return;
var body = { backup: backup, passphrase: $("im-pass").value };
if ($("im-seq").value) body.sequence = parseInt($("im-seq").value, 10);
return busy(btn, api("POST", "ipcr/key/import", body)).then(function (r) {
toast(r.status || ("Publisher key is now " + r.id + (r.adopted ? " (network tree adopted)" : "")), 8000);
$("im-pass").value = ""; loadStatus(true); loadImages();
});
}).catch(function (e) { toast(e.message, 8000); });
};
loadStatus(false).then(loadPublic); loadImages(); loadRepos(); loadSettings();
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

+410
View File
@@ -0,0 +1,410 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IPCR Forge</title>
<link rel="icon" href="icon.png">
<!--
Served by the forge's bridge (ipcr-forge-bridge, embedded in its binary). Every address is
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.
Live data: /images.json (IPCR's published.json), /repos.json (the bridge's Gitea → Radicle
mirror state) and the Radicle API's node info.
-->
<style>
:root {
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
--accent: #0f766e; --accent-2: #1e3a8a; --code: #f1f4f8; --ok: #15803d; --warn: #b45309; --bad: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
--accent: #2dd4bf; --accent-2: #93c5fd; --code: #0d1626; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 980px; margin: 0 auto; padding: 32px 16px 64px; }
header { display: flex; gap: 18px; align-items: center; margin-bottom: 8px; }
header img { width: 64px; height: 64px; }
h1 { font-size: 30px; margin: 0; letter-spacing: -0.01em; }
h2 { font-size: 21px; margin: 40px 0 12px; }
h3 { font-size: 16px; margin: 20px 0 6px; }
p { margin: 8px 0; }
.lede { color: var(--muted); font-size: 18px; margin: 4px 0 24px; }
a { color: var(--accent); }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
text-decoration: none; color: inherit; display: block; }
a.card:hover { border-color: var(--accent); }
.card b { display: block; font-size: 17px; margin-bottom: 4px; color: var(--accent); }
.card span { color: var(--muted); font-size: 14px; }
.pipe { display: flex; flex-wrap: wrap; align-items: stretch; gap: 8px; margin: 12px 0; }
.step { flex: 1 1 140px; background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 10px 12px; font-size: 14px; }
.step b { display: block; font-size: 15px; }
.step small { color: var(--muted); }
.arrow { align-self: center; color: var(--muted); }
pre, code { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 13.5px; }
code { background: var(--code); padding: 1px 5px; border-radius: 4px; }
pre { background: var(--code); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
overflow-x: auto; line-height: 1.5; }
pre code { background: none; padding: 0; }
ol { padding-left: 22px; }
li { margin: 6px 0; }
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line);
border-radius: 10px; overflow: hidden; font-size: 14px; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 13px; }
tr:last-child td { border-bottom: 0; }
td code { word-break: break-all; }
button.copy { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
font-size: 12px; padding: 3px 8px; cursor: pointer; white-space: nowrap; }
.muted { color: var(--muted); }
.note { border-left: 3px solid var(--accent); padding: 4px 12px; color: var(--muted); }
.scroll { overflow-x: auto; }
.state { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
.state.synced { color: var(--ok); } .state.waiting, .state.frozen { color: var(--warn); } .state.error { color: var(--bad); }
footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<main>
<header>
<img src="icon.png" alt="">
<div>
<h1>IPCR Forge</h1>
<div class="muted" id="host"></div>
</div>
</header>
<p class="lede">From <code>git push</code> to <code>docker pull</code> on any server. Work in Gitea as
usual: its Actions build your images, IPCR serves them from IPFS, and every public repository is
mirrored to the Radicle peer-to-peer network.</p>
<div class="cards">
<a class="card" id="link-gitea" href="#"><b>Gitea</b><span>Your repositories, pull requests, issues and builds. Sign in as <code>gitea_admin</code>.</span></a>
<a class="card" href="#images"><b>Published images</b><span>What this forge serves from IPFS, with the line to put in a compose file.</span></a>
<a class="card" href="#repos"><b>On Radicle</b><span>The public repositories mirrored to the Radicle network. Public.</span></a>
</div>
<h2 id="how">How it works</h2>
<div class="pipe">
<div class="step"><b>1 · Push</b><small><code>git push</code> a <code>v*</code> tag to Gitea, or create it in Gitea's web UI</small></div>
<div class="arrow">→</div>
<div class="step"><b>2 · Build</b><small>Gitea Actions runs <code>.gitea/workflows/</code> and pushes the image to <code>localhost:5000</code></small></div>
<div class="arrow">→</div>
<div class="step"><b>3 · IPFS</b><small>IPCR imports each tag and publishes it under this forge's IPNS name</small></div>
<div class="arrow">→</div>
<div class="step"><b>4 · Pull</b><small>any server with IPCR: <code>ipcr.localhost:4767/ipns/…</code></small></div>
</div>
<p>Steps 2 and 3 are automatic and usually take a few minutes. The very first build on this server
takes longer (about 5 to 10 minutes) while it downloads its build images.</p>
<p>Alongside, every <b>public</b> repository is copied to <a class="radicle-link" href="#">Radicle</a>
within seconds of each push: branches and tags, signed by this server's Radicle node. Gitea stays
the one place to work. The Radicle copy is for distribution: anyone can <code>rad clone</code> it,
and it stays available even when this server is not. Private repositories are never copied.</p>
<h2 id="example">Try it with the example</h2>
<p>The install created <a id="link-hello" href="#"><code>gitea_admin/ipcr-hello</code></a>, a tiny web
page with a Dockerfile and the workflow that publishes it.</p>
<ol>
<li>Open it in Gitea, then <b>Tags → New tag</b> (or <b>Releases → New release</b>): name
<code>v1.0.0</code>, target <code>main</code>.</li>
<li>Follow the build under the repository's <b>Actions</b> tab.</li>
<li>About a minute after it finishes, the image appears below under
<a href="#images">Published images</a>, and the tag on <a href="#repos">Radicle</a>.</li>
</ol>
<h2 id="images">Published images</h2>
<div id="images-body"><p class="muted">Loading…</p></div>
<h2 id="publish">Publish your own project</h2>
<ol>
<li><b>Create a repository</b> in <a class="gitea-link" href="#">Gitea</a> (<b>+ → New repository</b>)
and push your code to it:
<pre><code id="clone-cmd">git remote add origin https://gitea-&lt;domain&gt;/&lt;owner&gt;/&lt;repo&gt;.git
git push -u origin main</code></pre>
Git asks for your Gitea user name and password (or an access token, from <b>Settings →
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 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:
tags: ['v*']
jobs:
image:
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:
images: localhost:5000/&#36;{{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
- uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: &#36;{{ steps.meta.outputs.tags }}
labels: &#36;{{ steps.meta.outputs.labels }}</code></pre></li>
<li><b>Release:</b>
<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.
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: 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
server and on any server running IPCR:</p>
<pre><code>services:
myapp:
image: ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/&lt;owner&gt;/&lt;repo&gt;:1.0.0 # a tag: follows new builds of 1.0.0
# image: ipcr.localhost:4767/ipfs/bafkrei… # a CID: this exact image, forever</code></pre>
<p>Or pull it by hand: <code>docker pull ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/&lt;owner&gt;/&lt;repo&gt;:1.0.0</code>.
<code>/ipns/…</code> is a name this forge can move to a new build; <code>/ipfs/&lt;cid&gt;</code> is the
image's content address and can never change.</p>
<p class="note">For a readable name, point a name you own at this forge's publisher name
(<code id="publisher-name">k51…</code>), which holds every image it builds:</p>
<ul>
<li><b>ENS:</b> set the content hash of <code>example.eth</code> to
<code>ipns://<span class="publisher">k51…</span></code> (one transaction, once). Images are then
<code>ipcr.localhost:4767/ipns/example.eth/&lt;owner&gt;/&lt;repo&gt;:1.0.0</code>.</li>
<li><b>DNS:</b> add a TXT record <code>_dnslink.example.com</code> =
<code>dnslink=/ipns/<span class="publisher">k51…</span></code>. Images are then
<code>ipcr.localhost:4767/ipns/example.com/&lt;owner&gt;/&lt;repo&gt;:1.0.0</code>.</li>
</ul>
<p class="muted">New releases need no change to either record: they update the publisher name.</p>
<h2 id="repos">On Radicle</h2>
<p>Public repositories of this forge, as mirrored to the Radicle network by this server's node
(<code id="node-id">…</code>). Clone one with <code>rad clone &lt;id&gt;</code> from any Radicle node.</p>
<div id="repos-body"><p class="muted">Loading…</p></div>
<p class="muted">A repository that turns private or is deleted in Gitea is no longer synced
(<i>frozen</i>). What was already published stays on the Radicle network: it cannot be taken back.</p>
<h2 id="anywhere">Pull without IPCR</h2>
<p>To try an image on a machine without IPCR, pull it through this forge's public front door, with plain Docker
and nothing to install:</p>
<pre><code id="anywhere-cmd">docker pull ipcr-&lt;domain&gt;/ipns/&lt;name&gt;/&lt;repo&gt;:1.0.0</code></pre>
<p class="muted">It serves this forge's images only, read-only, and depends on this server being online. For
anything that should keep working without it, use IPCR's local address (below): any IPCR node can serve the image.</p>
<h2 id="install">Pull from another server</h2>
<p>Any server with IPCR resolves the same lines and fetches the image from the IPFS network,
from this server and from every other server that already pulled it.</p>
<h3>On a Yundera server</h3>
<p>Install <b>IPCR</b> from the App Store. There is nothing to configure.</p>
<h3>On any Linux server with Docker 29+</h3>
<pre><code>mkdir -p /DATA/AppData/ipcr &amp;&amp; cd /DATA/AppData/ipcr
curl -fsSLO https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/docker-compose.yml
install -d -m 0700 -o 1000 -g 1000 kubo &amp;&amp; install -d -o 1000 -g 1000 init.d
curl -fsSL -o init.d/01-ipcr-node.sh https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/seed/init.d/01-ipcr-node.sh
chmod 0755 init.d/01-ipcr-node.sh
printf 'PUID=1000\nPGID=1000\nTZ=UTC\nAppID=ipcr\n' &gt; .env
docker compose up -d</code></pre>
<p class="muted">IPCR listens on <code>127.0.0.1:4767</code> only, and trusts itself through
<code>/etc/docker/certs.d/ipcr.localhost:4767/ca.crt</code>. It publishes port 4768 (TCP and UDP)
for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">github.com/Yundera/ipcr</a>.</p>
<h2 id="addresses">Addresses on this server</h2>
<div class="scroll"><table>
<tr><th>Address</th><th>What</th></tr>
<tr><td><a id="t-gitea" href="#"></a></td><td>Gitea: repositories, Actions, users (<code>gitea_admin</code>)</td></tr>
<tr><td><a id="t-forge" href="#"></a></td><td>This page (public)</td></tr>
<tr><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr>
<tr><td><a id="t-radicle-api" href="#"></a></td><td>Radicle API and read-only git over HTTPS (public)</td></tr>
<tr><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr>
<tr><td><a id="t-ipcr" href="#"></a></td><td>Public front door: <code>docker pull</code> this forge's images from anywhere (read-only)</td></tr>
<tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr>
</table></div>
<footer>IPCR Forge · Gitea · act_runner · Radicle · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
</main>
<script>
(function () {
"use strict";
var host = location.hostname;
var base = host.indexOf("ipcr-forge-") === 0 ? host.slice("ipcr-forge-".length) : null;
function url(prefix) { return base ? "https://" + prefix + "-" + base : "#"; }
function setLink(id, prefix, text) {
var a = document.getElementById(id);
if (!a) return;
a.href = url(prefix);
if (text) a.textContent = base ? prefix + "-" + base : prefix + "-<domain>";
}
function all(sel, fn) { Array.prototype.forEach.call(document.querySelectorAll(sel), fn); }
document.getElementById("host").textContent = base || host;
setLink("link-gitea", "gitea");
all("a.gitea-link", function (a) { a.href = url("gitea"); });
all("a.radicle-link", function (a) { a.href = url("radicle"); });
document.getElementById("link-hello").href = base ? url("gitea") + "/gitea_admin/ipcr-hello" : "#";
if (base) {
document.getElementById("clone-cmd").textContent =
"git remote add origin " + url("gitea") + "/<owner>/<repo>.git\ngit push -u origin main";
}
setLink("t-gitea", "gitea", true);
setLink("t-forge", "ipcr-forge", true);
setLink("t-radicle", "radicle", true);
setLink("t-radicle-api", "radicle-api", true);
setLink("t-ipcr", "ipcr", true);
if (base) document.getElementById("anywhere-cmd").textContent = "docker pull ipcr-" + base + "/ipns/<name>/<repo>:1.0.0";
function el(tag, text, cls) {
var e = document.createElement(tag);
if (text != null) e.textContent = text;
if (cls) e.className = cls;
return e;
}
function link(href, text) { var a = el("a", text); a.href = href; return a; }
function copyButton(text) {
var b = el("button", "Copy", "copy");
b.type = "button";
b.addEventListener("click", function () {
var done = function () { b.textContent = "Copied"; setTimeout(function () { b.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); }
});
return b;
}
function when(s) { return s ? s.replace("T", " ").replace(/\..*$/, "").replace("Z", "") + " UTC" : ""; }
function cmpTags(a, b) {
if (a === "latest") return -1;
if (b === "latest") return 1;
var pa = a.split(/[.-]/), pb = b.split(/[.-]/);
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
var x = pa[i] || "", y = pb[i] || "";
var nx = parseInt(x, 10), ny = parseInt(y, 10);
if (!isNaN(nx) && !isNaN(ny) && nx !== ny) return ny - nx;
if (x !== y) return x < y ? 1 : -1;
}
return 0;
}
function table(headers) {
var wrap = el("div", null, "scroll"), t = el("table"), head = el("tr");
headers.forEach(function (h) { head.appendChild(el("th", h)); });
t.appendChild(head);
wrap.appendChild(t);
return { wrap: wrap, t: t };
}
// Published images: IPCR's published.json.
var imagesBody = document.getElementById("images-body");
fetch("images.json", { cache: "no-store" })
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function (pub) {
var reg = pub.registry || "ipcr.localhost:4767";
if (pub.publisher) {
document.getElementById("publisher-name").textContent = pub.publisher;
all("span.publisher", function (e) { e.textContent = pub.publisher; });
}
var repos = Object.keys(pub.images || {}).sort();
if (!repos.length) throw new Error("empty");
imagesBody.textContent = "";
repos.forEach(function (repo) {
var r = pub.images[repo];
imagesBody.appendChild(el("h3", repo));
var tb = table(["Tag", "Compose line", ""]);
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.
// 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));
if (info.at) td1.appendChild(el("div", when(info.at), "muted"));
var td2 = el("td"); td2.appendChild(el("code", line));
var pin = el("div", null, "muted");
pin.appendChild(document.createTextNode("this build only: "));
pin.appendChild(el("code", reg + "/ipfs/" + info.cid));
td2.appendChild(pin);
if (pub.public && base) {
var anywhere = el("div", null, "muted");
anywhere.appendChild(document.createTextNode("without IPCR: "));
anywhere.appendChild(el("code", "docker pull ipcr-" + base + "/ipns/" + path + ":" + tag));
td2.appendChild(anywhere);
}
var td3 = el("td"); td3.appendChild(copyButton(line));
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
tb.t.appendChild(tr);
});
imagesBody.appendChild(tb.wrap);
});
})
.catch(function () {
imagesBody.innerHTML = "";
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.
var reposBody = document.getElementById("repos-body");
fetch("repos.json", { cache: "no-store" })
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function (repos) {
if (!repos || !repos.length) throw new Error("empty");
reposBody.textContent = "";
var tb = table(["Repository", "Radicle", "State"]);
repos.forEach(function (r) {
var tr = el("tr");
var td1 = el("td");
// Not r.html_url: the bridge 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));
var td2 = el("td");
if (r.rid) {
td2.appendChild(base ? link(url("radicle") + "/nodes/radicle-api-" + base + "/" + r.rid, r.rid) : el("code", r.rid));
var clone = el("div", null, "muted");
clone.appendChild(el("code", "rad clone " + r.rid));
td2.appendChild(clone);
} else { td2.appendChild(el("span", "—", "muted")); }
var td3 = el("td");
td3.appendChild(el("span", r.state, "state " + r.state));
if (r.last_sync) td3.appendChild(el("div", when(r.last_sync), "muted"));
if (r.error) td3.appendChild(el("div", r.error, "muted"));
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
tb.t.appendChild(tr);
});
reposBody.appendChild(tb.wrap);
})
.catch(function () {
reposBody.innerHTML = "";
reposBody.appendChild(el("p", "No public repository mirrored yet. Every public repository in Gitea appears here within a minute of its first push.", "muted"));
});
// The node's ID. The Radicle API is public and CORS-enabled (the explorer calls it from the browser).
if (base) {
fetch(url("radicle-api") + "/api/v1/node", { cache: "no-store" })
.then(function (r) { return r.json(); })
.then(function (node) { if (node.id) document.getElementById("node-id").textContent = node.id; })
.catch(function () {});
}
})();
</script>
</body>
</html>