mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 01:38:17 +00:00
290 lines
11 KiB
Go
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",
|
|
})
|
|
}
|