initital commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
*.db
|
||||||
|
licenseServer
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Replace with your actual domain
|
||||||
|
licenses.yourdomain.com {
|
||||||
|
reverse_proxy app:8080
|
||||||
|
}
|
||||||
+26
@@ -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"]
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
// Package client provides license validation for client applications.
|
||||||
|
// It contacts the license server, falls back to a local signed cache
|
||||||
|
// during offline/grace periods, and blocks when the grace period expires.
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Config holds all settings for the license client.
|
||||||
|
type Config struct {
|
||||||
|
// ServerURL is the base URL of your license server.
|
||||||
|
// e.g. "https://licenses.yourdomain.com"
|
||||||
|
ServerURL string
|
||||||
|
|
||||||
|
// LicenseKey is the customer's license key (XXXX-XXXX-XXXX-XXXX).
|
||||||
|
LicenseKey string
|
||||||
|
|
||||||
|
// AppName is used to determine the cache file location.
|
||||||
|
AppName string
|
||||||
|
|
||||||
|
// GracePeriod is how long the app may run without reaching the server.
|
||||||
|
// Default: 7 days.
|
||||||
|
GracePeriod time.Duration
|
||||||
|
|
||||||
|
// RequestTimeout is the HTTP timeout when contacting the server.
|
||||||
|
// Default: 5 seconds. Keep this short so startup isn't slow offline.
|
||||||
|
RequestTimeout time.Duration
|
||||||
|
|
||||||
|
// CacheSecret is used to HMAC-sign the local cache file so users
|
||||||
|
// cannot tamper with it. Should be a constant baked into your binary.
|
||||||
|
// Must be at least 16 bytes.
|
||||||
|
CacheSecret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) defaults() {
|
||||||
|
if c.GracePeriod == 0 {
|
||||||
|
c.GracePeriod = 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
if c.RequestTimeout == 0 {
|
||||||
|
c.RequestTimeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if c.AppName == "" {
|
||||||
|
c.AppName = "app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Result
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Result is returned by Check.
|
||||||
|
type Result struct {
|
||||||
|
Valid bool
|
||||||
|
Offline bool // true if validated from local cache
|
||||||
|
CachedAt time.Time // when the cache was last refreshed from server
|
||||||
|
GraceLeft time.Duration // how much grace period remains (offline only)
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Result) String() string {
|
||||||
|
if !r.Valid {
|
||||||
|
return fmt.Sprintf("invalid: %s", r.Message)
|
||||||
|
}
|
||||||
|
if r.Offline {
|
||||||
|
return fmt.Sprintf("valid (offline, grace period: %s remaining)", r.GraceLeft.Round(time.Hour))
|
||||||
|
}
|
||||||
|
return "valid (server verified)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Cache
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type cache struct {
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
MachineID string `json:"machine_id"`
|
||||||
|
LastValidated time.Time `json:"last_validated"`
|
||||||
|
Signature string `json:"sig"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign returns HMAC-SHA256 of the cache payload fields (excluding sig itself).
|
||||||
|
func (c *cache) sign(secret []byte) string {
|
||||||
|
payload := fmt.Sprintf("%s|%s|%s", c.LicenseKey, c.MachineID, c.LastValidated.UTC().Format(time.RFC3339))
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cache) verify(secret []byte) bool {
|
||||||
|
expected := c.sign(secret)
|
||||||
|
return hmac.Equal([]byte(expected), []byte(c.Signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Client
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Client validates licenses against a remote server with offline grace period.
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
machineID string
|
||||||
|
cachePath string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a license Client. Call Check() to validate.
|
||||||
|
func New(cfg Config, machineID string) (*Client, error) {
|
||||||
|
cfg.defaults()
|
||||||
|
if cfg.ServerURL == "" {
|
||||||
|
return nil, errors.New("license: ServerURL is required")
|
||||||
|
}
|
||||||
|
if cfg.LicenseKey == "" {
|
||||||
|
return nil, errors.New("license: LicenseKey is required")
|
||||||
|
}
|
||||||
|
if len(cfg.CacheSecret) < 16 {
|
||||||
|
return nil, errors.New("license: CacheSecret must be at least 16 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath, err := resolveCachePath(cfg.AppName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("license: cannot resolve cache path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
cfg: cfg,
|
||||||
|
machineID: machineID,
|
||||||
|
cachePath: cachePath,
|
||||||
|
http: &http.Client{
|
||||||
|
Timeout: cfg.RequestTimeout,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check validates the license. Order of operations:
|
||||||
|
// 1. Try the license server (fast timeout so startup stays snappy)
|
||||||
|
// 2. If server unreachable → load local cache, check grace period
|
||||||
|
// 3. If server reachable but license invalid → block immediately (no grace)
|
||||||
|
func (c *Client) Check() Result {
|
||||||
|
// --- Attempt online validation ---
|
||||||
|
onlineErr := c.validateOnline()
|
||||||
|
|
||||||
|
if onlineErr == nil {
|
||||||
|
// Success — refresh cache and return valid
|
||||||
|
c.saveCache()
|
||||||
|
return Result{Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server was reachable but rejected the license ---
|
||||||
|
if !isNetworkError(onlineErr) {
|
||||||
|
return Result{Valid: false, Message: onlineErr.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Network error — try grace period ---
|
||||||
|
cached, cacheErr := c.loadCache()
|
||||||
|
if cacheErr != nil {
|
||||||
|
return Result{
|
||||||
|
Valid: false,
|
||||||
|
Message: fmt.Sprintf("license server unreachable and no valid cache found (%v)", cacheErr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify cache belongs to this machine and key
|
||||||
|
if cached.LicenseKey != c.cfg.LicenseKey {
|
||||||
|
return Result{Valid: false, Message: "license server unreachable and cached key does not match"}
|
||||||
|
}
|
||||||
|
if cached.MachineID != c.machineID {
|
||||||
|
return Result{Valid: false, Message: "license server unreachable and cached machine ID does not match"}
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(cached.LastValidated)
|
||||||
|
graceLeft := c.cfg.GracePeriod - elapsed
|
||||||
|
|
||||||
|
if graceLeft <= 0 {
|
||||||
|
return Result{
|
||||||
|
Valid: false,
|
||||||
|
Message: fmt.Sprintf("license server unreachable and grace period expired (%s ago)", (-graceLeft).Round(time.Hour)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{
|
||||||
|
Valid: true,
|
||||||
|
Offline: true,
|
||||||
|
CachedAt: cached.LastValidated,
|
||||||
|
GraceLeft: graceLeft,
|
||||||
|
Message: fmt.Sprintf("offline — %s grace period remaining", graceLeft.Round(time.Hour)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CachePath returns the path of the local cache file (useful for debugging).
|
||||||
|
func (c *Client) CachePath() string {
|
||||||
|
return c.cachePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Online validation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type validateRequest struct {
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
MachineID string `json:"machine_id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type validateResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) validateOnline() error {
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
body, _ := json.Marshal(validateRequest{
|
||||||
|
LicenseKey: c.cfg.LicenseKey,
|
||||||
|
MachineID: c.machineID,
|
||||||
|
Hostname: hostname,
|
||||||
|
OS: runtime.GOOS,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := c.http.Post(
|
||||||
|
strings.TrimRight(c.cfg.ServerURL, "/")+"/api/v1/validate",
|
||||||
|
"application/json",
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err // network error
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("%s", resp.Status)
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
var result validateResponse
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return fmt.Errorf("invalid server response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
return fmt.Errorf("%s", result.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Cache read / write
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (c *Client) saveCache() error {
|
||||||
|
entry := &cache{
|
||||||
|
LicenseKey: c.cfg.LicenseKey,
|
||||||
|
MachineID: c.machineID,
|
||||||
|
LastValidated: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
entry.Signature = entry.sign(c.cfg.CacheSecret)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(c.cachePath), 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(entry)
|
||||||
|
return os.WriteFile(c.cachePath, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) loadCache() (*cache, error) {
|
||||||
|
data, err := os.ReadFile(c.cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("no cache file: %w", err)
|
||||||
|
}
|
||||||
|
var entry cache
|
||||||
|
if err := json.Unmarshal(data, &entry); err != nil {
|
||||||
|
return nil, fmt.Errorf("corrupt cache: %w", err)
|
||||||
|
}
|
||||||
|
if !entry.verify(c.cfg.CacheSecret) {
|
||||||
|
// Cache was tampered with — delete it
|
||||||
|
os.Remove(c.cachePath)
|
||||||
|
return nil, errors.New("cache signature invalid (possible tampering)")
|
||||||
|
}
|
||||||
|
return &entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// isNetworkError returns true for connectivity failures (server unreachable,
|
||||||
|
// DNS failure, timeout) as opposed to server-side rejections.
|
||||||
|
func isNetworkError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "connection refused") ||
|
||||||
|
strings.Contains(msg, "no such host") ||
|
||||||
|
strings.Contains(msg, "network is unreachable") ||
|
||||||
|
strings.Contains(msg, "i/o timeout") ||
|
||||||
|
strings.Contains(msg, "EOF")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCachePath returns the platform-appropriate cache file path:
|
||||||
|
// - Windows: %APPDATA%\<appName>\license.cache
|
||||||
|
// - macOS: ~/Library/Application Support/<appName>/license.cache
|
||||||
|
// - Linux: ~/.config/<appName>/license.cache
|
||||||
|
func resolveCachePath(appName string) (string, error) {
|
||||||
|
var base string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
base = os.Getenv("APPDATA")
|
||||||
|
if base == "" {
|
||||||
|
return "", errors.New("APPDATA not set")
|
||||||
|
}
|
||||||
|
case "darwin":
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
base = filepath.Join(home, "Library", "Application Support")
|
||||||
|
default: // linux and others
|
||||||
|
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||||
|
base = xdg
|
||||||
|
} else {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
base = filepath.Join(home, ".config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(base, appName, "license.cache"), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package client_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"gitea.tecamino.local/paadi/licenseServer/client"
|
||||||
|
"gitea.tecamino.local/paadi/licenseServer/client/hwid"
|
||||||
|
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testMachineID = "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
|
||||||
|
|
||||||
|
var testSecret = []byte("test-cache-secret-32bytes-minimum!")
|
||||||
|
|
||||||
|
func newTestServer(valid bool, message string) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"valid": valid, "message": message})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(t *testing.T, serverURL string) *client.Client {
|
||||||
|
t.Helper()
|
||||||
|
c, err := client.New(client.Config{
|
||||||
|
ServerURL: serverURL,
|
||||||
|
LicenseKey: "TEST-1234-ABCD-5678",
|
||||||
|
AppName: "lictest_" + t.Name(),
|
||||||
|
GracePeriod: 7 * 24 * time.Hour,
|
||||||
|
CacheSecret: testSecret,
|
||||||
|
}, testMachineID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Remove(c.CachePath()) })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func signCache(key []byte, licenseKey, machineID, ts string) string {
|
||||||
|
payload := licenseKey + "|" + machineID + "|" + ts
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCache(t *testing.T, path string, licenseKey, machineID string, validatedAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
ts := validatedAt.UTC().Format(time.RFC3339)
|
||||||
|
entry := map[string]any{
|
||||||
|
"license_key": licenseKey,
|
||||||
|
"machine_id": machineID,
|
||||||
|
"last_validated": ts,
|
||||||
|
"sig": signCache(testSecret, licenseKey, machineID, ts),
|
||||||
|
}
|
||||||
|
os.MkdirAll(filepath.Dir(path), 0700)
|
||||||
|
data, _ := json.Marshal(entry)
|
||||||
|
os.WriteFile(path, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineValid(t *testing.T) {
|
||||||
|
srv := newTestServer(true, "")
|
||||||
|
defer srv.Close()
|
||||||
|
result := newClient(t, srv.URL).Check()
|
||||||
|
if !result.Valid || result.Offline {
|
||||||
|
t.Fatalf("expected online valid, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineInvalid(t *testing.T) {
|
||||||
|
srv := newTestServer(false, "license revoked")
|
||||||
|
defer srv.Close()
|
||||||
|
result := newClient(t, srv.URL).Check()
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid")
|
||||||
|
}
|
||||||
|
if result.Message != "license revoked" {
|
||||||
|
t.Fatalf("unexpected message: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineValidSavesCache(t *testing.T) {
|
||||||
|
srv := newTestServer(true, "")
|
||||||
|
defer srv.Close()
|
||||||
|
c := newClient(t, srv.URL)
|
||||||
|
c.Check()
|
||||||
|
if _, err := os.Stat(c.CachePath()); err != nil {
|
||||||
|
t.Fatal("expected cache file to be created after online validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfflineWithinGrace(t *testing.T) {
|
||||||
|
srv := newTestServer(true, "")
|
||||||
|
c := newClient(t, srv.URL)
|
||||||
|
c.Check()
|
||||||
|
srv.Close()
|
||||||
|
result := c.Check()
|
||||||
|
if !result.Valid || !result.Offline {
|
||||||
|
t.Fatalf("expected offline valid in grace period, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
if result.GraceLeft <= 0 {
|
||||||
|
t.Fatal("expected positive grace remaining")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfflineExpiredGrace(t *testing.T) {
|
||||||
|
c := newClient(t, "http://127.0.0.1:19999")
|
||||||
|
writeCache(t, c.CachePath(), "TEST-1234-ABCD-5678", testMachineID, time.Now().Add(-8*24*time.Hour))
|
||||||
|
result := c.Check()
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid after grace period expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfflineNoServerNoCache(t *testing.T) {
|
||||||
|
result := newClient(t, "http://127.0.0.1:19999").Check()
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid with no server and no cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfflineWrongKey(t *testing.T) {
|
||||||
|
c := newClient(t, "http://127.0.0.1:19999")
|
||||||
|
writeCache(t, c.CachePath(), "DIFF-ERENT-KEY0-0000", testMachineID, time.Now())
|
||||||
|
result := c.Check()
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid when cached key does not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfflineWrongMachine(t *testing.T) {
|
||||||
|
c := newClient(t, "http://127.0.0.1:19999")
|
||||||
|
writeCache(t, c.CachePath(), "TEST-1234-ABCD-5678", "different-machine-id", time.Now())
|
||||||
|
result := c.Check()
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid when cached machine does not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTamperedCache(t *testing.T) {
|
||||||
|
srv := newTestServer(true, "")
|
||||||
|
c := newClient(t, srv.URL)
|
||||||
|
c.Check()
|
||||||
|
srv.Close()
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(c.CachePath())
|
||||||
|
var entry map[string]any
|
||||||
|
json.Unmarshal(data, &entry)
|
||||||
|
entry["last_validated"] = time.Now().Add(10 * time.Second).Format(time.RFC3339)
|
||||||
|
tampered, _ := json.Marshal(entry)
|
||||||
|
os.WriteFile(c.CachePath(), tampered, 0600)
|
||||||
|
|
||||||
|
result := c.Check()
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("tampered cache should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMachineHash(t *testing.T) {
|
||||||
|
fp, err := hwid.Collect()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log(fp.Hash())
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Package hwid collects stable hardware identifiers to bind licenses
|
||||||
|
// to a specific machine. Works on Linux, Windows, and macOS.
|
||||||
|
package hwid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fingerprint holds raw hardware identifiers collected from the system.
|
||||||
|
type Fingerprint struct {
|
||||||
|
MACAddresses []string // Physical network interface MACs
|
||||||
|
Hostname string
|
||||||
|
CPUInfo string // CPU model / identifier
|
||||||
|
MachineID string // OS-level machine UUID
|
||||||
|
OS string // runtime.GOOS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect gathers hardware identifiers from the current machine.
|
||||||
|
func Collect() (*Fingerprint, error) {
|
||||||
|
fp := &Fingerprint{OS: runtime.GOOS}
|
||||||
|
|
||||||
|
fp.Hostname, _ = os.Hostname()
|
||||||
|
fp.MACAddresses = collectMACs()
|
||||||
|
fp.CPUInfo = collectCPU()
|
||||||
|
fp.MachineID = collectMachineID()
|
||||||
|
|
||||||
|
return fp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash returns a stable SHA-256 fingerprint string derived from the
|
||||||
|
// collected identifiers. Use this as the machine ID in your license claims.
|
||||||
|
// The hash is hex-encoded and always 64 characters long.
|
||||||
|
func (f *Fingerprint) Hash() string {
|
||||||
|
// Sort MACs for stability (interface order can vary)
|
||||||
|
macs := stableJoin(f.MACAddresses)
|
||||||
|
|
||||||
|
parts := []string{
|
||||||
|
"mac=" + macs,
|
||||||
|
"host=" + f.Hostname,
|
||||||
|
"cpu=" + f.CPUInfo,
|
||||||
|
"mid=" + f.MachineID,
|
||||||
|
"os=" + f.OS,
|
||||||
|
}
|
||||||
|
raw := strings.Join(parts, "|")
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
return fmt.Sprintf("%x", sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MAC addresses -------------------------------------------------------
|
||||||
|
|
||||||
|
// collectMACs returns the hardware MAC addresses of all physical interfaces.
|
||||||
|
// Loopback and virtual interfaces are excluded.
|
||||||
|
func collectMACs() []string {
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var macs []string
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
mac := iface.HardwareAddr.String()
|
||||||
|
if mac == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip loopback, virtual, docker, VPN interfaces
|
||||||
|
name := strings.ToLower(iface.Name)
|
||||||
|
if isVirtual(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
macs = append(macs, strings.ToUpper(mac))
|
||||||
|
}
|
||||||
|
return macs
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVirtual(name string) bool {
|
||||||
|
prefixes := []string{"lo", "docker", "veth", "br-", "virbr", "vmnet", "vbox", "utun", "tun", "tap"}
|
||||||
|
for _, p := range prefixes {
|
||||||
|
if strings.HasPrefix(name, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CPU info ------------------------------------------------------------
|
||||||
|
|
||||||
|
func collectCPU() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
return readLinuxCPU()
|
||||||
|
case "darwin":
|
||||||
|
return runCmd("sysctl", "-n", "machdep.cpu.brand_string")
|
||||||
|
case "windows":
|
||||||
|
return runCmd("wmic", "cpu", "get", "ProcessorId", "/value")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLinuxCPU() string {
|
||||||
|
data, err := os.ReadFile("/proc/cpuinfo")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.HasPrefix(line, "model name") {
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Machine ID ----------------------------------------------------------
|
||||||
|
|
||||||
|
// collectMachineID reads the OS-level machine UUID:
|
||||||
|
// - Linux: /etc/machine-id (systemd) or /var/lib/dbus/machine-id
|
||||||
|
// - macOS: ioreg hardware UUID
|
||||||
|
// - Windows: registry MachineGuid
|
||||||
|
func collectMachineID() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
return readLinuxMachineID()
|
||||||
|
case "darwin":
|
||||||
|
out := runCmd("ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
|
||||||
|
return extractIORegValue(out, "IOPlatformUUID")
|
||||||
|
case "windows":
|
||||||
|
out := runCmd("reg", "query",
|
||||||
|
`HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid")
|
||||||
|
return parseRegOutput(out)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLinuxMachineID() string {
|
||||||
|
for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err == nil {
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractIORegValue(output, key string) string {
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
if strings.Contains(line, key) {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return strings.Trim(strings.TrimSpace(parts[1]), `"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRegOutput(output string) string {
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "MachineGuid") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
func runCmd(name string, args ...string) string {
|
||||||
|
out, err := exec.Command(name, args...).Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stableJoin(ss []string) string {
|
||||||
|
// Simple insertion sort for stability without importing sort
|
||||||
|
a := append([]string(nil), ss...)
|
||||||
|
for i := 1; i < len(a); i++ {
|
||||||
|
for j := i; j > 0 && a[j] < a[j-1]; j-- {
|
||||||
|
a[j], a[j-1] = a[j-1], a[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(a, ",")
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
// Package token provides license token generation and validation.
|
||||||
|
// Supports two license modes:
|
||||||
|
// - Expiry-based: token is valid until a specific date
|
||||||
|
// - Usage-based: token is valid for a fixed number of uses
|
||||||
|
package token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LicenseType defines the kind of license restriction.
|
||||||
|
type LicenseType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeExpiry LicenseType = "expiry" // Valid until end date
|
||||||
|
TypeUsage LicenseType = "usage" // Valid for N uses
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims holds the license payload embedded in the token.
|
||||||
|
type Claims struct {
|
||||||
|
LicenseID string `json:"lid"` // Unique license identifier
|
||||||
|
Type LicenseType `json:"type"` // expiry or usage
|
||||||
|
Subject string `json:"sub"` // Who the license is for (app name, user, etc.)
|
||||||
|
IssuedAt int64 `json:"iat"` // Unix timestamp of issue
|
||||||
|
ExpiresAt *int64 `json:"exp,omitempty"` // Unix timestamp — set for TypeExpiry
|
||||||
|
MaxUses *int64 `json:"max,omitempty"` // Max allowed uses — set for TypeUsage
|
||||||
|
UsedCount int64 `json:"used"` // Current use count (for TypeUsage)
|
||||||
|
MachineID string `json:"mid,omitempty"` // Hardware fingerprint hash (optional binding)
|
||||||
|
Metadata map[string]string `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token is the signed, serialized license string.
|
||||||
|
type Token string
|
||||||
|
|
||||||
|
// ValidationResult is returned by Validate.
|
||||||
|
type ValidationResult struct {
|
||||||
|
Valid bool
|
||||||
|
Claims *Claims
|
||||||
|
Remaining *int64 // Remaining uses (TypeUsage only)
|
||||||
|
ExpiresAt *time.Time // Expiry time (TypeExpiry only)
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generator creates and signs license tokens.
|
||||||
|
type Generator struct {
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGenerator creates a Generator with the given HMAC secret.
|
||||||
|
// The secret should be at least 32 bytes of random data in production.
|
||||||
|
func NewGenerator(secret []byte) (*Generator, error) {
|
||||||
|
if len(secret) < 16 {
|
||||||
|
return nil, errors.New("license: secret must be at least 16 bytes")
|
||||||
|
}
|
||||||
|
return &Generator{secret: secret}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueExpiry creates a license token valid until the given end date.
|
||||||
|
func (g *Generator) IssueExpiry(subject string, expiresAt time.Time, meta map[string]string) (Token, error) {
|
||||||
|
exp := expiresAt.Unix()
|
||||||
|
claims := &Claims{
|
||||||
|
LicenseID: newID(),
|
||||||
|
Type: TypeExpiry,
|
||||||
|
Subject: subject,
|
||||||
|
IssuedAt: time.Now().Unix(),
|
||||||
|
ExpiresAt: &exp,
|
||||||
|
Metadata: meta,
|
||||||
|
}
|
||||||
|
return g.sign(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueUsage creates a license token valid for maxUses activations.
|
||||||
|
func (g *Generator) IssueUsage(subject string, maxUses int64, meta map[string]string) (Token, error) {
|
||||||
|
if maxUses <= 0 {
|
||||||
|
return "", errors.New("license: maxUses must be > 0")
|
||||||
|
}
|
||||||
|
claims := &Claims{
|
||||||
|
LicenseID: newID(),
|
||||||
|
Type: TypeUsage,
|
||||||
|
Subject: subject,
|
||||||
|
IssuedAt: time.Now().Unix(),
|
||||||
|
MaxUses: &maxUses,
|
||||||
|
UsedCount: 0,
|
||||||
|
Metadata: meta,
|
||||||
|
}
|
||||||
|
return g.sign(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueExpiryBound creates an expiry license locked to a specific machine.
|
||||||
|
func (g *Generator) IssueExpiryBound(subject string, expiresAt time.Time, machineID string, meta map[string]string) (Token, error) {
|
||||||
|
exp := expiresAt.Unix()
|
||||||
|
claims := &Claims{
|
||||||
|
LicenseID: newID(),
|
||||||
|
Type: TypeExpiry,
|
||||||
|
Subject: subject,
|
||||||
|
IssuedAt: time.Now().Unix(),
|
||||||
|
ExpiresAt: &exp,
|
||||||
|
MachineID: machineID,
|
||||||
|
Metadata: meta,
|
||||||
|
}
|
||||||
|
return g.sign(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueUsageBound creates a usage license locked to a specific machine.
|
||||||
|
func (g *Generator) IssueUsageBound(subject string, maxUses int64, machineID string, meta map[string]string) (Token, error) {
|
||||||
|
if maxUses <= 0 {
|
||||||
|
return "", errors.New("license: maxUses must be > 0")
|
||||||
|
}
|
||||||
|
claims := &Claims{
|
||||||
|
LicenseID: newID(),
|
||||||
|
Type: TypeUsage,
|
||||||
|
Subject: subject,
|
||||||
|
IssuedAt: time.Now().Unix(),
|
||||||
|
MaxUses: &maxUses,
|
||||||
|
MachineID: machineID,
|
||||||
|
Metadata: meta,
|
||||||
|
}
|
||||||
|
return g.sign(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign serializes claims and appends an HMAC signature.
|
||||||
|
func (g *Generator) sign(c *Claims) (Token, error) {
|
||||||
|
payload, err := json.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("license: marshal failed: %w", err)
|
||||||
|
}
|
||||||
|
b64 := base64.RawURLEncoding.EncodeToString(payload)
|
||||||
|
sig := g.hmac(b64)
|
||||||
|
return Token(b64 + "." + sig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hmac returns a base64url-encoded HMAC-SHA256 of data.
|
||||||
|
func (g *Generator) hmac(data string) string {
|
||||||
|
mac := hmac.New(sha256.New, g.secret)
|
||||||
|
mac.Write([]byte(data))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Reader validates and (for usage licenses) tracks consumption.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Reader validates tokens and tracks usage counts.
|
||||||
|
type Reader struct {
|
||||||
|
secret []byte
|
||||||
|
mu sync.Mutex
|
||||||
|
usageLedger map[string]int64 // licenseID -> used count (in-memory; swap for DB in prod)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReader creates a Reader with the same secret used by the Generator.
|
||||||
|
func NewReader(secret []byte) (*Reader, error) {
|
||||||
|
if len(secret) < 16 {
|
||||||
|
return nil, errors.New("license: secret must be at least 16 bytes")
|
||||||
|
}
|
||||||
|
return &Reader{
|
||||||
|
secret: secret,
|
||||||
|
usageLedger: make(map[string]int64),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the token signature and license constraints.
|
||||||
|
// Pass machineID (hwid.Fingerprint.Hash()) to enforce hardware binding, or "" to skip.
|
||||||
|
// It does NOT consume a usage unit - call Consume for that.
|
||||||
|
func (r *Reader) Validate(t Token, machineID string) ValidationResult {
|
||||||
|
claims, err := r.parse(t)
|
||||||
|
if err != nil {
|
||||||
|
return ValidationResult{Valid: false, Error: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardware binding check
|
||||||
|
if claims.MachineID != "" {
|
||||||
|
if machineID == "" {
|
||||||
|
return ValidationResult{Valid: false, Error: errors.New("license: cannot verify machine binding"), Claims: claims}
|
||||||
|
}
|
||||||
|
if !hmac.Equal([]byte(claims.MachineID), []byte(machineID)) {
|
||||||
|
return ValidationResult{Valid: false, Error: errors.New("license: bound to a different machine"), Claims: claims}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch claims.Type {
|
||||||
|
case TypeExpiry:
|
||||||
|
if claims.ExpiresAt == nil {
|
||||||
|
return ValidationResult{Valid: false, Error: errors.New("license: missing expiry date"), Claims: claims}
|
||||||
|
}
|
||||||
|
exp := time.Unix(*claims.ExpiresAt, 0)
|
||||||
|
if time.Now().After(exp) {
|
||||||
|
return ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Claims: claims,
|
||||||
|
ExpiresAt: &exp,
|
||||||
|
Error: fmt.Errorf("license: expired on %s", exp.Format(time.DateOnly)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ValidationResult{Valid: true, Claims: claims, ExpiresAt: &exp}
|
||||||
|
|
||||||
|
case TypeUsage:
|
||||||
|
if claims.MaxUses == nil {
|
||||||
|
return ValidationResult{Valid: false, Error: errors.New("license: missing max uses"), Claims: claims}
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
used := r.usageLedger[claims.LicenseID]
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
remaining := *claims.MaxUses - used
|
||||||
|
if remaining <= 0 {
|
||||||
|
return ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Claims: claims,
|
||||||
|
Remaining: &remaining,
|
||||||
|
Error: fmt.Errorf("license: usage limit of %d reached", *claims.MaxUses),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ValidationResult{Valid: true, Claims: claims, Remaining: &remaining}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult{Valid: false, Error: fmt.Errorf("license: unknown type %q", claims.Type)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume validates the token, records one usage unit, and returns the result.
|
||||||
|
// Pass machineID to enforce hardware binding, or "" to skip.
|
||||||
|
func (r *Reader) Consume(t Token, machineID string) (ValidationResult, error) {
|
||||||
|
result := r.Validate(t, machineID)
|
||||||
|
if !result.Valid {
|
||||||
|
return result, result.Error
|
||||||
|
}
|
||||||
|
if result.Claims.Type != TypeUsage {
|
||||||
|
return result, nil // expiry licenses don't need consumption
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
used := r.usageLedger[result.Claims.LicenseID] + 1
|
||||||
|
r.usageLedger[result.Claims.LicenseID] = used
|
||||||
|
|
||||||
|
remaining := *result.Claims.MaxUses - used
|
||||||
|
result.Remaining = &remaining
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadUsage seeds the reader with persisted usage counts (e.g. from a DB on startup).
|
||||||
|
func (r *Reader) LoadUsage(licenseID string, usedCount int64) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.usageLedger[licenseID] = usedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageSnapshot returns a copy of the current usage ledger for persistence.
|
||||||
|
func (r *Reader) UsageSnapshot() map[string]int64 {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
snap := make(map[string]int64, len(r.usageLedger))
|
||||||
|
for k, v := range r.usageLedger {
|
||||||
|
snap[k] = v
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse verifies the signature and deserializes claims.
|
||||||
|
func (r *Reader) parse(t Token) (*Claims, error) {
|
||||||
|
parts := strings.SplitN(string(t), ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, errors.New("license: malformed token")
|
||||||
|
}
|
||||||
|
b64, sig := parts[0], parts[1]
|
||||||
|
|
||||||
|
// Verify signature first (constant-time)
|
||||||
|
expected := r.hmac(b64)
|
||||||
|
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
||||||
|
return nil, errors.New("license: invalid signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(b64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("license: decode failed: %w", err)
|
||||||
|
}
|
||||||
|
var claims Claims
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("license: unmarshal failed: %w", err)
|
||||||
|
}
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hmac returns a base64url-encoded HMAC-SHA256 of data.
|
||||||
|
func (r *Reader) hmac(data string) string {
|
||||||
|
mac := hmac.New(sha256.New, r.secret)
|
||||||
|
mac.Write([]byte(data))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// newID generates a random 16-byte hex license ID.
|
||||||
|
func newID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return fmt.Sprintf("%x", b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package token_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.tecamino.local/paadi/licenseServer/client/hwid"
|
||||||
|
"gitea.tecamino.local/paadi/licenseServer/client/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
var secret = []byte("super-secret-key-32-bytes-minimum!")
|
||||||
|
|
||||||
|
func newPair(t *testing.T) (*token.Generator, *token.Reader) {
|
||||||
|
t.Helper()
|
||||||
|
g, err := token.NewGenerator(secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r, err := token.NewReader(secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return g, r
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Expiry license tests ---
|
||||||
|
|
||||||
|
func TestExpiryLicense_Valid(t *testing.T) {
|
||||||
|
g, r := newPair(t)
|
||||||
|
tok, err := g.IssueExpiry("myapp", time.Now().Add(24*time.Hour), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp, err := hwid.Collect()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
machineID := fp.Hash()
|
||||||
|
|
||||||
|
result := r.Validate(tok, machineID)
|
||||||
|
if !result.Valid {
|
||||||
|
t.Fatalf("expected valid, got: %v", result.Error)
|
||||||
|
}
|
||||||
|
if result.ExpiresAt == nil {
|
||||||
|
t.Fatal("expected ExpiresAt to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryLicense_Expired(t *testing.T) {
|
||||||
|
g, r := newPair(t)
|
||||||
|
tok, err := g.IssueExpiry("myapp", time.Now().Add(-1*time.Hour), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp, err := hwid.Collect()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
machineID := fp.Hash()
|
||||||
|
|
||||||
|
result := r.Validate(tok, machineID)
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid for expired license")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Usage license tests ---
|
||||||
|
|
||||||
|
func TestUsageLicense_Valid(t *testing.T) {
|
||||||
|
g, r := newPair(t)
|
||||||
|
tok, err := g.IssueUsage("myapp", 5, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp, err := hwid.Collect()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
machineID := fp.Hash()
|
||||||
|
|
||||||
|
result := r.Validate(tok, machineID)
|
||||||
|
if !result.Valid {
|
||||||
|
t.Fatalf("expected valid: %v", result.Error)
|
||||||
|
}
|
||||||
|
if *result.Remaining != 5 {
|
||||||
|
t.Fatalf("expected 5 remaining, got %d", *result.Remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageLicense_Consume(t *testing.T) {
|
||||||
|
g, r := newPair(t)
|
||||||
|
tok, err := g.IssueUsage("myapp", 3, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp, err := hwid.Collect()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
machineID := fp.Hash()
|
||||||
|
|
||||||
|
for i := int64(2); i >= 0; i-- {
|
||||||
|
res, err := r.Consume(tok, machineID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("consume failed: %v", err)
|
||||||
|
}
|
||||||
|
if *res.Remaining != i {
|
||||||
|
t.Fatalf("expected %d remaining, got %d", i, *res.Remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th consume should fail
|
||||||
|
_, err = r.Consume(tok, machineID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after exhausting usage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageLicense_Exhausted(t *testing.T) {
|
||||||
|
g, r := newPair(t)
|
||||||
|
tok, err := g.IssueUsage("myapp", 2, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp, err := hwid.Collect()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
machineID := fp.Hash()
|
||||||
|
|
||||||
|
r.Consume(tok, machineID)
|
||||||
|
r.Consume(tok, machineID)
|
||||||
|
|
||||||
|
result := r.Validate(tok, machineID)
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid after exhausting uses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tamper / signature tests ---
|
||||||
|
|
||||||
|
func TestTamperedToken(t *testing.T) {
|
||||||
|
g, r := newPair(t)
|
||||||
|
tok, _ := g.IssueExpiry("myapp", time.Now().Add(24*time.Hour), nil)
|
||||||
|
|
||||||
|
tampered := token.Token(string(tok) + "x")
|
||||||
|
|
||||||
|
fp, err := hwid.Collect()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
machineID := fp.Hash()
|
||||||
|
|
||||||
|
result := r.Validate(tampered, machineID)
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("tampered token should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongSecret(t *testing.T) {
|
||||||
|
g, _ := newPair(t)
|
||||||
|
tok, _ := g.IssueExpiry("myapp", time.Now().Add(24*time.Hour), nil)
|
||||||
|
|
||||||
|
// Reader with different secret
|
||||||
|
r2, _ := token.NewReader([]byte("different-secret-key-32-bytes-xx!"))
|
||||||
|
|
||||||
|
fp, err := hwid.Collect()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
machineID := fp.Hash()
|
||||||
|
|
||||||
|
result := r2.Validate(tok, machineID)
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("token signed with different secret should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Usage persistence ---
|
||||||
|
|
||||||
|
func TestUsageLedger_Persistence(t *testing.T) {
|
||||||
|
g, r := newPair(t)
|
||||||
|
tok, _ := g.IssueUsage("myapp", 10, nil)
|
||||||
|
|
||||||
|
fp, err := hwid.Collect()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
machineID := fp.Hash()
|
||||||
|
|
||||||
|
res, _ := r.Consume(tok, machineID)
|
||||||
|
res, _ = r.Consume(tok, machineID)
|
||||||
|
_ = res
|
||||||
|
|
||||||
|
snap := r.UsageSnapshot()
|
||||||
|
|
||||||
|
// Simulate restart: new reader, load snapshot
|
||||||
|
r2, _ := token.NewReader(secret)
|
||||||
|
for id, count := range snap {
|
||||||
|
r2.LoadUsage(id, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := r2.Validate(tok, machineID)
|
||||||
|
if !result.Valid {
|
||||||
|
t.Fatalf("expected valid after reload: %v", result.Error)
|
||||||
|
}
|
||||||
|
if *result.Remaining != 8 {
|
||||||
|
t.Fatalf("expected 8 remaining after reloading 2 uses, got %d", *result.Remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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, "-")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user