From 219c803de8255b6baab581beba2eca40450dada4 Mon Sep 17 00:00:00 2001
From: Jonathon Leight
Date: Wed, 8 Jul 2026 08:06:53 -0400
Subject: [PATCH] Remove standalone confirm page
---
internal/core/confirm.go | 299 ------------------
...ation_test.go => console_location_test.go} | 22 +-
...egration_test.go => console_login_test.go} | 14 +-
...rm_retry_test.go => console_retry_test.go} | 9 +-
internal/core/modem.go | 117 +++++++
internal/core/templates/confirm.html | 49 ---
internal/core/templates/repeater_added.html | 2 +-
internal/core/web.go | 2 -
internal/e2e/console_confirm_test.go | 73 +++++
internal/web/static/console.js | 2 +-
internal/web/static/serial-setup.js | 2 +-
internal/web/static/serial.js | 106 -------
internal/web/templates/repeater_tabs.html | 3 -
13 files changed, 223 insertions(+), 477 deletions(-)
delete mode 100644 internal/core/confirm.go
rename internal/core/{confirm_location_test.go => console_location_test.go} (84%)
rename internal/core/{confirm_integration_test.go => console_login_test.go} (91%)
rename internal/core/{confirm_retry_test.go => console_retry_test.go} (94%)
create mode 100644 internal/core/modem.go
delete mode 100644 internal/core/templates/confirm.html
create mode 100644 internal/e2e/console_confirm_test.go
delete mode 100644 internal/web/static/serial.js
diff --git a/internal/core/confirm.go b/internal/core/confirm.go
deleted file mode 100644
index eba44c9..0000000
--- a/internal/core/confirm.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/core/confirm_location_test.go b/internal/core/console_location_test.go
similarity index 84%
rename from internal/core/confirm_location_test.go
rename to internal/core/console_location_test.go
index a8ff9da..fe6e0e8 100644
--- a/internal/core/confirm_location_test.go
+++ b/internal/core/console_location_test.go
@@ -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...)
diff --git a/internal/core/confirm_integration_test.go b/internal/core/console_login_test.go
similarity index 91%
rename from internal/core/confirm_integration_test.go
rename to internal/core/console_login_test.go
index 556677e..8809f09 100644
--- a/internal/core/confirm_integration_test.go
+++ b/internal/core/console_login_test.go
@@ -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
diff --git a/internal/core/confirm_retry_test.go b/internal/core/console_retry_test.go
similarity index 94%
rename from internal/core/confirm_retry_test.go
rename to internal/core/console_retry_test.go
index 921ee55..68f7862 100644
--- a/internal/core/confirm_retry_test.go
+++ b/internal/core/console_retry_test.go
@@ -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
diff --git a/internal/core/modem.go b/internal/core/modem.go
new file mode 100644
index 0000000..4c290e6
--- /dev/null
+++ b/internal/core/modem.go
@@ -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
+}
diff --git a/internal/core/templates/confirm.html b/internal/core/templates/confirm.html
deleted file mode 100644
index 335b3d7..0000000
--- a/internal/core/templates/confirm.html
+++ /dev/null
@@ -1,49 +0,0 @@
-{{define "title"}}Confirm {{.Repeater.Name}} · MeshTender{{end}}
-{{define "header"}}
-
-
-
Confirm access
-
{{.Repeater.Name}}
-
-
-{{end}}
-{{define "content"}}
-
-
-
- Plug a MeshCore KISS modem 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.
-