Remove standalone confirm page

This commit is contained in:
Jonathon Leight
2026-07-08 08:06:53 -04:00
parent 98e1ce104e
commit 219c803de8
13 changed files with 223 additions and 477 deletions
-299
View File
@@ -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
+117
View File
@@ -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
}
-49
View File
@@ -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 &amp; 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}}
+1 -1
View File
@@ -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 &amp; confirm now</a>
<a class="btn btn-primary" href="/repeaters/{{.Repeater.PublicID}}/console">Open console &amp; confirm now</a>
<a class="btn" href="/">Skip for now</a>
</div>
</div>
-2
View File
@@ -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)
+73
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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.
//
-106
View File
@@ -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>