From d7c236aaf7af0888db0937a82a083180a70ff4da Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Wed, 1 Jul 2026 20:09:36 -0400 Subject: [PATCH] User-specified direct path --- internal/core/confirm.go | 38 ++++++++ internal/core/console.go | 11 ++- internal/core/templates/confirm.html | 6 ++ internal/core/templates/console.html | 5 + internal/mesh/exchange.go | 33 ++++++- internal/mesh/mesh.go | 24 ++++- internal/mesh/path.go | 56 +++++++++++ internal/mesh/path_test.go | 135 +++++++++++++++++++++++++++ internal/web/static/console.js | 13 ++- internal/web/static/serial.js | 13 ++- 10 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 internal/mesh/path.go create mode 100644 internal/mesh/path_test.go diff --git a/internal/core/confirm.go b/internal/core/confirm.go index c97d9f0..7c5c788 100644 --- a/internal/core/confirm.go +++ b/internal/core/confirm.go @@ -30,6 +30,40 @@ 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) @@ -89,6 +123,7 @@ func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) { 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). @@ -163,6 +198,9 @@ func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) { 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 { _ = bridge.Status("error", "could not save confirmation: "+err.Error()) diff --git a/internal/core/console.go b/internal/core/console.go index 7253b5f..86c5dcb 100644 --- a/internal/core/console.go +++ b/internal/core/console.go @@ -206,6 +206,7 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { modem.SetDataHandler(func(data []byte, _ float32, _ int8, _ bool) { ex.HandleData(data) }) + userPathSet := applyUserPath(ex, r, bridge) // Group this connection's commands into a session (required for logging). sessionID, err := s.Store.StartConsoleSession(ctx, id, uid) @@ -285,14 +286,18 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { // direct routing. If login gets no reply we proceed anyway — the repeater may // still have us cached as an admin client from an earlier session. _ = bridge.Status("info", "Establishing session…") - if _, err := ex.Login(ctx, "", func(attempt, max int) { + lr, err := ex.Login(ctx, "", func(attempt, max int) { if attempt > 1 { _ = bridge.Status("info", fmt.Sprintf("No reply yet — retrying (%d/%d)…", attempt, max)) } - }); errors.Is(err, mesh.ErrNoReply) { + }) + switch { + case errors.Is(err, mesh.ErrNoReply): _ = bridge.Status("warning", "Couldn't reach the repeater to establish a session — commands will still be attempted (flood), but may not work if it doesn't recognize MeshTender.") - } else if err != nil { + case err != nil: return // context cancelled + case userPathSet: + reportPathOutcome(bridge, lr) } _ = bridge.Status("info", "Connected. Ready for commands.") diff --git a/internal/core/templates/confirm.html b/internal/core/templates/confirm.html index 4a27bc1..24433c7 100644 --- a/internal/core/templates/confirm.html +++ b/internal/core/templates/confirm.html @@ -22,6 +22,12 @@ This browser does not support WebSerial. Use Chrome, Edge, or another Chromium-based browser over HTTPS or on localhost. +
+ + + Hex hops from your modem to the repeater, in order, same length each (e.g. 11, 22, 33). Leave blank to reach it by flood. +
+ diff --git a/internal/core/templates/console.html b/internal/core/templates/console.html index 948f441..2dbe8c6 100644 --- a/internal/core/templates/console.html +++ b/internal/core/templates/console.html @@ -21,6 +21,11 @@ +
+ + + Hex hops from your modem to the repeater, in order, same length each (e.g. 11, 22, 33). Leave blank to reach it by flood. +