commit a1922f5be1177d341c03f524652967fe20f10694 Author: paadi Date: Tue May 5 08:44:55 2026 +0000 initital commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d86d22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.db +licenseServer \ No newline at end of file diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..97fded0 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,4 @@ +# Replace with your actual domain +licenses.yourdomain.com { + reverse_proxy app:8080 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..637b589 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.21-alpine AS builder + +RUN apk add --no-cache gcc musl-dev sqlite-dev + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=1 GOOS=linux go build -o licensevault ./cmd/server + +# ---- Runtime ---- +FROM alpine:3.19 +RUN apk add --no-cache sqlite-libs ca-certificates + +WORKDIR /app +COPY --from=builder /app/licensevault . +COPY templates/ templates/ + +ENV PORT=8080 +ENV DB_PATH=/data/licenses.db + +VOLUME ["/data"] +EXPOSE 8080 + +CMD ["./licensevault"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b502a91 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# LicenseVault + +Self-hosted license management server with web UI. +Built with Go + HTMX + SQLite. Runs on Proxmox via Docker. + +## Stack +- **Go** — HTTP server (chi router) +- **HTMX** — reactive UI without a JS framework +- **SQLite** — zero-config database +- **Caddy** — automatic HTTPS + +## Deploy on Proxmox + +### 1. Point your domain +Create an A record: `licenses.yourdomain.com → your-proxmox-vm-ip` + +### 2. Edit Caddyfile +``` +licenses.yourdomain.com { + reverse_proxy app:8080 +} +``` + +### 3. Deploy +```bash +git clone +cd licenseServer +docker compose up -d +``` + +### 4. First-time setup +Open `https://licenses.yourdomain.com/setup` and create your admin account. + +--- + +## REST API (for client apps) + +### Validate a license +``` +POST /api/v1/validate +Content-Type: application/json + +{ + "license_key": "XXXX-XXXX-XXXX-XXXX", + "machine_id": "sha256-hardware-fingerprint", + "hostname": "customer-pc", + "os": "windows" +} +``` + +Response: +```json +{ "valid": true, "type": "expiry" } +{ "valid": false, "message": "license expired" } +``` + +### Go client example +```go +import ( + "bytes" + "encoding/json" + "net/http" + "github.com/example/license/hwid" +) + +fp, _ := hwid.Collect() + +body, _ := json.Marshal(map[string]string{ + "license_key": licenseKey, + "machine_id": fp.Hash(), + "hostname": fp.Hostname, + "os": fp.OS, +}) + +resp, _ := http.Post( + "https://licenses.yourdomain.com/api/v1/validate", + "application/json", + bytes.NewReader(body), +) + +var result struct { + Valid bool `json:"valid"` + Message string `json:"message"` +} +json.NewDecoder(resp.Body).Decode(&result) + +if !result.Valid { + log.Fatalf("License invalid: %s", result.Message) +} +``` + +## Environment Variables + +| Variable | Default | Description | +|-----------|------------------|------------------------| +| PORT | 8080 | HTTP port | +| DB_PATH | ./licenses.db | SQLite database path | + +## License Types + +**Expiry** — valid until a date. Machine binding optional. +**Usage** — valid for N activations. Each `/api/v1/validate` call counts as one use. + +Machine ID is the SHA-256 hardware fingerprint from the `hwid` package. +Leave blank when issuing to allow any machine (floating license). diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..4ed4e62 --- /dev/null +++ b/client/client.go @@ -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%\\license.cache +// - macOS: ~/Library/Application Support//license.cache +// - Linux: ~/.config//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 +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..ec15955 --- /dev/null +++ b/client/client_test.go @@ -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()) +} diff --git a/client/hwid/hwid.go b/client/hwid/hwid.go new file mode 100644 index 0000000..371cd86 --- /dev/null +++ b/client/hwid/hwid.go @@ -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, ",") +} diff --git a/client/token/license.go b/client/token/license.go new file mode 100644 index 0000000..c5ec82e --- /dev/null +++ b/client/token/license.go @@ -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) +} diff --git a/client/token/livense_test.go b/client/token/livense_test.go new file mode 100644 index 0000000..fb7448a --- /dev/null +++ b/client/token/livense_test.go @@ -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) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..730bbcc --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "net/http" + "os" + + "gitea.tecamino.local/paadi/licenseServer/internal/handlers" + "gitea.tecamino.local/paadi/licenseServer/internal/handlers/middleware" + "gitea.tecamino.local/paadi/licenseServer/internal/models" + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" +) + +func main() { + // Config from env with sensible defaults + port := getenv("PORT", "8080") + dbPath := getenv("DB_PATH", "./licenses.db") + + // Database + db, err := models.Open(dbPath) + if err != nil { + log.Fatalf("failed to open database: %v", err) + } + log.Printf("database opened: %s", dbPath) + + tmpl := template.Must( + template.New("").ParseGlob("templates/*.html"), + ) + + // Session store + sessions := middleware.NewSessionStore() + + // Handler + h := &handlers.Handler{ + DB: db, + Sessions: sessions, + Tmpl: tmpl, + } + + // Router + r := chi.NewRouter() + r.Use(chimiddleware.Logger) + r.Use(chimiddleware.Recoverer) + + // Public routes + r.Get("/setup", h.ShowSetup) + r.Post("/setup", h.PostSetup) + r.Get("/login", h.ShowLogin) + r.Post("/login", h.PostLogin) + + // REST API (for client apps) — no web auth needed, validate by license key + r.Post("/api/v1/validate", h.APIValidate) + + // Protected web UI routes + r.Group(func(r chi.Router) { + r.Use(sessions.RequireAuth) + r.Get("/", h.Dashboard) + r.Post("/logout", h.PostLogout) + r.Get("/licenses/new", h.ShowNewLicense) + r.Post("/licenses", h.PostNewLicense) + r.Delete("/licenses/{id}/revoke", h.RevokeLicense) + r.Post("/licenses/{id}/reinstate", h.ReinstateLicense) + }) + + // Redirect root to setup if no users exist + addr := fmt.Sprintf(":%s", port) + log.Printf("LicenseVault running on http://localhost%s", addr) + log.Fatal(http.ListenAndServe(addr, r)) +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d407eb6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + app: + build: . + restart: unless-stopped + environment: + - PORT=8080 + - DB_PATH=/data/licenses.db + volumes: + - licenses_data:/data + networks: + - internal + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + networks: + - internal + +volumes: + licenses_data: + caddy_data: + caddy_config: + +networks: + internal: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2f41eb6 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module gitea.tecamino.local/paadi/licenseServer + +go 1.26.1 + +require ( + github.com/go-chi/chi/v5 v5.0.12 + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.22.0 + modernc.org/sqlite v1.50.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..22a39c6 --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..422b2b2 --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,227 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + "time" + + "gitea.tecamino.local/paadi/licenseServer/internal/handlers/middleware" + "gitea.tecamino.local/paadi/licenseServer/internal/models" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// Handler holds shared dependencies for all HTTP handlers. +type Handler struct { + DB *models.DB + Sessions *middleware.SessionStore + Tmpl *template.Template +} + +// --- Auth --- + +func (h *Handler) ShowLogin(w http.ResponseWriter, r *http.Request) { + h.Tmpl.ExecuteTemplate(w, "login.html", nil) +} + +func (h *Handler) PostLogin(w http.ResponseWriter, r *http.Request) { + email := r.FormValue("email") + pass := r.FormValue("password") + + id, hash, err := h.DB.GetUserByEmail(email) + if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) != nil { + h.Tmpl.ExecuteTemplate(w, "login.html", map[string]string{"Error": "Invalid email or password"}) + return + } + token := h.Sessions.Create(id) + middleware.SetSession(w, token) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (h *Handler) PostLogout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("session"); err == nil { + h.Sessions.Delete(cookie.Value) + } + middleware.ClearSession(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (h *Handler) ShowSetup(w http.ResponseWriter, r *http.Request) { + if h.DB.UserCount() > 0 { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + h.Tmpl.ExecuteTemplate(w, "setup.html", nil) +} + +func (h *Handler) PostSetup(w http.ResponseWriter, r *http.Request) { + if h.DB.UserCount() > 0 { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + email := r.FormValue("email") + pass := r.FormValue("password") + if email == "" || len(pass) < 8 { + h.Tmpl.ExecuteTemplate(w, "setup.html", map[string]string{"Error": "Email required, password min 8 chars"}) + return + } + hash, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + h.DB.CreateUser(uuid.New().String(), email, string(hash)) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +// --- Dashboard --- + +func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { + licenses, err := h.DB.ListLicenses() + if err != nil { + http.Error(w, "DB error", 500) + return + } + + active, revoked := 0, 0 + for _, l := range licenses { + if l.Active { + active++ + } else { + revoked++ + } + } + h.Tmpl.ExecuteTemplate(w, "dashboard.html", map[string]any{ + "Page": "dashboard", + "Licenses": licenses, + "Total": len(licenses), + "ActiveCount": active, + "RevokedCount": revoked, + }) +} + +// --- License management --- + +func (h *Handler) ShowNewLicense(w http.ResponseWriter, r *http.Request) { + h.Tmpl.ExecuteTemplate(w, "new_license.html", map[string]string{ + "Page": "new", + + "GeneratedKey": GenerateKey(), + }) +} + +func (h *Handler) PostNewLicense(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + + licType := r.FormValue("type") + l := &models.License{ + ID: uuid.New().String(), + Key: r.FormValue("key"), + CustomerName: r.FormValue("customer_name"), + CustomerEmail: r.FormValue("customer_email"), + ProductName: r.FormValue("product_name"), + MachineID: r.FormValue("machine_id"), + Type: licType, + Active: true, + CreatedAt: time.Now(), + } + + if licType == "expiry" { + t, err := time.Parse("2006-01-02", r.FormValue("expires_at")) + if err == nil { + l.ExpiresAt = &t + } + } else { + n := 0 + + fmt.Sscanf(r.FormValue("max_uses"), "%d", &n) + l.MaxUses = &n + } + + if err := h.DB.CreateLicense(l); err != nil { + fmt.Printf("CreateLicense error: %v\n", err) + h.Tmpl.ExecuteTemplate(w, "new_license.html", map[string]any{ + "Error": err.Error(), + "GeneratedKey": l.Key, + }) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (h *Handler) RevokeLicense(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + h.DB.RevokeLicense(id) + w.Header().Set("HX-Refresh", "true") +} + +func (h *Handler) ReinstateLicense(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + h.DB.ActivateLicense(id) + w.Header().Set("HX-Refresh", "true") +} + +// --- REST API for client apps --- + +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,omitempty"` + Type string `json:"type,omitempty"` + Expires *time.Time `json:"expires,omitempty"` +} + +func (h *Handler) APIValidate(w http.ResponseWriter, r *http.Request) { + var req validateRequest + json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "application/json") + + license, err := h.DB.GetLicenseByKey(req.LicenseKey) + if err != nil || license == nil { + json.NewEncoder(w).Encode(validateResponse{Valid: false, Message: "license not found"}) + return + } + if !license.Active { + json.NewEncoder(w).Encode(validateResponse{Valid: false, Message: "license revoked"}) + return + } + + // Machine binding check + if license.MachineID != "" && license.MachineID != req.MachineID { + json.NewEncoder(w).Encode(validateResponse{Valid: false, Message: "machine mismatch"}) + return + } + + // Expiry check + if license.Type == "expiry" && license.ExpiresAt != nil { + if time.Now().After(*license.ExpiresAt) { + json.NewEncoder(w).Encode(validateResponse{Valid: false, Message: "license expired"}) + return + } + } + + // Usage check + if license.Type == "usage" && license.MaxUses != nil { + if license.UsedCount >= *license.MaxUses { + json.NewEncoder(w).Encode(validateResponse{Valid: false, Message: "usage limit reached"}) + return + } + h.DB.IncrementUsage(license.ID) + } + + // Record activation + h.DB.RecordActivation(&models.Activation{ + ID: uuid.New().String(), + LicenseID: license.ID, + MachineID: req.MachineID, + Hostname: req.Hostname, + OS: req.OS, + ActivatedAt: time.Now(), + }) + + json.NewEncoder(w).Encode(validateResponse{Valid: true, Type: license.Type, Expires: license.ExpiresAt}) +} diff --git a/internal/handlers/keygen.go b/internal/handlers/keygen.go new file mode 100644 index 0000000..bad8673 --- /dev/null +++ b/internal/handlers/keygen.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "crypto/rand" + "fmt" + "strings" +) + +// GenerateKey produces a license key in the format XXXX-XXXX-XXXX-XXXX +func GenerateKey() string { + b := make([]byte, 8) + rand.Read(b) + hex := fmt.Sprintf("%X", b) + parts := []string{ + hex[0:4], hex[4:8], hex[8:12], hex[12:16], + } + return strings.Join(parts, "-") +} diff --git a/internal/handlers/middleware/auth.go b/internal/handlers/middleware/auth.go new file mode 100644 index 0000000..ad39e96 --- /dev/null +++ b/internal/handlers/middleware/auth.go @@ -0,0 +1,110 @@ +package middleware + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/http" + "sync" + "time" +) + +type contextKey string + +const sessionKey contextKey = "userID" + +// SessionStore is a simple in-memory session store. +// Swap for Redis in production. +type SessionStore struct { + mu sync.RWMutex + sessions map[string]sessionEntry +} + +type sessionEntry struct { + userID string + expiresAt time.Time +} + +func NewSessionStore() *SessionStore { + s := &SessionStore{sessions: make(map[string]sessionEntry)} + go s.cleanup() + return s +} + +func (s *SessionStore) Create(userID string) string { + b := make([]byte, 32) + rand.Read(b) + token := hex.EncodeToString(b) + s.mu.Lock() + s.sessions[token] = sessionEntry{userID: userID, expiresAt: time.Now().Add(24 * time.Hour)} + s.mu.Unlock() + return token +} + +func (s *SessionStore) Get(token string) (string, bool) { + s.mu.RLock() + entry, ok := s.sessions[token] + s.mu.RUnlock() + if !ok || time.Now().After(entry.expiresAt) { + return "", false + } + return entry.userID, true +} + +func (s *SessionStore) Delete(token string) { + s.mu.Lock() + delete(s.sessions, token) + s.mu.Unlock() +} + +func (s *SessionStore) cleanup() { + for range time.Tick(time.Hour) { + s.mu.Lock() + for k, v := range s.sessions { + if time.Now().After(v.expiresAt) { + delete(s.sessions, k) + } + } + s.mu.Unlock() + } +} + +// RequireAuth middleware redirects unauthenticated requests to /login. +func (s *SessionStore) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + userID, ok := s.Get(cookie.Value) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + ctx := context.WithValue(r.Context(), sessionKey, userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// SetSession writes the session cookie. +func SetSession(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 86400, + }) +} + +// ClearSession removes the session cookie. +func ClearSession(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + }) +} diff --git a/internal/models/db.go b/internal/models/db.go new file mode 100644 index 0000000..136cdd1 --- /dev/null +++ b/internal/models/db.go @@ -0,0 +1,203 @@ +package models + +import ( + "database/sql" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// License represents a license record in the database. +type License struct { + ID string + Key string + CustomerName string + CustomerEmail string + ProductName string + Type string // expiry | usage + ExpiresAt *time.Time + MaxUses *int + UsedCount int + MachineID string + Active bool + CreatedAt time.Time + SearchText string // pre-built lowercase search string for the UI +} + +// Activation records each machine that activated a license. +type Activation struct { + ID string + LicenseID string + MachineID string + Hostname string + OS string + ActivatedAt time.Time +} + +// DB wraps the SQLite connection with helper methods. +type DB struct { + conn *sql.DB +} + +// Open opens (or creates) the SQLite database at path. +func Open(path string) (*DB, error) { + conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000") + if err != nil { + return nil, err + } + db := &DB{conn: conn} + return db, db.migrate() +} + +func (db *DB) migrate() error { + _, err := db.conn.Exec(` + CREATE TABLE IF NOT EXISTS licenses ( + id TEXT PRIMARY KEY, + key TEXT UNIQUE NOT NULL, + customer_name TEXT NOT NULL, + customer_email TEXT NOT NULL, + product_name TEXT NOT NULL, + type TEXT NOT NULL, + expires_at DATETIME, + max_uses INTEGER, + used_count INTEGER NOT NULL DEFAULT 0, + machine_id TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS activations ( + id TEXT PRIMARY KEY, + license_id TEXT NOT NULL REFERENCES licenses(id), + machine_id TEXT NOT NULL, + hostname TEXT, + os TEXT, + activated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME NOT NULL + ); + `) + return err +} + +// --- License CRUD --- + +func (db *DB) CreateLicense(l *License) error { + _, err := db.conn.Exec(` + INSERT INTO licenses (id, key, customer_name, customer_email, product_name, + type, expires_at, max_uses, machine_id, active, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + l.ID, l.Key, l.CustomerName, l.CustomerEmail, l.ProductName, + l.Type, l.ExpiresAt, l.MaxUses, l.MachineID, l.Active, l.CreatedAt, + ) + return err +} + +func (db *DB) ListLicenses() ([]License, error) { + rows, err := db.conn.Query(` + SELECT id, key, customer_name, customer_email, product_name, + type, expires_at, max_uses, used_count, machine_id, active, created_at + FROM licenses ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var licenses []License + for rows.Next() { + var l License + if err := rows.Scan(&l.ID, &l.Key, &l.CustomerName, &l.CustomerEmail, + &l.ProductName, &l.Type, &l.ExpiresAt, &l.MaxUses, + &l.UsedCount, &l.MachineID, &l.Active, &l.CreatedAt); err != nil { + return nil, err + } + l.SearchText = strings.ToLower(fmt.Sprintf("%s %s %s %s %s", + l.Key, l.CustomerName, l.CustomerEmail, l.ProductName, l.MachineID)) + licenses = append(licenses, l) + } + return licenses, nil +} + +func (db *DB) GetLicenseByKey(key string) (*License, error) { + var l License + err := db.conn.QueryRow(` + SELECT id, key, customer_name, customer_email, product_name, + type, expires_at, max_uses, used_count, machine_id, active, created_at + FROM licenses WHERE key = ?`, key). + Scan(&l.ID, &l.Key, &l.CustomerName, &l.CustomerEmail, + &l.ProductName, &l.Type, &l.ExpiresAt, &l.MaxUses, + &l.UsedCount, &l.MachineID, &l.Active, &l.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &l, err +} + +func (db *DB) RevokeLicense(id string) error { + _, err := db.conn.Exec(`UPDATE licenses SET active = 0 WHERE id = ?`, id) + return err +} + +func (db *DB) ActivateLicense(id string) error { + _, err := db.conn.Exec(`UPDATE licenses SET active = 1 WHERE id = ?`, id) + return err +} + +func (db *DB) IncrementUsage(id string) error { + _, err := db.conn.Exec(`UPDATE licenses SET used_count = used_count + 1 WHERE id = ?`, id) + return err +} + +// --- Activations --- + +func (db *DB) RecordActivation(a *Activation) error { + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO activations (id, license_id, machine_id, hostname, os, activated_at) + VALUES (?,?,?,?,?,?)`, + a.ID, a.LicenseID, a.MachineID, a.Hostname, a.OS, a.ActivatedAt, + ) + return err +} + +func (db *DB) ListActivations(licenseID string) ([]Activation, error) { + rows, err := db.conn.Query(` + SELECT id, license_id, machine_id, hostname, os, activated_at + FROM activations WHERE license_id = ? ORDER BY activated_at DESC`, licenseID) + if err != nil { + return nil, err + } + defer rows.Close() + var acts []Activation + for rows.Next() { + var a Activation + rows.Scan(&a.ID, &a.LicenseID, &a.MachineID, &a.Hostname, &a.OS, &a.ActivatedAt) + acts = append(acts, a) + } + return acts, nil +} + +// --- Users --- + +func (db *DB) CreateUser(id, email, hash string) error { + _, err := db.conn.Exec(` + INSERT INTO users (id, email, password_hash, created_at) VALUES (?,?,?,?)`, + id, email, hash, time.Now()) + return err +} + +func (db *DB) GetUserByEmail(email string) (id, hash string, err error) { + err = db.conn.QueryRow( + `SELECT id, password_hash FROM users WHERE email = ?`, email). + Scan(&id, &hash) + return +} + +func (db *DB) UserCount() int { + var n int + db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + return n +} diff --git a/templates/auth.html b/templates/auth.html new file mode 100644 index 0000000..34ceaaa --- /dev/null +++ b/templates/auth.html @@ -0,0 +1,96 @@ +{{define "login.html"}} + + + + + LicenseVault — Sign In + + + + + + +{{end}} + +{{define "setup.html"}} + + + + + LicenseVault — Setup + + + +
+ +
First-time setup — create your admin account
+
+
Create Admin Account
+ {{if .Error}}
{{.Error}}
{{end}} +
+
+ + +
+
+ + +
+ +
+
+
+ + +{{end}} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..21f92ee --- /dev/null +++ b/templates/base.html @@ -0,0 +1,332 @@ +{{define "base"}} + + + + + + LicenseVault{{block "title" .}}{{end}} + + + + +
+ + +
+
+ + {{if eq .Page "dashboard"}} {{template "dashboard-title" .}} {{end}} + {{if eq .Page "new"}} {{template "new-license-title" .}} {{end}} + + + Issue License +
+
+ {{if eq .Page "dashboard"}} {{template "dashboard-content" .}} {{end}} + {{if eq .Page "new"}} {{template "new-license-content" .}} {{end}} +
+
+
+ + +{{end}} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..d4fcb24 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,223 @@ +{{define "dashboard.html"}} +{{template "base" .}} +{{end}} + +{{define "dashboard-title"}}License Management{{end}} + +{{define "dashboard-content"}} +
+
+
{{.Total}}
+
Total Licenses
+
+
+
{{.ActiveCount}}
+
Active
+
+
+
{{.RevokedCount}}
+
Revoked
+
+
+ +
+
+ All Licenses + +
+ + +
+ +
+ + + +
+
+ + + +
+ +
+ +
+ + + + + + + + + + + + + + {{range .Licenses}} + {{template "license_row.html" .}} + {{else}} + + {{end}} + +
Key Customer Product Type Status Expires / Uses Actions
No licenses yet — issue your first one.
+ +
+
+ + + + +{{end}} \ No newline at end of file diff --git a/templates/license_now.html b/templates/license_now.html new file mode 100644 index 0000000..7facc8a --- /dev/null +++ b/templates/license_now.html @@ -0,0 +1,56 @@ +{{define "license_row.html"}} + + + {{.Key}} + + +
{{.CustomerName}}
+
{{.CustomerEmail}}
+ + {{.ProductName}} + + {{if eq .Type "expiry"}} + Expiry + {{else}} + Usage + {{end}} + + + {{if .Active}} + Active + {{else}} + Revoked + {{end}} + + + {{if eq .Type "expiry"}} + {{if .ExpiresAt}}{{.ExpiresAt.Format "2006-01-02"}}{{else}}—{{end}} + {{else}} + {{.UsedCount}}{{if .MaxUses}} / {{.MaxUses}}{{end}} uses + {{end}} + + + {{if .Active}} + + {{else}} + + {{end}} + + +{{end}} \ No newline at end of file diff --git a/templates/new_license.html b/templates/new_license.html new file mode 100644 index 0000000..2755624 --- /dev/null +++ b/templates/new_license.html @@ -0,0 +1,90 @@ +{{define "new_license.html"}} +{{template "base" .}} +{{end}} + +{{define "new-license-title"}}Issue New License{{end}} + +{{define "new-license-content"}} +
+
+
+ New License +
+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ + + +
+ + +
+ +
+ + Cancel +
+
+
+
+ + +{{end}} \ No newline at end of file