Berik Ashimov // senior.it.engineer
Download CV
POSTS / MIKROTIK

Automating RouterOS 7 with the REST API and Ansible: Desired State, Finally

// series · MikroTik RouterOS 7 Guide part 25 of 35
  1. ... 22 earlier
  2. RouterOS Scripting That Does Not Bite: Scope, Arrays, and Error Handling
  3. Scheduler and Event-Driven Automation on RouterOS 7: Scripts That Run Themselves
  4. Automating RouterOS 7 with the REST API and Ansible: Desired State, Finally
  5. Monitoring RouterOS 7: SNMPv3, Prometheus, and the Metrics That Actually Matter
  6. RouterOS 7 Logging and Audit: Syslog, Firewall Logs, and NetFlow Without the Noise
  7. ... 8 later
view the full series →

The previous two articles ended at the same wall: on-box scripting is imperative. It can react to events beautifully, but it cannot express “this is what the router should look like” and converge reality toward it. For that you need an external system talking to an API — and RouterOS 7 finally made that respectable by shipping a REST API in the base system. Combined with the mature community.routeros Ansible collection, MikroTik boxes can be managed the same way I manage VyOS, Cisco and Juniper gear in the network-automation articles elsewhere on this site: config in git, changes through review, idempotent runs.

The REST API

RouterOS 7’s REST API rides the web service: enable www-ssl (never plain www for API use), and every console path becomes a URL under /rest:

Terminal window
# On the router — TLS service with a real or self-signed cert
/certificate add name=api-cert common-name=router.example.com key-size=2048
/certificate sign api-cert
/ip service set www-ssl certificate=api-cert disabled=no address=10.10.0.0/24
/ip service set www disabled=yes

Then from any HTTP client, with basic auth against a RouterOS user:

Terminal window
# Read: GET maps to print
curl -sk -u automation:PASS https://10.10.0.1/rest/interface | jq .
curl -sk -u automation:PASS https://10.10.0.1/rest/ip/address | jq .
# Filtered query
curl -sk -u automation:PASS 'https://10.10.0.1/rest/ip/route?dst-address=0.0.0.0/0'
# Create: PUT adds an entry
curl -sk -u automation:PASS -X PUT -H 'Content-Type: application/json' \
-d '{"list":"blocklist","address":"203.0.113.66","timeout":"1h"}' \
https://10.10.0.1/rest/ip/firewall/address-list
# Modify: PATCH by internal .id
curl -sk -u automation:PASS -X PATCH -H 'Content-Type: application/json' \
-d '{"comment":"managed by automation"}' \
https://10.10.0.1/rest/ip/firewall/address-list/*2A
# Arbitrary commands: POST to the path with a verb
curl -sk -u automation:PASS -X POST -H 'Content-Type: application/json' \
-d '{".query":["disabled=false"]}' https://10.10.0.1/rest/interface/print

The mapping is mechanical: console path → URL, print → GET, add → PUT, set → PATCH, remove → DELETE, anything else → POST. Responses are JSON, and entries carry the internal .id you use for updates. Two operational notes: the API respects user group policies (an automation user with read,write,api,rest-api policy and no ftp/telnet is the right shape), and everything is synchronous — a long-running command holds the HTTP connection.

The binary API (port 8728/8729) predates REST and speaks a length-prefixed sentence protocol. It still exists, it’s what many libraries (librouteros, RouterOS-api) and Ansible’s api_* modules use, and it’s fine — but for anything you’re writing yourself today, REST + JSON is less friction and easier to debug with curl. Whichever you use, restrict it: /ip service set api,api-ssl address=<mgmt-subnet> or disable outright if only REST is in play.

Ansible: community.routeros

The collection ships three lanes and choosing correctly matters:

api_modify / api_find_and_modify — declarative, idempotent modules over the binary API. This is the lane for desired state.

api — raw API calls from playbooks; escape hatch for paths api_modify doesn’t model.

routeros.command — SSH/CLI-based; no API needed, not idempotent, right for one-off operational commands (/system backup save) and boxes where the API isn’t reachable yet.

Setup on the control node:

Terminal window
ansible-galaxy collection install community.routeros
pip install librouteros

Inventory:

