Azure Hub and Spoke Network

Today’s Weather: 72 and Sunny today. Spring is here.

I created an Azure Hub and Spoke network with Terraform featuring: A Hub VNet and two Spokes, an Azure Firewall, Bastion for SSH and RDP access, and a VPN Gateway to connect on-prem resources to my Azure network.

Code blocks below are collapsed by default to save space.

Link to Github Repo

Design

Test

The design is very simple, and very similar to what’s found in Microsoft’s documentation.

Variables

Just a few variables to show.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
variable "project_name" {
  type    = string
  default = "hub-spoke-test"
}

variable "location" {
  type    = string
  default = "southcentralus"
}

variable "admin_username" {
  type      = string
  sensitive = true
}

variable "vpn_psk" {
  type      = string
  sensitive = true
}

Hub VNet

The Hub VNet defines the Hub address space and has subnets for our shared services. Microsoft requires subnets for Azure Firewalls, Bastion, and VPNGateway to have specific names like “AzureFirewallSubnet.”

One thing of note here is that we have two subnets for the Azure Firewall, one for the firewall itself and one for a separate management interface. More on that later.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
resource "azurerm_virtual_network" "hub_vnet" {
  name                = "${var.project_name}-hub-vnet"
  resource_group_name = azurerm_resource_group.rg.name
  location            = var.location
  address_space       = ["10.0.0.0/16"]
}

resource "azurerm_subnet" "firewall_snet" {
  name                 = "AzureFirewallSubnet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.hub_vnet.name
  address_prefixes     = ["10.0.1.0/26"]

}


resource "azurerm_subnet" "vpngateway_snet" {
  name                 = "GatewaySubnet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.hub_vnet.name
  address_prefixes     = ["10.0.2.0/26"]
}


resource "azurerm_subnet" "bastion_snet" {
  name                 = "AzureBastionSubnet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.hub_vnet.name
  address_prefixes     = ["10.0.3.0/26"]

}

resource "azurerm_subnet" "firewall_mgmt_snet" {
  name                 = "AzureFirewallManagementSubnet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.hub_vnet.name
  address_prefixes     = ["10.0.4.0/26"]
}

Spokes

The “Prod” and “Dev” spokes both have Ubuntu Server VMs for testing, and NSGs that allow SSH traffic from the Bastion subnet.

Prod VNet code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

resource "azurerm_virtual_network" "prod_vnet" {
  resource_group_name = azurerm_resource_group.rg.name
  location            = var.location
  name                = "${var.project_name}-prod-vnet"
  address_space       = ["10.1.0.0/16"]
}

resource "azurerm_subnet" "prod_snet" {
  name                 = "${var.project_name}-prod-snet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.prod_vnet.name
  address_prefixes     = ["10.1.1.0/24"]
}

resource "azurerm_network_security_group" "prod_nsg" {
  name                = "Prod-Snet-NSG"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_network_security_rule" "prod_bastion_ssh" {
  name                        = "allow-bastion-ssh"
  priority                    = 100
  direction                   = "Inbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = "22"
  source_address_prefixes     = azurerm_subnet.bastion_snet.address_prefixes
  destination_address_prefix  = "*"
  resource_group_name         = azurerm_resource_group.rg.name
  network_security_group_name = azurerm_network_security_group.prod_nsg.name
}

resource "azurerm_subnet_network_security_group_association" "prod_nsg_assoc" {
  subnet_id                 = azurerm_subnet.prod_snet.id
  network_security_group_id = azurerm_network_security_group.prod_nsg.id
}

resource "azurerm_network_interface" "prodvm_vnic" {
  name                = "${var.project_name}-prodvm-vnic"
  location            = var.location
  resource_group_name = azurerm_resource_group.rg.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.prod_snet.id
    private_ip_address_allocation = "Dynamic"
  }
}

resource "azurerm_linux_virtual_machine" "prod_vm" {
  name                = "prod-vm"
  resource_group_name = azurerm_resource_group.rg.name
  location            = var.location
  size                = "Standard_D2s_v3"
  admin_username      = var.admin_username
  network_interface_ids = [
    azurerm_network_interface.prodvm_vnic.id
  ]
  admin_ssh_key {
    username   = var.admin_username
    public_key = file("~/.ssh/id_ed25519.pub")
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts-gen2"
    version   = "latest"
  }
}

