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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user