Files
Debian 62505d547c feat(license): Lizenz-System mit Ed25519-Verify gegen license.netcell-it.com
Portiert mail-gateway/internal/license (Verify, Cache, Trial, Signature)
+ DB-Mirror (internal/services/license) + REST-Handler (status/verify/key/clear)
+ UI-Page /license (Activate, Status, Limits, Features, Re-verify)
+ <LicenseBanner /> neben UpdateBanner (trial-expiring, expired, verify-failed)
+ Scheduler: täglich Re-verify (24h-Tick)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:41:16 +02:00

161 lines
4.7 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)
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
}