From 4064bbe25d449a7137c1e8911a8de9bc1a803ba8 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Thu, 4 Jun 2026 09:23:57 +0200 Subject: [PATCH] Feature(licensing): anonymous trial mode + server-side quota enforcement --- server/2015Remote/BuildDlg.cpp | 12 +- server/go/.vscode/launch.json | 6 +- server/go/README.md | 94 ++++++++++++- server/go/cmd/main.go | 81 +++++++---- server/go/licensing/factory.go | 102 +++++++++----- server/go/licensing/licensing_test.go | 95 +++++++++++-- server/go/licensing/remote.go | 62 ++++++++- server/go/licensing/server.go | 189 ++++++++++++++++++++++++-- 8 files changed, 541 insertions(+), 100 deletions(-) diff --git a/server/2015Remote/BuildDlg.cpp b/server/2015Remote/BuildDlg.cpp index 5f3361c..a78aa97 100644 --- a/server/2015Remote/BuildDlg.cpp +++ b/server/2015Remote/BuildDlg.cpp @@ -627,15 +627,15 @@ void CBuildDlg::OnBnClickedOk() BOOL checked = m_BtnFileServer.GetCheck() == BST_CHECKED; if (checked) { strcpy(sc->downloadUrl, m_sDownloadUrl.IsEmpty() ? BuildPayloadUrl(m_strIP, sc->file) : m_sDownloadUrl); - if (m_sDownloadUrl.IsEmpty()) MessageBoxL(CString("文件下载地址: \r\n") + sc->downloadUrl, "提示", MB_ICONINFORMATION); + if (m_sDownloadUrl.IsEmpty()) MessageBoxL(_TR("文件下载地址: \r\n") + sc->downloadUrl, "提示", MB_ICONINFORMATION); } - tip = payload.IsEmpty() ? "\r\n警告: 没有生成载荷!" : - checked ? "\r\n提示: 本机提供下载时,载荷文件必须拷贝至\"Payloads\"目录。" : "\r\n提示: 载荷文件必须拷贝至程序目录。"; + tip = payload.IsEmpty() ? _TR("\r\n警告: 没有生成载荷!") : + checked ? _TR("\r\n提示: 本机提供下载时,载荷文件必须拷贝至\"Payloads\"目录。") : _TR("\r\n提示: 载荷文件必须拷贝至程序目录。"); } BOOL r = WriteBinaryToFile(strSeverFile.GetString(), (char*)data, dwSize); if (r) { r = WriteBinaryToFile(payload.GetString(), (char*)srcData, srcLen, n == Payload_Raw ? 0 : -1); - if (!r) tip = "\r\n警告: 生成载荷失败!"; + if (!r) tip = _TR("\r\n警告: 生成载荷失败!"); } else { MessageBoxL(_TR("文件生成失败: ") + "\r\n" + strSeverFile, "提示", MB_ICONINFORMATION); } @@ -647,7 +647,7 @@ void CBuildDlg::OnBnClickedOk() } else if (sel == CLIENT_PE_TO_SEHLLCODE) { int pe_2_shellcode(const std::string & in_path, const std::string & out_str); int ret = pe_2_shellcode(strSeverFile.GetString(), strSeverFile.GetString()); - if (ret)MessageBoxL(CString("ShellCode 转换异常, 异常代码: ") + CString(std::to_string(ret).c_str()), + if (ret)MessageBoxL(_TR("ShellCode 转换异常, 异常代码: ") + CString(std::to_string(ret).c_str()), "提示", MB_ICONINFORMATION); } else if (sel == CLIENT_COMPRESS_SC_AES_OLD || // 兼容旧版本 sel == CLIENT_COMP_SC_AES_OLD_UPX) { @@ -927,7 +927,7 @@ void CBuildDlg::OnCbnSelchangeComboExe() SAFE_DELETE_ARRAY(szBuffer); } } else { - m_OtherItem.SetWindowTextA("未选择文件"); + m_OtherItem.SetWindowTextA(_TR("未选择文件")); } m_OtherItem.ShowWindow(SW_SHOW); } else { diff --git a/server/go/.vscode/launch.json b/server/go/.vscode/launch.json index 8eb08a4..5af3e26 100644 --- a/server/go/.vscode/launch.json +++ b/server/go/.vscode/launch.json @@ -9,7 +9,8 @@ "program": "${workspaceFolder}/cmd", "cwd": "${workspaceFolder}", "args": [ - "-port=9090" + "-port=6543", + "--http-port=8080" ], "env": { "YAMA_WEB_ADMIN_PASS": "3.14159" @@ -25,7 +26,8 @@ "program": "${workspaceFolder}/cmd", "cwd": "${workspaceFolder}", "args": [ - "-port=9090" + "-port=6543", + "--http-port=8080" ], "env": { "YAMA_WEB_ADMIN_PASS": "3.14159" diff --git a/server/go/README.md b/server/go/README.md index 992fd97..152c8d4 100644 --- a/server/go/README.md +++ b/server/go/README.md @@ -159,9 +159,10 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。 | `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` | | `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` | | `YAMA_SIGN_PASSWORD` | **[LocalSigner 模式]** HMAC-SHA256 master key,直接给 CMD_MASTERSETTING 签名。Operator 自己的部署用。设置此变量后进入 LocalSigner 模式(见下方"签名模式")。 | `` | -| `YAMA_LICENSE_SERVER` | **[RemoteSigner 模式]** Operator 的 License Server 公开 URL。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。必须与 `YAMA_LICENSE_TOKEN` 同时设置。 | `https://license.example.com` | -| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWT(RS256),作为 Bearer token 鉴权。每个客户一份。 | `eyJhbGciOiJSUzI1NiI...` | -| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` | +| `YAMA_LICENSE_SERVER` | **[RemoteSigner / Trial 模式]** License Server 公开 URL。**不设置则用 DefaultLicenseServerURL (`https://web.just-do-it.icu:8080`)**。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。 | `https://license.example.com` | +| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWT(RS256),作为 Bearer token 鉴权。每个客户一份。**未设置则进入 TRIAL 模式(匿名试用,按出口 IP 配额 2 台)**。 | `eyJhbGciOiJSUzI1NiI...` | +| `YAMA_LICENSE_DISABLED` | 设为 `1` 强制 NoOp 模式(既不读 token 也不连 License Server,客户端会拒绝屏幕/文件功能)。给本地开发 / 离线测试用。 | `1` | +| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner / Trial 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` | | `YAMA_LICENSE_PUBLIC_KEY` | **[License Server 模式]** Operator 自己(已经是 LocalSigner)想顺便对外提供 License Server 时,用来验证客户提交的 JWT 的 RSA 公钥 PEM 路径。必须与 `YAMA_LICENSE_HTTP_ADDR` 同时设置。 | `./license_pub.pem` | | `YAMA_LICENSE_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**(RemoteSigner 客户不能反向当 license server)。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` | | `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` | @@ -182,15 +183,16 @@ $env:YAMA_PWD="your_super_password" ## 签名模式(CMD_MASTERSETTING signer) -单个 Go 二进制按启动时的环境变量自动选择三种签名模式之一。同一个 master HMAC key 永远不会出现在客户机器上 —— 这是把 Go server 商业化部署给付费客户的核心安全前提。 +单个 Go 二进制按启动时的环境变量自动选择四种签名模式之一。同一个 master HMAC key 永远不会出现在客户机器上 —— 这是把 Go server 商业化部署给付费客户的核心安全前提。 | 模式 | 触发条件 | 用途 | | ---- | -------- | ---- | | **LocalSigner** | `YAMA_SIGN_PASSWORD` 已设 | Operator 自己的部署。master HMAC key 在本机内存,签名直连 HMAC,微秒级延迟。**可选**:再设 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 让本进程同时对外提供 License Server HTTP 服务。 | -| **RemoteSigner** | `YAMA_LICENSE_SERVER` + `YAMA_LICENSE_TOKEN` 已设 | 客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server,拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h(可调,`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 | -| **NoOpSigner** | 上述都没设 | Free tier。返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 | +| **RemoteSigner (paid)** | `YAMA_LICENSE_TOKEN` 已设(`YAMA_LICENSE_SERVER` 可选,未设则用 `DefaultLicenseServerURL`) | 付费客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server,带 Bearer JWT 鉴权,拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h(可调,`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 | +| **RemoteSigner (trial)** | `YAMA_LICENSE_TOKEN` 未设、且未显式 `YAMA_LICENSE_DISABLED=1` | 匿名试用模式。没有 JWT,连默认 License Server URL;服务端按下级出口 IP 识别身份,配额 `FreeMaxDevices` (2 台),且匿名 `/license/sign` 受 IP 限流(10 req/min)。零配置直接跑就在这个模式。 | +| **NoOpSigner** | `YAMA_LICENSE_DISABLED=1` | 显式离线/开发模式。不连任何 License Server,返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 | -注:`YAMA_SIGN_PASSWORD` 与 `YAMA_LICENSE_SERVER` 同时设置时 LocalSigner 优先(operator 自己的 server 不应该回连自己)。 +注:`YAMA_SIGN_PASSWORD` 与 `YAMA_LICENSE_*` 同时设置时 LocalSigner 优先(operator 自己的 server 不应该回连自己)。 ### License Server endpoints(仅 LocalSigner 暴露) @@ -496,6 +498,84 @@ publicIP := info.GetReservedField(11) // 公网 IP - [gopkg.in/natefinch/lumberjack.v2](https://github.com/natefinch/lumberjack) - 日志轮转 - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) - GBK 编码转换 +## 配置和启动参数 + +### 启动参数说明 + +- `-p` / `--port`:受管设备 TCP 监听端口(默认 6543,可分号分隔多端口,如 `6543;6544;6545`) +- `--http-port`:Web 管理 HTTP 端口(默认 8080,设为 0 则禁用 Web 管理) +- `--no-console`:守护进程模式,不输出到控制台(日志仍写入 `logs/server.log`) + +### (1)授权中心(由我运行) + +**模式判定**:设置了 `YAMA_SIGN_PASSWORD`,本进程以 LocalSigner 持有主 HMAC 密钥;可选地开启 +License Server HTTP(`YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR`),向下级服务签发带 24h 缓存的授权。 + +```bash +export YAMA_PWD="授权码 HMAC 校验密钥(TCP 端 passcode 签名验证)" +export YAMA_WEB_ADMIN_PASS="Web 端登录密码" +export YAMA_SIGN_PASSWORD="主控签名 HMAC 主密钥(受管设备验证服务端身份)" +export YAMA_WEB_TRUST_PROXY=1 +export YAMA_WEB_ALLOWED_ORIGINS="https://web.just-do-it.icu:8080" +export YAMA_LICENSE_PUBLIC_KEY="/opt/yama/license_pub.pem" +export YAMA_LICENSE_HTTP_ADDR="127.0.0.1:8443" + +nohup ./server_linux_amd64 -p 8000 --http-port=9001 --no-console & +``` + +由前端代理将公网流量(受管设备 8000、Web 9001、License Server 8443)转发到本进程。受管设备通过 8000 端口直连服务端; +下级服务(运行 RemoteSigner)通过代理对外暴露的 `YAMA_LICENSE_SERVER` 公网 URL 取授权签名,代理后端转发到本进程 +绑定的 `YAMA_LICENSE_HTTP_ADDR (127.0.0.1:8443)`。 + +可选环境变量: + +- `YAMA_PWDHASH`:TCP 授权码的 SHA256 哈希(未设置则使用代码内置默认值,启动 banner 会有警告) +- `YAMA_USERS_FILE`:额外 Web 用户列表 JSON 路径(默认 `users.json`) + +### (2)下级服务(运营商部署) + +下级二进制把"小白用户开箱即用"作为目标——**理想情况下,什么都不配,直接 `./server_linux_amd64` 就能跑起来**。 +启动后默认行为: + +- License Server:默认连到 `https://web.just-do-it.icu:8080`(DefaultLicenseServerURL) +- 模式:无 `YAMA_LICENSE_TOKEN` → 进入**试用模式**(TRIAL),授权中心按下级出口 IP 识别身份,最多 **2 台** + 受管设备(FreeMaxDevices) +- Web 管理:无 `YAMA_WEB_ADMIN_PASS` → 使用默认账号 `admin/admin`,启动日志会大字警告 +- 监听端口:受管设备 TCP 6543、Web 8080 + +#### 最简启动(零配置试用,2 台设备上限) + +```bash +nohup ./server_linux_amd64 --no-console & +``` + +启动日志会显示: + +``` +WARN ⚠ YAMA_WEB_ADMIN_PASS / YAMA_PWD 均未设置,Web 管理使用默认密码 admin/admin — 生产环境务必覆盖 +INFO Signer mode: TRIAL (anonymous试用模式, license server=https://web.just-do-it.icu:8080, + 最多 2 台受管设备; 设置 YAMA_LICENSE_TOKEN 解锁付费配额) +``` + +#### 推荐生产配置(覆盖默认密码 + 付费 token) + +```bash +export YAMA_WEB_ADMIN_PASS="自定义 Web 登录密码" +export YAMA_LICENSE_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3M..." # 向授权中心申请 + +nohup ./server_linux_amd64 -p 6543 --http-port=8080 --no-console & +``` + +设置 `YAMA_LICENSE_TOKEN` 后切换为 REMOTE 模式(付费),配额由 JWT 的 `max_devices` 决定。 +如果同时改 `YAMA_LICENSE_SERVER` 可以接到自部署的授权中心,否则继续走默认 URL。 + +#### 其他可选 + +- `YAMA_LICENSE_DISABLED=1`:完全禁用 License Server 通信(离线 / 内网测试),客户端会拒绝屏幕/文件功能但设备列表仍能用 +- `YAMA_LICENSE_OFFLINE_HRS=24`:本地签名缓存的 TTL,默认 24h +- 受管设备的 `client.exe` 由 Windows 主控端生成(已绑定服务端地址和公钥),下级运营商把它分发给终端用户。 + client 通过 6543 端口连接到本服务端;用户通过 8080 端口登录 Web 管理页面。 + ## License MIT License diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index bb2c0c4..47c976f 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -415,9 +415,29 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) { resolution = info.GetReservedField(protocol.ResFieldResolution) } - // Register with hub so the web side can list this device. Sub-connections - // (screen / terminal etc.) reuse the MasterID and will overwrite this entry - // harmlessly, but only the main login carries enough info to be useful here. + // Sign BEFORE registering in the hub so a quota-rejected device never + // appears in the web device list, even briefly. If signing fails we still + // send CMD_MASTERSETTING (zeroed signature, wire protocol stays clean) and + // then close the connection — no hub registration happens at all. + sigErr := h.sendMasterSetting(ctx, info.StartTime, clientID) + if sigErr != nil { + // Any sign error means no valid signature was issued — close without + // registering. Covers: quota exceeded (403), anonymous IP rate limit + // (429), and transient errors on a brand-new device with no cache. + // Existing devices reconnecting hit the fresh-cache path in Sign() and + // return ("sig", nil), so they are never rejected here. + h.log.Warn("sign failed for clientID=%s (%v) — closing connection", clientID, sigErr) + go func() { + time.Sleep(50 * time.Millisecond) // let CMD_MASTERSETTING flush + ctx.Close() + }() + return + } + + // Signing succeeded: register with hub so the web side can list this + // device. Sub-connections (screen / terminal etc.) reuse the MasterID and + // will overwrite this entry harmlessly, but only the main login carries + // enough info to be useful here. h.hub.Register(&hub.Device{ ID: clientID, Name: name, @@ -434,11 +454,6 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) { PublicIP: clientInfo.IP, ConnectedAt: time.Now(), }, ctx) - - // Push CMD_MASTERSETTING with a signature over "StartTime|ClientID". - // The client's private FileUpload init verifies this before allowing - // screen / file operations — without it the binary aborts itself. - h.sendMasterSetting(ctx, info.StartTime, clientID) } // sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it @@ -448,10 +463,10 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) { // - RemoteSigner: HTTPS POST to operator's License Server (customer deployment) // - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops) // -// On signer error (License Server unreachable + no cache hit), we still ship -// a zeroed signature so the packet is well-formed; the client will retry on -// next reconnect. -func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) { +// Returns the signer error (not the send error) so callers can distinguish +// quota-exceeded rejections from transient failures and act accordingly. +// The packet is always sent — even on error — so the wire protocol stays clean. +func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) error { buf := make([]byte, 1+protocol.MasterSettingsSize) buf[0] = protocol.CmdMasterSetting @@ -462,10 +477,10 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client buf[1:5], uint32(protocol.DefaultReportIntervalSec)) - sig, err := h.signer.Sign(startTime, clientID) - if err != nil { + sig, sigErr := h.signer.Sign(startTime, clientID) + if sigErr != nil { h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature", - h.signer.Mode(), clientID, err) + h.signer.Mode(), clientID, sigErr) } else if sig == "" { // NoOpSigner path, or LocalSigner with empty master key — same effect. // Log only once per process via the startup banner; don't spam here. @@ -477,6 +492,7 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client if err := h.srv.Send(ctx, buf); err != nil { h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err) } + return sigErr } // handleAuth handles authorization request (TOKEN_AUTH = 100) @@ -700,13 +716,13 @@ func main() { deviceHub := hub.New() // Build the CMD_MASTERSETTING signer based on env vars: - // - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment; - // HMAC master key lives here) - // - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner - // (customer deployment; never sees the master key, fetches signatures - // from operator's License Server with 24h cache) - // - neither → NoOpSigner (free tier; client refuses screen/file ops - // but device list still works) + // - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment; + // HMAC master key lives here) + // - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out; dev / offline) + // - YAMA_LICENSE_TOKEN set → RemoteSigner (paid customer; talks to + // operator's License Server with JWT) + // - neither of the above → RemoteSigner (anonymous trial; default + // URL, no JWT, cap FreeMaxDevices) signer, mode, err := licensing.NewFromEnv(log) if err != nil { log.Fatal("Failed to initialize signer: %v", err) @@ -718,10 +734,21 @@ func main() { case licensing.ModeLocal: log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)") case licensing.ModeRemote: - log.Info("Signer mode: REMOTE (customer deployment, %s=%s)", - licensing.EnvLicenseServer, os.Getenv(licensing.EnvLicenseServer)) + licServer := os.Getenv(licensing.EnvLicenseServer) + if licServer == "" { + licServer = licensing.DefaultLicenseServerURL + } + log.Info("Signer mode: REMOTE (paid customer, license server=%s)", licServer) + case licensing.ModeTrial: + licServer := os.Getenv(licensing.EnvLicenseServer) + if licServer == "" { + licServer = licensing.DefaultLicenseServerURL + } + log.Info("Signer mode: TRIAL (anonymous试用模式, license server=%s, 最多 %d 台受管设备; 设置 %s 解锁付费配额)", + licServer, licensing.FreeMaxDevices, licensing.EnvLicenseToken) case licensing.ModeNoOp: - log.Warn("Signer mode: NOOP (no licensing configured; screen/file features disabled on clients)") + log.Warn("Signer mode: NOOP (licensing disabled via %s=1; client refuses screen/file features)", + licensing.EnvLicenseDisabled) } // If the operator also wants this LocalSigner deployment to serve as @@ -749,6 +776,10 @@ func main() { adminPass = defaultWebAdminPass rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass) usingDefaultWebPass = true + // Loud warn (in addition to the startup banner): the binary is now + // accepting admin/admin on the public web UI. Anyone running this + // without overriding the env var in prod needs to see it in red. + log.Warn("⚠ YAMA_WEB_ADMIN_PASS / YAMA_PWD 均未设置,Web 管理使用默认密码 admin/admin — 生产环境务必覆盖") } webAuth.AddAdminFromPlainPassword("admin", adminPass) log.Info("Web admin user configured") diff --git a/server/go/licensing/factory.go b/server/go/licensing/factory.go index 1b62df7..6eb4fc3 100644 --- a/server/go/licensing/factory.go +++ b/server/go/licensing/factory.go @@ -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 } diff --git a/server/go/licensing/licensing_test.go b/server/go/licensing/licensing_test.go index 93ba005..44c8221 100644 --- a/server/go/licensing/licensing_test.go +++ b/server/go/licensing/licensing_test.go @@ -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) } } diff --git a/server/go/licensing/remote.go b/server/go/licensing/remote.go index 0e13ec5..1b00fc5 100644 --- a/server/go/licensing/remote.go +++ b/server/go/licensing/remote.go @@ -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 { diff --git a/server/go/licensing/server.go b/server/go/licensing/server.go index 9205fb9..bbf6f49 100644 --- a/server/go/licensing/server.go +++ b/server/go/licensing/server.go @@ -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:`) 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:", 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 }