diff --git a/README.md b/README.md index 38ef8c8..65ea37a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ the latest .NET 10 x64 runtime. The broker is published self-contained. Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review [docs/security.md](docs/security.md) before production deployment and [docs/architecture.md](docs/architecture.md) for the component contract. +For a public Azure VM connected to local Hyper-V clients through Azure VPN +Gateway, use [docs/azure-vpn-deployment.md](docs/azure-vpn-deployment.md). AD +ports remain private even though the VM owns a public IP. 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/azure-vpn-deployment.md b/docs/azure-vpn-deployment.md new file mode 100644 index 0000000..160ebb9 --- /dev/null +++ b/docs/azure-vpn-deployment.md @@ -0,0 +1,179 @@ +# Active Directory SGU en Azure con VPN Point-to-Site + +Esta variante conserva Active Directory en una VM Windows Server 2025 con IP +pública de Azure, pero **no publica Active Directory en Internet**. La IP pública +sirve para el ciclo de vida y, opcionalmente, RDP desde un único CIDR +administrativo. DNS, Kerberos, LDAP, SMB, RPC, WinRM, Auth Broker, monitoreo y +RustDesk viajan por Azure VPN Gateway Point-to-Site (P2S). + +La plantilla crea: + +- VNet `10.77.0.0/16`, subnet del DC `10.77.0.0/24` y `GatewaySubnet`; +- Windows Server 2025 con IP privada estática `10.77.0.4` reservada en la NIC; +- IP pública Standard para la VM, protegida por NSG; +- VPN Gateway `VpnGw1` con IKEv2/SSTP y autenticación por certificados; +- pool P2S `172.30.0.0/24`, autorizado en los firewalls SGU; +- DNS de la NIC del servidor apuntando a `10.77.0.4`. + +Los prefijos son parámetros. Deben ser RFC1918 y no deben solaparse con las +redes usadas por Hyper-V, el `Default Switch`, Wi-Fi o Ethernet locales. + +## 1. Crear la autoridad P2S y el certificado de administración + +En la estación administrativa donde está el repositorio: + +```powershell +$p2s = .\scripts\New-SguAzureP2sCertificates.ps1 ` + -ClientName 'AdminWorkstation' +``` + +Se pide una contraseña para proteger el PFX. La clave privada de la autoridad +raíz permanece no exportable en `Cert:\CurrentUser\My`; Azure recibe solamente +el `.cer` público. El PFX es una credencial de acceso a la VNet: se debe copiar +únicamente a la VM correspondiente y eliminarse de ubicaciones compartidas +después de importarlo. + +## 2. Desplegar Azure + +Requisitos: Azure CLI, una sesión iniciada con `az login`, permisos para crear +red, gateway, IP pública y VM, y una suscripción seleccionable. + +```powershell +$azure = .\scripts\Deploy-SguAzureInfrastructure.ps1 ` + -SubscriptionId '00000000-0000-0000-0000-000000000000' ` + -ResourceGroupName 'rg-sgu-lab' ` + -Location 'centralus' ` + -AdministratorUsername 'azureadmin' ` + -P2sRootCertificatePath $p2s.RootCertificatePath +``` + +La contraseña local de la VM se solicita como `SecureString`, se coloca sólo en +un archivo temporal con ACL exclusiva para el usuario actual y se elimina al +terminar. No aparece en los argumentos de Azure CLI ni queda guardada en el +repositorio. + +Por omisión ningún puerto administrativo de la VM se abre desde Internet. Para +habilitar temporalmente RDP durante el bootstrap, indique exclusivamente su IP +pública actual: + +```powershell +-AdministratorSourceAddressPrefix '203.0.113.10/32' +``` + +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. + +## 3. Descargar P2S y entrar por la IP privada + +Cuando el gateway esté `Succeeded`: + +```powershell +$vpn = .\scripts\Get-SguAzureP2sPackage.ps1 ` + -SubscriptionId '00000000-0000-0000-0000-000000000000' ` + -ResourceGroupName 'rg-sgu-lab' ` + -VpnGatewayName $azure.VpnGatewayName + +.\scripts\Install-SguAzureP2sClient.ps1 ` + -VpnProfilePackagePath $vpn.PackagePath ` + -ClientCertificatePfxPath $p2s.ClientCertificatePath ` + -ClientRootCertificatePath $p2s.RootCertificatePath ` + -Connect +``` + +Con el túnel conectado, use RDP contra `10.77.0.4` y habilite la redirección de +una unidad local para copiar `sgu-server-bootstrap-VERSION.zip` a la VM. La NIC +ya apunta a su futura dirección DNS propia, por lo que la resolución pública no +está disponible hasta que el bootstrap instale DNS y sus reenviadores. Así no es +necesario abrir 3389 en la IP pública. La opción +`AdministratorSourceAddressPrefix` queda como ruta de recuperación temporal, +no como el camino normal. + +## 4. Ejecutar el bootstrap dentro de Windows Server + +Descargue y extraiga `sgu-server-bootstrap-VERSION.zip` dentro de la VM. La IP +que recibe el bootstrap es la **privada** de la NIC, nunca la pública: + +```bat +Start-SguAzureServerBootstrap.cmd 10.77.0.4 172.30.0.0/24 +``` + +El modo `PlatformManaged` comprueba que Azure ya asignó `10.77.0.4/24`, pero no +deshabilita DHCP, no reemplaza la ruta predeterminada y no reinicia el adaptador. +El DNS de AD publica únicamente la dirección privada. `168.63.129.16` se usa +como reenviador DNS de la plataforma Azure. + +Después del reinicio de promoción, verificar: + +```powershell +Get-Content C:\ProgramData\SGU\Bootstrap\Server\bootstrap-complete.json +Resolve-DnsName _ldap._tcp.dc._msdcs.lci.lasalle.mx -Type SRV -Server 10.77.0.4 +``` + +El JSON debe indicar `NetworkConfigurationMode = PlatformManaged`, el pool P2S +en `TrustedClientNetworks` y ambos prefijos en `AllowedRemoteAddresses`. + +## 5. Emitir un certificado y enrolar cada VM Hyper-V + +En la estación administrativa, emita una credencial distinta por equipo: + +```powershell +$w11 = .\scripts\New-SguAzureP2sCertificates.ps1 -ClientName 'Windows11' +``` + +Copie a la VM Windows 11 de Hyper-V: + +- `sgu-client-bootstrap-VERSION.zip` extraído; +- `$vpn.PackagePath`; +- `$w11.ClientCertificatePath`; +- `sgu-azure-p2s-root.cer`. + +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 +``` + +En una sola ejecución el comando: + +1. importa el certificado de cliente en `LocalMachine\My` sin dejar la + contraseña en disco; +2. instala un perfil IKEv2 de todos los usuarios llamado `SGU Azure P2S`; +3. agrega la ruta `10.77.0.0/16` y una regla NRPT que envía sólo + `.lci.lasalle.mx` al DNS `10.77.0.4`; +4. conecta P2S con certificado de máquina; +5. registra mTLS, instala SGU/RustDesk y une el equipo al dominio; +6. reinicia Windows. + +Si la red local bloquea IKEv2 (UDP 500/4500), el paquete de Azure también +incluye un perfil SSTP sobre TCP 443, pero ese fallback todavía requiere +instalación manual con el instalador oficial incluido en `WindowsAmd64`. + +Windows 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 +todos los usuarios y se puede seleccionar desde el control de red de la +pantalla de inicio de sesión; antes del primer logon de una cuenta de dominio, +conecte `SGU Azure P2S` allí. Enterprise/Education pueden recibir posteriormente +un device tunnel Always On, pero eso no es requisito del enrolamiento SGU. + +Validación dentro del cliente, con la VPN conectada: + +```powershell +Get-VpnConnection -Name 'SGU Azure P2S' -AllUserConnection +Get-DnsClientNrptRule | Where-Object DisplayName -like 'SGU Azure P2S*' +Test-NetConnection 10.77.0.4 -Port 5985 +Resolve-DnsName _ldap._tcp.dc._msdcs.lci.lasalle.mx -Type SRV +nltest.exe /dsgetdc:lci.lasalle.mx +``` + +## Seguridad y referencias + +No agregue reglas NSG públicas para 53, 88, 135, 389, 445, 464, 636, 3268, +3269 ni RPC dinámico. El conjunto de puertos necesario para una unión de dominio +es precisamente la razón de encapsularlo en P2S. + +- [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) +- [Instalación de certificados P2S](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-how-to-vpn-client-install-azure-cert) +- [Puertos necesarios para unir un dominio](https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/active-directory-domain-join-troubleshooting-guidance) +- [Requisitos de edición de Windows](https://learn.microsoft.com/en-us/windows/security/licensing-and-edition-requirements) +- [Limitación de Always On device tunnel](https://learn.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-always-on-device-tunnel) diff --git a/docs/bootstrap-recovery.md b/docs/bootstrap-recovery.md index 2593964..f43dcef 100644 --- a/docs/bootstrap-recovery.md +++ b/docs/bootstrap-recovery.md @@ -1,6 +1,6 @@ # Recuperación desde cero y alta en una sola ejecución -Los releases entregan tres ZIP independientes. Cada uno contiene sus binarios, +Los releases entregan cuatro ZIP independientes. Cada uno contiene sus binarios, scripts, instalador offline requerido y un manifiesto SHA-256 interno. No contienen contraseñas, claves privadas ni certificados reutilizables. @@ -17,6 +17,11 @@ Compatible con Windows Server con Windows PowerShell 5.1. El servidor necesita una interfaz privada para el dominio y, para autenticar contra SGU, salida HTTPS por esa u otra interfaz. +Cuando el servidor vive en Azure, no se configura la IP dentro del sistema +operativo. La NIC reserva la IP privada y se usa el modo `PlatformManaged`; el +procedimiento completo, incluidos VPN Gateway y los clientes Hyper-V, está en +[azure-vpn-deployment.md](azure-vpn-deployment.md). + 1. Descargar y extraer `sgu-server-bootstrap-VERSION.zip`. 2. Abrir el directorio extraído. 3. Ejecutar, indicando la IP fija que tendrá el controlador: @@ -186,4 +191,4 @@ la línea de comandos. Para empaquetar recursos institucionales adicionales: -ServerContentPath C:\Preparacion\Packages ``` -`SHA256SUMS-VERSION.txt` permite comprobar los tres ZIP antes de usarlos. +`SHA256SUMS-VERSION.txt` permite comprobar los cuatro ZIP antes de usarlos. diff --git a/docs/security.md b/docs/security.md index ee6153d..8dd0e0c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,5 +1,14 @@ # Security model +## Public Azure deployment + +Owning a public Azure IP does not make the domain controller an Internet-facing +directory service. The supported cloud topology exposes no AD DS, DNS, SMB, +RPC, WinRM, broker, monitoring, or RustDesk port publicly. Hyper-V and later +physical Windows clients enter the VNet through certificate-authenticated Azure +VPN Gateway P2S; the Azure NSG and Windows firewall accept the P2S pool and the +private VNet only. See [azure-vpn-deployment.md](azure-vpn-deployment.md). + ## Password handling - The Credential Provider receives the password in Lithnet's secure password @@ -33,15 +42,18 @@ - Administrative enrichment first verifies the employee number and reads employee type/status, email, job title, and department from the incident overview. Only after that match, it reads given names and paternal/maternal - surnames from the personal page plus street, exterior/interior number, - neighborhood, locality, state, and postal code from the location page. -- Administrative birth date, RFC, CURP, sex, blood type, marital status, + surnames and the normalized `Male`/`Female` value from the personal page plus + street, exterior/interior number, neighborhood, locality, state, and postal + code from the location page. AD stores only the controlled `SGU-Gender` line, + not the original HTML field. +- Administrative birth date, RFC, CURP, blood type, marital status, nationality, telephone, email lists, housing type, and emergency-contact fields are ignored. - Student enrichment reads only the matching student number, given names, paternal/maternal surnames, email, career, street, neighborhood, - city/municipality, state, and postal code from known element IDs. -- Student CURP, birth date, sex, blood type, marital status, telephone, mobile, + city/municipality, state, postal code, and normalized sex from known element + IDs. +- Student CURP, birth date, blood type, marital status, telephone, mobile, guardian, medical, financial, and academic-history values are ignored. - Professor enrichment keeps the menu display name as its base. From the payroll consultation header it reads only a matching employee number, name, email, diff --git a/infra/azure/main.bicep b/infra/azure/main.bicep new file mode 100644 index 0000000..7270b48 --- /dev/null +++ b/infra/azure/main.bicep @@ -0,0 +1,323 @@ +targetScope = 'resourceGroup' + +@description('Short prefix used for every Azure resource.') +@minLength(3) +@maxLength(18) +param deploymentPrefix string = 'sgu-lab' + +@description('Azure region for the virtual network, gateway, and VM.') +param location string = resourceGroup().location + +@description('Windows Server VM administrator name. This must not be Administrator.') +@minLength(1) +@maxLength(20) +param administratorUsername string + +@secure() +@description('Windows Server VM administrator password.') +param administratorPassword string + +@description('Windows Server computer name; Active Directory limits this to 15 characters.') +@minLength(1) +@maxLength(15) +param computerName string = 'SGU-DC01' + +@description('VM size for the Windows Server 2025 domain controller.') +param vmSize string = 'Standard_D2s_v5' + +@description('Address space assigned to the Azure virtual network.') +param virtualNetworkAddressPrefix string = '10.77.0.0/16' + +@description('Subnet that contains the domain controller.') +param domainControllerSubnetPrefix string = '10.77.0.0/24' + +@description('Reserved Azure VPN Gateway subnet. Use /27 or larger.') +param gatewaySubnetPrefix string = '10.77.255.0/27' + +@description('Static private IP reserved on the Azure NIC for AD DS and DNS.') +param domainControllerPrivateIp string = '10.77.0.4' + +@description('Point-to-site client pool. It must not overlap the VNet or local Hyper-V networks.') +param vpnClientAddressPoolPrefix string = '172.30.0.0/24' + +@description('Name presented for the trusted P2S root certificate.') +param p2sRootCertificateName string = 'SGU-P2S-Root' + +@description('Base64 DER bytes of the trusted P2S root certificate, without PEM markers.') +param p2sRootCertificateData string + +@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 = '' + +var virtualNetworkName = '${deploymentPrefix}-vnet' +var domainControllerSubnetName = 'DomainControllers' +var gatewaySubnetName = 'GatewaySubnet' +var networkSecurityGroupName = '${deploymentPrefix}-dc-nsg' +var domainControllerPublicIpName = '${deploymentPrefix}-dc-pip' +var gatewayPublicIpName = '${deploymentPrefix}-vpngw-pip' +var networkInterfaceName = '${deploymentPrefix}-dc-nic' +var virtualMachineName = '${deploymentPrefix}-dc' +var virtualNetworkGatewayName = '${deploymentPrefix}-vpngw' + +resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { + name: networkSecurityGroupName + location: location + properties: { + securityRules: concat([ + { + name: 'Allow-SGU-P2S-clients' + properties: { + priority: 100 + access: 'Allow' + direction: 'Inbound' + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: vpnClientAddressPoolPrefix + destinationAddressPrefix: domainControllerPrivateIp + description: 'AD, DNS, broker, monitoring, and RustDesk are reachable only through the authenticated P2S address pool.' + } + } + ], empty(administratorSourceAddressPrefix) ? [] : [ + { + name: 'Allow-RDP-from-administrator' + properties: { + priority: 110 + access: 'Allow' + direction: 'Inbound' + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '3389' + sourceAddressPrefix: administratorSourceAddressPrefix + destinationAddressPrefix: domainControllerPrivateIp + description: 'Optional bootstrap-only RDP access from one explicitly supplied public CIDR.' + } + } + ]) + } +} + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = { + name: virtualNetworkName + location: location + properties: { + addressSpace: { + addressPrefixes: [ + virtualNetworkAddressPrefix + ] + } + subnets: [ + { + name: domainControllerSubnetName + properties: { + addressPrefix: domainControllerSubnetPrefix + networkSecurityGroup: { + id: networkSecurityGroup.id + } + } + } + { + name: gatewaySubnetName + properties: { + addressPrefix: gatewaySubnetPrefix + } + } + ] + } +} + +resource domainControllerPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = { + name: domainControllerPublicIpName + location: location + sku: { + name: 'Standard' + } + properties: { + publicIPAllocationMethod: 'Static' + publicIPAddressVersion: 'IPv4' + idleTimeoutInMinutes: 30 + } +} + +resource gatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = { + name: gatewayPublicIpName + location: location + sku: { + name: 'Standard' + } + properties: { + publicIPAllocationMethod: 'Static' + publicIPAddressVersion: 'IPv4' + } +} + +resource networkInterface 'Microsoft.Network/networkInterfaces@2024-05-01' = { + name: networkInterfaceName + location: location + properties: { + enableAcceleratedNetworking: true + dnsSettings: { + dnsServers: [ + domainControllerPrivateIp + ] + } + ipConfigurations: [ + { + name: 'ipconfig1' + properties: { + privateIPAllocationMethod: 'Static' + privateIPAddressVersion: 'IPv4' + privateIPAddress: domainControllerPrivateIp + subnet: { + id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, domainControllerSubnetName) + } + publicIPAddress: { + id: domainControllerPublicIp.id + } + } + } + ] + } + dependsOn: [ + virtualNetwork + ] +} + +resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-07-01' = { + name: virtualMachineName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + hardwareProfile: { + vmSize: vmSize + } + securityProfile: { + securityType: 'TrustedLaunch' + uefiSettings: { + secureBootEnabled: true + vTpmEnabled: true + } + } + osProfile: { + computerName: computerName + adminUsername: administratorUsername + adminPassword: administratorPassword + windowsConfiguration: { + provisionVMAgent: true + enableAutomaticUpdates: true + patchSettings: { + patchMode: 'AutomaticByPlatform' + assessmentMode: 'AutomaticByPlatform' + enableHotpatching: false + } + } + } + storageProfile: { + imageReference: { + publisher: 'MicrosoftWindowsServer' + offer: 'WindowsServer' + sku: '2025-datacenter-azure-edition' + version: 'latest' + } + osDisk: { + createOption: 'FromImage' + managedDisk: { + storageAccountType: 'Premium_LRS' + } + deleteOption: 'Delete' + } + } + networkProfile: { + networkInterfaces: [ + { + id: networkInterface.id + properties: { + primary: true + deleteOption: 'Delete' + } + } + ] + } + diagnosticsProfile: { + bootDiagnostics: { + enabled: true + } + } + } +} + +resource virtualNetworkGateway 'Microsoft.Network/virtualNetworkGateways@2024-05-01' = { + name: virtualNetworkGatewayName + location: location + properties: { + gatewayType: 'Vpn' + vpnType: 'RouteBased' + activeActive: false + enableBgp: false + ipConfigurations: [ + { + name: 'gateway-ipconfig' + properties: { + privateIPAllocationMethod: 'Dynamic' + subnet: { + id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, gatewaySubnetName) + } + publicIPAddress: { + id: gatewayPublicIp.id + } + } + } + ] + sku: { + name: 'VpnGw1' + tier: 'VpnGw1' + } + vpnClientConfiguration: { + vpnClientAddressPool: { + addressPrefixes: [ + vpnClientAddressPoolPrefix + ] + } + vpnClientProtocols: [ + 'IkeV2' + 'SSTP' + ] + vpnAuthenticationTypes: [ + 'Certificate' + ] + vpnClientRootCertificates: [ + { + name: p2sRootCertificateName + properties: { + publicCertData: p2sRootCertificateData + } + } + ] + } + } + dependsOn: [ + virtualNetwork + ] +} + +output domainControllerName string = virtualMachine.name +output domainControllerPrivateIp string = domainControllerPrivateIp +output domainControllerPublicIp string = domainControllerPublicIp.properties.ipAddress +output virtualNetworkName string = virtualNetwork.name +output virtualNetworkAddressPrefix string = virtualNetworkAddressPrefix +output vpnGatewayName string = virtualNetworkGateway.name +output vpnClientAddressPoolPrefix string = vpnClientAddressPoolPrefix +output serverBootstrapArguments array = [ + '-ServerIPv4Address' + domainControllerPrivateIp + '-PrefixLength' + last(split(domainControllerSubnetPrefix, '/')) + '-NetworkConfigurationMode' + 'PlatformManaged' + '-TrustedClientNetworks' + vpnClientAddressPoolPrefix + '-DnsForwarders' + '168.63.129.16' +] diff --git a/scripts/Deploy-SguAzureInfrastructure.ps1 b/scripts/Deploy-SguAzureInfrastructure.ps1 new file mode 100644 index 0000000..ecc1c01 --- /dev/null +++ b/scripts/Deploy-SguAzureInfrastructure.ps1 @@ -0,0 +1,140 @@ +#Requires -Version 5.1 +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)][string]$SubscriptionId, + [string]$ResourceGroupName = 'rg-sgu-lab', + [string]$Location = 'centralus', + [string]$DeploymentPrefix = 'sgu-lab', + [Parameter(Mandatory)][string]$AdministratorUsername, + [securestring]$AdministratorPassword, + [Parameter(Mandatory)][string]$P2sRootCertificatePath, + [string]$ComputerName = 'SGU-DC01', + [string]$VmSize = 'Standard_D2s_v5', + [string]$VirtualNetworkAddressPrefix = '10.77.0.0/16', + [string]$DomainControllerSubnetPrefix = '10.77.0.0/24', + [ipaddress]$DomainControllerPrivateIp = '10.77.0.4', + [string]$GatewaySubnetPrefix = '10.77.255.0/27', + [string]$VpnClientAddressPoolPrefix = '172.30.0.0/24', + [string]$AdministratorSourceAddressPrefix = '', + [string]$TemplateFile = (Join-Path $PSScriptRoot '..\infra\azure\main.bicep') +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw 'Azure CLI is required. Install it from https://aka.ms/installazurecliwindows and run az login.' +} +if (-not (Test-Path -LiteralPath $TemplateFile -PathType Leaf)) { + throw "Azure Bicep template not found: $TemplateFile" +} +if (-not (Test-Path -LiteralPath $P2sRootCertificatePath -PathType Leaf)) { + throw "P2S root certificate not found: $P2sRootCertificatePath" +} +if (-not $AdministratorPassword) { + $AdministratorPassword = Read-Host 'Password for the local Azure VM administrator' -AsSecureString +} + +$rootCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + (Resolve-Path -LiteralPath $P2sRootCertificatePath).Path) +if (-not ($rootCertificate.Extensions | Where-Object { + $_.Oid -and $_.Oid.Value -eq '2.5.29.19' -and $_.Format($false) -match 'CA' })) { + throw 'P2sRootCertificatePath must contain a certificate-authority certificate.' +} +$rootCertificateData = [Convert]::ToBase64String($rootCertificate.RawData) + +$account = & az account show --output json 2>$null +if ($LASTEXITCODE -ne 0) { + throw 'Azure CLI is not signed in. Run az login, then retry.' +} +& az account set --subscription $SubscriptionId --only-show-errors +if ($LASTEXITCODE -ne 0) { + throw "Could not select Azure subscription $SubscriptionId." +} + +if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", 'Create Azure VNet, Windows Server 2025 VM, public IP, and P2S VPN Gateway')) { + & az group create --name $ResourceGroupName --location $Location --only-show-errors --output none + if ($LASTEXITCODE -ne 0) { + throw "Could not create or update resource group $ResourceGroupName." + } + + $temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("sgu-azure-" + [Guid]::NewGuid().ToString('N')) + $parametersPath = Join-Path $temporaryRoot 'parameters.json' + $passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($AdministratorPassword) + try { + New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null + $acl = Get-Acl -LiteralPath $temporaryRoot + $acl.SetAccessRuleProtection($true, $false) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + [Security.Principal.WindowsIdentity]::GetCurrent().User, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + Set-Acl -LiteralPath $temporaryRoot -AclObject $acl + + $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer) + $parameters = [ordered]@{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' + contentVersion = '1.0.0.0' + parameters = [ordered]@{ + deploymentPrefix = @{ value = $DeploymentPrefix } + location = @{ value = $Location } + administratorUsername = @{ value = $AdministratorUsername } + administratorPassword = @{ value = $plainPassword } + computerName = @{ value = $ComputerName } + vmSize = @{ value = $VmSize } + virtualNetworkAddressPrefix = @{ value = $VirtualNetworkAddressPrefix } + domainControllerSubnetPrefix = @{ value = $DomainControllerSubnetPrefix } + gatewaySubnetPrefix = @{ value = $GatewaySubnetPrefix } + domainControllerPrivateIp = @{ value = $DomainControllerPrivateIp.IPAddressToString } + vpnClientAddressPoolPrefix = @{ value = $VpnClientAddressPoolPrefix } + p2sRootCertificateData = @{ value = $rootCertificateData } + administratorSourceAddressPrefix = @{ value = $AdministratorSourceAddressPrefix } + } + } + [IO.File]::WriteAllText( + $parametersPath, + ($parameters | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + $plainPassword = $null + $parameters.parameters.administratorPassword.value = $null + + $deploymentName = 'sgu-{0}' -f (Get-Date -Format 'yyyyMMdd-HHmmss') + $deploymentOutput = & az deployment group create ` + --name $deploymentName ` + --resource-group $ResourceGroupName ` + --template-file (Resolve-Path -LiteralPath $TemplateFile).Path ` + --parameters "@$parametersPath" ` + --only-show-errors ` + --output json + if ($LASTEXITCODE -ne 0) { + throw 'Azure deployment failed. Review the Azure CLI error above; no bootstrap credential was persisted by this script.' + } + $deployment = ($deploymentOutput -join [Environment]::NewLine) | ConvertFrom-Json + } + finally { + if ($passwordPointer -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer) + } + $AdministratorPassword = $null + if ($temporaryRoot -and (Test-Path -LiteralPath $temporaryRoot)) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + $values = @{} + foreach ($property in $deployment.properties.outputs.PSObject.Properties) { + $values[$property.Name] = $property.Value.value + } + [pscustomobject]@{ + ResourceGroupName = $ResourceGroupName + DeploymentName = $deploymentName + DomainControllerName = $values.domainControllerName + DomainControllerPrivateIp = $values.domainControllerPrivateIp + DomainControllerPublicIp = $values.domainControllerPublicIp + VpnGatewayName = $values.vpnGatewayName + VpnClientAddressPoolPrefix = $values.vpnClientAddressPoolPrefix + ServerBootstrapArguments = $values.serverBootstrapArguments + } +} diff --git a/scripts/Enroll-SguDomainClient.ps1 b/scripts/Enroll-SguDomainClient.ps1 index 5479178..4a2dfbc 100644 --- a/scripts/Enroll-SguDomainClient.ps1 +++ b/scripts/Enroll-SguDomainClient.ps1 @@ -22,6 +22,8 @@ param( [string]$NewComputerName, [string]$NetworkInterfaceAlias = 'Ethernet', [string[]]$DomainDnsServerAddresses = @('192.168.50.10'), + [ValidateSet('Direct', 'AzureP2S')] + [string]$ConnectivityMode = 'Direct', [string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP', [string]$DotNetRuntimeInstallerPath, [string]$RustDeskServerAddress, @@ -90,9 +92,21 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo # The broker uses a domain DNS name even before the machine joins the # domain. Point at AD DNS first so the provider-first health check works on # a completely clean Windows installation. - Set-DnsClientServerAddress ` - -InterfaceAlias $NetworkInterfaceAlias ` - -ServerAddresses $DomainDnsServerAddresses + if ($ConnectivityMode -eq 'Direct') { + Set-DnsClientServerAddress ` + -InterfaceAlias $NetworkInterfaceAlias ` + -ServerAddresses $DomainDnsServerAddresses + } + else { + $nrptDisplayName = "SGU Azure P2S DNS - $DomainName" + $nrptRule = Get-DnsClientNrptRule -ErrorAction SilentlyContinue | + Where-Object DisplayName -eq $nrptDisplayName | + Select-Object -First 1 + if (-not $nrptRule -or + @($DomainDnsServerAddresses | Where-Object { @($nrptRule.NameServers) -contains $_ }).Count -eq 0) { + throw "AzureP2S enrollment requires the managed NRPT rule '$nrptDisplayName'. Run Install-SguAzureP2sClient.ps1 first." + } + } Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null & (Join-Path $PSScriptRoot 'Install-CredentialProvider.ps1') @installParams | Out-Null @@ -159,6 +173,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo [pscustomobject]@{ ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME } DomainName = $DomainName + ConnectivityMode = $ConnectivityMode ProviderValidatedBeforeJoin = $true RustDesk = $rustDeskResult RestartRequired = [bool]$SkipRestart diff --git a/scripts/Get-SguAzureP2sPackage.ps1 b/scripts/Get-SguAzureP2sPackage.ps1 new file mode 100644 index 0000000..75cf11e --- /dev/null +++ b/scripts/Get-SguAzureP2sPackage.ps1 @@ -0,0 +1,48 @@ +#Requires -Version 5.1 +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)][string]$SubscriptionId, + [Parameter(Mandatory)][string]$ResourceGroupName, + [Parameter(Mandatory)][string]$VpnGatewayName, + [string]$OutputPath = (Join-Path $PSScriptRoot '..\artifacts\azure-p2s\sgu-azure-vpn-client.zip') +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw 'Azure CLI is required.' +} +& az account set --subscription $SubscriptionId --only-show-errors +if ($LASTEXITCODE -ne 0) { + throw "Could not select Azure subscription $SubscriptionId." +} + +if ($PSCmdlet.ShouldProcess($OutputPath, 'Generate and download the Azure P2S client package')) { + $downloadUriText = & az network vnet-gateway vpn-client generate ` + --resource-group $ResourceGroupName ` + --name $VpnGatewayName ` + --processor-architecture Amd64 ` + --authentication-method EAPTLS ` + --only-show-errors ` + --output tsv + $downloadUriText = ($downloadUriText -join '').Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($downloadUriText)) { + throw 'Azure did not generate a P2S client package URL.' + } + $downloadUri = $null + if (-not [uri]::TryCreate($downloadUriText, [UriKind]::Absolute, [ref]$downloadUri) -or + $downloadUri.Scheme -ne 'https') { + throw 'Azure returned an invalid VPN client package URL.' + } + $resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath) + New-Item -ItemType Directory -Path (Split-Path $resolvedOutputPath -Parent) -Force | Out-Null + Invoke-WebRequest -Uri $downloadUri -OutFile $resolvedOutputPath -UseBasicParsing + if ((Get-Item -LiteralPath $resolvedOutputPath).Length -lt 1024) { + throw 'The downloaded VPN client package is unexpectedly small.' + } + [pscustomobject]@{ + PackagePath = $resolvedOutputPath + Sha256 = (Get-FileHash -LiteralPath $resolvedOutputPath -Algorithm SHA256).Hash + VpnGatewayName = $VpnGatewayName + } +} diff --git a/scripts/Initialize-SguDomainController.ps1 b/scripts/Initialize-SguDomainController.ps1 index 655c501..ed305a7 100644 --- a/scripts/Initialize-SguDomainController.ps1 +++ b/scripts/Initialize-SguDomainController.ps1 @@ -6,6 +6,9 @@ param( [int]$PrefixLength = 24, [string]$NetworkInterfaceAlias, [ipaddress]$DefaultGateway, + [ValidateSet('GuestStatic', 'PlatformManaged')] + [string]$NetworkConfigurationMode = 'GuestStatic', + [string[]]$TrustedClientNetworks = @(), [ipaddress[]]$DnsForwarders = @(), [string]$DomainName = 'lci.lasalle.mx', [string]$DomainNetbios = 'LCI', @@ -69,6 +72,67 @@ function Get-DomainBaseDn { return (($DnsDomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ',' } +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 ConvertTo-NetworkCidr { + param( + [Parameter(Mandatory)][ipaddress]$Address, + [Parameter(Mandatory)][ValidateRange(1, 32)][int]$NetworkPrefixLength + ) + + if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) { + throw 'Only IPv4 networks are supported by the SGU bootstrap.' + } + $addressBytes = $Address.GetAddressBytes() + $networkBytes = [byte[]]::new(4) + $remainingBits = $NetworkPrefixLength + for ($index = 0; $index -lt 4; $index++) { + $mask = if ($remainingBits -ge 8) { + 255 + } + elseif ($remainingBits -le 0) { + 0 + } + else { + 256 - [Math]::Pow(2, 8 - $remainingBits) + } + $networkBytes[$index] = [byte]($addressBytes[$index] -band [int]$mask) + $remainingBits -= 8 + } + return "$(($networkBytes | ForEach-Object { [string]$_ }) -join '.')/$NetworkPrefixLength" +} + +function ConvertTo-PrivateNetworkCidr { + param([Parameter(Mandatory)][string]$Cidr) + + if ($Cidr -notmatch '^([^/]+)/(\d{1,2})$') { + throw "Trusted client network '$Cidr' must use IPv4 CIDR notation, for example 172.30.0.0/24." + } + $address = $null + if (-not [ipaddress]::TryParse($Matches[1], [ref]$address) -or + $address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) { + throw "Trusted client network '$Cidr' is not a valid IPv4 network." + } + $networkPrefixLength = [int]$Matches[2] + if ($networkPrefixLength -lt 1 -or $networkPrefixLength -gt 32) { + throw "Trusted client network '$Cidr' has an invalid prefix length." + } + if (-not (Test-PrivateIPv4Address -Address $address)) { + throw "Trusted client network '$Cidr' is not private RFC1918 space. The bootstrap never exposes AD services to public client addresses." + } + return ConvertTo-NetworkCidr -Address $address -NetworkPrefixLength $networkPrefixLength +} + function Resolve-PrivateInterfaceAlias { param([string]$RequestedAlias) @@ -147,6 +211,27 @@ function Set-StaticDomainAddress { -ServerAddresses $Address.IPAddressToString } +function Assert-PlatformManagedDomainAddress { + param( + [Parameter(Mandatory)][string]$InterfaceAlias, + [Parameter(Mandatory)][ipaddress]$Address, + [Parameter(Mandatory)][int]$NetworkPrefixLength + ) + + $adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop + $matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` + -IPAddress $Address.IPAddressToString -ErrorAction SilentlyContinue | + Where-Object PrefixLength -eq $NetworkPrefixLength | + Select-Object -First 1 + if (-not $matchingAddress) { + $observed = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` + -ErrorAction SilentlyContinue | + Where-Object PrefixOrigin -ne 'WellKnown' | + ForEach-Object { "$($_.IPAddress)/$($_.PrefixLength)" }) -join ', ' + throw "PlatformManaged mode expected $Address/$NetworkPrefixLength on $InterfaceAlias, but found: $observed. Configure a static private IP on the Azure NIC before running the bootstrap; do not assign it inside Windows." + } +} + function Register-ResumeTask { param([Parameter(Mandatory)][string]$ScriptPath) @@ -310,6 +395,8 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) { $PrefixLength = [int]$existingState.PrefixLength $NetworkInterfaceAlias = [string]$existingState.NetworkInterfaceAlias $DefaultGateway = if ($existingState.DefaultGateway) { [ipaddress][string]$existingState.DefaultGateway } else { $null } + $NetworkConfigurationMode = if ($existingState.NetworkConfigurationMode) { [string]$existingState.NetworkConfigurationMode } else { 'GuestStatic' } + $TrustedClientNetworks = if ($existingState.TrustedClientNetworks) { @($existingState.TrustedClientNetworks | ForEach-Object { [string]$_ }) } else { @() } $DnsForwarders = @($existingState.DnsForwarders | ForEach-Object { [ipaddress][string]$_ }) $DomainName = [string]$existingState.DomainName $DomainNetbios = [string]$existingState.DomainNetbios @@ -321,6 +408,16 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) { if (-not $ServerIPv4Address) { $ServerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address for this domain controller') } +if (-not (Test-PrivateIPv4Address -Address $ServerIPv4Address)) { + throw 'ServerIPv4Address must be the private address of the domain controller. An Azure public IP is never assigned to AD or published in domain DNS.' +} +$domainSubnet = ConvertTo-NetworkCidr -Address $ServerIPv4Address ` + -NetworkPrefixLength $PrefixLength +$TrustedClientNetworks = @($TrustedClientNetworks | + ForEach-Object { ConvertTo-PrivateNetworkCidr -Cidr $_ } | + Where-Object { $_ -ne $domainSubnet } | + Select-Object -Unique) +$allowedRemoteAddresses = @($domainSubnet) + $TrustedClientNetworks $sourceRoot = $PSScriptRoot if (-not $Resume) { @@ -383,6 +480,8 @@ if (-not $existingState) { PrefixLength = $PrefixLength NetworkInterfaceAlias = $NetworkInterfaceAlias DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null } + NetworkConfigurationMode = $NetworkConfigurationMode + TrustedClientNetworks = $TrustedClientNetworks DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString) DomainName = $DomainName DomainNetbios = $DomainNetbios @@ -396,9 +495,16 @@ if (-not $existingState) { [Text.UTF8Encoding]::new($false)) } -Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength." -Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias ` - -Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway +if ($NetworkConfigurationMode -eq 'PlatformManaged') { + Write-BootstrapLog "Validating platform-managed address $ServerIPv4Address/$PrefixLength on $NetworkInterfaceAlias without changing DHCP, routes, or the Azure NIC." + Assert-PlatformManagedDomainAddress -InterfaceAlias $NetworkInterfaceAlias ` + -Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength +} +else { + Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength." + Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias ` + -Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway +} $computer = Get-CimInstance Win32_ComputerSystem if (-not $computer.PartOfDomain) { @@ -472,8 +578,17 @@ Wait-ActiveDirectoryReady -ExpectedBaseDn $baseDn $domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias ` -ErrorAction SilentlyContinue if (-not $domainProfile -or $domainProfile.NetworkCategory -ne 'DomainAuthenticated') { - Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile." - Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false + if ($NetworkConfigurationMode -eq 'GuestStatic') { + Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile." + Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false + } + else { + # Restarting an Azure NIC from inside the guest can sever the only + # management path. Refresh NLA instead; this does not change the + # platform-managed address, DHCP lease, route, or link state. + Write-BootstrapLog 'Refreshing Network Location Awareness without restarting the Azure adapter.' + Restart-Service NlaSvc -Force -ErrorAction SilentlyContinue + } for ($attempt = 1; $attempt -le 15; $attempt++) { Start-Sleep -Seconds 2 $domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias ` @@ -577,7 +692,7 @@ if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) { -RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName ` -DefaultCompany 'La Salle' ` -FirewallLocalAddress $ServerIPv4Address ` - -FirewallRemoteAddress "$($ServerIPv4Address.IPAddressToString)/$PrefixLength" ` + -FirewallRemoteAddress $allowedRemoteAddresses ` -CreateMissingOus ` -DisableCertificateRevocationCheckForLab | Out-Null @@ -591,9 +706,8 @@ foreach ($hostRecord in $hostRecords) { } } -$privateSubnet = "$($ServerIPv4Address.IPAddressToString)/$PrefixLength" & (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') ` - -AllowedRemoteAddress $privateSubnet | Out-Null + -AllowedRemoteAddress $allowedRemoteAddresses | Out-Null $contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages' if (Test-Path -LiteralPath $contentPath -PathType Container) { @@ -613,7 +727,7 @@ if (-not $packageFirewallRule) { -Protocol TCP ` -LocalPort 445 ` -LocalAddress $ServerIPv4Address.IPAddressToString ` - -RemoteAddress $privateSubnet ` + -RemoteAddress $allowedRemoteAddresses ` -Profile Any | Out-Null } else { @@ -621,7 +735,7 @@ else { $packageFirewallRule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter ` -LocalAddress $ServerIPv4Address.IPAddressToString ` - -RemoteAddress $privateSubnet | Out-Null + -RemoteAddress $allowedRemoteAddresses | Out-Null } $collectorFqdn = "$env:COMPUTERNAME.$DomainName" @@ -642,7 +756,7 @@ $userPolicyParameters = @{ $rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') ` -ServerAddress $rustDeskDnsName ` - -FirewallRemoteAddress $privateSubnet + -FirewallRemoteAddress $allowedRemoteAddresses $rustDeskManagementRoot = Join-Path $env:ProgramData 'SGU\RustDesk' New-Item -ItemType Directory -Path $rustDeskManagementRoot -Force | Out-Null foreach ($scriptName in @( @@ -680,6 +794,9 @@ $validation = [ordered]@{ ComputerName = $env:COMPUTERNAME DomainName = $DomainName ServerIPv4Address = $ServerIPv4Address.IPAddressToString + NetworkConfigurationMode = $NetworkConfigurationMode + TrustedClientNetworks = $TrustedClientNetworks + AllowedRemoteAddresses = $allowedRemoteAddresses BrokerDnsName = $brokerDnsName BrokerCertificateThumbprint = $serverCertificate.Thumbprint BrokerService = (Get-Service SGUAuthBroker).Status.ToString() @@ -714,7 +831,8 @@ if ($validation.BrokerService -ne 'Running' -or -not $validation.RustDeskHbbsListening -or -not $validation.RustDeskHbbrListening -or $validation.EventCollector -ne 'Running' -or - -not $validation.EventSubscription) { + -not $validation.EventSubscription -or + $validation.DomainNetworkProfile -ne 'DomainAuthenticated') { throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.' } diff --git a/scripts/Install-SguAzureP2sClient.ps1 b/scripts/Install-SguAzureP2sClient.ps1 new file mode 100644 index 0000000..51c9c56 --- /dev/null +++ b/scripts/Install-SguAzureP2sClient.ps1 @@ -0,0 +1,123 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)][string]$VpnProfilePackagePath, + [Parameter(Mandatory)][string]$ClientCertificatePfxPath, + [securestring]$ClientCertificatePfxPassword, + [Parameter(Mandatory)][string]$ClientRootCertificatePath, + [string]$ConnectionName = 'SGU Azure P2S', + [string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'), + [ipaddress]$DomainControllerIPv4Address = '10.77.0.4', + [string]$DomainName = 'lci.lasalle.mx', + [switch]$Connect +) + +$ErrorActionPreference = 'Stop' +foreach ($path in @($VpnProfilePackagePath,$ClientCertificatePfxPath,$ClientRootCertificatePath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Required P2S file not found: $path" + } +} +if (-not $ClientCertificatePfxPassword) { + $ClientCertificatePfxPassword = Read-Host 'Password protecting the P2S client PFX' -AsSecureString +} + +$temporaryRoot = Join-Path $env:ProgramData ("SGU\AzureP2S\Import-" + [Guid]::NewGuid().ToString('N')) +try { + Expand-Archive -LiteralPath $VpnProfilePackagePath -DestinationPath $temporaryRoot -Force + $vpnSettingsPath = Get-ChildItem -LiteralPath $temporaryRoot -Recurse -Filter VpnSettings.xml -File | + Select-Object -First 1 -ExpandProperty FullName + if (-not $vpnSettingsPath) { + throw 'The Azure package does not contain Generic\VpnSettings.xml. Generate it with IKEv2 enabled.' + } + [xml]$vpnSettings = Get-Content -LiteralPath $vpnSettingsPath -Raw + $vpnServerNode = $vpnSettings.SelectSingleNode('//*[local-name()="VpnServer"]') + if (-not $vpnServerNode -or [string]::IsNullOrWhiteSpace($vpnServerNode.InnerText)) { + throw 'VpnSettings.xml does not contain the Azure VPN gateway FQDN.' + } + $vpnServer = $vpnServerNode.InnerText.Trim() + + $serverRootPath = Get-ChildItem -LiteralPath (Split-Path $vpnSettingsPath -Parent) ` + -Filter VpnServerRoot.cer -File | Select-Object -First 1 -ExpandProperty FullName + if ($serverRootPath) { + Import-Certificate -FilePath $serverRootPath -CertStoreLocation Cert:\LocalMachine\Root | Out-Null + } + $clientRoot = Import-Certificate -FilePath $ClientRootCertificatePath ` + -CertStoreLocation Cert:\LocalMachine\Root | Select-Object -First 1 + $clientCertificates = @(Import-PfxCertificate -FilePath $ClientCertificatePfxPath ` + -Password $ClientCertificatePfxPassword -CertStoreLocation Cert:\LocalMachine\My) + $clientCertificate = $clientCertificates | + Where-Object { + $_.HasPrivateKey -and + $_.NotAfter -gt (Get-Date) -and + @($_.EnhancedKeyUsageList | ForEach-Object ObjectId) -contains '1.3.6.1.5.5.7.3.2' + } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + if (-not $clientCertificate) { + throw 'The imported PFX does not contain a valid Client Authentication certificate with a private key.' + } + + if ($PSCmdlet.ShouldProcess($ConnectionName, 'Install an all-user IKEv2 Azure P2S connection using a machine certificate')) { + $existingConnection = Get-VpnConnection -Name $ConnectionName -AllUserConnection ` + -ErrorAction SilentlyContinue + if ($existingConnection) { + Remove-VpnConnection -Name $ConnectionName -AllUserConnection -Force + } + Add-VpnConnection ` + -Name $ConnectionName ` + -ServerAddress $vpnServer ` + -TunnelType Ikev2 ` + -AuthenticationMethod MachineCertificate ` + -MachineCertificateIssuerFilter $clientRoot ` + -MachineCertificateEKUFilter '1.3.6.1.5.5.7.3.2' ` + -EncryptionLevel Required ` + -SplitTunneling ` + -AllUserConnection ` + -DnsSuffix $DomainName ` + -Force | Out-Null + foreach ($prefix in $AzureNetworkPrefixes) { + Add-VpnConnectionRoute -ConnectionName $ConnectionName ` + -DestinationPrefix $prefix -AllUserConnection -PassThru | Out-Null + } + + $nrptDisplayName = "SGU Azure P2S DNS - $DomainName" + Get-DnsClientNrptRule -ErrorAction SilentlyContinue | + Where-Object DisplayName -eq $nrptDisplayName | + Remove-DnsClientNrptRule -Force + Add-DnsClientNrptRule ` + -Namespace ".$DomainName" ` + -NameServers $DomainControllerIPv4Address.IPAddressToString ` + -DisplayName $nrptDisplayName ` + -Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null + } + + if ($Connect) { + & "$env:SystemRoot\System32\rasdial.exe" $ConnectionName + if ($LASTEXITCODE -ne 0) { + throw "Windows could not connect $ConnectionName. Verify UDP 500/4500 (IKEv2) or use the Azure-generated SSTP profile when the local network blocks IKEv2." + } + } + + $connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection + [pscustomobject]@{ + ConnectionName = $connection.Name + ServerAddress = $connection.ServerAddress + TunnelType = $connection.TunnelType + AllUserConnection = $true + AuthenticationMethod = $connection.AuthenticationMethod + ConnectionStatus = $connection.ConnectionStatus + ClientCertificateThumbprint = $clientCertificate.Thumbprint + DomainControllerIPv4Address = $DomainControllerIPv4Address.IPAddressToString + DomainDnsNamespace = ".$DomainName" + AzureNetworkPrefixes = $AzureNetworkPrefixes + AvailableBeforeLogon = $true + } +} +finally { + $ClientCertificatePfxPassword = $null + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/Install-SguRustDeskServer.ps1 b/scripts/Install-SguRustDeskServer.ps1 index e255aa7..4197464 100644 --- a/scripts/Install-SguRustDeskServer.ps1 +++ b/scripts/Install-SguRustDeskServer.ps1 @@ -6,7 +6,8 @@ param( [string]$InstallRoot = "$env:ProgramFiles\SGU\RustDeskServer", [string]$DataRoot = "$env:ProgramData\SGU\RustDesk\Server", - [string]$FirewallRemoteAddress = '192.168.50.0/24', + [ValidateNotNullOrEmpty()] + [string[]]$FirewallRemoteAddress = @('192.168.50.0/24'), [uri]$DownloadUri = 'https://github.com/rustdesk/rustdesk-server/releases/download/1.1.16/rustdesk-server-windows-x86_64-unsigned.zip', [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedSha256 = 'B865A3A62FC8755B45480C508F1C4871C3338590408DDA8C58C7E9C373B7ADB0' diff --git a/scripts/Invoke-SguClientBootstrap.ps1 b/scripts/Invoke-SguClientBootstrap.ps1 index 8771f0b..ce5db82 100644 --- a/scripts/Invoke-SguClientBootstrap.ps1 +++ b/scripts/Invoke-SguClientBootstrap.ps1 @@ -8,6 +8,14 @@ param( [string]$DomainNetbios = 'LCI', [string]$ComputerOuDn, [string]$NewComputerName, + [ValidateSet('Direct', 'AzureP2S')] + [string]$ConnectivityMode = 'Direct', + [string]$VpnConnectionName = 'SGU Azure P2S', + [string]$VpnProfilePackagePath, + [string]$VpnClientCertificatePfxPath, + [securestring]$VpnClientCertificatePfxPassword, + [string]$VpnClientRootCertificatePath, + [string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'), [switch]$SkipRestart ) @@ -95,6 +103,30 @@ function Test-TcpPort { } } +function Connect-SguAzureP2s { + param([Parameter(Mandatory)][string]$ConnectionName) + + $connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection ` + -ErrorAction SilentlyContinue + if (-not $connection) { + throw "The all-user VPN connection '$ConnectionName' is not installed. Run Install-SguAzureP2sClient.ps1 in this VM first." + } + if ($connection.TunnelType -notcontains 'Ikev2' -and $connection.TunnelType -ne 'Ikev2') { + throw "The VPN connection '$ConnectionName' is not configured for IKEv2." + } + if ($connection.ConnectionStatus -ne 'Connected') { + & "$env:SystemRoot\System32\rasdial.exe" $ConnectionName + if ($LASTEXITCODE -ne 0) { + throw "Could not connect the Azure P2S profile '$ConnectionName'. Verify the machine certificate and that UDP 500/4500 is permitted by the local network." + } + } + $connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection + if ($connection.ConnectionStatus -ne 'Connected') { + throw "The Azure P2S profile '$ConnectionName' did not reach Connected state." + } + return $connection +} + Assert-Administrator $operatingSystem = Get-CimInstance Win32_OperatingSystem if ([int]$operatingSystem.ProductType -ne 1) { @@ -135,9 +167,52 @@ if (-not $runtimeInstaller) { throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.' } -$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias -Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias ` - -ServerAddresses $DomainControllerIPv4Address.IPAddressToString +if ($ConnectivityMode -eq 'AzureP2S') { + $existingVpnConnection = Get-VpnConnection -Name $VpnConnectionName -AllUserConnection ` + -ErrorAction SilentlyContinue + if (-not $existingVpnConnection) { + $installerPath = Join-Path $packageRoot 'Install-SguAzureP2sClient.ps1' + if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { + throw 'Install-SguAzureP2sClient.ps1 is missing from the client bootstrap package.' + } + foreach ($vpnInput in @( + @{ Name = 'VpnProfilePackagePath'; Value = $VpnProfilePackagePath }, + @{ Name = 'VpnClientCertificatePfxPath'; Value = $VpnClientCertificatePfxPath }, + @{ Name = 'VpnClientRootCertificatePath'; Value = $VpnClientRootCertificatePath })) { + if ([string]::IsNullOrWhiteSpace([string]$vpnInput.Value)) { + throw "$($vpnInput.Name) is required the first time an Azure P2S client is enrolled." + } + } + $vpnInstallParameters = @{ + VpnProfilePackagePath = $VpnProfilePackagePath + ClientCertificatePfxPath = $VpnClientCertificatePfxPath + ClientRootCertificatePath = $VpnClientRootCertificatePath + ConnectionName = $VpnConnectionName + AzureNetworkPrefixes = $AzureNetworkPrefixes + DomainControllerIPv4Address = $DomainControllerIPv4Address + DomainName = $DomainName + } + if ($VpnClientCertificatePfxPassword) { + $vpnInstallParameters.ClientCertificatePfxPassword = $VpnClientCertificatePfxPassword + } + & $installerPath @vpnInstallParameters | Out-Null + } + $vpnConnection = Connect-SguAzureP2s -ConnectionName $VpnConnectionName + $nrptDisplayName = "SGU Azure P2S DNS - $DomainName" + $nrptRule = Get-DnsClientNrptRule -ErrorAction SilentlyContinue | + Where-Object DisplayName -eq $nrptDisplayName | + Select-Object -First 1 + if (-not $nrptRule -or + @($nrptRule.NameServers) -notcontains $DomainControllerIPv4Address.IPAddressToString) { + throw "The SGU NRPT rule for $DomainName is missing or does not point to $DomainControllerIPv4Address. Re-run Install-SguAzureP2sClient.ps1." + } + $NetworkInterfaceAlias = $vpnConnection.Name +} +else { + $NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias + Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias ` + -ServerAddresses $DomainControllerIPv4Address.IPAddressToString +} if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) { throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP." @@ -292,6 +367,7 @@ try { ComputerOuDn = $ComputerOuDn NetworkInterfaceAlias = $NetworkInterfaceAlias DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString) + ConnectivityMode = $ConnectivityMode RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP" DotNetRuntimeInstallerPath = $runtimeInstaller.FullName RustDeskServerAddress = $serverIdentity.RustDeskServerAddress @@ -356,6 +432,7 @@ finally { } Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue $DomainCredential = $null + $VpnClientCertificatePfxPassword = $null } if ($SkipRestart) { @@ -365,6 +442,8 @@ if ($SkipRestart) { ProviderInstalled = $true ClientCertificateRegistered = $true BrokerEndpoint = $brokerEndpoint + ConnectivityMode = $ConnectivityMode + VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null } RestartRequired = $true RustDesk = if ($result) { $result.RustDesk } else { $null } EnrollmentResult = $result diff --git a/scripts/New-SguAzureP2sCertificates.ps1 b/scripts/New-SguAzureP2sCertificates.ps1 new file mode 100644 index 0000000..9962cfa --- /dev/null +++ b/scripts/New-SguAzureP2sCertificates.ps1 @@ -0,0 +1,89 @@ +#Requires -Version 5.1 +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] + [ValidatePattern('^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$')] + [string]$ClientName, + [string]$OutputDirectory = (Join-Path $PSScriptRoot '..\artifacts\azure-p2s'), + [securestring]$ClientPfxPassword, + [string]$RootSubject = 'CN=SGU Azure P2S Root', + [ValidateRange(1, 10)] + [int]$ClientValidityYears = 2, + [switch]$Force +) + +$ErrorActionPreference = 'Stop' +$resolvedOutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Path $resolvedOutputDirectory -Force | Out-Null +$rootCertificatePath = Join-Path $resolvedOutputDirectory 'sgu-azure-p2s-root.cer' +$clientCertificatePath = Join-Path $resolvedOutputDirectory "sgu-azure-p2s-$ClientName.pfx" +if ((Test-Path -LiteralPath $clientCertificatePath -PathType Leaf) -and -not $Force) { + throw "$clientCertificatePath already exists. Use -Force only when you intend to replace that exported client credential." +} + +if (-not $ClientPfxPassword) { + $ClientPfxPassword = Read-Host 'Password that will protect the exported P2S client certificate' -AsSecureString +} + +$rootCertificate = Get-ChildItem Cert:\CurrentUser\My | + Where-Object { + $_.Subject -eq $RootSubject -and + $_.HasPrivateKey -and + $_.NotAfter -gt (Get-Date).AddYears($ClientValidityYears) + } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + +if (-not $rootCertificate) { + if (-not $PSCmdlet.ShouldProcess($RootSubject, 'Create a non-exportable Azure P2S root certificate authority')) { + return + } + $rootCertificate = New-SelfSignedCertificate ` + -Type Custom ` + -Subject $RootSubject ` + -CertStoreLocation Cert:\CurrentUser\My ` + -KeyAlgorithm RSA ` + -KeyLength 4096 ` + -HashAlgorithm SHA256 ` + -KeySpec Signature ` + -KeyExportPolicy NonExportable ` + -KeyUsage CertSign,CRLSign,DigitalSignature ` + -NotAfter (Get-Date).AddYears(10) ` + -TextExtension @('2.5.29.19={critical}{text}ca=1&pathlength=1') +} + +if (-not $PSCmdlet.ShouldProcess($ClientName, 'Issue and export an Azure P2S machine certificate')) { + return +} + +$clientSubject = "CN=SGU Azure P2S $ClientName" +$clientCertificate = New-SelfSignedCertificate ` + -Type Custom ` + -Subject $clientSubject ` + -DnsName "sgu-p2s-$ClientName" ` + -Signer $rootCertificate ` + -CertStoreLocation Cert:\CurrentUser\My ` + -KeyAlgorithm RSA ` + -KeyLength 3072 ` + -HashAlgorithm SHA256 ` + -KeySpec Signature ` + -KeyExportPolicy Exportable ` + -KeyUsage DigitalSignature ` + -NotAfter (Get-Date).AddYears($ClientValidityYears) ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.2') + +Export-Certificate -Cert $rootCertificate -FilePath $rootCertificatePath -Force | Out-Null +Export-PfxCertificate -Cert $clientCertificate -FilePath $clientCertificatePath ` + -Password $ClientPfxPassword -ChainOption BuildChain -CryptoAlgorithmOption AES256_SHA256 ` + -Force | Out-Null + +[pscustomobject]@{ + RootCertificatePath = $rootCertificatePath + RootCertificateThumbprint = $rootCertificate.Thumbprint + RootCertificateData = [Convert]::ToBase64String($rootCertificate.RawData) + ClientName = $ClientName + ClientCertificatePath = $clientCertificatePath + ClientCertificateThumbprint = $clientCertificate.Thumbprint + ClientCertificateExpires = $clientCertificate.NotAfter + RootPrivateKeyExportable = $false +} diff --git a/scripts/New-SguBootstrapPackages.ps1 b/scripts/New-SguBootstrapPackages.ps1 index 54e75fe..38c6eb9 100644 --- a/scripts/New-SguBootstrapPackages.ps1 +++ b/scripts/New-SguBootstrapPackages.ps1 @@ -81,16 +81,18 @@ New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null $clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version" $serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version" $linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version" +$azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version" $clientZip = "$clientRoot.zip" $serverZip = "$serverRoot.zip" $linuxClientZip = "$linuxClientRoot.zip" -foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$clientZip,$serverZip,$linuxClientZip)) { +$azureZip = "$azureRoot.zip" +foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$azureRoot,$clientZip,$serverZip,$linuxClientZip,$azureZip)) { if (Test-Path -LiteralPath $target) { throw "Release target already exists: $target" } } -New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot -Force | Out-Null +New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot -Force | Out-Null $welcomeFontNames = @( 'IndivisaTextSans-Regular.otf', 'IndivisaTextSans-Bold.otf', @@ -103,6 +105,10 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Invoke-SguClientBootstrap.ps -Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1') Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') ` -Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd') +Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') ` + -Destination (Join-Path $clientRoot 'Start-SguAzureClientEnrollment.cmd') +Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') ` + -Destination (Join-Path $clientRoot 'Install-SguAzureP2sClient.ps1') $clientScripts = @( 'Enable-LabRemoteAccess.ps1', 'Enable-SguClientMonitoring.ps1', @@ -163,6 +169,8 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Initialize-SguDomainControll -Destination (Join-Path $serverRoot 'Initialize-SguDomainController.ps1') Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd') ` -Destination (Join-Path $serverRoot 'Start-SguServerBootstrap.cmd') +Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureServerBootstrap.cmd') ` + -Destination (Join-Path $serverRoot 'Start-SguAzureServerBootstrap.cmd') $serverScripts = @( 'Deploy-AuthBroker.ps1', 'Enable-SguServerRemoteManagement.ps1', @@ -211,10 +219,32 @@ Write-PackageManifest -PackageRoot $serverRoot -PackageVersion $Version -Package Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip ` -CompressionLevel Optimal +# Azure infrastructure is packaged separately because it runs on the trusted +# administrator workstation, not inside the domain controller or a client. +$azureScriptsRoot = Join-Path $azureRoot 'scripts' +$azureInfrastructureRoot = Join-Path $azureRoot 'infra\azure' +New-Item -ItemType Directory -Path $azureScriptsRoot,$azureInfrastructureRoot -Force | Out-Null +Copy-RequiredFile -Source (Join-Path $repositoryRoot 'infra\azure\main.bicep') ` + -Destination (Join-Path $azureInfrastructureRoot 'main.bicep') +foreach ($scriptName in @( + 'New-SguAzureP2sCertificates.ps1', + 'Deploy-SguAzureInfrastructure.ps1', + 'Get-SguAzureP2sPackage.ps1', + 'Install-SguAzureP2sClient.ps1')) { + Copy-RequiredFile -Source (Join-Path $PSScriptRoot $scriptName) ` + -Destination (Join-Path $azureScriptsRoot $scriptName) +} +Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\azure-vpn-deployment.md') ` + -Destination (Join-Path $azureRoot 'README.md') +Write-PackageManifest -PackageRoot $azureRoot -PackageVersion $Version -PackageKind AzureInfrastructure +Compress-Archive -Path (Join-Path $azureRoot '*') -DestinationPath $azureZip ` + -CompressionLevel Optimal + $checksums = @( ("{0} {1}" -f (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash, (Split-Path $clientZip -Leaf)) ("{0} {1}" -f (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash, (Split-Path $serverZip -Leaf)) ("{0} {1}" -f (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash, (Split-Path $linuxClientZip -Leaf)) + ("{0} {1}" -f (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash, (Split-Path $azureZip -Leaf)) ) $checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt" [IO.File]::WriteAllLines($checksumsPath, $checksums, [Text.UTF8Encoding]::new($false)) @@ -227,6 +257,8 @@ $checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt" LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash ServerPackage = $serverZip ServerSha256 = (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash + AzureInfrastructurePackage = $azureZip + AzureInfrastructureSha256 = (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash Checksums = $checksumsPath RuntimeInstaller = $runtimeInstaller.Name } diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 index 32caae0..4368b9b 100644 --- a/scripts/Publish-GiteaRelease.ps1 +++ b/scripts/Publish-GiteaRelease.ps1 @@ -18,6 +18,7 @@ $assetPaths = @( (Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"), + (Join-Path $ReleaseDirectory "sgu-azure-infrastructure-$Version.zip"), (Join-Path $ReleaseDirectory "SHA256SUMS-$Version.txt") ) foreach ($assetPath in $assetPaths) { @@ -112,6 +113,9 @@ 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-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque. - `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows. +- `sgu-azure-infrastructure-$Version.zip`: despliega mediante Bicep una VM Windows Server 2025, red privada, IP pública protegida por NSG y Azure VPN Gateway P2S; también genera certificados por equipo y descarga el perfil de cliente. +- El bootstrap Azure conserva la IP privada administrada por la NIC de Azure, autoriza el pool P2S en los firewalls SGU y nunca publica LDAP, Kerberos, SMB, RPC, WinRM ni el Auth Broker directamente a Internet. +- Los Windows 11 Pro pueden instalar un perfil IKEv2 de todos los usuarios con certificado de máquina, DNS dividido para `lci.lasalle.mx` y ejecutarlo desde la pantalla de inicio de sesión antes de autenticar una cuenta de dominio nueva. - 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 estos grupos de seguridad de forma idempotente. - El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral. - El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker. diff --git a/scripts/Start-SguAzureClientEnrollment.cmd b/scripts/Start-SguAzureClientEnrollment.cmd new file mode 100644 index 0000000..d823f0a --- /dev/null +++ b/scripts/Start-SguAzureClientEnrollment.cmd @@ -0,0 +1,8 @@ +@echo off +setlocal +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 + '"'),'-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" +exit /b %errorlevel% diff --git a/scripts/Start-SguAzureServerBootstrap.cmd b/scripts/Start-SguAzureServerBootstrap.cmd new file mode 100644 index 0000000..f684079 --- /dev/null +++ b/scripts/Start-SguAzureServerBootstrap.cmd @@ -0,0 +1,7 @@ +@echo off +setlocal +set "SGU_BOOTSTRAP_IP=%~1" +set "SGU_VPN_POOL=%~2" +if "%SGU_VPN_POOL%"=="" set "SGU_VPN_POOL=172.30.0.0/24" +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Initialize-SguDomainController.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-NetworkConfigurationMode','PlatformManaged','-TrustedClientNetworks',$env:SGU_VPN_POOL,'-DnsForwarders','168.63.129.16'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-ServerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode" +exit /b %errorlevel% diff --git a/tests/BootstrapNetwork.Tests.ps1 b/tests/BootstrapNetwork.Tests.ps1 new file mode 100644 index 0000000..cec36b2 --- /dev/null +++ b/tests/BootstrapNetwork.Tests.ps1 @@ -0,0 +1,74 @@ +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$serverBootstrapPath = Join-Path $repositoryRoot 'scripts\Initialize-SguDomainController.ps1' +$clientBootstrapPath = Join-Path $repositoryRoot 'scripts\Invoke-SguClientBootstrap.ps1' +$azureClientPath = Join-Path $repositoryRoot 'scripts\Install-SguAzureP2sClient.ps1' +$bicepPath = Join-Path $repositoryRoot 'infra\azure\main.bicep' + +$tokens = $null +$parseErrors = $null +$serverAst = [Management.Automation.Language.Parser]::ParseFile( + $serverBootstrapPath, + [ref]$tokens, + [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { + throw ($parseErrors -join [Environment]::NewLine) +} +$networkFunctionNames = @( + 'Test-PrivateIPv4Address', + 'ConvertTo-NetworkCidr', + 'ConvertTo-PrivateNetworkCidr' +) +$networkFunctions = $serverAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $networkFunctionNames -contains $node.Name + }, $true) +Invoke-Expression (($networkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine) + +Describe 'SGU public-cloud network safety' { + It 'canonicalizes a host address to its IPv4 network' { + ConvertTo-NetworkCidr -Address ([ipaddress]'10.77.0.4') ` + -NetworkPrefixLength 24 | Should Be '10.77.0.0/24' + } + + It 'canonicalizes the trusted P2S pool' { + ConvertTo-PrivateNetworkCidr -Cidr '172.30.4.19/16' | + Should Be '172.30.0.0/16' + } + + It 'rejects a public trusted-client CIDR' { + $wasRejected = $false + try { + ConvertTo-PrivateNetworkCidr -Cidr '8.8.8.0/24' | Out-Null + } + catch { + $wasRejected = $true + } + $wasRejected | Should Be $true + } + + It 'exposes explicit Azure modes on both bootstraps' { + ((Get-Command $serverBootstrapPath).Parameters.Keys -contains + 'NetworkConfigurationMode') | Should Be $true + ((Get-Command $serverBootstrapPath).Parameters.Keys -contains + 'TrustedClientNetworks') | Should Be $true + ((Get-Command $clientBootstrapPath).Parameters.Keys -contains + 'ConnectivityMode') | Should Be $true + ((Get-Command $clientBootstrapPath).Parameters.Keys -contains + 'VpnProfilePackagePath') | Should Be $true + } + + It 'uses an all-user machine-certificate VPN profile' { + $source = Get-Content -LiteralPath $azureClientPath -Raw + $source | Should Match '-AuthenticationMethod MachineCertificate' + $source | Should Match '-AllUserConnection' + $source | Should Match 'Add-DnsClientNrptRule' + } + + It 'limits optional public administration to RDP' { + $template = Get-Content -LiteralPath $bicepPath -Raw + $template | Should Match "name: 'Allow-RDP-from-administrator'" + $template | Should Match "destinationPortRange: '3389'" + $template | Should Not Match "sourceAddressPrefix: '0\.0\.0\.0/0'" + } +}