Feature(licensing): anonymous trial mode + server-side quota enforcement
This commit is contained in:
@@ -627,15 +627,15 @@ void CBuildDlg::OnBnClickedOk()
|
|||||||
BOOL checked = m_BtnFileServer.GetCheck() == BST_CHECKED;
|
BOOL checked = m_BtnFileServer.GetCheck() == BST_CHECKED;
|
||||||
if (checked) {
|
if (checked) {
|
||||||
strcpy(sc->downloadUrl, m_sDownloadUrl.IsEmpty() ? BuildPayloadUrl(m_strIP, sc->file) : m_sDownloadUrl);
|
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警告: 没有生成载荷!" :
|
tip = payload.IsEmpty() ? _TR("\r\n警告: 没有生成载荷!") :
|
||||||
checked ? "\r\n提示: 本机提供下载时,载荷文件必须拷贝至\"Payloads\"目录。" : "\r\n提示: 载荷文件必须拷贝至程序目录。";
|
checked ? _TR("\r\n提示: 本机提供下载时,载荷文件必须拷贝至\"Payloads\"目录。") : _TR("\r\n提示: 载荷文件必须拷贝至程序目录。");
|
||||||
}
|
}
|
||||||
BOOL r = WriteBinaryToFile(strSeverFile.GetString(), (char*)data, dwSize);
|
BOOL r = WriteBinaryToFile(strSeverFile.GetString(), (char*)data, dwSize);
|
||||||
if (r) {
|
if (r) {
|
||||||
r = WriteBinaryToFile(payload.GetString(), (char*)srcData, srcLen, n == Payload_Raw ? 0 : -1);
|
r = WriteBinaryToFile(payload.GetString(), (char*)srcData, srcLen, n == Payload_Raw ? 0 : -1);
|
||||||
if (!r) tip = "\r\n警告: 生成载荷失败!";
|
if (!r) tip = _TR("\r\n警告: 生成载荷失败!");
|
||||||
} else {
|
} else {
|
||||||
MessageBoxL(_TR("文件生成失败: ") + "\r\n" + strSeverFile, "提示", MB_ICONINFORMATION);
|
MessageBoxL(_TR("文件生成失败: ") + "\r\n" + strSeverFile, "提示", MB_ICONINFORMATION);
|
||||||
}
|
}
|
||||||
@@ -647,7 +647,7 @@ void CBuildDlg::OnBnClickedOk()
|
|||||||
} else if (sel == CLIENT_PE_TO_SEHLLCODE) {
|
} else if (sel == CLIENT_PE_TO_SEHLLCODE) {
|
||||||
int pe_2_shellcode(const std::string & in_path, const std::string & out_str);
|
int pe_2_shellcode(const std::string & in_path, const std::string & out_str);
|
||||||
int ret = pe_2_shellcode(strSeverFile.GetString(), strSeverFile.GetString());
|
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);
|
"提示", MB_ICONINFORMATION);
|
||||||
} else if (sel == CLIENT_COMPRESS_SC_AES_OLD || // 兼容旧版本
|
} else if (sel == CLIENT_COMPRESS_SC_AES_OLD || // 兼容旧版本
|
||||||
sel == CLIENT_COMP_SC_AES_OLD_UPX) {
|
sel == CLIENT_COMP_SC_AES_OLD_UPX) {
|
||||||
@@ -927,7 +927,7 @@ void CBuildDlg::OnCbnSelchangeComboExe()
|
|||||||
SAFE_DELETE_ARRAY(szBuffer);
|
SAFE_DELETE_ARRAY(szBuffer);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m_OtherItem.SetWindowTextA("未选择文件");
|
m_OtherItem.SetWindowTextA(_TR("未选择文件"));
|
||||||
}
|
}
|
||||||
m_OtherItem.ShowWindow(SW_SHOW);
|
m_OtherItem.ShowWindow(SW_SHOW);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
6
server/go/.vscode/launch.json
vendored
6
server/go/.vscode/launch.json
vendored
@@ -9,7 +9,8 @@
|
|||||||
"program": "${workspaceFolder}/cmd",
|
"program": "${workspaceFolder}/cmd",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"args": [
|
"args": [
|
||||||
"-port=9090"
|
"-port=6543",
|
||||||
|
"--http-port=8080"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
||||||
@@ -25,7 +26,8 @@
|
|||||||
"program": "${workspaceFolder}/cmd",
|
"program": "${workspaceFolder}/cmd",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"args": [
|
"args": [
|
||||||
"-port=9090"
|
"-port=6543",
|
||||||
|
"--http-port=8080"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
||||||
|
|||||||
@@ -159,9 +159,10 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
|||||||
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
|
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
|
||||||
| `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_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 模式(见下方"签名模式")。 | `<deployment-shared-secret>` |
|
| `YAMA_SIGN_PASSWORD` | **[LocalSigner 模式]** HMAC-SHA256 master key,直接给 CMD_MASTERSETTING 签名。Operator 自己的部署用。设置此变量后进入 LocalSigner 模式(见下方"签名模式")。 | `<deployment-shared-secret>` |
|
||||||
| `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_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 鉴权。每个客户一份。 | `eyJhbGciOiJSUzI1NiI...` |
|
| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWT(RS256),作为 Bearer token 鉴权。每个客户一份。**未设置则进入 TRIAL 模式(匿名试用,按出口 IP 配额 2 台)**。 | `eyJhbGciOiJSUzI1NiI...` |
|
||||||
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` |
|
| `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_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_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` |
|
| `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)
|
## 签名模式(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 服务。 |
|
| **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`),用于扛短暂网络故障。 |
|
| **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`),用于扛短暂网络故障。 |
|
||||||
| **NoOpSigner** | 上述都没设 | Free tier。返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 |
|
| **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 暴露)
|
### License Server endpoints(仅 LocalSigner 暴露)
|
||||||
|
|
||||||
@@ -496,6 +498,84 @@ publicIP := info.GetReservedField(11) // 公网 IP
|
|||||||
- [gopkg.in/natefinch/lumberjack.v2](https://github.com/natefinch/lumberjack) - 日志轮转
|
- [gopkg.in/natefinch/lumberjack.v2](https://github.com/natefinch/lumberjack) - 日志轮转
|
||||||
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) - GBK 编码转换
|
- [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
|
## License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|||||||
@@ -415,9 +415,29 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
|||||||
resolution = info.GetReservedField(protocol.ResFieldResolution)
|
resolution = info.GetReservedField(protocol.ResFieldResolution)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register with hub so the web side can list this device. Sub-connections
|
// Sign BEFORE registering in the hub so a quota-rejected device never
|
||||||
// (screen / terminal etc.) reuse the MasterID and will overwrite this entry
|
// appears in the web device list, even briefly. If signing fails we still
|
||||||
// harmlessly, but only the main login carries enough info to be useful here.
|
// 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{
|
h.hub.Register(&hub.Device{
|
||||||
ID: clientID,
|
ID: clientID,
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -434,11 +454,6 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
|||||||
PublicIP: clientInfo.IP,
|
PublicIP: clientInfo.IP,
|
||||||
ConnectedAt: time.Now(),
|
ConnectedAt: time.Now(),
|
||||||
}, ctx)
|
}, 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
|
// 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)
|
// - RemoteSigner: HTTPS POST to operator's License Server (customer deployment)
|
||||||
// - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops)
|
// - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops)
|
||||||
//
|
//
|
||||||
// On signer error (License Server unreachable + no cache hit), we still ship
|
// Returns the signer error (not the send error) so callers can distinguish
|
||||||
// a zeroed signature so the packet is well-formed; the client will retry on
|
// quota-exceeded rejections from transient failures and act accordingly.
|
||||||
// next reconnect.
|
// The packet is always sent — even on error — so the wire protocol stays clean.
|
||||||
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) {
|
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) error {
|
||||||
buf := make([]byte, 1+protocol.MasterSettingsSize)
|
buf := make([]byte, 1+protocol.MasterSettingsSize)
|
||||||
buf[0] = protocol.CmdMasterSetting
|
buf[0] = protocol.CmdMasterSetting
|
||||||
|
|
||||||
@@ -462,10 +477,10 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
|
|||||||
buf[1:5],
|
buf[1:5],
|
||||||
uint32(protocol.DefaultReportIntervalSec))
|
uint32(protocol.DefaultReportIntervalSec))
|
||||||
|
|
||||||
sig, err := h.signer.Sign(startTime, clientID)
|
sig, sigErr := h.signer.Sign(startTime, clientID)
|
||||||
if err != nil {
|
if sigErr != nil {
|
||||||
h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature",
|
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 == "" {
|
} else if sig == "" {
|
||||||
// NoOpSigner path, or LocalSigner with empty master key — same effect.
|
// NoOpSigner path, or LocalSigner with empty master key — same effect.
|
||||||
// Log only once per process via the startup banner; don't spam here.
|
// 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 {
|
if err := h.srv.Send(ctx, buf); err != nil {
|
||||||
h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err)
|
h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err)
|
||||||
}
|
}
|
||||||
|
return sigErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAuth handles authorization request (TOKEN_AUTH = 100)
|
// handleAuth handles authorization request (TOKEN_AUTH = 100)
|
||||||
@@ -700,13 +716,13 @@ func main() {
|
|||||||
deviceHub := hub.New()
|
deviceHub := hub.New()
|
||||||
|
|
||||||
// Build the CMD_MASTERSETTING signer based on env vars:
|
// Build the CMD_MASTERSETTING signer based on env vars:
|
||||||
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
|
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
|
||||||
// HMAC master key lives here)
|
// HMAC master key lives here)
|
||||||
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
|
// - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out; dev / offline)
|
||||||
// (customer deployment; never sees the master key, fetches signatures
|
// - YAMA_LICENSE_TOKEN set → RemoteSigner (paid customer; talks to
|
||||||
// from operator's License Server with 24h cache)
|
// operator's License Server with JWT)
|
||||||
// - neither → NoOpSigner (free tier; client refuses screen/file ops
|
// - neither of the above → RemoteSigner (anonymous trial; default
|
||||||
// but device list still works)
|
// URL, no JWT, cap FreeMaxDevices)
|
||||||
signer, mode, err := licensing.NewFromEnv(log)
|
signer, mode, err := licensing.NewFromEnv(log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to initialize signer: %v", err)
|
log.Fatal("Failed to initialize signer: %v", err)
|
||||||
@@ -718,10 +734,21 @@ func main() {
|
|||||||
case licensing.ModeLocal:
|
case licensing.ModeLocal:
|
||||||
log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)")
|
log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)")
|
||||||
case licensing.ModeRemote:
|
case licensing.ModeRemote:
|
||||||
log.Info("Signer mode: REMOTE (customer deployment, %s=%s)",
|
licServer := os.Getenv(licensing.EnvLicenseServer)
|
||||||
licensing.EnvLicenseServer, 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:
|
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
|
// If the operator also wants this LocalSigner deployment to serve as
|
||||||
@@ -749,6 +776,10 @@ func main() {
|
|||||||
adminPass = defaultWebAdminPass
|
adminPass = defaultWebAdminPass
|
||||||
rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass)
|
rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass)
|
||||||
usingDefaultWebPass = true
|
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)
|
webAuth.AddAdminFromPlainPassword("admin", adminPass)
|
||||||
log.Info("Web admin user configured")
|
log.Info("Web admin user configured")
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ const (
|
|||||||
EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path
|
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"
|
EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443"
|
||||||
EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24)
|
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
|
// DefaultOfflineGrace mirrors the "24 hours" decision recorded in the
|
||||||
// project memory's licensing design.
|
// project memory's licensing design.
|
||||||
const DefaultOfflineGrace = 24 * time.Hour
|
const DefaultOfflineGrace = 24 * time.Hour
|
||||||
@@ -28,6 +35,7 @@ type Mode int
|
|||||||
const (
|
const (
|
||||||
ModeLocal Mode = iota
|
ModeLocal Mode = iota
|
||||||
ModeRemote
|
ModeRemote
|
||||||
|
ModeTrial // RemoteSigner against License Server, but with no Bearer (anonymous trial)
|
||||||
ModeNoOp
|
ModeNoOp
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +45,8 @@ func (m Mode) String() string {
|
|||||||
return "local"
|
return "local"
|
||||||
case ModeRemote:
|
case ModeRemote:
|
||||||
return "remote"
|
return "remote"
|
||||||
|
case ModeTrial:
|
||||||
|
return "trial"
|
||||||
default:
|
default:
|
||||||
return "noop"
|
return "noop"
|
||||||
}
|
}
|
||||||
@@ -48,16 +58,25 @@ func SelectedMode() Mode {
|
|||||||
if os.Getenv(EnvSignPassword) != "" {
|
if os.Getenv(EnvSignPassword) != "" {
|
||||||
return ModeLocal
|
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 ModeRemote
|
||||||
}
|
}
|
||||||
return ModeNoOp
|
return ModeTrial
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFromEnv builds the Signer chosen by env vars:
|
// NewFromEnv builds the Signer chosen by env vars. Decision tree (top-down,
|
||||||
// - YAMA_SIGN_PASSWORD set → LocalSigner
|
// first match wins):
|
||||||
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
|
//
|
||||||
// - neither → NoOpSigner
|
// - 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
|
// If both LocalSigner and RemoteSigner vars are set, LocalSigner wins
|
||||||
// (an operator's own server should never accidentally call out to itself).
|
// (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
|
return s, ModeLocal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if server != "" && token != "" {
|
// Explicit opt-out: operator wants the binary to run with no licensing
|
||||||
if err := ValidateRemoteURL(server); err != nil {
|
// at all (dev, offline test, air-gapped). Screen/file features stay off
|
||||||
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
|
// 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 n < 0 {
|
||||||
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" {
|
return nil, ModeNoOp, fmt.Errorf(
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(hrs))
|
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
grace = time.Duration(n) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
return NewRemote(server, token, grace, lg), ModeRemote, nil
|
return NewRemote(server, token, grace, lg), ModeRemote, nil
|
||||||
}
|
}
|
||||||
|
// Anonymous trial: no Bearer token. License Server identifies by IP and
|
||||||
if server != "" || token != "" {
|
// caps at FreeMaxDevices.
|
||||||
// Partial config is almost certainly a misconfiguration — fail loudly
|
return NewRemote(server, "", grace, lg), ModeTrial, nil
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LicenseServerFromEnv builds the License Server HTTP handler if (and only
|
// 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
|
// 5-minute eviction window — twice a typical heartbeat interval. Matches
|
||||||
// the discussion in quota.go.
|
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,23 +253,102 @@ func TestQuotaEnforcement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAuthRejectsMissingBearer: no token → 401, not 200 / not 500. Belt
|
// TestAnonymousTrialSignsAndCaps: no Authorization header → anonymous trial
|
||||||
// and braces — the auth check sits in front of /sign and /heartbeat.
|
// branch. /sign returns 200 with a real signature up to FreeMaxDevices, then
|
||||||
func TestAuthRejectsMissingBearer(t *testing.T) {
|
// 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)
|
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{})
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
ts := httptest.NewServer(ls.Handler())
|
ts := httptest.NewServer(ls.Handler())
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
body := strings.NewReader(`{"client_id":"x","start_time":"y"}`)
|
call := func(clientID string) (int, string) {
|
||||||
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Post: %v", err)
|
t.Fatalf("Do: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
t.Errorf("expected 401, got %d", resp.StatusCode)
|
t.Errorf("expected 401 for malformed bearer, got %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,25 @@ import (
|
|||||||
"golang.org/x/sync/singleflight"
|
"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
|
// RemoteSigner fetches per-login signatures from an operator-hosted License
|
||||||
// Server. ServerURL and Token (a JWT issued offline by the operator) are
|
// Server. ServerURL and Token (a JWT issued offline by the operator) are
|
||||||
// loaded from YAMA_LICENSE_SERVER / YAMA_LICENSE_TOKEN at startup.
|
// 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
|
return sig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hard failure: fall back to stale cache if any. Better to keep an
|
// Quota-exceeded is authoritative — skip stale-cache fallback entirely.
|
||||||
// existing device alive than fail closed during a transient outage.
|
// 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()
|
r.mu.Lock()
|
||||||
c, ok := r.cache[key]
|
c, ok := r.cache[key]
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -179,7 +207,9 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
@@ -194,7 +224,17 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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",
|
return "", fmt.Errorf("License Server returned %d: %s",
|
||||||
resp.StatusCode, string(respBody))
|
resp.StatusCode, string(respBody))
|
||||||
}
|
}
|
||||||
@@ -265,7 +305,9 @@ func (r *RemoteSigner) sendHeartbeat() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := r.httpClient.Do(req)
|
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 {
|
func (r *RemoteSigner) Close() error {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -5,13 +5,36 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"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
|
// LicenseServer is the HTTP service the operator's LocalSigner exposes for
|
||||||
// RemoteSigner customer deployments. It uses the same LocalSigner instance
|
// RemoteSigner customer deployments. It uses the same LocalSigner instance
|
||||||
// (same HMAC master key) to produce signatures so customers can issue
|
// (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
|
// 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.
|
// "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:<ip>`) 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 {
|
type LicenseServer struct {
|
||||||
signer *LocalSigner
|
signer *LocalSigner
|
||||||
pubKey *rsa.PublicKey
|
pubKey *rsa.PublicKey
|
||||||
tracker *quotaTracker
|
tracker *quotaTracker
|
||||||
logger Logger
|
logger Logger
|
||||||
mux *http.ServeMux
|
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
|
// 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,
|
func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey,
|
||||||
evictAfter time.Duration, lg Logger) *LicenseServer {
|
evictAfter time.Duration, lg Logger) *LicenseServer {
|
||||||
s := &LicenseServer{
|
s := &LicenseServer{
|
||||||
signer: signer,
|
signer: signer,
|
||||||
pubKey: pubKey,
|
pubKey: pubKey,
|
||||||
tracker: newQuotaTracker(evictAfter),
|
tracker: newQuotaTracker(evictAfter),
|
||||||
logger: lg,
|
logger: lg,
|
||||||
mux: http.NewServeMux(),
|
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/sign", s.handleSign)
|
||||||
s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat)
|
s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat)
|
||||||
return s
|
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
|
// Handler returns the http.Handler the operator wires into their HTTP
|
||||||
// server (or runs standalone via http.ListenAndServe).
|
// server (or runs standalone via http.ListenAndServe).
|
||||||
func (s *LicenseServer) Handler() http.Handler { return s.mux }
|
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
|
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:<ip>", 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) {
|
func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := s.authenticate(w, r)
|
claims := s.resolveAuth(w, r)
|
||||||
if claims == nil {
|
if claims == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -117,7 +279,8 @@ func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
|
|||||||
// consume a slot — see quotaTracker.Reserve.
|
// consume a slot — see quotaTracker.Reserve.
|
||||||
active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices)
|
active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices)
|
||||||
if !accepted {
|
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)
|
claims.Subject, claims.Tier, active, claims.MaxDevices, req.ClientID)
|
||||||
writeJSONError(w, http.StatusForbidden,
|
writeJSONError(w, http.StatusForbidden,
|
||||||
fmt.Sprintf("quota exceeded: %d/%d devices in use", active, claims.MaxDevices))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := s.authenticate(w, r)
|
claims := s.resolveAuth(w, r)
|
||||||
if claims == nil {
|
if claims == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user