User-specified direct path

This commit is contained in:
Jonathon Leight
2026-07-01 20:09:36 -04:00
parent 42fd49f172
commit d7c236aaf7
10 changed files with 319 additions and 15 deletions
+38
View File
@@ -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())
+8 -3
View File
@@ -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.")
+6
View File
@@ -22,6 +22,12 @@
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>
+5
View File
@@ -21,6 +21,11 @@
<div id="unsupported" class="alert alert-danger" 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="mb-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" type="button">{{template "icon-plug" "me-1"}}Connect modem</button>
<ul id="log" class="evlog console mt-3" role="log" aria-live="polite" aria-label="Console activity"></ul>
<form id="cmdform" class="input-group mt-3" autocomplete="off" hidden>
+28 -5
View File
@@ -136,8 +136,16 @@ func (e *Exchanger) exchange(ctx context.Context, build func(ts time.Time, attem
func (e *Exchanger) Login(ctx context.Context, password string, onAttempt func(attempt, max int)) (*LoginResponse, error) {
var result *LoginResponse
err := e.exchange(ctx,
func(ts time.Time, _ int) ([]byte, error) {
// Login is always flood: it's the first contact, before any path is known.
func(ts time.Time, attempt int) ([]byte, error) {
// With a path known (learned or user-supplied), route the login directly
// on early attempts; fall back to flood on later ones in case it's stale.
e.mu.Lock()
direct := e.pathKnown && attempt <= e.directThreshold()
path, pathLen := e.outPath, e.outPathLen
e.mu.Unlock()
if direct {
return BuildLoginPacketDirect(e.server, e.repeater, password, ts, path, pathLen)
}
return BuildLoginPacket(e.server, e.repeater, password, ts)
},
func(raw []byte) bool {
@@ -153,15 +161,30 @@ func (e *Exchanger) Login(ctx context.Context, password string, onAttempt func(a
if err != nil {
return nil, err
}
// Learn the route home so subsequent commands can use direct routing.
e.mu.Lock()
e.pathKnown = true
e.outPath = result.OutPath
e.outPathLen = result.OutPathLen
// Only adopt the route from the reply when it carried one (a PATH reply from a
// flooded login). A direct login answered by a plain RESPONSE has no path, so
// keep the path we already had (e.g. one the user supplied) rather than wiping it.
if result.FromPath {
e.outPath = result.OutPath
e.outPathLen = result.OutPathLen
}
e.mu.Unlock()
return result, nil
}
// SetPath pre-seeds the route to the repeater (e.g. a user-supplied path), so the
// login and subsequent commands route directly from the start, with flood as the
// fallback. path/pathLen are as in LoginResponse.OutPath/OutPathLen.
func (e *Exchanger) SetPath(path []byte, pathLen byte) {
e.mu.Lock()
e.pathKnown = true
e.outPath = path
e.outPathLen = pathLen
e.mu.Unlock()
}
// directThreshold is the highest attempt number that uses direct routing once a
// path is known; later attempts fall back to flood in case the path is stale.
func (e *Exchanger) directThreshold() int { return (e.tries + 1) / 2 }
+19 -5
View File
@@ -49,10 +49,23 @@ type LoginResponse struct {
}
// BuildLoginPacket builds the raw MeshCore LoRa packet bytes that perform an
// anonymous login to repeater using server's identity. password may be empty
// when access was granted via `setperm <pubkey> 3` (pubkey ACL). now is the
// timestamp embedded in the request (the repeater uses it for clock sync).
// anonymous login to repeater using server's identity, sent as flood (first
// contact, before any path is known). password may be empty when access was
// granted via `setperm <pubkey> 3` (pubkey ACL). now is the timestamp embedded
// in the request (the repeater uses it for clock sync).
func BuildLoginPacket(server meshcore.LocalIdentity, repeater meshcore.Identity, password string, now time.Time) ([]byte, error) {
return buildLoginPacket(server, repeater, password, now, meshcore.RouteTypeFlood, floodPathLen, nil)
}
// BuildLoginPacketDirect builds an anonymous login routed directly over a known
// path (path/pathLen as in LoginResponse.OutPath/OutPathLen, or supplied by the
// user) rather than flooded — useful for distant repeaters where the flood login
// is unreliable.
func BuildLoginPacketDirect(server meshcore.LocalIdentity, repeater meshcore.Identity, password string, now time.Time, path []byte, pathLen byte) ([]byte, error) {
return buildLoginPacket(server, repeater, password, now, meshcore.RouteTypeDirect, pathLen, path)
}
func buildLoginPacket(server meshcore.LocalIdentity, repeater meshcore.Identity, password string, now time.Time, routeType, pathLen byte, path []byte) ([]byte, error) {
shared, err := server.SharedSecret(repeater)
if err != nil {
return nil, fmt.Errorf("derive shared secret: %w", err)
@@ -81,8 +94,9 @@ func BuildLoginPacket(server meshcore.LocalIdentity, repeater meshcore.Identity,
}
pkt := &meshcore.Packet{
Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypeAnonReq, 0),
PathLength: floodPathLen,
Header: meshcore.MakeHeader(routeType, meshcore.PayloadTypeAnonReq, 0),
PathLength: pathLen,
Path: path,
Payload: payload,
}
raw, err := pkt.ToBytes()
+56
View File
@@ -0,0 +1,56 @@
package mesh
import (
"encoding/hex"
"errors"
"fmt"
"strings"
)
// maxPathHops is the largest hop count a MeshCore path_len byte can describe: the
// count is the low 6 bits (0x3f).
const maxPathHops = 63
// ParsePath parses a user-entered repeater path into the raw path bytes and the
// MeshCore path_len descriptor byte. The input is comma-separated hex hops, each
// the same byte length (1, 2, or 3 bytes), e.g. "11, 22, 33" or "1111, 2222".
// Hops are listed in path order (from the modem outward to the repeater).
//
// Empty/whitespace input returns (nil, 0, nil) — "no path", not an error. The
// descriptor is (size-1)<<6 | count, matching floodPathLen and unwrapPathReturn.
func ParsePath(s string) (path []byte, pathLen byte, err error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, 0, nil
}
var hops [][]byte
size := -1
for _, raw := range strings.Split(s, ",") {
h := strings.TrimSpace(raw)
if h == "" {
return nil, 0, errors.New("a path hop is empty — use hex hops separated by commas, e.g. 11, 22, 33")
}
b, decErr := hex.DecodeString(h)
if decErr != nil {
return nil, 0, fmt.Errorf("hop %q isn't valid hex", h)
}
if len(b) < 1 || len(b) > 3 {
return nil, 0, fmt.Errorf("hop %q must be 1, 2, or 3 bytes (2, 4, or 6 hex digits)", h)
}
if size == -1 {
size = len(b)
} else if len(b) != size {
return nil, 0, errors.New("all path hops must be the same length")
}
hops = append(hops, b)
}
if len(hops) > maxPathHops {
return nil, 0, fmt.Errorf("too many hops (%d) — the maximum is %d", len(hops), maxPathHops)
}
path = make([]byte, 0, len(hops)*size)
for _, b := range hops {
path = append(path, b...)
}
pathLen = byte((size-1)<<6 | len(hops)) //nolint:gosec // G115: size≤3 and hops≤63, so the value is ≤191 and fits a byte
return path, pathLen, nil
}
+135
View File
@@ -0,0 +1,135 @@
package mesh
import (
"bytes"
"context"
"testing"
"time"
meshcore "github.com/meshcore-go/meshcore-go"
)
func TestParsePath(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
path []byte
pathLen byte
wantErr bool
}{
{"empty", "", nil, 0, false},
{"whitespace", " ", nil, 0, false},
{"one-byte hops", "11, 22, 33", []byte{0x11, 0x22, 0x33}, 0x03, false}, // size 1, count 3
{"no spaces", "aa,bb", []byte{0xaa, 0xbb}, 0x02, false}, // size 1, count 2
{"two-byte hops", "1111, 2222", []byte{0x11, 0x11, 0x22, 0x22}, (2-1)<<6 | 2, false},
{"three-byte hops", "111111, 222222", []byte{0x11, 0x11, 0x11, 0x22, 0x22, 0x22}, (3-1)<<6 | 2, false},
{"single hop", "a1b2c3", []byte{0xa1, 0xb2, 0xc3}, (3-1)<<6 | 1, false},
{"mixed lengths", "11, 2222", nil, 0, true},
{"bad hex", "11, zz", nil, 0, true},
{"too many bytes per hop", "11223344", nil, 0, true},
{"empty hop", "11,,22", nil, 0, true},
{"odd hex", "111", nil, 0, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
path, pathLen, err := ParsePath(c.in)
if c.wantErr {
if err == nil {
t.Fatalf("ParsePath(%q) = (%x, %d, nil), want error", c.in, path, pathLen)
}
return
}
if err != nil {
t.Fatalf("ParsePath(%q): %v", c.in, err)
}
if !bytes.Equal(path, c.path) || pathLen != c.pathLen {
t.Errorf("ParsePath(%q) = (%x, %#x), want (%x, %#x)", c.in, path, pathLen, c.path, c.pathLen)
}
})
}
}
func TestParsePathTooManyHops(t *testing.T) {
t.Parallel()
var b []byte
for i := 0; i < maxPathHops+1; i++ {
if i > 0 {
b = append(b, ',')
}
b = append(b, '0', '1')
}
if _, _, err := ParsePath(string(b)); err == nil {
t.Fatalf("want error for %d hops", maxPathHops+1)
}
}
// TestLoginUsesPresetPath verifies SetPath makes Login route directly with the
// supplied path, and that a direct RESPONSE reply (no path of its own) does not
// wipe the preset path — the subsequent command still routes direct via it.
func TestLoginUsesPresetPath(t *testing.T) {
t.Parallel()
server := mustIdentity(t)
repeater := mustIdentity(t)
shared, _ := repeater.SharedSecret(server.Identity)
userPath := []byte{0x11, 0x22, 0x33} // 3 one-byte hops
userPathLen := byte(0x03) // size 1, count 3
var loginRoute byte
var loginPath []byte
var cmdRoute byte
var cmdPath []byte
fm := &routeFakeModem{}
ex := NewExchanger(fm, server, repeater.Identity, 0, time.Millisecond, 4)
ex.SetPath(userPath, userPathLen)
fm.reply = func(raw []byte) {
pkt, err := meshcore.PacketFromBytes(raw)
if err != nil {
return
}
switch pkt.PayloadType() {
case meshcore.PayloadTypeAnonReq:
loginRoute = pkt.RouteType()
loginPath = append([]byte(nil), pkt.Path...)
// Reply as a direct RESPONSE (carries no return path of its own).
resp := make([]byte, 13)
resp[6] = 1 // admin
resp[7] = 3
enc, _ := meshcore.EncryptThenMAC(shared, resp)
r := &meshcore.Response{Destination: server.Hash()[0], Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]}
payload, _ := r.ToBytes()
rp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeDirect, meshcore.PayloadTypeResponse, 0), Payload: payload}
b, _ := rp.ToBytes()
ex.HandleData(b)
case meshcore.PayloadTypeTxtMsg:
cmdRoute = pkt.RouteType()
cmdPath = append([]byte(nil), pkt.Path...)
plain := meshcore.BuildTextPlaintext(time.Unix(1, 0), 1<<2, []byte("> ok"))
tm, _ := meshcore.NewTextMessage(repeater, server.Identity, plain, shared)
payload, _ := tm.ToBytes()
rp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypeTxtMsg, 0), Payload: payload}
b, _ := rp.ToBytes()
ex.HandleData(b)
}
}
lr, err := ex.Login(context.Background(), "", nil)
if err != nil {
t.Fatalf("Login: %v", err)
}
if !lr.IsAdmin {
t.Fatalf("login not admin")
}
if loginRoute != meshcore.RouteTypeDirect {
t.Errorf("login route = %d, want direct (%d)", loginRoute, meshcore.RouteTypeDirect)
}
if !bytes.Equal(loginPath, userPath) {
t.Errorf("login path = %x, want %x", loginPath, userPath)
}
if _, err := ex.Command(context.Background(), "get lat", nil); err != nil {
t.Fatalf("Command: %v", err)
}
if cmdRoute != meshcore.RouteTypeDirect || !bytes.Equal(cmdPath, userPath) {
t.Errorf("command routed %d path %x, want direct with preset path %x (preset path was wiped)", cmdRoute, cmdPath, userPath)
}
}
+12 -1
View File
@@ -9,6 +9,17 @@
const input = document.getElementById("cmdinput");
const sendBtn = document.getElementById("cmdsend");
// 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)) {
document.getElementById("unsupported").hidden = false;
connectBtn.disabled = true;
@@ -83,7 +94,7 @@
keepReading = true;
addLog("info", "Serial port open. Connecting to server…");
ws = new WebSocket(window.MESHTENDER_WS);
ws = new WebSocket(wsURL());
ws.binaryType = "arraybuffer";
ws.onopen = () => {
+12 -1
View File
@@ -6,6 +6,17 @@
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)) {
document.getElementById("unsupported").hidden = false;
connectBtn.disabled = true;
@@ -57,7 +68,7 @@
keepReading = true;
addLog("info", "Serial port open (115200 8N1). Connecting to server…");
ws = new WebSocket(window.MESHTENDER_WS);
ws = new WebSocket(wsURL());
ws.binaryType = "arraybuffer";
ws.onopen = () => {