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"}} -
- 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. -
-{{.Repeater.PublicKeyHex}}
- 11, 22, 33). Leave blank to reach it by flood.
- No modem handy? Skip this — anyone you share it with can corroborate it for you later.
diff --git a/internal/core/web.go b/internal/core/web.go index 84ed09f..5377c27 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -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) diff --git a/internal/e2e/console_confirm_test.go b/internal/e2e/console_confirm_test.go new file mode 100644 index 0000000..be306c6 --- /dev/null +++ b/internal/e2e/console_confirm_test.go @@ -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) +} diff --git a/internal/web/static/console.js b/internal/web/static/console.js index b336308..907766f 100644 --- a/internal/web/static/console.js +++ b/internal/web/static/console.js @@ -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 diff --git a/internal/web/static/serial-setup.js b/internal/web/static/serial-setup.js index cfa9a9e..f318d88 100644 --- a/internal/web/static/serial-setup.js +++ b/internal/web/static/serial-setup.js @@ -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. // diff --git a/internal/web/static/serial.js b/internal/web/static/serial.js deleted file mode 100644 index 8e32068..0000000 --- a/internal/web/static/serial.js +++ /dev/null @@ -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(); - } - }); -})(); diff --git a/internal/web/templates/repeater_tabs.html b/internal/web/templates/repeater_tabs.html index c99be0b..48857d2 100644 --- a/internal/web/templates/repeater_tabs.html +++ b/internal/web/templates/repeater_tabs.html @@ -17,13 +17,10 @@ for the owner. */}} - {{else}} - Confirm {{end}}