Unify Windows client bootstrap and discover network paths
This commit is contained in:
@@ -37,7 +37,9 @@ if ($clientParseErrors.Count -gt 0) {
|
||||
$clientNetworkFunctions = $clientAst.FindAll({
|
||||
param($node)
|
||||
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -eq 'Test-IPv4AddressesSharePrefix'
|
||||
$node.Name -in @('Test-IPv4AddressesSharePrefix', 'Resolve-ClientInterfaceAlias',
|
||||
'Set-ClientServerRoute', 'Set-ClientDomainDns', 'Test-TcpPort', 'Assert-ClientOperatingSystem',
|
||||
'Wait-ClientInterface')
|
||||
}, $true)
|
||||
Invoke-Expression (($clientNetworkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine)
|
||||
|
||||
@@ -95,12 +97,6 @@ Describe 'SGU public-cloud network safety' {
|
||||
Should Be $true
|
||||
}
|
||||
|
||||
It 'prefers the private adapter instead of the Internet default route' {
|
||||
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||
$source | Should Match '\$withoutDefaultGateway\.Count -eq 1'
|
||||
$source | Should Not Match "Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0\.0\.0\.0/0'"
|
||||
}
|
||||
|
||||
It 'waits for the new address and WinRM route to stabilize' {
|
||||
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||
$source | Should Match "AddressState -eq 'Preferred'"
|
||||
@@ -108,14 +104,10 @@ Describe 'SGU public-cloud network safety' {
|
||||
$source | Should Match 'Wait-TcpPort -Address \$DomainControllerIPv4Address -Port 5985'
|
||||
}
|
||||
|
||||
It 'keeps legacy and modern Windows package profiles isolated by build' {
|
||||
It 'uses the unified implementation without OS-specific network restrictions' {
|
||||
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||
$source.Contains("if (`$CompatibilityProfile -eq 'Windows10Legacy' -and `$windowsBuild -ge 22000)") |
|
||||
Should Be $true
|
||||
$source.Contains("if (`$CompatibilityProfile -eq 'Windows11Modern' -and `$windowsBuild -lt 22000)") |
|
||||
Should Be $true
|
||||
$source.Contains("if (`$CompatibilityProfile -eq 'Windows10Legacy' -and `$ConnectivityMode -eq 'AzureP2S')") |
|
||||
Should Be $true
|
||||
$source | Should Not Match 'package cannot enroll|belongs to the Windows 11'
|
||||
$source | Should Not Match 'Read-Host "Fixed IPv4 address for this SGU client'
|
||||
}
|
||||
|
||||
It 'uses an all-user machine-certificate VPN profile' {
|
||||
@@ -132,3 +124,213 @@ Describe 'SGU public-cloud network safety' {
|
||||
$template | Should Not Match "sourceAddressPrefix: '0\.0\.0\.0/0'"
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'SGU route and interface discovery' {
|
||||
BeforeEach {
|
||||
Mock Get-NetIPInterface {
|
||||
[pscustomobject]@{ InterfaceIndex = 4; InterfaceAlias = 'Internet'; ConnectionState = 'Connected'; InterfaceMetric = 5 }
|
||||
[pscustomobject]@{ InterfaceIndex = 8; InterfaceAlias = 'AD VPN'; ConnectionState = 'Connected'; InterfaceMetric = 30 }
|
||||
}
|
||||
Mock Get-NetIPAddress {
|
||||
if ($InterfaceIndex -eq 4) {
|
||||
[pscustomobject]@{ IPAddress = '192.168.1.2'; AddressState = 'Preferred'; SkipAsSource = $false }
|
||||
} else {
|
||||
[pscustomobject]@{ IPAddress = '172.30.0.2'; AddressState = 'Preferred'; SkipAsSource = $false }
|
||||
}
|
||||
}
|
||||
Mock Get-NetRoute {
|
||||
if ($InterfaceIndex -eq 4) {
|
||||
[pscustomobject]@{ DestinationPrefix = '0.0.0.0/0'; NextHop = '192.168.1.1'; RouteMetric = 0 }
|
||||
} else {
|
||||
[pscustomobject]@{ DestinationPrefix = '10.77.0.0/16'; NextHop = '0.0.0.0'; RouteMetric = 10 }
|
||||
}
|
||||
}
|
||||
Mock Find-NetRoute { [pscustomobject]@{ IPAddress = '192.168.1.2'; InterfaceIndex = 4 } }
|
||||
Mock Test-TcpPort { $InterfaceIndex -eq 8 }
|
||||
}
|
||||
|
||||
It 'tries another interface when the Internet route cannot reach WinRM' {
|
||||
$result = Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4'
|
||||
$result.InterfaceAlias | Should Be 'AD VPN'
|
||||
$result.IPAddress | Should Be '172.30.0.2'
|
||||
Assert-MockCalled Test-TcpPort -Scope It -Times 1 -Exactly -ParameterFilter { $InterfaceIndex -eq 4 }
|
||||
Assert-MockCalled Test-TcpPort -Scope It -Times 1 -Exactly -ParameterFilter {
|
||||
$InterfaceIndex -eq 8 -and $SourceAddress -eq [ipaddress]'172.30.0.2'
|
||||
}
|
||||
}
|
||||
|
||||
It 'uses a functioning Windows route first even with multiple interfaces' {
|
||||
Mock Test-TcpPort { $true }
|
||||
(Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4').InterfaceAlias | Should Be 'Internet'
|
||||
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly -ParameterFilter { $InterfaceIndex -eq 8 }
|
||||
}
|
||||
|
||||
It 'honors an explicit interface and never falls back to another' {
|
||||
Mock Test-TcpPort { $false }
|
||||
$rejected = $false
|
||||
try { Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4' | Out-Null } catch { $rejected = $true }
|
||||
$rejected | Should Be $true
|
||||
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly -ParameterFilter { $InterfaceIndex -eq 4 }
|
||||
}
|
||||
|
||||
It 'does not require a client to share the server subnet' {
|
||||
(Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4').IPAddress |
|
||||
Should Be '172.30.0.2'
|
||||
}
|
||||
|
||||
It 'accepts a normal default route when it is the only way to reach AD' {
|
||||
Mock Get-NetRoute { [pscustomobject]@{ DestinationPrefix = '0.0.0.0/0'; NextHop = '172.30.0.1'; RouteMetric = 0 } }
|
||||
(Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4').NextHop |
|
||||
Should Be '172.30.0.1'
|
||||
}
|
||||
|
||||
It 'does not probe disconnected or APIPA-only interfaces' {
|
||||
Mock Get-NetIPAddress { [pscustomobject]@{ IPAddress = '169.254.1.2'; AddressState = 'Preferred' } }
|
||||
$rejected = $false
|
||||
try { Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4' | Out-Null } catch { $rejected = $true }
|
||||
$rejected | Should Be $true
|
||||
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'does not select an adapter without a matching route' {
|
||||
Mock Get-NetRoute { [pscustomobject]@{ DestinationPrefix = '192.168.60.0/24'; NextHop = '0.0.0.0'; RouteMetric = 0 } }
|
||||
$rejected = $false
|
||||
try { Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4' | Out-Null } catch { $rejected = $true }
|
||||
$rejected | Should Be $true
|
||||
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'selects the longest matching prefix on an interface' {
|
||||
Mock Get-NetRoute {
|
||||
[pscustomobject]@{ DestinationPrefix = '0.0.0.0/0'; NextHop = '172.30.0.1'; RouteMetric = 0 }
|
||||
[pscustomobject]@{ DestinationPrefix = '10.77.0.4/32'; NextHop = '172.30.0.3'; RouteMetric = 100 }
|
||||
}
|
||||
(Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4').NextHop |
|
||||
Should Be '172.30.0.3'
|
||||
}
|
||||
|
||||
It 'does not change a working system route' {
|
||||
Mock New-NetRoute { throw 'Unexpected route mutation' }
|
||||
Set-ClientServerRoute -SelectedInterface ([pscustomobject]@{ InterfaceIndex = 4 }) -DomainControllerAddress '10.77.0.4'
|
||||
Assert-MockCalled New-NetRoute -Scope It -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'pins only the server when the working adapter differs from the system route' {
|
||||
$script:routeAdded = $false
|
||||
Mock Find-NetRoute {
|
||||
[pscustomobject]@{ IPAddress = '172.30.0.2'; InterfaceIndex = $(if ($script:routeAdded) { 8 } else { 4 }) }
|
||||
}
|
||||
Mock New-NetRoute { $script:routeAdded = $true }
|
||||
Set-ClientServerRoute -SelectedInterface ([pscustomobject]@{ InterfaceIndex = 8; NextHop = '172.30.0.1' }) `
|
||||
-DomainControllerAddress '10.77.0.4'
|
||||
Assert-MockCalled New-NetRoute -Scope It -Times 1 -Exactly -ParameterFilter {
|
||||
$DestinationPrefix -eq '10.77.0.4/32' -and $InterfaceIndex -eq 8 -and $NextHop -eq '172.30.0.1'
|
||||
}
|
||||
}
|
||||
|
||||
It 'removes its new route and reports a conflicting system route' {
|
||||
Mock New-NetRoute { [pscustomobject]@{ DestinationPrefix = '10.77.0.4/32'; InterfaceIndex = 8 } }
|
||||
Mock Remove-NetRoute { }
|
||||
$rejected = $false
|
||||
try {
|
||||
Set-ClientServerRoute -SelectedInterface ([pscustomobject]@{ InterfaceIndex = 8; NextHop = '172.30.0.1' }) `
|
||||
-DomainControllerAddress '10.77.0.4'
|
||||
} catch { $rejected = $true }
|
||||
$rejected | Should Be $true
|
||||
Assert-MockCalled Remove-NetRoute -Scope It -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'SGU split DNS' {
|
||||
It 'scopes DNS to the discovered domain and leaves adapter DNS untouched' {
|
||||
Mock Get-DnsClientNrptRule { }
|
||||
Mock Remove-DnsClientNrptRule { }
|
||||
Mock Add-DnsClientNrptRule { }
|
||||
Mock Clear-DnsClientCache { }
|
||||
Mock Set-DnsClientServerAddress { throw 'Unexpected adapter DNS change' }
|
||||
Set-ClientDomainDns -DnsDomain 'example.test' -ServerAddress '10.77.0.4'
|
||||
Assert-MockCalled Add-DnsClientNrptRule -Scope It -Times 1 -Exactly -ParameterFilter {
|
||||
$Namespace -contains '.example.test' -and $Namespace -contains 'example.test' -and
|
||||
$NameServers -eq '10.77.0.4'
|
||||
}
|
||||
Assert-MockCalled Set-DnsClientServerAddress -Scope It -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'reuses the managed DNS rule on a repeated enrollment' {
|
||||
Mock Get-DnsClientNrptRule {
|
||||
[pscustomobject]@{ DisplayName = 'SGU domain DNS - example.test';
|
||||
NameServers = @('10.77.0.4'); Namespace = @('example.test', '.example.test') }
|
||||
}
|
||||
Mock Add-DnsClientNrptRule { throw 'Unexpected DNS rule duplication' }
|
||||
Set-ClientDomainDns -DnsDomain 'example.test' -ServerAddress '10.77.0.4'
|
||||
Assert-MockCalled Add-DnsClientNrptRule -Scope It -Times 0 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'SGU Windows capability checks' {
|
||||
It 'accepts the same enrollment on Windows 10 LTSC, Windows 10 and Windows 11' {
|
||||
foreach ($build in @(14393, 17763, 19044, 19045, 22000, 22631, 26100)) {
|
||||
Assert-ClientOperatingSystem -OperatingSystem ([pscustomobject]@{ ProductType = 1; BuildNumber = $build }) `
|
||||
-Edition Enterprise -Architecture AMD64
|
||||
}
|
||||
}
|
||||
|
||||
It 'rejects Home, Server, pre-Windows 10 and incompatible architectures' {
|
||||
foreach ($sample in @(
|
||||
@{ ProductType = 1; BuildNumber = 26100; Edition = 'Core'; Architecture = 'AMD64' },
|
||||
@{ ProductType = 3; BuildNumber = 26100; Edition = 'ServerStandard'; Architecture = 'AMD64' },
|
||||
@{ ProductType = 1; BuildNumber = 9600; Edition = 'Professional'; Architecture = 'AMD64' },
|
||||
@{ ProductType = 1; BuildNumber = 26100; Edition = 'Professional'; Architecture = 'ARM64' },
|
||||
@{ ProductType = 1; BuildNumber = 19045; Edition = 'Professional'; Architecture = 'x86' }
|
||||
)) {
|
||||
$rejected = $false
|
||||
try {
|
||||
Assert-ClientOperatingSystem -OperatingSystem ([pscustomobject]$sample) `
|
||||
-Edition $sample.Edition -Architecture $sample.Architecture
|
||||
} catch { $rejected = $true }
|
||||
$rejected | Should Be $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'SGU real TCP probe' {
|
||||
It 'connects with a bound source and interface without relying on ICMP' {
|
||||
$listener = [Net.Sockets.TcpListener]::new([ipaddress]'127.0.0.1', 0)
|
||||
try {
|
||||
$listener.Start()
|
||||
$loopback = Get-NetIPAddress -IPAddress '127.0.0.1' -AddressFamily IPv4 | Select-Object -First 1
|
||||
Test-TcpPort -Address '127.0.0.1' -Port $listener.LocalEndpoint.Port `
|
||||
-SourceAddress '127.0.0.1' -InterfaceIndex $loopback.InterfaceIndex | Should Be $true
|
||||
} finally { $listener.Stop() }
|
||||
}
|
||||
|
||||
It 'returns false when the TCP service is closed' {
|
||||
$listener = [Net.Sockets.TcpListener]::new([ipaddress]'127.0.0.1', 0)
|
||||
$listener.Start()
|
||||
$port = $listener.LocalEndpoint.Port
|
||||
$listener.Stop()
|
||||
Test-TcpPort -Address '127.0.0.1' -Port $port -TimeoutMilliseconds 200 | Should Be $false
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'SGU network readiness retries' {
|
||||
It 'retries discovery while DHCP or VPN routes are initializing' {
|
||||
$script:discoveryAttempts = 0
|
||||
Mock Start-Sleep { }
|
||||
Mock Resolve-ClientInterfaceAlias {
|
||||
$script:discoveryAttempts++
|
||||
if ($script:discoveryAttempts -eq 1) { throw 'Address still tentative' }
|
||||
[pscustomobject]@{ InterfaceAlias = 'AD VPN'; IPAddress = '172.30.0.2' }
|
||||
}
|
||||
(Wait-ClientInterface -DomainControllerAddress '10.77.0.4').InterfaceAlias | Should Be 'AD VPN'
|
||||
Assert-MockCalled Resolve-ClientInterfaceAlias -Scope It -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'reports the last network diagnostic when the timeout expires' {
|
||||
Mock Resolve-ClientInterfaceAlias { throw 'No route to the server' }
|
||||
$message = ''
|
||||
try { Wait-ClientInterface -DomainControllerAddress '10.77.0.4' -TimeoutSeconds 0 }
|
||||
catch { $message = $_.Exception.Message }
|
||||
$message | Should Match 'No route to the server'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user