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).
@@ -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
|
||||
@@ -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: /
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
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
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 57 KiB |
@@ -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: /
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: /
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 129 KiB |
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The gate is the staging registry's front door (`ipcr-forge-bridge gate`). CI jobs push to
|
||||
// localhost:5000 and IPCR imports from it; before the gate, anyone who could run a job could push
|
||||
// any image name, and IPCR published what it found. Now:
|
||||
//
|
||||
// - a job logs in as its repository: user <owner>/<repo>, password the IPCR_PUSH_TOKEN secret the
|
||||
// bridge set on that repository (pushcreds.go). It may read and write that repository's
|
||||
// manifests, blobs and tags, and nothing else: no other name, no catalog, no delete, no
|
||||
// cross-repository blob mount;
|
||||
// - IPCR logs in as the importer (the credential it generated itself): read everything, list the
|
||||
// catalog, delete manifests it has published.
|
||||
//
|
||||
// It runs in the CI daemon's network namespace, where jobs see it as localhost:5000. The registry
|
||||
// behind it listens on a unix socket shared with the gate only, so a job cannot go around it.
|
||||
//
|
||||
// Environment: GATE_LISTEN (default :5000), GATE_UPSTREAM (default
|
||||
// unix:///run/staging/registry.sock), GATE_CREDENTIALS (default /gate/credentials.json).
|
||||
|
||||
// gateCreds is credentials.json, written by the bridge: SHA-256 (hex) of each password.
|
||||
type gateCreds struct {
|
||||
Push map[string]string `json:"push"` // "<owner>/<repo>" (lowercase) → hash
|
||||
Importer map[string]string `json:"importer"` // user → hash
|
||||
}
|
||||
|
||||
type gate struct {
|
||||
credFile string
|
||||
upstream http.Handler
|
||||
|
||||
mu sync.Mutex
|
||||
mtime time.Time
|
||||
creds gateCreds
|
||||
}
|
||||
|
||||
func gateMain() error {
|
||||
g := &gate{credFile: env("GATE_CREDENTIALS", "/gate/credentials.json")}
|
||||
up, err := upstreamProxy(env("GATE_UPSTREAM", "unix:///run/staging/registry.sock"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.upstream = up
|
||||
addr := env("GATE_LISTEN", ":5000")
|
||||
log.Printf("gate: %s → %s, credentials %s", addr, env("GATE_UPSTREAM", "unix:///run/staging/registry.sock"), g.credFile)
|
||||
return http.ListenAndServe(addr, g)
|
||||
}
|
||||
|
||||
// upstreamProxy forwards to the registry, keeping the client's Host (the registry builds upload
|
||||
// URLs from it) and dropping the client's credentials (the registry has none of its own).
|
||||
func upstreamProxy(target string) (http.Handler, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
dest := &url.URL{Scheme: u.Scheme, Host: u.Host}
|
||||
if u.Scheme == "unix" {
|
||||
sock := u.Path
|
||||
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", sock)
|
||||
}
|
||||
dest = &url.URL{Scheme: "http", Host: "registry"}
|
||||
}
|
||||
return &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(dest)
|
||||
pr.Out.Host = pr.In.Host
|
||||
pr.Out.Header.Del("Authorization")
|
||||
},
|
||||
Transport: tr,
|
||||
FlushInterval: -1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *gate) load() gateCreds {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
st, err := os.Stat(g.credFile)
|
||||
if err != nil {
|
||||
return g.creds
|
||||
}
|
||||
if !st.ModTime().Equal(g.mtime) {
|
||||
var c gateCreds
|
||||
if b, err := os.ReadFile(g.credFile); err == nil && json.Unmarshal(b, &c) == nil {
|
||||
g.creds, g.mtime = c, st.ModTime()
|
||||
}
|
||||
}
|
||||
return g.creds
|
||||
}
|
||||
|
||||
func hashSecret(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func matches(stored, pass string) bool {
|
||||
return stored != "" && subtle.ConstantTimeCompare([]byte(stored), []byte(hashSecret(pass))) == 1
|
||||
}
|
||||
|
||||
func registryError(w http.ResponseWriter, status int, code, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
|
||||
if status == http.StatusUnauthorized {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="ipcr-staging"`)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]any{"errors": []map[string]string{{"code": code, "message": msg}}})
|
||||
}
|
||||
|
||||
func (g *gate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
if r.URL.RawPath != "" || strings.Contains(p, "..") || strings.Contains(p, "//") || !strings.HasPrefix(p, "/v2") {
|
||||
registryError(w, http.StatusBadRequest, "NAME_INVALID", "bad path")
|
||||
return
|
||||
}
|
||||
user, pass, _ := r.BasicAuth()
|
||||
user = strings.ToLower(user)
|
||||
creds := g.load()
|
||||
role := ""
|
||||
switch {
|
||||
case matches(creds.Importer[user], pass):
|
||||
role = "importer"
|
||||
case matches(creds.Push[user], pass):
|
||||
role = "push"
|
||||
}
|
||||
if role == "" {
|
||||
registryError(w, http.StatusUnauthorized, "UNAUTHORIZED", "log in as <owner>/<repo> with the repository's IPCR_PUSH_TOKEN secret")
|
||||
return
|
||||
}
|
||||
if !allowed(role, user, r) {
|
||||
log.Printf("gate: %s refused %s %s", user, r.Method, p)
|
||||
registryError(w, http.StatusForbidden, "DENIED", user+" may only push "+user)
|
||||
return
|
||||
}
|
||||
if role == "push" {
|
||||
// A cross-repository mount would copy a blob from a repository this user may not read.
|
||||
// Without it the client simply uploads the blob.
|
||||
q := r.URL.Query()
|
||||
if q.Has("mount") || q.Has("from") {
|
||||
q.Del("mount")
|
||||
q.Del("from")
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
}
|
||||
g.upstream.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// allowed applies the two roles to a request.
|
||||
func allowed(role, user string, r *http.Request) bool {
|
||||
p, m := r.URL.Path, r.Method
|
||||
read := m == "GET" || m == "HEAD"
|
||||
if p == "/v2/" || p == "/v2" {
|
||||
return read
|
||||
}
|
||||
if role == "importer" {
|
||||
if read {
|
||||
return true
|
||||
}
|
||||
return m == "DELETE" && strings.Contains(p, "/manifests/")
|
||||
}
|
||||
rest, ok := strings.CutPrefix(p, "/v2/"+user+"/")
|
||||
if !ok || user == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(rest, "blobs/") && !strings.HasPrefix(rest, "manifests/") && !strings.HasPrefix(rest, "tags/") {
|
||||
return false
|
||||
}
|
||||
switch m {
|
||||
case "GET", "HEAD", "POST", "PUT", "PATCH":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestGate(t *testing.T) (*gate, *[]*http.Request) {
|
||||
t.Helper()
|
||||
var seen []*http.Request
|
||||
var mu sync.Mutex
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
seen = append(seen, r.Clone(r.Context()))
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(up.Close)
|
||||
proxy, err := upstreamProxy(up.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "credentials.json")
|
||||
writeCreds(file, gateCreds{
|
||||
Push: map[string]string{"metadec/app": hashSecret("app-token"), "alice/tool": hashSecret("alice-token")},
|
||||
Importer: map[string]string{"ipcr": hashSecret("importer-pass")},
|
||||
})
|
||||
return &gate{credFile: file, upstream: proxy}, &seen
|
||||
}
|
||||
|
||||
func TestGateAuthorization(t *testing.T) {
|
||||
g, _ := newTestGate(t)
|
||||
for _, c := range []struct {
|
||||
name, method, path, user, pass string
|
||||
want int
|
||||
}{
|
||||
{"no credentials", "GET", "/v2/", "", "", 401},
|
||||
{"wrong password", "GET", "/v2/", "metadec/app", "nope", 401},
|
||||
{"login check", "GET", "/v2/", "metadec/app", "app-token", 200},
|
||||
{"login, uppercase user", "GET", "/v2/", "Metadec/App", "app-token", 200},
|
||||
{"own manifest", "PUT", "/v2/metadec/app/manifests/1.0.0", "metadec/app", "app-token", 200},
|
||||
{"own blob upload", "POST", "/v2/metadec/app/blobs/uploads/", "metadec/app", "app-token", 200},
|
||||
{"own upload chunk", "PATCH", "/v2/metadec/app/blobs/uploads/abc", "metadec/app", "app-token", 200},
|
||||
{"own blob check", "HEAD", "/v2/metadec/app/blobs/sha256:00", "metadec/app", "app-token", 200},
|
||||
{"other repository", "PUT", "/v2/alice/tool/manifests/1.0.0", "metadec/app", "app-token", 403},
|
||||
{"prefix of another name", "PUT", "/v2/metadec/app2/manifests/1", "metadec/app", "app-token", 403},
|
||||
{"nested name", "PUT", "/v2/metadec/app/sub/manifests/1", "metadec/app", "app-token", 403},
|
||||
{"catalog as pusher", "GET", "/v2/_catalog", "metadec/app", "app-token", 403},
|
||||
{"delete as pusher", "DELETE", "/v2/metadec/app/manifests/sha256:00", "metadec/app", "app-token", 403},
|
||||
{"dot-dot", "GET", "/v2/metadec/app/../alice/tool/tags/list", "metadec/app", "app-token", 400},
|
||||
{"importer catalog", "GET", "/v2/_catalog", "ipcr", "importer-pass", 200},
|
||||
{"importer pull", "GET", "/v2/alice/tool/manifests/1.0.0", "ipcr", "importer-pass", 200},
|
||||
{"importer delete", "DELETE", "/v2/alice/tool/manifests/sha256:00", "ipcr", "importer-pass", 200},
|
||||
{"importer push", "PUT", "/v2/alice/tool/manifests/1.0.0", "ipcr", "importer-pass", 403},
|
||||
} {
|
||||
r := httptest.NewRequest(c.method, "http://localhost:5000"+c.path, nil)
|
||||
if c.user != "" {
|
||||
r.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
g.ServeHTTP(w, r)
|
||||
if w.Code != c.want {
|
||||
t.Errorf("%s: %d, want %d", c.name, w.Code, c.want)
|
||||
}
|
||||
if w.Code == 401 && !strings.HasPrefix(w.Header().Get("WWW-Authenticate"), "Basic ") {
|
||||
t.Errorf("%s: no Basic challenge", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateForwarding(t *testing.T) {
|
||||
g, seen := newTestGate(t)
|
||||
r := httptest.NewRequest("POST", "http://localhost:5000/v2/metadec/app/blobs/uploads/?mount=sha256:00&from=alice/tool", nil)
|
||||
r.Host = "localhost:5000"
|
||||
r.SetBasicAuth("metadec/app", "app-token")
|
||||
w := httptest.NewRecorder()
|
||||
g.ServeHTTP(w, r)
|
||||
if w.Code != 200 || len(*seen) != 1 {
|
||||
t.Fatalf("%d, %d upstream requests", w.Code, len(*seen))
|
||||
}
|
||||
up := (*seen)[0]
|
||||
if up.URL.RawQuery != "" {
|
||||
t.Errorf("cross-repository mount forwarded: %q", up.URL.RawQuery)
|
||||
}
|
||||
if up.Host != "localhost:5000" {
|
||||
t.Errorf("Host %q: the registry builds upload URLs from it", up.Host)
|
||||
}
|
||||
if up.Header.Get("Authorization") != "" {
|
||||
t.Error("credentials forwarded to the registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateReloadsCredentials(t *testing.T) {
|
||||
g, _ := newTestGate(t)
|
||||
try := func() int {
|
||||
r := httptest.NewRequest("GET", "http://localhost:5000/v2/", nil)
|
||||
r.SetBasicAuth("new/repo", "t")
|
||||
w := httptest.NewRecorder()
|
||||
g.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
if try() != 401 {
|
||||
t.Fatal("unknown repository accepted")
|
||||
}
|
||||
c := gateCreds{Push: map[string]string{"new/repo": hashSecret("t")}}
|
||||
writeCreds(g.credFile, c)
|
||||
// A different modification time, as a later write would have.
|
||||
st, _ := os.Stat(g.credFile)
|
||||
os.Chtimes(g.credFile, st.ModTime().Add(1e9), st.ModTime().Add(1e9))
|
||||
if try() != 200 {
|
||||
t.Error("new credentials not picked up")
|
||||
}
|
||||
}
|
||||
|
||||
// sync against a fake Gitea: secrets set for new repositories, kept for known ones, deleted for
|
||||
// removed ones, the importer taken from IPCR's file.
|
||||
func TestPushCredsSync(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
secrets := map[string]string{}
|
||||
var deleted []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
repo := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/repos/"), "/actions/secrets/"+pushSecret)
|
||||
switch r.Method {
|
||||
case "PUT":
|
||||
var b struct{ Data string }
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
secrets[repo] = b.Data
|
||||
w.WriteHeader(201)
|
||||
case "DELETE":
|
||||
deleted = append(deleted, repo)
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
dir := t.TempDir()
|
||||
tok := filepath.Join(dir, "token")
|
||||
os.WriteFile(tok, []byte("admin-token"), 0o600)
|
||||
auth := filepath.Join(dir, "staging-auth")
|
||||
os.WriteFile(auth, []byte("ipcr:importer-pass\n"), 0o644)
|
||||
p := &pushCreds{file: filepath.Join(dir, "gate", "credentials.json"), stagingAuth: auth, gitea: newGitea(srv.URL, "gitea_admin", tok)}
|
||||
|
||||
if err := p.sync([]string{"Metadec/App", "alice/tool"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := p.read()
|
||||
if !matches(c.Push["metadec/app"], secrets["Metadec/App"]) || !matches(c.Push["alice/tool"], secrets["alice/tool"]) {
|
||||
t.Fatalf("credentials do not match the secrets: %v %v", c.Push, secrets)
|
||||
}
|
||||
if !matches(c.Importer["ipcr"], "importer-pass") {
|
||||
t.Error("importer credential missing")
|
||||
}
|
||||
first := secrets["alice/tool"]
|
||||
|
||||
// alice/tool keeps its token; Metadec/App is rotated; a removed repository loses it.
|
||||
if err := p.sync([]string{"Metadec/App", "alice/tool"}, "metadec/app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secrets["alice/tool"] != first || !matches(p.read().Push["metadec/app"], secrets["Metadec/App"]) {
|
||||
t.Error("rotation")
|
||||
}
|
||||
if err := p.sync([]string{"Metadec/App"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := p.read().Push["alice/tool"]; ok || len(deleted) != 1 || deleted[0] != "alice/tool" {
|
||||
t.Errorf("removal: %v, deleted %v", p.read().Push, deleted)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module ipcr-forge-bridge
|
||||
|
||||
go 1.24
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/<name>/app</code>. Its members (Gitea teams) are the ones who
|
||||
can publish there. Leave empty for none.</p>
|
||||
<form class="row" id="org-form">
|
||||
<input type="text" id="org" placeholder="organisation" size="24" autocomplete="off">
|
||||
<input type="submit" value="Save">
|
||||
</form>
|
||||
<p id="org-state" class="muted"></p>
|
||||
</div>
|
||||
|
||||
<h2>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>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -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-<domain>/<owner>/<repo>.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: ${{ 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 }}</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><owner>/<repo>:1.0.0</code> under this forge's name.
|
||||
Repositories of the forge's own organisation are also published at the root:
|
||||
<code><repo>:1.0.0</code>.</li>
|
||||
</ol>
|
||||
<p class="note">Who can publish: 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>/<owner>/<repo>: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>/<owner>/<repo>:1.0.0</code>.
|
||||
<code>/ipns/…</code> is a name this forge can move to a new build; <code>/ipfs/<cid></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/<owner>/<repo>: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/<owner>/<repo>: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 <id></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-<domain>/ipns/<name>/<repo>: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 && 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 && 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' > .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>
|
||||