Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
520b4be955 | ||
|
|
7f8a9eed4e | ||
|
|
74235ec6f8 | ||
|
|
0b510082d4 | ||
|
|
5e62a65316 | ||
|
|
1332f546fa | ||
|
|
14c919b380 | ||
|
|
a6bd625e4e | ||
|
|
8baa47fe1e | ||
|
|
a24c25a3fb | ||
|
|
991fc70600 | ||
|
|
93871b62b0 |
@@ -9,7 +9,7 @@ source and adds an SGU-specific provider, an mTLS-protected broker, Active
|
||||
Directory synchronization, deployment scripts, and tests.
|
||||
|
||||
Ready-to-run bootstrap packages are published on the
|
||||
[releases page](https://github.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
|
||||
[releases page](https://gitea.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
|
||||
|
||||
## Authentication contract
|
||||
|
||||
@@ -44,6 +44,13 @@ skips that optional field. Missing or changed presentation HTML never blocks
|
||||
authentication or password synchronization after the lightweight NTLM root has
|
||||
accepted the credential.
|
||||
|
||||
For administrative staff and professors, the location page is enriched with its
|
||||
ASP.NET PageMethods responses. `GetDireccion` supplies the saved state,
|
||||
municipality and neighborhood identifiers; `GetLocalidadListado` resolves the
|
||||
municipality name, and `GetColoniasListado` validates or supplies the
|
||||
neighborhood name. This avoids reading the temporary `Seleccione...` values
|
||||
visible while the browser populates those controls asynchronously.
|
||||
|
||||
Operational documentation:
|
||||
|
||||
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md)
|
||||
@@ -55,11 +62,11 @@ Operational documentation:
|
||||
- [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md)
|
||||
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
|
||||
|
||||
| Prefix | Role | Default OU |
|
||||
|---|---|---|
|
||||
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
||||
| `AL` | Student / alumno | `OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
||||
| `AD` | Administrative | `OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
||||
| Prefix | Role | Default OU | Security group in the same OU |
|
||||
|---|---|---|---|
|
||||
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Docentes` |
|
||||
| `AL` | Student / alumno | `OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Alumnos` |
|
||||
| `AD` | Administrative | `OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Administrativos` |
|
||||
|
||||
If the broker or institutional NTLM authority is unavailable, the provider
|
||||
submits the unchanged credentials to Windows for normal AD/cached-domain
|
||||
@@ -97,17 +104,37 @@ the latest .NET 10 x64 runtime. The broker is published self-contained.
|
||||
Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review
|
||||
[docs/security.md](docs/security.md) before production deployment and
|
||||
[docs/architecture.md](docs/architecture.md) for the component contract.
|
||||
For a public Azure VM, use
|
||||
[docs/azure-vpn-deployment.md](docs/azure-vpn-deployment.md). It supports an
|
||||
optional Azure P2S gateway or direct enrollment restricted to explicit public
|
||||
source CIDRs.
|
||||
|
||||
Never disable the built-in Microsoft password Credential Provider. It is the
|
||||
supported recovery path if a third-party provider fails to load.
|
||||
|
||||
For a clean machine, the supported entry points are the release packages:
|
||||
For a clean Windows 10 or Windows 11 workstation, use the unified package:
|
||||
|
||||
- `sgu-windows-client-bootstrap-VERSION.zip` (x64 Pro, Enterprise or Education),
|
||||
including optional Azure P2S setup for both versions.
|
||||
|
||||
Double-click `Start-SguClientEnrollment.cmd` and enter the server IP and domain
|
||||
credentials, or provide the server IP on the command line:
|
||||
|
||||
```bat
|
||||
Start-SguServerBootstrap.cmd 192.168.50.10
|
||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
||||
```
|
||||
|
||||
El bootstrap prueba interfaces y rutas hacia el servidor, incluyendo VPN ya
|
||||
conectadas, sin pedir la IP del cliente ni exigir la misma subred. Conserva DHCP
|
||||
y el DNS de Internet; descubre el dominio autenticado y configura DNS sólo para
|
||||
ese dominio. Si recibe una IP pública, configura DoH y los nombres necesarios de
|
||||
AD después de autenticar al servidor. El segmento público del cliente debe estar
|
||||
autorizado en el servidor y su firewall perimetral. Los casos sin DHCP o sin ruta
|
||||
muestran un diagnóstico.
|
||||
Ver [client-enrollment.md](docs/client-enrollment.md) para requisitos y parámetros
|
||||
avanzados de IP estática.
|
||||
|
||||
Linux clients are enrolled through their native PAM/SSSD stack instead of the
|
||||
Windows Credential Provider:
|
||||
|
||||
|
||||
+17
-5
@@ -27,8 +27,12 @@ accepted it. The broker then makes separately bounded, best-effort profile GETs.
|
||||
It uses the administrative incident overview for `AD`, the student information
|
||||
page for `AL`, and the portal menu as a conservative base for `DO`. After the
|
||||
incident page confirms an `AD` employee number, two additional GETs in the same
|
||||
in-memory session read the structured name from `datos/personales.aspx` and the
|
||||
address from `datos/ubicacion.aspx`. Docentes request
|
||||
in-memory session read the structured name and selected sex from
|
||||
`datos/personales.aspx` and the address inputs from `datos/ubicacion.aspx`. The
|
||||
broker then calls the location page's `GetDireccion`, `GetLocalidadListado`, and
|
||||
`GetColoniasListado` methods to correlate the saved state, municipality, and
|
||||
neighborhood identifiers instead of reading transient `Seleccione...` options.
|
||||
Docentes request
|
||||
`nomina/consultanomina.aspx` for a matching employee number, email, employee
|
||||
type and job title, then attempt the same two shared staff modules without
|
||||
requiring any optional route to exist. A supplemental
|
||||
@@ -61,7 +65,9 @@ passes the submitted password directly to ADSI `SetPassword`.
|
||||
When the authenticated HTML exposes recognized stable IDs, the broker also
|
||||
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
|
||||
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
|
||||
`postalCode` attributes. Administrative and student numbers must match the six
|
||||
`postalCode` attributes. The SGU sex value is normalized to `Male`/`Female` and
|
||||
written as the managed `SGU-Gender:` line in the built-in `info` attribute while
|
||||
preserving unrelated notes. Administrative and student numbers must match the six
|
||||
numeric digits of the requested identity before any role-specific metadata is
|
||||
trusted. Administrative personal and location pages are accepted only after
|
||||
that incident-page match. Docente payroll metadata must match the requested
|
||||
@@ -74,8 +80,11 @@ password outcome.
|
||||
|
||||
Every synchronization also enforces one idempotent security-group membership
|
||||
from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to
|
||||
`SGU-Administrativos`, and `DO` to `SGU-Docentes`. This happens synchronously
|
||||
inside the broker before the institutional password is written to AD. A missing
|
||||
`SGU-Administrativos`, and `DO` to `SGU-Docentes`. Each role group is stored
|
||||
inside its corresponding user OU. During an upgrade,
|
||||
the bootstrap moves a legacy group from the `Usuarios-SGU` root while preserving
|
||||
its SID and memberships instead of creating a duplicate. Membership enforcement
|
||||
happens synchronously inside the broker before the institutional password is written to AD. A missing
|
||||
or inaccessible role group therefore fails provisioning instead of leaving a
|
||||
new usable account without its authorization classification. Existing accounts
|
||||
are repaired automatically on their next successful SGU authentication.
|
||||
@@ -118,6 +127,9 @@ settings for managed clients.
|
||||
|
||||
That computer GPO also owns the base lock-screen image and a per-logon command
|
||||
for the personalized desktop wallpaper. The client-side renderer reads the
|
||||
managed `SGU-Gender: Male|Female` line from the user's built-in `info` attribute
|
||||
(without requiring an irreversible AD schema extension). It uses neutral Spanish
|
||||
when that optional enrichment is unavailable. The renderer also reads the
|
||||
authenticated user's `displayName` plus the computer object's `location` and
|
||||
immediate parent OU, then composes those values over the bundled dark-blue
|
||||
background with the bundled Indivisa fonts. Missing directory attributes degrade
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Despliegue SGU en Azure y enrolamiento de Windows11-002
|
||||
|
||||
Fecha: 2026-09-10. Suscripción `1254ca0e-3950-4711-8b4b-e33b4677d950`.
|
||||
|
||||
## Infraestructura y bosque
|
||||
|
||||
| Componente | Configuración comprobada |
|
||||
| --- | --- |
|
||||
| Grupo de recursos / región | `rg-sgu-lab` / `centralus` |
|
||||
| VM Azure / nombre Windows | `sgu-lab-dc` / `SGU-DC01` |
|
||||
| Sistema y tamaño | Windows Server 2025 Azure Edition / `Standard_D2s_v5` |
|
||||
| Dirección del controlador de dominio | `10.77.0.4` |
|
||||
| Bosque / dominio / NetBIOS | `lci.lasalle.mx` / `lci.lasalle.mx` / `LCI` |
|
||||
| SID del dominio Azure | `S-1-5-21-2324484875-464590158-1758545597` |
|
||||
| VNet / pool P2S | `10.77.0.0/16` / `172.30.0.0/24` |
|
||||
| Gateway | `sgu-lab-vpngw`, `VpnGw1AZ`, estado `Succeeded` |
|
||||
| Protocolos configurados | IKEv2 y OpenVPN; prueba real con IKEv2 |
|
||||
| Cliente Hyper-V / nombre Windows | `Windows11-002` / `DESKTOP-LM7D7OM` |
|
||||
|
||||
Se creó un bosque nuevo en Azure. Tiene el mismo nombre DNS que el bosque del
|
||||
laboratorio local, pero una identidad distinta; no es una réplica ni una
|
||||
migración de sus usuarios. El cliente de esta prueba consulta el bosque Azure
|
||||
mediante una regla NRPT para `.lci.lasalle.mx`.
|
||||
|
||||
La promoción y la configuración SGU terminaron a las `22:10:36Z`. Se comprobó
|
||||
`bootstrap-complete.json`, los servicios AD DS, DNS, ADWS, Netlogon y SGUAuthBroker,
|
||||
los registros SRV y las pruebas dcdiag Connectivity, Advertising, SysVolCheck,
|
||||
NetLogons y Services, todas con resultado satisfactorio. RustDesk, WinRM,
|
||||
escritorio remoto y el colector de eventos quedaron configurados. Los puertos
|
||||
administrativos y de AD no están abiertos a Internet.
|
||||
|
||||
## Enrolamiento y VPN
|
||||
|
||||
El cliente usa Windows 11 Enterprise LTSC x64, build 26100. Se creó el checkpoint
|
||||
`Before-SGU-Azure-Enrollment-20260910` antes de modificarlo.
|
||||
|
||||
Se instaló un certificado de máquina y el perfil nativo `SGU Azure P2S`. Con el
|
||||
túnel conectado, el bootstrap recibió la IP `10.77.0.4` y una credencial de dominio;
|
||||
descubrió automáticamente dominio, NetBIOS, OU y la interfaz VPN `172.30.0.2`.
|
||||
No recibió una IP del cliente ni una interfaz elegida manualmente.
|
||||
|
||||
El objeto `DESKTOP-LM7D7OM` quedó habilitado en
|
||||
`OU=Laboratorio,DC=lci,DC=lasalle,DC=mx`. El proveedor SGU y sus certificados mTLS
|
||||
quedaron instalados. La validación con dominio, broker, acceso remoto y RustDesk
|
||||
exigidos devolvió `IsValid=True`, `Issues=[]`, `BrokerHealth=ok`,
|
||||
`RemoteAccessReady=True` y `RustDeskReady=True`. El guard de enrolamiento terminó
|
||||
con código 0.
|
||||
|
||||
Para este cliente Enterprise se instaló también `SGU Azure Device`, un perfil
|
||||
VPNv2 de dispositivo bajo SYSTEM, con IKEv2, certificado de máquina, Always On y
|
||||
ruta dividida `10.77.0.0/16`. El perfil manual permanece disponible. La NIC de
|
||||
Internet conserva DHCP y DNS `172.18.176.1`; el túnel utiliza la dirección
|
||||
`172.30.0.2` y la red del dominio.
|
||||
|
||||
La primera prueba de arranque del túnel detectó Netlogon 5719 y
|
||||
`ERROR_NO_LOGON_SERVERS`: la red VPN estaba disponible después de que Netlogon
|
||||
intentara localizar el dominio. Reiniciar únicamente Netlogon recuperó el canal
|
||||
seguro sin restablecer la contraseña de máquina. Se probaron
|
||||
`ExpectedDialupDelay=60` y `NegativeCachePeriod=3`, siguiendo la guía de Microsoft para
|
||||
[conectividad de dominio tardía al arrancar](https://learn.microsoft.com/en-us/troubleshoot/windows-server/group-policy/netlogon-event-id-5719-or-group-policy-event-1129).
|
||||
No resolvieron por sí solos esta VM; se devolvieron a sus valores predeterminados
|
||||
0 y 45, respectivamente.
|
||||
|
||||
Se instaló `SGU-Azure-DomainConnectivity`, una tarea SYSTEM de arranque con
|
||||
demora de 30 segundos y reintentos. Ejecuta
|
||||
`scripts/Repair-SguAzureDomainConnectivity.ps1`: espera al perfil VPN y a LDAP
|
||||
del controlador, comprueba el canal seguro y reinicia Netlogon únicamente si
|
||||
es necesario. Después vuelve a ejecutar el guard SGU, espera su resultado y
|
||||
reintenta fallos transitorios de resolución de grupos del dominio. No guarda credenciales
|
||||
ni restablece automáticamente la contraseña de la cuenta de equipo. El
|
||||
resultado se registra en
|
||||
`C:\ProgramData\SGU\Enrollment\azure-domain-connectivity.json`.
|
||||
|
||||
La verificación final se realizó a las **17:00:55 UTC-6**, después del arranque
|
||||
de las **16:57:35 UTC-6**, sin sesión interactiva (`UserName=null`):
|
||||
|
||||
- `SGU Azure Device=Connected`, `172.30.0.2`, red `DomainAuthenticated`.
|
||||
- `Test-ComputerSecureChannel=True`; DC localizado en `10.77.0.4`.
|
||||
- `IsValid=True`, sin incidencias; broker, acceso remoto y RustDesk correctos.
|
||||
- `SGU-Azure-DomainConnectivity` terminó con código 0 y registró la recuperación
|
||||
de Netlogon y `EnrollmentGuardResult=0`.
|
||||
- `SGU-CredentialProvider-EnrollmentGuard` terminó con código 0.
|
||||
- La captura muestra el acceso institucional SGU en la pantalla de inicio de
|
||||
sesión. Se obtuvo directamente de Hyper-V, sin usar el escritorio del host.
|
||||
|
||||
En este arranque, la recuperación completa de dominio y guard terminó unos
|
||||
dos minutos y medio después del inicio de Windows. No se comprobó un inicio de
|
||||
sesión interactivo con un usuario institucional del nuevo bosque; se validaron
|
||||
la unión, la confianza de máquina, los servicios y la salud mTLS.
|
||||
|
||||
## Correcciones y versiones usadas
|
||||
|
||||
- La plantilla Azure usa `VpnGw1AZ`, IP de gateway con zonas 1/2/3 y OpenVPN
|
||||
como alternativa a IKEv2. Azure rechazó las opciones anteriores VpnGw1/SSTP.
|
||||
- El disco del controlador tiene caché `None` para las escrituras de AD DS.
|
||||
- El bootstrap del servidor omite la VF de Accelerated Networking que figura
|
||||
activa sin una interfaz IPv4; configura el adaptador que realmente tiene IP.
|
||||
La prueba de regresión cubre ese caso.
|
||||
- Servidor: paquete local `0.5.2-azure.2`, SHA-256
|
||||
`BE29411A1A1C0FE0BB2BB7DB0418EF40881C98A721B42D1F52D54E22487077AC`.
|
||||
- Cliente: paquete local `0.5.2-azure.1`, con la corrección del saludo sin género.
|
||||
Las diferencias posteriores de `.2` corresponden al servidor.
|
||||
|
||||
Estos paquetes de validación no reemplazan el release 0.5.1 publicado en Gitea.
|
||||
La configuración del device tunnel y de su tarea de recuperación se aplicó a
|
||||
esta VM; no está integrada como opción automática en el instalador publicado.
|
||||
|
||||
## Evidencias y acceso administrativo
|
||||
|
||||
Las evidencias están en `artifacts/azure-deployment-20260910/`, excluido de Git:
|
||||
|
||||
- `server-verification.json`: bootstrap del servidor y dcdiag.
|
||||
- `azure-computer-verification.json`: objeto de equipo en el bosque Azure.
|
||||
- `client-enrollment-result.json`: resultado original de unión.
|
||||
- `client-validation-before-final-reboot.json`: validación completa antes del reinicio.
|
||||
- `client-postboot-verification.json`: comprobación posterior del arranque,
|
||||
incluyendo canal seguro, VPN, usuario interactivo y resultados de las tareas.
|
||||
- `windows11-azure-login.jpg`: captura directa del framebuffer de Hyper-V.
|
||||
- `enable-device-tunnel.ps1`: XML y comandos usados para el túnel de esta VM.
|
||||
- `state.json`: inventario y estado de la operación.
|
||||
|
||||
La cuenta administrativa del bosque es `LCI\azureadmin`. Su contraseña generada
|
||||
está protegida con DPAPI en `credentials.clixml`, dentro de ese directorio del
|
||||
host, para el usuario que ejecutó el despliegue. No se guardó en este documento.
|
||||
La contraseña DSRM se generó en la VM Azure y se conserva protegida para SYSTEM
|
||||
en `C:\ProgramData\SGU\Secrets\dsrm-password.clixml`.
|
||||
|
||||
La revisión automática rechazó la limpieza de la cuenta de almacenamiento
|
||||
temporal `sgustagea8e952c02421` y su rol, y después la limpieza de archivos y de
|
||||
la tarea temporal del cliente, sin indicar un motivo específico. No se
|
||||
eliminaron. El PFX del cliente permanece cifrado y bajo una ACL restringida a
|
||||
Administradores/SYSTEM; la tarea instaladora del túnel no tiene disparador
|
||||
recurrente. Esta limpieza queda pendiente.
|
||||
|
||||
OpenVPN y otras configuraciones de VPN no se probaron. Esta validación no
|
||||
extiende el soporte Always On device tunnel a ediciones Windows Pro.
|
||||
@@ -0,0 +1,225 @@
|
||||
# Active Directory SGU en Azure: VPN opcional o enrolamiento directo
|
||||
|
||||
La misma plantilla despliega Active Directory en Windows Server 2025 y permite
|
||||
elegir entre Azure VPN Gateway Point-to-Site (P2S) o acceso público directo. El
|
||||
modo directo restringe AD, WinRM, Auth Broker y RustDesk a los CIDR públicos
|
||||
indicados; el cliente configura DoH y la resolución del dominio automáticamente.
|
||||
La lista pública vacía no expone esos servicios.
|
||||
|
||||
La plantilla crea:
|
||||
|
||||
- VNet `10.77.0.0/16`, subnet del DC `10.77.0.0/24` y, si se solicita, `GatewaySubnet`;
|
||||
- Windows Server 2025 con IP privada estática `10.77.0.4` reservada en la NIC;
|
||||
- IP pública Standard para la VM, protegida por NSG;
|
||||
- VPN Gateway opcional `VpnGw1AZ` con IKEv2/OpenVPN y autenticación por certificados;
|
||||
- pool P2S `172.30.0.0/24`, autorizado en los firewalls SGU;
|
||||
- DNS de la NIC del servidor apuntando a `10.77.0.4`.
|
||||
|
||||
Los prefijos privados deben ser RFC1918 y no deben solaparse con las redes usadas
|
||||
por Hyper-V, el `Default Switch`, Wi-Fi o Ethernet locales. Los prefijos de
|
||||
enrolamiento directo deben ser CIDR IPv4 públicos explícitos.
|
||||
|
||||
## 1. Elegir el modo de conectividad
|
||||
|
||||
Para P2S, crear la autoridad y el certificado de cada cliente:
|
||||
|
||||
En la estación administrativa donde está el repositorio:
|
||||
|
||||
```powershell
|
||||
$p2s = .\scripts\New-SguAzureP2sCertificates.ps1 `
|
||||
-ClientName 'AdminWorkstation'
|
||||
```
|
||||
|
||||
Se pide una contraseña para proteger el PFX. La clave privada de la autoridad
|
||||
raíz permanece no exportable en `Cert:\CurrentUser\My`; Azure recibe solamente
|
||||
el `.cer` público. El PFX es una credencial de acceso a la VNet: se debe copiar
|
||||
únicamente a la VM correspondiente y eliminarse de ubicaciones compartidas
|
||||
después de importarlo.
|
||||
|
||||
Para acceso directo no se necesita certificado P2S. Se necesita conocer el
|
||||
segmento público de salida del laboratorio; por ejemplo, la IP
|
||||
`200.13.89.183` pertenece a `200.13.89.0/24`.
|
||||
|
||||
## 2. Desplegar Azure
|
||||
|
||||
Requisitos: Azure CLI, una sesión iniciada con `az login`, permisos para crear
|
||||
red, gateway, IP pública y VM, y una suscripción seleccionable.
|
||||
|
||||
```powershell
|
||||
$azure = .\scripts\Deploy-SguAzureInfrastructure.ps1 `
|
||||
-SubscriptionId '00000000-0000-0000-0000-000000000000' `
|
||||
-ResourceGroupName 'rg-sgu-lab' `
|
||||
-Location 'centralus' `
|
||||
-AdministratorUsername 'azureadmin' `
|
||||
-P2sRootCertificatePath $p2s.RootCertificatePath
|
||||
```
|
||||
|
||||
Sin VPN y autorizando un laboratorio completo:
|
||||
|
||||
```powershell
|
||||
$azure = .\scripts\Deploy-SguAzureInfrastructure.ps1 `
|
||||
-SubscriptionId '00000000-0000-0000-0000-000000000000' `
|
||||
-ResourceGroupName 'rg-sgu-lab' `
|
||||
-Location 'centralus' `
|
||||
-AdministratorUsername 'azureadmin' `
|
||||
-DeployVpnGateway $false `
|
||||
-PublicEnrollmentSourceAddressPrefixes '200.13.89.0/24' `
|
||||
-AdministratorSourceAddressPrefix '200.13.89.0/24'
|
||||
```
|
||||
|
||||
La contraseña local de la VM se solicita como `SecureString`, se coloca sólo en
|
||||
un archivo temporal con ACL exclusiva para el usuario actual y se elimina al
|
||||
terminar. No aparece en los argumentos de Azure CLI ni queda guardada en el
|
||||
repositorio.
|
||||
|
||||
Por omisión ningún puerto administrativo de la VM se abre desde Internet. Para
|
||||
habilitar temporalmente RDP durante el bootstrap, indique exclusivamente su IP
|
||||
pública actual:
|
||||
|
||||
```powershell
|
||||
-AdministratorSourceAddressPrefix '203.0.113.10/32'
|
||||
```
|
||||
|
||||
No utilice `0.0.0.0/0`. El despliegue de un VPN Gateway suele tardar bastante
|
||||
más que la VM; omitirlo reduce tiempo y costo. El comando espera hasta que Azure
|
||||
entregue un resultado final.
|
||||
|
||||
## 3. Conectarse al servidor
|
||||
|
||||
Cuando el gateway esté `Succeeded`:
|
||||
|
||||
```powershell
|
||||
$vpn = .\scripts\Get-SguAzureP2sPackage.ps1 `
|
||||
-SubscriptionId '00000000-0000-0000-0000-000000000000' `
|
||||
-ResourceGroupName 'rg-sgu-lab' `
|
||||
-VpnGatewayName $azure.VpnGatewayName
|
||||
|
||||
.\scripts\Install-SguAzureP2sClient.ps1 `
|
||||
-VpnProfilePackagePath $vpn.PackagePath `
|
||||
-ClientCertificatePfxPath $p2s.ClientCertificatePath `
|
||||
-ClientRootCertificatePath $p2s.RootCertificatePath `
|
||||
-Connect
|
||||
```
|
||||
|
||||
Con el túnel conectado, use RDP contra `10.77.0.4` y habilite la redirección de
|
||||
una unidad local para copiar `sgu-server-bootstrap-VERSION.zip` a la VM. La NIC
|
||||
ya apunta a su futura dirección DNS propia, por lo que la resolución pública no
|
||||
está disponible hasta que el bootstrap instale DNS y sus reenviadores. Así no es
|
||||
necesario abrir 3389 en la IP pública. La opción
|
||||
`AdministratorSourceAddressPrefix` queda como ruta de recuperación temporal.
|
||||
|
||||
En modo directo, use RDP contra `$azure.DomainControllerPublicIp` desde un origen
|
||||
incluido en `AdministratorSourceAddressPrefix`. RDP y enrolamiento tienen listas
|
||||
separadas para poder retirar RDP sin interrumpir los clientes.
|
||||
|
||||
## 4. Ejecutar el bootstrap dentro de Windows Server
|
||||
|
||||
Descargue y extraiga `sgu-server-bootstrap-VERSION.zip` dentro de la VM. La IP
|
||||
que recibe el bootstrap es la **privada** de la NIC, nunca la pública:
|
||||
|
||||
```bat
|
||||
Start-SguAzureServerBootstrap.cmd 10.77.0.4 172.30.0.0/24
|
||||
```
|
||||
|
||||
El modo `PlatformManaged` comprueba que Azure ya asignó `10.77.0.4/24`, pero no
|
||||
deshabilita DHCP, no reemplaza la ruta predeterminada y no reinicia el adaptador.
|
||||
El DNS de AD publica únicamente la dirección privada. `168.63.129.16` se usa
|
||||
como reenviador DNS de la plataforma Azure.
|
||||
|
||||
Después del reinicio de promoción, verificar:
|
||||
|
||||
```powershell
|
||||
Get-Content C:\ProgramData\SGU\Bootstrap\Server\bootstrap-complete.json
|
||||
Resolve-DnsName _ldap._tcp.dc._msdcs.lci.lasalle.mx -Type SRV -Server 10.77.0.4
|
||||
```
|
||||
|
||||
El JSON debe indicar `NetworkConfigurationMode = PlatformManaged`, el pool P2S
|
||||
en `TrustedClientNetworks` cuando exista VPN y los CIDR directos en
|
||||
`PublicEnrollmentNetworks` cuando se hayan habilitado.
|
||||
|
||||
## 5. Enrolar cada VM Hyper-V
|
||||
|
||||
En la estación administrativa, emita una credencial distinta por equipo:
|
||||
|
||||
```powershell
|
||||
$w11 = .\scripts\New-SguAzureP2sCertificates.ps1 -ClientName 'Windows11'
|
||||
```
|
||||
|
||||
Copie a la VM Windows 10/11 de Hyper-V:
|
||||
|
||||
- `sgu-windows-client-bootstrap-VERSION.zip` extraído (Windows 10/11 x64);
|
||||
- `$vpn.PackagePath`;
|
||||
- `$w11.ClientCertificatePath`;
|
||||
- `sgu-azure-p2s-root.cer`.
|
||||
|
||||
Desde la carpeta extraída del bootstrap de cliente, instale P2S y enrole:
|
||||
|
||||
```bat
|
||||
Start-SguAzureClientEnrollment.cmd 10.77.0.4 C:\SGU\sgu-azure-vpn-client.zip C:\SGU\sgu-azure-p2s-Windows11.pfx C:\SGU\sgu-azure-p2s-root.cer
|
||||
```
|
||||
|
||||
En una sola ejecución el comando:
|
||||
|
||||
1. importa el certificado de cliente en `LocalMachine\My` sin dejar la
|
||||
contraseña en disco;
|
||||
2. instala un perfil IKEv2 de todos los usuarios llamado `SGU Azure P2S`;
|
||||
3. agrega la ruta `10.77.0.0/16` y una regla NRPT que envía sólo
|
||||
`.lci.lasalle.mx` al DNS `10.77.0.4`;
|
||||
4. conecta P2S con certificado de máquina;
|
||||
5. registra mTLS, instala SGU/RustDesk y une el equipo al dominio;
|
||||
6. reinicia Windows.
|
||||
|
||||
Para el modo directo, cada Windows 10/11 usa el lanzador normal y la IP pública;
|
||||
no necesita perfil ni certificado VPN:
|
||||
|
||||
```bat
|
||||
Start-SguClientEnrollment.cmd 20.9.81.130
|
||||
```
|
||||
|
||||
El bootstrap pide la cuenta de dominio, prueba todas las interfaces con ruta,
|
||||
descubre el bosque por WinRM, instala el certificado público DoH, configura NRPT
|
||||
y los nombres del DC/broker, registra mTLS y une la máquina. No pide una IP del
|
||||
cliente ni una interfaz.
|
||||
|
||||
Si la red local bloquea IKEv2 (UDP 500/4500), se puede generar un perfil
|
||||
OpenVPN sobre TCP 443 para Azure VPN Client. Ese fallback requiere instalar
|
||||
y configurar el cliente correspondiente; el bootstrap instala el perfil nativo
|
||||
IKEv2. Azure ya no admite SSTP al crear este gateway.
|
||||
|
||||
La prueba real con `Windows11-002` y un bosque en Azure está documentada en
|
||||
[la validación del despliegue del 10 de septiembre de 2026](azure-deployment-validation-2026-09-10.md).
|
||||
Incluye un device tunnel para Enterprise y recuperación de Netlogon cuando el
|
||||
túnel tarda en estar disponible al arrancar. Son configuraciones adicionales
|
||||
aplicadas a esa VM; el instalador publicado crea el perfil manual anterior.
|
||||
|
||||
Windows 10/11 Pro admite unión a AD y VPN nativa, pero Microsoft no licencia el
|
||||
**Always On VPN device tunnel** para Pro. Por ello el perfil se instala para
|
||||
todos los usuarios y se puede seleccionar desde el control de red de la
|
||||
pantalla de inicio de sesión; antes del primer logon de una cuenta de dominio,
|
||||
conecte `SGU Azure P2S` allí. Enterprise/Education pueden recibir posteriormente
|
||||
un device tunnel Always On, pero eso no es requisito del enrolamiento SGU.
|
||||
|
||||
Validación dentro del cliente, con la VPN conectada o usando el acceso directo:
|
||||
|
||||
```powershell
|
||||
Get-VpnConnection -Name 'SGU Azure P2S' -AllUserConnection
|
||||
Get-DnsClientNrptRule | Where-Object DisplayName -like 'SGU Azure P2S*'
|
||||
Test-NetConnection 20.9.81.130 -Port 5985
|
||||
Resolve-DnsName _ldap._tcp.dc._msdcs.lci.lasalle.mx -Type SRV
|
||||
nltest.exe /dsgetdc:lci.lasalle.mx
|
||||
```
|
||||
|
||||
## Alcance de red y referencias
|
||||
|
||||
P2S mantiene los puertos de AD dentro del túnel. El modo directo abre el conjunto
|
||||
necesario para la unión sólo desde `publicEnrollmentSourceAddressPrefixes` y
|
||||
replica la misma lista en Windows Firewall mediante `PublicEnrollmentNetworks`.
|
||||
Prefiera `/32` si la salida es estable; use `/24` únicamente cuando deba admitir
|
||||
todo el segmento. Retire el CIDR cuando termine la prueba si ya no se requiere.
|
||||
|
||||
- [Azure VPN Gateway P2S con certificados](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-certificate-gateway)
|
||||
- [Cliente P2S nativo de Windows](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-vpn-client-certificate)
|
||||
- [Instalación de certificados P2S](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-how-to-vpn-client-install-azure-cert)
|
||||
- [Puertos necesarios para unir un dominio](https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/active-directory-domain-join-troubleshooting-guidance)
|
||||
- [Requisitos de edición de Windows](https://learn.microsoft.com/en-us/windows/security/licensing-and-edition-requirements)
|
||||
- [Limitación de Always On device tunnel](https://learn.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-always-on-device-tunnel)
|
||||
@@ -1,6 +1,6 @@
|
||||
# Recuperación desde cero y alta en una sola ejecución
|
||||
|
||||
Los releases entregan tres ZIP independientes. Cada uno contiene sus binarios,
|
||||
Los releases entregan cuatro ZIP independientes. Cada uno contiene sus binarios,
|
||||
scripts, instalador offline requerido y un manifiesto SHA-256 interno. No contienen
|
||||
contraseñas, claves privadas ni certificados reutilizables.
|
||||
|
||||
@@ -17,6 +17,11 @@ Compatible con Windows Server con Windows PowerShell 5.1. El servidor necesita
|
||||
una interfaz privada para el dominio y, para autenticar contra SGU, salida HTTPS
|
||||
por esa u otra interfaz.
|
||||
|
||||
Cuando el servidor vive en Azure, no se configura la IP dentro del sistema
|
||||
operativo. La NIC reserva la IP privada y se usa el modo `PlatformManaged`; el
|
||||
procedimiento completo, con VPN Gateway opcional o enrolamiento público directo, está en
|
||||
[azure-vpn-deployment.md](azure-vpn-deployment.md).
|
||||
|
||||
1. Descargar y extraer `sgu-server-bootstrap-VERSION.zip`.
|
||||
2. Abrir el directorio extraído.
|
||||
3. Ejecutar, indicando la IP fija que tendrá el controlador:
|
||||
@@ -65,6 +70,18 @@ privada. También vuelve a iniciar brevemente esa NIC privada si Windows Server
|
||||
2025 todavía la clasifica como Public al terminar la promoción. La salida HTTPS
|
||||
continúa por la NIC que tenga el gateway predeterminado.
|
||||
|
||||
Para permitir clientes que llegan directamente desde un segmento público, use
|
||||
`-PublicEnrollmentNetworks` al preparar el servidor. El parámetro valida y
|
||||
normaliza cada CIDR y limita a esos orígenes los puertos de AD, DoH, WinRM,
|
||||
broker y RustDesk:
|
||||
|
||||
```powershell
|
||||
.\Initialize-SguDomainController.ps1 `
|
||||
-ServerIPv4Address 10.77.0.4 `
|
||||
-NetworkConfigurationMode PlatformManaged `
|
||||
-PublicEnrollmentNetworks 200.13.89.0/24
|
||||
```
|
||||
|
||||
El broker arranca con una lista de clientes vacía. Eso no abre el servicio: mTLS
|
||||
rechaza todos los certificados hasta que el primer cliente registra el suyo.
|
||||
Los archivos opcionales colocados en `payload\server-content\Packages` al crear
|
||||
@@ -91,38 +108,64 @@ Se admiten Pro, Enterprise y Education. Windows Home no puede unirse a Active
|
||||
Directory local ni actuar como host RDP; el bootstrap lo detecta antes de cambiar
|
||||
el equipo y explica que se debe actualizar la edición.
|
||||
|
||||
1. Descargar y extraer `sgu-client-bootstrap-VERSION.zip`.
|
||||
1. Descargar y extraer `sgu-windows-client-bootstrap-VERSION.zip`, común para
|
||||
Windows 10 y Windows 11 x64 Pro, Enterprise o Education.
|
||||
2. Ejecutar con la IP fija actual del controlador de dominio:
|
||||
|
||||
```bat
|
||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
||||
```
|
||||
|
||||
También puede hacerse doble clic y escribir la IP del controlador. El cliente
|
||||
conserva sus IP/DHCP y prueba WinRM por las interfaces con rutas disponibles,
|
||||
incluyendo VPN y redes enrutadas. No requiere compartir subred con el servidor.
|
||||
Si una NIC sólo tiene APIPA, necesita DHCP o una IP asignada por el administrador;
|
||||
el bootstrap no inventa direcciones. Si falla, la ventana permanece abierta y
|
||||
el error queda en `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
|
||||
|
||||
El manifiesto usa `Auto` y ambos Windows ejecutan el mismo código. Azure P2S está
|
||||
incluido para ambos; otras VPN ya conectadas usan el lanzador habitual.
|
||||
|
||||
También puede proporcionarse directamente la IP pública del DC. Tras autenticar
|
||||
WinRM, el bootstrap configura DoH y resolución dividida, valida el SRV de AD y
|
||||
continúa sin pedir la IP del cliente. El segmento de salida del laboratorio debe
|
||||
estar en la lista `PublicEnrollmentNetworks` del servidor y en el NSG/firewall
|
||||
perimetral; por ejemplo, `200.13.89.0/24` cubre las salidas `.1` a `.254`.
|
||||
|
||||
Después de UAC, se solicita interactivamente la credencial autorizada para unir
|
||||
equipos. La contraseña existe sólo en memoria. El bootstrap:
|
||||
|
||||
1. apunta el DNS del adaptador al IP proporcionado;
|
||||
2. abre una sesión WinRM autenticada con el DC y verifica que pertenece al
|
||||
dominio esperado;
|
||||
1. selecciona una interfaz con conectividad comprobada al servidor;
|
||||
2. abre una sesión WinRM autenticada con el DC, descubre el dominio y configura
|
||||
DNS mediante NRPT sólo para ese dominio; cuando la IP es pública, además
|
||||
configura y valida automáticamente DoH, conservando el DNS de Internet;
|
||||
3. crea en el cliente un certificado mTLS RSA-3072 no exportable y envía sólo su
|
||||
parte pública al broker;
|
||||
4. recupera por esa sesión autenticada el certificado público del broker;
|
||||
5. instala el runtime .NET 10 offline y el Credential Provider;
|
||||
6. valida binarios, registro COM, certificados y salud del broker;
|
||||
7. instala el guardián de reparación al arranque;
|
||||
8. sólo entonces ejecuta `Add-Computer` dentro de `OU=Laboratorio` y reinicia;
|
||||
8. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` si existe, o en el
|
||||
contenedor de equipos predeterminado del dominio, y reinicia;
|
||||
9. al arrancar, activa RDP/NLA, WinRM y las reglas Domain, y vuelve a validar el
|
||||
enrolamiento.
|
||||
10. instala RustDesk desde el MSI oficial comprobado, lo apunta al servidor
|
||||
`rustdesk.lci.lasalle.mx` y registra el ID del dispositivo en el inventario
|
||||
protegido del DC.
|
||||
|
||||
Cuando la IP del DC es pública, el paso 2 crea o reutiliza DoH en el servidor,
|
||||
recupera su certificado público, configura el cliente y valida el registro SRV
|
||||
antes de continuar. El mismo doble clic funciona en LAN, una VPN ya conectada o
|
||||
Internet directo; el operador sólo proporciona IP del DC y credenciales.
|
||||
|
||||
Para elegir adaptador o nombre del equipo explícitamente:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass `
|
||||
-File .\Invoke-SguClientBootstrap.ps1 `
|
||||
-DomainControllerIPv4Address 192.168.50.10 `
|
||||
-ClientIPv4Address 192.168.50.11 `
|
||||
-ClientPrefixLength 24 `
|
||||
-NetworkInterfaceAlias 'Ethernet' `
|
||||
-NewComputerName 'LCI-101'
|
||||
```
|
||||
@@ -186,4 +229,4 @@ la línea de comandos. Para empaquetar recursos institucionales adicionales:
|
||||
-ServerContentPath C:\Preparacion\Packages
|
||||
```
|
||||
|
||||
`SHA256SUMS-VERSION.txt` permite comprobar los tres ZIP antes de usarlos.
|
||||
`SHA256SUMS-VERSION.txt` permite comprobar los cuatro ZIP antes de usarlos.
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# Enrolamiento obligatorio de clientes SGU
|
||||
|
||||
Para una instalación limpia de Windows se prefiere el único punto de entrada
|
||||
empaquetado:
|
||||
Para Windows 10 y Windows 11 se usa un solo paquete:
|
||||
|
||||
- `sgu-windows-client-bootstrap-VERSION.zip` (x64, Pro, Enterprise o Education).
|
||||
|
||||
Extraer el ZIP y hacer doble clic en `Start-SguClientEnrollment.cmd`. Aceptar
|
||||
UAC, introducir la IP del controlador y la cuenta del dominio con su contraseña.
|
||||
También se puede indicar el servidor desde consola:
|
||||
|
||||
```bat
|
||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
||||
@@ -12,6 +17,69 @@ una clave privada y luego ejecuta la transacción proveedor-primero. Las
|
||||
instrucciones completas están en
|
||||
[`bootstrap-recovery.md`](bootstrap-recovery.md).
|
||||
|
||||
El único dato de red necesario es la IPv4 del controlador. El bootstrap prueba
|
||||
WinRM desde las direcciones de las interfaces conectadas, incluyendo túneles
|
||||
VPN, empezando por la ruta elegida por Windows. Si ésta falla, prueba las demás
|
||||
rutas disponibles. Cliente y servidor pueden estar en subredes distintas.
|
||||
Si la red todavía está inicializando, reintenta el descubrimiento durante
|
||||
20 segundos antes de informar el último diagnóstico.
|
||||
Conserva DHCP, direcciones y puertas de enlace. Sólo si Windows elige otra
|
||||
interfaz agrega una ruta persistente `/32` hacia ese servidor por la interfaz
|
||||
comprobada; no modifica la ruta de Internet. Ante cualquier error conserva la
|
||||
ventana y escribe el diagnóstico en
|
||||
`C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
|
||||
|
||||
El dominio DNS, NetBIOS y contenedor de equipos se descubren en la sesión
|
||||
autenticada. Usa `OU=Laboratorio` si existe en la raíz del dominio y, en caso
|
||||
contrario, el contenedor de equipos configurado en AD. Los parámetros
|
||||
`-DomainName`, `-DomainNetbios` y `-ComputerOuDn` permiten validarlos o elegirlos
|
||||
explícitamente. Para otra cuenta, editar el usuario sugerido como
|
||||
`DOMINIO\usuario` o `usuario@dominio`. No se guardan contraseñas.
|
||||
|
||||
Si la IPv4 proporcionada es pública, el mismo flujo configura automáticamente
|
||||
la conectividad directa. Después de autenticar WinRM, el servidor crea o reutiliza
|
||||
un certificado DoH, publica DNS cifrado en TCP 443 y devuelve únicamente su
|
||||
certificado público. El cliente lo confía, registra el servidor DoH, agrega la
|
||||
regla NRPT y fija localmente los nombres del DC, dominio, broker y RustDesk a esa
|
||||
IP. A continuación comprueba el registro SRV de AD y continúa con la unión. El
|
||||
operador sigue introduciendo solamente IP del DC, usuario y contraseña.
|
||||
|
||||
El servidor o firewall perimetral debe autorizar previamente el segmento público
|
||||
del laboratorio. En el bootstrap del servidor se hace con
|
||||
`-PublicEnrollmentNetworks 200.13.89.0/24`; en Azure, con
|
||||
`-PublicEnrollmentSourceAddressPrefixes 200.13.89.0/24`. La lista vacía no abre
|
||||
puertos. Este modo requiere Windows 11 o una versión de Windows 10 que exponga
|
||||
los cmdlets DNS-over-HTTPS; en equipos anteriores funciona si la red ya permite
|
||||
DNS tradicional hacia el DC.
|
||||
|
||||
El DNS se configura mediante una regla NRPT para el dominio descubierto,
|
||||
conservando los servidores DNS de los adaptadores y la resolución de Internet.
|
||||
Las políticas DNS/VPN corporativas deben permitir resolver ese dominio.
|
||||
El servidor necesita el bootstrap SGU (AD/DNS, WinRM, Auth Broker y RustDesk):
|
||||
este paquete no es un enrolador genérico de Microsoft Entra ID ni instala SGU
|
||||
en un directorio ajeno automáticamente. Se comprueban TCP 53, 88, 135, 389, 445,
|
||||
5985 y 8443; la unión también requiere DNS/Kerberos por UDP y RPC dinámico
|
||||
según la configuración de AD. Estas pruebas no sustituyen la unión real.
|
||||
|
||||
Una VPN de cualquier proveedor ya conectada utiliza el mismo lanzador. Para
|
||||
instalar la VPN Azure IKEv2 se incluyen `Start-SguAzureClientEnrollment.cmd` e
|
||||
`Install-SguAzureP2sClient.ps1` en el mismo ZIP para ambos Windows; la primera
|
||||
instalación requiere perfil y certificados. La conectividad antes del inicio
|
||||
de sesión depende de la VPN y sus políticas, no se puede deducir de la IP del DC.
|
||||
|
||||
Una interfaz con sólo APIPA (`169.254.x.x`), sin DHCP o sin ruta necesita que
|
||||
el administrador configure la red o conecte la VPN. El bootstrap no inventa una
|
||||
IP libre ni una puerta de enlace. Para una LAN estática se mantienen los
|
||||
parámetros avanzados `-ClientIPv4Address`, `-ClientPrefixLength` y
|
||||
`-NetworkInterfaceAlias`; la IP y la interfaz deben indicarse juntas.
|
||||
|
||||
El manifiesto usa `CompatibilityProfile: Auto`. No hay restricciones de VPN
|
||||
por Windows 10/11; los nombres antiguos de perfil se aceptan como parámetros
|
||||
obsoletos. Se valida x64, edición con unión a AD y Windows 10 desde 1607 o
|
||||
Windows 11 (el runtime incluido es .NET 10; usar una edición/build compatible
|
||||
con su ciclo de soporte). Credential Provider, mTLS, cuenta `alumno`, RustDesk,
|
||||
monitorización y autorreparación conservan la misma implementación.
|
||||
|
||||
El flujo administrado instala y valida el Credential Provider **antes** de
|
||||
ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta
|
||||
el runtime, un certificado, el registro COM, la directiva predeterminada o la
|
||||
@@ -72,11 +140,14 @@ Orden de la transacción:
|
||||
1. instala .NET y los binarios versionados;
|
||||
2. registra COM, configura SGU como proveedor predeterminado y oculta el
|
||||
último usuario que cerró sesión;
|
||||
3. instala el guard de autorreparación;
|
||||
4. exige health mTLS del broker y ejecuta las comprobaciones locales;
|
||||
5. configura DNS del dominio;
|
||||
6. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
|
||||
7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
|
||||
3. crea o actualiza la cuenta local estándar `alumno` con la contraseña
|
||||
inicial `ingenieria` sólo al crearla; si ya existe conserva su contraseña,
|
||||
la habilita y garantiza que no pertenezca a Administradores;
|
||||
4. instala el guard de autorreparación;
|
||||
5. exige health mTLS del broker y ejecuta las comprobaciones locales;
|
||||
6. configura DNS del dominio;
|
||||
7. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
|
||||
8. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
|
||||
|
||||
La directiva de Windows **Assign a default credential provider** selecciona SGU
|
||||
por defecto. El instalador también habilita **Interactive logon: Don't display
|
||||
@@ -138,8 +209,9 @@ Start-ScheduledTask -TaskName SGU-CredentialProvider-EnrollmentGuard
|
||||
|
||||
Un resultado válido exige simultáneamente binario y registro COM, configuración,
|
||||
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
|
||||
enumeración local deshabilitada y proveedor de contraseña de Microsoft
|
||||
preservado. El script de reparación se encuentra en
|
||||
enumeración local deshabilitada, cuenta local estándar `alumno` habilitada y
|
||||
fuera del grupo Administradores, y proveedor de contraseña de Microsoft
|
||||
preservado. El guard recrea o corrige esa cuenta de forma idempotente. El script de reparación se encuentra en
|
||||
`C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
|
||||
administradores.
|
||||
|
||||
|
||||
+17
-5
@@ -1,5 +1,14 @@
|
||||
# Security model
|
||||
|
||||
## Public Azure deployment
|
||||
|
||||
The Azure topology supports certificate-authenticated P2S or direct enrollment.
|
||||
P2S keeps AD services inside the VNet. Direct enrollment exposes the required
|
||||
AD, DNS/DoH, WinRM, broker and RustDesk ports only to explicit public IPv4 CIDRs;
|
||||
an empty allowlist exposes none of them. Azure NSG and Windows Firewall enforce
|
||||
the same source list. RDP uses a separate allowlist. See
|
||||
[azure-vpn-deployment.md](azure-vpn-deployment.md).
|
||||
|
||||
## Password handling
|
||||
|
||||
- The Credential Provider receives the password in Lithnet's secure password
|
||||
@@ -33,15 +42,18 @@
|
||||
- Administrative enrichment first verifies the employee number and reads
|
||||
employee type/status, email, job title, and department from the incident
|
||||
overview. Only after that match, it reads given names and paternal/maternal
|
||||
surnames from the personal page plus street, exterior/interior number,
|
||||
neighborhood, locality, state, and postal code from the location page.
|
||||
- Administrative birth date, RFC, CURP, sex, blood type, marital status,
|
||||
surnames and the normalized `Male`/`Female` value from the personal page plus
|
||||
street, exterior/interior number, neighborhood, locality, state, and postal
|
||||
code from the location page. AD stores only the controlled `SGU-Gender` line,
|
||||
not the original HTML field.
|
||||
- Administrative birth date, RFC, CURP, blood type, marital status,
|
||||
nationality, telephone, email lists, housing type, and emergency-contact
|
||||
fields are ignored.
|
||||
- Student enrichment reads only the matching student number, given names,
|
||||
paternal/maternal surnames, email, career, street, neighborhood,
|
||||
city/municipality, state, and postal code from known element IDs.
|
||||
- Student CURP, birth date, sex, blood type, marital status, telephone, mobile,
|
||||
city/municipality, state, postal code, and normalized sex from known element
|
||||
IDs.
|
||||
- Student CURP, birth date, blood type, marital status, telephone, mobile,
|
||||
guardian, medical, financial, and academic-history values are ignored.
|
||||
- Professor enrichment keeps the menu display name as its base. From the payroll
|
||||
consultation header it reads only a matching employee number, name, email,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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: Windows 11
|
||||
|
||||
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).
|
||||
|
||||
## Prueba real en Hyper-V: Windows 10
|
||||
|
||||
Fecha: 2026-09-10. Cliente: VM `Windows10-001`, Windows 10 Enterprise LTSC
|
||||
x64, build 19044, nombre de equipo `DESKTOP-HDKRD5V`, inicialmente en WORKGROUP.
|
||||
Se usó el mismo ZIP 0.5.1 publicado en Gitea, sin modificar sus scripts ni
|
||||
binarios. SHA-256 del ZIP:
|
||||
|
||||
```text
|
||||
4DBE45697D74110F65C6D7825A593121B19C53B5F286F0DBC00068F3AFE45FB0
|
||||
```
|
||||
|
||||
Se guardó el checkpoint `Before SGU Windows10 validation 2026-09-10`.
|
||||
La VM ya tenía dos tarjetas: `Ethernet` en `Default Switch`, con DHCP y DNS
|
||||
`172.18.176.1`, y `Ethernet 2` en `Laboratorio AD`, con dirección APIPA.
|
||||
|
||||
Primero se ejecutó el bootstrap sin preparar la IP privada. Reintentó la
|
||||
conexión, diagnosticó que `Ethernet 2` no tenía una IPv4 utilizable y que la ruta
|
||||
de Internet no alcanzaba WinRM del servidor. No solicitó una IP del cliente,
|
||||
no modificó sus direcciones y mantuvo el equipo en WORKGROUP.
|
||||
|
||||
Para la prueba positiva se configuró administrativamente
|
||||
`192.168.50.203/24` en `Ethernet 2`, sin puerta de enlace, y se esperó a que
|
||||
Windows confirmara la dirección como `Preferred`. Esta preparación corresponde
|
||||
a la red de laboratorio sin DHCP; no la realizó el bootstrap. Se ejecutó de
|
||||
nuevo el ZIP con sólo la IP del DC, una credencial en memoria y `-SkipRestart`,
|
||||
sin parámetros de interfaz, IP del cliente, dominio, NetBIOS ni OU.
|
||||
|
||||
Resultados:
|
||||
|
||||
- Selección automática de `Ethernet 2`, descubrimiento de `lci.lasalle.mx`,
|
||||
`LCI` y `OU=Laboratorio`, y unión al dominio completada.
|
||||
- Proveedor y certificados instalados; proveedor validado antes de la unión.
|
||||
- Tras reiniciar, `Test-ComputerSecureChannel=True` y tarea
|
||||
`SGU-CredentialProvider-EnrollmentGuard` finalizada con `LastTaskResult=0`.
|
||||
- Validación con dominio, salud del broker, acceso remoto y RustDesk exigidos:
|
||||
`IsValid=True`, `Issues={}`, `BrokerHealth=ok`, `RemoteAccessReady=True` y
|
||||
`RustDeskReady=True`.
|
||||
- Runtime .NET y binarios presentes; proveedor de contraseña de Windows
|
||||
conservado. Cuenta `alumno` presente, sin permisos de administrador y con
|
||||
expiración de contraseña deshabilitada.
|
||||
- `Ethernet 2` quedó como `DomainAuthenticated`; `Ethernet` permaneció como
|
||||
`Public`, conservando su DHCP y DNS original. HTTPS hacia
|
||||
`https://www.microsoft.com` respondió HTTP 200.
|
||||
|
||||
No fue necesario corregir el bootstrap para esta prueba. La VM quedó encendida
|
||||
y enrolada, con el ZIP extraído en sus Descargas y el checkpoint previo disponible.
|
||||
|
||||
### Comprobación posterior del escritorio
|
||||
|
||||
La validación anterior comprobaba el enrolamiento, pero no el fondo visible
|
||||
en una sesión de usuario. Al revisar la sesión de `Windows10-001`, el fondo
|
||||
seguía siendo el predeterminado de Windows. El registro del generador mostró
|
||||
un fallo de validación al asignar el género vacío devuelto por AD al parámetro
|
||||
`Gender`, cuyo `ValidateSet` sólo permite `Male` o `Female`.
|
||||
|
||||
Se corrigió `Set-SguWelcomeWallpaper.ps1` para mantener el saludo neutral cuando
|
||||
el dato no está disponible. Se actualizaron el generador instalado y su copia
|
||||
en el paquete de autorreparación, y se ejecutó en el contexto de la sesión
|
||||
interactiva existente, sin cerrar sesión ni solicitar otra contraseña.
|
||||
El registro terminó con `OK`, la configuración del usuario apuntó al JPEG
|
||||
generado y se verificó visualmente el fondo institucional con nombre y saludo.
|
||||
La tarea temporal utilizada para actualizar la sesión se retiró al finalizar;
|
||||
la ejecución habitual al iniciar sesión sigue a cargo de la GPO.
|
||||
|
||||
Se agregaron cuatro pruebas de renderizado JPEG: género ausente, vacío o
|
||||
desconocido, valores reconocidos y prioridad de un valor explícito. Todas
|
||||
pasaron en Windows PowerShell 5.1. Esta corrección posterior está en el código
|
||||
y en la VM; el ZIP publicado como 0.5.1 no se modificó.
|
||||
|
||||
## Prueba real de enrolamiento público directo: Windows 10
|
||||
|
||||
Fecha: 2026-09-11. Se repitió el enrolamiento de `Windows10-001` contra el DC
|
||||
Azure `20.9.81.130`, sin perfil ni interfaz VPN. El cliente conservó sus dos NIC
|
||||
y seleccionó por sí solo `Ethernet` con DHCP (`172.18.183.201`), porque era la
|
||||
ruta que alcanzaba WinRM. El segmento público de salida autorizado fue
|
||||
`200.13.89.0/24`.
|
||||
|
||||
La VM aún nombraba `lci.lasalle.mx`, pero su canal seguro pertenecía al bosque
|
||||
anterior y estaba roto. El flujo creó o reutilizó la cuenta de equipo en la OU
|
||||
descubierta, restableció la contraseña de máquina contra
|
||||
`SGU-DC01.lci.lasalle.mx`, reinició Netlogon y conservó el equipo unido. También
|
||||
toleró SID huérfanos del bosque anterior al comprobar los grupos locales.
|
||||
|
||||
Windows 10 Enterprise LTSC build 19044 no expone los cmdlets DoH. El bootstrap
|
||||
lo detectó y usó DNS tradicional hacia la misma IP pública, limitado por NSG y
|
||||
Windows Firewall al CIDR permitido. Después de un reinicio real se comprobó:
|
||||
|
||||
- `Test-ComputerSecureChannel=True` y resolución SRV del DC;
|
||||
- `IsValid=True`, sin incidencias;
|
||||
- `BrokerHealth=ok`, `RemoteAccessReady=True` y `RustDeskReady=True`;
|
||||
- ningún perfil VPN instalado;
|
||||
- paquete final `0.5.9`, SHA-256 del ZIP de Windows:
|
||||
`E7AF77252E444FB7EBACF3C90005D322EE19F76DD9387AC5782C57EA9EE617B7`.
|
||||
|
||||
## Prueba real con Azure VPN
|
||||
|
||||
El 2026-09-10 se desplegó `sgu-lab-dc` en Azure Central US, se creó el bosque
|
||||
`lci.lasalle.mx` y se enroló `Windows11-002` mediante un gateway real
|
||||
`VpnGw1AZ`. El controlador tiene IP `10.77.0.4` y el cliente obtuvo
|
||||
`172.30.0.2` por IKEv2 con certificado de máquina. El bootstrap descubrió
|
||||
automáticamente la interfaz VPN, el dominio y la OU a partir de la IP del DC
|
||||
y la credencial administrativa.
|
||||
|
||||
Se comprobaron el objeto de equipo en `OU=Laboratorio`, el canal seguro y la
|
||||
validación SGU completa, incluyendo salud mTLS, acceso remoto y RustDesk.
|
||||
Para Enterprise se configuró un device tunnel y una recuperación de Netlogon
|
||||
para la conectividad tardía al arrancar. El bosque Azure es independiente del
|
||||
bosque local con el mismo nombre.
|
||||
|
||||
La infraestructura, versiones de paquetes, ajustes adicionales y evidencias
|
||||
están en [el informe de Azure](azure-deployment-validation-2026-09-10.md).
|
||||
Se usaron paquetes locales de validación `0.5.2-azure.*`; el release publicado
|
||||
0.5.1 no se reemplazó durante este despliegue.
|
||||
|
||||
## Alcance pendiente
|
||||
|
||||
OpenVPN y otras VPN requieren validación en sus redes reales. Las pruebas
|
||||
Windows realizadas cubren Enterprise LTSC x64, builds 19044 y 26100; no todas
|
||||
las ediciones ni builds. Azure se probó con Windows 11; la prueba de Windows 10
|
||||
descrita arriba corresponde a la LAN.
|
||||
|
||||
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.
|
||||
@@ -16,14 +16,20 @@ El generador consulta Active Directory con la identidad ya autenticada y sin
|
||||
guardar credenciales. Obtiene:
|
||||
|
||||
- `displayName` del usuario; si falta, utiliza `sAMAccountName`.
|
||||
- La línea administrada `SGU-Gender: Male|Female` del atributo `info`; el Auth
|
||||
Broker la obtiene del SGU y conserva cualquier otra nota que ya exista.
|
||||
- `location` del objeto de equipo.
|
||||
- La OU padre inmediata a partir de `distinguishedName`.
|
||||
|
||||
El texto secundario sigue estas reglas:
|
||||
El saludo usa `Bienvenido/ubicado` para `Male` y `Bienvenida/ubicada` para
|
||||
`Female`. Cuando el enriquecimiento no produjo este dato, utiliza la redacción
|
||||
neutral `Te damos la bienvenida` y `Ubicación:`. El texto secundario sigue estas
|
||||
reglas:
|
||||
|
||||
1. Con `location` y OU: `Estás ubicado en la Sala de Inmersión del Centro de Experiencia Digital.`
|
||||
2. Con sólo uno de los datos: muestra únicamente el dato disponible.
|
||||
3. Sin ambos: `Bienvenido al Laboratorio de Cómputo de Ingeniería.`
|
||||
3. Sin ambos: el texto adaptado `Bienvenido/Bienvenida al Laboratorio...`; sin
|
||||
sexo disponible, la forma neutral `Acceso al Laboratorio de Cómputo de Ingeniería.`
|
||||
|
||||
La ausencia de AD, de un atributo o de una tipografía nunca bloquea la sesión.
|
||||
Los errores de generación se registran en
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
targetScope = 'resourceGroup'
|
||||
|
||||
@description('Short prefix used for every Azure resource.')
|
||||
@minLength(3)
|
||||
@maxLength(18)
|
||||
param deploymentPrefix string = 'sgu-lab'
|
||||
|
||||
@description('Azure region for the virtual network, gateway, and VM.')
|
||||
param location string = resourceGroup().location
|
||||
|
||||
@description('Windows Server VM administrator name. This must not be Administrator.')
|
||||
@minLength(1)
|
||||
@maxLength(20)
|
||||
param administratorUsername string
|
||||
|
||||
@secure()
|
||||
@description('Windows Server VM administrator password.')
|
||||
param administratorPassword string
|
||||
|
||||
@description('Windows Server computer name; Active Directory limits this to 15 characters.')
|
||||
@minLength(1)
|
||||
@maxLength(15)
|
||||
param computerName string = 'SGU-DC01'
|
||||
|
||||
@description('VM size for the Windows Server 2025 domain controller.')
|
||||
param vmSize string = 'Standard_D2s_v5'
|
||||
|
||||
@description('Address space assigned to the Azure virtual network.')
|
||||
param virtualNetworkAddressPrefix string = '10.77.0.0/16'
|
||||
|
||||
@description('Subnet that contains the domain controller.')
|
||||
param domainControllerSubnetPrefix string = '10.77.0.0/24'
|
||||
|
||||
@description('Reserved Azure VPN Gateway subnet. Use /27 or larger.')
|
||||
param gatewaySubnetPrefix string = '10.77.255.0/27'
|
||||
|
||||
@description('Deploy the optional Azure Point-to-Site VPN Gateway. Direct public enrollment does not require it.')
|
||||
param deployVpnGateway bool = true
|
||||
|
||||
@description('Static private IP reserved on the Azure NIC for AD DS and DNS.')
|
||||
param domainControllerPrivateIp string = '10.77.0.4'
|
||||
|
||||
@description('Point-to-site client pool. It must not overlap the VNet or local Hyper-V networks.')
|
||||
param vpnClientAddressPoolPrefix string = '172.30.0.0/24'
|
||||
|
||||
@description('Name presented for the trusted P2S root certificate.')
|
||||
param p2sRootCertificateName string = 'SGU-P2S-Root'
|
||||
|
||||
@description('Base64 DER bytes of the trusted P2S root certificate, without PEM markers.')
|
||||
param p2sRootCertificateData string = ''
|
||||
|
||||
@description('Public IPv4 CIDRs allowed to enroll clients directly without VPN. Leave empty to expose no AD enrollment ports.')
|
||||
param publicEnrollmentSourceAddressPrefixes array = []
|
||||
|
||||
@description('Optional public CIDR allowed to RDP to the VM public IP, for example 203.0.113.10/32. Leave empty to expose no management port.')
|
||||
param administratorSourceAddressPrefix string = ''
|
||||
|
||||
var virtualNetworkName = '${deploymentPrefix}-vnet'
|
||||
var domainControllerSubnetName = 'DomainControllers'
|
||||
var gatewaySubnetName = 'GatewaySubnet'
|
||||
var networkSecurityGroupName = '${deploymentPrefix}-dc-nsg'
|
||||
var domainControllerPublicIpName = '${deploymentPrefix}-dc-pip'
|
||||
var gatewayPublicIpName = '${deploymentPrefix}-vpngw-pip'
|
||||
var networkInterfaceName = '${deploymentPrefix}-dc-nic'
|
||||
var virtualMachineName = '${deploymentPrefix}-dc'
|
||||
var virtualNetworkGatewayName = '${deploymentPrefix}-vpngw'
|
||||
|
||||
resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = {
|
||||
name: networkSecurityGroupName
|
||||
location: location
|
||||
properties: {
|
||||
securityRules: concat(deployVpnGateway ? [
|
||||
{
|
||||
name: 'Allow-SGU-P2S-clients'
|
||||
properties: {
|
||||
priority: 100
|
||||
access: 'Allow'
|
||||
direction: 'Inbound'
|
||||
protocol: '*'
|
||||
sourcePortRange: '*'
|
||||
destinationPortRange: '*'
|
||||
sourceAddressPrefix: vpnClientAddressPoolPrefix
|
||||
destinationAddressPrefix: domainControllerPrivateIp
|
||||
description: 'AD, DNS, broker, monitoring, and RustDesk are reachable only through the authenticated P2S address pool.'
|
||||
}
|
||||
}
|
||||
] : [], empty(publicEnrollmentSourceAddressPrefixes) ? [] : [
|
||||
{
|
||||
name: 'Allow-Direct-AD-TCP'
|
||||
properties: {
|
||||
priority: 110
|
||||
access: 'Allow'
|
||||
direction: 'Inbound'
|
||||
protocol: 'Tcp'
|
||||
sourcePortRange: '*'
|
||||
destinationPortRanges: [
|
||||
'53'
|
||||
'88'
|
||||
'135'
|
||||
'389'
|
||||
'443'
|
||||
'445'
|
||||
'464'
|
||||
'636'
|
||||
'3268'
|
||||
'3269'
|
||||
'21115-21117'
|
||||
'49152-65535'
|
||||
]
|
||||
sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes
|
||||
destinationAddressPrefix: domainControllerPrivateIp
|
||||
description: 'AD, DoH, and RustDesk TCP access for explicitly authorized public enrollment networks.'
|
||||
}
|
||||
}
|
||||
{
|
||||
name: 'Allow-Direct-AD-UDP'
|
||||
properties: {
|
||||
priority: 120
|
||||
access: 'Allow'
|
||||
direction: 'Inbound'
|
||||
protocol: 'Udp'
|
||||
sourcePortRange: '*'
|
||||
destinationPortRanges: [
|
||||
'53'
|
||||
'88'
|
||||
'123'
|
||||
'389'
|
||||
'464'
|
||||
'21116'
|
||||
]
|
||||
sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes
|
||||
destinationAddressPrefix: domainControllerPrivateIp
|
||||
description: 'AD, time, and RustDesk UDP access for explicitly authorized public enrollment networks.'
|
||||
}
|
||||
}
|
||||
{
|
||||
name: 'Allow-Direct-SGU-Enrollment-TCP'
|
||||
properties: {
|
||||
priority: 130
|
||||
access: 'Allow'
|
||||
direction: 'Inbound'
|
||||
protocol: 'Tcp'
|
||||
sourcePortRange: '*'
|
||||
destinationPortRanges: [
|
||||
'5985'
|
||||
'8443'
|
||||
]
|
||||
sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes
|
||||
destinationAddressPrefix: domainControllerPrivateIp
|
||||
description: 'WinRM discovery and SGU broker access for direct enrollment.'
|
||||
}
|
||||
}
|
||||
], empty(administratorSourceAddressPrefix) ? [] : [
|
||||
{
|
||||
name: 'Allow-RDP-from-administrator'
|
||||
properties: {
|
||||
priority: 140
|
||||
access: 'Allow'
|
||||
direction: 'Inbound'
|
||||
protocol: 'Tcp'
|
||||
sourcePortRange: '*'
|
||||
destinationPortRange: '3389'
|
||||
sourceAddressPrefix: administratorSourceAddressPrefix
|
||||
destinationAddressPrefix: domainControllerPrivateIp
|
||||
description: 'Optional bootstrap-only RDP access from one explicitly supplied public CIDR.'
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = {
|
||||
name: virtualNetworkName
|
||||
location: location
|
||||
properties: {
|
||||
addressSpace: {
|
||||
addressPrefixes: [
|
||||
virtualNetworkAddressPrefix
|
||||
]
|
||||
}
|
||||
subnets: concat([
|
||||
{
|
||||
name: domainControllerSubnetName
|
||||
properties: {
|
||||
addressPrefix: domainControllerSubnetPrefix
|
||||
networkSecurityGroup: {
|
||||
id: networkSecurityGroup.id
|
||||
}
|
||||
}
|
||||
}
|
||||
], deployVpnGateway ? [
|
||||
{
|
||||
name: gatewaySubnetName
|
||||
properties: {
|
||||
addressPrefix: gatewaySubnetPrefix
|
||||
}
|
||||
}
|
||||
] : [])
|
||||
}
|
||||
}
|
||||
|
||||
resource domainControllerPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = {
|
||||
name: domainControllerPublicIpName
|
||||
location: location
|
||||
sku: {
|
||||
name: 'Standard'
|
||||
}
|
||||
properties: {
|
||||
publicIPAllocationMethod: 'Static'
|
||||
publicIPAddressVersion: 'IPv4'
|
||||
idleTimeoutInMinutes: 30
|
||||
}
|
||||
}
|
||||
|
||||
resource gatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = if (deployVpnGateway) {
|
||||
name: gatewayPublicIpName
|
||||
location: location
|
||||
zones: [
|
||||
'1'
|
||||
'2'
|
||||
'3'
|
||||
]
|
||||
sku: {
|
||||
name: 'Standard'
|
||||
}
|
||||
properties: {
|
||||
publicIPAllocationMethod: 'Static'
|
||||
publicIPAddressVersion: 'IPv4'
|
||||
}
|
||||
}
|
||||
|
||||
resource networkInterface 'Microsoft.Network/networkInterfaces@2024-05-01' = {
|
||||
name: networkInterfaceName
|
||||
location: location
|
||||
properties: {
|
||||
enableAcceleratedNetworking: true
|
||||
dnsSettings: {
|
||||
dnsServers: [
|
||||
domainControllerPrivateIp
|
||||
]
|
||||
}
|
||||
ipConfigurations: [
|
||||
{
|
||||
name: 'ipconfig1'
|
||||
properties: {
|
||||
privateIPAllocationMethod: 'Static'
|
||||
privateIPAddressVersion: 'IPv4'
|
||||
privateIPAddress: domainControllerPrivateIp
|
||||
subnet: {
|
||||
id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, domainControllerSubnetName)
|
||||
}
|
||||
publicIPAddress: {
|
||||
id: domainControllerPublicIp.id
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
dependsOn: [
|
||||
virtualNetwork
|
||||
]
|
||||
}
|
||||
|
||||
resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-07-01' = {
|
||||
name: virtualMachineName
|
||||
location: location
|
||||
identity: {
|
||||
type: 'SystemAssigned'
|
||||
}
|
||||
properties: {
|
||||
hardwareProfile: {
|
||||
vmSize: vmSize
|
||||
}
|
||||
securityProfile: {
|
||||
securityType: 'TrustedLaunch'
|
||||
uefiSettings: {
|
||||
secureBootEnabled: true
|
||||
vTpmEnabled: true
|
||||
}
|
||||
}
|
||||
osProfile: {
|
||||
computerName: computerName
|
||||
adminUsername: administratorUsername
|
||||
adminPassword: administratorPassword
|
||||
windowsConfiguration: {
|
||||
provisionVMAgent: true
|
||||
enableAutomaticUpdates: true
|
||||
patchSettings: {
|
||||
patchMode: 'AutomaticByPlatform'
|
||||
assessmentMode: 'AutomaticByPlatform'
|
||||
enableHotpatching: false
|
||||
}
|
||||
}
|
||||
}
|
||||
storageProfile: {
|
||||
imageReference: {
|
||||
publisher: 'MicrosoftWindowsServer'
|
||||
offer: 'WindowsServer'
|
||||
sku: '2025-datacenter-azure-edition'
|
||||
version: 'latest'
|
||||
}
|
||||
osDisk: {
|
||||
createOption: 'FromImage'
|
||||
// AD DS requires durable writes; the bootstrap stores NTDS on this disk.
|
||||
caching: 'None'
|
||||
managedDisk: {
|
||||
storageAccountType: 'Premium_LRS'
|
||||
}
|
||||
deleteOption: 'Delete'
|
||||
}
|
||||
}
|
||||
networkProfile: {
|
||||
networkInterfaces: [
|
||||
{
|
||||
id: networkInterface.id
|
||||
properties: {
|
||||
primary: true
|
||||
deleteOption: 'Delete'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
diagnosticsProfile: {
|
||||
bootDiagnostics: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource virtualNetworkGateway 'Microsoft.Network/virtualNetworkGateways@2024-05-01' = if (deployVpnGateway) {
|
||||
name: virtualNetworkGatewayName
|
||||
location: location
|
||||
properties: {
|
||||
gatewayType: 'Vpn'
|
||||
vpnType: 'RouteBased'
|
||||
activeActive: false
|
||||
enableBgp: false
|
||||
ipConfigurations: [
|
||||
{
|
||||
name: 'gateway-ipconfig'
|
||||
properties: {
|
||||
privateIPAllocationMethod: 'Dynamic'
|
||||
subnet: {
|
||||
id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, gatewaySubnetName)
|
||||
}
|
||||
publicIPAddress: {
|
||||
id: gatewayPublicIp.id
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
sku: {
|
||||
name: 'VpnGw1AZ'
|
||||
tier: 'VpnGw1AZ'
|
||||
}
|
||||
vpnClientConfiguration: {
|
||||
vpnClientAddressPool: {
|
||||
addressPrefixes: [
|
||||
vpnClientAddressPoolPrefix
|
||||
]
|
||||
}
|
||||
vpnClientProtocols: [
|
||||
'IkeV2'
|
||||
'OpenVPN'
|
||||
]
|
||||
vpnAuthenticationTypes: [
|
||||
'Certificate'
|
||||
]
|
||||
vpnClientRootCertificates: [
|
||||
{
|
||||
name: p2sRootCertificateName
|
||||
properties: {
|
||||
publicCertData: p2sRootCertificateData
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
dependsOn: [
|
||||
virtualNetwork
|
||||
]
|
||||
}
|
||||
|
||||
output domainControllerName string = virtualMachine.name
|
||||
output domainControllerPrivateIp string = domainControllerPrivateIp
|
||||
output domainControllerPublicIp string = domainControllerPublicIp.properties.ipAddress
|
||||
output virtualNetworkName string = virtualNetwork.name
|
||||
output virtualNetworkAddressPrefix string = virtualNetworkAddressPrefix
|
||||
output vpnGatewayName string = deployVpnGateway ? virtualNetworkGateway.name : ''
|
||||
output vpnClientAddressPoolPrefix string = vpnClientAddressPoolPrefix
|
||||
output serverBootstrapArguments array = concat([
|
||||
'-ServerIPv4Address'
|
||||
domainControllerPrivateIp
|
||||
'-PrefixLength'
|
||||
last(split(domainControllerSubnetPrefix, '/'))
|
||||
'-NetworkConfigurationMode'
|
||||
'PlatformManaged'
|
||||
'-DnsForwarders'
|
||||
'168.63.129.16'
|
||||
], deployVpnGateway ? [
|
||||
'-TrustedClientNetworks'
|
||||
vpnClientAddressPoolPrefix
|
||||
] : [], empty(publicEnrollmentSourceAddressPrefixes) ? [] : concat([
|
||||
'-PublicEnrollmentNetworks'
|
||||
], publicEnrollmentSourceAddressPrefixes))
|
||||
@@ -76,16 +76,23 @@ if (-not $serverCertificate.Verify()) {
|
||||
}
|
||||
|
||||
Import-Module ActiveDirectory -ErrorAction Stop
|
||||
|
||||
function ConvertTo-LdapFilterValue {
|
||||
param([Parameter(Mandatory)][string]$Value)
|
||||
|
||||
return $Value.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29').Replace(([string][char]0), '\00')
|
||||
}
|
||||
|
||||
$usersOuName = 'Usuarios-SGU'
|
||||
$usersOuDn = "OU=$usersOuName,$BaseDn"
|
||||
if ([string]::IsNullOrWhiteSpace($ProfessorGroupDn)) {
|
||||
$ProfessorGroupDn = "CN=SGU-Docentes,$usersOuDn"
|
||||
$ProfessorGroupDn = "CN=SGU-Docentes,OU=Docentes,$usersOuDn"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($StudentGroupDn)) {
|
||||
$StudentGroupDn = "CN=SGU-Alumnos,$usersOuDn"
|
||||
$StudentGroupDn = "CN=SGU-Alumnos,OU=Alumnos,$usersOuDn"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($AdministrativeGroupDn)) {
|
||||
$AdministrativeGroupDn = "CN=SGU-Administrativos,$usersOuDn"
|
||||
$AdministrativeGroupDn = "CN=SGU-Administrativos,OU=Administrativos,$usersOuDn"
|
||||
}
|
||||
|
||||
if ($CreateMissingOus) {
|
||||
@@ -152,13 +159,30 @@ foreach ($definition in $roleGroupDefinitions) {
|
||||
throw "$($definition.Role)GroupDn must start with a simple CN component."
|
||||
}
|
||||
$groupName = $groupDnMatch.Groups['Name'].Value
|
||||
$groupPath = $groupDnMatch.Groups['Path'].Value
|
||||
if ($groupName.Length -gt 20) {
|
||||
throw "$($definition.Role) group name exceeds the 20-character sAMAccountName limit."
|
||||
}
|
||||
|
||||
$matchingGroups = @(Get-ADGroup `
|
||||
-LDAPFilter "(sAMAccountName=$(ConvertTo-LdapFilterValue -Value $groupName))" `
|
||||
-SearchBase $BaseDn -SearchScope Subtree -Server $LdapHost -ErrorAction Stop)
|
||||
if ($matchingGroups.Count -gt 1) {
|
||||
throw "More than one Active Directory group uses sAMAccountName $groupName; the bootstrap cannot select one safely."
|
||||
}
|
||||
if ($matchingGroups.Count -eq 1) {
|
||||
if ($matchingGroups[0].GroupCategory -ne 'Security') {
|
||||
throw "$($definition.Role)GroupDn must identify a security group."
|
||||
}
|
||||
Move-ADObject -Identity $matchingGroups[0].DistinguishedName `
|
||||
-TargetPath $groupPath -Server $LdapHost -Confirm:$false -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
New-ADGroup -Name $groupName -SamAccountName $groupName `
|
||||
-GroupCategory Security -GroupScope Global `
|
||||
-Path $groupDnMatch.Groups['Path'].Value `
|
||||
-Path $groupPath `
|
||||
-Description $definition.Description -Server $LdapHost | Out-Null
|
||||
}
|
||||
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
|
||||
}
|
||||
if (-not $roleGroup) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$SubscriptionId,
|
||||
[string]$ResourceGroupName = 'rg-sgu-lab',
|
||||
[string]$Location = 'centralus',
|
||||
[string]$DeploymentPrefix = 'sgu-lab',
|
||||
[Parameter(Mandatory)][string]$AdministratorUsername,
|
||||
[securestring]$AdministratorPassword,
|
||||
[string]$P2sRootCertificatePath,
|
||||
[bool]$DeployVpnGateway = $true,
|
||||
[string]$ComputerName = 'SGU-DC01',
|
||||
[string]$VmSize = 'Standard_D2s_v5',
|
||||
[string]$VirtualNetworkAddressPrefix = '10.77.0.0/16',
|
||||
[string]$DomainControllerSubnetPrefix = '10.77.0.0/24',
|
||||
[ipaddress]$DomainControllerPrivateIp = '10.77.0.4',
|
||||
[string]$GatewaySubnetPrefix = '10.77.255.0/27',
|
||||
[string]$VpnClientAddressPoolPrefix = '172.30.0.0/24',
|
||||
[string[]]$PublicEnrollmentSourceAddressPrefixes = @(),
|
||||
[string]$AdministratorSourceAddressPrefix = '',
|
||||
[string]$TemplateFile = (Join-Path $PSScriptRoot '..\infra\azure\main.bicep')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
|
||||
throw 'Azure CLI is required. Install it from https://aka.ms/installazurecliwindows and run az login.'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $TemplateFile -PathType Leaf)) {
|
||||
throw "Azure Bicep template not found: $TemplateFile"
|
||||
}
|
||||
if (-not $AdministratorPassword) {
|
||||
$AdministratorPassword = Read-Host 'Password for the local Azure VM administrator' -AsSecureString
|
||||
}
|
||||
|
||||
$rootCertificateData = ''
|
||||
if ($DeployVpnGateway) {
|
||||
if (-not $P2sRootCertificatePath -or
|
||||
-not (Test-Path -LiteralPath $P2sRootCertificatePath -PathType Leaf)) {
|
||||
throw 'P2sRootCertificatePath is required when DeployVpnGateway is true.'
|
||||
}
|
||||
$rootCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
|
||||
(Resolve-Path -LiteralPath $P2sRootCertificatePath).Path)
|
||||
if (-not ($rootCertificate.Extensions | Where-Object {
|
||||
$_.Oid -and $_.Oid.Value -eq '2.5.29.19' -and $_.Format($false) -match 'CA' })) {
|
||||
throw 'P2sRootCertificatePath must contain a certificate-authority certificate.'
|
||||
}
|
||||
$rootCertificateData = [Convert]::ToBase64String($rootCertificate.RawData)
|
||||
}
|
||||
|
||||
$account = & az account show --output json 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Azure CLI is not signed in. Run az login, then retry.'
|
||||
}
|
||||
& az account set --subscription $SubscriptionId --only-show-errors
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not select Azure subscription $SubscriptionId."
|
||||
}
|
||||
|
||||
$deploymentDescription = if ($DeployVpnGateway) {
|
||||
'Create Azure VNet, Windows Server 2025 VM, public IP, and P2S VPN Gateway'
|
||||
}
|
||||
else {
|
||||
'Create Azure VNet, Windows Server 2025 VM, and public IP for direct enrollment'
|
||||
}
|
||||
if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", $deploymentDescription)) {
|
||||
& az group create --name $ResourceGroupName --location $Location --only-show-errors --output none
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not create or update resource group $ResourceGroupName."
|
||||
}
|
||||
|
||||
$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("sgu-azure-" + [Guid]::NewGuid().ToString('N'))
|
||||
$parametersPath = Join-Path $temporaryRoot 'parameters.json'
|
||||
$passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($AdministratorPassword)
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
|
||||
$acl = Get-Acl -LiteralPath $temporaryRoot
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent().User,
|
||||
[Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',
|
||||
[Security.AccessControl.PropagationFlags]::None,
|
||||
[Security.AccessControl.AccessControlType]::Allow))
|
||||
Set-Acl -LiteralPath $temporaryRoot -AclObject $acl
|
||||
|
||||
$plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer)
|
||||
$parameters = [ordered]@{
|
||||
'$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#'
|
||||
contentVersion = '1.0.0.0'
|
||||
parameters = [ordered]@{
|
||||
deploymentPrefix = @{ value = $DeploymentPrefix }
|
||||
location = @{ value = $Location }
|
||||
administratorUsername = @{ value = $AdministratorUsername }
|
||||
administratorPassword = @{ value = $plainPassword }
|
||||
computerName = @{ value = $ComputerName }
|
||||
vmSize = @{ value = $VmSize }
|
||||
virtualNetworkAddressPrefix = @{ value = $VirtualNetworkAddressPrefix }
|
||||
domainControllerSubnetPrefix = @{ value = $DomainControllerSubnetPrefix }
|
||||
gatewaySubnetPrefix = @{ value = $GatewaySubnetPrefix }
|
||||
domainControllerPrivateIp = @{ value = $DomainControllerPrivateIp.IPAddressToString }
|
||||
vpnClientAddressPoolPrefix = @{ value = $VpnClientAddressPoolPrefix }
|
||||
deployVpnGateway = @{ value = $DeployVpnGateway }
|
||||
p2sRootCertificateData = @{ value = $rootCertificateData }
|
||||
publicEnrollmentSourceAddressPrefixes = @{ value = @($PublicEnrollmentSourceAddressPrefixes) }
|
||||
administratorSourceAddressPrefix = @{ value = $AdministratorSourceAddressPrefix }
|
||||
}
|
||||
}
|
||||
[IO.File]::WriteAllText(
|
||||
$parametersPath,
|
||||
($parameters | ConvertTo-Json -Depth 8),
|
||||
[Text.UTF8Encoding]::new($false))
|
||||
$plainPassword = $null
|
||||
$parameters.parameters.administratorPassword.value = $null
|
||||
|
||||
$deploymentName = 'sgu-{0}' -f (Get-Date -Format 'yyyyMMdd-HHmmss')
|
||||
$deploymentOutput = & az deployment group create `
|
||||
--name $deploymentName `
|
||||
--resource-group $ResourceGroupName `
|
||||
--template-file (Resolve-Path -LiteralPath $TemplateFile).Path `
|
||||
--parameters "@$parametersPath" `
|
||||
--only-show-errors `
|
||||
--output json
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Azure deployment failed. Review the Azure CLI error above; no bootstrap credential was persisted by this script.'
|
||||
}
|
||||
$deployment = ($deploymentOutput -join [Environment]::NewLine) | ConvertFrom-Json
|
||||
}
|
||||
finally {
|
||||
if ($passwordPointer -ne [IntPtr]::Zero) {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer)
|
||||
}
|
||||
$AdministratorPassword = $null
|
||||
if ($temporaryRoot -and (Test-Path -LiteralPath $temporaryRoot)) {
|
||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$values = @{}
|
||||
foreach ($property in $deployment.properties.outputs.PSObject.Properties) {
|
||||
$values[$property.Name] = $property.Value.value
|
||||
}
|
||||
[pscustomobject]@{
|
||||
ResourceGroupName = $ResourceGroupName
|
||||
DeploymentName = $deploymentName
|
||||
DomainControllerName = $values.domainControllerName
|
||||
DomainControllerPrivateIp = $values.domainControllerPrivateIp
|
||||
DomainControllerPublicIp = $values.domainControllerPublicIp
|
||||
VpnGatewayName = $values.vpnGatewayName
|
||||
VpnClientAddressPoolPrefix = $values.vpnClientAddressPoolPrefix
|
||||
DeployVpnGateway = $DeployVpnGateway
|
||||
PublicEnrollmentSourceAddressPrefixes = @($PublicEnrollmentSourceAddressPrefixes)
|
||||
ServerBootstrapArguments = $values.serverBootstrapArguments
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,24 @@ if (-not $computer.PartOfDomain) {
|
||||
|
||||
$remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-555')
|
||||
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1]
|
||||
$remoteDesktopPrincipalSid = ([Security.Principal.NTAccount]::new($RemoteDesktopPrincipal)).Translate(
|
||||
[Security.Principal.SecurityIdentifier])
|
||||
|
||||
function Get-LocalGroupMemberSid {
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
|
||||
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,group")
|
||||
foreach ($member in @($group.psbase.Invoke('Members'))) {
|
||||
try {
|
||||
$sidBytes = $member.GetType().InvokeMember('objectSid',
|
||||
[Reflection.BindingFlags]::GetProperty, $null, $member, $null)
|
||||
if ($sidBytes) {
|
||||
([Security.Principal.SecurityIdentifier]::new([byte[]]$sidBytes, 0)).Value
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesktopPrincipal access")) {
|
||||
function Invoke-PowerCfgBestEffort {
|
||||
@@ -57,9 +75,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesk
|
||||
-ErrorAction SilentlyContinue |
|
||||
Set-NetFirewallRule -Enabled True -Profile Domain
|
||||
|
||||
$existingMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorAction SilentlyContinue)
|
||||
if ($existingMembers.Name -notcontains $RemoteDesktopPrincipal) {
|
||||
Add-LocalGroupMember -Group $remoteDesktopUsersGroup -Member $RemoteDesktopPrincipal
|
||||
$existingMembers = @(Get-LocalGroupMemberSid -Name $remoteDesktopUsersGroup)
|
||||
if ($existingMembers -notcontains $remoteDesktopPrincipalSid.Value) {
|
||||
Add-LocalGroupMember -Group $remoteDesktopUsersGroup -Member $remoteDesktopPrincipalSid.Value
|
||||
}
|
||||
|
||||
# Use Windows PowerShell so both the inbox and compatible remoting endpoints
|
||||
@@ -97,7 +115,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesk
|
||||
}
|
||||
}
|
||||
|
||||
$rdpMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorAction SilentlyContinue)
|
||||
$rdpMembers = @(Get-LocalGroupMemberSid -Name $remoteDesktopUsersGroup)
|
||||
[pscustomobject]@{
|
||||
ComputerName = $env:COMPUTERNAME
|
||||
Domain = $computer.Domain
|
||||
@@ -108,7 +126,7 @@ $rdpMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorActio
|
||||
'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' `
|
||||
-Name UserAuthentication) -eq 1
|
||||
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
|
||||
PrincipalIsAuthorized = $rdpMembers.Name -contains $RemoteDesktopPrincipal
|
||||
PrincipalIsAuthorized = $rdpMembers -contains $remoteDesktopPrincipalSid.Value
|
||||
TermService = (Get-Service TermService).Status
|
||||
WinRM = (Get-Service WinRM).Status
|
||||
FirewallProfile = 'Domain'
|
||||
|
||||
@@ -3,6 +3,22 @@ param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-LocalGroupMemberSid {
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
|
||||
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,group")
|
||||
foreach ($member in @($group.psbase.Invoke('Members'))) {
|
||||
try {
|
||||
$sidBytes = $member.GetType().InvokeMember('objectSid',
|
||||
[Reflection.BindingFlags]::GetProperty, $null, $member, $null)
|
||||
if ($sidBytes) {
|
||||
([Security.Principal.SecurityIdentifier]::new([byte[]]$sidBytes, 0)).Value
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
@@ -30,11 +46,12 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable SGU session auditing and
|
||||
# NETWORK SERVICE. Resolve both principals by SID for localized Windows.
|
||||
$eventLogReadersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-573')
|
||||
$networkServiceSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-20')
|
||||
$members = @(Get-LocalGroupMember -SID $eventLogReadersSid -ErrorAction SilentlyContinue)
|
||||
$eventLogReadersGroup = ($eventLogReadersSid.Translate(
|
||||
[Security.Principal.NTAccount]).Value -split '\\', 2)[1]
|
||||
$members = @(Get-LocalGroupMemberSid -Name $eventLogReadersGroup)
|
||||
$eventLogReaderMembershipChanged = $false
|
||||
if ($members.SID.Value -notcontains $networkServiceSid.Value) {
|
||||
$networkServiceAccount = $networkServiceSid.Translate([Security.Principal.NTAccount]).Value
|
||||
Add-LocalGroupMember -SID $eventLogReadersSid -Member $networkServiceAccount
|
||||
if ($members -notcontains $networkServiceSid.Value) {
|
||||
Add-LocalGroupMember -SID $eventLogReadersSid -Member $networkServiceSid.Value
|
||||
$eventLogReaderMembershipChanged = $true
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,14 @@ param(
|
||||
[PSCredential]$DomainCredential,
|
||||
[string]$DomainName = 'lci.lasalle.mx',
|
||||
[string]$DomainNetbios = 'LCI',
|
||||
[string]$DomainControllerDnsName,
|
||||
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
|
||||
[string]$NewComputerName,
|
||||
[string]$NetworkInterfaceAlias = 'Ethernet',
|
||||
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
|
||||
[switch]$DomainDnsConfigured,
|
||||
[ValidateSet('Direct', 'AzureP2S')]
|
||||
[string]$ConnectivityMode = 'Direct',
|
||||
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
||||
[string]$DotNetRuntimeInstallerPath,
|
||||
[string]$RustDeskServerAddress,
|
||||
@@ -39,6 +43,7 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
|
||||
foreach ($scriptName in @(
|
||||
'Install-CredentialProvider.ps1',
|
||||
'Install-SguEnrollmentGuard.ps1',
|
||||
'Set-SguStandardLocalUser.ps1',
|
||||
'Test-SguClientEnrollment.ps1',
|
||||
'Repair-SguClientEnrollment.ps1',
|
||||
'Enable-LabRemoteAccess.ps1',
|
||||
@@ -53,6 +58,31 @@ $computer = Get-CimInstance Win32_ComputerSystem
|
||||
if ($computer.PartOfDomain -and $computer.Domain -ne $DomainName) {
|
||||
throw "The computer is already joined to the unexpected domain $($computer.Domain)."
|
||||
}
|
||||
$domainMembershipHealthy = $false
|
||||
if ($computer.PartOfDomain) {
|
||||
try {
|
||||
$domainMembershipHealthy = [bool](Test-ComputerSecureChannel -ErrorAction Stop)
|
||||
}
|
||||
catch {
|
||||
$domainMembershipHealthy = $false
|
||||
}
|
||||
}
|
||||
if ($computer.PartOfDomain -and -not $domainMembershipHealthy) {
|
||||
if (-not $DomainCredential) {
|
||||
$DomainCredential = Get-Credential `
|
||||
-UserName "$DomainNetbios\Administrator" `
|
||||
-Message "Credential permitted to repair this computer in $DomainName"
|
||||
}
|
||||
$repairServer = if ($DomainControllerDnsName) { $DomainControllerDnsName } else { $DomainName }
|
||||
Write-Warning "The computer names $DomainName but its secure channel is broken. Repairing it against $repairServer."
|
||||
Reset-ComputerMachinePassword -Server $repairServer -Credential $DomainCredential -ErrorAction Stop
|
||||
Restart-Service Netlogon -Force
|
||||
Start-Sleep -Seconds 2
|
||||
$domainMembershipHealthy = [bool](Test-ComputerSecureChannel -ErrorAction Stop)
|
||||
if (-not $domainMembershipHealthy) {
|
||||
throw "The secure channel to $DomainName remained invalid after repair."
|
||||
}
|
||||
}
|
||||
|
||||
$installParams = @{
|
||||
PublishPath = $PublishPath
|
||||
@@ -90,9 +120,24 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
||||
# The broker uses a domain DNS name even before the machine joins the
|
||||
# domain. Point at AD DNS first so the provider-first health check works on
|
||||
# a completely clean Windows installation.
|
||||
if ($DomainDnsConfigured) {
|
||||
# The bootstrap configured domain-scoped NRPT, preserving Internet DNS.
|
||||
}
|
||||
elseif ($ConnectivityMode -eq 'Direct') {
|
||||
Set-DnsClientServerAddress `
|
||||
-InterfaceAlias $NetworkInterfaceAlias `
|
||||
-ServerAddresses $DomainDnsServerAddresses
|
||||
}
|
||||
else {
|
||||
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
|
||||
$nrptRule = Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
|
||||
Where-Object DisplayName -eq $nrptDisplayName |
|
||||
Select-Object -First 1
|
||||
if (-not $nrptRule -or
|
||||
@($DomainDnsServerAddresses | Where-Object { @($nrptRule.NameServers) -contains $_ }).Count -eq 0) {
|
||||
throw "AzureP2S enrollment requires the managed NRPT rule '$nrptDisplayName'. Run Install-SguAzureP2sClient.ps1 first."
|
||||
}
|
||||
}
|
||||
Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null
|
||||
|
||||
& (Join-Path $PSScriptRoot 'Install-CredentialProvider.ps1') @installParams | Out-Null
|
||||
@@ -101,6 +146,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
||||
-ServerAddress $RustDeskServerAddress `
|
||||
-ServerPublicKey $RustDeskServerPublicKey
|
||||
}
|
||||
$localStudentUser = & (Join-Path $PSScriptRoot 'Set-SguStandardLocalUser.ps1')
|
||||
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
|
||||
|
||||
$testParameters = @{ RequireBrokerHealth = $true }
|
||||
@@ -113,7 +159,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
||||
throw "Domain join refused because SGU enrollment is invalid: $($preJoin.Issues -join ' ')"
|
||||
}
|
||||
|
||||
if ($computer.PartOfDomain) {
|
||||
if ($computer.PartOfDomain -and $domainMembershipHealthy) {
|
||||
& (Join-Path $PSScriptRoot 'Enable-LabRemoteAccess.ps1') `
|
||||
-RemoteDesktopPrincipal $RemoteDesktopPrincipal `
|
||||
-EnableAdministrativeFirewallGroups | Out-Null
|
||||
@@ -128,6 +174,10 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
||||
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
|
||||
}
|
||||
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
|
||||
if (-not $postJoin.IsValid) {
|
||||
throw "SGU validation failed on the joined computer: $($postJoin.Issues -join ' ')"
|
||||
}
|
||||
$postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser
|
||||
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
|
||||
return $postJoin
|
||||
}
|
||||
@@ -159,7 +209,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
||||
[pscustomobject]@{
|
||||
ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
|
||||
DomainName = $DomainName
|
||||
ConnectivityMode = $ConnectivityMode
|
||||
ProviderValidatedBeforeJoin = $true
|
||||
StandardLocalUser = $localStudentUser
|
||||
RustDesk = $rustDeskResult
|
||||
RestartRequired = [bool]$SkipRestart
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$SubscriptionId,
|
||||
[Parameter(Mandatory)][string]$ResourceGroupName,
|
||||
[Parameter(Mandatory)][string]$VpnGatewayName,
|
||||
[string]$OutputPath = (Join-Path $PSScriptRoot '..\artifacts\azure-p2s\sgu-azure-vpn-client.zip')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
|
||||
throw 'Azure CLI is required.'
|
||||
}
|
||||
& az account set --subscription $SubscriptionId --only-show-errors
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not select Azure subscription $SubscriptionId."
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($OutputPath, 'Generate and download the Azure P2S client package')) {
|
||||
$downloadUriText = & az network vnet-gateway vpn-client generate `
|
||||
--resource-group $ResourceGroupName `
|
||||
--name $VpnGatewayName `
|
||||
--processor-architecture Amd64 `
|
||||
--authentication-method EAPTLS `
|
||||
--only-show-errors `
|
||||
--output tsv
|
||||
$downloadUriText = ($downloadUriText -join '').Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($downloadUriText)) {
|
||||
throw 'Azure did not generate a P2S client package URL.'
|
||||
}
|
||||
$downloadUri = $null
|
||||
if (-not [uri]::TryCreate($downloadUriText, [UriKind]::Absolute, [ref]$downloadUri) -or
|
||||
$downloadUri.Scheme -ne 'https') {
|
||||
throw 'Azure returned an invalid VPN client package URL.'
|
||||
}
|
||||
$resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
|
||||
New-Item -ItemType Directory -Path (Split-Path $resolvedOutputPath -Parent) -Force | Out-Null
|
||||
Invoke-WebRequest -Uri $downloadUri -OutFile $resolvedOutputPath -UseBasicParsing
|
||||
if ((Get-Item -LiteralPath $resolvedOutputPath).Length -lt 1024) {
|
||||
throw 'The downloaded VPN client package is unexpectedly small.'
|
||||
}
|
||||
[pscustomobject]@{
|
||||
PackagePath = $resolvedOutputPath
|
||||
Sha256 = (Get-FileHash -LiteralPath $resolvedOutputPath -Algorithm SHA256).Hash
|
||||
VpnGatewayName = $VpnGatewayName
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ param(
|
||||
[int]$PrefixLength = 24,
|
||||
[string]$NetworkInterfaceAlias,
|
||||
[ipaddress]$DefaultGateway,
|
||||
[ValidateSet('GuestStatic', 'PlatformManaged')]
|
||||
[string]$NetworkConfigurationMode = 'GuestStatic',
|
||||
[string[]]$TrustedClientNetworks = @(),
|
||||
[string[]]$PublicEnrollmentNetworks = @(),
|
||||
[ipaddress[]]$DnsForwarders = @(),
|
||||
[string]$DomainName = 'lci.lasalle.mx',
|
||||
[string]$DomainNetbios = 'LCI',
|
||||
@@ -69,6 +73,135 @@ function Get-DomainBaseDn {
|
||||
return (($DnsDomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ','
|
||||
}
|
||||
|
||||
function Test-PrivateIPv4Address {
|
||||
param([Parameter(Mandatory)][ipaddress]$Address)
|
||||
|
||||
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
return $false
|
||||
}
|
||||
$bytes = $Address.GetAddressBytes()
|
||||
return $bytes[0] -eq 10 -or
|
||||
($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or
|
||||
($bytes[0] -eq 192 -and $bytes[1] -eq 168)
|
||||
}
|
||||
|
||||
function ConvertTo-NetworkCidr {
|
||||
param(
|
||||
[Parameter(Mandatory)][ipaddress]$Address,
|
||||
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$NetworkPrefixLength
|
||||
)
|
||||
|
||||
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
throw 'Only IPv4 networks are supported by the SGU bootstrap.'
|
||||
}
|
||||
$addressBytes = $Address.GetAddressBytes()
|
||||
$networkBytes = [byte[]]::new(4)
|
||||
$remainingBits = $NetworkPrefixLength
|
||||
for ($index = 0; $index -lt 4; $index++) {
|
||||
$mask = if ($remainingBits -ge 8) {
|
||||
255
|
||||
}
|
||||
elseif ($remainingBits -le 0) {
|
||||
0
|
||||
}
|
||||
else {
|
||||
256 - [Math]::Pow(2, 8 - $remainingBits)
|
||||
}
|
||||
$networkBytes[$index] = [byte]($addressBytes[$index] -band [int]$mask)
|
||||
$remainingBits -= 8
|
||||
}
|
||||
return "$(($networkBytes | ForEach-Object { [string]$_ }) -join '.')/$NetworkPrefixLength"
|
||||
}
|
||||
|
||||
function ConvertTo-PrivateNetworkCidr {
|
||||
param([Parameter(Mandatory)][string]$Cidr)
|
||||
|
||||
if ($Cidr -notmatch '^([^/]+)/(\d{1,2})$') {
|
||||
throw "Trusted client network '$Cidr' must use IPv4 CIDR notation, for example 172.30.0.0/24."
|
||||
}
|
||||
$address = $null
|
||||
if (-not [ipaddress]::TryParse($Matches[1], [ref]$address) -or
|
||||
$address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
throw "Trusted client network '$Cidr' is not a valid IPv4 network."
|
||||
}
|
||||
$networkPrefixLength = [int]$Matches[2]
|
||||
if ($networkPrefixLength -lt 1 -or $networkPrefixLength -gt 32) {
|
||||
throw "Trusted client network '$Cidr' has an invalid prefix length."
|
||||
}
|
||||
if (-not (Test-PrivateIPv4Address -Address $address)) {
|
||||
throw "Trusted client network '$Cidr' is not private RFC1918 space. The bootstrap never exposes AD services to public client addresses."
|
||||
}
|
||||
return ConvertTo-NetworkCidr -Address $address -NetworkPrefixLength $networkPrefixLength
|
||||
}
|
||||
|
||||
function ConvertTo-PublicNetworkCidr {
|
||||
param([Parameter(Mandatory)][string]$Cidr)
|
||||
|
||||
if ($Cidr -notmatch '^([^/]+)/(\d{1,2})$') {
|
||||
throw "Public enrollment network '$Cidr' must use IPv4 CIDR notation, for example 203.0.113.0/24."
|
||||
}
|
||||
$address = $null
|
||||
if (-not [ipaddress]::TryParse($Matches[1], [ref]$address) -or
|
||||
$address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
throw "Public enrollment network '$Cidr' is not a valid IPv4 network."
|
||||
}
|
||||
$networkPrefixLength = [int]$Matches[2]
|
||||
if ($networkPrefixLength -lt 1 -or $networkPrefixLength -gt 32) {
|
||||
throw "Public enrollment network '$Cidr' has an invalid prefix length."
|
||||
}
|
||||
if (Test-PrivateIPv4Address -Address $address) {
|
||||
throw "Public enrollment network '$Cidr' is private RFC1918 space. Use -TrustedClientNetworks for LAN or VPN ranges."
|
||||
}
|
||||
$bytes = $address.GetAddressBytes()
|
||||
if ($bytes[0] -in @(0, 127) -or
|
||||
($bytes[0] -eq 169 -and $bytes[1] -eq 254) -or
|
||||
$bytes[0] -ge 224) {
|
||||
throw "Public enrollment network '$Cidr' is not usable unicast IPv4 space."
|
||||
}
|
||||
return ConvertTo-NetworkCidr -Address $address -NetworkPrefixLength $networkPrefixLength
|
||||
}
|
||||
|
||||
function Set-SguPublicEnrollmentFirewall {
|
||||
param(
|
||||
[Parameter(Mandatory)][ipaddress]$LocalAddress,
|
||||
[Parameter(Mandatory)][string[]]$RemoteAddress
|
||||
)
|
||||
|
||||
if ($RemoteAddress.Count -eq 0) { return }
|
||||
$definitions = @(
|
||||
@{ Name = 'SGU Public Enrollment TCP'; Protocol = 'TCP';
|
||||
Port = @('53','88','135','389','443','445','464','636','3268','3269','5985','8443','21115-21117','49152-65535') },
|
||||
@{ Name = 'SGU Public Enrollment UDP'; Protocol = 'UDP';
|
||||
Port = @('53','88','123','389','464','21116') }
|
||||
)
|
||||
foreach ($definition in $definitions) {
|
||||
$rule = Get-NetFirewallRule -DisplayName $definition.Name -ErrorAction SilentlyContinue
|
||||
if (-not $rule) {
|
||||
New-NetFirewallRule -DisplayName $definition.Name -Direction Inbound -Action Allow `
|
||||
-Protocol $definition.Protocol -LocalPort $definition.Port `
|
||||
-LocalAddress $LocalAddress.IPAddressToString -RemoteAddress $RemoteAddress `
|
||||
-Profile Any | Out-Null
|
||||
}
|
||||
else {
|
||||
$rule | Set-NetFirewallRule -Enabled True -Action Allow -Profile Any | Out-Null
|
||||
$rule | Get-NetFirewallPortFilter | Set-NetFirewallPortFilter `
|
||||
-Protocol $definition.Protocol -LocalPort $definition.Port | Out-Null
|
||||
$rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter `
|
||||
-LocalAddress $LocalAddress.IPAddressToString -RemoteAddress $RemoteAddress | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ActiveIPv4Adapters {
|
||||
# Accelerated Networking exposes an Up VF without an IP stack. Configure
|
||||
# the synthetic adapter that owns IPv4, never the underlying VF.
|
||||
Get-NetAdapter | Where-Object {
|
||||
$_.Status -eq 'Up' -and
|
||||
(Get-NetIPInterface -InterfaceIndex $_.ifIndex -AddressFamily IPv4 `
|
||||
-ErrorAction SilentlyContinue | Where-Object ConnectionState -eq 'Connected')
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-PrivateInterfaceAlias {
|
||||
param([string]$RequestedAlias)
|
||||
|
||||
@@ -77,7 +210,7 @@ function Resolve-PrivateInterfaceAlias {
|
||||
return $RequestedAlias
|
||||
}
|
||||
|
||||
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
|
||||
$upAdapters = @(Get-ActiveIPv4Adapters)
|
||||
$withoutGateway = @($upAdapters | Where-Object {
|
||||
-not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway
|
||||
})
|
||||
@@ -147,6 +280,27 @@ function Set-StaticDomainAddress {
|
||||
-ServerAddresses $Address.IPAddressToString
|
||||
}
|
||||
|
||||
function Assert-PlatformManagedDomainAddress {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InterfaceAlias,
|
||||
[Parameter(Mandatory)][ipaddress]$Address,
|
||||
[Parameter(Mandatory)][int]$NetworkPrefixLength
|
||||
)
|
||||
|
||||
$adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop
|
||||
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-IPAddress $Address.IPAddressToString -ErrorAction SilentlyContinue |
|
||||
Where-Object PrefixLength -eq $NetworkPrefixLength |
|
||||
Select-Object -First 1
|
||||
if (-not $matchingAddress) {
|
||||
$observed = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-ErrorAction SilentlyContinue |
|
||||
Where-Object PrefixOrigin -ne 'WellKnown' |
|
||||
ForEach-Object { "$($_.IPAddress)/$($_.PrefixLength)" }) -join ', '
|
||||
throw "PlatformManaged mode expected $Address/$NetworkPrefixLength on $InterfaceAlias, but found: $observed. Configure a static private IP on the Azure NIC before running the bootstrap; do not assign it inside Windows."
|
||||
}
|
||||
}
|
||||
|
||||
function Register-ResumeTask {
|
||||
param([Parameter(Mandatory)][string]$ScriptPath)
|
||||
|
||||
@@ -310,6 +464,9 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
|
||||
$PrefixLength = [int]$existingState.PrefixLength
|
||||
$NetworkInterfaceAlias = [string]$existingState.NetworkInterfaceAlias
|
||||
$DefaultGateway = if ($existingState.DefaultGateway) { [ipaddress][string]$existingState.DefaultGateway } else { $null }
|
||||
$NetworkConfigurationMode = if ($existingState.NetworkConfigurationMode) { [string]$existingState.NetworkConfigurationMode } else { 'GuestStatic' }
|
||||
$TrustedClientNetworks = if ($existingState.TrustedClientNetworks) { @($existingState.TrustedClientNetworks | ForEach-Object { [string]$_ }) } else { @() }
|
||||
$PublicEnrollmentNetworks = if ($existingState.PublicEnrollmentNetworks) { @($existingState.PublicEnrollmentNetworks | ForEach-Object { [string]$_ }) } else { @() }
|
||||
$DnsForwarders = @($existingState.DnsForwarders | ForEach-Object { [ipaddress][string]$_ })
|
||||
$DomainName = [string]$existingState.DomainName
|
||||
$DomainNetbios = [string]$existingState.DomainNetbios
|
||||
@@ -321,6 +478,19 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
|
||||
if (-not $ServerIPv4Address) {
|
||||
$ServerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address for this domain controller')
|
||||
}
|
||||
if (-not (Test-PrivateIPv4Address -Address $ServerIPv4Address)) {
|
||||
throw 'ServerIPv4Address must be the private address of the domain controller. An Azure public IP is never assigned to AD or published in domain DNS.'
|
||||
}
|
||||
$domainSubnet = ConvertTo-NetworkCidr -Address $ServerIPv4Address `
|
||||
-NetworkPrefixLength $PrefixLength
|
||||
$TrustedClientNetworks = @($TrustedClientNetworks |
|
||||
ForEach-Object { ConvertTo-PrivateNetworkCidr -Cidr $_ } |
|
||||
Where-Object { $_ -ne $domainSubnet } |
|
||||
Select-Object -Unique)
|
||||
$PublicEnrollmentNetworks = @($PublicEnrollmentNetworks |
|
||||
ForEach-Object { ConvertTo-PublicNetworkCidr -Cidr $_ } |
|
||||
Select-Object -Unique)
|
||||
$allowedRemoteAddresses = @($domainSubnet) + $TrustedClientNetworks + $PublicEnrollmentNetworks
|
||||
|
||||
$sourceRoot = $PSScriptRoot
|
||||
if (-not $Resume) {
|
||||
@@ -383,6 +553,9 @@ if (-not $existingState) {
|
||||
PrefixLength = $PrefixLength
|
||||
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
||||
DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null }
|
||||
NetworkConfigurationMode = $NetworkConfigurationMode
|
||||
TrustedClientNetworks = $TrustedClientNetworks
|
||||
PublicEnrollmentNetworks = $PublicEnrollmentNetworks
|
||||
DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString)
|
||||
DomainName = $DomainName
|
||||
DomainNetbios = $DomainNetbios
|
||||
@@ -396,9 +569,16 @@ if (-not $existingState) {
|
||||
[Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
|
||||
if ($NetworkConfigurationMode -eq 'PlatformManaged') {
|
||||
Write-BootstrapLog "Validating platform-managed address $ServerIPv4Address/$PrefixLength on $NetworkInterfaceAlias without changing DHCP, routes, or the Azure NIC."
|
||||
Assert-PlatformManagedDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength
|
||||
}
|
||||
else {
|
||||
Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength."
|
||||
Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway
|
||||
}
|
||||
|
||||
$computer = Get-CimInstance Win32_ComputerSystem
|
||||
if (-not $computer.PartOfDomain) {
|
||||
@@ -454,7 +634,7 @@ Write-BootstrapLog 'Finalizing Active Directory, DNS, policies, broker, shares,
|
||||
# Once the machine is a DC, every active adapter must query the local DNS
|
||||
# service. Only the private domain adapter may publish its address in the AD
|
||||
# zone; otherwise clients can receive the DHCP/NAT address of the Internet NIC.
|
||||
Get-NetAdapter | Where-Object Status -eq 'Up' | ForEach-Object {
|
||||
Get-ActiveIPv4Adapters | ForEach-Object {
|
||||
Set-DnsClientServerAddress -InterfaceIndex $_.ifIndex `
|
||||
-ServerAddresses $ServerIPv4Address.IPAddressToString
|
||||
Set-DnsClient -InterfaceIndex $_.ifIndex `
|
||||
@@ -472,8 +652,17 @@ Wait-ActiveDirectoryReady -ExpectedBaseDn $baseDn
|
||||
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
|
||||
-ErrorAction SilentlyContinue
|
||||
if (-not $domainProfile -or $domainProfile.NetworkCategory -ne 'DomainAuthenticated') {
|
||||
if ($NetworkConfigurationMode -eq 'GuestStatic') {
|
||||
Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile."
|
||||
Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false
|
||||
}
|
||||
else {
|
||||
# Restarting an Azure NIC from inside the guest can sever the only
|
||||
# management path. Refresh NLA instead; this does not change the
|
||||
# platform-managed address, DHCP lease, route, or link state.
|
||||
Write-BootstrapLog 'Refreshing Network Location Awareness without restarting the Azure adapter.'
|
||||
Restart-Service NlaSvc -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
for ($attempt = 1; $attempt -le 15; $attempt++) {
|
||||
Start-Sleep -Seconds 2
|
||||
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
|
||||
@@ -577,7 +766,7 @@ if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) {
|
||||
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
|
||||
-DefaultCompany 'La Salle' `
|
||||
-FirewallLocalAddress $ServerIPv4Address `
|
||||
-FirewallRemoteAddress "$($ServerIPv4Address.IPAddressToString)/$PrefixLength" `
|
||||
-FirewallRemoteAddress $allowedRemoteAddresses `
|
||||
-CreateMissingOus `
|
||||
-DisableCertificateRevocationCheckForLab | Out-Null
|
||||
|
||||
@@ -591,9 +780,10 @@ foreach ($hostRecord in $hostRecords) {
|
||||
}
|
||||
}
|
||||
|
||||
$privateSubnet = "$($ServerIPv4Address.IPAddressToString)/$PrefixLength"
|
||||
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
|
||||
-AllowedRemoteAddress $privateSubnet | Out-Null
|
||||
-AllowedRemoteAddress $allowedRemoteAddresses | Out-Null
|
||||
Set-SguPublicEnrollmentFirewall -LocalAddress $ServerIPv4Address `
|
||||
-RemoteAddress $PublicEnrollmentNetworks
|
||||
|
||||
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
|
||||
if (Test-Path -LiteralPath $contentPath -PathType Container) {
|
||||
@@ -613,7 +803,7 @@ if (-not $packageFirewallRule) {
|
||||
-Protocol TCP `
|
||||
-LocalPort 445 `
|
||||
-LocalAddress $ServerIPv4Address.IPAddressToString `
|
||||
-RemoteAddress $privateSubnet `
|
||||
-RemoteAddress $allowedRemoteAddresses `
|
||||
-Profile Any | Out-Null
|
||||
}
|
||||
else {
|
||||
@@ -621,7 +811,7 @@ else {
|
||||
$packageFirewallRule | Get-NetFirewallAddressFilter |
|
||||
Set-NetFirewallAddressFilter `
|
||||
-LocalAddress $ServerIPv4Address.IPAddressToString `
|
||||
-RemoteAddress $privateSubnet | Out-Null
|
||||
-RemoteAddress $allowedRemoteAddresses | Out-Null
|
||||
}
|
||||
|
||||
$collectorFqdn = "$env:COMPUTERNAME.$DomainName"
|
||||
@@ -642,7 +832,7 @@ $userPolicyParameters = @{
|
||||
|
||||
$rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') `
|
||||
-ServerAddress $rustDeskDnsName `
|
||||
-FirewallRemoteAddress $privateSubnet
|
||||
-FirewallRemoteAddress $allowedRemoteAddresses
|
||||
$rustDeskManagementRoot = Join-Path $env:ProgramData 'SGU\RustDesk'
|
||||
New-Item -ItemType Directory -Path $rustDeskManagementRoot -Force | Out-Null
|
||||
foreach ($scriptName in @(
|
||||
@@ -680,6 +870,10 @@ $validation = [ordered]@{
|
||||
ComputerName = $env:COMPUTERNAME
|
||||
DomainName = $DomainName
|
||||
ServerIPv4Address = $ServerIPv4Address.IPAddressToString
|
||||
NetworkConfigurationMode = $NetworkConfigurationMode
|
||||
TrustedClientNetworks = $TrustedClientNetworks
|
||||
PublicEnrollmentNetworks = $PublicEnrollmentNetworks
|
||||
AllowedRemoteAddresses = $allowedRemoteAddresses
|
||||
BrokerDnsName = $brokerDnsName
|
||||
BrokerCertificateThumbprint = $serverCertificate.Thumbprint
|
||||
BrokerService = (Get-Service SGUAuthBroker).Status.ToString()
|
||||
@@ -714,7 +908,8 @@ if ($validation.BrokerService -ne 'Running' -or
|
||||
-not $validation.RustDeskHbbsListening -or
|
||||
-not $validation.RustDeskHbbrListening -or
|
||||
$validation.EventCollector -ne 'Running' -or
|
||||
-not $validation.EventSubscription) {
|
||||
-not $validation.EventSubscription -or
|
||||
$validation.DomainNetworkProfile -ne 'DomainAuthenticated') {
|
||||
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#Requires -Version 5.1
|
||||
#Requires -RunAsAdministrator
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$VpnProfilePackagePath,
|
||||
[Parameter(Mandatory)][string]$ClientCertificatePfxPath,
|
||||
[securestring]$ClientCertificatePfxPassword,
|
||||
[Parameter(Mandatory)][string]$ClientRootCertificatePath,
|
||||
[string]$ConnectionName = 'SGU Azure P2S',
|
||||
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
|
||||
[ipaddress]$DomainControllerIPv4Address = '10.77.0.4',
|
||||
[string]$DomainName = 'lci.lasalle.mx',
|
||||
[switch]$Connect
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
foreach ($path in @($VpnProfilePackagePath,$ClientCertificatePfxPath,$ClientRootCertificatePath)) {
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
throw "Required P2S file not found: $path"
|
||||
}
|
||||
}
|
||||
if (-not $ClientCertificatePfxPassword) {
|
||||
$ClientCertificatePfxPassword = Read-Host 'Password protecting the P2S client PFX' -AsSecureString
|
||||
}
|
||||
|
||||
$temporaryRoot = Join-Path $env:ProgramData ("SGU\AzureP2S\Import-" + [Guid]::NewGuid().ToString('N'))
|
||||
try {
|
||||
Expand-Archive -LiteralPath $VpnProfilePackagePath -DestinationPath $temporaryRoot -Force
|
||||
$vpnSettingsPath = Get-ChildItem -LiteralPath $temporaryRoot -Recurse -Filter VpnSettings.xml -File |
|
||||
Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $vpnSettingsPath) {
|
||||
throw 'The Azure package does not contain Generic\VpnSettings.xml. Generate it with IKEv2 enabled.'
|
||||
}
|
||||
[xml]$vpnSettings = Get-Content -LiteralPath $vpnSettingsPath -Raw
|
||||
$vpnServerNode = $vpnSettings.SelectSingleNode('//*[local-name()="VpnServer"]')
|
||||
if (-not $vpnServerNode -or [string]::IsNullOrWhiteSpace($vpnServerNode.InnerText)) {
|
||||
throw 'VpnSettings.xml does not contain the Azure VPN gateway FQDN.'
|
||||
}
|
||||
$vpnServer = $vpnServerNode.InnerText.Trim()
|
||||
|
||||
$serverRootPath = Get-ChildItem -LiteralPath (Split-Path $vpnSettingsPath -Parent) `
|
||||
-Filter VpnServerRoot.cer -File | Select-Object -First 1 -ExpandProperty FullName
|
||||
if ($serverRootPath) {
|
||||
Import-Certificate -FilePath $serverRootPath -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
|
||||
}
|
||||
$clientRoot = Import-Certificate -FilePath $ClientRootCertificatePath `
|
||||
-CertStoreLocation Cert:\LocalMachine\Root | Select-Object -First 1
|
||||
$clientCertificates = @(Import-PfxCertificate -FilePath $ClientCertificatePfxPath `
|
||||
-Password $ClientCertificatePfxPassword -CertStoreLocation Cert:\LocalMachine\My)
|
||||
$clientCertificate = $clientCertificates |
|
||||
Where-Object {
|
||||
$_.HasPrivateKey -and
|
||||
$_.NotAfter -gt (Get-Date) -and
|
||||
@($_.EnhancedKeyUsageList | ForEach-Object ObjectId) -contains '1.3.6.1.5.5.7.3.2'
|
||||
} |
|
||||
Sort-Object NotAfter -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $clientCertificate) {
|
||||
throw 'The imported PFX does not contain a valid Client Authentication certificate with a private key.'
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($ConnectionName, 'Install an all-user IKEv2 Azure P2S connection using a machine certificate')) {
|
||||
$existingConnection = Get-VpnConnection -Name $ConnectionName -AllUserConnection `
|
||||
-ErrorAction SilentlyContinue
|
||||
if ($existingConnection) {
|
||||
Remove-VpnConnection -Name $ConnectionName -AllUserConnection -Force
|
||||
}
|
||||
$dnsParameters = @{}
|
||||
if ($DomainName) { $dnsParameters.DnsSuffix = $DomainName }
|
||||
Add-VpnConnection @dnsParameters `
|
||||
-Name $ConnectionName `
|
||||
-ServerAddress $vpnServer `
|
||||
-TunnelType Ikev2 `
|
||||
-AuthenticationMethod MachineCertificate `
|
||||
-MachineCertificateIssuerFilter $clientRoot `
|
||||
-MachineCertificateEKUFilter '1.3.6.1.5.5.7.3.2' `
|
||||
-EncryptionLevel Required `
|
||||
-SplitTunneling `
|
||||
-AllUserConnection `
|
||||
-Force | Out-Null
|
||||
foreach ($prefix in $AzureNetworkPrefixes) {
|
||||
Add-VpnConnectionRoute -ConnectionName $ConnectionName `
|
||||
-DestinationPrefix $prefix -AllUserConnection -PassThru | Out-Null
|
||||
}
|
||||
|
||||
# The unified bootstrap can discover the domain after connecting.
|
||||
if ($DomainName) {
|
||||
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
|
||||
Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
|
||||
Where-Object DisplayName -eq $nrptDisplayName |
|
||||
Remove-DnsClientNrptRule -Force
|
||||
Add-DnsClientNrptRule `
|
||||
-Namespace ".$DomainName" `
|
||||
-NameServers $DomainControllerIPv4Address.IPAddressToString `
|
||||
-DisplayName $nrptDisplayName `
|
||||
-Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
if ($Connect) {
|
||||
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Windows could not connect $ConnectionName. Verify UDP 500/4500 (IKEv2), or configure the Azure-generated OpenVPN profile in Azure VPN Client when the local network blocks IKEv2."
|
||||
}
|
||||
}
|
||||
|
||||
$connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection
|
||||
[pscustomobject]@{
|
||||
ConnectionName = $connection.Name
|
||||
ServerAddress = $connection.ServerAddress
|
||||
TunnelType = $connection.TunnelType
|
||||
AllUserConnection = $true
|
||||
AuthenticationMethod = $connection.AuthenticationMethod
|
||||
ConnectionStatus = $connection.ConnectionStatus
|
||||
ClientCertificateThumbprint = $clientCertificate.Thumbprint
|
||||
DomainControllerIPv4Address = $DomainControllerIPv4Address.IPAddressToString
|
||||
DomainDnsNamespace = ".$DomainName"
|
||||
AzureNetworkPrefixes = $AzureNetworkPrefixes
|
||||
AvailableBeforeLogon = $true
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$ClientCertificatePfxPassword = $null
|
||||
if (Test-Path -LiteralPath $temporaryRoot) {
|
||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ $sourceScripts = @(
|
||||
'Enable-LabRemoteAccess.ps1',
|
||||
'Enable-SguClientMonitoring.ps1',
|
||||
'Install-SguRustDeskClient.ps1',
|
||||
'Set-SguStandardLocalUser.ps1',
|
||||
'Test-SguClientEnrollment.ps1',
|
||||
'Repair-SguClientEnrollment.ps1'
|
||||
)
|
||||
|
||||
@@ -6,7 +6,8 @@ param(
|
||||
|
||||
[string]$InstallRoot = "$env:ProgramFiles\SGU\RustDeskServer",
|
||||
[string]$DataRoot = "$env:ProgramData\SGU\RustDesk\Server",
|
||||
[string]$FirewallRemoteAddress = '192.168.50.0/24',
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string[]]$FirewallRemoteAddress = @('192.168.50.0/24'),
|
||||
[uri]$DownloadUri = 'https://github.com/rustdesk/rustdesk-server/releases/download/1.1.16/rustdesk-server-windows-x86_64-unsigned.zip',
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedSha256 = 'B865A3A62FC8755B45480C508F1C4871C3338590408DDA8C58C7E9C373B7ADB0'
|
||||
|
||||
@@ -3,19 +3,60 @@
|
||||
param(
|
||||
[ipaddress]$DomainControllerIPv4Address,
|
||||
[string]$NetworkInterfaceAlias,
|
||||
[ipaddress]$ClientIPv4Address,
|
||||
[ValidateRange(1, 32)]
|
||||
[int]$ClientPrefixLength = 24,
|
||||
[PSCredential]$DomainCredential,
|
||||
[string]$DomainName = 'lci.lasalle.mx',
|
||||
[string]$DomainNetbios = 'LCI',
|
||||
[string]$DomainName,
|
||||
[string]$DomainNetbios,
|
||||
[string]$ComputerOuDn,
|
||||
[string]$NewComputerName,
|
||||
[ValidateSet('Auto', 'Windows10Legacy', 'Windows11Modern')]
|
||||
[string]$CompatibilityProfile = 'Auto',
|
||||
[ValidateSet('Direct', 'AzureP2S')]
|
||||
[string]$ConnectivityMode = 'Direct',
|
||||
[string]$VpnConnectionName = 'SGU Azure P2S',
|
||||
[string]$VpnProfilePackagePath,
|
||||
[string]$VpnClientCertificatePfxPath,
|
||||
[securestring]$VpnClientCertificatePfxPassword,
|
||||
[string]$VpnClientRootCertificatePath,
|
||||
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
|
||||
[switch]$PauseOnError,
|
||||
[switch]$SkipRestart
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$brokerRecordName = 'sgu-auth'
|
||||
$brokerDnsName = "$brokerRecordName.$DomainName"
|
||||
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
|
||||
$temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N'))
|
||||
$bootstrapLogRoot = Join-Path $env:ProgramData 'SGU\Bootstrap\Client'
|
||||
$bootstrapErrorLog = Join-Path $bootstrapLogRoot 'latest-error.log'
|
||||
|
||||
trap {
|
||||
$failure = $_
|
||||
$failureText = @(
|
||||
"SGU client enrollment failed at $((Get-Date).ToString('s')).",
|
||||
'',
|
||||
$failure.Exception.Message,
|
||||
'',
|
||||
$failure.ScriptStackTrace
|
||||
) -join [Environment]::NewLine
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $bootstrapLogRoot -Force | Out-Null
|
||||
[IO.File]::WriteAllText($bootstrapErrorLog, $failureText, [Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
catch {
|
||||
# Keep the original enrollment error when diagnostics cannot be written.
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'SGU client enrollment did not complete.' -ForegroundColor Red
|
||||
Write-Host $failure.Exception.Message -ForegroundColor Red
|
||||
Write-Host "Diagnostic log: $bootstrapErrorLog" -ForegroundColor Yellow
|
||||
if ($PauseOnError -and [Environment]::UserInteractive) {
|
||||
Read-Host 'Press ENTER to close this window' | Out-Null
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Assert-Administrator {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
@@ -44,42 +85,235 @@ function Assert-PackageManifest {
|
||||
throw "Bootstrap package integrity check failed: $($entry.Path)"
|
||||
}
|
||||
}
|
||||
return $manifest
|
||||
}
|
||||
|
||||
function Resolve-ClientInterfaceAlias {
|
||||
param([string]$RequestedAlias)
|
||||
param(
|
||||
[string]$RequestedAlias,
|
||||
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress
|
||||
)
|
||||
|
||||
if ($RequestedAlias) {
|
||||
Get-NetAdapter -Name $RequestedAlias -ErrorAction Stop | Out-Null
|
||||
return $RequestedAlias
|
||||
# IP interfaces include tunnel/PPP adapters that Get-NetAdapter can omit.
|
||||
$interfaces = @(Get-NetIPInterface -AddressFamily IPv4 | Where-Object {
|
||||
$_.ConnectionState -eq 'Connected' -and
|
||||
(-not $RequestedAlias -or $_.InterfaceAlias -eq $RequestedAlias)
|
||||
})
|
||||
$preferred = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString `
|
||||
-ErrorAction SilentlyContinue | Where-Object { $_.PSObject.Properties['IPAddress'] })
|
||||
$attempts = @()
|
||||
$candidates = @(foreach ($interface in $interfaces) {
|
||||
$addresses = @(Get-NetIPAddress -InterfaceIndex $interface.InterfaceIndex -AddressFamily IPv4 `
|
||||
-ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.AddressState -eq 'Preferred' -and -not $_.SkipAsSource -and
|
||||
$_.IPAddress -notmatch '^(0\.|127\.|169\.254\.)'
|
||||
})
|
||||
if ($addresses.Count -eq 0) {
|
||||
$attempts += "$($interface.InterfaceAlias): no usable IPv4 address (check DHCP/static configuration)"
|
||||
continue
|
||||
}
|
||||
$route = Get-NetRoute -InterfaceIndex $interface.InterfaceIndex -AddressFamily IPv4 `
|
||||
-PolicyStore ActiveStore -ErrorAction SilentlyContinue | Where-Object {
|
||||
$parts = $_.DestinationPrefix -split '/'
|
||||
Test-IPv4AddressesSharePrefix -FirstAddress $DomainControllerAddress `
|
||||
-SecondAddress ([ipaddress]$parts[0]) -PrefixLength ([int]$parts[1])
|
||||
} | Sort-Object @{ Expression = { [int]($_.DestinationPrefix -split '/')[1] }; Descending = $true },
|
||||
RouteMetric | Select-Object -First 1
|
||||
if ($route) {
|
||||
foreach ($address in $addresses) {
|
||||
[pscustomobject]@{
|
||||
InterfaceAlias = $interface.InterfaceAlias
|
||||
InterfaceIndex = [int]$interface.InterfaceIndex
|
||||
IPAddress = $address.IPAddress
|
||||
NextHop = $route.NextHop
|
||||
Preferred = @($preferred | Where-Object IPAddress -eq $address.IPAddress).Count -gt 0
|
||||
PrefixLength = [int]($route.DestinationPrefix -split '/')[1]
|
||||
Metric = [int]$route.RouteMetric + [int]$interface.InterfaceMetric
|
||||
}
|
||||
}
|
||||
}
|
||||
else { $attempts += "$($interface.InterfaceAlias): no route to $DomainControllerAddress" }
|
||||
})
|
||||
if ($interfaces.Count -eq 0) { $attempts += 'No matching connected IPv4 interface' }
|
||||
foreach ($candidate in ($candidates | Sort-Object @{ Expression = { $_.Preferred }; Descending = $true },
|
||||
@{ Expression = { $_.PrefixLength }; Descending = $true }, Metric, InterfaceIndex, IPAddress)) {
|
||||
Write-Host "Checking $($candidate.InterfaceAlias) ($($candidate.IPAddress)) -> $DomainControllerAddress..."
|
||||
if (Test-TcpPort -Address $DomainControllerAddress -Port 5985 -TimeoutMilliseconds 2000 `
|
||||
-SourceAddress ([ipaddress]$candidate.IPAddress) -InterfaceIndex $candidate.InterfaceIndex) {
|
||||
return $candidate
|
||||
}
|
||||
$attempts += "$($candidate.InterfaceAlias) [$($candidate.IPAddress), next hop $($candidate.NextHop)]: TCP 5985 unavailable"
|
||||
}
|
||||
throw "Cannot reach SGU server $DomainControllerAddress. $($attempts -join '; '). Check the LAN/VPN connection, DHCP or an administrator-assigned IP, routes and the server WinRM firewall. No client IP was changed."
|
||||
}
|
||||
|
||||
$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 Test-PrivateIPv4Address {
|
||||
param([Parameter(Mandatory)][ipaddress]$Address)
|
||||
|
||||
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
return $false
|
||||
}
|
||||
$bytes = $Address.GetAddressBytes()
|
||||
return $bytes[0] -eq 10 -or
|
||||
($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or
|
||||
($bytes[0] -eq 192 -and $bytes[1] -eq 168)
|
||||
}
|
||||
|
||||
function Wait-ClientInterface {
|
||||
param(
|
||||
[string]$RequestedAlias,
|
||||
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
|
||||
[int]$TimeoutSeconds = 20
|
||||
)
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
do {
|
||||
try {
|
||||
return Resolve-ClientInterfaceAlias -RequestedAlias $RequestedAlias `
|
||||
-DomainControllerAddress $DomainControllerAddress
|
||||
}
|
||||
catch {
|
||||
$lastFailure = $_
|
||||
if ((Get-Date) -ge $deadline) { throw $lastFailure }
|
||||
Write-Host 'Waiting for DHCP, VPN routes or server connectivity to become ready...'
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
} while ($true)
|
||||
}
|
||||
|
||||
function Assert-UsableClientIPv4Address {
|
||||
param(
|
||||
[Parameter(Mandatory)][ipaddress]$Address,
|
||||
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
|
||||
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
|
||||
)
|
||||
|
||||
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
throw "The SGU client address '$Address' must be IPv4."
|
||||
}
|
||||
if ($Address.IPAddressToString -eq $DomainControllerAddress.IPAddressToString) {
|
||||
throw 'The SGU client and domain controller cannot use the same IPv4 address.'
|
||||
}
|
||||
if ($Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.)') {
|
||||
throw "The SGU client address '$Address' is not usable on the private domain network."
|
||||
}
|
||||
if (-not (Test-IPv4AddressesSharePrefix -FirstAddress $Address `
|
||||
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)) {
|
||||
throw "The SGU client address '$Address/$PrefixLength' is not on the same network as domain controller $DomainControllerAddress."
|
||||
}
|
||||
}
|
||||
|
||||
function Set-ClientDomainAddress {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InterfaceAlias,
|
||||
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
|
||||
[ipaddress]$RequestedAddress,
|
||||
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
|
||||
)
|
||||
|
||||
$adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop
|
||||
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-ErrorAction SilentlyContinue |
|
||||
Sort-Object RouteMetric,InterfaceMetric |
|
||||
Where-Object {
|
||||
$_.AddressState -eq 'Preferred' -and
|
||||
$_.IPAddress -notmatch '^(127\.|169\.254\.)' -and
|
||||
(Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) `
|
||||
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)
|
||||
} |
|
||||
Select-Object -First 1
|
||||
if ($defaultRoute) {
|
||||
return [string](Get-NetAdapter -InterfaceIndex $defaultRoute.InterfaceIndex).Name
|
||||
|
||||
if (-not $RequestedAddress -and $matchingAddress) {
|
||||
return [ipaddress]$matchingAddress.IPAddress
|
||||
}
|
||||
if (-not $RequestedAddress) {
|
||||
throw 'Static addressing requires an explicit -ClientIPv4Address. Automatic enrollment preserves DHCP and existing addresses.'
|
||||
}
|
||||
Assert-UsableClientIPv4Address -Address $RequestedAddress `
|
||||
-DomainControllerAddress $DomainControllerAddress -PrefixLength $PrefixLength
|
||||
|
||||
Set-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -Dhcp Disabled
|
||||
$existingAddresses = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-ErrorAction SilentlyContinue | Where-Object PrefixOrigin -ne 'WellKnown')
|
||||
foreach ($existingAddress in $existingAddresses) {
|
||||
if ($existingAddress.IPAddress -ne $RequestedAddress.IPAddressToString -or
|
||||
[int]$existingAddress.PrefixLength -ne $PrefixLength) {
|
||||
Remove-NetIPAddress -InputObject $existingAddress -Confirm:$false
|
||||
}
|
||||
}
|
||||
if (-not (Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-IPAddress $RequestedAddress.IPAddressToString -ErrorAction SilentlyContinue)) {
|
||||
New-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-IPAddress $RequestedAddress.IPAddressToString -PrefixLength $PrefixLength | Out-Null
|
||||
}
|
||||
|
||||
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
|
||||
if ($upAdapters.Count -eq 1) {
|
||||
return [string]$upAdapters[0].Name
|
||||
$addressReadyDeadline = (Get-Date).AddSeconds(20)
|
||||
do {
|
||||
$configuredAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex `
|
||||
-AddressFamily IPv4 -IPAddress $RequestedAddress.IPAddressToString `
|
||||
-ErrorAction SilentlyContinue
|
||||
if ($configuredAddress -and $configuredAddress.AddressState -eq 'Preferred') {
|
||||
return $RequestedAddress
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
} while ((Get-Date) -lt $addressReadyDeadline)
|
||||
|
||||
$aliases = ($upAdapters.Name | Sort-Object) -join ', '
|
||||
throw "Could not select a network adapter. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
|
||||
$observedState = if ($configuredAddress) { $configuredAddress.AddressState } else { 'Missing' }
|
||||
throw "The SGU client address '$RequestedAddress' did not become ready on '$InterfaceAlias' within 20 seconds. Observed state: $observedState."
|
||||
}
|
||||
|
||||
function Test-TcpPort {
|
||||
param(
|
||||
[Parameter(Mandatory)][ipaddress]$Address,
|
||||
[Parameter(Mandatory)][int]$Port,
|
||||
[int]$TimeoutMilliseconds = 5000
|
||||
[int]$TimeoutMilliseconds = 5000,
|
||||
[ipaddress]$SourceAddress,
|
||||
[int]$InterfaceIndex
|
||||
)
|
||||
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
$client = [Net.Sockets.TcpClient]::new([Net.Sockets.AddressFamily]::InterNetwork)
|
||||
$connect = $null
|
||||
try {
|
||||
if ($InterfaceIndex) {
|
||||
# IP_UNICAST_IF (31) expects the interface index in network byte order.
|
||||
$client.Client.SetSocketOption([Net.Sockets.SocketOptionLevel]::IP,
|
||||
[Net.Sockets.SocketOptionName]31, [Net.IPAddress]::HostToNetworkOrder($InterfaceIndex))
|
||||
}
|
||||
if ($SourceAddress) {
|
||||
$client.Client.Bind([Net.IPEndPoint]::new($SourceAddress, 0))
|
||||
}
|
||||
$connect = $client.BeginConnect($Address, $Port, $null, $null)
|
||||
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) {
|
||||
return $false
|
||||
@@ -92,30 +326,223 @@ function Test-TcpPort {
|
||||
}
|
||||
finally {
|
||||
$client.Dispose()
|
||||
if ($connect) { $connect.AsyncWaitHandle.Close() }
|
||||
}
|
||||
}
|
||||
|
||||
function Set-ClientServerRoute {
|
||||
param(
|
||||
[Parameter(Mandatory)]$SelectedInterface,
|
||||
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress
|
||||
)
|
||||
$current = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString |
|
||||
Where-Object { $_.PSObject.Properties['IPAddress'] })
|
||||
if (@($current | Where-Object InterfaceIndex -eq $SelectedInterface.InterfaceIndex).Count -gt 0) { return }
|
||||
|
||||
# Only pin this server when Windows currently chooses a different interface.
|
||||
# Do not replace default routes or change interface metrics used by Internet traffic.
|
||||
$route = New-NetRoute -DestinationPrefix "$DomainControllerAddress/32" `
|
||||
-InterfaceIndex $SelectedInterface.InterfaceIndex -NextHop $SelectedInterface.NextHop `
|
||||
-RouteMetric 1
|
||||
$current = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString |
|
||||
Where-Object { $_.PSObject.Properties['IPAddress'] })
|
||||
if (@($current | Where-Object InterfaceIndex -eq $SelectedInterface.InterfaceIndex).Count -eq 0) {
|
||||
$route | Remove-NetRoute -Confirm:$false
|
||||
throw "Windows still routes $DomainControllerAddress through another interface. Resolve conflicting host routes or VPN policies and retry."
|
||||
}
|
||||
}
|
||||
|
||||
function Set-ClientDomainDns {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$DnsDomain,
|
||||
[Parameter(Mandatory)][ipaddress]$ServerAddress
|
||||
)
|
||||
$displayName = "SGU domain DNS - $DnsDomain"
|
||||
$existing = @(Get-DnsClientNrptRule -ErrorAction Stop | Where-Object DisplayName -eq $displayName)
|
||||
if ($existing.Count -eq 1 -and @($existing[0].NameServers) -contains $ServerAddress.IPAddressToString -and
|
||||
@($existing[0].Namespace) -contains ".$DnsDomain" -and @($existing[0].Namespace) -contains $DnsDomain) { return }
|
||||
$existing | Remove-DnsClientNrptRule -Force
|
||||
Add-DnsClientNrptRule -Namespace @($DnsDomain, ".$DnsDomain") `
|
||||
-NameServers $ServerAddress.IPAddressToString -DisplayName $displayName | Out-Null
|
||||
Clear-DnsClientCache
|
||||
}
|
||||
|
||||
function Test-ClientDomainDns {
|
||||
param([Parameter(Mandatory)][string]$DnsDomain)
|
||||
|
||||
try {
|
||||
$records = @(Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DnsDomain" `
|
||||
-DnsOnly -ErrorAction Stop)
|
||||
return @($records | Where-Object {
|
||||
$_.Type -eq 'SRV' -and -not [string]::IsNullOrWhiteSpace([string]$_.NameTarget)
|
||||
}).Count -gt 0
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Set-ClientHostMappings {
|
||||
param(
|
||||
[Parameter(Mandatory)][ipaddress]$ServerAddress,
|
||||
[Parameter(Mandatory)][string[]]$HostNames
|
||||
)
|
||||
|
||||
$hostsPath = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts'
|
||||
$managedNames = @($HostNames |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||
ForEach-Object { $_.Trim().ToLowerInvariant() } |
|
||||
Select-Object -Unique)
|
||||
$preservedLines = foreach ($line in [IO.File]::ReadAllLines($hostsPath)) {
|
||||
$data = ($line -split '#', 2)[0].Trim()
|
||||
$tokens = @($data -split '\s+' | Where-Object { $_ })
|
||||
$lineNames = if ($tokens.Count -gt 1) {
|
||||
@($tokens[1..($tokens.Count - 1)] | ForEach-Object { $_.ToLowerInvariant() })
|
||||
}
|
||||
else { @() }
|
||||
if (@($lineNames | Where-Object { $managedNames -contains $_ }).Count -eq 0) {
|
||||
$line
|
||||
}
|
||||
}
|
||||
$mapping = '{0} {1} # SGU managed direct enrollment' -f
|
||||
$ServerAddress.IPAddressToString,($managedNames -join ' ')
|
||||
[IO.File]::WriteAllLines($hostsPath, @($preservedLines) + $mapping,
|
||||
[Text.UTF8Encoding]::new($false))
|
||||
Clear-DnsClientCache
|
||||
}
|
||||
|
||||
function Enable-ClientDnsOverHttps {
|
||||
param(
|
||||
[Parameter(Mandatory)][ipaddress]$ServerAddress,
|
||||
[Parameter(Mandatory)][string]$DohTemplate,
|
||||
[Parameter(Mandatory)][string]$CertificateBase64
|
||||
)
|
||||
|
||||
if (-not (Get-Command Add-DnsClientDohServerAddress -ErrorAction SilentlyContinue)) {
|
||||
throw 'This Windows build cannot configure DNS over HTTPS. Permit traditional DNS to the supplied server or update Windows, then retry.'
|
||||
}
|
||||
|
||||
$certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
|
||||
[Convert]::FromBase64String($CertificateBase64))
|
||||
$store = [Security.Cryptography.X509Certificates.X509Store]::new(
|
||||
[Security.Cryptography.X509Certificates.StoreName]::Root,
|
||||
[Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine)
|
||||
try {
|
||||
$store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
|
||||
if (-not @($store.Certificates | Where-Object Thumbprint -eq $certificate.Thumbprint).Count) {
|
||||
$store.Add($certificate)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$store.Close()
|
||||
$certificate.Dispose()
|
||||
}
|
||||
|
||||
$existing = Get-DnsClientDohServerAddress -ErrorAction SilentlyContinue |
|
||||
Where-Object ServerAddress -eq $ServerAddress.IPAddressToString |
|
||||
Select-Object -First 1
|
||||
if ($existing) {
|
||||
Set-DnsClientDohServerAddress -ServerAddress $ServerAddress.IPAddressToString `
|
||||
-DohTemplate $DohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true | Out-Null
|
||||
}
|
||||
else {
|
||||
Add-DnsClientDohServerAddress -ServerAddress $ServerAddress.IPAddressToString `
|
||||
-DohTemplate $DohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true | Out-Null
|
||||
}
|
||||
& "$env:SystemRoot\System32\netsh.exe" dnsclient set global doh=yes | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Windows did not enable its global DNS over HTTPS client setting.'
|
||||
}
|
||||
Clear-DnsClientCache
|
||||
}
|
||||
|
||||
function Assert-ClientOperatingSystem {
|
||||
param(
|
||||
[Parameter(Mandatory)]$OperatingSystem,
|
||||
[Parameter(Mandatory)][string]$Edition,
|
||||
[Parameter(Mandatory)][string]$Architecture
|
||||
)
|
||||
if ([int]$OperatingSystem.ProductType -ne 1) {
|
||||
throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.'
|
||||
}
|
||||
if ([int]$OperatingSystem.BuildNumber -lt 14393 -or $Architecture -ne 'AMD64') {
|
||||
throw 'This package requires Windows 10 1607 or later, or Windows 11, running x64 Windows PowerShell.'
|
||||
}
|
||||
if ($Edition -match '^Core' -or $Edition -match 'Home') {
|
||||
throw "Windows edition '$Edition' cannot join an Active Directory domain. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again."
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-TcpPort {
|
||||
param(
|
||||
[Parameter(Mandatory)][ipaddress]$Address,
|
||||
[Parameter(Mandatory)][int]$Port,
|
||||
[int]$TimeoutSeconds = 20
|
||||
)
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
do {
|
||||
if (Test-TcpPort -Address $Address -Port $Port -TimeoutMilliseconds 2000) {
|
||||
return $true
|
||||
}
|
||||
Start-Sleep -Milliseconds 750
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
return $false
|
||||
}
|
||||
|
||||
function Connect-SguAzureP2s {
|
||||
param([Parameter(Mandatory)][string]$ConnectionName)
|
||||
|
||||
$connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection `
|
||||
-ErrorAction SilentlyContinue
|
||||
if (-not $connection) {
|
||||
throw "The all-user VPN connection '$ConnectionName' is not installed. Run Install-SguAzureP2sClient.ps1 in this VM first."
|
||||
}
|
||||
if ($connection.TunnelType -notcontains 'Ikev2' -and $connection.TunnelType -ne 'Ikev2') {
|
||||
throw "The VPN connection '$ConnectionName' is not configured for IKEv2."
|
||||
}
|
||||
if ($connection.ConnectionStatus -ne 'Connected') {
|
||||
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not connect the Azure P2S profile '$ConnectionName'. Verify the machine certificate and that UDP 500/4500 is permitted by the local network."
|
||||
}
|
||||
}
|
||||
$connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection
|
||||
if ($connection.ConnectionStatus -ne 'Connected') {
|
||||
throw "The Azure P2S profile '$ConnectionName' did not reach Connected state."
|
||||
}
|
||||
return $connection
|
||||
}
|
||||
|
||||
Assert-Administrator
|
||||
$operatingSystem = Get-CimInstance Win32_OperatingSystem
|
||||
if ([int]$operatingSystem.ProductType -ne 1) {
|
||||
throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.'
|
||||
}
|
||||
|
||||
$edition = (Get-WindowsEdition -Online).Edition
|
||||
if ($edition -match '^Core' -or $edition -match 'Home') {
|
||||
throw "Windows edition '$edition' cannot join an on-premises Active Directory domain or host RDP. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again."
|
||||
}
|
||||
Assert-ClientOperatingSystem -OperatingSystem $operatingSystem -Edition $edition `
|
||||
-Architecture $env:PROCESSOR_ARCHITECTURE
|
||||
$windowsBuild = [int]$operatingSystem.BuildNumber
|
||||
$windowsName = if ($windowsBuild -ge 22000) { 'Windows 11' } else { 'Windows 10' }
|
||||
Write-Host "$windowsName (build $windowsBuild): unified SGU enrollment."
|
||||
|
||||
if (-not $DomainControllerIPv4Address) {
|
||||
$DomainControllerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address of the SGU domain controller')
|
||||
}
|
||||
if (-not $ComputerOuDn) {
|
||||
$baseDn = (($DomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ','
|
||||
$ComputerOuDn = "OU=Laboratorio,$baseDn"
|
||||
if ($DomainControllerIPv4Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or
|
||||
$DomainControllerIPv4Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.|24\d\.|25[0-5]\.)') {
|
||||
throw 'Enter a reachable unicast IPv4 address for the domain controller.'
|
||||
}
|
||||
$publicDirectEnrollment = $ConnectivityMode -eq 'Direct' -and
|
||||
-not (Test-PrivateIPv4Address -Address $DomainControllerIPv4Address)
|
||||
if ($publicDirectEnrollment) {
|
||||
Write-Host 'Public domain-controller address detected. Direct DNS and domain discovery will be configured automatically.'
|
||||
}
|
||||
|
||||
$packageRoot = $PSScriptRoot
|
||||
Assert-PackageManifest -PackageRoot $packageRoot
|
||||
$packageManifest = Assert-PackageManifest -PackageRoot $packageRoot
|
||||
# Retain the old parameter for existing automation; neither name restricts networking.
|
||||
if ($CompatibilityProfile -ne 'Auto') {
|
||||
Write-Warning 'CompatibilityProfile is deprecated. This package uses the same implementation on Windows 10 and 11.'
|
||||
}
|
||||
$CompatibilityProfile = 'Auto'
|
||||
$scriptsRoot = Join-Path $packageRoot 'payload\scripts'
|
||||
$providerPublishPath = Join-Path $packageRoot 'payload\credential-provider'
|
||||
$runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') `
|
||||
@@ -134,20 +561,67 @@ foreach ($requiredPath in @(
|
||||
if (-not $runtimeInstaller) {
|
||||
throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.'
|
||||
}
|
||||
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enroll with SGU server $DomainControllerIPv4Address")) { return }
|
||||
if ($ClientIPv4Address) {
|
||||
if (-not $NetworkInterfaceAlias -or $ConnectivityMode -eq 'AzureP2S') {
|
||||
throw 'Explicit static IP setup requires -NetworkInterfaceAlias with Direct connectivity. Omit -ClientIPv4Address to preserve the current LAN/VPN configuration.'
|
||||
}
|
||||
$ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||
-DomainControllerAddress $DomainControllerIPv4Address `
|
||||
-RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength
|
||||
}
|
||||
|
||||
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
|
||||
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||
-ServerAddresses $DomainControllerIPv4Address.IPAddressToString
|
||||
if ($ConnectivityMode -eq 'AzureP2S') {
|
||||
$existingVpnConnection = Get-VpnConnection -Name $VpnConnectionName -AllUserConnection `
|
||||
-ErrorAction SilentlyContinue
|
||||
if (-not $existingVpnConnection) {
|
||||
$installerPath = Join-Path $packageRoot 'Install-SguAzureP2sClient.ps1'
|
||||
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) {
|
||||
throw 'Install-SguAzureP2sClient.ps1 is missing from the client bootstrap package.'
|
||||
}
|
||||
foreach ($vpnInput in @(
|
||||
@{ Name = 'VpnProfilePackagePath'; Value = $VpnProfilePackagePath },
|
||||
@{ Name = 'VpnClientCertificatePfxPath'; Value = $VpnClientCertificatePfxPath },
|
||||
@{ Name = 'VpnClientRootCertificatePath'; Value = $VpnClientRootCertificatePath })) {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$vpnInput.Value)) {
|
||||
throw "$($vpnInput.Name) is required the first time an Azure P2S client is enrolled."
|
||||
}
|
||||
}
|
||||
$vpnInstallParameters = @{
|
||||
VpnProfilePackagePath = $VpnProfilePackagePath
|
||||
ClientCertificatePfxPath = $VpnClientCertificatePfxPath
|
||||
ClientRootCertificatePath = $VpnClientRootCertificatePath
|
||||
ConnectionName = $VpnConnectionName
|
||||
AzureNetworkPrefixes = $AzureNetworkPrefixes
|
||||
DomainControllerIPv4Address = $DomainControllerIPv4Address
|
||||
DomainName = $DomainName
|
||||
}
|
||||
if ($VpnClientCertificatePfxPassword) {
|
||||
$vpnInstallParameters.ClientCertificatePfxPassword = $VpnClientCertificatePfxPassword
|
||||
}
|
||||
& $installerPath @vpnInstallParameters | Out-Null
|
||||
}
|
||||
$vpnConnection = Connect-SguAzureP2s -ConnectionName $VpnConnectionName
|
||||
}
|
||||
$selectedInterface = Wait-ClientInterface -RequestedAlias $NetworkInterfaceAlias `
|
||||
-DomainControllerAddress $DomainControllerIPv4Address
|
||||
$NetworkInterfaceAlias = $selectedInterface.InterfaceAlias
|
||||
$ClientIPv4Address = [ipaddress]$selectedInterface.IPAddress
|
||||
Set-ClientServerRoute -SelectedInterface $selectedInterface -DomainControllerAddress $DomainControllerIPv4Address
|
||||
Write-Host "Using $NetworkInterfaceAlias ($ClientIPv4Address)."
|
||||
|
||||
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) {
|
||||
throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP."
|
||||
if (-not (Wait-TcpPort -Address $DomainControllerIPv4Address -Port 5985 -TimeoutSeconds 20)) {
|
||||
throw "The domain controller at $DomainControllerIPv4Address did not accept WinRM on TCP 5985 after 20 seconds. Run the server bootstrap first and verify the selected IP."
|
||||
}
|
||||
|
||||
if (-not $DomainCredential) {
|
||||
$suggestedUser = if ($DomainNetbios) { "$DomainNetbios\Administrator" }
|
||||
elseif ($DomainName) { "Administrator@$DomainName" } else { 'Administrator' }
|
||||
$DomainCredential = Get-Credential `
|
||||
-UserName "$DomainNetbios\Administrator" `
|
||||
-Message "Credential permitted to enroll this computer in $DomainName"
|
||||
-UserName $suggestedUser `
|
||||
-Message "Domain account permitted to enroll this computer (DOMAIN\user or user@domain)"
|
||||
}
|
||||
if (-not $DomainCredential) { throw 'Enrollment cancelled: no domain credential was provided.' }
|
||||
|
||||
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
|
||||
$clientCertificatePath = Join-Path $temporaryRoot 'client.cer'
|
||||
@@ -177,6 +651,12 @@ try {
|
||||
|
||||
$serverIdentity = Invoke-Command -Session $session -ScriptBlock {
|
||||
$computer = Get-CimInstance Win32_ComputerSystem
|
||||
if ([int]$computer.DomainRole -lt 4) { throw 'The supplied server is not an Active Directory domain controller.' }
|
||||
Import-Module ActiveDirectory -ErrorAction Stop
|
||||
$domain = Get-ADDomain -ErrorAction Stop
|
||||
$labOu = Get-ADOrganizationalUnit -LDAPFilter '(ou=Laboratorio)' `
|
||||
-SearchBase $domain.DistinguishedName -SearchScope OneLevel -ErrorAction Stop |
|
||||
Select-Object -First 1
|
||||
$brokerService = Get-Service SGUAuthBroker -ErrorAction SilentlyContinue
|
||||
$rustDeskStatusPath = Join-Path $env:ProgramData 'SGU\RustDesk\server.json'
|
||||
$rustDeskStatus = if (Test-Path -LiteralPath $rustDeskStatusPath -PathType Leaf) {
|
||||
@@ -190,6 +670,8 @@ try {
|
||||
[pscustomobject]@{
|
||||
ComputerName = $env:COMPUTERNAME
|
||||
Domain = $computer.Domain
|
||||
DomainNetbios = $domain.NetBIOSName
|
||||
ComputerContainer = if ($labOu) { $labOu.DistinguishedName } else { $domain.ComputersContainer }
|
||||
BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' }
|
||||
RustDeskServerAddress = if ($rustDeskStatus) { [string]$rustDeskStatus.ServerAddress } else { $null }
|
||||
RustDeskPublicKey = if ($rustDeskStatus) { [string]$rustDeskStatus.PublicKey } else { $null }
|
||||
@@ -197,10 +679,19 @@ try {
|
||||
RustDeskHbbrTask = if ($hbbrTask) { $hbbrTask.State.ToString() } else { 'Missing' }
|
||||
}
|
||||
}
|
||||
if (-not $serverIdentity.Domain -or
|
||||
-not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
if (-not $serverIdentity.Domain -or ($DomainName -and
|
||||
-not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase))) {
|
||||
throw "The server at $DomainControllerIPv4Address belongs to $($serverIdentity.Domain), not $DomainName."
|
||||
}
|
||||
if ($DomainNetbios -and $DomainNetbios -ne $serverIdentity.DomainNetbios) {
|
||||
throw "The supplied NetBIOS domain '$DomainNetbios' does not match '$($serverIdentity.DomainNetbios)'."
|
||||
}
|
||||
$DomainName = [string]$serverIdentity.Domain
|
||||
$DomainNetbios = [string]$serverIdentity.DomainNetbios
|
||||
if (-not $ComputerOuDn) { $ComputerOuDn = [string]$serverIdentity.ComputerContainer }
|
||||
$brokerDnsName = "$brokerRecordName.$DomainName"
|
||||
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
|
||||
Write-Host "Discovered domain: $DomainName ($DomainNetbios). Computer container: $ComputerOuDn"
|
||||
if ($serverIdentity.BrokerService -ne 'Running') {
|
||||
throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)."
|
||||
}
|
||||
@@ -211,6 +702,146 @@ try {
|
||||
throw "The RustDesk server is not ready on $($serverIdentity.ComputerName). Run the current server bootstrap first."
|
||||
}
|
||||
|
||||
$targetComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
|
||||
Invoke-Command -Session $session -ScriptBlock {
|
||||
param($ComputerName, $ComputerPath)
|
||||
Import-Module ActiveDirectory -ErrorAction Stop
|
||||
$samAccountName = "$ComputerName`$"
|
||||
$account = Get-ADComputer -Filter "SamAccountName -eq '$samAccountName'" |
|
||||
Select-Object -First 1
|
||||
if (-not $account) {
|
||||
New-ADComputer -Name $ComputerName -SamAccountName $samAccountName `
|
||||
-Path $ComputerPath -Enabled $true -ErrorAction Stop
|
||||
}
|
||||
} -ArgumentList $targetComputerName,$ComputerOuDn
|
||||
|
||||
if ($publicDirectEnrollment) {
|
||||
$directDns = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($DnsDomain, $DomainControllerComputerName)
|
||||
|
||||
$domainControllerFqdn = "$DomainControllerComputerName.$DnsDomain".ToLowerInvariant()
|
||||
$dohTemplate = "https://${domainControllerFqdn}:443/dns-query"
|
||||
$dohCommand = Get-Command Set-DnsServerEncryptionProtocol -ErrorAction SilentlyContinue
|
||||
if (-not $dohCommand) {
|
||||
return [pscustomobject]@{
|
||||
DohSupported = $false
|
||||
DomainControllerFqdn = $domainControllerFqdn
|
||||
}
|
||||
}
|
||||
|
||||
$certificate = Get-ChildItem Cert:\LocalMachine\My |
|
||||
Where-Object {
|
||||
$_.Subject -eq "CN=$domainControllerFqdn" -and
|
||||
$_.HasPrivateKey -and
|
||||
$_.NotAfter -gt (Get-Date).AddDays(30)
|
||||
} |
|
||||
Sort-Object NotAfter -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $certificate) {
|
||||
$certificate = New-SelfSignedCertificate `
|
||||
-DnsName $domainControllerFqdn `
|
||||
-CertStoreLocation Cert:\LocalMachine\My `
|
||||
-FriendlyName 'SGU Direct Enrollment DoH' `
|
||||
-Type SSLServerAuthentication `
|
||||
-KeyAlgorithm RSA `
|
||||
-KeyLength 2048 `
|
||||
-HashAlgorithm SHA256 `
|
||||
-KeyExportPolicy NonExportable `
|
||||
-NotAfter (Get-Date).AddYears(2)
|
||||
}
|
||||
|
||||
$bindingOutput = @(& "$env:SystemRoot\System32\netsh.exe" http show sslcert ipport=0.0.0.0:443 2>&1)
|
||||
$bindingExists = $LASTEXITCODE -eq 0
|
||||
$normalizedBinding = (($bindingOutput -join '') -replace '[^0-9A-Fa-f]', '').ToUpperInvariant()
|
||||
$normalizedThumbprint = ($certificate.Thumbprint -replace ' ', '').ToUpperInvariant()
|
||||
if ($bindingExists -and -not $normalizedBinding.Contains($normalizedThumbprint)) {
|
||||
throw 'TCP 443 already has an HTTPS certificate binding that is not managed by SGU. Free that port or configure SGU DoH before enrolling this client.'
|
||||
}
|
||||
if (-not $bindingExists) {
|
||||
& "$env:SystemRoot\System32\netsh.exe" http add sslcert `
|
||||
ipport=0.0.0.0:443 "certhash=$($certificate.Thumbprint)" `
|
||||
"appid={47E9CF26-79B7-4C9D-A0AE-ADFA22447A41}" certstorename=MY | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Could not bind the SGU DoH certificate to TCP 443.' }
|
||||
}
|
||||
|
||||
$dnsChanged = $false
|
||||
$encryption = Get-DnsServerEncryptionProtocol
|
||||
if (-not $encryption.EnableDoh -or $encryption.UriTemplate -ne $dohTemplate) {
|
||||
Set-DnsServerEncryptionProtocol -EnableDoh $true -UriTemplate $dohTemplate
|
||||
$dnsChanged = $true
|
||||
}
|
||||
|
||||
Import-Module ActiveDirectory -ErrorAction Stop
|
||||
$domainController = Get-ADComputer -Identity $DomainControllerComputerName `
|
||||
-Properties ServicePrincipalName
|
||||
if (@($domainController.ServicePrincipalName) -notcontains "cifs/$DnsDomain") {
|
||||
& "$env:SystemRoot\System32\setspn.exe" -S "cifs/$DnsDomain" $DomainControllerComputerName | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not register cifs/$DnsDomain on $DomainControllerComputerName." }
|
||||
}
|
||||
|
||||
$lanmanPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters'
|
||||
$optionalNames = @((Get-ItemProperty $lanmanPath -Name OptionalNames `
|
||||
-ErrorAction SilentlyContinue).OptionalNames | Where-Object { $_ })
|
||||
$serverChanged = $false
|
||||
if ($optionalNames -notcontains $DnsDomain) {
|
||||
New-ItemProperty -Path $lanmanPath -Name OptionalNames -PropertyType MultiString `
|
||||
-Value (@($optionalNames) + $DnsDomain) -Force | Out-Null
|
||||
$serverChanged = $true
|
||||
}
|
||||
New-ItemProperty -Path $lanmanPath -Name DisableStrictNameChecking `
|
||||
-PropertyType DWord -Value 1 -Force | Out-Null
|
||||
|
||||
if ($serverChanged) {
|
||||
Restart-Service LanmanServer -Force
|
||||
Start-Service Netlogon
|
||||
}
|
||||
if ($dnsChanged) {
|
||||
Restart-Service DNS -Force
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
DohSupported = $true
|
||||
DohTemplate = $dohTemplate
|
||||
DohCertificateBase64 = [Convert]::ToBase64String($certificate.RawData)
|
||||
DomainControllerFqdn = $domainControllerFqdn
|
||||
}
|
||||
} -ArgumentList $DomainName,$serverIdentity.ComputerName
|
||||
|
||||
$directHostNames = @(
|
||||
$directDns.DomainControllerFqdn,
|
||||
$DomainName,
|
||||
$brokerDnsName
|
||||
)
|
||||
if ([string]$serverIdentity.RustDeskServerAddress -match '[A-Za-z]') {
|
||||
$directHostNames += [string]$serverIdentity.RustDeskServerAddress
|
||||
}
|
||||
Set-ClientHostMappings -ServerAddress $DomainControllerIPv4Address `
|
||||
-HostNames $directHostNames
|
||||
|
||||
if ($directDns.DohSupported -and
|
||||
(Get-Command Add-DnsClientDohServerAddress -ErrorAction SilentlyContinue)) {
|
||||
Enable-ClientDnsOverHttps -ServerAddress $DomainControllerIPv4Address `
|
||||
-DohTemplate $directDns.DohTemplate `
|
||||
-CertificateBase64 $directDns.DohCertificateBase64
|
||||
}
|
||||
elseif (-not $directDns.DohSupported) {
|
||||
Write-Warning 'The server does not support DNS over HTTPS; enrollment will use traditional DNS.'
|
||||
}
|
||||
else {
|
||||
Write-Warning 'This Windows build does not support DNS over HTTPS; enrollment will use traditional DNS.'
|
||||
}
|
||||
}
|
||||
Set-ClientDomainDns -DnsDomain $DomainName -ServerAddress $DomainControllerIPv4Address
|
||||
if (-not (Test-ClientDomainDns -DnsDomain $DomainName)) {
|
||||
throw "The domain DNS service at $DomainControllerIPv4Address did not return an Active Directory SRV record. For a public server, permit DNS over HTTPS on TCP 443 or traditional DNS from this client network."
|
||||
}
|
||||
foreach ($port in @(53, 88, 135, 389, 445, 8443)) {
|
||||
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port $port -TimeoutMilliseconds 2000)) {
|
||||
throw "Server $DomainControllerIPv4Address is reachable, but required TCP port $port is unavailable. Check AD/SGU services and the LAN/VPN firewall. Domain join has not started."
|
||||
}
|
||||
}
|
||||
|
||||
$certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME"
|
||||
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
|
||||
Where-Object {
|
||||
@@ -289,9 +920,12 @@ try {
|
||||
DomainCredential = $DomainCredential
|
||||
DomainName = $DomainName
|
||||
DomainNetbios = $DomainNetbios
|
||||
DomainControllerDnsName = "$($serverIdentity.ComputerName).$DomainName"
|
||||
ComputerOuDn = $ComputerOuDn
|
||||
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
||||
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
|
||||
DomainDnsConfigured = $true
|
||||
ConnectivityMode = $ConnectivityMode
|
||||
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
|
||||
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
|
||||
RustDeskServerAddress = $serverIdentity.RustDeskServerAddress
|
||||
@@ -352,10 +986,12 @@ finally {
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force
|
||||
}
|
||||
if (-not $winRmWasRunning) {
|
||||
Stop-Service WinRM -Force -ErrorAction SilentlyContinue
|
||||
Stop-Service WinRM -Force -NoWait -WarningAction SilentlyContinue `
|
||||
-ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$DomainCredential = $null
|
||||
$VpnClientCertificatePfxPassword = $null
|
||||
}
|
||||
|
||||
if ($SkipRestart) {
|
||||
@@ -365,6 +1001,9 @@ if ($SkipRestart) {
|
||||
ProviderInstalled = $true
|
||||
ClientCertificateRegistered = $true
|
||||
BrokerEndpoint = $brokerEndpoint
|
||||
ConnectivityMode = $ConnectivityMode
|
||||
CompatibilityProfile = $CompatibilityProfile
|
||||
VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null }
|
||||
RestartRequired = $true
|
||||
RustDesk = if ($result) { $result.RustDesk } else { $null }
|
||||
EnrollmentResult = $result
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$')]
|
||||
[string]$ClientName,
|
||||
[string]$OutputDirectory = (Join-Path $PSScriptRoot '..\artifacts\azure-p2s'),
|
||||
[securestring]$ClientPfxPassword,
|
||||
[string]$RootSubject = 'CN=SGU Azure P2S Root',
|
||||
[ValidateRange(1, 10)]
|
||||
[int]$ClientValidityYears = 2,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$resolvedOutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
|
||||
New-Item -ItemType Directory -Path $resolvedOutputDirectory -Force | Out-Null
|
||||
$rootCertificatePath = Join-Path $resolvedOutputDirectory 'sgu-azure-p2s-root.cer'
|
||||
$clientCertificatePath = Join-Path $resolvedOutputDirectory "sgu-azure-p2s-$ClientName.pfx"
|
||||
if ((Test-Path -LiteralPath $clientCertificatePath -PathType Leaf) -and -not $Force) {
|
||||
throw "$clientCertificatePath already exists. Use -Force only when you intend to replace that exported client credential."
|
||||
}
|
||||
|
||||
if (-not $ClientPfxPassword) {
|
||||
$ClientPfxPassword = Read-Host 'Password that will protect the exported P2S client certificate' -AsSecureString
|
||||
}
|
||||
|
||||
$rootCertificate = Get-ChildItem Cert:\CurrentUser\My |
|
||||
Where-Object {
|
||||
$_.Subject -eq $RootSubject -and
|
||||
$_.HasPrivateKey -and
|
||||
$_.NotAfter -gt (Get-Date).AddYears($ClientValidityYears)
|
||||
} |
|
||||
Sort-Object NotAfter -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $rootCertificate) {
|
||||
if (-not $PSCmdlet.ShouldProcess($RootSubject, 'Create a non-exportable Azure P2S root certificate authority')) {
|
||||
return
|
||||
}
|
||||
$rootCertificate = New-SelfSignedCertificate `
|
||||
-Type Custom `
|
||||
-Subject $RootSubject `
|
||||
-CertStoreLocation Cert:\CurrentUser\My `
|
||||
-KeyAlgorithm RSA `
|
||||
-KeyLength 4096 `
|
||||
-HashAlgorithm SHA256 `
|
||||
-KeySpec Signature `
|
||||
-KeyExportPolicy NonExportable `
|
||||
-KeyUsage CertSign,CRLSign,DigitalSignature `
|
||||
-NotAfter (Get-Date).AddYears(10) `
|
||||
-TextExtension @('2.5.29.19={critical}{text}ca=1&pathlength=1')
|
||||
}
|
||||
|
||||
if (-not $PSCmdlet.ShouldProcess($ClientName, 'Issue and export an Azure P2S machine certificate')) {
|
||||
return
|
||||
}
|
||||
|
||||
$clientSubject = "CN=SGU Azure P2S $ClientName"
|
||||
$clientCertificate = New-SelfSignedCertificate `
|
||||
-Type Custom `
|
||||
-Subject $clientSubject `
|
||||
-DnsName "sgu-p2s-$ClientName" `
|
||||
-Signer $rootCertificate `
|
||||
-CertStoreLocation Cert:\CurrentUser\My `
|
||||
-KeyAlgorithm RSA `
|
||||
-KeyLength 3072 `
|
||||
-HashAlgorithm SHA256 `
|
||||
-KeySpec Signature `
|
||||
-KeyExportPolicy Exportable `
|
||||
-KeyUsage DigitalSignature `
|
||||
-NotAfter (Get-Date).AddYears($ClientValidityYears) `
|
||||
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.2')
|
||||
|
||||
Export-Certificate -Cert $rootCertificate -FilePath $rootCertificatePath -Force | Out-Null
|
||||
Export-PfxCertificate -Cert $clientCertificate -FilePath $clientCertificatePath `
|
||||
-Password $ClientPfxPassword -ChainOption BuildChain -CryptoAlgorithmOption AES256_SHA256 `
|
||||
-Force | Out-Null
|
||||
|
||||
[pscustomobject]@{
|
||||
RootCertificatePath = $rootCertificatePath
|
||||
RootCertificateThumbprint = $rootCertificate.Thumbprint
|
||||
RootCertificateData = [Convert]::ToBase64String($rootCertificate.RawData)
|
||||
ClientName = $ClientName
|
||||
ClientCertificatePath = $clientCertificatePath
|
||||
ClientCertificateThumbprint = $clientCertificate.Thumbprint
|
||||
ClientCertificateExpires = $clientCertificate.NotAfter
|
||||
RootPrivateKeyExportable = $false
|
||||
}
|
||||
@@ -33,7 +33,10 @@ function Write-PackageManifest {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$PackageRoot,
|
||||
[Parameter(Mandatory)][string]$PackageVersion,
|
||||
[Parameter(Mandatory)][string]$PackageKind
|
||||
[Parameter(Mandatory)][string]$PackageKind,
|
||||
[ValidateSet('Auto')]
|
||||
[string]$CompatibilityProfile,
|
||||
[string]$TargetOperatingSystem
|
||||
)
|
||||
|
||||
$resolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path.TrimEnd('\')
|
||||
@@ -48,13 +51,19 @@ function Write-PackageManifest {
|
||||
}
|
||||
})
|
||||
$manifest = [ordered]@{
|
||||
SchemaVersion = 1
|
||||
SchemaVersion = 2
|
||||
Product = 'SGU Credential Provider'
|
||||
PackageKind = $PackageKind
|
||||
Version = $PackageVersion
|
||||
CreatedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
Files = $files
|
||||
}
|
||||
if ($CompatibilityProfile) {
|
||||
$manifest['CompatibilityProfile'] = $CompatibilityProfile
|
||||
}
|
||||
if ($TargetOperatingSystem) {
|
||||
$manifest['TargetOperatingSystem'] = $TargetOperatingSystem
|
||||
}
|
||||
[IO.File]::WriteAllText(
|
||||
(Join-Path $resolvedPackageRoot 'package-manifest.json'),
|
||||
($manifest | ConvertTo-Json -Depth 6),
|
||||
@@ -78,19 +87,25 @@ if (-not $runtimeInstaller) {
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null
|
||||
$clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version"
|
||||
$clientRoot = Join-Path $resolvedOutputRoot "sgu-windows-client-bootstrap-$Version"
|
||||
$serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version"
|
||||
$linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version"
|
||||
$azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version"
|
||||
$clientZip = "$clientRoot.zip"
|
||||
$serverZip = "$serverRoot.zip"
|
||||
$linuxClientZip = "$linuxClientRoot.zip"
|
||||
foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$clientZip,$serverZip,$linuxClientZip)) {
|
||||
$azureZip = "$azureRoot.zip"
|
||||
foreach ($target in @(
|
||||
$clientRoot,$serverRoot,$linuxClientRoot,$azureRoot,
|
||||
$clientZip,$serverZip,$linuxClientZip,$azureZip)) {
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
throw "Release target already exists: $target"
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory `
|
||||
-Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot `
|
||||
-Force | Out-Null
|
||||
$welcomeFontNames = @(
|
||||
'IndivisaTextSans-Regular.otf',
|
||||
'IndivisaTextSans-Bold.otf',
|
||||
@@ -112,6 +127,7 @@ $clientScripts = @(
|
||||
'Install-SguRustDeskClient.ps1',
|
||||
'Register-SguClientCertificate.ps1',
|
||||
'Repair-SguClientEnrollment.ps1',
|
||||
'Set-SguStandardLocalUser.ps1',
|
||||
'Test-SguClientEnrollment.ps1'
|
||||
)
|
||||
foreach ($scriptName in $clientScripts) {
|
||||
@@ -134,7 +150,19 @@ foreach ($fontName in $welcomeFontNames) {
|
||||
}
|
||||
Copy-RequiredFile -Source $runtimeInstaller.FullName `
|
||||
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
|
||||
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
|
||||
|
||||
# One Windows implementation supports existing LAN/VPN routes and optional Azure P2S.
|
||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') `
|
||||
-Destination (Join-Path $clientRoot 'Start-SguAzureClientEnrollment.cmd')
|
||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') `
|
||||
-Destination (Join-Path $clientRoot 'Install-SguAzureP2sClient.ps1')
|
||||
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\client-enrollment.md') `
|
||||
-Destination (Join-Path $clientRoot 'README.md')
|
||||
Copy-Item -Path (Join-Path $repositoryRoot 'docs\*.md') -Destination $clientRoot
|
||||
|
||||
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version `
|
||||
-PackageKind WindowsClient -CompatibilityProfile Auto `
|
||||
-TargetOperatingSystem 'Windows 10 1607+ or Windows 11; x64 Pro, Enterprise, or Education'
|
||||
Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip `
|
||||
-CompressionLevel Optimal
|
||||
|
||||
@@ -163,6 +191,8 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Initialize-SguDomainControll
|
||||
-Destination (Join-Path $serverRoot 'Initialize-SguDomainController.ps1')
|
||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd') `
|
||||
-Destination (Join-Path $serverRoot 'Start-SguServerBootstrap.cmd')
|
||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureServerBootstrap.cmd') `
|
||||
-Destination (Join-Path $serverRoot 'Start-SguAzureServerBootstrap.cmd')
|
||||
$serverScripts = @(
|
||||
'Deploy-AuthBroker.ps1',
|
||||
'Enable-SguServerRemoteManagement.ps1',
|
||||
@@ -211,22 +241,46 @@ Write-PackageManifest -PackageRoot $serverRoot -PackageVersion $Version -Package
|
||||
Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip `
|
||||
-CompressionLevel Optimal
|
||||
|
||||
# Azure infrastructure is packaged separately because it runs on the trusted
|
||||
# administrator workstation, not inside the domain controller or a client.
|
||||
$azureScriptsRoot = Join-Path $azureRoot 'scripts'
|
||||
$azureInfrastructureRoot = Join-Path $azureRoot 'infra\azure'
|
||||
New-Item -ItemType Directory -Path $azureScriptsRoot,$azureInfrastructureRoot -Force | Out-Null
|
||||
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'infra\azure\main.bicep') `
|
||||
-Destination (Join-Path $azureInfrastructureRoot 'main.bicep')
|
||||
foreach ($scriptName in @(
|
||||
'New-SguAzureP2sCertificates.ps1',
|
||||
'Deploy-SguAzureInfrastructure.ps1',
|
||||
'Get-SguAzureP2sPackage.ps1',
|
||||
'Install-SguAzureP2sClient.ps1')) {
|
||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot $scriptName) `
|
||||
-Destination (Join-Path $azureScriptsRoot $scriptName)
|
||||
}
|
||||
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\azure-vpn-deployment.md') `
|
||||
-Destination (Join-Path $azureRoot 'README.md')
|
||||
Write-PackageManifest -PackageRoot $azureRoot -PackageVersion $Version -PackageKind AzureInfrastructure
|
||||
Compress-Archive -Path (Join-Path $azureRoot '*') -DestinationPath $azureZip `
|
||||
-CompressionLevel Optimal
|
||||
|
||||
$checksums = @(
|
||||
("{0} {1}" -f (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash, (Split-Path $clientZip -Leaf))
|
||||
("{0} {1}" -f (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash, (Split-Path $serverZip -Leaf))
|
||||
("{0} {1}" -f (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash, (Split-Path $linuxClientZip -Leaf))
|
||||
("{0} {1}" -f (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash, (Split-Path $azureZip -Leaf))
|
||||
)
|
||||
$checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
|
||||
[IO.File]::WriteAllLines($checksumsPath, $checksums, [Text.UTF8Encoding]::new($false))
|
||||
|
||||
[pscustomobject]@{
|
||||
Version = $Version
|
||||
ClientPackage = $clientZip
|
||||
ClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash
|
||||
WindowsClientPackage = $clientZip
|
||||
WindowsClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash
|
||||
LinuxClientPackage = $linuxClientZip
|
||||
LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash
|
||||
ServerPackage = $serverZip
|
||||
ServerSha256 = (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash
|
||||
AzureInfrastructurePackage = $azureZip
|
||||
AzureInfrastructureSha256 = (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash
|
||||
Checksums = $checksumsPath
|
||||
RuntimeInstaller = $runtimeInstaller.Name
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tagName = "v$Version"
|
||||
$assetPaths = @(
|
||||
(Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "sgu-windows-client-bootstrap-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "sgu-azure-infrastructure-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "SHA256SUMS-$Version.txt")
|
||||
)
|
||||
foreach ($assetPath in $assetPaths) {
|
||||
@@ -27,7 +28,9 @@ foreach ($assetPath in $assetPaths) {
|
||||
}
|
||||
|
||||
$token = $env:GITEA_TOKEN
|
||||
if (-not $token) {
|
||||
$authorizationScheme = 'token'
|
||||
$authorizationParameter = $token
|
||||
if (-not $authorizationParameter) {
|
||||
$credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n"
|
||||
$credentialOutput = $credentialInput | & git credential fill
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
@@ -40,17 +43,25 @@ if (-not $token) {
|
||||
$credentialValues[$parts[0]] = $parts[1]
|
||||
}
|
||||
}
|
||||
$token = $credentialValues.password
|
||||
if ($credentialValues.username -and $credentialValues.password) {
|
||||
$authorizationScheme = 'Basic'
|
||||
$basicCredential = '{0}:{1}' -f $credentialValues.username,$credentialValues.password
|
||||
$authorizationParameter = [Convert]::ToBase64String(
|
||||
[Text.Encoding]::UTF8.GetBytes($basicCredential))
|
||||
$basicCredential = $null
|
||||
}
|
||||
if (-not $token) {
|
||||
throw 'No Gitea token is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.'
|
||||
}
|
||||
if (-not $authorizationParameter) {
|
||||
throw 'No Gitea credential is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.'
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.Net.Http
|
||||
$handler = [Net.Http.HttpClientHandler]::new()
|
||||
$client = [Net.Http.HttpClient]::new($handler)
|
||||
$client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/')
|
||||
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('token', $token)
|
||||
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new(
|
||||
$authorizationScheme,
|
||||
$authorizationParameter)
|
||||
$client.DefaultRequestHeaders.UserAgent.ParseAdd('SGU-CredentialProvider-Release/1.0')
|
||||
|
||||
function Invoke-GiteaJson {
|
||||
@@ -100,9 +111,20 @@ Bootstrap reproducible para el laboratorio SGU.
|
||||
|
||||
- **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema.
|
||||
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
|
||||
- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque.
|
||||
- `sgu-windows-client-bootstrap-$Version.zip`: paquete único para Windows 10 1607+ y Windows 11 x64 Pro, Enterprise o Education, con LAN, VPN existente y Azure P2S opcional.
|
||||
- Doble clic en `Start-SguClientEnrollment.cmd`, IP del servidor y credenciales: descubre el dominio autenticado, comprueba las interfaces y rutas disponibles y conserva DHCP, las IP del cliente y el DNS de Internet. Ya no solicita una IP del cliente.
|
||||
- Si la IP del DC es pública, configura automáticamente DoH autenticado, confianza del certificado, NRPT y nombres del bosque antes de unir el equipo; funciona con cualquier interfaz que pueda alcanzar el servidor.
|
||||
- Conserva seguridad mTLS, Credential Provider, cuenta estándar, RustDesk, supervisión y autorreparación. El servidor puede ser accesible por LAN, una VPN ya conectada o un CIDR público autorizado.
|
||||
- `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.
|
||||
- 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 estos grupos de seguridad de forma idempotente.
|
||||
- `sgu-azure-infrastructure-$Version.zip`: despliega mediante Bicep una VM Windows Server 2025, red privada e IP pública protegida por NSG; Azure VPN Gateway P2S es opcional.
|
||||
- El modo directo recibe una lista explícita de CIDR públicos, la replica en NSG y Windows Firewall y deja cerrados los puertos de enrolamiento cuando la lista está vacía.
|
||||
- Windows 10 y 11 pueden instalar el perfil IKEv2 de todos los usuarios con certificado de máquina y DNS dividido del dominio; la disponibilidad antes del inicio de sesión depende del perfil y de las políticas del equipo.
|
||||
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea cada grupo dentro de la OU de su rol y migra idempotentemente cualquier grupo heredado sin cambiar su SID.
|
||||
- El Auth Broker resuelve la dirección guardada de administrativos y docentes mediante `GetDireccion`, `GetLocalidadListado` y `GetColoniasListado`, evitando conservar los valores transitorios `Seleccione...` de los controles dinámicos de SGU.
|
||||
- El enrolamiento y la reparación de clientes Windows crean y verifican idempotentemente la cuenta local estándar `alumno`, sin pertenencia al grupo de administradores.
|
||||
- La descripción de la cuenta local administrada respeta el límite de 48 caracteres de Windows 10 Enterprise.
|
||||
- La validación de expiración de contraseña usa el indicador de cuenta compatible con Windows 10 y 11, en lugar de una propiedad que Windows 10 no expone.
|
||||
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
|
||||
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
|
||||
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
||||
|
||||
@@ -155,6 +177,8 @@ Las contraseñas se solicitan de forma interactiva y no se escriben en archivos
|
||||
}
|
||||
finally {
|
||||
$token = $null
|
||||
$authorizationParameter = $null
|
||||
$credentialValues = $null
|
||||
$client.Dispose()
|
||||
$handler.Dispose()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#Requires -Version 5.1
|
||||
#Requires -RunAsAdministrator
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConnectionName = 'SGU Azure Device',
|
||||
[ValidateRange(30,600)][int]$WaitSeconds = 180
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$computer = Get-CimInstance Win32_ComputerSystem
|
||||
if (-not $computer.PartOfDomain) { throw 'The device must already be joined to its domain.' }
|
||||
$logPath = Join-Path $env:ProgramData 'SGU\Enrollment\azure-domain-connectivity.json'
|
||||
$deadline = (Get-Date).AddSeconds($WaitSeconds)
|
||||
$restarted = $false
|
||||
$controller = $null
|
||||
try {
|
||||
do {
|
||||
$vpn = Get-VpnConnection -Name $ConnectionName -AllUserConnection -ErrorAction SilentlyContinue
|
||||
$reachable = $false
|
||||
if ($vpn -and $vpn.ConnectionStatus -eq 'Connected') {
|
||||
$record = Resolve-DnsName "_ldap._tcp.dc._msdcs.$($computer.Domain)" -Type SRV -ErrorAction SilentlyContinue |
|
||||
Where-Object Type -eq 'SRV' | Select-Object -First 1
|
||||
if ($record) {
|
||||
$controller = $record.NameTarget.TrimEnd('.')
|
||||
$socket = [Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$connect = $socket.BeginConnect($controller, 389, $null, $null)
|
||||
if ($connect.AsyncWaitHandle.WaitOne(2000)) {
|
||||
$socket.EndConnect($connect)
|
||||
$reachable = $socket.Connected
|
||||
}
|
||||
} catch { $reachable = $false }
|
||||
finally { $socket.Dispose() }
|
||||
}
|
||||
}
|
||||
if ($reachable) { break }
|
||||
Start-Sleep -Seconds 5
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
if (-not $reachable) { throw "The VPN and a domain controller were not reachable within $WaitSeconds seconds." }
|
||||
|
||||
# An early Netlogon attempt can remain failed after the device VPN connects.
|
||||
# Refresh only that service, after confirming the domain is reachable.
|
||||
if (-not (Test-ComputerSecureChannel -Server $controller)) {
|
||||
Restart-Service -Name Netlogon
|
||||
$restarted = $true
|
||||
}
|
||||
$secure = $false
|
||||
for ($attempt = 0; $attempt -lt 6; $attempt++) {
|
||||
$secure = Test-ComputerSecureChannel -Server $controller
|
||||
if ($secure) { break }
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
if (-not $secure) { throw 'The domain is reachable but the secure channel is still invalid. Administrative repair is required.' }
|
||||
$guard = Get-ScheduledTask -TaskName 'SGU-CredentialProvider-EnrollmentGuard' -ErrorAction SilentlyContinue
|
||||
$guardResult = $null
|
||||
if ($guard) {
|
||||
# Domain principal lookup can recover after the secure channel itself.
|
||||
# Await the guard and retry a transient failure instead of reporting
|
||||
# success while its asynchronous repair is still running or failed.
|
||||
$guardDeadline = (Get-Date).AddMinutes(3)
|
||||
do {
|
||||
$guard = Get-ScheduledTask -TaskName $guard.TaskName
|
||||
if ($guard.State -notin @('Running','Queued')) {
|
||||
$previousRun = (Get-ScheduledTaskInfo -TaskName $guard.TaskName).LastRunTime
|
||||
Start-ScheduledTask -InputObject $guard
|
||||
do {
|
||||
Start-Sleep -Seconds 2
|
||||
$guard = Get-ScheduledTask -TaskName $guard.TaskName
|
||||
$info = Get-ScheduledTaskInfo -TaskName $guard.TaskName
|
||||
} while (($info.LastRunTime -le $previousRun -or $guard.State -in @('Running','Queued')) -and (Get-Date) -lt $guardDeadline)
|
||||
if ($info.LastRunTime -gt $previousRun -and $guard.State -notin @('Running','Queued')) {
|
||||
$guardResult = $info.LastTaskResult
|
||||
if ($guardResult -eq 0) { break }
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 10
|
||||
} while ((Get-Date) -lt $guardDeadline)
|
||||
if ($guardResult -ne 0) { throw "The secure channel recovered, but the enrollment guard did not succeed (result $guardResult)." }
|
||||
}
|
||||
[pscustomobject]@{
|
||||
CheckedAt = (Get-Date).ToString('o')
|
||||
ComputerName = $computer.Name
|
||||
Domain = $computer.Domain
|
||||
DomainController = $controller
|
||||
ConnectionName = $ConnectionName
|
||||
NetlogonRestarted = $restarted
|
||||
SecureChannel = $secure
|
||||
EnrollmentGuardResult = $guardResult
|
||||
} | ConvertTo-Json | Set-Content -LiteralPath $logPath
|
||||
} catch {
|
||||
[pscustomobject]@{
|
||||
CheckedAt = (Get-Date).ToString('o')
|
||||
ConnectionName = $ConnectionName
|
||||
NetlogonRestarted = $restarted
|
||||
SecureChannel = $false
|
||||
Error = $_.Exception.Message
|
||||
} | ConvertTo-Json | Set-Content -LiteralPath $logPath
|
||||
throw
|
||||
}
|
||||
@@ -11,9 +11,11 @@ $installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
|
||||
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
|
||||
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
|
||||
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
|
||||
$localUserScript = Join-Path $enrollmentRoot 'Set-SguStandardLocalUser.ps1'
|
||||
|
||||
$before = & $testScript
|
||||
if (-not $before.IsValid) {
|
||||
& $localUserScript | Out-Null
|
||||
$installParams = @{
|
||||
PublishPath = [string]$configuration.PublishPath
|
||||
BrokerEndpoint = [string]$configuration.BrokerEndpoint
|
||||
@@ -30,7 +32,16 @@ if (-not $before.IsValid) {
|
||||
}
|
||||
|
||||
$computer = Get-CimInstance Win32_ComputerSystem
|
||||
$domainReady = $false
|
||||
if ($computer.PartOfDomain) {
|
||||
try {
|
||||
$domainReady = [bool](Test-ComputerSecureChannel -ErrorAction Stop)
|
||||
}
|
||||
catch {
|
||||
$domainReady = $false
|
||||
}
|
||||
}
|
||||
if ($domainReady) {
|
||||
& $remoteAccessScript `
|
||||
-RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) `
|
||||
-EnableAdministrativeFirewallGroups | Out-Null
|
||||
@@ -43,7 +54,7 @@ if ($configuration.RustDeskServerAddress -and $configuration.RustDeskServerPubli
|
||||
}
|
||||
|
||||
$verificationParams = @{}
|
||||
if ($computer.PartOfDomain) {
|
||||
if ($domainReady) {
|
||||
$verificationParams.RequireDomainJoined = $true
|
||||
$verificationParams.RequireRemoteAccess = $true
|
||||
$verificationParams.RemoteDesktopPrincipal = [string]$configuration.RemoteDesktopPrincipal
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
#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')
|
||||
}
|
||||
|
||||
function Get-LocalGroupMemberSid {
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
|
||||
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,group")
|
||||
foreach ($member in @($group.psbase.Invoke('Members'))) {
|
||||
try {
|
||||
$sidBytes = $member.GetType().InvokeMember(
|
||||
'objectSid',
|
||||
[Reflection.BindingFlags]::GetProperty,
|
||||
$null,
|
||||
$member,
|
||||
$null)
|
||||
if ($sidBytes) {
|
||||
([Security.Principal.SecurityIdentifier]::new([byte[]]$sidBytes, 0)).Value
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# An orphaned domain SID can no longer resolve after a forest is
|
||||
# rebuilt. Other members must remain inspectable and unchanged.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$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-LocalGroupMemberSid -Name $administratorsGroup.Name)
|
||||
if ($administratorMembers -contains $user.SID.Value) {
|
||||
([ADSI]("WinNT://$env:COMPUTERNAME/$($administratorsGroup.Name),group")).Remove(
|
||||
"WinNT://$env:COMPUTERNAME/$userName,user")
|
||||
}
|
||||
|
||||
$standardMembers = @(Get-LocalGroupMemberSid -Name $usersGroup.Name)
|
||||
if ($standardMembers -notcontains $user.SID.Value) {
|
||||
([ADSI]("WinNT://$env:COMPUTERNAME/$($usersGroup.Name),group")).Add(
|
||||
"WinNT://$env:COMPUTERNAME/$userName,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-LocalGroupMemberSid -Name $verifiedAdministratorsGroup.Name)
|
||||
$verifiedUsers = @(Get-LocalGroupMemberSid -Name $verifiedUsersGroup.Name)
|
||||
if ($verifiedAdministrators -contains $verifiedUser.SID.Value) {
|
||||
throw "The local account '$userName' still belongs to the local Administrators group."
|
||||
}
|
||||
if ($verifiedUsers -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
|
||||
}
|
||||
@@ -8,6 +8,8 @@ param(
|
||||
[string]$ComputerName = $env:COMPUTERNAME,
|
||||
[string]$Location,
|
||||
[string]$OrganizationalUnit,
|
||||
[ValidateSet('Male', 'Female')]
|
||||
[string]$Gender,
|
||||
[ValidateRange(640, 16384)]
|
||||
[int]$CanvasWidth,
|
||||
[ValidateRange(480, 16384)]
|
||||
@@ -67,6 +69,22 @@ function Get-ImmediateOrganizationalUnit {
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-SguGenderFromInfo {
|
||||
param([string]$Info)
|
||||
|
||||
if (-not $Info) {
|
||||
return $null
|
||||
}
|
||||
|
||||
foreach ($line in $Info -split '\r?\n') {
|
||||
if ($line -match '^\s*SGU-Gender:\s*(Male|Female)\s*$') {
|
||||
return [Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase(
|
||||
$Matches[1].ToLowerInvariant())
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-DirectoryWelcomeMetadata {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserName,
|
||||
@@ -93,6 +111,7 @@ function Get-DirectoryWelcomeMetadata {
|
||||
$userSearcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName={0}))' -f `
|
||||
(ConvertTo-LdapFilterValue -Value $UserName)
|
||||
[void]$userSearcher.PropertiesToLoad.Add('displayName')
|
||||
[void]$userSearcher.PropertiesToLoad.Add('info')
|
||||
$userResult = $userSearcher.FindOne()
|
||||
$directoryDisplayName = if ($userResult -and $userResult.Properties['displayname'].Count) {
|
||||
[string]$userResult.Properties['displayname'][0]
|
||||
@@ -100,6 +119,12 @@ function Get-DirectoryWelcomeMetadata {
|
||||
else {
|
||||
$null
|
||||
}
|
||||
$directoryGender = if ($userResult -and $userResult.Properties['info'].Count) {
|
||||
Get-SguGenderFromInfo -Info ([string]$userResult.Properties['info'][0])
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$userSearcher.Dispose()
|
||||
@@ -136,6 +161,7 @@ function Get-DirectoryWelcomeMetadata {
|
||||
|
||||
[pscustomobject]@{
|
||||
DisplayName = $directoryDisplayName
|
||||
Gender = $directoryGender
|
||||
Location = $directoryLocation
|
||||
OrganizationalUnit = Get-ImmediateOrganizationalUnit -DistinguishedName $computerDn
|
||||
}
|
||||
@@ -156,11 +182,20 @@ function Get-SpanishArticle {
|
||||
function Get-WelcomeLocationText {
|
||||
param(
|
||||
[string]$Room,
|
||||
[string]$OuName
|
||||
[string]$OuName,
|
||||
[string]$Gender
|
||||
)
|
||||
|
||||
$located = 'Est{0}s ubicado en' -f [char]0x00E1
|
||||
$engineeringLab = 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED
|
||||
$located = switch ($Gender) {
|
||||
'Male' { 'Est{0}s ubicado en' -f [char]0x00E1 }
|
||||
'Female' { 'Est{0}s ubicada en' -f [char]0x00E1 }
|
||||
default { 'Ubicaci{0}n:' -f [char]0x00F3 }
|
||||
}
|
||||
$engineeringLab = switch ($Gender) {
|
||||
'Male' { 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
|
||||
'Female' { 'Bienvenida al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
|
||||
default { 'Acceso al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
|
||||
}
|
||||
$Room = if ($Room) { $Room.Trim() } else { $null }
|
||||
$OuName = if ($OuName) { $OuName.Trim() } else { $null }
|
||||
|
||||
@@ -184,6 +219,16 @@ function Get-WelcomeLocationText {
|
||||
return $engineeringLab
|
||||
}
|
||||
|
||||
function Get-WelcomeHeading {
|
||||
param([string]$Gender)
|
||||
|
||||
switch ($Gender) {
|
||||
'Male' { return 'Bienvenido,' }
|
||||
'Female' { return 'Bienvenida,' }
|
||||
default { return 'Te damos la bienvenida,' }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AvailableFontFamily {
|
||||
param(
|
||||
[Parameter(Mandatory)][string[]]$Candidates,
|
||||
@@ -270,10 +315,17 @@ if (-not $DisplayName) {
|
||||
if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) {
|
||||
$Location = $metadata.Location
|
||||
}
|
||||
$genderWasProvided = $PSBoundParameters.ContainsKey('Gender')
|
||||
if (-not $genderWasProvided -and $metadata -and $metadata.Gender -in @('Male', 'Female')) {
|
||||
# The parameter's ValidateSet also runs on assignments. Missing AD gender
|
||||
# must leave the optional parameter unset so the neutral wording can render.
|
||||
$Gender = $metadata.Gender
|
||||
}
|
||||
$welcomeHeading = Get-WelcomeHeading -Gender $Gender
|
||||
if (-not $PSBoundParameters.ContainsKey('OrganizationalUnit') -and $metadata) {
|
||||
$OrganizationalUnit = $metadata.OrganizationalUnit
|
||||
}
|
||||
$locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit
|
||||
$locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit -Gender $Gender
|
||||
|
||||
if (-not $CanvasWidth -or -not $CanvasHeight) {
|
||||
try {
|
||||
@@ -366,7 +418,7 @@ try {
|
||||
$format.Trimming = [Drawing.StringTrimming]::EllipsisWord
|
||||
try {
|
||||
$graphics.FillRectangle($panelBrush, $panelX, $panelY, $panelWidth, $panelHeight)
|
||||
Draw-CenteredText -Graphics $graphics -Text 'Bienvenido,' -Font $welcomeFont `
|
||||
Draw-CenteredText -Graphics $graphics -Text $welcomeHeading -Font $welcomeFont `
|
||||
-Brush $accentBrush -Bounds ([Drawing.RectangleF]::new($panelX, $panelY + 24*$scale, $panelWidth, 50*$scale)) -Format $format
|
||||
Draw-CenteredText -Graphics $graphics -Text $DisplayName -Font $nameFont `
|
||||
-Brush $whiteBrush -Bounds ([Drawing.RectangleF]::new($panelX + 30*$scale, $panelY + 64*$scale, $panelWidth - 60*$scale, 105*$scale)) -Format $format
|
||||
@@ -433,12 +485,15 @@ namespace Sgu {
|
||||
}
|
||||
}
|
||||
|
||||
Write-WelcomeLog -Message ("OK computer={0}; location={1}; ou={2}; output={3}" -f $ComputerName,[bool]$Location,[bool]$OrganizationalUnit,$OutputPath)
|
||||
$genderLogValue = if ($Gender) { $Gender } else { 'Neutral' }
|
||||
Write-WelcomeLog -Message ("OK computer={0}; gender={1}; location={2}; ou={3}; output={4}" -f $ComputerName,$genderLogValue,[bool]$Location,[bool]$OrganizationalUnit,$OutputPath)
|
||||
[pscustomobject]@{
|
||||
DisplayName = $DisplayName
|
||||
ComputerName = $ComputerName
|
||||
Location = $Location
|
||||
OrganizationalUnit = $OrganizationalUnit
|
||||
Gender = $Gender
|
||||
WelcomeHeading = $welcomeHeading
|
||||
LocationText = $locationText
|
||||
OutputPath = $OutputPath
|
||||
Applied = -not $SkipApply
|
||||
|
||||
@@ -53,6 +53,7 @@ computer_name=${computer_name^^}
|
||||
location=''
|
||||
distinguished_name=''
|
||||
organizational_unit=''
|
||||
gender=''
|
||||
|
||||
read_ldif_value() {
|
||||
local attribute=$1
|
||||
@@ -108,9 +109,18 @@ if [[ -n $DOMAIN_CONTROLLER && -n $BASE_DN ]] &&
|
||||
user_result=$(ldapsearch -LLL -N -o ldif-wrap=no -Y GSSAPI \
|
||||
-H "ldap://${ldap_server}" -b "$BASE_DN" \
|
||||
"(&(objectCategory=person)(objectClass=user)(sAMAccountName=${account_name}))" \
|
||||
displayName 2>/dev/null || true)
|
||||
displayName info 2>/dev/null || true)
|
||||
directory_display_name=$(read_ldif_value displayName "$user_result")
|
||||
[[ -n $directory_display_name ]] && display_name=$directory_display_name
|
||||
directory_info=$(read_ldif_value info "$user_result")
|
||||
gender=$(printf '%s\n' "$directory_info" | awk -F: '
|
||||
tolower($1) ~ /^[[:space:]]*sgu-gender[[:space:]]*$/ {
|
||||
value=tolower($2); gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||
if (value == "male") print "Male"
|
||||
else if (value == "female") print "Female"
|
||||
exit
|
||||
}
|
||||
')
|
||||
fi
|
||||
else
|
||||
log_message 'WARN AD metadata query skipped because Kerberos or LDAP session data was unavailable.'
|
||||
@@ -136,6 +146,24 @@ with_article() {
|
||||
fi
|
||||
}
|
||||
|
||||
case "$gender" in
|
||||
Male)
|
||||
welcome_text='Bienvenido,'
|
||||
located_text='Estás ubicado en'
|
||||
engineering_lab_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.'
|
||||
;;
|
||||
Female)
|
||||
welcome_text='Bienvenida,'
|
||||
located_text='Estás ubicada en'
|
||||
engineering_lab_text='Bienvenida al Laboratorio de Cómputo de Ingeniería.'
|
||||
;;
|
||||
*)
|
||||
welcome_text='Te damos la bienvenida,'
|
||||
located_text='Ubicación:'
|
||||
engineering_lab_text='Acceso al Laboratorio de Cómputo de Ingeniería.'
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n $location && -n $organizational_unit ]]; then
|
||||
room_phrase=$(with_article "$location")
|
||||
ou_article=$(article_for "$organizational_unit")
|
||||
@@ -146,13 +174,13 @@ if [[ -n $location && -n $organizational_unit ]]; then
|
||||
else
|
||||
ou_phrase="de ${organizational_unit}"
|
||||
fi
|
||||
location_text="Estás ubicado en ${room_phrase} ${ou_phrase}."
|
||||
location_text="${located_text} ${room_phrase} ${ou_phrase}."
|
||||
elif [[ -n $location ]]; then
|
||||
location_text="Estás ubicado en $(with_article "$location")."
|
||||
location_text="${located_text} $(with_article "$location")."
|
||||
elif [[ -n $organizational_unit ]]; then
|
||||
location_text="Estás ubicado en $(with_article "$organizational_unit")."
|
||||
location_text="${located_text} $(with_article "$organizational_unit")."
|
||||
else
|
||||
location_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.'
|
||||
location_text=$engineering_lab_text
|
||||
fi
|
||||
|
||||
width=1600
|
||||
@@ -210,7 +238,7 @@ if ! "${image_command[@]}" "$BASE_IMAGE" \
|
||||
-gravity center \
|
||||
-font "$sans_font" -weight 700 -style Normal -pointsize "$welcome_size" \
|
||||
-fill '#D3E2FF' -stroke 'rgba(0,0,0,0.48)' -strokewidth 1 \
|
||||
-annotate "+0-$(( 92 * scale / 100 ))" 'Bienvenido,' \
|
||||
-annotate "+0-$(( 92 * scale / 100 ))" "$welcome_text" \
|
||||
-font "$serif_font" -weight 700 -style Italic -pointsize "$name_size" \
|
||||
-fill white -annotate "+0-$(( 22 * scale / 100 ))" "$display_name" \
|
||||
-font "$sans_font" -weight 400 -style Normal -pointsize "$location_size" \
|
||||
@@ -242,7 +270,7 @@ if [[ $applied == false ]] && command -v xfconf-query >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
if [[ $applied == true ]]; then
|
||||
log_message "OK computer=${computer_name}; location=$([[ -n $location ]] && printf true || printf false); ou=$([[ -n $organizational_unit ]] && printf true || printf false); output=${output_path}"
|
||||
log_message "OK computer=${computer_name}; gender=${gender:-Neutral}; location=$([[ -n $location ]] && printf true || printf false); ou=$([[ -n $organizational_unit ]] && printf true || printf false); output=${output_path}"
|
||||
else
|
||||
log_message 'WARN Wallpaper rendered, but no supported desktop background API was found.'
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "SGU_BOOTSTRAP_IP=%~1"
|
||||
set "SGU_VPN_PACKAGE=%~2"
|
||||
set "SGU_VPN_PFX=%~3"
|
||||
set "SGU_VPN_ROOT=%~4"
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-PauseOnError','-ConnectivityMode','AzureP2S'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',('"' + $env:SGU_BOOTSTRAP_IP + '"')) }; if ($env:SGU_VPN_PACKAGE) { $arguments += @('-VpnProfilePackagePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PACKAGE) + '"')) }; if ($env:SGU_VPN_PFX) { $arguments += @('-VpnClientCertificatePfxPath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PFX) + '"')) }; if ($env:SGU_VPN_ROOT) { $arguments += @('-VpnClientRootCertificatePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_ROOT) + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
||||
set "SGU_EXIT_CODE=%errorlevel%"
|
||||
if not "%SGU_EXIT_CODE%"=="0" (
|
||||
echo.
|
||||
echo SGU Windows Azure enrollment did not complete. Review:
|
||||
echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log
|
||||
pause
|
||||
)
|
||||
exit /b %SGU_EXIT_CODE%
|
||||
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "SGU_BOOTSTRAP_IP=%~1"
|
||||
set "SGU_VPN_POOL=%~2"
|
||||
if "%SGU_VPN_POOL%"=="" set "SGU_VPN_POOL=172.30.0.0/24"
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Initialize-SguDomainController.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-NetworkConfigurationMode','PlatformManaged','-TrustedClientNetworks',$env:SGU_VPN_POOL,'-DnsForwarders','168.63.129.16'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-ServerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
||||
exit /b %errorlevel%
|
||||
@@ -1,5 +1,14 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "SGU_BOOTSTRAP_IP=%~1"
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"')); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
||||
exit /b %errorlevel%
|
||||
set "SGU_CLIENT_IP=%~2"
|
||||
set "SGU_NETWORK_ALIAS=%~3"
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-PauseOnError'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; if ($env:SGU_CLIENT_IP) { $arguments += @('-ClientIPv4Address',$env:SGU_CLIENT_IP) }; if ($env:SGU_NETWORK_ALIAS) { $arguments += @('-NetworkInterfaceAlias',('"' + $env:SGU_NETWORK_ALIAS + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
||||
set "SGU_EXIT_CODE=%errorlevel%"
|
||||
if not "%SGU_EXIT_CODE%"=="0" (
|
||||
echo.
|
||||
echo SGU client enrollment did not complete. Review the elevated window or:
|
||||
echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log
|
||||
pause
|
||||
)
|
||||
exit /b %SGU_EXIT_CODE%
|
||||
|
||||
@@ -19,6 +19,30 @@ $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
|
||||
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
||||
$issues = [Collections.Generic.List[string]]::new()
|
||||
$standardLocalUserName = 'alumno'
|
||||
$passwordNeverExpiresFlag = 0x10000
|
||||
|
||||
function Get-LocalGroupMemberSid {
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
|
||||
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,group")
|
||||
foreach ($member in @($group.psbase.Invoke('Members'))) {
|
||||
try {
|
||||
$sidBytes = $member.GetType().InvokeMember(
|
||||
'objectSid',
|
||||
[Reflection.BindingFlags]::GetProperty,
|
||||
$null,
|
||||
$member,
|
||||
$null)
|
||||
if ($sidBytes) {
|
||||
([Security.Principal.SecurityIdentifier]::new([byte[]]$sidBytes, 0)).Value
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Keep validating known members when an old forest SID no longer resolves.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$computer = Get-CimInstance Win32_ComputerSystem
|
||||
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
|
||||
@@ -87,6 +111,50 @@ if (-not $passwordProviderPreserved) {
|
||||
$issues.Add('The built-in Microsoft password provider registration is missing.')
|
||||
}
|
||||
|
||||
$standardLocalUser = Get-LocalUser -Name $standardLocalUserName -ErrorAction SilentlyContinue
|
||||
$standardLocalUserPresent = [bool]$standardLocalUser
|
||||
$standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled
|
||||
$standardLocalUserIsAdministrator = $false
|
||||
$standardLocalUserInUsersGroup = $false
|
||||
$standardLocalUserPasswordNeverExpires = $false
|
||||
if ($standardLocalUserPresent) {
|
||||
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
|
||||
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
|
||||
$administratorMembers = @(Get-LocalGroupMemberSid -Name $administratorsGroup.Name)
|
||||
$standardMembers = @(Get-LocalGroupMemberSid -Name $usersGroup.Name)
|
||||
$standardLocalUserIsAdministrator =
|
||||
$administratorMembers -contains $standardLocalUser.SID.Value
|
||||
$standardLocalUserInUsersGroup =
|
||||
$standardMembers -contains $standardLocalUser.SID.Value
|
||||
try {
|
||||
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$standardLocalUserName,user")
|
||||
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
|
||||
$standardLocalUserPasswordNeverExpires =
|
||||
($userFlags -band $passwordNeverExpiresFlag) -ne 0
|
||||
}
|
||||
catch {
|
||||
# Report the account as invalid when Windows cannot read its flags.
|
||||
$standardLocalUserPasswordNeverExpires = $false
|
||||
}
|
||||
}
|
||||
if (-not $standardLocalUserPresent) {
|
||||
$issues.Add("The required standard local user '$standardLocalUserName' is missing.")
|
||||
}
|
||||
elseif (-not $standardLocalUserEnabled) {
|
||||
$issues.Add("The required standard local user '$standardLocalUserName' is disabled.")
|
||||
}
|
||||
elseif ($standardLocalUserIsAdministrator) {
|
||||
$issues.Add("The required standard local user '$standardLocalUserName' belongs to the local Administrators group.")
|
||||
}
|
||||
elseif (-not $standardLocalUserInUsersGroup) {
|
||||
$issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.")
|
||||
}
|
||||
elseif (-not $standardLocalUserPasswordNeverExpires) {
|
||||
$issues.Add("The required standard local user '$standardLocalUserName' password is not configured to never expire.")
|
||||
}
|
||||
|
||||
$settings = $null
|
||||
try {
|
||||
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
|
||||
@@ -149,12 +217,18 @@ $remoteAccessReady = $null
|
||||
if ($RequireRemoteAccess) {
|
||||
$remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-555')
|
||||
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1]
|
||||
$rdpMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorAction SilentlyContinue)
|
||||
$rdpMembers = @(Get-LocalGroupMemberSid -Name $remoteDesktopUsersGroup)
|
||||
$remoteDesktopPrincipalSid = $null
|
||||
try {
|
||||
$remoteDesktopPrincipalSid = ([Security.Principal.NTAccount]::new($RemoteDesktopPrincipal)).Translate(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
}
|
||||
catch { }
|
||||
$remoteAccessReady =
|
||||
(Get-Service TermService).Status -eq 'Running' -and
|
||||
(Get-Service WinRM).Status -eq 'Running' -and
|
||||
(Get-ItemPropertyValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections) -eq 0 -and
|
||||
$rdpMembers.Name -contains $RemoteDesktopPrincipal
|
||||
$remoteDesktopPrincipalSid -and $rdpMembers -contains $remoteDesktopPrincipalSid
|
||||
if (-not $remoteAccessReady) {
|
||||
$issues.Add('RDP/WinRM or the authorized domain group is not fully configured.')
|
||||
}
|
||||
@@ -207,6 +281,11 @@ $result = [pscustomobject]@{
|
||||
LastSignedInUserHidden = $lastSignedInUserHidden
|
||||
LocalUserEnumerationDisabled = $localUserEnumerationDisabled
|
||||
PasswordProviderPreserved = $passwordProviderPreserved
|
||||
StandardLocalUserPresent = $standardLocalUserPresent
|
||||
StandardLocalUserEnabled = $standardLocalUserEnabled
|
||||
StandardLocalUserIsAdministrator = $standardLocalUserIsAdministrator
|
||||
StandardLocalUserInUsersGroup = $standardLocalUserInUsersGroup
|
||||
StandardLocalUserPasswordNeverExpires = $standardLocalUserPasswordNeverExpires
|
||||
SettingsPresent = [bool]$settings
|
||||
ClientCertificatePresent = [bool]$clientCertificatePresent
|
||||
ServerCertificateTrusted = $serverCertificateTrusted
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SGU.AuthBroker.Core.Profiles;
|
||||
|
||||
public enum InstitutionalGender
|
||||
{
|
||||
Male,
|
||||
Female
|
||||
}
|
||||
@@ -12,7 +12,8 @@ public sealed record InstitutionalProfile(
|
||||
string? StreetAddress = null,
|
||||
string? City = null,
|
||||
string? State = null,
|
||||
string? PostalCode = null)
|
||||
string? PostalCode = null,
|
||||
InstitutionalGender? Gender = null)
|
||||
{
|
||||
public bool HasValues =>
|
||||
EmployeeNumber is not null ||
|
||||
@@ -26,7 +27,8 @@ public sealed record InstitutionalProfile(
|
||||
StreetAddress is not null ||
|
||||
City is not null ||
|
||||
State is not null ||
|
||||
PostalCode is not null;
|
||||
PostalCode is not null ||
|
||||
Gender is not null;
|
||||
|
||||
public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
|
||||
values is null
|
||||
@@ -44,6 +46,7 @@ public sealed record InstitutionalProfile(
|
||||
StreetAddress = values.StreetAddress ?? StreetAddress,
|
||||
City = values.City ?? City,
|
||||
State = values.State ?? State,
|
||||
PostalCode = values.PostalCode ?? PostalCode
|
||||
PostalCode = values.PostalCode ?? PostalCode,
|
||||
Gender = values.Gender ?? Gender
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace SGU.AuthBroker.Core.Profiles;
|
||||
|
||||
public sealed record SguAdministrativeLocationSelection(
|
||||
string? StateId = null,
|
||||
string? MunicipalityId = null,
|
||||
string? NeighborhoodId = null,
|
||||
string? StateName = null,
|
||||
string? MunicipalityName = null,
|
||||
string? NeighborhoodName = null,
|
||||
string? PostalCode = null)
|
||||
{
|
||||
public bool HasValues =>
|
||||
StateId is not null ||
|
||||
MunicipalityId is not null ||
|
||||
NeighborhoodId is not null ||
|
||||
StateName is not null ||
|
||||
MunicipalityName is not null ||
|
||||
NeighborhoodName is not null ||
|
||||
PostalCode is not null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SGU.AuthBroker.Core.Profiles;
|
||||
|
||||
@@ -14,6 +15,8 @@ public static class SguProfileParser
|
||||
private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
|
||||
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
|
||||
private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno";
|
||||
private const string AdministrativeGenderId = "ctl00_contenedor_ddlsexo";
|
||||
private const string AdministrativeGenderName = "ctl00$contenedor$ddlsexo";
|
||||
private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle";
|
||||
private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
|
||||
private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt";
|
||||
@@ -34,6 +37,7 @@ public static class SguProfileParser
|
||||
private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP";
|
||||
private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
|
||||
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
|
||||
private const string StudentGenderId = "ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP";
|
||||
|
||||
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
|
||||
=> ParseStaffHeader(html, expectedEmployeeNumber);
|
||||
@@ -91,11 +95,22 @@ public static class SguProfileParser
|
||||
InstitutionalProfile profile = new(
|
||||
DisplayName: displayName,
|
||||
GivenName: givenName,
|
||||
Surname: surname);
|
||||
Surname: surname,
|
||||
Gender: ParseStaffGender(ExtractSelectedOptionValue(
|
||||
html,
|
||||
AdministrativeGenderId,
|
||||
AdministrativeGenderName)));
|
||||
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);
|
||||
|
||||
@@ -105,18 +120,79 @@ public static class SguProfileParser
|
||||
string? interiorNumber = NormalizeAddressUnit(
|
||||
ExtractInputValue(html, AdministrativeInteriorNumberId));
|
||||
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
|
||||
string? neighborhood = NormalizeTitle(
|
||||
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
|
||||
256);
|
||||
string? neighborhood = NormalizeTitle(FirstNonEmpty(
|
||||
selection?.NeighborhoodName,
|
||||
ResolveNeighborhoodName(neighborhoodsJson, selection),
|
||||
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId)), 256);
|
||||
string? city = NormalizeTitle(FirstNonEmpty(
|
||||
selection?.MunicipalityName,
|
||||
ResolveLocalityName(localitiesJson, selection),
|
||||
ExtractSelectedOptionText(html, AdministrativeCityId)), 128);
|
||||
string? state = NormalizeTitle(FirstNonEmpty(
|
||||
selection?.StateName,
|
||||
ExtractOptionTextByValue(html, AdministrativeStateId, selection?.StateId),
|
||||
ExtractSelectedOptionText(html, AdministrativeStateId)), 128);
|
||||
string? postalCode = NormalizePostalCode(FirstNonEmpty(
|
||||
selection?.PostalCode,
|
||||
ExtractInputValue(html, AdministrativePostalCodeId)));
|
||||
|
||||
InstitutionalProfile profile = new(
|
||||
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
|
||||
City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128),
|
||||
State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128),
|
||||
PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId)));
|
||||
City: city,
|
||||
State: state,
|
||||
PostalCode: postalCode);
|
||||
return profile.HasValues ? profile : null;
|
||||
}
|
||||
|
||||
public static SguAdministrativeLocationSelection? ParseAdministrativeLocationSelection(
|
||||
string json,
|
||||
string? expectedPostalCode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(json);
|
||||
|
||||
string? expected = NormalizePostalCode(expectedPostalCode);
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (JsonElement value in values.EnumerateArray())
|
||||
{
|
||||
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
|
||||
if (expected is not null &&
|
||||
!string.Equals(postalCode, expected, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
SguAdministrativeLocationSelection selection = new(
|
||||
StateId: NormalizeCatalogId(GetJsonString(value, "p_IdEstado")),
|
||||
MunicipalityId: NormalizeCatalogId(GetJsonString(value, "p_IdMunicipio")),
|
||||
NeighborhoodId: NormalizeCatalogId(GetJsonString(value, "p_IdCP")),
|
||||
StateName: Limit(GetJsonString(value, "p_NombreEstado"), 128),
|
||||
MunicipalityName: Limit(GetJsonString(value, "p_NombreMunicipio"), 128),
|
||||
NeighborhoodName: Limit(FirstNonEmpty(
|
||||
GetJsonString(value, "p_NombreColonia"),
|
||||
GetJsonString(value, "p_NombreAsentamiento"),
|
||||
GetJsonString(value, "p_Nombre")), 256),
|
||||
PostalCode: postalCode);
|
||||
if (selection.HasValues)
|
||||
{
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
@@ -151,7 +227,8 @@ public static class SguProfileParser
|
||||
StreetAddress: streetAddress,
|
||||
City: city ?? municipality,
|
||||
State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128),
|
||||
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)));
|
||||
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)),
|
||||
Gender: ParseStudentGender(ExtractSpanText(html, StudentGenderId)));
|
||||
return profile.HasValues ? profile : null;
|
||||
}
|
||||
|
||||
@@ -292,11 +369,248 @@ public static class SguProfileParser
|
||||
return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null;
|
||||
}
|
||||
|
||||
private static string? ExtractSelectedOptionValue(string html, string id, string name)
|
||||
{
|
||||
string? openingTag = FindOpeningTag(html, "select", id) ??
|
||||
FindOpeningTagByAttribute(html, "select", "name", name);
|
||||
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? selectedValue = ExtractAttributeValue(openingTag, "value");
|
||||
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);
|
||||
if (optionTagEnd < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
|
||||
string? optionValue = ExtractAttributeValue(optionTag, "value");
|
||||
if (optionValue is not null &&
|
||||
(HasAttribute(optionTag, "selected") ||
|
||||
(selectedValue is not null &&
|
||||
string.Equals(optionValue, selectedValue, StringComparison.Ordinal))))
|
||||
{
|
||||
return NormalizeText(optionValue);
|
||||
}
|
||||
|
||||
searchFrom = optionTagEnd + 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractOptionTextByValue(string html, string id, string? expectedValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expectedValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? openingTag = FindOpeningTag(html, "select", id);
|
||||
if (openingTag is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
|
||||
int contentStart = openingTagIndex + openingTag.Length;
|
||||
int contentEnd = html.IndexOf("</select", contentStart, StringComparison.OrdinalIgnoreCase);
|
||||
if (openingTagIndex < 0 || contentEnd < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string optionsHtml = html[contentStart..contentEnd];
|
||||
int searchFrom = 0;
|
||||
while (searchFrom < optionsHtml.Length)
|
||||
{
|
||||
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
|
||||
if (optionStart < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
|
||||
int optionEnd = optionTagEnd < 0
|
||||
? -1
|
||||
: optionsHtml.IndexOf("</option", optionTagEnd + 1, StringComparison.OrdinalIgnoreCase);
|
||||
if (optionTagEnd < 0 || optionEnd < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
|
||||
string? optionValue = ExtractAttributeValue(optionTag, "value");
|
||||
if (string.Equals(optionValue, expectedValue, StringComparison.Ordinal))
|
||||
{
|
||||
return NormalizeText(optionsHtml[(optionTagEnd + 1)..optionEnd]);
|
||||
}
|
||||
|
||||
searchFrom = optionEnd + "</option".Length;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ResolveLocalityName(
|
||||
string? json,
|
||||
SguAdministrativeLocationSelection? selection)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) ||
|
||||
string.IsNullOrWhiteSpace(selection?.MunicipalityId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (JsonElement value in values.EnumerateArray())
|
||||
{
|
||||
if (string.Equals(
|
||||
NormalizeCatalogId(GetJsonString(value, "Id_Municipio")),
|
||||
selection.MunicipalityId,
|
||||
StringComparison.Ordinal) &&
|
||||
(selection.StateId is null || string.Equals(
|
||||
NormalizeCatalogId(GetJsonString(value, "ID_Estado")),
|
||||
selection.StateId,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
return GetJsonString(value, "Nombre");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ResolveNeighborhoodName(
|
||||
string? json,
|
||||
SguAdministrativeLocationSelection? selection)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || selection is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
List<string> postalCodeMatches = [];
|
||||
foreach (JsonElement value in values.EnumerateArray())
|
||||
{
|
||||
string? neighborhoodId = NormalizeCatalogId(GetJsonString(value, "p_IdCP"));
|
||||
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
|
||||
string? name = GetJsonString(value, "p_Nombre");
|
||||
if (name is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selection.NeighborhoodId is not null &&
|
||||
string.Equals(neighborhoodId, selection.NeighborhoodId, StringComparison.Ordinal))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
if (selection.PostalCode is not null &&
|
||||
string.Equals(postalCode, selection.PostalCode, StringComparison.Ordinal))
|
||||
{
|
||||
postalCodeMatches.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return postalCodeMatches.Count == 1 ? postalCodeMatches[0] : null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetPageMethodArray(JsonElement root, out JsonElement values)
|
||||
{
|
||||
values = default;
|
||||
return root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("d", out values) &&
|
||||
values.ValueKind == JsonValueKind.Array;
|
||||
}
|
||||
|
||||
private static string? GetJsonString(JsonElement value, string propertyName)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Object ||
|
||||
!value.TryGetProperty(propertyName, out JsonElement property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.GetString(),
|
||||
JsonValueKind.Number => property.GetRawText(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeCatalogId(string? value)
|
||||
{
|
||||
string? candidate = value?.Trim();
|
||||
return string.IsNullOrEmpty(candidate) ||
|
||||
candidate.Length > 32 ||
|
||||
!candidate.All(char.IsAsciiLetterOrDigit)
|
||||
? null
|
||||
: candidate;
|
||||
}
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] values) =>
|
||||
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
||||
|
||||
private static string? FindOpeningTag(string html, string tagName, string id)
|
||||
=> FindOpeningTagByAttribute(html, tagName, "id", id);
|
||||
|
||||
private static string? FindOpeningTagByAttribute(
|
||||
string html,
|
||||
string tagName,
|
||||
string attributeName,
|
||||
string attributeValue)
|
||||
{
|
||||
foreach (char quote in new[] { '"', '\'' })
|
||||
{
|
||||
string marker = $"id={quote}{id}{quote}";
|
||||
string marker = $"{attributeName}={quote}{attributeValue}{quote}";
|
||||
int searchFrom = 0;
|
||||
while (searchFrom < html.Length)
|
||||
{
|
||||
@@ -324,6 +638,20 @@ public static class SguProfileParser
|
||||
return null;
|
||||
}
|
||||
|
||||
private static InstitutionalGender? ParseStaffGender(string? value) => value?.Trim() switch
|
||||
{
|
||||
"1" => InstitutionalGender.Male,
|
||||
"2" => InstitutionalGender.Female,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static InstitutionalGender? ParseStudentGender(string? value) => value?.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"M" => InstitutionalGender.Male,
|
||||
"F" => InstitutionalGender.Female,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string? ExtractAttributeValue(string openingTag, string attributeName)
|
||||
{
|
||||
foreach (char quote in new[] { '"', '\'' })
|
||||
|
||||
@@ -176,11 +176,11 @@ public sealed class ActiveDirectoryOptions
|
||||
|
||||
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
|
||||
public string ProfessorGroupDn { get; init; } = "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
public string ProfessorGroupDn { get; init; } = "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
|
||||
public string StudentGroupDn { get; init; } = "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
public string StudentGroupDn { get; init; } = "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
|
||||
public string AdministrativeGroupDn { get; init; } = "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
public string AdministrativeGroupDn { get; init; } = "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
|
||||
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ public sealed class ActiveDirectorySynchronizer(
|
||||
BrokerOptions options,
|
||||
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
|
||||
{
|
||||
private const string GenderMetadataPrefix = "SGU-Gender:";
|
||||
private const int InfoAttributeMaximumLength = 1024;
|
||||
private const int AccountDisabled = 0x0002;
|
||||
private const int NormalAccount = 0x0200;
|
||||
private static readonly AuthenticationTypes BindFlags =
|
||||
@@ -164,6 +166,7 @@ public sealed class ActiveDirectorySynchronizer(
|
||||
SetOptionalProperty(user, "l", profile.City);
|
||||
SetOptionalProperty(user, "st", profile.State);
|
||||
SetOptionalProperty(user, "postalCode", profile.PostalCode);
|
||||
SetGenderMetadata(user, profile.Gender, identity.UserName, logger);
|
||||
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
|
||||
{
|
||||
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
|
||||
@@ -201,6 +204,56 @@ public sealed class ActiveDirectorySynchronizer(
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetGenderMetadata(
|
||||
DirectoryEntry entry,
|
||||
InstitutionalGender? gender,
|
||||
string institutionalUser,
|
||||
ILogger logger)
|
||||
{
|
||||
if (gender is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string existing = Convert.ToString(entry.Properties["info"].Value) ?? string.Empty;
|
||||
string? updated = MergeGenderMetadata(existing, gender);
|
||||
if (updated is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.DirectoryOptionalMetadataFailure,
|
||||
"Gender metadata was not written for {InstitutionalUser} because the Active Directory info attribute has no remaining capacity.",
|
||||
institutionalUser);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.Properties["info"].Value = updated;
|
||||
}
|
||||
|
||||
internal static string? MergeGenderMetadata(
|
||||
string? existing,
|
||||
InstitutionalGender? gender)
|
||||
{
|
||||
if (gender is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string managedLine = $"{GenderMetadataPrefix} {gender}";
|
||||
string normalizedExisting = (existing ?? string.Empty)
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace('\r', '\n');
|
||||
string[] preservedLines = string.IsNullOrEmpty(normalizedExisting)
|
||||
? []
|
||||
: normalizedExisting
|
||||
.Split('\n')
|
||||
.Where(line => !line.TrimStart().StartsWith(
|
||||
GenderMetadataPrefix,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
string updated = string.Join("\r\n", preservedLines.Append(managedLine));
|
||||
return updated.Length <= InfoAttributeMaximumLength ? updated : null;
|
||||
}
|
||||
|
||||
private void EnsureRoleGroupMembership(DirectoryEntry user, UserIdentity identity)
|
||||
{
|
||||
user.RefreshCache(["distinguishedName"]);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
@@ -431,9 +433,6 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
pages.Add((
|
||||
options.AdministrativePersonalProfilePath,
|
||||
SguProfileParser.ParseAdministrativePersonal));
|
||||
pages.Add((
|
||||
options.AdministrativeLocationProfilePath,
|
||||
SguProfileParser.ParseAdministrativeLocation));
|
||||
|
||||
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
|
||||
{
|
||||
@@ -474,7 +473,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
break;
|
||||
return profile;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -487,6 +486,142 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
}
|
||||
}
|
||||
|
||||
return await TryEnrichStaffLocationAsync(
|
||||
client,
|
||||
profile,
|
||||
identity,
|
||||
allowedHosts,
|
||||
timeoutToken,
|
||||
requestCancellationToken,
|
||||
elapsed).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<InstitutionalProfile> TryEnrichStaffLocationAsync(
|
||||
HttpClient client,
|
||||
InstitutionalProfile profile,
|
||||
UserIdentity identity,
|
||||
HashSet<string> allowedHosts,
|
||||
CancellationToken timeoutToken,
|
||||
CancellationToken requestCancellationToken,
|
||||
Stopwatch elapsed)
|
||||
{
|
||||
string path = options.AdministrativeLocationProfilePath;
|
||||
Uri locationPageUri = GetProfileUri(path);
|
||||
try
|
||||
{
|
||||
string? html = await TryFetchAdditionalProfilePageAsync(
|
||||
client,
|
||||
locationPageUri,
|
||||
allowedHosts,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
if (html is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfilePageUnavailable,
|
||||
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
|
||||
path,
|
||||
identity.Role);
|
||||
return profile;
|
||||
}
|
||||
|
||||
InstitutionalProfile? staticLocation = SguProfileParser.ParseAdministrativeLocation(html);
|
||||
if (staticLocation is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfileHtmlUnexpected,
|
||||
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
|
||||
path,
|
||||
identity.Role);
|
||||
return profile;
|
||||
}
|
||||
|
||||
profile = profile.Overlay(staticLocation);
|
||||
if (string.IsNullOrWhiteSpace(staticLocation.PostalCode))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
string? directionJson = await TryPostProfilePageMethodAsync(
|
||||
client,
|
||||
GetAdministrativeLocationMethodUri("GetDireccion"),
|
||||
locationPageUri,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["CodigoPostal"] = staticLocation.PostalCode
|
||||
},
|
||||
allowedHosts,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
if (directionJson is null)
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
SguAdministrativeLocationSelection? selection =
|
||||
SguProfileParser.ParseAdministrativeLocationSelection(
|
||||
directionJson,
|
||||
staticLocation.PostalCode);
|
||||
if (selection is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfileHtmlUnexpected,
|
||||
"SGU location method GetDireccion returned an unexpected payload for role {Role}; preserving the static address fields.",
|
||||
identity.Role);
|
||||
return profile;
|
||||
}
|
||||
|
||||
string? localitiesJson = null;
|
||||
if (!string.IsNullOrWhiteSpace(selection.StateId))
|
||||
{
|
||||
localitiesJson = await TryPostProfilePageMethodAsync(
|
||||
client,
|
||||
GetAdministrativeLocationMethodUri("GetLocalidadListado"),
|
||||
locationPageUri,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["pIdEstado"] = selection.StateId
|
||||
},
|
||||
allowedHosts,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
string? neighborhoodsJson = await TryPostProfilePageMethodAsync(
|
||||
client,
|
||||
GetAdministrativeLocationMethodUri("GetColoniasListado"),
|
||||
locationPageUri,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["pIdEstado"] = string.Empty,
|
||||
["pLocalidad"] = string.Empty,
|
||||
["CodigoPostal"] = selection.PostalCode ?? staticLocation.PostalCode
|
||||
},
|
||||
allowedHosts,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
|
||||
InstitutionalProfile? resolvedLocation = SguProfileParser.ParseAdministrativeLocation(
|
||||
html,
|
||||
selection,
|
||||
localitiesJson,
|
||||
neighborhoodsJson);
|
||||
return profile.Overlay(resolvedLocation);
|
||||
}
|
||||
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfileEnrichmentTimeout,
|
||||
"SGU optional staff location enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfileEnrichmentFailure,
|
||||
exception,
|
||||
"SGU optional staff location enrichment failed for role {Role} after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
@@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> TryPostProfilePageMethodAsync(
|
||||
HttpClient client,
|
||||
Uri requestedUri,
|
||||
Uri referrerUri,
|
||||
IReadOnlyDictionary<string, string> payload,
|
||||
HashSet<string> allowedHosts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsAllowedHttpsUri(requestedUri, allowedHosts) ||
|
||||
!IsAllowedHttpsUri(referrerUri, allowedHosts))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, requestedUri);
|
||||
request.Headers.Referrer = referrerUri;
|
||||
request.Content = new StringContent(
|
||||
JsonSerializer.Serialize(payload),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
using HttpResponseMessage response = await client
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
int statusCode = (int)response.StatusCode;
|
||||
if (statusCode is >= 200 and < 300)
|
||||
{
|
||||
return await ReadLimitedStringAsync(
|
||||
response.Content,
|
||||
options.MaxProfileBytes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfilePageUnavailable,
|
||||
"Optional SGU profile method {Path} returned HTTP {StatusCode}.",
|
||||
requestedUri.AbsolutePath,
|
||||
statusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddCredential(
|
||||
Uri uri,
|
||||
CredentialCache credentialCache,
|
||||
@@ -658,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
private Uri GetProfileUri(string path) =>
|
||||
new(new Uri(options.Endpoint, UriKind.Absolute), path);
|
||||
|
||||
private Uri GetAdministrativeLocationMethodUri(string methodName)
|
||||
{
|
||||
Uri pageUri = GetProfileUri(options.AdministrativeLocationProfilePath);
|
||||
return new Uri($"{pageUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/{methodName}");
|
||||
}
|
||||
|
||||
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
||||
HttpResponseMessage response,
|
||||
UserIdentity identity,
|
||||
|
||||
@@ -50,9 +50,9 @@
|
||||
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"ProfessorGroupDn": "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"StudentGroupDn": "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"AdministrativeGroupDn": "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"ProfessorGroupDn": "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"StudentGroupDn": "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"AdministrativeGroupDn": "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"RemoteDesktopGroupDn": "",
|
||||
"DefaultCompany": "La Salle",
|
||||
"CreateMissingOus": false
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$serverBootstrapPath = Join-Path $repositoryRoot 'scripts\Initialize-SguDomainController.ps1'
|
||||
$clientBootstrapPath = Join-Path $repositoryRoot 'scripts\Invoke-SguClientBootstrap.ps1'
|
||||
$azureClientPath = Join-Path $repositoryRoot 'scripts\Install-SguAzureP2sClient.ps1'
|
||||
$domainEnrollmentPath = Join-Path $repositoryRoot 'scripts\Enroll-SguDomainClient.ps1'
|
||||
$repairEnrollmentPath = Join-Path $repositoryRoot 'scripts\Repair-SguClientEnrollment.ps1'
|
||||
$bicepPath = Join-Path $repositoryRoot 'infra\azure\main.bicep'
|
||||
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$serverAst = [Management.Automation.Language.Parser]::ParseFile(
|
||||
$serverBootstrapPath,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors)
|
||||
if ($parseErrors.Count -gt 0) {
|
||||
throw ($parseErrors -join [Environment]::NewLine)
|
||||
}
|
||||
$networkFunctionNames = @(
|
||||
'Test-PrivateIPv4Address',
|
||||
'ConvertTo-NetworkCidr',
|
||||
'ConvertTo-PrivateNetworkCidr',
|
||||
'ConvertTo-PublicNetworkCidr',
|
||||
'Get-ActiveIPv4Adapters',
|
||||
'Resolve-PrivateInterfaceAlias'
|
||||
)
|
||||
$networkFunctions = $serverAst.FindAll({
|
||||
param($node)
|
||||
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$networkFunctionNames -contains $node.Name
|
||||
}, $true)
|
||||
Invoke-Expression (($networkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine)
|
||||
|
||||
$clientTokens = $null
|
||||
$clientParseErrors = $null
|
||||
$clientAst = [Management.Automation.Language.Parser]::ParseFile(
|
||||
$clientBootstrapPath,
|
||||
[ref]$clientTokens,
|
||||
[ref]$clientParseErrors)
|
||||
if ($clientParseErrors.Count -gt 0) {
|
||||
throw ($clientParseErrors -join [Environment]::NewLine)
|
||||
}
|
||||
$clientNetworkFunctions = $clientAst.FindAll({
|
||||
param($node)
|
||||
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -in @('Test-IPv4AddressesSharePrefix', 'Resolve-ClientInterfaceAlias',
|
||||
'Set-ClientServerRoute', 'Set-ClientDomainDns', 'Test-TcpPort', 'Assert-ClientOperatingSystem',
|
||||
'Wait-ClientInterface', 'Test-PrivateIPv4Address', 'Test-ClientDomainDns')
|
||||
}, $true)
|
||||
Invoke-Expression (($clientNetworkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine)
|
||||
|
||||
Describe 'SGU public-cloud network safety' {
|
||||
It 'canonicalizes a host address to its IPv4 network' {
|
||||
ConvertTo-NetworkCidr -Address ([ipaddress]'10.77.0.4') `
|
||||
-NetworkPrefixLength 24 | Should Be '10.77.0.0/24'
|
||||
}
|
||||
|
||||
It 'canonicalizes the trusted P2S pool' {
|
||||
ConvertTo-PrivateNetworkCidr -Cidr '172.30.4.19/16' |
|
||||
Should Be '172.30.0.0/16'
|
||||
}
|
||||
|
||||
It 'rejects a public trusted-client CIDR' {
|
||||
$wasRejected = $false
|
||||
try {
|
||||
ConvertTo-PrivateNetworkCidr -Cidr '8.8.8.0/24' | Out-Null
|
||||
}
|
||||
catch {
|
||||
$wasRejected = $true
|
||||
}
|
||||
$wasRejected | Should Be $true
|
||||
}
|
||||
|
||||
It 'canonicalizes an explicitly authorized public enrollment network' {
|
||||
ConvertTo-PublicNetworkCidr -Cidr '200.13.89.183/24' |
|
||||
Should Be '200.13.89.0/24'
|
||||
}
|
||||
|
||||
It 'rejects private space in the public enrollment allowlist' {
|
||||
$wasRejected = $false
|
||||
try {
|
||||
ConvertTo-PublicNetworkCidr -Cidr '10.77.0.0/16' | Out-Null
|
||||
}
|
||||
catch {
|
||||
$wasRejected = $true
|
||||
}
|
||||
$wasRejected | Should Be $true
|
||||
}
|
||||
|
||||
It 'exposes explicit Azure modes on both bootstraps' {
|
||||
((Get-Command $serverBootstrapPath).Parameters.Keys -contains
|
||||
'NetworkConfigurationMode') | Should Be $true
|
||||
((Get-Command $serverBootstrapPath).Parameters.Keys -contains
|
||||
'TrustedClientNetworks') | Should Be $true
|
||||
((Get-Command $serverBootstrapPath).Parameters.Keys -contains
|
||||
'PublicEnrollmentNetworks') | Should Be $true
|
||||
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||
'ConnectivityMode') | Should Be $true
|
||||
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||
'VpnProfilePackagePath') | Should Be $true
|
||||
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||
'CompatibilityProfile') | Should Be $true
|
||||
}
|
||||
|
||||
It 'accepts an explicit static IPv4 address for a private Windows adapter' {
|
||||
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||
'ClientIPv4Address') | Should Be $true
|
||||
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||
'ClientPrefixLength') | Should Be $true
|
||||
}
|
||||
|
||||
It 'matches a client and domain controller within the requested prefix' {
|
||||
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'192.168.50.11') `
|
||||
-SecondAddress ([ipaddress]'192.168.50.10') -PrefixLength 24 |
|
||||
Should Be $true
|
||||
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'192.168.51.11') `
|
||||
-SecondAddress ([ipaddress]'192.168.50.10') -PrefixLength 24 |
|
||||
Should Be $false
|
||||
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'10.77.15.20') `
|
||||
-SecondAddress ([ipaddress]'10.77.0.4') -PrefixLength 16 |
|
||||
Should Be $true
|
||||
}
|
||||
|
||||
It 'waits for the new address and WinRM route to stabilize' {
|
||||
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||
$source | Should Match "AddressState -eq 'Preferred'"
|
||||
$source | Should Match 'function Wait-TcpPort'
|
||||
$source | Should Match 'Wait-TcpPort -Address \$DomainControllerIPv4Address -Port 5985'
|
||||
}
|
||||
|
||||
It 'uses the unified implementation without OS-specific network restrictions' {
|
||||
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||
$source | Should Not Match 'package cannot enroll|belongs to the Windows 11'
|
||||
$source | Should Not Match 'Read-Host "Fixed IPv4 address for this SGU client'
|
||||
}
|
||||
|
||||
It 'uses an all-user machine-certificate VPN profile' {
|
||||
$source = Get-Content -LiteralPath $azureClientPath -Raw
|
||||
$source | Should Match '-AuthenticationMethod MachineCertificate'
|
||||
$source | Should Match '-AllUserConnection'
|
||||
$source | Should Match 'Add-DnsClientNrptRule'
|
||||
}
|
||||
|
||||
It 'keeps public enrollment closed unless explicit source CIDRs are supplied' {
|
||||
$template = Get-Content -LiteralPath $bicepPath -Raw
|
||||
$template | Should Match "name: 'Allow-RDP-from-administrator'"
|
||||
$template | Should Match "destinationPortRange: '3389'"
|
||||
$template | Should Match 'param publicEnrollmentSourceAddressPrefixes array = \[\]'
|
||||
$template | Should Match "name: 'Allow-Direct-AD-TCP'"
|
||||
$template | Should Match 'sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes'
|
||||
$template | Should Match 'param deployVpnGateway bool = true'
|
||||
$template | Should Not Match "sourceAddressPrefix: '0\.0\.0\.0/0'"
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'SGU direct public enrollment discovery' {
|
||||
It 'distinguishes public server addresses from LAN and VPN addresses' {
|
||||
Test-PrivateIPv4Address -Address ([ipaddress]'10.77.0.4') | Should Be $true
|
||||
Test-PrivateIPv4Address -Address ([ipaddress]'172.30.0.4') | Should Be $true
|
||||
Test-PrivateIPv4Address -Address ([ipaddress]'192.168.50.10') | Should Be $true
|
||||
Test-PrivateIPv4Address -Address ([ipaddress]'20.9.81.130') | Should Be $false
|
||||
}
|
||||
|
||||
It 'bootstraps DoH and host mappings after authenticated server discovery' {
|
||||
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||
$source | Should Match 'Set-DnsServerEncryptionProtocol'
|
||||
$source | Should Match 'Enable-ClientDnsOverHttps'
|
||||
$source | Should Match 'Set-ClientHostMappings'
|
||||
$source | Should Match 'Test-ClientDomainDns'
|
||||
$source | Should Match 'Get-DnsClientDohServerAddress'
|
||||
$source | Should Match 'Add-DnsClientDohServerAddress'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Azure accelerated server adapters' {
|
||||
It 'ignores an Up accelerated VF that has no IPv4 interface' {
|
||||
Mock Get-NetAdapter {
|
||||
[pscustomobject]@{ Name = 'Ethernet'; ifIndex = 4; Status = 'Up' }
|
||||
[pscustomobject]@{ Name = 'Ethernet VF'; ifIndex = 10; Status = 'Up' }
|
||||
[pscustomobject]@{ Name = 'Disconnected'; ifIndex = 12; Status = 'Disconnected' }
|
||||
}
|
||||
Mock Get-NetIPInterface {
|
||||
if ($InterfaceIndex -eq 4) {
|
||||
[pscustomobject]@{ InterfaceIndex = 4; ConnectionState = 'Connected' }
|
||||
}
|
||||
}
|
||||
Mock Get-NetIPConfiguration { [pscustomobject]@{ IPv4DefaultGateway = '10.77.0.1' } }
|
||||
$adapters = @(Get-ActiveIPv4Adapters)
|
||||
$adapters.Count | Should Be 1
|
||||
$adapters[0].Name | Should Be 'Ethernet'
|
||||
Resolve-PrivateInterfaceAlias | Should Be 'Ethernet'
|
||||
}
|
||||
}
|
||||
|
||||
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 repeated domain enrollment' {
|
||||
It 'rejoins a same-name forest when the machine secure channel is broken' {
|
||||
$source = Get-Content -LiteralPath $domainEnrollmentPath -Raw
|
||||
$source | Should Match 'Test-ComputerSecureChannel'
|
||||
$source | Should Match '\$computer\.PartOfDomain -and \$domainMembershipHealthy'
|
||||
$source | Should Match 'Reset-ComputerMachinePassword'
|
||||
$source | Should Match 'DomainControllerDnsName'
|
||||
$source | Should Match 'Add-Computer @joinParams'
|
||||
}
|
||||
|
||||
It 'defers domain-only repair until the secure channel is healthy' {
|
||||
$source = Get-Content -LiteralPath $repairEnrollmentPath -Raw
|
||||
$source | Should Match 'Test-ComputerSecureChannel'
|
||||
$source | Should Match 'if \(\$domainReady\)'
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,64 @@ public sealed class SguProfileParserTests
|
||||
Assert.Null(profile.Email);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ctl00_contenedor_ddlsexo", "ctl00$contenedor$ddlsexo", "1", InstitutionalGender.Male)]
|
||||
[InlineData("alternate-id", "ctl00$contenedor$ddlsexo", "2", InstitutionalGender.Female)]
|
||||
public void ParsesStaffGenderFromTheSelectedPersonalDataOption(
|
||||
string id,
|
||||
string name,
|
||||
string selectedValue,
|
||||
InstitutionalGender expected)
|
||||
{
|
||||
string html = $"""
|
||||
<select id="{id}" name="{name}">
|
||||
<option value="">Seleccione...</option>
|
||||
<option value="1"{(selectedValue == "1" ? " selected=\"selected\"" : string.Empty)}>Masculino</option>
|
||||
<option value="2"{(selectedValue == "2" ? " selected=\"selected\"" : string.Empty)}>Femenino</option>
|
||||
</select>
|
||||
""";
|
||||
|
||||
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
|
||||
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal(expected, profile.Gender);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("M", InstitutionalGender.Male)]
|
||||
[InlineData("f", InstitutionalGender.Female)]
|
||||
public void ParsesStudentGenderFromTheInformationSpan(
|
||||
string source,
|
||||
InstitutionalGender expected)
|
||||
{
|
||||
string html = $"""
|
||||
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
|
||||
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">{source}</span>
|
||||
""";
|
||||
|
||||
InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456");
|
||||
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal(expected, profile.Gender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoresUnknownGenderValuesWithoutFailingProfileParsing()
|
||||
{
|
||||
const string html = """
|
||||
<input id="ctl00_contenedor_txtNombre" value="PERSONA" />
|
||||
<select id="ctl00_contenedor_ddlsexo">
|
||||
<option selected="selected" value="9">SIN CLASIFICAR</option>
|
||||
</select>
|
||||
""";
|
||||
|
||||
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
|
||||
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("Persona", profile.DisplayName);
|
||||
Assert.Null(profile.Gender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
|
||||
{
|
||||
@@ -170,6 +228,65 @@ public sealed class SguProfileParserTests
|
||||
Assert.Null(profile.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesAdministrativeAddressFromPageMethodIdentifiers()
|
||||
{
|
||||
const string html = """
|
||||
<html><body>
|
||||
<input id='ctl00_contenedor_txtCalle' value='RETORNO 1, SUR 16' />
|
||||
<input id='ctl00_contenedor_txtNoExt' value='74' />
|
||||
<input id='ctl00_contenedor_txtNoInt' value='' />
|
||||
<input id='ctl00_contenedor_txtCP' value='08500' />
|
||||
<select id='ctl00_contenedor_ddlEstado'>
|
||||
<option selected='selected' value='0'>Seleccione...</option>
|
||||
<option value='09'>CIUDAD DE MÉXICO</option>
|
||||
</select>
|
||||
<select id='ctl00_contenedor_ddlLocalidad'>
|
||||
<option selected='selected' value='0'>Seleccione alguna localidad...</option>
|
||||
</select>
|
||||
<select id='ctl00_contenedor_ddlColonia'>
|
||||
<option selected='selected' value='0,0'>Seleccione alguna colonia...</option>
|
||||
</select>
|
||||
</body></html>
|
||||
""";
|
||||
const string directionJson = """
|
||||
{"d":[{"p_IdCP":"091263","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"006","p_NombreMunicipio":"","p_NombreColonia":"","p_Cp":"08500"}]}
|
||||
""";
|
||||
const string localitiesJson = """
|
||||
{"d":[{"ID_Estado":"09","Id_Municipio":"002","Nombre":"AZCAPOTZALCO"},{"ID_Estado":"09","Id_Municipio":"006","Nombre":"IZTACALCO"}]}
|
||||
""";
|
||||
const string neighborhoodsJson = """
|
||||
{"d":[{"p_IdCP":"091263","p_Nombre":"AGRÍCOLA ORIENTAL","p_Cp":"08500"}]}
|
||||
""";
|
||||
|
||||
SguAdministrativeLocationSelection? selection =
|
||||
SguProfileParser.ParseAdministrativeLocationSelection(directionJson, "08500");
|
||||
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativeLocation(
|
||||
html,
|
||||
selection,
|
||||
localitiesJson,
|
||||
neighborhoodsJson);
|
||||
|
||||
Assert.NotNull(selection);
|
||||
Assert.Equal("09", selection.StateId);
|
||||
Assert.Equal("006", selection.MunicipalityId);
|
||||
Assert.Equal("091263", selection.NeighborhoodId);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("Retorno 1, Sur 16 74\r\nAgrícola Oriental", profile.StreetAddress);
|
||||
Assert.Equal("Iztacalco", profile.City);
|
||||
Assert.Equal("Ciudad de México", profile.State);
|
||||
Assert.Equal("08500", profile.PostalCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-json")]
|
||||
[InlineData("{\"d\":{}}")]
|
||||
[InlineData("{\"d\":[]}")]
|
||||
public void RejectsUnexpectedAdministrativeLocationPayloads(string json)
|
||||
{
|
||||
Assert.Null(SguProfileParser.ParseAdministrativeLocationSelection(json, "08500"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
|
||||
{
|
||||
@@ -182,7 +299,8 @@ public sealed class SguProfileParserTests
|
||||
InstitutionalProfile personal = new(
|
||||
DisplayName: "María del Carmen de la Fuente",
|
||||
GivenName: "María del Carmen",
|
||||
Surname: "de la Fuente");
|
||||
Surname: "de la Fuente",
|
||||
Gender: InstitutionalGender.Female);
|
||||
InstitutionalProfile location = new(
|
||||
StreetAddress: "Calle Uno 10",
|
||||
City: "Ciudad de México",
|
||||
@@ -200,6 +318,7 @@ public sealed class SguProfileParserTests
|
||||
Assert.Equal("Ingeniería", combined.Department);
|
||||
Assert.Equal("Calle Uno 10", combined.StreetAddress);
|
||||
Assert.Equal("01000", combined.PostalCode);
|
||||
Assert.Equal(InstitutionalGender.Female, combined.Gender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -215,6 +334,7 @@ public sealed class SguProfileParserTests
|
||||
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">
|
||||
<a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a>
|
||||
</span>
|
||||
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">F</span>
|
||||
<span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP">
|
||||
DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE
|
||||
</span>
|
||||
@@ -262,6 +382,7 @@ public sealed class SguProfileParserTests
|
||||
Assert.Equal("Ciudad de México", profile.City);
|
||||
Assert.Equal("Ciudad de México", profile.State);
|
||||
Assert.Equal("08500", profile.PostalCode);
|
||||
Assert.Equal(InstitutionalGender.Female, profile.Gender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
using SGU.AuthBroker.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace SGU.AuthBroker.Tests;
|
||||
|
||||
public sealed class ActiveDirectorySynchronizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GenderMetadataPreservesUnmanagedNotesAndReplacesItsManagedLine()
|
||||
{
|
||||
const string existing = " Responsable de laboratorio \r\n\r\nSGU-Gender: Male\r\nTurno vespertino";
|
||||
|
||||
string? updated = ActiveDirectorySynchronizer.MergeGenderMetadata(
|
||||
existing,
|
||||
InstitutionalGender.Female);
|
||||
|
||||
Assert.Equal(
|
||||
" Responsable de laboratorio \r\n\r\nTurno vespertino\r\nSGU-Gender: Female",
|
||||
updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenderMetadataDoesNotTruncateAnExistingFullNotesField()
|
||||
{
|
||||
string existing = new('x', 1024);
|
||||
|
||||
string? updated = ActiveDirectorySynchronizer.MergeGenderMetadata(
|
||||
existing,
|
||||
InstitutionalGender.Male);
|
||||
|
||||
Assert.Null(updated);
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,9 @@ public sealed class BrokerOptionsTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstitutionalRole.Student, "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||
[InlineData(InstitutionalRole.Administrative, "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||
[InlineData(InstitutionalRole.Professor, "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||
[InlineData(InstitutionalRole.Student, "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||
[InlineData(InstitutionalRole.Administrative, "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||
[InlineData(InstitutionalRole.Professor, "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||
public void DefaultRoleGroupMappingsMatchInstitutionalPrefixes(InstitutionalRole role, string expectedGroupDn)
|
||||
{
|
||||
ActiveDirectoryOptions options = new();
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
using SGU.AuthBroker.Options;
|
||||
using SGU.AuthBroker.Services;
|
||||
using Xunit;
|
||||
@@ -132,6 +133,10 @@ public sealed class NtlmCredentialValidatorTests
|
||||
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
|
||||
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
|
||||
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
|
||||
<select name="ctl00$contenedor$ddlsexo">
|
||||
<option value="1">Masculino</option>
|
||||
<option selected="selected" value="2">Femenino</option>
|
||||
</select>
|
||||
"""),
|
||||
Response(
|
||||
HttpStatusCode.OK,
|
||||
@@ -140,14 +145,24 @@ public sealed class NtlmCredentialValidatorTests
|
||||
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||
<select id="ctl00_contenedor_ddlEstado">
|
||||
<option selected="selected">CIUDAD DE MÉXICO</option>
|
||||
<option selected="selected" value="0">Seleccione...</option>
|
||||
<option value="09">CIUDAD DE MÉXICO</option>
|
||||
</select>
|
||||
<select id="ctl00_contenedor_ddlLocalidad">
|
||||
<option selected="selected">ÁLVARO OBREGÓN</option>
|
||||
<option selected="selected" value="0">Seleccione alguna localidad...</option>
|
||||
</select>
|
||||
<select id="ctl00_contenedor_ddlColonia">
|
||||
<option>FLORIDA</option>
|
||||
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
|
||||
</select>
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
|
||||
"""));
|
||||
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||
|
||||
@@ -168,15 +183,24 @@ public sealed class NtlmCredentialValidatorTests
|
||||
Assert.Equal("Álvaro Obregón", result.Profile.City);
|
||||
Assert.Equal("Ciudad de México", result.Profile.State);
|
||||
Assert.Equal("01000", result.Profile.PostalCode);
|
||||
Assert.Equal(InstitutionalGender.Female, result.Profile.Gender);
|
||||
Assert.Equal(
|
||||
[
|
||||
"/psulsa/",
|
||||
"/psulsa/",
|
||||
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
|
||||
],
|
||||
handler.RequestPaths);
|
||||
Assert.Equal("{\"CodigoPostal\":\"01000\"}", handler.RequestBodies[5]);
|
||||
Assert.Equal("{\"pIdEstado\":\"09\"}", handler.RequestBodies[6]);
|
||||
Assert.Equal(
|
||||
"{\"pIdEstado\":\"\",\"pLocalidad\":\"\",\"CodigoPostal\":\"01000\"}",
|
||||
handler.RequestBodies[7]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -224,6 +248,10 @@ public sealed class NtlmCredentialValidatorTests
|
||||
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
|
||||
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
|
||||
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
|
||||
<select id="ctl00_contenedor_ddlsexo">
|
||||
<option selected="selected" value="1">Masculino</option>
|
||||
<option value="2">Femenino</option>
|
||||
</select>
|
||||
"""),
|
||||
Response(
|
||||
HttpStatusCode.OK,
|
||||
@@ -232,14 +260,24 @@ public sealed class NtlmCredentialValidatorTests
|
||||
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||
<select id="ctl00_contenedor_ddlEstado">
|
||||
<option selected="selected">CIUDAD DE MÉXICO</option>
|
||||
<option selected="selected" value="0">Seleccione...</option>
|
||||
<option value="09">CIUDAD DE MÉXICO</option>
|
||||
</select>
|
||||
<select id="ctl00_contenedor_ddlLocalidad">
|
||||
<option selected="selected">ÁLVARO OBREGÓN</option>
|
||||
<option selected="selected" value="0">Seleccione alguna localidad...</option>
|
||||
</select>
|
||||
<select id="ctl00_contenedor_ddlColonia">
|
||||
<option selected="selected">FLORIDA</option>
|
||||
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
|
||||
</select>
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
|
||||
"""));
|
||||
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||
|
||||
@@ -262,6 +300,7 @@ public sealed class NtlmCredentialValidatorTests
|
||||
Assert.Equal("Álvaro Obregón", result.Profile.City);
|
||||
Assert.Equal("Ciudad de México", result.Profile.State);
|
||||
Assert.Equal("01000", result.Profile.PostalCode);
|
||||
Assert.Equal(InstitutionalGender.Male, result.Profile.Gender);
|
||||
Assert.Equal(
|
||||
[
|
||||
"/psulsa/",
|
||||
@@ -269,7 +308,10 @@ public sealed class NtlmCredentialValidatorTests
|
||||
"/psulsa/menu.aspx",
|
||||
"/psulsa/gadmon/nomina/consultanomina.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
|
||||
],
|
||||
handler.RequestPaths);
|
||||
}
|
||||
@@ -352,18 +394,29 @@ public sealed class NtlmCredentialValidatorTests
|
||||
Content = new StringContent(content)
|
||||
};
|
||||
|
||||
private static HttpResponseMessage JsonResponse(string content) =>
|
||||
new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(content, null, "application/json")
|
||||
};
|
||||
|
||||
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> responses = new(responses);
|
||||
|
||||
public List<string> RequestPaths { get; } = [];
|
||||
|
||||
public List<string?> RequestBodies { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
RequestPaths.Add(request.RequestUri!.AbsolutePath);
|
||||
RequestBodies.Add(request.Content is null
|
||||
? null
|
||||
: request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult());
|
||||
if (responses.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("The validator sent more requests than expected.");
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$wallpaperScript = Join-Path $repositoryRoot 'scripts\Set-SguWelcomeWallpaper.ps1'
|
||||
$source = Get-Content -LiteralPath $wallpaperScript -Raw
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$ast = [Management.Automation.Language.Parser]::ParseFile($wallpaperScript, [ref]$tokens, [ref]$parseErrors)
|
||||
if ($parseErrors.Count) { throw ($parseErrors -join [Environment]::NewLine) }
|
||||
$lookup = $ast.Find({
|
||||
param($node)
|
||||
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -eq 'Get-DirectoryWelcomeMetadata'
|
||||
}, $true)
|
||||
|
||||
function Invoke-WelcomeFixture {
|
||||
param($DirectoryGender, [string]$ExplicitGender)
|
||||
|
||||
# Replace only the external directory lookup. Execute the actual script,
|
||||
# including its validated parameters, metadata assignment and JPEG renderer.
|
||||
$fixtureJson = [pscustomobject]@{
|
||||
DisplayName = 'Usuario de prueba'
|
||||
Gender = $DirectoryGender
|
||||
Location = 'Sala de pruebas'
|
||||
OrganizationalUnit = 'Laboratorio'
|
||||
} | ConvertTo-Json -Compress
|
||||
$fixtureFunction = 'function Get-DirectoryWelcomeMetadata { param($UserName, $MachineName); ConvertFrom-Json ''' +
|
||||
$fixtureJson.Replace("'", "''") + ''' }'
|
||||
$fixtureSource = $source.Remove($lookup.Extent.StartOffset, $lookup.Extent.EndOffset - $lookup.Extent.StartOffset).
|
||||
Insert($lookup.Extent.StartOffset, $fixtureFunction)
|
||||
$testScript = Join-Path $TestDrive ('wallpaper-' + [Guid]::NewGuid().ToString('N') + '.ps1')
|
||||
[IO.File]::WriteAllText($testScript, $fixtureSource, [Text.UTF8Encoding]::new($false))
|
||||
$parameters = @{
|
||||
BaseImagePath = Join-Path $repositoryRoot 'assets\branding\darkblue.jpg'
|
||||
FontsPath = Join-Path $repositoryRoot 'assets\branding\fonts'
|
||||
OutputPath = Join-Path $TestDrive ([IO.Path]::GetFileNameWithoutExtension($testScript) + '.jpg')
|
||||
CanvasWidth = 640
|
||||
CanvasHeight = 480
|
||||
SkipApply = $true
|
||||
}
|
||||
if ($ExplicitGender) { $parameters.Gender = $ExplicitGender }
|
||||
$previousLocalAppData = $env:LOCALAPPDATA
|
||||
try {
|
||||
$env:LOCALAPPDATA = $TestDrive
|
||||
& $testScript @parameters
|
||||
}
|
||||
finally { $env:LOCALAPPDATA = $previousLocalAppData }
|
||||
}
|
||||
|
||||
Describe 'Welcome wallpaper with AD metadata' {
|
||||
It 'renders a neutral JPEG when AD has no gender' {
|
||||
$result = Invoke-WelcomeFixture -DirectoryGender $null
|
||||
$result.WelcomeHeading | Should Be 'Te damos la bienvenida,'
|
||||
$result.Applied | Should Be $false
|
||||
$bitmap = [Drawing.Image]::FromFile($result.OutputPath)
|
||||
try { $bitmap.Width | Should Be 640; $bitmap.Height | Should Be 480 }
|
||||
finally { $bitmap.Dispose() }
|
||||
}
|
||||
|
||||
It 'uses neutral wording for empty or unrecognized metadata' {
|
||||
foreach ($value in @('', 'Unknown')) {
|
||||
(Invoke-WelcomeFixture -DirectoryGender $value).WelcomeHeading | Should Be 'Te damos la bienvenida,'
|
||||
}
|
||||
}
|
||||
|
||||
It 'keeps the gendered greetings for recognized directory values' {
|
||||
(Invoke-WelcomeFixture -DirectoryGender 'Female').WelcomeHeading | Should Be 'Bienvenida,'
|
||||
(Invoke-WelcomeFixture -DirectoryGender 'Male').WelcomeHeading | Should Be 'Bienvenido,'
|
||||
}
|
||||
|
||||
It 'honors an explicit gender over directory metadata' {
|
||||
(Invoke-WelcomeFixture -DirectoryGender 'Female' -ExplicitGender 'Male').WelcomeHeading | Should Be 'Bienvenido,'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user