Major Refactoring, Client can now be used as a library
Some checks failed
Build (artifact) / build (push) Failing after 1m3s
Some checks failed
Build (artifact) / build (push) Failing after 1m3s
This commit is contained in:
618
server/acme_manager.go
Normal file
618
server/acme_manager.go
Normal 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
|
||||
// - Let’s 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
1
server/git.go
Normal file
@@ -0,0 +1 @@
|
||||
package server
|
||||
167
server/server.go
167
server/server.go
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user