Automate direct domain enrollment across Windows versions

This commit is contained in:
2026-09-11 17:34:19 -06:00
parent 7f8a9eed4e
commit 520b4be955
23 changed files with 1244 additions and 108 deletions
+8 -5
View File
@@ -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.
+72 -26
View File
@@ -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)
+26 -2
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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
+119 -5
View File
@@ -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
View File
@@ -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))
+27 -11
View File
@@ -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
}
}
+23 -5
View File
@@ -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'
+21 -4
View File
@@ -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
}
+27 -1
View File
@@ -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
+80 -3
View File
@@ -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
+1 -1
View File
@@ -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."
}
}
+241
View File
@@ -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)
+4 -3
View File
@@ -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
}
+10 -1
View File
@@ -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
+35 -10
View File
@@ -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 =
+3 -1
View File
@@ -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
+34 -6
View File
@@ -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.')
}
+86 -3
View File
@@ -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)
+72
View File
@@ -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,'
}
}