Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions registry/coder/modules/windows-rdp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
71 changes: 71 additions & 0 deletions registry/coder/modules/windows-rdp/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<TestVariables>(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<TestVariables>(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<TestVariables>(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");
});
});
36 changes: 36 additions & 0 deletions registry/coder/modules/windows-rdp/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
95 changes: 95 additions & 0 deletions registry/coder/modules/windows-rdp/rdp-keepalive.ps1.tftpl
Original file line number Diff line number Diff line change
@@ -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
}