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('Deploy the optional Azure Point-to-Site VPN Gateway. Direct public enrollment does not require it.') param deployVpnGateway bool = true @description('Static private IP reserved on the Azure NIC for AD DS and DNS.') param domainControllerPrivateIp string = '10.77.0.4' @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('Public IPv4 CIDRs allowed to enroll clients directly without VPN. Leave empty to expose no AD enrollment ports.') param publicEnrollmentSourceAddressPrefixes array = [] @description('Optional public CIDR allowed to RDP to the VM public IP, for example 203.0.113.10/32. Leave empty to expose no management port.') param administratorSourceAddressPrefix string = '' @description('Deploy private Azure Files shares for SGU user roaming. Clients require P2S or another private route into the VNet.') param deployUserRoaming bool = false @description('Optional globally unique storage account name for roaming data. Leave empty to derive a stable name from the resource group.') param userRoamingStorageAccountName string = '' @description('Azure Files share used for AD/DO FSLogix profile containers.') @minLength(3) @maxLength(63) param fsLogixProfilesShareName string = 'profiles' @description('Azure Files share used for AL Documents and Desktop folder redirection.') @minLength(3) @maxLength(63) param redirectedFoldersShareName string = 'redirected' @description('Quota in GiB for the FSLogix profile-container share.') @minValue(100) @maxValue(102400) param fsLogixProfilesQuotaGiB int = 1024 @description('Quota in GiB for the redirected-folders share.') @minValue(100) @maxValue(102400) param redirectedFoldersQuotaGiB int = 1024 var virtualNetworkName = '${deploymentPrefix}-vnet' var 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' 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 location: location properties: { securityRules: concat(deployVpnGateway ? [ { 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(publicEnrollmentSourceAddressPrefixes) ? [] : [ { name: 'Allow-Direct-AD-TCP' properties: { priority: 110 access: 'Allow' direction: 'Inbound' protocol: 'Tcp' sourcePortRange: '*' destinationPortRanges: [ '53' '88' '135' '389' '443' '445' '464' '636' '3268' '3269' '21115-21117' '49152-65535' ] sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes destinationAddressPrefix: domainControllerPrivateIp description: 'AD, DoH, and RustDesk TCP access for explicitly authorized public enrollment networks.' } } { name: 'Allow-Direct-AD-UDP' properties: { priority: 120 access: 'Allow' direction: 'Inbound' protocol: 'Udp' sourcePortRange: '*' destinationPortRanges: [ '53' '88' '123' '389' '464' '21116' ] sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes destinationAddressPrefix: domainControllerPrivateIp description: 'AD, time, and RustDesk UDP access for explicitly authorized public enrollment networks.' } } { name: 'Allow-Direct-SGU-Enrollment-TCP' properties: { priority: 130 access: 'Allow' direction: 'Inbound' protocol: 'Tcp' sourcePortRange: '*' destinationPortRanges: [ '5985' '8443' ] sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes destinationAddressPrefix: domainControllerPrivateIp description: 'WinRM discovery and SGU broker access for direct enrollment.' } } ], empty(administratorSourceAddressPrefix) ? [] : [ { name: 'Allow-RDP-from-administrator' properties: { priority: 140 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 ] } dhcpOptions: { dnsServers: [ domainControllerPrivateIp ] } subnets: concat([ { name: domainControllerSubnetName properties: { addressPrefix: domainControllerSubnetPrefix privateEndpointNetworkPolicies: deployUserRoaming ? 'Disabled' : null networkSecurityGroup: { id: networkSecurityGroup.id } } } ], deployVpnGateway ? [ { name: gatewaySubnetName properties: { addressPrefix: gatewaySubnetPrefix } } ] : []) } } resource userRoamingStorageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = if (deployUserRoaming) { name: effectiveUserRoamingStorageAccountName location: location tags: { purpose: 'SGU-user-roaming' } sku: { name: 'Standard_LRS' } kind: 'StorageV2' properties: { accessTier: 'Hot' allowBlobPublicAccess: false allowCrossTenantReplication: false allowSharedKeyAccess: true largeFileSharesState: 'Enabled' minimumTlsVersion: 'TLS1_2' publicNetworkAccess: 'Disabled' supportsHttpsTrafficOnly: true networkAcls: { bypass: 'AzureServices' defaultAction: 'Deny' } } } resource userRoamingFileService 'Microsoft.Storage/storageAccounts/fileServices@2023-05-01' = if (deployUserRoaming) { parent: userRoamingStorageAccount name: 'default' properties: { shareDeleteRetentionPolicy: { enabled: true days: 14 } } } resource fsLogixProfilesShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-05-01' = if (deployUserRoaming) { parent: userRoamingFileService name: fsLogixProfilesShareName properties: { accessTier: 'TransactionOptimized' enabledProtocols: 'SMB' shareQuota: fsLogixProfilesQuotaGiB } } resource redirectedFoldersShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-05-01' = if (deployUserRoaming) { parent: userRoamingFileService name: redirectedFoldersShareName properties: { accessTier: 'TransactionOptimized' enabledProtocols: 'SMB' shareQuota: redirectedFoldersQuotaGiB } } resource azureFilesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (deployUserRoaming) { name: azureFilesPrivateDnsZoneName location: 'global' } resource azureFilesPrivateDnsVnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = if (deployUserRoaming) { parent: azureFilesPrivateDnsZone name: '${deploymentPrefix}-vnet-link' location: 'global' properties: { registrationEnabled: false virtualNetwork: { id: virtualNetwork.id } } } resource userRoamingPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = if (deployUserRoaming) { name: userRoamingPrivateEndpointName location: location properties: { subnet: { id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, domainControllerSubnetName) } privateLinkServiceConnections: [ { name: 'azure-files' properties: { groupIds: [ 'file' ] privateLinkServiceId: userRoamingStorageAccount.id } } ] } dependsOn: [ virtualNetwork ] } resource userRoamingPrivateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = if (deployUserRoaming) { parent: userRoamingPrivateEndpoint name: 'default' properties: { privateDnsZoneConfigs: [ { name: 'azure-files' properties: { privateDnsZoneId: azureFilesPrivateDnsZone.id } } ] } dependsOn: [ azureFilesPrivateDnsVnetLink ] } resource domainControllerPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = { name: domainControllerPublicIpName location: location sku: { name: 'Standard' } properties: { publicIPAllocationMethod: 'Static' publicIPAddressVersion: 'IPv4' idleTimeoutInMinutes: 30 } } resource gatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = if (deployVpnGateway) { name: gatewayPublicIpName location: location zones: [ '1' '2' '3' ] 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' // AD DS requires durable writes; the bootstrap stores NTDS on this disk. caching: 'None' 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' = if (deployVpnGateway) { 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: 'VpnGw1AZ' tier: 'VpnGw1AZ' } vpnClientConfiguration: { vpnClientAddressPool: { addressPrefixes: [ vpnClientAddressPoolPrefix ] } vpnClientProtocols: [ 'IkeV2' 'OpenVPN' ] 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 = 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 '-PrefixLength' last(split(domainControllerSubnetPrefix, '/')) '-NetworkConfigurationMode' 'PlatformManaged' '-DnsForwarders' '168.63.129.16' ], deployVpnGateway ? [ '-TrustedClientNetworks' vpnClientAddressPoolPrefix ] : [], empty(publicEnrollmentSourceAddressPrefixes) ? [] : concat([ '-PublicEnrollmentNetworks' ], publicEnrollmentSourceAddressPrefixes))