Automate direct domain enrollment across Windows versions
This commit is contained in:
@@ -104,9 +104,10 @@ 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 connected to local Hyper-V clients through Azure VPN
|
||||
Gateway, use [docs/azure-vpn-deployment.md](docs/azure-vpn-deployment.md). AD
|
||||
ports remain private even though the VM owns a public IP.
|
||||
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.
|
||||
@@ -127,8 +128,10 @@ Start-SguClientEnrollment.cmd 192.168.50.10
|
||||
El bootstrap prueba interfaces y rutas hacia el servidor, incluyendo VPN ya
|
||||
conectadas, sin pedir la IP del cliente ni exigir la misma subred. Conserva DHCP
|
||||
y el DNS de Internet; descubre el dominio autenticado y configura DNS sólo para
|
||||
ese dominio. El servidor debe tener SGU preparado y existir conectividad LAN/VPN.
|
||||
Los casos sin DHCP, sin ruta o con VPN desconectada muestran un diagnóstico.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,24 +1,27 @@
|
||||
# Active Directory SGU en Azure con VPN Point-to-Site
|
||||
# Active Directory SGU en Azure: VPN opcional o enrolamiento directo
|
||||
|
||||
Esta variante conserva Active Directory en una VM Windows Server 2025 con IP
|
||||
pública de Azure, pero **no publica Active Directory en Internet**. La IP pública
|
||||
sirve para el ciclo de vida y, opcionalmente, RDP desde un único CIDR
|
||||
administrativo. DNS, Kerberos, LDAP, SMB, RPC, WinRM, Auth Broker, monitoreo y
|
||||
RustDesk viajan por Azure VPN Gateway Point-to-Site (P2S).
|
||||
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 `GatewaySubnet`;
|
||||
- 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 `VpnGw1` con IKEv2/SSTP y autenticación por certificados;
|
||||
- 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 son parámetros. Deben ser RFC1918 y no deben solaparse con las
|
||||
redes usadas por Hyper-V, el `Default Switch`, Wi-Fi o Ethernet locales.
|
||||
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. Crear la autoridad P2S y el certificado de administración
|
||||
## 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:
|
||||
|
||||
@@ -33,6 +36,10 @@ 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
|
||||
@@ -47,6 +54,19 @@ $azure = .\scripts\Deploy-SguAzureInfrastructure.ps1 `
|
||||
-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
|
||||
@@ -61,9 +81,10 @@ pública actual:
|
||||
```
|
||||
|
||||
No utilice `0.0.0.0/0`. El despliegue de un VPN Gateway suele tardar bastante
|
||||
más que la VM; el comando espera hasta que Azure entregue un resultado final.
|
||||
más que la VM; omitirlo reduce tiempo y costo. El comando espera hasta que Azure
|
||||
entregue un resultado final.
|
||||
|
||||
## 3. Descargar P2S y entrar por la IP privada
|
||||
## 3. Conectarse al servidor
|
||||
|
||||
Cuando el gateway esté `Succeeded`:
|
||||
|
||||
@@ -85,8 +106,11 @@ 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,
|
||||
no como el camino normal.
|
||||
`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
|
||||
|
||||
@@ -110,9 +134,10 @@ 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` y ambos prefijos en `AllowedRemoteAddresses`.
|
||||
en `TrustedClientNetworks` cuando exista VPN y los CIDR directos en
|
||||
`PublicEnrollmentNetworks` cuando se hayan habilitado.
|
||||
|
||||
## 5. Emitir un certificado y enrolar cada VM Hyper-V
|
||||
## 5. Enrolar cada VM Hyper-V
|
||||
|
||||
En la estación administrativa, emita una credencial distinta por equipo:
|
||||
|
||||
@@ -144,9 +169,28 @@ En una sola ejecución el comando:
|
||||
5. registra mTLS, instala SGU/RustDesk y une el equipo al dominio;
|
||||
6. reinicia Windows.
|
||||
|
||||
Si la red local bloquea IKEv2 (UDP 500/4500), el paquete de Azure también
|
||||
incluye un perfil SSTP sobre TCP 443, pero ese fallback todavía requiere
|
||||
instalación manual con el instalador oficial incluido en `WindowsAmd64`.
|
||||
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
|
||||
@@ -155,21 +199,23 @@ 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:
|
||||
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 10.77.0.4 -Port 5985
|
||||
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
|
||||
```
|
||||
|
||||
## Seguridad y referencias
|
||||
## Alcance de red y referencias
|
||||
|
||||
No agregue reglas NSG públicas para 53, 88, 135, 389, 445, 464, 636, 3268,
|
||||
3269 ni RPC dinámico. El conjunto de puertos necesario para una unión de dominio
|
||||
es precisamente la razón de encapsularlo en P2S.
|
||||
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)
|
||||
|
||||
@@ -19,7 +19,7 @@ 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, incluidos VPN Gateway y los clientes Hyper-V, está en
|
||||
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`.
|
||||
@@ -70,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
|
||||
@@ -114,12 +126,19 @@ 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. selecciona una interfaz con conectividad comprobada al servidor;
|
||||
2. abre una sesión WinRM autenticada con el DC, descubre el dominio y configura
|
||||
DNS mediante NRPT sólo para ese dominio, conservando el DNS de Internet;
|
||||
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;
|
||||
@@ -134,6 +153,11 @@ equipos. La contraseña existe sólo en memoria. El bootstrap:
|
||||
`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
|
||||
|
||||
@@ -36,6 +36,22 @@ contrario, el contenedor de equipos configurado en AD. Los parámetros
|
||||
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.
|
||||
|
||||
+6
-6
@@ -2,12 +2,12 @@
|
||||
|
||||
## Public Azure deployment
|
||||
|
||||
Owning a public Azure IP does not make the domain controller an Internet-facing
|
||||
directory service. The supported cloud topology exposes no AD DS, DNS, SMB,
|
||||
RPC, WinRM, broker, monitoring, or RustDesk port publicly. Hyper-V and later
|
||||
physical Windows clients enter the VNet through certificate-authenticated Azure
|
||||
VPN Gateway P2S; the Azure NSG and Windows firewall accept the P2S pool and the
|
||||
private VNet only. See [azure-vpn-deployment.md](azure-vpn-deployment.md).
|
||||
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
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Fecha: 2026-09-10. Paquete: 0.5.1.
|
||||
- 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
|
||||
## 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,
|
||||
@@ -46,12 +46,126 @@ Resultados comprobados:
|
||||
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
|
||||
|
||||
Windows 10 se cubrió mediante pruebas de compatibilidad y código compartido,
|
||||
pero no se ejecutó una instalación real en Windows 10 en esta sesión. La VPN
|
||||
Azure y otras VPN requieren validación en sus redes reales; las pruebas locales
|
||||
cubren rutas en otra subred, pero no un gateway Azure activo.
|
||||
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.
|
||||
|
||||
+98
-15
@@ -34,6 +34,9 @@ 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'
|
||||
|
||||
@@ -44,7 +47,10 @@ param vpnClientAddressPoolPrefix string = '172.30.0.0/24'
|
||||
param p2sRootCertificateName string = 'SGU-P2S-Root'
|
||||
|
||||
@description('Base64 DER bytes of the trusted P2S root certificate, without PEM markers.')
|
||||
param p2sRootCertificateData string
|
||||
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 = ''
|
||||
@@ -63,7 +69,7 @@ resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-0
|
||||
name: networkSecurityGroupName
|
||||
location: location
|
||||
properties: {
|
||||
securityRules: concat([
|
||||
securityRules: concat(deployVpnGateway ? [
|
||||
{
|
||||
name: 'Allow-SGU-P2S-clients'
|
||||
properties: {
|
||||
@@ -78,11 +84,77 @@ resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-0
|
||||
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: 110
|
||||
priority: 140
|
||||
access: 'Allow'
|
||||
direction: 'Inbound'
|
||||
protocol: 'Tcp'
|
||||
@@ -106,7 +178,7 @@ resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = {
|
||||
virtualNetworkAddressPrefix
|
||||
]
|
||||
}
|
||||
subnets: [
|
||||
subnets: concat([
|
||||
{
|
||||
name: domainControllerSubnetName
|
||||
properties: {
|
||||
@@ -116,13 +188,14 @@ resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = {
|
||||
}
|
||||
}
|
||||
}
|
||||
], deployVpnGateway ? [
|
||||
{
|
||||
name: gatewaySubnetName
|
||||
properties: {
|
||||
addressPrefix: gatewaySubnetPrefix
|
||||
}
|
||||
}
|
||||
]
|
||||
] : [])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +212,14 @@ resource domainControllerPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-0
|
||||
}
|
||||
}
|
||||
|
||||
resource gatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = {
|
||||
resource gatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = if (deployVpnGateway) {
|
||||
name: gatewayPublicIpName
|
||||
location: location
|
||||
zones: [
|
||||
'1'
|
||||
'2'
|
||||
'3'
|
||||
]
|
||||
sku: {
|
||||
name: 'Standard'
|
||||
}
|
||||
@@ -223,6 +301,8 @@ resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-07-01' = {
|
||||
}
|
||||
osDisk: {
|
||||
createOption: 'FromImage'
|
||||
// AD DS requires durable writes; the bootstrap stores NTDS on this disk.
|
||||
caching: 'None'
|
||||
managedDisk: {
|
||||
storageAccountType: 'Premium_LRS'
|
||||
}
|
||||
@@ -248,7 +328,7 @@ resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-07-01' = {
|
||||
}
|
||||
}
|
||||
|
||||
resource virtualNetworkGateway 'Microsoft.Network/virtualNetworkGateways@2024-05-01' = {
|
||||
resource virtualNetworkGateway 'Microsoft.Network/virtualNetworkGateways@2024-05-01' = if (deployVpnGateway) {
|
||||
name: virtualNetworkGatewayName
|
||||
location: location
|
||||
properties: {
|
||||
@@ -271,8 +351,8 @@ resource virtualNetworkGateway 'Microsoft.Network/virtualNetworkGateways@2024-05
|
||||
}
|
||||
]
|
||||
sku: {
|
||||
name: 'VpnGw1'
|
||||
tier: 'VpnGw1'
|
||||
name: 'VpnGw1AZ'
|
||||
tier: 'VpnGw1AZ'
|
||||
}
|
||||
vpnClientConfiguration: {
|
||||
vpnClientAddressPool: {
|
||||
@@ -282,7 +362,7 @@ resource virtualNetworkGateway 'Microsoft.Network/virtualNetworkGateways@2024-05
|
||||
}
|
||||
vpnClientProtocols: [
|
||||
'IkeV2'
|
||||
'SSTP'
|
||||
'OpenVPN'
|
||||
]
|
||||
vpnAuthenticationTypes: [
|
||||
'Certificate'
|
||||
@@ -307,17 +387,20 @@ output domainControllerPrivateIp string = domainControllerPrivateIp
|
||||
output domainControllerPublicIp string = domainControllerPublicIp.properties.ipAddress
|
||||
output virtualNetworkName string = virtualNetwork.name
|
||||
output virtualNetworkAddressPrefix string = virtualNetworkAddressPrefix
|
||||
output vpnGatewayName string = virtualNetworkGateway.name
|
||||
output vpnGatewayName string = deployVpnGateway ? virtualNetworkGateway.name : ''
|
||||
output vpnClientAddressPoolPrefix string = vpnClientAddressPoolPrefix
|
||||
output serverBootstrapArguments array = [
|
||||
output serverBootstrapArguments array = concat([
|
||||
'-ServerIPv4Address'
|
||||
domainControllerPrivateIp
|
||||
'-PrefixLength'
|
||||
last(split(domainControllerSubnetPrefix, '/'))
|
||||
'-NetworkConfigurationMode'
|
||||
'PlatformManaged'
|
||||
'-TrustedClientNetworks'
|
||||
vpnClientAddressPoolPrefix
|
||||
'-DnsForwarders'
|
||||
'168.63.129.16'
|
||||
]
|
||||
], deployVpnGateway ? [
|
||||
'-TrustedClientNetworks'
|
||||
vpnClientAddressPoolPrefix
|
||||
] : [], empty(publicEnrollmentSourceAddressPrefixes) ? [] : concat([
|
||||
'-PublicEnrollmentNetworks'
|
||||
], publicEnrollmentSourceAddressPrefixes))
|
||||
|
||||
@@ -7,7 +7,8 @@ param(
|
||||
[string]$DeploymentPrefix = 'sgu-lab',
|
||||
[Parameter(Mandatory)][string]$AdministratorUsername,
|
||||
[securestring]$AdministratorPassword,
|
||||
[Parameter(Mandatory)][string]$P2sRootCertificatePath,
|
||||
[string]$P2sRootCertificatePath,
|
||||
[bool]$DeployVpnGateway = $true,
|
||||
[string]$ComputerName = 'SGU-DC01',
|
||||
[string]$VmSize = 'Standard_D2s_v5',
|
||||
[string]$VirtualNetworkAddressPrefix = '10.77.0.0/16',
|
||||
@@ -15,6 +16,7 @@ param(
|
||||
[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')
|
||||
)
|
||||
@@ -28,20 +30,24 @@ if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
|
||||
if (-not (Test-Path -LiteralPath $TemplateFile -PathType Leaf)) {
|
||||
throw "Azure Bicep template not found: $TemplateFile"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $P2sRootCertificatePath -PathType Leaf)) {
|
||||
throw "P2S root certificate not found: $P2sRootCertificatePath"
|
||||
}
|
||||
if (-not $AdministratorPassword) {
|
||||
$AdministratorPassword = Read-Host 'Password for the local Azure VM administrator' -AsSecureString
|
||||
}
|
||||
|
||||
$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 = ''
|
||||
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)
|
||||
}
|
||||
$rootCertificateData = [Convert]::ToBase64String($rootCertificate.RawData)
|
||||
|
||||
$account = & az account show --output json 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
@@ -52,7 +58,13 @@ if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not select Azure subscription $SubscriptionId."
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", 'Create Azure VNet, Windows Server 2025 VM, public IP, and P2S VPN Gateway')) {
|
||||
$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."
|
||||
@@ -89,7 +101,9 @@ if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", 'Create Azure VNe
|
||||
gatewaySubnetPrefix = @{ value = $GatewaySubnetPrefix }
|
||||
domainControllerPrivateIp = @{ value = $DomainControllerPrivateIp.IPAddressToString }
|
||||
vpnClientAddressPoolPrefix = @{ value = $VpnClientAddressPoolPrefix }
|
||||
deployVpnGateway = @{ value = $DeployVpnGateway }
|
||||
p2sRootCertificateData = @{ value = $rootCertificateData }
|
||||
publicEnrollmentSourceAddressPrefixes = @{ value = @($PublicEnrollmentSourceAddressPrefixes) }
|
||||
administratorSourceAddressPrefix = @{ value = $AdministratorSourceAddressPrefix }
|
||||
}
|
||||
}
|
||||
@@ -135,6 +149,8 @@ if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", 'Create Azure VNe
|
||||
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,6 +18,7 @@ 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',
|
||||
@@ -57,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
|
||||
@@ -133,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
|
||||
|
||||
@@ -9,6 +9,7 @@ param(
|
||||
[ValidateSet('GuestStatic', 'PlatformManaged')]
|
||||
[string]$NetworkConfigurationMode = 'GuestStatic',
|
||||
[string[]]$TrustedClientNetworks = @(),
|
||||
[string[]]$PublicEnrollmentNetworks = @(),
|
||||
[ipaddress[]]$DnsForwarders = @(),
|
||||
[string]$DomainName = 'lci.lasalle.mx',
|
||||
[string]$DomainNetbios = 'LCI',
|
||||
@@ -133,6 +134,74 @@ function ConvertTo-PrivateNetworkCidr {
|
||||
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)
|
||||
|
||||
@@ -141,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
|
||||
})
|
||||
@@ -397,6 +466,7 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
|
||||
$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
|
||||
@@ -417,7 +487,10 @@ $TrustedClientNetworks = @($TrustedClientNetworks |
|
||||
ForEach-Object { ConvertTo-PrivateNetworkCidr -Cidr $_ } |
|
||||
Where-Object { $_ -ne $domainSubnet } |
|
||||
Select-Object -Unique)
|
||||
$allowedRemoteAddresses = @($domainSubnet) + $TrustedClientNetworks
|
||||
$PublicEnrollmentNetworks = @($PublicEnrollmentNetworks |
|
||||
ForEach-Object { ConvertTo-PublicNetworkCidr -Cidr $_ } |
|
||||
Select-Object -Unique)
|
||||
$allowedRemoteAddresses = @($domainSubnet) + $TrustedClientNetworks + $PublicEnrollmentNetworks
|
||||
|
||||
$sourceRoot = $PSScriptRoot
|
||||
if (-not $Resume) {
|
||||
@@ -482,6 +555,7 @@ if (-not $existingState) {
|
||||
DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null }
|
||||
NetworkConfigurationMode = $NetworkConfigurationMode
|
||||
TrustedClientNetworks = $TrustedClientNetworks
|
||||
PublicEnrollmentNetworks = $PublicEnrollmentNetworks
|
||||
DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString)
|
||||
DomainName = $DomainName
|
||||
DomainNetbios = $DomainNetbios
|
||||
@@ -560,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 `
|
||||
@@ -708,6 +782,8 @@ foreach ($hostRecord in $hostRecords) {
|
||||
|
||||
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
|
||||
-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) {
|
||||
@@ -796,6 +872,7 @@ $validation = [ordered]@{
|
||||
ServerIPv4Address = $ServerIPv4Address.IPAddressToString
|
||||
NetworkConfigurationMode = $NetworkConfigurationMode
|
||||
TrustedClientNetworks = $TrustedClientNetworks
|
||||
PublicEnrollmentNetworks = $PublicEnrollmentNetworks
|
||||
AllowedRemoteAddresses = $allowedRemoteAddresses
|
||||
BrokerDnsName = $brokerDnsName
|
||||
BrokerCertificateThumbprint = $serverCertificate.Thumbprint
|
||||
|
||||
@@ -100,7 +100,7 @@ try {
|
||||
if ($Connect) {
|
||||
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Windows could not connect $ConnectionName. Verify UDP 500/4500 (IKEv2) or use the Azure-generated SSTP profile when the local network blocks IKEv2."
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,18 @@ function Test-IPv4AddressesSharePrefix {
|
||||
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,
|
||||
@@ -355,6 +367,95 @@ function Set-ClientDomainDns {
|
||||
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,
|
||||
@@ -429,6 +530,11 @@ if ($DomainControllerIPv4Address.AddressFamily -ne [Net.Sockets.AddressFamily]::
|
||||
$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
|
||||
$packageManifest = Assert-PackageManifest -PackageRoot $packageRoot
|
||||
@@ -595,7 +701,141 @@ try {
|
||||
$serverIdentity.RustDeskHbbrTask -ne 'Running') {
|
||||
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."
|
||||
@@ -680,6 +920,7 @@ try {
|
||||
DomainCredential = $DomainCredential
|
||||
DomainName = $DomainName
|
||||
DomainNetbios = $DomainNetbios
|
||||
DomainControllerDnsName = "$($serverIdentity.ComputerName).$DomainName"
|
||||
ComputerOuDn = $ComputerOuDn
|
||||
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
||||
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
|
||||
|
||||
@@ -113,10 +113,11 @@ Bootstrap reproducible para el laboratorio SGU.
|
||||
- `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-windows-client-bootstrap-$Version.zip`: paquete único para Windows 10 1607+ y Windows 11 x64 Pro, Enterprise o Education, con LAN, VPN existente y Azure P2S opcional.
|
||||
- Doble clic en `Start-SguClientEnrollment.cmd`, IP del servidor y credenciales: descubre el dominio autenticado, comprueba las interfaces y rutas disponibles y conserva DHCP, las IP del cliente y el DNS de Internet. Ya no solicita una IP del cliente.
|
||||
- Conserva seguridad mTLS, Credential Provider, cuenta estándar, RustDesk, supervisión y autorreparación. El servidor debe estar preparado con SGU y ser accesible por LAN o VPN.
|
||||
- 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.
|
||||
- `sgu-azure-infrastructure-$Version.zip`: despliega mediante Bicep una VM Windows Server 2025, red privada, IP pública protegida por NSG y Azure VPN Gateway P2S; también genera certificados por equipo y descarga el perfil de cliente.
|
||||
- El bootstrap Azure conserva la IP privada administrada por la NIC de Azure, autoriza el pool P2S en los firewalls SGU y nunca publica LDAP, Kerberos, SMB, RPC, WinRM ni el Auth Broker directamente a Internet.
|
||||
- `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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -32,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
|
||||
@@ -45,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
|
||||
|
||||
@@ -15,6 +15,29 @@ function Get-LocalUserFlags {
|
||||
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)) {
|
||||
@@ -64,14 +87,16 @@ try {
|
||||
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
|
||||
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
|
||||
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
|
||||
if ($administratorMembers.SID.Value -contains $user.SID.Value) {
|
||||
Remove-LocalGroupMember -Group $administratorsGroup -Member $user -Confirm:$false
|
||||
$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-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
|
||||
if ($standardMembers.SID.Value -notcontains $user.SID.Value) {
|
||||
Add-LocalGroupMember -Group $usersGroup -Member $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 {
|
||||
@@ -85,12 +110,12 @@ $verifiedAdministratorsGroup = Get-LocalGroup `
|
||||
$verifiedUsersGroup = Get-LocalGroup `
|
||||
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')) `
|
||||
-ErrorAction Stop
|
||||
$verifiedAdministrators = @(Get-LocalGroupMember -Group $verifiedAdministratorsGroup -ErrorAction Stop)
|
||||
$verifiedUsers = @(Get-LocalGroupMember -Group $verifiedUsersGroup -ErrorAction Stop)
|
||||
if (@($verifiedAdministrators).SID.Value -contains $verifiedUser.SID.Value) {
|
||||
$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.SID.Value -notcontains $verifiedUser.SID.Value) {
|
||||
if ($verifiedUsers -notcontains $verifiedUser.SID.Value) {
|
||||
throw "The local account '$userName' does not belong to the local Users group."
|
||||
}
|
||||
$verifiedPasswordNeverExpires =
|
||||
|
||||
@@ -316,7 +316,9 @@ if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) {
|
||||
$Location = $metadata.Location
|
||||
}
|
||||
$genderWasProvided = $PSBoundParameters.ContainsKey('Gender')
|
||||
if (-not $genderWasProvided -and $metadata) {
|
||||
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
|
||||
|
||||
@@ -22,6 +22,28 @@ $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) {
|
||||
$issues.Add('The computer is not joined to a domain.')
|
||||
@@ -100,12 +122,12 @@ if ($standardLocalUserPresent) {
|
||||
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
|
||||
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
|
||||
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
|
||||
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
|
||||
$administratorMembers = @(Get-LocalGroupMemberSid -Name $administratorsGroup.Name)
|
||||
$standardMembers = @(Get-LocalGroupMemberSid -Name $usersGroup.Name)
|
||||
$standardLocalUserIsAdministrator =
|
||||
$administratorMembers.SID.Value -contains $standardLocalUser.SID.Value
|
||||
$administratorMembers -contains $standardLocalUser.SID.Value
|
||||
$standardLocalUserInUsersGroup =
|
||||
$standardMembers.SID.Value -contains $standardLocalUser.SID.Value
|
||||
$standardMembers -contains $standardLocalUser.SID.Value
|
||||
try {
|
||||
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$standardLocalUserName,user")
|
||||
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
|
||||
@@ -195,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.')
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ $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
|
||||
@@ -16,7 +18,10 @@ if ($parseErrors.Count -gt 0) {
|
||||
$networkFunctionNames = @(
|
||||
'Test-PrivateIPv4Address',
|
||||
'ConvertTo-NetworkCidr',
|
||||
'ConvertTo-PrivateNetworkCidr'
|
||||
'ConvertTo-PrivateNetworkCidr',
|
||||
'ConvertTo-PublicNetworkCidr',
|
||||
'Get-ActiveIPv4Adapters',
|
||||
'Resolve-PrivateInterfaceAlias'
|
||||
)
|
||||
$networkFunctions = $serverAst.FindAll({
|
||||
param($node)
|
||||
@@ -39,7 +44,7 @@ $clientNetworkFunctions = $clientAst.FindAll({
|
||||
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -in @('Test-IPv4AddressesSharePrefix', 'Resolve-ClientInterfaceAlias',
|
||||
'Set-ClientServerRoute', 'Set-ClientDomainDns', 'Test-TcpPort', 'Assert-ClientOperatingSystem',
|
||||
'Wait-ClientInterface')
|
||||
'Wait-ClientInterface', 'Test-PrivateIPv4Address', 'Test-ClientDomainDns')
|
||||
}, $true)
|
||||
Invoke-Expression (($clientNetworkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine)
|
||||
|
||||
@@ -65,11 +70,29 @@ Describe 'SGU public-cloud network safety' {
|
||||
$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
|
||||
@@ -117,14 +140,57 @@ Describe 'SGU public-cloud network safety' {
|
||||
$source | Should Match 'Add-DnsClientNrptRule'
|
||||
}
|
||||
|
||||
It 'limits optional public administration to RDP' {
|
||||
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 {
|
||||
@@ -293,6 +359,23 @@ Describe 'SGU Windows capability checks' {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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