Files
keywarden/agent/internal/config/config.go
T
2026-04-16 17:12:29 +01:00

249 lines
7.3 KiB
Go

package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
)
const (
DefaultConfigPath = "/etc/keywarden/agent.json"
DefaultStateDir = "/var/lib/keywarden-agent"
DefaultSyncIntervalSeconds = 30
DefaultLogBatchSize = 500
DefaultUsernameTemplate = "{{username}}_{{user_id}}"
DefaultShell = "/bin/bash"
DefaultAdminGroup = "sudo"
)
type AccountPolicy struct {
UsernameTemplate string `json:"username_template"`
DefaultShell string `json:"default_shell"`
AdminGroup string `json:"admin_group"`
CreateHome bool `json:"create_home"`
LockOnRevoke bool `json:"lock_on_revoke"`
}
type LogSource struct {
SourceID string `json:"source_id,omitempty"`
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
ServiceUnit string `json:"service_unit,omitempty"`
FilePath string `json:"file_path,omitempty"`
Parser string `json:"parser,omitempty"`
IncludeMatches map[string][]string `json:"include_matches,omitempty"`
ExcludeMatches map[string][]string `json:"exclude_matches,omitempty"`
Category string `json:"category,omitempty"`
EventType string `json:"event_type,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
type Config struct {
ServerURL string `json:"server_url"`
ServerID string `json:"server_id,omitempty"`
AgentAPIToken string `json:"agent_api_token,omitempty"`
ServerCAPath string `json:"server_ca_path,omitempty"`
SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"`
LogBatchSize int `json:"log_batch_size,omitempty"`
LogSources []LogSource `json:"log_sources,omitempty"`
StateDir string `json:"state_dir,omitempty"`
AccountPolicy AccountPolicy `json:"account_policy,omitempty"`
}
func LoadOrInit(path string, serverURL string) (*Config, error) {
if path == "" {
path = DefaultConfigPath
}
data, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read config: %w", err)
}
if serverURL == "" {
return nil, errors.New("server url required for first boot")
}
cfg := &Config{
ServerURL: serverURL,
AgentAPIToken: os.Getenv("KEYWARDEN_AGENT_API_TOKEN"),
ServerCAPath: os.Getenv("KEYWARDEN_SERVER_CA_PATH"),
}
applyDefaults(cfg)
if err := validate(cfg, false); err != nil {
return nil, err
}
if err := Save(path, cfg); err != nil {
return nil, err
}
return cfg, nil
}
cfg := &Config{}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
envAgentToken := strings.TrimSpace(os.Getenv("KEYWARDEN_AGENT_API_TOKEN"))
if cfg.ServerCAPath == "" {
cfg.ServerCAPath = os.Getenv("KEYWARDEN_SERVER_CA_PATH")
}
// Environment should win when set so operators can rotate tokens centrally
// without editing persisted agent.json on every node.
if envAgentToken != "" {
cfg.AgentAPIToken = envAgentToken
}
applyDefaults(cfg)
if err := validate(cfg, false); err != nil {
return nil, err
}
return cfg, nil
}
func Save(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config: %w", err)
}
if err := os.MkdirAll(dir(path), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
func applyDefaults(cfg *Config) {
if cfg.SyncIntervalSeconds <= 0 {
cfg.SyncIntervalSeconds = DefaultSyncIntervalSeconds
}
if cfg.LogBatchSize <= 0 {
cfg.LogBatchSize = DefaultLogBatchSize
}
if cfg.StateDir == "" {
cfg.StateDir = DefaultStateDir
}
if cfg.AccountPolicy.UsernameTemplate == "" {
cfg.AccountPolicy.UsernameTemplate = DefaultUsernameTemplate
}
if cfg.AccountPolicy.DefaultShell == "" {
cfg.AccountPolicy.DefaultShell = DefaultShell
}
if cfg.AccountPolicy.AdminGroup == "" {
cfg.AccountPolicy.AdminGroup = DefaultAdminGroup
}
}
func validate(cfg *Config, requireServerID bool) error {
cfg.ServerID = strings.TrimSpace(cfg.ServerID)
cfg.AgentAPIToken = strings.TrimSpace(cfg.AgentAPIToken)
var missing []string
if cfg.ServerURL == "" {
missing = append(missing, "server_url")
}
if requireServerID && cfg.ServerID == "" {
missing = append(missing, "server_id")
}
if len(missing) > 0 {
return fmt.Errorf("missing required config fields: %v", missing)
}
if looksLikePlaceholder(cfg.ServerID) {
return errors.New("invalid server_id placeholder in config; remove server_id and re-enroll the agent")
}
if cfg.ServerID != "" {
if _, err := strconv.Atoi(cfg.ServerID); err != nil {
return errors.New("invalid server_id in config; expected numeric server id from enrollment")
}
}
if looksLikePlaceholder(cfg.AgentAPIToken) {
return errors.New("invalid agent_api_token placeholder in config; remove agent_api_token and re-enroll the agent")
}
if cfg.SyncIntervalSeconds < 5 {
return errors.New("sync_interval_seconds must be >= 5")
}
for i, source := range cfg.LogSources {
kind := strings.ToLower(strings.TrimSpace(source.Kind))
switch kind {
case "journal":
// journal source is valid with optional include/exclude matches.
case "service":
if strings.TrimSpace(source.ServiceUnit) == "" && strings.TrimSpace(source.Name) == "" {
return fmt.Errorf("log_sources[%d]: service source requires service_unit or name", i)
}
case "file":
if strings.TrimSpace(source.FilePath) == "" && strings.TrimSpace(source.Name) == "" {
return fmt.Errorf("log_sources[%d]: file source requires file_path or name", i)
}
default:
return fmt.Errorf("log_sources[%d]: kind must be 'journal', 'service' or 'file'", i)
}
parser := strings.ToLower(strings.TrimSpace(source.Parser))
if parser == "" {
continue
}
switch parser {
case "none", "syslog", "nginx_access", "json":
default:
return fmt.Errorf("log_sources[%d]: parser must be one of none|syslog|nginx_access|json", i)
}
}
return nil
}
func looksLikePlaceholder(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
return strings.Contains(value, "<") || strings.Contains(value, ">")
}
func (c *Config) ClientCertPath() string {
return c.StateDir + "/agent.crt"
}
func (c *Config) ClientKeyPath() string {
return c.StateDir + "/agent.key"
}
func (c *Config) CACertPath() string {
return c.StateDir + "/ca.crt"
}
func (c *Config) LogCursorPath() string {
return c.StateDir + "/journal.cursor"
}
func (c *Config) LogSpoolDir() string {
return c.StateDir + "/spool"
}
func (c *Config) LogOffsetsPath() string {
return c.StateDir + "/log.offsets.json"
}
func (c *Config) LogStatePath() string {
return c.StateDir + "/log.state.json"
}
func (c *Config) EnabledLogSources() []LogSource {
if len(c.LogSources) == 0 {
return nil
}
out := make([]LogSource, 0, len(c.LogSources))
for _, source := range c.LogSources {
if source.Enabled != nil && !*source.Enabled {
continue
}
out = append(out, source)
}
return out
}
func dir(path string) string {
if idx := strings.LastIndex(path, string(os.PathSeparator)); idx != -1 {
return path[:idx]
}
return "."
}