Feature(Go): issue-token subcommand for minting customer JWTs

This commit is contained in:
yuanyuanxiang
2026-06-04 10:04:02 +02:00
parent 4064bbe25d
commit be09b271e1
7 changed files with 328 additions and 36 deletions

View File

@@ -163,8 +163,10 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWTRS256作为 Bearer token 鉴权。每个客户一份。**未设置则进入 TRIAL 模式(匿名试用,按出口 IP 配额 2 台)**。 | `eyJhbGciOiJSUzI1NiI...` | | `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWTRS256作为 Bearer token 鉴权。每个客户一份。**未设置则进入 TRIAL 模式(匿名试用,按出口 IP 配额 2 台)**。 | `eyJhbGciOiJSUzI1NiI...` |
| `YAMA_LICENSE_DISABLED` | 设为 `1` 强制 NoOp 模式(既不读 token 也不连 License Server客户端会拒绝屏幕/文件功能)。给本地开发 / 离线测试用。 | `1` | | `YAMA_LICENSE_DISABLED` | 设为 `1` 强制 NoOp 模式(既不读 token 也不连 License Server客户端会拒绝屏幕/文件功能)。给本地开发 / 离线测试用。 | `1` |
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner / Trial 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` | | `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner / Trial 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` |
| `YAMA_LICENSE_PRIVATE_KEY` | **[issue-token 子命令]** RSA 私钥 PEM 路径,用于离线签发客户 JWT。与 `YAMA_LICENSE_PUBLIC_KEY` 配对。设置后 `issue-token` 子命令无需 `-key` 参数。 | `/opt/yama/license_priv.pem` |
| `YAMA_LICENSE_PUBLIC_KEY` | **[License Server 模式]** Operator 自己(已经是 LocalSigner想顺便对外提供 License Server 时,用来验证客户提交的 JWT 的 RSA 公钥 PEM 路径。必须与 `YAMA_LICENSE_HTTP_ADDR` 同时设置。 | `./license_pub.pem` | | `YAMA_LICENSE_PUBLIC_KEY` | **[License Server 模式]** Operator 自己(已经是 LocalSigner想顺便对外提供 License Server 时,用来验证客户提交的 JWT 的 RSA 公钥 PEM 路径。必须与 `YAMA_LICENSE_HTTP_ADDR` 同时设置。 | `./license_pub.pem` |
| `YAMA_LICENSE_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**RemoteSigner 客户不能反向当 license server。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` | | `YAMA_LICENSE_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**RemoteSigner 客户不能反向当 license server。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` |
| `YAMA_LICENSE_STATE_PATH` | **[License Server 模式]** Quota 状态持久化文件路径。设置后每次新设备入队或 slot 被驱逐时原子写入磁盘tmp + renameLicense Server 重启时从此文件恢复设备列表,消除重启期间因 tracker 清空导致的配额绕过窗口。不设则仅内存状态,重启后 tracker 清零。 | `/var/lib/yama/quota.json` |
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` | | `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` | | `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` | | `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
@@ -205,22 +207,41 @@ $env:YAMA_PWD="your_super_password"
### 颁发客户 JWT ### 颁发客户 JWT
**第一步:一次性生成 RSA 密钥对**(只在授权中心执行一次,私钥永久保管)
```bash ```bash
# 一次性生成 RSA 密钥对(私钥 operator 自己保管,公钥用于 License Server 验证)
openssl genrsa -out license_priv.pem 2048 openssl genrsa -out license_priv.pem 2048
openssl rsa -in license_priv.pem -pubout -out license_pub.pem openssl rsa -in license_priv.pem -pubout -out license_pub.pem
``` ```
底层 API 是 `licensing.Issue(privKey, sub, tier, maxDevices, ttl)`(见 [`licensing/server.go`](licensing/server.go))。一个开箱即用的 CLI 包装在独立仓库 [`yama-issue-token`](https://git.simpleremoter.com/yuanyuanxiang/yama-issue-token)go.mod `replace` 指向本仓库的 `licensing` 包),用法: - `license_priv.pem` — 私钥,设为 `YAMA_LICENSE_PRIVATE_KEY`,仅存于授权中心,**绝不外发**
- `license_pub.pem` — 公钥,设为 `YAMA_LICENSE_PUBLIC_KEY`,授权中心 License Server 用于验证客户 JWT
**第二步:颁发 JWT**
`server` 二进制内置 `issue-token` 子命令。授权中心已配置 `YAMA_LICENSE_PRIVATE_KEY` 时,只需要提供客户标识:
```bash ```bash
yama-issue-token -priv license_priv.pem -sub acme-corp -tier paid -max 100 -days 365 # 最简调用(私钥路径从 $YAMA_LICENSE_PRIVATE_KEY 读取)
server issue-token -sub customer-acme
# 完整参数
server issue-token \
-sub customer-acme \ # 客户唯一标识(必填)
-tier paid \ # paid 或 trial默认 paid
-devices 20 \ # 最大并发设备数(默认 10
-ttl 8760h # 有效期(默认 8760h = 1 年)
``` ```
| Tier | max_devices 默认 | 备注 | 命令将 JWT 字符串输出到 stdout将其作为 `YAMA_LICENSE_TOKEN` 交给客户。
| ---- | ---------------- | ---- |
| `trial` | 20JWT 未指定时) | 移植 C++ 反代理 RTT 逻辑 | | 参数 | 默认值 | 说明 |
| `paid` | JWT 必须显式指定 | 长 TTL token | | ---- | ------ | ---- |
| `-key` | `$YAMA_LICENSE_PRIVATE_KEY` | RSA 私钥 PEM 路径env 已设则无需重复指定 |
| `-sub` | (必填) | 客户唯一标识,建议用 `company-id` 格式 |
| `-tier` | `paid` | `paid``trial` |
| `-devices` | `10` | 并发设备上限;`paid` 必须显式设置合理值 |
| `-ttl` | `8760h` | Token 有效期,支持 Go duration 语法(`h`/`m`/`s` |
## 使用示例 ## 使用示例
@@ -517,6 +538,7 @@ export YAMA_WEB_ADMIN_PASS="Web 端登录密码"
export YAMA_SIGN_PASSWORD="主控签名 HMAC 主密钥(受管设备验证服务端身份)" export YAMA_SIGN_PASSWORD="主控签名 HMAC 主密钥(受管设备验证服务端身份)"
export YAMA_WEB_TRUST_PROXY=1 export YAMA_WEB_TRUST_PROXY=1
export YAMA_WEB_ALLOWED_ORIGINS="https://web.just-do-it.icu:8080" export YAMA_WEB_ALLOWED_ORIGINS="https://web.just-do-it.icu:8080"
export YAMA_LICENSE_PRIVATE_KEY="/opt/yama/license_priv.pem"
export YAMA_LICENSE_PUBLIC_KEY="/opt/yama/license_pub.pem" export YAMA_LICENSE_PUBLIC_KEY="/opt/yama/license_pub.pem"
export YAMA_LICENSE_HTTP_ADDR="127.0.0.1:8443" export YAMA_LICENSE_HTTP_ADDR="127.0.0.1:8443"

View File

@@ -661,7 +661,72 @@ func splitCSV(s string) []string {
return out return out
} }
// runIssueToken handles the "issue-token" subcommand. It mints a customer JWT
// signed with the operator's RSA private key and prints it to stdout.
//
// The private key path defaults to $YAMA_LICENSE_PRIVATE_KEY so that on the
// authorization server — where env vars are already configured — only the
// per-customer fields need to be specified:
//
// server issue-token -sub customer-acme [-tier paid|trial] [-devices 10] [-ttl 8760h]
//
// If neither -key nor $YAMA_LICENSE_PRIVATE_KEY is set, the command exits
// with a clear error rather than silently using a wrong default.
func runIssueToken(args []string) {
fs := flag.NewFlagSet("issue-token", flag.ExitOnError)
// Default from env so the operator doesn't need to retype the path.
keyPath := fs.String("key", os.Getenv(licensing.EnvLicensePrivKeyPath),
"Path to RSA private key PEM (PKCS#1 or PKCS#8); default: $"+licensing.EnvLicensePrivKeyPath)
sub := fs.String("sub", "", "Customer identifier — unique string, e.g. \"customer-acme\" (required)")
tier := fs.String("tier", licensing.TierPaid, "License tier: \"paid\" or \"trial\"")
devices := fs.Int("devices", 10, "Max concurrent managed devices")
ttl := fs.Duration("ttl", 365*24*time.Hour, "Token validity period, e.g. 8760h (1 year)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s issue-token [flags]\n\nFlags:\n", os.Args[0])
fs.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExample:\n %s issue-token -sub customer-acme -tier paid -devices 20 -ttl 8760h\n", os.Args[0])
}
_ = fs.Parse(args)
var errs []string
if *keyPath == "" {
errs = append(errs, fmt.Sprintf("-key or $%s is required", licensing.EnvLicensePrivKeyPath))
}
if *sub == "" {
errs = append(errs, "-sub is required")
}
if len(errs) > 0 {
for _, e := range errs {
fmt.Fprintln(os.Stderr, "error:", e)
}
fmt.Fprintln(os.Stderr)
fs.Usage()
os.Exit(1)
}
privKey, err := licensing.LoadRSAPrivateKey(*keyPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error loading private key: %v\n", err)
os.Exit(1)
}
token, err := licensing.Issue(privKey, *sub, *tier, *devices, *ttl)
if err != nil {
fmt.Fprintf(os.Stderr, "error issuing token: %v\n", err)
os.Exit(1)
}
fmt.Println(token)
}
func main() { func main() {
// Subcommand dispatch: "server issue-token ..." runs the token-minting
// helper and exits without starting the server.
if len(os.Args) > 1 && os.Args[1] == "issue-token" {
runIssueToken(os.Args[2:])
return
}
// Parse command line flags // Parse command line flags
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)") portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)") flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)")