inventory/routers.yml
mikrotik:
hosts:
gw-office:
ansible_host: 10.10.0.1
vars:
ansible_network_os: community.routeros.routeros
ansible_user: automation
ansible_password: "{{ vault_routeros_password }}"

And a playbook that expresses state, not steps:

- name: Baseline firewall address-lists
hosts: mikrotik
gather_facts: false
connection: local
tasks:
- name: Management subnet list is exactly this
community.routeros.api_modify:
hostname: "{{ ansible_host }}"
username: "{{ ansible_user }}"
password: "{{ ansible_password }}"
tls: true
validate_certs: false # true + proper CA in real life
path: ip firewall address-list
data:
- list: mgmt
address: 10.10.0.0/24
- list: mgmt
address: 172.16.255.0/24
handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible
restrict:
- field: list
values: [mgmt]

The restrict + handle_absent_entries combination is the payoff: Ansible makes the mgmt list contain exactly those entries — adds what’s missing, removes what shouldn’t be there, touches nothing outside the restriction. Run it twice, the second run reports zero changes. That’s the idempotency the scheduler article said you’d otherwise hand-roll with find-before-act guards.

--check --diff works and is non-negotiable in a pipeline: every change to router config gets a dry-run diff in the merge request before it gets an apply. This is the same discipline as the VyOS and Cisco Ansible articles — MikroTik just took a few extra years to make it this clean.

The Idempotency Reality Check

Honesty section. api_modify models many paths well (firewall, addresses, interfaces, DHCP, DNS, routes…), but RouterOS wasn’t designed as declarative-first, and edges exist:

Ordering is your problem. Firewall rules are order-sensitive; api_modify manages entry content but rule sequencing needs care — manage whole chains with explicit ordering, or structure chains so order-critical segments are jump targets built entirely by automation. Mixing hand-placed and Ansible-placed rules in one chain ends in tears.

Some paths are stateful or dynamic. Dynamic entries (DHCP leases marked D, connection-tracking, dynamic address-list entries with timeouts) don’t belong in desired state — restrict your tasks so automation never fights the router’s own dynamism.

Secrets echo. Passwords set via API/Ansible appear in playbooks and logs unless you vault them (ansible-vault, no_log: true on tasks). A router credential in plaintext in git is a full compromise of everything downstream — treat inventory vars as production secrets, because they are.

Check mode isn’t proof. --check validates against the module’s model, not the router’s runtime behavior. A rule can be idempotent-correct and operationally wrong (wrong chain, wrong position relative to fasttrack). The verification habit stays: after apply, confirm counters move on the rules you expect (/ip firewall filter print stats).

SSH Fallback and Backups

Not everything deserves API plumbing. Operational one-offs ride routeros.command over SSH:

- name: Export config for the git archive
community.routeros.command:
commands:
- /export show-sensitive
register: config_export
- name: Save export locally
ansible.builtin.copy:
content: "{{ config_export.stdout[0] }}"
dest: "backups/{{ inventory_hostname }}-{{ '%Y%m%d' | strftime }}.rsc"
delegate_to: localhost

Nightly, this gives you every router’s config in git with history — which quietly solves the “what changed before it broke” question forever. (The backup article coming later in this series covers the binary-backup side and why you want both.)

A Sane Pipeline

What this looks like assembled, and what I actually run for network gear: inventory + playbooks in a git repo; changes as merge requests; CI runs --check --diff against a lab CHR (the CHR article closes this series — a free CHR instance is a perfect pre-prod twin); human reviews the diff; merge applies to production with serial: 1 so a bad change hits one router, not the fleet. On-box scripting from the last two articles doesn’t disappear — it keeps handling local reactions (failover, alerts) while Ansible owns configuration. Reactions on the box, state in git.

Closing Thoughts

RouterOS 7’s REST API turned MikroTik automation from “install a library that speaks a binary protocol” into “curl and jq.” The Ansible collection turned config management from imperative scripts into reviewable desired state with real idempotency — restricted, diffed, dry-runnable. The edges are real: you own rule ordering, you vault secrets, you verify with counters after apply. But the overall shift is the one that matters: the router stops being a box you log into and becomes an endpoint your infrastructure code converges. Every serious network I run has crossed that line; the boxes that haven’t are the ones I still get paged about.

// share