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:
@@ -22,6 +22,8 @@ import (
|
|||||||
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
|
||||||
|
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
|
||||||
chronyrender "git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
chronyrender "git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
||||||
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
||||||
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
|
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
|
||||||
@@ -45,7 +47,7 @@ import (
|
|||||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.46"
|
var version = "1.0.47"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
@@ -234,6 +236,19 @@ func main() {
|
|||||||
return chronyrender.New(pool).Render(ctx)
|
return chronyrender.New(pool).Render(ctx)
|
||||||
}
|
}
|
||||||
handlers.NewNTPHandler(ntpRepo, auditRepo, nodeID, withFW(chronyReloader)).Register(authed)
|
handlers.NewNTPHandler(ntpRepo, auditRepo, nodeID, withFW(chronyReloader)).Register(authed)
|
||||||
|
|
||||||
|
// License — node-local key store + DB-mirror of last verify
|
||||||
|
// result. Real verify runs against license.netcell-it.com via
|
||||||
|
// internal/license; the scheduler triggers daily re-verify.
|
||||||
|
licRepo := licsvc.New(pool)
|
||||||
|
licClient := license.NewClient()
|
||||||
|
licKeyStore := license.NewKeyStore()
|
||||||
|
handlers.NewLicenseHandler(licRepo, licKeyStore, licClient, auditRepo, nodeID).Register(authed)
|
||||||
|
// Kick off periodic re-verify in this process so a long-running
|
||||||
|
// api answers /license/status with fresh data even without the
|
||||||
|
// scheduler. StartPeriodicVerification is a no-op when the key
|
||||||
|
// is empty.
|
||||||
|
licClient.StartPeriodicVerification(licKeyStore.Get())
|
||||||
}
|
}
|
||||||
|
|
||||||
mountUI(r)
|
mountUI(r)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.46"
|
var version = "1.0.47"
|
||||||
|
|
||||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,21 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer"
|
||||||
|
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.46"
|
var version = "1.0.47"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
@@ -32,6 +35,10 @@ const (
|
|||||||
// certDir matches handlers.NewTLSCertsHandler default — HAProxy
|
// certDir matches handlers.NewTLSCertsHandler default — HAProxy
|
||||||
// reads from this directory.
|
// reads from this directory.
|
||||||
certDir = "/etc/edgeguard/tls"
|
certDir = "/etc/edgeguard/tls"
|
||||||
|
|
||||||
|
// licenseTickInterval — daily re-verify against
|
||||||
|
// license.netcell-it.com. Result lands in the licenses table.
|
||||||
|
licenseTickInterval = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -61,18 +68,65 @@ func main() {
|
|||||||
slog.Warn("scheduler: setup.acme_email empty — ACME renewal disabled until setup wizard ran")
|
slog.Warn("scheduler: setup.acme_email empty — ACME renewal disabled until setup wizard ran")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
licRepo := licsvc.New(pool)
|
||||||
|
licClient := license.NewClient()
|
||||||
|
licKeyStore := license.NewKeyStore()
|
||||||
|
nodeID := os.Getenv("EDGEGUARD_NODE_ID")
|
||||||
|
slog.Info("scheduler: license re-verify enabled", "tick", licenseTickInterval)
|
||||||
|
|
||||||
if renewer != nil {
|
if renewer != nil {
|
||||||
runRenewer(ctx, renewer)
|
runRenewer(ctx, renewer)
|
||||||
}
|
}
|
||||||
tick := time.NewTicker(renewTickInterval)
|
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID)
|
||||||
defer tick.Stop()
|
|
||||||
for range tick.C {
|
renewTick := time.NewTicker(renewTickInterval)
|
||||||
if renewer != nil {
|
defer renewTick.Stop()
|
||||||
runRenewer(ctx, renewer)
|
licTick := time.NewTicker(licenseTickInterval)
|
||||||
|
defer licTick.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-renewTick.C:
|
||||||
|
if renewer != nil {
|
||||||
|
runRenewer(ctx, renewer)
|
||||||
|
}
|
||||||
|
case <-licTick.C:
|
||||||
|
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runLicenseVerify performs a single re-verify pass. Empty key = no-op
|
||||||
|
// (box stays in trial), so this is safe to call on every tick.
|
||||||
|
func runLicenseVerify(ctx context.Context, c *license.Client, ks *license.KeyStore,
|
||||||
|
repo *licsvc.Repo, nodeID string) {
|
||||||
|
key := ks.Get()
|
||||||
|
if key == "" {
|
||||||
|
slog.Debug("scheduler: license verify skipped — no key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := c.Verify(key)
|
||||||
|
if err != nil {
|
||||||
|
_ = repo.MarkError(ctx, key, err.Error())
|
||||||
|
slog.Warn("scheduler: license verify failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(res)
|
||||||
|
status := "active"
|
||||||
|
if !res.Valid {
|
||||||
|
status = "expired"
|
||||||
|
if res.Status == "revoked" {
|
||||||
|
status = "invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := repo.Upsert(ctx, key, status, res.ExpiresAt, nodeID, 0, payload, ""); err != nil {
|
||||||
|
slog.Warn("scheduler: license db upsert failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("scheduler: license verified",
|
||||||
|
"status", status, "valid", res.Valid, "expires_at", res.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
func runRenewer(ctx context.Context, r *certrenewer.Service) {
|
func runRenewer(ctx context.Context, r *certrenewer.Service) {
|
||||||
res, err := r.Run(ctx)
|
res, err := r.Run(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
5
internal/license/doc.go
Normal file
5
internal/license/doc.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Package license implements the NetCell Licensing client (license.netcell-it.com)
|
||||||
|
// with Ed25519 signature verification, KeyDB-lock based leader election (one
|
||||||
|
// verify per cluster/day), shared cache in KeyDB, and a 7-day trial fallback.
|
||||||
|
// Pattern adopted wholesale from netcell-webpanel/docs/licensing-integration.md.
|
||||||
|
package license
|
||||||
78
internal/license/keystore.go
Normal file
78
internal/license/keystore.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyStore holds the per-node license key on disk. Every node in a
|
||||||
|
// cluster runs its own verification against license.netcell-it.com
|
||||||
|
// with its own fingerprint + key, so the file lives under
|
||||||
|
// /var/lib/edgeguard/ (node-local, never replicated via the config
|
||||||
|
// fan-out).
|
||||||
|
//
|
||||||
|
// Shape on disk: a single-line text file, leading/trailing whitespace
|
||||||
|
// stripped. Mode 0600, owner nmg so a compromised read of a
|
||||||
|
// checked-out working copy never leaks the key.
|
||||||
|
type KeyStore struct {
|
||||||
|
Path string
|
||||||
|
|
||||||
|
// cached holds the last-loaded key so Get() is cheap; Reload()
|
||||||
|
// flushes it.
|
||||||
|
cached atomic.Value // string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultKeyStorePath = "/var/lib/edgeguard/license_key"
|
||||||
|
|
||||||
|
// NewKeyStore returns a store writing to /var/lib/edgeguard/license_key.
|
||||||
|
func NewKeyStore() *KeyStore { return &KeyStore{Path: DefaultKeyStorePath} }
|
||||||
|
|
||||||
|
// Get returns the currently persisted key, or "" when the file is
|
||||||
|
// absent / empty. Never returns an error so callers can happily fall
|
||||||
|
// through to the trial-mode path.
|
||||||
|
func (s *KeyStore) Get() string {
|
||||||
|
if v, ok := s.cached.Load().(string); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(s.Path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
k := strings.TrimSpace(string(data))
|
||||||
|
s.cached.Store(k)
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the key atomically (tmp + rename) with mode 0600 and
|
||||||
|
// primes the in-memory cache. Empty key removes the file — that's
|
||||||
|
// how an admin returns the node to trial mode.
|
||||||
|
func (s *KeyStore) Save(key string) error {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
dir := filepath.Dir(s.Path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
if err := os.Remove(s.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.cached.Store("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tmp := s.Path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, []byte(key+"\n"), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, s.Path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.cached.Store(key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
391
internal/license/license.go
Normal file
391
internal/license/license.go
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
// Package license implements the NetCell MailGuard client for the NetCell
|
||||||
|
// Licensing server (license.netcell-it.com). It verifies a license key
|
||||||
|
// with Ed25519-signed responses, caches the result on disk (24 h TTL),
|
||||||
|
// falls back to a 30-day offline trial when no key is configured, and
|
||||||
|
// protects against clock manipulation via ServerTime drift checks.
|
||||||
|
//
|
||||||
|
// Pattern wholesale adopted from netcell-webpanel/management-api/internal/
|
||||||
|
// middleware/license.go; edgeguard uses `active_domains` (operator-defined domains) as the usage
|
||||||
|
// counter sent to the licensing server (the number of mail domains this
|
||||||
|
// cluster serves) instead of the webpanel's active_sites/active_servers.
|
||||||
|
//
|
||||||
|
// Cluster semantics: only one peer per cluster contacts license.netcell-it.com
|
||||||
|
// at a time — that peer is selected via a KeyDB lock (cluster:license-leader,
|
||||||
|
// 60-s TTL). The result ends up in KeyDB key cluster:license-status so every
|
||||||
|
// peer can read it locally. Leader election lives in internal/cluster; this
|
||||||
|
// package implements the verification + caching mechanics.
|
||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tunables. Kept package-private constants; override via internal/license/testing.go in tests.
|
||||||
|
const (
|
||||||
|
DefaultServerURL = "https://license.netcell-it.com"
|
||||||
|
DefaultSelfServiceURL = "https://license.netcell-it.com/self-service"
|
||||||
|
|
||||||
|
DefaultCacheDir = "/var/lib/edgeguard"
|
||||||
|
defaultCacheFile = "license.cache"
|
||||||
|
defaultTrialFile = "trial.json"
|
||||||
|
defaultHTTPTimeout = 10 * time.Second
|
||||||
|
CacheMaxAge = 24 * time.Hour
|
||||||
|
VerifyInterval = 24 * time.Hour
|
||||||
|
TrialDuration = 30 * 24 * time.Hour
|
||||||
|
maxClockDrift = 48 * time.Hour
|
||||||
|
maxResponseBytes = 64 * 1024
|
||||||
|
|
||||||
|
// GracePeriod: wenn der Server eine valid:false-Antwort liefert,
|
||||||
|
// behält der Client den letzten valid:true-Cache so lange bei,
|
||||||
|
// solange der nicht älter als GracePeriod ist. Schützt vor
|
||||||
|
// transienten Server-Bugs (z.B. activation_limit_exceeded falsch
|
||||||
|
// gezählt) und kurzen Wartungsfenstern. Der User-sichtbare Effekt:
|
||||||
|
// keine spontane „Lizenz fehlt"-UI-Zustand mid-Session.
|
||||||
|
// Override per Env EDGEGUARD_LICENSE_GRACE_DAYS.
|
||||||
|
GracePeriod = 7 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is the license server response plus a few internal fields.
|
||||||
|
// JSON tags match the wire format from license.netcell-it.com.
|
||||||
|
type Result struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"` // license | trial
|
||||||
|
Status string `json:"status,omitempty"` // active | expired | revoked
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
GracePeriod bool `json:"grace_period,omitempty"`
|
||||||
|
Features map[string]bool `json:"features,omitempty"`
|
||||||
|
Limits map[string]int64 `json:"limits,omitempty"`
|
||||||
|
ServerTime *time.Time `json:"server_time,omitempty"` // authoritative timestamp (signed)
|
||||||
|
CachedAt *time.Time `json:"cached_at,omitempty"` // set when loaded from local cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature returns true if the given feature flag is enabled in this license.
|
||||||
|
func (r *Result) Feature(name string) bool { return r.Features[name] }
|
||||||
|
|
||||||
|
// Limit returns the numeric limit for a given key, or 0 if not set.
|
||||||
|
func (r *Result) Limit(name string) int64 { return r.Limits[name] }
|
||||||
|
|
||||||
|
// ActiveDomainsFn yields the mail-domain count for usage reporting
|
||||||
|
// to the license server. Optional — nil means send 0.
|
||||||
|
type ActiveDomainsFn func() int64
|
||||||
|
|
||||||
|
// Client performs verification calls. Construct with NewClient.
|
||||||
|
type Client struct {
|
||||||
|
ServerURL string
|
||||||
|
CacheDir string
|
||||||
|
HTTPTimeout time.Duration
|
||||||
|
ActiveDomains ActiveDomainsFn
|
||||||
|
HTTPClient *http.Client
|
||||||
|
SignatureKeys []SignatureKey // verifies the X-Signature header
|
||||||
|
ClockNow func() time.Time
|
||||||
|
OSHostname func() (string, error)
|
||||||
|
ReadMachineID func() ([]byte, error)
|
||||||
|
InterfacesFn func() ([]net.Interface, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient returns a client with production defaults.
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
ServerURL: DefaultServerURL,
|
||||||
|
CacheDir: DefaultCacheDir,
|
||||||
|
HTTPTimeout: defaultHTTPTimeout,
|
||||||
|
HTTPClient: &http.Client{Timeout: defaultHTTPTimeout},
|
||||||
|
SignatureKeys: DefaultSigningKeys(),
|
||||||
|
ClockNow: func() time.Time { return time.Now().UTC() },
|
||||||
|
OSHostname: os.Hostname,
|
||||||
|
ReadMachineID: func() ([]byte, error) { return os.ReadFile("/etc/machine-id") },
|
||||||
|
InterfacesFn: net.Interfaces,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify performs a live check against the license server. Non-nil Result
|
||||||
|
// means we got a valid, signed response. On network failure returns an error;
|
||||||
|
// callers should fall back to the cache (LoadCache) or to Trial (see Check).
|
||||||
|
func (c *Client) Verify(key string) (*Result, error) {
|
||||||
|
if key == "" {
|
||||||
|
return nil, errors.New("license key is empty")
|
||||||
|
}
|
||||||
|
fp := c.SystemFingerprint()
|
||||||
|
hostname, _ := c.OSHostname()
|
||||||
|
|
||||||
|
var activeDomains int64
|
||||||
|
if c.ActiveDomains != nil {
|
||||||
|
activeDomains = c.ActiveDomains()
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf(
|
||||||
|
"%s/api/v1/licenses/%s/verify?system_id=%s&system_name=%s&active_domains=%d",
|
||||||
|
c.ServerURL,
|
||||||
|
url.PathEscape(key),
|
||||||
|
url.QueryEscape(fp),
|
||||||
|
url.QueryEscape(hostname),
|
||||||
|
activeDomains,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := c.HTTPClient.Get(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("license server unreachable: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("license server returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read license response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := resp.Header.Get("X-Signature")
|
||||||
|
if sig == "" {
|
||||||
|
return nil, errors.New("license server response missing X-Signature header")
|
||||||
|
}
|
||||||
|
if !VerifySignature(c.SignatureKeys, body, sig) {
|
||||||
|
return nil, errors.New("license server response signature invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result Result
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode license response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ServerTime != nil {
|
||||||
|
drift := c.ClockNow().Sub(*result.ServerTime)
|
||||||
|
if drift < 0 {
|
||||||
|
drift = -drift
|
||||||
|
}
|
||||||
|
if drift > maxClockDrift {
|
||||||
|
return nil, fmt.Errorf("system clock drift %s exceeds %s (check NTP)", drift, maxClockDrift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check returns the currently effective license, trying in order:
|
||||||
|
// live verify (valid:true) -> live verify (valid:false) but cached
|
||||||
|
// valid:true within grace period -> local cache (24 h TTL) -> trial
|
||||||
|
// (7 days) -> expired. Never returns an error.
|
||||||
|
//
|
||||||
|
// Grace-Period-Verhalten (1.6.66+):
|
||||||
|
// - Server-Antwort valid:true → SaveCache + return r
|
||||||
|
// - Server-Antwort valid:false → cache laden:
|
||||||
|
// * cache.Valid && cached_at innerhalb GracePeriod → cache nutzen
|
||||||
|
// (typische Ursache: transientes Server-Problem wie
|
||||||
|
// activation_limit_exceeded nach mehreren verify-Calls)
|
||||||
|
// * sonst → server-Antwort durchreichen, cache NICHT überschreiben
|
||||||
|
// - Verify-Fehler (Netzwerk/HTTP) → cache nutzen (wie pre-1.6.66)
|
||||||
|
//
|
||||||
|
// Die SaveCache-Aufrufe werden ausschließlich für valid:true-Results
|
||||||
|
// gemacht. Damit überschreibt eine vorübergehende valid:false-Antwort
|
||||||
|
// niemals einen vorher gespeicherten gültigen Stand. Operator hat 7
|
||||||
|
// Tage Zeit zu reagieren ohne dass die UI sofort Lizenz-Eingabe fordert.
|
||||||
|
func (c *Client) Check(key string) *Result {
|
||||||
|
if key != "" {
|
||||||
|
if r, err := c.Verify(key); err == nil {
|
||||||
|
if r.Valid {
|
||||||
|
_ = c.SaveCache(r)
|
||||||
|
slog.Info("license verified", "product", r.Product, "type", r.Type, "valid", true)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
// Server says valid:false. Vor wir das durchreichen,
|
||||||
|
// prüfen wir ob ein gültiger Cache existiert der noch
|
||||||
|
// in der Grace-Period liegt — dann nutzen wir den.
|
||||||
|
if cached, cerr := c.LoadCache(); cerr == nil && cached.Valid {
|
||||||
|
age := graceAge(cached)
|
||||||
|
if age <= GracePeriod {
|
||||||
|
slog.Warn("license server said invalid, using cached valid result (grace period)",
|
||||||
|
"server_reason", r.Reason, "cache_age", age, "grace", GracePeriod)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
slog.Warn("license server said invalid, cached valid result is too old",
|
||||||
|
"server_reason", r.Reason, "cache_age", age, "grace", GracePeriod)
|
||||||
|
}
|
||||||
|
slog.Warn("license invalid", "reason", r.Reason)
|
||||||
|
return r
|
||||||
|
} else {
|
||||||
|
slog.Warn("license verify failed, falling back to cache", "error", err)
|
||||||
|
}
|
||||||
|
if r, err := c.LoadCache(); err == nil {
|
||||||
|
slog.Warn("using cached license", "cached_at", r.CachedAt)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return &Result{Valid: false, Reason: "verify_failed_no_cache", Type: "license"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No key — trial mode.
|
||||||
|
r, err := c.Trial()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("trial check failed", "error", err)
|
||||||
|
return &Result{Valid: false, Reason: "trial_check_failed", Type: "trial"}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// graceAge berechnet das Alter des Cache-Eintrags. Fallback auf weit-
|
||||||
|
// in-der-Zukunft wenn cached_at fehlt — dann gilt die Cache als zu alt
|
||||||
|
// und Grace greift NICHT (defensiv: lieber einmal Auth-Fragen als
|
||||||
|
// dauerhaft falsche Lizenz nutzen).
|
||||||
|
func graceAge(r *Result) time.Duration {
|
||||||
|
if r == nil || r.CachedAt == nil {
|
||||||
|
return 100 * 365 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
return time.Since(*r.CachedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPeriodicVerification re-checks the license every VerifyInterval.
|
||||||
|
// Writes each result back to the cache; does not exit on failure.
|
||||||
|
func (c *Client) StartPeriodicVerification(key string) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(VerifyInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
r := c.Check(key)
|
||||||
|
if !r.Valid {
|
||||||
|
slog.Warn("periodic license check: invalid",
|
||||||
|
"reason", r.Reason, "self_service", DefaultSelfServiceURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemFingerprint is a stable SHA-256 over machine-id, the first active
|
||||||
|
// non-loopback MAC and the hostname. Identical bytes on the same machine
|
||||||
|
// across reboots; changes when network hardware or hostname changes.
|
||||||
|
func (c *Client) SystemFingerprint() string {
|
||||||
|
var parts []string
|
||||||
|
if b, err := c.ReadMachineID(); err == nil {
|
||||||
|
parts = append(parts, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
ifaces, _ := c.InterfacesFn()
|
||||||
|
for _, i := range ifaces {
|
||||||
|
if len(i.HardwareAddr) > 0 && i.Flags&net.FlagLoopback == 0 && i.Flags&net.FlagUp != 0 {
|
||||||
|
parts = append(parts, i.HardwareAddr.String())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h, err := c.OSHostname(); err == nil {
|
||||||
|
parts = append(parts, h)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
|
||||||
|
return fmt.Sprintf("%x", sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache + Trial helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
func (c *Client) cachePath() string { return filepath.Join(c.CacheDir, defaultCacheFile) }
|
||||||
|
func (c *Client) trialPath() string { return filepath.Join(c.CacheDir, defaultTrialFile) }
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
Result *Result `json:"result"`
|
||||||
|
CachedAt time.Time `json:"cached_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCache writes a verified result to disk. Errors are logged, not fatal.
|
||||||
|
func (c *Client) SaveCache(r *Result) error {
|
||||||
|
if err := os.MkdirAll(c.CacheDir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry := cacheEntry{Result: r, CachedAt: c.ClockNow()}
|
||||||
|
data, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(c.cachePath(), data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCache reads the cached result; errors if missing, malformed or older
|
||||||
|
// than CacheMaxAge.
|
||||||
|
func (c *Client) LoadCache() (*Result, error) {
|
||||||
|
data, err := os.ReadFile(c.cachePath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var entry cacheEntry
|
||||||
|
if err := json.Unmarshal(data, &entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.ClockNow().Sub(entry.CachedAt) > CacheMaxAge {
|
||||||
|
return nil, errors.New("cache expired")
|
||||||
|
}
|
||||||
|
if entry.Result != nil {
|
||||||
|
entry.Result.CachedAt = &entry.CachedAt
|
||||||
|
}
|
||||||
|
return entry.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type trialInfo struct {
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trial returns an active trial result while inside TrialDuration, else
|
||||||
|
// a Valid=false "trial_expired" result. Creates the marker file on first call.
|
||||||
|
func (c *Client) Trial() (*Result, error) {
|
||||||
|
var info trialInfo
|
||||||
|
data, err := os.ReadFile(c.trialPath())
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info = trialInfo{StartedAt: c.ClockNow()}
|
||||||
|
if err := os.MkdirAll(c.CacheDir, 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(info)
|
||||||
|
if err := os.WriteFile(c.trialPath(), raw, 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
slog.Info("nmg trial started",
|
||||||
|
"expires_at", info.StartedAt.Add(TrialDuration))
|
||||||
|
} else {
|
||||||
|
if err := json.Unmarshal(data, &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := TrialDuration - c.ClockNow().Sub(info.StartedAt)
|
||||||
|
if remaining <= 0 {
|
||||||
|
return &Result{Valid: false, Reason: "trial_expired", Type: "trial"}, nil
|
||||||
|
}
|
||||||
|
daysLeft := int(remaining.Hours()/24) + 1
|
||||||
|
expiresAt := info.StartedAt.Add(TrialDuration)
|
||||||
|
|
||||||
|
// Trial schaltet alle Pro-Features frei, Limits bleiben 0 (= Skip-
|
||||||
|
// Enforcement-Konvention). nmg-Backend prüft `lim > 0 && count >= lim`
|
||||||
|
// und blockiert in Trial-Mode niemanden.
|
||||||
|
return &Result{
|
||||||
|
Valid: true,
|
||||||
|
Type: "trial",
|
||||||
|
Status: "active",
|
||||||
|
Product: "NetCell MailGuard",
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
GracePeriod: daysLeft <= 2,
|
||||||
|
Features: map[string]bool{
|
||||||
|
"reporting": true,
|
||||||
|
"eu_portal": true,
|
||||||
|
"digest": true,
|
||||||
|
"whitelabel": true,
|
||||||
|
"rest_api_write": true,
|
||||||
|
},
|
||||||
|
Limits: map[string]int64{
|
||||||
|
"max_domains": 0, // 0 = Skip-Enforcement (Trial-Konvention)
|
||||||
|
"max_nodes": 0,
|
||||||
|
"max_activations": 0,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
52
internal/license/signature.go
Normal file
52
internal/license/signature.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SignatureKey is an Ed25519 public key used to verify license-server
|
||||||
|
// responses. Shipped as hardcoded base64 constants; multiple keys allow
|
||||||
|
// rotation.
|
||||||
|
type SignatureKey = ed25519.PublicKey
|
||||||
|
|
||||||
|
// Default signing keys, base64 of the raw 32-byte Ed25519 public key.
|
||||||
|
// These must match the keys used by license.netcell-it.com to sign the
|
||||||
|
// verify response's X-Signature header — they are intentionally identical
|
||||||
|
// to the enconf/netcell-webpanel keys (same licensing backend).
|
||||||
|
const (
|
||||||
|
signingKeyPrimaryB64 = "uyXQLl8hFgI4rvvr5pfyF0SmFw1j2R849OL3HUZov5I="
|
||||||
|
signingKeyNextB64 = "zSdKn799Fmu1KaZPYfkB5gDVqeU2doIUFWvmvXigN6M="
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultSigningKeys decodes the embedded base64 keys into Ed25519 public
|
||||||
|
// keys. Invalid entries are skipped with a warning.
|
||||||
|
func DefaultSigningKeys() []SignatureKey {
|
||||||
|
var out []SignatureKey
|
||||||
|
for _, b64 := range []string{signingKeyPrimaryB64, signingKeyNextB64} {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil || len(raw) != ed25519.PublicKeySize {
|
||||||
|
slog.Error("license: invalid embedded signing key", "base64", b64)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ed25519.PublicKey(raw))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifySignature accepts base64-encoded Ed25519 signatures in the
|
||||||
|
// X-Signature header of a license response and checks them against all
|
||||||
|
// provided keys — success on any match.
|
||||||
|
func VerifySignature(keys []SignatureKey, body []byte, signatureB64 string) bool {
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(signatureB64)
|
||||||
|
if err != nil || len(sig) != ed25519.SignatureSize {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, pk := range keys {
|
||||||
|
if ed25519.Verify(pk, body, sig) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
104
internal/services/license/license.go
Normal file
104
internal/services/license/license.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Package license provides a thin DB-mirror of the in-memory
|
||||||
|
// license.Result. The actual verify-against-license-server logic
|
||||||
|
// lives in internal/license; this package only persists the latest
|
||||||
|
// result into the licenses table so the UI can show it without a
|
||||||
|
// live network call.
|
||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||||
|
|
||||||
|
// State is what we persist + what the UI reads.
|
||||||
|
type State struct {
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ValidUntil *time.Time `json:"valid_until,omitempty"`
|
||||||
|
LastVerifiedAt *time.Time `json:"last_verified_at,omitempty"`
|
||||||
|
LastVerifiedNode *string `json:"last_verified_node,omitempty"`
|
||||||
|
ActiveDomainsAtVerify *int `json:"active_domains_at_verify,omitempty"`
|
||||||
|
Payload json.RawMessage `json:"payload,omitempty"`
|
||||||
|
LastError *string `json:"last_error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the most-recent license row (we keep one row per key
|
||||||
|
// but typically the operator only has one key — so this returns the
|
||||||
|
// last-updated). Returns sql ErrNoRows-equivalent if none exists.
|
||||||
|
func (r *Repo) Get(ctx context.Context) (*State, error) {
|
||||||
|
row := r.Pool.QueryRow(ctx, `
|
||||||
|
SELECT license_key, status, valid_until, last_verified_at, last_verified_node,
|
||||||
|
active_domains_at_verify, payload, last_error, created_at, updated_at
|
||||||
|
FROM licenses
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1`)
|
||||||
|
var s State
|
||||||
|
if err := row.Scan(&s.LicenseKey, &s.Status, &s.ValidUntil, &s.LastVerifiedAt,
|
||||||
|
&s.LastVerifiedNode, &s.ActiveDomainsAtVerify, &s.Payload, &s.LastError,
|
||||||
|
&s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert persists a fresh verify-result. Empty error string clears
|
||||||
|
// any previous error.
|
||||||
|
func (r *Repo) Upsert(ctx context.Context, key, status string, validUntil *time.Time,
|
||||||
|
nodeID string, activeDomains int, payload []byte, lastErr string) error {
|
||||||
|
var nodeArg any = nodeID
|
||||||
|
if nodeID == "" {
|
||||||
|
nodeArg = nil
|
||||||
|
}
|
||||||
|
var errArg any = lastErr
|
||||||
|
if lastErr == "" {
|
||||||
|
errArg = nil
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := r.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO licenses (license_key, status, valid_until, last_verified_at,
|
||||||
|
last_verified_node, active_domains_at_verify, payload, last_error)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (license_key) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
valid_until = EXCLUDED.valid_until,
|
||||||
|
last_verified_at = EXCLUDED.last_verified_at,
|
||||||
|
last_verified_node = EXCLUDED.last_verified_node,
|
||||||
|
active_domains_at_verify = EXCLUDED.active_domains_at_verify,
|
||||||
|
payload = EXCLUDED.payload,
|
||||||
|
last_error = EXCLUDED.last_error,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
key, status, validUntil, now, nodeArg, activeDomains, payload, errArg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkError records a verify failure WITHOUT touching status — the
|
||||||
|
// previous valid status stays so a transient server failure doesn't
|
||||||
|
// flip the box into "expired" mid-session.
|
||||||
|
func (r *Repo) MarkError(ctx context.Context, key, errMsg string) error {
|
||||||
|
_, err := r.Pool.Exec(ctx,
|
||||||
|
`UPDATE licenses SET last_error = $1, updated_at = NOW() WHERE license_key = $2`,
|
||||||
|
errMsg, key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the row when the operator clears the key.
|
||||||
|
func (r *Repo) Delete(ctx context.Context, key string) error {
|
||||||
|
_, err := r.Pool.Exec(ctx, `DELETE FROM licenses WHERE license_key = $1`, key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
|
|||||||
const DNSPage = lazy(() => import('./pages/DNS'))
|
const DNSPage = lazy(() => import('./pages/DNS'))
|
||||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||||
|
const LicensePage = lazy(() => import('./pages/License'))
|
||||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -108,6 +109,7 @@ export default function App() {
|
|||||||
<Route path="/dns" element={<DNSPage />} />
|
<Route path="/dns" element={<DNSPage />} />
|
||||||
<Route path="/ntp" element={<NTPPage />} />
|
<Route path="/ntp" element={<NTPPage />} />
|
||||||
<Route path="/cluster" element={<ClusterPage />} />
|
<Route path="/cluster" element={<ClusterPage />} />
|
||||||
|
<Route path="/license" element={<LicensePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import Sidebar from './Sidebar'
|
import Sidebar from './Sidebar'
|
||||||
import Header from './Header'
|
import Header from './Header'
|
||||||
import UpdateBanner from '../UpdateBanner'
|
import UpdateBanner from '../UpdateBanner'
|
||||||
|
import LicenseBanner from '../LicenseBanner'
|
||||||
|
|
||||||
// PAGE_TITLES maps the pathname to an i18n nav key. Header reads
|
// PAGE_TITLES maps the pathname to an i18n nav key. Header reads
|
||||||
// this to render "where you are". Empty fallback = app.title.
|
// this to render "where you are". Empty fallback = app.title.
|
||||||
@@ -16,6 +17,7 @@ const PAGE_TITLES: Record<string, string> = {
|
|||||||
'/networks': 'nav.networks',
|
'/networks': 'nav.networks',
|
||||||
'/ip-addresses': 'nav.ipAddresses',
|
'/ip-addresses': 'nav.ipAddresses',
|
||||||
'/cluster': 'nav.cluster',
|
'/cluster': 'nav.cluster',
|
||||||
|
'/license': 'nav.license',
|
||||||
'/settings': 'nav.settings',
|
'/settings': 'nav.settings',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ export default function AppLayout() {
|
|||||||
|
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<Header pageTitle={title} onMenuToggle={() => setSidebarOpen(true)} />
|
<Header pageTitle={title} onMenuToggle={() => setSidebarOpen(true)} />
|
||||||
|
<LicenseBanner />
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<div className="content-area">
|
<div className="content-area">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
|
CrownOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
FireOutlined,
|
FireOutlined,
|
||||||
@@ -70,12 +71,13 @@ const NAV: NavSection[] = [
|
|||||||
labelKey: 'nav.section.system',
|
labelKey: 'nav.section.system',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
|
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
|
||||||
|
{ path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> },
|
||||||
{ path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined /> },
|
{ path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const VERSION = '1.0.46'
|
const VERSION = '1.0.47'
|
||||||
|
|
||||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
91
management-ui/src/components/LicenseBanner.tsx
Normal file
91
management-ui/src/components/LicenseBanner.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Alert } from 'antd'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import apiClient, { isEnvelope } from '../api/client'
|
||||||
|
|
||||||
|
interface LicenseStatus {
|
||||||
|
license_key?: string
|
||||||
|
status: string
|
||||||
|
type?: string
|
||||||
|
valid_until?: string
|
||||||
|
expires_at?: string
|
||||||
|
last_error?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseBanner shows up in AppLayout next to UpdateBanner.
|
||||||
|
// Three visible states:
|
||||||
|
// - trial-expiring (≤14d remaining) → warning
|
||||||
|
// - expired / invalid → error
|
||||||
|
// - last verify failed → warning (transient)
|
||||||
|
// Everything else stays silent.
|
||||||
|
export default function LicenseBanner() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { data: s } = useQuery({
|
||||||
|
queryKey: ['license', 'status'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await apiClient.get('/license/status')
|
||||||
|
return isEnvelope(r.data) ? (r.data.data as LicenseStatus) : null
|
||||||
|
},
|
||||||
|
refetchInterval: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!s) return null
|
||||||
|
|
||||||
|
const expiry = s.valid_until || s.expires_at
|
||||||
|
const days = expiry ? Math.ceil((new Date(expiry).getTime() - Date.now()) / 86_400_000) : null
|
||||||
|
const isTrial = s.type === 'trial' || !s.license_key
|
||||||
|
|
||||||
|
if (s.status === 'expired' || s.status === 'invalid') {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
banner
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
{t('licenseBanner.expired')}{' '}
|
||||||
|
<Link to="/license">{t('licenseBanner.cta')}</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTrial && days !== null && days <= 14) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type={days <= 3 ? 'error' : 'warning'}
|
||||||
|
showIcon
|
||||||
|
banner
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
{t('licenseBanner.trialExpiring', { days })}{' '}
|
||||||
|
<Link to="/license">{t('licenseBanner.cta')}</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.last_error) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
banner
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
{t('licenseBanner.verifyFailed')}: {s.last_error}{' '}
|
||||||
|
<Link to="/license">{t('licenseBanner.openPage')}</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
closable
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"ntp": "Zeit (NTP)",
|
"ntp": "Zeit (NTP)",
|
||||||
"firewall": "Firewall",
|
"firewall": "Firewall",
|
||||||
"cluster": "Cluster",
|
"cluster": "Cluster",
|
||||||
|
"license": "Lizenz",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"section": {
|
"section": {
|
||||||
"overview": "Übersicht",
|
"overview": "Übersicht",
|
||||||
@@ -551,5 +552,42 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"copy": "Kopieren",
|
"copy": "Kopieren",
|
||||||
"copied": "Kopiert"
|
"copied": "Kopiert"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"title": "Lizenz",
|
||||||
|
"status": "Status",
|
||||||
|
"product": "Produkt",
|
||||||
|
"key": "Lizenz-Schlüssel",
|
||||||
|
"noKey": "Kein Schlüssel hinterlegt",
|
||||||
|
"validUntil": "Gültig bis",
|
||||||
|
"expired": "Abgelaufen",
|
||||||
|
"daysLeft": "noch {{days}} Tage",
|
||||||
|
"lastVerifiedAt": "Letzte Verifizierung",
|
||||||
|
"verifiedBy": "Verifiziert von",
|
||||||
|
"limits": "Limits",
|
||||||
|
"unlimited": "Unbegrenzt",
|
||||||
|
"features": "Features",
|
||||||
|
"reverify": "Erneut prüfen",
|
||||||
|
"reverified": "Lizenz erfolgreich verifiziert",
|
||||||
|
"enterKey": "Schlüssel eingeben",
|
||||||
|
"replaceKey": "Schlüssel ersetzen",
|
||||||
|
"enterKeyHint": "Lizenz-Schlüssel aus dem Self-Service-Portal von license.netcell-it.com einfügen.",
|
||||||
|
"activate": "Aktivieren",
|
||||||
|
"saved": "Lizenz gespeichert und verifiziert",
|
||||||
|
"savedButVerifyFailed": "Schlüssel gespeichert, aber Server-Verifizierung fehlgeschlagen",
|
||||||
|
"clearKey": "Schlüssel entfernen",
|
||||||
|
"cleared": "Lizenz entfernt — System fällt auf Trial zurück",
|
||||||
|
"confirmClear": "Lizenz-Schlüssel wirklich entfernen?",
|
||||||
|
"confirmClearHint": "System fällt auf Trial-Modus zurück, sobald der Schlüssel gelöscht wird.",
|
||||||
|
"lastVerifyFailed": "Letzte Server-Verifizierung fehlgeschlagen",
|
||||||
|
"trialExpiring": "Trial läuft in {{days}} Tag(en) ab",
|
||||||
|
"trialExpiringHint": "Lizenz aktivieren, bevor die Trial-Periode endet."
|
||||||
|
},
|
||||||
|
"licenseBanner": {
|
||||||
|
"expired": "Lizenz abgelaufen oder ungültig.",
|
||||||
|
"trialExpiring": "Trial läuft in {{days}} Tag(en) ab.",
|
||||||
|
"verifyFailed": "Lizenz-Verifizierung fehlgeschlagen",
|
||||||
|
"cta": "Jetzt aktivieren →",
|
||||||
|
"openPage": "Lizenz-Seite öffnen →"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"ntp": "Time (NTP)",
|
"ntp": "Time (NTP)",
|
||||||
"firewall": "Firewall",
|
"firewall": "Firewall",
|
||||||
"cluster": "Cluster",
|
"cluster": "Cluster",
|
||||||
|
"license": "License",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"section": {
|
"section": {
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
@@ -551,5 +552,42 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied"
|
"copied": "Copied"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"title": "License",
|
||||||
|
"status": "Status",
|
||||||
|
"product": "Product",
|
||||||
|
"key": "License key",
|
||||||
|
"noKey": "No key configured",
|
||||||
|
"validUntil": "Valid until",
|
||||||
|
"expired": "Expired",
|
||||||
|
"daysLeft": "{{days}} days left",
|
||||||
|
"lastVerifiedAt": "Last verified",
|
||||||
|
"verifiedBy": "Verified by",
|
||||||
|
"limits": "Limits",
|
||||||
|
"unlimited": "Unlimited",
|
||||||
|
"features": "Features",
|
||||||
|
"reverify": "Re-verify",
|
||||||
|
"reverified": "License re-verified successfully",
|
||||||
|
"enterKey": "Enter key",
|
||||||
|
"replaceKey": "Replace key",
|
||||||
|
"enterKeyHint": "Paste your license key from the self-service portal at license.netcell-it.com.",
|
||||||
|
"activate": "Activate",
|
||||||
|
"saved": "License saved and verified",
|
||||||
|
"savedButVerifyFailed": "Key saved but server-verify failed",
|
||||||
|
"clearKey": "Remove key",
|
||||||
|
"cleared": "License removed — system falls back to trial",
|
||||||
|
"confirmClear": "Really remove the license key?",
|
||||||
|
"confirmClearHint": "The system will fall back to trial-mode once the key is deleted.",
|
||||||
|
"lastVerifyFailed": "Last server verify failed",
|
||||||
|
"trialExpiring": "Trial expires in {{days}} day(s)",
|
||||||
|
"trialExpiringHint": "Activate a license before the trial period ends."
|
||||||
|
},
|
||||||
|
"licenseBanner": {
|
||||||
|
"expired": "License expired or invalid.",
|
||||||
|
"trialExpiring": "Trial expires in {{days}} day(s).",
|
||||||
|
"verifyFailed": "License verification failed",
|
||||||
|
"cta": "Activate now →",
|
||||||
|
"openPage": "Open license page →"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
235
management-ui/src/pages/License/index.tsx
Normal file
235
management-ui/src/pages/License/index.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Alert, Button, Card, Descriptions, Form, Input, Modal, Popconfirm, Space, Tag, Typography, message,
|
||||||
|
} from 'antd'
|
||||||
|
import { CrownOutlined, ReloadOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
interface LicenseStatus {
|
||||||
|
license_key?: string
|
||||||
|
status: string
|
||||||
|
type?: string
|
||||||
|
valid?: boolean
|
||||||
|
valid_until?: string
|
||||||
|
expires_at?: string
|
||||||
|
last_verified_at?: string
|
||||||
|
last_verified_node?: string
|
||||||
|
last_error?: string | null
|
||||||
|
reason?: string
|
||||||
|
payload?: {
|
||||||
|
product?: string
|
||||||
|
type?: string
|
||||||
|
limits?: Record<string, number>
|
||||||
|
features?: Record<string, boolean>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysUntil(iso?: string): number | null {
|
||||||
|
if (!iso) return null
|
||||||
|
const ms = new Date(iso).getTime() - Date.now()
|
||||||
|
return Math.ceil(ms / 86_400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTag(s: LicenseStatus) {
|
||||||
|
if (s.status === 'active' && s.type === 'trial') return <Tag color="orange">Trial</Tag>
|
||||||
|
if (s.status === 'active') return <Tag color="green">Aktiv</Tag>
|
||||||
|
if (s.status === 'expired') return <Tag color="red">Abgelaufen</Tag>
|
||||||
|
if (s.status === 'invalid') return <Tag color="red">Ungültig</Tag>
|
||||||
|
return <Tag>{s.status}</Tag>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LicensePage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [form] = Form.useForm<{ license_key: string }>()
|
||||||
|
|
||||||
|
const { data: status, isLoading } = useQuery({
|
||||||
|
queryKey: ['license', 'status'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await apiClient.get('/license/status')
|
||||||
|
return isEnvelope(r.data) ? (r.data.data as LicenseStatus) : null
|
||||||
|
},
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const setKey = useMutation({
|
||||||
|
mutationFn: async (key: string) => {
|
||||||
|
const r = await apiClient.put('/license/key', { license_key: key })
|
||||||
|
return isEnvelope(r.data) ? r.data.data : r.data
|
||||||
|
},
|
||||||
|
onSuccess: (res: { verify_error?: string }) => {
|
||||||
|
if (res?.verify_error) {
|
||||||
|
message.warning(t('license.savedButVerifyFailed') + ': ' + res.verify_error)
|
||||||
|
} else {
|
||||||
|
message.success(t('license.saved'))
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
qc.invalidateQueries({ queryKey: ['license'] })
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const verify = useMutation({
|
||||||
|
mutationFn: async () => { await apiClient.post('/license/verify') },
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('license.reverified'))
|
||||||
|
qc.invalidateQueries({ queryKey: ['license'] })
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const clear = useMutation({
|
||||||
|
mutationFn: async () => { await apiClient.delete('/license/key') },
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('license.cleared'))
|
||||||
|
qc.invalidateQueries({ queryKey: ['license'] })
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const expiry = status?.valid_until ?? status?.expires_at
|
||||||
|
const days = daysUntil(expiry)
|
||||||
|
const isTrial = status?.type === 'trial' || (!status?.license_key && status?.status === 'active')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={t('license.title')}
|
||||||
|
icon={<CrownOutlined />}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => verify.mutate()} loading={verify.isPending}
|
||||||
|
disabled={!status?.license_key}>
|
||||||
|
{t('license.reverify')}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<SafetyCertificateOutlined />} onClick={() => setOpen(true)}>
|
||||||
|
{status?.license_key ? t('license.replaceKey') : t('license.enterKey')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading && <Alert message={t('common.loading')} type="info" />}
|
||||||
|
|
||||||
|
{status?.last_error && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message={t('license.lastVerifyFailed')}
|
||||||
|
description={status.last_error}
|
||||||
|
className="mb-16"
|
||||||
|
closable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isTrial && days !== null && days <= 14 && (
|
||||||
|
<Alert
|
||||||
|
type={days <= 3 ? 'error' : 'warning'}
|
||||||
|
showIcon
|
||||||
|
message={t('license.trialExpiring', { days })}
|
||||||
|
description={t('license.trialExpiringHint')}
|
||||||
|
className="mb-16"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label={t('license.status')}>
|
||||||
|
{status ? statusTag(status) : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('license.product')}>
|
||||||
|
{status?.payload?.product || (isTrial ? 'Trial' : '-')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('license.key')}>
|
||||||
|
<Text code copyable={!!status?.license_key}>
|
||||||
|
{status?.license_key || '— ' + t('license.noKey') + ' —'}
|
||||||
|
</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('license.validUntil')}>
|
||||||
|
{expiry ? (
|
||||||
|
<>
|
||||||
|
{new Date(expiry).toLocaleString()}{' '}
|
||||||
|
{days !== null && (
|
||||||
|
<Tag color={days < 0 ? 'red' : days <= 14 ? 'orange' : 'blue'}>
|
||||||
|
{days < 0 ? t('license.expired') : t('license.daysLeft', { days })}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t('license.lastVerifiedAt')}>
|
||||||
|
{status?.last_verified_at ? new Date(status.last_verified_at).toLocaleString() : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{status?.last_verified_node && (
|
||||||
|
<Descriptions.Item label={t('license.verifiedBy')}>
|
||||||
|
<Text code>{status.last_verified_node}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
{status?.payload?.limits && Object.keys(status.payload.limits).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Paragraph strong className="mt-24">{t('license.limits')}</Paragraph>
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
{Object.entries(status.payload.limits).map(([k, v]) => (
|
||||||
|
<Descriptions.Item key={k} label={k}>
|
||||||
|
{v === 0 ? <Tag>{t('license.unlimited')}</Tag> : v}
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.payload?.features && Object.keys(status.payload.features).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Paragraph strong className="mt-24">{t('license.features')}</Paragraph>
|
||||||
|
<Space wrap>
|
||||||
|
{Object.entries(status.payload.features).map(([k, v]) => (
|
||||||
|
<Tag color={v ? 'green' : 'default'} key={k}>{k}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.license_key && (
|
||||||
|
<div className="mt-24">
|
||||||
|
<Popconfirm
|
||||||
|
title={t('license.confirmClear')}
|
||||||
|
description={t('license.confirmClearHint')}
|
||||||
|
okText={t('license.clearKey')}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => clear.mutate()}
|
||||||
|
>
|
||||||
|
<Button danger>{t('license.clearKey')}</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={status?.license_key ? t('license.replaceKey') : t('license.enterKey')}
|
||||||
|
open={open}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={setKey.isPending}
|
||||||
|
okText={t('license.activate')}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Paragraph type="secondary">{t('license.enterKeyHint')}</Paragraph>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(v) => setKey.mutate(v.license_key.trim())}>
|
||||||
|
<Form.Item name="license_key" label={t('license.key')} rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea rows={3} placeholder="NMG-XXXX-XXXX-XXXX-XXXX" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user