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 }