azure/Detonate-VM.psm1

# Taken and modified from https://github.com/stevenjudd/safelydetonate/tree/main
# Requires -Modules Az.Accounts, Az.Compute, Az.Resources, Az.Network

# try to import required modules and if not found, install them, but ask user first
$requiredModules = @('Az.Accounts', 'Az.Compute', 'Az.Resources', 'Az.Network')
$missingModules = @()

foreach ($module in $requiredModules) {
  if (-not (Get-Module -ListAvailable -Name $module)) {
    $missingModules += $module
  }
}

if ($missingModules) {
  $installModules = Read-Host -Prompt "The following modules are missing: $($missingModules -join ', '). Do you want to install them? (Y/N)"
  if ($installModules -eq 'Y') {
    Install-Module -Name $missingModules -AllowClobber -Force
  }
  else {
    Write-Host "Required modules not found. Exiting." -ForegroundColor Red
    return
  }
}

function JRE-AzureDetonateVmHelp {
  Write-Host "`n=== Azure Detonate VM Module Loaded ===" -ForegroundColor Cyan
  Write-Host "`nExample: Create a new VM" -ForegroundColor Yellow
  Write-Host 'JRE-AzureDetonateVmCreate -EmailRecipient "me@jranck.com"'

  Write-Host "`nExample: Remove a VM" -ForegroundColor Yellow
  Write-Host "JRE-AzureDetonateVmRemove"
  Write-Host ""
}



