mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-23 09:52:32 +00:00
feat(nodes): indicate repeaters outside configured local borders (#153)
This commit is contained in:
1 parent
775aedfd2a
commit
6aba17c75e
18 files changed
+668
No files matched your search
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ©, nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user