An asynchronous MikroTik RouterOS API client for Rust, built on Tokio.
Send commands, stream responses, and manage multiple concurrent operations against MikroTik routers with a type-safe, channel-based API.
- Fully async -- built on Tokio with non-blocking I/O throughout
- Concurrent command execution -- each command gets its own response channel; run as many in parallel as you need
- Typestate command builder -- the compiler enforces correct command construction order
- Compile-time command validation -- the
command!macro validates RouterOS command paths at compile time - Zero-copy protocol parsing -- sentences and words are parsed directly from the receive buffer
- Complete query support -- equality, comparison, presence, and boolean operators for RouterOS queries
- Structured error handling -- a typed error hierarchy with
thiserror, fullyClone-able - No
unsafecode --unsafe_code = "forbid"is enforced at the workspace level - Automatic lifecycle management -- dropping a response receiver cancels the command on the router; dropping all device handles cancels everything
cargo add mikrotik-rsRequirements: Rust 2024 edition, Tokio runtime.
use mikrotik_rs::{protocol::command::CommandBuilder, MikrotikDevice};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Connect to the router
let device = MikrotikDevice::connect("192.168.88.1:8728", "admin", Some("password")).await?;
// Build a command
let cmd = CommandBuilder::new()
.command("/system/resource/print")
.build();
// Send it -- returns a dedicated channel for this command's responses
let mut responses = device.send_command(cmd).await?;
while let Some(result) = responses.recv().await {
println!("{:?}", result?);
}
Ok(())
}MikrotikDevice is cheaply Clone-able -- share it across tasks, spawn concurrent commands, and let each one
stream responses independently.
The CommandBuilder uses a typestate pattern: you must call .command() before .attribute() or .build().
The compiler rejects incorrect ordering.
use mikrotik_rs::protocol::command::CommandBuilder;
let cmd = CommandBuilder::new()
.command("/interface/ethernet/monitor")
.attribute("numbers", Some("0,1"))
.attribute("once", None)
.build();For static command paths, the command! macro validates the path at compile time and provides a
more concise syntax:
use mikrotik_rs::command;
// Simple command
let cmd = command!("/interface/print");
// Command with attributes
let cmd = command!(
"/interface/ethernet/monitor",
numbers = "0,1",
once
);
// These fail at compile time:
// command!("invalid//command"); // no empty segments
// command!("no-leading-slash"); // must start with '/'Every call to send_command returns an mpsc::Receiver scoped to that command.
Responses arrive as CommandResponse variants:
use mikrotik_rs::protocol::CommandResponse;
let mut rx = device.send_command(cmd).await?;
while let Some(result) = rx.recv().await {
match result? {
CommandResponse::Reply(reply) => {
// Key-value data from the router
println!("attributes: {:?}", reply.attributes);
}
CommandResponse::Done(_) => {
println!("command completed");
}
CommandResponse::Trap(trap) => {
// Router-side error (e.g., invalid command, permission denied)
eprintln!("trap: {} (category: {:?})", trap.message, trap.category);
}
CommandResponse::Fatal(reason) => {
// Fatal protocol error -- connection is dead
eprintln!("fatal: {reason}");
}
CommandResponse::Empty(_) => {
// RouterOS 7.18+: command had no data to return
}
}
}Commands that produce continuous output (e.g., traffic monitoring, resource polling) stream results through the same channel:
let monitor = CommandBuilder::new()
.command("/interface/monitor-traffic")
.attribute("interface", Some("ether1"))
.build();
let mut rx = device.send_command(monitor).await?;
// Receives updates continuously until the channel is dropped
while let Some(result) = rx.recv().await {
let reply = result?;
println!("{:?}", reply);
}
// Dropping `rx` automatically sends /cancel to the routerFilter results using RouterOS query operations:
use mikrotik_rs::protocol::command::{CommandBuilder, QueryOperator};
let cmd = CommandBuilder::new()
.command("/interface/print")
.query_equal("type", "ether")
.query_equal("running", "true")
.query_operations([QueryOperator::And].into_iter())
.build();Available query methods:
| Method | Wire format | Description |
|---|---|---|
query_equal(k, v) |
?k=v |
Property equals value |
query_gt(k, v) |
?>k=v |
Property greater than value |
query_lt(k, v) |
?<k=v |
Property less than value |
query_is_present(k) |
?k |
Property exists |
query_not_present(k) |
?-k |
Property does not exist |
query_operations(ops) |
?#... |
Boolean stack operations (And, Or, Not, Dot) |
For non-UTF-8 or binary attribute values:
let cmd = CommandBuilder::new()
.command("/file/print")
.attribute_raw("contents", Some(&[0x00, 0xFF, 0xAB]))
.build();Responses expose both attributes (UTF-8 strings) and attributes_raw (byte vectors) on ReplyResponse.
┌──────────────────────────┐
MikrotikDevice ──────►│ Background Actor Task │
(Clone-able) │ │
│ ┌────────┐ ┌────────┐ │
send_command() ──────►│ │ Writer │ │ Reader │ │◄──── TCP
│ └────────┘ └────────┘ │
│ ▲ │ │
│ │ route by UUID │
│ commands responses │
└──────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
Receiver<Cmd1> Receiver<Cmd2> Receiver<CmdN>
- Actor pattern -- a single spawned Tokio task owns the TCP connection and multiplexes commands/responses over it.
- UUID tagging -- every command is tagged with a UUID v4. Responses from the router include this tag, enabling correct routing to per-command channels.
- Graceful shutdown -- dropping all
MikrotikDevicehandles cancels every active command on the router and tears down the connection cleanly.
The repository includes runnable examples in examples/
This crate uses the Rust 2024 edition and requires a compatible toolchain.
Contributions are welcome. Please read CONTRIBUTING.md before opening a pull request.
Licensed under either of
at your option.
This project is not affiliated with MikroTik. It is an independent, community-developed library.