Tutorial: Azure P2S VPN Resolving Private DNS Zone Records (with Terraform)

João SabinoJoão Sabino
8 min read

Let's say you have Azure DNS Private Zones and you want your VPN clients to resolve those DNS Private Zones using Terraform. You have gone through all the Microsoft articles and a couple of Stack Overflow questions but still have doubts about how to build this PaaS/SaaS environment. Or, let's say you just want to build that environment from scratch. You are in the right place.

This article is also going to guide you through how you can integrate all that using Azure Firewall, substantially increasing the security of the setup. Besides that, you will also learn how to set up forced tunnelling, so that both internal and internet traffic from your VPN clients are also going through Azure Infrastructure (Azure Firewall) and a static public IP.

Prerequisites

As a pre-requisite for this environment, you will need:

The resources that we will be creating are going to be:

  • VPN

    • Consisting of Virtual Wan, Virtual Hub, Azure Firewall
  • DNS Private Zone

    • And VNET Links
  • DNS Private Resolver

    • And Dns Private Resolver Inbound Endpoint

Getting started

This article will use the next basic resources that you will usually already have set up in your infrastructure, which consists of a Resource Group, an Azure Virtual Network, and a Network Security Group. If you are going to use a VNET you already have, make sure that you create a Subnet with the delegations as showed in the "dns" subnet below:

data "azurerm_client_config" "current" {}

resource "azurerm_resource_group" "this" {
  name     = "rg-example"
  location = "West Europe"
}

resource "azurerm_network_security_group" "this" {
  name                = "nsg-example"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
}

resource "azurerm_virtual_network" "this" {
  name                = "vnet-core"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  address_space       = ["10.0.0.0/16"]
}

resource "azurerm_subnet" "this" {
  name                 = "dns"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = ["10.0.1.0/25"]

  delegation {
    name = "Microsoft.Network.dnsResolvers"
    service_delegation {
      actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
      name    = "Microsoft.Network/dnsResolvers"
    }
  }
}

We are also going to be referencing the following variables from our variables.tf file:

variable "location" {
  type    = string
  default = "North Europe"
}

variable "aad_audience" {
  type    = string
  default = "<Your Entra ID Azure VPN App Registration>"
}

Now, let's get started with setting up our VPN, let's start by creating a Virtual Wan:

resource "azurerm_virtual_wan" "this" {
  name                   = "vwan-example"
  resource_group_name    = azurerm_resource_group.this.name
  location               = "northeurope"
  type                   = "Standard"
  disable_vpn_encryption = false
}

An Azure VPN Server Configuration:

resource "azurerm_vpn_server_configuration" "this" {
  name                     = "vsc-example"
  resource_group_name      = azurerm_resource_group.this.name
  location                 = var.location
  vpn_authentication_types = ["AAD"]

  azure_active_directory_authentication {
    tenant   = "https://login.microsoftonline.com/${data.azurerm_client_config.current.tenant_id}"
    audience = var.aad_audience
    issuer   = "https://sts.windows.net/${data.azurerm_client_config.current.tenant_id}/"
  }
}

An Azure Virtual Hub:

resource "azurerm_virtual_hub" "this" {
  name                = "vhub-example"
  resource_group_name = azurerm_resource_group.this.name
  location            = "northeurope"
  virtual_wan_id      = azurerm_virtual_wan.this.id
  address_prefix      = "10.111.0.0/24"
}

An Azure Point to Site VPN Configuration:

resource "azurerm_point_to_site_vpn_gateway" "this" {
  name                        = "vgat-example"
  resource_group_name         = azurerm_resource_group.this.name
  location                    = "northeurope"
  virtual_hub_id              = azurerm_virtual_hub.this.id
  vpn_server_configuration_id = azurerm_vpn_server_configuration.this.id
  scale_unit                  = 1

#   dns_servers = [
#     azurerm_private_dns_resolver_inbound_endpoint.this.ip_configurations.private_ip_address
#   ]

  connection_configuration {
    name                      = "vpnconfig-example"
    internet_security_enabled = true

    vpn_client_address_pool {
      address_prefixes = [
        "10.111.1.0/24"
      ]
    }
  }
}

A Virtual Hub Connection between the VNET and the recently created Virtual Hub. This is an important resource, as it will ensure that VPN clients will have connectivity to the VNET, and it will also allow a future resource we are going to create (Route Intents), to automatically set up the routes on the VPN client devices.

