Skip to content

Instantly share code, notes, and snippets.

@Geofferey
Last active June 2, 2026 11:43
Show Gist options
  • Select an option

  • Save Geofferey/0d1763e770879cdb183493a9427ddee9 to your computer and use it in GitHub Desktop.

Select an option

Save Geofferey/0d1763e770879cdb183493a9427ddee9 to your computer and use it in GitHub Desktop.
RustDeskAD - RustDesk for Active Directory
$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
#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
<#
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
}
# 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 = @'
iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAABmJLR0QA/wD/AP+gvaeTAAAgAElEQVR4nOzdd3gcxcEG8HeuqVnVkqvcKzbGxhVs0w3Y9E6o
X+iQBAj5EggpBEgl+UiBJIReE2pC6M0GDNhgcO+9W7Zsq7fTtfn+OMlW2T3d3fbV+3seHtDc3ewg3e68Ozs7K0CWkVJmABgEYDCAIQB6ASgG0LPDPwBQ2PLv
AIAcUxtKRNS1GgAxAPUAwgCaABwEUAZgP4B9APa2/LwBwC4hhLSmqQQAwuoGdAdSykIARwEY1/LvMYh3+H3BvwERdU8NADa2/LMOwFIAi4QQ+y1tVTfCzkdn
LWf1kwFMBzADwEQAAyxtFBGRc2wH8BWARQC+ALBUCBGztEUuxQCgkZQyAGAmgNMAHAdgEoAMSxtFROQeBwHMBfAhgA+FEHssbo9rMACkQUo5CMDsln9mAehh
bYuIiLqNlQBeA/CKEGKD1Y1xMgaAJLV0+hcDuATAFIubQ0REwAoArwJ4WQix2erGOA0DQAJSyl4ALgdwKYBp4O+LiMiuPgPwOIDXhBBBqxvjBOzQOpBSegCc
AuBGAOcgftsdERE5QyWA5wE8JoRYa3Vj7IwBoIWUsgTADQCuR/wWPSIicrYPAfxeCDHP6obYUbcPAFLKYQBuQ7zjz7a4OUREpL8VAP4I4F9CiIjVjbGLbhsA
pJTHAfghgLMAeCxuDhERGW8bgF8CeJ5BoBsGACnlsQDuR/z2PSIi6n42APgNgH8KIaJWN8Yq3SYASCmnIN7xz7a6LUREZAvLAfxUCPGu1Q2xgusDgJRyOIA/
ADgX3eD/l4iIUvYBgNu728JCru0QpZQ5AH4E4C4AmRY3h4iI7C0M4BEA9wghaqxujBlcFwBa7uO/FsCvAPS2uDlEROQs+wDcKYR43uqGGM1VAUBKOQLAYwBO
tLgpRETkbO8DuEkIsdPqhhjFFbe/SSn9Usq7AKwCO38iItJuNoB1Usq7WkaWXcfxIwAtt/U9AWCM1W0hIiJXmg/gGiHENqsboifHphoppa/lrH8+2PkTEZFx
TgCwXEp5pdUN0ZMjRwCklIMRf9jDTIubQkRE3curAG4UQlRb3RCtHDcCIKW8BsBKsPMnIiLzXQzgaynlZKsbopVjAoCUMkNK+TiApwDkWt0eIiLqtkYA+EJK
eaPVDdHCEZcApJSlAP4NYKrVbSEiImrjecRvF2yyuiGpsn0AkFKeAOBlcFEfIiKyp6UALhBC7LC6Iamw9SUAKeWtAOaBnT8REdnXRACLpJSOGqW2ZQCQUgop
5b0AHgLgtbg5REREXekNYL6U8lKrG5Is210CkFJmAngWwCVWt4WIiChFEsD9Qoh7rW5IV2wVAKSUJQDeBif7ERGRsz0M4A4hRNTqhqixTQCQUvYB8CGAcVa3
hYiISAcvA7hKCBG2uiFKbBEApJQDAcxF/N5KIiIit3gHwMV2vE3Q8gAgpRyC+Ez/IVa3hYiIyADzAZwjhKi1uiFtWRoApJQjEP/F9LWyHURERAZbBGC2nZ4h
YFkAkFIOAPAZgMFWtYGIiMhEiwCcZpeRAEvWAZBS9gLwEdj5ExFR9zENwHtSyh5WNwSwIABIKYsBfAxglNnbJiIisth0xENAjtUNMTUASCmzEZ8ROdbM7RIR
EdnITAD/llIGrGyEaQFASulB/KlJXOSHiIi6u9MBPNvSN1rCzA0/COACE7dHRERkZ98C8IBVGzflLgAp5Y0AHjVjW0RERA7zAyHEn8zeqOEBQEo5C8D74FP9
iIiIlMQAXCaEeMXMjRoaAFqW+F0MoMTI7RARETlcEMAJQoivzdqgYQGg5bG+nwOYbNQ2iIiIXGQvgClCiD1mbMzISYB/BTt/IiKiZPUF8IaUMsuMjRkSAKSU
3wZwnRF1ExERudgkAP8wY0O6XwKQUg4FsAxAnt51ExERdRM3CyEMvXtO1wAgpfQh/oCfY/Wsl4iIqJtpBjBdCLHUqA3ofQngHrDzJyIi0ioDwCtSynyjNqDb
CICU8ljEZ/3zfn8iIiJ9vCaEuNiIinUZAZBSZgB4Auz8iYiI9HSRlPImIyrW6xLA3QDG6FQXERERHfaglHKk3pVqvgQgpRwNYDni1yuIiIhIf18COE4IEdWr
Qk0jAC2PMXwK7PyJiIiMdCyAH+tZoaYRACnl9QAe16ktREREpC4MYJoQYpkelaUdAKSUuQA2AuijR0OIiIioSysATBZCRLRWpOUSwD1g509ERGSm8QBu06Oi
tEYApJTDAKwBr/0TERGZrRHAOCHEVi2VpDsC8H9g509ERGSFbMSfuKtJyiMAUsopABal81kiIiLSzYVCiP+k++F0AsD7AE5Pd4NERESki20Axgghgul8OKVL
AFLK6WDnT0REZAdDANyR7odTGgGQUn4M4KR0N0ZERES6qgcwSghRluoHkx4BkFIeD3b+REREdtIDwK/T+WDSIwBSyjcBnJ3ORoiIiMgwMQBHCyFWpvKhpEYA
Wp5CdGY6rSIiIiJDeRBfnC/lDyXjf1N4LxEREZnrAinl1FQ+0OUlACllCYAdALLSbRUREREZ7n0hxJxk35zMWf0NYOdPRERkd7OllDOSfXPCACClFACu1dwk
IiIiMsNdyb6xqxGAUwEM09YWIiIiMslZUsojk3ljVwHgBh0aQ0REROYQAO5M9o2KpJTFAHaDT/0jIiJykgiAEUKI7YnelGgE4Eqw8yciInIaH4DvdfWmRCMA
XwGYpmeLiIiIyBRVAEqFEI1qb1AcAZBSDgCQ0oICREREZBuFAC5J9Aa1SwDfQopPCiQiIiJbuTXRi2oB4GIDGkJERETmmSilnKz2YqcAIKUcBED1A0REROQY
31Z7QWkEYA44/E9EROQGl0kpA0ovKAWA2QY3hoiIiMxRBJV+vd2ZfktKOAgg14RGEVEawjGgvCmGXQ1R7G2KYW9jDNUhidqwRHUo/t/xf2KoCUlEADRHJRoj
EgAgAVSHZct/AR4B5PsFIACvEMjzxw8LmV6BgoBAQYZAgd8T/++AB/kBgaIMgd6ZHpRme9Evy4NemXxaOJGNvSqE6HRHQMcAcDKAeaY1iYg6icSA7fVRbKqN
YlNNFJtro9heH0NZYwxljVGUN8Vaum4oX6xLqUx2/b4k6szwCPTNEijN8aB/thdDcrwYnuvFyDwfRuZ6GRCIrBUE0FsIUdu20NfhTaeZ1x6i7i0cA9ZVR7Ci
IoqVlRGsq453+tvrogjHkFxHLrWWCbQLAUrvS6LO5pjE9gaJ7Q1RQIQ7fTw/IDAi14cRuV6MyfdhQqEPRxf60S+LwYDIBJmIz+97uW1hxxGAhQCONbFRRN1C
MCqx5EAEyyqiWFERwfKKCFZXRdEcbdP56n3mbsFIQJxMetu9sjyYUBAPAxMKfZhU5MfwHl6VjRORBi8KIS5vW3Bol5RSZgCoAdf/J9KsJiTxzYEIFuwNY0F5
BF/siyAYjR1+g6YOFq4KAR3Lemd6MKWnHzOK/ZhREsCUIh8yPLwxiUijGgC9hBCh1oK2AWAmgM+taBWR0x0MSny0O4R5u+Md/obqaPs3HN7TFMo6YAhoV5bt
FZja04/jS/w4tU8A03oG4GMeIErHaUKIj1p/aBsAfgTg95Y0ichhohJYfjCCubvDeHtHCF+WRxBr23cm7OQYAtIvk8jxCxzb048z+2Xi3H4ZGJzDSwZESXpI
CHF76w9tA8B/AZxrSZOIHKAiKPHW9hDe3B4/068Lp3v9HmAISLU96vWNyvVhTt8MnNc/AzOKA/BydIBIzWohxLjWH9oGgF0ASi1pEpFNHWiSeG9nCK9tCeGD
nWGEW0/zdenQGAL0CgGtemZ4cEbfDFxUmonT+wQQ4NwBorYkgL5CiHKgdTeXsghAhZWtIrKLPQ0xvLgxhNe3hrCodWi//f0y8X8xBKRYpzkhoLWswO/B2f0y
cGFpJub0yYCfdxwSAcC3hBAvA4cDwIkAPrGyRURWCkYl3toWxvPrm/H+zjAiUuFNDAHq70u6TnNDQKvCgAcXl2biykGZmFmsuCw6UXfxqBDiZuBwAPgegIct
bRKRyWISWLg3ghc2NOPFTSHUhVLtOBkC0qvTmhDQanSeD5cOyMTVg7IwhBMIqftZK4QYCxwOAI8DuN7SJhGZZH9TDE+uacbja5qxvbaLe/MZAlKrL+k6rQ0B
EPFnIJzWOwO3DMvGmX0ywOkC1E3EABQIIepaA8AXAGZY2yYiYy3ZH8Xjq5vx/PpmNEWVxvjBEKBHfUnXaX0IaNUvy4MbhmTjO8OyUZLByQLkescLIT5vDQB7
APSzuEFEumsIS7ywPoR/rGrGioNtF+dR6SwAhgA96ku6TvuEACD+UKOLB2TiO8OycUyRX+VDRI73AyHEn4SUMhNAAwDGXnKNg00Sf1sRxF9XNKMiqNY5MwQY
Wl/SddorBLSWTSzw47bh2bhiYBbXFiC3+acQ4kohpRwFYL3VrSHSw/baGP68NIgnVofQqDTMzxCgoT1p1Jd0nfYMAQAwLMeL7w3Pxk1DspHJJEDusFwIcbSQ
Up4O4H2rW0OkxcqDUTy4JIgXN4QQaTOvT1NnkfTnO5YxBKRXp31DAAD0zhC4eWg2bh+RgwIuKkDO1gAgV0gpbwTwqNWtIUrHqoNR/GJhE97YEo53RXp3FmnX
yRCQXp32DgGARGHAg/8dmYPbhuWgB59KRM41QEgp7wFwn9UtIUrFtpoYHvgmiCdXN6PTSD9DQBJlDAFaQgAAFGccDgJZvDRAznOKkFL+BcBtVreEKBk7amP4
7ddBPLW6dajfpM4i7ToZAtKr0xkhAALoleHBHSNy8P3hOcjgYgLkHDd7ABRb3Qqirhxokvju3CaMfLIWj61se51fdOp3AGgoU6kv7TqFxva0LROJ35dynSLJ
9+lYX9J1mvR3TbuNh/+u+5tjuHt1HcZ8dAD/2tWkWiWRzfQTUsr3AZxudUuIlIRjwN+XN+O+hUFUNyc6A+ZIQPp1ciRAj5GAVpML/fjTUXmY0ZPPHCBbe1RI
KRcDmGR1S4g6mrsjgu9/3IS1FVF7dRZp18kQkF6dzgsBAsBF/TPxwLg8DM7m8wbIlv4rpJSbAAy3uiVErZbvj+J/P23CJzsj7V+wU2eRdp0MAenV6bwQAADZ
XoEfjeqBH43IQTYnCpK9fCmklLsAlFrdEqK6kMQ9XwTx16UtM/vNOLgzBHQoYwjQOwQAwOAcL56cWICTSnhZgGxjo5BSlgPoZXVLqHv776YwbpvXhN11sfYv
MAQkUcYQ4IQQIATwq7G5uHtkD5XKiUy1S0gpqwHkW90S6p521cVw29wg3tgUVu4sAIaApMoYApwQAiCAO4bn4MFxeSqVE5nmgJBSNgLIsrol1L1EJfDQkmb8
4otm1Ic6dl4MAemVMQQ4JQT8dmwu7uJIAFmrTkgpIwA4TZVMs60mhmvfacL8XZHkO4tD5emUMQSkXydDgBEhwOMB5s7siROLOSeALBMRUkquW0GmkAAeXx7C
Dz8JKpz1d8AQoKE+hgAnhIBhPbxYM6sEAa4eSBbhI63IFHvqYjjj5Qbc/H5T+84fSH5luYTv7apMcMXAtOsUSb5Px/qSrtOkv2vabVT/u26pj+Kx7Y0qFRMZ
jyMAZLh/rgnjto+aUBVM5qxfqYwjAemVcSTA7iMBw3K82HBaL3AQgKzAEQAyTH1I4n/easRVbza27/wBbWeMKX++LY4EpF8nRwL0HgnY0hDFZwdDKpUSGYsB
gAyxcn8UU5+px/Orw/ECvTsLTXUyBKRfJ0OA3iHg3X1BlQqJjMUAQLp7blUY059txPoKLWf9SmUMAemVMQTYOQQsrAyrVEZkLJ/VDSD3qAtJ3PRuEC+tbXNA
EwLtjnYSnfvxlMo61KepTgFIqbE9SdSXdp0t/6+af2dt6lJ7X8p1Gvx31dRGk/6uabex/d91TS0DAFmDAYB0sfZgDOe/2ohNVR2W8jWjs9BUJ0NA+nUyBOgR
AmrCEqGY5O2AZDpeAiDN3toUwfRnGrCpMmbdsLGmOnk5IP06eTlAj8sBDVG1SoiMwwBAaZMAHljYjPNfbURtc4czN6U3MwRoqJMhIL06nRECenh5KCbz8RIA
paUhLHHtm0G8uq7l+qVdho011cnLAenXycsB6baxKOCFn/0/WYBfO0rZjpoYjnum8XDnD9jrjFFTnRwJSL9OjgSk08Yj83geRtZgAKCUrCyPYubTDVi+L9r5
RTt1FprqZAhIv06GgFTbOLPYr/IBImMxAFDS3tscwcxnGrGnrs3QcUd26iw01ckQkH6dDAGptHFOnwyVNxMZiwGAkvLMijDOe7kJ9R1XLWUI0F5f2nUyBKRX
p31CwBF5PkzvyUcCkzUYAKhL980P4do3gggfGvW34ODOEKBSxhCQXp32CAE/GJGjOG+QyAx8GiCpisSAW94J4sllHVYqO3TEkirl6ZYZXJ+mOqUO7UmivrTr
lBrbA+W/qy5ttOB7knSdJv1dFcrG5fuw9NRieJkAyCIcASBF4Rhw+b+b8ORShWVKlc4Y25WnW8aRgPTr5EhAenVaMxLg9wCPTc5n50+WYgCgToIR4MKXm/Da
2ki8INnOosv3JlPGEJB+nQwB6dVpfgj404Q8TCvi7H+yFgMAtdMYljj3xSa8vTHS/gWGgBYMAenXyRAACfx8TA98Z1i2wotE5uIcADqkIRTv/D/e1jLbT8u1
47Q+37HM4Po01Sk5JyDtOi34niRdp7F/1x+OysHvx+eqNJDIXAwABADYWRPDhS8HsaSswwI/DAEJyhgC0q+z+4WAH4/OwW+OYudP9sEAQNhdK3HiU43YWhXT
v7NI6/MdyxgC0q+TISC9OvX9u/5wdDbP/Ml2OAegmyuvlzj92SZsq2o5eOp97ViXOg2uT1OdgnMC0q7Tgu9J0nXq93dl5092xQDQjR1okJj1dBPWH4wBAIRR
nYUudTIEpF8nQ0B6dWr/u7LzJztjAOimDjZKnPpME9YeiHUY2WUISK+MISD9Ot0ZAtj5k90xAHRDtc0Ss59twqry2OFChgAd6mQISL9Od4WAu8fk4PcT2PmT
vTEAdDPhKHDJS0EsK4t1fpEhQIc6GQLSr9MdIeBHo3Pw66N6qGyMyD4YALoRKYEb/hvER5tbbvXr4oDGEJBunQwB6dfp7BDwoyNy8MAEdv7kDAwA3ciP3m/G
88tTWeGPIYAhIJkyhgBIdv7kPAwA3cQji8L404JwWgdThoB062QISL9OZ4UAdv7kRAwA3cArqyK47e3mwwUMAanVp6lOhoD063RGCPjJ2Bw8cDQ7f3IergTo
ckv2xHDi441ojCi8mMaqZrL1qKf3ynK61GlwfZrqlLquLKdaX9p1GvR31aWNFnxPkqzzR2Oy2fmTY3EEwMX21kmc/0IQjWHodhbKkYB06+RIQPp12nMkgJ0/
OR0DgEsFI8AFLwSxp1ZC34M7QwBDQDJl7g4Bd7LzJxdgAHAhKYEb/tOMr3e3vdefIUC3+jTVyRCQfp32CAF3js3G79j5kwswALjQA/PD+NfyiHJnATAE6FGf
pjoZAtKv09oQwM6f3ISTAF3m3Q1RnPtcELG2f1WlCWSK5emVcWJgunVKTgxMu07zvyc/G5eD+yfkKFRA5EwcAXCRXTUS17za3L7zBzgSYGR9murkSED6dZr7
PblzbDY7f3IdBgCXCEeBK15sxsEGmXxnoVieXhlDQLp1MgSkX6c535M7x2bjdxM57E/uwwDgEne/H8KCHdHDBQwBHcoYAtKvs/uGgDvHsPMn92IAcIF31kXx
5y/CnV9gCOhQxhCQfp3dLwTcOTYbv5vEzp/ci5MAHW57lcTkh5pQ1dTyZ9QygUzT59HhuK5nfVKlPN0yg+vTVKfUdWKgV0gUZgnkBwQKMuJ5P9MLZPlEu/fW
hiSiEqgJSTSEJRoiErUhpb+hQX9Xm00M/Pn4bNzHa/7kcj6rG0Dpi8SAy/8VPNz5A/FjYMcDWlJlIl6Y9ufblwmIeAjQpb6WtmloT/syg+vTVKcApEIIUPis
XwCluR4MyvNgUK4Hg/M8GJjnPfRzSZZAXkCtZ+1aKAocCMawv1FiX1MMZQ0x7KiLYUddFNtqo9hUE0N5U5u1JrT8XZXel3Kd+vxd2flTd8ERAAf75dww7v0o
pOsZI0cCdKxPU53tQ0BBhsDEXl5M6u3DxN5eTOzlw9B8D7zp9++6qGyWWFsZxbqqCFZWRLHkQAQrKqNoiqTyu7XPSAA7f+pOGAAcavHuGGb+PYhwFNB72Jgh
QMf60qjT7wGm9fVhRj/voc5+WIFzputEYsCaqgi+LI/gi70RfFYWxu6GmO1DwD3js3Hv0ez8qftgAHCgpjAw+S9BrD/QdqlfhoD0y6wPAUcWezFniA8nDfTh
uP4+5PgtPrXX2ba6GObuDuGDXWHM3RM+PL8AsEUIYOdP3REDgAPd/kYIf10Q6XLY+BCGgCTKzA0BPg9w0gAfzhrux1lD/RiS75wzfK0iMWBBeRhvbQ/h9W0h
bKuLWRoC7pnAzl9JKBafDAoB5Po88LkrkxIYABxn3qYoTn+iGYf+agwBmtrTvszY+rwCOH6gD5eMCuDCkX4UZ/GICgBLD0bw+rYQXtwSwtbaKMwMAez8D6sI
xfCfPUHM3R/C0qowtjdGEZWH99fSLC8m5PtwYkkGLuyfiUHZXmsbTJoxADhIbVBi3INB7K7pqqNiCEi/TP/6hhd6cM2RAfzPkQH069F9zvRTJQEs2BfBC5ua
8cqWIKoVb0NsQ8PfVQjgN5NzcNe47LTb6xZb6qP45bp6vLwriOaY0u+8c5lHAGf0zsDdo3vg2KKAWU0lnTEAOMitr4fw94VKQ/9gCNDYnvZl2uvze4CLRvlx
w4QAThjgU+3DSFkwKvHq1hAeXRvEwvKwriEg2yfw9HG5uHhIhj6NdahwDPjl2no8sKEeYaVeoIsQ0Prj1YOy8Kej8lDgZ7h1GgYAh/hqRwzH/a3NU/4YAjqU
2SME9MoRuHF8ADcfzbN9vaysjOCRNUE8t7m58+2FQEp/1xm9fXhyZi5G5nfv4evyYAznLajCoso2K4h2+XtU31+H9fDi39MKcVS+X8dWktEYABwgHAWm/iWI
lXtj7V9gCOhQZl0IGJLvwZ3HZODb4wLI6N59i2EOBGP4+9og/r4miAPB1PaFkfle3D0+G1cNz4Cnmw/HbG+IYtb8SmxtiHZ+UUMIKAx48P6MIkwpZAhwCgYA
B3jg4zB+8q7KMChDQIcyc0PA6J4e/PjYDFw+JgAfT/hN0RSReHFLM17e0oyPy8KIqoyK9fALnD0ogEuGZOCsgQHLF02yg20NUZz8SSV2NEY1XFZR3197Zniw
6MRiDM1hCnYCBgCb21IhMf7/mtDUOlLHEJBEmfEhYHC+B7+YmYErj2THYqWqZonVVRFsrImiMSKR4RUoCAiMK/JhRJ6XoayNrS2d/87GNmf+BoSA8QV+LDqx
JwLdfajFARgAbG72Y834aGOHoTqGgCTKjAkBvbIFfjojEzcdHUCAJznkEFvqozjlk0rsbEpy2F+tPMkQcO+YXNwzmk9StDsGABt7Y3UUFzzdrH+nzRCQcpnf
C9wy0Y/7js9EfgbPbMg5tjdEcfInVdje0LLGQrL7gVp5EiEgwyOw6tRiDM/h8+bsjAHApkJRYNzvg9h8UMKQTpshIOmyOcN8+OOsTIzqyfFkcpbN9fHOf3fb
YX+TQsBF/TPxyrTCZJtKFuARzab+8lmkpfMHAKG4smz6ZQbUp+nz7cuErvUJlfKuy3rnCDxzdhbeuTSbnT85zqa6KE6aV4ndnWb7p7D/q5W3K1PeX1/bE8T8
g6FkmkoW4QiADZXXSYz+XRC1wY6vcCQg/fqSHwkQAK6b4McDp2SiMJPD/eQ8G+uiOOXjSuxpanO7pJb9X628i5GAowv8+PqkYk6UtSme1tjQz94No7ZJ6RWO
BKRfX3IjAX16CLx5aTYeOzOLnT850sa6KE6Z16HzB7Tt/2rlXYwELKsO4+kdjQlaS1biCIDNLNsTw9Q/Nide8Y8jARrqUx8JuGysH3+dw7N+cq4NtRGc/HEV
9rZ2/nrv/0nV2X5/7Z3hwYbTeiHPZY+4dgOOANjMT96JHO78AZU0zpGA9OvrPBKQGxB44bws/PN8nvWTc63v2PkD+u//SdXZfn8tb47hgY31KpWRlTgCYCNf
bI3hhIdTuO2PIwEa6ovXNabEg5cvzMbYEmZhcq4NtRGcMq8KZU0xc/b/pOo8vL/m+AQ2n94LvTO4n9kJ/xo28rN3Wpb7SzqNcyQg/foEbpwYwOLre7DzJ0db
WxPBiXNbOn/AnP0/qToP768NEYnfrucogN3wyGcTH6yP4vOtqQ7dAQwBqdfnE8BfZmfgH2dmIpPrlJCDra+N4tR5VSjv+HAkG4aAR7c1YpfSSoRkGQYAm7jv
vUjnQoYAxTItIaBnlsAHV2bj1ikBhTcSOcf62ihOmVuFvU3Suv0/qTrj+2tzVOJX6zgKYCcMADbw35VRLNoR07YTMwR0+dmxJR4svjEbJw3mIv7kbKuqIzjh
w7YT/izc/5OqM76/Pr29EZvrFU52yBIMABaTEri37dk/Q0DSZamEgBMHe/H5NdkYlM+vPDnbyqoIZs2txoGOw/4OCAERCdzHUQDb4NHQYu+siWHV3g57DkNA
0mXJhIALj/Dh3SuyUcBb/MjhVnTs/M3YX3UOAS/tasImjgLYAgOAxX4/L9KygyS3Ul1SZQwBh3x3ih+vXJLFyX7keMsrIzj1o2ocbNZxhT8LQkBUCvxpU4PK
h8lMXAfAQp9tieGkh9o8LEMAnfYeLfe8Q9pynYCAFxjf14ORxQKjSzzonyeQExAozErjDL3dOgHtDcoXGMGH+JALLK+M4LS5bTp/q9b10GmdgAyPwNY5Jeib
yf3TSjwvstADczsMg0kAQqBdVybReUdKtgwiPskg3c93Kku/voEFApce5cOs4V5MH+RFtr9jW4lIyfLKCI0jLt4AACAASURBVE6dW42K5g63CRu4v2qur4s6
m2MSD29uwG+OzFX4IJmFIwAWWVkmMfGBZtURfDeMBHg9wIXjfLhxig8nDPXCw0vwRClZVhnBaR9VoyLUccJfCwePBOT5BbbPKUGBn6MAVuEIgEUe+CiCQ9FL
MSU7dyTAI4CrJ/pw14l+jCzmzk2UjnAMeHZLE6pDsbTOsg+z50hAbVji0a1NuGtUjsKHyAwcAbDA7mqJYfc2I9I21KsmameNBEwu9eBv52Vgcik7fiI9rK6O
4IaFdVh0MJzW9fbD7DcS0DfLg21zShDg8KAleJS2wGNfRNt3/kCCmbPOuDvA5wF+OyeAL7+bxc6fSEdHFvjw+ZxC/PLoHASESkdp1d08Gu8O2NsUw7/3NKt8
gIzGEQCThaLA4J83o7wu1TRu35GAAQXAvy7PwPRBqa2wt2lfPdbvqcPmffWobAihrimMaCz1r6MHgI+L+5EDBLwe5Gb70acgE2NL8zF2QD56pHCP6oL9YVz4
aQ32d7wVsJUDRwJm9Azg85OKVN5MRmIAMNmLi6O48tnw4QKHh4CxvT1477oM9M/veggvFInhw5Xl+Pei3Zi/9iDKa4JdfqYrHgFk+NSPNUR25vd6MH1UMc6Y
2BcXHTMAeVld3x6zoyGKcz+uwcpqlcV0HBgCFs/qiYkFvDXIbAwAJjv+TyEs2NohvTs0BBwzyIO3rslAUXbi7reqIYRHPtyKf8zdgoq6UML3pkIAyPS1zJck
crCYBPx+L648bhB+dPYo9M7PTPj+qpDE6R9VYXGlO0LADUOy8eikPJU3klEYAEy0fHcMk34X0mGnsz4EjOvjwSe3ZCRcvCccjeGRD7fg16+vR31Q/6U/M33g
rYXkeDEZf1Je617dI9OH/z1zJO44cyT8XvX5NNUhidlzq/F1RVj5DQ4KAVlegV1nlqAowPlDZuJv20SPfN7yLGzNE3GsnRg4qFDg3esTd/7r99Rh5j2f4u4X
VxvS+Qe87PzJ+Tp2/gBQH4zgvn+vxYn3f4r1e2pVP1sQEHjr5HwM66EyAcZBEwObohLP7mhSeRMZhQHAJI0h4JUl0cMFDg0BAQ/w0pUZ6Jen3vu+8uUuHH/v
J1i9q0b1PVr4PPF/iJxMqfNva/n2ahx/36d4Y3GZah0lmR68fUqB+pmzg0LA41sZAMzGw6hJXl8eRW1Qx07bohDw2zMCmDpA/Wvz0Pubce0/FqOhOar6Hi08
In72T+RkEvHlcLu6/trQHMEVf/0KD723SfU9o/K8eGp6LkQKt9/ZMQSsr4vg60qVyxlkCAYAkzy7KKZ/p21yCJg5xIPbj1O/Zemh9zbh7n+tglGzSgSADHb+
5AKhqEx6P5ESuPulVXjw7Q2q7zlnQAZuGZWV0j34dgwBz/EygKkYAEywq0rikw1tn9/tvBDgE8DD5wdUZ9y/uGAnfvLSauUXdRLwcsY/OV8kBkTTCMn3vLoG
T36yTfX1P0zugaG5XkeHgJd2BtGcxloglB4GABO88HUM7b7TDgwBNxzjw1F9lb8uq3fV4Nanlxt25g/Er/knmBBN5BhhDTvKD55fjo9X71d8Lcsr8JcpLU/X
c2gIqAzF8HYZVwY0Cw+pJnhhkcL1cAeFgIAXuPNk5UU6miMxXP23b9AUMuaaf0tL4OfQP7lAJAZNQTkSlbjmH99gX7XyIlpnlgZwdmlG/AeHhgBeBjAPA4DB
vt4usX6fyp7okBBwxSQfBhYoj73/+Z1N2FBWp/iaXrjSH7lFRIdhsoN1zbjx8SWqQeLeCTmH9xcHhoD39zWrL3VMumIAMNhrSxPc+3+o3N4h4OZjlSf+ldcE
8Ye31Ccm6cHn4f3+5A4xCeh1eXve6nK8+tUuxdeOLvLh7AEZhwscFgLCMeC13dqXCaeuMQAY7PXlbZKsA0PAmN4Ck1Vu+3vovc2GD/3zlj9yC73ntt390io0
NCsvsnX7EdntCxwWAv7DAGAKBgADLd0psfVgh2+6w0LAZROVz/4bmqN44mP1Gcl6YOdPbqL3oPa+6iCeVNkHT+zjx7DcDvuug0LA/AMhHOBlAMMxABjov8uj
Kd0CY8cQMGuk8lfkzcV7DFnit5VXcNY/uYsRj13583ubEIp07igFgCuHZiKp/V+t3MIQEJXAm7wbwHA8xBroP8va3vvfgQNCQH6GwKRS5a/Iq1/tUSzXC8/+
ibpWXhPEO8v2Kr52Zmkg+f1frdzCEMDLAMZjADDI2r0S69rO/ndgCJg6SCiehYejMSxYf1ChAn34PVzwh9xH//P/uH8t2KlYPrHIh95ZHseGgHnlIVSHeRnA
SAwABvnvcoUvrsNCwBG9lb8ey7fXxIf/DTii8Z5/ciujDrafrtmPYLjzZFyPAI4taVm/w4EhIBSTeIuXAQzFAGCQ91fHtO0gh8qtCwEjS5RPw9s9olTnEMDO
n9xKGDSs1RiK4uvNlYqvjStss0M5MAS8ywBgKAYAA1Q3Aou2pXj934YhYGCh8gFr0776rj+fBo/gY37JvbwGXtZauVP50duj85XuBHBOCPiwPJTWcxMoOTzc
GmDe+hgisTY7mUNDQI8M5SPWwVqFVK7DTurnt5FczCOMm9uypbxesbxYaR92UAioCsWwmI8INgwPuQb4YE3r9X9nh4DcDChSvf1PQwjw8LY/6gZ8BiWA6gbl
TrIg4NF0EqBabmIIeH8fLwMYhYdcA3y0Vrb5Qjs3BGQorwGE5kQzc9MMATz7p+7AqEtcjSHlUJ7dug87OAR8yABgGB52dbamTGJnZcs32QUhIC0pft4Dnv1T
9yAA+Kx6uIVDQ8DXFWFUhng7oBF42NXZR2s7fFEZArrEmf/UnfisXOPCgSEgKgXmlYdUGkRaqAzyUro+36TwjZZo2b8EDn27D5Upva+LskPlberTWqdSfVqp
tb0Nge519p8Z8KJPQSZ652dCCGB/TTP2VjUZ+lAlshePiN8RYNns9mT3/4THnq7KBCClxuPR4R8/3R/CxQMyFRpDWjAA6EhKYOFm2cUXmiGgre5w9l9alI3z
p/XHnAn9MGlYITwdJoJJCSzbXoX3lu3F61/vxo4DDRa1lMzi8whEzUoAWvZ/m4SALw7wTgAjMADoaEO5xP66ZDp3hgDA/Wf/hTkBfG/OCNw0axgyEiQdIYCJ
QwoxcUgh7jx3NF78Yid+/8Y6lNdwLXS38op2RwHjOTwErK6JoDIUQ1HAxQcMC/C3qaOFmzvszgmvbzlsToBWCtvxenTfim1MGV6EBb+ahdvmjEzY+Xfk93pw
9QmD8eWvT8Vp4/sY2EKymumTAY2YE2TSnAAJYOFBjgLojQFAR19sSnX9/+4dAty66t+3pg/Ef390HEryVBZSSEJulg/Pfu8YXHvSUB1bRnZiyfffwSGAAUB/
Lj0EW2NB6/X/jpwaAozQsh2PiP/jNieN7YU/XTMRAR2O7j6PwO+uGI9zp/TXoWVkNwLGLg8MQP+TAAtDwBcHeCeA3hgAdLKvRmLz/jbX9TtiCGi3HTee/Q/t
3QNP3DJV16FdIYCHrpmEI/rn6VYn2YfXjBTskhDwTUUEQT4YQFcuPAxbY+mOVK7/K5V1oxAg3Dn575eXjkNell/3erMzvPjdFeN1r5esZ/gIQCsXhIDmmMSK
apVlyCktLjwMW2PZToVvL0OAotYZ0G5yzMiehk7amz6qGLOO4qRAtzHlMkArF4SAZVUMAHpiANDJsp3pXP9XKnN/CHDj8P9Ns4Ybvo0bT+GEQDcy9m4Ai1YM
NSgELK/iREA9ufBQbI3lO1v+gyEgIeHC4f+Az4MTx/YyfDszjyhBfrb+lxjIWsZPA3BPCOAIgL5cdii2RnUjsL1C4tCO4aYQoDPThjtNNGNUMXpkGr+mlt/r
wXFHlBi+HTKX4ZcB9D4JsDAErK6OIMJ5gLphANDBil0SsmPHzRCgyG1n/wAwuFeOadsaYuK2yDxeYXAydkkIaIpKrK/lKIBeXHg4Nt/yTtf/GQIUCcDjwm9c
73zzHlJi5rbIPKaMjLkkBPAygH5ceDg239qylm8oQ0BCbpz9D8CU4f9WRtxmSNYTZi2M5YIQsJq3AuqGAUAHm8rbfDvdFAJ05saV/wDgYG2zadviA4Lcq+NT
Ig3j8BCwsY4BQC8MADrYuK/Dt5UhQJEbr/8DwL5q8zrlchO3ReYy7DKAlk7bhiFgY21UpWJKlUsPyeapCwL7ahVeYAhox7QhTgus3Fnjym2RuQzdP1wUArbU
RXkngE4YADTauE9CKjwEEABDQBtu7fwBYO3uGuw82Gj4dg7WNmPJ1krDt0PWEGAISCYEhGISOxo4CqAHBgCNNrde/0/qS959Q4Bbh/9bvbVkj+HbeGdZGaIx
nvq4meHzAFwSAjbyVkBduPywbLyN5W1+cFsI0JGbRwAAYMeBBkOnUIQiMTz83iYDt0B2YMoB2QUhYGMdRwD0YN79Sy619YDCBEClzq5duYgXKL035bKWutL6
rFJ5m/r04uLr/wGfB/defCRumDXM0O08MW8LdhxoMHQbqfB7PVj9xzko6hHo8r3ff2Yp/vn5Dl2227cwCyv/b3Zan5USqG4MobohjKqGEKrqQyivCeLrzRX4
amMFtpTX69JGLQzZT1I6LnTY/zUfo5KoL+k6RfyPKOLzAEg7BgCN9lQqdJYMAe24tfMf0DMbT9wyFROHFBq6ndU7a/DAG+sM3UaqZh3VO6nOHwDOn1qqWwDQ
QgigMCeAwpwAhuDwioqXzxwEID7HYuHGg3hu/nbMX7vfkjYatq+4LATsbmQA0AMDgEZl1Uijg239we4hQB9uDACzJ/TFw9dNQoHBD+c5WNuMqx7+Co3N9jrg
XXLswKTfO3N0CUryMnDAxPUS0lGcl4FzJvfHOZP7Y82uGvz9g814/evdCEfVZvkawyMAQ6Z6uCgE7G4092/iVgwAGu2uTLHT7VRu4xCgEzMDQMDnwdFDCjG6
Xx4GleQgP9sPv1cgGI7hYF0ztuyrx6qd1di4ty6t+n0egbsvGINbZ49UzUhrdtXA4xE4on+ehv8TYGt5Pa56+CvsrjT+DoNU5Gf7cdr4Pkm/3+sROGdyfzz5
8VYDW6WvsQPy8bfrJ+GOs0bh+n98jTW7zLv90iMEYtKIBADXhIA9DAC6YADQoLYpvg7AIW4LAToxOgD4vR7MObovLp0+EMcfUYLMgLfLz+yvCeKtJWV4acFO
LN9eldR2+hVm4fGbp2Dq8J6q73lu/nbc/a8V8Hs9+MNVE3DRMQPSGkx5a/EefP+ZZahtst/zz8+d0h8BX2rT1c6fWuqoANBqeJ8e+PBnJ+Kel1eZ1n7DJwK6
IASUB2MIxwA/p7FrwgCgwZ6qJK//d/MQYNSdTV6PwFXHD8btZ45EaVF2Sp/tlZ+J604eiutOHorP1x3Ab/+7Ft9sVr/H/rgjSvDojVNQkpeh+HpdUwR3PLsU
b3wTvx0wFInhO08sxhPztuBnF45N+jG+izZV4Jf/XoNFmypS+v8xUyrD/62mDu+J0qJs241mJCPg8+B3V4zH0UMKcetTS2DUyXkrU1YEdngIiElgXzCKAdld
h31SxwCgQVkVdLre3vqD+0KAEMbkiiMH5uOhayZi3MACzXUdd0QJZo4+AS8u2IGfvrgS9cHD9xgLAdw6eyR+euEY1Xu0V++swbWPLMK2/Z1n6S/dVoUL/u8L
lBZlY/aEPph5RAn6FWahb2EWhAD2VjVhb1UQCzcexLtLy0xZUEiLgcXZCUdA1AgRHzn42wfG3sq4q6IRmxQu7wgAWQEfinIDKMoJoCg3kPI995dOH4iyyib8
5vW1OrVWme4jZpqut8O2IWBPY4wBQCMGAA3KqoHWWakMAcqMGP6/4rhBeOCK8cjw67fzCxGfDX7MiJ74n78uwvqyWvTMzcAjN0zGSWN7qX7ulS934ofPLUdT
KPEkvd2VjXji4614woHD4G1dfOxA1TPURz7cjBtnDYNX5Y9+/rRSwwPAu0vL8LOXVnX5vrwsP44d2RMzR5fgvKml6FOQ3GOW7zhrFLaW1+OlhTu1NlWVIQMA
LgwBnAegHa+gaFBR3/rlFcqT5pIt61QuUvt8wjKR5Pu6KEuT3sOZPzxnNP787Ym6dv5tDe3dA2/dfTyuPWkoPr33ZNXOPxiK4vvPLMV3n1jSZefvJhcfO0D1
tac/2YqvEly6GD+oAEN79zCiWSmrbQrjgxX78POXV2HSXR/gzheWY09lU1KfffB/jjb8/8OwEJBsuWqZSPJ9OtanUl7RzACgFQOABjXtjhcMAUr0PJB9/4yR
uOvcI3SsUVlBth8PXDle9axw8756nPbrT21xb7uZJg0txDCVjm/Nrhps29+At5eUJazjvCn9jWiaJqFIDE9/sg3H3zMPH63c1+X7Az6P4d9DfUfOutj/1cpt
HgKqQgZPxugGGAA0qO50yddlIUAHeh3IzprYDz+5YKw+lWnw2le7MOv+T7But9IjIN3t4gST/95q6fjfWVqWcJLcBdPURxCsVtsUxpUPfYXH527p8r3nTy3F
mNJ8w9oidB8DcF8IqA5xBEArBgANqhqkwheVIaAtPS4BlBZl48/XTDRndrSKUCSGn764Erc8vhgNzd3vQSR+rwfnTy1Vff2txfG7H/ZWNWHJNvW7KUb1y8UR
pdrWRzBSTEr87KVV+LSLlQCFAO48d7Rh7dD1u57s/q9WbtMQUM0RAM0YADSoap2wzRCgSo/j2G8uPwr5Bq+4l0hFfQjnPPAZHkvizNCtThmnvvTvxr117RZW
entx4ssAFyQIEnYQkxI3PfpNlysXnnpUH2QlseZEOnTPui4MAVUcAdCMAUCD2rZ3bDEEKNJ6JnPsyGLMObqvPo1JUygcw6qd5q0EZ0eXJJj81/G6/9tLEweA
RCMJdlFZH8I/Ptyc8D0BnwfTRxUbsn1DRrtcFgKqmjkCoBUDgAbVHW/ZZgjQ3R1njrS6CehbmIlLpqe++I1b5Gf7cfoE9RD2Zsvwf6sdBxqwOkFgGlSSg6MN
foCSHp7+dGuXKzGefGRvk1qjExeFAI4AaMcAoEGj0gihm0KARlrPYgb0zMYJCe7BN9NVxw+2ugmWOWey+tK/2/c3KK6T/9aSPQrvPszulwGA+OqO7y7dm/A9
x49JboXHVOk2AKB1/7dxCGjsflNxdMcAoEEoojQJEAwBnWtOy4XTSlNerc0oE4cUYkivnK7f6EKJlv59S+W2v65uBzx3Sn/b/G0TWbDhQMLXBxUb853Q9Tfj
0hAQMuSRid0LA4AG4db1XxgClGk8ih0/xh5n/61OsFl7zDCgOBvTRqgv/at2pr9xb53ikryt+hZmJazXLhZuOJjw9ayA15iJgEYsB6xa5swQwCsA2jEAaBBq
OwTFENCJlmOYEMCkoUX6NEQnU4bbqz1muDjB0wz3VDZh2Tb1Jyl2NQrghMmAOw82IhxN3NMU5yo/IMp2XBYCOAKgHQOABqGO16AYAnTTvzAb2Rn2etDH8D65
VjfBdAkX/1mc+Dq/2uWBVudO6Q+f0c+K1kF1Q+KJgD0NCACG/VZcFALC3WcFbsMwAGgQUjouMAToom9Rcg9nMVP/oiyrm2CqiUMKMbyP+pr3XXXwq3ZWJ3y6
YVGPQNKPSbZSZX0o4etqEyRtyyUhgCMA2jnsm2sv4Sg0fKEdEgIs0iPTuoV/1PTI7F4Pz0z04J/ymiAWb1Ff8a/V213dDWDjpYFbhSKJTzUP1iVeMMhyRpwE
2CAENHMEQDMGAA0OBVCGAHIZn0ckvEb/9pIyxBIt+t/mfYmcMbGv7c+gC7KVV0BsdbCLFQNtwYUhIJrE948Ss/eeZ3O+tpeoXRkCrFMfTHzd1Qp1Td3nxuOT
x/VOeG27q4691eKtldhXHVR9PS/Lj1PG2XsxnYIc9QAQisS6XCzINlwWAvwedl9a8TeoQadH0jME6GZvpXqnYZWyquSeF+8Gie79r6wP4cuNiW+PayVl/AmB
idj5boCcDB9ys9Qv/dh++L8jF4WA7nVBzhj8HWoQ8AKdugSJzqPnSZWJ+NEyrc8qlYt4QdrtsdaeqkY0NkdtdSdAovva3SQvy4/ZE/qovl7UI4B9j5+n2/Zm
T+iL7AwvGm14UXfKsMS3fq7cUW3Idg3N3ykfE8ThFiV17OmqrE19abUnzuZXjhyBv0IN/F6NZ+5OGAmwiJTx4WM7+WazvdpjlLMn90NGp+Et42QFvDh9vLUP
fFJzbBcP+/l4dblJLdFA7zlBNhkJ8DvgFlK7YwDQIJ5AGQLUaJ2jM39N4meym62rZ8S7RaLhf6PY9TLA6ePVR0IAYN4qYwKA7vPbXBgC/Oy9NOOvUAP/oQso
DAFGeP3r3UnNNDfDkq1V2HGgwepmGK60KBvHjjTmEbeJnDKuN/Kz7XXr5wljemHsgHzV1zfvq0+4zoF9mLxOiEkhwMcBAM0YADTIaDeDgiFA7yp2VTTiU5uM
Ajz/2Tarm2CKi45VX/rXSAGfB3OOttdlgNvPSPwo6n8v2mXYtvWPve4LAX4HPEzK7hgANMjL7PgFdFkI0EqHOv/0zgbtlWhUVtWEV7807mBvJ5ckWPzHaHZ6
RPDNpw1PuEphZX0Ij3602cQW6cFdISDPzwCgFe8C0CA/BwqzVTXO5te7vk7lKd4doIEemeKrjRV4Z2kZzpzYT4fa0pPh82LcwHws2ar+4Bs3mDC4ECP6Jn7e
QVfL4iZS1CPxgjrHjemFnrkZqLD41roZo4pxz0VjE77nL+9uNHRdCF2vfGnZ/xOWWXt3QEGAAUArBgAN8luXhmcIUCUlNA8p//TFlZgxugQFFl0j7pkbwJt3
HY/7Xl2Nx+ZusaQNZujq7P+avy9KegEgJZkBLzb8+UzVWzt9HoFzJvfD059Yd7nl8pmD8IerJsDvVR8c3VcdxFOfbDW0HboPyLkwBBQEOICtFX+DGhTktPlB
7+F7l1wO0KOqPZVNuP2pJfrPjE5BwOfBry87Co/cMBk5Ge7LzV0t/dsUimqe8R4MRTFv1b6E77HqboBR/XLx+E1T8JdrJiZcmjgSk/jOE4sRDBm7ZoEh33W9
5wRZfDkgjyMAmjEAaJDf8eFwDAGdq9GpnneX7cX9r63WpzINLjpmAObecxKOKM2zuim6OunI3ijOU1/6d96qcjTp0Ol19QTBY0YUo1+hOU9dHFSSgyuPH4xn
v3cMPrv/FJyXRPj48Qsr8Pm6A4a3TRoyKQeuCgH5nAOgmftOZUyUn61Q6KbLATrQ80zmr+9vQsDnwd3nj9GvUgVVDSH89vW1+MFZo9GnoPNjiYf36YEPf3oi
fvyvFfjn5zsMbYtZuhr+f6uLp/ol66OV+xCKxFTPsoUAzpnSH//4ML0JdnOO7ochvZQfYewRQF62H4U5ARTnZaAwwRr/Sh6buwXPzjfn8oShT7p1yeWAQo4A
aMYAoEFBdocvaCuGgENi+laHP769AWVVTfjDlROQGdB/tbpNe+vw7b8twsa9dXhzcRkeuWEyThrbq9P7MgNe/PnbEzF9VDF++NxyXc6OrZKb5cPsCeq34IUi
MXy4IvHQfbLqgxF8umY/TkuwwM4FU0vTDgADi7MxsFgpmadPynj4/PV/1uhab8Jt6l1ZwuOCM0NALucAaMYAoEHvfKTwJe+eIcCIa5kvLdiJFTuq8dA1EzFh
cKEudcakxAuf7cA9L69CQ3N8dndFXTMu/dMC3Dp7JH564Rh4FGYzXnLsQIzpn49rH1mEbfvVFwoaUJyNORP6YuboEvQtzESfgiwIAZRXB1FW1YQvN1bg3WVl
2J6gDqOcPal/wjD1yZr9qA/qN+P9rSV7EgaAo4cUYnCvHEt+Fx3VNIZx61NL8N6yvaZt07Dr/y4LASWdbsOmVDEAaNC3te9hCFBl1MS9dbtrcfqv5uOK4wbh
jjNHYYCGs75P1+7Hb/+zFku3db7NT0rgofc2Ytn2Kjx64xSUKFwnP3JgPubdczLueHYp3vim/VD55KFF+NlFYzFDZU353vmZOGpQAWZP6Iv7LjkS32ypxK/+
vQYLNyT3tD09XDI98dK/by/WZ/i/1QfL9yESk/AlWMv9vCml+LPFa0As3HAQtz291PQVIPUeNTvEZSGgXzZHALTib1CDfoXpTHxx8MTANBh5LTMmJZ7/bDum
/eQjXPP3RXhv2d6knyhXVtWEx+duwSn3f4KLH1yg2Pm39fm6A5h1/yf4enOF4uu5WT48cfNUPHj10Qj4POiR6cNjN03Buz85QbXzVzJlWBHeuPM4PPPdaabc
9ti/KAvTEyz9G47G8P5yfc9+qxpCWLA+8UQ6KxcF+mR1Oc5+4DOc+/vPLVn+2fDr/wnLnDMxsH+2fZ4U6lQcAdCgf8fRZ7eNBOgkJuMTsIwSjsbw9pIyvL2k
DAGfB+MHF+CI/nkY0DMHRT0C8HsFGkNRHKxrxpZ99Vi1sxqb99WnvJ2yqiac+8DnuPuCMbh19kjF9Q2uPmEwJg0thNcjMLp/+ncKnDmxH8aU5uPKh77ERgMf
Q3zxMYmX/v1i3QFUN4Z13+7bS8pwwpjOcytaHVGah9H98rC+rFb3bSvZUl6Pz9YewD8/344VBj3iN1mG3+7qkpEAjgBoxwCgQXYGUJANVLd9HoirQoA+pM7z
ChIJRWL4ZnOlYY/ujcQkfvnaGnyzuRIPXzdJ8Sw90QNkUjGkVw7euPM4nParT7GrwpiHzlzUxZP/urptL13vLC3DA1eOV5xX0er8aaX47etrddtmbVMYFXUh
VNQ3o6IuhPLqIL7eXIHP1h3A3qom3bajle4PwErrmGLvEJDt41LAemAA0KhfYYcAALgoBOgjJgG3Dda9v3wvTr73Yzxxy1RMHKLPREQlxXkZeP7WY3DGb+cn
fXkjFTN/Plf3OpNxoLYZva//b8qf21vVhJLrXjegRfZhyBwAl4WAfhz+1wXHUDTqV5jCNXjFcrvPCdDO0GuaFtpV0Yizf/cZHjd4+gLvngAAIABJREFUeeCx
A/Jx93nGrn1A9mDt9X+lMnvOCeifxa5LD/wtajSodf4UQ4AqtwYAIH7J4ScvrsSP/7nC0O1ce/JQDO6V0/UbydEM31dcEgJKc9h16YG/RY1G9O3iC+3kEKAT
KU2Y2GQxvRef6Sjg8+DW2YmfT0/OFzVjP3FBCBiex0sAemAA0GhEx/VMGAIUuXkUAADOmtTfhG30g9fI2ynIcoZMAEy23EEhYHg+A4AeGAA0GtZboNMsFYaA
Tkw5s7HI2AH5ho8AAEBRjwAmDysyfDtkDSkN2gVdGAJGcgRAFwwAGg3v0/pLZAhIJGbY8mbWGzdQn9v+kttWgWnbInMZF5JV9n/AsSGAlwD0wQCgUVYAKO2J
li8pQ4CamFFnNzbQp8Ccx9cC8aWDyZ2MHSVzTwgozvSgMIOXwvTAAKCD4X3a7gwMAWrcOgpQnJvaY2W16J3f+VkE5A66X//vxB0hYHgeuy298Depg1H92vzA
EKAq6tIAUNek35PyulJjwLK8ZL2YjM8BMH6YzPkhYCQnAOqGAUAHEwZ1KHBTCNCRWycCltcEXbktMk+7fUPv/STZ/V9t2zYLAUcVcQFbvTAA6GD8YIXrUQwB
nauW7rwd0Mzn1pu5LTJPtOOOwRCg+r7xDAC6YQDQwZEDAJ/Sb5IhoBM3XgZYuPEg6oPGXwYIRWKYv26/4dshc6kGY4YAxfdN6MkAoBcGAB1kBYBRfZHgC80Q
0MqNASAUieHj1eWGb+eL9QdMnW9A5ogk2tcYAtqVDezhQc9M3gGgFwYAnUwY0tUX34EhwACHJju5zCMfbDb8/+sfHxn70CGyRqfh/44YAg6V8exfXwwAOhnf
diIgQ0BCEReOAizeWon3l+81rP4FGw7iExNGGchcsWTnxWjZN7Xu/zYKAbz+ry8GAJ1MHNKhgCFAlRsvAwDAL15ZhWoDbtNraI7gxy8Y+7RBskZKd8YwBGA8
RwB0xQCgk6kjBHwdb09lCFCU9FmPw2zb34AbHvkaER3/56QEbntqKdaX1epWJ9lHyt+Vbh4CpvViANATA4BOcjKAowYovMAQoMitowCfrt2P259aipAO1zki
MYk7X1iONxfv0aFlZDfRdOfDdNMQMCTXg/457LL0xN+mjqaPTrUjtnkIMJAb5wG0euXLnTj/D1/gQG1z2nVU1odw6R8X4JlPt+nYMrITTftANwwBM3r7VRpE
6WIA0NGMUS3/4ZoQYBwp3TsKAABfb67A9J9+hIfe24jmcDTpz4WjMTw3fztm/nwuPlt3wMAWkpXi33+NO1yyHzdiJNCCEDCjD4f/9cbfqI5mjBY49K2V6HwS
rVrW5nOpfFaxXMSPLklvO8ntGCASA7wujqDVjWH88rU1eHLeVpw/tRRzju6LycOK4PW0/wXHpMTSbVV4f9levP71buw82GhRi8ksuo2AJbu/pr3/qxxP0q6z
5ViXxmdn9OEIgN4YAHTUvwgYWAzsPNhSwBCQUDTW0kyXr+tRVtWEv32wCX/7YBMy/F70KchEn4JMCAGUVwextzqIYCj5UQJyvoiei0akMhLg0BBQkCEwppAP
AdIbA4DOZowW2PlFGh25HUOACSIxwN+N9uvmcBQ7DjRgxwGu6d9dRWJpTv5LxOUh4NjePniUtk2auHgA1hqzxikUOnVOgAkiUcs2TWSJsNX3wDpwTsBJ/Tn8
bwQGAJ2dOl4oj54zBCiSiIcAou7AkLP/rug6MdiaEHBaaUBlo6QFA4DO+hcBY0qhw05nfQgIqixqlxXQd8zezbcEErVl1Hc9U+U6WvwhlSauE2JACOiT7cG4
nt3oOqGJGAAMcNp4vVa/sjYE1KhMRi/I0Xc4zu23BBIB8e94zKDT/x5ZytO56kKt23NuCDit1G/WjUndDgOAAU4d3+YHB4eAinrlaof27qGywfRxFIDczshr
/yV5GYrltaG223RmCDh1AK//G4UBwADHjxHIbPuddWgIWLtHudKjBuarbCx90RgnA5J7RWLGPv9iRB/lUL6ztmOydlYIEABmlTIAGIUBwABZAeC4MWl22jYK
Aat2KVc4bUSR7vMAAAsmRxGZxOiZ/0f0z1Ms31illKydEwLGF/vQO4vdlFH4mzXIuVME0u60bRICvtwkFTvlrIAXJ4wpUdlQ+qy+O4rICGGDZ/7nZftx1MAC
xdfWV7bcYuPQEHDOIJ79G4kBwCDnTmn95To3BOyrAZbvVK7siuMGqmxEAwYAchkpgXDU2C/28aOL4fN2niYXjgGL9ra5x9aBIeDcobz9z0gMAAbpVwQcMxLa
Om0bhIB3lytXdNbEvuiVrzzxiIjiQiZMbr1wWqli+Tf7Im3uAmjhoBAwJM+Do4u5WK2RGAAMdN60thNanBkCnvtC+TJAwOfB988YobKBNPFeH3KRqNThiX9d
yMv246xJ/RRf+3B7RFunbXEIuIBn/4ZjADDQhce0+cGhIWDzPon565Uruf6Uoeidn6mygdRxrW9yEzOe73TDyUORrTIh96V1ofh/ODQEMAAYjwHAQIN7ARMG
tylwaAh46APlccycDC9+c9mRKpWnjgGA3CIcBaTBt7X0yPTh1tnDFV/7am8kfgdAK4eFgP45HkzrzeF/ozEAGOz8Y5Q6aGeFgDeXSqxQmQx46fQBOHGs9jsC
vPwmkkvEpDkP/PnxuaNVFwB6dHmoc6GDQsC5Q/w8ITABD7sGu/J4hefdOywESAnc+2/lUQAhgCdumoyeudqG63z8JpJLmDH0f+SAfHxP5ex/W00M/1ob0r/T
NjEEfGskJxibgYddgw3uBcwcpfCCw0LAm0sl3l+h/OG+hZl46pbJirciJUOAIwDkDuGocev9t8rJ8OHZ70yBX2Wn+d1XQYRb87oDQ8DQPA9m9OXwvxl42DXB
FSck2pmcEwJufz6m+oTAWeN648Grxyu/2AU/93VyATOG/j1C4G/XTcRolZX/lpZH8dSqDsP/DgsBV43O4A1BJmEAMMHF0xF/NoDDQ8Dmcok7X1S/sfn6k4fg
vkvGqr6uxCM4/E/OJwE0mzD0/5vLxuHiY5Tv+49J4LsfNiEqFbpPh4QAAeCKUZz9bxYeek1QkAOcPaXlB4eHgL/PjeGdZepnOT88eyR+c9mRnec9qAjw7J9c
IGTwrH+PEPj9FUepzvoHgD983Rxf+U9p/wccEQKO7ePD8Hz9nzNCyhgATHL1iW12SAeHACmBbz8WxcZ96ge7288YgRdunYacjMQ7csDHW//I+cIxYxf86ZHp
w7PfnYrvnq7e+S/YE8XPPw8eLnBoCLhyNCf/mUlIo29WJQBAJAoMvklib1WbQqXOTwCd9gzV9yVblmZ9iuXxcbrhvQUW/MKL4lyVzwFYt6cW1z2yBCt2VHd6
LeAFfAz65HBRCTRHjDuEThhcgGe+M1X1cb8AsKs2hukvNGBPvcLlOaX9/1B5OmVSh+ORcn0ZXqDsukIUZvCswCwcATCJzwtcN6tDoYNHAjaXS5z9YBQ1jSqf
Q/wRpfPvOwH3XDQGPTIPj/X72fmTC0gJhCLG1J2f7ccfrhyPz+49KWHnXxmUmPNqA/bUKT32F44aCbh4eICdv8k4AmCiskpg6M0S4Y6ThRw8EjBlqMB7d3pR
mKPyuRblNUH8/o0NeGnhToTCBh01iUwUjOh/y19JXgZuOXUYbj51GPKzEz8Kt6JJ4qzXGrCorMMBJdn9P+F7uyrTfyRgwSV5OLYPJwWZiQHAZBf9QeL1rxRe
cHAIGD9Q4L8/8GJgT5XPtVHbFMarX+7Cu0vL8OXGCoSjJjwujUhnzVH9rvv3KcjESWN74cJppZg1rpfq/f1t7ayNYc7LDVhXEdO2/yd8b1dl+oWACSVeLL1M
+dZGMg4DgMnmrQROu0/lV+7gENA7H3jtdi+mj0h+CK8+GMGaXTVYs7sG+6qDqG0MMxCQ7UVi8Vvu0uH1CORm+VCSm4GhvXvgiP65GNZbfYhfyfydEVzxZhPK
2l7zd3gIeOyUbFw/lhMAzcYAYDIpgbG3SWwoU3mDg0NAwAf84gIPfnSmhyv7EeksKoHfLGzG/Z8HobjkgENDQEGmwK5r85Hj5/V/s/EwbTIhgJtOT7wYhnKZ
/ScGhiLAT1+J4YRfRrFmD3MlkV6+3BPFlKfr8YvPgohK6L//p/z5trRNDLx6dICdv0U4AmCBmkZg8I0StY1I4SwbjhkJAOKz/D+4y4sTj+COTZSudQdj+O3C
ZvxrTRjxAX+D939NdaY+EuARwJqr8jCqkLcFWYEjABbIzwZuPK3lBxeOBAgBPHi5h50/URpiEpi7LYJL/tOEcY/X44XV4ficAzP2f011pj4ScM5QPzt/C3EE
wCJllcDwWySaWx+u45KRACGAv/6PwM2zmC2JklUfkvh8VxRzt0Xwytow9tS17I9W7f+a6kx+JOCLS3MxnU/+swx/8xbpVwRcfjzw9LyWAgnlnUapXCLe07bd
aVXfZ3B9bcqFAB662oObZ/HMn5yvolFiZXksfs1di0O7Q7yiqiaJ+hCwpz6GDQdj2FARw4ryaPwRvkbvr8nWp6lOEZ/t3MX7juvvY+dvMY4AWGjDHuDI22T7
W4ocOhIgBPDwtz24hZ0/ucDbGyK45JUgmiOAbN0ndLjdzS77qx1GAt48twfOGpJ4sSMyFsdpLTSqP3D21A6FDpwT4BHAP65j50/u8NzyCC58Md75A4Bo3Sc0
71vx2rR9vmOZM+cEjO3pxZns/C3HAGCxO89PcodTK7c4BHgE8Oj1Hlx/Ejt/cr7fzA/h2teDiMTQ7nvPEJBuncoh4H8nZqoOdpJ5GAAsdsxI4LQJCi84IAR4
ADx2gwfXnshdmZwtGgNufqMZP58XQruLogwBOtTZPgQMyffg8tEBhQ+S2RgAbOD+y4VyGrZxCPAI4PEbPbjmBHb+5Gw1QYlz/tmExxeHu9w/GALSrfNwCPj5
tCwEeOefLTAA2MCU4cDZU5D8zqVWblII8AjgyZs8+DY7f3K4zRUxzHy8Ce9vbLO4LkOAen2a6hQYWeDFlUfw7N8uGABs4r7LBDwKd+IAKmVq5QYfBLwe4Kmb
PLj6eHb+5GzvbYxi2iNNWFuu8AAqhgD1+jTUed/0LPjY69gG/xQ2cdRg4KIZLT/YNAR4PfEz/6vY+ZODRWPAfR+HcM7zTagOtnzh09g/GAJSq/PIYi8uHsmZ
/3bCAGAj910m4Gu9NmazEOAVwFM3s/MnZ9vfIHHGs024f16o8yN9GQJSqy/Fz/9yRmZ8lJNsgwHARkb2A65qO6PeJiHA6wGe+67Alcdx7yXnendDFBMeasLc
zS1D/rp02gwByXx+ah8vzhnGs3+7YQCwmV9fCeRl2ycEeD3A07cIfGsGO39ypsYw8P23Qzjn+SDK61u/6Hp22gwBXX3+Dydk8b5/G2IAsJneBcCd5wPtdjKL
QoDPC7zwPYEreOZPDjV/WxTj/9KIhxeGITvN9WMI0K2+BO+9ZJQfx5VyzX874rMAbCgYAsbeCmzfD3Q4wnRm0LMDvB7g2e8KXDaTnT85T3VQ4t6PwvjbV+HO
1/qV1qxXLE+vjM8OOPyfmT6BtdfkYnA+zzXtiH8VG8oMAL+7uvUn80cC/B7gxdvZ+ZMzvbIygrF/bMLDC8OIKdzh1/k7z5EA3err8N47JmWw87cxjgDY2Ik/
AT5f1/qTOSMBXg/w3K285k/Os/5ADHf8f3t3HiZVfed7/P2tXqvZRVbZVEBAQUBRJiIkLpi4ICiaa4xCVDLGSTIZEnWem3FyZ55MMlEczXKNZkyuTpIhrpjF
IELQcQFxQ0DZNCKLIIqCKDY03fW7f1R3W3SdbqpOnapzqurzeh6f0N+u+tU3fbrr96nf2f7YwOOvN6V/M6O/Ga0EBDYe0KeTseGarnSt1ntJVCmaRdi8q1I3
UP5XAqoq4P65mvyluOza57huwQHG3FbP4xubcvhUq5WAwMYDvn96XJN/xCkARNjJQ+G6c/F+Ewg4BFRVwH3/YEw/RX+wUhz2NcDNTx5k+M313LWikabU5X6F
gAxq+RvvlH4VfOUEXfI36rQLIOI+3g+fuQFe24L3cmAAuwOqKx33zTWmTdDk32L1VseStY5Xtjpef8dRfxDqG2BgTxjWx5g0zJg2NkaX2rA7LT/1B+E/VzTy
g6UHeW9fy9+CC2zpvnW8nJ5Pm7xePrsDqmKOF67swpheuuNP1CkAFIG/vgOTboR3PyTwEFBdCQ98xzj/5JzbLHqfNMCdTyT41VMJ1u3o4A27uV5bBReOizF3
aoyThyg85dve/Y47n2vk9qcak+fze03aCgE+a8GNd8OpNfz7FCXjYqAAUCTWbYOp/wzbPyCwENAlDvd/25g6NrA2i5Jz8IsnE/zLI03s/MjjARmEqSnHwbfP
qeDc0TFMWSBQ2/c67ljWyM+XN7Kn/nATlUKA/1ru4x3dLcaaqzpTV6U/gmKgAFBEtn8AX5oHT68l5xAwYgDMn2uMGRxsj8Vmy/uOq3/VxNK1QayoOIb1MeZM
jjH7tAp6dg6uz3L03OYEP3nmIA+vaeJgy4H9mU7aCgE+a7mN9+jMTnzhGF30p1goABSZpgTcuRC+Nx9272upZj551dXAN86Fmy414mV+jM7Lmx0X/Ecj7+z1
+GYOIQCS13K4+KQYV0+KcfrwmG6CkqHd9Y75Lzfxq+cbWfl2IrdJWyHAZ83feJeOrOJ30+o8HixRpQBQpPZ+AncvhnuXwqtb4HAhYMCRMPsM+NupRv8jCtVl
dD25zjH9x018tB9ynizS6oe+uQ86wrh8YozLJ8YY2U9JoK2DTbBkYxO/fbmJBWsa2d/Y5gEKARnUwg0BR8aN1Vd3oW8n/X4XEwWAEvDGDnhuA6ze7Ni5J3kp
YQwG9ITh/Y3PjIATBqF9082eWOuYdnsTnzSkVvMbAlqMPsq46KQYM8bHGH1U+W6QxgQ8/WaC+1Y28fCaJt7P59H8CgE+a5mPd9+FdVwyQnf7KzYKAFJWnljr
mHZb8+Qf9GSRVu/4zX1Yb+O8MTHOOd44fXiMeIm/f+6udyxal+CPa5tYtC7B7vp2ft4KATmMV/gQcNnxVfz2Ai39FyMFACkbS9c6LrytzSf/kENAi3gVTB4e
47MjjElDY0wYYlQV+WnUnzTAsk0Jlr7exBOvJ3hpW4Imr3cbhYCiDQH9O8dYfU1njqgt39WsYqYAIGVh6WvNk/9Bj29GJASk1uqqYcKQGKcebZw8xBg/yDj6
yOi+ySYcvLHL8cLmBCs2J1jxVoJV2xMc9LoZT8Y/R4UA/+PlPwQY8KdLddR/MVMAkJLXOvm3fPIvxGSRVs/9zf2IzsljCEb0NUb1M0b2NY7uZQzsUbjVggON
sOl9x5u7HK+/l2DtO47V2x2v7Uiwr3W3SoCTj0JADuPlNwR8dVw1d34+7vFgKRYKAFLSlr6W3Odf39DmG0UaArze3CsqoH83Y3BPo29X6NPV6NXF6NUZjuhk
1FUnVxS6xZNPNoPuze/bBxqTS/UOx4f1ycsd76l37KlP/u+7Hzm27YGdex1b9zh27HUknFc/bWsKAX5rxRACjusZ44XZnemsm/0UNQUAKVlPrXecP6+JfQcI
b7JIq+fpzT2QHqNxPrlCQLRDQG2l49krOzOuT5EfpCK6G6CUpkMmf8jt7nC+n+tVtxz7Sa1Zho87TK21bh41n2MGPV6u2yHo7eo1Xk7PP7QW5bsI3n52XJN/
iVAAkJLz1HrH+bekTP4tFALar7XWFQIUAtqvXTqyiq+OLfNLiJYQBQApKUtedZx7c4J9ByI2WaTVFQJ81RQCchgvt+0wtEeMX5yru/yVEgUAKRlL1jim/0ci
5YC/iE0WaXWFAF+1qG3XMggBNTG476I4XWt00F8pUQCQktA6+bdd9o/aZJFWVwjwVYvadi3xEPDTz2u/fylSAJCil/bJvxBv7goBPmsKAX5rYYWAb51SzTVj
S/w61WVKpwFKUUtf9k9RiFO/dIqgz1qA40Vtu5bQKYJnHl3JwsvqqNRHxZKkzSpFa8kax/RbvZb9m2klIPfxWutaCSi3lYAh3WPMnxHX5F/CtGmlKC1e3Tz5
t7vs3149YpNFWl0hwFctatu1yENA52rj95fWcWSdDvorZQoAUnQWr3bMuNVj2V8hwKOmEKAQkEnt0+0QM7h3WpzRvTU9lDptYSkqh0z+vidYiNxkkVZXCPBV
i9p2LcIQMO/sWmaM0B3+yoECgBSN5OTvDv3krxCQYU0hQCHg8LW5E2v41qm60l+50FkAUhQWr3ZMv8Wx/yC5HUXuWXfROoo8re5y7Ce15jJ83GFqrXWf20Fn
BxDsdoVczw64ZFQl8y+OE9Nu/7KhFQCJvEMmf8jtE6NnPWKfGNPqWgnwVYvado3wSsDkwRXcO12Tf7lRAJBIW7zaMf3mlMm/hUJA9mMqBAQwZumFgFG9Yiz4
Ypxa7fYvOwoAElmLXkmZ/IOeLDzrEZss0uoKAb5qUduuEQoBg7vFWHh5HT1q9dG/HCkASCQtXuW4aF6bT/4KASgE+Bwvats1AiGgTyfjsSviDOyqyb9cKQBI
5Dy+qvmTv9flfRUCUAjwOV7UtmuIIaBXnfGXWXUc11NTQDnT1pdIeXyVY8bNbQ/4a0MhAIUAn+NFbbuGEAK61xoLvxxnVC+9/Zc7/QZIZDz+imPGj9o74C/T
mkKAv5pCQDmEgK41xqIr4ozvp1v7igKARMRjK1M++Yc1WXjWIzZZpNUVAnzVorZdCxACutQYj14eZ8JRmvwlSQFAQvfYSrj4FjI84C/TmkKAv5pCQCmGgCPi
xuJZcU4bpMlfPqUrAUqolm+Eqf/i+OQA0bqynGfdRevKcml1l2M/qTWX4eMOU2utR2S7evYYse3qNV4Oz+/dyXhsVpwT++rznhxKAUBC8+ZOOPl6x4efpBSj
NFl41iM2WaTVFQJ81aK2XQMKAQO6GotmxxlxpCZ/SaffCglFwsGVP24z+UO0lo096xFbNk6ra3eAr1rUtmsAuwOO6RHjyas1+Uv79Jshobj3CVi+gcK8uSsE
ZD+mQkAAY4YXAkb1Tk7+R/fQW7y0T7sApOAam2DodY6tu1KKhVjm1e6A7MfU7oAAxizs7oApQyp46Eu19Ii3twFEkhQPpeD++AKHTv6glYAgxkurayXAVy1q
2zWLlYAvjq5k4ay4Jn/JiAKAFNzvnmlnZlAIyH28tLpCgK9a1LZrBiHgG39TxW8uqaVGd/WTDCkASEElHCxZRRYTLNGaLDzrEZss0uoKAb5qUduu7YSACoOf
XVDD7efVENMHf8mCAoAU1KadsGdf8xcKAfkbL62uEOCrFrXt2qbWpSbGw5fX8rVTqzweLNIxBQApqDd2tCkoBORvvLS6QoCvWtS2a3NtWM8Yy66t5fwRWvMX
fxQApKB2f+xRVAjI33hpdYUAX7WIbdepQytY/rVaRvXWW7j4p98eKaimBDlOsERrsvCsR2uySK8rBPiqRWC7msH1k6v40yyd5ie509qRFFS3uuZ/ONLPY/aq
dfhY45B3y0zHDHo8z7qBczn2E+B4afXm/685/8xSxvL1XK96RLarZ4/hbde6Srh7Zg1fHKO3bQmGfpOkoAb3TvlCISDLmkKA/x6LOwSM6BVj/pdqGKMb+kiA
9NskBXXcUVBT5WPZuMPHRmTZ2LMe/rJxx3XtDvBVK+B2vWJ8JSu+XqvJXwKn3ygpqOpKOH0U+JosOnxsRCYLz7pCQNa11npEtmsIISBeBbddUM09l9TQuVr7
+yV4CgBScDMmtvxLIUAhoINaaz0i27WAIWB03xgvfjPON0/T+f2SP7oZkBTcR/Uw6GrYW99SSfkV9Pqg096Hn3Yf6zJ8XJ7H86x77OvNup8Ax0uruxz7Sa35
2K4d9hiR7erZYzDb1QyunVjJLedVE9fcL3mmFQApuC5x+NY0cvvE2OFjI/KJ0bOulYCsa631iGzXPK0E9Oti/GF2DT+brslfCsOcc41ARdiNSHnZtx9O+AZs
eY/cPjF2+NiIfGL0rGslIOtaaz0i2zXAlYCvTKjk1guq6Farff1SME0xYH/YXUj56VQLv54LFTG0EuC7nwDHS6trJcBXLcvt0KezsWBWDXdfUq3JXwrtgAKA
hGbSSPjJV5u/UAjw2U+A46XVFQJ81TLcDpePr+TV79Qy7XgtwEooDlSiACAhuvbzUH8Abrgneavg5Puw0fpu2VpL4VXr8LEp42UzZtDjedYNXSzIb48R2a6e
Pba/HYb1Mu64qJozhmnil1AdMOfc68DQsDuR8vbwcpjzs+ZbBeuYAJ/9BDheWt3l2A+5bdcSOCagphJuPKOKG8+oolbXYJXwbTHn3HJg4mEfKpJnW3fB1++C
P72AQoDvfgIcL62uEOCrhuNzwyq44+JqhvfSfn6JjI3mnHsEuDDsTkRaLH4F5v4S1m5rqSgEKAR0UGutR2S7ptQGdDN+eH4Vl42rSO5hEImOFTFgZ9hdiKQ6
eyysvB3u/Br06gqHvKPqwMAsazmOl1a3HPtJrfnYrkVyYGBdNdxwRiWv3ljLl8Zr8pdIel8BQCKpsgLmTIV1/xe+cR5UVigEKAR0UGuth7tdzWDmiRW8ekMt
Pzyvii417fQqEr5dMWB72F2ItKdHZ7j9Glh5G0yfaJ9+klIIyLKmEOC/ltl4U4bGeGFuDffNqmZwD33kl8j7wJxzZwGLw+5EJBOr34J/e8Dx4LLmgo4JyLKm
YwL817zHG9M/xnfPrmTmWJ3WJ0Xln8w5NwjYHHYnItlYth7+6TeO/3kNhYCsawoB/mufjndCvxg3nVPJxSdqH78Upa+acy4GfAzEw+5GJFuPvgj/ep/jxb96
fFMhIH/jpdXLJwQc39f43hcquWiMJn4pamcn/zycWwOcEHIzIr79ZRX8+wLH0tVtvqEQkL/x0uqlHQLGD4hx/VkVzBxbQUwTvxS/oS0BYD7wv0JuRiRnr2y5
5OJfAAAMIUlEQVSC2/7gmP80NCWaiwoB+RsvrV56IeC0Y2PccGYl5x0f0yd+KRVNQF1LAPg2MC/cfkSCs/5tmPeIY/5TsP8gCgH5HC+tXvwhoKoCLhlXwXfO
rOTEozTrS8nZamaDWgLAFODJcPsRCd6uvfDLJXDXIsfmXe08SCEg9/HS6sUZAnp1NmZPrOC6yRUM0ql8UrqeMrMpLQGgM/AhEAu3J5H8SDh4Yg38+FHHn18C
1/ZcboWA3MdLqxdPCDhpUIw5p1Xw5VMqiFe18zyR0vFzM7vu0z8v59YCI0NsSKQg1r8Ndy5KHiewa2/KNxQCch8vrR7dENCl1nH5hAquPb2S0f31aV/Kyt+Z
2R2pAeAXwJwQGxIpqIZGWPgy3POEY+FLcLAJhYAgxkurRycExAw+OzzGFafEmDG2gi61Ho8XKX2TzOzZ1ABwGfDfITYkEprdH8ODy+HXTzqe3eDxAIWA7MZL
q4cbAkb1N2aOi3HlxAqO7qlP+1LWHHCEme1JDQB9gB20/+csUhbWboOHlsPDzzlWb075hkJAduOl1QsbAob0NC4aF+PyUyoYO0BvayLNNpvZEGj75+nca8Co
EBoSiaS33oU/vAAPLncs29Ay7SgERDUEHHOkcd7oGDPHxzjtWJ23L+JhgZldBOkB4KfA10NpSSTiNr0Lv38eHnvF8fRal7y+QAuFgPZrafXgQkBFzDFhcIwL
x8aYMS7GsN6a8UUO43ozmwfpAeAc4LFQWhIpIvUNsGy9Y8ka+Mtqx0tvohDQUS2t7j8E9OtmnDnSuGBMjDNGxDiiUzuvJyJeJprZCkgPAFXAu0D3MLoSKVab
3k0GgafXw9PrHJvfS/mmQkA79cxCQK8ucNrQGGePinHWSGOoPuWL+FUPdDezBvD6E3fud8AXC92VSCnZsRueXe94Zj0s2+BY+VbyYkSHUAjAKwQc08v4zFBj
0tAYnxlqjOpn2pcvEoynzGxKyxdeAUCnA4oEbM8+eOUtx8pN8PKm5L83bIemtqEAyiYExCw52Z84EE4caIwfbEw8NkaPunaeIyK5+oGZfbflC68A0A14B9Al
MkTy6JMDsGozrNrsWP82bNgBr+9I7j5I3siwdEJAv24wrI8xop8xdpAxZqAxeoDRucbj8SKSL1PM7KmWLzzfPpxzDwIXF6wlEWl14CBs3AEbdzg27oAtuxxv
74atu2D7Hseuj9o8IQIhoEstHNXD6NMNju1tDO1jDO396b810YuEbi9wpJm1nr/UXgCYDiwoVFcikrn6Btj2AWz/wLHzQ9i9Dz7YB7v3ueS/P07WPt4P+xuh
viG5kvDhJ5BwjsYEfLQ/OVb3OpL715vfCWqrIF5tVFVA1zh0rUs+plud0bU2+XWPOujX3ejXHfp0S078ddXh/CxEJGMPmdnM1EJ7AaAa2A70LERXIiIikldz
zOzu1ILn7X+bTxG4vyAtiYiISD45PK7x4xkAmt2Tt1ZERESkUFaY2ba2xXYDgJk9D7yY15ZEREQk3+7zKna0AgBwRx4aERERkcJwwMNe3+jw+lrOuTiwFR0M
KCIiUoyeNrPJXt/ocAXAzOrRsQAiIiLF6oH2vnHYK2w75wYCfwWqguxIRERE8qoBGGBm73l983DHAGBmW2nnAAIRERGJrIfbm/whgwDQ7Ae0XJ5cREREisHd
HX0zowBgZuuAPwfSjoiIiOTbJuCJjh6Q6QoAwI9y60VEREQK5G4z63Dl/rAHAaZyzi0CpubUkoiIiORTPTC4o/3/kN0KAMD/Ju0m5SIiIhIh9xxu8ocsA4CZ
vQQ84rslERERyacE8JNMHpjtCgDATeiMABERkSh6xMzWZ/LArAOAmb0G/DrrlkRERCTfbs30gVkdBNjCOdcH2Ah09fN8ERERCdxiM8v4QH0/uwAws53otEAR
EZEo+edsHuxrBQDAOVcNvAYM9TuGiIiIBOJRMzs/myf4WgEAMLMG4Ea/zxcREZFAOOB72T7JdwAAMLOHgYW5jCEiIiI5eaj5NP2s+N4F0MI5N4jkroDOuY4l
IiIiWWkAjjezN7J9Yk4rAABmtgX4P7mOIyIiIlm71c/kDwGsAAA45yqAFcBJQYwnIiIih7UTGG5me/08OecVAAAzawLmAAeDGE9EREQO6x/9Tv4QUAAAMLOV
wL8GNZ6IiIi06xngv3IZIJBdAC2cczHgSeD0IMcVERGRVgeA8Wa2NpdBAlsBADCzBDAb+CjIcUVERKTVv+U6+UPAKwAtnHPXAj/Px9giIiJl7FXgpOaL8eUk
LwEAwDn3ADAzX+OLiIiUmUZgkpmtCGKwQHcBtHEVsCGP44uIiJST7wc1+UMeVwAAnHNjgOeAeD5fR0REpMQ9C3zWzBqDGjCfKwCY2Wpgbj5fQ0REpMR9DMwO
cvKHPAcAADO7E7g3368jIiJSor7m93K/HcnrLoAWzrlqYAm6PoCIiEg27jKza/MxcEECAIBzri/wPDCwUK8pIiJSxJ4HJpvZgXwMXrAAAOCcG0fy8oV1hXxd
ERGRIvM+cLKZvZWvF8j7MQCpmu8XMBtIFPJ1RUREikgjcGk+J38ocAAAMLMHgK8X+nVFRESKxD+Y2dJ8v0jBAwCAmf0cuCWM1xYREYmweWb2s0K8UEGPAUjl
nDPg/wGzwupBREQkQv4ETDezpkK8WGgBAFpPD/wDcE6YfYiIiITsOeAMM6sv1AuGGgAAnHNxkqnnjLB7ERERCcEGYIqZ7Szki4YeAACcc3XAo8BnQ25FRESk
kN4gea7/jkK/cCQCAIBzrhOwEF0tUEREysM24PR8n+7XnlDOAvBiZvuAaST3g4iIiJSyHST3+b8VVgORCQAAZrYHOAtYHHYvIiIiebKF5D7/18NsIlIBAFpX
Ai4AFoTdi4iISMA2AZ8Le/KHCAYAgOYbH1wK/FfYvYiIiARkHTDJzN4MuxGIaAAAMLNG4Crgp2H3IiIikqPnSB7wtz3sRlpENgAAmFmTmX0T+BZQkCsjiYiI
BGwBcKaZvR92I6kicxrg4TjnpgO/RbcSFhGR4vETkjf3idxdcIsmAAA45yaQvHRw37B7ERER6UAj8PdmdkfYjbSnqAIAgHOuP/Ag8Ddh9yIiIuJhF3CZmS0J
u5GORPoYAC/NB1BMBn4Udi8iIiJtvAxMiPrkD0W4ApDKOXcFcBcQD7sXEREpe78G/raQd/TLRVEHAADn3MnAfwPDwu5FRETK0l7g78zsN2E3ko2i2wXQlpm9
CJxI8khLERGRQloBnFRskz+UQAAAMLN6M/t7YCawO+x+RESk5DWSPBbtdDN7I+xm/Cj6XQBtOeeGAL8CPhdyKyIiUppeBa4xsxVhN5KLklgBSNV8a8UzgVnA
B+F2IyIiJeQgyU/9Jxf75A8luAKQyjnXD7gDmB52LyIiUtSWAXPMbG3YjQSl5FYAUpnZDjObAXwZ2BF2PyIiUnR2AF8hua+/ZCZ/KPEVgFTOuU7A9cA/AjUh
tyMiItHWANwJ3GRme8NuJh/KJgC0cM4NB24HvhB2LyIiEjkOeAi40czeDLuZfCrpXQBezGyjmZ0LnAusDLsfERGJjEdJXsb3klKf/KEMVwBSOecMOB/4PjAm
5HZERCQczwLfNbP/CbuRQirrANDCOVcBXAncBBwdcjsiIpJ/CZK3l7/FzJaF3UwYFABSOOdiwHnAd4FTQ25HRESCdwC4H/ihma0Lu5kwKQC0wzl3JsmzBqai
n5OISLF7E/gl8J9m9l7YzUSBJrbDcM4dR/Ic0KuAXiG3IyIimWsAfk/yNr1/NrOmkPuJFAWADDnnaoGLgTnAZPSzExGJogTJq/bdD8w3s10h9xNZmsR8cM4d
C1za/N/YkNsRESl3juRtee8HHjCzbSH3UxQUAHLknBtGMgjMBE5EP1MRkUL4EFgCPAY8pkk/e5qsAtR886GpwDnAWeiYARGRoNQDLwJPA4uAZWbWGG5LxU0B
IE+aTykcB0wieUrhqcAxoTYlIlI8NpO8WuszJPfpv2RmDeG2VFoUAArIOdcLOAUYD4wEhgPHAZ3D7EtEJCQJ4G1gE7AOWAWsAdaY2YdhNlYOFAAiwDk3kGQQ
OAro2/xfb6A/0APoAlQAXUlus+7hdCoi0q6PgYNAE7CX5OT+vsd/O4G3SE76W/SpPjz/H4DKmLvv0RthAAAAAElFTkSuQmCC
'@
}
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 &#128273;</button>"
}
else {
$legacyCopy = "<span class=""lapsMuted"">Password unreadable</span>"
}
$lapsLines += @"
<div class="lapsPanelCard"><div class="lapsPanelHeader"><div class="lapsPanelName"><span class="lapsShield">&#128737;&#65039;</span>Legacy LAPS</div>$legacyCopy</div><div class="lapsRow"><div class="lapsRowIcon">&#9989;</div><div class="lapsRowLabel">Status</div><div class="lapsRowValue">Success</div></div><div class="lapsRow"><div class="lapsRowIcon">&#128100;</div><div class="lapsRowLabel">Username</div><div class="lapsRowValue">Administrator</div></div><div class="lapsRow"><div class="lapsRowIcon">&#128467;&#65039;</div><div class="lapsRowLabel">Expires</div><div class="lapsRowValue">$legacyExpireText</div></div><div class="lapsRow"><div class="lapsRowIcon">&#128257;</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 = "&#9989; Success - Decrypted Password"
}
elseif ($lapsNewEncryptedRaw -or $lapsNewPasswordRaw) {
$newStatusText = "&#9940; Failure - Encrypted Password"
}
elseif ($lapsNewLookupStatus) {
$newStatusText = "&#9940; 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 &#128273;</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">&#128737;&#65039;</span>Windows LAPS</div>$newCopy</div><div class="lapsRow"><div class="lapsRowIcon">&#9989;</div><div class="lapsRowLabel">Status</div><div class="lapsRowValue">$newStatusText</div></div><div class="lapsRow"><div class="lapsRowIcon">&#128100;</div><div class="lapsRowLabel">Username</div><div class="lapsRowValue">$lapsNewLookupAccount</div></div><div class="lapsRow"><div class="lapsRowIcon">&#128467;&#65039;</div><div class="lapsRowLabel">Expires</div><div class="lapsRowValue">$newExpireText</div></div><div class="lapsRow"><div class="lapsRowIcon">&#128257;</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">&#128421;&#65039;</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:&#10;shutdown /r /f /t 0&#10;shutdown /s /f /t 0&#10;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