Feature(Go): Web terminal relay with PTY mode and graceful close (Phase 6)

This commit is contained in:
yuanyuanxiang
2026-05-18 15:03:42 +02:00
committed by yuanyuanxiang
parent 6485e800d6
commit d7f38ecfdb
7 changed files with 696 additions and 96 deletions

View File

@@ -46,11 +46,12 @@ type wsClient struct {
once sync.Once
// Mutated under wsHub.mu (or only by the read loop owning this client).
nonce string // outstanding challenge — cleared after a successful login
token string // set once authenticated
role string // mirrors session role after login
addr string // client address for logs
watching string // device ID this browser is currently streaming, "" when on the list
nonce string // outstanding challenge — cleared after a successful login
token string // set once authenticated
role string // mirrors session role after login
addr string // client address for logs
watching string // device ID this browser is currently streaming, "" when on the list
termWatching string // device ID for an open web terminal session, "" otherwise
}
// queue writes a JSON text frame onto the send buffer. Drops silently if the
@@ -176,6 +177,61 @@ func (h *wsHub) OnScreenFrame(deviceID string, packet []byte, _ bool) {
}
}
// OnTerminalReady notifies the requesting browser that its term_open
// handshake completed. mode is "pty" or "legacy" — xterm.js disables the
// resize callback in legacy mode (no PTY behind the cmd pipe).
func (h *wsHub) OnTerminalReady(deviceID string, isPTY bool) {
mode := "legacy"
if isPTY {
mode = "pty"
}
msg := mustJSON(map[string]any{
"cmd": "term_ready",
"id": deviceID,
"mode": mode,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.queue(msg)
}
}
}
// OnTerminalData ships one chunk of raw shell output (already wrapped in
// the "TRM1" magic header) over the binary WS frame. Single-viewer is
// enforced upstream so at most one client matches per device.
func (h *wsHub) OnTerminalData(deviceID string, packet []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.queueBinary(packet)
}
}
}
// OnTerminalClosed fires when the device's shell exits or the sub-conn
// drops. The browser closes its xterm panel. We also clear termWatching
// so a subsequent term_open from the same browser isn't rejected as
// "already open" by stale state.
func (h *wsHub) OnTerminalClosed(deviceID string, reason string) {
msg := mustJSON(map[string]any{
"cmd": "term_closed",
"ok": true,
"reason": reason,
})
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.termWatching = ""
c.queue(msg)
}
}
}
// OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list
// rows can refresh RTT and active-window labels without re-fetching.
func (h *wsHub) OnDeviceUpdate(id string, rtt int, activeWindow string) {
@@ -221,6 +277,13 @@ func (h *wsHub) unregister(c *wsClient) {
if c.watching != "" && h.countWatchers(c.watching) == 0 {
h.devices.CloseScreen(c.watching)
}
// Terminal sessions are single-viewer by design, so any open session
// belongs to this client. Tear it down so the next viewer doesn't
// hit ErrTerminalBusy from an abandoned session.
if c.termWatching != "" {
h.devices.CloseTerminalSession(c.termWatching)
c.termWatching = ""
}
// Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
// Frontend may close+reopen the WS at any time (visibilitychange handler,
// brief network blip, reload) and must be able to resume with the same

View File

@@ -15,8 +15,8 @@ import (
// 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/7 commands (term_*, user mgmt) get a friendly "not yet implemented"
// reply so the browser UI doesn't hang silently.
// 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":
@@ -39,9 +39,13 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
case "key":
h.handleKey(c, raw)
case "term_open":
h.replyNotImplemented(c, "term_closed", "Web terminal not yet implemented on Go server")
case "term_input", "term_resize", "term_close":
// silently ignored — no terminal session
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":
@@ -55,14 +59,6 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
}
}
func (h *wsHub) replyNotImplemented(c *wsClient, replyCmd, msg string) {
c.queue(mustJSON(map[string]any{
"cmd": replyCmd,
"ok": false,
"msg": msg,
}))
}
// 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.
@@ -555,6 +551,134 @@ func (h *wsHub) handleGetGroups(c *wsClient, raw []byte) {
}))
}
// 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