Files
eqt-network-meraki/README.md

646 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EQT Network — Meraki Infrastructure as Code
All Meraki network configuration is managed as Infrastructure as Code using [Terraform](https://www.terraform.io/) with the [`CiscoDevNet/meraki`](https://registry.terraform.io/providers/CiscoDevNet/meraki/latest) provider (v1.9.0). Every change is deployed through a GitHub Actions CI/CD pipeline — **no one runs `terraform apply` locally**. Terraform state is stored remotely in an S3 bucket with DynamoDB locking to prevent concurrent modifications.
---
## TL;DR
> [!CAUTION]
> **Never run `terraform apply` locally.** All applies go through the GitHub Actions pipeline to ensure auditability and prevent state drift.
### Modifying an existing site
**1. Find the right file** in `sites/<SITE>/`:
| What you want to change | File |
|------------------------|------|
| VLAN IDs, subnets, gateway IPs, DHCP | `vlans.tf` |
| Wi-Fi SSIDs | `ssids.tf` |
| Firewall rules | `firewall.tf` |
| Switch ports, stacks, 802.1X policies | `switch.tf` |
| MX LAN ports | `appliance.tf` |
| WAN IPs, Warm Spare (HA) | `wan.tf` |
| Organization or network name | `main.tf` (top `locals` block) |
**2.** Edit the value inside the `locals { }` block. All device names (stacks, switches, MX) must match the **exact display name** in the Meraki Dashboard.
**3. Commit, push and open a PR** — see [Step by step — VS Code](#step-by-step--vs-code) or [Step by step — CLI](#step-by-step--cli).
GitHub Actions runs `terraform plan` automatically and posts the output as a PR comment. Review the plan, then merge — `terraform apply` runs automatically on merge.
---
### Adding a new site
```bash
cp -r sites/BCN01-LAB sites/MAD01
```
Then edit only these values in the copied files:
**`main.tf`** — module name, `organization_name` and `network_name`:
```hcl
locals {
organization_name = "..." # exact org name in Meraki Dashboard
network_name = "MAD01" # exact network name in Meraki Dashboard
}
module "mad01" { # rename to match the new site
source = "../../modules/meraki-site"
# everything else stays the same
}
```
**`vlans.tf`** — replace subnets and gateway IPs with the new site's IP ranges.
**`ssids.tf`** — update RADIUS server IPs if different.
**`firewall.tf`** — update any CIDRs that reference site-specific subnets.
**`switch.tf`** — replace stack/switch names (`bcn01-lab-stack01` → actual name in MAD01's Dashboard).
**`appliance.tf`** and **`wan.tf`** — update MX device names and WAN IPs.
**`variables.tf`** — do not touch. It is identical across all sites.
Open a PR — the workflow detects `sites/MAD01/` automatically, no workflow changes needed.
---
## Table of Contents
1. [Repository Structure](#1-repository-structure)
2. [How It Works — Architecture Overview](#2-how-it-works--architecture-overview)
3. [Change Workflow](#3-change-workflow)
4. [Making a Change to an Existing Site](#4-making-a-change-to-an-existing-site)
5. [Adding a New Site](#5-adding-a-new-site)
6. [Configuration Reference](#6-configuration-reference)
7. [GitHub Actions Workflows](#7-github-actions-workflows)
8. [Sensitive Variables and Secrets](#8-sensitive-variables-and-secrets)
9. [Running Terraform Locally (plan only)](#9-running-terraform-locally-plan-only)
10. [Manual Steps — Provider Limitations](#10-manual-steps--provider-limitations)
---
## 1. Repository Structure
```
.
├── backend.hcl # Shared S3 backend config (bucket, region, DynamoDB table)
├── modules/
│ └── meraki-site/ # Reusable module — all Meraki resource logic lives here
│ ├── main.tf # Resource definitions (VLANs, SSIDs, firewall, switches, WAN, HA)
│ ├── variables.tf # All input variable declarations with types and defaults
│ └── outputs.tf # Exported values (network_id, vlan_ids, stack_ids, device_serials)
├── sites/
│ └── BCN01-LAB/ # One directory per physical site
│ ├── main.tf # Terraform backend + organization/network locals + module call
│ ├── variables.tf # Only two sensitive vars: radius_secret, wifi_password_psk
│ ├── vlans.tf # locals: VLAN definitions (IDs, subnets, DHCP)
│ ├── ssids.tf # locals: Wireless SSID configuration
│ ├── firewall.tf # locals: L3 firewall rules
│ ├── switch.tf # locals: Switch ports, stacks, 802.1X policies
│ ├── appliance.tf # locals: MX LAN port configuration
│ ├── wan.tf # locals: WAN uplinks and Warm Spare (HA)
│ └── MANUAL_STEPS.md # Steps that cannot be automated (provider limitations)
└── .github/
└── workflows/
├── plan.yml # Runs terraform plan on Pull Requests
└── apply.yml # Runs terraform apply on merge to main
```
> **One directory per site.** Each directory under `sites/` is a fully independent Terraform root module with its own remote state. Sites share the `modules/meraki-site` module but have no shared state between them.
>
> **No variable boilerplate.** Site configuration lives in `locals {}` blocks — no need to re-declare types and defaults that already exist in the module. The only `variables.tf` in a site holds the two sensitive variables that must arrive via `TF_VAR_*` environment variables.
---
## 2. How It Works — Architecture Overview
### Module pattern
The `modules/meraki-site` module encapsulates all Meraki resource logic. A site directory is a thin wrapper that calls the module with site-specific locals and declares the remote backend:
```
sites/BCN01-LAB/
*.tf (locals) ──► main.tf ──► module "meraki-site" ──► Meraki API
│
modules/meraki-site/
main.tf (resources)
variables.tf
```
### Dynamic resource resolution
Terraform never needs device serials hardcoded. At plan time, the module:
- Calls `data "meraki_network_devices"` to build a `name → serial` map for MX and standalone switches
- Calls `data "meraki_switch_stacks"` to resolve stack names to their member serials
This means you reference devices by their **Dashboard display name** in all configuration files.
### Port range expansion
Switch port configuration accepts ranges like `"1-24"`, `"47-48"`, or `"1-3,5,47"`. The module expands these into individual port resources at plan time. A single config entry can configure dozens of ports.
### VLAN and L3 gateway
The module creates L3 VLAN interfaces on the MX for every VLAN with a `subnet` defined. VLANs without a subnet (e.g. a pure-switching WAN VLAN) are created as L2-only and excluded from the MX gateway resources.
### Firewall rules
`meraki_appliance_l3_firewall_rules` **replaces the entire rule set** on every apply. The list in `firewall.tf` is authoritative. Rules are evaluated top-down; always end the list with an explicit deny-all rule.
### SSID split
The Meraki API rejects the `wpa_encryption_mode` attribute for SSIDs with `auth_mode = "open"`. The module handles this internally by splitting SSIDs into two resources — one for open SSIDs and one for all others. No action needed from the operator.
---
## 3. Change Workflow
> **Never run `terraform apply` locally.** All applies go through GitHub Actions to ensure auditability and prevent state drift.
Every change follows this Git-based process:
```
1. Create a feature branch
2. Edit the relevant .tf file under sites/<site>/
3. Commit the changes
4. Push the branch and open a Pull Request
5. GitHub Actions runs terraform plan and posts the output as a PR comment
6. Team member reviews the plan output in the PR
7. Approve & merge → GitHub Actions runs terraform apply automatically
```
### Branch and commit naming
| Type | Pattern | Example |
|------|---------|---------|
| Branch | `feature/<site>-<description>` | `feature/BCN01-LAB-add-iot-vlan` |
| Commit | `feat(<site>): <description>` | `feat(BCN01-LAB): add IoT VLAN 112` |
| Bugfix branch | `fix/<site>-<description>` | `fix/BCN01-LAB-ssid-visible` |
| Bugfix commit | `fix(<site>): <description>` | `fix(BCN01-LAB): set EQT-CORPO-OWE-OK to hidden` |
### Step by step — VS Code
1. Click the branch name in the bottom-left status bar → **Create new branch** → enter `feature/<site>-<description>`
2. Edit the relevant file(s) under `sites/<site>/`
3. Open the **Source Control** panel (`Ctrl+Shift+G` / `Cmd+Shift+G`)
4. Click **`+`** next to each changed file (or next to "Changes" to stage all)
5. Type the commit message in the text box and click **Commit**
6. Click **Publish Branch** — this pushes the branch to GitHub
7. Open a PR:
- **Option A** — GitHub will show a banner in the repo: *"Compare & pull request"*. Click it.
- **Option B** — Install the [GitHub Pull Requests](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github) extension and create the PR directly from VS Code without opening the browser.
### Step by step — CLI
```bash
# 1. Create the branch
git checkout -b feature/<site>-<description>
# 2. Edit files, then stage and commit
git add sites/<site>/<file>.tf
git commit -m "feat(<site>): <description>"
# 3. Push the branch
git push origin feature/<site>-<description>
# 4. Open a PR (interactive) or directly in the browser
gh pr create --title "feat(<site>): <description>"
gh pr create --web
```
> The `gh` CLI must be installed and authenticated (`gh auth login`).
---
## 4. Making a Change to an Existing Site
For a quick reference on which file to edit, see the [TL;DR](#tldr) at the top. The examples below show the syntax for the most common changes.
### Example: adding a firewall rule
Edit `sites/<site>/firewall.tf`. The `src_port` and `dest_port` fields default to `"any"` and can be omitted:
```hcl
locals {
firewall_rules = [
# ... existing rules ...
{
comment = "Allow IoT to NTP server"
policy = "allow"
protocol = "udp"
src_cidr = "10.2.60.0/24" # IoT VLAN
dest_cidr = "10.2.56.10/32" # NTP server
dest_port = "123"
},
{
comment = "Deny all other traffic"
policy = "deny"
protocol = "any"
src_cidr = "any"
dest_cidr = "any"
},
]
}
```
### Example: adding a VLAN
Edit `sites/<site>/vlans.tf`. The map key is the VLAN ID:
```hcl
locals {
switch_vlans = {
# ... existing VLANs ...
"112" = {
name = "IOT"
subnet = "10.2.60.0/24"
appliance_ip = "10.2.60.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.60.1", end = "10.2.60.49" }
]
}
}
}
```
### Example: configuring switch ports
Edit `sites/<site>/switch.tf`. Use `switch_stack_port_configs` to apply a config to all members of a stack, or `switch_named_port_configs` to target a specific switch by name:
```hcl
locals {
# Apply to all members of the stack
switch_stack_port_configs = [
{
stack_name = "bcn01-lab-stack01" # exact name from Dashboard
port_range = "1-44"
type = "access"
vlan = 100 # fallback VLAN if RADIUS doesn't assign one
access_policy_type = "Custom access policy"
access_policy_number = 1 # references the DOT1X-CORPO policy
},
]
# Target a specific stack member by display name
switch_named_port_configs = [
{
switch_name = "bcn01-lab-sw01"
port_range = "45-48"
type = "trunk"
vlan = 109 # native (untagged) VLAN
allowed_vlans = "all"
},
]
}
```
`port_range` supports single ports (`"1"`), ranges (`"1-24"`), and mixed (`"1-3,5,47"`).
### Example: adding a wireless SSID
Edit `sites/<site>/ssids.tf`. Meraki numbers SSIDs from 0 to 14:
```hcl
locals {
wireless_ssids = [
# ... existing SSIDs ...
{
number = 3
name = "EQT-IOT"
enabled = true
auth_mode = "psk"
encryption_mode = "wpa"
wpa_encryption_mode = "WPA3 Transition Mode"
# Password is injected via TF_VAR_wifi_password_psk (GitHub Secret)
ip_assignment_mode = "Bridge mode"
use_vlan_tagging = true
default_vlan_id = 112
},
]
}
```
**Auth mode reference:**
| `auth_mode` | Use case | Notes |
|-------------|----------|-------|
| `"open"` | Open network | `wpa_encryption_mode` must be omitted |
| `"open-enhanced"` | OWE (Opportunistic Wireless Encryption) | Use with `wpa_encryption_mode = "WPA3 only"` |
| `"psk"` | WPA2/WPA3 with shared password | Requires `encryption_mode = "wpa"` |
| `"8021x-radius"` | Enterprise 802.1X | Requires `radius_servers` list |
---
## 5. Adding a New Site
Adding a new site requires creating one new directory. The GitHub Actions workflows detect it automatically — no workflow changes needed.
### Step 1 — Copy an existing site
```bash
cp -r sites/BCN01-LAB sites/MAD01
```
### Step 2 — Update `sites/MAD01/main.tf`
Change the module name and the two locals at the top:
```hcl
locals {
organization_name = "..." # exact org name in Meraki Dashboard
network_name = "MAD01" # exact network name in Meraki Dashboard
}
module "mad01" { # rename to match the new site
source = "../../modules/meraki-site"
# everything else stays the same
}
```
The S3 state key is derived automatically from the directory name (`MAD01/terraform.tfstate`) — no manual backend configuration needed.
### Step 3 — Update the config files
Replace BCN01-LAB-specific values with the new site's actual configuration. See the [TL;DR](#adding-a-new-site) for the per-file summary, and the [Configuration Reference](#6-configuration-reference) for the full schema of each block.
> Device names (`stack_name`, `switch_name`, MX names) must match the **exact display names** in the Meraki Dashboard for that network.
### Step 4 — Open a PR
See [Step by step — VS Code](#step-by-step--vs-code) or [Step by step — CLI](#step-by-step--cli).
GitHub Actions detects the new `sites/MAD01/` directory, runs `terraform plan`, and posts the output as a PR comment. Review the plan, then merge to apply.
### Step 5 — Review `MANUAL_STEPS.md`
After the initial apply, check `sites/MAD01/MANUAL_STEPS.md` for any Dashboard steps that could not be automated. See [Section 10](#10-manual-steps--provider-limitations) for known provider limitations.
---
## 6. Configuration Reference
### VLANs (`vlans.tf`)
```hcl
switch_vlans = {
"<vlan_id>" = {
name = string # Display name
subnet = optional string # CIDR, e.g. "10.2.32.0/21". Null = L2 only (no MX gateway)
appliance_ip = optional string # MX gateway IP within the subnet
dhcp_handling = optional string # "Run a DHCP server" (default)
# "Relay DHCP to another server"
# "Do not respond to DHCP requests"
reserved_ip_ranges = optional list of {
comment = string
id = string # unique identifier, e.g. "static"
start = string # first IP to reserve
end = string # last IP to reserve
}
}
}
```
### Firewall rules (`firewall.tf`)
Rules are applied **in order**. The last rule should always be an explicit deny-all. The entire list replaces the Dashboard rules on every apply.
```hcl
firewall_rules = [
{
comment = string # Human-readable description
policy = "allow" | "deny"
protocol = "any" | "tcp" | "udp" | "icmp"
src_cidr = string # CIDR or "any"
src_port = optional string # Port or "any" (default: "any")
dest_cidr = string # CIDR or "any"
dest_port = optional string # Port or "any" (default: "any")
syslog_enabled = optional bool # default: false
},
]
```
### Wireless SSIDs (`ssids.tf`)
```hcl
wireless_ssids = [
{
number = number # Meraki SSID slot (0–14)
name = string
enabled = optional bool # default: true
visible = optional bool # false = hidden SSID. default: true
auth_mode = string # "open", "open-enhanced", "psk", "8021x-radius"
encryption_mode = optional string # "wpa" required for psk; null otherwise
wpa_encryption_mode = optional string # "WPA3 only", "WPA3 Transition Mode". Null for open
splash_page = optional string # default: "None"
ip_assignment_mode = optional string # default: "Bridge mode"
use_vlan_tagging = optional bool # default: false
default_vlan_id = optional number
radius_servers = optional list of {
host = string # RADIUS server IP
port = number
# secret is injected from TF_VAR_radius_secret — never put it here
}
},
]
```
### Switch access policies (`switch.tf`)
```hcl
switch_access_policies = [
{
name = string # referenced by access_policy_number in port configs
access_policy_type = optional string # "802.1x" (default), "Hybrid authentication"
host_mode = optional string # "Multi-Auth" (default)
radius_failed_auth_vlan_id = optional number # fallback VLAN if RADIUS unreachable
radius_re_authentication_interval = optional number # seconds. 0 = disabled
radius_servers = list of {
host = string
port = number
}
},
]
```
### Switch port configs (`switch.tf`)
Three methods — use whichever fits:
```hcl
# 1. By explicit serial
switch_port_configs = [
{
serial = "XXXX-XXXX-XXXX"
port_range = "1-24"
type = "access" | "trunk"
vlan = optional number # access VLAN (access) or native VLAN (trunk)
allowed_vlans = optional string # trunk only. default: "all"
access_policy_type = optional string # "Open" (default) or "Custom access policy"
access_policy_number = optional number # index of the policy in switch_access_policies
},
]
# 2. By stack name — applies to ALL members of the stack
switch_stack_port_configs = [
{
stack_name = "bcn01-lab-stack01" # exact Dashboard name
port_range = "1-44"
# ... same fields as above ...
},
]
# 3. By switch display name — resolves serial dynamically
switch_named_port_configs = [
{
switch_name = "bcn01-lab-sw01" # exact Dashboard name
port_range = "1,2,3"
# ... same fields as above ...
},
]
```
### MX WAN and HA (`wan.tf`)
```hcl
mx_wan_uplinks = [
{
name = "BCN01-F04-MX01" # exact Dashboard device name
wan1_static_ip = "x.x.x.x"
wan1_static_subnet_mask = "255.255.255.240"
wan1_static_gateway_ip = "x.x.x.x"
wan1_static_dns = ["8.8.8.8", "8.8.4.4"]
},
]
mx_warm_spare = {
enabled = true
spare_name = "BCN01-F04-MX02" # exact Dashboard device name
uplink_mode = "virtual"
virtual_ip1 = "x.x.x.x" # floating VIP on WAN1
virtual_ip2 = "x.x.x.x" # floating VIP on WAN2 (if applicable)
}
```
---
## 7. GitHub Actions Workflows
Both workflows use a `detect-sites` job that dynamically determines which sites to plan or apply based on which files changed.
| Trigger | Workflow | Action |
|---------|----------|--------|
| Pull Request → `main` | `plan.yml` | Runs `terraform plan` for each changed site, posts output as PR comment |
| Push to `main` (merge) | `apply.yml` | Runs `terraform apply` for each changed site, serialized |
**Site detection logic:**
- `modules/` or `backend.hcl` changed → all sites planned/applied
- Only `sites/<name>/` changed → only that site planned/applied
- No relevant files changed → workflow skips entirely
### `plan.yml` — Pull Request
```
PR opened/updated
│
▼
detect-sites (ubuntu-latest) — reads git diff, builds site matrix
│
▼
plan (self-hosted, matrix per site, parallel)
├── terraform init
├── terraform plan → plan_output.txt
└── Post plan as PR comment
```
### `apply.yml` — Merge to main
```
Merge to main
│
▼
detect-sites (ubuntu-latest)
│
▼
apply (self-hosted, matrix per site, max-parallel: 1)
├── terraform init
└── terraform apply -auto-approve
```
`max-parallel: 1` serializes applies across sites to avoid DynamoDB lock contention.
### Backend initialization
```bash
terraform init \
-backend-config=../../backend.hcl \ # shared: bucket, region, dynamodb_table
-backend-config="key=<site>/terraform.tfstate" # site-specific state path
```
---
## 8. Sensitive Variables and Secrets
Two variables must **never** appear in any `.tf` file. They are injected at runtime via environment variables:
| Variable | GitHub Secret | Injected as |
|----------|--------------|-------------|
| `radius_secret` | `RADIUS_SECRET` | `TF_VAR_radius_secret` |
| `wifi_password_psk` | `WIFI_PASSWORD_PSK` | `TF_VAR_wifi_password_psk` |
All other required GitHub Secrets:
| Secret | Purpose |
|--------|---------|
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | S3 backend (state storage) |
| `MERAKI_DASHBOARD_API_KEY` | Meraki API authentication |
The RADIUS secret is shared across all RADIUS servers (SSIDs and switch 802.1X policies). If a site requires a different secret, a new GitHub Secret and a separate variable must be added.
---
## 9. Running Terraform Locally (plan only)
Local `terraform plan` is useful for debugging. `terraform apply` must never be run locally.
```bash
export MERAKI_DASHBOARD_API_KEY="your-api-key"
export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
export TF_VAR_radius_secret="..."
export TF_VAR_wifi_password_psk="..."
cd sites/BCN01-LAB
terraform init \
-backend-config=../../backend.hcl \
-backend-config="key=BCN01-LAB/terraform.tfstate"
terraform plan
```
> The first `terraform init` downloads the provider binary into `.terraform/`. This directory is gitignored.
---
## 10. Manual Steps — Provider Limitations
Some Meraki features are not yet supported by the `CiscoDevNet/meraki` provider v1.9.0 and must be configured directly in the Meraki Dashboard. Each site directory should include a `MANUAL_STEPS.md` documenting any steps that cannot be automated for that site.
For `BCN01-LAB`, see [`sites/BCN01-LAB/MANUAL_STEPS.md`](sites/BCN01-LAB/MANUAL_STEPS.md).
| Feature | Status | Notes |
|---------|--------|-------|
| Client VPN (L2TP/IPSec) | Manual | No resource exists in provider v1.9.0 |
| OWE initial activation | Warning | Provider manages `auth_mode = "open-enhanced"` correctly; a one-time Dashboard confirmation may be needed after the very first apply |
When a previously manual step becomes supported by the provider, migrate it to the appropriate `.tf` config file and remove it from `MANUAL_STEPS.md`.