diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..1258312 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,61 @@ +name: bridge + +# IPCR Forge's bridge image (bridge/): Gitea → Radicle mirror + the forge's page. +on: + push: + branches: [main] + tags: ["bridge-v*"] + paths: ["bridge/**", ".github/workflows/bridge.yml"] + pull_request: + paths: ["bridge/**", ".github/workflows/bridge.yml"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/yundera/ipcr-forge-bridge + +jobs: + vet: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: bridge/go.mod + - run: test -z "$(gofmt -l .)" && go vet ./... && go test ./... + working-directory: bridge + + image: + needs: vet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + if: github.event_name != 'pull_request' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=match,pattern=bridge-v(.*),group=1 + type=ref,event=branch + type=sha + # amd64 only: the Radicle CLI is fetched as its x86_64 release, and the Forge listing is + # amd64 only. + - uses: docker/build-push-action@v6 + with: + context: bridge + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: VERSION=${{ steps.meta.outputs.version }} + cache-from: type=gha,scope=bridge + cache-to: type=gha,mode=max,scope=bridge diff --git a/Apps/Gitea/docker-compose.yml b/Apps/Gitea/docker-compose.yml new file mode 100644 index 0000000..6a02fdf --- /dev/null +++ b/Apps/Gitea/docker-compose.yml @@ -0,0 +1,408 @@ +name: gitea + +services: + # Gitea itself — git hosting, the Actions controller, and the OCI package + # registry, all in one process. SQLite: a personal forge never has the write + # concurrency that would justify a second container for Postgres. + gitea: + image: gitea/gitea:1.27.3-rootless + container_name: gitea + user: $PUID:$PGID + restart: unless-stopped + cpu_shares: 70 + deploy: + resources: + limits: + memory: 1G + environment: + TZ: $TZ + # Every URL Gitea hands out — clone URLs, the registry host, and the + # server_url the CI runner passes to jobs — is derived from ROOT_URL, so + # it has to be the address that works from outside as well as inside. + GITEA__server__ROOT_URL: https://gitea-${APP_DOMAIN}/ + GITEA__server__DOMAIN: gitea-${APP_DOMAIN} + GITEA__server__HTTP_PORT: "3000" + GITEA__server__PROTOCOL: http + # The address Gitea's own CLI uses to reach the running server's internal + # API. It defaults to localhost, which is right for the server itself and + # useless from the init container that runs + # `gitea actions generate-runner-token`. It has to be set here rather than + # there: the CLI reads it out of the shared app.ini, and only this + # container's entrypoint renders environment into app.ini. + GITEA__server__LOCAL_ROOT_URL: http://gitea:3000/ + # Git over SSH would need a published host port of its own. Everything + # here — clone, push, CI, registry — goes over HTTPS instead. + GITEA__server__DISABLE_SSH: "true" + GITEA__server__START_SSH_SERVER: "false" + # Skips the first-run install wizard: every value it would ask for is set + # here, and the admin account is created by an init step below. + GITEA__security__INSTALL_LOCK: "true" + GITEA__database__DB_TYPE: sqlite3 + GITEA__database__PATH: /var/lib/gitea/data/gitea.db + GITEA__service__DISABLE_REGISTRATION: "true" + GITEA__service__REQUIRE_SIGNIN_VIEW: "true" + GITEA__actions__ENABLED: "true" + # Where a bare `uses: actions/checkout@v4` is resolved from. + GITEA__actions__DEFAULT_ACTIONS_URL: github + GITEA__packages__ENABLED: "true" + # Lets a pull mirror name a repository on this server's own Docker + # network — a Radicle node's git endpoint, for instance. See rationale.md. + GITEA__migrations__ALLOW_LOCALNETWORKS: "true" + # How often a pull mirror is re-fetched. The sync emits the same push + # event a real push would, so this interval is also how quickly CI + # reacts to a commit that arrives from the mirrored source. + GITEA__mirror__DEFAULT_INTERVAL: 10m + GITEA__mirror__MIN_INTERVAL: 10m + volumes: + - ${DATA_ROOT:-/DATA}/AppData/$AppID/data:/var/lib/gitea + - ${DATA_ROOT:-/DATA}/AppData/$AppID/config:/etc/gitea + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/healthz"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + expose: + - 3000 + labels: + caddy_0: gitea-${APP_DOMAIN} + caddy_0.import: gateway_tls + caddy_0.reverse_proxy: "{{upstreams 3000}}" + caddy_1: gitea-${APP_PUBLIC_IP_DASH}.nip.io + caddy_1.import: gateway_tls + caddy_1.reverse_proxy: "{{upstreams 3000}}" + caddy_2: gitea-${APP_PUBLIC_IP_DASH}.sslip.io + caddy_2.reverse_proxy: "{{upstreams 3000}}" + networks: + - pcs + - gitea-internal + + # The CI runner: act_runner with its own Docker daemon inside it (dind), so + # workflow jobs and the images they build never touch the host's daemon. + # See rationale.md for what `privileged` buys and what it costs. + gitea-runner: + image: gitea/act_runner:0.6.1-dind + container_name: gitea-runner + restart: unless-stopped + privileged: true + cpu_shares: 20 + deploy: + resources: + limits: + memory: 2G + environment: + TZ: $TZ + # This address is not just how the runner daemon reaches Gitea — it is + # handed to every job as GITHUB_SERVER_URL, and a job runs inside the + # runner's *nested* daemon, on a network where `gitea` does not resolve. + # So it has to be an address that works from the outside: checkout and + # `docker login` from a workflow both go out and back over HTTPS. + GITEA_INSTANCE_URL: https://gitea-${APP_DOMAIN} + GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token + GITEA_RUNNER_NAME: gitea-runner + # Registration-time labels. runner-config/config.yaml repeats them, and + # that copy wins on every start, so an installed runner follows store updates. + GITEA_RUNNER_LABELS: ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04,docker:docker://docker:28.5-cli + CONFIG_FILE: /config/config.yaml + # DOCKER_TLS_CERTDIR is deliberately left at the image's default. Emptying + # it does not turn the nested daemon's TCP listener off — it turns the + # listener's TLS off, and 2375 then answers unauthenticated to anything + # that can reach this container. + volumes: + # .runner (the registration this runner is identified by) and the + # registration token the init step leaves for it. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/runner:/data + - ${DATA_ROOT:-/DATA}/AppData/$AppID/runner-config:/config:ro + # Replaces the image's own start-up wait — see the comments in the + # script. It is declared under x-compose-app.files rather than left to + # the seed tree, because it has to arrive executable. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/runner-config/act_runner-run:/etc/s6/act_runner/run:ro + # The inner daemon's image store. Without this every restart re-pulls + # every base image a workflow uses. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/runner-docker:/var/lib/docker + networks: + - gitea-internal + +networks: + pcs: + name: ${APP_NET:-pcs} + external: true + # The runner has no business being reachable from other apps — it only ever + # talks to gitea and to the internet. + gitea-internal: + driver: bridge + +x-casaos: + architectures: + - amd64 + main: gitea + developer: Gitea + category: Developer + tags: ["developer", "git", "ci"] + description: + en_us: | + Gitea — a git forge with CI and a container registry + + Gitea is a lightweight, self-contained software forge: repositories, issues, pull requests, releases, a CI engine and an OCI container registry in a single service that is happy on a small machine. + + What it gives you here: + + • Git hosting over HTTPS, with issues, pull requests, releases and wikis + • Actions — CI workflows in GitHub Actions syntax, so existing workflow files usually run unchanged + • A built-in container registry, so a workflow can build an image and push it next to the code it came from + • Pull mirrors — point a repository at any git URL and it is re-fetched on a schedule, and each sync starts the same workflows a push would + + That last pair is what makes it useful in front of a git source that has no webhooks of its own: mirror the repository, and the mirror's own updates drive the build. + + A runner ships with the app, with its own Docker daemon inside it, so workflows can build container images out of the box. + fr_fr: | + Gitea — une forge git avec CI et registre de conteneurs + + Gitea est une forge logicielle légère et autonome : dépôts, tickets, pull requests, versions, moteur d'intégration continue et registre de conteneurs OCI dans un seul service, à l'aise sur une petite machine. + + Ce que vous obtenez ici : + + • Hébergement git en HTTPS, avec tickets, pull requests, versions et wikis + • Actions — des workflows d'intégration continue en syntaxe GitHub Actions, si bien qu'un fichier de workflow existant tourne généralement tel quel + • Un registre de conteneurs intégré : un workflow peut construire une image et la publier à côté du code dont elle provient + • Miroirs — pointez un dépôt vers n'importe quelle URL git, il est re-synchronisé périodiquement, et chaque synchronisation déclenche les mêmes workflows qu'un push + + Ces deux derniers points sont ce qui rend Gitea utile devant une source git dépourvue de webhooks : mettez le dépôt en miroir, et ce sont les mises à jour du miroir qui pilotent la construction. + + Un runner est fourni avec l'application, avec son propre démon Docker embarqué, afin que les workflows puissent construire des images de conteneurs dès l'installation. + zh_cn: | + Gitea — 带 CI 与容器仓库的 Git 代码托管 + + Gitea 是轻量、自足的软件协作平台:仓库、议题、合并请求、发布、CI 引擎与 OCI 容器仓库集于一个服务,在小型机器上也能流畅运行。 + + 它在这里提供: + + • 通过 HTTPS 托管 Git,附带议题、合并请求、发布与百科 + • Actions —— 采用 GitHub Actions 语法的 CI 工作流,现有的工作流文件通常无需修改即可运行 + • 内置容器仓库,工作流可以构建镜像并发布在源代码旁边 + • 拉取镜像同步 —— 将仓库指向任意 Git URL,它会按计划重新拉取,每次同步都会触发与推送相同的工作流 + + 后两项让 Gitea 特别适合放在没有 webhook 的 Git 源前面:把仓库设为镜像,镜像的更新就会驱动构建。 + + 应用自带一个运行器,内含独立的 Docker 守护进程,因此工作流开箱即可构建容器镜像。 + icon: icon.png + screenshot_link: + - screenshot-1.png + - screenshot-2.png + - screenshot-3.png + thumbnail: thumbnail.png + tagline: + en_us: A git forge with CI and a container registry, on your own domain. + fr_fr: Une forge git avec intégration continue et registre de conteneurs, sur votre domaine. + zh_cn: 运行在你自己域名下的 Git 托管,内建 CI 与容器仓库。 + tips: + before_install: + en_us: | + Signing in + + | | | + | --- | --- | + | Address | `https://gitea-$APP_DOMAIN` | + | User | `gitea_admin` | + | Password | your server's default app password | + + Gitea uses its own account system. Self-registration is off, so + `gitea_admin` is the only account until you create more under + Site Administration. + + Building from a repository that has no webhooks + + New Migration → check **This repository will be a mirror**, and give it + the git URL. Gitea re-fetches on a schedule, and each sync raises the + same `push` event a real push would — so a workflow with `on: push` + runs. Commit the workflow to `.gitea/workflows/` in the mirrored + repository itself (`.github/workflows/` also works; `.forgejo/` does + not). + + One thing to change by hand: a migrated repository arrives with + **Actions switched off**. Repository → Settings → Advanced Settings → + tick **Actions**, or nothing will ever run, however correct the + workflow is. + + Publishing images + + The registry lives at the same address as the web UI. In a workflow, + log in to `gitea-$APP_DOMAIN` and push to + `gitea-$APP_DOMAIN//:`. 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//:`. 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: / diff --git a/Apps/Gitea/icon.png b/Apps/Gitea/icon.png new file mode 100644 index 0000000..8eda1ea Binary files /dev/null and b/Apps/Gitea/icon.png differ diff --git a/Apps/Gitea/rationale.md b/Apps/Gitea/rationale.md new file mode 100644 index 0000000..7228860 --- /dev/null +++ b/Apps/Gitea/rationale.md @@ -0,0 +1,86 @@ +# Gitea — Rationale + +## What deviation / exception is being requested + +1. `gitea-runner` runs with `privileged: true`. +2. Gitea is configured with `[migrations] ALLOW_LOCALNETWORKS = true`. +3. The admin account is called `gitea_admin`, not the deployment's usual `admin`. + +## Why it is necessary + +**1. The privileged runner.** The point of shipping a runner at all is that a +workflow can build a container image. That needs a Docker daemon, and there are +only two ways to give a CI job one: + +- mount the host's `/var/run/docker.sock` into the runner, which hands every + workflow — including one that arrives from a mirrored third-party repository — + full control of the host's daemon, i.e. root on the PCS; or +- give the runner a daemon of its own inside the container (`dind`), which + requires `privileged`. + +The second is the smaller exposure: the blast radius is the runner's own +container and its own image store, not the host's containers, volumes and +socket. It is also what upstream ships the `-dind` tag for. + +The rootless dind variant (`0.6.1-dind-rootless`) would have avoided +`privileged` altogether, and was tried first. It does not start on this +platform: Ubuntu sets `kernel.apparmor_restrict_unprivileged_userns=1`, and +rootlesskit dies with `fork/exec /proc/self/exe: operation not permitted`. +Making it work means turning that protection off for the container +(`security_opt: apparmor=unconfined`), which weakens the host more than +`privileged` on a single container does. Revisit if the platform ever ships +that sysctl relaxed, or if upstream's runner learns to use the host's +user-namespace remapping. + +**2. `ALLOW_LOCALNETWORKS`.** Gitea refuses by default to migrate or mirror +from an address on a private network. The app's most useful configuration — +mirroring a repository from another app on the same server, e.g. a Radicle +node's git endpoint at `http://radicle-api:8080/.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. diff --git a/Apps/Gitea/screenshot-1.png b/Apps/Gitea/screenshot-1.png new file mode 100644 index 0000000..98b4954 Binary files /dev/null and b/Apps/Gitea/screenshot-1.png differ diff --git a/Apps/Gitea/screenshot-2.png b/Apps/Gitea/screenshot-2.png new file mode 100644 index 0000000..0f6ce7c Binary files /dev/null and b/Apps/Gitea/screenshot-2.png differ diff --git a/Apps/Gitea/screenshot-3.png b/Apps/Gitea/screenshot-3.png new file mode 100644 index 0000000..254ab5b Binary files /dev/null and b/Apps/Gitea/screenshot-3.png differ diff --git a/Apps/Gitea/seed/runner-config/act_runner-run b/Apps/Gitea/seed/runner-config/act_runner-run new file mode 100644 index 0000000..ae63df8 --- /dev/null +++ b/Apps/Gitea/seed/runner-config/act_runner-run @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Replaces the image's own /etc/s6/act_runner/run. Two waits, both of which the +# stock script gets wrong for this deployment. +# +# 1. The nested Docker daemon. Upstream waits for it through s6's readiness +# notification. On a cold start with a warm image store dockerd takes longer +# than s6-notifyoncheck's window, the notification never arrives, s6 marks +# the service permanently failed, and act_runner sits blocked in +# `s6-svwait -U` for two to four minutes before s6 gives up and lets it past +# anyway. The runner shows "offline" in the forge for that whole time and +# picks up no jobs. Asking the daemon directly is the same wait without the +# guesswork. +# +# 2. The registration token. It is minted by an init step that runs after the +# stack is up, so on a first install it does not exist yet when this +# container starts — and the stock entrypoint reads the token file exactly +# once, before its retry loop, so a token that arrives late is never seen. + +while [ ! -d /etc/s6/docker/supervise ]; do + sleep 0.1 +done + +while ! docker info >/dev/null 2>&1; do + sleep 1 +done + +while [ ! -s "${GITEA_RUNNER_REGISTRATION_TOKEN_FILE:-/data/token}" ] && [ ! -s /data/.runner ]; do + echo "waiting for the runner registration token" + sleep 2 +done + +exec run.sh diff --git a/Apps/Gitea/seed/runner-config/config.yaml b/Apps/Gitea/seed/runner-config/config.yaml new file mode 100644 index 0000000..9da8334 --- /dev/null +++ b/Apps/Gitea/seed/runner-config/config.yaml @@ -0,0 +1,40 @@ +# act_runner configuration. Only the settings that differ from upstream's +# defaults are written out — `act_runner generate-config` prints the full set. + +log: + level: info + +runner: + # The file the runner's own registration is kept in, relative to /data. + file: .runner + # One job at a time. A PCS shares its CPU with everything else installed on + # it, and an image build is the least polite thing on the box. + capacity: 1 + timeout: 1h + fetch_timeout: 5s + fetch_interval: 2s + # Overrides the labels stored at registration (.runner). These are upstream + # act_runner defaults: Gitea's own job images, which carry node, bash, git + # and the docker CLI with buildx, so marketplace actions (actions/checkout, + # docker/build-push-action, ...) run as they do on GitHub. The registration + # labels pointed at node:22-bookworm, which has no docker CLI. + labels: + - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" + - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" + - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" + - "docker:docker://docker:28.5-cli" + +cache: + # The cache server binds an address inside the runner container, which the + # job containers — created by the Docker daemon nested inside that same + # container — cannot route to. Left on, every `actions/cache` step fails + # noisily instead of being skipped. + enabled: false + +container: + # Jobs run on the runner's own nested daemon, on its default bridge: they + # reach the internet, and they reach Gitea the way anything outside would, + # through its public address. + network: bridge + privileged: false + force_pull: false diff --git a/Apps/Gitea/thumbnail.png b/Apps/Gitea/thumbnail.png new file mode 100644 index 0000000..d831b58 Binary files /dev/null and b/Apps/Gitea/thumbnail.png differ diff --git a/Apps/IPCR-Forge/docker-compose.yml b/Apps/IPCR-Forge/docker-compose.yml new file mode 100644 index 0000000..f69e9d1 --- /dev/null +++ b/Apps/IPCR-Forge/docker-compose.yml @@ -0,0 +1,989 @@ +name: ipcr-forge + +# IPCR Forge — a forge whose images are served from IPFS and whose code is mirrored to Radicle: +# +# git push origin main v1.2.3 (developer, plain git) +# → gitea the source of truth: repositories, PRs, issues, users +# → forge-runner (act_runner) Gitea Actions: .gitea/workflows/ for the tag +# → forge-registry staging: localhost:5000//: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///:1.2.3 (anyone) +# +# gitea ──system webhook──▶ ipcr-forge (bridge) ──rad init / git push rad──▶ radicle-node +# every PUBLIC repository, one way: Radicle is a distribution mirror signed by this node. +# +# The tile opens Gitea. ipcr-forge- is the forge's own page, served by the bridge: how to +# publish, the published images with the `image:` line to use, and the Radicle mirrors. +# +# Gitea, Radicle and IPCR services are copied from their standalone listings (Apps/Gitea, +# Apps/Radicle, Apps/IPCR), so each keeps its own comments and rationale.md; the differences are +# marked "Forge:". Data folders move, one subfolder per part: $AppID/{gitea,radicle,bridge,ci,ipcr}/. +# +# Replaces the Gitea, Radicle and IPCR apps; it cannot sit beside them. It uses the same container +# names, domains (gitea-, radicle-, radicle-api-) and host ports (8776, 4768, 127.0.0.1:4767). +# +# The handoff from CI to IPFS is a pull, run by IPCR: jobs run in the forge's own Docker daemon, +# which cannot reach ipcr.localhost (the *host's* loopback). So a workflow pushes to the staging +# registry, which shares that daemon's network namespace and is therefore `localhost:5000` to +# every job, and ipcr-gateway polls it over ci-internal. + +services: + # ═══ Bridge — the forge's page, and Gitea → Radicle ════════════════════════════ + # ipcr-forge-bridge (bridge/ in the IPCR repository). Receives Gitea's system webhook on :8081 + # (internal: no Caddy route) and mirrors every public repository to the Radicle node below, + # with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080. + # Its install step (forge-setup, below) mints its Gitea token and registers the webhook. + ipcr-forge: + image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0 + container_name: ipcr-forge + user: $PUID:$PGID + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + restart: unless-stopped + command: ["ipcr-forge-bridge", "serve"] + cpu_shares: 30 + deploy: + resources: + limits: + memory: 256M + environment: + TZ: $TZ + GITEA_URL: http://gitea:3000 + GITEA_OWNER: gitea_admin + SECRETS_DIR: /secrets + STATE_DIR: /bridge + RAD_HOME: /radicle-home + IPCR_PUBLISHED: /srv/ipcr-state/published.json + RECONCILE: 10m + # Admin pages (/admin): Gitea site admins only, signed in through Gitea (an OAuth2 app the + # forge-setup step registers, one redirect URI per host below). They manage IPCR through its + # admin API (on ipcr-admin) and back up the publisher key, read from Kubo's keystore. + PUBLIC_HOSTS: ipcr-forge-${APP_DOMAIN},ipcr-forge-${APP_PUBLIC_IP_DASH}.nip.io,ipcr-forge-${APP_PUBLIC_IP_DASH}.sslip.io + IPCR_ADMIN: http://ipcr-gateway:4769 + IPCR_ADMIN_TOKEN_FILE: /srv/ipcr-state/admin-token + KUBO_KEYSTORE: /srv/ipcr-keystore + IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge} + # The organisation published at the root of the forge's name (metadec/app → …/app), until + # changed in the admin pages. Its members, through Gitea teams, are who can publish there. + IPCR_ROOT_ORG: ${IPCR_ROOT_ORG:-} + tmpfs: + # HOME for git and rad. + - /tmp + volumes: + # The node's home: its storage (where repositories are created and pushed), its control + # socket (announces) and its key, which signs everything it publishes. The same mount + # radicle-api has. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/ + # repos.json (Gitea repository → Radicle ID) and one bare mirror per repository. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge + # The Gitea token and the webhook secret the install step writes. Read-only here. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro + # published.json → /images.json, and the admin API's token. Read-only. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro + # Kubo's keystore, read-only: the publisher key, for encrypted backups (Kubo exports keys + # from its CLI only, and ipcrd, root without capabilities, cannot read a $PUID 0700 folder). + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/kubo/keystore:/srv/ipcr-keystore:ro + depends_on: + - gitea + - radicle-node + healthcheck: + test: ["CMD", "ipcr-forge-bridge", "health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + expose: + - 8080 + - 8081 + labels: + caddy_0: ipcr-forge-${APP_DOMAIN} + caddy_0.import: gateway_tls + caddy_0.reverse_proxy: "{{upstreams 8080}}" + caddy_1: ipcr-forge-${APP_PUBLIC_IP_DASH}.nip.io + caddy_1.import: gateway_tls + caddy_1.reverse_proxy: "{{upstreams 8080}}" + caddy_2: ipcr-forge-${APP_PUBLIC_IP_DASH}.sslip.io + caddy_2.reverse_proxy: "{{upstreams 8080}}" + networks: + - pcs + # To IPCR's admin API only. + - ipcr-admin + + # ═══ Gitea — the source of truth ═══════════════════════════════════════════════ + # From Apps/Gitea; its data folder moves to $AppID/gitea/. + + # Gitea itself — git hosting, the Actions controller, and the OCI package + # registry, all in one process. SQLite: a personal forge never has the write + # concurrency that would justify a second container for Postgres. + gitea: + image: gitea/gitea:1.27.3-rootless + container_name: gitea + user: $PUID:$PGID + restart: unless-stopped + cpu_shares: 70 + deploy: + resources: + limits: + memory: 1G + environment: + TZ: $TZ + # Every URL Gitea hands out — clone URLs, the registry host, and the + # server_url the CI runner passes to jobs — is derived from ROOT_URL, so + # it has to be the address that works from outside as well as inside. + GITEA__server__ROOT_URL: https://gitea-${APP_DOMAIN}/ + GITEA__server__DOMAIN: gitea-${APP_DOMAIN} + GITEA__server__HTTP_PORT: "3000" + GITEA__server__PROTOCOL: http + # The address Gitea's own CLI uses to reach the running server's internal + # API. It defaults to localhost, which is right for the server itself and + # useless from the init container that runs + # `gitea actions generate-runner-token`. It has to be set here rather than + # there: the CLI reads it out of the shared app.ini, and only this + # container's entrypoint renders environment into app.ini. + GITEA__server__LOCAL_ROOT_URL: http://gitea:3000/ + # Git over SSH would need a published host port of its own. Everything + # here — clone, push, CI, registry — goes over HTTPS instead. + GITEA__server__DISABLE_SSH: "true" + GITEA__server__START_SSH_SERVER: "false" + # Skips the first-run install wizard: every value it would ask for is set + # here, and the admin account is created by an init step below. + GITEA__security__INSTALL_LOCK: "true" + GITEA__database__DB_TYPE: sqlite3 + GITEA__database__PATH: /var/lib/gitea/data/gitea.db + GITEA__service__DISABLE_REGISTRATION: "true" + GITEA__service__REQUIRE_SIGNIN_VIEW: "true" + GITEA__actions__ENABLED: "true" + # Where a bare `uses: actions/checkout@v4` is resolved from. + GITEA__actions__DEFAULT_ACTIONS_URL: github + # Forge: off. Images go to the staging registry that IPCR follows; a second registry would + # be a second place an image could be published from. + GITEA__packages__ENABLED: "false" + # Forge: webhooks may reach the bridge (ipcr-forge, on this server's network) and, as by + # default, any public address. Not `private`: that would let any user's webhook reach every + # container on the shared network. + GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge + volumes: + - ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/data:/var/lib/gitea + - ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/config:/etc/gitea + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/healthz"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + expose: + - 3000 + labels: + caddy_0: gitea-${APP_DOMAIN} + caddy_0.import: gateway_tls + caddy_0.reverse_proxy: "{{upstreams 3000}}" + caddy_1: gitea-${APP_PUBLIC_IP_DASH}.nip.io + caddy_1.import: gateway_tls + caddy_1.reverse_proxy: "{{upstreams 3000}}" + caddy_2: gitea-${APP_PUBLIC_IP_DASH}.sslip.io + caddy_2.reverse_proxy: "{{upstreams 3000}}" + networks: + - pcs + + # ═══ CI — Gitea Actions ════════════════════════════════════════════════════════ + + # The runner: act_runner (plain, not its -dind variant) driving the separate daemon below over a + # unix socket. Runs unprivileged; whoever controls it controls that daemon, which is the point. + # Its config (seed/gitea/runner-config/config.yaml) puts jobs on the daemon's host network, where + # the staging registry is localhost:5000. + forge-runner: + image: gitea/act_runner:0.6.1 + container_name: forge-runner + user: $PUID:$PGID + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + restart: unless-stopped + # The stock run.sh reads the registration token once and gives up after 10 tries; the token is + # minted by an install step that runs after the stack is up. So wait for it (or for an + # existing registration) first. + entrypoint: + - /sbin/tini + - -- + - sh + - -c + - | + until [ -s /data/token ] || [ -s /data/.runner ]; do echo "waiting for the runner registration token"; sleep 2; done + until [ -S /run/dind/docker.sock ]; do sleep 1; done + exec run.sh + cpu_shares: 20 + deploy: + resources: + limits: + # act_runner holds a job's checkout and actions in memory while it copies them into the + # job container: 256M was OOM-killed mid-job on the first build (holyhorse, 2026-10-04). + memory: 1G + environment: + TZ: $TZ + HOME: /data + DOCKER_HOST: unix:///run/dind/docker.sock + # Handed to every job as GITHUB_SERVER_URL, and jobs run on the daemon's network where + # `gitea` does not resolve: checkout goes out and back over HTTPS. + GITEA_INSTANCE_URL: https://gitea-${APP_DOMAIN} + GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token + GITEA_RUNNER_NAME: forge-runner + # Registration-time labels; runner-config/config.yaml repeats them and wins on every start. + GITEA_RUNNER_LABELS: ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04 + CONFIG_FILE: /config/config.yaml + volumes: + # .runner (this runner's registration) and the registration token the init step leaves. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner:/data + - ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-config:/config:ro + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind + depends_on: + - gitea + - forge-docker + networks: + # Egress: it polls Gitea through its public address, like the jobs. + - ci-internal + + # The daemon jobs run in: Docker-in-Docker, so jobs and the images they build never touch the + # host's daemon. See rationale.md for what `privileged` buys and what it costs. No TCP listener: + # its only API is a unix socket in a folder shared with forge-runner, owned by $PGID. + forge-docker: + image: docker:29.1-dind + container_name: forge-docker + privileged: true + restart: unless-stopped + cpu_shares: 20 + command: ["--host=unix:///run/dind/docker.sock", "--group=$PGID"] + deploy: + resources: + limits: + memory: 2G + environment: + TZ: $TZ + # No certificates: with an explicit --host the entrypoint adds no TCP listener to protect. + DOCKER_TLS_CERTDIR: "" + volumes: + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind + # The daemon's image store. Without it every restart re-pulls every job image. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/docker:/var/lib/docker + healthcheck: + test: ["CMD", "docker", "-H", "unix:///run/dind/docker.sock", "info"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - ci-internal + + # The staging registry's front door: `localhost:5000` to the daemon and to every job (act runs + # jobs on the host network, which is this namespace), forge-docker:5000 to ipcr-gateway. Plain + # HTTP, which Docker allows to localhost only. It checks a login on every request: a job logs in + # as its repository (/, the IPCR_PUSH_TOKEN secret the bridge sets on it) and may + # push that name only; IPCR logs in as the importer (state/staging-auth, which it generates). + # Same image as the bridge (`ipcr-forge-bridge gate`, bridge/gate.go). See rationale.md. + forge-gate: + image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0 + container_name: forge-gate + user: $PUID:$PGID + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + restart: unless-stopped + network_mode: service:forge-docker + command: ["ipcr-forge-bridge", "gate"] + cpu_shares: 20 + deploy: + resources: + limits: + memory: 64M + environment: + TZ: $TZ + GATE_LISTEN: ":5000" + GATE_UPSTREAM: unix:///run/staging/registry.sock + GATE_CREDENTIALS: /gate/credentials.json + volumes: + # Hashes only, written by the bridge. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state/gate:/gate:ro + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging + depends_on: + - forge-docker + - forge-registry + + # The staging registry itself: no network at all. It listens on a unix socket in a folder only the + # gate also mounts, so nothing reaches it but through the gate: not a job, which runs in the + # daemon above and sees neither the folder nor a port. + forge-registry: + image: registry:3.0.0 + container_name: forge-registry + user: $PUID:$PGID + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + restart: unless-stopped + network_mode: none + # Garbage collection frees the blobs of deleted tags, and must not run while the registry + # serves: so it runs at every start, before serving. + entrypoint: ["sh", "-c", "registry garbage-collect --delete-untagged /etc/distribution/config.yml; exec registry serve /etc/distribution/config.yml"] + cpu_shares: 20 + deploy: + resources: + limits: + memory: 128M + environment: + TZ: $TZ + REGISTRY_HTTP_NET: unix + REGISTRY_HTTP_ADDR: /run/staging/registry.sock + # Upload URLs relative to the request, whatever Host the gate passes on. + REGISTRY_HTTP_RELATIVEURLS: "true" + # Staging only: IPCR has its own copy once imported, and deletes each tag it has published + # (IMPORT_CLEANUP). + REGISTRY_STORAGE_DELETE_ENABLED: "true" + OTEL_TRACES_EXPORTER: none + volumes: + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry:/var/lib/registry + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging + + # ═══ Radicle — the public mirror ═══════════════════════════════════════════════ + # Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The bridge + # creates and pushes repositories in this node's storage; the node replicates them. + + # The web UI: the Radicle Explorer bundle served as static files by Caddy. + # It is a pure client-side app — every request it makes goes from the visitor's + # browser straight to the API below, which is why that one needs its own domain. + radicle: + image: dirk1980/radicle-explorer-container@sha256:0e31485c38a765e83a10b1a4bf71b12d4f9fec630881d171bba736a4a56a16ce + container_name: radicle + user: $PUID:$PGID + restart: unless-stopped + cpu_shares: 80 + deploy: + resources: + limits: + memory: 128M + environment: + TZ: $TZ + # Caddy writes its autosaved config and its (unused here) certificate store + # under these two paths. The image points them at /config and /data, which are + # root-owned, so as $PUID the process cannot create them and refuses to start. + XDG_CONFIG_HOME: /tmp/caddy-config + XDG_DATA_HOME: /tmp/caddy-data + volumes: + # Replaces the image's own :80 Caddyfile — see the comments in that file. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + expose: + - 8080 + labels: + caddy_0: radicle-${APP_DOMAIN} + caddy_0.import: gateway_tls + caddy_0.reverse_proxy: "{{upstreams 8080}}" + caddy_1: radicle-${APP_PUBLIC_IP_DASH}.nip.io + caddy_1.import: gateway_tls + caddy_1.reverse_proxy: "{{upstreams 8080}}" + caddy_2: radicle-${APP_PUBLIC_IP_DASH}.sslip.io + caddy_2.reverse_proxy: "{{upstreams 8080}}" + networks: + - pcs + + # radicle-httpd — the node's read-only JSON API, on its own domain. + # + # This domain is deliberately NOT behind the SSO gate: the explorer above is a + # static bundle running in the visitor's browser, so it is the browser that calls + # this API, cross-origin. The daemon serves public repositories only (private ones + # are filtered out upstream) and has no write endpoints at all. See rationale.md. + radicle-api: + image: dirk1980/radicle-httpd@sha256:5e95be30e4bde55b509a65fd56f8b825cafd0f5cd752c1a13e79aad75b958895 + container_name: radicle-api + user: $PUID:$PGID + restart: unless-stopped + cpu_shares: 70 + deploy: + resources: + limits: + memory: 512M + command: ["--listen", "0.0.0.0:8080"] + environment: + TZ: $TZ + volumes: + # Same Radicle home as the node: the daemon reads the repository storage + # directly and talks to the node over the control socket inside it. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/ + healthcheck: + # curl, not wget: this image carries curl and no busybox. + test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/api/v1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + expose: + - 8080 + labels: + caddy_0: radicle-api-${APP_DOMAIN} + caddy_0.import: gateway_tls + caddy_0.reverse_proxy: "{{upstreams 8080}}" + caddy_1: radicle-api-${APP_PUBLIC_IP_DASH}.nip.io + caddy_1.import: gateway_tls + caddy_1.reverse_proxy: "{{upstreams 8080}}" + caddy_2: radicle-api-${APP_PUBLIC_IP_DASH}.sslip.io + caddy_2.reverse_proxy: "{{upstreams 8080}}" + networks: + - pcs + + # The node itself: speaks the Radicle peer-to-peer protocol (gossip, fetch, + # announce) on TCP 8776. No HTTP, so no Caddy route and no place on the shared + # network — it only needs egress, which the app-internal bridge provides. + radicle-node: + image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2 + container_name: radicle-node + user: $PUID:$PGID + restart: unless-stopped + cpu_shares: 50 + deploy: + resources: + limits: + memory: 1G + environment: + TZ: $TZ + volumes: + - ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/ + ports: + # Radicle peer-to-peer protocol. Other nodes dial in here to fetch the + # repositories this node seeds; without it the node still syncs outbound, + # but nobody can fetch from it. Hence the needs-public-ip tag. + - "8776:8776" + healthcheck: + # `rad node status` round-trips the node's control socket, which is the only + # liveness signal this container has — but it also renders the peer table, and + # that takes 3-8s on an idle node and longer while the node is syncing. Hence + # the unusually generous timeout: a 10s one fails during the first sync and + # flaps the container between healthy and unhealthy. + test: ["CMD", "rad", "node", "status"] + interval: 60s + timeout: 30s + retries: 3 + start_period: 90s + networks: + - radicle-internal + + # ═══ IPCR — the IPFS container registry ════════════════════════════════════════ + # Verbatim from Apps/IPCR; only its data folder moves to $AppID/ipcr/. + + # ── The registry Docker talks to ──────────────────────────────────────────── + # ipcrd: TLS front door on 127.0.0.1:4767. Passes /ipfs/ through to the stock nerdctl + # registry below, resolves /ipns/: 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//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/:; + # a tag is re-imported when it moves. Empty = off. IPCR_IMPORT_AUTH is user:token, + # a read-only (read:package) token is enough. + # Forge: watch the CI's staging registry, over ci-internal. Internal, so no credentials. + IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000} + IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-} + # Forge: IPCR's login at the staging registry's gate, generated on first start; the bridge + # reads it (read-only ipcr/state) and gives it the importer's rights at the gate. + IMPORT_AUTH_FILE: /data/state/staging-auth + IMPORT_AUTH_GENERATE: "true" + IMPORT_REPOS: ${IPCR_IMPORT_REPOS:-} + # Forge: every repository this node builds is published under one IPNS key, "forge", as + # /ipns//:. One name for the whole forge, which a DNSLink domain or an + # ENS name can point at: ipcr.localhost:4767/ipns/example.eth/:. + IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge} + # Forge: publish only the repositories the bridge allows (public Gitea repositories, minus + # those switched off in the admin pages). Until the bridge sends its list, nothing is. + IMPORT_ALLOW_REQUIRED: "true" + # Forge: delete each tag from the staging registry once published (its blobs go at the + # registry's next start, see forge-registry). + IMPORT_CLEANUP: "true" + # IPNS records valid 7 days instead of Kubo's 48 h, so a forge offline for a weekend keeps + # its name; resolvers cache them 5 minutes, so a new tag shows up quickly. + IPNS_LIFETIME: ${IPCR_IPNS_LIFETIME:-168h} + IPNS_TTL: 5m + # Forge: the admin API, for the bridge, on ipcr-admin. Bearer token in state/admin-token. + ADMIN_LISTEN: ":4769" + IMPORT_INTERVAL: 60s + STATE_DIR: /data/state + volumes: + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/tls:/data/tls + # What the watcher already imported (tag → manifest digest). + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/data/state + # The host's Docker trust store, so the CA above can be dropped into it: no daemon + # reload, no daemon.json edit. The parent is mounted because Compose cannot create a + # bind source whose name contains a colon (ipcr.localhost:4767). ipcrd writes exactly + # one file here. See rationale.md. + - /etc/docker/certs.d:/certs.d + ports: + # Loopback only: the client is the host's Docker daemon, which resolves ipcr.localhost + # to 127.0.0.1 (RFC 6761). Not reachable from the network, and not a web UI. + - "127.0.0.1:4767:4767" + depends_on: + - ipcr-registry + healthcheck: + test: ["CMD", "ipcrd", "health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - ipcr-internal + # Forge only: reaches the staging registry for the import poller above. + - ci-internal + # Forge only: the admin API, reached by the bridge. + - ipcr-admin + cpu_shares: 50 + deploy: + resources: + limits: + memory: 128M + + # ── Public front door ─────────────────────────────────────────────────────── + # Forge only: the same ipcrd, a second process, serving the registry API to anyone at + # ipcr-, 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-/ipns//:` + # 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, `/`: 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, + ``: 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, `/` : 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, `` : ses membres décident qui y publie. Les workflows des forks n'ont pas de + secrets. La CI tourne sur un démon Docker privilégié : ne donnez l'accès en écriture qu'à des + personnes à qui vous confiez le serveur. + index: / + webui_port: 3000 + store_app_id: ipcr-forge + +x-compose-app: + schema_version: 2 + folders: + # ── Bridge ── + - path: /DATA/AppData/$AppID/bridge/state + user: $PUID + group: $PGID + - path: /DATA/AppData/$AppID/bridge/secrets + user: $PUID + group: $PGID + mode: "0700" + # The gate's credentials (hashes), written by the bridge, read by the gate. + - path: /DATA/AppData/$AppID/bridge/state/gate + user: $PUID + group: $PGID + # ── Gitea ── + - path: /DATA/AppData/$AppID/gitea/data + user: $PUID + group: $PGID + - path: /DATA/AppData/$AppID/gitea/config + user: $PUID + group: $PGID + - path: /DATA/AppData/$AppID/gitea/runner + user: $PUID + group: $PGID + mode: "0700" + - path: /DATA/AppData/$AppID/gitea/runner-config + user: $PUID + group: $PGID + # ── Radicle ── + - path: /DATA/AppData/$AppID/radicle/home + user: $PUID + group: $PGID + - path: /DATA/AppData/$AppID/radicle/caddy + user: $PUID + group: $PGID + # ── CI daemon ── + # The daemon's socket: created by root, group $PGID (dockerd --group), mode 0660. + - path: /DATA/AppData/$AppID/ci/socket + user: $PUID + group: $PGID + mode: "0750" + - path: /DATA/AppData/$AppID/ci/docker + user: $PUID + group: $PGID + mode: "0710" + - path: /DATA/AppData/$AppID/ci/registry + user: $PUID + group: $PGID + mode: "0700" + # The staging registry's socket: the registry and the gate only. + - path: /DATA/AppData/$AppID/ci/registry-socket + user: $PUID + group: $PGID + mode: "0700" + # ── IPCR ── + - path: /DATA/AppData/$AppID/ipcr/kubo + user: $PUID + group: $PGID + mode: "0700" + # Created ahead of Kubo's first start: the bridge mounts it, and Docker would otherwise create + # it as root, where Kubo cannot write its keys. + - path: /DATA/AppData/$AppID/ipcr/kubo/keystore + user: $PUID + group: $PGID + mode: "0700" + - path: /DATA/AppData/$AppID/ipcr/init.d + user: $PUID + group: $PGID + files: + # Each entry and its reason are in the standalone listing it comes from. All follow store + # updates (re-rendered on every up). + - path: /DATA/AppData/$AppID/gitea/runner-config/config.yaml + from: gitea/runner-config/config.yaml + ensure: always + mode: "0644" + - path: /DATA/AppData/$AppID/radicle/home/config.json + from: radicle/home/config.json.tmpl + ensure: always + mode: "0644" + - path: /DATA/AppData/$AppID/radicle/caddy/Caddyfile + from: radicle/caddy/Caddyfile.tmpl + ensure: always + mode: "0644" + - path: /DATA/AppData/$AppID/ipcr/init.d/01-ipcr-node.sh + from: ipcr/init.d/01-ipcr-node.sh + ensure: always + mode: "0755" + init: + # In order: Radicle's identity before its node starts, then Gitea's admin, then the runner + # token and the bridge's setup, which both need that admin to exist. + - name: radicle-identity + image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2 + user: $PUID:$PGID + volumes: + - /DATA/AppData/$AppID/radicle/home:/radicle-home + command: ["sh", "-c", "yes '' | rad auth --alias radicle-${APP_PUBLIC_IP_DASH} --stdin"] + when: absent:/DATA/AppData/$AppID/radicle/home/keys/radicle + - name: gitea-admin + image: gitea/gitea:1.27.3-rootless + user: $PUID:$PGID + phase: post_up + volumes: + - /DATA/AppData/$AppID/gitea/data:/var/lib/gitea + - /DATA/AppData/$AppID/gitea/config:/etc/gitea + command: + - sh + - -c + - | + timeout 240 sh -c 'until gitea admin user list >/dev/null 2>&1; do sleep 3; done' + gitea admin user create --admin --username gitea_admin \ + --password "$APP_DEFAULT_PASSWORD" --email "$APP_EMAIL" \ + --must-change-password=false \ + || echo "admin account already present, leaving it alone" + when: always + - name: gitea-runner-token + image: gitea/gitea:1.27.3-rootless + user: $PUID:$PGID + phase: post_up + network: $APP_NET + volumes: + - /DATA/AppData/$AppID/gitea/data:/var/lib/gitea + - /DATA/AppData/$AppID/gitea/config:/etc/gitea + - /DATA/AppData/$AppID/gitea/runner:/runner + command: + - sh + - -c + - | + timeout 240 sh -c 'until gitea actions generate-runner-token > /runner/token.new 2>/dev/null; do sleep 3; done' + mv /runner/token.new /runner/token + chmod 600 /runner/token + when: absent:/DATA/AppData/$AppID/gitea/runner/.runner + # The bridge's Gitea tokens (public-only read; and write:repository for push secrets), the + # webhook secret, the system webhook, the admin pages' OAuth2 app, the root organisation and + # the example repository (in that organisation). Idempotent, so on every start: it repairs the webhook. + # The admin password is used here only, never stored. + - name: forge-setup + image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0 + user: $PUID:$PGID + phase: post_up + network: $APP_NET + volumes: + - /DATA/AppData/$AppID/bridge/secrets:/secrets + command: + - sh + - -c + - GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 IPCR_ROOT_ORG="$IPCR_ROOT_ORG" PUBLIC_HOSTS="ipcr-forge-$APP_DOMAIN,ipcr-forge-$APP_PUBLIC_IP_DASH.nip.io,ipcr-forge-$APP_PUBLIC_IP_DASH.sslip.io" ipcr-forge-bridge setup + when: always + webui-host: gitea-${domain} + webui-path: / diff --git a/Apps/IPCR-Forge/icon.png b/Apps/IPCR-Forge/icon.png new file mode 100644 index 0000000..70da9e0 Binary files /dev/null and b/Apps/IPCR-Forge/icon.png differ diff --git a/Apps/IPCR-Forge/rationale.md b/Apps/IPCR-Forge/rationale.md new file mode 100644 index 0000000..bce7c60 --- /dev/null +++ b/Apps/IPCR-Forge/rationale.md @@ -0,0 +1,280 @@ +# IPCR Forge — Rationale + +IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon, +and the bridge (`ipcr-forge-bridge`), which mirrors public Gitea repositories to Radicle and +serves the forge's page. The Gitea, Radicle and IPCR services are copied from their listings, +so each keeps the deviations its standalone listing already argues. This document lists them, +then covers only what the bundle adds. + +Gitea is the source of truth. Earlier versions made Radicle the source: first with a read-only +Gitea fed from it (v1), then with CI on the Radicle node alone (v2). Users found both hard to work +with. See docs/forge.md. + +## What deviation / exception is being requested + +**Inherited, unchanged.** The full argument for each is in that listing's `rationale.md`. + +| From | Deviation | +| --- | --- | +| Gitea | An admin account (`gitea_admin`) created with the server's default app password | +| Radicle | No authentication gate on `radicle-` and `radicle-api-`; host port `8776/tcp` | +| IPCR | `ipcr-gateway` and `ipcr-registry` run as root (no capabilities); `/etc/docker/certs.d` mounted; no authentication; ports `127.0.0.1:4767` and `4768/tcp+udp` | + +**Added by the bundle:** + +1. **`forge-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did. +2. **The bridge (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's + key. It publishes repositories signed by that key. +3. **A system webhook and its allow-list.** Gitea may deliver webhooks to `ipcr-forge` on the + server's network (`GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge`). +4. **An install step that uses the admin password** to mint the bridge's token and to register + that webhook. +5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry + is. +6. **A public page.** `ipcr-forge-` is not behind the SSO gate. +7. **Public repositories are published to Radicle automatically**, and that cannot be undone. +8. **It replaces three apps** and cannot be installed beside them: it uses the same container names, + domains and host ports. +9. **Admin pages with their own login** (`/admin`): Gitea OAuth2, Gitea site admins only. The + install step registers the OAuth2 app. +10. **The bridge mounts Kubo's keystore read-only**, so it can export the publisher key, encrypted. +11. **An admin API in `ipcr-gateway`** (`:4769`) on a new internal network, `ipcr-admin`. Its Bearer + token is in `ipcr/state/admin-token`, mode 0644. +12. **A staging gate** (`forge-gate`), running in the CI daemon's network namespace. The registry + behind it has no network and listens on a unix socket. +13. **A second Gitea token for the bridge,** with `write:repository` and `read:user`. It sets each + publishing repository's `IPCR_PUSH_TOKEN` Actions secret and checks name collisions. +14. **A credential IPCR generates for itself** (`ipcr/state/staging-auth`, mode 0644), read by the + bridge. +15. **The root organisation** (`IPCR_ROOT_ORG`): setup creates it if missing, and its repositories + are also published at the root of the forge's name. +16. **A public, unauthenticated, read-only registry route** (`ipcr-` → `ipcr-public`), so this + forge's images can be pulled without IPCR. + +## Why it is necessary + +**1. Privileged Docker-in-Docker.** Building an image needs a Docker daemon. A CI job can get one +in two ways: +- the host's socket, which gives every workflow root on the PCS; +- a daemon of its own, which needs `privileged`. + +The second keeps a build inside its own container and image store. The rootless variant does +not start on this platform (`kernel.apparmor_restrict_unprivileged_userns=1`; see +Apps/Gitea/rationale.md, where it was tried first). The runner itself is a separate, unprivileged +container that reaches the daemon over a unix socket. + +**2. The node's home.** Publishing to Radicle means three things: creating a repository in the +node's storage, signing its refs with the node's key, and announcing them through the node's +control socket. Those live in that folder. This is the mount `radicle-api` already has. The bridge +uses the stock `rad` CLI (1.10.1, the node's version) rather than reimplementing any of it. + +**3. The webhook.** Pushes reach Radicle within seconds instead of at the next 10-minute +reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the bridge sits +on one. The list adds exactly one host name. `private` would let any user's webhook reach every +container on the shared network. + +**4. The admin password at install.** Gitea's token API and its system-webhook API accept only an +administrator signing in. The step uses the password once per start, never stores it, and +leaves the running service holding nothing but a `public-only`, `read:repository` token. If the +password has been changed, the step keeps the existing token and webhook instead of failing. + +**5. The network link.** IPCR publishes images by *pulling* them: it polls a registry and +imports every new tag into IPFS. Jobs run in the CI's own daemon, which cannot reach +`ipcr.localhost`, because that name is the host's loopback. So a workflow pushes to a staging +registry instead. That registry shares the daemon's network namespace, so every job sees it as +`localhost:5000`. That is the only address Docker and BuildKit accept plain HTTP for, so no TLS +or credentials are involved. `ipcr-gateway` reaches it as `forge-docker:5000` over `ci-internal`. + +**6. The public page.** It holds links, documentation, image names that are public on IPFS anyway, +and the list of repositories already public on Radicle. A login in front of it would stand between +a stranger and the instructions for pulling an image. + +**7. Publishing to Radicle.** That is the point of the mirror: the code of a released image stays +available without this server. Only public repositories go. A public Gitea repository is meant to +be read by anyone, and Radicle is one more place to read it. + +**8. Replacing three apps.** The bundle runs the same services, not a different product. Renaming +every container, domain and port so they could coexist would break the defaults the services rely +on (`radicle-api:8080`, `gitea:3000`) and the addresses users type. + +**9. Admin pages.** The operator needs to manage the IPCR side: the ENS or DNS name; whether the +name and the images resolve from outside; unpublishing; which repositories may publish; and, most +of all, backing up the publisher key, which is the only copy of every image name. Gitea is the +forge's identity system, so the admin pages sign in with it rather than adding accounts or relying +on the PCS gate, which is not on every host. + +**10. The keystore mount.** Kubo exports keys from its CLI only (`key/export` is not available +over its HTTP API). ipcrd runs as root without capabilities, so it cannot read a `$PUID` 0700 +folder; giving it `DAC_READ_SEARCH` was rejected once already. The bridge runs as `$PUID` and can. + +**11. The admin API and its token.** Unpublishing, moving tags, key restore and the allowlist +change ipcrd's own state (the published tree, `published.json`), so they live in ipcrd. The token is +needed because CI jobs on `ci-internal` can reach `ipcr-gateway`. The file is world-readable +because ipcrd, root without `CAP_CHOWN`, cannot hand a file to `$PUID` any other way. It sits in +the app's own state folder. + +**12. The gate.** Before it, any CI job could push any image name to the staging registry, and IPCR +published what it found. With a short organisation address (`metadec.eth/app`), that would let +anyone with write access to any repository publish under the organisation's name. The gate makes +each repository's credential good for its own name only. It is a proxy rather than a registry +token server: Docker, BuildKit and ipcrd all answer a Basic challenge, so no JWT signing or second +realm is needed. The registry moves to a unix socket so the gate cannot be bypassed: the jobs' +daemon sees neither the socket's folder nor a port. + +**13. The secrets token.** Gitea Actions secrets are the one credential Gitea gives a repository's +own workflows and withholds from forks. The job's automatic `GITEA_TOKEN` cannot prove which +repository it belongs to. Setting a secret takes `write:repository`. The token is not +`public-only`, so collision checks also see private users and organisations. + +**14. IPCR's own credential.** Same handoff as the admin token: ipcrd (root without capabilities) +cannot write into another uid's folder, so it writes into its own state folder, which the bridge +already mounts read-only. + +**15. The root organisation.** Short names (`metadec.eth/ipcr-hello`) need one owner whose +repositories may use them. A Gitea organisation makes "who may publish there" a matter of team +membership, managed in Gitea. + +**16. The public front door.** The IPFS address only works on machines running IPCR. People trying +an image elsewhere (a laptop, a CI on another platform) need a registry that plain Docker can pull +from. Public IPFS gateways cannot do it, since they have no registry API. A login would defeat the +purpose: the images are public on IPFS anyway. + +## Security mitigations in place + +**The bridge** +- **It sees public repositories only.** Its Gitea token is scoped `public-only` and + `read:repository`. Gitea hides every private or limited repository from it, and it can write + nothing in Gitea. The code checks visibility a second time. Forks and pull mirrors are skipped. +- **Webhooks are authenticated and carry no content.** Each delivery must pass the HMAC-SHA256 + check against a random secret written at install (`bridge/secrets`, 0700). A delivery only says + *which* repository changed. The bridge re-reads that repository from Gitea, so a forged payload + can at most cause a pointless sync. Port 8081 has no Caddy route. +- **It runs unprivileged:** `$PUID`, every capability dropped, a read-only root filesystem, + `no-new-privileges`, 256 MB. One worker does one sync at a time. +- **The node's key stays in the bridge and the node.** No CI job mounts the Radicle home. + +**The CI daemon** +- **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an + empty `DOCKER_TLS_CERTDIR`. The unix socket sits in a folder shared only with `forge-runner`, + group `$PGID`. +- **`forge-runner` runs unprivileged:** `$PUID`, no capabilities, `no-new-privileges`. Jobs run in + the nested daemon, never on the host's. +- **The staging registry is unreachable from outside.** It has no port and no Caddy route and sits + on `ci-internal` only. It runs as `$PUID` with no capabilities. +- **Bounded.** One job at a time, at most 1 hour per job, 2 GB for the daemon. +- **Severable.** Stopping `forge-runner` and `forge-docker` removes the privileged container and + leaves a working Gitea, Radicle node and IPCR registry. + +**Gitea** +- **Fork pull requests wait for approval.** In Gitea 1.27, a run from a user without write access + waits for "Approve and run". +- **Registration is off.** Every account is created by the admin. +- **Who can publish is stated** in the tips and on the page. Write access to any repository means + being able to publish images under the forge's name. Images from private repositories become + public on IPFS. + +**The staging gate** +- **Least privilege per credential.** + - A repository's credential reads and writes only `/v2///{blobs,manifests,tags}/`: + no catalog, no delete, no other name. + - Cross-repository blob mounts are stripped. + - The importer can read, list and delete, but not push. + - Paths with `..`, `//` or encoded segments are refused. +- **The gate holds no secrets.** It stores SHA-256 hashes of random 32-byte tokens and compares + them in constant time. The tokens themselves exist only in Gitea's secret store and in the jobs + that use them. +- **The registry cannot be reached directly.** It runs with `network_mode: none`, and its socket + folder (0700, `$PUID`) is mounted only into the gate. +- **Credentials follow the list.** A repository switched off or frozen loses its gate credential + and its secret at once. "Reissue" rotates a token. +- **Gate container:** `$PUID`, no capabilities, read-only root filesystem, 64 MB. + +**The public front door** +- **This forge's images only.** It answers only for the publisher's own IPNS name, its verified + ENS/DNS name, and the root CIDs of published images. Anything else is 404 before any resolution, + so strangers cannot use it to fetch or serve other IPFS content. +- **Read-only and storage-neutral.** GET and HEAD only, and `AUTO_PIN=false`: a pull never pins. +- **Isolated.** + - It is a separate process (`ipcr-public`) on `pcs` and `ipcr-internal` only, not on the admin + or CI networks. + - It runs as `$PUID` with no capabilities and a read-only root filesystem, capped at 256 MB. + - It reads the main gateway's state read-only. +- **Can be switched off** in the admin pages (`config.json` `"public": false`). + +**The page** +- **The public page cannot change anything.** The bridge serves it read-only: the embedded page, + IPCR's `published.json` (mounted read-only) and the bridge's repository list. + +**The admin pages** +- **Only Gitea site admins get in.** The check is `is_admin`, read from Gitea's own API through the + internal address. The Gitea token is dropped right after. +- **The session cookie:** + - HMAC-signed, `HttpOnly`, `Secure`, valid 8 h; + - the login uses PKCE and `state`, and is only accepted on the hosts in `PUBLIC_HOSTS`. +- **Every write must be same-origin.** The `Origin` header (or Fetch Metadata) must match, and the + custom `X-IPCR-Admin` header is required. A cross-site form cannot send that header, and a + cross-site script cannot without a CORS preflight, which is never answered. +- **The admin API is only on `ipcr-admin`.** That network is `internal: true` and shared only by + the bridge and `ipcr-gateway`; every call needs the Bearer token, compared in constant time. Kubo's + own API stays on `ipcr-internal`. +- **The cleartext key never crosses the network.** The bridge encrypts it in memory (AES-256-GCM, + PBKDF2 at 600k iterations, at least 12 characters of passphrase) and ipcrd decrypts it. A + restore never deletes a key: the previous one is kept, renamed. +- **The allowlist is closed by default.** Until the bridge sends its list, nothing is published + (`IMPORT_ALLOW_REQUIRED`). Private repositories are never on it. + +## Alternatives considered and rejected + +| Alternative | Why not | +| --- | --- | +| Radicle as the source, Gitea read-only (v1) | Users had to work in Radicle and found a read-only Gitea confusing | +| Radicle only, CI on the node (v2) | Not user-friendly enough: every step went through `rad` | +| Gitea push mirror to Radicle | Gitea cannot push to `rad://` (no `git-remote-rad`), and mirror pushes run on a timer | +| Publishing to Radicle from a workflow job | It would put the node's key inside CI jobs | +| A per-repository webhook | Every repository would need setup; one system webhook covers all of them, including new ones | +| Gitea's package registry as the staging registry (v1) | Needs tokens for the workflow and for IPCR, plus the `DAC_READ_SEARCH` capability for IPCR to read its token. It would also be a second place images get published from | +| Host Docker socket for jobs | Any workflow would get root on the host | +| A long-lived admin token for the bridge | The webhook is repaired by the install step on every start instead, so the running service never holds admin rights | + +## Known limitations + +- **Radicle cannot unpublish.** A repository made private or deleted in Gitea is *frozen* (no + further sync). What already reached the network stays there. +- **A deleted tag stays in Radicle's canonical `refs/tags/`.** It is removed from the node's own + namespace only. +- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only + delegate. +- **The gate is not a sandbox.** Jobs run on a privileged Docker-in-Docker daemon (deviation 1). + Anyone who can run a workflow can break out of it to the host, and from there do anything, + including publish under any name. The gate removes the ordinary path, a plain `docker push` of + someone else's name. It cannot contain a hostile CI user. Real isolation needs a VM-based runner. +- **Repository writers can read the push token,** since a workflow can print it. Reissue it when a + repository changes hands. +- **Existing workflows must add a login step,** or their pushes fail with 401. +- **Private repositories cannot publish:** they get no push credential. +- **The bridge can read the publisher key.** A compromise of the public-facing bridge leaks it. That + is the trust it already has with the Radicle node key. +- **The admin token is world-readable** inside the app's state folder on the host. +- **Old keys accumulate.** `-replaced-*` keys are kept and republished by Kubo until removed by + hand (`docker exec ipcr-kubo ipfs key rm `). +- **Re-importing a tag needs it in staging,** and staging is cleaned once a tag is published. A new + CI run brings it back. + +## Data protection + +Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part: + +| Folder | Holds | +| --- | --- | +| `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config | +| `radicle/` | node identity (key pair), seeded repositories, Caddy config | +| `bridge/` | `state/`: the Gitea → Radicle mapping, one bare mirror per repository (disposable), the admin session key and the repository toggles (`admin.json`); `gate/credentials.json` (token hashes); `secrets/` (0700): its two Gitea tokens, the webhook secret, the OAuth2 client credentials | +| `ci/` | the daemon's socket, its image store (`docker/`), the staging registry (`registry/`, disposable) and its socket (`registry-socket/`) | +| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `config.json` (name, allowlist), the admin token | + +No user directory (`/DATA/Documents`, `Media`, …) is mounted. The one file outside `/DATA` is +IPCR's `/etc/docker/certs.d/ipcr.localhost:4767/ca.crt`, as in the standalone app. Identities that +matter on the network survive uninstall and reinstall, so published repositories and image names +stay valid: +- Radicle's node key; +- Kubo's node key and IPNS keys. diff --git a/Apps/IPCR-Forge/screenshot-1.png b/Apps/IPCR-Forge/screenshot-1.png new file mode 100644 index 0000000..7148051 Binary files /dev/null and b/Apps/IPCR-Forge/screenshot-1.png differ diff --git a/Apps/IPCR-Forge/screenshot-2.png b/Apps/IPCR-Forge/screenshot-2.png new file mode 100644 index 0000000..7438d1a Binary files /dev/null and b/Apps/IPCR-Forge/screenshot-2.png differ diff --git a/Apps/IPCR-Forge/screenshot-3.png b/Apps/IPCR-Forge/screenshot-3.png new file mode 100644 index 0000000..45f8cce Binary files /dev/null and b/Apps/IPCR-Forge/screenshot-3.png differ diff --git a/Apps/IPCR-Forge/seed/gitea/runner-config/config.yaml b/Apps/IPCR-Forge/seed/gitea/runner-config/config.yaml new file mode 100644 index 0000000..8e8f42f --- /dev/null +++ b/Apps/IPCR-Forge/seed/gitea/runner-config/config.yaml @@ -0,0 +1,42 @@ +# act_runner configuration for IPCR Forge. Only the settings that differ from upstream's defaults +# are written out — `act_runner generate-config` prints the full set. From Apps/Gitea's, except +# where marked "Forge:". + +log: + level: info + +runner: + # The file the runner's own registration is kept in, relative to /data. + file: .runner + # One job at a time. A PCS shares its CPU with everything else installed on + # it, and an image build is the least polite thing on the box. + capacity: 1 + timeout: 1h + fetch_timeout: 5s + fetch_interval: 2s + # Overrides the labels stored at registration (.runner). Gitea's own job images, which carry + # node, bash, git and the docker CLI with buildx, so marketplace actions (actions/checkout, + # docker/build-push-action, ...) run as they do on GitHub. + labels: + - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" + - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" + - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" + +cache: + # The cache server binds an address inside the runner container, which the + # job containers — created by the separate Docker daemon — cannot route to. + # Left on, every `actions/cache` step fails noisily instead of being skipped. + enabled: false + +container: + # Forge: jobs run on the daemon's host network, which is the forge-docker container's own + # namespace — where the staging registry listens as localhost:5000. Plain HTTP to localhost is + # the one thing Docker and BuildKit accept without TLS, so a workflow needs no login and no + # certificate. Jobs still reach the internet (actions, base images) and Gitea through its public + # address. + network: host + privileged: false + # Empty: the runner's own daemon (DOCKER_HOST) is mounted into each job, so `docker build` + # and buildx work in a job, against the same daemon (and the same localhost:5000). + docker_host: "" + force_pull: false diff --git a/Apps/IPCR-Forge/seed/ipcr/init.d/01-ipcr-node.sh b/Apps/IPCR-Forge/seed/ipcr/init.d/01-ipcr-node.sh new file mode 100644 index 0000000..629c75c --- /dev/null +++ b/Apps/IPCR-Forge/seed/ipcr/init.d/01-ipcr-node.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Run by Kubo's entrypoint before every daemon start (after `ipfs init` on the first one), +# so a store update that changes these values reaches an installed node. +set -e + +# libp2p swarm on 4768 rather than Kubo's 4001: the Kubo store app already publishes 4001 and +# two apps cannot publish the same host port. Listening on the published port itself (instead of +# mapping 4768→4001) keeps the addresses the node announces dialable. +P=${IPCR_SWARM_PORT:-4768} +ipfs config --json Addresses.Swarm "[ + \"/ip4/0.0.0.0/tcp/$P\", \"/ip6/::/tcp/$P\", + \"/ip4/0.0.0.0/udp/$P/quic-v1\", \"/ip6/::/udp/$P/quic-v1\", + \"/ip4/0.0.0.0/udp/$P/quic-v1/webtransport\", \"/ip6/::/udp/$P/quic-v1/webtransport\", + \"/ip4/0.0.0.0/udp/$P/webrtc-direct\", \"/ip6/::/udp/$P/webrtc-direct\" +]" + +# Soft cap on the block store. Pinned images are never collected; with --enable-gc everything +# else (blocks fetched but not pinned) is swept every Datastore.GCPeriod. +ipfs config Datastore.StorageMax "${IPFS_STORAGE_MAX:-20GB}" + +# How long an IPNS record stays valid when Kubo republishes it (every 4 h, while online). Kubo's +# default is 48 h: a node offline for a weekend would lose every name it publishes. ipcrd publishes +# with the same lifetime (IPNS_LIFETIME). +ipfs config Ipns.RecordLifetime "${IPCR_IPNS_LIFETIME:-168h}" diff --git a/Apps/IPCR-Forge/seed/radicle/caddy/Caddyfile.tmpl b/Apps/IPCR-Forge/seed/radicle/caddy/Caddyfile.tmpl new file mode 100644 index 0000000..9d83309 --- /dev/null +++ b/Apps/IPCR-Forge/seed/radicle/caddy/Caddyfile.tmpl @@ -0,0 +1,20 @@ +# Serves the Radicle Explorer single-page app. +# +# Port 8080, not 80: the installer runs every service as an unprivileged user, and +# such a process cannot bind a port below 1024. The image's own Caddyfile +# listens on :80, so this file replaces it. +:8080 { + root * /usr/share/caddy + + # The explorer bundle ships with the public Radicle seeds compiled into it, so + # "/" would open on the public network rather than on this server's node. This + # sends the entry point to the node page of the API served alongside it. Every + # other path is left alone, so the wider network stays browsable. + redir / /nodes/radicle-api-${APP_DOMAIN} + + # The explorer is a client-side router: /nodes/..., /repos/... and friends must + # resolve to the app shell rather than to a 404. + try_files {path} /index.html + file_server + encode gzip +} diff --git a/Apps/IPCR-Forge/seed/radicle/home/config.json.tmpl b/Apps/IPCR-Forge/seed/radicle/home/config.json.tmpl new file mode 100644 index 0000000..bc67dec --- /dev/null +++ b/Apps/IPCR-Forge/seed/radicle/home/config.json.tmpl @@ -0,0 +1,15 @@ +{ + "preferredSeeds": [ + "z6MksmpU5b1dS7oaqF2bHXhQi1DWy2hB7Mh9CuN7y1DN6QSz@seed.radicle.dev:8776", + "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776", + "z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776" + ], + "node": { + "alias": "radicle-${APP_PUBLIC_IP_DASH}", + "listen": ["0.0.0.0:8776"], + "externalAddresses": ["radicle-${APP_PUBLIC_IP_DASH}.sslip.io:8776"], + "seedingPolicy": { + "default": "block" + } + } +} diff --git a/Apps/Radicle/docker-compose.yml b/Apps/Radicle/docker-compose.yml new file mode 100644 index 0000000..79a64e9 --- /dev/null +++ b/Apps/Radicle/docker-compose.yml @@ -0,0 +1,334 @@ +name: radicle + +services: + # The web UI: the Radicle Explorer bundle served as static files by Caddy. + # It is a pure client-side app — every request it makes goes from the visitor's + # browser straight to the API below, which is why that one needs its own domain. + radicle: + image: dirk1980/radicle-explorer-container@sha256:0e31485c38a765e83a10b1a4bf71b12d4f9fec630881d171bba736a4a56a16ce + container_name: radicle + user: $PUID:$PGID + restart: unless-stopped + cpu_shares: 80 + deploy: + resources: + limits: + memory: 128M + environment: + TZ: $TZ + # Caddy writes its autosaved config and its (unused here) certificate store + # under these two paths. The image points them at /config and /data, which are + # root-owned, so as $PUID the process cannot create them and refuses to start. + XDG_CONFIG_HOME: /tmp/caddy-config + XDG_DATA_HOME: /tmp/caddy-data + volumes: + # Replaces the image's own :80 Caddyfile — see the comments in that file. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + expose: + - 8080 + labels: + caddy_0: radicle-${APP_DOMAIN} + caddy_0.import: gateway_tls + caddy_0.reverse_proxy: "{{upstreams 8080}}" + caddy_1: radicle-${APP_PUBLIC_IP_DASH}.nip.io + caddy_1.import: gateway_tls + caddy_1.reverse_proxy: "{{upstreams 8080}}" + caddy_2: radicle-${APP_PUBLIC_IP_DASH}.sslip.io + caddy_2.reverse_proxy: "{{upstreams 8080}}" + networks: + - pcs + + # radicle-httpd — the node's read-only JSON API, on its own domain. + # + # This domain is deliberately NOT behind the SSO gate: the explorer above is a + # static bundle running in the visitor's browser, so it is the browser that calls + # this API, cross-origin. The daemon serves public repositories only (private ones + # are filtered out upstream) and has no write endpoints at all. See rationale.md. + radicle-api: + image: dirk1980/radicle-httpd@sha256:5e95be30e4bde55b509a65fd56f8b825cafd0f5cd752c1a13e79aad75b958895 + container_name: radicle-api + user: $PUID:$PGID + restart: unless-stopped + cpu_shares: 70 + deploy: + resources: + limits: + memory: 512M + command: ["--listen", "0.0.0.0:8080"] + environment: + TZ: $TZ + volumes: + # Same Radicle home as the node: the daemon reads the repository storage + # directly and talks to the node over the control socket inside it. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/home/:/radicle-home/ + healthcheck: + # curl, not wget: this image carries curl and no busybox. + test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/api/v1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + expose: + - 8080 + labels: + caddy_0: radicle-api-${APP_DOMAIN} + caddy_0.import: gateway_tls + caddy_0.reverse_proxy: "{{upstreams 8080}}" + caddy_1: radicle-api-${APP_PUBLIC_IP_DASH}.nip.io + caddy_1.import: gateway_tls + caddy_1.reverse_proxy: "{{upstreams 8080}}" + caddy_2: radicle-api-${APP_PUBLIC_IP_DASH}.sslip.io + caddy_2.reverse_proxy: "{{upstreams 8080}}" + networks: + - pcs + + # The node itself: speaks the Radicle peer-to-peer protocol (gossip, fetch, + # announce) on TCP 8776. No HTTP, so no Caddy route and no place on the shared + # network — it only needs egress, which the app-internal bridge provides. + radicle-node: + image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2 + container_name: radicle-node + user: $PUID:$PGID + restart: unless-stopped + cpu_shares: 50 + deploy: + resources: + limits: + memory: 1G + environment: + TZ: $TZ + volumes: + - ${DATA_ROOT:-/DATA}/AppData/$AppID/home/:/radicle-home/ + ports: + # Radicle peer-to-peer protocol. Other nodes dial in here to fetch the + # repositories this node seeds; without it the node still syncs outbound, + # but nobody can fetch from it. Hence the needs-public-ip tag. + - "8776:8776" + healthcheck: + # `rad node status` round-trips the node's control socket, which is the only + # liveness signal this container has — but it also renders the peer table, and + # that takes 3-8s on an idle node and longer while the node is syncing. Hence + # the unusually generous timeout: a 10s one fails during the first sync and + # flaps the container between healthy and unhealthy. + test: ["CMD", "rad", "node", "status"] + interval: 60s + timeout: 30s + retries: 3 + start_period: 90s + networks: + - radicle-internal + +networks: + pcs: + name: ${APP_NET:-pcs} + external: true + # Egress-only network for the node. Nothing else joins it — the node and the + # API daemon share state through the Radicle home directory, not over TCP. + radicle-internal: + driver: bridge + +x-casaos: + architectures: + - amd64 + main: radicle + developer: Radicle + category: Developer + tags: ["developer", "personal", "needs-public-ip"] + description: + en_us: | + Radicle — your own peer-to-peer forge + + Radicle is a sovereign alternative to GitHub and GitLab. Instead of a company's server holding the canonical copy of your code, every participant keeps a full replica and gossips changes directly to peers. Repositories, issues and patches are all Git objects, signed by their author and replicated across the network — there is no central service to go down, change its terms, or lock you out. + + This app runs a complete Radicle node on your server, plus the official Radicle Explorer web interface to browse it: + + • Your node stays online and keeps your repositories available even when your laptop is not + • Anything you push from your machine is replicated here and served to the network + • Issues, patches and code are browsable in your own web UI, on your own domain + • Anyone can clone your repositories over plain HTTPS, no account required + + The web interface is for browsing. Creating repositories, opening issues and proposing patches happen with the `rad` command line on your own machine — the node here is what keeps them alive and reachable. + + Repositories are only replicated once you seed them. A fresh node starts empty and stores nothing until you tell it what to follow. + fr_fr: | + Radicle — votre forge pair-à-pair + + Radicle est une alternative souveraine à GitHub et GitLab. Plutôt qu'un serveur d'entreprise détenant la copie de référence de votre code, chaque participant conserve un réplica complet et propage ses modifications directement à ses pairs. Dépôts, tickets et patches sont tous des objets Git, signés par leur auteur et répliqués sur le réseau — aucun service central ne peut tomber, changer ses conditions ou vous verrouiller dehors. + + Cette application fait tourner un nœud Radicle complet sur votre serveur, ainsi que l'interface web officielle Radicle Explorer pour le parcourir : + + • Votre nœud reste en ligne et garde vos dépôts disponibles même quand votre portable ne l'est pas + • Tout ce que vous poussez depuis votre machine est répliqué ici et servi au réseau + • Tickets, patches et code se consultent dans votre propre interface web, sur votre domaine + • N'importe qui peut cloner vos dépôts en HTTPS, sans compte + + L'interface web sert à consulter. Créer des dépôts, ouvrir des tickets et proposer des patches se fait avec la ligne de commande `rad` sur votre machine — le nœud installé ici est ce qui les maintient vivants et accessibles. + + Les dépôts ne sont répliqués qu'une fois que vous les suivez. Un nœud neuf démarre vide et ne stocke rien tant que vous ne lui avez rien indiqué. + zh_cn: | + Radicle — 属于你自己的点对点代码托管 + + Radicle 是 GitHub 与 GitLab 的自主替代方案。代码的权威副本不再由某家公司的服务器保管,而是每个参与者都持有完整副本,并直接向对等节点传播更改。仓库、议题和补丁都是 Git 对象,由作者签名并在网络中复制——没有中心服务会宕机、修改条款或将你拒之门外。 + + 此应用在你的服务器上运行一个完整的 Radicle 节点,并附带官方的 Radicle Explorer 网页界面: + + • 节点持续在线,即使你的笔记本关机,仓库依然可用 + • 你从本机推送的内容会复制到这里,并向网络提供服务 + • 议题、补丁和代码都可以在你自己的域名下浏览 + • 任何人都可以通过 HTTPS 克隆你的仓库,无需账户 + + 网页界面用于浏览。创建仓库、提交议题和提出补丁请在本机使用 `rad` 命令行完成——这里的节点负责让它们保持在线、可被获取。 + + 仓库只有在你选择跟随(seed)之后才会被复制。全新节点启动时是空的,在你指定之前不会存储任何内容。 + icon: icon.png + screenshot_link: + - screenshot-1.png + - screenshot-2.png + - screenshot-3.png + tagline: + en_us: Your own peer-to-peer Git forge — a Radicle node and web explorer on your domain. + fr_fr: Votre forge Git pair-à-pair — un nœud Radicle et son explorateur web sur votre domaine. + zh_cn: 你自己的点对点 Git 托管——运行在你域名下的 Radicle 节点与网页浏览器。 + thumbnail: thumbnail.png + tips: + before_install: + en_us: | + This app is public on purpose + + A Radicle node is a participant in a public peer-to-peer network. Both of + its addresses are served directly to the public, bypassing the server's + single sign-on: + + | Address | What it serves | + | ------- | -------------- | + | `https://radicle-$APP_DOMAIN` | The web interface | + | `https://radicle-api-$APP_DOMAIN/api/v1` | The read-only API the interface calls | + + Only **public** repositories are served, the API has no write endpoints, and + a fresh node holds nothing until you seed something. See `rationale.md`. + + Connecting your machine + + Install the `rad` CLI from [radicle.dev](https://radicle.dev), then push a + repository and tell this node to seed it. Your node's identity (its Node ID) + is printed at `https://radicle-api-$APP_DOMAIN/api/v1` under `nid`. + + Peer-to-peer port + + The node publishes TCP **8776** so other peers can fetch from it. On a server + without a directly reachable public IP the app still works — your repositories + replicate outward to public seeds — but nobody fetches from you directly. + fr_fr: | + Cette application est publique, volontairement + + Un nœud Radicle participe à un réseau pair-à-pair public. Ses deux adresses + sont servies directement au public, en contournant l'authentification + unique du serveur : + + | Adresse | Ce qu'elle sert | + | ------- | --------------- | + | `https://radicle-$APP_DOMAIN` | L'interface web | + | `https://radicle-api-$APP_DOMAIN/api/v1` | L'API en lecture seule appelée par l'interface | + + Seuls les dépôts **publics** sont servis, l'API n'a aucun point d'écriture, et + un nœud neuf ne contient rien tant que vous n'avez rien suivi. Voir + `rationale.md`. + + Connecter votre machine + + Installez la CLI `rad` depuis [radicle.dev](https://radicle.dev), puis poussez + un dépôt et demandez à ce nœud de le suivre. L'identité de votre nœud (Node ID) + est affichée sur `https://radicle-api-$APP_DOMAIN/api/v1`, champ `nid`. + + Port pair-à-pair + + Le nœud publie le port TCP **8776** pour que les autres pairs puissent récupérer + ses dépôts. Sur un serveur sans IP publique directement joignable, l'application + fonctionne quand même — vos dépôts se répliquent vers les seeds publics — mais + personne ne récupère directement chez vous. + zh_cn: | + 此应用有意保持公开 + + Radicle 节点是公开点对点网络的参与者。它的两个地址都直接向公众开放, + 绕过服务器的单点登录: + + | 地址 | 提供的内容 | + | ---- | ---------- | + | `https://radicle-$APP_DOMAIN` | 网页界面 | + | `https://radicle-api-$APP_DOMAIN/api/v1` | 界面调用的只读 API | + + 仅提供**公开**仓库,API 没有任何写入接口,全新节点在你指定跟随之前不含任何内容。 + 详见 `rationale.md`。 + + 连接你的机器 + + 从 [radicle.dev](https://radicle.dev) 安装 `rad` 命令行,然后推送仓库并让此节点 + 跟随它。节点身份(Node ID)显示在 `https://radicle-api-$APP_DOMAIN/api/v1` 的 + `nid` 字段。 + + 点对点端口 + + 节点公开 TCP **8776**,供其他对等节点抓取。若服务器没有可直接访问的公网 IP,应用 + 仍可使用——你的仓库会向公共 seed 节点复制——但别人无法直接从你这里抓取。 + title: + en_us: Radicle + index: / + webui_port: 8080 + store_app_id: radicle + +x-compose-app: + schema_version: 2 + folders: + - path: /DATA/AppData/$AppID/home + user: $PUID + group: $PGID + - path: /DATA/AppData/$AppID/caddy + user: $PUID + group: $PGID + init: + # Creates the node's Ed25519 identity. Radicle has no first-run wizard: without + # a key pair in the Radicle home, radicle-node exits immediately. `rad auth` + # prompts for an optional passphrase, hence the empty line on stdin — a seed + # node's key secures peer connections and signs nothing permanent, so upstream's + # own guidance is to leave it unencrypted (a passphrase would have to be typed + # in at every start). + - name: init-identity + image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2 + user: $PUID:$PGID + volumes: + - /DATA/AppData/$AppID/home:/radicle-home + command: ["sh", "-c", "yes '' | rad auth --alias radicle-${APP_PUBLIC_IP_DASH} --stdin"] + when: absent:/DATA/AppData/$AppID/home/keys/radicle + files: + # The node's config carries the deployment's own addresses, so it is re-rendered + # on every up rather than frozen at install. `rad auth` above writes a default + # config.json first; this replaces it with ours and keeps it in step if the PCS + # domain or public IP ever changes. + # + # It also carries the bootstrap seeds, and those are NOT the compiled-in defaults. + # radicle 1.10 ships `iris.radicle.network` / `rosa.radicle.network`, whose IPv4 + # addresses refuse connections on 8776 (verified from two networks, 2026-09-04) — + # a node configured with them never finds a peer. The same two nodes answer under + # their previous hostnames, which is what this file uses; radicle logs a rename + # warning for them and keeps working. Revisit when upstream's new addresses accept + # peer traffic again. + - path: /DATA/AppData/$AppID/home/config.json + from: home/config.json.tmpl + ensure: always + mode: "0644" + # Carries this deployment's API domain in a redirect, so it is re-rendered too. + - path: /DATA/AppData/$AppID/caddy/Caddyfile + from: caddy/Caddyfile.tmpl + ensure: always + mode: "0644" + # The landing path is handled by the Caddy redirect above, not here: neither + # `webui-path` nor `x-casaos.index` is interpolated, so a ${...} written in + # either reaches the browser as a literal. + webui-host: radicle-${domain} + webui-path: / diff --git a/Apps/Radicle/icon.png b/Apps/Radicle/icon.png new file mode 100644 index 0000000..27878ad Binary files /dev/null and b/Apps/Radicle/icon.png differ diff --git a/Apps/Radicle/rationale.md b/Apps/Radicle/rationale.md new file mode 100644 index 0000000..3650d99 --- /dev/null +++ b/Apps/Radicle/rationale.md @@ -0,0 +1,85 @@ +# Radicle — Rationale + +## What deviation / exception is being requested + +Two, both on the security checklist: + +1. **No authentication gate.** Neither of the app's two domains (`radicle-`, the + web UI, and `radicle-api-`, 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-`, 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 `. 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. diff --git a/Apps/Radicle/screenshot-1.png b/Apps/Radicle/screenshot-1.png new file mode 100644 index 0000000..81b213f Binary files /dev/null and b/Apps/Radicle/screenshot-1.png differ diff --git a/Apps/Radicle/screenshot-2.png b/Apps/Radicle/screenshot-2.png new file mode 100644 index 0000000..ec11aaf Binary files /dev/null and b/Apps/Radicle/screenshot-2.png differ diff --git a/Apps/Radicle/screenshot-3.png b/Apps/Radicle/screenshot-3.png new file mode 100644 index 0000000..bb153c6 Binary files /dev/null and b/Apps/Radicle/screenshot-3.png differ diff --git a/Apps/Radicle/seed/caddy/Caddyfile.tmpl b/Apps/Radicle/seed/caddy/Caddyfile.tmpl new file mode 100644 index 0000000..9d83309 --- /dev/null +++ b/Apps/Radicle/seed/caddy/Caddyfile.tmpl @@ -0,0 +1,20 @@ +# Serves the Radicle Explorer single-page app. +# +# Port 8080, not 80: the installer runs every service as an unprivileged user, and +# such a process cannot bind a port below 1024. The image's own Caddyfile +# listens on :80, so this file replaces it. +:8080 { + root * /usr/share/caddy + + # The explorer bundle ships with the public Radicle seeds compiled into it, so + # "/" would open on the public network rather than on this server's node. This + # sends the entry point to the node page of the API served alongside it. Every + # other path is left alone, so the wider network stays browsable. + redir / /nodes/radicle-api-${APP_DOMAIN} + + # The explorer is a client-side router: /nodes/..., /repos/... and friends must + # resolve to the app shell rather than to a 404. + try_files {path} /index.html + file_server + encode gzip +} diff --git a/Apps/Radicle/seed/home/config.json.tmpl b/Apps/Radicle/seed/home/config.json.tmpl new file mode 100644 index 0000000..bc67dec --- /dev/null +++ b/Apps/Radicle/seed/home/config.json.tmpl @@ -0,0 +1,15 @@ +{ + "preferredSeeds": [ + "z6MksmpU5b1dS7oaqF2bHXhQi1DWy2hB7Mh9CuN7y1DN6QSz@seed.radicle.dev:8776", + "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776", + "z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776" + ], + "node": { + "alias": "radicle-${APP_PUBLIC_IP_DASH}", + "listen": ["0.0.0.0:8776"], + "externalAddresses": ["radicle-${APP_PUBLIC_IP_DASH}.sslip.io:8776"], + "seedingPolicy": { + "default": "block" + } + } +} diff --git a/Apps/Radicle/thumbnail.png b/Apps/Radicle/thumbnail.png new file mode 100644 index 0000000..978525d Binary files /dev/null and b/Apps/Radicle/thumbnail.png differ diff --git a/docs/forge.md b/docs/forge.md new file mode 100644 index 0000000..d90aae8 --- /dev/null +++ b/docs/forge.md @@ -0,0 +1,403 @@ +# IPCR Forge — design notes + +How the forge in [Apps/IPCR-Forge/](../Apps/IPCR-Forge/) goes from `git push` to `docker pull`, why it +is shaped this way, and what was learned building it. The listing's own security argument is in +[rationale.md](../Apps/IPCR-Forge/rationale.md). + +## Goal + +A forge people actually want to use, whose releases do not depend on it staying online: + +- **Gitea is the source of truth.** Repositories, pull requests, issues, users and CI live there. + Developers use plain git and a familiar web UI. +- **Images go to IPFS** through IPCR, under one name for the whole forge, so any server running IPCR + can pull them from any peer that holds them. +- **Code goes to Radicle.** Every public repository is mirrored, one way, to the Radicle network, + so it can be cloned without this server. + +## History: three versions + +| Version | Source of truth | CI | Feedback | +| --- | --- | --- | --- | +| v1 (`afb1072`) | Radicle | Gitea Actions on a read-only Gitea fed by a Radicle → Gitea bridge | Radicle as the place to work was not user-friendly, and a read-only Gitea was confusing | +| v2 (`734a33f`) | Radicle | Radicle CI broker + act on the node (`ci/`, removed since) | Not user-friendly enough: everything went through `rad` | +| **v3** (this) | **Gitea** | **Gitea Actions** | — | + +v3 keeps what worked in both: v1's Gitea + Actions setup, and v2's staging registry inside the +CI's own Docker daemon (no tokens, no registry credentials). The bridge is v1's glue reversed and +rewritten in Go ([bridge/](../bridge/)). + +The objection that removed Gitea in v2 was "two places where state can originate". It does not apply +here: state originates in Gitea only. Radicle receives a copy that nobody edits; patches or issues +opened on the Radicle side are not read. + +## Architecture + +``` +developer: git push origin main v1.2.3 (or: create a tag in Gitea's UI) + │ + ▼ +gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (bridge) + │ · git fetch from Gitea + │ Actions · rad init (first time) + ▼ · git push rad (signed by the node) +forge-runner (act_runner 0.6.1) ──unix socket──▶ forge-docker (dind) ▼ + jobs on the daemon's host network → localhost:5000 = radicle-node ──▶ Radicle network +forge-registry (registry:3, shares dind's network namespace) + ▲ polled every minute over ci-internal +ipcr-gateway (ipcrd): import → IPFS → publish under the forge's IPNS key + │ + ▼ +anyone: docker pull ipcr.localhost:4767/ipns///:1.2.3 +``` + +The two legs do not depend on each other: a repository can be built without being mirrored +(private repositories), and mirrored without being built (no workflow). + +## The bridge + +[bridge/](../bridge/): one Go binary, `ipcr-forge-bridge`, image `ghcr.io/yundera/ipcr-forge-bridge`. + +- **`setup`**, an install step (`post_up`, on every start, idempotent). It signs in once as + `gitea_admin` with the default app password and does four things: + - mints the daemon's token, `public-only` + `read:repository`; + - generates the webhook secret; + - creates or repairs one **system webhook** (`POST /api/v1/admin/hooks`, `is_system_webhook`), + matched by URL so it is never duplicated; + - creates the example `gitea_admin/ipcr-hello`. +- **`serve`**: + - `:8081` takes the webhook. It is internal, with no Caddy route. It checks the HMAC, queues the + repository ID and answers 202 at once, because Gitea gives a delivery 5 seconds. + - One worker, with a coalescing queue: a burst of deliveries is one sync. + - A **reconcile** every 10 minutes catches what no event announces. In Gitea 1.27 that is renames + and visibility changes, plus any lost delivery. + - `:8080` serves the forge's page (`/`, `/images.json`, `/repos.json`). It is the place for future + admin pages, such as Radicle settings. + +### Mirror semantics + +| Gitea | Radicle | +| --- | --- | +| public repository, first sight | `rad init --public` from a bare mirror; this node is the only delegate; then a `xyz.radicle.crefs` rule `refs/tags/*` (threshold 1) so tags are canonical | +| push, force push, branch delete | `git push --prune rad +refs/heads/*:… +refs/tags/*:…`, then `rad sync --announce` | +| tag created (git or web UI) | same; becomes canonical `refs/tags/` | +| tag deleted | removed from the node's namespace; **the canonical `refs/tags/` stays** (see findings) | +| default branch changed | `rad id update … defaultBranch` | +| rename / transfer | same RID (state is keyed by Gitea's repository ID) | +| made private, deleted | **frozen**: no further sync. Nothing is retracted; it cannot be | +| private, fork, pull mirror | never mirrored (forks and mirrors did not originate here) | +| empty | waits for its first push | + +The token cannot see a private repository at all (`public-only`), so a bug cannot publish one. The +code checks visibility again anyway. + +## Trust + +- **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a + distribution channel here, not the trust root. The `/rad//…` image-naming idea in + [naming.md](naming.md) is weaker for it. +- **Only allowed names are published.** IPCR publishes an image name only if it is on the allowlist + the bridge keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus + those switched off in the admin pages. Images built from private repositories are therefore not + published. +- **Each repository publishes its own name only.** The staging registry sits behind a gate (see + [Staging gate](#staging-gate-and-push-credentials)). A job logs in with its repository's + `IPCR_PUSH_TOKEN`, and may push `/` and nothing else. Gitea does not give secrets to + workflows from forks, so a fork's pull request cannot publish. +- **The root organisation's short names belong to its members.** Gitea teams decide who can push + to `metadec/*`, so who can publish `metadec.eth/`. +- **Not a sandbox.** Jobs run on a privileged Docker-in-Docker daemon. Anyone who can run a + workflow here can break out of it to the host, and from there to everything, including the + gate. The gate stops the ordinary way to publish someone else's name; it does not contain a + hostile CI user. Registration is off, so every user was created by the admin. +- **Fork pull requests** from users without write access wait for "Approve and run" in Gitea 1.27. +- The page, the tips and `rationale.md` say all of this plainly. +- **Who can read a push token:** anyone with write access to its repository (a workflow can print + it). Reissue it in the admin pages when a repository changes hands. + +## Admin + +`https://ipcr-forge-/admin`. It is served by the bridge and only lets in Gitea site +administrators. + +**Sign-in.** It uses an OAuth2 app that the `setup` step registers, with one redirect URI per +host. The flow is the authorization code with PKCE and the `read:user` scope. The bridge reads +`is_admin` from `/api/v1/user`, drops the Gitea token, and sets its own HMAC-signed session (8 h; +the key lives in `bridge/state`). Writes require the `X-IPCR-Admin` header and a same-origin +request. + +**Behind the page.** The page calls the bridge, and the bridge calls ipcrd's admin API +(`gateway/admin.go`). That API listens on `:4769`, on the `ipcr-admin` network (internal, bridge +and gateway only), and needs a Bearer token from `ipcr/state/admin-token`. + +| Section | What it does | +| --- | --- | +| Publisher name | The key and its `k51…`. You enter your ENS or DNSLink name; it is checked by resolving it through Kubo. While it points here, pull lines (page and admin) use it. The check runs again every 30 min. | +| Health | Shows the IPNS record held by this node and the one the network serves (delegated routing): sequence, expiry, whether it is the current tree. "Republish" refreshes it. Also: peers, the addresses the network sees, and storage against `StorageMax`. | +| Published images | For each tag: "Findable?" (is this node a listed provider), "Announce", "Make latest", "Unpublish". Unpublish removes the tag from the tree, unpins the objects no other image uses, and leaves a tombstone so the tag is not re-imported. Staging tags that are not allowed, or not imported yet, offer "Import now". | +| Which repositories may publish | One toggle per public repository. These toggles make up IPCR's allowlist. | +| Key backup | "Download" encrypts the publisher key with a passphrase. "Restore" makes a backup the publisher key: the current key is kept under `-replaced-