routeros-quickchr-cli
Answer a RouterOS question by asking RouterOS. quickchr boots a real, disposable MikroTik CHR router on the local machine under QEMU — no hardware, no cloud, gone when you remove it — and centrs drive
SKILL.mdrouteros-quickchr-cli/SKILL.md
GitHubCHR from the shell: quickchr CLI + centrs
quickchr (@tikoci/quickchr, CLI + Bun/TS
library) downloads a MikroTik CHR image, boots it under QEMU, and hands you a
router. This skill is the shell-first path: you have a terminal, you want a
router to interrogate, and the answer will be quoted as shell + RouterOS
commands (e.g. in a forum.mikrotik.com reply). If you are writing a
bun:test harness that imports QuickCHR, use the
routeros-quickchr skill instead — one scheme per task, never both.[^split]
Behavior below is pinned to quickchr 0.4.8. Several of these are 0.4.8 changes, called out inline; on 0.4.7 and earlier the answer differs.[^pin]
One limit to keep in view: a CHR booted this way runs the free
license (quickchr get <name> shows Level: free), which MikroTik caps
at 1 Mbps upload per interface — every other RouterOS feature is
unrestricted. Config, API and CLI grounding are unaffected; a throughput
or queue number measured here is the license talking, not the
feature.[^license]
Lifecycle: add is not start
quickchr add --name lab-a --version 7.24.4 --arch x86 --add-network user
quickchr start lab-a --bg
quickchr exec lab-a "/system/resource/print"
quickchr stop lab-a
quickchr remove lab-a
addonly writes config (0s on a warm cache) and printsquickchr start <name>as its tip. It never boots — the single most common wrong assumption about this CLI.[^ground-add]start --bgblocks until the guest is REST-ready, then prints the REST/SSH/WinBox endpoints (≈22s first boot, ≈11s restart, warm-cache HVF). Under TCG software emulation expect minutes, not seconds — a sandboxed field lab saw ~4 min boots and a realBOOT_TIMEOUTat 480s. Size harness timeouts from the slow end.[^ground-timings]exec <name> <command…>runs one RouterOS command over REST.--viaisauto|rest|qgahere, andqgais narrower than it looks: RouterOS only starts its guest agent for an x86 CHR under Linux KVM. Under macOS HVF or TCG the port is presented but the guest never opens it, and ARM64 CHR has no QGA at all — both time out. Stay on REST unless you are on Linux/KVM with an x86 guest. (The library'sExecTransporttype also listssshandconsole; those are not CLI surface.)stopis instant;removedeletes the machine.listshows state and PIDs.[^ground-exec]- Version selection:
--version 7.24.4pins;--channel stable|long-term|testing|developmentresolves the newest of a channel. Barequickchr --versionprints what each channel currently resolves to.[^ground-channel] - Ports are auto-allocated from 9100 in per-machine blocks of ten
(9140, 9150, 9160 …), so machines created one after another do not
collide and you need no port bookkeeping.
--port-baseoverrides;--forward name:host:guest/protoadds extras, ranges included.[^ground-ports] - Creating several at once (
quickchr start a & quickchr start b &) is only safe after 0.4.8. On 0.4.8 and earlier both can pick the same block, and one fails withSPAWN_FAILED. Start them one at a time, or give each--port-baseat least 10 apart. Later releases give concurrent creations distinct blocks, refuse an overlapping--port-basewithPORT_CONFLICT, and makestop/removewait for astartof the same machine in progress. A foregroundstartholds the machine until its VM exits, so they fail withMACHINE_LOCKEDinstead of waiting.[^ground-concurrent] - Pre-warm without booting:
quickchr cache add --version 7.24.4 --arch x86resolves and downloads one image, no QEMU and no machine required.cache keyprintsdir=/version=/arch=for CI. (Both new in 0.4.8 — before it,cachewaslist|prune|clearonly and the only way to warm the cache was to boot something.)[^ground-cache] - Never hand-edit
machine.jsonto change networks or options — it breaks boot. Everything is reachable throughaddflags.[^field]
--bg is a no-op — background it yourself
--bg changes nothing: background is already the default. The CLI
sets background = !wantFg, so the flag only fails to select the
foreground mode you did not ask for; --fg/--foreground is the
switch that does something. And start waits for REST-readiness by
contract either way, so --bg does not hand your shell back. Both
halves of what the name promises are things it does not do
(tikoci/quickchr#159 — the --no-wait half and the naming decision are
still open).[^bg]
Examples here pass --bg only because it is harmless and widespread in
existing scripts. To actually get the shell back, background it
yourself and poll readiness:
nohup quickchr start lab-a --bg >lab-a.start.log 2>&1 &
ready() { centrs retrieve --quickchr lab-a /system/resource >/dev/null 2>&1; }
for _ in $(seq 60); do ready && break; sleep 5; done # 60 x 5s = 5 min
ready || { quickchr list; tail -20 lab-a.start.log; exit 1; }
Bound the wait. start can fail outright — MISSING_QEMU,
SPAWN_FAILED, BOOT_TIMEOUT — and an unbounded until loop then
polls forever, which in CI is a hung job with no diagnostic. Size the
bound off the slow end (TCG minutes, not HVF seconds) and on timeout
print quickchr list plus the start log rather than just
exiting.[^ground-poll]
A killed start does not stop the VM. On 0.4.8 QEMU is spawned
detached (its own session), so even a harness that SIGKILLs the whole
process group leaves the guest booting — it comes up REST-ready with
nothing left to report it. Verified against both published releases:
group-SIGKILL 8s into a first boot took the VM down on 0.4.7, and
left it running and REST-ready on 0.4.8. So never infer "start
failed" from a killed CLI — check quickchr list and poll REST, and
stop/remove to clean up. (A BOOT_TIMEOUT can likewise auto-clean
the machine while QEMU keeps running.)[^ground-bg]
If a harness group-kills commands and you are pinned below 0.4.8, that kill does take the VM with it — upgrade rather than work around it.
NIC order and the two-VM link
- Put
userfirst:--add-network user --add-network 'socket::lab-link'. RouterOS auto-DHCPs only ether1 (observed: dynamic10.0.2.15/24), and the host port-forwards assume it.[^ground-nic] - For an L2 link between two VMs, prefer a named socket
socket::<name>. It resolves through a registry entry, defaults todgram(a unix datagram pair) off Windows, and either machine may start first — there are no listener/connector roles to get wrong.startprints the transport each link resolved to. Verified: two machines on onesocket::link, started in arbitrary order, static/24s on ether2, ping 3/3 on a macOS host.[^ground-l2] - The raw
socket:listen:<port>/socket:connect:<port>pair is the ordering-sensitive form — a TCP pair where the listener must start first. Reach for it only when you need those explicit roles;socket::<name>exists to avoid the ordering problem.[^ground-forms] - A third machine on a two-machine link is refused at
start(not atadd), naming the two holders and pointing at--mode mcast. That refusal is the configuration check working.[^ground-third]
Which transport: default dgram, opt-in mcast
quickchr networks sockets create <name> takes --mode dgram|listen-connect|mcast and prints the transport it created —
believe the output, not the name. Default is dgram (unix datagram
pair, filesystem-confined, two machines) on macOS/Linux and
listen-connect (TCP pair) on Windows, which has no AF_UNIX datagram
socket. --mode mcast is UDP multicast 230.0.0.1 — the only N-way
transport, and the only one that fails silently.[^ground-sock]
quickchr prints all three hazards below as a warning when you create an
mcast link. Two of them fail silently — same signature each time:
interfaces up, addresses assigned, 100% loss, nothing logged:[^mcast]
- No macOS delivery — QEMU's multicast socket omits
SO_REUSEPORT, which BSD/macOS require before two sockets on one group both receive (tikoci/quickchr#167). - Refused where unconnected UDP sends are blocked —
seccomp-filtered sandboxes and some Linux CI return
EPERMonsendto()while the group join succeeds (tikoci/quickchr#169).
The third is the opposite failure — traffic flows where it should not:
- Not confined to your machine — quickchr passes no
localaddr=, so the group rides the host's default multicast interface. Another host on your LAN using the same group joins your segment, and the link keeps working, so nothing looks wrong. That is an isolation failure, not a loss one. Give an N-way link a group of its own, or stay ondgramfor two machines.
Driving the router: centrs --quickchr
Once the machine is up, reads and writes go through centrs
(@tikoci/centrs) targeting the
machine by name — it resolves the endpoint through quickchr inspect,
so there is no credential plumbing:[^ground-drive]
centrs retrieve --quickchr lab-a /system/resource
centrs execute --quickchr lab-a --yes '/ip/address/add interface=ether2 address=198.51.100.1/24'
Reads need no flag; writes refuse without --yes
([usage/confirmation-required]). One RouterOS statement per call.
Prefer it over quickchr exec: as of 0.4.8 quickchr itself says so, in
exec --help and in a stderr tip on a bare quickchr exec
(suppressible with QUICKCHR_NO_TIPS=1; tips never touch stdout, so
--json stays parseable). quickchr exec is a raw /rest/execute
pipe with no validation — whatever you type is what RouterOS is asked
to run, and its output is screen-scraped text where centrs returns
typed JSON.[^ground-tips]
Need the raw endpoint instead — curl, or some other tool?
quickchr inspect <name> --json carries the resolved URL and
ready-to-use credentials. CHR here is admin with an empty
password, and inspect hands you the Basic header already encoded:
"rest-api": { "url": "http://127.0.0.1:9150/rest", "port": 9150,
"auth": { "username": "admin", "password": "", "basic": "admin:" } }
(auth also carries a pre-encoded header if you would rather not
build the Basic value yourself.) Straight to curl:
url=$(quickchr inspect lab-a --json | jq -r '.services["rest-api"].url')
curl -s -u admin: "$url/system/resource" | jq .
That is the same door centrs uses — --quickchr resolves through
quickchr inspect, never by reading machine.json — which is why it
needs no credential configuration from you.[^ground-inspect]
Device-mode: the window closes at first boot
Two traps, and agents hit them in this order.
The guest-side route does not work. /system/device-mode/update container=yes over REST or API does not complete — RouterOS is waiting
for a power cycle it cannot perform on itself — and the feature stays
false. Observed live: the call did not return within the 10s client
timeout and container was unchanged afterwards. (Whether it would
ever return is not established; a field lab reported waiting ~5 min.)
Do not reach for this, and do not read the timeout as a transport
problem.[^dm-trap]
The flags are honored only on a machine's first boot. quickchr
applies device-mode by provisioning — boot the guest, set the mode,
power-cycle QEMU via the monitor, re-read to confirm (≈45s vs ≈22s
without). That runs only while the machine has never started. Once
lastStartedAt is set, --device-mode-enable is parsed, accepted and
silently ignored: no warning, no extra boot time, no change
(tikoci/quickchr#176 — open).[^dm-window]
| machine state | start --device-mode-enable container |
|---|---|
| added, never started | applies — "Device-mode verified: mode=rose container=yes" |
| started at least once | silently ignored — stays container: false |
So add --device-mode-enable <feature> when you can; if you only
realize you need it after creating the machine, you can still pass it
to the first start. Once it has booted, the CLI has no way to
change device-mode — remove + add --device-mode-enable … + start
is the only CLI path. (The library exposes
instance.setDeviceMode(), which power-cycles for you — see
routeros-quickchr.)
Which side of the line a machine is on, and what it currently has:
quickchr inspect <name> --json | jq -r '.lastStartedAt // "never started"'
quickchr get <name> # live Device Mode / License / Admin Users
Enabling container moves the mode itself too (advanced → rose).
Provisioning needs RouterOS 7.20.8+ and a user-mode NIC — with
only socket NICs it fails NETWORK_UNAVAILABLE; older 7.x is
boot-only.[^device-mode]
Grounding behind this skill
references/cli-grounding.md— the live runs (commands, outputs, timings, environment) and an explicit list of what was not re-verified there.- quickchr
MANUAL.md,DESIGN.md,docs/networking.md,CHANGELOG.md— authoritative behavior reference; the skill favors stable CLI concepts over version-specific flags. - Field lab that motivated this skill: external 3-CHR lab notes
(muse-app, 2026-09-19/20) —
add-doesn't-start, detached starts + REST polling, cold power-cycle for device-mode, cache-warming gap.
[^split]: Split rationale: tikoci/routeros-skills#21. Library consumers
stay on routeros-quickchr; shell-first agents come here.
[^pin]: Pinned observations: published @tikoci/quickchr 0.4.8 (and
0.4.7 as a control), CHR 7.24.4 x86, QEMU 11.1.1 + HVF on macOS
Intel. The named-socket dgram default, the printed transport,
the three-machine refusal and the POSIX detach are all 0.4.8
(quickchr #158, #159) — they are absent from 0.4.7.
[^ground-add]: references/cli-grounding.md §Lifecycle timings.
[^ground-timings]: Same; TCG band and the 480s BOOT_TIMEOUT from the
field lab notes.
[^ground-exec]: Same, §Lifecycle timings.
[^ground-cache]: references/cli-grounding.md §Cache; quickchr
CHANGELOG 0.4.8 "Added" (cache add, cache key).
[^field]: Field lab notes: --add-network at add time, never a
hand-edited machine.json.
[^bg]: --bg as a no-op: src/cli/index.ts — "Background default:
true. Explicitly foreground only with --fg / --foreground /
--no-background / --no-bg", and quickchr start --help renders
--bg / --background Run in background (default).
tikoci/quickchr#159 — part 1 (--no-wait) and the --bg naming
decision remain open; part 2 (POSIX process group) shipped in
0.4.8.
[^ground-bg]: references/cli-grounding.md §Group-kill, which records
the 0.4.7-vs-0.4.8 before/after.
[^ground-nic]: references/cli-grounding.md §First NIC.
[^ground-l2]: references/cli-grounding.md §Named-socket link.
[^ground-forms]: parseSocketSpecifier() in quickchr
src/lib/network.ts accepts socket::<name>,
socket:listen:<port>, socket:connect:<port>,
socket:mcast:<group>:<port>; docs/networking.md — "Named
sockets avoid the listen/connect ordering problem".
[^ground-third]: references/cli-grounding.md §Third machine refused.
[^ground-sock]: references/cli-grounding.md §Socket create output;
defaultSocketMode() in quickchr src/lib/socket-registry.ts.
[^mcast]: quickchr DESIGN.md "A named socket says what it is";
tikoci/quickchr#167 (delivery) and #169 (permission); warning text
quoted in references/cli-grounding.md.
[^ground-drive]: references/cli-grounding.md §Driving with centrs
(retrieve, execute --yes, and the refusal without --yes).
[^ground-tips]: quickchr exec --help and bare quickchr exec on
0.4.8, quoted in references/cli-grounding.md §Driving with
centrs; CHANGELOG 0.4.8 "Added" (tips).
[^dm-trap]: references/cli-grounding.md §Device-mode — the guest-side
/system/device-mode/update container=yes timing out with
container unchanged, reproduced on 0.4.8 / CHR 7.24.4.
[^dm-window]: Same section: the never-started machine applied and
verified the change, the already-booted one silently did not. The
gate is !existing.lastStartedAt in QuickCHR.start()
(src/lib/quickchr.ts), which passes provisioning options to
_launchExisting() on a first boot and undefined afterwards.
Filed as tikoci/quickchr#176, which also asks which provisioning
steps should be allowed to run later; until it lands, treat the
silence as the documented behavior.
[^ground-inspect]: references/cli-grounding.md §Endpoints and
credentials (quickchr inspect --json, quickchr get).
[^ground-poll]: Failure codes from quickchr's ErrorCode union
(src/lib/types.ts); the bound-and-diagnose shape is this skill's
recommendation, verified as a script in
references/cli-grounding.md §Bounded readiness poll.
[^ground-channel]: quickchr add --help (--version, --channel);
quickchr --version prints the resolved stable/long-term versions.
[^ground-concurrent]: tikoci/quickchr#140
(two concurrent starts on 0.4.7 both chose 9140; the second QEMU failed
its hostfwd bind) and the fix,
tikoci/quickchr#190,
covered by quickchr's concurrency unit tests and a live two-CHR start.
See the routeros-quickchr skill, "Running several CHRs at once".
[^ground-ports]: quickchr add --help — "--port-base <port> Starting
port number (default: auto-allocated from 9100)"; successive
machines observed at 9140/9150/9160 in
references/cli-grounding.md.
[^license]: MikroTik, CHR: Licensing → CHR License
Levels:
"The Free license level allows CHR to run indefinitely with a 1 Mbps
upload limit per interface. All other RouterOS features are available
without restrictions." Paid tiers raise the cap (P1 1 Gbit, P10
10 Gbit, P-Unlimited), and a 60-day trial of a paid tier is free with
a MikroTik account. This is MikroTik licensing, not a quickchr
behavior; quickchr get <name> reports License Level: free on a
default machine.
[^device-mode]: quickchr MANUAL.md "Order of operations"
(_provisionInstance); --device-mode-enable in
src/cli/flags.ts (accepted by both add and start, though the
condensed add --help lists only --device-mode <m>); the
user-mode-NIC requirement is the NETWORK_UNAVAILABLE guard in
QuickCHR.start().