Improve Go Server to support remote desktop and command control (#1)
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -90,3 +90,5 @@ YAMA.code-workspace
|
||||
.vscode/settings.json
|
||||
Bin/*
|
||||
nul
|
||||
server/go/web/assets/index.html
|
||||
server/go/users.json
|
||||
|
||||
18
server/go/.vscode/launch.json
vendored
18
server/go/.vscode/launch.json
vendored
@@ -8,9 +8,14 @@
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/cmd",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"console": "integratedTerminal"
|
||||
"args": [
|
||||
"-port=9090"
|
||||
],
|
||||
"env": {
|
||||
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"preLaunchTask": "sync-web-assets"
|
||||
},
|
||||
{
|
||||
"name": "Debug Server",
|
||||
@@ -22,9 +27,12 @@
|
||||
"args": [
|
||||
"-port=9090"
|
||||
],
|
||||
"env": {},
|
||||
"env": {
|
||||
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"buildFlags": "-gcflags='all=-N -l'"
|
||||
"buildFlags": "-gcflags='all=-N -l'",
|
||||
"preLaunchTask": "sync-web-assets"
|
||||
}
|
||||
]
|
||||
}
|
||||
20
server/go/.vscode/tasks.json
vendored
Normal file
20
server/go/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "sync-web-assets",
|
||||
"type": "shell",
|
||||
"command": "powershell",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"New-Item -ItemType Directory -Force -Path '${workspaceFolder}\\web\\assets' | Out-Null; Copy-Item -Force '${workspaceFolder}\\..\\web\\index.html' '${workspaceFolder}\\web\\assets\\index.html'"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "silent",
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,41 +17,49 @@ LDFLAGS=-ldflags "-s -w"
|
||||
MKDIR=if not exist $(BUILD_DIR) mkdir $(BUILD_DIR)
|
||||
RMDIR=if exist $(BUILD_DIR) rmdir /s /q $(BUILD_DIR)
|
||||
|
||||
.PHONY: all clean windows linux build help windows-386 linux-arm64 all-platforms test fmt deps
|
||||
.PHONY: all clean windows linux build help windows-386 linux-arm64 all-platforms test fmt deps sync
|
||||
|
||||
# Default target
|
||||
all: clean windows linux
|
||||
|
||||
# Sync web assets from server/web/ into web/assets/ for //go:embed.
|
||||
# Single source of truth is server/web/index.html; this just keeps a vendored copy.
|
||||
sync:
|
||||
@echo Syncing web assets from ../web/...
|
||||
@if not exist web\assets mkdir web\assets
|
||||
@copy /Y ..\web\index.html web\assets\index.html >nul
|
||||
@echo Done
|
||||
|
||||
# Build for current platform
|
||||
build:
|
||||
build: sync
|
||||
@echo Building for current platform...
|
||||
@$(MKDIR)
|
||||
go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME).exe $(MAIN_PACKAGE)
|
||||
@echo Done
|
||||
|
||||
# Build for Windows (amd64)
|
||||
windows:
|
||||
windows: sync
|
||||
@echo Building for Windows amd64...
|
||||
@$(MKDIR)
|
||||
set GOOS=windows&& set GOARCH=amd64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_windows_amd64.exe $(MAIN_PACKAGE)
|
||||
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_windows_amd64.exe
|
||||
|
||||
# Build for Windows (386)
|
||||
windows-386:
|
||||
windows-386: sync
|
||||
@echo Building for Windows 386...
|
||||
@$(MKDIR)
|
||||
set GOOS=windows&& set GOARCH=386&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_windows_386.exe $(MAIN_PACKAGE)
|
||||
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_windows_386.exe
|
||||
|
||||
# Build for Linux (amd64)
|
||||
linux:
|
||||
linux: sync
|
||||
@echo Building for Linux amd64...
|
||||
@$(MKDIR)
|
||||
set GOOS=linux&& set GOARCH=amd64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_linux_amd64 $(MAIN_PACKAGE)
|
||||
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_linux_amd64
|
||||
|
||||
# Build for Linux (arm64)
|
||||
linux-arm64:
|
||||
linux-arm64: sync
|
||||
@echo Building for Linux arm64...
|
||||
@$(MKDIR)
|
||||
set GOOS=linux&& set GOARCH=arm64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_linux_arm64 $(MAIN_PACKAGE)
|
||||
@@ -89,6 +97,7 @@ help:
|
||||
@echo linux - Build for Linux amd64
|
||||
@echo linux-arm64 - Build for Linux arm64
|
||||
@echo all-platforms - Build for all platforms
|
||||
@echo sync - Sync web assets from ../web/ for //go:embed
|
||||
@echo clean - Clean build directory
|
||||
@echo test - Run tests
|
||||
@echo fmt - Format code
|
||||
|
||||
@@ -25,36 +25,78 @@ server/go/
|
||||
│ └── pool.go # Goroutine 工作池
|
||||
├── logger/
|
||||
│ └── logger.go # 日志模块 (基于 zerolog)
|
||||
├── hub/
|
||||
│ └── hub.go # 在线设备注册表 + 事件订阅
|
||||
├── wsauth/
|
||||
│ └── wsauth.go # Web 鉴权 (challenge-response + 不透明 token)
|
||||
├── web/
|
||||
│ ├── embed.go # //go:embed 嵌入 HTML/xterm.js 等 web 资源
|
||||
│ ├── server.go # HTTP server (静态页面 + REST + WS 路由)
|
||||
│ ├── ws.go # WebSocket 连接生命周期
|
||||
│ ├── ws_handlers.go # WS 消息分发与处理
|
||||
│ └── assets/
|
||||
│ ├── index.html # 从 ../../web/index.html sync 而来 (gitignored)
|
||||
│ └── static/ # 第三方 xterm.js 资源 (checked in)
|
||||
└── cmd/
|
||||
└── main.go # 程序入口
|
||||
```
|
||||
|
||||
## 核心特性
|
||||
|
||||
底层基础设施:
|
||||
|
||||
- **高并发**: 基于 Goroutine 池管理并发连接
|
||||
- **协议兼容**: 支持原有 C++ 客户端的多种协议标识 (Hell/Hello/Shine/Fuck)
|
||||
- **协议头解密**: 支持8种协议头加密方式 (V0-V6 + Default)
|
||||
- **授权验证**: 支持 TOKEN_AUTH 和 Heartbeat HMAC-SHA256 双重授权验证
|
||||
- **XOR编码**: 支持 XOREncoder16 数据编码/解码
|
||||
- **ZSTD 压缩**: 使用高效的 ZSTD 算法进行数据压缩
|
||||
- **GBK编码**: 自动将 Windows 客户端的 GBK 编码转换为 UTF-8
|
||||
- **线程安全**: Buffer、连接管理器和 LastActive 均为线程安全设计
|
||||
- **优雅关闭**: 支持信号处理和优雅停机,自动释放资源
|
||||
- **可配置**: 支持自定义端口、最大连接数、超时时间等
|
||||
- **日志系统**: 基于 zerolog,支持文件输出、日志轮转、客户端上下线记录
|
||||
- **协议兼容**: 支持原有客户端的多种协议标识 (Hell/Hello/Shine/Fuck)
|
||||
- **协议头解密**: 支持 8 种协议头加密方式 (V0-V6 + Default)
|
||||
- **授权验证**: TOKEN_AUTH 和 Heartbeat HMAC-SHA256 双重授权
|
||||
- **XOR 编码 / ZSTD 压缩**: 与客户端完全兼容
|
||||
- **字符编码自适应**: 根据客户端能力位选择 UTF-8 直通或 GBK→UTF-8 转换
|
||||
- **线程安全 / 优雅关闭 / 多端口监听 / 结构化日志**
|
||||
|
||||
Web 应用能力 (Phase 3-7):
|
||||
|
||||
- **Web 鉴权**: challenge-response 登录 + 不透明 token,与 users.json schema 互通
|
||||
- **登录加固**: 双维度速率限制(10 次/分钟·IP + 5 次/15 分钟·用户名)+ 失败固定延迟,防口令枚举;`/get_salt` 用确定性假盐响应未知用户,杜绝用户名探测;WebSocket Origin 同源校验 + 显式白名单;`/api/devices` Bearer Token 鉴权
|
||||
- **设备列表与监控**: 在线设备 / RTT / 活动窗口 / 分辨率 实时下发
|
||||
- **Web 远程桌面**: 浏览器 WebCodecs 解码 H.264,二进制 WS 帧低延迟中继;late-join 自动重发最近 IDR;优雅 BYE 关闭防止客户端无意义重连
|
||||
- **鼠标 / 键盘输入**: Win32 消息映射 (`WM_*` / `VK_*` / `MK_*`),MSG64 48 字节布局直传客户端
|
||||
- **Web 终端**: xterm.js + Windows ConPTY / 旧 cmd 管道双模式;二进制 "TRM1" 帧分流;尺寸自适应;单设备单 viewer
|
||||
- **用户与分组**: admin 可创建/删除 viewer 账号、配置 allowed_groups,users.json 原子写入
|
||||
|
||||
## 支持的命令
|
||||
|
||||
当前已实现以下命令处理:
|
||||
### 客户端 → 服务端
|
||||
|
||||
| 命令 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| TOKEN_AUTH | 100 | 授权请求 (验证 SN + Passcode + HMAC) |
|
||||
| TOKEN_HEARTBEAT | 101 | 心跳包 (支持 HMAC 授权验证,返回 Authorized 状态) |
|
||||
| TOKEN_LOGIN | 102 | 客户端登录 |
|
||||
| CMD_HEARTBEAT_ACK | 216 | 心跳响应 (包含 Authorized 字段) |
|
||||
| Token | 值 | 用途 |
|
||||
| ---- | ---- | ---- |
|
||||
| `TOKEN_AUTH` | 100 | 授权请求(SN + Passcode + HMAC) |
|
||||
| `TOKEN_HEARTBEAT` | 101 | 心跳包(携带 ActiveWnd / Ping / SN) |
|
||||
| `TOKEN_LOGIN` | 102 | 主连接登录 |
|
||||
| `TOKEN_BITMAPINFO` | 115 | 屏幕子连接首包,含分辨率 + clientID |
|
||||
| `TOKEN_FIRSTSCREEN` | 116 | 原始 BGRA 首帧(Go 侧丢弃) |
|
||||
| `TOKEN_NEXTSCREEN` | 117 | H.264 屏幕帧 |
|
||||
| `TOKEN_SHELL_START` | 128 | 旧 cmd-pipe 终端子连接首包 |
|
||||
| `TOKEN_KEYFRAME` | 134 | GOP 关键帧(DEFAULT_GOP 无限大,实际未用) |
|
||||
| `TOKEN_TERMINAL_START` | 232 | PTY 终端子连接首包 |
|
||||
| `TOKEN_TERMINAL_CLOSE` | 233 | 终端关闭通知 |
|
||||
| `TOKEN_CONN_AUTH` | 246 | 子连接身份握手,含 clientID |
|
||||
| (raw bytes) | — | 终端 sub-conn 绑定后裸字节即 shell 输出 |
|
||||
|
||||
其他命令会被记录为 Debug 日志,可按需扩展。
|
||||
### 服务端 → 客户端
|
||||
|
||||
| Command | 值 | 用途 |
|
||||
| ---- | ---- | ---- |
|
||||
| `COMMAND_SCREEN_SPY` | 16 | 启动屏幕捕获 |
|
||||
| `COMMAND_SCREEN_CONTROL` | 20 | 鼠标 / 键盘输入(MSG64 批次) |
|
||||
| `COMMAND_NEXT` | 30 | 解除客户端读线程阻塞 |
|
||||
| `COMMAND_SHELL` | 40 | 请求开启 shell 子连接 |
|
||||
| `CMD_TERMINAL_RESIZE` | 81 | PTY 尺寸 (cols / rows int16 LE) |
|
||||
| `COMMAND_BYE` | 204 | 优雅断开屏幕 / 终端 |
|
||||
| `CMD_MASTERSETTING` | 215 | 主控配置 + HMAC 签名 (1000B) |
|
||||
| `CMD_HEARTBEAT_ACK` | 216 | 心跳响应(携带 Authorized 字段) |
|
||||
| `TOKEN_CONN_AUTH` | 246 | 子连接身份握手响应 (256B) |
|
||||
|
||||
未列出的命令字节会被记录为 Debug 日志,按需扩展。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -67,24 +109,49 @@ go mod tidy
|
||||
|
||||
### 编译
|
||||
|
||||
推荐用 Makefile,编译前会自动从 `server/web/` 同步 HTML 到 `web/assets/`:
|
||||
|
||||
```bash
|
||||
go build -o simpleremoter-server ./cmd
|
||||
make build # 当前平台
|
||||
make windows # Windows amd64
|
||||
make linux # Linux amd64
|
||||
```
|
||||
|
||||
也可以直接用 `go build`,但要先手动 sync:
|
||||
|
||||
```bash
|
||||
make sync && go build -o simpleremoter-server ./cmd
|
||||
```
|
||||
|
||||
VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
./simpleremoter-server
|
||||
```
|
||||
|
||||
服务器默认监听 6543 端口,日志输出到 `logs/server.log`。
|
||||
默认监听 TCP 端口 `6543`(被控设备),HTTP 端口 `8080`(浏览器 Web UI)。日志写到 `logs/server.log`。
|
||||
|
||||
### 命令行参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
| ---- | ------ | ---- |
|
||||
| `-port` / `-p` | `6543` | TCP 监听端口,分号分隔可多端口(如 `6543;6544`) |
|
||||
| `-http-port` | `8080` | HTTP 监听端口(Web UI),传 `0` 禁用 |
|
||||
| `-no-console` | `false` | 关闭控制台输出(守护进程模式) |
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| ---- | ---- | ---- |
|
||||
| `YAMA_PWDHASH` | 密码的 SHA256 哈希值 (64位十六进制) | `61f04dd6...` |
|
||||
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证 | `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_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_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_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` |
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
@@ -100,6 +167,10 @@ $env:YAMA_PWD="your_super_password"
|
||||
|
||||
## 使用示例
|
||||
|
||||
完整的 TCP + Hub + Web 集成示例就是 [`cmd/main.go`](cmd/main.go),那是程序入口本身、也是最权威的范例 —— 包含 handler 装配、hub 注册、web HTTP/WS 服务、信号优雅关闭等。
|
||||
|
||||
如果只想用 TCP 框架做自定义服务端(不要 Web/Hub),最小示例如下:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
@@ -114,57 +185,32 @@ import (
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
|
||||
)
|
||||
|
||||
// 实现 Handler 接口
|
||||
type MyHandler struct {
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
func (h *MyHandler) OnConnect(ctx *connection.Context) {
|
||||
h.log.ClientEvent("online", ctx.ID, ctx.GetPeerIP())
|
||||
}
|
||||
|
||||
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {
|
||||
h.log.ClientEvent("offline", ctx.ID, ctx.GetPeerIP())
|
||||
}
|
||||
type MyHandler struct{ log *logger.Logger }
|
||||
|
||||
func (h *MyHandler) OnConnect(ctx *connection.Context) {}
|
||||
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {}
|
||||
func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
cmd := data[0]
|
||||
switch cmd {
|
||||
case protocol.TokenLogin:
|
||||
if data[0] == protocol.TokenLogin {
|
||||
info, _ := protocol.ParseLoginInfo(data)
|
||||
h.log.Info("Client login: %s (%s)", info.PCName, info.OsVerInfo)
|
||||
case protocol.TokenHeartbeat:
|
||||
h.log.Debug("Heartbeat from client %d", ctx.ID)
|
||||
h.log.Info("login: %s (%s)", info.PCName, info.OsVerInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 配置日志 (控制台 + 文件)
|
||||
logCfg := logger.DefaultConfig()
|
||||
logCfg.File = "logs/server.log"
|
||||
log := logger.New(logCfg)
|
||||
|
||||
// 配置服务器
|
||||
config := server.DefaultConfig()
|
||||
config.Port = 6543
|
||||
|
||||
// 创建并启动服务器
|
||||
srv := server.New(config)
|
||||
log := logger.New(logger.DefaultConfig())
|
||||
srv := server.New(server.DefaultConfig())
|
||||
srv.SetLogger(log.WithPrefix("Server"))
|
||||
srv.SetHandler(&MyHandler{log: log})
|
||||
|
||||
if err := srv.Start(); err != nil {
|
||||
log.Fatal("启动失败: %v", err)
|
||||
log.Fatal("start: %v", err)
|
||||
}
|
||||
|
||||
// 等待退出信号
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
srv.Stop()
|
||||
}
|
||||
```
|
||||
@@ -250,7 +296,7 @@ func main() {
|
||||
| bWebCamExist | 448 | 4 | 是否有摄像头 |
|
||||
| dwSpeed | 452 | 4 | 网速 |
|
||||
| szStartTime | 456 | 20 | 启动时间 |
|
||||
| szReserved | 476 | 512 | 扩展字段 (用`|`分隔) |
|
||||
| szReserved | 476 | 512 | 扩展字段(多字段以 `\|` 分隔) |
|
||||
|
||||
### Heartbeat 结构
|
||||
|
||||
@@ -374,15 +420,19 @@ publicIP := info.GetReservedField(11) // 公网 IP
|
||||
## 与 C++ 版本对比
|
||||
|
||||
| 特性 | C++ (IOCP) | Go |
|
||||
|------|------------|-----|
|
||||
| ---- | ---- | ---- |
|
||||
| 并发模型 | IOCP + 线程池 | Goroutine 池 |
|
||||
| 压缩算法 | ZSTD | ZSTD |
|
||||
| 跨平台 | Windows | 全平台 |
|
||||
| 内存管理 | 手动 | GC |
|
||||
| 代码复杂度 | 高 | 低 |
|
||||
| 协议头解密 | 8种方式 | 8种方式 |
|
||||
| XOR编码 | XOREncoder16 | XOREncoder16 |
|
||||
| 字符编码 | GBK | GBK -> UTF-8 |
|
||||
| 压缩 / XOR / 头加密 | 完整 8 套加密方式 + XOREncoder16 + ZSTD | 完全对齐 |
|
||||
| 字符编码 | GBK | UTF-8 直通 / GBK→UTF-8 (按客户端能力位) |
|
||||
| 设备列表与监控 | MFC 列表控件 | Web UI |
|
||||
| Web 远程桌面 | 内嵌浏览器 + H.264 | 完全对齐(WebCodecs 解码) |
|
||||
| 鼠标键盘转发 | 已实现 | 完全对齐 |
|
||||
| Web 终端 | 内嵌 xterm.js + ConPTY | 完全对齐(含旧 cmd-pipe 兼容) |
|
||||
| 用户 / 分组管理 | 已实现 | users.json schema 互通 |
|
||||
| 文件传输 / 摄像头 / 录音 等 | 已实现 | 暂未实现(按需扩展) |
|
||||
|
||||
## 依赖
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -8,12 +9,16 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/web"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
// MyHandler implements the server.Handler interface
|
||||
@@ -21,6 +26,8 @@ type MyHandler struct {
|
||||
log *logger.Logger
|
||||
auth *auth.Authenticator
|
||||
srv *server.Server
|
||||
hub *hub.Hub
|
||||
signPwd string // HMAC key for CMD_MASTERSETTING signatures (YAMA_SIGN_PASSWORD)
|
||||
}
|
||||
|
||||
// OnConnect is called when a client connects
|
||||
@@ -30,12 +37,34 @@ func (h *MyHandler) OnConnect(ctx *connection.Context) {
|
||||
|
||||
// OnDisconnect is called when a client disconnects
|
||||
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {
|
||||
// Always clean up any sub-context mapping first — the connection may
|
||||
// be a screen / terminal sub-conn rather than a main login connection.
|
||||
// Both Unbind* calls are no-ops if not tracked. UnbindTerminalConn
|
||||
// also fires OnTerminalClosed so the browser sees the session end on
|
||||
// unexpected device-side drops.
|
||||
h.hub.UnbindScreenConn(ctx)
|
||||
h.hub.UnbindTerminalConn(ctx)
|
||||
|
||||
info := ctx.GetInfo()
|
||||
if info.ClientID != "" {
|
||||
// Only treat this disconnect as a device-going-offline event if this
|
||||
// ctx is the device's MAIN login connection. Phase 6 added ClientID
|
||||
// pinning to sub-conns (via ConnAuth — needed for terminal routing),
|
||||
// so a non-empty ClientID alone no longer distinguishes main from
|
||||
// sub. Closing a screen / terminal sub-conn must NOT remove the
|
||||
// device from the hub.
|
||||
if info.ClientID != "" && h.hub.MainConn(info.ClientID) == ctx {
|
||||
h.log.ClientEvent("offline", ctx.ID, ctx.GetPeerIP(),
|
||||
"clientID", info.ClientID,
|
||||
"computer", info.ComputerName,
|
||||
)
|
||||
// Tear down any active sub-conn sessions BEFORE Unregister so the
|
||||
// browser sees screen/terminal close events alongside the
|
||||
// device-offline event, instead of frames/output continuing to
|
||||
// stream from orphaned sub-conn ctxs until they time out on
|
||||
// their own. Both calls no-op if there's no active session.
|
||||
h.hub.CloseScreen(info.ClientID)
|
||||
h.hub.CloseTerminalSession(info.ClientID)
|
||||
h.hub.Unregister(info.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +74,27 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
// Terminal-bound sub-conns deliver RAW shell output with no leading
|
||||
// command byte — see client/ConPTYManager.cpp:328 (Send2Server with
|
||||
// just the buffer). We must short-circuit BEFORE the command switch
|
||||
// or the first output byte will be misinterpreted as a token.
|
||||
// Exception: a length-1 packet whose byte is TOKEN_TERMINAL_CLOSE
|
||||
// is the device's "shell exited" notification, NOT data.
|
||||
if devID := h.hub.TerminalDeviceID(ctx); devID != "" {
|
||||
if len(data) == 1 && data[0] == protocol.TokenTerminalClose {
|
||||
h.log.Info("terminal closed by device=%s conn=%d", devID, ctx.ID)
|
||||
h.hub.CloseTerminalSession(devID)
|
||||
return
|
||||
}
|
||||
// Wrap with the 'TRM1' magic the browser uses to demultiplex
|
||||
// terminal output from screen frames over the shared WS.
|
||||
packet := make([]byte, 4+len(data))
|
||||
copy(packet[:4], protocol.TerminalBinaryMagic[:])
|
||||
copy(packet[4:], data)
|
||||
h.hub.PublishTerminalData(devID, packet)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := data[0]
|
||||
// Handle commands
|
||||
switch cmd {
|
||||
@@ -54,12 +104,231 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
|
||||
h.handleAuth(ctx, data)
|
||||
case protocol.TokenHeartbeat:
|
||||
h.handleHeartbeat(ctx, data)
|
||||
case protocol.TokenConnAuth:
|
||||
h.handleConnAuth(ctx, data)
|
||||
case protocol.TokenBitmapInfo:
|
||||
h.handleBitmapInfo(ctx, data)
|
||||
case protocol.TokenTerminalStart:
|
||||
h.handleTerminalStart(ctx, true)
|
||||
case protocol.TokenShellStart:
|
||||
h.handleTerminalStart(ctx, false)
|
||||
case protocol.TokenTerminalClose:
|
||||
// Pre-bind close (rare — device gives up before the server
|
||||
// finished its half of the handshake). Best-effort cleanup.
|
||||
if devID := h.deviceIDOfSubConn(ctx); devID != "" {
|
||||
h.log.Info("pre-bind terminal close: device=%s conn=%d", devID, ctx.ID)
|
||||
h.hub.CloseTerminalSession(devID)
|
||||
}
|
||||
case protocol.TokenFirstScreen:
|
||||
// TOKEN_FIRSTSCREEN delivers a RAW BGRA baseline frame, not an
|
||||
// H264 unit — bytes ≈ width × height × 4. The C++ MFC dialog
|
||||
// blits it directly into a DIB; web viewers only consume H264 NAL
|
||||
// data, so dropping it here is correct. The first real H264 IDR
|
||||
// arrives shortly after via TOKEN_NEXTSCREEN.
|
||||
case protocol.TokenNextScreen:
|
||||
h.handleScreenFrame(ctx, data, false)
|
||||
case protocol.TokenKeyframe:
|
||||
// Sent by the client only when frameID % m_GOP == 0; the client's
|
||||
// DEFAULT_GOP is 0x7FFFFFFF (effectively infinite), so this token
|
||||
// is essentially unused in practice. Treat as a no-op for now —
|
||||
// IDRs always arrive in-band via TOKEN_NEXTSCREEN and we catch
|
||||
// them via the H264 NAL scan in handleScreenFrame.
|
||||
case protocol.CmdCursorImage:
|
||||
// Custom cursor bitmaps — relayed in Phase 5+ when the web cursor
|
||||
// overlay learns to render arbitrary BGRA images. Drop silently for
|
||||
// now; the standard IDC_* index (data[10] of every frame header) is
|
||||
// what we actually use right now.
|
||||
default:
|
||||
// Other commands are not implemented yet
|
||||
h.log.Info("Unhandled command %d from client %d", cmd, ctx.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConnAuth answers a sub-connection identity handshake. Every sub-conn
|
||||
// the client opens (screen, terminal, file, ...) sends a 512-byte
|
||||
// ConnAuthPacket as its very first payload and blocks for up to 10 s waiting
|
||||
// on our 256-byte ConnAuthAck. Without an OK reply the client closes the
|
||||
// connection, so a missing ack here means nothing else can proceed.
|
||||
//
|
||||
// The handshake includes an HMAC signature field. The reference server
|
||||
// treats verification failures as soft (logs and still allows commands),
|
||||
// and the signing primitive lives in a vendored component out of scope
|
||||
// for this server, so we always reply OK and let TOKEN_BITMAPINFO carry
|
||||
// the device ID via offset 41 when the screen sub-conn proceeds.
|
||||
func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) {
|
||||
// Pin the parent device's ClientID onto the sub-conn. Without this,
|
||||
// later 1-byte tokens (TOKEN_TERMINAL_START / TOKEN_SHELL_START) have
|
||||
// no way to identify which device they belong to — they carry no
|
||||
// clientID themselves. ConnAuthPacket layout has clientID at offset 1
|
||||
// (uint64 LE); see common/commands.h::ConnAuthPacket.
|
||||
if len(data) >= protocol.ConnAuthOffClientID+8 {
|
||||
clientID := binary.LittleEndian.Uint64(
|
||||
data[protocol.ConnAuthOffClientID : protocol.ConnAuthOffClientID+8])
|
||||
if clientID != 0 {
|
||||
// Sub-conns never go through handleLogin, so their ctx.Info
|
||||
// is otherwise empty. We only need ClientID for routing.
|
||||
info := ctx.GetInfo()
|
||||
info.ClientID = strconv.FormatUint(clientID, 10)
|
||||
ctx.SetInfo(info)
|
||||
}
|
||||
}
|
||||
|
||||
ack := make([]byte, protocol.ConnAuthAckSize)
|
||||
ack[0] = protocol.TokenConnAuth
|
||||
ack[protocol.ConnAuthAckOffStatus] = protocol.ConnAuthStatusOK
|
||||
binary.LittleEndian.PutUint64(
|
||||
ack[protocol.ConnAuthAckOffServerTime:protocol.ConnAuthAckOffServerTime+8],
|
||||
uint64(time.Now().Unix()))
|
||||
if err := h.srv.Send(ctx, ack); err != nil {
|
||||
h.log.Error("ConnAuth ack send failed for conn=%d: %v", ctx.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// deviceIDOfSubConn resolves the parent device of a sub-conn from the
|
||||
// ClientID pinned by handleConnAuth. Returns "" for the rare case of a
|
||||
// legacy client that skipped ConnAuth (the Go server's only target is
|
||||
// modern clients, so this is effectively a paranoia check).
|
||||
func (h *MyHandler) deviceIDOfSubConn(ctx *connection.Context) string {
|
||||
return ctx.GetInfo().ClientID
|
||||
}
|
||||
|
||||
// handleTerminalStart fires when the device's freshly-spawned shell
|
||||
// sub-conn announces itself. TOKEN_TERMINAL_START (232) means PTY mode
|
||||
// (Linux/macOS or Windows ConPTY); TOKEN_SHELL_START (128) means the
|
||||
// legacy Windows cmd-pipe path. Both packets are 1-byte tokens — the
|
||||
// device identity comes from ConnAuth's pinned ClientID.
|
||||
//
|
||||
// After binding we send:
|
||||
// - For PTY only: an initial CMD_TERMINAL_RESIZE 80x24 so the shell
|
||||
// doesn't render at the PTY default before the browser's first fit.
|
||||
// vim/htop look broken otherwise. The browser will follow up with a
|
||||
// real term_resize once xterm.js sizes the canvas.
|
||||
// - Always: COMMAND_NEXT to unblock the device's read thread (the
|
||||
// ConPTYManager ReadThread sits on m_hEventDlgOpen until then —
|
||||
// see client/ConPTYManager.cpp:259).
|
||||
func (h *MyHandler) handleTerminalStart(ctx *connection.Context, isPTY bool) {
|
||||
devID := h.deviceIDOfSubConn(ctx)
|
||||
if devID == "" {
|
||||
h.log.Warn("terminal start with no clientID: conn=%d", ctx.ID)
|
||||
ctx.Close()
|
||||
return
|
||||
}
|
||||
if !h.hub.BindTerminalConn(devID, ctx, isPTY) {
|
||||
// No pending session — this is a stale sub-conn (e.g. browser
|
||||
// gave up and closed term_close already). Drop it.
|
||||
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
|
||||
devID, ctx.ID, isPTY)
|
||||
ctx.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if isPTY {
|
||||
if err := h.srv.Send(ctx, protocol.BuildTerminalResize(80, 24)); err != nil {
|
||||
h.log.Error("initial resize send failed: conn=%d: %v", ctx.ID, err)
|
||||
}
|
||||
}
|
||||
if err := h.srv.Send(ctx, []byte{protocol.CommandNext}); err != nil {
|
||||
h.log.Error("COMMAND_NEXT send failed on terminal: conn=%d: %v", ctx.ID, err)
|
||||
}
|
||||
h.log.Info("terminal bound: device=%s conn=%d isPTY=%v", devID, ctx.ID, isPTY)
|
||||
}
|
||||
|
||||
// handleBitmapInfo is the first packet on a freshly-arrived screen
|
||||
// sub-connection. Packet layout (after the command byte at data[0]):
|
||||
//
|
||||
// [BITMAPINFOHEADER:40][clientID:8 uint64 LE][dlgID:8 uint64 LE][...]
|
||||
//
|
||||
// So clientID lives at data[41..49] and dlgID at data[49..57]. We use
|
||||
// clientID (= MasterID) to bind this sub-context to its parent device.
|
||||
func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) {
|
||||
if len(data) < 49 {
|
||||
h.log.Warn("TOKEN_BITMAPINFO from conn %d too short (%d bytes)", ctx.ID, len(data))
|
||||
return
|
||||
}
|
||||
clientID := uint64(data[41]) | uint64(data[42])<<8 | uint64(data[43])<<16 | uint64(data[44])<<24 |
|
||||
uint64(data[45])<<32 | uint64(data[46])<<40 | uint64(data[47])<<48 | uint64(data[48])<<56
|
||||
deviceID := strconv.FormatUint(clientID, 10)
|
||||
|
||||
if !h.hub.BindScreenConn(deviceID, ctx) {
|
||||
// Device not registered — main login hasn't happened (or device just
|
||||
// went offline). Drop the orphan sub-conn rather than leak it.
|
||||
h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID)
|
||||
ctx.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// BITMAPINFOHEADER starts at data[1]. biWidth at offset 4, biHeight at
|
||||
// offset 8 (both int32 LE). biHeight may be negative for top-down DIBs.
|
||||
width := int(int32(binary.LittleEndian.Uint32(data[5:9])))
|
||||
height := int(int32(binary.LittleEndian.Uint32(data[9:13])))
|
||||
if height < 0 {
|
||||
height = -height
|
||||
}
|
||||
|
||||
h.log.Info("screen sub-conn bound: conn=%d device=%s resolution=%dx%d",
|
||||
ctx.ID, deviceID, width, height)
|
||||
h.hub.PublishResolution(deviceID, width, height)
|
||||
|
||||
// Notify the client its "dialog is open" so it stops blocking in
|
||||
// Manager::WaitForDialogOpen (client/Manager.cpp:259). Without this
|
||||
// the client waits a full 8 s timeout before it begins streaming
|
||||
// real H264 frames via TOKEN_NEXTSCREEN. 32-byte packet matches the
|
||||
// C++ CScreenSpyDlg::SendNext layout:
|
||||
// [0]=COMMAND_NEXT [1..9]=dlgID uint64 [9..13]=capabilities uint32
|
||||
// [13..17]=scrollInterval int32 [17..32]=zero reserved
|
||||
// We don't need scroll-detect / a real dlgID, so leave them zero.
|
||||
nextCmd := make([]byte, 32)
|
||||
nextCmd[0] = protocol.CommandNext
|
||||
if err := h.srv.Send(ctx, nextCmd); err != nil {
|
||||
h.log.Error("COMMAND_NEXT send failed for conn=%d: %v", ctx.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleScreenFrame relays one TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN packet
|
||||
// to all browsers watching this device. The on-the-wire packet starts with
|
||||
// the token byte then a small fixed header (algorithm, cursor pos, cursor
|
||||
// index) before the H.264 NAL payload. The browser-facing WS packet uses
|
||||
// the C++-compatible layout: [deviceID:4 LE][frameType:1][dataLen:4 LE][H264:N].
|
||||
//
|
||||
// alwaysKey=true is used for TOKEN_FIRSTSCREEN (always IDR by construction);
|
||||
// TOKEN_NEXTSCREEN is keyframe iff the NAL stream contains a 5/7/8 unit.
|
||||
func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwaysKey bool) {
|
||||
deviceID := h.hub.ScreenDeviceID(ctx)
|
||||
if deviceID == "" {
|
||||
return // not a bound screen sub-conn — drop
|
||||
}
|
||||
// data[0] is the token; the 11-byte header sits at data[1..12].
|
||||
const skip = 1 + protocol.ScreenFrameHeaderLen
|
||||
if len(data) <= skip {
|
||||
return
|
||||
}
|
||||
// Cursor index lives at the last byte of the small per-frame header
|
||||
// (offset 1 + 1 + 8 = 10). Publish before the heavy frame work so the
|
||||
// browser sees cursor updates even if we end up dropping frames later.
|
||||
h.hub.PublishCursor(deviceID, data[10])
|
||||
|
||||
h264 := data[skip:]
|
||||
isKey := alwaysKey || protocol.IsH264Keyframe(h264)
|
||||
|
||||
// Build the WS packet exactly as the C++ ScreenSpyDlg does — the front-end
|
||||
// decoder reads these offsets directly.
|
||||
id64, _ := strconv.ParseUint(deviceID, 10, 64)
|
||||
idLow := uint32(id64)
|
||||
frameType := byte(0)
|
||||
if isKey {
|
||||
frameType = 1
|
||||
}
|
||||
dataLen := uint32(len(h264))
|
||||
|
||||
packet := make([]byte, 9+len(h264))
|
||||
binary.LittleEndian.PutUint32(packet[0:4], idLow)
|
||||
packet[4] = frameType
|
||||
binary.LittleEndian.PutUint32(packet[5:9], dataLen)
|
||||
copy(packet[9:], h264)
|
||||
|
||||
h.hub.PublishScreenFrame(deviceID, packet, isKey)
|
||||
}
|
||||
|
||||
// handleLogin handles client login (TOKEN_LOGIN = 102)
|
||||
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
||||
info, err := protocol.ParseLoginInfo(data)
|
||||
@@ -68,8 +337,18 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use MasterID from login request as ClientID for logging
|
||||
clientID := info.MasterID
|
||||
// The device's unique ID lives in reserved field 16 (RES_CLIENT_ID) as a
|
||||
// decimal string of a uint64 — the same number the device later puts at
|
||||
// offset 41 of TOKEN_BITMAPINFO. Using szMasterID here is WRONG: it is a
|
||||
// compile-time MASTER_HASH constant shared by every binary built from
|
||||
// the same source, so all clients would collide in the hub.
|
||||
clientID := info.GetReservedField(protocol.ResFieldClientID)
|
||||
if clientID == "" || clientID == "0" {
|
||||
// Legacy fallback (very old clients that don't fill RES_CLIENT_ID).
|
||||
// MasterID is still preferable to a per-connection number because it
|
||||
// at least stays stable across reconnects of the same binary.
|
||||
clientID = info.MasterID
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = fmt.Sprintf("conn-%d", ctx.ID)
|
||||
}
|
||||
@@ -86,17 +365,17 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
||||
}
|
||||
|
||||
// Parse additional info from reserved field
|
||||
if len(reserved) > 0 {
|
||||
clientInfo.ClientType = info.GetReservedField(0)
|
||||
if len(reserved) > protocol.ResFieldClientType {
|
||||
clientInfo.ClientType = info.GetReservedField(protocol.ResFieldClientType)
|
||||
}
|
||||
if len(reserved) > 2 {
|
||||
clientInfo.CPU = info.GetReservedField(2)
|
||||
}
|
||||
if len(reserved) > 4 {
|
||||
clientInfo.FilePath = info.GetReservedField(4)
|
||||
if len(reserved) > protocol.ResFieldFilePath {
|
||||
clientInfo.FilePath = info.GetReservedField(protocol.ResFieldFilePath)
|
||||
}
|
||||
if len(reserved) > 11 {
|
||||
clientInfo.IP = info.GetReservedField(11) // Public IP
|
||||
if len(reserved) > protocol.ResFieldClientPubIP {
|
||||
clientInfo.IP = info.GetReservedField(protocol.ResFieldClientPubIP)
|
||||
}
|
||||
|
||||
ctx.SetInfo(clientInfo)
|
||||
@@ -109,6 +388,82 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
||||
"version", info.ModuleVersion,
|
||||
"path", clientInfo.FilePath,
|
||||
)
|
||||
|
||||
// PCName carries "ComputerName/Group"; ModuleVersion carries "Version-Capability".
|
||||
// strings.Cut returns the full string as the head when the separator is
|
||||
// absent, which gives us the natural "no group / no capability" fallback.
|
||||
name, group, _ := strings.Cut(info.PCName, "/")
|
||||
version, capability, _ := strings.Cut(info.ModuleVersion, "-")
|
||||
|
||||
// Client-reported geo string (RES_CLIENT_LOC).
|
||||
location := ""
|
||||
if len(reserved) > protocol.ResFieldClientLoc {
|
||||
location = info.GetReservedField(protocol.ResFieldClientLoc)
|
||||
}
|
||||
// RES_RESOLUTION is already formatted by the client as "N:W*H"
|
||||
// (see client/LoginServer.cpp:414). Pass through unchanged so the web
|
||||
// UI's device card renders it next to the version label.
|
||||
resolution := ""
|
||||
if len(reserved) > protocol.ResFieldResolution {
|
||||
resolution = info.GetReservedField(protocol.ResFieldResolution)
|
||||
}
|
||||
|
||||
// Register with hub so the web side can list this device. Sub-connections
|
||||
// (screen / terminal etc.) reuse the MasterID and will overwrite this entry
|
||||
// harmlessly, but only the main login carries enough info to be useful here.
|
||||
h.hub.Register(&hub.Device{
|
||||
ID: clientID,
|
||||
Name: name,
|
||||
Group: group,
|
||||
Version: version,
|
||||
Capability: capability,
|
||||
OS: info.OsVerInfo,
|
||||
CPU: clientInfo.CPU,
|
||||
FilePath: clientInfo.FilePath,
|
||||
InstallTime: info.StartTime,
|
||||
Location: location,
|
||||
Resolution: resolution,
|
||||
PeerIP: ctx.GetPeerIP(),
|
||||
PublicIP: clientInfo.IP,
|
||||
ConnectedAt: time.Now(),
|
||||
}, ctx)
|
||||
|
||||
// Push CMD_MASTERSETTING with a signature over "StartTime|ClientID".
|
||||
// The client's private FileUpload init verifies this before allowing
|
||||
// screen / file operations — without it the binary aborts itself.
|
||||
h.sendMasterSetting(ctx, info.StartTime, clientID)
|
||||
}
|
||||
|
||||
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
|
||||
// down the main TCP connection. Most fields stay zeroed — only Signature
|
||||
// matters today. If no signing password is configured, a zeroed signature is
|
||||
// still sent (and logged once) so the client at least sees a well-formed
|
||||
// packet; in that case the client's private library will refuse to start
|
||||
// screen / file features and abort.
|
||||
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) {
|
||||
buf := make([]byte, 1+protocol.MasterSettingsSize)
|
||||
buf[0] = protocol.CmdMasterSetting
|
||||
|
||||
// ReportInterval (int32 LE at struct offset 0, +1 for the cmd byte).
|
||||
// Sending 0 makes the client drop the active-window field of its
|
||||
// heartbeat, which kills the web UI's live activeWindow updates.
|
||||
binary.LittleEndian.PutUint32(
|
||||
buf[1:5],
|
||||
uint32(protocol.DefaultReportIntervalSec))
|
||||
|
||||
if h.signPwd == "" {
|
||||
h.log.Warn("YAMA_SIGN_PASSWORD not set — client may abort on screen/file ops")
|
||||
} 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
|
||||
copy(buf[sigOffset:sigOffset+protocol.MasterSettingsSignatureLen], []byte(sig))
|
||||
}
|
||||
|
||||
if err := h.srv.Send(ctx, buf); err != nil {
|
||||
h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuth handles authorization request (TOKEN_AUTH = 100)
|
||||
@@ -159,13 +514,41 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
|
||||
uint64(data[5])<<32 | uint64(data[6])<<40 | uint64(data[7])<<48 | uint64(data[8])<<56
|
||||
}
|
||||
|
||||
// Forward live fields (ActiveWnd + Ping) to the hub so the web UI can
|
||||
// display current latency and foreground window per device. Skip until
|
||||
// login has happened — the hub is keyed by MasterID, which only exists
|
||||
// post-login.
|
||||
if info := ctx.GetInfo(); info.ClientID != "" {
|
||||
var rtt int32
|
||||
var activeWindow string
|
||||
// ActiveWnd at data[9..521] is a 512-byte NUL-padded string. Encoding
|
||||
// depends on the client: new clients advertise CLIENT_CAP_UTF8 (bit
|
||||
// 0x0002 in the moduleVersion hex tail) and ship UTF-8 directly;
|
||||
// legacy Windows clients still use CP936 (GBK). Decoding UTF-8 bytes
|
||||
// as GBK turns Chinese characters into mojibake — see the matching
|
||||
// C++ guard at server/2015Remote/WebService.cpp:1530.
|
||||
if len(data) >= 9+512 {
|
||||
activeWindow = protocol.DecodeClientString(
|
||||
data[9:9+512],
|
||||
h.hub.Capability(info.ClientID),
|
||||
info.ClientType,
|
||||
)
|
||||
}
|
||||
// Ping at data[521..525] is a little-endian int32.
|
||||
if len(data) >= 525 {
|
||||
rtt = int32(uint32(data[521]) | uint32(data[522])<<8 |
|
||||
uint32(data[523])<<16 | uint32(data[524])<<24)
|
||||
}
|
||||
h.hub.UpdateLive(info.ClientID, int(rtt), activeWindow)
|
||||
}
|
||||
|
||||
// Authenticate heartbeat if it contains authorization info
|
||||
// data[1:] skips the command byte to get the raw Heartbeat structure
|
||||
var authorized byte = 0
|
||||
if len(data) > 1 {
|
||||
authResult := h.auth.AuthenticateHeartbeat(data[1:])
|
||||
if authResult.Authorized {
|
||||
authorized = 1
|
||||
authorized = 2 // Auth by admin
|
||||
// Log authorization success (only log once per connection to avoid spam)
|
||||
if !ctx.IsAuthorized.Load() {
|
||||
ctx.IsAuthorized.Store(true)
|
||||
@@ -228,10 +611,32 @@ func parsePorts(portStr string) ([]int, error) {
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
// splitCSV splits a comma-separated env-var value into trimmed, non-empty
|
||||
// entries. Returns nil for an empty input so callers can keep the natural
|
||||
// "no value → no restriction" semantics with a single nil check.
|
||||
func splitCSV(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse command line flags
|
||||
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
|
||||
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)")
|
||||
httpPort := flag.Int("http-port", 8080, "HTTP server port for web UI (0 to disable)")
|
||||
noConsole := flag.Bool("no-console", false, "Disable console output (for daemon mode)")
|
||||
flag.Parse()
|
||||
|
||||
@@ -267,6 +672,48 @@ func main() {
|
||||
// Create authenticator (shared by all servers)
|
||||
authenticator := auth.New(authCfg)
|
||||
|
||||
// Shared device registry — every TCP handler reports devices into it,
|
||||
// the HTTP server reads from it.
|
||||
deviceHub := hub.New()
|
||||
|
||||
// HMAC key used to sign the per-login CMD_MASTERSETTING reply. The
|
||||
// client verifies this signature before enabling its screen / file
|
||||
// features and aborts the process on mismatch. Kept in an env var so
|
||||
// the literal stays out of the binary; provision out-of-band and
|
||||
// never commit it.
|
||||
signPwd := os.Getenv("YAMA_SIGN_PASSWORD")
|
||||
if signPwd == "" {
|
||||
log.Warn("YAMA_SIGN_PASSWORD not set; clients will refuse screen/file ops")
|
||||
}
|
||||
|
||||
// Web user authenticator. Bootstrap admin from env var YAMA_WEB_ADMIN_PASS;
|
||||
// if unset, fall back to YAMA_PWD (same secret the TCP authorization uses)
|
||||
// so a single password env var is enough to bring up the whole stack.
|
||||
// If neither is set, no admin is registered and login will always fail —
|
||||
// the user must define a password before browsers can log in.
|
||||
webAuth := wsauth.New()
|
||||
adminPass := os.Getenv("YAMA_WEB_ADMIN_PASS")
|
||||
if adminPass == "" {
|
||||
adminPass = os.Getenv("YAMA_PWD")
|
||||
}
|
||||
if adminPass != "" {
|
||||
webAuth.AddAdminFromPlainPassword("admin", adminPass)
|
||||
log.Info("Web admin user configured")
|
||||
} else {
|
||||
log.Warn("Neither YAMA_WEB_ADMIN_PASS nor YAMA_PWD is set; web login will be unavailable")
|
||||
}
|
||||
|
||||
// Persistent users live in users.json next to the binary's working dir
|
||||
// — same default the C++ WebService uses (m_ConfigDir + "users.json").
|
||||
// Loading is best-effort: a missing file means "no extra users yet".
|
||||
usersFile := os.Getenv("YAMA_USERS_FILE")
|
||||
if usersFile == "" {
|
||||
usersFile = "users.json"
|
||||
}
|
||||
if err := webAuth.SetUsersFile(usersFile); err != nil {
|
||||
log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err)
|
||||
}
|
||||
|
||||
// Create servers for each port
|
||||
var servers []*server.Server
|
||||
for _, port := range ports {
|
||||
@@ -282,20 +729,66 @@ func main() {
|
||||
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
|
||||
auth: authenticator,
|
||||
srv: srv,
|
||||
hub: deviceHub,
|
||||
signPwd: signPwd,
|
||||
}
|
||||
srv.SetHandler(handler)
|
||||
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
|
||||
// Start all servers
|
||||
// Wire the hub's outbound sender once all TCP servers exist. Any server's
|
||||
// Send method will do — the per-connection encoder uses ctx-local state
|
||||
// and is independent of which server originally accepted the connection.
|
||||
if len(servers) > 0 {
|
||||
s := servers[0]
|
||||
deviceHub.SetSender(func(ctx *connection.Context, data []byte) error {
|
||||
return s.Send(ctx, data)
|
||||
})
|
||||
}
|
||||
|
||||
// Start all TCP servers
|
||||
for _, srv := range servers {
|
||||
if err := srv.Start(); err != nil {
|
||||
log.Fatal("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Web-UI hardening knobs for public-HTTPS deployment.
|
||||
//
|
||||
// YAMA_WEB_ALLOWED_ORIGINS: comma-separated Origin allowlist (e.g.
|
||||
// "https://yama.example.com,https://yama-mobile.example.com").
|
||||
// Empty (default) → only same-origin WS upgrades accepted, which
|
||||
// is correct when the web UI and WS endpoint share a host.
|
||||
//
|
||||
// Login rate limits are hard-coded at sensible defaults for the
|
||||
// small-user web UI: 10 attempts / minute per IP, 5 / 15 min per
|
||||
// username. The handler also injects a 250 ms delay on every failure
|
||||
// so online brute force is impractical even within budget.
|
||||
allowedOrigins := splitCSV(os.Getenv("YAMA_WEB_ALLOWED_ORIGINS"))
|
||||
trustProxy := os.Getenv("YAMA_WEB_TRUST_PROXY") == "1"
|
||||
if trustProxy {
|
||||
log.Info("Trusting X-Forwarded-For for client IP — make sure a reverse proxy is in front")
|
||||
}
|
||||
webCfg := web.Config{
|
||||
AllowedOrigins: allowedOrigins,
|
||||
LoginIPLimit: wsauth.NewRateLimiter(10, time.Minute),
|
||||
LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute),
|
||||
TrustForwardedFor: trustProxy,
|
||||
}
|
||||
|
||||
// Start HTTP server for web UI. Hub gives it read-only access to the
|
||||
// device registry; the authenticator owns user accounts and session tokens.
|
||||
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
|
||||
WithConfig(webCfg)
|
||||
if err := httpSrv.Start(); err != nil {
|
||||
log.Fatal("Failed to start HTTP server: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Server started on port(s): %v\n", ports)
|
||||
if *httpPort != 0 {
|
||||
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort)
|
||||
}
|
||||
fmt.Println("Logs are written to: logs/server.log")
|
||||
fmt.Println("Press Ctrl+C to stop...")
|
||||
|
||||
@@ -305,6 +798,7 @@ func main() {
|
||||
<-sigChan
|
||||
|
||||
fmt.Println("\nShutting down...")
|
||||
httpSrv.Stop()
|
||||
for _, srv := range servers {
|
||||
srv.Stop()
|
||||
}
|
||||
|
||||
@@ -86,6 +86,15 @@ const (
|
||||
// NewContext creates a new connection context
|
||||
func NewContext(conn net.Conn, mgr *Manager) *Context {
|
||||
now := time.Now()
|
||||
// Disable Nagle's algorithm. The protocol mixes tiny acks (ConnAuth,
|
||||
// HeartbeatAck, CMD_MASTERSETTING) with large frame bursts; with Nagle
|
||||
// on, those acks sit in the kernel buffer up to ~200 ms waiting for
|
||||
// more bytes, and combined with the peer's delayed-ACK that's enough
|
||||
// to make the screen handshake feel sluggish vs. the C++ server (which
|
||||
// sets TCP_NODELAY on every sub-context in ScreenSpyDlg).
|
||||
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tcp.SetNoDelay(true)
|
||||
}
|
||||
ctx := &Context{
|
||||
Conn: conn,
|
||||
RemoteAddr: conn.RemoteAddr().String(),
|
||||
|
||||
17
server/go/go.mod
Normal file
17
server/go/go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module github.com/yuanyuanxiang/SimpleRemoter/server/go
|
||||
|
||||
go 1.24.5
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/klauspost/compress v1.18.2
|
||||
github.com/rs/zerolog v1.34.0
|
||||
golang.org/x/text v0.32.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // 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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
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/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
|
||||
805
server/go/hub/hub.go
Normal file
805
server/go/hub/hub.go
Normal file
@@ -0,0 +1,805 @@
|
||||
// Package hub maintains the registry of currently online devices and acts as
|
||||
// the bridge between the TCP server (which sees raw client connections) and
|
||||
// the web server (which serves browser clients).
|
||||
//
|
||||
// The TCP side calls Register / Unregister / UpdateLive / BindScreenConn as
|
||||
// the protocol layer notices new sub-connections.
|
||||
// The web side calls ListDevices / SendToDevice / Subscribe.
|
||||
// Neither side imports the other — both depend only on this package.
|
||||
package hub
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||
)
|
||||
|
||||
// ErrDeviceOffline is returned by SendToDevice when the target device is not
|
||||
// (no longer) registered.
|
||||
var ErrDeviceOffline = errors.New("device offline")
|
||||
|
||||
// ErrNoSender is returned by SendToDevice if SetSender has not been called.
|
||||
var ErrNoSender = errors.New("hub sender not configured")
|
||||
|
||||
// SendFunc encodes-and-writes raw command bytes to a device's TCP context.
|
||||
// In practice this is bound to server.Server.Send at startup.
|
||||
type SendFunc func(ctx *connection.Context, data []byte) error
|
||||
|
||||
// Device is the internal record for one logical end-device (keyed by MasterID).
|
||||
// A single device may use multiple TCP sub-connections (screen, terminal …);
|
||||
// only the main login connection is stored here.
|
||||
//
|
||||
// PCName from LOGIN_INFOR is interpreted as "ComputerName/Group" and
|
||||
// ModuleVersion as "Version-Capability"; the split halves live in separate
|
||||
// fields so the front-end can render them independently.
|
||||
type Device struct {
|
||||
ID string // MasterID — stable identifier the client reports at login
|
||||
Name string // PCName before '/' (real computer name)
|
||||
Group string // PCName after '/' (group label; may be empty)
|
||||
Version string // ModuleVersion before '-' (semantic version)
|
||||
Capability string // ModuleVersion after '-' (capability tags; may be empty)
|
||||
OS string // OS version string
|
||||
CPU string // from LOGIN_INFOR reserved field 2
|
||||
FilePath string // from LOGIN_INFOR reserved field 4
|
||||
InstallTime string // from LOGIN_INFOR reserved field 6 (or StartTime)
|
||||
Location string // client-reported geo string (reserved field 10)
|
||||
PeerIP string // network-level remote address as seen by the server
|
||||
PublicIP string // client-reported public IP (reserved field 11)
|
||||
Resolution string // client-formatted screen geometry "N:W*H" (reserved field 15)
|
||||
ConnectedAt time.Time
|
||||
|
||||
// Live fields refreshed on every heartbeat. Protected by hub.mu.
|
||||
RTT int // network latency in ms (Heartbeat.Ping)
|
||||
ActiveWindow string // foreground window title (Heartbeat.ActiveWnd, decoded)
|
||||
|
||||
// conn is the main connection's context — used by SendToDevice to forward
|
||||
// commands (COMMAND_SCREEN_SPY, etc.) to the device.
|
||||
conn *connection.Context
|
||||
|
||||
// screenConn is the device-initiated sub-connection that streams
|
||||
// TOKEN_BITMAPINFO / TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN frames. Bound
|
||||
// after the device responds to COMMAND_SCREEN_SPY. Nil while no screen
|
||||
// session is active.
|
||||
screenConn *connection.Context
|
||||
|
||||
// Cached screen state, for late-joining browsers. Populated by the
|
||||
// PublishResolution / PublishScreenFrame call sites. screenWidth==0 or
|
||||
// lastKeyframe==nil indicates "no session" / "no IDR seen yet".
|
||||
screenWidth int
|
||||
screenHeight int
|
||||
lastKeyframe []byte // fully-packed WS binary packet of the most recent IDR
|
||||
|
||||
// Cursor index dedup: cursors arrive on every frame (~30 Hz) but only
|
||||
// rarely change. Suppress duplicates so the WS doesn't carry redundant
|
||||
// JSON messages.
|
||||
cursorSeen bool
|
||||
lastCursorIndex byte
|
||||
|
||||
// Terminal session state — at most one web terminal per device (MVP
|
||||
// constraint shared with the C++ server). All three fields are
|
||||
// guarded by hub.mu.
|
||||
//
|
||||
// terminalPending: COMMAND_SHELL has been sent, waiting for the device's
|
||||
// sub-conn to arrive and announce itself via TOKEN_TERMINAL_START /
|
||||
// TOKEN_SHELL_START.
|
||||
// terminalConn: the shell sub-conn ctx after binding. Nil before BIND
|
||||
// and after teardown.
|
||||
// terminalIsPTY: distinguishes Linux/macOS/ConPTY (true) from the legacy
|
||||
// Windows cmd-pipe path. PTY mode supports resize; cmd-pipe ignores it.
|
||||
terminalPending bool
|
||||
terminalConn *connection.Context
|
||||
terminalIsPTY bool
|
||||
}
|
||||
|
||||
// ScreenCache is a read-only snapshot of a device's last-seen screen state,
|
||||
// used by wsHub.handleConnect to bootstrap late joiners.
|
||||
type ScreenCache struct {
|
||||
Width int
|
||||
Height int
|
||||
Keyframe []byte // packed WS packet; nil if no keyframe cached yet
|
||||
Active bool // true iff a screen sub-conn is currently bound
|
||||
}
|
||||
|
||||
// ScreenState returns a snapshot of the device's current screen state, or
|
||||
// an empty struct if the device is unknown. Safe to call from any goroutine.
|
||||
func (h *Hub) ScreenState(deviceID string) ScreenCache {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
d, ok := h.devices[deviceID]
|
||||
if !ok {
|
||||
return ScreenCache{}
|
||||
}
|
||||
return ScreenCache{
|
||||
Width: d.screenWidth,
|
||||
Height: d.screenHeight,
|
||||
Keyframe: d.lastKeyframe,
|
||||
Active: d.screenConn != nil,
|
||||
}
|
||||
}
|
||||
|
||||
// MainConn exposes the device's main TCP context for callers that need to
|
||||
// send commands directly. Returns nil if the device is not registered.
|
||||
func (h *Hub) MainConn(id string) *connection.Context {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
if d, ok := h.devices[id]; ok {
|
||||
return d.conn
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Capability returns the device's reported capability hex string
|
||||
// (LOGIN_INFOR.moduleVersion tail). Empty for unknown devices — callers
|
||||
// should treat that as "no caps" (legacy Windows GBK default).
|
||||
func (h *Hub) Capability(id string) string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
if d, ok := h.devices[id]; ok {
|
||||
return d.Capability
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DeviceInfo is the JSON-safe projection of Device for the /api/devices
|
||||
// endpoint and the WS device_list message. Field names match what the
|
||||
// existing browser front-end expects.
|
||||
type DeviceInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Capability string `json:"capability,omitempty"`
|
||||
OS string `json:"os"`
|
||||
CPU string `json:"cpu,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
InstallTime string `json:"install_time,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
IP string `json:"ip"` // client-reported public IP (matches C++ key)
|
||||
PeerIP string `json:"peer_ip,omitempty"`
|
||||
Screen string `json:"screen,omitempty"` // "N:W*H" — matches C++ DeviceInfo.screen key
|
||||
RTT int `json:"rtt"`
|
||||
ActiveWindow string `json:"activeWindow,omitempty"`
|
||||
ConnectedAt int64 `json:"connected_at"`
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
|
||||
// EventHandler receives notifications about device lifecycle, per-tick live
|
||||
// updates, screen frames and resolution changes. Methods are invoked
|
||||
// synchronously from the corresponding hub mutator — implementations must
|
||||
// be non-blocking (typically just write to a channel or queue).
|
||||
type EventHandler interface {
|
||||
OnDeviceOnline(d DeviceInfo)
|
||||
OnDeviceOffline(id string)
|
||||
OnDeviceUpdate(id string, rtt int, activeWindow string)
|
||||
// OnScreenFrame delivers a fully-formed WS binary packet for the given
|
||||
// device. The packet matches the C++ layout:
|
||||
// [DeviceID:4 LE][FrameType:1][DataLen:4 LE][H264:N]
|
||||
// Implementations should treat the slice as read-only.
|
||||
OnScreenFrame(deviceID string, packet []byte, isKeyframe bool)
|
||||
// OnResolutionChange fires when a screen session starts (TOKEN_BITMAPINFO)
|
||||
// or whenever the device reports a new screen geometry mid-stream.
|
||||
OnResolutionChange(deviceID string, width, height int)
|
||||
// OnCursorChange fires when the device's foreground cursor index changes.
|
||||
// Duplicates (same index as the previous frame) are filtered out by the
|
||||
// hub before reaching subscribers.
|
||||
OnCursorChange(deviceID string, index byte)
|
||||
// OnTerminalReady fires once the device's shell sub-conn is bound and
|
||||
// the server has sent COMMAND_NEXT to start its output read loop.
|
||||
// isPTY=true means PTY mode (Linux/macOS or ConPTY); false means the
|
||||
// legacy Windows cmd-pipe path which doesn't support resize.
|
||||
OnTerminalReady(deviceID string, isPTY bool)
|
||||
// OnTerminalData ships one chunk of raw shell output (already wrapped
|
||||
// in the WS-binary "TRM1" magic header) to terminal viewers.
|
||||
OnTerminalData(deviceID string, packet []byte)
|
||||
// OnTerminalClosed fires when the session ends — either because the
|
||||
// device sent TOKEN_TERMINAL_CLOSE, the sub-conn dropped, or the
|
||||
// server explicitly tore it down.
|
||||
OnTerminalClosed(deviceID string, reason string)
|
||||
}
|
||||
|
||||
// Hub is a thread-safe registry of online devices.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
devices map[string]*Device
|
||||
subMu sync.RWMutex
|
||||
subscribers []EventHandler
|
||||
|
||||
sender SendFunc
|
||||
|
||||
// Reverse index: TCP context -> device ID for the device's screen
|
||||
// sub-connection. Lets us clean up on raw-connection close without
|
||||
// having to walk every device. Empty when no screen sessions exist.
|
||||
screenIndex map[*connection.Context]string
|
||||
screenIndexMu sync.RWMutex
|
||||
|
||||
// Parallel reverse index for terminal sub-conns. Same purpose: O(1)
|
||||
// lookup from a raw ctx (e.g. on OnDisconnect) back to its device.
|
||||
terminalIndex map[*connection.Context]string
|
||||
terminalIndexMu sync.RWMutex
|
||||
}
|
||||
|
||||
// New returns an empty Hub.
|
||||
func New() *Hub {
|
||||
return &Hub{
|
||||
devices: make(map[string]*Device),
|
||||
screenIndex: make(map[*connection.Context]string),
|
||||
terminalIndex: make(map[*connection.Context]string),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSender wires the function used to deliver outbound bytes on a device's
|
||||
// main TCP connection. Typically called once in main() with server.Send.
|
||||
func (h *Hub) SetSender(fn SendFunc) {
|
||||
h.sender = fn
|
||||
}
|
||||
|
||||
// SendToDevice forwards an already-formed command payload to the device's
|
||||
// main connection. data should be the raw command bytes (the sender takes
|
||||
// care of framing / compression at the protocol layer).
|
||||
func (h *Hub) SendToDevice(id string, data []byte) error {
|
||||
h.mu.RLock()
|
||||
d, ok := h.devices[id]
|
||||
h.mu.RUnlock()
|
||||
if !ok || d.conn == nil {
|
||||
return ErrDeviceOffline
|
||||
}
|
||||
if h.sender == nil {
|
||||
return ErrNoSender
|
||||
}
|
||||
return h.sender(d.conn, data)
|
||||
}
|
||||
|
||||
// SendToScreen routes a payload to the device's currently-bound screen
|
||||
// sub-connection. Input events (COMMAND_SCREEN_CONTROL) MUST go through the
|
||||
// screen sub-conn rather than the main conn — the C++ client only dispatches
|
||||
// these commands from CScreenManager::OnReceive, which reads exclusively from
|
||||
// the sub-conn (see client/ScreenManager.cpp:1065). Returns ErrDeviceOffline
|
||||
// when the device is unknown OR has no active screen session, so callers can
|
||||
// quietly drop input from browsers that haven't called connect yet.
|
||||
func (h *Hub) SendToScreen(id string, data []byte) error {
|
||||
h.mu.RLock()
|
||||
d, ok := h.devices[id]
|
||||
var sc *connection.Context
|
||||
if ok {
|
||||
sc = d.screenConn
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
if !ok || sc == nil {
|
||||
return ErrDeviceOffline
|
||||
}
|
||||
if h.sender == nil {
|
||||
return ErrNoSender
|
||||
}
|
||||
return h.sender(sc, data)
|
||||
}
|
||||
|
||||
// BindScreenConn associates a freshly-arrived sub-connection (the one that
|
||||
// just sent TOKEN_BITMAPINFO) with the device identified by clientID.
|
||||
// Returns false if the device is not registered — callers should drop the
|
||||
// orphan connection in that case.
|
||||
func (h *Hub) BindScreenConn(deviceID string, ctx *connection.Context) bool {
|
||||
if deviceID == "" || ctx == nil {
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
d, ok := h.devices[deviceID]
|
||||
if !ok {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
d.screenConn = ctx
|
||||
h.mu.Unlock()
|
||||
|
||||
h.screenIndexMu.Lock()
|
||||
h.screenIndex[ctx] = deviceID
|
||||
h.screenIndexMu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// ScreenDeviceID returns the device ID whose screen sub-connection this
|
||||
// context represents, or "" if the context is not a screen sub-connection.
|
||||
// Used by the TCP layer to route TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN frames.
|
||||
func (h *Hub) ScreenDeviceID(ctx *connection.Context) string {
|
||||
h.screenIndexMu.RLock()
|
||||
defer h.screenIndexMu.RUnlock()
|
||||
return h.screenIndex[ctx]
|
||||
}
|
||||
|
||||
// UnbindScreenConn removes the screen sub-connection mapping (called on TCP
|
||||
// disconnect of a screen sub-context). No-op if the context isn't tracked.
|
||||
func (h *Hub) UnbindScreenConn(ctx *connection.Context) {
|
||||
h.screenIndexMu.Lock()
|
||||
deviceID, ok := h.screenIndex[ctx]
|
||||
if !ok {
|
||||
h.screenIndexMu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(h.screenIndex, ctx)
|
||||
h.screenIndexMu.Unlock()
|
||||
|
||||
h.mu.Lock()
|
||||
if d, ok := h.devices[deviceID]; ok && d.screenConn == ctx {
|
||||
d.screenConn = nil
|
||||
// Clear the cache too — when this device's screen comes back up, the
|
||||
// resolution and IDR will be republished fresh.
|
||||
d.screenWidth = 0
|
||||
d.screenHeight = 0
|
||||
d.lastKeyframe = nil
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// Subscribe registers an EventHandler. The returned func removes it.
|
||||
// Multiple handlers are supported; each receives every event.
|
||||
func (h *Hub) Subscribe(eh EventHandler) (unsubscribe func()) {
|
||||
h.subMu.Lock()
|
||||
h.subscribers = append(h.subscribers, eh)
|
||||
h.subMu.Unlock()
|
||||
return func() {
|
||||
h.subMu.Lock()
|
||||
defer h.subMu.Unlock()
|
||||
for i, x := range h.subscribers {
|
||||
if x == eh {
|
||||
h.subscribers = append(h.subscribers[:i], h.subscribers[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) snapshotSubscribers() []EventHandler {
|
||||
h.subMu.RLock()
|
||||
defer h.subMu.RUnlock()
|
||||
out := make([]EventHandler, len(h.subscribers))
|
||||
copy(out, h.subscribers)
|
||||
return out
|
||||
}
|
||||
|
||||
// Register records a device as online and pins the main TCP connection that
|
||||
// will receive outbound commands via SendToDevice. Re-registering an existing
|
||||
// ID overwrites the previous entry (e.g. a client reconnect with the same
|
||||
// MasterID). A nil device, nil conn, or empty ID is silently ignored.
|
||||
// Subscribers are notified after the device is added.
|
||||
func (h *Hub) Register(d *Device, conn *connection.Context) {
|
||||
if d == nil || d.ID == "" || conn == nil {
|
||||
return
|
||||
}
|
||||
d.conn = conn
|
||||
h.mu.Lock()
|
||||
h.devices[d.ID] = d
|
||||
info := deviceToInfo(d)
|
||||
h.mu.Unlock()
|
||||
for _, s := range h.snapshotSubscribers() {
|
||||
s.OnDeviceOnline(info)
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister removes a device by ID. No-op if not present.
|
||||
// Subscribers are notified after the device is removed (only if it existed).
|
||||
func (h *Hub) Unregister(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
_, existed := h.devices[id]
|
||||
delete(h.devices, id)
|
||||
h.mu.Unlock()
|
||||
if !existed {
|
||||
return
|
||||
}
|
||||
for _, s := range h.snapshotSubscribers() {
|
||||
s.OnDeviceOffline(id)
|
||||
}
|
||||
}
|
||||
|
||||
// ListDevices returns a fresh snapshot slice. The caller may mutate it freely;
|
||||
// it shares no state with the hub.
|
||||
func (h *Hub) ListDevices() []DeviceInfo {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
out := make([]DeviceInfo, 0, len(h.devices))
|
||||
for _, d := range h.devices {
|
||||
out = append(out, deviceToInfo(d))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deviceToInfo(d *Device) DeviceInfo {
|
||||
return DeviceInfo{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Group: d.Group,
|
||||
Version: d.Version,
|
||||
Capability: d.Capability,
|
||||
OS: d.OS,
|
||||
CPU: d.CPU,
|
||||
FilePath: d.FilePath,
|
||||
InstallTime: d.InstallTime,
|
||||
Location: d.Location,
|
||||
IP: d.PublicIP,
|
||||
PeerIP: d.PeerIP,
|
||||
Screen: d.Resolution,
|
||||
RTT: d.RTT,
|
||||
ActiveWindow: d.ActiveWindow,
|
||||
ConnectedAt: d.ConnectedAt.Unix(),
|
||||
Online: true, // a device that's in the map is by definition online
|
||||
}
|
||||
}
|
||||
|
||||
// PublishCursor notifies subscribers when the device reports a new cursor
|
||||
// index. Repeated identical indices are suppressed so the WS isn't spammed
|
||||
// with per-frame cursor JSON. No-op for unknown devices.
|
||||
func (h *Hub) PublishCursor(deviceID string, index byte) {
|
||||
h.mu.Lock()
|
||||
d, ok := h.devices[deviceID]
|
||||
if !ok {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if d.cursorSeen && d.lastCursorIndex == index {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
d.cursorSeen = true
|
||||
d.lastCursorIndex = index
|
||||
h.mu.Unlock()
|
||||
for _, s := range h.snapshotSubscribers() {
|
||||
s.OnCursorChange(deviceID, index)
|
||||
}
|
||||
}
|
||||
|
||||
// CloseScreen tears down the active screen sub-connection for the device,
|
||||
// if any. Used when the last viewer leaves so the device stops capturing.
|
||||
//
|
||||
// Cache (screenConn / screenWidth / lastKeyframe) is cleared SYNCHRONOUSLY
|
||||
// here, not deferred to the eventual OnDisconnect → UnbindScreenConn path.
|
||||
// Otherwise a new viewer arriving in the brief window between TCP close and
|
||||
// the disconnect callback would see Active=true with stale dimensions/IDR
|
||||
// and skip the COMMAND_SCREEN_SPY kick, leaving the page stuck on a "connected"
|
||||
// status with no frames ever arriving.
|
||||
func (h *Hub) CloseScreen(deviceID string) {
|
||||
h.mu.Lock()
|
||||
d, ok := h.devices[deviceID]
|
||||
if !ok {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
sc := d.screenConn
|
||||
d.screenConn = nil
|
||||
d.screenWidth = 0
|
||||
d.screenHeight = 0
|
||||
d.lastKeyframe = nil
|
||||
h.mu.Unlock()
|
||||
if sc != nil {
|
||||
// Drop the screenIndex entry SYNCHRONOUSLY so any in-flight frames
|
||||
// still draining out of the device on this sub-conn (between our
|
||||
// FIN and the device's clean-up) are silently dropped instead of
|
||||
// being relayed to the freshly initialized browser decoder. Mixing
|
||||
// frames from the old x264 SPS/PPS sequence with the new session's
|
||||
// decoder produces the classic "every other quick reconnect goes
|
||||
// black" symptom — old NAL units come in via the old ctx after we
|
||||
// nulled d.screenConn but before OnDisconnect fires.
|
||||
h.screenIndexMu.Lock()
|
||||
delete(h.screenIndex, sc)
|
||||
h.screenIndexMu.Unlock()
|
||||
|
||||
// Tell the client to shut its screen pipeline down gracefully.
|
||||
// Without this, the client's IOCPClient sees recv()==0 as a network
|
||||
// blip and fires m_ReconnectFunc, which:
|
||||
// 1. Reconnects the sub-conn (~100 ms)
|
||||
// 2. Re-sends ConnAuthPacket (no BITMAPINFO!)
|
||||
// 3. Keeps the capture thread alive for ~10 s holding DXGI handles
|
||||
// 4. ConnAuth eventually times out, ScreenManager exits
|
||||
// Net effect: a second viewer arriving within ~10 s of leaving lands
|
||||
// in the dead window where the device is still capturing for the old
|
||||
// (now unrouted) sub-conn — page sits on "Waiting for video".
|
||||
//
|
||||
// COMMAND_BYE is what the C++ server sends via
|
||||
// CDialogBase::SayByeBye (server/2015Remote/IOCPServer.h:248) before
|
||||
// it tears down a sub-conn for the same reason. Client-side handler:
|
||||
// CScreenManager::OnReceive case COMMAND_BYE
|
||||
// (client/ScreenManager.cpp:812) sets m_bIsWorking=FALSE and calls
|
||||
// StopRunning() — the clean exit path that does NOT trigger reconnect.
|
||||
if h.sender != nil {
|
||||
_ = h.sender(sc, []byte{protocol.CommandBye})
|
||||
}
|
||||
// Mirror the C++ flow (ScreenSpyDlg.cpp:842 — Sleep(500); CancelIO()).
|
||||
// Give the device's read loop a moment to pull COMMAND_BYE off the
|
||||
// wire before our FIN arrives; otherwise on a fast LAN the BYE byte
|
||||
// can be coalesced with the FIN and the client's IOCPClient may
|
||||
// observe recv()==0 first and trigger reconnect anyway.
|
||||
// Run the close on a goroutine so the caller (web handler) isn't
|
||||
// blocked for 500 ms. screenIndex is already cleared above, so
|
||||
// in-flight frames during the grace window are silently dropped.
|
||||
go func(c *connection.Context) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
c.Close()
|
||||
}(sc)
|
||||
}
|
||||
}
|
||||
|
||||
// PublishResolution announces a new (or first-ever) screen geometry for a
|
||||
// device. The browser uses width/height to initialize its WebCodecs decoder.
|
||||
// The latest dimensions are also cached on the Device so future late-joining
|
||||
// viewers can be bootstrapped without waiting for the next BITMAPINFO.
|
||||
func (h *Hub) PublishResolution(deviceID string, width, height int) {
|
||||
h.mu.Lock()
|
||||
if d, ok := h.devices[deviceID]; ok {
|
||||
d.screenWidth = width
|
||||
d.screenHeight = height
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for _, s := range h.snapshotSubscribers() {
|
||||
s.OnResolutionChange(deviceID, width, height)
|
||||
}
|
||||
}
|
||||
|
||||
// PublishScreenFrame fans out a screen frame packet to all subscribers.
|
||||
// Callers must have already wrapped the H.264 NAL payload in the
|
||||
// [DeviceID:4][FrameType:1][DataLen:4][...] header expected by the browser.
|
||||
// The packet slice is shared with subscribers — do not mutate after publish.
|
||||
//
|
||||
// Keyframe packets are also retained on the Device record so a new viewer
|
||||
// joining a live session can immediately receive a decodable starting point
|
||||
// instead of waiting up to ~15 s for the next IDR.
|
||||
func (h *Hub) PublishScreenFrame(deviceID string, packet []byte, isKeyframe bool) {
|
||||
if isKeyframe {
|
||||
h.mu.Lock()
|
||||
if d, ok := h.devices[deviceID]; ok {
|
||||
d.lastKeyframe = packet
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
for _, s := range h.snapshotSubscribers() {
|
||||
s.OnScreenFrame(deviceID, packet, isKeyframe)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateLive applies a heartbeat-derived RTT and active-window title to the
|
||||
// device's live fields, then notifies subscribers. No-op if the device is
|
||||
// not registered (e.g. heartbeat arriving for a connection that never sent
|
||||
// TOKEN_LOGIN or has already disconnected).
|
||||
func (h *Hub) UpdateLive(id string, rtt int, activeWindow string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
d, ok := h.devices[id]
|
||||
if !ok {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
d.RTT = rtt
|
||||
d.ActiveWindow = activeWindow
|
||||
h.mu.Unlock()
|
||||
for _, s := range h.snapshotSubscribers() {
|
||||
s.OnDeviceUpdate(id, rtt, activeWindow)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Terminal session management (Phase 6) --------------------------------
|
||||
|
||||
// ErrTerminalBusy is returned by OpenTerminalSession when the device already
|
||||
// has a pending or active terminal session — MVP enforces single-viewer.
|
||||
var ErrTerminalBusy = errors.New("terminal already open by another viewer")
|
||||
|
||||
// OpenTerminalSession atomically marks a terminal session as pending for the
|
||||
// device, then sends COMMAND_SHELL on the main TCP connection so the device
|
||||
// will spawn a shell sub-conn. Returns nil if the request was sent. On any
|
||||
// failure the pending flag is rolled back so retries are possible.
|
||||
//
|
||||
// Single-viewer constraint: if a pending or bound session already exists,
|
||||
// returns ErrTerminalBusy. Mirrors C++ CWebService::HandleTermOpen
|
||||
// (server/2015Remote/WebService.cpp:1838).
|
||||
func (h *Hub) OpenTerminalSession(deviceID string) error {
|
||||
if deviceID == "" {
|
||||
return ErrDeviceOffline
|
||||
}
|
||||
h.mu.Lock()
|
||||
d, ok := h.devices[deviceID]
|
||||
if !ok || d.conn == nil {
|
||||
h.mu.Unlock()
|
||||
return ErrDeviceOffline
|
||||
}
|
||||
if d.terminalPending || d.terminalConn != nil {
|
||||
h.mu.Unlock()
|
||||
return ErrTerminalBusy
|
||||
}
|
||||
d.terminalPending = true
|
||||
mainConn := d.conn
|
||||
h.mu.Unlock()
|
||||
|
||||
if h.sender == nil {
|
||||
// Roll back so a retry isn't permanently blocked.
|
||||
h.mu.Lock()
|
||||
d.terminalPending = false
|
||||
h.mu.Unlock()
|
||||
return ErrNoSender
|
||||
}
|
||||
if err := h.sender(mainConn, []byte{protocol.CommandShell}); err != nil {
|
||||
h.mu.Lock()
|
||||
d.terminalPending = false
|
||||
h.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsTerminalPending tells the TCP layer whether the next-arriving shell
|
||||
// sub-conn should be claimed by the web terminal. The C++ side uses this
|
||||
// in MessageHandle to decide between WebService takeover and opening an
|
||||
// MFC dialog (server/2015Remote/2015RemoteDlg.cpp:5753).
|
||||
func (h *Hub) IsTerminalPending(deviceID string) bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
d, ok := h.devices[deviceID]
|
||||
return ok && d.terminalPending
|
||||
}
|
||||
|
||||
// BindTerminalConn promotes the pending session to an active one by
|
||||
// associating the device's freshly-arrived shell sub-conn. Returns false
|
||||
// if no pending session exists — callers should drop the orphan ctx.
|
||||
//
|
||||
// Subscribers receive OnTerminalReady AFTER binding so they can flip the
|
||||
// browser into "ready" state immediately on the same TCP roundtrip that
|
||||
// will deliver the first shell output.
|
||||
func (h *Hub) BindTerminalConn(deviceID string, ctx *connection.Context, isPTY bool) bool {
|
||||
if deviceID == "" || ctx == nil {
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
d, ok := h.devices[deviceID]
|
||||
if !ok || !d.terminalPending {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
d.terminalConn = ctx
|
||||
d.terminalIsPTY = isPTY
|
||||
d.terminalPending = false
|
||||
h.mu.Unlock()
|
||||
|
||||
h.terminalIndexMu.Lock()
|
||||
h.terminalIndex[ctx] = deviceID
|
||||
h.terminalIndexMu.Unlock()
|
||||
|
||||
for _, s := range h.snapshotSubscribers() {
|
||||
s.OnTerminalReady(deviceID, isPTY)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TerminalDeviceID returns the device ID whose terminal sub-conn this
|
||||
// context belongs to, or "" otherwise. The TCP layer uses this on every
|
||||
// inbound packet on a sub-conn — when non-empty, the bytes are raw shell
|
||||
// output and bypass the usual command-byte switch.
|
||||
func (h *Hub) TerminalDeviceID(ctx *connection.Context) string {
|
||||
h.terminalIndexMu.RLock()
|
||||
defer h.terminalIndexMu.RUnlock()
|
||||
return h.terminalIndex[ctx]
|
||||
}
|
||||
|
||||
// UnbindTerminalConn removes the terminal mapping (called from the TCP
|
||||
// disconnect path for any sub-conn ctx). Fires OnTerminalClosed once if
|
||||
// the unbind actually removed something — so subscribers can update the
|
||||
// browser even on unexpected device-side drops.
|
||||
func (h *Hub) UnbindTerminalConn(ctx *connection.Context) {
|
||||
h.terminalIndexMu.Lock()
|
||||
deviceID, tracked := h.terminalIndex[ctx]
|
||||
if !tracked {
|
||||
h.terminalIndexMu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(h.terminalIndex, ctx)
|
||||
h.terminalIndexMu.Unlock()
|
||||
|
||||
h.mu.Lock()
|
||||
if d, ok := h.devices[deviceID]; ok && d.terminalConn == ctx {
|
||||
d.terminalConn = nil
|
||||
d.terminalPending = false
|
||||
d.terminalIsPTY = false
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, s := range h.snapshotSubscribers() {
|
||||
s.OnTerminalClosed(deviceID, "disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
// SendToTerminal forwards bytes (typically xterm.js keystrokes) to the
|
||||
// device's shell sub-conn. Returns ErrDeviceOffline if no session is
|
||||
// active for this device.
|
||||
func (h *Hub) SendToTerminal(id string, data []byte) error {
|
||||
h.mu.RLock()
|
||||
d, ok := h.devices[id]
|
||||
var tc *connection.Context
|
||||
if ok {
|
||||
tc = d.terminalConn
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
if !ok || tc == nil {
|
||||
return ErrDeviceOffline
|
||||
}
|
||||
if h.sender == nil {
|
||||
return ErrNoSender
|
||||
}
|
||||
return h.sender(tc, data)
|
||||
}
|
||||
|
||||
// TerminalIsPTY reports whether the active session is PTY mode (the
|
||||
// resize command only applies in PTY mode — legacy cmd-pipe ignores it).
|
||||
func (h *Hub) TerminalIsPTY(id string) bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
d, ok := h.devices[id]
|
||||
return ok && d.terminalConn != nil && d.terminalIsPTY
|
||||
}
|
||||
|
||||
// CloseTerminalSession tears down the session from the server side
|
||||
// (typically when the requesting browser sends term_close or disconnects).
|
||||
// Mirrors CloseScreen's graceful pattern: drop the index synchronously,
|
||||
// send COMMAND_BYE, then close after a short grace period so the client's
|
||||
// IOCPClient reconnect logic doesn't fire.
|
||||
func (h *Hub) CloseTerminalSession(deviceID string) {
|
||||
h.mu.Lock()
|
||||
d, ok := h.devices[deviceID]
|
||||
if !ok {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
tc := d.terminalConn
|
||||
// hadSession guards against firing spurious OnTerminalClosed events
|
||||
// when there was nothing to tear down — relevant when the main-conn
|
||||
// teardown path calls CloseTerminalSession unconditionally as part of
|
||||
// device-offline cleanup, or when both OnDisconnect and an explicit
|
||||
// browser term_close race for the same teardown.
|
||||
hadSession := tc != nil || d.terminalPending
|
||||
d.terminalConn = nil
|
||||
d.terminalPending = false
|
||||
d.terminalIsPTY = false
|
||||
h.mu.Unlock()
|
||||
|
||||
if !hadSession {
|
||||
return
|
||||
}
|
||||
|
||||
for _, s := range h.snapshotSubscribers() {
|
||||
s.OnTerminalClosed(deviceID, "closed")
|
||||
}
|
||||
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
h.terminalIndexMu.Lock()
|
||||
delete(h.terminalIndex, tc)
|
||||
h.terminalIndexMu.Unlock()
|
||||
|
||||
// Mirror Hub.CloseScreen: send COMMAND_BYE then close after 500 ms so
|
||||
// the device exits its shell read loop instead of treating the FIN as
|
||||
// a network blip and triggering reconnect.
|
||||
if h.sender != nil {
|
||||
_ = h.sender(tc, []byte{protocol.CommandBye})
|
||||
}
|
||||
go func(c *connection.Context) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
c.Close()
|
||||
}(tc)
|
||||
}
|
||||
|
||||
// PublishTerminalData fans out one chunk of shell output to subscribers.
|
||||
// Caller has already wrapped it in the "TRM1" magic header so the browser
|
||||
// can demultiplex from screen frames over the shared WebSocket.
|
||||
func (h *Hub) PublishTerminalData(deviceID string, packet []byte) {
|
||||
for _, s := range h.snapshotSubscribers() {
|
||||
s.OnTerminalData(deviceID, packet)
|
||||
}
|
||||
}
|
||||
|
||||
// Count returns the current number of online devices.
|
||||
func (h *Hub) Count() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.devices)
|
||||
}
|
||||
171
server/go/hub/hub_test.go
Normal file
171
server/go/hub/hub_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||
)
|
||||
|
||||
// stubCtx returns a non-nil *connection.Context useful only as a sentinel.
|
||||
// Tests never invoke Send / Close on it.
|
||||
func stubCtx() *connection.Context { return &connection.Context{} }
|
||||
|
||||
func TestHubRegisterListUnregister(t *testing.T) {
|
||||
h := New()
|
||||
if got := h.Count(); got != 0 {
|
||||
t.Fatalf("empty hub: want Count=0, got %d", got)
|
||||
}
|
||||
|
||||
h.Register(&Device{ID: "a", Name: "Alice", ConnectedAt: time.Now()}, stubCtx())
|
||||
h.Register(&Device{ID: "b", Name: "Bob", ConnectedAt: time.Now()}, stubCtx())
|
||||
if got := h.Count(); got != 2 {
|
||||
t.Fatalf("after 2 registers: want Count=2, got %d", got)
|
||||
}
|
||||
|
||||
list := h.ListDevices()
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("want 2 devices in list, got %d", len(list))
|
||||
}
|
||||
|
||||
h.Unregister("a")
|
||||
if got := h.Count(); got != 1 {
|
||||
t.Fatalf("after unregister: want Count=1, got %d", got)
|
||||
}
|
||||
|
||||
// Unregister non-existent ID is a no-op
|
||||
h.Unregister("ghost")
|
||||
if got := h.Count(); got != 1 {
|
||||
t.Fatalf("after no-op unregister: want Count=1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubNilAndEmptyIgnored(t *testing.T) {
|
||||
h := New()
|
||||
h.Register(nil, stubCtx())
|
||||
h.Register(&Device{ID: ""}, stubCtx())
|
||||
h.Register(&Device{ID: "valid"}, nil) // nil conn should also be rejected
|
||||
h.Unregister("")
|
||||
if got := h.Count(); got != 0 {
|
||||
t.Fatalf("nil/empty register should be no-op, got Count=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type captureHandler struct {
|
||||
mu sync.Mutex
|
||||
online []string
|
||||
offline []string
|
||||
updates []string // formatted "id:rtt"
|
||||
}
|
||||
|
||||
func (c *captureHandler) OnDeviceOnline(d DeviceInfo) {
|
||||
c.mu.Lock()
|
||||
c.online = append(c.online, d.ID)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *captureHandler) OnDeviceOffline(id string) {
|
||||
c.mu.Lock()
|
||||
c.offline = append(c.offline, id)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *captureHandler) OnDeviceUpdate(id string, rtt int, _ string) {
|
||||
c.mu.Lock()
|
||||
c.updates = append(c.updates, fmt.Sprintf("%s:%d", id, rtt))
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *captureHandler) OnScreenFrame(_ string, _ []byte, _ bool) {}
|
||||
|
||||
func (c *captureHandler) OnResolutionChange(_ string, _, _ int) {}
|
||||
|
||||
func (c *captureHandler) OnCursorChange(_ string, _ byte) {}
|
||||
|
||||
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
|
||||
|
||||
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
|
||||
|
||||
func (c *captureHandler) OnTerminalClosed(_ string, _ string) {}
|
||||
|
||||
func TestHubSubscribeEvents(t *testing.T) {
|
||||
h := New()
|
||||
c := &captureHandler{}
|
||||
unsub := h.Subscribe(c)
|
||||
|
||||
h.Register(&Device{ID: "x", Name: "x"}, stubCtx())
|
||||
h.Register(&Device{ID: "y", Name: "y"}, stubCtx())
|
||||
h.Unregister("x")
|
||||
h.Unregister("nonexistent") // no event
|
||||
|
||||
if len(c.online) != 2 || c.online[0] != "x" || c.online[1] != "y" {
|
||||
t.Fatalf("online events: %+v", c.online)
|
||||
}
|
||||
if len(c.offline) != 1 || c.offline[0] != "x" {
|
||||
t.Fatalf("offline events: %+v", c.offline)
|
||||
}
|
||||
|
||||
unsub()
|
||||
h.Register(&Device{ID: "z"}, stubCtx())
|
||||
if len(c.online) != 2 {
|
||||
t.Fatalf("after unsubscribe should not receive events: %+v", c.online)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubUpdateLive(t *testing.T) {
|
||||
h := New()
|
||||
c := &captureHandler{}
|
||||
h.Subscribe(c)
|
||||
|
||||
h.Register(&Device{ID: "x", Name: "x"}, stubCtx())
|
||||
h.UpdateLive("x", 42, "Notepad")
|
||||
h.UpdateLive("ghost", 999, "should be ignored") // unknown id, no event
|
||||
|
||||
if len(c.updates) != 1 || c.updates[0] != "x:42" {
|
||||
t.Fatalf("updates: %+v", c.updates)
|
||||
}
|
||||
|
||||
list := h.ListDevices()
|
||||
if list[0].RTT != 42 || list[0].ActiveWindow != "Notepad" {
|
||||
t.Fatalf("live fields not applied: %+v", list[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubRegisterOverwrites(t *testing.T) {
|
||||
h := New()
|
||||
h.Register(&Device{ID: "x", Name: "first"}, stubCtx())
|
||||
h.Register(&Device{ID: "x", Name: "second"}, stubCtx())
|
||||
list := h.ListDevices()
|
||||
if len(list) != 1 || list[0].Name != "second" {
|
||||
t.Fatalf("re-register should overwrite, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
// Race detector should not fire under `go test -race ./hub/...`.
|
||||
func TestHubConcurrent(t *testing.T) {
|
||||
h := New()
|
||||
const goroutines = 50
|
||||
const opsPer = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := range goroutines {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := range opsPer {
|
||||
id := fmt.Sprintf("g%d-%d", g, i)
|
||||
h.Register(&Device{ID: id, Name: id, ConnectedAt: time.Now()}, stubCtx())
|
||||
_ = h.ListDevices()
|
||||
_ = h.Count()
|
||||
h.Unregister(id)
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := h.Count(); got != 0 {
|
||||
t.Fatalf("after all unregisters: want 0, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,22 @@ package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// gbkToUTF8 converts GBK encoded bytes to UTF-8 string
|
||||
func gbkToUTF8(data []byte) string {
|
||||
// GbkToUTF8 converts GBK encoded bytes to UTF-8 string. The input is treated
|
||||
// as a null-terminated GBK buffer (typical for Windows clients); content
|
||||
// after the first NUL byte is discarded. Non-printable characters are
|
||||
// stripped from the result.
|
||||
func GbkToUTF8(data []byte) string {
|
||||
// Find the first null byte and truncate there
|
||||
if idx := bytes.IndexByte(data, 0); idx >= 0 {
|
||||
data = data[:idx]
|
||||
@@ -30,6 +37,21 @@ func gbkToUTF8(data []byte) string {
|
||||
return cleanString(buf.String())
|
||||
}
|
||||
|
||||
// Utf8CleanString trims at the first NUL and strips non-printables — the
|
||||
// UTF-8 counterpart of GbkToUTF8 for clients that have the CLIENT_CAP_UTF8
|
||||
// capability bit. Decoding as GBK in that case would mangle multi-byte
|
||||
// sequences (the C++ comment at WebService.cpp:1530 calls out this exact
|
||||
// "double-encoding" footgun).
|
||||
func Utf8CleanString(data []byte) string {
|
||||
if idx := bytes.IndexByte(data, 0); idx >= 0 {
|
||||
data = data[:idx]
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
return cleanString(string(data))
|
||||
}
|
||||
|
||||
// cleanString removes non-printable characters except common whitespace
|
||||
func cleanString(s string) string {
|
||||
var result strings.Builder
|
||||
@@ -41,10 +63,52 @@ func cleanString(s string) string {
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// Command tokens - matching the C++ definitions
|
||||
// Client capability bitmask values, matching common/commands.h CLIENT_CAP_*.
|
||||
// Reported in the hex tail of LOGIN_INFOR.moduleVersion (after the '-').
|
||||
const (
|
||||
ClientCapV2 uint32 = 0x0001 // CLIENT_CAP_V2 — V2 file transfer
|
||||
ClientCapUTF8 uint32 = 0x0002 // CLIENT_CAP_UTF8 — UTF-8 protocol strings (activeWindow, key-log titles, ...)
|
||||
ClientCapScreenPreview uint32 = 0x0004 // CLIENT_CAP_SCREEN_PREVIEW
|
||||
)
|
||||
|
||||
// SupportsCap returns true when the client's reported capability hex string
|
||||
// has the given bit set. An empty / unparseable string means "no caps" and
|
||||
// matches the legacy GBK-Windows convention.
|
||||
func SupportsCap(capability string, bit uint32) bool {
|
||||
if capability == "" {
|
||||
return false
|
||||
}
|
||||
caps, err := strconv.ParseUint(strings.TrimSpace(capability), 16, 32)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return uint32(caps)&bit != 0
|
||||
}
|
||||
|
||||
// DecodeClientString decodes a fixed-length, NUL-padded buffer the client
|
||||
// sent as part of a binary protocol field (typically ActiveWnd). If the
|
||||
// client signals UTF-8 capability or is known to ship UTF-8 by default
|
||||
// (Linux / macOS), the bytes are treated as UTF-8; otherwise they're
|
||||
// decoded from GBK (CP936 — the legacy Windows default).
|
||||
//
|
||||
// clientType comes from LOGIN_INFOR reserved field 0 (RES_CLIENT_TYPE) and
|
||||
// capability from the hex tail of moduleVersion. Both can be empty.
|
||||
func DecodeClientString(data []byte, capability, clientType string) string {
|
||||
if SupportsCap(capability, ClientCapUTF8) || clientType == "LNX" || clientType == "MAC" {
|
||||
return Utf8CleanString(data)
|
||||
}
|
||||
return GbkToUTF8(data)
|
||||
}
|
||||
|
||||
// Command tokens - matching the C++ definitions (common/commands.h).
|
||||
const (
|
||||
// Server -> Client commands
|
||||
CommandActived byte = 0 // COMMAND_ACTIVED
|
||||
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
|
||||
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
|
||||
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
||||
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
|
||||
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
|
||||
CommandBye byte = 204 // COMMAND_BYE - disconnect
|
||||
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
|
||||
|
||||
@@ -52,8 +116,239 @@ const (
|
||||
TokenAuth byte = 100 // TOKEN_AUTH - authorization required
|
||||
TokenHeartbeat byte = 101 // TOKEN_HEARTBEAT
|
||||
TokenLogin byte = 102 // TOKEN_LOGIN - login packet
|
||||
TokenBitmapInfo byte = 115 // TOKEN_BITMAPINFO - screen sub-connection header
|
||||
TokenFirstScreen byte = 116 // TOKEN_FIRSTSCREEN - raw BGRA baseline frame (NOT H264)
|
||||
TokenNextScreen byte = 117 // TOKEN_NEXTSCREEN - non-keyframe H264 (P-frame)
|
||||
TokenShellStart byte = 128 // TOKEN_SHELL_START - legacy cmd-pipe shell sub-conn open
|
||||
TokenKeyframe byte = 134 // TOKEN_KEYFRAME - H264 IDR (sent on GOP boundary)
|
||||
TokenTerminalStart byte = 232 // TOKEN_TERMINAL_START - modern PTY shell sub-conn open
|
||||
TokenTerminalClose byte = 233 // TOKEN_TERMINAL_CLOSE - shell exited / close ack
|
||||
TokenConnAuth byte = 246 // TOKEN_CONN_AUTH - sub-connection identity handshake
|
||||
CmdCursorImage byte = 93 // CMD_CURSOR_IMAGE - custom cursor bitmap (Phase 5+ feature)
|
||||
)
|
||||
|
||||
// Sub-connection authentication (matches common/commands.h ConnAuth* structs).
|
||||
// Each newly-opened sub-conn first sends a 512-byte ConnAuthPacket, then waits
|
||||
// for a 256-byte ConnAuthAck before any further command is meaningful.
|
||||
const (
|
||||
ConnAuthPacketSize = 512
|
||||
ConnAuthAckSize = 256
|
||||
// ConnAuthPacket field offsets within the inbound 512-byte buffer.
|
||||
// Layout (from common/commands.h::ConnAuthPacket):
|
||||
// [token:1][clientID:8 LE][timestamp:8 LE][nonce:16][signature:64][reserved:415]
|
||||
ConnAuthOffClientID = 1 // uint64 LE — pin to the sub-conn so later
|
||||
// // 1-byte tokens (TOKEN_TERMINAL_START etc.) can
|
||||
// // resolve the parent device.
|
||||
// ConnAuthAck field offsets within the outbound 256-byte buffer.
|
||||
ConnAuthAckOffStatus = 1 // uint8
|
||||
ConnAuthAckOffServerTime = 2 // uint64 LE
|
||||
// Status codes.
|
||||
ConnAuthStatusOK byte = 0
|
||||
)
|
||||
|
||||
// CMD_MASTERSETTING is the server's reply to a fresh client login. The
|
||||
// client uses the Signature field to prove this server has the shared
|
||||
// secret; without a valid signature the client's private FileUpload init
|
||||
// aborts the process. Struct layout matches MasterSettings in
|
||||
// common/commands.h (pragma pack 4, total 1000 bytes).
|
||||
const (
|
||||
CmdMasterSetting byte = 215
|
||||
MasterSettingsSize = 1000
|
||||
MasterSettingsOffReportInterval = 0 // int32, seconds
|
||||
MasterSettingsOffSignature = 508 // Signature[64]
|
||||
MasterSettingsSignatureLen = 64
|
||||
// DefaultReportIntervalSec matches the C++ default. Sending 0 makes the
|
||||
// client disable its active-window heartbeat field, breaking RTT /
|
||||
// ActiveWindow live updates on the web UI.
|
||||
DefaultReportIntervalSec = 5
|
||||
)
|
||||
|
||||
// SignMessage computes HMAC-SHA256(key, msg) and returns the 64-char
|
||||
// lowercase hex digest. Used to sign CMD_MASTERSETTING replies so the
|
||||
// client can verify the response came from a legitimate server.
|
||||
//
|
||||
// The key is a deployment-time shared secret loaded from the
|
||||
// YAMA_SIGN_PASSWORD env var so the binary doesn't carry the literal in
|
||||
// cleartext; provision out-of-band and never commit it.
|
||||
func SignMessage(password string, msg []byte) string {
|
||||
mac := hmac.New(sha256.New, []byte(password))
|
||||
mac.Write(msg)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// Screen-spy parameters that match the C++ ScreenSpy implementation.
|
||||
const (
|
||||
AlgorithmH264 byte = 2 // ALGORITHM_H264 — H264 encoding (the algorithm web uses)
|
||||
)
|
||||
|
||||
// Windows message constants used inside MSG64.message. The client dispatches
|
||||
// on these values verbatim (CScreenManager::ProcessCommand at
|
||||
// client/ScreenManager.cpp:1617), so these MUST stay bit-identical to the
|
||||
// WinUser.h definitions even though this Go server is cross-platform.
|
||||
const (
|
||||
WMKeyDown uint64 = 0x0100
|
||||
WMKeyUp uint64 = 0x0101
|
||||
WMSysKeyDown uint64 = 0x0104
|
||||
WMSysKeyUp uint64 = 0x0105
|
||||
WMMouseMove uint64 = 0x0200
|
||||
WMLButtonDown uint64 = 0x0201
|
||||
WMLButtonUp uint64 = 0x0202
|
||||
WMLButtonDblClk uint64 = 0x0203
|
||||
WMRButtonDown uint64 = 0x0204
|
||||
WMRButtonUp uint64 = 0x0205
|
||||
WMRButtonDblClk uint64 = 0x0206
|
||||
WMMButtonDown uint64 = 0x0207
|
||||
WMMButtonUp uint64 = 0x0208
|
||||
WMMouseWheel uint64 = 0x020A
|
||||
)
|
||||
|
||||
// Virtual-key codes referenced from the input mapping. Same numeric values
|
||||
// as the Win32 VK_* constants.
|
||||
const (
|
||||
VKLWin = 0x5B // VK_LWIN — filtered: never forwarded
|
||||
VKRWin = 0x5C // VK_RWIN — filtered: never forwarded
|
||||
VKPrior = 0x21 // VK_PRIOR (Page Up) — extended-key range start
|
||||
VKDown = 0x28 // VK_DOWN — extended-key range end
|
||||
VKInsert = 0x2D
|
||||
VKDelete = 0x2E
|
||||
VKNumLock = 0x90
|
||||
VKRControl = 0xA3
|
||||
VKRMenu = 0xA5
|
||||
VKApps = 0x5D
|
||||
)
|
||||
|
||||
// MK_* wParam bitflags for mouse-button messages.
|
||||
const (
|
||||
MKLButton uint64 = 0x0001
|
||||
MKRButton uint64 = 0x0002
|
||||
MKMButton uint64 = 0x0010
|
||||
)
|
||||
|
||||
// MSG64 is the 48-byte fixed layout the client expects inside a
|
||||
// COMMAND_SCREEN_CONTROL packet (common/commands.h class MSG64).
|
||||
//
|
||||
// [hwnd:8][message:8][wParam:8][lParam:8][time:8][pt.x:4][pt.y:4]
|
||||
//
|
||||
// All uint64 fields are little-endian; pt is two int32 LE. The client's
|
||||
// ProcessCommand validates `ulLength % 48 == 0` and treats each 48-byte
|
||||
// block as one MSG64.
|
||||
const Msg64Size = 48
|
||||
|
||||
// BuildScreenControlPacket encodes one COMMAND_SCREEN_CONTROL packet
|
||||
// carrying a single MSG64 record. The cmd byte is prepended.
|
||||
//
|
||||
// Wire layout:
|
||||
//
|
||||
// [CMD:1][hwnd:8 LE][message:8 LE][wParam:8 LE][lParam:8 LE][time:8 LE][pt.x:4 LE][pt.y:4 LE]
|
||||
//
|
||||
// time is filled with a monotonic-ish ms value (ms since Unix epoch trimmed
|
||||
// to 32 bits) so the client's GetTickCount() comparisons stay reasonable.
|
||||
func BuildScreenControlPacket(message, wParam, lParam uint64, ptX, ptY int32, timeMs uint32) []byte {
|
||||
buf := make([]byte, 1+Msg64Size)
|
||||
buf[0] = CommandScreenControl
|
||||
// hwnd left zero — the client recomputes hWnd via WindowFromPoint.
|
||||
binary.LittleEndian.PutUint64(buf[1+8:1+16], message)
|
||||
binary.LittleEndian.PutUint64(buf[1+16:1+24], wParam)
|
||||
binary.LittleEndian.PutUint64(buf[1+24:1+32], lParam)
|
||||
binary.LittleEndian.PutUint64(buf[1+32:1+40], uint64(timeMs))
|
||||
binary.LittleEndian.PutUint32(buf[1+40:1+44], uint32(ptX))
|
||||
binary.LittleEndian.PutUint32(buf[1+44:1+48], uint32(ptY))
|
||||
return buf
|
||||
}
|
||||
|
||||
// TerminalBinaryMagic is the 4-byte prefix the web UI uses to demultiplex
|
||||
// terminal output from screen frames over the single WebSocket. Matches
|
||||
// the C++ side at server/2015Remote/WebService.cpp:2013 ("TRM1"). Screen
|
||||
// frames lead with a uint32 LE device ID, so collisions with this exact
|
||||
// magic are astronomically rare in practice.
|
||||
var TerminalBinaryMagic = [4]byte{'T', 'R', 'M', '1'}
|
||||
|
||||
// BuildTerminalResize encodes the 5-byte CMD_TERMINAL_RESIZE packet the
|
||||
// client's ConPTYManager/TerminalManager expects on the shell sub-conn:
|
||||
//
|
||||
// [CMD_TERMINAL_RESIZE:1][cols:2 LE][rows:2 LE]
|
||||
//
|
||||
// cols/rows are signed int16 on the wire (the C++ side casts to `short`).
|
||||
func BuildTerminalResize(cols, rows int) []byte {
|
||||
buf := make([]byte, 5)
|
||||
buf[0] = CommandTerminalRsize
|
||||
binary.LittleEndian.PutUint16(buf[1:3], uint16(int16(cols)))
|
||||
binary.LittleEndian.PutUint16(buf[3:5], uint16(int16(rows)))
|
||||
return buf
|
||||
}
|
||||
|
||||
// MakeLParam packs x into the low word and y into the high word — the
|
||||
// Windows MAKELPARAM macro the client expects in mouse-message lParams.
|
||||
func MakeLParam(x, y int32) uint64 {
|
||||
return uint64(uint32(x)&0xFFFF) | (uint64(uint32(y)&0xFFFF) << 16)
|
||||
}
|
||||
|
||||
// IsExtendedKey returns true when the given Win32 VK code should set the
|
||||
// extended-key bit (bit 24) in a keyboard lParam. Matches the C++
|
||||
// HandleKey logic (server/2015Remote/WebService.cpp:944).
|
||||
func IsExtendedKey(vk int) bool {
|
||||
if vk >= VKPrior && vk <= VKDown {
|
||||
return true
|
||||
}
|
||||
switch vk {
|
||||
case VKInsert, VKDelete, VKNumLock, VKRControl, VKRMenu, VKApps:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Reserved-field indices we care about (see common/commands.h RES_* enum).
|
||||
// LOGIN_INFOR.szReserved is a '|'-separated list; clients fill known slots
|
||||
// even when leaving others blank ("?").
|
||||
const (
|
||||
ResFieldClientType = 0 // RES_CLIENT_TYPE — client kind (Windows / macOS / ...)
|
||||
ResFieldFilePath = 4 // RES_FILE_PATH — install path
|
||||
ResFieldInstallTime = 6 // RES_INSTALL_TIME
|
||||
ResFieldClientLoc = 10 // RES_CLIENT_LOC — geo string
|
||||
ResFieldClientPubIP = 11 // RES_CLIENT_PUBIP — public IP
|
||||
ResFieldResolution = 15 // RES_RESOLUTION — client-formatted screen geometry: "N:W*H"
|
||||
ResFieldClientID = 16 // RES_CLIENT_ID — uint64 decimal, matches TOKEN_BITMAPINFO clientID
|
||||
)
|
||||
|
||||
// ScreenFrameHeaderLen is the size of the small per-frame header prepended by
|
||||
// the device on every TOKEN_NEXTSCREEN buffer, before the H.264 NAL payload.
|
||||
// Layout (excluding the leading TOKEN_* byte):
|
||||
//
|
||||
// [algorithm:1][cursorPos:8 (int32 x, int32 y)][cursorIdx:1] = 10 bytes
|
||||
//
|
||||
// (The C++ side counts the token byte into its ulHeadLength=11; we keep the
|
||||
// constant strictly post-token so the call site reads `skip := 1 + headerLen`
|
||||
// without confusion.) SCREENYSPY_IMPROVE adds a 4-byte frameID after the
|
||||
// cursor index, which is the production-off setting per common/commands.h.
|
||||
const ScreenFrameHeaderLen = 1 + 8 + 1
|
||||
|
||||
// IsH264Keyframe scans an Annex-B H.264 bitstream for a NAL unit indicating
|
||||
// a keyframe boundary — IDR (type 5), SPS (7) or PPS (8). Returns true on
|
||||
// the first hit. Matches the detection used by the C++ ScreenSpy broadcast
|
||||
// path so frame-type bytes stay consistent across server implementations.
|
||||
func IsH264Keyframe(data []byte) bool {
|
||||
n := len(data)
|
||||
for i := 0; i+4 < n; i++ {
|
||||
var nalOffset int
|
||||
switch {
|
||||
case data[i] == 0 && data[i+1] == 0 && data[i+2] == 0 && data[i+3] == 1:
|
||||
nalOffset = i + 4
|
||||
case data[i] == 0 && data[i+1] == 0 && data[i+2] == 1:
|
||||
nalOffset = i + 3
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if nalOffset >= n {
|
||||
continue
|
||||
}
|
||||
nalType := data[nalOffset] & 0x1F
|
||||
if nalType == 5 || nalType == 7 || nalType == 8 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LOGIN_INFOR structure size and offsets (matching C++ struct with default alignment)
|
||||
// Note: C++ struct uses default alignment (4-byte for uint32/int)
|
||||
const (
|
||||
@@ -111,17 +406,17 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
|
||||
// Parse module version (offset 164, 24 bytes)
|
||||
// This contains date string like "Dec 19 2025"
|
||||
if len(data) >= OffsetModuleVersion+24 {
|
||||
info.ModuleVersion = gbkToUTF8(data[OffsetModuleVersion : OffsetModuleVersion+24])
|
||||
info.ModuleVersion = GbkToUTF8(data[OffsetModuleVersion : OffsetModuleVersion+24])
|
||||
}
|
||||
|
||||
// Parse PC name (offset 188, 240 bytes)
|
||||
if len(data) >= OffsetPCName+240 {
|
||||
info.PCName = gbkToUTF8(data[OffsetPCName : OffsetPCName+240])
|
||||
info.PCName = GbkToUTF8(data[OffsetPCName : OffsetPCName+240])
|
||||
}
|
||||
|
||||
// Parse Master ID (offset 428, 20 bytes)
|
||||
if len(data) >= OffsetMasterID+20 {
|
||||
info.MasterID = gbkToUTF8(data[OffsetMasterID : OffsetMasterID+20])
|
||||
info.MasterID = GbkToUTF8(data[OffsetMasterID : OffsetMasterID+20])
|
||||
}
|
||||
|
||||
// Parse WebCam exist (offset 448, 4 bytes)
|
||||
@@ -136,14 +431,14 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
|
||||
|
||||
// Parse Start time (offset 456, 20 bytes)
|
||||
if len(data) >= OffsetStartTime+20 {
|
||||
info.StartTime = gbkToUTF8(data[OffsetStartTime : OffsetStartTime+20])
|
||||
info.StartTime = GbkToUTF8(data[OffsetStartTime : OffsetStartTime+20])
|
||||
}
|
||||
|
||||
// Parse Reserved (offset 476, 512 bytes) - contains additional info
|
||||
if len(data) >= OffsetReserved+512 {
|
||||
info.Reserved = gbkToUTF8(data[OffsetReserved : OffsetReserved+512])
|
||||
info.Reserved = GbkToUTF8(data[OffsetReserved : OffsetReserved+512])
|
||||
} else if len(data) > OffsetReserved {
|
||||
info.Reserved = gbkToUTF8(data[OffsetReserved:])
|
||||
info.Reserved = GbkToUTF8(data[OffsetReserved:])
|
||||
}
|
||||
|
||||
return info, nil
|
||||
@@ -152,7 +447,7 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
|
||||
// parseOsVersionInfo parses the OS version info field
|
||||
// The C++ client fills this with a readable string like "Windows 10" via getSystemName()
|
||||
func parseOsVersionInfo(data []byte) string {
|
||||
return gbkToUTF8(data)
|
||||
return GbkToUTF8(data)
|
||||
}
|
||||
|
||||
// ParseReserved parses the reserved field into a slice of strings
|
||||
|
||||
47
server/go/protocol/commands_test.go
Normal file
47
server/go/protocol/commands_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package protocol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSignMessageHMACVector(t *testing.T) {
|
||||
// Standard HMAC-SHA256 sanity vector. Anchors that SignMessage matches
|
||||
// the canonical RFC 4231 algorithm so signatures stay interoperable
|
||||
// with peers that compute the same digest.
|
||||
got := SignMessage("key", []byte("hello"))
|
||||
want := "9307b3b915efb5171ff14d8cb55fbcc798c6c0ef1456d66ded1a6aa723a58b7b"
|
||||
if got != want {
|
||||
t.Fatalf("SignMessage(key, hello) = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignMessageDeterministic(t *testing.T) {
|
||||
a := SignMessage("test-key", []byte("2026-01-01 12:00:00|123456789"))
|
||||
b := SignMessage("test-key", []byte("2026-01-01 12:00:00|123456789"))
|
||||
if a != b {
|
||||
t.Fatalf("non-deterministic: %s != %s", a, b)
|
||||
}
|
||||
if len(a) != 64 {
|
||||
t.Fatalf("expected 64 hex chars, got %d (%s)", len(a), a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsH264KeyframeBasic(t *testing.T) {
|
||||
// 4-byte start code + IDR (NAL type 5)
|
||||
idr := []byte{0x00, 0x00, 0x00, 0x01, 0x65, 0x88}
|
||||
if !IsH264Keyframe(idr) {
|
||||
t.Fatal("IDR should be detected as keyframe")
|
||||
}
|
||||
// 3-byte start code + SPS (NAL type 7)
|
||||
sps := []byte{0x00, 0x00, 0x01, 0x67, 0x42}
|
||||
if !IsH264Keyframe(sps) {
|
||||
t.Fatal("SPS should be detected as keyframe")
|
||||
}
|
||||
// 4-byte start code + non-IDR slice (NAL type 1)
|
||||
pframe := []byte{0x00, 0x00, 0x00, 0x01, 0x41, 0x9b}
|
||||
if IsH264Keyframe(pframe) {
|
||||
t.Fatal("non-IDR slice should not be detected as keyframe")
|
||||
}
|
||||
// Garbage
|
||||
if IsH264Keyframe([]byte{0xde, 0xad, 0xbe, 0xef}) {
|
||||
t.Fatal("non-H264 bytes should not match")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,19 @@ type Parser struct {
|
||||
codec *Codec
|
||||
}
|
||||
|
||||
// findHTTPBodyOffset returns the byte offset of the HTTP body — i.e. one past
|
||||
// the first `\r\n\r\n` separator. Returns -1 if the separator isn't present
|
||||
// yet (caller should wait for more data). Matches the C++ UnMaskHttp scan in
|
||||
// common/mask.h.
|
||||
func findHTTPBodyOffset(data []byte) int {
|
||||
for i := 0; i+4 <= len(data); i++ {
|
||||
if data[i] == '\r' && data[i+1] == '\n' && data[i+2] == '\r' && data[i+3] == '\n' {
|
||||
return i + 4
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// NewParser creates a new parser
|
||||
func NewParser() *Parser {
|
||||
return &Parser{
|
||||
@@ -38,6 +51,22 @@ func (p *Parser) Close() {
|
||||
func (p *Parser) Parse(ctx *connection.Context) ([]byte, error) {
|
||||
buf := ctx.InBuffer
|
||||
|
||||
// Strip optional HTTP-mask wrapper. The client may disguise each outbound
|
||||
// chunk as a `POST /<random> HTTP/1.1\r\n...\r\n\r\n` envelope followed
|
||||
// by the real binary body (see common/mask.h: HttpMask). Each chunk
|
||||
// carries its own envelope so we strip every time we see the prefix.
|
||||
if buf.Len() >= 5 {
|
||||
head := buf.Peek(5)
|
||||
if len(head) == 5 && head[0] == 'P' && head[1] == 'O' && head[2] == 'S' && head[3] == 'T' && head[4] == ' ' {
|
||||
bodyOffset := findHTTPBodyOffset(buf.Bytes())
|
||||
if bodyOffset < 0 {
|
||||
// Headers not fully arrived yet — wait for more bytes.
|
||||
return nil, ErrNeedMore
|
||||
}
|
||||
buf.Skip(bodyOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// Need at least minimum bytes to determine protocol
|
||||
if buf.Len() < MinComLen {
|
||||
return nil, ErrNeedMore
|
||||
|
||||
8
server/go/web/assets/static/fit.min.js
vendored
Normal file
8
server/go/web/assets/static/fit.min.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
209
server/go/web/assets/static/xterm.css
Normal file
209
server/go/web/assets/static/xterm.css
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
8
server/go/web/assets/static/xterm.min.js
vendored
Normal file
8
server/go/web/assets/static/xterm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
23
server/go/web/embed.go
Normal file
23
server/go/web/embed.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package web
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// IndexHTML is the web remote desktop landing page, synced from
|
||||
// server/web/index.html via `make sync` (or VSCode's sync-assets task).
|
||||
// Do not edit assets/index.html directly — source of truth lives at
|
||||
// server/web/index.html.
|
||||
//
|
||||
//go:embed assets/index.html
|
||||
var IndexHTML []byte
|
||||
|
||||
// Third-party xterm.js library assets. Checked in as-is; updates are
|
||||
// infrequent and done manually from server/2015Remote/res/web/.
|
||||
|
||||
//go:embed assets/static/xterm.min.js
|
||||
var xtermJS []byte
|
||||
|
||||
//go:embed assets/static/xterm.css
|
||||
var xtermCSS []byte
|
||||
|
||||
//go:embed assets/static/fit.min.js
|
||||
var xtermFitJS []byte
|
||||
219
server/go/web/server.go
Normal file
219
server/go/web/server.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
// Server serves the web remote desktop UI: the embedded index.html, xterm.js
|
||||
// static assets, the PWA manifest, and JSON APIs backed by the device hub.
|
||||
// WebSocket signaling and screen streaming will be wired up in later phases.
|
||||
type Server struct {
|
||||
port int
|
||||
log *logger.Logger
|
||||
srv *http.Server
|
||||
hub *hub.Hub
|
||||
auth *wsauth.Authenticator
|
||||
ws *wsHub
|
||||
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
|
||||
loginIPLimit *wsauth.RateLimiter
|
||||
loginUserLimit *wsauth.RateLimiter
|
||||
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
|
||||
}
|
||||
|
||||
// Config tunes the server's exposed-on-public-HTTPS hardening knobs.
|
||||
// All fields are optional; zero values pick reasonable defaults.
|
||||
type Config struct {
|
||||
// AllowedOrigins is the comma-separated list of Origin header values
|
||||
// the WebSocket upgrade will accept in addition to same-origin
|
||||
// requests. Empty (default) → only same-origin upgrades are allowed,
|
||||
// which is correct when the web UI and the WS endpoint are served
|
||||
// from the same host.
|
||||
AllowedOrigins []string
|
||||
// LoginIPLimit / LoginUserLimit throttle the get_salt + login flow
|
||||
// per source IP and per username respectively. Pass nil to disable
|
||||
// either dimension (e.g. dev mode).
|
||||
LoginIPLimit *wsauth.RateLimiter
|
||||
LoginUserLimit *wsauth.RateLimiter
|
||||
// TrustForwardedFor switches client-IP extraction from RemoteAddr
|
||||
// (default) to the last entry of X-Forwarded-For. Set true only when
|
||||
// running behind a reverse proxy that you control; on direct
|
||||
// exposure the header is client-controlled and would let attackers
|
||||
// evade per-IP rate limits.
|
||||
TrustForwardedFor bool
|
||||
}
|
||||
|
||||
// New creates an HTTP server bound to the given port. port=0 disables the server.
|
||||
// The hub provides read access to the online-device registry; the authenticator
|
||||
// owns user accounts and session tokens.
|
||||
func New(port int, log *logger.Logger, h *hub.Hub, auth *wsauth.Authenticator) *Server {
|
||||
return &Server{port: port, log: log, hub: h, auth: auth}
|
||||
}
|
||||
|
||||
// WithConfig applies hardening configuration. Returns the receiver for
|
||||
// chainable setup. Safe to call before Start; ignored thereafter.
|
||||
func (s *Server) WithConfig(cfg Config) *Server {
|
||||
s.allowedOrigins = cfg.AllowedOrigins
|
||||
s.loginIPLimit = cfg.LoginIPLimit
|
||||
s.loginUserLimit = cfg.LoginUserLimit
|
||||
s.trustForwardedFor = cfg.TrustForwardedFor
|
||||
return s
|
||||
}
|
||||
|
||||
// Start launches the server in a goroutine and returns immediately.
|
||||
// If port is 0, returns nil without starting anything.
|
||||
func (s *Server) Start() error {
|
||||
if s.port == 0 {
|
||||
s.log.Info("HTTP server disabled (-http-port=0)")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.ws = newWSHub(s.auth, s.hub, s.log).
|
||||
withOriginAllowlist(s.allowedOrigins).
|
||||
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
|
||||
withTrustForwardedFor(s.trustForwardedFor)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
mux.HandleFunc("/manifest.json", s.handleManifest)
|
||||
mux.HandleFunc("/api/devices", s.requireBearer(s.handleDevices))
|
||||
mux.HandleFunc("/ws", s.ws.serve)
|
||||
mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8"))
|
||||
mux.HandleFunc("/static/xterm.css", staticHandler(xtermCSS, "text/css; charset=utf-8"))
|
||||
mux.HandleFunc("/static/xterm-fit.js", staticHandler(xtermFitJS, "application/javascript; charset=utf-8"))
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: ":" + strconv.Itoa(s.port),
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Bind synchronously so port-in-use / permission errors propagate to the
|
||||
// caller instead of being lost inside the goroutine after a misleading
|
||||
// "started" log line.
|
||||
ln, err := net.Listen("tcp", s.srv.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on :%d: %w", s.port, err)
|
||||
}
|
||||
s.log.Info("HTTP server started on :%d", s.port)
|
||||
go func() {
|
||||
if err := s.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Error("HTTP server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts the server down.
|
||||
func (s *Server) Stop() {
|
||||
if s.ws != nil {
|
||||
s.ws.stop()
|
||||
}
|
||||
if s.srv == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(IndexHTML)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// handleDevices returns a JSON snapshot of currently-online devices. Empty
|
||||
// array (not null) when no clients are connected — matches what the front-end
|
||||
// will eventually expect. Auth-gated via requireBearer.
|
||||
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
devices := s.hub.ListDevices()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := json.NewEncoder(w).Encode(devices); err != nil {
|
||||
s.log.Error("encode /api/devices: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// requireBearer wraps a handler with `Authorization: Bearer <token>` auth
|
||||
// against the same session-token store the WebSocket uses. Returns 401 on
|
||||
// missing / invalid / expired tokens. Used to gate REST endpoints that
|
||||
// previously fell through with no auth (notably /api/devices, which
|
||||
// otherwise leaks the full online-device list to anyone on the internet).
|
||||
func (s *Server) requireBearer(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
const prefix = "Bearer "
|
||||
hdr := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(hdr, prefix) {
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(hdr[len(prefix):])
|
||||
if token == "" {
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
if _, err := s.auth.ValidateToken(token); err != nil {
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) unauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="yama"`)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
}
|
||||
|
||||
// PWA manifest. Referenced by <link rel="manifest"> in index.html.
|
||||
// Static JSON, no template needed.
|
||||
const manifestJSON = `{
|
||||
"name": "SimpleRemoter",
|
||||
"short_name": "Remoter",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#1a1a2e",
|
||||
"theme_color": "#1a1a2e"
|
||||
}`
|
||||
|
||||
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = w.Write([]byte(manifestJSON))
|
||||
}
|
||||
|
||||
// staticHandler returns an http.HandlerFunc that serves a fixed byte slice
|
||||
// with the given content-type. Used for embedded third-party assets (xterm.js etc.)
|
||||
// that change infrequently — 1-day browser cache is fine.
|
||||
func staticHandler(body []byte, contentType string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
479
server/go/web/ws.go
Normal file
479
server/go/web/ws.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
// ----- WS framing knobs ---------------------------------------------------
|
||||
|
||||
const (
|
||||
wsWriteWait = 10 * time.Second // single-frame write deadline
|
||||
wsReadLimit = 1 << 20 // refuse incoming frames over 1 MB
|
||||
wsSendBuffer = 64 // outbound queue depth per client
|
||||
)
|
||||
|
||||
// baseUpgrader carries the buffer-size config shared by all WS upgrades.
|
||||
// CheckOrigin is set per-hub in wsHub.upgradeWS so the allowlist is
|
||||
// closed-over per instance instead of being a global mutable.
|
||||
var baseUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
}
|
||||
|
||||
// ----- per-connection client state ----------------------------------------
|
||||
|
||||
// wsMsg is one queued WebSocket frame. binary toggles between
|
||||
// websocket.TextMessage (JSON signaling) and websocket.BinaryMessage
|
||||
// (screen frames).
|
||||
type wsMsg struct {
|
||||
binary bool
|
||||
data []byte
|
||||
}
|
||||
|
||||
type wsClient struct {
|
||||
conn *websocket.Conn
|
||||
send chan wsMsg
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
|
||||
// Mutated under wsHub.mu (or only by the read loop owning this client).
|
||||
nonce string // outstanding challenge — cleared after a successful login
|
||||
token string // set once authenticated
|
||||
role string // mirrors session role after login
|
||||
addr string // client address for logs
|
||||
watching string // device ID this browser is currently streaming, "" when on the list
|
||||
termWatching string // device ID for an open web terminal session, "" otherwise
|
||||
}
|
||||
|
||||
// queue writes a JSON text frame onto the send buffer. Drops silently if the
|
||||
// buffer is full so a stuck reader can't back-pressure the broadcast path.
|
||||
func (c *wsClient) queue(payload []byte) {
|
||||
c.enqueue(wsMsg{binary: false, data: payload})
|
||||
}
|
||||
|
||||
// queueBinary writes a binary WS frame. Used for screen-stream packets.
|
||||
func (c *wsClient) queueBinary(payload []byte) {
|
||||
c.enqueue(wsMsg{binary: true, data: payload})
|
||||
}
|
||||
|
||||
func (c *wsClient) enqueue(m wsMsg) {
|
||||
select {
|
||||
case c.send <- m:
|
||||
case <-c.closed:
|
||||
default:
|
||||
// queue full — drop (acceptable for video; signaling clients are
|
||||
// typically not behind enough for the small text buffer to fill).
|
||||
}
|
||||
}
|
||||
|
||||
// close signals both loops to exit. Safe to call multiple times.
|
||||
func (c *wsClient) close() {
|
||||
c.once.Do(func() {
|
||||
close(c.closed)
|
||||
_ = c.conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// ----- ws hub: registry of all connected browsers -------------------------
|
||||
|
||||
type wsHub struct {
|
||||
auth *wsauth.Authenticator
|
||||
devices *hub.Hub
|
||||
log *logger.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[*wsClient]struct{}
|
||||
|
||||
unsub func()
|
||||
|
||||
// Hardening knobs wired from server.Config. Nil/empty values mean
|
||||
// "no extra restriction" — useful for local dev where the hub is
|
||||
// exercised without server.Server wiring up the env-driven defaults.
|
||||
allowedOrigins []string // empty → only same-origin upgrades accepted
|
||||
loginIPLimit *wsauth.RateLimiter
|
||||
loginUserLimit *wsauth.RateLimiter
|
||||
trustForwardedFor bool // honor X-Forwarded-For (only when behind a trusted proxy)
|
||||
}
|
||||
|
||||
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger) *wsHub {
|
||||
h := &wsHub{
|
||||
auth: auth,
|
||||
devices: devices,
|
||||
log: log,
|
||||
clients: make(map[*wsClient]struct{}),
|
||||
}
|
||||
h.unsub = devices.Subscribe(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// withOriginAllowlist returns h after installing the explicit Origin
|
||||
// allowlist. Chainable. Pass empty/nil to keep "same-origin only".
|
||||
func (h *wsHub) withOriginAllowlist(origins []string) *wsHub {
|
||||
h.allowedOrigins = origins
|
||||
return h
|
||||
}
|
||||
|
||||
// withLoginRateLimiters wires per-IP and per-username throttles into
|
||||
// the login flow. Either may be nil to disable that dimension.
|
||||
func (h *wsHub) withLoginRateLimiters(byIP, byUser *wsauth.RateLimiter) *wsHub {
|
||||
h.loginIPLimit = byIP
|
||||
h.loginUserLimit = byUser
|
||||
return h
|
||||
}
|
||||
|
||||
// withTrustForwardedFor opts in to using the last entry of the
|
||||
// X-Forwarded-For header as the client IP. Safe only when the server is
|
||||
// behind a reverse proxy that you control.
|
||||
func (h *wsHub) withTrustForwardedFor(trust bool) *wsHub {
|
||||
h.trustForwardedFor = trust
|
||||
return h
|
||||
}
|
||||
|
||||
// checkOrigin decides whether to accept a WebSocket upgrade based on
|
||||
// the request's Origin header. Same-origin (Origin host == Host) is
|
||||
// always accepted; explicit allowlist entries cover the
|
||||
// PWA-from-different-domain or local-dev cases.
|
||||
//
|
||||
// An empty Origin header is rejected: a legitimate browser always sends
|
||||
// it on cross-origin requests, and same-origin requests have it too in
|
||||
// modern Chrome/Safari/Firefox. Non-browser clients (curl, scripts) that
|
||||
// omit Origin shouldn't be talking to the WS endpoint anyway.
|
||||
func (h *wsHub) checkOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
// Same-origin (Origin host matches the Host the request came in on).
|
||||
// Strip any port mismatch: if the server is behind a proxy, Host may
|
||||
// not include a port while Origin does (or vice versa), so compare
|
||||
// the hostname components.
|
||||
originHost := u.Hostname()
|
||||
reqHost := stripPort(r.Host)
|
||||
if originHost == reqHost && originHost != "" {
|
||||
return true
|
||||
}
|
||||
// Explicit allowlist entries — match Origin in full (scheme + host
|
||||
// + port) so a customer can pin exactly one trusted PWA origin.
|
||||
for _, allowed := range h.allowedOrigins {
|
||||
if allowed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(origin, strings.TrimSpace(allowed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stripPort(hostport string) string {
|
||||
if h, _, err := net.SplitHostPort(hostport); err == nil {
|
||||
return h
|
||||
}
|
||||
return hostport
|
||||
}
|
||||
|
||||
// clientIP returns the source IP of an HTTP request. By default uses
|
||||
// r.RemoteAddr (the actual TCP peer); this is the only safe choice when
|
||||
// the server is directly exposed to the internet, because a malicious
|
||||
// client can put anything in X-Forwarded-For and would otherwise rotate
|
||||
// it to evade per-IP rate limits.
|
||||
//
|
||||
// When `trustForwardedFor` is true the LAST entry of X-Forwarded-For is
|
||||
// returned instead — appropriate only when running behind a reverse
|
||||
// proxy that you control and that overwrites/appends the header (caddy,
|
||||
// nginx with proper config, etc). Toggled via the YAMA_WEB_TRUST_PROXY
|
||||
// env var at startup.
|
||||
func clientIP(r *http.Request, trustForwardedFor bool) string {
|
||||
if trustForwardedFor {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the LAST entry — the closest hop, i.e. our own
|
||||
// trusted proxy's view of the peer. Trusting the first
|
||||
// entry would let a malicious client at the head of the
|
||||
// chain set an arbitrary value.
|
||||
parts := strings.Split(xff, ",")
|
||||
ip := strings.TrimSpace(parts[len(parts)-1])
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
return stripPort(r.RemoteAddr)
|
||||
}
|
||||
|
||||
// stop unsubscribes from the device hub. Existing connections keep running
|
||||
// until they close on their own; we only block new event delivery.
|
||||
func (h *wsHub) stop() {
|
||||
if h.unsub != nil {
|
||||
h.unsub()
|
||||
h.unsub = nil
|
||||
}
|
||||
}
|
||||
|
||||
// hub.EventHandler — invoked from hub.Register / hub.Unregister.
|
||||
func (h *wsHub) OnDeviceOnline(_ hub.DeviceInfo) {
|
||||
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
|
||||
}
|
||||
|
||||
func (h *wsHub) OnDeviceOffline(_ string) {
|
||||
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
|
||||
}
|
||||
|
||||
// OnCursorChange relays the remote cursor index to every viewer of this
|
||||
// device. The browser maps the index to a CSS cursor (desktop) or overlay
|
||||
// SVG variant (touch). Hub already de-duplicates so we always have a real
|
||||
// transition here.
|
||||
func (h *wsHub) OnCursorChange(deviceID string, index byte) {
|
||||
msg := mustJSON(map[string]any{
|
||||
"cmd": "cursor",
|
||||
"index": index,
|
||||
})
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
if c.watching == deviceID && c.token != "" {
|
||||
c.queue(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnResolutionChange notifies viewers so the browser-side WebCodecs decoder
|
||||
// can be (re)initialized with the right frame size. Without this, incoming
|
||||
// binary frames after connect_result are decoded by an uninitialized
|
||||
// VideoDecoder and the page stays on "Waiting for video...".
|
||||
func (h *wsHub) OnResolutionChange(deviceID string, width, height int) {
|
||||
msg := mustJSON(map[string]any{
|
||||
"cmd": "resolution_changed",
|
||||
"id": deviceID,
|
||||
"width": width,
|
||||
"height": height,
|
||||
})
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
if c.watching == deviceID && c.token != "" {
|
||||
c.queue(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnScreenFrame ships a screen packet to every browser currently watching
|
||||
// this device. We hold the read lock for the whole iteration, but each
|
||||
// queueBinary is non-blocking (drops on backpressure) so a slow viewer
|
||||
// cannot stall the fast ones.
|
||||
func (h *wsHub) OnScreenFrame(deviceID string, packet []byte, _ bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
if c.watching == deviceID && c.token != "" {
|
||||
c.queueBinary(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnTerminalReady notifies the requesting browser that its term_open
|
||||
// handshake completed. mode is "pty" or "legacy" — xterm.js disables the
|
||||
// resize callback in legacy mode (no PTY behind the cmd pipe).
|
||||
func (h *wsHub) OnTerminalReady(deviceID string, isPTY bool) {
|
||||
mode := "legacy"
|
||||
if isPTY {
|
||||
mode = "pty"
|
||||
}
|
||||
msg := mustJSON(map[string]any{
|
||||
"cmd": "term_ready",
|
||||
"id": deviceID,
|
||||
"mode": mode,
|
||||
})
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
if c.termWatching == deviceID && c.token != "" {
|
||||
c.queue(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnTerminalData ships one chunk of raw shell output (already wrapped in
|
||||
// the "TRM1" magic header) over the binary WS frame. Single-viewer is
|
||||
// enforced upstream so at most one client matches per device.
|
||||
func (h *wsHub) OnTerminalData(deviceID string, packet []byte) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
if c.termWatching == deviceID && c.token != "" {
|
||||
c.queueBinary(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnTerminalClosed fires when the device's shell exits or the sub-conn
|
||||
// drops. The browser closes its xterm panel. We also clear termWatching
|
||||
// so a subsequent term_open from the same browser isn't rejected as
|
||||
// "already open" by stale state.
|
||||
func (h *wsHub) OnTerminalClosed(deviceID string, reason string) {
|
||||
msg := mustJSON(map[string]any{
|
||||
"cmd": "term_closed",
|
||||
"ok": true,
|
||||
"reason": reason,
|
||||
})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for c := range h.clients {
|
||||
if c.termWatching == deviceID && c.token != "" {
|
||||
c.termWatching = ""
|
||||
c.queue(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list
|
||||
// rows can refresh RTT and active-window labels without re-fetching.
|
||||
func (h *wsHub) OnDeviceUpdate(id string, rtt int, activeWindow string) {
|
||||
payload := mustJSON(map[string]any{
|
||||
"cmd": "device_update",
|
||||
"id": id,
|
||||
"rtt": rtt,
|
||||
"activeWindow": activeWindow,
|
||||
})
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
if c.token != "" {
|
||||
c.queue(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHub) broadcastAuthenticated(msg string) {
|
||||
payload := []byte(msg)
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
if c.token != "" {
|
||||
c.queue(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHub) register(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *wsHub) unregister(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, c)
|
||||
h.mu.Unlock()
|
||||
// If this client was the last viewer of a device, tear down the screen
|
||||
// session so the device stops encoding. Done OUTSIDE the lock so the
|
||||
// hub's mutators can take their own locks without risk of recursion.
|
||||
if c.watching != "" && h.countWatchers(c.watching) == 0 {
|
||||
h.devices.CloseScreen(c.watching)
|
||||
}
|
||||
// Terminal sessions are single-viewer by design, so any open session
|
||||
// belongs to this client. Tear it down so the next viewer doesn't
|
||||
// hit ErrTerminalBusy from an abandoned session.
|
||||
if c.termWatching != "" {
|
||||
h.devices.CloseTerminalSession(c.termWatching)
|
||||
c.termWatching = ""
|
||||
}
|
||||
// Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
|
||||
// Frontend may close+reopen the WS at any time (visibilitychange handler,
|
||||
// brief network blip, reload) and must be able to resume with the same
|
||||
// cached token. The token expires on its own TTL.
|
||||
c.close()
|
||||
}
|
||||
|
||||
// ----- HTTP handler -------------------------------------------------------
|
||||
|
||||
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
||||
// Build a per-call upgrader so CheckOrigin closes over this hub's
|
||||
// allowlist instead of a package-level mutable.
|
||||
upgrader := baseUpgrader
|
||||
upgrader.CheckOrigin = h.checkOrigin
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
h.log.Error("ws upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(wsReadLimit)
|
||||
|
||||
nonce, err := wsauth.NewNonce()
|
||||
if err != nil {
|
||||
h.log.Error("nonce gen: %v", err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
client := &wsClient{
|
||||
conn: conn,
|
||||
send: make(chan wsMsg, wsSendBuffer),
|
||||
closed: make(chan struct{}),
|
||||
nonce: nonce,
|
||||
addr: clientIP(r, h.trustForwardedFor),
|
||||
}
|
||||
h.register(client)
|
||||
defer h.unregister(client)
|
||||
|
||||
go h.writeLoop(client)
|
||||
|
||||
// Greet with a challenge nonce so the browser can compute the login response.
|
||||
client.queue([]byte(`{"cmd":"challenge","nonce":"` + nonce + `"}`))
|
||||
|
||||
h.readLoop(client)
|
||||
}
|
||||
|
||||
// writeLoop drains the send queue. Exits when the channel is closed or a
|
||||
// write fails. Closing the underlying connection is the read loop's job.
|
||||
func (h *wsHub) writeLoop(c *wsClient) {
|
||||
for {
|
||||
select {
|
||||
case msg := <-c.send:
|
||||
msgType := websocket.TextMessage
|
||||
if msg.binary {
|
||||
msgType = websocket.BinaryMessage
|
||||
}
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
if err := c.conn.WriteMessage(msgType, msg.data); err != nil {
|
||||
c.close()
|
||||
return
|
||||
}
|
||||
case <-c.closed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop dispatches incoming messages. Exits on read error (peer closed,
|
||||
// timeout, malformed frame, etc.), which then triggers unregister cleanup.
|
||||
func (h *wsHub) readLoop(c *wsClient) {
|
||||
for {
|
||||
_, raw, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var env struct {
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
continue // ignore garbage frames
|
||||
}
|
||||
h.dispatch(c, env.Cmd, raw)
|
||||
}
|
||||
}
|
||||
754
server/go/web/ws_handlers.go
Normal file
754
server/go/web/ws_handlers.go
Normal file
@@ -0,0 +1,754 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||
)
|
||||
|
||||
// dispatch routes one inbound message to its handler. The `raw` payload is
|
||||
// passed through so handlers can re-parse to their own shape.
|
||||
//
|
||||
// Phase 3 implements: get_salt, login, get_devices, ping, disconnect.
|
||||
// Phase 4 adds: connect, screen frame relay.
|
||||
// Phase 5 adds: mouse, key (input forwarding to the device screen sub-conn).
|
||||
// Phase 6 adds: term_open / term_input / term_resize / term_close (PTY relay).
|
||||
// Phase 7 covers admin: create_user / delete_user / list_users / get_groups.
|
||||
func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
||||
switch cmd {
|
||||
case "get_salt":
|
||||
h.handleGetSalt(c, raw)
|
||||
case "login":
|
||||
h.handleLogin(c, raw)
|
||||
case "get_devices":
|
||||
h.handleGetDevices(c, raw)
|
||||
case "ping":
|
||||
// no-op heartbeat; the read itself was the keep-alive signal
|
||||
case "disconnect":
|
||||
h.handleDisconnect(c, raw)
|
||||
|
||||
case "connect":
|
||||
h.handleConnect(c, raw)
|
||||
case "rdp_reset":
|
||||
// silently ignored — UI uses this as a fire-and-forget
|
||||
case "mouse":
|
||||
h.handleMouse(c, raw)
|
||||
case "key":
|
||||
h.handleKey(c, raw)
|
||||
case "term_open":
|
||||
h.handleTermOpen(c, raw)
|
||||
case "term_input":
|
||||
h.handleTermInput(c, raw)
|
||||
case "term_resize":
|
||||
h.handleTermResize(c, raw)
|
||||
case "term_close":
|
||||
h.handleTermClose(c, raw)
|
||||
|
||||
// Admin operations (Phase 7).
|
||||
case "create_user":
|
||||
h.handleCreateUser(c, raw)
|
||||
case "delete_user":
|
||||
h.handleDeleteUser(c, raw)
|
||||
case "list_users":
|
||||
h.handleListUsers(c, raw)
|
||||
case "get_groups":
|
||||
h.handleGetGroups(c, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// requireAdmin combines token validation with a role=="admin" check. The
|
||||
// reply on failure has the standard `{cmd, ok:false, msg}` shape so the
|
||||
// front-end's generic toast handler can surface the reason.
|
||||
func (h *wsHub) requireAdmin(c *wsClient, raw []byte, replyCmd string) (ok bool) {
|
||||
if !h.requireAuth(c, raw, replyCmd) {
|
||||
return false
|
||||
}
|
||||
// c.role is cached on first successful requireAuth; safe to read here.
|
||||
if c.role != "admin" {
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false, "msg": "Permission denied",
|
||||
}))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ----- handlers ------------------------------------------------------------
|
||||
|
||||
func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) {
|
||||
// Throttle the salt-probe surface together with login: an attacker
|
||||
// who can poll get_salt freely would otherwise still learn nothing
|
||||
// (the unknown-user fake salt mitigation handles that), but the
|
||||
// endpoint is otherwise free CPU on the server. Limiting by IP is
|
||||
// enough; we don't have a username yet to limit by user.
|
||||
if !h.allowLoginByIP(c) {
|
||||
// Stall the response so a tight-loop attacker doesn't flood the
|
||||
// queue. Still return a well-formed salt to avoid making the
|
||||
// limit detectable from the client side.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
|
||||
salt, _ := h.auth.GetSalt(in.Username)
|
||||
// GetSalt now returns a deterministic fake salt (16 hex chars) for
|
||||
// unknown users — same shape as a real salt — so an attacker can't
|
||||
// tell from this response alone whether the username exists.
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": "salt",
|
||||
"ok": true,
|
||||
"salt": salt,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
Response string `json:"response"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid request"}))
|
||||
return
|
||||
}
|
||||
|
||||
// Rate-limit BEFORE doing the hash work, so a flood doesn't pin CPU.
|
||||
// Two-dimensional throttle: per-IP catches scanners that try many
|
||||
// usernames; per-username catches scanners that rotate IPs against a
|
||||
// known account (admin). Either dimension tripping rejects the call
|
||||
// with a uniform "credentials" error so the limit is not detectable.
|
||||
if !h.allowLoginByIP(c) || !h.allowLoginByUsername(in.Username) {
|
||||
h.log.Warn("ws login throttled: user=%s addr=%s", in.Username, c.addr)
|
||||
// Burn the challenge so the attacker can't immediately replay.
|
||||
c.nonce = ""
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
|
||||
return
|
||||
}
|
||||
|
||||
// Bind the response to the challenge we issued at connect time so that
|
||||
// replays from a different connection can't reuse a captured response.
|
||||
if in.Nonce == "" || in.Nonce != c.nonce {
|
||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid challenge"}))
|
||||
return
|
||||
}
|
||||
|
||||
token, role, err := h.auth.VerifyLogin(in.Username, in.Response, in.Nonce)
|
||||
if err != nil {
|
||||
// Burn the challenge on failure too — forces a new round on retry.
|
||||
c.nonce = ""
|
||||
// Fixed delay on failure: makes online brute force impractical
|
||||
// even within the rate-limit budget, and erases the timing
|
||||
// difference between "wrong password" and "wrong nonce".
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
|
||||
return
|
||||
}
|
||||
// Successful login: clear the per-IP/per-user budgets so a legitimate
|
||||
// user who fat-fingered a few times doesn't stay throttled.
|
||||
h.resetLoginThrottle(c, in.Username)
|
||||
|
||||
c.nonce = ""
|
||||
c.token = token
|
||||
c.role = role
|
||||
h.log.Info("ws login: user=%s role=%s addr=%s", in.Username, role, c.addr)
|
||||
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": "login_result",
|
||||
"ok": true,
|
||||
"token": token,
|
||||
"role": role,
|
||||
}))
|
||||
}
|
||||
|
||||
// allowLoginByIP / allowLoginByUsername return true when the call is
|
||||
// within budget; nil limiter always returns true (effectively disabled).
|
||||
func (h *wsHub) allowLoginByIP(c *wsClient) bool {
|
||||
if h.loginIPLimit == nil || c == nil || c.addr == "" {
|
||||
return true
|
||||
}
|
||||
return h.loginIPLimit.Allow(c.addr)
|
||||
}
|
||||
|
||||
func (h *wsHub) allowLoginByUsername(username string) bool {
|
||||
if h.loginUserLimit == nil || username == "" {
|
||||
return true
|
||||
}
|
||||
return h.loginUserLimit.Allow(username)
|
||||
}
|
||||
|
||||
func (h *wsHub) resetLoginThrottle(c *wsClient, username string) {
|
||||
if h.loginIPLimit != nil && c != nil && c.addr != "" {
|
||||
h.loginIPLimit.Reset(c.addr)
|
||||
}
|
||||
if h.loginUserLimit != nil && username != "" {
|
||||
h.loginUserLimit.Reset(username)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConnect kicks off a screen-sharing session for the browser. We send
|
||||
// COMMAND_SCREEN_SPY to the device's main TCP connection; the device then
|
||||
// opens a new sub-connection (TOKEN_BITMAPINFO) which the TCP side binds to
|
||||
// the device via hub.BindScreenConn. Frame relay to the browser is handled
|
||||
// in Phase 4.2 once frames actually arrive.
|
||||
//
|
||||
// Reply semantics: returning connect_result.ok=true (without width/height)
|
||||
// triggers the browser's "Waiting for video..." spinner. We can't deliver
|
||||
// width/height here because we don't yet know them — they show up in the
|
||||
// first TOKEN_BITMAPINFO from the device.
|
||||
// handleDisconnect detaches this client from any device it was watching and
|
||||
// — if no other authenticated client is still watching — closes the device's
|
||||
// screen sub-connection. Closing the TCP sub-conn is the signal the C++
|
||||
// device firmware uses to stop screen capture, so this is how we ask the
|
||||
// device to free its encoder.
|
||||
func (h *wsHub) handleDisconnect(c *wsClient, _ []byte) {
|
||||
// Mirror handleConnect: take h.mu so event-handler readers
|
||||
// (OnResolutionChange/OnScreenFrame) get a consistent view of c.watching.
|
||||
h.mu.Lock()
|
||||
prev := c.watching
|
||||
c.watching = ""
|
||||
h.mu.Unlock()
|
||||
c.queue([]byte(`{"cmd":"disconnect_result","ok":true}`))
|
||||
if prev != "" && h.countWatchers(prev) == 0 {
|
||||
h.devices.CloseScreen(prev)
|
||||
}
|
||||
}
|
||||
|
||||
// countWatchers returns how many authenticated clients still have their
|
||||
// `watching` field pointing at deviceID. Called from disconnect paths.
|
||||
func (h *wsHub) countWatchers(deviceID string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
n := 0
|
||||
for c := range h.clients {
|
||||
if c.watching == deviceID {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
||||
if !h.requireAuth(c, raw, "connect_result") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
|
||||
c.queue(mustJSON(map[string]any{"cmd": "connect_result", "ok": false, "msg": "Bad request"}))
|
||||
return
|
||||
}
|
||||
|
||||
// If a screen session is already live for this device (another browser
|
||||
// is already watching), reuse it: hand the new viewer the current
|
||||
// resolution and the most recent IDR keyframe so its decoder can start
|
||||
// rendering immediately, without waiting for the next IDR (~15 s).
|
||||
cache := h.devices.ScreenState(in.ID)
|
||||
if cache.Active {
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": "connect_result", "ok": true,
|
||||
"width": cache.Width, "height": cache.Height,
|
||||
}))
|
||||
if len(cache.Keyframe) > 0 {
|
||||
c.queueBinary(cache.Keyframe)
|
||||
}
|
||||
h.mu.Lock()
|
||||
c.watching = in.ID
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// No active session — kick the device to start capturing. We send the
|
||||
// same 32-byte COMMAND_SCREEN_SPY payload the C++ WebService sends:
|
||||
// [0]=COMMAND_SCREEN_SPY, [1]=0 (GDI), [2]=ALGORITHM_H264, [3]=1 (multi-screen),
|
||||
// [4..31]=0.
|
||||
cmd := make([]byte, 32)
|
||||
cmd[0] = protocol.CommandScreenSpy
|
||||
cmd[2] = protocol.AlgorithmH264
|
||||
cmd[3] = 1
|
||||
|
||||
// CRITICAL: bind c.watching BEFORE asking the device to start capturing.
|
||||
// On fast reconnects the device's screen sub-conn handshake completes in
|
||||
// <100 ms, so TOKEN_BITMAPINFO and even the first H264 frame can arrive
|
||||
// before this handler finishes — and the resolution_changed / frame
|
||||
// broadcasts in wsHub filter on c.watching. With the assignment after
|
||||
// SendToDevice the new viewer silently misses the very first IDR and
|
||||
// resolution_changed, leaving the page stuck on "Waiting for video".
|
||||
//
|
||||
// The write needs to share the lock event handlers use to read c.watching
|
||||
// (they iterate h.clients under h.mu.RLock). Without that the write is a
|
||||
// data race; on a fast reconnect the reader goroutine can keep observing
|
||||
// the previous value ("") long enough to drop the first resolution_changed
|
||||
// and the first IDR, which produces the exact "every other quick reconnect
|
||||
// goes black" symptom — the C++ server avoids it because it does the same
|
||||
// state mutation under std::mutex and reaps the memory-barrier as a bonus.
|
||||
h.mu.Lock()
|
||||
c.watching = in.ID
|
||||
h.mu.Unlock()
|
||||
|
||||
if err := h.devices.SendToDevice(in.ID, cmd); err != nil {
|
||||
// Roll back the watching flag if we never managed to kick capture.
|
||||
h.mu.Lock()
|
||||
c.watching = ""
|
||||
h.mu.Unlock()
|
||||
msg := "Device offline"
|
||||
if err != hub.ErrDeviceOffline {
|
||||
msg = "Failed to start screen capture"
|
||||
h.log.Error("SendToDevice(%s): %v", in.ID, err)
|
||||
}
|
||||
c.queue(mustJSON(map[string]any{"cmd": "connect_result", "ok": false, "msg": msg}))
|
||||
return
|
||||
}
|
||||
h.log.Info("[timing] COMMAND_SCREEN_SPY sent to device=%s (cold start)", in.ID)
|
||||
|
||||
c.queue(mustJSON(map[string]any{"cmd": "connect_result", "ok": true}))
|
||||
}
|
||||
|
||||
func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) {
|
||||
if !h.requireAuth(c, raw, "device_list") {
|
||||
return
|
||||
}
|
||||
devices := h.devices.ListDevices()
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": "device_list",
|
||||
"ok": true,
|
||||
"devices": devices,
|
||||
}))
|
||||
}
|
||||
|
||||
// requireAuth validates the token embedded in raw against the authenticator's
|
||||
// session store (not against c.token). Tokens live independently of WS
|
||||
// connections — the browser may reconnect after a visibility/network blip and
|
||||
// resume with the same token, so we must not tie validity to one WS lifetime.
|
||||
// On the first authenticated message we cache the token/role on the wsClient
|
||||
// so broadcasts know to deliver to this connection.
|
||||
func (h *wsHub) requireAuth(c *wsClient, raw []byte, replyCmd string) bool {
|
||||
var in struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
if in.Token == "" {
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
|
||||
return false
|
||||
}
|
||||
sess, err := h.auth.ValidateToken(in.Token)
|
||||
if err != nil {
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
|
||||
return false
|
||||
}
|
||||
if c.token == "" {
|
||||
c.token = in.Token
|
||||
c.role = sess.Role
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleMouse forwards one mouse event from the browser to the device's
|
||||
// screen sub-connection as a COMMAND_SCREEN_CONTROL packet carrying a
|
||||
// single MSG64. Mirrors the C++ CWebService::HandleMouse path
|
||||
// (server/2015Remote/WebService.cpp:773) so the client's
|
||||
// CScreenManager::ProcessCommand sees identical bytes regardless of which
|
||||
// server is serving the device. Unauthenticated and unknown event types
|
||||
// drop silently — input is high-frequency, error replies would just spam
|
||||
// the WS.
|
||||
func (h *wsHub) handleMouse(c *wsClient, raw []byte) {
|
||||
if !h.requireAuth(c, raw, "mouse_result") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Type string `json:"type"`
|
||||
X int32 `json:"x"`
|
||||
Y int32 `json:"y"`
|
||||
Button int `json:"button"`
|
||||
Delta int `json:"delta"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return
|
||||
}
|
||||
deviceID := c.watching
|
||||
if deviceID == "" {
|
||||
return // browser hasn't picked a device yet
|
||||
}
|
||||
|
||||
var message, wParam uint64
|
||||
switch in.Type {
|
||||
case "down":
|
||||
switch in.Button {
|
||||
case 0:
|
||||
message, wParam = protocol.WMLButtonDown, protocol.MKLButton
|
||||
case 1:
|
||||
message, wParam = protocol.WMMButtonDown, protocol.MKMButton
|
||||
case 2:
|
||||
message, wParam = protocol.WMRButtonDown, protocol.MKRButton
|
||||
default:
|
||||
return
|
||||
}
|
||||
case "up":
|
||||
switch in.Button {
|
||||
case 0:
|
||||
message = protocol.WMLButtonUp
|
||||
case 1:
|
||||
message = protocol.WMMButtonUp
|
||||
case 2:
|
||||
message = protocol.WMRButtonUp
|
||||
default:
|
||||
return
|
||||
}
|
||||
case "move":
|
||||
message = protocol.WMMouseMove
|
||||
case "wheel":
|
||||
message = protocol.WMMouseWheel
|
||||
// Windows expects ±120 per notch in HIWORD(wParam). Browsers report
|
||||
// `deltaY` in pixels with sign flipped relative to Win32 conventions
|
||||
// (positive deltaY = scroll down). Normalize to one notch per call,
|
||||
// signed so up scrolls "up" on the remote.
|
||||
var wheel int16
|
||||
switch {
|
||||
case in.Delta > 0:
|
||||
wheel = -120
|
||||
case in.Delta < 0:
|
||||
wheel = 120
|
||||
}
|
||||
wParam = uint64(uint16(wheel)) << 16
|
||||
case "dblclick":
|
||||
// Windows synthesizes double-click from rapid down/up sequences, so
|
||||
// the C++ server only forwards this for macOS clients. We don't
|
||||
// currently track client OS on the Go side; drop the event — the
|
||||
// down/up pair the browser already sent is sufficient on Windows.
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
lParam := protocol.MakeLParam(in.X, in.Y)
|
||||
pkt := protocol.BuildScreenControlPacket(message, wParam, lParam, in.X, in.Y, tickMillis())
|
||||
_ = h.devices.SendToScreen(deviceID, pkt)
|
||||
}
|
||||
|
||||
// handleKey forwards one keyboard event to the device. lParam is built to
|
||||
// match the Windows convention so applications relying on TranslateMessage
|
||||
// (e.g. text input fields) behave correctly. Mirrors
|
||||
// CWebService::HandleKey at server/2015Remote/WebService.cpp:878.
|
||||
//
|
||||
// Scan code: the C++ server resolves the VK -> scan code via the local
|
||||
// MapVirtualKey. Go is cross-platform so we leave the scan-code bits zero;
|
||||
// the client side ultimately delivers events via SendInput/keybd_event
|
||||
// which accept VK-only input, and the extended-key bit (bit 24) is what
|
||||
// actually matters for distinguishing numpad/arrow keys.
|
||||
func (h *wsHub) handleKey(c *wsClient, raw []byte) {
|
||||
if !h.requireAuth(c, raw, "key_result") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
KeyCode int `json:"keyCode"`
|
||||
Down bool `json:"down"`
|
||||
Alt bool `json:"alt"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return
|
||||
}
|
||||
if in.KeyCode == protocol.VKLWin || in.KeyCode == protocol.VKRWin {
|
||||
return // Windows keys stay local; matches both C++ server and client
|
||||
}
|
||||
deviceID := c.watching
|
||||
if deviceID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var message uint64
|
||||
switch {
|
||||
case in.Alt && in.Down:
|
||||
message = protocol.WMSysKeyDown
|
||||
case in.Alt && !in.Down:
|
||||
message = protocol.WMSysKeyUp
|
||||
case in.Down:
|
||||
message = protocol.WMKeyDown
|
||||
default:
|
||||
message = protocol.WMKeyUp
|
||||
}
|
||||
|
||||
// lParam layout (Win32 keyboard message):
|
||||
// bits 0..15: repeat count (= 1)
|
||||
// bits 16..23: scan code (we leave zero — see handleKey doc)
|
||||
// bit 24: extended-key flag (arrows, numpad, RCtrl, RAlt, ...)
|
||||
// bit 29: context code (Alt held)
|
||||
// bits 30..31: previous-key/transition flags (both set on key-up)
|
||||
lParam := uint64(1)
|
||||
if protocol.IsExtendedKey(in.KeyCode) {
|
||||
lParam |= 1 << 24
|
||||
}
|
||||
if in.Alt {
|
||||
lParam |= 1 << 29
|
||||
}
|
||||
if !in.Down {
|
||||
lParam |= 3 << 30
|
||||
}
|
||||
|
||||
wParam := uint64(uint32(in.KeyCode))
|
||||
pkt := protocol.BuildScreenControlPacket(message, wParam, lParam, 0, 0, tickMillis())
|
||||
_ = h.devices.SendToScreen(deviceID, pkt)
|
||||
}
|
||||
|
||||
// handleCreateUser provisions a new web account. Admin-only. Mirrors the
|
||||
// C++ CWebService::HandleCreateUser semantics (WebService.cpp:1009): the
|
||||
// password is hashed with a fresh salt, the user table is persisted, and
|
||||
// any duplicate username is rejected.
|
||||
func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
|
||||
const replyCmd = "create_user_result"
|
||||
if !h.requireAdmin(c, raw, replyCmd) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
AllowedGroups []string `json:"allowed_groups"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
|
||||
return
|
||||
}
|
||||
if in.Role == "" {
|
||||
in.Role = "viewer"
|
||||
}
|
||||
if err := h.auth.CreateUser(in.Username, in.Password, in.Role, in.AllowedGroups); err != nil {
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
|
||||
return
|
||||
}
|
||||
h.log.Info("user created by %s: name=%s role=%s groups=%d",
|
||||
c.role, in.Username, in.Role, len(in.AllowedGroups))
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
|
||||
}
|
||||
|
||||
// handleDeleteUser removes an account and revokes any of its live sessions.
|
||||
// Admin-only. The bootstrap "admin" account is rejected at the wsauth layer.
|
||||
func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
|
||||
const replyCmd = "delete_user_result"
|
||||
if !h.requireAdmin(c, raw, replyCmd) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
|
||||
return
|
||||
}
|
||||
if err := h.auth.DeleteUser(in.Username); err != nil {
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
|
||||
return
|
||||
}
|
||||
h.log.Info("user deleted by %s: name=%s", c.role, in.Username)
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
|
||||
}
|
||||
|
||||
// handleListUsers returns the account table to admin callers. Field shape
|
||||
// matches the C++ list_users_result the browser already parses: an array
|
||||
// of {username, role, allowed_groups}.
|
||||
func (h *wsHub) handleListUsers(c *wsClient, raw []byte) {
|
||||
const replyCmd = "list_users_result"
|
||||
if !h.requireAdmin(c, raw, replyCmd) {
|
||||
return
|
||||
}
|
||||
users := h.auth.ListUsers()
|
||||
out := make([]map[string]any, 0, len(users))
|
||||
for _, u := range users {
|
||||
groups := u.AllowedGroups
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"username": u.Username,
|
||||
"role": u.Role,
|
||||
"allowed_groups": groups,
|
||||
})
|
||||
}
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd,
|
||||
"ok": true,
|
||||
"users": out,
|
||||
}))
|
||||
}
|
||||
|
||||
// handleGetGroups returns the deduplicated set of group labels currently
|
||||
// observed on online devices, plus a synthetic "default" entry that the
|
||||
// admin UI uses for ungrouped devices. Admin-only — non-admins don't get
|
||||
// to enumerate the device fleet's groups. Matches the contract of
|
||||
// CWebService::HandleGetGroups (WebService.cpp:1130).
|
||||
func (h *wsHub) handleGetGroups(c *wsClient, raw []byte) {
|
||||
const replyCmd = "groups"
|
||||
if !h.requireAdmin(c, raw, replyCmd) {
|
||||
return
|
||||
}
|
||||
seen := map[string]struct{}{"default": {}}
|
||||
for _, d := range h.devices.ListDevices() {
|
||||
g := d.Group
|
||||
if g == "" {
|
||||
g = "default"
|
||||
}
|
||||
seen[g] = struct{}{}
|
||||
}
|
||||
groups := make([]string, 0, len(seen))
|
||||
for g := range seen {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
sort.Strings(groups)
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd,
|
||||
"ok": true,
|
||||
"groups": groups,
|
||||
}))
|
||||
}
|
||||
|
||||
// handleTermOpen kicks off a web terminal session. On success the wsClient
|
||||
// records `termWatching = deviceID` so subsequent term_input / term_resize
|
||||
// have a target, and the hub sends COMMAND_SHELL to the device. The
|
||||
// device's shell sub-conn arrives separately and is bound by the TCP layer
|
||||
// via Hub.BindTerminalConn; that step fires OnTerminalReady to flip the
|
||||
// browser into "ready" state.
|
||||
//
|
||||
// Single-viewer is enforced at the hub. The C++ side matches:
|
||||
// server/2015Remote/WebService.cpp:1799.
|
||||
func (h *wsHub) handleTermOpen(c *wsClient, raw []byte) {
|
||||
const replyCmd = "term_closed"
|
||||
if !h.requireAuth(c, raw, replyCmd) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Bad request"}))
|
||||
return
|
||||
}
|
||||
|
||||
// Pin termWatching BEFORE asking the hub to open the session: the
|
||||
// device's shell sub-conn can arrive in <100 ms on LAN, and
|
||||
// OnTerminalReady filters by termWatching. Same race shape as the
|
||||
// screen path in handleConnect.
|
||||
h.mu.Lock()
|
||||
if c.termWatching != "" && c.termWatching != in.ID {
|
||||
h.mu.Unlock()
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false,
|
||||
"msg": "Close current terminal before opening another",
|
||||
}))
|
||||
return
|
||||
}
|
||||
c.termWatching = in.ID
|
||||
h.mu.Unlock()
|
||||
|
||||
if err := h.devices.OpenTerminalSession(in.ID); err != nil {
|
||||
h.mu.Lock()
|
||||
c.termWatching = ""
|
||||
h.mu.Unlock()
|
||||
msg := "Device offline"
|
||||
switch err {
|
||||
case hub.ErrTerminalBusy:
|
||||
msg = "Terminal already open by another viewer"
|
||||
case hub.ErrDeviceOffline:
|
||||
msg = "Device offline"
|
||||
default:
|
||||
msg = "Failed to start terminal"
|
||||
h.log.Error("OpenTerminalSession(%s): %v", in.ID, err)
|
||||
}
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": msg}))
|
||||
return
|
||||
}
|
||||
h.log.Info("term_open: device=%s role=%s", in.ID, c.role)
|
||||
}
|
||||
|
||||
// handleTermInput forwards xterm.js keystrokes to the device's shell
|
||||
// sub-conn verbatim. The client's ConPTYManager treats anything that
|
||||
// isn't a known control byte (CMD_TERMINAL_RESIZE / COMMAND_NEXT) as
|
||||
// raw PTY input — see client/ConPTYManager.cpp:244.
|
||||
func (h *wsHub) handleTermInput(c *wsClient, raw []byte) {
|
||||
if !h.requireAuth(c, raw, "term_input_result") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return
|
||||
}
|
||||
if in.ID == "" || in.Data == "" {
|
||||
return
|
||||
}
|
||||
if c.termWatching != in.ID {
|
||||
return // someone else's session, or no session
|
||||
}
|
||||
_ = h.devices.SendToTerminal(in.ID, []byte(in.Data))
|
||||
}
|
||||
|
||||
// handleTermResize forwards xterm.js fit/resize events to the device's
|
||||
// PTY. Legacy cmd-pipe mode silently ignores resize (the underlying
|
||||
// pipes have no notion of geometry).
|
||||
func (h *wsHub) handleTermResize(c *wsClient, raw []byte) {
|
||||
if !h.requireAuth(c, raw, "term_resize_result") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return
|
||||
}
|
||||
if in.ID == "" || in.Cols <= 0 || in.Rows <= 0 {
|
||||
return
|
||||
}
|
||||
if c.termWatching != in.ID {
|
||||
return
|
||||
}
|
||||
if !h.devices.TerminalIsPTY(in.ID) {
|
||||
return // legacy cmd pipe — ignored, same as the C++ guard
|
||||
}
|
||||
_ = h.devices.SendToTerminal(in.ID, protocol.BuildTerminalResize(in.Cols, in.Rows))
|
||||
}
|
||||
|
||||
// handleTermClose tears down the active session. CloseTerminalSession
|
||||
// fires OnTerminalClosed which the wsHub broadcast loop turns into the
|
||||
// front-end's `term_closed` notification — no need to ack here.
|
||||
func (h *wsHub) handleTermClose(c *wsClient, raw []byte) {
|
||||
if !h.requireAuth(c, raw, "term_closed") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
|
||||
return
|
||||
}
|
||||
if c.termWatching != in.ID {
|
||||
return
|
||||
}
|
||||
h.devices.CloseTerminalSession(in.ID)
|
||||
}
|
||||
|
||||
// tickMillis returns a 32-bit-truncated ms timestamp suitable for the
|
||||
// MSG64.time field. The client compares these with GetTickCount(), which
|
||||
// is also a 32-bit ms counter — exact origin doesn't matter, only that
|
||||
// successive events have non-decreasing values within the wrap window.
|
||||
func tickMillis() uint32 {
|
||||
return uint32(time.Now().UnixMilli() & 0xFFFFFFFF)
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
// All callers pass simple map[string]any with primitive values;
|
||||
// marshal can't realistically fail. If it does, return a safe fallback.
|
||||
return []byte(`{"cmd":"error","msg":"internal encode error"}`)
|
||||
}
|
||||
return b
|
||||
}
|
||||
110
server/go/wsauth/ratelimit.go
Normal file
110
server/go/wsauth/ratelimit.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package wsauth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter is a sliding-window per-key counter used to throttle login
|
||||
// attempts. Two instances are typically created: one keyed by client IP
|
||||
// (to slow distributed brute force), one keyed by username (to slow
|
||||
// targeted attacks against a known account).
|
||||
//
|
||||
// Design notes:
|
||||
// - Denied attempts are NOT recorded — the window slides naturally and a
|
||||
// legitimate user who fat-fingers their password recovers as soon as
|
||||
// the oldest attempt ages out, while a determined attacker is capped
|
||||
// at `limit` successful attempts per `window` indefinitely.
|
||||
// - Lazy cleanup: stale timestamps for a key are pruned on every Allow()
|
||||
// call. Truly idle keys are GC'd by Sweep(), which callers should run
|
||||
// periodically from a background goroutine.
|
||||
// - Map size is bounded by the count of recently-active keys; for the
|
||||
// web UI's expected load (a handful of users + occasional scanners),
|
||||
// no extra GC pressure considerations needed.
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
window time.Duration
|
||||
entries map[string][]time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter returns a limiter that allows up to `limit` events per
|
||||
// `window` duration per key. Zero or negative limit/window disables the
|
||||
// limiter (Allow always returns true) — useful for tests / dev mode.
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
limit: limit,
|
||||
window: window,
|
||||
entries: make(map[string][]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// Allow records an attempt for `key` if and only if the caller is under
|
||||
// the per-key limit. Returns true when allowed, false when over limit.
|
||||
// Empty key is treated as "no throttle" (returns true without recording)
|
||||
// so the caller can fall through when the IP/username is unavailable.
|
||||
func (r *RateLimiter) Allow(key string) bool {
|
||||
if r == nil || r.limit <= 0 || r.window <= 0 || key == "" {
|
||||
return true
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-r.window)
|
||||
times := r.entries[key]
|
||||
// Compact in place — keep only timestamps within the window.
|
||||
keep := times[:0]
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(keep) >= r.limit {
|
||||
// Update the map even when denying so the compacted slice doesn't
|
||||
// keep stale entries forever. Don't append the new attempt: that
|
||||
// would let attackers extend the window arbitrarily.
|
||||
r.entries[key] = keep
|
||||
return false
|
||||
}
|
||||
|
||||
r.entries[key] = append(keep, time.Now())
|
||||
return true
|
||||
}
|
||||
|
||||
// Reset clears state for a key. Call on successful login to give the user
|
||||
// a fresh budget — otherwise a string of failed attempts followed by a
|
||||
// correct one still leaves the budget partially consumed.
|
||||
func (r *RateLimiter) Reset(key string) {
|
||||
if r == nil || key == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
delete(r.entries, key)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// Sweep removes entries whose timestamps have all aged out of the window.
|
||||
// Safe to call concurrently with Allow. Intended for periodic invocation
|
||||
// from a background ticker (e.g. every window-length) to bound the map.
|
||||
func (r *RateLimiter) Sweep() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cutoff := time.Now().Add(-r.window)
|
||||
for key, times := range r.entries {
|
||||
keep := times[:0]
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
delete(r.entries, key)
|
||||
} else {
|
||||
r.entries[key] = keep
|
||||
}
|
||||
}
|
||||
}
|
||||
467
server/go/wsauth/wsauth.go
Normal file
467
server/go/wsauth/wsauth.go
Normal file
@@ -0,0 +1,467 @@
|
||||
// Package wsauth provides authentication and session-token management for
|
||||
// the web service. Protocol surface (challenge nonce + SHA256-based response
|
||||
// and SHA256(password+salt) hashes) is kept compatible with the existing
|
||||
// browser front-end and users.json format. Internal token representation is
|
||||
// deliberately different from the C++ counterpart — opaque random hex strings
|
||||
// keyed into an in-memory map — to avoid leaking the proprietary token format.
|
||||
package wsauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Default knobs. Override via SetDefaults at startup if needed.
|
||||
const (
|
||||
DefaultTokenExpire = 24 * time.Hour
|
||||
nonceBytes = 16 // 32 hex chars
|
||||
tokenBytes = 32 // 64 hex chars
|
||||
saltBytes = 8 // 16 hex chars
|
||||
)
|
||||
|
||||
// ErrInvalidToken is returned when a token is unknown or expired.
|
||||
var ErrInvalidToken = errors.New("invalid or expired token")
|
||||
|
||||
// User is the credentials record for one web account.
|
||||
type User struct {
|
||||
Username string
|
||||
PasswordHash string // SHA256(password+salt) in lowercase hex
|
||||
Salt string // empty for admin (matches C++ convention)
|
||||
Role string // "admin" or "viewer"
|
||||
// AllowedGroups restricts which device groups this user can view. Empty
|
||||
// slice = no device access (admin role is treated as "all" elsewhere
|
||||
// without consulting this list). Matches the C++ WebUser.allowed_groups
|
||||
// field one-for-one so users.json is interchangeable between the two
|
||||
// servers.
|
||||
AllowedGroups []string
|
||||
}
|
||||
|
||||
// Session is the authenticated state attached to a valid token.
|
||||
type Session struct {
|
||||
Username string
|
||||
Role string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Authenticator owns the user table and the active token map. It is safe to
|
||||
// use from multiple goroutines.
|
||||
type Authenticator struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]*User // username -> user
|
||||
tokens map[string]*Session // token -> session
|
||||
tokenExpire time.Duration
|
||||
// usersFile is the persistence target for non-admin accounts (admin
|
||||
// lives in env/master-password only and is never written out, matching
|
||||
// the C++ SaveUsers behavior). Empty disables persistence.
|
||||
usersFile string
|
||||
}
|
||||
|
||||
// New returns an empty Authenticator. Call AddUser to populate.
|
||||
func New() *Authenticator {
|
||||
return &Authenticator{
|
||||
users: make(map[string]*User),
|
||||
tokens: make(map[string]*Session),
|
||||
tokenExpire: DefaultTokenExpire,
|
||||
}
|
||||
}
|
||||
|
||||
// SetTokenExpire overrides the default session lifetime.
|
||||
func (a *Authenticator) SetTokenExpire(d time.Duration) {
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.tokenExpire = d
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// AddUser registers a user. PasswordHash should already be
|
||||
// SHA256(password+salt) in lowercase hex; pass empty Salt to mirror the
|
||||
// admin-style "no salt" convention used by the C++ side.
|
||||
func (a *Authenticator) AddUser(u User) {
|
||||
if u.Username == "" {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.users[u.Username] = &u
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// AddAdminFromPlainPassword is a convenience for the bootstrap admin.
|
||||
// Unlike legacy convention, the admin record is given a real per-instance
|
||||
// salt — exposing an empty salt for admin while everyone else has a real
|
||||
// 16-hex one would let an unauthenticated probe distinguish admin from
|
||||
// other accounts via /get_salt alone. The cost is a tiny break in
|
||||
// users.json schema compat: admin is never persisted to users.json
|
||||
// anyway (snapshotPersistableLocked excludes it), so this is in-memory
|
||||
// only.
|
||||
func (a *Authenticator) AddAdminFromPlainPassword(username, plainPassword string) {
|
||||
salt, err := NewSalt()
|
||||
if err != nil {
|
||||
// Fall back to deterministic salt derived from the password hash
|
||||
// rather than empty — preserves the uniform-shape property even
|
||||
// if crypto/rand briefly errors at startup.
|
||||
salt = ComputeSHA256(plainPassword)[:saltBytes*2]
|
||||
}
|
||||
a.AddUser(User{
|
||||
Username: username,
|
||||
PasswordHash: HashPassword(plainPassword, salt),
|
||||
Salt: salt,
|
||||
Role: "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// CreateUser registers a new account, persists the user table, and returns
|
||||
// nil on success. Mirrors the C++ CWebService::CreateUser semantics:
|
||||
// - role must be "admin" or "viewer"
|
||||
// - "admin" is reserved for the bootstrap account and cannot be created
|
||||
// via this API
|
||||
// - duplicate usernames are rejected
|
||||
//
|
||||
// Password is hashed with a fresh per-user salt before being stored.
|
||||
func (a *Authenticator) CreateUser(username, plainPassword, role string, allowedGroups []string) error {
|
||||
if username == "" || plainPassword == "" {
|
||||
return errors.New("username and password required")
|
||||
}
|
||||
if username == "admin" {
|
||||
return errors.New("'admin' is reserved")
|
||||
}
|
||||
if role != "admin" && role != "viewer" {
|
||||
return errors.New("role must be 'admin' or 'viewer'")
|
||||
}
|
||||
|
||||
salt, err := NewSalt()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := User{
|
||||
Username: username,
|
||||
PasswordHash: HashPassword(plainPassword, salt),
|
||||
Salt: salt,
|
||||
Role: role,
|
||||
AllowedGroups: append([]string(nil), allowedGroups...),
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
if _, exists := a.users[username]; exists {
|
||||
a.mu.Unlock()
|
||||
return errors.New("user already exists")
|
||||
}
|
||||
a.users[username] = &u
|
||||
path := a.usersFile
|
||||
snapshot := a.snapshotPersistableLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
if path != "" {
|
||||
return writeUsersFile(path, snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user account and persists the change. The bootstrap
|
||||
// admin (always Role=="admin" with empty Salt) is protected: deleting it
|
||||
// would lock everyone out of the UI with no way back in.
|
||||
func (a *Authenticator) DeleteUser(username string) error {
|
||||
if username == "" || username == "admin" {
|
||||
return errors.New("cannot delete admin")
|
||||
}
|
||||
a.mu.Lock()
|
||||
if _, ok := a.users[username]; !ok {
|
||||
a.mu.Unlock()
|
||||
return errors.New("user not found")
|
||||
}
|
||||
delete(a.users, username)
|
||||
// Also drop any live sessions belonging to that user so they don't
|
||||
// outlive their account.
|
||||
for tok, s := range a.tokens {
|
||||
if s.Username == username {
|
||||
delete(a.tokens, tok)
|
||||
}
|
||||
}
|
||||
path := a.usersFile
|
||||
snapshot := a.snapshotPersistableLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
if path != "" {
|
||||
return writeUsersFile(path, snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUsers returns a stable, copied snapshot of all users in name order.
|
||||
// PasswordHash and Salt are zeroed in the returned records so callers can
|
||||
// JSON-marshal the result without leaking credentials.
|
||||
func (a *Authenticator) ListUsers() []User {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
out := make([]User, 0, len(a.users))
|
||||
for _, u := range a.users {
|
||||
c := *u
|
||||
c.PasswordHash = ""
|
||||
c.Salt = ""
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
||||
return out
|
||||
}
|
||||
|
||||
// GetSalt returns the per-user salt for an existing user, or a
|
||||
// deterministic 16-hex pseudo-salt for an unknown user. The ok flag
|
||||
// reports which case occurred, so callers can decide whether to update
|
||||
// rate-limit / audit state — but the returned salt itself is shaped
|
||||
// identically (16 hex chars) in both cases, defeating the user-existence
|
||||
// probe an attacker would otherwise mount via /get_salt.
|
||||
//
|
||||
// The pseudo-salt is derived from a server-instance secret (the admin
|
||||
// password hash, taken at first call) mixed with the username, so the
|
||||
// same unknown user always sees the same fake salt across requests.
|
||||
// Without this, an attacker could fingerprint the "fake-salt branch"
|
||||
// by submitting the same username twice and watching for differences.
|
||||
func (a *Authenticator) GetSalt(username string) (string, bool) {
|
||||
a.mu.RLock()
|
||||
u, ok := a.users[username]
|
||||
a.mu.RUnlock()
|
||||
if ok {
|
||||
return u.Salt, true
|
||||
}
|
||||
return a.fakeSalt(username), false
|
||||
}
|
||||
|
||||
// fakeSalt derives a deterministic 16-hex value for unknown usernames.
|
||||
// The secret pepper is the bootstrap admin's password hash — present as
|
||||
// long as the server has any admin, deterministic per deployment, never
|
||||
// transmitted. Reveals nothing useful to an attacker even if reverse-
|
||||
// engineered: the only thing they can do with it is reproduce the fake
|
||||
// salt, which they already see in the response.
|
||||
func (a *Authenticator) fakeSalt(username string) string {
|
||||
a.mu.RLock()
|
||||
pepper := ""
|
||||
if admin, ok := a.users["admin"]; ok {
|
||||
pepper = admin.PasswordHash
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
digest := ComputeSHA256("yama-fake-salt|" + pepper + "|" + username)
|
||||
return digest[:saltBytes*2]
|
||||
}
|
||||
|
||||
// VerifyLogin checks a challenge-response login. The browser sends
|
||||
// response = SHA256(passwordHash + nonce). On success the function mints a
|
||||
// new session token, stores it, and returns (token, role, nil).
|
||||
func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, role string, err error) {
|
||||
a.mu.RLock()
|
||||
u, ok := a.users[username]
|
||||
expire := a.tokenExpire
|
||||
a.mu.RUnlock()
|
||||
if !ok {
|
||||
return "", "", errors.New("invalid credentials")
|
||||
}
|
||||
expected := ComputeSHA256(u.PasswordHash + nonce)
|
||||
if response != expected {
|
||||
return "", "", errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
token, err = randomHex(tokenBytes)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.tokens[token] = &Session{
|
||||
Username: username,
|
||||
Role: u.Role,
|
||||
ExpiresAt: time.Now().Add(expire),
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return token, u.Role, nil
|
||||
}
|
||||
|
||||
// usersFileEntry is the on-disk shape of one record in users.json. Field
|
||||
// names match the C++ WebService SaveUsers output exactly so the two
|
||||
// servers can share the file.
|
||||
type usersFileEntry struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Salt string `json:"salt"`
|
||||
Role string `json:"role"`
|
||||
AllowedGroups []string `json:"allowed_groups"`
|
||||
}
|
||||
|
||||
type usersFile struct {
|
||||
Users []usersFileEntry `json:"users"`
|
||||
}
|
||||
|
||||
// SetUsersFile points the authenticator at a JSON file used to persist
|
||||
// non-admin accounts and loads any existing entries. Calling it again with
|
||||
// a different path is allowed but the previous file is not re-read on a
|
||||
// nil-arg call — initialize once at startup. Missing files are not an
|
||||
// error; they're treated as an empty user table.
|
||||
func (a *Authenticator) SetUsersFile(path string) error {
|
||||
a.mu.Lock()
|
||||
a.usersFile = path
|
||||
a.mu.Unlock()
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var f usersFile
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return err
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, e := range f.Users {
|
||||
// Skip admin: it's always sourced from env/master password and
|
||||
// already injected via AddAdminFromPlainPassword.
|
||||
if e.Username == "" || e.Username == "admin" {
|
||||
continue
|
||||
}
|
||||
if e.PasswordHash == "" {
|
||||
continue
|
||||
}
|
||||
role := e.Role
|
||||
if role == "" {
|
||||
role = "viewer"
|
||||
}
|
||||
a.users[e.Username] = &User{
|
||||
Username: e.Username,
|
||||
PasswordHash: e.PasswordHash,
|
||||
Salt: e.Salt,
|
||||
Role: role,
|
||||
AllowedGroups: append([]string(nil), e.AllowedGroups...),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// snapshotPersistableLocked builds the on-disk slice while a.mu is held by
|
||||
// the caller. Admin records are excluded — they live in env, not in the file.
|
||||
func (a *Authenticator) snapshotPersistableLocked() []usersFileEntry {
|
||||
out := make([]usersFileEntry, 0, len(a.users))
|
||||
for _, u := range a.users {
|
||||
if u.Username == "admin" {
|
||||
continue
|
||||
}
|
||||
groups := append([]string{}, u.AllowedGroups...)
|
||||
out = append(out, usersFileEntry{
|
||||
Username: u.Username,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Salt: u.Salt,
|
||||
Role: u.Role,
|
||||
AllowedGroups: groups,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
||||
return out
|
||||
}
|
||||
|
||||
// writeUsersFile writes the user list atomically: encode to a temp file in
|
||||
// the same directory, fsync, rename — so a crash mid-write can't leave the
|
||||
// service starting up with a half-written users.json next time.
|
||||
func writeUsersFile(path string, entries []usersFileEntry) error {
|
||||
data, err := json.MarshalIndent(usersFile{Users: entries}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "users-*.json.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateToken returns the session for a token or ErrInvalidToken. Expired
|
||||
// tokens are removed lazily as they are looked up.
|
||||
func (a *Authenticator) ValidateToken(token string) (*Session, error) {
|
||||
a.mu.RLock()
|
||||
s, ok := a.tokens[token]
|
||||
a.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if time.Now().After(s.ExpiresAt) {
|
||||
a.mu.Lock()
|
||||
delete(a.tokens, token)
|
||||
a.mu.Unlock()
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RevokeToken removes a token from the active set. No-op for unknown tokens.
|
||||
func (a *Authenticator) RevokeToken(token string) {
|
||||
a.mu.Lock()
|
||||
delete(a.tokens, token)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// NewNonce returns a fresh challenge nonce (hex string). Each WS connection
|
||||
// should receive exactly one nonce, consumed by a single login attempt.
|
||||
func NewNonce() (string, error) {
|
||||
return randomHex(nonceBytes)
|
||||
}
|
||||
|
||||
// NewSalt returns a fresh per-user salt (hex string).
|
||||
func NewSalt() (string, error) {
|
||||
return randomHex(saltBytes)
|
||||
}
|
||||
|
||||
// ComputeSHA256 returns the lowercase-hex SHA256 of s.
|
||||
func ComputeSHA256(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// HashPassword computes the stored hash for a (password, salt) pair using
|
||||
// the same scheme as the existing C++ users.json: SHA256(password + salt).
|
||||
func HashPassword(password, salt string) string {
|
||||
return ComputeSHA256(password + salt)
|
||||
}
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
202
server/go/wsauth/wsauth_test.go
Normal file
202
server/go/wsauth/wsauth_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package wsauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSHA256Vector(t *testing.T) {
|
||||
// Known vector — keeps us honest against accidental algorithm changes.
|
||||
got := ComputeSHA256("abc")
|
||||
want := "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
if got != want {
|
||||
t.Fatalf("SHA256(abc): got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// adminLoginResponse helps tests compute the right login response for an
|
||||
// admin account that now uses a real per-instance salt.
|
||||
func adminLoginResponse(t *testing.T, a *Authenticator, username, password, nonce string) string {
|
||||
t.Helper()
|
||||
salt, ok := a.GetSalt(username)
|
||||
if !ok {
|
||||
t.Fatalf("admin %s not registered", username)
|
||||
}
|
||||
return ComputeSHA256(HashPassword(password, salt) + nonce)
|
||||
}
|
||||
|
||||
func TestLoginRoundTripAdmin(t *testing.T) {
|
||||
a := New()
|
||||
a.AddAdminFromPlainPassword("admin", "hunter2")
|
||||
|
||||
salt, ok := a.GetSalt("admin")
|
||||
if !ok {
|
||||
t.Fatal("admin should be found")
|
||||
}
|
||||
if len(salt) != 2*saltBytes {
|
||||
t.Fatalf("admin salt should be a real 16-hex value, got %q (len=%d)", salt, len(salt))
|
||||
}
|
||||
|
||||
nonce := "abc123"
|
||||
response := adminLoginResponse(t, a, "admin", "hunter2", nonce)
|
||||
|
||||
token, role, err := a.VerifyLogin("admin", response, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyLogin: %v", err)
|
||||
}
|
||||
if role != "admin" {
|
||||
t.Fatalf("role: got %q want admin", role)
|
||||
}
|
||||
if len(token) != 2*tokenBytes {
|
||||
t.Fatalf("token length: got %d want %d", len(token), 2*tokenBytes)
|
||||
}
|
||||
|
||||
sess, err := a.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken: %v", err)
|
||||
}
|
||||
if sess.Username != "admin" || sess.Role != "admin" {
|
||||
t.Fatalf("session: %+v", sess)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRoundTripViewerWithSalt(t *testing.T) {
|
||||
a := New()
|
||||
salt, _ := NewSalt()
|
||||
a.AddUser(User{
|
||||
Username: "alice",
|
||||
PasswordHash: HashPassword("p@ss", salt),
|
||||
Salt: salt,
|
||||
Role: "viewer",
|
||||
})
|
||||
|
||||
gotSalt, ok := a.GetSalt("alice")
|
||||
if !ok || gotSalt != salt {
|
||||
t.Fatalf("salt: ok=%v got=%q want=%q", ok, gotSalt, salt)
|
||||
}
|
||||
|
||||
nonce, _ := NewNonce()
|
||||
response := ComputeSHA256(HashPassword("p@ss", salt) + nonce)
|
||||
_, role, err := a.VerifyLogin("alice", response, nonce)
|
||||
if err != nil || role != "viewer" {
|
||||
t.Fatalf("VerifyLogin: role=%q err=%v", role, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSaltUnknownUserShape verifies the salt-probe mitigation: an
|
||||
// unknown user must get back a value that's shape-identical to a real
|
||||
// salt, so an attacker can't tell from /get_salt alone whether a
|
||||
// username exists.
|
||||
func TestGetSaltUnknownUserShape(t *testing.T) {
|
||||
a := New()
|
||||
a.AddAdminFromPlainPassword("admin", "pw")
|
||||
|
||||
fake, ok := a.GetSalt("nobody")
|
||||
if ok {
|
||||
t.Fatal("ok should be false for unknown user")
|
||||
}
|
||||
if len(fake) != 2*saltBytes {
|
||||
t.Fatalf("fake salt should be %d hex chars; got %q (len=%d)", 2*saltBytes, fake, len(fake))
|
||||
}
|
||||
// Determinism: repeated probes for the same username get the same fake.
|
||||
fake2, _ := a.GetSalt("nobody")
|
||||
if fake != fake2 {
|
||||
t.Fatalf("fake salt should be deterministic for repeated probes; got %q vs %q", fake, fake2)
|
||||
}
|
||||
// Different usernames get different fake salts.
|
||||
other, _ := a.GetSalt("ghost")
|
||||
if fake == other {
|
||||
t.Fatalf("fake salts should differ across usernames; both = %q", fake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsWrongResponse(t *testing.T) {
|
||||
a := New()
|
||||
a.AddAdminFromPlainPassword("admin", "x")
|
||||
_, _, err := a.VerifyLogin("admin", "deadbeef", "nonce")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad response")
|
||||
}
|
||||
_, _, err = a.VerifyLogin("ghost", "anything", "anything")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExpiry(t *testing.T) {
|
||||
a := New()
|
||||
a.SetTokenExpire(50 * time.Millisecond)
|
||||
a.AddAdminFromPlainPassword("admin", "x")
|
||||
nonce, _ := NewNonce()
|
||||
response := adminLoginResponse(t, a, "admin", "x", nonce)
|
||||
token, _, err := a.VerifyLogin("admin", response, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.ValidateToken(token); err != nil {
|
||||
t.Fatalf("fresh token should validate: %v", err)
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if _, err := a.ValidateToken(token); err == nil {
|
||||
t.Fatal("expired token should not validate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevoke(t *testing.T) {
|
||||
a := New()
|
||||
a.AddAdminFromPlainPassword("admin", "x")
|
||||
nonce, _ := NewNonce()
|
||||
response := adminLoginResponse(t, a, "admin", "x", nonce)
|
||||
token, _, _ := a.VerifyLogin("admin", response, nonce)
|
||||
a.RevokeToken(token)
|
||||
if _, err := a.ValidateToken(token); err == nil {
|
||||
t.Fatal("revoked token should not validate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterAllowsBurstThenBlocks(t *testing.T) {
|
||||
r := NewRateLimiter(3, time.Minute)
|
||||
for i := 0; i < 3; i++ {
|
||||
if !r.Allow("ip-a") {
|
||||
t.Fatalf("attempt %d should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
if r.Allow("ip-a") {
|
||||
t.Fatal("4th attempt should be denied")
|
||||
}
|
||||
// Different key has independent budget.
|
||||
if !r.Allow("ip-b") {
|
||||
t.Fatal("different key should still be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterReset(t *testing.T) {
|
||||
r := NewRateLimiter(2, time.Minute)
|
||||
r.Allow("k")
|
||||
r.Allow("k")
|
||||
if r.Allow("k") {
|
||||
t.Fatal("3rd should be denied")
|
||||
}
|
||||
r.Reset("k")
|
||||
if !r.Allow("k") {
|
||||
t.Fatal("after Reset, should be allowed again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterDisabledWhenZeroLimit(t *testing.T) {
|
||||
r := NewRateLimiter(0, time.Minute)
|
||||
for i := 0; i < 100; i++ {
|
||||
if !r.Allow("k") {
|
||||
t.Fatalf("limit=0 should never deny, denied at i=%d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterNilSafe(t *testing.T) {
|
||||
var r *RateLimiter
|
||||
if !r.Allow("anything") {
|
||||
t.Fatal("nil limiter should allow")
|
||||
}
|
||||
r.Reset("anything")
|
||||
r.Sweep()
|
||||
}
|
||||
Reference in New Issue
Block a user