From 5d1ddd6e5d12f8f9874dc73347d9fa2a54bfe776 Mon Sep 17 00:00:00 2001 From: Lumpiasty Date: Thu, 12 Mar 2026 17:34:49 +0100 Subject: [PATCH] Remake Ansible playbook to target MikroTik router Basically, I've exported configuration from Mikrotik router using /export and vibe-coded playbook using the file. --- .gitmodules | 3 - README.md | 10 +- ansible/README.md | 20 ++ ansible/ansible.cfg | 5 + ansible/hosts | 2 - ansible/inventory/hosts.yml | 6 + ansible/playbook.yml | 6 - ansible/playbooks/routeros.yml | 92 +++++ ansible/requirements.yml | 5 + ansible/roles/router/files/bird.conf | 53 --- ansible/roles/router/handlers/main.yml | 5 - ansible/roles/router/tasks/main.yml | 16 - ansible/tasks/addressing.yml | 48 +++ ansible/tasks/base.yml | 226 ++++++++++++ ansible/tasks/containers.yml | 66 ++++ ansible/tasks/firewall.yml | 480 +++++++++++++++++++++++++ ansible/tasks/hardware.yml | 103 ++++++ ansible/tasks/preflight.yml | 46 +++ ansible/tasks/routing.yml | 99 +++++ ansible/tasks/system.yml | 43 +++ ansible/tasks/wan.yml | 44 +++ ansible/vars/routeros-secrets.yml | 19 + devenv.nix | 7 +- 23 files changed, 1317 insertions(+), 87 deletions(-) delete mode 100644 .gitmodules create mode 100644 ansible/README.md create mode 100644 ansible/ansible.cfg delete mode 100644 ansible/hosts create mode 100644 ansible/inventory/hosts.yml delete mode 100644 ansible/playbook.yml create mode 100644 ansible/playbooks/routeros.yml create mode 100644 ansible/requirements.yml delete mode 100644 ansible/roles/router/files/bird.conf delete mode 100644 ansible/roles/router/handlers/main.yml delete mode 100644 ansible/roles/router/tasks/main.yml create mode 100644 ansible/tasks/addressing.yml create mode 100644 ansible/tasks/base.yml create mode 100644 ansible/tasks/containers.yml create mode 100644 ansible/tasks/firewall.yml create mode 100644 ansible/tasks/hardware.yml create mode 100644 ansible/tasks/preflight.yml create mode 100644 ansible/tasks/routing.yml create mode 100644 ansible/tasks/system.yml create mode 100644 ansible/tasks/wan.yml create mode 100644 ansible/vars/routeros-secrets.yml diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b8513ce..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "openwrt/roles/ansible-openwrt"] - path = openwrt/roles/ansible-openwrt - url = https://github.com/gekmihesg/ansible-openwrt.git diff --git a/README.md b/README.md index 5b982d4..49bcd75 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Currently the k8s cluster consists of single node (hostname anapistula-delrosala ## Software stack -The cluster itself is based on [Talos Linux](https://www.talos.dev/) (which is also a Kubernetes distribution) and uses [Cilium](https://cilium.io/) as CNI, IPAM, kube-proxy replacement, Load Balancer, and BGP control plane. Persistent volumes are managed by [OpenEBS LVM LocalPV](https://openebs.io/docs/user-guides/local-storage-user-guide/local-pv-lvm/lvm-overview). Applications are deployed using GitOps (this repo) and reconciled on cluster using [Flux](https://fluxcd.io/). Git repository is hosted on [Gitea](https://gitea.io/) running on a cluster itself. Secets are kept in [OpenBao](https://openbao.org/) (HashiCorp Vault fork) running on a cluster and synced to cluster objects using [Vault Secrets Operator](https://github.com/hashicorp/vault-secrets-operator). Deployments are kept up to date using self hosted [Renovate](https://www.mend.io/renovate/) bot updating manifests in the Git repository. Incoming HTTP traffic is routed to cluster using [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/) and certificates are issued by [cert-manager](https://cert-manager.io/) with [Let's Encrypt](https://letsencrypt.org/) ACME issuer with [cert-manager-webhook-ovh](https://github.com/aureq/cert-manager-webhook-ovh) resolving DNS-01 challanges. Cluster also runs [CloudNativePG](https://cloudnative-pg.io/) operator for managing PostgreSQL databases. High level core cluster software architecture is shown on the diagram below. +The cluster itself is based on [Talos Linux](https://www.talos.dev/) (which is also a Kubernetes distribution) and uses [Cilium](https://cilium.io/) as CNI, IPAM, kube-proxy replacement, Load Balancer, and BGP control plane. Persistent volumes are managed by [OpenEBS LVM LocalPV](https://openebs.io/docs/user-guides/local-storage-user-guide/local-pv-lvm/lvm-overview). Applications are deployed using GitOps (this repo) and reconciled on cluster using [Flux](https://fluxcd.io/). Git repository is hosted on [Gitea](https://gitea.io/) running on a cluster itself. Secets are kept in [OpenBao](https://openbao.org/) (HashiCorp Vault fork) running on a cluster and synced to cluster objects using [Vault Secrets Operator](https://github.com/hashicorp/vault-secrets-operator). Deployments are kept up to date using self hosted [Renovate](https://www.mend.io/renovate/) bot updating manifests in the Git repository. Incoming HTTP traffic is routed to cluster using [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/) and certificates are issued by [cert-manager](https://cert-manager.io/) with [Let's Encrypt](https://letsencrypt.org/) ACME issuer with [cert-manager-webhook-ovh](https://github.com/aureq/cert-manager-webhook-ovh) resolving DNS-01 challanges. Cluster also runs [CloudNativePG](https://cloudnative-pg.io/) operator for managing PostgreSQL databases. Router is running [Mikrotik RouterOS](https://help.mikrotik.com/docs/spaces/ROS/pages/328059/RouterOS) and its configuration is managed via [Ansible](https://docs.ansible.com/) playbook in this repo. High level core cluster software architecture is shown on the diagram below. > Talos Linux is an immutable Linux distribution purpose-built for running Kubernetes. The OS is distributed as an OCI (Docker) image and does not contain any package manager, shell, SSH, or any other tools for managing the system. Instead, all operations are performed using API, which can be accessed using `talosctl` CLI tool. @@ -227,6 +227,14 @@ Talos config in this repo is stored as yaml patches under [talos/patches](talos/ To compile config, you need to have secrets file, which contains certificates and keys for cluster. Those secrets are then incorporated into final config files. That is also why we can not store full config in repo. +### Router config changes + +Router config is stored as Ansible playbook under `ansible/` directory. To apply changes to router, run `ansible-playbook playbooks/routeros.yml` command in `ansible/` directory Before running playbook, you can check what changes will be applied to router using `--check` flag to `ansible-playbook` command, which will run playbook in "check mode" and show you the changes that would be applied without actually applying them. This is useful for verifying that your changes are correct before applying them to the router. + +To run Ansible playbook, you need to have required Ansible collections installed. You can install them using `ansible-galaxy collection install -r ansible/requirements.yml` command. Configuring this in devenv is yet to be done, so you might need to install collections manually for now. + +Secrets needed to access the router API are stored in OpenBao and loaded on demand when running playbook so you need to have access to appropriate secrets. + ### Kube API access To generate kubeconfig for accessing cluster API, run `make get-kubeconfig` command, which will generate kubeconfig under `talos/generated/kubeconfig` path. Devenv automatically sets `KUBECONFIG` enviornment variable to point to this file, so you can start using `kubectl` right away. diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..7d1989c --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,20 @@ +## RouterOS Ansible + +This directory contains the new Ansible automation for the MikroTik router. + +- Transport: RouterOS API (`community.routeros` collection), not SSH CLI scraping. +- Layout: one playbook (`playbooks/routeros.yml`) importing domain task files from `tasks/`. +- Goal: idempotent convergence using `community.routeros.api_modify` for managed paths. + +### Quick start + +1. Install dependencies: + - `ansible-galaxy collection install -r ansible/requirements.yml` + - `python -m pip install librouteros hvac` +2. Configure secret references in `ansible/vars/routeros-secrets.yml`. +3. Store required fields in OpenBao under configured KV path. +4. Export token (`OPENBAO_TOKEN` or `VAULT_TOKEN`). +5. Run: + - `ANSIBLE_CONFIG=ansible/ansible.cfg ansible-playbook ansible/playbooks/routeros.yml` + +More details and design rationale: `docs/ansible/routeros-design.md`. diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..b8a2cf1 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,5 @@ +[defaults] +inventory = inventory/hosts.yml +host_key_checking = False +retry_files_enabled = False +result_format = yaml diff --git a/ansible/hosts b/ansible/hosts deleted file mode 100644 index 3a5e754..0000000 --- a/ansible/hosts +++ /dev/null @@ -1,2 +0,0 @@ -[openwrt] -2001:470:61a3:100:ffff:ffff:ffff:ffff ansible_scp_extra_args="-O" \ No newline at end of file diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml new file mode 100644 index 0000000..439fe32 --- /dev/null +++ b/ansible/inventory/hosts.yml @@ -0,0 +1,6 @@ +all: + children: + mikrotik: + hosts: + crs418: + ansible_host: 192.168.255.10 diff --git a/ansible/playbook.yml b/ansible/playbook.yml deleted file mode 100644 index 1e42071..0000000 --- a/ansible/playbook.yml +++ /dev/null @@ -1,6 +0,0 @@ -- name: Configure router - hosts: openwrt - remote_user: root - roles: - - ansible-openwrt - - router diff --git a/ansible/playbooks/routeros.yml b/ansible/playbooks/routeros.yml new file mode 100644 index 0000000..5a9075d --- /dev/null +++ b/ansible/playbooks/routeros.yml @@ -0,0 +1,92 @@ +--- +- name: Converge MikroTik RouterOS config + hosts: mikrotik + gather_facts: false + connection: local + + vars_files: + - ../vars/routeros-secrets.yml + + pre_tasks: + - name: Load router secrets from OpenBao + ansible.builtin.set_fact: + routeros_api_username: >- + {{ + lookup( + 'community.hashi_vault.vault_kv2_get', + openbao_fields.routeros_api.path, + engine_mount_point=openbao_kv_mount + ).secret[openbao_fields.routeros_api.username_key] + }} + routeros_api_password: >- + {{ + lookup( + 'community.hashi_vault.vault_kv2_get', + openbao_fields.routeros_api.path, + engine_mount_point=openbao_kv_mount + ).secret[openbao_fields.routeros_api.password_key] + }} + routeros_pppoe_username: >- + {{ + lookup( + 'community.hashi_vault.vault_kv2_get', + openbao_fields.wan_pppoe.path, + engine_mount_point=openbao_kv_mount + ).secret[openbao_fields.wan_pppoe.username_key] + }} + routeros_pppoe_password: >- + {{ + lookup( + 'community.hashi_vault.vault_kv2_get', + openbao_fields.wan_pppoe.path, + engine_mount_point=openbao_kv_mount + ).secret[openbao_fields.wan_pppoe.password_key] + }} + routeros_tailscale_container_password: >- + {{ + lookup( + 'community.hashi_vault.vault_kv2_get', + openbao_fields.routeros_tailscale_container.path, + engine_mount_point=openbao_kv_mount + ).secret[openbao_fields.routeros_tailscale_container.container_password_key] + }} + no_log: true + + module_defaults: + group/community.routeros.api: + hostname: "{{ ansible_host }}" + username: "{{ routeros_api_username }}" + password: "{{ routeros_api_password }}" + tls: true + validate_certs: false + validate_cert_hostname: false + force_no_cert: true + encoding: UTF-8 + + tasks: + - name: Preflight checks + ansible.builtin.import_tasks: ../tasks/preflight.yml + + - name: Base network configuration + ansible.builtin.import_tasks: ../tasks/base.yml + + - name: WAN and tunnel interfaces + ansible.builtin.import_tasks: ../tasks/wan.yml + + - name: Hardware and platform tuning + ansible.builtin.import_tasks: ../tasks/hardware.yml + + - name: RouterOS container configuration + ansible.builtin.import_tasks: ../tasks/containers.yml + + - name: Addressing configuration + ansible.builtin.import_tasks: ../tasks/addressing.yml + + - name: Firewall configuration + ansible.builtin.import_tasks: ../tasks/firewall.yml + + - name: Routing configuration + ansible.builtin.import_tasks: ../tasks/routing.yml + + - name: System configuration + ansible.builtin.import_tasks: ../tasks/system.yml diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000..73c1860 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,5 @@ +collections: + - name: community.routeros + version: ">=3.16.0" + - name: community.hashi_vault + version: ">=7.1.0" diff --git a/ansible/roles/router/files/bird.conf b/ansible/roles/router/files/bird.conf deleted file mode 100644 index b4082c5..0000000 --- a/ansible/roles/router/files/bird.conf +++ /dev/null @@ -1,53 +0,0 @@ -# Would never work without this awesome blogpost -# https://farcaller.net/2024/making-cilium-bgp-work-with-ipv6/ - -log "/tmp/bird.log" all; -log syslog all; - -#Router ID -router id 192.168.1.1; - -protocol kernel kernel4 { - learn; - scan time 10; - merge paths yes; - ipv4 { - import none; - export all; - }; -} - -protocol kernel kernel6 { - learn; - scan time 10; - merge paths yes; - ipv6 { - import none; - export all; - }; -} - -protocol device { - scan time 10; -} - -protocol direct { - interface "*"; -} - -protocol bgp homelab { - debug { events }; - passive; - direct; - local 2001:470:61a3:100:ffff:ffff:ffff:ffff as 65000; - neighbor range 2001:470:61a3:100::/64 as 65000; - ipv4 { - extended next hop yes; - import all; - export all; - }; - ipv6 { - import all; - export all; - }; -} diff --git a/ansible/roles/router/handlers/main.yml b/ansible/roles/router/handlers/main.yml deleted file mode 100644 index 7607c3a..0000000 --- a/ansible/roles/router/handlers/main.yml +++ /dev/null @@ -1,5 +0,0 @@ -- name: Reload bird - service: - name: bird - state: restarted - enabled: true diff --git a/ansible/roles/router/tasks/main.yml b/ansible/roles/router/tasks/main.yml deleted file mode 100644 index 0c9ba2a..0000000 --- a/ansible/roles/router/tasks/main.yml +++ /dev/null @@ -1,16 +0,0 @@ ---- -- name: Install bird2 - opkg: - name: "{{ item }}" - state: present - # Workaround for opkg module not handling multiple names at once well - loop: - - bird2 - - bird2c - -- name: Set up bird.conf - ansible.builtin.copy: - src: bird.conf - dest: /etc/bird.conf - mode: "644" - notify: Reload bird diff --git a/ansible/tasks/addressing.yml b/ansible/tasks/addressing.yml new file mode 100644 index 0000000..b3c5267 --- /dev/null +++ b/ansible/tasks/addressing.yml @@ -0,0 +1,48 @@ +--- +- name: Configure IPv4 addresses + community.routeros.api_modify: + path: ip address + data: + - address: 172.17.0.1/16 + interface: dockers + network: 172.17.0.0 + - address: 192.168.4.1/24 + interface: lo + network: 192.168.4.0 + - address: 192.168.100.20/24 + interface: sfp-sfpplus1 + network: 192.168.100.0 + - address: 192.168.255.10/24 + interface: bridge1 + network: 192.168.255.0 + - address: 192.168.0.1/24 + interface: vlan2 + network: 192.168.0.0 + - address: 192.168.1.1/24 + interface: vlan4 + network: 192.168.1.0 + - address: 192.168.3.1/24 + interface: vlan3 + network: 192.168.3.0 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure IPv6 addresses + community.routeros.api_modify: + path: ipv6 address + data: + - address: 2001:470:70:dd::2/64 + advertise: false + interface: sit1 + - address: ::ffff:ffff:ffff:ffff/64 + from-pool: pool1 + interface: vlan2 + - address: 2001:470:61a3:500:ffff:ffff:ffff:ffff/64 + interface: dockers + - address: 2001:470:61a3:100::1/64 + advertise: false + interface: vlan4 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true diff --git a/ansible/tasks/base.yml b/ansible/tasks/base.yml new file mode 100644 index 0000000..7fdfa71 --- /dev/null +++ b/ansible/tasks/base.yml @@ -0,0 +1,226 @@ +--- +- name: Configure bridges + community.routeros.api_modify: + path: interface bridge + data: + - name: bridge1 + vlan-filtering: true + - name: dockers + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure VLAN interfaces + community.routeros.api_modify: + path: interface vlan + data: + - name: vlan2 + comment: LAN (PC, WIFI) + interface: bridge1 + vlan-id: 2 + - name: vlan3 + comment: KAMERY + interface: bridge1 + vlan-id: 3 + - name: vlan4 + comment: SERVER LAN + interface: bridge1 + vlan-id: 4 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure interface lists + community.routeros.api_modify: + path: interface list + data: + - name: wan + comment: contains interfaces facing internet + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure interface list members + community.routeros.api_modify: + path: interface list member + data: + - interface: pppoe-gpon + list: wan + - interface: lte1 + list: wan + - interface: sit1 + list: wan + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure bridge ports + community.routeros.api_modify: + path: interface bridge port + data: + - bridge: dockers + interface: veth1 + comment: Tailscale container interface + - bridge: bridge1 + interface: ether1 + pvid: 2 + - bridge: bridge1 + interface: ether2 + pvid: 2 + - bridge: bridge1 + interface: ether8 + pvid: 4 + - bridge: bridge1 + interface: ether9 + pvid: 2 + - bridge: bridge1 + interface: ether10 + pvid: 3 + - bridge: bridge1 + interface: sfp-sfpplus2 + - bridge: bridge1 + interface: ether11 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure bridge VLAN membership + community.routeros.api_modify: + path: interface bridge vlan + data: + - bridge: bridge1 + tagged: sfp-sfpplus2 + untagged: ether1,ether2,ether9 + vlan-ids: 2 + - bridge: bridge1 + tagged: sfp-sfpplus2 + untagged: ether10 + vlan-ids: 3 + - bridge: bridge1 + untagged: ether8 + vlan-ids: 4 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure IPv4 pools + community.routeros.api_modify: + path: ip pool + data: + - name: dhcp_pool0 + ranges: 192.168.0.50-192.168.0.250 + comment: LAN DHCP pool + - name: dhcp_pool1 + ranges: 192.168.255.1-192.168.255.9,192.168.255.11-192.168.255.254 + comment: MGMT DHCP pool + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure DHCP servers + community.routeros.api_modify: + path: ip dhcp-server + data: + - name: dhcp1 + address-pool: dhcp_pool0 + interface: vlan2 + lease-time: 30m + comment: LAN + - name: dhcp2 + address-pool: dhcp_pool1 + interface: bridge1 + lease-time: 30m + comment: MGMT + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure DHCP networks + community.routeros.api_modify: + path: ip dhcp-server network + data: + - address: 192.168.0.0/24 + dns-server: 192.168.0.1 + gateway: 192.168.0.1 + - address: 192.168.255.0/24 + dns-none: true + gateway: 192.168.255.10 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +# TODO: IPv6 pools are useful when we have dynamic prefix, but we don't +# We can remove it now +- name: Configure IPv6 pools + community.routeros.api_modify: + path: ipv6 pool + data: + - name: pool1 + prefix: 2001:470:61a3::/48 + prefix-length: 64 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure DNS + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: ip dns + find: {} + values: + allow-remote-requests: true + cache-size: 20480 + servers: 1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001 + +- name: Configure NAT-PMP global settings + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: ip nat-pmp + find: {} + values: + enabled: true + +- name: Configure NAT-PMP interfaces + community.routeros.api_modify: + path: ip nat-pmp interfaces + data: + - interface: dockers + type: internal + - interface: pppoe-gpon + type: external + - interface: vlan2 + type: internal + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure UPnP global settings + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: ip upnp + find: {} + values: + enabled: true + +- name: Configure UPnP interfaces + community.routeros.api_modify: + path: ip upnp interfaces + data: + - interface: dockers + type: internal + - interface: pppoe-gpon + type: external + - interface: vlan2 + type: internal + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure IPv6 ND defaults + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: ipv6 nd + find: + default: true + values: + advertise-dns: true diff --git a/ansible/tasks/containers.yml b/ansible/tasks/containers.yml new file mode 100644 index 0000000..97d7d37 --- /dev/null +++ b/ansible/tasks/containers.yml @@ -0,0 +1,66 @@ +--- +- name: Configure container runtime defaults + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: container config + find: {} + values: + registry-url: https://ghcr.io + tmpdir: /tmp1/pull + +- name: Configure container env lists + community.routeros.api_modify: + path: container envs + data: + - key: ADVERTISE_ROUTES + list: tailscale + value: 192.168.0.0/24,192.168.1.0/24,192.168.4.1/32,192.168.100.1/32,192.168.255.0/24,10.42.0.0/16,10.43.0.0/16,10.44.0.0/16,2001:470:61a3::/48 + - key: CONTAINER_GATEWAY + list: tailscale + value: 172.17.0.1 + - key: PASSWORD + list: tailscale + value: "{{ routeros_tailscale_container_password }}" + - key: TAILSCALE_ARGS + list: tailscale + value: --accept-routes --advertise-exit-node --snat-subnet-routes=false + - key: UPDATE_TAILSCALE + list: tailscale + value: y + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure container mounts + community.routeros.api_modify: + path: container mounts + data: + - dst: /var/lib/tailscale + list: tailscale + src: /usb1/tailscale + - dst: /root + list: tailscale-root + src: /tmp1/tailscale-root + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure tailscale container + community.routeros.api_modify: + path: container + data: + - dns: 172.17.0.1 + envlists: tailscale + hostname: mikrotik + interface: veth1 + layer-dir: "" + mountlists: tailscale + name: tailscale-mikrotik:latest + remote-image: fluent-networks/tailscale-mikrotik:latest + root-dir: /usb1/containers/tailscale + start-on-boot: true + tmpfs: /tmp:67108864:01777 + workdir: / + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true diff --git a/ansible/tasks/firewall.yml b/ansible/tasks/firewall.yml new file mode 100644 index 0000000..47b5336 --- /dev/null +++ b/ansible/tasks/firewall.yml @@ -0,0 +1,480 @@ +--- +- name: Configure IPv4 firewall filter rules + community.routeros.api_modify: + path: ip firewall filter + data: + - action: fasttrack-connection + chain: forward + connection-state: established,related + - action: accept + chain: forward + comment: Allow all already established connections + connection-state: established,related + - action: accept + chain: forward + comment: Allow LTE modem management (next rule forbids it otherwise) + dst-address: 192.168.8.1 + out-interface: lte1 + - action: reject + chain: forward + comment: Forbid forwarding 192.168.0.0/16 to WAN + dst-address: 192.168.0.0/16 + out-interface-list: wan + reject-with: icmp-network-unreachable + - action: reject + chain: forward + comment: Forbid forwarding 10.0.0.0/8 to WAN + dst-address: 10.0.0.0/8 + out-interface-list: wan + reject-with: icmp-network-unreachable + - action: reject + chain: forward + comment: Forbid forwarding 172.16.0.0/12 to WAN + dst-address: 172.16.0.0/12 + out-interface-list: wan + reject-with: icmp-network-unreachable + - action: reject + chain: forward + comment: Forbid forwarding 100.64.0.0/10 to WAN + dst-address: 100.64.0.0/10 + out-interface-list: wan + reject-with: icmp-network-unreachable + - action: accept + chain: forward + comment: Allow from LAN to everywhere + in-interface: vlan2 + - action: accept + chain: forward + comment: Allow from SRV to internet + in-interface: vlan4 + out-interface-list: wan + - action: accept + chain: forward + comment: Allow from SRV to CAM + in-interface: vlan4 + out-interface: vlan3 + - action: accept + chain: forward + comment: Allow from dockers to everywhere + in-interface: dockers + - action: jump + chain: forward + comment: Allow port forwards + in-interface: pppoe-gpon + jump-target: allow-ports + - action: reject + chain: forward + comment: Reject all remaining (port unreachable from WAN) + in-interface-list: wan + log-prefix: FORWARD REJECT + reject-with: icmp-port-unreachable + - action: reject + chain: forward + comment: Reject all remaining (net prohibited from LAN) + log-prefix: FORWARD REJECT + reject-with: icmp-net-prohibited + - action: accept + chain: input + comment: Allow all already established connections + connection-state: established,related + - action: accept + chain: input + comment: Allow HE tunnel + in-interface: pppoe-gpon + protocol: ipv6-encap + - action: accept + chain: input + comment: Allow ICMP + protocol: icmp + - action: accept + chain: input + comment: Allow Winbox + dst-port: 8291 + log: true + protocol: tcp + - action: accept + chain: input + comment: Allow SSH Mikrotik + dst-port: 2137 + log: true + protocol: tcp + - action: accept + chain: input + comment: Allow RouterOS API-SSL from MGMT + dst-port: 8729 + protocol: tcp + - action: accept + chain: input + comment: Allow DNS from LAN + dst-port: 53 + in-interface: vlan2 + protocol: udp + - action: accept + chain: input + dst-port: 53 + in-interface: vlan2 + protocol: tcp + - action: accept + chain: input + comment: Allow DNS from SRV + dst-port: 53 + in-interface: vlan4 + protocol: udp + - action: accept + chain: input + dst-port: 53 + in-interface: vlan4 + protocol: tcp + - action: accept + chain: input + comment: Allow DNS from dockers + dst-port: 53 + in-interface: dockers + protocol: udp + - action: accept + chain: input + dst-port: 53 + in-interface: dockers + protocol: tcp + - action: accept + chain: input + comment: Allow BGP from SRV + dst-port: 179 + in-interface: vlan4 + protocol: udp + - action: accept + chain: input + comment: NAT-PMP from LAN + dst-port: 5351 + in-interface: vlan2 + protocol: udp + - action: accept + chain: input + comment: NAT-PMP from dockers (for tailscale) + dst-port: 5351 + in-interface: dockers + protocol: udp + - action: reject + chain: input + comment: Reject all remaining + log-prefix: INPUT REJECT + reject-with: icmp-port-unreachable + - action: accept + chain: allow-ports + comment: Allow TS3 + dst-port: 9987 + out-interface: vlan4 + protocol: udp + - action: accept + chain: allow-ports + dst-port: 30033 + out-interface: vlan4 + protocol: tcp + - action: accept + chain: allow-ports + comment: Allow HTTP + dst-port: 80 + out-interface: vlan4 + protocol: tcp + - action: accept + chain: allow-ports + comment: Allow HTTPS + dst-port: 443 + out-interface: vlan4 + protocol: tcp + - action: accept + chain: allow-ports + comment: Allow SSH Gitea + dst-port: 22 + out-interface: vlan4 + protocol: tcp + - action: accept + chain: allow-ports + comment: Allow anything udp to Tailscale + dst-address: 172.17.0.2 + out-interface: dockers + protocol: udp + - action: accept + chain: allow-ports + comment: Allow anything from GPON to LAN (NAT-PMP) + dst-address: 192.168.0.0/24 + in-interface: pppoe-gpon + out-interface: vlan2 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure IPv4 NAT rules + community.routeros.api_modify: + path: ip firewall nat + data: + - action: masquerade + chain: srcnat + comment: Masquerade to internet + out-interface-list: wan + - action: masquerade + chain: srcnat + comment: GPON ONT management + dst-address: 192.168.100.1 + - action: masquerade + chain: srcnat + comment: LTE Modem management + dst-address: 192.168.8.1 + - action: dst-nat + chain: dstnat + comment: TS3 + dst-address: 139.28.40.212 + dst-port: 9987 + protocol: udp + to-addresses: 10.44.0.0 + - action: dst-nat + chain: dstnat + dst-address: 139.28.40.212 + dst-port: 30033 + protocol: tcp + to-addresses: 10.44.0.0 + - action: src-nat + chain: srcnat + comment: src-nat from LAN to TS3 to some Greenland address + dst-address: 10.44.0.0 + dst-port: 9987 + in-interface: '!pppoe-gpon' + protocol: udp + to-addresses: 128.0.70.5 + - action: src-nat + chain: srcnat + dst-address: 10.44.0.0 + dst-port: 30033 + in-interface: '!pppoe-gpon' + protocol: tcp + to-addresses: 128.0.70.5 + - action: dst-nat + chain: dstnat + comment: HTTPS + dst-address: 139.28.40.212 + dst-port: 443 + protocol: tcp + to-addresses: 10.44.0.6 + - action: dst-nat + chain: dstnat + comment: HTTP + dst-address: 139.28.40.212 + dst-port: 80 + protocol: tcp + to-addresses: 10.44.0.6 + - action: dst-nat + chain: dstnat + comment: SSH Gitea + dst-address: 139.28.40.212 + dst-port: 22 + protocol: tcp + to-addresses: 10.44.0.6 + - action: dst-nat + chain: dstnat + comment: sunshine + dst-address: 139.28.40.212 + dst-port: 47984 + in-interface: pppoe-gpon + protocol: tcp + to-addresses: 192.168.0.67 + - action: dst-nat + chain: dstnat + comment: sunshine + dst-address: 139.28.40.212 + dst-port: 47989 + in-interface: pppoe-gpon + protocol: tcp + to-addresses: 192.168.0.67 + - action: dst-nat + chain: dstnat + comment: sunshine + dst-address: 139.28.40.212 + dst-port: 48010 + in-interface: pppoe-gpon + protocol: tcp + to-addresses: 192.168.0.67 + - action: dst-nat + chain: dstnat + comment: sunshine + dst-address: 139.28.40.212 + dst-port: 48010 + in-interface: pppoe-gpon + protocol: udp + to-addresses: 192.168.0.67 + - action: dst-nat + chain: dstnat + comment: sunshine + dst-address: 139.28.40.212 + dst-port: 47998-48000 + in-interface: pppoe-gpon + protocol: udp + to-addresses: 192.168.0.67 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure IPv6 firewall filter rules + community.routeros.api_modify: + path: ipv6 firewall filter + data: + - action: fasttrack-connection + chain: forward + connection-state: established,related + - action: accept + chain: forward + comment: Allow all already established connections + connection-state: established,related + - action: reject + chain: forward + comment: Forbid forwarding routed /48 from tunnelbroker to WAN + dst-address: 2001:470:61a3::/48 + out-interface-list: wan + reject-with: icmp-no-route + - action: reject + chain: forward + comment: Forbid forwarding routed /64 from tunnelbroker to WAN + dst-address: 2001:470:71:dd::/64 + out-interface-list: wan + reject-with: icmp-no-route + - action: accept + chain: forward + comment: Allow from LAN to everywhere + in-interface: vlan2 + - action: accept + chain: forward + comment: Allow ICMPv6 from internet to LAN + in-interface-list: wan + out-interface: vlan2 + protocol: icmpv6 + - action: accept + chain: forward + comment: Allow from SRV to internet + in-interface: vlan4 + out-interface-list: wan + - action: accept + chain: forward + comment: Allow from internet to SRV nodes + dst-address: 2001:470:61a3:100::/64 + in-interface-list: wan + out-interface: vlan4 + - action: accept + chain: forward + comment: Allow from internet to homelab LB + dst-address: 2001:470:61a3:400::/112 + in-interface-list: wan + out-interface: vlan4 + - action: accept + chain: forward + comment: Allow from SRV to CAM + in-interface: vlan4 + out-interface: vlan3 + - action: accept + chain: forward + comment: Allow from dockers to everywhere + in-interface: dockers + - action: accept + chain: forward + comment: Allow from internet to dockers + dst-address: 2001:470:61a3:500::/64 + in-interface-list: wan + out-interface: dockers + - action: accept + chain: forward + comment: Allow tcp transmission port to LAN + dst-port: 51413 + out-interface: vlan2 + protocol: tcp + - action: accept + chain: forward + comment: Allow udp transmission port to LAN + dst-port: 51413 + out-interface: vlan2 + protocol: udp + - action: reject + chain: forward + comment: Reject all remaining + reject-with: icmp-no-route + - action: accept + chain: input + comment: Allow all already established connections + connection-state: established,related + - action: accept + chain: input + comment: Allow ICMPv6 + protocol: icmpv6 + - action: accept + chain: input + comment: Allow Winbox + dst-port: 8291 + protocol: tcp + - action: accept + chain: input + comment: Allow SSH Mikrotik + dst-port: 2137 + protocol: tcp + - action: accept + chain: input + comment: Allow DNS from LAN + dst-port: 53 + in-interface: vlan2 + protocol: udp + - action: accept + chain: input + dst-port: 53 + in-interface: vlan2 + protocol: tcp + - action: accept + chain: input + comment: Allow DNS from SRV + dst-port: 53 + in-interface: vlan4 + protocol: udp + - action: accept + chain: input + dst-port: 53 + in-interface: vlan4 + protocol: tcp + - action: accept + chain: input + comment: Allow DNS from dockers + dst-port: 53 + in-interface: dockers + protocol: udp + - action: accept + chain: input + dst-port: 53 + in-interface: dockers + protocol: tcp + - action: accept + chain: input + comment: Allow BGP from SRV + dst-port: 179 + in-interface: vlan4 + protocol: tcp + src-address: 2001:470:61a3:100::/64 + - action: reject + chain: input + comment: Reject all remaining + reject-with: icmp-admin-prohibited + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure IPv6 NAT rules + community.routeros.api_modify: + path: ipv6 firewall nat + data: + - action: src-nat + chain: srcnat + comment: src-nat tailnet to internet + out-interface-list: wan + src-address: fd7a:115c:a1e0::/48 + to-address: 2001:470:61a3:600::/64 + - action: masquerade + chain: srcnat + disabled: true + in-interface: vlan2 + out-interface: vlan4 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true diff --git a/ansible/tasks/hardware.yml b/ansible/tasks/hardware.yml new file mode 100644 index 0000000..bb9f89a --- /dev/null +++ b/ansible/tasks/hardware.yml @@ -0,0 +1,103 @@ +--- +- name: Configure ethernet interface metadata and SFP options + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: interface ethernet + find: + default-name: "{{ item.default_name }}" + values: "{{ item.config }}" + loop: + - default_name: ether1 + config: + comment: Mój pc + - default_name: ether2 + config: + comment: Wifi środek + - default_name: ether8 + config: + comment: Serwer + - default_name: ether9 + config: + comment: Wifi góra + - default_name: ether10 + config: + comment: Kamera na domu + - default_name: ether11 + config: + comment: KVM serwer + - default_name: sfp-sfpplus1 + config: + auto-negotiation: false + comment: GPON WAN + speed: 2.5G-baseX + - default_name: sfp-sfpplus2 + config: + comment: GARAŻ + loop_control: + label: "{{ item.default_name }}" + +- name: Configure LTE interface defaults + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: interface lte + find: + default-name: lte1 + values: + apn-profiles: default-nodns + comment: Backup LTE WAN + +- name: Configure LTE APN profiles + community.routeros.api_modify: + path: interface lte apn + data: + - add-default-route: false + apn: internet + comment: default but without dns and default route + ipv6-interface: lte1 + name: default-nodns + use-network-apn: true + use-peer-dns: false + # Default APN we can't really remove yet I don't want to reconfigure it + - add-default-route: true + apn: internet + authentication: none + default-route-distance: 2 + ip-type: auto + name: default + use-network-apn: true + use-peer-dns: true + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + +- name: Configure temporary disk for containers + community.routeros.api_modify: + path: disk + data: + - slot: tmp1 + type: tmpfs + # This is not ideal, there's no unique identifier for usb disk, + # after reinstall it might be assigned to another slot + # Just adding disk with slot usb1 and not specifying anything else + # so ansible doesn't touch it + - slot: usb1 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + +- name: Configure switch settings + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: interface ethernet switch + find: + .id: "0" + values: + qos-hw-offloading: true + # Enabling L3 offloading would cause all packets to skip firewall and NAT + l3-hw-offloading: false + +- name: Configure neighbor discovery settings + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: ip neighbor discovery-settings + find: {} + values: + discover-interface-list: '!dynamic' diff --git a/ansible/tasks/preflight.yml b/ansible/tasks/preflight.yml new file mode 100644 index 0000000..8f6e2e2 --- /dev/null +++ b/ansible/tasks/preflight.yml @@ -0,0 +1,46 @@ +--- +- name: Verify API connectivity and fetch basic facts + community.routeros.api_facts: + gather_subset: + - default + - hardware + +- name: Show target identity + ansible.builtin.debug: + msg: "Managing {{ ansible_host }} ({{ ansible_facts['net_model'] | default('unknown model') }})" + +- name: Assert expected router model + ansible.builtin.assert: + that: + - ansible_facts['net_model'] is defined + - ansible_facts['net_model'] == "CRS418-8P-8G-2S+" + fail_msg: "Unexpected router model: {{ ansible_facts['net_model'] | default('unknown') }}" + success_msg: "Router model matches expected CRS418-8P-8G-2S+" + +- name: Read RouterOS device-mode flags + community.routeros.api: + path: system/device-mode + register: routeros_device_mode + check_mode: false + changed_when: false + +- name: Assert container feature is enabled in device mode + ansible.builtin.assert: + that: + - not (routeros_device_mode.skipped | default(false)) + - (routeros_device_mode | to_nice_json | lower) is search('container[^a-z0-9]+(yes|true)') + fail_msg: "RouterOS device-mode does not report container as enabled. Payload: {{ routeros_device_mode | to_nice_json }}" + success_msg: "RouterOS device-mode confirms container=yes" + +- name: Read configured disks + community.routeros.api_info: + path: disk + register: routeros_disks + check_mode: false + +- name: Assert usb1 disk is present + ansible.builtin.assert: + that: + - (routeros_disks.result | selectattr('slot', 'equalto', 'usb1') | list | length) > 0 + fail_msg: "Required disk slot usb1 is not present on router." + success_msg: "Required disk usb1 is present" diff --git a/ansible/tasks/routing.yml b/ansible/tasks/routing.yml new file mode 100644 index 0000000..5c758f9 --- /dev/null +++ b/ansible/tasks/routing.yml @@ -0,0 +1,99 @@ +--- +- name: Configure IPv4 routes + community.routeros.api_modify: + path: ip route + data: + - comment: Tailnet + disabled: false + distance: 1 + dst-address: 100.64.0.0/10 + gateway: 172.17.0.2 + routing-table: main + scope: 30 + suppress-hw-offload: false + target-scope: 10 + - disabled: false + distance: 1 + dst-address: 0.0.0.0/0 + gateway: pppoe-gpon + routing-table: main + scope: 30 + suppress-hw-offload: false + target-scope: 10 + vrf-interface: pppoe-gpon + - disabled: false + distance: 2 + dst-address: 0.0.0.0/0 + gateway: 192.168.8.1 + routing-table: main + scope: 30 + suppress-hw-offload: false + target-scope: 10 + vrf-interface: lte1 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + +- name: Configure IPv6 routes + community.routeros.api_modify: + path: ipv6 route + data: + - disabled: false + distance: 1 + dst-address: 2000::/3 + gateway: 2001:470:70:dd::1 + scope: 30 + target-scope: 10 + - comment: Tailnet + disabled: false + dst-address: fd7a:115c:a1e0::/48 + gateway: 2001:470:61a3:500::1 + pref-src: "" + routing-table: main + suppress-hw-offload: false + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + +- name: Configure BGP instance + community.routeros.api_modify: + path: routing bgp instance + data: + - name: bgp-homelab + as: 65000 + disabled: false + router-id: 192.168.1.1 + routing-table: main + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure BGP templates + community.routeros.api_modify: + path: routing bgp template + data: + - name: klaster + afi: ip,ipv6 + as: 6500 + disabled: false + # Default template + - name: default + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + +- name: Configure BGP connections + community.routeros.api_modify: + path: routing bgp connection + data: + - name: bgp1 + afi: ip,ipv6 + as: 65000 + connect: true + disabled: false + instance: bgp-homelab + listen: true + local.role: ibgp + remote.address: 2001:470:61a3:100::3/128 + routing-table: main + templates: klaster + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true diff --git a/ansible/tasks/system.yml b/ansible/tasks/system.yml new file mode 100644 index 0000000..491dda9 --- /dev/null +++ b/ansible/tasks/system.yml @@ -0,0 +1,43 @@ +--- +- name: Configure system clock + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: system clock + find: {} + values: + time-zone-name: Europe/Warsaw + +- name: Configure dedicated Ansible management user + community.routeros.api_modify: + path: user + data: + - name: "{{ routeros_api_username }}" + group: full + password: "{{ routeros_api_password }}" + disabled: false + comment: "Ansible management user" + handle_absent_entries: ignore + handle_entries_content: remove_as_much_as_possible + +- name: Configure service ports and service enablement + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: ip service + find: + name: "{{ item.name }}" + values: "{{ item }}" + loop: + - name: ftp + disabled: true + - name: telnet + disabled: true + - name: www + disabled: true + - name: ssh + port: 2137 + - name: api + disabled: true + - name: api-ssl + disabled: false + loop_control: + label: "{{ item.name }}" diff --git a/ansible/tasks/wan.yml b/ansible/tasks/wan.yml new file mode 100644 index 0000000..9400784 --- /dev/null +++ b/ansible/tasks/wan.yml @@ -0,0 +1,44 @@ +--- +- name: Configure PPPoE client + community.routeros.api_modify: + path: interface pppoe-client + data: + - disabled: false + interface: sfp-sfpplus1 + keepalive-timeout: 2 + name: pppoe-gpon + password: "{{ routeros_pppoe_password }}" + use-peer-dns: true + user: "{{ routeros_pppoe_username }}" + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure 6to4 tunnel interface + community.routeros.api_modify: + path: interface 6to4 + data: + - comment: Hurricane Electric IPv6 Tunnel Broker + local-address: 139.28.40.212 + mtu: 1472 + name: sit1 + remote-address: 216.66.80.162 + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + +- name: Configure veth interface for containers + community.routeros.api_modify: + path: interface veth + data: + - address: 172.17.0.2/16,2001:470:61a3:500::1/64 + container-mac-address: 7E:7E:A1:B1:2A:7C + dhcp: false + gateway: 172.17.0.1 + gateway6: 2001:470:61a3:500:ffff:ffff:ffff:ffff + mac-address: 7E:7E:A1:B1:2A:7B + name: veth1 + comment: Tailscale container + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true diff --git a/ansible/vars/routeros-secrets.yml b/ansible/vars/routeros-secrets.yml new file mode 100644 index 0000000..bfb66e2 --- /dev/null +++ b/ansible/vars/routeros-secrets.yml @@ -0,0 +1,19 @@ +--- +# Secret references only; actual values are loaded from OpenBao/Vault at runtime. + +# KVv2 mount and secret path (full secret path is /data/). +openbao_kv_mount: secret + +# Field names expected in the OpenBao secret. +openbao_fields: + routeros_api: + path: routeros_api + username_key: username + password_key: password + wan_pppoe: + path: wan_pppoe + username_key: username + password_key: password + routeros_tailscale_container: + path: router_tailscale + container_password_key: container_password diff --git a/devenv.nix b/devenv.nix index fa779c7..f93bf38 100644 --- a/devenv.nix +++ b/devenv.nix @@ -4,6 +4,7 @@ let # Python with hvac package python = pkgs.python313.withPackages (python-pkgs: with python-pkgs; [ hvac + librouteros ]); in { @@ -35,7 +36,6 @@ in openebs browse-pvc ])) - ansible fluxcd restic openbao @@ -59,4 +59,9 @@ in echo "Running tests" git --version | grep --color=auto "${pkgs.git.version}" ''; + + languages.ansible.enable = true; + # TODO: automatically manage collections from ansible/requirements.yml + # For now, we need to manually install them with `ansible-galaxy collection install -r ansible/requirements.yml` + # This is not implemented in devenv }