improve store

This commit is contained in:
Worph
2026-10-03 17:56:57 +02:00
parent d21e5d93ed
commit a07b28b10c
9 changed files with 526 additions and 4 deletions
+91 -4
View File
@@ -11,6 +11,11 @@ name: ipcr-forge
# → ipcr-kubo / ipcr-registry
# 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,
# 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/<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: |
**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/<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: /
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: /
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

+108
View File
@@ -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

+308
View File
@@ -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 &lt;node-id&gt;@&lt;this server&gt;: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=&#36;{GITHUB_SERVER_URL#https://}" &gt;&gt; "&#36;GITHUB_OUTPUT"
- uses: docker/metadata-action@v5
id: meta
with:
images: &#36;{{ steps.registry.outputs.host }}/&#36;{{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: &#36;{{ steps.registry.outputs.host }}
username: &#36;{{ github.repository_owner }}
password: &#36;{{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: &#36;{{ steps.meta.outputs.tags }}
labels: &#36;{{ 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/&lt;repo&gt;</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/&lt;name&gt;:&lt;tag&gt;</code> is a name the publisher can move to a new build.
<code>/ipfs/&lt;cid&gt;</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 &amp;&amp; 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 &amp;&amp; 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' &gt; .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>
+19
View File
@@ -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";
}
}