Feature(Go): issue-token subcommand for minting customer JWTs
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user