Files
certmanager/internal/config/config.go
T
unkin-agent b0aad96673
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline failed
Source the CA from /ca_chain, not from issuance
Deriving ca_certificate from an issue response welds CA distribution to
per-node renewal, so a rotation takes ~90 days to reach the estate, and
issuing_ca is the intermediate alone, which strict path builders reject
as a CAfile.

- add a `ca` subcommand printing {mount}/ca_chain as PEM, unauthenticated
- drop ca_certificate from the issue document and its file output
- order full_chain leaf first
- require only vault.addr for the CA path via config.LoadCA
2026-09-19 13:27:47 +10:00

182 lines
5.1 KiB
Go

package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// AuthMethod selects how certmanager authenticates to Vault.
type AuthMethod string
const (
AuthMethodAppRole AuthMethod = "approle"
AuthMethodKubernetes AuthMethod = "kubernetes"
)
// DefaultJWTPath is the in-cluster service account token location.
const DefaultJWTPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
// DefaultTimeout bounds every Vault request; Puppet calls this binary from
// generate() during catalog compilation, so an unbounded request would hang the
// compile forever.
const DefaultTimeout = 30 * time.Second
// defaultPath is overridable at build time with
// -X git.unkin.net/unkin/certmanager/internal/config.defaultPath=/some/path
var defaultPath = "/opt/certmanager/config.yaml"
// DefaultPath returns the config path baked in at build time.
func DefaultPath() string { return defaultPath }
// Config is the top-level configuration structure.
type Config struct {
Vault VaultConfig `yaml:"vault"`
}
// VaultConfig holds Vault connection and auth parameters. Only the fields
// relevant to the chosen AuthMethod need to be populated.
type VaultConfig struct {
Addr string `yaml:"addr"`
AuthMethod AuthMethod `yaml:"auth_method"`
MountPoint string `yaml:"mount_point"`
RoleName string `yaml:"role_name"`
OutputPath string `yaml:"output_path"`
RoleID string `yaml:"role_id"`
ApprolePath string `yaml:"approle_path"`
K8sMount string `yaml:"k8s_mount"`
K8sRole string `yaml:"k8s_role"`
JWTPath string `yaml:"jwt_path"`
// TLSSkipVerify defaults to true, preserving the Python script's
// verify=False behaviour until the estate trusts the internal CA.
TLSSkipVerify *bool `yaml:"tls_skip_verify"`
// Timeout is a Go duration string (e.g. "30s").
Timeout string `yaml:"timeout"`
}
// SkipTLSVerify reports whether certificate verification is disabled.
func (v VaultConfig) SkipTLSVerify() bool {
if v.TLSSkipVerify == nil {
return true
}
return *v.TLSSkipVerify
}
// RequestTimeout returns the configured per-request timeout.
func (v VaultConfig) RequestTimeout() (time.Duration, error) {
if v.Timeout == "" {
return DefaultTimeout, nil
}
d, err := time.ParseDuration(v.Timeout)
if err != nil {
return 0, fmt.Errorf("vault.timeout %q is not a valid duration: %w", v.Timeout, err)
}
if d <= 0 {
return 0, fmt.Errorf("vault.timeout %q must be positive", v.Timeout)
}
return d, nil
}
// Load reads, parses, defaults and validates the config file at path.
func Load(path string) (*Config, error) {
cfg, err := parse(path)
if err != nil {
return nil, err
}
if err := cfg.Vault.Validate(); err != nil {
return nil, fmt.Errorf("invalid config %q: %w", path, err)
}
return cfg, nil
}
// LoadCA reads the config for the CA chain path. That endpoint is public, so
// only vault.addr is required: a config carrying no auth method, no role and no
// credentials still loads.
func LoadCA(path string) (*Config, error) {
cfg, err := parse(path)
if err != nil {
return nil, err
}
if err := cfg.Vault.ValidateCA(); err != nil {
return nil, fmt.Errorf("invalid config %q: %w", path, err)
}
return cfg, nil
}
func parse(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open config %q: %w", path, err)
}
defer func() { _ = f.Close() }()
var cfg Config
if err := yaml.NewDecoder(f).Decode(&cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
if cfg.Vault.AuthMethod == "" {
cfg.Vault.AuthMethod = AuthMethodAppRole
}
if cfg.Vault.JWTPath == "" {
cfg.Vault.JWTPath = DefaultJWTPath
}
return &cfg, nil
}
// ValidateCA rejects a config that could not reach the CA chain endpoint. It
// deliberately ignores every auth key: the endpoint is unauthenticated.
func (v VaultConfig) ValidateCA() error {
if v.Addr == "" {
return fmt.Errorf("vault.addr is required")
}
if _, err := v.RequestTimeout(); err != nil {
return err
}
return nil
}
// Validate rejects a config that could not produce a certificate, naming the
// key at fault. It never falls back from one auth method to another.
func (v VaultConfig) Validate() error {
if v.Addr == "" {
return fmt.Errorf("vault.addr is required")
}
if v.MountPoint == "" {
return fmt.Errorf("vault.mount_point is required")
}
if v.RoleName == "" {
return fmt.Errorf("vault.role_name is required")
}
if _, err := v.RequestTimeout(); err != nil {
return err
}
switch v.AuthMethod {
case AuthMethodAppRole:
if v.RoleID == "" {
return fmt.Errorf("vault.role_id is required for auth_method %q", v.AuthMethod)
}
if v.ApprolePath == "" {
return fmt.Errorf("vault.approle_path is required for auth_method %q", v.AuthMethod)
}
case AuthMethodKubernetes:
if v.K8sMount == "" {
return fmt.Errorf("vault.k8s_mount is required for auth_method %q", v.AuthMethod)
}
if v.K8sRole == "" {
return fmt.Errorf("vault.k8s_role is required for auth_method %q", v.AuthMethod)
}
default:
return fmt.Errorf("unknown vault.auth_method %q (valid: %s, %s)",
v.AuthMethod, AuthMethodAppRole, AuthMethodKubernetes)
}
return nil
}