Federated via graft
4.6 kB
129 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</label>
59 <input type="password" id="password" name="password" required autocomplete="off">
60
61 <button type="submit">Connect & seed</button>
62 </form>
63</div>
64<p><a class="nav-link" href="/add-peer">Add a Forgejo peer instead →</a></p>
65`))
66
67func (h *Handler) addRadiclePeer(w http.ResponseWriter, r *http.Request) {
68 switch r.Method {
69 case http.MethodGet:
70 h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series())})
71 case http.MethodPost:
72 h.submitAddRadiclePeer(w, r)
73 default:
74 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
75 }
76}
77
78func (h *Handler) renderAddRadiclePeer(w http.ResponseWriter, data map[string]any) {
79 render(w, "add a radicle peer", "SELF-SERVICE ONBOARDING", renderFragment(addRadiclePeerTmpl, data))
80}
81
82func (h *Handler) submitAddRadiclePeer(w http.ResponseWriter, r *http.Request) {
83 ip := clientIP(r)
84 if !h.passwordLimiter.allowed(ip) {
85 h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many attempts from your address — try again later"})
86 return
87 }
88 if err := r.ParseForm(); err != nil {
89 h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "couldn't read the form"})
90 return
91 }
92 form := formData(r, "series", "node_id", "address", "password")
93 retry := func(msg string) {
94 h.renderAddRadiclePeer(w, map[string]any{
95 "Series": seriesNames(h.cfg.Series()), "Error": msg, "SelectedSeries": form["series"],
96 "NodeID": form["node_id"], "Address": form["address"],
97 })
98 }
99
100 if !checkPassword(h.cfg, form["password"]) {
101 h.passwordLimiter.recordFailure(ip)
102 retry("wrong admin password")
103 return
104 }
105 series := findSeries(h.cfg.Series(), form["series"])
106 if series == nil {
107 retry("pick a federation to join")
108 return
109 }
110 if !validNodeID.MatchString(form["node_id"]) {
111 retry("that doesn't look like a valid node id")
112 return
113 }
114 if !validNodeAddress.MatchString(form["address"]) {
115 retry("address must be host:port")
116 return
117 }
118
119 if _, err := runRad(h.cfg.DefaultRadHome, "node", "connect", form["node_id"]+"@"+form["address"]); err != nil {
120 retry("could not connect to that node: " + err.Error())
121 return
122 }
123 if _, err := runRad(h.cfg.DefaultRadHome, "seed", series.RadicleRID); err != nil {
124 retry("connected, but could not seed the repository: " + err.Error())
125 return
126 }
127
128 render(w, "radicle peer added", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Radicle peer added", "Name": form["node_id"]}))
129}