resource "azurerm_virtual_hub_connection" "core" {
  name                      = "vhubconn-core-example"
  virtual_hub_id            = azurerm_virtual_hub.this.id
  remote_virtual_network_id = azurerm_virtual_network.this.id
}

Let's start making this interesting by deploying an Azure Firewall:

resource "azurerm_firewall_policy" "this" {
  name                     = "afwp-example"
  resource_group_name      = azurerm_resource_group.this.name
  location                 = "northeurope"
  sku                      = "Standard"
  threat_intelligence_mode = "Alert"
}

resource "azurerm_firewall_policy_rule_collection_group" "this" {
  name               = "rcg-example"
  firewall_policy_id = azurerm_firewall_policy.this.id
  priority           = 100

  application_rule_collection {
    name     = "DenyMaliciousWebCategories"
    action   = "Deny"
    priority = 200
    rule {
      name        = "Deny-Malicious-Web-Categories"
      description = "Deny access to malicious web categories"
      protocols {
        type = "Https"
        port = 443
      }
      protocols {
        type = "Http"
        port = 80
      }
      terminate_tls    = false
      source_addresses = ["*"]
      web_categories = [
        "ChildAbuseImages",
        "Gambling",
        "HateAndIntolerance",
        "IllegalDrug",
        "IllegalSoftware",
        "Nudity",
        "Violence",
        "Weapons"
      ]
    }
  }

  network_rule_collection {
    name     = "AllowAllToAll"
    action   = "Allow"
    priority = 300

    rule {
      name                  = "Allow-VPN-To-Internet"
      protocols             = ["Any"]
      source_addresses      = ["10.111.1.0/24"]
      destination_addresses = ["0.0.0.0/0"]
      destination_ports     = ["*"]
    }
    rule {
      name                  = "Allow-VPN-To-Internal"
      protocols             = ["Any"]
      source_addresses      = ["10.111.1.0/24"]
      destination_addresses = ["10.101.100.0/22"]
      destination_ports     = ["*"]
    }
  }
}

resource "azurerm_firewall" "this" {
  name                = "afw-example"
  resource_group_name = azurerm_resource_group.this.name
  location            = "northeurope"
  sku_name            = "AZFW_Hub"
  sku_tier            = "Standard"
  virtual_hub {
    virtual_hub_id  = azurerm_virtual_hub.this.id
    public_ip_count = 1
  }
  firewall_policy_id = azurerm_firewall_policy.this.id
  # can't specify public ip: https://github.com/hashicorp/terraform-provider-azurerm/issues/22543
}
💡
Make sure you adapt the WAF Policy rules as per your need!

Perfect! So at this point, we were able to configure a VPN and set up a Firewall and its rules to secure our environment.

However, this firewall is still not set up to be used by our VPN or Virtual Hub. Let's add the route intents (resource mentioned previously), to instruct the Virtual Hub to use the Firewall as the next hop for both internal and internet traffic:

resource "azurerm_virtual_hub_routing_intent" "this" {
  name           = "RoutingIntent"
  virtual_hub_id = azurerm_virtual_hub.this.id

  routing_policy {
    name         = "InternetTrafficPolicy"
    destinations = ["Internet"]
    next_hop     = azurerm_firewall.this.id
  }
  routing_policy {
    name         = "PrivateTrafficPolicy"
    destinations = ["PrivateTraffic"]
    next_hop     = azurerm_firewall.this.id
  }
}

Nice! Now VPN customers will be able to access the internet, and also internal resources. Let's stop here and do some tests, before we proceed with deploying and setting up the DNS Server (Azure DNS Private Resolver) to our VPN.

At this point, the following resources should be created on our Resource Group:

It's time for a quick test, let's connect to our VPN to see if it is working. First let's head into the Virtual WAN, and download the VPN Configuration:

Download and install the Azure VPN Client for Windows or Mac, and import the azurevpnconfig.xml file into the vpn client:

And voilà, we have connection with our VPN:

💡
No worries if DNS Queries/Internet stop working when connected to the VPN, this is because the VPN Client is publishing the route 0.0.0.0, which forces all of your device traffic to go through the VPN server, but hey, your dns server is still just on your local network, so you will be unable to make dns queries.