Dev VNet Code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
resource "azurerm_virtual_network" "dev_vnet" {
  resource_group_name = azurerm_resource_group.rg.name
  location            = var.location
  name                = "${var.project_name}-dev-net"
  address_space       = ["10.2.0.0/16"]
}

resource "azurerm_subnet" "dev_snet" {
  name                 = "${var.project_name}-dev-snet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.dev_vnet.name
  address_prefixes     = ["10.2.1.0/24"]
}

resource "azurerm_network_security_group" "dev_nsg" {
  name                = "Dev-Snet-NSG"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_network_security_rule" "dev_bastion_ssh" {
  name                        = "allow-bastion-ssh"
  priority                    = 100
  direction                   = "Inbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = "22"
  source_address_prefixes     = azurerm_subnet.bastion_snet.address_prefixes
  destination_address_prefix  = "*"
  resource_group_name         = azurerm_resource_group.rg.name
  network_security_group_name = azurerm_network_security_group.dev_nsg.name
}

resource "azurerm_subnet_network_security_group_association" "dev_nsg_assoc" {
  subnet_id                 = azurerm_subnet.dev_snet.id
  network_security_group_id = azurerm_network_security_group.dev_nsg.id
}

resource "azurerm_network_interface" "devvm_vnic" {
  name                = "${var.project_name}-devvm-vnic"
  location            = var.location
  resource_group_name = azurerm_resource_group.rg.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.dev_snet.id
    private_ip_address_allocation = "Dynamic"
  }
}

resource "azurerm_linux_virtual_machine" "dev_vm" {
  name                = "dev-vm"
  resource_group_name = azurerm_resource_group.rg.name
  location            = var.location
  size                = "Standard_D2s_v3"
  admin_username      = var.admin_username
  network_interface_ids = [
    azurerm_network_interface.devvm_vnic.id
  ]
  admin_ssh_key {
    username   = var.admin_username
    public_key = file("~/.ssh/id_ed25519.pub")
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts-gen2"
    version   = "latest"
  }
}

Peering and Routing

With the Hub and Spoke VNets established, we need to allow them to talk to each other. You can create Virtual Network Peerings to allow this communication, as by default two Azure VNets cannot talk to each other.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
resource "azurerm_virtual_network_peering" "hub_to_prod" {
  name                      = "${var.project_name}-peer-hub-to-prod"
  resource_group_name       = azurerm_resource_group.rg.name
  virtual_network_name      = azurerm_virtual_network.hub_vnet.name
  remote_virtual_network_id = azurerm_virtual_network.prod_vnet.id
  allow_forwarded_traffic   = true
}

resource "azurerm_virtual_network_peering" "prod_to_hub" {
  name                      = "${var.project_name}-peer-prod-to-hub"
  resource_group_name       = azurerm_resource_group.rg.name
  virtual_network_name      = azurerm_virtual_network.prod_vnet.name
  remote_virtual_network_id = azurerm_virtual_network.hub_vnet.id
  allow_forwarded_traffic   = true
}

resource "azurerm_virtual_network_peering" "hub_to_dev" {
  name                      = "${var.project_name}-peer-hub-to-dev"
  resource_group_name       = azurerm_resource_group.rg.name
  virtual_network_name      = azurerm_virtual_network.hub_vnet.name
  remote_virtual_network_id = azurerm_virtual_network.dev_vnet.id
  allow_forwarded_traffic   = true
}

resource "azurerm_virtual_network_peering" "dev_to_hub" {
  name                      = "${var.project_name}-peer-dev-to-hub"
  resource_group_name       = azurerm_resource_group.rg.name
  virtual_network_name      = azurerm_virtual_network.dev_vnet.name
  remote_virtual_network_id = azurerm_virtual_network.hub_vnet.id
  allow_forwarded_traffic   = true
}

Once the Peering is in place, I want to force all traffic between the subnets through the Firewall. This way we can have a central point of control and capture for any traffic. I made simple default routes here to force any traffic between the Spokes through the firewall, and also forcing traffic from my on prem network through the firewall.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#----------------------------------------------------
# Routes forcing spoke traffic through AZ-FW
#----------------------------------------------------
resource "azurerm_route_table" "route_table" {
  name                = "${var.project_name}-subnet-route-table"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  route {
    name                   = "default-route"
    address_prefix         = "0.0.0.0/0"
    next_hop_type          = "VirtualAppliance"
    next_hop_in_ip_address = azurerm_firewall.firewall.ip_configuration[0].private_ip_address
  }
}

