diff --git a/server/go/README.md b/server/go/README.md index 152c8d4..55beeff 100644 --- a/server/go/README.md +++ b/server/go/README.md @@ -163,8 +163,10 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。 | `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWT(RS256),作为 Bearer token 鉴权。每个客户一份。**未设置则进入 TRIAL 模式(匿名试用,按出口 IP 配额 2 台)**。 | `eyJhbGciOiJSUzI1NiI...` | | `YAMA_LICENSE_DISABLED` | 设为 `1` 强制 NoOp 模式(既不读 token 也不连 License Server,客户端会拒绝屏幕/文件功能)。给本地开发 / 离线测试用。 | `1` | | `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_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**(RemoteSigner 客户不能反向当 license server)。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` | +| `YAMA_LICENSE_STATE_PATH` | **[License Server 模式]** Quota 状态持久化文件路径。设置后每次新设备入队或 slot 被驱逐时原子写入磁盘(tmp + rename),License 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_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` | @@ -205,22 +207,41 @@ $env:YAMA_PWD="your_super_password" ### 颁发客户 JWT +**第一步:一次性生成 RSA 密钥对**(只在授权中心执行一次,私钥永久保管) + ```bash -# 一次性生成 RSA 密钥对(私钥 operator 自己保管,公钥用于 License Server 验证) openssl genrsa -out license_priv.pem 2048 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 -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 默认 | 备注 | -| ---- | ---------------- | ---- | -| `trial` | 20(JWT 未指定时) | 移植 C++ 反代理 RTT 逻辑 | -| `paid` | JWT 必须显式指定 | 长 TTL token | +命令将 JWT 字符串输出到 stdout,将其作为 `YAMA_LICENSE_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_WEB_TRUST_PROXY=1 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_HTTP_ADDR="127.0.0.1:8443" diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index 47c976f..139d8f3 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -661,7 +661,72 @@ func splitCSV(s string) []string { 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() { + // 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 portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)") flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)") diff --git a/server/go/licensing/factory.go b/server/go/licensing/factory.go index 6eb4fc3..c621d5f 100644 --- a/server/go/licensing/factory.go +++ b/server/go/licensing/factory.go @@ -13,8 +13,10 @@ const ( EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL 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 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) 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 // 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 // /ws and /license/ behind the same nginx, so the answer is always the diff --git a/server/go/licensing/licensing_test.go b/server/go/licensing/licensing_test.go index 44c8221..6d1bcd3 100644 --- a/server/go/licensing/licensing_test.go +++ b/server/go/licensing/licensing_test.go @@ -138,7 +138,7 @@ func TestLocalSignerDeterministic(t *testing.T) { func TestRemoteSignerCacheHit(t *testing.T) { priv := testKey(t) 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()) defer ts.Close() @@ -180,7 +180,7 @@ func TestRemoteSignerCacheHit(t *testing.T) { func TestRemoteSignerStaleFallback(t *testing.T) { priv := testKey(t) 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()) tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour) @@ -214,7 +214,7 @@ func TestRemoteSignerStaleFallback(t *testing.T) { func TestQuotaEnforcement(t *testing.T) { priv := testKey(t) 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()) defer ts.Close() @@ -260,7 +260,7 @@ func TestQuotaEnforcement(t *testing.T) { func TestAnonymousTrialSignsAndCaps(t *testing.T) { priv := testKey(t) 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()) defer ts.Close() @@ -303,7 +303,7 @@ func TestAnonymousTrialSignsAndCaps(t *testing.T) { func TestAnonymousTrialIPRateLimit(t *testing.T) { priv := testKey(t) 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()) defer ts.Close() @@ -335,7 +335,7 @@ func TestAnonymousTrialIPRateLimit(t *testing.T) { func TestAuthRejectsBadBearer(t *testing.T) { priv := testKey(t) 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()) defer ts.Close() @@ -378,7 +378,7 @@ func TestRemoteSignerHardFailNoCacheReturnsError(t *testing.T) { func TestHeartbeatRefreshOnly(t *testing.T) { priv := testKey(t) 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()) defer ts.Close() @@ -461,7 +461,7 @@ func TestHeartbeatRefreshOnly(t *testing.T) { func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) { priv := testKey(t) 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()) defer ts.Close() @@ -597,3 +597,36 @@ func TestJWTAlgLockedToRS256(t *testing.T) { 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") + } +} diff --git a/server/go/licensing/quota.go b/server/go/licensing/quota.go index 8899dc0..be0e8c8 100644 --- a/server/go/licensing/quota.go +++ b/server/go/licensing/quota.go @@ -1,6 +1,8 @@ package licensing import ( + "encoding/json" + "os" "sync" "time" ) @@ -22,20 +24,31 @@ const ( 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 // identified by the JWT "sub" claim. The set is keyed by clientID (uint64 // from the device, stringified) — same device coming back through the // same License Server is one slot, not two. // // Eviction: any clientID not seen in /sign or /license/heartbeat within -// the eviction window is silently dropped from the active set. This stops -// a never-heartbeating customer from holding slots forever. Default -// window is twice the heartbeat interval the customer reports at (5 min). +// the eviction window is silently dropped from the active set. Default +// window is 5 minutes (twice the heartbeat interval). // -// Empty customer entries are reaped at the end of each mutation so the -// outer map doesn't accumulate sub claims of expired contracts. +// Persistence: when statePath is set, the sub→clientID map is written +// 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 { evictAfter time.Duration + statePath string // "" = no persistence + log Logger // nil = silent mu sync.Mutex 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. -func (q *quotaTracker) evictLocked(st *customerState) { +// Load reads the persisted state from statePath and restores each clientID +// 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) + removed := 0 for cid, last := range st.devices { if last.Before(cutoff) { delete(st.devices, cid) + removed++ } } + return removed } // 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). func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) { q.mu.Lock() - defer q.mu.Unlock() st, ok := q.customer[sub] if !ok { @@ -92,21 +195,38 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) q.customer[sub] = st } - q.evictLocked(st) + evicted := q.evictLocked(st) if _, already := st.devices[clientID]; already { 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 { - // Don't reap on rejection — the customer might be at exactly cap - // with valid devices, and an empty map would lose info. - return len(st.devices), false + count := len(st.devices) + var snap map[string][]string + 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() - 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 @@ -118,14 +238,14 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) // known to us from a prior Reserve). func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int { q.mu.Lock() - defer q.mu.Unlock() st, ok := q.customer[sub] if !ok { + q.mu.Unlock() return 0 } - q.evictLocked(st) + evicted := q.evictLocked(st) now := time.Now() refreshed := 0 @@ -137,6 +257,13 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int { } 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 } @@ -144,16 +271,25 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int { // /license/heartbeat to report the server-side view. func (q *quotaTracker) Snapshot(sub string) []string { q.mu.Lock() - defer q.mu.Unlock() + st, ok := q.customer[sub] if !ok { + q.mu.Unlock() return nil } - q.evictLocked(st) + + evicted := q.evictLocked(st) out := make([]string, 0, len(st.devices)) for cid := range st.devices { out = append(out, cid) } q.reapEmptyLocked(sub) + + var snap map[string][]string + if evicted > 0 { + snap = q.snapshotLocked() + } + q.mu.Unlock() + q.save(snap) return out } diff --git a/server/go/licensing/server.go b/server/go/licensing/server.go index bbf6f49..5924809 100644 --- a/server/go/licensing/server.go +++ b/server/go/licensing/server.go @@ -100,11 +100,17 @@ type Logger interface { // quiet device keeps its slot before its quota is reclaimed (recommend // 5 min — twice a typical heartbeat interval). 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{ signer: signer, pubKey: pubKey, - tracker: newQuotaTracker(evictAfter), + tracker: qt, logger: lg, mux: http.NewServeMux(), anonBuckets: make(map[string]*anonBucket), diff --git a/server/go/licensing/token.go b/server/go/licensing/token.go index db5aa8a..af2d88f 100644 --- a/server/go/licensing/token.go +++ b/server/go/licensing/token.go @@ -23,6 +23,34 @@ type LicenseClaims struct { 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 // Server loads this once at startup to verify incoming customer JWTs. // Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM