WebSocket Terminal Protocol

NexusPanel's web terminal uses WebSocket connections to bridge the browser with server-side PTY (pseudo-terminal) sessions powered by node-pty.


Architecture

Browser (xterm.js)                Server (node-pty)
      │                                │
      │  ──── WebSocket ──────►        │
      │       (base64 encoded)         │
      │                                │
      │                           ┌────┴────┐
      │                           │  node-pty │
      │                           │  session  │
      │                           └────┬────┘
      │                                │
      │  ◄──── WebSocket ──────        │
      │       (base64 encoded)         │
      │                           ┌────┴────┐
      │                           │   bash   │
      │                           │  shell   │
      │                           └─────────┘

Connection Setup

WebSocket Server

NexusPanel uses ws in noServer mode:

const wss = new WebSocketServer({ noServer: true });

The HTTP server listens for upgrade events and manually routes:

PathHandler
/ws/terminalTerminal WebSocket
/ws/dockerDocker exec WebSocket
Anything elseSocket destroyed

Authentication

Authentication happens during the HTTP upgrade handshake, before the WebSocket connection is established:

  1. Parse raw Cookie header from the upgrade request
  2. Extract token cookie
  3. If missing → write HTTP/1.1 401 Unauthorized and destroy socket
  4. Verify via jwt.verify(token, JWT_SECRET)
  5. If invalid → write HTTP/1.1 401 Unauthorized and destroy socket
  6. If valid → populate req.user and call wss.handleUpgrade()

Connection Lifecycle

1. Browser opens WebSocket to /ws/terminal
       │
2. Server authenticates via JWT cookie
       │
3. Server sends: { type: "ready" }
       │
4. Browser sends: { type: "create" }
       │
5. Server spawns PTY session via node-pty
       │
6. Server sends: { type: "created", paneId: "p1" }
       │
7. Bidirectional data flow begins
       │
8. Browser sends: { type: "close-pane", paneId: "p1" }
       │
9. Server kills PTY with SIGHUP
       │
10. Server sends: { type: "pane-closed", paneId: "p1" }

Message Format

All messages are JSON strings. Data fields (user input, terminal output) are base64-encoded to safely transport binary/ANSI data.

Client → Server Messages

create

Create a new terminal pane.

{
  "type": "create",
  "cols": 120,
  "rows": 40,
  "paneId": "custom-id"
}
FieldRequiredDescription
typeYesMust be "create"
colsNoTerminal columns (default: 80)
rowsNoTerminal rows (default: 24)
paneIdNoCustom pane ID (auto-generated if omitted)

Response: { type: "created", paneId: "p1" }

create-pane

Create a new terminal pane with auto-generated ID.

{
  "type": "create-pane",
  "cols": 120,
  "rows": 40
}

Response: { type: "pane-created", paneId: "p2" }

input

Send user input to the terminal.

{
  "type": "input",
  "paneId": "p1",
  "data": "bHMgLWxhCg=="
}
FieldRequiredDescription
typeYesMust be "input"
paneIdYesTarget pane ID
dataYesBase64-encoded user input

No response (output comes back as data messages).

resize

Resize the terminal.

{
  "type": "resize",
  "paneId": "p1",
  "cols": 160,
  "rows": 50
}
FieldRequiredDescription
typeYesMust be "resize"
paneIdYesTarget pane ID
colsYesNew column count
rowsYesNew row count

No response.

close-pane

Close a terminal pane (graceful).

{
  "type": "close-pane",
  "paneId": "p1"
}

Sends SIGHUP to the PTY process and removes it from the panes map.

Response: { type: "pane-closed", paneId: "p1" }

kill

Kill a terminal pane (force).

{
  "type": "kill",
  "paneId": "p1"
}

Sends SIGHUP to the PTY process but does not remove from the panes map (removal happens on the PTY exit event).

No immediate response (exit message comes later).


Server → Client Messages

ready

Sent immediately on connection establishment.

{ "type": "ready" }

created

Acknowledges a create message.

{ "type": "created", "paneId": "p1" }

pane-created

Acknowledges a create-pane message.

{ "type": "pane-created", "paneId": "p2" }

pane-closed

Acknowledges a close-pane message.

{ "type": "pane-closed", "paneId": "p1" }

data

Terminal output from the PTY.

{
  "type": "data",
  "paneId": "p1",
  "data": "bHMgLWxhCgp0b3RhbCA4CmRyaXctZXcK..."
}
FieldDescription
typeAlways "data"
paneIdSource pane ID
dataBase64-encoded terminal output (may contain ANSI escape codes)

