Compare commits

...
7 Commits
24 changed files with 1702 additions and 122 deletions
+23 -2
View File
@@ -9,7 +9,7 @@ source and adds an SGU-specific provider, an mTLS-protected broker, Active
Directory synchronization, deployment scripts, and tests.
Ready-to-run bootstrap packages are published on the
[releases page](https://github.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
[releases page](https://gitea.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
## Authentication contract
@@ -44,6 +44,13 @@ skips that optional field. Missing or changed presentation HTML never blocks
authentication or password synchronization after the lightweight NTLM root has
accepted the credential.
For administrative staff and professors, the location page is enriched with its
ASP.NET PageMethods responses. `GetDireccion` supplies the saved state,
municipality and neighborhood identifiers; `GetLocalidadListado` resolves the
municipality name, and `GetColoniasListado` validates or supplies the
neighborhood name. This avoids reading the temporary `Seleccione...` values
visible while the browser populates those controls asynchronously.
Operational documentation:
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md)
@@ -104,13 +111,27 @@ 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, the supported entry points are the release packages:
For a clean Windows 10 or Windows 11 workstation, use the unified package:
- `sgu-windows-client-bootstrap-VERSION.zip` (x64 Pro, Enterprise or Education),
including optional Azure P2S setup for both versions.
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
```
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:
+5 -1
View File
@@ -28,7 +28,11 @@ It uses the administrative incident overview for `AD`, the student information
page for `AL`, and the portal menu as a conservative base for `DO`. After the
incident page confirms an `AD` employee number, two additional GETs in the same
in-memory session read the structured name and selected sex from
`datos/personales.aspx` and the address from `datos/ubicacion.aspx`. Docentes request
`datos/personales.aspx` and the address inputs from `datos/ubicacion.aspx`. The
broker then calls the location page's `GetDireccion`, `GetLocalidadListado`, and
`GetColoniasListado` methods to correlate the saved state, municipality, and
neighborhood identifiers instead of reading transient `Seleccione...` options.
Docentes request
`nomina/consultanomina.aspx` for a matching employee number, email, employee
type and job title, then attempt the same two shared staff modules without
requiring any optional route to exist. A supplemental
+3 -3
View File
@@ -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-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,
+19 -5
View File
@@ -96,26 +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 `sgu-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
```
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`.
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
@@ -128,6 +140,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'
```
+65 -9
View File
@@ -1,7 +1,12 @@
# Enrolamiento obligatorio de clientes SGU
Para una instalación limpia de Windows se prefiere el único punto de entrada
empaquetado:
Para Windows 10 y Windows 11 se usa un solo paquete:
- `sgu-windows-client-bootstrap-VERSION.zip` (x64, Pro, Enterprise o Education).
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
@@ -12,6 +17,53 @@ una clave privada y luego ejecuta la transacción proveedor-primero. Las
instrucciones completas están en
[`bootstrap-recovery.md`](bootstrap-recovery.md).
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 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
el runtime, un certificado, el registro COM, la directiva predeterminada o la
@@ -72,11 +124,14 @@ Orden de la transacción:
1. instala .NET y los binarios versionados;
2. registra COM, configura SGU como proveedor predeterminado y oculta el
último usuario que cerró sesión;
3. instala el guard de autorreparación;
4. exige health mTLS del broker y ejecuta las comprobaciones locales;
5. configura DNS del dominio;
6. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
3. crea o actualiza la cuenta local estándar `alumno` con la contraseña
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;
7. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
8. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
La directiva de Windows **Assign a default credential provider** selecciona SGU
por defecto. El instalador también habilita **Interactive logon: Don't display
@@ -138,8 +193,9 @@ Start-ScheduledTask -TaskName SGU-CredentialProvider-EnrollmentGuard
Un resultado válido exige simultáneamente binario y registro COM, configuración,
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
enumeración local deshabilitada y proveedor de contraseña de Microsoft
preservado. El script de reparación se encuentra en
enumeración local deshabilitada, cuenta local estándar `alumno` habilitada y
fuera del grupo Administradores, y proveedor de contraseña de Microsoft
preservado. El guard recrea o corrige esa cuenta de forma idempotente. El script de reparación se encuentra en
`C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
administradores.
+57
View File
@@ -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.
+12 -1
View File
@@ -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',
@@ -41,6 +42,7 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
foreach ($scriptName in @(
'Install-CredentialProvider.ps1',
'Install-SguEnrollmentGuard.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1',
'Enable-LabRemoteAccess.ps1',
@@ -92,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
@@ -115,6 +120,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
-ServerAddress $RustDeskServerAddress `
-ServerPublicKey $RustDeskServerPublicKey
}
$localStudentUser = & (Join-Path $PSScriptRoot 'Set-SguStandardLocalUser.ps1')
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
$testParameters = @{ RequireBrokerHealth = $true }
@@ -142,6 +148,10 @@ 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
}
@@ -175,6 +185,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
DomainName = $DomainName
ConnectivityMode = $ConnectivityMode
ProviderValidatedBeforeJoin = $true
StandardLocalUser = $localStudentUser
RustDesk = $rustDeskResult
RestartRequired = [bool]$SkipRestart
}
+6 -2
View File
@@ -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,13 +77,14 @@ try {
-EncryptionLevel Required `
-SplitTunneling `
-AllUserConnection `
-DnsSuffix $DomainName `
-Force | Out-Null
foreach ($prefix in $AzureNetworkPrefixes) {
Add-VpnConnectionRoute -ConnectionName $ConnectionName `
-DestinationPrefix $prefix -AllUserConnection -PassThru | 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 |
@@ -92,6 +95,7 @@ try {
-DisplayName $nrptDisplayName `
-Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null
}
}
if ($Connect) {
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
+1
View File
@@ -32,6 +32,7 @@ $sourceScripts = @(
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Install-SguRustDeskClient.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1'
)
+370 -51
View File
@@ -3,11 +3,16 @@
param(
[ipaddress]$DomainControllerIPv4Address,
[string]$NetworkInterfaceAlias,
[ipaddress]$ClientIPv4Address,
[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')]
[string]$CompatibilityProfile = 'Auto',
[ValidateSet('Direct', 'AzureP2S')]
[string]$ConnectivityMode = 'Direct',
[string]$VpnConnectionName = 'SGU Azure P2S',
@@ -16,14 +21,42 @@ param(
[securestring]$VpnClientCertificatePfxPassword,
[string]$VpnClientRootCertificatePath,
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
[switch]$PauseOnError,
[switch]$SkipRestart
)
$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'
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()
@@ -52,42 +85,223 @@ function Assert-PackageManifest {
throw "Bootstrap package integrity check failed: $($entry.Path)"
}
}
return $manifest
}
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
# 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)
})
$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"
}
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(0, 32)][int]$PrefixLength
)
if ($FirstAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or
$SecondAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
return $false
}
$defaultRoute = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' `
$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 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,
[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 |
Sort-Object RouteMetric,InterfaceMetric |
Where-Object {
$_.AddressState -eq 'Preferred' -and
$_.IPAddress -notmatch '^(127\.|169\.254\.)' -and
(Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) `
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)
} |
Select-Object -First 1
if ($defaultRoute) {
return [string](Get-NetAdapter -InterfaceIndex $defaultRoute.InterfaceIndex).Name
if (-not $RequestedAddress -and $matchingAddress) {
return [ipaddress]$matchingAddress.IPAddress
}
if (-not $RequestedAddress) {
throw 'Static addressing requires an explicit -ClientIPv4Address. Automatic enrollment preserves DHCP and existing addresses.'
}
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
}
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
if ($upAdapters.Count -eq 1) {
return [string]$upAdapters[0].Name
$addressReadyDeadline = (Get-Date).AddSeconds(20)
do {
$configuredAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex `
-AddressFamily IPv4 -IPAddress $RequestedAddress.IPAddressToString `
-ErrorAction SilentlyContinue
if ($configuredAddress -and $configuredAddress.AddressState -eq 'Preferred') {
return $RequestedAddress
}
Start-Sleep -Milliseconds 500
} while ((Get-Date) -lt $addressReadyDeadline)
$aliases = ($upAdapters.Name | Sort-Object) -join ', '
throw "Could not select a network adapter. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
$observedState = if ($configuredAddress) { $configuredAddress.AddressState } else { 'Missing' }
throw "The SGU client address '$RequestedAddress' did not become ready on '$InterfaceAlias' within 20 seconds. Observed state: $observedState."
}
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
@@ -100,9 +314,81 @@ 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."
}
}
function Wait-TcpPort {
param(
[Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][int]$Port,
[int]$TimeoutSeconds = 20
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
do {
if (Test-TcpPort -Address $Address -Port $Port -TimeoutMilliseconds 2000) {
return $true
}
Start-Sleep -Milliseconds 750
} while ((Get-Date) -lt $deadline)
return $false
}
function Connect-SguAzureP2s {
param([Parameter(Mandatory)][string]$ConnectionName)
@@ -129,25 +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
Assert-PackageManifest -PackageRoot $packageRoot
$packageManifest = Assert-PackageManifest -PackageRoot $packageRoot
# 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') `
@@ -166,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 `
@@ -198,31 +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
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 (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) {
throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP."
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'
@@ -252,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) {
@@ -265,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 }
@@ -272,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)."
}
@@ -285,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 |
@@ -367,6 +683,7 @@ try {
ComputerOuDn = $ComputerOuDn
NetworkInterfaceAlias = $NetworkInterfaceAlias
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
DomainDnsConfigured = $true
ConnectivityMode = $ConnectivityMode
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
@@ -428,7 +745,8 @@ finally {
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force
}
if (-not $winRmWasRunning) {
Stop-Service WinRM -Force -ErrorAction SilentlyContinue
Stop-Service WinRM -Force -NoWait -WarningAction SilentlyContinue `
-ErrorAction SilentlyContinue
}
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
$DomainCredential = $null
@@ -443,6 +761,7 @@ if ($SkipRestart) {
ClientCertificateRegistered = $true
BrokerEndpoint = $brokerEndpoint
ConnectivityMode = $ConnectivityMode
CompatibilityProfile = $CompatibilityProfile
VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null }
RestartRequired = $true
RustDesk = if ($result) { $result.RustDesk } else { $null }
+34 -12
View File
@@ -33,7 +33,10 @@ function Write-PackageManifest {
param(
[Parameter(Mandatory)][string]$PackageRoot,
[Parameter(Mandatory)][string]$PackageVersion,
[Parameter(Mandatory)][string]$PackageKind
[Parameter(Mandatory)][string]$PackageKind,
[ValidateSet('Auto')]
[string]$CompatibilityProfile,
[string]$TargetOperatingSystem
)
$resolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path.TrimEnd('\')
@@ -48,13 +51,19 @@ function Write-PackageManifest {
}
})
$manifest = [ordered]@{
SchemaVersion = 1
SchemaVersion = 2
Product = 'SGU Credential Provider'
PackageKind = $PackageKind
Version = $PackageVersion
CreatedAt = (Get-Date).ToUniversalTime().ToString('o')
Files = $files
}
if ($CompatibilityProfile) {
$manifest['CompatibilityProfile'] = $CompatibilityProfile
}
if ($TargetOperatingSystem) {
$manifest['TargetOperatingSystem'] = $TargetOperatingSystem
}
[IO.File]::WriteAllText(
(Join-Path $resolvedPackageRoot 'package-manifest.json'),
($manifest | ConvertTo-Json -Depth 6),
@@ -78,7 +87,7 @@ if (-not $runtimeInstaller) {
}
New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null
$clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version"
$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"
@@ -86,13 +95,17 @@ $clientZip = "$clientRoot.zip"
$serverZip = "$serverRoot.zip"
$linuxClientZip = "$linuxClientRoot.zip"
$azureZip = "$azureRoot.zip"
foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$azureRoot,$clientZip,$serverZip,$linuxClientZip,$azureZip)) {
foreach ($target in @(
$clientRoot,$serverRoot,$linuxClientRoot,$azureRoot,
$clientZip,$serverZip,$linuxClientZip,$azureZip)) {
if (Test-Path -LiteralPath $target) {
throw "Release target already exists: $target"
}
}
New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot -Force | Out-Null
New-Item -ItemType Directory `
-Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot `
-Force | Out-Null
$welcomeFontNames = @(
'IndivisaTextSans-Regular.otf',
'IndivisaTextSans-Bold.otf',
@@ -105,10 +118,6 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Invoke-SguClientBootstrap.ps
-Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') `
-Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') `
-Destination (Join-Path $clientRoot 'Start-SguAzureClientEnrollment.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') `
-Destination (Join-Path $clientRoot 'Install-SguAzureP2sClient.ps1')
$clientScripts = @(
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
@@ -118,6 +127,7 @@ $clientScripts = @(
'Install-SguRustDeskClient.ps1',
'Register-SguClientCertificate.ps1',
'Repair-SguClientEnrollment.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1'
)
foreach ($scriptName in $clientScripts) {
@@ -140,7 +150,19 @@ foreach ($fontName in $welcomeFontNames) {
}
Copy-RequiredFile -Source $runtimeInstaller.FullName `
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
# One Windows implementation supports existing LAN/VPN routes and optional Azure P2S.
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') `
-Destination (Join-Path $clientRoot 'Start-SguAzureClientEnrollment.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot '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 $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
@@ -251,8 +273,8 @@ $checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
[pscustomobject]@{
Version = $Version
ClientPackage = $clientZip
ClientSha256 = (Get-FileHash -LiteralPath $clientZip -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
+9 -3
View File
@@ -15,7 +15,7 @@ param(
$ErrorActionPreference = 'Stop'
$tagName = "v$Version"
$assetPaths = @(
(Join-Path $ReleaseDirectory "sgu-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"),
@@ -111,12 +111,18 @@ 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.
- `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.
- La descripción de la cuenta local administrada respeta el límite de 48 caracteres de Windows 10 Enterprise.
- La validación de expiración de contraseña usa el indicador de cuenta compatible con Windows 10 y 11, en lugar de una propiedad que Windows 10 no expone.
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
+2
View File
@@ -11,9 +11,11 @@ $installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
$localUserScript = Join-Path $enrollmentRoot 'Set-SguStandardLocalUser.ps1'
$before = & $testScript
if (-not $before.IsValid) {
& $localUserScript | Out-Null
$installParams = @{
PublishPath = [string]$configuration.PublishPath
BrokerEndpoint = [string]$configuration.BrokerEndpoint
+108
View File
@@ -0,0 +1,108 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param()
$ErrorActionPreference = 'Stop'
$userName = 'alumno'
$plainTextPassword = 'ingenieria'
$description = 'Cuenta local estandar SGU para recuperacion'
$passwordNeverExpiresFlag = 0x10000
function Get-LocalUserFlags {
param([Parameter(Mandatory)][string]$Name)
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,user")
return [int]$directoryEntry.InvokeGet('UserFlags')
}
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated Windows PowerShell session.'
}
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Create or update standard local user $userName")) {
return
}
$securePassword = ConvertTo-SecureString $plainTextPassword -AsPlainText -Force
try {
$user = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue
if ($user -and $user.SID.Value.EndsWith('-500', [StringComparison]::Ordinal)) {
throw "The local account '$userName' is the built-in Administrator account and cannot be converted to a standard user."
}
if ($user) {
# Preserve existing credentials on enrollment/repair. Resetting the same
# password after domain join can violate password history/complexity.
Set-LocalUser -Name $userName `
-PasswordNeverExpires $true `
-Description $description
if (-not $user.Enabled) {
Enable-LocalUser -Name $userName
}
}
else {
New-LocalUser -Name $userName `
-Password $securePassword `
-PasswordNeverExpires `
-Description $description | Out-Null
}
# Windows 10's Get-LocalUser object has PasswordExpires but does not expose
# PasswordNeverExpires. Enforce and verify the underlying UF_DONT_EXPIRE_PASSWD
# flag so the result is consistent across Windows 10 and Windows 11.
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$userName,user")
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
if (($userFlags -band $passwordNeverExpiresFlag) -eq 0) {
$directoryEntry.InvokeSet('UserFlags', ($userFlags -bor $passwordNeverExpiresFlag))
$directoryEntry.CommitChanges()
}
$user = Get-LocalUser -Name $userName -ErrorAction Stop
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
if ($administratorMembers.SID.Value -contains $user.SID.Value) {
Remove-LocalGroupMember -Group $administratorsGroup -Member $user -Confirm:$false
}
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
if ($standardMembers.SID.Value -notcontains $user.SID.Value) {
Add-LocalGroupMember -Group $usersGroup -Member $user
}
}
finally {
$securePassword = $null
}
$verifiedUser = Get-LocalUser -Name $userName -ErrorAction Stop
$verifiedAdministratorsGroup = Get-LocalGroup `
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')) `
-ErrorAction Stop
$verifiedUsersGroup = Get-LocalGroup `
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')) `
-ErrorAction Stop
$verifiedAdministrators = @(Get-LocalGroupMember -Group $verifiedAdministratorsGroup -ErrorAction Stop)
$verifiedUsers = @(Get-LocalGroupMember -Group $verifiedUsersGroup -ErrorAction Stop)
if (@($verifiedAdministrators).SID.Value -contains $verifiedUser.SID.Value) {
throw "The local account '$userName' still belongs to the local Administrators group."
}
if ($verifiedUsers.SID.Value -notcontains $verifiedUser.SID.Value) {
throw "The local account '$userName' does not belong to the local Users group."
}
$verifiedPasswordNeverExpires =
((Get-LocalUserFlags -Name $userName) -band $passwordNeverExpiresFlag) -ne 0
if (-not $verifiedPasswordNeverExpires) {
throw "The local account '$userName' password is not configured to never expire."
}
[pscustomobject]@{
UserName = $verifiedUser.Name
Enabled = $verifiedUser.Enabled
IsAdministrator = $false
IsStandardUser = $true
PasswordNeverExpires = $verifiedPasswordNeverExpires
}
+9 -2
View File
@@ -4,5 +4,12 @@ set "SGU_BOOTSTRAP_IP=%~1"
set "SGU_VPN_PACKAGE=%~2"
set "SGU_VPN_PFX=%~3"
set "SGU_VPN_ROOT=%~4"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-ConnectivityMode','AzureP2S'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',('"' + $env:SGU_BOOTSTRAP_IP + '"')) }; if ($env:SGU_VPN_PACKAGE) { $arguments += @('-VpnProfilePackagePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PACKAGE) + '"')) }; if ($env:SGU_VPN_PFX) { $arguments += @('-VpnClientCertificatePfxPath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PFX) + '"')) }; if ($env:SGU_VPN_ROOT) { $arguments += @('-VpnClientRootCertificatePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_ROOT) + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
exit /b %errorlevel%
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-PauseOnError','-ConnectivityMode','AzureP2S'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',('"' + $env:SGU_BOOTSTRAP_IP + '"')) }; if ($env:SGU_VPN_PACKAGE) { $arguments += @('-VpnProfilePackagePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PACKAGE) + '"')) }; if ($env:SGU_VPN_PFX) { $arguments += @('-VpnClientCertificatePfxPath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PFX) + '"')) }; if ($env:SGU_VPN_ROOT) { $arguments += @('-VpnClientRootCertificatePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_ROOT) + '"')) }; $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 Windows Azure enrollment did not complete. Review:
echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log
pause
)
exit /b %SGU_EXIT_CODE%
+11 -2
View File
@@ -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%
+51
View File
@@ -19,6 +19,8 @@ $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
$issues = [Collections.Generic.List[string]]::new()
$standardLocalUserName = 'alumno'
$passwordNeverExpiresFlag = 0x10000
$computer = Get-CimInstance Win32_ComputerSystem
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
@@ -87,6 +89,50 @@ if (-not $passwordProviderPreserved) {
$issues.Add('The built-in Microsoft password provider registration is missing.')
}
$standardLocalUser = Get-LocalUser -Name $standardLocalUserName -ErrorAction SilentlyContinue
$standardLocalUserPresent = [bool]$standardLocalUser
$standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled
$standardLocalUserIsAdministrator = $false
$standardLocalUserInUsersGroup = $false
$standardLocalUserPasswordNeverExpires = $false
if ($standardLocalUserPresent) {
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
$standardLocalUserIsAdministrator =
$administratorMembers.SID.Value -contains $standardLocalUser.SID.Value
$standardLocalUserInUsersGroup =
$standardMembers.SID.Value -contains $standardLocalUser.SID.Value
try {
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$standardLocalUserName,user")
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
$standardLocalUserPasswordNeverExpires =
($userFlags -band $passwordNeverExpiresFlag) -ne 0
}
catch {
# Report the account as invalid when Windows cannot read its flags.
$standardLocalUserPasswordNeverExpires = $false
}
}
if (-not $standardLocalUserPresent) {
$issues.Add("The required standard local user '$standardLocalUserName' is missing.")
}
elseif (-not $standardLocalUserEnabled) {
$issues.Add("The required standard local user '$standardLocalUserName' is disabled.")
}
elseif ($standardLocalUserIsAdministrator) {
$issues.Add("The required standard local user '$standardLocalUserName' belongs to the local Administrators group.")
}
elseif (-not $standardLocalUserInUsersGroup) {
$issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.")
}
elseif (-not $standardLocalUserPasswordNeverExpires) {
$issues.Add("The required standard local user '$standardLocalUserName' password is not configured to never expire.")
}
$settings = $null
try {
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
@@ -207,6 +253,11 @@ $result = [pscustomobject]@{
LastSignedInUserHidden = $lastSignedInUserHidden
LocalUserEnumerationDisabled = $localUserEnumerationDisabled
PasswordProviderPreserved = $passwordProviderPreserved
StandardLocalUserPresent = $standardLocalUserPresent
StandardLocalUserEnabled = $standardLocalUserEnabled
StandardLocalUserIsAdministrator = $standardLocalUserIsAdministrator
StandardLocalUserInUsersGroup = $standardLocalUserInUsersGroup
StandardLocalUserPasswordNeverExpires = $standardLocalUserPasswordNeverExpires
SettingsPresent = [bool]$settings
ClientCertificatePresent = [bool]$clientCertificatePresent
ServerCertificateTrusted = $serverCertificateTrusted
@@ -0,0 +1,20 @@
namespace SGU.AuthBroker.Core.Profiles;
public sealed record SguAdministrativeLocationSelection(
string? StateId = null,
string? MunicipalityId = null,
string? NeighborhoodId = null,
string? StateName = null,
string? MunicipalityName = null,
string? NeighborhoodName = null,
string? PostalCode = null)
{
public bool HasValues =>
StateId is not null ||
MunicipalityId is not null ||
NeighborhoodId is not null ||
StateName is not null ||
MunicipalityName is not null ||
NeighborhoodName is not null ||
PostalCode is not null;
}
@@ -1,6 +1,7 @@
using System.Net;
using System.Net.Mail;
using System.Text;
using System.Text.Json;
namespace SGU.AuthBroker.Core.Profiles;
@@ -102,7 +103,14 @@ public static class SguProfileParser
return profile.HasValues ? profile : null;
}
public static InstitutionalProfile? ParseAdministrativeLocation(string html)
public static InstitutionalProfile? ParseAdministrativeLocation(string html) =>
ParseAdministrativeLocation(html, null, null, null);
public static InstitutionalProfile? ParseAdministrativeLocation(
string html,
SguAdministrativeLocationSelection? selection,
string? localitiesJson,
string? neighborhoodsJson)
{
ArgumentNullException.ThrowIfNull(html);
@@ -112,18 +120,79 @@ public static class SguProfileParser
string? interiorNumber = NormalizeAddressUnit(
ExtractInputValue(html, AdministrativeInteriorNumberId));
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
string? neighborhood = NormalizeTitle(
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
256);
string? neighborhood = NormalizeTitle(FirstNonEmpty(
selection?.NeighborhoodName,
ResolveNeighborhoodName(neighborhoodsJson, selection),
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId)), 256);
string? city = NormalizeTitle(FirstNonEmpty(
selection?.MunicipalityName,
ResolveLocalityName(localitiesJson, selection),
ExtractSelectedOptionText(html, AdministrativeCityId)), 128);
string? state = NormalizeTitle(FirstNonEmpty(
selection?.StateName,
ExtractOptionTextByValue(html, AdministrativeStateId, selection?.StateId),
ExtractSelectedOptionText(html, AdministrativeStateId)), 128);
string? postalCode = NormalizePostalCode(FirstNonEmpty(
selection?.PostalCode,
ExtractInputValue(html, AdministrativePostalCodeId)));
InstitutionalProfile profile = new(
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128),
State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128),
PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId)));
City: city,
State: state,
PostalCode: postalCode);
return profile.HasValues ? profile : null;
}
public static SguAdministrativeLocationSelection? ParseAdministrativeLocationSelection(
string json,
string? expectedPostalCode)
{
ArgumentNullException.ThrowIfNull(json);
string? expected = NormalizePostalCode(expectedPostalCode);
try
{
using JsonDocument document = JsonDocument.Parse(json);
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
{
return null;
}
foreach (JsonElement value in values.EnumerateArray())
{
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
if (expected is not null &&
!string.Equals(postalCode, expected, StringComparison.Ordinal))
{
continue;
}
SguAdministrativeLocationSelection selection = new(
StateId: NormalizeCatalogId(GetJsonString(value, "p_IdEstado")),
MunicipalityId: NormalizeCatalogId(GetJsonString(value, "p_IdMunicipio")),
NeighborhoodId: NormalizeCatalogId(GetJsonString(value, "p_IdCP")),
StateName: Limit(GetJsonString(value, "p_NombreEstado"), 128),
MunicipalityName: Limit(GetJsonString(value, "p_NombreMunicipio"), 128),
NeighborhoodName: Limit(FirstNonEmpty(
GetJsonString(value, "p_NombreColonia"),
GetJsonString(value, "p_NombreAsentamiento"),
GetJsonString(value, "p_Nombre")), 256),
PostalCode: postalCode);
if (selection.HasValues)
{
return selection;
}
}
}
catch (JsonException)
{
return null;
}
return null;
}
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
{
ArgumentNullException.ThrowIfNull(html);
@@ -350,6 +419,186 @@ public static class SguProfileParser
return null;
}
private static string? ExtractOptionTextByValue(string html, string id, string? expectedValue)
{
if (string.IsNullOrWhiteSpace(expectedValue))
{
return null;
}
string? openingTag = FindOpeningTag(html, "select", id);
if (openingTag is null)
{
return null;
}
int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
int contentStart = openingTagIndex + openingTag.Length;
int contentEnd = html.IndexOf("</select", contentStart, StringComparison.OrdinalIgnoreCase);
if (openingTagIndex < 0 || contentEnd < 0)
{
return null;
}
string optionsHtml = html[contentStart..contentEnd];
int searchFrom = 0;
while (searchFrom < optionsHtml.Length)
{
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
if (optionStart < 0)
{
break;
}
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
int optionEnd = optionTagEnd < 0
? -1
: optionsHtml.IndexOf("</option", optionTagEnd + 1, StringComparison.OrdinalIgnoreCase);
if (optionTagEnd < 0 || optionEnd < 0)
{
break;
}
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
string? optionValue = ExtractAttributeValue(optionTag, "value");
if (string.Equals(optionValue, expectedValue, StringComparison.Ordinal))
{
return NormalizeText(optionsHtml[(optionTagEnd + 1)..optionEnd]);
}
searchFrom = optionEnd + "</option".Length;
}
return null;
}
private static string? ResolveLocalityName(
string? json,
SguAdministrativeLocationSelection? selection)
{
if (string.IsNullOrWhiteSpace(json) ||
string.IsNullOrWhiteSpace(selection?.MunicipalityId))
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(json);
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
{
return null;
}
foreach (JsonElement value in values.EnumerateArray())
{
if (string.Equals(
NormalizeCatalogId(GetJsonString(value, "Id_Municipio")),
selection.MunicipalityId,
StringComparison.Ordinal) &&
(selection.StateId is null || string.Equals(
NormalizeCatalogId(GetJsonString(value, "ID_Estado")),
selection.StateId,
StringComparison.Ordinal)))
{
return GetJsonString(value, "Nombre");
}
}
}
catch (JsonException)
{
return null;
}
return null;
}
private static string? ResolveNeighborhoodName(
string? json,
SguAdministrativeLocationSelection? selection)
{
if (string.IsNullOrWhiteSpace(json) || selection is null)
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(json);
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
{
return null;
}
List<string> postalCodeMatches = [];
foreach (JsonElement value in values.EnumerateArray())
{
string? neighborhoodId = NormalizeCatalogId(GetJsonString(value, "p_IdCP"));
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
string? name = GetJsonString(value, "p_Nombre");
if (name is null)
{
continue;
}
if (selection.NeighborhoodId is not null &&
string.Equals(neighborhoodId, selection.NeighborhoodId, StringComparison.Ordinal))
{
return name;
}
if (selection.PostalCode is not null &&
string.Equals(postalCode, selection.PostalCode, StringComparison.Ordinal))
{
postalCodeMatches.Add(name);
}
}
return postalCodeMatches.Count == 1 ? postalCodeMatches[0] : null;
}
catch (JsonException)
{
return null;
}
}
private static bool TryGetPageMethodArray(JsonElement root, out JsonElement values)
{
values = default;
return root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("d", out values) &&
values.ValueKind == JsonValueKind.Array;
}
private static string? GetJsonString(JsonElement value, string propertyName)
{
if (value.ValueKind != JsonValueKind.Object ||
!value.TryGetProperty(propertyName, out JsonElement property))
{
return null;
}
return property.ValueKind switch
{
JsonValueKind.String => property.GetString(),
JsonValueKind.Number => property.GetRawText(),
_ => null
};
}
private static string? NormalizeCatalogId(string? value)
{
string? candidate = value?.Trim();
return string.IsNullOrEmpty(candidate) ||
candidate.Length > 32 ||
!candidate.All(char.IsAsciiLetterOrDigit)
? null
: candidate;
}
private static string? FirstNonEmpty(params string?[] values) =>
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static string? FindOpeningTag(string html, string tagName, string id)
=> FindOpeningTagByAttribute(html, tagName, "id", id);
@@ -1,5 +1,7 @@
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Json;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
@@ -431,9 +433,6 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
pages.Add((
options.AdministrativePersonalProfilePath,
SguProfileParser.ParseAdministrativePersonal));
pages.Add((
options.AdministrativeLocationProfilePath,
SguProfileParser.ParseAdministrativeLocation));
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
{
@@ -474,7 +473,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
break;
return profile;
}
catch (Exception exception)
{
@@ -487,6 +486,142 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
}
}
return await TryEnrichStaffLocationAsync(
client,
profile,
identity,
allowedHosts,
timeoutToken,
requestCancellationToken,
elapsed).ConfigureAwait(false);
}
private async Task<InstitutionalProfile> TryEnrichStaffLocationAsync(
HttpClient client,
InstitutionalProfile profile,
UserIdentity identity,
HashSet<string> allowedHosts,
CancellationToken timeoutToken,
CancellationToken requestCancellationToken,
Stopwatch elapsed)
{
string path = options.AdministrativeLocationProfilePath;
Uri locationPageUri = GetProfileUri(path);
try
{
string? html = await TryFetchAdditionalProfilePageAsync(
client,
locationPageUri,
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (html is null)
{
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
path,
identity.Role);
return profile;
}
InstitutionalProfile? staticLocation = SguProfileParser.ParseAdministrativeLocation(html);
if (staticLocation is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
path,
identity.Role);
return profile;
}
profile = profile.Overlay(staticLocation);
if (string.IsNullOrWhiteSpace(staticLocation.PostalCode))
{
return profile;
}
string? directionJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetDireccion"),
locationPageUri,
new Dictionary<string, string>
{
["CodigoPostal"] = staticLocation.PostalCode
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (directionJson is null)
{
return profile;
}
SguAdministrativeLocationSelection? selection =
SguProfileParser.ParseAdministrativeLocationSelection(
directionJson,
staticLocation.PostalCode);
if (selection is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"SGU location method GetDireccion returned an unexpected payload for role {Role}; preserving the static address fields.",
identity.Role);
return profile;
}
string? localitiesJson = null;
if (!string.IsNullOrWhiteSpace(selection.StateId))
{
localitiesJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetLocalidadListado"),
locationPageUri,
new Dictionary<string, string>
{
["pIdEstado"] = selection.StateId
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
}
string? neighborhoodsJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetColoniasListado"),
locationPageUri,
new Dictionary<string, string>
{
["pIdEstado"] = string.Empty,
["pLocalidad"] = string.Empty,
["CodigoPostal"] = selection.PostalCode ?? staticLocation.PostalCode
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
InstitutionalProfile? resolvedLocation = SguProfileParser.ParseAdministrativeLocation(
html,
selection,
localitiesJson,
neighborhoodsJson);
return profile.Overlay(resolvedLocation);
}
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU optional staff location enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
catch (Exception exception)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception,
"SGU optional staff location enrichment failed for role {Role} after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
return profile;
}
@@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return null;
}
private async Task<string?> TryPostProfilePageMethodAsync(
HttpClient client,
Uri requestedUri,
Uri referrerUri,
IReadOnlyDictionary<string, string> payload,
HashSet<string> allowedHosts,
CancellationToken cancellationToken)
{
if (!IsAllowedHttpsUri(requestedUri, allowedHosts) ||
!IsAllowedHttpsUri(referrerUri, allowedHosts))
{
return null;
}
using HttpRequestMessage request = new(HttpMethod.Post, requestedUri);
request.Headers.Referrer = referrerUri;
request.Content = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8,
"application/json");
using HttpResponseMessage response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
int statusCode = (int)response.StatusCode;
if (statusCode is >= 200 and < 300)
{
return await ReadLimitedStringAsync(
response.Content,
options.MaxProfileBytes,
cancellationToken).ConfigureAwait(false);
}
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile method {Path} returned HTTP {StatusCode}.",
requestedUri.AbsolutePath,
statusCode);
return null;
}
private static void AddCredential(
Uri uri,
CredentialCache credentialCache,
@@ -658,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
private Uri GetProfileUri(string path) =>
new(new Uri(options.Endpoint, UriKind.Absolute), path);
private Uri GetAdministrativeLocationMethodUri(string methodName)
{
Uri pageUri = GetProfileUri(options.AdministrativeLocationProfilePath);
return new Uri($"{pageUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/{methodName}");
}
private async Task<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response,
UserIdentity identity,
+262
View File
@@ -25,6 +25,24 @@ $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 -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)
Describe 'SGU public-cloud network safety' {
It 'canonicalizes a host address to its IPv4 network' {
ConvertTo-NetworkCidr -Address ([ipaddress]'10.77.0.4') `
@@ -56,6 +74,40 @@ Describe 'SGU public-cloud network safety' {
'ConnectivityMode') | Should Be $true
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'VpnProfilePackagePath') | Should Be $true
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'CompatibilityProfile') | 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 'waits for the new address and WinRM route to stabilize' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$source | Should Match "AddressState -eq 'Preferred'"
$source | Should Match 'function Wait-TcpPort'
$source | Should Match 'Wait-TcpPort -Address \$DomainControllerIPv4Address -Port 5985'
}
It 'uses the unified implementation without OS-specific network restrictions' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$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' {
@@ -72,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'
}
}
+75
View File
@@ -0,0 +1,75 @@
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$localUserScriptPath = Join-Path $repositoryRoot 'scripts\Set-SguStandardLocalUser.ps1'
$enrollmentTestScriptPath = Join-Path $repositoryRoot 'scripts\Test-SguClientEnrollment.ps1'
$packageScriptPath = Join-Path $repositoryRoot 'scripts\New-SguBootstrapPackages.ps1'
$releaseScriptPath = Join-Path $repositoryRoot 'scripts\Publish-GiteaRelease.ps1'
$azureLauncherPath = Join-Path $repositoryRoot 'scripts\Start-SguAzureClientEnrollment.cmd'
$tokens = $null
$parseErrors = $null
$scriptAst = [Management.Automation.Language.Parser]::ParseFile(
$localUserScriptPath,
[ref]$tokens,
[ref]$parseErrors)
if ($parseErrors.Count -gt 0) {
throw ($parseErrors -join [Environment]::NewLine)
}
$descriptionAssignment = $scriptAst.Find({
param($node)
$node -is [Management.Automation.Language.AssignmentStatementAst] -and
$node.Left.Extent.Text -eq '$description'
}, $true)
$description = $descriptionAssignment.Right.Extent.Text.Trim("'")
Describe 'SGU Windows client enrollment scripts' {
It 'keeps the local-user description within the Windows 10 limit' {
($description.Length -le 48) | Should Be $true
}
It 'declares the managed local student account' {
$source = Get-Content -LiteralPath $localUserScriptPath -Raw
$source | Should Match "\$userName = 'alumno'"
$source | Should Match "\$plainTextPassword = 'ingenieria'"
}
It 'uses the cross-version Windows account flag for password expiration' {
$localUserSource = Get-Content -LiteralPath $localUserScriptPath -Raw
$enrollmentTestSource = Get-Content -LiteralPath $enrollmentTestScriptPath -Raw
$localUserSource | Should Match '\$passwordNeverExpiresFlag = 0x10000'
$enrollmentTestSource | Should Match '\$passwordNeverExpiresFlag = 0x10000'
$localUserSource | Should Not Match '\$verifiedUser\.PasswordNeverExpires'
$enrollmentTestSource | Should Not Match '\$standardLocalUser\.PasswordNeverExpires'
}
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 '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 `$clientRoot 'Start-SguAzureClientEnrollment.cmd'") |
Should Be $true
$packageSource.Contains("Join-Path `$clientRoot 'Install-SguAzureP2sClient.ps1'") |
Should Be $true
$azureLauncher | Should Match '-PauseOnError'
}
}
@@ -228,6 +228,65 @@ public sealed class SguProfileParserTests
Assert.Null(profile.Email);
}
[Fact]
public void ResolvesAdministrativeAddressFromPageMethodIdentifiers()
{
const string html = """
<html><body>
<input id='ctl00_contenedor_txtCalle' value='RETORNO 1, SUR 16' />
<input id='ctl00_contenedor_txtNoExt' value='74' />
<input id='ctl00_contenedor_txtNoInt' value='' />
<input id='ctl00_contenedor_txtCP' value='08500' />
<select id='ctl00_contenedor_ddlEstado'>
<option selected='selected' value='0'>Seleccione...</option>
<option value='09'>CIUDAD DE MÉXICO</option>
</select>
<select id='ctl00_contenedor_ddlLocalidad'>
<option selected='selected' value='0'>Seleccione alguna localidad...</option>
</select>
<select id='ctl00_contenedor_ddlColonia'>
<option selected='selected' value='0,0'>Seleccione alguna colonia...</option>
</select>
</body></html>
""";
const string directionJson = """
{"d":[{"p_IdCP":"091263","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"006","p_NombreMunicipio":"","p_NombreColonia":"","p_Cp":"08500"}]}
""";
const string localitiesJson = """
{"d":[{"ID_Estado":"09","Id_Municipio":"002","Nombre":"AZCAPOTZALCO"},{"ID_Estado":"09","Id_Municipio":"006","Nombre":"IZTACALCO"}]}
""";
const string neighborhoodsJson = """
{"d":[{"p_IdCP":"091263","p_Nombre":"AGRÍCOLA ORIENTAL","p_Cp":"08500"}]}
""";
SguAdministrativeLocationSelection? selection =
SguProfileParser.ParseAdministrativeLocationSelection(directionJson, "08500");
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativeLocation(
html,
selection,
localitiesJson,
neighborhoodsJson);
Assert.NotNull(selection);
Assert.Equal("09", selection.StateId);
Assert.Equal("006", selection.MunicipalityId);
Assert.Equal("091263", selection.NeighborhoodId);
Assert.NotNull(profile);
Assert.Equal("Retorno 1, Sur 16 74\r\nAgrícola Oriental", profile.StreetAddress);
Assert.Equal("Iztacalco", profile.City);
Assert.Equal("Ciudad de México", profile.State);
Assert.Equal("08500", profile.PostalCode);
}
[Theory]
[InlineData("not-json")]
[InlineData("{\"d\":{}}")]
[InlineData("{\"d\":[]}")]
public void RejectsUnexpectedAdministrativeLocationPayloads(string json)
{
Assert.Null(SguProfileParser.ParseAdministrativeLocationSelection(json, "08500"));
}
[Fact]
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
{
@@ -145,14 +145,24 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNoExt" value="15" />
<input id="ctl00_contenedor_txtCP" value="01000" />
<select id="ctl00_contenedor_ddlEstado">
<option selected="selected">CIUDAD DE MÉXICO</option>
<option selected="selected" value="0">Seleccione...</option>
<option value="09">CIUDAD DE MÉXICO</option>
</select>
<select id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option>
<option selected="selected" value="0">Seleccione alguna localidad...</option>
</select>
<select id="ctl00_contenedor_ddlColonia">
<option>FLORIDA</option>
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
</select>
"""),
JsonResponse("""
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
"""),
JsonResponse("""
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
"""),
JsonResponse("""
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
"""));
NtlmCredentialValidator validator = CreateValidator(handler);
@@ -180,9 +190,17 @@ public sealed class NtlmCredentialValidatorTests
"/psulsa/",
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
],
handler.RequestPaths);
Assert.Equal("{\"CodigoPostal\":\"01000\"}", handler.RequestBodies[5]);
Assert.Equal("{\"pIdEstado\":\"09\"}", handler.RequestBodies[6]);
Assert.Equal(
"{\"pIdEstado\":\"\",\"pLocalidad\":\"\",\"CodigoPostal\":\"01000\"}",
handler.RequestBodies[7]);
}
[Fact]
@@ -242,14 +260,24 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNoExt" value="15" />
<input id="ctl00_contenedor_txtCP" value="01000" />
<select id="ctl00_contenedor_ddlEstado">
<option selected="selected">CIUDAD DE MÉXICO</option>
<option selected="selected" value="0">Seleccione...</option>
<option value="09">CIUDAD DE MÉXICO</option>
</select>
<select id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option>
<option selected="selected" value="0">Seleccione alguna localidad...</option>
</select>
<select id="ctl00_contenedor_ddlColonia">
<option selected="selected">FLORIDA</option>
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
</select>
"""),
JsonResponse("""
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
"""),
JsonResponse("""
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
"""),
JsonResponse("""
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
"""));
NtlmCredentialValidator validator = CreateValidator(handler);
@@ -280,7 +308,10 @@ public sealed class NtlmCredentialValidatorTests
"/psulsa/menu.aspx",
"/psulsa/gadmon/nomina/consultanomina.aspx",
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
],
handler.RequestPaths);
}
@@ -363,18 +394,29 @@ public sealed class NtlmCredentialValidatorTests
Content = new StringContent(content)
};
private static HttpResponseMessage JsonResponse(string content) =>
new(HttpStatusCode.OK)
{
Content = new StringContent(content, null, "application/json")
};
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> responses = new(responses);
public List<string> RequestPaths { get; } = [];
public List<string?> RequestBodies { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
RequestPaths.Add(request.RequestUri!.AbsolutePath);
RequestBodies.Add(request.Content is null
? null
: request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult());
if (responses.Count == 0)
{
throw new InvalidOperationException("The validator sent more requests than expected.");