From 3f502b69a71dec9958130fb6180cc4ada7064a6a Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 20 Aug 2026 18:08:06 -0700 Subject: [PATCH 1/3] feat(routeros): add sw-core01 opentofu root Manage the CRS309 core switch from GilmanLab/networking with a pinned local CA, ROS_* env credentials, and adoption import blocks. --- routeros/sw-core01/.gitignore | 5 + routeros/sw-core01/.terraform.lock.hcl | 37 ++++++++ routeros/sw-core01/Justfile | 48 ++++++++++ routeros/sw-core01/README.md | 66 +++++++++++++ routeros/sw-core01/backend.tf | 8 ++ routeros/sw-core01/bridge.tf | 77 +++++++++++++++ routeros/sw-core01/certs/.gitkeep | 0 routeros/sw-core01/certs/sw-core01-ca.crt | 18 ++++ routeros/sw-core01/ethernet.tf | 54 +++++++++++ routeros/sw-core01/imports.tf | 109 ++++++++++++++++++++++ routeros/sw-core01/mgmt.tf | 24 +++++ routeros/sw-core01/providers.tf | 5 + routeros/sw-core01/services.tf | 51 ++++++++++ routeros/sw-core01/system.tf | 12 +++ routeros/sw-core01/terraform.tf | 10 ++ 15 files changed, 524 insertions(+) create mode 100644 routeros/sw-core01/.gitignore create mode 100644 routeros/sw-core01/.terraform.lock.hcl create mode 100644 routeros/sw-core01/Justfile create mode 100644 routeros/sw-core01/README.md create mode 100644 routeros/sw-core01/backend.tf create mode 100644 routeros/sw-core01/bridge.tf create mode 100644 routeros/sw-core01/certs/.gitkeep create mode 100644 routeros/sw-core01/certs/sw-core01-ca.crt create mode 100644 routeros/sw-core01/ethernet.tf create mode 100644 routeros/sw-core01/imports.tf create mode 100644 routeros/sw-core01/mgmt.tf create mode 100644 routeros/sw-core01/providers.tf create mode 100644 routeros/sw-core01/services.tf create mode 100644 routeros/sw-core01/system.tf create mode 100644 routeros/sw-core01/terraform.tf diff --git a/routeros/sw-core01/.gitignore b/routeros/sw-core01/.gitignore new file mode 100644 index 0000000..9d1af6c --- /dev/null +++ b/routeros/sw-core01/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +tfplan +*.tfstate +*.tfstate.backup +pre-apply.rsc diff --git a/routeros/sw-core01/.terraform.lock.hcl b/routeros/sw-core01/.terraform.lock.hcl new file mode 100644 index 0000000..0a62494 --- /dev/null +++ b/routeros/sw-core01/.terraform.lock.hcl @@ -0,0 +1,37 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/terraform-routeros/routeros" { + version = "1.99.1" + constraints = "~> 1.0" + hashes = [ + "h1:1KWPcLddi3UAXMa7ZFi/IcniAOnMaRgxSreQgY5UpvU=", + "h1:5QGT+Dt9Gdy6BfGguoX+Zx3zBt+84ZMDgS5Ma0IBB+c=", + "h1:5djJeu4l5UiJKbvTy02ZtrizymGivxOtl9oLR1uCSyE=", + "h1:5wO5v4Xyi2XxOAPObwHLQEs1/zAGBeqjnC0yng+ZBS8=", + "h1:DweIuEd2oFIBgsc8oAsPR/X4ioHmXBH0UsSMJGtp0uc=", + "h1:OqlmH3ZKk+SiMHRRiBnirAsSxoDLC5xhHmODj5kIy1Q=", + "h1:PoQv4kGpY+nZvg3ewZEAj/64TVAiGKEpWld167c1L9M=", + "h1:RfI38WE27X14QTsErxS6jsnSTMgngyilSeH8Ygyn8IY=", + "h1:SamSNQ3L/s63YcwlHvhvXKhjqHIDbiSkqKvBL+r1+uU=", + "h1:slX5FrpPj7cboNLs4mb8UULrEqO18Q41SleUc2SaaXg=", + "h1:t/hSx26sCh+A0Sj28XmbfP7BbFHqkdzy9+muPkS9IyY=", + "h1:tZS1iS4I2MOCZ891qD7PGWwOz6kVLVF7iyi5fbKBS7g=", + "h1:uhvBYkHmx6pZ9PsR2H42j096fJLpfVvtJJffuJigtII=", + "h1:xYFAufza35FKXMWmcX4ctp98uWB1wny5xSoa6WIX81o=", + "zh:1d94c6bfa50af17b02608b9dc050767aaaf665bb03fd6ef5dcb0ed20153d0349", + "zh:224f0a7828291e9535769706a1fae30c10fd15cd3fdcb8094fdffcb83f91671b", + "zh:3765f09dd024c39cb850a8f6e6d3cc3a3018ece1209e7b7473a489d639e12c8f", + "zh:3948fa15907ba6be7a22407b86ad80a2122b2e81c0d4ccdefeafa4ce57839928", + "zh:5176354f60331c16c52a47b310a20fa6812e957bec042b8664ffc4b547b43734", + "zh:7ad6abffbcdd6ff61602a97e860a6961556726f1bb6b582c9fac9c37ee08fc5e", + "zh:87073d29a0b29aca139f5f581fe183419f457bc34addbe48d3c2ceecb9c94914", + "zh:a84fb2bf078e548fc00080c19c075f52650968cca897455b79726566ba5f13c0", + "zh:ade29eb26d3a6f6ff5073029086ee78a3b3533afc0414f12df3746711358e51b", + "zh:bc332546ed3903363877fc9b575a1eff6049fd2671e581f001d13c6190ffae36", + "zh:cd714f65083fe59e2210f868ee98dcd19f3c61b5b32943823e422337d9d34665", + "zh:e2352ded68b39b200a2e37c6b149cc95313cf0205ac553f638e85b8f28b76f63", + "zh:ee5f855417cd6e6c568c742b45b76f777306de949fbfcd43151dbec41697b6c6", + "zh:fcaff357fe794fdcc206934d75b1d74811fd33914c06bf47423281f9f41c6054", + ] +} diff --git a/routeros/sw-core01/Justfile b/routeros/sw-core01/Justfile new file mode 100644 index 0000000..d5100bf --- /dev/null +++ b/routeros/sw-core01/Justfile @@ -0,0 +1,48 @@ +set shell := ["bash", "-euo", "pipefail", "-c"] + +secrets_file := "network/sw-core01/terraform.sops.yaml" + +default: + @just --list + +# Offline validation: no backend or credentials required. +check: + tofu fmt -check -recursive + tofu init -backend=false -input=false + ROS_USERNAME="${ROS_USERNAME:-validate}" ROS_PASSWORD="${ROS_PASSWORD:-validate}" tofu validate + +# Format Tofu files in place. +fmt: + tofu fmt -recursive + +# Initialize the working directory against the lab S3 backend. +init: + test -n "${GLAB_AWS_STATE_BUCKET:-}" || { echo "Set GLAB_AWS_STATE_BUCKET to the pre-created S3 backend bucket." >&2; exit 1; } + AWS_PROFILE="${AWS_PROFILE:-lab-admin}" tofu init -reconfigure -backend-config="bucket=${GLAB_AWS_STATE_BUCKET}" + +# Render and save a plan to `tfplan`. +plan: + test -n "${GLAB_SECRETS_DIR:-}" || { echo "Set GLAB_SECRETS_DIR to the GilmanLab/secrets checkout." >&2; exit 1; }; \ + test -n "${GLAB_AWS_STATE_BUCKET:-}" || { echo "Set GLAB_AWS_STATE_BUCKET to the pre-created S3 backend bucket." >&2; exit 1; }; \ + export AWS_PROFILE="${AWS_PROFILE:-lab-admin}"; \ + export ROS_USERNAME="$(sops -d --extract '["username"]' "$GLAB_SECRETS_DIR/{{secrets_file}}")"; \ + export ROS_PASSWORD="$(sops -d --extract '["password"]' "$GLAB_SECRETS_DIR/{{secrets_file}}")"; \ + tofu plan -out=tfplan + +# Apply the saved plan produced by `just plan`. +apply: + test -n "${GLAB_SECRETS_DIR:-}" || { echo "Set GLAB_SECRETS_DIR to the GilmanLab/secrets checkout." >&2; exit 1; }; \ + test -n "${GLAB_AWS_STATE_BUCKET:-}" || { echo "Set GLAB_AWS_STATE_BUCKET to the pre-created S3 backend bucket." >&2; exit 1; }; \ + export AWS_PROFILE="${AWS_PROFILE:-lab-admin}"; \ + export ROS_USERNAME="$(sops -d --extract '["username"]' "$GLAB_SECRETS_DIR/{{secrets_file}}")"; \ + export ROS_PASSWORD="$(sops -d --extract '["password"]' "$GLAB_SECRETS_DIR/{{secrets_file}}")"; \ + tofu apply tfplan + +# Export live config from the switch before any apply. +snapshot: + ssh admin@10.10.10.2 '/export show-sensitive file=pre-apply' + scp admin@10.10.10.2:pre-apply.rsc . + +# Show outputs from the last applied state. +output: + AWS_PROFILE="${AWS_PROFILE:-lab-admin}" tofu output diff --git a/routeros/sw-core01/README.md b/routeros/sw-core01/README.md new file mode 100644 index 0000000..be47219 --- /dev/null +++ b/routeros/sw-core01/README.md @@ -0,0 +1,66 @@ +# sw-core01 + +OpenTofu root for the lab core switch (`sw-core01`, MikroTik CRS309-1G-8S+). +State lives at `s3://$GLAB_AWS_STATE_BUCKET/networking/routeros/sw-core01.tfstate`. + +The bootstrap runbook that produces the TLS pin, service account, and first +adoption is `docs/docs/runbooks/sw-core01-configuration.md` in the meta +repository. + +## Prerequisites + +- `tofu` >= 1.10 +- `just` +- `sops` +- SSH access as `admin@10.10.10.2` for snapshots +- Network reach to `10.10.10.2` + +Export these from the workspace `.envrc` before `init`/`plan`/`apply`: + +```sh +export AWS_PROFILE=lab-admin +export GLAB_AWS_STATE_BUCKET=glab-lab-tfstate-186067932323 +export GLAB_SECRETS_DIR=/path/to/GilmanLab/secrets +``` + +`just plan` and `just apply` decrypt `username`/`password` from +`$GLAB_SECRETS_DIR/network/sw-core01/terraform.sops.yaml` into +`ROS_USERNAME`/`ROS_PASSWORD`. Those values are never Terraform variables. + +## Certificate pin + +`certs/sw-core01-ca.crt` is the on-device local CA (`CN=sw-core01-ca`) that +signs the `sw-core01-tls` leaf. The provider `ca_certificate` points at this +file. RouterOS 7.16 cannot self-sign a leaf, so the pin is the CA, not the +leaf. Certificate lifecycle remains runbook-owned. + +## Plan and apply + +```sh +just check +just init +just snapshot +just plan +just apply +``` + +Run `just snapshot` before every apply. RouterOS has no commit-confirmed, and +Safe Mode does not cover REST. + +`just check` is offline (`fmt -check`, `init -backend=false`, `validate`). +CI never decrypts secrets and never contacts the device. + +## Drift + +Run `just plan` before every change, after any RouterOS upgrade, and ad hoc. +There is no CI drift job. + +## Notes + +- Ethernet names stay at factory (`ether1`, `sfp-sfpplus1`–`8`). Roles and + PHY IDs live in comments. +- `ether1` is disabled (`comment = "unused"`). +- Users, passwords, and the `sw-core01-tls` leaf certificate are + runbook-owned. The `svc-tofu` user is restricted to + `10.10.10.0/24,192.168.1.0/24,100.64.0.0/10`. +- Delete `imports.tf` after the adoption apply. diff --git a/routeros/sw-core01/backend.tf b/routeros/sw-core01/backend.tf new file mode 100644 index 0000000..bcb5617 --- /dev/null +++ b/routeros/sw-core01/backend.tf @@ -0,0 +1,8 @@ +terraform { + backend "s3" { + key = "networking/routeros/sw-core01.tfstate" + region = "us-west-2" + encrypt = true + use_lockfile = true + } +} diff --git a/routeros/sw-core01/bridge.tf b/routeros/sw-core01/bridge.tf new file mode 100644 index 0000000..87f9944 --- /dev/null +++ b/routeros/sw-core01/bridge.tf @@ -0,0 +1,77 @@ +resource "routeros_interface_bridge" "lab" { + name = "bridge-lab" + vlan_filtering = true +} + +resource "routeros_interface_bridge_port" "sfp_sfpplus1" { + bridge = routeros_interface_bridge.lab.name + interface = routeros_interface_ethernet.sfp_sfpplus1.name + frame_types = "admit-only-vlan-tagged" + ingress_filtering = true +} + +resource "routeros_interface_bridge_port" "sfp_sfpplus2" { + bridge = routeros_interface_bridge.lab.name + interface = routeros_interface_ethernet.sfp_sfpplus2.name + frame_types = "admit-only-vlan-tagged" + ingress_filtering = true +} + +resource "routeros_interface_bridge_port" "sfp_sfpplus3" { + bridge = routeros_interface_bridge.lab.name + interface = routeros_interface_ethernet.sfp_sfpplus3.name + frame_types = "admit-only-vlan-tagged" + ingress_filtering = true +} + +resource "routeros_interface_bridge_port" "sfp_sfpplus4" { + bridge = routeros_interface_bridge.lab.name + interface = routeros_interface_ethernet.sfp_sfpplus4.name + frame_types = "admit-only-vlan-tagged" + ingress_filtering = true +} + +resource "routeros_interface_bridge_port" "sfp_sfpplus5" { + bridge = routeros_interface_bridge.lab.name + interface = routeros_interface_ethernet.sfp_sfpplus5.name + frame_types = "admit-only-vlan-tagged" + ingress_filtering = true +} + +resource "routeros_interface_bridge_port" "sfp_sfpplus6" { + bridge = routeros_interface_bridge.lab.name + interface = routeros_interface_ethernet.sfp_sfpplus6.name + frame_types = "admit-only-vlan-tagged" + ingress_filtering = true +} + +resource "routeros_interface_bridge_port" "sfp_sfpplus7" { + bridge = routeros_interface_bridge.lab.name + interface = routeros_interface_ethernet.sfp_sfpplus7.name + frame_types = "admit-only-vlan-tagged" + ingress_filtering = true +} + +resource "routeros_interface_bridge_port" "sfp_sfpplus8" { + bridge = routeros_interface_bridge.lab.name + interface = routeros_interface_ethernet.sfp_sfpplus8.name + frame_types = "admit-only-vlan-tagged" + ingress_filtering = true +} + +resource "routeros_interface_bridge_vlan" "vlan10" { + bridge = routeros_interface_bridge.lab.name + vlan_ids = ["10"] + tagged = [ + routeros_interface_bridge.lab.name, + routeros_interface_ethernet.sfp_sfpplus8.name, + ] +} + +resource "routeros_interface_bridge_vlan" "vlan40" { + bridge = routeros_interface_bridge.lab.name + vlan_ids = ["40"] + tagged = [ + routeros_interface_ethernet.sfp_sfpplus8.name, + ] +} diff --git a/routeros/sw-core01/certs/.gitkeep b/routeros/sw-core01/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/routeros/sw-core01/certs/sw-core01-ca.crt b/routeros/sw-core01/certs/sw-core01-ca.crt new file mode 100644 index 0000000..085b034 --- /dev/null +++ b/routeros/sw-core01/certs/sw-core01-ca.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC8jCCAdqgAwIBAgIICxv5e44fxSgwDQYJKoZIhvcNAQELBQAwFzEVMBMGA1UE +AwwMc3ctY29yZTAxLWNhMB4XDTI2MDgyMTAxMDIwNloXDTM2MDgxODAxMDIwNlow +FzEVMBMGA1UEAwwMc3ctY29yZTAxLWNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA6HNvRkVjoTHhrL0Sw7igkBESPP/KSRel4lyXHslxtujbkZQ3gcu5 +0SRFaDgMtlZsqw8rlP+AKBhuZ5zU7+qD+83freRuXVwbZhhX/YgD6z18sNWztkvh +0NXFVnAMyGK1pmshsLhiM7+FYvDnvXLHWN/JSqoQ/giGdT942yimU0ciduVvIbAf +Xrthl/hZbjO4SNVo6SSferfH9GLIBPmEK3/wn44+jUbyGWbY7PXCRtDniuKMgW3k +yMaROoMrGRQg5NSgEj0TyKtRDdD1kIJHHbhrBRtDBOeHy0tL/NBNu7HJR4UwTuCv +E+N2Z7fVXk503/wmgOLikfO0oN7up3txhQIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUb2FwnXpYtLOBhezrgU9TA3bT +pbUwDQYJKoZIhvcNAQELBQADggEBAH098juhK4/md3Zi0onQaLaitaIFdbWR8aDq +H5vXfB+tkqgOt7D+DR4GxQ1o3PAKsuGMPrjQO9tuuGinqZ6S0W1Geo6c2Ag4g5Cw +dwJg4+r4jjnqyJQFRjPUBKMte6r4sUZOWPtdLRMYukDLXn9o5gZ8EsGhSLQRsBSc +TTgWf90w8P9rjnyE1zwSUfRoQwnXdhvun5Jh3qoThi9urgTP+kHoIxVD1YCb7I/U +HZhGPYOgM8ozn5PqOxFSizmXFQGuvMYagLoqwKlnjQb33wPABA/AGnjp2N+1Hl8I ++a+z8m0uSV7DX+dBTBSfPrIH74eWkTUkOoWOx1mVIOS/enDJybY= +-----END CERTIFICATE----- diff --git a/routeros/sw-core01/ethernet.tf b/routeros/sw-core01/ethernet.tf new file mode 100644 index 0000000..5521611 --- /dev/null +++ b/routeros/sw-core01/ethernet.tf @@ -0,0 +1,54 @@ +resource "routeros_interface_ethernet" "ether1" { + factory_name = "ether1" + name = "ether1" + disabled = true + comment = "unused" +} + +resource "routeros_interface_ethernet" "sfp_sfpplus1" { + factory_name = "sfp-sfpplus1" + name = "sfp-sfpplus1" + comment = "lab01 SFP right (PHY-012)" +} + +resource "routeros_interface_ethernet" "sfp_sfpplus2" { + factory_name = "sfp-sfpplus2" + name = "sfp-sfpplus2" + comment = "lab01 SFP left (PHY-013)" +} + +resource "routeros_interface_ethernet" "sfp_sfpplus3" { + factory_name = "sfp-sfpplus3" + name = "sfp-sfpplus3" + comment = "lab02 SFP right (PHY-014)" +} + +resource "routeros_interface_ethernet" "sfp_sfpplus4" { + factory_name = "sfp-sfpplus4" + name = "sfp-sfpplus4" + comment = "lab02 SFP left (PHY-015)" +} + +resource "routeros_interface_ethernet" "sfp_sfpplus5" { + factory_name = "sfp-sfpplus5" + name = "sfp-sfpplus5" + comment = "lab03 SFP right (PHY-016)" +} + +resource "routeros_interface_ethernet" "sfp_sfpplus6" { + factory_name = "sfp-sfpplus6" + name = "sfp-sfpplus6" + comment = "lab03 SFP left (PHY-017)" +} + +resource "routeros_interface_ethernet" "sfp_sfpplus7" { + factory_name = "sfp-sfpplus7" + name = "sfp-sfpplus7" + comment = "nas01 (PHY-018)" +} + +resource "routeros_interface_ethernet" "sfp_sfpplus8" { + factory_name = "sfp-sfpplus8" + name = "sfp-sfpplus8" + comment = "gw01 trunk (PHY-002)" +} diff --git a/routeros/sw-core01/imports.tf b/routeros/sw-core01/imports.tf new file mode 100644 index 0000000..a4c76d9 --- /dev/null +++ b/routeros/sw-core01/imports.tf @@ -0,0 +1,109 @@ +# Deleted after the adoption apply. Live RouterOS item IDs (*HEX) are filled +# from the device before that apply wherever name-based addressing is not +# unique; name-based IDs follow the provider import docs. + +import { + to = routeros_interface_bridge.lab + id = "name=bridge-lab" +} + +import { + to = routeros_interface_bridge_port.sfp_sfpplus1 + id = "interface=sfp-sfpplus1" +} + +import { + to = routeros_interface_bridge_port.sfp_sfpplus2 + id = "interface=sfp-sfpplus2" +} + +import { + to = routeros_interface_bridge_port.sfp_sfpplus3 + id = "interface=sfp-sfpplus3" +} + +import { + to = routeros_interface_bridge_port.sfp_sfpplus4 + id = "interface=sfp-sfpplus4" +} + +import { + to = routeros_interface_bridge_port.sfp_sfpplus8 + id = "interface=sfp-sfpplus8" +} + +import { + to = routeros_interface_bridge_vlan.vlan10 + # TODO(adoption): fill live ID + # :put [/interface/bridge/vlan get [print show-ids]] + id = "*TODO" +} + +import { + to = routeros_interface_bridge_vlan.vlan40 + # TODO(adoption): fill live ID + # :put [/interface/bridge/vlan get [print show-ids]] + id = "*TODO" +} + +import { + to = routeros_interface_vlan.mgmt + id = "name=mgmt-vlan10" +} + +import { + to = routeros_ip_address.mgmt + id = "address=10.10.10.2/24" +} + +import { + to = routeros_ip_route.default + # TODO(adoption): fill live ID if dst_address is not unique + # :put [/ip/route get [print show-ids]] + id = "dst_address=0.0.0.0/0" +} + +import { + to = routeros_ip_service.www_ssl + id = "www-ssl" +} + +import { + to = routeros_ip_service.ssh + id = "ssh" +} + +import { + to = routeros_ip_service.winbox + id = "winbox" +} + +import { + to = routeros_ip_service.www + id = "www" +} + +import { + to = routeros_ip_service.ftp + id = "ftp" +} + +import { + to = routeros_ip_service.telnet + id = "telnet" +} + +import { + to = routeros_ip_service.api + id = "api" +} + +import { + to = routeros_ip_service.api_ssl + id = "api-ssl" +} + +import { + to = routeros_system_user_group.tofu_svc + id = "name=tofu-svc" +} diff --git a/routeros/sw-core01/mgmt.tf b/routeros/sw-core01/mgmt.tf new file mode 100644 index 0000000..288d0e2 --- /dev/null +++ b/routeros/sw-core01/mgmt.tf @@ -0,0 +1,24 @@ +resource "routeros_interface_vlan" "mgmt" { + interface = routeros_interface_bridge.lab.name + name = "mgmt-vlan10" + vlan_id = 10 +} + +resource "routeros_ip_address" "mgmt" { + address = "10.10.10.2/24" + interface = routeros_interface_vlan.mgmt.name +} + +resource "routeros_ip_dns" "mgmt" { + servers = ["10.10.10.1"] +} + +resource "routeros_ip_route" "default" { + dst_address = "0.0.0.0/0" + gateway = "10.10.10.1" +} + +resource "routeros_system_ntp_client" "mgmt" { + enabled = true + servers = ["10.10.10.1"] +} diff --git a/routeros/sw-core01/providers.tf b/routeros/sw-core01/providers.tf new file mode 100644 index 0000000..294e275 --- /dev/null +++ b/routeros/sw-core01/providers.tf @@ -0,0 +1,5 @@ +provider "routeros" { + hosturl = "https://10.10.10.2" + ca_certificate = "${path.module}/certs/sw-core01-ca.crt" + # username/password from ROS_USERNAME / ROS_PASSWORD env — never variables +} diff --git a/routeros/sw-core01/services.tf b/routeros/sw-core01/services.tf new file mode 100644 index 0000000..a826a91 --- /dev/null +++ b/routeros/sw-core01/services.tf @@ -0,0 +1,51 @@ +resource "routeros_ip_service" "www_ssl" { + numbers = "www-ssl" + port = 443 + certificate = "sw-core01-tls" + address = "10.10.10.0/24,192.168.1.0/24,100.64.0.0/10" + disabled = false +} + +resource "routeros_ip_service" "ssh" { + numbers = "ssh" + port = 22 + address = "10.10.10.0/24,192.168.1.0/24,100.64.0.0/10" + disabled = false +} + +resource "routeros_ip_service" "winbox" { + numbers = "winbox" + port = 8291 + address = "10.10.10.0/24,192.168.1.0/24,100.64.0.0/10" + disabled = false +} + +resource "routeros_ip_service" "www" { + numbers = "www" + port = 80 + disabled = true +} + +resource "routeros_ip_service" "ftp" { + numbers = "ftp" + port = 21 + disabled = true +} + +resource "routeros_ip_service" "telnet" { + numbers = "telnet" + port = 23 + disabled = true +} + +resource "routeros_ip_service" "api" { + numbers = "api" + port = 8728 + disabled = true +} + +resource "routeros_ip_service" "api_ssl" { + numbers = "api-ssl" + port = 8729 + disabled = true +} diff --git a/routeros/sw-core01/system.tf b/routeros/sw-core01/system.tf new file mode 100644 index 0000000..563cabe --- /dev/null +++ b/routeros/sw-core01/system.tf @@ -0,0 +1,12 @@ +resource "routeros_system_identity" "this" { + name = "sw-core01" +} + +# Changing this group's policy can cut tofu's own REST session. +# RouterOS REST authenticates via the binary api, so `api` is required +# alongside `rest-api`. +resource "routeros_system_user_group" "tofu_svc" { + name = "tofu-svc" + comment = "Changing this group can cut tofu's own session." + policy = ["read", "write", "api", "rest-api"] +} diff --git a/routeros/sw-core01/terraform.tf b/routeros/sw-core01/terraform.tf new file mode 100644 index 0000000..212c64d --- /dev/null +++ b/routeros/sw-core01/terraform.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.10" + + required_providers { + routeros = { + source = "terraform-routeros/routeros" + version = "~> 1.0" + } + } +} From a86f62c84efbdf4aad4439a76235b818c71792e5 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 20 Aug 2026 18:08:06 -0700 Subject: [PATCH 2/3] ci(moon): wire routeros-check Add operator plan/apply tasks and pin just plus tofu so CI can run the offline fmt/init/validate check. --- mise.lock | 40 ++++++++++++++++++++++++++++++++++++++++ mise.toml | 2 ++ moon.yml | 31 ++++++++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/mise.lock b/mise.lock index 3728573..45f16bd 100644 --- a/mise.lock +++ b/mise.lock @@ -24,6 +24,26 @@ checksum = "sha256:4c9f52262a14da336e4a42ed24992d12d0c956acde87619e4611d321dffa6 url = "https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-apple-darwin.tar.gz" provenance = "github-attestations" +[[tools."aqua:casey/just"]] +version = "1.58.0" +backend = "aqua:casey/just" + +[tools."aqua:casey/just"."platforms.linux-arm64"] +checksum = "sha256:748237128c4c40cbdabc65e841d05ceba13cc23a91eaba395495894c1d9764df" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-aarch64-unknown-linux-musl.tar.gz" + +[tools."aqua:casey/just"."platforms.linux-x64"] +checksum = "sha256:4a5cc2f53e6f0f8c59092a6cc38291eb729d46a7dd95d3ae582008881b84931d" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-unknown-linux-musl.tar.gz" + +[tools."aqua:casey/just"."platforms.macos-arm64"] +checksum = "sha256:50ae3e996c974a0bf32ea7d10f495070df33f1b43e0616b2769e3d4821ed8f48" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-aarch64-apple-darwin.tar.gz" + +[tools."aqua:casey/just"."platforms.macos-x64"] +checksum = "sha256:9a09cfef66aaa79da58203970103a0684307716caaabd3e9844cacc4dc0f4023" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-apple-darwin.tar.gz" + [[tools."aqua:moonrepo/moon"]] version = "2.4.6" backend = "aqua:moonrepo/moon" @@ -40,6 +60,26 @@ url = "https://github.com/moonrepo/moon/releases/download/v2.4.6/moon_cli-x86_64 checksum = "sha256:5bc863dd2c5e18c11e35a035318e6a3b5aaa7636f6c16ec6bd6366baf031e596" url = "https://github.com/moonrepo/moon/releases/download/v2.4.6/moon_cli-aarch64-apple-darwin.tar.xz" +[[tools."aqua:opentofu/opentofu"]] +version = "1.12.5" +backend = "aqua:opentofu/opentofu" + +[tools."aqua:opentofu/opentofu"."platforms.linux-arm64"] +checksum = "sha256:e67e9da2b1ddf5050ebee62a584cb826eafe1dfd3827d7ec20899ac62791ed1a" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.5/tofu_1.12.5_linux_arm64.tar.gz" + +[tools."aqua:opentofu/opentofu"."platforms.linux-x64"] +checksum = "sha256:a6894d45ae7a17ce83189cce8fe04b5a65f68cefceb62455b5a6a89fa53ab38f" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.5/tofu_1.12.5_linux_amd64.tar.gz" + +[tools."aqua:opentofu/opentofu"."platforms.macos-arm64"] +checksum = "sha256:2ae38150a667f5c0bd57b318d18ad8091d08f93fcca40345f3d88998661de5a9" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.5/tofu_1.12.5_darwin_arm64.tar.gz" + +[tools."aqua:opentofu/opentofu"."platforms.macos-x64"] +checksum = "sha256:1012d8f3d4567bcbcd1f2c7d766feca39a30bced32fb8be47e1887fbbee2456d" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.5/tofu_1.12.5_darwin_amd64.tar.gz" + [[tools.python]] version = "3.14.7" backend = "core:python" diff --git a/mise.toml b/mise.toml index 4bd39a4..dd50e12 100644 --- a/mise.toml +++ b/mise.toml @@ -2,6 +2,8 @@ python = "3.14.7" "aqua:astral-sh/uv" = "0.12.3" "aqua:moonrepo/moon" = "2.4.6" +"aqua:casey/just" = "1.58.0" +"aqua:opentofu/opentofu" = "1.12.5" [settings] lockfile = true diff --git a/moon.yml b/moon.yml index 3777079..0bca5b7 100644 --- a/moon.yml +++ b/moon.yml @@ -4,11 +4,12 @@ stack: 'backend' tags: - 'network' - 'vyos' + - 'routeros' - 'uv' project: title: 'Lab2 network' - description: 'Static validation and operator tooling for the lab2 gw01 VyOS gateway.' + description: 'Static validation and operator tooling for the lab2 network devices.' owner: 'GilmanLab' maintainers: - 'josh' @@ -28,6 +29,9 @@ fileGroups: configs: - 'vyos/gw01/config.boot.tmpl' - 'vyos/gw01/assets/**/*' + routeros: + - 'routeros/sw-core01/**/*.tf' + - 'routeros/sw-core01/Justfile' workspace: inheritedTasks: @@ -96,6 +100,30 @@ tasks: cache: false runInCI: false + routeros-check: + command: 'just -f routeros/sw-core01/Justfile check' + inputs: + - '@group(routeros)' + options: + cache: false + runInCI: true + + routeros-plan: + command: 'just -f routeros/sw-core01/Justfile plan' + inputs: + - '@group(routeros)' + options: + cache: false + runInCI: false + + routeros-apply: + command: 'just -f routeros/sw-core01/Justfile apply' + inputs: + - '@group(routeros)' + options: + cache: false + runInCI: false + check: deps: - 'network:lock' @@ -104,6 +132,7 @@ tasks: - 'network:typecheck' - 'network:test' - 'network:vyos-validate' + - 'network:routeros-check' inputs: [] options: cache: false From e9e4c4c207836e002af7b47eb935c3420bfe55ab Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 20 Aug 2026 18:23:26 -0700 Subject: [PATCH 3/3] fix(routeros): adjust sw-core01 root from live adoption - fill live import IDs; drop broken ip_service imports (provider Name-ID importer defect; adopt-by-create /set semantics instead) - defer-then-restore sfpplus5-7 port rows around the defconf purge - user group is runbook-owned: svc-tofu lacks the policy permission by design - hermetic offline check via dedicated TF_DATA_DIR - drop the dead sw-core01 DHCP reservation from the gw01 template (static) --- routeros/sw-core01/.gitignore | 2 +- routeros/sw-core01/Justfile | 7 ++- routeros/sw-core01/imports.tf | 109 ---------------------------------- routeros/sw-core01/system.tf | 11 +--- vyos/gw01/config.boot.tmpl | 4 -- 5 files changed, 8 insertions(+), 125 deletions(-) delete mode 100644 routeros/sw-core01/imports.tf diff --git a/routeros/sw-core01/.gitignore b/routeros/sw-core01/.gitignore index 9d1af6c..806bf47 100644 --- a/routeros/sw-core01/.gitignore +++ b/routeros/sw-core01/.gitignore @@ -1,4 +1,4 @@ -.terraform/ +.terraform*/ tfplan *.tfstate *.tfstate.backup diff --git a/routeros/sw-core01/Justfile b/routeros/sw-core01/Justfile index d5100bf..3b86bc1 100644 --- a/routeros/sw-core01/Justfile +++ b/routeros/sw-core01/Justfile @@ -5,11 +5,12 @@ secrets_file := "network/sw-core01/terraform.sops.yaml" default: @just --list -# Offline validation: no backend or credentials required. +# Offline validation: no backend or credentials required. Uses a dedicated +# TF_DATA_DIR so an operator-initialized .terraform/ (S3 backend) never leaks in. check: tofu fmt -check -recursive - tofu init -backend=false -input=false - ROS_USERNAME="${ROS_USERNAME:-validate}" ROS_PASSWORD="${ROS_PASSWORD:-validate}" tofu validate + TF_DATA_DIR=.terraform-check tofu init -backend=false -input=false + TF_DATA_DIR=.terraform-check ROS_USERNAME="${ROS_USERNAME:-validate}" ROS_PASSWORD="${ROS_PASSWORD:-validate}" tofu validate # Format Tofu files in place. fmt: diff --git a/routeros/sw-core01/imports.tf b/routeros/sw-core01/imports.tf deleted file mode 100644 index a4c76d9..0000000 --- a/routeros/sw-core01/imports.tf +++ /dev/null @@ -1,109 +0,0 @@ -# Deleted after the adoption apply. Live RouterOS item IDs (*HEX) are filled -# from the device before that apply wherever name-based addressing is not -# unique; name-based IDs follow the provider import docs. - -import { - to = routeros_interface_bridge.lab - id = "name=bridge-lab" -} - -import { - to = routeros_interface_bridge_port.sfp_sfpplus1 - id = "interface=sfp-sfpplus1" -} - -import { - to = routeros_interface_bridge_port.sfp_sfpplus2 - id = "interface=sfp-sfpplus2" -} - -import { - to = routeros_interface_bridge_port.sfp_sfpplus3 - id = "interface=sfp-sfpplus3" -} - -import { - to = routeros_interface_bridge_port.sfp_sfpplus4 - id = "interface=sfp-sfpplus4" -} - -import { - to = routeros_interface_bridge_port.sfp_sfpplus8 - id = "interface=sfp-sfpplus8" -} - -import { - to = routeros_interface_bridge_vlan.vlan10 - # TODO(adoption): fill live ID - # :put [/interface/bridge/vlan get [print show-ids]] - id = "*TODO" -} - -import { - to = routeros_interface_bridge_vlan.vlan40 - # TODO(adoption): fill live ID - # :put [/interface/bridge/vlan get [print show-ids]] - id = "*TODO" -} - -import { - to = routeros_interface_vlan.mgmt - id = "name=mgmt-vlan10" -} - -import { - to = routeros_ip_address.mgmt - id = "address=10.10.10.2/24" -} - -import { - to = routeros_ip_route.default - # TODO(adoption): fill live ID if dst_address is not unique - # :put [/ip/route get [print show-ids]] - id = "dst_address=0.0.0.0/0" -} - -import { - to = routeros_ip_service.www_ssl - id = "www-ssl" -} - -import { - to = routeros_ip_service.ssh - id = "ssh" -} - -import { - to = routeros_ip_service.winbox - id = "winbox" -} - -import { - to = routeros_ip_service.www - id = "www" -} - -import { - to = routeros_ip_service.ftp - id = "ftp" -} - -import { - to = routeros_ip_service.telnet - id = "telnet" -} - -import { - to = routeros_ip_service.api - id = "api" -} - -import { - to = routeros_ip_service.api_ssl - id = "api-ssl" -} - -import { - to = routeros_system_user_group.tofu_svc - id = "name=tofu-svc" -} diff --git a/routeros/sw-core01/system.tf b/routeros/sw-core01/system.tf index 563cabe..1b6d9a8 100644 --- a/routeros/sw-core01/system.tf +++ b/routeros/sw-core01/system.tf @@ -2,11 +2,6 @@ resource "routeros_system_identity" "this" { name = "sw-core01" } -# Changing this group's policy can cut tofu's own REST session. -# RouterOS REST authenticates via the binary api, so `api` is required -# alongside `rest-api`. -resource "routeros_system_user_group" "tofu_svc" { - name = "tofu-svc" - comment = "Changing this group can cut tofu's own session." - policy = ["read", "write", "api", "rest-api"] -} +# The tofu-svc group and svc-tofu user are runbook-owned: svc-tofu deliberately +# lacks the `policy` permission, so it cannot manage user groups (self-escalation +# guard), and user passwords must never enter state. diff --git a/vyos/gw01/config.boot.tmpl b/vyos/gw01/config.boot.tmpl index f845cd0..05e0511 100644 --- a/vyos/gw01/config.boot.tmpl +++ b/vyos/gw01/config.boot.tmpl @@ -717,10 +717,6 @@ service { start 10.10.10.200 stop 10.10.10.250 } - static-mapping sw-core01 { - ip-address 10.10.10.2 - mac 04:f4:1c:13:86:87 - } subnet-id 1 } }