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:
2026-05-18 22:06:07 +00:00
24 changed files with 4547 additions and 110 deletions

2
.gitignore vendored
View File

@@ -90,3 +90,5 @@ YAMA.code-workspace
.vscode/settings.json .vscode/settings.json
Bin/* Bin/*
nul nul
server/go/web/assets/index.html
server/go/users.json

View File

@@ -8,9 +8,14 @@
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/cmd", "program": "${workspaceFolder}/cmd",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"args": [], "args": [
"env": {}, "-port=9090"
"console": "integratedTerminal" ],
"env": {
"YAMA_WEB_ADMIN_PASS": "3.14159"
},
"console": "integratedTerminal",
"preLaunchTask": "sync-web-assets"
}, },
{ {
"name": "Debug Server", "name": "Debug Server",
@@ -22,9 +27,12 @@
"args": [ "args": [
"-port=9090" "-port=9090"
], ],
"env": {}, "env": {
"YAMA_WEB_ADMIN_PASS": "3.14159"
},
"console": "integratedTerminal", "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
View 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": []
}
]
}

View File

@@ -17,41 +17,49 @@ LDFLAGS=-ldflags "-s -w"
MKDIR=if not exist $(BUILD_DIR) mkdir $(BUILD_DIR) MKDIR=if not exist $(BUILD_DIR) mkdir $(BUILD_DIR)
RMDIR=if exist $(BUILD_DIR) rmdir /s /q $(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 # Default target
all: clean windows linux 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 for current platform
build: build: sync
@echo Building for current platform... @echo Building for current platform...
@$(MKDIR) @$(MKDIR)
go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME).exe $(MAIN_PACKAGE) go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME).exe $(MAIN_PACKAGE)
@echo Done @echo Done
# Build for Windows (amd64) # Build for Windows (amd64)
windows: windows: sync
@echo Building for Windows amd64... @echo Building for Windows amd64...
@$(MKDIR) @$(MKDIR)
set GOOS=windows&& set GOARCH=amd64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_windows_amd64.exe $(MAIN_PACKAGE) 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 @echo Done: $(BUILD_DIR)\$(BINARY_NAME)_windows_amd64.exe
# Build for Windows (386) # Build for Windows (386)
windows-386: windows-386: sync
@echo Building for Windows 386... @echo Building for Windows 386...
@$(MKDIR) @$(MKDIR)
set GOOS=windows&& set GOARCH=386&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_windows_386.exe $(MAIN_PACKAGE) 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 @echo Done: $(BUILD_DIR)\$(BINARY_NAME)_windows_386.exe
# Build for Linux (amd64) # Build for Linux (amd64)
linux: linux: sync
@echo Building for Linux amd64... @echo Building for Linux amd64...
@$(MKDIR) @$(MKDIR)
set GOOS=linux&& set GOARCH=amd64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_linux_amd64 $(MAIN_PACKAGE) 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 @echo Done: $(BUILD_DIR)\$(BINARY_NAME)_linux_amd64
# Build for Linux (arm64) # Build for Linux (arm64)
linux-arm64: linux-arm64: sync
@echo Building for Linux arm64... @echo Building for Linux arm64...
@$(MKDIR) @$(MKDIR)
set GOOS=linux&& set GOARCH=arm64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_linux_arm64 $(MAIN_PACKAGE) 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 - Build for Linux amd64
@echo linux-arm64 - Build for Linux arm64 @echo linux-arm64 - Build for Linux arm64
@echo all-platforms - Build for all platforms @echo all-platforms - Build for all platforms
@echo sync - Sync web assets from ../web/ for //go:embed
@echo clean - Clean build directory @echo clean - Clean build directory
@echo test - Run tests @echo test - Run tests
@echo fmt - Format code @echo fmt - Format code

View File

@@ -25,36 +25,78 @@ server/go/
│ └── pool.go # Goroutine 工作池 │ └── pool.go # Goroutine 工作池
├── logger/ ├── logger/
│ └── logger.go # 日志模块 (基于 zerolog) │ └── 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/ └── cmd/
└── main.go # 程序入口 └── main.go # 程序入口
``` ```
## 核心特性 ## 核心特性
底层基础设施:
- **高并发**: 基于 Goroutine 池管理并发连接 - **高并发**: 基于 Goroutine 池管理并发连接
- **协议兼容**: 支持原有 C++ 客户端的多种协议标识 (Hell/Hello/Shine/Fuck) - **协议兼容**: 支持原有客户端的多种协议标识 (Hell/Hello/Shine/Fuck)
- **协议头解密**: 支持8种协议头加密方式 (V0-V6 + Default) - **协议头解密**: 支持 8 种协议头加密方式 (V0-V6 + Default)
- **授权验证**: 支持 TOKEN_AUTH 和 Heartbeat HMAC-SHA256 双重授权验证 - **授权验证**: TOKEN_AUTH 和 Heartbeat HMAC-SHA256 双重授权
- **XOR编码**: 支持 XOREncoder16 数据编码/解码 - **XOR 编码 / ZSTD 压缩**: 与客户端完全兼容
- **ZSTD 压缩**: 使用高效的 ZSTD 算法进行数据压缩 - **字符编码自适应**: 根据客户端能力位选择 UTF-8 直通或 GBK→UTF-8 转换
- **GBK编码**: 自动将 Windows 客户端的 GBK 编码转换为 UTF-8 - **线程安全 / 优雅关闭 / 多端口监听 / 结构化日志**
- **线程安全**: Buffer、连接管理器和 LastActive 均为线程安全设计
- **优雅关闭**: 支持信号处理和优雅停机,自动释放资源 Web 应用能力 (Phase 3-7)
- **可配置**: 支持自定义端口、最大连接数、超时时间等
- **日志系统**: 基于 zerolog支持文件输出、日志轮转、客户端上下线记录 - **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_groupsusers.json 原子写入
## 支持的命令 ## 支持的命令
当前已实现以下命令处理: ### 客户端 → 服务端
| 命令 | 值 | 说明 | | Token | 值 | 用途 |
|------|-----|------| | ---- | ---- | ---- |
| TOKEN_AUTH | 100 | 授权请求 (验证 SN + Passcode + HMAC) | | `TOKEN_AUTH` | 100 | 授权请求SN + Passcode + HMAC |
| TOKEN_HEARTBEAT | 101 | 心跳包 (支持 HMAC 授权验证,返回 Authorized 状态) | | `TOKEN_HEARTBEAT` | 101 | 心跳包(携带 ActiveWnd / Ping / SN |
| TOKEN_LOGIN | 102 | 客户端登录 | | `TOKEN_LOGIN` | 102 | 主连接登录 |
| CMD_HEARTBEAT_ACK | 216 | 心跳响应 (包含 Authorized 字段) | | `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 ```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 ```bash
./simpleremoter-server ./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_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 ```bash
# Linux/macOS # 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 ```go
package main package main
@@ -114,57 +185,32 @@ import (
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server" "github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
) )
// 实现 Handler 接口 type MyHandler struct{ log *logger.Logger }
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())
}
func (h *MyHandler) OnConnect(ctx *connection.Context) {}
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {}
func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) { func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
if len(data) == 0 { if len(data) == 0 {
return return
} }
cmd := data[0] if data[0] == protocol.TokenLogin {
switch cmd {
case protocol.TokenLogin:
info, _ := protocol.ParseLoginInfo(data) info, _ := protocol.ParseLoginInfo(data)
h.log.Info("Client login: %s (%s)", info.PCName, info.OsVerInfo) h.log.Info("login: %s (%s)", info.PCName, info.OsVerInfo)
case protocol.TokenHeartbeat:
h.log.Debug("Heartbeat from client %d", ctx.ID)
} }
} }
func main() { func main() {
// 配置日志 (控制台 + 文件) log := logger.New(logger.DefaultConfig())
logCfg := logger.DefaultConfig() srv := server.New(server.DefaultConfig())
logCfg.File = "logs/server.log"
log := logger.New(logCfg)
// 配置服务器
config := server.DefaultConfig()
config.Port = 6543
// 创建并启动服务器
srv := server.New(config)
srv.SetLogger(log.WithPrefix("Server")) srv.SetLogger(log.WithPrefix("Server"))
srv.SetHandler(&MyHandler{log: log}) srv.SetHandler(&MyHandler{log: log})
if err := srv.Start(); err != nil { if err := srv.Start(); err != nil {
log.Fatal("启动失败: %v", err) log.Fatal("start: %v", err)
} }
// 等待退出信号 sig := make(chan os.Signal, 1)
sigChan := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sig
<-sigChan
srv.Stop() srv.Stop()
} }
``` ```
@@ -250,7 +296,7 @@ func main() {
| bWebCamExist | 448 | 4 | 是否有摄像头 | | bWebCamExist | 448 | 4 | 是否有摄像头 |
| dwSpeed | 452 | 4 | 网速 | | dwSpeed | 452 | 4 | 网速 |
| szStartTime | 456 | 20 | 启动时间 | | szStartTime | 456 | 20 | 启动时间 |
| szReserved | 476 | 512 | 扩展字段 (用`|`分隔) | | szReserved | 476 | 512 | 扩展字段(多字段以 `\|` 分隔 |
### Heartbeat 结构 ### Heartbeat 结构
@@ -374,15 +420,19 @@ publicIP := info.GetReservedField(11) // 公网 IP
## 与 C++ 版本对比 ## 与 C++ 版本对比
| 特性 | C++ (IOCP) | Go | | 特性 | C++ (IOCP) | Go |
|------|------------|-----| | ---- | ---- | ---- |
| 并发模型 | IOCP + 线程池 | Goroutine 池 | | 并发模型 | IOCP + 线程池 | Goroutine 池 |
| 压缩算法 | ZSTD | ZSTD |
| 跨平台 | Windows | 全平台 | | 跨平台 | Windows | 全平台 |
| 内存管理 | 手动 | GC | | 内存管理 | 手动 | GC |
| 代码复杂度 | 高 | 低 | | 代码复杂度 | 高 | 低 |
| 协议头解密 | 8种方式 | 8种方式 | | 压缩 / XOR / 头加密 | 完整 8 套加密方式 + XOREncoder16 + ZSTD | 完全对齐 |
| XOR编码 | XOREncoder16 | XOREncoder16 | | 字符编码 | GBK | UTF-8 直通 / GBK→UTF-8 (按客户端能力位) |
| 字符编码 | GBK | GBK -> UTF-8 | | 设备列表与监控 | MFC 列表控件 | Web UI |
| Web 远程桌面 | 内嵌浏览器 + H.264 | 完全对齐WebCodecs 解码) |
| 鼠标键盘转发 | 已实现 | 完全对齐 |
| Web 终端 | 内嵌 xterm.js + ConPTY | 完全对齐(含旧 cmd-pipe 兼容) |
| 用户 / 分组管理 | 已实现 | users.json schema 互通 |
| 文件传输 / 摄像头 / 录音 等 | 已实现 | 暂未实现(按需扩展) |
## 依赖 ## 依赖

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/binary"
"flag" "flag"
"fmt" "fmt"
"os" "os"
@@ -8,12 +9,16 @@ import (
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth" "github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection" "github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger" "github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol" "github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server" "github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/web"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
) )
// MyHandler implements the server.Handler interface // MyHandler implements the server.Handler interface
@@ -21,6 +26,8 @@ type MyHandler struct {
log *logger.Logger log *logger.Logger
auth *auth.Authenticator auth *auth.Authenticator
srv *server.Server srv *server.Server
hub *hub.Hub
signPwd string // HMAC key for CMD_MASTERSETTING signatures (YAMA_SIGN_PASSWORD)
} }
// OnConnect is called when a client connects // 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 // OnDisconnect is called when a client disconnects
func (h *MyHandler) OnDisconnect(ctx *connection.Context) { 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() 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(), h.log.ClientEvent("offline", ctx.ID, ctx.GetPeerIP(),
"clientID", info.ClientID, "clientID", info.ClientID,
"computer", info.ComputerName, "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 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] cmd := data[0]
// Handle commands // Handle commands
switch cmd { switch cmd {
@@ -54,12 +104,231 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
h.handleAuth(ctx, data) h.handleAuth(ctx, data)
case protocol.TokenHeartbeat: case protocol.TokenHeartbeat:
h.handleHeartbeat(ctx, data) 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: default:
// Other commands are not implemented yet // Other commands are not implemented yet
h.log.Info("Unhandled command %d from client %d", cmd, ctx.ID) 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) // handleLogin handles client login (TOKEN_LOGIN = 102)
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) { func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
info, err := protocol.ParseLoginInfo(data) info, err := protocol.ParseLoginInfo(data)
@@ -68,8 +337,18 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
return return
} }
// Use MasterID from login request as ClientID for logging // The device's unique ID lives in reserved field 16 (RES_CLIENT_ID) as a
clientID := info.MasterID // 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 == "" { if clientID == "" {
clientID = fmt.Sprintf("conn-%d", ctx.ID) 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 // Parse additional info from reserved field
if len(reserved) > 0 { if len(reserved) > protocol.ResFieldClientType {
clientInfo.ClientType = info.GetReservedField(0) clientInfo.ClientType = info.GetReservedField(protocol.ResFieldClientType)
} }
if len(reserved) > 2 { if len(reserved) > 2 {
clientInfo.CPU = info.GetReservedField(2) clientInfo.CPU = info.GetReservedField(2)
} }
if len(reserved) > 4 { if len(reserved) > protocol.ResFieldFilePath {
clientInfo.FilePath = info.GetReservedField(4) clientInfo.FilePath = info.GetReservedField(protocol.ResFieldFilePath)
} }
if len(reserved) > 11 { if len(reserved) > protocol.ResFieldClientPubIP {
clientInfo.IP = info.GetReservedField(11) // Public IP clientInfo.IP = info.GetReservedField(protocol.ResFieldClientPubIP)
} }
ctx.SetInfo(clientInfo) ctx.SetInfo(clientInfo)
@@ -109,6 +388,82 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
"version", info.ModuleVersion, "version", info.ModuleVersion,
"path", clientInfo.FilePath, "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) // 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 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 // Authenticate heartbeat if it contains authorization info
// data[1:] skips the command byte to get the raw Heartbeat structure // data[1:] skips the command byte to get the raw Heartbeat structure
var authorized byte = 0 var authorized byte = 0
if len(data) > 1 { if len(data) > 1 {
authResult := h.auth.AuthenticateHeartbeat(data[1:]) authResult := h.auth.AuthenticateHeartbeat(data[1:])
if authResult.Authorized { if authResult.Authorized {
authorized = 1 authorized = 2 // Auth by admin
// Log authorization success (only log once per connection to avoid spam) // Log authorization success (only log once per connection to avoid spam)
if !ctx.IsAuthorized.Load() { if !ctx.IsAuthorized.Load() {
ctx.IsAuthorized.Store(true) ctx.IsAuthorized.Store(true)
@@ -228,10 +611,32 @@ func parsePorts(portStr string) ([]int, error) {
return ports, nil 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() { func main() {
// Parse command line flags // Parse command line flags
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)") portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)") 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)") noConsole := flag.Bool("no-console", false, "Disable console output (for daemon mode)")
flag.Parse() flag.Parse()
@@ -267,6 +672,48 @@ func main() {
// Create authenticator (shared by all servers) // Create authenticator (shared by all servers)
authenticator := auth.New(authCfg) 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 // Create servers for each port
var servers []*server.Server var servers []*server.Server
for _, port := range ports { for _, port := range ports {
@@ -282,20 +729,66 @@ func main() {
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)), log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
auth: authenticator, auth: authenticator,
srv: srv, srv: srv,
hub: deviceHub,
signPwd: signPwd,
} }
srv.SetHandler(handler) srv.SetHandler(handler)
servers = append(servers, srv) 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 { for _, srv := range servers {
if err := srv.Start(); err != nil { if err := srv.Start(); err != nil {
log.Fatal("Failed to start server: %v", err) 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) 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("Logs are written to: logs/server.log")
fmt.Println("Press Ctrl+C to stop...") fmt.Println("Press Ctrl+C to stop...")
@@ -305,6 +798,7 @@ func main() {
<-sigChan <-sigChan
fmt.Println("\nShutting down...") fmt.Println("\nShutting down...")
httpSrv.Stop()
for _, srv := range servers { for _, srv := range servers {
srv.Stop() srv.Stop()
} }

View File

@@ -86,6 +86,15 @@ const (
// NewContext creates a new connection context // NewContext creates a new connection context
func NewContext(conn net.Conn, mgr *Manager) *Context { func NewContext(conn net.Conn, mgr *Manager) *Context {
now := time.Now() 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{ ctx := &Context{
Conn: conn, Conn: conn,
RemoteAddr: conn.RemoteAddr().String(), RemoteAddr: conn.RemoteAddr().String(),

17
server/go/go.mod Normal file
View 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
)

View File

@@ -1,5 +1,7 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/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 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=

805
server/go/hub/hub.go Normal file
View 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
View 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)
}
}

View File

@@ -2,15 +2,22 @@ package protocol
import ( import (
"bytes" "bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/hex"
"strconv"
"strings" "strings"
"golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform" "golang.org/x/text/transform"
) )
// gbkToUTF8 converts GBK encoded bytes to UTF-8 string // GbkToUTF8 converts GBK encoded bytes to UTF-8 string. The input is treated
func gbkToUTF8(data []byte) string { // 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 // Find the first null byte and truncate there
if idx := bytes.IndexByte(data, 0); idx >= 0 { if idx := bytes.IndexByte(data, 0); idx >= 0 {
data = data[:idx] data = data[:idx]
@@ -30,6 +37,21 @@ func gbkToUTF8(data []byte) string {
return cleanString(buf.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 // cleanString removes non-printable characters except common whitespace
func cleanString(s string) string { func cleanString(s string) string {
var result strings.Builder var result strings.Builder
@@ -41,10 +63,52 @@ func cleanString(s string) string {
return strings.TrimSpace(result.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 ( const (
// Server -> Client commands // Server -> Client commands
CommandActived byte = 0 // COMMAND_ACTIVED 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 CommandBye byte = 204 // COMMAND_BYE - disconnect
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
@@ -52,8 +116,239 @@ const (
TokenAuth byte = 100 // TOKEN_AUTH - authorization required TokenAuth byte = 100 // TOKEN_AUTH - authorization required
TokenHeartbeat byte = 101 // TOKEN_HEARTBEAT TokenHeartbeat byte = 101 // TOKEN_HEARTBEAT
TokenLogin byte = 102 // TOKEN_LOGIN - login packet 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) // LOGIN_INFOR structure size and offsets (matching C++ struct with default alignment)
// Note: C++ struct uses default alignment (4-byte for uint32/int) // Note: C++ struct uses default alignment (4-byte for uint32/int)
const ( const (
@@ -111,17 +406,17 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
// Parse module version (offset 164, 24 bytes) // Parse module version (offset 164, 24 bytes)
// This contains date string like "Dec 19 2025" // This contains date string like "Dec 19 2025"
if len(data) >= OffsetModuleVersion+24 { 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) // Parse PC name (offset 188, 240 bytes)
if len(data) >= OffsetPCName+240 { 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) // Parse Master ID (offset 428, 20 bytes)
if len(data) >= OffsetMasterID+20 { 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) // Parse WebCam exist (offset 448, 4 bytes)
@@ -136,14 +431,14 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
// Parse Start time (offset 456, 20 bytes) // Parse Start time (offset 456, 20 bytes)
if len(data) >= OffsetStartTime+20 { 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 // Parse Reserved (offset 476, 512 bytes) - contains additional info
if len(data) >= OffsetReserved+512 { if len(data) >= OffsetReserved+512 {
info.Reserved = gbkToUTF8(data[OffsetReserved : OffsetReserved+512]) info.Reserved = GbkToUTF8(data[OffsetReserved : OffsetReserved+512])
} else if len(data) > OffsetReserved { } else if len(data) > OffsetReserved {
info.Reserved = gbkToUTF8(data[OffsetReserved:]) info.Reserved = GbkToUTF8(data[OffsetReserved:])
} }
return info, nil return info, nil
@@ -152,7 +447,7 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
// parseOsVersionInfo parses the OS version info field // parseOsVersionInfo parses the OS version info field
// The C++ client fills this with a readable string like "Windows 10" via getSystemName() // The C++ client fills this with a readable string like "Windows 10" via getSystemName()
func parseOsVersionInfo(data []byte) string { func parseOsVersionInfo(data []byte) string {
return gbkToUTF8(data) return GbkToUTF8(data)
} }
// ParseReserved parses the reserved field into a slice of strings // ParseReserved parses the reserved field into a slice of strings

View 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")
}
}

View File

@@ -20,6 +20,19 @@ type Parser struct {
codec *Codec 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 // NewParser creates a new parser
func NewParser() *Parser { func NewParser() *Parser {
return &Parser{ return &Parser{
@@ -38,6 +51,22 @@ func (p *Parser) Close() {
func (p *Parser) Parse(ctx *connection.Context) ([]byte, error) { func (p *Parser) Parse(ctx *connection.Context) ([]byte, error) {
buf := ctx.InBuffer 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 // Need at least minimum bytes to determine protocol
if buf.Len() < MinComLen { if buf.Len() < MinComLen {
return nil, ErrNeedMore return nil, ErrNeedMore

View 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

View 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;
}

File diff suppressed because one or more lines are too long

23
server/go/web/embed.go Normal file
View 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
View 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
View 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)
}
}

View 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
}

View 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
View 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
}

View 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()
}