diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index dcdea2fe6..e59e54a2c 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -9,7 +9,7 @@ Open-source, self-hosted MeshCore mesh network packet analyzer. Community altern - **Frontend**: SPA, vanilla HTML/CSS/JS, Leaflet maps, WebSocket live feed, Canvas animations - **Backend**: Node.js + Express + better-sqlite3 + ws + mqtt - **Decoder**: Custom `decoder.js` (from MeshCore Packet.h spec) -- **Data**: SQLite, MQTT ingestion, REST API, manual packet injection +- **Data**: SQLite, MQTT ingestion, REST API ## Architecture @@ -19,7 +19,6 @@ The `@michaelhart/meshcore-decoder` npm library has a path parsing bug — treat ### Packet Ingestion - MQTT subscriber (configurable broker/topic) - Companion bridge (BLE → MQTT via `meshcore_observer.py`) -- POST `/api/packets` for manual injection - WebSocket broadcast to all connected clients ### Channel Decryption @@ -129,7 +128,6 @@ meshcore-analyzer/ │ ├── live.js/css (live view + VCR) │ └── vendor/ (third-party libs) └── tools/ - ├── generate-packets.js ├── e2e-test.js └── frontend-test.js ``` diff --git a/cmd/server/apikey_security_test.go b/cmd/server/apikey_security_test.go index 49913797b..a133e9542 100644 --- a/cmd/server/apikey_security_test.go +++ b/cmd/server/apikey_security_test.go @@ -52,7 +52,7 @@ func TestRequireAPIKey_RejectsWeakKey(t *testing.T) { w.WriteHeader(http.StatusOK) })) - req := httptest.NewRequest("POST", "/api/packets", nil) + req := httptest.NewRequest("POST", "/api/perf/reset", nil) req.Header.Set("X-API-Key", "test") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) @@ -69,7 +69,7 @@ func TestRequireAPIKey_AcceptsStrongKey(t *testing.T) { w.WriteHeader(http.StatusOK) })) - req := httptest.NewRequest("POST", "/api/packets", nil) + req := httptest.NewRequest("POST", "/api/perf/reset", nil) req.Header.Set("X-API-Key", strongKey) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) @@ -85,7 +85,7 @@ func TestRequireAPIKey_EmptyKeyDisablesEndpoints(t *testing.T) { w.WriteHeader(http.StatusOK) })) - req := httptest.NewRequest("POST", "/api/packets", nil) + req := httptest.NewRequest("POST", "/api/perf/reset", nil) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) @@ -100,7 +100,7 @@ func TestRequireAPIKey_WrongKeyUnauthorized(t *testing.T) { w.WriteHeader(http.StatusOK) })) - req := httptest.NewRequest("POST", "/api/packets", nil) + req := httptest.NewRequest("POST", "/api/perf/reset", nil) req.Header.Set("X-API-Key", "wrong-key-entirely-here") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index 8b086bcb4..4494e3fe3 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -4,7 +4,6 @@ import ( "crypto/sha256" "encoding/binary" "encoding/hex" - "encoding/json" "fmt" "math" "strings" @@ -720,15 +719,6 @@ func ComputeContentHash(rawHex string) string { return hex.EncodeToString(h[:])[:16] } -// PayloadJSON serializes the payload to JSON for DB storage. -func PayloadJSON(p *Payload) string { - b, err := json.Marshal(p) - if err != nil { - return "{}" - } - return string(b) -} - // ValidateAdvert checks decoded advert data before DB insertion. func ValidateAdvert(p *Payload) (bool, string) { if p == nil || p.Error != "" { diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index bd418cf7c..6cd374fae 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -66,7 +66,6 @@ func routeDescriptions() map[string]routeMeta { {Name: "search", Description: "Full-text search", Type: "string"}, {Name: "groupByHash", Description: "Group duplicate packets by hash", Type: "boolean"}, }}, - "POST /api/packets": {Summary: "Ingest a packet", Description: "Submit a raw packet for decoding and storage.", Tag: "packets", Auth: true}, "GET /api/packets/{id}": {Summary: "Get packet detail", Tag: "packets"}, "GET /api/packets/timestamps": {Summary: "Get packet timestamp ranges", Tag: "packets"}, "POST /api/packets/observations": {Summary: "Batch submit observations", Description: "Submit multiple observer sightings for existing packets.", Tag: "packets"}, diff --git a/cmd/server/openapi_test.go b/cmd/server/openapi_test.go index a9fa6a0da..1691f331f 100644 --- a/cmd/server/openapi_test.go +++ b/cmd/server/openapi_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/gorilla/mux" ) func TestOpenAPISpecEndpoint(t *testing.T) { @@ -139,4 +141,27 @@ func TestExtractPathParams(t *testing.T) { } } - +// The served spec is built by walking the router, so a description left in +// routeDescriptions for a removed route (like "POST /api/packets") never +// shows up there and would rot silently. Every description must name a +// registered method and path. +func TestOpenAPIDescriptionsHaveRoutes(t *testing.T) { + _, router := setupTestServer(t) + registered := map[string]bool{} + router.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { + path, err := route.GetPathTemplate() + if err != nil { + return nil + } + methods, _ := route.GetMethods() + for _, m := range methods { + registered[m+" "+path] = true + } + return nil + }) + for key := range routeDescriptions() { + if !registered[key] { + t.Errorf("routeDescriptions has %q, but no such route is registered", key) + } + } +} diff --git a/cmd/server/post_packets_removed_223_test.go b/cmd/server/post_packets_removed_223_test.go new file mode 100644 index 000000000..90d931162 --- /dev/null +++ b/cmd/server/post_packets_removed_223_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// POST /api/packets (#223) inserted into transmissions, observers and +// observations on the server's mode=ro handle (#1283), so every call failed +// with a 500 carrying the raw SQLite error. It was removed: ingest goes +// through MQTT and cmd/ingestor. These tests pin what is left; the source +// guard against packet-table writes is TestServerHasNoPacketTableWrites +// (readonly_invariant_test.go). + +const removedPostAPIKey = "test-secret-key-strong-enough" + +// removedPostPacketBody is a valid FLOOD/ADVERT body that the old handler +// accepted (it decoded, then failed on the INSERT). +const removedPostPacketBody = `{"hex":"110011223344556677889900AABBCCDD","observer":"obs1","snr":5.5,"rssi":-72}` + +// readOnlyPacketServer seeds a file DB, opens it the way main.go does +// (OpenDB, mode=ro) and registers the API routes with a valid API key. +func readOnlyPacketServer(t *testing.T) (dbPath string, router *mux.Router) { + t.Helper() + dbPath = filepath.Join(t.TempDir(), "ro.db") + now := time.Now().UTC() + seedTestDBRows(t, dbPath, 3, 2, func(i int) (string, int64) { + ts := now.Add(-time.Duration(i) * time.Minute) + return ts.Format(time.RFC3339), ts.Unix() + }) + db, err := OpenDB(dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.conn.Close() }) + srv := NewServer(db, &Config{Port: 3000, APIKey: removedPostAPIKey}, NewHub()) + router = mux.NewRouter() + srv.RegisterRoutes(router) + return dbPath, router +} + +// packetTableCounts reads the row counts of the tables the old handler wrote, +// through a separate connection. +func packetTableCounts(t *testing.T, dbPath string) map[string]int { + t.Helper() + conn, err := sql.Open("sqlite", "file:"+dbPath+"?mode=ro") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + out := map[string]int{} + for _, table := range []string{"transmissions", "observations", "observers"} { + var n int + if err := conn.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil { + t.Fatalf("count %s: %v", table, err) + } + out[table] = n + } + return out +} + +func postRemovedPacket(router http.Handler) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", "/api/packets", strings.NewReader(removedPostPacketBody)) + req.Header.Set("X-API-Key", removedPostAPIKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +// On the API router, POST /api/packets now hits the GET route's path with the +// wrong method: gorilla/mux answers 405 before any handler or DB access. A +// valid key and a decodable body make no difference, and the read-only DB is +// left untouched. +func TestPostPacketsRemovedReturns405OnReadOnlyDB(t *testing.T) { + dbPath, router := readOnlyPacketServer(t) + before := packetTableCounts(t, dbPath) + + w := postRemovedPacket(router) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST /api/packets: want 405, got %d (body: %q)", w.Code, w.Body.String()) + } + if body := strings.ToLower(w.Body.String()); strings.Contains(body, "sqlite") || strings.Contains(body, "readonly") || strings.Contains(body, "insert") { + t.Errorf("response leaks database error text: %q", w.Body.String()) + } + if after := packetTableCounts(t, dbPath); fmt.Sprint(after) != fmt.Sprint(before) { + t.Errorf("packet tables changed: before %v, after %v", before, after) + } +} + +// The other /api/packets routes keep their registration. Matching does not +// run handlers, so this pins only the routing, not handler output. +func TestPacketsRoutesSurviveRemoval(t *testing.T) { + _, router := readOnlyPacketServer(t) + for _, c := range []struct{ method, path string }{ + {"GET", "/api/packets"}, + {"GET", "/api/packets/timestamps"}, + {"POST", "/api/packets/observations"}, + {"GET", "/api/packets/abc123"}, + {"GET", "/api/packets/abc123/path"}, + {"POST", "/api/decode"}, + } { + var m mux.RouteMatch + if !router.Match(httptest.NewRequest(c.method, c.path, nil), &m) || m.MatchErr != nil { + t.Errorf("%s %s: no longer routed (err %v)", c.method, c.path, m.MatchErr) + } + } +} + +// The served OpenAPI spec keeps GET /api/packets and drops the POST. +func TestOpenAPISpecHasNoPostPackets(t *testing.T) { + _, router := readOnlyPacketServer(t) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/spec", nil)) + if w.Code != http.StatusOK { + t.Fatalf("GET /api/spec: want 200, got %d", w.Code) + } + var spec struct { + Paths map[string]map[string]json.RawMessage `json:"paths"` + } + if err := json.Unmarshal(w.Body.Bytes(), &spec); err != nil { + t.Fatal(err) + } + ops := spec.Paths["/api/packets"] + if _, ok := ops["get"]; !ok { + t.Errorf("/api/packets lost its get operation: %v", ops) + } + if _, ok := ops["post"]; ok { + t.Errorf("/api/packets still documents a post operation") + } +} + +// main.go mounts a catch-all SPA handler after the API routes. With it in +// place, gorilla/mux lets the catch-all win over the method mismatch, so a +// POST to the removed endpoint is served index.html like any other unmatched +// path (pre-existing fallback behaviour, not specific to #223). Pin that it +// is the SPA page, not JSON, and that nothing is written. +func TestPostPacketsRemovedFallsThroughToSPAInProductionRouter(t *testing.T) { + dbPath, router := readOnlyPacketServer(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("SPA"), 0o644); err != nil { + t.Fatal(err) + } + router.PathPrefix("/").Handler(wsOrStatic(NewHub(), spaHandler(dir, http.FileServer(http.Dir(dir))))) + before := packetTableCounts(t, dbPath) + + w := postRemovedPacket(router) + if w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/html") || w.Body.String() != "SPA" { + t.Fatalf("POST /api/packets with SPA fallback: want 200 text/html index.html, got %d %q %q", + w.Code, w.Header().Get("Content-Type"), w.Body.String()) + } + if after := packetTableCounts(t, dbPath); fmt.Sprint(after) != fmt.Sprint(before) { + t.Errorf("packet tables changed: before %v, after %v", before, after) + } +} diff --git a/cmd/server/readonly_invariant_test.go b/cmd/server/readonly_invariant_test.go index 398cc3b35..6e8b3e25e 100644 --- a/cmd/server/readonly_invariant_test.go +++ b/cmd/server/readonly_invariant_test.go @@ -205,39 +205,45 @@ func nodeTableWritePattern(verb, trailer string) *regexp.Regexp { } // txTableWritePattern matches DML against the ingestor-owned packet tables -// (transmissions, observations) and the tables hung off them, in the shapes -// nodeTableWritePattern covers. +// (transmissions, observations, observers, dropped_packets) and the tables +// hung off them, in the shapes nodeTableWritePattern covers. func txTableWritePattern(verb string) *regexp.Regexp { - const table = "[\"`\\[]?(transmissions|observations|ping_triggers|route_mask_changes)[\"`\\]]?" + const table = "[\"`\\[]?(transmissions|observations|observers|dropped_packets|ping_triggers|route_mask_changes)[\"`\\]]?" return regexp.MustCompile(`(?i)` + verb + `\s+` + table + `\b`) } -// TestServerHasNoPacketTableWrites enforces #215: the content-hash migration -// used to UPDATE transmissions/observations and DELETE transmissions from the -// server's mode=ro handle. Every statement failed, was logged as a collision, -// and was retried at every start. cmd/server/ may not rewrite the packet -// tables; that is the ingestor's job (cmd/ingestor/hash_migrate.go). -// -// UPDATE, DELETE and REPLACE are forbidden in every server source file. The -// hash migration files (hash_migrate*.go) may not issue any statement at all: -// no INSERT either, and no transaction or Exec on the connection. +// packetTableWritePatterns are the statements TestServerHasNoPacketTableWrites +// forbids in every server source file. +func packetTableWritePatterns() []*regexp.Regexp { + return []*regexp.Regexp{ + txTableWritePattern(`INSERT\s+(OR\s+\w+\s+)?INTO`), + txTableWritePattern(`UPDATE(\s+OR\s+\w+)?`), + txTableWritePattern(`DELETE\s+FROM`), + txTableWritePattern(`REPLACE\s+INTO`), + } +} + +// TestServerHasNoPacketTableWrites keeps cmd/server/ off the packet tables; +// writing them is the ingestor's job (#1283). +// - #215: the content-hash migration UPDATEd transmissions/observations and +// DELETEd transmissions from the server's mode=ro handle. Every statement +// failed, was logged as a collision, and was retried at every start. It +// now lives in cmd/ingestor/hash_migrate.go. +// - #223: POST /api/packets INSERTed into transmissions, observers and +// observations on the same handle and answered 500. It was removed. // -// Known gap, outside this change: handlePostPacket (routes.go) INSERTs into -// transmissions/observations on the same read-only handle. It is not part of -// the hash migration and is left alone here; INSERT is therefore only -// checked in the migration files. +// INSERT, UPDATE, DELETE and REPLACE on these tables are forbidden in every +// server source file, with no exceptions: the documented write exceptions +// (ping_score_history.go, its own database; backup.go, VACUUM INTO) do not +// touch them. The hash migration files (hash_migrate*.go) may not issue any +// statement at all: no transaction or Exec on the connection either. func TestServerHasNoPacketTableWrites(t *testing.T) { entries, err := os.ReadDir(".") if err != nil { t.Fatal(err) } - everywhere := []*regexp.Regexp{ - txTableWritePattern(`UPDATE(\s+OR\s+\w+)?`), - txTableWritePattern(`DELETE\s+FROM`), - txTableWritePattern(`REPLACE\s+INTO`), - } + everywhere := packetTableWritePatterns() migrationOnly := []*regexp.Regexp{ - txTableWritePattern(`INSERT\s+(OR\s+\w+\s+)?INTO`), regexp.MustCompile(`\.conn\.(Begin|BeginTx|Exec|ExecContext|Prepare|PrepareContext)\s*\(`), } var violations []string @@ -255,38 +261,55 @@ func TestServerHasNoPacketTableWrites(t *testing.T) { patterns = append(append([]*regexp.Regexp{}, everywhere...), migrationOnly...) } for _, p := range patterns { - if loc := p.FindIndex(b); loc != nil { + for _, loc := range p.FindAllIndex(b, -1) { line := 1 + strings.Count(string(b[:loc[0]]), "\n") - violations = append(violations, fmt.Sprintf("%s:%d: %s", name, line, p.String())) + violations = append(violations, fmt.Sprintf("%s:%d: %s", name, line, strings.Join(strings.Fields(string(b[loc[0]:loc[1]])), " "))) } } } if len(violations) > 0 { - t.Errorf("cmd/server/ writes the packet tables (#215):\n %s", strings.Join(violations, "\n ")) + t.Errorf("cmd/server/ writes the packet tables; that belongs in cmd/ingestor (#215, #223, #1283):\n %s", strings.Join(violations, "\n ")) } } // TestServerHasNoPacketTableWritesIsSensitive keeps the guard honest: it must -// match the statements the old migration issued. +// match the statements the old migration (#215) and the removed POST +// handler (#223) issued, and must not match reads or look-alike tables. func TestServerHasNoPacketTableWritesIsSensitive(t *testing.T) { + matches := func(stmt string) bool { + for _, p := range packetTableWritePatterns() { + if p.MatchString(stmt) { + return true + } + } + return false + } for _, stmt := range []string{ `UPDATE transmissions SET hash = ? WHERE id = ?`, `UPDATE observations SET transmission_id = ? WHERE transmission_id = ?`, `DELETE FROM transmissions WHERE id = ?`, `update "observations" set x = 1`, `UPDATE OR IGNORE observations SET transmission_id = 1`, + `INSERT INTO transmissions (hash, raw_hex) VALUES (?, ?)`, + `INSERT OR IGNORE INTO observers (id, name, last_seen, first_seen) VALUES (?, ?, ?, ?)`, + `INSERT INTO observations (transmission_id, observer_idx) VALUES (?, ?)`, + "insert into\n\t\"observations\" (x) values (1)", + `REPLACE INTO dropped_packets (id) VALUES (1)`, + `DELETE FROM route_mask_changes`, } { - matched := false - for _, p := range []*regexp.Regexp{ - txTableWritePattern(`UPDATE(\s+OR\s+\w+)?`), - txTableWritePattern(`DELETE\s+FROM`), - } { - if p.MatchString(stmt) { - matched = true - } - } - if !matched { + if !matches(stmt) { t.Errorf("the guard does not match %q", stmt) } } + for _, stmt := range []string{ + `INSERT INTO ping_score_history_entries (a) VALUES (1)`, + `SELECT COUNT(*) FROM observations`, + `INSERT INTO observations_archive (a) VALUES (1)`, + `INSERT INTO observer_neighbors (a) VALUES (1)`, + `tx_inserted`, + } { + if matches(stmt) { + t.Errorf("the guard must not match %q", stmt) + } + } } diff --git a/cmd/server/readonly_sql_literal_test.go b/cmd/server/readonly_sql_literal_test.go index 91681efcd..82e13e848 100644 --- a/cmd/server/readonly_sql_literal_test.go +++ b/cmd/server/readonly_sql_literal_test.go @@ -25,14 +25,14 @@ var writeSQLPattern = regexp.MustCompile(`(?is)\b(INSERT\s+(OR\s+\w+\s+)?INTO|RE // shared one. // - backup.go: VACUUM INTO writes a snapshot file, not the database. // - openapi.go: prose mentioning VACUUM INTO. -// - routes.go: pre-existing writes to the shared database (the packet -// POST handler), tracked separately. hash_migrate.go had 3 until the -// content-hash migration moved to the ingestor (#215). +// +// hash_migrate.go had 3 until the content-hash migration moved to the +// ingestor (#215); routes.go had 3 until POST /api/packets was removed +// (#223). var knownServerWriteSQL = map[string]int{ "backup.go": 1, "openapi.go": 1, "ping_score_history.go": 15, - "routes.go": 3, } // TestServerSourceHasNoNewWriteSQL guards the read-only server contract diff --git a/cmd/server/routes.go b/cmd/server/routes.go index f349355f4..7228a9430 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -20,7 +20,6 @@ import ( "github.com/gorilla/mux" "github.com/meshcore-analyzer/channelregistry" "github.com/meshcore-analyzer/geofilter" - "github.com/meshcore-analyzer/packetpath" "github.com/meshcore-analyzer/prunequeue" regionutil "github.com/meshcore-analyzer/regions" "golang.org/x/sync/singleflight" @@ -348,7 +347,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET") r.HandleFunc("/api/packets/{id}", s.handlePacketDetail).Methods("GET") r.HandleFunc("/api/packets", s.handlePackets).Methods("GET") - r.Handle("/api/packets", s.requireAPIKey(http.HandlerFunc(s.handlePostPacket))).Methods("POST") // Decode endpoint r.HandleFunc("/api/decode", s.handleDecode).Methods("POST") @@ -1537,110 +1535,6 @@ func (s *Server) handleDecode(w http.ResponseWriter, r *http.Request) { }) } -func (s *Server) handlePostPacket(w http.ResponseWriter, r *http.Request) { - var body struct { - Hex string `json:"hex"` - Observer *string `json:"observer"` - Snr *float64 `json:"snr"` - Rssi *float64 `json:"rssi"` - Region *string `json:"region"` - Hash *string `json:"hash"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, 400, "invalid JSON body") - return - } - hexStr := strings.TrimSpace(body.Hex) - if hexStr == "" { - writeError(w, 400, "hex is required") - return - } - decoded, err := DecodePacket(hexStr, false) - if err != nil { - writeError(w, 400, err.Error()) - return - } - - contentHash := ComputeContentHash(hexStr) - pathJSON := "[]" - // For TRACE packets, path_json must be the payload-decoded route hops - // (decoded.Path.Hops), NOT the raw_hex header bytes which are SNR values. - // For all other packet types, derive path from raw_hex (#886). - if !packetpath.PathBytesAreHops(byte(decoded.Header.PayloadType)) { - if len(decoded.Path.Hops) > 0 { - if pj, e := json.Marshal(decoded.Path.Hops); e == nil { - pathJSON = string(pj) - } - } - } else if hops, err := packetpath.DecodePathFromRawHex(hexStr); err == nil && len(hops) > 0 { - if pj, e := json.Marshal(hops); e == nil { - pathJSON = string(pj) - } - } - decodedJSON := PayloadJSON(&decoded.Payload) - now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") - nowEpoch := time.Now().Unix() - - var snr, rssi interface{} - if body.Snr != nil { - snr = *body.Snr - } - if body.Rssi != nil { - rssi = *body.Rssi - } - - // v3 schema (cmd/ingestor/db.go:251-303): transmissions no longer carries - // path_json (it lives on observations now), observations uses observer_idx - // INTEGER (FK observers.rowid) and timestamp INTEGER (unix epoch). - // Fix for #1196 — pre-fix code wrote v2 column names and silently - // swallowed the observations insert error. - res, dbErr := s.db.conn.Exec(`INSERT INTO transmissions (hash, raw_hex, route_type, payload_type, payload_version, decoded_json, first_seen) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - contentHash, strings.ToUpper(hexStr), decoded.Header.RouteType, decoded.Header.PayloadType, - decoded.Header.PayloadVersion, decodedJSON, now) - if dbErr != nil { - writeError(w, 500, "transmission insert: "+dbErr.Error()) - return - } - insertedID, _ := res.LastInsertId() - - // Resolve observer string → observers.rowid. INSERT OR IGNORE then SELECT - // mirrors the ingestor's resolver (cmd/ingestor/db.go:778,799,906). - var observerIdx interface{} - if body.Observer != nil && *body.Observer != "" { - obsID := *body.Observer - if _, err := s.db.conn.Exec( - `INSERT OR IGNORE INTO observers (id, name, last_seen, first_seen) VALUES (?, ?, ?, ?)`, - obsID, obsID, now, now); err != nil { - writeError(w, 500, "observer upsert: "+err.Error()) - return - } - var rowid int64 - if err := s.db.conn.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, obsID).Scan(&rowid); err != nil { - writeError(w, 500, "observer lookup: "+err.Error()) - return - } - observerIdx = rowid - } - - if _, obsErr := s.db.conn.Exec( - `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) - VALUES (?, ?, ?, ?, ?, ?)`, - insertedID, observerIdx, snr, rssi, pathJSON, nowEpoch); obsErr != nil { - writeError(w, 500, "observation insert: "+obsErr.Error()) - return - } - - writeJSON(w, PacketIngestResponse{ - ID: insertedID, - Decoded: map[string]interface{}{ - "header": decoded.Header, - "path": decoded.Path, - "payload": decoded.Payload, - }, - }) -} - // --- Node Handlers --- // nodeListPostFilters bundles the filters handleNodes applies AFTER diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 9eae00520..1d685ab96 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -5667,72 +5667,6 @@ func TestListLimitsConfigurable(t *testing.T) { } } -// TestPostPacketPersistsV3Schema is the round-trip regression for #1196. -// POST /api/packets must write the observation row using the v3 schema -// (observer_idx INTEGER, timestamp INTEGER) and surface insert errors. -// The pre-fix handler writes v2 columns (observer_id, observer_name, -// RFC3339 timestamp) and silently swallows the obs insert error. -func TestPostPacketPersistsV3Schema(t *testing.T) { - const apiKey = "test-secret-key-strong-enough" - srv, router := setupTestServerWithAPIKey(t, apiKey) - - // FLOOD/ADVERT hex (header 0x11, path byte 0x00, payload bytes). - // Mirrors TestDecodePacket_FloodHasNoCodes. - const rawHex = "110011223344556677889900AABBCCDD" - bodyJSON := `{"hex":"` + rawHex + `","observer":"obs1","snr":5.5,"rssi":-72}` - - req := httptest.NewRequest("POST", "/api/packets", - bytes.NewReader([]byte(bodyJSON))) - req.Header.Set("X-API-Key", apiKey) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("POST /api/packets: expected 200, got %d (body: %s)", - w.Code, w.Body.String()) - } - - var resp map[string]interface{} - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode response: %v", err) - } - idF, _ := resp["id"].(float64) - txID := int64(idF) - if txID <= 0 { - t.Fatalf("expected transmission id > 0, got %v (body: %s)", - resp["id"], w.Body.String()) - } - - // Resolve expected observer_idx from the seeded observers table. - var wantIdx int64 - if err := srv.db.conn.QueryRow( - "SELECT rowid FROM observers WHERE id = ?", "obs1", - ).Scan(&wantIdx); err != nil { - t.Fatalf("lookup observer rowid: %v", err) - } - - // Assert the observation row was written with v3 columns. - var ( - gotIdx int64 - gotTS int64 - ) - err := srv.db.conn.QueryRow( - "SELECT observer_idx, timestamp FROM observations WHERE transmission_id = ?", - txID, - ).Scan(&gotIdx, &gotTS) - if err != nil { - t.Fatalf("observation row missing for tx %d: %v (handler swallowed insert error?)", txID, err) - } - if gotIdx != wantIdx { - t.Errorf("observer_idx: want %d, got %d", wantIdx, gotIdx) - } - nowSec := time.Now().Unix() - if gotTS < nowSec-60 || gotTS > nowSec+60 { - t.Errorf("timestamp: want unix int near %d, got %d", nowSec, gotTS) - } -} - // TestConfigThemeTypeColorsLegacyRequestKey verifies the REQUEST→REQ rename // (#1799 PR #1804 r1 item 6) doesn't break operators whose config.json still // carries the legacy `typeColors.REQUEST` key. The GET response must: diff --git a/cmd/server/types.go b/cmd/server/types.go index 980fd4b54..2805a87af 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -823,11 +823,6 @@ type PacketDetailResponse struct { Observations []ObservationResp `json:"observations,omitempty"` } -type PacketIngestResponse struct { - ID int64 `json:"id"` - Decoded interface{} `json:"decoded"` -} - type DecodeResponse struct { Decoded interface{} `json:"decoded"` } diff --git a/docs/api-spec.md b/docs/api-spec.md index e3e9a1030..739620d36 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -28,7 +28,6 @@ - [GET /api/packets](#get-apipackets) - [GET /api/packets/timestamps](#get-apipacketstimestamps) - [GET /api/packets/:id](#get-apipacketsid) -- [POST /api/packets](#post-apipackets) - [POST /api/decode](#post-apidecode) - [GET /api/observers](#get-apiobservers) - [GET /api/observers/:id](#get-apiobserversid) @@ -1095,48 +1094,6 @@ Single packet detail with byte breakdown and observations. --- -## POST /api/packets - -Ingest a raw packet. Requires API key. - -### Headers - -- `X-API-Key: ` (required if `config.apiKey` is set) - -### Request Body - -```jsonc -{ - "hex": string, // required — raw hex-encoded packet - "observer": string | null, // observer ID - "snr": number | null, - "rssi": number | null, - "region": string | null, // IATA code - "hash": string | null // pre-computed content hash -} -``` - -### Response `200` - -```jsonc -{ - "id": number, // packet/observation ID - "decoded": { // full decode result - "header": DecodedHeader, - "path": DecodedPath, - "payload": object - } -} -``` - -### Response `400` - -```json -{ "error": "hex is required" } -``` - ---- - ## POST /api/decode Decode a raw packet without storing it. diff --git a/docs/user-guide/faq.md b/docs/user-guide/faq.md index 34d84efa9..8546d4d4b 100644 --- a/docs/user-guide/faq.md +++ b/docs/user-guide/faq.md @@ -39,7 +39,7 @@ Faded markers indicate **stale** nodes — they haven't been heard recently. The ## 8. Can I run CoreScope without MQTT? -Yes. You can POST packets directly to the `/api/packets` endpoint using the API key. However, MQTT is the standard way to ingest data from mesh observers. +No. Packets are ingested only through MQTT: observers publish to a broker and the ingestor (`cmd/ingestor`) subscribes, decodes and writes them to the database. The web server is read-only; it serves the UI and the REST API from that database and cannot ingest packets itself. ## 9. How do I change the map's default location? diff --git a/proto/decoded.proto b/proto/decoded.proto index 38ba447eb..8427e3261 100644 --- a/proto/decoded.proto +++ b/proto/decoded.proto @@ -5,7 +5,7 @@ package meshcore.v1; option go_package = "github.com/corescope/proto/v1"; // ─── Decoded Packet Structure ────────────────────────────────────────────────── -// Returned by POST /api/decode, POST /api/packets, and WS broadcast. +// Returned by POST /api/decode and WS broadcast. // See firmware source (firmware/docs/packet_format.md) for authoritative format. // Full decoded result: header + path + payload. diff --git a/proto/packet.proto b/proto/packet.proto index 16f517726..50d991bd8 100644 --- a/proto/packet.proto +++ b/proto/packet.proto @@ -184,30 +184,6 @@ message PacketDetailResponse { repeated Observation observations = 5; } -// POST /api/packets — ingest a raw packet. -message PacketIngestRequest { - // Raw hex-encoded packet (required). - string hex = 1; - // Observer device ID. - optional string observer = 2; - // Signal-to-noise ratio (dB). - optional double snr = 3; - // Received signal strength (dBm). - optional double rssi = 4; - // IATA region code. - optional string region = 5; - // Pre-computed content hash. - optional string hash = 6; -} - -// POST /api/packets — response. -message PacketIngestResponse { - // Observation or transmission ID. - int64 id = 1; - // Full structured decode result. - DecodedResult decoded = 2; -} - // POST /api/decode — decode without storing. message DecodeRequest { // Raw hex-encoded packet (required). diff --git a/tools/generate-packets.js b/tools/generate-packets.js deleted file mode 100644 index 898de0913..000000000 --- a/tools/generate-packets.js +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const crypto = require('crypto'); -const http = require('http'); - -const API_URL = 'http://localhost:3000/api/packets'; - -// --- Bay Area mesh network topology --- -const OBSERVERS = [ - { id: 'SJC-Lick-Observatory', region: 'SJC', lat: 37.3414, lon: -121.6429 }, - { id: 'SJC-Comm-Hill', region: 'SJC', lat: 37.3375, lon: -121.8377 }, - { id: 'SFO-Twin-Peaks', region: 'SFO', lat: 37.7544, lon: -122.4477 }, - { id: 'SFO-Bernal-Heights', region: 'SFO', lat: 37.7426, lon: -122.4157 }, - { id: 'OAK-Grizzly-Peak', region: 'OAK', lat: 37.8816, lon: -122.2446 }, - { id: 'MTV-Black-Mountain', region: 'MTV', lat: 37.3209, lon: -122.1485 }, - { id: 'SCZ-UCSC-Tower', region: 'SCZ', lat: 36.9916, lon: -122.0583 }, -]; - -// Persistent nodes with fixed pubkeys and locations -const NODES = [ - { name: 'SJ-Downtown-RPT', role: 'repeater', lat: 37.3382, lon: -121.8863, cluster: 'SJC' }, - { name: 'SJ-Almaden-Solar', role: 'repeater', lat: 37.2504, lon: -121.8617, cluster: 'SJC' }, - { name: 'SJ-Japantown-Relay', role: 'repeater', lat: 37.3485, lon: -121.8950, cluster: 'SJC' }, - { name: 'MV-Googleplex-Node', role: 'companion', lat: 37.4220, lon: -122.0841, cluster: 'MTV' }, - { name: 'MV-Shoreline-RPT', role: 'repeater', lat: 37.4300, lon: -122.0880, cluster: 'MTV' }, - { name: 'PA-University-Ave', role: 'companion', lat: 37.4419, lon: -122.1430, cluster: 'MTV' }, - { name: 'SF-TwinPeaks-Solar', role: 'repeater', lat: 37.7544, lon: -122.4477, cluster: 'SFO' }, - { name: 'SF-Mission-Room', role: 'room', lat: 37.7599, lon: -122.4148, cluster: 'SFO' }, - { name: 'SF-SOMA-Sensor', role: 'sensor', lat: 37.7785, lon: -122.3893, cluster: 'SFO' }, - { name: 'SF-Sunset-Relay', role: 'repeater', lat: 37.7530, lon: -122.4944, cluster: 'SFO' }, - { name: 'Oak-Hills-Relay', role: 'repeater', lat: 37.8324, lon: -122.2390, cluster: 'OAK' }, - { name: 'Oak-Temescal-Node', role: 'companion', lat: 37.8340, lon: -122.2600, cluster: 'OAK' }, - { name: 'Berkeley-Marina', role: 'repeater', lat: 37.8694, lon: -122.3100, cluster: 'OAK' }, - { name: 'Fremont-Hub', role: 'companion', lat: 37.5485, lon: -121.9886, cluster: 'SJC' }, - { name: 'Sunnyvale-Central', role: 'companion', lat: 37.3688, lon: -122.0363, cluster: 'MTV' }, - { name: 'Cupertino-Foothills', role: 'repeater', lat: 37.3230, lon: -122.0322, cluster: 'MTV' }, - { name: 'RedwoodCity-Harbor', role: 'companion', lat: 37.5074, lon: -122.2117, cluster: 'MTV' }, - { name: 'Saratoga-Summit-RPT', role: 'repeater', lat: 37.2560, lon: -122.0230, cluster: 'SCZ' }, - { name: 'LosGatos-Creek', role: 'companion', lat: 37.2306, lon: -121.9625, cluster: 'SCZ' }, - { name: 'SC-Boardwalk-Node', role: 'companion', lat: 36.9641, lon: -122.0178, cluster: 'SCZ' }, - { name: 'HalfMoonBay-Coast', role: 'repeater', lat: 37.4636, lon: -122.4286, cluster: 'SFO' }, - { name: 'Pacifica-Fog-RPT', role: 'repeater', lat: 37.6138, lon: -122.4869, cluster: 'SFO' }, - { name: 'Napa-Valley-Hilltop', role: 'repeater', lat: 38.2975, lon: -122.2869, cluster: 'OAK' }, - { name: 'SanMateo-Bridge-RPT', role: 'repeater', lat: 37.5800, lon: -122.2530, cluster: 'MTV' }, - { name: 'Milpitas-Gateway', role: 'companion', lat: 37.4323, lon: -121.8996, cluster: 'SJC' }, - { name: 'Campbell-Downtown', role: 'companion', lat: 37.2872, lon: -121.9500, cluster: 'SJC' }, - { name: 'MorganHill-South', role: 'companion', lat: 37.1305, lon: -121.6544, cluster: 'SJC' }, - { name: 'Gilroy-Garlic-Relay', role: 'repeater', lat: 37.0058, lon: -121.5683, cluster: 'SJC' }, - { name: 'DalyCity-Colma-RPT', role: 'repeater', lat: 37.6879, lon: -122.4702, cluster: 'SFO' }, - { name: 'Burlingame-RPT', role: 'repeater', lat: 37.5841, lon: -122.3660, cluster: 'MTV' }, - { name: 'Hayward-Hills', role: 'repeater', lat: 37.6688, lon: -122.0808, cluster: 'OAK' }, - { name: 'Newark-Bridge-Node', role: 'companion', lat: 37.5316, lon: -122.0402, cluster: 'OAK' }, -]; - -// Generate stable pubkeys per node -NODES.forEach(n => { n.pubKey = crypto.createHash('sha256').update(n.name).digest('hex').slice(0, 64); }); - -const CHANNEL_HASHES = [ - { hash: 0xC3, name: 'public' }, - { hash: 0x7A, name: '#bayarea' }, - { hash: 0x3F, name: '#meshdev' }, - { hash: 0x91, name: '#offtopic' }, -]; - -const CHAT_MESSAGES = { - '#bayarea': [ - 'Good morning from SJ!', 'Coverage test from Twin Peaks', 'Can confirm — signal reaches across the bay now', - 'New repeater going up on Mt Hamilton next week', 'Anyone want to do a range test Saturday?', - 'Fog is killing my signal today', 'Just hit 12 hops from Gilroy to Napa!', - 'Battery swap done on the solar repeater', 'Who maintains the Berkeley Marina node?', - 'Link budget looking great after the antenna upgrade', 'Need help with antenna alignment on Oak Hills', - 'Beautiful propagation conditions today', 'Testing new firmware build', 'Signal check from Half Moon Bay', - ], - '#meshdev': [ - 'PR merged for the new path length encoding', 'Anyone tested the latest companion app build?', - 'Found a bug in flood routing — packets loop when path > 6 hops', - 'New ADVERT format adds 8 bytes for altitude data', 'Working on trace route visualization', - 'The decoder library needs a fix for v1 headers', 'Who broke the MQTT bridge? 😅', - 'Benchmarking shows ~200ms latency across 8 hops', 'Memory leak in the repeater firmware — investigating', - 'Released v0.4.2 with power management fixes', - ], - '#offtopic': [ - 'Anyone catch the Warriors game?', 'Best tacos in the mission — El Farolito, fight me', - 'Weather station says 72°F, feels like 90 on the roof', 'My cat unplugged the repeater again', - 'Happy Friday everyone!', 'Who else is going to the maker faire?', - 'Just got a new solar panel, 100W for $40', 'Coffee recommendations near downtown SJ?', - ], - 'public': [ - 'Hello mesh!', 'Testing testing', 'CQ CQ CQ', 'Anyone there?', 'GM from Bay Area mesh', - 'New node online', 'Running range test', '73s everyone', 'First packet!', - 'Checking in from the coast', 'Mesh is alive!', 'Good copy on all channels', - ], -}; - -// --- Helpers --- -function rand(min, max) { return Math.random() * (max - min) + min; } -function randInt(min, max) { return Math.floor(rand(min, max + 1)); } -function pick(arr) { return arr[randInt(0, arr.length - 1)]; } -function gaussRand(mean, std) { - const u1 = Math.random(), u2 = Math.random(); - return mean + std * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); -} - -// Time spread: packets over last 7 days, clustered towards recent -function randomTimestamp() { - const now = Date.now(); - const sevenDays = 7 * 24 * 60 * 60 * 1000; - // Exponential distribution — more packets recently - const age = sevenDays * Math.pow(Math.random(), 2); - return new Date(now - age).toISOString(); -} - -// Nearby observers see the same packet (geographic clustering) -function getObserversForCluster(cluster) { - const clusterObs = { - SJC: ['SJC-Lick-Observatory', 'SJC-Comm-Hill'], - SFO: ['SFO-Twin-Peaks', 'SFO-Bernal-Heights'], - OAK: ['OAK-Grizzly-Peak'], - MTV: ['MTV-Black-Mountain'], - SCZ: ['SCZ-UCSC-Tower'], - }; - // Always include local, sometimes include neighbors - const local = clusterObs[cluster] || [pick(OBSERVERS).id]; - const result = [...local]; - // 30% chance a nearby cluster also hears it - if (Math.random() < 0.3) { - const neighbors = { SJC: 'MTV', MTV: 'SJC', SFO: 'OAK', OAK: 'SFO', SCZ: 'SJC' }; - const neighbor = neighbors[cluster]; - if (neighbor && clusterObs[neighbor]) result.push(pick(clusterObs[neighbor])); - } - return result; -} - -// --- Packet Builders --- - -function buildHeader(payloadType, routeType = 1, version = 0) { - return (version << 6) | (payloadType << 2) | routeType; -} - -function buildPath(maxHops = 8) { - const hashSize = Math.random() < 0.7 ? 2 : 1; - const hashCount = randInt(0, maxHops); - const pathByte = ((hashSize - 1) << 6) | hashCount; - const pathData = crypto.randomBytes(hashSize * hashCount); - return { pathByte, pathData, hashCount }; -} - -function buildAdvert(node) { - const pubKey = Buffer.from(node.pubKey, 'hex'); - const timestamp = Math.floor(Date.now() / 1000) - randInt(0, 3600); - const tsBuf = Buffer.alloc(4); - tsBuf.writeUInt32LE(timestamp); - const signature = crypto.randomBytes(64); - - let flags = 0x80 | 0x10; // hasName + hasLocation - if (node.role === 'repeater') flags |= 0x02; - if (node.role === 'room') flags |= 0x04; - if (node.role === 'sensor') flags |= 0x08; - if (Math.random() < 0.6) flags |= 0x01; // chat - - // Slight GPS jitter - const lat = node.lat + gaussRand(0, 0.0002); - const lon = node.lon + gaussRand(0, 0.0002); - const latBuf = Buffer.alloc(4); latBuf.writeInt32LE(Math.round(lat * 1e6)); - const lonBuf = Buffer.alloc(4); lonBuf.writeInt32LE(Math.round(lon * 1e6)); - - return { payload: Buffer.concat([pubKey, tsBuf, signature, Buffer.from([flags]), latBuf, lonBuf, Buffer.from(node.name, 'utf8')]), cluster: node.cluster }; -} - -function buildGrpTxt(senderNode) { - const ch = pick(CHANNEL_HASHES); - const mac = crypto.randomBytes(2); - const msgs = CHAT_MESSAGES[ch.name] || CHAT_MESSAGES['public']; - const sender = senderNode ? senderNode.name : pick(NODES).name; - const msg = `${sender}: ${pick(msgs)}`; - return { payload: Buffer.concat([Buffer.from([ch.hash]), mac, Buffer.from(msg, 'utf8')]), cluster: senderNode?.cluster || pick(NODES).cluster }; -} - -function buildAck() { - return { payload: crypto.randomBytes(18), cluster: pick(NODES).cluster }; -} - -function buildTxtMsg(senderNode) { - const msg = pick(CHAT_MESSAGES['public']); - return { payload: Buffer.concat([crypto.randomBytes(16), Buffer.from(msg, 'utf8')]), cluster: senderNode?.cluster || pick(NODES).cluster }; -} - -function buildTrace() { - const flags = randInt(0, 3); - const tag = Buffer.alloc(4); tag.writeUInt32LE(randInt(1, 100000)); - const dest = crypto.randomBytes(6); - const src = crypto.randomBytes(1); - const snrPath = Buffer.alloc(randInt(0, 6)); - for (let i = 0; i < snrPath.length; i++) snrPath[i] = randInt(0, 255); - return { payload: Buffer.concat([Buffer.from([flags]), tag, dest, src, snrPath]), cluster: pick(NODES).cluster }; -} - -// --- Generate full packet hex --- - -function generatePacket() { - const r = Math.random(); - let payloadType, built, routeType; - const node = pick(NODES); - - if (r < 0.50) { - payloadType = 0x04; // ADVERT - built = buildAdvert(node); - routeType = 1; - } else if (r < 0.78) { - payloadType = 0x05; // GRP_TXT - built = buildGrpTxt(node); - routeType = 1; - } else if (r < 0.88) { - payloadType = 0x03; // ACK - built = buildAck(); - routeType = 1; - } else if (r < 0.93) { - payloadType = 0x02; // TXT_MSG - built = buildTxtMsg(node); - routeType = Math.random() < 0.4 ? 2 : 1; - } else if (r < 0.97) { - payloadType = 0x09; // TRACE - built = buildTrace(); - routeType = 1; - } else { - payloadType = pick([0x00, 0x01, 0x08]); // REQ/RESPONSE/PATH - built = { payload: Buffer.concat([crypto.randomBytes(16), crypto.randomBytes(randInt(4, 20))]), cluster: pick(NODES).cluster }; - routeType = 1; - } - - const { pathByte, pathData, hashCount } = buildPath(payloadType === 0x04 ? 4 : 8); - const headerByte = buildHeader(payloadType, routeType); - - let transportCodes = Buffer.alloc(0); - if (routeType === 0 || routeType === 3) { - transportCodes = crypto.randomBytes(4); - } - - const hex = Buffer.concat([Buffer.from([headerByte, pathByte]), transportCodes, pathData, built.payload]).toString('hex').toUpperCase(); - const hash = crypto.createHash('sha256').update(hex).digest('hex').slice(0, 16); - - return { hex, hash, cluster: built.cluster, payloadType }; -} - -// --- API posting --- - -function postPacket(body) { - return new Promise((resolve, reject) => { - const data = JSON.stringify(body); - const url = new URL(API_URL); - const req = http.request({ - hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, - }, (res) => { - let body = ''; - res.on('data', c => body += c); - res.on('end', () => { - if (res.statusCode >= 400) reject(new Error(`HTTP ${res.statusCode}: ${body}`)); - else resolve(JSON.parse(body)); - }); - }); - req.on('error', reject); - req.write(data); - req.end(); - }); -} - -// --- Main --- - -async function main() { - const count = parseInt(process.argv[2] || '500'); - console.log(`Generating ${count} packets with realistic Bay Area topology...`); - console.log(` ${NODES.length} nodes, ${OBSERVERS.length} observers, ${CHANNEL_HASHES.length} channels`); - - let posted = 0, errors = 0; - - for (let i = 0; i < count; i++) { - const pkt = generatePacket(); - const observers = getObserversForCluster(pkt.cluster); - - for (const obsId of observers) { - const obs = OBSERVERS.find(o => o.id === obsId); - try { - await postPacket({ - hex: pkt.hex, - hash: pkt.hash, - observer: obsId, - snr: Math.round(gaussRand(2, 4) * 10) / 10, - rssi: Math.round(gaussRand(-85, 12)), - region: obs?.region || 'UNK', - }); - posted++; - } catch (e) { - errors++; - if (errors < 5) console.error(` Error: ${e.message}`); - } - } - - if ((i + 1) % 100 === 0) console.log(` ${i + 1}/${count} packets generated (${posted} observations posted)`); - } - - console.log(`\nDone! ${count} packets → ${posted} observations posted (${errors} errors)`); -} - -main().catch(e => { console.error(e); process.exit(1); });