Files
edgeguard-native/internal/services/certstore/certstore_test.go
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

110 lines
2.9 KiB
Go

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
}