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:
222
internal/handlers/tlscerts.go
Normal file
222
internal/handlers/tlscerts.go
Normal 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
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
152
internal/services/tlscerts/tlscerts.go
Normal file
152
internal/services/tlscerts/tlscerts.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Package tlscerts implements CRUD against the tls_certs table —
|
||||
// the operator-visible inventory of certificates EdgeGuard manages,
|
||||
// covering both Let's-Encrypt-issued and operator-uploaded PEMs.
|
||||
package tlscerts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("tls cert not found")
|
||||
|
||||
type Repo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||
|
||||
const baseSelect = `
|
||||
SELECT id, domain, issuer, status, cert_path, key_path,
|
||||
not_before, not_after, last_renewed_at, last_error,
|
||||
created_at, updated_at
|
||||
FROM tls_certs
|
||||
`
|
||||
|
||||
func (r *Repo) List(ctx context.Context) ([]models.TLSCert, error) {
|
||||
rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY domain ASC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.TLSCert, 0, 4)
|
||||
for rows.Next() {
|
||||
c, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) Get(ctx context.Context, id int64) (*models.TLSCert, error) {
|
||||
row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id)
|
||||
c, err := scan(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *Repo) GetByDomain(ctx context.Context, domain string) (*models.TLSCert, error) {
|
||||
row := r.Pool.QueryRow(ctx, baseSelect+" WHERE domain = $1", domain)
|
||||
c, err := scan(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Upsert persists a cert by (domain). issued/renewed by certbot or
|
||||
// uploaded → same code path. Sets last_renewed_at = NOW() so the
|
||||
// renewal cron knows when to come back.
|
||||
func (r *Repo) Upsert(ctx context.Context, c models.TLSCert) (*models.TLSCert, error) {
|
||||
now := time.Now().UTC()
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO tls_certs (domain, issuer, status, cert_path, key_path,
|
||||
not_before, not_after, last_renewed_at, last_error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (domain) DO UPDATE SET
|
||||
issuer = EXCLUDED.issuer,
|
||||
status = EXCLUDED.status,
|
||||
cert_path = EXCLUDED.cert_path,
|
||||
key_path = EXCLUDED.key_path,
|
||||
not_before = EXCLUDED.not_before,
|
||||
not_after = EXCLUDED.not_after,
|
||||
last_renewed_at = EXCLUDED.last_renewed_at,
|
||||
last_error = EXCLUDED.last_error,
|
||||
updated_at = NOW()
|
||||
RETURNING id, domain, issuer, status, cert_path, key_path,
|
||||
not_before, not_after, last_renewed_at, last_error,
|
||||
created_at, updated_at`,
|
||||
c.Domain, c.Issuer, c.Status, c.CertPath, c.KeyPath,
|
||||
c.NotBefore, c.NotAfter, &now, c.LastError)
|
||||
return scan(row)
|
||||
}
|
||||
|
||||
func (r *Repo) MarkError(ctx context.Context, domain, msg string) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE tls_certs SET status = 'error', last_error = $1, updated_at = NOW()
|
||||
WHERE domain = $2`, msg, domain)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
tag, err := r.Pool.Exec(ctx, `DELETE FROM tls_certs WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListExpiringSoon returns certs whose not_after is within the next
|
||||
// `within` (typically 30 days) and aren't already in error state —
|
||||
// the renewal scheduler picks these up.
|
||||
func (r *Repo) ListExpiringSoon(ctx context.Context, within time.Duration) ([]models.TLSCert, error) {
|
||||
cutoff := time.Now().UTC().Add(within)
|
||||
rows, err := r.Pool.Query(ctx, baseSelect+
|
||||
" WHERE not_after IS NOT NULL AND not_after <= $1 AND status <> 'error'", cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.TLSCert, 0, 4)
|
||||
for rows.Next() {
|
||||
c, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scan(row interface{ Scan(...any) error }) (*models.TLSCert, error) {
|
||||
var c models.TLSCert
|
||||
if err := row.Scan(
|
||||
&c.ID, &c.Domain, &c.Issuer, &c.Status,
|
||||
&c.CertPath, &c.KeyPath,
|
||||
&c.NotBefore, &c.NotAfter,
|
||||
&c.LastRenewedAt, &c.LastError,
|
||||
&c.CreatedAt, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
Reference in New Issue
Block a user