From 7f8a9eed4e48e323e5e8bdce00732b2e69435840 Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Thu, 10 Sep 2026 08:13:46 -0600 Subject: [PATCH] Unify Windows client bootstrap and discover network paths --- README.md | 27 +- docs/azure-vpn-deployment.md | 6 +- docs/bootstrap-recovery.md | 36 ++- docs/client-enrollment.md | 69 +++-- docs/unified-bootstrap-validation.md | 57 ++++ scripts/Enroll-SguDomainClient.ps1 | 9 +- scripts/Install-SguAzureP2sClient.ps1 | 26 +- scripts/Invoke-SguClientBootstrap.ps1 | 290 +++++++++++++++------ scripts/New-SguBootstrapPackages.ps1 | 50 ++-- scripts/Publish-GiteaRelease.ps1 | 12 +- scripts/Set-SguStandardLocalUser.ps1 | 3 +- scripts/Start-SguAzureClientEnrollment.cmd | 2 +- tests/BootstrapNetwork.Tests.ps1 | 230 +++++++++++++++- tests/ClientEnrollmentScripts.Tests.ps1 | 36 ++- 14 files changed, 634 insertions(+), 219 deletions(-) create mode 100644 docs/unified-bootstrap-validation.md diff --git a/README.md b/README.md index 3697cdf..d2ff570 100644 --- a/README.md +++ b/README.md @@ -111,27 +111,26 @@ ports remain private even though the VM owns a public IP. Never disable the built-in Microsoft password Credential Provider. It is the supported recovery path if a third-party provider fails to load. -For a clean machine, choose the release package that matches the workstation: +For a clean Windows 10 or Windows 11 workstation, use the unified package: -- `sgu-windows10-legacy-client-bootstrap-VERSION.zip` for Windows 10; -- `sgu-windows11-client-bootstrap-VERSION.zip` for Windows 11, including the - modern Azure P2S/pre-logon flow. +- `sgu-windows-client-bootstrap-VERSION.zip` (x64 Pro, Enterprise or Education), + including optional Azure P2S setup for both versions. -Both use the same direct-lab entry point: +Double-click `Start-SguClientEnrollment.cmd` and enter the server IP and domain +credentials, or provide the server IP on the command line: ```bat Start-SguServerBootstrap.cmd 192.168.50.10 -Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11 +Start-SguClientEnrollment.cmd 192.168.50.10 ``` -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. - -El manifiesto identifica el perfil `Windows10Legacy` o `Windows11Modern` y el -bootstrap valida el build antes de hacer cambios. Las correcciones comunes se -mantienen en ambos; Windows 11 conserva además sus puntos de entrada modernos. +El bootstrap prueba interfaces y rutas hacia el servidor, incluyendo VPN ya +conectadas, sin pedir la IP del cliente ni exigir la misma subred. Conserva DHCP +y el DNS de Internet; descubre el dominio autenticado y configura DNS sólo para +ese dominio. El servidor debe tener SGU preparado y existir conectividad LAN/VPN. +Los casos sin DHCP, sin ruta o con VPN desconectada muestran un diagnóstico. +Ver [client-enrollment.md](docs/client-enrollment.md) para requisitos y parámetros +avanzados de IP estática. Linux clients are enrolled through their native PAM/SSSD stack instead of the Windows Credential Provider: diff --git a/docs/azure-vpn-deployment.md b/docs/azure-vpn-deployment.md index b7dcd6c..9c893af 100644 --- a/docs/azure-vpn-deployment.md +++ b/docs/azure-vpn-deployment.md @@ -120,9 +120,9 @@ En la estación administrativa, emita una credencial distinta por equipo: $w11 = .\scripts\New-SguAzureP2sCertificates.ps1 -ClientName 'Windows11' ``` -Copie a la VM Windows 11 de Hyper-V: +Copie a la VM Windows 10/11 de Hyper-V: -- `sgu-windows11-client-bootstrap-VERSION.zip` extraído; +- `sgu-windows-client-bootstrap-VERSION.zip` extraído (Windows 10/11 x64); - `$vpn.PackagePath`; - `$w11.ClientCertificatePath`; - `sgu-azure-p2s-root.cer`. @@ -148,7 +148,7 @@ Si la red local bloquea IKEv2 (UDP 500/4500), el paquete de Azure también incluye un perfil SSTP sobre TCP 443, pero ese fallback todavía requiere instalación manual con el instalador oficial incluido en `WindowsAmd64`. -Windows 11 Pro admite unión a AD y VPN nativa, pero Microsoft no licencia el +Windows 10/11 Pro admite unión a AD y VPN nativa, pero Microsoft no licencia el **Always On VPN device tunnel** para Pro. Por ello el perfil se instala para todos los usuarios y se puede seleccionar desde el control de red de la pantalla de inicio de sesión; antes del primer logon de una cuenta de dominio, diff --git a/docs/bootstrap-recovery.md b/docs/bootstrap-recovery.md index f8f3749..4cabda4 100644 --- a/docs/bootstrap-recovery.md +++ b/docs/bootstrap-recovery.md @@ -96,42 +96,38 @@ Se admiten Pro, Enterprise y Education. Windows Home no puede unirse a Active Directory local ni actuar como host RDP; el bootstrap lo detecta antes de cambiar el equipo y explica que se debe actualizar la edición. -1. Descargar y extraer el paquete correspondiente: - `sgu-windows10-legacy-client-bootstrap-VERSION.zip` o - `sgu-windows11-client-bootstrap-VERSION.zip`. +1. Descargar y extraer `sgu-windows-client-bootstrap-VERSION.zip`, común para + Windows 10 y Windows 11 x64 Pro, Enterprise o Education. 2. Ejecutar con la IP fija actual del controlador de dominio: ```bat -Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11 +Start-SguClientEnrollment.cmd 192.168.50.10 ``` -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`. +También puede hacerse doble clic y escribir la IP del controlador. El cliente +conserva sus IP/DHCP y prueba WinRM por las interfaces con rutas disponibles, +incluyendo VPN y redes enrutadas. No requiere compartir subred con el servidor. +Si una NIC sólo tiene APIPA, necesita DHCP o una IP asignada por el administrador; +el bootstrap no inventa direcciones. Si falla, la ventana permanece abierta y +el error queda en `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`. -Cada manifiesto fija su perfil y evita cruzar paquetes: Windows 10 utiliza -`Windows10Legacy` (build menor a 22000) y Windows 11 `Windows11Modern` (build -22000 o posterior). El ZIP moderno conserva tanto el enrolamiento directo como -Azure P2S/pre-logon; el ZIP legado contiene el flujo directo. El código común y -las garantías de seguridad son idénticos. +El manifiesto usa `Auto` y ambos Windows ejecutan el mismo código. Azure P2S está +incluido para ambos; otras VPN ya conectadas usan el lanzador habitual. Después de UAC, se solicita interactivamente la credencial autorizada para unir equipos. La contraseña existe sólo en memoria. El bootstrap: -1. apunta el DNS del adaptador al IP proporcionado; -2. abre una sesión WinRM autenticada con el DC y verifica que pertenece al - dominio esperado; +1. selecciona una interfaz con conectividad comprobada al servidor; +2. abre una sesión WinRM autenticada con el DC, descubre el dominio y configura + DNS mediante NRPT sólo para ese dominio, conservando el DNS de Internet; 3. crea en el cliente un certificado mTLS RSA-3072 no exportable y envía sólo su parte pública al broker; 4. recupera por esa sesión autenticada el certificado público del broker; 5. instala el runtime .NET 10 offline y el Credential Provider; 6. valida binarios, registro COM, certificados y salud del broker; 7. instala el guardián de reparación al arranque; -8. sólo entonces ejecuta `Add-Computer` dentro de `OU=Laboratorio` y reinicia; +8. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` si existe, o en el + contenedor de equipos predeterminado del dominio, y reinicia; 9. al arrancar, activa RDP/NLA, WinRM y las reglas Domain, y vuelve a validar el enrolamiento. 10. instala RustDesk desde el MSI oficial comprobado, lo apunta al servidor diff --git a/docs/client-enrollment.md b/docs/client-enrollment.md index dbf730f..eb28d86 100644 --- a/docs/client-enrollment.md +++ b/docs/client-enrollment.md @@ -1,14 +1,15 @@ # Enrolamiento obligatorio de clientes SGU -Para una instalación limpia se selecciona primero el ZIP correspondiente: +Para Windows 10 y Windows 11 se usa un solo paquete: -- `sgu-windows10-legacy-client-bootstrap-VERSION.zip` para Windows 10; -- `sgu-windows11-client-bootstrap-VERSION.zip` para Windows 11. +- `sgu-windows-client-bootstrap-VERSION.zip` (x64, Pro, Enterprise o Education). -Ambos conservan el punto de entrada directo: +Extraer el ZIP y hacer doble clic en `Start-SguClientEnrollment.cmd`. Aceptar +UAC, introducir la IP del controlador y la cuenta del dominio con su contraseña. +También se puede indicar el servidor desde consola: ```bat -Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11 +Start-SguClientEnrollment.cmd 192.168.50.10 ``` Este comando realiza el intercambio de certificados descrito abajo sin mover @@ -16,19 +17,52 @@ 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 +El único dato de red necesario es la IPv4 del controlador. El bootstrap prueba +WinRM desde las direcciones de las interfaces conectadas, incluyendo túneles +VPN, empezando por la ruta elegida por Windows. Si ésta falla, prueba las demás +rutas disponibles. Cliente y servidor pueden estar en subredes distintas. +Si la red todavía está inicializando, reintenta el descubrimiento durante +20 segundos antes de informar el último diagnóstico. +Conserva DHCP, direcciones y puertas de enlace. Sólo si Windows elige otra +interfaz agrega una ruta persistente `/32` hacia ese servidor por la interfaz +comprobada; no modifica la ruta de Internet. Ante cualquier error conserva la +ventana y escribe el diagnóstico en `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`. -El manifiesto contiene el perfil `Windows10Legacy` o `Windows11Modern` y el -bootstrap rechaza un ZIP que no corresponda al build instalado. El paquete de -Windows 11 conserva además `Start-SguAzureClientEnrollment.cmd` y el instalador -P2S de equipo; el legado de Windows 10 se limita al transporte directo del -laboratorio. Credential Provider, mTLS, cuenta `alumno`, RustDesk, monitorización -y autorreparación siguen saliendo de la misma base de código. +El dominio DNS, NetBIOS y contenedor de equipos se descubren en la sesión +autenticada. Usa `OU=Laboratorio` si existe en la raíz del dominio y, en caso +contrario, el contenedor de equipos configurado en AD. Los parámetros +`-DomainName`, `-DomainNetbios` y `-ComputerOuDn` permiten validarlos o elegirlos +explícitamente. Para otra cuenta, editar el usuario sugerido como +`DOMINIO\usuario` o `usuario@dominio`. No se guardan contraseñas. + +El DNS se configura mediante una regla NRPT para el dominio descubierto, +conservando los servidores DNS de los adaptadores y la resolución de Internet. +Las políticas DNS/VPN corporativas deben permitir resolver ese dominio. +El servidor necesita el bootstrap SGU (AD/DNS, WinRM, Auth Broker y RustDesk): +este paquete no es un enrolador genérico de Microsoft Entra ID ni instala SGU +en un directorio ajeno automáticamente. Se comprueban TCP 53, 88, 135, 389, 445, +5985 y 8443; la unión también requiere DNS/Kerberos por UDP y RPC dinámico +según la configuración de AD. Estas pruebas no sustituyen la unión real. + +Una VPN de cualquier proveedor ya conectada utiliza el mismo lanzador. Para +instalar la VPN Azure IKEv2 se incluyen `Start-SguAzureClientEnrollment.cmd` e +`Install-SguAzureP2sClient.ps1` en el mismo ZIP para ambos Windows; la primera +instalación requiere perfil y certificados. La conectividad antes del inicio +de sesión depende de la VPN y sus políticas, no se puede deducir de la IP del DC. + +Una interfaz con sólo APIPA (`169.254.x.x`), sin DHCP o sin ruta necesita que +el administrador configure la red o conecte la VPN. El bootstrap no inventa una +IP libre ni una puerta de enlace. Para una LAN estática se mantienen los +parámetros avanzados `-ClientIPv4Address`, `-ClientPrefixLength` y +`-NetworkInterfaceAlias`; la IP y la interfaz deben indicarse juntas. + +El manifiesto usa `CompatibilityProfile: Auto`. No hay restricciones de VPN +por Windows 10/11; los nombres antiguos de perfil se aceptan como parámetros +obsoletos. Se valida x64, edición con unión a AD y Windows 10 desde 1607 o +Windows 11 (el runtime incluido es .NET 10; usar una edición/build compatible +con su ciclo de soporte). Credential Provider, mTLS, cuenta `alumno`, RustDesk, +monitorización y autorreparación conservan la misma implementación. El flujo administrado instala y valida el Credential Provider **antes** de ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta @@ -91,7 +125,8 @@ Orden de la transacción: 2. registra COM, configura SGU como proveedor predeterminado y oculta el último usuario que cerró sesión; 3. crea o actualiza la cuenta local estándar `alumno` con la contraseña - `ingenieria`, la habilita y garantiza que no pertenezca a Administradores; + inicial `ingenieria` sólo al crearla; si ya existe conserva su contraseña, + la habilita y garantiza que no pertenezca a Administradores; 4. instala el guard de autorreparación; 5. exige health mTLS del broker y ejecuta las comprobaciones locales; 6. configura DNS del dominio; diff --git a/docs/unified-bootstrap-validation.md b/docs/unified-bootstrap-validation.md new file mode 100644 index 0000000..1be78d3 --- /dev/null +++ b/docs/unified-bootstrap-validation.md @@ -0,0 +1,57 @@ +# Validación del bootstrap Windows unificado + +Fecha: 2026-09-10. Paquete: 0.5.1. + +## Comprobaciones locales + +- Publicación Release del Auth Broker y del Credential Provider completada. +- Pruebas Pester ejecutadas en Windows PowerShell 5.1: selección de rutas, + dos interfaces, VPN en otra subred, preferencia por la ruta de Windows, + restricción explícita de interfaz, APIPA, falta de ruta, prefijo más específico, + ruta de host y conflictos, DNS limitado al dominio, reintentos y compatibilidad. +- Prueba TCP real con socket ligado a una IP e interfaz y servicio cerrado. +- Reenrolamiento: se conserva la contraseña de `alumno` si la cuenta ya existe, + para no provocar rechazos de historial/complejidad tras aplicar las políticas + del dominio; se mantienen las verificaciones de permisos de usuario estándar. +- El empaquetador genera un solo ZIP Windows con los lanzadores habitual y Azure, + el instalador VPN, el runtime offline y el manifiesto SHA-256. + +## Prueba real en Hyper-V + +Servidor: VM `Windows Server`, dominio `lci.lasalle.mx`, DC `192.168.50.10`. +Cliente: VM `Windows11-002`, Windows 11 Enterprise LTSC, build 26100, +nombre de equipo `DESKTOP-LM7D7OM`, inicialmente en WORKGROUP. + +Antes de la prueba se creó el checkpoint +`Before SGU unified enrollment 2026-09-10`. El cliente sólo tenía conexión al +`Default Switch`; se añadió la tarjeta `SGU AD Test` al switch `Laboratorio AD` +y se configuró administrativamente `192.168.50.202/24` sin puerta de enlace. +Esta preparación de la red del laboratorio es independiente del bootstrap: +el enrolador no asignó esa dirección y no recibió parámetros de IP del cliente, +interfaz, dominio, NetBIOS ni OU. + +Se ejecutó el paquete con la IP del DC, una credencial en memoria y +`-SkipRestart` para inspeccionar el resultado; después se reinició el cliente. + +Resultados comprobados: + +- Selección automática de `Laboratorio AD` y descubrimiento autenticado de + `lci.lasalle.mx`, `LCI` y `OU=Laboratorio`. +- Proveedor y certificados instalados; salud mTLS verificada antes de la unión. +- Unión al dominio completada y `Test-ComputerSecureChannel` verdadero después + del reinicio. +- `Test-SguClientEnrollment.ps1` con exigencia de dominio, broker, acceso remoto + y RustDesk: `IsValid=True`, sin incidencias, después del guard de arranque. +- Interfaz privada `DomainAuthenticated`; interfaz de Internet `Public`, con + DHCP y su servidor DNS originales. Resolución pública y HTTPS comprobados + contra `www.microsoft.com` (HTTP 200). + +## Alcance pendiente + +Windows 10 se cubrió mediante pruebas de compatibilidad y código compartido, +pero no se ejecutó una instalación real en Windows 10 en esta sesión. La VPN +Azure y otras VPN requieren validación en sus redes reales; las pruebas locales +cubren rutas en otra subred, pero no un gateway Azure activo. + +El servidor debe tener SGU preparado. La IP de un DC no permite crear una VPN, +adivinar una IP libre ni sustituir permisos, DHCP o políticas de firewall. diff --git a/scripts/Enroll-SguDomainClient.ps1 b/scripts/Enroll-SguDomainClient.ps1 index 1d7ebfb..f5883fe 100644 --- a/scripts/Enroll-SguDomainClient.ps1 +++ b/scripts/Enroll-SguDomainClient.ps1 @@ -22,6 +22,7 @@ param( [string]$NewComputerName, [string]$NetworkInterfaceAlias = 'Ethernet', [string[]]$DomainDnsServerAddresses = @('192.168.50.10'), + [switch]$DomainDnsConfigured, [ValidateSet('Direct', 'AzureP2S')] [string]$ConnectivityMode = 'Direct', [string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP', @@ -93,7 +94,10 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo # The broker uses a domain DNS name even before the machine joins the # domain. Point at AD DNS first so the provider-first health check works on # a completely clean Windows installation. - if ($ConnectivityMode -eq 'Direct') { + if ($DomainDnsConfigured) { + # The bootstrap configured domain-scoped NRPT, preserving Internet DNS. + } + elseif ($ConnectivityMode -eq 'Direct') { Set-DnsClientServerAddress ` -InterfaceAlias $NetworkInterfaceAlias ` -ServerAddresses $DomainDnsServerAddresses @@ -144,6 +148,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo $postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress } $postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters + if (-not $postJoin.IsValid) { + throw "SGU validation failed on the joined computer: $($postJoin.Issues -join ' ')" + } $postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser $postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult return $postJoin diff --git a/scripts/Install-SguAzureP2sClient.ps1 b/scripts/Install-SguAzureP2sClient.ps1 index 51c9c56..2ebe251 100644 --- a/scripts/Install-SguAzureP2sClient.ps1 +++ b/scripts/Install-SguAzureP2sClient.ps1 @@ -65,7 +65,9 @@ try { if ($existingConnection) { Remove-VpnConnection -Name $ConnectionName -AllUserConnection -Force } - Add-VpnConnection ` + $dnsParameters = @{} + if ($DomainName) { $dnsParameters.DnsSuffix = $DomainName } + Add-VpnConnection @dnsParameters ` -Name $ConnectionName ` -ServerAddress $vpnServer ` -TunnelType Ikev2 ` @@ -75,22 +77,24 @@ try { -EncryptionLevel Required ` -SplitTunneling ` -AllUserConnection ` - -DnsSuffix $DomainName ` -Force | Out-Null foreach ($prefix in $AzureNetworkPrefixes) { Add-VpnConnectionRoute -ConnectionName $ConnectionName ` -DestinationPrefix $prefix -AllUserConnection -PassThru | Out-Null } - $nrptDisplayName = "SGU Azure P2S DNS - $DomainName" - Get-DnsClientNrptRule -ErrorAction SilentlyContinue | - Where-Object DisplayName -eq $nrptDisplayName | - Remove-DnsClientNrptRule -Force - Add-DnsClientNrptRule ` - -Namespace ".$DomainName" ` - -NameServers $DomainControllerIPv4Address.IPAddressToString ` - -DisplayName $nrptDisplayName ` - -Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null + # The unified bootstrap can discover the domain after connecting. + if ($DomainName) { + $nrptDisplayName = "SGU Azure P2S DNS - $DomainName" + Get-DnsClientNrptRule -ErrorAction SilentlyContinue | + Where-Object DisplayName -eq $nrptDisplayName | + Remove-DnsClientNrptRule -Force + Add-DnsClientNrptRule ` + -Namespace ".$DomainName" ` + -NameServers $DomainControllerIPv4Address.IPAddressToString ` + -DisplayName $nrptDisplayName ` + -Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null + } } if ($Connect) { diff --git a/scripts/Invoke-SguClientBootstrap.ps1 b/scripts/Invoke-SguClientBootstrap.ps1 index 2b3eb98..0fef596 100644 --- a/scripts/Invoke-SguClientBootstrap.ps1 +++ b/scripts/Invoke-SguClientBootstrap.ps1 @@ -7,8 +7,8 @@ param( [ValidateRange(1, 32)] [int]$ClientPrefixLength = 24, [PSCredential]$DomainCredential, - [string]$DomainName = 'lci.lasalle.mx', - [string]$DomainNetbios = 'LCI', + [string]$DomainName, + [string]$DomainNetbios, [string]$ComputerOuDn, [string]$NewComputerName, [ValidateSet('Auto', 'Windows10Legacy', 'Windows11Modern')] @@ -27,8 +27,6 @@ param( $ErrorActionPreference = 'Stop' $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' @@ -91,33 +89,69 @@ function Assert-PackageManifest { } function Resolve-ClientInterfaceAlias { - param([string]$RequestedAlias) + param( + [string]$RequestedAlias, + [Parameter(Mandatory)][ipaddress]$DomainControllerAddress + ) - if ($RequestedAlias) { - Get-NetAdapter -Name $RequestedAlias -ErrorAction Stop | Out-Null - return $RequestedAlias - } - - $upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up') - $withoutDefaultGateway = @($upAdapters | Where-Object { - -not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway + # IP interfaces include tunnel/PPP adapters that Get-NetAdapter can omit. + $interfaces = @(Get-NetIPInterface -AddressFamily IPv4 | Where-Object { + $_.ConnectionState -eq 'Connected' -and + (-not $RequestedAlias -or $_.InterfaceAlias -eq $RequestedAlias) }) - if ($withoutDefaultGateway.Count -eq 1) { - return [string]$withoutDefaultGateway[0].Name + $preferred = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString ` + -ErrorAction SilentlyContinue | Where-Object { $_.PSObject.Properties['IPAddress'] }) + $attempts = @() + $candidates = @(foreach ($interface in $interfaces) { + $addresses = @(Get-NetIPAddress -InterfaceIndex $interface.InterfaceIndex -AddressFamily IPv4 ` + -ErrorAction SilentlyContinue | Where-Object { + $_.AddressState -eq 'Preferred' -and -not $_.SkipAsSource -and + $_.IPAddress -notmatch '^(0\.|127\.|169\.254\.)' + }) + if ($addresses.Count -eq 0) { + $attempts += "$($interface.InterfaceAlias): no usable IPv4 address (check DHCP/static configuration)" + continue + } + $route = Get-NetRoute -InterfaceIndex $interface.InterfaceIndex -AddressFamily IPv4 ` + -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Where-Object { + $parts = $_.DestinationPrefix -split '/' + Test-IPv4AddressesSharePrefix -FirstAddress $DomainControllerAddress ` + -SecondAddress ([ipaddress]$parts[0]) -PrefixLength ([int]$parts[1]) + } | Sort-Object @{ Expression = { [int]($_.DestinationPrefix -split '/')[1] }; Descending = $true }, + RouteMetric | Select-Object -First 1 + if ($route) { + foreach ($address in $addresses) { + [pscustomobject]@{ + InterfaceAlias = $interface.InterfaceAlias + InterfaceIndex = [int]$interface.InterfaceIndex + IPAddress = $address.IPAddress + NextHop = $route.NextHop + Preferred = @($preferred | Where-Object IPAddress -eq $address.IPAddress).Count -gt 0 + PrefixLength = [int]($route.DestinationPrefix -split '/')[1] + Metric = [int]$route.RouteMetric + [int]$interface.InterfaceMetric + } + } + } + else { $attempts += "$($interface.InterfaceAlias): no route to $DomainControllerAddress" } + }) + if ($interfaces.Count -eq 0) { $attempts += 'No matching connected IPv4 interface' } + foreach ($candidate in ($candidates | Sort-Object @{ Expression = { $_.Preferred }; Descending = $true }, + @{ Expression = { $_.PrefixLength }; Descending = $true }, Metric, InterfaceIndex, IPAddress)) { + Write-Host "Checking $($candidate.InterfaceAlias) ($($candidate.IPAddress)) -> $DomainControllerAddress..." + if (Test-TcpPort -Address $DomainControllerAddress -Port 5985 -TimeoutMilliseconds 2000 ` + -SourceAddress ([ipaddress]$candidate.IPAddress) -InterfaceIndex $candidate.InterfaceIndex) { + return $candidate + } + $attempts += "$($candidate.InterfaceAlias) [$($candidate.IPAddress), next hop $($candidate.NextHop)]: TCP 5985 unavailable" } - if ($upAdapters.Count -eq 1) { - return [string]$upAdapters[0].Name - } - - $aliases = ($upAdapters.Name | Sort-Object) -join ', ' - throw "Could not select the private domain adapter unambiguously. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases" + throw "Cannot reach SGU server $DomainControllerAddress. $($attempts -join '; '). Check the LAN/VPN connection, DHCP or an administrator-assigned IP, routes and the server WinRM firewall. No client IP was changed." } function Test-IPv4AddressesSharePrefix { param( [Parameter(Mandatory)][ipaddress]$FirstAddress, [Parameter(Mandatory)][ipaddress]$SecondAddress, - [Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength + [Parameter(Mandatory)][ValidateRange(0, 32)][int]$PrefixLength ) if ($FirstAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or @@ -147,6 +181,27 @@ function Test-IPv4AddressesSharePrefix { return $true } +function Wait-ClientInterface { + param( + [string]$RequestedAlias, + [Parameter(Mandatory)][ipaddress]$DomainControllerAddress, + [int]$TimeoutSeconds = 20 + ) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + try { + return Resolve-ClientInterfaceAlias -RequestedAlias $RequestedAlias ` + -DomainControllerAddress $DomainControllerAddress + } + catch { + $lastFailure = $_ + if ((Get-Date) -ge $deadline) { throw $lastFailure } + Write-Host 'Waiting for DHCP, VPN routes or server connectivity to become ready...' + Start-Sleep -Seconds 2 + } + } while ($true) +} + function Assert-UsableClientIPv4Address { param( [Parameter(Mandatory)][ipaddress]$Address, @@ -192,7 +247,7 @@ function Set-ClientDomainAddress { return [ipaddress]$matchingAddress.IPAddress } if (-not $RequestedAddress) { - $RequestedAddress = [ipaddress](Read-Host "Fixed IPv4 address for this SGU client on '$InterfaceAlias'") + throw 'Static addressing requires an explicit -ClientIPv4Address. Automatic enrollment preserves DHCP and existing addresses.' } Assert-UsableClientIPv4Address -Address $RequestedAddress ` -DomainControllerAddress $DomainControllerAddress -PrefixLength $PrefixLength @@ -231,11 +286,22 @@ function Test-TcpPort { param( [Parameter(Mandatory)][ipaddress]$Address, [Parameter(Mandatory)][int]$Port, - [int]$TimeoutMilliseconds = 5000 + [int]$TimeoutMilliseconds = 5000, + [ipaddress]$SourceAddress, + [int]$InterfaceIndex ) - $client = [Net.Sockets.TcpClient]::new() + $client = [Net.Sockets.TcpClient]::new([Net.Sockets.AddressFamily]::InterNetwork) + $connect = $null try { + if ($InterfaceIndex) { + # IP_UNICAST_IF (31) expects the interface index in network byte order. + $client.Client.SetSocketOption([Net.Sockets.SocketOptionLevel]::IP, + [Net.Sockets.SocketOptionName]31, [Net.IPAddress]::HostToNetworkOrder($InterfaceIndex)) + } + if ($SourceAddress) { + $client.Client.Bind([Net.IPEndPoint]::new($SourceAddress, 0)) + } $connect = $client.BeginConnect($Address, $Port, $null, $null) if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) { return $false @@ -248,6 +314,61 @@ function Test-TcpPort { } finally { $client.Dispose() + if ($connect) { $connect.AsyncWaitHandle.Close() } + } +} + +function Set-ClientServerRoute { + param( + [Parameter(Mandatory)]$SelectedInterface, + [Parameter(Mandatory)][ipaddress]$DomainControllerAddress + ) + $current = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString | + Where-Object { $_.PSObject.Properties['IPAddress'] }) + if (@($current | Where-Object InterfaceIndex -eq $SelectedInterface.InterfaceIndex).Count -gt 0) { return } + + # Only pin this server when Windows currently chooses a different interface. + # Do not replace default routes or change interface metrics used by Internet traffic. + $route = New-NetRoute -DestinationPrefix "$DomainControllerAddress/32" ` + -InterfaceIndex $SelectedInterface.InterfaceIndex -NextHop $SelectedInterface.NextHop ` + -RouteMetric 1 + $current = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString | + Where-Object { $_.PSObject.Properties['IPAddress'] }) + if (@($current | Where-Object InterfaceIndex -eq $SelectedInterface.InterfaceIndex).Count -eq 0) { + $route | Remove-NetRoute -Confirm:$false + throw "Windows still routes $DomainControllerAddress through another interface. Resolve conflicting host routes or VPN policies and retry." + } +} + +function Set-ClientDomainDns { + param( + [Parameter(Mandatory)][string]$DnsDomain, + [Parameter(Mandatory)][ipaddress]$ServerAddress + ) + $displayName = "SGU domain DNS - $DnsDomain" + $existing = @(Get-DnsClientNrptRule -ErrorAction Stop | Where-Object DisplayName -eq $displayName) + if ($existing.Count -eq 1 -and @($existing[0].NameServers) -contains $ServerAddress.IPAddressToString -and + @($existing[0].Namespace) -contains ".$DnsDomain" -and @($existing[0].Namespace) -contains $DnsDomain) { return } + $existing | Remove-DnsClientNrptRule -Force + Add-DnsClientNrptRule -Namespace @($DnsDomain, ".$DnsDomain") ` + -NameServers $ServerAddress.IPAddressToString -DisplayName $displayName | Out-Null + Clear-DnsClientCache +} + +function Assert-ClientOperatingSystem { + param( + [Parameter(Mandatory)]$OperatingSystem, + [Parameter(Mandatory)][string]$Edition, + [Parameter(Mandatory)][string]$Architecture + ) + if ([int]$OperatingSystem.ProductType -ne 1) { + throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.' + } + if ([int]$OperatingSystem.BuildNumber -lt 14393 -or $Architecture -ne 'AMD64') { + throw 'This package requires Windows 10 1607 or later, or Windows 11, running x64 Windows PowerShell.' + } + if ($Edition -match '^Core' -or $Edition -match 'Home') { + throw "Windows edition '$Edition' cannot join an Active Directory domain. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again." } } @@ -294,56 +415,28 @@ function Connect-SguAzureP2s { Assert-Administrator $operatingSystem = Get-CimInstance Win32_OperatingSystem -if ([int]$operatingSystem.ProductType -ne 1) { - throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.' -} - $edition = (Get-WindowsEdition -Online).Edition -if ($edition -match '^Core' -or $edition -match 'Home') { - throw "Windows edition '$edition' cannot join an on-premises Active Directory domain or host RDP. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again." -} +Assert-ClientOperatingSystem -OperatingSystem $operatingSystem -Edition $edition ` + -Architecture $env:PROCESSOR_ARCHITECTURE +$windowsBuild = [int]$operatingSystem.BuildNumber +$windowsName = if ($windowsBuild -ge 22000) { 'Windows 11' } else { 'Windows 10' } +Write-Host "$windowsName (build $windowsBuild): unified SGU enrollment." if (-not $DomainControllerIPv4Address) { $DomainControllerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address of the SGU domain controller') } -if (-not $ComputerOuDn) { - $baseDn = (($DomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ',' - $ComputerOuDn = "OU=Laboratorio,$baseDn" +if ($DomainControllerIPv4Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or + $DomainControllerIPv4Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.|24\d\.|25[0-5]\.)') { + throw 'Enter a reachable unicast IPv4 address for the domain controller.' } $packageRoot = $PSScriptRoot $packageManifest = Assert-PackageManifest -PackageRoot $packageRoot -$manifestProfile = if ($packageManifest.PSObject.Properties['CompatibilityProfile']) { - [string]$packageManifest.CompatibilityProfile -} -else { - 'Auto' -} -if ($CompatibilityProfile -ne 'Auto' -and $manifestProfile -ne 'Auto' -and - $CompatibilityProfile -ne $manifestProfile) { - throw "The requested compatibility profile '$CompatibilityProfile' does not match package profile '$manifestProfile'." -} -if ($CompatibilityProfile -eq 'Auto') { - $CompatibilityProfile = $manifestProfile -} -$windowsBuild = [int]$operatingSystem.BuildNumber -if ($CompatibilityProfile -eq 'Auto') { - $CompatibilityProfile = if ($windowsBuild -lt 22000) { - 'Windows10Legacy' - } - else { - 'Windows11Modern' - } -} -if ($CompatibilityProfile -eq 'Windows10Legacy' -and $windowsBuild -ge 22000) { - throw "The Windows 10 legacy package cannot enroll Windows build $windowsBuild. Use the Windows 11 modern client package." -} -if ($CompatibilityProfile -eq 'Windows11Modern' -and $windowsBuild -lt 22000) { - throw "The Windows 11 modern package cannot enroll Windows build $windowsBuild. Use the Windows 10 legacy client package." -} -if ($CompatibilityProfile -eq 'Windows10Legacy' -and $ConnectivityMode -eq 'AzureP2S') { - throw 'Azure P2S pre-logon enrollment belongs to the Windows 11 modern package. Use Direct connectivity for the Windows 10 legacy package.' +# Retain the old parameter for existing automation; neither name restricts networking. +if ($CompatibilityProfile -ne 'Auto') { + Write-Warning 'CompatibilityProfile is deprecated. This package uses the same implementation on Windows 10 and 11.' } +$CompatibilityProfile = 'Auto' $scriptsRoot = Join-Path $packageRoot 'payload\scripts' $providerPublishPath = Join-Path $packageRoot 'payload\credential-provider' $runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') ` @@ -362,6 +455,15 @@ foreach ($requiredPath in @( if (-not $runtimeInstaller) { throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.' } +if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enroll with SGU server $DomainControllerIPv4Address")) { return } +if ($ClientIPv4Address) { + if (-not $NetworkInterfaceAlias -or $ConnectivityMode -eq 'AzureP2S') { + throw 'Explicit static IP setup requires -NetworkInterfaceAlias with Direct connectivity. Omit -ClientIPv4Address to preserve the current LAN/VPN configuration.' + } + $ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias ` + -DomainControllerAddress $DomainControllerIPv4Address ` + -RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength +} if ($ConnectivityMode -eq 'AzureP2S') { $existingVpnConnection = Get-VpnConnection -Name $VpnConnectionName -AllUserConnection ` @@ -394,34 +496,26 @@ if ($ConnectivityMode -eq 'AzureP2S') { & $installerPath @vpnInstallParameters | Out-Null } $vpnConnection = Connect-SguAzureP2s -ConnectionName $VpnConnectionName - $nrptDisplayName = "SGU Azure P2S DNS - $DomainName" - $nrptRule = Get-DnsClientNrptRule -ErrorAction SilentlyContinue | - Where-Object DisplayName -eq $nrptDisplayName | - Select-Object -First 1 - if (-not $nrptRule -or - @($nrptRule.NameServers) -notcontains $DomainControllerIPv4Address.IPAddressToString) { - throw "The SGU NRPT rule for $DomainName is missing or does not point to $DomainControllerIPv4Address. Re-run Install-SguAzureP2sClient.ps1." - } - $NetworkInterfaceAlias = $vpnConnection.Name -} -else { - $NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias - $ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias ` - -DomainControllerAddress $DomainControllerIPv4Address ` - -RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength - Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias ` - -ServerAddresses $DomainControllerIPv4Address.IPAddressToString } +$selectedInterface = Wait-ClientInterface -RequestedAlias $NetworkInterfaceAlias ` + -DomainControllerAddress $DomainControllerIPv4Address +$NetworkInterfaceAlias = $selectedInterface.InterfaceAlias +$ClientIPv4Address = [ipaddress]$selectedInterface.IPAddress +Set-ClientServerRoute -SelectedInterface $selectedInterface -DomainControllerAddress $DomainControllerIPv4Address +Write-Host "Using $NetworkInterfaceAlias ($ClientIPv4Address)." if (-not (Wait-TcpPort -Address $DomainControllerIPv4Address -Port 5985 -TimeoutSeconds 20)) { throw "The domain controller at $DomainControllerIPv4Address did not accept WinRM on TCP 5985 after 20 seconds. Run the server bootstrap first and verify the selected IP." } if (-not $DomainCredential) { + $suggestedUser = if ($DomainNetbios) { "$DomainNetbios\Administrator" } + elseif ($DomainName) { "Administrator@$DomainName" } else { 'Administrator' } $DomainCredential = Get-Credential ` - -UserName "$DomainNetbios\Administrator" ` - -Message "Credential permitted to enroll this computer in $DomainName" + -UserName $suggestedUser ` + -Message "Domain account permitted to enroll this computer (DOMAIN\user or user@domain)" } +if (-not $DomainCredential) { throw 'Enrollment cancelled: no domain credential was provided.' } New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null $clientCertificatePath = Join-Path $temporaryRoot 'client.cer' @@ -451,6 +545,12 @@ try { $serverIdentity = Invoke-Command -Session $session -ScriptBlock { $computer = Get-CimInstance Win32_ComputerSystem + if ([int]$computer.DomainRole -lt 4) { throw 'The supplied server is not an Active Directory domain controller.' } + Import-Module ActiveDirectory -ErrorAction Stop + $domain = Get-ADDomain -ErrorAction Stop + $labOu = Get-ADOrganizationalUnit -LDAPFilter '(ou=Laboratorio)' ` + -SearchBase $domain.DistinguishedName -SearchScope OneLevel -ErrorAction Stop | + Select-Object -First 1 $brokerService = Get-Service SGUAuthBroker -ErrorAction SilentlyContinue $rustDeskStatusPath = Join-Path $env:ProgramData 'SGU\RustDesk\server.json' $rustDeskStatus = if (Test-Path -LiteralPath $rustDeskStatusPath -PathType Leaf) { @@ -464,6 +564,8 @@ try { [pscustomobject]@{ ComputerName = $env:COMPUTERNAME Domain = $computer.Domain + DomainNetbios = $domain.NetBIOSName + ComputerContainer = if ($labOu) { $labOu.DistinguishedName } else { $domain.ComputersContainer } BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' } RustDeskServerAddress = if ($rustDeskStatus) { [string]$rustDeskStatus.ServerAddress } else { $null } RustDeskPublicKey = if ($rustDeskStatus) { [string]$rustDeskStatus.PublicKey } else { $null } @@ -471,10 +573,19 @@ try { RustDeskHbbrTask = if ($hbbrTask) { $hbbrTask.State.ToString() } else { 'Missing' } } } - if (-not $serverIdentity.Domain -or - -not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase)) { + if (-not $serverIdentity.Domain -or ($DomainName -and + -not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase))) { throw "The server at $DomainControllerIPv4Address belongs to $($serverIdentity.Domain), not $DomainName." } + if ($DomainNetbios -and $DomainNetbios -ne $serverIdentity.DomainNetbios) { + throw "The supplied NetBIOS domain '$DomainNetbios' does not match '$($serverIdentity.DomainNetbios)'." + } + $DomainName = [string]$serverIdentity.Domain + $DomainNetbios = [string]$serverIdentity.DomainNetbios + if (-not $ComputerOuDn) { $ComputerOuDn = [string]$serverIdentity.ComputerContainer } + $brokerDnsName = "$brokerRecordName.$DomainName" + $brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate" + Write-Host "Discovered domain: $DomainName ($DomainNetbios). Computer container: $ComputerOuDn" if ($serverIdentity.BrokerService -ne 'Running') { throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)." } @@ -484,6 +595,12 @@ try { $serverIdentity.RustDeskHbbrTask -ne 'Running') { throw "The RustDesk server is not ready on $($serverIdentity.ComputerName). Run the current server bootstrap first." } + Set-ClientDomainDns -DnsDomain $DomainName -ServerAddress $DomainControllerIPv4Address + foreach ($port in @(53, 88, 135, 389, 445, 8443)) { + if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port $port -TimeoutMilliseconds 2000)) { + throw "Server $DomainControllerIPv4Address is reachable, but required TCP port $port is unavailable. Check AD/SGU services and the LAN/VPN firewall. Domain join has not started." + } + } $certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME" $clientCertificate = Get-ChildItem Cert:\LocalMachine\My | @@ -566,6 +683,7 @@ try { ComputerOuDn = $ComputerOuDn NetworkInterfaceAlias = $NetworkInterfaceAlias DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString) + DomainDnsConfigured = $true ConnectivityMode = $ConnectivityMode RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP" DotNetRuntimeInstallerPath = $runtimeInstaller.FullName diff --git a/scripts/New-SguBootstrapPackages.ps1 b/scripts/New-SguBootstrapPackages.ps1 index 6b6e9c0..dde3564 100644 --- a/scripts/New-SguBootstrapPackages.ps1 +++ b/scripts/New-SguBootstrapPackages.ps1 @@ -34,7 +34,7 @@ function Write-PackageManifest { [Parameter(Mandatory)][string]$PackageRoot, [Parameter(Mandatory)][string]$PackageVersion, [Parameter(Mandatory)][string]$PackageKind, - [ValidateSet('Windows10Legacy', 'Windows11Modern')] + [ValidateSet('Auto')] [string]$CompatibilityProfile, [string]$TargetOperatingSystem ) @@ -87,27 +87,24 @@ if (-not $runtimeInstaller) { } New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null -$windows11ClientRoot = Join-Path $resolvedOutputRoot "sgu-windows11-client-bootstrap-$Version" -$windows10ClientRoot = Join-Path $resolvedOutputRoot "sgu-windows10-legacy-client-bootstrap-$Version" -$clientRoot = $windows11ClientRoot +$clientRoot = Join-Path $resolvedOutputRoot "sgu-windows-client-bootstrap-$Version" $serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version" $linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version" $azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version" -$windows11ClientZip = "$windows11ClientRoot.zip" -$windows10ClientZip = "$windows10ClientRoot.zip" +$clientZip = "$clientRoot.zip" $serverZip = "$serverRoot.zip" $linuxClientZip = "$linuxClientRoot.zip" $azureZip = "$azureRoot.zip" foreach ($target in @( - $windows11ClientRoot,$windows10ClientRoot,$serverRoot,$linuxClientRoot,$azureRoot, - $windows11ClientZip,$windows10ClientZip,$serverZip,$linuxClientZip,$azureZip)) { + $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot, + $clientZip,$serverZip,$linuxClientZip,$azureZip)) { if (Test-Path -LiteralPath $target) { throw "Release target already exists: $target" } } New-Item -ItemType Directory ` - -Path $windows11ClientRoot,$windows10ClientRoot,$serverRoot,$linuxClientRoot,$azureRoot ` + -Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot ` -Force | Out-Null $welcomeFontNames = @( 'IndivisaTextSans-Regular.otf', @@ -154,25 +151,19 @@ foreach ($fontName in $welcomeFontNames) { Copy-RequiredFile -Source $runtimeInstaller.FullName ` -Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)") -# Both Windows packages share the provider and enrollment implementation. The -# Windows 10 artifact freezes the direct-network compatibility surface, while -# the Windows 11 artifact adds the modern Azure P2S/pre-logon entry point. -Copy-Item -Path (Join-Path $windows11ClientRoot '*') ` - -Destination $windows10ClientRoot -Recurse -Force +# One Windows implementation supports existing LAN/VPN routes and optional Azure P2S. Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') ` - -Destination (Join-Path $windows11ClientRoot 'Start-SguAzureClientEnrollment.cmd') + -Destination (Join-Path $clientRoot 'Start-SguAzureClientEnrollment.cmd') Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') ` - -Destination (Join-Path $windows11ClientRoot 'Install-SguAzureP2sClient.ps1') + -Destination (Join-Path $clientRoot 'Install-SguAzureP2sClient.ps1') +Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\client-enrollment.md') ` + -Destination (Join-Path $clientRoot 'README.md') +Copy-Item -Path (Join-Path $repositoryRoot 'docs\*.md') -Destination $clientRoot -Write-PackageManifest -PackageRoot $windows10ClientRoot -PackageVersion $Version ` - -PackageKind WindowsClient -CompatibilityProfile Windows10Legacy ` - -TargetOperatingSystem 'Windows 10 Pro, Enterprise, or Education (build below 22000)' -Write-PackageManifest -PackageRoot $windows11ClientRoot -PackageVersion $Version ` - -PackageKind WindowsClient -CompatibilityProfile Windows11Modern ` - -TargetOperatingSystem 'Windows 11 Pro, Enterprise, or Education (build 22000 or later)' -Compress-Archive -Path (Join-Path $windows10ClientRoot '*') -DestinationPath $windows10ClientZip ` - -CompressionLevel Optimal -Compress-Archive -Path (Join-Path $windows11ClientRoot '*') -DestinationPath $windows11ClientZip ` +Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version ` + -PackageKind WindowsClient -CompatibilityProfile Auto ` + -TargetOperatingSystem 'Windows 10 1607+ or Windows 11; x64 Pro, Enterprise, or Education' +Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip ` -CompressionLevel Optimal # Linux clients use their native PAM/SSSD sign-in stack rather than the Windows @@ -272,8 +263,7 @@ Compress-Archive -Path (Join-Path $azureRoot '*') -DestinationPath $azureZip ` -CompressionLevel Optimal $checksums = @( - ("{0} {1}" -f (Get-FileHash -LiteralPath $windows10ClientZip -Algorithm SHA256).Hash, (Split-Path $windows10ClientZip -Leaf)) - ("{0} {1}" -f (Get-FileHash -LiteralPath $windows11ClientZip -Algorithm SHA256).Hash, (Split-Path $windows11ClientZip -Leaf)) + ("{0} {1}" -f (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash, (Split-Path $clientZip -Leaf)) ("{0} {1}" -f (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash, (Split-Path $serverZip -Leaf)) ("{0} {1}" -f (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash, (Split-Path $linuxClientZip -Leaf)) ("{0} {1}" -f (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash, (Split-Path $azureZip -Leaf)) @@ -283,10 +273,8 @@ $checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt" [pscustomobject]@{ Version = $Version - Windows10LegacyClientPackage = $windows10ClientZip - Windows10LegacyClientSha256 = (Get-FileHash -LiteralPath $windows10ClientZip -Algorithm SHA256).Hash - Windows11ClientPackage = $windows11ClientZip - Windows11ClientSha256 = (Get-FileHash -LiteralPath $windows11ClientZip -Algorithm SHA256).Hash + WindowsClientPackage = $clientZip + WindowsClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash LinuxClientPackage = $linuxClientZip LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash ServerPackage = $serverZip diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 index 195d42a..de34871 100644 --- a/scripts/Publish-GiteaRelease.ps1 +++ b/scripts/Publish-GiteaRelease.ps1 @@ -15,8 +15,7 @@ param( $ErrorActionPreference = 'Stop' $tagName = "v$Version" $assetPaths = @( - (Join-Path $ReleaseDirectory "sgu-windows10-legacy-client-bootstrap-$Version.zip"), - (Join-Path $ReleaseDirectory "sgu-windows11-client-bootstrap-$Version.zip"), + (Join-Path $ReleaseDirectory "sgu-windows-client-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-azure-infrastructure-$Version.zip"), @@ -112,14 +111,13 @@ 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-windows10-legacy-client-bootstrap-$Version.zip`: perfil directo para Windows 10 de laboratorio, con las correcciones de NIC privada, límites de cuentas locales y compatibilidad de sus APIs heredadas. -- `sgu-windows11-client-bootstrap-$Version.zip`: perfil completo para Windows 11; conserva el enrolamiento directo y añade Azure P2S con certificado de máquina y entrada previa al inicio de sesión. -- Ambos clientes comparten los mismos binarios, seguridad mTLS, Credential Provider, cuenta estándar, RustDesk, supervisión y autorreparación; el manifiesto impide ejecutar accidentalmente el paquete de la otra versión de Windows. -- 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, espera a que la dirección y WinRM estén disponibles y conserva en pantalla y archivo cualquier error de enrolamiento. +- `sgu-windows-client-bootstrap-$Version.zip`: paquete único para Windows 10 1607+ y Windows 11 x64 Pro, Enterprise o Education, con LAN, VPN existente y Azure P2S opcional. +- Doble clic en `Start-SguClientEnrollment.cmd`, IP del servidor y credenciales: descubre el dominio autenticado, comprueba las interfaces y rutas disponibles y conserva DHCP, las IP del cliente y el DNS de Internet. Ya no solicita una IP del cliente. +- Conserva seguridad mTLS, Credential Provider, cuenta estándar, RustDesk, supervisión y autorreparación. El servidor debe estar preparado con SGU y ser accesible por LAN o VPN. - `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. -- Los Windows 11 Pro pueden instalar un perfil IKEv2 de todos los usuarios con certificado de máquina, DNS dividido para `lci.lasalle.mx` y ejecutarlo desde la pantalla de inicio de sesión antes de autenticar una cuenta de dominio nueva. +- Windows 10 y 11 pueden instalar el perfil IKEv2 de todos los usuarios con certificado de máquina y DNS dividido del dominio; la disponibilidad antes del inicio de sesión depende del perfil y de las políticas del equipo. - El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea cada grupo dentro de la OU de su rol y migra idempotentemente cualquier grupo heredado sin cambiar su SID. - El Auth Broker resuelve la dirección guardada de administrativos y docentes mediante `GetDireccion`, `GetLocalidadListado` y `GetColoniasListado`, evitando conservar los valores transitorios `Seleccione...` de los controles dinámicos de SGU. - El enrolamiento y la reparación de clientes Windows crean y verifican idempotentemente la cuenta local estándar `alumno`, sin pertenencia al grupo de administradores. diff --git a/scripts/Set-SguStandardLocalUser.ps1 b/scripts/Set-SguStandardLocalUser.ps1 index 575ca25..441e48a 100644 --- a/scripts/Set-SguStandardLocalUser.ps1 +++ b/scripts/Set-SguStandardLocalUser.ps1 @@ -33,8 +33,9 @@ try { } if ($user) { + # Preserve existing credentials on enrollment/repair. Resetting the same + # password after domain join can violate password history/complexity. Set-LocalUser -Name $userName ` - -Password $securePassword ` -PasswordNeverExpires $true ` -Description $description if (-not $user.Enabled) { diff --git a/scripts/Start-SguAzureClientEnrollment.cmd b/scripts/Start-SguAzureClientEnrollment.cmd index ef666d0..cccc9de 100644 --- a/scripts/Start-SguAzureClientEnrollment.cmd +++ b/scripts/Start-SguAzureClientEnrollment.cmd @@ -8,7 +8,7 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Jo set "SGU_EXIT_CODE=%errorlevel%" if not "%SGU_EXIT_CODE%"=="0" ( echo. - echo SGU Windows 11 Azure enrollment did not complete. Review: + echo SGU Windows Azure enrollment did not complete. Review: echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log pause ) diff --git a/tests/BootstrapNetwork.Tests.ps1 b/tests/BootstrapNetwork.Tests.ps1 index 6b4cb02..f8626f6 100644 --- a/tests/BootstrapNetwork.Tests.ps1 +++ b/tests/BootstrapNetwork.Tests.ps1 @@ -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' + } +} diff --git a/tests/ClientEnrollmentScripts.Tests.ps1 b/tests/ClientEnrollmentScripts.Tests.ps1 index 0e3d916..039c7d6 100644 --- a/tests/ClientEnrollmentScripts.Tests.ps1 +++ b/tests/ClientEnrollmentScripts.Tests.ps1 @@ -42,24 +42,34 @@ Describe 'SGU Windows client enrollment scripts' { $enrollmentTestSource | Should Not Match '\$standardLocalUser\.PasswordNeverExpires' } - It 'publishes separate legacy Windows 10 and modern Windows 11 artifacts' { - $packageSource = Get-Content -LiteralPath $packageScriptPath -Raw - $releaseSource = Get-Content -LiteralPath $releaseScriptPath -Raw - $packageSource | Should Match 'sgu-windows10-legacy-client-bootstrap-\$Version' - $packageSource | Should Match 'sgu-windows11-client-bootstrap-\$Version' - $packageSource | Should Match '-CompatibilityProfile Windows10Legacy' - $packageSource | Should Match '-CompatibilityProfile Windows11Modern' - $releaseSource | Should Match 'sgu-windows10-legacy-client-bootstrap-\$Version\.zip' - $releaseSource | Should Match 'sgu-windows11-client-bootstrap-\$Version\.zip' + It 'preserves existing local credentials when enrollment is repeated under domain password policies' { + $updates = $scriptAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -eq 'Set-LocalUser' + }, $true) + $updates.Count | Should Be 1 + @($updates[0].CommandElements | Where-Object { + $_ -is [Management.Automation.Language.CommandParameterAst] -and $_.ParameterName -eq 'Password' + }).Count | Should Be 0 } - It 'keeps Azure P2S in the modern Windows 11 artifact' { + It 'publishes one Windows artifact with automatic compatibility' { + $packageSource = Get-Content -LiteralPath $packageScriptPath -Raw + $releaseSource = Get-Content -LiteralPath $releaseScriptPath -Raw + $packageSource | Should Match 'sgu-windows-client-bootstrap-\$Version' + $packageSource | Should Match '-CompatibilityProfile Auto' + $packageSource | Should Not Match 'sgu-windows10-legacy|sgu-windows11-client' + $releaseSource | Should Match 'sgu-windows-client-bootstrap-\$Version\.zip' + $releaseSource | Should Not Match 'sgu-windows10-legacy|sgu-windows11-client' + } + + It 'includes Azure P2S in the shared Windows artifact' { $packageSource = Get-Content -LiteralPath $packageScriptPath -Raw $azureLauncher = Get-Content -LiteralPath $azureLauncherPath -Raw - $packageSource.Contains("Join-Path `$windows11ClientRoot 'Start-SguAzureClientEnrollment.cmd'") | + $packageSource.Contains("Join-Path `$clientRoot 'Start-SguAzureClientEnrollment.cmd'") | + Should Be $true + $packageSource.Contains("Join-Path `$clientRoot 'Install-SguAzureP2sClient.ps1'") | Should Be $true - $packageSource.Contains("Join-Path `$windows10ClientRoot 'Start-SguAzureClientEnrollment.cmd'") | - Should Be $false $azureLauncher | Should Match '-PauseOnError' } }