Feature(Go): Web terminal relay with PTY mode and graceful close (Phase 6)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user