Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions mise.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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:
Expand Down Expand Up @@ -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'
Expand All @@ -104,6 +132,7 @@ tasks:
- 'network:typecheck'
- 'network:test'
- 'network:vyos-validate'
- 'network:routeros-check'
inputs: []
options:
cache: false
Expand Down
5 changes: 5 additions & 0 deletions routeros/sw-core01/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.terraform*/
tfplan
*.tfstate
*.tfstate.backup
pre-apply.rsc
37 changes: 37 additions & 0 deletions routeros/sw-core01/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 49 additions & 0 deletions routeros/sw-core01/Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
set shell := ["bash", "-euo", "pipefail", "-c"]

secrets_file := "network/sw-core01/terraform.sops.yaml"

default:
@just --list

# 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
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:
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
66 changes: 66 additions & 0 deletions routeros/sw-core01/README.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions routeros/sw-core01/backend.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
terraform {
backend "s3" {
key = "networking/routeros/sw-core01.tfstate"
region = "us-west-2"
encrypt = true
use_lockfile = true
}
}
77 changes: 77 additions & 0 deletions routeros/sw-core01/bridge.tf
Original file line number Diff line number Diff line change
@@ -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,
]
}
Empty file.
18 changes: 18 additions & 0 deletions routeros/sw-core01/certs/sw-core01-ca.crt
Original file line number Diff line number Diff line change
@@ -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-----
Loading