Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
106be26e7c | ||
|
|
01e0ee5ac4 | ||
|
|
c47913e81d | ||
|
|
da310e4213 | ||
|
|
bda8b354b7 | ||
|
|
7986b76e35 | ||
|
|
7de6e2d867 | ||
|
|
7460f9316d | ||
|
|
351adb0bdc | ||
|
|
2de1105ecd | ||
|
|
6c52a9546c | ||
|
|
520b4be955 |
@@ -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,9 +106,15 @@ 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.
|
||||||
@@ -127,8 +135,10 @@ Start-SguClientEnrollment.cmd 192.168.50.10
|
|||||||
El bootstrap prueba interfaces y rutas hacia el servidor, incluyendo VPN ya
|
El bootstrap prueba interfaces y rutas hacia el servidor, incluyendo VPN ya
|
||||||
conectadas, sin pedir la IP del cliente ni exigir la misma subred. Conserva DHCP
|
conectadas, sin pedir la IP del cliente ni exigir la misma subred. Conserva DHCP
|
||||||
y el DNS de Internet; descubre el dominio autenticado y configura DNS sólo para
|
y el DNS de Internet; descubre el dominio autenticado y configura DNS sólo para
|
||||||
ese dominio. El servidor debe tener SGU preparado y existir conectividad LAN/VPN.
|
ese dominio. Si recibe una IP pública, configura DoH y los nombres necesarios de
|
||||||
Los casos sin DHCP, sin ruta o con VPN desconectada muestran un diagnóstico.
|
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
|
Ver [client-enrollment.md](docs/client-enrollment.md) para requisitos y parámetros
|
||||||
avanzados de IP estática.
|
avanzados de IP estática.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|
||||||
@@ -130,7 +168,7 @@ Copie a la VM Windows 10/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,9 +182,32 @@ 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.
|
||||||
|
|
||||||
|
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
|
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
|
||||||
@@ -155,21 +216,23 @@ pantalla de inicio de sesión; antes del primer logon de una cuenta de dominio,
|
|||||||
conecte `SGU Azure P2S` allí. Enterprise/Education pueden recibir posteriormente
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -114,12 +126,19 @@ el error queda en `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
|
|||||||
El manifiesto usa `Auto` y ambos Windows ejecutan el mismo código. Azure P2S está
|
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.
|
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. selecciona una interfaz con conectividad comprobada al servidor;
|
1. selecciona una interfaz con conectividad comprobada al servidor;
|
||||||
2. abre una sesión WinRM autenticada con el DC, descubre el dominio y configura
|
2. abre una sesión WinRM autenticada con el DC, descubre el dominio y configura
|
||||||
DNS mediante NRPT sólo para ese dominio, conservando el DNS de Internet;
|
DNS mediante NRPT sólo para ese dominio; cuando la IP es pública, además
|
||||||
|
configura y valida automáticamente DoH, conservando el DNS de Internet;
|
||||||
3. crea en el cliente un certificado mTLS RSA-3072 no exportable y envía sólo su
|
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;
|
||||||
@@ -134,6 +153,11 @@ equipos. La contraseña existe sólo en memoria. El bootstrap:
|
|||||||
`rustdesk.lci.lasalle.mx` y registra el ID del dispositivo en el inventario
|
`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
|
||||||
|
|||||||
@@ -36,6 +36,26 @@ contrario, el contenedor de equipos configurado en AD. Los parámetros
|
|||||||
explícitamente. Para otra cuenta, editar el usuario sugerido como
|
explícitamente. Para otra cuenta, editar el usuario sugerido como
|
||||||
`DOMINIO\usuario` o `usuario@dominio`. No se guardan contraseñas.
|
`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,
|
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.
|
conservando los servidores DNS de los adaptadores y la resolución de Internet.
|
||||||
Las políticas DNS/VPN corporativas deben permitir resolver ese dominio.
|
Las políticas DNS/VPN corporativas deben permitir resolver ese dominio.
|
||||||
|
|||||||
@@ -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/).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Fecha: 2026-09-10. Paquete: 0.5.1.
|
|||||||
- El empaquetador genera un solo ZIP Windows con los lanzadores habitual y Azure,
|
- El empaquetador genera un solo ZIP Windows con los lanzadores habitual y Azure,
|
||||||
el instalador VPN, el runtime offline y el manifiesto SHA-256.
|
el instalador VPN, el runtime offline y el manifiesto SHA-256.
|
||||||
|
|
||||||
## Prueba real en Hyper-V
|
## Prueba real en Hyper-V: Windows 11
|
||||||
|
|
||||||
Servidor: VM `Windows Server`, dominio `lci.lasalle.mx`, DC `192.168.50.10`.
|
Servidor: VM `Windows Server`, dominio `lci.lasalle.mx`, DC `192.168.50.10`.
|
||||||
Cliente: VM `Windows11-002`, Windows 11 Enterprise LTSC, build 26100,
|
Cliente: VM `Windows11-002`, Windows 11 Enterprise LTSC, build 26100,
|
||||||
@@ -46,12 +46,126 @@ Resultados comprobados:
|
|||||||
DHCP y su servidor DNS originales. Resolución pública y HTTPS comprobados
|
DHCP y su servidor DNS originales. Resolución pública y HTTPS comprobados
|
||||||
contra `www.microsoft.com` (HTTP 200).
|
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
|
## Alcance pendiente
|
||||||
|
|
||||||
Windows 10 se cubrió mediante pruebas de compatibilidad y código compartido,
|
OpenVPN y otras VPN requieren validación en sus redes reales. Las pruebas
|
||||||
pero no se ejecutó una instalación real en Windows 10 en esta sesión. La VPN
|
Windows realizadas cubren Enterprise LTSC x64, builds 19044 y 26100; no todas
|
||||||
Azure y otras VPN requieren validación en sus redes reales; las pruebas locales
|
las ediciones ni builds. Azure se probó con Windows 11; la prueba de Windows 10
|
||||||
cubren rutas en otra subred, pero no un gateway Azure activo.
|
descrita arriba corresponde a la LAN.
|
||||||
|
|
||||||
El servidor debe tener SGU preparado. La IP de un DC no permite crear una VPN,
|
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.
|
adivinar una IP libre ni sustituir permisos, DHCP o políticas de firewall.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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',
|
||||||
@@ -57,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
|
||||||
@@ -133,7 +159,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
|||||||
throw "Domain join refused because SGU enrollment is invalid: $($preJoin.Issues -join ' ')"
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ try {
|
|||||||
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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ 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
|
||||||
)
|
)
|
||||||
@@ -181,6 +182,18 @@ 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 {
|
function Wait-ClientInterface {
|
||||||
param(
|
param(
|
||||||
[string]$RequestedAlias,
|
[string]$RequestedAlias,
|
||||||
@@ -355,6 +368,95 @@ function Set-ClientDomainDns {
|
|||||||
Clear-DnsClientCache
|
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 {
|
function Assert-ClientOperatingSystem {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]$OperatingSystem,
|
[Parameter(Mandatory)]$OperatingSystem,
|
||||||
@@ -429,6 +531,11 @@ if ($DomainControllerIPv4Address.AddressFamily -ne [Net.Sockets.AddressFamily]::
|
|||||||
$DomainControllerIPv4Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.|24\d\.|25[0-5]\.)') {
|
$DomainControllerIPv4Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.|24\d\.|25[0-5]\.)') {
|
||||||
throw 'Enter a reachable unicast IPv4 address for the domain controller.'
|
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
|
||||||
$packageManifest = Assert-PackageManifest -PackageRoot $packageRoot
|
$packageManifest = Assert-PackageManifest -PackageRoot $packageRoot
|
||||||
@@ -445,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'))) {
|
||||||
@@ -456,6 +564,18 @@ 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 }
|
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 ($ClientIPv4Address) {
|
||||||
if (-not $NetworkInterfaceAlias -or $ConnectivityMode -eq 'AzureP2S') {
|
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.'
|
throw 'Explicit static IP setup requires -NetworkInterfaceAlias with Direct connectivity. Omit -ClientIPv4Address to preserve the current LAN/VPN configuration.'
|
||||||
@@ -595,7 +715,141 @@ try {
|
|||||||
$serverIdentity.RustDeskHbbrTask -ne 'Running') {
|
$serverIdentity.RustDeskHbbrTask -ne 'Running') {
|
||||||
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
|
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)) {
|
foreach ($port in @(53, 88, 135, 389, 445, 8443)) {
|
||||||
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port $port -TimeoutMilliseconds 2000)) {
|
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."
|
throw "Server $DomainControllerIPv4Address is reachable, but required TCP port $port is unavailable. Check AD/SGU services and the LAN/VPN firewall. Domain join has not started."
|
||||||
@@ -680,6 +934,7 @@ 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)
|
||||||
@@ -763,6 +1018,8 @@ if ($SkipRestart) {
|
|||||||
ConnectivityMode = $ConnectivityMode
|
ConnectivityMode = $ConnectivityMode
|
||||||
CompatibilityProfile = $CompatibilityProfile
|
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
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ $clientScripts = @(
|
|||||||
'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',
|
||||||
@@ -195,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',
|
||||||
@@ -210,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) {
|
||||||
@@ -237,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
|
||||||
@@ -258,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
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ 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-windows-client-bootstrap-$Version.zip"),
|
(Join-Path $ReleaseDirectory "sgu-windows-client-bootstrap-$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,
|
||||||
@@ -113,10 +118,11 @@ Bootstrap reproducible para el laboratorio SGU.
|
|||||||
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
|
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
|
||||||
- `sgu-windows-client-bootstrap-$Version.zip`: paquete único para Windows 10 1607+ y Windows 11 x64 Pro, Enterprise o Education, con LAN, VPN existente y Azure P2S opcional.
|
- `sgu-windows-client-bootstrap-$Version.zip`: paquete único para Windows 10 1607+ y Windows 11 x64 Pro, Enterprise o Education, con LAN, VPN existente y Azure P2S opcional.
|
||||||
- Doble clic en `Start-SguClientEnrollment.cmd`, IP del servidor y credenciales: descubre el dominio autenticado, comprueba las interfaces y rutas disponibles y conserva DHCP, las IP del cliente y el DNS de Internet. Ya no solicita una IP del cliente.
|
- Doble clic en `Start-SguClientEnrollment.cmd`, IP del servidor y credenciales: descubre el dominio autenticado, comprueba las interfaces y rutas disponibles y conserva DHCP, las IP del cliente y el DNS de Internet. Ya no solicita una IP del cliente.
|
||||||
- Conserva seguridad mTLS, Credential Provider, cuenta estándar, RustDesk, supervisión y autorreparación. El servidor debe estar preparado con SGU y ser accesible por LAN o VPN.
|
- Si la IP del DC es pública, configura automáticamente DoH autenticado, confianza del certificado, NRPT y nombres del bosque antes de unir el equipo; funciona con cualquier interfaz que pueda alcanzar el servidor.
|
||||||
|
- Conserva seguridad mTLS, Credential Provider, cuenta estándar, RustDesk, supervisión y autorreparación. El servidor puede ser accesible por LAN, una VPN ya conectada o un CIDR público autorizado.
|
||||||
- `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows.
|
- `sgu-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.
|
||||||
- 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.
|
- 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.
|
||||||
@@ -129,6 +135,9 @@ Bootstrap reproducible para el laboratorio SGU.
|
|||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -15,6 +15,29 @@ function Get-LocalUserFlags {
|
|||||||
return [int]$directoryEntry.InvokeGet('UserFlags')
|
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)
|
||||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
@@ -64,14 +87,16 @@ try {
|
|||||||
$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 {
|
||||||
@@ -85,12 +110,12 @@ $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 =
|
$verifiedPasswordNeverExpires =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ 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 + '"'),'-PauseOnError','-ConnectivityMode','AzureP2S'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',('"' + $env:SGU_BOOTSTRAP_IP + '"')) }; if ($env:SGU_VPN_PACKAGE) { $arguments += @('-VpnProfilePackagePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PACKAGE) + '"')) }; if ($env:SGU_VPN_PFX) { $arguments += @('-VpnClientCertificatePfxPath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PFX) + '"')) }; if ($env:SGU_VPN_ROOT) { $arguments += @('-VpnClientRootCertificatePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_ROOT) + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
set "SGU_FSLOGIX_INSTALLER=%~5"
|
||||||
|
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%"
|
set "SGU_EXIT_CODE=%errorlevel%"
|
||||||
if not "%SGU_EXIT_CODE%"=="0" (
|
if not "%SGU_EXIT_CODE%"=="0" (
|
||||||
echo.
|
echo.
|
||||||
|
|||||||
@@ -22,6 +22,28 @@ $issues = [Collections.Generic.List[string]]::new()
|
|||||||
$standardLocalUserName = 'alumno'
|
$standardLocalUserName = 'alumno'
|
||||||
$passwordNeverExpiresFlag = 0x10000
|
$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) {
|
||||||
$issues.Add('The computer is not joined to a domain.')
|
$issues.Add('The computer is not joined to a domain.')
|
||||||
@@ -70,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 `
|
||||||
@@ -100,12 +136,12 @@ if ($standardLocalUserPresent) {
|
|||||||
$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 {
|
try {
|
||||||
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$standardLocalUserName,user")
|
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$standardLocalUserName,user")
|
||||||
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
|
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
|
||||||
@@ -195,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.')
|
||||||
}
|
}
|
||||||
@@ -251,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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -39,7 +44,7 @@ $clientNetworkFunctions = $clientAst.FindAll({
|
|||||||
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
||||||
$node.Name -in @('Test-IPv4AddressesSharePrefix', 'Resolve-ClientInterfaceAlias',
|
$node.Name -in @('Test-IPv4AddressesSharePrefix', 'Resolve-ClientInterfaceAlias',
|
||||||
'Set-ClientServerRoute', 'Set-ClientDomainDns', 'Test-TcpPort', 'Assert-ClientOperatingSystem',
|
'Set-ClientServerRoute', 'Set-ClientDomainDns', 'Test-TcpPort', 'Assert-ClientOperatingSystem',
|
||||||
'Wait-ClientInterface')
|
'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)
|
||||||
|
|
||||||
@@ -65,11 +70,29 @@ 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
|
||||||
@@ -117,14 +140,57 @@ 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' {
|
Describe 'SGU route and interface discovery' {
|
||||||
BeforeEach {
|
BeforeEach {
|
||||||
Mock Get-NetIPInterface {
|
Mock Get-NetIPInterface {
|
||||||
@@ -293,6 +359,23 @@ Describe 'SGU Windows capability checks' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Describe 'SGU repeated domain enrollment' {
|
||||||
|
It 'rejoins a same-name forest when the machine secure channel is broken' {
|
||||||
|
$source = Get-Content -LiteralPath $domainEnrollmentPath -Raw
|
||||||
|
$source | Should Match 'Test-ComputerSecureChannel'
|
||||||
|
$source | Should Match '\$computer\.PartOfDomain -and \$domainMembershipHealthy'
|
||||||
|
$source | Should Match 'Reset-ComputerMachinePassword'
|
||||||
|
$source | Should Match 'DomainControllerDnsName'
|
||||||
|
$source | Should Match 'Add-Computer @joinParams'
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'defers domain-only repair until the secure channel is healthy' {
|
||||||
|
$source = Get-Content -LiteralPath $repairEnrollmentPath -Raw
|
||||||
|
$source | Should Match 'Test-ComputerSecureChannel'
|
||||||
|
$source | Should Match 'if \(\$domainReady\)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Describe 'SGU real TCP probe' {
|
Describe 'SGU real TCP probe' {
|
||||||
It 'connects with a bound source and interface without relying on ICMP' {
|
It 'connects with a bound source and interface without relying on ICMP' {
|
||||||
$listener = [Net.Sockets.TcpListener]::new([ipaddress]'127.0.0.1', 0)
|
$listener = [Net.Sockets.TcpListener]::new([ipaddress]'127.0.0.1', 0)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ $enrollmentTestScriptPath = Join-Path $repositoryRoot 'scripts\Test-SguClientEnr
|
|||||||
$packageScriptPath = Join-Path $repositoryRoot 'scripts\New-SguBootstrapPackages.ps1'
|
$packageScriptPath = Join-Path $repositoryRoot 'scripts\New-SguBootstrapPackages.ps1'
|
||||||
$releaseScriptPath = Join-Path $repositoryRoot 'scripts\Publish-GiteaRelease.ps1'
|
$releaseScriptPath = Join-Path $repositoryRoot 'scripts\Publish-GiteaRelease.ps1'
|
||||||
$azureLauncherPath = Join-Path $repositoryRoot 'scripts\Start-SguAzureClientEnrollment.cmd'
|
$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
|
$tokens = $null
|
||||||
$parseErrors = $null
|
$parseErrors = $null
|
||||||
@@ -72,4 +74,13 @@ Describe 'SGU Windows client enrollment scripts' {
|
|||||||
Should Be $true
|
Should Be $true
|
||||||
$azureLauncher | Should Match '-PauseOnError'
|
$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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user