resource "azurerm_subnet_route_table_association" "prod_snet_routes" {
  subnet_id      = azurerm_subnet.prod_snet.id
  route_table_id = azurerm_route_table.route_table.id
}

resource "azurerm_subnet_route_table_association" "dev_snet_routes" {
  subnet_id      = azurerm_subnet.dev_snet.id
  route_table_id = azurerm_route_table.route_table.id
}

#----------------------------------------------------
# Routes forcing traffic from gateway to Azure networks through AZ-FW
#----------------------------------------------------

resource "azurerm_route_table" "vpngw_route_table" {
  name                = "${var.project_name}-vpngw-route-table"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  route {
    name                   = "vpngw-to-prod-spoke"
    address_prefix         = "10.1.0.0/16"
    next_hop_type          = "VirtualAppliance"
    next_hop_in_ip_address = azurerm_firewall.firewall.ip_configuration[0].private_ip_address
  }

  route {
    name                   = "vpngw-to-dev-spoke"
    address_prefix         = "10.2.0.0/16"
    next_hop_type          = "VirtualAppliance"
    next_hop_in_ip_address = azurerm_firewall.firewall.ip_configuration[0].private_ip_address
  }

}

resource "azurerm_subnet_route_table_association" "vpngw_routes" {
  subnet_id      = azurerm_subnet.vpngateway_snet.id
  route_table_id = azurerm_route_table.vpngw_route_table.id
}

Firewall

For cost reasons, I went with the Basic Firewall sku. This sku actually requires you to use two public IPs instead of one, and it’s also the reason I have a separate “AzureFirewallManagementSubnet”.

The best reason I could find for this online is that the Basic sku has a max throughput of 250Mbps. Microsoft wants to have a traffic free dedicated path to your firewall for health monitoring and updates. The idea seems to be that because 250Mbps is such a small amount of throughput, they want to ensure they can always reach your firewall for their backend tasks, even if your traffic limit is saturated.

