mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 08:46:54 +00:00
## Summary **Partial fix for #730 (M1 only — M2 frontend and M3 alerting deferred).** Today the ingestor **silently drops** ADVERTs whose GPS lies outside the configured `geo_filter` polygon. That's the wrong default for an analytics tool — operators get zero visibility into bridged or leaked meshes. This PR makes the new default **flag, don't drop**: foreign adverts are stored, the node row is tagged `foreign_advert=1`, and the API surfaces `"foreign": true` so dashboards / map overlays can be built on top. ## Behavior | Mode | What happens to an ADVERT outside `geo_filter` | |---|---| | (default) flag | Stored, marked `foreign_advert=1`, exposed via API | | drop (legacy) | Silently dropped (preserves old behavior for ops who want it) | ## What's done (M1 — Backend) - ingestor stores foreign adverts instead of dropping - `nodes.foreign_advert` column added (migration) - `/api/nodes` and `/api/nodes/{pk}` expose `foreign: true` field - Config: `geofilter.action: "flag"|"drop"` (default `flag`) - Tests + config docs ## What's NOT done (deferred to M2 + M3) - **M2 — Frontend:** Map overlay showing foreign adverts as distinct markers, foreign-advert filter on packets/nodes pages, dedicated foreign-advert dashboard - **M3 — Alerting:** Time-series detection of bridging events, alert when foreign advert rate spikes, identify bridge entry-point nodes Issue #730 remains open for M2 and M3. --------- Co-authored-by: corescope-bot <bot@corescope>
This commit is contained in:
+19
-1
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-2
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -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)
|
||||
|
||||
+7
-5
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user