diff --git a/internal/core/console.go b/internal/core/console.go index cc3e9f7..93ef862 100644 --- a/internal/core/console.go +++ b/internal/core/console.go @@ -391,6 +391,11 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { } } _ = bridge.Status("reply", reply) + // A "get lat"/"get lon" the user ran themselves carries the repeater's + // current coordinate — capture it so the stored location tracks what the + // device reports, without a separate fetch. Keyed on the resolved catalog + // token (not raw text) so only these two commands trigger it. + s.storeCoordFromReply(ctx, r, cmd, reply, id, bridge) case errors.Is(err, mesh.ErrNoReply): _ = bridge.Status("noreply", "No reply received after several tries — the command may still have run.") default: @@ -415,3 +420,32 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { } } } + +// storeCoordFromReply persists the coordinate reported by a "get lat"/"get lon" +// command the user ran directly in the console, so the stored location tracks +// what the device reports without a separate fetch. It matches on the resolved +// catalog token (not the raw typed text) so only those two commands trigger it, +// and no-ops for any other command or an unparseable reply. A single "get lat" +// updates only the latitude — SetRepeaterLatitude/Longitude write one column so +// reading one coordinate never clobbers the other. On success it emits a +// "location" status so an open config panel refreshes its region commands. +func (s *Handlers) storeCoordFromReply(ctx context.Context, r *http.Request, cmd *store.Command, reply string, id int64, bridge *wsbridge.Conn) { + value, ok := parseLocationFloat(reply) + if !ok { + return + } + var update func(context.Context, int64, float64) error + switch commandPrefix(cmd.Template) { + case "get lat": + update = s.Store.SetRepeaterLatitude + case "get lon": + update = s.Store.SetRepeaterLongitude + default: + return + } + if err := update(ctx, id, value); err != nil { + web.LogError(r, "console: store coordinate", err, "repeater_id", id, "command_id", cmd.ID) + return + } + _ = bridge.Status("location", "Location updated from the repeater.") +} diff --git a/internal/core/console_integration_test.go b/internal/core/console_integration_test.go index a7e2a09..89cccb3 100644 --- a/internal/core/console_integration_test.go +++ b/internal/core/console_integration_test.go @@ -189,6 +189,160 @@ func TestConsoleRoundTrip(t *testing.T) { } } +// TestConsoleGetLatUpdatesLocation: running "get lat" directly in the console +// captures the reported coordinate into the stored location, updating only the +// latitude (leaving longitude untouched). The test plays browser, modem, and +// repeater as in TestConsoleRoundTrip. +func TestConsoleGetLatUpdatesLocation(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, "geographer") + 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") + must(ws.Write(rw, websocket.MessageText, []byte(`{"type":"cmd","text":"get lat"}`)), "cmd") + + serverID := idSvc.Local().Identity + shared, err := repeater.SharedSecret(serverID) + must(err, "shared") + const replyText = "> 37.7749" + + // Reply to login (flood) then to the "get lat" command. The server stamps the + // latitude after pushing the "reply" and emits a "location" status; waiting for + // that status guarantees the DB write has completed before we assert. + var buf []byte + loggedIn := false + cmdReplied := false + located := false + for !located { + 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 == "error" || m.State == "denied" || m.State == "noreply" { + t.Fatalf("unexpected status %q: %s", m.State, m.Message) + } + if m.State == "location" { + located = 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 + } + switch pkt.PayloadType() { + case meshcore.PayloadTypeAnonReq: + if loggedIn { + continue + } + loggedIn = true + resp := make([]byte, 13) + binary.LittleEndian.PutUint32(resp[:4], 1_700_002_000) + resp[6] = 1 // admin + resp[7] = 3 + 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") + case meshcore.PayloadTypeTxtMsg: + if cmdReplied { + continue + } + tm, err := meshcore.TextMessageFromBytes(pkt.Payload) + must(err, "parse text message") + plain := tm.Decrypt(shared) + if plain == nil || !strings.HasPrefix(string(plain[5:]), "get lat") { + t.Fatalf("decoded command = %q, want get lat", string(plain[5:])) + } + cmdReplied = true + replyPlain := meshcore.BuildTextPlaintext(time.Unix(1_700_002_000, 0), 1<<2, []byte(replyText)) + rtm, err := meshcore.NewTextMessage(repeater, serverID, replyPlain, shared) + must(err, "reply text message") + payload, _ := rtm.ToBytes() + rpkt := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypeTxtMsg, 0), Payload: payload} + raw, _ := rpkt.ToBytes() + must(ws.Write(rw, websocket.MessageBinary, hardware.EncodeDataFrame(raw)), "send reply") + } + } + } + + // Only the latitude should be stored; longitude stays NULL (we never read it). + got, err := st.GetRepeaterForUser(ctx, user.ID, rep.ID) + must(err, "reload repeater") + if got.Latitude == nil || *got.Latitude != 37.7749 { + t.Fatalf("stored latitude = %v, want 37.7749", got.Latitude) + } + if got.Longitude != nil { + t.Fatalf("stored longitude = %v, want nil (get lat must not touch longitude)", got.Longitude) + } +} + // 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/store/repeaters.go b/internal/store/repeaters.go index 71eaeee..cee4d08 100644 --- a/internal/store/repeaters.go +++ b/internal/store/repeaters.go @@ -275,6 +275,29 @@ func (s *Store) SetRepeaterLocation(ctx context.Context, repeaterID int64, lat, return nil } +// SetRepeaterLatitude stores only a repeater's latitude, leaving the longitude +// untouched. Used when the console reads a single coordinate ("get lat"), which +// arrives independently of the other and must not clobber it. +func (s *Store) SetRepeaterLatitude(ctx context.Context, repeaterID int64, lat float64) error { + _, err := s.pool.Exec(ctx, + `UPDATE repeaters SET latitude = $2 WHERE id = $1`, repeaterID, lat) + if err != nil { + return fmt.Errorf("set latitude: %w", err) + } + return nil +} + +// SetRepeaterLongitude stores only a repeater's longitude, leaving the latitude +// untouched. See SetRepeaterLatitude. +func (s *Store) SetRepeaterLongitude(ctx context.Context, repeaterID int64, lon float64) error { + _, err := s.pool.Exec(ctx, + `UPDATE repeaters SET longitude = $2 WHERE id = $1`, repeaterID, lon) + if err != nil { + return fmt.Errorf("set longitude: %w", err) + } + return nil +} + // DeleteRepeaterOwned deletes a repeater only if owned by ownerID. func (s *Store) DeleteRepeaterOwned(ctx context.Context, ownerID, repeaterID int64) error { tag, err := s.pool.Exec(ctx, `DELETE FROM repeaters WHERE id = $1 AND owner_id = $2`, repeaterID, ownerID) diff --git a/internal/store/repeaters_location_test.go b/internal/store/repeaters_location_test.go new file mode 100644 index 0000000..12dc508 --- /dev/null +++ b/internal/store/repeaters_location_test.go @@ -0,0 +1,66 @@ +package store + +import ( + "strings" + "testing" +) + +// TestSetRepeaterCoordIndependent pins that SetRepeaterLatitude and +// SetRepeaterLongitude each write only their own column. The console reads the +// two coordinates independently ("get lat" / "get lon"), so writing one must +// never clobber the other — otherwise a single "get lat" would blank the stored +// longitude. +func TestSetRepeaterCoordIndependent(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + + owner, err := st.CreateUser(ctx, "owner", "") + if err != nil { + t.Fatalf("create owner: %v", err) + } + rep, err := st.CreateRepeater(ctx, &Repeater{ + OwnerID: owner.ID, Name: "rep", PublicKeyHex: strings.Repeat("a", 64), + RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5, + }) + if err != nil { + t.Fatalf("create repeater: %v", err) + } + + read := func() (lat, lon *float64) { + t.Helper() + r, err := st.GetRepeaterOwned(ctx, owner.ID, rep.ID) + if err != nil { + t.Fatalf("get repeater: %v", err) + } + return r.Latitude, r.Longitude + } + + // A fresh repeater has neither coordinate. + if lat, lon := read(); lat != nil || lon != nil { + t.Fatalf("new repeater should be unlocated, got lat=%v lon=%v", lat, lon) + } + + // Setting latitude alone leaves longitude NULL. + if err := st.SetRepeaterLatitude(ctx, rep.ID, 37.7749); err != nil { + t.Fatalf("set latitude: %v", err) + } + if lat, lon := read(); lat == nil || *lat != 37.7749 || lon != nil { + t.Fatalf("after set latitude: got lat=%v lon=%v, want lat=37.7749 lon=nil", lat, lon) + } + + // Setting longitude alone must not disturb the stored latitude. + if err := st.SetRepeaterLongitude(ctx, rep.ID, -122.4194); err != nil { + t.Fatalf("set longitude: %v", err) + } + if lat, lon := read(); lat == nil || *lat != 37.7749 || lon == nil || *lon != -122.4194 { + t.Fatalf("after set longitude: got lat=%v lon=%v, want lat=37.7749 lon=-122.4194", lat, lon) + } + + // Overwriting latitude keeps longitude intact. + if err := st.SetRepeaterLatitude(ctx, rep.ID, 40.0); err != nil { + t.Fatalf("overwrite latitude: %v", err) + } + if lat, lon := read(); lat == nil || *lat != 40.0 || lon == nil || *lon != -122.4194 { + t.Fatalf("after overwrite latitude: got lat=%v lon=%v, want lat=40.0 lon=-122.4194", lat, lon) + } +}