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:
Debian
2026-05-11 13:41:16 +02:00
parent 1324a34f11
commit 62505d547c
17 changed files with 1278 additions and 10 deletions

View 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
View 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

View 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
View 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
}

View 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
}

View 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
}