Move the store listings to the IPCR app store (Yundera/ipcr Apps/)

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

Before

Width:  |  Height:  |  Size: 9.9 KiB

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

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

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

Before

Width:  |  Height:  |  Size: 57 KiB

-989
View File
@@ -1,989 +0,0 @@
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 ──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 forge service: 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,forge,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 (forge/ in the ipcr-forge repository). Receives Gitea's system webhook on :8081
# (internal: no Caddy route) and mirrors every public repository to the Radicle node below,
# 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: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", "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: /forge
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/forge/state:/forge
# The Gitea token and the webhook secret the install step writes. Read-only here.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/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", "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 forge service (ipcr-forge, on this server's network) and, as by
# default, any public address. Not `private`: that would let any user's webhook reach every
# 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 forge service 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 forge service (`ipcr-forge gate`, forge/gate.go). See rationale.md.
forge-gate:
image: ghcr.io/yundera/ipcr-forge: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", "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 forge service.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/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 forge service
# 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 forge service
# 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 forge service allows (public Gitea repositories, minus
# those switched off in the admin pages). Until the forge service 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 forge service, 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 forge service.
- 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/forge/state
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/forge/secrets
user: $PUID
group: $PGID
mode: "0700"
# The gate's credentials (hashes), written by the forge service, read by the gate.
- path: /DATA/AppData/$AppID/forge/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 forge service 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 forge service'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 forge service's Gitea tokens (public-only read; and write:repository for push secrets), the
# webhook secret, the system webhook, the admin pages' OAuth2 app, the root organisation and
# 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:0.4.0
user: $PUID:$PGID
phase: post_up
network: $APP_NET
volumes:
- /DATA/AppData/$AppID/forge/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 setup
when: always
webui-host: gitea-${domain}
webui-path: /
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

-280
View File
@@ -1,280 +0,0 @@
# IPCR Forge — Rationale
IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon,
and the forge service (`ipcr-forge`), which mirrors public Gitea repositories to Radicle and
serves the forge's page. The Gitea, Radicle and IPCR services are copied from their listings,
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 forge service (`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 forge service'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 forge service mounts Kubo's keystore read-only**, so it can export the publisher key, encrypted.
11. **An admin API in `ipcr-gateway`** (`:4769`) on a new internal network, `ipcr-admin`. Its Bearer
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 forge service,** 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
forge service.
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 forge service
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 forge service 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 forge service 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 forge service
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 forge service**
- **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 (`forge/secrets`, 0700). A delivery only says
*which* repository changed. The forge service re-reads that repository from Gitea, so a forged payload
can at most cause a pointless sync. Port 8081 has no Caddy route.
- **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 forge service 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 forge service serves it read-only: the embedded page,
IPCR's `published.json` (mounted read-only) and the forge service'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 forge service 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 forge service 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 forge service 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 forge service | 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 forge service can read the publisher key.** A compromise of the public-facing forge service leaks it. That
is the trust it already has with the Radicle node key.
- **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 |
| `forge/` | `state/`: the Gitea → Radicle mapping, one bare mirror per repository (disposable), the admin session key and the repository toggles (`admin.json`); `gate/credentials.json` (token hashes); `secrets/` (0700): its two Gitea tokens, the webhook secret, the OAuth2 client credentials |
| `ci/` | the daemon's socket, its image store (`docker/`), the staging registry (`registry/`, disposable) and its socket (`registry-socket/`) |
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `config.json` (name, allowlist), the admin token |
No user directory (`/DATA/Documents`, `Media`, …) is mounted. The one file outside `/DATA` is
IPCR's `/etc/docker/certs.d/ipcr.localhost:4767/ca.crt`, as in the standalone app. Identities that
matter on the network survive uninstall and reinstall, so published repositories and image names
stay valid:
- Radicle's node key;
- Kubo's node key and IPNS keys.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

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

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

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

Before

Width:  |  Height:  |  Size: 129 KiB

+8 -5
View File
@@ -15,12 +15,14 @@ registry gateway, IPFS import, IPNS naming and admin API), used here as the imag
`ghcr.io/yundera/ipcr`.
- **[docs/forge.md](docs/forge.md)**: how it works, the trust model, findings, what was verified.
- **[Apps/IPCR-Forge/](Apps/IPCR-Forge/)**: the Yundera AppStore listing (compose, rationale, seed).
- **[Apps/IPCR-Forge](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge)** (in the IPCR repository, with the
rest of the IPCR app store): the Yundera AppStore listing (compose, rationale, seed). It pins this
repository's image and the engine's.
- **[forge/](forge/)**: the forge service, `ghcr.io/yundera/ipcr-forge` (Go, standard library only):
the Gitea → Radicle mirror, the forge's page and admin pages (Gitea login), the staging registry's
gate and per-repository push credentials, and the install step.
- **[Apps/Gitea/](Apps/Gitea/)**, **[Apps/Radicle/](Apps/Radicle/)**: the standalone listings the
forge is built from.
- The standalone listings the forge is built from, [Gitea](https://github.com/Yundera/ipcr/tree/main/Apps/Gitea) and [Radicle](https://github.com/Yundera/ipcr/tree/main/Apps/Radicle), are in
the same store.
## Develop
@@ -30,5 +32,6 @@ docker build -t ghcr.io/yundera/ipcr-forge:dev forge
```
Releases: a `vX.Y.Z` tag publishes `ghcr.io/yundera/ipcr-forge:X.Y.Z`
([.github/workflows/image.yml](.github/workflows/image.yml)). The listing pins both images: this
one and the engine's.
([.github/workflows/image.yml](.github/workflows/image.yml)). Then bump the version in the
listing, `Apps/IPCR-Forge/docker-compose.yml` in the IPCR repository (the service, `forge-gate`
and the `forge-setup` step).
+2 -2
View File
@@ -6,9 +6,9 @@ naming, the admin API). The engine knows nothing about Gitea, Radicle or this fo
the engine's image (`ghcr.io/yundera/ipcr`) and the contract listed in its
[design notes](https://github.com/Yundera/ipcr/blob/main/docs/design.md#contract-with-add-ons).
How the forge in [Apps/IPCR-Forge/](../Apps/IPCR-Forge/) goes from `git push` to `docker pull`, why it
How the forge (store listing: [Apps/IPCR-Forge](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge), in the IPCR repository) 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).
[rationale.md](https://github.com/Yundera/ipcr/blob/main/Apps/IPCR-Forge/rationale.md).
## Goal