package internal import ( "bytes" "errors" "fmt" "log" "os" "path/filepath" "strings" "sync" pb "git.nevets.tech/Keys/CertManager/proto/v1" "github.com/google/uuid" "github.com/spf13/viper" ) var ( ErrBlankConfigEntry = errors.New("blank config entry") ErrConfigNotFound = errors.New("config file not found") ) type DomainConfigStore struct { mu sync.RWMutex configs map[string]*viper.Viper } func NewDomainConfigStore() *DomainConfigStore { return &DomainConfigStore{ configs: make(map[string]*viper.Viper), } } func (s *DomainConfigStore) Get(domain string) (*viper.Viper, bool) { s.mu.RLock() defer s.mu.RUnlock() v, ok := s.configs[domain] return v, ok } func (s *DomainConfigStore) Set(domain string, v *viper.Viper) { s.mu.Lock() defer s.mu.Unlock() s.configs[domain] = v } // Swap atomically replaces the entire config map (used during reload). func (s *DomainConfigStore) Swap(newConfigs map[string]*viper.Viper) { s.mu.Lock() defer s.mu.Unlock() s.configs = newConfigs } // Snapshot returns a shallow copy safe to iterate without holding the lock. func (s *DomainConfigStore) Snapshot() map[string]*viper.Viper { s.mu.RLock() defer s.mu.RUnlock() snap := make(map[string]*viper.Viper, len(s.configs)) for k, v := range s.configs { snap[k] = v } return snap } // --------------------------------------------------------------------------- // Global state // --------------------------------------------------------------------------- var ( config *viper.Viper configMu sync.RWMutex domainStore = NewDomainConfigStore() ) func Config() *viper.Viper { configMu.RLock() defer configMu.RUnlock() return config } func DomainStore() *DomainConfigStore { domainStore.mu.RLock() defer domainStore.mu.RUnlock() return domainStore } // --------------------------------------------------------------------------- // Loading // --------------------------------------------------------------------------- // LoadConfig reads the main certman.conf into config. func LoadConfig() error { config = viper.New() config.SetConfigFile("/etc/certman/certman.conf") config.SetConfigType("toml") err := config.ReadInConfig() if err != nil { return err } switch config.GetString("App.mode") { case "server": config.SetConfigType("toml") config.SetConfigFile("server.conf") return config.MergeInConfig() case "Client": config.SetConfigType("toml") config.SetConfigFile("Client.conf") return config.MergeInConfig() } return nil } // LoadDomainConfigs reads every .conf file in the domains directory. func LoadDomainConfigs() error { dir := "/etc/certman/domains/" entries, err := os.ReadDir(dir) if err != nil { return fmt.Errorf("reading domain config dir: %w", err) } temp := make(map[string]*viper.Viper) for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".conf" { continue } path := filepath.Join(dir, entry.Name()) v := viper.New() v.SetConfigFile(path) v.SetConfigType("toml") if err := v.ReadInConfig(); err != nil { return fmt.Errorf("loading %s: %w", path, err) } domain := v.GetString("domain.domain_name") if domain == "" { return fmt.Errorf("%s: missing domain.domain_name", path) } if _, exists := temp[domain]; exists { fmt.Printf("Duplicate domain in %s, skipping...\n", path) continue } temp[domain] = v } domainStore.Swap(temp) return nil } // --------------------------------------------------------------------------- // Saving // --------------------------------------------------------------------------- func WriteConfig(filePath string, config *viper.Viper) error { var buf bytes.Buffer if err := config.WriteConfigTo(&buf); err != nil { return fmt.Errorf("marshal config: %w", err) } if err := os.WriteFile(filePath, buf.Bytes(), 0640); err != nil { return fmt.Errorf("write config file: %w", err) } return nil } func WriteMainConfig() error { return WriteConfig("/etc/certman/certman.conf", config) } func WriteDomainConfig(config *viper.Viper) error { return WriteConfig(config.GetString("Domain.domain_name"), config) } // SaveDomainConfigs writes every loaded domain config back to disk. func SaveDomainConfigs() error { for domain, v := range domainStore.Snapshot() { err := WriteConfig("/etc/certman/domains/"+domain+".conf", v) if err != nil { return err } } return nil } // --------------------------------------------------------------------------- // Domain Specific Lookups // --------------------------------------------------------------------------- func PostPullHooks(domain string) ([]*pb.Hook, error) { var hooks []*pb.Hook if err := viper.UnmarshalKey("Hooks.PostPull", hooks); err != nil { return nil, err } return hooks, nil } // --------------------------------------------------------------------------- // Effective lookups (domain → global fallback) // --------------------------------------------------------------------------- // EffectiveString looks up a key in the domain config first, falling back to // the global config. Keys use dot notation matching INI sections, e.g. // "certificates.data_root". func EffectiveString(domainCfg *viper.Viper, key string) (string, error) { if domainCfg != nil { val := strings.TrimSpace(domainCfg.GetString(key)) if val != "" { return val, nil } } if config == nil { return "", ErrConfigNotFound } val := strings.TrimSpace(config.GetString(key)) if val == "" { return "", ErrBlankConfigEntry } return val, nil } // MustEffectiveString is like EffectiveString but logs a fatal error on failure. func MustEffectiveString(domainCfg *viper.Viper, key string) string { val, err := EffectiveString(domainCfg, key) if err != nil { log.Fatalf("Config key %q: %v", key, err) } return val } // EffectiveInt returns an int with domain → global fallback. Returns the // fallback value if the key is missing or zero in both configs. func EffectiveInt(domainCfg *viper.Viper, key string, fallback int) int { if domainCfg != nil { if val := domainCfg.GetInt(key); val != 0 { return val } } if config != nil { if val := config.GetInt(key); val != 0 { return val } } return fallback } // EffectiveBool returns a bool with domain → global fallback. func EffectiveBool(domainCfg *viper.Viper, key string) bool { if domainCfg != nil && domainCfg.IsSet(key) { return domainCfg.GetBool(key) } if config != nil { return config.GetBool(key) } return false } // --------------------------------------------------------------------------- // Directory bootstrapping // --------------------------------------------------------------------------- func MakeDirs() { dirs := []struct { path string perm os.FileMode }{ {"/etc/certman", 0755}, {"/etc/certman/domains", 0755}, {"/var/local/certman", 0750}, } for _, d := range dirs { if err := os.MkdirAll(d.path, d.perm); err != nil { log.Fatalf("Unable to create directory %s: %v", d.path, err) } } } func CreateConfig(mode string) { content := strings.NewReplacer( "{mode}", mode, ).Replace(defaultConfig) createFile("/etc/certman/certman.conf", 0640, []byte(content)) switch mode { case "server": content = strings.NewReplacer( "{uuid}", uuid.New().String(), ).Replace(defaultServerConfig) createFile("/etc/certman/server.conf", 640, []byte(content)) } } func CreateDomainConfig(domain string) error { key, err := GenerateKey() if err != nil { return fmt.Errorf("unable to generate key: %v", err) } var content string switch Config().GetString("App.mode") { case "server": content = strings.NewReplacer( "{domain}", domain, "{key}", key, ).Replace(defaultServerDomainConfig) case "client": content = strings.NewReplacer( "{domain}", domain, "{key}", key, ).Replace(defaultClientDomainConfig) default: return fmt.Errorf("unknown certman mode: %v", Config().GetString("App.mode")) } path := filepath.Join("/etc/certman/domains", domain+".conf") createFile(path, 0640, []byte(content)) return nil } func CreateDomainCertsDir(domain string, dir string, dirOverride bool) { var target string if dirOverride { target = filepath.Join(dir, domain) } else { target = filepath.Join("/var/local/certman/certificates", domain) } if err := os.MkdirAll(target, 0750); err != nil { if os.IsExist(err) { fmt.Println("Directory already exists...") return } log.Fatalf("Error creating certificate directory for %s: %v", domain, err) } } // --------------------------------------------------------------------------- // Default config templates // --------------------------------------------------------------------------- const defaultConfig = `[App] mode = '{mode}' tick_rate = 2 [Git] host = 'gitea' server = 'https://gitea.instance.com' username = 'User' api_token = 'xxxxxxxxxxxxxxxxxxxxxxxxx' org_name = 'org' [Certificates] data_root = '/var/local/certman' ` const defaultServerConfig = `[App] uuid = '{uuid}' [Certificates] email = 'User@example.com' data_root = '/var/local/certman' ca_dir_url = 'https://acme-v02.api.letsencrypt.org/directory' [Cloudflare] cf_email = 'email@example.com' cf_api_key = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxx'` const defaultClientConfig = `` const defaultServerDomainConfig = `[Domain] domain_name = '{domain}' enabled = true dns_server = 'default' [Certificates] data_root = '' expiry = 90 request_method = 'dns-01' renew_period = 30 subdomains = [] crypto_key = '{key}' [Repo] repo_suffix = '-certificates' [Internal] last_issued = 0 repo_exists = false status = 'clean' ` const defaultClientDomainConfig = `[Certificates] data_root = '' cert_symlinks = [] key_symlinks = [] crypto_key = '{key}' [Domain] domain_name = '{domain}' enabled = true [Hooks.PostPull] command = [] cwd = "/dev/null" timeout_seconds = 30 env = { "FOO" = "bar" } [Repo] repo_suffix = '-certificates' ` const readme = ``