Last active
June 2, 2026 11:43
-
-
Save Geofferey/0d1763e770879cdb183493a9427ddee9 to your computer and use it in GitHub Desktop.
RustDeskAD - RustDesk for Active Directory
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| $Services = @( | |
| "RustDeskAD - Agent", | |
| "RustDeskAD - Remote Desktop" | |
| ) | |
| $Processes = @( | |
| "RustDeskADAgent", | |
| "RustDeskADAgentServiceWrapper", | |
| "RustDesk" | |
| ) | |
| Write-Host "[1] Disabling services first..." | |
| foreach ($svc in $Services) { | |
| Write-Host "Disabling: $svc" | |
| sc.exe config $svc start= disabled | Out-Null | |
| } | |
| Write-Host "`n[2] Rapid stop/kill loop..." | |
| for ($i = 1; $i -le 20; $i++) { | |
| Write-Host "Loop $i" | |
| foreach ($svc in $Services) { | |
| sc.exe stop $svc 2>$null | Out-Null | |
| } | |
| foreach ($proc in $Processes) { | |
| Get-Process -Name $proc -ErrorAction SilentlyContinue | ForEach-Object { | |
| try { | |
| Stop-Process -Id $_.Id -Force -ErrorAction Stop | |
| Write-Host "Killed $($_.ProcessName) PID $($_.Id)" | |
| } | |
| catch { | |
| Write-Host "Failed to kill $($_.ProcessName) PID $($_.Id): $($_.Exception.Message)" | |
| } | |
| } | |
| } | |
| Start-Sleep -Milliseconds 250 | |
| } | |
| Write-Host "`n[3] Final status..." | |
| Get-Service -Name $Services -ErrorAction SilentlyContinue | | |
| Select-Object Name, Status, StartType | |
| Get-CimInstance Win32_Process | | |
| Where-Object { $_.Name -match 'RustDeskADAgent|RustDeskADAgentServiceWrapper|RustDesk' } | | |
| Select-Object ProcessId, ParentProcessId, Name, CommandLine | | |
| Format-Table -AutoSize |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #requires -RunAsAdministrator | |
| <# | |
| Install-RustDeskAD-DirectoryAndService.ps1 | |
| Full RustDeskAD bootstrap: | |
| Schema: | |
| - Creates rustDeskUri if missing | |
| - Creates rustDeskPass if missing | |
| - Creates rustDeskCommand if missing | |
| - Creates rustDeskMetadata if missing | |
| - Adds the attributes to the computer class mayContain list if missing | |
| AD permissions: | |
| - Allows computer objects / SELF to read/write their own RustDeskAD attributes | |
| - Creates RustDeskAD_ClientOperators | |
| - Allows RustDeskAD_ClientOperators to read all four attributes | |
| - Allows RustDeskAD_ClientOperators to write rustDeskCommand and rustDeskPass | |
| - Does not use dsacls | |
| - Removes/replaces only RustDeskAD-managed ACEs to avoid stacking | |
| Service account: | |
| - Creates svc_RustDeskADClient | |
| - Adds it to RustDeskAD_ClientOperators | |
| Local machine: | |
| - Grants svc_RustDeskADClient Log on as a service | |
| - Denies interactive local logon | |
| - Denies RDP logon | |
| - Grants NTFS permissions | |
| - Adds HttpListener URL ACLs | |
| - Builds a native .NET Windows service wrapper and registers/recreates a service running: | |
| RustDeskADClientServiceWrapper.exe | |
| The wrapper launches: | |
| RustDeskADClient.exe -NoOpen -NoHeartBeat | |
| Important: | |
| RustDeskADClient.exe does not need to be service-aware. This script compiles | |
| a small native .NET ServiceBase wrapper that talks to SCM and launches | |
| RustDeskADClient.exe as a child process. | |
| Run as: | |
| - Schema Admin / Enterprise Admin for schema changes | |
| - Domain Admin or delegated equivalent for OU ACL changes | |
| - Local admin on the machine where the service is registered | |
| #> | |
| param( | |
| [string]$ServiceSam = "svc_RustDeskADClient", | |
| [string]$ServiceDisplayName = "RustDeskAD Client Service Runner", | |
| [string]$GroupName = "RustDeskAD_ClientOperators", | |
| [string]$ServiceAccountOuDn = "OU=NETLABWORK SERVICES,DC=netlabwork,DC=us", | |
| [string]$TargetComputerOuDn = "OU=Workstations,DC=netlabwork,DC=us", | |
| [string]$RustDeskADClientExe = "C:\Program Files\RustDeskADClient\RustDeskADClient.exe", | |
| [string]$RustDeskADWorkingDir = "C:\Program Files\RustDeskADClient", | |
| [string]$RustDeskADDataDir = "C:\ProgramData\RustDeskAD", | |
| [string]$RustDeskADArgs = "-NoOpen -NoHeartBeat", | |
| [string]$WrapperExePath = "C:\Program Files\RustDeskADClient\RustDeskADClientServiceWrapper.exe", | |
| [string]$ServiceName = "RustDeskADClient", | |
| [string]$LocalServiceDisplayName = "RustDeskAD Client", | |
| [string]$LocalServiceDescription = "Runs RustDeskADClient headless for RustDeskAD web UI/API.", | |
| # Microsoft-style generated OID root commonly used for lab/custom AD schema extensions. | |
| # For production, replace this with your owned enterprise OID root. | |
| [string]$EnterpriseOidRoot = "1.2.840.113556.1.8000.2554", | |
| # Marks RustDeskAD attributes confidential so normal Authenticated Users cannot casually read them. | |
| # The script grants ExtendedRight to SELF and RustDeskAD_ClientOperators for these attributes. | |
| [switch]$MakeAttributesConfidential = $true | |
| ) | |
| function Write-Ok { | |
| param([string]$Message) | |
| Write-Host "[OK] $Message" -ForegroundColor Green | |
| } | |
| function Write-Info { | |
| param([string]$Message) | |
| Write-Host "[INFO] $Message" -ForegroundColor Cyan | |
| } | |
| function Write-Warn { | |
| param([string]$Message) | |
| Write-Host "[WARN] $Message" -ForegroundColor Yellow | |
| } | |
| function Die { | |
| param([string]$Message) | |
| Write-Host "[FAIL] $Message" -ForegroundColor Red | |
| exit 1 | |
| } | |
| function Get-PlainTextPasswordFromCredential { | |
| param( | |
| [Parameter(Mandatory)] | |
| [pscredential]$Credential | |
| ) | |
| return $Credential.GetNetworkCredential().Password | |
| } | |
| function New-RandomSchemaOid { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$Root | |
| ) | |
| $Guid = [Guid]::NewGuid().ToByteArray() | |
| $Parts = @( | |
| [BitConverter]::ToUInt32($Guid, 0) | |
| [BitConverter]::ToUInt32($Guid, 4) | |
| [BitConverter]::ToUInt32($Guid, 8) | |
| [BitConverter]::ToUInt32($Guid, 12) | |
| ) | |
| return "$Root." + ($Parts -join ".") | |
| } | |
| function Get-SchemaNamingContext { | |
| $RootDse = [ADSI]"LDAP://RootDSE" | |
| return [string]$RootDse.schemaNamingContext | |
| } | |
| function Invoke-SchemaUpdateNow { | |
| Write-Info "Requesting schema cache refresh" | |
| $RootDse = [ADSI]"LDAP://RootDSE" | |
| $RootDse.Put("schemaUpdateNow", 1) | |
| $RootDse.SetInfo() | |
| Start-Sleep -Seconds 3 | |
| Write-Ok "Schema cache refresh requested" | |
| } | |
| function Get-ADAttributeSchemaObject { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$LdapDisplayName | |
| ) | |
| $SchemaNc = Get-SchemaNamingContext | |
| $Searcher = New-Object DirectoryServices.DirectorySearcher | |
| $Searcher.SearchRoot = [ADSI]"LDAP://$SchemaNc" | |
| $Searcher.Filter = "(&(objectClass=attributeSchema)(lDAPDisplayName=$LdapDisplayName))" | |
| $Searcher.PropertiesToLoad.Add("distinguishedName") | Out-Null | |
| $Searcher.PropertiesToLoad.Add("schemaIDGUID") | Out-Null | |
| $Searcher.PropertiesToLoad.Add("searchFlags") | Out-Null | |
| $Searcher.PropertiesToLoad.Add("attributeID") | Out-Null | |
| return $Searcher.FindOne() | |
| } | |
| function Get-ADAttributeSchemaGuid { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$LdapDisplayName | |
| ) | |
| $Result = Get-ADAttributeSchemaObject -LdapDisplayName $LdapDisplayName | |
| if (-not $Result) { | |
| throw "Attribute schema not found: $LdapDisplayName" | |
| } | |
| return New-Object Guid (,$Result.Properties["schemaidguid"][0]) | |
| } | |
| function Ensure-RustDeskADSchemaAttribute { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$LdapDisplayName, | |
| [int]$RangeUpper = 262144 | |
| ) | |
| $Existing = Get-ADAttributeSchemaObject -LdapDisplayName $LdapDisplayName | |
| if ($Existing) { | |
| Write-Ok "Schema attribute already exists: $LdapDisplayName" | |
| if ($Existing.Properties["attributeid"].Count -gt 0) { | |
| Write-Info "$LdapDisplayName OID: $($Existing.Properties["attributeid"][0])" | |
| } | |
| if ($MakeAttributesConfidential) { | |
| $Dn = [string]$Existing.Properties["distinguishedname"][0] | |
| $AttrObj = [ADSI]"LDAP://$Dn" | |
| $CurrentSearchFlags = 0 | |
| try { | |
| $CurrentSearchFlags = [int]$AttrObj.Get("searchFlags") | |
| } | |
| catch { | |
| $CurrentSearchFlags = 0 | |
| } | |
| $ConfidentialFlag = 128 | |
| if (($CurrentSearchFlags -band $ConfidentialFlag) -eq 0) { | |
| $NewSearchFlags = $CurrentSearchFlags -bor $ConfidentialFlag | |
| $AttrObj.Put("searchFlags", $NewSearchFlags) | |
| $AttrObj.SetInfo() | |
| Write-Ok "Marked existing attribute confidential: $LdapDisplayName" | |
| } | |
| else { | |
| Write-Ok "Attribute already confidential: $LdapDisplayName" | |
| } | |
| } | |
| return | |
| } | |
| $SchemaNc = Get-SchemaNamingContext | |
| $Schema = [ADSI]"LDAP://$SchemaNc" | |
| $Oid = New-RandomSchemaOid -Root $EnterpriseOidRoot | |
| Write-Info "Creating schema attribute: $LdapDisplayName" | |
| Write-Info "OID: $Oid" | |
| $Attr = $Schema.Create("attributeSchema", "CN=$LdapDisplayName") | |
| $Attr.Put("lDAPDisplayName", $LdapDisplayName) | |
| $Attr.Put("adminDisplayName", $LdapDisplayName) | |
| $Attr.Put("adminDescription", "RustDeskAD custom attribute: $LdapDisplayName") | |
| $Attr.Put("attributeID", $Oid) | |
| # Unicode string syntax. | |
| $Attr.Put("attributeSyntax", "2.5.5.12") | |
| $Attr.Put("oMSyntax", 64) | |
| $Attr.Put("isSingleValued", $true) | |
| $Attr.Put("rangeUpper", $RangeUpper) | |
| if ($MakeAttributesConfidential) { | |
| # 128 = CONFIDENTIAL. | |
| # Requires explicit CONTROL_ACCESS / ExtendedRight to read. | |
| $Attr.Put("searchFlags", 128) | |
| } | |
| else { | |
| $Attr.Put("searchFlags", 0) | |
| } | |
| $Attr.SetInfo() | |
| Write-Ok "Created schema attribute: $LdapDisplayName" | |
| } | |
| function Ensure-AttributesOnComputerClass { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string[]]$Attributes | |
| ) | |
| $SchemaNc = Get-SchemaNamingContext | |
| $Searcher = New-Object DirectoryServices.DirectorySearcher | |
| $Searcher.SearchRoot = [ADSI]"LDAP://$SchemaNc" | |
| $Searcher.Filter = "(&(objectClass=classSchema)(lDAPDisplayName=computer))" | |
| $Searcher.PropertiesToLoad.Add("distinguishedName") | Out-Null | |
| $Searcher.PropertiesToLoad.Add("mayContain") | Out-Null | |
| $Result = $Searcher.FindOne() | |
| if (-not $Result) { | |
| throw "Could not find computer classSchema object" | |
| } | |
| $ComputerClassDn = [string]$Result.Properties["distinguishedname"][0] | |
| $ComputerClass = [ADSI]"LDAP://$ComputerClassDn" | |
| $Current = @() | |
| try { | |
| $Current = @($ComputerClass.GetEx("mayContain")) | |
| } | |
| catch { | |
| $Current = @() | |
| } | |
| $Missing = @() | |
| foreach ($Attr in $Attributes) { | |
| if ($Current -notcontains $Attr) { | |
| $Missing += $Attr | |
| } | |
| } | |
| if ($Missing.Count -eq 0) { | |
| Write-Ok "Computer class already allows all RustDeskAD attributes" | |
| return | |
| } | |
| Write-Info "Adding missing RustDeskAD attributes to computer class mayContain: $($Missing -join ', ')" | |
| # ADS_PROPERTY_APPEND = 3 | |
| $ComputerClass.PutEx(3, "mayContain", $Missing) | |
| $ComputerClass.SetInfo() | |
| Write-Ok "Updated computer class mayContain" | |
| } | |
| function Grant-UserRight { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$Account, | |
| [Parameter(Mandatory)] | |
| [string]$Right, | |
| [switch]$Required | |
| ) | |
| $Temp = Join-Path $env:TEMP "userrights_$([guid]::NewGuid().ToString('N'))" | |
| New-Item -ItemType Directory -Path $Temp -Force | Out-Null | |
| $ExportInf = Join-Path $Temp "export.inf" | |
| $ImportInf = Join-Path $Temp "import.inf" | |
| $Db = Join-Path $Temp "rights.sdb" | |
| try { | |
| secedit /export /cfg $ExportInf /quiet | Out-Null | |
| if ($LASTEXITCODE -ne 0 -or -not (Test-Path $ExportInf)) { | |
| throw "Failed to export local security policy. Exit code: $LASTEXITCODE" | |
| } | |
| $Sid = (New-Object System.Security.Principal.NTAccount($Account)).Translate([System.Security.Principal.SecurityIdentifier]).Value | |
| $SidEntry = "*$Sid" | |
| $Lines = Get-Content $ExportInf | |
| $Output = New-Object System.Collections.Generic.List[string] | |
| $InPrivilegeRights = $false | |
| $PrivilegeSectionFound = $false | |
| $RightFound = $false | |
| foreach ($Line in $Lines) { | |
| if ($Line -match '^\[Privilege Rights\]') { | |
| $InPrivilegeRights = $true | |
| $PrivilegeSectionFound = $true | |
| $Output.Add($Line) | |
| continue | |
| } | |
| if ($InPrivilegeRights -and $Line -match '^\[') { | |
| if (-not $RightFound) { | |
| $Output.Add("$Right = $SidEntry") | |
| $RightFound = $true | |
| } | |
| $InPrivilegeRights = $false | |
| $Output.Add($Line) | |
| continue | |
| } | |
| if ($InPrivilegeRights -and $Line -match "^\s*$([regex]::Escape($Right))\s*=") { | |
| $ExistingValue = ($Line -split "=", 2)[1].Trim() | |
| $Entries = @() | |
| if ($ExistingValue) { | |
| $Entries = $ExistingValue -split "," | | |
| ForEach-Object { $_.Trim() } | | |
| Where-Object { $_ } | |
| } | |
| if ($Entries -notcontains $SidEntry) { | |
| $Entries += $SidEntry | |
| } | |
| $Output.Add("$Right = " + ($Entries -join ",")) | |
| $RightFound = $true | |
| continue | |
| } | |
| $Output.Add($Line) | |
| } | |
| if (-not $PrivilegeSectionFound) { | |
| $Output.Add("") | |
| $Output.Add("[Privilege Rights]") | |
| $Output.Add("$Right = $SidEntry") | |
| $RightFound = $true | |
| } | |
| elseif ($InPrivilegeRights -and -not $RightFound) { | |
| $Output.Add("$Right = $SidEntry") | |
| $RightFound = $true | |
| } | |
| $Output | Set-Content -Path $ImportInf -Encoding Unicode | |
| secedit /configure /db $Db /cfg $ImportInf /areas USER_RIGHTS /quiet | Out-Null | |
| if ($LASTEXITCODE -ne 0) { | |
| $Msg = "Failed granting $Right to $Account. secedit failed with exit code $LASTEXITCODE" | |
| if ($Required) { | |
| Die $Msg | |
| } | |
| else { | |
| Write-Warn $Msg | |
| Write-Warn "Continuing because $Right is hardening, not required for service startup." | |
| return $false | |
| } | |
| } | |
| Write-Ok "Granted $Right to $Account" | |
| return $true | |
| } | |
| catch { | |
| $Msg = "Failed granting $Right to $Account. $($_.Exception.Message)" | |
| if ($Required) { | |
| Die $Msg | |
| } | |
| else { | |
| Write-Warn $Msg | |
| Write-Warn "Continuing because $Right is hardening, not required for service startup." | |
| return $false | |
| } | |
| } | |
| finally { | |
| Remove-Item $Temp -Recurse -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| function Set-RustDeskADDelegation { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$OuDn, | |
| [Parameter(Mandatory)] | |
| [string]$OperatorGroupName | |
| ) | |
| Write-Info "Applying non-stacking RustDeskAD delegation on: $OuDn" | |
| $OperatorGroup = Get-ADGroup -Identity $OperatorGroupName -ErrorAction Stop | |
| $OperatorSid = New-Object System.Security.Principal.SecurityIdentifier($OperatorGroup.SID.Value) | |
| # SELF principal. | |
| $SelfSid = New-Object System.Security.Principal.SecurityIdentifier("S-1-5-10") | |
| $Ou = [ADSI]"LDAP://$OuDn" | |
| $Security = $Ou.ObjectSecurity | |
| $Rights = [System.DirectoryServices.ActiveDirectoryRights] | |
| $Allow = [System.Security.AccessControl.AccessControlType]::Allow | |
| # For operator group we use All descendants to avoid inheritance/class filtering issues. | |
| $AllInheritance = [System.DirectoryServices.ActiveDirectorySecurityInheritance]::All | |
| # For SELF we scope to descendant computer objects. | |
| $DescendentsInheritance = [System.DirectoryServices.ActiveDirectorySecurityInheritance]::Descendents | |
| $ComputerClassGuid = [Guid]"bf967a86-0de6-11d0-a285-00aa003049e2" | |
| $Attrs = @( | |
| "rustDeskUri", | |
| "rustDeskPass", | |
| "rustDeskCommand", | |
| "rustDeskMetadata" | |
| ) | |
| $AttrGuids = @{} | |
| foreach ($Attr in $Attrs) { | |
| $AttrGuids[$Attr] = Get-ADAttributeSchemaGuid -LdapDisplayName $Attr | |
| Write-Ok "$Attr GUID: $($AttrGuids[$Attr])" | |
| } | |
| # Explicit ACEs only. Avoid touching inherited parent rules. | |
| $Rules = $Security.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | |
| $Removed = 0 | |
| foreach ($Rule in $Rules) { | |
| $IsManagedPrincipal = ( | |
| $Rule.IdentityReference -eq $OperatorSid -or | |
| $Rule.IdentityReference -eq $SelfSid | |
| ) | |
| if (-not $IsManagedPrincipal) { | |
| continue | |
| } | |
| $Remove = $false | |
| # Remove prior generic visibility rules for the operator group only. | |
| if ( | |
| $Rule.IdentityReference -eq $OperatorSid -and | |
| $Rule.ObjectType -eq [Guid]::Empty -and | |
| ( | |
| ($Rule.ActiveDirectoryRights -band $Rights::ListChildren) -or | |
| ($Rule.ActiveDirectoryRights -band $Rights::ReadProperty) | |
| ) | |
| ) { | |
| $Remove = $true | |
| } | |
| # Remove RustDeskAD attribute-specific rules for operator group or SELF. | |
| foreach ($Guid in $AttrGuids.Values) { | |
| if ($Rule.ObjectType -eq $Guid) { | |
| $Remove = $true | |
| break | |
| } | |
| } | |
| if ($Remove) { | |
| [void]$Security.RemoveAccessRuleSpecific($Rule) | |
| $Removed++ | |
| } | |
| } | |
| Write-Ok "Removed $Removed existing RustDeskAD-managed ACE(s)" | |
| # Operator group basic visibility/search. | |
| $BasicRights = $Rights::ListChildren -bor $Rights::ReadProperty | |
| $OperatorBasicRule = New-Object System.DirectoryServices.ActiveDirectoryAccessRule( | |
| $OperatorSid, | |
| $BasicRights, | |
| $Allow, | |
| [Guid]::Empty, | |
| $AllInheritance | |
| ) | |
| [void]$Security.AddAccessRule($OperatorBasicRule) | |
| Write-Ok "Added operator basic list/read visibility" | |
| # Operator group reads all four. | |
| foreach ($Attr in $Attrs) { | |
| $ReadRights = $Rights::ReadProperty -bor $Rights::ExtendedRight | |
| $Rule = New-Object System.DirectoryServices.ActiveDirectoryAccessRule( | |
| $OperatorSid, | |
| $ReadRights, | |
| $Allow, | |
| $AttrGuids[$Attr], | |
| $AllInheritance | |
| ) | |
| [void]$Security.AddAccessRule($Rule) | |
| Write-Ok "Added operator READ + EXTENDEDRIGHT for $Attr" | |
| } | |
| # Operator group writes command/pass. | |
| foreach ($Attr in @("rustDeskCommand", "rustDeskPass")) { | |
| $WriteRights = $Rights::WriteProperty | |
| $Rule = New-Object System.DirectoryServices.ActiveDirectoryAccessRule( | |
| $OperatorSid, | |
| $WriteRights, | |
| $Allow, | |
| $AttrGuids[$Attr], | |
| $AllInheritance | |
| ) | |
| [void]$Security.AddAccessRule($Rule) | |
| Write-Ok "Added operator WRITE for $Attr" | |
| } | |
| # Computers / SELF read-write their own RustDeskAD attributes. | |
| foreach ($Attr in $Attrs) { | |
| $SelfRights = $Rights::ReadProperty -bor $Rights::WriteProperty -bor $Rights::ExtendedRight | |
| $Rule = New-Object System.DirectoryServices.ActiveDirectoryAccessRule( | |
| $SelfSid, | |
| $SelfRights, | |
| $Allow, | |
| $AttrGuids[$Attr], | |
| $DescendentsInheritance, | |
| $ComputerClassGuid | |
| ) | |
| [void]$Security.AddAccessRule($Rule) | |
| Write-Ok "Added computer SELF READ/WRITE + EXTENDEDRIGHT for $Attr" | |
| } | |
| $Ou.ObjectSecurity = $Security | |
| $Ou.CommitChanges() | |
| Write-Ok "Committed RustDeskAD delegation without dsacls stacking" | |
| } | |
| function Ensure-HttpUrlAcl { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$Url, | |
| [Parameter(Mandatory)] | |
| [string]$Account | |
| ) | |
| $Existing = netsh http show urlacl url=$Url 2>$null | |
| if ($Existing -match [regex]::Escape($Url)) { | |
| if ($Existing -match [regex]::Escape($Account)) { | |
| Write-Ok "URL ACL already exists for $Url -> $Account" | |
| } | |
| else { | |
| Write-Warn "URL ACL already exists for $Url, but not clearly for $Account" | |
| Write-Warn "Not replacing existing URL ACL automatically" | |
| } | |
| return | |
| } | |
| netsh http add urlacl url=$Url user="$Account" | Out-Null | |
| if ($LASTEXITCODE -eq 0) { | |
| Write-Ok "Added URL ACL: $Url -> $Account" | |
| } | |
| else { | |
| Write-Warn "Failed to add URL ACL: $Url" | |
| } | |
| } | |
| function New-RustDeskADServiceWrapper { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$WrapperExePath, | |
| [Parameter(Mandatory)] | |
| [string]$ClientExePath, | |
| [Parameter(Mandatory)] | |
| [string]$ClientArguments, | |
| [Parameter(Mandatory)] | |
| [string]$WorkingDirectory, | |
| [Parameter(Mandatory)] | |
| [string]$DataDirectory, | |
| [Parameter(Mandatory)] | |
| [string]$ServiceName | |
| ) | |
| Write-Info "Creating native Windows service wrapper" | |
| Write-Info "Wrapper EXE: $WrapperExePath" | |
| Write-Info "Client EXE: $ClientExePath" | |
| Write-Info "Client Args: $ClientArguments" | |
| $WrapperDir = Split-Path -Parent $WrapperExePath | |
| New-Item -ItemType Directory -Path $WrapperDir -Force | Out-Null | |
| New-Item -ItemType Directory -Path $DataDirectory -Force | Out-Null | |
| $SourcePath = Join-Path $WrapperDir "RustDeskADClientServiceWrapper.cs" | |
| $EscapedServiceName = $ServiceName.Replace('\', '\\').Replace('"', '\"') | |
| $EscapedClientExe = $ClientExePath.Replace('\', '\\').Replace('"', '\"') | |
| $EscapedClientArgs = $ClientArguments.Replace('\', '\\').Replace('"', '\"') | |
| $EscapedWorkingDir = $WorkingDirectory.Replace('\', '\\').Replace('"', '\"') | |
| $EscapedDataDir = $DataDirectory.Replace('\', '\\').Replace('"', '\"') | |
| $Source = @" | |
| using System; | |
| using System.Diagnostics; | |
| using System.IO; | |
| using System.ServiceProcess; | |
| public class RustDeskADClientServiceWrapper : ServiceBase | |
| { | |
| private Process childProcess; | |
| private readonly object syncRoot = new object(); | |
| private const string WrappedServiceName = "$EscapedServiceName"; | |
| private const string ClientExe = "$EscapedClientExe"; | |
| private const string ClientArgs = "$EscapedClientArgs"; | |
| private const string WorkingDir = "$EscapedWorkingDir"; | |
| private const string DataDir = "$EscapedDataDir"; | |
| public RustDeskADClientServiceWrapper() | |
| { | |
| ServiceName = WrappedServiceName; | |
| CanStop = true; | |
| CanShutdown = true; | |
| CanPauseAndContinue = false; | |
| AutoLog = true; | |
| } | |
| protected override void OnStart(string[] args) | |
| { | |
| // Never throw back to Service Control Manager. | |
| // The wrapper should report service start success even if the child launch fails. | |
| try | |
| { | |
| Directory.CreateDirectory(DataDir); | |
| Log("service wrapper starting"); | |
| StartChild(); | |
| Log("service wrapper OnStart returning success"); | |
| } | |
| catch (Exception ex) | |
| { | |
| Log("OnStart swallowed startup error and is still returning success: " + ex.ToString()); | |
| } | |
| } | |
| protected override void OnStop() | |
| { | |
| Log("service wrapper stopping"); | |
| StopChild(); | |
| } | |
| protected override void OnShutdown() | |
| { | |
| Log("service wrapper shutdown"); | |
| StopChild(); | |
| } | |
| private static void Log(string message) | |
| { | |
| try | |
| { | |
| Directory.CreateDirectory(DataDir); | |
| File.AppendAllText(Path.Combine(DataDir, "service-wrapper.log"), DateTime.Now.ToString("s") + " " + message + Environment.NewLine); | |
| } | |
| catch { } | |
| } | |
| private static void LogLine(string fileName, string line) | |
| { | |
| try | |
| { | |
| Directory.CreateDirectory(DataDir); | |
| File.AppendAllText(Path.Combine(DataDir, fileName), DateTime.Now.ToString("s") + " " + line + Environment.NewLine); | |
| } | |
| catch { } | |
| } | |
| private void StartChild() | |
| { | |
| try | |
| { | |
| lock (syncRoot) | |
| { | |
| if (childProcess != null && !childProcess.HasExited) | |
| { | |
| Log("child already running PID " + childProcess.Id); | |
| return; | |
| } | |
| if (!File.Exists(ClientExe)) | |
| { | |
| Log("child launch skipped because client EXE does not exist: " + ClientExe); | |
| return; | |
| } | |
| if (!Directory.Exists(WorkingDir)) | |
| { | |
| Log("working directory does not exist, creating: " + WorkingDir); | |
| Directory.CreateDirectory(WorkingDir); | |
| } | |
| var psi = new ProcessStartInfo(); | |
| psi.FileName = ClientExe; | |
| psi.Arguments = ClientArgs; | |
| psi.WorkingDirectory = WorkingDir; | |
| psi.UseShellExecute = false; | |
| psi.CreateNoWindow = true; | |
| psi.RedirectStandardOutput = true; | |
| psi.RedirectStandardError = true; | |
| childProcess = new Process(); | |
| childProcess.StartInfo = psi; | |
| childProcess.EnableRaisingEvents = true; | |
| childProcess.OutputDataReceived += (sender, e) => | |
| { | |
| if (e.Data != null) LogLine("service-wrapper.stdout.log", e.Data); | |
| }; | |
| childProcess.ErrorDataReceived += (sender, e) => | |
| { | |
| if (e.Data != null) LogLine("service-wrapper.stderr.log", e.Data); | |
| }; | |
| childProcess.Exited += (sender, e) => | |
| { | |
| try | |
| { | |
| Log("child exited with code " + childProcess.ExitCode); | |
| } | |
| catch | |
| { | |
| Log("child exited"); | |
| } | |
| }; | |
| bool started = childProcess.Start(); | |
| if (!started) | |
| { | |
| Log("childProcess.Start returned false; wrapper will still remain running"); | |
| return; | |
| } | |
| childProcess.BeginOutputReadLine(); | |
| childProcess.BeginErrorReadLine(); | |
| Log("child started PID " + childProcess.Id); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| Log("StartChild swallowed launch error; wrapper stays running: " + ex.ToString()); | |
| } | |
| } | |
| private void StopChild() | |
| { | |
| lock (syncRoot) | |
| { | |
| if (childProcess == null) | |
| { | |
| Log("no child process to stop"); | |
| return; | |
| } | |
| try | |
| { | |
| if (!childProcess.HasExited) | |
| { | |
| Log("stopping child PID " + childProcess.Id); | |
| try { childProcess.CloseMainWindow(); } catch { } | |
| if (!childProcess.WaitForExit(5000)) | |
| { | |
| Log("child did not exit gracefully, killing PID " + childProcess.Id); | |
| childProcess.Kill(); | |
| childProcess.WaitForExit(10000); | |
| } | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| Log("stop error: " + ex.ToString()); | |
| } | |
| finally | |
| { | |
| childProcess.Dispose(); | |
| childProcess = null; | |
| } | |
| } | |
| } | |
| public static void Main(string[] args) | |
| { | |
| ServiceBase.Run(new RustDeskADClientServiceWrapper()); | |
| } | |
| } | |
| "@ | |
| Set-Content -Path $SourcePath -Value $Source -Encoding UTF8 | |
| $CscCandidates = @( | |
| "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe", | |
| "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" | |
| ) | |
| $Csc = $CscCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 | |
| if (-not $Csc) { | |
| Die "Could not find csc.exe. Install .NET Framework 4.x developer/build components or switch to Scheduled Task/NSSM/WinSW." | |
| } | |
| $CompileArgs = @( | |
| "/nologo", | |
| "/target:exe", | |
| "/platform:anycpu", | |
| "/out:$WrapperExePath", | |
| "/reference:System.ServiceProcess.dll", | |
| "/reference:System.dll", | |
| $SourcePath | |
| ) | |
| & $Csc @CompileArgs | |
| if ($LASTEXITCODE -ne 0 -or -not (Test-Path $WrapperExePath)) { | |
| Die "Failed compiling service wrapper" | |
| } | |
| Write-Ok "Compiled service wrapper: $WrapperExePath" | |
| } | |
| function Register-RustDeskADWindowsService { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$Name, | |
| [Parameter(Mandatory)] | |
| [string]$DisplayName, | |
| [Parameter(Mandatory)] | |
| [string]$Description, | |
| [Parameter(Mandatory)] | |
| [string]$ExePath, | |
| [Parameter(Mandatory)] | |
| [AllowEmptyString()] | |
| [string]$Arguments, | |
| [Parameter(Mandatory)] | |
| [string]$RunAsAccount, | |
| [Parameter(Mandatory)] | |
| [pscredential]$Credential | |
| ) | |
| if (-not (Test-Path $ExePath)) { | |
| Write-Warn "Service EXE not found: $ExePath" | |
| Write-Warn "Service registration will still be attempted, but it cannot start until the EXE exists" | |
| } | |
| Write-Info "Always deleting existing service before recreation: $Name" | |
| try { | |
| Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue | |
| } | |
| catch { | |
| Write-Warn "Stop-Service failed or service was already stopped: $($_.Exception.Message)" | |
| } | |
| sc.exe delete $Name | Out-Null | |
| $DeleteExitCode = $LASTEXITCODE | |
| if ($DeleteExitCode -eq 0) { | |
| Write-Ok "Deleted existing service: $Name" | |
| } | |
| elseif ($DeleteExitCode -eq 1060) { | |
| Write-Ok "Service did not exist, nothing to delete: $Name" | |
| } | |
| else { | |
| Write-Warn "sc.exe delete returned exit code $DeleteExitCode for $Name" | |
| Write-Warn "Continuing, but service creation may fail if the old service is pending deletion." | |
| } | |
| Start-Sleep -Seconds 3 | |
| $StillExists = Get-Service -Name $Name -ErrorAction SilentlyContinue | |
| if ($StillExists) { | |
| Write-Warn "Service still appears present after delete. Waiting for SCM to release it..." | |
| Start-Sleep -Seconds 5 | |
| } | |
| $StillExists = Get-Service -Name $Name -ErrorAction SilentlyContinue | |
| if ($StillExists) { | |
| Die "Service $Name still exists after delete attempt. Close services.msc/service handles and rerun." | |
| } | |
| $BinaryPath = "`"$ExePath`" $Arguments".Trim() | |
| Write-Info "Creating Windows service: $Name" | |
| Write-Info "Binary path: $BinaryPath" | |
| Write-Info "Run as: $RunAsAccount" | |
| New-Service ` | |
| -Name $Name ` | |
| -BinaryPathName $BinaryPath ` | |
| -DisplayName $DisplayName ` | |
| -Description $Description ` | |
| -StartupType Automatic ` | |
| -Credential $Credential ` | |
| -ErrorAction Stop | |
| Write-Ok "Created service: $Name" | |
| sc.exe failure $Name reset= 60 actions= restart/60000/restart/60000/restart/60000 | Out-Null | |
| if ($LASTEXITCODE -eq 0) { | |
| Write-Ok "Configured service restart-on-failure actions" | |
| } | |
| else { | |
| Write-Warn "Failed to configure service failure actions" | |
| } | |
| } | |
| Write-Info "Loading Active Directory module" | |
| try { | |
| Import-Module ActiveDirectory -ErrorAction Stop | |
| } | |
| catch { | |
| Die "Failed to load ActiveDirectory module. Install RSAT AD tools or run on a DC. $($_.Exception.Message)" | |
| } | |
| $Domain = Get-ADDomain | |
| $DomainNetbios = $Domain.NetBIOSName | |
| $DomainDns = $Domain.DNSRoot | |
| $ServiceUpn = "$ServiceSam@$DomainDns" | |
| $ServiceAccountFull = "$DomainNetbios\$ServiceSam" | |
| Write-Info "Domain DNS: $DomainDns" | |
| Write-Info "Domain NetBIOS: $DomainNetbios" | |
| Write-Info "Service account: $ServiceAccountFull" | |
| Write-Info "Operator group: $DomainNetbios\$GroupName" | |
| Write-Info "Service account OU: $ServiceAccountOuDn" | |
| Write-Info "Target computer OU: $TargetComputerOuDn" | |
| try { | |
| Get-ADOrganizationalUnit -Identity $ServiceAccountOuDn -ErrorAction Stop | Out-Null | |
| Write-Ok "Service account OU exists" | |
| } | |
| catch { | |
| Die "Service account OU not found: $ServiceAccountOuDn" | |
| } | |
| try { | |
| Get-ADOrganizationalUnit -Identity $TargetComputerOuDn -ErrorAction Stop | Out-Null | |
| Write-Ok "Target computer OU exists" | |
| } | |
| catch { | |
| Die "Target computer OU not found: $TargetComputerOuDn" | |
| } | |
| $SchemaMaster = (Get-ADForest).SchemaMaster | |
| $LocalComputerFqdn = ([System.Net.Dns]::GetHostEntry($env:COMPUTERNAME)).HostName | |
| Write-Info "Schema Master: $SchemaMaster" | |
| Write-Info "Local Host FQDN: $LocalComputerFqdn" | |
| if ($SchemaMaster.ToLower() -ne $LocalComputerFqdn.ToLower()) { | |
| Write-Warn "You are not running on the Schema Master." | |
| Write-Warn "Schema writes may still work through AD, but running directly on the Schema Master is safest." | |
| } | |
| # ------------------------------------------------------------------------- | |
| # Schema creation | |
| # ------------------------------------------------------------------------- | |
| $RustDeskAttributes = @( | |
| "rustDeskUri", | |
| "rustDeskPass", | |
| "rustDeskCommand", | |
| "rustDeskMetadata" | |
| ) | |
| Write-Info "Ensuring RustDeskAD schema attributes exist" | |
| foreach ($Attr in $RustDeskAttributes) { | |
| Ensure-RustDeskADSchemaAttribute -LdapDisplayName $Attr -RangeUpper 262144 | |
| } | |
| Invoke-SchemaUpdateNow | |
| Write-Info "Ensuring RustDeskAD attributes are allowed on computer objects" | |
| Ensure-AttributesOnComputerClass -Attributes $RustDeskAttributes | |
| Invoke-SchemaUpdateNow | |
| # ------------------------------------------------------------------------- | |
| # Group creation | |
| # ------------------------------------------------------------------------- | |
| $Group = Get-ADGroup -Filter "SamAccountName -eq '$GroupName'" -ErrorAction SilentlyContinue | |
| if (-not $Group) { | |
| Write-Info "Creating group: $GroupName" | |
| New-ADGroup ` | |
| -Name $GroupName ` | |
| -SamAccountName $GroupName ` | |
| -GroupScope Global ` | |
| -GroupCategory Security ` | |
| -Path $ServiceAccountOuDn ` | |
| -Description "RustDeskAD Client operators; read RustDeskAD attrs and write command/pass attrs" ` | |
| -ErrorAction Stop | |
| Write-Ok "Created group: $GroupName" | |
| } | |
| else { | |
| Write-Ok "Group already exists: $GroupName" | |
| } | |
| # ------------------------------------------------------------------------- | |
| # Service account creation | |
| # ------------------------------------------------------------------------- | |
| $User = Get-ADUser -Filter "SamAccountName -eq '$ServiceSam'" -ErrorAction SilentlyContinue | |
| if (-not $User) { | |
| $RuntimeCred = Get-Credential -UserName $ServiceAccountFull -Message "Enter password for new service account $ServiceAccountFull" | |
| Write-Info "Creating service account: $ServiceSam" | |
| New-ADUser ` | |
| -Name $ServiceDisplayName ` | |
| -SamAccountName $ServiceSam ` | |
| -UserPrincipalName $ServiceUpn ` | |
| -DisplayName $ServiceDisplayName ` | |
| -Path $ServiceAccountOuDn ` | |
| -AccountPassword $RuntimeCred.Password ` | |
| -Enabled $true ` | |
| -PasswordNeverExpires $true ` | |
| -CannotChangePassword $true ` | |
| -Description "Runs RustDeskADClient.exe as a least-privilege Windows service" ` | |
| -ErrorAction Stop | |
| Write-Ok "Created service account: $ServiceAccountFull" | |
| } | |
| else { | |
| Write-Ok "Service account already exists: $ServiceAccountFull" | |
| Set-ADUser ` | |
| -Identity $ServiceSam ` | |
| -PasswordNeverExpires $true ` | |
| -Description "Runs RustDeskADClient.exe as a least-privilege Windows service" ` | |
| -ErrorAction Stop | |
| Write-Ok "Updated service account flags" | |
| $RuntimeCred = Get-Credential -UserName $ServiceAccountFull -Message "Enter password for existing service account $ServiceAccountFull" | |
| } | |
| if ($RuntimeCred.UserName -ne $ServiceAccountFull) { | |
| $RuntimeCred = Get-Credential -UserName $ServiceAccountFull -Message "Enter password for $ServiceAccountFull" | |
| } | |
| # Add service account to group. | |
| try { | |
| Add-ADGroupMember ` | |
| -Identity $GroupName ` | |
| -Members $ServiceSam ` | |
| -ErrorAction Stop | |
| Write-Ok "Added $ServiceSam to $GroupName" | |
| } | |
| catch { | |
| if ($_.Exception.Message -match "already a member") { | |
| Write-Ok "$ServiceSam is already a member of $GroupName" | |
| } | |
| else { | |
| Die "Failed adding $ServiceSam to $GroupName. $($_.Exception.Message)" | |
| } | |
| } | |
| # ------------------------------------------------------------------------- | |
| # AD delegation | |
| # ------------------------------------------------------------------------- | |
| try { | |
| Set-RustDeskADDelegation -OuDn $TargetComputerOuDn -OperatorGroupName $GroupName | |
| } | |
| catch { | |
| Die "Failed applying RustDeskAD AD delegation. $($_.Exception.Message)" | |
| } | |
| # ------------------------------------------------------------------------- | |
| # Local runtime | |
| # ------------------------------------------------------------------------- | |
| Write-Info "Configuring local runtime folders and permissions" | |
| New-Item -ItemType Directory -Path $RustDeskADWorkingDir -Force | Out-Null | |
| New-Item -ItemType Directory -Path $RustDeskADDataDir -Force | Out-Null | |
| icacls $RustDeskADWorkingDir /grant "${ServiceAccountFull}:RX" | Out-Null | |
| icacls $RustDeskADDataDir /grant "${ServiceAccountFull}:M" | Out-Null | |
| if (Test-Path $RustDeskADClientExe) { | |
| icacls $RustDeskADClientExe /grant "${ServiceAccountFull}:RX" | Out-Null | |
| Write-Ok "Granted RX to $RustDeskADClientExe" | |
| } | |
| else { | |
| Write-Warn "EXE not found yet: $RustDeskADClientExe" | |
| } | |
| Write-Ok "Configured NTFS permissions" | |
| Write-Info "Configuring local logon rights" | |
| [void](Grant-UserRight -Account $ServiceAccountFull -Right "SeServiceLogonRight" -Required) | |
| [void](Grant-UserRight -Account $ServiceAccountFull -Right "SeDenyInteractiveLogonRight") | |
| [void](Grant-UserRight -Account $ServiceAccountFull -Right "SeDenyRemoteInteractiveLogonRight") | |
| Write-Info "Configuring HttpListener URL ACLs" | |
| Ensure-HttpUrlAcl -Url "http://127.0.0.1:8765/" -Account $ServiceAccountFull | |
| Ensure-HttpUrlAcl -Url "http://localhost:8765/" -Account $ServiceAccountFull | |
| # ------------------------------------------------------------------------- | |
| # Windows service | |
| # ------------------------------------------------------------------------- | |
| Write-Info "Registering/recreating true Windows service" | |
| New-RustDeskADServiceWrapper ` | |
| -WrapperExePath $WrapperExePath ` | |
| -ClientExePath $RustDeskADClientExe ` | |
| -ClientArguments $RustDeskADArgs ` | |
| -WorkingDirectory $RustDeskADWorkingDir ` | |
| -DataDirectory $RustDeskADDataDir ` | |
| -ServiceName $ServiceName | |
| if (Test-Path $WrapperExePath) { | |
| icacls $WrapperExePath /grant "${ServiceAccountFull}:RX" | Out-Null | |
| Write-Ok "Granted RX to $WrapperExePath" | |
| } | |
| Register-RustDeskADWindowsService ` | |
| -Name $ServiceName ` | |
| -DisplayName $LocalServiceDisplayName ` | |
| -Description $LocalServiceDescription ` | |
| -ExePath $WrapperExePath ` | |
| -Arguments "" ` | |
| -RunAsAccount $ServiceAccountFull ` | |
| -Credential $RuntimeCred | |
| Write-Host "" | |
| Write-Host "Final model:" -ForegroundColor Yellow | |
| Write-Host " Schema attributes:" | |
| Write-Host " rustDeskUri" | |
| Write-Host " rustDeskPass" | |
| Write-Host " rustDeskCommand" | |
| Write-Host " rustDeskMetadata" | |
| Write-Host "" | |
| Write-Host " Computer SELF permissions on:" | |
| Write-Host " $TargetComputerOuDn" | |
| Write-Host " Read/Write rustDeskUri" | |
| Write-Host " Read/Write rustDeskPass" | |
| Write-Host " Read/Write rustDeskCommand" | |
| Write-Host " Read/Write rustDeskMetadata" | |
| Write-Host "" | |
| Write-Host " Operator group:" | |
| Write-Host " $DomainNetbios\$GroupName" | |
| Write-Host " Read rustDeskUri" | |
| Write-Host " Read rustDeskPass" | |
| Write-Host " Read rustDeskCommand" | |
| Write-Host " Read rustDeskMetadata" | |
| Write-Host " Write rustDeskCommand" | |
| Write-Host " Write rustDeskPass" | |
| Write-Host "" | |
| Write-Host " Service account:" | |
| Write-Host " $ServiceAccountFull" | |
| Write-Host " Member of $DomainNetbios\$GroupName" | |
| Write-Host "" | |
| Write-Host " Windows service:" | |
| Write-Host " Name: $ServiceName" | |
| Write-Host " Runs as: $ServiceAccountFull" | |
| Write-Host " Service Binary: `"$WrapperExePath`"" | |
| Write-Host " Child Client: `"$RustDeskADClientExe`" $RustDeskADArgs" | |
| Write-Host "" | |
| Write-Host "Done. No dsacls used, no dsacls stacking. Service is recreated on re-execution." -ForegroundColor Green |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| RustDesk AD metadata agent / publisher | |
| Purpose: | |
| - Publishes rustDeskUri and rustDeskMetadata onto this computer's own AD object. | |
| - Intended to run as SYSTEM / computer account. | |
| - EXE-friendly for ps12exe: supports one-shot, long-running agent loop, and single-instance lock. | |
| Required AD ACL: | |
| Computer SELF should have write-property on: | |
| rustDeskUri | |
| rustDeskMetadata | |
| Examples: | |
| .\rustdesk-metadata-agent.exe | |
| One-shot publish and exit. | |
| .\rustdesk-metadata-agent.exe -Agent | |
| Keep running and refresh metadata every 300 seconds. | |
| .\rustdesk-metadata-agent.exe -Agent -IntervalSeconds 60 | |
| Keep running and refresh every minute. If not already running as the | |
| RustDeskAD Agent service child, this also bootstraps the Windows service | |
| using the same effective interval, then exits so the service owns it. | |
| .\rustdesk-metadata-agent.exe -InstallScheduledTask | |
| Create a SYSTEM scheduled task that runs the EXE at startup in agent mode. | |
| .\rustdesk-metadata-agent.exe -UninstallScheduledTask | |
| Remove the scheduled task. | |
| Notes: | |
| - This does not require the ActiveDirectory PowerShell module. | |
| - Uses System.DirectoryServices and writes only this computer's own object. | |
| - If another copy is already running in agent mode, a new agent instance exits cleanly. | |
| #> | |
| param( | |
| [switch]$Agent, | |
| [Alias('Interval')] | |
| [int]$IntervalSeconds = 300, | |
| [switch]$InstallScheduledTask, | |
| [switch]$UninstallScheduledTask, | |
| [switch]$InstallService, | |
| [switch]$UninstallService, | |
| [string]$TaskName = 'RustDesk Metadata Agent', | |
| [string]$AgentServiceName = 'RustDeskAD - Agent', | |
| [string]$AgentServiceDisplayName = 'RustDeskAD Agent', | |
| [string]$AgentServiceDescription = 'Runs the RustDeskAD metadata agent as LocalSystem and restarts it if the child process exits.', | |
| [string]$AgentServiceWrapperPath = "$env:ProgramData\RustDeskAD\RustDeskADAgentServiceWrapper.exe", | |
| [string]$AgentServiceWrapperSourcePath = "$env:ProgramData\RustDeskAD\RustDeskADAgentServiceWrapper.cs", | |
| [int]$AgentServiceChildRestartSeconds = 1, | |
| # When an existing legacy/scheduled/manual -Agent process starts, it can bootstrap | |
| # the real Windows service using the same effective -Interval it was launched with, | |
| # then exit so the service-owned child becomes the only long-running agent. | |
| [switch]$NoAgentServiceSelfInstall, | |
| # Tamper resistance is enabled by default for the service install/self-install path. | |
| # It hardens the RustDeskAD - Agent service DACL and ProgramData files/folder ACLs. | |
| # This is not stealth and does not make the agent impossible for a determined admin to remove; | |
| # it prevents casual stop/disable/delete/file-removal from local admin tooling. | |
| [switch]$DisableTamperResistance, | |
| [string[]]$TamperAdminGroups = @(), | |
| [string]$TamperRootPath = "$env:ProgramData\RustDeskAD", | |
| # Legit self-repair recovery copies live in ProgramData. C:\Windows\Temp\<computer-uuid> | |
| # is used only as a temporary staging directory while copying/replacing files. | |
| [switch]$DisableRecoveryProtection, | |
| [switch]$RefreshRecoveryCopies, | |
| [string]$RecoveryRootPath = "$env:ProgramData\RustDeskAD\Recovery", | |
| [string]$LogPath = "$env:ProgramData\RustDeskAD\rustdeskad-agent.log", | |
| [int]$MaxPublishRetries = 3, | |
| [int]$RetryDelaySeconds = 10, | |
| [int]$NetworkProbeTimeoutMilliseconds = 2500, | |
| [switch]$NoRustDeskServiceRepair, | |
| [switch]$Quiet, | |
| # Public key only. Agents can encrypt rustDeskPass for AD storage, but cannot decrypt it. | |
| [string]$RustDeskADPublicKeyPath = "$env:ProgramData\RustDeskAD\rustdeskad-public.xml", | |
| # Backward-compatible placeholder. Commands queued in rustDeskCommand are literal commandLine values. | |
| # Keeping this switch avoids breaking existing scheduled-task arguments, but it no longer gates execution. | |
| [switch]$EnableArbitraryCommands | |
| ) | |
| $ErrorActionPreference = 'Stop' | |
| # ----------------------------- | |
| # Static config | |
| # ----------------------------- | |
| $RustDeskPath = Join-Path $env:ProgramFiles 'RustDesk' | |
| $RustDeskExe = Join-Path $RustDeskPath 'rustdesk.exe' | |
| $RustDeskServiceName = 'RustDeskAD - Remote Desktop' | |
| $RustDeskServiceDisplayName = 'RustDeskAD Remote Desktop' | |
| $OfficialRustDeskServiceNames = @('RustDesk', 'Rustdesk') | |
| $OfficialRustDeskServiceDisplayNames = @('RustDesk Service', 'RustDesk') | |
| $MetadataAttribute = 'rustDeskMetadata' | |
| $UriAttribute = 'rustDeskUri' | |
| $PasswordAttribute = 'rustDeskPass' | |
| $CommandAttribute = 'rustDeskCommand' | |
| $PasswordCryptoPrefix = 'RDADENC2:' | |
| $GeneratedPasswordLength = 28 | |
| $MutexName = 'Global\RustDeskADMetadataAgent' | |
| $script:ConsecutivePublishFailures = 0 | |
| $script:RustDeskADAgentServiceWrapperOrphaned = $false | |
| $script:RustDeskADAgentServiceWrapperOrphanRepairCompleted = $false | |
| $script:RustDeskADCachedLdapServer = $null | |
| $script:RustDeskADTamperFileAclLogWritten = $false | |
| $script:RustDeskADRecoveryLogWritten = $false | |
| # AD attributes copied with original names preserved. | |
| $ADAttributes = @( | |
| 'name', | |
| 'sAMAccountName', | |
| 'cn', | |
| 'dNSHostName', | |
| 'operatingSystem', | |
| 'operatingSystemVersion', | |
| 'operatingSystemServicePack', | |
| 'description', | |
| 'distinguishedName', | |
| 'whenCreated', | |
| 'whenChanged', | |
| 'lastLogonTimestamp', | |
| 'pwdLastSet', | |
| 'userAccountControl', | |
| 'location', | |
| 'managedBy', | |
| 'memberOf', | |
| 'info', | |
| 'serialNumber', | |
| 'ms-Mcs-AdmPwdExpirationTime', | |
| 'msLAPS-PasswordExpirationTime', | |
| 'rustDeskUri' | |
| ) | |
| # ----------------------------- | |
| # Logging | |
| # ----------------------------- | |
| function Initialize-LogPath { | |
| try { | |
| $dir = Split-Path -Path $LogPath -Parent | |
| if ($dir -and -not (Test-Path $dir)) { | |
| New-Item -Path $dir -ItemType Directory -Force | Out-Null | |
| } | |
| } | |
| catch {} | |
| } | |
| function Write-AgentLog { | |
| param( | |
| [string]$Message, | |
| [string]$Level = 'INFO' | |
| ) | |
| $line = '{0} [{1}] {2}' -f (Get-Date).ToString('s'), $Level, $Message | |
| if (-not $Quiet) { | |
| Write-Host $line | |
| } | |
| try { | |
| Initialize-LogPath | |
| Add-Content -Path $LogPath -Value $line -Encoding UTF8 | |
| } | |
| catch {} | |
| } | |
| # ----------------------------- | |
| # Scheduled task helpers | |
| # ----------------------------- | |
| function Test-IsPowerShellHostPath { | |
| param([string]$Path) | |
| if ([string]::IsNullOrWhiteSpace($Path)) { return $false } | |
| $fileName = [IO.Path]::GetFileName($Path) | |
| return ($fileName -match '^(powershell|pwsh)(\.exe)?$') | |
| } | |
| function Get-SelfPath { | |
| $mainModulePath = $null | |
| try { | |
| $mainModulePath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName | |
| } | |
| catch {} | |
| # ps12exe compiled mode: the running EXE is the real self path. | |
| if ($mainModulePath -and | |
| [IO.Path]::GetExtension($mainModulePath).Equals('.exe', [StringComparison]::OrdinalIgnoreCase) -and | |
| -not (Test-IsPowerShellHostPath -Path $mainModulePath)) { | |
| return $mainModulePath | |
| } | |
| try { | |
| if ($PSCommandPath) { return $PSCommandPath } | |
| } | |
| catch {} | |
| try { | |
| if ($MyInvocation.MyCommand.Path) { return $MyInvocation.MyCommand.Path } | |
| } | |
| catch {} | |
| if ($mainModulePath) { return $mainModulePath } | |
| throw 'Unable to determine current script/exe path.' | |
| } | |
| function Install-AgentScheduledTask { | |
| $self = Get-SelfPath | |
| $isExe = [IO.Path]::GetExtension($self).Equals('.exe', [StringComparison]::OrdinalIgnoreCase) | |
| if ($isExe) { | |
| $execute = $self | |
| $arguments = '-Agent -Quiet' | |
| } | |
| else { | |
| $execute = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" | |
| $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -Agent -Quiet' -f $self | |
| } | |
| $action = New-ScheduledTaskAction -Execute $execute -Argument $arguments | |
| $trigger = New-ScheduledTaskTrigger -AtStartup | |
| $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest | |
| $settings = New-ScheduledTaskSettingsSet ` | |
| -AllowStartIfOnBatteries ` | |
| -DontStopIfGoingOnBatteries ` | |
| -StartWhenAvailable ` | |
| -ExecutionTimeLimit (New-TimeSpan -Days 0) ` | |
| -RestartCount 999 ` | |
| -RestartInterval (New-TimeSpan -Minutes 1) | |
| Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null | |
| Write-AgentLog "Installed scheduled task '$TaskName' -> $execute $arguments" | |
| } | |
| function Uninstall-AgentScheduledTask { | |
| $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | |
| if ($task) { | |
| Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false | |
| Write-AgentLog "Removed scheduled task '$TaskName'" | |
| } | |
| else { | |
| Write-AgentLog "Scheduled task '$TaskName' not found" | |
| } | |
| } | |
| function ConvertTo-RustDeskADComparablePath { | |
| param([AllowNull()][string]$Path) | |
| if ([string]::IsNullOrWhiteSpace($Path)) { return $null } | |
| try { | |
| $expanded = [Environment]::ExpandEnvironmentVariables([string]$Path).Trim() | |
| $expanded = $expanded.Trim('"') | |
| if ([string]::IsNullOrWhiteSpace($expanded)) { return $null } | |
| return ([IO.Path]::GetFullPath($expanded).TrimEnd('\')).ToLowerInvariant() | |
| } | |
| catch { | |
| try { | |
| return ([string]$Path).Trim().Trim('"').TrimEnd('\').ToLowerInvariant() | |
| } | |
| catch { | |
| return $null | |
| } | |
| } | |
| } | |
| function Get-RustDeskADScheduledTaskActionPaths { | |
| param($Action) | |
| $paths = New-Object System.Collections.Generic.List[string] | |
| if ($null -eq $Action) { return @() } | |
| $execute = $null | |
| $arguments = $null | |
| try { $execute = [string]$Action.Execute } catch {} | |
| try { $arguments = [string]$Action.Arguments } catch {} | |
| if (-not [string]::IsNullOrWhiteSpace($execute)) { | |
| $paths.Add($execute) | Out-Null | |
| } | |
| if (-not [string]::IsNullOrWhiteSpace($arguments)) { | |
| # Common scheduled-task forms: | |
| # powershell.exe -File "C:\ProgramData\RustDeskAD\RustDeskADAgent.ps1" -Agent -Quiet | |
| # powershell.exe -ExecutionPolicy Bypass -NoProfile -File C:\ProgramData\RustDeskAD\RustDeskADAgent.ps1 -Agent | |
| # "C:\ProgramData\RustDeskAD\RustDeskADAgent.exe" -Agent -Quiet | |
| foreach ($match in [regex]::Matches($arguments, '(?i)(?:-File\s+)(?:"(?<path>[^"]+\.ps1)"|''(?<path>[^'']+\.ps1)''|(?<path>\S+\.ps1))')) { | |
| $path = $match.Groups['path'].Value | |
| if (-not [string]::IsNullOrWhiteSpace($path)) { $paths.Add($path) | Out-Null } | |
| } | |
| foreach ($match in [regex]::Matches($arguments, '(?i)(?:"(?<path>[^"]+\.(?:exe|ps1))"|''(?<path>[^'']+\.(?:exe|ps1))'')')) { | |
| $path = $match.Groups['path'].Value | |
| if (-not [string]::IsNullOrWhiteSpace($path)) { $paths.Add($path) | Out-Null } | |
| } | |
| } | |
| return @($paths) | |
| } | |
| function Test-RustDeskADScheduledTaskPointsToSelf { | |
| param( | |
| [Parameter(Mandatory)] | |
| $Task, | |
| [Parameter(Mandatory)] | |
| [string[]]$SelfComparablePaths | |
| ) | |
| try { | |
| foreach ($action in @($Task.Actions)) { | |
| $candidatePaths = Get-RustDeskADScheduledTaskActionPaths -Action $action | |
| foreach ($candidate in $candidatePaths) { | |
| $candidateComparable = ConvertTo-RustDeskADComparablePath -Path $candidate | |
| if ($candidateComparable -and ($SelfComparablePaths -contains $candidateComparable)) { | |
| return $true | |
| } | |
| } | |
| } | |
| } | |
| catch {} | |
| return $false | |
| } | |
| function Disable-RustDeskADScheduledTasksPointingToSelf { | |
| param( | |
| [switch]$StopRunningTasks | |
| ) | |
| $self = $null | |
| try { | |
| $self = Get-SelfPath | |
| } | |
| catch { | |
| Write-AgentLog "Could not determine self path while trying to disable legacy scheduled tasks. Error: $($_.Exception.Message)" 'WARN' | |
| return | |
| } | |
| $selfComparable = ConvertTo-RustDeskADComparablePath -Path $self | |
| if (-not $selfComparable) { | |
| Write-AgentLog "Self path '$self' could not be normalized while trying to disable legacy scheduled tasks." 'WARN' | |
| return | |
| } | |
| $selfPaths = New-Object System.Collections.Generic.List[string] | |
| $selfPaths.Add($selfComparable) | Out-Null | |
| # If running from the compiled EXE, also consider a same-folder .ps1 with the same basename. | |
| # If running from the .ps1, also consider a same-folder .exe with the same basename. | |
| # This lets the service migration disable both old script-mode and compiled-mode bootstrap tasks. | |
| try { | |
| $extension = [IO.Path]::GetExtension($self) | |
| $alternate = $null | |
| if ($extension.Equals('.exe', [StringComparison]::OrdinalIgnoreCase)) { | |
| $alternate = [IO.Path]::ChangeExtension($self, '.ps1') | |
| } | |
| elseif ($extension.Equals('.ps1', [StringComparison]::OrdinalIgnoreCase)) { | |
| $alternate = [IO.Path]::ChangeExtension($self, '.exe') | |
| } | |
| $alternateComparable = ConvertTo-RustDeskADComparablePath -Path $alternate | |
| if ($alternateComparable -and -not ($selfPaths -contains $alternateComparable)) { | |
| $selfPaths.Add($alternateComparable) | Out-Null | |
| } | |
| } | |
| catch {} | |
| $disabledCount = 0 | |
| $matchedCount = 0 | |
| try { | |
| $tasks = @(Get-ScheduledTask -ErrorAction Stop) | |
| } | |
| catch { | |
| Write-AgentLog "Unable to enumerate scheduled tasks for service migration cleanup. Error: $($_.Exception.Message)" 'WARN' | |
| return | |
| } | |
| foreach ($task in $tasks) { | |
| if (-not (Test-RustDeskADScheduledTaskPointsToSelf -Task $task -SelfComparablePaths @($selfPaths))) { | |
| continue | |
| } | |
| $matchedCount++ | |
| $taskName = [string]$task.TaskName | |
| $taskPath = [string]$task.TaskPath | |
| if ([string]::IsNullOrWhiteSpace($taskPath)) { $taskPath = '\' } | |
| try { | |
| if ($StopRunningTasks) { | |
| Stop-ScheduledTask -TaskName $taskName -TaskPath $taskPath -ErrorAction SilentlyContinue | |
| } | |
| if ($task.State -ne 'Disabled') { | |
| Disable-ScheduledTask -TaskName $taskName -TaskPath $taskPath -ErrorAction Stop | Out-Null | |
| $disabledCount++ | |
| Write-AgentLog "Disabled legacy scheduled task '$taskPath$taskName' because it points to this agent path after '$AgentServiceName' service was ensured." | |
| } | |
| else { | |
| Write-AgentLog "Legacy scheduled task '$taskPath$taskName' already disabled; it points to this agent path." | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Failed to disable scheduled task '$taskPath$taskName' that points to this agent path. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| } | |
| if ($matchedCount -eq 0) { | |
| Write-AgentLog "No scheduled tasks were found pointing to this agent path. Self='$self'" | |
| } | |
| else { | |
| Write-AgentLog "Scheduled-task service migration cleanup complete. Matched=$matchedCount DisabledNow=$disabledCount Self='$self'" | |
| } | |
| } | |
| # ----------------------------- | |
| # Recovery copy / staged replacement helpers | |
| # ----------------------------- | |
| function Get-RustDeskADSafeNameFragment { | |
| param([AllowNull()][string]$Value) | |
| if ([string]::IsNullOrWhiteSpace($Value)) { return 'unknown' } | |
| $clean = ($Value -replace '[^A-Za-z0-9_.-]', '_').Trim('._-') | |
| if ([string]::IsNullOrWhiteSpace($clean)) { return 'unknown' } | |
| return $clean | |
| } | |
| function Get-RustDeskADComputerUuid { | |
| $uuid = $null | |
| try { | |
| $csp = Get-CimInstance -ClassName Win32_ComputerSystemProduct -ErrorAction Stop | |
| if ($csp.UUID -and $csp.UUID -notmatch '^(00000000-0000-0000-0000-000000000000|FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF)$') { | |
| $uuid = [string]$csp.UUID | |
| } | |
| } | |
| catch {} | |
| if ([string]::IsNullOrWhiteSpace($uuid)) { | |
| try { | |
| $uuid = [string](Get-ItemPropertyValue -Path 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop) | |
| } | |
| catch {} | |
| } | |
| if ([string]::IsNullOrWhiteSpace($uuid)) { | |
| $uuid = "$env:COMPUTERNAME" | |
| } | |
| return (Get-RustDeskADSafeNameFragment $uuid) | |
| } | |
| function Get-RustDeskADRecoveryStagingPath { | |
| $computerUuid = Get-RustDeskADComputerUuid | |
| return (Join-Path (Join-Path $env:WINDIR 'Temp') $computerUuid) | |
| } | |
| function Get-RustDeskADFileSha256 { | |
| param([string]$Path) | |
| if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { | |
| return $null | |
| } | |
| try { | |
| return (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash.ToUpperInvariant() | |
| } | |
| catch { | |
| Write-AgentLog "Unable to hash file '$Path'. Error: $($_.Exception.Message)" 'WARN' | |
| return $null | |
| } | |
| } | |
| function Copy-RustDeskADFileViaTempStage { | |
| param( | |
| [Parameter(Mandatory)][string]$SourcePath, | |
| [Parameter(Mandatory)][string]$DestinationPath, | |
| [string]$Reason = 'copy' | |
| ) | |
| if (-not (Test-Path -LiteralPath $SourcePath -PathType Leaf)) { | |
| throw "Source file does not exist: $SourcePath" | |
| } | |
| $stagingDir = Get-RustDeskADRecoveryStagingPath | |
| $destDir = Split-Path -Parent $DestinationPath | |
| if ([string]::IsNullOrWhiteSpace($destDir)) { throw "Destination path has no parent directory: $DestinationPath" } | |
| New-Item -ItemType Directory -Path $destDir -Force | Out-Null | |
| New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null | |
| $stageName = '{0}.{1}.staged' -f ([IO.Path]::GetFileName($DestinationPath)), ([Guid]::NewGuid().ToString('N')) | |
| $stagePath = Join-Path $stagingDir $stageName | |
| try { | |
| Copy-Item -LiteralPath $SourcePath -Destination $stagePath -Force -ErrorAction Stop | |
| try { Unblock-File -LiteralPath $stagePath -ErrorAction SilentlyContinue } catch {} | |
| Move-Item -LiteralPath $stagePath -Destination $DestinationPath -Force -ErrorAction Stop | |
| Write-AgentLog "Staged file replacement completed. Reason='$Reason' Source='$SourcePath' Destination='$DestinationPath' StageDir='$stagingDir'" | |
| } | |
| finally { | |
| try { | |
| if (Test-Path -LiteralPath $stagePath) { | |
| Remove-Item -LiteralPath $stagePath -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| catch {} | |
| # The staging directory is intentionally temporary. Leave no persistent payload in C:\Windows\Temp. | |
| try { | |
| if (Test-Path -LiteralPath $stagingDir) { | |
| $items = @(Get-ChildItem -LiteralPath $stagingDir -Force -ErrorAction SilentlyContinue) | |
| if ($items.Count -eq 0) { | |
| Remove-Item -LiteralPath $stagingDir -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| } | |
| catch {} | |
| } | |
| } | |
| function Get-RustDeskADRecoveryId { | |
| $computerUuid = Get-RustDeskADComputerUuid | |
| try { | |
| $bytes = [Text.Encoding]::UTF8.GetBytes("$env:USERDNSDOMAIN|$env:COMPUTERNAME|$computerUuid") | |
| $sha = [Security.Cryptography.SHA256]::Create() | |
| $hashBytes = $sha.ComputeHash($bytes) | |
| return (($hashBytes | ForEach-Object { $_.ToString('x2') }) -join '').Substring(0, 16) | |
| } | |
| catch { | |
| return (Get-RustDeskADSafeNameFragment $computerUuid) | |
| } | |
| } | |
| function Get-RustDeskADRecoveryDirectory { | |
| return (Join-Path $RecoveryRootPath (Get-RustDeskADRecoveryId)) | |
| } | |
| function Get-RustDeskADAgentRecoveryPath { | |
| $self = Get-SelfPath | |
| $ext = [IO.Path]::GetExtension($self) | |
| if ([string]::IsNullOrWhiteSpace($ext)) { $ext = '.bin' } | |
| return (Join-Path (Get-RustDeskADRecoveryDirectory) ("RustDeskADAgent.recovery$ext")) | |
| } | |
| function Get-RustDeskADWrapperRecoveryPath { | |
| return (Join-Path (Get-RustDeskADRecoveryDirectory) 'RustDeskADAgentServiceWrapper.recovery.exe') | |
| } | |
| function Get-RustDeskADWrapperSourceRecoveryPath { | |
| return (Join-Path (Get-RustDeskADRecoveryDirectory) 'RustDeskADAgentServiceWrapper.recovery.cs') | |
| } | |
| function Write-RustDeskADRecoveryManifest { | |
| param( | |
| [string]$RecoveryDir, | |
| [string]$AgentLivePath, | |
| [string]$AgentRecoveryPath, | |
| [string]$WrapperLivePath, | |
| [string]$WrapperRecoveryPath, | |
| [string]$WrapperSourceLivePath, | |
| [string]$WrapperSourceRecoveryPath | |
| ) | |
| try { | |
| $manifest = [ordered]@{ | |
| Product = 'RustDeskAD' | |
| Component = 'AgentRecovery' | |
| GeneratedAt = (Get-Date).ToString('o') | |
| ComputerName = $env:COMPUTERNAME | |
| ComputerUuid = (Get-RustDeskADComputerUuid) | |
| RecoveryId = (Get-RustDeskADRecoveryId) | |
| RecoveryDirectory = $RecoveryDir | |
| TempStagingDirectory = (Get-RustDeskADRecoveryStagingPath) | |
| Note = 'Recovery copies are stored in ProgramData. C:\Windows\Temp\<computer-uuid> is used only during staged copy/replace operations.' | |
| Files = [ordered]@{ | |
| Agent = [ordered]@{ | |
| LivePath = $AgentLivePath | |
| RecoveryPath = $AgentRecoveryPath | |
| LiveSha256 = (Get-RustDeskADFileSha256 $AgentLivePath) | |
| RecoverySha256 = (Get-RustDeskADFileSha256 $AgentRecoveryPath) | |
| } | |
| Wrapper = [ordered]@{ | |
| LivePath = $WrapperLivePath | |
| RecoveryPath = $WrapperRecoveryPath | |
| LiveSha256 = (Get-RustDeskADFileSha256 $WrapperLivePath) | |
| RecoverySha256 = (Get-RustDeskADFileSha256 $WrapperRecoveryPath) | |
| } | |
| WrapperSource = [ordered]@{ | |
| LivePath = $WrapperSourceLivePath | |
| RecoveryPath = $WrapperSourceRecoveryPath | |
| LiveSha256 = (Get-RustDeskADFileSha256 $WrapperSourceLivePath) | |
| RecoverySha256 = (Get-RustDeskADFileSha256 $WrapperSourceRecoveryPath) | |
| } | |
| } | |
| } | |
| $manifestPath = Join-Path $RecoveryDir 'manifest.json' | |
| ($manifest | ConvertTo-Json -Depth 8) | Set-Content -LiteralPath $manifestPath -Encoding UTF8 -Force | |
| } | |
| catch { | |
| Write-AgentLog "Unable to write recovery manifest. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| } | |
| function Sync-RustDeskADRecoveryPair { | |
| param( | |
| [Parameter(Mandatory)][string]$LivePath, | |
| [Parameter(Mandatory)][string]$RecoveryPath, | |
| [string]$Label | |
| ) | |
| $liveExists = (Test-Path -LiteralPath $LivePath -PathType Leaf) | |
| $recoveryExists = (Test-Path -LiteralPath $RecoveryPath -PathType Leaf) | |
| if ($liveExists -and -not $recoveryExists) { | |
| Copy-RustDeskADFileViaTempStage -SourcePath $LivePath -DestinationPath $RecoveryPath -Reason "$Label recovery seed" | |
| return | |
| } | |
| if (-not $liveExists -and $recoveryExists) { | |
| Copy-RustDeskADFileViaTempStage -SourcePath $RecoveryPath -DestinationPath $LivePath -Reason "$Label live restore from recovery" | |
| return | |
| } | |
| if (-not $liveExists -and -not $recoveryExists) { | |
| Write-AgentLog "Recovery pair '$Label' has neither live nor recovery file. Live='$LivePath' Recovery='$RecoveryPath'" 'WARN' | |
| return | |
| } | |
| $liveHash = Get-RustDeskADFileSha256 $LivePath | |
| $recoveryHash = Get-RustDeskADFileSha256 $RecoveryPath | |
| if ($liveHash -and $recoveryHash -and $liveHash -ne $recoveryHash) { | |
| if ($RefreshRecoveryCopies) { | |
| Copy-RustDeskADFileViaTempStage -SourcePath $LivePath -DestinationPath $RecoveryPath -Reason "$Label recovery refresh from current live hash" | |
| } | |
| else { | |
| Write-AgentLog "Recovery pair '$Label' hash mismatch. Leaving existing recovery copy unchanged. Use -RefreshRecoveryCopies during an intentional upgrade. Live='$LivePath' Recovery='$RecoveryPath'" 'WARN' | |
| } | |
| } | |
| } | |
| function Ensure-RustDeskADRecoveryProtection { | |
| if ($DisableRecoveryProtection) { | |
| Write-AgentLog 'Recovery protection skipped because -DisableRecoveryProtection was specified' | |
| return | |
| } | |
| try { | |
| $recoveryDir = Get-RustDeskADRecoveryDirectory | |
| New-Item -ItemType Directory -Path $recoveryDir -Force | Out-Null | |
| $agentLive = Get-SelfPath | |
| $agentRecovery = Get-RustDeskADAgentRecoveryPath | |
| $wrapperRecovery = Get-RustDeskADWrapperRecoveryPath | |
| $wrapperSourceRecovery = Get-RustDeskADWrapperSourceRecoveryPath | |
| Sync-RustDeskADRecoveryPair -LivePath $agentLive -RecoveryPath $agentRecovery -Label 'agent' | |
| Sync-RustDeskADRecoveryPair -LivePath $AgentServiceWrapperPath -RecoveryPath $wrapperRecovery -Label 'service wrapper' | |
| Sync-RustDeskADRecoveryPair -LivePath $AgentServiceWrapperSourcePath -RecoveryPath $wrapperSourceRecovery -Label 'service wrapper source' | |
| Write-RustDeskADRecoveryManifest ` | |
| -RecoveryDir $recoveryDir ` | |
| -AgentLivePath $agentLive ` | |
| -AgentRecoveryPath $agentRecovery ` | |
| -WrapperLivePath $AgentServiceWrapperPath ` | |
| -WrapperRecoveryPath $wrapperRecovery ` | |
| -WrapperSourceLivePath $AgentServiceWrapperSourcePath ` | |
| -WrapperSourceRecoveryPath $wrapperSourceRecovery | |
| try { | |
| New-Item -Path 'HKLM:\Software\RustDeskAD\Agent' -Force | Out-Null | |
| Set-ItemProperty -Path 'HKLM:\Software\RustDeskAD\Agent' -Name 'RecoveryPath' -Value $recoveryDir -Force | |
| Set-ItemProperty -Path 'HKLM:\Software\RustDeskAD\Agent' -Name 'TempStagingPath' -Value (Get-RustDeskADRecoveryStagingPath) -Force | |
| } | |
| catch { | |
| Write-AgentLog "Unable to write recovery registry hints. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| if (-not $script:RustDeskADRecoveryLogWritten) { | |
| Write-AgentLog "Recovery protection active. RecoveryDir='$recoveryDir' TempStage='$(Get-RustDeskADRecoveryStagingPath)'" | |
| $script:RustDeskADRecoveryLogWritten = $true | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Recovery protection failed. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| } | |
| # ----------------------------- | |
| # Windows service wrapper helpers | |
| # ----------------------------- | |
| function ConvertTo-CSharpLiteral { | |
| param([AllowNull()][string]$Value) | |
| if ($null -eq $Value) { return '' } | |
| return $Value.Replace('\', '\\').Replace('"', '\"').Replace("`r", '\r').Replace("`n", '\n') | |
| } | |
| function Get-CscPath { | |
| $candidates = @( | |
| "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe", | |
| "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" | |
| ) | |
| foreach ($candidate in $candidates) { | |
| if (Test-Path $candidate) { return $candidate } | |
| } | |
| throw 'Could not find csc.exe under Microsoft.NET Framework v4.0.30319. Install/enable .NET Framework 4.x build tools on this machine.' | |
| } | |
| function Get-AgentServiceChildCommand { | |
| $self = Get-SelfPath | |
| $isExe = [IO.Path]::GetExtension($self).Equals('.exe', [StringComparison]::OrdinalIgnoreCase) -and -not (Test-IsPowerShellHostPath -Path $self) | |
| if ($isExe) { | |
| return [pscustomobject]@{ | |
| ExePath = $self | |
| Arguments = '-Agent -Quiet -IntervalSeconds {0}' -f $IntervalSeconds | |
| WorkingDirectory = Split-Path -Parent $self | |
| } | |
| } | |
| return [pscustomobject]@{ | |
| ExePath = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" | |
| Arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -Agent -Quiet -IntervalSeconds {1}' -f $self, $IntervalSeconds | |
| WorkingDirectory = Split-Path -Parent $self | |
| } | |
| } | |
| function New-RustDeskADAgentServiceWrapper { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$WrapperExePath, | |
| [Parameter(Mandatory)] | |
| [string]$WrapperSourcePath, | |
| [Parameter(Mandatory)] | |
| [string]$ChildExePath, | |
| [Parameter(Mandatory)] | |
| [AllowEmptyString()] | |
| [string]$ChildArguments, | |
| [Parameter(Mandatory)] | |
| [string]$WorkingDirectory, | |
| [Parameter(Mandatory)] | |
| [string]$DataDirectory, | |
| [Parameter(Mandatory)] | |
| [string]$ServiceName, | |
| [int]$ChildRestartSeconds = 10 | |
| ) | |
| if ($ChildRestartSeconds -lt 1) { $ChildRestartSeconds = 1 } | |
| $wrapperDir = Split-Path -Parent $WrapperExePath | |
| $sourceDir = Split-Path -Parent $WrapperSourcePath | |
| New-Item -ItemType Directory -Path $wrapperDir -Force | Out-Null | |
| New-Item -ItemType Directory -Path $sourceDir -Force | Out-Null | |
| New-Item -ItemType Directory -Path $DataDirectory -Force | Out-Null | |
| $escapedServiceName = ConvertTo-CSharpLiteral $ServiceName | |
| $escapedChildExe = ConvertTo-CSharpLiteral $ChildExePath | |
| $escapedChildArgs = ConvertTo-CSharpLiteral $ChildArguments | |
| $escapedWorkingDir = ConvertTo-CSharpLiteral $WorkingDirectory | |
| $escapedDataDir = ConvertTo-CSharpLiteral $DataDirectory | |
| $restartMs = [Math]::Max(1000, ($ChildRestartSeconds * 1000)) | |
| $source = @" | |
| using System; | |
| using System.Diagnostics; | |
| using System.IO; | |
| using System.ServiceProcess; | |
| using System.Threading; | |
| public class RustDeskADAgentServiceWrapper : ServiceBase | |
| { | |
| private Process childProcess; | |
| private Thread supervisorThread; | |
| private readonly object syncRoot = new object(); | |
| private volatile bool stopping = false; | |
| private const string WrappedServiceName = "$escapedServiceName"; | |
| private const string ChildExe = "$escapedChildExe"; | |
| private const string ChildArgs = "$escapedChildArgs"; | |
| private const string WorkingDir = "$escapedWorkingDir"; | |
| private const string DataDir = "$escapedDataDir"; | |
| private const int RestartDelayMilliseconds = $restartMs; | |
| private const int SupervisorPollMilliseconds = 2000; | |
| public RustDeskADAgentServiceWrapper() | |
| { | |
| ServiceName = WrappedServiceName; | |
| CanStop = true; | |
| CanShutdown = true; | |
| CanPauseAndContinue = false; | |
| AutoLog = true; | |
| } | |
| protected override void OnStart(string[] args) | |
| { | |
| try | |
| { | |
| stopping = false; | |
| Directory.CreateDirectory(DataDir); | |
| Log("service wrapper starting"); | |
| StartSupervisor(); | |
| StartChild("service start"); | |
| Log("service wrapper OnStart returning success"); | |
| } | |
| catch (Exception ex) | |
| { | |
| Log("OnStart swallowed startup error and is still returning success: " + ex.ToString()); | |
| } | |
| } | |
| protected override void OnStop() | |
| { | |
| // Intentional: do NOT stop the child agent when the wrapper service is | |
| // stopped from SCM/services.msc. The child is the repair path. If a | |
| // local admin stops/disables the service wrapper while the child is | |
| // still alive, the child agent can re-enable/start RustDeskAD - Agent. | |
| Log("service wrapper stopping by SCM request; leaving child agent running for service self-repair"); | |
| stopping = true; | |
| DetachChildForRepair(); | |
| } | |
| protected override void OnShutdown() | |
| { | |
| // During OS shutdown, stopping the child is fine. The service will start | |
| // normally again on next boot. This keeps shutdown clean while preserving | |
| // the anti-tamper repair behavior for normal service stop attempts. | |
| Log("service wrapper shutdown"); | |
| stopping = true; | |
| StopChild(); | |
| } | |
| private static void Log(string message) | |
| { | |
| try | |
| { | |
| Directory.CreateDirectory(DataDir); | |
| File.AppendAllText(Path.Combine(DataDir, "agent-service-wrapper.log"), DateTime.Now.ToString("s") + " " + message + Environment.NewLine); | |
| } | |
| catch { } | |
| } | |
| private static void LogLine(string fileName, string line) | |
| { | |
| try | |
| { | |
| Directory.CreateDirectory(DataDir); | |
| File.AppendAllText(Path.Combine(DataDir, fileName), DateTime.Now.ToString("s") + " " + line + Environment.NewLine); | |
| } | |
| catch { } | |
| } | |
| private void StartSupervisor() | |
| { | |
| lock (syncRoot) | |
| { | |
| if (supervisorThread != null && supervisorThread.IsAlive) | |
| { | |
| return; | |
| } | |
| supervisorThread = new Thread(SupervisorLoop); | |
| supervisorThread.IsBackground = true; | |
| supervisorThread.Name = "RustDeskAD Agent child supervisor"; | |
| supervisorThread.Start(); | |
| Log("supervisor thread started"); | |
| } | |
| } | |
| private void SupervisorLoop() | |
| { | |
| while (!stopping) | |
| { | |
| try | |
| { | |
| bool needsStart = false; | |
| lock (syncRoot) | |
| { | |
| needsStart = (childProcess == null || childProcess.HasExited); | |
| } | |
| if (needsStart && !stopping) | |
| { | |
| Log("supervisor detected missing/exited child; restarting"); | |
| StartChild("supervisor poll"); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| Log("supervisor loop error: " + ex.ToString()); | |
| } | |
| try { Thread.Sleep(SupervisorPollMilliseconds); } catch { } | |
| } | |
| Log("supervisor thread exiting"); | |
| } | |
| private void StartChild(string reason) | |
| { | |
| lock (syncRoot) | |
| { | |
| try | |
| { | |
| if (stopping) | |
| { | |
| Log("child start skipped because wrapper is stopping; reason=" + reason); | |
| return; | |
| } | |
| if (childProcess != null && !childProcess.HasExited) | |
| { | |
| return; | |
| } | |
| if (!File.Exists(ChildExe)) | |
| { | |
| Log("child launch skipped because EXE does not exist: " + ChildExe); | |
| QueueRestartIfNeeded("missing child exe"); | |
| return; | |
| } | |
| if (!Directory.Exists(WorkingDir)) | |
| { | |
| Directory.CreateDirectory(WorkingDir); | |
| } | |
| var psi = new ProcessStartInfo(); | |
| psi.FileName = ChildExe; | |
| psi.Arguments = ChildArgs; | |
| psi.WorkingDirectory = WorkingDir; | |
| psi.UseShellExecute = false; | |
| psi.CreateNoWindow = true; | |
| psi.RedirectStandardOutput = true; | |
| psi.RedirectStandardError = true; | |
| psi.EnvironmentVariables["RUSTDESKAD_AGENT_SERVICE_WRAPPED"] = "1"; | |
| psi.EnvironmentVariables["RUSTDESKAD_AGENT_SERVICE_NAME"] = WrappedServiceName; | |
| psi.EnvironmentVariables["RUSTDESKAD_AGENT_ALLOW_ORPHAN_SERVICE_REPAIR"] = "1"; | |
| childProcess = new Process(); | |
| childProcess.StartInfo = psi; | |
| childProcess.EnableRaisingEvents = true; | |
| childProcess.OutputDataReceived += (sender, e) => | |
| { | |
| if (e.Data != null) LogLine("agent-service-wrapper.stdout.log", e.Data); | |
| }; | |
| childProcess.ErrorDataReceived += (sender, e) => | |
| { | |
| if (e.Data != null) LogLine("agent-service-wrapper.stderr.log", e.Data); | |
| }; | |
| childProcess.Exited += (sender, e) => | |
| { | |
| int pid = 0; | |
| try { pid = childProcess.Id; } catch { } | |
| try | |
| { | |
| Log("child exited PID " + pid + " code " + childProcess.ExitCode + "; restart queued"); | |
| } | |
| catch | |
| { | |
| Log("child exited PID " + pid + "; restart queued"); | |
| } | |
| QueueRestartIfNeeded("child exit event"); | |
| }; | |
| bool started = childProcess.Start(); | |
| if (!started) | |
| { | |
| Log("childProcess.Start returned false; wrapper remains running"); | |
| QueueRestartIfNeeded("Start returned false"); | |
| return; | |
| } | |
| childProcess.BeginOutputReadLine(); | |
| childProcess.BeginErrorReadLine(); | |
| Log("child started PID " + childProcess.Id + " reason=" + reason + " command: " + ChildExe + " " + ChildArgs); | |
| } | |
| catch (Exception ex) | |
| { | |
| Log("StartChild swallowed launch error; wrapper stays running: " + ex.ToString()); | |
| QueueRestartIfNeeded("StartChild exception"); | |
| } | |
| } | |
| } | |
| private void QueueRestartIfNeeded(string reason) | |
| { | |
| if (stopping) return; | |
| ThreadPool.QueueUserWorkItem(delegate | |
| { | |
| try | |
| { | |
| Thread.Sleep(RestartDelayMilliseconds); | |
| if (!stopping) StartChild(reason); | |
| } | |
| catch (Exception ex) | |
| { | |
| Log("restart queue error: " + ex.ToString()); | |
| } | |
| }); | |
| } | |
| private void DetachChildForRepair() | |
| { | |
| lock (syncRoot) | |
| { | |
| if (childProcess == null) | |
| { | |
| Log("no child process to detach for repair"); | |
| return; | |
| } | |
| try | |
| { | |
| if (!childProcess.HasExited) | |
| { | |
| Log("detaching child PID " + childProcess.Id + " for service self-repair"); | |
| } | |
| else | |
| { | |
| Log("child was already exited while detaching for repair"); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| Log("detach status error: " + ex.ToString()); | |
| } | |
| finally | |
| { | |
| try { childProcess.Dispose(); } catch { } | |
| childProcess = null; | |
| } | |
| } | |
| } | |
| private void StopChild() | |
| { | |
| lock (syncRoot) | |
| { | |
| if (childProcess == null) | |
| { | |
| Log("no child process to stop"); | |
| return; | |
| } | |
| try | |
| { | |
| if (!childProcess.HasExited) | |
| { | |
| Log("stopping child PID " + childProcess.Id); | |
| try { childProcess.CloseMainWindow(); } catch { } | |
| if (!childProcess.WaitForExit(3000)) | |
| { | |
| Log("child did not exit gracefully, killing PID " + childProcess.Id); | |
| childProcess.Kill(); | |
| childProcess.WaitForExit(10000); | |
| } | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| Log("stop error: " + ex.ToString()); | |
| } | |
| finally | |
| { | |
| try { childProcess.Dispose(); } catch { } | |
| childProcess = null; | |
| } | |
| } | |
| } | |
| public static void Main(string[] args) | |
| { | |
| AppDomain.CurrentDomain.UnhandledException += delegate(object sender, UnhandledExceptionEventArgs e) | |
| { | |
| try { Log("wrapper unhandled exception: " + e.ExceptionObject.ToString()); } catch { } | |
| }; | |
| try | |
| { | |
| ServiceBase.Run(new RustDeskADAgentServiceWrapper()); | |
| } | |
| catch (Exception ex) | |
| { | |
| Log("ServiceBase.Run fatal error: " + ex.ToString()); | |
| throw; | |
| } | |
| } | |
| } | |
| "@ | |
| Set-Content -Path $WrapperSourcePath -Value $source -Encoding UTF8 | |
| $csc = Get-CscPath | |
| $compileArgs = @( | |
| '/nologo', | |
| '/target:exe', | |
| '/platform:anycpu', | |
| "/out:$WrapperExePath", | |
| '/reference:System.ServiceProcess.dll', | |
| '/reference:System.dll', | |
| $WrapperSourcePath | |
| ) | |
| Write-AgentLog "Compiling service wrapper. CSC='$csc' Wrapper='$WrapperExePath' Child='$ChildExePath $ChildArguments'" | |
| & $csc @compileArgs | |
| if ($LASTEXITCODE -ne 0 -or -not (Test-Path $WrapperExePath)) { | |
| throw "Failed compiling service wrapper '$WrapperExePath'. csc.exe exit code: $LASTEXITCODE" | |
| } | |
| Write-AgentLog "Compiled service wrapper '$WrapperExePath'" | |
| } | |
| function Stop-AndDeleteWindowsService { | |
| param([Parameter(Mandatory)][string]$Name) | |
| try { | |
| Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue | |
| } | |
| catch { | |
| Write-AgentLog "Stop-Service failed or service was already stopped for '$Name': $($_.Exception.Message)" 'WARN' | |
| } | |
| & sc.exe delete $Name 2>$null | Out-Null | |
| $deleteExit = $LASTEXITCODE | |
| if ($deleteExit -eq 0 -or $deleteExit -eq 1060) { | |
| Write-AgentLog "Deleted existing service '$Name' or it was already absent. scExit=$deleteExit" | |
| } | |
| else { | |
| Write-AgentLog "sc.exe delete returned exit code $deleteExit for '$Name'. Continuing after wait." 'WARN' | |
| } | |
| Start-Sleep -Seconds 3 | |
| if (Get-Service -Name $Name -ErrorAction SilentlyContinue) { | |
| Write-AgentLog "Service '$Name' still exists after delete. Waiting for SCM release." 'WARN' | |
| Start-Sleep -Seconds 5 | |
| } | |
| if (Get-Service -Name $Name -ErrorAction SilentlyContinue) { | |
| throw "Service '$Name' still exists after delete attempt. Close services.msc/service handles and retry." | |
| } | |
| } | |
| function ConvertTo-RustDeskADSidString { | |
| param([Parameter(Mandatory)][string]$AccountName) | |
| try { | |
| $nt = New-Object System.Security.Principal.NTAccount($AccountName) | |
| $sid = $nt.Translate([System.Security.Principal.SecurityIdentifier]) | |
| return $sid.Value | |
| } | |
| catch { | |
| Write-AgentLog "Unable to resolve account '$AccountName' to SID for tamper resistance. Error: $($_.Exception.Message)" 'WARN' | |
| return $null | |
| } | |
| } | |
| function Get-RustDeskADDefaultTamperAdminGroups { | |
| $groups = New-Object System.Collections.Generic.List[string] | |
| foreach ($g in @($TamperAdminGroups)) { | |
| if (-not [string]::IsNullOrWhiteSpace($g)) { | |
| $groups.Add([string]$g) | Out-Null | |
| } | |
| } | |
| try { | |
| $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop | |
| $domain = [string]$cs.Domain | |
| $partOfDomain = [bool]$cs.PartOfDomain | |
| if ($partOfDomain -and -not [string]::IsNullOrWhiteSpace($domain)) { | |
| foreach ($domainGroup in @("$domain\Domain Admins", "$domain\Enterprise Admins")) { | |
| if (-not ($groups -contains $domainGroup)) { | |
| $groups.Add($domainGroup) | Out-Null | |
| } | |
| } | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Unable to determine domain admin groups for tamper resistance. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| return @($groups) | |
| } | |
| function Get-RustDeskADAgentServiceTamperSddl { | |
| # Service rights abbreviations used by SCM SDDL: | |
| # CC=query config, DC=change config, LC=query status, SW=enumerate dependents, | |
| # RP=start, WP=stop, DT=pause/continue, LO=interrogate, CR=user-defined control, | |
| # SD=delete, RC=read control, WD=write DACL, WO=write owner. | |
| # | |
| # Tight mode: only LocalSystem receives service-control/config/delete/DACL ownership rights. | |
| # Domain Admins, Enterprise Admins, custom tamper groups, Local Administrators, and | |
| # Authenticated Users are intentionally read/query only for the SERVICE object. | |
| # Break-glass stop/disable/delete is done by launching the maintenance command as SYSTEM. | |
| $full = 'CCDCLCSWRPWPDTLOCRSDRCWDWO' | |
| $readOnly = 'CCLCSWLOCRRC' | |
| $aces = New-Object System.Collections.Generic.List[string] | |
| # SYSTEM is the only identity with full control over the service object. | |
| $aces.Add("(A;;$full;;;SY)") | Out-Null | |
| # Domain Admins / Enterprise Admins get visibility only. They cannot stop, disable, | |
| # delete, rewrite DACL, or take ownership through normal SCM APIs unless they first | |
| # break glass into a SYSTEM context. | |
| $aces.Add("(A;;$readOnly;;;DA)") | Out-Null | |
| $aces.Add("(A;;$readOnly;;;EA)") | Out-Null | |
| foreach ($group in (Get-RustDeskADDefaultTamperAdminGroups)) { | |
| $sid = ConvertTo-RustDeskADSidString -AccountName $group | |
| if ($sid) { | |
| $aces.Add("(A;;$readOnly;;;$sid)") | Out-Null | |
| } | |
| } | |
| # Local Administrators can query only; they cannot start/stop, disable/reconfigure, delete, | |
| # rewrite the service DACL, or take ownership through normal service-control APIs. | |
| $aces.Add("(A;;$readOnly;;;BA)") | Out-Null | |
| # Authenticated Users can only query/interrogate/read state. | |
| $aces.Add('(A;;CCLCSWLOCRRC;;;AU)') | Out-Null | |
| return ('D:' + (($aces | Select-Object -Unique) -join '')) | |
| } | |
| function Ensure-RustDeskADAgentServiceTamperAcl { | |
| if ($DisableTamperResistance) { | |
| Write-AgentLog "Tamper resistance service ACL repair skipped because -DisableTamperResistance was specified" | |
| return | |
| } | |
| if (-not (Get-Service -Name $AgentServiceName -ErrorAction SilentlyContinue)) { | |
| return | |
| } | |
| $targetSddl = Get-RustDeskADAgentServiceTamperSddl | |
| try { | |
| $current = (& sc.exe sdshow $AgentServiceName 2>$null) -join '' | |
| if ($LASTEXITCODE -eq 0 -and $current -eq $targetSddl) { | |
| return | |
| } | |
| } | |
| catch {} | |
| try { | |
| & sc.exe sdset $AgentServiceName $targetSddl | Out-Null | |
| if ($LASTEXITCODE -eq 0) { | |
| Write-AgentLog "Applied SYSTEM-only service-control DACL to '$AgentServiceName'. SYSTEM has full control; Domain Admins/Enterprise Admins/custom groups/local admins have query/read only." | |
| } | |
| else { | |
| Write-AgentLog "sc.exe sdset failed for '$AgentServiceName'. ExitCode=$LASTEXITCODE SDDL='$targetSddl'" 'WARN' | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Failed to apply tamper-resistant service DACL to '$AgentServiceName'. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| } | |
| function Add-RustDeskADFileSystemAccessRule { | |
| param( | |
| [Parameter(Mandatory)][System.Security.AccessControl.FileSystemSecurity]$Acl, | |
| [Parameter(Mandatory)][System.Security.Principal.IdentityReference]$Identity, | |
| [Parameter(Mandatory)][System.Security.AccessControl.FileSystemRights]$Rights, | |
| [bool]$IsDirectory | |
| ) | |
| $inheritance = [System.Security.AccessControl.InheritanceFlags]::None | |
| $propagation = [System.Security.AccessControl.PropagationFlags]::None | |
| if ($IsDirectory) { | |
| $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit | |
| } | |
| $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( | |
| $Identity, | |
| $Rights, | |
| $inheritance, | |
| $propagation, | |
| [System.Security.AccessControl.AccessControlType]::Allow | |
| ) | |
| $Acl.AddAccessRule($rule) | Out-Null | |
| } | |
| function Set-RustDeskADFileSystemTamperAcl { | |
| param([Parameter(Mandatory)][string]$Path) | |
| if (-not (Test-Path -LiteralPath $Path)) { | |
| return | |
| } | |
| try { | |
| $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop | |
| $isDirectory = [bool]$item.PSIsContainer | |
| $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop | |
| $acl.SetAccessRuleProtection($true, $false) | |
| foreach ($existing in @($acl.Access)) { | |
| [void]$acl.RemoveAccessRuleAll($existing) | |
| } | |
| $systemSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18') | |
| $builtinAdminsSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544') | |
| Add-RustDeskADFileSystemAccessRule -Acl $acl -Identity $systemSid -Rights ([System.Security.AccessControl.FileSystemRights]::FullControl) -IsDirectory $isDirectory | |
| Add-RustDeskADFileSystemAccessRule -Acl $acl -Identity $builtinAdminsSid -Rights ([System.Security.AccessControl.FileSystemRights]::ReadAndExecute) -IsDirectory $isDirectory | |
| foreach ($group in (Get-RustDeskADDefaultTamperAdminGroups)) { | |
| $sidValue = ConvertTo-RustDeskADSidString -AccountName $group | |
| if ($sidValue) { | |
| $sid = New-Object System.Security.Principal.SecurityIdentifier($sidValue) | |
| Add-RustDeskADFileSystemAccessRule -Acl $acl -Identity $sid -Rights ([System.Security.AccessControl.FileSystemRights]::FullControl) -IsDirectory $isDirectory | |
| } | |
| } | |
| try { $acl.SetOwner($systemSid) } catch {} | |
| Set-Acl -LiteralPath $Path -AclObject $acl -ErrorAction Stop | |
| } | |
| catch { | |
| Write-AgentLog "Failed to apply tamper-resistant filesystem ACL to '$Path'. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| } | |
| function Get-RustDeskADTamperProtectedPaths { | |
| $paths = New-Object System.Collections.Generic.List[string] | |
| foreach ($p in @($TamperRootPath, $RecoveryRootPath, (Get-RustDeskADRecoveryDirectory), (Get-RustDeskADAgentRecoveryPath), (Get-RustDeskADWrapperRecoveryPath), (Get-RustDeskADWrapperSourceRecoveryPath), $AgentServiceWrapperPath, $AgentServiceWrapperSourcePath)) { | |
| if (-not [string]::IsNullOrWhiteSpace($p)) { $paths.Add([string]$p) | Out-Null } | |
| } | |
| try { | |
| $self = Get-SelfPath | |
| if (-not [string]::IsNullOrWhiteSpace($self)) { | |
| $paths.Add($self) | Out-Null | |
| $ext = [IO.Path]::GetExtension($self) | |
| if ($ext.Equals('.exe', [StringComparison]::OrdinalIgnoreCase)) { | |
| $paths.Add([IO.Path]::ChangeExtension($self, '.ps1')) | Out-Null | |
| } | |
| elseif ($ext.Equals('.ps1', [StringComparison]::OrdinalIgnoreCase)) { | |
| $paths.Add([IO.Path]::ChangeExtension($self, '.exe')) | Out-Null | |
| } | |
| } | |
| } | |
| catch {} | |
| return @($paths | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) | |
| } | |
| function Ensure-RustDeskADAgentFileTamperAcl { | |
| if ($DisableTamperResistance) { | |
| Write-AgentLog "Tamper resistance filesystem ACL repair skipped because -DisableTamperResistance was specified" | |
| return | |
| } | |
| try { | |
| if (-not (Test-Path -LiteralPath $TamperRootPath)) { | |
| New-Item -ItemType Directory -Path $TamperRootPath -Force | Out-Null | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Unable to create/verify tamper root '$TamperRootPath'. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| foreach ($path in (Get-RustDeskADTamperProtectedPaths)) { | |
| Set-RustDeskADFileSystemTamperAcl -Path $path | |
| } | |
| if (-not $script:RustDeskADTamperFileAclLogWritten) { | |
| Write-AgentLog "Applied tamper-resistant filesystem ACLs. Root='$TamperRootPath'. Local Administrators have read/execute only; SYSTEM/domain recovery groups keep full filesystem control. Service control remains SYSTEM-only." | |
| $script:RustDeskADTamperFileAclLogWritten = $true | |
| } | |
| } | |
| function Ensure-RustDeskADAgentTamperResistance { | |
| if ($DisableTamperResistance) { | |
| return | |
| } | |
| Ensure-RustDeskADAgentServiceTamperAcl | |
| Ensure-RustDeskADAgentFileTamperAcl | |
| } | |
| function Get-RustDeskADAgentServiceImagePath { | |
| return ('"{0}"' -f $AgentServiceWrapperPath) | |
| } | |
| function Test-IsRustDeskADAgentServiceWrapped { | |
| try { | |
| return ([string]::Equals($env:RUSTDESKAD_AGENT_SERVICE_WRAPPED, '1', [StringComparison]::OrdinalIgnoreCase)) | |
| } | |
| catch { | |
| return $false | |
| } | |
| } | |
| function Test-RustDeskADAgentServiceWrapperParentAlive { | |
| if (-not (Test-IsRustDeskADAgentServiceWrapped)) { | |
| $script:RustDeskADAgentServiceWrapperOrphaned = $false | |
| return $true | |
| } | |
| $allowOrphanRepair = $false | |
| try { | |
| $allowOrphanRepair = [string]::Equals($env:RUSTDESKAD_AGENT_ALLOW_ORPHAN_SERVICE_REPAIR, '1', [StringComparison]::OrdinalIgnoreCase) | |
| } | |
| catch {} | |
| try { | |
| $currentPid = [System.Diagnostics.Process]::GetCurrentProcess().Id | |
| $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$currentPid" -ErrorAction Stop | |
| $parentPid = [int]$proc.ParentProcessId | |
| $parentIsBad = $false | |
| $reason = '' | |
| if ($parentPid -le 0) { | |
| $parentIsBad = $true | |
| $reason = "could not determine wrapper parent PID" | |
| } | |
| else { | |
| $parent = Get-CimInstance Win32_Process -Filter "ProcessId=$parentPid" -ErrorAction SilentlyContinue | |
| if (-not $parent) { | |
| $parentIsBad = $true | |
| $reason = "wrapper parent process is gone. ParentPid=$parentPid" | |
| } | |
| else { | |
| $parentName = [string]$parent.Name | |
| if ($parentName -notmatch 'RustDeskADAgentServiceWrapper') { | |
| $parentIsBad = $true | |
| $reason = "parent is not the expected wrapper. ParentPid=$parentPid ParentName='$parentName'" | |
| } | |
| } | |
| } | |
| if (-not $parentIsBad) { | |
| if ($script:RustDeskADAgentServiceWrapperOrphaned) { | |
| Write-AgentLog "Service wrapper parent is healthy again. Clearing orphan repair state. ParentPid=$parentPid" | |
| } | |
| $script:RustDeskADAgentServiceWrapperOrphaned = $false | |
| return $true | |
| } | |
| if ($allowOrphanRepair) { | |
| if (-not $script:RustDeskADAgentServiceWrapperOrphaned) { | |
| Write-AgentLog "Service-wrapped agent detected missing/stopped wrapper but will remain alive for service self-repair: $reason" 'WARN' | |
| } | |
| $script:RustDeskADAgentServiceWrapperOrphaned = $true | |
| return $true | |
| } | |
| Write-AgentLog "Service-wrapped agent $reason. Exiting child so SCM wrapper can relaunch cleanly." 'WARN' | |
| return $false | |
| } | |
| catch { | |
| Write-AgentLog "Failed checking service wrapper parent health. Error: $($_.Exception.Message)" 'WARN' | |
| return $true | |
| } | |
| } | |
| function Start-RustDeskADAgentResponsiveSleep { | |
| param([int]$Seconds) | |
| if ($Seconds -lt 1) { return } | |
| $deadline = (Get-Date).AddSeconds($Seconds) | |
| while ((Get-Date) -lt $deadline) { | |
| if (-not (Test-RustDeskADAgentServiceWrapperParentAlive)) { | |
| exit 0 | |
| } | |
| if ($script:RustDeskADAgentServiceWrapperOrphaned) { | |
| Write-AgentLog "Breaking responsive sleep early because wrapper orphan repair mode is active." 'WARN' | |
| break | |
| } | |
| $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds) | |
| if ($remaining -le 0) { break } | |
| Start-Sleep -Seconds ([Math]::Min(2, $remaining)) | |
| } | |
| } | |
| function Ensure-RustDeskADAgentServiceFromCurrentRun { | |
| if ($NoAgentServiceSelfInstall) { | |
| Write-AgentLog "Agent service self-install skipped because -NoAgentServiceSelfInstall was specified" | |
| return $false | |
| } | |
| if (Test-IsRustDeskADAgentServiceWrapped) { | |
| Write-AgentLog "Agent service self-install skipped because this process is already running under '$AgentServiceName' wrapper" | |
| return $false | |
| } | |
| try { | |
| Write-AgentLog "Bootstrapping '$AgentServiceName' service from current -Agent run. EffectiveIntervalSeconds=$IntervalSeconds" | |
| Install-RustDeskADAgentService | |
| Write-AgentLog "'$AgentServiceName' service installed/started from current -Agent run. Exiting bootstrap process so service child owns the agent loop." | |
| return $true | |
| } | |
| catch { | |
| Write-AgentLog "Failed to bootstrap '$AgentServiceName' service from current -Agent run. Continuing in foreground agent mode. Error: $($_.Exception.Message)" 'ERROR' | |
| return $false | |
| } | |
| } | |
| function New-RustDeskADAgentWindowsService { | |
| $imagePath = Get-RustDeskADAgentServiceImagePath | |
| $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$AgentServiceName" | |
| Write-AgentLog "Creating RustDeskAD agent service '$AgentServiceName' with the same sc.exe/registry ImagePath pattern used by '$RustDeskServiceName'." | |
| & sc.exe stop $AgentServiceName 2>$null | Out-Null | |
| & sc.exe delete $AgentServiceName 2>$null | Out-Null | |
| Start-Sleep -Seconds 2 | |
| # IMPORTANT: | |
| # This intentionally mirrors the working RustDeskAD - Remote Desktop service trick: | |
| # 1. Let SCM create the service with a harmless/dummy cmd.exe binPath. | |
| # 2. Immediately overwrite the service registry ImagePath with the real wrapper EXE. | |
| # This avoids the weirdness we hit with New-Service/ps12exe while still ending with a normal SCM service. | |
| & sc.exe create $AgentServiceName binPath= "C:\Windows\System32\cmd.exe" start= auto obj= LocalSystem DisplayName= $AgentServiceDisplayName | Out-Null | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "sc.exe create failed for service '$AgentServiceName'. ExitCode=$LASTEXITCODE" | |
| } | |
| Set-ItemProperty -Path $regPath -Name ImagePath -Value $imagePath -ErrorAction Stop | |
| & sc.exe description $AgentServiceName $AgentServiceDescription | Out-Null | |
| Ensure-RustDeskADAgentServiceFailureActions | |
| Write-AgentLog "Created service '$AgentServiceName' with ImagePath=$imagePath" | |
| } | |
| function Ensure-RustDeskADAgentServiceStartupEnabled { | |
| $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$AgentServiceName" | |
| try { | |
| $startValue = [int](Get-ItemPropertyValue -Path $regPath -Name Start -ErrorAction Stop) | |
| if ($startValue -ne 2) { | |
| Write-AgentLog "Service '$AgentServiceName' startup was not Automatic. RegistryStart='$startValue'. Re-enabling." 'WARN' | |
| Set-ItemProperty -Path $regPath -Name Start -Value 2 -ErrorAction Stop | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Unable to verify/repair startup type for service '$AgentServiceName' through registry. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| try { | |
| Set-Service -Name $AgentServiceName -StartupType Automatic -ErrorAction Stop | |
| } | |
| catch { | |
| Write-AgentLog "Set-Service could not force startup type to Automatic for '$AgentServiceName'. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| } | |
| function Ensure-RustDeskADAgentServiceFailureActions { | |
| if (-not (Get-Service -Name $AgentServiceName -ErrorAction SilentlyContinue)) { | |
| return | |
| } | |
| try { | |
| # Fast service-wrapper recovery. This is what matters when Task Manager kills | |
| # RustDeskADAgentServiceWrapper.exe. The child RustDeskADAgent.exe is expendable; | |
| # the wrapper/service is the supervisor SCM should bring back. | |
| & sc.exe failure $AgentServiceName reset= 86400 actions= restart/0/restart/1000/restart/5000 | Out-Null | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-AgentLog "sc.exe failure failed for '$AgentServiceName'. ExitCode=$LASTEXITCODE" 'WARN' | |
| } | |
| & sc.exe failureflag $AgentServiceName 1 | Out-Null | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-AgentLog "sc.exe failureflag failed for '$AgentServiceName'. ExitCode=$LASTEXITCODE" 'WARN' | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Unable to repair service failure actions for '$AgentServiceName'. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| } | |
| function Ensure-RustDeskADAgentWindowsService { | |
| param( | |
| [switch]$DoNotStart | |
| ) | |
| $imagePath = Get-RustDeskADAgentServiceImagePath | |
| $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$AgentServiceName" | |
| $service = Get-Service -Name $AgentServiceName -ErrorAction SilentlyContinue | |
| $needsCreate = ($null -eq $service) | |
| Ensure-RustDeskADRecoveryProtection | |
| if (-not (Test-Path $AgentServiceWrapperPath)) { | |
| Write-AgentLog "Agent service wrapper is missing at '$AgentServiceWrapperPath'. Rebuilding before service repair." 'WARN' | |
| $dataDir = Split-Path -Path $LogPath -Parent | |
| if ([string]::IsNullOrWhiteSpace($dataDir)) { $dataDir = "$env:ProgramData\RustDeskAD" } | |
| $child = Get-AgentServiceChildCommand | |
| New-RustDeskADAgentServiceWrapper ` | |
| -WrapperExePath $AgentServiceWrapperPath ` | |
| -WrapperSourcePath $AgentServiceWrapperSourcePath ` | |
| -ChildExePath $child.ExePath ` | |
| -ChildArguments $child.Arguments ` | |
| -WorkingDirectory $child.WorkingDirectory ` | |
| -DataDirectory $dataDir ` | |
| -ServiceName $AgentServiceName ` | |
| -ChildRestartSeconds $AgentServiceChildRestartSeconds | |
| Ensure-RustDeskADRecoveryProtection | |
| } | |
| if (-not $needsCreate) { | |
| try { | |
| $currentImagePath = [string](Get-ItemPropertyValue -Path $regPath -Name ImagePath -ErrorAction Stop) | |
| if ($currentImagePath -ne $imagePath) { | |
| Write-AgentLog "Service '$AgentServiceName' ImagePath mismatch. Current='$currentImagePath' Expected='$imagePath'. Recreating." 'WARN' | |
| $needsCreate = $true | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Service '$AgentServiceName' registry ImagePath missing/unreadable. Recreating. Error: $($_.Exception.Message)" 'WARN' | |
| $needsCreate = $true | |
| } | |
| } | |
| if ($needsCreate) { | |
| New-RustDeskADAgentWindowsService | |
| Ensure-RustDeskADAgentServiceStartupEnabled | |
| Ensure-RustDeskADAgentServiceFailureActions | |
| $service = Get-Service -Name $AgentServiceName -ErrorAction Stop | |
| } | |
| else { | |
| Ensure-RustDeskADAgentServiceStartupEnabled | |
| Ensure-RustDeskADAgentServiceFailureActions | |
| $service = Get-Service -Name $AgentServiceName -ErrorAction Stop | |
| } | |
| if (-not $DoNotStart -and $service.Status -ne 'Running') { | |
| Write-AgentLog "Starting service '$AgentServiceName'. CurrentStatus='$($service.Status)'" | |
| Ensure-RustDeskADAgentServiceStartupEnabled | |
| Ensure-RustDeskADAgentServiceFailureActions | |
| Start-Service -Name $AgentServiceName -ErrorAction Stop | |
| } | |
| try { | |
| Disable-RustDeskADScheduledTasksPointingToSelf | |
| } | |
| catch { | |
| Write-AgentLog "Scheduled-task service migration cleanup failed after ensuring '$AgentServiceName'. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| try { | |
| Ensure-RustDeskADRecoveryProtection | |
| } | |
| catch { | |
| Write-AgentLog "Agent recovery-protection repair failed after ensuring '$AgentServiceName'. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| try { | |
| Ensure-RustDeskADAgentTamperResistance | |
| } | |
| catch { | |
| Write-AgentLog "Agent tamper-resistance repair failed after ensuring '$AgentServiceName'. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| return (Get-Service -Name $AgentServiceName -ErrorAction Stop) | |
| } | |
| function Complete-RustDeskADAgentServiceOrphanRepairIfNeeded { | |
| if (-not $script:RustDeskADAgentServiceWrapperOrphaned) { return } | |
| if ($script:RustDeskADAgentServiceWrapperOrphanRepairCompleted) { return } | |
| try { | |
| $svc = Get-Service -Name $AgentServiceName -ErrorAction Stop | |
| if ($svc.Status -eq 'Running') { | |
| $script:RustDeskADAgentServiceWrapperOrphanRepairCompleted = $true | |
| Write-AgentLog "Orphan repair completed: '$AgentServiceName' is running again. Exiting orphaned child so the restarted wrapper can own the next agent instance." 'WARN' | |
| exit 0 | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Orphan repair completion check could not read '$AgentServiceName'. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| } | |
| function Install-RustDeskADAgentService { | |
| $dataDir = Split-Path -Path $LogPath -Parent | |
| if ([string]::IsNullOrWhiteSpace($dataDir)) { $dataDir = "$env:ProgramData\RustDeskAD" } | |
| New-Item -ItemType Directory -Path $dataDir -Force | Out-Null | |
| # The service is the replacement for old startup scheduled tasks. | |
| # Do not rely on knowing the legacy task name. After the service is created and started, | |
| # disable every scheduled task whose action points at this same agent .exe/.ps1 path. | |
| $child = Get-AgentServiceChildCommand | |
| New-RustDeskADAgentServiceWrapper ` | |
| -WrapperExePath $AgentServiceWrapperPath ` | |
| -WrapperSourcePath $AgentServiceWrapperSourcePath ` | |
| -ChildExePath $child.ExePath ` | |
| -ChildArguments $child.Arguments ` | |
| -WorkingDirectory $child.WorkingDirectory ` | |
| -DataDirectory $dataDir ` | |
| -ServiceName $AgentServiceName ` | |
| -ChildRestartSeconds $AgentServiceChildRestartSeconds | |
| Ensure-RustDeskADRecoveryProtection | |
| Write-AgentLog "Installing Windows service '$AgentServiceName'. Wrapper='$AgentServiceWrapperPath' Child='$($child.ExePath) $($child.Arguments)'" | |
| New-RustDeskADAgentWindowsService | |
| Ensure-RustDeskADAgentServiceStartupEnabled | |
| Ensure-RustDeskADAgentServiceFailureActions | |
| Start-Service -Name $AgentServiceName -ErrorAction Stop | |
| Write-AgentLog "Started service '$AgentServiceName'" | |
| Disable-RustDeskADScheduledTasksPointingToSelf | |
| Ensure-RustDeskADRecoveryProtection | |
| Ensure-RustDeskADAgentTamperResistance | |
| } | |
| function Uninstall-RustDeskADAgentService { | |
| Stop-AndDeleteWindowsService -Name $AgentServiceName | |
| Write-AgentLog "Removed service '$AgentServiceName'" | |
| } | |
| # ----------------------------- | |
| # AD helpers | |
| # ----------------------------- | |
| function Convert-ADValue { | |
| param ($Value) | |
| if ($null -eq $Value) { return $null } | |
| if ($Value -is [byte[]]) { | |
| return [Convert]::ToBase64String($Value) | |
| } | |
| if ($Value.GetType().FullName -like '*IADsLargeInteger*' -or | |
| ($Value.PSObject.Properties.Name -contains 'HighPart' -and $Value.PSObject.Properties.Name -contains 'LowPart')) { | |
| try { | |
| return ([Int64]$Value.HighPart -shl 32) -bor ([UInt32]$Value.LowPart) | |
| } | |
| catch { | |
| return [string]$Value | |
| } | |
| } | |
| if ($Value -is [DateTime]) { | |
| return $Value.ToUniversalTime().ToString('o') | |
| } | |
| return $Value | |
| } | |
| function Convert-FileTimeUtc { | |
| param ($Value) | |
| try { | |
| if ($null -eq $Value -or [Int64]$Value -eq 0) { return $null } | |
| return ([DateTime]::FromFileTimeUtc([Int64]$Value)).ToString('o') | |
| } | |
| catch { | |
| return $null | |
| } | |
| } | |
| function Escape-LdapFilterValue { | |
| param ([string]$Value) | |
| if ($null -eq $Value) { return '' } | |
| return ($Value ` | |
| -replace '\\', '\5c' ` | |
| -replace '\*', '\2a' ` | |
| -replace '\(', '\28' ` | |
| -replace '\)', '\29' ` | |
| -replace "`0", '\00') | |
| } | |
| function Test-IsWindows10Client { | |
| try { | |
| $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | |
| $version = [Version]$os.Version | |
| # Windows 10 reports major version 10 and build numbers below 22000. | |
| # Windows 11 also reports major version 10, but starts at build 22000+. | |
| return ($version.Major -eq 10 -and $version.Build -lt 22000) | |
| } | |
| catch { | |
| return $false | |
| } | |
| } | |
| function Test-TcpPort { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$HostName, | |
| [int]$Port = 636, | |
| [int]$TimeoutMilliseconds = $NetworkProbeTimeoutMilliseconds | |
| ) | |
| $client = $null | |
| try { | |
| $client = New-Object System.Net.Sockets.TcpClient | |
| $async = $client.BeginConnect($HostName, $Port, $null, $null) | |
| if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMilliseconds, $false)) { | |
| return $false | |
| } | |
| $client.EndConnect($async) | |
| return $true | |
| } | |
| catch { | |
| return $false | |
| } | |
| finally { | |
| try { if ($client) { $client.Close() } } catch {} | |
| } | |
| } | |
| function Resolve-ADDomainControllerFqdn { | |
| $candidates = @() | |
| try { | |
| $domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetComputerDomain() | |
| $dc = $domain.FindDomainController() | |
| if ($dc -and $dc.Name) { $candidates += [string]$dc.Name } | |
| } | |
| catch {} | |
| try { | |
| if ($env:LOGONSERVER) { | |
| $logonServer = ($env:LOGONSERVER -replace '^\\+', '').Trim() | |
| if ($logonServer) { | |
| if ($logonServer -like '*.*') { | |
| $candidates += $logonServer | |
| } | |
| elseif ($env:USERDNSDOMAIN) { | |
| $candidates += "$logonServer.$($env:USERDNSDOMAIN.ToLower())" | |
| $candidates += $logonServer | |
| } | |
| else { | |
| $candidates += $logonServer | |
| } | |
| } | |
| } | |
| } | |
| catch {} | |
| $candidates = @($candidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) | |
| foreach ($candidate in $candidates) { | |
| if (Test-TcpPort -HostName $candidate -Port 636) { | |
| return $candidate | |
| } | |
| } | |
| if ($candidates.Count -gt 0) { | |
| Write-AgentLog "No discovered DC answered on LDAPS/636. Falling back to first discovered DC '$($candidates[0])'." 'WARN' | |
| return $candidates[0] | |
| } | |
| throw 'Unable to discover a domain controller for LDAP/LDAPS binding.' | |
| } | |
| function Get-ADBindMode { | |
| # Windows 10 / LTSC clients can fail against confidential custom attributes over normal LDAP. | |
| # Use LDAPS automatically there. Newer clients keep the original implicit LDAP behavior. | |
| if (Test-IsWindows10Client) { return 'LDAPS' } | |
| return 'Default' | |
| } | |
| function Clear-ADCachedLdapServer { | |
| $script:RustDeskADCachedLdapServer = $null | |
| } | |
| function Get-ADLdapServerForBind { | |
| if ($script:RustDeskADCachedLdapServer) { | |
| if (Test-TcpPort -HostName $script:RustDeskADCachedLdapServer -Port 636) { | |
| return $script:RustDeskADCachedLdapServer | |
| } | |
| Write-AgentLog "Cached LDAPS DC '$script:RustDeskADCachedLdapServer' is not reachable on 636. Rediscovering DC." 'WARN' | |
| Clear-ADCachedLdapServer | |
| } | |
| $script:RustDeskADCachedLdapServer = Resolve-ADDomainControllerFqdn | |
| return $script:RustDeskADCachedLdapServer | |
| } | |
| function New-ADDirectoryEntry { | |
| param( | |
| [AllowNull()][string]$DistinguishedName | |
| ) | |
| $bindMode = Get-ADBindMode | |
| if ($bindMode -eq 'LDAPS') { | |
| $dc = Get-ADLdapServerForBind | |
| if ([string]::IsNullOrWhiteSpace($DistinguishedName)) { | |
| return New-Object System.DirectoryServices.DirectoryEntry("LDAP://$dc`:636/RootDSE") | |
| } | |
| return New-Object System.DirectoryServices.DirectoryEntry("LDAP://$dc`:636/$DistinguishedName") | |
| } | |
| if ([string]::IsNullOrWhiteSpace($DistinguishedName)) { | |
| return New-Object System.DirectoryServices.DirectoryEntry('LDAP://RootDSE') | |
| } | |
| return New-Object System.DirectoryServices.DirectoryEntry("LDAP://$DistinguishedName") | |
| } | |
| function Get-ADDefaultNamingContext { | |
| $root = $null | |
| try { | |
| $root = New-ADDirectoryEntry -DistinguishedName $null | |
| $root.RefreshCache() | |
| return [string]$root.Properties['defaultNamingContext'][0] | |
| } | |
| finally { | |
| try { if ($root) { $root.Dispose() } } catch {} | |
| } | |
| } | |
| function Get-ThisComputerDirectoryEntry { | |
| $computerSam = Escape-LdapFilterValue "$env:COMPUTERNAME`$" | |
| $bindMode = Get-ADBindMode | |
| if ($bindMode -eq 'LDAPS') { | |
| $dc = Get-ADLdapServerForBind | |
| $namingContext = Get-ADDefaultNamingContext | |
| Write-AgentLog "Using LDAPS for AD bind because this client is Windows 10. DC='$dc' NC='$namingContext'" | |
| $base = New-ADDirectoryEntry -DistinguishedName $namingContext | |
| $searcher = New-Object System.DirectoryServices.DirectorySearcher($base) | |
| } | |
| else { | |
| $searcher = New-Object System.DirectoryServices.DirectorySearcher | |
| } | |
| $searcher.Filter = "(&(objectCategory=computer)(sAMAccountName=$computerSam))" | |
| $searcher.PageSize = 1 | |
| [void]$searcher.PropertiesToLoad.Add('distinguishedName') | |
| $result = $searcher.FindOne() | |
| if (-not $result) { | |
| throw "Computer object not found in AD: $env:COMPUTERNAME" | |
| } | |
| $dn = [string]$result.Properties['distinguishedname'][0] | |
| if ([string]::IsNullOrWhiteSpace($dn)) { | |
| throw "Computer object DN was empty for $env:COMPUTERNAME" | |
| } | |
| if ($bindMode -eq 'LDAPS') { | |
| return New-ADDirectoryEntry -DistinguishedName $dn | |
| } | |
| return $result.GetDirectoryEntry() | |
| } | |
| function Get-ADAttributesForEntry { | |
| param ( | |
| [System.DirectoryServices.DirectoryEntry]$Entry, | |
| [string[]]$Attributes | |
| ) | |
| $data = [ordered]@{} | |
| foreach ($attr in $Attributes) { | |
| $data[$attr] = $null | |
| try { | |
| $Entry.RefreshCache(@($attr)) | |
| if (-not $Entry.Properties.Contains($attr)) { continue } | |
| $values = @() | |
| foreach ($v in $Entry.Properties[$attr]) { | |
| $values += Convert-ADValue $v | |
| } | |
| if ($values.Count -eq 1) { | |
| $data[$attr] = $values[0] | |
| } | |
| elseif ($values.Count -gt 1) { | |
| $data[$attr] = $values | |
| } | |
| } | |
| catch { | |
| $data[$attr] = $null | |
| } | |
| } | |
| return $data | |
| } | |
| function Set-ADAttribute { | |
| param ( | |
| [System.DirectoryServices.DirectoryEntry]$Entry, | |
| [string]$Attribute, | |
| [AllowNull()][string]$Value | |
| ) | |
| try { | |
| if ([string]::IsNullOrWhiteSpace($Value)) { | |
| $Entry.Properties[$Attribute].Clear() | |
| } | |
| else { | |
| $Entry.Put($Attribute, $Value) | |
| } | |
| $Entry.CommitChanges() | |
| } | |
| catch { | |
| # Stale LDAP handles are common after Wi-Fi/VPN/internet changes. Force the next loop to rediscover. | |
| Clear-ADCachedLdapServer | |
| throw | |
| } | |
| } | |
| function Get-ADAttribute ($arg1) { | |
| try { | |
| $entry = Get-ThisComputerDirectoryEntry | |
| $entry.RefreshCache(@($arg1)) | |
| if (-not $entry.Properties.Contains($arg1)) { | |
| return $null | |
| } | |
| return $entry.Properties[$arg1] | |
| } | |
| catch { | |
| Write-AgentLog "Failed to read AD attribute '$arg1': $($_.Exception.Message)" 'WARN' | |
| return $null | |
| } | |
| } | |
| # ----------------------------- | |
| # rustDeskPass public-key encryption helpers | |
| # ----------------------------- | |
| function Test-RustDeskADEncryptedPasswordValue { | |
| param ([AllowNull()][string]$Value) | |
| return (-not [string]::IsNullOrWhiteSpace($Value) -and ([string]$Value -like 'RDADENC*:*')) | |
| } | |
| function Test-RustDeskADPublicKeyAvailable { | |
| return (-not [string]::IsNullOrWhiteSpace([string]$RustDeskADPublicKeyPath) -and (Test-Path -LiteralPath $RustDeskADPublicKeyPath)) | |
| } | |
| function Get-RustDeskADPublicKeyXml { | |
| if ([string]::IsNullOrWhiteSpace([string]$RustDeskADPublicKeyPath)) { return $null } | |
| try { | |
| if (Test-Path -LiteralPath $RustDeskADPublicKeyPath) { | |
| $xml = (Get-Content -LiteralPath $RustDeskADPublicKeyPath -Raw -ErrorAction Stop).Trim() | |
| if (-not [string]::IsNullOrWhiteSpace($xml)) { return $xml } | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Failed to read RustDeskAD public key file '$RustDeskADPublicKeyPath': $($_.Exception.Message)" 'WARN' | |
| } | |
| return $null | |
| } | |
| function Protect-RustDeskADPasswordValue { | |
| param ([AllowNull()][string]$PlainText) | |
| if ([string]::IsNullOrWhiteSpace($PlainText)) { return $PlainText } | |
| if (Test-RustDeskADEncryptedPasswordValue $PlainText) { return $PlainText } | |
| $publicKeyXml = Get-RustDeskADPublicKeyXml | |
| if ([string]::IsNullOrWhiteSpace($publicKeyXml)) { | |
| Write-AgentLog "No RustDeskAD public key found. Writing rustDeskPass in legacy plaintext mode. Deploy $RustDeskADPublicKeyPath to enable RDADENC2 public-key encryption." 'WARN' | |
| return $PlainText | |
| } | |
| $aes = [System.Security.Cryptography.Aes]::Create() | |
| $rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider 2048 | |
| try { | |
| $rsa.PersistKeyInCsp = $false | |
| $rsa.FromXmlString($publicKeyXml) | |
| $aes.Mode = [System.Security.Cryptography.CipherMode]::CBC | |
| $aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7 | |
| $aes.KeySize = 256 | |
| $aes.GenerateKey() | |
| $aes.GenerateIV() | |
| $macKey = New-Object byte[] 32 | |
| $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() | |
| try { $rng.GetBytes($macKey) } | |
| finally { $rng.Dispose() } | |
| $plainBytes = [System.Text.Encoding]::UTF8.GetBytes($PlainText) | |
| $encryptor = $aes.CreateEncryptor() | |
| try { | |
| $cipherBytes = $encryptor.TransformFinalBlock($plainBytes, 0, $plainBytes.Length) | |
| } | |
| finally { | |
| $encryptor.Dispose() | |
| } | |
| $hmac = New-Object -TypeName System.Security.Cryptography.HMACSHA256 -ArgumentList @(,$macKey) | |
| try { | |
| $ivAndCipher = New-Object byte[] ($aes.IV.Length + $cipherBytes.Length) | |
| [Buffer]::BlockCopy($aes.IV, 0, $ivAndCipher, 0, $aes.IV.Length) | |
| [Buffer]::BlockCopy($cipherBytes, 0, $ivAndCipher, $aes.IV.Length, $cipherBytes.Length) | |
| $tag = $hmac.ComputeHash($ivAndCipher) | |
| } | |
| finally { | |
| $hmac.Dispose() | |
| } | |
| # Encrypt the short AES + HMAC keys with the public RSA key. OAEP-SHA1 is | |
| # used for Windows PowerShell/.NET Framework compatibility. The private | |
| # key remains only on the RustDeskADClient/admin host. | |
| $wrappedKeyMaterial = New-Object byte[] ($aes.Key.Length + $macKey.Length) | |
| [Buffer]::BlockCopy($aes.Key, 0, $wrappedKeyMaterial, 0, $aes.Key.Length) | |
| [Buffer]::BlockCopy($macKey, 0, $wrappedKeyMaterial, $aes.Key.Length, $macKey.Length) | |
| $encryptedKey = $rsa.Encrypt($wrappedKeyMaterial, $true) | |
| $payload = [ordered]@{ | |
| v = 2 | |
| alg = 'RSA-OAEP-SHA1+A256CBC-HS256' | |
| ek = [Convert]::ToBase64String($encryptedKey) | |
| iv = [Convert]::ToBase64String($aes.IV) | |
| ct = [Convert]::ToBase64String($cipherBytes) | |
| tag = [Convert]::ToBase64String($tag) | |
| } | |
| $json = $payload | ConvertTo-Json -Compress | |
| return $PasswordCryptoPrefix + [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) | |
| } | |
| finally { | |
| if ($aes) { $aes.Dispose() } | |
| if ($rsa) { $rsa.PersistKeyInCsp = $false; $rsa.Clear() } | |
| } | |
| } | |
| function Unprotect-RustDeskADPasswordValue { | |
| param ([AllowNull()][string]$StoredValue) | |
| if ([string]::IsNullOrWhiteSpace($StoredValue)) { return $StoredValue } | |
| if (-not (Test-RustDeskADEncryptedPasswordValue $StoredValue)) { return $StoredValue } | |
| throw 'rustDeskPass is encrypted. This agent intentionally has only the public key and cannot decrypt AD-stored passwords.' | |
| } | |
| function Convert-RustDeskADPasswordStorageIfNeeded { | |
| param ( | |
| [System.DirectoryServices.DirectoryEntry]$Entry, | |
| [AllowNull()][string]$StoredValue | |
| ) | |
| if (Test-ADPasswordUnassigned $StoredValue) { return $false } | |
| if (Test-RustDeskADEncryptedPasswordValue $StoredValue) { return $false } | |
| if (-not (Test-RustDeskADPublicKeyAvailable)) { return $false } | |
| $encrypted = Protect-RustDeskADPasswordValue -PlainText $StoredValue | |
| if ($encrypted -eq $StoredValue) { return $false } | |
| Set-ADAttribute -Entry $Entry -Attribute $PasswordAttribute -Value $encrypted | |
| Write-AgentLog "$PasswordAttribute legacy plaintext value was migrated to encrypted $PasswordCryptoPrefix storage." | |
| return $true | |
| } | |
| function New-RustDeskADPasswordStorageObject { | |
| param ( | |
| [Parameter(Mandatory = $true)][string]$Password, | |
| [string]$Origin = 'generated' | |
| ) | |
| return [pscustomobject]@{ | |
| Password = $Password | |
| Origin = $Origin | |
| WroteToAD = $true | |
| CanUse = $true | |
| } | |
| } | |
| function New-RustDeskADGeneratedPasswordAndWriteToAD { | |
| param ( | |
| [System.DirectoryServices.DirectoryEntry]$Entry, | |
| [string]$Reason = 'blank-or-unusable-ad-password' | |
| ) | |
| $generated = New-RustDeskADPassword | |
| $stored = Protect-RustDeskADPasswordValue -PlainText $generated | |
| Set-ADAttribute -Entry $Entry -Attribute $PasswordAttribute -Value $stored | |
| $Entry.RefreshCache(@($PasswordAttribute)) | |
| $verifyStored = Get-ADSingleStringAttribute -Entry $Entry -Attribute $PasswordAttribute | |
| if ([string]::IsNullOrWhiteSpace($verifyStored)) { | |
| throw "AD write verification failed for $PasswordAttribute" | |
| } | |
| $storageMode = if (Test-RustDeskADEncryptedPasswordValue $verifyStored) { 'encrypted-public-key' } else { 'legacy-plaintext' } | |
| Write-AgentLog "$PasswordAttribute required a new value. Generated a new password and wrote it to this computer object. Reason='$Reason' StorageMode='$storageMode'" | |
| return (New-RustDeskADPasswordStorageObject -Password $generated -Origin "generated-and-written-to-ad-$storageMode") | |
| } | |
| # ----------------------------- | |
| # RustDesk helpers | |
| # ----------------------------- | |
| function Get-RustDeskId { | |
| if (-not (Test-Path $RustDeskExe)) { return $null } | |
| try { | |
| $id = & $RustDeskExe --get-id 2>$null | Out-String | |
| $id = $id.Trim() | |
| if ([string]::IsNullOrWhiteSpace($id)) { return $null } | |
| return $id | |
| } | |
| catch { | |
| return $null | |
| } | |
| } | |
| function Test-ADPasswordUnassigned { | |
| param ([AllowNull()][object]$Value) | |
| if ($null -eq $Value) { return $true } | |
| if ($Value -is [System.DirectoryServices.ResultPropertyValueCollection] -or | |
| $Value -is [System.DirectoryServices.PropertyValueCollection] -or | |
| ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string]))) { | |
| $items = @($Value | ForEach-Object { $_ }) | |
| if ($items.Count -eq 0) { return $true } | |
| $Value = $items[0] | |
| } | |
| $text = [string]$Value | |
| if ([string]::IsNullOrWhiteSpace($text)) { return $true } | |
| switch -Regex ($text.Trim()) { | |
| '^(null|none|n/a|na|undefined|unassigned|not set|notset|blank|empty)$' { return $true } | |
| '^<\s*(null|none|unset|unassigned|blank|empty)\s*>$' { return $true } | |
| } | |
| return $false | |
| } | |
| function Get-ADSingleStringAttribute { | |
| param ( | |
| [System.DirectoryServices.DirectoryEntry]$Entry, | |
| [string]$Attribute | |
| ) | |
| try { | |
| $Entry.RefreshCache(@($Attribute)) | |
| if (-not $Entry.Properties.Contains($Attribute)) { return $null } | |
| if ($Entry.Properties[$Attribute].Count -lt 1) { return $null } | |
| return [string]$Entry.Properties[$Attribute][0] | |
| } | |
| catch { | |
| return $null | |
| } | |
| } | |
| function New-RustDeskADPassword { | |
| param ([int]$Length = $GeneratedPasswordLength) | |
| # Prefix gives us a simple future signal that the value was agent-generated. | |
| # Keep generated passwords URI-handler friendly. RustDesk's rustdesk:// password | |
| # query parsing is not consistent across builds, so avoid characters that need | |
| # escaping or are commonly normalized/split in URI query strings: | |
| # % ? # & + = ; quotes backticks slash backslash colon spaces/control chars | |
| # This still gives strong entropy at the default length without breaking auto-launch. | |
| $prefix = 'RDAD-' | |
| $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789._~-'.ToCharArray() | |
| $bodyLength = [Math]::Max(1, ($Length - $prefix.Length)) | |
| $bytes = New-Object byte[] $bodyLength | |
| $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() | |
| try { | |
| $rng.GetBytes($bytes) | |
| } | |
| finally { | |
| $rng.Dispose() | |
| } | |
| $bodyChars = foreach ($b in $bytes) { | |
| $chars[[int]$b % $chars.Length] | |
| } | |
| return $prefix + (-join $bodyChars) | |
| } | |
| function Resolve-RustDeskPasswordFromAD { | |
| param ( | |
| [System.DirectoryServices.DirectoryEntry]$Entry, | |
| [switch]$AllowOverwriteEncrypted | |
| ) | |
| $currentStored = Get-ADSingleStringAttribute -Entry $Entry -Attribute $PasswordAttribute | |
| if (-not (Test-ADPasswordUnassigned $currentStored)) { | |
| if (Test-RustDeskADEncryptedPasswordValue $currentStored) { | |
| if ($AllowOverwriteEncrypted) { | |
| return (New-RustDeskADGeneratedPasswordAndWriteToAD -Entry $Entry -Reason 'existing-encrypted-value-cannot-be-decrypted-by-agent') | |
| } | |
| return [pscustomobject]@{ | |
| Password = $null | |
| Origin = 'existing-encrypted-ad-value-public-key-no-decrypt' | |
| WroteToAD = $false | |
| CanUse = $true | |
| } | |
| } | |
| # Legacy plaintext/manual AD value. The agent can still use it for local | |
| # service setup, then opportunistically migrate storage to RDADENC2. | |
| $currentPlain = [string]$currentStored | |
| [void](Convert-RustDeskADPasswordStorageIfNeeded -Entry $Entry -StoredValue $currentStored) | |
| $origin = if ($currentPlain -like 'RDAD-*') { 'existing-agent-generated-plaintext-ad-value' } else { 'existing-manual-or-legacy-plaintext-ad-value' } | |
| return [pscustomobject]@{ | |
| Password = $currentPlain | |
| Origin = $origin | |
| WroteToAD = $false | |
| CanUse = $true | |
| } | |
| } | |
| try { | |
| return (New-RustDeskADGeneratedPasswordAndWriteToAD -Entry $Entry -Reason 'blank-or-unassigned') | |
| } | |
| catch { | |
| throw "$PasswordAttribute is blank/unassigned and writeback failed. Refusing to set/change the local RustDesk password. Error: $($_.Exception.Message)" | |
| } | |
| } | |
| function Invoke-RustDeskExe { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string[]]$ArgumentList, | |
| [string]$Action = 'RustDesk command' | |
| ) | |
| if (-not (Test-Path $RustDeskExe)) { | |
| throw "$Action failed. rustdesk.exe not found: $RustDeskExe" | |
| } | |
| $proc = Start-Process -FilePath $RustDeskExe -ArgumentList $ArgumentList -Wait -PassThru -ErrorAction Stop | |
| if ($proc.ExitCode -ne 0) { | |
| throw "$Action failed. rustdesk.exe exit code: $($proc.ExitCode)" | |
| } | |
| } | |
| function Get-RustDeskADPasswordHashPath { | |
| $dir = Join-Path $env:ProgramData 'RustDeskAD' | |
| if (-not (Test-Path $dir)) { | |
| New-Item -Path $dir -ItemType Directory -Force | Out-Null | |
| } | |
| return (Join-Path $dir 'rustdesk-password.sha256') | |
| } | |
| function Get-RustDeskADPasswordHash { | |
| param([AllowNull()][string]$Password) | |
| if ([string]::IsNullOrWhiteSpace($Password)) { return $null } | |
| $sha = [System.Security.Cryptography.SHA256]::Create() | |
| try { | |
| $bytes = [System.Text.Encoding]::UTF8.GetBytes($Password) | |
| return [Convert]::ToBase64String($sha.ComputeHash($bytes)) | |
| } | |
| finally { | |
| $sha.Dispose() | |
| } | |
| } | |
| function Get-RustDeskADLastAppliedPasswordHash { | |
| try { | |
| $path = Get-RustDeskADPasswordHashPath | |
| if (Test-Path $path) { | |
| return ((Get-Content -Path $path -Raw -ErrorAction Stop).Trim()) | |
| } | |
| } | |
| catch {} | |
| return $null | |
| } | |
| function Set-RustDeskADLastAppliedPasswordHash { | |
| param([AllowNull()][string]$Password) | |
| $hash = Get-RustDeskADPasswordHash -Password $Password | |
| if ([string]::IsNullOrWhiteSpace($hash)) { return } | |
| $path = Get-RustDeskADPasswordHashPath | |
| Set-Content -Path $path -Value $hash -Encoding ASCII -Force | |
| } | |
| function Test-RustDeskADPasswordNeedsApply { | |
| param([AllowNull()][string]$Password) | |
| $hash = Get-RustDeskADPasswordHash -Password $Password | |
| if ([string]::IsNullOrWhiteSpace($hash)) { return $false } | |
| $lastHash = Get-RustDeskADLastAppliedPasswordHash | |
| return ($hash -ne $lastHash) | |
| } | |
| function Get-RustDeskADRemoteDesktopImagePath { | |
| return ('"{0}" --service' -f $RustDeskExe) | |
| } | |
| function Invoke-ProcessChecked { | |
| param( | |
| [Parameter(Mandatory = $true)][string]$FilePath, | |
| [string[]]$ArgumentList = @(), | |
| [string]$Action = 'External command', | |
| [int[]]$AllowedExitCodes = @(0) | |
| ) | |
| $proc = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -Wait -PassThru -WindowStyle Hidden -ErrorAction Stop | |
| if ($AllowedExitCodes -notcontains [int]$proc.ExitCode) { | |
| throw "$Action failed. ExitCode=$($proc.ExitCode) FilePath='$FilePath' Arguments='$($ArgumentList -join ' ')'." | |
| } | |
| return $proc.ExitCode | |
| } | |
| function Get-LatestRustDeskMsiUrl { | |
| try { | |
| [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 | |
| } | |
| catch {} | |
| $releaseApi = 'https://api.github.com/repos/rustdesk/rustdesk/releases/latest' | |
| $headers = @{ 'User-Agent' = 'RustDeskADAgent' } | |
| $release = Invoke-RestMethod -Uri $releaseApi -Headers $headers -UseBasicParsing -ErrorAction Stop | |
| $asset = @($release.assets | Where-Object { | |
| $_.name -match '^rustdesk-.*-x86_64\.msi$' -and | |
| -not [string]::IsNullOrWhiteSpace([string]$_.browser_download_url) | |
| } | Sort-Object name -Descending | Select-Object -First 1) | |
| if (-not $asset) { | |
| throw "Latest RustDesk release did not contain a rustdesk-*-x86_64.msi asset." | |
| } | |
| return [string]$asset.browser_download_url | |
| } | |
| function Install-LatestRustDesk { | |
| $downloadRoot = Join-Path $env:ProgramData 'RustDeskAD\Downloads' | |
| if (-not (Test-Path $downloadRoot)) { | |
| New-Item -Path $downloadRoot -ItemType Directory -Force | Out-Null | |
| } | |
| $msiUrl = Get-LatestRustDeskMsiUrl | |
| $fileName = Split-Path ([Uri]$msiUrl).AbsolutePath -Leaf | |
| if ([string]::IsNullOrWhiteSpace($fileName) -or $fileName -notmatch '\.msi$') { | |
| $fileName = 'rustdesk-latest-x86_64.msi' | |
| } | |
| $msiPath = Join-Path $downloadRoot $fileName | |
| Write-AgentLog "Downloading latest RustDesk MSI: $msiUrl" | |
| Invoke-WebRequest -Uri $msiUrl -OutFile $msiPath -UseBasicParsing -ErrorAction Stop | |
| $installLog = Join-Path $downloadRoot 'rustdesk-install.log' | |
| $installFolderArg = 'INSTALLFOLDER="{0}"' -f $RustDeskPath | |
| $msiArgs = @( | |
| '/i', | |
| $msiPath, | |
| '/qn', | |
| $installFolderArg, | |
| 'CREATESTARTMENUSHORTCUTS="N"', | |
| 'CREATEDESKTOPSHORTCUTS="N"', | |
| 'STARTUPSHORTCUTS="N"', | |
| 'STOP_SERVICE="N"', | |
| 'LAUNCH_TRAY_APP="N"', | |
| '/norestart', | |
| '/l*v', | |
| $installLog | |
| ) | |
| Write-AgentLog "Installing RustDesk MSI: $msiPath InstallFolder='$RustDeskPath' Log='$installLog'" | |
| [void](Invoke-ProcessChecked -FilePath "$env:SystemRoot\System32\msiexec.exe" -ArgumentList $msiArgs -Action 'RustDesk MSI install' -AllowedExitCodes @(0, 1641, 3010)) | |
| if (-not (Test-Path $RustDeskExe)) { | |
| throw "RustDesk MSI install completed but rustdesk.exe was not found at $RustDeskExe" | |
| } | |
| Write-AgentLog "RustDesk MSI deployed successfully. RustDeskExe='$RustDeskExe'" | |
| } | |
| function Ensure-RustDeskInstalled { | |
| if (Test-Path $RustDeskExe) { return } | |
| Write-AgentLog "RustDesk executable not found: $RustDeskExe. Downloading and deploying latest RustDesk." 'WARN' | |
| Install-LatestRustDesk | |
| } | |
| function Disable-OfficialRustDeskService { | |
| $services = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { | |
| ($OfficialRustDeskServiceNames -contains $_.Name -or $OfficialRustDeskServiceDisplayNames -contains $_.DisplayName) -and | |
| $_.Name -ne $RustDeskServiceName | |
| }) | |
| foreach ($svc in $services) { | |
| try { | |
| if ($svc.Status -ne 'Stopped') { | |
| Write-AgentLog "Stopping official RustDesk service '$($svc.Name)' / '$($svc.DisplayName)'." | |
| Stop-Service -Name $svc.Name -Force -ErrorAction Stop | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Failed to stop official RustDesk service '$($svc.Name)': $($_.Exception.Message)" 'WARN' | |
| } | |
| try { | |
| Set-Service -Name $svc.Name -StartupType Disabled -ErrorAction Stop | |
| Write-AgentLog "Disabled official RustDesk service '$($svc.Name)' / '$($svc.DisplayName)'." | |
| } | |
| catch { | |
| Write-AgentLog "Failed to disable official RustDesk service '$($svc.Name)': $($_.Exception.Message)" 'WARN' | |
| } | |
| } | |
| } | |
| function New-RustDeskADRemoteDesktopService { | |
| $imagePath = Get-RustDeskADRemoteDesktopImagePath | |
| $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$RustDeskServiceName" | |
| Write-AgentLog "Creating RustDeskAD remote desktop service '$RustDeskServiceName'." | |
| & sc.exe stop $RustDeskServiceName 2>$null | Out-Null | |
| & sc.exe delete $RustDeskServiceName 2>$null | Out-Null | |
| Start-Sleep -Seconds 2 | |
| & sc.exe create $RustDeskServiceName binPath= "C:\Windows\System32\cmd.exe" start= auto obj= LocalSystem DisplayName= $RustDeskServiceDisplayName | Out-Null | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "sc.exe create failed for service '$RustDeskServiceName'. ExitCode=$LASTEXITCODE" | |
| } | |
| Set-ItemProperty -Path $regPath -Name ImagePath -Value $imagePath -ErrorAction Stop | |
| & sc.exe description $RustDeskServiceName "Agent created RustDesk service intentionally isolated from RustDesk app service controls" | Out-Null | |
| & sc.exe failure $RustDeskServiceName reset= 60 actions= restart/5000/restart/5000/restart/10000 | Out-Null | |
| & sc.exe failureflag $RustDeskServiceName 1 | Out-Null | |
| Write-AgentLog "Created service '$RustDeskServiceName' with ImagePath=$imagePath" | |
| } | |
| function Ensure-RustDeskADServiceStartupEnabled { | |
| $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$RustDeskServiceName" | |
| try { | |
| $startValue = [int](Get-ItemPropertyValue -Path $regPath -Name Start -ErrorAction Stop) | |
| if ($startValue -ne 2) { | |
| Write-AgentLog "Service '$RustDeskServiceName' startup was not Automatic. RegistryStart='$startValue'. Re-enabling." 'WARN' | |
| Set-ItemProperty -Path $regPath -Name Start -Value 2 -ErrorAction Stop | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Unable to verify/repair startup type for service '$RustDeskServiceName' through registry. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| try { | |
| Set-Service -Name $RustDeskServiceName -StartupType Automatic -ErrorAction Stop | |
| } | |
| catch { | |
| Write-AgentLog "Set-Service could not force startup type to Automatic for '$RustDeskServiceName'. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| } | |
| function Ensure-RustDeskADRemoteDesktopService { | |
| Ensure-RustDeskInstalled | |
| Disable-OfficialRustDeskService | |
| $imagePath = Get-RustDeskADRemoteDesktopImagePath | |
| $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$RustDeskServiceName" | |
| $service = Get-Service -Name $RustDeskServiceName -ErrorAction SilentlyContinue | |
| $needsCreate = ($null -eq $service) | |
| if (-not $needsCreate) { | |
| try { | |
| $currentImagePath = [string](Get-ItemPropertyValue -Path $regPath -Name ImagePath -ErrorAction Stop) | |
| if ($currentImagePath -ne $imagePath) { | |
| Write-AgentLog "Service '$RustDeskServiceName' ImagePath mismatch. Current='$currentImagePath' Expected='$imagePath'. Recreating." 'WARN' | |
| $needsCreate = $true | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Service '$RustDeskServiceName' registry ImagePath missing/unreadable. Recreating. Error: $($_.Exception.Message)" 'WARN' | |
| $needsCreate = $true | |
| } | |
| } | |
| if ($needsCreate) { | |
| New-RustDeskADRemoteDesktopService | |
| Ensure-RustDeskADServiceStartupEnabled | |
| $service = Get-Service -Name $RustDeskServiceName -ErrorAction Stop | |
| } | |
| else { | |
| Ensure-RustDeskADServiceStartupEnabled | |
| $service = Get-Service -Name $RustDeskServiceName -ErrorAction Stop | |
| } | |
| if ($service.Status -ne 'Running') { | |
| Write-AgentLog "Starting service '$RustDeskServiceName'. CurrentStatus='$($service.Status)'" | |
| Ensure-RustDeskADServiceStartupEnabled | |
| Start-Service -Name $RustDeskServiceName -ErrorAction Stop | |
| } | |
| return (Get-Service -Name $RustDeskServiceName -ErrorAction Stop) | |
| } | |
| function Restart-RustDeskServiceIfPresent { | |
| param( | |
| [string]$Reason = 'RustDesk configuration changed', | |
| [AllowNull()][string]$Password = $null | |
| ) | |
| if (-not [string]::IsNullOrWhiteSpace($Password)) { | |
| Invoke-RustDeskExe -ArgumentList @('--password', $Password) -Action 'Set RustDesk password before service restart' | |
| Set-RustDeskADLastAppliedPasswordHash -Password $Password | |
| } | |
| $service = Get-Service -Name $RustDeskServiceName -ErrorAction SilentlyContinue | |
| if ($null -eq $service) { | |
| return | |
| } | |
| try { | |
| Write-AgentLog "Restarting RustDeskAD remote desktop service '$RustDeskServiceName'. Reason='$Reason'" | |
| Ensure-RustDeskADServiceStartupEnabled | |
| Restart-Service -Name $RustDeskServiceName -Force -ErrorAction Stop | |
| Write-AgentLog "RustDeskAD remote desktop service restarted successfully." | |
| } | |
| catch { | |
| Write-AgentLog "Restart-Service failed, trying stop/start fallback. Error: $($_.Exception.Message)" 'WARN' | |
| Stop-Service -Name $RustDeskServiceName -Force -ErrorAction Stop | |
| Ensure-RustDeskADServiceStartupEnabled | |
| Start-Service -Name $RustDeskServiceName -ErrorAction Stop | |
| Write-AgentLog "RustDeskAD remote desktop service stop/start fallback completed successfully." | |
| } | |
| } | |
| function Ensure-RustDeskService { | |
| param ([System.DirectoryServices.DirectoryEntry]$Entry) | |
| if ($NoRustDeskServiceRepair) { return $null } | |
| $serviceBeforeRepair = Get-Service -Name $RustDeskServiceName -ErrorAction SilentlyContinue | |
| Ensure-RustDeskADRemoteDesktopService | Out-Null | |
| if ($null -eq $serviceBeforeRepair) { | |
| $passwordInfo = Resolve-RustDeskPasswordFromAD -Entry $Entry -AllowOverwriteEncrypted | |
| Write-AgentLog "RustDeskAD remote desktop service was missing and has been recreated. Applying local RustDesk password before restart. PasswordOrigin='$($passwordInfo.Origin)'" | |
| Restart-RustDeskServiceIfPresent -Reason 'RustDeskAD remote desktop service was recreated; local RustDesk password was applied' -Password $passwordInfo.Password | |
| return $passwordInfo | |
| } | |
| $service = Get-Service -Name $RustDeskServiceName -ErrorAction SilentlyContinue | |
| if ($service -ne $null) { | |
| $current = Get-ADSingleStringAttribute -Entry $Entry -Attribute $PasswordAttribute | |
| if (Test-ADPasswordUnassigned $current) { | |
| $passwordInfo = Resolve-RustDeskPasswordFromAD -Entry $Entry | |
| Write-AgentLog "RustDeskAD remote desktop service already exists but AD password was blank. Updating local RustDesk password from generated AD value. PasswordOrigin='$($passwordInfo.Origin)'" | |
| Restart-RustDeskServiceIfPresent -Reason 'rustDeskPass was generated and local RustDesk password was reset' -Password $passwordInfo.Password | |
| Write-AgentLog "Existing RustDeskAD remote desktop service password updated from AD successfully. PasswordOrigin='$($passwordInfo.Origin)'" | |
| return $passwordInfo | |
| } | |
| if (Test-RustDeskADEncryptedPasswordValue $current) { | |
| $origin = 'existing-encrypted-ad-value-public-key-no-decrypt' | |
| } | |
| else { | |
| # Plaintext/manual value is usable by the agent for local service config. Apply it before | |
| # migration so manual password changes in AD become effective, then encrypt storage when possible. | |
| $currentPlain = [string]$current | |
| if (Test-RustDeskADPasswordNeedsApply -Password $currentPlain) { | |
| Restart-RustDeskServiceIfPresent -Reason 'plaintext rustDeskPass changed in AD; local RustDesk password was rotated' -Password $currentPlain | |
| } | |
| else { | |
| Write-AgentLog 'Plaintext rustDeskPass matches last applied local password hash; no RustDesk service restart needed.' | |
| } | |
| [void](Convert-RustDeskADPasswordStorageIfNeeded -Entry $Entry -StoredValue $current) | |
| $origin = if ($currentPlain -like 'RDAD-*') { | |
| 'agent-generated-plaintext-applied-and-migrated-if-key-present' | |
| } | |
| else { | |
| 'existing-manual-plaintext-applied-and-migrated-if-key-present' | |
| } | |
| } | |
| Write-AgentLog "RustDeskAD remote desktop service exists and is controlled by agent. PasswordOrigin='$origin'" | |
| return [pscustomobject]@{ | |
| Password = $null | |
| Origin = $origin | |
| WroteToAD = $false | |
| CanUse = $true | |
| } | |
| } | |
| # Defensive fallback. Ensure-RustDeskADRemoteDesktopService should have created the service already, | |
| # but keep the old missing-service flow so deletion during the cycle gets repaired cleanly. | |
| $passwordInfo = Resolve-RustDeskPasswordFromAD -Entry $Entry -AllowOverwriteEncrypted | |
| Write-AgentLog "RustDeskAD remote desktop service missing after repair attempt; setting password and recreating service. PasswordOrigin='$($passwordInfo.Origin)'" | |
| Invoke-RustDeskExe -ArgumentList @('--password', $passwordInfo.Password) -Action 'Pre-create RustDesk password set' | |
| Set-RustDeskADLastAppliedPasswordHash -Password $passwordInfo.Password | |
| New-RustDeskADRemoteDesktopService | |
| Ensure-RustDeskADServiceStartupEnabled | |
| Start-Service -Name $RustDeskServiceName -ErrorAction Stop | |
| Write-AgentLog "RustDeskAD remote desktop service created after password was set from AD. PasswordOrigin='$($passwordInfo.Origin)'" | |
| return $passwordInfo | |
| } | |
| # ----------------------------- | |
| # Local fact helpers | |
| # ----------------------------- | |
| function Get-LastLoggedOnUser { | |
| $paths = @( | |
| 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI', | |
| 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI\SessionData\1' | |
| ) | |
| foreach ($path in $paths) { | |
| try { | |
| $p = Get-ItemProperty -Path $path -ErrorAction Stop | |
| foreach ($name in @('LastLoggedOnUser', 'LastLoggedOnSAMUser', 'LoggedOnSAMUser', 'UserName')) { | |
| if ($p.$name -and -not [string]::IsNullOrWhiteSpace([string]$p.$name)) { | |
| return [string]$p.$name | |
| } | |
| } | |
| } | |
| catch {} | |
| } | |
| return $null | |
| } | |
| function Get-InteractiveUsers { | |
| $users = @() | |
| try { | |
| $users += Get-CimInstance Win32_Process -Filter "name = 'explorer.exe'" | | |
| ForEach-Object { | |
| try { | |
| $owner = $_ | Invoke-CimMethod -MethodName GetOwner | |
| if ($owner.User) { | |
| if ($owner.Domain) { "$($owner.Domain)\$($owner.User)" } else { "$($owner.User)" } | |
| } | |
| } | |
| catch {} | |
| } | | |
| Where-Object { $_ } | | |
| Sort-Object -Unique | |
| } | |
| catch {} | |
| return @($users | Sort-Object -Unique) | |
| } | |
| function Get-PrimaryIPv4 { | |
| try { | |
| return @(Get-NetIPConfiguration | | |
| Where-Object { $_.IPv4DefaultGateway -and $_.IPv4Address } | | |
| ForEach-Object { $_.IPv4Address.IPAddress })[0] | |
| } | |
| catch { | |
| return $null | |
| } | |
| } | |
| function Get-IPAddresses { | |
| try { | |
| return @(Get-NetIPAddress -AddressFamily IPv4, IPv6 -ErrorAction Stop | | |
| Where-Object { | |
| $_.IPAddress -and | |
| $_.IPAddress -notlike '169.254.*' -and | |
| $_.IPAddress -ne '127.0.0.1' -and | |
| $_.IPAddress -ne '::1' | |
| } | | |
| Select-Object -ExpandProperty IPAddress | | |
| Sort-Object -Unique) | |
| } | |
| catch { | |
| return @() | |
| } | |
| } | |
| function Get-MacAddresses { | |
| try { | |
| return @(Get-CimInstance Win32_NetworkAdapter | | |
| Where-Object { $_.PhysicalAdapter -eq $true -and $_.MACAddress } | | |
| Select-Object -ExpandProperty MACAddress | | |
| Sort-Object -Unique) | |
| } | |
| catch { | |
| return @() | |
| } | |
| } | |
| function Get-NetworkAdapters { | |
| $items = @() | |
| try { | |
| $adapters = Get-CimInstance Win32_NetworkAdapter | | |
| Where-Object { $_.PhysicalAdapter -eq $true -and $_.MACAddress } | |
| foreach ($adapter in $adapters) { | |
| $items += [ordered]@{ | |
| name = $adapter.Name | |
| netConnectionId= $adapter.NetConnectionID | |
| macAddress = $adapter.MACAddress | |
| adapterType = $adapter.AdapterType | |
| speed = $adapter.Speed | |
| manufacturer = $adapter.Manufacturer | |
| netEnabled = $adapter.NetEnabled | |
| } | |
| } | |
| } | |
| catch {} | |
| return @($items) | |
| } | |
| function Get-LocalFacts { | |
| $bios = $null | |
| $cs = $null | |
| $os = $null | |
| $csProd = $null | |
| $baseBd = $null | |
| try { $bios = Get-CimInstance Win32_BIOS } catch {} | |
| try { $cs = Get-CimInstance Win32_ComputerSystem } catch {} | |
| try { $os = Get-CimInstance Win32_OperatingSystem } catch {} | |
| try { $csProd = Get-CimInstance Win32_ComputerSystemProduct } catch {} | |
| try { $baseBd = Get-CimInstance Win32_BaseBoard } catch {} | |
| $bootTime = $null | |
| $uptimeSeconds = $null | |
| if ($os -and $os.LastBootUpTime) { | |
| try { | |
| $bootTime = ([DateTime]$os.LastBootUpTime).ToUniversalTime().ToString('o') | |
| $uptimeSeconds = [Int64]((Get-Date) - ([DateTime]$os.LastBootUpTime)).TotalSeconds | |
| } | |
| catch {} | |
| } | |
| $machineGuid = $null | |
| try { $machineGuid = Get-ItemPropertyValue -Path 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name 'MachineGuid' -ErrorAction Stop } catch {} | |
| $machineSid = $null | |
| try { | |
| $account = New-Object Security.Principal.NTAccount("$env:COMPUTERNAME`$") | |
| $machineSid = $account.Translate([Security.Principal.SecurityIdentifier]).AccountDomainSid.Value | |
| } | |
| catch {} | |
| $smbiosUuid = if ($csProd) { $csProd.UUID } else { $null } | |
| if ($smbiosUuid -match '^(00000000-0000-0000-0000-000000000000|FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF)$') { | |
| $smbiosUuid = $null | |
| } | |
| $systemSerial = if ($csProd) { $csProd.IdentifyingNumber } else { $null } | |
| if ($systemSerial -match '^(|None|To Be Filled By O\.E\.M\.|To be filled by O\.E\.M\.|System Serial Number|Default string)$') { | |
| $systemSerial = $null | |
| } | |
| $baseBoardSerial = if ($baseBd) { $baseBd.SerialNumber } else { $null } | |
| if ($baseBoardSerial -match '^(|None|To Be Filled By O\.E\.M\.|To be filled by O\.E\.M\.|Base Board Serial Number|Default string)$') { | |
| $baseBoardSerial = $null | |
| } | |
| $biosSerial = if ($bios) { $bios.SerialNumber } else { $null } | |
| if ($biosSerial -match '^(|None|To Be Filled By O\.E\.M\.|To be filled by O\.E\.M\.|System Serial Number|Default string)$') { | |
| $biosSerial = $null | |
| } | |
| $preferredStableId = @($smbiosUuid, $machineGuid, $systemSerial, $baseBoardSerial, $biosSerial, $machineSid) | | |
| Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | | |
| Select-Object -First 1 | |
| return [ordered]@{ | |
| generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') | |
| generatedBy = "$env:USERDOMAIN\$env:USERNAME" | |
| runAsSid = ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value | |
| computerName = $env:COMPUTERNAME | |
| userDnsDomain = $env:USERDNSDOMAIN | |
| userDomain = $env:USERDOMAIN | |
| lastLoggedOnUser = Get-LastLoggedOnUser | |
| interactiveUsers = Get-InteractiveUsers | |
| primaryIPv4 = Get-PrimaryIPv4 | |
| ipAddresses = Get-IPAddresses | |
| macAddresses = Get-MacAddresses | |
| networkAdapters = Get-NetworkAdapters | |
| preferredStableId = $preferredStableId | |
| smbiosUuid = $smbiosUuid | |
| machineGuid = $machineGuid | |
| systemSerial = $systemSerial | |
| baseBoardSerial = $baseBoardSerial | |
| biosSerial = $biosSerial | |
| machineSid = $machineSid | |
| manufacturer = if ($cs) { $cs.Manufacturer } else { $null } | |
| model = if ($cs) { $cs.Model } else { $null } | |
| systemType = if ($cs) { $cs.SystemType } else { $null } | |
| serialNumber = if ($bios) { $bios.SerialNumber } else { $null } | |
| biosVersion = if ($bios) { @($bios.SMBIOSBIOSVersion, $bios.Version) | Where-Object { $_ } | Select-Object -First 1 } else { $null } | |
| osCaption = if ($os) { $os.Caption } else { $null } | |
| osVersion = if ($os) { $os.Version } else { $null } | |
| osBuildNumber = if ($os) { $os.BuildNumber } else { $null } | |
| lastBootUpTimeUtc = $bootTime | |
| uptimeSeconds = $uptimeSeconds | |
| rustDeskId = $rustDeskId | |
| } | |
| } | |
| # ----------------------------- | |
| # Remote command queue helpers | |
| # ----------------------------- | |
| function New-RustDeskADCommandResult { | |
| param( | |
| [Parameter(Mandatory = $true)] $Command, | |
| [Parameter(Mandatory = $true)] [string]$Status, | |
| [string]$Message = '', | |
| [string]$Error = '' | |
| ) | |
| $id = '' | |
| $action = '' | |
| $requestedBy = '' | |
| $requestedAt = '' | |
| try { $id = [string]$Command.id } catch {} | |
| try { $action = [string]$Command.action } catch {} | |
| try { $requestedBy = [string]$Command.requestedBy } catch {} | |
| try { $requestedAt = [string]$Command.requestedAt } catch {} | |
| return [ordered]@{ | |
| id = $id | |
| action = $action | |
| status = $Status | |
| message = $Message | |
| error = $Error | |
| requestedBy = $requestedBy | |
| requestedAt = $requestedAt | |
| handledBy = $env:COMPUTERNAME | |
| handledAt = (Get-Date).ToUniversalTime().ToString('o') | |
| } | |
| } | |
| function Set-RustDeskADCommandState { | |
| param( | |
| [Parameter(Mandatory = $true)] [System.DirectoryServices.DirectoryEntry]$Entry, | |
| [Parameter(Mandatory = $true)] $Result | |
| ) | |
| $json = $Result | ConvertTo-Json -Depth 8 -Compress | |
| Set-ADAttribute -Entry $Entry -Attribute $CommandAttribute -Value $json | |
| return $Result | |
| } | |
| function Get-RustDeskADCommandObject { | |
| param([Parameter(Mandatory = $true)] [System.DirectoryServices.DirectoryEntry]$Entry) | |
| try { | |
| $Entry.RefreshCache(@($CommandAttribute)) | |
| if (-not $Entry.Properties.Contains($CommandAttribute)) { return $null } | |
| $raw = [string]$Entry.Properties[$CommandAttribute].Value | |
| if ([string]::IsNullOrWhiteSpace($raw)) { return $null } | |
| return ($raw | ConvertFrom-Json -ErrorAction Stop) | |
| } | |
| catch { | |
| Write-AgentLog "Failed to parse $CommandAttribute JSON: $($_.Exception.Message)" 'WARN' | |
| return $null | |
| } | |
| } | |
| function Invoke-RustDeskADQueuedCommand { | |
| param([Parameter(Mandatory = $true)] [System.DirectoryServices.DirectoryEntry]$Entry) | |
| $cmd = Get-RustDeskADCommandObject -Entry $Entry | |
| if ($null -eq $cmd) { return $null } | |
| $status = '' | |
| try { $status = [string]$cmd.status } catch {} | |
| # Only pending/blank commands execute. Completed/failed records are left in AD as an audit/result marker. | |
| if (-not [string]::IsNullOrWhiteSpace($status) -and $status -ne 'pending') { | |
| return $cmd | |
| } | |
| $commandLine = '' | |
| try { $commandLine = ([string]$cmd.commandLine).Trim() } catch {} | |
| if ([string]::IsNullOrWhiteSpace($commandLine)) { | |
| $result = New-RustDeskADCommandResult -Command $cmd -Status 'failed' -Error 'Queued command was missing commandLine.' | |
| Write-AgentLog "Queued command failed: commandLine was blank." 'ERROR' | |
| return Set-RustDeskADCommandState -Entry $Entry -Result $result | |
| } | |
| try { | |
| # Literal command execution by design: RustDeskADClient writes the exact Windows command. | |
| # cmd.exe /d /c avoids AutoRun pollution and lets normal Windows command lines work as expected. | |
| Start-Process -FilePath "$env:SystemRoot\System32\cmd.exe" -ArgumentList @('/d', '/c', $commandLine) -WindowStyle Hidden | |
| $result = New-RustDeskADCommandResult -Command $cmd -Status 'completed' -Message "Started commandLine through cmd.exe." | |
| Write-AgentLog "Executed queued command '$commandLine'. $($result.message)" | |
| return Set-RustDeskADCommandState -Entry $Entry -Result $result | |
| } | |
| catch { | |
| $result = New-RustDeskADCommandResult -Command $cmd -Status 'failed' -Error $_.Exception.Message | |
| Write-AgentLog "Queued command '$commandLine' failed: $($_.Exception.Message)" 'ERROR' | |
| return Set-RustDeskADCommandState -Entry $Entry -Result $result | |
| } | |
| } | |
| # ----------------------------- | |
| # Publish cycle | |
| # ----------------------------- | |
| function Publish-RustDeskMetadata { | |
| $entry = Get-ThisComputerDirectoryEntry | |
| $passwordInfo = $null | |
| try { | |
| $passwordInfo = Ensure-RustDeskService -Entry $entry | |
| } | |
| catch { | |
| Write-AgentLog "RustDesk service check failed: $($_.Exception.Message)" 'WARN' | |
| } | |
| $commandInfo = $null | |
| try { | |
| $commandInfo = Invoke-RustDeskADQueuedCommand -Entry $entry | |
| } | |
| catch { | |
| Write-AgentLog "Queued command processing failed: $($_.Exception.Message)" 'WARN' | |
| } | |
| $rustDeskId = Get-RustDeskId | |
| $rustDeskUri = if ($rustDeskId) { "rustdesk://$rustDeskId" } else { $null } | |
| if ($rustDeskUri) { | |
| Set-ADAttribute -Entry $entry -Attribute $UriAttribute -Value $rustDeskUri | |
| } | |
| $entry.RefreshCache() | |
| $ad = Get-ADAttributesForEntry -Entry $entry -Attributes $ADAttributes | |
| # Make sure the in-memory copy agrees with what we just generated. | |
| if ($rustDeskUri) { | |
| $ad[$UriAttribute] = $rustDeskUri | |
| } | |
| $adFriendly = [ordered]@{ | |
| lastLogonTimestamp_utc = Convert-FileTimeUtc $ad['lastLogonTimestamp'] | |
| pwdLastSet_utc = Convert-FileTimeUtc $ad['pwdLastSet'] | |
| 'ms-Mcs-AdmPwdExpirationTime_utc' = Convert-FileTimeUtc $ad['ms-Mcs-AdmPwdExpirationTime'] | |
| 'msLAPS-PasswordExpirationTime_utc' = Convert-FileTimeUtc $ad['msLAPS-PasswordExpirationTime'] | |
| } | |
| $metadata = [ordered]@{ | |
| schemaVersion = 2 | |
| agent = [ordered]@{ | |
| name = 'RustDesk AD Metadata Agent' | |
| mode = if ($Agent) { 'agent' } else { 'oneshot' } | |
| intervalSeconds = if ($Agent) { $IntervalSeconds } else { $null } | |
| lastupdated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') | |
| processId = $PID | |
| exePath = Get-SelfPath | |
| arbitraryCommandsEnabled = [bool]$EnableArbitraryCommands | |
| rustDeskPass = if ($passwordInfo) { [ordered]@{ origin = $passwordInfo.Origin; wroteToAD = $passwordInfo.WroteToAD; canUse = $passwordInfo.CanUse } } else { $null } | |
| lastCommand = $commandInfo | |
| } | |
| ad = $ad | |
| adComputed = $adFriendly | |
| local = Get-LocalFacts | |
| } | |
| $json = $metadata | ConvertTo-Json -Depth 16 -Compress | |
| Set-ADAttribute -Entry $entry -Attribute $MetadataAttribute -Value $json | |
| Write-AgentLog "Published metadata. RustDeskId='$rustDeskId' JSONLength=$($json.Length)" | |
| } | |
| function Invoke-PublishWithRetry { | |
| $attempt = 0 | |
| $lastError = $null | |
| while ($attempt -lt $MaxPublishRetries) { | |
| $attempt++ | |
| try { | |
| Publish-RustDeskMetadata | |
| $script:ConsecutivePublishFailures = 0 | |
| return $true | |
| } | |
| catch { | |
| $lastError = $_.Exception.Message | |
| $script:ConsecutivePublishFailures++ | |
| Clear-ADCachedLdapServer | |
| $level = if ($attempt -lt $MaxPublishRetries) { 'WARN' } else { 'ERROR' } | |
| Write-AgentLog "Publish attempt $attempt/$MaxPublishRetries failed: $lastError" $level | |
| if ($attempt -lt $MaxPublishRetries) { | |
| $delay = [Math]::Min(($RetryDelaySeconds * $attempt), 60) | |
| Start-Sleep -Seconds $delay | |
| } | |
| } | |
| } | |
| Write-AgentLog "Publish failed after $MaxPublishRetries attempt(s). ConsecutiveFailures=$script:ConsecutivePublishFailures. Agent will keep running and try again next cycle." 'ERROR' | |
| return $false | |
| } | |
| # ----------------------------- | |
| # Main | |
| # ----------------------------- | |
| try { | |
| # Normalize interval before any install/bootstrap path so services inherit the | |
| # same effective cadence as the current invocation. The original loop also | |
| # enforced a 15-second floor, so keep that behavior consistent here. | |
| if ($IntervalSeconds -lt 15) { | |
| Write-AgentLog "IntervalSeconds '$IntervalSeconds' is below minimum. Using 15." 'WARN' | |
| $IntervalSeconds = 15 | |
| } | |
| if ($InstallService) { | |
| Install-RustDeskADAgentService | |
| exit 0 | |
| } | |
| if ($UninstallService) { | |
| Uninstall-RustDeskADAgentService | |
| exit 0 | |
| } | |
| if ($InstallScheduledTask) { | |
| Install-AgentScheduledTask | |
| exit 0 | |
| } | |
| if ($UninstallScheduledTask) { | |
| Uninstall-AgentScheduledTask | |
| exit 0 | |
| } | |
| if ($Agent) { | |
| if (Ensure-RustDeskADAgentServiceFromCurrentRun) { | |
| exit 0 | |
| } | |
| $createdNew = $false | |
| $mutex = New-Object System.Threading.Mutex($true, $MutexName, [ref]$createdNew) | |
| if (-not $createdNew) { | |
| Write-AgentLog 'Another metadata agent instance is already running. Exiting cleanly.' 'WARN' | |
| exit 0 | |
| } | |
| try { | |
| Write-AgentLog "Agent started. IntervalSeconds=$IntervalSeconds" | |
| while ($true) { | |
| if (-not (Test-RustDeskADAgentServiceWrapperParentAlive)) { | |
| exit 0 | |
| } | |
| try { | |
| # Keep the agent service itself repaired using the same self-healing pattern as the RustDeskAD remote desktop service. | |
| # Normal wrapped children avoid starting the service to prevent SCM recursion. | |
| # If this child was intentionally orphaned because the wrapper service was stopped, allow it to restart the service. | |
| if ($script:RustDeskADAgentServiceWrapperOrphaned) { | |
| [void](Ensure-RustDeskADAgentWindowsService) | |
| Complete-RustDeskADAgentServiceOrphanRepairIfNeeded | |
| } | |
| else { | |
| [void](Ensure-RustDeskADAgentWindowsService -DoNotStart) | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Agent service self-repair failed but metadata publishing will continue. Error: $($_.Exception.Message)" 'WARN' | |
| } | |
| [void](Invoke-PublishWithRetry) | |
| $sleepSeconds = $IntervalSeconds | |
| if ($script:ConsecutivePublishFailures -gt 0) { | |
| # Back off slightly during outages so domain controller/DNS/VPN loss does not create log spam. | |
| $sleepSeconds = [Math]::Min(($IntervalSeconds + ($script:ConsecutivePublishFailures * $RetryDelaySeconds)), 900) | |
| } | |
| Start-RustDeskADAgentResponsiveSleep -Seconds $sleepSeconds | |
| } | |
| } | |
| finally { | |
| try { $mutex.ReleaseMutex() | Out-Null } catch {} | |
| try { $mutex.Dispose() } catch {} | |
| } | |
| } | |
| else { | |
| if (Invoke-PublishWithRetry) { exit 0 } | |
| exit 1 | |
| } | |
| } | |
| catch { | |
| Write-AgentLog "Fatal error: $($_.Exception.Message)" 'ERROR' | |
| exit 1 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # DOM data refresh preserves expanded cards/details during auto-refresh. | |
| # Implements JIT (Just-In-Time) credential fetching to avoid DOM-based cleartext passwords. | |
| # RustDeskADClient | |
| # Maintainer map: | |
| # 1. Startup/configuration and shared state | |
| # 2. HTTP response/browser helpers | |
| # 3. Active Directory lookup/status/credential helpers | |
| # 4. LAPS/metadata rendering helpers | |
| # 5. RustDesk/WOL action helpers | |
| # 6. HTML/CSS/JavaScript generation | |
| # 7. HttpListener route loop | |
| # | |
| # Rebase goal: | |
| # Keep the UI and behavior the same, but make the script easier to read, | |
| # patch, and reason about. Routes should return JSON through Send-JsonResponse | |
| # and page responses through Send-HtmlResponse so endpoint logic stays small. | |
| param ( | |
| [int]$Port = 8765, | |
| [string]$WakeBroadcast = "255.255.255.255", | |
| [int]$WakePort = 9, | |
| [switch]$Listen, | |
| [switch]$PWA, | |
| [switch]$NoHeartBeat, | |
| [switch]$NoOpen, | |
| [switch]$RefreshExisting, | |
| [switch]$UriEXELaunch, | |
| [switch]$Debug, | |
| [int]$HeartbeatIntervalSec = 5, | |
| [int]$HeartbeatTimeoutSec = 20, | |
| [switch]$GenerateRustDeskADKeyPair, | |
| # Private key stays only on this RustDeskADClient/admin host. | |
| [string]$RustDeskADPrivateKeyPath = "$env:ProgramData\RustDeskAD\rustdeskad-private.xml", | |
| # Public key is safe to copy to agents. Used only by -GenerateRustDeskADKeyPair. | |
| [string]$RustDeskADPublicKeyPath = "$env:ProgramData\RustDeskAD\rustdeskad-public.xml" | |
| ) | |
| Add-Type -AssemblyName System.Web | |
| $script:RustDeskADDebug = [bool]$Debug | |
| function Write-RDLog { | |
| param ( | |
| [Parameter(Position = 0)] [AllowEmptyString()] [string]$Message = "", | |
| [ValidateSet("TRACE", "DEBUG", "INFO", "WARN", "ERROR", "OK", "MARK")] [string]$Level = "INFO", | |
| [ConsoleColor]$ForegroundColor = [ConsoleColor]::Gray, | |
| [switch]$NoTimestamp | |
| ) | |
| if (-not $script:RustDeskADDebug) { return } | |
| if ([string]::IsNullOrEmpty($Message)) { | |
| Write-Host "" | |
| return | |
| } | |
| $prefix = if ($NoTimestamp) { "[$Level]" } else { "[{0}] [{1}]" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $Level } | |
| Write-Host "$prefix $Message" -ForegroundColor $ForegroundColor | |
| } | |
| function Write-RDMarker { | |
| param ( | |
| [Parameter(Mandatory = $true)] [string]$Name, | |
| [string]$Detail = "" | |
| ) | |
| if ([string]::IsNullOrWhiteSpace($Detail)) { | |
| Write-RDLog "===== $Name =====" -Level "MARK" -ForegroundColor Cyan | |
| } | |
| else { | |
| Write-RDLog "===== $Name :: $Detail =====" -Level "MARK" -ForegroundColor Cyan | |
| } | |
| } | |
| Write-RDMarker "Startup" "Port=$Port Listen=$($Listen.IsPresent) PWA=$($PWA.IsPresent) NoHeartBeat=$($NoHeartBeat.IsPresent) NoOpen=$($NoOpen.IsPresent) RefreshExisting=$($RefreshExisting.IsPresent) UriEXELaunch=$($UriEXELaunch.IsPresent)" | |
| $script:LastADQueryOk = $true | |
| $script:LastADError = "" | |
| $script:LastADQueryAt = $null | |
| $script:LastADDomain = "" | |
| $script:LastADDomainController = "" | |
| $script:CredentialCache = @{} | |
| $PasswordCryptoPrefix = 'RDADENC2:' | |
| $CommandAttribute = 'rustDeskCommand' | |
| function Write-HttpResponseBytes { | |
| param ( | |
| [Parameter(Mandatory = $true)] $Response, | |
| [Parameter(Mandatory = $true)] [byte[]] $Bytes | |
| ) | |
| try { | |
| $Response.OutputStream.Write($Bytes, 0, $Bytes.Length) | |
| } | |
| catch [System.Net.HttpListenerException] { | |
| # Remote browser/client disconnected before the response finished. | |
| # This is normal in -Listen mode and should not kill the listener. | |
| } | |
| catch [System.IO.IOException] { | |
| # Network stream disappeared while writing. Ignore and keep serving. | |
| } | |
| } | |
| function Close-HttpResponseSafe { | |
| param ([Parameter(Mandatory = $true)] $Response) | |
| try { | |
| $Response.OutputStream.Close() | |
| } | |
| catch [System.Net.HttpListenerException] { } | |
| catch [System.IO.IOException] { } | |
| catch { } | |
| } | |
| function Send-JsonResponse { | |
| param ( | |
| [Parameter(Mandatory = $true)] $Response, | |
| [Parameter(Mandatory = $true)] $Payload, | |
| [int]$StatusCode = 200, | |
| [int]$Depth = 4, | |
| [switch]$NoStore | |
| ) | |
| $json = ($Payload | ConvertTo-Json -Depth $Depth) | |
| $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) | |
| $Response.StatusCode = $StatusCode | |
| $Response.ContentType = "application/json; charset=utf-8" | |
| if ($NoStore) { | |
| $Response.Headers.Add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") | |
| $Response.Headers.Add("Pragma", "no-cache") | |
| } | |
| Write-HttpResponseBytes -Response $Response -Bytes $bytes | |
| } | |
| function Send-HtmlResponse { | |
| param ( | |
| [Parameter(Mandatory = $true)] $Response, | |
| [Parameter(Mandatory = $true)] [string]$Html, | |
| [int]$StatusCode = 200, | |
| [switch]$NoStore | |
| ) | |
| $bytes = [System.Text.Encoding]::UTF8.GetBytes($Html) | |
| $Response.StatusCode = $StatusCode | |
| $Response.ContentType = "text/html; charset=utf-8" | |
| if ($NoStore) { | |
| $Response.Headers.Add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") | |
| $Response.Headers.Add("Pragma", "no-cache") | |
| } | |
| Write-HttpResponseBytes -Response $Response -Bytes $bytes | |
| } | |
| function Send-TextResponse { | |
| param ( | |
| [Parameter(Mandatory = $true)] $Response, | |
| [Parameter(Mandatory = $true)] [string]$Text, | |
| [string]$ContentType = "text/plain; charset=utf-8", | |
| [int]$StatusCode = 200, | |
| [switch]$NoStore | |
| ) | |
| $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) | |
| $Response.StatusCode = $StatusCode | |
| $Response.ContentType = $ContentType | |
| if ($NoStore) { | |
| $Response.Headers.Add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") | |
| $Response.Headers.Add("Pragma", "no-cache") | |
| } | |
| Write-HttpResponseBytes -Response $Response -Bytes $bytes | |
| } | |
| function Get-RustDeskRoutePath { | |
| param ([string]$Path) | |
| $p = ([string]$Path).TrimEnd('/') | |
| if ([string]::IsNullOrWhiteSpace($p)) { return "/" } | |
| $p = $p.ToLowerInvariant() | |
| $known = @( | |
| "/api/rotate-rustdesk-password", | |
| "/api/agent-command", | |
| "/api/credentials", | |
| "/manifest.webmanifest", | |
| "/service-worker.js", | |
| "/sw.js", | |
| "/icon.svg", | |
| "/favicon.ico", | |
| "/pwa-icon-192.png", | |
| "/pwa-icon-512.png", | |
| "/page-exit", | |
| "/ad-status", | |
| "/heartbeat", | |
| "/terminal", | |
| "/connect", | |
| "/health", | |
| "/rdp", | |
| "/wol" | |
| ) | |
| foreach ($route in $known) { | |
| if ($p -eq $route -or $p.EndsWith($route)) { | |
| return $route | |
| } | |
| } | |
| # When the UI sits behind an IIS virtual directory/reverse proxy, the listener | |
| # may see /RustDesk or /RustDesk/ instead of /. Treat extensionless unknown | |
| # paths as the app shell so relative manifest/API URLs still work. | |
| $leaf = ($p -split '/')[-1] | |
| if ([string]::IsNullOrWhiteSpace($leaf) -or $leaf.IndexOf('.') -lt 0) { | |
| return "/" | |
| } | |
| return $p | |
| } | |
| function Get-PwaManifestJson { | |
| $manifest = [ordered]@{ | |
| name = "RustDeskADClient" | |
| short_name = "RustDeskAD" | |
| description = "RustDesk Active Directory client console" | |
| id = "./" | |
| start_url = "./" | |
| scope = "./" | |
| display = "standalone" | |
| background_color = "#0b0f17" | |
| theme_color = "#0b0f17" | |
| icons = @( | |
| [ordered]@{ | |
| src = "icon.svg" | |
| sizes = "any" | |
| type = "image/svg+xml" | |
| purpose = "any" | |
| }, | |
| [ordered]@{ | |
| src = "pwa-icon-192.png" | |
| sizes = "192x192" | |
| type = "image/png" | |
| purpose = "any maskable" | |
| }, | |
| [ordered]@{ | |
| src = "pwa-icon-512.png" | |
| sizes = "512x512" | |
| type = "image/png" | |
| purpose = "any maskable" | |
| } | |
| ) | |
| } | |
| return ($manifest | ConvertTo-Json -Depth 6) | |
| } | |
| function Get-PwaServiceWorkerJs { | |
| @' | |
| const CACHE_VERSION = "rustdeskad-pwa-v1"; | |
| self.addEventListener("install", (event) => { | |
| self.skipWaiting(); | |
| }); | |
| self.addEventListener("activate", (event) => { | |
| event.waitUntil((async () => { | |
| const keys = await caches.keys(); | |
| await Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key))); | |
| await self.clients.claim(); | |
| })()); | |
| }); | |
| // Network-only by design. RustDeskADClient is live AD data and credential workflow, | |
| // so the service worker exists for desktop PWA install support without caching secrets. | |
| self.addEventListener("fetch", (event) => { | |
| event.respondWith(fetch(event.request)); | |
| }); | |
| '@ | |
| } | |
| function Get-PwaIconPngBytes { | |
| param ([int]$Size = 192) | |
| # These PNGs are rendered from the embedded RustDeskAD SVG above. | |
| # Keep the browser favicon as the inline SVG, but expose PNG fallbacks because | |
| # Chromium PWA install/app icons are still pickier about raster 192/512 assets. | |
| if ($Size -ge 512) { | |
| $b64 = @' | |
| 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 | |
| '@ | |
| } | |
| else { | |
| $b64 = @' | |
| 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 | |
| n+n9P+V1cK+dSuQOAAAAAElFTkSuQmCC | |
| '@ | |
| } | |
| return [Convert]::FromBase64String(($b64 -replace "\s", "")) | |
| } | |
| # ----------------------------------------------------------------------------- | |
| # Browser/listener startup helpers | |
| # ----------------------------------------------------------------------------- | |
| function Test-ExistingRustDeskListener { | |
| param ([string]$Url) | |
| try { | |
| $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop | |
| if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { | |
| $content = [string]$response.Content | |
| if ($content -match "RustDeskADClient" -or $content -match "rustDeskMetadata" -or $content -match "WOL") { | |
| return $true | |
| } | |
| } | |
| } | |
| catch {} | |
| return $false | |
| } | |
| function Get-ExecutableFromCommandLine { | |
| param ([string]$CommandLine) | |
| if ([string]::IsNullOrWhiteSpace($CommandLine)) { | |
| return $null | |
| } | |
| $expanded = [Environment]::ExpandEnvironmentVariables($CommandLine.Trim()) | |
| if ($expanded -match '^"([^"]+\.exe)"') { | |
| return $Matches[1] | |
| } | |
| if ($expanded -match '^([^\s]+\.exe)') { | |
| return $Matches[1] | |
| } | |
| if ($expanded -match '([A-Za-z]:\\[^\"]+?\.exe)') { | |
| return $Matches[1] | |
| } | |
| return $null | |
| } | |
| function Get-DefaultBrowserInfo { | |
| $progId = $null | |
| foreach ($root in @( | |
| "HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", | |
| "HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice" | |
| )) { | |
| try { | |
| $progId = (Get-ItemProperty -Path $root -ErrorAction Stop).ProgId | |
| if (-not [string]::IsNullOrWhiteSpace($progId)) { break } | |
| } | |
| catch {} | |
| } | |
| $command = $null | |
| if ($progId) { | |
| foreach ($root in @("HKCU:\Software\Classes", "HKLM:\Software\Classes")) { | |
| try { | |
| $command = (Get-ItemProperty -Path (Join-Path $root "$progId\shell\open\command") -ErrorAction Stop).'(default)' | |
| if (-not [string]::IsNullOrWhiteSpace($command)) { break } | |
| } | |
| catch {} | |
| } | |
| } | |
| if (-not $command) { | |
| try { | |
| $command = (Get-ItemProperty -Path "HKCR:\http\shell\open\command" -ErrorAction Stop).'(default)' | |
| } | |
| catch {} | |
| } | |
| $exe = Get-ExecutableFromCommandLine $command | |
| if (-not $exe -or -not (Test-Path $exe)) { | |
| return $null | |
| } | |
| $file = [System.IO.Path]::GetFileName($exe).ToLowerInvariant() | |
| $kind = switch -Regex ($file) { | |
| '^msedge\.exe$' { 'edge'; break } | |
| '^chrome\.exe$' { 'chrome'; break } | |
| '^brave\.exe$' { 'chromium'; break } | |
| '^vivaldi\.exe$' { 'chromium'; break } | |
| '^opera.*\.exe$' { 'chromium'; break } | |
| '^firefox\.exe$' { 'firefox'; break } | |
| default { 'unknown' } | |
| } | |
| [PSCustomObject]@{ | |
| ProgId = $progId | |
| Command = $command | |
| Path = $exe | |
| FileName = $file | |
| Kind = $kind | |
| } | |
| } | |
| function Open-BrowserSafe { | |
| param ([string]$Url) | |
| Write-RDMarker "Browser open" $Url | |
| $browser = Get-DefaultBrowserInfo | |
| try { | |
| if ($browser -and $browser.Path) { | |
| switch ($browser.Kind) { | |
| 'edge' { | |
| Write-RDLog "Opening in default browser PWA-style: Microsoft Edge" -ForegroundColor Cyan | |
| Start-Process -FilePath $browser.Path -ArgumentList @("--app=$Url", "--new-window") | |
| return | |
| } | |
| { $_ -in @('chrome', 'chromium') } { | |
| Write-RDLog "Opening in default browser PWA-style: $($browser.FileName)" -ForegroundColor Cyan | |
| Start-Process -FilePath $browser.Path -ArgumentList @("--app=$Url", "--new-window") | |
| return | |
| } | |
| 'firefox' { | |
| Write-RDLog "Default browser is Firefox; opening a clean new window." -ForegroundColor Cyan | |
| Start-Process -FilePath $browser.Path -ArgumentList @("-new-window", $Url) | |
| return | |
| } | |
| default { | |
| Write-RDLog "Default browser detected, but no PWA/app mode is known for $($browser.FileName)." -ForegroundColor Yellow | |
| Start-Process -FilePath $browser.Path -ArgumentList $Url | |
| return | |
| } | |
| } | |
| } | |
| Start-Process $Url | |
| } | |
| catch { | |
| Write-RDLog "Open this URL in your browser:" -ForegroundColor Yellow | |
| Write-RDLog " $Url" | |
| } | |
| } | |
| # ----------------------------------------------------------------------------- | |
| # Active Directory status, lookup, and credential mutation helpers | |
| # ----------------------------------------------------------------------------- | |
| function Test-ActiveDirectoryConnection { | |
| param ( | |
| [int]$TimeoutSeconds = 4 | |
| ) | |
| Write-RDMarker "AD connection test" "TimeoutSeconds=$TimeoutSeconds" | |
| $script:LastADQueryAt = Get-Date | |
| try { | |
| # RootDSE is a cheap live LDAP bind/read. It is better for the UI health | |
| # indicator than trusting the last inventory query or browser-cached data. | |
| $root = New-Object System.DirectoryServices.DirectoryEntry("LDAP://RootDSE") | |
| $root.RefreshCache(@("defaultNamingContext", "dnsHostName", "currentTime")) | |
| $namingContext = [string]$root.Properties["defaultNamingContext"].Value | |
| $dcHost = [string]$root.Properties["dnsHostName"].Value | |
| if ([string]::IsNullOrWhiteSpace($namingContext)) { | |
| throw "RootDSE did not return defaultNamingContext." | |
| } | |
| $script:LastADQueryOk = $true | |
| $script:LastADError = "" | |
| $script:LastADDomain = $namingContext | |
| $script:LastADDomainController = $dcHost | |
| if ($null -ne $root) { | |
| $root.Dispose() | |
| } | |
| return $true | |
| } | |
| catch { | |
| $script:LastADQueryOk = $false | |
| if ($_.Exception -and $_.Exception.InnerException) { | |
| $script:LastADError = $_.Exception.InnerException.Message | |
| } | |
| elseif ($_.Exception) { | |
| $script:LastADError = $_.Exception.Message | |
| } | |
| else { | |
| $script:LastADError = [string]$_ | |
| } | |
| return $false | |
| } | |
| } | |
| function Get-ADStatusPayload { | |
| Write-RDMarker "AD status payload" | |
| $connected = Test-ActiveDirectoryConnection | |
| $domainText = $env:USERDNSDOMAIN | |
| if ([string]::IsNullOrWhiteSpace([string]$domainText)) { | |
| $domainText = $env:USERDOMAIN | |
| } | |
| # Keep the visible subtext clean: domain name only. | |
| # Detailed DC/error information is returned separately in `error` for tooltips/debugging. | |
| return [ordered]@{ | |
| ok = $true | |
| connected = [bool]$connected | |
| statusText = if ($connected) { "Connected to Active Directory" } else { "Cannot contact Active Directory" } | |
| dotClass = if ($connected) { "" } else { "badDot" } | |
| domainText = [string]$domainText | |
| checkedAt = (Get-Date).ToString("o") | |
| error = [string]$script:LastADError | |
| } | |
| } | |
| function Get-Attributes { | |
| param ( | |
| [string[]]$Attributes, | |
| [string]$Filter = "(name=$env:ComputerName)" | |
| ) | |
| Write-RDMarker "AD attribute query" "Filter=$Filter Attributes=$($Attributes -join ',')" | |
| $script:LastADQueryAt = Get-Date | |
| $script:LastADQueryOk = $false | |
| $script:LastADError = "" | |
| try { | |
| $searcher = New-Object System.DirectoryServices.DirectorySearcher | |
| $searcher.Filter = $Filter | |
| $searcher.PageSize = 1000 | |
| $searcher.ClientTimeout = [TimeSpan]::FromSeconds(6) | |
| $searcher.ServerTimeLimit = [TimeSpan]::FromSeconds(6) | |
| $searcher.PropertiesToLoad.Clear() | |
| foreach ($attr in $Attributes) { | |
| [void]$searcher.PropertiesToLoad.Add($attr) | |
| } | |
| $results = $searcher.FindAll() | |
| $script:LastADQueryOk = $true | |
| $script:LastADError = "" | |
| $output = @() | |
| foreach ($result in $results) { | |
| $obj = [ordered]@{} | |
| foreach ($attr in $Attributes) { | |
| $key = $attr.ToLower() | |
| if ($result.Properties.Contains($key)) { | |
| if ($result.Properties[$key].Count -gt 1) { | |
| $obj[$attr] = @($result.Properties[$key]) | |
| } | |
| else { | |
| $obj[$attr] = $result.Properties[$key][0] | |
| } | |
| } | |
| else { | |
| $obj[$attr] = $null | |
| } | |
| } | |
| $output += [PSCustomObject]$obj | |
| } | |
| if ($null -ne $results) { | |
| $results.Dispose() | |
| } | |
| if ($null -ne $searcher) { | |
| $searcher.Dispose() | |
| } | |
| return $output | |
| } | |
| catch { | |
| $script:LastADQueryOk = $false | |
| if ($_.Exception -and $_.Exception.InnerException) { | |
| $script:LastADError = $_.Exception.InnerException.Message | |
| } | |
| elseif ($_.Exception) { | |
| $script:LastADError = $_.Exception.Message | |
| } | |
| else { | |
| $script:LastADError = [string]$_ | |
| } | |
| Write-RDLog "Active Directory query failed: $($script:LastADError)" -ForegroundColor Yellow | |
| # Return no computer rows, but do not throw. The web UI must stay alive | |
| # even when the domain controller/DNS/VPN/network is unavailable. | |
| return @() | |
| } | |
| } | |
| function Reset-RustDeskPasswordAttribute { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$ComputerName | |
| ) | |
| Write-RDMarker "Rotate RustDesk password" "Computer=$ComputerName" | |
| if ([string]::IsNullOrWhiteSpace($ComputerName)) { | |
| return [PSCustomObject]@{ | |
| Ok = $false | |
| Computer = $ComputerName | |
| Error = "Computer name was blank." | |
| } | |
| } | |
| $safeName = $ComputerName.Replace("\\", "\\5c").Replace("*", "\\2a").Replace("(", "\\28").Replace(")", "\\29").Replace([string][char]0, "\\00") | |
| $searcher = $null | |
| $result = $null | |
| $entry = $null | |
| try { | |
| $searcher = New-Object System.DirectoryServices.DirectorySearcher | |
| $searcher.Filter = "(&(objectCategory=computer)(name=$safeName))" | |
| $searcher.SearchScope = [System.DirectoryServices.SearchScope]::Subtree | |
| $searcher.ClientTimeout = [TimeSpan]::FromSeconds(6) | |
| $searcher.ServerTimeLimit = [TimeSpan]::FromSeconds(6) | |
| $searcher.PropertiesToLoad.Clear() | |
| [void]$searcher.PropertiesToLoad.Add("distinguishedName") | |
| [void]$searcher.PropertiesToLoad.Add("rustDeskPass") | |
| $result = $searcher.FindOne() | |
| if ($null -eq $result) { | |
| throw "Computer '$ComputerName' was not found in Active Directory." | |
| } | |
| $dn = [string]$result.Properties["distinguishedname"][0] | |
| if ([string]::IsNullOrWhiteSpace($dn)) { | |
| throw "Computer '$ComputerName' did not return a distinguishedName." | |
| } | |
| $entry = $result.GetDirectoryEntry() | |
| $entry.RefreshCache(@("rustDeskPass")) | |
| # ADUC displays an actually empty LDAP attribute as <not set>. | |
| # Clear() removes the attribute value instead of writing the literal string '<not set>'. | |
| $entry.Properties["rustDeskPass"].Clear() | |
| $entry.CommitChanges() | |
| if ($script:CredentialCache.ContainsKey($ComputerName)) { | |
| $script:CredentialCache[$ComputerName]["rustdesk"] = "" | |
| } | |
| $script:LastADQueryOk = $true | |
| $script:LastADError = "" | |
| return [PSCustomObject]@{ | |
| Ok = $true | |
| Computer = $ComputerName | |
| DistinguishedName = $dn | |
| ClearedAt = (Get-Date).ToString("o") | |
| Message = "rustDeskPass was cleared. The agent should generate and write a new password on its next run." | |
| } | |
| } | |
| catch { | |
| $err = if ($_.Exception -and $_.Exception.InnerException) { $_.Exception.InnerException.Message } elseif ($_.Exception) { $_.Exception.Message } else { [string]$_ } | |
| $script:LastADQueryOk = $false | |
| $script:LastADError = $err | |
| return [PSCustomObject]@{ | |
| Ok = $false | |
| Computer = $ComputerName | |
| Error = $err | |
| } | |
| } | |
| finally { | |
| if ($null -ne $entry) { $entry.Dispose() } | |
| if ($null -ne $searcher) { $searcher.Dispose() } | |
| } | |
| } | |
| function New-RustDeskADLdapFilterValue { | |
| param ([string]$Value) | |
| if ($null -eq $Value) { return "" } | |
| return ([string]$Value).Replace("\\", "\\5c").Replace("*", "\\2a").Replace("(", "\\28").Replace(")", "\\29").Replace([string][char]0, "\\00") | |
| } | |
| function Set-RustDeskAgentCommandAttribute { | |
| param ( | |
| [Parameter(Mandatory = $true)] [string]$ComputerName, | |
| [Parameter(Mandatory = $true)] [string]$CommandLine, | |
| [string]$Comment = "RustDeskADClient queued admin command" | |
| ) | |
| Write-RDMarker "Queue agent command" "Computer=$ComputerName CommandLength=$(([string]$CommandLine).Length)" | |
| if ([string]::IsNullOrWhiteSpace($ComputerName)) { | |
| return [PSCustomObject]@{ Ok = $false; Computer = $ComputerName; Error = "Computer name was blank." } | |
| } | |
| $literalCommand = ([string]$CommandLine).Trim() | |
| if ([string]::IsNullOrWhiteSpace($literalCommand)) { | |
| return [PSCustomObject]@{ Ok = $false; Computer = $ComputerName; Error = "Command line was blank." } | |
| } | |
| if ($literalCommand.Length -gt 4000) { | |
| return [PSCustomObject]@{ Ok = $false; Computer = $ComputerName; Error = "Command line is too long." } | |
| } | |
| $safeName = New-RustDeskADLdapFilterValue $ComputerName | |
| $searcher = $null | |
| $entry = $null | |
| try { | |
| $searcher = New-Object System.DirectoryServices.DirectorySearcher | |
| $searcher.Filter = "(&(objectCategory=computer)(name=$safeName))" | |
| $searcher.SearchScope = [System.DirectoryServices.SearchScope]::Subtree | |
| $searcher.ClientTimeout = [TimeSpan]::FromSeconds(6) | |
| $searcher.ServerTimeLimit = [TimeSpan]::FromSeconds(6) | |
| $searcher.PropertiesToLoad.Clear() | |
| [void]$searcher.PropertiesToLoad.Add("distinguishedName") | |
| [void]$searcher.PropertiesToLoad.Add($CommandAttribute) | |
| $result = $searcher.FindOne() | |
| if ($null -eq $result) { throw "Computer '$ComputerName' was not found in Active Directory." } | |
| $dn = [string]$result.Properties["distinguishedname"][0] | |
| if ([string]::IsNullOrWhiteSpace($dn)) { throw "Computer '$ComputerName' did not return a distinguishedName." } | |
| if ([string]::IsNullOrWhiteSpace($Comment)) { $Comment = "RustDeskADClient queued admin command" } | |
| if ($Comment.Length -gt 250) { $Comment = $Comment.Substring(0, 250) } | |
| $payload = [ordered]@{ | |
| id = [guid]::NewGuid().ToString("N") | |
| status = "pending" | |
| commandLine = $literalCommand | |
| comment = $Comment | |
| requestedBy = [Security.Principal.WindowsIdentity]::GetCurrent().Name | |
| requestedFrom = $env:COMPUTERNAME | |
| requestedAt = (Get-Date).ToUniversalTime().ToString("o") | |
| } | |
| $json = $payload | ConvertTo-Json -Depth 8 -Compress | |
| $entry = $result.GetDirectoryEntry() | |
| $entry.Properties[$CommandAttribute].Value = $json | |
| $entry.CommitChanges() | |
| $script:LastADQueryOk = $true | |
| $script:LastADError = "" | |
| return [PSCustomObject]@{ | |
| Ok = $true | |
| Computer = $ComputerName | |
| DistinguishedName = $dn | |
| CommandId = $payload.id | |
| CommandLine = $literalCommand | |
| QueuedAt = $payload.requestedAt | |
| Message = "Command queued. The agent will execute it on its next check-in: $literalCommand" | |
| } | |
| } | |
| catch { | |
| $err = if ($_.Exception -and $_.Exception.InnerException) { $_.Exception.InnerException.Message } elseif ($_.Exception) { $_.Exception.Message } else { [string]$_ } | |
| $script:LastADQueryOk = $false | |
| $script:LastADError = $err | |
| return [PSCustomObject]@{ Ok = $false; Computer = $ComputerName; Error = $err } | |
| } | |
| finally { | |
| if ($null -ne $entry) { $entry.Dispose() } | |
| if ($null -ne $searcher) { $searcher.Dispose() } | |
| } | |
| } | |
| function Read-RequestBodyText { | |
| param ($Request) | |
| if ($null -eq $Request -or -not $Request.HasEntityBody) { | |
| return "" | |
| } | |
| $reader = $null | |
| try { | |
| $encoding = $Request.ContentEncoding | |
| if ($null -eq $encoding) { $encoding = [Text.Encoding]::UTF8 } | |
| $reader = New-Object IO.StreamReader($Request.InputStream, $encoding) | |
| return $reader.ReadToEnd() | |
| } | |
| catch { | |
| return "" | |
| } | |
| finally { | |
| if ($null -ne $reader) { $reader.Dispose() } | |
| } | |
| } | |
| function ConvertFrom-UrlEncodedForm { | |
| param ([string]$Body) | |
| $form = @{} | |
| if ([string]::IsNullOrWhiteSpace($Body)) { | |
| return $form | |
| } | |
| foreach ($part in ($Body -split "&")) { | |
| if ([string]::IsNullOrWhiteSpace($part)) { continue } | |
| $kv = $part -split "=", 2 | |
| $keyRaw = $kv[0] | |
| $valRaw = if ($kv.Count -gt 1) { $kv[1] } else { "" } | |
| $key = [System.Net.WebUtility]::UrlDecode(($keyRaw -replace "\+", " ")) | |
| $val = [System.Net.WebUtility]::UrlDecode(($valRaw -replace "\+", " ")) | |
| if (-not [string]::IsNullOrWhiteSpace($key)) { | |
| $form[$key] = $val | |
| } | |
| } | |
| return $form | |
| } | |
| function Get-RequestValue { | |
| param ( | |
| $Request, | |
| [hashtable]$Form, | |
| [string]$Name | |
| ) | |
| if ($null -ne $Form -and $Form.ContainsKey($Name)) { | |
| return [string]$Form[$Name] | |
| } | |
| return [string]$Request.QueryString[$Name] | |
| } | |
| # ----------------------------------------------------------------------------- | |
| # LAPS, metadata, and rendering helpers | |
| # ----------------------------------------------------------------------------- | |
| function Convert-ADFileTime { | |
| param ($Value) | |
| if ($null -eq $Value -or $Value -eq "" -or $Value -eq 0) { | |
| return "" | |
| } | |
| try { | |
| return ([DateTime]::FromFileTime([Int64]$Value)).ToString("yyyy-MM-dd HH:mm") | |
| } | |
| catch { | |
| return "" | |
| } | |
| } | |
| function Get-LapsPasswordDisplay { | |
| param ($Value) | |
| if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { | |
| return "" | |
| } | |
| $text = [string]$Value | |
| # Windows LAPS stores the cleartext password in msLAPS-Password as JSON when | |
| # unencrypted storage is enabled. Example keys are usually n/t/p. | |
| try { | |
| $json = $text | ConvertFrom-Json -ErrorAction Stop | |
| if ($json.PSObject.Properties.Name -contains "p" -and -not [string]::IsNullOrWhiteSpace([string]$json.p)) { | |
| return [string]$json.p | |
| } | |
| if ($json.PSObject.Properties.Name -contains "password" -and -not [string]::IsNullOrWhiteSpace([string]$json.password)) { | |
| return [string]$json.password | |
| } | |
| # Parsed as JSON but did not contain a cleartext password field. | |
| return "" | |
| } | |
| catch { } | |
| # Legacy LAPS ms-Mcs-AdmPwd is already just the password string. | |
| return $text | |
| } | |
| function Escape-LdapFilterValue { | |
| param ([AllowNull()][string]$Value) | |
| if ($null -eq $Value) { | |
| return "" | |
| } | |
| $s = [string]$Value | |
| $s = $s.Replace("\", "\5c") | |
| $s = $s.Replace("*", "\2a") | |
| $s = $s.Replace("(", "\28") | |
| $s = $s.Replace(")", "\29") | |
| $s = $s.Replace([string][char]0, "\00") | |
| return $s | |
| } | |
| function Ensure-DpapiNgNative { | |
| if ("RustDeskAD.DpapiNgNative" -as [type]) { | |
| return | |
| } | |
| Add-Type -TypeDefinition @" | |
| using System; | |
| using System.Runtime.InteropServices; | |
| namespace RustDeskAD | |
| { | |
| public static class DpapiNgNative | |
| { | |
| [DllImport("ncrypt.dll", CharSet = CharSet.Unicode)] | |
| public static extern int NCryptUnprotectSecret( | |
| out IntPtr phDescriptor, | |
| int dwFlags, | |
| byte[] pbProtectedBlob, | |
| int cbProtectedBlob, | |
| IntPtr pMemPara, | |
| IntPtr hWnd, | |
| out IntPtr ppbData, | |
| out int pcbData | |
| ); | |
| [DllImport("ncrypt.dll")] | |
| public static extern int NCryptFreeBuffer(IntPtr pvInput); | |
| } | |
| } | |
| "@ -ErrorAction Stop | |
| } | |
| function Convert-LapsJsonTimestamp { | |
| param ($Value) | |
| if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { | |
| return $null | |
| } | |
| try { | |
| # Windows LAPS JSON field "t" is a hex Windows FILETIME. | |
| $ft = [Convert]::ToInt64([string]$Value, 16) | |
| return [DateTime]::FromFileTimeUtc($ft) | |
| } | |
| catch { | |
| return $null | |
| } | |
| } | |
| function Convert-ADFileTimeUtc { | |
| param ($Value) | |
| if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { | |
| return $null | |
| } | |
| try { | |
| return [DateTime]::FromFileTimeUtc([Int64]$Value) | |
| } | |
| catch { | |
| return $null | |
| } | |
| } | |
| function Convert-BytesToLikelyText { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [byte[]]$Bytes | |
| ) | |
| if ($Bytes.Length -eq 0) { | |
| return "" | |
| } | |
| $looksUtf16 = $false | |
| if ($Bytes.Length -ge 4) { | |
| $nullOddBytes = 0 | |
| $sampleCount = [Math]::Min($Bytes.Length, 128) | |
| for ($i = 1; $i -lt $sampleCount; $i += 2) { | |
| if ($Bytes[$i] -eq 0) { | |
| $nullOddBytes++ | |
| } | |
| } | |
| if ($nullOddBytes -ge 4) { | |
| $looksUtf16 = $true | |
| } | |
| } | |
| if ($looksUtf16) { | |
| return ([Text.Encoding]::Unicode.GetString($Bytes)).Trim([char]0, " ", "`r", "`n", "`t") | |
| } | |
| return ([Text.Encoding]::UTF8.GetString($Bytes)).Trim([char]0, " ", "`r", "`n", "`t") | |
| } | |
| function Get-ComputerDirectorySearchResult { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Identity, | |
| [Parameter(Mandatory = $true)] | |
| [string[]]$PropertiesToLoad | |
| ) | |
| $sam = [string]$Identity | |
| if ($sam.EndsWith('$')) { | |
| $samAccountName = $sam | |
| } | |
| else { | |
| $samAccountName = "$sam`$" | |
| } | |
| $escapedSam = Escape-LdapFilterValue $samAccountName | |
| $searcher = New-Object System.DirectoryServices.DirectorySearcher | |
| $searcher.Filter = "(&(objectCategory=computer)(sAMAccountName=$escapedSam))" | |
| $searcher.SearchScope = [System.DirectoryServices.SearchScope]::Subtree | |
| $searcher.PageSize = 500 | |
| foreach ($pName in $PropertiesToLoad) { | |
| [void]$searcher.PropertiesToLoad.Add($pName) | |
| } | |
| return $searcher.FindOne() | |
| } | |
| function Get-SearchResultFirstValue { | |
| param ( | |
| $SearchResult, | |
| [Parameter(Mandatory = $true)] | |
| [string]$PropertyName | |
| ) | |
| if ($null -eq $SearchResult) { | |
| return $null | |
| } | |
| $key = $PropertyName.ToLowerInvariant() | |
| if (-not $SearchResult.Properties.Contains($key)) { | |
| return $null | |
| } | |
| if ($SearchResult.Properties[$key].Count -lt 1) { | |
| return $null | |
| } | |
| return $SearchResult.Properties[$key][0] | |
| } | |
| function Unprotect-DpapiNgBlob { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [byte[]]$ProtectedBlob | |
| ) | |
| Ensure-DpapiNgNative | |
| $descriptor = [IntPtr]::Zero | |
| $dataPtr = [IntPtr]::Zero | |
| $dataLen = 0 | |
| try { | |
| $status = [RustDeskAD.DpapiNgNative]::NCryptUnprotectSecret( | |
| [ref]$descriptor, | |
| 0, | |
| $ProtectedBlob, | |
| $ProtectedBlob.Length, | |
| [IntPtr]::Zero, | |
| [IntPtr]::Zero, | |
| [ref]$dataPtr, | |
| [ref]$dataLen | |
| ) | |
| if ($status -ne 0) { | |
| throw ("NCryptUnprotectSecret failed with status 0x{0:X8}" -f $status) | |
| } | |
| if ($dataPtr -eq [IntPtr]::Zero -or $dataLen -le 0) { | |
| throw "NCryptUnprotectSecret returned empty data" | |
| } | |
| $plain = New-Object byte[] $dataLen | |
| [Runtime.InteropServices.Marshal]::Copy($dataPtr, $plain, 0, $dataLen) | |
| return $plain | |
| } | |
| finally { | |
| if ($dataPtr -ne [IntPtr]::Zero) { | |
| [void][RustDeskAD.DpapiNgNative]::NCryptFreeBuffer($dataPtr) | |
| } | |
| if ($descriptor -ne [IntPtr]::Zero) { | |
| [void][RustDeskAD.DpapiNgNative]::NCryptFreeBuffer($descriptor) | |
| } | |
| } | |
| } | |
| function ConvertFrom-WindowsLapsEncryptedPasswordBlob { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [byte[]]$Blob | |
| ) | |
| if ($Blob.Length -lt 17) { | |
| throw "msLAPS-EncryptedPassword blob is too small: $($Blob.Length) bytes" | |
| } | |
| # Windows LAPS encrypted AD blob: | |
| # 0-7 PasswordUpdateTimestamp FILETIME | |
| # 8-11 EncryptedPasswordSize | |
| # 12-15 Reserved | |
| # 16.. DPAPI-NG/CNG protected payload | |
| $updateFileTime = [BitConverter]::ToInt64($Blob, 0) | |
| $encryptedSize = [BitConverter]::ToInt32($Blob, 8) | |
| $reserved = [BitConverter]::ToInt32($Blob, 12) | |
| if ($encryptedSize -le 0) { | |
| throw "Invalid encrypted LAPS payload size: $encryptedSize" | |
| } | |
| if (($encryptedSize + 16) -gt $Blob.Length) { | |
| throw "Encrypted LAPS payload size $encryptedSize exceeds blob length $($Blob.Length)" | |
| } | |
| $encryptedPayload = New-Object byte[] $encryptedSize | |
| [Array]::Copy($Blob, 16, $encryptedPayload, 0, $encryptedSize) | |
| $plainBytes = Unprotect-DpapiNgBlob -ProtectedBlob $encryptedPayload | |
| $jsonText = Convert-BytesToLikelyText -Bytes $plainBytes | |
| if ([string]::IsNullOrWhiteSpace($jsonText)) { | |
| throw "Decrypted Windows LAPS payload was empty" | |
| } | |
| $json = $jsonText | ConvertFrom-Json -ErrorAction Stop | |
| $password = "" | |
| $account = "" | |
| $jsonUpdateTime = $null | |
| if ($json.PSObject.Properties.Name -contains "p") { | |
| $password = [string]$json.p | |
| } | |
| elseif ($json.PSObject.Properties.Name -contains "password") { | |
| $password = [string]$json.password | |
| } | |
| if ($json.PSObject.Properties.Name -contains "n") { | |
| $account = [string]$json.n | |
| } | |
| elseif ($json.PSObject.Properties.Name -contains "account") { | |
| $account = [string]$json.account | |
| } | |
| if ($json.PSObject.Properties.Name -contains "t") { | |
| $jsonUpdateTime = Convert-LapsJsonTimestamp $json.t | |
| } | |
| $headerUpdateTime = $null | |
| try { | |
| $headerUpdateTime = [DateTime]::FromFileTimeUtc($updateFileTime) | |
| } | |
| catch { } | |
| return [PSCustomObject]@{ | |
| Password = $password | |
| Account = $account | |
| PasswordUpdateTime = $(if ($jsonUpdateTime) { $jsonUpdateTime } else { $headerUpdateTime }) | |
| HeaderPasswordUpdateTime = $headerUpdateTime | |
| EncryptedPasswordSize = $encryptedSize | |
| Reserved = $reserved | |
| Json = $jsonText | |
| } | |
| } | |
| function Get-WindowsLapsPasswordForEndpointViaDirectoryServices { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Identity | |
| ) | |
| Write-RDMarker "Windows LAPS via DirectoryServices" "Identity=$Identity" | |
| $props = @( | |
| "distinguishedName", | |
| "msLAPS-Password", | |
| "msLAPS-EncryptedPassword", | |
| "msLAPS-PasswordExpirationTime", | |
| "ms-Mcs-AdmPwd", | |
| "ms-Mcs-AdmPwdExpirationTime" | |
| ) | |
| $result = Get-ComputerDirectorySearchResult -Identity $Identity -PropertiesToLoad $props | |
| if ($null -eq $result) { | |
| return [PSCustomObject]@{ | |
| Password = "" | |
| Account = "" | |
| ExpirationTimestamp = $null | |
| PasswordUpdateTime = $null | |
| Source = "" | |
| DecryptionStatus = "Computer not found" | |
| Error = "Could not find computer object for identity: $Identity" | |
| } | |
| } | |
| $expirationRaw = Get-SearchResultFirstValue $result "msLAPS-PasswordExpirationTime" | |
| $expiration = Convert-ADFileTimeUtc $expirationRaw | |
| # Windows LAPS encrypted mode. | |
| $encryptedRaw = Get-SearchResultFirstValue $result "msLAPS-EncryptedPassword" | |
| if ($null -ne $encryptedRaw) { | |
| try { | |
| [byte[]]$encryptedBlob = $encryptedRaw | |
| $dec = ConvertFrom-WindowsLapsEncryptedPasswordBlob -Blob $encryptedBlob | |
| return [PSCustomObject]@{ | |
| Password = [string]$dec.Password | |
| Account = [string]$dec.Account | |
| ExpirationTimestamp = $expiration | |
| PasswordUpdateTime = $dec.PasswordUpdateTime | |
| Source = "EncryptedPasswordDirect" | |
| DecryptionStatus = "Success" | |
| Error = "" | |
| } | |
| } | |
| catch { | |
| return [PSCustomObject]@{ | |
| Password = "" | |
| Account = "" | |
| ExpirationTimestamp = $expiration | |
| PasswordUpdateTime = $null | |
| Source = "EncryptedPasswordDirect" | |
| DecryptionStatus = "Not readable" | |
| Error = $_.Exception.Message | |
| } | |
| } | |
| } | |
| # Windows LAPS cleartext mode, if password encryption is disabled. | |
| $clearRaw = Get-SearchResultFirstValue $result "msLAPS-Password" | |
| if ($null -ne $clearRaw -and -not [string]::IsNullOrWhiteSpace([string]$clearRaw)) { | |
| try { | |
| $json = ([string]$clearRaw) | ConvertFrom-Json -ErrorAction Stop | |
| $password = "" | |
| $account = "" | |
| $updateTime = $null | |
| if ($json.PSObject.Properties.Name -contains "p") { | |
| $password = [string]$json.p | |
| } | |
| if ($json.PSObject.Properties.Name -contains "n") { | |
| $account = [string]$json.n | |
| } | |
| if ($json.PSObject.Properties.Name -contains "t") { | |
| $updateTime = Convert-LapsJsonTimestamp $json.t | |
| } | |
| return [PSCustomObject]@{ | |
| Password = $password | |
| Account = $account | |
| ExpirationTimestamp = $expiration | |
| PasswordUpdateTime = $updateTime | |
| Source = "PasswordDirect" | |
| DecryptionStatus = "Success" | |
| Error = "" | |
| } | |
| } | |
| catch { | |
| return [PSCustomObject]@{ | |
| Password = "" | |
| Account = "" | |
| ExpirationTimestamp = $expiration | |
| PasswordUpdateTime = $null | |
| Source = "PasswordDirect" | |
| DecryptionStatus = "Not readable" | |
| Error = "Failed parsing msLAPS-Password JSON: $($_.Exception.Message)" | |
| } | |
| } | |
| } | |
| # Legacy Microsoft LAPS fallback. | |
| $legacyPass = Get-SearchResultFirstValue $result "ms-Mcs-AdmPwd" | |
| $legacyExpRaw = Get-SearchResultFirstValue $result "ms-Mcs-AdmPwdExpirationTime" | |
| $legacyExp = Convert-ADFileTimeUtc $legacyExpRaw | |
| if ($null -ne $legacyPass -and -not [string]::IsNullOrWhiteSpace([string]$legacyPass)) { | |
| return [PSCustomObject]@{ | |
| Password = [string]$legacyPass | |
| Account = "Administrator" | |
| ExpirationTimestamp = $legacyExp | |
| PasswordUpdateTime = $null | |
| Source = "LegacyDirect" | |
| DecryptionStatus = "Success" | |
| Error = "" | |
| } | |
| } | |
| return [PSCustomObject]@{ | |
| Password = "" | |
| Account = "" | |
| ExpirationTimestamp = $expiration | |
| PasswordUpdateTime = $null | |
| Source = "" | |
| DecryptionStatus = "No value" | |
| Error = "No readable Windows LAPS or legacy LAPS value was returned by DirectorySearcher." | |
| } | |
| } | |
| function Get-WindowsLapsPasswordForEndpointViaCmdlet { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Identity | |
| ) | |
| Write-RDMarker "Windows LAPS via cmdlet" "Identity=$Identity" | |
| $cmd = Get-Command Get-LapsADPassword -ErrorAction SilentlyContinue | |
| if (-not $cmd) { | |
| try { | |
| Import-Module LAPS -ErrorAction Stop | |
| $cmd = Get-Command Get-LapsADPassword -ErrorAction SilentlyContinue | |
| } | |
| catch { } | |
| } | |
| if (-not $cmd) { | |
| return [PSCustomObject]@{ | |
| Password = "" | |
| Account = "" | |
| ExpirationTimestamp = $null | |
| PasswordUpdateTime = $null | |
| Source = "" | |
| DecryptionStatus = "LAPS module missing" | |
| Error = "Get-LapsADPassword is not available on this machine." | |
| } | |
| } | |
| try { | |
| $laps = Get-LapsADPassword -Identity $Identity -AsPlainText -ErrorAction Stop | |
| return [PSCustomObject]@{ | |
| Password = [string]$laps.Password | |
| Account = [string]$laps.Account | |
| ExpirationTimestamp = $laps.ExpirationTimestamp | |
| PasswordUpdateTime = $laps.PasswordUpdateTime | |
| Source = [string]$laps.Source | |
| DecryptionStatus = [string]$laps.DecryptionStatus | |
| Error = "" | |
| } | |
| } | |
| catch { | |
| return [PSCustomObject]@{ | |
| Password = "" | |
| Account = "" | |
| ExpirationTimestamp = $null | |
| PasswordUpdateTime = $null | |
| Source = "" | |
| DecryptionStatus = "Not readable" | |
| Error = $_.Exception.Message | |
| } | |
| } | |
| } | |
| function Get-WindowsLapsPasswordForEndpoint { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Identity | |
| ) | |
| Write-RDMarker "Windows LAPS dispatcher" "Identity=$Identity" | |
| if ([string]::IsNullOrWhiteSpace($Identity)) { | |
| return $null | |
| } | |
| $direct = $null | |
| try { | |
| $direct = Get-WindowsLapsPasswordForEndpointViaDirectoryServices -Identity $Identity | |
| if ($direct -and | |
| $direct.DecryptionStatus -eq "Success" -and | |
| -not [string]::IsNullOrWhiteSpace([string]$direct.Password)) { | |
| return $direct | |
| } | |
| } | |
| catch { | |
| $direct = [PSCustomObject]@{ | |
| Password = "" | |
| Account = "" | |
| ExpirationTimestamp = $null | |
| PasswordUpdateTime = $null | |
| Source = "Direct" | |
| DecryptionStatus = "Error" | |
| Error = $_.Exception.Message | |
| } | |
| } | |
| $fallback = Get-WindowsLapsPasswordForEndpointViaCmdlet -Identity $Identity | |
| if ($fallback -and | |
| $fallback.DecryptionStatus -eq "Success" -and | |
| -not [string]::IsNullOrWhiteSpace([string]$fallback.Password)) { | |
| return $fallback | |
| } | |
| if ($direct -and -not [string]::IsNullOrWhiteSpace([string]$direct.Error)) { | |
| if ($fallback -and -not [string]::IsNullOrWhiteSpace([string]$fallback.Error)) { | |
| $fallback.Error = "Direct LDAP/DPAPI-NG failed: $($direct.Error) | Get-LapsADPassword fallback failed: $($fallback.Error)" | |
| return $fallback | |
| } | |
| return $direct | |
| } | |
| return $fallback | |
| } | |
| function Convert-DateDisplay { | |
| param ($Value) | |
| if ($null -eq $Value -or $Value -eq "") { | |
| return "" | |
| } | |
| try { | |
| return ([DateTime]$Value).ToString("yyyy-MM-dd HH:mm") | |
| } | |
| catch { | |
| return [string]$Value | |
| } | |
| } | |
| function Get-OUFromDN { | |
| param ([string]$DN) | |
| if ([string]::IsNullOrWhiteSpace($DN)) { | |
| return "" | |
| } | |
| $parts = $DN -split "," | |
| $ous = $parts | Where-Object { $_ -like "OU=*" } | |
| if (-not $ous) { | |
| return "" | |
| } | |
| return (($ous -replace "^OU=", "") -join " / ") | |
| } | |
| # ----------------------------------------------------------------------------- | |
| # rustDeskPass public/private-key decryption helpers | |
| # ----------------------------------------------------------------------------- | |
| function Test-RustDeskADEncryptedPasswordValue { | |
| param ([AllowNull()][string]$Value) | |
| return (-not [string]::IsNullOrWhiteSpace($Value) -and ([string]$Value -like 'RDADENC*:*')) | |
| } | |
| function New-RustDeskADKeyPair { | |
| param ( | |
| [Parameter(Mandatory = $true)][string]$PrivatePath, | |
| [Parameter(Mandatory = $true)][string]$PublicPath | |
| ) | |
| Write-RDMarker "Generate RustDeskAD key pair" "Private=$PrivatePath Public=$PublicPath" | |
| foreach ($path in @($PrivatePath, $PublicPath)) { | |
| $dir = Split-Path -Parent $path | |
| if (-not [string]::IsNullOrWhiteSpace($dir)) { | |
| New-Item -ItemType Directory -Path $dir -Force | Out-Null | |
| } | |
| } | |
| $rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider 2048 | |
| try { | |
| $rsa.PersistKeyInCsp = $false | |
| $privateXml = $rsa.ToXmlString($true) | |
| $publicXml = $rsa.ToXmlString($false) | |
| Set-Content -LiteralPath $PrivatePath -Value $privateXml -Encoding ASCII | |
| Set-Content -LiteralPath $PublicPath -Value $publicXml -Encoding ASCII | |
| Write-RDLog "Generated RustDeskAD key pair:" -ForegroundColor Green | |
| Write-RDLog " Private key: $PrivatePath" | |
| Write-RDLog " Public key : $PublicPath" | |
| Write-RDLog "Copy ONLY the public key to agents. Keep the private key only on the RustDeskADClient/admin host." -ForegroundColor Yellow | |
| } | |
| finally { | |
| $rsa.PersistKeyInCsp = $false | |
| $rsa.Clear() | |
| } | |
| } | |
| function Get-RustDeskADPrivateKeyXml { | |
| Write-RDMarker "Load RustDeskAD private key" $RustDeskADPrivateKeyPath | |
| if ([string]::IsNullOrWhiteSpace([string]$RustDeskADPrivateKeyPath)) { return $null } | |
| try { | |
| if (Test-Path -LiteralPath $RustDeskADPrivateKeyPath) { | |
| $xml = (Get-Content -LiteralPath $RustDeskADPrivateKeyPath -Raw -ErrorAction Stop).Trim() | |
| if (-not [string]::IsNullOrWhiteSpace($xml)) { return $xml } | |
| } | |
| } | |
| catch { | |
| Write-RDLog "Failed to read RustDeskAD private key file '$RustDeskADPrivateKeyPath': $($_.Exception.Message)" -ForegroundColor Yellow | |
| } | |
| return $null | |
| } | |
| function Unprotect-RustDeskADPasswordValue { | |
| param ([AllowNull()][string]$StoredValue) | |
| Write-RDMarker "Unprotect RustDesk password" "HasValue=$(-not [string]::IsNullOrWhiteSpace($StoredValue)) Encrypted=$(Test-RustDeskADEncryptedPasswordValue $StoredValue)" | |
| if ([string]::IsNullOrWhiteSpace($StoredValue)) { return $StoredValue } | |
| if (-not (Test-RustDeskADEncryptedPasswordValue $StoredValue)) { return $StoredValue } | |
| if ([string]$StoredValue -like 'RDADENC1:*') { | |
| Write-RDLog 'rustDeskPass uses legacy RDADENC1 shared-key format. This public/private-key client expects RDADENC2 or plaintext.' -ForegroundColor Yellow | |
| return '' | |
| } | |
| if (-not ([string]$StoredValue -like "$PasswordCryptoPrefix*")) { | |
| Write-RDLog "Unsupported rustDeskPass encrypted format." -ForegroundColor Yellow | |
| return '' | |
| } | |
| $privateKeyXml = Get-RustDeskADPrivateKeyXml | |
| if ([string]::IsNullOrWhiteSpace($privateKeyXml)) { | |
| Write-RDLog "rustDeskPass is encrypted but no RustDeskAD private key is available. Deploy $RustDeskADPrivateKeyPath on this client/admin host." -ForegroundColor Yellow | |
| return '' | |
| } | |
| $rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider 2048 | |
| $aes = [System.Security.Cryptography.Aes]::Create() | |
| try { | |
| $raw = [string]$StoredValue | |
| $payloadB64 = $raw.Substring($PasswordCryptoPrefix.Length) | |
| $json = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payloadB64)) | |
| $payload = $json | ConvertFrom-Json | |
| if ([int]$payload.v -ne 2) { throw "Unsupported RDADENC version '$($payload.v)'" } | |
| $encryptedKey = [Convert]::FromBase64String([string]$payload.ek) | |
| $iv = [Convert]::FromBase64String([string]$payload.iv) | |
| $cipherBytes = [Convert]::FromBase64String([string]$payload.ct) | |
| $rsa.PersistKeyInCsp = $false | |
| $rsa.FromXmlString($privateKeyXml) | |
| $wrappedKeyMaterial = $rsa.Decrypt($encryptedKey, $true) | |
| if ($wrappedKeyMaterial.Length -lt 64) { throw 'RDADENC2 wrapped key material is too short.' } | |
| $aesKey = New-Object byte[] 32 | |
| $macKey = New-Object byte[] 32 | |
| [Buffer]::BlockCopy($wrappedKeyMaterial, 0, $aesKey, 0, 32) | |
| [Buffer]::BlockCopy($wrappedKeyMaterial, 32, $macKey, 0, 32) | |
| $tag = [Convert]::FromBase64String([string]$payload.tag) | |
| $ivAndCipher = New-Object byte[] ($iv.Length + $cipherBytes.Length) | |
| [Buffer]::BlockCopy($iv, 0, $ivAndCipher, 0, $iv.Length) | |
| [Buffer]::BlockCopy($cipherBytes, 0, $ivAndCipher, $iv.Length, $cipherBytes.Length) | |
| $hmac = New-Object -TypeName System.Security.Cryptography.HMACSHA256 -ArgumentList @(,$macKey) | |
| try { | |
| $expectedTag = $hmac.ComputeHash($ivAndCipher) | |
| } | |
| finally { | |
| $hmac.Dispose() | |
| } | |
| if ($tag.Length -ne $expectedTag.Length) { throw 'RDADENC2 HMAC verification failed.' } | |
| $ok = $true | |
| for ($i = 0; $i -lt $expectedTag.Length; $i++) { | |
| if ($tag[$i] -ne $expectedTag[$i]) { $ok = $false } | |
| } | |
| if (-not $ok) { throw 'RDADENC2 HMAC verification failed.' } | |
| $aes.Mode = [System.Security.Cryptography.CipherMode]::CBC | |
| $aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7 | |
| $aes.KeySize = 256 | |
| $aes.Key = $aesKey | |
| $aes.IV = $iv | |
| $decryptor = $aes.CreateDecryptor() | |
| try { | |
| $plainBytes = $decryptor.TransformFinalBlock($cipherBytes, 0, $cipherBytes.Length) | |
| } | |
| finally { | |
| $decryptor.Dispose() | |
| } | |
| return [System.Text.Encoding]::UTF8.GetString($plainBytes) | |
| } | |
| catch { | |
| Write-RDLog "Failed to decrypt rustDeskPass: $($_.Exception.Message)" -ForegroundColor Yellow | |
| return '' | |
| } | |
| finally { | |
| if ($aes) { $aes.Dispose() } | |
| if ($rsa) { $rsa.PersistKeyInCsp = $false; $rsa.Clear() } | |
| } | |
| } | |
| if ($GenerateRustDeskADKeyPair) { | |
| New-RustDeskADKeyPair -PrivatePath $RustDeskADPrivateKeyPath -PublicPath $RustDeskADPublicKeyPath | |
| return | |
| } | |
| function Html { | |
| param ($Value) | |
| if ($null -eq $Value) { | |
| return "" | |
| } | |
| if ($Value -is [array]) { | |
| $Value = ($Value | Where-Object { $null -ne $_ -and $_ -ne "" }) -join ", " | |
| } | |
| return [System.Web.HttpUtility]::HtmlEncode([string]$Value) | |
| } | |
| function Redact-ClientSecretText { | |
| param ($Value) | |
| if ($null -eq $Value) { | |
| return "" | |
| } | |
| $text = [string]$Value | |
| # Never let credential values bleed into generated HTML, data-search, or raw metadata view. | |
| # The real values stay server-side in $script:CredentialCache and are returned only by | |
| # /api/credentials when a user clicks Copy/Control/Terminal/RDP. | |
| $secretJsonKeys = @( | |
| "rustDeskPass", | |
| "ms-Mcs-AdmPwd", | |
| "msLAPS-Password", | |
| "msLAPS-EncryptedPassword", | |
| "lapsLegacy", | |
| "lapsWindows", | |
| "RDADENC1", | |
| "RDADENC2" | |
| ) | |
| foreach ($key in $secretJsonKeys) { | |
| $escaped = [regex]::Escape($key) | |
| $text = [regex]::Replace($text, '(?i)("' + $escaped + '"\s*:\s*")[^"]*(")', '$1[REDACTED]$2') | |
| $text = [regex]::Replace($text, '(?i)(' + $escaped + '\s*=\s*)[^,;\r\n]+', '$1[REDACTED]') | |
| } | |
| return $text | |
| } | |
| function Js { | |
| param ($Value) | |
| if ($null -eq $Value) { | |
| return "" | |
| } | |
| return [System.Web.HttpUtility]::JavaScriptStringEncode([string]$Value) | |
| } | |
| # ----------------------------------------------------------------------------- | |
| # RustDesk launch and Wake-on-LAN helpers | |
| # ----------------------------------------------------------------------------- | |
| function Normalize-MacAddress { | |
| param ([string]$Mac) | |
| if ([string]::IsNullOrWhiteSpace($Mac)) { | |
| throw "MAC address is empty." | |
| } | |
| $clean = ([string]$Mac).Trim() -replace "[^0-9A-Fa-f]", "" | |
| if ($clean.Length -ne 12) { | |
| throw "Invalid MAC address '$Mac'. Expected 12 hex characters after cleanup." | |
| } | |
| return (($clean.ToUpper() -split "(.{2})" | Where-Object { $_ }) -join ":") | |
| } | |
| function Send-WakeOnLan { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Mac, | |
| [string]$Broadcast = "255.255.255.255", | |
| [int]$Port = 9 | |
| ) | |
| Write-RDMarker "Wake-on-LAN" "MAC=$Mac Broadcast=$Broadcast Port=$Port" | |
| $normalized = Normalize-MacAddress $Mac | |
| $clean = $normalized -replace ":", "" | |
| [byte[]]$macBytes = for ($i = 0; $i -lt 12; $i += 2) { | |
| [Convert]::ToByte($clean.Substring($i, 2), 16) | |
| } | |
| [byte[]]$packet = New-Object byte[] 102 | |
| for ($i = 0; $i -lt 6; $i++) { | |
| $packet[$i] = 0xFF | |
| } | |
| for ($i = 1; $i -le 16; $i++) { | |
| [Array]::Copy($macBytes, 0, $packet, $i * 6, 6) | |
| } | |
| $udp = New-Object System.Net.Sockets.UdpClient | |
| try { | |
| $udp.EnableBroadcast = $true | |
| [void]$udp.Send($packet, $packet.Length, $Broadcast, $Port) | |
| } | |
| finally { | |
| $udp.Close() | |
| } | |
| return $normalized | |
| } | |
| function Find-RustDeskExe { | |
| Write-RDMarker "Find RustDesk.exe" | |
| $paths = @( | |
| "C:\Program Files\RustDesk\rustdesk.exe", | |
| "C:\Program Files (x86)\RustDesk\rustdesk.exe" | |
| ) | |
| foreach ($path in $paths) { | |
| if (Test-Path -LiteralPath $path) { | |
| return $path | |
| } | |
| } | |
| return $null | |
| } | |
| function Get-RustDeskIdFromUri { | |
| param ([string]$Uri) | |
| if ([string]::IsNullOrWhiteSpace($Uri)) { | |
| return "" | |
| } | |
| try { | |
| $parsed = [Uri]$Uri | |
| if ($parsed.Scheme -eq "rustdesk") { | |
| if (-not [string]::IsNullOrWhiteSpace($parsed.Host) -and $parsed.Host -ne "terminal") { | |
| return $parsed.Host | |
| } | |
| $pathId = $parsed.AbsolutePath.Trim("/") | |
| if (-not [string]::IsNullOrWhiteSpace($pathId)) { | |
| return $pathId | |
| } | |
| } | |
| } | |
| catch {} | |
| if ($Uri -match '^rustdesk://([^/?#]+)') { | |
| return $Matches[1] | |
| } | |
| return "" | |
| } | |
| function Start-RustDeskConnection { | |
| param ( | |
| [string]$Id, | |
| [string]$Password, | |
| [string]$Mode = "control" | |
| ) | |
| Write-RDMarker "Start RustDesk connection" "Id=$Id Mode=$Mode PasswordPresent=$(-not [string]::IsNullOrWhiteSpace($Password))" | |
| if ([string]::IsNullOrWhiteSpace($Id)) { | |
| throw "RustDesk ID is empty." | |
| } | |
| $rustDeskExe = Find-RustDeskExe | |
| if ([string]::IsNullOrWhiteSpace($rustDeskExe)) { | |
| throw "rustdesk.exe was not found in C:\Program Files\RustDesk\." | |
| } | |
| if ($Mode -eq "terminal") { | |
| $args = @("--terminal", $Id) | |
| } | |
| elseif ($Mode -eq "rdp") { | |
| $args = @("--rdp", $Id) | |
| } | |
| else { | |
| $args = @("--connect", $Id) | |
| } | |
| if (-not [string]::IsNullOrWhiteSpace($Password)) { | |
| $args += @("--password", $Password) | |
| } | |
| Start-Process -FilePath $rustDeskExe -ArgumentList $args | |
| return $rustDeskExe | |
| } | |
| function Get-FlatMetadataStrings { | |
| param ($Value) | |
| $results = New-Object System.Collections.Generic.List[string] | |
| function Add-FlatValue { | |
| param ($Item) | |
| if ($null -eq $Item) { | |
| return | |
| } | |
| if ($Item -is [string]) { | |
| if (-not [string]::IsNullOrWhiteSpace($Item)) { | |
| [void]$results.Add($Item) | |
| } | |
| return | |
| } | |
| if ($Item -is [System.Collections.IEnumerable] -and -not ($Item -is [string])) { | |
| foreach ($child in $Item) { | |
| Add-FlatValue $child | |
| } | |
| return | |
| } | |
| if ($Item.PSObject -and $Item.PSObject.Properties.Count -gt 0) { | |
| foreach ($prop in $Item.PSObject.Properties) { | |
| Add-FlatValue $prop.Value | |
| } | |
| return | |
| } | |
| $s = [string]$Item | |
| if (-not [string]::IsNullOrWhiteSpace($s)) { | |
| [void]$results.Add($s) | |
| } | |
| } | |
| Add-FlatValue $Value | |
| return @($results) | |
| } | |
| function Get-MetadataPropertyValue { | |
| param ( | |
| $Object, | |
| [string[]]$Names | |
| ) | |
| if ($null -eq $Object -or -not $Object.PSObject) { | |
| return $null | |
| } | |
| foreach ($name in $Names) { | |
| foreach ($prop in $Object.PSObject.Properties) { | |
| if ($prop.Name -ieq $name) { | |
| return $prop.Value | |
| } | |
| } | |
| } | |
| return $null | |
| } | |
| function Find-MetadataMacForIp { | |
| param ( | |
| $Node, | |
| [string]$PrimaryIp | |
| ) | |
| if ($null -eq $Node -or [string]::IsNullOrWhiteSpace($PrimaryIp)) { | |
| return $null | |
| } | |
| if ($Node -is [string]) { | |
| return $null | |
| } | |
| if ($Node -is [System.Collections.IEnumerable] -and -not ($Node -is [string])) { | |
| foreach ($child in $Node) { | |
| $found = Find-MetadataMacForIp -Node $child -PrimaryIp $PrimaryIp | |
| if (-not [string]::IsNullOrWhiteSpace([string]$found)) { | |
| return $found | |
| } | |
| } | |
| return $null | |
| } | |
| if ($Node.PSObject -and $Node.PSObject.Properties.Count -gt 0) { | |
| $macValue = Get-MetadataPropertyValue -Object $Node -Names @( | |
| "primaryMac", "primaryMAC", "mac", "macAddress", "macAddresses", | |
| "physicalAddress", "physicalAddresses", "linkLayerAddress" | |
| ) | |
| if ($null -ne $macValue) { | |
| $allStrings = @(Get-FlatMetadataStrings $Node) | |
| foreach ($s in $allStrings) { | |
| if ([string]$s -eq $PrimaryIp -or ([string]$s).Contains($PrimaryIp)) { | |
| foreach ($candidateMac in @(Normalize-List $macValue)) { | |
| try { | |
| return (Normalize-MacAddress ([string]$candidateMac)) | |
| } | |
| catch {} | |
| } | |
| } | |
| } | |
| } | |
| foreach ($prop in $Node.PSObject.Properties) { | |
| $found = Find-MetadataMacForIp -Node $prop.Value -PrimaryIp $PrimaryIp | |
| if (-not [string]::IsNullOrWhiteSpace([string]$found)) { | |
| return $found | |
| } | |
| } | |
| } | |
| return $null | |
| } | |
| function Resolve-PrimaryWolMac { | |
| param ( | |
| $Metadata, | |
| $PrimaryIPv4, | |
| $PrimaryIPv6, | |
| $MacAddresses | |
| ) | |
| Write-RDMarker "Resolve primary WOL MAC" "IPv4=$PrimaryIPv4 IPv6=$PrimaryIPv6 MacCount=$(@($MacAddresses).Count)" | |
| $explicitPrimaryMac = Get-MetaValue $Metadata "local" "primaryMac" | |
| if ([string]::IsNullOrWhiteSpace([string]$explicitPrimaryMac)) { | |
| $explicitPrimaryMac = Get-MetaValue $Metadata "local" "primaryMAC" | |
| } | |
| if ([string]::IsNullOrWhiteSpace([string]$explicitPrimaryMac)) { | |
| $explicitPrimaryMac = Get-MetaValue $Metadata "network" "primaryMac" | |
| } | |
| if (-not [string]::IsNullOrWhiteSpace([string]$explicitPrimaryMac)) { | |
| try { return (Normalize-MacAddress ([string]$explicitPrimaryMac)) } catch {} | |
| } | |
| $primaryIps = @($PrimaryIPv4, $PrimaryIPv6) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | |
| foreach ($ip in $primaryIps) { | |
| $found = Find-MetadataMacForIp -Node $Metadata -PrimaryIp ([string]$ip) | |
| if (-not [string]::IsNullOrWhiteSpace([string]$found)) { | |
| return $found | |
| } | |
| } | |
| foreach ($candidateMac in @(Normalize-List $MacAddresses)) { | |
| try { return (Normalize-MacAddress ([string]$candidateMac)) } catch {} | |
| } | |
| return "" | |
| } | |
| function New-PrimaryWolButton { | |
| param ($Mac) | |
| if ([string]::IsNullOrWhiteSpace([string]$Mac)) { | |
| return "" | |
| } | |
| try { | |
| $normalized = Normalize-MacAddress ([string]$Mac) | |
| $macHtml = Html $normalized | |
| $macJs = Js $normalized | |
| return "<button type=""button"" class=""openBtn wolBtn"" title=""Wake-on-LAN primary NIC $macHtml"" data-mac=""$macHtml"" onclick=""sendWol('$macJs', this)"">⚡</button>" | |
| } | |
| catch { | |
| return "" | |
| } | |
| } | |
| function New-PowerActionButton { | |
| param ($ComputerName) | |
| if ([string]::IsNullOrWhiteSpace([string]$ComputerName)) { | |
| return "" | |
| } | |
| $computerHtml = Html $ComputerName | |
| return "<button type=""button"" class=""openBtn powerBtn"" title=""Power actions"" data-computer=""$computerHtml"" onclick=""openPowerModal(this)"">🔌</button>" | |
| } | |
| function New-WolButtons { | |
| param ( | |
| $Values, | |
| [int]$Max = 8 | |
| ) | |
| $items = @(Normalize-List $Values) | |
| if ($items.Count -eq 0) { | |
| return "" | |
| } | |
| $htmlParts = @() | |
| foreach ($item in ($items | Select-Object -First $Max)) { | |
| try { | |
| $mac = Normalize-MacAddress ([string]$item) | |
| $macHtml = Html $mac | |
| $macJs = Js $mac | |
| $htmlParts += "<button type=""button"" class=""pill wolBtn"" onclick=""sendWol('$macJs', this)"" title=""Send Wake-on-LAN magic packet to $macHtml"">⚡ $macHtml</button>" | |
| } | |
| catch { | |
| $htmlParts += "<span class=""pill warn"">$(Html $item)</span>" | |
| } | |
| } | |
| if ($items.Count -gt $Max) { | |
| $htmlParts += "<span class=""pill"">+$($items.Count - $Max) more</span>" | |
| } | |
| return ($htmlParts -join " ") | |
| } | |
| # ----------------------------------------------------------------------------- | |
| # Endpoint/card data shaping helpers | |
| # ----------------------------------------------------------------------------- | |
| function First-Value { | |
| param ( | |
| $Primary, | |
| $Fallback | |
| ) | |
| if ($null -ne $Primary -and [string]$Primary -ne "") { | |
| return $Primary | |
| } | |
| return $Fallback | |
| } | |
| function Get-MetaValue { | |
| param ( | |
| $Metadata, | |
| [string]$Section, | |
| [string]$Name | |
| ) | |
| if ($null -eq $Metadata) { | |
| return $null | |
| } | |
| try { | |
| $sectionObj = $Metadata.$Section | |
| if ($null -eq $sectionObj) { | |
| return $null | |
| } | |
| if ($sectionObj.PSObject.Properties.Name -contains $Name) { | |
| return $sectionObj.$Name | |
| } | |
| } | |
| catch {} | |
| return $null | |
| } | |
| function Parse-Metadata { | |
| param ($Json) | |
| if ($null -eq $Json -or [string]::IsNullOrWhiteSpace([string]$Json)) { | |
| return $null | |
| } | |
| try { | |
| return ([string]$Json | ConvertFrom-Json) | |
| } | |
| catch { | |
| return $null | |
| } | |
| } | |
| [int]$GraceSeconds = 60 | |
| function Get-AgentOnlineStatus { | |
| param ( | |
| $LastUpdated, | |
| $IntervalSeconds, | |
| [int]$GraceSeconds = 60 | |
| ) | |
| $result = [PSCustomObject]@{ | |
| State = "unknown" | |
| Text = "Unknown" | |
| DotClass = "unknownDot" | |
| Detail = "Agent heartbeat metadata is missing." | |
| LastUpdatedDisplay = "" | |
| IntervalSeconds = "" | |
| AgeSeconds = $null | |
| OfflineAfterDisplay = "" | |
| } | |
| if ([string]::IsNullOrWhiteSpace([string]$LastUpdated)) { | |
| return $result | |
| } | |
| $interval = 0 | |
| if (-not [int]::TryParse([string]$IntervalSeconds, [ref]$interval) -or $interval -le 0) { | |
| $result.Detail = "Agent lastupdated exists, but agent.intervalseconds is missing or invalid." | |
| return $result | |
| } | |
| try { | |
| $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal | |
| $last = [DateTimeOffset]::Parse([string]$LastUpdated, [System.Globalization.CultureInfo]::InvariantCulture, $styles).ToUniversalTime() | |
| $now = [DateTimeOffset]::UtcNow | |
| $offlineAfter = $last.AddSeconds($interval + $GraceSeconds) | |
| $ageSeconds = [int][Math]::Max(0, ($now - $last).TotalSeconds) | |
| $result.LastUpdatedDisplay = $last.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss") | |
| $result.IntervalSeconds = [string]$interval | |
| $result.AgeSeconds = $ageSeconds | |
| $result.OfflineAfterDisplay = $offlineAfter.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss") | |
| if ($now -le $offlineAfter) { | |
| $result.State = "online" | |
| $result.Text = "Online" | |
| $result.DotClass = "" | |
| $result.Detail = "Metadata updated $ageSeconds seconds ago. Offline after $($result.OfflineAfterDisplay)." | |
| } | |
| else { | |
| $result.State = "offline" | |
| $result.Text = "Offline" | |
| $result.DotClass = "badDot" | |
| $result.Detail = "Metadata is stale. Last update was $ageSeconds seconds ago; expected every $interval seconds plus $GraceSeconds seconds grace." | |
| } | |
| } | |
| catch { | |
| $result.Detail = "Agent lastupdated could not be parsed: $LastUpdated" | |
| } | |
| return $result | |
| } | |
| function Normalize-List { | |
| param ($Value) | |
| if ($null -eq $Value) { | |
| return @() | |
| } | |
| if ($Value -is [array]) { | |
| return @($Value | Where-Object { $null -ne $_ -and [string]$_ -ne "" }) | |
| } | |
| if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { | |
| return @($Value | Where-Object { $null -ne $_ -and [string]$_ -ne "" }) | |
| } | |
| if ([string]$Value -ne "") { | |
| return @($Value) | |
| } | |
| return @() | |
| } | |
| function Make-Pills { | |
| param ( | |
| $Values, | |
| [int]$Max = 8 | |
| ) | |
| $items = @(Normalize-List $Values) | |
| if ($items.Count -eq 0) { | |
| return "" | |
| } | |
| $htmlParts = @() | |
| foreach ($item in ($items | Select-Object -First $Max)) { | |
| $htmlParts += "<span class=""pill"">$(Html $item)</span>" | |
| } | |
| if ($items.Count -gt $Max) { | |
| $htmlParts += "<span class=""pill"">+$($items.Count - $Max) more</span>" | |
| } | |
| return ($htmlParts -join " ") | |
| } | |
| function Dn-To-Name { | |
| param ($DN) | |
| if ([string]::IsNullOrWhiteSpace([string]$DN)) { | |
| return "" | |
| } | |
| if ([string]$DN -match "^CN=([^,]+)") { | |
| return $Matches[1] | |
| } | |
| return [string]$DN | |
| } | |
| $bindHost = if ($Listen) { "+" } else { "127.0.0.1" } | |
| $prefix = "http://$bindHost`:$Port/" | |
| $localUrl = "http://127.0.0.1:$Port/" | |
| # ----------------------------------------------------------------------------- | |
| # Host/browser environment helpers | |
| # ----------------------------------------------------------------------------- | |
| function Get-LocalIPv4Addresses { | |
| try { | |
| [System.Net.Dns]::GetHostAddresses([System.Net.Dns]::GetHostName()) | | |
| Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork -and -not $_.IPAddressToString.StartsWith("127.") } | | |
| ForEach-Object { $_.IPAddressToString } | | |
| Sort-Object -Unique | |
| } | |
| catch { | |
| @() | |
| } | |
| } | |
| if (-not $RefreshExisting -and (Test-ExistingRustDeskListener -Url $localUrl)) { | |
| Write-RDLog "" | |
| Write-RDLog "RustDeskADClient listener is already running:" -ForegroundColor Green | |
| Write-RDLog " $localUrl" | |
| Write-RDLog "" | |
| if ((-not $NoOpen) -and ((-not $Listen) -or $PWA)) { | |
| Write-RDLog "Opening existing web UI instead of starting another listener." -ForegroundColor Cyan | |
| } | |
| else { | |
| Write-RDLog "Existing web UI detected; not opening PWA/browser because -Listen was used without -PWA." -ForegroundColor Cyan | |
| } | |
| Write-RDLog "Use -RefreshExisting to try rebuilding/rebinding instead, or -Port 8766 for another instance." -ForegroundColor Yellow | |
| if ((-not $NoOpen) -and ((-not $Listen) -or $PWA)) { | |
| Open-BrowserSafe $localUrl | |
| } | |
| return | |
| } | |
| $out = "$env:TEMP\rustdesk-registry.html" | |
| $svgOut = "$env:TEMP\rustdesk-ad-icon.svg" | |
| $svg = @' | |
| <svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <rect width="512" height="512" rx="110" fill="white"/> | |
| <path d="M382.112 162.388L353.125 191.198C348.02 195.774 345.549 203.132 348.413 209.351C367.72 249.756 360.389 297.928 328.699 329.59C296.996 361.238 248.77 368.556 208.323 349.263C202.363 346.588 195.371 348.856 190.781 353.499L161.319 382.921C159.615 384.591 158.315 386.629 157.518 388.878C156.721 391.127 156.448 393.528 156.721 395.899C156.993 398.269 157.804 400.547 159.09 402.556C160.376 404.566 162.104 406.255 164.143 407.495C197.878 427.908 237.475 436.443 276.623 431.738C315.772 427.034 352.219 409.361 380.157 381.536C408.076 353.736 425.866 317.376 430.68 278.271C435.495 239.167 427.056 199.579 406.714 165.837C405.529 163.749 403.88 161.96 401.895 160.611C399.909 159.261 397.64 158.386 395.262 158.053C392.885 157.719 390.463 157.938 388.183 158.689C385.902 159.441 383.826 160.706 382.112 162.388ZM131.381 131.092C103.329 158.784 85.3794 195.088 80.4069 234.19C75.4344 273.293 83.7263 312.934 103.955 346.765C105.14 348.853 106.789 350.641 108.774 351.99C110.76 353.34 113.029 354.215 115.407 354.548C117.784 354.882 120.206 354.664 122.487 353.913C124.767 353.161 126.844 351.895 128.557 350.213L157.395 321.566C162.622 317.004 165.161 309.563 162.269 303.264C142.949 262.858 150.267 214.687 181.97 183.025C213.659 151.363 261.899 144.045 302.346 163.338C308.238 165.972 315.136 163.786 319.738 159.265L349.364 129.667C351.073 127.999 352.377 125.962 353.177 123.712C353.977 121.462 354.251 119.059 353.979 116.687C353.706 114.314 352.894 112.036 351.605 110.026C350.315 108.017 348.583 106.328 346.54 105.092C312.737 84.8501 273.137 76.4919 234.037 81.3458C194.938 86.1997 158.584 103.987 130.756 131.88L131.381 131.092Z" fill="url(#g)"/> | |
| <defs> | |
| <linearGradient id="g" x1="131" y1="380" x2="377" y2="134" gradientUnits="userSpaceOnUse"> | |
| <stop stop-color="#024EFF"/> | |
| <stop offset="1" stop-color="#01AEE6"/> | |
| </linearGradient> | |
| </defs> | |
| <g transform="translate(138.24,184.32)"> | |
| <rect x="0" y="0" width="235.52" height="143.36" rx="25.80" fill="#0b5cab" fill-opacity="0.96" stroke="#ffffff" stroke-width="6.14"/> | |
| <circle cx="47.10" cy="71.68" r="18.64" fill="#ffffff"/> | |
| <circle cx="84.79" cy="43.01" r="13.62" fill="#ffffff"/> | |
| <circle cx="84.79" cy="100.35" r="13.62" fill="#ffffff"/> | |
| <line x1="47.10" y1="71.68" x2="84.79" y2="43.01" stroke="#ffffff" stroke-width="5.12" stroke-linecap="round"/> | |
| <line x1="47.10" y1="71.68" x2="84.79" y2="100.35" stroke="#ffffff" stroke-width="5.12" stroke-linecap="round"/> | |
| <text x="117.76" y="91.75" font-family="Segoe UI, Arial, sans-serif" font-size="60.21" font-weight="900" fill="#ffffff">AD</text> | |
| </g> | |
| </svg> | |
| '@ | |
| $svg | Set-Content $svgOut -Encoding UTF8 | |
| $attributes = @( | |
| "name", | |
| "rustDeskUri", | |
| "rustDeskPass", | |
| "rustDeskMetadata", | |
| "operatingSystem", | |
| "operatingSystemVersion", | |
| "dNSHostName", | |
| "description", | |
| "lastLogon", | |
| "lastLogonTimestamp", | |
| "whenCreated", | |
| "distinguishedName", | |
| "location", | |
| "managedBy", | |
| "userAccountControl", | |
| "pwdLastSet", | |
| "ms-Mcs-AdmPwd", | |
| "ms-Mcs-AdmPwdExpirationTime", | |
| "msLAPS-Password", | |
| "msLAPS-EncryptedPassword", | |
| "msLAPS-PasswordExpirationTime", | |
| "info" | |
| ) | |
| function Test-MobileBrowserUserAgent { | |
| param ([string]$UserAgent) | |
| if ([string]::IsNullOrWhiteSpace($UserAgent)) { | |
| return $false | |
| } | |
| # Keep the desktop layout unchanged unless the browser identifies as a mobile/tablet client. | |
| # The iPadOS desktop-mode Safari string can look like Macintosh, so include the common | |
| # mobile-only tokens and iPad/iPod/Kindle/Silk tablet tokens explicitly. | |
| return ($UserAgent -match '(?i)(Mobile|Android|iPhone|iPad|iPod|IEMobile|Windows Phone|BlackBerry|BB10|Opera Mini|Opera Mobi|Kindle|Silk)') | |
| } | |
| # ----------------------------------------------------------------------------- | |
| # HTML/CSS/JavaScript renderer | |
| # ----------------------------------------------------------------------------- | |
| function Get-RegistryHtml { | |
| param ([string]$UserAgent = "") | |
| Write-RDMarker "Render registry HTML" "Mobile=$(Test-MobileBrowserUserAgent -UserAgent $UserAgent)" | |
| $isMobileBrowser = Test-MobileBrowserUserAgent -UserAgent $UserAgent | |
| $bodyClass = if ($isMobileBrowser) { "mobileBrowser" } else { "" } | |
| $adHealth = Get-ADStatusPayload | |
| $adConnected = [bool]$adHealth.connected | |
| if ($adConnected) { | |
| $computers = Get-Attributes ` | |
| -Attributes $attributes ` | |
| -Filter "(|(rustDeskUri=*)(rustDeskMetadata=*))" | | |
| Sort-Object name | |
| $adConnected = [bool]$script:LastADQueryOk | |
| } | |
| else { | |
| $computers = @() | |
| } | |
| $adDotClass = if ($adConnected) { "" } else { "badDot" } | |
| $adStatusText = if ($adConnected) { "Connected to Active Directory" } else { "Cannot contact Active Directory" } | |
| $adDomainTextRaw = [string]$adHealth.domainText | |
| if (-not $adConnected) { | |
| if (-not [string]::IsNullOrWhiteSpace([string]$script:LastADError) -and ($adDomainTextRaw -notmatch [regex]::Escape($script:LastADError))) { | |
| $adDomainTextRaw = "$adDomainTextRaw - $($script:LastADError)" | |
| } | |
| elseif ([string]::IsNullOrWhiteSpace($adDomainTextRaw)) { | |
| $adDomainTextRaw = "AD unavailable" | |
| } | |
| } | |
| $adStatusTextHtml = Html $adStatusText | |
| $adDomainText = Html $adDomainTextRaw | |
| $emptyMessage = if ($adConnected) { "No machines match your search." } else { "Cannot contact the domain / Active Directory right now. The listener is still running; check network, DNS, VPN, or domain controller access, then click refresh." } | |
| $emptyMessageHtml = Html $emptyMessage | |
| $count = @($computers).Count | |
| $lastRefresh = Html ((Get-Date).ToString("yyyy-MM-dd HH:mm:ss")) | |
| $svgUri = Html ("data:image/svg+xml;base64," + [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($svg))) | |
| $html = @" | |
| <!doctype html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes"> | |
| <title>RustDeskADClient</title> | |
| <script> | |
| (function(){ | |
| try { | |
| var p = window.location.pathname || "/"; | |
| var leaf = (p.split("/").pop() || ""); | |
| if (p !== "/" && !p.endsWith("/") && leaf.indexOf(".") < 0) { | |
| window.history.replaceState(null, document.title, p + "/" + window.location.search + window.location.hash); | |
| } | |
| } catch(e) {} | |
| })(); | |
| </script> | |
| <link rel="icon" type="image/svg+xml" href="$svgUri"> | |
| <link rel="shortcut icon" type="image/svg+xml" href="$svgUri"> | |
| <link rel="alternate icon" type="image/png" href="pwa-icon-192.png"> | |
| <link rel="manifest" id="pwaManifestLink" href="manifest.webmanifest" crossorigin="use-credentials"> | |
| <meta name="theme-color" content="#0b0f17"> | |
| <meta name="application-name" content="RustDeskADClient"> | |
| <meta name="apple-mobile-web-app-capable" content="no"> | |
| <style> | |
| :root { | |
| --bg: #0b0f17; | |
| --panel: #111827; | |
| --panel2: #151f31; | |
| --border: #263247; | |
| --text: #e7edf7; | |
| --muted: #8c9bb3; | |
| --accent2: #7ab7ff; | |
| --good: #50fa7b; | |
| --warn: #ffd866; | |
| --bad: #ff6b6b; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| font-family: Segoe UI, Arial, sans-serif; | |
| background: | |
| radial-gradient(circle at top left, rgba(255, 75, 75, 0.13), transparent 32%), | |
| radial-gradient(circle at top right, rgba(122, 183, 255, 0.11), transparent 30%), | |
| var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| padding: 28px; | |
| } | |
| .container { max-width: 1600px; margin: 0 auto; } | |
| .header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 20px; | |
| margin-bottom: 22px; | |
| } | |
| .brand { display: flex; align-items: center; gap: 18px; } | |
| .logo { | |
| width: 68px; | |
| height: 68px; | |
| border-radius: 22px; | |
| background: rgba(18, 24, 38, 0.82); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: 0 0 45px rgba(122, 183, 255, 0.20), inset 0 0 18px rgba(255,255,255,0.03); | |
| overflow: hidden; | |
| padding: 8px; | |
| } | |
| .logo img { width: 100%; height: 100%; object-fit: contain; } | |
| h1 { margin: 0; font-size: 30px; letter-spacing: -0.5px; } | |
| .subtitle { color: var(--muted); margin-top: 5px; font-size: 14px; } | |
| .badges { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; } | |
| .badge { | |
| background: rgba(80, 250, 123, 0.12); | |
| border: 1px solid rgba(80, 250, 123, 0.35); | |
| color: var(--good); | |
| padding: 10px 15px; | |
| border-radius: 999px; | |
| font-size: 13px; | |
| white-space: nowrap; | |
| } | |
| .badge.secondary { background: rgba(122, 183, 255, 0.10); border-color: rgba(122, 183, 255, 0.28); color: var(--accent2); } | |
| .toolbar { | |
| background: rgba(18, 24, 38, 0.82); | |
| border: 1px solid var(--border); | |
| border-radius: 20px; | |
| padding: 14px; | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| margin-bottom: 16px; | |
| box-shadow: 0 18px 45px rgba(0, 0, 0, 0.22); | |
| } | |
| .searchWrap { position: relative; flex: 1; } | |
| .searchIcon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--muted); pointer-events: none; } | |
| input { | |
| width: 100%; | |
| padding: 13px 14px 13px 42px; | |
| background: #0d1320; | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| outline: none; | |
| font-size: 15px; | |
| } | |
| input:focus { border-color: rgba(122, 183, 255, 0.7); box-shadow: 0 0 0 3px rgba(122, 183, 255, 0.11); } | |
| .stat { color: var(--muted); font-size: 14px; white-space: nowrap; } | |
| .card { | |
| background: rgba(18, 24, 38, 0.62); | |
| border: 1px solid var(--border); | |
| border-radius: 24px; | |
| padding: 14px; | |
| box-shadow: 0 22px 55px rgba(0, 0, 0, 0.30); | |
| } | |
| .machineGrid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); | |
| gap: 14px; | |
| } | |
| .machineCard { | |
| background: rgba(17, 24, 39, 0.90); | |
| border: 1px solid rgba(38, 50, 71, 0.88); | |
| border-radius: 20px; | |
| overflow: hidden; | |
| box-shadow: 0 18px 34px rgba(0,0,0,0.20); | |
| } | |
| .machineTop { | |
| padding: 16px; | |
| display: grid; | |
| grid-template-columns: 1fr auto; | |
| gap: 14px; | |
| align-items: start; | |
| } | |
| .computer { font-weight: 800; color: #ffffff; font-size: 17px; letter-spacing: -0.2px; } | |
| .small { color: var(--muted); font-size: 12px; margin-top: 4px; } | |
| .mono { font-family: Consolas, monospace; font-size: 12px; word-break: break-all; } | |
| .quickFacts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 16px 14px 16px; } | |
| .fact { | |
| background: rgba(13, 19, 32, 0.70); | |
| border: 1px solid rgba(38, 50, 71, 0.65); | |
| border-radius: 14px; | |
| padding: 9px 10px; | |
| min-height: 48px; | |
| } | |
| .factLabel { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .07em; margin-bottom: 4px; } | |
| .factValue { font-size: 12px; } | |
| .actionBar { | |
| padding: 12px 16px; | |
| border-top: 1px solid rgba(38, 50, 71, 0.72); | |
| background: rgba(21, 31, 49, 0.65); | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .detailPanel { | |
| display: none; | |
| padding: 14px 16px 16px 16px; | |
| border-top: 1px solid rgba(38, 50, 71, 0.72); | |
| background: rgba(8, 13, 22, 0.50); | |
| } | |
| .machineCard.open .detailPanel { display: block; } | |
| .machineCard.open { border-color: rgba(122,183,255,0.42); box-shadow: 0 0 0 1px rgba(122,183,255,0.12), 0 22px 55px rgba(0,0,0,0.32); } | |
| .detailGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } | |
| .detailBox { background: rgba(13, 19, 32, 0.72); border: 1px solid rgba(38, 50, 71, 0.70); border-radius: 14px; padding: 11px; } | |
| .detailTitle { color: var(--accent2); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 7px; } | |
| .detailWide { grid-column: 1 / -1; } | |
| .lapsItem { | |
| border: 1px solid rgba(122, 183, 255, 0.18); | |
| background: rgba(122, 183, 255, 0.06); | |
| border-radius: 12px; | |
| padding: 10px; | |
| margin-top: 8px; | |
| } | |
| .lapsHeader { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| margin-bottom: 8px; | |
| } | |
| .lapsName { color: var(--accent2); font-weight: 700; font-size: 12px; } | |
| .lapsRows { display: grid; gap: 5px; } | |
| .lapsRows div { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 10px; | |
| border-top: 1px solid rgba(122, 183, 255, 0.10); | |
| padding-top: 5px; | |
| } | |
| .lapsRows span { color: var(--muted); font-size: 11px; } | |
| .lapsRows strong { | |
| color: var(--text); | |
| font-size: 12px; | |
| font-weight: 600; | |
| text-align: right; | |
| word-break: break-word; | |
| } | |
| .lapsMuted { color: var(--muted); font-size: 11px; } | |
| .miniCopyBtn { | |
| padding: 5px 9px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(80,250,123,.35); | |
| background: rgba(80,250,123,.10); | |
| color: var(--good); | |
| font-size: 11px; | |
| font-family: Segoe UI, Arial, sans-serif; | |
| cursor: pointer; | |
| white-space: nowrap; | |
| } | |
| .miniCopyBtn:hover { background: rgba(80,250,123,.18); } | |
| .pill { | |
| display: inline-block; | |
| padding: 4px 8px; | |
| border-radius: 999px; | |
| background: rgba(122, 183, 255, 0.10); | |
| border: 1px solid rgba(122, 183, 255, 0.24); | |
| color: var(--accent2); | |
| font-size: 12px; | |
| margin: 4px 4px 0 0; | |
| } | |
| .pill.good { color: var(--good); border-color: rgba(80, 250, 123, 0.35); background: rgba(80, 250, 123, 0.10); } | |
| .pill.warn { color: var(--warn); border-color: rgba(255, 216, 102, 0.35); background: rgba(255, 216, 102, 0.10); } | |
| .pill.bad { color: var(--bad); border-color: rgba(255, 107, 107, 0.35); background: rgba(255, 107, 107, 0.10); } | |
| .warn { color: var(--warn); } | |
| a { color: var(--accent2); text-decoration: none; } | |
| a:hover { text-decoration: underline; } | |
| .openBtn, .panelBtn { | |
| display: inline-block; | |
| padding: 7px 11px; | |
| border-radius: 999px; | |
| background: rgba(122, 183, 255, 0.10); | |
| border: 1px solid rgba(122, 183, 255, 0.25); | |
| color: var(--accent2); | |
| font-size: 12px; | |
| font-family: Segoe UI, Arial, sans-serif; | |
| cursor: pointer; | |
| } | |
| button.openBtn, button.panelBtn { appearance: none; } | |
| .openBtn:hover, .panelBtn:hover { background: rgba(122, 183, 255, 0.18); text-decoration: none; } | |
| .primaryBtn { color: #ffffff; border-color: rgba(80,250,123,.35); background: rgba(80,250,123,.13); } | |
| .wolBtn { cursor: pointer; font-family: Consolas, monospace; } | |
| .wolBtn:hover { background: rgba(80, 250, 123, 0.18); border-color: rgba(80, 250, 123, 0.45); color: var(--good); } | |
| .wolBtn.sending { opacity: 0.7; } | |
| .wolBtn.sent { color: var(--good); border-color: rgba(80, 250, 123, 0.45); background: rgba(80, 250, 123, 0.12); } | |
| .wolBtn.fail { color: var(--bad); border-color: rgba(255, 107, 107, 0.45); background: rgba(255, 107, 107, 0.12); } | |
| .pwaInstallBtn { | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 7px; | |
| width: 100%; | |
| margin-top: 10px; | |
| padding: 9px 12px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(122, 183, 255, 0.28); | |
| background: rgba(122, 183, 255, 0.10); | |
| color: var(--accent2); | |
| font-family: Segoe UI, Arial, sans-serif; | |
| font-size: 12px; | |
| cursor: pointer; | |
| } | |
| .pwaInstallBtn.show { display: inline-flex; } | |
| .pwaInstallBtn.pending { opacity: 0.72; border-style: dashed; } | |
| .pwaInstallBtn:hover { background: rgba(122, 183, 255, 0.18); color: #fff; } | |
| body.mobileBrowser .pwaInstallBtn { display: none !important; } | |
| .toast { | |
| position: fixed; | |
| right: 22px; | |
| bottom: 22px; | |
| max-width: 520px; | |
| padding: 13px 16px; | |
| border-radius: 14px; | |
| background: rgba(18, 24, 38, 0.96); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| box-shadow: 0 18px 45px rgba(0, 0, 0, 0.35); | |
| display: none; | |
| z-index: 99; | |
| } | |
| .toast.good { border-color: rgba(80, 250, 123, 0.45); } | |
| .toast.bad { border-color: rgba(255, 107, 107, 0.45); } | |
| .rotateModalOverlay { | |
| position: fixed; | |
| inset: 0; | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 22px; | |
| background: rgba(2, 6, 14, 0.68); | |
| backdrop-filter: blur(8px); | |
| z-index: 120; | |
| } | |
| .rotateModalOverlay.show { display: flex; } | |
| .rotateModal { | |
| width: min(520px, 100%); | |
| border-radius: 22px; | |
| border: 1px solid rgba(122, 183, 255, 0.24); | |
| background: linear-gradient(180deg, rgba(18, 27, 44, 0.98), rgba(10, 16, 29, 0.98)); | |
| box-shadow: 0 28px 90px rgba(0, 0, 0, 0.58); | |
| padding: 22px; | |
| color: var(--text); | |
| } | |
| .rotateModalIcon { | |
| width: 54px; | |
| height: 54px; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 18px; | |
| background: rgba(255, 216, 102, 0.12); | |
| border: 1px solid rgba(255, 216, 102, 0.28); | |
| color: var(--warn); | |
| font-size: 26px; | |
| margin-bottom: 14px; | |
| } | |
| .rotateModal h2 { | |
| margin: 0 0 8px; | |
| font-size: 22px; | |
| line-height: 1.2; | |
| } | |
| .rotateModal p { | |
| margin: 0; | |
| color: var(--muted); | |
| line-height: 1.45; | |
| font-size: 14px; | |
| } | |
| .rotateModal .targetName { | |
| color: var(--accent2); | |
| font-family: Consolas, monospace; | |
| font-weight: 700; | |
| } | |
| .rotateModalActions { | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 10px; | |
| margin-top: 20px; | |
| } | |
| .modalBtn { | |
| appearance: none; | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| padding: 10px 16px; | |
| font-family: Segoe UI, Arial, sans-serif; | |
| font-size: 13px; | |
| cursor: pointer; | |
| color: var(--text); | |
| background: rgba(255, 255, 255, 0.06); | |
| } | |
| .modalBtn:hover { background: rgba(255, 255, 255, 0.10); } | |
| .modalBtn.danger { | |
| color: #fff; | |
| border-color: rgba(255, 107, 107, 0.46); | |
| background: rgba(255, 107, 107, 0.16); | |
| } | |
| .modalBtn.danger:hover { background: rgba(255, 107, 107, 0.24); } | |
| .modalBtn:disabled { opacity: 0.55; cursor: wait; } | |
| .commandBox { | |
| display: grid; | |
| gap: 14px; | |
| } | |
| .commandHelp { | |
| color: var(--muted); | |
| font-size: 14px; | |
| line-height: 1.45; | |
| } | |
| .commandInput { | |
| width: 100%; | |
| min-height: 130px; | |
| resize: vertical; | |
| box-sizing: border-box; | |
| border-radius: 14px; | |
| border: 1px solid rgba(122, 183, 255, 0.24); | |
| background: rgba(4, 9, 18, 0.72); | |
| color: var(--text); | |
| padding: 13px 14px; | |
| font: 14px Consolas, monospace; | |
| outline: none; | |
| } | |
| .commandInput:focus { | |
| border-color: rgba(77,160,255,.58); | |
| box-shadow: 0 0 0 3px rgba(77,160,255,.12); | |
| } | |
| .commandActions { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .commandConfirm { | |
| display: none; | |
| border: 1px solid rgba(255, 216, 102, 0.30); | |
| background: rgba(255, 216, 102, 0.08); | |
| border-radius: 16px; | |
| padding: 14px; | |
| } | |
| .commandConfirm.show { display: block; } | |
| .commandConfirmTitle { | |
| color: var(--warn); | |
| font-weight: 800; | |
| margin-bottom: 8px; | |
| } | |
| .commandConfirmText { | |
| display: block; | |
| margin: 10px 0 0 0; | |
| padding: 10px; | |
| border-radius: 12px; | |
| border: 1px solid rgba(38,50,71,.86); | |
| background: rgba(4, 9, 18, 0.72); | |
| color: #fff; | |
| font: 13px Consolas, monospace; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .commandResult { | |
| color: var(--muted); | |
| font-size: 13px; | |
| } | |
| details { margin-top: 8px; } | |
| summary { cursor: pointer; color: var(--accent2); font-size: 12px; } | |
| pre { max-height: 260px; overflow: auto; background: #0d1320; border: 1px solid var(--border); border-radius: 12px; padding: 10px; color: var(--muted); font-size: 11px; white-space: pre-wrap; } | |
| .empty { display: none; text-align: center; color: var(--muted); padding: 40px; font-size: 15px; } | |
| .footer { color: var(--muted); margin-top: 14px; font-size: 12px; text-align: right; } | |
| /* Sidebar / selected-computer workspace inspired by the provided mockup */ | |
| .appShell { | |
| min-height: calc(100vh - 56px); | |
| display: grid; | |
| grid-template-columns: 360px minmax(0, 1fr); | |
| border: 1px solid rgba(38, 50, 71, 0.78); | |
| border-radius: 18px; | |
| overflow: hidden; | |
| background: rgba(7, 13, 24, 0.72); | |
| box-shadow: 0 24px 70px rgba(0,0,0,.34); | |
| } | |
| .sidebar { | |
| border-right: 1px solid rgba(38, 50, 71, 0.88); | |
| background: rgba(8, 16, 29, .84); | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 0; | |
| } | |
| .sidebarStatus { | |
| padding: 22px 18px 16px 18px; | |
| border-bottom: 1px solid rgba(38, 50, 71, 0.78); | |
| } | |
| .statusLine { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| color: #cbd7eb; | |
| font-size: 16px; | |
| } | |
| .serverStatusIconSlot { | |
| width: 88px; | |
| height: 78px; | |
| flex: 0 0 88px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-start; | |
| overflow: hidden; | |
| } | |
| .serverStatusIcon { | |
| width: 76px; | |
| height: 76px; | |
| border-radius: 0; | |
| background: transparent; | |
| border: 0; | |
| box-shadow: none; | |
| padding: 0; | |
| object-fit: contain; | |
| display: block; | |
| flex: 0 0 76px; | |
| } | |
| .serverStatusTextBlock { | |
| min-width: 0; | |
| flex: 1 1 auto; | |
| display: grid; | |
| grid-template-columns: auto minmax(0, 1fr); | |
| grid-template-rows: auto auto; | |
| column-gap: 12px; | |
| row-gap: 8px; | |
| align-items: center; | |
| } | |
| .serverStatusLabel { | |
| grid-column: 2; | |
| grid-row: 1; | |
| line-height: 1.25; | |
| color: #eef4ff; | |
| } | |
| .dot { width: 15px; height: 15px; border-radius: 50%; display: inline-block; background: var(--good); box-shadow: 0 0 18px rgba(80,250,123,.35); flex: 0 0 auto; } | |
| .serverStatusTextBlock > .dot { | |
| grid-column: 1; | |
| grid-row: 1; | |
| align-self: center; | |
| } | |
| .dot.warnDot { background: var(--warn); box-shadow: 0 0 18px rgba(255,216,102,.35); } | |
| .dot.badDot { background: var(--bad); box-shadow: 0 0 18px rgba(255,107,107,.35); } | |
| .dot.unknownDot { background: var(--muted); box-shadow: 0 0 18px rgba(140,155,179,.28); } | |
| .domainText { | |
| grid-column: 2; | |
| grid-row: 2; | |
| color: var(--muted); | |
| margin: 0; | |
| font-size: 14px; | |
| text-align: left; | |
| justify-self: start; | |
| width: auto; | |
| transform: none; | |
| } | |
| .sidebarSearch { padding: 16px; border-bottom: 1px solid rgba(38, 50, 71, 0.78); } | |
| .sidebarSearch input { padding-left: 16px; } | |
| .sidebarHead { display: flex; align-items: center; justify-content: space-between; color: #cbd7eb; padding: 0 16px 12px 16px; font-size: 14px; } | |
| .sidebarList { overflow: auto; padding: 0 16px 16px 16px; flex: 1; } | |
| .sideComputer { | |
| width: 100%; | |
| appearance: none; | |
| border: 0; | |
| color: var(--text); | |
| background: transparent; | |
| border-bottom: 1px solid rgba(38, 50, 71, 0.58); | |
| padding: 13px 10px; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| text-align: left; | |
| font-family: Segoe UI, Arial, sans-serif; | |
| font-size: 16px; | |
| cursor: pointer; | |
| border-radius: 8px; | |
| } | |
| .sideComputer:hover { background: rgba(122,183,255,.08); } | |
| .sideComputer.selected { | |
| background: linear-gradient(135deg, rgba(0, 102, 255, .88), rgba(0, 58, 165, .92)); | |
| border-bottom-color: transparent; | |
| box-shadow: inset 0 0 0 1px rgba(122,183,255,.22), 0 10px 28px rgba(0,80,255,.20); | |
| } | |
| .sideName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .sidebarFooter { border-top: 1px solid rgba(38, 50, 71, 0.78); padding: 16px 20px; display: flex; align-items: center; justify-content: space-between; color: var(--muted); } | |
| .refreshBtn { background: transparent; border: 0; color: #cbd7eb; font-size: 28px; cursor: pointer; line-height: 1; display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 999px; transition: background .15s ease, color .15s ease; } | |
| .refreshBtn:hover { background: rgba(122,183,255,.10); color: #fff; } | |
| .refreshBtn.refreshing { cursor: wait; opacity: .88; } | |
| .refreshBtn.refreshing .refreshIcon { animation: refreshSpin .8s linear infinite; } | |
| .refreshIcon { display: inline-block; transform-origin: 50% 50%; } | |
| @keyframes refreshSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } | |
| .workspace { min-width: 0; padding: 34px 30px; overflow: auto; } | |
| .machineView { display: none; } | |
| .machineView.selected { display: block; } | |
| .selectedHeader { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 24px; } | |
| .selectedTitle { display: flex; align-items: center; gap: 18px; } | |
| .selectedIcon { width: 72px; height: 72px; border-radius: 18px; background: rgba(122,183,255,.08); border: 1px solid rgba(122,183,255,.18); display:flex; align-items:center; justify-content:center; font-size: 42px; } | |
| .selectedTitle h1 { font-size: 28px; } | |
| .onlineLine { color: #dbe6f7; margin-top: 6px; font-size: 18px; display:flex; align-items:center; gap: 10px; } | |
| .tabBar { display: flex; gap: 6px; background: rgba(18, 29, 47, .72); border: 1px solid rgba(38,50,71,.62); border-radius: 14px; padding: 8px; margin-bottom: 20px; overflow-x: auto; } | |
| .tabBtn { appearance: none; border: 0; background: transparent; color: #c2ccdc; padding: 12px 18px; border-radius: 10px; cursor: pointer; font: 15px Segoe UI, Arial, sans-serif; white-space: nowrap; position: relative; } | |
| .tabBtn:hover { background: rgba(122,183,255,.08); color: #fff; } | |
| .tabBtn.active { color: #4da0ff; background: rgba(77,160,255,.07); } | |
| .tabBtn.active::after { content: ''; position: absolute; left: 18px; right: 18px; bottom: 3px; height: 3px; border-radius: 999px; background: #248cff; box-shadow: 0 0 16px rgba(36,140,255,.8); } | |
| .tabPanel { display: none; border: 1px solid rgba(38,50,71,.86); border-radius: 18px; background: rgba(10, 18, 32, .64); padding: 24px 26px; } | |
| .tabPanel.active { display: block; } | |
| .tabTitle { color: #4da0ff; font-size: 22px; font-weight: 700; margin: 0 0 18px 0; } | |
| .infoGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } | |
| .infoCard { background: rgba(13, 23, 39, .68); border: 1px solid rgba(38,50,71,.76); border-radius: 16px; padding: 16px; } | |
| .infoLabel { color: var(--muted); font-size: 13px; margin-bottom: 8px; } | |
| .infoValue { color: var(--text); font-size: 16px; font-weight: 600; word-break: break-word; } | |
| .lapsPanelCard { border: 1px solid rgba(57, 83, 113, .92); background: rgba(13, 24, 41, .72); border-radius: 16px; margin: 18px 0; padding: 0 18px 14px 18px; overflow: hidden; } | |
| .lapsPanelHeader { display:flex; align-items:center; justify-content:space-between; gap: 14px; padding: 16px 0; border-bottom: 1px solid rgba(57,83,113,.55); } | |
| .lapsPanelName { display:flex; align-items:center; gap: 14px; color: #fff; font-size: 22px; font-weight: 800; } | |
| .lapsShield { font-size: 32px; filter: drop-shadow(0 0 12px rgba(77,160,255,.35)); } | |
| .lapsRow { display:grid; grid-template-columns: 44px 1fr auto; gap: 14px; align-items:center; padding: 14px 0; border-bottom: 1px solid rgba(57,83,113,.45); } | |
| .lapsRow:last-child { border-bottom: 0; } | |
| .lapsRowIcon { font-size: 24px; text-align:center; } | |
| .lapsRowLabel { color: #b8c4d6; font-size: 16px; } | |
| .lapsRowValue { color: #fff; font-weight: 700; font-size: 17px; text-align:right; } | |
| .remoteTopBtn { padding: 13px 18px; border-radius: 12px; } | |
| .noSelection { color: var(--muted); text-align: center; padding: 70px 20px; } | |
| body.mobileBrowser { | |
| padding: 14px; | |
| font-size: 15px; | |
| -webkit-text-size-adjust: 112%; | |
| text-size-adjust: 112%; | |
| } | |
| body.mobileBrowser .container { max-width: none; width: 100%; } | |
| body.mobileBrowser .header { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| gap: 18px; | |
| margin-bottom: 20px; | |
| } | |
| body.mobileBrowser .brand { gap: 16px; align-items: center; } | |
| body.mobileBrowser .logo { width: 72px; height: 72px; border-radius: 24px; } | |
| body.mobileBrowser h1 { font-size: 28px; line-height: 1.05; } | |
| body.mobileBrowser .subtitle { font-size: 16px; line-height: 1.35; } | |
| body.mobileBrowser .badges { | |
| justify-content: flex-start; | |
| gap: 10px; | |
| } | |
| body.mobileBrowser .badge { | |
| font-size: 15px; | |
| padding: 11px 16px; | |
| } | |
| body.mobileBrowser .toolbar { | |
| flex-direction: column; | |
| align-items: stretch; | |
| padding: 16px; | |
| border-radius: 22px; | |
| gap: 14px; | |
| margin-bottom: 18px; | |
| } | |
| body.mobileBrowser .searchIcon { | |
| left: 17px; | |
| font-size: 18px; | |
| } | |
| body.mobileBrowser input { | |
| font-size: 18px; | |
| min-height: 50px; | |
| padding: 16px 16px 16px 48px; | |
| border-radius: 16px; | |
| } | |
| body.mobileBrowser .stat { | |
| text-align: right; | |
| font-size: 16px; | |
| } | |
| body.mobileBrowser .card { | |
| padding: 16px; | |
| border-radius: 22px; | |
| } | |
| body.mobileBrowser .machineGrid { | |
| grid-template-columns: 1fr; | |
| gap: 18px; | |
| } | |
| body.mobileBrowser .machineCard { | |
| border-radius: 24px; | |
| } | |
| body.mobileBrowser .machineTop { | |
| padding: 17px; | |
| gap: 16px; | |
| } | |
| body.mobileBrowser .computer { | |
| font-size: 20px; | |
| line-height: 1.12; | |
| } | |
| body.mobileBrowser .small { | |
| font-size: 15px; | |
| line-height: 1.35; | |
| } | |
| body.mobileBrowser .mono { | |
| font-size: 15px; | |
| } | |
| body.mobileBrowser .quickFacts { | |
| grid-template-columns: 1fr; | |
| gap: 10px; | |
| padding: 0 20px 18px 20px; | |
| } | |
| body.mobileBrowser .fact { | |
| min-height: 68px; | |
| padding: 13px 14px; | |
| border-radius: 18px; | |
| } | |
| body.mobileBrowser .factLabel { | |
| font-size: 12px; | |
| margin-bottom: 6px; | |
| } | |
| body.mobileBrowser .factValue { | |
| font-size: 16px; | |
| line-height: 1.25; | |
| } | |
| body.mobileBrowser .actionBar { | |
| padding: 13px 17px; | |
| gap: 10px; | |
| } | |
| body.mobileBrowser .openBtn, | |
| body.mobileBrowser .panelBtn { | |
| font-size: 16px; | |
| padding: 12px 16px; | |
| min-height: 46px; | |
| border-radius: 999px; | |
| } | |
| body.mobileBrowser .detailPanel { | |
| padding: 18px 20px 20px 20px; | |
| } | |
| body.mobileBrowser .detailGrid { | |
| grid-template-columns: 1fr; | |
| gap: 14px; | |
| } | |
| body.mobileBrowser .detailBox { | |
| padding: 15px; | |
| border-radius: 18px; | |
| } | |
| body.mobileBrowser .detailTitle { | |
| font-size: 13px; | |
| margin-bottom: 9px; | |
| } | |
| body.mobileBrowser .pill { | |
| font-size: 15px; | |
| padding: 7px 10px; | |
| } | |
| body.mobileBrowser summary { | |
| font-size: 16px; | |
| padding: 8px 0; | |
| } | |
| body.mobileBrowser pre { | |
| font-size: 14px; | |
| padding: 14px; | |
| } | |
| body.mobileBrowser .toast { | |
| left: 14px; | |
| right: 14px; | |
| bottom: 18px; | |
| max-width: none; | |
| font-size: 16px; | |
| } | |
| body.mobileBrowser .rotateModal { | |
| padding: 24px; | |
| } | |
| body.mobileBrowser .rotateModal h2 { font-size: 24px; } | |
| body.mobileBrowser .rotateModal p { font-size: 16px; } | |
| body.mobileBrowser .modalBtn { font-size: 16px; padding: 12px 18px; } | |
| body.mobileBrowser .footer { | |
| font-size: 13px; | |
| text-align: center; | |
| margin-top: 18px; | |
| } | |
| @media (max-width: 820px) { | |
| body { padding: 0; } | |
| .container { max-width: none; } | |
| .appShell { grid-template-columns: 1fr; border-radius: 0; min-height: 100vh; } | |
| .sidebar { max-height: 42vh; border-right: 0; border-bottom: 1px solid rgba(38,50,71,.88); } | |
| .workspace { padding: 20px 14px; } | |
| .selectedHeader { flex-direction: column; align-items: stretch; } | |
| .infoGrid { grid-template-columns: 1fr; } | |
| .lapsRow { grid-template-columns: 34px 1fr; } | |
| .lapsRowValue { grid-column: 2; text-align: left; } | |
| } | |
| </style> | |
| </head> | |
| <body class="$bodyClass"> | |
| <div class="container"> | |
| <div class="appShell"> | |
| <aside class="sidebar"> | |
| <div class="sidebarStatus"> | |
| <div class="statusLine"><span class="serverStatusIconSlot"><img class="serverStatusIcon" src="$svgUri" alt=""></span><div class="serverStatusTextBlock"><span id="adStatusDot" class="dot $adDotClass"></span><span id="adStatusLabel" class="serverStatusLabel">$adStatusTextHtml</span><div id="adDomainText" class="domainText">$adDomainText</div></div></div> | |
| </div> | |
| <div class="sidebarSearch"> | |
| <input type="text" id="search" placeholder="Search computers..." onkeyup="filterTable()" autocomplete="off"> | |
| </div> | |
| <div class="sidebarHead"><span>Computer Name</span></div> | |
| <div id="sidebarList" class="sidebarList"> | |
| "@ | |
| $sidebarItems = @() | |
| $machineViews = @() | |
| $firstMachine = $true | |
| foreach ($c in $computers) { | |
| $meta = Parse-Metadata $c.rustDeskMetadata | |
| $nameRaw = First-Value (Get-MetaValue $meta "ad" "name") $c.name | |
| $uriRaw = First-Value (Get-MetaValue $meta "ad" "rustDeskUri") $c.rustDeskUri | |
| $passRaw = First-Value (Get-MetaValue $meta "ad" "rustDeskPass") $c.rustDeskPass | |
| $passPlain = Unprotect-RustDeskADPasswordValue -StoredValue ([string]$passRaw) | |
| $osRaw = First-Value (Get-MetaValue $meta "ad" "operatingSystem") $c.operatingSystem | |
| $osverRaw = First-Value (Get-MetaValue $meta "ad" "operatingSystemVersion") $c.operatingSystemVersion | |
| $dnsRaw = First-Value (Get-MetaValue $meta "ad" "dNSHostName") $c.dNSHostName | |
| $descRaw = First-Value (Get-MetaValue $meta "ad" "description") $c.description | |
| $locationRaw = First-Value (Get-MetaValue $meta "ad" "location") $c.location | |
| $managedByRaw = First-Value (Get-MetaValue $meta "ad" "managedBy") $c.managedBy | |
| $infoRaw = First-Value (Get-MetaValue $meta "ad" "info") $c.info | |
| $serialRaw = First-Value (Get-MetaValue $meta "ad" "serialNumber") $c.serialNumber | |
| $dnRaw = First-Value (Get-MetaValue $meta "ad" "distinguishedName") $c.distinguishedName | |
| $uacRaw = First-Value (Get-MetaValue $meta "ad" "userAccountControl") $c.userAccountControl | |
| $lastLogonRaw = First-Value (Get-MetaValue $meta "ad" "lastLogon") $c.lastLogon | |
| $lastLogonTimestampRaw = First-Value (Get-MetaValue $meta "ad" "lastLogonTimestamp") $c.lastLogonTimestamp | |
| $pwdLastSetRaw = First-Value (Get-MetaValue $meta "ad" "pwdLastSet") $c.pwdLastSet | |
| $lapsOldPasswordRaw = First-Value (Get-MetaValue $meta "ad" "ms-Mcs-AdmPwd") $c.'ms-Mcs-AdmPwd' | |
| $lapsOldRaw = First-Value (Get-MetaValue $meta "ad" "ms-Mcs-AdmPwdExpirationTime") $c.'ms-Mcs-AdmPwdExpirationTime' | |
| $lapsNewPasswordRaw = First-Value (Get-MetaValue $meta "ad" "msLAPS-Password") $c.'msLAPS-Password' | |
| $lapsNewEncryptedRaw = First-Value (Get-MetaValue $meta "ad" "msLAPS-EncryptedPassword") $c.'msLAPS-EncryptedPassword' | |
| $lapsNewRaw = First-Value (Get-MetaValue $meta "ad" "msLAPS-PasswordExpirationTime") $c.'msLAPS-PasswordExpirationTime' | |
| $createdRaw = First-Value (Get-MetaValue $meta "ad" "whenCreated") $c.whenCreated | |
| $lastLoggedOnUserRaw = Get-MetaValue $meta "local" "lastLoggedOnUser" | |
| $interactiveUsersRaw = Get-MetaValue $meta "local" "interactiveUsers" | |
| $primaryIPv4Raw = Get-MetaValue $meta "local" "primaryIPv4" | |
| $primaryIPv6Raw = Get-MetaValue $meta "local" "primaryIPv6" | |
| $macAddressesRaw = Get-MetaValue $meta "local" "macAddresses" | |
| $manufacturerRaw = Get-MetaValue $meta "local" "manufacturer" | |
| $modelRaw = Get-MetaValue $meta "local" "model" | |
| $localSerialRaw = Get-MetaValue $meta "local" "serialNumber" | |
| $biosVersionRaw = Get-MetaValue $meta "local" "biosVersion" | |
| $osVersionRaw = Get-MetaValue $meta "local" "osVersion" | |
| $osCaptionRaw = Get-MetaValue $meta "local" "osCaption" | |
| $uptimeRaw = Get-MetaValue $meta "local" "uptimeSeconds" | |
| $publisherRaw = Get-MetaValue $meta "local" "publisher" | |
| $publishedAtRaw = Get-MetaValue $meta "local" "publishedAt" | |
| $rustDeskIdRaw = Get-MetaValue $meta "local" "rustDeskId" | |
| $agentLastUpdatedRaw = Get-MetaValue $meta "agent" "lastupdated" | |
| $agentIntervalSecondsRaw = Get-MetaValue $meta "agent" "intervalseconds" | |
| $localBaseSerialRaw = Get-MetaValue $meta "local" "baseBoardSerial" | |
| if ( | |
| $null -eq $serialRaw -or | |
| [string]::IsNullOrWhiteSpace([string]$serialRaw) -or | |
| [string]$serialRaw -eq "To be filled by O.E.M." -or | |
| [string]$serialRaw -eq "Default string" | |
| ) { | |
| if (-not [string]::IsNullOrWhiteSpace([string]$localBaseSerialRaw)) { | |
| $serialRaw = $localBaseSerialRaw | |
| } | |
| elseif (-not [string]::IsNullOrWhiteSpace([string]$localSerialRaw)) { | |
| $serialRaw = $localSerialRaw | |
| } | |
| } | |
| $name = Html $nameRaw | |
| $uri = Html $uriRaw | |
| $os = Html $osRaw | |
| $osver = Html $osverRaw | |
| $dns = Html $dnsRaw | |
| $desc = Html $descRaw | |
| $location = Html $locationRaw | |
| $managedBy = Html (Dn-To-Name $managedByRaw) | |
| $managedByDn = Html $managedByRaw | |
| $info = Html $infoRaw | |
| $serial = Html $serialRaw | |
| $bios = Html $biosVersionRaw | |
| $osbuild = Html $osVersionRaw | |
| $osname = Html $osCaptionRaw | |
| $ou = Html (Get-OUFromDN $dnRaw) | |
| $uac = Html $uacRaw | |
| $baseboardserial = Html $localBaseSerialRaw | |
| $lastSeen = Html (Convert-ADFileTime $lastLogonRaw) | |
| $pwdLastSet = Html (Convert-ADFileTime $pwdLastSetRaw) | |
| $lapsOld = Html (Convert-ADFileTime $lapsOldRaw) | |
| $lapsNew = Html (Convert-ADFileTime $lapsNewRaw) | |
| # Process LAPS strings early to add them to cache without putting into DOM | |
| $lapsOldPasswordPlain = Get-LapsPasswordDisplay $lapsOldPasswordRaw | |
| $lapsNewPasswordPlain = Get-LapsPasswordDisplay $lapsNewPasswordRaw | |
| $lapsNewLastRotationRaw = "" | |
| $lapsNewLookupStatusRaw = "" | |
| $lapsNewLookupSourceRaw = "" | |
| $lapsNewLookupAccountRaw = "" | |
| $lapsNewLookupErrorRaw = "" | |
| if ($lapsNewRaw -or $lapsNewPasswordRaw -or $lapsNewEncryptedRaw) { | |
| $lapsNewLookup = Get-WindowsLapsPasswordForEndpoint -Identity $nameRaw | |
| if ($lapsNewLookup) { | |
| $lapsNewLookupStatusRaw = $lapsNewLookup.DecryptionStatus | |
| $lapsNewLookupSourceRaw = $lapsNewLookup.Source | |
| $lapsNewLookupAccountRaw = $lapsNewLookup.Account | |
| $lapsNewLookupErrorRaw = $lapsNewLookup.Error | |
| if (-not [string]::IsNullOrWhiteSpace([string]$lapsNewLookup.Password)) { | |
| $lapsNewPasswordPlain = [string]$lapsNewLookup.Password | |
| } | |
| if ($lapsNewLookup.ExpirationTimestamp) { | |
| $lapsNew = Html (Convert-DateDisplay $lapsNewLookup.ExpirationTimestamp) | |
| } | |
| if ($lapsNewLookup.PasswordUpdateTime) { | |
| $lapsNewLastRotationRaw = Convert-DateDisplay $lapsNewLookup.PasswordUpdateTime | |
| } | |
| } | |
| } | |
| # Store credentials securely in server memory keyed by the raw computer name. | |
| $script:CredentialCache[$nameRaw] = @{ | |
| rustdesk = [string]$passPlain | |
| lapsLegacy = [string]$lapsOldPasswordPlain | |
| lapsWindows = [string]$lapsNewPasswordPlain | |
| } | |
| $lapsNewLastRotation = Html $lapsNewLastRotationRaw | |
| $lapsNewLookupStatus = Html $lapsNewLookupStatusRaw | |
| $lapsNewLookupAccount = Html $lapsNewLookupAccountRaw | |
| $lapsNewLookupError = Html $lapsNewLookupErrorRaw | |
| $created = Html (Convert-DateDisplay $createdRaw) | |
| $publishedAt = Html (Convert-DateDisplay $publishedAtRaw) | |
| $lastLoggedOnUser = Html $lastLoggedOnUserRaw | |
| $interactiveUsers = Make-Pills $interactiveUsersRaw 5 | |
| $primaryIPv4 = Html $primaryIPv4Raw | |
| $primaryIPv6 = Html $primaryIPv6Raw | |
| $macPills = New-WolButtons $macAddressesRaw 6 | |
| $primaryWolMacRaw = Resolve-PrimaryWolMac -Metadata $meta -PrimaryIPv4 $primaryIPv4Raw -PrimaryIPv6 $primaryIPv6Raw -MacAddresses $macAddressesRaw | |
| $primaryWolButton = New-PrimaryWolButton $primaryWolMacRaw | |
| $primaryPowerButton = New-PowerActionButton $nameRaw | |
| $manufacturer = Html $manufacturerRaw | |
| $model = Html $modelRaw | |
| $publisher = Html $publisherRaw | |
| $rustDeskId = First-Value $rustDeskIdRaw (Get-RustDeskIdFromUri $uriRaw) | |
| $rustDeskIdHtml = Html $rustDeskId | |
| $rustDeskIdAttr = Html $rustDeskId | |
| $agentStatus = Get-AgentOnlineStatus -LastUpdated $agentLastUpdatedRaw -IntervalSeconds $agentIntervalSecondsRaw -GraceSeconds $GraceSeconds | |
| $agentState = Html $agentStatus.State | |
| $agentStatusText = Html $agentStatus.Text | |
| $agentDotClass = Html $agentStatus.DotClass | |
| $agentStatusDetail = Html $agentStatus.Detail | |
| $agentLastUpdated = Html $agentStatus.LastUpdatedDisplay | |
| $agentIntervalSeconds = Html $agentStatus.IntervalSeconds | |
| $agentAgeSeconds = Html $agentStatus.AgeSeconds | |
| $agentOfflineAfter = Html $agentStatus.OfflineAfterDisplay | |
| # Keep metadata search useful, but never include credential-bearing fields in page source. | |
| $safeMetadataSearchRaw = Redact-ClientSecretText $c.rustDeskMetadata | |
| $searchTextRaw = @( | |
| $nameRaw, $uriRaw, $osRaw, $osverRaw, $dnsRaw, $descRaw, | |
| $locationRaw, $managedByRaw, $infoRaw, $serialRaw, $biosVersionRaw, $dnRaw, $uacRaw, | |
| $lastLogonRaw, $pwdLastSetRaw, $lapsOldRaw, | |
| $lapsNewRaw, $createdRaw, | |
| $lastLoggedOnUserRaw, $interactiveUsersRaw, $primaryIPv4Raw, | |
| $primaryIPv6Raw, $macAddressesRaw, $manufacturerRaw, $modelRaw, | |
| $localSerialRaw, $uptimeRaw, $publisherRaw, $publishedAtRaw, | |
| $agentLastUpdatedRaw, $agentIntervalSecondsRaw, $agentStatus.Text, $agentStatus.State, | |
| $rustDeskIdRaw, $rustDeskId, $safeMetadataSearchRaw | |
| ) | Where-Object { $null -ne $_ -and [string]$_ -ne "" } | |
| $searchText = Html (Redact-ClientSecretText (($searchTextRaw | ForEach-Object { [string]$_ }) -join " ")) | |
| $uptimeText = "" | |
| if ($null -ne $uptimeRaw -and [string]$uptimeRaw -ne "") { | |
| try { | |
| $ts = [TimeSpan]::FromSeconds([double]$uptimeRaw) | |
| $uptimeText = Html ("{0}d {1}h {2}m" -f [int]$ts.TotalDays, $ts.Hours, $ts.Minutes) | |
| } | |
| catch { | |
| $uptimeText = Html $uptimeRaw | |
| } | |
| } | |
| $lapsLines = @() | |
| if ($lapsOld -or $lapsOldPasswordPlain) { | |
| $legacyExpireText = "Detected" | |
| if ($lapsOld) { $legacyExpireText = $lapsOld } | |
| $legacyCopy = "" | |
| if ($lapsOldPasswordPlain) { | |
| $legacyCopy = "<button type=""button"" class=""miniCopyBtn"" data-computer=""$name"" data-credtype=""lapsLegacy"" onclick=""copyPass(this)"">Copy password 🔑</button>" | |
| } | |
| else { | |
| $legacyCopy = "<span class=""lapsMuted"">Password unreadable</span>" | |
| } | |
| $lapsLines += @" | |
| <div class="lapsPanelCard"><div class="lapsPanelHeader"><div class="lapsPanelName"><span class="lapsShield">🛡️</span>Legacy LAPS</div>$legacyCopy</div><div class="lapsRow"><div class="lapsRowIcon">✅</div><div class="lapsRowLabel">Status</div><div class="lapsRowValue">Success</div></div><div class="lapsRow"><div class="lapsRowIcon">👤</div><div class="lapsRowLabel">Username</div><div class="lapsRowValue">Administrator</div></div><div class="lapsRow"><div class="lapsRowIcon">🗓️</div><div class="lapsRowLabel">Expires</div><div class="lapsRowValue">$legacyExpireText</div></div><div class="lapsRow"><div class="lapsRowIcon">🔁</div><div class="lapsRowLabel">Last rotation</div><div class="lapsRowValue">Unavailable</div></div></div> | |
| "@ | |
| } | |
| if ($lapsNew -or $lapsNewPasswordPlain -or $lapsNewEncryptedRaw -or $lapsNewPasswordRaw) { | |
| $newExpireText = "Detected" | |
| if ($lapsNew) { $newExpireText = $lapsNew } | |
| $newRotationText = "Unavailable" | |
| if ($lapsNewLastRotation) { $newRotationText = $lapsNewLastRotation } | |
| $newStatusText = "" | |
| if ($lapsNewPasswordPlain) { | |
| $newStatusText = "✅ Success - Decrypted Password" | |
| } | |
| elseif ($lapsNewEncryptedRaw -or $lapsNewPasswordRaw) { | |
| $newStatusText = "⛔ Failure - Encrypted Password" | |
| } | |
| elseif ($lapsNewLookupStatus) { | |
| $newStatusText = "⛔ Failure - Encrypted Password" | |
| } | |
| else { | |
| $newStatusText = "Unknown" | |
| } | |
| $newUsernameRow = "" | |
| if ($lapsNewLookupAccount) { | |
| $newUsernameRow = "<div><span>Username</span><strong>$lapsNewLookupAccount</strong></div>" | |
| } | |
| $newCopy = "" | |
| if ($lapsNewPasswordPlain) { | |
| $newCopy = "<button type=""button"" class=""miniCopyBtn"" data-computer=""$name"" data-credtype=""lapsWindows"" onclick=""copyPass(this)"">Copy password 🔑</button>" | |
| } | |
| elseif ($lapsNewLookupError) { | |
| $newCopy = "<span class=""lapsMuted"" title=""$lapsNewLookupError"">Password unreadable</span>" | |
| } | |
| else { | |
| $newCopy = "<span class=""lapsMuted"">Password unreadable</span>" | |
| } | |
| $lapsLines += @" | |
| <div class="lapsPanelCard"><div class="lapsPanelHeader"><div class="lapsPanelName"><span class="lapsShield">🛡️</span>Windows LAPS</div>$newCopy</div><div class="lapsRow"><div class="lapsRowIcon">✅</div><div class="lapsRowLabel">Status</div><div class="lapsRowValue">$newStatusText</div></div><div class="lapsRow"><div class="lapsRowIcon">👤</div><div class="lapsRowLabel">Username</div><div class="lapsRowValue">$lapsNewLookupAccount</div></div><div class="lapsRow"><div class="lapsRowIcon">🗓️</div><div class="lapsRowLabel">Expires</div><div class="lapsRowValue">$newExpireText</div></div><div class="lapsRow"><div class="lapsRowIcon">🔁</div><div class="lapsRowLabel">Last rotation</div><div class="lapsRowValue">$newRotationText</div></div></div> | |
| "@ | |
| } | |
| if ($lapsLines.Count -gt 0) { | |
| $lapsText = $lapsLines -join "`n" | |
| } | |
| else { | |
| $lapsText = "<div class=""small"">Not detected</div>" | |
| } | |
| $metaStatus = "<span class=""pill bad"">No JSON</span>" | |
| $rawMetaBlock = "" | |
| if ($null -ne $meta) { | |
| $schemaVersion = Html $meta.schemaVersion | |
| $metaStatus = "<span class=""pill good"">JSON v$schemaVersion</span>" | |
| $rawMetaBlock = Html (Redact-ClientSecretText (($meta | ConvertTo-Json -Depth 12))) | |
| } | |
| elseif ($c.rustDeskMetadata) { | |
| $metaStatus = "<span class=""pill warn"">Bad JSON</span>" | |
| $rawMetaBlock = Html (Redact-ClientSecretText $c.rustDeskMetadata) | |
| } | |
| $rustDeskCell = "" | |
| if (-not [string]::IsNullOrWhiteSpace($rustDeskId) -and -not [string]::IsNullOrWhiteSpace($passPlain)) { | |
| $rustDeskCell = @" | |
| <br> | |
| <button type="button" class="openBtn" title="Control" data-id="$rustDeskIdAttr" data-computer="$name" data-credtype="rustdesk" onclick="launchRustDesk(this)">🖱️</button> | |
| <button type="button" class="openBtn" title="RDP Tunnel" data-id="$rustDeskIdAttr" data-computer="$name" data-credtype="rustdesk" onclick="launchRustDeskRdp(this)">🪟</button> | |
| <button type="button" class="openBtn" title="Terminal" data-id="$rustDeskIdAttr" data-computer="$name" data-credtype="rustdesk" onclick="launchRustDeskTerminal(this)">🐚</button> | |
| $primaryPowerButton | |
| $primaryWolButton | |
| "@ | |
| } | |
| elseif ($uri) { | |
| $rustDeskCell = @" | |
| <br> | |
| $primaryWolButton | |
| "@ | |
| } | |
| else { | |
| $rustDeskCell = '<span class="small warn">No rustDeskUri</span>' | |
| } | |
| $passBlock = "" | |
| if (-not [string]::IsNullOrWhiteSpace([string]$passPlain)) { | |
| $passBlock = @" | |
| <button type="button" class="openBtn" title="Copy password" data-computer="$name" data-credtype="rustdesk" onclick="copyPass(this)">🔑</button> | |
| <button type="button" class="openBtn" title="Rotate RustDesk password" data-computer="$name" onclick="rotateRustDeskPassword(this)">🔄</button> | |
| "@ | |
| } | |
| $selectedClass = if ($firstMachine) { " selected" } else { "" } | |
| $firstMachine = $false | |
| $sidebarItems += @" | |
| <button type="button" class="sideComputer dataRow$selectedClass" data-key="$name" data-search="$searchText" onclick="selectMachine('$name')" title="$agentStatusDetail"> | |
| <span class="dot $agentDotClass"></span><span class="sideName">$name</span> | |
| </button> | |
| "@ | |
| $machineViews += @" | |
| <section class="machineView$selectedClass" data-key="$name"> | |
| <div class="selectedHeader"> | |
| <div class="selectedTitle"> | |
| <div class="selectedIcon">🖥️</div> | |
| <div> | |
| <h1>$name</h1> | |
| <div class="onlineLine" title="$agentStatusDetail"><span class="dot $agentDotClass"></span> $agentStatusText</div> | |
| <div class="small mono">$dns</div> | |
| </div> | |
| </div> | |
| <div> | |
| $rustDeskCell | |
| $passBlock | |
| </div> | |
| </div> | |
| <div class="tabBar"> | |
| <button type="button" class="tabBtn active" data-tab-target="overview" onclick="showTab(this, 'overview')">Overview</button> | |
| <button type="button" class="tabBtn" data-tab-target="system" onclick="showTab(this, 'system')">System</button> | |
| <button type="button" class="tabBtn" data-tab-target="network" onclick="showTab(this, 'network')">Network</button> | |
| <button type="button" class="tabBtn" data-tab-target="laps" onclick="showTab(this, 'laps')">LAPS</button> | |
| <button type="button" class="tabBtn" data-tab-target="sessions" onclick="showTab(this, 'sessions')">Sessions</button> | |
| <button type="button" class="tabBtn" data-tab-target="commands" onclick="showTab(this, 'commands')">Commands</button> | |
| <button type="button" class="tabBtn" data-tab-target="events" onclick="showTab(this, 'events')">Metadata</button> | |
| </div> | |
| <div class="tabPanel active" data-tab="overview"> | |
| <div class="tabTitle">Overview</div> | |
| <div class="infoGrid"> | |
| <div class="infoCard"><div class="infoLabel">RustDesk ID</div><div class="infoValue mono"><a href="#" data-id="$rustDeskIdAttr" data-computer="$name" data-credtype="rustdesk" onclick="launchRustDesk(this); return false;">$rustDeskIdHtml</a></div></div> | |
| <div class="infoCard"><div class="infoLabel">Operating System</div><div class="infoValue">$os $osver</div></div> | |
| <div class="infoCard"><div class="infoLabel">Last user</div><div class="infoValue mono">$lastLoggedOnUser</div></div> | |
| <div class="infoCard"><div class="infoLabel">Last active</div><div class="infoValue">$lastSeen</div></div> | |
| <div class="infoCard"><div class="infoLabel">Agent status</div><div class="infoValue">$agentStatusText</div><div class="small">$agentStatusDetail</div></div> | |
| <div class="infoCard"><div class="infoLabel">Agent last updated</div><div class="infoValue">$agentLastUpdated</div></div> | |
| <div class="infoCard"><div class="infoLabel">Description</div><div class="infoValue">$desc</div></div> | |
| <div class="infoCard"><div class="infoLabel">Notes</div><div class="infoValue">$info</div></div> | |
| </div> | |
| </div> | |
| <div class="tabPanel" data-tab="system"> | |
| <div class="tabTitle">System</div> | |
| <div class="infoGrid"> | |
| <div class="infoCard"><div class="infoLabel">Manufacturer</div><div class="infoValue">$manufacturer</div></div> | |
| <div class="infoCard"><div class="infoLabel">Model</div><div class="infoValue">$model</div></div> | |
| <div class="infoCard"><div class="infoLabel">Operating System</div><div class="infoValue mono">$osname</div></div> | |
| <div class="infoCard"><div class="infoLabel">Serial</div><div class="infoValue mono">$serial</div></div> | |
| <div class="infoCard"><div class="infoLabel">Bios</div><div class="infoValue mono">$bios</div></div> | |
| <div class="infoCard"><div class="infoLabel">OS Build</div><div class="infoValue mono">$osbuild</div></div> | |
| <div class="infoCard"><div class="infoLabel">Uptime</div><div class="infoValue">$uptimeText</div></div> | |
| <div class="infoCard"><div class="infoLabel">Created</div><div class="infoValue">$created</div></div> | |
| <div class="infoCard"><div class="infoLabel">Pwd Last Set</div><div class="infoValue">$pwdLastSet</div></div> | |
| </div> | |
| </div> | |
| <div class="tabPanel" data-tab="network"> | |
| <div class="tabTitle">Network</div> | |
| <div class="infoGrid"> | |
| <div class="infoCard"><div class="infoLabel">DNS Hostname</div><div class="infoValue mono">$dns</div></div> | |
| <div class="infoCard"><div class="infoLabel">IPv4</div><div class="infoValue mono">$primaryIPv4</div></div> | |
| <div class="infoCard"><div class="infoLabel">IPv6</div><div class="infoValue mono">$primaryIPv6</div></div> | |
| <div class="infoCard"><div class="infoLabel">Wake-on-LAN</div><div class="infoValue">$macPills</div></div> | |
| <div class="infoCard"><div class="infoLabel">Location</div><div class="infoValue">$location</div></div> | |
| <div class="infoCard"><div class="infoLabel">OU</div><div class="infoValue">$ou</div></div> | |
| </div> | |
| </div> | |
| <div class="tabPanel" data-tab="laps"> | |
| <div class="tabTitle">LAPS</div> | |
| $lapsText | |
| </div> | |
| <div class="tabPanel" data-tab="sessions"> | |
| <div class="tabTitle">Sessions</div> | |
| <div class="infoGrid"> | |
| <div class="infoCard"><div class="infoLabel">Interactive Users</div><div class="infoValue">$interactiveUsers</div></div> | |
| <div class="infoCard"><div class="infoLabel">Managed By</div><div class="infoValue">$managedBy</div><div class="small mono">$managedByDn</div></div> | |
| <div class="infoCard"><div class="infoLabel">Publisher</div><div class="infoValue">$publisher</div></div> | |
| <div class="infoCard"><div class="infoLabel">Published At</div><div class="infoValue">$publishedAt</div></div> | |
| </div> | |
| </div> | |
| <div class="tabPanel" data-tab="commands"> | |
| <div class="tabTitle">Commands</div> | |
| <div class="commandBox"> | |
| <div class="commandHelp">Queue a literal Windows command for this device. The RustDeskADAgent will run it as the agent account during its next check-in.</div> | |
| <textarea class="commandInput" spellcheck="false" placeholder="Examples: shutdown /r /f /t 0 shutdown /s /f /t 0 shutdown /a"></textarea> | |
| <div class="commandActions"> | |
| <button type="button" class="modalBtn danger commandQueueBtn" onclick="prepareCustomCommand(this)">Queue command</button> | |
| <span class="commandResult"></span> | |
| </div> | |
| <div class="commandConfirm"> | |
| <div class="commandConfirmTitle">Confirm command queue</div> | |
| <div class="commandHelp">Queue this command for <span class="targetName commandConfirmComputer">$name</span>?</div> | |
| <code class="commandConfirmText"></code> | |
| <div class="rotateModalActions"> | |
| <button type="button" class="modalBtn" onclick="cancelCustomCommand(this)">Cancel</button> | |
| <button type="button" class="modalBtn danger" onclick="confirmCustomCommand(this)">Confirm queue</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tabPanel" data-tab="events"> | |
| <div class="tabTitle">Metadata</div> | |
| <div>$metaStatus</div> | |
| <details open><summary>Raw JSON</summary><pre>$rawMetaBlock</pre></details> | |
| <div class="infoCard"><div class="infoLabel">Last updated</div><div class="infoValue">$agentLastUpdated</div><div class="small">Agent interval: $agentIntervalSeconds sec</div></div> | |
| </div> | |
| </section> | |
| "@ | |
| } | |
| $html += ($sidebarItems -join "`n") | |
| $html += @" | |
| </div> | |
| <div class="sidebarFooter"><span>Total: <span id="visibleCount">$count</span>/<span id="totalCount">$count</span></span><button type="button" id="refreshBtn" class="refreshBtn" onclick="refreshPageFromListener()" title="Refresh" aria-label="Refresh"><span class="refreshIcon">⟳</span></button></div> | |
| <button type="button" id="pwaInstallBtn" class="pwaInstallBtn" title="Install RustDeskADClient as a desktop app">⬇ Install desktop app</button> | |
| </aside> | |
| <main class="workspace"> | |
| <div id="machineTable"> | |
| "@ | |
| $html += ($machineViews -join "`n") | |
| $html += @" | |
| </div> | |
| <div id="empty" class="empty">$emptyMessageHtml</div> | |
| </main> | |
| </div> | |
| <div class="footer">RustDeskADClient • Powered by Active Directory • Assisted by ChatGPT 👻 (ShellGhost)</div> | |
| </div> | |
| <div id="toast" class="toast"></div> | |
| <div id="powerModalOverlay" class="rotateModalOverlay" role="dialog" aria-modal="true" aria-labelledby="powerModalTitle"> | |
| <div class="rotateModal"> | |
| <div class="rotateModalIcon">⏻</div> | |
| <h2 id="powerModalTitle">Power action?</h2> | |
| <p>Choose a power action for <span id="powerModalComputer" class="targetName"></span>. The agent will run it during the next metadata update.</p> | |
| <div class="rotateModalActions"> | |
| <button type="button" id="powerCancelBtn" class="modalBtn">Cancel</button> | |
| <button type="button" id="powerRebootBtn" class="modalBtn danger">Reboot</button> | |
| <button type="button" id="powerShutdownBtn" class="modalBtn danger">Shutdown</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="rotateModalOverlay" class="rotateModalOverlay" role="dialog" aria-modal="true" aria-labelledby="rotateModalTitle"> | |
| <div class="rotateModal"> | |
| <div class="rotateModalIcon">🔄</div> | |
| <h2 id="rotateModalTitle">Rotate RustDesk password?</h2> | |
| <p>This will clear <strong>rustDeskPass</strong> in Active Directory for <span id="rotateModalComputer" class="targetName"></span> and terminate any existing connections. The agent will rotate password during the next metadata update.</p> | |
| <div class="rotateModalActions"> | |
| <button type="button" id="rotateCancelBtn" class="modalBtn">Cancel</button> | |
| <button type="button" id="rotateConfirmBtn" class="modalBtn danger">Rotate password</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let deferredPwaInstallPrompt = null; | |
| let pwaServiceWorkerReady = false; | |
| let pwaLastStatus = "Checking desktop app install support."; | |
| let pwaInstallCheckCompleted = false; | |
| function isMobileClientBrowser() { | |
| return document.body && document.body.classList.contains("mobileBrowser"); | |
| } | |
| function isRunningStandalonePwa() { | |
| return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; | |
| } | |
| function getPwaBasePath() { | |
| const path = window.location.pathname || "/"; | |
| if (path.endsWith("/")) return path; | |
| const lastPart = path.split("/").pop() || ""; | |
| // IIS virtual directory/proxy case: /RustDesk should behave like /RustDesk/. | |
| // Otherwise relative manifest/service-worker URLs resolve to /manifest.webmanifest. | |
| if (lastPart.indexOf(".") < 0) return path + "/"; | |
| return path.replace(/[^/]*$/, ""); | |
| } | |
| function getPwaResourceUrl(fileName) { | |
| return new URL(getPwaBasePath() + fileName, window.location.origin).toString(); | |
| } | |
| function getPwaRelativeUrl(fileName) { | |
| return String(fileName || "").replace(/^\/+/, ""); | |
| } | |
| function ensurePwaManifestLink() { | |
| if (isMobileClientBrowser()) return; | |
| let link = document.getElementById("pwaManifestLink"); | |
| if (!link) { | |
| link = document.createElement("link"); | |
| link.id = "pwaManifestLink"; | |
| link.rel = "manifest"; | |
| link.crossOrigin = "use-credentials"; | |
| document.head.appendChild(link); | |
| } | |
| // Use a relative manifest URL after the early head path-normalizer has made | |
| // /RustDesk behave like /RustDesk/. This gives Chrome a stable manifest during | |
| // initial parsing instead of discovering it late after DOMContentLoaded. | |
| link.href = getPwaRelativeUrl("manifest.webmanifest"); | |
| link.crossOrigin = "use-credentials"; | |
| console.debug("RustDeskADClient PWA manifest:", new URL(link.href, window.location.href).toString(), "crossOrigin:", link.crossOrigin); | |
| } | |
| function updatePwaInstallButton() { | |
| const btn = document.getElementById("pwaInstallBtn"); | |
| if (!btn) return; | |
| btn.classList.remove("show", "pending"); | |
| if (isMobileClientBrowser() || isRunningStandalonePwa()) { | |
| return; | |
| } | |
| // Show the button on desktop so install state is not silent. It will prompt once | |
| // Chrome/Edge fires beforeinstallprompt; before that it gives a useful status toast. | |
| btn.classList.add("show"); | |
| if (deferredPwaInstallPrompt) { | |
| btn.title = "Install RustDeskADClient as a desktop app"; | |
| btn.textContent = "⬇ Install desktop app"; | |
| return; | |
| } | |
| btn.classList.add("pending"); | |
| btn.title = pwaLastStatus || "Waiting for browser install prompt"; | |
| btn.textContent = "⬇ Desktop app"; | |
| } | |
| async function installPwaFromButton() { | |
| if (isMobileClientBrowser()) return; | |
| if (!deferredPwaInstallPrompt) { | |
| showToast(pwaLastStatus || "Chrome has not exposed the install prompt yet. Use Chrome menu > Cast, save and share > Install page as app, or reload once after the service worker is ready.", false); | |
| return; | |
| } | |
| const promptEvent = deferredPwaInstallPrompt; | |
| deferredPwaInstallPrompt = null; | |
| updatePwaInstallButton(); | |
| promptEvent.prompt(); | |
| try { await promptEvent.userChoice; } catch(e) {} | |
| } | |
| async function registerPwaServiceWorker() { | |
| if (isMobileClientBrowser()) return; | |
| ensurePwaManifestLink(); | |
| if (!("serviceWorker" in navigator)) { | |
| pwaLastStatus = "Desktop app install unavailable: service workers are not supported by this browser."; | |
| console.debug("RustDeskADClient PWA unavailable: serviceWorker is not supported by this browser."); | |
| updatePwaInstallButton(); | |
| return; | |
| } | |
| if (!window.isSecureContext) { | |
| pwaLastStatus = "Desktop app install unavailable: this page is not a secure context. Use HTTPS or localhost."; | |
| console.debug("RustDeskADClient PWA unavailable: page is not a secure context. Use HTTPS or localhost for browser install prompts."); | |
| updatePwaInstallButton(); | |
| return; | |
| } | |
| try { | |
| const swUrl = getPwaRelativeUrl("service-worker.js"); | |
| const swScope = "./"; | |
| const registration = await navigator.serviceWorker.register(swUrl, { scope: swScope }); | |
| try { await navigator.serviceWorker.ready; } catch(e) {} | |
| pwaServiceWorkerReady = true; | |
| pwaInstallCheckCompleted = true; | |
| pwaLastStatus = "Desktop app support is ready. If Chrome still does not offer install, reload once or use Chrome menu > Cast, save and share > Install page as app."; | |
| console.debug("RustDeskADClient PWA service worker:", new URL(swUrl, window.location.href).toString(), "scope:", registration.scope); | |
| } | |
| catch (err) { | |
| pwaLastStatus = "Desktop app service worker registration failed. Check /service-worker.js through the IIS proxy."; | |
| console.debug("RustDeskADClient service worker registration failed", err); | |
| } | |
| updatePwaInstallButton(); | |
| } | |
| window.addEventListener("beforeinstallprompt", (event) => { | |
| if (isMobileClientBrowser()) return; | |
| event.preventDefault(); | |
| deferredPwaInstallPrompt = event; | |
| updatePwaInstallButton(); | |
| }); | |
| function rememberPwaInstalledToast(message) { | |
| try { | |
| sessionStorage.setItem("rustdeskadPwaInstalledToastShown", "1"); | |
| } catch(e) {} | |
| showToast(message || "RustDeskADClient desktop app installed.", true); | |
| } | |
| function checkStandalonePwaToast() { | |
| if (isMobileClientBrowser()) return; | |
| if (!isRunningStandalonePwa()) return; | |
| let alreadyShown = false; | |
| try { alreadyShown = sessionStorage.getItem("rustdeskadPwaInstalledToastShown") === "1"; } catch(e) {} | |
| if (!alreadyShown) { | |
| rememberPwaInstalledToast("RustDeskADClient is running as a desktop app."); | |
| } | |
| } | |
| window.addEventListener("appinstalled", () => { | |
| deferredPwaInstallPrompt = null; | |
| try { localStorage.setItem("rustdeskadPwaInstalled", "1"); } catch(e) {} | |
| updatePwaInstallButton(); | |
| rememberPwaInstalledToast("RustDeskADClient desktop app installed."); | |
| }); | |
| window.addEventListener("pageshow", () => { | |
| window.setTimeout(checkStandalonePwaToast, 350); | |
| }); | |
| function apiUrl(path) { | |
| const clean = String(path || "").replace(/^\/+/, ""); | |
| if (!clean) return window.location.pathname + window.location.search; | |
| // Keep calls inside the current IIS virtual directory/reverse-proxy path. | |
| // Absolute /ad-status breaks when hosted under /RustDesk because IIS may return | |
| // the site HTML/login page, which then explodes as "Unexpected token '<'" JSON. | |
| const pathname = window.location.pathname || "/"; | |
| let basePath; | |
| if (pathname.endsWith("/")) { | |
| basePath = pathname; | |
| } | |
| else { | |
| const lastSegment = pathname.split("/").pop() || ""; | |
| basePath = lastSegment.indexOf(".") > -1 | |
| ? pathname.replace(/[^/]*$/, "") | |
| : pathname + "/"; | |
| } | |
| return basePath + clean; | |
| } | |
| async function fetchJsonSafe(url, options) { | |
| const response = await fetch(url, options || {}); | |
| const contentType = (response.headers.get("content-type") || "").toLowerCase(); | |
| const text = await response.text(); | |
| if (!response.ok) { | |
| throw new Error("HTTP " + response.status); | |
| } | |
| if (contentType.indexOf("application/json") === -1 && text.trim().charAt(0) === "<") { | |
| throw new Error("Endpoint returned HTML instead of JSON"); | |
| } | |
| try { | |
| return JSON.parse(text); | |
| } | |
| catch (err) { | |
| throw new Error("Invalid JSON from endpoint"); | |
| } | |
| } | |
| function togglePanel(button) { | |
| const card = button ? button.closest(".machineCard") : null; | |
| if (!card) return; | |
| card.classList.toggle("open"); | |
| button.textContent = card.classList.contains("open") ? "Hide details🔺" : "Details🔻"; | |
| } | |
| function showToast(message, ok) { | |
| const toast = document.getElementById("toast"); | |
| toast.textContent = message; | |
| toast.className = "toast " + (ok ? "good" : "bad"); | |
| toast.style.display = "block"; | |
| window.clearTimeout(window.__toastTimer); | |
| window.__toastTimer = window.setTimeout(() => { | |
| toast.style.display = "none"; | |
| }, 4200); | |
| } | |
| // Interceptor function to securely fetch JIT credentials | |
| async function fetchPass(button) { | |
| if (!button) return ""; | |
| const comp = button.getAttribute("data-computer"); | |
| const type = button.getAttribute("data-credtype"); | |
| if (!comp || !type) return ""; | |
| try { | |
| const data = await fetchJsonSafe(apiUrl("api/credentials?computer=" + encodeURIComponent(comp) + "&type=" + encodeURIComponent(type))); | |
| if (data.ok) { | |
| return data.password || ""; | |
| } | |
| } catch (e) { | |
| console.error("Failed to fetch password:", e); | |
| } | |
| return ""; | |
| } | |
| async function postAgentCommand(comp, commandLine) { | |
| const body = new URLSearchParams(); | |
| body.set("computer", comp || ""); | |
| body.set("commandLine", commandLine || ""); | |
| return await fetchJsonSafe(apiUrl("api/agent-command"), { | |
| method: "POST", | |
| headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" }, | |
| body: body.toString() | |
| }); | |
| } | |
| function getMachineViewFromButton(button) { | |
| return button ? button.closest(".machineView") : null; | |
| } | |
| function getCommandDraftStorageKey(comp) { | |
| return "RustDeskADClientCommandDraft::" + (comp || ""); | |
| } | |
| function persistCommandDraftForInput(input) { | |
| if (!input) return; | |
| const view = input.closest(".machineView"); | |
| const comp = view ? (view.getAttribute("data-key") || "") : ""; | |
| if (!comp) return; | |
| try { | |
| const key = getCommandDraftStorageKey(comp); | |
| const value = input.value || ""; | |
| if (value) localStorage.setItem(key, value); | |
| else localStorage.removeItem(key); | |
| } catch(e) {} | |
| } | |
| function restoreCommandDraftsFromStorage(root) { | |
| const scope = root || document; | |
| scope.querySelectorAll(".machineView").forEach((view) => { | |
| const comp = view.getAttribute("data-key") || ""; | |
| const input = view.querySelector(".commandInput"); | |
| if (!comp || !input) return; | |
| try { | |
| const saved = localStorage.getItem(getCommandDraftStorageKey(comp)); | |
| if (saved !== null) input.value = saved; | |
| } catch(e) {} | |
| }); | |
| } | |
| function clearCommandDraftForComputer(comp) { | |
| if (!comp) return; | |
| try { localStorage.removeItem(getCommandDraftStorageKey(comp)); } catch(e) {} | |
| const input = document.querySelector('.machineView[data-key="' + cssEscapeValue(comp) + '"] .commandInput'); | |
| if (input) input.value = ""; | |
| } | |
| function getCommandElements(button) { | |
| const view = getMachineViewFromButton(button); | |
| if (!view) return {}; | |
| return { | |
| view: view, | |
| input: view.querySelector(".commandInput"), | |
| confirm: view.querySelector(".commandConfirm"), | |
| confirmText: view.querySelector(".commandConfirmText"), | |
| result: view.querySelector(".commandResult"), | |
| queueBtn: view.querySelector(".commandQueueBtn") | |
| }; | |
| } | |
| function prepareCustomCommand(button) { | |
| const els = getCommandElements(button); | |
| if (!els.view || !els.input || !els.confirm) { | |
| showToast("Command tab is missing required UI elements.", false); | |
| return; | |
| } | |
| const comp = els.view.getAttribute("data-key") || ""; | |
| const commandLine = (els.input.value || "").trim(); | |
| if (!comp) { | |
| showToast("No computer name found for command queue.", false); | |
| return; | |
| } | |
| if (!commandLine) { | |
| showToast("Command line is blank.", false); | |
| els.input.focus(); | |
| return; | |
| } | |
| els.confirm.__pendingComputer = comp; | |
| els.confirm.__pendingCommandLine = commandLine; | |
| els.confirm.setAttribute("data-computer", comp); | |
| const compText = els.confirm.querySelector(".commandConfirmComputer"); | |
| if (compText) compText.textContent = comp; | |
| if (els.confirmText) els.confirmText.textContent = commandLine; | |
| if (els.result) els.result.textContent = ""; | |
| els.confirm.classList.add("show"); | |
| } | |
| function cancelCustomCommand(button) { | |
| const els = getCommandElements(button); | |
| if (els.confirm) { | |
| els.confirm.classList.remove("show"); | |
| els.confirm.__pendingComputer = ""; | |
| els.confirm.__pendingCommandLine = ""; | |
| } | |
| } | |
| async function confirmCustomCommand(button) { | |
| const els = getCommandElements(button); | |
| if (!els.confirm) return; | |
| const comp = els.confirm.__pendingComputer || els.confirm.getAttribute("data-computer") || ""; | |
| const commandLine = els.confirm.__pendingCommandLine || ""; | |
| if (!comp || !commandLine) { | |
| showToast("No pending command found.", false); | |
| return; | |
| } | |
| const originalText = button ? button.textContent : ""; | |
| if (button) { | |
| button.disabled = true; | |
| button.textContent = "Queueing..."; | |
| } | |
| if (els.queueBtn) els.queueBtn.disabled = true; | |
| if (els.result) els.result.textContent = "Queueing command..."; | |
| try { | |
| const data = await postAgentCommand(comp, commandLine); | |
| if (!data.ok) { | |
| throw new Error(data.error || "Command queue failed"); | |
| } | |
| els.confirm.classList.remove("show"); | |
| els.confirm.__pendingComputer = ""; | |
| els.confirm.__pendingCommandLine = ""; | |
| clearCommandDraftForComputer(comp); | |
| if (els.result) els.result.textContent = "Command queued. Waiting for agent check-in."; | |
| showToast("Command queued for " + comp + ".", true); | |
| window.setTimeout(refreshPageFromListener, 1200); | |
| } | |
| catch (err) { | |
| if (els.result) els.result.textContent = "Queue failed: " + err.message; | |
| showToast("Command queue failed: " + err.message, false); | |
| } | |
| finally { | |
| if (button) { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| }, 1200); | |
| } | |
| if (els.queueBtn) { | |
| window.setTimeout(() => { els.queueBtn.disabled = false; }, 1200); | |
| } | |
| } | |
| } | |
| let __pendingPowerButton = null; | |
| function openPowerModal(button) { | |
| const comp = button ? (button.getAttribute("data-computer") || "") : ""; | |
| if (!comp) { | |
| showToast("No computer name found for power action.", false); | |
| return; | |
| } | |
| __pendingPowerButton = button; | |
| const overlay = document.getElementById("powerModalOverlay"); | |
| const target = document.getElementById("powerModalComputer"); | |
| const rebootBtn = document.getElementById("powerRebootBtn"); | |
| const shutdownBtn = document.getElementById("powerShutdownBtn"); | |
| if (target) target.textContent = comp; | |
| if (rebootBtn) { rebootBtn.disabled = false; rebootBtn.textContent = "Reboot"; } | |
| if (shutdownBtn) { shutdownBtn.disabled = false; shutdownBtn.textContent = "Shutdown"; } | |
| if (overlay) overlay.classList.add("show"); | |
| } | |
| function closePowerModal() { | |
| const overlay = document.getElementById("powerModalOverlay"); | |
| if (overlay) overlay.classList.remove("show"); | |
| __pendingPowerButton = null; | |
| } | |
| async function queuePowerAction(action) { | |
| const button = __pendingPowerButton; | |
| if (!button) return; | |
| const comp = button.getAttribute("data-computer") || ""; | |
| if (!comp) { | |
| closePowerModal(); | |
| showToast("No computer name found for power action.", false); | |
| return; | |
| } | |
| const isShutdown = action === "shutdown"; | |
| const pretty = isShutdown ? "Shutdown" : "Reboot"; | |
| const commandLine = isShutdown ? "shutdown /s /f /t 0" : "shutdown /r /f /t 0"; | |
| const rebootBtn = document.getElementById("powerRebootBtn"); | |
| const shutdownBtn = document.getElementById("powerShutdownBtn"); | |
| const originalText = button.textContent; | |
| button.disabled = true; | |
| button.textContent = "Queueing..."; | |
| if (rebootBtn) rebootBtn.disabled = true; | |
| if (shutdownBtn) shutdownBtn.disabled = true; | |
| try { | |
| const data = await postAgentCommand(comp, commandLine); | |
| if (!data.ok) { | |
| throw new Error(data.error || "Command queue failed"); | |
| } | |
| closePowerModal(); | |
| showToast(pretty + " queued for " + comp + ". Waiting for agent check-in.", true); | |
| window.setTimeout(refreshPageFromListener, 1200); | |
| } | |
| catch (err) { | |
| showToast(pretty + " queue failed: " + err.message, false); | |
| if (rebootBtn) rebootBtn.disabled = false; | |
| if (shutdownBtn) shutdownBtn.disabled = false; | |
| } | |
| finally { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| }, 1200); | |
| } | |
| } | |
| let __pendingRotateButton = null; | |
| function openRotateModal(button) { | |
| const comp = button ? (button.getAttribute("data-computer") || "") : ""; | |
| if (!comp) { | |
| showToast("No computer name found for password rotation.", false); | |
| return; | |
| } | |
| __pendingRotateButton = button; | |
| const overlay = document.getElementById("rotateModalOverlay"); | |
| const target = document.getElementById("rotateModalComputer"); | |
| const confirmBtn = document.getElementById("rotateConfirmBtn"); | |
| if (target) target.textContent = comp; | |
| if (confirmBtn) { | |
| confirmBtn.disabled = false; | |
| confirmBtn.textContent = "Rotate password"; | |
| } | |
| if (overlay) overlay.classList.add("show"); | |
| } | |
| function closeRotateModal() { | |
| const overlay = document.getElementById("rotateModalOverlay"); | |
| if (overlay) overlay.classList.remove("show"); | |
| __pendingRotateButton = null; | |
| } | |
| async function confirmRotateRustDeskPassword() { | |
| const button = __pendingRotateButton; | |
| if (!button) return; | |
| const comp = button.getAttribute("data-computer") || ""; | |
| if (!comp) { | |
| closeRotateModal(); | |
| showToast("No computer name found for password rotation.", false); | |
| return; | |
| } | |
| const confirmBtn = document.getElementById("rotateConfirmBtn"); | |
| const originalText = button.textContent; | |
| button.disabled = true; | |
| button.textContent = "Rotating..."; | |
| if (confirmBtn) { | |
| confirmBtn.disabled = true; | |
| confirmBtn.textContent = "Rotating..."; | |
| } | |
| try { | |
| const data = await fetchJsonSafe(apiUrl("api/rotate-rustdesk-password?computer=" + encodeURIComponent(comp)), { method: "POST" }); | |
| if (!data.ok) { | |
| throw new Error(data.error || "Rotation request failed"); | |
| } | |
| closeRotateModal(); | |
| showToast("rustDeskPass cleared for " + comp + ". Waiting for agent to publish a new password.", true); | |
| window.setTimeout(refreshPageFromListener, 1200); | |
| } | |
| catch (err) { | |
| showToast("Password rotation failed: " + err.message, false); | |
| if (confirmBtn) { | |
| confirmBtn.disabled = false; | |
| confirmBtn.textContent = "Rotate password"; | |
| } | |
| } | |
| finally { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| }, 1200); | |
| } | |
| } | |
| function rotateRustDeskPassword(button) { | |
| openRotateModal(button); | |
| } | |
| document.addEventListener("DOMContentLoaded", () => { | |
| const overlay = document.getElementById("rotateModalOverlay"); | |
| const cancelBtn = document.getElementById("rotateCancelBtn"); | |
| const confirmBtn = document.getElementById("rotateConfirmBtn"); | |
| const powerOverlay = document.getElementById("powerModalOverlay"); | |
| const powerCancelBtn = document.getElementById("powerCancelBtn"); | |
| const powerRebootBtn = document.getElementById("powerRebootBtn"); | |
| const powerShutdownBtn = document.getElementById("powerShutdownBtn"); | |
| const pwaInstallBtn = document.getElementById("pwaInstallBtn"); | |
| if (pwaInstallBtn) pwaInstallBtn.addEventListener("click", installPwaFromButton); | |
| registerPwaServiceWorker(); | |
| updatePwaInstallButton(); | |
| window.setTimeout(checkStandalonePwaToast, 600); | |
| if (cancelBtn) cancelBtn.addEventListener("click", closeRotateModal); | |
| if (confirmBtn) confirmBtn.addEventListener("click", confirmRotateRustDeskPassword); | |
| if (powerCancelBtn) powerCancelBtn.addEventListener("click", closePowerModal); | |
| if (powerRebootBtn) powerRebootBtn.addEventListener("click", () => queuePowerAction("reboot")); | |
| if (powerShutdownBtn) powerShutdownBtn.addEventListener("click", () => queuePowerAction("shutdown")); | |
| if (overlay) { | |
| overlay.addEventListener("click", (event) => { | |
| if (event.target === overlay) closeRotateModal(); | |
| }); | |
| } | |
| if (powerOverlay) { | |
| powerOverlay.addEventListener("click", (event) => { | |
| if (event.target === powerOverlay) closePowerModal(); | |
| }); | |
| } | |
| document.addEventListener("keydown", (event) => { | |
| if (event.key === "Escape") { | |
| closeRotateModal(); | |
| closePowerModal(); | |
| } | |
| }); | |
| document.addEventListener("input", (event) => { | |
| if (event.target && event.target.classList && event.target.classList.contains("commandInput")) { | |
| persistCommandDraftForInput(event.target); | |
| } | |
| }); | |
| }); | |
| async function copyPass(button) { | |
| const originalText = button ? button.textContent : ""; | |
| if (button) { | |
| button.disabled = true; | |
| button.textContent = "Fetching..."; | |
| } | |
| const pass = await fetchPass(button); | |
| if (!pass) { | |
| showToast("No password to copy.", false); | |
| if (button) { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| } | |
| return; | |
| } | |
| if (navigator.clipboard && window.isSecureContext) { | |
| try { | |
| await navigator.clipboard.writeText(pass); | |
| showToast("Password copied.", true); | |
| } | |
| catch (err) { | |
| // Fall through to the compatibility copy method below. | |
| } | |
| } | |
| else { | |
| const textArea = document.createElement("textarea"); | |
| textArea.value = pass; | |
| textArea.setAttribute("readonly", ""); | |
| textArea.style.position = "fixed"; | |
| textArea.style.left = "-9999px"; | |
| textArea.style.top = "0"; | |
| textArea.style.opacity = "0"; | |
| document.body.appendChild(textArea); | |
| try { | |
| textArea.focus(); | |
| textArea.select(); | |
| textArea.setSelectionRange(0, textArea.value.length); | |
| if (document.execCommand("copy")) { | |
| showToast("Password copied.", true); | |
| } | |
| else { | |
| throw new Error("Browser refused clipboard access"); | |
| } | |
| } | |
| catch (err) { | |
| showToast("Copy failed. Check browser security settings.", false); | |
| } | |
| finally { | |
| document.body.removeChild(textArea); | |
| } | |
| } | |
| if (button) { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| }, 1200); | |
| } | |
| } | |
| function shouldUseHostRustDeskExe() { | |
| const forceExeLaunch = $($UriEXELaunch.IsPresent.ToString().ToLowerInvariant()); | |
| if (!forceExeLaunch) { | |
| return false; | |
| } | |
| const host = (window.location.hostname || "").toLowerCase(); | |
| const port = window.location.port || "80"; | |
| return window.location.protocol === "http:" && | |
| (host === "127.0.0.1" || host === "localhost" || host === "[::1]") && | |
| port === "$Port"; | |
| } | |
| function encodeRustDeskUriPassword(pass) { | |
| // RustDesk's URI handler is picky with passwords. Full encodeURIComponent() | |
| // can make some builds treat the encoded text as the literal password. | |
| // Escape the query breakers, plus signs, and control chars that most often | |
| // get normalized before RustDesk sees the password. | |
| return String(pass || "") | |
| .replace(/%/g, "%25") | |
| .replace(/\r/g, "%0D") | |
| .replace(/\n/g, "%0A") | |
| .replace(/\?/g, "%3F") | |
| .replace(/#/g, "%23") | |
| .replace(/&/g, "%26") | |
| .replace(/\+/g, "%2B") | |
| .replace(/;/g, "%3B"); | |
| } | |
| function buildRustDeskUri(id, pass, mode) { | |
| let uri = mode === "terminal" | |
| ? ("rustdesk://terminal/" + encodeURIComponent(id)) | |
| : (mode === "rdp" ? ("rustdesk://rdp/" + encodeURIComponent(id)) : ("rustdesk://" + encodeURIComponent(id))); | |
| if (pass) { | |
| uri += "?password=" + encodeRustDeskUriPassword(pass); | |
| } | |
| return uri; | |
| } | |
| function hasRustDeskCredentialTarget(button) { | |
| return !!(button && button.getAttribute("data-computer") && button.getAttribute("data-credtype") === "rustdesk"); | |
| } | |
| async function launchRustDesk(button) { | |
| const id = button ? (button.getAttribute("data-id") || "") : ""; | |
| const originalText = button ? button.textContent : ""; | |
| if (!id) { | |
| showToast("No RustDesk ID found.", false); | |
| return; | |
| } | |
| if (button) { | |
| button.disabled = true; | |
| button.textContent = "Opening..."; | |
| } | |
| const pass = await fetchPass(button); | |
| if (hasRustDeskCredentialTarget(button) && !pass) { | |
| showToast("RustDesk password could not be fetched. Launch blocked so it does not open passwordless.", false); | |
| if (button) { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| } | |
| return; | |
| } | |
| if (!shouldUseHostRustDeskExe()) { | |
| window.location.href = buildRustDeskUri(id, pass, "control"); | |
| showToast("Opening RustDesk URI for " + id + " with fetched password.", true); | |
| if (button) { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| }, 1200); | |
| } | |
| return; | |
| } | |
| try { | |
| const data = await fetchJsonSafe(apiUrl("connect?id=" + encodeURIComponent(id) + "&password=" + encodeURIComponent(pass)), { method: "POST" }); | |
| if (!data.ok) { | |
| throw new Error(data.error || "Launch failed"); | |
| } | |
| showToast("RustDesk launched for " + id + ".", true); | |
| } | |
| catch (err) { | |
| showToast("RustDesk launch failed: " + err.message, false); | |
| } | |
| finally { | |
| if (button) { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| }, 1200); | |
| } | |
| } | |
| } | |
| async function launchRustDeskTerminal(button) { | |
| const id = button ? (button.getAttribute("data-id") || "") : ""; | |
| const originalText = button ? button.textContent : ""; | |
| if (!id) { | |
| showToast("No RustDesk ID found.", false); | |
| return; | |
| } | |
| if (button) { | |
| button.disabled = true; | |
| button.textContent = "Opening..."; | |
| } | |
| const pass = await fetchPass(button); | |
| if (hasRustDeskCredentialTarget(button) && !pass) { | |
| showToast("RustDesk password could not be fetched. Launch blocked so it does not open passwordless.", false); | |
| if (button) { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| } | |
| return; | |
| } | |
| if (!shouldUseHostRustDeskExe()) { | |
| window.location.href = buildRustDeskUri(id, pass, "terminal"); | |
| showToast("Opening RustDesk terminal URI for " + id + " with fetched password.", true); | |
| if (button) { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| }, 1200); | |
| } | |
| return; | |
| } | |
| try { | |
| const data = await fetchJsonSafe(apiUrl("terminal?id=" + encodeURIComponent(id) + "&password=" + encodeURIComponent(pass)), { method: "POST" }); | |
| if (!data.ok) { | |
| throw new Error(data.error || "Launch failed"); | |
| } | |
| showToast("RustDesk terminal launched for " + id + ".", true); | |
| } | |
| catch (err) { | |
| showToast("RustDesk terminal launch failed: " + err.message, false); | |
| } | |
| finally { | |
| if (button) { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| }, 1200); | |
| } | |
| } | |
| } | |
| async function launchRustDeskRdp(button) { | |
| const id = button ? (button.getAttribute("data-id") || "") : ""; | |
| const originalText = button ? button.textContent : ""; | |
| if (!id) { | |
| showToast("No RustDesk ID found.", false); | |
| return; | |
| } | |
| if (button) { | |
| button.disabled = true; | |
| button.textContent = "Opening..."; | |
| } | |
| const pass = await fetchPass(button); | |
| if (hasRustDeskCredentialTarget(button) && !pass) { | |
| showToast("RustDesk password could not be fetched. Launch blocked so it does not open passwordless.", false); | |
| if (button) { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| } | |
| return; | |
| } | |
| if (!shouldUseHostRustDeskExe()) { | |
| window.location.href = buildRustDeskUri(id, pass, "rdp"); | |
| showToast("Opening RustDesk RDP URI for " + id + " with fetched password.", true); | |
| if (button) { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| }, 1200); | |
| } | |
| return; | |
| } | |
| try { | |
| const data = await fetchJsonSafe(apiUrl("rdp?id=" + encodeURIComponent(id) + "&password=" + encodeURIComponent(pass)), { method: "POST" }); | |
| if (!data.ok) { | |
| throw new Error(data.error || "Launch failed"); | |
| } | |
| showToast("RustDesk RDP launched for " + id + ".", true); | |
| } | |
| catch (err) { | |
| showToast("RustDesk RDP launch failed: " + err.message, false); | |
| } | |
| finally { | |
| if (button) { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| }, 1200); | |
| } | |
| } | |
| } | |
| async function sendWol(mac, button) { | |
| const originalText = button ? button.textContent : ""; | |
| if (button) { | |
| button.classList.remove("sent", "fail"); | |
| button.classList.add("sending"); | |
| button.textContent = "⚡ sending..."; | |
| button.disabled = true; | |
| } | |
| try { | |
| const data = await fetchJsonSafe(apiUrl("wol?mac=" + encodeURIComponent(mac)), { method: "POST" }); | |
| if (!data.ok) { | |
| throw new Error(data.error || "Wake-on-LAN failed"); | |
| } | |
| if (button) { | |
| button.classList.remove("sending"); | |
| button.classList.add("sent"); | |
| button.textContent = "✓ " + data.mac; | |
| } | |
| showToast("Wake-on-LAN sent to " + data.mac + " via " + data.broadcast + ":" + data.port, true); | |
| } | |
| catch (err) { | |
| if (button) { | |
| button.classList.remove("sending"); | |
| button.classList.add("fail"); | |
| button.textContent = "✕ " + mac; | |
| } | |
| showToast("WOL failed for " + mac + ": " + err.message, false); | |
| } | |
| finally { | |
| if (button) { | |
| window.setTimeout(() => { | |
| button.disabled = false; | |
| button.classList.remove("sending", "sent", "fail"); | |
| button.textContent = originalText; | |
| }, 2500); | |
| } | |
| } | |
| } | |
| function cssEscapeValue(value) { | |
| if (window.CSS && CSS.escape) return CSS.escape(value); | |
| return String(value).replace(/"/g, '\\"'); | |
| } | |
| function selectMachine(key) { | |
| const escaped = cssEscapeValue(key); | |
| document.querySelectorAll(".sideComputer").forEach((node) => node.classList.remove("selected")); | |
| document.querySelectorAll(".machineView").forEach((node) => { | |
| node.classList.remove("selected"); | |
| node.style.display = "none"; | |
| }); | |
| const side = document.querySelector('.sideComputer[data-key="' + escaped + '"]'); | |
| const view = document.querySelector('.machineView[data-key="' + escaped + '"]'); | |
| if (side) side.classList.add("selected"); | |
| if (view) { | |
| view.classList.add("selected"); | |
| view.style.display = "block"; | |
| } | |
| } | |
| function showTab(button, tabName) { | |
| const view = button ? button.closest(".machineView") : null; | |
| if (!view) return; | |
| view.querySelectorAll(".tabBtn").forEach((btn) => btn.classList.remove("active")); | |
| view.querySelectorAll(".tabPanel").forEach((panel) => panel.classList.remove("active")); | |
| button.classList.add("active"); | |
| const panel = view.querySelector('.tabPanel[data-tab="' + tabName + '"]'); | |
| if (panel) panel.classList.add("active"); | |
| } | |
| function filterTable() { | |
| let input = document.getElementById("search"); | |
| let filter = (input.value || "").toLowerCase(); | |
| let rows = document.getElementsByClassName("dataRow"); | |
| let visible = 0; | |
| let firstVisibleKey = ""; | |
| for (let i = 0; i < rows.length; i++) { | |
| let text = rows[i].getAttribute("data-search") || rows[i].textContent || rows[i].innerText; | |
| let key = rows[i].getAttribute("data-key") || ""; | |
| let match = text.toLowerCase().indexOf(filter) > -1; | |
| rows[i].style.display = match ? "" : "none"; | |
| const view = document.querySelector('.machineView[data-key="' + cssEscapeValue(key) + '"]'); | |
| if (view && !view.classList.contains("selected")) view.style.display = "none"; | |
| if (match) { | |
| visible++; | |
| if (!firstVisibleKey) firstVisibleKey = key; | |
| } | |
| } | |
| const selected = document.querySelector(".sideComputer.selected"); | |
| const selectedVisible = selected && selected.style.display !== "none"; | |
| if (!selectedVisible && firstVisibleKey) selectMachine(firstVisibleKey); | |
| const selectedView = document.querySelector(".machineView.selected"); | |
| if (selectedView) selectedView.style.display = visible === 0 ? "none" : "block"; | |
| document.getElementById("visibleCount").textContent = visible; | |
| document.getElementById("empty").style.display = visible === 0 ? "block" : "none"; | |
| } | |
| let __refreshing = false; | |
| let __lastSearch = ""; | |
| function rememberSearch() { | |
| const search = document.getElementById("search"); | |
| if (search) { | |
| __lastSearch = search.value || ""; | |
| try { localStorage.setItem("RustDeskADClientSearch", __lastSearch); } catch(e) {} | |
| } | |
| } | |
| function restoreSearch() { | |
| const search = document.getElementById("search"); | |
| if (search) { | |
| try { | |
| const saved = localStorage.getItem("RustDeskADClientSearch"); | |
| if (saved !== null) search.value = saved; | |
| } catch(e) {} | |
| filterTable(); | |
| } | |
| } | |
| function captureRefreshState() { | |
| const state = { | |
| search: "", | |
| scrollX: window.scrollX, | |
| scrollY: window.scrollY, | |
| selectedKey: "", | |
| selectedTab: "overview", | |
| openDetails: [], | |
| commandDrafts: {} | |
| }; | |
| const search = document.getElementById("search"); | |
| if (search) state.search = search.value || ""; | |
| const selected = document.querySelector(".machineView.selected"); | |
| if (selected) { | |
| state.selectedKey = selected.getAttribute("data-key") || ""; | |
| const activePanel = selected.querySelector(".tabPanel.active"); | |
| if (activePanel) { | |
| state.selectedTab = activePanel.getAttribute("data-tab") || "overview"; | |
| } else { | |
| const activeTab = selected.querySelector(".tabBtn.active"); | |
| if (activeTab) state.selectedTab = activeTab.getAttribute("data-tab-target") || activeTab.textContent.trim().toLowerCase(); | |
| } | |
| } | |
| document.querySelectorAll(".machineView").forEach((card) => { | |
| const key = card.getAttribute("data-key") || ""; | |
| if (!key) return; | |
| card.querySelectorAll("details").forEach((detailsNode, index) => { | |
| if (detailsNode.open) state.openDetails.push(key + "::" + index); | |
| }); | |
| const commandInput = card.querySelector(".commandInput"); | |
| if (commandInput) { | |
| const draft = commandInput.value || ""; | |
| if (draft) state.commandDrafts[key] = draft; | |
| try { | |
| const storageKey = getCommandDraftStorageKey(key); | |
| if (draft) localStorage.setItem(storageKey, draft); | |
| else localStorage.removeItem(storageKey); | |
| } catch(e) {} | |
| } | |
| }); | |
| return state; | |
| } | |
| function restoreRefreshState(state) { | |
| if (!state) return; | |
| const search = document.getElementById("search"); | |
| if (search) { | |
| search.value = state.search || ""; | |
| try { localStorage.setItem("RustDeskADClientSearch", search.value); } catch(e) {} | |
| } | |
| const openDetails = new Set(state.openDetails || []); | |
| if (state.selectedKey) selectMachine(state.selectedKey); | |
| document.querySelectorAll(".machineView").forEach((card) => { | |
| const key = card.getAttribute("data-key") || ""; | |
| card.querySelectorAll("details").forEach((detailsNode, index) => { | |
| detailsNode.open = key && openDetails.has(key + "::" + index); | |
| }); | |
| const commandInput = card.querySelector(".commandInput"); | |
| if (commandInput && key) { | |
| let draft = ""; | |
| if (state.commandDrafts && Object.prototype.hasOwnProperty.call(state.commandDrafts, key)) { | |
| draft = state.commandDrafts[key] || ""; | |
| } else { | |
| try { draft = localStorage.getItem(getCommandDraftStorageKey(key)) || ""; } catch(e) {} | |
| } | |
| commandInput.value = draft; | |
| } | |
| const tabName = (state.selectedTab || "overview").toLowerCase(); | |
| const tabButton = Array.from(card.querySelectorAll(".tabBtn")).find((btn) => { | |
| const target = btn.getAttribute("data-tab-target") || ""; | |
| const onclick = btn.getAttribute("onclick") || ""; | |
| const label = btn.textContent.trim().toLowerCase(); | |
| return target.toLowerCase() === tabName || onclick.toLowerCase().indexOf("'" + tabName + "'") > -1 || label === tabName; | |
| }); | |
| if (tabButton && card.classList.contains("selected")) showTab(tabButton, tabName); | |
| }); | |
| filterTable(); | |
| window.scrollTo(state.scrollX || 0, state.scrollY || 0); | |
| } | |
| async function refreshAdStatusOnly() { | |
| try { | |
| const data = await fetchJsonSafe(apiUrl("ad-status?t=" + Date.now()), { cache: "no-store" }); | |
| if (!data || !data.ok) throw new Error("Invalid AD status response"); | |
| const dot = document.getElementById("adStatusDot"); | |
| const label = document.getElementById("adStatusLabel"); | |
| const domain = document.getElementById("adDomainText"); | |
| const sidebarStatus = document.querySelector(".sidebarStatus"); | |
| if (dot) { | |
| dot.className = "dot" + (data.dotClass ? (" " + data.dotClass) : ""); | |
| } | |
| if (label) { | |
| label.textContent = data.statusText || (data.connected ? "Connected to Active Directory" : "Cannot contact Active Directory"); | |
| } | |
| if (domain) { | |
| domain.textContent = data.domainText || ""; | |
| domain.title = data.error || data.domainText || ""; | |
| } | |
| if (sidebarStatus) { | |
| sidebarStatus.setAttribute("data-ad-connected", data.connected ? "true" : "false"); | |
| } | |
| return !!data.connected; | |
| } | |
| catch (err) { | |
| const dot = document.getElementById("adStatusDot"); | |
| const label = document.getElementById("adStatusLabel"); | |
| const domain = document.getElementById("adDomainText"); | |
| if (dot) dot.className = "dot badDot"; | |
| if (label) label.textContent = "Cannot contact Active Directory"; | |
| if (domain) { | |
| // Preserve the domain text; put details in tooltip only so the UI stays clean. | |
| domain.title = err.message; | |
| } | |
| return false; | |
| } | |
| } | |
| async function refreshPageFromListener() { | |
| if (__refreshing) return; | |
| __refreshing = true; | |
| const refreshBtn = document.getElementById("refreshBtn"); | |
| if (refreshBtn) { | |
| refreshBtn.classList.add("refreshing"); | |
| refreshBtn.disabled = true; | |
| refreshBtn.setAttribute("aria-busy", "true"); | |
| refreshBtn.title = "Refreshing..."; | |
| } | |
| const state = captureRefreshState(); | |
| try { | |
| await refreshAdStatusOnly(); | |
| const response = await fetch(apiUrl("?partial=1&refresh=" + Date.now()), { cache: "no-store" }); | |
| if (!response.ok) throw new Error("HTTP " + response.status); | |
| const html = await response.text(); | |
| const doc = new DOMParser().parseFromString(html, "text/html"); | |
| const newStatus = doc.querySelector(".sidebarStatus"); | |
| const oldStatus = document.querySelector(".sidebarStatus"); | |
| if (newStatus && oldStatus) { | |
| oldStatus.innerHTML = newStatus.innerHTML; | |
| } | |
| const statusText = newStatus ? (newStatus.textContent || "") : ""; | |
| const adOffline = /Cannot contact Active Directory/i.test(statusText); | |
| if (adOffline) { | |
| // AD is unreachable, but the listener is alive. Keep the last-known-good | |
| // sidebar and selected machine workspace visible instead of replacing them | |
| // with the empty offline render. The status indicator above is still updated. | |
| showToast("Cannot contact Active Directory. Keeping last-known-good data visible.", false); | |
| return; | |
| } | |
| const newTotal = doc.getElementById("totalCount"); | |
| const oldTotal = document.getElementById("totalCount"); | |
| if (newTotal && oldTotal) { | |
| oldTotal.textContent = newTotal.textContent; | |
| } | |
| const newSidebar = doc.getElementById("sidebarList"); | |
| const oldSidebar = document.getElementById("sidebarList"); | |
| if (newSidebar && oldSidebar) oldSidebar.innerHTML = newSidebar.innerHTML; | |
| const newTable = doc.getElementById("machineTable"); | |
| const oldTable = document.getElementById("machineTable"); | |
| if (newTable && oldTable) { | |
| oldTable.innerHTML = newTable.innerHTML; | |
| restoreRefreshState(state); | |
| } | |
| const newEmpty = doc.getElementById("empty"); | |
| const oldEmpty = document.getElementById("empty"); | |
| if (newEmpty && oldEmpty) { | |
| oldEmpty.innerHTML = newEmpty.innerHTML; | |
| } | |
| } | |
| catch (err) { | |
| showToast("Refresh failed: " + err.message, false); | |
| } | |
| finally { | |
| __refreshing = false; | |
| if (refreshBtn) { | |
| refreshBtn.classList.remove("refreshing"); | |
| refreshBtn.disabled = false; | |
| refreshBtn.removeAttribute("aria-busy"); | |
| refreshBtn.title = "Refresh"; | |
| } | |
| } | |
| } | |
| let __heartbeatTimer = null; | |
| async function sendHeartbeat() { | |
| try { | |
| await fetch(apiUrl("heartbeat?t=" + Date.now()), { | |
| method: "POST", | |
| cache: "no-store", | |
| keepalive: true | |
| }); | |
| } | |
| catch (e) {} | |
| } | |
| function startHeartbeat() { | |
| sendHeartbeat(); | |
| if (__heartbeatTimer) window.clearInterval(__heartbeatTimer); | |
| __heartbeatTimer = window.setInterval(sendHeartbeat, $($HeartbeatIntervalSec * 1000)); | |
| } | |
| window.addEventListener("pagehide", () => { | |
| try { | |
| navigator.sendBeacon("/page-exit?t=" + Date.now(), "bye"); | |
| } catch(e) {} | |
| }); | |
| window.addEventListener("beforeunload", () => { | |
| try { | |
| navigator.sendBeacon("/page-exit?t=" + Date.now(), "bye"); | |
| } catch(e) {} | |
| }); | |
| window.addEventListener("load", () => { | |
| restoreSearch(); | |
| restoreCommandDraftsFromStorage(document); | |
| if (!$($Listen.IsPresent.ToString().ToLowerInvariant()) && !$($NoHeartBeat.IsPresent.ToString().ToLowerInvariant())) { | |
| startHeartbeat(); | |
| } | |
| refreshAdStatusOnly(); | |
| window.setInterval(refreshAdStatusOnly, 5000); | |
| window.setInterval(refreshPageFromListener, 30000); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| "@ | |
| return $html | |
| } | |
| $listener = New-Object System.Net.HttpListener | |
| $listener.Prefixes.Add($prefix) | |
| Write-RDMarker "Listener start" $prefix | |
| try { | |
| $listener.Start() | |
| } | |
| catch { | |
| Write-RDLog "Failed to start web listener on $prefix" -ForegroundColor Red | |
| Write-RDLog $_.Exception.Message -ForegroundColor Red | |
| Write-RDLog "" | |
| if (Test-ExistingRustDeskListener -Url $localUrl) { | |
| Write-RDLog "Looks like another RustDeskADClient instance already owns that port." -ForegroundColor Yellow | |
| if ((-not $NoOpen) -and ((-not $Listen) -or $PWA)) { | |
| Write-RDLog "Opening the existing web UI instead." -ForegroundColor Cyan | |
| } | |
| else { | |
| Write-RDLog "Existing web UI detected; not opening PWA/browser because -Listen was used without -PWA." -ForegroundColor Cyan | |
| } | |
| if ((-not $NoOpen) -and ((-not $Listen) -or $PWA)) { | |
| Open-BrowserSafe $localUrl | |
| } | |
| return | |
| } | |
| if ($Listen) { | |
| Write-RDLog "For -Listen mode, you may need to run PowerShell as Administrator or add a URL ACL, for example:" -ForegroundColor Yellow | |
| Write-RDLog " netsh http add urlacl url=http://+:$Port/ user=Everyone" | |
| Write-RDLog "You may also need a Windows Firewall inbound rule for TCP $Port." -ForegroundColor Yellow | |
| } | |
| Write-RDLog "Try another port, for example:" -ForegroundColor Yellow | |
| Write-RDLog " .\RustDeskADClient.ps1 -Port 8766" | |
| if ($Listen) { Write-RDLog " .\RustDeskADClient.ps1 -Listen -Port 8766" } | |
| return | |
| } | |
| Write-RDLog "" | |
| Write-RDLog "RustDeskADClient is running:" -ForegroundColor Green | |
| Write-RDLog " Local: $localUrl" | |
| if ($Listen) { | |
| Write-RDLog " Remote bind: $prefix" -ForegroundColor Cyan | |
| $remoteIps = @(Get-LocalIPv4Addresses) | |
| if ($remoteIps.Count -gt 0) { | |
| Write-RDLog " Try from another machine:" -ForegroundColor Cyan | |
| foreach ($ip in $remoteIps) { | |
| Write-RDLog " http://$ip`:$Port/" | |
| } | |
| } | |
| else { | |
| Write-RDLog " Remote URL: http://<this-computer-ip>:$Port/" -ForegroundColor Cyan | |
| } | |
| Write-RDLog "" | |
| Write-RDLog "Remote mode exposes this UI, RustDesk launch actions, WOL, and stored RustDesk passwords to machines that can reach this port." -ForegroundColor Yellow | |
| Write-RDLog "Make sure Windows Firewall and your network only allow trusted clients." -ForegroundColor Yellow | |
| } | |
| else { | |
| Write-RDLog " Bind: 127.0.0.1 loopback only" -ForegroundColor Cyan | |
| } | |
| Write-RDLog "" | |
| Write-RDLog "Wake-on-LAN target:" -ForegroundColor Cyan | |
| Write-RDLog " Broadcast: $WakeBroadcast" | |
| Write-RDLog " UDP Port : $WakePort" | |
| Write-RDLog "" | |
| if ($Listen -or $NoHeartBeat) { | |
| if ($Listen) { | |
| Write-RDLog "Heartbeat monitor: disabled in -Listen mode" -ForegroundColor Cyan | |
| } | |
| else { | |
| Write-RDLog "Heartbeat monitor: disabled by -NoHeartBeat" -ForegroundColor Cyan | |
| } | |
| Write-RDLog "Press Ctrl+C to stop manually." | |
| } | |
| else { | |
| Write-RDLog "Heartbeat monitor:" -ForegroundColor Cyan | |
| Write-RDLog " Browser heartbeat every $HeartbeatIntervalSec seconds" | |
| Write-RDLog " Auto-close after $HeartbeatTimeoutSec seconds without heartbeat" | |
| Write-RDLog "" | |
| Write-RDLog "Leave the web page open while using WOL. Closing the page will stop this script." | |
| Write-RDLog "Press Ctrl+C to stop manually." | |
| } | |
| Write-RDLog "" | |
| $script:StartedAt = (Get-Date).ToString("o") | |
| $script:LastHeartbeatUtc = $null | |
| $script:HeartbeatSeen = $false | |
| if ((-not $NoOpen) -and ((-not $Listen) -or $PWA)) { | |
| Open-BrowserSafe $localUrl | |
| } | |
| try { | |
| while ($listener.IsListening) { | |
| if ((-not $Listen) -and (-not $NoHeartBeat) -and $script:HeartbeatSeen -and $null -ne $script:LastHeartbeatUtc) { | |
| $age = ([DateTime]::UtcNow - $script:LastHeartbeatUtc).TotalSeconds | |
| if ($age -gt $HeartbeatTimeoutSec) { | |
| Write-RDLog "No browser heartbeat for $([int]$age) seconds. Closing RustDeskADClient listener." -ForegroundColor Yellow | |
| break | |
| } | |
| } | |
| $async = $listener.BeginGetContext($null, $null) | |
| while (-not $async.AsyncWaitHandle.WaitOne(1000)) { | |
| if (-not $listener.IsListening) { break } | |
| if ((-not $Listen) -and (-not $NoHeartBeat) -and $script:HeartbeatSeen -and $null -ne $script:LastHeartbeatUtc) { | |
| $age = ([DateTime]::UtcNow - $script:LastHeartbeatUtc).TotalSeconds | |
| if ($age -gt $HeartbeatTimeoutSec) { | |
| Write-RDLog "No browser heartbeat for $([int]$age) seconds. Closing RustDeskADClient listener." -ForegroundColor Yellow | |
| $listener.Stop() | |
| break | |
| } | |
| } | |
| } | |
| if (-not $listener.IsListening) { break } | |
| try { | |
| $ctx = $listener.EndGetContext($async) | |
| } | |
| catch { | |
| if ($listener.IsListening) { Write-RDLog $_.Exception.Message -ForegroundColor Red } | |
| break | |
| } | |
| $req = $ctx.Request | |
| $res = $ctx.Response | |
| try { | |
| $path = ([string]$req.Url.AbsolutePath).TrimEnd('/') | |
| if ([string]::IsNullOrWhiteSpace($path)) { $path = "/" } | |
| $pathLower = Get-RustDeskRoutePath -Path $path | |
| if ($pathLower -ne "/heartbeat") { | |
| Write-RDLog ("HTTP {0} {1} from {2}" -f $req.HttpMethod, $pathLower, $req.RemoteEndPoint) -Level "TRACE" -ForegroundColor DarkGray | |
| } | |
| # Route map: | |
| # GET /manifest.webmanifest -> desktop PWA manifest | |
| # GET /service-worker.js, /sw.js -> desktop PWA service worker | |
| # GET /icon.svg, /favicon.ico -> embedded SVG favicon/PWA icon | |
| # POST /api/agent-command -> queue a rustDeskCommand payload for the agent | |
| # POST /api/rotate-rustdesk-password -> clear rustDeskPass in AD so the agent rotates it | |
| # GET /api/credentials -> JIT password fetch from server-side cache | |
| # POST /heartbeat, /page-exit -> local auto-close heartbeat | |
| # GET /ad-status, /health -> status probes for UI/proxy checks | |
| # GET /connect, /terminal, /rdp -> local RustDesk.exe launch helpers | |
| # GET /wol -> Wake-on-LAN magic packet | |
| # GET / -> full HTML UI | |
| if ($pathLower -eq "/manifest.webmanifest") { | |
| Send-TextResponse -Response $res -NoStore -ContentType "application/manifest+json; charset=utf-8" -Text (Get-PwaManifestJson) | |
| } | |
| elseif ($pathLower -eq "/service-worker.js" -or $pathLower -eq "/sw.js") { | |
| Send-TextResponse -Response $res -NoStore -ContentType "application/javascript; charset=utf-8" -Text (Get-PwaServiceWorkerJs) | |
| } | |
| elseif ($pathLower -eq "/icon.svg") { | |
| Send-TextResponse -Response $res -NoStore -ContentType "image/svg+xml; charset=utf-8" -Text $svg | |
| } | |
| elseif ($pathLower -eq "/favicon.ico") { | |
| $res.StatusCode = 200 | |
| $res.ContentType = "image/png" | |
| $res.Headers.Add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") | |
| Write-HttpResponseBytes -Response $res -Bytes (Get-PwaIconPngBytes -Size 192) | |
| } | |
| elseif ($pathLower -eq "/pwa-icon-192.png") { | |
| $res.StatusCode = 200 | |
| $res.ContentType = "image/png" | |
| $res.Headers.Add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") | |
| Write-HttpResponseBytes -Response $res -Bytes (Get-PwaIconPngBytes -Size 192) | |
| } | |
| elseif ($pathLower -eq "/pwa-icon-512.png") { | |
| $res.StatusCode = 200 | |
| $res.ContentType = "image/png" | |
| $res.Headers.Add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") | |
| Write-HttpResponseBytes -Response $res -Bytes (Get-PwaIconPngBytes -Size 512) | |
| } | |
| elseif ($pathLower -eq "/api/agent-command") { | |
| Write-RDMarker "Route" "/api/agent-command" | |
| if ($req.HttpMethod -ne "POST") { | |
| Send-JsonResponse -Response $res -StatusCode 405 -NoStore -Payload ([ordered]@{ | |
| ok = $false | |
| error = "POST required" | |
| }) | |
| } | |
| else { | |
| $form = ConvertFrom-UrlEncodedForm (Read-RequestBodyText -Request $req) | |
| $computerName = Get-RequestValue -Request $req -Form $form -Name "computer" | |
| $commandLine = Get-RequestValue -Request $req -Form $form -Name "commandLine" | |
| if ([string]::IsNullOrWhiteSpace($commandLine)) { $commandLine = Get-RequestValue -Request $req -Form $form -Name "cmd" } | |
| # Backward-compatible fallback for older browser JS that only sent action=. | |
| if ([string]::IsNullOrWhiteSpace($commandLine)) { | |
| $action = (Get-RequestValue -Request $req -Form $form -Name "action").Trim().ToLowerInvariant() | |
| switch ($action) { | |
| "reboot" { $commandLine = "shutdown /r /f /t 0" } | |
| "restart" { $commandLine = "shutdown /r /f /t 0" } | |
| "shutdown" { $commandLine = "shutdown /s /f /t 0" } | |
| "abortshutdown" { $commandLine = "shutdown /a" } | |
| "cancelshutdown" { $commandLine = "shutdown /a" } | |
| "abort" { $commandLine = "shutdown /a" } | |
| } | |
| } | |
| $queued = Set-RustDeskAgentCommandAttribute -ComputerName $computerName -CommandLine $commandLine | |
| $statusCode = if ($queued.Ok) { 200 } else { 500 } | |
| $clientMessage = if ($queued.Ok) { "Command queued. The agent will execute it on its next check-in." } else { [string]$queued.Message } | |
| Send-JsonResponse -Response $res -StatusCode $statusCode -NoStore -Payload ([ordered]@{ | |
| ok = [bool]$queued.Ok | |
| computer = [string]$queued.Computer | |
| commandId = [string]$queued.CommandId | |
| queuedAt = [string]$queued.QueuedAt | |
| message = $clientMessage | |
| error = [string]$queued.Error | |
| }) | |
| } | |
| } | |
| elseif ($pathLower -eq "/api/rotate-rustdesk-password") { | |
| Write-RDMarker "Route" "/api/rotate-rustdesk-password" | |
| if ($req.HttpMethod -ne "POST") { | |
| Send-JsonResponse -Response $res -StatusCode 405 -NoStore -Payload ([ordered]@{ | |
| ok = $false | |
| error = "POST required" | |
| }) | |
| } | |
| else { | |
| $rotate = Reset-RustDeskPasswordAttribute -ComputerName $req.QueryString["computer"] | |
| $statusCode = if ($rotate.Ok) { 200 } else { 500 } | |
| Send-JsonResponse -Response $res -StatusCode $statusCode -NoStore -Payload ([ordered]@{ | |
| ok = [bool]$rotate.Ok | |
| computer = [string]$rotate.Computer | |
| message = [string]$rotate.Message | |
| error = [string]$rotate.Error | |
| clearedAt = [string]$rotate.ClearedAt | |
| }) | |
| } | |
| } | |
| elseif ($pathLower -eq "/api/credentials") { | |
| Write-RDMarker "Route" "/api/credentials" | |
| $comp = $req.QueryString["computer"] | |
| $type = $req.QueryString["type"] | |
| $pass = "" | |
| if ($null -ne $comp -and $null -ne $type) { | |
| if ($script:CredentialCache.ContainsKey($comp) -and $script:CredentialCache[$comp].ContainsKey($type)) { | |
| Write-RDLog "Credential cache hit: Computer=$comp Type=$type" -Level "DEBUG" -ForegroundColor DarkGray | |
| $pass = $script:CredentialCache[$comp][$type] | |
| } | |
| else { | |
| Write-RDLog "Credential cache miss: Computer=$comp Type=$type" -Level "DEBUG" -ForegroundColor Yellow | |
| } | |
| } | |
| Send-JsonResponse -Response $res -NoStore -Payload ([ordered]@{ | |
| ok = $true | |
| password = $pass | |
| }) | |
| } | |
| elseif ($pathLower -eq "/heartbeat") { | |
| $script:LastHeartbeatUtc = [DateTime]::UtcNow | |
| $script:HeartbeatSeen = $true | |
| Send-JsonResponse -Response $res -Payload ([ordered]@{ | |
| ok = $true | |
| app = "RustDeskADClient" | |
| heartbeatAt = $script:LastHeartbeatUtc.ToString("o") | |
| }) | |
| } | |
| elseif ($pathLower -eq "/page-exit") { | |
| # Do not kill instantly here; reloads also fire pagehide/beforeunload. | |
| # The heartbeat timeout below safely decides whether the page is really gone. | |
| Send-JsonResponse -Response $res -Payload ([ordered]@{ | |
| ok = $true | |
| exiting = $true | |
| }) | |
| } | |
| elseif ($pathLower -eq "/ad-status") { | |
| Write-RDMarker "Route" "/ad-status" | |
| Send-JsonResponse -Response $res -NoStore -Payload (Get-ADStatusPayload) | |
| } | |
| elseif ($pathLower -eq "/health") { | |
| Write-RDMarker "Route" "/health" | |
| Send-JsonResponse -Response $res -Depth 5 -Payload ([ordered]@{ | |
| ok = $true | |
| app = "RustDeskADClient" | |
| wol = $true | |
| port = $Port | |
| wakeBroadcast = $WakeBroadcast | |
| wakePort = $WakePort | |
| startedAt = $script:StartedAt | |
| ad = (Get-ADStatusPayload) | |
| }) | |
| } | |
| elseif ($pathLower -eq "/connect") { | |
| Write-RDMarker "Route" "/connect" | |
| try { | |
| $id = $req.QueryString["id"] | |
| $password = $req.QueryString["password"] | |
| $exePath = Start-RustDeskConnection -Id $id -Password $password | |
| Send-JsonResponse -Response $res -Payload ([ordered]@{ | |
| ok = $true | |
| id = $id | |
| exe = $exePath | |
| launchedAt = (Get-Date).ToString("o") | |
| }) | |
| } | |
| catch { | |
| Send-JsonResponse -Response $res -StatusCode 400 -Payload ([ordered]@{ | |
| ok = $false | |
| error = $_.Exception.Message | |
| }) | |
| } | |
| } | |
| elseif ($pathLower -eq "/terminal") { | |
| Write-RDMarker "Route" "/terminal" | |
| try { | |
| $id = $req.QueryString["id"] | |
| $password = $req.QueryString["password"] | |
| $exePath = Start-RustDeskConnection -Id $id -Password $password -Mode "terminal" | |
| Send-JsonResponse -Response $res -Payload ([ordered]@{ | |
| ok = $true | |
| id = $id | |
| exe = $exePath | |
| mode = "terminal" | |
| launchedAt = (Get-Date).ToString("o") | |
| }) | |
| } | |
| catch { | |
| Send-JsonResponse -Response $res -StatusCode 500 -Payload ([ordered]@{ | |
| ok = $false | |
| error = $_.Exception.Message | |
| }) | |
| } | |
| } | |
| elseif ($pathLower -eq "/rdp") { | |
| Write-RDMarker "Route" "/rdp" | |
| try { | |
| $id = $req.QueryString["id"] | |
| $password = $req.QueryString["password"] | |
| $exePath = Start-RustDeskConnection -Id $id -Password $password -Mode "rdp" | |
| Send-JsonResponse -Response $res -Payload ([ordered]@{ | |
| ok = $true | |
| id = $id | |
| exe = $exePath | |
| mode = "rdp" | |
| launchedAt = (Get-Date).ToString("o") | |
| }) | |
| } | |
| catch { | |
| Send-JsonResponse -Response $res -StatusCode 500 -Payload ([ordered]@{ | |
| ok = $false | |
| error = $_.Exception.Message | |
| }) | |
| } | |
| } | |
| elseif ($pathLower -eq "/wol") { | |
| Write-RDMarker "Route" "/wol" | |
| try { | |
| $sentMac = Send-WakeOnLan -Mac $req.QueryString["mac"] -Broadcast $WakeBroadcast -Port $WakePort | |
| Send-JsonResponse -Response $res -Payload ([ordered]@{ | |
| ok = $true | |
| mac = $sentMac | |
| broadcast = $WakeBroadcast | |
| port = $WakePort | |
| sentAt = (Get-Date).ToString("o") | |
| }) | |
| } | |
| catch { | |
| Send-JsonResponse -Response $res -StatusCode 400 -Payload ([ordered]@{ | |
| ok = $false | |
| error = $_.Exception.Message | |
| }) | |
| } | |
| } | |
| elseif ($pathLower -eq "/") { | |
| Write-RDMarker "Route" "/" | |
| if ((-not $NoHeartBeat) -and (-not $script:HeartbeatSeen)) { | |
| $script:LastHeartbeatUtc = [DateTime]::UtcNow | |
| } | |
| Send-HtmlResponse -Response $res -NoStore -Html (Get-RegistryHtml -UserAgent $req.UserAgent) | |
| } | |
| else { | |
| Send-JsonResponse -Response $res -StatusCode 404 -Payload ([ordered]@{ | |
| ok = $false | |
| error = "Not found" | |
| }) | |
| } | |
| } | |
| catch { | |
| $requestError = $_.Exception.Message | |
| Write-RDLog "Request handling failed: $requestError" -ForegroundColor Yellow | |
| try { | |
| if ($req.Url.AbsolutePath -eq "/" -or $req.Url.AbsolutePath -eq "") { | |
| $safeError = [System.Web.HttpUtility]::HtmlEncode([string]$requestError) | |
| $fallbackHtml = @" | |
| <!doctype html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>RustDeskADClient</title> | |
| <style> | |
| body { margin:0; font-family: Segoe UI, Arial, sans-serif; background:#0b0f17; color:#e7edf7; } | |
| .box { max-width: 900px; margin: 64px auto; background:#111827; border:1px solid #263247; border-radius:18px; padding:28px; } | |
| .status { display:flex; align-items:center; gap:12px; font-size:20px; } | |
| .dot { width:15px; height:15px; border-radius:50%; background:#ff6b6b; box-shadow:0 0 18px rgba(255,107,107,.35); display:inline-block; } | |
| .small { color:#8c9bb3; margin-top:14px; line-height:1.5; } | |
| button { margin-top:18px; padding:10px 14px; border-radius:10px; border:1px solid #263247; background:#151f31; color:#e7edf7; cursor:pointer; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="box"> | |
| <div class="status"><span class="dot"></span><span>RustDeskADClient could not render the Active Directory page</span></div> | |
| <div class="small">$safeError</div> | |
| <div class="small">The listener is still running. Check network, DNS, VPN, or domain controller access, then refresh.</div> | |
| <button onclick="location.reload()">Refresh</button> | |
| </div> | |
| </body> | |
| </html> | |
| "@ | |
| Send-HtmlResponse -Response $res -Html $fallbackHtml | |
| } | |
| else { | |
| Send-JsonResponse -Response $res -StatusCode 500 -Payload ([ordered]@{ | |
| ok = $false | |
| error = $requestError | |
| }) | |
| } | |
| } | |
| catch { | |
| # Do not let response-writing failures kill the listener. | |
| } | |
| } | |
| finally { | |
| Close-HttpResponseSafe -Response $res | |
| } | |
| } | |
| } | |
| finally { | |
| if ($listener.IsListening) { | |
| $listener.Stop() | |
| } | |
| $listener.Close() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment