// Package admin serves graft's self-service dashboard actions: a one-time // secret-sharing page (so a token never has to travel through chat or // email in the clear), and two onboarding forms ("add a peer to an // existing federation" and "start a new repo from scratch") gated by a // single shared admin password rather than a config.yaml edit and a // restart. // // None of this requires a session or a cookie: every state-changing // action re-submits the password on its own form, checked with a // constant-time comparison and backed by a rate limiter — the password is // short and shared, so the limiter (not the comparison's timing safety // alone) is the thing actually standing between an attacker and a brute // force of it. package admin import ( "crypto/subtle" "html/template" "net/http" "strings" "sync" "time" "graft/internal/state" ) // SeriesInfo is one federation an "add a peer" submission can join, // carrying the Radicle side every existing pair in it already shares — // so a newly-added Forgejo peer never has to (and never gets to) pick its // own RID or seed. type SeriesInfo struct { Name string RadicleRID string RadicleHTTPBaseURL string RadicleExplorerURL string } // Config wires the admin package to the rest of graft without importing // cmd/sync or internal/sync — it only ever reads/writes through Store and // the callbacks below. type Config struct { Store *state.Store // AdminPassword gates every state-changing action here: claiming a // share needs its own 6-digit passcode instead, but creating a share, // and submitting or approving an add-peer/new-repo form, all check // this. Never logged, never echoed back on error. AdminPassword string // PublicHost is used to build the share link shown after creation // (https:///share/). Required for the share feature; // the onboarding forms work without it. PublicHost string // TokenDir is the directory a submitted token gets written into as // its own chmod-600 file, following the same convention as every // config.yaml-declared pair (e.g. /etc/graft). TokenDir string // DefaultRadHome is graft's own local Radicle identity — the same // one every pair, static or dynamic, pushes as. Never user-supplied. DefaultRadHome string // Series returns every federation an "add a peer" submission can // join, freshly computed per request (cheap: it's just config.Repos // plus whatever dynamic_repo rows exist already). Series func() []SeriesInfo } // Handler serves every admin-facing route. Two independent rate limiters: // a wrong admin password and a wrong share passcode are different secrets // with different scopes, so a lockout on one never blocks the other. type Handler struct { cfg Config passwordLimiter *rateLimiter shareLimiter *rateLimiter } func NewHandler(cfg Config) *Handler { return &Handler{cfg: cfg, passwordLimiter: newRateLimiter(), shareLimiter: newRateLimiter()} } func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/share/new", h.shareNew) mux.HandleFunc("/share/", h.shareClaim) // also serves /share//qr.png mux.HandleFunc("/add-peer", h.addPeer) mux.HandleFunc("/add-radicle-peer", h.addRadiclePeer) mux.HandleFunc("/new-repo", h.newRepo) } // checkPassword compares in constant time, so a wrong guess never takes // measurably longer or shorter based on which character differs. func checkPassword(cfg Config, submitted string) bool { want := []byte(cfg.AdminPassword) got := []byte(submitted) if len(want) != len(got) { return false } return subtle.ConstantTimeCompare(want, got) == 1 } // clientIP prefers the first hop of X-Forwarded-For (set by graft's own // nginx reverse proxy in front of it) and falls back to the raw remote // address — imperfect if a future proxy change stops setting that header // faithfully, which is exactly why the rate limiter also enforces a // global cap independent of IP attribution (see rateLimiter). func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if i := strings.Index(xff, ","); i >= 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } if i := strings.LastIndex(r.RemoteAddr, ":"); i >= 0 { return r.RemoteAddr[:i] } return r.RemoteAddr } const ( rateLimitWindow = 15 * time.Minute maxFailuresPerIP = 5 maxFailuresGlobal = 20 ) // rateLimiter is a simple in-memory sliding-window counter over password // failures — not persisted (a restart resets it), which is an acceptable // trade for a password meant to be short-lived and low-stakes rather than // a real credential; see TUTORIAL.md's "Future: built-in token exchange" // for the actually-strong alternative (delegated token minting) this // password model is a deliberately lightweight stand-in for. type rateLimiter struct { mu sync.Mutex byIP map[string][]time.Time global []time.Time } func newRateLimiter() *rateLimiter { return &rateLimiter{byIP: map[string][]time.Time{}} } func prune(times []time.Time, now time.Time) []time.Time { out := times[:0] for _, t := range times { if now.Sub(t) < rateLimitWindow { out = append(out, t) } } return out } // allowed reports whether ip may attempt a password check right now. func (rl *rateLimiter) allowed(ip string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() rl.global = prune(rl.global, now) if len(rl.global) >= maxFailuresGlobal { return false } rl.byIP[ip] = prune(rl.byIP[ip], now) return len(rl.byIP[ip]) < maxFailuresPerIP } // recordFailure counts one wrong password attempt against both the // per-IP and global windows. func (rl *rateLimiter) recordFailure(ip string) { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() rl.byIP[ip] = append(rl.byIP[ip], now) rl.global = append(rl.global, now) } // pageTmpl is the shared chrome every admin page renders inside — // visually related to the main dashboard (same palette) but with a // distinct amber accent so a self-service action page is never // mistaken for the read-only status page. var pageTmpl = template.Must(template.New("admin").Parse(` graft — {{.Title}}
{{if .Eyebrow}}{{.Eyebrow}}{{end}} {{.Body}}
`)) type pageData struct { Title string Eyebrow string Body template.HTML } // render wraps body (already-escaped HTML built from a sub-template) in // the shared page chrome. body is template.HTML deliberately — every // caller builds it via html/template itself, so this isn't an escaping // bypass, just composition of two already-safe fragments. func render(w http.ResponseWriter, title, eyebrow string, body template.HTML) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = pageTmpl.Execute(w, pageData{Title: title, Eyebrow: eyebrow, Body: body}) } func renderFragment(tmpl *template.Template, data any) template.HTML { var b strings.Builder if err := tmpl.Execute(&b, data); err != nil { return template.HTML("

" + template.HTMLEscapeString(err.Error()) + "

") } return template.HTML(b.String()) }