diff --git a/registry/coder/modules/windows-rdp/README.md b/registry/coder/modules/windows-rdp/README.md index 111e8d7fd..2e528f214 100644 --- a/registry/coder/modules/windows-rdp/README.md +++ b/registry/coder/modules/windows-rdp/README.md @@ -59,3 +59,56 @@ module "windows_rdp" { devolutions_gateway_version = "2025.2.2" # Specify a specific version } ``` + +### RDP Keepalive + +The module starts a small PowerShell monitor that keeps the workspace active +while an RDP session is connected. The monitor checks for established local RDP +connections and extends the workspace deadline with the workspace agent token. +Coder requires extension deadlines to be at least 30 minutes in the future, so +the extension window must be 30 minutes or more. + +```tf +module "windows_rdp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/windows-rdp/coder" + version = "1.3.0" + agent_id = coder_agent.main.id + keepalive_interval_seconds = 60 + keepalive_extension_minutes = 30 +} +``` + +If your Coder deployment does not allow the workspace agent token to update the +workspace deadline, use the `coder-login` module alongside `windows-rdp`. The `coder-login` module injects a scoped token into the `CODER_SESSION_TOKEN` environment variable, which the keepalive monitor will automatically use if present: + +```tf +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/coder-login/coder" + version = "1.1.1" + agent_id = coder_agent.main.id +} + +module "windows_rdp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/windows-rdp/coder" + version = "1.3.0" + agent_id = coder_agent.main.id +} +``` + +To disable the monitor: + +```tf +module "windows_rdp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/windows-rdp/coder" + version = "1.3.0" + agent_id = coder_agent.main.id + keepalive_enabled = false +} +``` + +The monitor log is written to +`C:\ProgramData\Coder\windows-rdp\rdp-keepalive.log`. diff --git a/registry/coder/modules/windows-rdp/main.test.ts b/registry/coder/modules/windows-rdp/main.test.ts index 80c09fd0d..eeef81d7c 100644 --- a/registry/coder/modules/windows-rdp/main.test.ts +++ b/registry/coder/modules/windows-rdp/main.test.ts @@ -11,6 +11,9 @@ type TestVariables = Readonly<{ share?: string; admin_username?: string; admin_password?: string; + keepalive_enabled?: boolean; + keepalive_interval_seconds?: number; + keepalive_extension_minutes?: number; }>; function findWindowsRdpScript(state: TerraformState): string | null { @@ -128,4 +131,72 @@ describe("Web RDP", async () => { expect(customResultsGroup.username).toBe(customAdminUsername); expect(customResultsGroup.password).toBe(customAdminPassword); }); + + it("Installs the RDP keepalive monitor by default", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + + const rdpScript = findWindowsRdpScript(state); + expect(rdpScript).toBeString(); + expect(rdpScript).toContain("Install-RDPKeepaliveMonitor"); + expect(rdpScript).toContain( + '$keepaliveEnabled = [System.Convert]::ToBoolean("true")', + ); + expect(rdpScript).toContain( + 'Join-Path $env:ProgramData "Coder\\windows-rdp"', + ); + expect(rdpScript).toContain("Stop-RDPKeepaliveMonitor"); + expect(rdpScript).toContain( + 'Start-Process -FilePath "powershell.exe" -WindowStyle Hidden', + ); + expect(rdpScript).toContain("$intervalSeconds = 60"); + expect(rdpScript).toContain("$extensionMinutes = 30"); + expect(rdpScript).toContain( + "Get-NetTCPConnection -LocalPort 3389 -State Established -ErrorAction SilentlyContinue", + ); + expect(rdpScript).toContain( + 'if (-not [string]::IsNullOrWhiteSpace($env:CODER_SESSION_TOKEN))', + ); + expect(rdpScript).toContain( + 'if (-not [string]::IsNullOrWhiteSpace($env:CODER_AGENT_TOKEN))', + ); + expect(rdpScript).toContain( + '"Coder-Session-Token" = $sessionToken', + ); + expect(rdpScript).toContain( + '$uri = "$baseUrl/api/v2/workspaces/$workspaceId/extend"', + ); + expect(rdpScript).toContain( + 'Invoke-RestMethod -Method Put -Uri $uri -Headers $headers -ContentType "application/json" -Body $body', + ); + }); + + it("Customizes the RDP keepalive interval and extension window", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + keepalive_interval_seconds: 15, + keepalive_extension_minutes: 45, + }); + + const rdpScript = findWindowsRdpScript(state); + expect(rdpScript).toBeString(); + expect(rdpScript).toContain("$intervalSeconds = 15"); + expect(rdpScript).toContain("$extensionMinutes = 45"); + }); + + it("Can disable the RDP keepalive monitor", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + keepalive_enabled: false, + }); + + const rdpScript = findWindowsRdpScript(state); + expect(rdpScript).toBeString(); + expect(rdpScript).toContain( + '$keepaliveEnabled = [System.Convert]::ToBoolean("false")', + ); + expect(rdpScript).toContain("RDP keepalive disabled"); + expect(rdpScript).toContain("Remove-Item -Path $scriptPath -Force"); + }); }); diff --git a/registry/coder/modules/windows-rdp/main.tf b/registry/coder/modules/windows-rdp/main.tf index 3c83d195b..3c1a3e54f 100644 --- a/registry/coder/modules/windows-rdp/main.tf +++ b/registry/coder/modules/windows-rdp/main.tf @@ -70,6 +70,36 @@ variable "devolutions_gateway_version" { description = "Version of Devolutions Gateway to install. Use 'latest' for the most recent version, or specify a version like '2025.3.2'." } +variable "keepalive_enabled" { + type = bool + default = true + description = "Whether to keep the workspace active while an RDP session is connected." +} + +variable "keepalive_interval_seconds" { + type = number + default = 60 + description = "How often the RDP keepalive monitor checks for active RDP sessions." + + validation { + condition = var.keepalive_interval_seconds >= 10 + error_message = "keepalive_interval_seconds must be at least 10 seconds." + } +} + +variable "keepalive_extension_minutes" { + type = number + default = 30 + description = "How far ahead to extend the workspace deadline when an RDP session is active." + + validation { + condition = var.keepalive_extension_minutes >= 30 + error_message = "keepalive_extension_minutes must be at least 30 minutes." + } +} + +data "coder_workspace" "me" {} + resource "coder_script" "windows-rdp" { agent_id = var.agent_id display_name = "windows-rdp" @@ -79,6 +109,12 @@ resource "coder_script" "windows-rdp" { admin_username = var.admin_username admin_password = var.admin_password devolutions_gateway_version = var.devolutions_gateway_version + keepalive_enabled = var.keepalive_enabled + keepalive_script_contents = templatefile("${path.module}/rdp-keepalive.ps1.tftpl", { + workspace_id = data.coder_workspace.me.id + interval_seconds = var.keepalive_interval_seconds + extension_minutes = var.keepalive_extension_minutes + }) # Wanted to have this be in the powershell template file, but Terraform # doesn't allow recursive calls to the templatefile function. Have to feed diff --git a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl index 1657b878d..5d0c58d77 100644 --- a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl +++ b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl @@ -125,7 +125,48 @@ if ($isPatched -eq $null) { } } +function Stop-RDPKeepaliveMonitor { + param ( + [string]$scriptPath + ) + + Get-CimInstance Win32_Process -Filter "name = 'powershell.exe'" | + Where-Object { $_.CommandLine -and $_.CommandLine -like "*$scriptPath*" } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } +} + +function Install-RDPKeepaliveMonitor { +$keepaliveEnabled = [System.Convert]::ToBoolean("${keepalive_enabled}") +$keepaliveRoot = Join-Path $env:ProgramData "Coder\windows-rdp" +$scriptPath = Join-Path $keepaliveRoot "rdp-keepalive.ps1" +$logPath = Join-Path $keepaliveRoot "rdp-keepalive.log" + +New-Item -ItemType Directory -Path $keepaliveRoot -Force | Out-Null +Stop-RDPKeepaliveMonitor -scriptPath $scriptPath + +if (-not $keepaliveEnabled) { + if (Test-Path $scriptPath) { + Remove-Item -Path $scriptPath -Force + } + "RDP keepalive disabled" | Set-Content -Path $logPath + return +} + +@' +${keepalive_script_contents} +'@ | Set-Content -Path $scriptPath -Encoding UTF8 + +Start-Process -FilePath "powershell.exe" -WindowStyle Hidden -ArgumentList @( + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + $scriptPath +) +} + Set-AdminPassword -adminPassword "${admin_password}" Configure-RDP Install-DevolutionsGateway Patch-Devolutions-HTML +Install-RDPKeepaliveMonitor diff --git a/registry/coder/modules/windows-rdp/rdp-keepalive.ps1.tftpl b/registry/coder/modules/windows-rdp/rdp-keepalive.ps1.tftpl new file mode 100644 index 000000000..72c773f4c --- /dev/null +++ b/registry/coder/modules/windows-rdp/rdp-keepalive.ps1.tftpl @@ -0,0 +1,95 @@ +$ErrorActionPreference = "Continue" + +$workspaceId = "${workspace_id}" +$intervalSeconds = ${interval_seconds} +$extensionMinutes = ${extension_minutes} +$logPath = Join-Path $env:ProgramData "Coder\windows-rdp\rdp-keepalive.log" + +function Write-KeepaliveLog { + param ( + [string]$Message + ) + + $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + "$timestamp $Message" | Add-Content -Path $logPath +} + +function Test-RDPConnectionActive { + try { + $connections = @( + Get-NetTCPConnection -LocalPort 3389 -State Established -ErrorAction SilentlyContinue + ) + + return $connections.Count -gt 0 + } + catch { + Write-KeepaliveLog "Unable to inspect RDP connections: $($_.Exception.Message)" + return $false + } +} + +function Get-CoderAgentBaseUrl { + if ([string]::IsNullOrWhiteSpace($env:CODER_AGENT_URL)) { + Write-KeepaliveLog "CODER_AGENT_URL is not available" + return $null + } + + return $env:CODER_AGENT_URL.TrimEnd([char]"/") +} + +function Get-CoderSessionToken { + if (-not [string]::IsNullOrWhiteSpace($env:CODER_SESSION_TOKEN)) { + return $env:CODER_SESSION_TOKEN + } + + if (-not [string]::IsNullOrWhiteSpace($env:CODER_AGENT_TOKEN)) { + return $env:CODER_AGENT_TOKEN + } + + Write-KeepaliveLog "No Coder session token is available" + return $null +} + +function Invoke-CoderWorkspaceExtend { + if ([string]::IsNullOrWhiteSpace($workspaceId)) { + Write-KeepaliveLog "workspace id is not available" + return + } + + $baseUrl = Get-CoderAgentBaseUrl + if ([string]::IsNullOrWhiteSpace($baseUrl)) { + return + } + + $sessionToken = Get-CoderSessionToken + if ([string]::IsNullOrWhiteSpace($sessionToken)) { + return + } + + $deadline = (Get-Date).ToUniversalTime().AddMinutes($extensionMinutes).ToString("yyyy-MM-ddTHH:mm:ssZ") + $uri = "$baseUrl/api/v2/workspaces/$workspaceId/extend" + $headers = @{ + "Coder-Session-Token" = $sessionToken + } + $body = @{ + deadline = $deadline + } | ConvertTo-Json -Compress + + try { + Invoke-RestMethod -Method Put -Uri $uri -Headers $headers -ContentType "application/json" -Body $body | Out-Null + Write-KeepaliveLog "Extended workspace deadline to $deadline" + } + catch { + Write-KeepaliveLog "Unable to extend workspace deadline: $($_.Exception.Message)" + } +} + +Write-KeepaliveLog "Starting Coder RDP keepalive monitor for workspace $workspaceId" + +while ($true) { + if (Test-RDPConnectionActive) { + Invoke-CoderWorkspaceExtend + } + + Start-Sleep -Seconds $intervalSeconds +}