Federated via graft
0

Configure Feed

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

Self-service federation onboarding + one-time secret sharing

New internal/admin package, wired into the dashboard:

- POST /share/new + GET/POST /share/<id>: a one-time secret share.
AES-256-GCM key derived from the share's own high-entropy id plus a
6-digit passcode, neither of which is ever stored together — only the
id's hash goes in the database, so a full DB dump alone can never
derive the decryption key. Destroyed on first successful claim, after
5 wrong passcodes, or after 15 minutes, whichever comes first. QR code
(pure-Go, github.com/skip2/go-qrcode) encodes the claim link only —
the passcode is still meant to travel over a separate channel.

- GET/POST /add-peer: join an existing federation. Picks up the
series' Radicle side (RID, seed, explorer) automatically — nothing
Radicle-related is ever user-supplied for this flow, since Radicle
already replicates to whatever's seeding that RID.

- GET/POST /new-repo: start a repo graft has never seen, with the
manual prerequisite steps (create the Forgejo repo + token, rad init,
note the RID) spelled out inline before the form.

Both onboarding forms are gated by a single shared admin password
(config.yaml's admin_password, defaulting to "graft" with a startup log
warning when left at that default) checked in constant time, backed by
a rate limiter — 5 failures per IP or 20 globally per 15-minute window,
independent of X-Forwarded-For accuracy. A submitted token is written to
its own chmod-600 file under the same directory static pairs already
use, following the existing token_file convention exactly; only the
path is ever persisted to the database.

Submissions land in a new dynamic_repo table and go live on the very
next sync pass — no restart. cmd/sync/main.go now tracks all runtime
registration (syncers, topology, AI-detection state) behind a mutex,
since it's written from the ticker goroutine and read from HTTP handler
goroutines; each pass checks for newly-approved dynamic repos, builds a
RepoSyncer for each, and rebuilds the dashboard's topology and
Authorized-Integration detection to include them.

CSP's form-action tightened from 'none' to 'self' — the new pages are
plain HTML forms posting back to graft itself; 'none' would have
silently blocked every one of them.

Verified: 9 functional tests (share round-trip, one-time destruction,
max-attempts destruction, wrong-password rejection, rate limiting
blocking even a correct password once tripped, successful add-peer/
new-repo with real DB row + token file content/permissions, QR
generation, clean GET rendering) plus a live production round trip
(share create/claim/QR, wrong-password rejection leaving no trace) and
a Playwright pass confirming zero CSP violations on all three new pages.