View File

@@ -13,8 +13,10 @@ const (
EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key
EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL
EnvLicenseToken = "YAMA_LICENSE_TOKEN" // RemoteSigner: customer JWT EnvLicenseToken = "YAMA_LICENSE_TOKEN" // RemoteSigner: customer JWT
EnvLicensePrivKeyPath = "YAMA_LICENSE_PRIVATE_KEY" // issue-token: RSA private key PEM path (paired with public key)
EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path
EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443" EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443"
EnvLicenseStatePath = "YAMA_LICENSE_STATE_PATH" // LocalSigner-as-LS: quota state persistence file path
EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24) EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24)
EnvLicenseDisabled = "YAMA_LICENSE_DISABLED" // set to 1 to force NoOpSigner (offline / dev) EnvLicenseDisabled = "YAMA_LICENSE_DISABLED" // set to 1 to force NoOpSigner (offline / dev)
) )
@@ -176,7 +178,7 @@ func LicenseServerFromEnv(signer Signer, lg Logger) (*LicenseServer, string, err
// 5-minute eviction window — twice a typical heartbeat interval. Matches // 5-minute eviction window — twice a typical heartbeat interval. Matches
// the discussion in quota.go. // the discussion in quota.go.
ls := NewLicenseServer(local, pubKey, 5*time.Minute, lg) ls := NewLicenseServer(local, pubKey, 5*time.Minute, lg, os.Getenv(EnvLicenseStatePath))
// Reuse the web's trust-proxy env var: standard deployment puts both // Reuse the web's trust-proxy env var: standard deployment puts both
// /ws and /license/ behind the same nginx, so the answer is always the // /ws and /license/ behind the same nginx, so the answer is always the

View File

@@ -138,7 +138,7 @@ func TestLocalSignerDeterministic(t *testing.T) {
func TestRemoteSignerCacheHit(t *testing.T) { func TestRemoteSignerCacheHit(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "real-hmac-key-for-test-xx") master := mustLocal(t, "real-hmac-key-for-test-xx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -180,7 +180,7 @@ func TestRemoteSignerCacheHit(t *testing.T) {
func TestRemoteSignerStaleFallback(t *testing.T) { func TestRemoteSignerStaleFallback(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-fallback-test-xxx") master := mustLocal(t, "master-fallback-test-xxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour) tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour)
@@ -214,7 +214,7 @@ func TestRemoteSignerStaleFallback(t *testing.T) {
func TestQuotaEnforcement(t *testing.T) { func TestQuotaEnforcement(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-quota-test-xxxxxx") master := mustLocal(t, "master-quota-test-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -260,7 +260,7 @@ func TestQuotaEnforcement(t *testing.T) {
func TestAnonymousTrialSignsAndCaps(t *testing.T) { func TestAnonymousTrialSignsAndCaps(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-trial-test-xxxxxx") master := mustLocal(t, "master-trial-test-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -303,7 +303,7 @@ func TestAnonymousTrialSignsAndCaps(t *testing.T) {
func TestAnonymousTrialIPRateLimit(t *testing.T) { func TestAnonymousTrialIPRateLimit(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-rate-test-xxxxxxx") master := mustLocal(t, "master-rate-test-xxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -335,7 +335,7 @@ func TestAnonymousTrialIPRateLimit(t *testing.T) {
func TestAuthRejectsBadBearer(t *testing.T) { func TestAuthRejectsBadBearer(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-bad-bearer-xxxxxx") master := mustLocal(t, "master-bad-bearer-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -378,7 +378,7 @@ func TestRemoteSignerHardFailNoCacheReturnsError(t *testing.T) {
func TestHeartbeatRefreshOnly(t *testing.T) { func TestHeartbeatRefreshOnly(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-hb-test-xxxxxxxxxx") master := mustLocal(t, "master-hb-test-xxxxxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -461,7 +461,7 @@ func TestHeartbeatRefreshOnly(t *testing.T) {
func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) { func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-no-leak-xxxxxxxxxxxx") master := mustLocal(t, "master-no-leak-xxxxxxxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -597,3 +597,36 @@ func TestJWTAlgLockedToRS256(t *testing.T) {
t.Error("VerifyJWT accepted RS384; alg should be locked to RS256") t.Error("VerifyJWT accepted RS384; alg should be locked to RS256")
} }
} }
// TestQuotaTrackerPersistence: after a simulated restart (new tracker loaded
// from the file written by the first), previously-admitted devices re-occupy
// their slots and a new over-quota device is still rejected.
func TestQuotaTrackerPersistence(t *testing.T) {
path := t.TempDir() + "/quota.json"
// First "run": admit dev-1 and dev-2 up to cap=2.
q1 := newQuotaTracker(5 * time.Minute)
q1.statePath = path
if _, ok := q1.Reserve("sub", "dev-1", 2); !ok {
t.Fatal("dev-1 should be admitted")
}
if _, ok := q1.Reserve("sub", "dev-2", 2); !ok {
t.Fatal("dev-2 should be admitted")
}
// Simulate restart: new tracker loads the persisted file.
q2 := newQuotaTracker(5 * time.Minute)
q2.statePath = path
if err := q2.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
// Restored tracker knows about dev-1 and dev-2: quota full.
if count, ok := q2.Reserve("sub", "dev-3", 2); ok {
t.Errorf("dev-3 should be rejected after restore, count=%d", count)
}
// Existing devices re-sign successfully (idempotent refresh).
if _, ok := q2.Reserve("sub", "dev-1", 2); !ok {
t.Error("dev-1 re-sign should succeed after restore")
}
}

View File

@@ -1,6 +1,8 @@
package licensing package licensing
import ( import (
"encoding/json"
"os"
"sync" "sync"
"time" "time"
) )
@@ -22,20 +24,31 @@ const (
TrialMaxDevices = 20 TrialMaxDevices = 20
) )
// persistedQuota is the on-disk snapshot format. V=1 is the current schema.
type persistedQuota struct {
V int `json:"v"` // schema version
Customers map[string][]string `json:"customers"` // sub → []clientID
}
// quotaTracker maintains the active-device set per customer. Customers are // quotaTracker maintains the active-device set per customer. Customers are
// identified by the JWT "sub" claim. The set is keyed by clientID (uint64 // identified by the JWT "sub" claim. The set is keyed by clientID (uint64
// from the device, stringified) — same device coming back through the // from the device, stringified) — same device coming back through the
// same License Server is one slot, not two. // same License Server is one slot, not two.
// //
// Eviction: any clientID not seen in /sign or /license/heartbeat within // Eviction: any clientID not seen in /sign or /license/heartbeat within
// the eviction window is silently dropped from the active set. This stops // the eviction window is silently dropped from the active set. Default
// a never-heartbeating customer from holding slots forever. Default // window is 5 minutes (twice the heartbeat interval).
// window is twice the heartbeat interval the customer reports at (5 min).
// //
// Empty customer entries are reaped at the end of each mutation so the // Persistence: when statePath is set, the sub→clientID map is written
// outer map doesn't accumulate sub claims of expired contracts. // atomically to disk on every structural change (device added or evicted).
// Load() restores the state on startup with fresh timestamps so a License
// Server restart does not open a quota-bypass window.
//
// Empty customer entries are reaped at the end of each mutation.
type quotaTracker struct { type quotaTracker struct {
evictAfter time.Duration evictAfter time.Duration
statePath string // "" = no persistence
log Logger // nil = silent
mu sync.Mutex mu sync.Mutex
customer map[string]*customerState // sub claim → state customer map[string]*customerState // sub claim → state
@@ -52,14 +65,105 @@ func newQuotaTracker(evictAfter time.Duration) *quotaTracker {
} }
} }
// evictLocked drops stale entries from st.devices. Caller must hold q.mu. // Load reads the persisted state from statePath and restores each clientID
func (q *quotaTracker) evictLocked(st *customerState) { // with timestamp time.Now() so restored devices survive the initial eviction
// window long enough to heartbeat or re-sign. A missing or corrupt file is
// silently ignored so the server starts cleanly on first run.
func (q *quotaTracker) Load() error {
if q.statePath == "" {
return nil
}
data, err := os.ReadFile(q.statePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var p persistedQuota
if err := json.Unmarshal(data, &p); err != nil {
if q.log != nil {
q.log.Warn("quota: corrupt state file %s (starting empty): %v", q.statePath, err)
}
return nil
}
q.mu.Lock()
defer q.mu.Unlock()
now := time.Now()
restored := 0
for sub, ids := range p.Customers {
if len(ids) == 0 {
continue
}
st := &customerState{devices: make(map[string]time.Time, len(ids))}
for _, cid := range ids {
st.devices[cid] = now
restored++
}
q.customer[sub] = st
}
if q.log != nil && restored > 0 {
q.log.Info("quota: restored %d device slot(s) from %s", restored, q.statePath)
}
return nil
}
// snapshotLocked returns a sub→[]clientID map of the current state.
// Caller must hold q.mu.
func (q *quotaTracker) snapshotLocked() map[string][]string {
out := make(map[string][]string, len(q.customer))
for sub, st := range q.customer {
if len(st.devices) == 0 {
continue
}
ids := make([]string, 0, len(st.devices))
for cid := range st.devices {
ids = append(ids, cid)
}
out[sub] = ids
}
return out
}
// save writes snap atomically (temp file + rename). No-op when statePath is
// empty or snap is nil.
func (q *quotaTracker) save(snap map[string][]string) {
if q.statePath == "" || snap == nil {
return
}
data, err := json.Marshal(persistedQuota{V: 1, Customers: snap})
if err != nil {
if q.log != nil {
q.log.Warn("quota: marshal state: %v", err)
}
return
}
tmp := q.statePath + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
if q.log != nil {
q.log.Warn("quota: write state to %s: %v", tmp, err)
}
return
}
if err := os.Rename(tmp, q.statePath); err != nil {
if q.log != nil {
q.log.Warn("quota: rename %s → %s: %v", tmp, q.statePath, err)
}
}
}
// evictLocked drops stale entries from st.devices. Returns the number removed.
// Caller must hold q.mu.
func (q *quotaTracker) evictLocked(st *customerState) int {
cutoff := time.Now().Add(-q.evictAfter) cutoff := time.Now().Add(-q.evictAfter)
removed := 0
for cid, last := range st.devices { for cid, last := range st.devices {
if last.Before(cutoff) { if last.Before(cutoff) {
delete(st.devices, cid) delete(st.devices, cid)
removed++
} }
} }
return removed
} }
// reapEmptyLocked deletes sub entries whose device sets are empty. This // reapEmptyLocked deletes sub entries whose device sets are empty. This
@@ -84,7 +188,6 @@ func (q *quotaTracker) reapEmptyLocked(sub string) {
// re-signing is never a quota violation — caps only apply to ADDING). // re-signing is never a quota violation — caps only apply to ADDING).
func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) { func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub] st, ok := q.customer[sub]
if !ok { if !ok {
@@ -92,21 +195,38 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool)
q.customer[sub] = st q.customer[sub] = st
} }
q.evictLocked(st) evicted := q.evictLocked(st)
if _, already := st.devices[clientID]; already { if _, already := st.devices[clientID]; already {
st.devices[clientID] = time.Now() st.devices[clientID] = time.Now()
return len(st.devices), true count := len(st.devices)
var snap map[string][]string
if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return count, true
} }
if len(st.devices)+1 > maxDevices { if len(st.devices)+1 > maxDevices {
// Don't reap on rejection — the customer might be at exactly cap count := len(st.devices)
// with valid devices, and an empty map would lose info. var snap map[string][]string
return len(st.devices), false if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return count, false
} }
// New device admitted: always persist so a restart sees this slot.
st.devices[clientID] = time.Now() st.devices[clientID] = time.Now()
return len(st.devices), true count := len(st.devices)
snap := q.snapshotLocked()
q.mu.Unlock()
q.save(snap)
return count, true
} }
// RefreshExisting bumps the last-activity timestamp for any clientID in // RefreshExisting bumps the last-activity timestamp for any clientID in
@@ -118,14 +238,14 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool)
// known to us from a prior Reserve). // known to us from a prior Reserve).
func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int { func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub] st, ok := q.customer[sub]
if !ok { if !ok {
q.mu.Unlock()
return 0 return 0
} }
q.evictLocked(st) evicted := q.evictLocked(st)
now := time.Now() now := time.Now()
refreshed := 0 refreshed := 0
@@ -137,6 +257,13 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
} }
q.reapEmptyLocked(sub) // eviction may have emptied us q.reapEmptyLocked(sub) // eviction may have emptied us
var snap map[string][]string
if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return refreshed return refreshed
} }
@@ -144,16 +271,25 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
// /license/heartbeat to report the server-side view. // /license/heartbeat to report the server-side view.
func (q *quotaTracker) Snapshot(sub string) []string { func (q *quotaTracker) Snapshot(sub string) []string {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub] st, ok := q.customer[sub]
if !ok { if !ok {
q.mu.Unlock()
return nil return nil
} }
q.evictLocked(st)
evicted := q.evictLocked(st)
out := make([]string, 0, len(st.devices)) out := make([]string, 0, len(st.devices))
for cid := range st.devices { for cid := range st.devices {
out = append(out, cid) out = append(out, cid)
} }
q.reapEmptyLocked(sub) q.reapEmptyLocked(sub)
var snap map[string][]string
if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return out return out
} }

View File

@@ -100,11 +100,17 @@ type Logger interface {
// quiet device keeps its slot before its quota is reclaimed (recommend // quiet device keeps its slot before its quota is reclaimed (recommend
// 5 min — twice a typical heartbeat interval). // 5 min — twice a typical heartbeat interval).
func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey, func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey,
evictAfter time.Duration, lg Logger) *LicenseServer { evictAfter time.Duration, lg Logger, statePath string) *LicenseServer {
qt := newQuotaTracker(evictAfter)
qt.statePath = statePath
qt.log = lg
if err := qt.Load(); err != nil && lg != nil {
lg.Warn("License Server: failed to load quota state from %s: %v", statePath, err)
}
s := &LicenseServer{ s := &LicenseServer{
signer: signer, signer: signer,
pubKey: pubKey, pubKey: pubKey,
tracker: newQuotaTracker(evictAfter), tracker: qt,
logger: lg, logger: lg,
mux: http.NewServeMux(), mux: http.NewServeMux(),
anonBuckets: make(map[string]*anonBucket), anonBuckets: make(map[string]*anonBucket),

View File

@@ -23,6 +23,34 @@ type LicenseClaims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// LoadRSAPrivateKey parses an RSA private key from a PEM file. Used by the
// "issue-token" CLI subcommand to sign customer JWTs offline.
// Accepts PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") PEM encodings.
func LoadRSAPrivateKey(pemPath string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(pemPath)
if err != nil {
return nil, fmt.Errorf("read private key %s: %w", pemPath, err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block in %s", pemPath)
}
// PKCS#1: "RSA PRIVATE KEY"
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
// PKCS#8: "PRIVATE KEY"
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("PKCS#8 key in %s is not RSA", pemPath)
}
return rsaKey, nil
}
return nil, fmt.Errorf("failed to parse %s as PKCS#1 or PKCS#8 RSA private key", pemPath)
}
// LoadRSAPublicKey parses an RSA public key from a PEM file. The License // LoadRSAPublicKey parses an RSA public key from a PEM file. The License
// Server loads this once at startup to verify incoming customer JWTs. // Server loads this once at startup to verify incoming customer JWTs.
// Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM // Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM