Files
edgeguard-native/internal/handlers/license.go
Debian 32ab2c7f47 chore(lint): Backlog auf 0 + golangci-lint als HARTER Gate — v1.3.3
Go-Quality-Baseline-Rollout ABGESCHLOSSEN.

Code-Quality-Backlog (55 → 0):
- errcheck: unbehandelte Close/Rollback/Remove explizit `_ =`; fmt.Sscanf
  `_, _ =` (Zero-Value degradiert sauber).
- unused: toter Code entfernt (nodeIDOrHostname, stripTrailingNewline,
  acme.Service.user, strFold + ungenutzter Import).
- noctx (net/http): http.NewRequestWithContext mit vorhandenem ctx.
- staticcheck: QF1001/S1009/ST1005/SA9003.
- contextcheck: detached-by-design-Stellen mit begründetem //nolint.

Zwei echte Bugs beim Aufräumen gefunden+gefixt:
- backup/remote SFTP-Upload: dst.Close()-Flush-Fehler wurde verschluckt →
  unvollständiges Remote-File galt als Erfolg. Jetzt geprüft+gemeldet.
- haproxy_test: leere if-Assertion (SA9003) testete faktisch nichts →
  echte t.Errorf-Prüfung (kein HSTS für HSTS-disabled Domain).

Bewusste Config-Entscheidungen (.golangci.yml):
- noctx-on-os/exec ausgeschlossen: System-Command-Reloads (systemctl/nft/
  wg/pg) dürfen NICHT an den Request-Context gebunden werden — ein Client-
  Disconnect darf keinen laufenden Reload mitten in der Ausführung killen.
  net/http-noctx bleibt voll aktiv. KEINE exec-Zeile im Code angefasst.
- rowserrcheck/sqlclosecheck raus (database/sql-Linter, bei pgx nur FPs).

Gate scharf gestellt: Makefile release-check ruft golangci-lint jetzt als
HARTEN Gate (install-if-missing, pinned v2.12.2). `make release-check`
grün: vet, golangci-lint, govulncheck, build, test -race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:49:07 +02:00

161 lines
4.8 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
)
// LicenseHandler exposes:
// GET /api/v1/license/status — current state from DB (cached)
// POST /api/v1/license/verify — force live verify against server
// PUT /api/v1/license/key — submit/replace license key + verify
// DELETE /api/v1/license/key — clear key, fall back to trial
type LicenseHandler struct {
Repo *licsvc.Repo
KeyStore *license.KeyStore
Client *license.Client
Audit *audit.Repo
NodeID string
}
func NewLicenseHandler(repo *licsvc.Repo, ks *license.KeyStore, client *license.Client,
a *audit.Repo, nodeID string) *LicenseHandler {
return &LicenseHandler{Repo: repo, KeyStore: ks, Client: client, Audit: a, NodeID: nodeID}
}
func (h *LicenseHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/license")
g.GET("/status", h.Status)
g.POST("/verify", h.Verify)
g.PUT("/key", h.SetKey)
g.DELETE("/key", h.ClearKey)
}
// Status returns the most recent verify result. If no row exists,
// reports trial (license.Result with Type=trial — the client computes
// trial expiry from the install-time).
func (h *LicenseHandler) Status(c *gin.Context) {
state, err := h.Repo.Get(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
if state == nil {
trial, terr := h.Client.Trial()
if terr != nil || trial == nil {
response.OK(c, gin.H{
"license_key": "",
"status": "expired",
"type": "trial",
"valid": false,
"reason": "trial expired",
})
return
}
response.OK(c, gin.H{
"license_key": "",
"status": trial.Status,
"type": trial.Type,
"expires_at": trial.ExpiresAt,
"valid": trial.Valid,
"reason": trial.Reason,
})
return
}
response.OK(c, state)
}
// Verify forces a live verify against the license server using the
// stored key. Useful for the "Re-verify"-button im UI.
func (h *LicenseHandler) Verify(c *gin.Context) {
key := h.KeyStore.Get()
if key == "" {
response.BadRequest(c, errors.New("no license key configured — use PUT /license/key first"))
return
}
res, err := h.runVerifyAndPersist(c.Request.Context(), key)
if err != nil {
response.Err(c, 502, err)
return
}
response.OK(c, res)
}
type setKeyReq struct {
LicenseKey string `json:"license_key" binding:"required"`
}
// SetKey writes the new key to disk + immediately verifies it. If
// verify fails, the key is still saved (operator can re-trigger
// later when the network is back) but the response carries the error.
func (h *LicenseHandler) SetKey(c *gin.Context) {
var req setKeyReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.KeyStore.Save(req.LicenseKey); err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "license.key.set", "license", nil, h.NodeID)
res, err := h.runVerifyAndPersist(c.Request.Context(), req.LicenseKey)
if err != nil {
// Save succeeded, verify failed — return both so the UI can
// surface the error without losing the saved-state info.
response.OK(c, gin.H{
"saved": true,
"verify_error": err.Error(),
})
return
}
response.OK(c, res)
}
// ClearKey wipes the key file + license row. Box falls back to trial.
func (h *LicenseHandler) ClearKey(c *gin.Context) {
prev := h.KeyStore.Get()
if err := h.KeyStore.Save(""); err != nil {
response.Internal(c, err)
return
}
if prev != "" {
_ = h.Repo.Delete(c.Request.Context(), prev)
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "license.key.clear", "license", nil, h.NodeID)
response.NoContent(c)
}
// runVerifyAndPersist performs the live verify call and mirrors the
// result into the licenses table. On error, marks last_error in DB
// (status stays as before — grace).
func (h *LicenseHandler) runVerifyAndPersist(ctx context.Context, key string) (*license.Result, error) {
res, err := h.Client.Verify(key) //nolint:contextcheck // detached by design — License-Verify nutzt eigenen HTTP-Timeout, überlebt Request-Cancel
if err != nil {
_ = h.Repo.MarkError(ctx, key, err.Error())
slog.Warn("license: verify failed", "error", err)
return nil, err
}
payload, _ := json.Marshal(res)
status := "active"
if !res.Valid {
status = "expired"
if res.Status == "revoked" {
status = "invalid"
}
}
if err := h.Repo.Upsert(ctx, key, status, res.ExpiresAt, h.NodeID, 0, payload, ""); err != nil {
slog.Warn("license: db upsert failed", "error", err)
}
return res, nil
}