From 98e1ce104e6f91c276e16e821d9014acbf98cc60 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Wed, 8 Jul 2026 07:52:08 -0400 Subject: [PATCH] Merge confirm into console --- internal/core/console.go | 34 ++++-- internal/core/console_integration_test.go | 131 ++++++++++++++++++++++ internal/core/templates/console.html | 34 +++++- internal/web/static/console.js | 29 +++++ 4 files changed, 215 insertions(+), 13 deletions(-) diff --git a/internal/core/console.go b/internal/core/console.go index 93ef862..67ce862 100644 --- a/internal/core/console.go +++ b/internal/core/console.go @@ -170,6 +170,7 @@ func (s *Handlers) pageConsole(w http.ResponseWriter, r *http.Request) { "Repeater": rep, "Commands": allowed, "ShowConfig": len(configOrgs) > 0, + "Debug": r.URL.Query().Get("debug") == "1", }) } @@ -225,6 +226,15 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { ex.HandleData(data) }) userPathSet := applyUserPath(ex, r, bridge) + debug := r.URL.Query().Get("debug") == "1" + 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). Same aid the + // dedicated confirm page used to offer. + 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)) + }) + } // Group this connection's commands into a session (required for logging). sessionID, err := s.Store.StartConsoleSession(ctx, id, uid) @@ -329,17 +339,19 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { if userPathSet { reportPathOutcome(bridge, lr) } - // A successful admin login proves we reached the repeater, so treat connecting - // from the console as a confirmation (the same as the dedicated confirm flow). - // This is cheap — no extra packets. Fetching the location is deferred to an - // explicit "getloc" request (below) so a plain console session doesn't pay for - // a location round-trip it doesn't need. - if lr.IsAdmin { - if err := s.Store.SetRepeaterConfirmed(ctx, id, uid, lr.IsAdmin, int16(lr.Permissions)); err != nil { - web.LogError(r, "console: save confirmation", err, "repeater_id", id) - } else { - _ = bridge.Status("confirmed", "Repeater confirmed with admin access. ✓") - } + // A successful login proves we reached the repeater, so treat connecting from + // the console as a confirmation (the same as the dedicated confirm flow) — for + // guest access too, which records the access level. This is cheap (no extra + // packets). Fetching the location is deferred to an explicit "getloc" request + // so a plain console session doesn't pay for a location round-trip it doesn't + // need; the page offers a "Fetch location" button that sends it. + if err := s.Store.SetRepeaterConfirmed(ctx, id, uid, lr.IsAdmin, int16(lr.Permissions)); err != nil { + web.LogError(r, "console: save confirmation", err, "repeater_id", id) + } else if lr.IsAdmin { + _ = bridge.Status("confirmed", "Repeater confirmed with admin access. ✓") + } else { + // Guests can't run CLI commands, so warn as the confirm flow did. + _ = bridge.Status("warning", 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())) } } _ = bridge.Status("info", "Connected. Ready for commands.") diff --git a/internal/core/console_integration_test.go b/internal/core/console_integration_test.go index 89cccb3..608b95e 100644 --- a/internal/core/console_integration_test.go +++ b/internal/core/console_integration_test.go @@ -343,6 +343,137 @@ func TestConsoleGetLatUpdatesLocation(t *testing.T) { } } +// TestConsoleGuestLoginConfirms: connecting the console to a repeater that only +// grants GUEST access still records the confirmation (with is_admin=false) and +// warns the user — the same as the dedicated confirm flow did. It must NOT emit a +// "confirmed" (admin) status. +func TestConsoleGuestLoginConfirms(t *testing.T) { + t.Parallel() + st, ctx := coreStore(t) + + var masterKey [32]byte + _, _ = rand.Read(masterKey[:]) + idSvc, err := identity.LoadOrCreate(ctx, st, masterKey) + if err != nil { + t.Fatalf("identity: %v", err) + } + authSvc, err := auth.New(st, st.Pool(), testAuthConfig()) + if err != nil { + t.Fatalf("auth: %v", err) + } + srv, err := NewServer(st, authSvc, idSvc, testConfig()) + if err != nil { + t.Fatalf("server: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + jar, _ := cookiejar.New(nil) + user := seedSession(t, ts, st, ctx, jar, "guestuser") + repeater, err := meshcore.GenerateLocalIdentity(rand.Reader) + if err != nil { + t.Fatalf("repeater identity: %v", err) + } + rep, err := st.CreateRepeater(ctx, &store.Repeater{ + OwnerID: user.ID, Name: "Test", PublicKeyHex: repeater.String(), + RadioFreqHz: 869525000, RadioBwHz: 250000, RadioSF: 11, RadioCR: 5, + }) + if err != nil { + t.Fatalf("create repeater: %v", err) + } + + 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 + for _, c := range cs { + parts = append(parts, c.Name+"="+c.Value) + } + hdr.Set("Cookie", strings.Join(parts, "; ")) + } + dctx, dcancel := context.WithTimeout(ctx, 5*time.Second) + defer dcancel() + ws, _, err := websocket.Dial(dctx, wsURL, &websocket.DialOptions{HTTPHeader: hdr}) + if err != nil { + t.Fatalf("ws dial: %v", err) + } + defer ws.Close(websocket.StatusNormalClosure, "") + + rw, rwcancel := context.WithTimeout(ctx, 15*time.Second) + defer rwcancel() + must := func(err error, msg string) { + if err != nil { + t.Fatalf("%s: %v", msg, err) + } + } + must(ws.Write(rw, websocket.MessageText, []byte(`{"type":"ready"}`)), "ready") + + serverID := idSvc.Local().Identity + shared, err := repeater.SharedSecret(serverID) + must(err, "shared") + + // Reply to login as a guest (is_admin=0), then wait for the warning the console + // emits for guest access. It must never report a "confirmed" (admin) status. + var buf []byte + loggedIn := false + warned := false + for !warned { + typ, data, err := ws.Read(rw) + must(err, "ws read") + if typ == websocket.MessageText { + var m struct{ State, Message string } + if json.Unmarshal(data, &m) == nil { + if m.State == "confirmed" { + t.Fatalf("guest login must not report admin-confirmed, got %q", m.Message) + } + if m.State == "error" { + t.Fatalf("unexpected error status: %s", m.Message) + } + if m.State == "warning" && strings.Contains(m.Message, "GUEST") { + warned = true + } + } + continue + } + buf = append(buf, data...) + frames, rest, _ := hardware.ExtractFrames(buf) + buf = rest + for _, f := range frames { + if f.Command != hardware.KISS_CMD_DATA { + continue + } + pkt, err := meshcore.PacketFromBytes(f.Data) + if err != nil { + continue + } + if pkt.PayloadType() == meshcore.PayloadTypeAnonReq && !loggedIn { + loggedIn = true + resp := make([]byte, 13) + binary.LittleEndian.PutUint32(resp[:4], 1_700_002_000) + resp[6] = 0 // guest (not admin) + resp[7] = 1 + body := append([]byte{0x00, meshcore.PayloadTypeResponse}, resp...) + enc, _ := meshcore.EncryptThenMAC(shared, body) + p := &meshcore.Path{Destination: serverID.Hash()[0], Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]} + payload, _ := p.ToBytes() + lp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypePath, 0), Payload: payload} + raw, _ := lp.ToBytes() + must(ws.Write(rw, websocket.MessageBinary, hardware.EncodeDataFrame(raw)), "login reply") + } + } + } + + // The guest connection still records the confirmation, with guest access. + got, err := st.GetRepeaterForUser(ctx, user.ID, rep.ID) + must(err, "reload repeater") + if !got.Confirmed { + t.Fatal("guest login did not confirm the repeater") + } + if !got.AccessKnown() || got.IsAdmin() { + t.Fatalf("access level = admin?%v known?%v, want guest (known, not admin)", got.IsAdmin(), got.AccessKnown()) + } +} + // TestConsoleAuditFailureRefusesCommand: if a command can't be recorded to the // audit log, the console must refuse to send it to the device rather than execute // an unlogged command. We drop command_log so LogCommand fails, then send a diff --git a/internal/core/templates/console.html b/internal/core/templates/console.html index 6d21cb7..53b506c 100644 --- a/internal/core/templates/console.html +++ b/internal/core/templates/console.html @@ -13,6 +13,29 @@ Allowed commands list; the server enforces what you're allowed to send. +{{/* Confirm/location prompts. Connecting the modem sends a signed login that +confirms access (server-side); these banners reflect the state at page load and +are hidden live by console.js once a confirmed/location status arrives. */}} +{{if not .Repeater.Confirmed}} + +{{else if and .Repeater.IsAdmin (not .Repeater.Latitude)}} + +{{end}} + +{{if and .Repeater.Confirmed .Repeater.AccessKnown (not .Repeater.IsAdmin)}} + +{{end}} +
@@ -94,7 +117,14 @@
-{{template "icon-arrow-left" "me-1"}}Back to dashboard + {{if .ShowConfig}} @@ -104,7 +134,7 @@ {{end}} {{end}} diff --git a/internal/web/static/console.js b/internal/web/static/console.js index 7f91106..b336308 100644 --- a/internal/web/static/console.js +++ b/internal/web/static/console.js @@ -61,6 +61,7 @@ const unsupportedEl = document.getElementById("unsupported"); if (unsupportedEl) unsupportedEl.hidden = false; if (connectBtn) connectBtn.disabled = true; + document.querySelectorAll("[data-fetch-location]").forEach((b) => { b.disabled = true; }); return; // MeshConsole stays defined but never becomes ready (no modem here) } @@ -160,4 +161,32 @@ connectBtn.addEventListener("click", connect); api.connect = connect; // let the config modal (console-config.js) connect too + + // "Fetch location" banner button: query the repeater's coordinates. If the + // modem isn't connected yet, connect first and fetch once the session is ready. + let pendingGetLocation = false; + document.querySelectorAll("[data-fetch-location]").forEach((btn) => { + btn.addEventListener("click", () => { + if (api.ready) { api.getLocation(); return; } + pendingGetLocation = true; + connect(); + }); + }); + document.addEventListener("mesh:ready", () => { + if (pendingGetLocation) { pendingGetLocation = false; api.getLocation(); } + }); + + // Reflect confirm/location progress live: once the server reports the repeater + // confirmed or its location stored, the matching page-load banner is stale. + document.addEventListener("mesh:status", (ev) => { + const state = ev.detail && ev.detail.state; + if (state === "confirmed") { + const b = document.querySelector('[data-testid="confirm-banner"]'); + if (b) b.hidden = true; + } + if (state === "location") { + const b = document.querySelector('[data-testid="location-banner"]'); + if (b) b.hidden = true; + } + }); })();