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