diff --git a/RdpCoexist/.gitignore b/RdpCoexist/.gitignore new file mode 100644 index 0000000..dab627a --- /dev/null +++ b/RdpCoexist/.gitignore @@ -0,0 +1,6 @@ +# Never commit the engine binary (upstream has no redistribution license) or runtime state. +engine/ +logs/ +state.json +*.exe +*.dll diff --git a/RdpCoexist/README.md b/RdpCoexist/README.md new file mode 100644 index 0000000..a86ea19 --- /dev/null +++ b/RdpCoexist/README.md @@ -0,0 +1,79 @@ +# RdpCoexist + +A small, portable **governance/ops shell** that lets **AnyDesk and RDP run side by +side** (concurrent RDP sessions + local console) on modern Windows — **without +modifying `System32\termsrv.dll` on disk**. + +It does *not* reimplement the wrapper. It drives +[`sergiye/rdpWrapper`](https://github.com/sergiye/rdpWrapper) (a portable, .NET +Framework 4.7.2, single-file rewrite of RDP Wrapper that redirects the +`TermService` `ServiceDll` to a non-destructive proxy — `TermWrap` — with a +built-in offset engine) and wraps it with the operational pieces it lacks. + +> Approach: stop RDP service → point `TermService` `ServiceDll` at a wrapper in a +> portable folder → set `fDenyTSConnections=0`, `fSingleSessionPerUser=0`, +> `EnableConcurrentSessions=1` → start service. The original `termsrv.dll` is +> **never touched**, so SFC / WRP / Windows Update leave it alone. + +## Why a shell (the 5 gaps this closes) + +| # | Gap in the bare engine | What this shell adds | +|---|------------------------|----------------------| +| 1 | No license grant → cannot repackage/redistribute the engine binary | Engine is **fetched from its official release** and **SHA256-pinned**; nothing is bundled here (`.gitignore` blocks `*.exe`/`*.dll`) | +| 2 | No self-heal after Windows Update replaces `termsrv.dll` | `-Heal` + a **SYSTEM startup scheduled task**: verify, and only re-install if the listener dropped | +| 3 | Verification is GUI-only | `-Verify` returns a **machine-checkable verdict** (`COEXIST-OK` requires a real listener **and** ≥2 active sessions, not just registry values) | +| 4 | Engine phones home for updates → version drift | Engine is **always** invoked with `-offline`; version is pinned in `config.psd1` | +| 5 | Engine DLL is commonly flagged `HackTool:Win32/RDPWrap` | Idempotent **Defender exclusion** for the engine folder (skipped gracefully if exclusions are policy-locked) | + +## Layout + +``` +RdpCoexist/ +├─ RdpCoexist.cmd self-elevating launcher (UAC) +├─ RdpCoexist.ps1 -Apply | -Revert | -Verify | -Heal | -Status +├─ config.psd1 version pin, SHA256 pin, paths, options +└─ modules/ + ├─ Common.psm1 logging, admin check, GPO-override warning + ├─ Engine.psm1 download + hash-verify + drive the engine + ├─ Defender.psm1 idempotent exclusion add/remove + ├─ Verify.psm1 listener + active-session verdict + ├─ Heal.psm1 scheduled-task register/unregister + └─ LocalUser.psm1 optional dedicated RDP account +``` +Engine binary, logs, and state live under `%ProgramData%\RdpCoexist\` — never in this repo. + +## Usage + +1. Edit `config.psd1`. At minimum set **`EngineSha256`** to the hash of the engine + release you vetted (or run the first `-Apply` with `-TrustOnFirstUse`, which + prints the observed hash for you to paste back and lock). +2. Run: + +``` +RdpCoexist.cmd -Apply :: fetch+verify engine, install, register heal task, verify +RdpCoexist.cmd -Verify :: print current verdict +RdpCoexist.cmd -Revert :: uninstall engine, remove task + exclusion +``` + +`COEXIST-OK` only appears once **two** sessions are actually active (e.g. console + +one RDP), because a listener alone does not prove coexistence. + +## Requirements & caveats + +- **Admin** required (the launcher elevates). +- **.NET Framework 4.7.2** — built into supported Windows 10/11; no install needed. +- **Domain GPO**: if `fSingleSessionPerUser` / `fDenyTSConnections` are enforced by + policy, they revert on GP refresh. The shell warns; resolve with IT. +- **Same-user dual session** trips license locks in OneDrive/Outlook/Office. Prefer + the optional dedicated RDP account (`CreateRdpUser`), keeping the console user for AnyDesk. +- **Portability vs. resilience**: the engine is copied under `%ProgramData%` by + default. Pointing `ServiceDll` at removable media means RDP dies if the media is + pulled — don't. +- Concurrent-session enablement changes the effective RDP session limit; confirm your + own licensing position before use. + +## Configuration keys + +See `config.psd1` for the annotated list. Values shipped here are **neutral +placeholders** — set the version pin, hash pin, and (if used) the RDP user name to +your own before running. diff --git a/RdpCoexist/RdpCoexist.cmd b/RdpCoexist/RdpCoexist.cmd new file mode 100644 index 0000000..3bda4b4 --- /dev/null +++ b/RdpCoexist/RdpCoexist.cmd @@ -0,0 +1,12 @@ +@echo off +setlocal +set "PS1=%~dp0RdpCoexist.ps1" +net session >nul 2>&1 +if %errorlevel%==0 ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%PS1%" %* +) else ( + echo Requesting administrator elevation... + powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','%PS1%' %*" +) +endlocal diff --git a/RdpCoexist/RdpCoexist.ps1 b/RdpCoexist/RdpCoexist.ps1 new file mode 100644 index 0000000..b303e0e --- /dev/null +++ b/RdpCoexist/RdpCoexist.ps1 @@ -0,0 +1,94 @@ +<# +.SYNOPSIS + Portable AnyDesk + RDP coexistence orchestrator (governance shell around + sergiye/rdpWrapper). Enables concurrent RDP sessions without touching + System32\termsrv.dll on disk (ServiceDll redirection + TermWrap backend). + +.DESCRIPTION + This shell adds the operational pieces the engine does not provide: + 1. Engine is fetched from its official release and SHA256-pinned (no + redistribution here; upstream has no license grant). + 2. Post-Windows-Update self-heal via a SYSTEM startup scheduled task. + 3. A machine-checkable verdict (listener + >=2 active sessions), not GUI. + 4. The engine is always run with -offline (no phone-home / version drift). + 5. Windows Defender exclusion handled idempotently. + +.EXAMPLE + RdpCoexist.cmd -Apply + RdpCoexist.cmd -Verify + RdpCoexist.cmd -Revert +#> +[CmdletBinding(DefaultParameterSetName = 'Verify')] +param( + [Parameter(ParameterSetName = 'Apply')] [switch]$Apply, + [Parameter(ParameterSetName = 'Revert')] [switch]$Revert, + [Parameter(ParameterSetName = 'Verify')] [switch]$Verify, + [Parameter(ParameterSetName = 'Heal')] [switch]$Heal, + [Parameter(ParameterSetName = 'Status')] [switch]$Status, + [switch]$TrustOnFirstUse +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$here = Split-Path -Parent $MyInvocation.MyCommand.Path + +Import-Module (Join-Path $here 'modules\Common.psm1') -Force +Import-Module (Join-Path $here 'modules\Engine.psm1') -Force +Import-Module (Join-Path $here 'modules\Defender.psm1') -Force +Import-Module (Join-Path $here 'modules\Verify.psm1') -Force +Import-Module (Join-Path $here 'modules\Heal.psm1') -Force +Import-Module (Join-Path $here 'modules\LocalUser.psm1') -Force + +$cfg = Import-PowerShellDataFile (Join-Path $here 'config.psd1') +New-Item -ItemType Directory -Path $cfg.InstallRoot -Force | Out-Null +Initialize-Log -Root $cfg.InstallRoot + +function Do-Apply { + Assert-Admin + Test-DomainPolicyOverride + if ($cfg.AddDefenderExclusion) { Add-DefenderExclusion -Path (Join-Path $cfg.InstallRoot 'engine') } + + $exe = Resolve-Engine -Config $cfg -TrustOnFirstUse:$TrustOnFirstUse + Set-PreferredWrapper -Wrapper $cfg.PreferredWrapper + $rc = Invoke-Engine -Exe $exe -EngineArgs @('-install') -Offline:$cfg.AlwaysOffline + if ($rc -ne 0) { throw "Engine install failed (exit=$rc)." } + + if ($cfg.CreateRdpUser) { New-RdpLocalUser -UserName $cfg.RdpUserName } + if ($cfg.RegisterHealTask) { Register-HealTask -Config $cfg -ScriptPath $PSCommandPath } + + $v = Test-Coexist -ActiveSessionPattern $cfg.ActiveSessionPattern + $v | Format-List | Out-String | Write-Host + if ($v.Verdict -eq 'FAIL') { Write-Log 'Apply finished but listener is down.' 'Error' } + elseif ($v.Verdict -eq 'LISTENING-UNVERIFIED') { + Write-Log 'Listener up. Open a second concurrent session to confirm COEXIST-OK.' 'Warn' + } + else { Write-Log 'COEXIST-OK.' 'Ok' } +} + +function Do-Revert { + Assert-Admin + $exe = Join-Path (Join-Path $cfg.InstallRoot 'engine') $cfg.EngineAsset + if (Test-Path $exe) { Invoke-Engine -Exe $exe -EngineArgs @('-uninstall') -Offline:$cfg.AlwaysOffline | Out-Null } + Unregister-HealTask -Config $cfg + if ($cfg.AddDefenderExclusion) { Remove-DefenderExclusion -Path (Join-Path $cfg.InstallRoot 'engine') } + if ($cfg.CreateRdpUser) { Write-Log "Local user '$($cfg.RdpUserName)' left intact; remove manually if desired." 'Warn' } + Write-Log 'Revert complete.' 'Ok' +} + +function Do-Heal { + Assert-Admin + $v = Test-Coexist -ActiveSessionPattern $cfg.ActiveSessionPattern + if ($v.Verdict -ne 'FAIL') { Write-Log "Heal: healthy ($($v.Verdict)); nothing to do." 'Ok'; return } + Write-Log 'Heal: listener down (likely post-update). Re-installing engine.' 'Warn' + $exe = Resolve-Engine -Config $cfg -TrustOnFirstUse:$TrustOnFirstUse + Set-PreferredWrapper -Wrapper $cfg.PreferredWrapper + Invoke-Engine -Exe $exe -EngineArgs @('-install') -Offline:$cfg.AlwaysOffline | Out-Null + (Test-Coexist -ActiveSessionPattern $cfg.ActiveSessionPattern) | Format-List | Out-String | Write-Host +} + +switch ($PSCmdlet.ParameterSetName) { + 'Apply' { Do-Apply } + 'Revert' { Do-Revert } + 'Heal' { Do-Heal } + default { (Test-Coexist -ActiveSessionPattern $cfg.ActiveSessionPattern) | Format-List } +} diff --git a/RdpCoexist/config.psd1 b/RdpCoexist/config.psd1 new file mode 100644 index 0000000..bc8f495 --- /dev/null +++ b/RdpCoexist/config.psd1 @@ -0,0 +1,35 @@ +@{ + # ========================================================================= + # RdpCoexist configuration + # Values below are neutral defaults / placeholders. Fill EngineSha256 + # before the first -Apply (or use -TrustOnFirstUse to pin interactively). + # ========================================================================= + + # ---- Engine: sergiye/rdpWrapper (used as-is; not redistributed here) ---- + EngineRepo = 'sergiye/rdpWrapper' + EngineVersion = '2.15' # pin ONE vetted release; never float to "latest" + EngineAsset = 'rdpWrapper_x64.exe' + + # REQUIRED before first Apply. SHA256 of the vetted asset, uppercase hex. + # Empty => -Apply refuses unless you pass -TrustOnFirstUse (prints the hash + # so you can paste it back here and re-run). + EngineSha256 = '' + + # ---- Paths ---- + InstallRoot = 'C:\ProgramData\RdpCoexist' + + # ---- Behaviour ---- + PreferredWrapper = 'TermWrap' # TermWrap = offset-engine backend, no ini/offset dance + AlwaysOffline = $true # always pass -offline so the engine never phones home + AddDefenderExclusion = $true # engine dll is commonly flagged HackTool:Win32/RDPWrap + RegisterHealTask = $true # self-heal after Windows Update (re-install if listener drops) + HealTaskName = 'RdpCoexist-Heal' + + # ---- Optional: dedicated local account for RDP (avoids same-user dual-session + # license traps in OneDrive/Outlook/Office). Off by default. ---- + CreateRdpUser = $false + RdpUserName = 'rdpuser' # placeholder; change before enabling + + # ---- Verify: locales that render session state as "Active" in qwinsta ---- + ActiveSessionPattern = 'Active|使用中|アクティブ|활성' +} diff --git a/RdpCoexist/modules/Common.psm1 b/RdpCoexist/modules/Common.psm1 new file mode 100644 index 0000000..26a29ef --- /dev/null +++ b/RdpCoexist/modules/Common.psm1 @@ -0,0 +1,46 @@ +Set-StrictMode -Version Latest + +$script:LogPath = $null + +function Initialize-Log { + param([string]$Root) + $dir = Join-Path $Root 'logs' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $script:LogPath = Join-Path $dir ('rdpcoexist-{0:yyyyMMdd}.log' -f (Get-Date)) +} + +function Write-Log { + param( + [Parameter(Mandatory)][string]$Message, + [ValidateSet('Info', 'Warn', 'Error', 'Ok')][string]$Level = 'Info' + ) + $stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $line = "[$stamp] [$Level] $Message" + $color = switch ($Level) { 'Warn' { 'Yellow' } 'Error' { 'Red' } 'Ok' { 'Green' } default { 'Gray' } } + Write-Host $line -ForegroundColor $color + if ($script:LogPath) { Add-Content -Path $script:LogPath -Value $line -Encoding UTF8 } +} + +function Assert-Admin { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + $pr = New-Object Security.Principal.WindowsPrincipal($id) + if (-not $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Administrator rights required. Launch via RdpCoexist.cmd (UAC) or an elevated shell.' + } +} + +function Test-DomainPolicyOverride { + # fSingleSessionPerUser / fDenyTSConnections may be enforced by GPO and will + # silently revert local changes on the next GP refresh. Warn, do not fail. + $p = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services' + if (Test-Path $p) { + $k = Get-ItemProperty $p -ErrorAction SilentlyContinue + foreach ($v in 'fSingleSessionPerUser', 'fDenyTSConnections') { + if ($null -ne $k.$v) { + Write-Log "GPO sets $v=$($k.$v); it may override local settings on GP refresh. Check with IT." 'Warn' + } + } + } +} + +Export-ModuleMember -Function Initialize-Log, Write-Log, Assert-Admin, Test-DomainPolicyOverride diff --git a/RdpCoexist/modules/Defender.psm1 b/RdpCoexist/modules/Defender.psm1 new file mode 100644 index 0000000..b3df142 --- /dev/null +++ b/RdpCoexist/modules/Defender.psm1 @@ -0,0 +1,25 @@ +Set-StrictMode -Version Latest + +function Add-DefenderExclusion { + param([Parameter(Mandatory)][string]$Path) + try { + $cur = @((Get-MpPreference).ExclusionPath) + if ($cur -notcontains $Path) { + Add-MpPreference -ExclusionPath $Path + Write-Log "Defender exclusion added: $Path" 'Ok' + } + else { Write-Log "Defender exclusion already present: $Path" } + } + catch { + # If exclusions are locked by Intune/Tenant, only IT can add them. + Write-Log "Could not add Defender exclusion (managed by policy?): $_" 'Warn' + } +} + +function Remove-DefenderExclusion { + param([Parameter(Mandatory)][string]$Path) + try { Remove-MpPreference -ExclusionPath $Path -ErrorAction SilentlyContinue } + catch { Write-Log "Could not remove Defender exclusion: $_" 'Warn' } +} + +Export-ModuleMember -Function Add-DefenderExclusion, Remove-DefenderExclusion diff --git a/RdpCoexist/modules/Engine.psm1 b/RdpCoexist/modules/Engine.psm1 new file mode 100644 index 0000000..8bc87b4 --- /dev/null +++ b/RdpCoexist/modules/Engine.psm1 @@ -0,0 +1,71 @@ +Set-StrictMode -Version Latest + +function Resolve-Engine { + <# + Ensures the sergiye/rdpWrapper engine exe is present under \engine + and its SHA256 matches the pin. The engine is downloaded from its official + GitHub release (not redistributed in this repo, which has no license grant). + #> + param( + [Parameter(Mandatory)]$Config, + [switch]$TrustOnFirstUse + ) + $dir = Join-Path $Config.InstallRoot 'engine' + $exe = Join-Path $dir $Config.EngineAsset + New-Item -ItemType Directory -Path $dir -Force | Out-Null + + if (-not (Test-Path $exe)) { + $url = "https://github.com/$($Config.EngineRepo)/releases/download/$($Config.EngineVersion)/$($Config.EngineAsset)" + Write-Log "Downloading engine: $url" + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $url -OutFile $exe -UseBasicParsing + } + + $hash = (Get-FileHash $exe -Algorithm SHA256).Hash.ToUpper() + $pin = ($Config.EngineSha256 | Out-String).Trim().ToUpper() + + if ([string]::IsNullOrWhiteSpace($pin)) { + if ($TrustOnFirstUse) { + Write-Log "TOFU pin: $hash <-- paste into config.psd1 EngineSha256 to lock it." 'Warn' + } + else { + Remove-Item $exe -Force -ErrorAction SilentlyContinue + throw "EngineSha256 is not pinned. Verify the asset out-of-band, set the pin, or re-run with -TrustOnFirstUse. Observed: $hash" + } + } + elseif ($hash -ne $pin) { + Remove-Item $exe -Force -ErrorAction SilentlyContinue + throw "Engine hash mismatch (deleted). expected=$pin actual=$hash" + } + else { + Write-Log "Engine hash verified." 'Ok' + } + return $exe +} + +function Invoke-Engine { + param( + [Parameter(Mandatory)][string]$Exe, + [Parameter(Mandatory)][string[]]$EngineArgs, + [bool]$Offline = $true + ) + $a = @($EngineArgs) + if ($Offline) { $a += '-offline' } # -offline must be the LAST parameter + Write-Log "engine $($a -join ' ')" + $p = Start-Process -FilePath $Exe -ArgumentList $a -Wait -PassThru -NoNewWindow + Write-Log "engine exit=$($p.ExitCode)" + return $p.ExitCode +} + +function Set-PreferredWrapper { + # The engine reads preferredWrapper from its own persistent settings; default + # is already TermWrap. Mirror the choice into that store so -install is unattended. + param([Parameter(Mandatory)][string]$Wrapper) + $key = 'HKCU:\Software\rdpWrapper' + try { + New-Item $key -Force | Out-Null + Set-ItemProperty $key -Name 'preferredWrapper' -Value $Wrapper + } catch { Write-Log "Could not preset preferredWrapper: $_" 'Warn' } +} + +Export-ModuleMember -Function Resolve-Engine, Invoke-Engine, Set-PreferredWrapper diff --git a/RdpCoexist/modules/Heal.psm1 b/RdpCoexist/modules/Heal.psm1 new file mode 100644 index 0000000..767a935 --- /dev/null +++ b/RdpCoexist/modules/Heal.psm1 @@ -0,0 +1,30 @@ +Set-StrictMode -Version Latest + +function Register-HealTask { + <# + Windows Update replaces termsrv.dll, which can break memory offsets and drop + the listener. This registers a SYSTEM startup task that runs -Heal: verify, + and only if not COEXIST-OK re-run the engine install (offset engine re-adapts). + #> + param( + [Parameter(Mandatory)]$Config, + [Parameter(Mandatory)][string]$ScriptPath + ) + $arg = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -Heal' -f $ScriptPath + $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arg + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries + Register-ScheduledTask -TaskName $Config.HealTaskName -Action $action -Trigger $trigger ` + -Principal $principal -Settings $settings -Force | Out-Null + Write-Log "Heal task registered: $($Config.HealTaskName)" 'Ok' +} + +function Unregister-HealTask { + param([Parameter(Mandatory)]$Config) + Unregister-ScheduledTask -TaskName $Config.HealTaskName -Confirm:$false -ErrorAction SilentlyContinue + Write-Log "Heal task removed: $($Config.HealTaskName)" +} + +Export-ModuleMember -Function Register-HealTask, Unregister-HealTask diff --git a/RdpCoexist/modules/LocalUser.psm1 b/RdpCoexist/modules/LocalUser.psm1 new file mode 100644 index 0000000..c0c384b --- /dev/null +++ b/RdpCoexist/modules/LocalUser.psm1 @@ -0,0 +1,30 @@ +Set-StrictMode -Version Latest + +function New-RdpLocalUser { + <# + Optional: a dedicated local account for RDP so the interactive (console) + user and the RDP user are different principals. Avoids single-session + license locks in OneDrive/Outlook/Office. Prompts for the password; this + tool never bakes or transmits credentials. + #> + param([Parameter(Mandatory)][string]$UserName) + + if (Get-LocalUser -Name $UserName -ErrorAction SilentlyContinue) { + Write-Log "Local user '$UserName' already exists." ; return + } + $sec = Read-Host "Set password for new RDP user '$UserName'" -AsSecureString + New-LocalUser -Name $UserName -Password $sec -PasswordNeverExpires:$true ` + -Description 'RdpCoexist dedicated RDP account' | Out-Null + Add-LocalGroupMember -Group 'Remote Desktop Users' -Member $UserName -ErrorAction SilentlyContinue + Write-Log "Local user '$UserName' created and added to Remote Desktop Users." 'Ok' +} + +function Remove-RdpLocalUser { + param([Parameter(Mandatory)][string]$UserName) + if (Get-LocalUser -Name $UserName -ErrorAction SilentlyContinue) { + Remove-LocalUser -Name $UserName + Write-Log "Local user '$UserName' removed." + } +} + +Export-ModuleMember -Function New-RdpLocalUser, Remove-RdpLocalUser diff --git a/RdpCoexist/modules/Verify.psm1 b/RdpCoexist/modules/Verify.psm1 new file mode 100644 index 0000000..ae875ff --- /dev/null +++ b/RdpCoexist/modules/Verify.psm1 @@ -0,0 +1,37 @@ +Set-StrictMode -Version Latest + +function Get-ActiveSessionCount { + param([string]$Pattern = 'Active|使用中|アクティブ|활성') + $out = & qwinsta.exe 2>$null + if (-not $out) { return 0 } + return @($out | Select-String -Pattern $Pattern).Count +} + +function Test-Coexist { + <# + COEXIST-OK requires an actual listener AND >=2 concurrent active sessions, + not merely the registry values. Registry-only checks lie. + #> + param([string]$ActiveSessionPattern = 'Active|使用中|アクティブ|활성') + + $svc = (Get-Service TermService -ErrorAction SilentlyContinue).Status + $listen = @(Get-NetTCPConnection -LocalPort 3389 -State Listen -ErrorAction SilentlyContinue).Count -gt 0 + $dll = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\TermService\Parameters' ` + -Name ServiceDll -ErrorAction SilentlyContinue).ServiceDll + $active = Get-ActiveSessionCount -Pattern $ActiveSessionPattern + + $verdict = + if ($listen -and $active -ge 2) { 'COEXIST-OK' } + elseif ($listen) { 'LISTENING-UNVERIFIED' } + else { 'FAIL' } + + [pscustomobject]@{ + TermService = $svc + Listening = $listen + ServiceDll = $dll + ActiveSessions = $active + Verdict = $verdict + } +} + +Export-ModuleMember -Function Test-Coexist, Get-ActiveSessionCount