Files
bootapi/internal/config/config.go
T
unkin-agent a414918350
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Fetch templates over HTTP instead of shelling out to git
The runtime image is distroless and has no git binary, so every sync
failed and bootapi silently served the stale embedded templates.

- fetch the branch tarball (<repo>/archive/<branch>.tar.gz) and extract
  it into an in-memory FS; no checkout, no writable volume
- digest the extracted tree, not the archive bytes, so a recompressed
  identical archive is not a change
- skip entries that would escape the tree
- log the source commit from Gitea's immutable Link header
2026-09-26 18:59:07 +10:00

232 lines
8.4 KiB
Go

// 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 fetch a templates repo's branch
// tarball over HTTP (<repo>/archive/<branch>.tar.gz) and re-fetch
// 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,
// sent as a Gitea token header. 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
}