From 14c919b380e0de3793e83b104409ee91942452bf Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Wed, 9 Sep 2026 10:05:59 -0600 Subject: [PATCH] Configure private IPv4 during Windows enrollment --- README.md | 7 +- docs/bootstrap-recovery.md | 12 +- docs/client-enrollment.md | 9 +- scripts/Invoke-SguClientBootstrap.ps1 | 152 ++++++++++++++++++++++++-- scripts/Publish-GiteaRelease.ps1 | 1 + scripts/Start-SguClientEnrollment.cmd | 13 ++- tests/BootstrapNetwork.Tests.ps1 | 41 +++++++ 7 files changed, 221 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 945b750..38eb0d7 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,14 @@ For a clean machine, the supported entry points are the release packages: ```bat Start-SguServerBootstrap.cmd 192.168.50.10 -Start-SguClientEnrollment.cmd 192.168.50.10 +Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11 ``` +El segundo argumento es la IP fija, única, del cliente en la red privada. Si se +omite y ese adaptador todavía usa una dirección `169.254.x.x`, el enrolador la +solicita de forma interactiva. En equipos con dos NIC selecciona el adaptador +sin puerta de enlace y conserva el `Default Switch` para Internet. + Linux clients are enrolled through their native PAM/SSSD stack instead of the Windows Credential Provider: diff --git a/docs/bootstrap-recovery.md b/docs/bootstrap-recovery.md index f43dcef..070b2dd 100644 --- a/docs/bootstrap-recovery.md +++ b/docs/bootstrap-recovery.md @@ -100,9 +100,17 @@ el equipo y explica que se debe actualizar la edición. 2. Ejecutar con la IP fija actual del controlador de dominio: ```bat -Start-SguClientEnrollment.cmd 192.168.50.10 +Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11 ``` +El primer argumento es el controlador de dominio y el segundo es una dirección +IPv4 fija, libre y exclusiva del cliente en la red privada. Si se omite la IP +del cliente, el bootstrap la solicita cuando el adaptador sólo tiene APIPA +(`169.254.x.x`). En una VM con Internet por `Default Switch` y otra NIC para +`Laboratorio AD`, el bootstrap elige la NIC sin puerta de enlace y no cambia la +ruta predeterminada. Si falla, la ventana elevada permanece abierta y el mismo +error queda en `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`. + Después de UAC, se solicita interactivamente la credencial autorizada para unir equipos. La contraseña existe sólo en memoria. El bootstrap: @@ -128,6 +136,8 @@ Para elegir adaptador o nombre del equipo explícitamente: powershell.exe -NoProfile -ExecutionPolicy Bypass ` -File .\Invoke-SguClientBootstrap.ps1 ` -DomainControllerIPv4Address 192.168.50.10 ` + -ClientIPv4Address 192.168.50.11 ` + -ClientPrefixLength 24 ` -NetworkInterfaceAlias 'Ethernet' ` -NewComputerName 'LCI-101' ``` diff --git a/docs/client-enrollment.md b/docs/client-enrollment.md index 077b36b..64f852d 100644 --- a/docs/client-enrollment.md +++ b/docs/client-enrollment.md @@ -4,7 +4,7 @@ Para una instalación limpia de Windows se prefiere el único punto de entrada empaquetado: ```bat -Start-SguClientEnrollment.cmd 192.168.50.10 +Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11 ``` Este comando realiza el intercambio de certificados descrito abajo sin mover @@ -12,6 +12,13 @@ una clave privada y luego ejecuta la transacción proveedor-primero. Las instrucciones completas están en [`bootstrap-recovery.md`](bootstrap-recovery.md). +El primer argumento es la IP fija del controlador; el segundo es una IP fija y +única para el cliente en la misma subred. Si el segundo se omite y la NIC +privada no tiene una IP válida, se solicita en pantalla. El bootstrap prefiere +la única NIC activa sin puerta de enlace para no reemplazar el adaptador de +Internet. Ante cualquier error conserva la ventana y escribe el diagnóstico en +`C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`. + El flujo administrado instala y valida el Credential Provider **antes** de ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta el runtime, un certificado, el registro COM, la directiva predeterminada o la diff --git a/scripts/Invoke-SguClientBootstrap.ps1 b/scripts/Invoke-SguClientBootstrap.ps1 index ce5db82..6b5e2fd 100644 --- a/scripts/Invoke-SguClientBootstrap.ps1 +++ b/scripts/Invoke-SguClientBootstrap.ps1 @@ -3,6 +3,9 @@ param( [ipaddress]$DomainControllerIPv4Address, [string]$NetworkInterfaceAlias, + [ipaddress]$ClientIPv4Address, + [ValidateRange(1, 32)] + [int]$ClientPrefixLength = 24, [PSCredential]$DomainCredential, [string]$DomainName = 'lci.lasalle.mx', [string]$DomainNetbios = 'LCI', @@ -16,6 +19,7 @@ param( [securestring]$VpnClientCertificatePfxPassword, [string]$VpnClientRootCertificatePath, [string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'), + [switch]$PauseOnError, [switch]$SkipRestart ) @@ -24,6 +28,35 @@ $brokerRecordName = 'sgu-auth' $brokerDnsName = "$brokerRecordName.$DomainName" $brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate" $temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N')) +$bootstrapLogRoot = Join-Path $env:ProgramData 'SGU\Bootstrap\Client' +$bootstrapErrorLog = Join-Path $bootstrapLogRoot 'latest-error.log' + +trap { + $failure = $_ + $failureText = @( + "SGU client enrollment failed at $((Get-Date).ToString('s')).", + '', + $failure.Exception.Message, + '', + $failure.ScriptStackTrace + ) -join [Environment]::NewLine + try { + New-Item -ItemType Directory -Path $bootstrapLogRoot -Force | Out-Null + [IO.File]::WriteAllText($bootstrapErrorLog, $failureText, [Text.UTF8Encoding]::new($false)) + } + catch { + # Keep the original enrollment error when diagnostics cannot be written. + } + + Write-Host '' + Write-Host 'SGU client enrollment did not complete.' -ForegroundColor Red + Write-Host $failure.Exception.Message -ForegroundColor Red + Write-Host "Diagnostic log: $bootstrapErrorLog" -ForegroundColor Yellow + if ($PauseOnError -and [Environment]::UserInteractive) { + Read-Host 'Press ENTER to close this window' | Out-Null + } + exit 1 +} function Assert-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() @@ -62,21 +95,119 @@ function Resolve-ClientInterfaceAlias { return $RequestedAlias } - $defaultRoute = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' ` - -ErrorAction SilentlyContinue | - Sort-Object RouteMetric,InterfaceMetric | - Select-Object -First 1 - if ($defaultRoute) { - return [string](Get-NetAdapter -InterfaceIndex $defaultRoute.InterfaceIndex).Name - } - $upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up') + $withoutDefaultGateway = @($upAdapters | Where-Object { + -not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway + }) + if ($withoutDefaultGateway.Count -eq 1) { + return [string]$withoutDefaultGateway[0].Name + } if ($upAdapters.Count -eq 1) { return [string]$upAdapters[0].Name } $aliases = ($upAdapters.Name | Sort-Object) -join ', ' - throw "Could not select a network adapter. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases" + throw "Could not select the private domain adapter unambiguously. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases" +} + +function Test-IPv4AddressesSharePrefix { + param( + [Parameter(Mandatory)][ipaddress]$FirstAddress, + [Parameter(Mandatory)][ipaddress]$SecondAddress, + [Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength + ) + + if ($FirstAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or + $SecondAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) { + return $false + } + + $firstBytes = $FirstAddress.GetAddressBytes() + $secondBytes = $SecondAddress.GetAddressBytes() + $remainingBits = $PrefixLength + for ($index = 0; $index -lt 4; $index++) { + $bits = [Math]::Min(8, $remainingBits) + $mask = if ($bits -eq 0) { + 0 + } + elseif ($bits -eq 8) { + 255 + } + else { + 256 - [int][Math]::Pow(2, 8 - $bits) + } + if (($firstBytes[$index] -band $mask) -ne ($secondBytes[$index] -band $mask)) { + return $false + } + $remainingBits -= $bits + } + return $true +} + +function Assert-UsableClientIPv4Address { + param( + [Parameter(Mandatory)][ipaddress]$Address, + [Parameter(Mandatory)][ipaddress]$DomainControllerAddress, + [Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength + ) + + if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) { + throw "The SGU client address '$Address' must be IPv4." + } + if ($Address.IPAddressToString -eq $DomainControllerAddress.IPAddressToString) { + throw 'The SGU client and domain controller cannot use the same IPv4 address.' + } + if ($Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.)') { + throw "The SGU client address '$Address' is not usable on the private domain network." + } + if (-not (Test-IPv4AddressesSharePrefix -FirstAddress $Address ` + -SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)) { + throw "The SGU client address '$Address/$PrefixLength' is not on the same network as domain controller $DomainControllerAddress." + } +} + +function Set-ClientDomainAddress { + param( + [Parameter(Mandatory)][string]$InterfaceAlias, + [Parameter(Mandatory)][ipaddress]$DomainControllerAddress, + [ipaddress]$RequestedAddress, + [Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength + ) + + $adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop + $matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` + -ErrorAction SilentlyContinue | + Where-Object { + $_.IPAddress -notmatch '^(127\.|169\.254\.)' -and + (Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) ` + -SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength) + } | + Select-Object -First 1 + + if (-not $RequestedAddress -and $matchingAddress) { + return [ipaddress]$matchingAddress.IPAddress + } + if (-not $RequestedAddress) { + $RequestedAddress = [ipaddress](Read-Host "Fixed IPv4 address for this SGU client on '$InterfaceAlias'") + } + Assert-UsableClientIPv4Address -Address $RequestedAddress ` + -DomainControllerAddress $DomainControllerAddress -PrefixLength $PrefixLength + + Set-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -Dhcp Disabled + $existingAddresses = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` + -ErrorAction SilentlyContinue | Where-Object PrefixOrigin -ne 'WellKnown') + foreach ($existingAddress in $existingAddresses) { + if ($existingAddress.IPAddress -ne $RequestedAddress.IPAddressToString -or + [int]$existingAddress.PrefixLength -ne $PrefixLength) { + Remove-NetIPAddress -InputObject $existingAddress -Confirm:$false + } + } + if (-not (Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` + -IPAddress $RequestedAddress.IPAddressToString -ErrorAction SilentlyContinue)) { + New-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` + -IPAddress $RequestedAddress.IPAddressToString -PrefixLength $PrefixLength | Out-Null + } + return $RequestedAddress } function Test-TcpPort { @@ -210,6 +341,9 @@ if ($ConnectivityMode -eq 'AzureP2S') { } else { $NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias + $ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias ` + -DomainControllerAddress $DomainControllerIPv4Address ` + -RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias ` -ServerAddresses $DomainControllerIPv4Address.IPAddressToString } diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 index 4803f08..4b1df9c 100644 --- a/scripts/Publish-GiteaRelease.ps1 +++ b/scripts/Publish-GiteaRelease.ps1 @@ -112,6 +112,7 @@ Bootstrap reproducible para el laboratorio SGU. - **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema. - `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio. - `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque. +- En clientes Hyper-V con dos NIC, el bootstrap selecciona la red privada sin puerta de enlace, solicita o acepta la IP fija del cliente y conserva en pantalla y archivo cualquier error de enrolamiento. - `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows. - `sgu-azure-infrastructure-$Version.zip`: despliega mediante Bicep una VM Windows Server 2025, red privada, IP pública protegida por NSG y Azure VPN Gateway P2S; también genera certificados por equipo y descarga el perfil de cliente. - El bootstrap Azure conserva la IP privada administrada por la NIC de Azure, autoriza el pool P2S en los firewalls SGU y nunca publica LDAP, Kerberos, SMB, RPC, WinRM ni el Auth Broker directamente a Internet. diff --git a/scripts/Start-SguClientEnrollment.cmd b/scripts/Start-SguClientEnrollment.cmd index ab18dc5..194340d 100644 --- a/scripts/Start-SguClientEnrollment.cmd +++ b/scripts/Start-SguClientEnrollment.cmd @@ -1,5 +1,14 @@ @echo off setlocal set "SGU_BOOTSTRAP_IP=%~1" -powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"')); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode" -exit /b %errorlevel% +set "SGU_CLIENT_IP=%~2" +set "SGU_NETWORK_ALIAS=%~3" +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-PauseOnError'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; if ($env:SGU_CLIENT_IP) { $arguments += @('-ClientIPv4Address',$env:SGU_CLIENT_IP) }; if ($env:SGU_NETWORK_ALIAS) { $arguments += @('-NetworkInterfaceAlias',('"' + $env:SGU_NETWORK_ALIAS + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode" +set "SGU_EXIT_CODE=%errorlevel%" +if not "%SGU_EXIT_CODE%"=="0" ( + echo. + echo SGU client enrollment did not complete. Review the elevated window or: + echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log + pause +) +exit /b %SGU_EXIT_CODE% diff --git a/tests/BootstrapNetwork.Tests.ps1 b/tests/BootstrapNetwork.Tests.ps1 index cec36b2..57f6bc9 100644 --- a/tests/BootstrapNetwork.Tests.ps1 +++ b/tests/BootstrapNetwork.Tests.ps1 @@ -25,6 +25,22 @@ $networkFunctions = $serverAst.FindAll({ }, $true) Invoke-Expression (($networkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine) +$clientTokens = $null +$clientParseErrors = $null +$clientAst = [Management.Automation.Language.Parser]::ParseFile( + $clientBootstrapPath, + [ref]$clientTokens, + [ref]$clientParseErrors) +if ($clientParseErrors.Count -gt 0) { + throw ($clientParseErrors -join [Environment]::NewLine) +} +$clientNetworkFunctions = $clientAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Test-IPv4AddressesSharePrefix' + }, $true) +Invoke-Expression (($clientNetworkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine) + Describe 'SGU public-cloud network safety' { It 'canonicalizes a host address to its IPv4 network' { ConvertTo-NetworkCidr -Address ([ipaddress]'10.77.0.4') ` @@ -58,6 +74,31 @@ Describe 'SGU public-cloud network safety' { 'VpnProfilePackagePath') | Should Be $true } + It 'accepts an explicit static IPv4 address for a private Windows adapter' { + ((Get-Command $clientBootstrapPath).Parameters.Keys -contains + 'ClientIPv4Address') | Should Be $true + ((Get-Command $clientBootstrapPath).Parameters.Keys -contains + 'ClientPrefixLength') | Should Be $true + } + + It 'matches a client and domain controller within the requested prefix' { + Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'192.168.50.11') ` + -SecondAddress ([ipaddress]'192.168.50.10') -PrefixLength 24 | + Should Be $true + Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'192.168.51.11') ` + -SecondAddress ([ipaddress]'192.168.50.10') -PrefixLength 24 | + Should Be $false + Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'10.77.15.20') ` + -SecondAddress ([ipaddress]'10.77.0.4') -PrefixLength 16 | + 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 'uses an all-user machine-certificate VPN profile' { $source = Get-Content -LiteralPath $azureClientPath -Raw $source | Should Match '-AuthenticationMethod MachineCertificate'