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).
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user