Federated via graft
5.3 kB
140 lines
1package admin
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "html/template"
8 "net/http"
9 "os"
10 "os/exec"
11 "regexp"
12 "time"
13)
14
15// radExecTimeout bounds "rad node connect" / "rad seed" — a network
16// handshake with a node that may never answer shouldn't hang the request.
17const radExecTimeout = 60 * time.Second
18
19var validNodeID = regexp.MustCompile(`^[a-zA-Z0-9]{1,80}$`)
20var validNodeAddress = regexp.MustCompile(`^[a-zA-Z0-9.\-]+:[0-9]{1,5}$`)
21
22// runRad runs the rad CLI against graft's own local Radicle identity —
23// the same one every pair (static or dynamic) already pushes as. Never
24// scoped to a single repo (unlike internal/radicle.Client), since
25// "connect" operates on the node itself, not a repository.
26func runRad(radHome string, args ...string) (string, error) {
27 ctx, cancel := context.WithTimeout(context.Background(), radExecTimeout)
28 defer cancel()
29 cmd := exec.CommandContext(ctx, "rad", args...)
30 cmd.Env = append(os.Environ(), "RAD_HOME="+radHome)
31 var out bytes.Buffer
32 cmd.Stdout = &out
33 cmd.Stderr = &out
34 if err := cmd.Run(); err != nil {
35 return out.String(), fmt.Errorf("rad %v: %w: %s", args, err, out.String())
36 }
37 return out.String(), nil
38}
39
40var addRadiclePeerTmpl = template.Must(template.New("add-radicle-peer").Parse(`
41<h1>Add a Radicle peer</h1>
42<p class="lead">Connects a new Radicle node to the mesh and has it seed an existing federation.</p>
43{{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}}
44<div class="card">
45 <form method="POST" action="/add-radicle-peer">
46 <label for="series">Federation (series)</label>
47 <select id="series" name="series" required>
48 <option value="">— choose —</option>
49 {{range .Series}}<option value="{{.}}" {{if eq . $.SelectedSeries}}selected{{end}}>{{.}}</option>{{end}}
50 </select>
51
52 <label for="node_id">Node ID</label>
53 <input type="text" id="node_id" name="node_id" required value="{{.NodeID}}" placeholder="z6Mk...">
54
55 <label for="address">Address</label>
56 <input type="text" id="address" name="address" required value="{{.Address}}" placeholder="host:8776">
57
58 <label for="password">Admin password (optional)</label>
59 <input type="password" id="password" name="password" autocomplete="off">
60 <div class="hint">Know it? Enter it to connect & seed immediately. Leave it blank to submit for the graft admin to review and approve instead.</div>
61
62 <button type="submit">Submit</button>
63 </form>
64</div>
65<p><a class="nav-link" href="/add-peer">Add a Forgejo peer instead →</a></p>
66`))
67
68func (h *Handler) addRadiclePeer(w http.ResponseWriter, r *http.Request) {
69 switch r.Method {
70 case http.MethodGet:
71 h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series())})
72 case http.MethodPost:
73 h.submitAddRadiclePeer(w, r)
74 default:
75 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
76 }
77}
78
79func (h *Handler) renderAddRadiclePeer(w http.ResponseWriter, data map[string]any) {
80 render(w, "add a radicle peer", "SELF-SERVICE ONBOARDING", renderFragment(addRadiclePeerTmpl, data))
81}
82
83func (h *Handler) submitAddRadiclePeer(w http.ResponseWriter, r *http.Request) {
84 ip := clientIP(r)
85 if !h.submitLimiter.allowed(ip) {
86 h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many submissions from your address — try again later"})
87 return
88 }
89 if err := r.ParseForm(); err != nil {
90 h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "couldn't read the form"})
91 return
92 }
93 form := formData(r, "series", "node_id", "address", "password")
94 retry := func(msg string) {
95 h.renderAddRadiclePeer(w, map[string]any{
96 "Series": seriesNames(h.cfg.Series()), "Error": msg, "SelectedSeries": form["series"],
97 "NodeID": form["node_id"], "Address": form["address"],
98 })
99 }
100 h.submitLimiter.recordFailure(ip)
101
102 series := findSeries(h.cfg.Series(), form["series"])
103 if series == nil {
104 retry("pick a federation to join")
105 return
106 }
107 if !validNodeID.MatchString(form["node_id"]) {
108 retry("that doesn't look like a valid node id")
109 return
110 }
111 if !validNodeAddress.MatchString(form["address"]) {
112 retry("address must be host:port")
113 return
114 }
115
116 if form["password"] != "" {
117 if !checkPassword(h.cfg, form["password"]) {
118 h.passwordLimiter.recordFailure(ip)
119 retry("wrong admin password")
120 return
121 }
122 if _, err := runRad(h.cfg.DefaultRadHome, "node", "connect", form["node_id"]+"@"+form["address"]); err != nil {
123 retry("could not connect to that node: " + err.Error())
124 return
125 }
126 if _, err := runRad(h.cfg.DefaultRadHome, "seed", series.RadicleRID); err != nil {
127 retry("connected, but could not seed the repository: " + err.Error())
128 return
129 }
130 render(w, "radicle peer added", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]any{"Title": "Radicle peer added", "Name": form["node_id"], "Approved": true}))
131 return
132 }
133
134 if _, err := h.cfg.Store.CreatePendingRadiclePeer(series.Name, form["node_id"], form["address"]); err != nil {
135 retry("could not save: " + err.Error())
136 return
137 }
138
139 render(w, "radicle peer submitted", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]any{"Title": "Radicle peer submitted", "Name": form["node_id"], "Approved": false}))
140}