diff --git a/Apps/IPCR-Forge/docker-compose.yml b/Apps/IPCR-Forge/docker-compose.yml index d9f63c3..f1479ce 100644 --- a/Apps/IPCR-Forge/docker-compose.yml +++ b/Apps/IPCR-Forge/docker-compose.yml @@ -11,6 +11,11 @@ name: ipcr-forge # → ipcr-kubo / ipcr-registry # docker pull ipcr.localhost:4767/ipns/:1.2.3 (anyone) # +# The tile opens ipcr-forge-: a static landing page (nginx) that explains the pipeline, +# links Radicle and Gitea, and lists every published image with the exact `image:` line to use +# (IPCR's published.json, served as /images.json). is the IPNS key IPCR minted for the +# repository (k51…), not the repository's name, which is why the page lists it. +# # Every service is copied verbatim from its standalone listing (apps/Radicle, apps/Gitea, # apps/Radicle-Gitea-Bridge, apps/IPCR), so each keeps its own comments and rationale.md. # Only the data folders move, one subfolder per part: $AppID/{radicle,gitea,bridge,ipcr}/. @@ -24,6 +29,53 @@ name: ipcr-forge # bridge-credentials step mints. Gitea does not know IPCR exists. services: + # ═══ Landing page — the tile's web UI ══════════════════════════════════════════ + # Static: the page, its icon, and IPCR's list of published images (read-only). It shows only + # links, documentation and image names that are public on IPFS anyway, so it is not behind + # the SSO gate; Gitea keeps its own login, Radicle is public by design. + ipcr-forge: + image: nginxinc/nginx-unprivileged:1.29.3-alpine + container_name: ipcr-forge + user: $PUID:$PGID + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + restart: unless-stopped + cpu_shares: 80 + deploy: + resources: + limits: + memory: 32M + environment: + TZ: $TZ + tmpfs: + # nginx-unprivileged keeps its pid file and temp paths here. + - /tmp + volumes: + - ${DATA_ROOT:-/DATA}/AppData/$AppID/site:/usr/share/nginx/html:ro + - ${DATA_ROOT:-/DATA}/AppData/$AppID/web/default.conf:/etc/nginx/conf.d/default.conf:ro + # published.json → /images.json. Read-only; the location in default.conf serves that file only. + - ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + expose: + - 8080 + 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 + # ═══ Radicle — the source of truth ═════════════════════════════════════════════ # Verbatim from apps/Radicle; only its data folder moves to $AppID/radicle/. @@ -484,12 +536,16 @@ networks: x-casaos: architectures: - amd64 - main: radicle + main: ipcr-forge 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: @@ -506,6 +562,9 @@ x-casaos: A repository joins the pipeline by committing a workflow under `.gitea/workflows/`: there is nothing to configure per project. + Its own page explains how to publish a project and lists every published image with the + line to put in a compose file. + This app bundles the Radicle, Gitea, Radicle Gitea Bridge and IPCR apps. Install it instead of them, not beside them. fr_fr: | @@ -518,6 +577,9 @@ x-casaos: Un dépôt rejoint la chaîne en ajoutant un workflow sous `.gitea/workflows/` : rien à configurer par projet. + Sa page explique comment publier un projet et liste chaque image publiée avec la ligne à + mettre dans un fichier compose. + Cette application regroupe les applications Radicle, Gitea, Radicle Gitea Bridge et IPCR. Installez-la à leur place, pas à côté. tips: @@ -528,6 +590,7 @@ x-casaos: | Address | What | | --- | --- | + | `https://ipcr-forge-$APP_DOMAIN` | This app: how to publish, and every published image | | `https://radicle-$APP_DOMAIN` | Radicle explorer (public) | | `https://radicle-api-$APP_DOMAIN` | Radicle API and read-only git (public) | | `https://gitea-$APP_DOMAIN` | Gitea: builds, logs, staging registry (`gitea_admin`) | @@ -537,13 +600,15 @@ x-casaos: **Images reach IPFS on their own.** IPCR follows this app's Gitea registry: about a minute after a build pushes `gitea_admin/:`, it can be pulled as - `ipcr.localhost:4767/ipns/gitea_admin-:`. + `ipcr.localhost:4767/ipns/:`, where `` is the IPNS name (`k51…`) IPCR + creates for the repository. The app's page lists each one with the line to copy. fr_fr: | **Remplace quatre applications.** Désinstallez d'abord Radicle, Gitea, Radicle Gitea Bridge et IPCR : cette application utilise les mêmes adresses et ports. | Adresse | Quoi | | --- | --- | + | `https://ipcr-forge-$APP_DOMAIN` | Cette application : publier, et toutes les images publiées | | `https://radicle-$APP_DOMAIN` | Explorateur Radicle (public) | | `https://radicle-api-$APP_DOMAIN` | API Radicle et git en lecture seule (public) | | `https://gitea-$APP_DOMAIN` | Gitea : builds, journaux, registre de transit (`gitea_admin`) | @@ -553,7 +618,8 @@ x-casaos: **Les images arrivent sur IPFS d'elles-mêmes.** IPCR suit le registre Gitea de cette application : environ une minute après qu'un build a poussé `gitea_admin/:`, - elle se tire avec `ipcr.localhost:4767/ipns/gitea_admin-:`. + elle se tire avec `ipcr.localhost:4767/ipns/:`, où `` est le nom IPNS + (`k51…`) qu'IPCR crée pour le dépôt. La page de l'application les liste, prêts à copier. index: / webui_port: 8080 store_app_id: ipcr-forge @@ -561,6 +627,13 @@ x-casaos: x-compose-app: schema_version: 2 folders: + # ── Landing page ── + - path: /DATA/AppData/$AppID/site + user: $PUID + group: $PGID + - path: /DATA/AppData/$AppID/web + user: $PUID + group: $PGID # ── Radicle ── - path: /DATA/AppData/$AppID/radicle/home user: $PUID @@ -608,6 +681,20 @@ x-compose-app: group: $PGID mode: "0700" files: + # The landing page follows store updates: re-rendered on every up. None of the three holds a + # dollar-brace sequence, so rendering cannot fail on an unresolved variable. + - path: /DATA/AppData/$AppID/site/index.html + from: site/index.html + ensure: always + mode: "0644" + - path: /DATA/AppData/$AppID/site/icon.png + from: site/icon.png + ensure: always + mode: "0644" + - path: /DATA/AppData/$AppID/web/default.conf + from: web/default.conf + ensure: always + mode: "0644" # Each entry and its reason are in the standalone listing it comes from. - path: /DATA/AppData/$AppID/radicle/home/config.json from: radicle/home/config.json.tmpl @@ -683,5 +770,5 @@ x-compose-app: - -c - GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" IPCR_AUTH_FILE=/ipcr-secrets/registry-auth bridge-init when: absent:/DATA/AppData/$AppID/bridge/secrets/gitea-token - webui-host: radicle-${domain} + webui-host: ipcr-forge-${domain} webui-path: / diff --git a/Apps/IPCR-Forge/icon.png b/Apps/IPCR-Forge/icon.png index 6d092fb..70da9e0 100644 Binary files a/Apps/IPCR-Forge/icon.png 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..17c6cb9 --- /dev/null +++ b/Apps/IPCR-Forge/rationale.md @@ -0,0 +1,108 @@ +# IPCR Forge — Rationale + +IPCR Forge bundles four store apps (Radicle, Gitea, Radicle Gitea Bridge, IPCR) and a static +landing page. Their services are copied verbatim, so each part keeps the deviations its +standalone listing already argues. This document lists them, then covers only what the +bundle adds. + +## What deviation / exception is being requested + +**Inherited, unchanged** (the full argument is in each listing's `rationale.md`): + +| From | Deviation | +| --- | --- | +| Radicle | No authentication gate on `radicle-` and `radicle-api-`; host port `8776/tcp` | +| Gitea | `gitea-runner` is `privileged` (Docker-in-Docker); `ALLOW_LOCALNETWORKS`; admin account `gitea_admin` | +| Radicle Gitea Bridge | An install step signs in once with the default app password to mint scoped tokens | +| 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. **A cross-app network link.** `ipcr-gateway` joins `gitea-internal`, the network the + standalone Gitea app reserves for Gitea and its runner. +2. **A cross-app credential.** The bridge's install step writes a token into IPCR's folder + (`ipcr/secrets/registry-auth`), which `ipcr-gateway` reads. +3. **A public landing page.** `ipcr-forge-` is not behind the SSO gate. +4. **It replaces four apps** and cannot be installed beside them: same container names, + domains and host ports. + +## Why it is necessary + +**1. The network link.** IPCR publishes images by *pulling* them from Gitea's registry: it +polls the registry and imports every new tag into IPFS. The pull has to come from IPCR's +side, because Gitea's build jobs run in the runner's nested Docker, which cannot reach +`ipcr.localhost` (that name is the host's loopback). In the standalone apps the gateway pulls +over Gitea's public HTTPS address. In the bundle both are on the same server, so it uses +`http://gitea:3000` over `gitea-internal` instead: no round trip through Caddy, and it keeps +working on a box with no public address. Gitea's registry hands out its bearer tokens at the +address the request came in on (`http://gitea:3000/v2/token`), so authentication stays +internal too. + +**2. The credential.** Gitea's registry requires sign-in (`REQUIRE_SIGNIN_VIEW`), so IPCR +needs a token. Gitea mints tokens only for a caller holding the account password, and the +bridge's install step is the one place that already does that. It mints one more token, +`ipcr-import`, with `read:package` only, and writes it as a file because an install step can +produce files but not environment variables. `ipcrd` reads `IMPORT_AUTH_FILE` at the start of +every poll, so the file may appear after the gateway has started. + +**3. The landing page.** It holds no data that isn't public already: links to Radicle (public +by design) and Gitea (behind its own login), documentation, and the list of images IPCR has +published, which are public on IPFS by definition. A login in front of it would protect +nothing and would stand between a developer and the `image:` line they came for. + +**4. Replacing the four apps.** The bundle is the same services, not a different product. +Renaming every container, domain and port to coexist would break the defaults the bridge +and IPCR rely on (`radicle-api:8080`, `gitea:3000`) and the documented addresses users type, +for no use case: running two copies of the same forge on one server. + +## Security mitigations in place + +- **The network link is narrow.** `gitea-internal` holds only `gitea`, `gitea-runner` and now + `ipcr-gateway`. The gateway's only listener is the read-only registry API. Kubo's + unauthenticated RPC API stays on `ipcr-internal`, which nothing from Gitea joins. Jobs run + in the runner's nested daemon, on a network where none of these names resolve. +- **The credential is read-only and contained.** `read:package` cannot push, delete or change + anything. The file is written as `$PUID` into a `0700` folder and mounted read-only into the + gateway. It is written then renamed, so a half-written token is never read. Re-running the + install step replaces the token in Gitea, which revokes the old one. +- **The admin password is used once and never stored.** It is passed to `bridge-init` on its + command line, as the Gitea app's own `create-admin` step does, and only the scoped tokens + are written to disk. +- **The landing page cannot change anything.** `nginx-unprivileged` runs as `$PUID` with a + read-only root filesystem (a tmpfs `/tmp` only), no capabilities, `no-new-privileges` and a + 32 MB limit. It mounts the page, its config and IPCR's `state/` read-only, and serves static + files only. +- **It publishes one file of IPCR's state, not the folder.** `state/` is mounted outside the + web root, and `default.conf` maps exactly one path to it: + `location = /images.json { alias /srv/ipcr-state/published.json; }`. The watcher's own + bookkeeping (`imported.json`, tag → digest) and anything else in `state/` answers 404. +- **Every inherited mitigation still applies,** because the services are unchanged. For + example, the privileged runner can still be stopped without affecting the rest of the forge. + +## Alternatives considered and rejected + +| Alternative | Why not | +| --- | --- | +| Gitea pushes to IPCR (webhook, or a workflow step) | A workflow step cannot reach `ipcr.localhost`. A webhook would give IPCR a write endpoint on a shared network; a pull needs no inbound access at all. | +| Gateway pulls over the public `gitea-` address, as standalone | Works, but routes a same-box fetch out through Caddy and back, and fails on a box with no public address. | +| An admin-password credential for IPCR | Simpler, but gives the gateway full control of Gitea for a job that only reads packages. | +| `IMPORT_AUTH` as an environment variable | An install step cannot set one, so it would be a manual step after every install. | +| Renaming everything so the bundle can sit beside the four apps | Breaks the defaults and the documented addresses (see 4). | +| No landing page, Radicle explorer as the tile | Leaves newcomers without the pipeline explained or the IPNS name to pull, which IPCR mints and nobody can guess. | + +## Data protection + +Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part: + +| Folder | Holds | +| --- | --- | +| `radicle/` | node identity (key pair), seeded repositories, Caddy config | +| `gitea/` | repositories, SQLite database, `app.ini`, runner registration, the runner's image store | +| `bridge/` | `cache/` (disposable bare repositories), `secrets/gitea-token` (0600) | +| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `secrets/registry-auth` | +| `site/`, `web/` | the landing page and its nginx config, re-rendered on every start | + +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 (Radicle's node key, Kubo's node key and IPNS keys) survive +uninstall and reinstall, so published repositories and image names stay valid. 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/site/icon.png b/Apps/IPCR-Forge/seed/site/icon.png new file mode 100644 index 0000000..70da9e0 Binary files /dev/null and b/Apps/IPCR-Forge/seed/site/icon.png differ diff --git a/Apps/IPCR-Forge/seed/site/index.html b/Apps/IPCR-Forge/seed/site/index.html new file mode 100644 index 0000000..d4c3556 --- /dev/null +++ b/Apps/IPCR-Forge/seed/site/index.html @@ -0,0 +1,308 @@ + + + + + +IPCR Forge + + + + + +
+ +
+ +
+

