Federated via graft
11 kB
277 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. Two independent rate limiters:
71// a wrong admin password and a wrong share passcode are different secrets
72// with different scopes, so a lockout on one never blocks the other.
73type Handler struct {
74 cfg Config
75 passwordLimiter *rateLimiter
76 shareLimiter *rateLimiter
77}
78
79func NewHandler(cfg Config) *Handler {
80 return &Handler{cfg: cfg, passwordLimiter: newRateLimiter(), shareLimiter: newRateLimiter()}
81}
82
83func (h *Handler) Register(mux *http.ServeMux) {
84 mux.HandleFunc("/share/new", h.shareNew)
85 mux.HandleFunc("/share/", h.shareClaim) // also serves /share/<id>/qr.png
86 mux.HandleFunc("/add-peer", h.addPeer)
87 mux.HandleFunc("/add-radicle-peer", h.addRadiclePeer)
88 mux.HandleFunc("/new-repo", h.newRepo)
89}
90
91// checkPassword compares in constant time, so a wrong guess never takes
92// measurably longer or shorter based on which character differs.
93func checkPassword(cfg Config, submitted string) bool {
94 want := []byte(cfg.AdminPassword)
95 got := []byte(submitted)
96 if len(want) != len(got) {
97 return false
98 }
99 return subtle.ConstantTimeCompare(want, got) == 1
100}
101
102// clientIP prefers the first hop of X-Forwarded-For (set by graft's own
103// nginx reverse proxy in front of it) and falls back to the raw remote
104// address — imperfect if a future proxy change stops setting that header
105// faithfully, which is exactly why the rate limiter also enforces a
106// global cap independent of IP attribution (see rateLimiter).
107func clientIP(r *http.Request) string {
108 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
109 if i := strings.Index(xff, ","); i >= 0 {
110 return strings.TrimSpace(xff[:i])
111 }
112 return strings.TrimSpace(xff)
113 }
114 if i := strings.LastIndex(r.RemoteAddr, ":"); i >= 0 {
115 return r.RemoteAddr[:i]
116 }
117 return r.RemoteAddr
118}
119
120const (
121 rateLimitWindow = 15 * time.Minute
122 maxFailuresPerIP = 5
123 maxFailuresGlobal = 20
124)
125
126// rateLimiter is a simple in-memory sliding-window counter over password
127// failures — not persisted (a restart resets it), which is an acceptable
128// trade for a password meant to be short-lived and low-stakes rather than
129// a real credential; see TUTORIAL.md's "Future: built-in token exchange"
130// for the actually-strong alternative (delegated token minting) this
131// password model is a deliberately lightweight stand-in for.
132type rateLimiter struct {
133 mu sync.Mutex
134 byIP map[string][]time.Time
135 global []time.Time
136}
137
138func newRateLimiter() *rateLimiter {
139 return &rateLimiter{byIP: map[string][]time.Time{}}
140}
141
142func prune(times []time.Time, now time.Time) []time.Time {
143 out := times[:0]
144 for _, t := range times {
145 if now.Sub(t) < rateLimitWindow {
146 out = append(out, t)
147 }
148 }
149 return out
150}
151
152// allowed reports whether ip may attempt a password check right now.
153func (rl *rateLimiter) allowed(ip string) bool {
154 rl.mu.Lock()
155 defer rl.mu.Unlock()
156 now := time.Now()
157 rl.global = prune(rl.global, now)
158 if len(rl.global) >= maxFailuresGlobal {
159 return false
160 }
161 rl.byIP[ip] = prune(rl.byIP[ip], now)
162 return len(rl.byIP[ip]) < maxFailuresPerIP
163}
164
165// recordFailure counts one wrong password attempt against both the
166// per-IP and global windows.
167func (rl *rateLimiter) recordFailure(ip string) {
168 rl.mu.Lock()
169 defer rl.mu.Unlock()
170 now := time.Now()
171 rl.byIP[ip] = append(rl.byIP[ip], now)
172 rl.global = append(rl.global, now)
173}
174
175// pageTmpl is the shared chrome every admin page renders inside —
176// visually related to the main dashboard (same palette) but with a
177// distinct amber accent so a self-service action page is never
178// mistaken for the read-only status page.
179var pageTmpl = template.Must(template.New("admin").Parse(`<!doctype html>
180<html lang="en">
181<head>
182<meta charset="utf-8">
183<meta name="viewport" content="width=device-width, initial-scale=1">
184<title>graft — {{.Title}}</title>
185<style>
186 :root {
187 color-scheme: light;
188 --surface: #ffffff; --surface-sunk: #f4f5f7; --text: #172b4d; --text-muted: #6b778c;
189 --border: #dfe1e6; --brand: #0052cc; --accent: #ae6b00; --accent-soft: #fff4e0;
190 --bad: #de350b; --good: #36b37e;
191 }
192 * { box-sizing: border-box; }
193 body { margin: 0; background: var(--surface-sunk); color: var(--text);
194 font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
195 main { max-width: 640px; margin: 0 auto; padding: 2.5rem 1.5rem 3rem; }
196 .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; }
197 h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
198 .eyebrow { display: inline-block; font-size: .7rem; text-transform: uppercase; letter-spacing: .06em;
199 color: var(--accent); background: var(--accent-soft); padding: .15rem .5rem; border-radius: 3px; margin-bottom: .5rem; }
200 .nav-link { color: var(--brand); font-size: .82rem; text-decoration: none; font-weight: 500; }
201 .nav-link:hover { text-decoration: underline; }
202 .card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 1.5rem; margin-bottom: 1.25rem; }
203 p.lead { color: var(--text-muted); font-size: .88rem; margin: 0 0 1.25rem; }
204 label { display: block; font-size: .78rem; font-weight: 600; color: var(--text-muted); margin: 1rem 0 .3rem; }
205 label:first-of-type { margin-top: 0; }
206 input[type=text], input[type=password], input[type=url], select, textarea {
207 width: 100%; padding: .55rem .65rem; border: 1px solid var(--border); border-radius: 4px;
208 font: inherit; background: var(--surface-sunk); color: var(--text);
209 }
210 textarea { resize: vertical; min-height: 4.5rem; font-family: ui-monospace, monospace; font-size: .82rem; }
211 .hint { font-size: .74rem; color: var(--text-muted); margin-top: .3rem; }
212 .row { display: flex; gap: 1rem; }
213 .row > div { flex: 1; }
214 .checks { display: flex; gap: 1.25rem; margin-top: .5rem; }
215 .checks label { display: flex; align-items: center; gap: .4rem; font-weight: 500; margin: 0; }
216 .checks input { width: auto; }
217 button {
218 margin-top: 1.5rem; background: var(--brand); color: #fff; border: none; border-radius: 4px;
219 padding: .65rem 1.1rem; font: inherit; font-weight: 600; cursor: pointer;
220 }
221 button:hover { background: #0747a6; }
222 .steps { counter-reset: step; list-style: none; padding: 0; margin: 0 0 1.5rem; }
223 .steps li { counter-increment: step; position: relative; padding: 0 0 1rem 2.2rem; font-size: .86rem; }
224 .steps li::before {
225 content: counter(step); position: absolute; left: 0; top: 0; width: 1.5rem; height: 1.5rem;
226 border-radius: 50%; background: var(--accent-soft); color: var(--accent); font-weight: 700;
227 font-size: .76rem; display: flex; align-items: center; justify-content: center;
228 }
229 .steps code { background: var(--surface-sunk); padding: .1rem .35rem; border-radius: 3px; font-size: .82em; }
230 .notice { border-radius: 6px; padding: .9rem 1.1rem; font-size: .86rem; margin-bottom: 1.25rem; }
231 .notice.good { background: #e3fcef; color: #006644; border: 1px solid #abf5d1; }
232 .notice.bad { background: #ffebe6; color: #bf2600; border: 1px solid #ffbdad; }
233 .secret-box {
234 font-family: ui-monospace, monospace; font-size: .95rem; background: var(--surface-sunk);
235 border: 1px dashed var(--border); border-radius: 4px; padding: .9rem; word-break: break-all; margin: .5rem 0;
236 }
237 .passcode { font-size: 1.6rem; font-weight: 700; letter-spacing: .1em; text-align: center;
238 background: var(--accent-soft); color: var(--accent); border-radius: 6px; padding: .7rem; margin: .5rem 0; }
239 footer { max-width: 640px; margin: 0 auto; padding: 1rem 1.5rem 2.5rem; color: var(--text-muted); font-size: .78rem; }
240</style>
241</head>
242<body>
243<main>
244 <div class="top">
245 <h1>graft</h1>
246 <a class="nav-link" href="/">← Dashboard</a>
247 </div>
248 {{if .Eyebrow}}<span class="eyebrow">{{.Eyebrow}}</span>{{end}}
249 {{.Body}}
250</main>
251</body>
252</html>
253`))
254
255type pageData struct {
256 Title string
257 Eyebrow string
258 Body template.HTML
259}
260
261// render wraps body (already-escaped HTML built from a sub-template) in
262// the shared page chrome. body is template.HTML deliberately — every
263// caller builds it via html/template itself, so this isn't an escaping
264// bypass, just composition of two already-safe fragments.
265func render(w http.ResponseWriter, title, eyebrow string, body template.HTML) {
266 w.Header().Set("Content-Type", "text/html; charset=utf-8")
267 w.Header().Set("Cache-Control", "no-store")
268 _ = pageTmpl.Execute(w, pageData{Title: title, Eyebrow: eyebrow, Body: body})
269}
270
271func renderFragment(tmpl *template.Template, data any) template.HTML {
272 var b strings.Builder
273 if err := tmpl.Execute(&b, data); err != nil {
274 return template.HTML("<p class=\"notice bad\">" + template.HTMLEscapeString(err.Error()) + "</p>")
275 }
276 return template.HTML(b.String())
277}