package licensing import ( "fmt" "os" "strconv" "strings" "time" ) // Env var names — keep these in one place so README and code stay in sync. 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 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 // Mode is what NewFromEnv inspected to pick a Signer; useful for logging. type Mode int const ( ModeLocal Mode = iota ModeRemote ModeTrial // RemoteSigner against License Server, but with no Bearer (anonymous trial) ModeNoOp ) func (m Mode) String() string { switch m { case ModeLocal: return "local" case ModeRemote: return "remote" case ModeTrial: return "trial" default: return "noop" } } // SelectedMode reports which mode NewFromEnv picked based on env vars, // without actually constructing a signer. Useful for startup banners. func SelectedMode() Mode { if os.Getenv(EnvSignPassword) != "" { return ModeLocal } if strings.TrimSpace(os.Getenv(EnvLicenseDisabled)) == "1" { return ModeNoOp } if os.Getenv(EnvLicenseToken) != "" { return ModeRemote } return ModeTrial } // 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). // In that case we log a loud warning so the operator notices the dead config. // // may be nil — RemoteSigner falls back to a silent logger internally. func NewFromEnv(lg Logger) (Signer, Mode, error) { server := os.Getenv(EnvLicenseServer) token := os.Getenv(EnvLicenseToken) pwd := os.Getenv(EnvSignPassword) if pwd != "" { if (server != "" || token != "") && lg != nil { lg.Warn("%s is set; ignoring %s/%s (LocalSigner wins) — "+ "if you meant to be a customer deployment, unset %s", EnvSignPassword, EnvLicenseServer, EnvLicenseToken, EnvSignPassword) } s, err := NewLocal(pwd) if err != nil { return nil, ModeNoOp, err } return s, ModeLocal, nil } // 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) } 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 } // 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 // if) the operator wants to expose it: requires both YAMA_LICENSE_PUBLIC_KEY // (to verify customer JWTs) and YAMA_LICENSE_HTTP_ADDR (listen address) set, // AND the Signer must be a *LocalSigner (RemoteSigner customer deployments // must never serve a License Server — they don't have the master key). // // Returns (nil, "", nil) if not configured. Returns (server, addr, nil) when // configured. Returns error on bad / partial config. func LicenseServerFromEnv(signer Signer, lg Logger) (*LicenseServer, string, error) { pubPath := os.Getenv(EnvLicensePubKeyPath) addr := os.Getenv(EnvLicenseHTTPAddr) if pubPath == "" && addr == "" { return nil, "", nil // not configured — fine } if pubPath == "" || addr == "" { return nil, "", fmt.Errorf( "%s and %s must be set together to enable License Server (got %s=%q %s=%q)", EnvLicensePubKeyPath, EnvLicenseHTTPAddr, EnvLicensePubKeyPath, pubPath, EnvLicenseHTTPAddr, addr) } local, ok := signer.(*LocalSigner) if !ok { return nil, "", fmt.Errorf( "License Server requires LocalSigner mode (set %s); current mode is %s", EnvSignPassword, signer.Mode()) } pubKey, err := LoadRSAPublicKey(pubPath) if err != nil { return nil, "", err } // 5-minute eviction window — twice a typical heartbeat interval. Matches // the discussion in quota.go. 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 }