IPCR Forge

+
+
+
+

From git push to docker pull on any server, without a central +service. Code lives on Radicle, Gitea Actions builds it, and IPCR serves the image from IPFS.

+ + + +

How it works

+
+
1 · Pushgit push rad a v* tag to the Radicle network
+
→
+
2 · Bridgerepos with .gitea/workflows/ are copied into Gitea
+
→
+
3 · BuildGitea Actions builds the image into Gitea's registry
+
→
+
4 · IPFSIPCR imports each tag and publishes it under an IPNS name
+
→
+
5 · Pullany server with IPCR: ipcr.localhost:4767/ipns/…
+
+

Steps 2 to 4 are automatic and take two to three minutes. A repository needs no configuration +beyond committing a workflow.

+ +

Published images

+

Loading…

+ +

Publish a project

+
    +
  1. Install Radicle on your machine and create an identity: see + radicle.xyz, then rad auth.
  2. +
  3. Connect to this server's node so your pushes reach it: +
    rad node connect <node-id>@<this server>:8776
  4. +
  5. Publish the repository from its folder: rad init. It prints a repository + ID (rad:z…).
  6. +
  7. Let this node seed it. The node only keeps repositories it is told to (seeding policy + block). In this server's Terminal app or over SSH: +
    docker exec radicle-node rad seed rad:z… --scope all
  8. +
  9. Add a workflow at .gitea/workflows/build.yml. This one builds the + Dockerfile at the root and publishes it for every v* tag. The + REGISTRY_TOKEN secret is already set for every repository. +
    on:
    +  push:
    +    tags: ['v*']
    +jobs:
    +  image:
    +    runs-on: ubuntu-latest
    +    steps:
    +      - uses: actions/checkout@v4
    +      - name: Registry address
    +        id: registry
    +        run: echo "host=${GITHUB_SERVER_URL#https://}" >> "$GITHUB_OUTPUT"
    +      - uses: docker/metadata-action@v5
    +        id: meta
    +        with:
    +          images: ${{ steps.registry.outputs.host }}/${{ github.repository }}
    +          tags: |
    +            type=semver,pattern={{version}}
    +            type=raw,value=latest
    +      - uses: docker/setup-buildx-action@v3
    +      - uses: docker/login-action@v3
    +        with:
    +          registry: ${{ steps.registry.outputs.host }}
    +          username: ${{ github.repository_owner }}
    +          password: ${{ secrets.REGISTRY_TOKEN }}
    +      - uses: docker/build-push-action@v6
    +        with:
    +          push: true
    +          tags: ${{ steps.meta.outputs.tags }}
    +          labels: ${{ steps.meta.outputs.labels }}
  10. +
  11. Release: +
    git tag v1.0.0
    +git push rad main v1.0.0
    + Follow the build in Gitea under gitea_admin/<repo> → Actions. A minute after + it finishes, the tag appears under Published images.
  12. +
