diff --git a/VERSION b/VERSION index 3c92cd0..bde91a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.46 +1.0.47 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 38aa3c3..03b66f4 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -22,6 +22,8 @@ import ( 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/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" squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid" 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" ) -var version = "1.0.46" +var version = "1.0.47" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -234,6 +236,19 @@ func main() { return chronyrender.New(pool).Render(ctx) } 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) diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index c7dae44..52f5f11 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.46" +var version = "1.0.47" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index cbb8529..7a75813 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -10,18 +10,21 @@ package main import ( "context" + "encoding/json" "log/slog" "os" "time" "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/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/tlscerts" ) -var version = "1.0.46" +var version = "1.0.47" const ( // renewTickInterval — how often we re-evaluate expiring certs. @@ -32,6 +35,10 @@ const ( // certDir matches handlers.NewTLSCertsHandler default — HAProxy // reads from this directory. 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() { @@ -61,18 +68,65 @@ func main() { 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 { runRenewer(ctx, renewer) } - tick := time.NewTicker(renewTickInterval) - defer tick.Stop() - for range tick.C { - if renewer != nil { - runRenewer(ctx, renewer) + runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID) + + renewTick := time.NewTicker(renewTickInterval) + defer renewTick.Stop() + 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) { res, err := r.Run(ctx) if err != nil { diff --git a/internal/handlers/license.go b/internal/handlers/license.go new file mode 100644 index 0000000..3be9063 --- /dev/null +++ b/internal/handlers/license.go @@ -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 +} diff --git a/internal/license/doc.go b/internal/license/doc.go new file mode 100644 index 0000000..cedb58c --- /dev/null +++ b/internal/license/doc.go @@ -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 diff --git a/internal/license/keystore.go b/internal/license/keystore.go new file mode 100644 index 0000000..3512341 --- /dev/null +++ b/internal/license/keystore.go @@ -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 +} diff --git a/internal/license/license.go b/internal/license/license.go new file mode 100644 index 0000000..a1e0765 --- /dev/null +++ b/internal/license/license.go @@ -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 +} diff --git a/internal/license/signature.go b/internal/license/signature.go new file mode 100644 index 0000000..feb748a --- /dev/null +++ b/internal/license/signature.go @@ -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 +} diff --git a/internal/services/license/license.go b/internal/services/license/license.go new file mode 100644 index 0000000..aa6fae2 --- /dev/null +++ b/internal/services/license/license.go @@ -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 +} diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index c437818..cda871f 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -25,6 +25,7 @@ const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy')) const DNSPage = lazy(() => import('./pages/DNS')) const NTPPage = lazy(() => import('./pages/NTP')) const ClusterPage = lazy(() => import('./pages/Cluster')) +const LicensePage = lazy(() => import('./pages/License')) const SettingsPage = lazy(() => import('./pages/Settings')) const queryClient = new QueryClient({ @@ -108,6 +109,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/management-ui/src/components/Layout/AppLayout.tsx b/management-ui/src/components/Layout/AppLayout.tsx index da8a049..6ceb690 100644 --- a/management-ui/src/components/Layout/AppLayout.tsx +++ b/management-ui/src/components/Layout/AppLayout.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import Sidebar from './Sidebar' import Header from './Header' import UpdateBanner from '../UpdateBanner' +import LicenseBanner from '../LicenseBanner' // PAGE_TITLES maps the pathname to an i18n nav key. Header reads // this to render "where you are". Empty fallback = app.title. @@ -16,6 +17,7 @@ const PAGE_TITLES: Record = { '/networks': 'nav.networks', '/ip-addresses': 'nav.ipAddresses', '/cluster': 'nav.cluster', + '/license': 'nav.license', '/settings': 'nav.settings', } @@ -42,6 +44,7 @@ export default function AppLayout() {
setSidebarOpen(true)} /> +
diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 3d15066..7bbe526 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -5,6 +5,7 @@ import { ClockCircleOutlined, CloudServerOutlined, ClusterOutlined, + CrownOutlined, DashboardOutlined, DatabaseOutlined, FireOutlined, @@ -70,12 +71,13 @@ const NAV: NavSection[] = [ labelKey: 'nav.section.system', items: [ { path: '/cluster', labelKey: 'nav.cluster', icon: }, + { path: '/license', labelKey: 'nav.license', icon: }, { path: '/settings', labelKey: 'nav.settings', icon: }, ], }, ] -const VERSION = '1.0.46' +const VERSION = '1.0.47' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/components/LicenseBanner.tsx b/management-ui/src/components/LicenseBanner.tsx new file mode 100644 index 0000000..21a2637 --- /dev/null +++ b/management-ui/src/components/LicenseBanner.tsx @@ -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 ( + + {t('licenseBanner.expired')}{' '} + {t('licenseBanner.cta')} + + } + /> + ) + } + + if (isTrial && days !== null && days <= 14) { + return ( + + {t('licenseBanner.trialExpiring', { days })}{' '} + {t('licenseBanner.cta')} + + } + /> + ) + } + + if (s.last_error) { + return ( + + {t('licenseBanner.verifyFailed')}: {s.last_error}{' '} + {t('licenseBanner.openPage')} + + } + closable + /> + ) + } + + return null +} diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 74b1358..23fc9fb 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -18,6 +18,7 @@ "ntp": "Zeit (NTP)", "firewall": "Firewall", "cluster": "Cluster", + "license": "Lizenz", "settings": "Einstellungen", "section": { "overview": "Übersicht", @@ -551,5 +552,42 @@ "download": "Download", "copy": "Kopieren", "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 →" } } diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index ab31155..707f41f 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -18,6 +18,7 @@ "ntp": "Time (NTP)", "firewall": "Firewall", "cluster": "Cluster", + "license": "License", "settings": "Settings", "section": { "overview": "Overview", @@ -551,5 +552,42 @@ "download": "Download", "copy": "Copy", "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 →" } } diff --git a/management-ui/src/pages/License/index.tsx b/management-ui/src/pages/License/index.tsx new file mode 100644 index 0000000..820e582 --- /dev/null +++ b/management-ui/src/pages/License/index.tsx @@ -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 + features?: Record + } +} + +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 Trial + if (s.status === 'active') return Aktiv + if (s.status === 'expired') return Abgelaufen + if (s.status === 'invalid') return Ungültig + return {s.status} +} + +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 ( + <> + } + extra={ + + + + + } + /> + + {isLoading && } + + {status?.last_error && ( + + )} + + {isTrial && days !== null && days <= 14 && ( + + )} + + + + + {status ? statusTag(status) : '-'} + + + {status?.payload?.product || (isTrial ? 'Trial' : '-')} + + + + {status?.license_key || '— ' + t('license.noKey') + ' —'} + + + + {expiry ? ( + <> + {new Date(expiry).toLocaleString()}{' '} + {days !== null && ( + + {days < 0 ? t('license.expired') : t('license.daysLeft', { days })} + + )} + + ) : '-'} + + + {status?.last_verified_at ? new Date(status.last_verified_at).toLocaleString() : '-'} + + {status?.last_verified_node && ( + + {status.last_verified_node} + + )} + + + {status?.payload?.limits && Object.keys(status.payload.limits).length > 0 && ( + <> + {t('license.limits')} + + {Object.entries(status.payload.limits).map(([k, v]) => ( + + {v === 0 ? {t('license.unlimited')} : v} + + ))} + + + )} + + {status?.payload?.features && Object.keys(status.payload.features).length > 0 && ( + <> + {t('license.features')} + + {Object.entries(status.payload.features).map(([k, v]) => ( + {k} + ))} + + + )} + + {status?.license_key && ( +
+ clear.mutate()} + > + + +
+ )} +
+ + setOpen(false)} + onOk={() => form.submit()} + confirmLoading={setKey.isPending} + okText={t('license.activate')} + destroyOnHidden + > + {t('license.enterKeyHint')} +
setKey.mutate(v.license_key.trim())}> + + + +
+
+ + ) +}