Major update to SPEC-012 adding dual-mode terminal access: Mode 1: Serial Console Mode (True Remote Console) - Direct access to system serial console (/dev/ttyS0 or /dev/console) - Sees GRUB bootloader, kernel boot messages, login prompts, kernel panics - Boot-time interaction: select GRUB entries, edit kernel parameters, single-user mode - Requires root privileges or CAP_SYS_TTY_CONFIG capability - Setup: GRUB + kernel parameters configured for serial console output - Like KVM-over-IP or IPMI Serial-over-LAN (text-mode equivalent) Mode 2: PTY Shell Mode (Interactive Shell) - Spawn pseudo-TTY with bash/zsh shell session - Normal server management (package updates, log review, etc.) - Runs as unprivileged agent service user - Standard interactive shell with full ANSI/VT100 support Architecture: - Agent mode selection based on viewer request (console vs. shell) - Dashboard shows two buttons: "Console" and "Shell" for headless agents - Same xterm.js viewer handles both modes transparently - Protobuf extensions: TerminalModeRequest enum, console_mode flag Security: - Console mode requires root (boot-level control risk) - Recommend RBAC: separate console_access and shell_access permissions - Console sessions should require MFA (Phase 2) - Audit logging for both modes Setup Requirements: - One-time GRUB configuration for serial console - systemd service with CAP_SYS_TTY_CONFIG for console mode - serial-getty@ttyS0.service enabled for login prompt Updated effort: Medium (5-7 weeks, up from 4-6) Priority remains P2 Addresses user request for "remote console" (as if at the machine) not just shell access. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
35 KiB
SPEC-012: Headless Linux Mode (Serial Console + PTY Shell Access)
Status: Proposed Priority: P2 Requested By: Mike Swanson (2026-05-30) Estimated Effort: Medium (5-7 weeks)
Overview
Enable GuruConnect agent support for headless Linux servers (no X11/Wayland GUI) by providing two modes of terminal access: Serial Console Mode for boot-level access (GRUB, kernel messages, panics) and PTY Shell Mode for normal server management. This addresses critical server management use cases—from emergency recovery to routine administration—without requiring SSH. The viewer displays a terminal emulator (xterm.js web viewer) connected to either the system serial console (/dev/ttyS0) or a pseudo-TTY shell session. Serial Console Mode provides true "remote console" access like KVM-over-IP or IPMI Serial-over-LAN, seeing everything the physical monitor would show. PTY Shell Mode provides an interactive shell for normal management tasks. Success criteria: technician can access GRUB bootloader, view kernel boot messages, handle kernel panics, AND perform routine server management—all via GuruConnect dashboard with centralized authentication and audit logging.
Use Cases:
- Boot-level access: GRUB menu selection, kernel parameter editing, single-user mode
- Emergency recovery: Kernel panic diagnosis, filesystem repair, systemd rescue shell
- Server management: Package updates, configuration changes, log review (normal shell access)
- Container debugging: Exec into running containers via GuruConnect
- MSP consolidation: One tool for desktop support (GUI), server boot recovery (console), and server management (shell)
Success Criteria:
- Serial Console Mode: View GRUB bootloader, kernel boot messages, kernel panics, login prompts—as if sitting at physical console
- PTY Shell Mode: Interactive shell (bash/zsh) with full ANSI color, cursor control, vim/nano/htop support
- GuruConnect agent runs on Ubuntu Server 22.04 minimal install (no desktop packages)
- Dashboard mode selector: "Console" vs. "Shell" per agent (user chooses at connection time)
- Same protobuf-over-WSS transport, support-code and persistent-agent authentication
- Audit logging: session recording for both console and shell modes
Scope
Included in v1
Mode 1: Serial Console Mode (True Remote Console)
- Open system serial console device (
/dev/ttyS0or/dev/console) for raw I/O - Relay all bytes bidirectionally: console output →
TerminalData→ viewer; viewer input →TerminalInput→ console - Sees everything: GRUB bootloader menu, kernel boot messages, systemd startup, login prompts, kernel panics
- Boot-time interaction: Select GRUB entries, edit kernel parameters, boot into single-user mode
- Requires root privileges (serial console access restricted to root)
- Requires serial console enabled on target server (GRUB + kernel parameters configured)
- No PTY spawning—direct device I/O, like
screen /dev/ttyS0 115200 - Agent config flag:
console_mode: true+console_device: "/dev/ttyS0"
Mode 2: PTY Shell Mode (Interactive Shell)
- Detect headless environment (no DISPLAY, no X11/Wayland libraries) at runtime
- Spawn pseudo-TTY (PTY) via
openpty()+ fork/exec shell (/bin/bash -lor user's$SHELL) - Terminal I/O: read PTY output → encode as protobuf
TerminalData→ send via WebSocket - Input: receive protobuf
TerminalInput→ write to PTY master - Terminal resize: handle
TerminalResizemessage → sendSIGWINCHto PTY - Fallback shell selection:
$SHELLenv var →/bin/bash→/bin/sh - Graceful PTY cleanup on session end (send exit command, wait for shell exit, close PTY)
- Standard user privileges (runs as agent service user)
Mode Selection:
- Dashboard shows mode selector when connecting to headless agent: "Console" vs. "Shell"
- "Console" button: viewer sends
mode: consolein connection request → agent opens/dev/ttyS0 - "Shell" button: viewer sends
mode: shellin connection request → agent spawns PTY - Agent config specifies default mode if serial console unavailable
- If serial console device doesn't exist or permission denied, fall back to PTY shell mode with warning
Both Modes Share:
- Same agent binary:
guruconnectdetects headless and offers both modes - Same xterm.js viewer (handles both serial console and PTY identically)
Viewer (Web Viewer):
- xterm.js-based terminal emulator embedded in
viewer.html - Connects to same
/ws/viewerendpoint with session JWT - Relay server detects
TerminalDataframes (notFrameData) and routes accordingly - Terminal controls: resize on window resize, copy/paste support, configurable font size
- Session toolbar: connection status, terminal size (e.g., "80x24"), reconnect button
Viewer (Native Desktop Viewer - optional Phase 2):
- Defer native viewer terminal support to Phase 2
- v1: web viewer only for terminal sessions (show "Open in browser" prompt if launched via
guruconnect://)
Protobuf Protocol:
- New message types:
TerminalData(PTY output),TerminalInput(keyboard input),TerminalResize(window size) AgentStatusincludesterminal_mode: boolflag (true for headless agents)- Dashboard shows terminal icon for headless agents, camera icon for GUI agents
Dashboard:
- Detect
terminal_mode: truein agent status - "Connect" button opens web viewer in terminal mode (not screen capture mode)
- Agent list shows "Terminal" badge for headless agents
Session Recording (Audit):
- Log all terminal I/O to
eventstable or separateterminal_sessionstable - Playback: recorded session can be replayed as "terminal recording" (asciicast format or raw PTY dump)
Explicitly out of scope
- GUI mode on headless agents — v1 is terminal-only; no attempt to start Xvfb or launch GUI apps
- SSH key management — agent uses GuruConnect auth (support code / agent key), not SSH keys
- File transfer via terminal — defer to SPEC (file transfer is a separate roadmap item for all agent types)
- Multi-user terminal sessions — v1 is single-session console/PTY; no tmux/screen built-in sharing
- Windows terminal mode — defer; Windows Server typically has GUI (RDP) or SSH (OpenSSH)
- macOS terminal mode — defer; macOS servers are rare and typically have GUI access
- Framebuffer capture (
/dev/fb0) — defer; serial console is more reliable and doesn't require framebuffer device
Serial Console Setup Requirements (Mode 1)
To use Serial Console Mode, the target Linux server must be configured to output to serial console. This is a one-time setup per server (typically done during provisioning):
Step 1: Configure GRUB
Edit /etc/default/grub:
# Enable serial console output at 115200 baud
GRUB_TERMINAL="serial console"
GRUB_SERIAL_COMMAND="serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1"
# Kernel console output to both VGA (tty0) and serial (ttyS0)
GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200n8"
Update GRUB and reboot:
sudo update-grub # Debian/Ubuntu
# OR
sudo grub2-mkconfig -o /boot/grub2/grub.cfg # RHEL/CentOS
sudo reboot
Step 2: Enable getty on Serial Console
Ensure a login prompt appears on serial console after boot:
sudo systemctl enable serial-getty@ttyS0.service
sudo systemctl start serial-getty@ttyS0.service
Step 3: Verify
Test serial console locally before configuring GuruConnect:
sudo screen /dev/ttyS0 115200
# Should see kernel messages, login prompt
What This Provides:
- ✓ GRUB bootloader menu visible via serial console
- ✓ Kernel boot messages stream to serial console
- ✓ Login prompt on
/dev/ttyS0after boot - ✓ Kernel panics output to serial console
- ✓ systemd rescue shell accessible via serial console
Compatibility:
- Physical servers: Uses hardware serial port (COM1 = ttyS0)
- Virtual machines: VMware/Proxmox/KVM expose virtual serial port; configure VM to attach serial port
- Cloud VMs: AWS, GCP, Azure offer "Serial Console" feature (already configured); GuruConnect agent can relay it
Architecture
Agent Mode Selection
Connection request handling:
// agent/src/session/terminal.rs
pub async fn handle_terminal_session(
ws: WebSocketClient,
mode: TerminalMode, // Console or Shell
support_code: String
) -> Result<()> {
match mode {
TerminalMode::Console => run_console_session(ws, support_code).await,
TerminalMode::Shell => run_shell_session(ws, support_code).await,
}
}
pub enum TerminalMode {
Console, // Serial console (/dev/ttyS0)
Shell, // PTY shell session
}
Agent Serial Console Handling (Mode 1)
Serial device open:
// agent/src/platform/linux/console.rs
use std::fs::OpenOptions;
use std::os::unix::io::AsRawFd;
pub struct ConsoleSession {
device_fd: RawFd,
device_path: String, // "/dev/ttyS0" or "/dev/console"
}
impl ConsoleSession {
pub fn open(device_path: &str) -> Result<Self> {
// Open serial console device for read/write
// Requires root privileges
let file = OpenOptions::new()
.read(true)
.write(true)
.open(device_path)
.context("Failed to open serial console - requires root")?;
let device_fd = file.as_raw_fd();
// Configure terminal settings (115200 baud, 8N1)
unsafe {
let mut termios: libc::termios = std::mem::zeroed();
if libc::tcgetattr(device_fd, &mut termios) != 0 {
return Err(anyhow!("tcgetattr failed"));
}
// Set baud rate to 115200
libc::cfsetispeed(&mut termios, libc::B115200);
libc::cfsetospeed(&mut termios, libc::B115200);
// 8N1 (8 data bits, no parity, 1 stop bit)
termios.c_cflag &= !libc::CSIZE;
termios.c_cflag |= libc::CS8;
termios.c_cflag &= !(libc::PARENB | libc::PARODD);
termios.c_cflag &= !libc::CSTOPB;
// Raw mode (no line buffering, no echo)
libc::cfmakeraw(&mut termios);
if libc::tcsetattr(device_fd, libc::TCSANOW, &termios) != 0 {
return Err(anyhow!("tcsetattr failed"));
}
}
Ok(ConsoleSession {
device_fd,
device_path: device_path.to_string(),
})
}
pub fn read(&self, buf: &mut [u8]) -> Result<usize> {
unsafe {
let n = libc::read(self.device_fd, buf.as_mut_ptr() as *mut _, buf.len());
if n < 0 {
Err(anyhow!("Console read failed"))
} else {
Ok(n as usize)
}
}
}
pub fn write(&self, data: &[u8]) -> Result<()> {
unsafe {
let n = libc::write(self.device_fd, data.as_ptr() as *const _, data.len());
if n < 0 {
Err(anyhow!("Console write failed"))
} else {
Ok(())
}
}
}
}
impl Drop for ConsoleSession {
fn drop(&mut self) {
unsafe { libc::close(self.device_fd); }
}
}
Console session loop:
// agent/src/session/console.rs
pub async fn run_console_session(ws: WebSocketClient, support_code: String) -> Result<()> {
// Try /dev/ttyS0 first, fall back to /dev/console
let console = ConsoleSession::open("/dev/ttyS0")
.or_else(|_| ConsoleSession::open("/dev/console"))?;
// Status update: terminal mode, console
ws.send(AgentStatus {
terminal_mode: true,
console_mode: true, // NEW flag
os: "Linux".to_string(),
// ...
}).await?;
let mut buf = vec![0u8; 4096];
loop {
tokio::select! {
// Read console output, send to relay
Ok(n) = tokio::task::spawn_blocking({
let fd = console.device_fd;
move || unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) }
}) => {
if n > 0 {
ws.send(TerminalData {
data: buf[..n as usize].to_vec(),
}).await?;
}
}
// Receive input from relay, write to console
Some(msg) = ws.recv() => {
match msg {
Message::TerminalInput(input) => {
console.write(&input.data)?;
}
Message::Disconnect => break,
// Note: Resize ignored for serial console (not applicable)
_ => {}
}
}
}
}
Ok(())
}
Agent PTY Handling (Mode 2)
Headless detection:
// agent/src/platform/linux/headless.rs
pub fn is_headless() -> bool {
// Check if DISPLAY is unset and no X11/Wayland session detected
std::env::var("DISPLAY").is_err() &&
std::env::var("WAYLAND_DISPLAY").is_err() &&
!std::path::Path::new("/tmp/.X11-unix").exists()
}
PTY spawn:
// agent/src/platform/linux/pty.rs
use libc::{openpty, fork, execvp, dup2, STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO, winsize, TIOCSWINSZ};
use std::os::unix::io::RawFd;
pub struct PtySession {
master_fd: RawFd,
child_pid: libc::pid_t,
cols: u16,
rows: u16,
}
impl PtySession {
pub fn spawn(shell: &str, cols: u16, rows: u16) -> Result<Self> {
let mut master_fd: RawFd = 0;
let mut slave_fd: RawFd = 0;
let mut winsize = winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
unsafe {
if openpty(&mut master_fd, &mut slave_fd, std::ptr::null_mut(),
std::ptr::null(), &mut winsize as *mut _) != 0 {
return Err(anyhow!("openpty failed"));
}
let pid = fork();
if pid == 0 {
// Child process: exec shell
dup2(slave_fd, STDIN_FILENO);
dup2(slave_fd, STDOUT_FILENO);
dup2(slave_fd, STDERR_FILENO);
libc::close(master_fd);
libc::close(slave_fd);
let shell_cstr = CString::new(shell)?;
let args = [shell_cstr.as_ptr(), std::ptr::null()];
execvp(shell_cstr.as_ptr(), args.as_ptr());
std::process::exit(1); // exec failed
} else {
// Parent process: close slave, return master FD
libc::close(slave_fd);
Ok(PtySession {
master_fd,
child_pid: pid,
cols,
rows,
})
}
}
}
pub fn read(&self, buf: &mut [u8]) -> Result<usize> {
unsafe {
let n = libc::read(self.master_fd, buf.as_mut_ptr() as *mut _, buf.len());
if n < 0 {
Err(anyhow!("PTY read failed"))
} else {
Ok(n as usize)
}
}
}
pub fn write(&self, data: &[u8]) -> Result<()> {
unsafe {
let n = libc::write(self.master_fd, data.as_ptr() as *const _, data.len());
if n < 0 {
Err(anyhow!("PTY write failed"))
} else {
Ok(())
}
}
}
pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
self.cols = cols;
self.rows = rows;
let winsize = winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
unsafe {
if libc::ioctl(self.master_fd, TIOCSWINSZ, &winsize as *const _) != 0 {
return Err(anyhow!("TIOCSWINSZ failed"));
}
}
// Send SIGWINCH to child process group
unsafe { libc::kill(-self.child_pid, libc::SIGWINCH); }
Ok(())
}
}
impl Drop for PtySession {
fn drop(&mut self) {
unsafe {
libc::close(self.master_fd);
// Send SIGTERM to child, wait briefly, then SIGKILL if still alive
libc::kill(self.child_pid, libc::SIGTERM);
std::thread::sleep(std::time::Duration::from_millis(500));
libc::waitpid(self.child_pid, std::ptr::null_mut(), libc::WNOHANG);
}
}
}
Agent session loop:
// agent/src/session/terminal.rs
pub async fn run_terminal_session(ws: WebSocketClient, support_code: String) -> Result<()> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
let mut pty = PtySession::spawn(&shell, 80, 24)?;
// Status update: terminal mode
ws.send(AgentStatus {
terminal_mode: true,
os: "Linux".to_string(),
// ...
}).await?;
let mut buf = vec![0u8; 4096];
loop {
tokio::select! {
// Read PTY output, send to relay
Ok(n) = tokio::task::spawn_blocking({
let master = pty.master_fd;
move || unsafe { libc::read(master, buf.as_mut_ptr() as *mut _, buf.len()) }
}) => {
if n > 0 {
ws.send(TerminalData {
data: buf[..n as usize].to_vec(),
}).await?;
}
}
// Receive input from relay, write to PTY
Some(msg) = ws.recv() => {
match msg {
Message::TerminalInput(input) => {
pty.write(&input.data)?;
}
Message::TerminalResize(resize) => {
pty.resize(resize.cols, resize.rows)?;
}
Message::Disconnect => break,
_ => {}
}
}
}
}
Ok(())
}
Protobuf Protocol Extensions
// proto/guruconnect.proto
message AgentStatus {
// Existing fields...
optional bool terminal_mode = 21; // true for headless agents
optional bool console_mode = 22; // true for serial console mode, false for PTY shell mode
}
message TerminalData {
bytes data = 1; // Raw terminal output (PTY or serial console, includes ANSI escape sequences)
}
message TerminalInput {
bytes data = 1; // Keyboard input from viewer (UTF-8 encoded)
}
message TerminalResize {
uint32 cols = 1; // Terminal width (characters)
uint32 rows = 2; // Terminal height (lines)
// Note: Resize only applies to PTY shell mode; serial console ignores this
}
enum TerminalModeRequest {
SHELL = 0; // Request PTY shell session
CONSOLE = 1; // Request serial console session
}
message SessionRequest {
// Existing fields...
optional TerminalModeRequest terminal_mode_request = 10; // NEW: viewer specifies console vs. shell
}
// Update AgentMessage and ViewerMessage unions
message AgentMessage {
oneof message {
AgentStatus status = 1;
FrameData frame = 2;
TerminalData terminal_data = 10; // NEW
}
}
message ViewerMessage {
oneof message {
InputEvent input = 1;
TerminalInput terminal_input = 10; // NEW
TerminalResize terminal_resize = 11; // NEW
}
}
Relay Server Changes
Route terminal vs. screen capture sessions:
// server/src/relay/mod.rs
async fn handle_agent_message(msg: AgentMessage, session: &Session) {
match msg.message {
Some(agent_message::Message::Status(status)) => {
session.terminal_mode = status.terminal_mode.unwrap_or(false);
// Store in DB: UPDATE sessions SET terminal_mode = ? WHERE id = ?
}
Some(agent_message::Message::TerminalData(data)) => {
// Forward to viewer WebSocket
if let Some(viewer_ws) = session.viewer_ws.lock().await.as_mut() {
viewer_ws.send(ViewerMessage {
message: Some(viewer_message::Message::TerminalData(data))
}).await?;
}
// Optional: append to terminal_recording buffer for audit
}
Some(agent_message::Message::Frame(frame)) => {
// Existing screen capture logic...
}
_ => {}
}
}
async fn handle_viewer_message(msg: ViewerMessage, session: &Session) {
match msg.message {
Some(viewer_message::Message::TerminalInput(input)) => {
// Forward to agent WebSocket
if let Some(agent_ws) = session.agent_ws.lock().await.as_mut() {
agent_ws.send(AgentMessage {
message: Some(agent_message::Message::TerminalInput(input))
}).await?;
}
}
Some(viewer_message::Message::TerminalResize(resize)) => {
// Forward resize to agent
if let Some(agent_ws) = session.agent_ws.lock().await.as_mut() {
agent_ws.send(AgentMessage {
message: Some(agent_message::Message::TerminalResize(resize))
}).await?;
}
}
Some(viewer_message::Message::Input(input)) => {
// Existing GUI input logic...
}
_ => {}
}
}
Web Viewer (xterm.js)
HTML template:
<!-- server/static/viewer-terminal.html -->
<!DOCTYPE html>
<html>
<head>
<title>GuruConnect Terminal</title>
<link rel="stylesheet" href="/vendor/xterm/xterm.css" />
<script src="/vendor/xterm/xterm.js"></script>
<script src="/vendor/xterm/xterm-addon-fit.js"></script>
<style>
#terminal { height: 100vh; }
.toolbar { background: #333; color: #fff; padding: 8px; }
</style>
</head>
<body>
<div class="toolbar">
<span id="status">Connecting...</span>
<span id="size" style="float: right;">80x24</span>
</div>
<div id="terminal"></div>
<script>
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Consolas, "Courier New", monospace',
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
}
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();
const ws = new WebSocket(`wss://${location.host}/ws/viewer?token=${TOKEN}&session=${SESSION_ID}`);
ws.onopen = () => {
document.getElementById('status').textContent = 'Connected';
// Send initial terminal size
ws.send(encodeTerminalResize(term.cols, term.rows));
};
ws.onmessage = (event) => {
const msg = decodeProtobuf(event.data);
if (msg.terminal_data) {
term.write(new Uint8Array(msg.terminal_data.data));
}
};
term.onData((data) => {
ws.send(encodeTerminalInput(data));
});
term.onResize(({ cols, rows }) => {
document.getElementById('size').textContent = `${cols}x${rows}`;
ws.send(encodeTerminalResize(cols, rows));
});
window.addEventListener('resize', () => fitAddon.fit());
</script>
</body>
</html>
Dashboard Mode Selector
// server/static/dashboard.js
function renderAgentRow(agent) {
const icon = agent.terminal_mode
? '<i class="icon-terminal"></i> Terminal'
: '<i class="icon-screen"></i> Screen';
// For headless agents, show mode selector (Console vs. Shell)
let connectButtons;
if (agent.terminal_mode && agent.online) {
connectButtons = `
<div class="terminal-mode-selector">
<button class="btn-console" onclick="connectToTerminal('${agent.id}', 'console')"
title="Serial console access (GRUB, boot, panics)">
Console
</button>
<button class="btn-shell" onclick="connectToTerminal('${agent.id}', 'shell')"
title="Interactive shell (bash/zsh)">
Shell
</button>
</div>
`;
} else if (!agent.terminal_mode && agent.online) {
// GUI agent
connectButtons = `<button onclick="connectToAgent('${agent.id}')">Connect</button>`;
} else {
connectButtons = '<span>Offline</span>';
}
return `<tr>
<td>${agent.name}</td>
<td>${icon}</td>
<td>${agent.os} ${agent.os_version}</td>
<td>${connectButtons}</td>
</tr>`;
}
function connectToAgent(agentId) {
// GUI agent connection
window.open(`/viewer.html?session=${agentId}&token=${JWT}`, '_blank');
}
function connectToTerminal(agentId, mode) {
// Terminal agent connection with mode parameter
window.open(`/viewer-terminal.html?session=${agentId}&token=${JWT}&mode=${mode}`, '_blank');
}
Dashboard UI for headless agents:
- Shows two buttons: "Console" and "Shell"
- "Console" button: opens serial console session (GRUB, boot messages, panics)
- "Shell" button: opens PTY shell session (normal server management)
- Tooltip on hover explains each mode
- Mode parameter passed to viewer via URL query string
Database Schema
Minor addition to sessions table:
-- migrations/012_terminal_mode.sql
ALTER TABLE connect_sessions ADD COLUMN terminal_mode BOOLEAN DEFAULT FALSE;
-- Optional: separate table for terminal recordings
CREATE TABLE IF NOT EXISTS terminal_recordings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID REFERENCES connect_sessions(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ DEFAULT NOW(),
ended_at TIMESTAMPTZ,
recording_data BYTEA, -- asciicast JSON or raw PTY dump (compressed)
size_bytes BIGINT,
INDEX idx_terminal_recordings_session (session_id)
);
Implementation Details
Files to Create
Agent (Linux-specific):
agent/src/platform/linux/console.rs— NEW: Serial console device I/O (/dev/ttyS0, termios config)agent/src/session/console.rs— NEW: Console session loop (serial device ↔ WebSocket)agent/src/platform/linux/pty.rs— PTY spawn, I/O, resize (openpty, fork, exec)agent/src/platform/linux/headless.rs— Headless detection logicagent/src/session/terminal.rs— Mode dispatcher (console vs. shell), shell session loop
Server:
server/src/relay/terminal.rs— Terminal message routing (TerminalData/Input/Resize)server/static/viewer-terminal.html— xterm.js-based web terminal viewerserver/static/vendor/xterm/— xterm.js library files (CDN or bundled)server/migrations/012_terminal_mode.sql— Schema update
Protobuf:
proto/guruconnect.proto— Add TerminalData, TerminalInput, TerminalResize messages
Dashboard:
server/static/dashboard.js— Detectterminal_mode, render terminal icon, route to terminal viewer
Key Dependencies
# agent/Cargo.toml (Linux-specific)
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2" # openpty, fork, exec, ioctl
nix = "0.27" # Safe wrappers for POSIX APIs
xterm.js (web viewer):
- Version: 5.3.0+ (latest stable)
- Addons:
xterm-addon-fit(auto-resize) - Delivery: CDN link or bundled in
server/static/vendor/xterm/
Security Considerations
Serial Console Access (Mode 1)
- Requires root privileges: Opening
/dev/ttyS0or/dev/consolerequires root access - Implication: Agent must run as root for console mode, OR use capabilities (
CAP_SYS_TTY_CONFIG) - Boot-level control: Serial console grants full boot-time control (GRUB menu, kernel parameters, single-user mode)
- Risk: Attacker with console access can modify bootloader, disable security features, boot into recovery
- Mitigation 1: Restrict console mode to authorized users only (dashboard RBAC: "console_access" permission)
- Mitigation 2: Require MFA for console mode sessions (stronger auth than shell mode)
- Mitigation 3: Audit logging: record ALL console I/O with immutable timestamps
- Mitigation 4: Alert on console mode connections (notify admin when console session starts)
Recommended deployment:
- Run agent as unprivileged user for shell mode (default)
- For console mode: either run agent as root OR grant
CAP_SYS_TTY_CONFIGcapability via systemd unit
Shell Access Risk (Mode 2)
- Privilege escalation: PTY spawns shell as the agent's user (typically unprivileged
guruconnectservice user) - Mitigation 1: Run agent as unprivileged user, use
sudofor privileged commands - Mitigation 2: Add
allowed_commandswhitelist (optional Phase 2 feature) — restrict to specific binaries - Mitigation 3: Audit logging: record all terminal I/O for compliance review
Authentication
Same as GUI agents:
- Support-code for ad-hoc sessions (6-digit, time-limited)
- Persistent agent key for managed servers (per-agent
cak_*key from SPEC-004) - Viewer JWT token required for WebSocket connection
Session Recording (Compliance)
- Optional toggle: dashboard setting "Record terminal sessions" (default: ON for compliance)
- Storage:
terminal_recordingstable (BYTEA column, compressed) - Playback: Admin dashboard can replay terminal sessions as asciicast (xterm.js built-in playback)
- Retention: configurable (default: 90 days, auto-purge older recordings)
Input Sanitization
- No sanitization needed: PTY handles raw bytes; ANSI escape sequences are terminal-native
- DoS risk: Malicious viewer could spam resize events; rate-limit
TerminalResize(max 10/sec)
Testing Strategy
Unit Tests
- PTY spawn/cleanup: verify
openpty()success, shell exec, FD management - Terminal I/O: mock PTY master FD, test read/write buffers
- Protobuf serialization: TerminalData/Input/Resize round-trip
Integration Tests
- Headless VM: Ubuntu Server 22.04 minimal (no desktop packages)
- Agent install:
guruconnectbinary, systemd service, no X11 deps - Connect flow: Dashboard → "Connect" → xterm.js viewer → type
ls, verify output - Resize: Browser window resize → PTY receives SIGWINCH →
htopredraws correctly - Session cleanup: Close viewer → PTY process exits gracefully
Manual Testing Scenarios
-
Basic shell interaction:
- Connect to headless agent via dashboard
- Type
ls -la, verify colorized output - Run
vim test.txt, verify cursor movement, editing, save/quit - Run
htop, verify full-screen TUI app renders correctly
-
Terminal resize:
- Start session at default 80x24
- Resize browser window to 120x40
- Run
tput cols; tput lines→ verify output matches - Run
htop→ verify UI scales to new dimensions
-
Multi-line output:
- Run
dmesg | head -100→ verify scrollback works - Run
journalctl -f→ verify live log streaming
- Run
-
Session recording playback:
- Perform session actions (ls, vim, htop)
- End session
- Admin dashboard → "View Recording" → verify asciicast playback
-
Privilege escalation (sudo):
- Agent runs as
guruconnectuser (non-root) - Connect via terminal
- Run
sudo apt update→ enter sudo password → verify command executes - Run
whoami→ verify showsrootafter sudo
- Agent runs as
Performance
- Latency target: <100ms round-trip for input (same as GUI mode)
- Bandwidth: ~1-5 KB/sec for typical terminal I/O (much lower than screen capture)
- Stress test: Run
yescommand (infinite output) → verify relay doesn't OOM, rate-limit applied
Effort Estimate & Dependencies
Size: Medium (5-7 weeks, 1 developer)
Breakdown:
- Serial console implementation (Linux agent): 1.5 weeks
- PTY implementation (Linux agent): 1.5 weeks
- Mode selection + dispatcher: 0.5 weeks
- Protobuf protocol updates (mode enum, console_mode flag): 0.5 weeks
- Relay server terminal routing: 1 week
- xterm.js web viewer integration: 1 week
- Dashboard mode selector UI + routing: 0.5 weeks
- Session recording + playback (both modes): 1 week
- Testing (console + shell modes), edge cases, systemd integration: 1.5 weeks
- Documentation (setup guide for serial console): 0.5 weeks
Dependencies:
- SPEC-010 Linux agent base — PTY mode extends the Linux agent; can be implemented in parallel with SPEC-010's GUI capture
- xterm.js library — mature, well-tested (used by VS Code, Jupyter, many commercial products)
- libc/nix crates — standard Rust POSIX bindings
- SPEC-004 per-agent keys — already shipped for persistent agent auth
Unblocks:
- Boot-level access (GRUB menu, kernel parameters, single-user mode) via serial console mode
- Emergency recovery (kernel panics, filesystem repair, systemd rescue shell) via serial console
- Server management (Linux VMs, containers, bare metal) via shell mode
- SSH replacement with centralized audit logging and GuruConnect auth
- Container debugging (exec into running containers via GuruConnect)
- KVM-over-IP alternative (serial console provides text-mode equivalent to IPMI Serial-over-LAN)
Open Questions
-
Serial console permissions - root vs. capabilities? — Opening
/dev/ttyS0requires root. Options: (a) run agent as root for console mode, (b) use Linux capabilities (CAP_SYS_TTY_CONFIG), (c) add agent user todialoutgroup (may not work for/dev/console). Recommend (b) via systemd unit:AmbientCapabilities=CAP_SYS_TTY_CONFIG. -
Default mode if serial console unavailable? — If
/dev/ttyS0doesn't exist or permission denied, fall back to shell mode automatically or show error? Recommend auto-fallback with warning message in viewer. -
Serial console baud rate? — v1 hardcodes 115200 (industry standard). Phase 2: make configurable if slower links needed (9600, 38400).
-
Shell selection (PTY mode)? — v1:
$SHELLenv var →/bin/bash→/bin/sh. Phase 2: dashboard setting to override shell per agent (/bin/zsh,/bin/fish). -
Concurrent sessions? — v1: one console/shell session per agent connection (like SSH). Phase 2: tmux/screen integration for multi-viewer session sharing.
-
Terminal recording format? — Asciicast (JSON, industry standard, xterm.js playback support) vs. raw dump (more compact, custom playback). Recommend asciicast for v1.
-
Command whitelisting (shell mode)? — Optional Phase 2 feature. v1 is unrestricted shell access (same as SSH). Add
allowed_commandsarray to agent config if compliance requires it. -
RBAC for console vs. shell access? — Should some users only have shell access (not console, which grants boot-level control)? Recommend yes: add
console_accesspermission, separate fromshell_access. -
MFA for console mode? — Given boot-level control risk, require MFA for console mode sessions? Defer to Phase 2 (MFA is a broader GuruConnect feature).
-
Windows/macOS terminal mode? — Defer. Windows Server typically uses RDP or SSH (OpenSSH built-in since Server 2019). macOS servers are rare. Linux headless servers are the primary use case.
-
File upload/download via terminal? — v1: use standard tools (
scp,rsync,wget). Phase 2: integrate with SPEC (file transfer) for dashboard-native upload/download.
Cross-references:
- SPEC-010: Cross-platform agents (macOS/Linux GUI) — headless mode extends Linux agent with PTY alternative
- SPEC-004: Stable machine identity — headless agents use same deterministic
machine_uid(/etc/machine-id) - ADR-001: GuruConnect is standalone — headless mode doesn't require GuruRMM integration
- Future: File transfer spec (roadmap item) — will integrate with terminal mode for
scp-like functionality