347 lines
11 KiB
Go
347 lines
11 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"`
|
|
}
|
|
|
|
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"` // 1, 2, or 3 — maps to ip_queue.siteN_complete
|
|
}
|
|
|
|
// 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
|
|
}
|