Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

graft-source / internal / admin / admin.go
12 kB 284 lines
1// Package admin serves graft's self-service dashboard actions: a one-time 2// secret-sharing page (so a token never has to travel through chat or 3// email in the clear), and two onboarding forms ("add a peer to an 4// existing federation" and "start a new repo from scratch") gated by a 5// single shared admin password rather than a config.yaml edit and a 6// restart. 7// 8// None of this requires a session or a cookie: every state-changing 9// action re-submits the password on its own form, checked with a 10// constant-time comparison and backed by a rate limiter — the password is 11// short and shared, so the limiter (not the comparison's timing safety 12// alone) is the thing actually standing between an attacker and a brute 13// force of it. 14package admin 15 16import ( 17 "crypto/subtle" 18 "html/template" 19 "net/http" 20 "strings" 21 "sync" 22 "time" 23 24 "graft/internal/state" 25) 26 27// SeriesInfo is one federation an "add a peer" submission can join, 28// carrying the Radicle side every existing pair in it already shares — 29// so a newly-added Forgejo peer never has to (and never gets to) pick its 30// own RID or seed. 31type SeriesInfo struct { 32 Name string 33 RadicleRID string 34 RadicleHTTPBaseURL string 35 RadicleExplorerURL string 36} 37 38// Config wires the admin package to the rest of graft without importing 39// cmd/sync or internal/sync — it only ever reads/writes through Store and 40// the callbacks below. 41type Config struct { 42 Store *state.Store 43 44 // AdminPassword gates every state-changing action here: claiming a 45 // share needs its own 6-digit passcode instead, but creating a share, 46 // and submitting or approving an add-peer/new-repo form, all check 47 // this. Never logged, never echoed back on error. 48 AdminPassword string 49 50 // PublicHost is used to build the share link shown after creation 51 // (https://<PublicHost>/share/<id>). Required for the share feature; 52 // the onboarding forms work without it. 53 PublicHost string 54 55 // TokenDir is the directory a submitted token gets written into as 56 // its own chmod-600 file, following the same convention as every 57 // config.yaml-declared pair (e.g. /etc/graft). 58 TokenDir string 59 60 // DefaultRadHome is graft's own local Radicle identity — the same 61 // one every pair, static or dynamic, pushes as. Never user-supplied. 62 DefaultRadHome string 63 64 // Series returns every federation an "add a peer" submission can 65 // join, freshly computed per request (cheap: it's just config.Repos 66 // plus whatever dynamic_repo rows exist already). 67 Series func() []SeriesInfo 68} 69 70// Handler serves every admin-facing route. Three independent rate 71// limiters: a wrong admin password (used only on the /admin/pending 72// review page now), a wrong share passcode, and a plain volume cap on 73// onboarding submissions (which need no password at all — anyone can 74// propose a peer, only an admin approval activates it) are different 75// concerns, so a lockout on one never blocks the others. 76type Handler struct { 77 cfg Config 78 passwordLimiter *rateLimiter 79 shareLimiter *rateLimiter 80 submitLimiter *rateLimiter 81} 82 83func NewHandler(cfg Config) *Handler { 84 return &Handler{cfg: cfg, passwordLimiter: newRateLimiter(), shareLimiter: newRateLimiter(), submitLimiter: newRateLimiter()} 85} 86 87func (h *Handler) Register(mux *http.ServeMux) { 88 mux.HandleFunc("/share/new", h.shareNew) 89 mux.HandleFunc("/share/", h.shareClaim) // also serves /share/<id>/qr.png 90 mux.HandleFunc("/add-peer", h.addPeer) 91 mux.HandleFunc("/add-radicle-peer", h.addRadiclePeer) 92 mux.HandleFunc("/new-repo", h.newRepo) 93 mux.HandleFunc("/admin/pending", h.pending) 94 mux.HandleFunc("/admin/pending/approve", h.approvePending) 95 mux.HandleFunc("/admin/pending/reject", h.rejectPending) 96} 97 98// checkPassword compares in constant time, so a wrong guess never takes 99// measurably longer or shorter based on which character differs. 100func checkPassword(cfg Config, submitted string) bool { 101 want := []byte(cfg.AdminPassword) 102 got := []byte(submitted) 103 if len(want) != len(got) { 104 return false 105 } 106 return subtle.ConstantTimeCompare(want, got) == 1 107} 108 109// clientIP prefers the first hop of X-Forwarded-For (set by graft's own 110// nginx reverse proxy in front of it) and falls back to the raw remote 111// address — imperfect if a future proxy change stops setting that header 112// faithfully, which is exactly why the rate limiter also enforces a 113// global cap independent of IP attribution (see rateLimiter). 114func clientIP(r *http.Request) string { 115 if xff := r.Header.Get("X-Forwarded-For"); xff != "" { 116 if i := strings.Index(xff, ","); i >= 0 { 117 return strings.TrimSpace(xff[:i]) 118 } 119 return strings.TrimSpace(xff) 120 } 121 if i := strings.LastIndex(r.RemoteAddr, ":"); i >= 0 { 122 return r.RemoteAddr[:i] 123 } 124 return r.RemoteAddr 125} 126 127const ( 128 rateLimitWindow = 15 * time.Minute 129 maxFailuresPerIP = 5 130 maxFailuresGlobal = 20 131) 132 133// rateLimiter is a simple in-memory sliding-window counter over password 134// failures — not persisted (a restart resets it), which is an acceptable 135// trade for a password meant to be short-lived and low-stakes rather than 136// a real credential; see TUTORIAL.md's "Future: built-in token exchange" 137// for the actually-strong alternative (delegated token minting) this 138// password model is a deliberately lightweight stand-in for. 139type rateLimiter struct { 140 mu sync.Mutex 141 byIP map[string][]time.Time 142 global []time.Time 143} 144 145func newRateLimiter() *rateLimiter { 146 return &rateLimiter{byIP: map[string][]time.Time{}} 147} 148 149func prune(times []time.Time, now time.Time) []time.Time { 150 out := times[:0] 151 for _, t := range times { 152 if now.Sub(t) < rateLimitWindow { 153 out = append(out, t) 154 } 155 } 156 return out 157} 158 159// allowed reports whether ip may attempt a password check right now. 160func (rl *rateLimiter) allowed(ip string) bool { 161 rl.mu.Lock() 162 defer rl.mu.Unlock() 163 now := time.Now() 164 rl.global = prune(rl.global, now) 165 if len(rl.global) >= maxFailuresGlobal { 166 return false 167 } 168 rl.byIP[ip] = prune(rl.byIP[ip], now) 169 return len(rl.byIP[ip]) < maxFailuresPerIP 170} 171 172// recordFailure counts one wrong password attempt against both the 173// per-IP and global windows. 174func (rl *rateLimiter) recordFailure(ip string) { 175 rl.mu.Lock() 176 defer rl.mu.Unlock() 177 now := time.Now() 178 rl.byIP[ip] = append(rl.byIP[ip], now) 179 rl.global = append(rl.global, now) 180} 181 182// pageTmpl is the shared chrome every admin page renders inside — 183// visually related to the main dashboard (same palette) but with a 184// distinct amber accent so a self-service action page is never 185// mistaken for the read-only status page. 186var pageTmpl = template.Must(template.New("admin").Parse(`<!doctype html> 187<html lang="en"> 188<head> 189<meta charset="utf-8"> 190<meta name="viewport" content="width=device-width, initial-scale=1"> 191<title>graft — {{.Title}}</title> 192<style> 193 :root { 194 color-scheme: light; 195 --surface: #ffffff; --surface-sunk: #f4f5f7; --text: #172b4d; --text-muted: #6b778c; 196 --border: #dfe1e6; --brand: #0052cc; --accent: #ae6b00; --accent-soft: #fff4e0; 197 --bad: #de350b; --good: #36b37e; 198 } 199 * { box-sizing: border-box; } 200 body { margin: 0; background: var(--surface-sunk); color: var(--text); 201 font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } 202 main { max-width: 640px; margin: 0 auto; padding: 2.5rem 1.5rem 3rem; } 203 .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; } 204 h1 { font-size: 1.15rem; margin: 0; font-weight: 600; } 205 .eyebrow { display: inline-block; font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; 206 color: var(--accent); background: var(--accent-soft); padding: .15rem .5rem; border-radius: 3px; margin-bottom: .5rem; } 207 .nav-link { color: var(--brand); font-size: .82rem; text-decoration: none; font-weight: 500; } 208 .nav-link:hover { text-decoration: underline; } 209 .card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 1.5rem; margin-bottom: 1.25rem; } 210 p.lead { color: var(--text-muted); font-size: .88rem; margin: 0 0 1.25rem; } 211 label { display: block; font-size: .78rem; font-weight: 600; color: var(--text-muted); margin: 1rem 0 .3rem; } 212 label:first-of-type { margin-top: 0; } 213 input[type=text], input[type=password], input[type=url], select, textarea { 214 width: 100%; padding: .55rem .65rem; border: 1px solid var(--border); border-radius: 4px; 215 font: inherit; background: var(--surface-sunk); color: var(--text); 216 } 217 textarea { resize: vertical; min-height: 4.5rem; font-family: ui-monospace, monospace; font-size: .82rem; } 218 .hint { font-size: .74rem; color: var(--text-muted); margin-top: .3rem; } 219 .row { display: flex; flex-wrap: wrap; gap: 1rem; } 220 .row > div { flex: 1 1 12rem; min-width: 0; } 221 .checks { display: flex; gap: 1.25rem; margin-top: .5rem; } 222 .checks label { display: flex; align-items: center; gap: .4rem; font-weight: 500; margin: 0; } 223 .checks input { width: auto; } 224 button { 225 margin-top: 1.5rem; background: var(--brand); color: #fff; border: none; border-radius: 4px; 226 padding: .65rem 1.1rem; font: inherit; font-weight: 600; cursor: pointer; 227 } 228 button:hover { background: #0747a6; } 229 .steps { counter-reset: step; list-style: none; padding: 0; margin: 0 0 1.5rem; } 230 .steps li { counter-increment: step; position: relative; padding: 0 0 1rem 2.2rem; font-size: .86rem; } 231 .steps li::before { 232 content: counter(step); position: absolute; left: 0; top: 0; width: 1.5rem; height: 1.5rem; 233 border-radius: 50%; background: var(--accent-soft); color: var(--accent); font-weight: 700; 234 font-size: .76rem; display: flex; align-items: center; justify-content: center; 235 } 236 .steps code { background: var(--surface-sunk); padding: .1rem .35rem; border-radius: 3px; font-size: .82em; } 237 .notice { border-radius: 6px; padding: .9rem 1.1rem; font-size: .86rem; margin-bottom: 1.25rem; } 238 .notice.good { background: #e3fcef; color: #006644; border: 1px solid #abf5d1; } 239 .notice.bad { background: #ffebe6; color: #bf2600; border: 1px solid #ffbdad; } 240 .secret-box { 241 font-family: ui-monospace, monospace; font-size: .95rem; background: var(--surface-sunk); 242 border: 1px dashed var(--border); border-radius: 4px; padding: .9rem; word-break: break-all; margin: .5rem 0; 243 } 244 .passcode { font-size: 1.6rem; font-weight: 700; letter-spacing: .1em; text-align: center; 245 background: var(--accent-soft); color: var(--accent); border-radius: 6px; padding: .7rem; margin: .5rem 0; } 246 footer { max-width: 640px; margin: 0 auto; padding: 1rem 1.5rem 2.5rem; color: var(--text-muted); font-size: .78rem; } 247</style> 248</head> 249<body> 250<main> 251 <div class="top"> 252 <h1>graft</h1> 253 <a class="nav-link" href="/">&larr; Dashboard</a> 254 </div> 255 {{if .Eyebrow}}<span class="eyebrow">{{.Eyebrow}}</span>{{end}} 256 {{.Body}} 257</main> 258</body> 259</html> 260`)) 261 262type pageData struct { 263 Title string 264 Eyebrow string 265 Body template.HTML 266} 267 268// render wraps body (already-escaped HTML built from a sub-template) in 269// the shared page chrome. body is template.HTML deliberately — every 270// caller builds it via html/template itself, so this isn't an escaping 271// bypass, just composition of two already-safe fragments. 272func render(w http.ResponseWriter, title, eyebrow string, body template.HTML) { 273 w.Header().Set("Content-Type", "text/html; charset=utf-8") 274 w.Header().Set("Cache-Control", "no-store") 275 _ = pageTmpl.Execute(w, pageData{Title: title, Eyebrow: eyebrow, Body: body}) 276} 277 278func renderFragment(tmpl *template.Template, data any) template.HTML { 279 var b strings.Builder 280 if err := tmpl.Execute(&b, data); err != nil { 281 return template.HTML("<p class=\"notice bad\">" + template.HTMLEscapeString(err.Error()) + "</p>") 282 } 283 return template.HTML(b.String()) 284}