Compare commits

..
17 Commits
Author SHA1 Message Date
alexrg 106be26e7c Default all users to local Windows profiles 2026-09-18 12:06:15 -06:00
alexrg 01e0ee5ac4 Bound FSLogix Cloud Cache sign-out time 2026-09-18 11:24:33 -06:00
alexrg c47913e81d Use FSLogix Cloud Cache for public profile storage 2026-09-18 11:09:44 -06:00
alexrg da310e4213 Deploy FSLogix automatically through domain policy 2026-09-18 10:22:28 -06:00
alexrg bda8b354b7 Enable public Azure Files roaming deployment 2026-09-18 09:55:39 -06:00
alexrg 7986b76e35 Disable Fast User Switching on domain clients 2026-09-18 09:06:43 -06:00
alexrg 7de6e2d867 Add Azure user roaming and reproducible Laboratorio wallpaper policy 2026-09-17 16:40:44 -06:00
alexrg 7460f9316d Apply lock screen through PersonalizationCSP 2026-09-17 15:19:43 -06:00
alexrg 351adb0bdc Persist per-machine welcome location 2026-09-17 14:59:35 -06:00
alexrg 2de1105ecd Customize welcome text from room and OU 2026-09-17 11:46:33 -06:00
alexrg 6c52a9546c Make Gitea release uploads resilient 2026-09-11 17:41:07 -06:00
alexrg 520b4be955 Automate direct domain enrollment across Windows versions 2026-09-11 17:34:19 -06:00
alexrg 7f8a9eed4e Unify Windows client bootstrap and discover network paths 2026-09-10 08:13:46 -06:00
alexrg 74235ec6f8 Split Windows client bootstrap profiles 2026-09-09 11:04:01 -06:00
alexrg 0b510082d4 Validate local password expiration on Windows 10 2026-09-09 10:31:53 -06:00
alexrg 5e62a65316 Honor Windows local user description limit 2026-09-09 10:23:03 -06:00
alexrg 1332f546fa Wait for Windows enrollment network readiness 2026-09-09 10:19:36 -06:00
44 changed files with 4160 additions and 241 deletions
+28 -9
View File
@@ -60,6 +60,8 @@ Operational documentation:
- [Linux client enrollment with realmd and SSSD](docs/linux-client-enrollment.md) - [Linux client enrollment with realmd and SSSD](docs/linux-client-enrollment.md)
- [Self-hosted RustDesk server and managed Windows remote access](docs/rustdesk-operations.md) - [Self-hosted RustDesk server and managed Windows remote access](docs/rustdesk-operations.md)
- [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md) - [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md)
- [Laboratorio desktop wallpaper protection](docs/laboratorio-wallpaper-policy.md)
- [Optional Azure user roaming by role with Azure Files and FSLogix](docs/user-roaming.md)
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md) - [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
| Prefix | Role | Default OU | Security group in the same OU | | Prefix | Role | Default OU | Security group in the same OU |
@@ -104,24 +106,41 @@ the latest .NET 10 x64 runtime. The broker is published self-contained.
Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review
[docs/security.md](docs/security.md) before production deployment and [docs/security.md](docs/security.md) before production deployment and
[docs/architecture.md](docs/architecture.md) for the component contract. [docs/architecture.md](docs/architecture.md) for the component contract.
For a public Azure VM connected to local Hyper-V clients through Azure VPN For a public Azure VM, use
Gateway, use [docs/azure-vpn-deployment.md](docs/azure-vpn-deployment.md). AD [docs/azure-vpn-deployment.md](docs/azure-vpn-deployment.md). It supports an
ports remain private even though the VM owns a public IP. optional Azure P2S gateway or direct enrollment restricted to explicit public
source CIDRs. Every SGU role uses an ordinary local Windows profile by default:
Desktop and Documents stay under `C:\Users` and no profile share is mapped.
Azure Files/FSLogix roaming remains available only as an experimental,
explicit opt-in that requires acknowledgement of its login, sign-out, storage,
cache and recovery risks. See [docs/user-roaming.md](docs/user-roaming.md) for
the opt-in and rollback procedures.
Never disable the built-in Microsoft password Credential Provider. It is the Never disable the built-in Microsoft password Credential Provider. It is the
supported recovery path if a third-party provider fails to load. supported recovery path if a third-party provider fails to load.
For a clean machine, the supported entry points are the release packages: For a clean Windows 10 or Windows 11 workstation, use the unified package:
- `sgu-windows-client-bootstrap-VERSION.zip` (x64 Pro, Enterprise or Education),
including optional Azure P2S setup for both versions.
Double-click `Start-SguClientEnrollment.cmd` and enter the server IP and domain
credentials, or provide the server IP on the command line:
```bat ```bat
Start-SguServerBootstrap.cmd 192.168.50.10 Start-SguServerBootstrap.cmd 192.168.50.10
Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11 Start-SguClientEnrollment.cmd 192.168.50.10
``` ```
El segundo argumento es la IP fija, única, del cliente en la red privada. Si se El bootstrap prueba interfaces y rutas hacia el servidor, incluyendo VPN ya
omite y ese adaptador todavía usa una dirección `169.254.x.x`, el enrolador la conectadas, sin pedir la IP del cliente ni exigir la misma subred. Conserva DHCP
solicita de forma interactiva. En equipos con dos NIC selecciona el adaptador y el DNS de Internet; descubre el dominio autenticado y configura DNS sólo para
sin puerta de enlace y conserva el `Default Switch` para Internet. ese dominio. Si recibe una IP pública, configura DoH y los nombres necesarios de
AD después de autenticar al servidor. El segmento público del cliente debe estar
autorizado en el servidor y su firewall perimetral. Los casos sin DHCP o sin ruta
muestran un diagnóstico.
Ver [client-enrollment.md](docs/client-enrollment.md) para requisitos y parámetros
avanzados de IP estática.
Linux clients are enrolled through their native PAM/SSSD stack instead of the Linux clients are enrolled through their native PAM/SSSD stack instead of the
Windows Credential Provider: Windows Credential Provider:
+15
View File
@@ -78,6 +78,21 @@ is deliberately left unset because the verified page does not expose it.
Missing metadata does not clear existing AD values and never changes the Missing metadata does not clear existing AD values and never changes the
password outcome. password outcome.
## Optional Azure user roaming
The default architecture uses local Windows profiles for every role. Desktop,
Documents and the remainder of the profile live under `C:\Users`; enrollment
does not map a profile share and the domain GPO keeps FSLogix disabled.
An experimental opt-in can add a dedicated Azure Files account. Student
accounts (`AL`) redirect only Documents/Desktop, while administrative and
professor accounts (`AD`/`DO`) receive an FSLogix VHDX profile container through
object-specific settings. Enabling it is never implicit: both infrastructure
deployment and policy configuration require an explicit risk acknowledgement.
The storage account uses AD DS Kerberos with AES-256 and share-root ACLs use
`CREATOR OWNER`. See [user-roaming.md](user-roaming.md) for the observed
sign-out/cache failure modes, staged rollout requirements and rollback.
Every synchronization also enforces one idempotent security-group membership Every synchronization also enforces one idempotent security-group membership
from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to
`SGU-Administrativos`, and `DO` to `SGU-Docentes`. Each role group is stored `SGU-Administrativos`, and `DO` to `SGU-Docentes`. Each role group is stored
@@ -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.
+94 -31
View File
@@ -1,24 +1,29 @@
# 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 La misma plantilla despliega Active Directory en Windows Server 2025 y permite
pública de Azure, pero **no publica Active Directory en Internet**. La IP pública elegir entre Azure VPN Gateway Point-to-Site (P2S) o acceso público directo. El
sirve para el ciclo de vida y, opcionalmente, RDP desde un único CIDR modo directo restringe AD, WinRM, Auth Broker y RustDesk a los CIDR públicos
administrativo. DNS, Kerberos, LDAP, SMB, RPC, WinRM, Auth Broker, monitoreo y indicados; el cliente configura DoH y la resolución del dominio automáticamente.
RustDesk viajan por Azure VPN Gateway Point-to-Site (P2S). La lista pública vacía no expone esos servicios.
La plantilla crea: 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; - 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; - 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; - pool P2S `172.30.0.0/24`, autorizado en los firewalls SGU;
- DNS de la NIC del servidor apuntando a `10.77.0.4`. - DNS de la NIC del servidor apuntando a `10.77.0.4`;
- perfiles locales para todos los usuarios por omisión; Azure Files/FSLogix es
una característica opcional que debe habilitarse y aceptarse explícitamente.
Los prefijos son parámetros. Deben ser RFC1918 y no deben solaparse con las Los prefijos privados deben ser RFC1918 y no deben solaparse con las redes usadas
redes usadas por Hyper-V, el `Default Switch`, Wi-Fi o Ethernet locales. 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: En la estación administrativa donde está el repositorio:
@@ -33,6 +38,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 únicamente a la VM correspondiente y eliminarse de ubicaciones compartidas
después de importarlo. 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 ## 2. Desplegar Azure
Requisitos: Azure CLI, una sesión iniciada con `az login`, permisos para crear Requisitos: Azure CLI, una sesión iniciada con `az login`, permisos para crear
@@ -47,6 +56,19 @@ $azure = .\scripts\Deploy-SguAzureInfrastructure.ps1 `
-P2sRootCertificatePath $p2s.RootCertificatePath -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 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 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 terminar. No aparece en los argumentos de Azure CLI ni queda guardada en el
@@ -61,9 +83,13 @@ pública actual:
``` ```
No utilice `0.0.0.0/0`. El despliegue de un VPN Gateway suele tardar bastante 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. User roaming está desactivado por omisión. Sólo se
provisiona al indicar juntos `-DeployUserRoaming $true` y
`-AcknowledgeUserRoamingRisks`, después de revisar
[sus riesgos y recuperación](user-roaming.md).
## 3. Descargar P2S y entrar por la IP privada ## 3. Conectarse al servidor
Cuando el gateway esté `Succeeded`: Cuando el gateway esté `Succeeded`:
@@ -85,8 +111,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 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 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 necesario abrir 3389 en la IP pública. La opción
`AdministratorSourceAddressPrefix` queda como ruta de recuperación temporal, `AdministratorSourceAddressPrefix` queda como ruta de recuperación temporal.
no como el camino normal.
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 ## 4. Ejecutar el bootstrap dentro de Windows Server
@@ -110,9 +139,18 @@ 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 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 Después de terminar la promoción no se necesita ninguna acción de perfiles: las
cuentas usarán archivos locales. Si se decide realizar un piloto explícito de
Azure Files, publique las políticas ejecutando
`Enable-SguAzureUserRoaming.ps1` con `-AcknowledgeUserRoamingRisks`. Esa fase opcional requiere Azure PowerShell y el
módulo oficial AzFilesHybrid porque el SID y GUID del bosque todavía no existen
durante el despliegue Bicep. Consulte
[user roaming por rol en Azure](user-roaming.md).
## 5. Enrolar cada VM Hyper-V
En la estación administrativa, emita una credencial distinta por equipo: En la estación administrativa, emita una credencial distinta por equipo:
@@ -120,9 +158,9 @@ En la estación administrativa, emita una credencial distinta por equipo:
$w11 = .\scripts\New-SguAzureP2sCertificates.ps1 -ClientName 'Windows11' $w11 = .\scripts\New-SguAzureP2sCertificates.ps1 -ClientName 'Windows11'
``` ```
Copie a la VM Windows 11 de Hyper-V: Copie a la VM Windows 10/11 de Hyper-V:
- `sgu-client-bootstrap-VERSION.zip` extraído; - `sgu-windows-client-bootstrap-VERSION.zip` extraído (Windows 10/11 x64);
- `$vpn.PackagePath`; - `$vpn.PackagePath`;
- `$w11.ClientCertificatePath`; - `$w11.ClientCertificatePath`;
- `sgu-azure-p2s-root.cer`. - `sgu-azure-p2s-root.cer`.
@@ -130,7 +168,7 @@ Copie a la VM Windows 11 de Hyper-V:
Desde la carpeta extraída del bootstrap de cliente, instale P2S y enrole: Desde la carpeta extraída del bootstrap de cliente, instale P2S y enrole:
```bat ```bat
Start-SguAzureClientEnrollment.cmd 10.77.0.4 C:\SGU\sgu-azure-vpn-client.zip C:\SGU\sgu-azure-p2s-Windows11.pfx C:\SGU\sgu-azure-p2s-root.cer Start-SguAzureClientEnrollment.cmd 10.77.0.4 C:\SGU\sgu-azure-vpn-client.zip C:\SGU\sgu-azure-p2s-Windows11.pfx C:\SGU\sgu-azure-p2s-root.cer C:\SGU\FSLogixAppsSetup.exe
``` ```
En una sola ejecución el comando: En una sola ejecución el comando:
@@ -144,32 +182,57 @@ En una sola ejecución el comando:
5. registra mTLS, instala SGU/RustDesk y une el equipo al dominio; 5. registra mTLS, instala SGU/RustDesk y une el equipo al dominio;
6. reinicia Windows. 6. reinicia Windows.
Si la red local bloquea IKEv2 (UDP 500/4500), el paquete de Azure también El quinto argumento es opcional. Cuando se proporciona, instala el binario
incluye un perfil SSTP sobre TCP 443, pero ese fallback todavía requiere oficial de FSLogix después de validar su firma de Microsoft; puede omitirse si
instalación manual con el instalador oficial incluido en `WindowsAmd64`. la imagen ya lo contiene. Las GPO activan el contenedor únicamente para AD/DO.
Windows 11 Pro admite unión a AD y VPN nativa, pero Microsoft no licencia el 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 **Always On VPN device tunnel** para Pro. Por ello el perfil se instala para
todos los usuarios y se puede seleccionar desde el control de red de la todos los usuarios y se puede seleccionar desde el control de red de la
pantalla de inicio de sesión; antes del primer logon de una cuenta de dominio, pantalla de inicio de sesión; antes del primer logon de una cuenta de dominio,
conecte `SGU Azure P2S` allí. Enterprise/Education pueden recibir posteriormente conecte `SGU Azure P2S` allí. Enterprise/Education pueden recibir posteriormente
un device tunnel Always On, pero eso no es requisito del enrolamiento SGU. 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 ```powershell
Get-VpnConnection -Name 'SGU Azure P2S' -AllUserConnection Get-VpnConnection -Name 'SGU Azure P2S' -AllUserConnection
Get-DnsClientNrptRule | Where-Object DisplayName -like 'SGU Azure P2S*' 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 Resolve-DnsName _ldap._tcp.dc._msdcs.lci.lasalle.mx -Type SRV
nltest.exe /dsgetdc:lci.lasalle.mx 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, P2S mantiene los puertos de AD dentro del túnel. El modo directo abre el conjunto
3269 ni RPC dinámico. El conjunto de puertos necesario para una unión de dominio necesario para la unión sólo desde `publicEnrollmentSourceAddressPrefixes` y
es precisamente la razón de encapsularlo en P2S. 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) - [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) - [Cliente P2S nativo de Windows](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-vpn-client-certificate)
+42 -14
View File
@@ -19,7 +19,7 @@ por esa u otra interfaz.
Cuando el servidor vive en Azure, no se configura la IP dentro del sistema 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 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). [azure-vpn-deployment.md](azure-vpn-deployment.md).
1. Descargar y extraer `sgu-server-bootstrap-VERSION.zip`. 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 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. 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 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. rechaza todos los certificados hasta que el primer cliente registra el suyo.
Los archivos opcionales colocados en `payload\server-content\Packages` al crear Los archivos opcionales colocados en `payload\server-content\Packages` al crear
@@ -96,40 +108,56 @@ Se admiten Pro, Enterprise y Education. Windows Home no puede unirse a Active
Directory local ni actuar como host RDP; el bootstrap lo detecta antes de cambiar Directory local ni actuar como host RDP; el bootstrap lo detecta antes de cambiar
el equipo y explica que se debe actualizar la edición. el equipo y explica que se debe actualizar la edición.
1. Descargar y extraer `sgu-client-bootstrap-VERSION.zip`. 1. Descargar y extraer `sgu-windows-client-bootstrap-VERSION.zip`, común para
Windows 10 y Windows 11 x64 Pro, Enterprise o Education.
2. Ejecutar con la IP fija actual del controlador de dominio: 2. Ejecutar con la IP fija actual del controlador de dominio:
```bat ```bat
Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11 Start-SguClientEnrollment.cmd 192.168.50.10
``` ```
El primer argumento es el controlador de dominio y el segundo es una dirección También puede hacerse doble clic y escribir la IP del controlador. El cliente
IPv4 fija, libre y exclusiva del cliente en la red privada. Si se omite la IP conserva sus IP/DHCP y prueba WinRM por las interfaces con rutas disponibles,
del cliente, el bootstrap la solicita cuando el adaptador sólo tiene APIPA incluyendo VPN y redes enrutadas. No requiere compartir subred con el servidor.
(`169.254.x.x`). En una VM con Internet por `Default Switch` y otra NIC para Si una NIC sólo tiene APIPA, necesita DHCP o una IP asignada por el administrador;
`Laboratorio AD`, el bootstrap elige la NIC sin puerta de enlace y no cambia la el bootstrap no inventa direcciones. Si falla, la ventana permanece abierta y
ruta predeterminada. Si falla, la ventana elevada permanece abierta y el mismo el error queda en `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
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 Después de UAC, se solicita interactivamente la credencial autorizada para unir
equipos. La contraseña existe sólo en memoria. El bootstrap: equipos. La contraseña existe sólo en memoria. El bootstrap:
1. apunta el DNS del adaptador al IP proporcionado; 1. selecciona una interfaz con conectividad comprobada al servidor;
2. abre una sesión WinRM autenticada con el DC y verifica que pertenece al 2. abre una sesión WinRM autenticada con el DC, descubre el dominio y configura
dominio esperado; DNS mediante NRPT sólo para ese dominio; 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 3. crea en el cliente un certificado mTLS RSA-3072 no exportable y envía sólo su
parte pública al broker; parte pública al broker;
4. recupera por esa sesión autenticada el certificado público del broker; 4. recupera por esa sesión autenticada el certificado público del broker;
5. instala el runtime .NET 10 offline y el Credential Provider; 5. instala el runtime .NET 10 offline y el Credential Provider;
6. valida binarios, registro COM, certificados y salud del broker; 6. valida binarios, registro COM, certificados y salud del broker;
7. instala el guardián de reparación al arranque; 7. instala el guardián de reparación al arranque;
8. sólo entonces ejecuta `Add-Computer` dentro de `OU=Laboratorio` y reinicia; 8. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` si existe, o en el
contenedor de equipos predeterminado del dominio, y reinicia;
9. al arrancar, activa RDP/NLA, WinRM y las reglas Domain, y vuelve a validar el 9. al arrancar, activa RDP/NLA, WinRM y las reglas Domain, y vuelve a validar el
enrolamiento. enrolamiento.
10. instala RustDesk desde el MSI oficial comprobado, lo apunta al servidor 10. instala RustDesk desde el MSI oficial comprobado, lo apunta al servidor
`rustdesk.lci.lasalle.mx` y registra el ID del dispositivo en el inventario `rustdesk.lci.lasalle.mx` y registra el ID del dispositivo en el inventario
protegido del DC. 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: Para elegir adaptador o nombre del equipo explícitamente:
```powershell ```powershell
+75 -9
View File
@@ -1,10 +1,15 @@
# Enrolamiento obligatorio de clientes SGU # Enrolamiento obligatorio de clientes SGU
Para una instalación limpia de Windows se prefiere el único punto de entrada Para Windows 10 y Windows 11 se usa un solo paquete:
empaquetado:
- `sgu-windows-client-bootstrap-VERSION.zip` (x64, Pro, Enterprise o Education).
Extraer el ZIP y hacer doble clic en `Start-SguClientEnrollment.cmd`. Aceptar
UAC, introducir la IP del controlador y la cuenta del dominio con su contraseña.
También se puede indicar el servidor desde consola:
```bat ```bat
Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11 Start-SguClientEnrollment.cmd 192.168.50.10
``` ```
Este comando realiza el intercambio de certificados descrito abajo sin mover Este comando realiza el intercambio de certificados descrito abajo sin mover
@@ -12,13 +17,73 @@ una clave privada y luego ejecuta la transacción proveedor-primero. Las
instrucciones completas están en instrucciones completas están en
[`bootstrap-recovery.md`](bootstrap-recovery.md). [`bootstrap-recovery.md`](bootstrap-recovery.md).
El primer argumento es la IP fija del controlador; el segundo es una IP fija y El único dato de red necesario es la IPv4 del controlador. El bootstrap prueba
única para el cliente en la misma subred. Si el segundo se omite y la NIC WinRM desde las direcciones de las interfaces conectadas, incluyendo túneles
privada no tiene una IP válida, se solicita en pantalla. El bootstrap prefiere VPN, empezando por la ruta elegida por Windows. Si ésta falla, prueba las demás
la única NIC activa sin puerta de enlace para no reemplazar el adaptador de rutas disponibles. Cliente y servidor pueden estar en subredes distintas.
Internet. Ante cualquier error conserva la ventana y escribe el diagnóstico en Si la red todavía está inicializando, reintenta el descubrimiento durante
20 segundos antes de informar el último diagnóstico.
Conserva DHCP, direcciones y puertas de enlace. Sólo si Windows elige otra
interfaz agrega una ruta persistente `/32` hacia ese servidor por la interfaz
comprobada; no modifica la ruta de Internet. Ante cualquier error conserva la
ventana y escribe el diagnóstico en
`C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`. `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
El dominio DNS, NetBIOS y contenedor de equipos se descubren en la sesión
autenticada. Usa `OU=Laboratorio` si existe en la raíz del dominio y, en caso
contrario, el contenedor de equipos configurado en AD. Los parámetros
`-DomainName`, `-DomainNetbios` y `-ComputerOuDn` permiten validarlos o elegirlos
explícitamente. Para otra cuenta, editar el usuario sugerido como
`DOMINIO\usuario` o `usuario@dominio`. No se guardan contraseñas.
El enrolamiento también oculta las entradas de Cambio rápido de usuario mediante
una política de equipo. Docentes, administrativos y alumnos conservan la opción
de cerrar sesión, pero no pueden dejar una sesión abierta para cambiar a otra.
Si la IPv4 proporcionada es pública, el mismo flujo configura automáticamente
la conectividad directa. Después de autenticar WinRM, el servidor crea o reutiliza
un certificado DoH, publica DNS cifrado en TCP 443 y devuelve únicamente su
certificado público. El cliente lo confía, registra el servidor DoH, agrega la
regla NRPT y fija localmente los nombres del DC, dominio, broker y RustDesk a esa
IP. A continuación comprueba el registro SRV de AD y continúa con la unión. El
operador sigue introduciendo solamente IP del DC, usuario y contraseña.
El servidor o firewall perimetral debe autorizar previamente el segmento público
del laboratorio. En el bootstrap del servidor se hace con
`-PublicEnrollmentNetworks 200.13.89.0/24`; en Azure, con
`-PublicEnrollmentSourceAddressPrefixes 200.13.89.0/24`. La lista vacía no abre
puertos. Este modo requiere Windows 11 o una versión de Windows 10 que exponga
los cmdlets DNS-over-HTTPS; en equipos anteriores funciona si la red ya permite
DNS tradicional hacia el DC.
El DNS se configura mediante una regla NRPT para el dominio descubierto,
conservando los servidores DNS de los adaptadores y la resolución de Internet.
Las políticas DNS/VPN corporativas deben permitir resolver ese dominio.
El servidor necesita el bootstrap SGU (AD/DNS, WinRM, Auth Broker y RustDesk):
este paquete no es un enrolador genérico de Microsoft Entra ID ni instala SGU
en un directorio ajeno automáticamente. Se comprueban TCP 53, 88, 135, 389, 445,
5985 y 8443; la unión también requiere DNS/Kerberos por UDP y RPC dinámico
según la configuración de AD. Estas pruebas no sustituyen la unión real.
Una VPN de cualquier proveedor ya conectada utiliza el mismo lanzador. Para
instalar la VPN Azure IKEv2 se incluyen `Start-SguAzureClientEnrollment.cmd` e
`Install-SguAzureP2sClient.ps1` en el mismo ZIP para ambos Windows; la primera
instalación requiere perfil y certificados. La conectividad antes del inicio
de sesión depende de la VPN y sus políticas, no se puede deducir de la IP del DC.
Una interfaz con sólo APIPA (`169.254.x.x`), sin DHCP o sin ruta necesita que
el administrador configure la red o conecte la VPN. El bootstrap no inventa una
IP libre ni una puerta de enlace. Para una LAN estática se mantienen los
parámetros avanzados `-ClientIPv4Address`, `-ClientPrefixLength` y
`-NetworkInterfaceAlias`; la IP y la interfaz deben indicarse juntas.
El manifiesto usa `CompatibilityProfile: Auto`. No hay restricciones de VPN
por Windows 10/11; los nombres antiguos de perfil se aceptan como parámetros
obsoletos. Se valida x64, edición con unión a AD y Windows 10 desde 1607 o
Windows 11 (el runtime incluido es .NET 10; usar una edición/build compatible
con su ciclo de soporte). Credential Provider, mTLS, cuenta `alumno`, RustDesk,
monitorización y autorreparación conservan la misma implementación.
El flujo administrado instala y valida el Credential Provider **antes** de El flujo administrado instala y valida el Credential Provider **antes** de
ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta
el runtime, un certificado, el registro COM, la directiva predeterminada o la el runtime, un certificado, el registro COM, la directiva predeterminada o la
@@ -80,7 +145,8 @@ Orden de la transacción:
2. registra COM, configura SGU como proveedor predeterminado y oculta el 2. registra COM, configura SGU como proveedor predeterminado y oculta el
último usuario que cerró sesión; último usuario que cerró sesión;
3. crea o actualiza la cuenta local estándar `alumno` con la contraseña 3. crea o actualiza la cuenta local estándar `alumno` con la contraseña
`ingenieria`, la habilita y garantiza que no pertenezca a Administradores; inicial `ingenieria` sólo al crearla; si ya existe conserva su contraseña,
la habilita y garantiza que no pertenezca a Administradores;
4. instala el guard de autorreparación; 4. instala el guard de autorreparación;
5. exige health mTLS del broker y ejecuta las comprobaciones locales; 5. exige health mTLS del broker y ejecuta las comprobaciones locales;
6. configura DNS del dominio; 6. configura DNS del dominio;
+33
View File
@@ -0,0 +1,33 @@
# Protección del fondo de escritorio en Laboratorio
El bootstrap de servidor publica `SGU - Laboratorio wallpaper protection` en
`OU=Laboratorio` y sus OU hijas. Para actualizar un dominio existente sin volver
a ejecutar el bootstrap, ejecute en el DC con permisos para administrar GPO:
```powershell
.\scripts\Set-SguLaboratorioWallpaperPolicy.ps1 `
-TargetOuDn 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx'
```
El script reutiliza la GPO, respalda una existente antes de modificarla y verifica
los valores escritos. Admite `-WhatIf`. El vínculo está habilitado y forzado, con
prioridad 1, para que también alcance las OU hijas con herencia bloqueada.
- Loopback **Merge** aplica las opciones de usuario según la OU de la computadora
y conserva las otras políticas del usuario.
- `NoChangingWallPaper=1` bloquea el cambio desde Personalización.
- `Wallpaper` fija el archivo generado por SGU:
`%LOCALAPPDATA%\SGU\Wallpapers\welcome-%COMPUTERNAME%.jpg`, con estilo Fill.
Los clientes deben tener el generador, el fondo base y las fuentes que ya entrega
el enrolamiento SGU. La política no reemplaza el generador ni modifica el fondo
de inicio de sesión. No aplica a Linux. Es una restricción de la interfaz de
Windows, no una barrera contra administradores ni contra la modificación directa
del JPG, que está dentro del perfil del usuario.
Tras actualizar las políticas del equipo, cierre e inicie sesión para procesar
loopback y comprobar la política de usuario con `gpresult /h resultado.html`.
Compruebe también visualmente el fondo y Personalización en una sesión de dominio.
La validación por WinRM no sustituye esa prueba de escritorio interactivo.
Referencia: [configuración de fondos mediante GPO de Microsoft](https://learn.microsoft.com/windows/configuration/background/).
+42
View File
@@ -0,0 +1,42 @@
# SGU Credential Provider 0.6.0
Continúa la serie publicada después de `0.5.11`; no sustituye ni renumera versiones anteriores.
## Cambios
- Se incorpora al bootstrap la GPO `SGU - Laboratorio wallpaper protection`:
restringe el cambio de fondo en equipos Windows de Laboratorio y sus OU hijas
mediante loopback Merge, manteniendo el fondo personalizado de SGU.
- Se publican los cambios pendientes de roaming: Azure Files privado, recursos
separados para perfiles y carpetas, configuración de AD DS/ACL, perfiles
FSLogix por grupo AD/DO y rutas de Escritorio/Documentos para AL.
- El enrolamiento Windows admite un instalador FSLogix opcional y verifica su
firma Microsoft. Se incluyen scripts y documentación en los paquetes.
- Se incluyen las mejoras ya presentes en main desde el último lanzamiento:
ubicación del fondo por equipo/OU y pantalla de bloqueo mediante PersonalizationCSP.
## Alcance y precauciones
- Roaming requiere un piloto de inicio/cierre de sesión, permisos entre usuarios,
desconexión de VPN y recuperación de datos. Esta publicación no lo despliega ni
lo activa en el dominio existente. Los perfiles locales no se eliminan por
omisión; no habilite su eliminación sin respaldo y migración.
- La plantilla Azure agrega almacenamiento privado por omisión para P2S. El modo
de enrolamiento público directo requiere `-DeployUserRoaming $false`.
- La GPO de fondo necesita el generador SGU instalado. Las comprobaciones de GPO
no sustituyen la verificación visual tras un inicio de sesión de dominio.
- El bootstrap de servidor crea un bosque nuevo: no restaura SID, contraseñas ni
relaciones de confianza de un bosque anterior.
- Windows Home no admite unión al dominio; use una edición compatible. FSLogix
requiere verificar la licencia correspondiente.
## Descargas
- `sgu-windows-client-bootstrap-0.6.0.zip`
- `sgu-server-bootstrap-0.6.0.zip`
- `sgu-linux-client-bootstrap-0.6.0.zip`
- `sgu-azure-infrastructure-0.6.0.zip`
- `SHA256SUMS-0.6.0.txt`
Verifique los hashes SHA-256 antes de ejecutar los paquetes. No se incluyen
credenciales, certificados privados ni configuración local del entorno desplegado.
+39
View File
@@ -0,0 +1,39 @@
# SGU Credential Provider 0.6.1
Continúa la serie publicada después de `0.6.0`.
## Cambios
- El configurador de roaming admite explícitamente Azure Files mediante endpoint
público con `-EndpointAccess Public`; el modo privado sigue siendo el valor
predeterminado.
- Se corrige la generación del nombre de la identidad de almacenamiento para
respetar el límite de 15 caracteres de una cuenta de equipo de Active
Directory.
- Las ACL de `profiles` y `redirected` aplican mínimo privilegio en la raíz: los
grupos autorizados pueden crear su directorio, pero no modificar ni eliminar
directorios ajenos; `CREATOR OWNER` conserva el control dentro del directorio
propio.
- Se documenta la secuencia de despliegue con firewall de Azure Storage en modo
`Deny`, autorizando finalmente sólo la red institucional `200.13.89.0/24`.
## Validación del despliegue
- Azure Files usa autenticación AD DS con Kerberos AES-256.
- `AF-02` y `AF-03` validaron ticket Kerberos, TCP 445 y lectura/escritura en los
shares `profiles` y `redirected`.
- FSLogix 26.08 (`3.26.826.17182`) quedó instalado y activo en ambos clientes.
- La GPO de FSLogix mantiene una base deshabilitada y se habilita sólo para los
SID de `SGU-Docentes` y `SGU-Administrativos`.
- La GPO de Alumnos redirige únicamente Escritorio y Documentos.
## Descargas
- `sgu-windows-client-bootstrap-0.6.1.zip`
- `sgu-server-bootstrap-0.6.1.zip`
- `sgu-linux-client-bootstrap-0.6.1.zip`
- `sgu-azure-infrastructure-0.6.1.zip`
- `SHA256SUMS-0.6.1.txt`
Verifique los hashes SHA-256 antes de ejecutar los paquetes. No se incluyen
credenciales, claves de almacenamiento ni artefactos temporales del despliegue.
+36
View File
@@ -0,0 +1,36 @@
# SGU Credential Provider 0.6.2
Continúa la serie publicada después de `0.6.1`.
## Cambios
- Se agrega despliegue automático e idempotente de FSLogix 26.08 mediante una
GPO de inicio de equipo ligada a `OU=Laboratorio`.
- El publicador verifica la firma Authenticode de Microsoft y el SHA-256 del
instalador antes de copiarlo al SYSVOL.
- Los equipos que estaban apagados reciben e instalan FSLogix en su siguiente
arranque; no se emplean tareas programadas.
- El script queda incluido en el paquete de servidor y la guía de roaming
documenta su publicación y comprobación.
## Validación del despliegue
- La GPO `SGU - FSLogix client deployment` está enlazada y sus versiones de AD
y SYSVOL coinciden.
- El instalador publicado conserva firma válida de Microsoft y SHA-256
`0BAF7FE8195571060F0361B351E58FE6CD422AE521A96BAB3E66D900544E8A30`.
- `AF-02` y `AF-03` aplicaron la GPO y ejecutan `frxsvc` con FSLogix
`3.26.826.17182`.
- Azure Storage permanece en `defaultAction=Deny` y sólo autoriza
`200.13.89.0/24`.
## Descargas
- `sgu-windows-client-bootstrap-0.6.2.zip`
- `sgu-server-bootstrap-0.6.2.zip`
- `sgu-linux-client-bootstrap-0.6.2.zip`
- `sgu-azure-infrastructure-0.6.2.zip`
- `SHA256SUMS-0.6.2.txt`
Verifique los hashes SHA-256 antes de ejecutar los paquetes. No se incluyen
credenciales, claves de almacenamiento ni artefactos temporales del despliegue.
+44
View File
@@ -0,0 +1,44 @@
# SGU Credential Provider 0.6.3
Corrige el primer inicio de sesión de perfiles completos `AD`/`DO` cuando Azure
Files se consume mediante su endpoint público.
## Cambios
- `Enable-SguAzureUserRoaming.ps1` selecciona automáticamente FSLogix Cloud
Cache para endpoints públicos y conserva el montaje directo para endpoints
privados.
- Cloud Cache realiza el I/O del perfil sobre el VHDX local y sincroniza con
Azure Files, evitando que la latencia WAN de SMB bloquee cada operación.
- La GPO elimina de forma efectiva `VHDLocations` al activar `CCDLocations`,
incluyendo equipos que habían aplicado la política directa anterior.
- Se impide el retorno silencioso a un perfil local o temporal cuando FSLogix
no puede registrar el contenedor.
- El cierre de sesión deja de esperar indefinidamente por un proveedor remoto:
se limita a 60 segundos y conserva la caché local si vence el plazo.
- La documentación agrega capacidad de disco, validación del registro efectivo
y rutas de diagnóstico para ambos modos.
## Validación del despliegue
- Se eliminó únicamente el perfil piloto fallido de `AD017045` en `AF-03`; la
cuenta de Active Directory se conservó.
- La GPO `SGU - AD-DO FSLogix profiles` quedó en Cloud Cache para los SID de
`SGU-Administrativos` y `SGU-Docentes`; `VHDLocations` ya no aparece en el
registro efectivo de `AF-03`.
- El nuevo inicio de `AD017045` creó la caché y el proxy locales, agregó la
redirección del perfil al VHDX y completó `LoadProfile` en 4.649 segundos, sin
error 258, perfil temporal ni retorno local.
- Pasaron 70 pruebas Pester, 41 pruebas de `SGU.AuthBroker.Core`, 16 de
`SGU.AuthBroker` y 6 del Credential Provider. La solución compiló sin errores.
## Descargas
- `sgu-windows-client-bootstrap-0.6.3.zip`
- `sgu-server-bootstrap-0.6.3.zip`
- `sgu-linux-client-bootstrap-0.6.3.zip`
- `sgu-azure-infrastructure-0.6.3.zip`
- `SHA256SUMS-0.6.3.txt`
Verifique los hashes SHA-256 antes de ejecutar los paquetes. No se incluyen
credenciales, claves de almacenamiento ni artefactos temporales del despliegue.
+27
View File
@@ -0,0 +1,27 @@
# SGU Credential Provider 0.6.4
Esta versión revierte el despliegue de perfiles móviles y deja los perfiles
locales como comportamiento predeterminado para todo el dominio SGU.
## Cambio operativo
- `AL`, `AD`, `DO` y cualquier otra cuenta usan un perfil local bajo `C:\Users`.
- Escritorio y Documentos vuelven a las rutas locales del usuario.
- No se configura `profilePath`, `homeDirectory` ni `homeDrive` en Active Directory.
- Las políticas deshabilitan FSLogix Profiles, ODFC, `frxsvc` y `frxccds`.
- El enlace de instalación automática de FSLogix queda deshabilitado.
- Los recursos Azure Files existentes no se destruyen y pueden conservarse para
recuperación o para un piloto futuro.
## Herramientas
`Disable-SguAzureUserRoaming.ps1` aplica el rollback idempotente en el dominio.
`Deploy-SguAzureInfrastructure.ps1` y Bicep usan
`DeployUserRoaming=false` por omisión. Tanto el aprovisionamiento como la
publicación de políticas exigen `AcknowledgeUserRoamingRisks` para volver a
activar la función opcional.
La documentación registra los tiempos prolongados de inicio/cierre, estados
pendientes de VHDX, caché obsoleta y recuperación mediante reinicio observados
durante el piloto. User roaming se considera experimental y nunca se habilita de
forma implícita.
+6 -6
View File
@@ -2,12 +2,12 @@
## Public Azure deployment ## Public Azure deployment
Owning a public Azure IP does not make the domain controller an Internet-facing The Azure topology supports certificate-authenticated P2S or direct enrollment.
directory service. The supported cloud topology exposes no AD DS, DNS, SMB, P2S keeps AD services inside the VNet. Direct enrollment exposes the required
RPC, WinRM, broker, monitoring, or RustDesk port publicly. Hyper-V and later AD, DNS/DoH, WinRM, broker and RustDesk ports only to explicit public IPv4 CIDRs;
physical Windows clients enter the VNet through certificate-authenticated Azure an empty allowlist exposes none of them. Azure NSG and Windows Firewall enforce
VPN Gateway P2S; the Azure NSG and Windows firewall accept the P2S pool and the the same source list. RDP uses a separate allowlist. See
private VNet only. See [azure-vpn-deployment.md](azure-vpn-deployment.md). [azure-vpn-deployment.md](azure-vpn-deployment.md).
## Password handling ## Password handling
+171
View File
@@ -0,0 +1,171 @@
# Validación del bootstrap Windows unificado
Fecha: 2026-09-10. Paquete: 0.5.1.
## Comprobaciones locales
- Publicación Release del Auth Broker y del Credential Provider completada.
- Pruebas Pester ejecutadas en Windows PowerShell 5.1: selección de rutas,
dos interfaces, VPN en otra subred, preferencia por la ruta de Windows,
restricción explícita de interfaz, APIPA, falta de ruta, prefijo más específico,
ruta de host y conflictos, DNS limitado al dominio, reintentos y compatibilidad.
- Prueba TCP real con socket ligado a una IP e interfaz y servicio cerrado.
- Reenrolamiento: se conserva la contraseña de `alumno` si la cuenta ya existe,
para no provocar rechazos de historial/complejidad tras aplicar las políticas
del dominio; se mantienen las verificaciones de permisos de usuario estándar.
- El empaquetador genera un solo ZIP Windows con los lanzadores habitual y Azure,
el instalador VPN, el runtime offline y el manifiesto SHA-256.
## Prueba real en Hyper-V: Windows 11
Servidor: VM `Windows Server`, dominio `lci.lasalle.mx`, DC `192.168.50.10`.
Cliente: VM `Windows11-002`, Windows 11 Enterprise LTSC, build 26100,
nombre de equipo `DESKTOP-LM7D7OM`, inicialmente en WORKGROUP.
Antes de la prueba se creó el checkpoint
`Before SGU unified enrollment 2026-09-10`. El cliente sólo tenía conexión al
`Default Switch`; se añadió la tarjeta `SGU AD Test` al switch `Laboratorio AD`
y se configuró administrativamente `192.168.50.202/24` sin puerta de enlace.
Esta preparación de la red del laboratorio es independiente del bootstrap:
el enrolador no asignó esa dirección y no recibió parámetros de IP del cliente,
interfaz, dominio, NetBIOS ni OU.
Se ejecutó el paquete con la IP del DC, una credencial en memoria y
`-SkipRestart` para inspeccionar el resultado; después se reinició el cliente.
Resultados comprobados:
- Selección automática de `Laboratorio AD` y descubrimiento autenticado de
`lci.lasalle.mx`, `LCI` y `OU=Laboratorio`.
- Proveedor y certificados instalados; salud mTLS verificada antes de la unión.
- Unión al dominio completada y `Test-ComputerSecureChannel` verdadero después
del reinicio.
- `Test-SguClientEnrollment.ps1` con exigencia de dominio, broker, acceso remoto
y RustDesk: `IsValid=True`, sin incidencias, después del guard de arranque.
- Interfaz privada `DomainAuthenticated`; interfaz de Internet `Public`, con
DHCP y su servidor DNS originales. Resolución pública y HTTPS comprobados
contra `www.microsoft.com` (HTTP 200).
## Prueba real en Hyper-V: Windows 10
Fecha: 2026-09-10. Cliente: VM `Windows10-001`, Windows 10 Enterprise LTSC
x64, build 19044, nombre de equipo `DESKTOP-HDKRD5V`, inicialmente en WORKGROUP.
Se usó el mismo ZIP 0.5.1 publicado en Gitea, sin modificar sus scripts ni
binarios. SHA-256 del ZIP:
```text
4DBE45697D74110F65C6D7825A593121B19C53B5F286F0DBC00068F3AFE45FB0
```
Se guardó el checkpoint `Before SGU Windows10 validation 2026-09-10`.
La VM ya tenía dos tarjetas: `Ethernet` en `Default Switch`, con DHCP y DNS
`172.18.176.1`, y `Ethernet 2` en `Laboratorio AD`, con dirección APIPA.
Primero se ejecutó el bootstrap sin preparar la IP privada. Reintentó la
conexión, diagnosticó que `Ethernet 2` no tenía una IPv4 utilizable y que la ruta
de Internet no alcanzaba WinRM del servidor. No solicitó una IP del cliente,
no modificó sus direcciones y mantuvo el equipo en WORKGROUP.
Para la prueba positiva se configuró administrativamente
`192.168.50.203/24` en `Ethernet 2`, sin puerta de enlace, y se esperó a que
Windows confirmara la dirección como `Preferred`. Esta preparación corresponde
a la red de laboratorio sin DHCP; no la realizó el bootstrap. Se ejecutó de
nuevo el ZIP con sólo la IP del DC, una credencial en memoria y `-SkipRestart`,
sin parámetros de interfaz, IP del cliente, dominio, NetBIOS ni OU.
Resultados:
- Selección automática de `Ethernet 2`, descubrimiento de `lci.lasalle.mx`,
`LCI` y `OU=Laboratorio`, y unión al dominio completada.
- Proveedor y certificados instalados; proveedor validado antes de la unión.
- Tras reiniciar, `Test-ComputerSecureChannel=True` y tarea
`SGU-CredentialProvider-EnrollmentGuard` finalizada con `LastTaskResult=0`.
- Validación con dominio, salud del broker, acceso remoto y RustDesk exigidos:
`IsValid=True`, `Issues={}`, `BrokerHealth=ok`, `RemoteAccessReady=True` y
`RustDeskReady=True`.
- Runtime .NET y binarios presentes; proveedor de contraseña de Windows
conservado. Cuenta `alumno` presente, sin permisos de administrador y con
expiración de contraseña deshabilitada.
- `Ethernet 2` quedó como `DomainAuthenticated`; `Ethernet` permaneció como
`Public`, conservando su DHCP y DNS original. HTTPS hacia
`https://www.microsoft.com` respondió HTTP 200.
No fue necesario corregir el bootstrap para esta prueba. La VM quedó encendida
y enrolada, con el ZIP extraído en sus Descargas y el checkpoint previo disponible.
### Comprobación posterior del escritorio
La validación anterior comprobaba el enrolamiento, pero no el fondo visible
en una sesión de usuario. Al revisar la sesión de `Windows10-001`, el fondo
seguía siendo el predeterminado de Windows. El registro del generador mostró
un fallo de validación al asignar el género vacío devuelto por AD al parámetro
`Gender`, cuyo `ValidateSet` sólo permite `Male` o `Female`.
Se corrigió `Set-SguWelcomeWallpaper.ps1` para mantener el saludo neutral cuando
el dato no está disponible. Se actualizaron el generador instalado y su copia
en el paquete de autorreparación, y se ejecutó en el contexto de la sesión
interactiva existente, sin cerrar sesión ni solicitar otra contraseña.
El registro terminó con `OK`, la configuración del usuario apuntó al JPEG
generado y se verificó visualmente el fondo institucional con nombre y saludo.
La tarea temporal utilizada para actualizar la sesión se retiró al finalizar;
la ejecución habitual al iniciar sesión sigue a cargo de la GPO.
Se agregaron cuatro pruebas de renderizado JPEG: género ausente, vacío o
desconocido, valores reconocidos y prioridad de un valor explícito. Todas
pasaron en Windows PowerShell 5.1. Esta corrección posterior está en el código
y en la VM; el ZIP publicado como 0.5.1 no se modificó.
## Prueba real de enrolamiento público directo: Windows 10
Fecha: 2026-09-11. Se repitió el enrolamiento de `Windows10-001` contra el DC
Azure `20.9.81.130`, sin perfil ni interfaz VPN. El cliente conservó sus dos NIC
y seleccionó por sí solo `Ethernet` con DHCP (`172.18.183.201`), porque era la
ruta que alcanzaba WinRM. El segmento público de salida autorizado fue
`200.13.89.0/24`.
La VM aún nombraba `lci.lasalle.mx`, pero su canal seguro pertenecía al bosque
anterior y estaba roto. El flujo creó o reutilizó la cuenta de equipo en la OU
descubierta, restableció la contraseña de máquina contra
`SGU-DC01.lci.lasalle.mx`, reinició Netlogon y conservó el equipo unido. También
toleró SID huérfanos del bosque anterior al comprobar los grupos locales.
Windows 10 Enterprise LTSC build 19044 no expone los cmdlets DoH. El bootstrap
lo detectó y usó DNS tradicional hacia la misma IP pública, limitado por NSG y
Windows Firewall al CIDR permitido. Después de un reinicio real se comprobó:
- `Test-ComputerSecureChannel=True` y resolución SRV del DC;
- `IsValid=True`, sin incidencias;
- `BrokerHealth=ok`, `RemoteAccessReady=True` y `RustDeskReady=True`;
- ningún perfil VPN instalado;
- paquete candidato usado en la prueba `0.5.9`, SHA-256 del ZIP de Windows:
`E7AF77252E444FB7EBACF3C90005D322EE19F76DD9387AC5782C57EA9EE617B7`.
## Prueba real con Azure VPN
El 2026-09-10 se desplegó `sgu-lab-dc` en Azure Central US, se creó el bosque
`lci.lasalle.mx` y se enroló `Windows11-002` mediante un gateway real
`VpnGw1AZ`. El controlador tiene IP `10.77.0.4` y el cliente obtuvo
`172.30.0.2` por IKEv2 con certificado de máquina. El bootstrap descubrió
automáticamente la interfaz VPN, el dominio y la OU a partir de la IP del DC
y la credencial administrativa.
Se comprobaron el objeto de equipo en `OU=Laboratorio`, el canal seguro y la
validación SGU completa, incluyendo salud mTLS, acceso remoto y RustDesk.
Para Enterprise se configuró un device tunnel y una recuperación de Netlogon
para la conectividad tardía al arrancar. El bosque Azure es independiente del
bosque local con el mismo nombre.
La infraestructura, versiones de paquetes, ajustes adicionales y evidencias
están en [el informe de Azure](azure-deployment-validation-2026-09-10.md).
Se usaron paquetes locales de validación `0.5.2-azure.*`; el release publicado
0.5.1 no se reemplazó durante este despliegue.
## Alcance pendiente
OpenVPN y otras VPN requieren validación en sus redes reales. Las pruebas
Windows realizadas cubren Enterprise LTSC x64, builds 19044 y 26100; no todas
las ediciones ni builds. Azure se probó con Windows 11; la prueba de Windows 10
descrita arriba corresponde a la LAN.
El servidor debe tener SGU preparado. La IP de un DC no permite crear una VPN,
adivinar una IP libre ni sustituir permisos, DHCP o políticas de firewall.
+180
View File
@@ -0,0 +1,180 @@
# User roaming por rol en Azure (opcional)
> **Estado predeterminado:** desactivado. Todas las cuentas `AL`, `AD`, `DO` y
> cualquier otra cuenta de dominio usan un perfil local normal bajo `C:\Users`.
> Escritorio y Documentos permanecen en el disco del equipo; no se mapean shares
> de perfiles ni se monta un contenedor FSLogix.
La implementación se conserva para un posible piloto futuro, pero no forma parte
del enrolamiento normal. Activarla requiere dos decisiones explícitas: desplegar
la infraestructura con `-DeployUserRoaming $true
-AcknowledgeUserRoamingRisks` y después ejecutar el configurador con
`-AcknowledgeUserRoamingRisks`.
## Diseño opcional
Cuando se habilita expresamente, el comportamiento es:
| Rol | Datos conservados | Implementación |
| --- | --- | --- |
| `AL` | `Desktop` y `Documents` | Redirección de carpetas a Azure Files; el resto del perfil sigue local. |
| `AD` / `DO` | Perfil completo | FSLogix VHDX: montaje directo con endpoint privado o Cloud Cache con endpoint público. |
Linux conserva su directorio personal nativo y no monta un perfil de Windows.
`infra/azure/main.bicep` puede crear una cuenta `StorageV2` dedicada con shares
`profiles` y `redirected`, soft delete de 14 días, private endpoint y zona
`privatelink.file.<sufijo-de-Azure>`. Su valor predeterminado
`deployUserRoaming=false` evita crear estos recursos. La cuenta de staging usada
para paquetes no se reutiliza automáticamente como almacenamiento de perfiles.
Los clientes de un endpoint privado requieren P2S u otra ruta privada a la VNet.
Un endpoint público exige resolución DNS, salida TCP 445 y reglas de red de
Storage limitadas a redes institucionales. El modo `Auto` selecciona `Direct`
para endpoint privado y `CloudCache` para uno público. `CCDLocations` y
`VHDLocations` son mutuamente excluyentes.
Azure Files se integra con AD DS mediante Kerberos AES-256. Las ACL de la raíz
usan `CREATOR OWNER` para aislar los directorios de usuarios. La clave de la
cuenta se usa sólo en memoria durante la creación de ACL y no se guarda en GPO.
## Riesgos que deben aceptarse
El piloto de septiembre de 2026 confirmó que esta función puede aumentar de
forma considerable el tiempo de inicio y cierre de sesión. Antes de activarla se
deben aceptar y probar, como mínimo, estos escenarios:
- latencia o indisponibilidad de Azure Files que mantiene `Welcome` o `Sign out`
durante varios minutos;
- VHDX remoto en estado abierto, pendiente de eliminación o de creación, con
fallos como `0x80070050`;
- caché o proxy local obsoleto de Cloud Cache y fallos de registro como
`0xE000000B`;
- recuperación que puede exigir detener por fuerza los servicios FSLogix y
reiniciar el equipo;
- datos todavía no sincronizados, caché local insuficiente o divergencia si se
fuerza una recuperación;
- dependencia de DNS, Kerberos, TCP 445, la red y el servicio de Storage tanto
para registrar como para desmontar el perfil.
No se debe desplegar a todos los usuarios sin un piloto de inicio, cierre,
desconexión, cuota, restauración y pérdida temporal del proveedor. Cloud Cache
reduce I/O WAN síncrono, pero no elimina la dependencia al cerrar sesión.
## 1. Desplegar la infraestructura opcional
El despliegue normal no incluye user roaming. Para crear deliberadamente sus
recursos:
```powershell
$azure = .\scripts\Deploy-SguAzureInfrastructure.ps1 `
-SubscriptionId '00000000-0000-0000-0000-000000000000' `
-ResourceGroupName 'rg-sgu-lab' `
-Location 'centralus' `
-AdministratorUsername 'azureadmin' `
-P2sRootCertificatePath $p2s.RootCertificatePath `
-DeployUserRoaming $true `
-AcknowledgeUserRoamingRisks
```
Puede definirse un nombre con `-UserRoamingStorageAccountName`. Si no se pasan
los dos parámetros de aceptación, la infraestructura de perfiles no se crea.
## 2. Unir Azure Files al dominio y publicar las GPO
Primero termine `Initialize-SguDomainController.ps1`. Después, en Windows
PowerShell 5.1 elevado dentro del DC, instale Azure PowerShell y el módulo oficial
[AzFilesHybrid](https://github.com/Azure-Samples/azure-files-samples/releases),
y ejecute:
```powershell
$parameters = @{
SubscriptionId = '00000000-0000-0000-0000-000000000000'
ResourceGroupName = 'rg-sgu-lab'
StorageAccountName = '<cuenta de perfiles>'
FsLogixProfilesShareName = 'profiles'
RedirectedFoldersShareName = 'redirected'
AzFilesHybridModulePath = 'C:\Admin\AzFilesHybrid'
UseDeviceAuthentication = $true
AcknowledgeUserRoamingRisks = $true
}
.\payload\scripts\Enable-SguAzureUserRoaming.ps1 @parameters
```
Para una cuenta autorizada con endpoint público:
```powershell
$parameters.EndpointAccess = 'Public'
$parameters.StaffProfileStorageMode = 'Auto'
.\payload\scripts\Enable-SguAzureUserRoaming.ps1 @parameters
```
El script crea las GPO `SGU - AL redirected folders` y `SGU - AD-DO FSLogix
profiles`, y vuelve a habilitar el enlace de instalación de FSLogix si ya existe.
No borra perfiles locales existentes salvo que se proporcione deliberadamente
`-DeleteExistingStaffLocalProfiles`.
## 3. Instalar FSLogix sólo para un piloto
FSLogix no forma parte de Windows. Valide su licencia y descargue
`FSLogixAppsSetup.exe` directamente de Microsoft. Puede pasarse como quinto
argumento al enrolamiento Azure o publicarse con:
```powershell
.\payload\scripts\Publish-SguFsLogixClientDeployment.ps1
```
El publicador valida firma y SHA-256 y crea `SGU - FSLogix client deployment`.
El rollback descrito abajo deshabilita su enlace para que no se instale en más
equipos.
## 4. Desactivar y volver a perfiles locales
Ejecute desde Windows PowerShell 5.1 elevado en el controlador de dominio:
```powershell
.\payload\scripts\Disable-SguAzureUserRoaming.ps1
```
Es idempotente y realiza lo siguiente:
- fija `Profiles\Enabled=0` y `ODFC\Enabled=0`;
- deshabilita `frxsvc` y `frxccds` mediante GPO;
- elimina `CCDLocations`, `VHDLocations` y valores de contenedor específicos de
los grupos `SGU-Administrativos` y `SGU-Docentes`;
- restaura Escritorio y Documentos de alumnos a
`%USERPROFILE%\Desktop` y `%USERPROFILE%\Documents`;
- deshabilita el enlace de despliegue del cliente FSLogix;
- limpia `profilePath`, `homeDirectory` y `homeDrive` de los usuarios de las OU
SGU, salvo que se use `-PreserveLegacyUserMappings`.
Después aplique `gpupdate /force` y reinicie cada Windows administrado antes de
probar un nuevo inicio de sesión. El primer ingreso vuelve a crear o reutilizar
un perfil local. El script no borra automáticamente VHDX, shares de Azure ni
cachés existentes: conservarlos permite recuperación y evita destrucción de
datos. Su eliminación debe ser una decisión separada y posterior a la validación.
## 5. Validar un piloto futuro
Después de `gpupdate /force` y reiniciar un cliente de prueba:
```powershell
Resolve-DnsName "$($azure.UserRoamingStorageAccountName).file.core.windows.net"
Test-NetConnection "$($azure.UserRoamingStorageAccountName).file.core.windows.net" -Port 445
Get-Service frxsvc
Get-ItemProperty 'HKLM:\SOFTWARE\FSLogix\Profiles\ObjectSpecific\<SID-del-grupo>'
& 'C:\Program Files\FSLogix\Apps\frx.exe' list-redirects
```
Revise `%ProgramData%\FSLogix\Logs\Profile`, pruebe inicio y cierre varias veces,
simule indisponibilidad y valide restauración. No active el borrado de perfiles
locales ni amplíe el alcance antes de superar esas pruebas.
Referencias de Microsoft:
- [Autenticación AD DS para Azure Files](https://learn.microsoft.com/azure/storage/files/storage-files-identity-ad-ds-enable)
- [Permisos SMB para FSLogix](https://learn.microsoft.com/fslogix/how-to-configure-storage-permissions)
- [Contenedores de perfil FSLogix](https://learn.microsoft.com/fslogix/how-to-configure-profile-containers)
- [Configuración ObjectSpecific de FSLogix](https://learn.microsoft.com/fslogix/how-to-configure-object-specific-settings)
- [Cloud Cache de FSLogix](https://learn.microsoft.com/fslogix/concepts-fslogix-cloud-cache)
- [Redirección de carpetas](https://learn.microsoft.com/windows-server/storage/folder-redirection/deploy-folder-redirection)
+9 -1
View File
@@ -21,12 +21,17 @@ guardar credenciales. Obtiene:
- `location` del objeto de equipo. - `location` del objeto de equipo.
- La OU padre inmediata a partir de `distinguishedName`. - La OU padre inmediata a partir de `distinguishedName`.
Los valores de equipo `SGU_WELCOME_LOCATION` y
`SGU_WELCOME_ORGANIZATIONAL_UNIT` pueden fijar ambos datos en una máquina
concreta. Tienen prioridad sobre el directorio y sobreviven cuando la GPO
restaura el comando genérico de inicio.
El saludo usa `Bienvenido/ubicado` para `Male` y `Bienvenida/ubicada` para El saludo usa `Bienvenido/ubicado` para `Male` y `Bienvenida/ubicada` para
`Female`. Cuando el enriquecimiento no produjo este dato, utiliza la redacción `Female`. Cuando el enriquecimiento no produjo este dato, utiliza la redacción
neutral `Te damos la bienvenida` y `Ubicación:`. El texto secundario sigue estas neutral `Te damos la bienvenida` y `Ubicación:`. El texto secundario sigue estas
reglas: reglas:
1. Con `location` y OU: `Estás ubicado en la Sala de Inmersión del Centro de Experiencia Digital.` 1. Con `location` y OU: `Acceso al Aula Flexible del Centro de Experiencia Digital.`
2. Con sólo uno de los datos: muestra únicamente el dato disponible. 2. Con sólo uno de los datos: muestra únicamente el dato disponible.
3. Sin ambos: el texto adaptado `Bienvenido/Bienvenida al Laboratorio...`; sin 3. Sin ambos: el texto adaptado `Bienvenido/Bienvenida al Laboratorio...`; sin
sexo disponible, la forma neutral `Acceso al Laboratorio de Cómputo de Ingeniería.` sexo disponible, la forma neutral `Acceso al Laboratorio de Cómputo de Ingeniería.`
@@ -46,6 +51,9 @@ en `%LOCALAPPDATA%\SGU\Wallpapers` y se aplica con la API nativa de Windows.
La antigua directiva estática de escritorio se elimina para que no sobrescriba La antigua directiva estática de escritorio se elimina para que no sobrescriba
el archivo individual. La personalización sigue estando gobernada por dominio: el archivo individual. La personalización sigue estando gobernada por dominio:
el comando de inicio y la pantalla de bloqueo pertenecen a la GPO de equipos. el comando de inicio y la pantalla de bloqueo pertenecen a la GPO de equipos.
La GPO configura tanto la directiva clásica como `PersonalizationCSP` y desactiva
Windows Spotlight en la pantalla de bloqueo, porque Windows 11 Pro puede ignorar
la ruta clásica aun cuando el valor de registro está presente.
## Linux ## Linux
+267 -14
View File
@@ -34,6 +34,9 @@ param domainControllerSubnetPrefix string = '10.77.0.0/24'
@description('Reserved Azure VPN Gateway subnet. Use /27 or larger.') @description('Reserved Azure VPN Gateway subnet. Use /27 or larger.')
param gatewaySubnetPrefix string = '10.77.255.0/27' 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.') @description('Static private IP reserved on the Azure NIC for AD DS and DNS.')
param domainControllerPrivateIp string = '10.77.0.4' param domainControllerPrivateIp string = '10.77.0.4'
@@ -44,11 +47,40 @@ param vpnClientAddressPoolPrefix string = '172.30.0.0/24'
param p2sRootCertificateName string = 'SGU-P2S-Root' param p2sRootCertificateName string = 'SGU-P2S-Root'
@description('Base64 DER bytes of the trusted P2S root certificate, without PEM markers.') @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.') @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 = '' param administratorSourceAddressPrefix string = ''
@description('Deploy private Azure Files shares for SGU user roaming. Clients require P2S or another private route into the VNet.')
param deployUserRoaming bool = false
@description('Optional globally unique storage account name for roaming data. Leave empty to derive a stable name from the resource group.')
param userRoamingStorageAccountName string = ''
@description('Azure Files share used for AD/DO FSLogix profile containers.')
@minLength(3)
@maxLength(63)
param fsLogixProfilesShareName string = 'profiles'
@description('Azure Files share used for AL Documents and Desktop folder redirection.')
@minLength(3)
@maxLength(63)
param redirectedFoldersShareName string = 'redirected'
@description('Quota in GiB for the FSLogix profile-container share.')
@minValue(100)
@maxValue(102400)
param fsLogixProfilesQuotaGiB int = 1024
@description('Quota in GiB for the redirected-folders share.')
@minValue(100)
@maxValue(102400)
param redirectedFoldersQuotaGiB int = 1024
var virtualNetworkName = '${deploymentPrefix}-vnet' var virtualNetworkName = '${deploymentPrefix}-vnet'
var domainControllerSubnetName = 'DomainControllers' var domainControllerSubnetName = 'DomainControllers'
var gatewaySubnetName = 'GatewaySubnet' var gatewaySubnetName = 'GatewaySubnet'
@@ -58,12 +90,18 @@ var gatewayPublicIpName = '${deploymentPrefix}-vpngw-pip'
var networkInterfaceName = '${deploymentPrefix}-dc-nic' var networkInterfaceName = '${deploymentPrefix}-dc-nic'
var virtualMachineName = '${deploymentPrefix}-dc' var virtualMachineName = '${deploymentPrefix}-dc'
var virtualNetworkGatewayName = '${deploymentPrefix}-vpngw' var virtualNetworkGatewayName = '${deploymentPrefix}-vpngw'
var effectiveUserRoamingStorageAccountName = empty(userRoamingStorageAccountName)
? 'sguroam${uniqueString(resourceGroup().id)}'
: toLower(userRoamingStorageAccountName)
var userRoamingPrivateEndpointName = '${deploymentPrefix}-profiles-pe'
var storageEndpointSuffix = environment().suffixes.storage
var azureFilesPrivateDnsZoneName = 'privatelink.file.${storageEndpointSuffix}'
resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = {
name: networkSecurityGroupName name: networkSecurityGroupName
location: location location: location
properties: { properties: {
securityRules: concat([ securityRules: concat(deployVpnGateway ? [
{ {
name: 'Allow-SGU-P2S-clients' name: 'Allow-SGU-P2S-clients'
properties: { properties: {
@@ -78,11 +116,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.' 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) ? [] : [ ], empty(administratorSourceAddressPrefix) ? [] : [
{ {
name: 'Allow-RDP-from-administrator' name: 'Allow-RDP-from-administrator'
properties: { properties: {
priority: 110 priority: 140
access: 'Allow' access: 'Allow'
direction: 'Inbound' direction: 'Inbound'
protocol: 'Tcp' protocol: 'Tcp'
@@ -106,24 +210,147 @@ resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = {
virtualNetworkAddressPrefix virtualNetworkAddressPrefix
] ]
} }
subnets: [ dhcpOptions: {
dnsServers: [
domainControllerPrivateIp
]
}
subnets: concat([
{ {
name: domainControllerSubnetName name: domainControllerSubnetName
properties: { properties: {
addressPrefix: domainControllerSubnetPrefix addressPrefix: domainControllerSubnetPrefix
privateEndpointNetworkPolicies: deployUserRoaming ? 'Disabled' : null
networkSecurityGroup: { networkSecurityGroup: {
id: networkSecurityGroup.id id: networkSecurityGroup.id
} }
} }
} }
], deployVpnGateway ? [
{ {
name: gatewaySubnetName name: gatewaySubnetName
properties: { properties: {
addressPrefix: gatewaySubnetPrefix addressPrefix: gatewaySubnetPrefix
} }
} }
] : [])
}
}
resource userRoamingStorageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = if (deployUserRoaming) {
name: effectiveUserRoamingStorageAccountName
location: location
tags: {
purpose: 'SGU-user-roaming'
}
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
allowBlobPublicAccess: false
allowCrossTenantReplication: false
allowSharedKeyAccess: true
largeFileSharesState: 'Enabled'
minimumTlsVersion: 'TLS1_2'
publicNetworkAccess: 'Disabled'
supportsHttpsTrafficOnly: true
networkAcls: {
bypass: 'AzureServices'
defaultAction: 'Deny'
}
}
}
resource userRoamingFileService 'Microsoft.Storage/storageAccounts/fileServices@2023-05-01' = if (deployUserRoaming) {
parent: userRoamingStorageAccount
name: 'default'
properties: {
shareDeleteRetentionPolicy: {
enabled: true
days: 14
}
}
}
resource fsLogixProfilesShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-05-01' = if (deployUserRoaming) {
parent: userRoamingFileService
name: fsLogixProfilesShareName
properties: {
accessTier: 'TransactionOptimized'
enabledProtocols: 'SMB'
shareQuota: fsLogixProfilesQuotaGiB
}
}
resource redirectedFoldersShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-05-01' = if (deployUserRoaming) {
parent: userRoamingFileService
name: redirectedFoldersShareName
properties: {
accessTier: 'TransactionOptimized'
enabledProtocols: 'SMB'
shareQuota: redirectedFoldersQuotaGiB
}
}
resource azureFilesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (deployUserRoaming) {
name: azureFilesPrivateDnsZoneName
location: 'global'
}
resource azureFilesPrivateDnsVnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = if (deployUserRoaming) {
parent: azureFilesPrivateDnsZone
name: '${deploymentPrefix}-vnet-link'
location: 'global'
properties: {
registrationEnabled: false
virtualNetwork: {
id: virtualNetwork.id
}
}
}
resource userRoamingPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = if (deployUserRoaming) {
name: userRoamingPrivateEndpointName
location: location
properties: {
subnet: {
id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, domainControllerSubnetName)
}
privateLinkServiceConnections: [
{
name: 'azure-files'
properties: {
groupIds: [
'file'
]
privateLinkServiceId: userRoamingStorageAccount.id
}
}
] ]
} }
dependsOn: [
virtualNetwork
]
}
resource userRoamingPrivateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = if (deployUserRoaming) {
parent: userRoamingPrivateEndpoint
name: 'default'
properties: {
privateDnsZoneConfigs: [
{
name: 'azure-files'
properties: {
privateDnsZoneId: azureFilesPrivateDnsZone.id
}
}
]
}
dependsOn: [
azureFilesPrivateDnsVnetLink
]
} }
resource domainControllerPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = { resource domainControllerPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = {
@@ -139,9 +366,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 name: gatewayPublicIpName
location: location location: location
zones: [
'1'
'2'
'3'
]
sku: { sku: {
name: 'Standard' name: 'Standard'
} }
@@ -223,6 +455,8 @@ resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-07-01' = {
} }
osDisk: { osDisk: {
createOption: 'FromImage' createOption: 'FromImage'
// AD DS requires durable writes; the bootstrap stores NTDS on this disk.
caching: 'None'
managedDisk: { managedDisk: {
storageAccountType: 'Premium_LRS' storageAccountType: 'Premium_LRS'
} }
@@ -248,7 +482,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 name: virtualNetworkGatewayName
location: location location: location
properties: { properties: {
@@ -271,8 +505,8 @@ resource virtualNetworkGateway 'Microsoft.Network/virtualNetworkGateways@2024-05
} }
] ]
sku: { sku: {
name: 'VpnGw1' name: 'VpnGw1AZ'
tier: 'VpnGw1' tier: 'VpnGw1AZ'
} }
vpnClientConfiguration: { vpnClientConfiguration: {
vpnClientAddressPool: { vpnClientAddressPool: {
@@ -282,7 +516,7 @@ resource virtualNetworkGateway 'Microsoft.Network/virtualNetworkGateways@2024-05
} }
vpnClientProtocols: [ vpnClientProtocols: [
'IkeV2' 'IkeV2'
'SSTP' 'OpenVPN'
] ]
vpnAuthenticationTypes: [ vpnAuthenticationTypes: [
'Certificate' 'Certificate'
@@ -307,17 +541,36 @@ output domainControllerPrivateIp string = domainControllerPrivateIp
output domainControllerPublicIp string = domainControllerPublicIp.properties.ipAddress output domainControllerPublicIp string = domainControllerPublicIp.properties.ipAddress
output virtualNetworkName string = virtualNetwork.name output virtualNetworkName string = virtualNetwork.name
output virtualNetworkAddressPrefix string = virtualNetworkAddressPrefix output virtualNetworkAddressPrefix string = virtualNetworkAddressPrefix
output vpnGatewayName string = virtualNetworkGateway.name output vpnGatewayName string = deployVpnGateway ? virtualNetworkGateway.name : ''
output vpnClientAddressPoolPrefix string = vpnClientAddressPoolPrefix output vpnClientAddressPoolPrefix string = vpnClientAddressPoolPrefix
output serverBootstrapArguments array = [ output userRoamingEnabled bool = deployUserRoaming
output userRoamingStorageAccountName string = deployUserRoaming ? userRoamingStorageAccount.name : ''
output fsLogixProfilesSharePath string = deployUserRoaming ? '\\\\${userRoamingStorageAccount.name}.file.${storageEndpointSuffix}\\${fsLogixProfilesShare.name}' : ''
output redirectedFoldersSharePath string = deployUserRoaming ? '\\\\${userRoamingStorageAccount.name}.file.${storageEndpointSuffix}\\${redirectedFoldersShare.name}' : ''
output userRoamingSetupArguments array = deployUserRoaming ? [
'-SubscriptionId'
subscription().subscriptionId
'-ResourceGroupName'
resourceGroup().name
'-StorageAccountName'
userRoamingStorageAccount.name
'-FsLogixProfilesShareName'
fsLogixProfilesShare.name
'-RedirectedFoldersShareName'
redirectedFoldersShare.name
] : []
output serverBootstrapArguments array = concat([
'-ServerIPv4Address' '-ServerIPv4Address'
domainControllerPrivateIp domainControllerPrivateIp
'-PrefixLength' '-PrefixLength'
last(split(domainControllerSubnetPrefix, '/')) last(split(domainControllerSubnetPrefix, '/'))
'-NetworkConfigurationMode' '-NetworkConfigurationMode'
'PlatformManaged' 'PlatformManaged'
'-TrustedClientNetworks'
vpnClientAddressPoolPrefix
'-DnsForwarders' '-DnsForwarders'
'168.63.129.16' '168.63.129.16'
] ], deployVpnGateway ? [
'-TrustedClientNetworks'
vpnClientAddressPoolPrefix
] : [], empty(publicEnrollmentSourceAddressPrefixes) ? [] : concat([
'-PublicEnrollmentNetworks'
], publicEnrollmentSourceAddressPrefixes))
+61 -5
View File
@@ -7,7 +7,8 @@ param(
[string]$DeploymentPrefix = 'sgu-lab', [string]$DeploymentPrefix = 'sgu-lab',
[Parameter(Mandatory)][string]$AdministratorUsername, [Parameter(Mandatory)][string]$AdministratorUsername,
[securestring]$AdministratorPassword, [securestring]$AdministratorPassword,
[Parameter(Mandatory)][string]$P2sRootCertificatePath, [string]$P2sRootCertificatePath,
[bool]$DeployVpnGateway = $true,
[string]$ComputerName = 'SGU-DC01', [string]$ComputerName = 'SGU-DC01',
[string]$VmSize = 'Standard_D2s_v5', [string]$VmSize = 'Standard_D2s_v5',
[string]$VirtualNetworkAddressPrefix = '10.77.0.0/16', [string]$VirtualNetworkAddressPrefix = '10.77.0.0/16',
@@ -15,7 +16,20 @@ param(
[ipaddress]$DomainControllerPrivateIp = '10.77.0.4', [ipaddress]$DomainControllerPrivateIp = '10.77.0.4',
[string]$GatewaySubnetPrefix = '10.77.255.0/27', [string]$GatewaySubnetPrefix = '10.77.255.0/27',
[string]$VpnClientAddressPoolPrefix = '172.30.0.0/24', [string]$VpnClientAddressPoolPrefix = '172.30.0.0/24',
[string[]]$PublicEnrollmentSourceAddressPrefixes = @(),
[string]$AdministratorSourceAddressPrefix = '', [string]$AdministratorSourceAddressPrefix = '',
[bool]$DeployUserRoaming = $false,
[switch]$AcknowledgeUserRoamingRisks,
[ValidatePattern('^$|^[a-z0-9]{3,24}$')]
[string]$UserRoamingStorageAccountName = '',
[ValidatePattern('^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$')]
[string]$FsLogixProfilesShareName = 'profiles',
[ValidatePattern('^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$')]
[string]$RedirectedFoldersShareName = 'redirected',
[ValidateRange(100, 102400)]
[int]$FsLogixProfilesQuotaGiB = 1024,
[ValidateRange(100, 102400)]
[int]$RedirectedFoldersQuotaGiB = 1024,
[string]$TemplateFile = (Join-Path $PSScriptRoot '..\infra\azure\main.bicep') [string]$TemplateFile = (Join-Path $PSScriptRoot '..\infra\azure\main.bicep')
) )
@@ -28,13 +42,30 @@ if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
if (-not (Test-Path -LiteralPath $TemplateFile -PathType Leaf)) { if (-not (Test-Path -LiteralPath $TemplateFile -PathType Leaf)) {
throw "Azure Bicep template not found: $TemplateFile" 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) { if (-not $AdministratorPassword) {
$AdministratorPassword = Read-Host 'Password for the local Azure VM administrator' -AsSecureString $AdministratorPassword = Read-Host 'Password for the local Azure VM administrator' -AsSecureString
} }
if ($DeployUserRoaming -and -not $DeployVpnGateway) {
throw 'Azure user roaming requires the P2S gateway deployed by this template so clients can reach the private Azure Files endpoint. Use -DeployUserRoaming $false with direct public enrollment.'
}
if ($DeployUserRoaming -and -not $AcknowledgeUserRoamingRisks) {
throw 'User roaming is disabled by default. To provision it, also pass -AcknowledgeUserRoamingRisks after reviewing the sign-in, sign-out, storage-availability, stale-cache, and recovery risks in docs/user-roaming.md.'
}
foreach ($shareName in @($FsLogixProfilesShareName, $RedirectedFoldersShareName)) {
if ($shareName.Contains('--')) {
throw "Azure Files share names cannot contain consecutive hyphens: $shareName"
}
}
if ($FsLogixProfilesShareName -eq $RedirectedFoldersShareName) {
throw 'FsLogixProfilesShareName and RedirectedFoldersShareName must be different.'
}
$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( $rootCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
(Resolve-Path -LiteralPath $P2sRootCertificatePath).Path) (Resolve-Path -LiteralPath $P2sRootCertificatePath).Path)
if (-not ($rootCertificate.Extensions | Where-Object { if (-not ($rootCertificate.Extensions | Where-Object {
@@ -42,6 +73,7 @@ if (-not ($rootCertificate.Extensions | Where-Object {
throw 'P2sRootCertificatePath must contain a certificate-authority certificate.' 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 $account = & az account show --output json 2>$null
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
@@ -52,7 +84,16 @@ if ($LASTEXITCODE -ne 0) {
throw "Could not select Azure subscription $SubscriptionId." 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 -and $DeployUserRoaming) {
'Create Azure VNet, Windows Server 2025 VM, public IP, P2S VPN Gateway, and private user-roaming storage'
}
elseif ($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 & az group create --name $ResourceGroupName --location $Location --only-show-errors --output none
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Could not create or update resource group $ResourceGroupName." throw "Could not create or update resource group $ResourceGroupName."
@@ -89,8 +130,16 @@ if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", 'Create Azure VNe
gatewaySubnetPrefix = @{ value = $GatewaySubnetPrefix } gatewaySubnetPrefix = @{ value = $GatewaySubnetPrefix }
domainControllerPrivateIp = @{ value = $DomainControllerPrivateIp.IPAddressToString } domainControllerPrivateIp = @{ value = $DomainControllerPrivateIp.IPAddressToString }
vpnClientAddressPoolPrefix = @{ value = $VpnClientAddressPoolPrefix } vpnClientAddressPoolPrefix = @{ value = $VpnClientAddressPoolPrefix }
deployVpnGateway = @{ value = $DeployVpnGateway }
p2sRootCertificateData = @{ value = $rootCertificateData } p2sRootCertificateData = @{ value = $rootCertificateData }
publicEnrollmentSourceAddressPrefixes = @{ value = @($PublicEnrollmentSourceAddressPrefixes) }
administratorSourceAddressPrefix = @{ value = $AdministratorSourceAddressPrefix } administratorSourceAddressPrefix = @{ value = $AdministratorSourceAddressPrefix }
deployUserRoaming = @{ value = $DeployUserRoaming }
userRoamingStorageAccountName = @{ value = $UserRoamingStorageAccountName }
fsLogixProfilesShareName = @{ value = $FsLogixProfilesShareName }
redirectedFoldersShareName = @{ value = $RedirectedFoldersShareName }
fsLogixProfilesQuotaGiB = @{ value = $FsLogixProfilesQuotaGiB }
redirectedFoldersQuotaGiB = @{ value = $RedirectedFoldersQuotaGiB }
} }
} }
[IO.File]::WriteAllText( [IO.File]::WriteAllText(
@@ -135,6 +184,13 @@ if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", 'Create Azure VNe
DomainControllerPublicIp = $values.domainControllerPublicIp DomainControllerPublicIp = $values.domainControllerPublicIp
VpnGatewayName = $values.vpnGatewayName VpnGatewayName = $values.vpnGatewayName
VpnClientAddressPoolPrefix = $values.vpnClientAddressPoolPrefix VpnClientAddressPoolPrefix = $values.vpnClientAddressPoolPrefix
DeployVpnGateway = $DeployVpnGateway
PublicEnrollmentSourceAddressPrefixes = @($PublicEnrollmentSourceAddressPrefixes)
ServerBootstrapArguments = $values.serverBootstrapArguments ServerBootstrapArguments = $values.serverBootstrapArguments
UserRoamingEnabled = [bool]$values.userRoamingEnabled
UserRoamingStorageAccountName = $values.userRoamingStorageAccountName
FsLogixProfilesSharePath = $values.fsLogixProfilesSharePath
RedirectedFoldersSharePath = $values.redirectedFoldersSharePath
UserRoamingSetupArguments = @($values.userRoamingSetupArguments)
} }
} }
+210
View File
@@ -0,0 +1,210 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$DomainController = $env:COMPUTERNAME,
[string]$StudentOuName = 'Alumnos',
[string]$ProfessorOuName = 'Docentes',
[string]$AdministrativeOuName = 'Administrativos',
[string]$LaboratoryOuName = 'Laboratorio',
[string]$StudentGroupName = 'SGU-Alumnos',
[string]$ProfessorGroupName = 'SGU-Docentes',
[string]$AdministrativeGroupName = 'SGU-Administrativos',
[string]$StudentGpoName = 'SGU - AL redirected folders',
[string]$StaffGpoName = 'SGU - AD-DO FSLogix profiles',
[string]$FsLogixDeploymentGpoName = 'SGU - FSLogix client deployment',
[switch]$PreserveLegacyUserMappings
)
$ErrorActionPreference = 'Stop'
function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated Windows PowerShell 5.1 session on the SGU domain controller.'
}
}
function Set-SguPolicyValue {
param(
[Parameter(Mandatory)][string]$GpoName,
[Parameter(Mandatory)][string]$DomainName,
[Parameter(Mandatory)][string]$Server,
[Parameter(Mandatory)][string]$Key,
[Parameter(Mandatory)][string]$ValueName,
[Parameter(Mandatory)][ValidateSet('DWord','String','ExpandString')][string]$Type,
[Parameter(Mandatory)]$Value
)
if ($PSCmdlet.ShouldProcess("$GpoName :: $Key\\$ValueName", "Set rollback value $Value")) {
Remove-GPRegistryValue -Name $GpoName -Domain $DomainName -Server $Server `
-Key $Key -ValueName "**del.$ValueName" -ErrorAction SilentlyContinue | Out-Null
Set-GPRegistryValue -Name $GpoName -Domain $DomainName -Server $Server `
-Key $Key -ValueName $ValueName -Type $Type -Value $Value | Out-Null
}
}
function Remove-SguPolicyValue {
param(
[Parameter(Mandatory)][string]$GpoName,
[Parameter(Mandatory)][string]$DomainName,
[Parameter(Mandatory)][string]$Server,
[Parameter(Mandatory)][string]$Key,
[Parameter(Mandatory)][string]$ValueName
)
if ($PSCmdlet.ShouldProcess("$GpoName :: $Key\\$ValueName", 'Remove roaming value')) {
Remove-GPRegistryValue -Name $GpoName -Domain $DomainName -Server $Server `
-Key $Key -ValueName $ValueName -ErrorAction SilentlyContinue | Out-Null
Set-GPRegistryValue -Name $GpoName -Domain $DomainName -Server $Server `
-Key $Key -ValueName "**del.$ValueName" -Type String -Value '' | Out-Null
}
}
Assert-Administrator
Import-Module ActiveDirectory -ErrorAction Stop
Import-Module GroupPolicy -ErrorAction Stop
$domain = Get-ADDomain -Server $DomainController
$domainName = $domain.DNSRoot
$domainDn = $domain.DistinguishedName
$laboratoryOuDn = "OU=$LaboratoryOuName,$domainDn"
$usersOuDn = "OU=Usuarios-SGU,$domainDn"
$studentOuDn = "OU=$StudentOuName,$usersOuDn"
$professorOuDn = "OU=$ProfessorOuName,$usersOuDn"
$administrativeOuDn = "OU=$AdministrativeOuName,$usersOuDn"
foreach ($ou in $laboratoryOuDn,$studentOuDn,$professorOuDn,$administrativeOuDn) {
Get-ADOrganizationalUnit -Identity $ou -Server $DomainController -ErrorAction Stop | Out-Null
}
$staffGpo = Get-GPO -Name $StaffGpoName -Domain $domainName -Server $DomainController `
-ErrorAction SilentlyContinue
if (-not $staffGpo -and $PSCmdlet.ShouldProcess($StaffGpoName, 'Create local-profile enforcement GPO')) {
$staffGpo = New-GPO -Name $StaffGpoName -Domain $domainName -Server $DomainController
}
if (-not $staffGpo) {
throw "The GPO '$StaffGpoName' does not exist and was not created."
}
$staffLink = @(Get-GPInheritance -Target $laboratoryOuDn -Domain $domainName `
-Server $DomainController).GpoLinks |
Where-Object DisplayName -eq $StaffGpoName | Select-Object -First 1
if (-not $staffLink -and $PSCmdlet.ShouldProcess($laboratoryOuDn, "Link '$StaffGpoName'")) {
New-GPLink -Name $StaffGpoName -Target $laboratoryOuDn -Domain $domainName `
-Server $DomainController -LinkEnabled Yes | Out-Null
}
elseif ($staffLink -and -not [bool]$staffLink.Enabled -and
$PSCmdlet.ShouldProcess($laboratoryOuDn, "Enable '$StaffGpoName' rollback link")) {
Set-GPLink -Name $StaffGpoName -Target $laboratoryOuDn -Domain $domainName `
-Server $DomainController -LinkEnabled Yes | Out-Null
}
$profilesRoot = 'HKLM\SOFTWARE\FSLogix\Profiles'
Set-SguPolicyValue -GpoName $StaffGpoName -DomainName $domainName -Server $DomainController `
-Key $profilesRoot -ValueName Enabled -Type DWord -Value 0
Set-SguPolicyValue -GpoName $StaffGpoName -DomainName $domainName -Server $DomainController `
-Key 'HKLM\SOFTWARE\FSLogix\ODFC' -ValueName Enabled -Type DWord -Value 0
foreach ($serviceName in 'frxsvc','frxccds') {
Set-SguPolicyValue -GpoName $StaffGpoName -DomainName $domainName -Server $DomainController `
-Key "HKLM\SYSTEM\CurrentControlSet\Services\$serviceName" `
-ValueName Start -Type DWord -Value 4
}
Remove-SguPolicyValue -GpoName $StaffGpoName -DomainName $domainName -Server $DomainController `
-Key 'HKLM\SOFTWARE\FSLogix\Apps' -ValueName VHDCompactDisk
$profileValueNames = @(
'CCDLocations','VHDLocations','CcdUnregisterTimeout','ClearCacheOnForcedUnregister',
'ClearCacheOnLogoff','HealthyProvidersRequiredForRegister',
'HealthyProvidersRequiredForUnregister','PreventLoginWithFailure',
'PreventLoginWithTempProfile','DeleteLocalProfileWhenVHDShouldApply',
'FlipFlopProfileDirectoryName','IsDynamic','LockedRetryCount','LockedRetryInterval',
'ProfileType','ReAttachIntervalSeconds','ReAttachRetryCount','SizeInMBs','VolumeType',
'VHDCompactDisk'
)
$roleGroups = @(
Get-ADGroup -Identity "CN=$ProfessorGroupName,$professorOuDn" -Server $DomainController
Get-ADGroup -Identity "CN=$AdministrativeGroupName,$administrativeOuDn" -Server $DomainController
)
foreach ($group in $roleGroups) {
$key = "$profilesRoot\ObjectSpecific\$($group.SID.Value)"
Set-SguPolicyValue -GpoName $StaffGpoName -DomainName $domainName -Server $DomainController `
-Key $key -ValueName Enabled -Type DWord -Value 0
foreach ($valueName in $profileValueNames) {
Remove-SguPolicyValue -GpoName $StaffGpoName -DomainName $domainName `
-Server $DomainController -Key $key -ValueName $valueName
}
}
Set-SguPolicyValue -GpoName $StaffGpoName -DomainName $domainName -Server $DomainController `
-Key 'HKLM\SOFTWARE\SGU\UserRoaming' -ValueName StaffProfileStorageMode `
-Type String -Value Disabled
$studentGpo = Get-GPO -Name $StudentGpoName -Domain $domainName -Server $DomainController `
-ErrorAction SilentlyContinue
if ($studentGpo) {
$studentLink = @(Get-GPInheritance -Target $studentOuDn -Domain $domainName `
-Server $DomainController).GpoLinks |
Where-Object DisplayName -eq $StudentGpoName | Select-Object -First 1
if ($studentLink -and -not [bool]$studentLink.Enabled -and
$PSCmdlet.ShouldProcess($studentOuDn, "Enable '$StudentGpoName' local-path rollback")) {
Set-GPLink -Name $StudentGpoName -Target $studentOuDn -Domain $domainName `
-Server $DomainController -LinkEnabled Yes | Out-Null
}
$shellFoldersKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
Set-SguPolicyValue -GpoName $StudentGpoName -DomainName $domainName -Server $DomainController `
-Key $shellFoldersKey -ValueName Desktop -Type ExpandString `
-Value '%USERPROFILE%\Desktop'
Set-SguPolicyValue -GpoName $StudentGpoName -DomainName $domainName -Server $DomainController `
-Key $shellFoldersKey -ValueName Personal -Type ExpandString `
-Value '%USERPROFILE%\Documents'
Remove-SguPolicyValue -GpoName $StudentGpoName -DomainName $domainName `
-Server $DomainController -Key 'HKCU\Software\Policies\Microsoft\Windows\NetCache' `
-ValueName DisableFRAdminPin
}
$deploymentGpo = Get-GPO -Name $FsLogixDeploymentGpoName -Domain $domainName `
-Server $DomainController -ErrorAction SilentlyContinue
if ($deploymentGpo) {
$deploymentLink = @(Get-GPInheritance -Target $laboratoryOuDn -Domain $domainName `
-Server $DomainController).GpoLinks |
Where-Object DisplayName -eq $FsLogixDeploymentGpoName | Select-Object -First 1
if ($deploymentLink -and [bool]$deploymentLink.Enabled -and
$PSCmdlet.ShouldProcess($laboratoryOuDn, "Disable '$FsLogixDeploymentGpoName'")) {
Set-GPLink -Name $FsLogixDeploymentGpoName -Target $laboratoryOuDn `
-Domain $domainName -Server $DomainController -LinkEnabled No | Out-Null
}
}
$clearedUsers = [Collections.Generic.List[string]]::new()
if (-not $PreserveLegacyUserMappings) {
foreach ($roleOu in $studentOuDn,$professorOuDn,$administrativeOuDn) {
foreach ($user in Get-ADUser -SearchBase $roleOu -SearchScope Subtree -Filter * `
-Server $DomainController -Properties profilePath,homeDirectory,homeDrive) {
if ($user.profilePath -or $user.homeDirectory -or $user.homeDrive) {
if ($PSCmdlet.ShouldProcess($user.SamAccountName, 'Clear AD profile and home-drive mappings')) {
Set-ADUser -Identity $user -Server $DomainController `
-Clear profilePath,homeDirectory,homeDrive
$clearedUsers.Add($user.SamAccountName)
}
}
}
}
}
$staffGpo = Get-GPO -Name $StaffGpoName -Domain $domainName -Server $DomainController
$studentGpo = Get-GPO -Name $StudentGpoName -Domain $domainName -Server $DomainController `
-ErrorAction SilentlyContinue
$deploymentLink = @(Get-GPInheritance -Target $laboratoryOuDn -Domain $domainName `
-Server $DomainController).GpoLinks |
Where-Object DisplayName -eq $FsLogixDeploymentGpoName | Select-Object -First 1
[pscustomobject]@{
Domain = $domainName
UserRoamingEnabled = $false
StaffGpoVersion = "$($staffGpo.Computer.DSVersion)/$($staffGpo.Computer.SysVolVersion)"
StudentGpoVersion = if ($studentGpo) {
"$($studentGpo.User.DSVersion)/$($studentGpo.User.SysVolVersion)"
} else { $null }
FsLogixDeploymentLinkEnabled = [bool]$deploymentLink.Enabled
StaffProfilesEnabled = 0
StudentDesktop = '%USERPROFILE%\Desktop'
StudentDocuments = '%USERPROFILE%\Documents'
ClearedAdUsers = @($clearedUsers)
RebootManagedWindowsClients = $true
}
+23 -5
View File
@@ -18,6 +18,24 @@ if (-not $computer.PartOfDomain) {
$remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-555') $remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-555')
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1] $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")) { if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesktopPrincipal access")) {
function Invoke-PowerCfgBestEffort { function Invoke-PowerCfgBestEffort {
@@ -57,9 +75,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesk
-ErrorAction SilentlyContinue | -ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain Set-NetFirewallRule -Enabled True -Profile Domain
$existingMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorAction SilentlyContinue) $existingMembers = @(Get-LocalGroupMemberSid -Name $remoteDesktopUsersGroup)
if ($existingMembers.Name -notcontains $RemoteDesktopPrincipal) { if ($existingMembers -notcontains $remoteDesktopPrincipalSid.Value) {
Add-LocalGroupMember -Group $remoteDesktopUsersGroup -Member $RemoteDesktopPrincipal Add-LocalGroupMember -Group $remoteDesktopUsersGroup -Member $remoteDesktopPrincipalSid.Value
} }
# Use Windows PowerShell so both the inbox and compatible remoting endpoints # 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]@{ [pscustomobject]@{
ComputerName = $env:COMPUTERNAME ComputerName = $env:COMPUTERNAME
Domain = $computer.Domain Domain = $computer.Domain
@@ -108,7 +126,7 @@ $rdpMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorActio
'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' ` 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' `
-Name UserAuthentication) -eq 1 -Name UserAuthentication) -eq 1
RemoteDesktopPrincipal = $RemoteDesktopPrincipal RemoteDesktopPrincipal = $RemoteDesktopPrincipal
PrincipalIsAuthorized = $rdpMembers.Name -contains $RemoteDesktopPrincipal PrincipalIsAuthorized = $rdpMembers -contains $remoteDesktopPrincipalSid.Value
TermService = (Get-Service TermService).Status TermService = (Get-Service TermService).Status
WinRM = (Get-Service WinRM).Status WinRM = (Get-Service WinRM).Status
FirewallProfile = 'Domain' FirewallProfile = 'Domain'
+550
View File
@@ -0,0 +1,550 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][guid]$SubscriptionId,
[Parameter(Mandatory)][string]$ResourceGroupName,
[Parameter(Mandatory)]
[ValidatePattern('^[a-z0-9]{3,24}$')]
[string]$StorageAccountName,
[ValidatePattern('^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$')]
[string]$FsLogixProfilesShareName = 'profiles',
[ValidatePattern('^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$')]
[string]$RedirectedFoldersShareName = 'redirected',
[string]$DomainController = $env:COMPUTERNAME,
[string]$AzureFilesOuName = 'AzureFilesConfig',
[string]$StudentOuName = 'Alumnos',
[string]$ProfessorOuName = 'Docentes',
[string]$AdministrativeOuName = 'Administrativos',
[string]$LaboratoryOuName = 'Laboratorio',
[string]$StudentGroupName = 'SGU-Alumnos',
[string]$ProfessorGroupName = 'SGU-Docentes',
[string]$AdministrativeGroupName = 'SGU-Administrativos',
[string]$StudentGpoName = 'SGU - AL redirected folders',
[string]$StaffGpoName = 'SGU - AD-DO FSLogix profiles',
[ValidateSet('Private', 'Public')]
[string]$EndpointAccess = 'Private',
[ValidateSet('Auto', 'Direct', 'CloudCache')]
[string]$StaffProfileStorageMode = 'Auto',
[ValidateRange(1024, 1048576)]
[int]$FsLogixProfileSizeMiB = 30000,
[string]$AzFilesHybridModulePath,
[switch]$UseDeviceAuthentication,
[switch]$DeleteExistingStaffLocalProfiles,
[switch]$AcknowledgeUserRoamingRisks,
[string]$FsLogixDeploymentGpoName = 'SGU - FSLogix client deployment'
)
$ErrorActionPreference = 'Stop'
if (-not $AcknowledgeUserRoamingRisks -and -not $WhatIfPreference) {
throw 'User roaming is an optional high-risk feature. Pass -AcknowledgeUserRoamingRisks only after reviewing docs/user-roaming.md, testing sign-in and sign-out, and accepting the documented storage and recovery failure modes.'
}
function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated Windows PowerShell 5.1 session on the SGU domain controller.'
}
}
function Import-SguAzFilesHybrid {
param([string]$ModulePath)
if ($ModulePath) {
if (-not (Test-Path -LiteralPath $ModulePath)) {
throw "AzFilesHybridModulePath does not exist: $ModulePath"
}
$resolvedModule = if (Test-Path -LiteralPath $ModulePath -PathType Container) {
Get-ChildItem -LiteralPath $ModulePath -Recurse -File |
Where-Object Name -in @('AzFilesHybrid.psd1', 'AzFilesHybrid.psm1') |
Sort-Object @{ Expression = { $_.Extension -eq '.psd1' }; Descending = $true }, FullName |
Select-Object -First 1
}
else {
Get-Item -LiteralPath $ModulePath
}
if (-not $resolvedModule) {
throw "AzFilesHybrid.psd1 or AzFilesHybrid.psm1 was not found beneath $ModulePath."
}
Import-Module -Name $resolvedModule.FullName -Force -ErrorAction Stop
}
else {
Import-Module -Name AzFilesHybrid -Force -ErrorAction Stop
}
$joinCommand = Get-Command Join-AzStorageAccount -ErrorAction SilentlyContinue
if (-not $joinCommand) {
$joinCommand = Get-Command Join-AzStorageAccountForAuth -ErrorAction SilentlyContinue
}
if (-not $joinCommand) {
throw 'AzFilesHybrid did not expose Join-AzStorageAccount. Install the current Microsoft AzFilesHybrid module and retry.'
}
return $joinCommand
}
function Get-SguStorageSamAccountName {
param([Parameter(Mandatory)][string]$StorageName)
if ($StorageName.Length -le 15) {
return $StorageName
}
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
$hash = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($StorageName))
$suffix = ([BitConverter]::ToString($hash) -replace '-', '').Substring(0, 10).ToLowerInvariant()
return "sgufs$suffix"
}
finally {
$sha256.Dispose()
}
}
function Ensure-SguOrganizationalUnit {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Server
)
$escapedName = $Name.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29')
$ou = Get-ADOrganizationalUnit -LDAPFilter "(ou=$escapedName)" -SearchBase $Path `
-SearchScope OneLevel -Server $Server -ErrorAction Stop | Select-Object -First 1
if (-not $ou -and $PSCmdlet.ShouldProcess("OU=$Name,$Path", 'Create Azure Files identity OU')) {
New-ADOrganizationalUnit -Name $Name -Path $Path -ProtectedFromAccidentalDeletion $true `
-Server $Server | Out-Null
$ou = Get-ADOrganizationalUnit -Identity "OU=$Name,$Path" -Server $Server
}
if (-not $ou) {
throw "The organizational unit OU=$Name,$Path does not exist."
}
return $ou
}
function Ensure-SguGpoLink {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$TargetOuDn,
[Parameter(Mandatory)][string]$DomainName,
[Parameter(Mandatory)][string]$Server
)
$gpo = Get-GPO -Name $Name -Domain $DomainName -Server $Server -ErrorAction SilentlyContinue
if (-not $gpo -and $PSCmdlet.ShouldProcess($Name, 'Create user-roaming GPO')) {
$gpo = New-GPO -Name $Name -Domain $DomainName -Server $Server
}
if (-not $gpo) {
throw "The GPO '$Name' does not exist and was not created."
}
$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $DomainName -Server $Server).GpoLinks |
Where-Object DisplayName -eq $Name | Select-Object -First 1
$linkEnabled = $link -and ($link.Enabled -eq $true -or [string]$link.Enabled -eq 'Yes')
if (-not $link -and $PSCmdlet.ShouldProcess($TargetOuDn, "Link and enable '$Name'")) {
New-GPLink -Name $Name -Target $TargetOuDn -Domain $DomainName -Server $Server `
-LinkEnabled Yes | Out-Null
}
elseif ($link -and -not $linkEnabled -and
$PSCmdlet.ShouldProcess($TargetOuDn, "Enable the '$Name' link")) {
Set-GPLink -Name $Name -Target $TargetOuDn -Domain $DomainName -Server $Server `
-LinkEnabled Yes | Out-Null
}
return $gpo
}
function Set-SguGpoRegistryValue {
param(
[Parameter(Mandatory)][string]$GpoName,
[Parameter(Mandatory)][string]$DomainName,
[Parameter(Mandatory)][string]$Server,
[Parameter(Mandatory)][string]$Key,
[Parameter(Mandatory)][string]$ValueName,
[Parameter(Mandatory)][ValidateSet('DWord', 'String', 'ExpandString')][string]$Type,
[Parameter(Mandatory)]$Value
)
if ($PSCmdlet.ShouldProcess("$GpoName :: $Key\\$ValueName", "Set $Type policy value")) {
Remove-GPRegistryValue -Name $GpoName -Domain $DomainName -Server $Server `
-Key $Key -ValueName "**del.$ValueName" -ErrorAction SilentlyContinue | Out-Null
Set-GPRegistryValue -Name $GpoName -Domain $DomainName -Server $Server `
-Key $Key -ValueName $ValueName -Type $Type -Value $Value | Out-Null
}
}
function Remove-SguGpoRegistryValue {
param(
[Parameter(Mandatory)][string]$GpoName,
[Parameter(Mandatory)][string]$DomainName,
[Parameter(Mandatory)][string]$Server,
[Parameter(Mandatory)][string]$Key,
[Parameter(Mandatory)][string]$ValueName
)
if ($PSCmdlet.ShouldProcess("$GpoName :: $Key\$ValueName", 'Remove obsolete policy value')) {
Remove-GPRegistryValue -Name $GpoName -Domain $DomainName -Server $Server `
-Key $Key -ValueName $ValueName -ErrorAction SilentlyContinue
}
}
function Set-SguGpoRegistryValueDeletion {
param(
[Parameter(Mandatory)][string]$GpoName,
[Parameter(Mandatory)][string]$DomainName,
[Parameter(Mandatory)][string]$Server,
[Parameter(Mandatory)][string]$Key,
[Parameter(Mandatory)][string]$ValueName
)
Remove-SguGpoRegistryValue -GpoName $GpoName -DomainName $DomainName -Server $Server `
-Key $Key -ValueName $ValueName
Set-SguGpoRegistryValue -GpoName $GpoName -DomainName $DomainName -Server $Server `
-Key $Key -ValueName "**del.$ValueName" -Type String -Value ''
}
function Get-SguUnusedDriveName {
$used = @(Get-PSDrive -PSProvider FileSystem | Select-Object -ExpandProperty Name)
foreach ($name in @('Z', 'Y', 'X', 'W', 'V')) {
if ($used -notcontains $name) {
return $name
}
}
throw 'No temporary drive letter is available for configuring Azure Files ACLs.'
}
function Set-SguAzureFileRootAcl {
param(
[Parameter(Mandatory)][string]$UncPath,
[Parameter(Mandatory)][PSCredential]$Credential,
[Parameter(Mandatory)][Security.Principal.SecurityIdentifier]$DomainAdminsSid,
[Parameter(Mandatory)][Security.Principal.SecurityIdentifier[]]$ContributorSids,
[Security.AccessControl.FileSystemRights]$ContributorRights =
[Security.AccessControl.FileSystemRights]::Modify
)
$driveName = Get-SguUnusedDriveName
try {
New-PSDrive -Name $driveName -PSProvider FileSystem -Root $UncPath `
-Credential $Credential -Scope Script -ErrorAction Stop | Out-Null
$rootPath = "${driveName}:\"
$acl = [Security.AccessControl.DirectorySecurity]::new()
$acl.SetAccessRuleProtection($true, $false)
$acl.SetOwner($DomainAdminsSid)
$allow = [Security.AccessControl.AccessControlType]::Allow
$containerAndObject = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'
$noneInheritance = [Security.AccessControl.InheritanceFlags]::None
$nonePropagation = [Security.AccessControl.PropagationFlags]::None
$inheritOnly = [Security.AccessControl.PropagationFlags]::InheritOnly
$systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$creatorOwnerSid = [Security.Principal.SecurityIdentifier]::new('S-1-3-0')
foreach ($administratorSid in @($systemSid, $DomainAdminsSid)) {
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
$administratorSid,
[Security.AccessControl.FileSystemRights]::FullControl,
$containerAndObject,
$nonePropagation,
$allow))
}
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
$creatorOwnerSid,
[Security.AccessControl.FileSystemRights]::Modify,
$containerAndObject,
$inheritOnly,
$allow))
foreach ($contributorSid in $ContributorSids) {
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
$contributorSid,
$ContributorRights,
$noneInheritance,
$nonePropagation,
$allow))
}
Set-Acl -LiteralPath $rootPath -AclObject $acl -ErrorAction Stop
}
finally {
Remove-PSDrive -Name $driveName -Scope Script -Force -ErrorAction SilentlyContinue
}
}
Assert-Administrator
if ($FsLogixProfilesShareName.Contains('--') -or $RedirectedFoldersShareName.Contains('--')) {
throw 'Azure Files share names cannot contain consecutive hyphens.'
}
if ($FsLogixProfilesShareName -eq $RedirectedFoldersShareName) {
throw 'The profile-container and redirected-folder shares must have different names.'
}
Import-Module ActiveDirectory -ErrorAction Stop
Import-Module GroupPolicy -ErrorAction Stop
foreach ($azureModule in @('Az.Accounts', 'Az.Storage')) {
try {
Import-Module $azureModule -ErrorAction Stop
}
catch {
throw "The current $azureModule module is required on the domain controller. Install Azure PowerShell and retry. $($_.Exception.Message)"
}
}
$joinStorageCommand = Import-SguAzFilesHybrid -ModulePath $AzFilesHybridModulePath
$domain = Get-ADDomain -Server $DomainController
$baseDn = $domain.DistinguishedName
$domainName = $domain.DNSRoot
$usersOuDn = "OU=Usuarios-SGU,$baseDn"
$studentOuDn = "OU=$StudentOuName,$usersOuDn"
$professorOuDn = "OU=$ProfessorOuName,$usersOuDn"
$administrativeOuDn = "OU=$AdministrativeOuName,$usersOuDn"
$laboratoryOuDn = "OU=$LaboratoryOuName,$baseDn"
foreach ($requiredOu in @($studentOuDn, $professorOuDn, $administrativeOuDn, $laboratoryOuDn)) {
Get-ADOrganizationalUnit -Identity $requiredOu -Server $DomainController -ErrorAction Stop | Out-Null
}
$studentGroup = Get-ADGroup -Identity "CN=$StudentGroupName,$studentOuDn" -Server $DomainController
$professorGroup = Get-ADGroup -Identity "CN=$ProfessorGroupName,$professorOuDn" -Server $DomainController
$administrativeGroup = Get-ADGroup -Identity "CN=$AdministrativeGroupName,$administrativeOuDn" `
-Server $DomainController
$domainAdminsSid = [Security.Principal.SecurityIdentifier]::new("$($domain.DomainSID.Value)-512")
$azureFilesOu = Ensure-SguOrganizationalUnit -Name $AzureFilesOuName -Path $baseDn -Server $DomainController
$azureContext = Get-AzContext -ErrorAction SilentlyContinue
if (-not $azureContext -or $azureContext.Subscription.Id -ne $SubscriptionId.Guid) {
$connectParameters = @{}
if ($UseDeviceAuthentication) {
$connectParameters.UseDeviceAuthentication = $true
}
Connect-AzAccount @connectParameters | Out-Null
}
Set-AzContext -SubscriptionId $SubscriptionId.Guid | Out-Null
$storageAccount = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName `
-Name $StorageAccountName -ErrorAction Stop
$fileEndpointHost = ([uri]$storageAccount.PrimaryEndpoints.File).Host
if (-not $fileEndpointHost) {
throw "Azure did not return a file endpoint for $StorageAccountName."
}
$directoryService = [string]$storageAccount.AzureFilesIdentityBasedAuth.DirectoryServiceOptions
if ($directoryService -and $directoryService -ne 'None' -and $directoryService -ne 'AD') {
throw "Storage account $StorageAccountName already uses the incompatible Azure Files identity source '$directoryService'."
}
if ($directoryService -ne 'AD') {
if ($PSCmdlet.ShouldProcess($StorageAccountName, "Join Azure Files to $domainName with AES-256 Kerberos")) {
$requestedSamAccountName = Get-SguStorageSamAccountName -StorageName $StorageAccountName
$joinParameters = @{
ResourceGroupName = $ResourceGroupName
StorageAccountName = $StorageAccountName
SamAccountName = $requestedSamAccountName
DomainAccountType = 'ComputerAccount'
OrganizationalUnitDistinguishedName = $azureFilesOu.DistinguishedName
}
& $joinStorageCommand @joinParameters
$storageAccount = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName `
-Name $StorageAccountName -ErrorAction Stop
}
}
$directoryService = [string]$storageAccount.AzureFilesIdentityBasedAuth.DirectoryServiceOptions
if ($directoryService -ne 'AD') {
throw "Azure Files identity authentication is '$directoryService', not AD. The domain join did not complete."
}
$activeDirectoryProperties = $storageAccount.AzureFilesIdentityBasedAuth.ActiveDirectoryProperties
if ([string]$activeDirectoryProperties.DomainName -ne $domainName) {
throw "Storage account $StorageAccountName is joined to $($activeDirectoryProperties.DomainName), not $domainName."
}
$storageSamAccountName = [string]$activeDirectoryProperties.SamAccountName
if (-not $storageSamAccountName) {
$storageSamAccountName = $StorageAccountName
}
$storageComputer = Get-ADComputer -Identity "${storageSamAccountName}$" `
-Server $DomainController -ErrorAction Stop
if ($PSCmdlet.ShouldProcess($storageComputer.DistinguishedName, 'Require AES-256 Kerberos and prevent an unattended storage identity password expiry')) {
Set-ADComputer -Identity $storageComputer -Server $DomainController `
-KerberosEncryptionType AES256 -PasswordNeverExpires $true
}
if ($PSCmdlet.ShouldProcess($StorageAccountName, 'Grant authenticated AD identities the Azure Files SMB contributor default share permission')) {
$storageAccount = Set-AzStorageAccount -ResourceGroupName $ResourceGroupName `
-Name $StorageAccountName `
-DefaultSharePermission StorageFileDataSmbShareContributor
}
$endpointAddresses = @(Resolve-DnsName -Name $fileEndpointHost -Type A -ErrorAction Stop |
Where-Object IPAddress | Select-Object -ExpandProperty IPAddress)
if ($endpointAddresses.Count -eq 0) {
throw "$fileEndpointHost did not resolve to an IPv4 address."
}
$privateAddresses = @($endpointAddresses | Where-Object {
$_ -match '^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)'
})
if ($EndpointAccess -eq 'Private' -and $privateAddresses.Count -eq 0) {
throw "$fileEndpointHost did not resolve to a private endpoint. Verify the privatelink.file.core.windows.net VNet link and the DC Azure DNS forwarder."
}
if (-not (Test-NetConnection -ComputerName $fileEndpointHost -Port 445 -InformationLevel Quiet)) {
throw "The domain controller cannot reach $fileEndpointHost on TCP 445 using the selected $EndpointAccess endpoint."
}
$storageKey = @(Get-AzStorageAccountKey -ResourceGroupName $ResourceGroupName `
-Name $StorageAccountName -ErrorAction Stop | Where-Object KeyName -eq 'key1' |
Select-Object -First 1).Value
if (-not $storageKey) {
throw "Azure did not return key1 for $StorageAccountName; it is required only to set the initial root ACLs."
}
$storageCredential = [PSCredential]::new(
"Azure\$StorageAccountName",
(ConvertTo-SecureString -String $storageKey -AsPlainText -Force))
$profilesSharePath = "\\$fileEndpointHost\$FsLogixProfilesShareName"
$redirectedFoldersSharePath = "\\$fileEndpointHost\$RedirectedFoldersShareName"
$resolvedStaffProfileStorageMode = if ($StaffProfileStorageMode -eq 'Auto') {
if ($EndpointAccess -eq 'Public') { 'CloudCache' } else { 'Direct' }
}
else {
$StaffProfileStorageMode
}
$perUserRootRights = [Security.AccessControl.FileSystemRights]::CreateDirectories -bor
[Security.AccessControl.FileSystemRights]::ListDirectory -bor
[Security.AccessControl.FileSystemRights]::ReadAttributes -bor
[Security.AccessControl.FileSystemRights]::ReadExtendedAttributes -bor
[Security.AccessControl.FileSystemRights]::ReadPermissions -bor
[Security.AccessControl.FileSystemRights]::Traverse -bor
[Security.AccessControl.FileSystemRights]::Synchronize
try {
if ($PSCmdlet.ShouldProcess($profilesSharePath, 'Apply isolated FSLogix root ACLs')) {
Set-SguAzureFileRootAcl -UncPath $profilesSharePath -Credential $storageCredential `
-DomainAdminsSid $domainAdminsSid `
-ContributorSids @($professorGroup.SID, $administrativeGroup.SID) `
-ContributorRights $perUserRootRights
}
if ($PSCmdlet.ShouldProcess($redirectedFoldersSharePath, 'Apply isolated student-folder root ACLs')) {
Set-SguAzureFileRootAcl -UncPath $redirectedFoldersSharePath -Credential $storageCredential `
-DomainAdminsSid $domainAdminsSid -ContributorSids @($studentGroup.SID) `
-ContributorRights $perUserRootRights
}
}
finally {
$storageKey = $null
$storageCredential = $null
}
$studentGpo = Ensure-SguGpoLink -Name $StudentGpoName -TargetOuDn $studentOuDn `
-DomainName $domainName -Server $DomainController
$userShellFoldersKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
Set-SguGpoRegistryValue -GpoName $studentGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key $userShellFoldersKey -ValueName 'Desktop' `
-Type ExpandString -Value "$redirectedFoldersSharePath\%USERNAME%\Desktop"
Set-SguGpoRegistryValue -GpoName $studentGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key $userShellFoldersKey -ValueName 'Personal' `
-Type ExpandString -Value "$redirectedFoldersSharePath\%USERNAME%\Documents"
Set-SguGpoRegistryValue -GpoName $studentGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key 'HKCU\Software\Policies\Microsoft\Windows\NetCache' `
-ValueName 'DisableFRAdminPin' -Type DWord -Value 1
$staffGpo = Ensure-SguGpoLink -Name $StaffGpoName -TargetOuDn $laboratoryOuDn `
-DomainName $domainName -Server $DomainController
$fsLogixRoot = 'HKLM\SOFTWARE\FSLogix\Profiles'
Set-SguGpoRegistryValue -GpoName $staffGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key $fsLogixRoot -ValueName 'Enabled' -Type DWord -Value 0
Set-SguGpoRegistryValue -GpoName $staffGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key 'HKLM\SYSTEM\CurrentControlSet\Services\frxsvc' `
-ValueName 'Start' -Type DWord -Value 2
Set-SguGpoRegistryValue -GpoName $staffGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key 'HKLM\SYSTEM\CurrentControlSet\Services\frxccds' `
-ValueName 'Start' -Type DWord -Value 2
$deploymentGpo = Get-GPO -Name $FsLogixDeploymentGpoName -Domain $domainName `
-Server $DomainController -ErrorAction SilentlyContinue
if ($deploymentGpo) {
$deploymentLink = @(Get-GPInheritance -Target $laboratoryOuDn -Domain $domainName `
-Server $DomainController).GpoLinks |
Where-Object DisplayName -eq $FsLogixDeploymentGpoName | Select-Object -First 1
if ($deploymentLink -and -not [bool]$deploymentLink.Enabled -and
$PSCmdlet.ShouldProcess($laboratoryOuDn, "Enable '$FsLogixDeploymentGpoName'")) {
Set-GPLink -Name $FsLogixDeploymentGpoName -Target $laboratoryOuDn `
-Domain $domainName -Server $DomainController -LinkEnabled Yes | Out-Null
}
}
$fsLogixValues = [ordered]@{
Enabled = @{ Type = 'DWord'; Value = 1 }
DeleteLocalProfileWhenVHDShouldApply = @{
Type = 'DWord'
Value = if ($DeleteExistingStaffLocalProfiles) { 1 } else { 0 }
}
FlipFlopProfileDirectoryName = @{ Type = 'DWord'; Value = 1 }
IsDynamic = @{ Type = 'DWord'; Value = 1 }
LockedRetryCount = @{ Type = 'DWord'; Value = 3 }
LockedRetryInterval = @{ Type = 'DWord'; Value = 15 }
ProfileType = @{ Type = 'DWord'; Value = 0 }
ReAttachIntervalSeconds = @{ Type = 'DWord'; Value = 15 }
ReAttachRetryCount = @{ Type = 'DWord'; Value = 3 }
SizeInMBs = @{ Type = 'DWord'; Value = $FsLogixProfileSizeMiB }
VolumeType = @{ Type = 'String'; Value = 'VHDX' }
}
$cloudCacheOnlyValues = @(
'CCDLocations',
'CcdUnregisterTimeout',
'ClearCacheOnForcedUnregister',
'ClearCacheOnLogoff',
'HealthyProvidersRequiredForRegister',
'HealthyProvidersRequiredForUnregister',
'PreventLoginWithFailure',
'PreventLoginWithTempProfile'
)
if ($resolvedStaffProfileStorageMode -eq 'CloudCache') {
$fsLogixValues.CCDLocations = @{
Type = 'String'
Value = "type=smb,name=`"SGU Azure Files`",connectionString=$profilesSharePath"
}
$fsLogixValues.CcdUnregisterTimeout = @{ Type = 'DWord'; Value = 60 }
$fsLogixValues.ClearCacheOnForcedUnregister = @{ Type = 'DWord'; Value = 0 }
$fsLogixValues.ClearCacheOnLogoff = @{ Type = 'DWord'; Value = 0 }
$fsLogixValues.HealthyProvidersRequiredForRegister = @{ Type = 'DWord'; Value = 1 }
$fsLogixValues.HealthyProvidersRequiredForUnregister = @{ Type = 'DWord'; Value = 1 }
$fsLogixValues.PreventLoginWithFailure = @{ Type = 'DWord'; Value = 1 }
$fsLogixValues.PreventLoginWithTempProfile = @{ Type = 'DWord'; Value = 1 }
}
else {
$fsLogixValues.VHDLocations = @{ Type = 'String'; Value = $profilesSharePath }
}
Set-SguGpoRegistryValue -GpoName $staffGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key 'HKLM\SOFTWARE\FSLogix\Apps' `
-ValueName 'VHDCompactDisk' -Type DWord -Value 0
foreach ($staffGroup in @($professorGroup, $administrativeGroup)) {
$objectSpecificKey = "$fsLogixRoot\ObjectSpecific\$($staffGroup.SID.Value)"
Set-SguGpoRegistryValueDeletion -GpoName $staffGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key $objectSpecificKey -ValueName 'VHDCompactDisk'
if ($resolvedStaffProfileStorageMode -eq 'CloudCache') {
Set-SguGpoRegistryValueDeletion -GpoName $staffGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key $objectSpecificKey -ValueName 'VHDLocations'
}
else {
foreach ($obsoleteValue in $cloudCacheOnlyValues) {
Set-SguGpoRegistryValueDeletion -GpoName $staffGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key $objectSpecificKey -ValueName $obsoleteValue
}
}
foreach ($setting in $fsLogixValues.GetEnumerator()) {
Remove-SguGpoRegistryValue -GpoName $staffGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key $objectSpecificKey -ValueName "**del.$($setting.Key)"
Set-SguGpoRegistryValue -GpoName $staffGpo.DisplayName -DomainName $domainName `
-Server $DomainController -Key $objectSpecificKey -ValueName $setting.Key `
-Type $setting.Value.Type -Value $setting.Value.Value
}
}
[pscustomobject]@{
StorageAccountName = $StorageAccountName
FileEndpoint = $fileEndpointHost
EndpointAccess = $EndpointAccess
StaffProfileStorageMode = $resolvedStaffProfileStorageMode
EndpointAddresses = $endpointAddresses
PrivateEndpointAddresses = $privateAddresses
DirectoryService = $directoryService
KerberosEncryption = 'AES256'
StorageIdentity = $storageComputer.DistinguishedName
StorageIdentityPasswordNeverExpires = $true
ProfilesSharePath = $profilesSharePath
RedirectedFoldersSharePath = $redirectedFoldersSharePath
StudentPolicy = $studentGpo.DisplayName
StaffPolicy = $staffGpo.DisplayName
StudentBehavior = 'Local non-authoritative profile; Documents and Desktop redirected without Offline Files pinning.'
StaffBehavior = if ($resolvedStaffProfileStorageMode -eq 'CloudCache') {
'FSLogix Cloud Cache profile container for SGU-Docentes and SGU-Administrativos only; local I/O is synchronized to Azure Files.'
}
else {
'Direct FSLogix VHDX profile container for SGU-Docentes and SGU-Administrativos only.'
}
ExistingStaffLocalProfilesDeleted = [bool]$DeleteExistingStaffLocalProfiles
}
+21 -4
View File
@@ -3,6 +3,22 @@ param()
$ErrorActionPreference = 'Stop' $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() $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity) $principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { 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. # NETWORK SERVICE. Resolve both principals by SID for localized Windows.
$eventLogReadersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-573') $eventLogReadersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-573')
$networkServiceSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-20') $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 $eventLogReaderMembershipChanged = $false
if ($members.SID.Value -notcontains $networkServiceSid.Value) { if ($members -notcontains $networkServiceSid.Value) {
$networkServiceAccount = $networkServiceSid.Translate([Security.Principal.NTAccount]).Value Add-LocalGroupMember -SID $eventLogReadersSid -Member $networkServiceSid.Value
Add-LocalGroupMember -SID $eventLogReadersSid -Member $networkServiceAccount
$eventLogReaderMembershipChanged = $true $eventLogReaderMembershipChanged = $true
} }
+35 -2
View File
@@ -18,10 +18,12 @@ param(
[PSCredential]$DomainCredential, [PSCredential]$DomainCredential,
[string]$DomainName = 'lci.lasalle.mx', [string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI', [string]$DomainNetbios = 'LCI',
[string]$DomainControllerDnsName,
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx', [string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[string]$NewComputerName, [string]$NewComputerName,
[string]$NetworkInterfaceAlias = 'Ethernet', [string]$NetworkInterfaceAlias = 'Ethernet',
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'), [string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
[switch]$DomainDnsConfigured,
[ValidateSet('Direct', 'AzureP2S')] [ValidateSet('Direct', 'AzureP2S')]
[string]$ConnectivityMode = 'Direct', [string]$ConnectivityMode = 'Direct',
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP', [string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
@@ -56,6 +58,31 @@ $computer = Get-CimInstance Win32_ComputerSystem
if ($computer.PartOfDomain -and $computer.Domain -ne $DomainName) { if ($computer.PartOfDomain -and $computer.Domain -ne $DomainName) {
throw "The computer is already joined to the unexpected domain $($computer.Domain)." 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 = @{ $installParams = @{
PublishPath = $PublishPath PublishPath = $PublishPath
@@ -93,7 +120,10 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
# The broker uses a domain DNS name even before the machine joins the # The broker uses a domain DNS name even before the machine joins the
# domain. Point at AD DNS first so the provider-first health check works on # domain. Point at AD DNS first so the provider-first health check works on
# a completely clean Windows installation. # a completely clean Windows installation.
if ($ConnectivityMode -eq 'Direct') { if ($DomainDnsConfigured) {
# The bootstrap configured domain-scoped NRPT, preserving Internet DNS.
}
elseif ($ConnectivityMode -eq 'Direct') {
Set-DnsClientServerAddress ` Set-DnsClientServerAddress `
-InterfaceAlias $NetworkInterfaceAlias ` -InterfaceAlias $NetworkInterfaceAlias `
-ServerAddresses $DomainDnsServerAddresses -ServerAddresses $DomainDnsServerAddresses
@@ -129,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 ' ')" 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') ` & (Join-Path $PSScriptRoot 'Enable-LabRemoteAccess.ps1') `
-RemoteDesktopPrincipal $RemoteDesktopPrincipal ` -RemoteDesktopPrincipal $RemoteDesktopPrincipal `
-EnableAdministrativeFirewallGroups | Out-Null -EnableAdministrativeFirewallGroups | Out-Null
@@ -144,6 +174,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress $postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
} }
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters $postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
if (-not $postJoin.IsValid) {
throw "SGU validation failed on the joined computer: $($postJoin.Issues -join ' ')"
}
$postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser $postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult $postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
return $postJoin return $postJoin
+83 -3
View File
@@ -9,6 +9,7 @@ param(
[ValidateSet('GuestStatic', 'PlatformManaged')] [ValidateSet('GuestStatic', 'PlatformManaged')]
[string]$NetworkConfigurationMode = 'GuestStatic', [string]$NetworkConfigurationMode = 'GuestStatic',
[string[]]$TrustedClientNetworks = @(), [string[]]$TrustedClientNetworks = @(),
[string[]]$PublicEnrollmentNetworks = @(),
[ipaddress[]]$DnsForwarders = @(), [ipaddress[]]$DnsForwarders = @(),
[string]$DomainName = 'lci.lasalle.mx', [string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI', [string]$DomainNetbios = 'LCI',
@@ -133,6 +134,74 @@ function ConvertTo-PrivateNetworkCidr {
return ConvertTo-NetworkCidr -Address $address -NetworkPrefixLength $networkPrefixLength 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 { function Resolve-PrivateInterfaceAlias {
param([string]$RequestedAlias) param([string]$RequestedAlias)
@@ -141,7 +210,7 @@ function Resolve-PrivateInterfaceAlias {
return $RequestedAlias return $RequestedAlias
} }
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up') $upAdapters = @(Get-ActiveIPv4Adapters)
$withoutGateway = @($upAdapters | Where-Object { $withoutGateway = @($upAdapters | Where-Object {
-not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway -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 } $DefaultGateway = if ($existingState.DefaultGateway) { [ipaddress][string]$existingState.DefaultGateway } else { $null }
$NetworkConfigurationMode = if ($existingState.NetworkConfigurationMode) { [string]$existingState.NetworkConfigurationMode } else { 'GuestStatic' } $NetworkConfigurationMode = if ($existingState.NetworkConfigurationMode) { [string]$existingState.NetworkConfigurationMode } else { 'GuestStatic' }
$TrustedClientNetworks = if ($existingState.TrustedClientNetworks) { @($existingState.TrustedClientNetworks | ForEach-Object { [string]$_ }) } else { @() } $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]$_ }) $DnsForwarders = @($existingState.DnsForwarders | ForEach-Object { [ipaddress][string]$_ })
$DomainName = [string]$existingState.DomainName $DomainName = [string]$existingState.DomainName
$DomainNetbios = [string]$existingState.DomainNetbios $DomainNetbios = [string]$existingState.DomainNetbios
@@ -417,7 +487,10 @@ $TrustedClientNetworks = @($TrustedClientNetworks |
ForEach-Object { ConvertTo-PrivateNetworkCidr -Cidr $_ } | ForEach-Object { ConvertTo-PrivateNetworkCidr -Cidr $_ } |
Where-Object { $_ -ne $domainSubnet } | Where-Object { $_ -ne $domainSubnet } |
Select-Object -Unique) Select-Object -Unique)
$allowedRemoteAddresses = @($domainSubnet) + $TrustedClientNetworks $PublicEnrollmentNetworks = @($PublicEnrollmentNetworks |
ForEach-Object { ConvertTo-PublicNetworkCidr -Cidr $_ } |
Select-Object -Unique)
$allowedRemoteAddresses = @($domainSubnet) + $TrustedClientNetworks + $PublicEnrollmentNetworks
$sourceRoot = $PSScriptRoot $sourceRoot = $PSScriptRoot
if (-not $Resume) { if (-not $Resume) {
@@ -447,6 +520,7 @@ foreach ($requiredPath in @(
(Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1'), (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1'),
(Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'), (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'),
(Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'), (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'),
(Join-Path $scriptsRoot 'Set-SguLaboratorioWallpaperPolicy.ps1'),
(Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'), (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'),
(Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1'), (Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1'),
(Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'), (Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'),
@@ -482,6 +556,7 @@ if (-not $existingState) {
DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null } DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null }
NetworkConfigurationMode = $NetworkConfigurationMode NetworkConfigurationMode = $NetworkConfigurationMode
TrustedClientNetworks = $TrustedClientNetworks TrustedClientNetworks = $TrustedClientNetworks
PublicEnrollmentNetworks = $PublicEnrollmentNetworks
DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString) DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString)
DomainName = $DomainName DomainName = $DomainName
DomainNetbios = $DomainNetbios DomainNetbios = $DomainNetbios
@@ -560,7 +635,7 @@ Write-BootstrapLog 'Finalizing Active Directory, DNS, policies, broker, shares,
# Once the machine is a DC, every active adapter must query the local DNS # 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 # 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. # 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 ` Set-DnsClientServerAddress -InterfaceIndex $_.ifIndex `
-ServerAddresses $ServerIPv4Address.IPAddressToString -ServerAddresses $ServerIPv4Address.IPAddressToString
Set-DnsClient -InterfaceIndex $_.ifIndex ` Set-DnsClient -InterfaceIndex $_.ifIndex `
@@ -708,6 +783,8 @@ foreach ($hostRecord in $hostRecords) {
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') ` & (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
-AllowedRemoteAddress $allowedRemoteAddresses | Out-Null -AllowedRemoteAddress $allowedRemoteAddresses | Out-Null
Set-SguPublicEnrollmentFirewall -LocalAddress $ServerIPv4Address `
-RemoteAddress $PublicEnrollmentNetworks
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages' $contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
if (Test-Path -LiteralPath $contentPath -PathType Container) { if (Test-Path -LiteralPath $contentPath -PathType Container) {
@@ -753,6 +830,8 @@ $userPolicyParameters = @{
ClearManagedWallpaper = $true ClearManagedWallpaper = $true
} }
& (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1') @userPolicyParameters | Out-Null & (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1') @userPolicyParameters | Out-Null
& (Join-Path $scriptsRoot 'Set-SguLaboratorioWallpaperPolicy.ps1') `
-TargetOuDn $laboratoryOuDn -DomainController $env:COMPUTERNAME | Out-Null
$rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') ` $rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') `
-ServerAddress $rustDeskDnsName ` -ServerAddress $rustDeskDnsName `
@@ -796,6 +875,7 @@ $validation = [ordered]@{
ServerIPv4Address = $ServerIPv4Address.IPAddressToString ServerIPv4Address = $ServerIPv4Address.IPAddressToString
NetworkConfigurationMode = $NetworkConfigurationMode NetworkConfigurationMode = $NetworkConfigurationMode
TrustedClientNetworks = $TrustedClientNetworks TrustedClientNetworks = $TrustedClientNetworks
PublicEnrollmentNetworks = $PublicEnrollmentNetworks
AllowedRemoteAddresses = $allowedRemoteAddresses AllowedRemoteAddresses = $allowedRemoteAddresses
BrokerDnsName = $brokerDnsName BrokerDnsName = $brokerDnsName
BrokerCertificateThumbprint = $serverCertificate.Thumbprint BrokerCertificateThumbprint = $serverCertificate.Thumbprint
+8
View File
@@ -314,6 +314,11 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti
-Value 1 ` -Value 1 `
-PropertyType DWord ` -PropertyType DWord `
-Force | Out-Null -Force | Out-Null
New-ItemProperty -Path $interactiveLogonPolicyPath `
-Name HideFastUserSwitching `
-Value 1 `
-PropertyType DWord `
-Force | Out-Null
if (-not (Test-Path -LiteralPath $defaultProviderPolicyPath)) { if (-not (Test-Path -LiteralPath $defaultProviderPolicyPath)) {
New-Item -Path $defaultProviderPolicyPath -Force | Out-Null New-Item -Path $defaultProviderPolicyPath -Force | Out-Null
} }
@@ -344,6 +349,9 @@ catch {
LastSignedInUserHidden = (Get-ItemPropertyValue ` LastSignedInUserHidden = (Get-ItemPropertyValue `
-LiteralPath $interactiveLogonPolicyPath ` -LiteralPath $interactiveLogonPolicyPath `
-Name DontDisplayLastUserName) -eq 1 -Name DontDisplayLastUserName) -eq 1
FastUserSwitchingHidden = (Get-ItemPropertyValue `
-LiteralPath $interactiveLogonPolicyPath `
-Name HideFastUserSwitching) -eq 1
LocalUserEnumerationDisabled = (Get-ItemPropertyValue ` LocalUserEnumerationDisabled = (Get-ItemPropertyValue `
-LiteralPath $defaultProviderPolicyPath ` -LiteralPath $defaultProviderPolicyPath `
-Name EnumerateLocalUsers) -eq 0 -Name EnumerateLocalUsers) -eq 0
+7 -3
View File
@@ -65,7 +65,9 @@ try {
if ($existingConnection) { if ($existingConnection) {
Remove-VpnConnection -Name $ConnectionName -AllUserConnection -Force Remove-VpnConnection -Name $ConnectionName -AllUserConnection -Force
} }
Add-VpnConnection ` $dnsParameters = @{}
if ($DomainName) { $dnsParameters.DnsSuffix = $DomainName }
Add-VpnConnection @dnsParameters `
-Name $ConnectionName ` -Name $ConnectionName `
-ServerAddress $vpnServer ` -ServerAddress $vpnServer `
-TunnelType Ikev2 ` -TunnelType Ikev2 `
@@ -75,13 +77,14 @@ try {
-EncryptionLevel Required ` -EncryptionLevel Required `
-SplitTunneling ` -SplitTunneling `
-AllUserConnection ` -AllUserConnection `
-DnsSuffix $DomainName `
-Force | Out-Null -Force | Out-Null
foreach ($prefix in $AzureNetworkPrefixes) { foreach ($prefix in $AzureNetworkPrefixes) {
Add-VpnConnectionRoute -ConnectionName $ConnectionName ` Add-VpnConnectionRoute -ConnectionName $ConnectionName `
-DestinationPrefix $prefix -AllUserConnection -PassThru | Out-Null -DestinationPrefix $prefix -AllUserConnection -PassThru | Out-Null
} }
# The unified bootstrap can discover the domain after connecting.
if ($DomainName) {
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName" $nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
Get-DnsClientNrptRule -ErrorAction SilentlyContinue | Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
Where-Object DisplayName -eq $nrptDisplayName | Where-Object DisplayName -eq $nrptDisplayName |
@@ -92,11 +95,12 @@ try {
-DisplayName $nrptDisplayName ` -DisplayName $nrptDisplayName `
-Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null -Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null
} }
}
if ($Connect) { if ($Connect) {
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName & "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
if ($LASTEXITCODE -ne 0) { 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."
} }
} }
+67
View File
@@ -0,0 +1,67 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$InstallerPath
)
$ErrorActionPreference = 'Stop'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run the FSLogix installer from an elevated Windows PowerShell session.'
}
$appsRoot = Join-Path $env:ProgramFiles 'FSLogix\Apps'
$frxPath = Join-Path $appsRoot 'frx.exe'
$service = Get-Service -Name frxsvc -ErrorAction SilentlyContinue
if ($service -and (Test-Path -LiteralPath $frxPath -PathType Leaf)) {
$versionOutput = @(& $frxPath version 2>&1) -join [Environment]::NewLine
return [pscustomobject]@{
Installed = $true
Changed = $false
RestartRequired = $false
Service = $service.Status.ToString()
Version = $versionOutput.Trim()
}
}
if (-not (Test-Path -LiteralPath $InstallerPath -PathType Leaf)) {
throw "FSLogixAppsSetup.exe was not found: $InstallerPath"
}
$resolvedInstaller = (Resolve-Path -LiteralPath $InstallerPath).Path
if ([IO.Path]::GetFileName($resolvedInstaller) -ne 'FSLogixAppsSetup.exe') {
throw 'InstallerPath must identify the Microsoft FSLogix core installer named FSLogixAppsSetup.exe.'
}
$signature = Get-AuthenticodeSignature -LiteralPath $resolvedInstaller
if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid -or
-not $signature.SignerCertificate -or
$signature.SignerCertificate.Subject -notmatch '(^|,\s*)CN=Microsoft Corporation(,|$)') {
throw 'FSLogixAppsSetup.exe must have a valid Microsoft Corporation Authenticode signature.'
}
$logRoot = Join-Path $env:ProgramData 'SGU\FSLogix'
$logPath = Join-Path $logRoot 'install.log'
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install Microsoft FSLogix Apps without restarting')) {
return
}
New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
$process = Start-Process -FilePath $resolvedInstaller `
-ArgumentList @('/install', '/quiet', '/norestart', '/log', "`"$logPath`"") `
-Wait -PassThru -WindowStyle Hidden
if ($process.ExitCode -notin @(0, 1641, 3010)) {
throw "FSLogix installation failed with exit code $($process.ExitCode). Review $logPath."
}
$service = Get-Service -Name frxsvc -ErrorAction SilentlyContinue
if (-not $service -or -not (Test-Path -LiteralPath $frxPath -PathType Leaf)) {
throw "FSLogix installation did not create the frxsvc service and frx.exe. Review $logPath."
}
$versionOutput = @(& $frxPath version 2>&1) -join [Environment]::NewLine
[pscustomobject]@{
Installed = $true
Changed = $true
RestartRequired = $true
Service = $service.Status.ToString()
Version = $versionOutput.Trim()
LogPath = $logPath
}
+501 -59
View File
@@ -7,10 +7,12 @@ param(
[ValidateRange(1, 32)] [ValidateRange(1, 32)]
[int]$ClientPrefixLength = 24, [int]$ClientPrefixLength = 24,
[PSCredential]$DomainCredential, [PSCredential]$DomainCredential,
[string]$DomainName = 'lci.lasalle.mx', [string]$DomainName,
[string]$DomainNetbios = 'LCI', [string]$DomainNetbios,
[string]$ComputerOuDn, [string]$ComputerOuDn,
[string]$NewComputerName, [string]$NewComputerName,
[ValidateSet('Auto', 'Windows10Legacy', 'Windows11Modern')]
[string]$CompatibilityProfile = 'Auto',
[ValidateSet('Direct', 'AzureP2S')] [ValidateSet('Direct', 'AzureP2S')]
[string]$ConnectivityMode = 'Direct', [string]$ConnectivityMode = 'Direct',
[string]$VpnConnectionName = 'SGU Azure P2S', [string]$VpnConnectionName = 'SGU Azure P2S',
@@ -19,14 +21,13 @@ param(
[securestring]$VpnClientCertificatePfxPassword, [securestring]$VpnClientCertificatePfxPassword,
[string]$VpnClientRootCertificatePath, [string]$VpnClientRootCertificatePath,
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'), [string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
[string]$FsLogixInstallerPath,
[switch]$PauseOnError, [switch]$PauseOnError,
[switch]$SkipRestart [switch]$SkipRestart
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$brokerRecordName = 'sgu-auth' $brokerRecordName = 'sgu-auth'
$brokerDnsName = "$brokerRecordName.$DomainName"
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
$temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N')) $temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N'))
$bootstrapLogRoot = Join-Path $env:ProgramData 'SGU\Bootstrap\Client' $bootstrapLogRoot = Join-Path $env:ProgramData 'SGU\Bootstrap\Client'
$bootstrapErrorLog = Join-Path $bootstrapLogRoot 'latest-error.log' $bootstrapErrorLog = Join-Path $bootstrapLogRoot 'latest-error.log'
@@ -85,36 +86,73 @@ function Assert-PackageManifest {
throw "Bootstrap package integrity check failed: $($entry.Path)" throw "Bootstrap package integrity check failed: $($entry.Path)"
} }
} }
return $manifest
} }
function Resolve-ClientInterfaceAlias { function Resolve-ClientInterfaceAlias {
param([string]$RequestedAlias) param(
[string]$RequestedAlias,
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress
)
if ($RequestedAlias) { # IP interfaces include tunnel/PPP adapters that Get-NetAdapter can omit.
Get-NetAdapter -Name $RequestedAlias -ErrorAction Stop | Out-Null $interfaces = @(Get-NetIPInterface -AddressFamily IPv4 | Where-Object {
return $RequestedAlias $_.ConnectionState -eq 'Connected' -and
} (-not $RequestedAlias -or $_.InterfaceAlias -eq $RequestedAlias)
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
$withoutDefaultGateway = @($upAdapters | Where-Object {
-not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway
}) })
if ($withoutDefaultGateway.Count -eq 1) { $preferred = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString `
return [string]$withoutDefaultGateway[0].Name -ErrorAction SilentlyContinue | Where-Object { $_.PSObject.Properties['IPAddress'] })
$attempts = @()
$candidates = @(foreach ($interface in $interfaces) {
$addresses = @(Get-NetIPAddress -InterfaceIndex $interface.InterfaceIndex -AddressFamily IPv4 `
-ErrorAction SilentlyContinue | Where-Object {
$_.AddressState -eq 'Preferred' -and -not $_.SkipAsSource -and
$_.IPAddress -notmatch '^(0\.|127\.|169\.254\.)'
})
if ($addresses.Count -eq 0) {
$attempts += "$($interface.InterfaceAlias): no usable IPv4 address (check DHCP/static configuration)"
continue
} }
if ($upAdapters.Count -eq 1) { $route = Get-NetRoute -InterfaceIndex $interface.InterfaceIndex -AddressFamily IPv4 `
return [string]$upAdapters[0].Name -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Where-Object {
$parts = $_.DestinationPrefix -split '/'
Test-IPv4AddressesSharePrefix -FirstAddress $DomainControllerAddress `
-SecondAddress ([ipaddress]$parts[0]) -PrefixLength ([int]$parts[1])
} | Sort-Object @{ Expression = { [int]($_.DestinationPrefix -split '/')[1] }; Descending = $true },
RouteMetric | Select-Object -First 1
if ($route) {
foreach ($address in $addresses) {
[pscustomobject]@{
InterfaceAlias = $interface.InterfaceAlias
InterfaceIndex = [int]$interface.InterfaceIndex
IPAddress = $address.IPAddress
NextHop = $route.NextHop
Preferred = @($preferred | Where-Object IPAddress -eq $address.IPAddress).Count -gt 0
PrefixLength = [int]($route.DestinationPrefix -split '/')[1]
Metric = [int]$route.RouteMetric + [int]$interface.InterfaceMetric
} }
}
$aliases = ($upAdapters.Name | Sort-Object) -join ', ' }
throw "Could not select the private domain adapter unambiguously. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases" else { $attempts += "$($interface.InterfaceAlias): no route to $DomainControllerAddress" }
})
if ($interfaces.Count -eq 0) { $attempts += 'No matching connected IPv4 interface' }
foreach ($candidate in ($candidates | Sort-Object @{ Expression = { $_.Preferred }; Descending = $true },
@{ Expression = { $_.PrefixLength }; Descending = $true }, Metric, InterfaceIndex, IPAddress)) {
Write-Host "Checking $($candidate.InterfaceAlias) ($($candidate.IPAddress)) -> $DomainControllerAddress..."
if (Test-TcpPort -Address $DomainControllerAddress -Port 5985 -TimeoutMilliseconds 2000 `
-SourceAddress ([ipaddress]$candidate.IPAddress) -InterfaceIndex $candidate.InterfaceIndex) {
return $candidate
}
$attempts += "$($candidate.InterfaceAlias) [$($candidate.IPAddress), next hop $($candidate.NextHop)]: TCP 5985 unavailable"
}
throw "Cannot reach SGU server $DomainControllerAddress. $($attempts -join '; '). Check the LAN/VPN connection, DHCP or an administrator-assigned IP, routes and the server WinRM firewall. No client IP was changed."
} }
function Test-IPv4AddressesSharePrefix { function Test-IPv4AddressesSharePrefix {
param( param(
[Parameter(Mandatory)][ipaddress]$FirstAddress, [Parameter(Mandatory)][ipaddress]$FirstAddress,
[Parameter(Mandatory)][ipaddress]$SecondAddress, [Parameter(Mandatory)][ipaddress]$SecondAddress,
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength [Parameter(Mandatory)][ValidateRange(0, 32)][int]$PrefixLength
) )
if ($FirstAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or if ($FirstAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or
@@ -144,6 +182,39 @@ function Test-IPv4AddressesSharePrefix {
return $true return $true
} }
function Test-PrivateIPv4Address {
param([Parameter(Mandatory)][ipaddress]$Address)
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
return $false
}
$bytes = $Address.GetAddressBytes()
return $bytes[0] -eq 10 -or
($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or
($bytes[0] -eq 192 -and $bytes[1] -eq 168)
}
function Wait-ClientInterface {
param(
[string]$RequestedAlias,
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
[int]$TimeoutSeconds = 20
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
do {
try {
return Resolve-ClientInterfaceAlias -RequestedAlias $RequestedAlias `
-DomainControllerAddress $DomainControllerAddress
}
catch {
$lastFailure = $_
if ((Get-Date) -ge $deadline) { throw $lastFailure }
Write-Host 'Waiting for DHCP, VPN routes or server connectivity to become ready...'
Start-Sleep -Seconds 2
}
} while ($true)
}
function Assert-UsableClientIPv4Address { function Assert-UsableClientIPv4Address {
param( param(
[Parameter(Mandatory)][ipaddress]$Address, [Parameter(Mandatory)][ipaddress]$Address,
@@ -178,6 +249,7 @@ function Set-ClientDomainAddress {
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` $matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-ErrorAction SilentlyContinue | -ErrorAction SilentlyContinue |
Where-Object { Where-Object {
$_.AddressState -eq 'Preferred' -and
$_.IPAddress -notmatch '^(127\.|169\.254\.)' -and $_.IPAddress -notmatch '^(127\.|169\.254\.)' -and
(Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) ` (Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) `
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength) -SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)
@@ -188,7 +260,7 @@ function Set-ClientDomainAddress {
return [ipaddress]$matchingAddress.IPAddress return [ipaddress]$matchingAddress.IPAddress
} }
if (-not $RequestedAddress) { if (-not $RequestedAddress) {
$RequestedAddress = [ipaddress](Read-Host "Fixed IPv4 address for this SGU client on '$InterfaceAlias'") throw 'Static addressing requires an explicit -ClientIPv4Address. Automatic enrollment preserves DHCP and existing addresses.'
} }
Assert-UsableClientIPv4Address -Address $RequestedAddress ` Assert-UsableClientIPv4Address -Address $RequestedAddress `
-DomainControllerAddress $DomainControllerAddress -PrefixLength $PrefixLength -DomainControllerAddress $DomainControllerAddress -PrefixLength $PrefixLength
@@ -207,18 +279,42 @@ function Set-ClientDomainAddress {
New-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` New-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-IPAddress $RequestedAddress.IPAddressToString -PrefixLength $PrefixLength | Out-Null -IPAddress $RequestedAddress.IPAddressToString -PrefixLength $PrefixLength | Out-Null
} }
$addressReadyDeadline = (Get-Date).AddSeconds(20)
do {
$configuredAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex `
-AddressFamily IPv4 -IPAddress $RequestedAddress.IPAddressToString `
-ErrorAction SilentlyContinue
if ($configuredAddress -and $configuredAddress.AddressState -eq 'Preferred') {
return $RequestedAddress return $RequestedAddress
} }
Start-Sleep -Milliseconds 500
} while ((Get-Date) -lt $addressReadyDeadline)
$observedState = if ($configuredAddress) { $configuredAddress.AddressState } else { 'Missing' }
throw "The SGU client address '$RequestedAddress' did not become ready on '$InterfaceAlias' within 20 seconds. Observed state: $observedState."
}
function Test-TcpPort { function Test-TcpPort {
param( param(
[Parameter(Mandatory)][ipaddress]$Address, [Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][int]$Port, [Parameter(Mandatory)][int]$Port,
[int]$TimeoutMilliseconds = 5000 [int]$TimeoutMilliseconds = 5000,
[ipaddress]$SourceAddress,
[int]$InterfaceIndex
) )
$client = [Net.Sockets.TcpClient]::new() $client = [Net.Sockets.TcpClient]::new([Net.Sockets.AddressFamily]::InterNetwork)
$connect = $null
try { try {
if ($InterfaceIndex) {
# IP_UNICAST_IF (31) expects the interface index in network byte order.
$client.Client.SetSocketOption([Net.Sockets.SocketOptionLevel]::IP,
[Net.Sockets.SocketOptionName]31, [Net.IPAddress]::HostToNetworkOrder($InterfaceIndex))
}
if ($SourceAddress) {
$client.Client.Bind([Net.IPEndPoint]::new($SourceAddress, 0))
}
$connect = $client.BeginConnect($Address, $Port, $null, $null) $connect = $client.BeginConnect($Address, $Port, $null, $null)
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) { if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) {
return $false return $false
@@ -231,9 +327,170 @@ function Test-TcpPort {
} }
finally { finally {
$client.Dispose() $client.Dispose()
if ($connect) { $connect.AsyncWaitHandle.Close() }
} }
} }
function Set-ClientServerRoute {
param(
[Parameter(Mandatory)]$SelectedInterface,
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress
)
$current = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString |
Where-Object { $_.PSObject.Properties['IPAddress'] })
if (@($current | Where-Object InterfaceIndex -eq $SelectedInterface.InterfaceIndex).Count -gt 0) { return }
# Only pin this server when Windows currently chooses a different interface.
# Do not replace default routes or change interface metrics used by Internet traffic.
$route = New-NetRoute -DestinationPrefix "$DomainControllerAddress/32" `
-InterfaceIndex $SelectedInterface.InterfaceIndex -NextHop $SelectedInterface.NextHop `
-RouteMetric 1
$current = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString |
Where-Object { $_.PSObject.Properties['IPAddress'] })
if (@($current | Where-Object InterfaceIndex -eq $SelectedInterface.InterfaceIndex).Count -eq 0) {
$route | Remove-NetRoute -Confirm:$false
throw "Windows still routes $DomainControllerAddress through another interface. Resolve conflicting host routes or VPN policies and retry."
}
}
function Set-ClientDomainDns {
param(
[Parameter(Mandatory)][string]$DnsDomain,
[Parameter(Mandatory)][ipaddress]$ServerAddress
)
$displayName = "SGU domain DNS - $DnsDomain"
$existing = @(Get-DnsClientNrptRule -ErrorAction Stop | Where-Object DisplayName -eq $displayName)
if ($existing.Count -eq 1 -and @($existing[0].NameServers) -contains $ServerAddress.IPAddressToString -and
@($existing[0].Namespace) -contains ".$DnsDomain" -and @($existing[0].Namespace) -contains $DnsDomain) { return }
$existing | Remove-DnsClientNrptRule -Force
Add-DnsClientNrptRule -Namespace @($DnsDomain, ".$DnsDomain") `
-NameServers $ServerAddress.IPAddressToString -DisplayName $displayName | Out-Null
Clear-DnsClientCache
}
function Test-ClientDomainDns {
param([Parameter(Mandatory)][string]$DnsDomain)
try {
$records = @(Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DnsDomain" `
-DnsOnly -ErrorAction Stop)
return @($records | Where-Object {
$_.Type -eq 'SRV' -and -not [string]::IsNullOrWhiteSpace([string]$_.NameTarget)
}).Count -gt 0
}
catch {
return $false
}
}
function Set-ClientHostMappings {
param(
[Parameter(Mandatory)][ipaddress]$ServerAddress,
[Parameter(Mandatory)][string[]]$HostNames
)
$hostsPath = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts'
$managedNames = @($HostNames |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object { $_.Trim().ToLowerInvariant() } |
Select-Object -Unique)
$preservedLines = foreach ($line in [IO.File]::ReadAllLines($hostsPath)) {
$data = ($line -split '#', 2)[0].Trim()
$tokens = @($data -split '\s+' | Where-Object { $_ })
$lineNames = if ($tokens.Count -gt 1) {
@($tokens[1..($tokens.Count - 1)] | ForEach-Object { $_.ToLowerInvariant() })
}
else { @() }
if (@($lineNames | Where-Object { $managedNames -contains $_ }).Count -eq 0) {
$line
}
}
$mapping = '{0} {1} # SGU managed direct enrollment' -f
$ServerAddress.IPAddressToString,($managedNames -join ' ')
[IO.File]::WriteAllLines($hostsPath, @($preservedLines) + $mapping,
[Text.UTF8Encoding]::new($false))
Clear-DnsClientCache
}
function Enable-ClientDnsOverHttps {
param(
[Parameter(Mandatory)][ipaddress]$ServerAddress,
[Parameter(Mandatory)][string]$DohTemplate,
[Parameter(Mandatory)][string]$CertificateBase64
)
if (-not (Get-Command Add-DnsClientDohServerAddress -ErrorAction SilentlyContinue)) {
throw 'This Windows build cannot configure DNS over HTTPS. Permit traditional DNS to the supplied server or update Windows, then retry.'
}
$certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
[Convert]::FromBase64String($CertificateBase64))
$store = [Security.Cryptography.X509Certificates.X509Store]::new(
[Security.Cryptography.X509Certificates.StoreName]::Root,
[Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine)
try {
$store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
if (-not @($store.Certificates | Where-Object Thumbprint -eq $certificate.Thumbprint).Count) {
$store.Add($certificate)
}
}
finally {
$store.Close()
$certificate.Dispose()
}
$existing = Get-DnsClientDohServerAddress -ErrorAction SilentlyContinue |
Where-Object ServerAddress -eq $ServerAddress.IPAddressToString |
Select-Object -First 1
if ($existing) {
Set-DnsClientDohServerAddress -ServerAddress $ServerAddress.IPAddressToString `
-DohTemplate $DohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true | Out-Null
}
else {
Add-DnsClientDohServerAddress -ServerAddress $ServerAddress.IPAddressToString `
-DohTemplate $DohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true | Out-Null
}
& "$env:SystemRoot\System32\netsh.exe" dnsclient set global doh=yes | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Windows did not enable its global DNS over HTTPS client setting.'
}
Clear-DnsClientCache
}
function Assert-ClientOperatingSystem {
param(
[Parameter(Mandatory)]$OperatingSystem,
[Parameter(Mandatory)][string]$Edition,
[Parameter(Mandatory)][string]$Architecture
)
if ([int]$OperatingSystem.ProductType -ne 1) {
throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.'
}
if ([int]$OperatingSystem.BuildNumber -lt 14393 -or $Architecture -ne 'AMD64') {
throw 'This package requires Windows 10 1607 or later, or Windows 11, running x64 Windows PowerShell.'
}
if ($Edition -match '^Core' -or $Edition -match 'Home') {
throw "Windows edition '$Edition' cannot join an Active Directory domain. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again."
}
}
function Wait-TcpPort {
param(
[Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][int]$Port,
[int]$TimeoutSeconds = 20
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
do {
if (Test-TcpPort -Address $Address -Port $Port -TimeoutMilliseconds 2000) {
return $true
}
Start-Sleep -Milliseconds 750
} while ((Get-Date) -lt $deadline)
return $false
}
function Connect-SguAzureP2s { function Connect-SguAzureP2s {
param([Parameter(Mandatory)][string]$ConnectionName) param([Parameter(Mandatory)][string]$ConnectionName)
@@ -260,25 +517,33 @@ function Connect-SguAzureP2s {
Assert-Administrator Assert-Administrator
$operatingSystem = Get-CimInstance Win32_OperatingSystem $operatingSystem = Get-CimInstance Win32_OperatingSystem
if ([int]$operatingSystem.ProductType -ne 1) {
throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.'
}
$edition = (Get-WindowsEdition -Online).Edition $edition = (Get-WindowsEdition -Online).Edition
if ($edition -match '^Core' -or $edition -match 'Home') { Assert-ClientOperatingSystem -OperatingSystem $operatingSystem -Edition $edition `
throw "Windows edition '$edition' cannot join an on-premises Active Directory domain or host RDP. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again." -Architecture $env:PROCESSOR_ARCHITECTURE
} $windowsBuild = [int]$operatingSystem.BuildNumber
$windowsName = if ($windowsBuild -ge 22000) { 'Windows 11' } else { 'Windows 10' }
Write-Host "$windowsName (build $windowsBuild): unified SGU enrollment."
if (-not $DomainControllerIPv4Address) { if (-not $DomainControllerIPv4Address) {
$DomainControllerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address of the SGU domain controller') $DomainControllerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address of the SGU domain controller')
} }
if (-not $ComputerOuDn) { if ($DomainControllerIPv4Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or
$baseDn = (($DomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ',' $DomainControllerIPv4Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.|24\d\.|25[0-5]\.)') {
$ComputerOuDn = "OU=Laboratorio,$baseDn" throw 'Enter a reachable unicast IPv4 address for the domain controller.'
}
$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 $packageRoot = $PSScriptRoot
Assert-PackageManifest -PackageRoot $packageRoot $packageManifest = Assert-PackageManifest -PackageRoot $packageRoot
# Retain the old parameter for existing automation; neither name restricts networking.
if ($CompatibilityProfile -ne 'Auto') {
Write-Warning 'CompatibilityProfile is deprecated. This package uses the same implementation on Windows 10 and 11.'
}
$CompatibilityProfile = 'Auto'
$scriptsRoot = Join-Path $packageRoot 'payload\scripts' $scriptsRoot = Join-Path $packageRoot 'payload\scripts'
$providerPublishPath = Join-Path $packageRoot 'payload\credential-provider' $providerPublishPath = Join-Path $packageRoot 'payload\credential-provider'
$runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') ` $runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') `
@@ -287,6 +552,7 @@ $runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites
Select-Object -First 1 Select-Object -First 1
foreach ($requiredPath in @( foreach ($requiredPath in @(
(Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1'), (Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1'),
(Join-Path $scriptsRoot 'Install-SguFsLogix.ps1'),
(Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'), (Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'),
(Join-Path $scriptsRoot 'Register-SguClientCertificate.ps1'), (Join-Path $scriptsRoot 'Register-SguClientCertificate.ps1'),
(Join-Path $providerPublishPath 'SGU.CredentialProvider.comhost.dll'))) { (Join-Path $providerPublishPath 'SGU.CredentialProvider.comhost.dll'))) {
@@ -297,6 +563,27 @@ foreach ($requiredPath in @(
if (-not $runtimeInstaller) { if (-not $runtimeInstaller) {
throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.' throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.'
} }
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enroll with SGU server $DomainControllerIPv4Address")) { return }
$fsLogixResult = $null
if ($FsLogixInstallerPath) {
$fsLogixResult = & (Join-Path $scriptsRoot 'Install-SguFsLogix.ps1') `
-InstallerPath $FsLogixInstallerPath
}
elseif (Get-Service -Name frxsvc -ErrorAction SilentlyContinue) {
$fsLogixResult = [pscustomobject]@{
Installed = $true
Changed = $false
RestartRequired = $false
}
}
if ($ClientIPv4Address) {
if (-not $NetworkInterfaceAlias -or $ConnectivityMode -eq 'AzureP2S') {
throw 'Explicit static IP setup requires -NetworkInterfaceAlias with Direct connectivity. Omit -ClientIPv4Address to preserve the current LAN/VPN configuration.'
}
$ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-DomainControllerAddress $DomainControllerIPv4Address `
-RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength
}
if ($ConnectivityMode -eq 'AzureP2S') { if ($ConnectivityMode -eq 'AzureP2S') {
$existingVpnConnection = Get-VpnConnection -Name $VpnConnectionName -AllUserConnection ` $existingVpnConnection = Get-VpnConnection -Name $VpnConnectionName -AllUserConnection `
@@ -329,34 +616,26 @@ if ($ConnectivityMode -eq 'AzureP2S') {
& $installerPath @vpnInstallParameters | Out-Null & $installerPath @vpnInstallParameters | Out-Null
} }
$vpnConnection = Connect-SguAzureP2s -ConnectionName $VpnConnectionName $vpnConnection = Connect-SguAzureP2s -ConnectionName $VpnConnectionName
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
$nrptRule = Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
Where-Object DisplayName -eq $nrptDisplayName |
Select-Object -First 1
if (-not $nrptRule -or
@($nrptRule.NameServers) -notcontains $DomainControllerIPv4Address.IPAddressToString) {
throw "The SGU NRPT rule for $DomainName is missing or does not point to $DomainControllerIPv4Address. Re-run Install-SguAzureP2sClient.ps1."
}
$NetworkInterfaceAlias = $vpnConnection.Name
}
else {
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
$ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-DomainControllerAddress $DomainControllerIPv4Address `
-RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
-ServerAddresses $DomainControllerIPv4Address.IPAddressToString
} }
$selectedInterface = Wait-ClientInterface -RequestedAlias $NetworkInterfaceAlias `
-DomainControllerAddress $DomainControllerIPv4Address
$NetworkInterfaceAlias = $selectedInterface.InterfaceAlias
$ClientIPv4Address = [ipaddress]$selectedInterface.IPAddress
Set-ClientServerRoute -SelectedInterface $selectedInterface -DomainControllerAddress $DomainControllerIPv4Address
Write-Host "Using $NetworkInterfaceAlias ($ClientIPv4Address)."
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) { if (-not (Wait-TcpPort -Address $DomainControllerIPv4Address -Port 5985 -TimeoutSeconds 20)) {
throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP." throw "The domain controller at $DomainControllerIPv4Address did not accept WinRM on TCP 5985 after 20 seconds. Run the server bootstrap first and verify the selected IP."
} }
if (-not $DomainCredential) { if (-not $DomainCredential) {
$suggestedUser = if ($DomainNetbios) { "$DomainNetbios\Administrator" }
elseif ($DomainName) { "Administrator@$DomainName" } else { 'Administrator' }
$DomainCredential = Get-Credential ` $DomainCredential = Get-Credential `
-UserName "$DomainNetbios\Administrator" ` -UserName $suggestedUser `
-Message "Credential permitted to enroll this computer in $DomainName" -Message "Domain account permitted to enroll this computer (DOMAIN\user or user@domain)"
} }
if (-not $DomainCredential) { throw 'Enrollment cancelled: no domain credential was provided.' }
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
$clientCertificatePath = Join-Path $temporaryRoot 'client.cer' $clientCertificatePath = Join-Path $temporaryRoot 'client.cer'
@@ -386,6 +665,12 @@ try {
$serverIdentity = Invoke-Command -Session $session -ScriptBlock { $serverIdentity = Invoke-Command -Session $session -ScriptBlock {
$computer = Get-CimInstance Win32_ComputerSystem $computer = Get-CimInstance Win32_ComputerSystem
if ([int]$computer.DomainRole -lt 4) { throw 'The supplied server is not an Active Directory domain controller.' }
Import-Module ActiveDirectory -ErrorAction Stop
$domain = Get-ADDomain -ErrorAction Stop
$labOu = Get-ADOrganizationalUnit -LDAPFilter '(ou=Laboratorio)' `
-SearchBase $domain.DistinguishedName -SearchScope OneLevel -ErrorAction Stop |
Select-Object -First 1
$brokerService = Get-Service SGUAuthBroker -ErrorAction SilentlyContinue $brokerService = Get-Service SGUAuthBroker -ErrorAction SilentlyContinue
$rustDeskStatusPath = Join-Path $env:ProgramData 'SGU\RustDesk\server.json' $rustDeskStatusPath = Join-Path $env:ProgramData 'SGU\RustDesk\server.json'
$rustDeskStatus = if (Test-Path -LiteralPath $rustDeskStatusPath -PathType Leaf) { $rustDeskStatus = if (Test-Path -LiteralPath $rustDeskStatusPath -PathType Leaf) {
@@ -399,6 +684,8 @@ try {
[pscustomobject]@{ [pscustomobject]@{
ComputerName = $env:COMPUTERNAME ComputerName = $env:COMPUTERNAME
Domain = $computer.Domain Domain = $computer.Domain
DomainNetbios = $domain.NetBIOSName
ComputerContainer = if ($labOu) { $labOu.DistinguishedName } else { $domain.ComputersContainer }
BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' } BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' }
RustDeskServerAddress = if ($rustDeskStatus) { [string]$rustDeskStatus.ServerAddress } else { $null } RustDeskServerAddress = if ($rustDeskStatus) { [string]$rustDeskStatus.ServerAddress } else { $null }
RustDeskPublicKey = if ($rustDeskStatus) { [string]$rustDeskStatus.PublicKey } else { $null } RustDeskPublicKey = if ($rustDeskStatus) { [string]$rustDeskStatus.PublicKey } else { $null }
@@ -406,10 +693,19 @@ try {
RustDeskHbbrTask = if ($hbbrTask) { $hbbrTask.State.ToString() } else { 'Missing' } RustDeskHbbrTask = if ($hbbrTask) { $hbbrTask.State.ToString() } else { 'Missing' }
} }
} }
if (-not $serverIdentity.Domain -or if (-not $serverIdentity.Domain -or ($DomainName -and
-not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase)) { -not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase))) {
throw "The server at $DomainControllerIPv4Address belongs to $($serverIdentity.Domain), not $DomainName." throw "The server at $DomainControllerIPv4Address belongs to $($serverIdentity.Domain), not $DomainName."
} }
if ($DomainNetbios -and $DomainNetbios -ne $serverIdentity.DomainNetbios) {
throw "The supplied NetBIOS domain '$DomainNetbios' does not match '$($serverIdentity.DomainNetbios)'."
}
$DomainName = [string]$serverIdentity.Domain
$DomainNetbios = [string]$serverIdentity.DomainNetbios
if (-not $ComputerOuDn) { $ComputerOuDn = [string]$serverIdentity.ComputerContainer }
$brokerDnsName = "$brokerRecordName.$DomainName"
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
Write-Host "Discovered domain: $DomainName ($DomainNetbios). Computer container: $ComputerOuDn"
if ($serverIdentity.BrokerService -ne 'Running') { if ($serverIdentity.BrokerService -ne 'Running') {
throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)." throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)."
} }
@@ -420,6 +716,146 @@ try {
throw "The RustDesk server is not ready on $($serverIdentity.ComputerName). Run the current server bootstrap first." throw "The RustDesk server is not ready on $($serverIdentity.ComputerName). Run the current server bootstrap first."
} }
$targetComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
Invoke-Command -Session $session -ScriptBlock {
param($ComputerName, $ComputerPath)
Import-Module ActiveDirectory -ErrorAction Stop
$samAccountName = "$ComputerName`$"
$account = Get-ADComputer -Filter "SamAccountName -eq '$samAccountName'" |
Select-Object -First 1
if (-not $account) {
New-ADComputer -Name $ComputerName -SamAccountName $samAccountName `
-Path $ComputerPath -Enabled $true -ErrorAction Stop
}
} -ArgumentList $targetComputerName,$ComputerOuDn
if ($publicDirectEnrollment) {
$directDns = Invoke-Command -Session $session -ScriptBlock {
param($DnsDomain, $DomainControllerComputerName)
$domainControllerFqdn = "$DomainControllerComputerName.$DnsDomain".ToLowerInvariant()
$dohTemplate = "https://${domainControllerFqdn}:443/dns-query"
$dohCommand = Get-Command Set-DnsServerEncryptionProtocol -ErrorAction SilentlyContinue
if (-not $dohCommand) {
return [pscustomobject]@{
DohSupported = $false
DomainControllerFqdn = $domainControllerFqdn
}
}
$certificate = Get-ChildItem Cert:\LocalMachine\My |
Where-Object {
$_.Subject -eq "CN=$domainControllerFqdn" -and
$_.HasPrivateKey -and
$_.NotAfter -gt (Get-Date).AddDays(30)
} |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if (-not $certificate) {
$certificate = New-SelfSignedCertificate `
-DnsName $domainControllerFqdn `
-CertStoreLocation Cert:\LocalMachine\My `
-FriendlyName 'SGU Direct Enrollment DoH' `
-Type SSLServerAuthentication `
-KeyAlgorithm RSA `
-KeyLength 2048 `
-HashAlgorithm SHA256 `
-KeyExportPolicy NonExportable `
-NotAfter (Get-Date).AddYears(2)
}
$bindingOutput = @(& "$env:SystemRoot\System32\netsh.exe" http show sslcert ipport=0.0.0.0:443 2>&1)
$bindingExists = $LASTEXITCODE -eq 0
$normalizedBinding = (($bindingOutput -join '') -replace '[^0-9A-Fa-f]', '').ToUpperInvariant()
$normalizedThumbprint = ($certificate.Thumbprint -replace ' ', '').ToUpperInvariant()
if ($bindingExists -and -not $normalizedBinding.Contains($normalizedThumbprint)) {
throw 'TCP 443 already has an HTTPS certificate binding that is not managed by SGU. Free that port or configure SGU DoH before enrolling this client.'
}
if (-not $bindingExists) {
& "$env:SystemRoot\System32\netsh.exe" http add sslcert `
ipport=0.0.0.0:443 "certhash=$($certificate.Thumbprint)" `
"appid={47E9CF26-79B7-4C9D-A0AE-ADFA22447A41}" certstorename=MY | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Could not bind the SGU DoH certificate to TCP 443.' }
}
$dnsChanged = $false
$encryption = Get-DnsServerEncryptionProtocol
if (-not $encryption.EnableDoh -or $encryption.UriTemplate -ne $dohTemplate) {
Set-DnsServerEncryptionProtocol -EnableDoh $true -UriTemplate $dohTemplate
$dnsChanged = $true
}
Import-Module ActiveDirectory -ErrorAction Stop
$domainController = Get-ADComputer -Identity $DomainControllerComputerName `
-Properties ServicePrincipalName
if (@($domainController.ServicePrincipalName) -notcontains "cifs/$DnsDomain") {
& "$env:SystemRoot\System32\setspn.exe" -S "cifs/$DnsDomain" $DomainControllerComputerName | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not register cifs/$DnsDomain on $DomainControllerComputerName." }
}
$lanmanPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters'
$optionalNames = @((Get-ItemProperty $lanmanPath -Name OptionalNames `
-ErrorAction SilentlyContinue).OptionalNames | Where-Object { $_ })
$serverChanged = $false
if ($optionalNames -notcontains $DnsDomain) {
New-ItemProperty -Path $lanmanPath -Name OptionalNames -PropertyType MultiString `
-Value (@($optionalNames) + $DnsDomain) -Force | Out-Null
$serverChanged = $true
}
New-ItemProperty -Path $lanmanPath -Name DisableStrictNameChecking `
-PropertyType DWord -Value 1 -Force | Out-Null
if ($serverChanged) {
Restart-Service LanmanServer -Force
Start-Service Netlogon
}
if ($dnsChanged) {
Restart-Service DNS -Force
Start-Sleep -Seconds 2
}
[pscustomobject]@{
DohSupported = $true
DohTemplate = $dohTemplate
DohCertificateBase64 = [Convert]::ToBase64String($certificate.RawData)
DomainControllerFqdn = $domainControllerFqdn
}
} -ArgumentList $DomainName,$serverIdentity.ComputerName
$directHostNames = @(
$directDns.DomainControllerFqdn,
$DomainName,
$brokerDnsName
)
if ([string]$serverIdentity.RustDeskServerAddress -match '[A-Za-z]') {
$directHostNames += [string]$serverIdentity.RustDeskServerAddress
}
Set-ClientHostMappings -ServerAddress $DomainControllerIPv4Address `
-HostNames $directHostNames
if ($directDns.DohSupported -and
(Get-Command Add-DnsClientDohServerAddress -ErrorAction SilentlyContinue)) {
Enable-ClientDnsOverHttps -ServerAddress $DomainControllerIPv4Address `
-DohTemplate $directDns.DohTemplate `
-CertificateBase64 $directDns.DohCertificateBase64
}
elseif (-not $directDns.DohSupported) {
Write-Warning 'The server does not support DNS over HTTPS; enrollment will use traditional DNS.'
}
else {
Write-Warning 'This Windows build does not support DNS over HTTPS; enrollment will use traditional DNS.'
}
}
Set-ClientDomainDns -DnsDomain $DomainName -ServerAddress $DomainControllerIPv4Address
if (-not (Test-ClientDomainDns -DnsDomain $DomainName)) {
throw "The domain DNS service at $DomainControllerIPv4Address did not return an Active Directory SRV record. For a public server, permit DNS over HTTPS on TCP 443 or traditional DNS from this client network."
}
foreach ($port in @(53, 88, 135, 389, 445, 8443)) {
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port $port -TimeoutMilliseconds 2000)) {
throw "Server $DomainControllerIPv4Address is reachable, but required TCP port $port is unavailable. Check AD/SGU services and the LAN/VPN firewall. Domain join has not started."
}
}
$certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME" $certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME"
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My | $clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
Where-Object { Where-Object {
@@ -498,9 +934,11 @@ try {
DomainCredential = $DomainCredential DomainCredential = $DomainCredential
DomainName = $DomainName DomainName = $DomainName
DomainNetbios = $DomainNetbios DomainNetbios = $DomainNetbios
DomainControllerDnsName = "$($serverIdentity.ComputerName).$DomainName"
ComputerOuDn = $ComputerOuDn ComputerOuDn = $ComputerOuDn
NetworkInterfaceAlias = $NetworkInterfaceAlias NetworkInterfaceAlias = $NetworkInterfaceAlias
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString) DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
DomainDnsConfigured = $true
ConnectivityMode = $ConnectivityMode ConnectivityMode = $ConnectivityMode
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP" RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
@@ -562,7 +1000,8 @@ finally {
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force
} }
if (-not $winRmWasRunning) { if (-not $winRmWasRunning) {
Stop-Service WinRM -Force -ErrorAction SilentlyContinue Stop-Service WinRM -Force -NoWait -WarningAction SilentlyContinue `
-ErrorAction SilentlyContinue
} }
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
$DomainCredential = $null $DomainCredential = $null
@@ -577,7 +1016,10 @@ if ($SkipRestart) {
ClientCertificateRegistered = $true ClientCertificateRegistered = $true
BrokerEndpoint = $brokerEndpoint BrokerEndpoint = $brokerEndpoint
ConnectivityMode = $ConnectivityMode ConnectivityMode = $ConnectivityMode
CompatibilityProfile = $CompatibilityProfile
VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null } VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null }
FsLogixInstalled = [bool]($fsLogixResult -and $fsLogixResult.Installed)
FsLogixChanged = [bool]($fsLogixResult -and $fsLogixResult.Changed)
RestartRequired = $true RestartRequired = $true
RustDesk = if ($result) { $result.RustDesk } else { $null } RustDesk = if ($result) { $result.RustDesk } else { $null }
EnrollmentResult = $result EnrollmentResult = $result
+44 -12
View File
@@ -33,7 +33,10 @@ function Write-PackageManifest {
param( param(
[Parameter(Mandatory)][string]$PackageRoot, [Parameter(Mandatory)][string]$PackageRoot,
[Parameter(Mandatory)][string]$PackageVersion, [Parameter(Mandatory)][string]$PackageVersion,
[Parameter(Mandatory)][string]$PackageKind [Parameter(Mandatory)][string]$PackageKind,
[ValidateSet('Auto')]
[string]$CompatibilityProfile,
[string]$TargetOperatingSystem
) )
$resolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path.TrimEnd('\') $resolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path.TrimEnd('\')
@@ -48,13 +51,19 @@ function Write-PackageManifest {
} }
}) })
$manifest = [ordered]@{ $manifest = [ordered]@{
SchemaVersion = 1 SchemaVersion = 2
Product = 'SGU Credential Provider' Product = 'SGU Credential Provider'
PackageKind = $PackageKind PackageKind = $PackageKind
Version = $PackageVersion Version = $PackageVersion
CreatedAt = (Get-Date).ToUniversalTime().ToString('o') CreatedAt = (Get-Date).ToUniversalTime().ToString('o')
Files = $files Files = $files
} }
if ($CompatibilityProfile) {
$manifest['CompatibilityProfile'] = $CompatibilityProfile
}
if ($TargetOperatingSystem) {
$manifest['TargetOperatingSystem'] = $TargetOperatingSystem
}
[IO.File]::WriteAllText( [IO.File]::WriteAllText(
(Join-Path $resolvedPackageRoot 'package-manifest.json'), (Join-Path $resolvedPackageRoot 'package-manifest.json'),
($manifest | ConvertTo-Json -Depth 6), ($manifest | ConvertTo-Json -Depth 6),
@@ -78,7 +87,7 @@ if (-not $runtimeInstaller) {
} }
New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null
$clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version" $clientRoot = Join-Path $resolvedOutputRoot "sgu-windows-client-bootstrap-$Version"
$serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version" $serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version"
$linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version" $linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version"
$azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version" $azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version"
@@ -86,13 +95,17 @@ $clientZip = "$clientRoot.zip"
$serverZip = "$serverRoot.zip" $serverZip = "$serverRoot.zip"
$linuxClientZip = "$linuxClientRoot.zip" $linuxClientZip = "$linuxClientRoot.zip"
$azureZip = "$azureRoot.zip" $azureZip = "$azureRoot.zip"
foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$azureRoot,$clientZip,$serverZip,$linuxClientZip,$azureZip)) { foreach ($target in @(
$clientRoot,$serverRoot,$linuxClientRoot,$azureRoot,
$clientZip,$serverZip,$linuxClientZip,$azureZip)) {
if (Test-Path -LiteralPath $target) { if (Test-Path -LiteralPath $target) {
throw "Release target already exists: $target" throw "Release target already exists: $target"
} }
} }
New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot -Force | Out-Null New-Item -ItemType Directory `
-Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot `
-Force | Out-Null
$welcomeFontNames = @( $welcomeFontNames = @(
'IndivisaTextSans-Regular.otf', 'IndivisaTextSans-Regular.otf',
'IndivisaTextSans-Bold.otf', 'IndivisaTextSans-Bold.otf',
@@ -105,15 +118,12 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Invoke-SguClientBootstrap.ps
-Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1') -Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') ` Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') `
-Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd') -Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') `
-Destination (Join-Path $clientRoot 'Start-SguAzureClientEnrollment.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') `
-Destination (Join-Path $clientRoot 'Install-SguAzureP2sClient.ps1')
$clientScripts = @( $clientScripts = @(
'Enable-LabRemoteAccess.ps1', 'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1', 'Enable-SguClientMonitoring.ps1',
'Enroll-SguDomainClient.ps1', 'Enroll-SguDomainClient.ps1',
'Install-CredentialProvider.ps1', 'Install-CredentialProvider.ps1',
'Install-SguFsLogix.ps1',
'Install-SguEnrollmentGuard.ps1', 'Install-SguEnrollmentGuard.ps1',
'Install-SguRustDeskClient.ps1', 'Install-SguRustDeskClient.ps1',
'Register-SguClientCertificate.ps1', 'Register-SguClientCertificate.ps1',
@@ -141,7 +151,19 @@ foreach ($fontName in $welcomeFontNames) {
} }
Copy-RequiredFile -Source $runtimeInstaller.FullName ` Copy-RequiredFile -Source $runtimeInstaller.FullName `
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)") -Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
# One Windows implementation supports existing LAN/VPN routes and optional Azure P2S.
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') `
-Destination (Join-Path $clientRoot 'Start-SguAzureClientEnrollment.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') `
-Destination (Join-Path $clientRoot 'Install-SguAzureP2sClient.ps1')
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\client-enrollment.md') `
-Destination (Join-Path $clientRoot 'README.md')
Copy-Item -Path (Join-Path $repositoryRoot 'docs\*.md') -Destination $clientRoot
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version `
-PackageKind WindowsClient -CompatibilityProfile Auto `
-TargetOperatingSystem 'Windows 10 1607+ or Windows 11; x64 Pro, Enterprise, or Education'
Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip ` Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip `
-CompressionLevel Optimal -CompressionLevel Optimal
@@ -174,10 +196,13 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureServerBootstra
-Destination (Join-Path $serverRoot 'Start-SguAzureServerBootstrap.cmd') -Destination (Join-Path $serverRoot 'Start-SguAzureServerBootstrap.cmd')
$serverScripts = @( $serverScripts = @(
'Deploy-AuthBroker.ps1', 'Deploy-AuthBroker.ps1',
'Disable-SguAzureUserRoaming.ps1',
'Enable-SguAzureUserRoaming.ps1',
'Enable-SguServerRemoteManagement.ps1', 'Enable-SguServerRemoteManagement.ps1',
'Get-SguUsageReport.ps1', 'Get-SguUsageReport.ps1',
'Get-SguBrokerLog.ps1', 'Get-SguBrokerLog.ps1',
'Install-SguDomainMonitoring.ps1', 'Install-SguDomainMonitoring.ps1',
'Publish-SguFsLogixClientDeployment.ps1',
'Install-SguRustDeskClient.ps1', 'Install-SguRustDeskClient.ps1',
'Install-SguRustDeskLinuxEnrollment.ps1', 'Install-SguRustDeskLinuxEnrollment.ps1',
'Install-SguRustDeskServer.ps1', 'Install-SguRustDeskServer.ps1',
@@ -189,6 +214,7 @@ $serverScripts = @(
'Register-SguRustDeskDevice.ps1', 'Register-SguRustDeskDevice.ps1',
'Set-LabBrokerDns.ps1', 'Set-LabBrokerDns.ps1',
'Set-SguDomainComputerPolicies.ps1', 'Set-SguDomainComputerPolicies.ps1',
'Set-SguLaboratorioWallpaperPolicy.ps1',
'Set-SguDomainUserPolicies.ps1' 'Set-SguDomainUserPolicies.ps1'
) )
foreach ($scriptName in $serverScripts) { foreach ($scriptName in $serverScripts) {
@@ -216,6 +242,10 @@ foreach ($fontName in $welcomeFontNames) {
Copy-RequiredFile -Source (Join-Path $repositoryRoot "assets\branding\fonts\$fontName") ` Copy-RequiredFile -Source (Join-Path $repositoryRoot "assets\branding\fonts\$fontName") `
-Destination (Join-Path $serverContentTarget "welcome-wallpaper\fonts\$fontName") -Destination (Join-Path $serverContentTarget "welcome-wallpaper\fonts\$fontName")
} }
foreach ($documentation in @('user-roaming.md', 'laboratorio-wallpaper-policy.md')) {
Copy-RequiredFile -Source (Join-Path $repositoryRoot "docs\$documentation") `
-Destination (Join-Path $serverRoot "docs\$documentation")
}
Write-PackageManifest -PackageRoot $serverRoot -PackageVersion $Version -PackageKind Server Write-PackageManifest -PackageRoot $serverRoot -PackageVersion $Version -PackageKind Server
Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip ` Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip `
-CompressionLevel Optimal -CompressionLevel Optimal
@@ -237,6 +267,8 @@ foreach ($scriptName in @(
} }
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\azure-vpn-deployment.md') ` Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\azure-vpn-deployment.md') `
-Destination (Join-Path $azureRoot 'README.md') -Destination (Join-Path $azureRoot 'README.md')
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\user-roaming.md') `
-Destination (Join-Path $azureRoot 'user-roaming.md')
Write-PackageManifest -PackageRoot $azureRoot -PackageVersion $Version -PackageKind AzureInfrastructure Write-PackageManifest -PackageRoot $azureRoot -PackageVersion $Version -PackageKind AzureInfrastructure
Compress-Archive -Path (Join-Path $azureRoot '*') -DestinationPath $azureZip ` Compress-Archive -Path (Join-Path $azureRoot '*') -DestinationPath $azureZip `
-CompressionLevel Optimal -CompressionLevel Optimal
@@ -252,8 +284,8 @@ $checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
[pscustomobject]@{ [pscustomobject]@{
Version = $Version Version = $Version
ClientPackage = $clientZip WindowsClientPackage = $clientZip
ClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash WindowsClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash
LinuxClientPackage = $linuxClientZip LinuxClientPackage = $linuxClientZip
LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash
ServerPackage = $serverZip ServerPackage = $serverZip
+18 -6
View File
@@ -9,13 +9,17 @@ param(
[string]$Owner = 'alexrg', [string]$Owner = 'alexrg',
[string]$Repository = 'SGU-CredentialProvider', [string]$Repository = 'SGU-CredentialProvider',
[string]$TargetCommitish = 'main', [string]$TargetCommitish = 'main',
[string]$ReleaseNotesPath,
[switch]$Draft [switch]$Draft
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if ($ReleaseNotesPath -and -not (Test-Path -LiteralPath $ReleaseNotesPath -PathType Leaf)) {
throw "Release notes file is missing: $ReleaseNotesPath"
}
$tagName = "v$Version" $tagName = "v$Version"
$assetPaths = @( $assetPaths = @(
(Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-windows-client-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-azure-infrastructure-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-azure-infrastructure-$Version.zip"),
@@ -58,6 +62,7 @@ if (-not $authorizationParameter) {
Add-Type -AssemblyName System.Net.Http Add-Type -AssemblyName System.Net.Http
$handler = [Net.Http.HttpClientHandler]::new() $handler = [Net.Http.HttpClientHandler]::new()
$client = [Net.Http.HttpClient]::new($handler) $client = [Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromMinutes(10)
$client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/') $client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/')
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new( $client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new(
$authorizationScheme, $authorizationScheme,
@@ -111,21 +116,28 @@ Bootstrap reproducible para el laboratorio SGU.
- **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema. - **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema.
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio. - `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque. - `sgu-windows-client-bootstrap-$Version.zip`: paquete único para Windows 10 1607+ y Windows 11 x64 Pro, Enterprise o Education, con LAN, VPN existente y Azure P2S opcional.
- En clientes Hyper-V con dos NIC, el bootstrap selecciona la red privada sin puerta de enlace, solicita o acepta la IP fija del cliente y conserva en pantalla y archivo cualquier error de enrolamiento. - Doble clic en `Start-SguClientEnrollment.cmd`, IP del servidor y credenciales: descubre el dominio autenticado, comprueba las interfaces y rutas disponibles y conserva DHCP, las IP del cliente y el DNS de Internet. Ya no solicita una IP del cliente.
- Si la IP del DC es pública, configura automáticamente DoH autenticado, confianza del certificado, NRPT y nombres del bosque antes de unir el equipo; funciona con cualquier interfaz que pueda alcanzar el servidor.
- Conserva seguridad mTLS, Credential Provider, cuenta estándar, RustDesk, supervisión y autorreparación. El servidor puede ser accesible por LAN, una VPN ya conectada o un CIDR público autorizado.
- `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows. - `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows.
- `sgu-azure-infrastructure-$Version.zip`: despliega mediante Bicep una VM Windows Server 2025, red privada, IP pública protegida por NSG y Azure VPN Gateway P2S; también genera certificados por equipo y descarga el perfil de cliente. - `sgu-azure-infrastructure-$Version.zip`: despliega mediante Bicep una VM Windows Server 2025, red privada e IP pública protegida por NSG; Azure VPN Gateway P2S es opcional.
- El bootstrap Azure conserva la IP privada administrada por la NIC de Azure, autoriza el pool P2S en los firewalls SGU y nunca publica LDAP, Kerberos, SMB, RPC, WinRM ni el Auth Broker directamente a Internet. - El 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.
- Los Windows 11 Pro pueden instalar un perfil IKEv2 de todos los usuarios con certificado de máquina, DNS dividido para `lci.lasalle.mx` y ejecutarlo desde la pantalla de inicio de sesión antes de autenticar una cuenta de dominio nueva. - Windows 10 y 11 pueden instalar el perfil IKEv2 de todos los usuarios con certificado de máquina y DNS dividido del dominio; la disponibilidad antes del inicio de sesión depende del perfil y de las políticas del equipo.
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea cada grupo dentro de la OU de su rol y migra idempotentemente cualquier grupo heredado sin cambiar su SID. - El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea cada grupo dentro de la OU de su rol y migra idempotentemente cualquier grupo heredado sin cambiar su SID.
- El Auth Broker resuelve la dirección guardada de administrativos y docentes mediante `GetDireccion`, `GetLocalidadListado` y `GetColoniasListado`, evitando conservar los valores transitorios `Seleccione...` de los controles dinámicos de SGU. - El Auth Broker resuelve la dirección guardada de administrativos y docentes mediante `GetDireccion`, `GetLocalidadListado` y `GetColoniasListado`, evitando conservar los valores transitorios `Seleccione...` de los controles dinámicos de SGU.
- El enrolamiento y la reparación de clientes Windows crean y verifican idempotentemente la cuenta local estándar `alumno`, sin pertenencia al grupo de administradores. - El enrolamiento y la reparación de clientes Windows crean y verifican idempotentemente la cuenta local estándar `alumno`, sin pertenencia al grupo de administradores.
- La descripción de la cuenta local administrada respeta el límite de 48 caracteres de Windows 10 Enterprise.
- La validación de expiración de contraseña usa el indicador de cuenta compatible con Windows 10 y 11, en lugar de una propiedad que Windows 10 no expone.
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral. - El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker. - El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host. - Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`. Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`.
"@ "@
if ($ReleaseNotesPath) {
$releaseNotes = Get-Content -LiteralPath $ReleaseNotesPath -Raw -Encoding UTF8
}
$releaseBody = [ordered]@{ $releaseBody = [ordered]@{
tag_name = $tagName tag_name = $tagName
target_commitish = $TargetCommitish target_commitish = $TargetCommitish
@@ -0,0 +1,140 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$GpoName = 'SGU - FSLogix client deployment',
[string]$LaboratoryOuDn,
[uri]$InstallerZipUri = 'https://download.microsoft.com/download/ae6d2014-e692-45fa-a88b-ee552567cdc1/FSLogix_26.08.zip',
[string]$ExpectedInstallerSha256 = '0BAF7FE8195571060F0361B351E58FE6CD422AE521A96BAB3E66D900544E8A30',
[string]$CacheRoot = "$env:ProgramData\SGU\FSLogixDeployment\26.08"
)
$ErrorActionPreference = 'Stop'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run the FSLogix GPO deployment from an elevated Windows PowerShell session.'
}
Import-Module ActiveDirectory -ErrorAction Stop
Import-Module GroupPolicy -ErrorAction Stop
$domain = Get-ADDomain
$domainName = $domain.DNSRoot
$domainDn = $domain.DistinguishedName
if (-not $LaboratoryOuDn) { $LaboratoryOuDn = "OU=Laboratorio,$domainDn" }
Get-ADOrganizationalUnit -Identity $LaboratoryOuDn -ErrorAction Stop | Out-Null
$zipPath = Join-Path $CacheRoot 'FSLogix_26.08.zip'
$extractRoot = Join-Path $CacheRoot 'expanded'
$installerPath = Join-Path $extractRoot 'x64\Release\FSLogixAppsSetup.exe'
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) {
if (-not $PSCmdlet.ShouldProcess($InstallerZipUri.AbsoluteUri, "Download official FSLogix package to $zipPath")) { return }
New-Item -ItemType Directory -Path $CacheRoot,$extractRoot -Force | Out-Null
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $InstallerZipUri -OutFile $zipPath -UseBasicParsing
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractRoot -Force
}
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) {
throw "The FSLogix x64 installer is missing after extraction: $installerPath"
}
$signature = Get-AuthenticodeSignature -LiteralPath $installerPath
if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid -or
-not $signature.SignerCertificate -or
$signature.SignerCertificate.Subject -notmatch '(^|,\s*)CN=Microsoft Corporation(,|$)') {
throw 'FSLogixAppsSetup.exe does not have a valid Microsoft Corporation signature.'
}
$installerHash = (Get-FileHash -LiteralPath $installerPath -Algorithm SHA256).Hash
if ($installerHash -ne $ExpectedInstallerSha256) {
throw "FSLogixAppsSetup.exe SHA-256 mismatch. Expected $ExpectedInstallerSha256; received $installerHash."
}
$gpo = Get-GPO -Name $GpoName -Domain $domainName -ErrorAction SilentlyContinue
if (-not $gpo) { $gpo = New-GPO -Name $GpoName -Domain $domainName }
$links = @(Get-GPInheritance -Target $LaboratoryOuDn -Domain $domainName).GpoLinks
if (-not ($links | Where-Object DisplayName -eq $GpoName)) {
New-GPLink -Name $GpoName -Target $LaboratoryOuDn -Domain $domainName -LinkEnabled Yes | Out-Null
}
# Set one ordinary machine value through the supported cmdlet. Besides providing
# a deployment marker, this initializes the computer half of the GPO and its
# Registry client-side extension before the Scripts extension is added below.
Set-GPRegistryValue -Name $GpoName -Domain $domainName `
-Key 'HKLM\SOFTWARE\SGU\FSLogixDeployment' -ValueName 'Version' `
-Type String -Value '26.08-3.26.826.17182' | Out-Null
$gpo = Get-GPO -Name $GpoName -Domain $domainName
$guid = $gpo.Id.ToString('B').ToUpperInvariant()
$gpoRoot = "\\$domainName\SYSVOL\$domainName\Policies\$guid"
$scriptsRoot = Join-Path $gpoRoot 'Machine\Scripts'
$startupRoot = Join-Path $scriptsRoot 'Startup'
New-Item -ItemType Directory -Path $startupRoot -Force | Out-Null
Copy-Item -LiteralPath $installerPath -Destination (Join-Path $startupRoot 'FSLogixAppsSetup.exe') -Force
$startupPowerShell = @'
$ErrorActionPreference = 'Stop'
$appsRoot = Join-Path $env:ProgramFiles 'FSLogix\Apps'
$frx = Join-Path $appsRoot 'frx.exe'
if ((Get-Service frxsvc -ErrorAction SilentlyContinue) -and (Test-Path -LiteralPath $frx)) { exit 0 }
$source = Join-Path $PSScriptRoot 'FSLogixAppsSetup.exe'
$signature = Get-AuthenticodeSignature -LiteralPath $source
if ($signature.Status -ne 'Valid' -or -not $signature.SignerCertificate -or
$signature.SignerCertificate.Subject -notmatch '(^|,\s*)CN=Microsoft Corporation(,|$)') { exit 10 }
$targetRoot = Join-Path $env:ProgramData 'SGU\FSLogix'
New-Item -ItemType Directory -Path $targetRoot -Force | Out-Null
$target = Join-Path $targetRoot 'FSLogixAppsSetup.exe'
Copy-Item -LiteralPath $source -Destination $target -Force
$log = Join-Path $targetRoot 'install.log'
$process = Start-Process -FilePath $target -ArgumentList @('/install','/quiet','/norestart','/log',"`"$log`"") -Wait -PassThru
if ($process.ExitCode -notin @(0,1641,3010)) { exit $process.ExitCode }
if (-not (Get-Service frxsvc -ErrorAction SilentlyContinue) -or -not (Test-Path -LiteralPath $frx)) { exit 11 }
exit 0
'@
[IO.File]::WriteAllText(
(Join-Path $startupRoot 'Install-SguFsLogix-Startup.ps1'),
$startupPowerShell,
[Text.UTF8Encoding]::new($true))
$startupCommand = '@echo off' + [Environment]::NewLine +
'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0Install-SguFsLogix-Startup.ps1"' + [Environment]::NewLine +
'exit /b %ERRORLEVEL%' + [Environment]::NewLine
[IO.File]::WriteAllText(
(Join-Path $startupRoot 'Install-SguFsLogix.cmd'),
$startupCommand,
[Text.Encoding]::ASCII)
$scriptsIni = "[Startup]`r`n0CmdLine=Install-SguFsLogix.cmd`r`n0Parameters=`r`n"
[IO.File]::WriteAllText(
(Join-Path $scriptsRoot 'scripts.ini'),
$scriptsIni,
[Text.Encoding]::Unicode)
$policyDn = "CN=$guid,CN=Policies,CN=System,$domainDn"
$policy = Get-ADObject -Identity $policyDn -Properties versionNumber,gPCMachineExtensionNames
$scriptExtension = '[{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B6664F-4972-11D1-A7CA-0000F87571E3}]'
$extensions = @([regex]::Matches([string]$policy.gPCMachineExtensionNames, '\[\{[0-9A-Fa-f-]{36}\}\{[0-9A-Fa-f-]{36}\}\]') |
ForEach-Object Value)
if ($extensions -notcontains $scriptExtension) { $extensions += $scriptExtension }
$extensionValue = ($extensions | Sort-Object { $_.Substring(1, 38) }) -join ''
$currentVersion = [int64]$policy.versionNumber
$machineVersion = $currentVersion -band 0xFFFF
if ($machineVersion -ge 65535) { throw 'The computer GPO version cannot be incremented further.' }
$newVersion = ($currentVersion -band 0xFFFF0000) -bor ($machineVersion + 1)
$gptIniPath = Join-Path $gpoRoot 'gpt.ini'
$gptLines = @(Get-Content -LiteralPath $gptIniPath)
$versionFound = $false
$gptLines = @($gptLines | ForEach-Object {
if ($_ -match '^Version=') { $versionFound = $true; "Version=$newVersion" } else { $_ }
})
if (-not $versionFound) { $gptLines += "Version=$newVersion" }
[IO.File]::WriteAllLines($gptIniPath, $gptLines, [Text.Encoding]::ASCII)
Set-ADObject -Identity $policy -Replace @{
gPCMachineExtensionNames = $extensionValue
versionNumber = [int]$newVersion
}
$verifiedGpo = Get-GPO -Name $GpoName -Domain $domainName
[pscustomobject]@{
GpoName = $verifiedGpo.DisplayName
GpoId = $verifiedGpo.Id
LaboratoryOuDn = $LaboratoryOuDn
MachineVersion = $verifiedGpo.Computer.DSVersion
InstallerVersion = '3.26.826.17182'
InstallerSha256 = $installerHash
StartupScript = Join-Path $startupRoot 'Install-SguFsLogix.cmd'
}
@@ -0,0 +1,99 @@
#Requires -Version 5.1
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[string]$ConnectionName = 'SGU Azure Device',
[ValidateRange(30,600)][int]$WaitSeconds = 180
)
$ErrorActionPreference = 'Stop'
$computer = Get-CimInstance Win32_ComputerSystem
if (-not $computer.PartOfDomain) { throw 'The device must already be joined to its domain.' }
$logPath = Join-Path $env:ProgramData 'SGU\Enrollment\azure-domain-connectivity.json'
$deadline = (Get-Date).AddSeconds($WaitSeconds)
$restarted = $false
$controller = $null
try {
do {
$vpn = Get-VpnConnection -Name $ConnectionName -AllUserConnection -ErrorAction SilentlyContinue
$reachable = $false
if ($vpn -and $vpn.ConnectionStatus -eq 'Connected') {
$record = Resolve-DnsName "_ldap._tcp.dc._msdcs.$($computer.Domain)" -Type SRV -ErrorAction SilentlyContinue |
Where-Object Type -eq 'SRV' | Select-Object -First 1
if ($record) {
$controller = $record.NameTarget.TrimEnd('.')
$socket = [Net.Sockets.TcpClient]::new()
try {
$connect = $socket.BeginConnect($controller, 389, $null, $null)
if ($connect.AsyncWaitHandle.WaitOne(2000)) {
$socket.EndConnect($connect)
$reachable = $socket.Connected
}
} catch { $reachable = $false }
finally { $socket.Dispose() }
}
}
if ($reachable) { break }
Start-Sleep -Seconds 5
} while ((Get-Date) -lt $deadline)
if (-not $reachable) { throw "The VPN and a domain controller were not reachable within $WaitSeconds seconds." }
# An early Netlogon attempt can remain failed after the device VPN connects.
# Refresh only that service, after confirming the domain is reachable.
if (-not (Test-ComputerSecureChannel -Server $controller)) {
Restart-Service -Name Netlogon
$restarted = $true
}
$secure = $false
for ($attempt = 0; $attempt -lt 6; $attempt++) {
$secure = Test-ComputerSecureChannel -Server $controller
if ($secure) { break }
Start-Sleep -Seconds 5
}
if (-not $secure) { throw 'The domain is reachable but the secure channel is still invalid. Administrative repair is required.' }
$guard = Get-ScheduledTask -TaskName 'SGU-CredentialProvider-EnrollmentGuard' -ErrorAction SilentlyContinue
$guardResult = $null
if ($guard) {
# Domain principal lookup can recover after the secure channel itself.
# Await the guard and retry a transient failure instead of reporting
# success while its asynchronous repair is still running or failed.
$guardDeadline = (Get-Date).AddMinutes(3)
do {
$guard = Get-ScheduledTask -TaskName $guard.TaskName
if ($guard.State -notin @('Running','Queued')) {
$previousRun = (Get-ScheduledTaskInfo -TaskName $guard.TaskName).LastRunTime
Start-ScheduledTask -InputObject $guard
do {
Start-Sleep -Seconds 2
$guard = Get-ScheduledTask -TaskName $guard.TaskName
$info = Get-ScheduledTaskInfo -TaskName $guard.TaskName
} while (($info.LastRunTime -le $previousRun -or $guard.State -in @('Running','Queued')) -and (Get-Date) -lt $guardDeadline)
if ($info.LastRunTime -gt $previousRun -and $guard.State -notin @('Running','Queued')) {
$guardResult = $info.LastTaskResult
if ($guardResult -eq 0) { break }
}
}
Start-Sleep -Seconds 10
} while ((Get-Date) -lt $guardDeadline)
if ($guardResult -ne 0) { throw "The secure channel recovered, but the enrollment guard did not succeed (result $guardResult)." }
}
[pscustomobject]@{
CheckedAt = (Get-Date).ToString('o')
ComputerName = $computer.Name
Domain = $computer.Domain
DomainController = $controller
ConnectionName = $ConnectionName
NetlogonRestarted = $restarted
SecureChannel = $secure
EnrollmentGuardResult = $guardResult
} | ConvertTo-Json | Set-Content -LiteralPath $logPath
} catch {
[pscustomobject]@{
CheckedAt = (Get-Date).ToString('o')
ConnectionName = $ConnectionName
NetlogonRestarted = $restarted
SecureChannel = $false
Error = $_.Exception.Message
} | ConvertTo-Json | Set-Content -LiteralPath $logPath
throw
}
+10 -1
View File
@@ -32,7 +32,16 @@ if (-not $before.IsValid) {
} }
$computer = Get-CimInstance Win32_ComputerSystem $computer = Get-CimInstance Win32_ComputerSystem
$domainReady = $false
if ($computer.PartOfDomain) { if ($computer.PartOfDomain) {
try {
$domainReady = [bool](Test-ComputerSecureChannel -ErrorAction Stop)
}
catch {
$domainReady = $false
}
}
if ($domainReady) {
& $remoteAccessScript ` & $remoteAccessScript `
-RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) ` -RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) `
-EnableAdministrativeFirewallGroups | Out-Null -EnableAdministrativeFirewallGroups | Out-Null
@@ -45,7 +54,7 @@ if ($configuration.RustDeskServerAddress -and $configuration.RustDeskServerPubli
} }
$verificationParams = @{} $verificationParams = @{}
if ($computer.PartOfDomain) { if ($domainReady) {
$verificationParams.RequireDomainJoined = $true $verificationParams.RequireDomainJoined = $true
$verificationParams.RequireRemoteAccess = $true $verificationParams.RequireRemoteAccess = $true
$verificationParams.RemoteDesktopPrincipal = [string]$configuration.RemoteDesktopPrincipal $verificationParams.RemoteDesktopPrincipal = [string]$configuration.RemoteDesktopPrincipal
+13 -1
View File
@@ -79,6 +79,8 @@ $eventForwardingPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\E
$auditPolicyKey = 'HKLM\System\CurrentControlSet\Control\Lsa' $auditPolicyKey = 'HKLM\System\CurrentControlSet\Control\Lsa'
$runPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Run' $runPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Run'
$personalizationPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\Personalization' $personalizationPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\Personalization'
$personalizationCspKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\PersonalizationCSP'
$cloudContentPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\CloudContent'
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}' $providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$welcomeWallpaperCommand = 'powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -BaseImagePath "{1}"' -f ` $welcomeWallpaperCommand = 'powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -BaseImagePath "{1}"' -f `
$WelcomeWallpaperScriptPath,$WelcomeWallpaperBasePath $WelcomeWallpaperScriptPath,$WelcomeWallpaperBasePath
@@ -97,6 +99,7 @@ $policies = @(
@{ Key = $credentialProviderPolicyKey; Name = 'DefaultCredentialProvider'; Type = 'String'; Value = $providerClassId }, @{ Key = $credentialProviderPolicyKey; Name = 'DefaultCredentialProvider'; Type = 'String'; Value = $providerClassId },
@{ Key = $credentialProviderPolicyKey; Name = 'EnumerateLocalUsers'; Type = 'DWord'; Value = 0 }, @{ Key = $credentialProviderPolicyKey; Name = 'EnumerateLocalUsers'; Type = 'DWord'; Value = 0 },
@{ Key = $interactiveLogonPolicyKey; Name = 'DontDisplayLastUserName'; Type = 'DWord'; Value = 1 }, @{ Key = $interactiveLogonPolicyKey; Name = 'DontDisplayLastUserName'; Type = 'DWord'; Value = 1 },
@{ Key = $interactiveLogonPolicyKey; Name = 'HideFastUserSwitching'; Type = 'DWord'; Value = 1 },
# Use Windows' native default account image for named user tiles. LogonUI # Use Windows' native default account image for named user tiles. LogonUI
# retains ownership of the anonymous Other user tile and its circular mask. # retains ownership of the anonymous Other user tile and its circular mask.
@@ -111,7 +114,16 @@ $policies = @(
# local payload lets the first desktop render without depending on SMB. # local payload lets the first desktop render without depending on SMB.
@{ Key = $runPolicyKey; Name = 'SGUWelcomeWallpaper'; Type = 'String'; Value = $welcomeWallpaperCommand }, @{ Key = $runPolicyKey; Name = 'SGUWelcomeWallpaper'; Type = 'String'; Value = $welcomeWallpaperCommand },
@{ Key = $personalizationPolicyKey; Name = 'LockScreenImage'; Type = 'String'; Value = $WelcomeWallpaperBasePath }, @{ Key = $personalizationPolicyKey; Name = 'LockScreenImage'; Type = 'String'; Value = $WelcomeWallpaperBasePath },
@{ Key = $personalizationPolicyKey; Name = 'NoChangingLockScreen'; Type = 'DWord'; Value = 1 } @{ Key = $personalizationPolicyKey; Name = 'NoChangingLockScreen'; Type = 'DWord'; Value = 1 },
# Windows 11 Pro can ignore the legacy lock-screen policy even though it is
# present in the registry. PersonalizationCSP provides the same local image
# to Pro while remaining harmless on Enterprise and LTSC editions.
@{ Key = $personalizationCspKey; Name = 'LockScreenImagePath'; Type = 'String'; Value = $WelcomeWallpaperBasePath },
@{ Key = $personalizationCspKey; Name = 'LockScreenImageUrl'; Type = 'String'; Value = $WelcomeWallpaperBasePath },
@{ Key = $personalizationCspKey; Name = 'LockScreenImageStatus'; Type = 'DWord'; Value = 1 },
@{ Key = $cloudContentPolicyKey; Name = 'DisableWindowsSpotlightFeatures'; Type = 'DWord'; Value = 1 },
@{ Key = $cloudContentPolicyKey; Name = 'DisableWindowsSpotlightOnLockScreen'; Type = 'DWord'; Value = 1 }
) )
$powerSettingIds = @( $powerSettingIds = @(
@@ -0,0 +1,75 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[string]$GpoName = 'SGU - Laboratorio wallpaper protection',
[string]$DomainController = $env:COMPUTERNAME,
[string]$WallpaperPath = '%LOCALAPPDATA%\SGU\Wallpapers\welcome-%COMPUTERNAME%.jpg'
)
$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory -ErrorAction Stop
Import-Module GroupPolicy -ErrorAction Stop
$domainName = (Get-ADDomain -Server $DomainController).DNSRoot
Get-ADOrganizationalUnit -Identity $TargetOuDn -Server $DomainController -ErrorAction Stop | Out-Null
if (-not $PSCmdlet.ShouldProcess($TargetOuDn, "Apply '$GpoName' with enforced loopback Merge")) {
return
}
$backupPath = $null
$gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue
if ($gpo) {
$backupPath = Join-Path $env:ProgramData ('SGU\PolicyBackups\Wallpaper-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $backupPath -Force | Out-Null
Backup-GPO -Guid $gpo.Id -Path $backupPath -Domain $domainName -Server $DomainController | Out-Null
}
else {
$gpo = New-GPO -Name $GpoName -Domain $domainName -Server $DomainController `
-Comment 'Protects the SGU desktop wallpaper on Laboratorio computers and child OUs; loopback Merge preserves existing user policies.'
}
# These are user policies scoped by the computer OU, not by the user OU.
# Match the per-user/per-computer output of Set-SguWelcomeWallpaper.ps1.
$settings = @(
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\System'; Name = 'UserPolicyMode'; Type = 'DWord'; Value = 1 },
@{ Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\ActiveDesktop'; Name = 'NoChangingWallPaper'; Type = 'DWord'; Value = 1 },
@{ Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System'; Name = 'Wallpaper'; Type = 'ExpandString'; Value = $WallpaperPath },
@{ Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System'; Name = 'WallpaperStyle'; Type = 'String'; Value = '10' }
)
foreach ($setting in $settings) {
Set-GPRegistryValue -Guid $gpo.Id -Domain $domainName -Server $DomainController `
-Key $setting.Key -ValueName $setting.Name -Type $setting.Type -Value $setting.Value | Out-Null
}
$linkParameters = @{
Guid = $gpo.Id
Target = $TargetOuDn
Domain = $domainName
Server = $DomainController
LinkEnabled = 'Yes'
Enforced = 'Yes'
Order = 1
}
$existingLink = (Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks |
Where-Object GpoId -eq $gpo.Id
if ($existingLink) {
Set-GPLink @linkParameters | Out-Null
}
else {
New-GPLink @linkParameters | Out-Null
}
foreach ($setting in $settings) {
$actual = Get-GPRegistryValue -Guid $gpo.Id -Domain $domainName -Server $DomainController `
-Key $setting.Key -ValueName $setting.Name
if ([string]$actual.Value -ne [string]$setting.Value -or [string]$actual.Type -ne $setting.Type) {
throw "Wallpaper policy verification failed for $($setting.Name)."
}
}
[pscustomobject]@{
Name = $gpo.DisplayName
Id = $gpo.Id
TargetOuDn = $TargetOuDn
WallpaperPath = $WallpaperPath
Loopback = 'Merge'
Enforced = $true
BackupPath = $backupPath
}
+62 -13
View File
@@ -5,7 +5,38 @@ param()
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$userName = 'alumno' $userName = 'alumno'
$plainTextPassword = 'ingenieria' $plainTextPassword = 'ingenieria'
$description = 'Cuenta local estandar de recuperacion para equipos SGU' $description = 'Cuenta local estandar SGU para recuperacion'
$passwordNeverExpiresFlag = 0x10000
function Get-LocalUserFlags {
param([Parameter(Mandatory)][string]$Name)
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,user")
return [int]$directoryEntry.InvokeGet('UserFlags')
}
function Get-LocalGroupMemberSid {
param([Parameter(Mandatory)][string]$Name)
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,group")
foreach ($member in @($group.psbase.Invoke('Members'))) {
try {
$sidBytes = $member.GetType().InvokeMember(
'objectSid',
[Reflection.BindingFlags]::GetProperty,
$null,
$member,
$null)
if ($sidBytes) {
([Security.Principal.SecurityIdentifier]::new([byte[]]$sidBytes, 0)).Value
}
}
catch {
# An orphaned domain SID can no longer resolve after a forest is
# rebuilt. Other members must remain inspectable and unchanged.
}
}
}
$identity = [Security.Principal.WindowsIdentity]::GetCurrent() $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity) $principal = [Security.Principal.WindowsPrincipal]::new($identity)
@@ -25,8 +56,9 @@ try {
} }
if ($user) { if ($user) {
# Preserve existing credentials on enrollment/repair. Resetting the same
# password after domain join can violate password history/complexity.
Set-LocalUser -Name $userName ` Set-LocalUser -Name $userName `
-Password $securePassword `
-PasswordNeverExpires $true ` -PasswordNeverExpires $true `
-Description $description -Description $description
if (-not $user.Enabled) { if (-not $user.Enabled) {
@@ -40,19 +72,31 @@ try {
-Description $description | Out-Null -Description $description | Out-Null
} }
# Windows 10's Get-LocalUser object has PasswordExpires but does not expose
# PasswordNeverExpires. Enforce and verify the underlying UF_DONT_EXPIRE_PASSWD
# flag so the result is consistent across Windows 10 and Windows 11.
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$userName,user")
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
if (($userFlags -band $passwordNeverExpiresFlag) -eq 0) {
$directoryEntry.InvokeSet('UserFlags', ($userFlags -bor $passwordNeverExpiresFlag))
$directoryEntry.CommitChanges()
}
$user = Get-LocalUser -Name $userName -ErrorAction Stop $user = Get-LocalUser -Name $userName -ErrorAction Stop
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') $usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop $administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop $usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop) $administratorMembers = @(Get-LocalGroupMemberSid -Name $administratorsGroup.Name)
if ($administratorMembers.SID.Value -contains $user.SID.Value) { if ($administratorMembers -contains $user.SID.Value) {
Remove-LocalGroupMember -Group $administratorsGroup -Member $user -Confirm:$false ([ADSI]("WinNT://$env:COMPUTERNAME/$($administratorsGroup.Name),group")).Remove(
"WinNT://$env:COMPUTERNAME/$userName,user")
} }
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop) $standardMembers = @(Get-LocalGroupMemberSid -Name $usersGroup.Name)
if ($standardMembers.SID.Value -notcontains $user.SID.Value) { if ($standardMembers -notcontains $user.SID.Value) {
Add-LocalGroupMember -Group $usersGroup -Member $user ([ADSI]("WinNT://$env:COMPUTERNAME/$($usersGroup.Name),group")).Add(
"WinNT://$env:COMPUTERNAME/$userName,user")
} }
} }
finally { finally {
@@ -66,19 +110,24 @@ $verifiedAdministratorsGroup = Get-LocalGroup `
$verifiedUsersGroup = Get-LocalGroup ` $verifiedUsersGroup = Get-LocalGroup `
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')) ` -SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')) `
-ErrorAction Stop -ErrorAction Stop
$verifiedAdministrators = @(Get-LocalGroupMember -Group $verifiedAdministratorsGroup -ErrorAction Stop) $verifiedAdministrators = @(Get-LocalGroupMemberSid -Name $verifiedAdministratorsGroup.Name)
$verifiedUsers = @(Get-LocalGroupMember -Group $verifiedUsersGroup -ErrorAction Stop) $verifiedUsers = @(Get-LocalGroupMemberSid -Name $verifiedUsersGroup.Name)
if (@($verifiedAdministrators).SID.Value -contains $verifiedUser.SID.Value) { if ($verifiedAdministrators -contains $verifiedUser.SID.Value) {
throw "The local account '$userName' still belongs to the local Administrators group." 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." throw "The local account '$userName' does not belong to the local Users group."
} }
$verifiedPasswordNeverExpires =
((Get-LocalUserFlags -Name $userName) -band $passwordNeverExpiresFlag) -ne 0
if (-not $verifiedPasswordNeverExpires) {
throw "The local account '$userName' password is not configured to never expire."
}
[pscustomobject]@{ [pscustomobject]@{
UserName = $verifiedUser.Name UserName = $verifiedUser.Name
Enabled = $verifiedUser.Enabled Enabled = $verifiedUser.Enabled
IsAdministrator = $false IsAdministrator = $false
IsStandardUser = $true IsStandardUser = $true
PasswordNeverExpires = $verifiedUser.PasswordNeverExpires PasswordNeverExpires = $verifiedPasswordNeverExpires
} }
+35 -9
View File
@@ -170,10 +170,10 @@ function Get-DirectoryWelcomeMetadata {
function Get-SpanishArticle { function Get-SpanishArticle {
param([Parameter(Mandatory)][string]$Value) param([Parameter(Mandatory)][string]$Value)
if ($Value -match '^(Sala|Aula|Facultad|Unidad|Biblioteca|Oficina|Coordinaci.n)\b') { if ($Value -match '^(Sala|Facultad|Unidad|Biblioteca|Oficina|Coordinaci.n)\b') {
return 'la' return 'la'
} }
if ($Value -match '^(Laboratorio|Centro|Edificio|Campus|Taller|Auditorio)\b') { if ($Value -match '^(Aula|Laboratorio|Centro|Edificio|Campus|Taller|Auditorio)\b') {
return 'el' return 'el'
} }
return $null return $null
@@ -202,9 +202,17 @@ function Get-WelcomeLocationText {
if ($Room -and $OuName) { if ($Room -and $OuName) {
$roomArticle = Get-SpanishArticle -Value $Room $roomArticle = Get-SpanishArticle -Value $Room
$ouArticle = Get-SpanishArticle -Value $OuName $ouArticle = Get-SpanishArticle -Value $OuName
$roomPhrase = if ($roomArticle) { "$roomArticle $Room" } else { $Room } $roomPhrase = if ($roomArticle -eq 'el') {
"al $Room"
}
elseif ($roomArticle) {
"a $roomArticle $Room"
}
else {
"a $Room"
}
$ouPhrase = if ($ouArticle -eq 'el') { "del $OuName" } elseif ($ouArticle) { "de $ouArticle $OuName" } else { "de $OuName" } $ouPhrase = if ($ouArticle -eq 'el') { "del $OuName" } elseif ($ouArticle) { "de $ouArticle $OuName" } else { "de $OuName" }
return "$located $roomPhrase $ouPhrase." return "Acceso $roomPhrase $ouPhrase."
} }
if ($Room) { if ($Room) {
$article = Get-SpanishArticle -Value $Room $article = Get-SpanishArticle -Value $Room
@@ -297,6 +305,14 @@ if (-not (Test-Path -LiteralPath $BaseImagePath -PathType Leaf)) {
$userName = [Environment]::UserName $userName = [Environment]::UserName
$metadata = $null $metadata = $null
$machineLocation = [Environment]::GetEnvironmentVariable('SGU_WELCOME_LOCATION', 'Machine')
$machineOrganizationalUnit = [Environment]::GetEnvironmentVariable(
'SGU_WELCOME_ORGANIZATIONAL_UNIT',
'Machine')
if (-not $machineLocation) { $machineLocation = $env:SGU_WELCOME_LOCATION }
if (-not $machineOrganizationalUnit) {
$machineOrganizationalUnit = $env:SGU_WELCOME_ORGANIZATIONAL_UNIT
}
if (-not $SkipDirectoryLookup) { if (-not $SkipDirectoryLookup) {
try { try {
$metadata = Get-DirectoryWelcomeMetadata -UserName $userName -MachineName $ComputerName $metadata = Get-DirectoryWelcomeMetadata -UserName $userName -MachineName $ComputerName
@@ -312,16 +328,26 @@ if (-not $PSBoundParameters.ContainsKey('DisplayName')) {
if (-not $DisplayName) { if (-not $DisplayName) {
$DisplayName = $userName $DisplayName = $userName
} }
if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) { if (-not $PSBoundParameters.ContainsKey('Location')) {
$Location = $metadata.Location $Location = if ($machineLocation) { $machineLocation } elseif ($metadata) { $metadata.Location } else { $null }
} }
$genderWasProvided = $PSBoundParameters.ContainsKey('Gender') $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 $Gender = $metadata.Gender
} }
$welcomeHeading = Get-WelcomeHeading -Gender $Gender $welcomeHeading = Get-WelcomeHeading -Gender $Gender
if (-not $PSBoundParameters.ContainsKey('OrganizationalUnit') -and $metadata) { if (-not $PSBoundParameters.ContainsKey('OrganizationalUnit')) {
$OrganizationalUnit = $metadata.OrganizationalUnit $OrganizationalUnit = if ($machineOrganizationalUnit) {
$machineOrganizationalUnit
}
elseif ($metadata) {
$metadata.OrganizationalUnit
}
else {
$null
}
} }
$locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit -Gender $Gender $locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit -Gender $Gender
+10 -2
View File
@@ -4,5 +4,13 @@ set "SGU_BOOTSTRAP_IP=%~1"
set "SGU_VPN_PACKAGE=%~2" set "SGU_VPN_PACKAGE=%~2"
set "SGU_VPN_PFX=%~3" set "SGU_VPN_PFX=%~3"
set "SGU_VPN_ROOT=%~4" set "SGU_VPN_ROOT=%~4"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-ConnectivityMode','AzureP2S'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',('"' + $env:SGU_BOOTSTRAP_IP + '"')) }; if ($env:SGU_VPN_PACKAGE) { $arguments += @('-VpnProfilePackagePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PACKAGE) + '"')) }; if ($env:SGU_VPN_PFX) { $arguments += @('-VpnClientCertificatePfxPath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PFX) + '"')) }; if ($env:SGU_VPN_ROOT) { $arguments += @('-VpnClientRootCertificatePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_ROOT) + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode" set "SGU_FSLOGIX_INSTALLER=%~5"
exit /b %errorlevel% powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-PauseOnError','-ConnectivityMode','AzureP2S'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',('"' + $env:SGU_BOOTSTRAP_IP + '"')) }; if ($env:SGU_VPN_PACKAGE) { $arguments += @('-VpnProfilePackagePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PACKAGE) + '"')) }; if ($env:SGU_VPN_PFX) { $arguments += @('-VpnClientCertificatePfxPath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PFX) + '"')) }; if ($env:SGU_VPN_ROOT) { $arguments += @('-VpnClientRootCertificatePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_ROOT) + '"')) }; if ($env:SGU_FSLOGIX_INSTALLER) { $arguments += @('-FsLogixInstallerPath',('"' + [IO.Path]::GetFullPath($env:SGU_FSLOGIX_INSTALLER) + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
set "SGU_EXIT_CODE=%errorlevel%"
if not "%SGU_EXIT_CODE%"=="0" (
echo.
echo SGU Windows Azure enrollment did not complete. Review:
echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log
pause
)
exit /b %SGU_EXIT_CODE%
+62 -9
View File
@@ -20,6 +20,29 @@ $interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\P
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json' $settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
$issues = [Collections.Generic.List[string]]::new() $issues = [Collections.Generic.List[string]]::new()
$standardLocalUserName = 'alumno' $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 $computer = Get-CimInstance Win32_ComputerSystem
if ($RequireDomainJoined -and -not $computer.PartOfDomain) { if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
@@ -69,6 +92,20 @@ if (-not $lastSignedInUserHidden) {
$issues.Add('The last signed-in user is not hidden from LogonUI.') $issues.Add('The last signed-in user is not hidden from LogonUI.')
} }
$fastUserSwitchingHidden = $false
try {
$fastUserSwitchingHidden = (Get-ItemPropertyValue `
-LiteralPath $interactiveLogonPolicyPath `
-Name HideFastUserSwitching `
-ErrorAction Stop) -eq 1
}
catch {
# Report the missing or unreadable policy as a failed enrollment check.
}
if (-not $fastUserSwitchingHidden) {
$issues.Add('Fast User Switching entry points are not hidden.')
}
$localUserEnumerationDisabled = $false $localUserEnumerationDisabled = $false
try { try {
$localUserEnumerationDisabled = (Get-ItemPropertyValue ` $localUserEnumerationDisabled = (Get-ItemPropertyValue `
@@ -93,19 +130,28 @@ $standardLocalUserPresent = [bool]$standardLocalUser
$standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled $standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled
$standardLocalUserIsAdministrator = $false $standardLocalUserIsAdministrator = $false
$standardLocalUserInUsersGroup = $false $standardLocalUserInUsersGroup = $false
$standardLocalUserPasswordNeverExpires = $standardLocalUserPasswordNeverExpires = $false
$standardLocalUserPresent -and $standardLocalUser.PasswordNeverExpires
if ($standardLocalUserPresent) { if ($standardLocalUserPresent) {
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') $usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop $administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop $usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop) $administratorMembers = @(Get-LocalGroupMemberSid -Name $administratorsGroup.Name)
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop) $standardMembers = @(Get-LocalGroupMemberSid -Name $usersGroup.Name)
$standardLocalUserIsAdministrator = $standardLocalUserIsAdministrator =
$administratorMembers.SID.Value -contains $standardLocalUser.SID.Value $administratorMembers -contains $standardLocalUser.SID.Value
$standardLocalUserInUsersGroup = $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')
$standardLocalUserPasswordNeverExpires =
($userFlags -band $passwordNeverExpiresFlag) -ne 0
}
catch {
# Report the account as invalid when Windows cannot read its flags.
$standardLocalUserPasswordNeverExpires = $false
}
} }
if (-not $standardLocalUserPresent) { if (-not $standardLocalUserPresent) {
$issues.Add("The required standard local user '$standardLocalUserName' is missing.") $issues.Add("The required standard local user '$standardLocalUserName' is missing.")
@@ -120,7 +166,7 @@ elseif (-not $standardLocalUserInUsersGroup) {
$issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.") $issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.")
} }
elseif (-not $standardLocalUserPasswordNeverExpires) { elseif (-not $standardLocalUserPasswordNeverExpires) {
$issues.Add("The required standard local user '$standardLocalUserName' does not retain its enrollment password.") $issues.Add("The required standard local user '$standardLocalUserName' password is not configured to never expire.")
} }
$settings = $null $settings = $null
@@ -185,12 +231,18 @@ $remoteAccessReady = $null
if ($RequireRemoteAccess) { if ($RequireRemoteAccess) {
$remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-555') $remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-555')
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1] $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 = $remoteAccessReady =
(Get-Service TermService).Status -eq 'Running' -and (Get-Service TermService).Status -eq 'Running' -and
(Get-Service WinRM).Status -eq 'Running' -and (Get-Service WinRM).Status -eq 'Running' -and
(Get-ItemPropertyValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections) -eq 0 -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) { if (-not $remoteAccessReady) {
$issues.Add('RDP/WinRM or the authorized domain group is not fully configured.') $issues.Add('RDP/WinRM or the authorized domain group is not fully configured.')
} }
@@ -241,6 +293,7 @@ $result = [pscustomobject]@{
ProviderBinaryPresent = [bool]$providerBinaryPresent ProviderBinaryPresent = [bool]$providerBinaryPresent
DefaultProviderConfigured = $defaultProviderConfigured DefaultProviderConfigured = $defaultProviderConfigured
LastSignedInUserHidden = $lastSignedInUserHidden LastSignedInUserHidden = $lastSignedInUserHidden
FastUserSwitchingHidden = $fastUserSwitchingHidden
LocalUserEnumerationDisabled = $localUserEnumerationDisabled LocalUserEnumerationDisabled = $localUserEnumerationDisabled
PasswordProviderPreserved = $passwordProviderPreserved PasswordProviderPreserved = $passwordProviderPreserved
StandardLocalUserPresent = $standardLocalUserPresent StandardLocalUserPresent = $standardLocalUserPresent
+310 -6
View File
@@ -2,6 +2,8 @@ $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$serverBootstrapPath = Join-Path $repositoryRoot 'scripts\Initialize-SguDomainController.ps1' $serverBootstrapPath = Join-Path $repositoryRoot 'scripts\Initialize-SguDomainController.ps1'
$clientBootstrapPath = Join-Path $repositoryRoot 'scripts\Invoke-SguClientBootstrap.ps1' $clientBootstrapPath = Join-Path $repositoryRoot 'scripts\Invoke-SguClientBootstrap.ps1'
$azureClientPath = Join-Path $repositoryRoot 'scripts\Install-SguAzureP2sClient.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' $bicepPath = Join-Path $repositoryRoot 'infra\azure\main.bicep'
$tokens = $null $tokens = $null
@@ -16,7 +18,10 @@ if ($parseErrors.Count -gt 0) {
$networkFunctionNames = @( $networkFunctionNames = @(
'Test-PrivateIPv4Address', 'Test-PrivateIPv4Address',
'ConvertTo-NetworkCidr', 'ConvertTo-NetworkCidr',
'ConvertTo-PrivateNetworkCidr' 'ConvertTo-PrivateNetworkCidr',
'ConvertTo-PublicNetworkCidr',
'Get-ActiveIPv4Adapters',
'Resolve-PrivateInterfaceAlias'
) )
$networkFunctions = $serverAst.FindAll({ $networkFunctions = $serverAst.FindAll({
param($node) param($node)
@@ -37,7 +42,9 @@ if ($clientParseErrors.Count -gt 0) {
$clientNetworkFunctions = $clientAst.FindAll({ $clientNetworkFunctions = $clientAst.FindAll({
param($node) param($node)
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node -is [Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq 'Test-IPv4AddressesSharePrefix' $node.Name -in @('Test-IPv4AddressesSharePrefix', 'Resolve-ClientInterfaceAlias',
'Set-ClientServerRoute', 'Set-ClientDomainDns', 'Test-TcpPort', 'Assert-ClientOperatingSystem',
'Wait-ClientInterface', 'Test-PrivateIPv4Address', 'Test-ClientDomainDns')
}, $true) }, $true)
Invoke-Expression (($clientNetworkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine) Invoke-Expression (($clientNetworkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine)
@@ -63,15 +70,35 @@ Describe 'SGU public-cloud network safety' {
$wasRejected | Should Be $true $wasRejected | Should Be $true
} }
It 'canonicalizes an explicitly authorized public enrollment network' {
ConvertTo-PublicNetworkCidr -Cidr '200.13.89.183/24' |
Should Be '200.13.89.0/24'
}
It 'rejects private space in the public enrollment allowlist' {
$wasRejected = $false
try {
ConvertTo-PublicNetworkCidr -Cidr '10.77.0.0/16' | Out-Null
}
catch {
$wasRejected = $true
}
$wasRejected | Should Be $true
}
It 'exposes explicit Azure modes on both bootstraps' { It 'exposes explicit Azure modes on both bootstraps' {
((Get-Command $serverBootstrapPath).Parameters.Keys -contains ((Get-Command $serverBootstrapPath).Parameters.Keys -contains
'NetworkConfigurationMode') | Should Be $true 'NetworkConfigurationMode') | Should Be $true
((Get-Command $serverBootstrapPath).Parameters.Keys -contains ((Get-Command $serverBootstrapPath).Parameters.Keys -contains
'TrustedClientNetworks') | Should Be $true 'TrustedClientNetworks') | Should Be $true
((Get-Command $serverBootstrapPath).Parameters.Keys -contains
'PublicEnrollmentNetworks') | Should Be $true
((Get-Command $clientBootstrapPath).Parameters.Keys -contains ((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'ConnectivityMode') | Should Be $true 'ConnectivityMode') | Should Be $true
((Get-Command $clientBootstrapPath).Parameters.Keys -contains ((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'VpnProfilePackagePath') | Should Be $true 'VpnProfilePackagePath') | Should Be $true
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'CompatibilityProfile') | Should Be $true
} }
It 'accepts an explicit static IPv4 address for a private Windows adapter' { It 'accepts an explicit static IPv4 address for a private Windows adapter' {
@@ -93,10 +120,17 @@ Describe 'SGU public-cloud network safety' {
Should Be $true Should Be $true
} }
It 'prefers the private adapter instead of the Internet default route' { It 'waits for the new address and WinRM route to stabilize' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw $source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$source | Should Match '\$withoutDefaultGateway\.Count -eq 1' $source | Should Match "AddressState -eq 'Preferred'"
$source | Should Not Match "Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0\.0\.0\.0/0'" $source | Should Match 'function Wait-TcpPort'
$source | Should Match 'Wait-TcpPort -Address \$DomainControllerIPv4Address -Port 5985'
}
It 'uses the unified implementation without OS-specific network restrictions' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$source | Should Not Match 'package cannot enroll|belongs to the Windows 11'
$source | Should Not Match 'Read-Host "Fixed IPv4 address for this SGU client'
} }
It 'uses an all-user machine-certificate VPN profile' { It 'uses an all-user machine-certificate VPN profile' {
@@ -106,10 +140,280 @@ Describe 'SGU public-cloud network safety' {
$source | Should Match 'Add-DnsClientNrptRule' $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 = Get-Content -LiteralPath $bicepPath -Raw
$template | Should Match "name: 'Allow-RDP-from-administrator'" $template | Should Match "name: 'Allow-RDP-from-administrator'"
$template | Should Match "destinationPortRange: '3389'" $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'" $template | Should Not Match "sourceAddressPrefix: '0\.0\.0\.0/0'"
} }
} }
Describe 'SGU direct public enrollment discovery' {
It 'distinguishes public server addresses from LAN and VPN addresses' {
Test-PrivateIPv4Address -Address ([ipaddress]'10.77.0.4') | Should Be $true
Test-PrivateIPv4Address -Address ([ipaddress]'172.30.0.4') | Should Be $true
Test-PrivateIPv4Address -Address ([ipaddress]'192.168.50.10') | Should Be $true
Test-PrivateIPv4Address -Address ([ipaddress]'20.9.81.130') | Should Be $false
}
It 'bootstraps DoH and host mappings after authenticated server discovery' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$source | Should Match 'Set-DnsServerEncryptionProtocol'
$source | Should Match 'Enable-ClientDnsOverHttps'
$source | Should Match 'Set-ClientHostMappings'
$source | Should Match 'Test-ClientDomainDns'
$source | Should Match 'Get-DnsClientDohServerAddress'
$source | Should Match 'Add-DnsClientDohServerAddress'
}
}
Describe 'Azure accelerated server adapters' {
It 'ignores an Up accelerated VF that has no IPv4 interface' {
Mock Get-NetAdapter {
[pscustomobject]@{ Name = 'Ethernet'; ifIndex = 4; Status = 'Up' }
[pscustomobject]@{ Name = 'Ethernet VF'; ifIndex = 10; Status = 'Up' }
[pscustomobject]@{ Name = 'Disconnected'; ifIndex = 12; Status = 'Disconnected' }
}
Mock Get-NetIPInterface {
if ($InterfaceIndex -eq 4) {
[pscustomobject]@{ InterfaceIndex = 4; ConnectionState = 'Connected' }
}
}
Mock Get-NetIPConfiguration { [pscustomobject]@{ IPv4DefaultGateway = '10.77.0.1' } }
$adapters = @(Get-ActiveIPv4Adapters)
$adapters.Count | Should Be 1
$adapters[0].Name | Should Be 'Ethernet'
Resolve-PrivateInterfaceAlias | Should Be 'Ethernet'
}
}
Describe 'SGU route and interface discovery' {
BeforeEach {
Mock Get-NetIPInterface {
[pscustomobject]@{ InterfaceIndex = 4; InterfaceAlias = 'Internet'; ConnectionState = 'Connected'; InterfaceMetric = 5 }
[pscustomobject]@{ InterfaceIndex = 8; InterfaceAlias = 'AD VPN'; ConnectionState = 'Connected'; InterfaceMetric = 30 }
}
Mock Get-NetIPAddress {
if ($InterfaceIndex -eq 4) {
[pscustomobject]@{ IPAddress = '192.168.1.2'; AddressState = 'Preferred'; SkipAsSource = $false }
} else {
[pscustomobject]@{ IPAddress = '172.30.0.2'; AddressState = 'Preferred'; SkipAsSource = $false }
}
}
Mock Get-NetRoute {
if ($InterfaceIndex -eq 4) {
[pscustomobject]@{ DestinationPrefix = '0.0.0.0/0'; NextHop = '192.168.1.1'; RouteMetric = 0 }
} else {
[pscustomobject]@{ DestinationPrefix = '10.77.0.0/16'; NextHop = '0.0.0.0'; RouteMetric = 10 }
}
}
Mock Find-NetRoute { [pscustomobject]@{ IPAddress = '192.168.1.2'; InterfaceIndex = 4 } }
Mock Test-TcpPort { $InterfaceIndex -eq 8 }
}
It 'tries another interface when the Internet route cannot reach WinRM' {
$result = Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4'
$result.InterfaceAlias | Should Be 'AD VPN'
$result.IPAddress | Should Be '172.30.0.2'
Assert-MockCalled Test-TcpPort -Scope It -Times 1 -Exactly -ParameterFilter { $InterfaceIndex -eq 4 }
Assert-MockCalled Test-TcpPort -Scope It -Times 1 -Exactly -ParameterFilter {
$InterfaceIndex -eq 8 -and $SourceAddress -eq [ipaddress]'172.30.0.2'
}
}
It 'uses a functioning Windows route first even with multiple interfaces' {
Mock Test-TcpPort { $true }
(Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4').InterfaceAlias | Should Be 'Internet'
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly -ParameterFilter { $InterfaceIndex -eq 8 }
}
It 'honors an explicit interface and never falls back to another' {
Mock Test-TcpPort { $false }
$rejected = $false
try { Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4' | Out-Null } catch { $rejected = $true }
$rejected | Should Be $true
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly -ParameterFilter { $InterfaceIndex -eq 4 }
}
It 'does not require a client to share the server subnet' {
(Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4').IPAddress |
Should Be '172.30.0.2'
}
It 'accepts a normal default route when it is the only way to reach AD' {
Mock Get-NetRoute { [pscustomobject]@{ DestinationPrefix = '0.0.0.0/0'; NextHop = '172.30.0.1'; RouteMetric = 0 } }
(Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4').NextHop |
Should Be '172.30.0.1'
}
It 'does not probe disconnected or APIPA-only interfaces' {
Mock Get-NetIPAddress { [pscustomobject]@{ IPAddress = '169.254.1.2'; AddressState = 'Preferred' } }
$rejected = $false
try { Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4' | Out-Null } catch { $rejected = $true }
$rejected | Should Be $true
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly
}
It 'does not select an adapter without a matching route' {
Mock Get-NetRoute { [pscustomobject]@{ DestinationPrefix = '192.168.60.0/24'; NextHop = '0.0.0.0'; RouteMetric = 0 } }
$rejected = $false
try { Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4' | Out-Null } catch { $rejected = $true }
$rejected | Should Be $true
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly
}
It 'selects the longest matching prefix on an interface' {
Mock Get-NetRoute {
[pscustomobject]@{ DestinationPrefix = '0.0.0.0/0'; NextHop = '172.30.0.1'; RouteMetric = 0 }
[pscustomobject]@{ DestinationPrefix = '10.77.0.4/32'; NextHop = '172.30.0.3'; RouteMetric = 100 }
}
(Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4').NextHop |
Should Be '172.30.0.3'
}
It 'does not change a working system route' {
Mock New-NetRoute { throw 'Unexpected route mutation' }
Set-ClientServerRoute -SelectedInterface ([pscustomobject]@{ InterfaceIndex = 4 }) -DomainControllerAddress '10.77.0.4'
Assert-MockCalled New-NetRoute -Scope It -Times 0 -Exactly
}
It 'pins only the server when the working adapter differs from the system route' {
$script:routeAdded = $false
Mock Find-NetRoute {
[pscustomobject]@{ IPAddress = '172.30.0.2'; InterfaceIndex = $(if ($script:routeAdded) { 8 } else { 4 }) }
}
Mock New-NetRoute { $script:routeAdded = $true }
Set-ClientServerRoute -SelectedInterface ([pscustomobject]@{ InterfaceIndex = 8; NextHop = '172.30.0.1' }) `
-DomainControllerAddress '10.77.0.4'
Assert-MockCalled New-NetRoute -Scope It -Times 1 -Exactly -ParameterFilter {
$DestinationPrefix -eq '10.77.0.4/32' -and $InterfaceIndex -eq 8 -and $NextHop -eq '172.30.0.1'
}
}
It 'removes its new route and reports a conflicting system route' {
Mock New-NetRoute { [pscustomobject]@{ DestinationPrefix = '10.77.0.4/32'; InterfaceIndex = 8 } }
Mock Remove-NetRoute { }
$rejected = $false
try {
Set-ClientServerRoute -SelectedInterface ([pscustomobject]@{ InterfaceIndex = 8; NextHop = '172.30.0.1' }) `
-DomainControllerAddress '10.77.0.4'
} catch { $rejected = $true }
$rejected | Should Be $true
Assert-MockCalled Remove-NetRoute -Scope It -Times 1 -Exactly
}
}
Describe 'SGU split DNS' {
It 'scopes DNS to the discovered domain and leaves adapter DNS untouched' {
Mock Get-DnsClientNrptRule { }
Mock Remove-DnsClientNrptRule { }
Mock Add-DnsClientNrptRule { }
Mock Clear-DnsClientCache { }
Mock Set-DnsClientServerAddress { throw 'Unexpected adapter DNS change' }
Set-ClientDomainDns -DnsDomain 'example.test' -ServerAddress '10.77.0.4'
Assert-MockCalled Add-DnsClientNrptRule -Scope It -Times 1 -Exactly -ParameterFilter {
$Namespace -contains '.example.test' -and $Namespace -contains 'example.test' -and
$NameServers -eq '10.77.0.4'
}
Assert-MockCalled Set-DnsClientServerAddress -Scope It -Times 0 -Exactly
}
It 'reuses the managed DNS rule on a repeated enrollment' {
Mock Get-DnsClientNrptRule {
[pscustomobject]@{ DisplayName = 'SGU domain DNS - example.test';
NameServers = @('10.77.0.4'); Namespace = @('example.test', '.example.test') }
}
Mock Add-DnsClientNrptRule { throw 'Unexpected DNS rule duplication' }
Set-ClientDomainDns -DnsDomain 'example.test' -ServerAddress '10.77.0.4'
Assert-MockCalled Add-DnsClientNrptRule -Scope It -Times 0 -Exactly
}
}
Describe 'SGU Windows capability checks' {
It 'accepts the same enrollment on Windows 10 LTSC, Windows 10 and Windows 11' {
foreach ($build in @(14393, 17763, 19044, 19045, 22000, 22631, 26100)) {
Assert-ClientOperatingSystem -OperatingSystem ([pscustomobject]@{ ProductType = 1; BuildNumber = $build }) `
-Edition Enterprise -Architecture AMD64
}
}
It 'rejects Home, Server, pre-Windows 10 and incompatible architectures' {
foreach ($sample in @(
@{ ProductType = 1; BuildNumber = 26100; Edition = 'Core'; Architecture = 'AMD64' },
@{ ProductType = 3; BuildNumber = 26100; Edition = 'ServerStandard'; Architecture = 'AMD64' },
@{ ProductType = 1; BuildNumber = 9600; Edition = 'Professional'; Architecture = 'AMD64' },
@{ ProductType = 1; BuildNumber = 26100; Edition = 'Professional'; Architecture = 'ARM64' },
@{ ProductType = 1; BuildNumber = 19045; Edition = 'Professional'; Architecture = 'x86' }
)) {
$rejected = $false
try {
Assert-ClientOperatingSystem -OperatingSystem ([pscustomobject]$sample) `
-Edition $sample.Edition -Architecture $sample.Architecture
} catch { $rejected = $true }
$rejected | Should Be $true
}
}
}
Describe 'SGU repeated domain enrollment' {
It 'rejoins a same-name forest when the machine secure channel is broken' {
$source = Get-Content -LiteralPath $domainEnrollmentPath -Raw
$source | Should Match 'Test-ComputerSecureChannel'
$source | Should Match '\$computer\.PartOfDomain -and \$domainMembershipHealthy'
$source | Should Match 'Reset-ComputerMachinePassword'
$source | Should Match 'DomainControllerDnsName'
$source | Should Match 'Add-Computer @joinParams'
}
It 'defers domain-only repair until the secure channel is healthy' {
$source = Get-Content -LiteralPath $repairEnrollmentPath -Raw
$source | Should Match 'Test-ComputerSecureChannel'
$source | Should Match 'if \(\$domainReady\)'
}
}
Describe 'SGU real TCP probe' {
It 'connects with a bound source and interface without relying on ICMP' {
$listener = [Net.Sockets.TcpListener]::new([ipaddress]'127.0.0.1', 0)
try {
$listener.Start()
$loopback = Get-NetIPAddress -IPAddress '127.0.0.1' -AddressFamily IPv4 | Select-Object -First 1
Test-TcpPort -Address '127.0.0.1' -Port $listener.LocalEndpoint.Port `
-SourceAddress '127.0.0.1' -InterfaceIndex $loopback.InterfaceIndex | Should Be $true
} finally { $listener.Stop() }
}
It 'returns false when the TCP service is closed' {
$listener = [Net.Sockets.TcpListener]::new([ipaddress]'127.0.0.1', 0)
$listener.Start()
$port = $listener.LocalEndpoint.Port
$listener.Stop()
Test-TcpPort -Address '127.0.0.1' -Port $port -TimeoutMilliseconds 200 | Should Be $false
}
}
Describe 'SGU network readiness retries' {
It 'retries discovery while DHCP or VPN routes are initializing' {
$script:discoveryAttempts = 0
Mock Start-Sleep { }
Mock Resolve-ClientInterfaceAlias {
$script:discoveryAttempts++
if ($script:discoveryAttempts -eq 1) { throw 'Address still tentative' }
[pscustomobject]@{ InterfaceAlias = 'AD VPN'; IPAddress = '172.30.0.2' }
}
(Wait-ClientInterface -DomainControllerAddress '10.77.0.4').InterfaceAlias | Should Be 'AD VPN'
Assert-MockCalled Resolve-ClientInterfaceAlias -Scope It -Times 2 -Exactly
}
It 'reports the last network diagnostic when the timeout expires' {
Mock Resolve-ClientInterfaceAlias { throw 'No route to the server' }
$message = ''
try { Wait-ClientInterface -DomainControllerAddress '10.77.0.4' -TimeoutSeconds 0 }
catch { $message = $_.Exception.Message }
$message | Should Match 'No route to the server'
}
}
+86
View File
@@ -0,0 +1,86 @@
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$localUserScriptPath = Join-Path $repositoryRoot 'scripts\Set-SguStandardLocalUser.ps1'
$enrollmentTestScriptPath = Join-Path $repositoryRoot 'scripts\Test-SguClientEnrollment.ps1'
$packageScriptPath = Join-Path $repositoryRoot 'scripts\New-SguBootstrapPackages.ps1'
$releaseScriptPath = Join-Path $repositoryRoot 'scripts\Publish-GiteaRelease.ps1'
$azureLauncherPath = Join-Path $repositoryRoot 'scripts\Start-SguAzureClientEnrollment.cmd'
$credentialProviderInstallerPath = Join-Path $repositoryRoot 'scripts\Install-CredentialProvider.ps1'
$computerPolicyScriptPath = Join-Path $repositoryRoot 'scripts\Set-SguDomainComputerPolicies.ps1'
$tokens = $null
$parseErrors = $null
$scriptAst = [Management.Automation.Language.Parser]::ParseFile(
$localUserScriptPath,
[ref]$tokens,
[ref]$parseErrors)
if ($parseErrors.Count -gt 0) {
throw ($parseErrors -join [Environment]::NewLine)
}
$descriptionAssignment = $scriptAst.Find({
param($node)
$node -is [Management.Automation.Language.AssignmentStatementAst] -and
$node.Left.Extent.Text -eq '$description'
}, $true)
$description = $descriptionAssignment.Right.Extent.Text.Trim("'")
Describe 'SGU Windows client enrollment scripts' {
It 'keeps the local-user description within the Windows 10 limit' {
($description.Length -le 48) | Should Be $true
}
It 'declares the managed local student account' {
$source = Get-Content -LiteralPath $localUserScriptPath -Raw
$source | Should Match "\$userName = 'alumno'"
$source | Should Match "\$plainTextPassword = 'ingenieria'"
}
It 'uses the cross-version Windows account flag for password expiration' {
$localUserSource = Get-Content -LiteralPath $localUserScriptPath -Raw
$enrollmentTestSource = Get-Content -LiteralPath $enrollmentTestScriptPath -Raw
$localUserSource | Should Match '\$passwordNeverExpiresFlag = 0x10000'
$enrollmentTestSource | Should Match '\$passwordNeverExpiresFlag = 0x10000'
$localUserSource | Should Not Match '\$verifiedUser\.PasswordNeverExpires'
$enrollmentTestSource | Should Not Match '\$standardLocalUser\.PasswordNeverExpires'
}
It 'preserves existing local credentials when enrollment is repeated under domain password policies' {
$updates = $scriptAst.FindAll({
param($node)
$node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -eq 'Set-LocalUser'
}, $true)
$updates.Count | Should Be 1
@($updates[0].CommandElements | Where-Object {
$_ -is [Management.Automation.Language.CommandParameterAst] -and $_.ParameterName -eq 'Password'
}).Count | Should Be 0
}
It 'publishes one Windows artifact with automatic compatibility' {
$packageSource = Get-Content -LiteralPath $packageScriptPath -Raw
$releaseSource = Get-Content -LiteralPath $releaseScriptPath -Raw
$packageSource | Should Match 'sgu-windows-client-bootstrap-\$Version'
$packageSource | Should Match '-CompatibilityProfile Auto'
$packageSource | Should Not Match 'sgu-windows10-legacy|sgu-windows11-client'
$releaseSource | Should Match 'sgu-windows-client-bootstrap-\$Version\.zip'
$releaseSource | Should Not Match 'sgu-windows10-legacy|sgu-windows11-client'
}
It 'includes Azure P2S in the shared Windows artifact' {
$packageSource = Get-Content -LiteralPath $packageScriptPath -Raw
$azureLauncher = Get-Content -LiteralPath $azureLauncherPath -Raw
$packageSource.Contains("Join-Path `$clientRoot 'Start-SguAzureClientEnrollment.cmd'") |
Should Be $true
$packageSource.Contains("Join-Path `$clientRoot 'Install-SguAzureP2sClient.ps1'") |
Should Be $true
$azureLauncher | Should Match '-PauseOnError'
}
It 'hides Fast User Switching during enrollment and through computer policy' {
(Get-Content -LiteralPath $credentialProviderInstallerPath -Raw) |
Should Match 'HideFastUserSwitching'
(Get-Content -LiteralPath $enrollmentTestScriptPath -Raw) |
Should Match 'FastUserSwitchingHidden'
(Get-Content -LiteralPath $computerPolicyScriptPath -Raw) |
Should Match "Name = 'HideFastUserSwitching'; Type = 'DWord'; Value = 1"
}
}
@@ -0,0 +1,37 @@
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$policyPath = Join-Path $repositoryRoot 'scripts\Set-SguLaboratorioWallpaperPolicy.ps1'
$policySource = Get-Content -LiteralPath $policyPath -Raw
Describe 'Laboratorio wallpaper policy packaging and scope' {
It 'parses in Windows PowerShell and supports a read-only preview' {
$tokens = $null
$parseErrors = $null
[Management.Automation.Language.Parser]::ParseFile($policyPath, [ref]$tokens, [ref]$parseErrors) | Out-Null
$parseErrors.Count | Should Be 0
$policySource | Should Match 'SupportsShouldProcess'
$policySource | Should Match 'if \(-not \$PSCmdlet.ShouldProcess'
}
It 'scopes user settings through computer loopback Merge' {
$policySource | Should Match "OU=Laboratorio,DC=lci,DC=lasalle,DC=mx"
$policySource | Should Match "Name = 'UserPolicyMode'; Type = 'DWord'; Value = 1"
$policySource | Should Match "Enforced = 'Yes'"
$policySource | Should Match "LinkEnabled = 'Yes'"
}
It 'locks the generated per-user and per-computer wallpaper path' {
$policySource | Should Match "Name = 'NoChangingWallPaper'; Type = 'DWord'; Value = 1"
$policySource | Should Match ([regex]::Escape('%LOCALAPPDATA%\SGU\Wallpapers\welcome-%COMPUTERNAME%.jpg'))
$policySource | Should Match "Name = 'Wallpaper'; Type = 'ExpandString'"
}
It 'reuses and backs up an existing GPO and verifies the result' {
foreach ($command in @('Get-GPO', 'Backup-GPO', 'Set-GPLink', 'New-GPLink', 'Get-GPRegistryValue')) {
$policySource | Should Match ([regex]::Escape($command))
}
$policySource | Should Match 'Wallpaper policy verification failed'
}
It 'ships in the server package and runs in server initialization' {
foreach ($path in @('scripts\New-SguBootstrapPackages.ps1', 'scripts\Initialize-SguDomainController.ps1')) {
(Get-Content (Join-Path $repositoryRoot $path) -Raw) |
Should Match 'Set-SguLaboratorioWallpaperPolicy.ps1'
}
}
}
+229
View File
@@ -0,0 +1,229 @@
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$bicepPath = Join-Path $repositoryRoot 'infra\azure\main.bicep'
$deploymentPath = Join-Path $repositoryRoot 'scripts\Deploy-SguAzureInfrastructure.ps1'
$configurationPath = Join-Path $repositoryRoot 'scripts\Enable-SguAzureUserRoaming.ps1'
$rollbackPath = Join-Path $repositoryRoot 'scripts\Disable-SguAzureUserRoaming.ps1'
$installerPath = Join-Path $repositoryRoot 'scripts\Install-SguFsLogix.ps1'
$gpoDeploymentPath = Join-Path $repositoryRoot 'scripts\Publish-SguFsLogixClientDeployment.ps1'
$clientBootstrapPath = Join-Path $repositoryRoot 'scripts\Invoke-SguClientBootstrap.ps1'
$azureLauncherPath = Join-Path $repositoryRoot 'scripts\Start-SguAzureClientEnrollment.cmd'
$packagePath = Join-Path $repositoryRoot 'scripts\New-SguBootstrapPackages.ps1'
foreach ($scriptPath in @(
$deploymentPath,
$configurationPath,
$rollbackPath,
$installerPath,
$gpoDeploymentPath,
$clientBootstrapPath,
$packagePath)) {
$tokens = $null
$parseErrors = $null
[Management.Automation.Language.Parser]::ParseFile(
$scriptPath,
[ref]$tokens,
[ref]$parseErrors) | Out-Null
if ($parseErrors.Count -gt 0) {
throw "$scriptPath contains PowerShell parser errors: $($parseErrors -join '; ')"
}
}
$configurationTokens = $null
$configurationParseErrors = $null
$configurationAst = [Management.Automation.Language.Parser]::ParseFile(
$configurationPath,
[ref]$configurationTokens,
[ref]$configurationParseErrors)
$samFunction = $configurationAst.Find({
param($node)
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq 'Get-SguStorageSamAccountName'
}, $true)
Invoke-Expression $samFunction.Extent.Text
Describe 'SGU Azure user-roaming infrastructure' {
It 'keeps roaming disabled by default while retaining optional private Azure Files resources' {
$source = Get-Content -LiteralPath $bicepPath -Raw
$source | Should Match 'param deployUserRoaming bool = false'
$source | Should Match "purpose: 'SGU-user-roaming'"
$source | Should Match "publicNetworkAccess: 'Disabled'"
$source | Should Match "privateLinkServiceId: userRoamingStorageAccount.id"
$source | Should Match "'file'"
$source | Should Match "privatelink\.file\.\$\{storageEndpointSuffix\}"
$source | Should Match 'dhcpOptions:'
$source | Should Match 'dnsServers:'
$source | Should Match 'domainControllerPrivateIp'
$source | Should Match 'fsLogixProfilesShare'
$source | Should Match 'redirectedFoldersShare'
}
It 'does not repurpose the temporary bootstrap staging account' {
(Get-Content -LiteralPath $bicepPath -Raw) | Should Not Match 'sgustage|SGU-bootstrap-staging'
}
It 'requires a private P2S route when roaming is enabled' {
$source = Get-Content -LiteralPath $deploymentPath -Raw
$source | Should Match '\$DeployUserRoaming -and -not \$DeployVpnGateway'
$source | Should Match 'private Azure Files endpoint'
$source | Should Match '\$DeployUserRoaming -and -not \$AcknowledgeUserRoamingRisks'
$source | Should Match '\[bool\]\$DeployUserRoaming = \$false'
}
It 'returns every post-domain setup value from the Azure deployment' {
$source = Get-Content -LiteralPath $deploymentPath -Raw
foreach ($name in @(
'UserRoamingEnabled',
'UserRoamingStorageAccountName',
'FsLogixProfilesSharePath',
'RedirectedFoldersSharePath',
'UserRoamingSetupArguments')) {
$source | Should Match $name
}
}
}
Describe 'SGU role-specific roaming policies' {
It 'requires an explicit risk acknowledgement before enabling roaming' {
$source = Get-Content -LiteralPath $configurationPath -Raw
$source | Should Match '\[switch\]\$AcknowledgeUserRoamingRisks'
$source | Should Match 'optional high-risk feature'
}
It 'supports an explicitly selected public SMB endpoint while keeping private as the default' {
(((Get-Command $configurationPath).Parameters['EndpointAccess'].Attributes |
Where-Object { $_ -is [Management.Automation.ValidateSetAttribute] }).ValidValues -join ',') |
Should Be 'Private,Public'
$source = Get-Content -LiteralPath $configurationPath -Raw
$source | Should Match ([regex]::Escape("[string]`$EndpointAccess = 'Private'"))
$source | Should Match ([regex]::Escape("`$EndpointAccess -eq 'Private'"))
$source | Should Match 'selected \$EndpointAccess endpoint'
}
It 'uses local Cloud Cache automatically for public WAN profile storage' {
(((Get-Command $configurationPath).Parameters['StaffProfileStorageMode'].Attributes |
Where-Object { $_ -is [Management.Automation.ValidateSetAttribute] }).ValidValues -join ',') |
Should Be 'Auto,Direct,CloudCache'
$source = Get-Content -LiteralPath $configurationPath -Raw
$source | Should Match "EndpointAccess -eq 'Public'\) \{ 'CloudCache' \}"
$source | Should Match 'CCDLocations'
$source | Should Match 'type=smb,name='
$source | Should Match "ValueName 'VHDLocations'"
$source | Should Match '\*\*del\.\$ValueName'
$source | Should Match 'HealthyProvidersRequiredForRegister'
$source | Should Match 'CcdUnregisterTimeout.*60'
$source | Should Match 'ClearCacheOnForcedUnregister'
$source | Should Match 'PreventLoginWithFailure'
$source | Should Match "'HKLM\\SOFTWARE\\FSLogix\\Apps'"
$source | Should Match "ValueName 'VHDCompactDisk'"
}
It 'uses an AD computer identity with AES-256 Kerberos' {
$source = Get-Content -LiteralPath $configurationPath -Raw
$source | Should Match "DomainAccountType = 'ComputerAccount'"
$source | Should Match 'KerberosEncryptionType AES256'
$source | Should Match 'PasswordNeverExpires \$true'
$source | Should Match 'StorageFileDataSmbShareContributor'
}
It 'isolates per-user directories with CREATOR OWNER ACLs' {
$source = Get-Content -LiteralPath $configurationPath -Raw
$source | Should Match "SecurityIdentifier\]::new\('S-1-3-0'\)"
$source | Should Match 'PropagationFlags\]::InheritOnly'
$source | Should Match 'SetAccessRuleProtection\(\$true, \$false\)'
$source | Should Match '\$perUserRootRights'
$source | Should Match 'CreateDirectories'
$source | Should Match 'ContributorRights \$perUserRootRights'
}
It 'redirects only the student Desktop and Documents folders' {
$source = Get-Content -LiteralPath $configurationPath -Raw
$source | Should Match "ValueName 'Desktop'"
$source | Should Match "ValueName 'Personal'"
$source | Should Match '%USERNAME%\\Desktop'
$source | Should Match '%USERNAME%\\Documents'
$source | Should Match "ValueName 'DisableFRAdminPin'"
}
It 'enables FSLogix only through the AD and DO group SIDs' {
$source = Get-Content -LiteralPath $configurationPath -Raw
$source | Should Match "\$fsLogixRoot = 'HKLM\\SOFTWARE\\FSLogix\\Profiles'"
$source | Should Match '\\ObjectSpecific\\\$\(\$staffGroup\.SID\.Value\)'
$source | Should Match '\$professorGroup, \$administrativeGroup'
$source | Should Match "ValueName 'Enabled' -Type DWord -Value 0"
$source | Should Match 'VHDLocations = @\{ Type = ''String''; Value = \$profilesSharePath \}'
}
It 'does not delete existing staff profiles unless explicitly requested' {
$source = Get-Content -LiteralPath $configurationPath -Raw
$source | Should Match '\[switch\]\$DeleteExistingStaffLocalProfiles'
$source | Should Match 'if \(\$DeleteExistingStaffLocalProfiles\) \{ 1 \} else \{ 0 \}'
}
It 'derives valid deterministic AD names for long storage account names' {
$name = Get-SguStorageSamAccountName -StorageName 'abcdefghijklmnopqrstuvwx'
$name.Length | Should Be 15
$name | Should Match '^sgufs[0-9a-f]{10}$'
(Get-SguStorageSamAccountName -StorageName 'abcdefghijklmnopqrstuvwx') | Should Be $name
(Get-SguStorageSamAccountName -StorageName 'sguroam1234567') |
Should Be 'sguroam1234567'
}
}
Describe 'SGU FSLogix image enrollment' {
It 'verifies the Microsoft signature and installs unattended' {
$source = Get-Content -LiteralPath $installerPath -Raw
$source | Should Match 'Get-AuthenticodeSignature'
$source | Should Match 'CN=Microsoft Corporation'
foreach ($argument in @('/install', '/quiet', '/norestart')) {
$source | Should Match ([regex]::Escape($argument))
}
$source | Should Match "Get-Service -Name frxsvc"
}
It 'exposes the optional installer through the unified Azure launcher' {
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'FsLogixInstallerPath') | Should Be $true
$launcher = Get-Content -LiteralPath $azureLauncherPath -Raw
$launcher | Should Match 'SGU_FSLOGIX_INSTALLER=%~5'
$launcher | Should Match '-FsLogixInstallerPath'
}
It 'packages both roaming setup scripts' {
$source = Get-Content -LiteralPath $packagePath -Raw
$source | Should Match "'Install-SguFsLogix\.ps1'"
$source | Should Match "'Disable-SguAzureUserRoaming\.ps1'"
$source | Should Match "'Enable-SguAzureUserRoaming\.ps1'"
$source | Should Match "'Publish-SguFsLogixClientDeployment\.ps1'"
}
It 'publishes a signed idempotent FSLogix computer startup deployment' {
$source = Get-Content -LiteralPath $gpoDeploymentPath -Raw
$source | Should Match 'Get-AuthenticodeSignature'
$source | Should Match 'ExpectedInstallerSha256'
$source | Should Match "'Machine\\Scripts'"
$source | Should Match "'Startup'"
$source | Should Match 'scripts\.ini'
$source | Should Match '42B5FAAE-6536-11D2-AE5A-0000F87571E3'
$source | Should Match 'Install-SguFsLogix-Startup\.ps1'
}
}
Describe 'SGU local-profile rollback' {
It 'disables FSLogix containers and their services through computer policy' {
$source = Get-Content -LiteralPath $rollbackPath -Raw
$source | Should Match "'HKLM\\SOFTWARE\\FSLogix\\Profiles'"
$source | Should Match "'HKLM\\SOFTWARE\\FSLogix\\ODFC'"
$source | Should Match '-ValueName Enabled -Type DWord -Value 0'
$source | Should Match "'frxsvc','frxccds'"
$source | Should Match '-ValueName Start -Type DWord -Value 4'
$source | Should Match 'StaffProfileStorageMode[\s\S]*-Value Disabled'
}
It 'restores student shell folders to the local profile and disables deployment' {
$source = Get-Content -LiteralPath $rollbackPath -Raw
$source | Should Match '%USERPROFILE%\\Desktop'
$source | Should Match '%USERPROFILE%\\Documents'
$source | Should Match "-LinkEnabled No"
$source | Should Match 'profilePath,homeDirectory,homeDrive'
}
}
+103
View File
@@ -0,0 +1,103 @@
$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,
[string]$DirectoryLocation = 'Sala de pruebas',
[string]$DirectoryOu = 'Laboratorio',
[string]$MachineLocation,
[string]$MachineOu
)
# 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 = $DirectoryLocation
OrganizationalUnit = $DirectoryOu
} | 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
$previousMachineLocation = $env:SGU_WELCOME_LOCATION
$previousMachineOu = $env:SGU_WELCOME_ORGANIZATIONAL_UNIT
try {
$env:LOCALAPPDATA = $TestDrive
$env:SGU_WELCOME_LOCATION = $MachineLocation
$env:SGU_WELCOME_ORGANIZATIONAL_UNIT = $MachineOu
& $testScript @parameters
}
finally {
$env:LOCALAPPDATA = $previousLocalAppData
$env:SGU_WELCOME_LOCATION = $previousMachineLocation
$env:SGU_WELCOME_ORGANIZATIONAL_UNIT = $previousMachineOu
}
}
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,'
}
It 'renders the flexible classroom and immediate OU as an access label' {
$result = Invoke-WelcomeFixture -DirectoryGender $null `
-DirectoryLocation 'Aula Flexible' `
-DirectoryOu 'Centro de Experiencia Digital'
$result.LocationText | Should Be 'Acceso al Aula Flexible del Centro de Experiencia Digital.'
}
It 'keeps machine location metadata when the GPO command has no location arguments' {
$result = Invoke-WelcomeFixture -DirectoryGender $null `
-DirectoryLocation 'Sala de pruebas' `
-DirectoryOu 'Laboratorio' `
-MachineLocation 'Aula Flexible' `
-MachineOu 'Centro de Experiencia Digital'
$result.LocationText | Should Be 'Acceso al Aula Flexible del Centro de Experiencia Digital.'
}
}