Feature(licensing): anonymous trial mode + server-side quota enforcement

This commit is contained in:
yuanyuanxiang
2026-06-04 09:23:57 +02:00
parent fcd3b13ca8
commit 4064bbe25d
8 changed files with 541 additions and 100 deletions

View File

@@ -5,13 +5,36 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Anonymous-trial rate limit: per-source-IP cap on /license/sign +
// /license/heartbeat requests without a Bearer token. Picked at "high enough
// for any legitimate single deployment, low enough to make brute-force /
// signature-probing pointless." Each /sign costs 1, /heartbeat 1, in a 60s
// sliding window. Authenticated requests skip this.
const (
anonRatePerWindow = 10
anonRateWindow = time.Minute
// anonReapInterval throws away stale buckets so the map doesn't grow
// unbounded across IP-cycling attackers. Walk the map every N requests.
anonReapEvery = 200
)
// Log-throttle cooldowns: downstream devices reconnect every few seconds when
// over-quota, so without throttling these two Warn lines flood the operator's
// log file. One log entry per cooldown window per unique key is enough signal.
const (
quotaWarnCooldown = 5 * time.Minute // per (sub, clientID) pair
rlWarnCooldown = anonRateWindow // per IP, matches the rate-limit window
)
// LicenseServer is the HTTP service the operator's LocalSigner exposes for
// RemoteSigner customer deployments. It uses the same LocalSigner instance
// (same HMAC master key) to produce signatures so customers can issue
@@ -37,12 +60,32 @@ import (
//
// Security: serve plain HTTP and put nginx / Caddy in front for TLS. JWT
// "alg" is locked to RS256 in token.go; "alg":"none" tampering is blocked.
//
// Anonymous trial: requests without a Bearer token are treated as anonymous
// trial — the client's source IP is used as "sub" (key=`trial:<ip>`) and
// MaxDevices is capped at FreeMaxDevices. This is what lets a zero-config
// downstream binary "just work" for evaluation. Heavily rate-limited per IP
// to make brute-force / signature-probing pointless.
type LicenseServer struct {
signer *LocalSigner
pubKey *rsa.PublicKey
tracker *quotaTracker
logger Logger
mux *http.ServeMux
signer *LocalSigner
pubKey *rsa.PublicKey
tracker *quotaTracker
logger Logger
mux *http.ServeMux
trustProxy bool // honor X-Forwarded-For / X-Real-IP — set only behind a trusted reverse proxy
anonMu sync.Mutex
anonBuckets map[string]*anonBucket // ip → bucket
anonReqSeen int // counter for periodic reap
warnMu sync.Mutex
lastWarn map[string]time.Time // dedup key → last log time
}
// anonBucket tracks anonymous request count within a sliding window.
type anonBucket struct {
count int
windowStart time.Time
}
// Logger is the minimal logging interface we need. The cmd package's
@@ -59,17 +102,42 @@ type Logger interface {
func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey,
evictAfter time.Duration, lg Logger) *LicenseServer {
s := &LicenseServer{
signer: signer,
pubKey: pubKey,
tracker: newQuotaTracker(evictAfter),
logger: lg,
mux: http.NewServeMux(),
signer: signer,
pubKey: pubKey,
tracker: newQuotaTracker(evictAfter),
logger: lg,
mux: http.NewServeMux(),
anonBuckets: make(map[string]*anonBucket),
lastWarn: make(map[string]time.Time),
}
s.mux.HandleFunc("/license/sign", s.handleSign)
s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat)
return s
}
// warnOnce emits a Warn log at most once per cooldown window for the given
// dedup key. Subsequent identical events within the window are silently
// dropped. This keeps high-frequency but expected conditions (quota exceeded,
// rate limit hit) from flooding the operator's log file while still providing
// one clear signal per event burst.
func (s *LicenseServer) warnOnce(key string, cooldown time.Duration, format string, args ...any) {
s.warnMu.Lock()
if t, ok := s.lastWarn[key]; ok && time.Since(t) < cooldown {
s.warnMu.Unlock()
return
}
s.lastWarn[key] = time.Now()
s.warnMu.Unlock()
s.logger.Warn(format, args...)
}
// SetTrustProxy switches IP extraction to X-Forwarded-For / X-Real-IP for
// the anonymous-trial branch. Only set this when running behind a reverse
// proxy you control (nginx / caddy / cloudflare); direct-exposure
// deployments MUST leave it false or attackers can spoof the header to
// evade the per-IP rate limit and the trial quota.
func (s *LicenseServer) SetTrustProxy(trust bool) { s.trustProxy = trust }
// Handler returns the http.Handler the operator wires into their HTTP
// server (or runs standalone via http.ListenAndServe).
func (s *LicenseServer) Handler() http.Handler { return s.mux }
@@ -92,13 +160,107 @@ func (s *LicenseServer) authenticate(w http.ResponseWriter, r *http.Request) *Li
return claims
}
// resolveAuth decides whether the request is paid (Bearer JWT) or anonymous
// trial (no Authorization header). Returns a LicenseClaims structure either
// way:
// - Paid: claims from JWT, untouched.
// - Trial: synthesized claims with Subject="trial:<ip>", Tier=TierFree,
// MaxDevices=FreeMaxDevices, no Bearer required.
//
// Anonymous requests are rate-limited per source IP; if the IP's bucket is
// full we write 429 and return nil. Bad JWTs still 401 as before.
func (s *LicenseServer) resolveAuth(w http.ResponseWriter, r *http.Request) *LicenseClaims {
if r.Header.Get("Authorization") != "" {
return s.authenticate(w, r)
}
// Anonymous trial branch.
ip := s.clientIP(r)
if !s.allowAnon(ip) {
s.warnOnce("rl:"+ip, rlWarnCooldown, "License Server: anonymous rate limit hit for ip=%s", ip)
w.Header().Set("Retry-After", "60")
writeJSONError(w, http.StatusTooManyRequests,
"trial rate limit exceeded; set YAMA_LICENSE_TOKEN for full license")
return nil
}
return &LicenseClaims{
Tier: TierFree,
MaxDevices: FreeMaxDevices,
RegisteredClaims: jwt.RegisteredClaims{
Subject: "trial:" + ip,
},
}
}
// clientIP returns the request's source IP. When trustProxy is set, prefer
// X-Real-IP, then the last entry of X-Forwarded-For. Otherwise fall back to
// r.RemoteAddr (host part only).
func (s *LicenseServer) clientIP(r *http.Request) string {
if s.trustProxy {
if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
return v
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Last entry is the one appended by the proxy closest to us.
parts := strings.Split(xff, ",")
last := strings.TrimSpace(parts[len(parts)-1])
if last != "" {
return last
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// allowAnon enforces the anonymous-trial per-IP rate limit. Returns true
// when the call is admitted, false when the IP's bucket is full. Also reaps
// stale buckets opportunistically.
func (s *LicenseServer) allowAnon(ip string) bool {
s.anonMu.Lock()
defer s.anonMu.Unlock()
now := time.Now()
b, ok := s.anonBuckets[ip]
if !ok || now.Sub(b.windowStart) >= anonRateWindow {
s.anonBuckets[ip] = &anonBucket{count: 1, windowStart: now}
s.maybeReapAnonLocked(now)
return true
}
if b.count >= anonRatePerWindow {
return false
}
b.count++
return true
}
// maybeReapAnonLocked drops buckets whose windows are stale every N requests.
// Caller must hold s.anonMu.
func (s *LicenseServer) maybeReapAnonLocked(now time.Time) {
s.anonReqSeen++
if s.anonReqSeen < anonReapEvery {
return
}
s.anonReqSeen = 0
cutoff := now.Add(-anonRateWindow)
for ip, b := range s.anonBuckets {
if b.windowStart.Before(cutoff) {
delete(s.anonBuckets, ip)
}
}
}
func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
claims := s.authenticate(w, r)
claims := s.resolveAuth(w, r)
if claims == nil {
return
}
@@ -117,7 +279,8 @@ func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
// consume a slot — see quotaTracker.Reserve.
active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices)
if !accepted {
s.logger.Warn("License Server: quota exceeded for sub=%s tier=%s active=%d max=%d clientID=%s",
s.warnOnce("quota:"+claims.Subject+":"+req.ClientID, quotaWarnCooldown,
"License Server: quota exceeded for sub=%s tier=%s active=%d max=%d clientID=%s",
claims.Subject, claims.Tier, active, claims.MaxDevices, req.ClientID)
writeJSONError(w, http.StatusForbidden,
fmt.Sprintf("quota exceeded: %d/%d devices in use", active, claims.MaxDevices))
@@ -154,7 +317,7 @@ func (s *LicenseServer) handleHeartbeat(w http.ResponseWriter, r *http.Request)
return
}
claims := s.authenticate(w, r)
claims := s.resolveAuth(w, r)
if claims == nil {
return
}