Remote Desktop
Überblick
Eine RDP-Route beschreibt einen Windows-Rechner, den ein Nutzer vom GateControl-Desktop-Client per RDP erreichen soll. Im Gegensatz zu HTTP- oder L4-Routen (die generischen Caddy-Upstream-Verkehr sind) ist eine RDP-Route eine Kombination aus Zieladresse, NLA-Einstellungen, Anmeldeinformationen im Credential-Vault, Wake-on-LAN-Konfiguration, Wartungsfenstern, Session-Tracking und einem optional vorgelagerten Home-Gateway oder Microsoft RD-Gateway.
RDP-Routen haben eine eigene Tabelle rdp_routes und eigene API-Endpoints (/api/v1/rdp/*). Sie werden NICHT in Caddy gerendert — RDP geht am Reverse-Proxy vorbei. Wenn aber der Admin access_mode='gateway' wählt, wird die Server-seitige Port-Brücke automatisch als gelinkte L4-Route erzeugt (und beim Delete/Toggle wieder aufgeräumt).
Das gesamte Feature steckt hinter dem Lizenz-Flag remote_desktop; die Gateway-Variante zusätzlich hinter rdp_via_gateway.
Architektur
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
Komponenten
| Komponente | Rolle |
|---|---|
rdp_routes |
Zentrale Tabelle, eigene Migration-Historie |
rdp_sessions |
Session-Log (start/heartbeat/end) |
rdpMonitor |
Health-Check-Service, TCP-Probe auf Port 3389 |
| Credential-Vault | AES-GCM-verschlüsselt in username_encrypted / password_encrypted |
| ECDH-Endpoint | E2EE-Download der Creds zum Desktop-Client |
| Linked L4-Route | Auto-erzeugte Caddy-L4-Route bei access_mode='gateway' |
Datenmodell
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 | Ziel | Wie der Client verbindet |
|---|---|---|
internal |
LAN-Host, erreichbar über VPN-Tunnel | <host>:<port> direkt |
external |
Öffentlich exponierter Port-Forward | <external_hostname>:<external_port> |
both |
Client darf beide Wege | Client-seitige Präferenz |
gateway |
Home-Gateway-Companion leitet in LAN weiter | <server-public-host>:<gateway_listen_port> → Server-Caddy-L4 → Gateway → <host>:<port> |
Zusätzlich kann bei jedem Mode ein Microsoft RD-Gateway (TSGateway) vorgeschaltet werden über die Felder gateway_host + gateway_port (nicht zu verwechseln mit dem Home-Gateway-Mode). GateControl reicht diese Werte nur als Konfiguration an den Desktop-Client weiter; die TSGateway-Rolle selbst läuft ausserhalb.
Gateway-Mode: die gelinkte L4-Route
Der Sinn von access_mode='gateway': Der Windows-Host hat keinen eigenen WG-Peer, lebt rein im LAN hinter einem Home-Gateway. Der Server muss trotzdem einen externen Port bereitstellen, auf dem der Desktop-Client landet, und den TCP-Stream zum Gateway forwarden.
Auto-Create beim Save
_syncLinkedL4Route(rdpId, previousL4RouteId) in src/services/rdp.js wird bei create und bei relevanten update-Fällen aufgerufen. Bei 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}"`
})
Die zurückerhaltene route.id wandert in rdp_routes.gateway_l4_route_id. Schlägt der L4-Create fehl, wird die RDP-Route wieder gelöscht — kein halb-konfigurierter Zustand bleibt zurück.
Update-Synchronisation
Wenn im RDP-Update eines dieser Felder ändert: access_mode, gateway_peer_id, gateway_listen_port, host, port, enabled → _syncLinkedL4Route wird erneut aufgerufen:
- access_mode bleibt
gateway→ L4 wird perroutes.update(previousL4RouteId, payload)in sync gebracht. - access_mode wechselt WEG von gateway → L4 wird gelöscht (
routes.remove(previousL4RouteId)),gateway_l4_route_id = NULL. - access_mode wechselt AUF gateway → neuer L4 wird erzeugt.
Failure-Mode ist absichtlich non-fatal: Ein fehlschlagendes L4-Sync rollbackt NICHT die RDP-Seite. Das RDP-Row ist aus Sicht des Admins korrekt gespeichert, der Sync-Fehler wird geloggt, der Admin kann per erneutem Save nochmal triggern.
Cascade beim Delete und Toggle
remove(id)— zuerstroutes.remove(gateway_l4_route_id), dannDELETE FROM rdp_routes. Reihenfolge stellt sicher, dass kein Orphan-L4 übrig bleibt, falls die RDP-Zeile plötzlich nicht mehr löschbar wäre.toggle(id)— neueenabled-State wird auf die L4-Route propagiert, sodass das externe Listen-Port mit dem RDP-Status synchron ist.
Wake-on-LAN
wol_enabled=1 + wol_mac_address aktiviert WoL für die Route. Zwei Pfade:
Via Home-Gateway
Bei access_mode='gateway': Server ruft gateways.notifyWol(gateway_peer_id, {mac, lan_host, timeout_ms}) auf. Der Gateway sendet das Magic-Packet im LAN und antwortet mit {success, elapsed_ms}. Siehe home-gateway.md.
Via direkter Peer (kein Gateway)
Wenn der Windows-Host selbst ein WG-Peer ist und WoL erforderlich wäre: Der Server kann ein Magic-Packet über das Tunnel-Interface senden, sofern der Host im gleichen L2-Broadcast-Scope liegt wie ein LAN-Gerät. Praktisch selten — bei klassischen RDP-via-WG-Setups ist der Peer-Rechner entweder schon an oder wird von einem anderen LAN-WoL-Sender geweckt.
Wartungsfenster
maintenance_enabled=1 + maintenance_schedule (JSON-Array von Zeilen wie "Mo-Fr 08:00-18:00"). Parser:
- Unterstützt deutsche Abkürzungen (Mo, Di, Mi, Do, Fr, Sa, So) und englische (Mon, Tue, Wed, Thu, Fri, Sat, Sun).
- Range
Mo-Froder einzelner Tag. - Zeitbereich
HH:MM-HH:MM, gleichtagig. - Mehrere Zeilen → Union (ODER).
isInMaintenanceWindow(routeId) gibt zur Abfragezeit true zurück, wenn "jetzt" innerhalb des Fensters liegt. Der Desktop-Client fragt das vor einem Connect an und zeigt eine Warnung ("Wartung bis 18:00"); der User kann bestätigen und trotzdem verbinden.
Sessions
Separates 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)
startsetztstatus='active', loggtrdp_session_startin Activity.heartbeataktualisiertlast_heartbeat; wenn eine Session N Minuten ohne Heartbeat bleibt, wird sie vom Sweeper alsabandonedbeendet.endsetztstatus='ended'|'abandoned'|'admin_disconnect'|'normal', berechnetduration_s.
Admin-Dashboard zeigt:
GET /api/v1/rdp— pro Route dieactive_sessions-Anzahl, letzter Zugriff.GET /api/v1/rdp/history— globale Session-Historie mit Filtern.GET /api/v1/rdp/history/export— CSV/JSON-Export.POST /api/v1/rdp/:id/sessions/disconnect-all— Admin-Kill für alle aktiven Sessions einer Route.
Access-Control
Pro Route:
user_ids— JSON-Array von User-IDs, neuere Policy. Wenn gesetzt und non-empty, haben nur diese User Zugriff.token_ids— JSON-Array von API-Token-IDs, Legacy. Nur geprüft wennuser_idsleer oder nicht gesetzt.- Ohne beide → Route ist für alle Token/User sichtbar.
getForToken(tokenId, userId) in src/services/rdp.js realisiert die Priorität.
Lizenzierung
remote_desktop— alle RDP-Endpoints sind insrc/routes/api/rdp.jshinterrouter.use(requireFeature('remote_desktop')).rdp_via_gateway— wird zusätzlich geprüft bei Create/Update, wennaccess_mode='gateway'. Ohne das Flag bleiben die drei anderen Modi verfügbar.
Monitor und FQDN-Hint
rdpMonitor.checkAll() macht pro-Route TCP-Probe auf den konfigurierten Port. Timeout 2s, Ergebnisse werden gecached und zusammen mit der Routes-List ausgeliefert.
Beim Connection-Info-Endpoint (/api/v1/client/rdp/:id/connection-info) wird, wenn der Ziel-Peer im internen DNS einen Hostname hat und internal_dns lizenziert ist, der FQDN mitgeliefert:
{
"peer_hostname": "nas1",
"peer_fqdn": "nas1.domaincaster.lan",
"external_hostname": "...",
...
}
Hintergrund: CredSSP/NLA validiert das Server-Zertifikat des Windows-Host gegen den angegebenen Server-Namen. Mit reiner IP schlägt das fehl. Siehe internal-dns.md.
Siehe auch
- home-gateway.md — wie der Gateway-Companion L4-Listener bereitstellt und WoL feuert.
- routing.md — die gelinkte L4-Route nutzt denselben Mechanismus wie manuelle L4-Gateway-Routen.
- internal-dns.md — FQDN für CredSSP.
- licensing.md —
remote_desktop,rdp_via_gateway. - ../API.md —
/api/v1/rdp/*und/api/v1/client/rdp/*.
Quelldateien
src/services/rdp.js— CRUD, Validierung,_syncLinkedL4Route, Credential-Handling, Maintenance-Parser.src/services/rdpSessions.js— Session-Start/Heartbeat/End, Historie, CSV-Export.src/services/rdpMonitor.js— Health-Probe pro 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).