// Package config loads bootapi server configuration from the environment, // following the same env-first convention as encapi. package config import ( "fmt" "os" "strings" "time" ) // Config is the fully-resolved server configuration. type Config struct { // ListenAddr is the plain-HTTP bind address, e.g. ":8000". The boot path // (iPXE + kickstart) is always served here so installers with no internal // CA trust can reach it. ListenAddr string // TLSListenAddr, when set with TLSCertFile/TLSKeyFile, additionally serves // HTTPS. Boot endpoints work on both; the plain-HTTP listener is mandatory, // HTTPS is opt-in (see docs/endpoints.md). TLSListenAddr string TLSCertFile string TLSKeyFile string // NetBoxURL is the base URL of the NetBox API, // e.g. "https://netbox.k8s.syd1.au.unkin.net". NetBoxURL string // NetBoxToken is the NetBox API token. Prefer NetBoxTokenFile in k8s. Needs // WRITE scope on the device pxe_enabled custom field for the callback. NetBoxToken string // NetBoxTimeout bounds each NetBox HTTP request. NetBoxTimeout time.Duration // NetBoxInsecure disables TLS verification against NetBox (dev only). NetBoxInsecure bool // CacheTTL is how long a resolved host is cached in memory. CacheTTL time.Duration // TemplateDir, when set, is a directory of override templates layered on // top of the embedded defaults (a ConfigMap mount). Ignored when a template // git repo is configured. TemplateDir string // DefaultTemplate is the kickstart template used when no catalog/platform // selection key matches. DefaultTemplate string // --- template git-sync (preferred over TemplateDir) --- // TemplateGitURL, when set, makes bootapi clone a templates repo and re-pull // it every TemplateGitInterval, atomically swapping the loaded set on change // and keeping the last-good set on a parse failure. TemplateGitURL string TemplateGitBranch string TemplateGitInterval time.Duration // TemplateGitToken is an optional token for a private templates repo, // injected into the HTTPS clone URL. Empty for a public repo. TemplateGitToken string // BaseURL is the http:// base PXE clients use to reach bootapi. It is baked // into the iPXE inst.ks= and /ks URLs, so it MUST be reachable without CA // trust (plain HTTP). e.g. "http://bootapi.k8s.syd1.au.unkin.net". BaseURL string // CallbackBaseURL is the base the end-of-kickstart callback uses. Defaults // to BaseURL (plain HTTP, works before the internal CA is installed). Set to // an https:// URL only if the kickstart installs the internal CA before the // callback runs. CallbackBaseURL string // ArtifactBaseURL is the artifactapi remote base the distro catalog builds // kernel/initrd URLs from, // e.g. "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote". ArtifactBaseURL string // BootBaseURL is a legacy fallback OS-tree base used only when no catalog // entry matches a host. Normally empty (the catalog drives boot images). BootBaseURL string // ProvisionToken guards POST /provisioned. Empty disables the callback // endpoint (fail closed). Prefer ProvisionTokenFile in k8s. ProvisionToken string // PuppetServer / PuppetCAServer are baked into kickstart %post so the // freshly-installed host checks in to the k8s puppetserver. PuppetServer string PuppetCAServer string // PuppetCAURL is written to the puppet-initial EnvironmentFile as // PUPPETCA_URL (consumed by that RPM's systemd bootstrap unit). PuppetCAURL string // Domain is the default DNS domain applied when NetBox records none. Domain string // Nameservers is the default resolver list applied when NetBox records none. Nameservers []string // RootPasswordHash is a crypt(3) hash injected into kickstarts at render // time (Vault in k8s). Empty locks the root account. RootPasswordHash string // SSHAuthorizedKeys are public keys installed for root at render time. SSHAuthorizedKeys []string // UnknownMACFallback selects the iPXE script for an unknown MAC: "local" // (boot local disk, safe default) or "shell" (iPXE shell for debugging). UnknownMACFallback string } // Load reads configuration from the environment, applying defaults. *_FILE // variants (Vault-mounted secrets) win over their inline counterparts. func Load() (*Config, error) { cacheTTL, err := time.ParseDuration(getenv("BOOTAPI_CACHE_TTL", "30s")) if err != nil { return nil, fmt.Errorf("invalid BOOTAPI_CACHE_TTL: %w", err) } nbTimeout, err := time.ParseDuration(getenv("BOOTAPI_NETBOX_TIMEOUT", "5s")) if err != nil { return nil, fmt.Errorf("invalid BOOTAPI_NETBOX_TIMEOUT: %w", err) } gitInterval, err := time.ParseDuration(getenv("BOOTAPI_TEMPLATE_GIT_INTERVAL", "3m")) if err != nil { return nil, fmt.Errorf("invalid BOOTAPI_TEMPLATE_GIT_INTERVAL: %w", err) } token, err := readSecret("BOOTAPI_NETBOX_TOKEN") if err != nil { return nil, err } rootHash, err := readSecret("BOOTAPI_ROOT_PASSWORD_HASH") if err != nil { return nil, err } provToken, err := readSecret("BOOTAPI_PROVISION_TOKEN") if err != nil { return nil, err } fallback := getenv("BOOTAPI_UNKNOWN_MAC_FALLBACK", "local") if fallback != "local" && fallback != "shell" { return nil, fmt.Errorf("invalid BOOTAPI_UNKNOWN_MAC_FALLBACK %q: want \"local\" or \"shell\"", fallback) } baseURL := strings.TrimRight(os.Getenv("BOOTAPI_BASE_URL"), "/") callbackBase := strings.TrimRight(os.Getenv("BOOTAPI_CALLBACK_BASE_URL"), "/") if callbackBase == "" { callbackBase = baseURL } ns := splitList(os.Getenv("BOOTAPI_NAMESERVERS")) if len(ns) == 0 { ns = []string{"198.18.200.7"} // k8s bind-resolvers LB } return &Config{ ListenAddr: getenv("BOOTAPI_LISTEN_ADDR", ":8000"), TLSListenAddr: getenv("BOOTAPI_TLS_LISTEN_ADDR", ""), TLSCertFile: os.Getenv("BOOTAPI_TLS_CERT_FILE"), TLSKeyFile: os.Getenv("BOOTAPI_TLS_KEY_FILE"), NetBoxURL: strings.TrimRight(os.Getenv("BOOTAPI_NETBOX_URL"), "/"), NetBoxToken: token, NetBoxTimeout: nbTimeout, NetBoxInsecure: getenv("BOOTAPI_NETBOX_INSECURE", "false") == "true", CacheTTL: cacheTTL, TemplateDir: os.Getenv("BOOTAPI_TEMPLATE_DIR"), DefaultTemplate: getenv("BOOTAPI_DEFAULT_TEMPLATE", "almalinux9"), TemplateGitURL: strings.TrimRight(os.Getenv("BOOTAPI_TEMPLATE_GIT_URL"), "/"), TemplateGitBranch: getenv("BOOTAPI_TEMPLATE_GIT_BRANCH", "main"), TemplateGitInterval: gitInterval, TemplateGitToken: os.Getenv("BOOTAPI_TEMPLATE_GIT_TOKEN"), BaseURL: baseURL, CallbackBaseURL: callbackBase, ArtifactBaseURL: strings.TrimRight(getenv("BOOTAPI_ARTIFACT_BASE_URL", "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote"), "/"), BootBaseURL: strings.TrimRight(os.Getenv("BOOTAPI_BOOT_BASE_URL"), "/"), ProvisionToken: provToken, PuppetServer: getenv("BOOTAPI_PUPPET_SERVER", "puppet.k8s.syd1.au.unkin.net"), PuppetCAServer: getenv("BOOTAPI_PUPPET_CA_SERVER", "puppetca.k8s.syd1.au.unkin.net"), PuppetCAURL: getenv("BOOTAPI_PUPPET_CA_URL", "puppetca.k8s.syd1.au.unkin.net"), Domain: getenv("BOOTAPI_DOMAIN", "main.unkin.net"), Nameservers: ns, RootPasswordHash: rootHash, SSHAuthorizedKeys: splitLines(os.Getenv("BOOTAPI_SSH_AUTHORIZED_KEYS")), UnknownMACFallback: fallback, }, nil } // readSecret returns the value of env key, or the trimmed contents of the file // named by key+"_FILE" when that is set (the file wins). func readSecret(key string) (string, error) { v := os.Getenv(key) if f := os.Getenv(key + "_FILE"); f != "" { b, err := os.ReadFile(f) if err != nil { return "", fmt.Errorf("read %s_FILE %q: %w", key, f, err) } v = strings.TrimSpace(string(b)) } return v, nil } func getenv(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // splitList splits a comma-separated env value into a trimmed, non-empty slice. func splitList(v string) []string { if v == "" { return nil } var out []string for _, p := range strings.Split(v, ",") { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } // splitLines splits a newline-separated env value (e.g. multiple SSH keys) into // a trimmed, non-empty slice. func splitLines(v string) []string { if v == "" { return nil } var out []string for _, p := range strings.Split(v, "\n") { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out }