2 * @file Guacamole Integration Service - Remote Desktop Gateway Management
3 * @module services/guacamoleService
5 * Apache Guacamole integration service providing clientless remote desktop access (RDP, VNC, SSH)
6 * through web browsers. Manages connections, users, authentication, and multi-tenant isolation
7 * via Guacamole's REST API and PostgreSQL database.
9 * **Apache Guacamole Overview:**
11 * Guacamole is a clientless remote desktop gateway supporting:
12 * - **RDP** (Remote Desktop Protocol) - Windows remote access
13 * - **VNC** (Virtual Network Computing) - Cross-platform screen sharing
14 * - **SSH** (Secure Shell) - Terminal access
15 * - Browser-based access via HTML5 WebSocket/HTTP
16 * - No client software required
20 * ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
21 * │ Browser │◄────►│ Guacamole │◄────►│ RMM Agent │
22 * │ (HTML5) │ │ Gateway │ │ (RDP/VNC) │
23 * └──────────────┘ └──────────────────┘ └──────────────┘
26 * ┌──────────────────┐
29 * └──────────────────┘
32 * **Multi-Tenancy Model:**
34 * Implements tenant isolation using Guacamole's connection group hierarchy:
38 * │ ├── agent_connection_1
39 * │ └── agent_connection_2
41 * │ └── agent_connection_3
43 * └── agent_connection_4
47 * - Dedicated connection group
48 * - Isolated user permissions
49 * - Separate connection credentials
50 * - Access control per agent
52 * **Connection Lifecycle:**
54 * 1. **Agent Registration**: Agent enrolls with RMM platform
55 * 2. **Connection Creation**: Service creates Guacamole connection for agent
56 * 3. **User Provisioning**: Dashboard users get Guacamole accounts
57 * 4. **Permission Grant**: Users assigned READ permission to tenant connections
58 * 5. **Session Initiation**: User requests remote access via dashboard
59 * 6. **Token Generation**: Service generates temporary connection token
60 * 7. **Browser Connect**: User opens Guacamole URL with token
61 * 8. **Tunnel Establishment**: Guacamole connects to agent via persistent tunnel
62 * 9. **Session Active**: User interacts with remote desktop in browser
63 * 10. **Session Cleanup**: Connection closed, logs saved to database
65 * **Persistent Tunnels:**
67 * Uses guacamoleTunnelService for persistent SSH tunnels to agents:
68 * - Agent opens reverse SSH tunnel on port 45000+
69 * - Guacamole connects to localhost:assigned_port
70 * - Eliminates need for public RDP/VNC exposure
71 * - Works through firewalls and NAT
74 * - `GUACAMOLE_URL` - Guacamole server URL (default: http://localhost:8080/guacamole)
75 * - `GUACAMOLE_USERNAME` - Admin username (default: guacadmin)
76 * - `GUACAMOLE_PASSWORD` - Admin password (required)
78 * **Database Schema:**
79 * - `agent_guacamole_connections` - Agent → Connection mapping
80 * - Guacamole native tables (guacamole_connection, guacamole_user, etc.)
82 * **API Endpoints Used:**
83 * - POST `/api/tokens` - Authentication
84 * - GET/POST `/api/session/data/postgresql/connectionGroups` - Groups
85 * - GET/POST/PATCH `/api/session/data/postgresql/connections` - Connections
86 * - GET/POST `/api/session/data/postgresql/users` - User management
87 * - PATCH `/api/session/data/postgresql/users/{user}/permissions` - Permissions
88 * - GET `/api/session/data/postgresql/activeConnections` - Active sessions
91 * - **Token caching**: Admin token cached for 50 minutes to reduce auth overhead
92 * - **Auto-provisioning**: Users/connections created automatically on first access
93 * - **Permission management**: READ permissions granted per tenant
94 * - **Port allocation**: Dynamic port assignment for persistent tunnels
95 * - **Connection tracking**: Database records for audit and monitoring
96 * - **Idle detection**: Automatic cleanup of stale connections
99 * - Tenant isolation via connection groups
100 * - User authentication required
101 * - Permission-based access control
102 * - Temporary session tokens (short-lived)
103 * - Encrypted tunnels (SSH)
106 * // Create connection for new agent
107 * const guacamoleService = require('./services/guacamoleService');
108 * await guacamoleService.createOrUpdateConnection({
109 * agent_uuid: '550e8400-e29b-41d4-a716-446655440000',
110 * hostname: 'WORKSTATION-01',
114 * // Generate session token for user
115 * const token = await guacamoleService.generateConnectionToken(
121 * const url = guacamoleService.getConnectionUrl(connectionId, token);
122 * // User opens URL in browser to start remote session
124 * // Provision dashboard user
125 * await guacamoleService.createOrUpdateGuacamoleUser(
126 * 'admin@example.com',
130 * @see {@link https://guacamole.apache.org/doc/gug/} Guacamole User Guide
131 * @see {@link https://guacamole.apache.org/api-documentation/} Guacamole REST API
132 * @see {@link module:services/guacamoleTunnelService} for persistent tunnel management
133 * @see {@link module:services/websocketManager} for RDS session alternative
134 * @requires axios - HTTP client for Guacamole API
135 * @requires ./db - PostgreSQL connection pool
136 * @requires ./guacamoleTunnelService - SSH tunnel management
141 * Manages Guacamole connections via API and database
144const axios = require('axios');
145const pool = require('./db');
146const tunnelService = require('./guacamoleTunnelService');
148const GUACAMOLE_URL = process.env.GUACAMOLE_URL || 'http://localhost:8080/guacamole';
149const GUACAMOLE_USERNAME = process.env.GUACAMOLE_USERNAME || 'guacadmin';
150const GUACAMOLE_PASSWORD = process.env.GUACAMOLE_PASSWORD;
152let cachedToken = null;
156 * Authenticate with Guacamole and get auth token (admin)
158async function getAuthToken() {
159 // Return cached token if still valid
160 if (cachedToken && Date.now() < tokenExpiry) {
165 const response = await axios.post(
166 `${GUACAMOLE_URL}/api/tokens`,
167 `username=${encodeURIComponent(GUACAMOLE_USERNAME)}&password=${encodeURIComponent(GUACAMOLE_PASSWORD)}`,
170 'Content-Type': 'application/x-www-form-urlencoded'
175 cachedToken = response.data.authToken;
176 // Token expires after 60 minutes, cache for 50 minutes to be safe
177 tokenExpiry = Date.now() + (50 * 60 * 1000);
181 console.error('[Guacamole] Authentication failed:', error.response?.data || error.message);
182 throw new Error('Failed to authenticate with Guacamole');
187 * Authenticate as a specific user and get their token
191async function getUserAuthToken(username, password) {
193 const response = await axios.post(
194 `${GUACAMOLE_URL}/api/tokens`,
195 `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`,
198 'Content-Type': 'application/x-www-form-urlencoded'
203 return response.data.authToken;
205 console.error('[Guacamole] User authentication failed:', error.response?.data || error.message);
206 throw new Error('Failed to authenticate user with Guacamole');
211 * Create or get a connection group for a tenant
212 * @param {string} tenantId - Tenant ID
213 * @param {string} tenantName - Tenant name for display
214 * @returns {Promise<string>} Group identifier
216async function createOrGetTenantGroup(tenantId, tenantName) {
218 const token = await getAuthToken();
220 // Check if group already exists
221 const groupsResponse = await axios.get(
222 `${GUACAMOLE_URL}/api/session/data/postgresql/connectionGroups/ROOT/tree`,
225 'Guacamole-Token': token
230 // Search for existing tenant group
231 const existingGroup = groupsResponse.data.childConnectionGroups?.find(
232 g => g.identifier === `tenant_${tenantId}` || g.name === `Tenant: ${tenantName}`
236 console.log(`[Guacamole] Found existing group for tenant ${tenantId}: ${existingGroup.identifier}`);
237 return existingGroup.identifier;
240 // Create new connection group for tenant
241 const createResponse = await axios.post(
242 `${GUACAMOLE_URL}/api/session/data/postgresql/connectionGroups`,
244 parentIdentifier: 'ROOT',
245 name: `Tenant: ${tenantName}`,
246 type: 'ORGANIZATIONAL',
248 'max-connections': '',
249 'max-connections-per-user': ''
254 'Guacamole-Token': token
259 const groupId = createResponse.data.identifier;
260 console.log(`[Guacamole] Created connection group ${groupId} for tenant ${tenantId}`);
264 console.error('[Guacamole] Error creating/getting tenant group:', error.response?.data || error.message);
270 * Create or update a Guacamole user for a dashboard user
271 * @param {string} userEmail - User email
272 * @param {string} tenantId - Tenant ID
273 * @param {string} tenantGroupId - Tenant group identifier
274 * @returns {Promise<{username: string, password: string}>}
276async function createOrUpdateGuacamoleUser(userEmail, tenantId, tenantGroupId) {
278 const token = await getAuthToken();
279 const crypto = require('crypto');
281 // Handle undefined userEmail gracefully
283 userEmail = `user_${Date.now()}@tenant.local`;
286 // Generate username from email (sanitize)
287 const username = `tenant_${tenantId}_${userEmail.split('@')[0].replace(/[^a-zA-Z0-9]/g, '_')}`;
289 // Check if user exists
290 let userExists = false;
293 `${GUACAMOLE_URL}/api/session/data/postgresql/users/${username}`,
296 'Guacamole-Token': token
302 // User doesn't exist
305 // Generate or retrieve password (store in database)
307 const passwordResult = await pool.query(
308 'SELECT guac_password FROM users WHERE email = $1',
312 if (passwordResult.rows.length > 0 && passwordResult.rows[0].guac_password) {
313 password = passwordResult.rows[0].guac_password;
315 // Generate new password
316 password = crypto.randomBytes(32).toString('hex');
318 // Store password in database
320 `UPDATE users SET guac_password = $1 WHERE email = $2`,
321 [password, userEmail]
326 // Update existing user permissions
328 `${GUACAMOLE_URL}/api/session/data/postgresql/users/${username}/permissions`,
331 path: `/connectionGroupPermissions/${tenantGroupId}`,
336 'Guacamole-Token': token,
337 'Content-Type': 'application/json'
342 console.log(`[Guacamole] Updated permissions for user ${username}`);
346 `${GUACAMOLE_URL}/api/session/data/postgresql/users`,
353 'access-window-start': '',
354 'access-window-end': '',
362 'Guacamole-Token': token
367 // Grant READ permission on tenant group
369 `${GUACAMOLE_URL}/api/session/data/postgresql/users/${username}/permissions`,
372 path: `/connectionGroupPermissions/${tenantGroupId}`,
377 'Guacamole-Token': token,
378 'Content-Type': 'application/json'
383 console.log(`[Guacamole] Created user ${username} with access to group ${tenantGroupId}`);
386 return { username, password };
388 console.error('[Guacamole] Error creating/updating user:', error.response?.data || error.message);
394 * Create or update a Guacamole connection for an agent
395 * @param {object} agent - Agent object with uuid, hostname, ip_address, os
396 * @param {string} tenantId - Tenant ID
397 * @returns {object} Connection details
399async function createOrUpdateConnection(agent, tenantId) {
401 const token = await getAuthToken();
403 // Get tenant info for group creation
404 const tenantResult = await pool.query(
405 'SELECT name FROM tenants WHERE id = $1',
409 const tenantName = tenantResult.rows.length > 0 ? tenantResult.rows[0].name : tenantId;
411 // Create or get tenant connection group
412 const tenantGroupId = await createOrGetTenantGroup(tenantId, tenantName);
414 // Determine connection protocol based on OS
415 const protocol = agent.os?.toLowerCase().includes('windows') ? 'rdp' : 'vnc';
416 const remotePort = protocol === 'rdp' ? 3389 : 5900;
418 // Check if connection already exists in our database
419 const existingResult = await pool.query(
420 'SELECT guac_connection_id FROM agent_guacamole_connections WHERE agent_uuid = $1',
424 let connectionId = existingResult.rows.length > 0 ? existingResult.rows[0].guac_connection_id : null;
426 // Don't create tunnel here - tunnels are created on-demand when "Request Access" is clicked
427 // Initial connection points to a placeholder (will be updated when tunnel is active)
428 const connectionName = `${agent.hostname || agent.agent_uuid}`;
430 if (existingResult.rows.length > 0) {
431 // Update existing connection metadata only (don't change host/port)
432 connectionId = existingResult.rows[0].guac_connection_id;
434 // Get current connection to preserve host/port
435 const token = await getAuthToken();
436 const currentResponse = await axios.get(
437 `${GUACAMOLE_URL}/api/session/data/postgresql/connections/${connectionId}`,
438 { headers: { 'Guacamole-Token': token } }
442 `${GUACAMOLE_URL}/api/session/data/postgresql/connections/${connectionId}`,
444 name: connectionName,
447 ...currentResponse.data.parameters, // Preserve existing host/port
448 hostname: currentResponse.data.parameters.hostname || '0.0.0.0', // Placeholder if not set
449 port: currentResponse.data.parameters.port || remotePort.toString(),
450 'ignore-cert': 'true',
452 'enable-wallpaper': 'false',
453 'enable-theming': 'false',
454 'enable-font-smoothing': 'true',
455 'resize-method': 'display-update',
456 // System-level access - no authentication required
457 username: '', // Blank username for system access
458 password: '', // Blank password for system access
459 domain: '' // No domain required
462 'max-connections': '2',
463 'max-connections-per-user': '1'
468 'Guacamole-Token': token
473 console.log(`[Guacamole] Updated connection ${connectionId} for agent ${agent.agent_uuid}`);
475 // Create new connection under tenant group with placeholder address
476 const token = await getAuthToken();
477 const response = await axios.post(
478 `${GUACAMOLE_URL}/api/session/data/postgresql/connections`,
480 parentIdentifier: tenantGroupId, // Place in tenant-specific group
481 name: connectionName,
484 hostname: '0.0.0.0', // Placeholder - will be updated when tunnel is created
485 port: remotePort.toString(),
486 'ignore-cert': 'true',
488 'enable-wallpaper': 'false',
489 'enable-theming': 'false',
490 'enable-font-smoothing': 'true',
491 'resize-method': 'display-update',
492 // System-level access - no authentication required
493 username: '', // Blank username for system access
494 password: '', // Blank password for system access
495 domain: '' // No domain required
498 'max-connections': '2',
499 'max-connections-per-user': '1'
504 'Guacamole-Token': token
509 connectionId = response.data.identifier;
511 // Store connection ID in our database
513 `INSERT INTO agent_guacamole_connections (agent_uuid, tenant_id, guac_connection_id, protocol, created_at, updated_at)
514 VALUES ($1, $2, $3, $4, NOW(), NOW())`,
515 [agent.agent_uuid, tenantId, connectionId, protocol]
518 console.log(`[Guacamole] Created connection ${connectionId} for agent ${agent.agent_uuid}`);
527 console.error('[Guacamole] Error creating/updating connection:', error.response?.data || error.message);
533 * Delete a Guacamole connection
534 * @param {string} agentUuid - Agent UUID
536async function deleteConnection(agentUuid) {
538 // Get connection ID from database
539 const result = await pool.query(
540 'SELECT guac_connection_id FROM agent_guacamole_connections WHERE agent_uuid = $1',
544 if (result.rows.length === 0) {
545 return; // No connection to delete
548 const connectionId = result.rows[0].guac_connection_id;
549 const token = await getAuthToken();
551 // Delete from Guacamole
553 `${GUACAMOLE_URL}/api/session/data/postgresql/connections/${connectionId}`,
556 'Guacamole-Token': token
561 // Delete from our database
563 'DELETE FROM agent_guacamole_connections WHERE agent_uuid = $1',
567 console.log(`[Guacamole] Deleted connection ${connectionId} for agent ${agentUuid}`);
569 console.error('[Guacamole] Error deleting connection:', error.response?.data || error.message);
570 // Don't throw - best effort deletion
575 * Get connection details for an agent
576 * @param {string} agentUuid - Agent UUID
577 * @returns {object | null} Connection details
579async function getConnection(agentUuid) {
580 const result = await pool.query(
581 'SELECT * FROM agent_guacamole_connections WHERE agent_uuid = $1',
585 return result.rows.length > 0 ? result.rows[0] : null;
589 * Get connections URL for embedding in dashboard
590 * @param {string} connectionId - Guacamole connection ID
591 * @param {string} authToken - Authentication token
592 * @returns {string} Complete Guacamole client URL with token
594function getConnectionUrl(connectionId, authToken) {
595 // Use external URL for dashboard/frontend access
596 const externalUrl = process.env.GUACAMOLE_EXTERNAL_URL || 'https://guacamole.everydaytech.au';
598 // Standard Guacamole client URL format
599 // The token should be passed as a URL parameter for auto-login
600 return `${externalUrl}/guacamole/#/client/${connectionId}?token=${authToken}`;
604 * Generate a connection token for auto-login
605 * IMPORTANT: This generates a user-specific token with ONLY access to their tenant's connections
606 * This prevents cross-tenant access and ensures proper tenant isolation
607 * @param {string} connectionId - Guacamole connection ID
608 * @param {string} agentUuid - Agent UUID for tenant verification
609 * @param {string} tenantId - Tenant ID for isolation
610 * @param {string} userEmail - User email for audit logging
611 * @returns {Promise<string>} Token for auto-login URL
613async function generateConnectionToken(connectionId, agentUuid, tenantId, userEmail) {
615 // Ensure userEmail has a fallback value
616 if (!userEmail || userEmail === 'undefined') {
617 userEmail = `user_${Date.now()}@tenant${tenantId}.local`;
620 // Verify this connection belongs to the tenant
621 const verifyResult = await pool.query(
622 `SELECT agc.guac_connection_id, agc.tenant_id, a.hostname, t.name as tenant_name
623 FROM agent_guacamole_connections agc
624 JOIN agents a ON a.agent_uuid = agc.agent_uuid
625 JOIN tenants t ON t.tenant_id = agc.tenant_id
626 WHERE agc.agent_uuid = $1 AND agc.tenant_id = $2`,
627 [agentUuid, tenantId]
630 if (verifyResult.rows.length === 0) {
631 throw new Error('Connection not found or access denied');
635 const tenantName = verifyResult.rows[0].tenant_name;
637 // Create or get tenant connection group
638 const tenantGroupId = await createOrGetTenantGroup(tenantId, tenantName);
640 // Create or update Guacamole user for this dashboard user
641 // This user will ONLY have access to their tenant's connection group
642 const { username, password } = await createOrUpdateGuacamoleUser(userEmail, tenantId, tenantGroupId);
644 // Authenticate as the tenant-specific user (not admin)
645 const userToken = await getUserAuthToken(username, password);
647 // Log access for audit trail
649 `INSERT INTO activity_logs (agent_uuid, tenant_id, action, details, user_email)
650 VALUES ($1, $2, $3, $4, $5)`,
654 'guacamole_token_generated',
656 connection_id: connectionId,
657 hostname: verifyResult.rows[0].hostname,
658 guacamole_user: username
664 console.log(`[Guacamole] Generated user-specific token for ${userEmail} (${username}) to access connection ${connectionId} (tenant ${tenantId})`);
666 // Return user-specific token - this user can ONLY see their tenant's connections
669 console.error('[Guacamole] Error generating connection token:', error.message);
675 * Create a new Guacamole connection (wrapper for createOrUpdateConnection)
676 * @param {string} agentUuid - Agent UUID
677 * @param {object} config - Connection configuration
678 * @returns {object} Connection details with identifier
680async function createConnection(agentUuid, config) {
681 const token = await getAuthToken();
683 // Create connection via Guacamole API
684 const response = await axios.post(
685 `${GUACAMOLE_URL}/api/session/data/postgresql/connections`,
687 parentIdentifier: 'ROOT',
689 protocol: config.protocol || 'rdp',
690 parameters: config.parameters,
692 'max-connections': '2',
693 'max-connections-per-user': '1'
698 'Guacamole-Token': token
704 identifier: response.data.identifier,
706 protocol: config.protocol || 'rdp'
711 * Update Guacamole connection with new tunnel port
712 * @param {string} agentUuid - Agent UUID
713 * @param {number} port - Local tunnel port
714 * @param {string} protocol - Connection protocol (rdp/vnc/ssh)
716async function updateConnectionPort(agentUuid, port, protocol) {
718 // Get connection ID from database
719 const result = await pool.query(
720 'SELECT guac_connection_id FROM agent_guacamole_connections WHERE agent_uuid = $1',
724 if (result.rows.length === 0) {
725 throw new Error('Connection not found');
728 const connectionId = result.rows[0].guac_connection_id;
729 const token = await getAuthToken();
731 // Get current connection details first
732 const currentResponse = await axios.get(
733 `${GUACAMOLE_URL}/api/session/data/postgresql/connections/${connectionId}`,
736 'Guacamole-Token': token
741 const currentConnection = currentResponse.data;
743 // Update connection with new port using Docker gateway IP
744 // 172.19.0.1 is the host IP accessible from inside Docker containers
746 `${GUACAMOLE_URL}/api/session/data/postgresql/connections/${connectionId}`,
748 ...currentConnection,
750 ...currentConnection.parameters,
751 hostname: '172.19.0.1', // Docker gateway to reach host
752 port: port.toString()
757 'Guacamole-Token': token
762 console.log(`[Guacamole] Updated connection ${connectionId} to use tunnel port ${port}`);
764 console.error('[Guacamole] Error updating connection port:', error.response?.data || error.message);
772 createOrGetTenantGroup,
773 createOrUpdateGuacamoleUser,
775 createOrUpdateConnection,
779 generateConnectionToken,