Files
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

104 lines
3.2 KiB
Go

package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// pushSecret is the Actions secret each publishing repository gets: its password at the gate.
const pushSecret = "IPCR_PUSH_TOKEN"
// pushCreds hands out the gate's credentials. Every repository allowed to publish gets a random
// token, set as its IPCR_PUSH_TOKEN Actions secret (Gitea gives secrets to the repository's own
// workflows, never to pull requests from forks); the gate only learns the token's hash. A
// repository that may no longer publish loses both. The importer's credential is the one IPCR
// generated (IMPORT_AUTH_GENERATE), read from its state folder.
type pushCreds struct {
file string // the gate's credentials.json
stagingAuth string // IPCR's "user:password"
gitea *gitea // admin-scoped (write:repository): sets and deletes secrets
mu sync.Mutex
}
func (p *pushCreds) read() gateCreds {
var c gateCreds
if b, err := os.ReadFile(p.file); err == nil {
json.Unmarshal(b, &c)
}
if c.Push == nil {
c.Push = map[string]string{}
}
return c
}
func (p *pushCreds) has(repo string) bool {
_, ok := p.read().Push[strings.ToLower(repo)]
return ok
}
// sync makes the gate's credentials match repos (owner/repo, as Gitea names them): new ones get a
// token, gone ones lose it. rotate, when set, gets a new token even if it has one.
func (p *pushCreds) sync(repos []string, rotate string) error {
p.mu.Lock()
defer p.mu.Unlock()
cur := p.read()
next := gateCreds{Push: map[string]string{}, Importer: map[string]string{}}
var errs []string
for _, repo := range repos {
key := strings.ToLower(repo)
if h, ok := cur.Push[key]; ok && !strings.EqualFold(repo, rotate) {
next.Push[key] = h
continue
}
tok := randomString(32)
if _, err := p.gitea.call("PUT", "/repos/"+repo+"/actions/secrets/"+pushSecret, map[string]string{"data": tok}, nil); err != nil {
errs = append(errs, repo+": "+err.Error())
if h, ok := cur.Push[key]; ok {
next.Push[key] = h // keep the old one working
}
continue
}
next.Push[key] = hashSecret(tok)
log.Printf("push credentials: %s gets a new %s", repo, pushSecret)
}
for key := range cur.Push {
if _, ok := next.Push[key]; !ok {
// Best effort: without its hash at the gate the secret is useless anyway.
p.gitea.call("DELETE", "/repos/"+key+"/actions/secrets/"+pushSecret, nil, nil)
log.Printf("push credentials: %s may no longer publish", key)
}
}
if b, err := os.ReadFile(p.stagingAuth); err == nil {
if user, pass, ok := strings.Cut(strings.TrimSpace(string(b)), ":"); ok && user != "" && pass != "" {
next.Importer[strings.ToLower(user)] = hashSecret(pass)
}
}
if err := writeCreds(p.file, next); err != nil {
return err
}
if len(errs) > 0 {
sort.Strings(errs)
return fmt.Errorf("could not set %s: %s", pushSecret, strings.Join(errs, "; "))
}
return nil
}
func writeCreds(path string, c gateCreds) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, _ := json.MarshalIndent(c, "", " ")
// Hashes only: readable by the gate (same uid), useless to anyone else.
if err := os.WriteFile(path+".new", b, 0o644); err != nil {
return err
}
return os.Rename(path+".new", path)
}