a73x

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
}