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>
This commit is contained in:
160
internal/handlers/license.go
Normal file
160
internal/handlers/license.go
Normal file
@@ -0,0 +1,160 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user