// 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"` } 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 } 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"` } 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.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 } return &c, nil } // ---- validator-agent ---- type ValidatorAgent struct { ValidatorID string `yaml:"validator_id"` ControlAPIURL string `yaml:"control_api_url"` 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.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"` 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.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"` } type DashboardControlAPIConfig struct { BaseURL string `yaml:"base_url"` TimeoutSeconds int `yaml:"timeout_seconds"` } // 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.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 }