Major Refactoring, Client can now be used as a library
Some checks failed
Build (artifact) / build (push) Failing after 1m3s

This commit is contained in:
2026-03-16 21:48:32 +01:00
parent e6a2ba2f8b
commit e0f68788c0
45 changed files with 1359 additions and 1245 deletions

618
server/acme_manager.go Normal file
View File

@@ -0,0 +1,618 @@
package server
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.nevets.tech/Keys/certman/common"
"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
// - Lets Encrypt production by default (from config fallback)
// - Cloudflare DNS-01 only
func NewACMEManager(config *common.AppConfig) (*ACMEManager, error) {
// Pull effective (main-only) certificate settings.
email := config.Certificates.Email
dataRoot := config.Certificates.DataRoot
caDirURL := config.Certificates.CADirURL
// 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(config)
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, config *common.AppConfig, domainConfig *common.DomainConfig) (*certificate.Resource, error) {
rcfg, err := buildDomainRuntimeConfig(config, domainConfig)
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 := common.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(config *common.AppConfig, domainConfig *common.DomainConfig) (*DomainRuntimeConfig, error) {
domainName := domainConfig.Domain.DomainName
email := config.Certificates.Email
// domain override data_root can be blank -> main fallback
var dataRoot string
if domainConfig.Certificates.DataRoot == "" {
dataRoot = config.Certificates.DataRoot
} else {
dataRoot = domainConfig.Certificates.DataRoot
}
caDirURL := config.Certificates.CADirURL
expiry := domainConfig.Certificates.Expiry
renewPeriod := domainConfig.Certificates.RenewPeriod
requestMethod := domainConfig.Certificates.RequestMethod
subdomainArray := domainConfig.Certificates.SubDomains
return &DomainRuntimeConfig{
DomainName: domainName,
Email: email,
DataRoot: dataRoot,
CADirURL: caDirURL,
ExpiryDays: expiry,
RenewPeriod: renewPeriod,
RequestMethod: requestMethod,
Subdomains: subdomainArray,
}, nil
}
// 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(config *common.AppConfig) (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 := config.Cloudflare.CFEmail
cfAPIKey := config.Cloudflare.CFAPIKey
// 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 := common.FileExists(accountJSON)
keyExists := common.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 := common.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 := common.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 := common.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
}

1
server/git.go Normal file
View File

@@ -0,0 +1 @@
package server

View File

@@ -1,167 +0,0 @@
package server
import (
"fmt"
"log"
"path/filepath"
"sync"
"time"
"git.nevets.tech/Keys/CertManager/internal"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5/storage/memory"
)
var (
tickMu sync.Mutex
mgr *internal.ACMEManager
mgrMu sync.Mutex
)
func getACMEManager() (*internal.ACMEManager, error) {
mgrMu.Lock()
defer mgrMu.Unlock()
if mgr == nil {
var err error
mgr, err = internal.NewACMEManager()
if err != nil {
return nil, err
}
}
return mgr, nil
}
func Init() {
err := internal.LoadDomainConfigs()
if err != nil {
log.Fatalf("Error loading domain configs: %v", err)
}
Tick()
}
func Tick() {
tickMu.Lock()
defer tickMu.Unlock()
fmt.Println("Tick!")
var err error
mgr, err = getACMEManager()
if err != nil {
fmt.Printf("Error getting acme manager: %v\n", err)
return
}
now := time.Now().UTC()
localDomainConfigs := internal.DomainStore().Snapshot()
for domainStr, domainConfig := range localDomainConfigs {
if !domainConfig.GetBool("Domain.enabled") {
continue
}
renewPeriod := domainConfig.GetInt("Certificates.renew_period")
lastIssued := time.Unix(domainConfig.GetInt64("Internal.last_issued"), 0).UTC()
renewalDue := lastIssued.AddDate(0, 0, renewPeriod)
if now.After(renewalDue) {
//TODO extra check if certificate expiry (create cache?)
_, 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
}
}
domainConfig.Set("Internal.last_issued", time.Now().UTC().Unix())
err = internal.WriteDomainConfig(domainConfig)
if err != nil {
fmt.Printf("Error saving domain config %s: %v\n", domainStr, err)
continue
}
err = internal.EncryptFileXChaCha(domainConfig.GetString("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 = internal.EncryptFileXChaCha(domainConfig.GetString("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 := internal.CreateGiteaClient()
if giteaClient == nil {
fmt.Printf("Error creating gitea client for domain %s: %v\n", domainStr, err)
continue
}
gitWorkspace := &internal.GitWorkspace{
Storage: memory.NewStorage(),
FS: memfs.New(),
}
var repoUrl string
if !domainConfig.GetBool("Internal.repo_exists") {
repoUrl = internal.CreateGiteaRepo(domainStr, giteaClient)
if repoUrl == "" {
fmt.Printf("Error creating Gitea repo for domain %s\n", domainStr)
continue
}
domainConfig.Set("Internal.repo_exists", true)
err = internal.WriteDomainConfig(domainConfig)
if err != nil {
fmt.Printf("Error saving domain config %s: %v\n", domainStr, err)
continue
}
err = internal.InitRepo(repoUrl, gitWorkspace)
if err != nil {
fmt.Printf("Error initializing repo for domain %s: %v\n", domainStr, err)
continue
}
} else {
repoUrl = internal.Config().GetString("Git.server") + "/" + internal.Config().GetString("Git.org_name") + "/" + domainStr + domainConfig.GetString("Repo.repo_suffix") + ".git"
err = internal.CloneRepo(repoUrl, gitWorkspace, internal.Server)
if err != nil {
fmt.Printf("Error cloning repo for domain %s: %v\n", domainStr, err)
continue
}
}
err = internal.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)
}
}
err = internal.SaveDomainConfigs()
if err != nil {
fmt.Printf("Error saving domain configs: %v\n", err)
}
}
func Reload() {
fmt.Println("Reloading configs...")
err := internal.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 Stop() {
fmt.Println("Shutting down server")
}