Files
SimpleRemoter/server/go/web/ws_handlers.go
T
yuanyuanxianganddeepseek-flash a522424491 Feature: Security audit logging with admin web query page
Add a dedicated security audit trail to the Go server. Login attempts,
authorization denials, WebSocket lifecycle, remote screen and terminal
sessions, clipboard operations, device authentication and admin mutations
now emit stable JSON-line events (audit=true, event=<name>) to a separate
logs/audit.log, mirrored to server.log. The path is configurable through
YAMA_AUDIT_LOG and the separate file can be switched off. Records never
contain passcodes, password hashes, tokens or clipboard contents, and
untrusted string fields are stripped of control characters and truncated
so they cannot forge log lines.

Expose the trail to administrators through a query_audit WebSocket command
and an Audit Log page in the web UI. The page filters by time range, event
(exact or prefix), actor, IP, device id, keyword and warning level, pages
newest-first, and stays hidden unless the server advertises the auditLog
capability, so older C++ servers degrade cleanly. Each query scans at most
the newest 32 MB of the current file to bound latency, records itself as
admin.audit.query, and tolerates torn or non-audit lines.

Add unit tests for event emission and redaction, query filtering, paging,
malformed lines, missing or disabled audit files, and handler
authorization.

Co-Authored-By: deepseek-flash
2026-10-18 18:44:17 +02:00

1437 lines
45 KiB
Go

