Feature(licensing): anonymous trial mode + server-side quota enforcement
This commit is contained in:
@@ -16,8 +16,15 @@ const (
|
||||
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"
|
||||
EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24)
|
||||
EnvLicenseDisabled = "YAMA_LICENSE_DISABLED" // set to 1 to force NoOpSigner (offline / dev)
|
||||
)
|
||||
|
||||
// DefaultLicenseServerURL is the publicly-hosted License Server new downstream
|
||||
// deployments hit when YAMA_LICENSE_SERVER is unset. "Zero config" trial mode
|
||||
// uses this URL with no Bearer token — the License Server treats it as an
|
||||
// anonymous trial (cap FreeMaxDevices, identified by source IP).
|
||||
const DefaultLicenseServerURL = "https://web.just-do-it.icu:8080"
|
||||
|
||||
// DefaultOfflineGrace mirrors the "24 hours" decision recorded in the
|
||||
// project memory's licensing design.
|
||||
const DefaultOfflineGrace = 24 * time.Hour
|
||||
@@ -28,6 +35,7 @@ type Mode int
|
||||
const (
|
||||
ModeLocal Mode = iota
|
||||
ModeRemote
|
||||
ModeTrial // RemoteSigner against License Server, but with no Bearer (anonymous trial)
|
||||
ModeNoOp
|
||||
)
|
||||
|
||||
@@ -37,6 +45,8 @@ func (m Mode) String() string {
|
||||
return "local"
|
||||
case ModeRemote:
|
||||
return "remote"
|
||||
case ModeTrial:
|
||||
return "trial"
|
||||
default:
|
||||
return "noop"
|
||||
}
|
||||
@@ -48,16 +58,25 @@ func SelectedMode() Mode {
|
||||
if os.Getenv(EnvSignPassword) != "" {
|
||||
return ModeLocal
|
||||
}
|
||||
if os.Getenv(EnvLicenseServer) != "" && os.Getenv(EnvLicenseToken) != "" {
|
||||
if strings.TrimSpace(os.Getenv(EnvLicenseDisabled)) == "1" {
|
||||
return ModeNoOp
|
||||
}
|
||||
if os.Getenv(EnvLicenseToken) != "" {
|
||||
return ModeRemote
|
||||
}
|
||||
return ModeNoOp
|
||||
return ModeTrial
|
||||
}
|
||||
|
||||
// NewFromEnv builds the Signer chosen by env vars:
|
||||
// - YAMA_SIGN_PASSWORD set → LocalSigner
|
||||
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
|
||||
// - neither → NoOpSigner
|
||||
// NewFromEnv builds the Signer chosen by env vars. Decision tree (top-down,
|
||||
// first match wins):
|
||||
//
|
||||
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator deployment)
|
||||
// - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out)
|
||||
// - YAMA_LICENSE_TOKEN set → RemoteSigner / paid (server URL
|
||||
// defaults to DefaultLicenseServerURL
|
||||
// if YAMA_LICENSE_SERVER is unset)
|
||||
// - neither of the above → RemoteSigner / trial (anonymous,
|
||||
// cap = FreeMaxDevices, default URL)
|
||||
//
|
||||
// If both LocalSigner and RemoteSigner vars are set, LocalSigner wins
|
||||
// (an operator's own server should never accidentally call out to itself).
|
||||
@@ -82,36 +101,43 @@ func NewFromEnv(lg Logger) (Signer, Mode, error) {
|
||||
return s, ModeLocal, nil
|
||||
}
|
||||
|
||||
if server != "" && token != "" {
|
||||
if err := ValidateRemoteURL(server); err != nil {
|
||||
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
|
||||
// Explicit opt-out: operator wants the binary to run with no licensing
|
||||
// at all (dev, offline test, air-gapped). Screen/file features stay off
|
||||
// on the client, device list still works.
|
||||
if strings.TrimSpace(os.Getenv(EnvLicenseDisabled)) == "1" {
|
||||
return NewNoOp(), ModeNoOp, nil
|
||||
}
|
||||
|
||||
// From here on we're going to talk to a License Server. Determine the
|
||||
// URL (env var wins over baked-in default) and the mode (paid if token
|
||||
// is set, anonymous trial if not).
|
||||
if server == "" {
|
||||
server = DefaultLicenseServerURL
|
||||
}
|
||||
if err := ValidateRemoteURL(server); err != nil {
|
||||
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
|
||||
}
|
||||
|
||||
grace := DefaultOfflineGrace
|
||||
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(hrs))
|
||||
if err != nil {
|
||||
return nil, ModeNoOp, fmt.Errorf(
|
||||
"%s must be an integer (hours), got %q", EnvLicenseOfflineHrs, hrs)
|
||||
}
|
||||
grace := DefaultOfflineGrace
|
||||
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(hrs))
|
||||
if err != nil {
|
||||
return nil, ModeNoOp, fmt.Errorf(
|
||||
"%s must be an integer (hours), got %q", EnvLicenseOfflineHrs, hrs)
|
||||
}
|
||||
if n < 0 {
|
||||
return nil, ModeNoOp, fmt.Errorf(
|
||||
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
|
||||
}
|
||||
grace = time.Duration(n) * time.Hour
|
||||
if n < 0 {
|
||||
return nil, ModeNoOp, fmt.Errorf(
|
||||
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
|
||||
}
|
||||
grace = time.Duration(n) * time.Hour
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
return NewRemote(server, token, grace, lg), ModeRemote, nil
|
||||
}
|
||||
|
||||
if server != "" || token != "" {
|
||||
// Partial config is almost certainly a misconfiguration — fail loudly
|
||||
// rather than silently degrading to NoOp.
|
||||
return nil, ModeNoOp, fmt.Errorf(
|
||||
"%s and %s must be set together (got %s=%q %s=%q)",
|
||||
EnvLicenseServer, EnvLicenseToken,
|
||||
EnvLicenseServer, server, EnvLicenseToken, token)
|
||||
}
|
||||
|
||||
return NewNoOp(), ModeNoOp, nil
|
||||
// Anonymous trial: no Bearer token. License Server identifies by IP and
|
||||
// caps at FreeMaxDevices.
|
||||
return NewRemote(server, "", grace, lg), ModeTrial, nil
|
||||
}
|
||||
|
||||
// LicenseServerFromEnv builds the License Server HTTP handler if (and only
|
||||
@@ -150,5 +176,15 @@ func LicenseServerFromEnv(signer Signer, lg Logger) (*LicenseServer, string, err
|
||||
|
||||
// 5-minute eviction window — twice a typical heartbeat interval. Matches
|
||||
// the discussion in quota.go.
|
||||
return NewLicenseServer(local, pubKey, 5*time.Minute, lg), addr, nil
|
||||
ls := NewLicenseServer(local, pubKey, 5*time.Minute, lg)
|
||||
|
||||
// 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
|
||||
// same. Honoring it here lets the anonymous-trial per-IP rate limit see
|
||||
// the real client IP instead of 127.0.0.1.
|
||||
if strings.TrimSpace(os.Getenv("YAMA_WEB_TRUST_PROXY")) == "1" {
|
||||
ls.SetTrustProxy(true)
|
||||
}
|
||||
|
||||
return ls, addr, nil
|
||||
}
|
||||
|
||||
@@ -253,23 +253,102 @@ func TestQuotaEnforcement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRejectsMissingBearer: no token → 401, not 200 / not 500. Belt
|
||||
// and braces — the auth check sits in front of /sign and /heartbeat.
|
||||
func TestAuthRejectsMissingBearer(t *testing.T) {
|
||||
// TestAnonymousTrialSignsAndCaps: no Authorization header → anonymous trial
|
||||
// branch. /sign returns 200 with a real signature up to FreeMaxDevices, then
|
||||
// 403 once the per-IP cap is reached. Replaces the older "missing bearer →
|
||||
// 401" test now that anonymous trial is a first-class mode.
|
||||
func TestAnonymousTrialSignsAndCaps(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-auth-test-xxxxxxx")
|
||||
master := mustLocal(t, "master-trial-test-xxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
body := strings.NewReader(`{"client_id":"x","start_time":"y"}`)
|
||||
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
||||
call := func(clientID string) (int, string) {
|
||||
body := strings.NewReader(fmt.Sprintf(
|
||||
`{"client_id":%q,"start_time":"2026-01-01T00:00:00Z"}`, clientID))
|
||||
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("Post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var sr signResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&sr)
|
||||
if sr.Signature != "" {
|
||||
return resp.StatusCode, sr.Signature
|
||||
}
|
||||
return resp.StatusCode, sr.Error
|
||||
}
|
||||
|
||||
// First FreeMaxDevices distinct clientIDs get real signatures.
|
||||
for i := range FreeMaxDevices {
|
||||
code, sig := call(fmt.Sprintf("trial-dev-%d", i))
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("dev-%d expected 200, got %d (%q)", i, code, sig)
|
||||
}
|
||||
if sig == "" {
|
||||
t.Errorf("dev-%d signature unexpectedly empty", i)
|
||||
}
|
||||
}
|
||||
// Cap+1 → 403 quota exceeded.
|
||||
code, msg := call("trial-dev-overflow")
|
||||
if code != http.StatusForbidden {
|
||||
t.Errorf("overflow expected 403, got %d (%q)", code, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymousTrialIPRateLimit: anonymous /sign is capped at
|
||||
// anonRatePerWindow requests per minute per source IP. Hitting the cap
|
||||
// returns 429 with Retry-After.
|
||||
func TestAnonymousTrialIPRateLimit(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-rate-test-xxxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
// Reuse the same clientID so quota does NOT also reject — we want to
|
||||
// isolate the rate limiter. quotaTracker.Reserve treats a repeat clientID
|
||||
// as a refresh (always accepted), so all the 200s here are the same slot.
|
||||
hit := func() int {
|
||||
body := strings.NewReader(`{"client_id":"rate-dev","start_time":"t"}`)
|
||||
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("Post: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
for i := range anonRatePerWindow {
|
||||
if code := hit(); code != http.StatusOK {
|
||||
t.Fatalf("req %d expected 200, got %d", i, code)
|
||||
}
|
||||
}
|
||||
if code := hit(); code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected 429 after %d requests, got %d", anonRatePerWindow, code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRejectsBadBearer: invalid JWT still returns 401 (we did NOT widen
|
||||
// the auth surface; only "no Authorization header at all" enters trial).
|
||||
func TestAuthRejectsBadBearer(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-bad-bearer-xxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/license/sign",
|
||||
strings.NewReader(`{"client_id":"x","start_time":"y"}`))
|
||||
req.Header.Set("Authorization", "Bearer not.a.real.jwt")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Post: %v", err)
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||
t.Errorf("expected 401 for malformed bearer, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,25 @@ import (
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// QuotaExceededError is returned by Sign when the License Server explicitly
|
||||
// rejects the device because the customer's slot quota is full. Unlike
|
||||
// transient network errors, stale-cache fallback is NOT appropriate — the
|
||||
// License Server's 403 decision is authoritative, and serving a stale
|
||||
// signature would silently bypass the operator's cap.
|
||||
type QuotaExceededError struct {
|
||||
Message string // raw error field from the License Server JSON body
|
||||
}
|
||||
|
||||
func (e *QuotaExceededError) Error() string { return e.Message }
|
||||
|
||||
// IsQuotaExceeded reports whether err is, or wraps, a QuotaExceededError.
|
||||
// Callers (e.g. handleLogin in cmd/main.go) use this to decide whether to
|
||||
// close the device connection server-side after sending a zeroed signature.
|
||||
func IsQuotaExceeded(err error) bool {
|
||||
var qe *QuotaExceededError
|
||||
return errors.As(err, &qe)
|
||||
}
|
||||
|
||||
// RemoteSigner fetches per-login signatures from an operator-hosted License
|
||||
// Server. ServerURL and Token (a JWT issued offline by the operator) are
|
||||
// loaded from YAMA_LICENSE_SERVER / YAMA_LICENSE_TOKEN at startup.
|
||||
@@ -153,8 +172,17 @@ func (r *RemoteSigner) Sign(startTime, clientID string) (string, error) {
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
// Hard failure: fall back to stale cache if any. Better to keep an
|
||||
// existing device alive than fail closed during a transient outage.
|
||||
// Quota-exceeded is authoritative — skip stale-cache fallback entirely.
|
||||
// Serving a cached signature here would bypass the operator's explicit cap
|
||||
// decision; the caller (handleLogin) should close the connection instead.
|
||||
if IsQuotaExceeded(err) {
|
||||
r.logger.Error("RemoteSigner: quota exceeded for clientID=%s (%v); sending zeroed signature",
|
||||
clientID, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Transient failure: fall back to stale cache if any. Better to keep an
|
||||
// existing device alive than fail closed during a momentary outage.
|
||||
r.mu.Lock()
|
||||
c, ok := r.cache[key]
|
||||
r.mu.Unlock()
|
||||
@@ -179,7 +207,9 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
if r.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
@@ -194,7 +224,17 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 401/403: token rejected — likely revoked or expired.
|
||||
// 403 with a JSON error body: quota exceeded — this is authoritative,
|
||||
// not a transient failure. Return a typed error so Sign() can skip
|
||||
// the stale-cache fallback and callers can close the connection.
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
var sr signResponse
|
||||
if jsonErr := json.Unmarshal(respBody, &sr); jsonErr == nil && sr.Error != "" {
|
||||
return "", &QuotaExceededError{Message: sr.Error}
|
||||
}
|
||||
return "", &QuotaExceededError{Message: string(respBody)}
|
||||
}
|
||||
// 401 / 5xx: token rejected or server error — treat as transient.
|
||||
return "", fmt.Errorf("License Server returned %d: %s",
|
||||
resp.StatusCode, string(respBody))
|
||||
}
|
||||
@@ -265,7 +305,9 @@ func (r *RemoteSigner) sendHeartbeat() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
if r.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
@@ -280,7 +322,15 @@ func (r *RemoteSigner) sendHeartbeat() {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RemoteSigner) Mode() string { return "remote" }
|
||||
// Mode reports "trial" if this RemoteSigner has no JWT (anonymous
|
||||
// downstream against the operator's License Server, capped at
|
||||
// FreeMaxDevices), otherwise "remote" (paid customer with JWT).
|
||||
func (r *RemoteSigner) Mode() string {
|
||||
if r.token == "" {
|
||||
return "trial"
|
||||
}
|
||||
return "remote"
|
||||
}
|
||||
|
||||
func (r *RemoteSigner) Close() error {
|
||||
select {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user