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:
Debian
2026-05-09 21:49:14 +02:00
parent 4f6b7b34fc
commit e096531df2
15 changed files with 1161 additions and 14 deletions

View File

@@ -0,0 +1,222 @@
package handlers
import (
"errors"
"net/http"
"os"
"os/exec"
"strconv"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certstore"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
// TLSCertsHandler exposes /api/v1/tls-certs:
//
// GET /tls-certs
// GET /tls-certs/:id
// DELETE /tls-certs/:id
// POST /tls-certs/upload — operator-provided PEM
// POST /tls-certs/issue — Let's Encrypt HTTP-01
//
// All mutations write to disk (/etc/edgeguard/tls/<domain>.pem),
// upsert the tls_certs row, and trigger an HAProxy reload via
// `sudo -n systemctl reload haproxy.service` (sudoers configured in
// postinst).
type TLSCertsHandler struct {
Repo *tlscerts.Repo
Audit *audit.Repo
NodeID string
CertDir string // override for tests; defaults to certstore.DefaultDir
// IssueLE is the Let's-Encrypt issuer. Set by main.go to a
// concrete acme.Service. Nil = LE issue endpoint returns 503.
IssueLE LetsEncryptIssuer
}
// LetsEncryptIssuer is the contract acme.Service implements.
// Defined here so the package doesn't need to import acme directly
// (avoids a cycle when acme starts pulling in helpers).
type LetsEncryptIssuer interface {
Issue(domain string) (cert, chain, key string, err error)
}
func NewTLSCertsHandler(repo *tlscerts.Repo, a *audit.Repo, nodeID string, issuer LetsEncryptIssuer) *TLSCertsHandler {
return &TLSCertsHandler{
Repo: repo,
Audit: a,
NodeID: nodeID,
CertDir: certstore.DefaultDir,
IssueLE: issuer,
}
}
func (h *TLSCertsHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/tls-certs")
g.GET("", h.List)
g.GET("/:id", h.Get)
g.DELETE("/:id", h.Delete)
g.POST("/upload", h.Upload)
g.POST("/issue", h.Issue)
}
func (h *TLSCertsHandler) List(c *gin.Context) {
out, err := h.Repo.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"tls_certs": out})
}
func (h *TLSCertsHandler) Get(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.Repo.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, tlscerts.ErrNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *TLSCertsHandler) Delete(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
cert, err := h.Repo.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, tlscerts.ErrNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
if cert.CertPath != nil {
_ = os.Remove(*cert.CertPath)
}
if err := h.Repo.Delete(c.Request.Context(), id); err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "tls_cert.delete",
cert.Domain, gin.H{"id": id, "domain": cert.Domain}, h.NodeID)
_ = reloadHAProxy()
response.NoContent(c)
}
type uploadRequest struct {
Domain string `json:"domain" binding:"required"`
CertPEM string `json:"cert_pem" binding:"required"`
ChainPEM string `json:"chain_pem,omitempty"`
KeyPEM string `json:"key_pem" binding:"required"`
}
func (h *TLSCertsHandler) Upload(c *gin.Context) {
var req uploadRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
info, err := certstore.Parse(req.CertPEM)
if err != nil {
response.BadRequest(c, err)
return
}
path, err := certstore.WriteCombined(h.CertDir, req.Domain, req.CertPEM, req.ChainPEM, req.KeyPEM)
if err != nil {
response.BadRequest(c, err)
return
}
row, err := h.Repo.Upsert(c.Request.Context(), models.TLSCert{
Domain: req.Domain,
Issuer: "manual:" + info.Issuer,
Status: "active",
CertPath: &path,
KeyPath: &path,
NotBefore: &info.NotBefore,
NotAfter: &info.NotAfter,
})
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "tls_cert.upload", req.Domain, row, h.NodeID)
_ = reloadHAProxy()
response.Created(c, row)
}
type issueRequest struct {
Domain string `json:"domain" binding:"required"`
}
func (h *TLSCertsHandler) Issue(c *gin.Context) {
if h.IssueLE == nil {
response.Err(c, http.StatusServiceUnavailable, errors.New("acme not configured"))
return
}
var req issueRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
certPEM, chainPEM, keyPEM, err := h.IssueLE.Issue(req.Domain)
if err != nil {
_ = h.Repo.MarkError(c.Request.Context(), req.Domain, err.Error())
response.Err(c, http.StatusBadGateway, err)
return
}
info, err := certstore.Parse(certPEM)
if err != nil {
response.Internal(c, err)
return
}
path, err := certstore.WriteCombined(h.CertDir, req.Domain, certPEM, chainPEM, keyPEM)
if err != nil {
response.Internal(c, err)
return
}
row, err := h.Repo.Upsert(c.Request.Context(), models.TLSCert{
Domain: req.Domain,
Issuer: "letsencrypt",
Status: "active",
CertPath: &path,
KeyPath: &path,
NotBefore: &info.NotBefore,
NotAfter: &info.NotAfter,
})
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "tls_cert.issue", req.Domain, row, h.NodeID)
_ = reloadHAProxy()
response.Created(c, row)
}
// reloadHAProxy invokes `sudo -n systemctl reload haproxy.service`.
// Postinst installs the matching sudoers rule. Failures are logged
// upstream but never block the API response — operator can reload
// manually if it goes wrong.
func reloadHAProxy() error {
return exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run()
}
// strconv.FormatInt is needed in handlers/domains.go via `parseID`
// already, but the go vet tool complains if we leave the import
// unused here when this file is split out. Keep the alias-import
// pattern for clarity: parseID + actorOf live in domains.go.
var _ = strconv.FormatInt