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:
Worph
2026-10-05 11:37:33 +00:00
parent b65bb867c9
commit c961d74e0e
15 changed files with 1275 additions and 189 deletions
+164 -36
View File
@@ -32,8 +32,11 @@ type adminAPI struct {
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
gitea *gitea
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
@@ -43,6 +46,24 @@ type adminAPI struct {
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 {
@@ -69,6 +90,9 @@ func (a *adminAPI) routes(mux *http.ServeMux, au *auth) {
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) {
@@ -148,8 +172,69 @@ type repoToggle struct {
Publish bool `json:"publish"`
}
func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) {
// 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
@@ -158,12 +243,18 @@ func (a *adminAPI) listRepos(w http.ResponseWriter, r *http.Request) {
for _, d := range a.load().Disabled {
disabled[d] = true
}
out := []repoToggle{}
out := []repoView{}
for _, rp := range repos {
if eligible(&rp) {
n := strings.ToLower(rp.FullName)
out = append(out, repoToggle{FullName: n, Publish: !disabled[n]})
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)
@@ -189,58 +280,95 @@ func (a *adminAPI) setRepo(w http.ResponseWriter, r *http.Request) {
}
sort.Strings(keep)
s.Disabled = keep
b, _ := json.MarshalIndent(s, "", " ")
err := os.WriteFile(a.settings+".new", b, 0o644)
if err == nil {
err = os.Rename(a.settings+".new", a.settings)
}
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 IPCR did not take the new list: "+err.Error(), http.StatusServiceUnavailable)
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 }
// allowlist is what ipcrd publishes: the image names CI builds are pushed under
// (localhost:5000/<owner>/<repo>, lowercased by docker/metadata-action).
func allowlist(repos []repo, disabled []string) []string {
off := map[string]bool{}
for _, d := range disabled {
off[d] = true
}
var out []string
for i := range repos {
n := strings.ToLower(repos[i].FullName)
if eligible(&repos[i]) && !off[n] {
out = append(out, n)
}
}
sort.Strings(out)
return out
}
// pushAllowlist sends ipcrd the current list. Called on every reconcile and on every toggle.
// 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 {
repos, err := a.gitea.publicRepos()
p, _, err := a.currentPlan()
if err != nil {
return err
}
return a.sendAllowlist(allowlist(repos, a.load().Disabled))
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(list []string) error {
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}})
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())
+19 -6
View File
@@ -171,7 +171,8 @@ func TestExampleEmbedded(t *testing.T) {
}
}
b, _ := exampleFiles.ReadFile("example/.gitea/workflows/build.yml")
if !strings.Contains(string(b), "localhost:5000/${{ github.repository }}") || !strings.Contains(string(b), "network=host") {
if !strings.Contains(string(b), "localhost:5000/${{ github.repository }}") || !strings.Contains(string(b), "network=host") ||
!strings.Contains(string(b), "secrets.IPCR_PUSH_TOKEN") {
t.Error("example workflow lost its staging registry lines")
}
}
@@ -408,7 +409,7 @@ func TestCallback(t *testing.T) {
}
}
func TestAllowlist(t *testing.T) {
func TestPlan(t *testing.T) {
mk := func(name string, f func(*repo)) repo {
r := repo{FullName: name}
if f != nil {
@@ -421,10 +422,22 @@ func TestAllowlist(t *testing.T) {
mk("owner/off", nil),
mk("owner/secret", func(r *repo) { r.Private = true }),
mk("owner/fork", func(r *repo) { r.Fork = true }),
mk("org/thing", nil),
mk("Metadec/Hello", nil),
mk("metadec/owner", nil), // short path /owner is owner/'s folder
mk("metadec/bob", nil), // a Gitea user is named bob
mk("metadec/off2", nil),
}
got := strings.Join(allowlist(repos, []string{"owner/off"}), ",")
if got != "org/thing,owner/app" {
t.Errorf("allowlist = %s", got)
p := plan(repos, []string{"owner/off", "metadec/off2"}, "metadec", func(n string) bool { return n == "bob" })
if got := strings.Join(p.Repos, ","); got != "metadec/bob,metadec/hello,metadec/owner,owner/app" {
t.Errorf("repos = %s", got)
}
if len(p.Aliases) != 1 || strings.Join(p.Aliases["metadec/hello"], ",") != "hello" {
t.Errorf("aliases = %v", p.Aliases)
}
if p.Collisions["metadec/owner"] == "" || p.Collisions["metadec/bob"] == "" || len(p.Collisions) != 2 {
t.Errorf("collisions = %v", p.Collisions)
}
if q := plan(repos, nil, "", nil); len(q.Aliases) != 0 {
t.Errorf("no root organisation, aliases %v", q.Aliases)
}
}
+9 -2
View File
@@ -2,8 +2,10 @@
# IPCR picks it up from there within a minute and publishes it on IPFS; the forge's page then
# lists the exact `image:` line to use.
#
# Differences from a GitHub-hosted workflow, both needed on this forge:
# images: localhost:5000/… the staging registry (no login, no secret)
# Differences from a GitHub-hosted workflow, all needed on this forge:
# login to localhost:5000 the staging registry, as this repository: IPCR_PUSH_TOKEN is a secret
# the forge sets on every repository allowed to publish
# images: localhost:5000/… this repository's own name there (no other is accepted)
# network=host so buildx's builder can reach that registry
name: image
on:
@@ -14,6 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: localhost:5000
username: ${{ github.repository }}
password: ${{ secrets.IPCR_PUSH_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
+189
View File
@@ -0,0 +1,189 @@
package main
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"sync"
"time"
)
// The gate is the staging registry's front door (`ipcr-forge-bridge gate`). CI jobs push to
// localhost:5000 and IPCR imports from it; before the gate, anyone who could run a job could push
// any image name, and IPCR published what it found. Now:
//
// - a job logs in as its repository: user <owner>/<repo>, password the IPCR_PUSH_TOKEN secret the
// bridge set on that repository (pushcreds.go). It may read and write that repository's
// manifests, blobs and tags, and nothing else: no other name, no catalog, no delete, no
// cross-repository blob mount;
// - IPCR logs in as the importer (the credential it generated itself): read everything, list the
// catalog, delete manifests it has published.
//
// It runs in the CI daemon's network namespace, where jobs see it as localhost:5000. The registry
// behind it listens on a unix socket shared with the gate only, so a job cannot go around it.
//
// Environment: GATE_LISTEN (default :5000), GATE_UPSTREAM (default
// unix:///run/staging/registry.sock), GATE_CREDENTIALS (default /gate/credentials.json).
// gateCreds is credentials.json, written by the bridge: SHA-256 (hex) of each password.
type gateCreds struct {
Push map[string]string `json:"push"` // "<owner>/<repo>" (lowercase) → hash
Importer map[string]string `json:"importer"` // user → hash
}
type gate struct {
credFile string
upstream http.Handler
mu sync.Mutex
mtime time.Time
creds gateCreds
}
func gateMain() error {
g := &gate{credFile: env("GATE_CREDENTIALS", "/gate/credentials.json")}
up, err := upstreamProxy(env("GATE_UPSTREAM", "unix:///run/staging/registry.sock"))
if err != nil {
return err
}
g.upstream = up
addr := env("GATE_LISTEN", ":5000")
log.Printf("gate: %s → %s, credentials %s", addr, env("GATE_UPSTREAM", "unix:///run/staging/registry.sock"), g.credFile)
return http.ListenAndServe(addr, g)
}
// upstreamProxy forwards to the registry, keeping the client's Host (the registry builds upload
// URLs from it) and dropping the client's credentials (the registry has none of its own).
func upstreamProxy(target string) (http.Handler, error) {
u, err := url.Parse(target)
if err != nil {
return nil, err
}
tr := http.DefaultTransport.(*http.Transport).Clone()
dest := &url.URL{Scheme: u.Scheme, Host: u.Host}
if u.Scheme == "unix" {
sock := u.Path
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", sock)
}
dest = &url.URL{Scheme: "http", Host: "registry"}
}
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(dest)
pr.Out.Host = pr.In.Host
pr.Out.Header.Del("Authorization")
},
Transport: tr,
FlushInterval: -1,
}, nil
}
func (g *gate) load() gateCreds {
g.mu.Lock()
defer g.mu.Unlock()
st, err := os.Stat(g.credFile)
if err != nil {
return g.creds
}
if !st.ModTime().Equal(g.mtime) {
var c gateCreds
if b, err := os.ReadFile(g.credFile); err == nil && json.Unmarshal(b, &c) == nil {
g.creds, g.mtime = c, st.ModTime()
}
}
return g.creds
}
func hashSecret(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
func matches(stored, pass string) bool {
return stored != "" && subtle.ConstantTimeCompare([]byte(stored), []byte(hashSecret(pass))) == 1
}
func registryError(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
if status == http.StatusUnauthorized {
w.Header().Set("WWW-Authenticate", `Basic realm="ipcr-staging"`)
}
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{"errors": []map[string]string{{"code": code, "message": msg}}})
}
func (g *gate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if r.URL.RawPath != "" || strings.Contains(p, "..") || strings.Contains(p, "//") || !strings.HasPrefix(p, "/v2") {
registryError(w, http.StatusBadRequest, "NAME_INVALID", "bad path")
return
}
user, pass, _ := r.BasicAuth()
user = strings.ToLower(user)
creds := g.load()
role := ""
switch {
case matches(creds.Importer[user], pass):
role = "importer"
case matches(creds.Push[user], pass):
role = "push"
}
if role == "" {
registryError(w, http.StatusUnauthorized, "UNAUTHORIZED", "log in as <owner>/<repo> with the repository's IPCR_PUSH_TOKEN secret")
return
}
if !allowed(role, user, r) {
log.Printf("gate: %s refused %s %s", user, r.Method, p)
registryError(w, http.StatusForbidden, "DENIED", user+" may only push "+user)
return
}
if role == "push" {
// A cross-repository mount would copy a blob from a repository this user may not read.
// Without it the client simply uploads the blob.
q := r.URL.Query()
if q.Has("mount") || q.Has("from") {
q.Del("mount")
q.Del("from")
r.URL.RawQuery = q.Encode()
}
}
g.upstream.ServeHTTP(w, r)
}
// allowed applies the two roles to a request.
func allowed(role, user string, r *http.Request) bool {
p, m := r.URL.Path, r.Method
read := m == "GET" || m == "HEAD"
if p == "/v2/" || p == "/v2" {
return read
}
if role == "importer" {
if read {
return true
}
return m == "DELETE" && strings.Contains(p, "/manifests/")
}
rest, ok := strings.CutPrefix(p, "/v2/"+user+"/")
if !ok || user == "" {
return false
}
if !strings.HasPrefix(rest, "blobs/") && !strings.HasPrefix(rest, "manifests/") && !strings.HasPrefix(rest, "tags/") {
return false
}
switch m {
case "GET", "HEAD", "POST", "PUT", "PATCH":
return true
}
return false
}
+175
View File
@@ -0,0 +1,175 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func newTestGate(t *testing.T) (*gate, *[]*http.Request) {
t.Helper()
var seen []*http.Request
var mu sync.Mutex
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
seen = append(seen, r.Clone(r.Context()))
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(up.Close)
proxy, err := upstreamProxy(up.URL)
if err != nil {
t.Fatal(err)
}
file := filepath.Join(t.TempDir(), "credentials.json")
writeCreds(file, gateCreds{
Push: map[string]string{"metadec/app": hashSecret("app-token"), "alice/tool": hashSecret("alice-token")},
Importer: map[string]string{"ipcr": hashSecret("importer-pass")},
})
return &gate{credFile: file, upstream: proxy}, &seen
}
func TestGateAuthorization(t *testing.T) {
g, _ := newTestGate(t)
for _, c := range []struct {
name, method, path, user, pass string
want int
}{
{"no credentials", "GET", "/v2/", "", "", 401},
{"wrong password", "GET", "/v2/", "metadec/app", "nope", 401},
{"login check", "GET", "/v2/", "metadec/app", "app-token", 200},
{"login, uppercase user", "GET", "/v2/", "Metadec/App", "app-token", 200},
{"own manifest", "PUT", "/v2/metadec/app/manifests/1.0.0", "metadec/app", "app-token", 200},
{"own blob upload", "POST", "/v2/metadec/app/blobs/uploads/", "metadec/app", "app-token", 200},
{"own upload chunk", "PATCH", "/v2/metadec/app/blobs/uploads/abc", "metadec/app", "app-token", 200},
{"own blob check", "HEAD", "/v2/metadec/app/blobs/sha256:00", "metadec/app", "app-token", 200},
{"other repository", "PUT", "/v2/alice/tool/manifests/1.0.0", "metadec/app", "app-token", 403},
{"prefix of another name", "PUT", "/v2/metadec/app2/manifests/1", "metadec/app", "app-token", 403},
{"nested name", "PUT", "/v2/metadec/app/sub/manifests/1", "metadec/app", "app-token", 403},
{"catalog as pusher", "GET", "/v2/_catalog", "metadec/app", "app-token", 403},
{"delete as pusher", "DELETE", "/v2/metadec/app/manifests/sha256:00", "metadec/app", "app-token", 403},
{"dot-dot", "GET", "/v2/metadec/app/../alice/tool/tags/list", "metadec/app", "app-token", 400},
{"importer catalog", "GET", "/v2/_catalog", "ipcr", "importer-pass", 200},
{"importer pull", "GET", "/v2/alice/tool/manifests/1.0.0", "ipcr", "importer-pass", 200},
{"importer delete", "DELETE", "/v2/alice/tool/manifests/sha256:00", "ipcr", "importer-pass", 200},
{"importer push", "PUT", "/v2/alice/tool/manifests/1.0.0", "ipcr", "importer-pass", 403},
} {
r := httptest.NewRequest(c.method, "http://localhost:5000"+c.path, nil)
if c.user != "" {
r.SetBasicAuth(c.user, c.pass)
}
w := httptest.NewRecorder()
g.ServeHTTP(w, r)
if w.Code != c.want {
t.Errorf("%s: %d, want %d", c.name, w.Code, c.want)
}
if w.Code == 401 && !strings.HasPrefix(w.Header().Get("WWW-Authenticate"), "Basic ") {
t.Errorf("%s: no Basic challenge", c.name)
}
}
}
func TestGateForwarding(t *testing.T) {
g, seen := newTestGate(t)
r := httptest.NewRequest("POST", "http://localhost:5000/v2/metadec/app/blobs/uploads/?mount=sha256:00&from=alice/tool", nil)
r.Host = "localhost:5000"
r.SetBasicAuth("metadec/app", "app-token")
w := httptest.NewRecorder()
g.ServeHTTP(w, r)
if w.Code != 200 || len(*seen) != 1 {
t.Fatalf("%d, %d upstream requests", w.Code, len(*seen))
}
up := (*seen)[0]
if up.URL.RawQuery != "" {
t.Errorf("cross-repository mount forwarded: %q", up.URL.RawQuery)
}
if up.Host != "localhost:5000" {
t.Errorf("Host %q: the registry builds upload URLs from it", up.Host)
}
if up.Header.Get("Authorization") != "" {
t.Error("credentials forwarded to the registry")
}
}
func TestGateReloadsCredentials(t *testing.T) {
g, _ := newTestGate(t)
try := func() int {
r := httptest.NewRequest("GET", "http://localhost:5000/v2/", nil)
r.SetBasicAuth("new/repo", "t")
w := httptest.NewRecorder()
g.ServeHTTP(w, r)
return w.Code
}
if try() != 401 {
t.Fatal("unknown repository accepted")
}
c := gateCreds{Push: map[string]string{"new/repo": hashSecret("t")}}
writeCreds(g.credFile, c)
// A different modification time, as a later write would have.
st, _ := os.Stat(g.credFile)
os.Chtimes(g.credFile, st.ModTime().Add(1e9), st.ModTime().Add(1e9))
if try() != 200 {
t.Error("new credentials not picked up")
}
}
// sync against a fake Gitea: secrets set for new repositories, kept for known ones, deleted for
// removed ones, the importer taken from IPCR's file.
func TestPushCredsSync(t *testing.T) {
var mu sync.Mutex
secrets := map[string]string{}
var deleted []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
repo := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/repos/"), "/actions/secrets/"+pushSecret)
switch r.Method {
case "PUT":
var b struct{ Data string }
json.NewDecoder(r.Body).Decode(&b)
secrets[repo] = b.Data
w.WriteHeader(201)
case "DELETE":
deleted = append(deleted, repo)
w.WriteHeader(204)
}
}))
defer srv.Close()
dir := t.TempDir()
tok := filepath.Join(dir, "token")
os.WriteFile(tok, []byte("admin-token"), 0o600)
auth := filepath.Join(dir, "staging-auth")
os.WriteFile(auth, []byte("ipcr:importer-pass\n"), 0o644)
p := &pushCreds{file: filepath.Join(dir, "gate", "credentials.json"), stagingAuth: auth, gitea: newGitea(srv.URL, "gitea_admin", tok)}
if err := p.sync([]string{"Metadec/App", "alice/tool"}, ""); err != nil {
t.Fatal(err)
}
c := p.read()
if !matches(c.Push["metadec/app"], secrets["Metadec/App"]) || !matches(c.Push["alice/tool"], secrets["alice/tool"]) {
t.Fatalf("credentials do not match the secrets: %v %v", c.Push, secrets)
}
if !matches(c.Importer["ipcr"], "importer-pass") {
t.Error("importer credential missing")
}
first := secrets["alice/tool"]
// alice/tool keeps its token; Metadec/App is rotated; a removed repository loses it.
if err := p.sync([]string{"Metadec/App", "alice/tool"}, "metadec/app"); err != nil {
t.Fatal(err)
}
if secrets["alice/tool"] != first || !matches(p.read().Push["metadec/app"], secrets["Metadec/App"]) {
t.Error("rotation")
}
if err := p.sync([]string{"Metadec/App"}, ""); err != nil {
t.Fatal(err)
}
if _, ok := p.read().Push["alice/tool"]; ok || len(deleted) != 1 || deleted[0] != "alice/tool" {
t.Errorf("removal: %v, deleted %v", p.read().Push, deleted)
}
}
+10
View File
@@ -140,3 +140,13 @@ func (g *gitea) repoByID(id int64) (*repo, error) {
}
return &r, nil
}
// exists reports whether a Gitea user or organisation has this name (an admin-scoped token sees
// private ones too). On doubt (Gitea unreachable) it answers yes: a short path is then not used.
func (g *gitea) exists(name string) bool {
if g == nil {
return false
}
code, err := g.call("GET", "/users/"+url.PathEscape(name), nil, nil)
return code == http.StatusOK || (err != nil && code != http.StatusNotFound)
}
+13 -2
View File
@@ -4,6 +4,7 @@
// serve (default) webhook receiver + mirror worker + the forge's page
// setup install step: Gitea token, system webhook, example repository (idempotent)
// health exit 0 when `serve` answers, for the container healthcheck
// gate the staging registry's front door: per-repository push credentials (gate.go)
//
// What gets mirrored: every public, non-fork, non-mirror Gitea repository, as seen by a token
// scoped `public-only` — a private repository is invisible to the service, not filtered by it.
@@ -39,6 +40,10 @@
// 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)
// IPCR_ROOT_ORG organisation published at the root of the forge's name, until changed in
// the admin page (default: none)
// GATE_CREDENTIALS the gate's credentials.json (default /bridge/gate/credentials.json)
// IPCR_STAGING_AUTH IPCR's own staging credential (default /srv/ipcr-state/staging-auth)
package main
import (
@@ -71,8 +76,10 @@ func main() {
err = setup()
case "health":
err = health()
case "gate":
err = gateMain()
default:
err = fmt.Errorf("usage: %s serve | setup | health", os.Args[0])
err = fmt.Errorf("usage: %s serve | setup | health | gate", os.Args[0])
}
if err != nil {
log.Fatal(err)
@@ -128,10 +135,14 @@ func serve() error {
if err != nil {
return err
}
adminGitea := newGitea(m.gitea.base, m.gitea.owner, secrets+"/gitea-admin-token")
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},
settings: stateDir + "/admin.json", gitea: m.gitea, admin: adminGitea, rootOrg: os.Getenv("IPCR_ROOT_ORG"),
creds: &pushCreds{file: env("GATE_CREDENTIALS", stateDir+"/gate/credentials.json"),
stagingAuth: env("IPCR_STAGING_AUTH", "/srv/ipcr-state/staging-auth"), gitea: adminGitea},
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
+103
View File
@@ -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)
}
+7 -3
View File
@@ -68,9 +68,13 @@ func (m *mirror) rad(dir string, args ...string) (string, error) {
// makes this node seed it (the node's policy is `block`: nothing is kept unless asked) and adds
// the `rad` remote that pushes go to.
func (m *mirror) radInit(dir string, r *repo) (string, error) {
desc := r.Description
if desc == "" {
desc = "Mirrored from Gitea: " + r.FullName
// A repository's ID is a hash of its first identity document (name, description, default
// branch, delegates). Two Gitea repositories with the same name and description (an example
// copied into an organisation) would get the same ID on this node, and the second rad init
// fails ("attempt to reinitialize"). The Gitea path makes the document unique per forge.
desc := "Mirrored from Gitea: " + r.FullName
if r.Description != "" {
desc = r.Description + " (Gitea: " + r.FullName + ")"
}
out, err := m.rad(dir, "init", dir, "--name", r.Name, "--description", desc,
"--default-branch", r.DefaultBranch, "--public", "--no-confirm")
+85 -11
View File
@@ -19,7 +19,29 @@ import (
//go:embed all:example
var exampleFiles embed.FS
const tokenName = "ipcr-forge-bridge"
const (
tokenName = "ipcr-forge-bridge"
adminTokenName = "ipcr-forge-secrets"
)
// ensureOrg creates the public organisation org, owned by the admin, unless it exists.
func ensureOrg(g *gitea, org string) error {
code, err := g.call("GET", "/orgs/"+org, nil, nil)
if code == http.StatusOK {
return nil
}
if code != http.StatusNotFound {
return err
}
if _, err := g.call("POST", "/admin/users/"+g.owner+"/orgs", map[string]any{
"username": org, "visibility": "public",
"description": "Its public repositories publish images at the root of the forge's name.",
}, nil); err != nil {
return err
}
log.Printf("setup: created organisation %s", org)
return nil
}
// setup is the install step, run after the stack is up and on every start (each action is
// idempotent). It signs in once as the admin with the server's default app password, because
@@ -30,7 +52,10 @@ const tokenName = "ipcr-forge-bridge"
// SECRETS_DIR/hook-secret the webhook's HMAC key. Generated only when missing.
// a system webhook every repository's push/create/delete/repository events → HOOK_URL,
// created or repaired (matched by URL), never duplicated.
// EXAMPLE_REPO a public example repository with a workflow, created when absent.
// SECRETS_DIR/gitea-admin-token write:repository + read:user: push secrets, name collisions.
// IPCR_ROOT_ORG the root organisation, created when absent (public, owned by the admin).
// EXAMPLE_REPO a public example repository with a workflow, created when absent, in
// the root organisation when there is one.
// an OAuth2 application the admin pages' Gitea login (PUBLIC_HOSTS → its redirect URIs);
// SECRETS_DIR/oauth-client-id and oauth-client-secret.
//
@@ -78,6 +103,36 @@ func setup() error {
log.Printf("setup: token %s written", tokenName)
}
// The admin-scoped token: sets each publishing repository's IPCR_PUSH_TOKEN secret, and checks
// short-path collisions against every user and organisation. Not public-only: an organisation
// may be private and still own the name.
adminTokenFile := filepath.Join(secrets, "gitea-admin-token")
if b, _ := os.ReadFile(adminTokenFile); len(strings.TrimSpace(string(b))) == 0 {
g.call("DELETE", "/users/"+g.owner+"/tokens/"+adminTokenName, nil, nil)
var t struct {
SHA1 string `json:"sha1"`
}
if _, err := g.call("POST", "/users/"+g.owner+"/tokens", map[string]any{
"name": adminTokenName, "scopes": []string{"write:repository", "read:user", "read:organization"},
}, &t); err != nil {
return fmt.Errorf("token: %w", err)
}
if err := writeSecret(adminTokenFile, t.SHA1); err != nil {
return err
}
log.Printf("setup: token %s written", adminTokenName)
}
// The root organisation: its repositories are also published at the root of the forge's name.
owner := g.owner
if org := strings.ToLower(env("IPCR_ROOT_ORG", "")); org != "" {
if err := ensureOrg(g, org); err != nil {
log.Printf("setup: organisation %s: %v", org, err)
} else {
owner = org
}
}
secretFile := filepath.Join(secrets, "hook-secret")
secret, _ := os.ReadFile(secretFile)
if strings.TrimSpace(string(secret)) == "" {
@@ -100,7 +155,7 @@ func setup() error {
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
// Not fatal: the forge works without its example.
if err := ensureExample(g, name); err != nil {
if err := ensureExample(g, owner, name); err != nil {
log.Printf("setup: example repository: %v", err)
}
}
@@ -146,18 +201,22 @@ func ensureHook(g *gitea, url, secret string) error {
// ensureExample creates <owner>/<name>, public, with the files under example/ in one commit. A
// repository that already has content is left as it is: it may be the user's by now.
func ensureExample(g *gitea, name string) error {
func ensureExample(g *gitea, owner, name string) error {
var r repo
code, err := g.call("GET", "/repos/"+g.owner+"/"+name, nil, &r)
code, err := g.call("GET", "/repos/"+owner+"/"+name, nil, &r)
switch {
case code == http.StatusNotFound:
if _, err := g.call("POST", "/admin/users/"+g.owner+"/repos", map[string]any{
create := "/admin/users/" + owner + "/repos"
if owner != g.owner {
create = "/orgs/" + owner + "/repos"
}
if _, err := g.call("POST", create, map[string]any{
"name": name, "private": false, "default_branch": "main",
"description": "Example: a tag here builds an image, published on IPFS by IPCR and mirrored to Radicle.",
}, &r); err != nil {
return err
}
log.Printf("setup: created %s/%s", g.owner, name)
log.Printf("setup: created %s/%s", owner, name)
case err != nil:
return err
case !r.Empty:
@@ -181,7 +240,7 @@ func ensureExample(g *gitea, name string) error {
if err != nil {
return err
}
_, err = g.call("POST", "/repos/"+g.owner+"/"+name+"/contents", map[string]any{
_, err = g.call("POST", "/repos/"+owner+"/"+name+"/contents", map[string]any{
"branch": "main", "message": "Example: a Dockerfile and the workflow that publishes it", "files": files,
}, nil)
return err
@@ -226,9 +285,24 @@ func ensureOAuthApp(g *gitea, hosts []string, secrets string) error {
continue
}
if haveSecret {
// Keep it (and its secret); only follow a change of hosts.
_, err := g.call("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), body, nil)
return err
// Keep it, and its secret. Gitea regenerates the secret on every update, so only
// update when the hosts changed, and keep the secret it hands back.
if strings.Join(app.RedirectURIs, ",") == strings.Join(redirects, ",") {
return nil
}
var updated struct {
ClientSecret string `json:"client_secret"`
}
if _, err := g.call("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), body, &updated); err != nil {
return err
}
if updated.ClientSecret != "" {
if err := writeSecret(secretFile, updated.ClientSecret); err != nil {
return err
}
}
log.Printf("setup: OAuth2 app %s now for %s", oauthAppName, strings.Join(redirects, ", "))
return nil
}
if _, err := g.call("DELETE", fmt.Sprintf("/user/applications/oauth2/%d", app.ID), nil, nil); err != nil {
return err
+241 -78
View File
@@ -59,6 +59,27 @@
.bar { height: 8px; background: var(--code); border-radius: 4px; overflow: hidden; margin: 6px 0; }
.bar span { display: block; height: 100%; background: var(--accent); }
.note { border-left: 3px solid var(--warn); padding: 4px 12px; color: var(--muted); margin: 8px 0; }
.wide { grid-column: 1 / -1; }
/* Names, CIDs and commands stay on one line; their box scrolls instead. */
.line { display: block; white-space: nowrap; overflow-x: auto; overflow-wrap: normal; }
.copyrow { display: flex; gap: 8px; align-items: center; min-width: 0; }
.copyrow code { flex: 1; min-width: 0; white-space: nowrap; overflow-x: auto; overflow-wrap: normal; padding: 3px 6px; }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin: 20px 0 4px; overflow-x: auto; }
.tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; color: var(--muted);
padding: 8px 14px; font-size: 14px; white-space: nowrap; }
.tabs button[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--accent); font-weight: 600; }
.count { font-size: 12px; background: var(--code); border-radius: 10px; padding: 0 7px; margin-left: 4px; }
.addrs { list-style: none; padding: 0; margin: 4px 0 0; font-size: 13px; }
.addrs li { margin: 2px 0; }
.tag { border-top: 1px solid var(--line); padding: 10px 0; }
.tag:first-of-type { border-top: 0; }
.taghead { display: flex; gap: 10px; align-items: baseline; justify-content: space-between; flex-wrap: wrap; margin-bottom: 6px; }
.cmd { display: grid; grid-template-columns: 44px 1fr auto; gap: 8px; align-items: center; margin: 3px 0; }
.cmd .k { font-size: 11.5px; font-weight: 600; color: var(--muted); text-transform: uppercase; }
.cmd code { white-space: nowrap; overflow-x: auto; overflow-wrap: normal; padding: 3px 6px; min-width: 0; }
.spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--line); border-top-color: var(--accent);
border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -1px; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }
#toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--ink); color: var(--bg);
padding: 8px 14px; border-radius: 8px; font-size: 14px; display: none; max-width: calc(100% - 32px); }
</style>
@@ -71,33 +92,43 @@
<button id="logout">Sign out</button>
</header>
<nav class="tabs" role="tablist">
<button role="tab" data-tab="overview">Overview</button>
<button role="tab" data-tab="images">Published images<span class="count" id="images-count">…</span></button>
<button role="tab" data-tab="repos">Repositories</button>
<button role="tab" data-tab="backup">Key backup</button>
</nav>
<section class="panel" id="tab-overview">
<h2>Publisher name</h2>
<div class="grid">
<div class="card">
<div class="card wide">
<h3>Key</h3>
<dl>
<dt>Key</dt><dd><code id="key-name">…</code></dd>
<dt>IPNS name</dt><dd><code id="key-id">…</code></dd>
<dt>Pull as</dt><dd><code id="pull-as">…</code></dd>
<dt>IPNS name</dt><dd><div class="copyrow"><code id="key-id">…</code><button class="copy" data-copy="key-id">Copy</button></div></dd>
<dt>Pull as</dt><dd><div class="copyrow"><code id="pull-as">…</code></div></dd>
</dl>
<p class="muted">Every image is published under this one name. Back the key up (below): it exists only on this server.</p>
<p class="muted">Every image is published under this one name. Back the key up (Key backup tab): it exists only on this server.</p>
</div>
<div class="card">
<div class="card wide">
<h3>Your domain or ENS name</h3>
<p class="muted">Point a name you own at the IPNS name, then enter it here. Once it checks out, pull lines use it.</p>
<form class="row" id="name-form">
<input type="text" id="name" placeholder="example.eth or example.com" size="26" autocomplete="off">
<input type="text" id="name" placeholder="example.eth or example.com" size="30" autocomplete="off">
<input type="submit" value="Save and check">
</form>
<p id="name-check" class="muted"></p>
<details><summary class="muted">How to point it here</summary>
<p><b>ENS:</b> in the ENS manager, Records → Content hash = <code>ipns://<span class="kid">k51…</span></code>.</p>
<p><b>DNS:</b> a TXT record <code>_dnslink.example.com</code> = <code>dnslink=/ipns/<span class="kid">k51…</span></code>.</p>
<p><b>ENS:</b> in the ENS manager, Records → Content hash =</p>
<div class="copyrow"><code id="ens-value">ipns://k51…</code><button class="copy" data-copy="ens-value">Copy</button></div>
<p><b>DNS:</b> a TXT record <code>_dnslink.example.com</code> =</p>
<div class="copyrow"><code id="dns-value">dnslink=/ipns/k51…</code><button class="copy" data-copy="dns-value">Copy</button></div>
</details>
</div>
</div>
<h2>Health <button id="refresh">Check again</button></h2>
<h2>Health <button id="refresh">Check again</button> <span id="refresh-state" class="muted"></span></h2>
<div class="grid">
<div class="card">
<h3>IPNS record</h3>
@@ -106,27 +137,50 @@
<p class="muted">The record must be renewed by this node while it is online (Kubo does it every 4 h). Lifetime is how long a
record stays valid if this node goes offline; TTL is how long resolvers cache it.</p>
</div>
<div class="card">
<h3>Node</h3>
<dl id="node"></dl>
<div class="actions" style="margin-top:8px"><button id="peers">What the network sees</button></div>
<div id="peers-out" class="muted"></div>
</div>
<div class="card">
<h3>Storage</h3>
<div id="storage"></div>
<p class="muted">Published images are pinned and never collected. Unpublishing an image frees the blocks no other image uses.</p>
</div>
<div class="card wide">
<h3>Node</h3>
<dl id="node"></dl>
<h3 style="margin-top:14px">Listening on</h3>
<ul class="addrs" id="addrs"></ul>
<div class="actions" style="margin-top:10px"><button id="peers">What the network sees</button></div>
<div id="peers-out" class="muted"></div>
</div>
</div>
</section>
<section class="panel" id="tab-images" hidden>
<h2>Published images</h2>
<p class="muted">Every tag can be pulled three ways: by <b>IPFS</b> CID (this exact image, forever), by <b>IPNS</b> name (follows the tag),
and by your ENS or DNS name once it checks out (Overview).</p>
<div id="images" class="muted">Loading…</div>
</section>
<section class="panel" id="tab-repos" hidden>
<h2>Root organisation</h2>
<div class="card">
<p class="muted">The public repositories of this Gitea organisation are also published at the root of the forge's
name: <code>metadec/app</code> → <code>…/ipns/&lt;name&gt;/app</code>. Its members (Gitea teams) are the ones who
can publish there. Leave empty for none.</p>
<form class="row" id="org-form">
<input type="text" id="org" placeholder="organisation" size="24" autocomplete="off">
<input type="submit" value="Save">
</form>
<p id="org-state" class="muted"></p>
</div>
<h2>Published images</h2>
<div id="images" class="muted">Loading…</div>
<h2>Which repositories may publish</h2>
<p class="muted">A build can push any image name to the staging registry; IPCR only publishes the names of the public
Gitea repositories switched on here (<code>&lt;owner&gt;/&lt;repo&gt;</code>). New public repositories are on.</p>
<p class="muted">Each public repository switched on here publishes under its own name only: the forge sets an
<code>IPCR_PUSH_TOKEN</code> secret on it, and the staging registry accepts that token for that name and no other.
Workflows log in with it (see the forge page's template). New public repositories are on.</p>
<div id="repos" class="muted">Loading…</div>
</section>
<section class="panel" id="tab-backup" hidden>
<h2>Key backup</h2>
<div class="grid">
<div class="card">
@@ -153,6 +207,7 @@
name's last published images are adopted.</p>
</div>
</div>
</section>
</main>
<div id="toast"></div>
@@ -213,6 +268,34 @@
.then(function () { location.href = "/"; });
};
// ---- tabs (the open one is kept in the URL, so a reload stays on it)
var tabs = document.querySelectorAll(".tabs button");
function showTab(name) {
if (!$("tab-" + name)) name = "overview";
Array.prototype.forEach.call(tabs, function (b) {
var on = b.getAttribute("data-tab") === name;
b.setAttribute("aria-selected", on ? "true" : "false");
$("tab-" + b.getAttribute("data-tab")).hidden = !on;
});
}
Array.prototype.forEach.call(tabs, function (b) {
b.onclick = function () { history.replaceState(null, "", "#" + b.getAttribute("data-tab")); showTab(b.getAttribute("data-tab")); };
});
showTab(location.hash.slice(1));
// ---- copy
function copy(text, btn) {
var done = function () { if (btn) { btn.textContent = "Copied"; setTimeout(function () { btn.textContent = "Copy"; }, 1500); } };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { window.prompt("Copy:", text); });
else window.prompt("Copy:", text);
}
function copyButton(text) { var b = el("button", "Copy", "copy"); b.type = "button"; b.onclick = function () { copy(text, b); }; return b; }
Array.prototype.forEach.call(document.querySelectorAll("button[data-copy]"), function (b) {
b.type = "button";
b.onclick = function () { copy($(b.getAttribute("data-copy")).textContent, b); };
});
function spinner(text) { var s = el("span"); s.appendChild(el("span", null, "spin")); s.appendChild(document.createTextNode(text)); return s; }
// ---- status
function recordView(r, err, tree) {
if (!r) return badge(err || "none", "bad");
@@ -222,20 +305,28 @@
if (tree && r.value !== tree) { s.appendChild(document.createTextNode(" · ")); s.appendChild(badge("older tree", "warn")); }
return s;
}
function showName(n) {
var nc = $("name-check"); nc.textContent = "";
if (n && n.name) {
nc.appendChild(n.ok ? badge("✓ " + n.name + " points at this forge", "ok") : badge("✗ " + (n.error || "does not resolve"), "bad"));
if (!n.ok && n.resolves) nc.appendChild(el("div", n.name + " currently resolves to " + n.resolves, "muted"));
if (n.checkedAt) nc.appendChild(el("div", "Checked " + n.checkedAt.replace("T", " ").replace("Z", " UTC"), "muted"));
} else nc.textContent = "No name set: pull lines use the IPNS name.";
}
function loadStatus(refresh) {
var st8 = $("refresh-state");
st8.textContent = ""; st8.appendChild(spinner(refresh ? "Checking the network…" : "Loading…"));
return api("GET", "ipcr/status" + (refresh ? "?refresh=1" : "")).then(function (s) {
st8.textContent = "";
publisher = s.key.id || "";
$("key-name").textContent = s.key.name || "—";
$("key-id").textContent = publisher || "not created yet (first import creates it)";
Array.prototype.forEach.call(document.querySelectorAll(".kid"), function (e) { e.textContent = publisher || "k51…"; });
$("key-id").textContent = publisher || "not created yet (the first import creates it)";
$("ens-value").textContent = "ipns://" + (publisher || "k51…");
$("dns-value").textContent = "dnslink=/ipns/" + (publisher || "k51…");
pubName = s.name && s.name.ok ? s.name.name : "";
$("pull-as").textContent = "ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "/<owner>/<repo>:<tag>";
if (s.name && s.name.name) $("name").value = s.name.name;
var nc = $("name-check"); nc.textContent = "";
if (s.name && s.name.name) {
nc.appendChild(s.name.ok ? badge("✓ " + s.name.name + " → this forge", "ok") : badge("✗ " + (s.name.error || "does not resolve"), "bad"));
if (!s.name.ok && s.name.resolves) nc.appendChild(el("div", "resolves to " + s.name.resolves, "muted"));
} else nc.textContent = "No name set: pull lines use the IPNS name.";
$("pull-as").textContent = "docker pull ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "/<owner>/<repo>:<tag>";
if (s.name && s.name.name && document.activeElement !== $("name")) $("name").value = s.name.name;
showName(s.name);
var rec = s.record;
dl($("record"), [
["Here", recordView(rec.local, rec.localError, rec.tree)],
@@ -243,64 +334,101 @@
["Lifetime", rec.lifetime], ["TTL", rec.ttl],
["Checked", s.checkedAt.replace("T", " ").replace("Z", " UTC")]
]);
dl($("node"), [["Peer ID", s.node.id], ["Connected peers", s.node.peers],
["Listening", (s.node.addresses || []).filter(function (a) { return a.indexOf("/127.") < 0 && a.indexOf("/::1/") < 0; }).slice(0, 4).join("\n")]]);
var st = s.storage, pct = st.storageMax ? Math.min(100, 100 * st.repoSize / st.storageMax) : 0;
$("storage").innerHTML = "";
$("storage").appendChild(el("div", size(st.repoSize) + " of " + size(st.storageMax) + " · " + st.pins + " pins"));
var pid = el("div", null, "copyrow"); pid.appendChild(el("code", s.node.id)); pid.appendChild(copyButton(s.node.id));
dl($("node"), [["Peer ID", pid], ["Connected peers", s.node.peers]]);
// Public addresses first; loopback and the container's own network are of no use to anyone else.
var addrs = (s.node.addresses || []).filter(function (a) { return !/^\/ip4\/(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)|^\/ip6\/(::1|f[cd])/.test(a); });
var ul = $("addrs"); ul.textContent = "";
if (!addrs.length) ul.appendChild(el("li", "no public address", "warn"));
addrs.forEach(function (a) { var li = el("li"); li.appendChild(el("code", a.replace(/\/p2p\/[^/]+$/, ""), "line")); ul.appendChild(li); });
var sto = s.storage, pct = sto.storageMax ? Math.min(100, 100 * sto.repoSize / sto.storageMax) : 0;
$("storage").textContent = "";
$("storage").appendChild(el("div", size(sto.repoSize) + " of " + size(sto.storageMax) + " · " + sto.pins + " pins"));
var bar = el("div", null, "bar"), fill = el("span"); fill.style.width = pct.toFixed(1) + "%"; bar.appendChild(fill);
$("storage").appendChild(bar);
}).catch(function (e) { toast("Status: " + e.message, 8000); });
}).catch(function (e) { st8.textContent = ""; toast("Status: " + e.message, 8000); });
}
$("refresh").onclick = function () { busy(this, loadStatus(true)); };
$("republish").onclick = function () {
busy(this, api("POST", "ipcr/republish")).then(function () { toast("Published again"); loadStatus(true); });
};
$("peers").onclick = function () {
var o = $("peers-out"); o.textContent = ""; o.appendChild(spinner("Asking the network…"));
busy(this, api("GET", "ipcr/peers")).then(function (p) {
var o = $("peers-out"); o.textContent = "";
o.textContent = "";
if (p.error) o.appendChild(badge(p.error, "bad"));
else if (!p.network || !p.network.length) o.appendChild(badge("The network does not know this node's addresses yet: check that port 4768 (TCP and UDP) is reachable.", "warn"));
else { o.appendChild(badge("Reachable via:", "ok")); o.appendChild(el("pre", p.network.join("\n"))); }
});
else {
o.appendChild(badge("✓ Other nodes can reach this one via:", "ok"));
var ul = el("ul", null, "addrs");
p.network.forEach(function (a) { var li = el("li"); li.appendChild(el("code", a, "line")); ul.appendChild(li); });
o.appendChild(ul);
}
}, function () { o.textContent = ""; });
};
$("name-form").onsubmit = function (e) {
e.preventDefault();
var btn = this.querySelector("input[type=submit]");
busy(btn, api("PATCH", "ipcr/config", { name: $("name").value })).then(function () { return loadStatus(true); });
var btn = this.querySelector("input[type=submit]"), input = $("name"), name = input.value.trim();
var nc = $("name-check");
nc.textContent = "";
nc.appendChild(spinner(name ? "Saving, then resolving " + name + " (up to a minute)…" : "Clearing the name…"));
btn.disabled = input.disabled = true; btn.value = "Checking…";
api("PATCH", "ipcr/config", { name: name })
.then(function () { return loadStatus(true); })
.then(function () {
toast(name ? (pubName ? name + " checks out: pull lines use it" : "Saved: " + name + " does not point here yet") : "Name cleared");
loadImages();
}, function (err) { showName(null); toast(err.message, 8000); })
.then(function () { btn.disabled = input.disabled = false; btn.value = "Save and check"; });
};
// ---- images
// The pull commands for one tag: by CID, by IPNS name, by the configured name when it checks out.
// The root organisation's repositories have a short path too: it comes first.
function pullCommands(reg, d, img, tg) {
var short = img.aliases && img.aliases.length ? img.aliases[0] : "";
var path = (short || img.repo) + ":" + tg.tag, rows = [["IPFS", reg + "/ipfs/" + tg.cid]];
if (d.publisher) rows.push(["IPNS", reg + "/ipns/" + d.publisher + "/" + path]);
if (d.name) rows.push([/\.eth$/.test(d.name) ? "ENS" : "DNS", reg + "/ipns/" + d.name + "/" + path]);
if (short && (d.name || d.publisher)) rows.push(["Also", reg + "/ipns/" + (d.name || d.publisher) + "/" + img.repo + ":" + tg.tag]);
var box = el("div");
rows.forEach(function (r) {
var cmd = "docker pull " + r[1], row = el("div", null, "cmd");
row.appendChild(el("span", r[0], "k")); row.appendChild(el("code", cmd)); row.appendChild(copyButton(cmd));
box.appendChild(row);
});
return box;
}
function loadImages() {
return api("GET", "ipcr/images").then(function (d) {
var box = $("images"); box.textContent = ""; box.className = "";
var reg = d.registry || "ipcr.localhost:4767", n = 0;
if (d.stagingError) box.appendChild(el("p", "Staging registry: " + d.stagingError, "warn"));
if (!d.images || !d.images.length) { box.appendChild(el("p", "Nothing published yet.", "muted")); return; }
if (!d.images || !d.images.length) { box.appendChild(el("p", "Nothing published yet.", "muted")); $("images-count").textContent = "0"; return; }
d.images.forEach(function (img) {
var h = el("h3", img.repo + " ");
n += img.tags.length;
var card = el("div", null, "card"); card.style.marginBottom = "12px";
var h = el("h3", (img.aliases && img.aliases.length ? img.aliases[0] + " · " : "") + img.repo + " ");
h.appendChild(img.allowed ? badge("publishing", "ok") : badge("not allowed: new tags are not published", "warn"));
box.appendChild(h);
var wrap = el("div", null, "scroll"), t = el("table"), head = el("tr");
["Tag", "Image", "", ""].forEach(function (x) { head.appendChild(el("th", x)); });
t.appendChild(head);
card.appendChild(h);
img.tags.forEach(function (tg) {
var tr = el("tr");
var td1 = el("td"); td1.appendChild(el("code", tg.tag));
if (tg.at) td1.appendChild(el("div", tg.at.replace("T", " ").replace("Z", ""), "muted"));
var td2 = el("td"); td2.appendChild(el("code", "/ipfs/" + tg.cid));
var td3 = el("td"), prov = el("span", "", "muted"); td3.appendChild(prov);
var td4 = el("td"), acts = el("div", null, "actions");
var t = el("div", null, "tag"), head = el("div", null, "taghead"), left = el("div");
left.appendChild(el("code", tg.tag));
if (tg.at) left.appendChild(el("span", " " + tg.at.replace("T", " ").replace("Z", " UTC"), "muted"));
var prov = el("span", "", "muted"); prov.style.marginLeft = "10px"; left.appendChild(prov);
var acts = el("div", null, "actions");
var chk = el("button", "Findable?");
chk.onclick = function () {
prov.textContent = ""; prov.appendChild(spinner("asking the network…"));
busy(chk, api("GET", "ipcr/providers?cid=" + encodeURIComponent(tg.cid))).then(function (p) {
prov.textContent = "";
prov.appendChild(p.self ? badge("✓ this node is listed", "ok") : badge("✗ not listed (" + p.providers + " other providers)", "warn"));
prov.appendChild(p.self ? badge("✓ findable: this node is listed", "ok") : badge("✗ not listed yet (" + p.providers + " other providers)", "warn"));
if (!p.self) {
var ann = el("button", "Announce");
ann.onclick = function () { busy(ann, api("POST", "ipcr/announce", { cid: tg.cid })).then(function () { toast("Queued: Kubo announces it within a minute or two; check again then.", 6000); }); };
prov.appendChild(document.createTextNode(" ")); prov.appendChild(ann);
}
});
}, function () { prov.textContent = ""; });
};
acts.appendChild(chk);
if (tg.tag !== "latest") {
@@ -318,48 +446,83 @@
});
};
acts.appendChild(un);
td4.appendChild(acts);
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
t.appendChild(tr);
head.appendChild(left); head.appendChild(acts);
t.appendChild(head);
t.appendChild(pullCommands(reg, d, img, tg));
card.appendChild(t);
});
(img.staging || []).filter(function (s) { return s.state !== "published"; }).forEach(function (s) {
var tr = el("tr");
var td1 = el("td"); td1.appendChild(el("code", s.tag));
var td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "in staging, not imported yet" : "in staging, not allowed"), "muted");
var td3 = el("td"), td4 = el("td");
if (img.allowed) {
var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now");
re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued"); setTimeout(loadImages, 5000); }); };
td4.appendChild(re);
}
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
t.appendChild(tr);
});
wrap.appendChild(t); box.appendChild(wrap);
var pending = (img.staging || []).filter(function (s) { return s.state !== "published"; });
if (pending.length) {
card.appendChild(el("h3", "In staging, not published"));
var tbl = el("table");
pending.forEach(function (s) {
var tr = el("tr"), td1 = el("td"), td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "not imported yet" : "repository not allowed"), "muted"), td3 = el("td");
td1.appendChild(el("code", s.tag));
if (img.allowed) {
var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now");
re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued: imported on the next pass"); setTimeout(loadImages, 5000); }); };
td3.appendChild(re);
}
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tbl.appendChild(tr);
});
card.appendChild(tbl);
}
box.appendChild(card);
});
$("images-count").textContent = String(n);
}).catch(function (e) { $("images").textContent = "Images: " + e.message; });
}
// ---- repositories
function loadSettings() {
return api("GET", "settings").then(function (st) {
if (document.activeElement !== $("org")) $("org").value = st.rootOrg || "";
$("org-state").textContent = st.rootOrg ? "Publishing " + st.rootOrg + "'s repositories at the root." : "No root organisation.";
}).catch(function (e) { $("org-state").textContent = e.message; });
}
$("org-form").onsubmit = function (e) {
e.preventDefault();
var btn = this.querySelector("input[type=submit]"), st = $("org-state");
st.textContent = ""; st.appendChild(spinner("Saving, then updating credentials and IPCR's list…"));
btn.disabled = true;
api("PUT", "settings", { rootOrg: $("org").value.trim() })
.then(function (r) { toast(r.rootOrg ? r.rootOrg + " is the root organisation; short paths appear on IPCR's next pass" : "No root organisation"); return Promise.all([loadSettings(), loadRepos()]); },
function (err) { toast(err.message, 8000); return loadSettings(); })
.then(function () { btn.disabled = false; setTimeout(loadImages, 70000); });
};
function loadRepos() {
return api("GET", "repos").then(function (repos) {
var box = $("repos"); box.textContent = ""; box.className = "";
if (!repos.length) { box.appendChild(el("p", "No public repository yet.", "muted")); return; }
var t = el("table"), head = el("tr");
["Repository", "Publish images"].forEach(function (x) { head.appendChild(el("th", x)); });
["Repository", "Published as", "Push credential", "Publish"].forEach(function (x) { head.appendChild(el("th", x)); });
t.appendChild(head);
repos.forEach(function (r) {
var tr = el("tr"), td1 = el("td"), td2 = el("td"), cb = document.createElement("input");
var tr = el("tr"), td1 = el("td"), td2 = el("td"), td3 = el("td"), td4 = el("td"), cb = document.createElement("input");
td1.appendChild(el("code", r.full_name));
(r.published_as || []).forEach(function (p, i) { var d = el("div"); d.appendChild(el("code", p + ":<tag>", i ? "muted" : "")); td2.appendChild(d); });
if (!r.publish) td2.appendChild(el("span", "—", "muted"));
if (r.collision) td2.appendChild(el("div", "no short path: " + r.collision, "warn"));
if (r.publish) {
td3.appendChild(r.credential ? badge("✓ IPCR_PUSH_TOKEN set", "ok") : badge("✗ missing", "bad"));
var rot = el("button", "Reissue"); rot.style.marginLeft = "8px";
rot.onclick = function () {
if (!confirm("Give " + r.full_name + " a new IPCR_PUSH_TOKEN? The old one stops working at once; running builds may fail.")) return;
busy(rot, api("POST", "repos/rotate", { full_name: r.full_name })).then(function () { toast("New token set on " + r.full_name); loadRepos(); });
};
td3.appendChild(rot);
} else td3.appendChild(el("span", "—", "muted"));
cb.type = "checkbox"; cb.checked = r.publish;
cb.onchange = function () {
cb.disabled = true;
api("PUT", "repos", { full_name: r.full_name, publish: cb.checked })
.then(function () { cb.disabled = false; toast(r.full_name + (cb.checked ? ": publishing" : ": not publishing")); loadImages(); },
function (e) { cb.disabled = false; cb.checked = !cb.checked; toast(e.message, 8000); });
.then(function () { toast(r.full_name + (cb.checked ? ": publishing" : ": not publishing")); return loadRepos(); },
function (e) { cb.checked = !cb.checked; toast(e.message, 8000); })
.then(function () { cb.disabled = false; loadImages(); });
};
td2.appendChild(cb);
tr.appendChild(td1); tr.appendChild(td2); t.appendChild(tr);
td4.appendChild(cb);
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
t.appendChild(tr);
});
var wrap = el("div", null, "scroll"); wrap.appendChild(t); box.appendChild(wrap);
}).catch(function (e) { $("repos").textContent = "Repositories: " + e.message; });
@@ -404,7 +567,7 @@
}).catch(function (e) { toast(e.message, 8000); });
};
loadStatus(false); loadImages(); loadRepos();
loadStatus(false); loadImages(); loadRepos(); loadSettings();
})();
</script>
</body>
+18 -9
View File
@@ -131,9 +131,10 @@ git push -u origin main</code></pre>
Applications</b>).</li>
<li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the
<code>Dockerfile</code> at the root for every <code>v*</code> tag. It is a GitHub-style
workflow with two differences: the image goes to <code>localhost:5000</code>, this forge's
staging registry (no login, no secret), and buildx's builder runs with <code>network=host</code>
so it can reach that registry.
workflow with three differences: it logs in to <code>localhost:5000</code>, this forge's staging
registry, as the repository itself (the forge sets the <code>IPCR_PUSH_TOKEN</code> secret on
every public repository allowed to publish), the image goes there under the repository's own
name, and buildx's builder runs with <code>network=host</code> so it can reach that registry.
<pre><code>name: image
on:
push:
@@ -143,6 +144,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: localhost:5000
username: &#36;{{ github.repository }}
password: &#36;{{ secrets.IPCR_PUSH_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
@@ -163,12 +169,13 @@ jobs:
<pre><code>git add .gitea && git commit -m "Build the image"
git tag v1.0.0
git push origin main v1.0.0</code></pre>
The image is published as <code>&lt;owner&gt;/&lt;repo&gt;:1.0.0</code> under this forge's name.</li>
The image is published as <code>&lt;owner&gt;/&lt;repo&gt;:1.0.0</code> under this forge's name.
Repositories of the forge's own organisation are also published at the root:
<code>&lt;repo&gt;:1.0.0</code>.</li>
</ol>
<p class="note">Who can publish: anyone who can push to a repository on this forge, since a build
can push any image name to the staging registry. Images are public once on IPFS, even when they come from
a private repository. Gitea holds a pull request from a fork until someone with write access approves
its workflow run: read the workflow before you approve.</p>
<p class="note">Who can publish: each public repository publishes under its own name only, with its
own push credential; the short root names belong to the forge's organisation, so to its members.
Workflows from forks get no secrets, so they cannot publish. Private repositories never publish.</p>
<h2 id="use">Use an image</h2>
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
@@ -316,7 +323,9 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
var info = r.tags[tag];
// The configured domain or ENS name, while IPCR's check says it points here.
var path = pub.name && pub.publisher && r.ipns.indexOf(pub.publisher) === 0 ? pub.name + r.ipns.slice(pub.publisher.length) : r.ipns;
// The short path (root organisation) when there is one, under the name when it checks out.
var rest = r.aliases && r.aliases.length ? "/" + r.aliases[0] : r.ipns.slice(pub.publisher ? pub.publisher.length : 0);
var path = (pub.name || pub.publisher || r.ipns.split("/")[0]) + rest;
var line = "image: " + reg + "/ipns/" + path + ":" + tag;
var tr = el("tr");
var td1 = el("td"); td1.appendChild(el("code", tag));