Remote Desktop
Overview
An RDP route describes a Windows machine that a user should reach from the GateControl desktop client via RDP. In contrast to HTTP or L4 routes (which are generic Caddy upstream traffic), an RDP route is a combination of target address, NLA settings, credentials in the credential vault, Wake-on-LAN configuration, maintenance windows, session tracking, and an optionally prepended Home Gateway or Microsoft RD Gateway.
RDP routes have their own table rdp_routes and their own API endpoints (/api/v1/rdp/*). They are NOT rendered in Caddy — RDP bypasses the reverse proxy. However, if the admin chooses access_mode='gateway', the server-side port bridge is automatically created as a linked L4 route (and cleaned up again on delete/toggle).
The entire feature is gated behind the license flag remote_desktop; the gateway variant additionally behind rdp_via_gateway.
Architecture
Desktop-Client ──(LAN direkt)──────────────▶ Windows-Host (access_mode=internal)
│
├──(externes Port-Forward)───────────▶ external_host:external_port → NAT → Windows-Host
│ (access_mode=external/both)
│
├──(Server :listen_port → WG-Tunnel)──▶ Gateway ──LAN──▶ Windows-Host
│ (access_mode=gateway, gelinkte L4-Route)
│
└──(optional) Microsoft RD-Gateway (TSGateway) davor vorgeschaltet
via rdp_routes.gateway_host/gateway_port — nur als Config-Weitergabe
Components
| Component | Role |
|---|---|
rdp_routes |
Central table, own migration history |
rdp_sessions |
Session log (start/heartbeat/end) |
rdpMonitor |
Health-check service, TCP probe on port 3389 |
| Credential vault | AES-GCM-encrypted in username_encrypted / password_encrypted |
| ECDH endpoint | E2EE download of credentials to the desktop client |
| Linked L4 route | Auto-created Caddy L4 route on access_mode='gateway' |
Data model
CREATE TABLE rdp_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Connection
name TEXT NOT NULL,
host TEXT NOT NULL, -- LAN-Ziel bzw. interner Hostname
port INTEGER NOT NULL DEFAULT 3389,
external_hostname TEXT, -- für access_mode=external
external_port INTEGER,
access_mode TEXT NOT NULL DEFAULT 'internal',
gateway_host TEXT, -- Microsoft RD-Gateway (TSGateway), nicht Home-Gateway
gateway_port INTEGER DEFAULT 443,
enabled INTEGER NOT NULL DEFAULT 1,
-- Credentials (AES-256-GCM)
credential_mode TEXT NOT NULL DEFAULT 'none', -- 'none' | 'user_only' | 'full'
username_encrypted TEXT,
password_encrypted TEXT,
domain TEXT,
-- Display, Redirects, Audio, Network-Profile … (viele Spalten)
resolution_mode, resolution_width, resolution_height, multi_monitor, color_depth,
redirect_clipboard, redirect_printers, redirect_drives, redirect_usb, redirect_smartcard,
audio_mode, network_profile, nla_enabled, ...
-- Wake-on-LAN
wol_enabled INTEGER, wol_mac_address TEXT,
-- Maintenance
maintenance_enabled INTEGER,
maintenance_schedule TEXT, -- JSON-serialisiert, Zeilen wie "Mo-Fr 08:00-18:00"
-- Credential-Rotation
credential_rotation_enabled INTEGER,
credential_rotation_days INTEGER DEFAULT 90,
credential_rotation_last TEXT,
-- Access-Control
token_ids TEXT, -- JSON-Array, legacy
user_ids TEXT, -- JSON-Array, neuer Pfad
-- Home-Gateway-Link (Migration 38)
gateway_peer_id INTEGER REFERENCES peers(id),
gateway_listen_port INTEGER,
gateway_l4_route_id INTEGER REFERENCES routes(id)
);
Access modes
| Mode | Target | How the client connects |
|---|---|---|
internal |
LAN host, reachable over the VPN tunnel | <host>:<port> directly |
external |
Publicly exposed port forward | <external_hostname>:<external_port> |
both |
Client may use either path | Client-side preference |
gateway |
Home Gateway companion forwards into the LAN | <server-public-host>:<gateway_listen_port> → server Caddy L4 → gateway → <host>:<port> |
In addition, any mode can have a Microsoft RD Gateway (TSGateway) prepended via the fields gateway_host + gateway_port (not to be confused with the Home Gateway mode). GateControl merely passes these values through as configuration to the desktop client; the TSGateway role itself runs outside.
Gateway mode: the linked L4 route
The point of access_mode='gateway': the Windows host has no WG peer of its own, lives purely in the LAN behind a Home Gateway. The server must still provide an external port on which the desktop client lands, and forward the TCP stream to the gateway.
Auto-create on save
_syncLinkedL4Route(rdpId, previousL4RouteId) in src/services/rdp.js is called on create and on relevant update cases. For access_mode='gateway':
routes.create({
route_type: 'l4',
target_kind: 'gateway',
target_peer_id: rdp.gateway_peer_id,
target_lan_host: rdp.host,
target_lan_port: rdp.port || 3389,
l4_protocol: 'tcp',
l4_listen_port: String(rdp.gateway_listen_port || rdp.port || 3389),
l4_tls_mode: 'none',
enabled: rdp.enabled,
description: `auto-created for RDP route "${rdp.name}"`
})
The returned route.id is stored in rdp_routes.gateway_l4_route_id. If the L4 create fails, the RDP route is deleted again — no half-configured state remains.
Update synchronization
When one of these fields changes in the RDP update: access_mode, gateway_peer_id, gateway_listen_port, host, port, enabled → _syncLinkedL4Route is called again:
- access_mode remains
gateway→ L4 is brought back in sync viaroutes.update(previousL4RouteId, payload). - access_mode changes AWAY from gateway → L4 is deleted (
routes.remove(previousL4RouteId)),gateway_l4_route_id = NULL. - access_mode changes TO gateway → new L4 is created.
Failure mode is intentionally non-fatal: a failing L4 sync does NOT roll back the RDP side. The RDP row is saved correctly from the admin's point of view, the sync failure is logged, and the admin can trigger again with another save.
Cascade on delete and toggle
remove(id)— firstroutes.remove(gateway_l4_route_id), thenDELETE FROM rdp_routes. The order ensures that no orphan L4 is left behind if the RDP row were suddenly no longer deletable.toggle(id)— the newenabledstate is propagated to the L4 route, so that the external listen port is in sync with the RDP status.
Wake-on-LAN
wol_enabled=1 + wol_mac_address activates WoL for the route. Two paths:
Via Home Gateway
With access_mode='gateway': the server calls gateways.notifyWol(gateway_peer_id, {mac, lan_host, timeout_ms}). The gateway sends the magic packet on the LAN and responds with {success, elapsed_ms}. See home-gateway.md.
Via direct peer (no gateway)
If the Windows host is itself a WG peer and WoL would be required: the server can send a magic packet over the tunnel interface, provided the host is in the same L2 broadcast scope as a LAN device. Rarely practical — with classic RDP-via-WG setups, the peer machine is either already on or is woken by another LAN WoL sender.
Maintenance windows
maintenance_enabled=1 + maintenance_schedule (JSON array of lines like "Mo-Fr 08:00-18:00"). Parser:
- Supports German abbreviations (Mo, Di, Mi, Do, Fr, Sa, So) and English (Mon, Tue, Wed, Thu, Fri, Sat, Sun).
- Range
Mo-Fror single day. - Time range
HH:MM-HH:MM, same-day. - Multiple lines → union (OR).
isInMaintenanceWindow(routeId) returns true at query time if "now" lies within the window. The desktop client queries this before a connect and shows a warning ("Maintenance until 18:00"); the user can confirm and connect anyway.
Sessions
Separate tracking in rdp_sessions:
POST /api/v1/client/rdp/:id/session → startSession() → session_id
PATCH /api/v1/client/rdp/session/:id/ping → heartbeatSession()
DELETE /api/v1/client/rdp/session/:id → endSession(reason)
startsetsstatus='active', logsrdp_session_startin activity.heartbeatupdateslast_heartbeat; if a session stays N minutes without a heartbeat, it is ended by the sweeper asabandoned.endsetsstatus='ended'|'abandoned'|'admin_disconnect'|'normal', computesduration_s.
The admin dashboard shows:
GET /api/v1/rdp— per route theactive_sessionscount, last access.GET /api/v1/rdp/history— global session history with filters.GET /api/v1/rdp/history/export— CSV/JSON export.POST /api/v1/rdp/:id/sessions/disconnect-all— admin kill for all active sessions of a route.
Access control
Per route:
user_ids— JSON array of user IDs, newer policy. If set and non-empty, only these users have access.token_ids— JSON array of API token IDs, legacy. Only checked ifuser_idsis empty or not set.- Without both → route is visible to all tokens/users.
getForToken(tokenId, userId) in src/services/rdp.js implements the priority.
Licensing
remote_desktop— all RDP endpoints are behindrouter.use(requireFeature('remote_desktop'))insrc/routes/api/rdp.js.rdp_via_gateway— additionally checked on create/update whenaccess_mode='gateway'. Without the flag, the other three modes remain available.
Monitor and FQDN hint
rdpMonitor.checkAll() does a per-route TCP probe on the configured port. Timeout 2s, results are cached and delivered together with the routes list.
At the connection-info endpoint (/api/v1/client/rdp/:id/connection-info), if the target peer has a hostname in internal DNS and internal_dns is licensed, the FQDN is included:
{
"peer_hostname": "nas1",
"peer_fqdn": "nas1.domaincaster.lan",
"external_hostname": "...",
...
}
Background: CredSSP/NLA validates the server certificate of the Windows host against the server name specified. With a plain IP this fails. See internal-dns.md.
See also
- home-gateway.md — how the gateway companion provides L4 listeners and fires WoL.
- routing.md — the linked L4 route uses the same mechanism as manual L4 gateway routes.
- internal-dns.md — FQDN for CredSSP.
- licensing.md —
remote_desktop,rdp_via_gateway. - ../API.md —
/api/v1/rdp/*and/api/v1/client/rdp/*.
Source files
src/services/rdp.js— CRUD, validation,_syncLinkedL4Route, credential handling, maintenance parser.src/services/rdpSessions.js— Session start/heartbeat/end, history, CSV export.src/services/rdpMonitor.js— Health probe per route.src/routes/api/rdp.js— Admin endpoints,/pubkey,/rotation/pending.src/routes/api/client.js— Desktop client endpoints,/connection-info, session lifecycle.src/utils/crypto.js—ecdhEncrypt,ecdhDecrypt,getServerPublicKey,publicKeyEncrypt.src/db/migrations.js— Migration 31 (create_rdp_routes), 38 (rdp_routes_gateway_link).