mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-20 08:42:01 +00:00
Imported boundaries override borderFile on the border map and for foreign marking, falling back to the manual border when MeshMapper has none.
420 lines
17 KiB
Go
420 lines
17 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/db"
|
|
_ "github.com/MeshCore-Beacon/beacon-server/docs"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api/handlers"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api/router"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/background"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/borders"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/cache"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/config"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/hub"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/iatadb"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/ingest"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/keystore"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/logging"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/meshmapper"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/presence"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/joho/godotenv"
|
|
)
|
|
|
|
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
|
|
// Falls back to "dev" when built without the flag.
|
|
var version = "dev"
|
|
|
|
// @title MeshCore Beacon API
|
|
// @version 1.6.0
|
|
// @description MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket.
|
|
// @description REST requests share a configurable per-client rate limit (default 300/minute and a 300-request one-second burst cap). Exceeded limits return HTTP 429 with error.code=rate_limited and a Retry-After header in seconds. CORS preflights and WebSocket upgrades do not consume this API budget.
|
|
// @description WebSocket upgrade attempts at /ws have a configurable per-client limit (default 10/minute, including failed handshakes). Rate exhaustion returns HTTP 429 with Retry-After before upgrade. An accepted socket exceeding the concurrent cap closes with code 1013 before hello; established connections remain open.
|
|
// @termsOfService https://github.com/MeshCore-Beacon/beacon-server
|
|
|
|
// @contact.name MeshCore Beacon
|
|
// @contact.url https://github.com/MeshCore-Beacon/beacon-server
|
|
|
|
// @license.name AGPL-3-or-later
|
|
|
|
// @host localhost:8080
|
|
// @BasePath /api/v1
|
|
|
|
// @schemes http https
|
|
|
|
// @securityDefinitions.apikey AdminKey
|
|
// @in header
|
|
// @name Authorization
|
|
// @description Enter Bearer followed by the configured operator key. Use HTTPS.
|
|
|
|
// @tag.name IATAs
|
|
// @tag.description Airport/location codes that group observers and packets
|
|
// @tag.name Regions
|
|
// @tag.description Super-regions grouping multiple IATAs
|
|
// @tag.name Observers
|
|
// @tag.description MeshCore MQTT observers (gateways)
|
|
// @tag.name Nodes
|
|
// @tag.description MeshCore radio nodes
|
|
// @tag.name Packets
|
|
// @tag.description LoRa packets heard by observers
|
|
// @tag.name Channels
|
|
// @tag.description MeshCore group text channels
|
|
// @tag.name Messages
|
|
// @tag.description Decrypted channel messages
|
|
// @tag.name Brokers
|
|
// @tag.description MQTT broker connection status
|
|
// @tag.name Stats
|
|
// @tag.description Network statistics and time series
|
|
func main() {
|
|
_ = godotenv.Load()
|
|
addr := os.Getenv("LISTEN_ADDR")
|
|
if addr == "" {
|
|
addr = ":8080"
|
|
}
|
|
|
|
configPath := os.Getenv("CONFIG_PATH")
|
|
if configPath == "" {
|
|
configPath = "config.yaml"
|
|
}
|
|
|
|
cfg, err := config.Load(configPath)
|
|
if err != nil {
|
|
slog.Error("failed to load config", "component", "startup", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
logger, err := logging.New(os.Stderr, cfg.Log)
|
|
if err != nil {
|
|
slog.Error("invalid logging configuration", "component", "startup", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.SetDefault(logger)
|
|
slog.Info("beacon starting", "component", "startup", "version", version)
|
|
if len(cfg.Server.TrustedProxies) == 0 {
|
|
slog.Warn("warning: server.trusted_proxies is empty; client IP headers are ignored and proxied clients share a WebSocket connection limit", "component", "startup")
|
|
}
|
|
|
|
resolved := config.Resolve(cfg)
|
|
borderFiles, err := config.LoadLocalBorders(cfg)
|
|
if err != nil {
|
|
slog.Error("invalid local border configuration", "component", "startup", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
var localBorders *borders.Live
|
|
if cfg.Nodes.MarkForeign {
|
|
local, _ := config.BuildLocalBorders(borderFiles, nil) // checked by LoadLocalBorders
|
|
localBorders = borders.NewLive(local)
|
|
}
|
|
|
|
slog.Info(fmt.Sprintf("config: loaded — %s", resolved), "component", "startup")
|
|
|
|
// ── Hub ──────────────────────────────────────────────────────────────────
|
|
h := hub.New()
|
|
go h.Run()
|
|
|
|
// ── MQTT ingest workers ──────────────────────────────────────────────────
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
dsn := getEnv("POSTGRES_DSN")
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
// Parse errors can embed the complete DSN, including its password.
|
|
slog.Error("invalid PostgreSQL connection configuration; check POSTGRES_DSN", "component", "startup")
|
|
os.Exit(1)
|
|
}
|
|
defer pool.Close()
|
|
backupOpts := configureBackup(ctx, cfg, pool, dsn, configPath)
|
|
|
|
if err := db.RunMigrations(ctx, pool); err != nil {
|
|
slog.Error("migrations failed", "component", "startup", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
store := db.New(pool, resolved.ClockDriftThreshold, resolved.NodeStaleThreshold)
|
|
|
|
// ── Presence write coalescing ────────────────────────────────────────────
|
|
// Ingest writes go through the coalescer; reads keep using the store.
|
|
coalescer := presence.New(store, resolved.PresenceFlushInterval, resolved.PresencePacketTTL)
|
|
go coalescer.Run(ctx)
|
|
|
|
// ── Redis cache layer ────────────────────────────────────────────────────
|
|
var reader api.Reader = store
|
|
if redisAddr := os.Getenv("REDIS_ADDR"); redisAddr != "" {
|
|
redisClient := cache.NewClient(
|
|
redisAddr,
|
|
os.Getenv("REDIS_PASSWORD"),
|
|
func() int {
|
|
if db := os.Getenv("REDIS_DB"); db != "" {
|
|
n, err := strconv.Atoi(db)
|
|
if err == nil {
|
|
return n
|
|
}
|
|
}
|
|
return 0
|
|
}(),
|
|
)
|
|
if err := redisClient.Ping(ctx); err != nil {
|
|
slog.Warn(fmt.Sprintf("warning: redis unavailable at %s, caching disabled", redisAddr), "component", "startup", "error", err)
|
|
} else {
|
|
ttls := cache.ResolveTTLs(cfg.Cache)
|
|
reader = cache.NewCachedReader(store, redisClient, ttls)
|
|
defer redisClient.Close()
|
|
slog.Info(fmt.Sprintf("cache: Redis connected at %s (stats=%s reference=%s nodes=%s observers=%s)", redisAddr, ttls.Stats, ttls.Reference, ttls.Nodes, ttls.Observers), "component", "startup")
|
|
}
|
|
}
|
|
|
|
// ── Seed config data ─────────────────────────────────────────────────────
|
|
if err := config.Seed(ctx, cfg, store); err != nil {
|
|
slog.Error("failed to seed config", "component", "startup", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// ── Build transport scope keystore ───────────────────────────────────────
|
|
scopes := scopestore.New()
|
|
scopeEntries, err := store.GetTransportScopes(ctx)
|
|
if err != nil {
|
|
slog.Error("failed to load transport scopes", "component", "startup", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
scopes.Load(scopeEntries)
|
|
slog.Info(fmt.Sprintf("loaded %d transport scopes", len(scopeEntries)), "component", "startup")
|
|
var scopeImporter *meshmapper.Importer
|
|
if cfg.MeshMapper.Scopes.Enabled {
|
|
restoreCtx, cancelRestore := context.WithTimeout(ctx, 10*time.Second)
|
|
scopeImporter, err = meshmapper.New(restoreCtx, cfg.MeshMapper.Scopes, store, scopes, scopeEntries)
|
|
cancelRestore()
|
|
if err != nil {
|
|
slog.Error("failed to restore MeshMapper scope catalogues", "component", "startup", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Always restored, so disabling the import prunes it and file borders return.
|
|
zones := meshmapper.NewZones(cfg.MeshMapper.Zones, cfg.RegionIATAs(), store)
|
|
if cr, ok := reader.(*cache.CachedReader); ok {
|
|
zones.OnChange(cr.InvalidateIATABorder)
|
|
}
|
|
if localBorders != nil {
|
|
zones.OnUpdate(func(imported map[string]json.RawMessage) {
|
|
local, err := config.BuildLocalBorders(borderFiles, imported)
|
|
if err != nil {
|
|
slog.Error("MeshMapper boundaries rejected for foreign marking", "component", "meshmapper.zones", "error", err)
|
|
return
|
|
}
|
|
localBorders.Store(local)
|
|
})
|
|
}
|
|
restoreCtx, cancelRestore := context.WithTimeout(ctx, 10*time.Second)
|
|
err = zones.Restore(restoreCtx)
|
|
cancelRestore()
|
|
if err != nil {
|
|
slog.Error("failed to restore MeshMapper boundaries", "component", "startup", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
if localBorders != nil && !localBorders.Ready() {
|
|
slog.Warn("nodes.mark_foreign has no boundaries yet; waiting for MeshMapper", "component", "startup")
|
|
}
|
|
|
|
// ── Build channel keystore ──────────────────────────────────────────────
|
|
entries := make(map[string][]keystore.Entry)
|
|
|
|
// Hashtag-derived channels: secret = SHA256("#tag")[:16], hash = SHA256(secret)[0]
|
|
for _, tag := range cfg.ChannelKeys.Hashtags {
|
|
secret, channelHash, fingerprint := keystore.DeriveHashtagKey(tag)
|
|
hashHex := fmt.Sprintf("%02x", channelHash)
|
|
entry := keystore.Entry{
|
|
Key: secret,
|
|
Fingerprint: fingerprint,
|
|
Hashtag: tag,
|
|
Name: "#" + tag,
|
|
}
|
|
if !keystore.EntryExists(entries[hashHex], entry) {
|
|
entries[hashHex] = append(entries[hashHex], entry)
|
|
slog.Info(fmt.Sprintf("config: loaded hashtag channel #%s (hash=%s)", tag, hashHex), "component", "startup")
|
|
}
|
|
}
|
|
|
|
// Explicit keys: hash provided directly, key is hex-encoded
|
|
for hashHex, keyCfg := range cfg.ChannelKeys.Keys {
|
|
key, err := hex.DecodeString(keyCfg.Key)
|
|
if err != nil {
|
|
slog.Warn(fmt.Sprintf("warning: invalid channel key for hash %s, skipping", hashHex), "component", "startup", "error", err)
|
|
continue
|
|
}
|
|
entry := keystore.Entry{
|
|
Key: key,
|
|
Fingerprint: keystore.Fingerprint(key),
|
|
Name: keyCfg.Name,
|
|
}
|
|
if !keystore.EntryExists(entries[hashHex], entry) {
|
|
entries[hashHex] = append(entries[hashHex], entry)
|
|
slog.Info(fmt.Sprintf("config: loaded explicit channel key for hash %s name=%q", hashHex, keyCfg.Name), "component", "startup")
|
|
}
|
|
}
|
|
|
|
keys := keystore.NewMapKeyStore(entries)
|
|
|
|
// ── Backfill channel messages ────────────────────────────────────────────
|
|
// Packets whose channel key wasn't yet configured at ingest time were stored as
|
|
// hash-only channels and never decrypted. Retry them now against the keystore we just
|
|
// built, so adding a channel key to the config surfaces its history on the next boot
|
|
// instead of leaving it stranded in the DB indefinitely.
|
|
if n, err := ingest.BackfillChannelMessages(ctx, store, keys); err != nil {
|
|
slog.Error("config: channel message backfill failed", "component", "startup", "error", err)
|
|
} else if n > 0 {
|
|
slog.Info(fmt.Sprintf("config: backfilled %d previously-undecrypted channel message(s)", n), "component", "startup")
|
|
}
|
|
|
|
// ── Build geographic ingest filter ───────────────────────────────────────────────────────────
|
|
allowedIATAs := iatadb.BuildAllowedSet(cfg.Ingest.AllowCountries, cfg.Ingest.AllowContinents)
|
|
if allowedIATAs != nil {
|
|
slog.Info(fmt.Sprintf("config: ingest filter active — %d allowed IATAs (countries=%v continents=%v)", len(allowedIATAs), cfg.Ingest.AllowCountries, cfg.Ingest.AllowContinents), "component", "startup")
|
|
} else {
|
|
slog.Info("config: ingest filter inactive — accepting all IATAs", "component", "startup")
|
|
}
|
|
|
|
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"),
|
|
TelemetryResolution: resolved.TelemetryResolution,
|
|
AllowedIATAs: allowedIATAs,
|
|
},
|
|
coalescer,
|
|
h,
|
|
keys,
|
|
scopes,
|
|
)
|
|
|
|
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"),
|
|
TelemetryResolution: resolved.TelemetryResolution,
|
|
AllowedIATAs: allowedIATAs,
|
|
},
|
|
coalescer,
|
|
h,
|
|
keys,
|
|
scopes,
|
|
)
|
|
|
|
if cr, ok := reader.(*cache.CachedReader); ok {
|
|
if scopeImporter != nil {
|
|
scopeImporter.SetCacheInvalidator(cr.InvalidateScopeNames)
|
|
}
|
|
broker1.SetCacheInvalidators(cr.InvalidateNode, cr.InvalidateObserver)
|
|
broker2.SetCacheInvalidators(cr.InvalidateNode, cr.InvalidateObserver)
|
|
}
|
|
|
|
var ingestWorkers sync.WaitGroup
|
|
ingestWorkers.Go(func() { broker1.Start(ctx) })
|
|
ingestWorkers.Go(func() { broker2.Start(ctx) })
|
|
|
|
tasks := []background.Task{
|
|
background.ViewRefreshTask(store, resolved.ViewRefreshInterval),
|
|
background.CleanupTask(store, resolved.TelemetryRetention, resolved.PacketRetention, resolved.NodeDeleteAfter, resolved.CleanupInterval),
|
|
background.ReconfirmTask(store, resolved.RouteRetention, resolved.RouteGrace, int64(resolved.RouteMinObservations), resolved.ReconfirmInterval),
|
|
}
|
|
if resolved.ObserverDeleteAfter > 0 {
|
|
var onDelete func(context.Context, uuid.UUID)
|
|
if cr, ok := reader.(*cache.CachedReader); ok {
|
|
onDelete = cr.InvalidateObserver
|
|
}
|
|
tasks = append(tasks, background.ObserverCleanupTask(coalescer, resolved.ObserverDeleteAfter, resolved.CleanupInterval, onDelete))
|
|
}
|
|
if scopeImporter != nil {
|
|
tasks = append(tasks, background.Task{Name: "meshmapper.scopes", Interval: meshmapper.PollInterval, Run: scopeImporter.Refresh})
|
|
}
|
|
if cfg.MeshMapper.Zones.Enabled {
|
|
tasks = append(tasks, background.Task{Name: "meshmapper.zones", Interval: meshmapper.PollInterval, Run: zones.Refresh})
|
|
}
|
|
profiles := configureProfiling(ctx, pool)
|
|
defer profiles.Stop()
|
|
for i := range tasks {
|
|
tasks[i].Run = profiles.WrapTask(tasks[i].Name, tasks[i].Run)
|
|
}
|
|
scheduler := background.New(tasks)
|
|
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}, router.Options{
|
|
MaxConnsPerIP: resolved.MaxConnsPerIP,
|
|
MaxConnectsPerMinute: resolved.MaxConnectsPerMinute,
|
|
WSAllowedOrigins: cfg.WebSocket.AllowedOrigins,
|
|
CORS: cfg.CORS, Server: cfg.Server, Auth: cfg.Auth, RateLimit: resolved.RateLimit,
|
|
AdminRoutes: map[string]http.Handler{
|
|
"/accounts": handlers.AccountsRouter(store),
|
|
"/backup": handlers.BackupRouter(backupOpts, ctx),
|
|
},
|
|
})
|
|
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: r,
|
|
ErrorLog: slog.NewLogLogger(slog.Default().With("component", "http").Handler(), slog.LevelError),
|
|
}
|
|
|
|
go func() {
|
|
slog.Info(fmt.Sprintf("Beacon listening on %s", addr), "component", "startup")
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
slog.Error("server error", "component", "startup", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
|
|
// ── Graceful shutdown ────────────────────────────────────────────────────
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
<-quit
|
|
|
|
slog.Info("shutting down...", "component", "startup")
|
|
cancel() // stops ingest workers
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer shutdownCancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
slog.Error("server shutdown error", "component", "startup", "error", err)
|
|
}
|
|
ingestWorkers.Wait()
|
|
coalescer.Flush(shutdownCtx)
|
|
}
|
|
|
|
// getEnv returns the value of an env var and logs a warning if it is unset.
|
|
// Callers that require the value to be non-empty should fatal themselves;
|
|
// ingest workers tolerate missing broker config and will fail on connect instead.
|
|
func getEnv(key string) string {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
slog.Warn(fmt.Sprintf("warning: %s is not set", key), "component", "startup")
|
|
}
|
|
return v
|
|
}
|