Files
SimpleRemoter/server/go/licensing/factory.go

191 lines
6.7 KiB
Go

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.
//
// <lg> 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
}