+ +

Use an image

+

Copy the line from Published images into a compose file. It works on this +server and on any server running IPCR:

+
services:
+  myapp:
+    image: ipcr.localhost:4767/ipns/k51qzi5uqu5d…:1.0.0   # a tag: follows new builds of 1.0.0
+    # image: ipcr.localhost:4767/ipfs/bafkrei…           # a CID: this exact image, forever
+

/ipns/<name>:<tag> is a name the publisher can move to a new build. +/ipfs/<cid> is the image's content address and can never change.

+

For a readable name, add a DNS TXT record _dnslink.example.com = +dnslink=/ipns/k51… to a domain you own. The image is then +ipcr.localhost:4767/ipns/example.com:1.0.0.

+ +

Pull from another server

+

Any server with IPCR resolves the same lines and fetches the image from the IPFS network, +from this server and from every other server that already pulled it.

+

On a Yundera server

+

Install IPCR from the App Store. There is nothing to configure.

+

On any Linux server with Docker 29+

+
mkdir -p /DATA/AppData/ipcr && cd /DATA/AppData/ipcr
+curl -fsSLO https://raw.githubusercontent.com/Yundera/ipcr/main/apps/IPCR/docker-compose.yml
+install -d -m 0700 -o 1000 -g 1000 kubo && install -d -o 1000 -g 1000 init.d
+curl -fsSL -o init.d/01-ipcr-node.sh https://raw.githubusercontent.com/Yundera/ipcr/main/apps/IPCR/seed/init.d/01-ipcr-node.sh
+chmod 0755 init.d/01-ipcr-node.sh
+printf 'PUID=1000\nPGID=1000\nTZ=UTC\nAppID=ipcr\n' > .env
+docker compose up -d
+

