Files
2026-05-05 08:44:55 +00:00

353 lines
9.7 KiB
Go

// Package client provides license validation for client applications.
// It contacts the license server, falls back to a local signed cache
// during offline/grace periods, and blocks when the grace period expires.
package client
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
// -----------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------
// Config holds all settings for the license client.
type Config struct {
// ServerURL is the base URL of your license server.
// e.g. "https://licenses.yourdomain.com"
ServerURL string
// LicenseKey is the customer's license key (XXXX-XXXX-XXXX-XXXX).
LicenseKey string
// AppName is used to determine the cache file location.
AppName string
// GracePeriod is how long the app may run without reaching the server.
// Default: 7 days.
GracePeriod time.Duration
// RequestTimeout is the HTTP timeout when contacting the server.
// Default: 5 seconds. Keep this short so startup isn't slow offline.
RequestTimeout time.Duration
// CacheSecret is used to HMAC-sign the local cache file so users
// cannot tamper with it. Should be a constant baked into your binary.
// Must be at least 16 bytes.
CacheSecret []byte
}
func (c *Config) defaults() {
if c.GracePeriod == 0 {
c.GracePeriod = 7 * 24 * time.Hour
}
if c.RequestTimeout == 0 {
c.RequestTimeout = 5 * time.Second
}
if c.AppName == "" {
c.AppName = "app"
}
}
// -----------------------------------------------------------------------
// Result
// -----------------------------------------------------------------------
// Result is returned by Check.
type Result struct {
Valid bool
Offline bool // true if validated from local cache
CachedAt time.Time // when the cache was last refreshed from server
GraceLeft time.Duration // how much grace period remains (offline only)
Message string
}
func (r Result) String() string {
if !r.Valid {
return fmt.Sprintf("invalid: %s", r.Message)
}
if r.Offline {
return fmt.Sprintf("valid (offline, grace period: %s remaining)", r.GraceLeft.Round(time.Hour))
}
return "valid (server verified)"
}
// -----------------------------------------------------------------------
// Cache
// -----------------------------------------------------------------------
type cache struct {
LicenseKey string `json:"license_key"`
MachineID string `json:"machine_id"`
LastValidated time.Time `json:"last_validated"`
Signature string `json:"sig"`
}
// sign returns HMAC-SHA256 of the cache payload fields (excluding sig itself).
func (c *cache) sign(secret []byte) string {
payload := fmt.Sprintf("%s|%s|%s", c.LicenseKey, c.MachineID, c.LastValidated.UTC().Format(time.RFC3339))
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
func (c *cache) verify(secret []byte) bool {
expected := c.sign(secret)
return hmac.Equal([]byte(expected), []byte(c.Signature))
}
// -----------------------------------------------------------------------
// Client
// -----------------------------------------------------------------------
// Client validates licenses against a remote server with offline grace period.
type Client struct {
cfg Config
machineID string
cachePath string
http *http.Client
}
// New creates a license Client. Call Check() to validate.
func New(cfg Config, machineID string) (*Client, error) {
cfg.defaults()
if cfg.ServerURL == "" {
return nil, errors.New("license: ServerURL is required")
}
if cfg.LicenseKey == "" {
return nil, errors.New("license: LicenseKey is required")
}
if len(cfg.CacheSecret) < 16 {
return nil, errors.New("license: CacheSecret must be at least 16 bytes")
}
cachePath, err := resolveCachePath(cfg.AppName)
if err != nil {
return nil, fmt.Errorf("license: cannot resolve cache path: %w", err)
}
return &Client{
cfg: cfg,
machineID: machineID,
cachePath: cachePath,
http: &http.Client{
Timeout: cfg.RequestTimeout,
},
}, nil
}
// Check validates the license. Order of operations:
// 1. Try the license server (fast timeout so startup stays snappy)
// 2. If server unreachable → load local cache, check grace period
// 3. If server reachable but license invalid → block immediately (no grace)
func (c *Client) Check() Result {
// --- Attempt online validation ---
onlineErr := c.validateOnline()
if onlineErr == nil {
// Success — refresh cache and return valid
c.saveCache()
return Result{Valid: true}
}
// --- Server was reachable but rejected the license ---
if !isNetworkError(onlineErr) {
return Result{Valid: false, Message: onlineErr.Error()}
}
// --- Network error — try grace period ---
cached, cacheErr := c.loadCache()
if cacheErr != nil {
return Result{
Valid: false,
Message: fmt.Sprintf("license server unreachable and no valid cache found (%v)", cacheErr),
}
}
// Verify cache belongs to this machine and key
if cached.LicenseKey != c.cfg.LicenseKey {
return Result{Valid: false, Message: "license server unreachable and cached key does not match"}
}
if cached.MachineID != c.machineID {
return Result{Valid: false, Message: "license server unreachable and cached machine ID does not match"}
}
elapsed := time.Since(cached.LastValidated)
graceLeft := c.cfg.GracePeriod - elapsed
if graceLeft <= 0 {
return Result{
Valid: false,
Message: fmt.Sprintf("license server unreachable and grace period expired (%s ago)", (-graceLeft).Round(time.Hour)),
}
}
return Result{
Valid: true,
Offline: true,
CachedAt: cached.LastValidated,
GraceLeft: graceLeft,
Message: fmt.Sprintf("offline — %s grace period remaining", graceLeft.Round(time.Hour)),
}
}
// CachePath returns the path of the local cache file (useful for debugging).
func (c *Client) CachePath() string {
return c.cachePath
}
// -----------------------------------------------------------------------
// Online validation
// -----------------------------------------------------------------------
type validateRequest struct {
LicenseKey string `json:"license_key"`
MachineID string `json:"machine_id"`
Hostname string `json:"hostname"`
OS string `json:"os"`
}
type validateResponse struct {
Valid bool `json:"valid"`
Message string `json:"message"`
}
func (c *Client) validateOnline() error {
hostname, _ := os.Hostname()
body, _ := json.Marshal(validateRequest{
LicenseKey: c.cfg.LicenseKey,
MachineID: c.machineID,
Hostname: hostname,
OS: runtime.GOOS,
})
resp, err := c.http.Post(
strings.TrimRight(c.cfg.ServerURL, "/")+"/api/v1/validate",
"application/json",
bytes.NewReader(body),
)
if err != nil {
return err // network error
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("%s", resp.Status)
}
data, _ := io.ReadAll(resp.Body)
var result validateResponse
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("invalid server response: %w", err)
}
if !result.Valid {
return fmt.Errorf("%s", result.Message)
}
return nil
}
// -----------------------------------------------------------------------
// Cache read / write
// -----------------------------------------------------------------------
func (c *Client) saveCache() error {
entry := &cache{
LicenseKey: c.cfg.LicenseKey,
MachineID: c.machineID,
LastValidated: time.Now().UTC(),
}
entry.Signature = entry.sign(c.cfg.CacheSecret)
if err := os.MkdirAll(filepath.Dir(c.cachePath), 0700); err != nil {
return err
}
data, _ := json.Marshal(entry)
return os.WriteFile(c.cachePath, data, 0600)
}
func (c *Client) loadCache() (*cache, error) {
data, err := os.ReadFile(c.cachePath)
if err != nil {
return nil, fmt.Errorf("no cache file: %w", err)
}
var entry cache
if err := json.Unmarshal(data, &entry); err != nil {
return nil, fmt.Errorf("corrupt cache: %w", err)
}
if !entry.verify(c.cfg.CacheSecret) {
// Cache was tampered with — delete it
os.Remove(c.cachePath)
return nil, errors.New("cache signature invalid (possible tampering)")
}
return &entry, nil
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
// isNetworkError returns true for connectivity failures (server unreachable,
// DNS failure, timeout) as opposed to server-side rejections.
func isNetworkError(err error) bool {
if err == nil {
return false
}
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
msg := err.Error()
return strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "no such host") ||
strings.Contains(msg, "network is unreachable") ||
strings.Contains(msg, "i/o timeout") ||
strings.Contains(msg, "EOF")
}
// resolveCachePath returns the platform-appropriate cache file path:
// - Windows: %APPDATA%\<appName>\license.cache
// - macOS: ~/Library/Application Support/<appName>/license.cache
// - Linux: ~/.config/<appName>/license.cache
func resolveCachePath(appName string) (string, error) {
var base string
switch runtime.GOOS {
case "windows":
base = os.Getenv("APPDATA")
if base == "" {
return "", errors.New("APPDATA not set")
}
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, "Library", "Application Support")
default: // linux and others
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
base = xdg
} else {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, ".config")
}
}
return filepath.Join(base, appName, "license.cache"), nil
}