feat: multi-site scalability, locals refactor, README

This commit is contained in:
Xavier Lario
2026-04-20 10:16:49 +02:00
parent 881d0ac5b8
commit 8ff53503db
23 changed files with 1155 additions and 740 deletions
+44 -4
View File
@@ -3,10 +3,47 @@ name: Terraform Apply
on: on:
push: push:
branches: [main] branches: [main]
paths:
- 'sites/**'
- 'modules/**'
- 'backend.hcl'
jobs: jobs:
detect-sites:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.detect.outputs.matrix }}
has-changes: ${{ steps.detect.outputs.has-changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- id: detect
run: |
all_sites=$(find sites -maxdepth 1 -mindepth 1 -type d -exec basename {} \; | sort | jq -sRc '.')
if git diff HEAD~1...HEAD --name-only | grep -qE '^(modules/|backend\.hcl)'; then
echo "has-changes=true" >> $GITHUB_OUTPUT
echo "matrix={\"site\": $all_sites}" >> $GITHUB_OUTPUT
else
changed=$(git diff HEAD~1...HEAD --name-only | grep '^sites/' | cut -d/ -f2 | sort -u | jq -sRc '.')
if [ "$changed" = "[]" ] || [ -z "$changed" ]; then
echo "has-changes=false" >> $GITHUB_OUTPUT
echo "matrix={\"site\": []}" >> $GITHUB_OUTPUT
else
echo "has-changes=true" >> $GITHUB_OUTPUT
echo "matrix={\"site\": $changed}" >> $GITHUB_OUTPUT
fi
fi
apply: apply:
name: Terraform Apply - BCN01-LAB needs: detect-sites
if: needs.detect-sites.outputs.has-changes == 'true'
strategy:
matrix: ${{ fromJSON(needs.detect-sites.outputs.matrix) }}
max-parallel: 1 # serializar applies para evitar conflictos de estado en DynamoDB
fail-fast: false
name: Terraform Apply - ${{ matrix.site }}
runs-on: self-hosted runs-on: self-hosted
steps: steps:
@@ -22,8 +59,11 @@ jobs:
terraform_version: 1.5.0 terraform_version: 1.5.0
- name: Terraform Init - name: Terraform Init
working-directory: sites/BCN01-LAB working-directory: sites/${{ matrix.site }}
run: terraform init run: |
terraform init \
-backend-config=../../backend.hcl \
-backend-config="key=${{ matrix.site }}/terraform.tfstate"
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -31,7 +71,7 @@ jobs:
MERAKI_DASHBOARD_API_KEY: ${{ secrets.MERAKI_DASHBOARD_API_KEY }} MERAKI_DASHBOARD_API_KEY: ${{ secrets.MERAKI_DASHBOARD_API_KEY }}
- name: Terraform Apply - name: Terraform Apply
working-directory: sites/BCN01-LAB working-directory: sites/${{ matrix.site }}
run: terraform apply -auto-approve run: terraform apply -auto-approve
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+56 -15
View File
@@ -3,10 +3,47 @@ name: Terraform Plan
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
paths:
- 'sites/**'
- 'modules/**'
- 'backend.hcl'
- '.github/workflows/**'
jobs: jobs:
detect-sites:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.detect.outputs.matrix }}
has-changes: ${{ steps.detect.outputs.has-changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: detect
run: |
all_sites=$(find sites -maxdepth 1 -mindepth 1 -type d -exec basename {} \; | sort | jq -sRc '.')
if git diff origin/main...HEAD --name-only | grep -qE '^(modules/|backend\.hcl)'; then
echo "has-changes=true" >> $GITHUB_OUTPUT
echo "matrix={\"site\": $all_sites}" >> $GITHUB_OUTPUT
else
changed=$(git diff origin/main...HEAD --name-only | grep '^sites/' | cut -d/ -f2 | sort -u | jq -sRc '.')
if [ "$changed" = "[]" ] || [ -z "$changed" ]; then
echo "has-changes=false" >> $GITHUB_OUTPUT
echo "matrix={\"site\": []}" >> $GITHUB_OUTPUT
else
echo "has-changes=true" >> $GITHUB_OUTPUT
echo "matrix={\"site\": $changed}" >> $GITHUB_OUTPUT
fi
fi
plan: plan:
name: Terraform Plan - BCN01-LAB needs: detect-sites
if: needs.detect-sites.outputs.has-changes == 'true'
strategy:
matrix: ${{ fromJSON(needs.detect-sites.outputs.matrix) }}
fail-fast: false
name: Terraform Plan - ${{ matrix.site }}
runs-on: self-hosted runs-on: self-hosted
permissions: permissions:
pull-requests: write pull-requests: write
@@ -24,24 +61,28 @@ jobs:
terraform_version: 1.5.0 terraform_version: 1.5.0
- name: Terraform Init - name: Terraform Init
working-directory: sites/BCN01-LAB working-directory: sites/${{ matrix.site }}
run: terraform init run: |
terraform init \
-backend-config=../../backend.hcl \
-backend-config="key=${{ matrix.site }}/terraform.tfstate"
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1 AWS_REGION: us-east-1
MERAKI_DASHBOARD_API_KEY: ${{ secrets.MERAKI_DASHBOARD_API_KEY }}
- name: Terraform Plan - name: Terraform Plan
id: plan id: plan
working-directory: sites/BCN01-LAB working-directory: sites/${{ matrix.site }}
run: terraform plan -no-color 2>&1 | tee plan_output.txt run: terraform plan -no-color 2>&1 | tee plan_output.txt
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1 AWS_REGION: us-east-1
MERAKI_DASHBOARD_API_KEY: ${{ secrets.MERAKI_DASHBOARD_API_KEY }} MERAKI_DASHBOARD_API_KEY: ${{ secrets.MERAKI_DASHBOARD_API_KEY }}
TF_VAR_radius_secret: ${{ secrets.RADIUS_SECRET }} TF_VAR_radius_secret: ${{ secrets.RADIUS_SECRET }}
TF_VAR_wifi_password_psk: ${{ secrets.WIFI_PASSWORD_PSK }} TF_VAR_wifi_password_psk: ${{ secrets.WIFI_PASSWORD_PSK }}
- name: Comentar Plan en el PR - name: Comentar Plan en el PR
uses: actions/github-script@v7 uses: actions/github-script@v7
@@ -49,11 +90,11 @@ jobs:
with: with:
script: | script: |
const fs = require('fs'); const fs = require('fs');
const plan = fs.readFileSync('sites/BCN01-LAB/plan_output.txt', 'utf8'); const plan = fs.readFileSync('sites/${{ matrix.site }}/plan_output.txt', 'utf8');
const truncated = plan.length > 60000 ? plan.substring(0, 60000) + '\n...(truncado)' : plan; const truncated = plan.length > 60000 ? plan.substring(0, 60000) + '\n...(truncado)' : plan;
github.rest.issues.createComment({ github.rest.issues.createComment({
issue_number: context.issue.number, issue_number: context.issue.number,
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
body: `## Terraform Plan - BCN01-LAB\n\`\`\`\n${truncated}\n\`\`\`` body: `## Terraform Plan - ${{ matrix.site }}\n\`\`\`\n${truncated}\n\`\`\``
}); });
+12
View File
@@ -12,3 +12,15 @@
# Documentacion (HLD, LLD) - no se versiona en git # Documentacion (HLD, LLD) - no se versiona en git
docs/ docs/
# Claude Code
CLAUDE.md
# Terraform runtime artifacts
plan_output.txt
crash.log
crash.*.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
+643
View File
@@ -0,0 +1,643 @@
# 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`.
+4
View File
@@ -0,0 +1,4 @@
bucket = "eqt-terraform-state-629066559706-us-east-1-an"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
+1
View File
@@ -1,4 +1,5 @@
terraform { terraform {
required_version = ">= 1.5.0"
required_providers { required_providers {
meraki = { meraki = {
source = "CiscoDevNet/meraki" source = "CiscoDevNet/meraki"
+12
View File
@@ -9,3 +9,15 @@ output "vlan_ids" {
description = "Mapa de VLAN ID => ID de recurso creado en el MX" description = "Mapa de VLAN ID => ID de recurso creado en el MX"
value = { for k, v in meraki_appliance_vlan.mx_gateways : k => v.vlan_id } value = { for k, v in meraki_appliance_vlan.mx_gateways : k => v.vlan_id }
} }
# IDs de los stacks de switches
output "stack_ids" {
description = "Mapa de nombre de stack => stack ID"
value = local.stack_ids
}
# Seriales de los dispositivos de red por nombre
output "device_serials" {
description = "Mapa de nombre de dispositivo => serial"
value = local.device_serials
}
+2 -4
View File
@@ -18,9 +18,9 @@ variable "firewall_rules" {
policy = string policy = string
protocol = string protocol = string
src_cidr = string src_cidr = string
src_port = string src_port = optional(string, "any")
dest_cidr = string dest_cidr = string
dest_port = string dest_port = optional(string, "any")
syslog_enabled = optional(bool, false) syslog_enabled = optional(bool, false)
})) }))
default = [] default = []
@@ -178,8 +178,6 @@ variable "stack_routing_interfaces" {
ip_address = string # IP estática del stack en esta VLAN ip_address = string # IP estática del stack en esta VLAN
subnet = string # subred en formato CIDR, ej: "10.2.55.0/24" subnet = string # subred en formato CIDR, ej: "10.2.55.0/24"
default_gateway = optional(string, null) # gateway para acceso a internet default_gateway = optional(string, null) # gateway para acceso a internet
dns1 = optional(string, null) # DNS primario
dns2 = optional(string, null) # DNS secundario
})) }))
default = [] default = []
description = "Interfaces L3 (SVIs) en stacks de switches para acceso de gestión al Dashboard de Meraki." description = "Interfaces L3 (SVIs) en stacks de switches para acceso de gestión al Dashboard de Meraki."
+3 -4
View File
@@ -25,9 +25,8 @@ y deben aplicarse directamente en el Meraki Dashboard.
## OWE (Opportunistic Wireless Encryption) — SSID EQT-CORPO ## OWE (Opportunistic Wireless Encryption) — SSID EQT-CORPO
> **Note:** This is not a provider limitation. The provider manages `auth_mode = "open-enhanced"` correctly.
**Dashboard:** Wireless → SSIDs → EQT-CORPO → Edit settings → Security **Dashboard:** Wireless → SSIDs → EQT-CORPO → Edit settings → Security
Activar manualmente **"Opportunistic Wireless Encryption"**. After the first `terraform apply`, verify that **"Opportunistic Wireless Encryption"** is active in the Dashboard. In some cases Meraki requires a one-time manual confirmation click to enable OWE on a new SSID, even though Terraform has already pushed the correct configuration.
> Terraform gestiona `auth_mode = "open-enhanced"` correctamente, pero la
> activación inicial de OWE puede requerir confirmación manual en el Dashboard.
-15
View File
@@ -1,15 +0,0 @@
# Configuración de puertos LAN del firewall MX
# port_id: número del puerto físico en el MX
# type: "trunk" o "access"
# vlan: VLAN nativa (untagged) en trunk, o VLAN de acceso en access
appliance_ports = [
{
# Puerto 7: trunk hacia el stack de switches
# VLAN nativa 109 (MANAGEMENT), permite todas las VLANs
port_id = "7"
enabled = true
type = "trunk"
vlan = 109 # MANAGEMENT - VLAN nativa (untagged)
allowed_vlans = "all"
},
]
+13
View File
@@ -0,0 +1,13 @@
locals {
appliance_ports = [
{
# Port 7: trunk toward the switch stack
# Native VLAN 109 (MANAGEMENT), allows all VLANs
port_id = "7"
enabled = true
type = "trunk"
vlan = 109 # MANAGEMENT — native (untagged) VLAN
allowed_vlans = "all"
},
]
}
-68
View File
@@ -1,68 +0,0 @@
# Reglas de firewall L3
firewall_rules = [
{
# Permite que los switches (VLAN MANAGEMENT) lleguen a internet para acceder al Dashboard de Meraki
comment = "Permitir MANAGEMENT a internet (acceso Dashboard Meraki)"
policy = "allow"
protocol = "any"
src_cidr = "10.2.55.0/24" # VLAN 109 - MANAGEMENT
src_port = "any"
dest_cidr = "any"
dest_port = "any"
syslog_enabled = false
},
{
# Permite que los APs (VLAN APs) lleguen a internet para acceder al Dashboard de Meraki
comment = "Permitir APs a internet (acceso Dashboard Meraki)"
policy = "allow"
protocol = "any"
src_cidr = "10.2.54.0/24" # VLAN 108 - APs
src_port = "any"
dest_cidr = "any"
dest_port = "any"
syslog_enabled = false
},
{
# Permite que los clientes GUEST (VLAN GUEST) lleguen a internet
comment = "Permitir GUEST a internet"
policy = "allow"
protocol = "any"
src_cidr = "10.2.40.0/21" # VLAN 101 - GUEST
src_port = "any"
dest_cidr = "any"
dest_port = "any"
syslog_enabled = false
},
{
# Permite que los clientes SERVERS (VLAN SERVERS) lleguen a internet
comment = "Permitir SERVERS a internet"
policy = "allow"
protocol = "any"
src_cidr = "10.2.56.0/24" # VLAN 102 - SERVERS
src_port = "any"
dest_cidr = "any"
dest_port = "any"
syslog_enabled = false
},
{
# Permite que los clientes GUEST a SERVERS, TEMPORAL
comment = "Permitir GUEST a SERVERS"
policy = "allow"
protocol = "any"
src_cidr = "10.2.40.0/21" # VLAN 101 - GUEST
src_port = "any"
dest_cidr = "10.2.56.0/24" # VLAN 102 - SERVERS
dest_port = "any"
syslog_enabled = false
},
{
comment = "Denegar el resto del trafico de salida"
policy = "deny"
protocol = "any"
src_cidr = "any"
src_port = "any"
dest_cidr = "any"
dest_port = "any"
syslog_enabled = false
}
]
+46
View File
@@ -0,0 +1,46 @@
locals {
firewall_rules = [
{
comment = "Allow MANAGEMENT to internet (Meraki Dashboard access)"
policy = "allow"
protocol = "any"
src_cidr = "10.2.55.0/24" # VLAN 109 - MANAGEMENT
dest_cidr = "any"
},
{
comment = "Allow APs to internet (Meraki Dashboard access)"
policy = "allow"
protocol = "any"
src_cidr = "10.2.54.0/24" # VLAN 108 - APs
dest_cidr = "any"
},
{
comment = "Allow GUEST to internet"
policy = "allow"
protocol = "any"
src_cidr = "10.2.40.0/21" # VLAN 101 - GUEST
dest_cidr = "any"
},
{
comment = "Allow SERVERS to internet"
policy = "allow"
protocol = "any"
src_cidr = "10.2.56.0/24" # VLAN 110 - SERVERS
dest_cidr = "any"
},
{
comment = "Allow GUEST to SERVERS (temporary)"
policy = "allow"
protocol = "any"
src_cidr = "10.2.40.0/21" # VLAN 101 - GUEST
dest_cidr = "10.2.56.0/24" # VLAN 110 - SERVERS
},
{
comment = "Deny all other outbound traffic"
policy = "deny"
protocol = "any"
src_cidr = "any"
dest_cidr = "any"
},
]
}
+22 -32
View File
@@ -1,42 +1,32 @@
# Configuración de Terraform y Provider
terraform { terraform {
required_version = ">= 1.5.0" backend "s3" {} # Config injected via -backend-config in CI (see backend.hcl at repo root)
required_providers { # required_version and required_providers are declared once in modules/meraki-site/main.tf
meraki = {
source = "CiscoDevNet/meraki"
version = "1.9.0"
}
}
backend "s3" {
bucket = "eqt-terraform-state-629066559706-us-east-1-an"
key = "BCN01-LAB/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
} }
provider "meraki" {} provider "meraki" {}
# Llamada al módulo meraki-site locals {
organization_name = "Adevinta Information Services SLU"
network_name = "BCN01-LAB"
}
module "bcn01_lab" { module "bcn01_lab" {
source = "../../modules/meraki-site" source = "../../modules/meraki-site"
organization_name = var.organization_name organization_name = local.organization_name
network_name = var.network_name network_name = local.network_name
switch_vlans = var.switch_vlans switch_vlans = local.switch_vlans
firewall_rules = var.firewall_rules firewall_rules = local.firewall_rules
wireless_ssids = var.wireless_ssids wireless_ssids = local.wireless_ssids
radius_secret = var.radius_secret radius_secret = var.radius_secret
switch_access_policies = var.switch_access_policies
switch_port_configs = var.switch_port_configs
switch_stack_port_configs = var.switch_stack_port_configs
switch_named_port_configs = var.switch_named_port_configs
switch_management_vlan = var.switch_management_vlan
stack_routing_interfaces = var.stack_routing_interfaces
appliance_ports = var.appliance_ports
mx_wan_uplinks = var.mx_wan_uplinks
mx_warm_spare = var.mx_warm_spare
wifi_password_psk = var.wifi_password_psk wifi_password_psk = var.wifi_password_psk
switch_access_policies = local.switch_access_policies
switch_port_configs = local.switch_port_configs
switch_stack_port_configs = local.switch_stack_port_configs
switch_named_port_configs = local.switch_named_port_configs
switch_management_vlan = local.switch_management_vlan
stack_routing_interfaces = local.stack_routing_interfaces
appliance_ports = local.appliance_ports
mx_wan_uplinks = local.mx_wan_uplinks
mx_warm_spare = local.mx_warm_spare
} }
-57
View File
@@ -1,57 +0,0 @@
# SSIDs wireless - BCN01
# NOTA: El shared secret de RADIUS NO está aquí.
# Se pasa como variable de entorno TF_VAR_radius_secret (GitHub secret: RADIUS_SECRET)
wireless_ssids = [
{
number = 0
name = "EQT-CORPO"
enabled = true
auth_mode = "open-enhanced" # OWE (Opportunistic Wireless Encryption)
# Meraki API utiliza "open-enhanced" para indicar Enhanced Open / OWE.
# Este valor refleja exactamente lo que está configurado en el Dashboard.
wpa_encryption_mode = "WPA3 only"
splash_page = "Password-protected with custom RADIUS"
ip_assignment_mode = "Bridge mode"
use_vlan_tagging = true
default_vlan_id = 100
redirect_url = "https://www.adevinta.com"
radius_servers = [
{
host = "15.15.15.15"
port = 1912
}
]
},
{
number = 2
name = "EQT-CORPO-OWE-OK"
enabled = true
visible = false # SSID oculto — no hace broadcast del nombre
auth_mode = "open"
wpa_encryption_mode = null # open no admite wpa_encryption_mode
splash_page = "Password-protected with custom RADIUS"
ip_assignment_mode = "Bridge mode"
use_vlan_tagging = true
default_vlan_id = 100
redirect_url = "https://www.adevinta.com"
radius_servers = [
{
host = "15.15.15.15"
port = 1912
}
]
},
{
number = 1
name = "EQT-GUEST"
enabled = true
auth_mode = "psk" # Modo para contraseña compartida
encryption_mode = "wpa" # Requerido por la API Meraki para PSK
wpa_encryption_mode = "WPA3 Transition Mode"
# psk se inyecta via TF_VAR_wifi_password_psk (GitHub secret WIFI_PASSWORD_PSK)
ip_assignment_mode = "Bridge mode"
use_vlan_tagging = true
default_vlan_id = 101
}
]
+46
View File
@@ -0,0 +1,46 @@
locals {
wireless_ssids = [
{
number = 0
name = "EQT-CORPO"
enabled = true
auth_mode = "open-enhanced" # OWE (Opportunistic Wireless Encryption)
wpa_encryption_mode = "WPA3 only"
splash_page = "Password-protected with custom RADIUS"
ip_assignment_mode = "Bridge mode"
use_vlan_tagging = true
default_vlan_id = 100
redirect_url = "https://www.adevinta.com"
radius_servers = [
{ host = "15.15.15.15", port = 1912 }
]
},
{
number = 2
name = "EQT-CORPO-OWE-OK"
enabled = true
visible = false # Hidden SSID — no broadcast
auth_mode = "open"
splash_page = "Password-protected with custom RADIUS"
ip_assignment_mode = "Bridge mode"
use_vlan_tagging = true
default_vlan_id = 100
redirect_url = "https://www.adevinta.com"
radius_servers = [
{ host = "15.15.15.15", port = 1912 }
]
},
{
number = 1
name = "EQT-GUEST"
enabled = true
auth_mode = "psk"
encryption_mode = "wpa"
wpa_encryption_mode = "WPA3 Transition Mode"
# Password injected via TF_VAR_wifi_password_psk (GitHub Secret: WIFI_PASSWORD_PSK)
ip_assignment_mode = "Bridge mode"
use_vlan_tagging = true
default_vlan_id = 101
},
]
}
-210
View File
@@ -1,210 +0,0 @@
# Configuración de switches MS - BCN01-LAB
# El shared secret de RADIUS se pasa via TF_VAR_radius_secret (GitHub secret: RADIUS_SECRET)
# --- POLÍTICAS DE ACCESO 802.1X ---
switch_access_policies = [
{
name = "DOT1X-CORPO"
access_policy_type = "Hybrid authentication"
host_mode = "Multi-Auth"
radius_accounting_enabled = false
radius_re_authentication_interval = 0
url_redirect_walled_garden_enabled = false
# VLAN a la que cae el puerto si el RADIUS no responde
radius_failed_auth_vlan_id = 101 # GUEST
radius_servers = [
{
host = "15.15.15.15"
port = 1912
}
]
}
]
# --- PUERTOS DE SWITCH ---
# El serial aparece en Dashboard > Switches > nombre del switch > Overview.
# access_policy_number: número auto-asignado por Meraki a la política creada arriba
# (visible en Dashboard > Switches > Switch settings > Access policies)
#
# Ejemplo con los tres tipos de puerto:
# switch_port_configs = [
#
# # Puertos de acceso general con 802.1X (PCs, portátiles)
# # Autenticación: 802.1X → MAB → VLAN GUEST si falla RADIUS
# # La VLAN final la asigna Okta dinámicamente; vlan=100 es el fallback estático
# {
# serial = "XXXX-XXXX-XXXX"
# port_range = "1-20"
# type = "access"
# vlan = 100 # ACCESS - fallback si Okta no devuelve VLAN
# access_policy_type = "Custom access policy"
# access_policy_number = 1 # id de la política DOT1X-CORPO
# },
#
# # Puertos designados para impresoras (sin 802.1X)
# # VLAN asignada estáticamente en el puerto - las Group Policies en switches no asignan VLAN
# {
# serial = "XXXX-XXXX-XXXX"
# port_range = "21-24"
# type = "access"
# vlan = 103 # PRINTERS - VLAN fija en el puerto
# access_policy_type = "Open"
# },
#
# # Puertos designados para APs (sin 802.1X)
# # Igual que impresoras: VLAN fija en el puerto
# {
# serial = "XXXX-XXXX-XXXX"
# port_range = "25-27"
# type = "access"
# vlan = 108 # APs - VLAN fija en el puerto
# access_policy_type = "Open"
# },
#
# # Puerto de uplink (trunk, sin autenticación)
# {
# serial = "XXXX-XXXX-XXXX"
# port_range = "28"
# type = "trunk"
# access_policy_type = "Open"
# },
#
# ]
switch_port_configs = []
# --- PUERTOS DE STACK ---
# Terraform resuelve automáticamente los seriales de todos los miembros del stack.
# El stack_name debe coincidir exactamente con el nombre en Dashboard > Switches > Stacks.
# Terraform aplicará el mismo port_range a CADA switch del stack (ambos de 48 puertos).
#
# Ejemplo para bnc01-lab-stack01 (2x 48 puertos):
# switch_stack_port_configs = [
#
# # Puertos 1-44: acceso general con 802.1X (PCs, portátiles)
# {
# stack_name = "bnc01-lab-stack01"
# port_range = "1-44"
# type = "access"
# vlan = 100 # ACCESS - fallback si Okta no devuelve VLAN
# access_policy_type = "Custom access policy"
# access_policy_number = 1 # id de la política DOT1X-CORPO
# },
#
# # Puertos 45-46: impresoras (VLAN fija, sin 802.1X)
# {
# stack_name = "bnc01-lab-stack01"
# port_range = "45-46"
# type = "access"
# vlan = 103 # PRINTERS
# access_policy_type = "Open"
# },
#
# # Puertos 47-48: APs (VLAN fija, sin 802.1X)
# {
# stack_name = "bnc01-lab-stack01"
# port_range = "47-48"
# type = "access"
# vlan = 108 # APs
# access_policy_type = "Open"
# },
#
# ]
switch_stack_port_configs = [
{
stack_name = "bcn01-lab-stack01"
port_range = "6"
type = "access"
vlan = 101 # GUEST
access_policy_type = "Open"
},
{
# Puerto 44: ISP router (acceso WAN)
stack_name = "bcn01-lab-stack01"
port_range = "44"
name = "ISP router 1"
type = "access"
vlan = 111 # WAN
access_policy_type = "Open"
},
{
# Puerto 45: WAN1 del MX primary
stack_name = "bcn01-lab-stack01"
port_range = "45"
name = "WAN 1 BCN01-F04-MX01"
type = "access"
vlan = 111 # WAN
access_policy_type = "Open"
},
{
# Puerto 46: WAN1 del MX spare
stack_name = "bcn01-lab-stack01"
port_range = "46"
name = "WAN 1 BCN01-F04-MX02"
type = "access"
vlan = 111 # WAN
access_policy_type = "Open"
},
{
# Puerto 47: uplink LAN del MX primary
stack_name = "bcn01-lab-stack01"
port_range = "47"
name = "UPLINK LAN BCN01-F04-MX01"
type = "trunk"
vlan = 109 # MANAGEMENT - VLAN nativa (untagged)
allowed_vlans = "all"
access_policy_type = "Open"
},
{
# Puerto 48: uplink LAN del MX spare
stack_name = "bcn01-lab-stack01"
port_range = "48"
name = "UPLINK LAN BCN01-F04-MX02"
type = "trunk"
vlan = 109 # MANAGEMENT - VLAN nativa (untagged)
allowed_vlans = "all"
access_policy_type = "Open"
},
]
# Puertos de un switch concreto (miembro individual del stack)
# El serial se encuentra en: Dashboard > Switches > eqt-lab-st01-sw01 > Overview
switch_named_port_configs = [
{
# Puerto 1 de eqt-lab-st01-sw01 → VLAN SERVERS (estática, sin autenticación)
switch_name = "eqt-lab-st01-sw01"
port_range = "1"
name = "Servers"
type = "access"
vlan = 101 # SERVERS
access_policy_type = "Open"
},
{
# Puertos 2 y 3 de eqt-lab-st01-sw01 → APs (trunk, nativa 108 APs, tageadas 100 ACCESS y 101 GUEST)
switch_name = "eqt-lab-st01-sw01"
port_range = "2,3"
name = "AP"
type = "trunk"
vlan = 108 # APs - VLAN nativa (untagged)
allowed_vlans = "100,101,108" # ACCESS + GUEST + APs
access_policy_type = "Open"
},
]
# VLAN de gestión de los switches del site
switch_management_vlan = 109
# Interfaces L3 en el stack para acceso de gestión al Dashboard de Meraki
stack_routing_interfaces = [
{
stack_name = "bcn01-lab-stack01"
name = "MANAGEMENT"
vlan_id = 109
ip_address = "10.2.55.2"
subnet = "10.2.55.0/24"
default_gateway = "10.2.55.1"
dns1 = "8.8.8.8"
dns2 = "8.8.4.4"
},
]
+121
View File
@@ -0,0 +1,121 @@
locals {
# 802.1X access policies
# The RADIUS shared secret is injected from var.radius_secret — never put it here.
switch_access_policies = [
{
name = "DOT1X-CORPO"
access_policy_type = "Hybrid authentication"
host_mode = "Multi-Auth"
radius_accounting_enabled = false
radius_re_authentication_interval = 0
url_redirect_walled_garden_enabled = false
radius_failed_auth_vlan_id = 101 # Fallback to GUEST if RADIUS unreachable
radius_servers = [
{ host = "15.15.15.15", port = 1912 }
]
},
]
# Ports by explicit serial — use when targeting a switch directly by serial number
# Example:
# switch_port_configs = [
# {
# serial = "XXXX-XXXX-XXXX"
# port_range = "1-20"
# type = "access"
# vlan = 100 # ACCESS — fallback if RADIUS doesn't assign a VLAN
# access_policy_type = "Custom access policy"
# access_policy_number = 1 # references DOT1X-CORPO above
# },
# ]
switch_port_configs = []
# Ports by stack name — Terraform resolves serials for all stack members automatically.
# The same port_range is applied to EVERY switch in the stack.
switch_stack_port_configs = [
{
stack_name = "bcn01-lab-stack01"
port_range = "6"
type = "access"
vlan = 101 # GUEST
access_policy_type = "Open"
},
{
stack_name = "bcn01-lab-stack01"
port_range = "44"
name = "ISP router 1"
type = "access"
vlan = 111 # WAN
access_policy_type = "Open"
},
{
stack_name = "bcn01-lab-stack01"
port_range = "45"
name = "WAN 1 BCN01-F04-MX01"
type = "access"
vlan = 111 # WAN
access_policy_type = "Open"
},
{
stack_name = "bcn01-lab-stack01"
port_range = "46"
name = "WAN 1 BCN01-F04-MX02"
type = "access"
vlan = 111 # WAN
access_policy_type = "Open"
},
{
stack_name = "bcn01-lab-stack01"
port_range = "47"
name = "UPLINK LAN BCN01-F04-MX01"
type = "trunk"
vlan = 109 # MANAGEMENT — native (untagged) VLAN
allowed_vlans = "all"
access_policy_type = "Open"
},
{
stack_name = "bcn01-lab-stack01"
port_range = "48"
name = "UPLINK LAN BCN01-F04-MX02"
type = "trunk"
vlan = 109 # MANAGEMENT — native (untagged) VLAN
allowed_vlans = "all"
access_policy_type = "Open"
},
]
# Ports by switch display name — targets a specific stack member without knowing its serial
switch_named_port_configs = [
{
switch_name = "eqt-lab-st01-sw01"
port_range = "1"
name = "Servers"
type = "access"
vlan = 101 # SERVERS
access_policy_type = "Open"
},
{
switch_name = "eqt-lab-st01-sw01"
port_range = "2,3"
name = "AP"
type = "trunk"
vlan = 108 # APs — native (untagged) VLAN
allowed_vlans = "100,101,108" # ACCESS + GUEST + APs
access_policy_type = "Open"
},
]
switch_management_vlan = 109
# L3 routing interfaces on the stack (SVIs for management access to Meraki Dashboard)
stack_routing_interfaces = [
{
stack_name = "bcn01-lab-stack01"
name = "MANAGEMENT"
vlan_id = 109
ip_address = "10.2.55.2"
subnet = "10.2.55.0/24"
default_gateway = "10.2.55.1"
},
]
}
+7 -201
View File
@@ -1,209 +1,15 @@
# Definición de la Organización # Sensitive variables — injected via TF_VAR_* environment variables (GitHub Secrets).
variable "organization_name" { # Never put values for these in any .tf file.
type = string
description = "Nombre exacto de tu organización en el Dashboard de Meraki"
}
# Definición de la Red
variable "network_name" {
type = string
description = "Nombre de la red (Network) donde reside el switch"
}
# Reglas de firewall L3
variable "firewall_rules" {
type = list(object({
comment = string
policy = string
protocol = string
src_cidr = string
src_port = string
dest_cidr = string
dest_port = string
syslog_enabled = optional(bool, false)
}))
default = []
description = "Lista de reglas de firewall L3 para el site"
}
# SSIDs wireless
variable "wireless_ssids" {
type = list(object({
number = number
name = string
enabled = optional(bool, true)
auth_mode = string
psk = optional(string, null)
encryption_mode = optional(string, null) # "wpa" para PSK; null para open/OWE
splash_page = optional(string, "None")
wpa_encryption_mode = optional(string, "WPA3 only")
ip_assignment_mode = optional(string, "Bridge mode")
use_vlan_tagging = optional(bool, false)
default_vlan_id = optional(number, null)
redirect_url = optional(string, "")
radius_servers = optional(list(object({
host = string
port = number
})), [])
}))
default = []
description = "Lista de SSIDs wireless a configurar en el site"
}
variable "wifi_password_psk" {
type = string
description = "Password para la SSID WPA2 desde GitHub Secrets"
sensitive = true
}
# Shared secret para servidores RADIUS (sensible, no incluir en tfvars)
variable "radius_secret" { variable "radius_secret" {
type = string type = string
sensitive = true sensitive = true
default = "" default = ""
description = "Shared secret para autenticación RADIUS. Pasar via TF_VAR_radius_secret" description = "RADIUS shared secret. Pass via TF_VAR_radius_secret (GitHub Secret: RADIUS_SECRET)."
} }
# Definición de VLANs variable "wifi_password_psk" {
variable "switch_vlans" { type = string
type = map(object({ sensitive = true
name = string description = "PSK Wi-Fi password. Pass via TF_VAR_wifi_password_psk (GitHub Secret: WIFI_PASSWORD_PSK)."
subnet = optional(string, null)
appliance_ip = optional(string, null)
dhcp_handling = optional(string, "Run a DHCP server")
reserved_ip_ranges = optional(list(object({
comment = string
id = string
start = string
end = string
})), [])
}))
description = "Mapa de configuraciones de VLAN. La clave del mapa será el ID de la VLAN"
}
# Políticas de acceso 802.1X para switches
variable "switch_access_policies" {
type = list(object({
name = string
access_policy_type = optional(string, "802.1x")
host_mode = optional(string, "Multi-Auth")
radius_accounting_enabled = optional(bool, false)
radius_testing_enabled = optional(bool, false)
radius_coa_support_enabled = optional(bool, false)
radius_failed_auth_vlan_id = optional(number, null)
radius_re_authentication_interval = optional(number, 0)
url_redirect_walled_garden_enabled = optional(bool, false)
radius_servers = list(object({
host = string
port = number
}))
}))
default = []
description = "Políticas de acceso 802.1X para switches MS"
}
# Configuración de puertos de switch
variable "switch_port_configs" {
type = list(object({
serial = string
port_range = string
name = optional(string, "")
type = optional(string, "access")
vlan = optional(number, null)
allowed_vlans = optional(string, "all")
access_policy_type = optional(string, "Open")
access_policy_number = optional(number, null)
}))
default = []
description = "Puertos de switch a configurar con 802.1X. Requiere serial del switch."
}
variable "switch_stack_port_configs" {
type = list(object({
stack_name = string
port_range = string
name = optional(string, "")
type = optional(string, "access")
vlan = optional(number, null)
allowed_vlans = optional(string, "all")
access_policy_type = optional(string, "Open")
access_policy_number = optional(number, null)
}))
default = []
description = "Puertos de stack a configurar por nombre. Terraform resuelve los seriales automáticamente."
}
variable "switch_named_port_configs" {
type = list(object({
switch_name = string
port_range = string
name = optional(string, "")
type = optional(string, "access")
vlan = optional(number, null)
allowed_vlans = optional(string, "all")
access_policy_type = optional(string, "Open")
access_policy_number = optional(number, null)
}))
default = []
description = "Puertos de switch a configurar por nombre de dispositivo. Terraform resuelve el serial automáticamente."
}
variable "switch_management_vlan" {
type = number
default = null
description = "VLAN ID de gestión para los switches del site."
}
variable "stack_routing_interfaces" {
type = list(object({
stack_name = string
name = string
vlan_id = number
ip_address = string
subnet = string
default_gateway = optional(string, null)
dns1 = optional(string, null)
dns2 = optional(string, null)
}))
default = []
description = "Interfaces L3 (SVIs) en stacks de switches para acceso de gestión al Dashboard."
}
variable "appliance_ports" {
type = list(object({
port_id = string
enabled = optional(bool, true)
type = optional(string, "access")
vlan = optional(number, null)
allowed_vlans = optional(string, "all")
drop_untagged_traffic = optional(bool, false)
}))
default = []
description = "Configuración de puertos LAN del firewall MX."
}
variable "mx_warm_spare" {
type = object({
enabled = optional(bool, true)
spare_name = string
uplink_mode = optional(string, "virtual")
virtual_ip1 = optional(string, null)
virtual_ip2 = optional(string, null)
})
default = null
description = "Configuración Warm Spare (HA) del MX."
}
variable "mx_wan_uplinks" {
type = list(object({
name = string # Nombre del dispositivo en el Dashboard
wan1_static_ip = optional(string, null)
wan1_static_subnet_mask = optional(string, null)
wan1_static_gateway_ip = optional(string, null)
wan1_static_dns = optional(list(string), null)
}))
default = []
description = "Configuración WAN1 estática de los MX. El serial se resuelve automáticamente por nombre de dispositivo."
} }
-97
View File
@@ -1,97 +0,0 @@
# Nombre exacto que aparece en tu Dashboard de Meraki
organization_name = "Adevinta Information Services SLU"
network_name = "BCN01-LAB"
# Configuración de las VLANs (L3)
# La clave (ej. "10") es el ID de la VLAN
switch_vlans = {
"100" = {
name = "ACCESS"
subnet = "10.2.32.0/21"
appliance_ip = "10.2.32.1"
reserved_ip_ranges = [
{ comment = "Estáticas reservadas", id = "static", start = "10.2.32.1", end = "10.2.32.49" }
]
}
"101" = {
name = "GUEST"
subnet = "10.2.40.0/21"
appliance_ip = "10.2.40.1"
reserved_ip_ranges = [
{ comment = "Estáticas reservadas", id = "static", start = "10.2.40.1", end = "10.2.40.49" }
]
}
"102" = {
name = "VC"
subnet = "10.2.48.0/24"
appliance_ip = "10.2.48.1"
reserved_ip_ranges = [
{ comment = "Estáticas reservadas", id = "static", start = "10.2.48.1", end = "10.2.48.49" }
]
}
"103" = {
name = "PRINTERS"
subnet = "10.2.49.0/24"
appliance_ip = "10.2.49.1"
reserved_ip_ranges = [
{ comment = "Estáticas reservadas", id = "static", start = "10.2.49.1", end = "10.2.49.49" }
]
}
"104" = {
name = "DISPLAYS"
subnet = "10.2.50.0/24"
appliance_ip = "10.2.50.1"
reserved_ip_ranges = [
{ comment = "Estáticas reservadas", id = "static", start = "10.2.50.1", end = "10.2.50.49" }
]
}
"105" = {
name = "BOOKING"
subnet = "10.2.51.0/24"
appliance_ip = "10.2.51.1"
reserved_ip_ranges = [
{ comment = "Estáticas reservadas", id = "static", start = "10.2.51.1", end = "10.2.51.49" }
]
}
"106" = {
name = "BADGE_READERS"
subnet = "10.2.52.0/24"
appliance_ip = "10.2.52.1"
reserved_ip_ranges = [
{ comment = "Estáticas reservadas", id = "static", start = "10.2.52.1", end = "10.2.52.49" }
]
}
"107" = {
name = "CCTV"
subnet = "10.2.53.0/24"
appliance_ip = "10.2.53.1"
reserved_ip_ranges = [
{ comment = "Estáticas reservadas", id = "static", start = "10.2.53.1", end = "10.2.53.49" }
]
}
"108" = {
name = "APs"
subnet = "10.2.54.0/24"
appliance_ip = "10.2.54.1"
reserved_ip_ranges = [
{ comment = "Estáticas reservadas", id = "static", start = "10.2.54.1", end = "10.2.54.49" }
]
}
"109" = {
name = "MANAGEMENT"
subnet = "10.2.55.0/24"
appliance_ip = "10.2.55.1"
dhcp_handling = "Do not respond to DHCP requests"
}
"110" = {
name = "SERVERS"
subnet = "10.2.56.0/24"
appliance_ip = "10.2.56.1"
dhcp_handling = "Do not respond to DHCP requests"
}
"111" = {
name = "WAN"
dhcp_handling = "Do not respond to DHCP requests"
# Sin L3: sin subnet ni appliance_ip (VLAN de switching puro hacia el ISP)
}
}
+93
View File
@@ -0,0 +1,93 @@
locals {
switch_vlans = {
"100" = {
name = "ACCESS"
subnet = "10.2.32.0/21"
appliance_ip = "10.2.32.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.32.1", end = "10.2.32.49" }
]
}
"101" = {
name = "GUEST"
subnet = "10.2.40.0/21"
appliance_ip = "10.2.40.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.40.1", end = "10.2.40.49" }
]
}
"102" = {
name = "VC"
subnet = "10.2.48.0/24"
appliance_ip = "10.2.48.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.48.1", end = "10.2.48.49" }
]
}
"103" = {
name = "PRINTERS"
subnet = "10.2.49.0/24"
appliance_ip = "10.2.49.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.49.1", end = "10.2.49.49" }
]
}
"104" = {
name = "DISPLAYS"
subnet = "10.2.50.0/24"
appliance_ip = "10.2.50.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.50.1", end = "10.2.50.49" }
]
}
"105" = {
name = "BOOKING"
subnet = "10.2.51.0/24"
appliance_ip = "10.2.51.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.51.1", end = "10.2.51.49" }
]
}
"106" = {
name = "BADGE_READERS"
subnet = "10.2.52.0/24"
appliance_ip = "10.2.52.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.52.1", end = "10.2.52.49" }
]
}
"107" = {
name = "CCTV"
subnet = "10.2.53.0/24"
appliance_ip = "10.2.53.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.53.1", end = "10.2.53.49" }
]
}
"108" = {
name = "APs"
subnet = "10.2.54.0/24"
appliance_ip = "10.2.54.1"
reserved_ip_ranges = [
{ comment = "Static reserved", id = "static", start = "10.2.54.1", end = "10.2.54.49" }
]
}
"109" = {
name = "MANAGEMENT"
subnet = "10.2.55.0/24"
appliance_ip = "10.2.55.1"
dhcp_handling = "Do not respond to DHCP requests"
}
"110" = {
name = "SERVERS"
subnet = "10.2.56.0/24"
appliance_ip = "10.2.56.1"
dhcp_handling = "Do not respond to DHCP requests"
}
"111" = {
name = "WAN"
dhcp_handling = "Do not respond to DHCP requests"
# No subnet or appliance_ip — L2-only switching VLAN toward the ISP
}
}
}
-32
View File
@@ -1,32 +0,0 @@
# Configuración WAN1 estática de los firewalls MX
# El nombre debe coincidir exactamente con el nombre del dispositivo en el Dashboard
# Dashboard > Security & SD-WAN > Appliance > nombre del dispositivo
# Warm Spare (HA): VIP flotante entre primary y spare
# La IP de salida del tráfico será siempre la VIP
mx_warm_spare = {
enabled = true
spare_name = "BCN01-F04-MX02"
uplink_mode = "virtual"
virtual_ip1 = "213.229.159.148" # VIP WAN1
virtual_ip2 = "10.212.160.40" # VIP WAN2
}
mx_wan_uplinks = [
{
# MX Primary
name = "BCN01-F04-MX01" # TODO: ajustar al nombre real en el Dashboard
wan1_static_ip = "213.229.159.145"
wan1_static_subnet_mask = "255.255.255.240" # /28
wan1_static_gateway_ip = "213.229.159.147"
wan1_static_dns = ["8.8.8.8", "8.8.4.4"]
},
{
# MX Spare (Warm Spare / HA)
name = "BCN01-F04-MX02" # TODO: ajustar al nombre real en el Dashboard
wan1_static_ip = "213.229.159.146"
wan1_static_subnet_mask = "255.255.255.240" # /28
wan1_static_gateway_ip = "213.229.159.147"
wan1_static_dns = ["8.8.8.8", "8.8.4.4"]
},
]
+29
View File
@@ -0,0 +1,29 @@
locals {
# Warm Spare (HA) — floating VIP between primary and spare MX
mx_warm_spare = {
enabled = true
spare_name = "BCN01-F04-MX02"
uplink_mode = "virtual"
virtual_ip1 = "213.229.159.148" # Floating VIP on WAN1
virtual_ip2 = "10.212.160.40" # Floating VIP on WAN2
}
# Static WAN1 configuration for each MX
# Device serials are resolved automatically from the display name
mx_wan_uplinks = [
{
name = "BCN01-F04-MX01"
wan1_static_ip = "213.229.159.145"
wan1_static_subnet_mask = "255.255.255.240" # /28
wan1_static_gateway_ip = "213.229.159.147"
wan1_static_dns = ["8.8.8.8", "8.8.4.4"]
},
{
name = "BCN01-F04-MX02"
wan1_static_ip = "213.229.159.146"
wan1_static_subnet_mask = "255.255.255.240" # /28
wan1_static_gateway_ip = "213.229.159.147"
wan1_static_dns = ["8.8.8.8", "8.8.4.4"]
},
]
}