Files
MrAlders0n a51d950e1f Pace the read-model backfills so they cannot starve a small server's disk (#257)
* Pace the one-off backfills by duty cycle so they cannot starve a small server's disk

* Plan the read-model fills with custom plans and analyze new read-model tables

* Analyze only never-analyzed read-model tables, bound the fill pause, and reset fill progress
2026-10-20 12:16:31 -04:00

894 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package config loads the Beacon configuration file and seeds the database
// with regions, IATA overrides, and channel keys on startup.
package config
import (
"fmt"
"net"
"net/netip"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"unicode"
"gopkg.in/yaml.v3"
"github.com/MeshCore-Beacon/beacon-server/db/planpolicy"
)
// Config is the top-level structure of the Beacon config file.
type Config struct {
Auth AuthConfig `yaml:"auth"`
Backup BackupConfig `yaml:"backup"`
Log LogConfig `yaml:"log"`
Server ServerConfig `yaml:"server"`
IATAs map[string]IATAConfig `yaml:"iatas"`
Regions []RegionConfig `yaml:"regions"`
ChannelKeys ChannelKeysConfig `yaml:"channel_keys"`
Telemetry TelemetryConfig `yaml:"telemetry"`
WebSocket WebSocketConfig `yaml:"websocket"`
Packets PacketsConfig `yaml:"packets"`
Routes RoutesConfig `yaml:"routes"`
Ingest IngestFilterConfig `yaml:"ingest"`
Scopes []ScopeConfig `yaml:"scopes"`
MeshMapper MeshMapperConfig `yaml:"meshmapper"`
Cache CacheConfig `yaml:"cache"`
CORS CORSConfig `yaml:"cors"`
RateLimit RateLimitConfig `yaml:"ratelimit"`
Background BackgroundConfig `yaml:"background"`
Presence PresenceConfig `yaml:"presence"`
Nodes NodesConfig `yaml:"nodes"`
Observers ObserversConfig `yaml:"observers"`
Analytics AnalyticsConfig `yaml:"analytics"`
Mobile MobileConfig `yaml:"mobile"`
Web WebConfig `yaml:"web"`
Database DatabaseConfig `yaml:"database"`
Guards GuardsConfig `yaml:"guards"`
Planguard PlanguardConfig `yaml:"planguard"`
ReadModels ReadModelsConfig `yaml:"readmodels"`
}
// ReadModelsConfig switches serving pages from each read model and paces their one-off backfills.
type ReadModelsConfig struct {
Cmi ReadModelConfig `yaml:"cmi"`
PacketSites ReadModelConfig `yaml:"packet_sites"`
Fill FillConfig `yaml:"fill"`
}
// FillConfig paces the one-off backfills (packet_sites, cmi and observation payload types). After each slice or
// batch a fill sleeps max(min_pause, elapsed × (1 − duty) / duty), where elapsed is that slice's run time, so it is
// busy at most duty of the time and a slow disk stretches the pauses on its own. Unset fields take the defaults.
type FillConfig struct {
Duty *float64 `yaml:"duty"` // 0.01 to 1; default 0.5
MinPause *duration `yaml:"min_pause"` // 0 to 1m; default 100ms
PacketSitesSlice *int64 `yaml:"packet_sites_slice"` // observation ids per packet_sites slice; default 500000
CmiBatch *int64 `yaml:"cmi_batch"` // message ids per cmi batch; default 10000
}
// Fill pacing defaults and limits.
const (
defaultFillDuty = 0.5
minFillDuty = 0.01 // db.MinFillDuty: 1 % busy
defaultFillMinPause = 100 * time.Millisecond
defaultFillPacketSitesSlice = 500_000
defaultFillCmiBatch = 10_000
maxFillMinPause = time.Minute
maxFillPacketSitesSlice = 10_000_000
maxFillCmiBatch = 1_000_000
)
// validate checks each set value's range.
func (f FillConfig) validate() error {
if d := f.Duty; d != nil && !(*d >= minFillDuty && *d <= 1) { // also rejects NaN
return fmt.Errorf("readmodels.fill.duty must be between %v and 1, got %v", minFillDuty, *d)
}
if d := f.MinPause; d != nil && (d.Duration < 0 || d.Duration > maxFillMinPause) {
return fmt.Errorf("readmodels.fill.min_pause must be between 0s and %s, got %s", maxFillMinPause, d.Duration)
}
if n := f.PacketSitesSlice; n != nil && (*n < 1 || *n > maxFillPacketSitesSlice) {
return fmt.Errorf("readmodels.fill.packet_sites_slice must be between 1 and %d, got %d", maxFillPacketSitesSlice, *n)
}
if n := f.CmiBatch; n != nil && (*n < 1 || *n > maxFillCmiBatch) {
return fmt.Errorf("readmodels.fill.cmi_batch must be between 1 and %d, got %d", maxFillCmiBatch, *n)
}
return nil
}
// ReadModelConfig controls one read model. Enabled false turns the pages it serves off (they answer 503 with
// Retry-After); the model is still maintained, so turning it back on needs only a restart.
type ReadModelConfig struct {
Enabled *bool `yaml:"enabled"` // nil = true
}
// PlanguardConfig controls the daily read-only plan regression check.
type PlanguardConfig struct {
// DailyCheck runs the check about an hour after boot and then daily. Off by default: the corpus's plan
// expectations assume production-sized tables, so small deployments would report false failures.
DailyCheck bool `yaml:"daily_check"`
}
// DatabaseConfig controls how API statements are spread across connection pools.
type DatabaseConfig struct {
// APIPlanLayout is custom_all (default), split or single.
APIPlanLayout string `yaml:"api_plan_layout"`
// SlowPoolSize is api_slow's connection count under custom_all and split; nil means 2.
SlowPoolSize *int `yaml:"slow_pool_size"`
}
// GuardsConfig controls the cost guards on the public API.
type GuardsConfig struct {
// Enforce lists the guard families that refuse; every other family only observes.
Enforce []string `yaml:"enforce"`
// Budget must be shadow: the per-client DB-time budget only records.
Budget string `yaml:"budget"`
}
// guardFamilies are the guard family names accepted by guards.enforce, in display order.
var guardFamilies = []string{"routes", "channel_messages", "packets_global", "packets_region", "packets_window", "packet_detail", "stats", "other"}
// MobileConfig holds settings the BEACON Mobile app reads from /info.
type MobileConfig struct {
// MinAppVersion is the oldest app version (X.Y.Z) allowed to use this server; empty means any.
MinAppVersion string `yaml:"min_app_version"`
}
// WebConfig holds settings Beacon Web reads from /info.
type WebConfig struct {
// MinWebVersion raises the built-in Beacon Web floor (X.Y.Z); it can't lower it.
MinWebVersion string `yaml:"min_web_version"`
}
// BackupConfig enables protected downloads. Disabled by default.
type BackupConfig struct {
Enabled bool `yaml:"enabled"`
}
// AuthConfig holds the operator key for the protected admin subtree.
// The key is excluded from JSON; it must not be exposed by configuration APIs.
type AuthConfig struct {
APIKey string `yaml:"api_key" json:"-"`
}
// LogConfig controls application verbosity and stderr output format.
type LogConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
// ServerConfig controls which direct peers may supply the client address.
type ServerConfig struct {
// TrustedProxies accepts IPv4/IPv6 CIDRs; an empty list trusts no proxy.
// netip.Prefix validates each CIDR while the configuration is loaded.
TrustedProxies []netip.Prefix `yaml:"trusted_proxies"`
// AdminListen is the host:port of the admin listener serving /metrics; empty disables it.
AdminListen string `yaml:"admin_listen"`
// ReadOnly boots a server with no migrations, ingest, jobs or boot writes; every database session is read-only.
ReadOnly bool `yaml:"read_only"`
}
func (c *ServerConfig) UnmarshalYAML(node *yaml.Node) error {
// Pointers retain null list entries, which yaml would otherwise discard.
// Leave them as invalid prefixes for Load's validation below.
var raw struct {
TrustedProxies []*netip.Prefix `yaml:"trusted_proxies"`
AdminListen string `yaml:"admin_listen"`
ReadOnly bool `yaml:"read_only"`
}
if err := node.Decode(&raw); err != nil {
return err
}
c.TrustedProxies = make([]netip.Prefix, len(raw.TrustedProxies))
for i, prefix := range raw.TrustedProxies {
if prefix != nil {
c.TrustedProxies[i] = *prefix
}
}
c.AdminListen = raw.AdminListen
c.ReadOnly = raw.ReadOnly
return nil
}
// ResolvedConfig holds all runtime configuration with defaults applied.
type ResolvedConfig struct {
RateLimit ResolvedRateLimitConfig
TelemetryResolution time.Duration
TelemetryRetention time.Duration
RollupRetention time.Duration
PacketRetention time.Duration
RouteRetention time.Duration
RouteGrace time.Duration
RouteMinObservations int
// RouteReconfirmRowsPerHour is the reconfirm sweep's pace; defaults to 81000.
RouteReconfirmRowsPerHour int
MaxConnsPerIP int
MaxConnectsPerMinute int
ViewRefreshInterval time.Duration
ReconfirmInterval time.Duration
CleanupInterval time.Duration
PresenceFlushInterval time.Duration
PresencePacketTTL time.Duration
// ClockDriftThreshold is the |device clock - server clock| magnitude, measured from a
// node's ADVERT timestamp, above which the node API reports clockOutOfSync=true for
// that node. Only meaningful for repeaters/room servers (nodeType 2/3).
ClockDriftThreshold time.Duration
// NodeStaleThreshold and NodeDeleteAfter mirror ClockDriftThreshold's "0 means unset,
// resolve to a default" pattern -- see NodesConfig.
NodeStaleThreshold time.Duration
NodeDeleteAfter time.Duration
ObserverDeleteAfter time.Duration
// APIPlanLayout selects the API pool layout; defaults to custom_all.
APIPlanLayout planpolicy.Layout
// SlowPoolSize is api_slow's connection count; defaults to 2.
SlowPoolSize int32
// GuardsEnforce lists the enforcing guard families; empty (never nil) when unset.
GuardsEnforce []string
// GuardsBudget is the per-client DB-time budget mode; always "shadow".
GuardsBudget string
// AdminListen is the admin listener address; empty disables it.
AdminListen string
// ReadOnly: no migrations, ingest, background jobs or boot writes; every database session is read-only.
ReadOnly bool
// PlanguardDailyCheck runs the daily read-only plan check; off by default.
PlanguardDailyCheck bool
// ReadModelCmiEnabled serves region and scope message pages from channel_message_iatas once trusted; false turns
// those pages off (503). Default true.
ReadModelCmiEnabled bool
// ReadModelPacketSitesEnabled serves region packet pages from packet_sites once trusted; false turns those pages
// off (503). Default true.
ReadModelPacketSitesEnabled bool
// FillDuty, FillMinPause, FillPacketSitesSlice and FillCmiBatch pace the one-off backfills; see FillConfig.
FillDuty float64
FillMinPause time.Duration
FillPacketSitesSlice int64
FillCmiBatch int64
}
// RateLimitConfig controls the per-client REST API request budget.
type RateLimitConfig struct {
Enabled *bool `yaml:"enabled"` // Defaults to true; false disables both windows.
RequestsPerMinute int `yaml:"requests_per_minute"`
Burst int `yaml:"burst"` // One-second window cap, not token-bucket capacity.
}
// ResolvedRateLimitConfig has defaults applied and no optional values.
type ResolvedRateLimitConfig struct {
Enabled bool
RequestsPerMinute int
Burst int
}
// PresenceConfig controls coalescing of presence bookkeeping writes
// (observer last_seen, observer_brokers, packet last_heard_at bumps).
type PresenceConfig struct {
// FlushInterval is how often coalesced bumps are flushed to Postgres.
// Defaults to 30s if not set.
FlushInterval duration `yaml:"flush_interval"`
// PacketTTL is how long a packet hash with no re-observations stays
// coalesced before the next observation writes through again.
// Defaults to 30s if not set.
PacketTTL duration `yaml:"packet_ttl"`
}
// BackgroundConfig controls the intervals for background maintenance tasks.
type BackgroundConfig struct {
// ViewRefresh is how often materialized views are refreshed.
// Defaults to 1h if not set.
ViewRefresh duration `yaml:"view_refresh"`
// Reconfirm is the interval of route retention and neighbor cleanup.
Reconfirm duration `yaml:"reconfirm"`
// Cleanup is how often old telemetry and packet rows are pruned.
// Defaults to 1h if not set.
Cleanup duration `yaml:"cleanup"`
}
// CORSConfig controls Cross-Origin Resource Sharing behaviour.
// If omitted, Beacon defaults to allowing all origins, which is appropriate
// for a public read-only API. Operators exposing write endpoints should
// restrict AllowedOrigins to known frontends.
type CORSConfig struct {
// AllowedOrigins is the list of origins permitted to make cross-origin
// requests. Use ["*"] to allow all origins (default if omitted).
AllowedOrigins []string `yaml:"allowed_origins"`
// AllowedMethods is the list of HTTP methods allowed in CORS requests.
// Defaults to [GET, HEAD, OPTIONS] if omitted.
AllowedMethods []string `yaml:"allowed_methods"`
// AllowedHeaders is the list of request headers allowed in CORS requests.
// Defaults to [Accept, Authorization, Content-Type] if omitted.
AllowedHeaders []string `yaml:"allowed_headers"`
// AllowCredentials indicates whether the request can include user
// credentials (cookies, HTTP authentication). Defaults to false.
AllowCredentials bool `yaml:"allow_credentials"`
// MaxAge is the number of seconds the browser may cache a preflight
// response. Defaults to 300 if omitted.
MaxAge int `yaml:"max_age"`
}
// CacheConfig controls Redis caching behaviour.
// If Addr is not set (via REDIS_ADDR env var), caching is disabled entirely
// and Beacon falls back to querying PostgreSQL directly for all reads.
type CacheConfig struct {
// TTL is the default cache entry lifetime applied to all categories
// that do not have an explicit override in TTLs. Defaults to 1h if not set.
TTL duration `yaml:"ttl"`
// TTLs holds optional per-category TTL overrides. Any category left
// at zero will fall back to TTL.
TTLs CacheTTLsConfig `yaml:"ttls"`
}
// CacheTTLsConfig holds per-category TTL overrides for the cache layer.
// Each field is optional — omit it in config to inherit the global TTL.
type CacheTTLsConfig struct {
// Stats controls the TTL for aggregated network statistics endpoints
// (overview, observations, payload breakdown, top nodes/observers, radio presets, scope stats).
// Rollup-backed keys also change with the rollup revision.
Stats duration `yaml:"stats"`
// Reference controls the TTL for mostly-static reference data
// (IATAs, regions, scopes). These change only when new observers
// arrive or config is reseeded.
Reference duration `yaml:"reference"`
// Nodes controls the TTL for individual node detail responses.
// Acts as a safety-net expiry alongside explicit invalidation on upsert.
Nodes duration `yaml:"nodes"`
// Observers controls the TTL for individual observer detail responses.
// Acts as a safety-net expiry alongside explicit invalidation on upsert.
Observers duration `yaml:"observers"`
}
// ScopeConfig defines a regional transport scope.
// Name can be provided with or without the # or $ prefix.
// Beacon normalizes plain names by prepending #.
type ScopeConfig struct {
Name string `yaml:"name"` // e.g. "bc", "#west", "$private"
Region string `yaml:"region"` // configured region slug; lists the scope under that region's IATAs
}
// TelemetryConfig controls observer telemetry storage behaviour.
type TelemetryConfig struct {
// Retention is how long telemetry rows are kept before the cleanup job removes them.
// Defaults to 744h (31 days) if not set.
Retention duration `yaml:"retention"`
// Resolution is how frequently a telemetry snapshot is stored per observer.
// Status messages arriving within the same resolution window are deduplicated.
// Defaults to 1h if not set.
Resolution duration `yaml:"resolution"`
}
// WebSocketConfig controls WebSocket connection behaviour.
// Settings here apply to the /ws endpoint only.
type WebSocketConfig struct {
// MaxConnectionsPerIP is the maximum number of concurrent WebSocket
// connections allowed from a single IP address. Defaults to 5 if not set.
MaxConnectionsPerIP int `yaml:"max_connections_per_ip"`
// MaxConnectsPerMinute limits upgrade attempts, including failed handshakes.
// Zero/omitted defaults to 10; IPv6 addresses share a /64 attempt budget.
MaxConnectsPerMinute int `yaml:"max_connects_per_minute"`
// AllowedOrigins are extra origins allowed to open /ws: exact, or scheme://*.domain[:port]
// for every subdomain of domain (never the apex). Defaults to same-host only.
AllowedOrigins []string `yaml:"allowed_origins"`
}
// PacketsConfig controls packet retention behaviour.
type PacketsConfig struct {
// Retention covers packets, observations and channel messages. Defaults to 168h.
Retention duration `yaml:"retention"`
}
// AnalyticsConfig controls hourly rollup retention behaviour.
type AnalyticsConfig struct {
// RollupRetention is how long hourly rollups are kept and the longest window /stats/series accepts. Defaults to 2160h (90 days).
RollupRetention duration `yaml:"rollup_retention"`
}
// RoutesConfig controls known-route retention behaviour.
type RoutesConfig struct {
// Retention is how long a route is kept after it was last observed.
// Defaults to 336h (14 days) if not set.
Retention duration `yaml:"retention"`
// Grace is how long a route observed fewer than MinObservations times is
// kept. Defaults to 168h (7 days) if not set.
Grace duration `yaml:"grace"`
// MinObservations is the observation count below which Grace applies
// instead of Retention. Defaults to 3 if not set.
MinObservations int `yaml:"min_observations"`
// ReconfirmRowsPerHour is how many routes the reconfirm sweep checks per
// hour for departed or now-ambiguous hops, in 5-minute slices. Defaults to
// 81000 if not set.
ReconfirmRowsPerHour int `yaml:"reconfirm_rows_per_hour"`
}
// NodesConfig controls node-derived signal thresholds.
type NodesConfig struct {
// MarkForeign annotates repeaters outside the union of IATA borders, with
// imported MeshMapper boundaries replacing border files. Disabled by default;
// enabling it requires a border file or meshmapper.zones.
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.
ClockDriftThreshold duration `yaml:"clock_drift_threshold"`
// StaleThreshold is how long since a node's last_seen before the node API reports
// stale=true for it. Defaults to 24h if not set.
StaleThreshold duration `yaml:"stale_threshold"`
// DeleteAfter is how long since a node's last_seen before the cleanup job deletes the
// node entirely. Defaults to 720h (30 days) if not set.
DeleteAfter duration `yaml:"delete_after"`
}
// ObserversConfig controls optional observer age-out.
type ObserversConfig struct {
// DeleteAfter is how long an observer must be unseen before it can be deleted.
// Omitted or nonpositive disables age-out. Retained packet observations,
// telemetry and ownership records always protect the observer from deletion.
DeleteAfter duration `yaml:"delete_after"`
}
// duration is a wrapper around time.Duration that supports YAML unmarshalling
// from human-readable strings like "24h", "7d", "30d".
type duration struct {
time.Duration
}
func (d *duration) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err != nil {
return err
}
parsed, err := time.ParseDuration(s)
if err != nil {
return err
}
d.Duration = parsed
return nil
}
// ChannelKeysConfig holds both hashtag-derived and explicit channel keys.
// Hashtag keys are derived automatically: secret = SHA256("#tag")[:16],
// channel_hash = SHA256(secret)[0]. Explicit keys are provided as hex strings
// keyed by the channel hash hex (e.g. "11" for 0x11).
type ChannelKeysConfig struct {
// Hashtags lists hashtag names (without the # prefix), each optionally scoped to a region.
// Beacon derives the PSK and channel hash automatically.
Hashtags []HashtagConfig `yaml:"hashtags"`
// Keys maps channel hash hex → explicit key config.
Keys map[string]ExplicitKeyConfig `yaml:"keys"`
}
// ExplicitKeyConfig holds an explicit channel key and optional display name.
type ExplicitKeyConfig struct {
Key string `yaml:"key"` // hex-encoded key bytes
Name string `yaml:"name"` // optional display name
Region string `yaml:"region"` // optional region slug; empty lists the channel Beacon-wide
}
// IATAConfig holds optional overrides for a known IATA code.
type IATAConfig struct {
Name string `yaml:"name"`
Lat *float64 `yaml:"lat"`
Lng *float64 `yaml:"lng"`
// BorderFile is a path to a GeoJSON Feature file (Polygon or MultiPolygon geometry) for
// this IATA's region border map. Relative paths are resolved against the directory
// containing the main config file (see Load). Validated and bbox-computed at seed time --
// see border.go.
BorderFile string `yaml:"borderFile"`
}
// RegionConfig defines a super-region and its member IATAs.
type RegionConfig struct {
Slug string `yaml:"slug"`
Name string `yaml:"name"`
Description string `yaml:"description"`
DisplayOrder int `yaml:"display_order"`
CenterLat *float64 `yaml:"center_lat"`
CenterLng *float64 `yaml:"center_lng"`
ZoomLevel *int `yaml:"zoom_level"`
IATAs []string `yaml:"iatas"`
}
// IngestFilterConfig restricts which packets Beacon stores based on the
// observer's IATA geographic location. Both filters are optional — if neither
// is set all IATAs are accepted. If both are set an IATA passes if it matches
// either (OR semantics).
//
// Country codes are ISO 3166-1 alpha-2 (e.g. "CA", "US").
// Continent codes are two-letter OurAirports codes: AF, AN, AS, EU, NA, OC, SA.
type IngestFilterConfig struct {
// AllowCountries is a list of ISO 3166-1 alpha-2 country codes to accept.
// Packets from observers in other countries are dropped at ingest.
AllowCountries []string `yaml:"allow_countries"`
// AllowContinents is a list of continent codes to accept.
// Packets from observers in other continents are dropped at ingest.
AllowContinents []string `yaml:"allow_continents"`
}
// Load reads and parses the config file at path.
// Returns an empty Config (not an error) if the file does not exist,
// so Beacon starts cleanly without a config file.
func Load(path string) (*Config, error) {
cfg := &Config{}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
cfg.MeshMapper.APIKey = strings.TrimSpace(os.Getenv("MESHMAPPER_API_KEY"))
cfg.Auth.APIKey = os.Getenv("BEACON_API_KEY")
if err := cfg.validateAPIKey(); err != nil {
return nil, err
}
return cfg, nil
}
return nil, err
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
if value, set := os.LookupEnv("BEACON_API_KEY"); set {
cfg.Auth.APIKey = value
}
if value, set := os.LookupEnv("MESHMAPPER_API_KEY"); set {
cfg.MeshMapper.APIKey = value
}
cfg.MeshMapper.APIKey = strings.TrimSpace(cfg.MeshMapper.APIKey)
if err := cfg.validateAPIKey(); err != nil {
return nil, err
}
if err := cfg.validateDurations(); err != nil {
return nil, err
}
for i, prefix := range cfg.Server.TrustedProxies {
if !prefix.IsValid() {
return nil, fmt.Errorf("server.trusted_proxies[%d] must be a valid CIDR", i)
}
}
if cfg.RateLimit.RequestsPerMinute < 0 || cfg.RateLimit.Burst < 0 {
return nil, fmt.Errorf("ratelimit.requests_per_minute and ratelimit.burst must be positive or zero for defaults")
}
if cfg.Routes.ReconfirmRowsPerHour < 0 {
return nil, fmt.Errorf("routes.reconfirm_rows_per_hour must be positive or zero for the default")
}
if cfg.WebSocket.MaxConnectsPerMinute < 0 {
return nil, fmt.Errorf("websocket.max_connects_per_minute must be positive or zero for the default")
}
for i, origin := range cfg.WebSocket.AllowedOrigins {
if err := validateOrigin(origin); err != nil {
return nil, fmt.Errorf("websocket.allowed_origins[%d]: %w", i, err)
}
}
if d := cfg.Packets.Retention.Duration; d != 0 && d < 24*time.Hour {
return nil, fmt.Errorf("packets.retention must be at least 24h")
}
if d := cfg.Analytics.RollupRetention.Duration; d != 0 && d < 24*time.Hour {
return nil, fmt.Errorf("analytics.rollup_retention must be at least 24h")
}
configDir := filepath.Dir(path)
if err := cfg.validateMeshMapper(); err != nil {
return nil, err
}
if err := cfg.validateChannelKeys(); err != nil {
return nil, err
}
if err := cfg.validateScopes(); err != nil {
return nil, err
}
if err := cfg.validateClientVersions(); err != nil {
return nil, err
}
if cfg.Planguard.DailyCheck && cfg.Server.ReadOnly {
return nil, fmt.Errorf("planguard.daily_check cannot be enabled with server.read_only: a read-only server runs no background jobs")
}
if cfg.Nodes.MarkForeign && !cfg.MeshMapper.Zones.Enabled && !cfg.hasBorderFile() {
return nil, fmt.Errorf("nodes.mark_foreign requires an iatas.*.borderFile or meshmapper.zones")
}
for iata, details := range cfg.IATAs {
if details.BorderFile != "" && !filepath.IsAbs(details.BorderFile) {
details.BorderFile = filepath.Join(configDir, details.BorderFile)
cfg.IATAs[iata] = details
}
}
if err := cfg.validateDatabaseGuards(); err != nil {
return nil, err
}
if err := cfg.ReadModels.Fill.validate(); err != nil {
return nil, err
}
return cfg, nil
}
// canonicalFamilies returns the known families in names once each, in guardFamilies order; never nil.
func canonicalFamilies(names []string) []string {
out := []string{}
for _, f := range guardFamilies {
if slices.Contains(names, f) {
out = append(out, f)
}
}
return out
}
// validateDatabaseGuards checks the plan layout, guard families, budget mode and admin listener address.
func (c *Config) validateDatabaseGuards() error {
if _, err := planpolicy.ParseLayout(c.Database.APIPlanLayout); err != nil {
return err
}
if n := c.Database.SlowPoolSize; n != nil && (*n < 1 || *n > 1000) {
if *n < 1 {
return fmt.Errorf("database.slow_pool_size must be at least 1")
}
return fmt.Errorf("database.slow_pool_size must be at most 1000")
}
for i, family := range c.Guards.Enforce {
if !slices.Contains(guardFamilies, family) {
return fmt.Errorf("guards.enforce[%d]: unknown family %q (%s)", i, family, strings.Join(guardFamilies, ", "))
}
}
if c.Guards.Budget != "" && c.Guards.Budget != "shadow" {
return fmt.Errorf("guards.budget must be shadow (budget enforcement is not available)")
}
if c.Server.AdminListen != "" {
_, port, err := net.SplitHostPort(c.Server.AdminListen)
if n, perr := strconv.Atoi(port); err != nil || perr != nil || n < 1 || n > 65535 {
return fmt.Errorf("server.admin_listen must be host:port, e.g. 127.0.0.1:9090")
}
}
return nil
}
// minAPIKeyLength keeps the admin key out of guessing range.
const minAPIKeyLength = 16
// validateAPIKey trims the key, since Bearer tokens can't carry whitespace; errors never echo it.
func (c *Config) validateAPIKey() error {
c.Auth.APIKey = strings.TrimSpace(c.Auth.APIKey)
switch key := c.Auth.APIKey; {
case key == "":
return nil
case strings.IndexFunc(key, unicode.IsSpace) >= 0:
return fmt.Errorf("auth.api_key / BEACON_API_KEY must not contain whitespace")
case len(key) < minAPIKeyLength:
return fmt.Errorf("auth.api_key / BEACON_API_KEY must be at least %d characters", minAPIKeyLength)
}
return nil
}
var appVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
// validateClientVersions keeps minimum client versions to plain X.Y.Z so every client can compare them.
func (c *Config) validateClientVersions() error {
for _, f := range []struct {
key string
v *string
}{{"mobile.min_app_version", &c.Mobile.MinAppVersion}, {"web.min_web_version", &c.Web.MinWebVersion}} {
*f.v = strings.TrimSpace(*f.v)
if *f.v != "" && !appVersionPattern.MatchString(*f.v) {
return fmt.Errorf("%s must be X.Y.Z (digits only), got %q", f.key, *f.v)
}
}
return nil
}
// validateDurations rejects negative intervals, which would otherwise panic tickers or invert cutoffs.
// observers.delete_after is exempt: nonpositive disables it.
func (c *Config) validateDurations() error {
for _, d := range []struct {
key string
value duration
}{
{"presence.flush_interval", c.Presence.FlushInterval},
{"presence.packet_ttl", c.Presence.PacketTTL},
{"background.view_refresh", c.Background.ViewRefresh},
{"background.reconfirm", c.Background.Reconfirm},
{"background.cleanup", c.Background.Cleanup},
{"cache.ttl", c.Cache.TTL},
{"cache.ttls.stats", c.Cache.TTLs.Stats},
{"cache.ttls.reference", c.Cache.TTLs.Reference},
{"cache.ttls.nodes", c.Cache.TTLs.Nodes},
{"cache.ttls.observers", c.Cache.TTLs.Observers},
{"telemetry.retention", c.Telemetry.Retention},
{"telemetry.resolution", c.Telemetry.Resolution},
{"routes.retention", c.Routes.Retention},
{"routes.grace", c.Routes.Grace},
{"nodes.clock_drift_threshold", c.Nodes.ClockDriftThreshold},
{"nodes.stale_threshold", c.Nodes.StaleThreshold},
{"nodes.delete_after", c.Nodes.DeleteAfter},
{"meshmapper.scopes.refresh_interval", c.MeshMapper.Scopes.RefreshInterval},
{"meshmapper.zones.refresh_interval", c.MeshMapper.Zones.RefreshInterval},
{"meshmapper.channels.refresh_interval", c.MeshMapper.Channels.RefreshInterval},
} {
if d.value.Duration < 0 {
return fmt.Errorf("%s must not be negative (omit it or use 0 for the default)", d.key)
}
}
return nil
}
// validateOrigin allows exact origins or a leading "*." subdomain wildcard; the WebSocket
// library treats entries as path.Match patterns, so every other pattern character is rejected.
func validateOrigin(origin string) error {
u, err := url.Parse(origin)
if err != nil {
return fmt.Errorf("invalid origin %q: %w", origin, err)
}
host := u.Hostname()
// At least two labels after the wildcard, so it never spans a whole TLD.
if rest, ok := strings.CutPrefix(host, "*."); ok {
if labels := strings.Split(rest, "."); len(labels) >= 2 && !slices.Contains(labels, "") {
host = rest
}
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil ||
u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" ||
strings.Contains(host, "*") || strings.ContainsAny(origin, `?[]\`) {
return fmt.Errorf("origin %q must be scheme://host[:port] or scheme://*.domain[:port] with an http or https scheme", origin)
}
return nil
}
// Resolve returns a ResolvedConfig with defaults applied for any zero values.
func Resolve(cfg *Config) ResolvedConfig {
r := ResolvedConfig{
RateLimit: ResolvedRateLimitConfig{
Enabled: cfg.RateLimit.Enabled == nil || *cfg.RateLimit.Enabled,
RequestsPerMinute: cfg.RateLimit.RequestsPerMinute,
Burst: cfg.RateLimit.Burst,
},
TelemetryResolution: cfg.Telemetry.Resolution.Duration,
TelemetryRetention: cfg.Telemetry.Retention.Duration,
RollupRetention: cfg.Analytics.RollupRetention.Duration,
PacketRetention: cfg.Packets.Retention.Duration,
RouteRetention: cfg.Routes.Retention.Duration,
RouteGrace: cfg.Routes.Grace.Duration,
RouteMinObservations: cfg.Routes.MinObservations,
RouteReconfirmRowsPerHour: cfg.Routes.ReconfirmRowsPerHour,
MaxConnsPerIP: cfg.WebSocket.MaxConnectionsPerIP,
MaxConnectsPerMinute: cfg.WebSocket.MaxConnectsPerMinute,
ViewRefreshInterval: cfg.Background.ViewRefresh.Duration,
ReconfirmInterval: cfg.Background.Reconfirm.Duration,
CleanupInterval: cfg.Background.Cleanup.Duration,
PresenceFlushInterval: cfg.Presence.FlushInterval.Duration,
PresencePacketTTL: cfg.Presence.PacketTTL.Duration,
ClockDriftThreshold: cfg.Nodes.ClockDriftThreshold.Duration,
NodeStaleThreshold: cfg.Nodes.StaleThreshold.Duration,
NodeDeleteAfter: cfg.Nodes.DeleteAfter.Duration,
ObserverDeleteAfter: cfg.Observers.DeleteAfter.Duration,
GuardsEnforce: canonicalFamilies(cfg.Guards.Enforce),
GuardsBudget: cfg.Guards.Budget,
AdminListen: cfg.Server.AdminListen,
ReadOnly: cfg.Server.ReadOnly,
PlanguardDailyCheck: cfg.Planguard.DailyCheck,
ReadModelCmiEnabled: cfg.ReadModels.Cmi.Enabled == nil || *cfg.ReadModels.Cmi.Enabled,
ReadModelPacketSitesEnabled: cfg.ReadModels.PacketSites.Enabled == nil || *cfg.ReadModels.PacketSites.Enabled,
}
// Load has validated the layout; an unvalidated config falls back to the default.
r.APIPlanLayout, _ = planpolicy.ParseLayout(cfg.Database.APIPlanLayout)
if r.APIPlanLayout == "" {
r.APIPlanLayout = planpolicy.CustomAll
}
r.SlowPoolSize = 2
if n := cfg.Database.SlowPoolSize; n != nil && *n >= 1 {
r.SlowPoolSize = int32(min(*n, 1000))
}
if r.GuardsBudget == "" {
r.GuardsBudget = "shadow"
}
if r.RateLimit.RequestsPerMinute == 0 {
r.RateLimit.RequestsPerMinute = 600
}
if r.RateLimit.Burst == 0 {
r.RateLimit.Burst = r.RateLimit.RequestsPerMinute
}
if r.TelemetryResolution == 0 {
r.TelemetryResolution = time.Hour
}
if r.TelemetryRetention == 0 {
r.TelemetryRetention = 31 * 24 * time.Hour
}
if r.RollupRetention == 0 {
r.RollupRetention = 90 * 24 * time.Hour
}
if r.PacketRetention == 0 {
r.PacketRetention = 7 * 24 * time.Hour
}
if r.RouteRetention == 0 {
r.RouteRetention = 14 * 24 * time.Hour
}
if r.RouteGrace == 0 {
r.RouteGrace = 7 * 24 * time.Hour
}
if r.RouteMinObservations == 0 {
r.RouteMinObservations = 3
}
if r.RouteReconfirmRowsPerHour == 0 {
r.RouteReconfirmRowsPerHour = 81000
}
if r.MaxConnsPerIP == 0 {
r.MaxConnsPerIP = 5
}
if r.MaxConnectsPerMinute == 0 {
r.MaxConnectsPerMinute = 10
}
if r.ViewRefreshInterval == 0 {
r.ViewRefreshInterval = time.Hour
}
if r.ReconfirmInterval == 0 {
r.ReconfirmInterval = time.Hour
}
if r.CleanupInterval == 0 {
r.CleanupInterval = time.Hour
}
if r.PresenceFlushInterval == 0 {
r.PresenceFlushInterval = 30 * time.Second
}
if r.PresencePacketTTL == 0 {
r.PresencePacketTTL = 30 * time.Second
}
if r.ClockDriftThreshold == 0 {
r.ClockDriftThreshold = 5 * time.Minute
}
if r.NodeStaleThreshold == 0 {
r.NodeStaleThreshold = 24 * time.Hour
}
if r.NodeDeleteAfter == 0 {
r.NodeDeleteAfter = 30 * 24 * time.Hour
}
// Load has validated the fill values; an unvalidated config falls back to the defaults.
fill := cfg.ReadModels.Fill
r.FillDuty, r.FillMinPause = defaultFillDuty, defaultFillMinPause
r.FillPacketSitesSlice, r.FillCmiBatch = defaultFillPacketSitesSlice, defaultFillCmiBatch
if fill.validate() == nil {
if fill.Duty != nil {
r.FillDuty = *fill.Duty
}
if fill.MinPause != nil {
r.FillMinPause = fill.MinPause.Duration
}
if fill.PacketSitesSlice != nil {
r.FillPacketSitesSlice = *fill.PacketSitesSlice
}
if fill.CmiBatch != nil {
r.FillCmiBatch = *fill.CmiBatch
}
}
return r
}
func (r ResolvedConfig) String() string {
return fmt.Sprintf(
"telemetryResolution=%s telemetryRetention=%s rollupRetention=%s packetRetention=%s routeRetention=%s routeGrace=%s routeMinObs=%d routeReconfirmRowsPerHour=%d maxConnsPerIP=%d maxConnectsPerMinute=%d viewRefresh=%s reconfirm=%s cleanup=%s presenceFlush=%s presencePacketTTL=%s clockDriftThreshold=%s nodeStaleThreshold=%s nodeDeleteAfter=%s observerDeleteAfter=%s rateLimitEnabled=%t requestsPerMinute=%d burst=%d apiPlanLayout=%s slowPoolSize=%d guardsEnforce=%v guardsBudget=%s adminListen=%q readOnly=%t planguardDailyCheck=%t readModelCmi=%t readModelPacketSites=%t fillDuty=%g fillMinPause=%s fillPacketSitesSlice=%d fillCmiBatch=%d",
r.TelemetryResolution, r.TelemetryRetention, r.RollupRetention, r.PacketRetention, r.RouteRetention, r.RouteGrace, r.RouteMinObservations, r.RouteReconfirmRowsPerHour,
r.MaxConnsPerIP, r.MaxConnectsPerMinute, r.ViewRefreshInterval, r.ReconfirmInterval, r.CleanupInterval,
r.PresenceFlushInterval, r.PresencePacketTTL, r.ClockDriftThreshold,
r.NodeStaleThreshold, r.NodeDeleteAfter, r.ObserverDeleteAfter,
r.RateLimit.Enabled, r.RateLimit.RequestsPerMinute, r.RateLimit.Burst,
r.APIPlanLayout, r.SlowPoolSize, r.GuardsEnforce, r.GuardsBudget, r.AdminListen, r.ReadOnly, r.PlanguardDailyCheck, r.ReadModelCmiEnabled, r.ReadModelPacketSitesEnabled,
r.FillDuty, r.FillMinPause, r.FillPacketSitesSlice, r.FillCmiBatch,
)
}