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.
@@ -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: /
|
||||
|
Before Width: | Height: | Size: 9.9 KiB |
@@ -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.
|
||||
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
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
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 57 KiB |
@@ -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: /
|
||||
|
Before Width: | Height: | Size: 9.7 KiB |
@@ -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.
|
||||
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: /
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -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.
|
||||
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 42 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 129 KiB |
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||