Files
ipcr-forge/forge/admin.go
T
Worph c961d74e0e IPCR Forge: org-root image names and per-repository push credentials
Short names: the public repositories of one Gitea organisation (IPCR_ROOT_ORG,
then the admin page; metadec on our boxes) are also published at the root of
the forge's name, so ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0
works. Collisions (a user/org of that name, another owner's folder) are
skipped and explained.

Push credentials: the staging registry now sits behind forge-gate
(ipcr-forge-bridge gate), in the CI daemon's network namespace, with the
registry on a unix socket and no network. Each publishing repository logs in
as <owner>/<repo> with an IPCR_PUSH_TOKEN Actions secret the bridge sets, and
may push its own name only; IPCR logs in with a credential it generates.
Workflows need a docker/login-action step (example and page updated).

ipcr 1.4.0: allow.aliases with per-pass alias sync; unpublish/retag on every
path; IMPORT_AUTH_GENERATE; names this node publishes resolve via
name/resolve --nocache (Kubo's resolve cache lagged, and the generic resolve
ignores nocache).

bridge 0.3.0: gate, push credentials (rotate in the admin page), root
organisation setting, setup creates the org and the example in it; the
Radicle description carries the Gitea path (identical documents gave the same
RID); OAuth2 app updated only when hosts change, keeping the new secret.
Admin page: tabs, one-line keys, name-check feedback, per-tag docker pull
commands (IPFS, IPNS, ENS, short path first).

Verified on holyhorse (docs/forge.md).
2026-10-05 11:37:33 +00:00

386 lines
13 KiB
Go

package main
import (
"bytes"
"encoding/base32"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// adminAPI is what the admin page calls, behind auth.requireAdmin:
//
// /admin/api/me who is signed in
// /admin/api/ipcr/<path> ipcrd's admin API (gateway/admin.go), with its token
// POST /admin/api/key/export the publisher key, encrypted with the given passphrase
// GET /admin/api/repos Gitea's public repositories and whether each may publish images
// PUT /admin/api/repos {full_name, publish}
//
// It also keeps ipcrd's allowlist equal to "public Gitea repositories, minus those switched off":
// a build can push any image name to the staging registry, and only those names are published.
type adminAPI struct {
ipcr string // ipcrd's admin API, e.g. http://ipcr-gateway:4769
tokenFile string // ipcrd's admin-token, read through the read-only ipcr/state mount
keystore string // Kubo's keystore folder, read-only
publisher string // the publisher key's name (IMPORT_PUBLISHER)
settings string // admin.json: the repositories switched off, the root organisation
gitea *gitea // public-only, read: lists the repositories
admin *gitea // admin-scoped: name collisions (any user or org), push secrets
creds *pushCreds
rootOrg string // default root organisation (IPCR_ROOT_ORG), until set in the admin page
au *auth
http *http.Client
mu sync.Mutex
}
type adminSettings struct {
// Repositories (owner/name, lowercase) whose images are not published.
Disabled []string `json:"disabled"`
// The organisation whose repositories are also published at the root of the forge's name
// (metadec/app → /ipns/<forge>/app). nil: IPCR_ROOT_ORG; "": none.
RootOrg *string `json:"rootOrg,omitempty"`
}
func (a *adminAPI) root() string {
if s := a.load(); s.RootOrg != nil {
return strings.ToLower(*s.RootOrg)
}
return strings.ToLower(a.rootOrg)
}
func (a *adminAPI) save(s adminSettings) error {
b, _ := json.MarshalIndent(s, "", " ")
if err := os.WriteFile(a.settings+".new", b, 0o644); err != nil {
return err
}
return os.Rename(a.settings+".new", a.settings)
}
func (a *adminAPI) load() adminSettings {
var s adminSettings
if b, err := os.ReadFile(a.settings); err == nil {
json.Unmarshal(b, &s)
}
return s
}
func (a *adminAPI) token() string {
b, _ := os.ReadFile(a.tokenFile)
return strings.TrimSpace(string(b))
}
func (a *adminAPI) routes(mux *http.ServeMux, au *auth) {
a.au = au
guard := au.requireAdmin
mux.Handle("GET /admin/api/me", guard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, _ := au.user(r)
writeJSONResponse(w, map[string]string{"user": u, "publisher": a.publisher})
})))
mux.Handle("/admin/api/ipcr/{path...}", guard(http.HandlerFunc(a.proxy)))
mux.Handle("POST /admin/api/key/export", guard(http.HandlerFunc(a.export)))
mux.Handle("GET /admin/api/repos", guard(http.HandlerFunc(a.listRepos)))
mux.Handle("PUT /admin/api/repos", guard(http.HandlerFunc(a.setRepo)))
mux.Handle("POST /admin/api/repos/rotate", guard(http.HandlerFunc(a.rotate)))
mux.Handle("GET /admin/api/settings", guard(http.HandlerFunc(a.getSettings)))
mux.Handle("PUT /admin/api/settings", guard(http.HandlerFunc(a.putSettings)))
}
func writeJSONResponse(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(v)
}
// proxy forwards to ipcrd's admin API. Only its own paths: /admin/<path>.
func (a *adminAPI) proxy(w http.ResponseWriter, r *http.Request) {
p := r.PathValue("path")
if strings.Contains(p, "..") {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
u := a.ipcr + "/admin/" + p
if r.URL.RawQuery != "" {
u += "?" + r.URL.RawQuery
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, u, io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.token())
resp, err := a.http.Do(req)
if err != nil {
// Not 502: a proxy in front (Cloudflare) replaces a 502's body with its own page.
http.Error(w, "IPCR unreachable: "+err.Error(), http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
// keystoreFile is where Kubo keeps the key <name>: "key_" + lowercase unpadded base32 of the name.
func keystoreFile(dir, name string) string {
return filepath.Join(dir, "key_"+strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(name))))
}
// export reads the publisher key from Kubo's keystore and returns it sealed with the passphrase.
// The cleartext key exists only in this process's memory, for the length of the request.
func (a *adminAPI) export(w http.ResponseWriter, r *http.Request) {
var req struct {
Passphrase string `json:"passphrase"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
http.Error(w, "{passphrase}?", http.StatusBadRequest)
return
}
key, err := os.ReadFile(keystoreFile(a.keystore, a.publisher))
if err != nil {
log.Printf("admin: key export: %v", err)
http.Error(w, "cannot read the publisher key "+a.publisher+" (has anything been published yet?)", http.StatusNotFound)
return
}
b, err := sealKey(a.publisher, key, req.Passphrase)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
u, _ := a.au.user(r)
log.Printf("admin: publisher key %s (%s) exported by %s", a.publisher, b.ID, u)
name := fmt.Sprintf("%s-%s-%s.ipcrkey.json", a.publisher, b.ID[len(b.ID)-8:], time.Now().UTC().Format("20060102"))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(b)
}
type repoToggle struct {
FullName string `json:"full_name"`
Publish bool `json:"publish"`
}
// repoView is a row of the admin page's Repositories tab.
type repoView struct {
repoToggle
PublishedAs []string `json:"published_as,omitempty"` // short path first
Credential bool `json:"credential"` // the gate knows its IPCR_PUSH_TOKEN
Collision string `json:"collision,omitempty"` // why its short path is not used
}
// pubPlan is what IPCR is told to publish, and why some short paths are not used.
type pubPlan struct {
Repos []string // owner/repo, lowercase
Aliases map[string][]string // owner/repo → short paths
Collisions map[string]string // owner/repo → reason
}
// plan computes the allowlist: the eligible public repositories not switched off, and for the root
// organisation's, their short path, unless it would collide with an owner's folder: the name of
// any Gitea user or organisation (exists), or the owner of another allowed repository.
func plan(repos []repo, disabled []string, root string, exists func(string) bool) pubPlan {
p := pubPlan{Aliases: map[string][]string{}, Collisions: map[string]string{}}
off := map[string]bool{}
for _, d := range disabled {
off[d] = true
}
owners := map[string]bool{}
for i := range repos {
n := strings.ToLower(repos[i].FullName)
if eligible(&repos[i]) && !off[n] {
p.Repos = append(p.Repos, n)
owners[strings.SplitN(n, "/", 2)[0]] = true
}
}
sort.Strings(p.Repos)
if root == "" {
return p
}
for _, n := range p.Repos {
owner, name, _ := strings.Cut(n, "/")
if owner != root {
continue
}
switch {
case owners[name]:
p.Collisions[n] = "short path /" + name + " is the folder of " + name + "'s own repositories"
case exists != nil && exists(name):
p.Collisions[n] = "a Gitea user or organisation is named " + name + ": its repositories would share /" + name
default:
p.Aliases[n] = []string{name}
}
}
return p
}
func (a *adminAPI) currentPlan() (pubPlan, []repo, error) {
repos, err := a.gitea.publicRepos()
if err != nil {
return pubPlan{}, nil, err
}
return plan(repos, a.load().Disabled, a.root(), a.admin.exists), repos, nil
}
func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) {
p, repos, err := a.currentPlan()
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
disabled := map[string]bool{}
for _, d := range a.load().Disabled {
disabled[d] = true
}
out := []repoView{}
for _, rp := range repos {
if !eligible(&rp) {
continue
}
n := strings.ToLower(rp.FullName)
v := repoView{repoToggle: repoToggle{FullName: n, Publish: !disabled[n]}, Collision: p.Collisions[n]}
if v.Publish {
v.PublishedAs = append(append([]string{}, p.Aliases[n]...), n)
v.Credential = a.creds != nil && a.creds.has(n)
}
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].FullName < out[j].FullName })
writeJSONResponse(w, out)
}
func (a *adminAPI) setRepo(w http.ResponseWriter, r *http.Request) {
var req repoToggle
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil || req.FullName == "" {
http.Error(w, "{full_name, publish}?", http.StatusBadRequest)
return
}
n := strings.ToLower(req.FullName)
a.mu.Lock()
s := a.load()
var keep []string
for _, d := range s.Disabled {
if d != n {
keep = append(keep, d)
}
}
if !req.Publish {
keep = append(keep, n)
}
sort.Strings(keep)
s.Disabled = keep
err := a.save(s)
a.mu.Unlock()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := a.pushAllowlist(); err != nil {
http.Error(w, "saved, but not fully applied: "+err.Error(), http.StatusServiceUnavailable)
return
}
writeJSONResponse(w, req)
}
// rotate gives a repository a new IPCR_PUSH_TOKEN: when it changed hands, or the old one leaked.
func (a *adminAPI) rotate(w http.ResponseWriter, r *http.Request) {
var req repoToggle
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil || req.FullName == "" {
http.Error(w, "{full_name}?", http.StatusBadRequest)
return
}
p, _, err := a.currentPlan()
if err == nil {
err = a.creds.sync(p.Repos, req.FullName)
}
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
writeJSONResponse(w, map[string]string{"rotated": req.FullName})
}
func (a *adminAPI) getSettings(w http.ResponseWriter, r *http.Request) {
writeJSONResponse(w, map[string]string{"rootOrg": a.root(), "rootOrgDefault": strings.ToLower(a.rootOrg)})
}
func (a *adminAPI) putSettings(w http.ResponseWriter, r *http.Request) {
var req struct {
RootOrg string `json:"rootOrg"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
http.Error(w, "{rootOrg}?", http.StatusBadRequest)
return
}
org := strings.ToLower(strings.TrimSpace(req.RootOrg))
if org != "" && !a.admin.exists(org) {
http.Error(w, "no Gitea organisation named "+org, http.StatusBadRequest)
return
}
a.mu.Lock()
s := a.load()
s.RootOrg = &org
err := a.save(s)
a.mu.Unlock()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := a.pushAllowlist(); err != nil {
http.Error(w, "saved, but not fully applied: "+err.Error(), http.StatusServiceUnavailable)
return
}
writeJSONResponse(w, map[string]string{"rootOrg": org})
}
// eligible: the repositories whose images may be published — the same that are mirrored.
func eligible(r *repo) bool { return r.public() && !r.Fork && !r.Mirror }
// pushAllowlist sends ipcrd the current plan and gives the gate the matching credentials. Called
// on every reconcile, toggle and root-organisation change.
func (a *adminAPI) pushAllowlist() error {
p, _, err := a.currentPlan()
if err != nil {
return err
}
if err := a.sendAllowlist(p); err != nil {
return err
}
if a.creds != nil {
return a.creds.sync(p.Repos, "")
}
return nil
}
func (a *adminAPI) sendAllowlist(p pubPlan) error {
list := p.Repos
if list == nil {
list = []string{}
}
body, _ := json.Marshal(map[string]any{"allow": map[string]any{"required": true, "repos": list, "aliases": p.Aliases}})
req, _ := http.NewRequest("PATCH", a.ipcr+"/admin/config", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.token())
resp, err := a.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
return errors.New(resp.Status + " " + strings.TrimSpace(string(b)))
}
return nil
}