Files
cloud-ip-validator/internal/config/config.go
T
ayurishchevandClaude Sonnet 5.5 aff8fe38b5 Scan floating IPs in the background, page by page, so thousands of addresses work
The "Scan Floating IP" button failed with a client timeout: the project now
holds ~6.4k floating IPs and the scan listed them all in one unpaginated,
timeout-less Neutron request on the HTTP request context.

openstack: ListFreeFloatingIPs reads marker-based pages (fields= keeps them
small) with per-page retry/backoff on transport errors, 5xx and 429, and every
request now has a timeout (also ends hangs inside the orchestrator tick).

orchestrator: the scan is a single-flight background job on the process
context with progress (clearing/listing/enqueuing/done/error), dry_run, full
discovery before anything is enqueued, then SubmitIPs in chunks of 500 in
ascending IP order; a failed read leaves the queue untouched. The auto-cycle
gets a "scanning" phase that polls the job, so the control loop and
autoCycleMu are never held across OpenStack/DB work; it recovers after a
restart and waits for (instead of adopting) a scan started by someone else.

db: migration 0009 (indexes), paged ListIPsPage/ListRegistryPage, GROUP BY
counters, EXISTS completion check, set-based ClearAllIPs.

API: POST /admin/ips/scan -> 202 (dry_run, wait), GET /admin/ips/scan, paging
and filters on /admin/ips and /admin/registry (bare arrays without limit),
results_by_overall in /admin/status.

dashboard: scan progress panel and dry-run button, paginated /ips and
/registry with server-side filters, Overview on counters and capped lists
with progress/ETA, "select all N by filter", hx-params fix for per-row
buttons, real counts in confirmations.

Also: docs (API, USAGE, DASHBOARD, README), plan and review under
docs/changes/, bin/ rebuilt with new SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 19:31:11 +03:00

435 lines
15 KiB
Go

