improve store
This commit is contained in:
@@ -11,6 +11,11 @@ name: ipcr-forge
|
|||||||
# → ipcr-kubo / ipcr-registry
|
# → ipcr-kubo / ipcr-registry
|
||||||
# docker pull ipcr.localhost:4767/ipns/<name>:1.2.3 (anyone)
|
# docker pull ipcr.localhost:4767/ipns/<name>:1.2.3 (anyone)
|
||||||
#
|
#
|
||||||
|
# The tile opens ipcr-forge-<domain>: 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). <name> 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,
|
# 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.
|
# 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}/.
|
# 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.
|
# bridge-credentials step mints. Gitea does not know IPCR exists.
|
||||||
|
|
||||||
services:
|
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 ═════════════════════════════════════════════
|
# ═══ Radicle — the source of truth ═════════════════════════════════════════════
|
||||||
# Verbatim from apps/Radicle; only its data folder moves to $AppID/radicle/.
|
# Verbatim from apps/Radicle; only its data folder moves to $AppID/radicle/.
|
||||||
|
|
||||||
@@ -484,12 +536,16 @@ networks:
|
|||||||
x-casaos:
|
x-casaos:
|
||||||
architectures:
|
architectures:
|
||||||
- amd64
|
- amd64
|
||||||
main: radicle
|
main: ipcr-forge
|
||||||
author: Yundera
|
author: Yundera
|
||||||
developer: Yundera
|
developer: Yundera
|
||||||
category: Developer
|
category: Developer
|
||||||
tags: ["developer", "git", "ci", "needs-public-ip"]
|
tags: ["developer", "git", "ci", "needs-public-ip"]
|
||||||
icon: icon.png
|
icon: icon.png
|
||||||
|
screenshot_link:
|
||||||
|
- screenshot-1.png
|
||||||
|
- screenshot-2.png
|
||||||
|
- screenshot-3.png
|
||||||
title:
|
title:
|
||||||
en_us: IPCR Forge
|
en_us: IPCR Forge
|
||||||
tagline:
|
tagline:
|
||||||
@@ -506,6 +562,9 @@ x-casaos:
|
|||||||
A repository joins the pipeline by committing a workflow under `.gitea/workflows/`: there
|
A repository joins the pipeline by committing a workflow under `.gitea/workflows/`: there
|
||||||
is nothing to configure per project.
|
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
|
This app bundles the Radicle, Gitea, Radicle Gitea Bridge and IPCR apps. Install it instead
|
||||||
of them, not beside them.
|
of them, not beside them.
|
||||||
fr_fr: |
|
fr_fr: |
|
||||||
@@ -518,6 +577,9 @@ x-casaos:
|
|||||||
Un dépôt rejoint la chaîne en ajoutant un workflow sous `.gitea/workflows/` : rien à
|
Un dépôt rejoint la chaîne en ajoutant un workflow sous `.gitea/workflows/` : rien à
|
||||||
configurer par projet.
|
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.
|
Cette application regroupe les applications Radicle, Gitea, Radicle Gitea Bridge et IPCR.
|
||||||
Installez-la à leur place, pas à côté.
|
Installez-la à leur place, pas à côté.
|
||||||
tips:
|
tips:
|
||||||
@@ -528,6 +590,7 @@ x-casaos:
|
|||||||
|
|
||||||
| Address | What |
|
| 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-$APP_DOMAIN` | Radicle explorer (public) |
|
||||||
| `https://radicle-api-$APP_DOMAIN` | Radicle API and read-only git (public) |
|
| `https://radicle-api-$APP_DOMAIN` | Radicle API and read-only git (public) |
|
||||||
| `https://gitea-$APP_DOMAIN` | Gitea: builds, logs, staging registry (`gitea_admin`) |
|
| `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
|
**Images reach IPFS on their own.** IPCR follows this app's Gitea registry: about a
|
||||||
minute after a build pushes `gitea_admin/<repo>:<tag>`, it can be pulled as
|
minute after a build pushes `gitea_admin/<repo>:<tag>`, it can be pulled as
|
||||||
`ipcr.localhost:4767/ipns/gitea_admin-<repo>:<tag>`.
|
`ipcr.localhost:4767/ipns/<name>:<tag>`, where `<name>` is the IPNS name (`k51…`) IPCR
|
||||||
|
creates for the repository. The app's page lists each one with the line to copy.
|
||||||
fr_fr: |
|
fr_fr: |
|
||||||
**Remplace quatre applications.** Désinstallez d'abord Radicle, Gitea, Radicle Gitea
|
**Remplace quatre applications.** Désinstallez d'abord Radicle, Gitea, Radicle Gitea
|
||||||
Bridge et IPCR : cette application utilise les mêmes adresses et ports.
|
Bridge et IPCR : cette application utilise les mêmes adresses et ports.
|
||||||
|
|
||||||
| Adresse | Quoi |
|
| 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-$APP_DOMAIN` | Explorateur Radicle (public) |
|
||||||
| `https://radicle-api-$APP_DOMAIN` | API Radicle et git en lecture seule (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`) |
|
| `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
|
**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/<dépôt>:<tag>`,
|
application : environ une minute après qu'un build a poussé `gitea_admin/<dépôt>:<tag>`,
|
||||||
elle se tire avec `ipcr.localhost:4767/ipns/gitea_admin-<dépôt>:<tag>`.
|
elle se tire avec `ipcr.localhost:4767/ipns/<nom>:<tag>`, où `<nom>` 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: /
|
index: /
|
||||||
webui_port: 8080
|
webui_port: 8080
|
||||||
store_app_id: ipcr-forge
|
store_app_id: ipcr-forge
|
||||||
@@ -561,6 +627,13 @@ x-casaos:
|
|||||||
x-compose-app:
|
x-compose-app:
|
||||||
schema_version: 2
|
schema_version: 2
|
||||||
folders:
|
folders:
|
||||||
|
# ── Landing page ──
|
||||||
|
- path: /DATA/AppData/$AppID/site
|
||||||
|
user: $PUID
|
||||||
|
group: $PGID
|
||||||
|
- path: /DATA/AppData/$AppID/web
|
||||||
|
user: $PUID
|
||||||
|
group: $PGID
|
||||||
# ── Radicle ──
|
# ── Radicle ──
|
||||||
- path: /DATA/AppData/$AppID/radicle/home
|
- path: /DATA/AppData/$AppID/radicle/home
|
||||||
user: $PUID
|
user: $PUID
|
||||||
@@ -608,6 +681,20 @@ x-compose-app:
|
|||||||
group: $PGID
|
group: $PGID
|
||||||
mode: "0700"
|
mode: "0700"
|
||||||
files:
|
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.
|
# Each entry and its reason are in the standalone listing it comes from.
|
||||||
- path: /DATA/AppData/$AppID/radicle/home/config.json
|
- path: /DATA/AppData/$AppID/radicle/home/config.json
|
||||||
from: radicle/home/config.json.tmpl
|
from: radicle/home/config.json.tmpl
|
||||||
@@ -683,5 +770,5 @@ x-compose-app:
|
|||||||
- -c
|
- -c
|
||||||
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" IPCR_AUTH_FILE=/ipcr-secrets/registry-auth bridge-init
|
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" IPCR_AUTH_FILE=/ipcr-secrets/registry-auth bridge-init
|
||||||
when: absent:/DATA/AppData/$AppID/bridge/secrets/gitea-token
|
when: absent:/DATA/AppData/$AppID/bridge/secrets/gitea-token
|
||||||
webui-host: radicle-${domain}
|
webui-host: ipcr-forge-${domain}
|
||||||
webui-path: /
|
webui-path: /
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.7 KiB |
@@ -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-<domain>`; 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-<domain>` 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-<domain>` 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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,308 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>IPCR Forge</title>
|
||||||
|
<link rel="icon" href="icon.png">
|
||||||
|
<!--
|
||||||
|
Plain file, deliberately not a Maison .tmpl: it contains no dollar-brace sequence, and every
|
||||||
|
address is derived in the browser from this page's own hostname (ipcr-forge-<domain> →
|
||||||
|
radicle-<domain>, gitea-<domain>), so the nip.io / sslip.io aliases work too.
|
||||||
|
Live data: /images.json (IPCR's published.json) and the Radicle API's node info.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
|
||||||
|
--accent: #0f766e; --accent-2: #1e3a8a; --code: #f1f4f8; --ok: #15803d;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
|
||||||
|
--accent: #2dd4bf; --accent-2: #93c5fd; --code: #0d1626; --ok: #4ade80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--ink);
|
||||||
|
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||||
|
main { max-width: 980px; margin: 0 auto; padding: 32px 16px 64px; }
|
||||||
|
header { display: flex; gap: 18px; align-items: center; margin-bottom: 8px; }
|
||||||
|
header img { width: 64px; height: 64px; }
|
||||||
|
h1 { font-size: 30px; margin: 0; letter-spacing: -0.01em; }
|
||||||
|
h2 { font-size: 21px; margin: 40px 0 12px; }
|
||||||
|
h3 { font-size: 16px; margin: 20px 0 6px; }
|
||||||
|
p { margin: 8px 0; }
|
||||||
|
.lede { color: var(--muted); font-size: 18px; margin: 4px 0 24px; }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
|
||||||
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
|
||||||
|
text-decoration: none; color: inherit; display: block; }
|
||||||
|
a.card:hover { border-color: var(--accent); }
|
||||||
|
.card b { display: block; font-size: 17px; margin-bottom: 4px; color: var(--accent); }
|
||||||
|
.card span { color: var(--muted); font-size: 14px; }
|
||||||
|
.pipe { display: flex; flex-wrap: wrap; align-items: stretch; gap: 8px; margin: 12px 0; }
|
||||||
|
.step { flex: 1 1 140px; background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
padding: 10px 12px; font-size: 14px; }
|
||||||
|
.step b { display: block; font-size: 15px; }
|
||||||
|
.step small { color: var(--muted); }
|
||||||
|
.arrow { align-self: center; color: var(--muted); }
|
||||||
|
pre, code { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 13.5px; }
|
||||||
|
code { background: var(--code); padding: 1px 5px; border-radius: 4px; }
|
||||||
|
pre { background: var(--code); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
|
||||||
|
overflow-x: auto; line-height: 1.5; }
|
||||||
|
pre code { background: none; padding: 0; }
|
||||||
|
ol { padding-left: 22px; }
|
||||||
|
li { margin: 6px 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; overflow: hidden; font-size: 14px; }
|
||||||
|
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||||
|
th { color: var(--muted); font-weight: 600; font-size: 13px; }
|
||||||
|
tr:last-child td { border-bottom: 0; }
|
||||||
|
td code { word-break: break-all; }
|
||||||
|
button.copy { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
|
||||||
|
font-size: 12px; padding: 3px 8px; cursor: pointer; white-space: nowrap; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.note { border-left: 3px solid var(--accent); padding: 4px 12px; color: var(--muted); }
|
||||||
|
.scroll { overflow-x: auto; }
|
||||||
|
footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<img src="icon.png" alt="">
|
||||||
|
<div>
|
||||||
|
<h1>IPCR Forge</h1>
|
||||||
|
<div class="muted" id="host"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p class="lede">From <code>git push</code> to <code>docker pull</code> on any server, without a central
|
||||||
|
service. Code lives on Radicle, Gitea Actions builds it, and IPCR serves the image from IPFS.</p>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<a class="card" id="link-radicle" href="#"><b>Radicle</b><span>Browse repositories, issues and patches. Public.</span></a>
|
||||||
|
<a class="card" id="link-gitea" href="#"><b>Gitea</b><span>Builds, logs and the staging registry. Sign in as <code>gitea_admin</code>.</span></a>
|
||||||
|
<a class="card" href="#images"><b>Published images</b><span>What this server serves from IPFS, with the line to put in a compose file.</span></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="how">How it works</h2>
|
||||||
|
<div class="pipe">
|
||||||
|
<div class="step"><b>1 · Push</b><small><code>git push rad</code> a <code>v*</code> tag to the Radicle network</small></div>
|
||||||
|
<div class="arrow">→</div>
|
||||||
|
<div class="step"><b>2 · Bridge</b><small>repos with <code>.gitea/workflows/</code> are copied into Gitea</small></div>
|
||||||
|
<div class="arrow">→</div>
|
||||||
|
<div class="step"><b>3 · Build</b><small>Gitea Actions builds the image into Gitea's registry</small></div>
|
||||||
|
<div class="arrow">→</div>
|
||||||
|
<div class="step"><b>4 · IPFS</b><small>IPCR imports each tag and publishes it under an IPNS name</small></div>
|
||||||
|
<div class="arrow">→</div>
|
||||||
|
<div class="step"><b>5 · Pull</b><small>any server with IPCR: <code>ipcr.localhost:4767/ipns/…</code></small></div>
|
||||||
|
</div>
|
||||||
|
<p>Steps 2 to 4 are automatic and take two to three minutes. A repository needs no configuration
|
||||||
|
beyond committing a workflow.</p>
|
||||||
|
|
||||||
|
<h2 id="images">Published images</h2>
|
||||||
|
<div id="images-body"><p class="muted">Loading…</p></div>
|
||||||
|
|
||||||
|
<h2 id="publish">Publish a project</h2>
|
||||||
|
<ol>
|
||||||
|
<li><b>Install Radicle</b> on your machine and create an identity: see
|
||||||
|
<a href="https://radicle.xyz/" target="_blank" rel="noopener">radicle.xyz</a>, then <code>rad auth</code>.</li>
|
||||||
|
<li><b>Connect to this server's node</b> so your pushes reach it:
|
||||||
|
<pre><code id="connect-cmd">rad node connect <node-id>@<this server>:8776</code></pre></li>
|
||||||
|
<li><b>Publish the repository</b> from its folder: <code>rad init</code>. It prints a repository
|
||||||
|
ID (<code>rad:z…</code>).</li>
|
||||||
|
<li><b>Let this node seed it.</b> The node only keeps repositories it is told to (seeding policy
|
||||||
|
<i>block</i>). In this server's Terminal app or over SSH:
|
||||||
|
<pre><code>docker exec radicle-node rad seed rad:z… --scope all</code></pre></li>
|
||||||
|
<li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the
|
||||||
|
<code>Dockerfile</code> at the root and publishes it for every <code>v*</code> tag. The
|
||||||
|
<code>REGISTRY_TOKEN</code> secret is already set for every repository.
|
||||||
|
<pre><code>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 }}</code></pre></li>
|
||||||
|
<li><b>Release:</b>
|
||||||
|
<pre><code>git tag v1.0.0
|
||||||
|
git push rad main v1.0.0</code></pre>
|
||||||
|
Follow the build in Gitea under <code>gitea_admin/<repo></code> → Actions. A minute after
|
||||||
|
it finishes, the tag appears under <a href="#images">Published images</a>.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2 id="use">Use an image</h2>
|
||||||
|
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
|
||||||
|
server and on any server running IPCR:</p>
|
||||||
|
<pre><code>services:
|
||||||
|
myapp:
|
||||||
|
image: ipcr.localhost:4767/ipns/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</code></pre>
|
||||||
|
<p><code>/ipns/<name>:<tag></code> is a name the publisher can move to a new build.
|
||||||
|
<code>/ipfs/<cid></code> is the image's content address and can never change.</p>
|
||||||
|
<p class="note">For a readable name, add a DNS TXT record <code>_dnslink.example.com</code> =
|
||||||
|
<code>dnslink=/ipns/k51…</code> to a domain you own. The image is then
|
||||||
|
<code>ipcr.localhost:4767/ipns/example.com:1.0.0</code>.</p>
|
||||||
|
|
||||||
|
<h2 id="install">Pull from another server</h2>
|
||||||
|
<p>Any server with IPCR resolves the same lines and fetches the image from the IPFS network,
|
||||||
|
from this server and from every other server that already pulled it.</p>
|
||||||
|
<h3>On a Yundera server</h3>
|
||||||
|
<p>Install <b>IPCR</b> from the App Store. There is nothing to configure.</p>
|
||||||
|
<h3>On any Linux server with Docker 29+</h3>
|
||||||
|
<pre><code>mkdir -p /DATA/AppData/ipcr && cd /DATA/AppData/ipcr
|
||||||
|
curl -fsSLO https://raw.githubusercontent.com/Yundera/ipcr/main/apps/IPCR/docker-compose.yml
|
||||||
|
install -d -m 0700 -o 1000 -g 1000 kubo && install -d -o 1000 -g 1000 init.d
|
||||||
|
curl -fsSL -o init.d/01-ipcr-node.sh https://raw.githubusercontent.com/Yundera/ipcr/main/apps/IPCR/seed/init.d/01-ipcr-node.sh
|
||||||
|
chmod 0755 init.d/01-ipcr-node.sh
|
||||||
|
printf 'PUID=1000\nPGID=1000\nTZ=UTC\nAppID=ipcr\n' > .env
|
||||||
|
docker compose up -d</code></pre>
|
||||||
|
<p class="muted">IPCR listens on <code>127.0.0.1:4767</code> only, and trusts itself through
|
||||||
|
<code>/etc/docker/certs.d/ipcr.localhost:4767/ca.crt</code>. It publishes port 4768 (TCP and UDP)
|
||||||
|
for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">github.com/Yundera/ipcr</a>.</p>
|
||||||
|
|
||||||
|
<h2 id="addresses">Addresses on this server</h2>
|
||||||
|
<div class="scroll"><table>
|
||||||
|
<tr><th>Address</th><th>What</th></tr>
|
||||||
|
<tr><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr>
|
||||||
|
<tr><td><a id="t-radicle-api" href="#"></a></td><td>Radicle API and read-only git over HTTPS (public)</td></tr>
|
||||||
|
<tr><td><a id="t-gitea" href="#"></a></td><td>Gitea: builds, logs, staging registry</td></tr>
|
||||||
|
<tr><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr>
|
||||||
|
<tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr>
|
||||||
|
</table></div>
|
||||||
|
|
||||||
|
<footer>IPCR Forge · Radicle · Gitea · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var host = location.hostname;
|
||||||
|
var base = host.indexOf("ipcr-forge-") === 0 ? host.slice("ipcr-forge-".length) : null;
|
||||||
|
function url(prefix) { return base ? "https://" + prefix + "-" + base : "#"; }
|
||||||
|
function setLink(id, prefix, text) {
|
||||||
|
var a = document.getElementById(id);
|
||||||
|
if (!a) return;
|
||||||
|
a.href = url(prefix);
|
||||||
|
if (text) a.textContent = base ? prefix + "-" + base : prefix + "-<domain>";
|
||||||
|
}
|
||||||
|
document.getElementById("host").textContent = base || host;
|
||||||
|
setLink("link-radicle", "radicle");
|
||||||
|
setLink("link-gitea", "gitea");
|
||||||
|
setLink("t-radicle", "radicle", true);
|
||||||
|
setLink("t-radicle-api", "radicle-api", true);
|
||||||
|
setLink("t-gitea", "gitea", true);
|
||||||
|
|
||||||
|
function el(tag, text, cls) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (text != null) e.textContent = text;
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
function copyButton(text) {
|
||||||
|
var b = el("button", "Copy", "copy");
|
||||||
|
b.type = "button";
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
var done = function () { b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1500); };
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(done, function () { window.prompt("Copy:", text); });
|
||||||
|
} else { window.prompt("Copy:", text); }
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
function cmpTags(a, b) {
|
||||||
|
if (a === "latest") return -1;
|
||||||
|
if (b === "latest") return 1;
|
||||||
|
var pa = a.split(/[.-]/), pb = b.split(/[.-]/);
|
||||||
|
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
var x = pa[i] || "", y = pb[i] || "";
|
||||||
|
var nx = parseInt(x, 10), ny = parseInt(y, 10);
|
||||||
|
if (!isNaN(nx) && !isNaN(ny) && nx !== ny) return ny - nx;
|
||||||
|
if (x !== y) return x < y ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = document.getElementById("images-body");
|
||||||
|
fetch("images.json", { cache: "no-store" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
||||||
|
.then(function (pub) {
|
||||||
|
var reg = pub.registry || "ipcr.localhost:4767";
|
||||||
|
var repos = Object.keys(pub.images || {}).sort();
|
||||||
|
if (!repos.length) throw new Error("empty");
|
||||||
|
body.textContent = "";
|
||||||
|
repos.forEach(function (repo) {
|
||||||
|
var r = pub.images[repo];
|
||||||
|
body.appendChild(el("h3", repo.replace(/^gitea_admin\//, "")));
|
||||||
|
var wrap = el("div", null, "scroll");
|
||||||
|
var t = el("table");
|
||||||
|
var head = el("tr");
|
||||||
|
["Tag", "Compose line", ""].forEach(function (h) { head.appendChild(el("th", h)); });
|
||||||
|
t.appendChild(head);
|
||||||
|
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
|
||||||
|
var info = r.tags[tag];
|
||||||
|
var line = "image: " + reg + "/ipns/" + r.ipns + ":" + tag;
|
||||||
|
var tr = el("tr");
|
||||||
|
var td1 = el("td"); td1.appendChild(el("code", tag));
|
||||||
|
if (info.at) td1.appendChild(el("div", info.at.replace("T", " ").replace("Z", " UTC"), "muted"));
|
||||||
|
var td2 = el("td"); td2.appendChild(el("code", line));
|
||||||
|
var pin = el("div", null, "muted");
|
||||||
|
pin.appendChild(document.createTextNode("this build only: "));
|
||||||
|
pin.appendChild(el("code", reg + "/ipfs/" + info.cid));
|
||||||
|
td2.appendChild(pin);
|
||||||
|
var td3 = el("td"); td3.appendChild(copyButton(line));
|
||||||
|
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
||||||
|
t.appendChild(tr);
|
||||||
|
});
|
||||||
|
wrap.appendChild(t);
|
||||||
|
body.appendChild(wrap);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
body.innerHTML = "";
|
||||||
|
body.appendChild(el("p", "Nothing published yet. Push a v* tag from a repository with a workflow (see below); its image appears here a few minutes later.", "muted"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The node's ID and dialable address, for `rad node connect`. The Radicle API is public and
|
||||||
|
// CORS-enabled (the explorer calls it from the browser). The address must be the node's own
|
||||||
|
// externalAddresses entry (radicle-<ip>.sslip.io:8776): the *-<domain> names are behind an
|
||||||
|
// HTTP proxy that does not carry port 8776.
|
||||||
|
if (base) {
|
||||||
|
fetch(url("radicle-api") + "/api/v1/node", { cache: "no-store" })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (node) {
|
||||||
|
var addrs = (node.config && node.config.externalAddresses) || [];
|
||||||
|
if (node.id && addrs.length) {
|
||||||
|
document.getElementById("connect-cmd").textContent =
|
||||||
|
"rad node connect " + node.id + "@" + addrs[0];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user