mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-16 16:42:37 +00:00
Remove standalone confirm page
This commit is contained in:
@@ -1,299 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
meshcore "github.com/meshcore-go/meshcore-go"
|
||||
"github.com/meshcore-go/meshcore-go/hardware"
|
||||
|
||||
"github.com/jleight/meshtender/internal/mesh"
|
||||
"github.com/jleight/meshtender/internal/web"
|
||||
"github.com/jleight/meshtender/internal/wsbridge"
|
||||
)
|
||||
|
||||
// confirmTimeout bounds a single confirm session (login + optional location
|
||||
// fetch, each with retries). Sized to comfortably exceed the worst case of a
|
||||
// few fully-failed exchanges at maxSendTries × perTryReply.
|
||||
const confirmTimeout = 150 * time.Second
|
||||
|
||||
// Packet send tuning, shared by confirm and console. perTryReply is how long we
|
||||
// wait for a reply before resending (a var so tests can shorten it);
|
||||
// maxSendTries is the maximum number of sends per request.
|
||||
var perTryReply = 10 * time.Second
|
||||
|
||||
const maxSendTries = 4
|
||||
|
||||
// applyUserPath seeds the exchanger with a caller-supplied route (the optional
|
||||
// ?path= query param from the confirm/console page) so the login and commands
|
||||
// route directly with flood fallback. A malformed path is reported and ignored
|
||||
// (we fall back to flood) rather than failing the session. It returns whether a
|
||||
// path was set, so the caller can report whether that path actually worked.
|
||||
func applyUserPath(ex *mesh.Exchanger, r *http.Request, bridge *wsbridge.Conn) bool {
|
||||
raw := r.URL.Query().Get("path")
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
path, pathLen, err := mesh.ParsePath(raw)
|
||||
if err != nil {
|
||||
_ = bridge.Status("warning", "Ignoring the path you entered ("+err.Error()+") — using flood.")
|
||||
return false
|
||||
}
|
||||
if path == nil {
|
||||
return false
|
||||
}
|
||||
ex.SetPath(path, pathLen)
|
||||
_ = bridge.Status("info", "Using the path you specified (direct routing, with flood fallback).")
|
||||
return true
|
||||
}
|
||||
|
||||
// reportPathOutcome logs whether the login reached the repeater over the
|
||||
// user-supplied path (a direct RESPONSE reply) or had to fall back to flood (a
|
||||
// PATH return reply). Only meaningful when a path was set and login succeeded.
|
||||
func reportPathOutcome(bridge *wsbridge.Conn, lr *mesh.LoginResponse) {
|
||||
if lr.FromPath {
|
||||
_ = bridge.Status("warning", "The path you specified didn't get through — reached the repeater by flood instead.")
|
||||
} else {
|
||||
_ = bridge.Status("info", "Reached the repeater directly over the path you specified. ✓")
|
||||
}
|
||||
}
|
||||
|
||||
// pageConfirm renders the WebSerial confirm page for a repeater the user can access.
|
||||
func (s *Handlers) pageConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
rep, _, ok := s.requireRepeaterAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.Render(w, r, "confirm.html", map[string]any{
|
||||
"Repeater": rep,
|
||||
"Debug": r.URL.Query().Get("debug") == "1",
|
||||
})
|
||||
}
|
||||
|
||||
// wsConfirm runs the live login round-trip over a WebSocket bridged to the
|
||||
// browser's WebSerial-attached KISS modem.
|
||||
func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
uid := s.Auth.CurrentUserID(r.Context())
|
||||
id, ok := s.repeaterID(r)
|
||||
if !ok {
|
||||
s.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rep, err := s.Store.GetRepeaterForUser(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
http.Error(w, "no access", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
repeaterID, err := meshcore.NewIdentityFromHex(rep.PublicKeyHex)
|
||||
if err != nil {
|
||||
s.ServerError(w, r, "stored repeater key invalid", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Track the socket so shutdown can drain it (http.Server.Shutdown doesn't close
|
||||
// hijacked/WebSocket conns). Add before Accept so a shutdown racing the upgrade
|
||||
// still waits for this handler.
|
||||
s.wsWG.Add(1)
|
||||
defer s.wsWG.Done()
|
||||
|
||||
ws, err := websocket.Accept(w, r, nil) // same-origin (request host) authorized by default
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// A connection-lifetime context derived from the server's WS context (cancelled
|
||||
// on shutdown), not the request context which is unsafe to use after Accept.
|
||||
ctx, cancel := context.WithTimeout(s.wsCtx, confirmTimeout)
|
||||
defer cancel()
|
||||
|
||||
bridge := wsbridge.New(ctx, ws)
|
||||
// Disable TX flow control: it blocks SendData until the modem emits a
|
||||
// HW_RESP_TX_DONE event, which not all KISS firmwares send — causing a
|
||||
// spurious "tx done timeout". We don't need it; the repeater's reply is the
|
||||
// real confirmation we wait for.
|
||||
modem := hardware.NewKissModem(bridge, hardware.WithTxFlowControl(0))
|
||||
defer func() { _ = modem.Close() }()
|
||||
|
||||
server := s.Identity.Local()
|
||||
debug := r.URL.Query().Get("debug") == "1"
|
||||
|
||||
// All sends (login + location queries) go through one exchanger: rate-limited,
|
||||
// monotonic timestamps, automatic retry of lost packets.
|
||||
ex := mesh.NewExchanger(modem, server, repeaterID, sendInterval, perTryReply, maxSendTries)
|
||||
modem.SetDataHandler(func(data []byte, _ float32, _ int8, _ bool) {
|
||||
ex.HandleData(data)
|
||||
})
|
||||
userPathSet := applyUserPath(ex, r, bridge)
|
||||
if debug {
|
||||
// Dump every inbound KISS frame as hex so we can see exactly what the
|
||||
// modem reports back (e.g. whether the repeater replies at all).
|
||||
bridge.SetObserver(func(f *hardware.KissFrame) {
|
||||
_ = bridge.Status("debug", fmt.Sprintf("rx frame cmd=0x%02x len=%d data=%x", f.Command, len(f.Data), f.Data))
|
||||
})
|
||||
}
|
||||
|
||||
if err := modem.Connect(ctx); err != nil {
|
||||
// The user's own local modem — the detail helps them troubleshoot, so keep it
|
||||
// in the status frame; also log it so operators see connection failures.
|
||||
web.LogError(r, "confirm: modem connect", err, "repeater_id", id)
|
||||
_ = bridge.Status("error", "modem connect: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ready := make(chan struct{}, 1)
|
||||
|
||||
// Socket read loop: binary = serial bytes, text = browser control frames.
|
||||
go func() {
|
||||
for {
|
||||
typ, data, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
bridge.MarkDead()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
switch typ {
|
||||
case websocket.MessageBinary:
|
||||
bridge.Feed(data)
|
||||
case websocket.MessageText:
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal(data, &msg) == nil && msg.Type == "ready" {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for the browser to report the serial port is open.
|
||||
select {
|
||||
case <-ready:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
_ = bridge.Status("info", "Tuning radio…")
|
||||
if err := modem.SetRadio(&hardware.RadioConfig{
|
||||
FreqHz: uint32(rep.RadioFreqHz), //nolint:gosec // G115: radio config value is bounded (preset-constrained)
|
||||
BwHz: uint32(rep.RadioBwHz), //nolint:gosec // G115: radio config value is bounded (preset-constrained)
|
||||
SF: uint8(rep.RadioSF), //nolint:gosec // G115: radio config value is bounded (preset-constrained)
|
||||
CR: uint8(rep.RadioCR), //nolint:gosec // G115: radio config value is bounded (preset-constrained)
|
||||
}); err != nil {
|
||||
web.LogError(r, "confirm: set radio", err, "repeater_id", id)
|
||||
_ = bridge.Status("error", "set radio: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Log in (retried internally on lost packets).
|
||||
lr, err := ex.Login(ctx, "", func(attempt, max int) {
|
||||
if attempt == 1 {
|
||||
_ = bridge.Status("info", "Sending login to repeater…")
|
||||
} else {
|
||||
_ = bridge.Status("info", fmt.Sprintf("No reply yet — retrying login (%d/%d)…", attempt, max))
|
||||
}
|
||||
})
|
||||
if errors.Is(err, mesh.ErrNoReply) {
|
||||
_ = bridge.Status("timeout", "No reply from the repeater after several tries. Check that the modem is on the repeater's frequency/SF/BW and that MeshTender has been granted access (setperm).")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return // context cancelled or a build/transmit error already reported
|
||||
}
|
||||
if userPathSet {
|
||||
reportPathOutcome(bridge, lr)
|
||||
}
|
||||
|
||||
if err := s.Store.SetRepeaterConfirmed(ctx, id, uid, lr.IsAdmin, int16(lr.Permissions)); err != nil {
|
||||
web.LogError(r, "confirm: save confirmation", err, "repeater_id", id)
|
||||
_ = bridge.Status("error", "Could not save the confirmation — please try again.")
|
||||
return
|
||||
}
|
||||
if debug {
|
||||
_ = bridge.Status("debug", fmt.Sprintf("login reply fromPath=%v admin=%v perms=%d", lr.FromPath, lr.IsAdmin, lr.Permissions))
|
||||
}
|
||||
if !lr.IsAdmin {
|
||||
// Guests can't run CLI commands (including get lat/lon), so there's
|
||||
// nothing more to do — stop here rather than fruitlessly querying.
|
||||
msg := fmt.Sprintf("Repeater reached, but MeshTender only has GUEST access (permissions=%d). Guest is open to anyone with a blank password, so MeshTender can't administer this repeater — re-run `%s` to grant admin.", lr.Permissions, s.Identity.SetPermCommand())
|
||||
if s.Cfg.RootHost != "" {
|
||||
msg += " See " + s.Origin(r, s.Cfg.RootHost) + "/docs#setperm for help."
|
||||
}
|
||||
_ = bridge.Status("warning", msg)
|
||||
return
|
||||
}
|
||||
_ = bridge.Status("confirmed", "Repeater reached with admin access. ✓")
|
||||
|
||||
s.fetchAndStoreLocation(ctx, r, ex, bridge, id, debug)
|
||||
}
|
||||
|
||||
// fetchAndStoreLocation queries the connected repeater for its coordinates
|
||||
// ("get lat"/"get lon", each retried) and persists them. It reports progress via
|
||||
// bridge.Status and returns the stored coordinates (ok=false if either read
|
||||
// failed). Shared by the confirm flow and the console's confirm-on-connect so
|
||||
// both capture location the same way. Requires admin access (guests can't run the
|
||||
// get commands) — callers must gate on that first.
|
||||
func (s *Handlers) fetchAndStoreLocation(ctx context.Context, r *http.Request, ex *mesh.Exchanger, bridge *wsbridge.Conn, id int64, debug bool) (lat, lon float64, ok bool) {
|
||||
fetchCoord := func(label, cmd string, accept func(text string) bool) (float64, bool) {
|
||||
reply, err := ex.CommandAccept(ctx, cmd, accept, func(attempt, max int) {
|
||||
if attempt == 1 {
|
||||
_ = bridge.Status("info", "Fetching "+label+"…")
|
||||
} else {
|
||||
_ = bridge.Status("info", fmt.Sprintf("Fetching %s — retry %d/%d…", label, attempt, max))
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parseLocationFloat(reply)
|
||||
}
|
||||
lat, okLat := fetchCoord("latitude", "get lat", nil)
|
||||
// A slow latitude fetch is retried, which makes the repeater re-run "get lat"
|
||||
// and emit duplicate replies; one can straggle in during the "get lon" wait and
|
||||
// be misread as the longitude (storing lat,lat). Since the two coordinates
|
||||
// differ, reject a longitude reply whose value equals the latitude we just read
|
||||
// and keep waiting for the genuine reply.
|
||||
lon, okLon := fetchCoord("longitude", "get lon", func(text string) bool {
|
||||
f, ok := parseLocationFloat(text)
|
||||
if ok && okLat && f == lat {
|
||||
if debug {
|
||||
_ = bridge.Status("debug", "ignored a stale 'get lat' reply while awaiting longitude")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !okLat || !okLon {
|
||||
_ = bridge.Status("warning", "Could not read a location from the repeater.")
|
||||
return 0, 0, false
|
||||
}
|
||||
if err := s.Store.SetRepeaterLocation(ctx, id, lat, lon); err != nil {
|
||||
web.LogError(r, "confirm: store location", err, "repeater_id", id)
|
||||
_ = bridge.Status("error", "Could not store the location — please try again.")
|
||||
return 0, 0, false
|
||||
}
|
||||
_ = bridge.Status("info", fmt.Sprintf("Stored location: %.5f, %.5f", lat, lon))
|
||||
return lat, lon, true
|
||||
}
|
||||
|
||||
// parseLocationFloat parses a "get lat"/"get lon" reply like "> 37.7749".
|
||||
func parseLocationFloat(reply string) (float64, bool) {
|
||||
s := strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(reply), ">"))
|
||||
if i := strings.IndexAny(s, " \t\r\n"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
@@ -41,9 +42,10 @@ func TestParseLocationFloat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmFetchesLocation drives the confirm flow and verifies the repeater's
|
||||
// lat/lon are fetched (get lat / get lon) and stored.
|
||||
func TestConfirmFetchesLocation(t *testing.T) {
|
||||
// TestConsoleFetchesLocation drives the console's "Fetch location" (getloc)
|
||||
// request and verifies the repeater's lat/lon are fetched (get lat / get lon)
|
||||
// and stored — including the stale-reply guard in fetchAndStoreLocation.
|
||||
func TestConsoleFetchesLocation(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx := coreStore(t)
|
||||
|
||||
@@ -67,7 +69,7 @@ func TestConfirmFetchesLocation(t *testing.T) {
|
||||
t.Fatalf("create repeater: %v", err)
|
||||
}
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/repeaters/" + rep.PublicID + "/ws"
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/repeaters/" + rep.PublicID + "/console/ws"
|
||||
hdr := http.Header{}
|
||||
if cs := jar.Cookies(mustURL(t, ts.URL)); len(cs) > 0 {
|
||||
var parts []string
|
||||
@@ -90,6 +92,9 @@ func TestConfirmFetchesLocation(t *testing.T) {
|
||||
shared, _ := repeater.SharedSecret(serverID)
|
||||
|
||||
_ = ws.Write(rw, websocket.MessageText, []byte(`{"type":"ready"}`))
|
||||
// The console fetches location only on request (unlike the old confirm flow's
|
||||
// eager fetch). Queue the getloc; the console processes it after login.
|
||||
_ = ws.Write(rw, websocket.MessageText, []byte(`{"type":"getloc"}`))
|
||||
|
||||
// Reply to login (PATH) then to get lat / get lon (TXT_MSG).
|
||||
replyText := func(text string) []byte {
|
||||
@@ -117,9 +122,16 @@ func TestConfirmFetchesLocation(t *testing.T) {
|
||||
for {
|
||||
typ, data, err := ws.Read(rw)
|
||||
if err != nil {
|
||||
break // server finished and closed; check the DB below
|
||||
break // socket closed; check the DB below
|
||||
}
|
||||
if typ == websocket.MessageText {
|
||||
// Unlike the old confirm flow, the console stays open after fetching
|
||||
// location (it's an interactive session), so it never closes the socket
|
||||
// on its own. It signals success with a "location" status — stop then.
|
||||
var m struct{ State, Message string }
|
||||
if json.Unmarshal(data, &m) == nil && m.State == "location" {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
buf = append(buf, data...)
|
||||
@@ -22,11 +22,13 @@ import (
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
// TestConfirmRoundTrip drives the full confirm path in-process, standing in for
|
||||
// the browser (WebSocket), the KISS modem (KISS framing), and the repeater
|
||||
// (MeshCore crypto). It is gated on MESHTENDER_TEST_DATABASE_URL so a plain
|
||||
// TestConsoleLoginConfirms drives the console's login/confirm-on-connect path
|
||||
// in-process, standing in for the browser (WebSocket), the KISS modem (KISS
|
||||
// framing), and the repeater (MeshCore crypto). It verifies the login packet
|
||||
// actually decrypts under the repeater↔server secret and that reaching the
|
||||
// repeater marks it confirmed. Gated on MESHTENDER_TEST_DATABASE_URL so a plain
|
||||
// `go test` never truncates a real database.
|
||||
func TestConfirmRoundTrip(t *testing.T) {
|
||||
func TestConsoleLoginConfirms(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx := coreStore(t)
|
||||
|
||||
@@ -66,8 +68,8 @@ func TestConfirmRoundTrip(t *testing.T) {
|
||||
t.Fatalf("create repeater: %v", err)
|
||||
}
|
||||
|
||||
// --- dial the confirm WebSocket with the auth cookie ---
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/repeaters/" + rep.PublicID + "/ws"
|
||||
// --- dial the console WebSocket with the auth cookie ---
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/repeaters/" + rep.PublicID + "/console/ws"
|
||||
hdr := http.Header{}
|
||||
if cs := jar.Cookies(mustURL(t, ts.URL)); len(cs) > 0 {
|
||||
var parts []string
|
||||
@@ -21,9 +21,10 @@ import (
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
// TestConfirmLoginRetry drops the first login (simulating a lost packet) and
|
||||
// verifies the confirm flow retries with a fresh timestamp and succeeds.
|
||||
func TestConfirmLoginRetry(t *testing.T) {
|
||||
// TestConsoleLoginRetry drops the first login (simulating a lost packet) and
|
||||
// verifies the console's login retries with a fresh timestamp and succeeds
|
||||
// (confirming the repeater).
|
||||
func TestConsoleLoginRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx := coreStore(t)
|
||||
|
||||
@@ -47,7 +48,7 @@ func TestConfirmLoginRetry(t *testing.T) {
|
||||
t.Fatalf("create repeater: %v", err)
|
||||
}
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/repeaters/" + rep.PublicID + "/ws"
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/repeaters/" + rep.PublicID + "/console/ws"
|
||||
hdr := http.Header{}
|
||||
if cs := jar.Cookies(mustURL(t, ts.URL)); len(cs) > 0 {
|
||||
var parts []string
|
||||
@@ -0,0 +1,117 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jleight/meshtender/internal/mesh"
|
||||
"github.com/jleight/meshtender/internal/web"
|
||||
"github.com/jleight/meshtender/internal/wsbridge"
|
||||
)
|
||||
|
||||
// Packet send tuning for the console's login/command exchanges. perTryReply is
|
||||
// how long we wait for a reply before resending (a var so tests can shorten it);
|
||||
// maxSendTries is the maximum number of sends per request.
|
||||
var perTryReply = 10 * time.Second
|
||||
|
||||
const maxSendTries = 4
|
||||
|
||||
// applyUserPath seeds the exchanger with a caller-supplied route (the optional
|
||||
// ?path= query param from the console page) so the login and commands route
|
||||
// directly with flood fallback. A malformed path is reported and ignored
|
||||
// (we fall back to flood) rather than failing the session. It returns whether a
|
||||
// path was set, so the caller can report whether that path actually worked.
|
||||
func applyUserPath(ex *mesh.Exchanger, r *http.Request, bridge *wsbridge.Conn) bool {
|
||||
raw := r.URL.Query().Get("path")
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
path, pathLen, err := mesh.ParsePath(raw)
|
||||
if err != nil {
|
||||
_ = bridge.Status("warning", "Ignoring the path you entered ("+err.Error()+") — using flood.")
|
||||
return false
|
||||
}
|
||||
if path == nil {
|
||||
return false
|
||||
}
|
||||
ex.SetPath(path, pathLen)
|
||||
_ = bridge.Status("info", "Using the path you specified (direct routing, with flood fallback).")
|
||||
return true
|
||||
}
|
||||
|
||||
// reportPathOutcome logs whether the login reached the repeater over the
|
||||
// user-supplied path (a direct RESPONSE reply) or had to fall back to flood (a
|
||||
// PATH return reply). Only meaningful when a path was set and login succeeded.
|
||||
func reportPathOutcome(bridge *wsbridge.Conn, lr *mesh.LoginResponse) {
|
||||
if lr.FromPath {
|
||||
_ = bridge.Status("warning", "The path you specified didn't get through — reached the repeater by flood instead.")
|
||||
} else {
|
||||
_ = bridge.Status("info", "Reached the repeater directly over the path you specified. ✓")
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAndStoreLocation queries the connected repeater for its coordinates
|
||||
// ("get lat"/"get lon", each retried) and persists them. It reports progress via
|
||||
// bridge.Status and returns the stored coordinates (ok=false if either read
|
||||
// failed). Driven by the console's "Fetch location" (getloc) request. Requires
|
||||
// admin access (guests can't run the get commands) — callers must gate on that
|
||||
// first.
|
||||
func (s *Handlers) fetchAndStoreLocation(ctx context.Context, r *http.Request, ex *mesh.Exchanger, bridge *wsbridge.Conn, id int64, debug bool) (lat, lon float64, ok bool) {
|
||||
fetchCoord := func(label, cmd string, accept func(text string) bool) (float64, bool) {
|
||||
reply, err := ex.CommandAccept(ctx, cmd, accept, func(attempt, max int) {
|
||||
if attempt == 1 {
|
||||
_ = bridge.Status("info", "Fetching "+label+"…")
|
||||
} else {
|
||||
_ = bridge.Status("info", fmt.Sprintf("Fetching %s — retry %d/%d…", label, attempt, max))
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parseLocationFloat(reply)
|
||||
}
|
||||
lat, okLat := fetchCoord("latitude", "get lat", nil)
|
||||
// A slow latitude fetch is retried, which makes the repeater re-run "get lat"
|
||||
// and emit duplicate replies; one can straggle in during the "get lon" wait and
|
||||
// be misread as the longitude (storing lat,lat). Since the two coordinates
|
||||
// differ, reject a longitude reply whose value equals the latitude we just read
|
||||
// and keep waiting for the genuine reply.
|
||||
lon, okLon := fetchCoord("longitude", "get lon", func(text string) bool {
|
||||
f, ok := parseLocationFloat(text)
|
||||
if ok && okLat && f == lat {
|
||||
if debug {
|
||||
_ = bridge.Status("debug", "ignored a stale 'get lat' reply while awaiting longitude")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !okLat || !okLon {
|
||||
_ = bridge.Status("warning", "Could not read a location from the repeater.")
|
||||
return 0, 0, false
|
||||
}
|
||||
if err := s.Store.SetRepeaterLocation(ctx, id, lat, lon); err != nil {
|
||||
web.LogError(r, "console: store location", err, "repeater_id", id)
|
||||
_ = bridge.Status("error", "Could not store the location — please try again.")
|
||||
return 0, 0, false
|
||||
}
|
||||
_ = bridge.Status("info", fmt.Sprintf("Stored location: %.5f, %.5f", lat, lon))
|
||||
return lat, lon, true
|
||||
}
|
||||
|
||||
// parseLocationFloat parses a "get lat"/"get lon" reply like "> 37.7749".
|
||||
func parseLocationFloat(reply string) (float64, bool) {
|
||||
s := strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(reply), ">"))
|
||||
if i := strings.IndexAny(s, " \t\r\n"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{{define "title"}}Confirm {{.Repeater.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Confirm access</div>
|
||||
<h2 class="page-title">{{.Repeater.Name}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary">
|
||||
Plug a MeshCore <strong>KISS modem</strong> into this computer, then connect it below.
|
||||
MeshTender will send a signed login to the repeater through your modem and read the reply
|
||||
to verify it has access. This is optional — the repeater works either way.
|
||||
</p>
|
||||
<code class="pk">{{.Repeater.PublicKeyHex}}</code>
|
||||
<div class="text-secondary small mt-2">{{mhz .Repeater.RadioFreqHz}} MHz · BW {{khz .Repeater.RadioBwHz}} · SF{{.Repeater.RadioSF}} · CR{{.Repeater.RadioCR}}</div>
|
||||
|
||||
<div id="unsupported" class="alert alert-danger mt-3" role="alert" hidden>
|
||||
This browser does not support WebSerial. Use Chrome, Edge, or another Chromium-based browser over HTTPS or on localhost.
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label" for="path">Path <span class="form-label-description">optional</span></label>
|
||||
<input type="text" class="form-control font-monospace" id="path" placeholder="e.g. 11, 22, 33" autocomplete="off" spellcheck="false">
|
||||
<small class="form-hint">Hex hops from your modem to the repeater, in order, same length each (e.g. <code>11, 22, 33</code>). Leave blank to reach it by flood.</small>
|
||||
</div>
|
||||
|
||||
<button id="connect" class="btn btn-primary mt-3" type="button">{{template "icon-plug" "me-1"}}Connect modem & confirm</button>
|
||||
|
||||
<ul id="log" class="evlog mt-3" role="log" aria-live="polite" aria-label="Confirmation activity"></ul>
|
||||
<div class="mt-3 d-flex align-items-center">
|
||||
<a class="back-link" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
|
||||
{{if .Debug}}
|
||||
<a class="text-secondary ms-2" href="/repeaters/{{.Repeater.PublicID}}/confirm">Disable debug</a>
|
||||
{{else}}
|
||||
<a class="text-secondary ms-2" href="/repeaters/{{.Repeater.PublicID}}/confirm?debug=1">Debug: show raw frames</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="{{.Nonce}}">
|
||||
window.MESHTENDER_WS = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/repeaters/{{.Repeater.PublicID}}/ws{{if .Debug}}?debug=1{{end}}";
|
||||
</script>
|
||||
<script src="/static/serial.js"></script>
|
||||
{{end}}
|
||||
@@ -34,7 +34,7 @@
|
||||
</p>
|
||||
<p class="text-secondary">No modem handy? Skip this — anyone you share it with can corroborate it for you later.</p>
|
||||
<div class="btn-list">
|
||||
<a class="btn btn-primary" href="/repeaters/{{.Repeater.PublicID}}/confirm">Connect modem & confirm now</a>
|
||||
<a class="btn btn-primary" href="/repeaters/{{.Repeater.PublicID}}/console">Open console & confirm now</a>
|
||||
<a class="btn" href="/">Skip for now</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,8 +197,6 @@ func (s *Handlers) appRouter() chi.Router {
|
||||
r.Post("/repeaters/{id}/edit", s.handleEditRepeater)
|
||||
r.Get("/repeaters/{id}/delete", s.pageDeleteRepeater)
|
||||
r.Post("/repeaters/{id}/delete", s.handleDeleteRepeater)
|
||||
r.Get("/repeaters/{id}/confirm", s.pageConfirm)
|
||||
r.Get("/repeaters/{id}/ws", s.wsConfirm)
|
||||
r.Get("/repeaters/{id}/console", s.pageConsole)
|
||||
r.Get("/repeaters/{id}/console/ws", s.wsConsole)
|
||||
r.Get("/repeaters/{id}/config.json", s.consoleConfigJSON)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build browser
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
cdplog "github.com/chromedp/cdproto/log"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// TestE2EConsoleConfirmBanner is the browser regression for folding the old
|
||||
// confirm page into the console: an unconfirmed repeater shows the "not
|
||||
// confirmed yet" banner, and a confirmed repeater with no known location shows
|
||||
// the location prompt with a "Fetch location" button. Both must render under the
|
||||
// strict CSP with no violations (the console's inline nonce'd bootstrap plus
|
||||
// console.js wiring the banner/button).
|
||||
func TestE2EConsoleConfirmBanner(t *testing.T) {
|
||||
srv := newE2EServer(t)
|
||||
owner, cookie := srv.login(t, "owner")
|
||||
|
||||
// Case 1: brand-new (unconfirmed) repeater → confirm prompt, no location prompt.
|
||||
unconfirmed := srv.newRepeater(t, owner.ID, "Unconfirmed Rep")
|
||||
|
||||
// Case 2: confirmed with admin access but no stored location → location prompt.
|
||||
located := srv.newRepeater(t, owner.ID, "Confirmed Rep")
|
||||
if err := srv.store.SetRepeaterConfirmed(srv.ctx, located.ID, owner.ID, true, 3); err != nil {
|
||||
t.Fatalf("confirm repeater: %v", err)
|
||||
}
|
||||
|
||||
bctx, cancel, watch := startBrowser(t)
|
||||
defer cancel()
|
||||
|
||||
// --- unconfirmed: confirm-banner present, location-banner absent ---
|
||||
var confirmBanner, locBannerA bool
|
||||
if err := chromedp.Run(bctx,
|
||||
network.Enable(),
|
||||
cdplog.Enable(),
|
||||
setSessionCookie(cookie),
|
||||
chromedp.Navigate(srv.appURL+"/repeaters/"+unconfirmed.PublicID+"/console"),
|
||||
chromedp.WaitVisible(`[data-testid="allowed-commands"]`, chromedp.ByQuery),
|
||||
chromedp.Evaluate(`!!document.querySelector('[data-testid="confirm-banner"]')`, &confirmBanner),
|
||||
chromedp.Evaluate(`!!document.querySelector('[data-testid="location-banner"]')`, &locBannerA),
|
||||
); err != nil {
|
||||
t.Fatalf("browser run (unconfirmed): %v", err)
|
||||
}
|
||||
if !confirmBanner {
|
||||
t.Error("unconfirmed repeater console is missing the confirm banner")
|
||||
}
|
||||
if locBannerA {
|
||||
t.Error("unconfirmed repeater console should not show the location banner")
|
||||
}
|
||||
|
||||
// --- confirmed, no location: location-banner + fetch button, no confirm banner ---
|
||||
var confirmBannerB, fetchBtn bool
|
||||
if err := chromedp.Run(bctx,
|
||||
chromedp.Navigate(srv.appURL+"/repeaters/"+located.PublicID+"/console"),
|
||||
chromedp.WaitVisible(`[data-testid="location-banner"]`, chromedp.ByQuery),
|
||||
chromedp.Evaluate(`!!document.querySelector('[data-testid="confirm-banner"]')`, &confirmBannerB),
|
||||
chromedp.Evaluate(`!!document.querySelector('[data-testid="fetch-location"]')`, &fetchBtn),
|
||||
); err != nil {
|
||||
t.Fatalf("browser run (confirmed): %v", err)
|
||||
}
|
||||
if confirmBannerB {
|
||||
t.Error("confirmed repeater console should not show the confirm banner")
|
||||
}
|
||||
if !fetchBtn {
|
||||
t.Error("confirmed repeater without a location is missing the Fetch location button")
|
||||
}
|
||||
|
||||
watch.assertClean(t)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Interactive repeater console. Bridges a WebSerial-connected KISS modem to the
|
||||
// server over a WebSocket (binary = raw KISS bytes), and exchanges JSON control
|
||||
// messages for commands and status. Mirrors serial.js's bridge, plus a CLI.
|
||||
// messages for commands and status: a KISS-modem bridge plus a CLI.
|
||||
//
|
||||
// It also exposes a small API (window.MeshConsole) and document events so the
|
||||
// separate "apply org configuration" script (console-config.js) can send commands
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Drives the "set up a brand-new repeater over USB" step of the add-repeater
|
||||
// wizard. Unlike serial.js (which bridges a KISS modem's raw bytes to the server
|
||||
// wizard. Unlike console.js (which bridges a KISS modem's raw bytes to the server
|
||||
// over a WebSocket), this talks the repeater's own plain-text serial CLI
|
||||
// DIRECTLY: it writes "command\n" and reads the text the device echoes back.
|
||||
//
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
// Bridges a WebSerial-connected MeshCore KISS modem to the MeshTender server
|
||||
// over a WebSocket. Binary WS messages carry raw KISS serial bytes; text WS
|
||||
// messages carry JSON status updates from the server.
|
||||
|
||||
(function () {
|
||||
const connectBtn = document.getElementById("connect");
|
||||
const log = document.getElementById("log");
|
||||
|
||||
// wsURL appends the optional user-entered path (#path) to the base ws URL so
|
||||
// the server routes login/commands directly (with flood fallback).
|
||||
function wsURL() {
|
||||
let url = window.MESHTENDER_WS;
|
||||
const el = document.getElementById("path");
|
||||
if (el && el.value.trim()) {
|
||||
url += (url.indexOf("?") === -1 ? "?" : "&") + "path=" + encodeURIComponent(el.value.trim());
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
if (!("serial" in navigator)) {
|
||||
const unsupportedEl = document.getElementById("unsupported");
|
||||
if (unsupportedEl) unsupportedEl.hidden = false;
|
||||
if (connectBtn) connectBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
function addLog(state, message) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "ev ev-" + state;
|
||||
li.textContent = message;
|
||||
log.appendChild(li);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
let port, ws, reader, writer, keepReading = false;
|
||||
|
||||
async function cleanup() {
|
||||
keepReading = false;
|
||||
try { if (reader) await reader.cancel(); } catch (_) {}
|
||||
try { if (writer) writer.releaseLock(); } catch (_) {}
|
||||
try { if (port) await port.close(); } catch (_) {}
|
||||
try { if (ws && ws.readyState === WebSocket.OPEN) ws.close(); } catch (_) {}
|
||||
connectBtn.disabled = false;
|
||||
}
|
||||
|
||||
async function pumpSerialToWS() {
|
||||
try {
|
||||
while (keepReading) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value && value.length && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(value); // Uint8Array -> binary frame
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
addLog("error", "Serial read error: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
connectBtn.addEventListener("click", async () => {
|
||||
connectBtn.disabled = true;
|
||||
log.innerHTML = "";
|
||||
try {
|
||||
addLog("info", "Requesting serial port…");
|
||||
port = await navigator.serial.requestPort();
|
||||
await port.open({ baudRate: 115200 });
|
||||
writer = port.writable.getWriter();
|
||||
reader = port.readable.getReader();
|
||||
keepReading = true;
|
||||
addLog("info", "Serial port open (115200 8N1). Connecting to server…");
|
||||
|
||||
ws = new WebSocket(wsURL());
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
pumpSerialToWS();
|
||||
ws.send(JSON.stringify({ type: "ready" }));
|
||||
addLog("info", "Connected. Confirming…");
|
||||
};
|
||||
|
||||
ws.onmessage = async (ev) => {
|
||||
if (typeof ev.data === "string") {
|
||||
let msg = {};
|
||||
try { msg = JSON.parse(ev.data); } catch (_) {}
|
||||
addLog(msg.state || "info", msg.message || ev.data);
|
||||
// The server keeps the session open after login to fetch the
|
||||
// location, then closes it when done (handled by ws.onclose). Don't
|
||||
// close proactively here, or the location fetch would be cut off.
|
||||
return;
|
||||
}
|
||||
// Binary from server -> write raw bytes to the modem.
|
||||
try {
|
||||
await writer.write(new Uint8Array(ev.data));
|
||||
} catch (e) {
|
||||
addLog("error", "Serial write error: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => { addLog("info", "Disconnected."); cleanup(); };
|
||||
ws.onerror = () => { addLog("error", "WebSocket error."); };
|
||||
} catch (e) {
|
||||
addLog("error", e.message);
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -17,13 +17,10 @@ for the owner. */}}
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Manage</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a class="dropdown-item" href="/repeaters/{{.PublicID}}/edit">{{template "icon-pencil" "dropdown-item-icon"}}Edit details</a>
|
||||
<a class="dropdown-item" href="/repeaters/{{.PublicID}}/confirm">{{template "icon-antenna" "dropdown-item-icon"}}Confirm access</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item text-danger" href="/repeaters/{{.PublicID}}/delete">{{template "icon-trash" "dropdown-item-icon"}}Delete repeater</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<a class="btn" href="/repeaters/{{.PublicID}}/confirm">Confirm</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user