Class: Pvectl::Console::TerminalSession

Inherits:
Object
  • Object
show all
Defined in:
lib/pvectl/console/terminal_session.rb,
sig/pvectl/console/terminal_session.rbs

Overview

Manages an interactive terminal session over a Proxmox VNC WebSocket.

TerminalSession handles the full lifecycle of a console connection:

  1. Opens a raw TCP/SSL socket to the Proxmox host
  2. Performs a WebSocket handshake with authentication
  3. Bridges local stdin/stdout with the remote terminal via the xtermjs wire protocol
  4. Manages raw terminal mode and signal handling (SIGWINCH for resize)

The xtermjs protocol uses numbered message types:

  • Type 0: input data — 0:<bytesize>:<data>
  • Type 1: terminal resize — 1:<cols>:<rows>:
  • Type 2: ping — 2

Examples:

Basic usage (called by Console::Command)

session = Pvectl::Console::TerminalSession.new(
  url: "wss://pve1:8006/api2/json/nodes/pve1/qemu/100/vncwebsocket?port=5900&vncticket=TICKET",
  cookie: "PVEAuthCookie=PVE:root@pam:abc",
  user: "root@pam",
  ticket: "PVEVNC:abc123",
  verify_ssl: true
)
session.run

See Also:

Defined Under Namespace

Classes: SocketAdapter

Constant Summary collapse

CTRL_CLOSE_BRACKET =

Ctrl+] — standard disconnect key (same as telnet/SSH escape)

Returns:

"\x1d"
PING_INTERVAL =

Seconds between keepalive pings sent to the server

Returns:

120
READ_CHUNK_SIZE =

Bytes to read per socket read call

Returns:

4096

Instance Method Summary collapse

Constructor Details

#initialize(url:, cookie:, user:, ticket:, verify_ssl:) ⇒ TerminalSession

Creates a new terminal session.

Parameters:

  • WebSocket URL for the VNC proxy endpoint

  • PVEAuthCookie header value for authentication

  • Proxmox user identifier (e.g., "root@pam")

  • VNC ticket for the handshake (e.g., "PVEVNC:abc123")

  • whether to verify the server's SSL certificate



54
55
56
57
58
59
60
61
62
# File 'lib/pvectl/console/terminal_session.rb', line 54

def initialize(url:, cookie:, user:, ticket:, verify_ssl:)
  @url = url
  @cookie = cookie
  @user = user
  @ticket = ticket
  @verify_ssl = verify_ssl
  @running = false
  @saved_stty = nil
end

Instance Method Details

#build_referer(uri) ⇒ String

Builds a Referer header that signals xtermjs mode to Proxmox.

Proxmox checks the Referer header's query parameters to decide whether to use xtermjs (text) or noVNC (binary RFB) protocol.

Parameters:

  • parsed WebSocket URL

Returns:

  • referer URL with xtermjs=1 query param



195
196
197
# File 'lib/pvectl/console/terminal_session.rb', line 195

def build_referer(uri)
  "https://#{uri.host}:#{uri.port}/?console=shell&xtermjs=1&vmid=0&vmname=&node=localhost&cmd="
end

#create_driver(uri, socket) ⇒ WebSocket::Driver::Client

Creates a WebSocket protocol driver for the given socket.

Uses SocketAdapter to satisfy websocket-driver's interface requirements (the adapter must respond to #url and #write).

Sets required headers for Proxmox xtermjs:

  • Cookie — PVEAuthCookie for session authentication
  • Referer — must include xtermjs=1 query param so the server uses text-based xtermjs protocol instead of binary VNC (RFB)

Parameters:

  • parsed WebSocket URL

  • the underlying socket

Returns:

  • configured WebSocket driver



179
180
181
182
183
184
185
# File 'lib/pvectl/console/terminal_session.rb', line 179

def create_driver(uri, socket)
  adapter = SocketAdapter.new(uri.to_s, socket)
  driver = WebSocket::Driver.client(adapter, protocols: ["binary"])
  driver.set_header("Cookie", @cookie)
  driver.set_header("Referer", build_referer(uri))
  driver
end

#detect_terminal_sizeArray<Integer>

Detects the current terminal dimensions.

Returns:

  • columns and rows as [cols, rows]



409
410
411
412
413
414
415
# File 'lib/pvectl/console/terminal_session.rb', line 409

def detect_terminal_size
  io = IO.console
  return [80, 24] unless io

  rows, cols = io.winsize
  [cols, rows]
end

#disconnect_key?(byte) ⇒ Boolean

Checks if the given byte is the disconnect key (Ctrl+]).

Parameters:

  • a single byte of input

