spec: add SPEC-013 Windows Session Selection and Backstage Mode
All checks were successful
All checks were successful
This commit is contained in:
@@ -62,6 +62,7 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
|
||||
- [x] Protobuf-over-WSS transport, Zstd frame compression
|
||||
- [~] React/TS web viewer (`dashboard/src/components/RemoteViewer.tsx`) — embeddable session viewer
|
||||
- [ ] **Headless Linux mode (direct TTY access)** — P2 — Terminal-based remote access for Linux servers without GUI. PTY spawn (`openpty`), xterm.js web viewer, full ANSI/VT100 support. Enables server management, container debugging, emergency recovery via GuruConnect dashboard with audit logging. SSH replacement with centralized auth. ([SPEC-012](specs/SPEC-012-headless-linux-tty.md))
|
||||
- [ ] **Windows session selection and backstage mode** — P2 — Enumerate and switch between Windows user sessions (Terminal Services/RDP/Fast User Switching) and access Session 0 (backstage) for system-level admin tasks. ScreenConnect parity: session selector shows all logged-on users, instant switching without reconnect. Backstage mode provides terminal/command interface for services management without disrupting any user desktop. Critical for multi-user server environments. ([SPEC-013](specs/SPEC-013-session-selection-and-backstage.md))
|
||||
- [ ] Multi-monitor switching — P2
|
||||
- [ ] File transfer — P3 (out of scope for native-remote-control v1)
|
||||
- [ ] Session recording — P3 (out of scope for native-remote-control v1)
|
||||
|
||||
717
docs/specs/SPEC-013-session-selection-and-backstage.md
Normal file
717
docs/specs/SPEC-013-session-selection-and-backstage.md
Normal file
@@ -0,0 +1,717 @@
|
||||
# SPEC-013: Windows Session Selection and Backstage Mode
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike Swanson (2026-05-30)
|
||||
**Estimated Effort:** Large
|
||||
|
||||
## Overview
|
||||
|
||||
Enable GuruConnect to enumerate and switch between multiple Windows user sessions (Terminal Services/RDP/Fast User Switching) and access Session 0 (backstage mode) for system-level administrative tasks. This addresses a critical gap for MSPs managing multi-user Windows servers and workstations — ScreenConnect's session selector allows technicians to see all logged-on users and switch between them instantly, and the backstage mode provides a command/task-manager interface for Session 0 (services) without disrupting any user's desktop. Success criteria: technicians can view and select from all active sessions in the dashboard/viewer, switch sessions mid-stream without reconnecting, and enter backstage mode to run commands and view services when no user desktop is needed.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
**Session Enumeration and Switching:**
|
||||
- Agent enumerates all Windows sessions via `WTSEnumerateSessions` API at startup and on-demand
|
||||
- Reports session list to server: session ID, user name, session state (active/disconnected), session type (console/RDP)
|
||||
- Viewer displays session selector UI (dropdown or list) showing all available sessions
|
||||
- User selects a session → server sends `SwitchSession` message → agent switches capture/input to that session
|
||||
- Session switching without WebSocket disconnect (same session token, just changes the desktop being captured)
|
||||
- Dashboard shows session count and logged-on users per machine
|
||||
|
||||
**Backstage Mode (Session 0 Access):**
|
||||
- Agent can enter "backstage" mode targeting Session 0 (services session)
|
||||
- Backstage mode provides a terminal/command interface (not DXGI screen capture, since Session 0 has no interactive desktop in modern Windows)
|
||||
- Dashboard/viewer shows backstage UI with:
|
||||
- Command prompt / PowerShell terminal (PTY-like, text-based)
|
||||
- Running services list (via `EnumServicesStatusEx`)
|
||||
- Running processes list (via `EnumProcesses` / `CreateToolhelp32Snapshot`)
|
||||
- System info panel (OS version, uptime, logged-on sessions)
|
||||
- Backstage mode uses `SessionType::BACKSTAGE` (already in protobuf line 42)
|
||||
- Agent runs commands in Session 0 context via `CreateProcessAsUser` with Session 0 token
|
||||
|
||||
**Protobuf Extensions:**
|
||||
- New messages: `SessionInfo`, `SessionList`, `SwitchSession`, `BackstageCommand`, `BackstageOutput`
|
||||
- Extend `AgentStatus` to include session list
|
||||
- Extend `StartStream` to specify target session ID
|
||||
|
||||
**Security:**
|
||||
- Session switching requires elevated (admin) agent process
|
||||
- Backstage mode is admin-only (gated server-side by user role)
|
||||
- Audit log records all session switches and backstage commands
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- **Session shadowing** (viewing a session while another user is active in it) — defer to Phase 2; complex Terminal Services permission model
|
||||
- **Session connect/disconnect control** (logging users off, disconnecting RDP sessions) — defer; high-impact action
|
||||
- **Cross-session clipboard** — clipboard currently session-scoped; multi-session clipboard is a future enhancement
|
||||
- **Linux/macOS session switching** — this spec is Windows-only; cross-platform session concepts differ significantly
|
||||
- **Backstage GUI tools** (registry editor, event log viewer) — v1 is terminal/command-only; GUI tools are future enhancements
|
||||
- **Session 0 screen capture** — Session 0 has no interactive desktop on modern Windows (Session 0 isolation, Vista+); backstage is terminal/data-only
|
||||
|
||||
## Architecture
|
||||
|
||||
### Agent Changes
|
||||
|
||||
**Session Management Module** (`agent/src/session/mod.rs` or new `agent/src/sessions/`)
|
||||
|
||||
- **Enumerate sessions:**
|
||||
```rust
|
||||
use windows::Win32::System::RemoteDesktop::{
|
||||
WTSEnumerateSessionsW, WTSFreeMemory, WTSQuerySessionInformationW,
|
||||
WTS_SESSION_INFOW, WTSUserName, WTSSessionInfo, WTS_CURRENT_SERVER_HANDLE
|
||||
};
|
||||
|
||||
pub struct SessionInfo {
|
||||
pub session_id: u32,
|
||||
pub user_name: String,
|
||||
pub state: SessionState, // Active, Disconnected, Idle
|
||||
pub session_type: SessionType, // Console, RDP
|
||||
}
|
||||
|
||||
pub fn enumerate_sessions() -> Result<Vec<SessionInfo>> {
|
||||
// Call WTSEnumerateSessionsW
|
||||
// For each session, call WTSQuerySessionInformationW(WTSUserName, WTSSessionInfo)
|
||||
// Filter out Session 0 (services) from user session list
|
||||
}
|
||||
```
|
||||
|
||||
- **Switch session desktop:**
|
||||
```rust
|
||||
use windows::Win32::System::RemoteDesktop::WTSGetActiveConsoleSessionId;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{OpenDesktop, CloseDesktop, SwitchDesktop};
|
||||
|
||||
pub struct SessionContext {
|
||||
pub session_id: u32,
|
||||
pub desktop_handle: HDESK, // Handle to the session's desktop
|
||||
}
|
||||
|
||||
pub fn switch_to_session(session_id: u32) -> Result<SessionContext> {
|
||||
// OpenDesktop for the target session (desktop name: "WinSta0\\Default" or session-specific)
|
||||
// SwitchDesktop to make it the active desktop for capture
|
||||
// Reinitialize DXGI capturer on the new desktop
|
||||
}
|
||||
```
|
||||
|
||||
- **Backstage mode (Session 0 command execution):**
|
||||
```rust
|
||||
use windows::Win32::System::Threading::{CreateProcessAsUserW, PROCESS_INFORMATION};
|
||||
use windows::Win32::Security::{LogonUserW, DuplicateTokenEx};
|
||||
|
||||
pub struct BackstageSession {
|
||||
pub session_id: u32, // Always 0 for backstage
|
||||
pub process_handle: HANDLE,
|
||||
pub stdout_pipe: HANDLE,
|
||||
}
|
||||
|
||||
pub fn execute_backstage_command(command: &str) -> Result<BackstageOutput> {
|
||||
// CreateProcessAsUserW with Session 0 token (obtained via WTSQueryUserToken for Session 0)
|
||||
// Capture stdout/stderr via anonymous pipes
|
||||
// Return output as text (no screen capture)
|
||||
}
|
||||
|
||||
pub fn list_services() -> Result<Vec<ServiceInfo>> {
|
||||
// EnumServicesStatusEx(SC_MANAGER_ENUMERATE_SERVICE)
|
||||
}
|
||||
|
||||
pub fn list_processes() -> Result<Vec<ProcessInfo>> {
|
||||
// CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS)
|
||||
}
|
||||
```
|
||||
|
||||
**Capture Module Changes** (`agent/src/capture/mod.rs`, `agent/src/capture/dxgi.rs`)
|
||||
|
||||
- Refactor `DxgiCapturer::new()` to accept a `session_id` parameter
|
||||
- `OpenDesktop` for the target session before initializing DXGI duplication
|
||||
- Store current session ID in capturer state; reinitialize on session switch
|
||||
- Backstage mode does NOT use DXGI — it's terminal/data-only
|
||||
|
||||
**Input Injection Changes** (`agent/src/input/mod.rs`)
|
||||
|
||||
- Ensure `SendInput` targets the correct session's desktop (already handles this via desktop handle)
|
||||
- Backstage mode does NOT inject input (command execution only)
|
||||
|
||||
**New Files:**
|
||||
- `agent/src/sessions/mod.rs` — session enumeration, switching, backstage execution
|
||||
- `agent/src/sessions/wts.rs` — WTS API wrappers (enumerate, query, get token)
|
||||
- `agent/src/sessions/backstage.rs` — Session 0 command execution, service/process enumeration
|
||||
|
||||
### Relay Server Changes
|
||||
|
||||
**Session State Management** (`server/src/relay/mod.rs` or `server/src/session/`)
|
||||
|
||||
- Extend `SessionState` struct to track current session ID:
|
||||
```rust
|
||||
pub struct SessionState {
|
||||
pub session_id: String,
|
||||
pub session_type: SessionType, // SCREEN_CONTROL, VIEW_ONLY, BACKSTAGE
|
||||
pub target_session_id: Option<u32>, // Windows session ID (1, 2, 3, etc.) or 0 for backstage
|
||||
// ... existing fields
|
||||
}
|
||||
```
|
||||
|
||||
- Handle `SessionList` message from agent (periodic update of available sessions)
|
||||
- Store session list in `SessionState` for dashboard queries
|
||||
- Handle `SwitchSession` message from viewer → forward to agent
|
||||
- Handle `BackstageCommand` message from viewer → forward to agent
|
||||
- Handle `BackstageOutput` message from agent → forward to viewer
|
||||
|
||||
**API Endpoints** (`server/src/api/sessions.rs`)
|
||||
|
||||
- `GET /api/sessions/:session_id/windows-sessions` — return list of Windows sessions for this agent
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{"id": 1, "user": "jdoe", "state": "Active", "type": "Console"},
|
||||
{"id": 2, "user": "admin", "state": "Disconnected", "type": "RDP"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Backstage commands are sent via WebSocket, not HTTP (same as mouse/keyboard input)
|
||||
|
||||
**Database Schema** (`server/migrations/`)
|
||||
|
||||
- Extend `connect_sessions` table:
|
||||
```sql
|
||||
ALTER TABLE connect_sessions ADD COLUMN windows_session_id INT NULL;
|
||||
ALTER TABLE connect_sessions ADD COLUMN session_type VARCHAR(20) DEFAULT 'screen_control';
|
||||
```
|
||||
|
||||
- Extend `events` table to log session switches:
|
||||
```sql
|
||||
INSERT INTO events (session_id, event_type, details) VALUES
|
||||
('...', 'session_switched', '{"from_session": 1, "to_session": 2, "user": "admin"}');
|
||||
```
|
||||
|
||||
### Protobuf Changes (`proto/guruconnect.proto`)
|
||||
|
||||
```protobuf
|
||||
// Session information (Windows Terminal Services session)
|
||||
message SessionInfo {
|
||||
uint32 session_id = 1; // Windows session ID (1, 2, 3, etc.)
|
||||
string user_name = 2; // Logged-on user name
|
||||
SessionState state = 3; // Active, Disconnected, Idle
|
||||
WindowsSessionType type = 4; // Console, RDP
|
||||
}
|
||||
|
||||
enum SessionState {
|
||||
SESSION_ACTIVE = 0;
|
||||
SESSION_DISCONNECTED = 1;
|
||||
SESSION_IDLE = 2;
|
||||
}
|
||||
|
||||
enum WindowsSessionType {
|
||||
SESSION_CONSOLE = 0; // Local console session
|
||||
SESSION_RDP = 1; // Remote Desktop session
|
||||
}
|
||||
|
||||
// List of available Windows sessions (agent -> server)
|
||||
message SessionList {
|
||||
repeated SessionInfo sessions = 1;
|
||||
uint32 current_session_id = 2; // Which session the agent is currently capturing
|
||||
}
|
||||
|
||||
// Switch to a different Windows session (viewer -> agent)
|
||||
message SwitchSession {
|
||||
uint32 target_session_id = 1; // Session ID to switch to (0 for backstage)
|
||||
}
|
||||
|
||||
// Backstage command execution (viewer -> agent)
|
||||
message BackstageCommand {
|
||||
BackstageCommandType command_type = 1;
|
||||
string command_text = 2; // For EXEC_COMMAND: PowerShell or CMD command
|
||||
}
|
||||
|
||||
enum BackstageCommandType {
|
||||
BACKSTAGE_EXEC_COMMAND = 0; // Execute a command in Session 0
|
||||
BACKSTAGE_LIST_SERVICES = 1; // Enumerate running services
|
||||
BACKSTAGE_LIST_PROCESSES = 2; // Enumerate running processes
|
||||
BACKSTAGE_SYSTEM_INFO = 3; // Get system information
|
||||
}
|
||||
|
||||
// Backstage output (agent -> viewer)
|
||||
message BackstageOutput {
|
||||
BackstageCommandType command_type = 1;
|
||||
oneof output {
|
||||
CommandOutput command_output = 10;
|
||||
ServiceList service_list = 11;
|
||||
ProcessList process_list = 12;
|
||||
SystemInfo system_info = 13;
|
||||
}
|
||||
}
|
||||
|
||||
message CommandOutput {
|
||||
string stdout = 1;
|
||||
string stderr = 2;
|
||||
int32 exit_code = 3;
|
||||
}
|
||||
|
||||
message ServiceList {
|
||||
repeated ServiceInfo services = 1;
|
||||
}
|
||||
|
||||
message ServiceInfo {
|
||||
string name = 1;
|
||||
string display_name = 2;
|
||||
ServiceStatus status = 3; // Running, Stopped, Paused
|
||||
string startup_type = 4; // Automatic, Manual, Disabled
|
||||
}
|
||||
|
||||
enum ServiceStatus {
|
||||
SERVICE_RUNNING = 0;
|
||||
SERVICE_STOPPED = 1;
|
||||
SERVICE_PAUSED = 2;
|
||||
}
|
||||
|
||||
message ProcessList {
|
||||
repeated ProcessInfo processes = 1;
|
||||
}
|
||||
|
||||
message ProcessInfo {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
string user = 3;
|
||||
uint64 memory_kb = 4;
|
||||
}
|
||||
|
||||
message SystemInfo {
|
||||
string os_version = 1;
|
||||
string hostname = 2;
|
||||
uint64 uptime_secs = 3;
|
||||
repeated SessionInfo logged_on_sessions = 4;
|
||||
}
|
||||
|
||||
// Extend Message wrapper to include new message types
|
||||
message Message {
|
||||
oneof payload {
|
||||
// ... existing messages ...
|
||||
|
||||
// Session switching (field numbers 90-99)
|
||||
SessionList session_list = 90;
|
||||
SwitchSession switch_session = 91;
|
||||
BackstageCommand backstage_command = 92;
|
||||
BackstageOutput backstage_output = 93;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard/Viewer Changes
|
||||
|
||||
**Dashboard** (`server/static/dashboard.html` or React component)
|
||||
|
||||
- Machine detail view shows "Sessions (3)" next to online status
|
||||
- Click sessions count → modal showing session list with Switch button
|
||||
- "Enter Backstage" button for admin users (gated by `user.role === 'admin'`)
|
||||
|
||||
**Native Viewer** (`agent/src/viewer/mod.rs`)
|
||||
|
||||
- Session selector dropdown in viewer toolbar
|
||||
- Populate dropdown from `SessionList` message
|
||||
- On selection change → send `SwitchSession` message
|
||||
- "Backstage Mode" button (toggle) — switches to terminal UI instead of video frames
|
||||
|
||||
**Web Viewer** (`dashboard/src/components/RemoteViewer.tsx`)
|
||||
|
||||
- Same session selector dropdown as native viewer
|
||||
- Backstage mode shows a terminal-like UI (xterm.js or custom) displaying command output
|
||||
- Text input for commands, buttons for List Services / List Processes / System Info
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Files to Create
|
||||
|
||||
**Agent:**
|
||||
- `agent/src/sessions/mod.rs` (300 lines) — session enumeration, switching, state
|
||||
- `agent/src/sessions/wts.rs` (200 lines) — WTS API wrappers
|
||||
- `agent/src/sessions/backstage.rs` (400 lines) — Session 0 command execution, service/process enum
|
||||
|
||||
**Server:**
|
||||
- `server/src/relay/sessions.rs` (150 lines) — session list state, switch logic
|
||||
- `server/migrations/00N_session_selection.sql` (50 lines) — schema changes
|
||||
|
||||
**Dashboard:**
|
||||
- `server/static/js/session-selector.js` (200 lines) — session dropdown UI
|
||||
- `server/static/css/backstage.css` (100 lines) — backstage terminal styling
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**Agent:**
|
||||
- `agent/src/capture/dxgi.rs:42` — Add session ID parameter to `DxgiCapturer::new()`
|
||||
- `agent/src/capture/mod.rs:76` — Call `OpenDesktop` for target session before DXGI init
|
||||
- `agent/src/session/mod.rs:120` — Extend session loop to handle `SwitchSession` and `BackstageCommand`
|
||||
- `agent/src/main.rs:15` — Add `mod sessions;`
|
||||
|
||||
**Server:**
|
||||
- `server/src/relay/mod.rs:85` — Handle `SessionList`, `SwitchSession`, `BackstageCommand`, `BackstageOutput` messages
|
||||
- `server/src/api/sessions.rs:40` — Add endpoint for session list query
|
||||
- `server/src/db/events.rs:25` — Log session switch events
|
||||
|
||||
**Proto:**
|
||||
- `proto/guruconnect.proto:454` — Add new message types (as shown above)
|
||||
|
||||
### Key Logic
|
||||
|
||||
**Agent Session Enumeration:**
|
||||
|
||||
```rust
|
||||
// agent/src/sessions/wts.rs
|
||||
use windows::Win32::System::RemoteDesktop::{
|
||||
WTSEnumerateSessionsW, WTSQuerySessionInformationW, WTSFreeMemory,
|
||||
WTS_SESSION_INFOW, WTSUserName, WTSConnectState, WTS_CURRENT_SERVER_HANDLE
|
||||
};
|
||||
|
||||
pub fn enumerate_sessions() -> Result<Vec<SessionInfo>> {
|
||||
let mut sessions = Vec::new();
|
||||
let mut session_info_ptr: *mut WTS_SESSION_INFOW = std::ptr::null_mut();
|
||||
let mut count: u32 = 0;
|
||||
|
||||
unsafe {
|
||||
if WTSEnumerateSessionsW(
|
||||
WTS_CURRENT_SERVER_HANDLE,
|
||||
0, // Reserved
|
||||
1, // Version
|
||||
&mut session_info_ptr,
|
||||
&mut count
|
||||
).as_bool() {
|
||||
let session_array = std::slice::from_raw_parts(session_info_ptr, count as usize);
|
||||
|
||||
for wts_session in session_array {
|
||||
// Skip Session 0 (services) for user session list
|
||||
if wts_session.SessionId == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Query user name
|
||||
let mut user_name_ptr: *mut u16 = std::ptr::null_mut();
|
||||
let mut bytes_returned: u32 = 0;
|
||||
|
||||
if WTSQuerySessionInformationW(
|
||||
WTS_CURRENT_SERVER_HANDLE,
|
||||
wts_session.SessionId,
|
||||
WTSUserName,
|
||||
&mut user_name_ptr as *mut _ as *mut _,
|
||||
&mut bytes_returned
|
||||
).as_bool() {
|
||||
let user_name = String::from_utf16_lossy(
|
||||
std::slice::from_raw_parts(user_name_ptr, (bytes_returned / 2) as usize)
|
||||
);
|
||||
|
||||
sessions.push(SessionInfo {
|
||||
session_id: wts_session.SessionId,
|
||||
user_name,
|
||||
state: map_wts_state(wts_session.State),
|
||||
session_type: if wts_session.pWinStationName.to_string()?.contains("RDP") {
|
||||
WindowsSessionType::RDP
|
||||
} else {
|
||||
WindowsSessionType::Console
|
||||
}
|
||||
});
|
||||
|
||||
WTSFreeMemory(user_name_ptr as *mut _);
|
||||
}
|
||||
}
|
||||
|
||||
WTSFreeMemory(session_info_ptr as *mut _);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
```
|
||||
|
||||
**Agent Session Switching:**
|
||||
|
||||
```rust
|
||||
// agent/src/sessions/mod.rs
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
OpenDesktopW, CloseDesktop, SwitchDesktop, HDESK
|
||||
};
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
pub struct SessionManager {
|
||||
current_session_id: u32,
|
||||
desktop_handle: Option<HDESK>,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn switch_session(&mut self, target_session_id: u32) -> Result<()> {
|
||||
tracing::info!("Switching from session {} to {}", self.current_session_id, target_session_id);
|
||||
|
||||
// Close current desktop handle
|
||||
if let Some(handle) = self.desktop_handle.take() {
|
||||
unsafe { CloseDesktop(handle) };
|
||||
}
|
||||
|
||||
// Open the target session's desktop
|
||||
// Desktop name format: "WinSta0\\Default" for console session
|
||||
let desktop_name = format!("WinSta0\\Default");
|
||||
let desktop_name_wide: Vec<u16> = desktop_name.encode_utf16().chain(Some(0)).collect();
|
||||
|
||||
let desktop_handle = unsafe {
|
||||
OpenDesktopW(
|
||||
PCWSTR::from_raw(desktop_name_wide.as_ptr()),
|
||||
0, // Flags
|
||||
false.into(), // Inherit handles
|
||||
0x0001 | 0x0002 | 0x0004 | 0x0008, // DESKTOP_READOBJECTS | DESKTOP_CREATEWINDOW | etc.
|
||||
)?
|
||||
};
|
||||
|
||||
// Switch to the new desktop
|
||||
unsafe {
|
||||
SwitchDesktop(desktop_handle)?;
|
||||
}
|
||||
|
||||
self.current_session_id = target_session_id;
|
||||
self.desktop_handle = Some(desktop_handle);
|
||||
|
||||
// Reinitialize screen capturer for the new desktop
|
||||
// (caller must recreate DxgiCapturer)
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Backstage Command Execution:**
|
||||
|
||||
```rust
|
||||
// agent/src/sessions/backstage.rs
|
||||
use windows::Win32::System::Threading::{CreateProcessW, PROCESS_INFORMATION, STARTUPINFOW};
|
||||
use windows::Win32::System::Pipes::{CreatePipe, ReadFile};
|
||||
|
||||
pub fn execute_command(command: &str) -> Result<CommandOutput> {
|
||||
// Create anonymous pipes for stdout/stderr
|
||||
let mut stdout_read: HANDLE = HANDLE::default();
|
||||
let mut stdout_write: HANDLE = HANDLE::default();
|
||||
|
||||
unsafe {
|
||||
CreatePipe(&mut stdout_read, &mut stdout_write, None, 0)?;
|
||||
}
|
||||
|
||||
// Launch cmd.exe /c <command> with redirected output
|
||||
let command_line = format!("cmd.exe /c {}", command);
|
||||
let mut command_line_wide: Vec<u16> = command_line.encode_utf16().chain(Some(0)).collect();
|
||||
|
||||
let mut startup_info = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
hStdOutput: stdout_write,
|
||||
hStdError: stdout_write,
|
||||
dwFlags: STARTF_USESTDHANDLES,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut process_info = PROCESS_INFORMATION::default();
|
||||
|
||||
unsafe {
|
||||
CreateProcessW(
|
||||
None,
|
||||
PWSTR::from_raw(command_line_wide.as_mut_ptr()),
|
||||
None,
|
||||
None,
|
||||
true.into(), // Inherit handles
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
&startup_info,
|
||||
&mut process_info
|
||||
)?;
|
||||
|
||||
CloseHandle(stdout_write);
|
||||
|
||||
// Read output
|
||||
let mut output = Vec::new();
|
||||
let mut buffer = [0u8; 4096];
|
||||
let mut bytes_read: u32 = 0;
|
||||
|
||||
loop {
|
||||
if ReadFile(stdout_read, Some(&mut buffer), Some(&mut bytes_read), None).is_ok() {
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
output.extend_from_slice(&buffer[..bytes_read as usize]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process to exit
|
||||
WaitForSingleObject(process_info.hProcess, INFINITE);
|
||||
|
||||
let mut exit_code: u32 = 0;
|
||||
GetExitCodeProcess(process_info.hProcess, &mut exit_code)?;
|
||||
|
||||
CloseHandle(process_info.hProcess);
|
||||
CloseHandle(process_info.hThread);
|
||||
CloseHandle(stdout_read);
|
||||
|
||||
Ok(CommandOutput {
|
||||
stdout: String::from_utf8_lossy(&output).to_string(),
|
||||
stderr: String::new(),
|
||||
exit_code: exit_code as i32,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **Session switching requires elevated agent:** Agent must run as SYSTEM or Administrator to call WTS APIs and `OpenDesktop` across sessions
|
||||
- **Backstage mode is admin-only:** Server checks `user.role === 'admin'` before allowing `BackstageCommand` messages
|
||||
- **Audit logging:** All session switches and backstage commands are logged to `events` table with:
|
||||
- Timestamp
|
||||
- Technician user ID
|
||||
- Session ID (from/to)
|
||||
- Command text (for backstage)
|
||||
- Output (sanitized, no secrets)
|
||||
|
||||
### Input Validation
|
||||
|
||||
- **Session ID bounds checking:** Agent validates `target_session_id` exists in `WTSEnumerateSessions` results before switching
|
||||
- **Command injection prevention:** Backstage commands are executed via `cmd.exe /c` with proper escaping; no shell metacharacter expansion
|
||||
- **Output sanitization:** Command output is truncated to 100KB max to prevent memory exhaustion
|
||||
|
||||
### Threat Model
|
||||
|
||||
- **Malicious session switch:** Attacker with viewer access tries to switch to a privileged session (e.g., admin RDP session) → mitigated by server-side session ownership validation (can only switch sessions on machines you have access to)
|
||||
- **Backstage command abuse:** Attacker executes destructive commands (e.g., `rd /s /q C:\`) → mitigated by admin-only role gate + audit logging
|
||||
- **Session 0 privilege escalation:** Attacker uses backstage to run code as SYSTEM → inherent risk, same as any admin tool; audit log is the control
|
||||
|
||||
### Audit Events
|
||||
|
||||
**New event types:**
|
||||
- `session_switched` — Log when technician switches Windows sessions
|
||||
- `backstage_command` — Log all backstage command executions
|
||||
- `backstage_service_query` — Log service list queries
|
||||
- `backstage_process_query` — Log process list queries
|
||||
|
||||
**Event schema:**
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
id UUID PRIMARY KEY,
|
||||
session_id UUID REFERENCES connect_sessions(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
details JSONB NOT NULL, -- {"from_session": 1, "to_session": 2, "command": "ipconfig", "exit_code": 0}
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Agent (Rust):**
|
||||
- `sessions::wts::test_enumerate_sessions()` — mock WTS API, verify session parsing
|
||||
- `sessions::backstage::test_execute_command()` — mock CreateProcess, verify output capture
|
||||
- `sessions::test_switch_session()` — mock OpenDesktop/SwitchDesktop, verify state change
|
||||
|
||||
**Server (Rust):**
|
||||
- `relay::sessions::test_session_list_update()` — verify session list parsing and storage
|
||||
- `relay::sessions::test_switch_session_message()` — verify message forwarding to agent
|
||||
- `api::sessions::test_get_sessions_endpoint()` — verify API response format
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**End-to-end session switching:**
|
||||
1. Start agent on Windows machine with 2 logged-on users (console + RDP)
|
||||
2. Connect viewer to session
|
||||
3. Agent sends `SessionList` with 2 sessions
|
||||
4. Viewer sends `SwitchSession(2)`
|
||||
5. Agent switches to RDP session, restarts DXGI capture
|
||||
6. Viewer receives frames from RDP session desktop
|
||||
7. Verify session switch logged in `events` table
|
||||
|
||||
**Backstage command execution:**
|
||||
1. Admin user connects to agent
|
||||
2. Enter backstage mode
|
||||
3. Execute command: `ipconfig /all`
|
||||
4. Verify output displayed in viewer
|
||||
5. Execute command: `sc query wuauserv` (query Windows Update service)
|
||||
6. Verify service status returned
|
||||
7. Verify commands logged with full details
|
||||
|
||||
### Manual Testing Scenarios
|
||||
|
||||
1. **Multi-user server:**
|
||||
- Windows Server 2019 with 3 RDP users logged in
|
||||
- Enumerate sessions → verify all 3 shown in dropdown
|
||||
- Switch between sessions → verify screen updates correctly
|
||||
- Verify no session 0 in dropdown (backstage mode is separate)
|
||||
|
||||
2. **Fast User Switching workstation:**
|
||||
- Windows 10 workstation with 2 users (one active, one locked)
|
||||
- Enumerate sessions → verify both shown with correct state
|
||||
- Switch to locked session → verify lock screen is captured
|
||||
|
||||
3. **Backstage mode:**
|
||||
- Connect to agent
|
||||
- Enter backstage → verify no video frames, just terminal UI
|
||||
- Run `hostname` → verify output
|
||||
- List services → verify service table displayed
|
||||
- List processes → verify process list
|
||||
- Exit backstage → return to normal screen control
|
||||
|
||||
4. **Permission enforcement:**
|
||||
- Non-admin user tries to enter backstage → verify 403 Forbidden
|
||||
- Non-admin user tries to send `BackstageCommand` → server rejects
|
||||
|
||||
5. **Audit logging:**
|
||||
- Perform session switch + backstage commands
|
||||
- Query `events` table → verify all actions logged with timestamps, user IDs, details
|
||||
|
||||
### CI/CD Additions
|
||||
|
||||
- **Windows test VM:** Gitea Actions Windows runner with 2 test users configured (via Local Users and Groups)
|
||||
- **Automated test:** PowerShell script to create RDP session, run integration test, verify session switch
|
||||
- **Audit log verification:** Integration test queries events table after actions, asserts correct event_type and details
|
||||
|
||||
## Effort Estimate & Dependencies
|
||||
|
||||
**Size:** Large (8-10 weeks, 1 developer)
|
||||
|
||||
**Breakdown:**
|
||||
- Agent session enumeration + WTS API wrappers: 1.5 weeks
|
||||
- Agent session switching (OpenDesktop, SwitchDesktop, DXGI reinit): 2 weeks
|
||||
- Agent backstage mode (command execution, service/process enum): 2 weeks
|
||||
- Server session state management + protobuf: 1 week
|
||||
- Dashboard session selector UI: 1 week
|
||||
- Backstage terminal UI (web viewer): 1.5 weeks
|
||||
- Testing (integration tests, manual scenarios, audit verification): 1.5 weeks
|
||||
- Buffer for Windows session edge cases (disconnected sessions, fast user switching quirks): 1 week
|
||||
|
||||
**Dependencies:**
|
||||
- **SPEC-002 v2 Phase 1 completion** — per-agent keys and secure session core must be stable (already shipped)
|
||||
- **Admin role enforcement** — server must have role-based access control for backstage gating (already exists: `users.role`)
|
||||
- **Event logging infrastructure** — audit events table and logging (already exists: `events` table)
|
||||
|
||||
**Unblocks:**
|
||||
- Multi-user server management (critical for MSPs with Windows Server environments)
|
||||
- Session 0 administrative tasks without GUI disruption (backstage mode)
|
||||
- SPEC-005 machines list view (can show session count and logged-on users per machine)
|
||||
- Future: session recording (can record specific session, not just active console)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Session 0 GUI in older Windows?** — Windows XP/Server 2003 allowed interactive Session 0. GuruConnect targets Windows 7+ where Session 0 is non-interactive. Confirm backstage terminal-only approach is acceptable, or add a "force capture Session 0 desktop" option for legacy systems?
|
||||
|
||||
2. **Session switching during active control?** — If technician is moving the mouse and switches sessions mid-action, should the agent queue the switch until input is idle, or switch immediately? (Recommend: immediate switch, input events are session-scoped.)
|
||||
|
||||
3. **Disconnected session handling?** — If technician switches to a disconnected RDP session, should the agent attempt to reconnect it (via `WTSConnectSession`), or just capture the "disconnected" screen? (Recommend: just capture; reconnection is a high-impact action.)
|
||||
|
||||
4. **Backstage command timeout?** — Long-running commands (e.g., `chkdsk`) could hang. Implement a timeout (e.g., 60 seconds) and kill the process? (Recommend: yes, 60s default, configurable.)
|
||||
|
||||
5. **Clipboard across sessions?** — Current clipboard implementation is session-scoped. Should session switching clear the clipboard state, or try to preserve it? (Recommend: clear on switch; cross-session clipboard is a future enhancement.)
|
||||
|
||||
---
|
||||
|
||||
**Cross-references:**
|
||||
- SPEC-002: v2 modernization (protobuf already has `BACKSTAGE = 2`)
|
||||
- SPEC-005: Machines list view (can show session count + logged-on users)
|
||||
- SPEC-008: Structured errors (session switch failures need clear error codes)
|
||||
- REQUIREMENTS.md:498 — "Backstage Tools (No Screen Required)"
|
||||
- REQUIREMENTS.md:727 — "Backstage / Silent Support Mode"
|
||||
Reference in New Issue
Block a user