exit

Sent when the PTY process exits (e.g., user types exit).

{ "type": "exit", "paneId": "p1" }

The pane is also removed from the server's panes map.


node-pty Integration

Session Creation

const pty = require('node-pty');

const session = pty.spawn(shell, [], {
  name: 'xterm-256color',
  cols: cols || 80,
  rows: rows || 24,
  cwd: process.env.HOME || '/root',
  env: sanitizeEnv(env),
});

Shell Selection

  1. $SHELL environment variable (if set)
  2. powershell.exe (on Windows)
  3. bash (fallback)

Environment Sanitization

Only safe environment variables are passed to the PTY:

VariableDescription
HOMEHome directory
USERCurrent username
LOGNAMELogin name
SHELLShell path
TERMAlways xterm-256color
PATHAlways included
LANGLocale
LC_ALLLocale override
EDITORDefault editor
PAGERDefault pager
DISPLAYX display
XAUTHORITYX authority file
HOSTNAMESystem hostname
HOSTSystem hostname
TZTimezone
PWDCurrent directory
OLDPWDPrevious directory

PTY Events

EventDescription
onData(callback)Terminal output received
onExit(callback)PTY process exited

PTY Methods

MethodDescription
write(data)Send input to the PTY
resize(cols, rows)Resize the terminal
kill(signal)Send signal to the PTY process

Multi-Pane Support

Each WebSocket connection maintains a Map of active panes:

const panes = new Map();
// Key: pane ID (e.g., "p1", "p2")
// Value: { pty: PTYProcess, id: string }

Pane ID Generation

  • Auto-generated IDs: p1, p2, p3, ... (incrementing counter per connection)
  • Custom IDs: Provided by the client via the create message

Connection Close

When a WebSocket connection closes, all panes for that connection are killed:

wss.on('connection', (ws) => {
  const panes = new Map();

  ws.on('close', () => {
    for (const [id, pane] of panes) {
      pane.pty.kill('SIGHUP');
    }
    panes.clear();
  });
});

This prevents orphaned PTY processes if the browser disconnects.


Terminal Presets

NexusPanel stores reusable command presets in data/terminal-presets.json.

Preset Format

{
  "id": "preset_<timestamp>",
  "label": "Docker PS",
  "command": "docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Status}}'",
  "category": "Docker"
}

Categories

CategoryExample Commands
Systemhtop, df -h, free -m, uname -a
Dockerdocker ps, docker images, docker logs
Filesls -la, find . -name "*.log", du -sh *
Networkss -tlnp, curl ifconfig.me, dig example.com
Databasepsql -U postgres, pg_dump
CustomUser-defined commands

API

MethodEndpointDescription
GET/api/terminal/presetsList all presets
POST/api/terminal/presetsCreate preset
PUT/api/terminal/presets/:idUpdate preset
DELETE/api/terminal/presets/:idDelete preset

Frontend Integration

xterm.js Configuration

The terminal frontend uses xterm.js 5.5.0 with the following addons:

AddonPurpose
FitAddonAuto-resize terminal to fit container
SearchAddonSearch within terminal output
WebLinksAddonClickable URLs in terminal output
WebGLAddonGPU-accelerated rendering
Unicode11AddonFull Unicode support

WebSocket URL

const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/ws/terminal`;

Data Flow

User types in xterm.js
       │
1. xterm.js onData fires
       │
2. Encode to base64: btoa(data)
       │
3. Send via WebSocket: { type: "input", paneId, data }
       │
4. Server decodes: Buffer.from(data, 'base64').toString()
       │
5. Write to PTY: pty.write(decoded)
       │
6. PTY outputs to shell
       │
7. Shell output via pty.onData
       │
8. Server encodes to base64
       │
9. Send via WebSocket: { type: "data", paneId, data }
       │
10. Client decodes and writes to xterm.js

Error Handling

Connection Errors

ErrorHandling
WebSocket fails to connectShow error message, offer retry
Authentication fails (401)Redirect to login page
PTY spawn failsSend error notification, close pane
PTY exits unexpectedlySend exit message, update UI
Browser disconnectsAll panes killed server-side

Idle Disconnect

The nginx proxy_read_timeout should be set to 3600s (1 hour) to prevent idle terminal disconnects. The terminal frontend sends keepalive resize events to maintain the connection.


Part of NexusPanel Documentation

← Back to Documentation