Returns:

  • true if the byte is the disconnect sequence



120
121
122
# File 'lib/pvectl/console/terminal_session.rb', line 120

def disconnect_key?(byte)
  byte == CTRL_CLOSE_BRACKET
end

#enable_raw_terminalvoid

This method returns an undefined value.

Enables raw terminal mode for direct character input.

Saves the current terminal state so it can be restored later. Uses stty for portability.



367
368
369
370
# File 'lib/pvectl/console/terminal_session.rb', line 367

def enable_raw_terminal
  @saved_stty = `stty -g`.chomp
  system("stty raw -echo -icanon -isig")
end

#encode_input(data) ⇒ String

Encodes user input for the xtermjs protocol.

Parameters:

  • raw input bytes from stdin

Returns:

  • encoded message in format "0::"



93
94
95
# File 'lib/pvectl/console/terminal_session.rb', line 93

def encode_input(data)
  "0:#{data.bytesize}:#{data}"
end

#encode_pingString

Encodes a keepalive ping message.

Returns:

  • the ping message "2"



111
112
113
# File 'lib/pvectl/console/terminal_session.rb', line 111

def encode_ping
  "2"
end

#encode_resize(cols, rows) ⇒ String

Encodes a terminal resize notification.

Parameters:

  • new terminal width in columns

  • new terminal height in rows

Returns:

  • encoded message in format "1:::"



103
104
105
# File 'lib/pvectl/console/terminal_session.rb', line 103

def encode_resize(cols, rows)
  "1:#{cols}:#{rows}:"
end

#handle_socket(driver, socket) ⇒ void

This method returns an undefined value.

Reads from the socket and feeds data to the WebSocket driver.

Parameters:

  • WebSocket protocol driver

  • underlying socket



351
352
353
354
355
356
# File 'lib/pvectl/console/terminal_session.rb', line 351

def handle_socket(driver, socket)
  data = socket.readpartial(READ_CHUNK_SIZE)
  driver.parse(data)
rescue EOFError, Errno::ECONNRESET
  @running = false
end

#handle_stdin(driver) ⇒ void

This method returns an undefined value.

Reads from stdin and sends encoded input to the WebSocket.

Parameters:

  • WebSocket protocol driver



332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/pvectl/console/terminal_session.rb', line 332

def handle_stdin(driver)
  data = $stdin.readpartial(READ_CHUNK_SIZE)

  if disconnect_key?(data)
    @running = false
    return
  end

  driver.text(encode_input(data))
rescue EOFError, IO::WaitReadable
  @running = false
end

#handshake_messageString

Builds the authentication handshake message.

Returns:

  • handshake in format ":\n"



128
129
130
# File 'lib/pvectl/console/terminal_session.rb', line 128

def handshake_message
  "#{@user}:#{@ticket}\n"
end

#open_socket(uri) ⇒ TCPSocket, OpenSSL::SSL::SSLSocket

Opens a TCP socket with optional SSL wrapping.

Parameters:

  • parsed WebSocket URL

Returns:

  • the connected socket



139
140
141
142
143
144
145
146
147
# File 'lib/pvectl/console/terminal_session.rb', line 139

def open_socket(uri)
  tcp = TCPSocket.new(uri.host, uri.port)

  if uri.scheme == "wss"
    wrap_ssl(tcp, uri.host)
  else
    tcp
  end
end

#perform_websocket_handshake(driver, socket) ⇒ void

This method returns an undefined value.

Performs the WebSocket handshake and Proxmox authentication.

Starts the WebSocket driver, waits for the :open event, sends the authentication message, and waits for an "OK" response.

Parameters:

  • WebSocket protocol driver

  • underlying socket

Raises:

  • if the handshake times out or authentication fails



234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/pvectl/console/terminal_session.rb', line 234

def perform_websocket_handshake(driver, socket)
  open = false
  authenticated = false

  driver.on(:open) { open = true }
  driver.on(:message) do |msg|
    authenticated = true if msg.data == "OK"
  end

  driver.start

  # Wait for WebSocket open
  until open
    data = read_from_socket(socket, timeout: 10)
    raise "WebSocket handshake timed out" unless data

    driver.parse(data)
  end

  # Send auth and wait for OK
  driver.text(handshake_message)

  until authenticated
    data = read_from_socket(socket, timeout: 10)
    raise "Authentication timed out" unless data

    driver.parse(data)
  end
end

#read_from_socket(socket, timeout:) ⇒ String?

Reads available data from a socket with a timeout.

For SSL sockets, checks pending first to handle buffered data that IO.select cannot detect.

Parameters:

  • socket to read from

  • maximum seconds to wait for data