package web
import (
"encoding/json"
"errors"
"sort"
"strings"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
"golang.org/x/text/encoding/simplifiedchinese"
)
// rotateChallenge replaces the client's nonce with a fresh one and pushes a
// {cmd:"challenge"} message so the browser updates its stored nonce. Called
// after every login failure so a retry on the SAME WebSocket works without
// the user having to refresh — the old nonce is still burned for replay
// protection, but the client now has a usable new one.
func (h *wsHub) rotateChallenge(c *wsClient) {
n, err := wsauth.NewNonce()
if err != nil {
h.log.Error("nonce regen failed: %v", err)
c.nonce = ""
return
}
c.nonce = n
// Must carry caps too: the browser replaces serverCaps on every challenge.
c.queue([]byte(`{"cmd":"challenge","nonce":"` + n + `"` + challengeCaps + `}`))
}
// 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) {
// Connection-scoped counters feed the web.ws.close audit summary, which
// is what turns a silent port scan into a visible event.
c.statCommands++
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 "keyframe":
h.handleKeyframe(c, raw)
case "rdp_reset":
h.handleRdpReset(c, raw)
case "audio_toggle":
// Audio capture/forwarding is not yet ported from the C++ WebService
// (see project_go_webservice_port). Reply explicitly so the front-end
// console shows why the toolbar button has no effect, instead of the
// request being silently dropped by the default case.
c.queue(mustJSON(map[string]any{
"cmd": "audio_toggle_result", "ok": false,
"msg": "Audio toggle not supported on Go server yet",
}))
case "mouse":
h.handleMouse(c, raw)
case "key":
h.handleKey(c, raw)
case "clipboard_set":
h.handleClipboardSet(c, raw)
case "clipboard_get":
h.handleClipboardGet(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 "delete_device":
h.handleDeleteDevice(c, raw)
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)
case "query_audit":
h.handleQueryAudit(c, raw)
default:
// Unknown commands are attacker-controlled and can be sent in a tight
// loop. Count them for the close summary and record only the first
// one per connection so the audit trail cannot be flooded.
c.statUnknownCmd++
if !c.auditUnknownCmdLogged {
c.auditUnknownCmdLogged = true
h.audit(c, true, auditUnknownCmd, map[string]any{
"command": cmd,
})
}
}
}
// 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.statAdminDenied++
h.audit(c, true, auditDenied, map[string]any{
"operation": strings.TrimSuffix(replyCmd, "_result"),
"reason": "not_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, exists := h.auth.GetSalt(in.Username)
// The response deliberately hides whether the user exists (fake salt),
// but the audit trail should not: repeated probes for a nonexistent
// account are a strong signal of username enumeration. Record the first
// probe per connection only; the close summary carries the total.
if !exists {
c.statUnknownSalt++
if !c.auditUnknownSaltLogged {
c.auditUnknownSaltLogged = true
h.audit(c, true, auditSaltProbe, map[string]any{
"username": in.Username,
"reason": "unknown_user",
})
}
}
// 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.statLoginFailures++
h.audit(c, true, auditLoginFail, map[string]any{"reason": "malformed_request"})
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) {
c.statLoginFailures++
h.audit(c, true, auditLoginThrot, map[string]any{
"username": in.Username,
"reason": "rate_limited",
})
time.Sleep(500 * time.Millisecond)
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
// Rotate the challenge: burns the previous nonce (replay protection)
// AND hands the client a fresh one so the next attempt does not
// require a page refresh.
h.rotateChallenge(c)
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.statLoginFailures++
h.audit(c, true, auditLoginFail, map[string]any{
"username": in.Username,
"reason": "invalid_challenge",
})
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid challenge"}))
h.rotateChallenge(c)
return
}
token, role, err := h.auth.VerifyLogin(in.Username, in.Response, in.Nonce)
if err != nil {
// Record whether the account exists — an attack that keeps hammering
// a real account (or scans for names) reads very differently from a
// user mistyping their own password.
_, exists := h.auth.GetSalt(in.Username)
c.statLoginFailures++
h.audit(c, true, auditLoginFail, map[string]any{
"username": in.Username,
"reason": "invalid_credentials",
"user_exists": exists,
})
// 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"}))
h.rotateChallenge(c)
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)
// Identity fields are read by audit() from hub goroutines, so publish
// them under h.mu.
h.mu.Lock()
c.nonce = ""
c.token = token
c.role = role
c.user = in.Username
h.mu.Unlock()
fields := c.wsRequestFields()
fields["method"] = "password"
fields["actor"] = in.Username
fields["role"] = role
h.audit(c, false, auditLoginOK, fields)
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, raw []byte) {
var in struct {
ID string `json:"id"`
MultiWatch bool `json:"multiWatch"`
Req string `json:"req"`
}
_ = json.Unmarshal(raw, &in)
// Take h.mu so event-handler readers (OnResolutionChange/OnScreenFrame)
// get a consistent view of the watch set.
var closed []string
starts := make(map[string]time.Time)
h.mu.Lock()
if in.MultiWatch && in.ID != "" {
// New frontend: drop only this device; the other wall tiles keep
// streaming on the same connection.
if c.isWatching(in.ID) {
delete(c.watchIDs, in.ID)
delete(c.keyframeAt, in.ID)
starts[in.ID] = c.watchStartedAt[in.ID]
delete(c.watchStartedAt, in.ID)
closed = append(closed, in.ID)
}
} else {
// Old frontend, or a disconnect without an id: it only ever watched
// one device, so clear everything.
for id := range c.watchIDs {
closed = append(closed, id)
starts[id] = c.watchStartedAt[id]
}
c.watchIDs = make(map[string]struct{})
c.keyframeAt = make(map[string]int64)
c.watchStartedAt = make(map[string]time.Time)
}
h.mu.Unlock()
res := map[string]any{"cmd": "disconnect_result", "ok": true}
if in.ID != "" {
res["id"] = in.ID
}
if in.Req != "" {
res["req"] = in.Req
}
c.queue(mustJSON(res))
// deferredCloseScreen takes h.mu, so it must run outside the lock above.
for _, id := range closed {
if !starts[id].IsZero() {
h.audit(c, false, auditScreenStop, map[string]any{
"device_id": id,
"duration_ms": durationMs(starts[id]),
"reason": "disconnect",
})
}
if h.countWatchers(id) == 0 {
h.deferredCloseScreen(id)
}
}
}
// countWatchers returns how many clients still have deviceID in their watch
// set. 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.isWatching(deviceID) {
n++
}
}
return n
}
// applyConnectWatch mutates c.watchIDs for a connect request. The caller must
// hold h.mu.
//
// It returns the ids dropped by the legacy replace-all semantics; the caller
// must stop their screen sessions outside the lock (only if nobody else still
// watches them). tooMany reports that the per-connection cap blocked the id.
func applyConnectWatch(c *wsClient, id string, multiWatch bool) (stopped []string, tooMany bool) {
if c.watchIDs == nil {
c.watchIDs = make(map[string]struct{})
}
if !multiWatch {
// Old frontend: one device per connection. Replace the whole set so
// switching devices repeatedly cannot grow it without bound.
for old := range c.watchIDs {
if old != id {
stopped = append(stopped, old)
}
}
c.watchIDs = make(map[string]struct{})
}
if _, ok := c.watchIDs[id]; ok {
return stopped, false // repeated connect (focus switch) is a no-op
}
if len(c.watchIDs) >= maxWatchPerClient {
return stopped, true
}
c.watchIDs[id] = struct{}{}
return stopped, false
}
// resolveWatchDevice maps an input command to the device it targets. The new
// frontend sends an explicit id, which must be in this connection's watch set
// so a stale or forged id can never inject input into a device the browser is
// not viewing. Old frontends omit id — accepted only while exactly one device
// is watched. With several devices and no id the command is dropped.
func (h *wsHub) resolveWatchDevice(c *wsClient, raw []byte) string {
var in struct {
ID string `json:"id"`
}
_ = json.Unmarshal(raw, &in)
h.mu.RLock()
defer h.mu.RUnlock()
if in.ID != "" {
if c.isWatching(in.ID) {
return in.ID
}
return ""
}
if len(c.watchIDs) == 1 {
for id := range c.watchIDs {
return id
}
}
return ""
}
func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "connect_result") {
return
}
var in struct {
ID string `json:"id"`
MultiWatch bool `json:"multiWatch"`
Req string `json:"req"`
}
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
}
// Every response echoes id and req: one socket may now serve several
// devices (screen wall), so the browser routes by id (and req when it
// needs to pair a reply with a specific request).
reply := func(ok bool, msg string) map[string]any {
m := map[string]any{"cmd": "connect_result", "ok": ok, "id": in.ID}
if in.Req != "" {
m["req"] = in.Req
}
if msg != "" {
m["msg"] = msg
}
return m
}
var stopped []string
starts := make(map[string]time.Time)
var tooMany, already bool
h.mu.Lock()
_, already = c.watchIDs[in.ID]
stopped, tooMany = applyConnectWatch(c, in.ID, in.MultiWatch)
// A pending deferred close means the previous session is still alive for
// a few seconds; cancel it so the cached resolution + IDR hot path wins.
// Only when the subscription was accepted: a rejected connect must not
// cancel the teardown of a device nobody is watching any more, or that
// session would keep streaming forever.
if !tooMany {
if t, ok := h.screenCloseTimers[in.ID]; ok {
t.Stop()
delete(h.screenCloseTimers, in.ID)
}
// Repeated connect (focus switch / wall re-subscribe) keeps the
// original start time so its audit duration covers the whole watch.
if _, ok := c.watchStartedAt[in.ID]; !ok {
c.watchStartedAt[in.ID] = time.Now()
}
for _, id := range stopped {
starts[id] = c.watchStartedAt[id]
delete(c.watchStartedAt, id)
delete(c.keyframeAt, id)
}
}
h.mu.Unlock()
if tooMany {
h.audit(c, true, auditScreenDenied, map[string]any{
"device_id": in.ID,
"reason": "too_many_watches",
})
c.queue(mustJSON(reply(false, "Too many devices on one connection")))
return
}
// Only the legacy replace-all semantics can produce stopped ids. Their
// screen sessions are torn down outside the lock; deferredCloseScreen is
// a no-op while another browser still watches the device.
for _, id := range stopped {
if !starts[id].IsZero() {
h.audit(c, false, auditScreenStop, map[string]any{
"device_id": id,
"duration_ms": durationMs(starts[id]),
"reason": "replaced",
})
}
if h.countWatchers(id) == 0 {
h.deferredCloseScreen(id)
}
}
// If a screen session is already live for this device (another browser
// is already watching, or this connection reconnected), reuse it: hand
// the viewer the cached 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 {
if !already {
h.audit(c, false, auditScreenStart, map[string]any{
"device_id": in.ID,
"mode": "cached",
"multi_watch": in.MultiWatch,
"width": cache.Width,
"height": cache.Height,
})
}
m := reply(true, "")
m["width"] = cache.Width
m["height"] = cache.Height
c.queue(mustJSON(m))
if len(cache.Keyframe) > 0 {
c.queueBinary(cache.Keyframe)
}
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: the watch entry is inserted above (applyConnectWatch) 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 filter on the watch set. If
// the entry were added afterwards the new viewer would silently miss the
// very first IDR and resolution_changed, leaving the page stuck on
// "Waiting for video".
if err := h.devices.SendToDevice(in.ID, cmd); err != nil {
// Roll back the subscription: no screen session was started.
h.mu.Lock()
delete(c.watchIDs, in.ID)
delete(c.keyframeAt, in.ID)
delete(c.watchStartedAt, in.ID)
h.mu.Unlock()
msg := "Device offline"
reason := "device_offline"
if err != hub.ErrDeviceOffline {
msg = "Failed to start screen capture"
reason = "start_failed"
h.log.Error("SendToDevice(%s): %v", in.ID, err)
}
h.audit(c, true, auditScreenDenied, map[string]any{
"device_id": in.ID,
"reason": reason,
})
c.queue(mustJSON(reply(false, msg)))
return
}
h.log.Debug("[timing] COMMAND_SCREEN_SPY sent to device=%s (cold start)", in.ID)
if !already {
h.audit(c, false, auditScreenStart, map[string]any{
"device_id": in.ID,
"mode": "started",
"multi_watch": in.MultiWatch,
})
}
c.queue(mustJSON(reply(true, "")))
}
// handleKeyframe asks the device to emit an IDR immediately. The browser calls
// it when it resumes decoding a wall tile, or right after connecting to reset
// the reference chain. The server first replays its cached IDR so the decoder
// has something to paint at once, then sends COMMAND_SCREEN_KEYFRAME_REQ to
// the client, whose fresh IDR replaces the cache (the cached one cannot
// guarantee the following P-frames reference it).
//
// Mirrors CWebService::HandleKeyframe. Requests are limited to one per device
// per second, so a retrying frontend cannot spin the encoder.
func (h *wsHub) handleKeyframe(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "keyframe_result") {
return
}
var in struct {
ID string `json:"id"`
Req string `json:"req"`
}
_ = json.Unmarshal(raw, &in)
reply := func(ok bool, msg string) map[string]any {
m := map[string]any{"cmd": "keyframe_result", "ok": ok}
if in.ID != "" {
m["id"] = in.ID
}
if in.Req != "" {
m["req"] = in.Req
}
if msg != "" {
m["msg"] = msg
}
return m
}
if in.ID == "" {
c.queue(mustJSON(reply(false, "Bad request")))
return
}
allowed := false
h.mu.Lock()
if c.isWatching(in.ID) {
now := time.Now().Unix()
if last, ok := c.keyframeAt[in.ID]; !ok || now > last {
if c.keyframeAt == nil {
c.keyframeAt = make(map[string]int64)
}
c.keyframeAt[in.ID] = now
allowed = true
}
}
h.mu.Unlock()
if !allowed {
c.queue(mustJSON(reply(false, "Not watching device or rate limited")))
return
}
cache := h.devices.ScreenState(in.ID)
if !cache.Active {
c.queue(mustJSON(reply(false, "No active screen session")))
return
}
if len(cache.Keyframe) > 0 {
c.queueBinary(cache.Keyframe)
}
if err := h.devices.SendToScreen(in.ID, []byte{protocol.CommandScreenKeyframeReq}); err != nil {
c.queue(mustJSON(reply(false, "Failed to request keyframe")))
return
}
c.queue(mustJSON(reply(true, "")))
}
// handleRdpReset asks the device to switch its screen capture back to the
// physical console session ("RDP 会话归位"). Useful when someone has RDP'd into
// the box: the device's screen thread is by default attached to whatever WTS
// session is currently active, so the operator may otherwise see a login
// screen or a different user's desktop instead of the local console.
//
// Fire-and-forget on purpose, matching the C++ server and the browser UI —
// front-end ignores any reply, so we don't send one. Failures (device offline,
// no active screen session, browser hasn't called `connect` yet) are warn-
// logged server-side only.
func (h *wsHub) handleRdpReset(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "rdp_reset_result") {
return
}
deviceID := h.resolveWatchDevice(c, raw)
if deviceID == "" {
h.audit(c, true, auditRdpReset, map[string]any{
"ok": false,
"reason": "no_device",
})
return
}
// CMD_RESTORE_CONSOLE must go through the screen sub-conn — the client
// dispatches it from CScreenManager::OnReceive, which only reads from
// the screen sub-conn (see client/ScreenManager.cpp:996). Sending on the
// main conn would silently no-op.
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CmdRestoreConsole}); err != nil {
h.audit(c, true, auditRdpReset, map[string]any{
"device_id": deviceID,
"ok": false,
"reason": "send_failed",
"error": err.Error(),
})
return
}
h.audit(c, false, auditRdpReset, map[string]any{
"device_id": deviceID,
"ok": true,
})
}
func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "device_list") {
return
}
c.queue(mustJSON(map[string]any{
"cmd": "device_list",
"ok": true,
"devices": h.listDevicesWithOffline(),
}))
}
// listDevicesWithOffline returns the online snapshot followed by historical
// devices that are not currently online. The online-ID set is derived from the
// same snapshot, so a device reconnecting between the two lookups cannot appear
// twice. With no history store wired up this is just the online list.
func (h *wsHub) listDevicesWithOffline() []hub.DeviceInfo {
devices := h.devices.ListDevices()
if h.history == nil {
return devices
}
online := make(map[string]struct{}, len(devices))
for _, d := range devices {
online[d.ID] = struct{}{}
}
return append(devices, h.history.Offline(online)...)
}
// 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 == "" {
h.auditBadToken(c, replyCmd, "missing_token")
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
return false
}
sess, err := h.auth.ValidateToken(in.Token)
if err != nil {
h.auditBadToken(c, replyCmd, "invalid_token")
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
return false
}
resumed := false
h.mu.Lock()
if c.token == "" {
c.token = in.Token
c.role = sess.Role
c.user = sess.Username
resumed = true
}
h.mu.Unlock()
if resumed {
h.audit(c, false, auditSession, map[string]any{"method": "token"})
}
return true
}
// auditBadToken counts rejected tokens and records only the first one per
// connection: an attacker can loop protected commands with garbage tokens,
// and one line plus the close-summary count is enough to see it.
func (h *wsHub) auditBadToken(c *wsClient, replyCmd, reason string) {
c.statInvalidToken++
if c.auditInvalidTokenLogged {
return
}
c.auditInvalidTokenLogged = true
h.audit(c, true, auditBadToken, map[string]any{
"command": strings.TrimSuffix(replyCmd, "_result"),
"reason": reason,
})
}
// 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 := h.resolveWatchDevice(c, raw)
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 := h.resolveWatchDevice(c, raw)
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)
}
// handleClipboardSet relays the browser's local clipboard text into the remote
// device's clipboard via COMMAND_SCREEN_SET_CLIPBOARD (25). The client's
// UpdateClientClipboard stores the payload as CF_TEXT (ANSI/GBK), so we encode
// the UTF-8 input to GBK here — matching the C++ WebService (explicit code
// page 936) and the existing MCP remote_clipboard convention. Non-GBK glyphs
// (emoji, some accented Latin) collapse to '?' — an accepted limitation.
func (h *wsHub) handleClipboardSet(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "clipboard_set_result") {
return
}
var in struct {
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &in); err != nil {
return
}
if in.Text == "" {
return
}
deviceID := h.resolveWatchDevice(c, raw)
if deviceID == "" {
h.audit(c, true, auditClipSet, map[string]any{
"ok": false,
"reason": "no_device",
})
return
}
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(in.Text))
if err != nil {
h.audit(c, true, auditClipSet, map[string]any{
"device_id": deviceID,
"ok": false,
"reason": "encode_failed",
"error": err.Error(),
})
return
}
payload := append([]byte{protocol.CommandScreenSetClipboard}, gbk...)
if err := h.devices.SendToScreen(deviceID, payload); err != nil {
h.audit(c, true, auditClipSet, map[string]any{
"device_id": deviceID,
"ok": false,
"reason": "send_failed",
"error": err.Error(),
})
return
}
// Record the size only — clipboard contents may be sensitive.
h.audit(c, false, auditClipSet, map[string]any{
"device_id": deviceID,
"ok": true,
"bytes": len(gbk),
"source_utf8": len(in.Text),
})
}
// handleClipboardGet asks the remote device for its clipboard text via
// COMMAND_SCREEN_GET_CLIPBOARD (24, a single byte). The device replies with
// TOKEN_CLIPBOARD_TEXT, which the hub relays back to the browser.
func (h *wsHub) handleClipboardGet(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "clipboard_get_result") {
return
}
deviceID := h.resolveWatchDevice(c, raw)
if deviceID == "" {
h.audit(c, true, auditClipGet, map[string]any{
"ok": false,
"reason": "no_device",
})
return
}
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CommandScreenGetClipboard}); err != nil {
h.audit(c, true, auditClipGet, map[string]any{
"device_id": deviceID,
"ok": false,
"reason": "send_failed",
"error": err.Error(),
})
return
}
h.audit(c, false, auditClipGet, map[string]any{
"device_id": deviceID,
"ok": true,
})
}
// 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 {
h.audit(c, true, auditUserCreate, map[string]any{
"ok": false,
"reason": "invalid_json",
})
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 {
h.audit(c, true, auditUserCreate, map[string]any{
"target": in.Username,
"target_role": in.Role,
"ok": false,
"reason": "create_failed",
"error": err.Error(),
})
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
return
}
h.audit(c, false, auditUserCreate, map[string]any{
"target": in.Username,
"target_role": in.Role,
"groups": len(in.AllowedGroups),
"ok": true,
})
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
}
// handleDeleteDevice implements the device list's "Delete" action.
//
// The meaning depends on liveness, matching the MFC client's
// OnOnlineUninstall: an online agent is told to uninstall itself with the
// single-byte TOKEN_UNINSTALL command; an offline device has no agent to
// talk to. Either way the persisted history record is removed as well, so
// the deleted device does not linger in the offline list.
//
// Admin-only: this is destructive and irreversible.
func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) {
const replyCmd = "delete_device_result"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
var in struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
h.audit(c, true, auditDeviceAdmin, map[string]any{
"ok": false,
"reason": "invalid_id",
})
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Invalid device id",
}))
return
}
if h.devices.IsOnline(in.ID) {
err := h.devices.SendToDevice(in.ID, []byte{protocol.CommandUninstall})
if err == nil {
// Drop the record too: after the agent uninstalls itself it will
// never log in again, so keeping it would leave a dead entry in
// the offline list. The disconnect event that follows finds no
// record and is a no-op.
if h.history != nil {
h.history.Remove(in.ID)
}
h.audit(c, false, auditDeviceAdmin, map[string]any{
"device_id": in.ID,
"mode": "uninstall",
"ok": true,
})
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": true, "id": in.ID, "mode": "uninstall",
}))
return
}
// Lost the race with a disconnect between IsOnline and the send:
// fall through to removing the (now offline) history record.
if !errors.Is(err, hub.ErrDeviceOffline) {
h.audit(c, true, auditDeviceAdmin, map[string]any{
"device_id": in.ID,
"mode": "uninstall",
"ok": false,
"reason": "uninstall_failed",
"error": err.Error(),
})
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": err.Error(),
}))
return
}
}
if h.history == nil || !h.history.Remove(in.ID) {
h.audit(c, true, auditDeviceAdmin, map[string]any{
"device_id": in.ID,
"mode": "removed",
"ok": false,
"reason": "not_found",
})
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Device not found",
}))
return
}
h.audit(c, false, auditDeviceAdmin, map[string]any{
"device_id": in.ID,
"mode": "removed",
"ok": true,
})
// Other browsers (and this one) refresh their lists; the reply below
// still gives the initiator a precise result/toast.
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": true, "id": in.ID, "mode": "removed",
}))
}
// 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 {
h.audit(c, true, auditUserDelete, map[string]any{
"ok": false,
"reason": "invalid_json",
})
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
return
}
if err := h.auth.DeleteUser(in.Username); err != nil {
h.audit(c, true, auditUserDelete, map[string]any{
"target": in.Username,
"ok": false,
"reason": "delete_failed",
"error": err.Error(),
})
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
return
}
h.audit(c, false, auditUserDelete, map[string]any{
"target": in.Username,
"ok": true,
})
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 {
active := c.termWatching
h.mu.Unlock()
h.audit(c, true, auditTermDenied, map[string]any{
"device_id": in.ID,
"reason": "other_terminal_open",
"active": active,
})
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false,
"msg": "Close current terminal before opening another",
}))
return
}
newSession := c.termWatching != in.ID
c.termWatching = in.ID
c.termCloseReason = ""
if newSession || c.termStartedAt.IsZero() {
c.termStartedAt = time.Now()
}
h.mu.Unlock()
if err := h.devices.OpenTerminalSession(in.ID); err != nil {
h.mu.Lock()
c.termWatching = ""
c.termStartedAt = time.Time{}
h.mu.Unlock()
msg := "Device offline"
reason := "device_offline"
switch err {
case hub.ErrTerminalBusy:
msg = "Terminal already open by another viewer"
reason = "terminal_busy"
case hub.ErrDeviceOffline:
msg = "Device offline"
default:
msg = "Failed to start terminal"
reason = "start_failed"
h.log.Error("OpenTerminalSession(%s): %v", in.ID, err)
}
h.audit(c, true, auditTermDenied, map[string]any{
"device_id": in.ID,
"reason": reason,
})
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": msg}))
return
}
if newSession {
h.audit(c, false, auditTermStart, map[string]any{
"device_id": in.ID,
})
}
}
// 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
}
h.mu.RLock()
watching := c.termWatching == in.ID
h.mu.RUnlock()
if !watching {
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
}
h.mu.RLock()
watching := c.termWatching == in.ID
h.mu.RUnlock()
if !watching {
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
}
h.mu.Lock()
watching := c.termWatching == in.ID
if watching {
// Consumed by OnTerminalClosed for the remote.terminal.stop audit
// record (it fires synchronously inside CloseTerminalSession).
c.termCloseReason = "user"
}
h.mu.Unlock()
if !watching {
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
}