mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-09 13:15:33 +00:00
Merge confirm into console
This commit is contained in:
+23
-11
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <your key> 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}}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user