Files
MeshTender/internal/core/repeater_setup.go
T

290 lines
11 KiB
Go

package core
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
meshcore "github.com/meshcore-go/meshcore-go"
"github.com/jleight/meshtender/internal/store"
"github.com/jleight/meshtender/internal/web"
)
// The "set up from scratch over USB serial" path of the add-repeater wizard.
//
// Unlike the KISS/console path (which bridges a companion modem to the server
// and sends encrypted CLI over LoRa), here the browser talks the repeater's own
// plain-text serial CLI directly. The server's only jobs are (1) generating the
// ordered command list for the device and (2) creating the repeater record once
// the run succeeds. The repeater's private key is generated in the browser and
// never sent to the server — only its public key is.
// identityPlaceholder is the line the client replaces with the real
// `set prv.key <128-hex>` before running. We emit a placeholder rather than the
// key because the key never reaches the server. `<key>` keeps the preview
// readable; the client substitutes it and refuses to run if any `<…>` remains.
const identityPlaceholder = "set prv.key <key>"
// setupCommandsRequest is the JSON body the serial setup page posts to build the
// command list. OrgID 0 means "no org" (standalone repeater, radio from preset).
type setupCommandsRequest struct {
Name string `json:"name"`
OrgID int64 `json:"orgId"`
Profile string `json:"profile"` // profile name within the org, optional
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
// Radio preset values (MHz/kHz), used only when OrgID == 0.
FreqMHz float64 `json:"freqMhz"`
BwKHz float64 `json:"bwKhz"`
SF int `json:"sf"`
CR int `json:"cr"`
}
// handleSetupCommands builds the ordered CLI command list for a from-scratch
// serial setup and returns it as JSON. The identity command is emitted as a
// placeholder for the client to fill in with the locally-generated private key.
func (s *Handlers) handleSetupCommands(w http.ResponseWriter, r *http.Request) {
uid := s.Auth.CurrentUserID(r.Context())
var req setupCommandsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name, ok := cleanRepeaterName(req.Name)
if !ok {
http.Error(w, "name is required and must be 31 characters or fewer", http.StatusBadRequest)
return
}
req.Name = name
// Resolve the authoritative radio settings (echoed back so the client can
// store them on the repeater record) and gather the org's profile + region
// commands. Radio + region come from the chosen org config; a standalone
// repeater (no org) uses the request's radio and gets no region hierarchy.
radio := setupRadio{FreqMHz: req.FreqMHz, BwKHz: req.BwKHz, SF: req.SF, CR: req.CR}
var steps []store.ConfigStep
var regionCmds []string
if req.OrgID != 0 {
if _, isMember, err := s.Store.OrgRole(r.Context(), req.OrgID, uid); err != nil || !isMember {
http.Error(w, "no access to that organization", http.StatusForbidden)
return
}
if req.Profile != "" {
s2, err := s.profileSteps(r.Context(), req.OrgID, req.Profile)
if err != nil {
s.ServerError(w, r, "could not load profile", err)
return
}
steps = s2
}
// Prefer the radio the profile sets on the device; if it sets none, fall
// back to the default preset so the device still ends up tunable (and the
// record radio is accurate).
if rad, ok := parseProfileRadio(profileCommandLines(steps)); ok {
radio = rad
} else {
radio = defaultSetupRadio()
}
regions, err := s.Store.ListRegions(r.Context(), req.OrgID)
if err != nil {
s.ServerError(w, r, "could not load regions", err)
return
}
rootAllow, err := s.Store.RootAllowFlood(r.Context(), req.OrgID)
if err != nil {
s.ServerError(w, r, "could not load regions", err)
return
}
regionCmds = store.RegionDefCommands(regions, rootAllow, req.Lat, req.Lon)
} else if radio.FreqMHz <= 0 || radio.BwKHz <= 0 {
http.Error(w, "radio settings are required", http.StatusBadRequest)
return
}
cmds := buildSetupCommands(req.Name, identityPlaceholder, s.Identity.SetPermCommand(), req.Lat, req.Lon, radio, steps, regionCmds)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(setupCommandsResponse{
Commands: cmds,
IdentityPlaceholder: identityPlaceholder,
Radio: radio,
})
}
// setupRadio is a resolved radio configuration in the firmware's command units
// (MHz / kHz), echoed to the client so it can persist it on the record.
type setupRadio struct {
FreqMHz float64 `json:"freqMhz"`
BwKHz float64 `json:"bwKhz"`
SF int `json:"sf"`
CR int `json:"cr"`
}
// setupCommandsResponse is the JSON handleSetupCommands returns: the ordered CLI
// lines (with identityPlaceholder standing in for the private-key command the
// client splices locally) and the resolved radio the client persists on save.
type setupCommandsResponse struct {
Commands []string `json:"commands"`
IdentityPlaceholder string `json:"identityPlaceholder"`
Radio setupRadio `json:"radio"`
}
// setupCompleteResponse is the JSON handleSetupComplete returns after creating
// the repeater record: where the client should navigate next.
type setupCompleteResponse struct {
Redirect string `json:"redirect"`
}
func defaultSetupRadio() setupRadio {
p := defaultPreset()
return setupRadio{FreqMHz: float64(p.FreqHz) / 1e6, BwKHz: float64(p.BwHz) / 1e3, SF: p.SF, CR: p.CR}
}
// parseProfileRadio extracts radio settings from a profile's `set radio
// <f,bw,sf,cr>` step, if present. Returns ok=false when the profile sets no
// radio (so the caller can fall back to a default).
func parseProfileRadio(steps []string) (setupRadio, bool) {
for _, line := range steps {
rest, ok := strings.CutPrefix(strings.TrimSpace(line), "set radio ")
if !ok {
continue
}
parts := strings.Split(strings.TrimSpace(rest), ",")
if len(parts) != 4 {
continue
}
f, e1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
bw, e2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
sf, e3 := strconv.Atoi(strings.TrimSpace(parts[2]))
cr, e4 := strconv.Atoi(strings.TrimSpace(parts[3]))
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
continue
}
return setupRadio{FreqMHz: f, BwKHz: bw, SF: sf, CR: cr}, true
}
return setupRadio{}, false
}
// radioCommand renders the MeshCore `set radio <f,bw,sf,cr>` line. Frequency is
// in MHz and bandwidth in kHz (matching the firmware's units for this command).
func radioCommand(rad setupRadio) string {
return "set radio " +
strconv.FormatFloat(rad.FreqMHz, 'f', -1, 64) + "," +
strconv.FormatFloat(rad.BwKHz, 'f', -1, 64) + "," +
strconv.Itoa(rad.SF) + "," + strconv.Itoa(rad.CR)
}
// buildSetupCommands assembles the ordered from-scratch USB setup command list:
// the header (name, client-spliced identity, location, radio, admin grant), then
// the profile's steps with the org's region commands spliced at the profile's
// {{ region }} marker (or appended after all steps when it has none), then a
// final reboot to apply the new identity and radio. Comment steps and the marker
// itself are not runnable, so they never reach the device.
func buildSetupCommands(name, identityCmd, setpermCmd string, lat, lon *float64, radio setupRadio, steps []store.ConfigStep, regionCmds []string) []string {
cmds := []string{"set name " + name, identityCmd}
if lat != nil && lon != nil {
cmds = append(cmds,
"set lat "+strconv.FormatFloat(*lat, 'f', 6, 64),
"set lon "+strconv.FormatFloat(*lon, 'f', 6, 64))
}
cmds = append(cmds, radioCommand(radio), setpermCmd)
before, after := store.SplitAtRegionMarker(steps)
cmds = append(cmds, profileCommandLines(before)...)
cmds = append(cmds, regionCmds...)
cmds = append(cmds, profileCommandLines(after)...)
return append(cmds, "reboot")
}
// profileCommandLines returns the runnable command lines of a step slice,
// skipping comment steps and the region marker.
func profileCommandLines(steps []store.ConfigStep) []string {
var out []string
for _, st := range steps {
if st.IsComment() || st.IsRegionMarker() {
continue
}
out = append(out, st.CommandLine)
}
return out
}
// profileSteps returns the ordered steps of a named profile in an org (nil when
// no profile by that name exists).
func (s *Handlers) profileSteps(ctx context.Context, orgID int64, name string) ([]store.ConfigStep, error) {
profiles, err := s.Store.ListProfiles(ctx, orgID)
if err != nil {
return nil, err
}
for _, p := range profiles {
if p.Name == name {
return p.Steps, nil
}
}
return nil, nil
}
// handleSetupComplete creates the repeater record after a successful serial run.
// The private key stays in the browser; only the public key arrives here.
func (s *Handlers) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
uid := s.Auth.CurrentUserID(r.Context())
name, ok := cleanRepeaterName(r.FormValue("name"))
pubHex := strings.ToLower(strings.TrimSpace(r.FormValue("public_key")))
if !ok {
http.Error(w, "name is required and must be 31 characters or fewer", http.StatusBadRequest)
return
}
if _, err := meshcore.NewIdentityFromHex(pubHex); err != nil {
http.Error(w, "public key must be 64 hex characters", http.StatusBadRequest)
return
}
freq, bw, sf, cr, ok := parseRadioForm(r)
if !ok {
http.Error(w, "radio parameters must be valid numbers", http.StatusBadRequest)
return
}
rep, err := s.Store.CreateRepeater(r.Context(), &store.Repeater{
OwnerID: uid,
Name: name,
PublicKeyHex: pubHex,
RadioFreqHz: freq,
RadioBwHz: bw,
RadioSF: int16(sf),
RadioCR: int16(cr),
ShowOnPublicOrg: r.FormValue("show_on_public_org") != "",
ExposePublicPage: r.FormValue("expose_public_page") != "",
})
if errors.Is(err, store.ErrDuplicate) {
http.Error(w, "you already added a repeater with that public key", http.StatusConflict)
return
}
if err != nil {
s.ServerError(w, r, "could not add repeater", err)
return
}
// Persist the location the user picked on the map (we just wrote it to the
// device, so we know it without an over-the-air confirm). The repeater is
// already created, so on a store failure don't fail the request (that would
// strand a created-but-unreachable-via-retry repeater) — log it; the location
// can be recovered later via the confirm/console flow.
if lat, err1 := strconv.ParseFloat(r.FormValue("lat"), 64); err1 == nil {
if lon, err2 := strconv.ParseFloat(r.FormValue("lon"), 64); err2 == nil {
if err := s.Store.SetRepeaterLocation(r.Context(), rep.ID, lat, lon); err != nil {
web.LogError(r, "setup: store location", err, "repeater_id", rep.ID)
}
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(setupCompleteResponse{
Redirect: "/repeaters/" + rep.PublicID + "/added",
})
}