Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f8a9eed4e | ||
|
|
74235ec6f8 | ||
|
|
0b510082d4 | ||
|
|
5e62a65316 | ||
|
|
1332f546fa | ||
|
|
14c919b380 | ||
|
|
a6bd625e4e |
@@ -9,7 +9,7 @@ source and adds an SGU-specific provider, an mTLS-protected broker, Active
|
|||||||
Directory synchronization, deployment scripts, and tests.
|
Directory synchronization, deployment scripts, and tests.
|
||||||
|
|
||||||
Ready-to-run bootstrap packages are published on the
|
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
|
## 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
|
authentication or password synchronization after the lightweight NTLM root has
|
||||||
accepted the credential.
|
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:
|
Operational documentation:
|
||||||
|
|
||||||
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md)
|
- [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
|
Never disable the built-in Microsoft password Credential Provider. It is the
|
||||||
supported recovery path if a third-party provider fails to load.
|
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
|
```bat
|
||||||
Start-SguServerBootstrap.cmd 192.168.50.10
|
Start-SguServerBootstrap.cmd 192.168.50.10
|
||||||
Start-SguClientEnrollment.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
|
Linux clients are enrolled through their native PAM/SSSD stack instead of the
|
||||||
Windows Credential Provider:
|
Windows Credential Provider:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
incident page confirms an `AD` employee number, two additional GETs in the same
|
||||||
in-memory session read the structured name and selected sex from
|
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
|
`nomina/consultanomina.aspx` for a matching employee number, email, employee
|
||||||
type and job title, then attempt the same two shared staff modules without
|
type and job title, then attempt the same two shared staff modules without
|
||||||
requiring any optional route to exist. A supplemental
|
requiring any optional route to exist. A supplemental
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ En la estación administrativa, emita una credencial distinta por equipo:
|
|||||||
$w11 = .\scripts\New-SguAzureP2sCertificates.ps1 -ClientName 'Windows11'
|
$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`;
|
- `$vpn.PackagePath`;
|
||||||
- `$w11.ClientCertificatePath`;
|
- `$w11.ClientCertificatePath`;
|
||||||
- `sgu-azure-p2s-root.cer`.
|
- `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
|
incluye un perfil SSTP sobre TCP 443, pero ese fallback todavía requiere
|
||||||
instalación manual con el instalador oficial incluido en `WindowsAmd64`.
|
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
|
**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
|
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,
|
pantalla de inicio de sesión; antes del primer logon de una cuenta de dominio,
|
||||||
|
|||||||
@@ -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
|
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.
|
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:
|
2. Ejecutar con la IP fija actual del controlador de dominio:
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
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
|
Después de UAC, se solicita interactivamente la credencial autorizada para unir
|
||||||
equipos. La contraseña existe sólo en memoria. El bootstrap:
|
equipos. La contraseña existe sólo en memoria. El bootstrap:
|
||||||
|
|
||||||
1. apunta el DNS del adaptador al IP proporcionado;
|
1. selecciona una interfaz con conectividad comprobada al servidor;
|
||||||
2. abre una sesión WinRM autenticada con el DC y verifica que pertenece al
|
2. abre una sesión WinRM autenticada con el DC, descubre el dominio y configura
|
||||||
dominio esperado;
|
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
|
3. crea en el cliente un certificado mTLS RSA-3072 no exportable y envía sólo su
|
||||||
parte pública al broker;
|
parte pública al broker;
|
||||||
4. recupera por esa sesión autenticada el certificado público del 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;
|
5. instala el runtime .NET 10 offline y el Credential Provider;
|
||||||
6. valida binarios, registro COM, certificados y salud del broker;
|
6. valida binarios, registro COM, certificados y salud del broker;
|
||||||
7. instala el guardián de reparación al arranque;
|
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
|
9. al arrancar, activa RDP/NLA, WinRM y las reglas Domain, y vuelve a validar el
|
||||||
enrolamiento.
|
enrolamiento.
|
||||||
10. instala RustDesk desde el MSI oficial comprobado, lo apunta al servidor
|
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 `
|
powershell.exe -NoProfile -ExecutionPolicy Bypass `
|
||||||
-File .\Invoke-SguClientBootstrap.ps1 `
|
-File .\Invoke-SguClientBootstrap.ps1 `
|
||||||
-DomainControllerIPv4Address 192.168.50.10 `
|
-DomainControllerIPv4Address 192.168.50.10 `
|
||||||
|
-ClientIPv4Address 192.168.50.11 `
|
||||||
|
-ClientPrefixLength 24 `
|
||||||
-NetworkInterfaceAlias 'Ethernet' `
|
-NetworkInterfaceAlias 'Ethernet' `
|
||||||
-NewComputerName 'LCI-101'
|
-NewComputerName 'LCI-101'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
# Enrolamiento obligatorio de clientes SGU
|
# Enrolamiento obligatorio de clientes SGU
|
||||||
|
|
||||||
Para una instalación limpia de Windows se prefiere el único punto de entrada
|
Para Windows 10 y Windows 11 se usa un solo paquete:
|
||||||
empaquetado:
|
|
||||||
|
- `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
|
```bat
|
||||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
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
|
instrucciones completas están en
|
||||||
[`bootstrap-recovery.md`](bootstrap-recovery.md).
|
[`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
|
El flujo administrado instala y valida el Credential Provider **antes** de
|
||||||
ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta
|
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
|
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;
|
1. instala .NET y los binarios versionados;
|
||||||
2. registra COM, configura SGU como proveedor predeterminado y oculta el
|
2. registra COM, configura SGU como proveedor predeterminado y oculta el
|
||||||
último usuario que cerró sesión;
|
último usuario que cerró sesión;
|
||||||
3. instala el guard de autorreparación;
|
3. crea o actualiza la cuenta local estándar `alumno` con la contraseña
|
||||||
4. exige health mTLS del broker y ejecuta las comprobaciones locales;
|
inicial `ingenieria` sólo al crearla; si ya existe conserva su contraseña,
|
||||||
5. configura DNS del dominio;
|
la habilita y garantiza que no pertenezca a Administradores;
|
||||||
6. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
|
4. instala el guard de autorreparación;
|
||||||
7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
|
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
|
La directiva de Windows **Assign a default credential provider** selecciona SGU
|
||||||
por defecto. El instalador también habilita **Interactive logon: Don't display
|
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,
|
Un resultado válido exige simultáneamente binario y registro COM, configuración,
|
||||||
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
|
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
|
||||||
enumeración local deshabilitada y proveedor de contraseña de Microsoft
|
enumeración local deshabilitada, cuenta local estándar `alumno` habilitada y
|
||||||
preservado. El script de reparación se encuentra en
|
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
|
`C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
|
||||||
administradores.
|
administradores.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -22,6 +22,7 @@ param(
|
|||||||
[string]$NewComputerName,
|
[string]$NewComputerName,
|
||||||
[string]$NetworkInterfaceAlias = 'Ethernet',
|
[string]$NetworkInterfaceAlias = 'Ethernet',
|
||||||
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
|
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
|
||||||
|
[switch]$DomainDnsConfigured,
|
||||||
[ValidateSet('Direct', 'AzureP2S')]
|
[ValidateSet('Direct', 'AzureP2S')]
|
||||||
[string]$ConnectivityMode = 'Direct',
|
[string]$ConnectivityMode = 'Direct',
|
||||||
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
||||||
@@ -41,6 +42,7 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
|
|||||||
foreach ($scriptName in @(
|
foreach ($scriptName in @(
|
||||||
'Install-CredentialProvider.ps1',
|
'Install-CredentialProvider.ps1',
|
||||||
'Install-SguEnrollmentGuard.ps1',
|
'Install-SguEnrollmentGuard.ps1',
|
||||||
|
'Set-SguStandardLocalUser.ps1',
|
||||||
'Test-SguClientEnrollment.ps1',
|
'Test-SguClientEnrollment.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1',
|
'Repair-SguClientEnrollment.ps1',
|
||||||
'Enable-LabRemoteAccess.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
|
# 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
|
# domain. Point at AD DNS first so the provider-first health check works on
|
||||||
# a completely clean Windows installation.
|
# 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 `
|
Set-DnsClientServerAddress `
|
||||||
-InterfaceAlias $NetworkInterfaceAlias `
|
-InterfaceAlias $NetworkInterfaceAlias `
|
||||||
-ServerAddresses $DomainDnsServerAddresses
|
-ServerAddresses $DomainDnsServerAddresses
|
||||||
@@ -115,6 +120,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
|||||||
-ServerAddress $RustDeskServerAddress `
|
-ServerAddress $RustDeskServerAddress `
|
||||||
-ServerPublicKey $RustDeskServerPublicKey
|
-ServerPublicKey $RustDeskServerPublicKey
|
||||||
}
|
}
|
||||||
|
$localStudentUser = & (Join-Path $PSScriptRoot 'Set-SguStandardLocalUser.ps1')
|
||||||
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
|
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
|
||||||
|
|
||||||
$testParameters = @{ RequireBrokerHealth = $true }
|
$testParameters = @{ RequireBrokerHealth = $true }
|
||||||
@@ -142,6 +148,10 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
|||||||
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
|
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
|
||||||
}
|
}
|
||||||
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
|
$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
|
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
|
||||||
return $postJoin
|
return $postJoin
|
||||||
}
|
}
|
||||||
@@ -175,6 +185,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
|||||||
DomainName = $DomainName
|
DomainName = $DomainName
|
||||||
ConnectivityMode = $ConnectivityMode
|
ConnectivityMode = $ConnectivityMode
|
||||||
ProviderValidatedBeforeJoin = $true
|
ProviderValidatedBeforeJoin = $true
|
||||||
|
StandardLocalUser = $localStudentUser
|
||||||
RustDesk = $rustDeskResult
|
RustDesk = $rustDeskResult
|
||||||
RestartRequired = [bool]$SkipRestart
|
RestartRequired = [bool]$SkipRestart
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ try {
|
|||||||
if ($existingConnection) {
|
if ($existingConnection) {
|
||||||
Remove-VpnConnection -Name $ConnectionName -AllUserConnection -Force
|
Remove-VpnConnection -Name $ConnectionName -AllUserConnection -Force
|
||||||
}
|
}
|
||||||
Add-VpnConnection `
|
$dnsParameters = @{}
|
||||||
|
if ($DomainName) { $dnsParameters.DnsSuffix = $DomainName }
|
||||||
|
Add-VpnConnection @dnsParameters `
|
||||||
-Name $ConnectionName `
|
-Name $ConnectionName `
|
||||||
-ServerAddress $vpnServer `
|
-ServerAddress $vpnServer `
|
||||||
-TunnelType Ikev2 `
|
-TunnelType Ikev2 `
|
||||||
@@ -75,13 +77,14 @@ try {
|
|||||||
-EncryptionLevel Required `
|
-EncryptionLevel Required `
|
||||||
-SplitTunneling `
|
-SplitTunneling `
|
||||||
-AllUserConnection `
|
-AllUserConnection `
|
||||||
-DnsSuffix $DomainName `
|
|
||||||
-Force | Out-Null
|
-Force | Out-Null
|
||||||
foreach ($prefix in $AzureNetworkPrefixes) {
|
foreach ($prefix in $AzureNetworkPrefixes) {
|
||||||
Add-VpnConnectionRoute -ConnectionName $ConnectionName `
|
Add-VpnConnectionRoute -ConnectionName $ConnectionName `
|
||||||
-DestinationPrefix $prefix -AllUserConnection -PassThru | Out-Null
|
-DestinationPrefix $prefix -AllUserConnection -PassThru | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The unified bootstrap can discover the domain after connecting.
|
||||||
|
if ($DomainName) {
|
||||||
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
|
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
|
||||||
Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
|
Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
|
||||||
Where-Object DisplayName -eq $nrptDisplayName |
|
Where-Object DisplayName -eq $nrptDisplayName |
|
||||||
@@ -92,6 +95,7 @@ try {
|
|||||||
-DisplayName $nrptDisplayName `
|
-DisplayName $nrptDisplayName `
|
||||||
-Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null
|
-Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($Connect) {
|
if ($Connect) {
|
||||||
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
|
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ $sourceScripts = @(
|
|||||||
'Enable-LabRemoteAccess.ps1',
|
'Enable-LabRemoteAccess.ps1',
|
||||||
'Enable-SguClientMonitoring.ps1',
|
'Enable-SguClientMonitoring.ps1',
|
||||||
'Install-SguRustDeskClient.ps1',
|
'Install-SguRustDeskClient.ps1',
|
||||||
|
'Set-SguStandardLocalUser.ps1',
|
||||||
'Test-SguClientEnrollment.ps1',
|
'Test-SguClientEnrollment.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1'
|
'Repair-SguClientEnrollment.ps1'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
param(
|
param(
|
||||||
[ipaddress]$DomainControllerIPv4Address,
|
[ipaddress]$DomainControllerIPv4Address,
|
||||||
[string]$NetworkInterfaceAlias,
|
[string]$NetworkInterfaceAlias,
|
||||||
|
[ipaddress]$ClientIPv4Address,
|
||||||
|
[ValidateRange(1, 32)]
|
||||||
|
[int]$ClientPrefixLength = 24,
|
||||||
[PSCredential]$DomainCredential,
|
[PSCredential]$DomainCredential,
|
||||||
[string]$DomainName = 'lci.lasalle.mx',
|
[string]$DomainName,
|
||||||
[string]$DomainNetbios = 'LCI',
|
[string]$DomainNetbios,
|
||||||
[string]$ComputerOuDn,
|
[string]$ComputerOuDn,
|
||||||
[string]$NewComputerName,
|
[string]$NewComputerName,
|
||||||
|
[ValidateSet('Auto', 'Windows10Legacy', 'Windows11Modern')]
|
||||||
|
[string]$CompatibilityProfile = 'Auto',
|
||||||
[ValidateSet('Direct', 'AzureP2S')]
|
[ValidateSet('Direct', 'AzureP2S')]
|
||||||
[string]$ConnectivityMode = 'Direct',
|
[string]$ConnectivityMode = 'Direct',
|
||||||
[string]$VpnConnectionName = 'SGU Azure P2S',
|
[string]$VpnConnectionName = 'SGU Azure P2S',
|
||||||
@@ -16,14 +21,42 @@ param(
|
|||||||
[securestring]$VpnClientCertificatePfxPassword,
|
[securestring]$VpnClientCertificatePfxPassword,
|
||||||
[string]$VpnClientRootCertificatePath,
|
[string]$VpnClientRootCertificatePath,
|
||||||
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
|
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
|
||||||
|
[switch]$PauseOnError,
|
||||||
[switch]$SkipRestart
|
[switch]$SkipRestart
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$brokerRecordName = 'sgu-auth'
|
$brokerRecordName = 'sgu-auth'
|
||||||
$brokerDnsName = "$brokerRecordName.$DomainName"
|
|
||||||
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
|
|
||||||
$temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N'))
|
$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 {
|
function Assert-Administrator {
|
||||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
@@ -52,42 +85,223 @@ function Assert-PackageManifest {
|
|||||||
throw "Bootstrap package integrity check failed: $($entry.Path)"
|
throw "Bootstrap package integrity check failed: $($entry.Path)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return $manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
function Resolve-ClientInterfaceAlias {
|
function Resolve-ClientInterfaceAlias {
|
||||||
param([string]$RequestedAlias)
|
param(
|
||||||
|
[string]$RequestedAlias,
|
||||||
|
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress
|
||||||
|
)
|
||||||
|
|
||||||
if ($RequestedAlias) {
|
# IP interfaces include tunnel/PPP adapters that Get-NetAdapter can omit.
|
||||||
Get-NetAdapter -Name $RequestedAlias -ErrorAction Stop | Out-Null
|
$interfaces = @(Get-NetIPInterface -AddressFamily IPv4 | Where-Object {
|
||||||
return $RequestedAlias
|
$_.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."
|
||||||
}
|
}
|
||||||
|
|
||||||
$defaultRoute = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' `
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 |
|
-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
|
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')
|
$addressReadyDeadline = (Get-Date).AddSeconds(20)
|
||||||
if ($upAdapters.Count -eq 1) {
|
do {
|
||||||
return [string]$upAdapters[0].Name
|
$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 ', '
|
$observedState = if ($configuredAddress) { $configuredAddress.AddressState } else { 'Missing' }
|
||||||
throw "Could not select a network adapter. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
|
throw "The SGU client address '$RequestedAddress' did not become ready on '$InterfaceAlias' within 20 seconds. Observed state: $observedState."
|
||||||
}
|
}
|
||||||
|
|
||||||
function Test-TcpPort {
|
function Test-TcpPort {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][ipaddress]$Address,
|
[Parameter(Mandatory)][ipaddress]$Address,
|
||||||
[Parameter(Mandatory)][int]$Port,
|
[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 {
|
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)
|
$connect = $client.BeginConnect($Address, $Port, $null, $null)
|
||||||
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) {
|
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) {
|
||||||
return $false
|
return $false
|
||||||
@@ -100,9 +314,81 @@ function Test-TcpPort {
|
|||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
$client.Dispose()
|
$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 {
|
function Connect-SguAzureP2s {
|
||||||
param([Parameter(Mandatory)][string]$ConnectionName)
|
param([Parameter(Mandatory)][string]$ConnectionName)
|
||||||
|
|
||||||
@@ -129,25 +415,28 @@ function Connect-SguAzureP2s {
|
|||||||
|
|
||||||
Assert-Administrator
|
Assert-Administrator
|
||||||
$operatingSystem = Get-CimInstance Win32_OperatingSystem
|
$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
|
$edition = (Get-WindowsEdition -Online).Edition
|
||||||
if ($edition -match '^Core' -or $edition -match 'Home') {
|
Assert-ClientOperatingSystem -OperatingSystem $operatingSystem -Edition $edition `
|
||||||
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."
|
-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) {
|
if (-not $DomainControllerIPv4Address) {
|
||||||
$DomainControllerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address of the SGU domain controller')
|
$DomainControllerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address of the SGU domain controller')
|
||||||
}
|
}
|
||||||
if (-not $ComputerOuDn) {
|
if ($DomainControllerIPv4Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or
|
||||||
$baseDn = (($DomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ','
|
$DomainControllerIPv4Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.|24\d\.|25[0-5]\.)') {
|
||||||
$ComputerOuDn = "OU=Laboratorio,$baseDn"
|
throw 'Enter a reachable unicast IPv4 address for the domain controller.'
|
||||||
}
|
}
|
||||||
|
|
||||||
$packageRoot = $PSScriptRoot
|
$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'
|
$scriptsRoot = Join-Path $packageRoot 'payload\scripts'
|
||||||
$providerPublishPath = Join-Path $packageRoot 'payload\credential-provider'
|
$providerPublishPath = Join-Path $packageRoot 'payload\credential-provider'
|
||||||
$runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') `
|
$runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') `
|
||||||
@@ -166,6 +455,15 @@ foreach ($requiredPath in @(
|
|||||||
if (-not $runtimeInstaller) {
|
if (-not $runtimeInstaller) {
|
||||||
throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.'
|
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') {
|
if ($ConnectivityMode -eq 'AzureP2S') {
|
||||||
$existingVpnConnection = Get-VpnConnection -Name $VpnConnectionName -AllUserConnection `
|
$existingVpnConnection = Get-VpnConnection -Name $VpnConnectionName -AllUserConnection `
|
||||||
@@ -198,31 +496,26 @@ if ($ConnectivityMode -eq 'AzureP2S') {
|
|||||||
& $installerPath @vpnInstallParameters | Out-Null
|
& $installerPath @vpnInstallParameters | Out-Null
|
||||||
}
|
}
|
||||||
$vpnConnection = Connect-SguAzureP2s -ConnectionName $VpnConnectionName
|
$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)) {
|
if (-not (Wait-TcpPort -Address $DomainControllerIPv4Address -Port 5985 -TimeoutSeconds 20)) {
|
||||||
throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP."
|
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) {
|
if (-not $DomainCredential) {
|
||||||
|
$suggestedUser = if ($DomainNetbios) { "$DomainNetbios\Administrator" }
|
||||||
|
elseif ($DomainName) { "Administrator@$DomainName" } else { 'Administrator' }
|
||||||
$DomainCredential = Get-Credential `
|
$DomainCredential = Get-Credential `
|
||||||
-UserName "$DomainNetbios\Administrator" `
|
-UserName $suggestedUser `
|
||||||
-Message "Credential permitted to enroll this computer in $DomainName"
|
-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
|
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
|
||||||
$clientCertificatePath = Join-Path $temporaryRoot 'client.cer'
|
$clientCertificatePath = Join-Path $temporaryRoot 'client.cer'
|
||||||
@@ -252,6 +545,12 @@ try {
|
|||||||
|
|
||||||
$serverIdentity = Invoke-Command -Session $session -ScriptBlock {
|
$serverIdentity = Invoke-Command -Session $session -ScriptBlock {
|
||||||
$computer = Get-CimInstance Win32_ComputerSystem
|
$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
|
$brokerService = Get-Service SGUAuthBroker -ErrorAction SilentlyContinue
|
||||||
$rustDeskStatusPath = Join-Path $env:ProgramData 'SGU\RustDesk\server.json'
|
$rustDeskStatusPath = Join-Path $env:ProgramData 'SGU\RustDesk\server.json'
|
||||||
$rustDeskStatus = if (Test-Path -LiteralPath $rustDeskStatusPath -PathType Leaf) {
|
$rustDeskStatus = if (Test-Path -LiteralPath $rustDeskStatusPath -PathType Leaf) {
|
||||||
@@ -265,6 +564,8 @@ try {
|
|||||||
[pscustomobject]@{
|
[pscustomobject]@{
|
||||||
ComputerName = $env:COMPUTERNAME
|
ComputerName = $env:COMPUTERNAME
|
||||||
Domain = $computer.Domain
|
Domain = $computer.Domain
|
||||||
|
DomainNetbios = $domain.NetBIOSName
|
||||||
|
ComputerContainer = if ($labOu) { $labOu.DistinguishedName } else { $domain.ComputersContainer }
|
||||||
BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' }
|
BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' }
|
||||||
RustDeskServerAddress = if ($rustDeskStatus) { [string]$rustDeskStatus.ServerAddress } else { $null }
|
RustDeskServerAddress = if ($rustDeskStatus) { [string]$rustDeskStatus.ServerAddress } else { $null }
|
||||||
RustDeskPublicKey = if ($rustDeskStatus) { [string]$rustDeskStatus.PublicKey } else { $null }
|
RustDeskPublicKey = if ($rustDeskStatus) { [string]$rustDeskStatus.PublicKey } else { $null }
|
||||||
@@ -272,10 +573,19 @@ try {
|
|||||||
RustDeskHbbrTask = if ($hbbrTask) { $hbbrTask.State.ToString() } else { 'Missing' }
|
RustDeskHbbrTask = if ($hbbrTask) { $hbbrTask.State.ToString() } else { 'Missing' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (-not $serverIdentity.Domain -or
|
if (-not $serverIdentity.Domain -or ($DomainName -and
|
||||||
-not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase)) {
|
-not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase))) {
|
||||||
throw "The server at $DomainControllerIPv4Address belongs to $($serverIdentity.Domain), not $DomainName."
|
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') {
|
if ($serverIdentity.BrokerService -ne 'Running') {
|
||||||
throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)."
|
throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)."
|
||||||
}
|
}
|
||||||
@@ -285,6 +595,12 @@ try {
|
|||||||
$serverIdentity.RustDeskHbbrTask -ne 'Running') {
|
$serverIdentity.RustDeskHbbrTask -ne 'Running') {
|
||||||
throw "The RustDesk server is not ready on $($serverIdentity.ComputerName). Run the current server bootstrap first."
|
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"
|
$certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME"
|
||||||
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
|
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
|
||||||
@@ -367,6 +683,7 @@ try {
|
|||||||
ComputerOuDn = $ComputerOuDn
|
ComputerOuDn = $ComputerOuDn
|
||||||
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
||||||
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
|
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
|
||||||
|
DomainDnsConfigured = $true
|
||||||
ConnectivityMode = $ConnectivityMode
|
ConnectivityMode = $ConnectivityMode
|
||||||
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
|
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
|
||||||
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
|
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
|
||||||
@@ -428,7 +745,8 @@ finally {
|
|||||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force
|
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force
|
||||||
}
|
}
|
||||||
if (-not $winRmWasRunning) {
|
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
|
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
$DomainCredential = $null
|
$DomainCredential = $null
|
||||||
@@ -443,6 +761,7 @@ if ($SkipRestart) {
|
|||||||
ClientCertificateRegistered = $true
|
ClientCertificateRegistered = $true
|
||||||
BrokerEndpoint = $brokerEndpoint
|
BrokerEndpoint = $brokerEndpoint
|
||||||
ConnectivityMode = $ConnectivityMode
|
ConnectivityMode = $ConnectivityMode
|
||||||
|
CompatibilityProfile = $CompatibilityProfile
|
||||||
VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null }
|
VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null }
|
||||||
RestartRequired = $true
|
RestartRequired = $true
|
||||||
RustDesk = if ($result) { $result.RustDesk } else { $null }
|
RustDesk = if ($result) { $result.RustDesk } else { $null }
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ function Write-PackageManifest {
|
|||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][string]$PackageRoot,
|
[Parameter(Mandatory)][string]$PackageRoot,
|
||||||
[Parameter(Mandatory)][string]$PackageVersion,
|
[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('\')
|
$resolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path.TrimEnd('\')
|
||||||
@@ -48,13 +51,19 @@ function Write-PackageManifest {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
$manifest = [ordered]@{
|
$manifest = [ordered]@{
|
||||||
SchemaVersion = 1
|
SchemaVersion = 2
|
||||||
Product = 'SGU Credential Provider'
|
Product = 'SGU Credential Provider'
|
||||||
PackageKind = $PackageKind
|
PackageKind = $PackageKind
|
||||||
Version = $PackageVersion
|
Version = $PackageVersion
|
||||||
CreatedAt = (Get-Date).ToUniversalTime().ToString('o')
|
CreatedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
Files = $files
|
Files = $files
|
||||||
}
|
}
|
||||||
|
if ($CompatibilityProfile) {
|
||||||
|
$manifest['CompatibilityProfile'] = $CompatibilityProfile
|
||||||
|
}
|
||||||
|
if ($TargetOperatingSystem) {
|
||||||
|
$manifest['TargetOperatingSystem'] = $TargetOperatingSystem
|
||||||
|
}
|
||||||
[IO.File]::WriteAllText(
|
[IO.File]::WriteAllText(
|
||||||
(Join-Path $resolvedPackageRoot 'package-manifest.json'),
|
(Join-Path $resolvedPackageRoot 'package-manifest.json'),
|
||||||
($manifest | ConvertTo-Json -Depth 6),
|
($manifest | ConvertTo-Json -Depth 6),
|
||||||
@@ -78,7 +87,7 @@ if (-not $runtimeInstaller) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null
|
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"
|
$serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version"
|
||||||
$linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version"
|
$linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version"
|
||||||
$azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version"
|
$azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version"
|
||||||
@@ -86,13 +95,17 @@ $clientZip = "$clientRoot.zip"
|
|||||||
$serverZip = "$serverRoot.zip"
|
$serverZip = "$serverRoot.zip"
|
||||||
$linuxClientZip = "$linuxClientRoot.zip"
|
$linuxClientZip = "$linuxClientRoot.zip"
|
||||||
$azureZip = "$azureRoot.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) {
|
if (Test-Path -LiteralPath $target) {
|
||||||
throw "Release target already exists: $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 = @(
|
$welcomeFontNames = @(
|
||||||
'IndivisaTextSans-Regular.otf',
|
'IndivisaTextSans-Regular.otf',
|
||||||
'IndivisaTextSans-Bold.otf',
|
'IndivisaTextSans-Bold.otf',
|
||||||
@@ -105,10 +118,6 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Invoke-SguClientBootstrap.ps
|
|||||||
-Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1')
|
-Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1')
|
||||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') `
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') `
|
||||||
-Destination (Join-Path $clientRoot '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 = @(
|
$clientScripts = @(
|
||||||
'Enable-LabRemoteAccess.ps1',
|
'Enable-LabRemoteAccess.ps1',
|
||||||
'Enable-SguClientMonitoring.ps1',
|
'Enable-SguClientMonitoring.ps1',
|
||||||
@@ -118,6 +127,7 @@ $clientScripts = @(
|
|||||||
'Install-SguRustDeskClient.ps1',
|
'Install-SguRustDeskClient.ps1',
|
||||||
'Register-SguClientCertificate.ps1',
|
'Register-SguClientCertificate.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1',
|
'Repair-SguClientEnrollment.ps1',
|
||||||
|
'Set-SguStandardLocalUser.ps1',
|
||||||
'Test-SguClientEnrollment.ps1'
|
'Test-SguClientEnrollment.ps1'
|
||||||
)
|
)
|
||||||
foreach ($scriptName in $clientScripts) {
|
foreach ($scriptName in $clientScripts) {
|
||||||
@@ -140,7 +150,19 @@ foreach ($fontName in $welcomeFontNames) {
|
|||||||
}
|
}
|
||||||
Copy-RequiredFile -Source $runtimeInstaller.FullName `
|
Copy-RequiredFile -Source $runtimeInstaller.FullName `
|
||||||
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
|
-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 `
|
Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip `
|
||||||
-CompressionLevel Optimal
|
-CompressionLevel Optimal
|
||||||
|
|
||||||
@@ -251,8 +273,8 @@ $checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
|
|||||||
|
|
||||||
[pscustomobject]@{
|
[pscustomobject]@{
|
||||||
Version = $Version
|
Version = $Version
|
||||||
ClientPackage = $clientZip
|
WindowsClientPackage = $clientZip
|
||||||
ClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash
|
WindowsClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash
|
||||||
LinuxClientPackage = $linuxClientZip
|
LinuxClientPackage = $linuxClientZip
|
||||||
LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash
|
LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash
|
||||||
ServerPackage = $serverZip
|
ServerPackage = $serverZip
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ param(
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$tagName = "v$Version"
|
$tagName = "v$Version"
|
||||||
$assetPaths = @(
|
$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-server-bootstrap-$Version.zip"),
|
||||||
(Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"),
|
(Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"),
|
||||||
(Join-Path $ReleaseDirectory "sgu-azure-infrastructure-$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.
|
- **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-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-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.
|
- `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.
|
- 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 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 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.
|
- 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.
|
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ $installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
|
|||||||
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
|
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
|
||||||
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
|
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
|
||||||
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
|
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
|
||||||
|
$localUserScript = Join-Path $enrollmentRoot 'Set-SguStandardLocalUser.ps1'
|
||||||
|
|
||||||
$before = & $testScript
|
$before = & $testScript
|
||||||
if (-not $before.IsValid) {
|
if (-not $before.IsValid) {
|
||||||
|
& $localUserScript | Out-Null
|
||||||
$installParams = @{
|
$installParams = @{
|
||||||
PublishPath = [string]$configuration.PublishPath
|
PublishPath = [string]$configuration.PublishPath
|
||||||
BrokerEndpoint = [string]$configuration.BrokerEndpoint
|
BrokerEndpoint = [string]$configuration.BrokerEndpoint
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,5 +4,12 @@ set "SGU_BOOTSTRAP_IP=%~1"
|
|||||||
set "SGU_VPN_PACKAGE=%~2"
|
set "SGU_VPN_PACKAGE=%~2"
|
||||||
set "SGU_VPN_PFX=%~3"
|
set "SGU_VPN_PFX=%~3"
|
||||||
set "SGU_VPN_ROOT=%~4"
|
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"
|
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"
|
||||||
exit /b %errorlevel%
|
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%
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal
|
setlocal
|
||||||
set "SGU_BOOTSTRAP_IP=%~1"
|
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"
|
set "SGU_CLIENT_IP=%~2"
|
||||||
exit /b %errorlevel%
|
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%
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
|
|||||||
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||||
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
||||||
$issues = [Collections.Generic.List[string]]::new()
|
$issues = [Collections.Generic.List[string]]::new()
|
||||||
|
$standardLocalUserName = 'alumno'
|
||||||
|
$passwordNeverExpiresFlag = 0x10000
|
||||||
|
|
||||||
$computer = Get-CimInstance Win32_ComputerSystem
|
$computer = Get-CimInstance Win32_ComputerSystem
|
||||||
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
|
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
|
||||||
@@ -87,6 +89,50 @@ if (-not $passwordProviderPreserved) {
|
|||||||
$issues.Add('The built-in Microsoft password provider registration is missing.')
|
$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
|
$settings = $null
|
||||||
try {
|
try {
|
||||||
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
|
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
|
||||||
@@ -207,6 +253,11 @@ $result = [pscustomobject]@{
|
|||||||
LastSignedInUserHidden = $lastSignedInUserHidden
|
LastSignedInUserHidden = $lastSignedInUserHidden
|
||||||
LocalUserEnumerationDisabled = $localUserEnumerationDisabled
|
LocalUserEnumerationDisabled = $localUserEnumerationDisabled
|
||||||
PasswordProviderPreserved = $passwordProviderPreserved
|
PasswordProviderPreserved = $passwordProviderPreserved
|
||||||
|
StandardLocalUserPresent = $standardLocalUserPresent
|
||||||
|
StandardLocalUserEnabled = $standardLocalUserEnabled
|
||||||
|
StandardLocalUserIsAdministrator = $standardLocalUserIsAdministrator
|
||||||
|
StandardLocalUserInUsersGroup = $standardLocalUserInUsersGroup
|
||||||
|
StandardLocalUserPasswordNeverExpires = $standardLocalUserPasswordNeverExpires
|
||||||
SettingsPresent = [bool]$settings
|
SettingsPresent = [bool]$settings
|
||||||
ClientCertificatePresent = [bool]$clientCertificatePresent
|
ClientCertificatePresent = [bool]$clientCertificatePresent
|
||||||
ServerCertificateTrusted = $serverCertificateTrusted
|
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;
|
||||||
using System.Net.Mail;
|
using System.Net.Mail;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace SGU.AuthBroker.Core.Profiles;
|
namespace SGU.AuthBroker.Core.Profiles;
|
||||||
|
|
||||||
@@ -102,7 +103,14 @@ public static class SguProfileParser
|
|||||||
return profile.HasValues ? profile : null;
|
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);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
|
|
||||||
@@ -112,18 +120,79 @@ public static class SguProfileParser
|
|||||||
string? interiorNumber = NormalizeAddressUnit(
|
string? interiorNumber = NormalizeAddressUnit(
|
||||||
ExtractInputValue(html, AdministrativeInteriorNumberId));
|
ExtractInputValue(html, AdministrativeInteriorNumberId));
|
||||||
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
|
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
|
||||||
string? neighborhood = NormalizeTitle(
|
string? neighborhood = NormalizeTitle(FirstNonEmpty(
|
||||||
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
|
selection?.NeighborhoodName,
|
||||||
256);
|
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(
|
InstitutionalProfile profile = new(
|
||||||
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
|
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
|
||||||
City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128),
|
City: city,
|
||||||
State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128),
|
State: state,
|
||||||
PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId)));
|
PostalCode: postalCode);
|
||||||
return profile.HasValues ? profile : null;
|
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)
|
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(html);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
@@ -350,6 +419,186 @@ public static class SguProfileParser
|
|||||||
return null;
|
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)
|
private static string? FindOpeningTag(string html, string tagName, string id)
|
||||||
=> FindOpeningTagByAttribute(html, tagName, "id", id);
|
=> FindOpeningTagByAttribute(html, tagName, "id", id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using SGU.AuthBroker.Core.Authentication;
|
using SGU.AuthBroker.Core.Authentication;
|
||||||
using SGU.AuthBroker.Core.Identity;
|
using SGU.AuthBroker.Core.Identity;
|
||||||
using SGU.AuthBroker.Core.Profiles;
|
using SGU.AuthBroker.Core.Profiles;
|
||||||
@@ -431,9 +433,6 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
pages.Add((
|
pages.Add((
|
||||||
options.AdministrativePersonalProfilePath,
|
options.AdministrativePersonalProfilePath,
|
||||||
SguProfileParser.ParseAdministrativePersonal));
|
SguProfileParser.ParseAdministrativePersonal));
|
||||||
pages.Add((
|
|
||||||
options.AdministrativeLocationProfilePath,
|
|
||||||
SguProfileParser.ParseAdministrativeLocation));
|
|
||||||
|
|
||||||
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
|
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.",
|
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
identity.Role,
|
identity.Role,
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
break;
|
return profile;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
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;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
return null;
|
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(
|
private static void AddCredential(
|
||||||
Uri uri,
|
Uri uri,
|
||||||
CredentialCache credentialCache,
|
CredentialCache credentialCache,
|
||||||
@@ -658,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
private Uri GetProfileUri(string path) =>
|
private Uri GetProfileUri(string path) =>
|
||||||
new(new Uri(options.Endpoint, UriKind.Absolute), 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(
|
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
UserIdentity identity,
|
UserIdentity identity,
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ $networkFunctions = $serverAst.FindAll({
|
|||||||
}, $true)
|
}, $true)
|
||||||
Invoke-Expression (($networkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine)
|
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' {
|
Describe 'SGU public-cloud network safety' {
|
||||||
It 'canonicalizes a host address to its IPv4 network' {
|
It 'canonicalizes a host address to its IPv4 network' {
|
||||||
ConvertTo-NetworkCidr -Address ([ipaddress]'10.77.0.4') `
|
ConvertTo-NetworkCidr -Address ([ipaddress]'10.77.0.4') `
|
||||||
@@ -56,6 +74,40 @@ Describe 'SGU public-cloud network safety' {
|
|||||||
'ConnectivityMode') | Should Be $true
|
'ConnectivityMode') | Should Be $true
|
||||||
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||||
'VpnProfilePackagePath') | Should Be $true
|
'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' {
|
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'"
|
$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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
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]
|
[Fact]
|
||||||
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
|
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -145,14 +145,24 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||||
<input id="ctl00_contenedor_txtCP" value="01000" />
|
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||||
<select id="ctl00_contenedor_ddlEstado">
|
<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>
|
||||||
<select id="ctl00_contenedor_ddlLocalidad">
|
<select id="ctl00_contenedor_ddlLocalidad">
|
||||||
<option selected="selected">ÁLVARO OBREGÓN</option>
|
<option selected="selected" value="0">Seleccione alguna localidad...</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ctl00_contenedor_ddlColonia">
|
<select id="ctl00_contenedor_ddlColonia">
|
||||||
<option>FLORIDA</option>
|
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
|
||||||
</select>
|
</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);
|
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||||
|
|
||||||
@@ -180,9 +190,17 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
"/psulsa/",
|
"/psulsa/",
|
||||||
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
||||||
"/psulsa/gadmon/capitalhumano/datos/personales.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);
|
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]
|
[Fact]
|
||||||
@@ -242,14 +260,24 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||||
<input id="ctl00_contenedor_txtCP" value="01000" />
|
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||||
<select id="ctl00_contenedor_ddlEstado">
|
<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>
|
||||||
<select id="ctl00_contenedor_ddlLocalidad">
|
<select id="ctl00_contenedor_ddlLocalidad">
|
||||||
<option selected="selected">ÁLVARO OBREGÓN</option>
|
<option selected="selected" value="0">Seleccione alguna localidad...</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ctl00_contenedor_ddlColonia">
|
<select id="ctl00_contenedor_ddlColonia">
|
||||||
<option selected="selected">FLORIDA</option>
|
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
|
||||||
</select>
|
</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);
|
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||||
|
|
||||||
@@ -280,7 +308,10 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
"/psulsa/menu.aspx",
|
"/psulsa/menu.aspx",
|
||||||
"/psulsa/gadmon/nomina/consultanomina.aspx",
|
"/psulsa/gadmon/nomina/consultanomina.aspx",
|
||||||
"/psulsa/gadmon/capitalhumano/datos/personales.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);
|
handler.RequestPaths);
|
||||||
}
|
}
|
||||||
@@ -363,18 +394,29 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
Content = new StringContent(content)
|
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 sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||||
{
|
{
|
||||||
private readonly Queue<HttpResponseMessage> responses = new(responses);
|
private readonly Queue<HttpResponseMessage> responses = new(responses);
|
||||||
|
|
||||||
public List<string> RequestPaths { get; } = [];
|
public List<string> RequestPaths { get; } = [];
|
||||||
|
|
||||||
|
public List<string?> RequestBodies { get; } = [];
|
||||||
|
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request,
|
HttpRequestMessage request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
RequestPaths.Add(request.RequestUri!.AbsolutePath);
|
RequestPaths.Add(request.RequestUri!.AbsolutePath);
|
||||||
|
RequestBodies.Add(request.Content is null
|
||||||
|
? null
|
||||||
|
: request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult());
|
||||||
if (responses.Count == 0)
|
if (responses.Count == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The validator sent more requests than expected.");
|
throw new InvalidOperationException("The validator sent more requests than expected.");
|
||||||
|
|||||||
Reference in New Issue
Block a user