package main import ( "errors" "fmt" "log" "os" "path/filepath" "strings" "sync" "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 domainStore = NewDomainConfigStore() ) // --------------------------------------------------------------------------- // Loading // --------------------------------------------------------------------------- // LoadConfig reads the main certman.conf into config. func LoadConfig(path string) error { config = viper.New() config.SetConfigFile(path) config.SetConfigType("ini") return config.ReadInConfig() } // 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("ini") 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 // --------------------------------------------------------------------------- // SaveDomainConfigs writes every loaded domain config back to disk. func SaveDomainConfigs() { for domain, v := range domainStore.Snapshot() { if err := v.WriteConfig(); err != nil { fmt.Printf("Error saving domain config %s: %v\n", domain, err) } } } // --------------------------------------------------------------------------- // 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 createNewConfig(mode string) { content := strings.NewReplacer("{mode}", mode).Replace(defaultConfig) createFile("/etc/certman/certman.conf", 640, []byte(content)) } func createNewDomainConfig(domain string) error { key, err := GenerateKey() if err != nil { return fmt.Errorf("unable to generate key: %v", err) } content := strings.NewReplacer( "{domain}", domain, "{key}", key, ).Replace(defaultDomainConfig) path := filepath.Join("/etc/certman/domains", domain+".conf") createFile(path, 0640, []byte(content)) return nil } func createNewDomainCertsDir(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] 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 defaultDomainConfig = `[Domain] domain_name = {domain} enabled = true dns_server = default [Certificates] data_root = expiry = 90 request_method = dns-01 renew_period = 30 subdomains = cert_symlink = key_symlink = crypto_key = {key} [Repo] repo_suffix = -certificates [Internal] last_issued = 0 repo_exists = false status = clean ` const readme = ``