Right, so let's disconnect from the VPN, because we will need internet connection to proceed with getting a nice DNS Server (Private Resolver) and Private zone working with the VPN.

Let's create a dns private zone called "myownprivatedns" and an A record called "test":

resource "azurerm_private_dns_zone" "this" {
  name                = "myownprivatedns.com"
  resource_group_name = azurerm_resource_group.this.name
}

resource "azurerm_private_dns_a_record" "test" {
  name                = "test"
  zone_name           = azurerm_private_dns_zone.this.name
  resource_group_name = azurerm_resource_group.this.name
  ttl                 = 300
  records             = ["10.0.180.17"]
}

resource "azurerm_private_dns_zone_virtual_network_link" "this" {
  name                  = "pdns-vnet-core-link"
  resource_group_name   = azurerm_resource_group.this.name
  private_dns_zone_name = azurerm_private_dns_zone.this.name
  virtual_network_id    = azurerm_virtual_network.this.id
}

We will also be creating a Private Dns Zone Virtual Network Link to the "vnet-core" that we created previously.

Now, the private DNS zone will be available for any resource like VMs and Kubernetes services running on the VNET where the network link was created to. But to be able to allow VPN Clients to do the same, we will need to create a DNS Private Resolver, and a DNS Private Resolver inbound endpoint to the "dns" subnet we created previously:

resource "azurerm_private_dns_resolver" "this" {
  name                = "pdnsr-example"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  virtual_network_id  = azurerm_virtual_network.this.id
}


resource "azurerm_private_dns_resolver_inbound_endpoint" "this" {
  name                    = "drie-example"
  private_dns_resolver_id = azurerm_private_dns_resolver.this.id
  location                = azurerm_private_dns_resolver.this.location
  ip_configurations {
    private_ip_allocation_method = "Dynamic"
    subnet_id                    = azurerm_subnet.dns.id
  }
}

Wonderful. This is how our resolver group should be looking like:

If we head to the Private Resolver Inbound Endpoint, we should be able to see an IP. That IP is what we will provide to our VPN Clients as a DNS Server IP (make sure Provisioning state shows Succeded).

To do so, let's go back to our code, on the "azurerm_point_to_site_vpn_gateway" resource, let's uncomment the block of code for "dns_servers"

resource "azurerm_point_to_site_vpn_gateway" "this" {
...
  dns_servers = [
    azurerm_private_dns_resolver_inbound_endpoint.this.ip_configurations[0].private_ip_address
  ]
...
}

Let's deploy and then connect to the VPN again. Once connected we should see the new dns server appearing in the right side.

If you navigate to the internet now, it should work. But hey, can we now make dns queries for the DNS Private Zone we created previously?

Yes! Of course we can! We have now an Azure VPN, with tunnelling enabled, an Azure Firewall and DNS Private Zone resolution.

Telnet to the DNS Private Resolver Inbound Endpoint should also work:

There are some things that you should be aware of before we finish:

  • The DNS Private Resolver Inbound Endpoint must be created in a VNET where the DNS Private Zone has a VNET link to, otherwise DNS resolution for that specific DNS Private Zone will fail.

  • Make sure you create appropriate Azure Firewall policies to restrict how much the VPN Clients should have access to the internal network (remember Zero Trust and Network Isolation concepts).

  • If you have a Private DNS Zone domain that also contains a Public DNS Zone, you will need to make sure that the public records also exists on the Private DNS Zone, otherwise the private dns zone won't know the public records, and won't resolve them.

And this marks the end of the tutorial.

Conclusion

In conclusion, this tutorial has guided you through the process of setting up an Azure P2S VPN with DNS Private Zone resolution using Terraform. By following the steps outlined, you have successfully configured a secure VPN, integrated Azure Firewall for enhanced security, and enabled DNS resolution for your VPN clients.

This setup not only ensures secure connectivity but also centralizes DNS management, making your network infrastructure more robust and efficient.

Thank you for following along, and I hope this guide has been helpful. Don't forget to check out the complete code on my Github and subscribe to my newsletter for more tutorials and updates.

0
Subscribe to my newsletter

Read articles from João Sabino directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

João Sabino
João Sabino

I am a passionated DevSecOps engineer. I like to write about Terraform, Kubernetes, DevOps and DevSecOps in general.