Files
edgeguard-native/internal/services/acme/acme.go
Debian 32ab2c7f47 chore(lint): Backlog auf 0 + golangci-lint als HARTER Gate — v1.3.3
Go-Quality-Baseline-Rollout ABGESCHLOSSEN.

Code-Quality-Backlog (55 → 0):
- errcheck: unbehandelte Close/Rollback/Remove explizit `_ =`; fmt.Sscanf
  `_, _ =` (Zero-Value degradiert sauber).
- unused: toter Code entfernt (nodeIDOrHostname, stripTrailingNewline,
  acme.Service.user, strFold + ungenutzter Import).
- noctx (net/http): http.NewRequestWithContext mit vorhandenem ctx.
- staticcheck: QF1001/S1009/ST1005/SA9003.
- contextcheck: detached-by-design-Stellen mit begründetem //nolint.

Zwei echte Bugs beim Aufräumen gefunden+gefixt:
- backup/remote SFTP-Upload: dst.Close()-Flush-Fehler wurde verschluckt →
  unvollständiges Remote-File galt als Erfolg. Jetzt geprüft+gemeldet.
- haproxy_test: leere if-Assertion (SA9003) testete faktisch nichts →
  echte t.Errorf-Prüfung (kein HSTS für HSTS-disabled Domain).

Bewusste Config-Entscheidungen (.golangci.yml):
- noctx-on-os/exec ausgeschlossen: System-Command-Reloads (systemctl/nft/
  wg/pg) dürfen NICHT an den Request-Context gebunden werden — ein Client-
  Disconnect darf keinen laufenden Reload mitten in der Ausführung killen.
  net/http-noctx bleibt voll aktiv. KEINE exec-Zeile im Code angefasst.
- rowserrcheck/sqlclosecheck raus (database/sql-Linter, bei pgx nur FPs).

Gate scharf gestellt: Makefile release-check ruft golangci-lint jetzt als
HARTEN Gate (install-if-missing, pinned v2.12.2). `make release-check`
grün: vet, golangci-lint, govulncheck, build, test -race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:49:07 +02:00

237 lines
6.5 KiB
Go

// 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
}
// 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, &reg); err == nil {
user.Registration = &reg
}
}
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)
}