Files
beacon-server/cmd/beacon/main.go
T
Enot (ded) Skelly d9a641b99c add log for no IATA filter
inverse of existing log for active filters
2026-06-05 08:38:55 -07:00

291 lines
9.6 KiB
Go

package main
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/MeshCore-Beacon/beacon-server/db"
_ "github.com/MeshCore-Beacon/beacon-server/docs"
"github.com/MeshCore-Beacon/beacon-server/internal/api/router"
"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/scopestore"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
)
// @title MeshCore Beacon API
// @version 1.0
// @description MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket.
// @termsOfService https://github.com/MeshCore-Beacon/beacon-server
// @contact.name MeshCore Beacon
// @contact.url https://github.com/MeshCore-Beacon/beacon-server
// @license.name MIT
// @host localhost:8080
// @BasePath /api/v1
// @schemes http 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 {
log.Fatalf("failed to load config: %v", err)
}
telemetryResolution := cfg.Telemetry.Resolution.Duration
if telemetryResolution == 0 {
telemetryResolution = time.Hour
}
telemetryRetention := cfg.Telemetry.Retention.Duration
if telemetryRetention == 0 {
telemetryRetention = 28 * 24 * time.Hour // 4 weeks
}
packetRetention := cfg.Packets.Retention.Duration
if packetRetention == 0 {
packetRetention = 30 * 24 * time.Hour // 30 days
}
maxConnsPerIP := cfg.WebSocket.MaxConnectionsPerIP
if maxConnsPerIP == 0 {
maxConnsPerIP = 5
}
// ── Hub ──────────────────────────────────────────────────────────────────
h := hub.New()
go h.Run()
// ── MQTT ingest workers ──────────────────────────────────────────────────
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool, err := pgxpool.New(ctx, mustEnv("POSTGRES_DSN"))
if err != nil {
log.Fatalf("failed to connect to postgres at %s: %v", os.Getenv("POSTGRES_DSN_HOST"), err)
}
defer pool.Close()
store := db.New(pool)
// refresh meterialized views on boot or restart to stay fresh
refreshMaterializedViews(ctx, store)
// ── Seed config data ─────────────────────────────────────────────────────
if err := config.Seed(ctx, cfg, store); err != nil {
log.Fatalf("failed to seed config: %v", err)
}
// ── Build transport scope keystore ───────────────────────────────────────
scopes := scopestore.New()
scopeEntries, err := store.GetTransportScopes(ctx)
if err != nil {
log.Fatalf("failed to load transport scopes: %v", err)
}
scopes.Load(scopeEntries)
log.Printf("loaded %d transport scopes", len(scopeEntries))
// ── 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 !entryExists(entries[hashHex], entry) {
entries[hashHex] = append(entries[hashHex], entry)
log.Printf("config: loaded hashtag channel #%s (hash=%s)", tag, hashHex)
}
}
// 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 {
log.Printf("warning: invalid channel key for hash %s, skipping: %v", hashHex, err)
continue
}
entry := keystore.Entry{
Key: key,
Fingerprint: keystore.Fingerprint(key),
Name: keyCfg.Name,
}
if !entryExists(entries[hashHex], entry) {
entries[hashHex] = append(entries[hashHex], entry)
log.Printf("config: loaded explicit channel key for hash %s name=%q", hashHex, keyCfg.Name)
}
}
keys := keystore.NewMapKeyStore(entries)
// ── Build geographic ingest filter ───────────────────────────────────────────────────────────
allowedIATAs := iatadb.BuildAllowedSet(cfg.Ingest.AllowCountries, cfg.Ingest.AllowContinents)
if allowedIATAs != nil {
log.Printf("config: ingest filter active — %d allowed IATAs (countries=%v continents=%v)",
len(allowedIATAs), cfg.Ingest.AllowCountries, cfg.Ingest.AllowContinents)
} else {
log.Printf("config: ingest filter inactive — accepting all IATAs")
}
broker1 := ingest.New(
ingest.Config{
BrokerName: "mqtt1",
URL: mustEnv("MQTT_BROKER_1_URL"),
Username: mustEnv("MQTT_BROKER_1_USERNAME"),
Password: mustEnv("MQTT_BROKER_1_PASSWORD"),
TelemetryResolution: telemetryResolution,
AllowedIATAs: allowedIATAs,
},
store,
h,
keys,
scopes,
)
broker2 := ingest.New(
ingest.Config{
BrokerName: "mqtt2",
URL: mustEnv("MQTT_BROKER_2_URL"),
Username: mustEnv("MQTT_BROKER_2_USERNAME"),
Password: mustEnv("MQTT_BROKER_2_PASSWORD"),
TelemetryResolution: telemetryResolution,
AllowedIATAs: allowedIATAs,
},
store,
h,
keys,
scopes,
)
go broker1.Start(ctx)
go broker2.Start(ctx)
// ── cleanup and materialized view refresh goroutine ─────────────────────────────────────────
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := store.DeleteOldTelemetry(ctx, time.Now().Add(-telemetryRetention)); err != nil {
log.Printf("cleanup: delete old telemetry failed: %v", err)
}
if err := store.DeleteOldPackets(ctx, time.Now().Add(-packetRetention)); err != nil {
log.Printf("cleanup: delete old packets failed: %v", err)
}
refreshMaterializedViews(ctx, store)
case <-ctx.Done():
return
}
}
}()
// ── HTTP server ──────────────────────────────────────────────────────────
r := router.New(h, store, []*ingest.Worker{broker1, broker2}, maxConnsPerIP)
srv := &http.Server{
Addr: addr,
Handler: r,
}
go func() {
fmt.Printf("Beacon listening on %s\n", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
// ── Graceful shutdown ────────────────────────────────────────────────────
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
cancel() // stops ingest workers
if err := srv.Shutdown(context.Background()); err != nil {
log.Printf("server shutdown error: %v", err)
}
}
// entryExists checks if an identical key entry already exists in the slice
// to prevent loading duplicate key/hash pairs from config.
func entryExists(entries []keystore.Entry, e keystore.Entry) bool {
for _, existing := range entries {
if bytes.Equal(existing.Key, e.Key) {
return true
}
}
return false
}
// mustEnv 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 mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
log.Printf("warning: %s is not set", key)
}
return v
}
func refreshMaterializedViews(ctx context.Context, store *db.Store) {
if err := store.RefreshHourlyStats(ctx); err != nil {
log.Printf("refresh: materialized view for hourly stats failed: %v", err)
}
if err := store.RefreshTopNodes(ctx); err != nil {
log.Printf("refresh: materialized view for top nodes failed: %v", err)
}
if err := store.RefreshRadioPresets(ctx); err != nil {
log.Printf("refresh: materialized view for radio presets failed: %v", err)
}
}