IPCR listens on 127.0.0.1:4767 only, and trusts itself through +/etc/docker/certs.d/ipcr.localhost:4767/ca.crt. It publishes port 4768 (TCP and UDP) +for IPFS peers. Details: github.com/Yundera/ipcr.

+ +

Addresses on this server

+
+ + + + + + +
AddressWhat
Radicle explorer (public)
Radicle API and read-only git over HTTPS (public)
Gitea: builds, logs, staging registry
ipcr.localhost:4767IPCR registry, for this server's Docker only
TCP 8776 · TCP/UDP 4768Radicle peers · IPFS peers
+ +
IPCR Forge · Radicle · Gitea · Kubo · nerdctl · IPCR
+
+ + + + diff --git a/Apps/IPCR-Forge/seed/web/default.conf b/Apps/IPCR-Forge/seed/web/default.conf new file mode 100644 index 0000000..7316e4b --- /dev/null +++ b/Apps/IPCR-Forge/seed/web/default.conf @@ -0,0 +1,19 @@ +# IPCR Forge landing page. Static, plus IPCR's published.json as /images.json. +# No nginx variables on purpose: this file is also safe to render as a Maison template. +server { + listen 8080; + server_tokens off; + root /usr/share/nginx/html; + index index.html; + + location = /images.json { + alias /srv/ipcr-state/published.json; + default_type application/json; + add_header Cache-Control "no-store"; + } + + # Only the page and its icon; nothing else under the state directory is reachable. + location / { + add_header Cache-Control "no-cache"; + } +}