package client import ( "errors" "fmt" "io" "os" "path/filepath" "strings" "git.nevets.tech/Steven/certman/common" ) // 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("read workspace root: %w", err) } 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++ } 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 { entries, err := os.ReadDir(certPath) if err != nil { return fmt.Errorf("read %s: %w", certPath, err) } var errs []error for _, entry := range entries { name := entry.Name() if !strings.HasSuffix(name, ".crpt") { continue } plainName, _ := strings.CutSuffix(name, ".crpt") 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 }