Outside of that, this block defines the firewall, public IPs, and rules to allow traffic between spokes, the internet, and my on-prem network.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
resource "azurerm_public_ip" "firewall_ip" {
  name                = "${var.project_name}-firewall-ip"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

resource "azurerm_public_ip" "fw_mgmt_ip" {
  name                = "${var.project_name}-fw-mgmt-ip"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

resource "azurerm_firewall" "firewall" {
  name                = "${var.project_name}-fw"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  sku_name            = "AZFW_VNet"
  sku_tier            = "Basic"
  firewall_policy_id  = azurerm_firewall_policy.hub_firewall_policy.id

  ip_configuration {
    name                 = "ip-configuration"
    subnet_id            = azurerm_subnet.firewall_snet.id
    public_ip_address_id = azurerm_public_ip.firewall_ip.id
  }

  management_ip_configuration {
    name                 = "mgmt-ip-configuration"
    subnet_id            = azurerm_subnet.firewall_mgmt_snet.id
    public_ip_address_id = azurerm_public_ip.fw_mgmt_ip.id
  }
}

resource "azurerm_firewall_policy" "hub_firewall_policy" {
  name                = "hub-firewall-policy"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  sku                 = "Basic"
}

resource "azurerm_firewall_policy_rule_collection_group" "network_rules" {
  name               = "spoke-connectivity-rules"
  firewall_policy_id = azurerm_firewall_policy.hub_firewall_policy.id
  priority           = 100

  network_rule_collection {
    name     = "spoke-to-spoke-conn"
    priority = 100
    action   = "Allow"


    rule {
      name                  = "allow-ping"
      protocols             = ["ICMP"]
      source_addresses      = concat(azurerm_subnet.prod_snet.address_prefixes, azurerm_subnet.dev_snet.address_prefixes)
      destination_addresses = concat(azurerm_subnet.prod_snet.address_prefixes, azurerm_subnet.dev_snet.address_prefixes)
      destination_ports     = ["*"]
    }

    rule {
      name                  = "allow-ssh"
      protocols             = ["TCP"]
      source_addresses      = concat(azurerm_subnet.prod_snet.address_prefixes, azurerm_subnet.dev_snet.address_prefixes)
      destination_addresses = concat(azurerm_subnet.prod_snet.address_prefixes, azurerm_subnet.dev_snet.address_prefixes)
      destination_ports     = ["22"]
    }
  }

  network_rule_collection {
    name     = "outbound-internet"
    priority = 200
    action   = "Allow"

    rule {
      name                  = "outbount-to-interet"
      protocols             = ["TCP", "UDP"]
      source_addresses      = concat(azurerm_subnet.prod_snet.address_prefixes, azurerm_subnet.dev_snet.address_prefixes)
      destination_addresses = ["*"]
      destination_ports     = ["80", "443"]
    }

    rule {
      name                  = "outbound-dns"
      protocols             = ["TCP", "UDP"]
      source_addresses      = concat(azurerm_subnet.prod_snet.address_prefixes, azurerm_subnet.dev_snet.address_prefixes)
      destination_addresses = ["*"]
      destination_ports     = ["53"]
    }
  }

  network_rule_collection {
    name     = "S2S VPN"
    priority = 300
    action   = "Allow"

    rule {
      name                  = "fortigate-to-azure"
      protocols             = ["TCP", "UDP", "ICMP"]
      source_addresses      = ["172.26.1.0/24"]
      destination_addresses = ["10.1.0.0/16", "10.2.0.0/16"]
      destination_ports     = ["*"]
    }

    rule {
      name                  = "spokes-to-fortigate"
      protocols             = ["TCP", "UDP", "ICMP"]
      source_addresses      = ["10.1.0.0/16", "10.2.0.0/16"]
      destination_addresses = ["172.26.1.0/24"]
      destination_ports     = ["*"]
    }
  }
}

Bastion

Bastion is Azure’s “Managed Jumpbox” service, and one of my favorite Azure resources. It allows you to RDP or SSH into your Azure VM’s from your browser, without having to worry about hardening a public facing jump box.

This configuration is very simple. We just define another IP, and a few Bastion settings. The peering between the Hub VNet and Spoke VNets allows Bastion to connect to the Spoke VMs.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
resource "azurerm_public_ip" "bastion_ip" {
  name                = "${var.project_name}-bastion-ip"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

resource "azurerm_bastion_host" "bastion_host" {
  name                   = "${var.project_name}-bastion-host"
  location               = azurerm_resource_group.rg.location
  resource_group_name    = azurerm_resource_group.rg.name
  sku                    = "Basic"
  zones                  = []
  copy_paste_enabled     = false
  file_copy_enabled      = false
  ip_connect_enabled     = false
  shareable_link_enabled = false

  ip_configuration {
    name                 = "ip-configuration"
    subnet_id            = azurerm_subnet.bastion_snet.id
    public_ip_address_id = azurerm_public_ip.bastion_ip.id
  }
}

VPN Gateway

Lastly, we have our VPN Gateway. This service allows me to connect my on prem Fortigate to my Azure network. This defines the VPN Gateway and its Public IP, a Local Network Gateway which represents my Fortigate in Azure, and a connection between them.

I also used Fortigate’s documentation to set things up on the Forti-side.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
resource "azurerm_public_ip" "vpngw_ip" {
  name                = "${var.project_name}-vpngw-ip"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  allocation_method   = "Static"
  sku                 = "Standard"
}

resource "azurerm_virtual_network_gateway" "vpngw" {
  name                = "${var.project_name}-vpngw"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  type                = "Vpn"
  vpn_type            = "RouteBased"

  active_active = false
  enable_bgp    = false
  sku           = "VpnGw1"

  ip_configuration {
    name                          = "vpngwconfig"
    public_ip_address_id          = azurerm_public_ip.vpngw_ip.id
    private_ip_address_allocation = "Dynamic"
    subnet_id                     = azurerm_subnet.vpngateway_snet.id
  }
}

resource "azurerm_local_network_gateway" "homefg" {
  name                = "${var.project_name}-homefg"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  gateway_address     = "108.81.196.225"
  address_space       = ["172.26.1.0/24"]
}

resource "azurerm_virtual_network_gateway_connection" "fg_to_azure" {
  name                = "${var.project_name}-lab-connection"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  type                       = "IPsec"
  virtual_network_gateway_id = azurerm_virtual_network_gateway.vpngw.id
  local_network_gateway_id   = azurerm_local_network_gateway.homefg.id

  shared_key = var.vpn_psk
}

Final Thoughts

I may not use all of these resources every time I want to deploy something in Azure, but having a working base template will surely save me lots of time.

Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy