// Package acme wraps github.com/go-acme/lego with EdgeGuard's // HTTP-01-via-shared-webroot setup. // // The webroot is /var/lib/edgeguard/acme — HAProxy already proxies // /.well-known/acme-challenge/* to edgeguard-api which serves files // from that directory (see internal/handlers/acme.go). Lego writes // challenge tokens there; the existing handler answers from disk. // // Account state (the ACME-account private key + URL) lives in // /var/lib/edgeguard/acme-account/account.key + account.json so a // renewal doesn't need to re-register. package acme import ( "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/json" "encoding/pem" "errors" "fmt" "os" "path/filepath" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/providers/http/webroot" "github.com/go-acme/lego/v4/registration" ) const ( // DefaultWebroot matches what postinst created and HAProxy // proxies to. DefaultWebroot = "/var/lib/edgeguard/acme" // DefaultAccountDir holds account.key + account.json. Mode 0700 // — the account key is a pseudo-credential. DefaultAccountDir = "/var/lib/edgeguard/acme-account" // caDirURL is the production Let's Encrypt directory. Tests // override via NewService's dirURL parameter. caDirURL = "https://acme-v02.api.letsencrypt.org/directory" ) // Service issues + renews certs against Let's Encrypt. One Service // per running edgeguard-api; lazily registers the account on first // Issue() call. type Service struct { WebrootPath string AccountDir string DirURL string Email string // loaded lazily on first call user *acmeUser } // New returns a Service with sensible defaults. Email comes from // setup.json's acme_email; the caller plumbs it through main.go. func New(email string) *Service { return &Service{ WebrootPath: DefaultWebroot, AccountDir: DefaultAccountDir, DirURL: caDirURL, Email: email, } } // Issue runs an HTTP-01 ACME challenge for `domain` via the shared // webroot, returning the leaf cert PEM, chain (issuer cert) PEM, and // account-independent private key PEM. func (s *Service) Issue(domain string) (certPEM, chainPEM, keyPEM string, err error) { if domain == "" { return "", "", "", errors.New("domain required") } if s.Email == "" { return "", "", "", errors.New("acme: email not configured (set via setup wizard)") } user, err := s.loadOrRegisterAccount() if err != nil { return "", "", "", fmt.Errorf("acme: account: %w", err) } cfg := lego.NewConfig(user) cfg.CADirURL = s.DirURL client, err := lego.NewClient(cfg) if err != nil { return "", "", "", fmt.Errorf("acme: client: %w", err) } provider, err := webroot.NewHTTPProvider(s.WebrootPath) if err != nil { return "", "", "", fmt.Errorf("acme: webroot provider: %w", err) } if err := client.Challenge.SetHTTP01Provider(provider); err != nil { return "", "", "", fmt.Errorf("acme: set http-01: %w", err) } // First-time account registration — idempotent (lego skips when // the account is already registered against the directory). if user.Registration == nil { reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) if err != nil { return "", "", "", fmt.Errorf("acme: register: %w", err) } user.Registration = reg if err := s.saveAccount(user); err != nil { return "", "", "", fmt.Errorf("acme: save account: %w", err) } } req := certificate.ObtainRequest{ Domains: []string{domain}, Bundle: true, } res, err := client.Certificate.Obtain(req) if err != nil { return "", "", "", fmt.Errorf("acme: obtain: %w", err) } // res.Certificate is the leaf+chain bundle (because Bundle=true). // The frontend wants leaf separated from chain so it can store // fields cleanly — split on the second BEGIN CERTIFICATE marker. leaf, chain := splitBundle(string(res.Certificate)) return leaf, chain, string(res.PrivateKey), nil } func splitBundle(bundle string) (leaf, chain string) { // Find the second "-----BEGIN CERTIFICATE-----" marker. Everything // before it is the leaf, after (incl. marker) is the chain. const marker = "-----BEGIN CERTIFICATE-----" first := indexOfNth(bundle, marker, 1) second := indexOfNth(bundle, marker, 2) if first < 0 { return bundle, "" } if second < 0 { return bundle, "" } return bundle[:second], bundle[second:] } func indexOfNth(s, sub string, n int) int { idx := -1 pos := 0 for i := 0; i < n; i++ { j := indexAfter(s, sub, pos) if j < 0 { return -1 } idx = j pos = j + len(sub) } return idx } func indexAfter(s, sub string, from int) int { if from >= len(s) { return -1 } rel := -1 for i := from; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { rel = i break } } return rel } // acmeUser implements lego's registration.User interface against // our on-disk account state. type acmeUser struct { Email string Registration *registration.Resource key crypto.PrivateKey } func (u *acmeUser) GetEmail() string { return u.Email } func (u *acmeUser) GetRegistration() *registration.Resource { return u.Registration } func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key } func (s *Service) loadOrRegisterAccount() (*acmeUser, error) { if err := os.MkdirAll(s.AccountDir, 0o700); err != nil { return nil, err } keyPath := filepath.Join(s.AccountDir, "account.key") regPath := filepath.Join(s.AccountDir, "account.json") user := &acmeUser{Email: s.Email} if b, err := os.ReadFile(keyPath); err == nil { block, _ := pem.Decode(b) if block == nil { return nil, errors.New("acme: account.key has no PEM block") } k, err := x509.ParseECPrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("acme: parse account.key: %w", err) } user.key = k } else { k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err } der, err := x509.MarshalECPrivateKey(k) if err != nil { return nil, err } buf := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) if err := os.WriteFile(keyPath, buf, 0o600); err != nil { return nil, err } user.key = k } if b, err := os.ReadFile(regPath); err == nil { var reg registration.Resource if err := json.Unmarshal(b, ®); err == nil { user.Registration = ® } } return user, nil } func (s *Service) saveAccount(u *acmeUser) error { if u.Registration == nil { return nil } b, err := json.MarshalIndent(u.Registration, "", " ") if err != nil { return err } return os.WriteFile(filepath.Join(s.AccountDir, "account.json"), b, 0o600) }