Feature(Go): issue-token subcommand for minting customer JWTs
This commit is contained in:
@@ -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