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:
120
internal/services/certstore/certstore.go
Normal file
120
internal/services/certstore/certstore.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// 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
|
||||
}
|
||||
109
internal/services/certstore/certstore_test.go
Normal file
109
internal/services/certstore/certstore_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package certstore
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// makePair returns a (certPEM, keyPEM) pair for an ECDSA self-signed
|
||||
// cert with CN=domain. Used by the tests to build valid input that
|
||||
// X509KeyPair can verify.
|
||||
func makePair(t *testing.T, domain string) (string, string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: domain},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{domain},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func TestWriteCombined_HappyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPEM, keyPEM := makePair(t, "example.com")
|
||||
path, err := WriteCombined(dir, "example.com", certPEM, "", keyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteCombined: %v", err)
|
||||
}
|
||||
if path != filepath.Join(dir, "example.com.pem") {
|
||||
t.Errorf("path: %s", path)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), "BEGIN CERTIFICATE") {
|
||||
t.Errorf("expected cert in output")
|
||||
}
|
||||
if !strings.Contains(string(body), "BEGIN EC PRIVATE KEY") {
|
||||
t.Errorf("expected key in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCombined_RejectsMismatchedKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPEM, _ := makePair(t, "example.com")
|
||||
_, otherKey := makePair(t, "example.com")
|
||||
if _, err := WriteCombined(dir, "example.com", certPEM, "", otherKey); err == nil {
|
||||
t.Errorf("expected error for mismatched key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCombined_RejectsHostileDomain(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPEM, keyPEM := makePair(t, "example.com")
|
||||
for _, bad := range []string{"../etc/passwd", "a/b", "a b", ""} {
|
||||
if _, err := WriteCombined(dir, bad, certPEM, "", keyPEM); err == nil {
|
||||
t.Errorf("WriteCombined should reject %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_ExtractsMetadata(t *testing.T) {
|
||||
certPEM, _ := makePair(t, "example.com")
|
||||
info, err := Parse(certPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if info.NotBefore.IsZero() || info.NotAfter.IsZero() {
|
||||
t.Errorf("missing NotBefore/After")
|
||||
}
|
||||
if !contains(info.DNSNames, "example.com") {
|
||||
t.Errorf("expected DNSNames to contain example.com, got %v", info.DNSNames)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user