initital commit

This commit is contained in:
2026-05-05 08:44:55 +00:00
commit a1922f5be1
22 changed files with 2922 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
*.db
licenseServer
+4
View File
@@ -0,0 +1,4 @@
# Replace with your actual domain
licenses.yourdomain.com {
reverse_proxy app:8080
}
+26
View File
@@ -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"]
+105
View File
@@ -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 <this-repo>
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).
+352
View File
@@ -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
}
+172
View File
@@ -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())
}
+195
View File
@@ -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, ",")
}
+309
View File
@@ -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)
}
+215
View File
@@ -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)
}
}
+79
View File
@@ -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
}
+32
View File
@@ -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:
+21
View File
@@ -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
)
+55
View File
@@ -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=
+227
View File
@@ -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})
}
+18
View File
@@ -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, "-")
}
+110
View File
@@ -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,
})
}
+203
View File
@@ -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
}
+96
View File
@@ -0,0 +1,96 @@
{{define "login.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>LicenseVault — Sign In</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
:root { --bg:#0a0a0f; --surface:#111118; --border:#1e1e2e; --accent:#00ff9d; --danger:#ff3b5c; --text:#e2e2f0; --muted:#555570; }
* { box-sizing:border-box; margin:0; padding:0; }
body { background:var(--bg); color:var(--text); font-family:'IBM Plex Sans',sans-serif; display:flex; align-items:center; justify-content:center; min-height:100vh; }
.login-wrap { width:380px; }
.login-logo { font-family:'IBM Plex Mono',monospace; font-size:18px; font-weight:600; color:var(--accent); text-align:center; margin-bottom:32px; letter-spacing:0.05em; }
.login-logo span { color:var(--muted); }
.card { background:var(--surface); border:1px solid var(--border); border-radius:4px; padding:32px; }
.card-title { font-family:'IBM Plex Mono',monospace; font-size:10px; text-transform:uppercase; letter-spacing:0.12em; color:var(--muted); margin-bottom:24px; }
.form-group { margin-bottom:16px; }
label { display:block; font-family:'IBM Plex Mono',monospace; font-size:10px; text-transform:uppercase; letter-spacing:0.1em; color:var(--muted); margin-bottom:6px; }
input { width:100%; background:var(--bg); border:1px solid var(--border); border-radius:3px; padding:10px 12px; color:var(--text); font-size:13px; font-family:'IBM Plex Sans',sans-serif; outline:none; transition:border-color 0.15s; }
input:focus { border-color:var(--accent); }
.btn { width:100%; padding:11px; background:var(--accent); color:#000; border:none; border-radius:3px; font-size:13px; font-weight:500; cursor:pointer; margin-top:8px; font-family:'IBM Plex Sans',sans-serif; transition:background 0.15s; }
.btn:hover { background:#00e68a; }
.alert { padding:10px 14px; border-radius:3px; margin-bottom:16px; font-size:12px; background:rgba(255,59,92,0.1); border:1px solid rgba(255,59,92,0.3); color:var(--danger); }
</style>
</head>
<body>
<div class="login-wrap">
<div class="login-logo">License<span>Vault</span></div>
<div class="card">
<div class="card-title">Administrator Sign In</div>
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label>Email</label>
<input type="email" name="email" autofocus required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn">Sign In</button>
</form>
</div>
</div>
</body>
</html>
{{end}}
{{define "setup.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>LicenseVault — Setup</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
:root { --bg:#0a0a0f; --surface:#111118; --border:#1e1e2e; --accent:#00ff9d; --danger:#ff3b5c; --text:#e2e2f0; --muted:#555570; }
* { box-sizing:border-box; margin:0; padding:0; }
body { background:var(--bg); color:var(--text); font-family:'IBM Plex Sans',sans-serif; display:flex; align-items:center; justify-content:center; min-height:100vh; }
.wrap { width:400px; }
.logo { font-family:'IBM Plex Mono',monospace; font-size:18px; font-weight:600; color:var(--accent); text-align:center; margin-bottom:8px; }
.logo span { color:var(--muted); }
.sub { text-align:center; color:var(--muted); font-size:12px; margin-bottom:32px; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:4px; padding:32px; }
.card-title { font-family:'IBM Plex Mono',monospace; font-size:10px; text-transform:uppercase; letter-spacing:0.12em; color:var(--muted); margin-bottom:24px; }
.form-group { margin-bottom:16px; }
label { display:block; font-family:'IBM Plex Mono',monospace; font-size:10px; text-transform:uppercase; letter-spacing:0.1em; color:var(--muted); margin-bottom:6px; }
input { width:100%; background:var(--bg); border:1px solid var(--border); border-radius:3px; padding:10px 12px; color:var(--text); font-size:13px; outline:none; transition:border-color 0.15s; font-family:'IBM Plex Sans',sans-serif; }
input:focus { border-color:var(--accent); }
.btn { width:100%; padding:11px; background:var(--accent); color:#000; border:none; border-radius:3px; font-size:13px; font-weight:500; cursor:pointer; margin-top:8px; font-family:'IBM Plex Sans',sans-serif; }
.alert { padding:10px 14px; border-radius:3px; margin-bottom:16px; font-size:12px; background:rgba(255,59,92,0.1); border:1px solid rgba(255,59,92,0.3); color:var(--danger); }
</style>
</head>
<body>
<div class="wrap">
<div class="logo">License<span>Vault</span></div>
<div class="sub">First-time setup — create your admin account</div>
<div class="card">
<div class="card-title">Create Admin Account</div>
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
<form method="POST" action="/setup">
<div class="form-group">
<label>Email</label>
<input type="email" name="email" autofocus required>
</div>
<div class="form-group">
<label>Password <span style="color:var(--muted)">(min 8 chars)</span></label>
<input type="password" name="password" minlength="8" required>
</div>
<button type="submit" class="btn">Create Account & Continue</button>
</form>
</div>
</div>
</body>
</html>
{{end}}
+332
View File
@@ -0,0 +1,332 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LicenseVault{{block "title" .}}{{end}}</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
:root {
--bg: #0a0a0f;
--surface: #111118;
--border: #1e1e2e;
--accent: #00ff9d;
--accent2: #0066ff;
--danger: #ff3b5c;
--warn: #ffaa00;
--text: #e2e2f0;
--muted: #555570;
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
}
/* Layout */
.layout { display: flex; min-height: 100vh; }
/* Sidebar */
.sidebar {
width: 220px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 0;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-logo {
padding: 24px 20px;
border-bottom: 1px solid var(--border);
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--accent);
text-transform: uppercase;
}
.sidebar-logo span { color: var(--muted); }
.sidebar-nav { padding: 16px 0; flex: 1; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
color: var(--muted);
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
border-left: 2px solid transparent;
}
.nav-item:hover, .nav-item.active {
color: var(--text);
background: rgba(255,255,255,0.03);
border-left-color: var(--accent);
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border);
}
/* Main */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.topbar {
height: 56px;
border-bottom: 1px solid var(--border);
padding: 0 28px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface);
}
.topbar-title {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.content { padding: 28px; flex: 1; overflow-y: auto; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 24px;
margin-bottom: 20px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.card-title {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
/* Stats row */
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
.stat {
background: var(--surface);
border: 1px solid var(--border);
padding: 20px;
border-radius: 4px;
}
.stat-value {
font-family: var(--mono);
font-size: 28px;
font-weight: 600;
color: var(--accent);
line-height: 1;
margin-bottom: 6px;
}
.stat-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
/* Table */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
td {
padding: 12px 12px;
border-bottom: 1px solid rgba(30,30,46,0.6);
font-size: 13px;
vertical-align: middle;
}
tr:hover td { background: rgba(255,255,255,0.015); }
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 2px;
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.badge-active { background: rgba(0,255,157,0.1); color: var(--accent); border: 1px solid rgba(0,255,157,0.2); }
.badge-revoked { background: rgba(255,59,92,0.1); color: var(--danger); border: 1px solid rgba(255,59,92,0.2); }
.badge-expiry { background: rgba(0,102,255,0.1); color: #4d99ff; border: 1px solid rgba(0,102,255,0.2); }
.badge-usage { background: rgba(255,170,0,0.1); color: var(--warn); border: 1px solid rgba(255,170,0,0.2); }
/* Key display */
.license-key {
font-family: var(--mono);
font-size: 12px;
color: var(--accent);
letter-spacing: 0.05em;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 3px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s;
text-decoration: none;
font-family: var(--sans);
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover { background: #00e68a; }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
.btn-danger {
background: transparent;
color: var(--danger);
border: 1px solid rgba(255,59,92,0.3);
}
.btn-danger:hover { background: rgba(255,59,92,0.1); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
/* Forms */
.form-group { margin-bottom: 18px; }
label {
display: block;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
margin-bottom: 6px;
}
input, select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 9px 12px;
color: var(--text);
font-size: 13px;
font-family: var(--sans);
transition: border-color 0.15s;
outline: none;
}
input:focus, select:focus { border-color: var(--accent); }
select option { background: var(--bg); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-actions { display: flex; gap: 10px; margin-top: 24px; }
/* Alert */
.alert {
padding: 12px 16px;
border-radius: 3px;
margin-bottom: 20px;
font-size: 13px;
}
.alert-error { background: rgba(255,59,92,0.1); border: 1px solid rgba(255,59,92,0.3); color: var(--danger); }
/* Type toggle */
.type-toggle { display: flex; gap: 8px; margin-bottom: 20px; }
.type-btn {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
border-radius: 3px;
cursor: pointer;
font-size: 12px;
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 0.08em;
transition: all 0.15s;
}
.type-btn.selected {
border-color: var(--accent);
color: var(--accent);
background: rgba(0,255,157,0.05);
}
/* Logout */
.logout-btn {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 12px;
font-family: var(--sans);
padding: 6px 10px;
border-radius: 3px;
transition: color 0.15s;
}
.logout-btn:hover { color: var(--danger); }
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-logo">License<span>Vault</span></div>
<nav class="sidebar-nav">
<a href="/" class="nav-item {{if eq .Page "dashboard"}}active{{end}}">
▦ Dashboard
</a>
<a href="/licenses/new" class="nav-item {{if eq .Page "new"}}active{{end}}">
+ New License
</a>
</nav>
<div class="sidebar-footer">
<form action="/logout" method="POST">
<button type="submit" class="logout-btn">⏻ Sign out</button>
</form>
</div>
</aside>
<div class="main">
<div class="topbar">
<span class="topbar-title">
{{if eq .Page "dashboard"}} {{template "dashboard-title" .}} {{end}}
{{if eq .Page "new"}} {{template "new-license-title" .}} {{end}}
</span>
<a href="/licenses/new" class="btn btn-primary btn-sm">+ Issue License</a>
</div>
<div class="content">
{{if eq .Page "dashboard"}} {{template "dashboard-content" .}} {{end}}
{{if eq .Page "new"}} {{template "new-license-content" .}} {{end}}
</div>
</div>
</div>
</body>
</html>
{{end}}
+223
View File
@@ -0,0 +1,223 @@
{{define "dashboard.html"}}
{{template "base" .}}
{{end}}
{{define "dashboard-title"}}License Management{{end}}
{{define "dashboard-content"}}
<div class="stats">
<div class="stat">
<div class="stat-value">{{.Total}}</div>
<div class="stat-label">Total Licenses</div>
</div>
<div class="stat">
<div class="stat-value" style="color:#4d99ff">{{.ActiveCount}}</div>
<div class="stat-label">Active</div>
</div>
<div class="stat">
<div class="stat-value" style="color:var(--danger)">{{.RevokedCount}}</div>
<div class="stat-label">Revoked</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">All Licenses</span>
<span id="results-count" style="font-size:11px;color:var(--muted);font-family:var(--mono)"></span>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<div class="filter-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--muted)">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" id="search" placeholder="Search key, customer, product…" oninput="applyFilters()">
</div>
<div class="filter-chips">
<button class="chip chip-active selected" data-filter="status" data-value="" onclick="setChip(this,'status')">All</button>
<button class="chip" data-filter="status" data-value="active" onclick="setChip(this,'status')">Active</button>
<button class="chip" data-filter="status" data-value="revoked" onclick="setChip(this,'status')">Revoked</button>
</div>
<div class="filter-chips">
<button class="chip chip-active selected" data-filter="type" data-value="" onclick="setChip(this,'type')">All Types</button>
<button class="chip" data-filter="type" data-value="expiry" onclick="setChip(this,'type')">Expiry</button>
<button class="chip" data-filter="type" data-value="usage" onclick="setChip(this,'type')">Usage</button>
</div>
<button class="btn btn-ghost btn-sm" onclick="clearFilters()" id="clear-btn" style="display:none">✕ Clear</button>
</div>
<div class="table-wrap">
<table id="license-table">
<thead>
<tr>
<th class="sortable" data-col="0" onclick="sortTable(0)">Key <span class="sort-icon"></span></th>
<th class="sortable" data-col="1" onclick="sortTable(1)">Customer <span class="sort-icon"></span></th>
<th class="sortable" data-col="2" onclick="sortTable(2)">Product <span class="sort-icon"></span></th>
<th class="sortable" data-col="3" onclick="sortTable(3)">Type <span class="sort-icon"></span></th>
<th class="sortable" data-col="4" onclick="sortTable(4)">Status <span class="sort-icon"></span></th>
<th class="sortable" data-col="5" onclick="sortTable(5)">Expires / Uses <span class="sort-icon"></span></th>
<th>Actions</th>
</tr>
</thead>
<tbody id="license-tbody">
{{range .Licenses}}
{{template "license_row.html" .}}
{{else}}
<tr id="empty-row"><td colspan="7" style="color:var(--muted);text-align:center;padding:32px">No licenses yet — issue your first one.</td></tr>
{{end}}
</tbody>
</table>
<div id="no-results" style="display:none;text-align:center;padding:40px;color:var(--muted);font-size:13px">
No licenses match your filters.
</div>
</div>
</div>
<style>
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-search {
position: relative;
flex: 1;
min-width: 200px;
}
.filter-search input {
padding-left: 32px;
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 8px 12px 8px 32px;
color: var(--text);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.filter-search input:focus { border-color: var(--accent); }
.filter-chips { display: flex; gap: 6px; }
.chip {
padding: 5px 12px;
border-radius: 20px;
font-size: 11px;
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 0.07em;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
transition: all 0.15s;
}
.chip:hover { color: var(--text); border-color: var(--muted); }
.chip.selected { border-color: var(--accent); color: var(--accent); background: rgba(0,255,157,0.07); }
th.sortable {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
th.sortable:hover { color: var(--text); }
th.sort-asc .sort-icon::after { content: ' ↑'; }
th.sort-desc .sort-icon::after { content: ' ↓'; }
.sort-icon { color: var(--muted); font-size: 10px; }
th.sort-asc .sort-icon,
th.sort-desc .sort-icon { color: var(--accent); }
</style>
<script>
// --- State ---
let sortCol = -1;
let sortDir = 1; // 1 = asc, -1 = desc
let filters = { status: '', type: '' };
// --- Sort ---
function sortTable(col) {
if (sortCol === col) {
sortDir *= -1;
} else {
sortCol = col;
sortDir = 1;
}
// Update header icons
document.querySelectorAll('th.sortable').forEach(th => {
th.classList.remove('sort-asc', 'sort-desc');
});
const th = document.querySelector(`th[data-col="${col}"]`);
th.classList.add(sortDir === 1 ? 'sort-asc' : 'sort-desc');
const tbody = document.getElementById('license-tbody');
const rows = Array.from(tbody.querySelectorAll('tr[data-key]'));
rows.sort((a, b) => {
const aVal = a.children[col]?.dataset?.sort || a.children[col]?.innerText.trim() || '';
const bVal = b.children[col]?.dataset?.sort || b.children[col]?.innerText.trim() || '';
return aVal.localeCompare(bVal, undefined, { numeric: true }) * sortDir;
});
rows.forEach(r => tbody.appendChild(r));
applyFilters();
}
// --- Filter ---
function setChip(el, filterKey) {
document.querySelectorAll(`.chip[data-filter="${filterKey}"]`).forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
filters[filterKey] = el.dataset.value;
applyFilters();
}
function applyFilters() {
const search = document.getElementById('search').value.toLowerCase().trim();
const tbody = document.getElementById('license-tbody');
const rows = Array.from(tbody.querySelectorAll('tr[data-key]'));
let visible = 0;
rows.forEach(row => {
const text = row.dataset.search || '';
const status = row.dataset.status || '';
const type = row.dataset.type || '';
const matchSearch = !search || text.includes(search); // text is pre-lowercased by server
const matchStatus = !filters.status || status === filters.status;
const matchType = !filters.type || type === filters.type;
const show = matchSearch && matchStatus && matchType;
row.style.display = show ? '' : 'none';
if (show) visible++;
});
// Update results count
const total = rows.length;
const countEl = document.getElementById('results-count');
countEl.textContent = visible === total
? `${total} license${total !== 1 ? 's' : ''}`
: `${visible} of ${total} licenses`;
// Show/hide empty state
document.getElementById('no-results').style.display = visible === 0 && total > 0 ? '' : 'none';
// Show clear button if any filter active
const anyFilter = search || filters.status || filters.type;
document.getElementById('clear-btn').style.display = anyFilter ? '' : 'none';
}
function clearFilters() {
document.getElementById('search').value = '';
filters = { status: '', type: '' };
document.querySelectorAll('.chip').forEach(c => {
c.classList.toggle('selected', c.dataset.value === '');
});
applyFilters();
}
// Init count on load
document.addEventListener('DOMContentLoaded', applyFilters);
</script>
{{end}}
+56
View File
@@ -0,0 +1,56 @@
{{define "license_row.html"}}
<tr data-key="{{.ID}}"
data-search="{{.SearchText}}"
data-status="{{if .Active}}active{{else}}revoked{{end}}"
data-type="{{.Type}}">
<td data-sort="{{.Key}}">
<span class="license-key">{{.Key}}</span>
</td>
<td data-sort="{{.CustomerName}}">
<div style="font-weight:500">{{.CustomerName}}</div>
<div style="color:var(--muted);font-size:11px">{{.CustomerEmail}}</div>
</td>
<td data-sort="{{.ProductName}}" style="color:var(--muted)">{{.ProductName}}</td>
<td data-sort="{{.Type}}">
{{if eq .Type "expiry"}}
<span class="badge badge-expiry">Expiry</span>
{{else}}
<span class="badge badge-usage">Usage</span>
{{end}}
</td>
<td data-sort="{{if .Active}}active{{else}}revoked{{end}}">
{{if .Active}}
<span class="badge badge-active">Active</span>
{{else}}
<span class="badge badge-revoked">Revoked</span>
{{end}}
</td>
<td data-sort="{{if eq .Type "expiry"}}{{if .ExpiresAt}}{{.ExpiresAt.Format "2006-01-02"}}{{else}}9999{{end}}{{else}}{{.UsedCount}}{{end}}"
style="font-family:var(--mono);font-size:12px;color:var(--muted)">
{{if eq .Type "expiry"}}
{{if .ExpiresAt}}{{.ExpiresAt.Format "2006-01-02"}}{{else}}—{{end}}
{{else}}
{{.UsedCount}}{{if .MaxUses}} / {{.MaxUses}}{{end}} uses
{{end}}
</td>
<td>
{{if .Active}}
<button class="btn btn-danger btn-sm"
hx-delete="/licenses/{{.ID}}/revoke"
hx-confirm="Revoke this license?"
hx-target="closest tr"
hx-swap="outerHTML">
Revoke
</button>
{{else}}
<button class="btn btn-ghost btn-sm"
hx-post="/licenses/{{.ID}}/reinstate"
hx-confirm="Reinstate this license?"
hx-target="closest tr"
hx-swap="outerHTML">
Reinstate
</button>
{{end}}
</td>
</tr>
{{end}}
+90
View File
@@ -0,0 +1,90 @@
{{define "new_license.html"}}
{{template "base" .}}
{{end}}
{{define "new-license-title"}}Issue New License{{end}}
{{define "new-license-content"}}
<div style="max-width:600px">
<div class="card">
<div class="card-header">
<span class="card-title">New License</span>
</div>
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<form method="POST" action="/licenses">
<div class="form-row">
<div class="form-group">
<label>Customer Name</label>
<input type="text" name="customer_name" placeholder="Acme Corp" required>
</div>
<div class="form-group">
<label>Customer Email</label>
<input type="email" name="customer_email" placeholder="admin@acme.com" required>
</div>
</div>
<div class="form-group">
<label>Product Name</label>
<input type="text" name="product_name" placeholder="My App Pro" required>
</div>
<div class="form-group">
<label>License Key</label>
<input type="text" name="key" value="{{.GeneratedKey}}" required
style="font-family:var(--mono);letter-spacing:0.05em;color:var(--accent)">
</div>
<div class="form-group">
<label>License Type</label>
<div class="type-toggle">
<button type="button" class="type-btn selected" id="btn-expiry"
onclick="setType('expiry')">⏱ Expiry Date</button>
<button type="button" class="type-btn" id="btn-usage"
onclick="setType('usage')">🔢 Usage Count</button>
</div>
<input type="hidden" name="type" id="type-input" value="expiry">
</div>
<div id="expiry-fields">
<div class="form-group">
<label>Expires On</label>
<input type="date" name="expires_at">
</div>
</div>
<div id="usage-fields" style="display:none">
<div class="form-group">
<label>Max Activations</label>
<input type="number" name="max_uses" placeholder="5" min="1" value="5">
</div>
</div>
<div class="form-group">
<label>Bind to Machine ID <span style="color:var(--muted)">(optional)</span></label>
<input type="text" name="machine_id" placeholder="Hardware fingerprint hash — leave blank for floating license"
style="font-family:var(--mono);font-size:11px">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Issue License</button>
<a href="/" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
<script>
function setType(type) {
document.getElementById('type-input').value = type;
document.getElementById('expiry-fields').style.display = type === 'expiry' ? '' : 'none';
document.getElementById('usage-fields').style.display = type === 'usage' ? '' : 'none';
document.getElementById('btn-expiry').classList.toggle('selected', type === 'expiry');
document.getElementById('btn-usage').classList.toggle('selected', type === 'usage');
}
</script>
{{end}}