Files
SimpleRemoter/server/go/web/ws_handlers.go
yuanyuanxiang d757c33bcb Fix(Go): stable device list ordering + RDP-reset handler
Fix UTF-8 login text decode + stale screen sub-conn retirement
2026-05-19 16:28:32 +02:00

785 lines
26 KiB
Go

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":
h.handleRdpReset(c, raw)
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}))
}
// 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 := c.watching
if deviceID == "" {
h.log.Warn("rdp_reset: no device watched (addr=%s role=%s)", c.addr, c.role)
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.log.Warn("rdp_reset: device=%s: %v", deviceID, err)
return
}
h.log.Info("rdp_reset sent: device=%s", deviceID)
}
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
}