Returns:

  • raw data or nil on timeout/EOF



208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/pvectl/console/terminal_session.rb', line 208

def read_from_socket(socket, timeout:)
  # SSL sockets may have buffered data not visible to IO.select
  if socket.respond_to?(:pending) && socket.pending > 0
    return socket.readpartial(READ_CHUNK_SIZE)
  end

  ready = IO.select([socket], nil, nil, timeout)
  return nil unless ready

  socket.readpartial(READ_CHUNK_SIZE)
rescue EOFError, Errno::ECONNRESET, IO::WaitReadable
  nil
end

#restore_terminalvoid

This method returns an undefined value.

Restores the terminal to its saved state.

Called in an ensure block to guarantee cleanup even on errors.



378
379
380
381
# File 'lib/pvectl/console/terminal_session.rb', line 378

def restore_terminal
  system("stty #{@saved_stty}") if @saved_stty
  @saved_stty = nil
end

#runvoid

This method returns an undefined value.

Runs the interactive terminal session.

Opens the WebSocket connection, performs the handshake, and enters the I/O loop bridging stdin to the remote terminal. Restores the local terminal state on exit (even on error).

Raises:

  • if the WebSocket handshake fails



73
74
75
76
77
78
79
80
81
82
# File 'lib/pvectl/console/terminal_session.rb', line 73

def run
  uri = URI.parse(@url)
  socket = open_socket(uri)
  driver = create_driver(uri, socket)
  perform_websocket_handshake(driver, socket)
  run_io_loop(driver, socket)
ensure
  restore_terminal
  socket&.close
end

#run_io_loop(driver, socket) ⇒ void

This method returns an undefined value.

Main I/O loop bridging local terminal and remote WebSocket.

Puts the terminal in raw mode, then multiplexes between stdin and the remote socket using IO.select. Sends keepalive pings every PING_INTERVAL seconds. Exits on disconnect key (Ctrl+]) or connection close.

Parameters:

  • WebSocket protocol driver

  • underlying socket



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
# File 'lib/pvectl/console/terminal_session.rb', line 276

def run_io_loop(driver, socket)
  @running = true

  enable_raw_terminal
  send_initial_resize(driver)
  # Send an initial empty input to wake the remote terminal prompt
  driver.text(encode_input("\n"))
  trap_resize(driver)

  driver.on(:message) do |msg|
    $stdout.write(msg.data)
    $stdout.flush
  end

  driver.on(:close) { @running = false }

  last_ping = Time.now

  while @running
    # SSL sockets may have buffered data
    if socket.respond_to?(:pending) && socket.pending > 0
      driver.parse(socket.readpartial(READ_CHUNK_SIZE))
      next
    end

    timeout = [PING_INTERVAL - (Time.now - last_ping), 1].max
    ready = IO.select([$stdin, socket], nil, nil, timeout)

    # Send ping on timeout
    if ready.nil?
      driver.text(encode_ping)
      last_ping = Time.now
      next
    end

    ready[0].each do |io|
      if io == $stdin
        handle_stdin(driver)
      else
        handle_socket(driver, socket)
      end
    end

    # Periodic ping
    if Time.now - last_ping >= PING_INTERVAL
      driver.text(encode_ping)
      last_ping = Time.now
    end
  end
end

#send_initial_resize(driver) ⇒ void

This method returns an undefined value.

Sends the initial terminal size to the remote server.

Parameters:

  • WebSocket protocol driver



388
389
390
391
# File 'lib/pvectl/console/terminal_session.rb', line 388

def send_initial_resize(driver)
  cols, rows = detect_terminal_size
  driver.text(encode_resize(cols, rows))
end

#trap_resize(driver) ⇒ void

This method returns an undefined value.

Installs a SIGWINCH handler to send resize events on terminal size changes.

Parameters:

  • WebSocket protocol driver



398
399
400
401
402
403
# File 'lib/pvectl/console/terminal_session.rb', line 398

def trap_resize(driver)
  Signal.trap("WINCH") do
    cols, rows = detect_terminal_size
    driver.text(encode_resize(cols, rows))
  end
end

#wrap_ssl(tcp, hostname) ⇒ OpenSSL::SSL::SSLSocket

Wraps a TCP socket in SSL.

Parameters:

  • raw TCP socket

  • server hostname for SNI

Returns:

  • SSL-wrapped socket



155
156
157
158
159
160
161
162
163
# File 'lib/pvectl/console/terminal_session.rb', line 155

def wrap_ssl(tcp, hostname)
  ctx = OpenSSL::SSL::SSLContext.new
  ctx.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE

  ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
  ssl.hostname = hostname
  ssl.connect
  ssl
end