
Routeros Fundamentals
- 1 installs
- 404 repo stars
- Updated August 5, 2026
- aiskillstore/marketplace
routeros-fundamentals is a domain-knowledge skill teaching MikroTik RouterOS 7.x CLI, REST API, and architecture to AI agents.
About
routeros-fundamentals is a domain-knowledge skill for MikroTik RouterOS v7. It explains that RouterOS is not GNU/Linux, its path-based CLI syntax, the REST API HTTP-verb mapping, version scheme, architecture names, and default credentials. A developer or agent uses it when writing RouterOS CLI/script commands, calling the REST API, or debugging why a Linux command fails on RouterOS.
- RouterOS v7 domain knowledge for AI agents
- Explains why Linux/shell commands fail on RouterOS
- REST API verb mapping and CLI syntax reference
Routeros Fundamentals by the numbers
- 1 all-time installs (skills.sh)
- Ranked #933 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
routeros-fundamentals capabilities & compatibility
- Capabilities
- routeros cli · rest api usage
- Use cases
- api development · debugging
What routeros-fundamentals says it does
RouterOS runs a Linux kernel (5.6.3) but **everything above the kernel is MikroTik's proprietary `nova` system**.
`PUT` creates (NOT updates) — opposite of many REST APIs
npx skills add https://github.com/aiskillstore/marketplace --skill routeros-fundamentalsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 404 |
| Last updated | August 5, 2026 |
| Repository | aiskillstore/marketplace ↗ |
What it does
Understand RouterOS 7.x CLI, REST API verbs, and architecture when automating MikroTik devices.
Who is it for?
Writing RouterOS CLI/scripts, calling its REST API, and avoiding Linux-command mistakes
Skip if: RouterOS v6 problems, which it explicitly does not cover
When should I use this skill?
Working with MikroTik RouterOS or debugging why a Linux command fails on it
What you get
Correct RouterOS CLI and REST calls instead of invalid Unix commands.
- RouterOS CLI command reference
- REST verb mapping table
By the numbers
- 5 HTTP-verb-to-RouterOS-action mappings
- 8 MikroTik architecture names
Files
RouterOS Fundamentals
RouterOS Is NOT GNU/Linux
RouterOS runs a Linux kernel (5.6.3) but everything above the kernel is MikroTik's proprietary `nova` system. This is the single most important fact for agents to internalize.
What does NOT exist on RouterOS:
- No
/bin,/usr,/etc,/var— no FHS layout - No bash, sh, ash, zsh — no Unix shell at all
- No coreutils (
ls,cat,grep,ps,mount,ip,iptables, etc.) - No glibc, musl, busybox
- No apt, pkg, opkg — no package manager (packages are
.npkfiles installed via upload + reboot) - No
systemctl,service, init system - No
/procor/sysaccessible from userland - No
docker,podman— RouterOS has its own/containersubsystem (7.x+)
What DOES exist:
- RouterOS CLI — its own language, not shell. Accessed via SSH, serial, WinBox, or WebFig
- REST API at
/rest/(HTTP, port 80 by default) — the primary programmatic interface - RouterOS scripting language (
.rscfiles) — its own syntax, not bash. See Scripting reference - WebFig (web UI) on port 80
- WinBox protocol on port 8291
Common agent mistakes to avoid:
- Do NOT try
ssh admin@host 'ls /'— it opens RouterOS CLI, not a shell - Do NOT suggest
mount,fdisk,mkfs— use/diskcommands instead - Do NOT look for config files at
/etc/— configuration is in the RouterOS database - Do NOT assume
pingworks the same — it's/tool/pingor/pingin CLI - Do NOT suggest installing packages via
aptoropkg— upload.npkvia SCP then/system/package/apply-changes(7.18+) or/system/reboot(<7.18) - See Extra packages reference for the full package list and installation pattern
RouterOS CLI Syntax
RouterOS CLI uses path-based navigation, not Unix command pipelines:
# Navigation
/ip/address/print
/interface/print
/system/resource/print
# Adding entries
/ip/address/add address=192.168.1.1/24 interface=ether1
# Modifying (by internal ID or find expression)
/ip/address/set [find interface=ether1] address=10.0.0.1/24
# Removing
/ip/address/remove [find address="192.168.1.1/24"]
# Running a command
/system/reboot
/tool/fetch url="http://example.com/file.npk" dst-path="/"Key syntax differences from shell:
=assigns properties (no spaces around it)[find ...]is the query expression (like WHERE)- Strings use
""(double quotes only) - Comments use
# - Variables:
:local myVar "value"and$myVar - No pipes, no redirection, no subshell
REST API
RouterOS REST API at http://HOST:PORT/rest/. HTTP verbs map non-standardly:
| HTTP | RouterOS Action | CLI Equiv |
|---|---|---|
GET | print (list/read) | /path/print |
PUT | add (create) | /path/add |
PATCH | set (update) | /path/set |
DELETE | remove | /path/remove |
POST | command (execute) | /path/command |
Key gotchas:
PUTcreates (NOT updates) — opposite of many REST APIs- Empty password auth:
admin:(colon required, nothing after) - WebFig root (
GET /) returns HTTP 200 without auth — use as health check - REST API (
/rest/) requires auth (HTTP 401 without it) .idfield is*HEXformat (e.g.,*1,*A)
See REST API reference for full patterns, error handling, filtering, POST commands, and /console/inspect.
Version Scheme
Format: MAJOR.MINOR[.PATCH][betaN|rcN] — e.g., 7.22, 7.22.1, 7.23beta2, 7.22rc1
Channels: stable / long-term / testing / development
Version endpoint (plain text): https://upgrade.mikrotik.com/routeros/NEWESTa7.<channel>
For version parsing, comparison, download URLs, and package naming: see Version parsing reference.
Architecture Names
MikroTik uses these architecture identifiers (not standard Linux arch names):
| MikroTik name | CPU | Common hardware |
|---|---|---|
x86 | x86_64 | CHR, x86-based RouterBOARDs |
arm64 | aarch64 | Modern ARM boards (RB5009, Chateau) |
arm | ARMv7 | Older ARM boards |
mipsbe | MIPS big-endian | Legacy RouterBOARDs |
mmips | MIPS multi-core | hAP ac, RB4011 |
smips | MIPS single-core | hAP lite, mAP |
ppc | PowerPC | CCR1xxx series |
tile | Tilera | CCR (older models) |
CHR (Cloud Hosted Router) is available only for x86 and arm64.
Default Credentials
- Username:
admin - Password: (empty — no password)
- On first login via SSH/console, RouterOS 7.x prompts to set a password or press
ato skip - REST API and WebFig allow empty-password access
Inspecting Hardware from RouterOS CLI
# PCI devices (the RouterOS equivalent of lspci)
/system/resource/hardware/print
# IRQ assignments (shows driver binding)
/system/resource/irq/print
# System overview
/system/resource/print
# Disk info
/disk/print
# Installed packages
/system/package/print
# IP services and ports
/ip/service/print
# Network interfaces
/interface/printAdditional Resources
Reference files:
- For REST API details and
/console/inspectcommand tree: see REST API reference - For version parsing, comparison, and download URL logic: see Version parsing reference
- For extra packages (container, iot, zerotier, etc.): see Extra packages reference
- For device-mode (modes, feature matrix, physical confirmation): see Device-mode reference
- For RouterOS scripting language syntax: see Scripting reference
- For user management, SSH keys, admin account: see Users REST reference
- For IP addressing, routing, DHCP, DNS, interfaces: see Networking REST reference
- For firewall filter/NAT/mangle and rule ordering: see Firewall REST reference
- For Bun runtime bugs affecting HTTP (req.destroy, pool, secrets): see Bun runtime gotchas
Related skills:
- For the /container subsystem (VETH, device-mode, lifecycle): see the
routeros-containerskill - For netinstall-cli and device flashing: see the
routeros-netinstallskill - For the /app YAML container format (7.22+): see the
routeros-app-yamlskill - For /console/inspect tree traversal and schema generation: see the
routeros-command-treeskill - For running CHR in QEMU (local or CI): see the
routeros-qemu-chrskill - For packet capture, /tool/sniffer, and TZSP streaming: see the
routeros-snifferskill
MCP tools:
- For command tree browsing and property lookups: use the
rosettaMCP server tools (routeros_search,routeros_get_page,routeros_command_tree)
RouterOS Async Commands via REST API
Lab-tested reference from CHR 7.22.1 (x86_64).
Three Modes for Monitor/Streaming Commands
RouterOS commands that stream data (monitor, check-for-updates, license/renew, etc.) behave differently on REST depending on which parameter is sent.
1. duration="Xs" — Timed run, section array response
Runs for X seconds. Response is held until the full duration completes. Returns a JSON array with .section indices — one per sample period (typically 1/s).
POST /rest/interface/monitor-traffic
Content-Type: application/json
{"interface":"ether1","duration":"3s"}[
{".section":"0","name":"ether1","rx-bits-per-second":"0","tx-bits-per-second":"0"},
{".section":"1","name":"ether1","rx-bits-per-second":"0","tx-bits-per-second":"0"},
{".section":"2","name":"ether1","rx-bits-per-second":"0","tx-bits-per-second":"0"}
].section values are string integers: "0", "1", "2", ...
Duration uses RouterOS duration format: "3s", "10s", "1m", "1d2h3m2s".
2. once="" — Single sample, immediate return
Returns a single-element JSON array with no .section field. Measured return time < 2ms.
POST /rest/interface/monitor-traffic
Content-Type: application/json
{"interface":"ether1","once":""}[{"name":"ether1","rx-bits-per-second":"512","tx-bits-per-second":"336"}]once is a presence-based boolean with one exception: any value enables it ("", "true", etc.), except once="false" which does NOT enable once mode — it blocks like no parameter.
3. No parameter — blocks indefinitely
Without duration or once, the REST call blocks until the HTTP client disconnects or the server's internal timeout fires.
POST /rest/interface/monitor-traffic
Content-Type: application/json
{"interface":"ether1"}⚠️ This hangs. Always set a client-side timeout or abort signal.
as-string for /rest/execute
Controls whether /rest/execute returns synchronously (inline output) or asynchronously (job ID). Separate from once/duration.
| Sent | Behavior | Response |
|---|---|---|
| absent | Async — returns job ID | {"ret":"*B546"} |
"as-string":"" | Sync — returns output inline | {"ret":"hello\r\n"} |
"as-string":"true" | Sync | same |
"as-string":"false" | Sync | same |
"as-string":0 | Sync | same |
Purely presence-based: ANY value (including "false" and 0) makes execute synchronous. This differs from once, where "false" does NOT activate the mode.
POST /rest/execute
Content-Type: application/json
{"script":":put hello","as-string":""}{"ret":"hello\r\n"}Response Shape by Command
/interface/monitor-traffic with duration="3s"
[
{".section":"0","name":"ether1","rx-bits-per-second":"0","tx-bits-per-second":"0"},
{".section":"1","name":"ether1","rx-bits-per-second":"0","tx-bits-per-second":"0"},
{".section":"2","name":"ether1","rx-bits-per-second":"0","tx-bits-per-second":"0"}
]/interface/ethernet/monitor with once=""
[{"name":"ether1","status":"link-ok","auto-negotiation":"done","rate":"","full-duplex":"false"}]Exact fields vary by NIC type (virtio vs real hardware).
/system/package/update/check-for-updates
[
{".section":"0","channel":"stable","installed-version":"7.22.1","status":"finding out latest version..."},
{".section":"1","channel":"stable","installed-version":"7.22.1","latest-version":"7.22.1","status":"System is already up to date"}
]/system/license/renew
[
{".section":"0","status":"connecting"},
{".section":"1","status":"renewing"},
{".section":"2","status":"ERROR: Unauthorized"}
]Commands Using This Pattern
All "monitor" / streaming-type commands follow the three modes above:
/interface/monitor-traffic/interface/ethernet/monitor/system/package/update/check-for-updates/system/license/renew/tool/bandwidth-test(documented as async, not lab-verified)
Exception: /system/device-mode/update
Does not use .section arrays. Blocks the HTTP connection and returns a single JSON object. See routeros-rest.instructions.md for its specific behavior.
General Rule
Any RouterOS command that streams data in the native API (/listen with .re sentences) will:
- Block indefinitely on REST without
duration=oronce= - Return `.section` arrays when using
duration= - Return single-element arrays (no
.section) when usingonce=
Recommended Patterns
One-shot status check
const { status, body } = await restPost(url, auth, { interface: "ether1", once: "" }, 5_000);
const [result] = JSON.parse(body);
// result has no .section — direct field accessTimed monitoring
const { status, body } = await restPost(url, auth, { interface: "ether1", duration: "5s" }, 10_000);
const sections = JSON.parse(body);
// sections[i][".section"] === String(i)
for (const sample of sections) {
console.log(`sample ${sample[".section"]}: rx=${sample["rx-bits-per-second"]} bps`);
}Commands that run their own course (check-for-updates, license/renew)
// Just POST with {} — the command decides when it's done
// Set an appropriate HTTP timeout as safety net
const { status, body } = await restPost(url, auth, { duration: "10s" }, 15_000);
const sections = JSON.parse(body);Error detection in section arrays
Check the last section's status field for an "ERROR:" prefix:
const sections = JSON.parse(body);
const last = sections[sections.length - 1];
if (last.status?.startsWith("ERROR:")) {
throw new Error(`RouterOS: ${last.status}`);
}Safety net
Always set a client-side timeout or AbortSignal on every async REST call. RouterOS will block indefinitely if duration/once is omitted or if the command enters an unexpected state.
Bun Runtime Gotchas for quickchr
Consolidated reference for Bun-specific issues encountered in quickchr development. Every item here has been investigated — some confirmed as real bugs, some disproved but documented for posterity.
Bug 1 — req.destroy() Doesn't Emit error Event
Status: CONFIRMED (Bun 1.3.11)
Bun's node:http implementation does NOT emit the error event when req.destroy() is called. In Node.js, destroying an in-flight request emits an error event with an ECONNRESET-like error. In Bun, the promise wrapping the request just never resolves.
Impact: Any code that calls req.destroy() as a timeout mechanism and awaits the error event will hang indefinitely. This is the primary reason rest.ts uses node:http with a manual done flag + setTimeout + direct reject() pattern instead of fetch().
Fix (used in `rest.ts`):
let done = false;
const timer = setTimeout(() => {
if (!done) { done = true; req.destroy(); reject(new Error("timeout")); }
}, timeoutMs);
// ... in response callbacks:
if (!done) { done = true; clearTimeout(timer); resolve(...); }Affected endpoints: /system/device-mode/update (blocks up to 5 minutes), /system/license/renew (blocks while contacting license server). Both require reliable timeout handling to avoid hanging the process.
Rule: Do NOT implement this pattern inline — always go through rest.ts which handles it centrally.
Bug 2 — fetch() Connection Pool (Stale Responses)
Status: NOT REPRODUCED (Bun 1.3.11)
The original claim: Bun's fetch() pools TCP connections by host:port and ignores Connection: close. When a CHR instance is stopped and a new one starts on the same port, the pooled connection returns stale responses from the dead instance.
Lab result: All 9 tests in test/lab/bun-pool/ passed with both fetch() and node:http. The stop/restart scenario showed correct uptime and identity after restart. fetch() was ~1.8× faster than node:http (expected from connection reuse).
Why `rest.ts` still uses `node:http`: Bug 1 (req.destroy() silence) is the real reason. The connection pool defense is belt-and-suspenders — low cost, eliminates an entire class of potential issues even if the pool bug resurfaces in a future Bun version.
History: The original pool bug reports (quickchr sessions 008–017) were likely caused by:
- Older Bun versions with actual pool bugs
device-mode/updateconnection-dropping behavior misattributed to the pool- Post-boot REST race (RouterOS returns wrong data briefly after boot)
Bug 3 — Bun.secrets.get() Keychain Dialog
Status: CONFIRMED (macOS only)
Bun.secrets.get(key) triggers the macOS Keychain authorization dialog. In non-interactive contexts (CI, background processes, headless test runners), this blocks the process indefinitely waiting for a dialog that never appears.
Impact: Integration tests that use Bun.secrets for MikroTik.com credentials (license renewal) hang in CI or when run from a non-TTY context.
Fix: Use environment variables as primary credential source, with Bun.secrets as fallback only in interactive terminals:
const user = process.env.MIKROTIK_WEB_USER ?? (process.stdout.isTTY ? Bun.secrets.get("MIKROTIK_WEB_USER") : undefined);Bug 4 — Test Runner Event Loop Sharing
Status: CONFIRMED (Bun 1.3.11)
Bun's test runner shares a single event loop across all test files in the same process. When one test file makes a blocking HTTP request (e.g., device-mode/update blocks for 5 minutes), it starves the event loop and prevents other test files' HTTP requests from completing.
Impact: Running multiple lab test files together (bun test test/lab/) hangs. This is NOT a connection pool issue — it affects both fetch() and node:http.
Fix: Run lab test files individually:
# CORRECT
QUICKCHR_INTEGRATION=1 bun test test/lab/device-mode/device-mode.test.ts
# WRONG — will hang
QUICKCHR_INTEGRATION=1 bun test test/lab/Note: This does NOT affect unit tests or integration tests, which don't make blocking HTTP calls lasting minutes. It's specific to lab tests that exercise long-blocking RouterOS endpoints.
HTTP Client Decision Matrix
| Scenario | Client | Why |
|---|---|---|
CHR REST calls (rest.ts) | node:http + agent: false | Bug 1: req.destroy() must resolve promises |
| External URLs (versions.ts, images.ts, packages.ts) | fetch() | No timeout/destroy concerns |
| Integration test REST helpers | node:http + agent: false | Consistency with rest.ts |
| Unit test mocks for CHR REST | node:http createServer on port 0 | Must match rest.ts transport |
| Unit test mocks for external URLs | globalThis.fetch = ... | Matches fetch() in source |
The rule: Use fetch() everywhere except when you need req.destroy() with guaranteed error propagation (timeout handling on long-polling/blocking endpoints). This means rest.ts stays on node:http because of the destroy bug, not the pool bug.
Future Actions
- Bun bug report: File issue for
req.destroy()not emitting error event (Bug 1) - Periodic re-test: Re-run
test/lab/bun-pool/on each major Bun release to track pool behavior - Eventual unification: If Bun fixes Bug 1,
rest.tscould migrate back tofetch(). Until then, mixed pattern is correct.
Source:
- Lab: test/lab/bun-pool/REPORT.md — 9 tests across 2 files, Bug 2 disproved- Lab: test/lab/bun-pool/REPORT.md — Bug 4 discovered during pool investigation- Instruction: .github/instructions/bun-http.instructions.md — Bug 1, 2, 3 descriptions- Code:quickchr/src/lib/rest.ts—doneflag pattern for Bug 1
- Code:quickchr/src/lib/license.ts—Bun.secretsusage for Bug 3
- History: quickchr sessions 008–017 — pool bug discovered/fixed 7 times independently
Device-Mode REST API Behavior
Lab-tested reference for /system/device-mode REST endpoints on CHR 7.22.1 (x86_64). Supplements device-mode.md (which covers modes and feature matrix) with REST-specific behavior, blocking semantics, and the quickchr automation pattern.
GET /rest/system/device-mode
Returns a flat JSON object (not an array). All values are strings — booleans are "true"/"false", numbers are "0", "1", etc.
{
"mode": "advanced",
"allowed-versions": "",
"flagged": "false",
"flagging-enabled": "true",
"attempt-count": "0",
"scheduler": "true",
"socks": "true",
"fetch": "true",
"pptp": "true",
"l2tp": "true",
"bandwidth-test": "true",
"traffic-gen": "false",
"sniffer": "true",
"ipsec": "true",
"romon": "true",
"proxy": "true",
"hotspot": "true",
"smb": "true",
"email": "true",
"zerotier": "true",
"container": "false",
"install-any-version": "false",
"partitions": "false",
"routerboard": "false"
}24 attributes total. Default CHR mode is "advanced" with most features "true".
.proplist
Supports field selection:
GET /rest/system/device-mode?.proplist=mode,container
→ {"mode":"advanced","container":"false"}/print Does Not Work
/rest/system/device-mode/print returns HTTP 500. This is a singleton resource — it has no /print action via REST. Use plain GET instead.
POST /rest/system/device-mode/update — Blocking Endpoint
This endpoint ALWAYS blocks the HTTP response. It does not return until either:
1. A power-cycle confirms the change, or 2. The activation-timeout expires (default: 5 minutes)
This is the critical fact for automation: even a no-op update (same values, empty {} body) blocks. While the update is pending, all REST endpoints become unresponsive — the entire HTTP server stalls.
activation-timeout
Controls how long RouterOS waits for the power-cycle confirmation.
- Range:
10sto1d(RouterOS duration string format) - Default:
5m - Examples:
"30s","5m","1d"
Always send a short timeout for automation:
POST /rest/system/device-mode/update
{"container":"true","activation-timeout":"30s"}quickchr Automation Pattern
The recommended flow used in src/lib/device-mode.ts:
1. POST /rest/system/device-mode/update
- Include desired changes + activation-timeout=30s
- Fire and forget (don't await — it blocks)
- Use a 300s safety timeout on the HTTP request
2. Sleep 2s
- Let RouterOS register the pending change
3. Hard power-cycle via QEMU monitor
- Send `system_reset` to the QEMU monitor socket
- This is the "physical confirmation" RouterOS requires
- The pending HTTP request dies with ECONNRESET (suppress it)
4. Wait for boot
- Poll GET / until RouterOS responds
5. Verify the change
- GET /rest/system/device-mode
- Check that the requested fields match
- attempt-count should be "0" (resets on successful power-cycle)Key implementation detail: startDeviceModeUpdate() returns a promise but callers race it against a sleep — if the POST hasn't resolved in 2s, RouterOS entered blocking state and needs a power-cycle. The ECONNRESET from killing the connection must be caught and suppressed.
Timeout Expiry (No Power-Cycle)
If activation-timeout expires without a power-cycle, RouterOS returns:
HTTP 400
{"detail":"update canceled","error":400,"message":"Bad Request"}RouterOS resumes normal operation after this. The attempt-count increments by 1.
attempt-count Behavior
- Increments by 1 on every failed/canceled attempt
- Resets to
"0"only on successful power-cycle confirmation - Lab tested up to count=12 with no REST-visible limit
- The docs mention "only three times" — this appears to be a CLI-only restriction or approximation; REST continues to accept and block on update requests regardless of count
flagged vs attempt-count
These are independent mechanisms:
flagged— set by RouterOS at boot when it detects suspicious configuration (scripts, fetch, etc.). Not related to update attempts.attempt-count— tracks pending-change failures. Not related to flagging.
To clear flagged:
POST /rest/system/device-mode/update
{"flagged":"no","activation-timeout":"30s"}Then power-cycle to confirm.
Error Responses (Immediate — No Blocking)
These errors return immediately without blocking:
| Condition | Response |
|---|---|
| Invalid mode value | {"detail":"input does not match any value of mode","error":400} |
| Unknown parameter | {"detail":"unknown parameter nonexistent","error":400} |
| Bad timeout value | {"detail":"value of activation-timeout is out of range (00:00:10 .. 1d00:00:00)","error":400} |
All are HTTP 400. The endpoint only blocks when the request is valid and accepted.
Via /rest/execute
Update commands block the same way:
POST /rest/execute
{"script":"/system/device-mode/update container=yes activation-timeout=30s","as-string":""}Print works normally (no blocking):
POST /rest/execute
{"script":"/system/device-mode/print","as-string":""}
→ {"ret":" mode: advanced\n ...key: value text..."}Via SSH
SSH commands have the same blocking behavior for updates:
ssh -o BatchMode=yes -p 9102 admin@127.0.0.1 "/system/device-mode/print"
# Returns immediately with key:value text
ssh -o BatchMode=yes -p 9102 admin@127.0.0.1 "/system/device-mode/update container=yes"
# Blocks until power-cycle or timeoutPost-Boot REST Race
GET /rest/system/device-mode is subject to the same post-boot race as all endpoints. Briefly after boot it may return wrong data (e.g., /system/resource body). The readDeviceMode() function in quickchr guards against this by checking for board-name / architecture-name keys that indicate resource data leaked into the response.
When polling after a power-cycle, use a deadline loop:
const deadline = Date.now() + 20_000;
while (Date.now() < deadline) {
const { status, body } = await restGet(url, auth, 5_000);
if (status >= 200 && status < 300) {
const data = JSON.parse(body);
if (data && typeof data === "object" && !Array.isArray(data) && "mode" in data) {
return data;
}
}
await Bun.sleep(1_000);
}Source: Lab testing on CHR 7.22.1 x86_64, verified via curl and quickchr integration tests.
Device-Mode
Device-mode gates access to potentially risky features. Changing the mode requires physical confirmation (reset button press or power cycle within the activation timeout).
# View current mode and pending changes
/system/device-mode/print
# Change mode and enable features
/system/device-mode/update mode=advanced container=yes
# After executing: physically confirm within activation-timeout
# - Press reset button, OR
# - Power cycle the deviceMode script bypass (7.22+): During netinstall, a mode script (-sm) can set device-mode on first boot, automatically triggering a reboot — removing the manual power-cycle requirement for provisioning. See the routeros-netinstall skill.
Modes and Factory Defaults
There are four modes. The factory default depends on device type (since 7.17):
| Mode | Factory default on | Notes |
|---|---|---|
advanced | CCR, 1100 series, CHR, pre-7.17 devices | Previously called enterprise |
home | Home routers (hAP, cAP, etc.) | Most features disabled |
basic | All other device types | Mid-range restrictions |
rose | RDS-series devices | Like advanced but with container enabled by default |
Feature Matrix
All features below are /system/device-mode/update properties. Every feature is updatable — the matrix shows which are enabled by default per mode.
| Property | Type | Home | Basic | Advanced | ROSE |
|---|---|---|---|---|---|
scheduler | mode default | - | yes | yes | yes |
fetch | mode default | - | yes | yes | yes |
bandwidth-test | mode default | - | - | yes | yes |
sniffer | mode default | - | yes | yes | yes |
romon | mode default | - | yes | yes | yes |
hotspot | mode default | - | - | yes | yes |
proxy | mode default | - | - | yes | yes |
socks | mode default | - | - | yes | yes |
email | mode default | - | yes | yes | yes |
container | always off | - | - | - | yes* |
zerotier | mode default | - | - | yes | yes |
traffic-gen | always off | - | - | - | - |
partitions | always off | - | - | - | - |
routerboard | always off | - | - | - | - |
install-any-version | always off | - | - | - | - |
*ROSE mode enables container by default; on all other modes it must be explicitly enabled.
"Always off" features (per official docs: traffic-gen, container, partitions, routerboard, install-any-version) require explicit property=yes regardless of mode. "Mode default" features are enabled/disabled by the mode choice.
Other Properties
| Property | Default | Description |
|---|---|---|
activation-timeout | 5m | Time window for physical confirmation (10s–1d) |
flagging-enabled | yes | Enable suspicious-config detection |
An attempt-count increments on each canceled/timed-out change and resets to 0 only on successful power-cycle confirmation. Official docs say "only three times" but lab testing showed 12+ attempts via REST with no visible limit — the REST API continued to accept and block on update requests regardless of count. The "three times" limit may be CLI-only or an approximation.
If not confirmed within the activation timeout, the change is canceled and the count increments (it does not revert on next reboot — attempt-count survives reboots).
Source: Device-mode page (rosetta page 93749258, 7.22 docs) + lab verification on CHR 7.22.1 (x86_64). See device-mode-rest.md for full REST API behavior.RouterOS Extra Packages
Overview
RouterOS ships with a base feature set. Additional functionality is available via extra packages (.npk files) that are downloaded separately and installed by uploading + rebooting.
Package Installation
Built-In Packages (CHR 7.22.1+)
CHR images include 12 optional packages built in. No SCP upload or download needed:
# 1. Reveal available packages
/system/package/update/check-for-updates
# 2. Enable the desired package
/system/package/enable container
# 3. Apply changes (triggers reboot AND activates — /system/reboot does NOT work!)
/system/package/apply-changes⚠️ Critical: `/system/reboot` does NOT apply pending package changes. Always use /system/package/apply-changes which both triggers a reboot and commits enable/disable operations. A plain reboot discards all pending changes. (Lab-verified on CHR 7.22.1.)
See packages-rest.md for full REST API details and response shapes.
External Packages (SCP Upload)
For packages not built into the image (e.g., third-party .npk files):
# 1. Upload .npk files via SCP (or Winbox drag-and-drop, or WebFig file upload)
# scp my-package-7.22-arm64.npk admin@router:/
# 2. Apply changes (NOT /system/reboot!)
/system/package/apply-changesKey Extra Packages
| Package | CLI Paths Added | Notable Features |
|---|---|---|
container | /container, /app | Container runtime, /app YAML system |
iot | /iot | MQTT, BLE, LoRa, GPS |
zerotier | /zerotier | ZeroTier VPN |
wifi-qcom / wifi-qcom-ac | /interface/wifi | Qualcomm WiFi drivers |
rose-storage | /disk | Extended storage management |
ups | /system/ups | UPS monitoring |
gps | /system/gps | GPS receiver |
calea | /system/calea | Lawful intercept |
tr069-client | /tr069-client | TR-069/CWMP |
user-manager | /user-manager | RADIUS user management |
Download URL
Extra packages are bundled in a single zip per architecture:
https://download.mikrotik.com/routeros/{version}/all_packages-{arch}-{version}.zipArchitectures: x86, arm64, arm, mipsbe, mmips, smips, ppc, tile
x86 naming exception: Individual x86 .npk files omit the architecture suffix entirely (e.g., container-7.22.npk not container-7.22-x86.npk). The all_packages zip does use x86 in its name. See version-parsing reference for full download URL patterns.
Impact on Command Tree
Installing extra packages extends the command tree — new paths, commands, and arguments become visible via /console/inspect. This is why schema generation runs in two variants:
- Base: only built-in RouterOS commands
- Extra: all packages installed — captures the full command tree
The /app REST endpoint (GET /rest/app) specifically requires the container package. Without it, the endpoint returns 404.
Package Detection
// Check installed packages via REST
const packages = await fetch(`${base}/system/package`, auth).then(r => r.json());
// Returns array: [{name: "routeros", version: "7.22", ...}, {name: "container", ...}]
const hasContainer = packages.some(p => p.name === "container" && !p.disabled);RouterOS /system/license REST API Reference
Lab-verified on CHR 7.22.1 (x86_64). Every response shape below was captured via curl against a running instance.CHR License Tiers
| Level | Throughput Limit | Notes |
|---|---|---|
free | 1 Mbps per interface | Default for CHR |
p1 | 1 Gbps per interface | 60-day trial available via /system/license/renew |
p10 | 10 Gbps per interface | |
p-unlimited | Unlimited |
Trial upgrades to p1 require valid MikroTik.com credentials and are limited per account.
GET /rest/system/license
Free Tier (default)
{"level":"free","system-id":"7WwsTkLUKQG"}Only two fields on a free CHR. No expiration, nlevel, or deadline keys exist.
After a trial upgrade, additional fields appear and level changes (e.g., to "p1").
Via /rest/execute
{"ret":" system-id: 7WwsTkLUKQG\r\n level: free "}Standard RouterOS key-value text output with whitespace padding.
POST /rest/system/license/renew
Async command — uses the .section array response pattern (same as monitor-traffic, check-for-updates). Blocks while contacting MikroTik license servers.
Request Fields
| Field | Required | Description |
|---|---|---|
account | Yes | MikroTik.com email address |
password | Yes | MikroTik.com password |
level | Yes | License level to request (e.g., "p1") |
duration | No | How long to wait for server response (RouterOS duration string: "10s", "15s") |
Response Shapes
Missing credentials — HTTP 400
curl -u admin: http://127.0.0.1:9100/rest/system/license/renew \
-X POST -H "Content-Type: application/json" \
-d '{"level":"p1"}'{"detail":"missing =account=","error":400,"message":"Bad Request"}Immediate response, no blocking.
Bad credentials — HTTP 200
curl -u admin: http://127.0.0.1:9100/rest/system/license/renew \
-X POST -H "Content-Type: application/json" \
-d '{"account":"user@example.com","password":"wrong","level":"p1","duration":"10s"}'[
{".section":"0","status":"connecting"},
{".section":"1","status":"renewing"},
{".section":"2","status":"ERROR: Unauthorized"}
]Takes ~2–5s to contact the server and receive the rejection.
Successful renewal — HTTP 200
curl -u admin: http://127.0.0.1:9100/rest/system/license/renew \
-X POST -H "Content-Type: application/json" \
-d '{"account":"valid@example.com","password":"correct","level":"p1","duration":"10s"}'[
{".section":"0","status":"connecting"},
{".section":"1","status":"done"}
]After success, GET /rest/system/license reflects the new level.
Trial limit reached — HTTP 200
[
{".section":"0","status":"connecting"},
{".section":"1","status":"renewing"},
{".section":"2","status":"ERROR: Licensing Error: too many trial licences"}
]Post-boot REST race (endpoint not initialized)
The response body contains system resource data instead of license data. This is the standard post-boot race condition — the endpoint has not finished initializing. Retry until the response matches the expected shape.
Error Classification
All .section array responses arrive as HTTP 200, including errors. The status field in the final section entry determines success or failure:
Last status value | Meaning | Action |
|---|---|---|
"done" | License accepted | Poll GET /system/license to verify level changed |
"connecting" | Still in progress | Should not be final — indicates truncated response or missing duration |
"ERROR: Unauthorized" | Bad MikroTik.com credentials | Throw immediately |
"ERROR: Licensing Error: too many trial licences" | Account trial limit reached | Throw immediately |
Any "ERROR: ..." | Server-side rejection | Throw immediately with the error text |
Critical: code MUST check the last section's status for an "ERROR:" prefix and throw immediately. Do NOT misclassify errors as "pending" and enter a polling loop — the error IS the final status.
Recommended Pattern
1. POST /rest/system/license/renew with duration="15s"
Body: {"account":"...","password":"...","level":"p1","duration":"15s"}
2. Parse array response
3. Find the LAST entry (highest .section number)
4. Check its status field:
- Starts with "ERROR:" → throw with the error message
- Equals "done" → poll GET /rest/system/license to verify level changed
5. If no credentials configured → skip renewal entirely (leave as free tier)RouterOS /system/package REST API Reference
Lab-verified against CHR 7.22.1 (x86_64). All responses confirmed via curl.
Package Object Shape
GET /rest/system/package returns a JSON array. Each element:
| Field | Type | Description |
|---|---|---|
.id | string | RouterOS internal ID (e.g. *1) |
name | string | Package name (e.g. routeros, container) |
version | string | Installed version, or "" if not installed |
available | string | "true" if not yet installed, "false" if installed |
disabled | string | "true" if disabled/not-active, "false" if active |
scheduled | string | Pending action (see below), or "" |
build-time | string | Build timestamp |
size | string | Package size in bytes |
Package States
| State | available | disabled | version |
|---|---|---|---|
| Installed + active | "false" | "false" | "7.22.1" |
| Installed + disabled | "false" | "true" | "7.22.1" |
| Available (not installed) | "true" | "true" | "" |
Built-In Optional Packages (CHR 7.22.1)
These 12 packages are built into the CHR image — no SCP upload or download needed:
routeros (always installed), calea, container, dude, gps, iot, openflow, rose-storage, tr069-client, ups, user-manager, wireless
Package Visibility
- Fresh boot: only installed packages appear (typically just
routeros). - After check-for-updates: all available built-in packages appear in the list.
- After disable + apply-changes: disabled packages remain visible with
disabled="true".
Endpoints
GET /rest/system/package
Returns all visible packages.
curl -s -u admin: http://127.0.0.1:9100/rest/system/packageGET /rest/system/package/update
Returns update channel and installed version.
curl -s -u admin: http://127.0.0.1:9100/rest/system/package/updateBefore check-for-updates:
{"channel":"stable","installed-version":"7.22.1"}After check-for-updates adds latest-version and status:
{"channel":"stable","installed-version":"7.22.1","latest-version":"7.22.1","status":"System is already up to date"}POST /rest/system/package/update/check-for-updates
Async command — returns a progressive-status array with .section indices:
curl -s -u admin: http://127.0.0.1:9100/rest/system/package/update/check-for-updates \
-X POST -H "Content-Type: application/json" -d '{}'[
{".section":"0","channel":"stable","installed-version":"7.22.1","status":"finding out latest version..."},
{".section":"1","channel":"stable","installed-version":"7.22.1","latest-version":"7.22.1","status":"System is already up to date"}
]Side effect: reveals all available optional packages in subsequent GET /rest/system/package.
POST /rest/system/package/enable
curl -s -u admin: http://127.0.0.1:9100/rest/system/package/enable \
-X POST -H "Content-Type: application/json" -d '{"numbers":"container"}'Response: [] (empty array = success). Sets scheduled="scheduled for enable" on the package.
POST /rest/system/package/disable
curl -s -u admin: http://127.0.0.1:9100/rest/system/package/disable \
-X POST -H "Content-Type: application/json" -d '{"numbers":"container"}'Response: [] (empty array = success). Sets scheduled="scheduled for disable" on the package.
POST /rest/system/package/apply-changes
Triggers reboot and applies all scheduled package changes.
curl -s -u admin: http://127.0.0.1:9100/rest/system/package/apply-changes \
-X POST -H "Content-Type: application/json" -d '{}'Response: [] — connection drops as router reboots.
Scheduled Field Values
| Value | Meaning |
|---|---|
"" | No pending changes |
"scheduled for enable" | Will be installed/enabled on apply-changes |
"scheduled for disable" | Will be disabled on apply-changes |
Note: the value is "scheduled for enable", NOT "scheduled for install".
Critical: apply-changes vs reboot
| Endpoint | Applies scheduled changes | Triggers reboot |
|---|---|---|
POST /rest/system/package/apply-changes | ✅ Yes | ✅ Yes |
POST /rest/system/reboot | ❌ No | ✅ Yes |
This is the biggest gotcha. A plain /system/reboot discards all pending package changes. Always use /system/package/apply-changes to commit enable/disable operations.
⚠️ Version requirement: /system/package/apply-changes was added in RouterOS 7.18. On versions <7.18, /system/reboot IS the correct (and only) method — and it DOES apply pending changes on those older versions. The "reboot discards changes" behavior is specific to 7.18+ where apply-changes exists. (Verified: rosetta routeros_command_version_check + live test on CHR 7.10, session 2025-07-17.)
Device-Mode Dependency
The container package can be enabled and installed without device-mode. However, /container commands will fail with "not allowed by device-mode" until device-mode is set:
/system/device-mode/update container=yesThis requires a power-cycle confirmation (see device-mode reference).
Recommended Pattern for quickchr
1. POST /rest/system/package/update/check-for-updates → reveals available packages
2. POST /rest/system/package/enable {"numbers":"<name>"} → schedule enable
3. POST /rest/system/package/apply-changes {} → reboot + apply
4. waitForBoot() → poll until REST ready
5. GET /rest/system/package → verify installedSCP upload is not needed for built-in packages on CHR 7.22.1+. The enable + apply-changes flow is sufficient.
RouterOS REST API Patterns
Verb Mapping
RouterOS REST maps HTTP verbs differently from typical REST APIs:
| HTTP Verb | RouterOS Action | CLI Equivalent | Notes |
|---|---|---|---|
GET | print (list/read) | /path/print | Returns array of objects |
PUT | add (create) | /path/add | Not update — this creates |
PATCH | set (update) | /path/set | Requires /*ID in URL |
DELETE | remove | /path/remove | Requires /*ID in URL |
POST | command (execute) | /path/command | For actions like reboot, flush |
Common Endpoints
// Health check (no auth needed — WebFig returns HTTP 200)
const alive = await fetch("http://HOST:PORT/").then(r => r.ok);
// System identity
const id = await fetch("http://HOST:PORT/rest/system/identity", auth);
// System resource (CPU, memory, uptime, version, architecture)
const res = await fetch("http://HOST:PORT/rest/system/resource", auth);
// All interfaces
const ifaces = await fetch("http://HOST:PORT/rest/interface", auth);
// IP addresses
const addrs = await fetch("http://HOST:PORT/rest/ip/address", auth);
// Firewall filter rules
const rules = await fetch("http://HOST:PORT/rest/ip/firewall/filter", auth);
// DNS cache
const dns = await fetch("http://HOST:PORT/rest/ip/dns/cache", auth);
// Files on the router
const files = await fetch("http://HOST:PORT/rest/file", auth);
// Installed packages
const pkgs = await fetch("http://HOST:PORT/rest/system/package", auth);Filtering and Query Parameters
// Filter by property value
await fetch(`${base}/interface?type=ether`, auth);
// Multiple filters (AND)
await fetch(`${base}/ip/address?interface=ether1&disabled=false`, auth);
// Proplist — select specific properties (reduces response size)
await fetch(`${base}/interface?.proplist=name,type,running`, auth);POST Commands (Actions)
Some RouterOS operations are actions, not CRUD:
// Reboot
await fetch(`${base}/system/reboot`, { method: "POST", ...auth });
// Check for updates
await fetch(`${base}/system/package/update/check-for-updates`, { method: "POST", ...auth });
// Flush DNS cache
await fetch(`${base}/ip/dns/cache/flush`, { method: "POST", ...auth });
// Run a script
await fetch(`${base}/system/script/run`, {
method: "POST",
...auth,
body: JSON.stringify({ ".id": "*1" }),
});Error Handling
// RouterOS returns structured errors
// { "error": 400, "message": "no such command prefix", "detail": "..." }
const response = await fetch(`${base}/ip/nonexistent`, auth);
if (!response.ok) {
const err = await response.json();
// err.message contains the RouterOS error
// err.detail may contain additional context
}Authentication Patterns
// Basic auth — empty password (fresh install)
const auth = {
headers: { Authorization: `Basic ${btoa("admin:")}` },
};
// With password
const auth = {
headers: { Authorization: `Basic ${btoa("admin:mypassword")}` },
};/console/inspect — Command Tree Introspection
RouterOS exposes its entire command tree via /console/inspect. This is how tools like restraml and rosetta build their command databases. For full details on tree traversal, node types, and schema generation, see the `routeros-command-tree` skill.
// List child paths under /ip
await fetch(`${base}/console/inspect`, {
method: "POST",
...auth,
body: JSON.stringify({
request: "child",
path: "ip",
}),
});
// Returns: [{type: "child", name: "address", "node-type": "path"}, ...]
// Get syntax description for an argument
await fetch(`${base}/console/inspect`, {
method: "POST",
...auth,
body: JSON.stringify({
request: "syntax",
path: "ip,address,add,address", // comma-separated, NOT dot or slash
}),
});
// Returns: [{type: "syntax", text: "IP address"}]Request types: child (enumerate children), syntax (help text), highlight (syntax coloring), completion (tab-completion)
Path format: Comma-separated segments — "ip,address,add" (not "ip.address.add" or "/ip/address/add").
Node types: dir (directory), path (navigable level), cmd (executable command), arg (parameter).
Dangerous paths to skip: where, do, else, rule, command, on-error — these crash the REST server when inspected.
CLI→REST mapping: get→GET, add→PUT (creates!), set→PATCH, remove→DELETE, others→POST.
Known Version Differences
- 7.21+:
/apppath exists (built-in app listing);/app/addwith YAML creation from 7.22 - 7.18+:
!emptysentence type in API protocol (indicates zero results, vs!donewhich may have data) - 7.20.8+: Minimum for reliable API protocol streaming
RouterOS Firewall REST API Reference
Reference for /ip/firewall/filter, /ip/firewall/nat, and /ip/firewall/mangle REST endpoints. Response shapes from docs — not lab-verified unless noted.
⚠️ RULE ORDERING — READ THIS FIRST
Rules are evaluated in order. First match wins (filter/NAT). PUT appends to the END.
This is the #1 agent mistake: adding a rule via PUT without place-before, causing it to land after a drop-all rule where it has zero effect.
# WRONG — rule lands at the end, after "drop all"
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/filter \
-H "content-type: application/json" \
-d '{"chain":"input","action":"accept","dst-port":"80","protocol":"tcp"}'
# RIGHT — insert before a specific rule (e.g., the drop-all rule *5)
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/filter \
-H "content-type: application/json" \
-d '{"chain":"input","action":"accept","dst-port":"80","protocol":"tcp","place-before":"*5"}'Workflow for safe rule insertion: 1. GET /rest/ip/firewall/filter?chain=input&.proplist=.id,action,comment — find the drop-all rule's .id 2. PUT /rest/ip/firewall/filter with "place-before":"*THAT_ID" — insert before it 3. GET /rest/ip/firewall/filter — verify ordering
If you skip `place-before`, your rule is useless. The default RouterOS config ends with action=drop chain=input — any rule added after it will never match.
---
HTTP Verb Mapping (Firewall-Specific)
| HTTP Verb | Action | CLI Equivalent | Notes |
|---|---|---|---|
GET | List rules (ordered) | print | Returns JSON array in evaluation order |
PUT | Add (create) rule | add | Appends to END — use place-before to control position |
PATCH | Modify existing rule | set | Requires /*ID in URL |
DELETE | Remove rule | remove | Requires /*ID in URL |
POST | Actions (reset-counters) | various | For commands, not CRUD |
See rest-api-patterns.md for general verb mapping details.
---
1. /ip/firewall/filter — Firewall Filter Rules
Three built-in chains (cannot be deleted):
- input — packets destined to the router itself
- forward — packets passing through the router
- output — packets originating from the router
GET — List Rules
# All filter rules (ordered array)
curl -u admin: http://HOST:PORT/rest/ip/firewall/filterResponse — JSON array, each element is a rule object:
[
{
".id": "*1",
"action": "accept",
"bytes": "50507925242",
"chain": "input",
"comment": "defconf: accept established,related",
"connection-state": "established,related",
"disabled": "false",
"dynamic": "false",
"invalid": "false",
"log": "false",
"log-prefix": "",
"packets": "50048246"
},
{
".id": "*5",
"action": "drop",
"chain": "input",
"comment": "defconf: drop all not coming from LAN",
"disabled": "false",
"in-interface-list": "!LAN"
}
]Key detail: Array order IS evaluation order. The .id values are *HEX format — stable across reboots but NOT sequential.
GET — Filter and Proplist
# Filter by chain
curl -u admin: 'http://HOST:PORT/rest/ip/firewall/filter?chain=input'
# Select specific fields only
curl -u admin: 'http://HOST:PORT/rest/ip/firewall/filter?.proplist=.id,chain,action,comment'
# Combine filter + proplist
curl -u admin: 'http://HOST:PORT/rest/ip/firewall/filter?chain=forward&.proplist=.id,action,comment,disabled'GET — Single Rule by ID
curl -u admin: http://HOST:PORT/rest/ip/firewall/filter/*1Returns a single JSON object (not array).
PUT — Add Rule
# Accept TCP port 80 on input chain (appends to END — see ordering warning above)
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/filter \
-H "content-type: application/json" \
-d '{"chain":"input","action":"accept","dst-port":"80","protocol":"tcp","comment":"allow HTTP"}'Response — the created rule object with .id:
{
".id": "*A",
"action": "accept",
"chain": "input",
"comment": "allow HTTP",
"disabled": "false",
"dst-port": "80",
"protocol": "tcp"
}place-before — Position Control
# Insert BEFORE rule *5 (e.g., before drop-all)
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/filter \
-H "content-type: application/json" \
-d '{"chain":"input","action":"accept","dst-port":"443","protocol":"tcp","place-before":"*5"}'place-before takes a .id value. The new rule is inserted immediately before the referenced rule. This property is NOT stored on the rule — it's a one-time placement instruction during creation.
PATCH — Modify Rule
# Disable rule *A
curl -u admin: -X PATCH http://HOST:PORT/rest/ip/firewall/filter/*A \
-H "content-type: application/json" \
-d '{"disabled":"true"}'
# Change action
curl -u admin: -X PATCH http://HOST:PORT/rest/ip/firewall/filter/*A \
-H "content-type: application/json" \
-d '{"action":"reject"}'DELETE — Remove Rule
curl -u admin: -X DELETE http://HOST:PORT/rest/ip/firewall/filter/*AReturns empty body on success (HTTP 204).
Filter Actions
| Action | Behavior |
|---|---|
accept | Accept packet, stop processing |
drop | Silently drop packet |
reject | Drop + send ICMP error (configurable via reject-with) |
jump | Jump to user-defined chain (set jump-target) |
return | Return from jump chain |
log | Log then continue to next rule (like passthrough) |
passthrough | Increment counter, continue (statistics) |
fasttrack-connection | Enable FastTrack for connection (IPv4 only) |
tarpit | Hold TCP connections (SYN/ACK reply, IPv4 only) |
add-dst-to-address-list | Add dst to address list |
add-src-to-address-list | Add src to address list |
Key Matcher Properties
| Property | Type | Description |
|---|---|---|
chain | string | input, forward, output, or user-defined |
action | string | See table above (default: accept) |
src-address | IP/mask or range | Source address match |
dst-address | IP/mask or range | Destination address match |
protocol | string | tcp, udp, icmp, etc. |
src-port | int range | Source port(s), requires protocol=tcp\ |
dst-port | int range | Destination port(s), requires protocol=tcp\ |
in-interface | string | Incoming interface name |
out-interface | string | Outgoing interface name |
in-interface-list | string | Interface list name |
out-interface-list | string | Interface list name |
connection-state | string | established, related, new, invalid (comma-separated) |
src-address-list | string | Match src against address list |
dst-address-list | string | Match dst against address list |
disabled | bool string | "true" or "false" |
comment | string | Descriptive comment |
log | bool string | Enable logging even if action is not log |
log-prefix | string | Prefix for log messages |
---
2. /ip/firewall/nat — NAT Rules
Two common built-in chains:
- srcnat — source NAT (postrouting) — modifies source address/port of outgoing packets
- dstnat — destination NAT (prerouting) — modifies destination address/port of incoming packets
GET — List NAT Rules
curl -u admin: http://HOST:PORT/rest/ip/firewall/natPUT — Add NAT Rule
# Masquerade — most common srcnat rule (dynamic source NAT)
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/nat \
-H "content-type: application/json" \
-d '{"chain":"srcnat","action":"masquerade","out-interface":"ether1","comment":"NAT outbound"}'
# Destination NAT — forward port 8080 to internal server
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/nat \
-H "content-type: application/json" \
-d '{"chain":"dstnat","action":"dst-nat","dst-port":"8080","protocol":"tcp","to-addresses":"192.168.88.100","to-ports":"80"}'
# Source NAT — static source mapping
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/nat \
-H "content-type: application/json" \
-d '{"chain":"srcnat","action":"src-nat","src-address":"192.168.88.0/24","to-addresses":"203.0.113.1"}'NAT-Specific Actions
| Action | Chain | Description |
|---|---|---|
masquerade | srcnat | Replace src IP with outgoing interface IP (dynamic) |
src-nat | srcnat | Replace src IP/port with explicit to-addresses/to-ports |
dst-nat | dstnat | Replace dst IP/port with to-addresses/to-ports |
redirect | dstnat | Redirect to router itself (change dst port via to-ports) |
netmap | either | Static 1:1 address mapping |
same | either | Consistent src/dst IP per client from a range (IPv4 only) |
endpoint-independent-nat | either | Endpoint-independent mapping (UDP only, IPv4 only) |
NAT-Specific Properties
| Property | Type | Description |
|---|---|---|
to-addresses | IP[-IP] | Replacement address or range. For dst-nat, src-nat, netmap, same |
to-ports | int[-int] | Replacement port or range. For dst-nat, redirect, masquerade, src-nat |
Ordering matters for NAT too. place-before works identically to filter rules.
Warning (from docs): Whenever NAT rules are changed or added, the connection tracking table should be cleared, otherwise NAT rules may seem to not function correctly until existing connection entries expire.
---
3. /ip/firewall/mangle — Packet Marking
Five built-in chains (matching packet flow stages):
- prerouting — as packets arrive on an interface
- input — before delivery to a local process
- forward — packets being routed through
- output — after produced by a local process
- postrouting — as packets leave an interface
GET / PUT / PATCH / DELETE
Same verb mapping as filter. All CRUD operations work identically.
# Mark connections from a specific source
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/mangle \
-H "content-type: application/json" \
-d '{"chain":"forward","action":"mark-connection","src-address":"192.168.88.100","connection-state":"new","new-connection-mark":"client1_conn"}'
# Mark packets belonging to that connection
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/mangle \
-H "content-type: application/json" \
-d '{"chain":"forward","action":"mark-packet","connection-mark":"client1_conn","new-packet-mark":"client1_pkt","passthrough":"true"}'
# Mark routing (for policy routing)
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/mangle \
-H "content-type: application/json" \
-d '{"chain":"prerouting","action":"mark-routing","src-address":"192.168.88.0/24","new-routing-mark":"via_isp2"}'Mangle-Specific Actions
| Action | Description |
|---|---|
mark-connection | Mark entire connection (set new-connection-mark) |
mark-packet | Mark individual packet (set new-packet-mark) |
mark-routing | Mark for policy routing (set new-routing-mark) |
change-mss | Change TCP MSS value (set new-mss) |
change-dscp | Change DSCP field (set new-dscp) |
change-ttl | Change TTL (set new-ttl) |
clear-df | Clear "Don't Fragment" flag |
set-priority | Set packet priority (set new-priority) |
route | Force gateway (prerouting only, set route-dst) |
sniff-tzsp | Send copy to TZSP receiver (Wireshark) |
passthrough | Count and continue (statistics) |
fasttrack-connection | FastTrack counter display |
Mangle-Specific Properties
| Property | Type | Default | Description |
|---|---|---|---|
new-connection-mark | string | Connection mark name | |
new-packet-mark | string | Packet mark name | |
new-routing-mark | string | Routing mark (must exist as routing table in v7) | |
new-mss | integer | New MSS value | |
new-dscp | 0..63 | New DSCP value | |
new-ttl | string | set:N, increment:N, decrement:N | |
passthrough | yes\ | no | yes |
Mangle ordering note: With passthrough=yes (default), ALL matching mangle rules fire — unlike filter where first match wins. But passthrough=no stops processing. When using mark-connection + mark-packet pairs, order still matters: the connection mark must be applied before the packet mark rule references it.
Warning (from docs): Packet marks are limited to a maximum of 4096 unique entries. Exceeding this limit causes error "bad new packet mark".
---
.id References
All firewall entries use *HEX format IDs (e.g., *1, *A, *1F).
# Find rule ID by filtering
curl -u admin: 'http://HOST:PORT/rest/ip/firewall/filter?chain=input&action=drop&.proplist=.id,comment'
# → [{ ".id": "*5", "comment": "defconf: drop all" }]
# Use ID in PATCH
curl -u admin: -X PATCH http://HOST:PORT/rest/ip/firewall/filter/*5 \
-H "content-type: application/json" \
-d '{"disabled":"true"}'
# Use ID in DELETE
curl -u admin: -X DELETE http://HOST:PORT/rest/ip/firewall/filter/*5IDs are stable across reboots. They are assigned incrementally but gaps appear when rules are deleted. Never hardcode IDs — always query first.
---
Common Firewall Patterns via REST
Pattern 1: Minimal Input Protection
Build the standard "protect the router" ruleset in correct order:
BASE="http://HOST:PORT/rest/ip/firewall/filter"
AUTH="-u admin:"
CT="content-type: application/json"
# 1. Accept established/related (first rule)
curl $AUTH -X PUT $BASE -H "$CT" \
-d '{"chain":"input","action":"accept","connection-state":"established,related","comment":"accept established,related"}'
# Returns .id, e.g. *1
# 2. Drop invalid connections
curl $AUTH -X PUT $BASE -H "$CT" \
-d '{"chain":"input","action":"drop","connection-state":"invalid","comment":"drop invalid"}'
# 3. Accept ICMP
curl $AUTH -X PUT $BASE -H "$CT" \
-d '{"chain":"input","action":"accept","protocol":"icmp","comment":"accept ICMP"}'
# 4. Accept from LAN
curl $AUTH -X PUT $BASE -H "$CT" \
-d '{"chain":"input","action":"accept","src-address":"192.168.88.0/24","comment":"accept LAN"}'
# 5. Drop everything else (LAST rule)
curl $AUTH -X PUT $BASE -H "$CT" \
-d '{"chain":"input","action":"drop","comment":"drop all other input"}'⚠️ This only works on a clean router with no existing rules. If rules already exist, you MUST use place-before for rules 1-4 to ensure they precede any existing drop-all rule.
Pattern 2: Adding a Rule to an Existing Firewall
# Step 1: Find the drop-all rule
curl -u admin: 'http://HOST:PORT/rest/ip/firewall/filter?chain=input&action=drop&.proplist=.id,comment'
# → [{"".id":"*5","comment":"drop all other input"}]
# Step 2: Insert new rule BEFORE the drop-all
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/filter \
-H "content-type: application/json" \
-d '{"chain":"input","action":"accept","dst-port":"8291","protocol":"tcp","comment":"allow WinBox","place-before":"*5"}'Pattern 3: Masquerade for NAT
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/nat \
-H "content-type: application/json" \
-d '{"chain":"srcnat","action":"masquerade","out-interface":"ether1","comment":"masquerade outbound"}'Pattern 4: Port Forwarding (dst-nat)
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/nat \
-H "content-type: application/json" \
-d '{"chain":"dstnat","action":"dst-nat","dst-port":"8080","protocol":"tcp","to-addresses":"192.168.88.100","to-ports":"80","comment":"forward 8080 to web server"}'Pattern 5: Connection + Packet Marking for QoS
# Mark connections from specific host
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/mangle \
-H "content-type: application/json" \
-d '{"chain":"forward","action":"mark-connection","src-address":"192.168.88.100","connection-state":"new","new-connection-mark":"client1_conn","comment":"mark client1 connections"}'
# Mark packets in those connections
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/mangle \
-H "content-type: application/json" \
-d '{"chain":"forward","action":"mark-packet","connection-mark":"client1_conn","new-packet-mark":"client1_pkt","passthrough":"true","comment":"mark client1 packets"}'---
Gotchas
1. PUT Appends — Use place-before
(See top of this file. Cannot be overstated.)
2. No move via REST
RouterOS CLI has move to reorder rules. There is no REST equivalent. To reorder, you must delete and re-add with place-before. This makes rule ordering fragile — plan your insertion order carefully.
3. Boolean Values Are Strings
All boolean fields are string "true" / "false", not JSON booleans. Send and compare as strings.
4. protocol Required for Port Matchers
dst-port and src-port require protocol to be set (tcp or udp). Omitting protocol when setting ports produces an error.
5. Connection State Is Comma-Separated
connection-state accepts comma-separated values: "established,related" — not an array.
6. Dynamic Rules
Default config rules and FastTrack rules appear as "dynamic":"true". These cannot be deleted or modified via REST. Filter them out with ?dynamic=false when listing user-created rules.
7. reject-with Only for action=reject
Values: icmp-no-route (default), icmp-admin-prohibited, icmp-port-unreachable, tcp-reset, etc.
8. Address Lists Are Separate
/ip/firewall/address-list is a separate endpoint for managing address lists referenced by src-address-list / dst-address-list matchers:
# Add address to list
curl -u admin: -X PUT http://HOST:PORT/rest/ip/firewall/address-list \
-H "content-type: application/json" \
-d '{"list":"blocked","address":"10.0.0.100","comment":"blocked host"}'
# List entries
curl -u admin: http://HOST:PORT/rest/ip/firewall/address-list---
Related Endpoints
| Path | Purpose |
|---|---|
/ip/firewall/filter | Firewall filter rules |
/ip/firewall/nat | NAT rules |
/ip/firewall/mangle | Packet marking |
/ip/firewall/raw | Pre-connection-tracking filtering |
/ip/firewall/address-list | Address lists |
/ip/firewall/connection | Active connection tracking table (read-only) |
/ip/firewall/service-port | NAT helpers (FTP, SIP, etc.) |
---
Source:
- Rosetta: pages 47579162 (REST API), 48660574 (Filter), 3211299 (NAT), 48660587 (Mangle), 250708064 (Common Firewall Matchers and Actions), 328513 (Building Advanced Firewall); property lookups forchain,action,place-before(not in property DB — documented in REST API page PUT section and CLI behavior)
- Reference: rest-api-patterns.md — verb mapping (PUT=create, PATCH=set, GET=print)- Note: Response shapes derived from docs and REST API page examples, not lab-verified. Rule ordering behavior is well-documented in MikroTik docs and confirmed by default config structure.
- Key gotcha source: Common agent mistake pattern observed across tikoci projects — agents PUT rules without place-before, placing them after drop-all rules where they have no effect.RouterOS Networking REST API Reference
Response shapes derived from official docs (page IDs below). Not lab-verified on CHR unless stated.
REST Verb Mapping (Quick Reminder)
| HTTP Verb | RouterOS Action | Notes |
|---|---|---|
GET | print (list) | Returns JSON array |
PUT | add (create) | NOT update — creates a new entry |
PATCH | set (update) | Requires /*ID in URL path |
DELETE | remove | Requires /*ID in URL path |
POST | command | Actions like flush, release, renew |
See rest-api-patterns.md for full details, filtering, proplist, and auth patterns.
---
1. /ip/address — IP Address Management
Sub-menu: /ip/address Docs: IP Addressing (page 328247)
Properties
| Property | Type | Default | RW | Description |
|---|---|---|---|---|
address | IP/netmask (e.g. 192.168.1.1/24) | RW | IPv4 address with CIDR netmask | |
interface | string | RW | Interface to assign the address to | |
network | IP | auto-calculated | RW | Network address — auto-derived from address if omitted |
comment | string | "" | RW | Description |
disabled | "true" / "false" | "false" | RW | Whether address is disabled |
actual-interface | string | RO | Resolved interface (e.g. bridge if port was bridged) | |
dynamic | "true" / "false" | RO | Whether address was dynamically created (DHCP, etc.) | |
invalid | "true" / "false" | RO | Whether address is invalid | |
.id | string (e.g. *1) | RO | Internal ID for PATCH/DELETE |
List addresses
curl -s -u admin: http://127.0.0.1:9100/rest/ip/addressExpected response:
[
{
".id": "*1",
"address": "192.168.88.1/24",
"network": "192.168.88.0",
"interface": "ether1",
"actual-interface": "ether1",
"invalid": "false",
"dynamic": "false",
"disabled": "false"
}
]Add an address (PUT = create)
curl -s -u admin: http://127.0.0.1:9100/rest/ip/address \
-X PUT -H "Content-Type: application/json" \
-d '{"address":"10.0.0.1/24","interface":"ether2"}'Response (returns the new .id):
{"ret":"*2"}network is auto-calculated from address if not explicitly provided.
Modify an address (PATCH)
curl -s -u admin: http://127.0.0.1:9100/rest/ip/address/*2 \
-X PATCH -H "Content-Type: application/json" \
-d '{"address":"10.0.0.2/24"}'Response: [] (empty array = success)
Remove an address (DELETE)
curl -s -u admin: http://127.0.0.1:9100/rest/ip/address/*2 \
-X DELETEResponse: []
Filtering
# Only addresses on ether1
curl -s -u admin: 'http://127.0.0.1:9100/rest/ip/address?interface=ether1'
# Exclude dynamic addresses
curl -s -u admin: 'http://127.0.0.1:9100/rest/ip/address?dynamic=false'
# Select specific fields
curl -s -u admin: 'http://127.0.0.1:9100/rest/ip/address?.proplist=address,interface'Gotchas
actual-interfacediffers frominterfacewhen the interface is a bridge port — the address moves to the bridge.- Dynamic addresses (from DHCP client) appear with
dynamic=trueand cannot be edited via/ip/addressPATCH. - All boolean values are strings (
"true","false"), not JSON booleans.
---
2. /ip/route — Static Routes
Sub-menu: /ip/route Docs: IP Routing (page 328084)
Key Properties
| Property | Type | Default | Description |
|---|---|---|---|
dst-address | IP/netmask | Destination network (e.g. 0.0.0.0/0 for default) | |
gateway | IP or interface name | Next-hop address or interface | |
distance | integer 0–255 | 1 | Administrative distance — lower wins |
routing-table | string | "main" | Routing table to install the route in |
disabled | "true" / "false" | "false" | |
scope | integer | 30 | Used for recursive nexthop resolution |
target-scope | integer | 10 | Target scope for nexthop lookup |
comment | string | "" | |
.id | string | Internal ID |
Read-only fields in GET: dynamic, active, connect, static, immediate-gw, etc.
List routes
curl -s -u admin: http://127.0.0.1:9100/rest/ip/routeExpected response (mix of static and dynamic):
[
{
".id": "*4",
"dst-address": "0.0.0.0/0",
"gateway": "10.155.101.1",
"distance": "1",
"scope": "30",
"target-scope": "10",
"active": "true",
"static": "true",
"dynamic": "false",
"disabled": "false"
},
{
".id": "*5",
"dst-address": "10.155.101.0/24",
"gateway": "ether12",
"distance": "0",
"active": "true",
"connect": "true",
"dynamic": "true"
}
]Add a static route
curl -s -u admin: http://127.0.0.1:9100/rest/ip/route \
-X PUT -H "Content-Type: application/json" \
-d '{"dst-address":"10.10.0.0/16","gateway":"192.168.1.1","distance":"10"}'Add a default route
curl -s -u admin: http://127.0.0.1:9100/rest/ip/route \
-X PUT -H "Content-Type: application/json" \
-d '{"dst-address":"0.0.0.0/0","gateway":"10.0.0.1"}'Modify / Delete
# Change gateway
curl -s -u admin: http://127.0.0.1:9100/rest/ip/route/*4 \
-X PATCH -H "Content-Type: application/json" \
-d '{"gateway":"10.0.0.254"}'
# Remove
curl -s -u admin: http://127.0.0.1:9100/rest/ip/route/*4 -X DELETERoute Types
| Type | dynamic | connect / static | How created |
|---|---|---|---|
| Connected | "true" | connect=true | Auto — from IP address on interface |
| Static | "false" | static=true | Manual — via /ip/route add |
| Dynamic | "true" | varies | From DHCP, OSPF, BGP, etc. |
Cannot PATCH/DELETE dynamic or connected routes — they are managed by their source protocol.
Gotcha: /routing/route vs /ip/route
/routing/route is read-only and shows all routes (IPv4+IPv6) with extended fields. Use /ip/route for CRUD on IPv4 static routes.
---
3. /ip/dhcp-client — DHCP Client
Sub-menu: /ip/dhcp-client Docs: DHCP (page 24805500, section "DHCP Client")
Properties
| Property | Type | Default | Description |
|---|---|---|---|
interface | string | Interface to run DHCP client on | |
add-default-route | yes / no / special-classless | yes | Install default route from DHCP server |
use-peer-dns | yes / no | yes | Accept DNS servers from DHCP |
use-peer-ntp | yes / no | yes | Accept NTP servers from DHCP |
disabled | yes / no | yes | Note: default is disabled! |
default-route-distance | integer 0–255 | Distance for auto-created default route | |
comment | string |
Read-only: address, gateway, status, dhcp-server, primary-dns, secondary-dns, expires-after
Add DHCP client on an interface
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dhcp-client \
-X PUT -H "Content-Type: application/json" \
-d '{"interface":"ether1","disabled":"no"}'Response: {"ret":"*1"}
Important: disabled defaults to "yes" — you must explicitly set "disabled":"no" or the client won't start.
Check DHCP client status
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dhcp-client[
{
".id": "*1",
"interface": "ether1",
"add-default-route": "yes",
"use-peer-dns": "yes",
"use-peer-ntp": "yes",
"status": "bound",
"address": "10.155.101.50/24",
"gateway": "10.155.101.1",
"dhcp-server": "10.155.101.1",
"primary-dns": "10.155.0.1",
"expires-after": "9m30s",
"disabled": "false",
"dynamic": "false"
}
]Release / Renew (POST commands)
# Release lease
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dhcp-client/release \
-X POST -H "Content-Type: application/json" \
-d '{"numbers":"*1"}'
# Renew lease
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dhcp-client/renew \
-X POST -H "Content-Type: application/json" \
-d '{"numbers":"*1"}'Gotchas
add-default-route=special-classlessadds both classless routes (option 121) AND option 3 default route (MS-style behavior).- The
statusfield values:bound,searching...,requesting...,rebinding...,error,stopped. - On a fresh CHR with QEMU user-mode networking,
ether1typically has a DHCP client auto-created as a dynamic entry.
---
4. /ip/dhcp-server — DHCP Server Setup
Setting up a DHCP server requires three components: an address pool, a network definition, and the server itself.
Step 1: Create an address pool (/ip/pool)
Sub-menu: /ip/pool Docs: IP Pools (page 129531938)
curl -s -u admin: http://127.0.0.1:9100/rest/ip/pool \
-X PUT -H "Content-Type: application/json" \
-d '{"name":"dhcp-pool","ranges":"192.168.1.100-192.168.1.200"}'Pool properties:
| Property | Type | Description |
|---|---|---|
name | string | Pool name (referenced by DHCP server) |
ranges | string | IP ranges: from1-to1,from2-to2 |
next-pool | string | Overflow pool when this one is full |
Step 2: Create DHCP server network (/ip/dhcp-server/network)
Sub-menu: /ip/dhcp-server/network
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dhcp-server/network \
-X PUT -H "Content-Type: application/json" \
-d '{"address":"192.168.1.0/24","gateway":"192.168.1.1","dns-server":"8.8.8.8,8.8.4.4"}'Network properties:
| Property | Type | Default | Description |
|---|---|---|---|
address | IP/netmask | Network the server serves (e.g. 192.168.1.0/24) | |
gateway | IP | 0.0.0.0 | Default gateway for clients |
dns-server | string | DNS servers (comma-separated). Falls back to router's /ip/dns if unset | |
domain | string | DNS domain for clients | |
ntp-server | IP | NTP server for clients | |
netmask | integer 0–32 | 0 | Override netmask (0 = use network prefix) |
Step 3: Create the DHCP server (/ip/dhcp-server)
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dhcp-server \
-X PUT -H "Content-Type: application/json" \
-d '{"name":"dhcp1","interface":"ether2","address-pool":"dhcp-pool","lease-time":"1h","disabled":"no"}'Server properties:
| Property | Type | Default | Description |
|---|---|---|---|
name | string | Server name | |
interface | string | Interface to serve DHCP on | |
address-pool | string / static-only | static-only | IP pool name. static-only = only static leases |
lease-time | time | 30m | Lease duration (e.g. 1h, 1d) |
disabled | yes / no | ||
authoritative | yes / no / after-2sec-delay / after-10sec-delay | yes | How to handle unknown clients |
Complete DHCP server setup (all 3 steps)
# Prerequisite: IP address must exist on the interface
curl -s -u admin: http://127.0.0.1:9100/rest/ip/address \
-X PUT -H "Content-Type: application/json" \
-d '{"address":"192.168.1.1/24","interface":"ether2"}'
# 1. Pool
curl -s -u admin: http://127.0.0.1:9100/rest/ip/pool \
-X PUT -H "Content-Type: application/json" \
-d '{"name":"dhcp-pool","ranges":"192.168.1.100-192.168.1.200"}'
# 2. Network
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dhcp-server/network \
-X PUT -H "Content-Type: application/json" \
-d '{"address":"192.168.1.0/24","gateway":"192.168.1.1","dns-server":"8.8.8.8"}'
# 3. Server
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dhcp-server \
-X PUT -H "Content-Type: application/json" \
-d '{"name":"dhcp1","interface":"ether2","address-pool":"dhcp-pool","lease-time":"1h","disabled":"no"}'View leases
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dhcp-server/leaseGotchas
- `address-pool` defaults to `static-only` — if you don't specify a pool, no dynamic leases are handed out.
- The interface must have an IP address in the same subnet as the pool/network.
- The DHCP server requires a real interface to receive raw ethernet packets. A bridge with no ports won't work.
---
5. /ip/dns — DNS Configuration
Sub-menu: /ip/dns Docs: DNS (page 37748767)
/ip/dns is a singleton — it uses GET/PATCH (not array-based CRUD).
Properties
| Property | Type | Default | Description |
|---|---|---|---|
servers | list of IPs | "" | Static DNS server addresses |
allow-remote-requests | yes / no | no | Act as DNS cache for clients |
cache-size | integer (KiB) | 2048 | DNS cache size |
cache-max-ttl | time | 1w | Maximum cache TTL |
max-concurrent-queries | integer | 100 | |
use-doh-server | string | "" | DoH server URL (overrides servers) |
dynamic-servers | list of IPs | RO — DNS servers from DHCP, etc. | |
cache-used | integer (KiB) | RO — current cache usage |
Get DNS settings
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dns{
"allow-remote-requests": "false",
"cache-max-ttl": "1w",
"cache-size": "2048",
"cache-used": "48",
"dynamic-servers": "10.155.0.1",
"max-concurrent-queries": "100",
"max-concurrent-tcp-sessions": "20",
"max-udp-packet-size": "4096",
"query-server-timeout": "2s",
"query-total-timeout": "10s",
"servers": "",
"use-doh-server": "",
"verify-doh-cert": "false"
}Note: /ip/dns returns a single object (not an array) — it's a singleton config, not an item list.
Set DNS servers
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dns/set \
-X POST -H "Content-Type: application/json" \
-d '{"servers":"8.8.8.8,1.1.1.1","allow-remote-requests":"yes"}'Response: [] (empty array = success)
DNS Cache
# List cached entries
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dns/cache
# List ALL cached entries (including PTR)
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dns/cache/all
# Flush cache
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dns/cache/flush \
-X POST -H "Content-Type: application/json" -d '{}'DNS Static entries
# Add a static DNS record
curl -s -u admin: http://127.0.0.1:9100/rest/ip/dns/static \
-X PUT -H "Content-Type: application/json" \
-d '{"name":"myhost.local","address":"192.168.1.50"}'Gotchas
serversis for static DNS servers.dynamic-servers(read-only) shows servers acquired from DHCP etc.- When
allow-remote-requests=yes, the router acts as a DNS proxy — add firewall rules to restrict port 53 access. setis done via POST to/ip/dns/set, NOT via PATCH (because it's a singleton, not a list item).- DoH (
use-doh-server) overrides allserversentries when active.
---
6. /interface — Interface Listing
Sub-menu: /interface Command tree: /interface (print, set, enable, disable, etc.)
List all interfaces
curl -s -u admin: http://127.0.0.1:9100/rest/interface[
{
".id": "*1",
"name": "ether1",
"type": "ether",
"mtu": "1500",
"actual-mtu": "1500",
"mac-address": "52:54:00:12:34:56",
"running": "true",
"disabled": "false",
"dynamic": "false"
},
{
".id": "*2",
"name": "ether2",
"type": "ether",
"mtu": "1500",
"actual-mtu": "1500",
"mac-address": "52:54:00:12:34:57",
"running": "false",
"disabled": "false",
"dynamic": "false"
}
]Key properties
| Property | Type | Description |
|---|---|---|
name | string | Interface name (e.g. ether1, bridge1, vlan100) |
type | string | Interface type: ether, bridge, vlan, veth, wireguard, etc. |
running | "true" / "false" | Whether interface has link |
disabled | "true" / "false" | Administratively disabled |
mac-address | string | MAC address |
mtu | string | Configured MTU |
actual-mtu | string | Effective MTU |
dynamic | "true" / "false" | Dynamically created (e.g. PPP sessions) |
Filter by type
# Only ethernet interfaces
curl -s -u admin: 'http://127.0.0.1:9100/rest/interface?type=ether'
# Only running interfaces
curl -s -u admin: 'http://127.0.0.1:9100/rest/interface?running=true'
# Specific fields only
curl -s -u admin: 'http://127.0.0.1:9100/rest/interface?.proplist=name,type,running'Enable / Disable an interface
# Disable ether2
curl -s -u admin: http://127.0.0.1:9100/rest/interface/*2 \
-X PATCH -H "Content-Type: application/json" \
-d '{"disabled":"true"}'
# Or use the command form
curl -s -u admin: http://127.0.0.1:9100/rest/interface/disable \
-X POST -H "Content-Type: application/json" \
-d '{"numbers":"*2"}'Rename an interface
curl -s -u admin: http://127.0.0.1:9100/rest/interface/*1 \
-X PATCH -H "Content-Type: application/json" \
-d '{"name":"wan"}'Gotchas
/interfaceis a unified view — it shows all interface types. Use type-specific sub-menus (/interface/ethernet,/interface/bridge,/interface/vlan) for type-specific properties.runningis a link-state indicator, not admin state. An enabled but disconnected interface hasrunning=false,disabled=false.- On a fresh CHR, you typically get
ether1throughetherNmatching the number of QEMU NICs configured. monitor-trafficis an async command (POST) — seerest-api-patterns.mdfor async handling.
---
Common Patterns for Agents
Verify interface has IP before configuring services
# Check that ether2 has an address
curl -s -u admin: 'http://127.0.0.1:9100/rest/ip/address?interface=ether2'
# If empty array → add one firstQuick network setup sequence
1. GET /rest/interface → find available interfaces
2. PUT /rest/ip/address {address, interface} → assign IP
3. PUT /rest/ip/route {dst-address, gateway} → add default route (if no DHCP)
4. POST /rest/ip/dns/set {servers} → configure DNSPost-boot REST race applies here too
All networking endpoints can return stale/wrong data briefly after boot. Use the polling pattern from rest-api-patterns.md — check for expected keys before trusting the response.
---
Source:
- Rosetta page IDs: 328247 (IP Addressing), 328084 (IP Routing), 24805500 (DHCP), 37748767 (DNS), 129531938 (IP Pools)
- Rosetta property lookups:actual-interface,add-default-route,use-peer-dns,use-peer-ntp,address-pool,allow-remote-requests,servers
- Rosetta command trees:/ip/address,/ip/route,/ip/dhcp-client,/ip/dhcp-server,/ip/dhcp-server/network,/ip/dns,/ip/dns/cache,/ip/pool,/interface
- Reference: rest-api-patterns.md — verb mapping, filtering, auth- Note: Response shapes are from docs, not lab-verified on CHR
RouterOS /user REST API Reference
Reference for /user, /user/group, and /user/ssh-keys REST endpoints. Response shapes from docs and quickchr provision.ts patterns. Curl examples assume CHR on 127.0.0.1:9100 with default admin: credentials.
(from docs, not lab-verified) unless otherwise noted.
User Object Shape
GET /rest/user returns a JSON array. Each element:
| Field | Type | Description |
|---|---|---|
.id | string | RouterOS internal ID (e.g. *1) |
name | string | Username (alphanumeric, may include _ . # - @; * prohibited) |
group | string | Group name the user belongs to (full, read, write, or custom) |
address | string | Allowed login address (IP/mask or IPv6 prefix, default "" = any) |
disabled | string | "true" or "false" |
last-logged-in | string | Timestamp or "" |
comment | string | User comment |
Note: password is write-only — it never appears in GET responses.
Endpoints
GET /rest/user — List All Users
curl -s -u admin: http://127.0.0.1:9100/rest/user[
{
".id": "*1",
"name": "admin",
"group": "full",
"address": "0.0.0.0/0",
"disabled": "false",
"last-logged-in": "jan/15/2025 10:30:00",
"comment": "system default user"
}
]GET /rest/user/*ID — Single User
curl -s -u admin: http://127.0.0.1:9100/rest/user/*1Returns a single JSON object (not array).
PUT /rest/user — Create User
curl -s -u admin: -X PUT http://127.0.0.1:9100/rest/user \
--data '{"name":"quickchr","password":"s3cret","group":"full"}' \
-H "content-type: application/json"Success returns the created object with all its properties (HTTP 201):
{
".id": "*2",
"name": "quickchr",
"group": "full",
"address": "",
"disabled": "false",
"last-logged-in": "",
"comment": ""
}Writable properties on create:
| Property | Type | Required | Description |
|---|---|---|---|
name | string | yes | Must start/end with alphanumeric. * prohibited. |
password | string | no | Defaults to empty string (no password) |
group | string | no | Defaults vary — always set explicitly. Use full, read, or write. |
address | string | no | Restrict login source (e.g. 192.168.0.0/24) |
comment | string | no | Free-form comment |
Error — duplicate name:
{"detail":"failure: user with the same name already exists","error":400,"message":"Bad Request"}POST /rest/user/add — Alternative Create
Equivalent to PUT. quickchr uses this form in provision.ts:
curl -s -u admin: -X POST http://127.0.0.1:9100/rest/user/add \
--data '{"name":"quickchr","password":"s3cret","group":"full"}' \
-H "content-type: application/json"Returns {"ret":"*2"} (the .id of the created user) on success. This differs from PUT which returns the full object.
PATCH /rest/user/*ID — Update User
curl -s -u admin: -X PATCH http://127.0.0.1:9100/rest/user/*1 \
--data '{"comment":"managed by quickchr"}' \
-H "content-type: application/json"Returns the updated user object on success.
Disable a User
curl -s -u admin: -X PATCH http://127.0.0.1:9100/rest/user/*1 \
--data '{"disabled":"yes"}' \
-H "content-type: application/json"Gotcha — self-disable silently no-ops: RouterOS silently ignores a user disabling itself via REST PATCH (returns HTTP 200 but the disabled field stays "false"). Use a different user with full group to disable admin. This is implemented in quickchr's disableAdmin() which passes verifyAuth from the newly-created user.
DELETE /rest/user/*ID — Remove User
curl -s -u admin: -X DELETE http://127.0.0.1:9100/rest/user/*2Empty response on success. Returns {"error":404,"message":"Not Found"} if already deleted.
Constraint: The last user with full access rights cannot be removed.
POST /rest/user/disable — Disable by Number/Name
curl -s -u admin: -X POST http://127.0.0.1:9100/rest/user/disable \
--data '{"numbers":"*1"}' \
-H "content-type: application/json"POST /rest/user/enable — Re-enable
curl -s -u admin: -X POST http://127.0.0.1:9100/rest/user/enable \
--data '{"numbers":"*1"}' \
-H "content-type: application/json"Password Change
Change Own Password — POST /rest/password
Changes the password of the currently authenticated user:
curl -s -u admin: -X POST http://127.0.0.1:9100/rest/password \
--data '{"old-password":"","new-password":"N3w","confirm-new-password":"N3w"}' \
-H "content-type: application/json"Returns [] on success. (from docs)
Note: This is /rest/password — NOT /rest/user/password. It changes the calling user's own password.
Set Another User's Password — PATCH
To set a password on another user (requires policy permission in the caller's group):
curl -s -u admin: -X PATCH http://127.0.0.1:9100/rest/user/*2 \
--data '{"password":"newpass"}' \
-H "content-type: application/json"POST /rest/user/expire-password
Forces the user to change password on next CLI/Winbox/SSH login:
curl -s -u admin: -X POST http://127.0.0.1:9100/rest/user/expire-password \
--data '{"numbers":"*2"}' \
-H "content-type: application/json"Admin Account Behavior
Default State (Fresh CHR)
- Username:
admin, password: empty string - Group:
full expired: trueflag is set
The expired Flag — REST Is Unaffected
Critical for automation: The expired: true flag on the admin account only triggers a password-change prompt at CLI, Winbox, and SSH login (bypassable with Ctrl-C). REST API and API sockets are completely unaffected. Authenticated requests with admin:"" succeed on a fresh CHR regardless of the expired flag.
Do NOT add workarounds for expired: true on REST paths. If early REST responses return unexpected data, the root cause is a startup timing race — not the expired flag.
quickchr Pattern
1. Boot CHR, wait for REST readiness (waitForBoot) 2. Create managed user quickchr with generated password via POST /rest/user/add (as admin) 3. Install SSH key for the managed user (see below) 4. Disable admin via PATCH /rest/user/*ID {"disabled":"yes"} — using the new user's credentials 5. Store credentials in secret store
User Groups — /rest/user/group
GET /rest/user/group — List Groups
curl -s -u admin: http://127.0.0.1:9100/rest/user/groupDefault Groups (cannot be deleted)
| Name | Key Policies |
|---|---|
read | local, telnet, ssh, reboot, read, test, winbox, password, web, sniff, sensitive, api, romon, rest-api. No ftp, write, policy |
write | Same as read + write. No ftp, policy |
full | All policies including ftp, write, policy |
Warning: Even the read group includes sensitive, reboot, sniff, and api. Do not assign it to untrusted users. Create a custom group with minimal policies instead.
Group Policy List
Policies are comma-separated in the policy field:
Login: local, telnet, ssh, ftp, web, winbox, password, api, rest-api, romon Config: reboot, read, write, policy, test, sensitive, sniff
The rest-api policy specifically controls REST API access. A user without rest-api in their group cannot use /rest/ endpoints.
PUT /rest/user/group — Create Custom Group
curl -s -u admin: -X PUT http://127.0.0.1:9100/rest/user/group \
--data '{"name":"automation","policy":"local,ssh,read,write,api,rest-api,test,password"}' \
-H "content-type: application/json"(from docs, not lab-verified)
SSH Keys — /rest/user/ssh-keys
SSH key management for public key authentication. Critical for quickchr's exec --via=ssh transport.
GET /rest/user/ssh-keys — List Public Keys
curl -s -u admin: http://127.0.0.1:9100/rest/user/ssh-keysReturns array of key objects:
[
{
".id": "*1",
"user": "quickchr",
"bits": "256",
"key-type": "ed25519",
"fingerprint": "SHA256:xxxx...",
"info": "quickchr@mymachine"
}
]Read-only properties: user, bits, key-type, fingerprint, info.
Adding SSH Keys — Two Methods
Method 1: POST /rest/user/ssh-keys/add (paste key string)
curl -s -u admin: -X POST http://127.0.0.1:9100/rest/user/ssh-keys/add \
--data '{"user":"quickchr","key":"ssh-ed25519 AAAA...base64... quickchr@mymachine"}' \
-H "content-type: application/json"Only OpenSSH format keys accepted via add. Parameters:
| Property | Required | Description |
|---|---|---|
user | yes | RouterOS user to associate the key with |
key | yes | Full public key string in OpenSSH format |
This is the method quickchr uses in installSshKey() via the serial console (/user/ssh-keys/add), then verifies via GET /rest/user/ssh-keys.
Method 2: POST /rest/user/ssh-keys/import (from file on router)
curl -s -u admin: -X POST http://127.0.0.1:9100/rest/user/ssh-keys/import \
--data '{"user":"quickchr","public-key-file":"id_ed25519.pub"}' \
-H "content-type: application/json"Requires the public key file to already exist on the router's filesystem (uploaded via SCP/FTP). Accepts PEM, PKCS#8, or OpenSSH formats.
| Property | Required | Description |
|---|---|---|
user | yes | RouterOS user to associate the key with |
public-key-file | yes | Filename in router's root directory |
key-owner | no | Optional owner label |
DELETE /rest/user/ssh-keys/*ID — Remove Key
curl -s -u admin: -X DELETE http://127.0.0.1:9100/rest/user/ssh-keys/*1Supported Key Types
- RSA — PEM, PKCS#8, or OpenSSH format
- Ed25519 — PEM, PKCS#8, or OpenSSH format
- Ed25519-sk — OpenSSH format (FIDO/security key)
SSH Key Behavior Warnings
1. Password auth disabled by default when key exists: Once an SSH key is added for a user, password-based SSH login is disabled for that user. Controlled by /ip/ssh property password-authentication (default: yes-if-no-key).
2. Keys are not exportable: /export does not include SSH keys or user passwords. They must be re-provisioned on restore.
3. Only `full` group can change key ownership: Changing the user attribute under /user/ssh-keys/private requires full rights.
quickchr SSH Key Provisioning Pattern
From provision.ts → installSshKey():
1. Generate ed25519 keypair with ssh-keygen
- Store in <machineDir>/ssh/id_ed25519 (private) and .pub (public)
2. Install public key via serial console:
/user/ssh-keys/add user="quickchr" key="ssh-ed25519 AAAA..."
(serial is preferred over REST — commits synchronously)
3. Verify via REST:
GET /rest/user/ssh-keys — poll until key appears for the user
4. SSH transport now works without passwordsWhy serial console for install: The REST add endpoint may return HTTP 200 before the key is durable in RouterOS storage. The serial console command commits synchronously, making the subsequent REST verification reliable.
Active Users — /rest/user/active
GET /rest/user/active — List Active Sessions
curl -s -u admin: http://127.0.0.1:9100/rest/user/activeAll properties are read-only:
| Field | Type | Description |
|---|---|---|
.id | string | Session ID |
name | string | Username |
address | string | Client IP/IPv6/MAC |
group | string | User's group |
via | string | Access method: telnet, ssh, winbox, api, rest-api, web, ftp |
when | string | Login timestamp |
radius | string | "true" if RADIUS-authenticated |
POST /rest/user/active/request-logout — Kill Session
curl -s -u admin: -X POST http://127.0.0.1:9100/rest/user/active/request-logout \
--data '{"numbers":"*1A"}' \
-H "content-type: application/json"(from docs, not lab-verified)
User Settings — /rest/user/settings
Password complexity requirements:
| Property | Type | Default | Description |
|---|---|---|---|
minimum-password-length | integer | (unset) | Minimum character length |
minimum-categories | integer (0–4) | (unset) | Complexity: categories = uppercase, lowercase, digit, symbol |
Gotchas
1. POST /rest/user/add vs PUT /rest/user: Both create users. add returns {"ret":"*ID"}. PUT returns the full user object. quickchr uses add.
2. Self-disable no-op: A user cannot disable itself via PATCH — HTTP 200 returned but no change applied. Always use a different user.
3. Post-boot REST race: The /rest/user endpoint is subject to the same startup race as all endpoints. Briefly after boot it may return wrong data. Use a polling loop with deadline when reading users immediately after boot (see readUser() + createUser() in provision.ts).
4. *The ` in .id is safe in URLs:** The ` character is a sub-delimiter per RFC 3986 and is NOT percent-encoded by URL constructors. `PATCH /rest/user/1` works directly.
5. `numbers` parameter in action endpoints: disable, enable, expire-password accept numbers which takes the .id value (e.g. "*1"), not the username string.
6. All values are strings: Consistent with all RouterOS REST responses — booleans are "true"/"false", even disabled is a string.
Source:
- Rosetta: pages 8978504 (User), 47579162 (REST API), 132350014 (SSH)
- Code: quickchr/src/lib/provision.ts — createUser, disableAdmin, installSshKey patterns- Instruction: provisioning.instructions.md — admin expired caveat, SSH key provisioning- Instruction: general.instructions.md — admin expired noteRouterOS Scripting Language
Overview
RouterOS has its own scripting language (.rsc files) used for automation. It is NOT bash, NOT Lua, NOT any standard language. It runs inside the RouterOS CLI environment.
Variable Declaration
# Local variable (scoped to current script/function)
:local myVar "hello"
# Global variable (persists across scripts until reboot)
:global myVar "hello"
# Variable reference
:put $myVarData Types
| Type | Syntax | Example |
|---|---|---|
| String | "text" | "hello world" |
| Number | 123 | 42, 0xFF |
| Boolean | true / false / yes / no | true |
| IP Address | 1.2.3.4 | 192.168.1.1 |
| IP Prefix | 1.2.3.0/24 | 10.0.0.0/8 |
| Array | {1; 2; 3} or {"a"; "b"} | {1; 2; "mixed"} |
| Time | 1h2m3s | 30s, 5m, 1d |
| Nil | (no keyword) | absent value |
String Operations
# Concatenation
:local greeting ("Hello " . "World")
# Substring (pick)
:local sub [:pick "Hello" 0 3] # → "Hel"
# Length
:local len [:len "Hello"] # → 5
# Find
:local pos [:find "Hello World" "World"] # → 6
# Convert to/from
:local num [:tonum "42"]
:local str [:tostr 42]Control Flow
# If/else
:if ($x > 10) do={
:put "big"
} else={
:put "small"
}
# For loop
:for i from=1 to=10 do={
:put $i
}
# Foreach
:foreach item in=$myArray do={
:put $item
}
# While
:while ($count < 10) do={
:set count ($count + 1)
}Critical syntax: do={...} and else={...} use = and curly braces. No colon before do.
Functions
# Define a function (stored as a global variable)
:global myFunc do={
:local arg1 $1
:return ("Result: " . $arg1)
}
# Call it
:put [$myFunc "test"]Common Built-in Commands
:put "text" # Print to console
:log info "message" # Write to system log
:delay 5s # Sleep
:execute script="/path/to/script" # Run another script
:resolve "example.com" # DNS lookup
:ping 8.8.8.8 count=3 # Ping
:time { /ip/route/print } # Measure execution time
:environment print # Show all variablesWorking with Router Config
# Add entry and capture its ID
:local newId [/ip/address/add address=10.0.0.1/24 interface=ether1]
# Find entries
:local entries [/ip/address/find where interface=ether1]
# Get property value
:local addr [/ip/address/get $newId address]
# Set property
/ip/address/set $newId disabled=yes
# Remove
/ip/address/remove $newIdScheduler (Cron Equivalent)
/system/scheduler/add name=my-task interval=1h \
on-event="/system/script/run myScript"File Operations
# Read file content
:local content [/file/get myfile.txt contents]
# Files are stored in RouterOS flash — /file/print lists them
/file/printError Handling
:do {
/ip/address/add address=invalid interface=ether1
} on-error={
:log error "Failed to add address"
}Comments
# This is a comment (single-line only)
:put "hello" # Inline comment after commandNo multi-line comment syntax exists. Each line needs its own #.
:execute vs :do
# :do — runs inline, blocks until complete
:do { /ip/address/print } on-error={ :put "failed" }
# :execute — runs in BACKGROUND, returns immediately
# Result can be captured to file or as-string
:local jobId [:execute script="/interface/print"]
# :execute with as-string — BLOCKS (not background)
:local result [:execute script=":put hello" as-string]
# :execute with file — runs in background, writes output to file
:execute script="/export" file="backup"Key difference: :execute without as-string runs asynchronously — the script continues immediately. With as-string, it blocks and returns the output. Executed scripts are limited to 64KB.
:parse — Dynamic Code
# Parse a string into an executable function
:global myFunc [:parse ":put hello!"]
$myFunc
# Useful for building commands dynamically
:local cmd ":put (1 + 2)"
:local fn [:parse $cmd]
$fn # → 3:parse compiles a string into a callable function. This is the only way to create "functions" in RouterOS — the do={} syntax for globals is syntactic sugar for :parse.
Array Operations
# Create array
:local arr {1; 2; 3; "four"}
# Named keys
:local dict {name="router1"; ip=192.168.1.1}
# Access by index (uses -> not [])
:put ($arr->0) # → 1
# Access by key
:put ($dict->"name") # → "router1"
# Set element value
:set ($dict->"name") "router2"
# Array length
:put [:len $arr] # → 4
# Append to array (no built-in append — rebuild or use set)
:set arr ($arr, 5) # append 5
# Loop with keys and values
:foreach k,v in=$dict do={
:put "$k=$v"
}
# Loop values only
:foreach v in=$arr do={
:put $v
}⚠️ Array key sorting: Elements with named keys are sorted alphabetically. Elements without keys preserve insertion order but are moved before keyed elements.
⚠️ Key names with uppercase or special chars must be quoted: ($arr->"myKey").
:serialize / :deserialize (JSON)
RouterOS 7.x supports JSON serialization:
# Serialize array to JSON
:local data {name="test"; value=42}
:put [:serialize to=json value=$data]
# → {"name":"test","value":42}
# Pretty print
:put [:serialize to=json value=$data options=json.pretty]
# Prevent string→number conversion
:put [:serialize to=json value=$data options=json.no-string-conversion]
# Deserialize JSON string to array
:local parsed [:deserialize from=json value="{\"name\":\"test\"}"]
:put ($parsed->"name") # → test
# Deserialize from file
:deserialize [/file/get config.json contents] from=json
# Also supports DSV (delimiter-separated values)
:put [:serialize to=dsv delimiter=";" value=$data]DSV options: dsv.plain (no header), dsv.array (header as keys), dsv.wrap-strings, dsv.remap (merge array of dicts).
/system/script — Script Repository
# Add stored script
/system/script/add name=my-backup source={
/export file="daily-backup"
:log info "Backup complete"
}
# Run stored script
/system/script/run my-backup
# List scripts
/system/script/print
# Edit script source
/system/script/set my-backup source={...new code...}
# Remove
/system/script/remove my-backupProperties: name, source, policy, comment, dont-require-permissions, owner (read-only), run-count (read-only), last-started (read-only).
Script Permissions (Policies)
Scripts have permission policies that control what they can access:
| Policy | Allows |
|---|---|
read | Retrieve configuration |
write | Change configuration |
policy | Manage users and policies |
reboot | Reboot the router |
password | Change passwords |
ftp | FTP access, send/retrieve files |
sensitive | Change "hide sensitive" parameter |
sniff | Run sniffer, torch |
test | Run ping, traceroute, bandwidth-test |
romon | RoMON access |
Rules:
- A script can only execute another script with equal or higher permissions
dont-require-permissions=yesbypasses the check (useful for Netwatch/scheduler scripts with limited permissions)- When run from CLI, user permissions apply. Use
run use-script-permissionsto use the script's own policy set.
Important Gotchas
- No pipes, no redirection — can't do
cmd | greporcmd > file - `$` is required to reference variables —
:put myVarprints literal "myVar" - Array indexing uses
->not[]—($arr->0)for first element - String comparison uses
=not==—(:if ($a = "test") do={...}) - Command substitution uses
[...]not$(...)—:local result [/system/identity/get name] - Semicolons in arrays —
{1; 2; 3}not{1, 2, 3} - Script line continuation — use
\at end of line - Property names with hyphens — use quotes in find:
[find where "mac-address"="AA:BB:CC:DD:EE:FF"] - Variable names are case-sensitive —
$myVar≠$myVAR - `:set` without value undefines —
:global myVar; :set myVarremoves it from environment - `:execute` script size limit — 64KB max for executed scripts
- Global variables survive across script runs but NOT across reboots
Source:
- Rosetta: page 47579229 (Scripting) — comprehensive language reference
- Reference: rest-api-patterns.md — REST vs scripting interface differences- Note::serialize/:deserializeare RouterOS 7.x features (from docs, not lab-verified)
- Note: Script permissions table from official docs, verified against page 47579229
RouterOS Version Parsing & Comparison
Version Format
RouterOS versions follow the pattern: MAJOR.MINOR[.PATCH][QUALIFIER]
| Component | Required | Examples |
|---|---|---|
| MAJOR | Yes | 7 |
| MINOR | Yes | 22, 23 |
| PATCH | No | .1, .2 (absent means .0) |
| QUALIFIER | No | beta1, beta2, rc1, rc2 |
Full examples: 7.22, 7.22.1, 7.23beta2, 7.22rc4, 7.9.2
Parsing Logic
function parseVersion(versionString: string) {
// Match: major.minor[.patch][betaN|rcN]
const match = versionString.match(
/^(\d+)\.(\d+)(?:\.(\d+))?(?:(beta|rc)(\d+))?$/
);
if (!match) return null;
return {
major: parseInt(match[1]),
minor: parseInt(match[2]),
patch: match[3] ? parseInt(match[3]) : 0,
preType: match[4] || null, // "beta", "rc", or null
preNum: match[5] ? parseInt(match[5]) : Infinity, // Infinity = stable (sorts last/highest)
};
}Key insight: Stable releases (no qualifier) get preNum = Infinity so they sort after all beta/rc releases of the same major.minor — this is correct because stable is released after all pre-releases.
NaN edge case: When comparing two stable versions with the same major.minor.patch, Infinity - Infinity = NaN. Array.sort() treats NaN as 0 (equal), so the result is correct — but be aware of this if adapting the comparison for other uses (e.g., strict less-than checks).
Sorting / Comparison
Sort order: major → minor → patch → preType → preNum
function compareVersions(a: string, b: string): number {
const pa = parseVersion(a);
const pb = parseVersion(b);
if (!pa || !pb) return 0;
// Major, minor, patch — numeric ascending
if (pa.major !== pb.major) return pb.major - pa.major;
if (pa.minor !== pb.minor) return pb.minor - pa.minor;
if (pa.patch !== pb.patch) return pb.patch - pa.patch;
// Pre-release type: stable (null) > rc > beta
const preOrder = { beta: 0, rc: 1, null: 2 }; // null = stable = highest
const aOrder = preOrder[pa.preType ?? "null"] ?? -1;
const bOrder = preOrder[pb.preType ?? "null"] ?? -1;
if (aOrder !== bOrder) return bOrder - aOrder;
// Same pre-release type: higher number = newer
return pb.preNum - pa.preNum;
}Result: newest first. 7.23 > 7.23rc2 > 7.23rc1 > 7.23beta4 > 7.23beta2 > 7.22.1 > 7.22
Pre-Release Detection
function isPreRelease(version: string): boolean {
return /(?:beta|rc)\d+$/.test(version);
}Pre-release versions:
- Use
download.mikrotik.comas the primary source, same as stable releases cdn.mikrotik.comis a backup mirror/cache and may lag slightly for very new releases- May have incomplete features or known bugs
- Should be excluded from user-facing version lists by default (opt-in display)
Version Channels
RouterOS publishes current versions per channel:
https://upgrade.mikrotik.com/routeros/NEWESTa7.<channel>| Channel | Audience | Example |
|---|---|---|
stable | Production | 7.22 |
long-term | Conservative | 7.18.2 |
testing | Pre-release | 7.23rc2 |
development | Beta | 7.23beta4 |
The response is plain text — just the version string, no JSON.
Download URL Selection
function getDownloadUrl(version: string, file: string): string {
// Use the primary MikroTik download host for all releases.
// Fall back to cdn.mikrotik.com only if the primary host is unavailable.
const host = "download.mikrotik.com";
return `https://${host}/routeros/${version}/${file}`;
}
// Package naming: {pkg}-{version}-{arch}.npk
// EXCEPTION: x86 packages omit the architecture suffix:
// routeros-7.22.npk (not routeros-7.22-x86.npk)
// container-7.22.npk (not container-7.22-x86.npk)
// But the all_packages zip DOES use x86: all_packages-x86-7.22.zip
//
// Common files:
// routeros-{ver}-{arch}.npk — system package (x86: routeros-{ver}.npk)
// all_packages-{arch}-{ver}.zip — extra packages bundle
// chr-{ver}.img.zip — x86_64 CHR disk image
// chr-{ver}-arm64.img.zip — aarch64 CHR disk image
// chr-{ver}.vdi.zip — VirtualBox format (used by some CI)
// netinstall-{ver}.tar.gz — netinstall-cli binary (7.18+)CI pattern: Always try download.mikrotik.com first, then fall back to cdn.mikrotik.com. Treat cdn.mikrotik.com as a backup mirror/cache rather than a version-specific host.
Checking If a Version Is "Built"
In the restraml project (and similar schema-generation projects), a version is considered fully built when specific artifact files exist:
// Version has base schema
const hasSchema = await fileExists(`docs/${version}/schema.raml`);
// Version has extra-packages schema
const hasExtra = await fileExists(`docs/${version}/extra/schema.raml`);
// Version has /app YAML schemas (7.22+)
const hasAppSchema = await fileExists(`docs/${version}/routeros-app-yaml-schema.json`);Related skills
FAQ
Does RouterOS have a Unix shell?
No; there is no bash, sh, or coreutils. RouterOS uses its own CLI language accessed via SSH, serial, WinBox, or WebFig.
How does the REST API map create operations?
PUT creates (add), not updates, which is the opposite of many REST APIs.