package shared import ( "errors" "fmt" "log" "os" "path/filepath" "strings" "sync" "git.nevets.tech/Keys/certman/common" pb "git.nevets.tech/Keys/certman/proto/v1" "github.com/google/uuid" "github.com/pelletier/go-toml/v2" "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]*common.DomainConfig } func NewDomainConfigStore() *DomainConfigStore { return &DomainConfigStore{ configs: make(map[string]*common.DomainConfig), } } func (s *DomainConfigStore) Get(domain string) (*common.DomainConfig, bool) { s.mu.RLock() defer s.mu.RUnlock() v, ok := s.configs[domain] return v, ok } func (s *DomainConfigStore) Set(domain string, v *common.DomainConfig) { 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]*common.DomainConfig) { 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]*common.DomainConfig { s.mu.RLock() defer s.mu.RUnlock() snap := make(map[string]*common.DomainConfig, len(s.configs)) for k, v := range s.configs { snap[k] = v } return snap } // --------------------------------------------------------------------------- // Global state // --------------------------------------------------------------------------- var ( config *common.AppConfig configMu sync.RWMutex domainStore = NewDomainConfigStore() ) func Config() *common.AppConfig { 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 { vConfig := viper.New() vConfig.SetConfigFile("/etc/certman/certman.conf") vConfig.SetConfigType("toml") if err := vConfig.ReadInConfig(); err != nil { return err } if vConfig.GetString("App.mode") == "server" { vConfig.SetConfigType("toml") vConfig.SetConfigFile("/etc/certman/server.conf") if err := vConfig.MergeInConfig(); err != nil { return err } } if err := vConfig.Unmarshal(&config); err != nil { return err } 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]*common.DomainConfig) 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 } cfg := &common.DomainConfig{} if err = v.Unmarshal(cfg); err != nil { return fmt.Errorf("unmarshaling %s: %w", path, err) } temp[domain] = cfg } domainStore.Swap(temp) return nil } // --------------------------------------------------------------------------- // Saving // --------------------------------------------------------------------------- func WriteConfig(filePath string, config *common.AppConfig) error { buf, err := toml.Marshal(&config) if err != nil { return fmt.Errorf("marshaling config: %w", err) } if err = os.WriteFile(filePath, buf, 0640); err != nil { return fmt.Errorf("write config file: %w", err) } return nil } func WriteDomainConfig(config *common.DomainConfig) error { buf, err := toml.Marshal(config) if err != nil { return fmt.Errorf("marshaling domain config: %w", err) } configPath := filepath.Join("/etc/certman/domains", config.Domain.DomainName+".conf") if err = os.WriteFile(configPath, buf, 0640); err != nil { return fmt.Errorf("write config file: %w", err) } return nil } // SaveDomainConfigs writes every loaded domain config back to disk. func SaveDomainConfigs() error { for _, v := range domainStore.Snapshot() { err := WriteDomainConfig(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 } // --------------------------------------------------------------------------- // 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 := common.GenerateKey() if err != nil { return fmt.Errorf("unable to generate key: %v", err) } localConfig := Config() var content string switch localConfig.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", localConfig.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) } } 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' ca_dir_url = 'https://acme-v02.api.letsencrypt.org/directory' [Cloudflare] cf_email = 'email@example.com' cf_api_key = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxx'` const defaultServerDomainConfig = `[Certificates] data_root = '' expiry = 90 request_method = 'dns-01' renew_period = 30 subdomains = [] crypto_key = '{key}' [Domain] domain_name = '{domain}' enabled = true dns_server = 'default' [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' `