Remake Ansible playbook to target MikroTik router

Basically, I've exported configuration from Mikrotik router using /export and vibe-coded playbook using the file.
This commit is contained in:
2026-03-12 17:34:49 +01:00
parent 6cf71312f6
commit 2d1001f0f2
23 changed files with 1317 additions and 87 deletions

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "openwrt/roles/ansible-openwrt"]
path = openwrt/roles/ansible-openwrt
url = https://github.com/gekmihesg/ansible-openwrt.git

View File

@@ -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.

20
ansible/README.md Normal file
View File

@@ -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`.

5
ansible/ansible.cfg Normal file
View File

@@ -0,0 +1,5 @@
[defaults]
inventory = inventory/hosts.yml
host_key_checking = False
retry_files_enabled = False
result_format = yaml

View File

@@ -1,2 +0,0 @@
[openwrt]
2001:470:61a3:100:ffff:ffff:ffff:ffff ansible_scp_extra_args="-O"

View File

@@ -0,0 +1,6 @@
all:
children:
mikrotik:
hosts:
crs418:
ansible_host: 192.168.255.10

View File

@@ -1,6 +0,0 @@
- name: Configure router
hosts: openwrt
remote_user: root
roles:
- ansible-openwrt
- router

View File

@@ -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

5
ansible/requirements.yml Normal file
View File

@@ -0,0 +1,5 @@
collections:
- name: community.routeros
version: ">=3.16.0"
- name: community.hashi_vault
version: ">=7.1.0"

View File

@@ -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;
};
}

View File

@@ -1,5 +0,0 @@
- name: Reload bird
service:
name: bird
state: restarted
enabled: true

View File

@@ -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

View File

@@ -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

226
ansible/tasks/base.yml Normal file
View File

@@ -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

View File

@@ -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

480
ansible/tasks/firewall.yml Normal file
View File

@@ -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

103
ansible/tasks/hardware.yml Normal file
View File

@@ -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'

View File

@@ -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"

99
ansible/tasks/routing.yml Normal file
View File

@@ -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

43
ansible/tasks/system.yml Normal file
View File

@@ -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 }}"

44
ansible/tasks/wan.yml Normal file
View File

@@ -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

View File

@@ -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 <mount>/data/<path>).
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

View File

@@ -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
}