mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-23 14:32:32 +00:00
feat(config): plan layout, guard and admin listener settings; plan policy classes (#237)
This commit is contained in:
1 parent
c8595424e9
commit
3ae3c250d8
6 files changed
+289
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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{}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user