+1426 -58
+259 -57
cmd/sync/main.go
··· 10 10 "net/http" 11 11 "net/url" 12 12 "os" 13 + "sort" 13 14 "strings" 15 + "sync" 14 16 "time" 15 17 16 18 "graft/internal/activitypub" 19 + "graft/internal/admin" 17 20 "graft/internal/config" 18 21 "graft/internal/state" 19 22 "graft/internal/status" 20 - "graft/internal/sync" 23 + gsync "graft/internal/sync" 21 24 ) 22 25 23 26 func main() { ··· 33 36 log.Error("load config", "err", err) 34 37 os.Exit(1) 35 38 } 39 + if cfg.AdminPassword == "graft" { 40 + log.Warn("admin_password is left at its default value — anyone who knows it can add a peer or start a repo; set admin_password in config.yaml to change it") 41 + } 36 42 37 43 st, err := state.Open(cfg.StateDB) 38 44 if err != nil { ··· 45 51 if idx := lastSlash(stateDir); idx >= 0 { 46 52 stateDir = stateDir[:idx] 47 53 } 54 + tokenDir := stateDir 55 + if len(cfg.Repos) > 0 { 56 + if idx := lastSlash(cfg.Repos[0].Forgejo.TokenFile); idx >= 0 { 57 + tokenDir = cfg.Repos[0].Forgejo.TokenFile[:idx] 58 + } 59 + } 60 + defaultRadHome := "" 61 + if len(cfg.Repos) > 0 { 62 + defaultRadHome = cfg.Repos[0].Radicle.RadHome 63 + } 48 64 49 - syncers := make([]*sync.RepoSyncer, 0, len(cfg.Repos)) 50 - syncersByPair := map[string]*sync.RepoSyncer{} 51 - syncersBySeries := map[string]*sync.RepoSyncer{} 52 - pairsBySeries := map[string][]*sync.RepoSyncer{} 65 + // live holds every piece of runtime state a dynamically-added peer or 66 + // repo needs to join without a restart — mutated only from the 67 + // runAll/ticker goroutine, but read concurrently by HTTP handler 68 + // goroutines (ActivityPub callbacks, the admin package's Series 69 + // lookup), so every access goes through mu. 70 + live := &liveState{ 71 + pairs: append([]config.RepoPair(nil), cfg.Repos...), 72 + syncersByPair: map[string]*gsync.RepoSyncer{}, 73 + syncersBySeries: map[string]*gsync.RepoSyncer{}, 74 + pairsBySeries: map[string][]*gsync.RepoSyncer{}, 75 + } 53 76 for _, pair := range cfg.Repos { 54 - rs, err := sync.New(pair, st, sync.WorkDirFor(stateDir, pair.Name)) 77 + rs, err := gsync.New(pair, st, gsync.WorkDirFor(stateDir, pair.Name)) 55 78 if err != nil { 56 79 log.Error("set up repo pair", "pair", pair.Name, "err", err) 57 80 os.Exit(1) 58 81 } 59 - syncers = append(syncers, rs) 60 - syncersByPair[pair.Name] = rs 61 - if _, ok := syncersBySeries[rs.Series()]; !ok { 62 - syncersBySeries[rs.Series()] = rs 63 - } 64 - pairsBySeries[rs.Series()] = append(pairsBySeries[rs.Series()], rs) 82 + live.addSyncer(rs) 65 83 } 66 - detectAuthorizedIntegrations(pairsBySeries, log) 67 - 68 - // aiTargetHosts marks which series+Forgejo-host combinations receive 69 - // pushes directly via Authorized Integrations rather than via graft 70 - // itself — read by buildTopology so the dashboard can tell that side's 71 - // silence apart from a side nothing has ever reached. 72 - aiTargetHosts := map[string]map[string]bool{} 73 - for series, pairs := range pairsBySeries { 74 - for _, rs := range pairs { 75 - if rs.HasAuthorizedIntegrationSource() { 76 - if aiTargetHosts[series] == nil { 77 - aiTargetHosts[series] = map[string]bool{} 78 - } 79 - aiTargetHosts[series][rs.ForgejoHost()] = true 80 - } 81 - } 82 - } 83 - 84 - topology := buildTopology(cfg, aiTargetHosts) 85 - blueskyConfigured := map[string]bool{} 86 - for _, pair := range cfg.Repos { 87 - if pair.Bluesky != nil { 88 - series := pair.Series 89 - if series == "" { 90 - series = pair.Name 91 - } 92 - blueskyConfigured[series] = true 93 - } 94 - } 84 + detectAuthorizedIntegrations(live.pairsBySeriesSnapshot(), log) 85 + live.rebuildTopology(cfg) 95 86 96 87 tracker := status.NewTracker(st, cfg.SourceURL) 97 - tracker.SetTopology(topology) 88 + tracker.SetTopology(live.topologySnapshot()) 98 89 tracker.SetPublicHost(cfg.PublicHost) 99 - tracker.SetBlueskyConfigured(blueskyConfigured) 90 + tracker.SetBlueskyConfigured(blueskyConfigured(cfg.Repos)) 100 91 101 92 var apHandler *activitypub.Handler 102 93 if cfg.PublicHost != "" { 103 94 apHandler = activitypub.NewHandler(st, cfg.PublicHost, log, 104 95 func(series string) string { 105 - if refs := topology[series]; len(refs) > 0 { 96 + if refs := live.topologySnapshot()[series]; len(refs) > 0 { 106 97 return refs[0].URL 107 98 } 108 99 return "" 109 100 }, 110 101 func(series string) bool { 111 - _, ok := topology[series] 102 + _, ok := live.topologySnapshot()[series] 112 103 return ok 113 104 }, 114 105 func(repoPair, kind string, forgejoID int64, radicleID, body string) error { 115 - rs, ok := syncersByPair[repoPair] 106 + rs, ok := live.syncerForPair(repoPair) 116 107 if !ok { 117 108 return fmt.Errorf("unknown repo pair %q", repoPair) 118 109 } 119 110 return rs.CommentOnItem(kind, forgejoID, radicleID, body) 120 111 }, 121 112 func(series string) string { 122 - rs, ok := syncersBySeries[series] 113 + rs, ok := live.syncerForSeries(series) 123 114 if !ok { 124 115 return "" 125 116 } ··· 133 124 ) 134 125 } 135 126 127 + adminHandler := admin.NewHandler(admin.Config{ 128 + Store: st, 129 + AdminPassword: cfg.AdminPassword, 130 + PublicHost: cfg.PublicHost, 131 + TokenDir: tokenDir, 132 + DefaultRadHome: defaultRadHome, 133 + Series: live.seriesInfos, 134 + }) 135 + 136 136 runAll := func() { 137 - for _, rs := range syncers { 137 + if materializeDynamicRepos(st, live, stateDir, log) { 138 + detectAuthorizedIntegrations(live.pairsBySeriesSnapshot(), log) 139 + live.rebuildTopology(cfg) 140 + tracker.SetTopology(live.topologySnapshot()) 141 + tracker.SetBlueskyConfigured(blueskyConfigured(live.pairsSnapshot())) 142 + } 143 + for _, rs := range live.syncersSnapshot() { 138 144 gitErr, issuesErr, patchErr := rs.Run(log) 139 145 tracker.Record(rs.Name(), rs.Series(), gitErr, issuesErr, patchErr) 140 146 } ··· 148 154 if apHandler != nil { 149 155 apHandler.Register(mux) 150 156 } 157 + adminHandler.Register(mux) 151 158 mux.Handle("/", tracker.Handler()) 152 159 153 160 go func() { ··· 169 176 } 170 177 } 171 178 179 + // liveState is every piece of runtime registration that can grow after 180 + // startup (a peer or repo added through the dashboard), guarded by one 181 + // mutex since it's written from the ticker goroutine and read from HTTP 182 + // handler goroutines. 183 + type liveState struct { 184 + mu sync.Mutex 185 + pairs []config.RepoPair 186 + syncers []*gsync.RepoSyncer 187 + syncersByPair map[string]*gsync.RepoSyncer 188 + syncersBySeries map[string]*gsync.RepoSyncer 189 + pairsBySeries map[string][]*gsync.RepoSyncer 190 + topology map[string][]status.ServerRef 191 + } 192 + 193 + func (l *liveState) addSyncer(rs *gsync.RepoSyncer) { 194 + l.mu.Lock() 195 + defer l.mu.Unlock() 196 + l.syncers = append(l.syncers, rs) 197 + l.syncersByPair[rs.Name()] = rs 198 + if _, ok := l.syncersBySeries[rs.Series()]; !ok { 199 + l.syncersBySeries[rs.Series()] = rs 200 + } 201 + l.pairsBySeries[rs.Series()] = append(l.pairsBySeries[rs.Series()], rs) 202 + } 203 + 204 + func (l *liveState) addPair(p config.RepoPair) { 205 + l.mu.Lock() 206 + defer l.mu.Unlock() 207 + l.pairs = append(l.pairs, p) 208 + } 209 + 210 + func (l *liveState) syncersSnapshot() []*gsync.RepoSyncer { 211 + l.mu.Lock() 212 + defer l.mu.Unlock() 213 + return append([]*gsync.RepoSyncer(nil), l.syncers...) 214 + } 215 + 216 + func (l *liveState) pairsSnapshot() []config.RepoPair { 217 + l.mu.Lock() 218 + defer l.mu.Unlock() 219 + return append([]config.RepoPair(nil), l.pairs...) 220 + } 221 + 222 + func (l *liveState) pairsBySeriesSnapshot() map[string][]*gsync.RepoSyncer { 223 + l.mu.Lock() 224 + defer l.mu.Unlock() 225 + out := make(map[string][]*gsync.RepoSyncer, len(l.pairsBySeries)) 226 + for k, v := range l.pairsBySeries { 227 + out[k] = append([]*gsync.RepoSyncer(nil), v...) 228 + } 229 + return out 230 + } 231 + 232 + func (l *liveState) syncerForPair(name string) (*gsync.RepoSyncer, bool) { 233 + l.mu.Lock() 234 + defer l.mu.Unlock() 235 + rs, ok := l.syncersByPair[name] 236 + return rs, ok 237 + } 238 + 239 + func (l *liveState) syncerForSeries(series string) (*gsync.RepoSyncer, bool) { 240 + l.mu.Lock() 241 + defer l.mu.Unlock() 242 + rs, ok := l.syncersBySeries[series] 243 + return rs, ok 244 + } 245 + 246 + func (l *liveState) topologySnapshot() map[string][]status.ServerRef { 247 + l.mu.Lock() 248 + defer l.mu.Unlock() 249 + return l.topology 250 + } 251 + 252 + // rebuildTopology recomputes the dashboard's topology map from the 253 + // current set of pairs (static config plus anything added dynamically) 254 + // and the AI-detection state each RepoSyncer already carries. 255 + func (l *liveState) rebuildTopology(cfg *config.Config) { 256 + l.mu.Lock() 257 + pairs := append([]config.RepoPair(nil), l.pairs...) 258 + pairsBySeries := make(map[string][]*gsync.RepoSyncer, len(l.pairsBySeries)) 259 + for k, v := range l.pairsBySeries { 260 + pairsBySeries[k] = v 261 + } 262 + l.mu.Unlock() 263 + 264 + aiTargetHosts := map[string]map[string]bool{} 265 + for series, rss := range pairsBySeries { 266 + for _, rs := range rss { 267 + if rs.HasAuthorizedIntegrationSource() { 268 + if aiTargetHosts[series] == nil { 269 + aiTargetHosts[series] = map[string]bool{} 270 + } 271 + aiTargetHosts[series][rs.ForgejoHost()] = true 272 + } 273 + } 274 + } 275 + topo := buildTopology(pairs, aiTargetHosts) 276 + 277 + l.mu.Lock() 278 + l.topology = topo 279 + l.mu.Unlock() 280 + } 281 + 282 + // seriesInfos is the admin package's view of every federation a new peer 283 + // can join — one entry per series, carrying whichever pair's Radicle side 284 + // every sibling in that series already shares. 285 + func (l *liveState) seriesInfos() []admin.SeriesInfo { 286 + pairs := l.pairsSnapshot() 287 + seen := map[string]admin.SeriesInfo{} 288 + var order []string 289 + for _, p := range pairs { 290 + series := p.Series 291 + if series == "" { 292 + series = p.Name 293 + } 294 + if _, ok := seen[series]; !ok { 295 + seen[series] = admin.SeriesInfo{ 296 + Name: series, 297 + RadicleRID: p.Radicle.RID, 298 + RadicleHTTPBaseURL: p.Radicle.HTTPBaseURL, 299 + RadicleExplorerURL: p.Radicle.ExplorerURL, 300 + } 301 + order = append(order, series) 302 + } 303 + } 304 + sort.Strings(order) 305 + out := make([]admin.SeriesInfo, 0, len(order)) 306 + for _, s := range order { 307 + out = append(out, seen[s]) 308 + } 309 + return out 310 + } 311 + 312 + // materializeDynamicRepos turns every dynamic_repo row nobody has 313 + // activated yet into a live RepoSyncer, joining the sync loop on this 314 + // very pass — no restart, matching the "shows up next pass" model the 315 + // rest of graft already uses. Returns whether anything changed, so the 316 + // caller only pays for a topology rebuild when it's actually needed. 317 + func materializeDynamicRepos(st *state.Store, live *liveState, stateDir string, log *slog.Logger) bool { 318 + rows, err := st.UnmaterializedDynamicRepos() 319 + if err != nil { 320 + log.Error("list dynamic repos", "err", err) 321 + return false 322 + } 323 + changed := false 324 + for _, d := range rows { 325 + pair := config.RepoPair{ 326 + Name: d.Name, 327 + Series: d.Series, 328 + Forgejo: config.ForgejoTarget{ 329 + BaseURL: d.ForgejoBaseURL, Owner: d.ForgejoOwner, Repo: d.ForgejoRepo, TokenFile: d.ForgejoTokenFile, 330 + }, 331 + Radicle: config.RadicleTarget{ 332 + RID: d.RadicleRID, HTTPBaseURL: d.RadicleHTTPBaseURL, RadHome: d.RadicleRadHome, ExplorerURL: d.RadicleExplorerURL, 333 + }, 334 + Sync: config.SyncScope{Git: d.SyncGit, Issues: d.SyncIssues, Patches: d.SyncPatches}, 335 + } 336 + rs, err := gsync.New(pair, st, gsync.WorkDirFor(stateDir, pair.Name)) 337 + if err != nil { 338 + // Left unmaterialized — retried next pass rather than 339 + // dropped, in case the failure is transient (e.g. the 340 + // Forgejo repo wasn't reachable yet). 341 + log.Error("materialize dynamic repo", "name", d.Name, "err", err) 342 + continue 343 + } 344 + live.addSyncer(rs) 345 + live.addPair(pair) 346 + if err := st.MarkDynamicRepoMaterialized(d.ID); err != nil { 347 + log.Error("mark dynamic repo materialized", "name", d.Name, "err", err) 348 + } 349 + log.Info("materialized dynamic repo", "name", d.Name, "series", d.Series) 350 + changed = true 351 + } 352 + return changed 353 + } 354 + 355 + func blueskyConfigured(repos []config.RepoPair) map[string]bool { 356 + out := map[string]bool{} 357 + for _, pair := range repos { 358 + if pair.Bluesky != nil { 359 + series := pair.Series 360 + if series == "" { 361 + series = pair.Name 362 + } 363 + out[series] = true 364 + } 365 + } 366 + return out 367 + } 368 + 172 369 // buildTopology declares every mirror target configured for each series 173 370 // (its Forgejo side and its Radicle side), so the dashboard can show a 174 371 // side that's never produced an event yet — not just sides the activity 175 372 // log happens to mention. 176 - func buildTopology(cfg *config.Config, aiTargetHosts map[string]map[string]bool) map[string][]status.ServerRef { 373 + func buildTopology(pairs []config.RepoPair, aiTargetHosts map[string]map[string]bool) map[string][]status.ServerRef { 177 374 topology := map[string][]status.ServerRef{} 178 375 seen := map[string]bool{} 179 376 add := func(series, host, repoURL string, radicle, ai bool) { ··· 193 390 }) 194 391 } 195 392 196 - for _, pair := range cfg.Repos { 393 + for _, pair := range pairs { 197 394 series := pair.Series 198 395 if series == "" { 199 396 series = pair.Name ··· 210 407 if u, err := url.Parse(pair.Radicle.HTTPBaseURL); err == nil { 211 408 rHost = u.Host 212 409 } 213 - add(series, rHost, sync.RadicleExplorerLink(pair.Radicle), true, false) 410 + add(series, rHost, gsync.RadicleExplorerLink(pair.Radicle), true, false) 214 411 } 215 412 return topology 216 413 } ··· 220 417 // workflow pushing directly into the other via Authorized Integrations 221 418 // (see internal/sync's authint.go) — and if so, tells the receiving 222 419 // pair's GitSyncer to stop relaying that content via Radicle itself, 223 - // since the direct path is faster. Run once at startup: workflow files 224 - // don't change often enough to justify re-checking every pass. 225 - func detectAuthorizedIntegrations(pairsBySeries map[string][]*sync.RepoSyncer, log *slog.Logger) { 420 + // since the direct path is faster. Run at startup and again whenever a 421 + // new peer joins dynamically — workflow files don't change often enough 422 + // to justify checking on every ordinary sync pass. 423 + func detectAuthorizedIntegrations(pairsBySeries map[string][]*gsync.RepoSyncer, log *slog.Logger) { 226 424 for series, pairs := range pairsBySeries { 227 425 if len(pairs) < 2 { 228 426 continue ··· 236 434 if destHost == "" { 237 435 continue 238 436 } 239 - if sync.HasAuthorizedIntegration(source.ForgejoClient(), destHost) { 437 + if gsync.HasAuthorizedIntegration(source.ForgejoClient(), destHost) { 240 438 log.Info("authorized integration detected", 241 439 "series", series, "source", source.ForgejoHost(), "target", destHost) 242 440 dest.SetAuthorizedIntegrationSource(source.ForgejoHost()) ··· 250 448 // headers on every request. Deliberately just static assignments — no 251 449 // per-request logic, nothing that can panic or branch incorrectly — since 252 450 // this wraps every route graft serves (dashboard, /social, ActivityPub, 253 - // /inbox) and a bug here would affect all of them at once. 451 + // /inbox, the admin onboarding forms) and a bug here would affect all of 452 + // them at once. 254 453 // 255 454 // Cross-Origin-Embedder-Policy is deliberately left out: it exists to 256 455 // protect cross-origin-isolated contexts (SharedArrayBuffer, WASM threads) ··· 275 474 // rendering, since style-src 'unsafe-inline' covers the one inline 276 475 // <style> block each page has — nothing else is ever injected into 277 476 // it, so there's no practical downside to allowing it specifically. 278 - h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") 477 + // form-action 'self': the admin onboarding/share pages are plain 478 + // HTML forms posting back to graft itself — 'none' would silently 479 + // block every one of them. 480 + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") 279 481 next.ServeHTTP(w, r) 280 482 }) 281 483 }
+1
go.mod
··· 13 13 github.com/mattn/go-isatty v0.0.20 // indirect 14 14 github.com/ncruces/go-strftime v0.1.9 // indirect 15 15 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 16 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect 16 17 golang.org/x/sys v0.22.0 // indirect 17 18 modernc.org/libc v1.55.3 // indirect 18 19 modernc.org/mathutil v1.6.0 // indirect
+2
go.sum
··· 10 10 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 11 11 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 12 12 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 13 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 14 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 13 15 golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= 14 16 golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 15 17 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+276
internal/admin/admin.go
··· 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. 14 + package admin 15 + 16 + import ( 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. 31 + type 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. 41 + type 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. 73 + type Handler struct { 74 + cfg Config 75 + passwordLimiter *rateLimiter 76 + shareLimiter *rateLimiter 77 + } 78 + 79 + func NewHandler(cfg Config) *Handler { 80 + return &Handler{cfg: cfg, passwordLimiter: newRateLimiter(), shareLimiter: newRateLimiter()} 81 + } 82 + 83 + func (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("/new-repo", h.newRepo) 88 + } 89 + 90 + // checkPassword compares in constant time, so a wrong guess never takes 91 + // measurably longer or shorter based on which character differs. 92 + func checkPassword(cfg Config, submitted string) bool { 93 + want := []byte(cfg.AdminPassword) 94 + got := []byte(submitted) 95 + if len(want) != len(got) { 96 + return false 97 + } 98 + return subtle.ConstantTimeCompare(want, got) == 1 99 + } 100 + 101 + // clientIP prefers the first hop of X-Forwarded-For (set by graft's own 102 + // nginx reverse proxy in front of it) and falls back to the raw remote 103 + // address — imperfect if a future proxy change stops setting that header 104 + // faithfully, which is exactly why the rate limiter also enforces a 105 + // global cap independent of IP attribution (see rateLimiter). 106 + func clientIP(r *http.Request) string { 107 + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { 108 + if i := strings.Index(xff, ","); i >= 0 { 109 + return strings.TrimSpace(xff[:i]) 110 + } 111 + return strings.TrimSpace(xff) 112 + } 113 + if i := strings.LastIndex(r.RemoteAddr, ":"); i >= 0 { 114 + return r.RemoteAddr[:i] 115 + } 116 + return r.RemoteAddr 117 + } 118 + 119 + const ( 120 + rateLimitWindow = 15 * time.Minute 121 + maxFailuresPerIP = 5 122 + maxFailuresGlobal = 20 123 + ) 124 + 125 + // rateLimiter is a simple in-memory sliding-window counter over password 126 + // failures — not persisted (a restart resets it), which is an acceptable 127 + // trade for a password meant to be short-lived and low-stakes rather than 128 + // a real credential; see TUTORIAL.md's "Future: built-in token exchange" 129 + // for the actually-strong alternative (delegated token minting) this 130 + // password model is a deliberately lightweight stand-in for. 131 + type rateLimiter struct { 132 + mu sync.Mutex 133 + byIP map[string][]time.Time 134 + global []time.Time 135 + } 136 + 137 + func newRateLimiter() *rateLimiter { 138 + return &rateLimiter{byIP: map[string][]time.Time{}} 139 + } 140 + 141 + func prune(times []time.Time, now time.Time) []time.Time { 142 + out := times[:0] 143 + for _, t := range times { 144 + if now.Sub(t) < rateLimitWindow { 145 + out = append(out, t) 146 + } 147 + } 148 + return out 149 + } 150 + 151 + // allowed reports whether ip may attempt a password check right now. 152 + func (rl *rateLimiter) allowed(ip string) bool { 153 + rl.mu.Lock() 154 + defer rl.mu.Unlock() 155 + now := time.Now() 156 + rl.global = prune(rl.global, now) 157 + if len(rl.global) >= maxFailuresGlobal { 158 + return false 159 + } 160 + rl.byIP[ip] = prune(rl.byIP[ip], now) 161 + return len(rl.byIP[ip]) < maxFailuresPerIP 162 + } 163 + 164 + // recordFailure counts one wrong password attempt against both the 165 + // per-IP and global windows. 166 + func (rl *rateLimiter) recordFailure(ip string) { 167 + rl.mu.Lock() 168 + defer rl.mu.Unlock() 169 + now := time.Now() 170 + rl.byIP[ip] = append(rl.byIP[ip], now) 171 + rl.global = append(rl.global, now) 172 + } 173 + 174 + // pageTmpl is the shared chrome every admin page renders inside — 175 + // visually related to the main dashboard (same palette) but with a 176 + // distinct amber accent so a self-service action page is never 177 + // mistaken for the read-only status page. 178 + var pageTmpl = template.Must(template.New("admin").Parse(`<!doctype html> 179 + <html lang="en"> 180 + <head> 181 + <meta charset="utf-8"> 182 + <meta name="viewport" content="width=device-width, initial-scale=1"> 183 + <title>graft — {{.Title}}</title> 184 + <style> 185 + :root { 186 + color-scheme: light; 187 + --surface: #ffffff; --surface-sunk: #f4f5f7; --text: #172b4d; --text-muted: #6b778c; 188 + --border: #dfe1e6; --brand: #0052cc; --accent: #ae6b00; --accent-soft: #fff4e0; 189 + --bad: #de350b; --good: #36b37e; 190 + } 191 + * { box-sizing: border-box; } 192 + body { margin: 0; background: var(--surface-sunk); color: var(--text); 193 + font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } 194 + main { max-width: 640px; margin: 0 auto; padding: 2.5rem 1.5rem 3rem; } 195 + .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; } 196 + h1 { font-size: 1.15rem; margin: 0; font-weight: 600; } 197 + .eyebrow { display: inline-block; font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; 198 + color: var(--accent); background: var(--accent-soft); padding: .15rem .5rem; border-radius: 3px; margin-bottom: .5rem; } 199 + .nav-link { color: var(--brand); font-size: .82rem; text-decoration: none; font-weight: 500; } 200 + .nav-link:hover { text-decoration: underline; } 201 + .card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 1.5rem; margin-bottom: 1.25rem; } 202 + p.lead { color: var(--text-muted); font-size: .88rem; margin: 0 0 1.25rem; } 203 + label { display: block; font-size: .78rem; font-weight: 600; color: var(--text-muted); margin: 1rem 0 .3rem; } 204 + label:first-of-type { margin-top: 0; } 205 + input[type=text], input[type=password], input[type=url], select, textarea { 206 + width: 100%; padding: .55rem .65rem; border: 1px solid var(--border); border-radius: 4px; 207 + font: inherit; background: var(--surface-sunk); color: var(--text); 208 + } 209 + textarea { resize: vertical; min-height: 4.5rem; font-family: ui-monospace, monospace; font-size: .82rem; } 210 + .hint { font-size: .74rem; color: var(--text-muted); margin-top: .3rem; } 211 + .row { display: flex; gap: 1rem; } 212 + .row > div { flex: 1; } 213 + .checks { display: flex; gap: 1.25rem; margin-top: .5rem; } 214 + .checks label { display: flex; align-items: center; gap: .4rem; font-weight: 500; margin: 0; } 215 + .checks input { width: auto; } 216 + button { 217 + margin-top: 1.5rem; background: var(--brand); color: #fff; border: none; border-radius: 4px; 218 + padding: .65rem 1.1rem; font: inherit; font-weight: 600; cursor: pointer; 219 + } 220 + button:hover { background: #0747a6; } 221 + .steps { counter-reset: step; list-style: none; padding: 0; margin: 0 0 1.5rem; } 222 + .steps li { counter-increment: step; position: relative; padding: 0 0 1rem 2.2rem; font-size: .86rem; } 223 + .steps li::before { 224 + content: counter(step); position: absolute; left: 0; top: 0; width: 1.5rem; height: 1.5rem; 225 + border-radius: 50%; background: var(--accent-soft); color: var(--accent); font-weight: 700; 226 + font-size: .76rem; display: flex; align-items: center; justify-content: center; 227 + } 228 + .steps code { background: var(--surface-sunk); padding: .1rem .35rem; border-radius: 3px; font-size: .82em; } 229 + .notice { border-radius: 6px; padding: .9rem 1.1rem; font-size: .86rem; margin-bottom: 1.25rem; } 230 + .notice.good { background: #e3fcef; color: #006644; border: 1px solid #abf5d1; } 231 + .notice.bad { background: #ffebe6; color: #bf2600; border: 1px solid #ffbdad; } 232 + .secret-box { 233 + font-family: ui-monospace, monospace; font-size: .95rem; background: var(--surface-sunk); 234 + border: 1px dashed var(--border); border-radius: 4px; padding: .9rem; word-break: break-all; margin: .5rem 0; 235 + } 236 + .passcode { font-size: 1.6rem; font-weight: 700; letter-spacing: .1em; text-align: center; 237 + background: var(--accent-soft); color: var(--accent); border-radius: 6px; padding: .7rem; margin: .5rem 0; } 238 + footer { max-width: 640px; margin: 0 auto; padding: 1rem 1.5rem 2.5rem; color: var(--text-muted); font-size: .78rem; } 239 + </style> 240 + </head> 241 + <body> 242 + <main> 243 + <div class="top"> 244 + <h1>graft</h1> 245 + <a class="nav-link" href="/">&larr; Dashboard</a> 246 + </div> 247 + {{if .Eyebrow}}<span class="eyebrow">{{.Eyebrow}}</span>{{end}} 248 + {{.Body}} 249 + </main> 250 + </body> 251 + </html> 252 + `)) 253 + 254 + type pageData struct { 255 + Title string 256 + Eyebrow string 257 + Body template.HTML 258 + } 259 + 260 + // render wraps body (already-escaped HTML built from a sub-template) in 261 + // the shared page chrome. body is template.HTML deliberately — every 262 + // caller builds it via html/template itself, so this isn't an escaping 263 + // bypass, just composition of two already-safe fragments. 264 + func render(w http.ResponseWriter, title, eyebrow string, body template.HTML) { 265 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 266 + w.Header().Set("Cache-Control", "no-store") 267 + _ = pageTmpl.Execute(w, pageData{Title: title, Eyebrow: eyebrow, Body: body}) 268 + } 269 + 270 + func renderFragment(tmpl *template.Template, data any) template.HTML { 271 + var b strings.Builder 272 + if err := tmpl.Execute(&b, data); err != nil { 273 + return template.HTML("<p class=\"notice bad\">" + template.HTMLEscapeString(err.Error()) + "</p>") 274 + } 275 + return template.HTML(b.String()) 276 + }
+337
internal/admin/onboard.go
··· 1 + package admin 2 + 3 + import ( 4 + "fmt" 5 + "html/template" 6 + "net/http" 7 + "net/url" 8 + "os" 9 + "path/filepath" 10 + "regexp" 11 + "strings" 12 + 13 + "graft/internal/state" 14 + ) 15 + 16 + // validName matches what's safe to use both as a token filename and as a 17 + // dynamic_repo primary key — deliberately narrow rather than trying to 18 + // escape whatever isn't. 19 + var validName = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,80}$`) 20 + 21 + var addPeerTmpl = template.Must(template.New("add-peer").Parse(` 22 + <h1>Add a peer to an existing federation</h1> 23 + <p class="lead">A new Forgejo instance joining a repo that's already mirrored elsewhere. It reuses the federation's existing Radicle side automatically — Radicle already replicates to it — so nothing about Radicle needs to be entered here.</p> 24 + {{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 25 + <div class="card"> 26 + <form method="POST" action="/add-peer"> 27 + <label for="series">Federation (series)</label> 28 + <select id="series" name="series" required> 29 + <option value="">— choose —</option> 30 + {{range .Series}}<option value="{{.}}" {{if eq . $.SelectedSeries}}selected{{end}}>{{.}}</option>{{end}} 31 + </select> 32 + <div class="hint">The RID, Radicle node and explorer this peer joins are inherited from the series you pick.</div> 33 + 34 + <label for="name">Pair name</label> 35 + <input type="text" id="name" name="name" required maxlength="80" pattern="[a-zA-Z0-9._-]+" value="{{.Name}}" placeholder="e.g. federation-x-gitadmin"> 36 + 37 + <div class="row"> 38 + <div> 39 + <label for="base_url">Forgejo base URL</label> 40 + <input type="url" id="base_url" name="base_url" required value="{{.BaseURL}}" placeholder="https://git.example.org"> 41 + </div> 42 + <div> 43 + <label for="owner">Owner</label> 44 + <input type="text" id="owner" name="owner" required value="{{.Owner}}"> 45 + </div> 46 + </div> 47 + <label for="repo">Repository</label> 48 + <input type="text" id="repo" name="repo" required value="{{.Repo}}"> 49 + 50 + <label for="token">Forgejo token</label> 51 + <textarea id="token" name="token" required placeholder="scoped to write:repository + write:issue"></textarea> 52 + <div class="hint">Written to its own chmod-600 file on save — never kept in this form or logged. Got it from someone else safely? See <a href="/share/new">one-time share</a>.</div> 53 + 54 + <label>Sync scope</label> 55 + <div class="checks"> 56 + <label><input type="checkbox" name="sync_git" checked> git</label> 57 + <label><input type="checkbox" name="sync_issues" checked> issues</label> 58 + <label><input type="checkbox" name="sync_patches" checked> patches</label> 59 + </div> 60 + 61 + <label for="password">Admin password</label> 62 + <input type="password" id="password" name="password" required autocomplete="off"> 63 + <div class="hint">Confirms this addition is authorized. Active within one sync interval once accepted.</div> 64 + 65 + <button type="submit">Add peer</button> 66 + </form> 67 + </div> 68 + `)) 69 + 70 + var newRepoTmpl = template.Must(template.New("new-repo").Parse(` 71 + <h1>Start a new repo</h1> 72 + <p class="lead">Mirroring a repo graft has never seen before, on both a Forgejo instance and a Radicle node.</p> 73 + <ol class="steps"> 74 + <li>On your Forgejo instance, create the repo (give it one initial commit) and generate a token scoped to <code>write:repository</code> + <code>write:issue</code>.</li> 75 + <li>Clone it locally and run <code>rad init --name &lt;repo&gt; --default-branch &lt;branch&gt; --public</code> — note the RID it prints (<code>rad:z...</code>).</li> 76 + <li>If other Radicle nodes should also carry it, tell them to <code>rad seed &lt;rid&gt;</code>.</li> 77 + <li>Fill in what you just created below.</li> 78 + </ol> 79 + {{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 80 + <div class="card"> 81 + <form method="POST" action="/new-repo"> 82 + <label for="name">Repo name</label> 83 + <input type="text" id="name" name="name" required maxlength="80" pattern="[a-zA-Z0-9._-]+" value="{{.Name}}"> 84 + 85 + <div class="row"> 86 + <div> 87 + <label for="base_url">Forgejo base URL</label> 88 + <input type="url" id="base_url" name="base_url" required value="{{.BaseURL}}" placeholder="https://git.example.org"> 89 + </div> 90 + <div> 91 + <label for="owner">Owner</label> 92 + <input type="text" id="owner" name="owner" required value="{{.Owner}}"> 93 + </div> 94 + </div> 95 + <label for="repo">Repository</label> 96 + <input type="text" id="repo" name="repo" required value="{{.Repo}}"> 97 + <label for="token">Forgejo token</label> 98 + <textarea id="token" name="token" required placeholder="scoped to write:repository + write:issue"></textarea> 99 + <div class="hint">Got it from someone else safely? See <a href="/share/new">one-time share</a>.</div> 100 + 101 + <label for="rid">Radicle RID</label> 102 + <input type="text" id="rid" name="rid" required value="{{.RID}}" placeholder="rad:z..."> 103 + <div class="row"> 104 + <div> 105 + <label for="rad_http">Radicle HTTP base URL</label> 106 + <input type="url" id="rad_http" name="rad_http" required value="{{.RadHTTP}}" placeholder="https://seed.example.org"> 107 + </div> 108 + <div> 109 + <label for="explorer">Explorer URL (optional)</label> 110 + <input type="url" id="explorer" name="explorer" value="{{.Explorer}}" placeholder="https://app.radicle.xyz"> 111 + </div> 112 + </div> 113 + 114 + <label>Sync scope</label> 115 + <div class="checks"> 116 + <label><input type="checkbox" name="sync_git" checked> git</label> 117 + <label><input type="checkbox" name="sync_issues" checked> issues</label> 118 + <label><input type="checkbox" name="sync_patches" checked> patches</label> 119 + </div> 120 + 121 + <label for="password">Admin password</label> 122 + <input type="password" id="password" name="password" required autocomplete="off"> 123 + 124 + <button type="submit">Start mirroring</button> 125 + </form> 126 + </div> 127 + `)) 128 + 129 + var onboardOKTmpl = template.Must(template.New("onboard-ok").Parse(` 130 + <h1>{{.Title}}</h1> 131 + <div class="notice good">{{.Name}} is saved and will start mirroring within the next sync interval — no restart needed.</div> 132 + <p><a class="nav-link" href="/">&larr; Back to the dashboard</a></p> 133 + `)) 134 + 135 + func (h *Handler) addPeer(w http.ResponseWriter, r *http.Request) { 136 + switch r.Method { 137 + case http.MethodGet: 138 + h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series())}) 139 + case http.MethodPost: 140 + h.submitAddPeer(w, r) 141 + default: 142 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 143 + } 144 + } 145 + 146 + func seriesNames(s []SeriesInfo) []string { 147 + out := make([]string, 0, len(s)) 148 + for _, si := range s { 149 + out = append(out, si.Name) 150 + } 151 + return out 152 + } 153 + 154 + func (h *Handler) renderAddPeer(w http.ResponseWriter, data map[string]any) { 155 + render(w, "add a peer", "SELF-SERVICE ONBOARDING", renderFragment(addPeerTmpl, data)) 156 + } 157 + 158 + func (h *Handler) submitAddPeer(w http.ResponseWriter, r *http.Request) { 159 + ip := clientIP(r) 160 + if !h.passwordLimiter.allowed(ip) { 161 + h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many attempts from your address — try again later"}) 162 + return 163 + } 164 + if err := r.ParseForm(); err != nil { 165 + h.renderAddPeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "couldn't read the form"}) 166 + return 167 + } 168 + form := formData(r, "series", "name", "base_url", "owner", "repo", "token", "password") 169 + retry := func(msg string) { 170 + h.renderAddPeer(w, map[string]any{ 171 + "Series": seriesNames(h.cfg.Series()), "Error": msg, "SelectedSeries": form["series"], 172 + "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], 173 + }) 174 + } 175 + 176 + if !checkPassword(h.cfg, form["password"]) { 177 + h.passwordLimiter.recordFailure(ip) 178 + retry("wrong admin password") 179 + return 180 + } 181 + if !validName.MatchString(form["name"]) { 182 + retry("pair name must be letters, digits, dots, dashes or underscores only") 183 + return 184 + } 185 + if form["token"] = strings.TrimSpace(r.FormValue("token")); form["token"] == "" { 186 + retry("a Forgejo token is required") 187 + return 188 + } 189 + series := findSeries(h.cfg.Series(), form["series"]) 190 + if series == nil { 191 + retry("pick a federation to join") 192 + return 193 + } 194 + if err := validateForgejo(form["base_url"], form["owner"], form["repo"]); err != nil { 195 + retry(err.Error()) 196 + return 197 + } 198 + 199 + tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) 200 + if err != nil { 201 + retry("could not save the token: " + err.Error()) 202 + return 203 + } 204 + 205 + _, err = h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 206 + Name: form["name"], Series: series.Name, 207 + ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, 208 + RadicleRID: series.RadicleRID, RadicleHTTPBaseURL: series.RadicleHTTPBaseURL, RadicleExplorerURL: series.RadicleExplorerURL, 209 + RadicleRadHome: h.cfg.DefaultRadHome, 210 + SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", 211 + }) 212 + if err != nil { 213 + retry("could not save: " + err.Error()) 214 + return 215 + } 216 + render(w, "peer added", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Peer added", "Name": form["name"]})) 217 + } 218 + 219 + func (h *Handler) newRepo(w http.ResponseWriter, r *http.Request) { 220 + switch r.Method { 221 + case http.MethodGet: 222 + h.renderNewRepo(w, map[string]any{}) 223 + case http.MethodPost: 224 + h.submitNewRepo(w, r) 225 + default: 226 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 227 + } 228 + } 229 + 230 + func (h *Handler) renderNewRepo(w http.ResponseWriter, data map[string]any) { 231 + render(w, "start a new repo", "SELF-SERVICE ONBOARDING", renderFragment(newRepoTmpl, data)) 232 + } 233 + 234 + func (h *Handler) submitNewRepo(w http.ResponseWriter, r *http.Request) { 235 + ip := clientIP(r) 236 + if !h.passwordLimiter.allowed(ip) { 237 + h.renderNewRepo(w, map[string]any{"Error": "too many attempts from your address — try again later"}) 238 + return 239 + } 240 + if err := r.ParseForm(); err != nil { 241 + h.renderNewRepo(w, map[string]any{"Error": "couldn't read the form"}) 242 + return 243 + } 244 + form := formData(r, "name", "base_url", "owner", "repo", "token", "rid", "rad_http", "explorer", "password") 245 + retry := func(msg string) { 246 + h.renderNewRepo(w, map[string]any{ 247 + "Error": msg, "Name": form["name"], "BaseURL": form["base_url"], "Owner": form["owner"], "Repo": form["repo"], 248 + "RID": form["rid"], "RadHTTP": form["rad_http"], "Explorer": form["explorer"], 249 + }) 250 + } 251 + 252 + if !checkPassword(h.cfg, form["password"]) { 253 + h.passwordLimiter.recordFailure(ip) 254 + retry("wrong admin password") 255 + return 256 + } 257 + if !validName.MatchString(form["name"]) { 258 + retry("repo name must be letters, digits, dots, dashes or underscores only") 259 + return 260 + } 261 + if form["token"] = strings.TrimSpace(r.FormValue("token")); form["token"] == "" { 262 + retry("a Forgejo token is required") 263 + return 264 + } 265 + if err := validateForgejo(form["base_url"], form["owner"], form["repo"]); err != nil { 266 + retry(err.Error()) 267 + return 268 + } 269 + if form["rid"] == "" || !strings.HasPrefix(form["rid"], "rad:") { 270 + retry("that doesn't look like a Radicle RID (expected rad:z...)") 271 + return 272 + } 273 + if _, err := url.ParseRequestURI(form["rad_http"]); err != nil { 274 + retry("the Radicle HTTP base URL isn't valid") 275 + return 276 + } 277 + 278 + tokenFile, err := writeTokenFile(h.cfg.TokenDir, form["name"], form["token"]) 279 + if err != nil { 280 + retry("could not save the token: " + err.Error()) 281 + return 282 + } 283 + 284 + _, err = h.cfg.Store.CreateDynamicRepo(state.DynamicRepo{ 285 + Name: form["name"], Series: form["name"], 286 + ForgejoBaseURL: form["base_url"], ForgejoOwner: form["owner"], ForgejoRepo: form["repo"], ForgejoTokenFile: tokenFile, 287 + RadicleRID: form["rid"], RadicleHTTPBaseURL: form["rad_http"], RadicleExplorerURL: form["explorer"], 288 + RadicleRadHome: h.cfg.DefaultRadHome, 289 + SyncGit: r.FormValue("sync_git") != "", SyncIssues: r.FormValue("sync_issues") != "", SyncPatches: r.FormValue("sync_patches") != "", 290 + }) 291 + if err != nil { 292 + retry("could not save: " + err.Error()) 293 + return 294 + } 295 + render(w, "repo started", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Repo started", "Name": form["name"]})) 296 + } 297 + 298 + func formData(r *http.Request, fields ...string) map[string]string { 299 + out := make(map[string]string, len(fields)) 300 + for _, f := range fields { 301 + out[f] = strings.TrimSpace(r.FormValue(f)) 302 + } 303 + return out 304 + } 305 + 306 + func findSeries(all []SeriesInfo, name string) *SeriesInfo { 307 + for _, s := range all { 308 + if s.Name == name { 309 + return &s 310 + } 311 + } 312 + return nil 313 + } 314 + 315 + func validateForgejo(baseURL, owner, repo string) error { 316 + u, err := url.ParseRequestURI(baseURL) 317 + if err != nil || u.Scheme != "https" || u.Host == "" { 318 + return fmt.Errorf("the Forgejo base URL must be a valid https:// URL") 319 + } 320 + if owner == "" || repo == "" { 321 + return fmt.Errorf("owner and repository are required") 322 + } 323 + return nil 324 + } 325 + 326 + // writeTokenFile saves a submitted token to its own chmod-600 file, 327 + // following the same convention as every config.yaml-declared pair. 328 + func writeTokenFile(dir, name, token string) (string, error) { 329 + if err := os.MkdirAll(dir, 0o700); err != nil { 330 + return "", err 331 + } 332 + path := filepath.Join(dir, name+".token") 333 + if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { 334 + return "", err 335 + } 336 + return path, nil 337 + }
+302
internal/admin/share.go
··· 1 + package admin 2 + 3 + import ( 4 + "crypto/aes" 5 + "crypto/cipher" 6 + "crypto/hmac" 7 + "crypto/rand" 8 + "crypto/sha256" 9 + "encoding/base64" 10 + "encoding/hex" 11 + "fmt" 12 + "html/template" 13 + "math/big" 14 + "net/http" 15 + "strings" 16 + "time" 17 + 18 + qrcode "github.com/skip2/go-qrcode" 19 + ) 20 + 21 + const ( 22 + shareTTL = 15 * time.Minute 23 + maxShareAttempts = 5 24 + maxSecretBytes = 8000 25 + ) 26 + 27 + // newShareID returns 16 random bytes, base64url-encoded (no padding) — 28 + // the only copy of it that ever exists in reversible form is the one 29 + // handed to whoever creates the share; the database only ever sees its 30 + // hash (see hashShareID). 31 + func newShareID() (string, error) { 32 + b := make([]byte, 16) 33 + if _, err := rand.Read(b); err != nil { 34 + return "", err 35 + } 36 + return base64.RawURLEncoding.EncodeToString(b), nil 37 + } 38 + 39 + func hashShareID(id string) string { 40 + sum := sha256.Sum256([]byte(id)) 41 + return hex.EncodeToString(sum[:]) 42 + } 43 + 44 + // newPasscode returns a uniformly random 6-digit code, zero-padded. 45 + func newPasscode() (string, error) { 46 + n, err := rand.Int(rand.Reader, big.NewInt(1_000_000)) 47 + if err != nil { 48 + return "", err 49 + } 50 + return fmt.Sprintf("%06d", n.Int64()), nil 51 + } 52 + 53 + // shareKey derives the AES-256 key for one share from its id and 54 + // passcode together — never from either alone. A database dump gives an 55 + // attacker id_hash and ciphertext but never the plaintext id, so it's 56 + // insufficient by itself; an attacker who only has the link (the 57 + // plaintext id) still needs the passcode, which is never stored at all — 58 + // GCM's authentication tag is what tells a wrong-passcode attempt apart 59 + // from a right one, not a stored comparison value. 60 + func shareKey(id, passcode string) []byte { 61 + mac := hmac.New(sha256.New, []byte(id)) 62 + mac.Write([]byte("graft-share-v1|" + passcode)) 63 + return mac.Sum(nil) 64 + } 65 + 66 + func encryptShare(id, passcode, plaintext string) (nonceB64, ciphertextB64 string, err error) { 67 + block, err := aes.NewCipher(shareKey(id, passcode)) 68 + if err != nil { 69 + return "", "", err 70 + } 71 + gcm, err := cipher.NewGCM(block) 72 + if err != nil { 73 + return "", "", err 74 + } 75 + nonce := make([]byte, gcm.NonceSize()) 76 + if _, err := rand.Read(nonce); err != nil { 77 + return "", "", err 78 + } 79 + ct := gcm.Seal(nil, nonce, []byte(plaintext), nil) 80 + return base64.StdEncoding.EncodeToString(nonce), base64.StdEncoding.EncodeToString(ct), nil 81 + } 82 + 83 + func decryptShare(id, passcode, nonceB64, ciphertextB64 string) (string, error) { 84 + block, err := aes.NewCipher(shareKey(id, passcode)) 85 + if err != nil { 86 + return "", err 87 + } 88 + gcm, err := cipher.NewGCM(block) 89 + if err != nil { 90 + return "", err 91 + } 92 + nonce, err := base64.StdEncoding.DecodeString(nonceB64) 93 + if err != nil { 94 + return "", err 95 + } 96 + ct, err := base64.StdEncoding.DecodeString(ciphertextB64) 97 + if err != nil { 98 + return "", err 99 + } 100 + pt, err := gcm.Open(nil, nonce, ct, nil) // fails (wrong passcode) is the expected common case, not a real error 101 + if err != nil { 102 + return "", errWrongPasscode 103 + } 104 + return string(pt), nil 105 + } 106 + 107 + var errWrongPasscode = fmt.Errorf("wrong passcode") 108 + 109 + func (h *Handler) shareBaseURL(r *http.Request) string { 110 + if h.cfg.PublicHost != "" { 111 + return "https://" + h.cfg.PublicHost 112 + } 113 + return "https://" + r.Host 114 + } 115 + 116 + var shareNewTmpl = template.Must(template.New("share-new").Parse(` 117 + <h1>Share a secret, once</h1> 118 + <p class="lead">Paste a token or anything else sensitive below. You'll get back a one-time link and a separate 6-digit passcode — hand the link and the passcode to the recipient over two <em>different</em> channels (a link over chat, the passcode read aloud on a call, say), so intercepting one alone is useless. The secret is encrypted, shown to the recipient exactly once, and then destroyed — including if they enter the wrong passcode five times.</p> 119 + {{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 120 + <div class="card"> 121 + <form method="POST" action="/share/new"> 122 + <label for="secret">Secret</label> 123 + <textarea id="secret" name="secret" required maxlength="8000" placeholder="the token, pasted as-is"></textarea> 124 + <div class="hint">Never stored in reversible form without a passcode only the recipient will have.</div> 125 + <button type="submit">Create one-time link</button> 126 + </form> 127 + </div> 128 + `)) 129 + 130 + var shareCreatedTmpl = template.Must(template.New("share-created").Parse(` 131 + <h1>Share created</h1> 132 + <div class="notice good">This is the only time both the link and passcode are shown together. Send them to the recipient now, over two separate channels.</div> 133 + <div class="card"> 134 + <label>One-time link</label> 135 + <div class="secret-box"><a href="{{.URL}}">{{.URL}}</a></div> 136 + <label>Passcode</label> 137 + <div class="passcode">{{.Passcode}}</div> 138 + <label>QR code (opens the link — the recipient still enters the passcode by hand)</label> 139 + <div style="text-align:center;margin-top:.5rem;"><img src="/share/{{.ID}}/qr.png" width="200" height="200" alt="QR code for the one-time link"></div> 140 + <div class="hint">Expires in 15 minutes if never claimed. Claimed exactly once — a second visit shows nothing.</div> 141 + </div> 142 + `)) 143 + 144 + var shareClaimTmpl = template.Must(template.New("share-claim").Parse(` 145 + <h1>Claim a shared secret</h1> 146 + <p class="lead">Enter the 6-digit passcode you were given separately from this link. It can only be tried a handful of times before the secret is destroyed.</p> 147 + {{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 148 + <div class="card"> 149 + <form method="POST" action="/share/{{.ID}}"> 150 + <label for="passcode">Passcode</label> 151 + <input type="text" id="passcode" name="passcode" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus> 152 + <button type="submit">Reveal</button> 153 + </form> 154 + </div> 155 + `)) 156 + 157 + var shareRevealedTmpl = template.Must(template.New("share-revealed").Parse(` 158 + <h1>Secret</h1> 159 + <div class="notice good">Shown once. It's already deleted — copy it now, this page won't show it again on refresh.</div> 160 + <div class="card"> 161 + <div class="secret-box">{{.Secret}}</div> 162 + </div> 163 + `)) 164 + 165 + func (h *Handler) shareNew(w http.ResponseWriter, r *http.Request) { 166 + switch r.Method { 167 + case http.MethodGet: 168 + render(w, "share a secret", "ONE-TIME SHARE", renderFragment(shareNewTmpl, map[string]string{})) 169 + case http.MethodPost: 170 + if err := r.ParseForm(); err != nil { 171 + render(w, "share a secret", "ONE-TIME SHARE", renderFragment(shareNewTmpl, map[string]string{"Error": "couldn't read the form"})) 172 + return 173 + } 174 + secret := strings.TrimSpace(r.FormValue("secret")) 175 + if secret == "" { 176 + render(w, "share a secret", "ONE-TIME SHARE", renderFragment(shareNewTmpl, map[string]string{"Error": "the secret can't be empty"})) 177 + return 178 + } 179 + if len(secret) > maxSecretBytes { 180 + render(w, "share a secret", "ONE-TIME SHARE", renderFragment(shareNewTmpl, map[string]string{"Error": "that's too long for a one-time share"})) 181 + return 182 + } 183 + id, err := newShareID() 184 + if err != nil { 185 + http.Error(w, "internal error", http.StatusInternalServerError) 186 + return 187 + } 188 + passcode, err := newPasscode() 189 + if err != nil { 190 + http.Error(w, "internal error", http.StatusInternalServerError) 191 + return 192 + } 193 + nonce, ct, err := encryptShare(id, passcode, secret) 194 + if err != nil { 195 + http.Error(w, "internal error", http.StatusInternalServerError) 196 + return 197 + } 198 + if err := h.cfg.Store.CreateTokenShare(hashShareID(id), nonce, ct, time.Now().Add(shareTTL)); err != nil { 199 + http.Error(w, "internal error", http.StatusInternalServerError) 200 + return 201 + } 202 + render(w, "share created", "ONE-TIME SHARE", renderFragment(shareCreatedTmpl, map[string]string{ 203 + "URL": h.shareBaseURL(r) + "/share/" + id, 204 + "Passcode": passcode, 205 + "ID": id, 206 + })) 207 + default: 208 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 209 + } 210 + } 211 + 212 + func (h *Handler) shareClaim(w http.ResponseWriter, r *http.Request) { 213 + path := strings.TrimPrefix(r.URL.Path, "/share/") 214 + parts := strings.Split(path, "/") 215 + id := parts[0] 216 + if id == "" { 217 + http.NotFound(w, r) 218 + return 219 + } 220 + if len(parts) == 2 && parts[1] == "qr.png" { 221 + h.serveShareQR(w, r, id) 222 + return 223 + } 224 + if len(parts) != 1 { 225 + http.NotFound(w, r) 226 + return 227 + } 228 + 229 + switch r.Method { 230 + case http.MethodGet: 231 + render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{"ID": id})) 232 + case http.MethodPost: 233 + h.claimShare(w, r, id) 234 + default: 235 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 236 + } 237 + } 238 + 239 + func (h *Handler) serveShareQR(w http.ResponseWriter, r *http.Request, id string) { 240 + // The QR encodes only the claim link, never the passcode — scanning 241 + // it is a convenience for reaching the page in person or on a call, 242 + // not a second channel in itself; the passcode is still meant to be 243 + // told separately. 244 + png, err := qrcode.Encode(h.shareBaseURL(r)+"/share/"+id, qrcode.Medium, 256) 245 + if err != nil { 246 + http.Error(w, "could not generate QR code", http.StatusInternalServerError) 247 + return 248 + } 249 + w.Header().Set("Content-Type", "image/png") 250 + w.Header().Set("Cache-Control", "no-store") 251 + _, _ = w.Write(png) 252 + } 253 + 254 + func (h *Handler) claimShare(w http.ResponseWriter, r *http.Request, id string) { 255 + ip := clientIP(r) 256 + if !h.shareLimiter.allowed(ip) { 257 + render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{ 258 + "ID": id, "Error": "too many attempts from your address — try again later", 259 + })) 260 + return 261 + } 262 + if err := r.ParseForm(); err != nil { 263 + render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{"ID": id, "Error": "couldn't read the form"})) 264 + return 265 + } 266 + passcode := strings.TrimSpace(r.FormValue("passcode")) 267 + 268 + idHash := hashShareID(id) 269 + share, err := h.cfg.Store.GetTokenShare(idHash) 270 + if err != nil { 271 + http.Error(w, "internal error", http.StatusInternalServerError) 272 + return 273 + } 274 + if share == nil { 275 + render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{ 276 + "ID": id, "Error": "this link is invalid, already claimed, or expired", 277 + })) 278 + return 279 + } 280 + 281 + secret, err := decryptShare(id, passcode, share.Nonce, share.Ciphertext) 282 + if err != nil { 283 + h.shareLimiter.recordFailure(ip) 284 + attempts, aerr := h.cfg.Store.IncrementTokenShareAttempts(idHash) 285 + if aerr == nil && attempts >= maxShareAttempts { 286 + _ = h.cfg.Store.DeleteTokenShare(idHash) 287 + render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{ 288 + "ID": id, "Error": "too many wrong passcodes — this secret has been destroyed", 289 + })) 290 + return 291 + } 292 + render(w, "claim a secret", "ONE-TIME SHARE", renderFragment(shareClaimTmpl, map[string]string{ 293 + "ID": id, "Error": "wrong passcode", 294 + })) 295 + return 296 + } 297 + 298 + // One-time by design: destroy it the moment it's successfully read, 299 + // before rendering the response that reveals it. 300 + _ = h.cfg.Store.DeleteTokenShare(idHash) 301 + render(w, "secret", "ONE-TIME SHARE", renderFragment(shareRevealedTmpl, map[string]string{"Secret": secret})) 302 + }
+8
internal/config/config.go
··· 21 21 // graft.cyberwild.org), used to build ActivityPub actor/acct URIs. 22 22 // Required only if any series should be reachable over ActivityPub. 23 23 PublicHost string `yaml:"public_host"` 24 + // AdminPassword gates the dashboard's self-service actions (adding a 25 + // peer, starting a new repo) — anyone who knows it can use them. 26 + // Defaults to "graft" if unset; Load logs a warning when that default 27 + // is still in effect, since it's meant to be changed. 28 + AdminPassword string `yaml:"admin_password"` 24 29 } 25 30 26 31 // RepoPair links one Forgejo repository to one Radicle repository and ··· 100 105 101 106 if cfg.SyncInterval <= 0 { 102 107 cfg.SyncInterval = 5 * time.Minute 108 + } 109 + if cfg.AdminPassword == "" { 110 + cfg.AdminPassword = "graft" 103 111 } 104 112 if cfg.StateDB == "" { 105 113 return nil, fmt.Errorf("state_db must be set")
+229
internal/state/store.go
··· 110 110 series TEXT PRIMARY KEY, 111 111 last_delivered_id INTEGER NOT NULL DEFAULT 0 112 112 ); 113 + 114 + -- One-time secret shares: an admin (graft's own, or a peer's) encrypts a 115 + -- secret (typically a Forgejo token) under a key derived from a 6-digit 116 + -- passcode plus the high-entropy id itself. Only id_hash (never the id in 117 + -- reversible form) is stored, so a database dump alone can never be used 118 + -- to derive the decryption key. Deleted the moment it's successfully 119 + -- claimed, or after too many wrong passcode attempts, or past expires_at — 120 + -- whichever comes first. 121 + CREATE TABLE IF NOT EXISTS token_share ( 122 + id_hash TEXT PRIMARY KEY, 123 + nonce TEXT NOT NULL, 124 + ciphertext TEXT NOT NULL, 125 + attempts INTEGER NOT NULL DEFAULT 0, 126 + created_at TEXT NOT NULL, 127 + expires_at TEXT NOT NULL 128 + ); 129 + 130 + -- A repo pair added through the dashboard's self-service "add a peer" / 131 + -- "start a new repo" forms, gated by the shared admin password rather 132 + -- than a config.yaml edit + restart. forgejo_token_file (not the token 133 + -- itself) follows the same convention as every statically-configured 134 + -- pair: graft writes the submitted token out to its own chmod-600 file 135 + -- under the same directory config.yaml's token_file paths live in, and 136 + -- only the path is kept here. materialized flips to 1 once the running 137 + -- daemon has turned this row into a live RepoSyncer — it happens on the 138 + -- next sync pass, never mid-request, so a submission is never trusted 139 + -- before graft itself has validated it end to end. 140 + CREATE TABLE IF NOT EXISTS dynamic_repo ( 141 + id INTEGER PRIMARY KEY AUTOINCREMENT, 142 + name TEXT NOT NULL UNIQUE, 143 + series TEXT NOT NULL, 144 + forgejo_base_url TEXT NOT NULL, 145 + forgejo_owner TEXT NOT NULL, 146 + forgejo_repo TEXT NOT NULL, 147 + forgejo_token_file TEXT NOT NULL, 148 + radicle_rid TEXT NOT NULL, 149 + radicle_http_base_url TEXT NOT NULL, 150 + radicle_explorer_url TEXT NOT NULL DEFAULT '', 151 + radicle_rad_home TEXT NOT NULL, 152 + sync_git INTEGER NOT NULL DEFAULT 1, 153 + sync_issues INTEGER NOT NULL DEFAULT 1, 154 + sync_patches INTEGER NOT NULL DEFAULT 1, 155 + created_at TEXT NOT NULL, 156 + materialized INTEGER NOT NULL DEFAULT 0 157 + ); 113 158 `) 114 159 if err != nil { 115 160 return err ··· 448 493 } 449 494 return scanActivityRows(rows) 450 495 } 496 + 497 + // TokenShare is one encrypted one-time secret. Nonce and Ciphertext are 498 + // base64-encoded AES-256-GCM output; the decryption key is derived from 499 + // the passcode the claimant supplies plus the share's own id (which is 500 + // never stored — only IDHash is), so a database dump alone never yields 501 + // enough to decrypt. 502 + type TokenShare struct { 503 + IDHash string 504 + Nonce string 505 + Ciphertext string 506 + Attempts int 507 + ExpiresAt time.Time 508 + } 509 + 510 + // CreateTokenShare stores a new encrypted share, indexed by the hash of 511 + // its id (the plaintext id is only ever known to whoever holds the URL). 512 + func (s *Store) CreateTokenShare(idHash, nonce, ciphertext string, expiresAt time.Time) error { 513 + _, err := s.db.Exec(` 514 + INSERT INTO token_share (id_hash, nonce, ciphertext, attempts, created_at, expires_at) 515 + VALUES (?, ?, ?, 0, ?, ?) 516 + `, idHash, nonce, ciphertext, time.Now().UTC().Format(time.RFC3339), expiresAt.UTC().Format(time.RFC3339)) 517 + return err 518 + } 519 + 520 + // GetTokenShare looks up a share by id hash. Returns nil, nil if it 521 + // doesn't exist or has already expired (expiry is checked here rather 522 + // than left to a background sweep, so a stale row can never be claimed 523 + // even if cleanup hasn't run yet). 524 + func (s *Store) GetTokenShare(idHash string) (*TokenShare, error) { 525 + row := s.db.QueryRow(`SELECT id_hash, nonce, ciphertext, attempts, expires_at FROM token_share WHERE id_hash = ?`, idHash) 526 + var t TokenShare 527 + var expiresAt string 528 + if err := row.Scan(&t.IDHash, &t.Nonce, &t.Ciphertext, &t.Attempts, &expiresAt); err != nil { 529 + if err == sql.ErrNoRows { 530 + return nil, nil 531 + } 532 + return nil, err 533 + } 534 + exp, err := time.Parse(time.RFC3339, expiresAt) 535 + if err != nil { 536 + return nil, err 537 + } 538 + t.ExpiresAt = exp 539 + if time.Now().UTC().After(exp) { 540 + _ = s.DeleteTokenShare(idHash) 541 + return nil, nil 542 + } 543 + return &t, nil 544 + } 545 + 546 + // IncrementTokenShareAttempts records one failed passcode attempt and 547 + // returns the new total, so the caller can destroy the share once too 548 + // many wrong guesses have been made — the actual brute-force defense, 549 + // since a 6-digit passcode alone is far too small a space to rely on 550 + // key-derivation cost against a determined online attacker. 551 + func (s *Store) IncrementTokenShareAttempts(idHash string) (int, error) { 552 + _, err := s.db.Exec(`UPDATE token_share SET attempts = attempts + 1 WHERE id_hash = ?`, idHash) 553 + if err != nil { 554 + return 0, err 555 + } 556 + row := s.db.QueryRow(`SELECT attempts FROM token_share WHERE id_hash = ?`, idHash) 557 + var n int 558 + if err := row.Scan(&n); err != nil { 559 + return 0, err 560 + } 561 + return n, nil 562 + } 563 + 564 + // DeleteTokenShare destroys a share — called on a successful claim (it's 565 + // one-time by design), on exceeding the attempt limit (fail closed), or 566 + // on expiry. 567 + func (s *Store) DeleteTokenShare(idHash string) error { 568 + _, err := s.db.Exec(`DELETE FROM token_share WHERE id_hash = ?`, idHash) 569 + return err 570 + } 571 + 572 + // DynamicRepo is one repo pair added through the dashboard's self-service 573 + // onboarding forms rather than a config.yaml edit. 574 + type DynamicRepo struct { 575 + ID int64 576 + Name string 577 + Series string 578 + ForgejoBaseURL string 579 + ForgejoOwner string 580 + ForgejoRepo string 581 + ForgejoTokenFile string 582 + RadicleRID string 583 + RadicleHTTPBaseURL string 584 + RadicleExplorerURL string 585 + RadicleRadHome string 586 + SyncGit bool 587 + SyncIssues bool 588 + SyncPatches bool 589 + Materialized bool 590 + } 591 + 592 + // CreateDynamicRepo inserts a newly-submitted repo pair, active as soon 593 + // as the next sync pass picks it up. Fails if the name is already taken — 594 + // the same uniqueness graft would need across config.yaml pairs anyway. 595 + func (s *Store) CreateDynamicRepo(r DynamicRepo) (int64, error) { 596 + res, err := s.db.Exec(` 597 + INSERT INTO dynamic_repo ( 598 + name, series, forgejo_base_url, forgejo_owner, forgejo_repo, forgejo_token_file, 599 + radicle_rid, radicle_http_base_url, radicle_explorer_url, radicle_rad_home, 600 + sync_git, sync_issues, sync_patches, created_at, materialized 601 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) 602 + `, r.Name, r.Series, r.ForgejoBaseURL, r.ForgejoOwner, r.ForgejoRepo, r.ForgejoTokenFile, 603 + r.RadicleRID, r.RadicleHTTPBaseURL, r.RadicleExplorerURL, r.RadicleRadHome, 604 + boolToInt(r.SyncGit), boolToInt(r.SyncIssues), boolToInt(r.SyncPatches), time.Now().UTC().Format(time.RFC3339)) 605 + if err != nil { 606 + return 0, err 607 + } 608 + return res.LastInsertId() 609 + } 610 + 611 + // UnmaterializedDynamicRepos returns every dynamic_repo row the running 612 + // daemon hasn't yet turned into a live RepoSyncer. 613 + func (s *Store) UnmaterializedDynamicRepos() ([]DynamicRepo, error) { 614 + rows, err := s.db.Query(` 615 + SELECT id, name, series, forgejo_base_url, forgejo_owner, forgejo_repo, forgejo_token_file, 616 + radicle_rid, radicle_http_base_url, radicle_explorer_url, radicle_rad_home, 617 + sync_git, sync_issues, sync_patches 618 + FROM dynamic_repo WHERE materialized = 0 619 + `) 620 + if err != nil { 621 + return nil, err 622 + } 623 + defer rows.Close() 624 + var out []DynamicRepo 625 + for rows.Next() { 626 + var r DynamicRepo 627 + var g, i, p int 628 + if err := rows.Scan(&r.ID, &r.Name, &r.Series, &r.ForgejoBaseURL, &r.ForgejoOwner, &r.ForgejoRepo, &r.ForgejoTokenFile, 629 + &r.RadicleRID, &r.RadicleHTTPBaseURL, &r.RadicleExplorerURL, &r.RadicleRadHome, &g, &i, &p); err != nil { 630 + return nil, err 631 + } 632 + r.SyncGit, r.SyncIssues, r.SyncPatches = g != 0, i != 0, p != 0 633 + out = append(out, r) 634 + } 635 + return out, rows.Err() 636 + } 637 + 638 + // MarkDynamicRepoMaterialized flips a row so it's never re-registered on 639 + // a later pass. 640 + func (s *Store) MarkDynamicRepoMaterialized(id int64) error { 641 + _, err := s.db.Exec(`UPDATE dynamic_repo SET materialized = 1 WHERE id = ?`, id) 642 + return err 643 + } 644 + 645 + // AllDynamicRepos returns every dynamic_repo row regardless of 646 + // materialization state — used at startup to rebuild the full set of 647 + // live pairs after a restart, since these rows persist across it but 648 + // config.yaml doesn't know about them. 649 + func (s *Store) AllDynamicRepos() ([]DynamicRepo, error) { 650 + rows, err := s.db.Query(` 651 + SELECT id, name, series, forgejo_base_url, forgejo_owner, forgejo_repo, forgejo_token_file, 652 + radicle_rid, radicle_http_base_url, radicle_explorer_url, radicle_rad_home, 653 + sync_git, sync_issues, sync_patches 654 + FROM dynamic_repo 655 + `) 656 + if err != nil { 657 + return nil, err 658 + } 659 + defer rows.Close() 660 + var out []DynamicRepo 661 + for rows.Next() { 662 + var r DynamicRepo 663 + var g, i, p int 664 + if err := rows.Scan(&r.ID, &r.Name, &r.Series, &r.ForgejoBaseURL, &r.ForgejoOwner, &r.ForgejoRepo, &r.ForgejoTokenFile, 665 + &r.RadicleRID, &r.RadicleHTTPBaseURL, &r.RadicleExplorerURL, &r.RadicleRadHome, &g, &i, &p); err != nil { 666 + return nil, err 667 + } 668 + r.SyncGit, r.SyncIssues, r.SyncPatches = g != 0, i != 0, p != 0 669 + out = append(out, r) 670 + } 671 + return out, rows.Err() 672 + } 673 + 674 + func boolToInt(b bool) int { 675 + if b { 676 + return 1 677 + } 678 + return 0 679 + }
+12 -1
internal/status/status.go
··· 722 722 723 723 .heatmap-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: .9rem; } 724 724 .heatmap-head h2 { margin: 0; } 725 - .legend { display: flex; gap: .9rem; margin-bottom: .9rem; } 725 + .legend { display: flex; gap: .9rem; margin-bottom: .9rem; flex-wrap: wrap; } 726 + .fed-actions { display: flex; gap: .5rem; margin-bottom: .9rem; } 727 + .btn-outline { 728 + font-size: .76rem; font-weight: 600; color: var(--brand); border: 1px solid var(--brand); 729 + border-radius: 4px; padding: .3rem .65rem; text-decoration: none; white-space: nowrap; 730 + } 731 + .btn-outline:hover { background: var(--brand); color: #fff; } 726 732 .legend span { display: flex; align-items: center; gap: .35rem; font-size: .74rem; color: var(--text-muted); } 727 733 .legend .sw { width: 8px; height: 8px; border-radius: 2px; display: inline-block; } 728 734 .legend .sw[data-kind="git"] { background: var(--kind-git); } ··· 839 845 <span><i class="sw" data-kind="source"></i>Source</span> 840 846 <span><i class="sw" data-kind="replicated"></i>Replicated</span> 841 847 </div> 848 + <div class="fed-actions"> 849 + <a class="btn-outline" href="/add-peer">+ Add peer</a> 850 + <a class="btn-outline" href="/new-repo">+ New repo</a> 851 + </div> 842 852 </div> 843 853 <div class="heatmap"> 844 854 {{range .Series}} ··· 916 926 </main> 917 927 <footer> 918 928 {{if .SourceURL}}<a href="{{.SourceURL}}" target="_blank" rel="noopener">{{.SourceURL}}</a>{{end}} 929 + <a href="/share/new">Share a secret, once &rarr;</a> 919 930 <span>GPLv3</span> 920 931 </footer> 921 932 </body>