package admin import ( "bytes" "context" "fmt" "html/template" "net/http" "os" "os/exec" "regexp" "time" ) // radExecTimeout bounds "rad node connect" / "rad seed" — a network // handshake with a node that may never answer shouldn't hang the request. const radExecTimeout = 60 * time.Second var validNodeID = regexp.MustCompile(`^[a-zA-Z0-9]{1,80}$`) var validNodeAddress = regexp.MustCompile(`^[a-zA-Z0-9.\-]+:[0-9]{1,5}$`) // runRad runs the rad CLI against graft's own local Radicle identity — // the same one every pair (static or dynamic) already pushes as. Never // scoped to a single repo (unlike internal/radicle.Client), since // "connect" operates on the node itself, not a repository. func runRad(radHome string, args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), radExecTimeout) defer cancel() cmd := exec.CommandContext(ctx, "rad", args...) cmd.Env = append(os.Environ(), "RAD_HOME="+radHome) var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = &out if err := cmd.Run(); err != nil { return out.String(), fmt.Errorf("rad %v: %w: %s", args, err, out.String()) } return out.String(), nil } var addRadiclePeerTmpl = template.Must(template.New("add-radicle-peer").Parse(`

Add a Radicle peer

Connects a new Radicle node to the mesh and has it seed an existing federation.

{{if .Error}}
{{.Error}}
{{end}}
Know it? Enter it to connect & seed immediately. Leave it blank to submit for the graft admin to review and approve instead.

Add a Forgejo peer instead →

`)) func (h *Handler) addRadiclePeer(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series())}) case http.MethodPost: h.submitAddRadiclePeer(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *Handler) renderAddRadiclePeer(w http.ResponseWriter, data map[string]any) { render(w, "add a radicle peer", "SELF-SERVICE ONBOARDING", renderFragment(addRadiclePeerTmpl, data)) } func (h *Handler) submitAddRadiclePeer(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) if !h.submitLimiter.allowed(ip) { h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many submissions from your address — try again later"}) return } if err := r.ParseForm(); err != nil { h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "couldn't read the form"}) return } form := formData(r, "series", "node_id", "address", "password") retry := func(msg string) { h.renderAddRadiclePeer(w, map[string]any{ "Series": seriesNames(h.cfg.Series()), "Error": msg, "SelectedSeries": form["series"], "NodeID": form["node_id"], "Address": form["address"], }) } h.submitLimiter.recordFailure(ip) series := findSeries(h.cfg.Series(), form["series"]) if series == nil { retry("pick a federation to join") return } if !validNodeID.MatchString(form["node_id"]) { retry("that doesn't look like a valid node id") return } if !validNodeAddress.MatchString(form["address"]) { retry("address must be host:port") return } if form["password"] != "" { if !checkPassword(h.cfg, form["password"]) { h.passwordLimiter.recordFailure(ip) retry("wrong admin password") return } if _, err := runRad(h.cfg.DefaultRadHome, "node", "connect", form["node_id"]+"@"+form["address"]); err != nil { retry("could not connect to that node: " + err.Error()) return } if _, err := runRad(h.cfg.DefaultRadHome, "seed", series.RadicleRID); err != nil { retry("connected, but could not seed the repository: " + err.Error()) return } render(w, "radicle peer added", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]any{"Title": "Radicle peer added", "Name": form["node_id"], "Approved": true})) return } if _, err := h.cfg.Store.CreatePendingRadiclePeer(series.Name, form["node_id"], form["address"]); err != nil { retry("could not save: " + err.Error()) return } render(w, "radicle peer submitted", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]any{"Title": "Radicle peer submitted", "Name": form["node_id"], "Approved": false})) }