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