internal/server/config/load.go
Ref: Size: 5.9 KiB History
// load.go reads and validates the server config. It lives here rather than in
// cmd/eitri-server so the rules are testable and coverage-gated (arch R14:
// main packages are wiring only).
package config
import (
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"maps"
"net/url"
"os"
"slices"
"strings"
"time"
"github.com/a73x/eitri/internal/names"
)
// Load reads path, decodes the JSON, and enforces every startup invariant the
// server refuses to boot without. It warns (but does not fail) on the retired
// admin_token key so an old config gets pruned rather than silently carried.
func Load(path string) (Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := json.Unmarshal(raw, &cfg); err != nil {
return Config{}, fmt.Errorf("parse config: %w", err)
}
if err := validate(cfg); err != nil {
return Config{}, err
}
// admin_token is retired: sign-in is OIDC and console credentials are
// sessions/PATs. Warn once so the operator prunes the stale key.
if cfg.AdminToken != "" {
slog.Warn("server.json: admin_token is no longer used and is ignored; remove it")
}
// default_image_url/sha are retired in favour of default_images. They are
// IGNORED rather than used as a fallback for an unlisted architecture: the
// digest says nothing about what the image can execute, so honouring one
// image for every host is how an amd64 image reaches an arm64 Mac and dies
// as "ephemeral VM lost". A one-click create for an unlisted arch fails
// instead, at create time, naming the arch — and the server still boots, so
// this never turns a config upgrade into an outage.
if cfg.DefaultImageURL != "" || cfg.DefaultImageSHA != "" {
slog.Warn("server.json: default_image_url/default_image_sha256 are no longer used and are IGNORED; " +
`move them under default_images keyed by host architecture, e.g. ` +
`"default_images": {"amd64": {"url": "...", "sha256": "..."}}`)
}
return cfg, nil
}
func validate(cfg Config) error {
if cfg.HostSecret == "" {
return fmt.Errorf("host_secret is required")
}
// The key that seals this server's key material. Checked at boot rather than
// at first use: a plane whose KEK is missing or mistyped cannot open its own
// host CA, and that is a refusal to start, not a surprise on some later
// request.
if _, err := cfg.KEKBytes(); err != nil {
return err
}
// The server is a pure OIDC relying party (spec §2): issuer, client_id and
// public_url are required. Collect every missing key so the operator fixes
// server.json in one pass rather than one restart per key.
var missingOIDC []string
if cfg.OIDC.Issuer == "" {
missingOIDC = append(missingOIDC, "oidc.issuer")
}
if cfg.OIDC.ClientID == "" {
missingOIDC = append(missingOIDC, "oidc.client_id")
}
if cfg.OIDC.PublicURL == "" {
missingOIDC = append(missingOIDC, "oidc.public_url")
}
if len(missingOIDC) > 0 {
return fmt.Errorf("missing required keys (point them at eitri-oidc or your IdP): %s", strings.Join(missingOIDC, ", "))
}
// public_url builds the OIDC callback URL, so it must be an absolute
// http(s) URL with a host — catch a bare host, missing scheme, or
// scheme-only URL at boot, not at the first redirect.
if u, err := url.Parse(cfg.OIDC.PublicURL); err != nil || !u.IsAbs() || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("oidc.public_url must be an absolute http(s) URL, got %q", cfg.OIDC.PublicURL)
}
if cfg.AdvertiseHTTP == "" || cfg.AdvertiseQUIC == "" {
return fmt.Errorf("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)")
}
// Fail fast on a malformed default image rather than letting every one-click
// create propagate a bad hash. Sorted so a config with several bad entries
// reports them in a stable order.
for _, arch := range slices.Sorted(maps.Keys(cfg.DefaultImages)) {
img := cfg.DefaultImages[arch]
switch {
case arch == "":
return fmt.Errorf(`default_images has an empty architecture key (want a GOARCH, e.g. "amd64")`)
case img.URL == "":
return fmt.Errorf("default_images[%q].url is required", arch)
case !names.IsSHA256Hex(img.SHA256):
return fmt.Errorf("default_images[%q].sha256 malformed (want 64 lowercase hex chars), got %q", arch, img.SHA256)
}
}
return nil
}
// KEKBytes decodes key_encryption_key into the raw key that seals every piece
// of key material this server holds. Load enforces it, so a config that loaded
// has one; the boot wiring decodes it once and hands the bytes to each place
// that seals or opens.
//
// The errors state the rule and the command that satisfies it, and never echo
// the value — a key does not belong in a startup log.
func (c Config) KEKBytes() ([]byte, error) {
const size = 32 // AES-256
if c.KeyEncryptionKey == "" {
return nil, fmt.Errorf("key_encryption_key is required (%d hex characters, minted with `openssl rand -hex %d`): "+
"it encrypts the host CA and the gate host key at rest", size*2, size)
}
kek, err := hex.DecodeString(c.KeyEncryptionKey)
if err != nil || len(kek) != size {
return nil, fmt.Errorf("key_encryption_key must be %d hex characters (%d bytes, minted with `openssl rand -hex %d`)", size*2, size, size)
}
return kek, nil
}
// ParseDuration parses raw (a config duration string) for the knob named name
// (the JSON field label echoed in errors). Empty raw keeps def. valid, when
// non-nil, is the knob's range rule; rule (e.g. ">= 0") spells it in the
// error. A nil valid skips the range check.
func ParseDuration(name, raw string, def time.Duration, valid func(time.Duration) bool, rule string) (time.Duration, error) {
if raw == "" {
return def, nil
}
d, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("%s invalid: %w", name, err)
}
if valid != nil && !valid(d) {
return 0, fmt.Errorf("%s must be %s, got %q", name, rule, raw)
}
return d, nil
}