mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-01 16:48:19 +00:00
291 lines
9.6 KiB
Go
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)
|
|
}
|
|
}
|