[Homelab] Network
This is part of the Homelab series. Please read disclaimer in the Intro.
Contents
Hardware
- Router Mikrotik CRS310-1G-5S-4S+IN — 10G-capable RouterOS switch used as the edge gateway (routing, VLANs, DHCP, firewall); will be referred to as
edge-router. - Switch HORACO 10Gb SFP+ 8 Ports — fanless 8×SFP+ Layer 2 aggregation switch for rack/backbone connectivity; will be referred to as
rack-10G. - WiFi Router Mikrotik hAP ac — dual‑band 802.11ac AP with 5×GbE + 1×SFP; used as a managed AP and small wired switch; will be referred to as
lan-wired-sw. - WiFi Router Tp-Link Archer BE800 — Wi‑Fi 7 router with 10G uplink; used as the main LAN Wi‑Fi AP in bridge/AP mode; will be referred to as
lan-wifi.
TODO: diagram
Configuration
All configuration will be done using OpenTofu, an open-source infrastructure as code (IaC) tool that’s a fork of Terraform. OpenTofu allows us to define and manage our network infrastructure as code, providing version control, repeatability, and automation.
Configuration management of the Mikrotik is handled through the Terraform RouterOS Provider, which enables declarative management of RouterOS devices via their REST API. This provider supports all major RouterOS features including interfaces, routing, firewall rules, DHCP configuration, and wireless settings.
Why OpenTofu?
- Declarative: Define the desired state of your infrastructure
- Version Control: Track changes to your network configuration over time
- Reproducibility: Recreate identical environments reliably
- State Management: Track resource relationships and dependencies
- Open Source: Community-driven development with no vendor lock-in
State Management
State will be stored in Google Cloud Storage using the GCS backend. The free tier provides 5GB of storage, which is more than enough for infrastructure state files. Remote state storage ensures:
- Collaboration: Multiple team members can work with the same state
- State Locking: Prevents concurrent modifications
- Backup: Automatic versioning and backup of state files
- Security: Encrypted storage and access control
Secret Management
Secrets will be stored in Google Secret Manager, providing secure storage for sensitive configuration data like passwords and certificates. The free tier allows 6 secrets with 10,000 access operations per month, but secrets can contain JSON objects to store multiple values efficiently.
Security Benefits:
- Automatic encryption at rest and in transit
- Access logging and audit trails
- IAM integration for fine-grained access control
- Automatic secret rotation capabilities
Initialization
First, log in to the Google Cloud CLI to set up Application Default Credentials for local development:
gcloud auth application-default loginNext, create a main.tf file with required providers and backend:
terraform {
required_version = ">= 1.9.0"
backend "gcs" {
bucket = var.state_bucket
prefix = var.state_bucket_prefix
}
required_providers {
google = {
source = "hashicorp/google"
}
}
}And a variables.tf file with required input variables:
variable "state_bucket" {
type = string
description = "Bucket to store state"
}
variable "state_bucket_prefix" {
type = string
description = "Prefix in the bucket to store state"
}
variable "google_project" {
type = string
description = "Google project to use"
}Variables can be passed via command line or with an all.auto.tfvars file:
google_project = "<redacted>"
state_bucket = "<redacted>"
state_bucket_prefix = "infra/state"Now call
tofu initPrepare Hardware
Before applying OpenTofu configuration, the MikroTik devices need initial preparation. This ensures clean configuration without conflicts from default settings.
- Reset MikroTik routers to factory settings without defconf
- Add addresses that will not overlap with future networks:
edge-router:192.168.88.1/24onether1interfacelan-wired-sw:192.168.88.2/24onsfp1interface
- Set admin passwords
Configuration Definition
Device Inventory
First, we’ll define our managed and unmanaged devices. Managed devices will have Terraform users created with automatically generated passwords, while unmanaged devices are tracked for static DHCP leases and documentation purposes.
locals {
managed-devices = {
edge = {
username = "terraform",
random_password = true,
},
lan-wired-sw = {
username = "terraform",
random_password = true,
address = "192.168.10.2"
mac_address = "<redacted>"
},
}
}locals {
unmanaged-devices = {
lan-wifi = {
address = "192.168.20.2"
mac_address = "<redacted>"
},
rack-10G = {
address = "192.168.10.3"
mac_address = "<redacted>"
},
}
}VLAN Design
Next, we’ll define our desired VLANs. This segmented approach provides network isolation, security boundaries, and organized traffic management across different device categories.
| ID | Name | Network | Purpose |
|---|---|---|---|
| 10 | mgmt | 192.168.10.0/24 | Management and interaction between network infrastructure |
| 20 | lan | 192.168.20.0/24 | Home network |
| 30 | iot | 192.168.30.0/24 | Network for IoT devices |
| 100 | cluster | 10.100.0.0/24 | Network for Kubernetes cluster nodes |
locals {
vlans = {
mgmt = {
vlan_id = 10,
network = "192.168.10.0/24",
comment = "VLAN for network infrastructure",
},
lan = {
vlan_id = 20,
network = "192.168.20.0/24",
comment = "VLAN for home network",
}
iot = {
vlan_id = 30,
network = "192.168.30.0/24",
comment = "VLAN for IoT devices",
},
cluster = {
vlan_id = 100,
network = "10.100.0.0/24",
comment = "VLAN for home cluster nodes",
}
}
}Cluster Nodes
Next, we’ll define our Kubernetes cluster nodes with their MAC addresses and IP assignments for consistent network identification:
locals {
cluster-nodes = {
node-1 : {
mac = "<redacted>"
ip = "10.100.0.2"
control = true
}
node-2 = {
mac = "<redacted>>"
ip = "10.100.0.3"
control = true
}
node-3 = {
mac = "<redacted>",
ip = "10.100.0.4",
control = true
}
}
}WiFi Configuration
Finally, we’ll configure WiFi networks for IoT devices using CAPsMAN (Controlled Access Point system MANager):
locals {
wifi-capsman = {
<redacted> = {
channels = {
wifi2 = {
ssid = "<redacted>"
band = "2ghz-b/g/n"
frequency = [2442]
hw_supported_modes = ["b", "g"]
}
wifi5 = {
ssid = "<redacted>"
band = "5ghz-a/n/ac"
frequency = []
hw_supported_modes = ["a", "ac"]
}
}
vlan_id = local.vlans.iot.vlan_id
}
}
}Create Secrets
For managed devices, Terraform users will be created with passwords stored in Google Secret Manager.
To do this, we need one more input variable in variables.tf:
variable "google_project" {
type = string
description = "Google project to use"
}And value in all.auto.tfvars
google_project = "<redacted>"And one more provider in main.tf to generate random passwords:
random = {
source = "hashicorp/random"
}secrets.tf
This section handles the creation and storage of sensitive configuration data. First, we’ll create random passwords for every managed device:
resource "random_password" "device_password" {
length = 32
special = false
for_each = {for k, v in local.managed-devices: k =>v if v.random_password }
} Next, we’ll save all passwords in Google Secret Manager using JSON format for efficient storage within the free tier limits:
resource "google_secret_manager_secret" "device-accounts" {
secret_id = "tf_device-accounts"
project = var.google_project
replication {
auto {}
}
}
resource "google_secret_manager_secret_version" "device-accounts" {
secret = google_secret_manager_secret.device-accounts.id
lifecycle {
create_before_destroy = true
}
secret_data = jsonencode({for k, v in local.managed-devices: k => random_password.device_password[k].result if v.random_password })
}This is not the best way to store secrets, but allows to stay in free tier. Unfortunately, opentofu does not support write-only attributes and will preserve these secrets in state.
TODO: use secret_data_wo when possible
For future use in providers define decoded version
locals {
_device-accounts-decoded = jsondecode(google_secret_manager_secret_version.device-accounts.secret_data)
device-accounts = {for k, v in local.managed-devices: k=> {
username = v.username,
password = local._device-accounts-decoded[k]
} if v.random_password}
}We’ll repeat the same process for Wi-Fi passwords:
resource "random_password" "wifi_password" {
length = 32
special = true
for_each = {for k, v in local.wifi-capsman: k =>v }
}
resource "google_secret_manager_secret" "wifi_passwords" {
secret_id = "tf_wifi_passwords"
project = var.google_project
replication {
auto {}
}
}
resource "google_secret_manager_secret_version" "wifi_passwords" {
secret = google_secret_manager_secret.wifi_passwords.id
lifecycle {
create_before_destroy = true
}
secret_data = jsonencode({for k, v in local.wifi-capsman: k => random_password.wifi_password[k].result })
}
locals {
_wifi_passwords-decoded = jsondecode(google_secret_manager_secret_version.wifi_passwords.secret_data)
wifi-passwords = {for k, v in local.wifi-capsman: k=> {
password = local._wifi_passwords-decoded[k]
}}
}Edge Router Configuration
The edge router serves as our network gateway, handling internet connectivity, VLAN routing, DHCP services, firewall rules, and wireless management through CAPsMAN. Let’s create an edge-router module to organize this complex configuration:
module "edge-router" {
source = "./edge-router"
hosturl = var.edge_router_url
account = local.device-accounts["edge"]
static-leases = merge({
for k, v in local.managed-devices: k=>{
address = v.address,
mac_address = v.mac_address,
comment = k,
} if contains(keys(v), "mac_address")
}, {
for k, v in local.unmanaged-devices : k=>{
address = v.address,
mac_address = v.mac_address,
comment = k,
} if contains(keys(v), "mac_address")
}, {
for k, v in local.cluster-nodes : k=>{
address = v.ip,
mac_address = v.mac,
comment = k,
}
})
vlans = local.vlans
capsman_wifi = {
networks = {
for k,v in local.wifi-capsman: k => {
channels = v.channels,
vlan_id = v.vlan_id,
}
}
}
capsman_psk = {
for k,v in local.wifi-passwords:
k => local.wifi-passwords[k].password
}
}And add provider to main.tf
routeros = {
source = "terraform-routeros/routeros"
}Edge Router Module
This module contains all the configuration for our primary network gateway, implementing VLANs, bridge configuration, DHCP services, wireless management, and security policies.
terraform {
required_providers {
routeros = {
source = "terraform-routeros/routeros"
}
}
}variable "vlans" {
type = map(object({
vlan_id = number,
network = string,
comment = string,
}))
}
variable "static-leases" {
type = map(object({
address = string,
mac_address = string,
comment = string,
}))
}
variable "capsman_wifi" {
type = object({
networks = map(object({
vlan_id = number
channels = map(object({
ssid = string
band = string
frequency = list(number)
hw_supported_modes = list(string)
}))
}))
})
}
variable "capsman_psk" {
type = map(string)
sensitive = true
}And one file that will be shared with lan-wired-sw (copy-paste)
variable "account" {
type = object({
username = string,
password = string
})
sensitive = true
}
resource "routeros_system_user" "user" {
group = "full"
name = var.account.username
password = var.account.password
}
variable "hosturl" {
type = string
}
provider "routeros" {
hosturl = var.hosturl
username = var.account.username
password = var.account.password
}Now we need small hack to initially provision tf account - replace username/password in provider with admin account and run
tofu init
tofu applyNow we will be able to use our created account for edge-router.
VLAN Creation
First, let’s create our VLANs on the bridge interface:
locals {
vlans = {
mgmt = 10,
lan = 20,
iot = 30,
cluster = 100,
}
}
resource "routeros_interface_vlan" "vlan" {
interface = routeros_interface_bridge.bridge.name
for_each = var.vlans
name = each.key
vlan_id = each.value.vlan_id
comment = each.value.comment
}Bridge and Port Configuration
Now we’ll set up the bridge interface and configure individual ports. The bridge enables VLAN switching and connects all network segments. Each port is configured with appropriate VLAN tagging based on its purpose:
locals {
bridge_ports = {
ether1 = {
disabled = false,
name = "mgmt-eth",
comment = "Management port",
untagged = routeros_interface_vlan.vlan["mgmt"].vlan_id,
tagged = [],
},
sfp1 = {
disabled = true,
},
sfp2 = {
disabled = true,
},
sfp3 = {
disabled = true,
},
sfp4 = {
disabled = true,
},
sfp5 = {
disabled = false,
name = "lan-wired",
comment = "LAN router + IoT AP",
untagged = routeros_interface_vlan.vlan["mgmt"].vlan_id,
tagged = [
routeros_interface_vlan.vlan["lan"].vlan_id,
routeros_interface_vlan.vlan["iot"].vlan_id,
],
},
sfp-sfpplus1 = {
disabled = true,
},
sfp-sfpplus2 = {
disabled = false,
name = "rack-10G",
comment = "Rack 10G SFP+ Switch",
untagged = routeros_interface_vlan.vlan["mgmt"].vlan_id,
tagged = [
routeros_interface_vlan.vlan["cluster"].vlan_id,
],
},
sfp-sfpplus3 = {
disabled = false,
name = "lan-wifi",
comment = "WiFi router for LAN",
untagged = routeros_interface_vlan.vlan["lan"].vlan_id,
tagged = [],
}
}
}
resource "routeros_interface_bridge" "bridge" {
name = "bridge"
vlan_filtering = true
}
resource "routeros_interface_ethernet" "port" {
for_each = local.bridge_ports
factory_name = each.key
name = each.value.disabled ? each.key : each.value.name
comment = each.value.disabled ? "" : each.value.comment
disabled = each.value.disabled
}
resource "routeros_interface_bridge_port" "bridge-port" {
bridge = routeros_interface_bridge.bridge.name
for_each = { for k, v in local.bridge_ports : k => v if !v.disabled }
interface = routeros_interface_ethernet.port[each.key].name
comment = each.value.comment
ingress_filtering = true //true
frame_types = length(each.value.tagged) == 0 ? "admit-only-untagged-and-priority-tagged" : "admit-all"
pvid = each.value.untagged
}
resource "routeros_interface_bridge_vlan" "bridge-vlan" {
bridge = routeros_interface_bridge.bridge.name
for_each = var.vlans
vlan_ids = [each.value.vlan_id]
tagged = concat([for k,v in local.bridge_ports : routeros_interface_bridge_port.bridge-port[k].interface if !v.disabled && contains(v.tagged, each.value.vlan_id)], [routeros_interface_bridge.bridge.name])
}Uplink Configuration
The uplink configuration handles our internet connection through the WAN interface, including DHCP client setup for IPv4 and IPv6, NAT configuration, and interface lists for firewall rules:
resource "routeros_interface_ethernet" "wan" {
factory_name = "sfp-sfpplus4"
name = "wan"
mtu = 1500
}
resource "routeros_ip_dhcp_client" "wan" {
interface = routeros_interface_ethernet.wan.name
}
resource "routeros_ipv6_dhcp_client" "wan" {
interface = routeros_interface_ethernet.wan.name
add_default_route = "false"
request = ["prefix"]
pool_name = "uplink-ipv6"
pool_prefix_length = 64
use_peer_dns = true
}
resource "routeros_ipv6_address" "wan" {
from_pool = routeros_ipv6_dhcp_client.wan.pool_name
interface = routeros_interface_ethernet.wan.name
//advertise = true
//eui_64 = true
}
resource "routeros_ipv6_neighbor_discovery" "wan" {
interface = routeros_interface_ethernet.wan.name
advertise_mac_address = true
disabled = false
}
resource "routeros_interface_list" "wan" {
name = "WAN"
}
resource "routeros_interface_list_member" "wan" {
interface = routeros_interface_ethernet.wan.name
list = routeros_interface_list.wan.name
}
resource "routeros_ip_firewall_nat" "wan-srcnat" {
chain = "srcnat"
action = "masquerade"
ipsec_policy = "out,none"
out_interface_list = routeros_interface_list.wan.name
}Since there is no firewall configured yet, it’s better to keep uplink cable unplugged.
DHCP and Network Configuration
Now we’ll configure DHCP services and IP addressing for each VLAN. Since there are many resources to configure for every network (IP addresses, DHCP pools, servers, and IPv6 settings), we’ll create a reusable module and apply it to all VLANs:
module "mikrotik-dhcp-net" {
source = "../mikrotik-dhcp-net"
for_each = var.vlans
comment = each.value.comment
name = each.key
interface = routeros_interface_vlan.vlan[each.key].name
ipv6-pool = routeros_ipv6_dhcp_client.wan.pool_name
network = each.value.network
providers = {
routeros = routeros
}
}With DHCP networks configured, we can now add static leases for known devices to ensure consistent IP assignments:
resource "routeros_dhcp_server_lease" "static-lease" {
for_each = var.static-leases
address = each.value.address
mac_address = each.value.mac_address
comment = each.value.comment
}NTP Configuration
To maintain accurate time synchronization across the network (essential for logs, certificates, and security), we’ll configure an NTP client:
resource "routeros_system_ntp_client" "ntp" {
servers = [
"pool.ntp.org",
"0.pool.ntp.org",
"1.pool.ntp.org",
"2.pool.ntp.org",
"3.pool.ntp.org"
]
enabled = true
}CAPsMAN Configuration
CAPsMAN (Controlled Access Point system MANager) provides centralized wireless management for our MikroTik access points. First, we need to set up certificates for secure communication between the controller and access points:
resource "routeros_system_certificate" "edge-ca" {
name = "edge-ca"
common_name = "edge-ca"
trusted = true
key_usage = ["key-cert-sign","crl-sign"]
sign {}
}
resource "routeros_system_certificate_scep_server" "edge-ca" {
ca_cert = routeros_system_certificate.edge-ca.name
path = "/scep/edge-ca"
}With certificates in place, we can now configure the CAPsMAN controller, wireless channels, security profiles, and provisioning rules:
locals {
capsman-ip = cidrhost(var.vlans["mgmt"].network, 1)
}
resource "routeros_system_certificate" "capsman" {
common_name = local.capsman-ip
name = "capsman"
sign {
ca = routeros_system_certificate.edge-ca.name
}
}
resource "routeros_capsman_manager" "settings" {
enabled = true
upgrade_policy = "none"
certificate = routeros_system_certificate.capsman.name
ca_certificate = routeros_system_certificate.edge-ca.name
require_peer_certificate = true
}
output "capsman-ip" {
value = local.capsman-ip
}
locals {
all_channels = {for x in flatten([
for nk,nv in var.capsman_wifi.networks: [
for k, v in nv.channels : { nkey = nk, nval = nv, ckey = k, cval = v }
]]): format("%s-%s", x.nkey, x.ckey) => { network_key = x.nkey, network = x.nval, channel = x.cval}
}
}
resource "routeros_capsman_channel" "wifi" {
for_each = local.all_channels
name = each.key
band = each.value.channel.band
frequency = each.value.channel.frequency
}
resource "routeros_capsman_security" "wifi_password" {
for_each = var.capsman_wifi.networks
name = each.key
authentication_types = ["wpa2-psk"]
passphrase = var.capsman_psk[each.key]
}
resource "routeros_capsman_datapath" "vlan_tagging" {
for_each = var.capsman_wifi.networks
name = format("%s-tagging", each.key)
comment = format("WiFi %s VLAN", each.key)
vlan_id = each.value.vlan_id
vlan_mode = "use-tag"
bridge = routeros_interface_bridge.bridge.name
}
resource "routeros_capsman_configuration" "config" {
for_each = local.all_channels
country = "switzerland"
name = each.value.channel.ssid
ssid = each.value.channel.ssid
comment = ""
mode = "ap"
installation = "indoor"
channel = {
config = routeros_capsman_channel.wifi[each.key].name
}
datapath = {
config = routeros_capsman_datapath.vlan_tagging[each.value.network_key].name
}
security = {
config = routeros_capsman_security.wifi_password[each.value.network_key].name
}
}
resource "routeros_capsman_provisioning" "wifi" {
action = "create-dynamic-enabled"
for_each = local.all_channels
comment = routeros_capsman_configuration.config[each.key].name
master_configuration = routeros_capsman_configuration.config[each.key].name
slave_configurations = []
hw_supported_modes = each.value.channel.hw_supported_modes
}
resource "routeros_capsman_manager_interface" "bridge" {
interface = routeros_interface_vlan.vlan["mgmt"].name
forbid = false
disabled = false
}Firewall Configuration
Now we’ll configure a basic but secure firewall ruleset. This includes allowing established connections, dropping invalid packets, accepting ICMP for network troubleshooting, and implementing proper NAT for internet access while blocking unwanted inbound traffic:
resource "routeros_interface_list" "all-local" {
name = "ALL_LOCAL"
}
resource "routeros_interface_list_member" "all-local-vlan" {
list = routeros_interface_list.all-local.name
for_each = var.vlans
interface = routeros_interface_vlan.vlan[each.key].name
}
resource "routeros_interface_list" "vlan" {
for_each = var.vlans
name = format("vlan-%s", each.key)
}
resource "routeros_interface_list_member" "vlan" {
list = routeros_interface_list.vlan[each.key].name
for_each = var.vlans
interface = routeros_interface_vlan.vlan[each.key].name
}
resource "routeros_ip_firewall_filter" "accept_established_related_untracked" {
action = "accept"
chain = "input"
comment = "accept established, related, untracked"
connection_state = "established,related,untracked"
place_before = routeros_ip_firewall_filter.drop_invalid.id
}
resource "routeros_ip_firewall_filter" "drop_invalid" {
action = "drop"
chain = "input"
comment = "drop invalid"
connection_state = "invalid"
place_before = routeros_ip_firewall_filter.accept_icmp.id
}
resource "routeros_ip_firewall_filter" "accept_icmp" {
action = "accept"
chain = "input"
comment = "accept ICMP"
protocol = "icmp"
place_before = routeros_ip_firewall_filter.capsman_accept_local_loopback.id
}
resource "routeros_ip_firewall_filter" "capsman_accept_local_loopback" {
action = "accept"
chain = "input"
comment = "accept to local loopback for capsman"
dst_address = "127.0.0.1"
place_before = routeros_ip_firewall_filter.drop_all_not_lan.id
}
resource "routeros_ip_firewall_filter" "drop_all_not_lan" {
action = "drop"
chain = "input"
comment = "drop all not coming from LAN"
in_interface_list = "!ALL_LOCAL"
place_before = routeros_ip_firewall_filter.fasttrack_connection.id
}
resource "routeros_ip_firewall_filter" "fasttrack_connection" {
action = "fasttrack-connection"
chain = "forward"
comment = "fasttrack"
connection_state = "established,related"
hw_offload = "true"
place_before = routeros_ip_firewall_filter.accept_established_related_untracked_forward.id
}
resource "routeros_ip_firewall_filter" "accept_established_related_untracked_forward" {
action = "accept"
chain = "forward"
comment = "accept established, related, untracked"
connection_state = "established,related,untracked"
place_before = routeros_ip_firewall_filter.drop_invalid_forward.id
}
resource "routeros_ip_firewall_filter" "drop_invalid_forward" {
action = "drop"
chain = "forward"
comment = "drop invalid"
connection_state = "invalid"
place_before = routeros_ip_firewall_filter.drop_all_wan_not_dstnat.id
}
resource "routeros_ip_firewall_filter" "drop_all_wan_not_dstnat" {
action = "drop"
chain = "forward"
comment = "drop all from WAN not DSTNATed"
connection_nat_state = "!dstnat"
connection_state = "new"
in_interface_list = "WAN"
}We’ll also configure similar rules for IPv6 traffic:
resource "routeros_ipv6_firewall_filter" "accept_established_related_untracked" {
action = "accept"
chain = "input"
comment = "accept established, related, untracked"
connection_state = "established,related,untracked"
place_before = routeros_ipv6_firewall_filter.drop_invalid.id
}
resource "routeros_ipv6_firewall_filter" "drop_invalid" {
action = "drop"
chain = "input"
comment = "drop invalid"
connection_state = "invalid"
place_before = routeros_ipv6_firewall_filter.accept_icmp.id
}
resource "routeros_ipv6_firewall_filter" "accept_icmp" {
action = "accept"
chain = "input"
comment = "accept ICMPv6"
protocol = "icmpv6"
place_before = routeros_ipv6_firewall_filter.accept_dhcpv6.id
}
resource "routeros_ipv6_firewall_filter" "accept_dhcpv6" {
action = "accept"
chain = "input"
comment = "accept DHCPv6"
protocol = "udp"
dst_port = "546"
place_before = routeros_ipv6_firewall_filter.drop_all_not_lan.id
}
resource "routeros_ipv6_firewall_filter" "drop_all_not_lan" {
action = "drop"
chain = "input"
comment = "drop all not coming from LAN"
in_interface_list = "!ALL_LOCAL"
place_before = routeros_ipv6_firewall_filter.drop_invalid_forward.id
}
resource "routeros_ipv6_firewall_filter" "drop_invalid_forward" {
action = "drop"
chain = "forward"
comment = "drop invalid"
connection_state = "invalid"
}MikroTik DHCP Network Module
This reusable module handles the complete network setup for a VLAN, including IP addressing, DHCP pool creation, server configuration, and IPv6 setup. It standardizes network configuration across all VLANs:
variable "interface" {
type = string
description = "Interface name"
}
variable "name" {
type = string
description = "Network name"
}
variable "dns" {
type = list(string)
description = "DNS servers"
nullable = true
default = null
}
variable "network" {
type = string
description = "Network in cidr notation"
}
variable "ipv6-pool" {
type = string
description = "IPv6 DHCP Pool name"
}
variable "comment" {
type = string
description = "Comment"
}And main body of module
terraform {
required_providers {
routeros = {
source = "terraform-routeros/routeros"
}
}
}
locals {
dhcp-ranges = [format("%s-%s", cidrhost(var.network, 2), cidrhost(var.network, -2))]
network = cidrhost(var.network, 0)
router-ip = cidrhost(var.network, 1)
netmask = tonumber(split("/", var.network)[1])
router-ip-with-net = format("%s/%d", local.router-ip, local.netmask)
}
resource "routeros_ip_address" "addr" {
address = local.router-ip-with-net
interface = var.interface
network = local.network
comment = var.name
}
resource "routeros_ip_pool" "pool" {
name = var.name
ranges = local.dhcp-ranges
comment = var.name
}
resource "routeros_ip_dhcp_server_network" "net" {
address = var.network
gateway = local.router-ip
dns_server = coalesce(var.dns, [local.router-ip])
netmask = local.netmask
comment = var.name
}
resource "routeros_ip_dhcp_server" "srv" {
name = var.name
address_pool = routeros_ip_pool.pool.name
interface = var.interface
}
resource "routeros_ipv6_address" "addr-v6" {
from_pool = var.ipv6-pool
interface = var.interface
advertise = true
eui_64 = true
comment = var.name
}LAN Wired Switch Configuration
Now we can configure our second MikroTik device, which serves as a managed access point and provides additional wired ports for the LAN. This device connects to the edge router via trunk and provides both wireless and wired access:
module "lan-wired-sw" {
source = "./lan-wired-sw"
capsman = module.edge-router.capsman-ip
hosturl = format("http://%s", local.managed-devices.lan-wired-sw.address)
account = local.device-accounts["lan-wired-sw"]
vlans = {for k in ["lan", "mgmt", "iot"]: k => local.vlans[k]}
}LAN Wired Switch Module
This module configures the secondary MikroTik device as a managed switch and wireless access point, handling VLAN trunking from the edge router and providing local network access:
Basic Network Services
Next, we’ll configure basic network services including DNS forwarding, IPv6 settings, and DHCP client for management connectivity:
terraform {
required_providers {
routeros = {
source = "terraform-routeros/routeros"
}
}
}variable "capsman" {
type = string
description = "Capsman address"
}First, use the same steps and tf-account.tf file.
Bridge and Port Configuration
First, we’ll configure the bridge and port assignments. This device acts as a VLAN-aware switch, with most ports providing untagged LAN access while the uplink carries multiple VLANs:
locals {
bridge_ports = {
ether1 = {
disabled = false,
name = "ether1",
comment = "Port 1",
untagged = routeros_interface_vlan.vlan["lan"].vlan_id,
tagged = [],
},
ether2 = {
disabled = false,
name = "ether2",
comment = "Port 2",
untagged = routeros_interface_vlan.vlan["lan"].vlan_id,
tagged = [],
},
ether3 = {
disabled = false,
name = "ether3",
comment = "Port 3",
untagged = routeros_interface_vlan.vlan["lan"].vlan_id,
tagged = [],
},
ether4 = {
disabled = false,
name = "ether4",
comment = "Port 4",
untagged = routeros_interface_vlan.vlan["lan"].vlan_id,
tagged = [],
},
ether5 = {
disabled = false,
name = "ether5",
comment = "Port 5",
untagged = routeros_interface_vlan.vlan["lan"].vlan_id,
tagged = [],
},
sfp1 = {
disabled = false,
name = "uplink",
comment = "To Edge Router",
untagged = routeros_interface_vlan.vlan["mgmt"].vlan_id,
tagged = [
routeros_interface_vlan.vlan["lan"].vlan_id,
routeros_interface_vlan.vlan["iot"].vlan_id,
],
}
}
}
resource "routeros_interface_bridge" "bridge" {
name = "bridge"
vlan_filtering = true
}
resource "routeros_interface_ethernet" "port" {
for_each = local.bridge_ports
factory_name = each.key
name = each.value.disabled ? each.key : each.value.name
comment = each.value.disabled ? "" : each.value.comment
disabled = each.value.disabled
}
resource "routeros_interface_bridge_port" "bridge-port" {
bridge = routeros_interface_bridge.bridge.name
for_each = { for k, v in local.bridge_ports : k => v if !v.disabled }
interface = routeros_interface_ethernet.port[each.key].name
comment = each.value.comment
ingress_filtering = true
frame_types = length(each.value.tagged) == 0 ? "admit-only-untagged-and-priority-tagged" : "admit-all"
pvid = each.value.untagged
}
resource "routeros_interface_bridge_vlan" "bridge-vlan" {
bridge = routeros_interface_bridge.bridge.name
for_each = var.vlans
vlan_ids = [each.value.vlan_id]
tagged = concat([for k,v in local.bridge_ports : routeros_interface_bridge_port.bridge-port[k].interface if !v.disabled && contains(v.tagged, each.value)], [routeros_interface_bridge.bridge.name])
}resource "routeros_ip_dns" "dns" {
allow_remote_requests = true
}
resource "routeros_ipv6_settings" "disable" {
disable_ipv6 = "false"
accept_router_advertisements = "yes"
forward = true
}
resource "routeros_dhcp_client" "dhcp-client" {
interface = routeros_interface_vlan.vlan["mgmt"].name
}VLAN Interface Creation
Create VLAN interfaces on the bridge for network segmentation:
resource "routeros_interface_vlan" "vlan" {
interface = routeros_interface_bridge.bridge.name
for_each = var.vlans
name = each.key
vlan_id = each.value.vlan_id
}Wireless Access Point Configuration
To configure this device as a controlled access point (CAP) managed by our CAPsMAN controller, we first need to obtain certificates for secure communication:
data "routeros_ip_addresses" "mgmtip" {
}
resource "routeros_system_certificate" "cap" {
common_name = split("/", data.routeros_ip_addresses.mgmtip.addresses[0].address)[0]
name = "cap"
key_usage = ["digital-signature", "key-agreement", "tls-client"]
sign_via_scep {
scep_url = format("http://%s/scep/edge-ca", var.capsman)
refresh = true
}
}With certificates configured, we can now set up the controlled access point functionality:
locals {
wlans = {
wlan1 = {
disabled = false
}
wlan2 = {
disabled = false
}
}
}
resource "routeros_interface_wireless_cap" "cap" {
bridge = routeros_interface_bridge.bridge.name
enabled = true
lock_to_caps_man = true
caps_man_certificate_common_names = [var.capsman]
caps_man_addresses = [var.capsman]
interfaces = [for k,v in local.wlans: k]
certificate = routeros_system_certificate.cap.name
}Finally, we’ll add the wireless interfaces to the bridge with proper VLAN tagging:
resource "routeros_interface_bridge_port" "bridge-port-wlan" {
bridge = routeros_interface_bridge.bridge.name
for_each = { for k, v in local.wlans : k => v if !v.disabled }
interface = each.key
ingress_filtering = true
frame_types = "admit-only-vlan-tagged"
}HORACO Switch
Unfortunately, there is no provider compatible with this switch. For now, switch is configured to have port 1 to be trunk and all other ports access ports of the VLAN 100.
Cleanup
The only thing left is to remove the temporary IPs added for initial configuration and plug in the uplink cable.