Feature(licensing): anonymous trial mode + server-side quota enforcement
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user