From fb1abd6211c54159eaac9eca354092038ccd8845 Mon Sep 17 00:00:00 2001 From: Steven Tracey Date: Fri, 24 Apr 2026 10:37:46 -0400 Subject: [PATCH] [CI-SKIP] Upload current --- Makefile | 2 +- app/client/certs.go | 42 ++------- app/client/daemon.go | 131 ++++++---------------------- app/server/certs.go | 76 ++++------------ app/server/daemon.go | 147 ++++++++++++++++--------------- client/certificates.go | 183 +++++++++++++++++++++------------------ client/git.go | 60 ++++++------- common/git.go | 162 ++++++++++++---------------------- common/provider_gitea.go | 55 ++++++++++++ common/repo_provider.go | 33 +++++++ common/util.go | 25 ++++-- server/git.go | 182 ++++++++++++++++++++++---------------- 12 files changed, 519 insertions(+), 579 deletions(-) create mode 100644 common/provider_gitea.go create mode 100644 common/repo_provider.go diff --git a/Makefile b/Makefile index ae36868..fc038e9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION := 1.1.5-beta +VERSION := 1.1.5-beta-claude BUILD := $(shell git rev-parse --short HEAD) GO := go diff --git a/app/client/certs.go b/app/client/certs.go index a12edef..3d13306 100644 --- a/app/client/certs.go +++ b/app/client/certs.go @@ -2,13 +2,10 @@ package main import ( "fmt" - "path/filepath" "git.nevets.tech/Steven/certman/app" "git.nevets.tech/Steven/certman/client" "git.nevets.tech/Steven/certman/common" - "github.com/go-git/go-billy/v5/memfs" - "github.com/go-git/go-git/v5/storage/memory" "github.com/spf13/cobra" ) @@ -47,21 +44,18 @@ func init() { } func renewCert(domain string) error { - gitWorkspace := &common.GitWorkspace{ - Domain: domain, - Storage: memory.NewStorage(), - FS: memfs.New(), - } config := app.Config() domainConfig, exists := app.DomainStore().Get(domain) if !exists { return app.ErrConfigNotFound } - if err := client.PullCerts(config, domainConfig, gitWorkspace); err != nil { - return err + url := common.RepoURL(config, domainConfig, domain) + ws := common.NewGitWorkspace(domain, url) + if err := common.CloneRepo(ws, config); err != nil { + return fmt.Errorf("clone %s: %w", domain, err) } - certsDir := common.EffectiveDataRoot(config, domainConfig) - return client.DecryptAndWriteCertificates(certsDir, config, domainConfig, gitWorkspace) + certsDir := common.CertsDir(config, domainConfig, domain) + return client.DecryptAndWriteCertificates(certsDir, domainConfig, ws) } func updateLinks(domain string) error { @@ -69,26 +63,6 @@ func updateLinks(domain string) error { if !exists { return fmt.Errorf("domain %s does not exist", domain) } - - effectiveDataRoot := common.EffectiveDataRoot(app.Config(), domainConfig) - certsDir := filepath.Join(effectiveDataRoot, "certificates", domain) - - certLinks := domainConfig.Certificates.CertSymlinks - for _, certLink := range certLinks { - err := common.LinkFile(filepath.Join(certsDir, domain+".crt"), certLink, domain, ".crt") - if err != nil { - fmt.Printf("Error linking cert %s to %s: %v", certLink, domain, err) - continue - } - } - - keyLinks := domainConfig.Certificates.KeySymlinks - for _, keyLink := range keyLinks { - err := common.LinkFile(filepath.Join(certsDir, domain+".key"), keyLink, domain, ".key") - if err != nil { - fmt.Printf("Error linking cert %s to %s: %v", keyLink, domain, err) - continue - } - } - return nil + certsDir := common.CertsDir(app.Config(), domainConfig, domain) + return client.UpdateSymlinks(domain, domainConfig, certsDir) } diff --git a/app/client/daemon.go b/app/client/daemon.go index 3ea4059..30a34ad 100644 --- a/app/client/daemon.go +++ b/app/client/daemon.go @@ -1,161 +1,84 @@ package main import ( + "errors" "fmt" - "io" "log" - "path/filepath" - "strings" "git.nevets.tech/Steven/certman/app" "git.nevets.tech/Steven/certman/client" "git.nevets.tech/Steven/certman/common" - "github.com/go-git/go-billy/v5/memfs" - "github.com/go-git/go-git/v5/storage/memory" ) type Daemon struct{} func (d *Daemon) Init() { fmt.Println("Starting CertManager in client mode...") - err := app.LoadDomainConfigs() - if err != nil { + if err := app.LoadDomainConfigs(); err != nil { log.Fatalf("Error loading domain configs: %v", err) } - d.Tick() } func (d *Daemon) Tick() { fmt.Println("tick!") - // Get local copy of configs config := app.Config() localDomainConfigs := app.DomainStore().Snapshot() - // Loop over all domain configs (domains) for domainStr, domainConfig := range localDomainConfigs { - // Skip non-enabled domains if !domainConfig.Domain.Enabled { continue } - // Skip domains with up-to-date commit hashes - // If the repo doesn't exist, we can't check for a remote commit, so stop the rest of the check - repoExists := domainConfig.Internal.RepoExists - if repoExists { - dataRoot := common.EffectiveDataRoot(config, domainConfig) - localHash, err := client.LocalCommitHash(domainStr, dataRoot) + certsDir := common.CertsDir(config, domainConfig, domainStr) + + // Short-circuit when the local copy already matches the remote HEAD. + // Only useful once the server has provisioned the repo; otherwise + // the RemoteCommitHash call returns ErrRepoNotFound and we skip + // this tick entirely (nothing to pull yet). + if domainConfig.Internal.RepoExists { + localHash, err := client.LocalCommitHash(certsDir) if err != nil { - fmt.Printf("No local commit hash found for domain %s\n", domainStr) + fmt.Printf("Error reading local hash for %s: %v\n", domainStr, err) } - gitSource, err := common.StrToGitSource(app.Config().Git.Host) + remoteHash, err := client.RemoteCommitHash(config, domainConfig, domainStr) if err != nil { - fmt.Printf("Error getting git source for domain %s: %v\n", domainStr, err) + if errors.Is(err, common.ErrRepoNotFound) { + fmt.Printf("Remote repo not yet provisioned for %s; skipping\n", domainStr) + continue + } + fmt.Printf("Error getting remote hash for %s: %v\n", domainStr, err) continue } - remoteHash, err := client.RemoteCommitHash(domainStr, gitSource, config, domainConfig) - if err != nil { - fmt.Printf("Error getting remote commit hash for domain %s: %v\n", domainStr, err) - } - // If both hashes are blank (errored), break - // If localHash equals remoteHash (local is up-to-date), skip - if !(localHash == "" && remoteHash == "") && localHash == remoteHash { + if localHash != "" && localHash == remoteHash { fmt.Printf("Domain %s is up to date. Skipping...\n", domainStr) continue } } - gitWorkspace := &common.GitWorkspace{ - Storage: memory.NewStorage(), - FS: memfs.New(), - } - // Ex: https://git.example.com/Org/Repo-suffix.git - // Clones repo and stores in gitWorkspace, skip if clone fails (doesn't exist?) - repoUrl := app.Config().Git.Server + "/" + config.Git.OrgName + "/" + domainStr + domainConfig.Repo.RepoSuffix + ".git" - err := common.CloneRepo(repoUrl, gitWorkspace, common.Client, config) - if err != nil { + url := common.RepoURL(config, domainConfig, domainStr) + ws := common.NewGitWorkspace(domainStr, url) + if err := common.CloneRepo(ws, config); err != nil { fmt.Printf("Error cloning domain repo %s: %v\n", domainStr, err) continue } - effectiveDataRoot := common.EffectiveDataRoot(config, domainConfig) - certsDir := filepath.Join(effectiveDataRoot, "certificates", domainStr) - - // Get files in repo - fileInfos, err := gitWorkspace.FS.ReadDir("/") - if err != nil { - fmt.Printf("Error reading directory in memFS on domain %s: %v\n", domainStr, err) + if err := client.DecryptAndWriteCertificates(certsDir, domainConfig, ws); err != nil { + fmt.Printf("Error decrypting certificates for %s: %v\n", domainStr, err) continue } - // Iterate over files, filtering by .crpt (encrypted) files in case other files were accidentally added - 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 - } - - err = common.DecryptFileFromBytes(domainConfig.Certificates.CryptoKey, fileBytes, filepath.Join(certsDir, filename), nil) - if err != nil { - fmt.Printf("Error decrypting file %s in domain %s: %v\n", filename, domainStr, err) - continue - } - - headRef, err := gitWorkspace.Repo.Head() - if err != nil { - fmt.Printf("Error getting head reference for domain %s: %v\n", domainStr, err) - continue - } - - err = common.WriteCommitHash(headRef.Hash().String(), config, domainConfig) - if err != nil { - fmt.Printf("Error writing commit hash: %v\n", err) - continue - } - - certLinks := domainConfig.Certificates.CertSymlinks - for _, certLink := range certLinks { - err = common.LinkFile(filepath.Join(certsDir, domainStr+".crt"), certLink, domainStr, ".crt") - if err != nil { - fmt.Printf("Error linking cert %s to %s: %v\n", certLink, domainStr, err) - continue - } - } - - keyLinks := domainConfig.Certificates.KeySymlinks - for _, keyLink := range keyLinks { - err = common.LinkFile(filepath.Join(certsDir, domainStr+".key"), keyLink, domainStr, ".key") - if err != nil { - fmt.Printf("Error linking cert %s to %s: %v\n", keyLink, domainStr, err) - continue - } - } - } + if err := client.UpdateSymlinks(domainStr, domainConfig, certsDir); err != nil { + fmt.Printf("Error updating symlinks for %s: %v\n", domainStr, err) + continue } } } func (d *Daemon) Reload() { fmt.Println("Reloading configs...") - - err := app.LoadDomainConfigs() - if err != nil { + if err := app.LoadDomainConfigs(); err != nil { fmt.Printf("Error loading domain configs: %v\n", err) - return } } diff --git a/app/server/certs.go b/app/server/certs.go index c3f29ec..905a9b5 100644 --- a/app/server/certs.go +++ b/app/server/certs.go @@ -8,8 +8,6 @@ import ( "git.nevets.tech/Steven/certman/app" "git.nevets.tech/Steven/certman/common" "git.nevets.tech/Steven/certman/server" - "github.com/go-git/go-billy/v5/memfs" - "github.com/go-git/go-git/v5/storage/memory" "github.com/spf13/cobra" ) @@ -41,12 +39,7 @@ func renewCertCmd(domain string, noPush bool) error { if err != nil { return err } - err = renewCerts(domain, noPush, mgr) - if err != nil { - return err - } - // return ReloadDaemonCmd() // Not sure if this is necessary - return nil + return renewCerts(domain, noPush, mgr) } func renewCerts(domain string, noPush bool, mgr *server.ACMEManager) error { @@ -56,70 +49,37 @@ func renewCerts(domain string, noPush bool, mgr *server.ACMEManager) error { return fmt.Errorf("domain %s does not exist", domain) } - _, err := mgr.RenewForDomain(domain) - if err != nil { - // if no existing cert, obtain instead - _, err = mgr.ObtainForDomain(domain, config, domainConfig) - if err != nil { + if _, err := mgr.RenewForDomain(domain); err != nil { + // If the domain has no stored resource yet, fall through to Obtain. + if _, err := mgr.ObtainForDomain(domain, config, domainConfig); err != nil { return fmt.Errorf("error obtaining domain certificates for domain %s: %v", domain, err) } } domainConfig.Internal.LastIssued = time.Now().UTC().Unix() - err = app.WriteDomainConfig(domainConfig) - if err != nil { + if err := app.WriteDomainConfig(domainConfig); err != nil { return fmt.Errorf("error saving domain config %s: %v", domain, err) } - err = common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, filepath.Join(mgr.CertsRoot, domain, domain+".crt"), filepath.Join(mgr.CertsRoot, domain, domain+".crt.crpt"), nil) - if err != nil { + certsDir := filepath.Join(mgr.CertsRoot, domain) + if err := common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, filepath.Join(certsDir, domain+".crt"), filepath.Join(certsDir, domain+".crt.crpt"), nil); err != nil { return fmt.Errorf("error encrypting domain cert for domain %s: %v", domain, err) } - err = common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, filepath.Join(mgr.CertsRoot, domain, domain+".key"), filepath.Join(mgr.CertsRoot, domain, domain+".key.crpt"), nil) - if err != nil { + if err := common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, filepath.Join(certsDir, domain+".key"), filepath.Join(certsDir, domain+".key.crpt"), nil); err != nil { return fmt.Errorf("error encrypting domain key for domain %s: %v", domain, err) } - if !noPush { - giteaClient := common.CreateGiteaClient(config) - if giteaClient == nil { - return fmt.Errorf("error creating gitea client for domain %s: %v", domain, err) - } - gitWorkspace := &common.GitWorkspace{ - Storage: memory.NewStorage(), - FS: memfs.New(), - } - - var repoUrl string - if !domainConfig.Internal.RepoExists { - repoUrl = common.CreateGiteaRepo(domain, giteaClient, config, domainConfig) - if repoUrl == "" { - return fmt.Errorf("error creating Gitea repo for domain %s", domain) - } - domainConfig.Internal.RepoExists = true - err = app.WriteDomainConfig(domainConfig) - if err != nil { - return fmt.Errorf("error saving domain config %s: %v", domain, err) - } - - err = common.InitRepo(repoUrl, gitWorkspace) - if err != nil { - return fmt.Errorf("error initializing repo for domain %s: %v", domain, err) - } - } else { - repoUrl = config.Git.Server + "/" + config.Git.OrgName + "/" + domain + domainConfig.Repo.RepoSuffix + ".git" - err = common.CloneRepo(repoUrl, gitWorkspace, common.Server, config) - if err != nil { - return fmt.Errorf("error cloning repo for domain %s: %v", domain, err) - } - } - - err = common.AddAndPushCerts(domain, gitWorkspace, config, domainConfig) - if err != nil { - return fmt.Errorf("error pushing certificates for domain %s: %v", domain, err) - } - fmt.Printf("Successfully pushed certificates for domain %s\n", domain) + if noPush { + return nil } + ws, err := prepareServerWorkspace(config, domainConfig, domain) + if err != nil { + return fmt.Errorf("prepare workspace for %s: %w", domain, err) + } + if err := server.AddAndPushCerts(ws, certsDir, config); err != nil { + return fmt.Errorf("push certificates for %s: %w", domain, err) + } + fmt.Printf("Successfully pushed certificates for domain %s\n", domain) return nil } diff --git a/app/server/daemon.go b/app/server/daemon.go index 3ff3960..574749c 100644 --- a/app/server/daemon.go +++ b/app/server/daemon.go @@ -12,8 +12,6 @@ import ( appShared "git.nevets.tech/Steven/certman/app" "git.nevets.tech/Steven/certman/common" "git.nevets.tech/Steven/certman/server" - "github.com/go-git/go-billy/v5/memfs" - "github.com/go-git/go-git/v5/storage/memory" ) type Daemon struct { @@ -69,91 +67,92 @@ func (d *Daemon) Tick() { renewPeriod := domainConfig.Certificates.RenewPeriod lastIssued := time.Unix(domainConfig.Internal.LastIssued, 0).UTC() renewalDue := lastIssued.AddDate(0, 0, renewPeriod) - if now.After(renewalDue) { - //TODO extra check if certificate expiry (create cache?) - _, err := d.ACMEManager.RenewForDomain(domainStr) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - // if no existing cert, obtain instead - _, err = d.ACMEManager.ObtainForDomain(domainStr, appShared.Config(), domainConfig) - if err != nil { - fmt.Printf("Error obtaining domain certificates for domain %s: %v\n", domainStr, err) - continue - } - } - fmt.Printf("Error: %v\n", err) - continue - } + if !now.After(renewalDue) { + continue + } - domainConfig.Internal.LastIssued = time.Now().UTC().Unix() - err = appShared.WriteDomainConfig(domainConfig) - if err != nil { - fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) - continue - } - - err = common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, filepath.Join(d.ACMEManager.CertsRoot, domainStr, domainStr+".crt"), filepath.Join(d.ACMEManager.CertsRoot, domainStr, domainStr+".crt.crpt"), nil) - if err != nil { - fmt.Printf("Error encrypting domain cert for domain %s: %v\n", domainStr, err) - continue - } - err = common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, filepath.Join(d.ACMEManager.CertsRoot, domainStr, domainStr+".key"), filepath.Join(d.ACMEManager.CertsRoot, domainStr, domainStr+".key.crpt"), nil) - if err != nil { - fmt.Printf("Error encrypting domain key for domain %s: %v\n", domainStr, err) - continue - } - - giteaClient := common.CreateGiteaClient(config) - if giteaClient == nil { - fmt.Printf("Error creating gitea client for domain %s: %v\n", domainStr, err) - continue - } - gitWorkspace := &common.GitWorkspace{ - Storage: memory.NewStorage(), - FS: memfs.New(), - } - - var repoUrl string - if !domainConfig.Internal.RepoExists { - repoUrl = common.CreateGiteaRepo(domainStr, giteaClient, config, domainConfig) - if repoUrl == "" { - fmt.Printf("Error creating Gitea repo for domain %s\n", domainStr) - continue - } - domainConfig.Internal.RepoExists = true - err = appShared.WriteDomainConfig(domainConfig) - if err != nil { - fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) - continue - } - - err = common.InitRepo(repoUrl, gitWorkspace) - if err != nil { - fmt.Printf("Error initializing repo for domain %s: %v\n", domainStr, err) + //TODO extra check if certificate expiry (create cache?) + if _, err := d.ACMEManager.RenewForDomain(domainStr); err != nil { + if errors.Is(err, os.ErrNotExist) { + if _, err := d.ACMEManager.ObtainForDomain(domainStr, config, domainConfig); err != nil { + fmt.Printf("Error obtaining domain certificates for domain %s: %v\n", domainStr, err) continue } } else { - repoUrl = appShared.Config().Git.Server + "/" + appShared.Config().Git.OrgName + "/" + domainStr + domainConfig.Repo.RepoSuffix + ".git" - err = common.CloneRepo(repoUrl, gitWorkspace, common.Server, config) - if err != nil { - fmt.Printf("Error cloning repo for domain %s: %v\n", domainStr, err) - continue - } - } - - err = common.AddAndPushCerts(domainStr, gitWorkspace, config, domainConfig) - if err != nil { - fmt.Printf("Error pushing certificates for domain %s: %v\n", domainStr, err) + fmt.Printf("Error: %v\n", err) continue } - fmt.Printf("Successfully pushed certificates for domain %s\n", domainStr) } + + domainConfig.Internal.LastIssued = time.Now().UTC().Unix() + if err := appShared.WriteDomainConfig(domainConfig); err != nil { + fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) + continue + } + + certsDir := filepath.Join(d.ACMEManager.CertsRoot, domainStr) + if err := common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, filepath.Join(certsDir, domainStr+".crt"), filepath.Join(certsDir, domainStr+".crt.crpt"), nil); err != nil { + fmt.Printf("Error encrypting domain cert for domain %s: %v\n", domainStr, err) + continue + } + if err := common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, filepath.Join(certsDir, domainStr+".key"), filepath.Join(certsDir, domainStr+".key.crpt"), nil); err != nil { + fmt.Printf("Error encrypting domain key for domain %s: %v\n", domainStr, err) + continue + } + + ws, err := prepareServerWorkspace(config, domainConfig, domainStr) + if err != nil { + fmt.Printf("Error preparing git workspace for domain %s: %v\n", domainStr, err) + continue + } + + if err := server.AddAndPushCerts(ws, certsDir, config); 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) } if err := appShared.SaveDomainConfigs(); err != nil { fmt.Printf("Error saving domain configs: %v\n", err) } } +// prepareServerWorkspace creates or clones the domain's remote repo into a +// fresh in-memory workspace. If the repo is being cloned, it verifies that +// SERVER_ID matches this server's UUID (or is absent, in which case the +// domain is adopted on the next push). +func prepareServerWorkspace(config *common.AppConfig, domainConfig *common.DomainConfig, domain string) (*common.GitWorkspace, error) { + if !domainConfig.Internal.RepoExists { + url, err := server.CreateRepo(config, domainConfig, domain) + if err != nil { + return nil, fmt.Errorf("create remote repo: %w", err) + } + domainConfig.Internal.RepoExists = true + if err := appShared.WriteDomainConfig(domainConfig); err != nil { + return nil, fmt.Errorf("save domain config: %w", err) + } + ws := common.NewGitWorkspace(domain, url) + if err := common.InitRepo(ws); err != nil { + return nil, fmt.Errorf("init workspace: %w", err) + } + return ws, nil + } + + url := common.RepoURL(config, domainConfig, domain) + ws := common.NewGitWorkspace(domain, url) + if err := common.CloneRepo(ws, config); err != nil { + return nil, fmt.Errorf("clone: %w", err) + } + owned, err := server.VerifyOwnership(ws, config.App.UUID) + if err != nil { + return nil, err + } + if !owned { + fmt.Printf("Adopting unclaimed repo for domain %s\n", domain) + } + return ws, nil +} + func (d *Daemon) Reload() { fmt.Println("Reloading configs...") err := appShared.LoadDomainConfigs() diff --git a/client/certificates.go b/client/certificates.go index e39b099..9b5ae9a 100644 --- a/client/certificates.go +++ b/client/certificates.go @@ -1,6 +1,7 @@ package client import ( + "errors" "fmt" "io" "os" @@ -10,100 +11,118 @@ import ( "git.nevets.tech/Steven/certman/common" ) -func PullCerts(config *common.AppConfig, domainConfig *common.DomainConfig, gitWorkspace *common.GitWorkspace) error { - // Ex: https://git.example.com/Org/Repo-suffix.git - // Clones repo and stores in gitWorkspace, skip if clone fails (doesn't exist?) - repoUrl := config.Git.Server + "/" + config.Git.OrgName + "/" + gitWorkspace.Domain + domainConfig.Repo.RepoSuffix + ".git" - err := common.CloneRepo(repoUrl, gitWorkspace, common.Client, config) +// DecryptAndWriteCertificates walks the workspace's root directory, decrypts +// every *.crpt file using the domain's crypto key, and writes the cleartext +// output into certsDir. +// +// On a fully successful pass it records the current HEAD commit SHA via +// WriteCommitHash, so the next tick can short-circuit when nothing changed. +// Per-file failures are collected and returned together; the commit-hash +// marker is only written when every file decrypted cleanly, so a partial +// sync never masquerades as up-to-date on the next tick. +func DecryptAndWriteCertificates(certsDir string, domainConfig *common.DomainConfig, ws *common.GitWorkspace) error { + entries, err := ws.FS.ReadDir("/") if err != nil { - return fmt.Errorf("Error cloning domain repo %s: %v\n", gitWorkspace.Domain, err) + return fmt.Errorf("read workspace root: %w", err) } - return nil -} -func DecryptAndWriteCertificates(certsDir string, config *common.AppConfig, domainConfig *common.DomainConfig, gitWorkspace *common.GitWorkspace) error { - // Get files in repo - fileInfos, err := gitWorkspace.FS.ReadDir("/") - if err != nil { - return fmt.Errorf("Error reading directory in memFS on domain %s: %v\n", gitWorkspace.Domain, err) - } - // Iterate over files, filtering by .crpt (encrypted) files in case other files were accidentally added - 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", gitWorkspace.Domain, err) - continue - } - fileBytes, err := io.ReadAll(file) - if err != nil { - fmt.Printf("Error reading file in memFS on domain %s: %v\n", gitWorkspace.Domain, err) - file.Close() - continue - } - err = file.Close() - if err != nil { - fmt.Printf("Error closing file on domain %s: %v\n", gitWorkspace.Domain, err) - continue - } - - err = common.DecryptFileFromBytes(domainConfig.Certificates.CryptoKey, fileBytes, filepath.Join(certsDir, filename), nil) - if err != nil { - fmt.Printf("Error decrypting file %s in domain %s: %v\n", filename, gitWorkspace.Domain, err) - continue - } - - headRef, err := gitWorkspace.Repo.Head() - if err != nil { - fmt.Printf("Error getting head reference for domain %s: %v\n", gitWorkspace.Domain, err) - continue - } - - err = common.WriteCommitHash(headRef.Hash().String(), config, domainConfig) - if err != nil { - fmt.Printf("Error writing commit hash: %v\n", err) - continue - } + var errs []error + var wrote int + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".crpt") { + continue } + plainName, _ := strings.CutSuffix(name, ".crpt") + + data, err := readWorkspaceFile(ws, name) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", name, err)) + continue + } + if err := common.DecryptFileFromBytes(domainConfig.Certificates.CryptoKey, data, filepath.Join(certsDir, plainName), nil); err != nil { + errs = append(errs, fmt.Errorf("%s: decrypt: %w", name, err)) + continue + } + wrote++ } - return nil + + if len(errs) > 0 { + return errors.Join(errs...) + } + if wrote == 0 { + return nil + } + + head, err := ws.Repo.Head() + if err != nil { + return fmt.Errorf("get repo head: %w", err) + } + return WriteCommitHash(certsDir, head.Hash().String()) } +// DecryptCertificates is a standalone utility that decrypts every *.crpt +// file in an on-disk directory. It is used by the `cert decrypt` CLI command +// and does not touch git state. func DecryptCertificates(certPath, cryptoKey string) error { - // Get files in repo - fileInfos, err := os.ReadDir(certPath) + entries, err := os.ReadDir(certPath) if err != nil { - return fmt.Errorf("error reading directory: %v", err) + return fmt.Errorf("read %s: %w", certPath, err) } - // Iterate over files, filtering by .crpt (encrypted) files in case other files were accidentally added - for _, fileInfo := range fileInfos { - if strings.HasSuffix(fileInfo.Name(), ".crpt") { - filename, _ := strings.CutSuffix(fileInfo.Name(), ".crpt") - file, err := os.OpenFile(fileInfo.Name(), os.O_RDONLY, 0640) - if err != nil { - fmt.Printf("Error opening file: %v\n", err) - continue - } - fileBytes, err := io.ReadAll(file) - if err != nil { - fmt.Printf("Error reading file: %v\n", err) - file.Close() - continue - } - err = file.Close() - if err != nil { - fmt.Printf("Error closing file: %v\n", err) - continue - } + var errs []error + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".crpt") { + continue + } + plainName, _ := strings.CutSuffix(name, ".crpt") - err = common.DecryptFileFromBytes(cryptoKey, fileBytes, filepath.Join(certPath, filename), nil) - if err != nil { - fmt.Printf("Error decrypting file %s: %v\n", filename, err) - continue - } + data, err := os.ReadFile(filepath.Join(certPath, name)) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", name, err)) + continue + } + if err := common.DecryptFileFromBytes(cryptoKey, data, filepath.Join(certPath, plainName), nil); err != nil { + errs = append(errs, fmt.Errorf("%s: decrypt: %w", name, err)) + continue } } - + if len(errs) > 0 { + return errors.Join(errs...) + } return nil } + +// UpdateSymlinks refreshes every configured cert and key symlink so it +// points at the domain's current cert/key files under certsDir. It reports +// all link failures together rather than stopping at the first one. +func UpdateSymlinks(domain string, domainConfig *common.DomainConfig, certsDir string) error { + var errs []error + for _, link := range domainConfig.Certificates.CertSymlinks { + if err := common.LinkFile(filepath.Join(certsDir, domain+".crt"), link, domain, ".crt"); err != nil { + errs = append(errs, fmt.Errorf("cert link %s: %w", link, err)) + } + } + for _, link := range domainConfig.Certificates.KeySymlinks { + if err := common.LinkFile(filepath.Join(certsDir, domain+".key"), link, domain, ".key"); err != nil { + errs = append(errs, fmt.Errorf("key link %s: %w", link, err)) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +func readWorkspaceFile(ws *common.GitWorkspace, name string) ([]byte, error) { + f, err := ws.FS.Open(name) + if err != nil { + return nil, fmt.Errorf("open: %w", err) + } + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + return data, nil +} diff --git a/client/git.go b/client/git.go index ef39cae..d861e94 100644 --- a/client/git.go +++ b/client/git.go @@ -10,52 +10,42 @@ import ( "git.nevets.tech/Steven/certman/common" ) -func WriteCommitHash(hash, dataRoot string) error { - //TODO: unfuck this logic, maybe use a domain struct with a flag for non-standard data root? - //var dataRoot string - //if domainConfig.Certificates.DataRoot == "" { - // dataRoot = filepath.Join(config.Certificates.DataRoot, "certificates", domainConfig.Domain.DomainName) - //} else { - // dataRoot = domainConfig.Certificates.DataRoot - //} +// hashFile is the filename inside a domain's local data root that records +// the last remote commit SHA the client successfully synced from. The daemon +// compares it against the remote HEAD to decide whether a sync is needed. +const hashFile = "hash" - err := os.WriteFile(filepath.Join(dataRoot, "hash"), []byte(hash), 0644) - if err != nil { - return err - } +// defaultBranch is the branch the client tracks on the remote repo. +const defaultBranch = "master" - return nil +// WriteCommitHash persists hash to /hash. Call it after a +// successful sync so the next tick can skip a no-op. +func WriteCommitHash(certsDir, hash string) error { + return os.WriteFile(filepath.Join(certsDir, hashFile), []byte(hash), 0o644) } -func LocalCommitHash(domain string, certsDir string) (string, error) { - data, err := os.ReadFile(filepath.Join(certsDir, "hash")) +// LocalCommitHash returns the commit SHA recorded at /hash. A +// missing file is not an error: it returns "" so a fresh client falls +// through to the full sync path. +func LocalCommitHash(certsDir string) (string, error) { + data, err := os.ReadFile(filepath.Join(certsDir, hashFile)) if err != nil { - if !os.IsNotExist(err) { - fmt.Printf("Error reading file for domain %s: %v\n", domain, err) - return "", err + if errors.Is(err, os.ErrNotExist) { + return "", nil } + return "", fmt.Errorf("read hash file: %w", err) } - return strings.TrimSpace(string(data)), nil } -func RemoteCommitHash(domain string, gitSource common.GitSource, config *common.AppConfig, domainConfig *common.DomainConfig) (string, error) { - switch gitSource { - case common.Gitea: - return getRemoteCommitHashGitea(config.Git.OrgName, domain+domainConfig.Repo.RepoSuffix, "master", config) - default: - fmt.Printf("Unimplemented git source %v\n", gitSource) - return "", errors.New("unimplemented git source") - } -} - -func getRemoteCommitHashGitea(org, repo, branchName string, config *common.AppConfig) (string, error) { - giteaClient := common.CreateGiteaClient(config) - branch, _, err := giteaClient.GetRepoBranch(org, repo, branchName) +// RemoteCommitHash returns the current HEAD commit SHA of the domain's repo +// on the configured git host. It returns common.ErrRepoNotFound if the repo +// does not exist yet, letting the daemon handle the "not provisioned" case +// without string-matching errors. +func RemoteCommitHash(config *common.AppConfig, domainConfig *common.DomainConfig, domain string) (string, error) { + provider, err := common.ProviderFor(config) if err != nil { - fmt.Printf("Error getting repo branch: %v\n", err) return "", err } - //TODO catch repo not found as ErrRepoNotInit - return branch.Commit.ID, nil + return provider.HeadCommit(domain, defaultBranch, domainConfig) } diff --git a/common/git.go b/common/git.go index e2ea943..294fa1e 100644 --- a/common/git.go +++ b/common/git.go @@ -3,34 +3,29 @@ package common import ( "errors" "fmt" - "io" - "os" - "strings" - "code.gitea.io/sdk/gitea" "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-git/v5" gitconf "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" ) -type CertManMode int - -const ( - Server CertManMode = iota - Client -) - +// GitWorkspace is an in-memory git working tree for a single domain's +// certificate repository. It is the shared primitive that both client and +// server modes operate on: client mode clones and reads, server mode +// init/clones and pushes. The struct carries no mode-specific state. type GitWorkspace struct { Domain string URL string - Repo *git.Repository Storage *memory.Storage FS billy.Filesystem + Repo *git.Repository WorkTree *git.Worktree } +// GitSource identifies a supported git repository host. type GitSource int const ( @@ -42,6 +37,8 @@ const ( CodeCommit ) +// GitSourceName maps GitSource to the string used in the app config's +// git.host field. var GitSourceName = map[GitSource]string{ Github: "github", Gitlab: "gitlab", @@ -51,133 +48,80 @@ var GitSourceName = map[GitSource]string{ CodeCommit: "code-commit", } +// StrToGitSource parses a config string (e.g. "gitea") into a GitSource. func StrToGitSource(s string) (GitSource, error) { for k, v := range GitSourceName { if v == s { return k, nil } } - return GitSource(0), errors.New("invalid gitsource name") + return 0, fmt.Errorf("invalid git source %q", s) } -//func createGithubClient() *github.Client { -// return github.NewClient(nil).WithAuthToken(config.GetString("Git.api_token")) -//} +// ErrRepoNotFound is returned by RepoProvider implementations when a domain's +// repository does not exist on the remote. Callers use it to distinguish +// "repo hasn't been created yet" from transport or auth failures. +var ErrRepoNotFound = errors.New("repository not found") -func CreateGiteaClient(config *AppConfig) *gitea.Client { - client, err := gitea.NewClient(config.Git.Server, gitea.SetToken(config.Git.APIToken)) - if err != nil { - fmt.Printf("Error connecting to gitea instance: %v\n", err) - return nil +// NewGitWorkspace returns a workspace with an in-memory filesystem and storage +// wired up. Call InitRepo (for a brand-new remote) or CloneRepo (for an +// existing one) to populate Repo and WorkTree. +func NewGitWorkspace(domain, url string) *GitWorkspace { + return &GitWorkspace{ + Domain: domain, + URL: url, + Storage: memory.NewStorage(), + FS: memfs.New(), } - return client } -//func createGithubRepo(domain *Domain, Client *github.Client) string { -// name := domain.name -// owner := domain.config.GetString("Repo.owner") -// description := domain.description -// private := true -// includeAllBranches := false -// -// ctx := context.Background() -// template := &github.TemplateRepoRequest{ -// Name: name, -// Owner: &owner, -// Description: description, -// Private: &private, -// IncludeAllBranches: &includeAllBranches, -// } -// repo, _, err := Client.Repositories.CreateFromTemplate(ctx, config.GetString("Git.org_name"), config.GetString("Git.template_name"), template) -// if err != nil { -// fmt.Println("Error creating repository from template,", err) -// return "" -// } -// return *repo.CloneURL -//} - -func CreateGiteaRepo(domain string, giteaClient *gitea.Client, config *AppConfig, domainConfig *DomainConfig) string { - options := gitea.CreateRepoOption{ - Name: domain + domainConfig.Repo.RepoSuffix, - 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.Git.OrgName, options) - if err != nil { - fmt.Printf("Error creating repo: %v\n", err) - return "" - } - return giteaRepo.CloneURL +// RepoURL builds the canonical clone URL for a domain's certificate repo. It +// is the single authoritative place for the "//.git" +// pattern so callers do not assemble URLs by hand. +func RepoURL(config *AppConfig, domainConfig *DomainConfig, domain string) string { + return config.Git.Server + "/" + config.Git.OrgName + "/" + domain + domainConfig.Repo.RepoSuffix + ".git" } -func (ws *GitWorkspace) InitRepo() error { - var err error - ws.Repo, err = git.Init(ws.Storage, ws.FS) +// InitRepo initializes an empty local repository in ws and registers origin +// pointed at ws.URL. Use this on the first push for a new domain; use +// CloneRepo on subsequent runs. +func InitRepo(ws *GitWorkspace) error { + repo, err := git.Init(ws.Storage, ws.FS) if err != nil { - fmt.Printf("Error initializing local repo: %v\n", err) - return err + return fmt.Errorf("git init: %w", err) } - - _, err = ws.Repo.CreateRemote(&gitconf.RemoteConfig{ + if _, err := repo.CreateRemote(&gitconf.RemoteConfig{ Name: "origin", URLs: []string{ws.URL}, - }) - if err != nil && !errors.Is(err, git.ErrRemoteExists) { - fmt.Printf("Error creating remote origin repo: %v\n", err) - return err + }); err != nil && !errors.Is(err, git.ErrRemoteExists) { + return fmt.Errorf("add remote: %w", err) } - - ws.WorkTree, err = ws.Repo.Worktree() + wt, err := repo.Worktree() if err != nil { - fmt.Printf("Error getting worktree from local repo: %v\n", err) - return err + return fmt.Errorf("get worktree: %w", err) } - + ws.Repo = repo + ws.WorkTree = wt return nil } -func (ws *GitWorkspace) CloneRepo(certmanMode CertManMode, config *AppConfig) error { - creds := &http.BasicAuth{ +// CloneRepo clones ws.URL into ws using the git credentials from config. It +// performs no ownership or mode-specific checks: server mode must follow up +// with server.VerifyOwnership before pushing. +func CloneRepo(ws *GitWorkspace, config *AppConfig) error { + auth := &http.BasicAuth{ Username: config.Git.Username, Password: config.Git.APIToken, } - var err error - ws.Repo, err = git.Clone(ws.Storage, ws.FS, &git.CloneOptions{URL: ws.URL, Auth: creds}) + repo, err := git.Clone(ws.Storage, ws.FS, &git.CloneOptions{URL: ws.URL, Auth: auth}) if err != nil { - fmt.Printf("Error cloning repo: %v\n", err) + return fmt.Errorf("git clone %s: %w", ws.URL, err) } - - ws.WorkTree, err = ws.Repo.Worktree() + wt, err := repo.Worktree() if err != nil { - fmt.Printf("Error getting worktree from cloned repo: %v\n", err) - return err - } - if certmanMode == Server { - serverIdFile, err := ws.FS.OpenFile("/SERVER_ID", os.O_RDWR, 0640) - if err != nil { - if os.IsNotExist(err) { - fmt.Printf("server ID file not found for %s, adopting domain\n", ws.URL) - return nil - } - return err - } - serverIdBytes, err := io.ReadAll(serverIdFile) - if err != nil { - return err - } - serverId := strings.TrimSpace(string(serverIdBytes)) - if serverId != config.App.UUID { - return fmt.Errorf("domain is already managed by server with uuid %s", serverId) - } + return fmt.Errorf("get worktree: %w", err) } + ws.Repo = repo + ws.WorkTree = wt return nil } diff --git a/common/provider_gitea.go b/common/provider_gitea.go new file mode 100644 index 0000000..4721682 --- /dev/null +++ b/common/provider_gitea.go @@ -0,0 +1,55 @@ +package common + +import ( + "fmt" + "net/http" + + "code.gitea.io/sdk/gitea" +) + +// giteaProvider implements RepoProvider against a Gitea instance. +type giteaProvider struct { + config *AppConfig + client *gitea.Client +} + +func newGiteaProvider(config *AppConfig) (*giteaProvider, error) { + client, err := gitea.NewClient(config.Git.Server, gitea.SetToken(config.Git.APIToken)) + if err != nil { + return nil, fmt.Errorf("connect gitea %s: %w", config.Git.Server, err) + } + return &giteaProvider{config: config, client: client}, nil +} + +// CreateRepo creates a private org repo named "" and +// returns its clone URL. +func (p *giteaProvider) CreateRepo(domain string, domainConfig *DomainConfig) (string, error) { + name := domain + domainConfig.Repo.RepoSuffix + opts := gitea.CreateRepoOption{ + Name: name, + Description: "Certificate storage for " + domain, + Private: true, + AutoInit: false, + DefaultBranch: "master", + TrustModel: gitea.TrustModelDefault, + } + repo, _, err := p.client.CreateOrgRepo(p.config.Git.OrgName, opts) + if err != nil { + return "", fmt.Errorf("create gitea repo %s/%s: %w", p.config.Git.OrgName, name, err) + } + return repo.CloneURL, nil +} + +// HeadCommit returns the commit ID of branch in the domain's repo. A 404 +// from Gitea (repo or branch missing) is mapped to ErrRepoNotFound. +func (p *giteaProvider) HeadCommit(domain, branch string, domainConfig *DomainConfig) (string, error) { + name := domain + domainConfig.Repo.RepoSuffix + b, resp, err := p.client.GetRepoBranch(p.config.Git.OrgName, name, branch) + if err != nil { + if resp != nil && resp.Response != nil && resp.StatusCode == http.StatusNotFound { + return "", ErrRepoNotFound + } + return "", fmt.Errorf("gitea branch %s/%s@%s: %w", p.config.Git.OrgName, name, branch, err) + } + return b.Commit.ID, nil +} diff --git a/common/repo_provider.go b/common/repo_provider.go new file mode 100644 index 0000000..df166bd --- /dev/null +++ b/common/repo_provider.go @@ -0,0 +1,33 @@ +package common + +import "fmt" + +// RepoProvider abstracts the remote git host (Gitea, GitHub, etc.) so the +// client and server packages stay host-agnostic. Provider-specific code lives +// in a single file per host (e.g. provider_gitea.go) that implements this +// interface. Adding a new host is a matter of adding a new file and a case +// in ProviderFor; no caller needs to change. +type RepoProvider interface { + // CreateRepo creates a new private domain repo on the remote and returns + // its canonical clone URL. + CreateRepo(domain string, domainConfig *DomainConfig) (string, error) + + // HeadCommit returns the commit SHA at the tip of branch for the domain's + // repo. It returns ErrRepoNotFound if either the repo or the branch does + // not exist, so callers can treat "not created yet" as a non-fatal state. + HeadCommit(domain, branch string, domainConfig *DomainConfig) (string, error) +} + +// ProviderFor returns a RepoProvider matching config.Git.Host. +func ProviderFor(config *AppConfig) (RepoProvider, error) { + source, err := StrToGitSource(config.Git.Host) + if err != nil { + return nil, err + } + switch source { + case Gitea: + return newGiteaProvider(config) + default: + return nil, fmt.Errorf("git source %q is not implemented", config.Git.Host) + } +} diff --git a/common/util.go b/common/util.go index e0a798f..7eb6cd3 100644 --- a/common/util.go +++ b/common/util.go @@ -11,8 +11,6 @@ import ( "strconv" "strings" "syscall" - - "code.gitea.io/sdk/gitea" ) var ( @@ -21,13 +19,6 @@ var ( ErrBlankCert = errors.New("cert is blank") ) -type Domain struct { - name *string - config *AppConfig - description *string - gtClient *gitea.Client -} - // 0x01 func createPIDFile() { file, err := os.Create("/var/run/certman.pid") @@ -293,6 +284,22 @@ func MakeCredential(username, groupname string) (*syscall.Credential, error) { return &syscall.Credential{Uid: uid, Gid: gid}, nil } +// CertsDir returns the on-disk directory where a domain's encrypted and +// decrypted certificate files live, along with the client's sync-state +// `hash` marker. A per-domain data_root override (domainConfig.Certificates.DataRoot) +// is used as-is; otherwise the path is /certificates/. +// This is the single source of truth for that convention — callers should +// not assemble the path themselves. +func CertsDir(config *AppConfig, domainConfig *DomainConfig, domain string) string { + if domainConfig != nil && domainConfig.Certificates.DataRoot != "" { + return domainConfig.Certificates.DataRoot + } + if config == nil { + return filepath.Join("certificates", domain) + } + return filepath.Join(config.Certificates.DataRoot, "certificates", domain) +} + func EffectiveDataRoot(config *AppConfig, domainConfig *DomainConfig) string { if config == nil { return "" diff --git a/server/git.go b/server/git.go index 2a1f2ad..79283b4 100644 --- a/server/git.go +++ b/server/git.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "io" "os" "path/filepath" "strings" @@ -14,107 +15,142 @@ import ( "github.com/go-git/go-git/v5/plumbing/transport/http" ) -type GitWorkspace common.GitWorkspace +// serverIDFile is the filename inside the domain's git repo that records +// which server (by config.App.UUID) owns the repo. Only the owning server +// pushes to it; other servers must refuse. +const serverIDFile = "SERVER_ID" -func (ws *GitWorkspace) AddAndPushCerts(dataRoot, repoSuffix string, config *common.AppConfig) error { - certFiles, err := os.ReadDir(dataRoot) +// pushBranch is the branch server mode pushes to. The Gitea repo is created +// with this as its default branch; the client tracks the same name. +const pushBranch = "master" + +// CreateRepo provisions the domain's remote repo via the configured provider +// and returns its clone URL. +func CreateRepo(config *common.AppConfig, domainConfig *common.DomainConfig, domain string) (string, error) { + provider, err := common.ProviderFor(config) if err != nil { - fmt.Printf("Error reading from directory: %v\n", err) - return err + return "", err } - 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) - return err - } - certFile, err := os.ReadFile(filepath.Join(dataRoot, entry.Name())) - if err != nil { - fmt.Printf("Error reading file to memfs: %v\n", err) - file.Close() - return err - } - _, err = file.Write(certFile) - if err != nil { - fmt.Printf("Error writing to memfs: %v\n", err) - file.Close() - return err - } - _, err = ws.WorkTree.Add(file.Name()) - if err != nil { - 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) - } + return provider.CreateRepo(domain, domainConfig) +} + +// VerifyOwnership reads SERVER_ID from the cloned workspace and compares it +// against uuid. It returns: +// +// (true, nil) — SERVER_ID matches uuid (we own this repo). +// (false, nil) — SERVER_ID is missing (repo is unclaimed; safe to adopt). +// (false, err) — SERVER_ID names a different server (refuse to push). +// +// The caller decides what to do with an unclaimed repo; adoption must be an +// explicit decision, not a silent fall-through. AddAndPushCerts re-writes +// SERVER_ID on every push, so the first successful push after adoption +// claims the repo for this server. +func VerifyOwnership(ws *common.GitWorkspace, uuid string) (bool, error) { + f, err := ws.FS.Open(serverIDFile) + if err != nil { + if os.IsNotExist(err) { + return false, nil } + return false, fmt.Errorf("open SERVER_ID: %w", err) } + defer f.Close() - file, err := ws.FS.Create("/SERVER_ID") + raw, err := io.ReadAll(f) if err != nil { - fmt.Printf("Error creating file in memfs: %v\n", err) + return false, fmt.Errorf("read SERVER_ID: %w", err) + } + existing := strings.TrimSpace(string(raw)) + if existing == uuid { + return true, nil + } + return false, fmt.Errorf("domain is owned by server %q", existing) +} + +// AddAndPushCerts stages every *.crpt file from dataRoot into the workspace, +// (re-)writes SERVER_ID with config.App.UUID, commits any resulting change, +// and pushes to origin/. If nothing changed the call is a no-op +// and returns nil without pushing. +func AddAndPushCerts(ws *common.GitWorkspace, dataRoot string, config *common.AppConfig) error { + if err := stageCerts(ws, dataRoot); err != nil { return err } - _, err = file.Write([]byte(config.App.UUID)) - if err != nil { - fmt.Printf("Error writing to memfs: %v\n", err) - file.Close() - return err - } - _, err = ws.WorkTree.Add(file.Name()) - if err != nil { - 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) + if err := stageFile(ws, serverIDFile, []byte(config.App.UUID)); err != nil { + return fmt.Errorf("stage SERVER_ID: %w", err) } status, err := ws.WorkTree.Status() if err != nil { - fmt.Printf("Error getting repo status: %v\n", err) - return err + return fmt.Errorf("get worktree status: %w", 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{ + sig := &object.Signature{ Name: "Cert Manager", Email: config.Certificates.Email, When: time.Now(), } - _, err = ws.WorkTree.Commit("Update "+ws.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) - return err - } - creds := &http.BasicAuth{ - Username: config.Git.Username, - Password: config.Git.APIToken, + msg := fmt.Sprintf("Update %s @ %s", ws.Domain, time.Now().Format("Mon Jan _2 2006 15:04:05 MST")) + if _, err := ws.WorkTree.Commit(msg, &git.CommitOptions{Author: sig, Committer: sig}); err != nil { + return fmt.Errorf("commit: %w", err) } + err = ws.Repo.Push(&git.PushOptions{ - Auth: creds, + Auth: &http.BasicAuth{ + Username: config.Git.Username, + Password: config.Git.APIToken, + }, Force: true, RemoteName: "origin", - RefSpecs: []gitconf.RefSpec{ - "refs/heads/master:refs/heads/master", - }, + RefSpecs: []gitconf.RefSpec{gitconf.RefSpec("refs/heads/" + pushBranch + ":refs/heads/" + pushBranch)}, }) if err != nil { - fmt.Printf("Error pushing to origin: %v\n", err) - return err + return fmt.Errorf("push %s: %w", ws.URL, err) + } + return nil +} + +// stageCerts copies every *.crpt file in dataRoot into the workspace +// filesystem and adds it to the work tree. +func stageCerts(ws *common.GitWorkspace, dataRoot string) error { + entries, err := os.ReadDir(dataRoot) + if err != nil { + return fmt.Errorf("read %s: %w", dataRoot, err) + } + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".crpt") { + continue + } + body, err := os.ReadFile(filepath.Join(dataRoot, name)) + if err != nil { + return fmt.Errorf("read %s: %w", name, err) + } + if err := stageFile(ws, name, body); err != nil { + return fmt.Errorf("stage %s: %w", name, err) + } + } + return nil +} + +// stageFile writes body to name in the workspace filesystem and adds it to +// the work tree. It is the single point where workspace-relative paths are +// constructed, so Create and Add always agree on the path. +func stageFile(ws *common.GitWorkspace, name string, body []byte) error { + f, err := ws.FS.Create(name) + if err != nil { + return fmt.Errorf("create: %w", err) + } + if _, err := f.Write(body); err != nil { + f.Close() + return fmt.Errorf("write: %w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("close: %w", err) + } + if _, err := ws.WorkTree.Add(f.Name()); err != nil { + return fmt.Errorf("git add: %w", err) } - - fmt.Println("Successfully uploaded to " + config.Git.Server + "/" + config.Git.OrgName + "/" + ws.Domain + repoSuffix + ".git") - return nil }