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).
104 lines
3.2 KiB
Go
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)
|
|
}
|