function JRE-AzureDetonateVmCreate {
  param(
    [parameter(Mandatory)]
    [ValidatePattern('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')]
    [string]$EmailRecipient,

    [securestring]$VMLocalAdminSecurePassword,

    [string]$Subscription = "29e1c74a-acb0-4547-b7e2-e5077025d6ed",

    [string]$VMLocalAdminUser = 'vmAdmin',

    [string]$LocationName = "northcentralus",

    [ValidateLength(1, 10)]
    [string]$UserName,

    [ValidateLength(1, 5)]
    [string]$VmRootName = 'DetVM',

    [ValidateRange(10, 11)]
    [string]$OsVersion = 11,

    [string]$VmSize = 'Standard_D4as_v6',

    [hashtable]$ResourceGroupTag = @{
      'Supervisor'       = 'Da Boss'
      'Manager'          = 'Big Boss'
      'Support Group'    = 'Digital Security'
      'Application Name' = 'Digital Security Detonate OS'
    },

    [switch]$WaitDebugger
  )

  # Function to validate password complexity and clear memory
  # DOES NOT WORK ON LINUX, THUS IS COMMENTED OUT UNTIL IT GETS FIXED
  # function Test-PasswordComplexity {
  # param (
  # [securestring]$SecurePassword
  # )

  # # Convert the SecureString to plain-text
  # $passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecurePassword)
  # $plainTextPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($passwordPointer)

  # try {
  # # Validate the password complexity
  # $complexityRequirementsMet = $(
  # $plainTextPassword -match '\d' -and # Contains a digit
  # $plainTextPassword -match '[A-Z]' -and # Contains an uppercase letter
  # $plainTextPassword -match '[a-z]' -and # Contains a lowercase letter
  # $plainTextPassword -match '[!@#$%^&*(),.?":{}|<>[\]]' -and # Contains a special character
  # ($plainTextPassword.Length -ge 14) # Minimum length of 8 characters
  # )

  # if ($complexityRequirementsMet) {
  # $true
  # } else {
  # $false
  # }
  # } finally {
  # # Clear the password from memory
  # [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer)
  # }
  # } # end function Test-PasswordComplexity

  # if (-not (Test-PasswordComplexity -SecurePassword $VMLocalAdminSecurePassword)) {
  # throw 'The provided password does not meet the complexity requirements.'
  # }
  
  Connect-AzAccount -Subscription $Subscription -ErrorAction Stop
  
  if (-not $VMLocalAdminSecurePassword) {
    do {
      $firstPassword = Read-Host -Prompt "Enter VM Admin Password" -AsSecureString
      $confirmPassword = Read-Host -Prompt "Confirm VM Admin Password" -AsSecureString

      $firstPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($firstPassword)
      $confirmPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($confirmPassword)
      try {
        $firstPlain = [Runtime.InteropServices.Marshal]::PtrToStringAuto($firstPtr)
        $confirmPlain = [Runtime.InteropServices.Marshal]::PtrToStringAuto($confirmPtr)
        $passwordsMatch = $firstPlain -eq $confirmPlain
      }
      finally {
        [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($firstPtr)
        [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($confirmPtr)
      }

      if (-not $passwordsMatch) {
        Write-Warning "Passwords do not match. Please try again."
      }
    } while (-not $passwordsMatch)

    $VMLocalAdminSecurePassword = $firstPassword
  }

  # check VvSize value
  if ($VmSize -notin (Get-AzComputeResourceSku -Location $LocationName).Name) {
    throw "Unable to find VmSize $VmSize in Datacenter $LocationName"
  }
  
  # $ErrorActionPreference = 'Stop'
  if ($UserName) {
    $userNameTrim = $UserName
  }
  else {
    $userNameTrim = switch ($true) {
      $IsLinux {
        $env:USER
      }
      $IsMacOS {
        $env:USER
      }
      $IsWindows {
        $env:USERNAME
      }
      default {
        $env:USERNAME
      }
    }
  }

  if ($userNameTrim -match '[^a-z0-9]') {
    Write-Verbose "Remove non-alphanumeric characters from username: $userNameValue"
    $userNameTrim = $userNameTrim.ToLower() -replace '[^a-z0-9]', ''
  }
  if ($userNameTrim.Length -gt 10) {
    Write-Verbose "Trim username '$userNameAlphaNum' to 10 characters"
    $userNameTrim = $userNameTrim.Substring(0, 10)
  }
  
  $detName = $VmRootName + $userNameTrim
  Write-Verbose "Use '$detName' as the RG and VM name"
  $resourceGroupName = "$detName"
  $vmName = "$detName"
  $vmPublisherName = 'MicrosoftWindowsDesktop'
  $vmOffer = "Windows-$OsVersion"
  $getAzVMImageSkuParam = @{
    Location      = "$LocationName"
    PublisherName = 'MicrosoftWindowsDesktop'
    Offer         = $vmOffer
  }
  if ($WaitDebugger) {
    Wait-Debugger
  }
  $vmSkus = Get-AzVMImageSku @getAzVMImageSkuParam | 
  Where-Object 'Skus' -Match 'win.*-pro$' | 
  Sort-Object 'Skus' | 
  Select-Object -Last 1 -ExpandProperty 'Skus'
  $vmVersion = 'latest'

  $networkName = "$detName-vnet"
  $nicName = "$detName-nic"
  $subnetName = "$detName-subnet"
  $subnetAddressPrefix = '10.0.0.0/24'
  $vnetAddressPrefix = '10.0.0.0/24'
  $publicIpAddress = "$detName-publicip"
  $networkSecurityGroupName = "$detName-nsg"

  if (-not (Get-AzSubscription -ErrorAction SilentlyContinue)) {
    throw 'Unable to get Azure Subscription. Please connect using Add-AzAccount.'
  }
  
  Write-Verbose "Setting the AzContext to the '$Subscription' subscription"
  try {
    Set-AzContext -Subscription $Subscription -ErrorAction Stop
  }
  catch {
    throw "Unable to set the AzContext to $Subscription"
  }
  $getAzVmParam = @{
    'ResourceGroupName' = $resourceGroupName
    'Name'              = $vmName
    'ErrorAction'       = 'SilentlyContinue'
  }
  if (Get-AzVM @getAzVmParam) {
    Write-Warning 'VM already exists'
    return
  }

  $getAzReourceGroupParam = @{
    'Name'        = $resourceGroupName
    'ErrorAction' = 'SilentlyContinue'
  }
  if (Get-AzResourceGroup @GetAzReourceGroupParam) {
    Write-Warning 'ResourceGroup already exists'
  }
  else {
    $newAzResourceGroupParam = @{
      Name     = $resourceGroupName
      Location = $LocationName
      Tag      = $ResourceGroupTag
    }
    New-AzResourceGroup @newAzResourceGroupParam
  }

  #region Create security rules
  # $securityRules = @()
  $priority = 100

  # base params

  $newAzNetworkSecurityRuleConfigParam = @{
    'Access'                   = 'Allow' 
    'Protocol'                 = 'Tcp'
    'Direction'                = 'Inbound'
    'SourceAddressPrefix'      = 'Internet'
    'SourcePortRange'          = '*'
    'DestinationAddressPrefix' = '*'
  }

  # Enable to allow RDP traffic
  $newAzNetworkSecurityRuleConfigParam.Name = 'rdp-rule'
  $newAzNetworkSecurityRuleConfigParam.Description = 'Allow RDP'
  $newAzNetworkSecurityRuleConfigParam.DestinationPortRange = 3389
  $newAzNetworkSecurityRuleConfigParam.Priority = $priority
  $securityRules += New-AzNetworkSecurityRuleConfig @newAzNetworkSecurityRuleConfigParam
  $priority++
  <#
  # Enable to allow http traffic
  $newAzNetworkSecurityRuleConfigParam.Name = 'http-rule'
  $newAzNetworkSecurityRuleConfigParam.Description = 'Allow HTTP'
  $newAzNetworkSecurityRuleConfigParam.Priority = $priority
  $newAzNetworkSecurityRuleConfigParam.DestinationPortRange = 80
  $securityRules += New-AzNetworkSecurityRuleConfig @newAzNetworkSecurityRuleConfigParam
  $priority++
  # Enable to allow https traffic
  $newAzNetworkSecurityRuleConfigParam.Name = 'https-rule'
  $newAzNetworkSecurityRuleConfigParam.Description = 'Allow HTTPS'
  $newAzNetworkSecurityRuleConfigParam.Priority = $priority
  $newAzNetworkSecurityRuleConfigParam.DestinationPortRange = 443
  $securityRules += New-AzNetworkSecurityRuleConfig @newAzNetworkSecurityRuleConfigParam
  $priority++
  #>


  #endregion Create security rules

  # Apply security rules
  $newAzNetworkSecurityGroupParam = @{
    'Name'              = $networkSecurityGroupName
    'ResourceGroupName' = $resourceGroupName
    'Location'          = $LocationName
    'SecurityRules'     = $securityRules
  }
  $nsg = New-AzNetworkSecurityGroup @newAzNetworkSecurityGroupParam

  $newAzVirtualNetworkSubnetConfigParam = @{
    'Name'          = $subnetName
    'AddressPrefix' = $subnetAddressPrefix
  }
  $singleSubnet = New-AzVirtualNetworkSubnetConfig @newAzVirtualNetworkSubnetConfigParam

  $newAzVirtualNetworkParam = @{
    'Name'              = $networkName
    'ResourceGroupName' = $resourceGroupName
    'Location'          = $LocationName
    'AddressPrefix'     = $vnetAddressPrefix
    'Subnet'            = $singleSubnet
  }
  $vnet = New-AzVirtualNetwork @newAzVirtualNetworkParam

  $newAzPublicIpAddressParam = @{
    'Name'              = $publicIpAddress
    'ResourceGroupName' = $resourceGroupName
    'AllocationMethod'  = 'Static'
    'Location'          = $LocationName
  }
  $publicIp = New-AzPublicIpAddress @newAzPublicIpAddressParam

  $newAzNetworkInterfaceParam = @{
    'Name'                   = $nicName
    'ResourceGroupName'      = $resourceGroupName
    'Location'               = $LocationName
    'SubnetId'               = $vnet.Subnets[0].Id
    'PublicIpAddressId'      = $publicIp.Id
    'NetworkSecurityGroupId' = $nsg.Id
  }
  $nic = New-AzNetworkInterface @newAzNetworkInterfaceParam


  $credential = New-Object System.Management.Automation.PSCredential (
    $VMLocalAdminUser, $VMLocalAdminSecurePassword
  )

  $newAzVMConfigParam = @{
    'VMName' = $vmName
    'VMSize' = $VMSize
  }
  $virtualMachine = New-AzVMConfig @newAzVMConfigParam
  
  $setAzVMOperatingSystemParam = @{
    'VM'               = $virtualMachine
    'Windows'          = $true
    'ComputerName'     = $vmName
    'Credential'       = $credential
    'ProvisionVMAgent' = $true
    'EnableAutoUpdate' = $true
  }
  $virtualMachine = Set-AzVMOperatingSystem @setAzVMOperatingSystemParam

  
  $addAzVMNetworkInterfaceParam = @{
    'VM' = $virtualMachine
    'Id' = $nic.Id
  }
  $virtualMachine = Add-AzVMNetworkInterface @addAzVMNetworkInterfaceParam
  
  $setAzVMSourceImageParam = @{
    'VM'            = $virtualMachine
    'PublisherName' = $vmPublisherName
    'Offer'         = $vmOffer
    'Skus'          = $vmSkus
    'Version'       = $vmVersion
  }
  $virtualMachine = Set-AzVMSourceImage @setAzVMSourceImageParam

  # Disable boot diagnostics
  $setAzVMBootDiagnostic = @{
    'VM'      = $virtualMachine
    'Disable' = $true
  }
  $virtualMachine = Set-AzVMBootDiagnostic @setAzVMBootDiagnostic

  Write-Host '===========================' -ForegroundColor Green
  Write-Host 'Creating VM. Please wait...'
  Write-Host '===========================' -ForegroundColor Green
  $newAzVMParam = @{
    'ResourceGroupName' = $resourceGroupName
    'Location'          = $LocationName
    'VM'                = $virtualMachine
    'Verbose'           = $true
  }
  if ($WaitDebugger) {
    Wait-Debugger
  }
  New-AzVM @newAzVMParam
  $newVm = Get-AzVM -ResourceGroupName $resourceGroupName -Name $vmName

  Write-Host '============================' -ForegroundColor Green
  Write-Host 'Creating autoshutdown object'
  Write-Host '============================' -ForegroundColor Green

  # create autoshutdown object
  $search = 'Microsoft\.Compute\/virtualMachines\/'
  $replace = 'microsoft.devtestlab/schedules/shutdown-computevm-'
  $shutDownResourceId = $newVm.Id -replace $search, $replace
  $twoHoursLater = (Get-Date).ToUniversalTime().AddHours(2) | Get-Date -Format 'HHmm'
  $shutDownResourceProperties = @{
    'Status'               = 'Enabled'
    'TaskType'             = 'ComputeVmShutdownTask'
    'DailyRecurrence'      = @{'time' = "$twoHoursLater" }
    'TimeZoneId'           = 'UTC'
    'NotificationSettings' = @{
      'Status'             = 'Enabled'
      'TimeInMinutes'      = 30
      'EmailRecipient'     = "$EmailRecipient"
      'NotificationLocale' = 'en'
    }
    'TargetResourceId'     = $newVm.Id
  }
  $newAzResourceParams = @{
    'ResourceId' = $shutDownResourceId
    'Location'   = $LocationName
    'Properties' = $shutDownResourceProperties
    'Force'      = $true
  }
  New-AzResource @newAzResourceParams

  Write-Host '=================================' -ForegroundColor Green
  Write-Host 'Setting Edge First Run Experience'
  Write-Host '=================================' -ForegroundColor Green

  # # Setting Edge First Run Experience
  # $vmSetupScript = {
  # #New-Item -Path HKLM:\SOFTWARE\Microsoft\Edge -ItemType Directory
  # $newItemPropertyParams1 = @{
  # 'Path' = 'HKLM:\SOFTWARE\Microsoft\Edge'
  # 'Name' = 'HideFirstRunExperience'
  # 'Value' = 1
  # 'PropertyType' = 'DWORD'
  # }
  # New-ItemProperty @newItemPropertyParams1

  # $newItemPropertyParams2 = @{
  # 'Path' = 'HKLM:\SOFTWARE\Microsoft\Edge'
  # 'Name' = 'HomepageLocation'
  # 'Value' = 'about:blank'
  # 'PropertyType' = 'String'
  # }
  # New-ItemProperty @newItemPropertyParams2
  # }

  # $invokeAzVMRunCommandParams = @{
  # 'ResourceGroupName' = $resourceGroupName
  # 'Name' = $vmName
  # 'CommandId' = 'RunPowerShellScript'
  # 'ScriptString' = $vmSetupScript
  # }
  # Invoke-AzVMRunCommand @invokeAzVMRunCommandParams

  Invoke-AzVMRunCommand -ResourceGroupName $resourceGroupName -VMName $vmName -CommandId 'RunPowerShellScript' -ScriptString "if (-not (Test-Path 'HKLM:\Software\Policies\Microsoft\Edge')) {New-Item -Path 'HKLM:\Software\Policies\Microsoft\Edge' -Force}"
  Invoke-AzVMRunCommand -ResourceGroupName $resourceGroupName -VMName $vmName -CommandId 'RunPowerShellScript' -ScriptString "if (-not (Test-Path 'HKLM:\Software\Policies\Microsoft\Windows\OOBE')) {New-Item -Path 'HKLM:\Software\Policies\Microsoft\Windows\OOBE' -Force}"
  Invoke-AzVMRunCommand -ResourceGroupName $resourceGroupName -VMName $vmName -CommandId 'RunPowerShellScript' -ScriptString "New-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Edge' -Name 'HideFirstRunExperience' -Value '1' -PropertyType DWord -Force"
  Invoke-AzVMRunCommand -ResourceGroupName $resourceGroupName -VMName $vmName -CommandId 'RunPowerShellScript' -ScriptString "New-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\OOBE' -Name 'DisablePrivacyExperience' -Value '1' -PropertyType DWord -Force"
 

  # connect via Remote Desktop
  $vmIpAddress = $((Get-AzPublicIpAddress -ResourceName $publicIpAddress).IpAddress)
  switch ($true) {
    $IsLinux {
      $doneMessage = "Run mstsc and connect as .\'$VMLocalAdminUser' to $vmIpAddress"
    }
    $IsMacOS {
      $doneMessage = "Run mstsc and connect as .\'$VMLocalAdminUser' to $vmIpAddress"
    }
    $IsWindows {
      $doneMessage = "Connect as .\'$VMLocalAdminUser' to $vmIpAddress"
      mstsc /v:$vmIpAddress /prompt
    }
    default {
      $doneMessage = "Connect as .\'$VMLocalAdminUser' to $vmIpAddress"
      mstsc /v:$vmIpAddress /prompt
    }
  }

  Write-Host $('↓' * $($doneMessage.length)) -ForegroundColor Green
  Write-Host $doneMessage
  Write-Host $('↑' * $($doneMessage.length)) -ForegroundColor Green

  Write-Host ''
  Write-Host 'Remember to run JRE-AzureDetonateVmRemove when done to remove the VM' -ForegroundColor Magenta
}

# $NewAzureWin10VmParam = @{
# 'EmailRecipient' = (Read-Host -Prompt 'Enter email to notify about shutdown')
# 'VMLocalAdminSecurePassword' = (Read-Host -Prompt 'Enter password for the Admin account' -AsSecureString)
# 'Subscription' = 'NotFree'
# 'VMLocalAdminUser' = 'vmAdmin'
# 'LocationName' = 'eastus'
# }
# New-AzureWin10Vm @NewAzureWin10VmParam


function JRE-AzureDetonateVmRemove {
  param(
    [string]$UserName,

    [ValidateLength(1, 5)]
    [string]$VmRootName = 'DetVM'
  )

  if ($UserName) {
    $userNameTrim = $UserName
  }
  else {
    $userNameTrim = switch ($true) {
      $IsLinux {
        $env:USER
      }
      $IsMacOS {
        $env:USER
      }
      $IsWindows {
        $env:USERNAME
      }
      default {
        $env:USERNAME
      }
    }
  }

  if ($userNameTrim -match '[^a-z0-9]') {
    Write-Verbose "Remove non-alphanumeric characters from username: $userNameValue"
    $userNameTrim = $userNameTrim.ToLower() -replace '[^a-z0-9]', ''
  }
  if ($userNameTrim.Length -gt 10) {
    Write-Verbose "Trim username '$userNameAlphaNum' to 10 characters"
    $userNameTrim = $userNameTrim.Substring(0, 10)
  }
  
  $nameRoot = $VmRootName + $userNameTrim

  try {
    if (
      Get-AzResourceGroup -Name $nameRoot -ErrorAction Stop | 
      Remove-AzResourceGroup -ErrorAction Stop -Verbose
    ) {
      Write-Host "Successfully removed VM: $nameRoot" -ForegroundColor Green
    }
  }
  catch {
    # Wait-Debugger
    # Get-AzVM | Select-Object -Property ResourceGroupName,Name
    # throw $_
    Write-Error "Cannot find and/or remove ResourceGroup: $nameRoot"
    return
  }
} #end function JRE-AzureDetonateVmRemove

Export-ModuleMember -Function 'JRE-AzureDetonateVmHelp', 'JRE-AzureDetonateVmCreate', 'JRE-AzureDetonateVmRemove'