Feat(go): add Signer interface + License Server for multi-customer deployments
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -92,3 +92,4 @@ Bin/*
|
|||||||
nul
|
nul
|
||||||
server/go/web/assets/index.html
|
server/go/web/assets/index.html
|
||||||
server/go/users.json
|
server/go/users.json
|
||||||
|
server/go/build/
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ server/go/
|
|||||||
│ └── assets/
|
│ └── assets/
|
||||||
│ ├── index.html # 从 ../../web/index.html sync 而来 (gitignored)
|
│ ├── index.html # 从 ../../web/index.html sync 而来 (gitignored)
|
||||||
│ └── static/ # 第三方 xterm.js 资源 (checked in)
|
│ └── static/ # 第三方 xterm.js 资源 (checked in)
|
||||||
|
├── licensing/
|
||||||
|
│ ├── signer.go # Signer interface (Sign / Mode / Close)
|
||||||
|
│ ├── local.go # LocalSigner — operator 部署,HMAC 直连
|
||||||
|
│ ├── remote.go # RemoteSigner — 客户部署,HTTPS + singleflight + 24h cache + heartbeat ticker
|
||||||
|
│ ├── noop.go # NoOpSigner — free tier,返回空签名
|
||||||
|
│ ├── token.go # JWT RS256 校验 + RSA 公钥加载
|
||||||
|
│ ├── quota.go # 配额追踪 (Reserve / RefreshExisting / 5min eviction)
|
||||||
|
│ ├── server.go # License Server HTTP handlers (/license/sign, /license/heartbeat) + Issue()
|
||||||
|
│ ├── factory.go # NewFromEnv / LicenseServerFromEnv + 模式选择
|
||||||
|
│ └── licensing_test.go # 17 个单元测试
|
||||||
└── cmd/
|
└── cmd/
|
||||||
└── main.go # 程序入口
|
└── main.go # 程序入口
|
||||||
```
|
```
|
||||||
@@ -148,7 +158,12 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
|||||||
| `YAMA_PWDHASH` | 密码的 SHA256 哈希值 (64位十六进制) | `61f04dd6...` |
|
| `YAMA_PWDHASH` | 密码的 SHA256 哈希值 (64位十六进制) | `61f04dd6...` |
|
||||||
| `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` | HMAC-SHA256 key used to sign CMD_MASTERSETTING replies; must match the client's expected value. Provision out-of-band. Unset → client refuses screen/file ops. | `<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_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWT(RS256),作为 Bearer token 鉴权。每个客户一份。 | `eyJhbGciOiJSUzI1NiI...` |
|
||||||
|
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner 模式]** 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` |
|
| `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_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
|
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
|
||||||
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
|
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
|
||||||
@@ -165,6 +180,46 @@ $env:YAMA_PWD="your_super_password"
|
|||||||
.\simpleremoter-server.exe
|
.\simpleremoter-server.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 签名模式(CMD_MASTERSETTING signer)
|
||||||
|
|
||||||
|
单个 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 功能。设备列表仍然可用。 |
|
||||||
|
|
||||||
|
注:`YAMA_SIGN_PASSWORD` 与 `YAMA_LICENSE_SERVER` 同时设置时 LocalSigner 优先(operator 自己的 server 不应该回连自己)。
|
||||||
|
|
||||||
|
### License Server endpoints(仅 LocalSigner 暴露)
|
||||||
|
|
||||||
|
设了 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 后,本进程会监听 `YAMA_LICENSE_HTTP_ADDR` 提供两个端点:
|
||||||
|
|
||||||
|
- `POST /license/sign` — body `{"client_id":"...","start_time":"..."}`,header `Authorization: Bearer <customer-JWT>`,回 `{"signature":"<64-hex>"}`。强制按 JWT 的 `tier` + `max_devices` 配额限制。
|
||||||
|
- `POST /license/heartbeat` — body `{"active_device_count":N,"active_device_ids":["..."]}`,回 `{"server_view_count":M,"drift":N-M}`。Drift 大于阈值时记日志,供 operator 反作弊审核。
|
||||||
|
|
||||||
|
部署建议:本进程只跑 plain HTTP,前面挂 nginx/Caddy/cloudflare 加 TLS。JWT 校验已经把 `alg` 锁死成 RS256,杜绝 `alg:none` 攻击。
|
||||||
|
|
||||||
|
### 颁发客户 JWT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一次性生成 RSA 密钥对(私钥 operator 自己保管,公钥用于 License Server 验证)
|
||||||
|
openssl genrsa -out license_priv.pem 2048
|
||||||
|
openssl rsa -in license_priv.pem -pubout -out license_pub.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
底层 API 是 `licensing.Issue(privKey, sub, tier, maxDevices, ttl)`(见 [`licensing/server.go`](licensing/server.go))。一个开箱即用的 CLI 包装在独立仓库 [`yama-issue-token`](https://github.com/yuanyuanxiang/yama-issue-token)(go.mod `replace` 指向本仓库的 `licensing` 包),用法:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yama-issue-token -priv license_priv.pem -sub acme-corp -tier paid -max 100 -days 365
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tier | max_devices 默认 | 备注 |
|
||||||
|
| ---- | ---------------- | ---- |
|
||||||
|
| `trial` | 20(JWT 未指定时) | 移植 C++ 反代理 RTT 逻辑 |
|
||||||
|
| `paid` | JWT 必须显式指定 | 长 TTL token |
|
||||||
|
|
||||||
## 使用示例
|
## 使用示例
|
||||||
|
|
||||||
完整的 TCP + Hub + Web 集成示例就是 [`cmd/main.go`](cmd/main.go),那是程序入口本身、也是最权威的范例 —— 包含 handler 装配、hub 注册、web HTTP/WS 服务、信号优雅关闭等。
|
完整的 TCP + Hub + Web 集成示例就是 [`cmd/main.go`](cmd/main.go),那是程序入口本身、也是最权威的范例 —— 包含 handler 装配、hub 注册、web HTTP/WS 服务、信号优雅关闭等。
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
|
||||||
@@ -27,7 +30,7 @@ type MyHandler struct {
|
|||||||
auth *auth.Authenticator
|
auth *auth.Authenticator
|
||||||
srv *server.Server
|
srv *server.Server
|
||||||
hub *hub.Hub
|
hub *hub.Hub
|
||||||
signPwd string // HMAC key for CMD_MASTERSETTING signatures (YAMA_SIGN_PASSWORD)
|
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnConnect is called when a client connects
|
// OnConnect is called when a client connects
|
||||||
@@ -436,10 +439,14 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
|||||||
|
|
||||||
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
|
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
|
||||||
// down the main TCP connection. Most fields stay zeroed — only Signature
|
// down the main TCP connection. Most fields stay zeroed — only Signature
|
||||||
// matters today. If no signing password is configured, a zeroed signature is
|
// matters today. The signer is one of:
|
||||||
// still sent (and logged once) so the client at least sees a well-formed
|
// - LocalSigner: HMAC directly with master key (operator's own deployment)
|
||||||
// packet; in that case the client's private library will refuse to start
|
// - RemoteSigner: HTTPS POST to operator's License Server (customer deployment)
|
||||||
// screen / file features and abort.
|
// - 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) {
|
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) {
|
||||||
buf := make([]byte, 1+protocol.MasterSettingsSize)
|
buf := make([]byte, 1+protocol.MasterSettingsSize)
|
||||||
buf[0] = protocol.CmdMasterSetting
|
buf[0] = protocol.CmdMasterSetting
|
||||||
@@ -451,12 +458,14 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
|
|||||||
buf[1:5],
|
buf[1:5],
|
||||||
uint32(protocol.DefaultReportIntervalSec))
|
uint32(protocol.DefaultReportIntervalSec))
|
||||||
|
|
||||||
if h.signPwd == "" {
|
sig, err := h.signer.Sign(startTime, clientID)
|
||||||
h.log.Warn("YAMA_SIGN_PASSWORD not set — client may abort on screen/file ops")
|
if err != nil {
|
||||||
|
h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature",
|
||||||
|
h.signer.Mode(), clientID, err)
|
||||||
|
} 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.
|
||||||
} else {
|
} else {
|
||||||
msg := startTime + "|" + clientID
|
|
||||||
sig := protocol.SignMessage(h.signPwd, []byte(msg))
|
|
||||||
// Signature[64] lives at offset 508 of the struct, +1 for the cmd byte.
|
|
||||||
const sigOffset = 1 + protocol.MasterSettingsOffSignature
|
const sigOffset = 1 + protocol.MasterSettingsOffSignature
|
||||||
copy(buf[sigOffset:sigOffset+protocol.MasterSettingsSignatureLen], []byte(sig))
|
copy(buf[sigOffset:sigOffset+protocol.MasterSettingsSignatureLen], []byte(sig))
|
||||||
}
|
}
|
||||||
@@ -676,14 +685,37 @@ func main() {
|
|||||||
// the HTTP server reads from it.
|
// the HTTP server reads from it.
|
||||||
deviceHub := hub.New()
|
deviceHub := hub.New()
|
||||||
|
|
||||||
// HMAC key used to sign the per-login CMD_MASTERSETTING reply. The
|
// Build the CMD_MASTERSETTING signer based on env vars:
|
||||||
// client verifies this signature before enabling its screen / file
|
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
|
||||||
// features and aborts the process on mismatch. Kept in an env var so
|
// HMAC master key lives here)
|
||||||
// the literal stays out of the binary; provision out-of-band and
|
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
|
||||||
// never commit it.
|
// (customer deployment; never sees the master key, fetches signatures
|
||||||
signPwd := os.Getenv("YAMA_SIGN_PASSWORD")
|
// from operator's License Server with 24h cache)
|
||||||
if signPwd == "" {
|
// - neither → NoOpSigner (free tier; client refuses screen/file ops
|
||||||
log.Warn("YAMA_SIGN_PASSWORD not set; clients will refuse screen/file ops")
|
// but device list still works)
|
||||||
|
signer, mode, err := licensing.NewFromEnv(log)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to initialize signer: %v", err)
|
||||||
|
}
|
||||||
|
// signer.Close() is called explicitly after the License Server HTTP is
|
||||||
|
// drained at shutdown — sequencing matters because an in-flight
|
||||||
|
// handleSign on the HTTP path needs a live signer to complete.
|
||||||
|
switch mode {
|
||||||
|
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))
|
||||||
|
case licensing.ModeNoOp:
|
||||||
|
log.Warn("Signer mode: NOOP (no licensing configured; screen/file features disabled on clients)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the operator also wants this LocalSigner deployment to serve as
|
||||||
|
// License Server for RemoteSigner customers, YAMA_LICENSE_PUBLIC_KEY +
|
||||||
|
// YAMA_LICENSE_HTTP_ADDR enable it. Off by default.
|
||||||
|
licSrv, licAddr, err := licensing.LicenseServerFromEnv(signer, log)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to initialize License Server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web user authenticator. Bootstrap admin from env var YAMA_WEB_ADMIN_PASS;
|
// Web user authenticator. Bootstrap admin from env var YAMA_WEB_ADMIN_PASS;
|
||||||
@@ -730,7 +762,7 @@ func main() {
|
|||||||
auth: authenticator,
|
auth: authenticator,
|
||||||
srv: srv,
|
srv: srv,
|
||||||
hub: deviceHub,
|
hub: deviceHub,
|
||||||
signPwd: signPwd,
|
signer: signer,
|
||||||
}
|
}
|
||||||
srv.SetHandler(handler)
|
srv.SetHandler(handler)
|
||||||
|
|
||||||
@@ -785,10 +817,42 @@ func main() {
|
|||||||
log.Fatal("Failed to start HTTP server: %v", err)
|
log.Fatal("Failed to start HTTP server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optionally serve the License Server HTTP endpoints in this process.
|
||||||
|
// Only active when the operator set YAMA_LICENSE_PUBLIC_KEY +
|
||||||
|
// YAMA_LICENSE_HTTP_ADDR. We serve plain HTTP — operator should put
|
||||||
|
// nginx/Caddy in front for TLS. (RemoteSigner customers connect to
|
||||||
|
// the public URL configured via YAMA_LICENSE_SERVER on their side.)
|
||||||
|
//
|
||||||
|
// Timeouts cap Slowloris / FD-exhaustion attacks. Values are generous
|
||||||
|
// enough for a slow public link (TLS-terminating proxy in front, real
|
||||||
|
// customer round-trips at ~hundreds of ms) but tight enough that a
|
||||||
|
// trickle-byte attacker cannot pin a connection indefinitely.
|
||||||
|
var licenseHTTP *http.Server
|
||||||
|
if licSrv != nil {
|
||||||
|
licenseHTTP = &http.Server{
|
||||||
|
Addr: licAddr,
|
||||||
|
Handler: licSrv.Handler(),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
MaxHeaderBytes: 16 << 10,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
log.Info("License Server listening on %s (POST /license/sign, /license/heartbeat)", licAddr)
|
||||||
|
if err := licenseHTTP.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Error("License Server stopped: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("Server started on port(s): %v\n", ports)
|
fmt.Printf("Server started on port(s): %v\n", ports)
|
||||||
if *httpPort != 0 {
|
if *httpPort != 0 {
|
||||||
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort)
|
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort)
|
||||||
}
|
}
|
||||||
|
if licenseHTTP != nil {
|
||||||
|
fmt.Printf("License Server on http://%s/license/{sign,heartbeat}\n", licAddr)
|
||||||
|
}
|
||||||
fmt.Println("Logs are written to: logs/server.log")
|
fmt.Println("Logs are written to: logs/server.log")
|
||||||
fmt.Println("Press Ctrl+C to stop...")
|
fmt.Println("Press Ctrl+C to stop...")
|
||||||
|
|
||||||
@@ -798,6 +862,17 @@ func main() {
|
|||||||
<-sigChan
|
<-sigChan
|
||||||
|
|
||||||
fmt.Println("\nShutting down...")
|
fmt.Println("\nShutting down...")
|
||||||
|
// Order matters: drain License Server HTTP first so no handleSign is
|
||||||
|
// mid-flight; THEN close the signer (which may release HTTP keepalives
|
||||||
|
// in RemoteSigner mode, or be a no-op for LocalSigner/NoOp).
|
||||||
|
if licenseHTTP != nil {
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
_ = licenseHTTP.Shutdown(shutdownCtx)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if err := signer.Close(); err != nil {
|
||||||
|
log.Warn("signer Close: %v", err)
|
||||||
|
}
|
||||||
httpSrv.Stop()
|
httpSrv.Stop()
|
||||||
for _, srv := range servers {
|
for _, srv := range servers {
|
||||||
srv.Stop()
|
srv.Stop()
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
module github.com/yuanyuanxiang/SimpleRemoter/server/go
|
module github.com/yuanyuanxiang/SimpleRemoter/server/go
|
||||||
|
|
||||||
go 1.24.5
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/klauspost/compress v1.18.2
|
github.com/klauspost/compress v1.18.2
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
@@ -13,5 +14,6 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.12.0 // indirect
|
golang.org/x/sys v0.12.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
@@ -13,6 +15,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
|||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||||
|
|||||||
154
server/go/licensing/factory.go
Normal file
154
server/go/licensing/factory.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Env var names — keep these in one place so README and code stay in sync.
|
||||||
|
const (
|
||||||
|
EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key
|
||||||
|
EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL
|
||||||
|
EnvLicenseToken = "YAMA_LICENSE_TOKEN" // RemoteSigner: customer JWT
|
||||||
|
EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path
|
||||||
|
EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443"
|
||||||
|
EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24)
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultOfflineGrace mirrors the "24 hours" decision recorded in the
|
||||||
|
// project memory's licensing design.
|
||||||
|
const DefaultOfflineGrace = 24 * time.Hour
|
||||||
|
|
||||||
|
// Mode is what NewFromEnv inspected to pick a Signer; useful for logging.
|
||||||
|
type Mode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeLocal Mode = iota
|
||||||
|
ModeRemote
|
||||||
|
ModeNoOp
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Mode) String() string {
|
||||||
|
switch m {
|
||||||
|
case ModeLocal:
|
||||||
|
return "local"
|
||||||
|
case ModeRemote:
|
||||||
|
return "remote"
|
||||||
|
default:
|
||||||
|
return "noop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedMode reports which mode NewFromEnv picked based on env vars,
|
||||||
|
// without actually constructing a signer. Useful for startup banners.
|
||||||
|
func SelectedMode() Mode {
|
||||||
|
if os.Getenv(EnvSignPassword) != "" {
|
||||||
|
return ModeLocal
|
||||||
|
}
|
||||||
|
if os.Getenv(EnvLicenseServer) != "" && os.Getenv(EnvLicenseToken) != "" {
|
||||||
|
return ModeRemote
|
||||||
|
}
|
||||||
|
return ModeNoOp
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFromEnv builds the Signer chosen by env vars:
|
||||||
|
// - YAMA_SIGN_PASSWORD set → LocalSigner
|
||||||
|
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
|
||||||
|
// - neither → NoOpSigner
|
||||||
|
//
|
||||||
|
// If both LocalSigner and RemoteSigner vars are set, LocalSigner wins
|
||||||
|
// (an operator's own server should never accidentally call out to itself).
|
||||||
|
// In that case we log a loud warning so the operator notices the dead config.
|
||||||
|
//
|
||||||
|
// <lg> may be nil — RemoteSigner falls back to a silent logger internally.
|
||||||
|
func NewFromEnv(lg Logger) (Signer, Mode, error) {
|
||||||
|
server := os.Getenv(EnvLicenseServer)
|
||||||
|
token := os.Getenv(EnvLicenseToken)
|
||||||
|
pwd := os.Getenv(EnvSignPassword)
|
||||||
|
|
||||||
|
if pwd != "" {
|
||||||
|
if (server != "" || token != "") && lg != nil {
|
||||||
|
lg.Warn("%s is set; ignoring %s/%s (LocalSigner wins) — "+
|
||||||
|
"if you meant to be a customer deployment, unset %s",
|
||||||
|
EnvSignPassword, EnvLicenseServer, EnvLicenseToken, EnvSignPassword)
|
||||||
|
}
|
||||||
|
s, err := NewLocal(pwd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ModeNoOp, err
|
||||||
|
}
|
||||||
|
return s, ModeLocal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if server != "" && token != "" {
|
||||||
|
if err := ValidateRemoteURL(server); err != nil {
|
||||||
|
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
|
||||||
|
}
|
||||||
|
grace := DefaultOfflineGrace
|
||||||
|
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(hrs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, ModeNoOp, fmt.Errorf(
|
||||||
|
"%s must be an integer (hours), got %q", EnvLicenseOfflineHrs, hrs)
|
||||||
|
}
|
||||||
|
if n < 0 {
|
||||||
|
return nil, ModeNoOp, fmt.Errorf(
|
||||||
|
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
|
||||||
|
}
|
||||||
|
grace = time.Duration(n) * time.Hour
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseServerFromEnv builds the License Server HTTP handler if (and only
|
||||||
|
// if) the operator wants to expose it: requires both YAMA_LICENSE_PUBLIC_KEY
|
||||||
|
// (to verify customer JWTs) and YAMA_LICENSE_HTTP_ADDR (listen address) set,
|
||||||
|
// AND the Signer must be a *LocalSigner (RemoteSigner customer deployments
|
||||||
|
// must never serve a License Server — they don't have the master key).
|
||||||
|
//
|
||||||
|
// Returns (nil, "", nil) if not configured. Returns (server, addr, nil) when
|
||||||
|
// configured. Returns error on bad / partial config.
|
||||||
|
func LicenseServerFromEnv(signer Signer, lg Logger) (*LicenseServer, string, error) {
|
||||||
|
pubPath := os.Getenv(EnvLicensePubKeyPath)
|
||||||
|
addr := os.Getenv(EnvLicenseHTTPAddr)
|
||||||
|
|
||||||
|
if pubPath == "" && addr == "" {
|
||||||
|
return nil, "", nil // not configured — fine
|
||||||
|
}
|
||||||
|
if pubPath == "" || addr == "" {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"%s and %s must be set together to enable License Server (got %s=%q %s=%q)",
|
||||||
|
EnvLicensePubKeyPath, EnvLicenseHTTPAddr,
|
||||||
|
EnvLicensePubKeyPath, pubPath, EnvLicenseHTTPAddr, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
local, ok := signer.(*LocalSigner)
|
||||||
|
if !ok {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"License Server requires LocalSigner mode (set %s); current mode is %s",
|
||||||
|
EnvSignPassword, signer.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, err := LoadRSAPublicKey(pubPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5-minute eviction window — twice a typical heartbeat interval. Matches
|
||||||
|
// the discussion in quota.go.
|
||||||
|
return NewLicenseServer(local, pubKey, 5*time.Minute, lg), addr, nil
|
||||||
|
}
|
||||||
520
server/go/licensing/licensing_test.go
Normal file
520
server/go/licensing/licensing_test.go
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testKey generates an ephemeral 2048-bit RSA key for JWT signing in tests.
|
||||||
|
// 2048 keeps the test fast (~50ms) but matches realistic security.
|
||||||
|
func testKey(t *testing.T) *rsa.PrivateKey {
|
||||||
|
t.Helper()
|
||||||
|
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// silentLogger swallows logs in tests but still satisfies the Logger
|
||||||
|
// interface — keeps test output uncluttered.
|
||||||
|
type silentLogger struct{}
|
||||||
|
|
||||||
|
func (silentLogger) Info(string, ...any) {}
|
||||||
|
func (silentLogger) Warn(string, ...any) {}
|
||||||
|
func (silentLogger) Error(string, ...any) {}
|
||||||
|
|
||||||
|
// mustLocal wraps NewLocal for tests so the per-test boilerplate stays
|
||||||
|
// readable. Any test-only HMAC key must be >= 16 chars (see minMasterKeyLen).
|
||||||
|
func mustLocal(t *testing.T, key string) *LocalSigner {
|
||||||
|
t.Helper()
|
||||||
|
s, err := NewLocal(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocal: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIssueVerifyRoundTrip is the smoke test: a token minted by Issue
|
||||||
|
// must verify under VerifyJWT with the matching public key, and the
|
||||||
|
// claims must round-trip intact.
|
||||||
|
func TestIssueVerifyRoundTrip(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
tok, err := Issue(priv, "customer-abc", TierTrial, 0, 30*24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := VerifyJWT(tok, &priv.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT: %v", err)
|
||||||
|
}
|
||||||
|
if claims.Subject != "customer-abc" {
|
||||||
|
t.Errorf("sub = %q, want customer-abc", claims.Subject)
|
||||||
|
}
|
||||||
|
if claims.Tier != TierTrial {
|
||||||
|
t.Errorf("tier = %q, want %q", claims.Tier, TierTrial)
|
||||||
|
}
|
||||||
|
if claims.MaxDevices != TrialMaxDevices {
|
||||||
|
// Trial JWT minted with MaxDevices=0 should default to TrialMaxDevices
|
||||||
|
// (VerifyJWT normalizes this — see token.go).
|
||||||
|
t.Errorf("max_devices = %d, want %d (trial default)", claims.MaxDevices, TrialMaxDevices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyRejectsWrongKey makes sure a token signed by key A cannot
|
||||||
|
// be verified with key B's public half. This is what would fail open
|
||||||
|
// if "alg":"none" tampering wasn't blocked, or if someone reused keys.
|
||||||
|
func TestVerifyRejectsWrongKey(t *testing.T) {
|
||||||
|
priv1 := testKey(t)
|
||||||
|
priv2 := testKey(t)
|
||||||
|
|
||||||
|
tok, err := Issue(priv1, "customer-x", TierPaid, 50, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := VerifyJWT(tok, &priv2.PublicKey); err == nil {
|
||||||
|
t.Fatal("VerifyJWT accepted token signed with a different key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaidRequiresMaxDevices: paid tier must carry an explicit cap; trial
|
||||||
|
// gets a default. Catches misconfigured tokens at verify time.
|
||||||
|
func TestPaidRequiresMaxDevices(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
_, err := Issue(priv, "customer-y", TierPaid, 0, time.Hour)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Issue accepted paid tier with max_devices=0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoOpSignerReturnsEmpty: free tier produces no signature so the
|
||||||
|
// client's private library trips and refuses high-tier features.
|
||||||
|
func TestNoOpSignerReturnsEmpty(t *testing.T) {
|
||||||
|
s := NewNoOp()
|
||||||
|
sig, err := s.Sign("2026-05-20", "12345")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
if sig != "" {
|
||||||
|
t.Errorf("NoOpSigner.Sign = %q, want empty", sig)
|
||||||
|
}
|
||||||
|
if s.Mode() != "noop" {
|
||||||
|
t.Errorf("Mode = %q, want noop", s.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalSignerDeterministic: HMAC is deterministic — same key + same
|
||||||
|
// input must always yield the same output. This is the property that
|
||||||
|
// makes RemoteSigner's cache correct.
|
||||||
|
func TestLocalSignerDeterministic(t *testing.T) {
|
||||||
|
s, err := NewLocal("shared-secret-xyz-long-enough")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocal: %v", err)
|
||||||
|
}
|
||||||
|
a, _ := s.Sign("2026-05-20T10:00:00Z", "12345")
|
||||||
|
b, _ := s.Sign("2026-05-20T10:00:00Z", "12345")
|
||||||
|
if a != b {
|
||||||
|
t.Errorf("non-deterministic: %q vs %q", a, b)
|
||||||
|
}
|
||||||
|
if len(a) != 64 {
|
||||||
|
t.Errorf("signature length = %d, want 64 (hex of HMAC-SHA256)", len(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoteSignerCacheHit verifies that the second call for the same
|
||||||
|
// (startTime, clientID) tuple doesn't hit the network. We assert this
|
||||||
|
// by counting requests at the fake License Server.
|
||||||
|
func TestRemoteSignerCacheHit(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "real-hmac-key-for-test-xx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
tok, err := Issue(priv, "cust-cache", TierPaid, 10, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count requests by wrapping the LS handler.
|
||||||
|
var calls atomic.Int64
|
||||||
|
counting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
ls.Handler().ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
defer counting.Close()
|
||||||
|
|
||||||
|
rs := NewRemote(counting.URL, tok, time.Hour, silentLogger{})
|
||||||
|
defer rs.Close()
|
||||||
|
|
||||||
|
sig1, err := rs.Sign("st-1", "client-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Sign: %v", err)
|
||||||
|
}
|
||||||
|
sig2, err := rs.Sign("st-1", "client-1") // identical → cache hit
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Sign: %v", err)
|
||||||
|
}
|
||||||
|
if sig1 != sig2 {
|
||||||
|
t.Errorf("signatures differ across cache: %q vs %q", sig1, sig2)
|
||||||
|
}
|
||||||
|
if got := calls.Load(); got != 1 {
|
||||||
|
t.Errorf("expected exactly 1 HTTP call (second served from cache), got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoteSignerStaleFallback: when the License Server goes down, an
|
||||||
|
// expired-cache entry is still better than zero signature (avoids breaking
|
||||||
|
// reconnects for existing devices during a transient outage).
|
||||||
|
func TestRemoteSignerStaleFallback(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "master-fallback-test-xxx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
|
||||||
|
tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace = 1 ns so the next call considers the cache stale.
|
||||||
|
rs := NewRemote(ts.URL, tok, time.Nanosecond, silentLogger{})
|
||||||
|
defer rs.Close()
|
||||||
|
|
||||||
|
first, err := rs.Sign("st-fb", "client-fb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take the server offline.
|
||||||
|
ts.Close()
|
||||||
|
|
||||||
|
second, err := rs.Sign("st-fb", "client-fb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post-outage Sign should fall back to stale cache, got err=%v", err)
|
||||||
|
}
|
||||||
|
if second != first {
|
||||||
|
t.Errorf("stale fallback returned %q, want cached %q", second, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaEnforcement: trial tier with 2 devices accepts the first two
|
||||||
|
// but rejects the third with 403.
|
||||||
|
func TestQuotaEnforcement(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "master-quota-test-xxxxxx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Trial JWT capped at 2 devices.
|
||||||
|
tok, err := Issue(priv, "cust-quota", TierTrial, 2, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
call := func(clientID string) (int, string) {
|
||||||
|
body, _ := json.Marshal(signRequest{ClientID: clientID, StartTime: "st"})
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %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
|
||||||
|
}
|
||||||
|
|
||||||
|
if code, _ := call("dev-1"); code != http.StatusOK {
|
||||||
|
t.Errorf("dev-1 expected 200, got %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := call("dev-2"); code != http.StatusOK {
|
||||||
|
t.Errorf("dev-2 expected 200, got %d", code)
|
||||||
|
}
|
||||||
|
if code, msg := call("dev-3"); code != http.StatusForbidden {
|
||||||
|
t.Errorf("dev-3 expected 403, got %d (%q)", code, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "master-auth-test-xxxxxxx")
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Post: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoteSignerHardFailNoCacheReturnsError: when the LS is unreachable
|
||||||
|
// AND we have no cached signature for this (startTime, clientID), Sign must
|
||||||
|
// return ("", err) so the caller (sendMasterSetting) ships zeroed bytes.
|
||||||
|
// Previously untested — easy to silently regress.
|
||||||
|
func TestRemoteSignerHardFailNoCacheReturnsError(t *testing.T) {
|
||||||
|
// Point RemoteSigner at a URL that will refuse all connections.
|
||||||
|
// Using an unreachable host avoids depending on a free local port.
|
||||||
|
rs := NewRemote("https://127.0.0.1:1", "any-token", time.Hour, silentLogger{})
|
||||||
|
defer rs.Close()
|
||||||
|
|
||||||
|
sig, err := rs.Sign("st-x", "client-x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when LS unreachable and cache empty")
|
||||||
|
}
|
||||||
|
if sig != "" {
|
||||||
|
t.Errorf("expected empty signature on hard failure, got %q", sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeartbeatRefreshOnly: malicious customer POSTs fake clientIDs to
|
||||||
|
// /license/heartbeat. The fake IDs MUST NOT show up in the server's view —
|
||||||
|
// only IDs already minted via /sign get refreshed. This is the anti-tamper
|
||||||
|
// property that makes the quota system actually enforce.
|
||||||
|
func TestHeartbeatRefreshOnly(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "master-hb-test-xxxxxxxxxx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
tok, err := Issue(priv, "cust-hb", TierPaid, 5, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve one legit device via /sign first.
|
||||||
|
signBody, _ := json.Marshal(signRequest{ClientID: "legit-1", StartTime: "st"})
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(signBody)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if resp, err := http.DefaultClient.Do(req); err != nil {
|
||||||
|
t.Fatalf("Do sign: %v", err)
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now heartbeat reports 1 legit ID + 99 fake IDs.
|
||||||
|
fakes := make([]string, 99)
|
||||||
|
for i := range fakes {
|
||||||
|
fakes[i] = fmt.Sprintf("fake-%d", i)
|
||||||
|
}
|
||||||
|
all := append([]string{"legit-1"}, fakes...)
|
||||||
|
hbBody, _ := json.Marshal(heartbeatRequest{
|
||||||
|
ActiveDeviceCount: len(all),
|
||||||
|
ActiveDeviceIDs: all,
|
||||||
|
})
|
||||||
|
req, _ = http.NewRequest("POST", ts.URL+"/license/heartbeat", strings.NewReader(string(hbBody)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do heartbeat: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("heartbeat returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var hbResp heartbeatResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&hbResp)
|
||||||
|
|
||||||
|
// Server should report exactly 1 active device (the one minted via /sign),
|
||||||
|
// NOT 1 + 99. drift = 99.
|
||||||
|
if hbResp.ServerViewCount != 1 {
|
||||||
|
t.Errorf("server view = %d, want 1 (heartbeat must not insert fake IDs)", hbResp.ServerViewCount)
|
||||||
|
}
|
||||||
|
if hbResp.Drift != 99 {
|
||||||
|
t.Errorf("drift = %d, want 99", hbResp.Drift)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the quota cap is still enforced: a fresh /sign for dev-2..dev-5
|
||||||
|
// should succeed (only 1 slot used), dev-6 should fail.
|
||||||
|
tryReserve := func(cid string) int {
|
||||||
|
body, _ := json.Marshal(signRequest{ClientID: cid, StartTime: "st"})
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
for i := 2; i <= 5; i++ {
|
||||||
|
if code := tryReserve(fmt.Sprintf("legit-%d", i)); code != http.StatusOK {
|
||||||
|
t.Errorf("legit-%d expected 200, got %d", i, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code := tryReserve("legit-6"); code != http.StatusForbidden {
|
||||||
|
t.Errorf("legit-6 expected 403 (max=5), got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaRejectionDoesNotConsumeSlot: a rejected /sign must not leave
|
||||||
|
// its clientID in the quota map. Otherwise a denied 3rd device would
|
||||||
|
// permanently take a slot from the legitimate 1st/2nd.
|
||||||
|
func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "master-no-leak-xxxxxxxxxxxx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
tok, err := Issue(priv, "cust-leak", TierTrial, 2, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
doSign := func(cid string) int {
|
||||||
|
body, _ := json.Marshal(signRequest{ClientID: cid, StartTime: "st"})
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill to cap.
|
||||||
|
if c := doSign("a"); c != http.StatusOK {
|
||||||
|
t.Fatalf("a expected 200, got %d", c)
|
||||||
|
}
|
||||||
|
if c := doSign("b"); c != http.StatusOK {
|
||||||
|
t.Fatalf("b expected 200, got %d", c)
|
||||||
|
}
|
||||||
|
// Over cap — must be denied AND must NOT consume a slot.
|
||||||
|
if c := doSign("c"); c != http.StatusForbidden {
|
||||||
|
t.Fatalf("c expected 403, got %d", c)
|
||||||
|
}
|
||||||
|
if c := doSign("d"); c != http.StatusForbidden {
|
||||||
|
t.Fatalf("d expected 403, got %d", c)
|
||||||
|
}
|
||||||
|
// Existing device 'a' re-signs — must still succeed (idempotent refresh).
|
||||||
|
if c := doSign("a"); c != http.StatusOK {
|
||||||
|
t.Fatalf("a re-sign expected 200, got %d", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaTrackerEviction: after evictAfter elapses, a previously-occupied
|
||||||
|
// slot must be reclaimed so a new device can take it. Exercises the time
|
||||||
|
// path that TestQuotaEnforcement skips (it uses a long eviction window).
|
||||||
|
func TestQuotaTrackerEviction(t *testing.T) {
|
||||||
|
q := newQuotaTracker(50 * time.Millisecond)
|
||||||
|
|
||||||
|
if count, ok := q.Reserve("cust", "dev-1", 1); !ok || count != 1 {
|
||||||
|
t.Fatalf("first Reserve: count=%d ok=%v", count, ok)
|
||||||
|
}
|
||||||
|
if count, ok := q.Reserve("cust", "dev-2", 1); ok {
|
||||||
|
t.Fatalf("expected over-cap rejection, got count=%d ok=%v", count, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
|
||||||
|
// dev-1's entry should now be stale; dev-2 should be admitted.
|
||||||
|
if count, ok := q.Reserve("cust", "dev-2", 1); !ok || count != 1 {
|
||||||
|
t.Fatalf("post-eviction Reserve: count=%d ok=%v", count, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateRemoteURL: factory must reject http:// for non-loopback
|
||||||
|
// targets so JWT/sigs don't leak in cleartext.
|
||||||
|
func TestValidateRemoteURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
url string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{"https://license.example.com", false},
|
||||||
|
{"https://localhost:8443", false},
|
||||||
|
{"http://localhost:8443", false}, // loopback exception
|
||||||
|
{"http://127.0.0.1:8443", false}, // loopback exception
|
||||||
|
{"http://license.example.com", true}, // public http → reject
|
||||||
|
{"ftp://license.example.com", true}, // bad scheme
|
||||||
|
{"not a url at all", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
err := ValidateRemoteURL(c.url)
|
||||||
|
if (err != nil) != c.wantError {
|
||||||
|
t.Errorf("ValidateRemoteURL(%q): err=%v, wantError=%v", c.url, err, c.wantError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIssueRejectsShortTTL: catches fat-finger ttl=0 / negative that mints
|
||||||
|
// an already-expired token.
|
||||||
|
func TestIssueRejectsShortTTL(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
if _, err := Issue(priv, "cust", TierPaid, 10, 0); err == nil {
|
||||||
|
t.Error("expected error for ttl=0")
|
||||||
|
}
|
||||||
|
if _, err := Issue(priv, "cust", TierPaid, 10, time.Minute); err == nil {
|
||||||
|
t.Error("expected error for ttl below minimum")
|
||||||
|
}
|
||||||
|
if _, err := Issue(priv, "", TierPaid, 10, time.Hour); err == nil {
|
||||||
|
t.Error("expected error for empty sub")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewLocalRejectsShortKey: catches misconfigured YAMA_SIGN_PASSWORD
|
||||||
|
// (empty / typo) at construction instead of silently signing with junk.
|
||||||
|
func TestNewLocalRejectsShortKey(t *testing.T) {
|
||||||
|
if _, err := NewLocal(""); err == nil {
|
||||||
|
t.Error("expected error for empty master key")
|
||||||
|
}
|
||||||
|
if _, err := NewLocal("short"); err == nil {
|
||||||
|
t.Error("expected error for too-short master key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJWTAlgLockedToRS256: a token with any non-RS256 alg (here RS384) must
|
||||||
|
// fail verification, even though the underlying RSA primitive is the same.
|
||||||
|
// This pins the docs↔code contract.
|
||||||
|
func TestJWTAlgLockedToRS256(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
now := time.Now()
|
||||||
|
claims := &LicenseClaims{
|
||||||
|
Tier: TierTrial,
|
||||||
|
MaxDevices: 10,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: "cust",
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS384, claims)
|
||||||
|
signed, err := tok.SignedString(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := VerifyJWT(signed, &priv.PublicKey); err == nil {
|
||||||
|
t.Error("VerifyJWT accepted RS384; alg should be locked to RS256")
|
||||||
|
}
|
||||||
|
}
|
||||||
45
server/go/licensing/local.go
Normal file
45
server/go/licensing/local.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LocalSigner signs directly with the deployment's HMAC master key. The
|
||||||
|
// operator's own Go server runs in this mode; it also serves as License
|
||||||
|
// Server for any RemoteSigner customer deployments (LicenseServer in
|
||||||
|
// server.go reuses the same Signer instance via its public Sign() method).
|
||||||
|
//
|
||||||
|
// Sign is HMAC-SHA256 in microseconds — no I/O, no caching needed.
|
||||||
|
type LocalSigner struct {
|
||||||
|
masterKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// minMasterKeyLen rejects obviously-broken HMAC keys (empty string, "x").
|
||||||
|
// Real keys are typically `openssl rand -hex 32` (64 chars); 16 bytes is
|
||||||
|
// the floor we'll accept to catch fat-finger configs without being too
|
||||||
|
// strict for tests.
|
||||||
|
const minMasterKeyLen = 16
|
||||||
|
|
||||||
|
// NewLocal returns a LocalSigner. An empty or too-short masterKey is a
|
||||||
|
// configuration bug — silently accepting it would produce stable but
|
||||||
|
// worthless HMAC output, so we reject at construction.
|
||||||
|
func NewLocal(masterKey string) (*LocalSigner, error) {
|
||||||
|
if masterKey == "" {
|
||||||
|
return nil, errors.New("LocalSigner: master HMAC key is empty (set YAMA_SIGN_PASSWORD)")
|
||||||
|
}
|
||||||
|
if len(masterKey) < minMasterKeyLen {
|
||||||
|
return nil, errors.New("LocalSigner: master HMAC key too short (need >= 16 chars)")
|
||||||
|
}
|
||||||
|
return &LocalSigner{masterKey: masterKey}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalSigner) Sign(startTime, clientID string) (string, error) {
|
||||||
|
msg := startTime + "|" + clientID
|
||||||
|
return protocol.SignMessage(l.masterKey, []byte(msg)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalSigner) Mode() string { return "local" }
|
||||||
|
|
||||||
|
func (l *LocalSigner) Close() error { return nil }
|
||||||
18
server/go/licensing/noop.go
Normal file
18
server/go/licensing/noop.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
// NoOpSigner returns an empty signature. Used when neither YAMA_SIGN_PASSWORD
|
||||||
|
// nor YAMA_LICENSE_SERVER is set — operator hasn't configured licensing at
|
||||||
|
// all (free-tier mode). Device list keeps working; the client's private
|
||||||
|
// library refuses to start screen/file features when it sees a zeroed
|
||||||
|
// Signature[64] field in CMD_MASTERSETTING.
|
||||||
|
type NoOpSigner struct{}
|
||||||
|
|
||||||
|
func NewNoOp() *NoOpSigner { return &NoOpSigner{} }
|
||||||
|
|
||||||
|
func (n *NoOpSigner) Sign(startTime, clientID string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NoOpSigner) Mode() string { return "noop" }
|
||||||
|
|
||||||
|
func (n *NoOpSigner) Close() error { return nil }
|
||||||
159
server/go/licensing/quota.go
Normal file
159
server/go/licensing/quota.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tier names. The free tier is hard-coded into the License Server (no JWT
|
||||||
|
// needed); trial / paid are encoded in the issued JWT's "tier" claim along
|
||||||
|
// with "max_devices".
|
||||||
|
const (
|
||||||
|
TierFree = "free"
|
||||||
|
TierTrial = "trial"
|
||||||
|
TierPaid = "paid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FreeMaxDevices is the per-deployment device cap for unauthenticated
|
||||||
|
// (no JWT) callers. Trial defaults to 20 if the JWT omits max_devices;
|
||||||
|
// paid customers MUST have an explicit max_devices in their JWT.
|
||||||
|
const (
|
||||||
|
FreeMaxDevices = 2
|
||||||
|
TrialMaxDevices = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// quotaTracker maintains the active-device set per customer. Customers are
|
||||||
|
// identified by the JWT "sub" claim. The set is keyed by clientID (uint64
|
||||||
|
// from the device, stringified) — same device coming back through the
|
||||||
|
// same License Server is one slot, not two.
|
||||||
|
//
|
||||||
|
// Eviction: any clientID not seen in /sign or /license/heartbeat within
|
||||||
|
// the eviction window is silently dropped from the active set. This stops
|
||||||
|
// a never-heartbeating customer from holding slots forever. Default
|
||||||
|
// window is twice the heartbeat interval the customer reports at (5 min).
|
||||||
|
//
|
||||||
|
// Empty customer entries are reaped at the end of each mutation so the
|
||||||
|
// outer map doesn't accumulate sub claims of expired contracts.
|
||||||
|
type quotaTracker struct {
|
||||||
|
evictAfter time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
customer map[string]*customerState // sub claim → state
|
||||||
|
}
|
||||||
|
|
||||||
|
type customerState struct {
|
||||||
|
devices map[string]time.Time // clientID → last activity
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQuotaTracker(evictAfter time.Duration) *quotaTracker {
|
||||||
|
return "aTracker{
|
||||||
|
evictAfter: evictAfter,
|
||||||
|
customer: make(map[string]*customerState),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictLocked drops stale entries from st.devices. Caller must hold q.mu.
|
||||||
|
func (q *quotaTracker) evictLocked(st *customerState) {
|
||||||
|
cutoff := time.Now().Add(-q.evictAfter)
|
||||||
|
for cid, last := range st.devices {
|
||||||
|
if last.Before(cutoff) {
|
||||||
|
delete(st.devices, cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reapEmptyLocked deletes sub entries whose device sets are empty. This
|
||||||
|
// prevents long-running License Servers from leaking memory across
|
||||||
|
// churned trial customers. Caller must hold q.mu.
|
||||||
|
func (q *quotaTracker) reapEmptyLocked(sub string) {
|
||||||
|
if st, ok := q.customer[sub]; ok && len(st.devices) == 0 {
|
||||||
|
delete(q.customer, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve atomically checks whether <clientID> can be admitted under <sub>
|
||||||
|
// without exceeding <maxDevices>, and if so records it as active. Returns
|
||||||
|
// (active_count_after, accepted).
|
||||||
|
//
|
||||||
|
// "Accepted" means the slot is now reserved. "Rejected" leaves the device
|
||||||
|
// map untouched — a denied request must NOT permanently consume a slot
|
||||||
|
// just because the caller asked.
|
||||||
|
//
|
||||||
|
// If <clientID> is already in the set, Reserve refreshes its activity
|
||||||
|
// timestamp and returns accepted=true regardless of maxDevices (same device
|
||||||
|
// re-signing is never a quota violation — caps only apply to ADDING).
|
||||||
|
func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
st, ok := q.customer[sub]
|
||||||
|
if !ok {
|
||||||
|
st = &customerState{devices: make(map[string]time.Time)}
|
||||||
|
q.customer[sub] = st
|
||||||
|
}
|
||||||
|
|
||||||
|
q.evictLocked(st)
|
||||||
|
|
||||||
|
if _, already := st.devices[clientID]; already {
|
||||||
|
st.devices[clientID] = time.Now()
|
||||||
|
return len(st.devices), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(st.devices)+1 > maxDevices {
|
||||||
|
// Don't reap on rejection — the customer might be at exactly cap
|
||||||
|
// with valid devices, and an empty map would lose info.
|
||||||
|
return len(st.devices), false
|
||||||
|
}
|
||||||
|
|
||||||
|
st.devices[clientID] = time.Now()
|
||||||
|
return len(st.devices), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshExisting bumps the last-activity timestamp for any clientID in
|
||||||
|
// <clientIDs> that is ALREADY in <sub>'s set. New IDs are silently ignored
|
||||||
|
// — this is the key anti-tamper property of /license/heartbeat: a
|
||||||
|
// malicious customer cannot inflate their quota by reporting fake IDs.
|
||||||
|
//
|
||||||
|
// Returns the count of IDs that were actually refreshed (i.e. that were
|
||||||
|
// known to us from a prior Reserve).
|
||||||
|
func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
st, ok := q.customer[sub]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
q.evictLocked(st)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
refreshed := 0
|
||||||
|
for _, cid := range clientIDs {
|
||||||
|
if _, exists := st.devices[cid]; exists {
|
||||||
|
st.devices[cid] = now
|
||||||
|
refreshed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
q.reapEmptyLocked(sub) // eviction may have emptied us
|
||||||
|
return refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns the current active clientIDs for <sub>. Used by
|
||||||
|
// /license/heartbeat to report the server-side view.
|
||||||
|
func (q *quotaTracker) Snapshot(sub string) []string {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
st, ok := q.customer[sub]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
q.evictLocked(st)
|
||||||
|
out := make([]string, 0, len(st.devices))
|
||||||
|
for cid := range st.devices {
|
||||||
|
out = append(out, cid)
|
||||||
|
}
|
||||||
|
q.reapEmptyLocked(sub)
|
||||||
|
return out
|
||||||
|
}
|
||||||
295
server/go/licensing/remote.go
Normal file
295
server/go/licensing/remote.go
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Cache strategy: every (startTime, clientID) tuple deterministically
|
||||||
|
// produces the same HMAC output. So once we've fetched a signature, we
|
||||||
|
// can serve it from memory for OfflineGrace (default 24h). We do honor the
|
||||||
|
// grace — the operator may want to revoke license / clear cache during
|
||||||
|
// outages — returning a stale signature beyond OfflineGrace defeats the
|
||||||
|
// point of the License Server.
|
||||||
|
//
|
||||||
|
// Thundering herd: on cache miss, concurrent Sign() calls for the same
|
||||||
|
// (startTime, clientID) are coalesced via singleflight so only one HTTPS
|
||||||
|
// round-trip happens. This matters at customer-side restart when many
|
||||||
|
// devices reconnect at the same time.
|
||||||
|
//
|
||||||
|
// Heartbeat: a background ticker POSTs the cached clientID set to
|
||||||
|
// /license/heartbeat every heartbeatInterval. This is what lets the
|
||||||
|
// License Server re-populate its quota view after a restart — without it,
|
||||||
|
// LS's view stays empty (since cache hits never re-fetch /sign).
|
||||||
|
//
|
||||||
|
// On License Server unreachable / non-200: try stale cache; if no cache,
|
||||||
|
// return ("", err). Caller (sendMasterSetting) ships zeroed signature and
|
||||||
|
// the device retries on next reconnect.
|
||||||
|
type RemoteSigner struct {
|
||||||
|
serverURL string
|
||||||
|
token string
|
||||||
|
offlineGrace time.Duration
|
||||||
|
httpClient *http.Client
|
||||||
|
logger Logger
|
||||||
|
sf singleflight.Group
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cache map[string]cachedSig
|
||||||
|
|
||||||
|
hbDone chan struct{}
|
||||||
|
hbWg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedSig struct {
|
||||||
|
sig string
|
||||||
|
fetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// signRequest mirrors the License Server's POST /license/sign body schema.
|
||||||
|
type signRequest struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// signResponse mirrors the License Server's response schema.
|
||||||
|
type signResponse struct {
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// nilLogger drops all log lines; used when callers don't pass a logger so
|
||||||
|
// the RemoteSigner stays safe to use without panic'ing on nil deref.
|
||||||
|
type nilLogger struct{}
|
||||||
|
|
||||||
|
func (nilLogger) Info(string, ...any) {}
|
||||||
|
func (nilLogger) Warn(string, ...any) {}
|
||||||
|
func (nilLogger) Error(string, ...any) {}
|
||||||
|
|
||||||
|
// heartbeatInterval is the period for /license/heartbeat POSTs. 90s is
|
||||||
|
// well below the License Server's 5-minute eviction window (quota.go), so
|
||||||
|
// the customer's devices never get reaped from the LS quota view while
|
||||||
|
// they're still actively heartbeating to it.
|
||||||
|
const heartbeatInterval = 90 * time.Second
|
||||||
|
|
||||||
|
// ValidateRemoteURL returns nil if u is a safe LICENSE_SERVER URL. We
|
||||||
|
// require https:// to keep the JWT and signature off the wire in cleartext;
|
||||||
|
// the only exception is http://localhost / http://127.0.0.1 for testing.
|
||||||
|
func ValidateRemoteURL(raw string) error {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not a URL: %w", err)
|
||||||
|
}
|
||||||
|
switch u.Scheme {
|
||||||
|
case "https":
|
||||||
|
return nil
|
||||||
|
case "http":
|
||||||
|
host := u.Hostname()
|
||||||
|
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("http:// scheme exposes JWT in cleartext; use https:// (got %q)", raw)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported scheme %q (need https://)", u.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRemote(serverURL, token string, offlineGrace time.Duration, lg Logger) *RemoteSigner {
|
||||||
|
if lg == nil {
|
||||||
|
lg = nilLogger{}
|
||||||
|
}
|
||||||
|
r := &RemoteSigner{
|
||||||
|
serverURL: strings.TrimRight(serverURL, "/"),
|
||||||
|
token: token,
|
||||||
|
offlineGrace: offlineGrace,
|
||||||
|
logger: lg,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
cache: make(map[string]cachedSig),
|
||||||
|
hbDone: make(chan struct{}),
|
||||||
|
}
|
||||||
|
r.hbWg.Add(1)
|
||||||
|
go r.heartbeatLoop()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteSigner) Sign(startTime, clientID string) (string, error) {
|
||||||
|
key := startTime + "|" + clientID
|
||||||
|
|
||||||
|
// Fresh cache hit: serve from memory, no network.
|
||||||
|
r.mu.Lock()
|
||||||
|
if c, ok := r.cache[key]; ok && time.Since(c.fetchedAt) < r.offlineGrace {
|
||||||
|
sig := c.sig
|
||||||
|
r.mu.Unlock()
|
||||||
|
return sig, nil
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Coalesce concurrent fetches for the same key — protects the License
|
||||||
|
// Server from herd reconnects after a network blip.
|
||||||
|
v, err, _ := r.sf.Do(key, func() (any, error) {
|
||||||
|
return r.fetch(startTime, clientID)
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
sig := v.(string)
|
||||||
|
r.mu.Lock()
|
||||||
|
r.cache[key] = cachedSig{sig: sig, fetchedAt: time.Now()}
|
||||||
|
r.mu.Unlock()
|
||||||
|
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.
|
||||||
|
r.mu.Lock()
|
||||||
|
c, ok := r.cache[key]
|
||||||
|
r.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
age := time.Since(c.fetchedAt).Round(time.Second)
|
||||||
|
r.logger.Warn("RemoteSigner: License Server unreachable (%v); serving stale cache (age=%s) for clientID=%s",
|
||||||
|
err, age, clientID)
|
||||||
|
return c.sig, nil
|
||||||
|
}
|
||||||
|
r.logger.Error("RemoteSigner: License Server unreachable (%v) and no cache for clientID=%s; client will see zeroed signature",
|
||||||
|
err, clientID)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
|
||||||
|
body, err := json.Marshal(signRequest{ClientID: clientID, StartTime: startTime})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", r.serverURL+"/license/sign", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// 401/403: token rejected — likely revoked or expired.
|
||||||
|
return "", fmt.Errorf("License Server returned %d: %s",
|
||||||
|
resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sr signResponse
|
||||||
|
if err := json.Unmarshal(respBody, &sr); err != nil {
|
||||||
|
return "", fmt.Errorf("malformed License Server response: %w", err)
|
||||||
|
}
|
||||||
|
if sr.Error != "" {
|
||||||
|
return "", errors.New(sr.Error)
|
||||||
|
}
|
||||||
|
if sr.Signature == "" {
|
||||||
|
return "", errors.New("License Server returned empty signature")
|
||||||
|
}
|
||||||
|
return sr.Signature, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeatLoop POSTs the cached clientID set to /license/heartbeat every
|
||||||
|
// heartbeatInterval. Goal: after a License Server restart, the customer's
|
||||||
|
// existing devices get re-counted in the LS quota view without each one
|
||||||
|
// needing to cache-miss /sign first.
|
||||||
|
func (r *RemoteSigner) heartbeatLoop() {
|
||||||
|
defer r.hbWg.Done()
|
||||||
|
ticker := time.NewTicker(heartbeatInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.hbDone:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
r.sendHeartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteSigner) sendHeartbeat() {
|
||||||
|
// Snapshot the cache's currently-fresh clientIDs.
|
||||||
|
r.mu.Lock()
|
||||||
|
cutoff := time.Now().Add(-r.offlineGrace)
|
||||||
|
ids := make([]string, 0, len(r.cache))
|
||||||
|
for key, c := range r.cache {
|
||||||
|
if c.fetchedAt.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// key is "startTime|clientID" — extract clientID for the heartbeat.
|
||||||
|
if _, cid, ok := strings.Cut(key, "|"); ok {
|
||||||
|
ids = append(ids, cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return // nothing to report yet
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(heartbeatRequest{
|
||||||
|
ActiveDeviceCount: len(ids),
|
||||||
|
ActiveDeviceIDs: ids,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST",
|
||||||
|
r.serverURL+"/license/heartbeat", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Transient — don't spam logs every 90s; debug-level if we add one.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
r.logger.Warn("RemoteSigner: heartbeat returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteSigner) Mode() string { return "remote" }
|
||||||
|
|
||||||
|
func (r *RemoteSigner) Close() error {
|
||||||
|
select {
|
||||||
|
case <-r.hbDone:
|
||||||
|
// already closed
|
||||||
|
default:
|
||||||
|
close(r.hbDone)
|
||||||
|
}
|
||||||
|
r.hbWg.Wait()
|
||||||
|
r.httpClient.CloseIdleConnections()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
283
server/go/licensing/server.go
Normal file
283
server/go/licensing/server.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// device logins without ever holding the master key themselves.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
//
|
||||||
|
// POST /license/sign
|
||||||
|
// Body: {"client_id": "...", "start_time": "..."}
|
||||||
|
// Auth: Authorization: Bearer <customer-JWT>
|
||||||
|
// Reply: {"signature": "<64-hex>"} (200)
|
||||||
|
// or {"error": "..."} (4xx/5xx)
|
||||||
|
// Enforces quota: claims.max_devices for the JWT's "sub".
|
||||||
|
//
|
||||||
|
// POST /license/heartbeat
|
||||||
|
// Body: {"active_device_count": N, "active_device_ids": ["...","..."]}
|
||||||
|
// Auth: Authorization: Bearer <customer-JWT>
|
||||||
|
// Reply: {"server_view_count": M, "drift": N-M}
|
||||||
|
// Used by the customer's Go server to surface its view; cross-validated
|
||||||
|
// against what /sign has actually been asked for under that customer.
|
||||||
|
// Large drift is logged for anti-tamper review; no automatic revocation
|
||||||
|
// in v1 — operator decides.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
type LicenseServer struct {
|
||||||
|
signer *LocalSigner
|
||||||
|
pubKey *rsa.PublicKey
|
||||||
|
tracker *quotaTracker
|
||||||
|
logger Logger
|
||||||
|
mux *http.ServeMux
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger is the minimal logging interface we need. The cmd package's
|
||||||
|
// *logger.Logger satisfies this with its existing Info/Warn methods.
|
||||||
|
type Logger interface {
|
||||||
|
Info(format string, args ...any)
|
||||||
|
Warn(format string, args ...any)
|
||||||
|
Error(format string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLicenseServer builds the HTTP handler set. evictAfter is how long a
|
||||||
|
// quiet device keeps its slot before its quota is reclaimed (recommend
|
||||||
|
// 5 min — twice a typical heartbeat interval).
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
s.mux.HandleFunc("/license/sign", s.handleSign)
|
||||||
|
s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
|
||||||
|
// authenticate extracts and verifies the bearer JWT. Returns the parsed
|
||||||
|
// claims on success; writes the appropriate HTTP error and returns nil
|
||||||
|
// on failure so the caller can simply `return` on a nil result.
|
||||||
|
func (s *LicenseServer) authenticate(w http.ResponseWriter, r *http.Request) *LicenseClaims {
|
||||||
|
authHdr := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHdr, "Bearer ") {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "missing Bearer token")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tokenStr := strings.TrimPrefix(authHdr, "Bearer ")
|
||||||
|
claims, err := VerifyJWT(tokenStr, s.pubKey)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, fmt.Sprintf("invalid token: %v", err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if claims == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req signRequest
|
||||||
|
if err := readJSONLimited(w, r, maxSignBodyBytes, &req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("bad request body: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ClientID == "" || req.StartTime == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "client_id and start_time required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically check + reserve the slot. A rejected request does NOT
|
||||||
|
// 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",
|
||||||
|
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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint the signature using the local HMAC master key.
|
||||||
|
sig, err := s.signer.Sign(req.StartTime, req.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("License Server: signer failed: %v", err)
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "signing failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, signResponse{Signature: sig})
|
||||||
|
s.logger.Info("License Server: signed for sub=%s clientID=%s active=%d/%d ttl=%s",
|
||||||
|
claims.Subject, req.ClientID, active, claims.MaxDevices,
|
||||||
|
formatTTL(claims.ttlSinceNow()))
|
||||||
|
}
|
||||||
|
|
||||||
|
type heartbeatRequest struct {
|
||||||
|
ActiveDeviceCount int `json:"active_device_count"`
|
||||||
|
ActiveDeviceIDs []string `json:"active_device_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type heartbeatResponse struct {
|
||||||
|
ServerViewCount int `json:"server_view_count"`
|
||||||
|
Drift int `json:"drift"` // customer count - server view count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := s.authenticate(w, r)
|
||||||
|
if claims == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req heartbeatRequest
|
||||||
|
if err := readJSONLimited(w, r, maxHeartbeatBodyBytes, &req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("bad request body: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// REFRESH-ONLY: only bump activity timestamps for devices already in
|
||||||
|
// the customer's set (i.e. that came through /sign). New IDs reported
|
||||||
|
// here are silently ignored — otherwise a malicious customer could
|
||||||
|
// inflate their quota by POSTing fake IDs to /heartbeat.
|
||||||
|
refreshed := s.tracker.RefreshExisting(claims.Subject, req.ActiveDeviceIDs)
|
||||||
|
|
||||||
|
serverView := len(s.tracker.Snapshot(claims.Subject))
|
||||||
|
drift := req.ActiveDeviceCount - serverView
|
||||||
|
|
||||||
|
// Soft anti-tamper: large persistent drift means the customer reports
|
||||||
|
// devices we never minted signatures for. Log for operator review; no
|
||||||
|
// automatic revocation in v1.
|
||||||
|
if drift > claims.MaxDevices/2 && drift > 5 {
|
||||||
|
s.logger.Warn("License Server: heartbeat drift sub=%s reported=%d server=%d refreshed=%d drift=%d",
|
||||||
|
claims.Subject, req.ActiveDeviceCount, serverView, refreshed, drift)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, heartbeatResponse{
|
||||||
|
ServerViewCount: serverView,
|
||||||
|
Drift: drift,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue is a small in-process helper for operators to mint customer JWTs
|
||||||
|
// without spinning up a separate tool. It is intentionally unexported as
|
||||||
|
// an HTTP endpoint — JWT issuance is a one-off operator action, not a
|
||||||
|
// remote API. Use it from a cmd/issue-token CLI or interactively.
|
||||||
|
//
|
||||||
|
// Returns the signed RS256 token string.
|
||||||
|
// minTokenTTL guards against fat-finger ttl=0 / negative — those produce
|
||||||
|
// already-expired tokens that 401 immediately and confuse the customer.
|
||||||
|
const minTokenTTL = time.Hour
|
||||||
|
|
||||||
|
func Issue(privKey *rsa.PrivateKey, sub, tier string, maxDevices int, ttl time.Duration) (string, error) {
|
||||||
|
if privKey == nil {
|
||||||
|
return "", errors.New("nil private key")
|
||||||
|
}
|
||||||
|
if sub == "" {
|
||||||
|
return "", errors.New("sub (customer ID) is required")
|
||||||
|
}
|
||||||
|
if ttl < minTokenTTL {
|
||||||
|
return "", fmt.Errorf("ttl too short (%v); minimum is %v", ttl, minTokenTTL)
|
||||||
|
}
|
||||||
|
switch tier {
|
||||||
|
case TierTrial:
|
||||||
|
// Trial: 0 means "use the default 20" (kept consistent with VerifyJWT).
|
||||||
|
if maxDevices <= 0 {
|
||||||
|
maxDevices = TrialMaxDevices
|
||||||
|
}
|
||||||
|
case TierPaid:
|
||||||
|
// Paid: must be explicit. A 0 here is almost certainly a misconfig
|
||||||
|
// and would silently let one customer use unlimited devices.
|
||||||
|
if maxDevices <= 0 {
|
||||||
|
return "", errors.New("paid tier requires explicit max_devices > 0")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported tier: %q", tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
claims := &LicenseClaims{
|
||||||
|
Tier: tier,
|
||||||
|
MaxDevices: maxDevices,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: sub,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
return tok.SignedString(privKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
// Body size caps. /sign sends a tiny fixed-schema body; /heartbeat carries
|
||||||
|
// a list of clientIDs whose worst case is bounded by the customer's max
|
||||||
|
// device cap (paid is operator-controlled). 64 KiB is roomy for hundreds
|
||||||
|
// of 20-byte IDs while still bounding the worst case.
|
||||||
|
const (
|
||||||
|
maxSignBodyBytes int64 = 8 << 10 // 8 KiB
|
||||||
|
maxHeartbeatBodyBytes int64 = 64 << 10 // 64 KiB
|
||||||
|
)
|
||||||
|
|
||||||
|
// readJSONLimited wraps the body with http.MaxBytesReader so a misconfigured
|
||||||
|
// (or malicious) client cannot OOM the server with a multi-GB payload.
|
||||||
|
// DisallowUnknownFields catches schema drift cleanly.
|
||||||
|
func readJSONLimited(w http.ResponseWriter, r *http.Request, limit int64, dst any) error {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
return dec.Decode(dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, signResponse{Error: msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTTL(d time.Duration) string {
|
||||||
|
if d <= 0 {
|
||||||
|
return "expired"
|
||||||
|
}
|
||||||
|
days := int(d.Hours() / 24)
|
||||||
|
if days > 0 {
|
||||||
|
return fmt.Sprintf("%dd", days)
|
||||||
|
}
|
||||||
|
return d.Round(time.Minute).String()
|
||||||
|
}
|
||||||
|
|
||||||
44
server/go/licensing/signer.go
Normal file
44
server/go/licensing/signer.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Package licensing provides pluggable signing strategies for the per-login
|
||||||
|
// CMD_MASTERSETTING signature. Three modes are picked at startup by env var:
|
||||||
|
//
|
||||||
|
// - LocalSigner (YAMA_SIGN_PASSWORD set): operator's own deployment.
|
||||||
|
// Signs directly with the HMAC master key. Optionally serves as License
|
||||||
|
// Server for RemoteSigner deployments (see server.go) when
|
||||||
|
// YAMA_LICENSE_HTTP_ADDR is also set.
|
||||||
|
//
|
||||||
|
// - RemoteSigner (YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set): customer
|
||||||
|
// deployments. Never sees the HMAC master key — each new device login
|
||||||
|
// does an HTTPS POST to the operator's License Server to fetch a
|
||||||
|
// signature. Successful results are cached for 24h so transient
|
||||||
|
// License Server outages don't break new logins.
|
||||||
|
//
|
||||||
|
// - NoOpSigner (neither set): free-tier mode. Returns empty signature so
|
||||||
|
// the client's private library refuses screen/file features; device
|
||||||
|
// list keeps working.
|
||||||
|
//
|
||||||
|
// The Sign call is on the per-login critical path — keep it fast.
|
||||||
|
// LocalSigner is microseconds (HMAC). RemoteSigner is one HTTPS round-trip
|
||||||
|
// on cache miss (~100-300 ms), zero on cache hit.
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
// Signer produces the per-login signature for CMD_MASTERSETTING.
|
||||||
|
//
|
||||||
|
// Sign returns the 64-char lowercase hex HMAC-SHA256 of "<startTime>|<clientID>"
|
||||||
|
// keyed by the deployment's master HMAC secret. Returning "" means "no
|
||||||
|
// signature available" — sendMasterSetting will ship a zeroed Signature[64]
|
||||||
|
// field, and the client's private library will refuse screen/file init.
|
||||||
|
type Signer interface {
|
||||||
|
// Sign generates the signature for a new device login. SHOULD be quick
|
||||||
|
// (sub-300ms target). Returning ("", nil) is allowed and means "this
|
||||||
|
// deployment doesn't sign" (NoOpSigner). Returning ("", err) means a
|
||||||
|
// transient failure — callers ship zeroed signature and log the error;
|
||||||
|
// the device can retry by reconnecting.
|
||||||
|
Sign(startTime, clientID string) (string, error)
|
||||||
|
|
||||||
|
// Mode returns a short identifier for logging: "local", "remote", "noop".
|
||||||
|
Mode() string
|
||||||
|
|
||||||
|
// Close releases background resources (HTTP keepalives, caches).
|
||||||
|
// Idempotent.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
114
server/go/licensing/token.go
Normal file
114
server/go/licensing/token.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LicenseClaims is the JWT payload the operator signs and ships to each
|
||||||
|
// customer. The operator picks "sub" (a unique customer ID), "tier" (trial
|
||||||
|
// or paid), "max_devices" (concurrent device cap), and "exp" (token
|
||||||
|
// expiry — independent of any in-memory cache TTL on the RemoteSigner
|
||||||
|
// side).
|
||||||
|
type LicenseClaims struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
MaxDevices int `json:"max_devices"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRSAPublicKey parses an RSA public key from a PEM file. The License
|
||||||
|
// Server loads this once at startup to verify incoming customer JWTs.
|
||||||
|
// Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM
|
||||||
|
// encodings — openssl emits PKIX by default; "openssl rsa -RSAPublicKey_out"
|
||||||
|
// emits PKCS#1.
|
||||||
|
func LoadRSAPublicKey(pemPath string) (*rsa.PublicKey, error) {
|
||||||
|
data, err := os.ReadFile(pemPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read public key %s: %w", pemPath, err)
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no PEM block in %s", pemPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try PKIX first (most common output of openssl genrsa | openssl rsa -pubout).
|
||||||
|
if pub, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
|
||||||
|
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("PKIX key in %s is not RSA", pemPath)
|
||||||
|
}
|
||||||
|
return rsaPub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to PKCS#1.
|
||||||
|
if pub, err := x509.ParsePKCS1PublicKey(block.Bytes); err == nil {
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to parse %s as PKIX or PKCS#1 RSA public key", pemPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyJWT validates the customer's JWT against the License Server's
|
||||||
|
// public key. Returns the parsed claims on success. Caller enforces the
|
||||||
|
// tier-specific quota using claims.MaxDevices.
|
||||||
|
//
|
||||||
|
// Validation done by jwt.ParseWithClaims:
|
||||||
|
// - signature (RS256, using the supplied public key)
|
||||||
|
// - "exp" claim (expiry)
|
||||||
|
// - "iat" / "nbf" if present
|
||||||
|
//
|
||||||
|
// We additionally require tier ∈ {trial, paid}, max_devices > 0, and "sub"
|
||||||
|
// non-empty.
|
||||||
|
func VerifyJWT(tokenStr string, pubKey *rsa.PublicKey) (*LicenseClaims, error) {
|
||||||
|
claims := &LicenseClaims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
|
||||||
|
// Lock to exactly RS256. The wider *SigningMethodRSA check (which
|
||||||
|
// would also accept RS384/RS512) is technically still safe because
|
||||||
|
// all RSA variants require the private key — but pinning the exact
|
||||||
|
// alg matches the docs and avoids surprises if Issue() ever changes.
|
||||||
|
// "alg":"none" tampering is blocked because none isn't SigningMethodRS256.
|
||||||
|
if t.Method != jwt.SigningMethodRS256 {
|
||||||
|
return nil, fmt.Errorf("unexpected JWT alg: %v (need RS256)", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return pubKey, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, errors.New("invalid JWT")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Subject == "" {
|
||||||
|
return nil, errors.New("JWT missing 'sub' claim")
|
||||||
|
}
|
||||||
|
switch claims.Tier {
|
||||||
|
case TierTrial:
|
||||||
|
if claims.MaxDevices <= 0 {
|
||||||
|
claims.MaxDevices = TrialMaxDevices
|
||||||
|
}
|
||||||
|
case TierPaid:
|
||||||
|
if claims.MaxDevices <= 0 {
|
||||||
|
return nil, errors.New("paid-tier JWT missing max_devices")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported tier: %q", claims.Tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ttlSinceNow returns the time.Duration until exp; useful for logging.
|
||||||
|
func (c *LicenseClaims) ttlSinceNow() time.Duration {
|
||||||
|
if c.ExpiresAt == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Until(c.ExpiresAt.Time)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user