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
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.tecamino.local/paadi/licenseServer/client"
|
||||
"gitea.tecamino.local/paadi/licenseServer/client/hwid"
|
||||
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testMachineID = "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
|
||||
|
||||
var testSecret = []byte("test-cache-secret-32bytes-minimum!")
|
||||
|
||||
func newTestServer(valid bool, message string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"valid": valid, "message": message})
|
||||
}))
|
||||
}
|
||||
|
||||
func newClient(t *testing.T, serverURL string) *client.Client {
|
||||
t.Helper()
|
||||
c, err := client.New(client.Config{
|
||||
ServerURL: serverURL,
|
||||
LicenseKey: "TEST-1234-ABCD-5678",
|
||||
AppName: "lictest_" + t.Name(),
|
||||
GracePeriod: 7 * 24 * time.Hour,
|
||||
CacheSecret: testSecret,
|
||||
}, testMachineID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Remove(c.CachePath()) })
|
||||
return c
|
||||
}
|
||||
|
||||
func signCache(key []byte, licenseKey, machineID, ts string) string {
|
||||
payload := licenseKey + "|" + machineID + "|" + ts
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(payload))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func writeCache(t *testing.T, path string, licenseKey, machineID string, validatedAt time.Time) {
|
||||
t.Helper()
|
||||
ts := validatedAt.UTC().Format(time.RFC3339)
|
||||
entry := map[string]any{
|
||||
"license_key": licenseKey,
|
||||
"machine_id": machineID,
|
||||
"last_validated": ts,
|
||||
"sig": signCache(testSecret, licenseKey, machineID, ts),
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(path), 0700)
|
||||
data, _ := json.Marshal(entry)
|
||||
os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func TestOnlineValid(t *testing.T) {
|
||||
srv := newTestServer(true, "")
|
||||
defer srv.Close()
|
||||
result := newClient(t, srv.URL).Check()
|
||||
if !result.Valid || result.Offline {
|
||||
t.Fatalf("expected online valid, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineInvalid(t *testing.T) {
|
||||
srv := newTestServer(false, "license revoked")
|
||||
defer srv.Close()
|
||||
result := newClient(t, srv.URL).Check()
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid")
|
||||
}
|
||||
if result.Message != "license revoked" {
|
||||
t.Fatalf("unexpected message: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineValidSavesCache(t *testing.T) {
|
||||
srv := newTestServer(true, "")
|
||||
defer srv.Close()
|
||||
c := newClient(t, srv.URL)
|
||||
c.Check()
|
||||
if _, err := os.Stat(c.CachePath()); err != nil {
|
||||
t.Fatal("expected cache file to be created after online validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineWithinGrace(t *testing.T) {
|
||||
srv := newTestServer(true, "")
|
||||
c := newClient(t, srv.URL)
|
||||
c.Check()
|
||||
srv.Close()
|
||||
result := c.Check()
|
||||
if !result.Valid || !result.Offline {
|
||||
t.Fatalf("expected offline valid in grace period, got: %s", result.Message)
|
||||
}
|
||||
if result.GraceLeft <= 0 {
|
||||
t.Fatal("expected positive grace remaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineExpiredGrace(t *testing.T) {
|
||||
c := newClient(t, "http://127.0.0.1:19999")
|
||||
writeCache(t, c.CachePath(), "TEST-1234-ABCD-5678", testMachineID, time.Now().Add(-8*24*time.Hour))
|
||||
result := c.Check()
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid after grace period expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineNoServerNoCache(t *testing.T) {
|
||||
result := newClient(t, "http://127.0.0.1:19999").Check()
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid with no server and no cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineWrongKey(t *testing.T) {
|
||||
c := newClient(t, "http://127.0.0.1:19999")
|
||||
writeCache(t, c.CachePath(), "DIFF-ERENT-KEY0-0000", testMachineID, time.Now())
|
||||
result := c.Check()
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid when cached key does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineWrongMachine(t *testing.T) {
|
||||
c := newClient(t, "http://127.0.0.1:19999")
|
||||
writeCache(t, c.CachePath(), "TEST-1234-ABCD-5678", "different-machine-id", time.Now())
|
||||
result := c.Check()
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid when cached machine does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedCache(t *testing.T) {
|
||||
srv := newTestServer(true, "")
|
||||
c := newClient(t, srv.URL)
|
||||
c.Check()
|
||||
srv.Close()
|
||||
|
||||
data, _ := os.ReadFile(c.CachePath())
|
||||
var entry map[string]any
|
||||
json.Unmarshal(data, &entry)
|
||||
entry["last_validated"] = time.Now().Add(10 * time.Second).Format(time.RFC3339)
|
||||
tampered, _ := json.Marshal(entry)
|
||||
os.WriteFile(c.CachePath(), tampered, 0600)
|
||||
|
||||
result := c.Check()
|
||||
if result.Valid {
|
||||
t.Fatal("tampered cache should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMachineHash(t *testing.T) {
|
||||
fp, err := hwid.Collect()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log(fp.Hash())
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Package hwid collects stable hardware identifiers to bind licenses
|
||||
// to a specific machine. Works on Linux, Windows, and macOS.
|
||||
package hwid
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Fingerprint holds raw hardware identifiers collected from the system.
|
||||
type Fingerprint struct {
|
||||
MACAddresses []string // Physical network interface MACs
|
||||
Hostname string
|
||||
CPUInfo string // CPU model / identifier
|
||||
MachineID string // OS-level machine UUID
|
||||
OS string // runtime.GOOS
|
||||
}
|
||||
|
||||
// Collect gathers hardware identifiers from the current machine.
|
||||
func Collect() (*Fingerprint, error) {
|
||||
fp := &Fingerprint{OS: runtime.GOOS}
|
||||
|
||||
fp.Hostname, _ = os.Hostname()
|
||||
fp.MACAddresses = collectMACs()
|
||||
fp.CPUInfo = collectCPU()
|
||||
fp.MachineID = collectMachineID()
|
||||
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// Hash returns a stable SHA-256 fingerprint string derived from the
|
||||
// collected identifiers. Use this as the machine ID in your license claims.
|
||||
// The hash is hex-encoded and always 64 characters long.
|
||||
func (f *Fingerprint) Hash() string {
|
||||
// Sort MACs for stability (interface order can vary)
|
||||
macs := stableJoin(f.MACAddresses)
|
||||
|
||||
parts := []string{
|
||||
"mac=" + macs,
|
||||
"host=" + f.Hostname,
|
||||
"cpu=" + f.CPUInfo,
|
||||
"mid=" + f.MachineID,
|
||||
"os=" + f.OS,
|
||||
}
|
||||
raw := strings.Join(parts, "|")
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
|
||||
// --- MAC addresses -------------------------------------------------------
|
||||
|
||||
// collectMACs returns the hardware MAC addresses of all physical interfaces.
|
||||
// Loopback and virtual interfaces are excluded.
|
||||
func collectMACs() []string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var macs []string
|
||||
for _, iface := range ifaces {
|
||||
mac := iface.HardwareAddr.String()
|
||||
if mac == "" {
|
||||
continue
|
||||
}
|
||||
// Skip loopback, virtual, docker, VPN interfaces
|
||||
name := strings.ToLower(iface.Name)
|
||||
if isVirtual(name) {
|
||||
continue
|
||||
}
|
||||
macs = append(macs, strings.ToUpper(mac))
|
||||
}
|
||||
return macs
|
||||
}
|
||||
|
||||
func isVirtual(name string) bool {
|
||||
prefixes := []string{"lo", "docker", "veth", "br-", "virbr", "vmnet", "vbox", "utun", "tun", "tap"}
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- CPU info ------------------------------------------------------------
|
||||
|
||||
func collectCPU() string {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return readLinuxCPU()
|
||||
case "darwin":
|
||||
return runCmd("sysctl", "-n", "machdep.cpu.brand_string")
|
||||
case "windows":
|
||||
return runCmd("wmic", "cpu", "get", "ProcessorId", "/value")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readLinuxCPU() string {
|
||||
data, err := os.ReadFile("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "model name") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Machine ID ----------------------------------------------------------
|
||||
|
||||
// collectMachineID reads the OS-level machine UUID:
|
||||
// - Linux: /etc/machine-id (systemd) or /var/lib/dbus/machine-id
|
||||
// - macOS: ioreg hardware UUID
|
||||
// - Windows: registry MachineGuid
|
||||
func collectMachineID() string {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return readLinuxMachineID()
|
||||
case "darwin":
|
||||
out := runCmd("ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
|
||||
return extractIORegValue(out, "IOPlatformUUID")
|
||||
case "windows":
|
||||
out := runCmd("reg", "query",
|
||||
`HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid")
|
||||
return parseRegOutput(out)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readLinuxMachineID() string {
|
||||
for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractIORegValue(output, key string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if strings.Contains(line, key) {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
return strings.Trim(strings.TrimSpace(parts[1]), `"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseRegOutput(output string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "MachineGuid") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 3 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Helpers -------------------------------------------------------------
|
||||
|
||||
func runCmd(name string, args ...string) string {
|
||||
out, err := exec.Command(name, args...).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func stableJoin(ss []string) string {
|
||||
// Simple insertion sort for stability without importing sort
|
||||
a := append([]string(nil), ss...)
|
||||
for i := 1; i < len(a); i++ {
|
||||
for j := i; j > 0 && a[j] < a[j-1]; j-- {
|
||||
a[j], a[j-1] = a[j-1], a[j]
|
||||
}
|
||||
}
|
||||
return strings.Join(a, ",")
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// Package token provides license token generation and validation.
|
||||
// Supports two license modes:
|
||||
// - Expiry-based: token is valid until a specific date
|
||||
// - Usage-based: token is valid for a fixed number of uses
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LicenseType defines the kind of license restriction.
|
||||
type LicenseType string
|
||||
|
||||
const (
|
||||
TypeExpiry LicenseType = "expiry" // Valid until end date
|
||||
TypeUsage LicenseType = "usage" // Valid for N uses
|
||||
)
|
||||
|
||||
// Claims holds the license payload embedded in the token.
|
||||
type Claims struct {
|
||||
LicenseID string `json:"lid"` // Unique license identifier
|
||||
Type LicenseType `json:"type"` // expiry or usage
|
||||
Subject string `json:"sub"` // Who the license is for (app name, user, etc.)
|
||||
IssuedAt int64 `json:"iat"` // Unix timestamp of issue
|
||||
ExpiresAt *int64 `json:"exp,omitempty"` // Unix timestamp — set for TypeExpiry
|
||||
MaxUses *int64 `json:"max,omitempty"` // Max allowed uses — set for TypeUsage
|
||||
UsedCount int64 `json:"used"` // Current use count (for TypeUsage)
|
||||
MachineID string `json:"mid,omitempty"` // Hardware fingerprint hash (optional binding)
|
||||
Metadata map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Token is the signed, serialized license string.
|
||||
type Token string
|
||||
|
||||
// ValidationResult is returned by Validate.
|
||||
type ValidationResult struct {
|
||||
Valid bool
|
||||
Claims *Claims
|
||||
Remaining *int64 // Remaining uses (TypeUsage only)
|
||||
ExpiresAt *time.Time // Expiry time (TypeExpiry only)
|
||||
Error error
|
||||
}
|
||||
|
||||
// Generator creates and signs license tokens.
|
||||
type Generator struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
// NewGenerator creates a Generator with the given HMAC secret.
|
||||
// The secret should be at least 32 bytes of random data in production.
|
||||
func NewGenerator(secret []byte) (*Generator, error) {
|
||||
if len(secret) < 16 {
|
||||
return nil, errors.New("license: secret must be at least 16 bytes")
|
||||
}
|
||||
return &Generator{secret: secret}, nil
|
||||
}
|
||||
|
||||
// IssueExpiry creates a license token valid until the given end date.
|
||||
func (g *Generator) IssueExpiry(subject string, expiresAt time.Time, meta map[string]string) (Token, error) {
|
||||
exp := expiresAt.Unix()
|
||||
claims := &Claims{
|
||||
LicenseID: newID(),
|
||||
Type: TypeExpiry,
|
||||
Subject: subject,
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: &exp,
|
||||
Metadata: meta,
|
||||
}
|
||||
return g.sign(claims)
|
||||
}
|
||||
|
||||
// IssueUsage creates a license token valid for maxUses activations.
|
||||
func (g *Generator) IssueUsage(subject string, maxUses int64, meta map[string]string) (Token, error) {
|
||||
if maxUses <= 0 {
|
||||
return "", errors.New("license: maxUses must be > 0")
|
||||
}
|
||||
claims := &Claims{
|
||||
LicenseID: newID(),
|
||||
Type: TypeUsage,
|
||||
Subject: subject,
|
||||
IssuedAt: time.Now().Unix(),
|
||||
MaxUses: &maxUses,
|
||||
UsedCount: 0,
|
||||
Metadata: meta,
|
||||
}
|
||||
return g.sign(claims)
|
||||
}
|
||||
|
||||
// IssueExpiryBound creates an expiry license locked to a specific machine.
|
||||
func (g *Generator) IssueExpiryBound(subject string, expiresAt time.Time, machineID string, meta map[string]string) (Token, error) {
|
||||
exp := expiresAt.Unix()
|
||||
claims := &Claims{
|
||||
LicenseID: newID(),
|
||||
Type: TypeExpiry,
|
||||
Subject: subject,
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: &exp,
|
||||
MachineID: machineID,
|
||||
Metadata: meta,
|
||||
}
|
||||
return g.sign(claims)
|
||||
}
|
||||
|
||||
// IssueUsageBound creates a usage license locked to a specific machine.
|
||||
func (g *Generator) IssueUsageBound(subject string, maxUses int64, machineID string, meta map[string]string) (Token, error) {
|
||||
if maxUses <= 0 {
|
||||
return "", errors.New("license: maxUses must be > 0")
|
||||
}
|
||||
claims := &Claims{
|
||||
LicenseID: newID(),
|
||||
Type: TypeUsage,
|
||||
Subject: subject,
|
||||
IssuedAt: time.Now().Unix(),
|
||||
MaxUses: &maxUses,
|
||||
MachineID: machineID,
|
||||
Metadata: meta,
|
||||
}
|
||||
return g.sign(claims)
|
||||
}
|
||||
|
||||
// sign serializes claims and appends an HMAC signature.
|
||||
func (g *Generator) sign(c *Claims) (Token, error) {
|
||||
payload, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("license: marshal failed: %w", err)
|
||||
}
|
||||
b64 := base64.RawURLEncoding.EncodeToString(payload)
|
||||
sig := g.hmac(b64)
|
||||
return Token(b64 + "." + sig), nil
|
||||
}
|
||||
|
||||
// hmac returns a base64url-encoded HMAC-SHA256 of data.
|
||||
func (g *Generator) hmac(data string) string {
|
||||
mac := hmac.New(sha256.New, g.secret)
|
||||
mac.Write([]byte(data))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reader validates and (for usage licenses) tracks consumption.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Reader validates tokens and tracks usage counts.
|
||||
type Reader struct {
|
||||
secret []byte
|
||||
mu sync.Mutex
|
||||
usageLedger map[string]int64 // licenseID -> used count (in-memory; swap for DB in prod)
|
||||
}
|
||||
|
||||
// NewReader creates a Reader with the same secret used by the Generator.
|
||||
func NewReader(secret []byte) (*Reader, error) {
|
||||
if len(secret) < 16 {
|
||||
return nil, errors.New("license: secret must be at least 16 bytes")
|
||||
}
|
||||
return &Reader{
|
||||
secret: secret,
|
||||
usageLedger: make(map[string]int64),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate checks the token signature and license constraints.
|
||||
// Pass machineID (hwid.Fingerprint.Hash()) to enforce hardware binding, or "" to skip.
|
||||
// It does NOT consume a usage unit - call Consume for that.
|
||||
func (r *Reader) Validate(t Token, machineID string) ValidationResult {
|
||||
claims, err := r.parse(t)
|
||||
if err != nil {
|
||||
return ValidationResult{Valid: false, Error: err}
|
||||
}
|
||||
|
||||
// Hardware binding check
|
||||
if claims.MachineID != "" {
|
||||
if machineID == "" {
|
||||
return ValidationResult{Valid: false, Error: errors.New("license: cannot verify machine binding"), Claims: claims}
|
||||
}
|
||||
if !hmac.Equal([]byte(claims.MachineID), []byte(machineID)) {
|
||||
return ValidationResult{Valid: false, Error: errors.New("license: bound to a different machine"), Claims: claims}
|
||||
}
|
||||
}
|
||||
|
||||
switch claims.Type {
|
||||
case TypeExpiry:
|
||||
if claims.ExpiresAt == nil {
|
||||
return ValidationResult{Valid: false, Error: errors.New("license: missing expiry date"), Claims: claims}
|
||||
}
|
||||
exp := time.Unix(*claims.ExpiresAt, 0)
|
||||
if time.Now().After(exp) {
|
||||
return ValidationResult{
|
||||
Valid: false,
|
||||
Claims: claims,
|
||||
ExpiresAt: &exp,
|
||||
Error: fmt.Errorf("license: expired on %s", exp.Format(time.DateOnly)),
|
||||
}
|
||||
}
|
||||
return ValidationResult{Valid: true, Claims: claims, ExpiresAt: &exp}
|
||||
|
||||
case TypeUsage:
|
||||
if claims.MaxUses == nil {
|
||||
return ValidationResult{Valid: false, Error: errors.New("license: missing max uses"), Claims: claims}
|
||||
}
|
||||
r.mu.Lock()
|
||||
used := r.usageLedger[claims.LicenseID]
|
||||
r.mu.Unlock()
|
||||
|
||||
remaining := *claims.MaxUses - used
|
||||
if remaining <= 0 {
|
||||
return ValidationResult{
|
||||
Valid: false,
|
||||
Claims: claims,
|
||||
Remaining: &remaining,
|
||||
Error: fmt.Errorf("license: usage limit of %d reached", *claims.MaxUses),
|
||||
}
|
||||
}
|
||||
return ValidationResult{Valid: true, Claims: claims, Remaining: &remaining}
|
||||
}
|
||||
|
||||
return ValidationResult{Valid: false, Error: fmt.Errorf("license: unknown type %q", claims.Type)}
|
||||
}
|
||||
|
||||
// Consume validates the token, records one usage unit, and returns the result.
|
||||
// Pass machineID to enforce hardware binding, or "" to skip.
|
||||
func (r *Reader) Consume(t Token, machineID string) (ValidationResult, error) {
|
||||
result := r.Validate(t, machineID)
|
||||
if !result.Valid {
|
||||
return result, result.Error
|
||||
}
|
||||
if result.Claims.Type != TypeUsage {
|
||||
return result, nil // expiry licenses don't need consumption
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
used := r.usageLedger[result.Claims.LicenseID] + 1
|
||||
r.usageLedger[result.Claims.LicenseID] = used
|
||||
|
||||
remaining := *result.Claims.MaxUses - used
|
||||
result.Remaining = &remaining
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LoadUsage seeds the reader with persisted usage counts (e.g. from a DB on startup).
|
||||
func (r *Reader) LoadUsage(licenseID string, usedCount int64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.usageLedger[licenseID] = usedCount
|
||||
}
|
||||
|
||||
// UsageSnapshot returns a copy of the current usage ledger for persistence.
|
||||
func (r *Reader) UsageSnapshot() map[string]int64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
snap := make(map[string]int64, len(r.usageLedger))
|
||||
for k, v := range r.usageLedger {
|
||||
snap[k] = v
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// parse verifies the signature and deserializes claims.
|
||||
func (r *Reader) parse(t Token) (*Claims, error) {
|
||||
parts := strings.SplitN(string(t), ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, errors.New("license: malformed token")
|
||||
}
|
||||
b64, sig := parts[0], parts[1]
|
||||
|
||||
// Verify signature first (constant-time)
|
||||
expected := r.hmac(b64)
|
||||
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
||||
return nil, errors.New("license: invalid signature")
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("license: decode failed: %w", err)
|
||||
}
|
||||
var claims Claims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, fmt.Errorf("license: unmarshal failed: %w", err)
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// hmac returns a base64url-encoded HMAC-SHA256 of data.
|
||||
func (r *Reader) hmac(data string) string {
|
||||
mac := hmac.New(sha256.New, r.secret)
|
||||
mac.Write([]byte(data))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// newID generates a random 16-byte hex license ID.
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package token_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.tecamino.local/paadi/licenseServer/client/hwid"
|
||||
"gitea.tecamino.local/paadi/licenseServer/client/token"
|
||||
)
|
||||
|
||||
var secret = []byte("super-secret-key-32-bytes-minimum!")
|
||||
|
||||
func newPair(t *testing.T) (*token.Generator, *token.Reader) {
|
||||
t.Helper()
|
||||
g, err := token.NewGenerator(secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := token.NewReader(secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g, r
|
||||
}
|
||||
|
||||
// --- Expiry license tests ---
|
||||
|
||||
func TestExpiryLicense_Valid(t *testing.T) {
|
||||
g, r := newPair(t)
|
||||
tok, err := g.IssueExpiry("myapp", time.Now().Add(24*time.Hour), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fp, err := hwid.Collect()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
machineID := fp.Hash()
|
||||
|
||||
result := r.Validate(tok, machineID)
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected valid, got: %v", result.Error)
|
||||
}
|
||||
if result.ExpiresAt == nil {
|
||||
t.Fatal("expected ExpiresAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryLicense_Expired(t *testing.T) {
|
||||
g, r := newPair(t)
|
||||
tok, err := g.IssueExpiry("myapp", time.Now().Add(-1*time.Hour), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fp, err := hwid.Collect()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
machineID := fp.Hash()
|
||||
|
||||
result := r.Validate(tok, machineID)
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid for expired license")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Usage license tests ---
|
||||
|
||||
func TestUsageLicense_Valid(t *testing.T) {
|
||||
g, r := newPair(t)
|
||||
tok, err := g.IssueUsage("myapp", 5, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fp, err := hwid.Collect()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
machineID := fp.Hash()
|
||||
|
||||
result := r.Validate(tok, machineID)
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected valid: %v", result.Error)
|
||||
}
|
||||
if *result.Remaining != 5 {
|
||||
t.Fatalf("expected 5 remaining, got %d", *result.Remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageLicense_Consume(t *testing.T) {
|
||||
g, r := newPair(t)
|
||||
tok, err := g.IssueUsage("myapp", 3, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fp, err := hwid.Collect()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
machineID := fp.Hash()
|
||||
|
||||
for i := int64(2); i >= 0; i-- {
|
||||
res, err := r.Consume(tok, machineID)
|
||||
if err != nil {
|
||||
t.Fatalf("consume failed: %v", err)
|
||||
}
|
||||
if *res.Remaining != i {
|
||||
t.Fatalf("expected %d remaining, got %d", i, *res.Remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// 4th consume should fail
|
||||
_, err = r.Consume(tok, machineID)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after exhausting usage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageLicense_Exhausted(t *testing.T) {
|
||||
g, r := newPair(t)
|
||||
tok, err := g.IssueUsage("myapp", 2, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fp, err := hwid.Collect()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
machineID := fp.Hash()
|
||||
|
||||
r.Consume(tok, machineID)
|
||||
r.Consume(tok, machineID)
|
||||
|
||||
result := r.Validate(tok, machineID)
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid after exhausting uses")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tamper / signature tests ---
|
||||
|
||||
func TestTamperedToken(t *testing.T) {
|
||||
g, r := newPair(t)
|
||||
tok, _ := g.IssueExpiry("myapp", time.Now().Add(24*time.Hour), nil)
|
||||
|
||||
tampered := token.Token(string(tok) + "x")
|
||||
|
||||
fp, err := hwid.Collect()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
machineID := fp.Hash()
|
||||
|
||||
result := r.Validate(tampered, machineID)
|
||||
if result.Valid {
|
||||
t.Fatal("tampered token should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongSecret(t *testing.T) {
|
||||
g, _ := newPair(t)
|
||||
tok, _ := g.IssueExpiry("myapp", time.Now().Add(24*time.Hour), nil)
|
||||
|
||||
// Reader with different secret
|
||||
r2, _ := token.NewReader([]byte("different-secret-key-32-bytes-xx!"))
|
||||
|
||||
fp, err := hwid.Collect()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
machineID := fp.Hash()
|
||||
|
||||
result := r2.Validate(tok, machineID)
|
||||
if result.Valid {
|
||||
t.Fatal("token signed with different secret should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Usage persistence ---
|
||||
|
||||
func TestUsageLedger_Persistence(t *testing.T) {
|
||||
g, r := newPair(t)
|
||||
tok, _ := g.IssueUsage("myapp", 10, nil)
|
||||
|
||||
fp, err := hwid.Collect()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
machineID := fp.Hash()
|
||||
|
||||
res, _ := r.Consume(tok, machineID)
|
||||
res, _ = r.Consume(tok, machineID)
|
||||
_ = res
|
||||
|
||||
snap := r.UsageSnapshot()
|
||||
|
||||
// Simulate restart: new reader, load snapshot
|
||||
r2, _ := token.NewReader(secret)
|
||||
for id, count := range snap {
|
||||
r2.LoadUsage(id, count)
|
||||
}
|
||||
|
||||
result := r2.Validate(tok, machineID)
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected valid after reload: %v", result.Error)
|
||||
}
|
||||
if *result.Remaining != 8 {
|
||||
t.Fatalf("expected 8 remaining after reloading 2 uses, got %d", *result.Remaining)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user