From d737353f2d8d62af2c8b03006c954ce425f9ab32 Mon Sep 17 00:00:00 2001 From: Steven Tracey Date: Mon, 23 Feb 2026 00:48:39 +0100 Subject: [PATCH] Server and Client daemons functional --- Makefile | 6 +- acme_manager.go | 672 ++++++++++++++++++++++++++++++++++++++++++++++++ certs.go | 97 ------- client.go | 103 ++++++++ config.go | 181 +++++++++++-- crypto.go | 78 +++--- git.go | 216 ++++++++++++---- go.mod | 2 +- go.sum | 6 + main.go | 418 +++++++++++++++++------------- server.go | 173 +++++++++++++ util.go | 217 +++++++++++----- 12 files changed, 1720 insertions(+), 449 deletions(-) create mode 100644 acme_manager.go delete mode 100644 certs.go create mode 100644 client.go create mode 100644 server.go diff --git a/Makefile b/Makefile index 613d2ff..eb0b3fd 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,6 @@ build: - @go build -o ./certman . \ No newline at end of file + @go build -o ./certman . + +stage: build + @sudo cp ./certman /srv/vm-passthru/certman + @ssh steven@192.168.122.44 updateCertman.sh \ No newline at end of file diff --git a/acme_manager.go b/acme_manager.go new file mode 100644 index 0000000..7f025aa --- /dev/null +++ b/acme_manager.go @@ -0,0 +1,672 @@ +// acme_manager.go +package main + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/go-acme/lego/v4/certcrypto" + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/providers/dns/cloudflare" + "github.com/go-acme/lego/v4/registration" +) + +// --------------------------------------------- +// Thread safety for your domain config map +// (assumes you already have these globals elsewhere) +// --------------------------------------------- +// var mu sync.RWMutex +// var domainConfigs map[string]*ezconf.Configuration +// var config *ezconf.Configuration +// +// func getDomainConfig(domain string) (*ezconf.Configuration, bool) { ... } + +// --------------------------------------------- +// ACME account user (file-backed) +// --------------------------------------------- + +type fileUser struct { + Email string `json:"email"` + Registration *registration.Resource `json:"registration,omitempty"` + + privateKey crypto.PrivateKey `json:"-"` +} + +func (u *fileUser) GetEmail() string { return u.Email } +func (u *fileUser) GetRegistration() *registration.Resource { return u.Registration } +func (u *fileUser) GetPrivateKey() crypto.PrivateKey { return u.privateKey } + +// --------------------------------------------- +// Manager config + manager +// --------------------------------------------- + +type ACMEManager struct { + mu sync.Mutex // serializes lego client ops + account writes + client *lego.Client + user *fileUser + + // root dirs + dataRoot string // e.g. /var/local/certman + accountRoot string // e.g. /var/local/certman/accounts + certsRoot string // e.g. /var/local/certman/certificates +} + +// DomainRuntimeConfig has domain-specific runtime settings derived from main+domain config. +type DomainRuntimeConfig struct { + DomainName string + Email string + DataRoot string + CADirURL string + ExpiryDays int + RenewPeriod int + RequestMethod string + Subdomains []string +} + +type StoredCertMeta struct { + DomainKey string `json:"domain_key"` + Domains []string `json:"domains"` + CertURL string `json:"cert_url,omitempty"` + CertStableURL string `json:"cert_stable_url,omitempty"` + LastIssued time.Time `json:"last_issued,omitempty"` + LastRenewed time.Time `json:"last_renewed,omitempty"` +} + +// --------------------------------------------- +// Public API +// --------------------------------------------- + +// NewACMEManager initializes a long-lived lego client using: +// - file-backed account +// - persistent ECDSA P-256 account key +// - Let’s Encrypt production by default (from config fallback) +// - Cloudflare DNS-01 only +func NewACMEManager() (*ACMEManager, error) { + // Pull effective (main-only) certificate settings. + email, err := config.GetAsStringErr("Certificates.email") + if err != nil { + return nil, err + } + dataRoot, err := config.GetAsStringErr("Certificates.data_root") + if err != nil { + return nil, err + } + caDirURL, err := config.GetAsStringErr("Certificates.ca_dir_url") + if err != nil { + return nil, err + } + + // Build manager paths + mgr := &ACMEManager{ + dataRoot: dataRoot, + accountRoot: filepath.Join(dataRoot, "accounts"), + certsRoot: filepath.Join(dataRoot, "certificates"), + } + + if err := os.MkdirAll(mgr.accountRoot, 0o700); err != nil { + return nil, fmt.Errorf("create account root: %w", err) + } + if err := os.MkdirAll(mgr.certsRoot, 0o700); err != nil { + return nil, fmt.Errorf("create certs root: %w", err) + } + + // Create/load file-backed account user + user, err := loadOrCreateACMEUser(mgr.accountRoot, email) + if err != nil { + return nil, fmt.Errorf("load/create acme user: %w", err) + } + + // Cloudflare provider (DNS-01 only). + // lego Cloudflare provider expects env vars (CLOUDFLARE_EMAIL/CLOUDFLARE_API_KEY or tokens). :contentReference[oaicite:2]{index=2} + restoreEnv, err := setCloudflareEnvFromMainConfig() + if err != nil { + return nil, err + } + defer restoreEnv() + + legoCfg := lego.NewConfig(user) + legoCfg.CADirURL = caDirURL + // Secure cert key type for issued certs (ECDSA P-256) + legoCfg.Certificate.KeyType = certcrypto.EC256 + + client, err := lego.NewClient(legoCfg) + if err != nil { + return nil, fmt.Errorf("lego.NewClient: %w", err) + } + + cfProvider, err := cloudflare.NewDNSProvider() + if err != nil { + return nil, fmt.Errorf("cloudflare dns provider: %w", err) + } + + if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil { + return nil, fmt.Errorf("set dns-01 provider: %w", err) + } + + mgr.client = client + mgr.user = user + + // Register account only on first run + if mgr.user.Registration == nil { + reg, err := mgr.client.Registration.Register(registration.RegisterOptions{ + TermsOfServiceAgreed: true, + }) + if err != nil { + return nil, fmt.Errorf("acme registration: %w", err) + } + mgr.user.Registration = reg + if err := saveACMEUser(mgr.accountRoot, mgr.user); err != nil { + return nil, fmt.Errorf("save acme user registration: %w", err) + } + } + + return mgr, nil +} + +// ObtainForDomain obtains a new cert for a configured domain and saves it to disk. +func (m *ACMEManager) ObtainForDomain(domainKey string) (*certificate.Resource, error) { + rcfg, err := buildDomainRuntimeConfig(domainKey) + if err != nil { + return nil, err + } + + domains := buildDomainList(rcfg.DomainName, rcfg.Subdomains) + if len(domains) == 0 { + return nil, fmt.Errorf("no domains built for %q", domainKey) + } + + req := certificate.ObtainRequest{ + Domains: domains, + Bundle: true, + } + + m.mu.Lock() + defer m.mu.Unlock() + + res, err := m.client.Certificate.Obtain(req) + if err != nil { + return nil, fmt.Errorf("obtain %q: %w", domainKey, err) + } + + if err := m.saveCertFiles(domainKey, res, true); err != nil { + return nil, fmt.Errorf("save cert files for %q: %w", domainKey, err) + } + + return res, nil +} + +// RenewForDomain renews an existing stored cert for a domain key. +func (m *ACMEManager) RenewForDomain(domainKey string) (*certificate.Resource, error) { + m.mu.Lock() + defer m.mu.Unlock() + + existing, err := m.loadStoredResource(domainKey) + if err != nil { + return nil, fmt.Errorf("load stored resource for %q: %w", domainKey, err) + } + + // RenewWithOptions is preferred in newer lego versions. + renewed, err := m.client.Certificate.RenewWithOptions(*existing, &certificate.RenewOptions{ + Bundle: true, + }) + if err != nil { + return nil, fmt.Errorf("renew %q: %w", domainKey, err) + } + + if err := m.saveCertFiles(domainKey, renewed, false); err != nil { + return nil, fmt.Errorf("save renewed cert files for %q: %w", domainKey, err) + } + + return renewed, nil +} + +// GetCertPaths returns disk paths for the domain's cert material. +func (m *ACMEManager) GetCertPaths(domainKey string) (certPEM, keyPEM string) { + base := sanitizeDomainKey(domainKey) + dir := filepath.Join(m.certsRoot, base) + return filepath.Join(dir, base+".crt"), + filepath.Join(dir, base+".key") +} + +// --------------------------------------------- +// Domain runtime config assembly +// --------------------------------------------- + +func buildDomainRuntimeConfig(domainKey string) (*DomainRuntimeConfig, error) { + domainCfg, exists := getDomainConfig(domainKey) + if !exists { + return nil, fmt.Errorf("domain config not found for %q", domainKey) + } + + domainName, err := domainCfg.GetAsStringErr("Domain.domain_name") + if err != nil { + return nil, err + } + + email, err := config.GetAsStringErr("Certificates.email") + if err != nil { + return nil, err + } + + // domain override data_root can be blank -> main fallback + dataRoot, err := getEffectiveString(domainCfg, "Certificates.data_root") + if err != nil { + return nil, err + } + + caDirURL, err := config.GetAsStringErr("Certificates.ca_dir_url") + if err != nil { + return nil, err + } + + expiry, err := domainCfg.GetAsIntErr("Certificates.expiry") + if err != nil { + return nil, err + } + + renewPeriod, err := domainCfg.GetAsIntErr("Certificates.renew_period") + if err != nil { + return nil, err + } + + requestMethod := "" + if k, err := domainCfg.GetKey("Certificates.request_method"); err == nil && k != nil { + requestMethod = strings.TrimSpace(k.String()) + } + + subdomains := []string{} + if k, err := domainCfg.GetKey("Certificates.subdomains"); err == nil && k != nil { + subdomains = parseCSVLines(k.String()) + } + + return &DomainRuntimeConfig{ + DomainName: domainName, + Email: email, + DataRoot: dataRoot, + CADirURL: caDirURL, + ExpiryDays: expiry, + RenewPeriod: renewPeriod, + RequestMethod: requestMethod, + Subdomains: subdomains, + }, nil +} + +func parseCSVLines(raw string) []string { + // supports comma-separated and newline-separated lists + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == '\n' || r == '\r' + }) + out := make([]string, 0, len(fields)) + for _, f := range fields { + s := strings.TrimSpace(f) + if s != "" { + out = append(out, s) + } + } + return out +} + +// If subdomains contains ["www","api"], returns ["example.com","www.example.com","api.example.com"]. +// If a subdomain entry looks like a full FQDN already, it is used as-is. +func buildDomainList(baseDomain string, subs []string) []string { + seen := map[string]struct{}{} + add := func(d string) { + d = strings.TrimSpace(strings.ToLower(d)) + if d == "" { + return + } + if _, ok := seen[d]; ok { + return + } + seen[d] = struct{}{} + } + + add(baseDomain) + + for _, s := range subs { + s = strings.TrimSpace(s) + if s == "" { + continue + } + + if s == "*" { + add("*." + baseDomain) + continue + } + + if strings.HasPrefix(s, "*.") && !strings.HasSuffix(s, "."+baseDomain) { + rest := strings.TrimPrefix(s, "*.") + if rest != "" { + add("*." + rest + "." + baseDomain) + continue + } + } + + if strings.Contains(s, ".") { + add(s) + continue + } + + add(s + "." + baseDomain) + } + + out := make([]string, 0, len(seen)) + for d := range seen { + out = append(out, d) + } + return out +} + +// --------------------------------------------- +// Cloudflare env setup from main config +// --------------------------------------------- + +func setCloudflareEnvFromMainConfig() (restore func(), err error) { + // Your current defaults show legacy email + API key fields. + // Prefer API tokens in the future if you add them. Cloudflare provider supports both styles. :contentReference[oaicite:3]{index=3} + cfEmail, err := config.GetAsStringErr("Cloudflare.cf_email") + if err != nil { + return nil, err + } + cfAPIKey, err := config.GetAsStringErr("Cloudflare.cf_api_key") + if err != nil { + return nil, err + } + + // Save prior env values so we can restore them after provider creation. + prevEmail, hadEmail := os.LookupEnv("CLOUDFLARE_EMAIL") + prevKey, hadKey := os.LookupEnv("CLOUDFLARE_API_KEY") + + if err := os.Setenv("CLOUDFLARE_EMAIL", cfEmail); err != nil { + return nil, err + } + if err := os.Setenv("CLOUDFLARE_API_KEY", cfAPIKey); err != nil { + return nil, err + } + + restore = func() { + if hadEmail { + _ = os.Setenv("CLOUDFLARE_EMAIL", prevEmail) + } else { + _ = os.Unsetenv("CLOUDFLARE_EMAIL") + } + if hadKey { + _ = os.Setenv("CLOUDFLARE_API_KEY", prevKey) + } else { + _ = os.Unsetenv("CLOUDFLARE_API_KEY") + } + } + return restore, nil +} + +// --------------------------------------------- +// File-backed ACME account persistence +// --------------------------------------------- + +func loadOrCreateACMEUser(accountRoot, email string) (*fileUser, error) { + if err := os.MkdirAll(accountRoot, 0o700); err != nil { + return nil, err + } + + accountJSON := filepath.Join(accountRoot, "account.json") + accountKey := filepath.Join(accountRoot, "account.key.pem") + + jsonExists := fileExists(accountJSON) + keyExists := fileExists(accountKey) + + switch { + case jsonExists && keyExists: + return loadACMEUser(accountRoot) + case !jsonExists && !keyExists: + return createACMEUser(accountRoot, email) + default: + return nil, fmt.Errorf("inconsistent account state (need both %s and %s)", accountJSON, accountKey) + } +} + +func createACMEUser(accountRoot, email string) (*fileUser, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ECDSA P-256 account key: %w", err) + } + + u := &fileUser{ + Email: email, + privateKey: priv, + } + if err := saveACMEUser(accountRoot, u); err != nil { + return nil, err + } + return u, nil +} + +func loadACMEUser(accountRoot string) (*fileUser, error) { + keyPEM, err := os.ReadFile(filepath.Join(accountRoot, "account.key.pem")) + if err != nil { + return nil, err + } + priv, err := parseECPrivateKeyPEM(keyPEM) + if err != nil { + return nil, err + } + + raw, err := os.ReadFile(filepath.Join(accountRoot, "account.json")) + if err != nil { + return nil, err + } + + var tmp struct { + Email string `json:"email"` + Registration *registration.Resource `json:"registration,omitempty"` + } + if err := json.Unmarshal(raw, &tmp); err != nil { + return nil, err + } + + return &fileUser{ + Email: tmp.Email, + Registration: tmp.Registration, + privateKey: priv, + }, nil +} + +func saveACMEUser(accountRoot string, u *fileUser) error { + if u == nil { + return errors.New("nil user") + } + if err := os.MkdirAll(accountRoot, 0o700); err != nil { + return err + } + + keyPEM, err := marshalECPrivateKeyPEM(u.privateKey) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(accountRoot, "account.key.pem"), keyPEM, 0o600); err != nil { + return err + } + + tmp := struct { + Email string `json:"email"` + Registration *registration.Resource `json:"registration,omitempty"` + }{ + Email: u.Email, + Registration: u.Registration, + } + data, err := json.MarshalIndent(tmp, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(accountRoot, "account.json"), data, 0o600); err != nil { + return err + } + + return nil +} + +func marshalECPrivateKeyPEM(pk crypto.PrivateKey) ([]byte, error) { + ec, ok := pk.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("expected *ecdsa.PrivateKey, got %T", pk) + } + der, err := x509.MarshalECPrivateKey(ec) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), nil +} + +func parseECPrivateKeyPEM(data []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("invalid PEM") + } + if block.Type != "EC PRIVATE KEY" { + return nil, fmt.Errorf("unexpected PEM type %q", block.Type) + } + return x509.ParseECPrivateKey(block.Bytes) +} + +// --------------------------------------------- +// Certificate storage / loading +// --------------------------------------------- + +type storedResource struct { + Domain string `json:"domain"` + Domains []string `json:"domains"` + CertURL string `json:"cert_url,omitempty"` + CertStableURL string `json:"cert_stable_url,omitempty"` + Certificate []byte `json:"certificate,omitempty"` + PrivateKey []byte `json:"private_key,omitempty"` + IssuerCert []byte `json:"issuer_certificate,omitempty"` + CSR []byte `json:"csr,omitempty"` +} + +func (m *ACMEManager) saveCertFiles(domainKey string, res *certificate.Resource, isNew bool) error { + if res == nil { + return errors.New("nil certificate resource") + } + + base := sanitizeDomainKey(domainKey) + dir := filepath.Join(m.certsRoot, base) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + if len(res.PrivateKey) > 0 { + if err := os.WriteFile(filepath.Join(dir, base+".key"), res.PrivateKey, 0o600); err != nil { + return err + } + } + if len(res.IssuerCertificate) > 0 { + fullchain := append(append([]byte{}, res.Certificate...), res.IssuerCertificate...) + if err := os.WriteFile(filepath.Join(dir, base+".crt"), fullchain, 0o600); err != nil { + return err + } + } else if len(res.Certificate) > 0 { + // fallback fullchain = cert only + if err := os.WriteFile(filepath.Join(dir, base+".pem"), res.Certificate, 0o600); err != nil { + return err + } + } + + sr := storedResource{ + Domain: domainKey, + Domains: append([]string(nil), res.Domain), + CertURL: res.CertURL, + CertStableURL: res.CertStableURL, + Certificate: res.Certificate, + PrivateKey: res.PrivateKey, + IssuerCert: res.IssuerCertificate, + CSR: res.CSR, + } + js, err := json.MarshalIndent(sr, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, base+".json"), js, 0o600); err != nil { + return err + } + + // metadata + meta := StoredCertMeta{ + DomainKey: domainKey, + Domains: append([]string(nil), res.Domain), + CertURL: res.CertURL, + CertStableURL: res.CertStableURL, + } + now := time.Now().UTC() + if old, err := m.loadMeta(domainKey); err == nil && !old.LastIssued.IsZero() { + meta.LastIssued = old.LastIssued + } + if isNew { + if meta.LastIssued.IsZero() { + meta.LastIssued = now + } + } else { + meta.LastRenewed = now + if meta.LastIssued.IsZero() { + meta.LastIssued = now + } + } + metaRaw, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, base+".meta.json"), metaRaw, 0o600); err != nil { + return err + } + + return nil +} + +func (m *ACMEManager) loadStoredResource(domainKey string) (*certificate.Resource, error) { + base := sanitizeDomainKey(domainKey) + dir := filepath.Join(m.certsRoot, base) + raw, err := os.ReadFile(filepath.Join(dir, base+".json")) + if err != nil { + return nil, err + } + + var sr storedResource + if err := json.Unmarshal(raw, &sr); err != nil { + return nil, err + } + + return &certificate.Resource{ + Domain: sr.Domain, + CertURL: sr.CertURL, + CertStableURL: sr.CertStableURL, + Certificate: sr.Certificate, + PrivateKey: sr.PrivateKey, + IssuerCertificate: sr.IssuerCert, + CSR: sr.CSR, + }, nil +} + +func (m *ACMEManager) loadMeta(domainKey string) (*StoredCertMeta, error) { + base := sanitizeDomainKey(domainKey) + dir := filepath.Join(m.certsRoot, base) + raw, err := os.ReadFile(filepath.Join(dir, base+".meta.json")) + if err != nil { + return nil, err + } + var meta StoredCertMeta + if err := json.Unmarshal(raw, &meta); err != nil { + return nil, err + } + return &meta, nil +} diff --git a/certs.go b/certs.go deleted file mode 100644 index be116b8..0000000 --- a/certs.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "fmt" - "log" - "os" - - "github.com/go-acme/lego/v4/providers/dns/cloudflare" - - "github.com/go-acme/lego/v4/certcrypto" - "github.com/go-acme/lego/v4/certificate" - "github.com/go-acme/lego/v4/lego" - "github.com/go-acme/lego/v4/registration" -) - -type User struct { - Email string - Registration *registration.Resource - key crypto.PrivateKey -} - -func (u *User) GetEmail() string { - return u.Email -} -func (u *User) GetRegistration() *registration.Resource { - return u.Registration -} -func (u *User) GetPrivateKey() crypto.PrivateKey { - return u.key -} - -func mainexample() { - - // Create a user. New accounts need an email and private key to start. - privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - log.Fatal(err) - } - - user := User{ - Email: config.GetAsString("Certificates.email"), - key: privateKey, - } - - configLE := lego.NewConfig(&user) - - // This CA URL is configured for a local dev instance of Boulder running in Docker in a VM. - configLE.CADirURL = "http://192.168.99.100:4000/directory" - configLE.Certificate.KeyType = certcrypto.RSA2048 - - // A client facilitates communication with the CA server. - client, err := lego.NewClient(configLE) - if err != nil { - log.Fatal(err) - } - - dnsConfig := cloudflare.NewDefaultConfig() - dnsConfig.AuthEmail = "" //TODO Pull from config - dnsConfig.AuthKey = "" //TODO Pull from config - - provider, err := cloudflare.NewDNSProviderConfig(dnsConfig) - if err != nil { - fmt.Printf("Error creating DNS provider: %v\n", err) - os.Exit(1) - } - err = client.Challenge.SetDNS01Provider(provider) - if err != nil { - fmt.Printf("Error setting dns provider: %v\n", err) - os.Exit(1) - } - - // New users will need to register - reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) - if err != nil { - log.Fatal(err) - } - user.Registration = reg - - request := certificate.ObtainRequest{ - Domains: []string{"mydomain.com"}, - Bundle: true, - } - certificates, err := client.Certificate.Obtain(request) - if err != nil { - log.Fatal(err) - } - - // Each certificate comes back with the cert bytes, the bytes of the client's - // private key, and a certificate URL. SAVE THESE TO DISK. - fmt.Printf("%#v\n", certificates) - - // ... all done. -} diff --git a/client.go b/client.go new file mode 100644 index 0000000..7140270 --- /dev/null +++ b/client.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "io" + "log" + "path/filepath" + "strings" + + "git.nevets.tech/Steven/ezconf" + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-git/v5/storage/memory" +) + +func initClient() { + err := loadDomainConfigs() + if err != nil { + log.Fatalf("Error loading domain configs: %v", err) + } +} + +func clientTick() { + fmt.Println("Tick!") + + mu.RLock() + localDomainConfigs := make(map[string]*ezconf.Configuration, len(domainConfigs)) + for k, v := range domainConfigs { + localDomainConfigs[k] = v + } + mu.RUnlock() + + for domainStr, domainConfig := range localDomainConfigs { + if !domainConfig.GetAsBoolean("Domain.enabled") { + continue + } + + gitWorkspace := &GitWorkspace{ + Storage: memory.NewStorage(), + FS: memfs.New(), + } + repoUrl := config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domainStr + domainConfig.GetAsString("Repo.repo_suffix") + ".git" + err := cloneRepo(repoUrl, gitWorkspace) + if err != nil { + fmt.Printf("Error cloning domain repo %s: %v\n", domainStr, err) + continue + } + + fileInfos, err := gitWorkspace.FS.ReadDir("/") + if err != nil { + fmt.Printf("Error reading directory in memFS on domain %s: %v\n", domainStr, err) + continue + } + for _, fileInfo := range fileInfos { + if strings.HasSuffix(fileInfo.Name(), ".crpt") { + filename, _ := strings.CutSuffix(fileInfo.Name(), ".crpt") + file, err := gitWorkspace.FS.Open(fileInfo.Name()) + if err != nil { + fmt.Printf("Error opening file in memFS on domain %s: %v\n", domainStr, err) + continue + } + + fileBytes, err := io.ReadAll(file) + if err != nil { + fmt.Printf("Error reading file in memFS on domain %s: %v\n", domainStr, err) + file.Close() + continue + } + err = file.Close() + if err != nil { + fmt.Printf("Error closing file on domain %s: %v\n", domainStr, err) + continue + } + + dataRoot, err := getEffectiveString(domainConfig, "Certificates.data_root") + if err != nil { + fmt.Printf("Error getting effective data_root for domain %s: %v\n", domainStr, err) + continue + } + err = DecryptFileFromBytes(domainConfig.GetAsString("Certificates.crypto_key"), fileBytes, filepath.Join(dataRoot, "certificates", domainStr, filename), nil) + if err != nil { + fmt.Printf("Error decrypting file %s in domain %s: %v\n", filename, domainStr, err) + continue + } + + //TODO write hash locally, compare on tick to determine update + } + } + } +} + +func reloadClient() { + fmt.Println("Reloading configs...") + + err := loadDomainConfigs() + if err != nil { + fmt.Printf("Error loading domain configs: %v\n", err) + return + } +} + +func stopClient() { + fmt.Println("Shutting down client") +} diff --git a/config.go b/config.go index cfb8ddd..923af76 100644 --- a/config.go +++ b/config.go @@ -1,14 +1,25 @@ package main import ( + "errors" "fmt" + "log" "os" + "path/filepath" "strings" + "sync" "git.nevets.tech/Steven/ezconf" + "gopkg.in/ini.v1" ) -var domainConfCache map[string]*ezconf.Configuration +var domainConfigs map[string]*ezconf.Configuration +var mu sync.RWMutex + +var ( + BlankConfigEntry = errors.New("blank config entry") + ConfigNotFound = errors.New("config file not found") +) func makeDirs() { err := os.MkdirAll("/etc/certman", 0644) @@ -37,14 +48,18 @@ func makeDirs() { } func createNewDomainConfig(domain string) { - data := []byte(strings.ReplaceAll(defaultDomainConfig, "{domain}", domain)) - createFile("/etc/certman/domains/"+domain+".conf", 0755, data) + key, err := GenerateKey() + if err != nil { + log.Fatalf("Unable to generate key: %v\n", err) + } + data := []byte(strings.ReplaceAll(strings.ReplaceAll(defaultDomainConfig, "{domain}", domain), "{key}", key)) + createFile("/etc/certman/domains/"+domain+".conf", 0640, data) } func createNewDomainCertsDir(domain string, dir string) { var err error if dir == "/opt/certs/example.com" { - err = os.Mkdir("/var/local/certman/"+domain, 0640) + err = os.MkdirAll("/var/local/certman/certificates/"+domain, 0640) } else { if strings.HasSuffix(dir, "/") { err = os.MkdirAll(dir+domain, 0640) @@ -56,27 +71,155 @@ func createNewDomainCertsDir(domain string, dir string) { if os.IsExist(err) { fmt.Println("Directory already exists...") } else { - fmt.Printf("Error creating certificate directory for %s: %v\n", domain, err) - os.Exit(1) + log.Fatalf("Error creating certificate directory for %s: %v\n", domain, err) } } } -func getDomainConfig(domain string) *ezconf.Configuration { - if domainConfCache == nil { - domainConfCache = make(map[string]*ezconf.Configuration) - domainConf := ezconf.LoadConfiguration("/etc/certman/domains/" + domain + ".conf") - domainConfCache[domain] = domainConf - return domainConf +func loadDomainConfigs() error { + tempDomainConfigs := make(map[string]*ezconf.Configuration) + entries, err := os.ReadDir("/etc/certman/domains/") + if err != nil { + return err } - if domainConfCache[domain] == nil { - domainConf := ezconf.LoadConfiguration("/etc/certman/domains/" + domain + ".conf") - domainConfCache[domain] = domainConf - return domainConf + + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".conf" { + continue + } + + domainConf, err := ezconf.LoadConfiguration("/etc/certman/domains/" + entry.Name()) + if err != nil { + return err + } + domain, err := domainConf.GetAsStringErr("Domain.domain_name") + if err != nil { + return err + } + + if _, exists := tempDomainConfigs[domain]; exists { + fmt.Printf("Duplicate domain found in %s, skipping...\n", "/etc/certman/domains/"+entry.Name()) + continue + } + tempDomainConfigs[domain] = domainConf } - return domainConfCache[domain] + mu.Lock() + domainConfigs = tempDomainConfigs + mu.Unlock() + return nil } -func clearDomainConfCache() { - domainConfCache = nil +func saveDomainConfigs() { + mu.RLock() + localDomainConfigs := make(map[string]*ezconf.Configuration, len(domainConfigs)) + for k, v := range domainConfigs { + localDomainConfigs[k] = v + } + mu.RUnlock() + + for domainStr, domainConfig := range localDomainConfigs { + err := domainConfig.Save() + if err != nil { + fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) + continue + } + } } + +func getDomainConfig(domain string) (*ezconf.Configuration, bool) { + mu.RLock() + defer mu.RUnlock() + funcDomainConfig, exists := domainConfigs[domain] + return funcDomainConfig, exists +} + +func getEffectiveKey(domainConfig *ezconf.Configuration, path string) *ini.Key { + key, err := getEffectiveKeyErr(domainConfig, path) + if err != nil { + if errors.Is(err, BlankConfigEntry) { + return &ini.Key{} + } + fmt.Printf("Error getting value for %s: %v\n", path, err) + return nil + } + return key +} + +func getEffectiveKeyErr(domainConfig *ezconf.Configuration, path string) (*ini.Key, error) { + if domainConfig != nil { + if key, err := domainConfig.GetKey(path); err == nil && key != nil { + if strings.TrimSpace(key.String()) != "" { + return key, nil + } + } + } + + key, err := config.GetKey(path) + if err != nil { + fmt.Printf("Error getting key for %s: %v\n", path, err) + return nil, err + } + if strings.TrimSpace(key.String()) == "" { + return nil, BlankConfigEntry + } + + return key, nil +} + +func getEffectiveString(domainCfg *ezconf.Configuration, path string) (string, error) { + k, err := getEffectiveKeyErr(domainCfg, path) + if err != nil { + return "", err + } + return strings.TrimSpace(k.String()), nil +} + +const defaultConfig = `[App] +mode = {mode} + +[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 +; default (use system dns) or IPv4 Address (1.1.1.1) +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 + + +; Don't change setting below here unless you know what you're doing! +[Internal] +last_issued = 0 +repo_exists = false +status = clean +` diff --git a/crypto.go b/crypto.go index 128262d..120c302 100644 --- a/crypto.go +++ b/crypto.go @@ -12,55 +12,6 @@ import ( "golang.org/x/crypto/chacha20poly1305" ) -//var cert *x509.Certificate -//var key *rsa.PrivateKey -// -//func encryptBytes(data []byte) []byte { -// if cert == nil || key == nil { -// loadCerts() -// } -// -// encrypted, err := rsa.EncryptPKCS1v15(rand.Reader, cert.PublicKey.(*rsa.PublicKey), data) -// if err != nil { -// fmt.Println("Error encrypting data,", err) -// os.Exit(1) -// } -// return encrypted -//} -// -//func decryptBytes(data []byte) []byte { -// if cert == nil || key == nil { -// loadCerts() -// } -// -// decrypted, err := rsa.DecryptPKCS1v15(rand.Reader, key, data) -// if err != nil { -// fmt.Println("Error decrypting data,", err) -// os.Exit(1) -// } -// return decrypted -//} -// -//func loadCerts() { -// var err error -// certBytes, err := os.ReadFile(config.GetAsString("Crypto.cert_path")) -// keyBytes, err := os.ReadFile(config.GetAsString("Crypto.key_path")) -// if err != nil { -// fmt.Println("Error reading cert or key,", err) -// os.Exit(1) -// } -// -// cert, err = x509.ParseCertificate(certBytes) -// if err != nil { -// fmt.Println("Error parsing certificate,", err) -// os.Exit(1) -// } -// key, err = x509.ParsePKCS1PrivateKey(keyBytes) -// if err != nil { -// fmt.Println("Error parsing private key,", err) -// } -//} - // GenerateKey returns a base64-encoded 32-byte random key suitable to use as the // symmetric passphrase for age scrypt mode. Store this securely (never in Git). func GenerateKey() (string, error) { @@ -150,3 +101,32 @@ func DecryptFileXChaCha(keyB64, inPath, outPath string, aad []byte) error { } return nil } + +func DecryptFileFromBytes(keyB64 string, inBytes []byte, outPath string, aad []byte) error { + key, err := decodeKey(keyB64) + if err != nil { + return err + } + + aead, err := chacha20poly1305.NewX(key) + if err != nil { + return fmt.Errorf("new aead: %w", err) + } + + if len(inBytes) < chacha20poly1305.NonceSizeX { + return errors.New("ciphertext too short") + } + + nonce := inBytes[:chacha20poly1305.NonceSizeX] + ciphertext := inBytes[chacha20poly1305.NonceSizeX:] + + plaintext, err := aead.Open(nil, nonce, ciphertext, aad) + if err != nil { + return fmt.Errorf("decrypt/auth failed: %w", err) + } + + if err := os.WriteFile(outPath, plaintext, 0640); err != nil { + return fmt.Errorf("write output: %w", err) + } + return nil +} diff --git a/git.go b/git.go index 552631d..7d7b080 100644 --- a/git.go +++ b/git.go @@ -2,17 +2,30 @@ package main import ( "context" + "errors" "fmt" "os" + "path/filepath" "strings" "time" "code.gitea.io/sdk/gitea" + "github.com/go-git/go-billy/v5" "github.com/go-git/go-git/v5" + gitconf "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/storage/memory" "github.com/google/go-github/v55/github" ) +type GitWorkspace struct { + Repo *git.Repository + Storage *memory.Storage + FS billy.Filesystem + WorkTree *git.Worktree +} + func createGithubClient() *github.Client { return github.NewClient(nil).WithAuthToken(config.GetAsString("Git.api_token")) } @@ -21,7 +34,7 @@ func createGiteaClient() *gitea.Client { client, err := gitea.NewClient(config.GetAsString("Git.server"), gitea.SetToken(config.GetAsString("Git.api_token"))) if err != nil { fmt.Printf("Error connecting to gitea instance: %v\n", err) - os.Exit(1) + return nil } return client } @@ -44,98 +57,205 @@ func createGithubRepo(domain *Domain, client *github.Client) string { repo, _, err := client.Repositories.CreateFromTemplate(ctx, config.GetAsString("Git.org_name"), config.GetAsString("Git.template_name"), template) if err != nil { fmt.Println("Error creating repository from template,", err) - os.Exit(1) + return "" } return *repo.CloneURL } -func createGiteaRepo() string { - domainConfig := getDomainConfig(domain) - options := gitea.CreateRepoFromTemplateOption{ - Avatar: true, - Description: "Certificates storage for " + domain, - GitContent: true, - GitHooks: true, - Labels: true, - Name: domain + domainConfig.GetAsString("Repo.repo_suffix"), - Owner: config.GetAsString("Git.org_name"), - Private: true, - Topics: true, - Webhooks: true, +func createGiteaRepo(domain string, giteaClient *gitea.Client) string { + domainConfig, exists := getDomainConfig(domain) + if !exists { + fmt.Printf("Domain %s config does not exist\n", domain) + return "" } - giteaRepo, _, err := giteaClient.CreateRepoFromTemplate(config.GetAsString("Git.org_name"), config.GetAsString("Git.template_name"), options) + //options := gitea.CreateRepoFromTemplateOption{ + // Avatar: true, + // Description: "Certificates storage for " + domain, + // GitContent: true, + // GitHooks: true, + // Labels: true, + // Name: domain + domainConfig.GetAsString("Repo.repo_suffix"), + // Owner: config.GetAsString("Git.org_name"), + // Private: true, + // Topics: true, + // Webhooks: true, + //} + options := gitea.CreateRepoOption{ + Name: domain + domainConfig.GetAsString("Repo.repo_suffix"), + Description: "Certificate storage for " + domain, + Private: true, + IssueLabels: "", + AutoInit: false, + Template: false, + Gitignores: "", + License: "", + Readme: "", + DefaultBranch: "master", + TrustModel: gitea.TrustModelDefault, + } + + giteaRepo, _, err := giteaClient.CreateOrgRepo(config.GetAsString("Git.org_name"), options) + //giteaRepo, _, err := giteaClient.CreateRepoFromTemplate(config.GetAsString("Git.org_name"), config.GetAsString("Git.template_name"), options) if err != nil { fmt.Printf("Error creating repo: %v\n", err) - os.Exit(1) + return "" } return giteaRepo.CloneURL } -func cloneRepo(url string) (*git.Repository, *git.Worktree) { - repository, err := git.Clone(storage, fs, &git.CloneOptions{URL: url, Auth: creds}) +func initRepo(url string, ws *GitWorkspace) error { + var err error + ws.Repo, err = git.Init(ws.Storage, ws.FS) if err != nil { - fmt.Printf("Error clone git repo: %v\n", err) - os.Exit(1) + fmt.Printf("Error initializing local repo: %v\n", err) + return err } - workingTree, err := repo.Worktree() - if err != nil { - fmt.Printf("Error getting worktree from repo: %v\n", err) - os.Exit(1) + _, err = ws.Repo.CreateRemote(&gitconf.RemoteConfig{ + Name: "origin", + URLs: []string{url}, + }) + if err != nil && !errors.Is(err, git.ErrRemoteExists) { + fmt.Printf("Error creating remote origin repo: %v\n", err) + return err } - return repository, workingTree + + ws.WorkTree, err = ws.Repo.Worktree() + if err != nil { + fmt.Printf("Error getting worktree from local repo: %v\n", err) + return err + } + + return nil } -func addAndPushCerts() { - certFiles, err := os.ReadDir(config.GetAsString("Certificates.certs_path") + "/certificates") +func cloneRepo(url string, ws *GitWorkspace) error { + creds := &http.BasicAuth{ + Username: config.GetAsString("Git.username"), + Password: config.GetAsString("Git.api_token"), + } + var err error + ws.Repo, err = git.Clone(ws.Storage, ws.FS, &git.CloneOptions{URL: url, Auth: creds}) + if err != nil { + fmt.Printf("Error cloning repo: %v\n", err) + } + + ws.WorkTree, err = ws.Repo.Worktree() + if err != nil { + fmt.Printf("Error getting worktree from cloned repo: %v\n", err) + return err + } + return nil +} + +func addAndPushCerts(domain string, ws *GitWorkspace) error { + domainConfig, exists := getDomainConfig(domain) + if !exists { + fmt.Printf("Domain %s config does not exist\n", domain) + return ConfigNotFound + } + + effectiveDataRoot, err := getEffectiveString(domainConfig, "Certificates.data_root") + if err != nil { + fmt.Printf("Error getting effective data root for domain %s: %v\n", domain, err) + return err + } + + certFiles, err := os.ReadDir(filepath.Join(effectiveDataRoot, "certificates", domain)) if err != nil { fmt.Printf("Error reading from directory: %v\n", err) - os.Exit(1) + return err } - for _, file := range certFiles { - if strings.HasPrefix(file.Name(), domain) { - file, err := fs.Create(file.Name()) + for _, entry := range certFiles { + if strings.HasSuffix(entry.Name(), ".crpt") { + file, err := ws.FS.Create(entry.Name()) if err != nil { fmt.Printf("Error copying file to memfs: %v\n", err) - os.Exit(1) + return err + } + certFile, err := os.ReadFile(filepath.Join(effectiveDataRoot, "certificates", domain, file.Name())) + if err != nil { + fmt.Printf("Error reading file to memfs: %v\n", err) + file.Close() + return err } - certFile, err := os.ReadFile(config.GetAsString("Certificates.certs_path") + "/certificates/" + file.Name()) - //certFile = encryptBytes(certFile) _, err = file.Write(certFile) - err = file.Close() if err != nil { fmt.Printf("Error writing to memfs: %v\n", err) - os.Exit(1) + file.Close() + return err } - _, err = workTree.Add(file.Name()) + _, err = ws.WorkTree.Add(file.Name()) if err != nil { - fmt.Printf("Error adding file %v: %v", file.Name(), err) - os.Exit(1) + fmt.Printf("Error adding file %v: %v\n", file.Name(), err) + file.Close() + return err + } + err = file.Close() + if err != nil { + fmt.Printf("Error closing file: %v\n", err) } } } - status, err := workTree.Status() + status, err := ws.WorkTree.Status() if err != nil { fmt.Printf("Error getting repo status: %v\n", err) - os.Exit(1) + return err } + if status.IsClean() { + fmt.Printf("Repository is clean, skipping commit...\n") + return nil + } + fmt.Println("Work Tree Status:\n" + status.String()) signature := &object.Signature{ Name: "Cert Manager", - Email: config.GetAsString("Git.email"), + Email: config.GetAsString("Certificates.email"), When: time.Now(), } - _, err = workTree.Commit("Update "+domain+" @ "+time.Now().Format("Mon Jan _2 2006 15:04:05 MST"), &git.CommitOptions{Author: signature, Committer: signature}) + _, err = ws.WorkTree.Commit("Update "+domain+" @ "+time.Now().Format("Mon Jan _2 2006 15:04:05 MST"), &git.CommitOptions{Author: signature, Committer: signature}) if err != nil { fmt.Printf("Error committing certs: %v\n", err) - os.Exit(1) + return err } - err = repo.Push(&git.PushOptions{Auth: creds, Force: true, RemoteName: "origin"}) + creds := &http.BasicAuth{ + Username: config.GetAsString("Git.username"), + Password: config.GetAsString("Git.api_token"), + } + err = ws.Repo.Push(&git.PushOptions{ + Auth: creds, + Force: true, + RemoteName: "origin", + RefSpecs: []gitconf.RefSpec{ + "refs/heads/master:refs/heads/master", + }, + }) if err != nil { fmt.Printf("Error pushing to origin: %v\n", err) - os.Exit(1) + return err } - fmt.Println("Successfully uploaded to " + config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domain + "-certificates.git") + fmt.Println("Successfully uploaded to " + config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domain + domainConfig.GetAsString("Repo.repo_suffix") + ".git") + return nil +} + +func getLocalCommitHash(domain string) (string, error) { + + return "", nil +} + +func writeCommitHash(domain string, ws *GitWorkspace) error { + //ref, err := ws.Repo.Head() + //if err != nil { + // fmt.Printf("Error getting HEAD: %v\n", err) + // return err + //} + //hash := ref.Hash() + return nil +} + +func getRemoteCommitHash(domain string) (string, error) { + + return "", nil } diff --git a/go.mod b/go.mod index 9c98254..a01c641 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.24.7 require ( code.gitea.io/sdk/gitea v0.15.1 filippo.io/age v1.2.1 - git.nevets.tech/Steven/ezconf v0.1.1 + git.nevets.tech/Steven/ezconf v0.1.4 github.com/go-acme/lego/v4 v4.26.0 github.com/go-git/go-billy/v5 v5.4.1 github.com/go-git/go-git/v5 v5.7.0 diff --git a/go.sum b/go.sum index b72df66..5b9f5d0 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,12 @@ filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= git.nevets.tech/Steven/ezconf v0.1.1 h1:dEqV9Q0zVKX9UkPg5UTchGLd0J0WhiuV4dVg0o3blnY= git.nevets.tech/Steven/ezconf v0.1.1/go.mod h1:O8svyJLWVPYdxPeZeiTkfmwz77BM0Wq2ZhDrHtdRhvI= +git.nevets.tech/Steven/ezconf v0.1.2 h1:KD47Av0swRPHKLxmDtJwahZd+x0k902CgNqBVQcxf2w= +git.nevets.tech/Steven/ezconf v0.1.2/go.mod h1:O8svyJLWVPYdxPeZeiTkfmwz77BM0Wq2ZhDrHtdRhvI= +git.nevets.tech/Steven/ezconf v0.1.3 h1:l9yG5SwYx/Jg4HzkikOsJ5FTPS9BTLGDBxTPgVOovLI= +git.nevets.tech/Steven/ezconf v0.1.3/go.mod h1:O8svyJLWVPYdxPeZeiTkfmwz77BM0Wq2ZhDrHtdRhvI= +git.nevets.tech/Steven/ezconf v0.1.4 h1:W9AHcnWQfmkc1PAlrRj54u3zPq1BXeX3u37X/+Y746g= +git.nevets.tech/Steven/ezconf v0.1.4/go.mod h1:O8svyJLWVPYdxPeZeiTkfmwz77BM0Wq2ZhDrHtdRhvI= github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 h1:ZK3C5DtzV2nVAQTx5S5jQvMeDqWtD1By5mOoyY/xJek= diff --git a/main.go b/main.go index 3d5a3a0..edf28d2 100644 --- a/main.go +++ b/main.go @@ -1,42 +1,25 @@ package main import ( - "bufio" "context" + "errors" "flag" "fmt" + "log" "os" - "os/exec" "os/signal" - "strconv" "strings" "sync" "syscall" "time" - "code.gitea.io/sdk/gitea" "git.nevets.tech/Steven/ezconf" - "github.com/go-git/go-billy/v5" - "github.com/go-git/go-billy/v5/memfs" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/go-git/go-git/v5/storage/memory" - "github.com/google/go-github/v55/github" - "github.com/makifdb/pidfile" ) +var version = "1.1.0-beta" +var build = "1" + var config *ezconf.Configuration -var githubClient *github.Client -var giteaClient *gitea.Client -var domain string -var legoBaseArgs []string - -var storage *memory.Storage -var fs billy.Filesystem -var workTree *git.Worktree -var creds *http.BasicAuth - -var repo *git.Repository var ctx context.Context var cancel context.CancelFunc @@ -48,10 +31,14 @@ func main() { devFlag := flag.Bool("dev", false, "Developer Mode") + versionFlag := flag.Bool("version", false, "Show version") + helpFlag := flag.Bool("help", false, "Show help") + configFile := flag.String("config", "/etc/certman/certman.conf", "Configuration file") newDomainFlag := flag.String("new-domain", "example.com", "Domain to create new configs and directories for") newDomainDirFlag := flag.String("new-domain-dir", "/opt/certs/example.com", "Directory that certs will be stored in") + localOnlyFlag := flag.Bool("local-only", false, "Local only") installFlag := flag.Bool("install", false, "Install Certman") modeFlag := flag.String("mode", "client", "CertManager Mode [server, client]") @@ -60,82 +47,143 @@ func main() { newKeyFlag := flag.Bool("newkey", false, "Generate new encryption key") reloadFlag := flag.Bool("reload", false, "Reload configs") + stopFlag := flag.Bool("stop", false, "Stop certman") daemonFlag := flag.Bool("d", false, "Daemon Mode") flag.Parse() if *devFlag { + testDomain := "lunamc.org" + var err error + config, err = ezconf.LoadConfiguration("/etc/certman/certman.conf") + if err != nil { + log.Fatalf("Error loading configuration: %v\n", err) + } + err = loadDomainConfigs() + if err != nil { + log.Fatalf("Error loading configs: %v\n", err) + } + + fmt.Println(testDomain) + os.Exit(0) } + + if *versionFlag { + fmt.Println("CertManager (certman) - Steven Tracey\nVersion: " + version + " build-" + build) + os.Exit(0) + } + + if *helpFlag { + fmt.Printf(`CertManager (certman) - Steven Tracey +Version: %s build-%s + +Subcommands: certman -subcommand + - version Shows the current version and build + - help Displays this help message + - newkey Creates a new random 256 bit base64 key + +Daemon Controls: certman -command + - d Start in daemon mode + - reload Reload configs + - stop Stop Daemon + +Installation: certman -install -mode (mode) [-t] [-config /path/to/file] + - install + - mode [mode] Uses the specified config file [server, client] + - t Thin install (skip creating directories) + - config /path/to/file Create config file at the specified path + +New Domain Options: certman -new-domain example.com [-new-domain-dir /path/to/certs] + - new-domain Creates a new domain config + - new-domain-dir Specifies directory for new domain certificates to be stored + - local-only Don't create git repo + +`, version, build) + + os.Exit(0) + } + if *newDomainFlag != "example.com" { fmt.Printf("Creating new domain %s\n", *newDomainFlag) createNewDomainConfig(*newDomainFlag) createNewDomainCertsDir(*newDomainFlag, *newDomainDirFlag) + if !*localOnlyFlag { + //TODO create git repo + } fmt.Println("Successfully created domain entry for " + *newDomainFlag + "\nUpdate config file as needed in /etc/certman/domains/" + *newDomainFlag + ".conf") os.Exit(0) } + if *installFlag { if !*thinInstallFlag { makeDirs() } - config = ezconf.NewConfiguration(*configFile, strings.ReplaceAll(defaultConfig, "{mode}", *modeFlag)) + var err error + config, err = ezconf.NewConfiguration(*configFile, strings.ReplaceAll(defaultConfig, "{mode}", *modeFlag)) + if err != nil { + log.Fatalf("Error creating config: %s\n", err) + } os.Exit(0) } if *newKeyFlag { key, err := GenerateKey() if err != nil { - fmt.Println(err) - os.Exit(1) + log.Fatalf("%v", err) } fmt.Printf(key) os.Exit(0) } if *reloadFlag { - pidBytes, err := os.ReadFile("/var/run/certman.pid") + proc, err := getDaemonProcess() if err != nil { - fmt.Printf("Error getting PID from /var/run/certman.pid: %v\n", err) - os.Exit(1) - } - pidStr := strings.TrimSpace(string(pidBytes)) - daemonPid, err := strconv.Atoi(pidStr) - if err != nil { - fmt.Printf("Error converting PID string to int (%s): %v\n", pidStr, err) - os.Exit(1) - } - proc, err := os.FindProcess(daemonPid) - if err != nil { - fmt.Printf("Error finding process with PID %d: %v\n", daemonPid, err) - os.Exit(1) + log.Fatalf("Error getting daemon process: %v", err) } err = proc.Signal(syscall.SIGHUP) if err != nil { - fmt.Printf("Error sending SIGHUP to PID %d: %v\n", daemonPid, err) - os.Exit(1) + log.Fatalf("Error sending SIGHUP to daemon PID: %v\n", err) + } + os.Exit(0) + } + + if *stopFlag { + proc, err := getDaemonProcess() + if err != nil { + log.Fatalf("Error getting daemon process: %v", err) + } + + err = proc.Signal(syscall.SIGTERM) + if err != nil { + log.Fatalf("Error sending SIGTERM to daemon PID: %v\n", err) } os.Exit(0) } if *daemonFlag { - err := pidfile.CreateOrUpdatePIDFile("/var/run/certman.pid") + err := createOrUpdatePIDFile("/var/run/certman.pid") if err != nil { - fmt.Println("Error creating pidfile") - os.Exit(1) + if errors.Is(err, ErrorPIDInUse) { + log.Fatalf("Deemon process is already running\n") + } + log.Fatalf("Error creating pidfile: %v\n", err) } ctx, cancel = context.WithCancel(context.Background()) // Check if main config exists if _, err := os.Stat(*configFile); os.IsNotExist(err) { - fmt.Println("Main config file not found, please run 'certman --install', then properly configure /etc/certman/certman.conf.") - os.Exit(1) + log.Fatalf("Main config file not found, please run 'certman --install', then properly configure /etc/certman/certman.conf.") } else if err != nil { fmt.Printf("Error opening %s: %v\n", *configFile, err) } - config = ezconf.LoadConfiguration(*configFile) + config, err = ezconf.LoadConfiguration(*configFile) + if err != nil { + log.Fatalf("Error loading configuration: %v\n", err) + } // Setup SIGINT and SIGTERM listeners sigChannel := make(chan os.Signal, 1) @@ -146,7 +194,7 @@ func main() { signal.Notify(reloadSigChan, syscall.SIGHUP) defer signal.Stop(reloadSigChan) - ticker := time.NewTicker(5 * time.Second) + ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() wg.Add(1) @@ -154,20 +202,17 @@ func main() { fmt.Println("Starting CertManager in server mode...") // Server Task loop go func() { + initServer() defer wg.Done() for { select { case <-ctx.Done(): - fmt.Println("Shutting down server") + stopServer() return case <-reloadSigChan: - { - fmt.Println("Reloading configs...") - } + reloadServer() case <-ticker.C: - { - fmt.Println("Tick!") - } + serverTick() } } }() @@ -175,20 +220,17 @@ func main() { fmt.Println("Starting CertManager in client mode...") // Client Task loop go func() { + initClient() defer wg.Done() for { select { case <-ctx.Done(): - fmt.Println("Shutting down client") + stopClient() return case <-reloadSigChan: - { - fmt.Println("Reloading configs...") - } + reloadClient() case <-ticker.C: - { - fmt.Println("Tick!") - } + clientTick() } } }() @@ -207,119 +249,143 @@ func main() { func stop() { cancel() + clearPIDFile() } -func maindis() { - config = ezconf.NewConfiguration("/etc/certman/certman.conf", "") - - var err error - args := os.Args - - // -d - hasDomain, domainIndex := contains(args, "-d") - if hasDomain { - domain = args[domainIndex+1] - } else { - fmt.Printf("Error, no domain passed. Please add '-d domain.tld' to the command\n") - os.Exit(1) - } - - hasDns, dnsIndex := contains(args, "--dns") - - legoBaseArgs = []string{ - "-a", - "--dns", - "cloudflare", - "--email=" + config.GetAsString("Cloudflare.cf_email"), - "--domains=" + domain, - "--domains=*." + domain, - "--path=" + config.GetAsString("Certificates.certs_path"), - } - legoNewSiteArgs := append(legoBaseArgs, "run") - legoRenewSiteArgs := append(legoBaseArgs, "renew", "--days", "90") - - subdomains := config.GetAsStrings("Certificates.subdomains") - if subdomains != nil { - for i, subdomain := range subdomains { - legoBaseArgs = insert(legoBaseArgs, 5+i, "--domains=*."+subdomain+"."+domain) - } - } - - if hasDns { - legoBaseArgs = insert(legoBaseArgs, 3, "--dns.resolvers="+args[dnsIndex+1]) - } - - creds = &http.BasicAuth{ - Username: config.GetAsString("Git.username"), - Password: config.GetAsString("Git.api_token"), - } - giteaClient = createGiteaClient() - - storage = memory.NewStorage() - fs = memfs.New() - - var cmd *exec.Cmd - switch args[len(args)-1] { - case "gen": - { - url := createGiteaRepo() - repo, workTree = cloneRepo(url) - cmd = exec.Command("lego", legoNewSiteArgs...) - } - case "renew": - { - repo, workTree = cloneRepo(config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domain + "-certificates.git") - cmd = exec.Command("lego", legoRenewSiteArgs...) - } - case "gen-cert-only": - { - cmd = exec.Command("lego", legoNewSiteArgs...) - } - case "renew-cert-only": - { - cmd = exec.Command("lego", legoRenewSiteArgs...) - } - case "git": - { - url := createGiteaRepo() - repo, workTree = cloneRepo(url) - addAndPushCerts() - os.Exit(0) - } - default: - { - fmt.Println("Missing arguments: conclude command with 'gen' or 'renew'") - os.Exit(1) - } - } - cmd.Env = append(cmd.Environ(), - "CLOUDFLARE_DNS_API_TOKEN="+config.GetAsString("Cloudflare.cf_api_token"), - "CLOUDFLARE_ZONE_API_TOKEN"+config.GetAsString("Cloudflare.cf_api_token"), - "CLOUDFLARE_EMAIL="+config.GetAsString("Cloudflare.cf_email"), - ) - stdout, err := cmd.StdoutPipe() - if err != nil { - fmt.Printf("Error getting stdout from lego process: %v\n", err) - os.Exit(1) - } - err = cmd.Start() - if err != nil { - fmt.Printf("Error creating certs with lego: %v\n", err) - os.Exit(1) - } - scanner := bufio.NewScanner(stdout) - go func() { - for scanner.Scan() { - fmt.Println(scanner.Text()) - } - if err := scanner.Err(); err != nil { - fmt.Fprintln(os.Stderr, "reading standard input:", err) - } - }() - err = cmd.Wait() - if err != nil { - fmt.Printf("Error waiting for lego command to finish: %v\n", err) - os.Exit(1) - } - addAndPushCerts() -} +//var legoBaseArgs []string +// +//func maindis() { +// config, err := ezconf.NewConfiguration("/etc/certman/certman.conf", "") +// var domain string +// if err != nil { +// log.Fatalf("Error loading configuration: %v\n", err) +// } +// +// args := os.Args +// +// // -d +// hasDomain, domainIndex := contains(args, "-d") +// if hasDomain { +// domain = args[domainIndex+1] +// } else { +// log.Fatalf("Error, no domain passed. Please add '-d domain.tld' to the command\n") +// } +// +// hasDns, dnsIndex := contains(args, "--dns") +// +// legoBaseArgs = []string{ +// "-a", +// "--dns", +// "cloudflare", +// "--email=" + config.GetAsString("Cloudflare.cf_email"), +// "--domains=" + domain, +// "--domains=*." + domain, +// "--path=" + config.GetAsString("Certificates.certs_path"), +// } +// legoNewSiteArgs := append(legoBaseArgs, "run") +// legoRenewSiteArgs := append(legoBaseArgs, "renew", "--days", "90") +// +// subdomains := config.GetAsStrings("Certificates.subdomains") +// if subdomains != nil { +// for i, subdomain := range subdomains { +// legoBaseArgs = insert(legoBaseArgs, 5+i, "--domains=*."+subdomain+"."+domain) +// } +// } +// +// if hasDns { +// legoBaseArgs = insert(legoBaseArgs, 3, "--dns.resolvers="+args[dnsIndex+1]) +// } +// +// giteaClient = createGiteaClient() +// gitWorkspace := &GitWorkspace{ +// Storage: memory.NewStorage(), +// FS: memfs.New(), +// } +// +// var cmd *exec.Cmd +// switch args[len(args)-1] { +// case "gen": +// { +// url := createGiteaRepo(domain) +// if url == "" { +// return +// } +// gitWorkspace.Repo, gitWorkspace.WorkTree = cloneRepo(url, gitWorkspace) +// if gitWorkspace.Repo == nil { +// return +// } +// cmd = exec.Command("lego", legoNewSiteArgs...) +// } +// case "renew": +// { +// gitWorkspace.Repo, gitWorkspace.WorkTree = cloneRepo(config.GetAsString("Git.server")+"/"+config.GetAsString("Git.org_name")+"/"+domain+"-certificates.git", gitWorkspace) +// if gitWorkspace.Repo == nil { +// return +// } +// cmd = exec.Command("lego", legoRenewSiteArgs...) +// } +// case "gen-cert-only": +// { +// cmd = exec.Command("lego", legoNewSiteArgs...) +// } +// case "renew-cert-only": +// { +// cmd = exec.Command("lego", legoRenewSiteArgs...) +// } +// case "git": +// { +// url := createGiteaRepo(domain) +// if url == "" { +// return +// } +// gitWorkspace.Repo, gitWorkspace.WorkTree = cloneRepo(url, gitWorkspace) +// if gitWorkspace.Repo == nil { +// return +// } +// err := addAndPushCerts(domain, gitWorkspace) +// if err != nil { +// return +// } +// os.Exit(0) +// } +// default: +// { +// fmt.Println("Missing arguments: conclude command with 'gen' or 'renew'") +// os.Exit(1) +// } +// } +// cmd.Env = append(cmd.Environ(), +// "CLOUDFLARE_DNS_API_TOKEN="+config.GetAsString("Cloudflare.cf_api_token"), +// "CLOUDFLARE_ZONE_API_TOKEN"+config.GetAsString("Cloudflare.cf_api_token"), +// "CLOUDFLARE_EMAIL="+config.GetAsString("Cloudflare.cf_email"), +// ) +// stdout, err := cmd.StdoutPipe() +// if err != nil { +// fmt.Printf("Error getting stdout from lego process: %v\n", err) +// os.Exit(1) +// } +// err = cmd.Start() +// if err != nil { +// fmt.Printf("Error creating certs with lego: %v\n", err) +// os.Exit(1) +// } +// scanner := bufio.NewScanner(stdout) +// go func() { +// for scanner.Scan() { +// fmt.Println(scanner.Text()) +// } +// if err := scanner.Err(); err != nil { +// fmt.Fprintln(os.Stderr, "reading standard input:", err) +// } +// }() +// err = cmd.Wait() +// if err != nil { +// fmt.Printf("Error waiting for lego command to finish: %v\n", err) +// os.Exit(1) +// } +// err = addAndPushCerts(domain, gitWorkspace) +// if err != nil { +// fmt.Printf("Error adding and pushing certs: %v\n", err) +// return +// } +//} diff --git a/server.go b/server.go new file mode 100644 index 0000000..5ffd411 --- /dev/null +++ b/server.go @@ -0,0 +1,173 @@ +package main + +import ( + "fmt" + "log" + "path/filepath" + "strconv" + "sync" + "time" + + "git.nevets.tech/Steven/ezconf" + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-git/v5/storage/memory" +) + +var ( + tickMu sync.Mutex + mgr *ACMEManager + mgrMu sync.Mutex +) + +func getACMEManager() (*ACMEManager, error) { + mgrMu.Lock() + defer mgrMu.Unlock() + + if mgr == nil { + var err error + mgr, err = NewACMEManager() + if err != nil { + return nil, err + } + } + return mgr, nil +} + +func initServer() { + err := loadDomainConfigs() + if err != nil { + log.Fatalf("Error loading domain configs: %v", err) + } +} + +func serverTick() { + tickMu.Lock() + defer tickMu.Unlock() + fmt.Println("Tick!") + + mgr, err := getACMEManager() + if err != nil { + fmt.Printf("Error getting acme manager: %v\n", err) + return + } + + now := time.Now().UTC() + + mu.RLock() + localDomainConfigs := make(map[string]*ezconf.Configuration, len(domainConfigs)) + for k, v := range domainConfigs { + localDomainConfigs[k] = v + } + mu.RUnlock() + for domainStr, domainConfig := range localDomainConfigs { + if !domainConfig.GetAsBoolean("Domain.enabled") { + continue + } + renewPeriod := domainConfig.GetAsInt("Certificates.renew_period") + lastIssued := time.Unix(domainConfig.GetAsInt64("Internal.last_issued"), 0).UTC() + renewalDue := lastIssued.AddDate(0, 0, renewPeriod) + if now.After(renewalDue) { + _, err = mgr.RenewForDomain(domainStr) + if err != nil { + // if no existing cert, obtain instead + _, err = mgr.ObtainForDomain(domainStr) + if err != nil { + fmt.Printf("Error obtaining domain certificates for domain %s: %v\n", domainStr, err) + continue + } + } + + err = domainConfig.SetValueErr("Internal.last_issued", strconv.FormatInt(time.Now().UTC().Unix(), 10)) + if err != nil { + fmt.Printf("Error updating last_issued config for domain %s: %v\n", domainStr, err) + continue + } + err = domainConfig.Save() + if err != nil { + fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) + continue + } + + err = EncryptFileXChaCha(domainConfig.GetAsString("Certificates.crypto_key"), filepath.Join(mgr.certsRoot, domainStr, domainStr+".crt"), filepath.Join(mgr.certsRoot, domainStr, domainStr+".crt.crpt"), nil) + if err != nil { + fmt.Printf("Error encrypting domain cert for domain %s: %v\n", domainStr, err) + continue + } + err = EncryptFileXChaCha(domainConfig.GetAsString("Certificates.crypto_key"), filepath.Join(mgr.certsRoot, domainStr, domainStr+".key"), filepath.Join(mgr.certsRoot, domainStr, domainStr+".key.crpt"), nil) + if err != nil { + fmt.Printf("Error encrypting domain key for domain %s: %v\n", domainStr, err) + continue + } + + giteaClient := createGiteaClient() + if giteaClient == nil { + fmt.Printf("Error creating gitea client for domain %s: %v\n", domainStr, err) + continue + } + gitWorkspace := &GitWorkspace{ + Storage: memory.NewStorage(), + FS: memfs.New(), + } + + var repoUrl string + if !domainConfig.GetAsBoolean("Internal.repo_exists") { + repoUrl = createGiteaRepo(domainStr, giteaClient) + if repoUrl == "" { + fmt.Printf("Error creating Gitea repo for domain %s\n", domainStr) + continue + } + err = domainConfig.SetValueErr("Internal.repo_exists", "true") + if err != nil { + fmt.Printf("Error updating repo_exists config for domain %s: %v\n", domainStr, err) + continue + } + err = domainConfig.Save() + if err != nil { + fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) + continue + } + + err = initRepo(repoUrl, gitWorkspace) + if err != nil { + fmt.Printf("Error initializing repo for domain %s: %v\n", domainStr, err) + continue + } + } else { + repoUrl = config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domainStr + domainConfig.GetAsString("Repo.repo_suffix") + ".git" + err = cloneRepo(repoUrl, gitWorkspace) + if err != nil { + fmt.Printf("Error cloning repo for domain %s: %v\n", domainStr, err) + continue + } + } + + err = addAndPushCerts(domainStr, gitWorkspace) + if err != nil { + fmt.Printf("Error pushing certificates for domain %s: %v\n", domainStr, err) + continue + } + fmt.Printf("Successfully pushed certificates for domain %s\n", domainStr) + } + } + saveDomainConfigs() +} + +func reloadServer() { + fmt.Println("Reloading configs...") + err := loadDomainConfigs() + + if err != nil { + fmt.Printf("Error loading domain configs: %v\n", err) + return + } + + mgrMu.Lock() + mgr = nil + mgrMu.Unlock() + + fmt.Println("Successfully reloaded configs") +} + +func stopServer() { + fmt.Println("Shutting down server") +} diff --git a/util.go b/util.go index e42b8ce..1c18515 100644 --- a/util.go +++ b/util.go @@ -4,12 +4,20 @@ import ( "errors" "fmt" "os" + "strconv" + "strings" + "syscall" "code.gitea.io/sdk/gitea" "git.nevets.tech/Steven/ezconf" "github.com/google/go-github/v55/github" ) +var ( + ErrorPIDInUse = errors.New("daemon is already running") + ErrLockFailed = errors.New("failed to acquire a lock on the PID file") +) + type Domain struct { name *string config *ezconf.Configuration @@ -18,17 +26,144 @@ type Domain struct { gtClient *gitea.Client } -type GlobalConfig struct { - Git struct { - Host string - Endpoint string - Username string - Password string - ApiToken string +// 0x01 +func createPIDFile() { + file, err := os.Create("/var/run/certman.pid") + if err != nil { + fmt.Printf("0x01: Error creating PID file: %v\n", err) + return + } + err = file.Close() + if err != nil { + fmt.Printf("0x01: Error closing PID file: %v\n", err) + return } } -type DomainConfig struct { +// 0x02 +func clearPIDFile() { + file, err := os.OpenFile("/var/run/certman.pid", os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + fmt.Printf("0x02: Error opening PID file: %v\n", err) + return + } + defer file.Close() + + err = file.Truncate(0) + if err != nil { + fmt.Printf("0x02: Error writing PID file: %v\n", err) + return + } +} + +// 0x03 +func createOrUpdatePIDFile(filename string) error { + pidBytes, err := os.ReadFile(filename) + if err != nil { + fmt.Printf("0x03: Error reading PID file: %v\n", err) + return err + } + + pidStr := strings.TrimSpace(string(pidBytes)) + isPidFileEmpty := pidStr == "" + + if !isPidFileEmpty { + pid, err := strconv.Atoi(pidStr) + if err != nil { + fmt.Printf("0x03: Error parsing PID file: %v\n", err) + return err + } + + isProcActive, err := isProcessActive(pid) + if err != nil { + fmt.Printf("0x03: Error checking if process is active: %v\n", err) + return err + + } + if isProcActive { + return ErrorPIDInUse + } + } + + pidFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + if os.IsNotExist(err) { + createPIDFile() + } else { + fmt.Printf("0x03: Error opening PID file: %v\n", err) + return err + } + } + defer pidFile.Close() + + if err := syscall.Flock(int(pidFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + if errors.Is(err, syscall.EWOULDBLOCK) { + return ErrLockFailed + } + return fmt.Errorf("error locking PID file: %w", err) + } + curPid := os.Getpid() + if _, err := pidFile.Write([]byte(strconv.Itoa(curPid))); err != nil { + return fmt.Errorf("error writing pid to PID file: %w", err) + } + + return nil +} + +// 0x04 +// isProcessActive checks whether the process with the provided PID is running. +func isProcessActive(pid int) (bool, error) { + if pid <= 0 { + return false, errors.New("invalid process ID") + } + process, err := os.FindProcess(pid) + if err != nil { + // On Unix systems, os.FindProcess always succeeds and returns a process with the given pid, irrespective of whether the process exists. + return false, nil + } + + err = process.Signal(syscall.Signal(0)) + if err != nil { + if errors.Is(err, syscall.ESRCH) { + // The process does not exist + return false, nil + } else if errors.Is(err, os.ErrProcessDone) { + return false, nil + } + // Some other unexpected error + return false, err + } + + // The process exists and is active + return true, nil +} + +// 0x05 +func getDaemonProcess() (*os.Process, error) { + pidBytes, err := os.ReadFile("/var/run/certman.pid") + if err != nil { + fmt.Printf("0x05: Error getting PID from /var/run/certman.pid: %v\n", err) + return nil, err + } + pidStr := strings.TrimSpace(string(pidBytes)) + daemonPid, err := strconv.Atoi(pidStr) + if err != nil { + fmt.Printf("0x05: Error converting PID string to int (%s): %v\n", pidStr, err) + return nil, err + } + isProcActive, err := isProcessActive(daemonPid) + if err != nil { + fmt.Printf("0x05: Error checking if process is active: %v\n", err) + } + if !isProcActive { + return nil, errors.New("process is not active") + } + proc, err := os.FindProcess(daemonPid) + if err != nil { + fmt.Printf("0x05: Error finding process with PID %d: %v\n", daemonPid, err) + return nil, err + } + return proc, nil } func createFile(fileName string, filePermission os.FileMode, data []byte) { @@ -71,16 +206,18 @@ func createFile(fileName string, filePermission os.FileMode, data []byte) { } } -func fileExists(filePath string) bool { - if _, err := os.Stat(filePath); err == nil { - return true - } else if errors.Is(err, os.ErrNotExist) { - return false - } else { - fmt.Println("Error checking file existence: ", err) - os.Exit(1) - return false +func linkFile(source, target string) error { + err := os.Symlink(source, target) + if err != nil { + fmt.Println("Error creating symlink:", err) + return err } + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil } func contains(slice []string, value string) (sliceHas bool, index int) { @@ -100,44 +237,8 @@ func insert(a []string, index int, value string) []string { return a } -const defaultConfig = `[App] -mode = {mode} - -[Git] -host = gitea -server = https://gitea.instance.com -username = user -org_name = org -template_name = template - -[Certificates] -email = user@example.com -data_root = /var/local/certman -request_method = dns - -[Cloudflare] -cf_email = email@example.com - -` - -const defaultDomainConfig = `[Domain] -domain_name = {domain} -; default (use system dns) or IPv4 Address (1.1.1.1) -dns_server = default -; optionally use /path/to/directory -file_location = default - -[Certificates] -subdomains = -expiry = 90 -cert_symlink = -key_symlink = - -[Repo] -repo_suffix = -certificates - -; Don't change setting below here unless you know what you're doing! -[Internal] -last_issued = never - -` +func sanitizeDomainKey(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + r := strings.NewReplacer("/", "_", "\\", "_", " ", "_", ":", "_") + return r.Replace(s) +}