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/.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