diff --git a/cmd/ingestor/config.go b/cmd/ingestor/config.go index fb56aeef..0429b23a 100644 --- a/cmd/ingestor/config.go +++ b/cmd/ingestor/config.go @@ -52,7 +52,8 @@ type Config struct { HashChannels []string `json:"hashChannels,omitempty"` Retention *RetentionConfig `json:"retention,omitempty"` Metrics *MetricsConfig `json:"metrics,omitempty"` - GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"` + GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"` + ForeignAdverts *ForeignAdvertConfig `json:"foreignAdverts,omitempty"` ValidateSignatures *bool `json:"validateSignatures,omitempty"` DB *DBConfig `json:"db,omitempty"` @@ -79,6 +80,23 @@ type Config struct { // GeoFilterConfig is an alias for the shared geofilter.Config type. type GeoFilterConfig = geofilter.Config +// ForeignAdvertConfig controls how the ingestor handles ADVERTs whose GPS lies +// outside the configured geofilter polygon (#730). Modes: +// - "flag" (default): store the advert/node and tag it foreign for visibility. +// - "drop": silently discard the advert (legacy behavior). +type ForeignAdvertConfig struct { + Mode string `json:"mode,omitempty"` +} + +// IsDropMode reports whether the foreign-advert config is set to "drop". +// Defaults to false ("flag" mode) when nil or unset. +func (f *ForeignAdvertConfig) IsDropMode() bool { + if f == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(f.Mode), "drop") +} + // RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes. type RetentionConfig struct { NodeDays int `json:"nodeDays"` diff --git a/cmd/ingestor/coverage_boost_test.go b/cmd/ingestor/coverage_boost_test.go index 90f82b48..58d9092f 100644 --- a/cmd/ingestor/coverage_boost_test.go +++ b/cmd/ingestor/coverage_boost_test.go @@ -428,7 +428,12 @@ func TestHandleMessageAdvertGeoFiltered(t *testing.T) { topic: "meshcore/SJC/obs1/packets", payload: []byte(`{"raw":"` + rawHex + `"}`), } - handleMessage(store, "test", source, msg, nil, &Config{GeoFilter: gf}) + // Legacy silent-drop behavior is now opt-in via ForeignAdverts.Mode="drop" + // (#730). The new default — flag — is covered by foreign_advert_test.go. + handleMessage(store, "test", source, msg, nil, &Config{ + GeoFilter: gf, + ForeignAdverts: &ForeignAdvertConfig{Mode: "drop"}, + }) // Geo-filtered adverts should not create nodes var nodeCount int @@ -436,7 +441,7 @@ func TestHandleMessageAdvertGeoFiltered(t *testing.T) { t.Fatal(err) } if nodeCount != 0 { - t.Errorf("nodes=%d, want 0 (geo-filtered advert should not create node)", nodeCount) + t.Errorf("nodes=%d, want 0 (geo-filtered advert in drop mode should not create node)", nodeCount) } } diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index 4d03b952..a71fb61b 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -101,7 +101,8 @@ func applySchema(db *sql.DB) error { first_seen TEXT, advert_count INTEGER DEFAULT 0, battery_mv INTEGER, - temperature_c REAL + temperature_c REAL, + foreign_advert INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS observers ( @@ -135,7 +136,8 @@ func applySchema(db *sql.DB) error { first_seen TEXT, advert_count INTEGER DEFAULT 0, battery_mv INTEGER, - temperature_c REAL + temperature_c REAL, + foreign_advert INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_inactive_nodes_last_seen ON inactive_nodes(last_seen); @@ -463,6 +465,25 @@ func applySchema(db *sql.DB) error { db.Exec(`INSERT INTO _migrations (name) VALUES ('cleanup_legacy_null_hash_ts')`) } + // Migration: foreign_advert column on nodes/inactive_nodes (#730) + // Marks nodes whose ADVERT GPS lies outside the configured geofilter polygon. + // Default 0; set to 1 by the ingestor when GeoFilter is configured and + // PassesFilter() returns false. Allows operators to surface bridged/leaked + // adverts without silently dropping them. + row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'foreign_advert_v1'") + if row.Scan(&migDone) != nil { + log.Println("[migration] Adding foreign_advert column to nodes/inactive_nodes...") + if _, err := db.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil { + log.Printf("[migration] nodes.foreign_advert: %v (may already exist)", err) + } + if _, err := db.Exec(`ALTER TABLE inactive_nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil { + log.Printf("[migration] inactive_nodes.foreign_advert: %v (may already exist)", err) + } + db.Exec(`CREATE INDEX IF NOT EXISTS idx_nodes_foreign_advert ON nodes(foreign_advert) WHERE foreign_advert = 1`) + db.Exec(`INSERT INTO _migrations (name) VALUES ('foreign_advert_v1')`) + log.Println("[migration] foreign_advert column added") + } + return nil } @@ -676,6 +697,21 @@ func (s *Store) IncrementAdvertCount(pubKey string) error { return err } +// MarkNodeForeign sets foreign_advert=1 on the node row identified by pubKey. +// Used when an ADVERT arrives whose GPS lies outside the configured geofilter +// polygon (#730). Idempotent — safe to call repeatedly. No-op if pubKey is +// empty. +func (s *Store) MarkNodeForeign(pubKey string) error { + if pubKey == "" { + return nil + } + _, err := s.db.Exec(`UPDATE nodes SET foreign_advert = 1 WHERE public_key = ?`, pubKey) + if err != nil { + s.Stats.WriteErrors.Add(1) + } + return err +} + // UpdateNodeTelemetry updates battery and temperature for a node. func (s *Store) UpdateNodeTelemetry(pubKey string, batteryMv *int, temperatureC *float64) error { var bv, tc interface{} @@ -1106,6 +1142,7 @@ type PacketData struct { DecodedJSON string ChannelHash string // grouping key for channel queries (#762) Region string // observer region: payload > topic > source config (#788) + Foreign bool // true when ADVERT GPS lies outside configured geofilter (#730) } // nilIfEmpty returns nil for empty strings (for nullable DB columns). diff --git a/cmd/ingestor/foreign_advert_test.go b/cmd/ingestor/foreign_advert_test.go new file mode 100644 index 00000000..920a6c85 --- /dev/null +++ b/cmd/ingestor/foreign_advert_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "testing" +) + +// TestHandleMessageAdvertForeign_FlagModeStoresWithFlag asserts that when an +// ADVERT comes from a node whose GPS is OUTSIDE the configured geofilter, +// the ingestor (in default "flag" mode) stores the node and marks it foreign, +// instead of silently dropping it (#730). +func TestHandleMessageAdvertForeign_FlagModeStoresWithFlag(t *testing.T) { + store, source := newTestContext(t) + + // Real ADVERT raw hex from existing TestHandleMessageAdvertGeoFiltered. + // Decoder will produce a node with a known GPS — the test below just + // asserts that with a tight geofilter that EXCLUDES that GPS, the node + // is still stored AND tagged as foreign. + rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52" + + latMin, latMax := -1.0, 1.0 + lonMin, lonMax := -1.0, 1.0 + gf := &GeoFilterConfig{ + LatMin: &latMin, LatMax: &latMax, + LonMin: &lonMin, LonMax: &lonMax, + } + + msg := &mockMessage{ + topic: "meshcore/SJC/obs1/packets", + payload: []byte(`{"raw":"` + rawHex + `"}`), + } + // Default mode (no ForeignAdverts.Mode set) MUST be "flag", per #730 design. + handleMessage(store, "test", source, msg, nil, &Config{GeoFilter: gf}) + + var nodeCount int + if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil { + t.Fatal(err) + } + if nodeCount != 1 { + t.Fatalf("nodes=%d, want 1 (foreign advert should be stored, not dropped, in flag mode)", nodeCount) + } + + var foreign int + if err := store.db.QueryRow("SELECT foreign_advert FROM nodes").Scan(&foreign); err != nil { + t.Fatalf("foreign_advert column missing or unreadable: %v", err) + } + if foreign != 1 { + t.Errorf("foreign_advert=%d, want 1", foreign) + } +} + +// TestHandleMessageAdvertForeign_DropModeStillDrops asserts the legacy +// drop-on-foreign behavior is preserved when ForeignAdverts.Mode = "drop". +func TestHandleMessageAdvertForeign_DropModeStillDrops(t *testing.T) { + store, source := newTestContext(t) + + rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52" + + latMin, latMax := -1.0, 1.0 + lonMin, lonMax := -1.0, 1.0 + gf := &GeoFilterConfig{ + LatMin: &latMin, LatMax: &latMax, + LonMin: &lonMin, LonMax: &lonMax, + } + + msg := &mockMessage{ + topic: "meshcore/SJC/obs1/packets", + payload: []byte(`{"raw":"` + rawHex + `"}`), + } + cfg := &Config{ + GeoFilter: gf, + ForeignAdverts: &ForeignAdvertConfig{Mode: "drop"}, + } + handleMessage(store, "test", source, msg, nil, cfg) + + var nodeCount int + if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil { + t.Fatal(err) + } + if nodeCount != 0 { + t.Errorf("nodes=%d, want 0 (drop mode preserves legacy silent-drop behavior)", nodeCount) + } +} + +// TestHandleMessageAdvertInRegion_NotFlaggedForeign asserts in-region +// adverts are NOT marked foreign. +func TestHandleMessageAdvertInRegion_NotFlaggedForeign(t *testing.T) { + store, source := newTestContext(t) + + rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52" + + // Wide-open geofilter: every coord passes. + latMin, latMax := -90.0, 90.0 + lonMin, lonMax := -180.0, 180.0 + gf := &GeoFilterConfig{ + LatMin: &latMin, LatMax: &latMax, + LonMin: &lonMin, LonMax: &lonMax, + } + msg := &mockMessage{ + topic: "meshcore/SJC/obs1/packets", + payload: []byte(`{"raw":"` + rawHex + `"}`), + } + handleMessage(store, "test", source, msg, nil, &Config{GeoFilter: gf}) + + var foreign int + err := store.db.QueryRow("SELECT foreign_advert FROM nodes").Scan(&foreign) + if err != nil { + t.Fatalf("query foreign_advert: %v", err) + } + if foreign != 0 { + t.Errorf("foreign_advert=%d, want 0 (in-region node)", foreign) + } +} diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index e23fb2b3..3ad34424 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -422,10 +422,28 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, }) return } + foreign := false if !NodePassesGeoFilter(decoded.Payload.Lat, decoded.Payload.Lon, cfg.GeoFilter) { - return + if cfg.ForeignAdverts.IsDropMode() { + return + } + foreign = true + lat, lon := 0.0, 0.0 + if decoded.Payload.Lat != nil { + lat = *decoded.Payload.Lat + } + if decoded.Payload.Lon != nil { + lon = *decoded.Payload.Lon + } + truncPK := decoded.Payload.PubKey + if len(truncPK) > 16 { + truncPK = truncPK[:16] + } + log.Printf("MQTT [%s] foreign advert: node=%s name=%s lat=%.4f lon=%.4f observer=%s", + tag, truncPK, decoded.Payload.Name, lat, lon, firstNonEmpty(mqttMsg.Origin, observerID)) } pktData := BuildPacketData(mqttMsg, decoded, observerID, region) + pktData.Foreign = foreign isNew, err := store.InsertTransmission(pktData) if err != nil { log.Printf("MQTT [%s] db insert error: %v", tag, err) @@ -434,6 +452,11 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil { log.Printf("MQTT [%s] node upsert error: %v", tag, err) } + if foreign { + if err := store.MarkNodeForeign(decoded.Payload.PubKey); err != nil { + log.Printf("MQTT [%s] mark foreign error: %v", tag, err) + } + } if isNew { if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil { log.Printf("MQTT [%s] advert count error: %v", tag, err) diff --git a/cmd/server/db.go b/cmd/server/db.go index c84dff2e..7c329ce6 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -787,7 +787,7 @@ func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortB var total int db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM nodes %s", w), args...).Scan(&total) - querySQL := fmt.Sprintf("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c FROM nodes %s ORDER BY %s LIMIT ? OFFSET ?", w, order) + querySQL := fmt.Sprintf("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert FROM nodes %s ORDER BY %s LIMIT ? OFFSET ?", w, order) qArgs := append(args, limit, offset) rows, err := db.conn.Query(querySQL, qArgs...) @@ -813,7 +813,7 @@ func (db *DB) SearchNodes(query string, limit int) ([]map[string]interface{}, er if limit <= 0 { limit = 10 } - rows, err := db.conn.Query(`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c + rows, err := db.conn.Query(`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert FROM nodes WHERE name LIKE ? OR public_key LIKE ? ORDER BY last_seen DESC LIMIT ?`, "%"+query+"%", query+"%", limit) if err != nil { @@ -852,7 +852,7 @@ func (db *DB) GetNodeByPrefix(prefix string) (map[string]interface{}, bool, erro } } rows, err := db.conn.Query( - `SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c + `SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert FROM nodes WHERE public_key LIKE ? LIMIT 2`, prefix+"%", ) @@ -882,7 +882,7 @@ func (db *DB) GetNodeByPrefix(prefix string) (map[string]interface{}, bool, erro // GetNodeByPubkey returns a single node. func (db *DB) GetNodeByPubkey(pubkey string) (map[string]interface{}, error) { - rows, err := db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c FROM nodes WHERE public_key = ?", pubkey) + rows, err := db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert FROM nodes WHERE public_key = ?", pubkey) if err != nil { return nil, err } @@ -1867,8 +1867,9 @@ func scanNodeRow(rows *sql.Rows) map[string]interface{} { var advertCount int var batteryMv sql.NullInt64 var temperatureC sql.NullFloat64 + var foreign sql.NullInt64 - if err := rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC); err != nil { + if err := rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC, &foreign); err != nil { return nil } m := map[string]interface{}{ @@ -1883,6 +1884,7 @@ func scanNodeRow(rows *sql.Rows) map[string]interface{} { "last_heard": nullStr(lastSeen), "hash_size": nil, "hash_size_inconsistent": false, + "foreign": foreign.Valid && foreign.Int64 != 0, } if batteryMv.Valid { m["battery_mv"] = int(batteryMv.Int64) diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 118aa3c4..2ae6e3e7 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -32,7 +32,8 @@ func setupTestDB(t *testing.T) *DB { first_seen TEXT, advert_count INTEGER DEFAULT 0, battery_mv INTEGER, - temperature_c REAL + temperature_c REAL, + foreign_advert INTEGER DEFAULT 0 ); CREATE TABLE observers ( @@ -1173,7 +1174,8 @@ func setupTestDBV2(t *testing.T) *DB { first_seen TEXT, advert_count INTEGER DEFAULT 0, battery_mv INTEGER, - temperature_c REAL + temperature_c REAL, + foreign_advert INTEGER DEFAULT 0 ); CREATE TABLE observers ( diff --git a/cmd/server/foreign_advert_test.go b/cmd/server/foreign_advert_test.go new file mode 100644 index 00000000..39207601 --- /dev/null +++ b/cmd/server/foreign_advert_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestHandleNodes_ExposesForeignAdvertField asserts the /api/nodes response +// surfaces the foreign_advert column as a boolean `foreign` field on each +// node, so operators can see bridged/leaked nodes (#730). +func TestHandleNodes_ExposesForeignAdvertField(t *testing.T) { + srv, router := setupTestServer(t) + conn := srv.db.conn + + if _, err := conn.Exec(`INSERT INTO nodes + (public_key, name, role, lat, lon, last_seen, first_seen, advert_count, foreign_advert) + VALUES + ('PK_LOCAL', 'local-node', 'companion', 37.0, -122.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 1, 0), + ('PK_FOREIGN', 'foreign-node', 'companion', 50.0, 10.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 1, 1)`, + ); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/nodes?limit=100", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + + var resp struct { + Nodes []map[string]interface{} `json:"nodes"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + + got := map[string]bool{} + for _, n := range resp.Nodes { + pk, _ := n["public_key"].(string) + f, ok := n["foreign"].(bool) + if !ok { + t.Errorf("node %s: missing/non-bool 'foreign' field, got %T %v", pk, n["foreign"], n["foreign"]) + continue + } + got[pk] = f + } + if !got["PK_LOCAL"] == false || got["PK_LOCAL"] != false { + t.Errorf("PK_LOCAL foreign=%v, want false", got["PK_LOCAL"]) + } + if got["PK_FOREIGN"] != true { + t.Errorf("PK_FOREIGN foreign=%v, want true", got["PK_FOREIGN"]) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 020e0a37..73709ab4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -205,6 +205,13 @@ func main() { log.Printf("[store] warning: could not add observers.last_packet_at column: %v", err) } + // Ensure nodes.foreign_advert column exists (#730 reads it on every /api/nodes + // scan; ingestor migration foreign_advert_v1 adds it but server may run against + // DBs ingestor never touched, e.g. e2e fixture). + if err := ensureForeignAdvertColumn(dbPath); err != nil { + log.Printf("[store] warning: could not add nodes.foreign_advert column: %v", err) + } + // Soft-delete observers that are in the blacklist (mark inactive=1) so // historical data from a prior unblocked window is hidden too. if len(cfg.ObserverBlacklist) > 0 { diff --git a/cmd/server/neighbor_persist.go b/cmd/server/neighbor_persist.go index 4abb1ac7..2d5af964 100644 --- a/cmd/server/neighbor_persist.go +++ b/cmd/server/neighbor_persist.go @@ -353,6 +353,52 @@ func ensureLastPacketAtColumn(dbPath string) error { return nil } +// ensureForeignAdvertColumn adds the foreign_advert column to nodes/inactive_nodes +// if missing (#730). The column is added by the ingestor migration foreign_advert_v1 +// — but the server may run against a DB the ingestor has never touched (e2e fixture, +// fresh installs where the server boots first), in which case scanNodeRow fails +// with "no such column: foreign_advert" and /api/nodes silently returns nothing. +func ensureForeignAdvertColumn(dbPath string) error { + rw, err := cachedRW(dbPath) + if err != nil { + return err + } + for _, table := range []string{"nodes", "inactive_nodes"} { + has, err := tableHasColumn(rw, table, "foreign_advert") + if err != nil { + return fmt.Errorf("inspect %s: %w", table, err) + } + if has { + continue + } + if _, err := rw.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN foreign_advert INTEGER DEFAULT 0", table)); err != nil { + return fmt.Errorf("add foreign_advert to %s: %w", table, err) + } + log.Printf("[store] Added foreign_advert column to %s", table) + } + return nil +} + +// tableHasColumn reports whether the named table has the named column. +func tableHasColumn(rw *sql.DB, table, column string) (bool, error) { + rows, err := rw.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var cid int + var colName string + var colType sql.NullString + var notNull, pk int + var dflt sql.NullString + if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == column { + return true, nil + } + } + return false, nil +} + // softDeleteBlacklistedObservers marks observers matching the blacklist as // inactive=1 so they are hidden from API responses. Runs once at startup. func softDeleteBlacklistedObservers(dbPath string, blacklist []string) { diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 2483a62b..bc82af87 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1119,6 +1119,16 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { if s.cfg.GeoFilter != nil { filtered := nodes[:0] for _, node := range nodes { + // Foreign-flagged nodes (#730) are kept even when their GPS lies + // outside the geofilter polygon — that's the whole point of the + // flag: operators need to SEE bridged/leaked nodes, not have them + // filtered away. The ingestor sets foreign_advert=1 when its + // configured geo_filter rejected the advert; the server must + // surface those. + if isForeign, _ := node["foreign"].(bool); isForeign { + filtered = append(filtered, node) + continue + } if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) { filtered = append(filtered, node) } diff --git a/config.example.json b/config.example.json index a58cb91c..80ac0a55 100644 --- a/config.example.json +++ b/config.example.json @@ -176,6 +176,10 @@ "bufferKm": 20, "_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon, save drafts to localStorage with Save Draft, and export a config snippet with Download — paste the snippet here as the `geo_filter` block. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through." }, + "foreignAdverts": { + "mode": "flag", + "_comment": "Controls how the ingestor handles ADVERTs whose GPS is OUTSIDE the geo_filter polygon (#730). 'flag' (default): store the advert/node and tag it foreign_advert=1 so operators can see bridged/leaked nodes via the API ('foreign': true on /api/nodes). 'drop': legacy behavior — silently discard the advert (no log, no node row). Only applies when geo_filter is configured; otherwise has no effect." + }, "regions": { "SJC": "San Jose, US", "SFO": "San Francisco, US",