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>
161 lines
4.8 KiB
Go
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
|
|
}
|