From 520b4be9551bb4718c546bf712f1b0110820e851 Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Fri, 11 Sep 2026 17:34:19 -0600 Subject: [PATCH] Automate direct domain enrollment across Windows versions --- README.md | 13 +- .../azure-deployment-validation-2026-09-10.md | 136 ++++++++++ docs/azure-vpn-deployment.md | 98 +++++-- docs/bootstrap-recovery.md | 28 +- docs/client-enrollment.md | 16 ++ docs/security.md | 12 +- docs/unified-bootstrap-validation.md | 124 ++++++++- infra/azure/main.bicep | 113 ++++++-- scripts/Deploy-SguAzureInfrastructure.ps1 | 38 ++- scripts/Enable-LabRemoteAccess.ps1 | 28 +- scripts/Enable-SguClientMonitoring.ps1 | 25 +- scripts/Enroll-SguDomainClient.ps1 | 28 +- scripts/Initialize-SguDomainController.ps1 | 83 +++++- scripts/Install-SguAzureP2sClient.ps1 | 2 +- scripts/Invoke-SguClientBootstrap.ps1 | 241 ++++++++++++++++++ scripts/Publish-GiteaRelease.ps1 | 7 +- scripts/Repair-SguAzureDomainConnectivity.ps1 | 99 +++++++ scripts/Repair-SguClientEnrollment.ps1 | 11 +- scripts/Set-SguStandardLocalUser.ps1 | 45 +++- scripts/Set-SguWelcomeWallpaper.ps1 | 4 +- scripts/Test-SguClientEnrollment.ps1 | 40 ++- tests/BootstrapNetwork.Tests.ps1 | 89 ++++++- tests/WelcomeWallpaper.Tests.ps1 | 72 ++++++ 23 files changed, 1244 insertions(+), 108 deletions(-) create mode 100644 docs/azure-deployment-validation-2026-09-10.md create mode 100644 scripts/Repair-SguAzureDomainConnectivity.ps1 create mode 100644 tests/WelcomeWallpaper.Tests.ps1 diff --git a/README.md b/README.md index d2ff570..40c30d7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/azure-deployment-validation-2026-09-10.md b/docs/azure-deployment-validation-2026-09-10.md new file mode 100644 index 0000000..c92bc38 --- /dev/null +++ b/docs/azure-deployment-validation-2026-09-10.md @@ -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. diff --git a/docs/azure-vpn-deployment.md b/docs/azure-vpn-deployment.md index 9c893af..6d0bb0f 100644 --- a/docs/azure-vpn-deployment.md +++ b/docs/azure-vpn-deployment.md @@ -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) diff --git a/docs/bootstrap-recovery.md b/docs/bootstrap-recovery.md index 4cabda4..df7a6ca 100644 --- a/docs/bootstrap-recovery.md +++ b/docs/bootstrap-recovery.md @@ -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 diff --git a/docs/client-enrollment.md b/docs/client-enrollment.md index eb28d86..3097298 100644 --- a/docs/client-enrollment.md +++ b/docs/client-enrollment.md @@ -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. diff --git a/docs/security.md b/docs/security.md index 8dd0e0c..85d136e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/docs/unified-bootstrap-validation.md b/docs/unified-bootstrap-validation.md index 1be78d3..c542b30 100644 --- a/docs/unified-bootstrap-validation.md +++ b/docs/unified-bootstrap-validation.md @@ -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. diff --git a/infra/azure/main.bicep b/infra/azure/main.bicep index 7270b48..906ed1a 100644 --- a/infra/azure/main.bicep +++ b/infra/azure/main.bicep @@ -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)) diff --git a/scripts/Deploy-SguAzureInfrastructure.ps1 b/scripts/Deploy-SguAzureInfrastructure.ps1 index ecc1c01..d9e8737 100644 --- a/scripts/Deploy-SguAzureInfrastructure.ps1 +++ b/scripts/Deploy-SguAzureInfrastructure.ps1 @@ -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 } } diff --git a/scripts/Enable-LabRemoteAccess.ps1 b/scripts/Enable-LabRemoteAccess.ps1 index 73c0d3c..4ba3115 100644 --- a/scripts/Enable-LabRemoteAccess.ps1 +++ b/scripts/Enable-LabRemoteAccess.ps1 @@ -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' diff --git a/scripts/Enable-SguClientMonitoring.ps1 b/scripts/Enable-SguClientMonitoring.ps1 index 595cc21..122ba20 100644 --- a/scripts/Enable-SguClientMonitoring.ps1 +++ b/scripts/Enable-SguClientMonitoring.ps1 @@ -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 } diff --git a/scripts/Enroll-SguDomainClient.ps1 b/scripts/Enroll-SguDomainClient.ps1 index f5883fe..51c03fb 100644 --- a/scripts/Enroll-SguDomainClient.ps1 +++ b/scripts/Enroll-SguDomainClient.ps1 @@ -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 diff --git a/scripts/Initialize-SguDomainController.ps1 b/scripts/Initialize-SguDomainController.ps1 index ed305a7..16c6582 100644 --- a/scripts/Initialize-SguDomainController.ps1 +++ b/scripts/Initialize-SguDomainController.ps1 @@ -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 diff --git a/scripts/Install-SguAzureP2sClient.ps1 b/scripts/Install-SguAzureP2sClient.ps1 index 2ebe251..efc7298 100644 --- a/scripts/Install-SguAzureP2sClient.ps1 +++ b/scripts/Install-SguAzureP2sClient.ps1 @@ -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." } } diff --git a/scripts/Invoke-SguClientBootstrap.ps1 b/scripts/Invoke-SguClientBootstrap.ps1 index 0fef596..56acd40 100644 --- a/scripts/Invoke-SguClientBootstrap.ps1 +++ b/scripts/Invoke-SguClientBootstrap.ps1 @@ -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) diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 index de34871..cef0b51 100644 --- a/scripts/Publish-GiteaRelease.ps1 +++ b/scripts/Publish-GiteaRelease.ps1 @@ -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. diff --git a/scripts/Repair-SguAzureDomainConnectivity.ps1 b/scripts/Repair-SguAzureDomainConnectivity.ps1 new file mode 100644 index 0000000..42d7706 --- /dev/null +++ b/scripts/Repair-SguAzureDomainConnectivity.ps1 @@ -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 +} diff --git a/scripts/Repair-SguClientEnrollment.ps1 b/scripts/Repair-SguClientEnrollment.ps1 index d23d9bf..672e8bd 100644 --- a/scripts/Repair-SguClientEnrollment.ps1 +++ b/scripts/Repair-SguClientEnrollment.ps1 @@ -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 diff --git a/scripts/Set-SguStandardLocalUser.ps1 b/scripts/Set-SguStandardLocalUser.ps1 index 441e48a..13114ce 100644 --- a/scripts/Set-SguStandardLocalUser.ps1 +++ b/scripts/Set-SguStandardLocalUser.ps1 @@ -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 = diff --git a/scripts/Set-SguWelcomeWallpaper.ps1 b/scripts/Set-SguWelcomeWallpaper.ps1 index b55f61e..6891c21 100644 --- a/scripts/Set-SguWelcomeWallpaper.ps1 +++ b/scripts/Set-SguWelcomeWallpaper.ps1 @@ -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 diff --git a/scripts/Test-SguClientEnrollment.ps1 b/scripts/Test-SguClientEnrollment.ps1 index 44e7d84..481f8b7 100644 --- a/scripts/Test-SguClientEnrollment.ps1 +++ b/scripts/Test-SguClientEnrollment.ps1 @@ -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.') } diff --git a/tests/BootstrapNetwork.Tests.ps1 b/tests/BootstrapNetwork.Tests.ps1 index f8626f6..bec0e16 100644 --- a/tests/BootstrapNetwork.Tests.ps1 +++ b/tests/BootstrapNetwork.Tests.ps1 @@ -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) diff --git a/tests/WelcomeWallpaper.Tests.ps1 b/tests/WelcomeWallpaper.Tests.ps1 new file mode 100644 index 0000000..e0aac62 --- /dev/null +++ b/tests/WelcomeWallpaper.Tests.ps1 @@ -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,' + } +}