initital commit
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user