// Package config defines the YAML configuration structures for all three
// binaries and loads them from disk. OpenStack credentials are deliberately
// never part of these structs — only the *names* of environment variables
// to read them from — so secrets never land in a config file on disk.
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// ---- control-api ----
type ControlAPI struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
OpenStack OpenStackConfig `yaml:"openstack"`
Orchestrator OrchestratorConfig `yaml:"orchestrator"`
Aggregation AggregationConfig `yaml:"aggregation"`
Validators []ValidatorConfig `yaml:"validators"`
Sites []SiteConfig `yaml:"sites"`
CheckTypes []CheckTypeConfig `yaml:"check_types"`
Targets map[string][]string `yaml:"targets"`
Inbound InboundConfig `yaml:"inbound_checks"`
IPAddresses []string `yaml:"ip_addresses"`
Auth ControlAPIAuth `yaml:"auth"`
}
// ControlAPIAuth names the environment variables control-api reads its two
// static bearer tokens from. Values are never stored in the config. An unset
// (empty) token leaves that access level open, with a startup warning.
type ControlAPIAuth struct {
AdminTokenEnv string `yaml:"admin_token_env"` // default CONTROL_API_ADMIN_TOKEN — protects /api/v1/admin/*
AgentTokenEnv string `yaml:"agent_token_env"` // default CONTROL_API_AGENT_TOKEN — protects agent/prober write calls
}
type ServerConfig struct {
ListenAddr string `yaml:"listen_addr"`
}
type DatabaseConfig struct {
Path string `yaml:"path"`
}
// OpenStackConfig names the environment variables control-api reads its
// OpenStack admin credential from at startup. Mode "mock" skips all of this
// and uses an in-memory FloatingIPClient instead — used for local dev and
// the offline end-to-end harness.
//
// AuthMethod selects which credential shape is expected in the process
// environment: "token" (default) reads a pre-issued, already
// project-scoped token from TokenEnv and uses it as-is (no renewal — the
// operator reissues and restarts on expiry). "password" reads
// username/password/domain from UsernameEnv/PasswordEnv/UserDomainNameEnv
// and has the client obtain and auto-renew its own token.
type OpenStackConfig struct {
Mode string `yaml:"mode"` // "mock" | "real"
AuthMethod string `yaml:"auth_method"` // "token" (default) | "password"
AuthURLEnv string `yaml:"auth_url_env"` // default OS_AUTH_URL
ProjectIDEnv string `yaml:"project_id_env"` // default OS_PROJECT_ID
RegionEnv string `yaml:"region_env"` // default OS_REGION_NAME
InterfaceEnv string `yaml:"interface_env"` // default OS_INTERFACE; "" at runtime defaults to "public"
TokenEnv string `yaml:"token_env"` // default OS_TOKEN — used when auth_method: token
UsernameEnv string `yaml:"username_env"` // default OS_USERNAME — used when auth_method: password
UserDomainNameEnv string `yaml:"user_domain_name_env"` // default OS_USER_DOMAIN_NAME
PasswordEnv string `yaml:"password_env"` // default OS_PASSWORD
// ListPageSize is how many floating IPs one Neutron list request asks
// for (the scan reads the project page by page). Default 200.
ListPageSize int `yaml:"list_page_size"`
// RequestTimeoutSeconds bounds every single HTTP request to Keystone and
// Neutron. Default 60.
RequestTimeoutSeconds int `yaml:"request_timeout_seconds"`
// ListPageRetries is how many times one failed page of the listing is
// retried (backoff 1s,2s,4s,...) on network errors, 5xx and 429. Default
// 5; a negative value disables retries.
ListPageRetries int `yaml:"list_page_retries"`
}
type OrchestratorConfig struct {
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
SelfCheckTimeoutSeconds int `yaml:"self_check_timeout_seconds"`
MaxSelfCheckRetries int `yaml:"max_self_check_retries"`
CheckingWindowSeconds int `yaml:"checking_window_seconds"`
MaxRetries int `yaml:"max_retries"`
LeaseTTLSeconds int `yaml:"lease_ttl_seconds"`
HeartbeatTimeoutSeconds int `yaml:"heartbeat_timeout_seconds"`
// FIPSettleSeconds is only the one-time seed value used the first time
// control-api starts against an empty database; after that it's
// managed at runtime via PUT /api/v1/admin/config/orchestrator (or the
// dashboard's /settings page) and this field is ignored. Zero (no
// pause) is a valid, backward-compatible default — unlike every other
// field in this struct, it deliberately gets no nonzero default in
// LoadControlAPI below.
FIPSettleSeconds int `yaml:"fip_settle_seconds"`
// FIPScanIntervalSeconds enables periodic Orchestrator.ScanFloatingIPs
// runs on this interval when > 0. 0 (the default) disables periodic
// scanning — an operator can still trigger a scan manually via
// POST /api/v1/admin/ips/scan or the dashboard's "Scan Floating IPs"
// button either way.
FIPScanIntervalSeconds int `yaml:"fip_scan_interval_seconds"`
// FIPScanTimeoutSeconds is the overall deadline of one background
// floating-IP scan (clear + paged read + enqueue). Default 1800.
FIPScanTimeoutSeconds int `yaml:"fip_scan_timeout_seconds"`
}
type AggregationConfig struct {
MissingCountsAsFail bool `yaml:"missing_counts_as_fail"`
}
type ValidatorConfig struct {
ValidatorID string `yaml:"validator_id"`
OSPortID string `yaml:"os_port_id"`
}
type SiteConfig struct {
SiteID string `yaml:"site_id"`
Index int `yaml:"index"` // any int >= 1, no cap on the number of slots
}
// CheckTypeConfig maps a check type (https, icmp, ssh) to the named target
// groups (keys into ControlAPI.Targets) it should run against. This drives
// the egress/outbound checks the validator-agent performs.
type CheckTypeConfig struct {
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
Targets []string `yaml:"targets"`
}
type InboundConfig struct {
Ports []int `yaml:"ports"`
ICMP bool `yaml:"icmp"`
}
func LoadControlAPI(path string) (*ControlAPI, error) {
var c ControlAPI
if err := loadYAML(path, &c); err != nil {
return nil, err
}
if c.Server.ListenAddr == "" {
c.Server.ListenAddr = ":8080"
}
if c.Database.Path == "" {
c.Database.Path = "control-api.db"
}
if c.OpenStack.Mode == "" {
c.OpenStack.Mode = "mock"
}
if c.OpenStack.AuthMethod == "" {
c.OpenStack.AuthMethod = "token"
}
if c.OpenStack.AuthURLEnv == "" {
c.OpenStack.AuthURLEnv = "OS_AUTH_URL"
}
if c.OpenStack.ProjectIDEnv == "" {
c.OpenStack.ProjectIDEnv = "OS_PROJECT_ID"
}
if c.OpenStack.RegionEnv == "" {
c.OpenStack.RegionEnv = "OS_REGION_NAME"
}
if c.OpenStack.InterfaceEnv == "" {
c.OpenStack.InterfaceEnv = "OS_INTERFACE"
}
if c.OpenStack.TokenEnv == "" {
c.OpenStack.TokenEnv = "OS_TOKEN"
}
if c.OpenStack.UsernameEnv == "" {
c.OpenStack.UsernameEnv = "OS_USERNAME"
}
if c.OpenStack.UserDomainNameEnv == "" {
c.OpenStack.UserDomainNameEnv = "OS_USER_DOMAIN_NAME"
}
if c.OpenStack.PasswordEnv == "" {
c.OpenStack.PasswordEnv = "OS_PASSWORD"
}
if c.OpenStack.ListPageSize == 0 {
c.OpenStack.ListPageSize = 200
}
if c.OpenStack.RequestTimeoutSeconds == 0 {
c.OpenStack.RequestTimeoutSeconds = 60
}
if c.OpenStack.ListPageRetries == 0 {
c.OpenStack.ListPageRetries = 5
}
if c.Auth.AdminTokenEnv == "" {
c.Auth.AdminTokenEnv = "CONTROL_API_ADMIN_TOKEN"
}
if c.Auth.AgentTokenEnv == "" {
c.Auth.AgentTokenEnv = "CONTROL_API_AGENT_TOKEN"
}
if c.Orchestrator.PollIntervalSeconds == 0 {
c.Orchestrator.PollIntervalSeconds = 5
}
if c.Orchestrator.SelfCheckTimeoutSeconds == 0 {
c.Orchestrator.SelfCheckTimeoutSeconds = 60
}
if c.Orchestrator.MaxSelfCheckRetries == 0 {
c.Orchestrator.MaxSelfCheckRetries = 3
}
if c.Orchestrator.CheckingWindowSeconds == 0 {
c.Orchestrator.CheckingWindowSeconds = 120
}
if c.Orchestrator.MaxRetries == 0 {
c.Orchestrator.MaxRetries = 3
}
if c.Orchestrator.LeaseTTLSeconds == 0 {
c.Orchestrator.LeaseTTLSeconds = 180
}
if c.Orchestrator.HeartbeatTimeoutSeconds == 0 {
c.Orchestrator.HeartbeatTimeoutSeconds = 30
}
if c.Orchestrator.FIPScanTimeoutSeconds == 0 {
c.Orchestrator.FIPScanTimeoutSeconds = 1800
}
return &c, nil
}
// ---- validator-agent ----
type ValidatorAgent struct {
ValidatorID string `yaml:"validator_id"`
ControlAPIURL string `yaml:"control_api_url"`
// ControlAPITokenEnv is the name of the env var holding the agent bearer
// token (default CONTROL_API_AGENT_TOKEN). Empty value = no token sent.
ControlAPITokenEnv string `yaml:"control_api_token_env"`
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
SelfCheck SelfCheckCfg `yaml:"self_check"`
Checks AgentChecks `yaml:"checks"`
}
// SelfCheckCfg configures how the agent confirms its egress actually flows
// through the newly assigned floating IP. This must query a resource
// genuinely outside the cloud project: OpenStack only applies floating-IP
// SNAT to traffic leaving via the external/provider network, so any
// in-project resource (including control-api, if it's reachable over the
// project's internal network) would see the validator's private address
// instead — a false negative that never changes. IPEchoURLs are tried in
// order (falling through to the next on error/timeout, not on a genuine
// mismatch) until one returns a parseable IP.
type SelfCheckCfg struct {
TimeoutSeconds int `yaml:"timeout_seconds"`
IPEchoURLs []string `yaml:"ip_echo_urls"`
}
type AgentChecks struct {
HTTPSTimeoutSeconds int `yaml:"https_timeout_seconds"`
ICMPTimeoutSeconds int `yaml:"icmp_timeout_seconds"`
ICMPCount int `yaml:"icmp_count"`
SSH SSHCfg `yaml:"ssh"`
}
type SSHCfg struct {
Enabled bool `yaml:"enabled"`
TimeoutSeconds int `yaml:"timeout_seconds"`
}
func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
var c ValidatorAgent
if err := loadYAML(path, &c); err != nil {
return nil, err
}
if c.PollIntervalSeconds == 0 {
c.PollIntervalSeconds = 5
}
if c.ControlAPITokenEnv == "" {
c.ControlAPITokenEnv = "CONTROL_API_AGENT_TOKEN"
}
if c.SelfCheck.TimeoutSeconds == 0 {
c.SelfCheck.TimeoutSeconds = 10
}
if len(c.SelfCheck.IPEchoURLs) == 0 {
c.SelfCheck.IPEchoURLs = []string{"https://api.ipify.org", "https://ifconfig.me/ip"}
}
if c.Checks.HTTPSTimeoutSeconds == 0 {
c.Checks.HTTPSTimeoutSeconds = 10
}
if c.Checks.ICMPTimeoutSeconds == 0 {
c.Checks.ICMPTimeoutSeconds = 5
}
if c.Checks.ICMPCount == 0 {
c.Checks.ICMPCount = 3
}
if c.Checks.SSH.TimeoutSeconds == 0 {
c.Checks.SSH.TimeoutSeconds = 5
}
if c.ValidatorID == "" {
return nil, fmt.Errorf("validator_id is required")
}
if c.ControlAPIURL == "" {
return nil, fmt.Errorf("control_api_url is required")
}
return &c, nil
}
// ---- prober ----
type Prober struct {
SiteID string `yaml:"site_id"`
ControlAPIURL string `yaml:"control_api_url"`
// ControlAPITokenEnv is the name of the env var holding the agent bearer
// token (default CONTROL_API_AGENT_TOKEN). Empty value = no token sent.
ControlAPITokenEnv string `yaml:"control_api_token_env"`
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
Checks ProberChecks `yaml:"checks"`
}
type ProberChecks struct {
TCPTimeoutSeconds int `yaml:"tcp_timeout_seconds"`
ICMPTimeoutSeconds int `yaml:"icmp_timeout_seconds"`
ICMPCount int `yaml:"icmp_count"`
}
func LoadProber(path string) (*Prober, error) {
var c Prober
if err := loadYAML(path, &c); err != nil {
return nil, err
}
if c.PollIntervalSeconds == 0 {
c.PollIntervalSeconds = 5
}
if c.ControlAPITokenEnv == "" {
c.ControlAPITokenEnv = "CONTROL_API_AGENT_TOKEN"
}
if c.Checks.TCPTimeoutSeconds == 0 {
c.Checks.TCPTimeoutSeconds = 5
}
if c.Checks.ICMPTimeoutSeconds == 0 {
c.Checks.ICMPTimeoutSeconds = 5
}
if c.Checks.ICMPCount == 0 {
c.Checks.ICMPCount = 3
}
if c.SiteID == "" {
return nil, fmt.Errorf("site_id is required")
}
if c.ControlAPIURL == "" {
return nil, fmt.Errorf("control_api_url is required")
}
return &c, nil
}
// ---- admin-dashboard ----
// AdminDashboard is the config for the 4th binary, cmd/admin-dashboard — a
// stateless, server-rendered web UI over control-api's /api/v1/admin/*
// HTTP API (see docs/DASHBOARD.md). It never talks to the database.
type AdminDashboard struct {
Server ServerConfig `yaml:"server"`
ControlAPI DashboardControlAPIConfig `yaml:"control_api"`
Overview DashboardOverviewConfig `yaml:"overview"`
Auth DashboardAuthConfig `yaml:"auth"`
}
// DashboardAuthConfig names the env vars holding the single administrator's
// login, password and the session-cookie HMAC key. If username or password
// is empty at runtime, login is not required (with a startup warning).
type DashboardAuthConfig struct {
UsernameEnv string `yaml:"username_env"` // default ADMIN_DASHBOARD_USERNAME
PasswordEnv string `yaml:"password_env"` // default ADMIN_DASHBOARD_PASSWORD
SessionSecretEnv string `yaml:"session_secret_env"` // default ADMIN_DASHBOARD_SESSION_SECRET
SessionTTLMinutes int `yaml:"session_ttl_minutes"` // default 480
}
type DashboardControlAPIConfig struct {
BaseURL string `yaml:"base_url"`
TimeoutSeconds int `yaml:"timeout_seconds"`
// TokenEnv is the name of the env var holding control-api's admin bearer
// token (default ADMIN_DASHBOARD_CONTROL_API_TOKEN).
TokenEnv string `yaml:"token_env"`
}
// DashboardOverviewConfig configures the overview page's "текущая
// проверка" / "последние N завершённых" summary — see
// docs/DASHBOARD.md#текущая-и-последняя-завершённая-проверка. Both are
// computed fresh on every request from control-api's existing
// status/queue endpoints; there is no persisted "run"/"batch" concept.
type DashboardOverviewConfig struct {
LastCompletedCount int `yaml:"last_completed_count"`
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
}
func LoadAdminDashboard(path string) (*AdminDashboard, error) {
var c AdminDashboard
if err := loadYAML(path, &c); err != nil {
return nil, err
}
if c.Server.ListenAddr == "" {
c.Server.ListenAddr = ":8090"
}
if c.ControlAPI.BaseURL == "" {
return nil, fmt.Errorf("control_api.base_url is required")
}
if c.ControlAPI.TimeoutSeconds == 0 {
c.ControlAPI.TimeoutSeconds = 10
}
if c.ControlAPI.TokenEnv == "" {
c.ControlAPI.TokenEnv = "ADMIN_DASHBOARD_CONTROL_API_TOKEN"
}
if c.Auth.UsernameEnv == "" {
c.Auth.UsernameEnv = "ADMIN_DASHBOARD_USERNAME"
}
if c.Auth.PasswordEnv == "" {
c.Auth.PasswordEnv = "ADMIN_DASHBOARD_PASSWORD"
}
if c.Auth.SessionSecretEnv == "" {
c.Auth.SessionSecretEnv = "ADMIN_DASHBOARD_SESSION_SECRET"
}
if c.Auth.SessionTTLMinutes == 0 {
c.Auth.SessionTTLMinutes = 480
}
if c.Overview.LastCompletedCount == 0 {
c.Overview.LastCompletedCount = 20
}
if c.Overview.PollIntervalSeconds == 0 {
c.Overview.PollIntervalSeconds = 5
}
return &c, nil
}
func loadYAML(path string, out interface{}) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
if err := yaml.Unmarshal(data, out); err != nil {
return fmt.Errorf("parse config %s: %w", path, err)
}
return nil
}