353 lines
9.7 KiB
Go
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
|
|
}
|