diff --git a/README.md b/README.md index 40c30d7..c5d1f8f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Operational documentation: - [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) - [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md) +- [Laboratorio desktop wallpaper protection](docs/laboratorio-wallpaper-policy.md) +- [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) | Prefix | Role | Default OU | Security group in the same OU | @@ -107,7 +109,9 @@ Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review For a public Azure VM, use [docs/azure-vpn-deployment.md](docs/azure-vpn-deployment.md). It supports an optional Azure P2S gateway or direct enrollment restricted to explicit public -source CIDRs. +source CIDRs. P2S deployments also provision private Azure Files storage for +role-based roaming: `AL` redirects only Documents/Desktop, while `AD` and `DO` +can mount an FSLogix VHDX profile after the post-domain setup step. Never disable the built-in Microsoft password Credential Provider. It is the supported recovery path if a third-party provider fails to load. diff --git a/docs/architecture.md b/docs/architecture.md index cf4ea86..4a3c700 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,6 +78,17 @@ is deliberately left unset because the verified page does not expose it. Missing metadata does not clear existing AD values and never changes the password outcome. +## Azure user roaming + +Azure P2S deployments can add a dedicated, private Azure Files account. Student +accounts (`AL`) keep a non-authoritative local profile and redirect only +Documents/Desktop. Administrative and professor accounts (`AD`/`DO`) receive an +FSLogix VHDX profile container through object-specific settings keyed to their +AD security-group SIDs. The storage account uses AD DS Kerberos with AES-256; +share-root ACLs use `CREATOR OWNER` so users cannot browse one another's data. +The bootstrap staging account is deliberately separate. See +[user-roaming.md](user-roaming.md) for rollout and recovery constraints. + Every synchronization also enforces one idempotent security-group membership from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to `SGU-Administrativos`, and `DO` to `SGU-Docentes`. Each role group is stored diff --git a/docs/azure-vpn-deployment.md b/docs/azure-vpn-deployment.md index 6d0bb0f..01e4b8d 100644 --- a/docs/azure-vpn-deployment.md +++ b/docs/azure-vpn-deployment.md @@ -13,7 +13,9 @@ La plantilla crea: - IP pública Standard para la VM, protegida por NSG; - VPN Gateway opcional `VpnGw1AZ` con IKEv2/OpenVPN y autenticación por certificados; - pool P2S `172.30.0.0/24`, autorizado en los firewalls SGU; -- DNS de la NIC del servidor apuntando a `10.77.0.4`. +- DNS de la NIC del servidor apuntando a `10.77.0.4`; +- Azure Files privado para user roaming: `AL` conserva sólo Escritorio/Documentos + y `AD`/`DO` usa contenedores de perfil FSLogix. Los prefijos privados deben ser RFC1918 y no deben solaparse con las redes usadas por Hyper-V, el `Default Switch`, Wi-Fi o Ethernet locales. Los prefijos de @@ -82,7 +84,9 @@ pública actual: No utilice `0.0.0.0/0`. El despliegue de un VPN Gateway suele tardar bastante más que la VM; omitirlo reduce tiempo y costo. El comando espera hasta que Azure -entregue un resultado final. +entregue un resultado final. User roaming se crea por omisión y requiere la ruta +privada del P2S; para un despliegue de enrolamiento público directo indique +`-DeployUserRoaming $false`. ## 3. Conectarse al servidor @@ -137,6 +141,12 @@ El JSON debe indicar `NetworkConfigurationMode = PlatformManaged`, el pool P2S en `TrustedClientNetworks` cuando exista VPN y los CIDR directos en `PublicEnrollmentNetworks` cuando se hayan habilitado. +Después de terminar la promoción, una Azure Files al nuevo bosque y publique +las políticas por rol con `Enable-SguAzureUserRoaming.ps1`. Esta segunda fase +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: @@ -155,7 +165,7 @@ Copie a la VM Windows 10/11 de Hyper-V: Desde la carpeta extraída del bootstrap de cliente, instale P2S y enrole: ```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: @@ -169,6 +179,10 @@ En una sola ejecución el comando: 5. registra mTLS, instala SGU/RustDesk y une el equipo al dominio; 6. reinicia Windows. +El quinto argumento es opcional. Cuando se proporciona, instala el binario +oficial de FSLogix después de validar su firma de Microsoft; puede omitirse si +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: diff --git a/docs/laboratorio-wallpaper-policy.md b/docs/laboratorio-wallpaper-policy.md new file mode 100644 index 0000000..74552cf --- /dev/null +++ b/docs/laboratorio-wallpaper-policy.md @@ -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/). diff --git a/docs/release-0.6.0.md b/docs/release-0.6.0.md new file mode 100644 index 0000000..f8bd6d4 --- /dev/null +++ b/docs/release-0.6.0.md @@ -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. diff --git a/docs/user-roaming.md b/docs/user-roaming.md new file mode 100644 index 0000000..1ded99f --- /dev/null +++ b/docs/user-roaming.md @@ -0,0 +1,144 @@ +# User roaming por rol en Azure + +El despliegue Azure puede crear almacenamiento privado y políticas distintas para +cada tipo de cuenta SGU: + +| Rol | Datos conservados | Implementación | +| --- | --- | --- | +| `AL` | `Desktop` y `Documents` | Redirección de carpetas a Azure Files; el resto del perfil local no es autoritativo y no se copia al iniciar o cerrar sesión. | +| `AD` / `DO` | Perfil completo | Un contenedor VHDX único de FSLogix montado desde Azure Files. | + +Linux conserva su solución nativa de directorios personales; no intenta montar +un perfil de Windows. + +## Arquitectura y seguridad + +`infra/azure/main.bicep` crea, por omisión, una cuenta `StorageV2` dedicada con +dos recursos SMB (`profiles` y `redirected`), soft delete de 14 días, un private +endpoint y la zona `privatelink.file.` ligada a la VNet. El +endpoint público queda deshabilitado. La VNet entrega el DNS del controlador de +dominio a los clientes P2S para que el nombre de Azure Files resuelva a la IP +privada. La cuenta de staging usada para transferir paquetes no se reutiliza ni +se convierte en almacenamiento de perfiles. + +Los clientes deben usar el P2S de Azure o disponer de otra ruta privada a la +VNet. El modo de enrolamiento público directo no puede usar estos recursos y el +script de despliegue exige `-DeployUserRoaming $false` cuando no se despliega el +gateway P2S. + +Azure Files se integra con el AD DS SGU, no con cuentas o claves guardadas en +cada cliente. El script configura AES-256 para Kerberos, una ACL de mínimo +privilegio con `CREATOR OWNER`, administradores y los grupos del rol, y permiso +SMB predeterminado para identidades autenticadas. La ACL NTFS mantiene aislado +el contenido de cada usuario. La clave de la cuenta se usa en memoria sólo para +crear las ACL iniciales y no se escribe en el dominio, en GPO ni en disco. + +## 1. Desplegar la infraestructura + +El soporte está activado por omisión para despliegues P2S: + +```powershell +$azure = .\scripts\Deploy-SguAzureInfrastructure.ps1 ` + -SubscriptionId '00000000-0000-0000-0000-000000000000' ` + -ResourceGroupName 'rg-sgu-lab' ` + -Location 'centralus' ` + -AdministratorUsername 'azureadmin' ` + -P2sRootCertificatePath $p2s.RootCertificatePath + +$azure.UserRoamingStorageAccountName +$azure.FsLogixProfilesSharePath +$azure.RedirectedFoldersSharePath +$azure.UserRoamingSetupArguments +``` + +Puede definirse un nombre globalmente único con +`-UserRoamingStorageAccountName`. Para omitir completamente el almacenamiento: + +```powershell +-DeployUserRoaming $false +``` + +## 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: + +1. Instale Azure PowerShell actual (`Az.Accounts` y `Az.Storage` 8.1 o posterior). +2. Descargue y extraiga el módulo oficial + [AzFilesHybrid](https://github.com/Azure-Samples/azure-files-samples/releases). +3. Ejecute el configurador incluido en el paquete de servidor: + +```powershell +$storageAccountName = '' +$parameters = @{ + SubscriptionId = '00000000-0000-0000-0000-000000000000' + ResourceGroupName = 'rg-sgu-lab' + StorageAccountName = $storageAccountName + FsLogixProfilesShareName = 'profiles' + RedirectedFoldersShareName = 'redirected' + AzFilesHybridModulePath = 'C:\Admin\AzFilesHybrid' + UseDeviceAuthentication = $true +} +.\payload\scripts\Enable-SguAzureUserRoaming.ps1 @parameters +``` + +La operación es idempotente. Crea `OU=AzureFilesConfig`, une la cuenta de +almacenamiento al bosque y publica dos GPO: + +- `SGU - AL redirected folders`, ligada a `OU=Alumnos`; +- `SGU - AD-DO FSLogix profiles`, ligada a `OU=Laboratorio` y habilitada sólo + mediante configuración `ObjectSpecific` para los SID de `SGU-Docentes` y + `SGU-Administrativos`. + +Por seguridad, el script no borra perfiles locales existentes de AD/DO. Antes +de un piloto real, migre o respalde su contenido y elimínelos manualmente. El +switch `-DeleteExistingStaffLocalProfiles` habilita la opción de FSLogix que los +elimina al aplicar el contenedor; úselo sólo después de respaldar y validar. + +La identidad de Azure Files queda con contraseña sin expiración en la OU +dedicada para evitar que una cuenta de equipo sin agente pierda Kerberos a los +30 días. Si la política exige rotación, use periódicamente el procedimiento +oficial `Update-AzStorageAccountADObjectPassword` y no cambie sólo un lado. + +## 3. Instalar FSLogix en las imágenes Windows + +FSLogix no forma parte de Windows y su licencia debe validarse para las ediciones +y suscripciones usadas. Descargue `FSLogixAppsSetup.exe` directamente de +Microsoft y agréguelo a la imagen base, o páselo como quinto argumento al +enrolamiento Azure: + +```bat +Start-SguAzureClientEnrollment.cmd 10.77.0.4 C:\SGU\vpn.zip C:\SGU\client.pfx C:\SGU\root.cer C:\SGU\FSLogixAppsSetup.exe +``` + +`Install-SguFsLogix.ps1` exige una firma Authenticode válida de Microsoft, +instala silenciosamente sin reiniciar y comprueba `frxsvc` y `frx.exe`. Si la +imagen ya contiene FSLogix, el instalador puede omitirse. Microsoft recomienda +mantener la versión más reciente; FSLogix no se actualiza mediante Windows +Update. + +## 4. Validar un piloto + +Después de `gpupdate /force` y reiniciar un cliente: + +```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' +& 'C:\Program Files\FSLogix\Apps\frx.exe' list-redirects +``` + +La resolución debe devolver la IP privada del endpoint. Inicie primero con un +`AL` nuevo y confirme las ubicaciones de Escritorio/Documentos; luego pruebe un +`AD` o `DO` sin perfil local previo y confirme la creación de un VHDX en +`profiles`. No active el borrado de perfiles locales ni despliegue a todos los +usuarios hasta probar inicio, cierre, desconexión de VPN, cuota y restauración. + +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) +- [Redirección de carpetas](https://learn.microsoft.com/windows-server/storage/folder-redirection/deploy-folder-redirection) diff --git a/infra/azure/main.bicep b/infra/azure/main.bicep index 906ed1a..1c7c148 100644 --- a/infra/azure/main.bicep +++ b/infra/azure/main.bicep @@ -55,6 +55,32 @@ param publicEnrollmentSourceAddressPrefixes array = [] @description('Optional public CIDR allowed to RDP to the VM public IP, for example 203.0.113.10/32. Leave empty to expose no management port.') param administratorSourceAddressPrefix string = '' +@description('Deploy private Azure Files shares for SGU user roaming. Clients require P2S or another private route into the VNet.') +param deployUserRoaming bool = true + +@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 domainControllerSubnetName = 'DomainControllers' var gatewaySubnetName = 'GatewaySubnet' @@ -64,6 +90,12 @@ var gatewayPublicIpName = '${deploymentPrefix}-vpngw-pip' var networkInterfaceName = '${deploymentPrefix}-dc-nic' var virtualMachineName = '${deploymentPrefix}-dc' 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' = { name: networkSecurityGroupName @@ -178,11 +210,17 @@ resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = { virtualNetworkAddressPrefix ] } + dhcpOptions: { + dnsServers: [ + domainControllerPrivateIp + ] + } subnets: concat([ { name: domainControllerSubnetName properties: { addressPrefix: domainControllerSubnetPrefix + privateEndpointNetworkPolicies: deployUserRoaming ? 'Disabled' : null networkSecurityGroup: { id: networkSecurityGroup.id } @@ -199,6 +237,122 @@ resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = { } } +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' = { name: domainControllerPublicIpName location: location @@ -389,6 +543,22 @@ output virtualNetworkName string = virtualNetwork.name output virtualNetworkAddressPrefix string = virtualNetworkAddressPrefix output vpnGatewayName string = deployVpnGateway ? virtualNetworkGateway.name : '' output vpnClientAddressPoolPrefix string = vpnClientAddressPoolPrefix +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' domainControllerPrivateIp diff --git a/scripts/Deploy-SguAzureInfrastructure.ps1 b/scripts/Deploy-SguAzureInfrastructure.ps1 index d9e8737..56e7a0d 100644 --- a/scripts/Deploy-SguAzureInfrastructure.ps1 +++ b/scripts/Deploy-SguAzureInfrastructure.ps1 @@ -18,6 +18,17 @@ param( [string]$VpnClientAddressPoolPrefix = '172.30.0.0/24', [string[]]$PublicEnrollmentSourceAddressPrefixes = @(), [string]$AdministratorSourceAddressPrefix = '', + [bool]$DeployUserRoaming = $true, + [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') ) @@ -33,6 +44,17 @@ if (-not (Test-Path -LiteralPath $TemplateFile -PathType Leaf)) { if (-not $AdministratorPassword) { $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.' +} +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) { @@ -58,7 +80,10 @@ if ($LASTEXITCODE -ne 0) { throw "Could not select Azure subscription $SubscriptionId." } -$deploymentDescription = if ($DeployVpnGateway) { +$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 { @@ -105,6 +130,12 @@ if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", $deploymentDescri p2sRootCertificateData = @{ value = $rootCertificateData } publicEnrollmentSourceAddressPrefixes = @{ value = @($PublicEnrollmentSourceAddressPrefixes) } administratorSourceAddressPrefix = @{ value = $AdministratorSourceAddressPrefix } + deployUserRoaming = @{ value = $DeployUserRoaming } + userRoamingStorageAccountName = @{ value = $UserRoamingStorageAccountName } + fsLogixProfilesShareName = @{ value = $FsLogixProfilesShareName } + redirectedFoldersShareName = @{ value = $RedirectedFoldersShareName } + fsLogixProfilesQuotaGiB = @{ value = $FsLogixProfilesQuotaGiB } + redirectedFoldersQuotaGiB = @{ value = $RedirectedFoldersQuotaGiB } } } [IO.File]::WriteAllText( @@ -152,5 +183,10 @@ if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", $deploymentDescri DeployVpnGateway = $DeployVpnGateway PublicEnrollmentSourceAddressPrefixes = @($PublicEnrollmentSourceAddressPrefixes) ServerBootstrapArguments = $values.serverBootstrapArguments + UserRoamingEnabled = [bool]$values.userRoamingEnabled + UserRoamingStorageAccountName = $values.userRoamingStorageAccountName + FsLogixProfilesSharePath = $values.fsLogixProfilesSharePath + RedirectedFoldersSharePath = $values.redirectedFoldersSharePath + UserRoamingSetupArguments = @($values.userRoamingSetupArguments) } } diff --git a/scripts/Enable-SguAzureUserRoaming.ps1 b/scripts/Enable-SguAzureUserRoaming.ps1 new file mode 100644 index 0000000..5b1b4d3 --- /dev/null +++ b/scripts/Enable-SguAzureUserRoaming.ps1 @@ -0,0 +1,430 @@ +#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', + [ValidateRange(1024, 1048576)] + [int]$FsLogixProfileSizeMiB = 30000, + [string]$AzFilesHybridModulePath, + [switch]$UseDeviceAuthentication, + [switch]$DeleteExistingStaffLocalProfiles +) + +$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 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 20) { + return $StorageName + } + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $hash = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($StorageName)) + $suffix = ([BitConverter]::ToString($hash) -replace '-', '').Substring(0, 15).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")) { + Set-GPRegistryValue -Name $GpoName -Domain $DomainName -Server $Server ` + -Key $Key -ValueName $ValueName -Type $Type -Value $Value | Out-Null + } +} + +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 +} + +$privateAddresses = @(Resolve-DnsName -Name $fileEndpointHost -Type A -ErrorAction Stop | + Where-Object IPAddress | Select-Object -ExpandProperty IPAddress) +if ($privateAddresses.Count -eq 0 -or @($privateAddresses | Where-Object { + $_ -match '^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)' + }).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 through the private 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" +try { + if ($PSCmdlet.ShouldProcess($profilesSharePath, 'Apply isolated FSLogix root ACLs')) { + Set-SguAzureFileRootAcl -UncPath $profilesSharePath -Credential $storageCredential ` + -DomainAdminsSid $domainAdminsSid ` + -ContributorSids @($professorGroup.SID, $administrativeGroup.SID) + } + if ($PSCmdlet.ShouldProcess($redirectedFoldersSharePath, 'Apply isolated student-folder root ACLs')) { + $studentRootRights = [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 + Set-SguAzureFileRootAcl -UncPath $redirectedFoldersSharePath -Credential $storageCredential ` + -DomainAdminsSid $domainAdminsSid -ContributorSids @($studentGroup.SID) ` + -ContributorRights $studentRootRights + } +} +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 +$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 } + VHDLocations = @{ Type = 'String'; Value = $profilesSharePath } + VolumeType = @{ Type = 'String'; Value = 'VHDX' } +} +foreach ($staffGroup in @($professorGroup, $administrativeGroup)) { + $objectSpecificKey = "$fsLogixRoot\ObjectSpecific\$($staffGroup.SID.Value)" + foreach ($setting in $fsLogixValues.GetEnumerator()) { + 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 + 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 = 'FSLogix VHDX profile container for SGU-Docentes and SGU-Administrativos only.' + ExistingStaffLocalProfilesDeleted = [bool]$DeleteExistingStaffLocalProfiles +} diff --git a/scripts/Initialize-SguDomainController.ps1 b/scripts/Initialize-SguDomainController.ps1 index 16c6582..86b5f47 100644 --- a/scripts/Initialize-SguDomainController.ps1 +++ b/scripts/Initialize-SguDomainController.ps1 @@ -520,6 +520,7 @@ foreach ($requiredPath in @( (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1'), (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'), (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'), + (Join-Path $scriptsRoot 'Set-SguLaboratorioWallpaperPolicy.ps1'), (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'), (Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1'), (Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'), @@ -829,6 +830,8 @@ $userPolicyParameters = @{ ClearManagedWallpaper = $true } & (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') ` -ServerAddress $rustDeskDnsName ` diff --git a/scripts/Install-SguFsLogix.ps1 b/scripts/Install-SguFsLogix.ps1 new file mode 100644 index 0000000..b85d1fa --- /dev/null +++ b/scripts/Install-SguFsLogix.ps1 @@ -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 +} diff --git a/scripts/Invoke-SguClientBootstrap.ps1 b/scripts/Invoke-SguClientBootstrap.ps1 index 56acd40..b3757b2 100644 --- a/scripts/Invoke-SguClientBootstrap.ps1 +++ b/scripts/Invoke-SguClientBootstrap.ps1 @@ -21,6 +21,7 @@ param( [securestring]$VpnClientCertificatePfxPassword, [string]$VpnClientRootCertificatePath, [string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'), + [string]$FsLogixInstallerPath, [switch]$PauseOnError, [switch]$SkipRestart ) @@ -551,6 +552,7 @@ $runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites Select-Object -First 1 foreach ($requiredPath in @( (Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1'), + (Join-Path $scriptsRoot 'Install-SguFsLogix.ps1'), (Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'), (Join-Path $scriptsRoot 'Register-SguClientCertificate.ps1'), (Join-Path $providerPublishPath 'SGU.CredentialProvider.comhost.dll'))) { @@ -562,6 +564,18 @@ if (-not $runtimeInstaller) { throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.' } if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enroll with SGU server $DomainControllerIPv4Address")) { return } +$fsLogixResult = $null +if ($FsLogixInstallerPath) { + $fsLogixResult = & (Join-Path $scriptsRoot 'Install-SguFsLogix.ps1') ` + -InstallerPath $FsLogixInstallerPath +} +elseif (Get-Service -Name frxsvc -ErrorAction SilentlyContinue) { + $fsLogixResult = [pscustomobject]@{ + Installed = $true + Changed = $false + RestartRequired = $false + } +} if ($ClientIPv4Address) { if (-not $NetworkInterfaceAlias -or $ConnectivityMode -eq 'AzureP2S') { throw 'Explicit static IP setup requires -NetworkInterfaceAlias with Direct connectivity. Omit -ClientIPv4Address to preserve the current LAN/VPN configuration.' @@ -1004,6 +1018,8 @@ if ($SkipRestart) { ConnectivityMode = $ConnectivityMode CompatibilityProfile = $CompatibilityProfile VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null } + FsLogixInstalled = [bool]($fsLogixResult -and $fsLogixResult.Installed) + FsLogixChanged = [bool]($fsLogixResult -and $fsLogixResult.Changed) RestartRequired = $true RustDesk = if ($result) { $result.RustDesk } else { $null } EnrollmentResult = $result diff --git a/scripts/New-SguBootstrapPackages.ps1 b/scripts/New-SguBootstrapPackages.ps1 index dde3564..1a0e050 100644 --- a/scripts/New-SguBootstrapPackages.ps1 +++ b/scripts/New-SguBootstrapPackages.ps1 @@ -123,6 +123,7 @@ $clientScripts = @( 'Enable-SguClientMonitoring.ps1', 'Enroll-SguDomainClient.ps1', 'Install-CredentialProvider.ps1', + 'Install-SguFsLogix.ps1', 'Install-SguEnrollmentGuard.ps1', 'Install-SguRustDeskClient.ps1', 'Register-SguClientCertificate.ps1', @@ -195,6 +196,7 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureServerBootstra -Destination (Join-Path $serverRoot 'Start-SguAzureServerBootstrap.cmd') $serverScripts = @( 'Deploy-AuthBroker.ps1', + 'Enable-SguAzureUserRoaming.ps1', 'Enable-SguServerRemoteManagement.ps1', 'Get-SguUsageReport.ps1', 'Get-SguBrokerLog.ps1', @@ -210,6 +212,7 @@ $serverScripts = @( 'Register-SguRustDeskDevice.ps1', 'Set-LabBrokerDns.ps1', 'Set-SguDomainComputerPolicies.ps1', + 'Set-SguLaboratorioWallpaperPolicy.ps1', 'Set-SguDomainUserPolicies.ps1' ) foreach ($scriptName in $serverScripts) { @@ -237,6 +240,10 @@ foreach ($fontName in $welcomeFontNames) { Copy-RequiredFile -Source (Join-Path $repositoryRoot "assets\branding\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 Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip ` -CompressionLevel Optimal @@ -258,6 +265,8 @@ foreach ($scriptName in @( } Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\azure-vpn-deployment.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 Compress-Archive -Path (Join-Path $azureRoot '*') -DestinationPath $azureZip ` -CompressionLevel Optimal diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 index 38d1830..72c2c6a 100644 --- a/scripts/Publish-GiteaRelease.ps1 +++ b/scripts/Publish-GiteaRelease.ps1 @@ -9,10 +9,14 @@ param( [string]$Owner = 'alexrg', [string]$Repository = 'SGU-CredentialProvider', [string]$TargetCommitish = 'main', + [string]$ReleaseNotesPath, [switch]$Draft ) $ErrorActionPreference = 'Stop' +if ($ReleaseNotesPath -and -not (Test-Path -LiteralPath $ReleaseNotesPath -PathType Leaf)) { + throw "Release notes file is missing: $ReleaseNotesPath" +} $tagName = "v$Version" $assetPaths = @( (Join-Path $ReleaseDirectory "sgu-windows-client-bootstrap-$Version.zip"), @@ -131,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`. "@ + if ($ReleaseNotesPath) { + $releaseNotes = Get-Content -LiteralPath $ReleaseNotesPath -Raw -Encoding UTF8 + } $releaseBody = [ordered]@{ tag_name = $tagName target_commitish = $TargetCommitish diff --git a/scripts/Set-SguLaboratorioWallpaperPolicy.ps1 b/scripts/Set-SguLaboratorioWallpaperPolicy.ps1 new file mode 100644 index 0000000..738737e --- /dev/null +++ b/scripts/Set-SguLaboratorioWallpaperPolicy.ps1 @@ -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 +} diff --git a/scripts/Start-SguAzureClientEnrollment.cmd b/scripts/Start-SguAzureClientEnrollment.cmd index cccc9de..da4a06a 100644 --- a/scripts/Start-SguAzureClientEnrollment.cmd +++ b/scripts/Start-SguAzureClientEnrollment.cmd @@ -4,7 +4,8 @@ set "SGU_BOOTSTRAP_IP=%~1" set "SGU_VPN_PACKAGE=%~2" set "SGU_VPN_PFX=%~3" 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%" if not "%SGU_EXIT_CODE%"=="0" ( echo. diff --git a/tests/LaboratorioWallpaperPolicy.Tests.ps1 b/tests/LaboratorioWallpaperPolicy.Tests.ps1 new file mode 100644 index 0000000..e9098e9 --- /dev/null +++ b/tests/LaboratorioWallpaperPolicy.Tests.ps1 @@ -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' + } + } +} diff --git a/tests/UserRoaming.Tests.ps1 b/tests/UserRoaming.Tests.ps1 new file mode 100644 index 0000000..82fd1eb --- /dev/null +++ b/tests/UserRoaming.Tests.ps1 @@ -0,0 +1,153 @@ +$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' +$installerPath = Join-Path $repositoryRoot 'scripts\Install-SguFsLogix.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, + $installerPath, + $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 'deploys dedicated private Azure Files resources by default' { + $source = Get-Content -LiteralPath $bicepPath -Raw + $source | Should Match 'param deployUserRoaming bool = true' + $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' + } + + 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 '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\)' + } + + 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 20 + $name | Should Match '^sgufs[0-9a-f]{15}$' + (Get-SguStorageSamAccountName -StorageName 'abcdefghijklmnopqrstuvwx') | Should Be $name + (Get-SguStorageSamAccountName -StorageName 'sguroam1234567890123') | + Should Be 'sguroam1234567890123' + } +} + +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 "'Enable-SguAzureUserRoaming\.ps1'" + } +}