feat(ssl): TLS-Cert-Verwaltung in der GUI — Let's Encrypt + eigenes PEM
Backend: * internal/services/tlscerts/ — Repo (List/Get/Upsert/Delete/ GetByDomain/ListExpiringSoon/MarkError) gegen tls_certs-Tabelle. * internal/services/certstore/ — WriteCombined verifiziert cert/key match via tls.X509KeyPair, schreibt /etc/edgeguard/tls/<domain>.pem (HAProxy-format: cert + chain + key konkatenert). Parse extrahiert NotBefore/After/Issuer/SANs aus dem PEM. Domain-Charset-Whitelist gegen Path-Traversal beim Filename. 4 Tests (happy path, mismatched key, hostile filename, parse). * internal/services/acme/ — go-acme/lego v4 mit HTTP-01 über die bestehende /var/lib/edgeguard/acme-Webroot (HAProxy proxied dort schon hin). Account-Key persistent in /var/lib/edgeguard/acme- account/account.key, Registrierung lazy beim ersten Issue(). * internal/handlers/tlscerts.go — REST CRUD + /upload (custom PEM) + /issue (LE HTTP-01) auf /api/v1/tls-certs. Reload HAProxy via sudo nach jeder Mutation. Audit-Log pro Aktion. Frontend: * management-ui/src/pages/SSL/ — Tabs (Let's Encrypt / Eigenes Zertifikat) plus Tabelle aller installierten Zerts mit expires-in-Anzeige (orange ab <30 Tage, rot wenn abgelaufen) und Status-Tags. Sidebar-Eintrag, i18n de/en. * Networks-Form: Parent-Interface ist jetzt ein Select aus den System-Discovered-Interfaces statt freier Input — User-Wunsch. Packaging: * postinst legt /var/lib/edgeguard/acme-account/ 0700 an. * postinst installt /etc/sudoers.d/edgeguard mit NOPASSWD-Rule für systemctl reload haproxy.service — damit der edgeguard-User reloaden kann ohne root. Live deployed auf 89.163.205.6. /api/v1/tls-certs antwortet jetzt 401 ohne Cookie (Route registriert), POST /tls-certs/upload + /issue sind bereit. ACME-Issue gegen externe FQDN (utm-1.netcell-it.de) braucht nur noch die Domain, die im wizard schon angelegt ist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
239
internal/services/acme/acme.go
Normal file
239
internal/services/acme/acme.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user