IPCR Forge admin: name, health, unpublish, allowlist, key backup

ipcr 1.3.0 (gateway/):
- admin API on ADMIN_LISTEN (Bearer token in STATE_DIR/admin-token):
  status (key, node, storage, IPNS record here and on the network via
  delegated routing, ENS/DNSLink name check), providers/announce, peers,
  images, unpublish (tombstone + unpin unused objects), retag, reimport,
  config (name, allowlist), republish, key import (restore with
  sequence = max(network, local) + 1; previous key kept, kept copies reused)
- watcher refactored into shared state; config.json with an allowlist
  (IMPORT_ALLOW_REQUIRED); staging cleanup after publish (IMPORT_CLEANUP)
- IPNS records published with IPNS_LIFETIME / IPNS_TTL; superseded trees
  unpinned
- encrypted publisher-key backups (PBKDF2 + AES-256-GCM), ipcrd key decrypt

bridge 0.2.0 (bridge/):
- /admin: Gitea OAuth2 login (PKCE, site admins only), signed session,
  same-origin + X-IPCR-Admin on writes; setup registers the OAuth2 app
- admin page: publisher name and ENS/DNS check, health, published images,
  per-repository publish toggles (pushed to IPCR as its allowlist), key
  export (from Kubo's keystore, encrypted) and restore
- public page pull lines use the verified name

Compose: ipcr-admin internal network, keystore mount, 7-day IPNS lifetime,
staging GC at registry start. Verified on holyhorse (docs/forge.md).
This commit is contained in:
Worph
2026-10-04 20:33:16 +00:00
parent 5227a77477
commit b65bb867c9
14 changed files with 1641 additions and 34 deletions
+51 -2
View File
@@ -30,6 +30,15 @@
// IPCR_PUBLISHED IPCR's published.json, served as /images.json
// (default /srv/ipcr-state/published.json)
// RECONCILE full pass interval (default 10m)
//
// Admin pages (/admin, auth.go and admin.go), on when IPCR_ADMIN is set:
//
// PUBLIC_HOSTS the hosts the page is served on (ipcr-forge-<domain>, …), comma separated;
// setup registers one OAuth2 redirect URI per host
// IPCR_ADMIN ipcrd's admin API (e.g. http://ipcr-gateway:4769)
// IPCR_ADMIN_TOKEN_FILE its token (default /srv/ipcr-state/admin-token)
// KUBO_KEYSTORE Kubo's keystore, read-only, for key backups (default /srv/ipcr-keystore)
// IMPORT_PUBLISHER the publisher key's name (default forge)
package main
import (
@@ -37,6 +46,7 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
)
@@ -104,7 +114,6 @@ func serve() error {
}
q := newQueue()
go q.run(m.sync)
go reconcileLoop(m, q, every)
hook := &hookHandler{secretFile: secrets + "/hook-secret", enqueue: q.add}
hmux := http.NewServeMux()
@@ -113,8 +122,45 @@ func serve() error {
log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux))
}()
mux := webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json"))
if ipcr := os.Getenv("IPCR_ADMIN"); ipcr != "" {
au, err := newAuth(splitList(env("PUBLIC_HOSTS", "")), m.gitea.base, secrets, stateDir+"/session-key")
if err != nil {
return err
}
adm := &adminAPI{
ipcr: strings.TrimRight(ipcr, "/"), tokenFile: env("IPCR_ADMIN_TOKEN_FILE", "/srv/ipcr-state/admin-token"),
keystore: env("KUBO_KEYSTORE", "/srv/ipcr-keystore"), publisher: env("IMPORT_PUBLISHER", "forge"),
settings: stateDir + "/admin.json", gitea: m.gitea, http: &http.Client{Timeout: 3 * time.Minute},
}
adminRoutes(mux, au, adm)
// IPCR publishes nothing until it has the list (IMPORT_ALLOW_REQUIRED): send it as soon as
// both ends are up, then after every reconcile.
go func() {
for {
if !m.gitea.hasToken() {
time.Sleep(15 * time.Second)
continue
}
err := adm.pushAllowlist()
if err == nil {
return
}
log.Printf("allowlist: %v (retrying)", err)
time.Sleep(30 * time.Second)
}
}()
m.onReconcile = func() {
if err := adm.pushAllowlist(); err != nil {
log.Printf("allowlist: %v", err)
}
}
log.Printf("bridge: admin pages on, IPCR admin API %s", ipcr)
}
go reconcileLoop(m, q, every)
log.Printf("bridge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every)
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json")))
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), mux)
}
// reconcileLoop queues every repository Gitea shows as public, plus every one already known (so one
@@ -144,6 +190,9 @@ func reconcileLoop(m *mirror, q *queue, every time.Duration) {
q.add(id)
}
}
if m.onReconcile != nil {
m.onReconcile()
}
time.Sleep(every)
}
}