From 3ae3c250d8796f6097bfc4e5aeafbe964efd4dc6 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:43:49 -0400 Subject: [PATCH] feat(config): plan layout, guard and admin listener settings; plan policy classes (#237) --- config.yaml.example | 21 +++++++++ db/planpolicy/cached_gen.go | 9 ++++ db/planpolicy/planpolicy.go | 75 ++++++++++++++++++++++++++++++++ db/planpolicy/planpolicy_test.go | 54 +++++++++++++++++++++++ internal/config/config.go | 74 ++++++++++++++++++++++++++++++- internal/config/config_test.go | 57 ++++++++++++++++++++++++ 6 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 db/planpolicy/cached_gen.go create mode 100644 db/planpolicy/planpolicy.go create mode 100644 db/planpolicy/planpolicy_test.go diff --git a/config.yaml.example b/config.yaml.example index 37786a2..a75c6e6 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -34,6 +34,9 @@ server: # With additional proxy hops, first configure their trust at the edge proxy # and pass its validated client address rather than a client-supplied header. trusted_proxies: [] + # Address of the admin listener serving Prometheus /metrics, e.g. 127.0.0.1:9090. Off when empty. + # It has no authentication: bind it to a private interface. + admin_listen: "" # Applies to /api/v1/* only. Both counters are local to this Beacon instance. # Set trusted_proxies correctly before using a reverse proxy, or clients share @@ -46,6 +49,24 @@ ratelimit: # A 429 returns error.code=rate_limited and Retry-After (1 or 60 seconds). burst: 600 +# Database pools. Every deployment opens: api_legacy 8, api_custom 8, ingest 6, maint 4 connections +# (26), or 30 with split (api_custom 7, api_cached 5). Keep max_connections well above that. +#database: +# # custom_all (default): every API statement gets a custom plan. +# # split: statements planguard proved safe run on cached generic plans; turn on only after the +# # daily planguard prod check is clean on this deployment. +# # single: rollback. One API pool on today's settings; every guard family observes. +# api_plan_layout: custom_all + +# Cost guards on the public /api/v1 group (not /admin, not /ws). Every family observes by default: +# nothing is refused, only beacon_guard_would_refuse_total counts. Listed families enforce: +# 429 (4 concurrent DB requests per client, 5 s wait), 503 (pool busy 5 s; class deadline 5/10/30 s). +# Families: routes, channel_messages, packets_global, packets_region, packets_window, +# packet_detail, stats, other. PUT /api/v1/admin/config changes the list at runtime. +#guards: +# enforce: [] +# budget: shadow # the per-client DB-time budget only records; enforcement is not available + # MeshMapper integrations (optional, require a regional or grouped-region API key). # Your local MeshMapper regional or grouped-region admin can generate the key. # A grouped-region key covers multiple regions. diff --git a/db/planpolicy/cached_gen.go b/db/planpolicy/cached_gen.go new file mode 100644 index 0000000..87d7271 --- /dev/null +++ b/db/planpolicy/cached_gen.go @@ -0,0 +1,9 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Code generated by planguard gen-policy. DO NOT EDIT. + +package planpolicy + +// Cached lists class G statements. The planguard generator writes it from the planguard corpus. +var Cached = map[string]bool{} diff --git a/db/planpolicy/planpolicy.go b/db/planpolicy/planpolicy.go new file mode 100644 index 0000000..eec9ae7 --- /dev/null +++ b/db/planpolicy/planpolicy.go @@ -0,0 +1,75 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package planpolicy decides which plan class each sqlc statement runs under and +// which API pool layout the server opens. +package planpolicy + +import "errors" + +// Layout selects how API statements are spread across connection pools. +type Layout string + +const ( + // CustomAll is the default: every API statement gets a custom plan. + CustomAll Layout = "custom_all" + // Split runs statements proven generic-safe on cached generic plans. + Split Layout = "split" + // Single is the rollback layout: one API pool, and every guard family observes. + Single Layout = "single" +) + +var errLayout = errors.New("database.api_plan_layout must be custom_all, split or single") + +// ParseLayout reads database.api_plan_layout; empty means CustomAll. +func ParseLayout(s string) (Layout, error) { + switch l := Layout(s); l { + case "": + return CustomAll, nil + case CustomAll, Split, Single: + return l, nil + } + return "", errLayout +} + +// Class is the plan class of one sqlc statement. +type Class int + +const ( + // ClassC gets a custom plan on every execution: the default for every name. + ClassC Class = iota + // ClassG is proven generic-safe by planguard and runs on a cached generic plan under Split. + ClassG + // ClassLegacy is a statement not yet rewritten: it runs on api_legacy, unguarded. + ClassLegacy +) + +// Legacy lists the statements not yet rewritten. Each is removed in the change that replaces it. +var Legacy = map[string]bool{ + "ListPackets": true, + "ListPacketsByIATAs": true, + "ListChannelMessages": true, + "ListAllChannelMessages": true, + "GetStatsSeries": true, + "GetPathStats": true, + "GetTopNodes": true, + "GetStatsTopAdvertisers": true, + "ListObserverAdverts": true, + "ListTraceTags": true, +} + +// Undecided, needs the maintainer's approval before the legacy pool is removed: a Fallback map for the +// untrusted read-model fallbacks (ListPacketsByIATAs, ListChannelMessages, ListAllChannelMessages) would +// go here, as a fourth class that the router sends to api_custom like C but never gates, so no deadline +// ever cuts it. + +// ClassOf returns the plan class for a sqlc statement name. +func ClassOf(name string) Class { + switch { + case Legacy[name]: + return ClassLegacy + case Cached[name]: + return ClassG + } + return ClassC +} diff --git a/db/planpolicy/planpolicy_test.go b/db/planpolicy/planpolicy_test.go new file mode 100644 index 0000000..e30d386 --- /dev/null +++ b/db/planpolicy/planpolicy_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package planpolicy_test + +import ( + "reflect" + "testing" + + "github.com/MeshCore-Beacon/beacon-server/db/planpolicy" + sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc" +) + +// Every Legacy/Cached name is a statement in db/sqlc, so a stale entry fails CI; the two sets are disjoint. +func TestPlanPolicyNamesExist(t *testing.T) { + q := reflect.TypeOf((*sqlc.Querier)(nil)).Elem() + for _, set := range []map[string]bool{planpolicy.Legacy, planpolicy.Cached} { + for name := range set { + if _, ok := q.MethodByName(name); !ok { + t.Errorf("%s is not a sqlc statement", name) + } + if planpolicy.Legacy[name] && planpolicy.Cached[name] { + t.Errorf("%s is both Legacy and Cached", name) + } + } + } + if len(planpolicy.Legacy) != 10 || planpolicy.ClassOf("ListPackets") != planpolicy.ClassLegacy || + planpolicy.ClassOf("GetNodeByID") != planpolicy.ClassC || planpolicy.ClassOf("") != planpolicy.ClassC { + t.Fatal("ClassOf") + } +} + +func TestParseLayout(t *testing.T) { + for _, tc := range []struct { + in string + want planpolicy.Layout + }{ + {"", planpolicy.CustomAll}, + {"custom_all", planpolicy.CustomAll}, + {"split", planpolicy.Split}, + {"single", planpolicy.Single}, + } { + got, err := planpolicy.ParseLayout(tc.in) + if err != nil || got != tc.want { + t.Errorf("ParseLayout(%q) = %q, %v; want %q", tc.in, got, err, tc.want) + } + } + for _, in := range []string{"Split", "x"} { + _, err := planpolicy.ParseLayout(in) + if err == nil || err.Error() != "database.api_plan_layout must be custom_all, split or single" { + t.Errorf("ParseLayout(%q) error = %v", in, err) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 332c477..abe735c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ package config import ( "fmt" + "net" "net/netip" "net/url" "os" @@ -18,6 +19,8 @@ import ( "unicode" "gopkg.in/yaml.v3" + + "github.com/MeshCore-Beacon/beacon-server/db/planpolicy" ) // Config is the top-level structure of the Beacon config file. @@ -46,8 +49,27 @@ type Config struct { Analytics AnalyticsConfig `yaml:"analytics"` Mobile MobileConfig `yaml:"mobile"` Web WebConfig `yaml:"web"` + Database DatabaseConfig `yaml:"database"` + Guards GuardsConfig `yaml:"guards"` } +// 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"` +} + +// 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. @@ -82,6 +104,8 @@ 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"` } func (c *ServerConfig) UnmarshalYAML(node *yaml.Node) error { @@ -89,6 +113,7 @@ func (c *ServerConfig) UnmarshalYAML(node *yaml.Node) error { // Leave them as invalid prefixes for Load's validation below. var raw struct { TrustedProxies []*netip.Prefix `yaml:"trusted_proxies"` + AdminListen string `yaml:"admin_listen"` } if err := node.Decode(&raw); err != nil { return err @@ -99,6 +124,7 @@ func (c *ServerConfig) UnmarshalYAML(node *yaml.Node) error { c.TrustedProxies[i] = *prefix } } + c.AdminListen = raw.AdminListen return nil } @@ -133,6 +159,15 @@ type ResolvedConfig struct { NodeStaleThreshold time.Duration NodeDeleteAfter time.Duration ObserverDeleteAfter time.Duration + + // APIPlanLayout selects the API pool layout; defaults to custom_all. + APIPlanLayout planpolicy.Layout + // 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 } // RateLimitConfig controls the per-client REST API request budget. @@ -486,9 +521,33 @@ func Load(path string) (*Config, error) { cfg.IATAs[iata] = details } } + if err := cfg.validateDatabaseGuards(); err != nil { + return nil, err + } return cfg, nil } +// 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 + } + 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 != "" { + if _, _, err := net.SplitHostPort(c.Server.AdminListen); err != nil { + 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 @@ -608,6 +667,18 @@ func Resolve(cfg *Config) ResolvedConfig { NodeStaleThreshold: cfg.Nodes.StaleThreshold.Duration, NodeDeleteAfter: cfg.Nodes.DeleteAfter.Duration, ObserverDeleteAfter: cfg.Observers.DeleteAfter.Duration, + + GuardsEnforce: append([]string{}, cfg.Guards.Enforce...), + GuardsBudget: cfg.Guards.Budget, + AdminListen: cfg.Server.AdminListen, + } + // 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 + } + if r.GuardsBudget == "" { + r.GuardsBudget = "shadow" } if r.RateLimit.RequestsPerMinute == 0 { r.RateLimit.RequestsPerMinute = 600 @@ -674,11 +745,12 @@ func Resolve(cfg *Config) ResolvedConfig { 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", + "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 guardsEnforce=%v guardsBudget=%s adminListen=%q", 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.GuardsEnforce, r.GuardsBudget, r.AdminListen, ) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ba795cc..4555a72 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" "time" + + "github.com/MeshCore-Beacon/beacon-server/db/planpolicy" ) func TestLoadLogConfig(t *testing.T) { @@ -389,3 +391,58 @@ func TestLoadMinWebVersion(t *testing.T) { }) } } + +func TestResolve_DatabaseGuardsDefaults(t *testing.T) { + r := Resolve(&Config{}) + if r.APIPlanLayout != planpolicy.CustomAll { + t.Errorf("APIPlanLayout = %q, want %q", r.APIPlanLayout, planpolicy.CustomAll) + } + if r.GuardsEnforce == nil || len(r.GuardsEnforce) != 0 { + t.Errorf("GuardsEnforce = %#v, want non-nil empty", r.GuardsEnforce) + } + if r.GuardsBudget != "shadow" { + t.Errorf("GuardsBudget = %q, want shadow", r.GuardsBudget) + } + if r.AdminListen != "" { + t.Errorf("AdminListen = %q, want empty", r.AdminListen) + } +} + +func TestLoad_DatabaseGuardsValidation(t *testing.T) { + for _, tc := range []struct { + yaml, err string + }{ + {"database:\n api_plan_layout: generic\n", "database.api_plan_layout must be custom_all, split or single"}, + {"guards:\n enforce: [routes, bogus]\n", `guards.enforce[1]: unknown family "bogus" (routes, channel_messages, packets_global, packets_region, packets_window, packet_detail, stats, other)`}, + {"guards:\n budget: enforce\n", "guards.budget must be shadow (budget enforcement is not available)"}, + {"server:\n admin_listen: \"9090\"\n", "server.admin_listen must be host:port, e.g. 127.0.0.1:9090"}, + } { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil || err.Error() != tc.err { + t.Errorf("Load(%q) error = %v, want %q", tc.yaml, err, tc.err) + } + } + + path := filepath.Join(t.TempDir(), "config.yaml") + yml := "server:\n trusted_proxies: [10.0.0.0/8]\n admin_listen: \":9090\"\n" + + "database:\n api_plan_layout: split\nguards:\n enforce: [routes, stats]\n budget: shadow\n" + if err := os.WriteFile(path, []byte(yml), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if len(cfg.Server.TrustedProxies) != 1 || cfg.Server.TrustedProxies[0].String() != "10.0.0.0/8" || + cfg.Server.AdminListen != ":9090" { + t.Fatalf("server config = %+v", cfg.Server) + } + r := Resolve(cfg) + if r.AdminListen != ":9090" || r.APIPlanLayout != planpolicy.Split || r.GuardsBudget != "shadow" || + strings.Join(r.GuardsEnforce, ",") != "routes,stats" { + t.Fatalf("resolved = %s", r) + } +}