feat(config): plan layout, guard and admin listener settings; plan policy classes (#237)

This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-19 07:43:49 -04:00
1 parent c8595424e9
commit 3ae3c250d8
6 files changed
+289 -1

No files matched your search

+21
View File
@@ -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.
+9
View File
@@ -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{}
+75
View File
@@ -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
}
+54
View File
@@ -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)
}
}
}
+73 -1
View File
@@ -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,
)
}
+57
View File
@@ -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)
}
}