feat(nodes): indicate repeaters outside configured local borders (#153)

This commit is contained in:
n30nex authored and GitHub committed 2026-09-19 15:27:34 -04:00
1 parent 775aedfd2a
commit 6aba17c75e
18 files changed
+668

No files matched your search

+28
View File
@@ -244,6 +244,7 @@ websocket:
# Node staleness, deletion, and clock-drift thresholds.
nodes:
mark_foreign: false # optional indication for repeaters outside configured IATA borders
stale_threshold: 24h # mark a node "stale" in the API after this long unseen (default: 24h)
delete_after: 720h # delete a node entirely after this long unseen (default: 30 days, same default as packets.retention)
clock_drift_threshold: 5m # |device clock - server clock| above which clockOutOfSync=true for a repeater/room server (default: 5m)
@@ -287,6 +288,33 @@ must be defined here — they are not auto-created.
---
### Foreign repeater indication
Set `nodes.mark_foreign: true` to expose `possiblyForeign` on repeater nodes.
The local operating area is the union of **all configured**
`iatas.<code>.borderFile` GeoJSON Polygon/MultiPolygon features. IATAs without
border files do not add an area; airport coordinates and the current API region
filter are not boundaries. Enabling this with no borders, missing files or
invalid geometry fails startup. Border changes require a restart.
Inside any polygon (including its edges) means `false`; outside the entire union
means `true`. Hole interiors are outside; hole edges are local. Other node roles,
missing/invalid positions and the 0/0 location reset have no classification.
This is a hint based on reported position, not proof of a repeater's origin.
Packet ingestion, heard-in IATAs and route matching remain unchanged.
Node list/detail reads apply the current geometry after cache reads, so existing
historical nodes need no backfill. A `nodeUpdate` includes `possiblyForeign`
when its advert provides a position: a boolean for known positions, `null` to
clear an unknown/reset position. Omission retains the previous value when a
repeater's advert omits its position. A change to another role also sends `null`.
The field is omitted everywhere when the feature is disabled (the default).
Use longitude/latitude coordinate order and split antimeridian-crossing borders
into MultiPolygons as described in [RFC 7946 section 3.1.9](https://www.rfc-editor.org/rfc/rfc7946#section-3.1.9).
Classification rejects unsplit edges spanning more than 180 degrees rather than
silently treating them as the complementary global area.
## Authentication
API authentication is not yet implemented. Beacon is intended for trusted
+10
View File
@@ -108,6 +108,11 @@ func main() {
}
resolved := config.Resolve(cfg)
localBorders, err := config.LoadLocalBorders(cfg)
if err != nil {
slog.Error("invalid local border configuration", "component", "startup", "error", err)
os.Exit(1)
}
slog.Info(fmt.Sprintf("config: loaded — %s", resolved), "component", "startup")
@@ -242,6 +247,7 @@ func main() {
broker1 := ingest.New(
ingest.Config{
BrokerName: "mqtt1",
LocalBorders: localBorders,
URL: getEnv("MQTT_BROKER_1_URL"),
Username: getEnv("MQTT_BROKER_1_USERNAME"),
Password: getEnv("MQTT_BROKER_1_PASSWORD"),
@@ -257,6 +263,7 @@ func main() {
broker2 := ingest.New(
ingest.Config{
BrokerName: "mqtt2",
LocalBorders: localBorders,
URL: getEnv("MQTT_BROKER_2_URL"),
Username: getEnv("MQTT_BROKER_2_USERNAME"),
Password: getEnv("MQTT_BROKER_2_PASSWORD"),
@@ -293,6 +300,9 @@ func main() {
go scheduler.Start(ctx)
// ── HTTP server ──────────────────────────────────────────────────────────
// Wrap after wiring cache invalidators and cleanup callbacks to the actual
// CachedReader. Only response projections receive the geographic annotation.
reader = api.WithLocalBorders(reader, localBorders)
r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, resolved.MaxConnsPerIP, resolved.MaxConnectsPerMinute, cfg.CORS, cfg.Server, cfg.Auth, resolved.RateLimit)
srv := &http.Server{
+3
View File
@@ -145,6 +145,9 @@ websocket:
# Node staleness, deletion, and clock-drift thresholds.
#nodes:
# mark_foreign: false # opt in to possiblyForeign on repeater nodes
# # uses the union of configured iatas.*.borderFile polygons;
# # requires at least one valid border; no packet filtering
# stale_threshold: 24h # default: 24h
# delete_after: 720h # default: 30 days, same default as packets.retention
# clock_drift_threshold: 5m # default: 5m
+8
View File
@@ -3094,6 +3094,10 @@ const docTemplate = `{
"description": "UUID of the associated observer row, if any",
"type": "string"
},
"possiblyForeign": {
"description": "PossiblyForeign compares a repeater's stored position with this server's\nconfigured IATA border union. Omitted when disabled, unknown, or not repeater.",
"type": "boolean"
},
"publicKey": {
"description": "hex-encoded Ed25519 public key",
"type": "string"
@@ -3224,6 +3228,10 @@ const docTemplate = `{
"description": "UUID of the associated observer row, if any",
"type": "string"
},
"possiblyForeign": {
"description": "PossiblyForeign compares a repeater's stored position with this server's\nconfigured IATA border union. Omitted when disabled, unknown, or not repeater.",
"type": "boolean"
},
"publicKey": {
"description": "hex-encoded Ed25519 public key",
"type": "string"
+8
View File
@@ -3092,6 +3092,10 @@
"description": "UUID of the associated observer row, if any",
"type": "string"
},
"possiblyForeign": {
"description": "PossiblyForeign compares a repeater's stored position with this server's\nconfigured IATA border union. Omitted when disabled, unknown, or not repeater.",
"type": "boolean"
},
"publicKey": {
"description": "hex-encoded Ed25519 public key",
"type": "string"
@@ -3222,6 +3226,10 @@
"description": "UUID of the associated observer row, if any",
"type": "string"
},
"possiblyForeign": {
"description": "PossiblyForeign compares a repeater's stored position with this server's\nconfigured IATA border union. Omitted when disabled, unknown, or not repeater.",
"type": "boolean"
},
"publicKey": {
"description": "hex-encoded Ed25519 public key",
"type": "string"
+10
View File
@@ -318,6 +318,11 @@ definitions:
observerId:
description: UUID of the associated observer row, if any
type: string
possiblyForeign:
description: |-
PossiblyForeign compares a repeater's stored position with this server's
configured IATA border union. Omitted when disabled, unknown, or not repeater.
type: boolean
publicKey:
description: hex-encoded Ed25519 public key
type: string
@@ -413,6 +418,11 @@ definitions:
observerId:
description: UUID of the associated observer row, if any
type: string
possiblyForeign:
description: |-
PossiblyForeign compares a repeater's stored position with this server's
configured IATA border union. Omitted when disabled, unknown, or not repeater.
type: boolean
publicKey:
description: hex-encoded Ed25519 public key
type: string
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package api
import (
"context"
"slices"
"github.com/MeshCore-Beacon/beacon-server/internal/borders"
"github.com/google/uuid"
)
// WithLocalBorders annotates node responses after cache reads. Historical rows
// use the current startup configuration without extra SQL or cached-policy drift.
// Cached values are copied; the decorator never changes its inner reader's data.
func WithLocalBorders(reader Reader, local *borders.Local) Reader {
if local == nil {
return reader
}
return &localBorderReader{Reader: reader, local: local}
}
type localBorderReader struct {
Reader
local *borders.Local
}
func (r *localBorderReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, paths, traces *bool, pubkey []byte, prefix, name, scope string, cursor int64, limit int32, neighbors bool) (Page[NodeSummary], error) {
page, err := r.Reader.ListNodes(ctx, nodeType, iatas, paths, traces, pubkey, prefix, name, scope, cursor, limit, neighbors)
if err != nil {
return page, err
}
page.Items = slices.Clone(page.Items)
for i := range page.Items {
node := &page.Items[i]
node.PossiblyForeign = r.local.PossiblyForeign(node.NodeType, node.Latitude, node.Longitude)
}
return page, nil
}
func (r *localBorderReader) GetNode(ctx context.Context, id uuid.UUID) (*Node, error) {
node, err := r.Reader.GetNode(ctx, id)
if err != nil || node == nil {
return node, err
}
copy := *node
copy.PossiblyForeign = r.local.PossiblyForeign(node.NodeType, node.Latitude, node.Longitude)
return &copy, nil
}
+124
View File
@@ -0,0 +1,124 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package api_test
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/borders"
"github.com/MeshCore-Beacon/beacon-server/internal/cache"
"github.com/alicebob/miniredis/v2"
"github.com/google/uuid"
"github.com/paulmach/orb"
)
type borderReader struct {
api.Reader
node *api.Node
page api.Page[api.NodeSummary]
getCalls, listCalls int
err error
}
func (r *borderReader) GetNode(context.Context, uuid.UUID) (*api.Node, error) {
r.getCalls++
return r.node, r.err
}
func (r *borderReader) ListNodes(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) {
r.listCalls++
return r.page, r.err
}
func TestLocalBordersAfterCache(t *testing.T) {
ctx := context.Background()
local, err := borders.New([]orb.Polygon{{{{10, 10}, {20, 10}, {20, 20}, {10, 20}, {10, 10}}}})
if err != nil {
t.Fatal(err)
}
other, err := borders.New([]orb.Polygon{{{{30, 30}, {40, 30}, {40, 40}, {30, 40}, {30, 30}}}})
if err != nil {
t.Fatal(err)
}
node := &api.Node{NodeSummary: api.NodeSummary{ID: uuid.New(), NodeType: 2, Latitude: new(15.0), Longitude: new(15.0), IATAs: []api.NodeIATA{{IATA: "ZZZ", LastHeard: 1}}}}
base := &borderReader{node: node, page: api.Page[api.NodeSummary]{Items: []api.NodeSummary{node.NodeSummary}, HasMore: true, NextCursor: new(int64(123))}}
mr := miniredis.RunT(t)
client := cache.NewClient(mr.Addr(), "", 0)
t.Cleanup(func() { client.Close() })
cached := cache.NewCachedReader(base, client, cache.CacheTTLs{Nodes: time.Hour})
first := api.WithLocalBorders(cached, local)
second := api.WithLocalBorders(cached, other)
if api.WithLocalBorders(cached, nil) != cached {
t.Fatal("disabled decorator changed reader")
}
for _, tc := range []struct {
reader api.Reader
want bool
}{{first, false}, {second, true}} {
got, err := tc.reader.GetNode(ctx, node.ID)
if err != nil {
t.Fatal(err)
}
if got.PossiblyForeign == nil || *got.PossiblyForeign != tc.want || got.IATAs[0].IATA != "ZZZ" {
t.Fatalf("detail: %+v", got)
}
page, err := tc.reader.ListNodes(ctx, 2, []string{"ZZZ"}, nil, nil, nil, "", "", "", 0, 50, false)
if err != nil {
t.Fatal(err)
}
if !page.HasMore || *page.NextCursor != 123 || page.Items[0].PossiblyForeign == nil || *page.Items[0].PossiblyForeign != tc.want {
t.Fatalf("page: %+v", page)
}
data, _ := json.Marshal(got)
if !strings.Contains(string(data), `"possiblyForeign":`) {
t.Fatal("known false was omitted")
}
}
if base.getCalls != 1 || base.listCalls != 2 {
t.Fatalf("extra reads: get=%d list=%d", base.getCalls, base.listCalls)
}
if node.PossiblyForeign != nil || base.page.Items[0].PossiblyForeign != nil {
t.Fatal("shared reader data was mutated")
}
disabled, err := cached.GetNode(ctx, node.ID)
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(disabled)
if strings.Contains(string(data), "possiblyForeign") {
t.Fatal("classification leaked into cached data")
}
// Existing ingest invalidation still supplies fresh coordinates to the wrapper.
base.node.Longitude = new(35.0)
cached.(*cache.CachedReader).InvalidateNode(ctx, node.ID)
updated, err := first.GetNode(ctx, node.ID)
if err != nil || updated.PossiblyForeign == nil || !*updated.PossiblyForeign || base.getCalls != 2 {
t.Fatal("updated coordinates not classified")
}
}
func TestLocalBordersPreserveReaderErrors(t *testing.T) {
local, err := borders.New([]orb.Polygon{{{{10, 10}, {20, 10}, {20, 20}, {10, 20}, {10, 10}}}})
if err != nil {
t.Fatal(err)
}
want := errors.New("reader unavailable")
base := &borderReader{err: want}
reader := api.WithLocalBorders(base, local)
if _, err := reader.GetNode(context.Background(), uuid.New()); !errors.Is(err, want) {
t.Fatal(err)
}
if _, err := reader.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 1, false); !errors.Is(err, want) {
t.Fatal(err)
}
base.err = nil
if node, err := reader.GetNode(context.Background(), uuid.New()); node != nil || err != nil {
t.Fatal("nil result changed")
}
}
+3
View File
@@ -53,6 +53,9 @@ type NodeSummary struct {
// Applies to every node type, unlike ClockDriftSeconds/ClockOutOfSync on Node, which
// are repeater/room-server only.
Stale bool `json:"stale"`
// PossiblyForeign compares a repeater's stored position with this server's
// configured IATA border union. Omitted when disabled, unknown, or not repeater.
PossiblyForeign *bool `json:"possiblyForeign,omitempty"`
}
// Node is the full node representation including firmware capability flags,
+90
View File
@@ -0,0 +1,90 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package borders classifies advertised repeater positions against a site's
// immutable, configured operating area. It does not change heard-in IATAs.
package borders
import (
"fmt"
"math"
"github.com/meshcore-go/meshcore-go"
"github.com/paulmach/orb"
"github.com/paulmach/orb/planar"
)
type polygon struct {
rings orb.Polygon
bound orb.Bound
}
// Local owns the configured polygon union and precomputed bounds.
// A nil Local disables classification. Methods do not mutate shared state.
type Local struct{ polygons []polygon }
// New copies the polygons so the classifier can be shared across readers and
// ingest workers. Antimeridian regions must use GeoJSON's split representation.
func New(polygons []orb.Polygon) (*Local, error) {
if len(polygons) == 0 {
return nil, fmt.Errorf("no local border polygons configured")
}
local := &Local{}
for _, poly := range polygons {
if len(poly) == 0 {
return nil, fmt.Errorf("border polygon has no rings")
}
for _, ring := range poly {
if !ring.Closed() {
return nil, fmt.Errorf("border ring must be closed with at least four points")
}
for i, p := range ring {
if !validPosition(p.Lat(), p.Lon()) {
return nil, fmt.Errorf("border coordinates must be finite longitude/latitude in range")
}
if i > 0 && math.Abs(p.Lon()-ring[i-1].Lon()) > 180 {
return nil, fmt.Errorf("split antimeridian-crossing borders into a MultiPolygon")
}
}
}
if planar.Area(poly) <= 0 {
return nil, fmt.Errorf("border polygon must have positive area")
}
copy := poly.Clone()
local.polygons = append(local.polygons, polygon{copy, copy.Bound()})
}
return local, nil
}
func validPosition(lat, lng float64) bool {
return !math.IsNaN(lat) && !math.IsNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180
}
// PossiblyForeign is nil when disabled, the role is not repeater, or position
// is unknown/invalid (including the protocol's 0/0 location reset).
// All polygon boundaries, including hole edges, count as local; hole interiors
// remain outside. Location is self-reported, so this is an indication only.
func (local *Local) PossiblyForeign(role int16, lat, lng *float64) *bool {
if local == nil || role != int16(meshcore.AdvertTypeRepeater) || lat == nil || lng == nil || !validPosition(*lat, *lng) || *lat == 0 && *lng == 0 {
return nil
}
point := orb.Point{*lng, *lat}
foreign := true
for _, poly := range local.polygons {
if !poly.bound.Contains(point) || !planar.RingContains(poly.rings[0], point) {
continue
}
inside := true
for _, hole := range poly.rings[1:] {
if planar.RingContains(hole, point) && planar.DistanceFrom(orb.LineString(hole), point) != 0 {
inside = false
break
}
}
if inside {
foreign = false
break
}
}
return &foreign
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package borders
import (
"math"
"testing"
"github.com/paulmach/orb"
)
func TestPossiblyForeign(t *testing.T) {
outer := orb.Ring{{10, 10}, {20, 10}, {20, 20}, {10, 20}, {10, 10}}
hole := orb.Ring{{12, 12}, {14, 12}, {14, 14}, {12, 14}, {12, 12}}
other := orb.Ring{{30, 10}, {40, 10}, {40, 20}, {30, 20}, {30, 10}}
local, err := New([]orb.Polygon{{outer, hole}, {other}})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
role int16
lat, lng float64
want *bool
}{
{"inside", 2, 15, 15, new(false)},
{"outside", 2, 25, 25, new(true)},
{"second polygon", 2, 15, 35, new(false)},
{"hole", 2, 13, 13, new(true)},
{"exterior edge", 2, 15, 10, new(false)},
{"hole edge", 2, 13, 12, new(false)},
{"vertex", 2, 10, 10, new(false)},
{"companion", 1, 25, 25, nil},
{"room", 3, 25, 25, nil},
{"sensor", 4, 25, 25, nil},
{"zero reset", 2, 0, 0, nil},
{"equator is valid", 2, 0, 25, new(true)},
{"prime meridian is valid", 2, 25, 0, new(true)},
{"invalid latitude", 2, 91, 25, nil},
{"invalid longitude", 2, 25, -181, nil},
{"NaN", 2, math.NaN(), 25, nil},
{"infinite", 2, 25, math.Inf(1), nil},
} {
t.Run(tc.name, func(t *testing.T) {
got := local.PossiblyForeign(tc.role, &tc.lat, &tc.lng)
if (got == nil) != (tc.want == nil) || got != nil && *got != *tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
})
}
if local.PossiblyForeign(2, nil, new(15.0)) != nil || local.PossiblyForeign(2, new(15.0), nil) != nil {
t.Fatal("missing coordinate classified")
}
var disabled *Local
if disabled.PossiblyForeign(2, new(25.0), new(25.0)) != nil {
t.Fatal("disabled classification")
}
// The shared startup classifier owns its geometry; callers cannot mutate it.
outer[0] = orb.Point{99, 99}
if *local.PossiblyForeign(2, new(15.0), new(15.0)) {
t.Fatal("input mutation changed classifier")
}
}
func TestLocalBordersValidation(t *testing.T) {
for _, polys := range [][]orb.Polygon{
nil, {{}}, {{{{0, 0}, {1, 0}, {1, 1}}}},
{{{{0, 0}, {0, 0}, {0, 0}, {0, 0}}}},
{{{{179, 10}, {-179, 10}, {-179, 20}, {179, 20}, {179, 10}}}},
} {
if _, err := New(polys); err == nil {
t.Fatalf("accepted invalid/uncut geometry: %v", polys)
}
}
cut := []orb.Polygon{
{{{179, 10}, {180, 10}, {180, 20}, {179, 20}, {179, 10}}},
{{{-180, 10}, {-179, 10}, {-179, 20}, {-180, 20}, {-180, 10}}},
}
local, err := New(cut)
if err != nil {
t.Fatal(err)
}
for _, lon := range []float64{179.5, -179.5, 180, -180} {
if *local.PossiblyForeign(2, new(15.0), &lon) {
t.Fatalf("dateline island excluded: %v", lon)
}
}
if !*local.PossiblyForeign(2, new(15.0), new(0.0)) {
t.Fatal("uncut global region inferred")
}
}
func BenchmarkPossiblyForeign(b *testing.B) {
ring := make(orb.Ring, 10001)
for i := 0; i < 10000; i++ {
a := 2 * math.Pi * float64(i) / 10000
ring[i] = orb.Point{20 + math.Cos(a), 50 + math.Sin(a)}
}
ring[10000] = ring[0]
local, err := New([]orb.Polygon{{ring}})
if err != nil {
b.Fatal(err)
}
for _, tc := range []struct {
name string
lat, lng float64
}{{"inside_10k_vertices", 50, 20}, {"outside_bounds_10k_vertices", 0, 1}} {
b.Run(tc.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
local.PossiblyForeign(2, &tc.lat, &tc.lng)
}
})
}
}
+3
View File
@@ -30,6 +30,9 @@ func ValidateBorder(raw []byte) (json.RawMessage, error) {
if feat.Type != "Feature" {
return nil, fmt.Errorf(`top-level "type" must be "Feature", got %q`, feat.Type)
}
if feat.Geometry == nil {
return nil, fmt.Errorf("Feature must have a Polygon or MultiPolygon geometry")
}
switch geom := feat.Geometry.(type) {
case orb.Polygon:
if err := validateBorderPolygon(geom); err != nil {
+3
View File
@@ -259,6 +259,9 @@ type RoutesConfig struct {
// NodesConfig controls node-derived signal thresholds.
type NodesConfig struct {
// MarkForeign annotates repeaters outside the union of configured IATA border
// files. Disabled by default; enabling it requires at least one usable border.
MarkForeign bool `yaml:"mark_foreign"`
// ClockDriftThreshold is the |device clock - server clock| magnitude, measured from a
// repeater/room server's ADVERT timestamp, above which the node API reports
// clockOutOfSync=true for that node. Defaults to 5m if not set.
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package config
import (
"fmt"
"os"
"sort"
"github.com/MeshCore-Beacon/beacon-server/internal/borders"
"github.com/paulmach/orb"
"github.com/paulmach/orb/geojson"
)
// LoadLocalBorders builds one startup-only classifier from configured border
// files. IATA airport coordinates and auto-discovered IATAs are not boundaries.
func LoadLocalBorders(cfg *Config) (*borders.Local, error) {
if !cfg.Nodes.MarkForeign {
return nil, nil
}
var codes []string
for code, details := range cfg.IATAs {
if details.BorderFile != "" {
codes = append(codes, code)
}
}
sort.Strings(codes)
var polygons []orb.Polygon
for _, code := range codes {
raw, err := os.ReadFile(cfg.IATAs[code].BorderFile)
if err != nil {
return nil, fmt.Errorf("nodes.mark_foreign: reading border for %s: %w", code, err)
}
validated, err := ValidateBorder(raw)
if err != nil {
return nil, fmt.Errorf("nodes.mark_foreign: invalid border for %s: %w", code, err)
}
feature, err := geojson.UnmarshalFeature(validated)
if err != nil {
return nil, fmt.Errorf("nodes.mark_foreign: decoding border for %s: %w", code, err)
}
switch geometry := feature.Geometry.(type) {
case orb.Polygon:
polygons = append(polygons, geometry)
case orb.MultiPolygon:
polygons = append(polygons, geometry...)
}
}
local, err := borders.New(polygons)
if err != nil {
return nil, fmt.Errorf("nodes.mark_foreign: %w", err)
}
return local, nil
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateBorderNoGeometry(t *testing.T) {
for _, raw := range []string{`{"type":"Feature","geometry":null}`, `{"type":"Feature"}`} {
if _, err := ValidateBorder([]byte(raw)); err == nil {
t.Fatal("missing geometry accepted")
}
}
}
func TestLoadLocalBorders(t *testing.T) {
if got, err := LoadLocalBorders(&Config{}); err != nil || got != nil {
t.Fatalf("disabled: %v %v", got, err)
}
if _, err := LoadLocalBorders(&Config{Nodes: NodesConfig{MarkForeign: true}}); err == nil {
t.Fatal("enabled with no border")
}
dir := t.TempDir()
border := filepath.Join(dir, "region.json")
raw := `{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[10,10],[20,10],[20,20],[10,20],[10,10]]],[[[30,10],[40,10],[40,20],[30,20],[30,10]]]]}}`
if err := os.WriteFile(border, []byte(raw), 0600); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte("nodes:\n mark_foreign: true\niatas:\n AAA:\n borderFile: region.json\n BBB:\n name: Airport without an operating border\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
local, err := LoadLocalBorders(cfg)
if err != nil {
t.Fatal(err)
}
if *local.PossiblyForeign(2, new(15.0), new(15.0)) || *local.PossiblyForeign(2, new(15.0), new(35.0)) || !*local.PossiblyForeign(2, new(25.0), new(25.0)) {
t.Fatal("polygon union not used")
}
for _, bad := range []string{`not json`, `{"type":"Feature","geometry":null}`, `{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[179,10],[-179,10],[-179,20],[179,20],[179,10]]]}}`} {
if err := os.WriteFile(border, []byte(bad), 0600); err != nil {
t.Fatal(err)
}
if _, err := LoadLocalBorders(cfg); err == nil || !strings.Contains(err.Error(), "nodes.mark_foreign") {
t.Fatalf("bad boundary did not fail clearly: %v", err)
}
}
if err := os.Remove(border); err != nil {
t.Fatal(err)
}
if _, err := LoadLocalBorders(cfg); err == nil {
t.Fatal("missing file accepted")
}
if *local.PossiblyForeign(2, new(15.0), new(15.0)) {
t.Fatal("running classifier changed with its source file")
}
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package ingest
import (
"context"
"encoding/binary"
"encoding/json"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/borders"
"github.com/MeshCore-Beacon/beacon-server/internal/hub"
"github.com/meshcore-go/meshcore-go"
"github.com/paulmach/orb"
)
func TestForeignNodeUpdates(t *testing.T) {
local, err := borders.New([]orb.Polygon{{{{10, 10}, {20, 10}, {20, 20}, {10, 20}, {10, 10}}}})
if err != nil {
t.Fatal(err)
}
w, _ := newTestWorker()
go w.hub.Run()
client := w.hub.NewClient()
defer w.hub.Remove(client)
w.hub.AddScope(client, "foreign", hub.Scope{Events: []hub.EventType{hub.EventNodeUpdate, hub.EventObserverStatus}})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
waitForSummarySubscriber(t, ctx, w.hub, client)
for _, tc := range []struct {
name string
enabled, position bool
role byte
lat, lng int32
present bool
want any
}{
{"disabled", false, true, 2, 25000000, 25000000, false, nil},
{"inside", true, true, 2, 15000000, 15000000, true, false},
{"outside", true, true, 2, 25000000, 25000000, true, true},
{"omitted retains old position", true, false, 2, 0, 0, false, nil},
{"explicit reset clears flag", true, true, 2, 0, 0, true, nil},
{"invalid position clears flag", true, true, 2, 91000000, 25000000, true, nil},
{"role change clears flag", true, false, meshcore.AdvertTypeChat, 0, 0, true, nil},
} {
t.Run(tc.name, func(t *testing.T) {
w.cfg.LocalBorders = nil
if tc.enabled {
w.cfg.LocalBorders = local
}
data := []byte{tc.role}
if tc.position {
data[0] |= meshcore.AdvertLatLonMask
data = binary.LittleEndian.AppendUint32(data, uint32(tc.lat))
data = binary.LittleEndian.AppendUint32(data, uint32(tc.lng))
}
w.handlePayloadTypeSideEffects(ctx, buildAdvertPacketWithData(t, data, false), "AAA", []byte{1}, RadioSettings{}, nil, nil, nil, 0)
for {
select {
case event := <-client.Send:
if event.Type != hub.EventNodeUpdate {
continue
}
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatal(err)
}
got, present := payload["possiblyForeign"]
if present != tc.present || got != tc.want {
t.Fatalf("flag = %v (present=%v); want %v (present=%v)", got, present, tc.want, tc.present)
}
return
case <-ctx.Done():
t.Fatal("node update not received")
}
}
})
}
}
+3
View File
@@ -50,6 +50,7 @@ import (
"github.com/google/uuid"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/borders"
"github.com/MeshCore-Beacon/beacon-server/internal/hub"
"github.com/MeshCore-Beacon/beacon-server/internal/keystore"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
@@ -57,6 +58,8 @@ import (
// Config holds the connection parameters for one broker.
type Config struct {
// LocalBorders is the same immutable classifier used by node API reads.
LocalBorders *borders.Local
// BrokerName is a short human-readable label ("mqtt1", "mqtt2") used in
// log messages and stored in packet_observations.source_broker.
BrokerName string
+7
View File
@@ -61,6 +61,9 @@ type nodeUpdateEvent struct {
IATAs []api.NodeIATA `json:"iatas"`
DefaultScope *string `json:"defaultScope,omitempty"`
Radio *string `json:"radio,omitempty"`
// Omission retains the previous classification (no advertised position).
// The outer pointer allows explicit null to clear an unknown/reset position.
PossiblyForeign **bool `json:"possiblyForeign,omitempty"`
}
// handlePayloadTypeSideEffects runs payload-type-specific processing after a
@@ -173,6 +176,10 @@ func (w *Worker) handlePayloadTypeSideEffects(ctx context.Context, packet *meshc
DefaultScope: defaultScope,
Radio: radioStr,
}
if w.cfg.LocalBorders != nil && (lat != nil || advert.Type() != meshcore.AdvertTypeRepeater) {
foreign := w.cfg.LocalBorders.PossiblyForeign(int16(advert.Type()), lat, lon)
evt.PossiblyForeign = &foreign
}
w.broadcast(hub.EventNodeUpdate, iata, meshcore.PayloadTypeAdvert, "", evt)
return
}