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
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.