Merge confirm into console

This commit is contained in:
Jonathon Leight
2026-07-08 07:52:08 -04:00
parent b57e297fd7
commit 98e1ce104e
4 changed files with 215 additions and 13 deletions
+23 -11
View File
@@ -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.")
+131
View File
@@ -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
+32 -2
View File
@@ -13,6 +13,29 @@
Allowed commands list; the server enforces what you're allowed to send.
</div>
{{/* 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}}
<div class="alert alert-warning" role="alert" data-testid="confirm-banner">
This repeater hasn't been confirmed yet. Connect your modem below — MeshTender will send a signed login
to verify it has access. Confirming happens automatically as soon as the repeater replies.
</div>
{{else if and .Repeater.IsAdmin (not .Repeater.Latitude)}}
<div class="alert alert-info d-flex align-items-center" role="alert" data-testid="location-banner">
<div class="flex-fill">This repeater is confirmed, but its location isn't known yet.</div>
<button type="button" class="btn btn-sm btn-primary ms-3" data-fetch-location data-testid="fetch-location">{{template "icon-map-pin" "me-1"}}Fetch location</button>
</div>
{{end}}
{{if and .Repeater.Confirmed .Repeater.AccessKnown (not .Repeater.IsAdmin)}}
<div class="alert alert-warning" role="alert" data-testid="guest-banner">
MeshTender only has <strong>guest</strong> access here. Guest is available to anyone with a blank password,
so there's no point operating at this level — re-run <code>setperm &lt;your key&gt; 3</code> on the repeater
to grant admin, then reconnect.
</div>
{{end}}
<div class="row row-cards">
<div class="col-12 col-lg-8">
<div class="card">
@@ -94,7 +117,14 @@
</div>
</div>
<a class="back-link mt-3" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
<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}}/console">Disable debug</a>
{{else}}
<a class="text-secondary ms-2" href="/repeaters/{{.Repeater.PublicID}}/console?debug=1">Debug: show raw frames</a>
{{end}}
</div>
{{if .ShowConfig}}
<link rel="stylesheet" href="/static/leaflet.css">
@@ -104,7 +134,7 @@
{{end}}
<script nonce="{{.Nonce}}">
window.MESHTENDER_WS = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/repeaters/{{.Repeater.PublicID}}/console/ws";
window.MESHTENDER_WS = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/repeaters/{{.Repeater.PublicID}}/console/ws{{if .Debug}}?debug=1{{end}}";
</script>
<script src="/static/console.js"></script>
{{end}}
+29
View File
@@ -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;
}
});
})();