What We’re Testing
Command execution over remote shell uses two distinct paths:
Path 1 — Interactive PTY (WebSocket)
The WebSocket handler createRemoteShellWsHandler in backend/src/handlers/remote-shell-ws.ts proxies browser keystrokes to the Go agent’s HTTP PTY endpoint. The flow is:
Browser (xterm.js) <--WebSocket-- Backend (/api/remote-shell/ws) <--HTTP streaming-- Agent (tailnet_ip:2222/shell)
The backend opens a long-lived HTTP GET to http://TAILNET_IP:2222/shell?token=SHELL_TOKEN. The request body is kept open as a writable stream (agent stdin). The response body is a chunked stream (agent stdout/stderr). The session maximum is 4 hours (timeout: 4 * 60 * 60 * 1000 ms in remote-shell-ws.ts).
Path 2 — Scripted Execution (REST)
The execute_script action in handleRemoteShell (remote-shell.ts) records a script_executions row with status = 'pending', returns a one-time token and connection details, then the Go agent picks up the script via the same agent endpoint. The execution status transitions are: pending → running → completed / failed / timeout. Output is capped at 100 KB by update_execution.
Both paths require remote_management feature gate, org admin role, and the machine to be online.
Your Test Setup
| Machine | Role |
|---|---|
| ⊞ Win-A | Dashboard browser, API caller |
| 🐧 Linux-C | Target machine running the Go agent with PTY endpoint on port 2222 |
Prerequisites:
- 🐧 Linux-C is online with the QuickZTNA agent running (
ztna up). - Org is on Workforce plan with
remote_managementenabled. - Caller is an org admin with a valid JWT.
- A valid
shell_tokenhas been obtained viacreate_session(see chapter 111).
ST1 — Interactive PTY: Connect and Run a Command
What it verifies: The WebSocket bridge successfully relays keystrokes to the agent PTY and streams output back to the browser terminal.
Steps:
- In the dashboard on ⊞ Win-A , navigate to Remote Management (
/remote-management). - Select 🐧 Linux-C from the machine dropdown.
- Click “Connect”. The
RemoteTerminalcomponent callscreate_sessionand then opens a WebSocket to/api/remote-shell/ws. - Observe the terminal header: the status indicator should turn green and show “Connected”.
- The terminal writes:
QuickZTNA Remote Shell
Connecting to Linux-C...
Session created (expires in 300s)
Connected to Linux-C
- Type a simple command:
uname -a
- Press Enter. Observe output within 1-2 seconds, e.g.:
Linux linux-c 6.8.0-51-generic #52-Ubuntu SMP PREEMPT_DYNAMIC ...
- Run a multi-line command:
for i in 1 2 3; do echo "line $i"; sleep 0.2; done
- Confirm each line appears incrementally (streaming, not batched).
Pass: Keystrokes are echoed back by the PTY. Command output appears within 2 seconds. Multi-line output arrives in order.
Fail / Common issues:
- Terminal header stays yellow (“Connecting…”) — the agent is not reachable at
tailnet_ip:2222. Ensure the Go agent is running on Linux-C and the tailnet is up. - WebSocket closes immediately with code 4003 — the feature gate or admin check failed. Confirm plan and role.
- Output is garbled — this is a terminal encoding issue; the PTY uses UTF-8 by default, which xterm.js handles correctly. If garbled, check that the agent’s PTY is not outputting raw binary sequences.
ST2 — Interactive PTY: Special Keys and Control Sequences
What it verifies: Control characters (Ctrl+C, Ctrl+L, arrow keys, Tab completion) are relayed correctly as binary frames through the WebSocket.
Steps:
- With an active session on 🐧 Linux-C , run a long-running command:
sleep 60
- Press Ctrl+C. The
RemoteTerminalcomponent sends the byte0x03viaws.send. Verify the sleep process is killed and the prompt returns. - Type
ls /etand press Tab. Verify Tab completion expands tols /etc/. - Press the Up arrow key. Verify the previous command
sleep 60appears (shell history recall). - Press Ctrl+L. Verify the terminal clears the screen (sends
0x0c). - Start the
topcommand:
top
- Press
qto quit. Verify top exits cleanly and the shell prompt returns.
Pass: Ctrl+C terminates the foreground process. Tab completion and arrow keys work. Ctrl+L clears the screen. Interactive programs (top) render and respond to keypresses.
Fail / Common issues:
- Ctrl+C does not kill the process — the
onDatahandler sends the raw string"\x03", but if the WebSocket message is not received by the agent as binary, the PTY may not interpret it as SIGINT. Verify the agent is running the latest binary (v3.2.8+). - Tab completion not working — the shell on Linux-C may not have completion enabled. Run
bash --loginfirst.
ST3 — Script Execution via REST API
What it verifies: The execute_script action creates a script_executions record, returns a token, and the execution history reflects the correct status lifecycle.
Steps:
- First, add a test script. On ⊞ Win-A :
curl -s -X POST "https://login.quickztna.com/api/remote-shell" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "add_script",
"org_id": "YOUR_ORG_ID",
"name": "Disk Check",
"description": "Check disk usage",
"script_body": "df -h",
"os_target": "linux"
}'
Expected: HTTP 200, data.id contains the new script UUID.
- Execute the script against Linux-C:
curl -s -X POST "https://login.quickztna.com/api/remote-shell" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "execute_script",
"org_id": "YOUR_ORG_ID",
"script_id": "SCRIPT_UUID",
"machine_id": "LINUX_C_MACHINE_ID"
}'
Expected response:
{
"success": true,
"data": {
"execution_id": "UUID",
"script_name": "Disk Check",
"machine_name": "Linux-C",
"tailnet_ip": "100.64.x.x",
"port": 2222,
"token": "UUID-UUID"
},
"error": null
}
- Immediately query execution history to see the
pendingstatus:
curl -s -X POST "https://login.quickztna.com/api/remote-shell" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "execution_history",
"org_id": "YOUR_ORG_ID",
"limit": 5
}'
Expected: The newest entry shows status: "pending" for the Disk Check script on Linux-C.
- Wait for the agent to execute the script (the frontend polls every 3 seconds). After the agent calls
update_executionwithstatus: "completed", re-query history. Expected:status: "completed",exit_code: 0,outputcontainsdf -houtput.
Pass: Execution record transitions pending → running → completed. Exit code is 0. Output contains filesystem data.
Fail / Common issues:
- Status stays
pendingindefinitely — the Go agent is not polling for new scripts. Verify the agent version and that it hasremote_managementintegration enabled. OS_MISMATCHerror — the scriptos_targetislinuxbut the machine’sosfield in themachinestable showswindows. Run the script against the correct machine.
ST4 — Script Execution OS Mismatch Rejection
What it verifies: The execute_script action rejects execution when the script’s os_target does not match the machine’s OS (and is not all).
Steps:
- Create a Windows-only script:
curl -s -X POST "https://login.quickztna.com/api/remote-shell" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "add_script",
"org_id": "YOUR_ORG_ID",
"name": "Windows Only",
"script_body": "Get-Process",
"os_target": "windows"
}'
- Attempt to run it against 🐧 Linux-C (which has
os: "linux"):
curl -s -X POST "https://login.quickztna.com/api/remote-shell" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d '{
"action": "execute_script",
"org_id": "YOUR_ORG_ID",
"script_id": "WINDOWS_SCRIPT_UUID",
"machine_id": "LINUX_C_MACHINE_ID"
}'
Expected:
{
"success": false,
"data": null,
"error": {
"code": "OS_MISMATCH",
"message": "Script targets windows but machine runs linux"
}
}
- Verify that a script with
os_target: "all"executes on Linux-C without an OS mismatch error.
Pass: HTTP 400 with OS_MISMATCH. Scripts with os_target: "all" bypass the OS check and execute on any machine.
Fail / Common issues:
- No
OS_MISMATCHerror even with mismatched OS — themachines.osfield may be null or unset. The handler only performs the check whenmachine.osis present:if (script.os_target !== 'all' && machine.os && machine.os !== script.os_target). A nullmachine.osskips the check by design.
ST5 — Script Body Size Limit Enforcement
What it verifies: The add_script action rejects scripts exceeding 50 KB.
Steps:
- On ⊞ Win-A , generate a script body larger than 50,000 characters (50 KB):
$bigBody = "echo hello`n" * 5001 # ~60,000 chars
curl -s -X POST "https://login.quickztna.com/api/remote-shell" `
-H "Authorization: Bearer YOUR_TOKEN" `
-H "Content-Type: application/json" `
-d (ConvertTo-Json @{
action = "add_script"
org_id = "YOUR_ORG_ID"
name = "Too Big"
script_body = $bigBody
os_target = "all"
})
Expected:
{
"success": false,
"data": null,
"error": {
"code": "INVALID_INPUT",
"message": "Script body too large (max 50KB)"
}
}
- Confirm the limit is checked at
script_body.length > 50000(character count, not byte count). - Submit a script body of exactly 50,000 characters. This should succeed.
Pass: Scripts over 50,000 characters return HTTP 400 with INVALID_INPUT. Scripts at or under the limit are accepted.
Fail / Common issues:
- Request times out before reaching the size check — the backend applies a 1 MB body size limit by default. A 50 KB payload will not hit that limit. If requests fail with a 413, the Caddy body size limit or the server body limit is too restrictive.
Summary
| Sub-test | What it proves |
|---|---|
| ST1 | WebSocket PTY relays keystrokes and streams output; create_session token is used as shell_token |
| ST2 | Control sequences (Ctrl+C, Tab, arrows) are forwarded as binary frames and processed correctly by the PTY |
| ST3 | execute_script creates a pending execution record; agent transitions it to completed with output |
| ST4 | OS mismatch between script os_target and machine os returns OS_MISMATCH; os_target: "all" bypasses the check |
| ST5 | Scripts over 50,000 characters are rejected with INVALID_INPUT; the limit is enforced in add_script before any DB write |