Files
Debian e096531df2 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>
2026-05-09 21:49:14 +02:00

121 lines
3.6 KiB
Go

// Package certstore writes the combined PEM (cert + chain + key)
// HAProxy expects under /etc/edgeguard/tls/<domain>.pem and
// validates the cert against its private key.
//
// Layout:
//
// /etc/edgeguard/tls/<domain>.pem — operator-provided or
// Let's-Encrypt-issued
// /etc/edgeguard/tls/_default.pem — self-signed fallback
// (postinst-generated)
package certstore
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// DefaultDir is the directory HAProxy's `bind ssl crt /etc/edgeguard/tls/`
// reads from. Override only in tests.
const DefaultDir = "/etc/edgeguard/tls"
// CertInfo is the parsed metadata callers want to persist.
type CertInfo struct {
NotBefore time.Time
NotAfter time.Time
Issuer string
Subject string
DNSNames []string
}
// WriteCombined writes <dir>/<domain>.pem with the cert chain
// followed by the private key — the format HAProxy `crt` consumes.
// Validates the cert/key match before writing so a bad upload can't
// brick the running HAProxy on next reload.
func WriteCombined(dir, domain, certPEM, chainPEM, keyPEM string) (string, error) {
if domain == "" {
return "", errors.New("domain required")
}
if certPEM == "" || keyPEM == "" {
return "", errors.New("cert and key are required")
}
// Validate that the key matches the cert. tls.X509KeyPair does
// the heavy lifting (PEM parse + private-key/public-key match).
if _, err := tls.X509KeyPair([]byte(certPEM+"\n"+chainPEM), []byte(keyPEM)); err != nil {
return "", fmt.Errorf("cert/key mismatch: %w", err)
}
// Compose the file: cert, optional chain, then key. Each section
// is normalised to end in a newline so concatenation produces a
// well-formed PEM bundle.
var sb strings.Builder
sb.WriteString(strings.TrimRight(certPEM, "\n"))
sb.WriteString("\n")
if chain := strings.TrimRight(chainPEM, "\n"); chain != "" {
sb.WriteString(chain)
sb.WriteString("\n")
}
sb.WriteString(strings.TrimRight(keyPEM, "\n"))
sb.WriteString("\n")
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", fmt.Errorf("mkdir %s: %w", dir, err)
}
if !safeDomain(domain) {
return "", fmt.Errorf("domain %q contains characters that aren't safe for a filename", domain)
}
out := filepath.Join(dir, domain+".pem")
if err := os.WriteFile(out, []byte(sb.String()), 0o640); err != nil {
return "", fmt.Errorf("write %s: %w", out, err)
}
return out, nil
}
// Parse pulls Subject/Issuer/NotBefore/NotAfter/SANs out of a PEM
// cert block. Useful for handlers that want to surface the metadata
// without keeping the raw PEM on the wire.
func Parse(certPEM string) (*CertInfo, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return nil, errors.New("no PEM block in certificate input")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
return &CertInfo{
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
Issuer: cert.Issuer.String(),
Subject: cert.Subject.String(),
DNSNames: cert.DNSNames,
}, nil
}
// safeDomain rejects names that aren't shaped like a DNS hostname.
// We're going to use this as a filename component — anything outside
// the conservative DNS charset is refused.
func safeDomain(s string) bool {
if s == "" || strings.ContainsAny(s, "/\\\x00 \t\n") {
return false
}
for _, r := range s {
ok := r == '-' || r == '.' || r == '_' ||
(r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z')
if !ok {
return false
}
}
return true
}