protobufs update for mikrotik things

This commit is contained in:
Graham McIntire 2026-02-02 12:09:29 -06:00
parent 224c2b5898
commit eb330b2f6f
No known key found for this signature in database
7 changed files with 1123 additions and 12 deletions

103
Cargo.lock generated
View file

@ -67,6 +67,28 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "aws-lc-rs"
version = "1.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "bitflags"
version = "2.10.0"
@ -101,6 +123,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -150,6 +174,15 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
@ -202,6 +235,12 @@ dependencies = [
"syn",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.15.0"
@ -251,6 +290,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.31"
@ -534,6 +579,16 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -850,7 +905,22 @@ dependencies = [
"log",
"ring",
"rustls-pki-types",
"rustls-webpki",
"rustls-webpki 0.102.8",
"subtle",
"zeroize",
]
[[package]]
name = "rustls"
version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki 0.103.9",
"subtle",
"zeroize",
]
@ -875,6 +945,18 @@ dependencies = [
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "serde"
version = "1.0.228"
@ -1107,11 +1189,21 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
dependencies = [
"rustls",
"rustls 0.22.4",
"rustls-pki-types",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.36",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
@ -1120,10 +1212,10 @@ checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls 0.22.4",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tokio-rustls 0.25.0",
"tungstenite",
"webpki-roots 0.26.11",
]
@ -1142,6 +1234,7 @@ dependencies = [
"serde_json",
"snmp",
"tokio",
"tokio-rustls 0.26.4",
"tokio-tungstenite",
"tracing",
"tracing-subscriber",
@ -1221,7 +1314,7 @@ dependencies = [
"httparse",
"log",
"rand",
"rustls",
"rustls 0.22.4",
"rustls-pki-types",
"sha1",
"thiserror",

View file

@ -5,8 +5,9 @@ edition = "2021"
[dependencies]
snmp = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "process", "signal"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "process", "signal", "io-util"] }
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
tokio-rustls = "0.26"
futures = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View file

@ -112,6 +112,7 @@ message HeartbeatResponse {
enum JobType {
DISCOVER = 0;
POLL = 1;
MIKROTIK = 2;
}
enum QueryType {
@ -129,6 +130,8 @@ message AgentJob {
string device_id = 3;
SnmpDevice snmp_device = 4;
repeated SnmpQuery queries = 5;
MikrotikDevice mikrotik_device = 6;
repeated MikrotikCommand mikrotik_commands = 7;
}
message SnmpDevice {
@ -162,3 +165,30 @@ message AgentError {
string error_message = 2;
int64 timestamp = 3;
}
// MikroTik RouterOS API messages
message MikrotikDevice {
string ip = 1;
uint32 port = 2;
string username = 3;
string password = 4;
bool use_ssl = 5;
}
message MikrotikCommand {
string command = 1;
map<string, string> args = 2;
}
message MikrotikResult {
string device_id = 1;
string job_id = 2;
repeated MikrotikSentence sentences = 3;
string error = 4;
int64 timestamp = 5;
}
message MikrotikSentence {
map<string, string> attributes = 1;
}

View file

@ -1,3 +1,4 @@
mod mikrotik;
mod proto;
mod snmp;
mod version;
@ -44,12 +45,20 @@ fn convert_to_websocket_url(url: &str) -> String {
#[command(about = "Towerops remote SNMP polling agent", long_about = None)]
struct Args {
/// API URL (e.g., wss://towerops.net or https://towerops.net)
#[arg(long, env = "TOWEROPS_API_URL")]
api_url: String,
#[arg(
long,
env = "TOWEROPS_API_URL",
required_unless_present = "mikrotik_test"
)]
api_url: Option<String>,
/// Agent authentication token
#[arg(long, env = "TOWEROPS_AGENT_TOKEN")]
token: String,
#[arg(
long,
env = "TOWEROPS_AGENT_TOKEN",
required_unless_present = "mikrotik_test"
)]
token: Option<String>,
/// UDP port for SNMP trap listener
#[arg(long, env = "TRAP_PORT", default_value_t = snmp::DEFAULT_TRAP_PORT)]
@ -58,6 +67,30 @@ struct Args {
/// Enable SNMP trap listener
#[arg(long, env = "TRAP_ENABLED", default_value_t = false)]
trap_enabled: bool,
/// Run MikroTik API test instead of normal agent operation
#[arg(long)]
mikrotik_test: bool,
/// MikroTik device IP address (for --mikrotik-test)
#[arg(long, required_if_eq("mikrotik_test", "true"))]
mikrotik_ip: Option<String>,
/// MikroTik username (for --mikrotik-test)
#[arg(long, default_value = "admin")]
mikrotik_user: String,
/// MikroTik password (for --mikrotik-test)
#[arg(long, default_value = "")]
mikrotik_pass: String,
/// MikroTik API port (for --mikrotik-test)
#[arg(long, default_value_t = 8729)]
mikrotik_port: u16,
/// Use plain TCP instead of SSL (port 8728) - WARNING: credentials sent in plaintext
#[arg(long, default_value_t = false)]
mikrotik_plain: bool,
}
#[tokio::main]
@ -67,6 +100,12 @@ async fn main() {
let args = Args::parse();
// Handle MikroTik test mode
if args.mikrotik_test {
run_mikrotik_test(&args).await;
return;
}
tracing::info!("Towerops agent starting");
// Check for newer Docker image version
@ -92,7 +131,7 @@ async fn main() {
}
// Convert HTTP(S) URL to WebSocket URL
let ws_url = convert_to_websocket_url(&args.api_url);
let ws_url = convert_to_websocket_url(args.api_url.as_ref().unwrap());
tracing::info!("WebSocket URL: {}", ws_url);
@ -137,7 +176,7 @@ async fn main() {
}
// Connect to Towerops server via WebSocket
let mut client = match AgentClient::connect(&ws_url, &args.token).await {
let mut client = match AgentClient::connect(&ws_url, args.token.as_ref().unwrap()).await {
Ok(client) => {
tracing::info!("Successfully connected to server");
// Mark as connected for health check
@ -177,6 +216,129 @@ async fn main() {
tracing::info!("Towerops agent stopped");
}
/// Run MikroTik API test
async fn run_mikrotik_test(args: &Args) {
use mikrotik::{MikrotikClient, SecretString};
let ip = args.mikrotik_ip.as_ref().expect("--mikrotik-ip required");
let port = args.mikrotik_port;
let username = &args.mikrotik_user;
let password = SecretString::new(&args.mikrotik_pass);
println!("Connecting to MikroTik device at {}:{}...", ip, port);
println!(" Username: {}", username);
println!(
" Password: {}",
if password.expose().is_empty() {
"(empty)"
} else {
"(set)"
}
);
// Quick TCP connectivity check first
print!(" Testing TCP connectivity... ");
match tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::net::TcpStream::connect(format!("{}:{}", ip, port)),
)
.await
{
Ok(Ok(_)) => println!("OK"),
Ok(Err(e)) => {
println!("FAILED");
eprintln!("\nTCP connection failed: {}", e);
eprintln!("Make sure the API-SSL service is enabled on the router:");
eprintln!(" /ip service set api-ssl disabled=no");
std::process::exit(1);
}
Err(_) => {
println!("TIMEOUT");
eprintln!("\nTCP connection timed out after 5 seconds.");
eprintln!("Check network connectivity and firewall rules.");
std::process::exit(1);
}
}
let use_plain = args.mikrotik_plain;
if use_plain {
print!(" Connecting (plain TCP) and authenticating... ");
} else {
print!(" Establishing TLS and authenticating... ");
}
let connect_result = if use_plain {
MikrotikClient::connect_plain(ip, port, username, &password).await
} else {
MikrotikClient::connect(ip, port, username, &password).await
};
let mut client = match connect_result {
Ok(client) => {
println!("OK");
client
}
Err(e) => {
println!("FAILED");
eprintln!("\nError: {}", e);
eprintln!("\nTroubleshooting tips:");
if use_plain {
eprintln!(" 1. Verify the API service (non-SSL) is enabled:");
eprintln!(" /ip service set api disabled=no");
} else {
eprintln!(" 1. Verify the API-SSL service is enabled:");
eprintln!(" /ip service set api-ssl disabled=no");
}
eprintln!(" 2. Verify the username/password are correct");
eprintln!(" 3. Check if the user has API access permission:");
eprintln!(" /user print");
std::process::exit(1);
}
};
println!("\nRunning /system/identity/print...");
match client.execute("/system/identity/print", &[]).await {
Ok(response) => {
if let Some(err) = response.error {
eprintln!("Command error: {}", err);
} else if let Some(sentence) = response.sentences.first() {
if let Some(name) = sentence.attributes.get("name") {
println!("Device identity: {}", name);
} else {
println!("Response: {:?}", sentence.attributes);
}
} else {
println!("No response data received");
}
}
Err(e) => {
eprintln!("Command failed: {}", e);
}
}
println!("\nRunning /system/resource/print...");
match client.execute("/system/resource/print", &[]).await {
Ok(response) => {
if let Some(err) = response.error {
eprintln!("Command error: {}", err);
} else if let Some(sentence) = response.sentences.first() {
println!("System resources:");
for (key, value) in &sentence.attributes {
println!(" {}: {}", key, value);
}
} else {
println!("No response data received");
}
}
Err(e) => {
eprintln!("Command failed: {}", e);
}
}
let _ = client.close().await;
println!("\nTest complete.");
}
/// Wait for SIGTERM or SIGINT shutdown signal.
async fn wait_for_shutdown_signal() {
#[cfg(unix)]

639
src/mikrotik/client.rs Normal file
View file

@ -0,0 +1,639 @@
use super::types::{CommandResponse, MikrotikError, MikrotikResult, SecretString, Sentence};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};
use tokio_rustls::client::TlsStream;
use tokio_rustls::rustls::ClientConfig;
use tokio_rustls::TlsConnector;
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
const READ_TIMEOUT: Duration = Duration::from_secs(30);
/// Stream type that can be either TLS or plain TCP
enum MikrotikStream {
Tls(Box<TlsStream<TcpStream>>),
Plain(TcpStream),
}
impl AsyncRead for MikrotikStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
match self.get_mut() {
MikrotikStream::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf),
MikrotikStream::Plain(s) => Pin::new(s).poll_read(cx, buf),
}
}
}
impl AsyncWrite for MikrotikStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
match self.get_mut() {
MikrotikStream::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf),
MikrotikStream::Plain(s) => Pin::new(s).poll_write(cx, buf),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
match self.get_mut() {
MikrotikStream::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx),
MikrotikStream::Plain(s) => Pin::new(s).poll_flush(cx),
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
match self.get_mut() {
MikrotikStream::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx),
MikrotikStream::Plain(s) => Pin::new(s).poll_shutdown(cx),
}
}
}
/// MikroTik RouterOS API client (supports both SSL and plain connections)
pub struct MikrotikClient {
stream: MikrotikStream,
}
impl MikrotikClient {
/// Connect to a MikroTik device over SSL (port 8729) and authenticate
pub async fn connect(
ip: &str,
port: u16,
username: &str,
password: &SecretString,
) -> MikrotikResult<Self> {
// Create TLS config that accepts any certificate (RouterOS uses self-signed)
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
// Connect TCP
let addr = format!("{}:{}", ip, port);
let tcp_stream = match timeout(CONNECTION_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(stream)) => stream,
Ok(Err(e)) => {
return Err(MikrotikError::ConnectionFailed(format!(
"TCP connect to {} failed: {}",
addr, e
)))
}
Err(_) => return Err(MikrotikError::Timeout),
};
// Upgrade to TLS - handle both IP addresses and hostnames
let domain = if let Ok(ip_addr) = ip.parse::<std::net::IpAddr>() {
tokio_rustls::rustls::pki_types::ServerName::IpAddress(
tokio_rustls::rustls::pki_types::IpAddr::from(ip_addr),
)
} else {
tokio_rustls::rustls::pki_types::ServerName::try_from(ip.to_string()).unwrap_or_else(
|_| {
tokio_rustls::rustls::pki_types::ServerName::try_from("mikrotik".to_string())
.unwrap()
},
)
};
let tls_stream =
match timeout(CONNECTION_TIMEOUT, connector.connect(domain, tcp_stream)).await {
Ok(Ok(stream)) => stream,
Ok(Err(e)) => {
return Err(MikrotikError::TlsError(format!(
"TLS handshake failed: {}",
e
)))
}
Err(_) => return Err(MikrotikError::Timeout),
};
let mut client = Self {
stream: MikrotikStream::Tls(Box::new(tls_stream)),
};
// Authenticate
client.authenticate(username, password).await?;
Ok(client)
}
/// Connect to a MikroTik device over plain TCP (port 8728) and authenticate
/// WARNING: Credentials are sent in plaintext - use only for testing or on trusted networks
pub async fn connect_plain(
ip: &str,
port: u16,
username: &str,
password: &SecretString,
) -> MikrotikResult<Self> {
// Connect TCP
let addr = format!("{}:{}", ip, port);
let tcp_stream = match timeout(CONNECTION_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(stream)) => stream,
Ok(Err(e)) => {
return Err(MikrotikError::ConnectionFailed(format!(
"TCP connect to {} failed: {}",
addr, e
)))
}
Err(_) => return Err(MikrotikError::Timeout),
};
let mut client = Self {
stream: MikrotikStream::Plain(tcp_stream),
};
// Authenticate
client.authenticate(username, password).await?;
Ok(client)
}
/// Authenticate with the RouterOS device
async fn authenticate(
&mut self,
username: &str,
password: &SecretString,
) -> MikrotikResult<()> {
let response = self
.execute(
"/login",
&[("name", username), ("password", password.expose())],
)
.await?;
if let Some(err) = response.error {
return Err(MikrotikError::AuthenticationFailed(err));
}
Ok(())
}
/// Execute a command and return the response
pub async fn execute(
&mut self,
command: &str,
args: &[(&str, &str)],
) -> MikrotikResult<CommandResponse> {
// Build and send the command
let mut words = vec![command.to_string()];
for (key, value) in args {
words.push(format!("={}={}", key, value));
}
self.send_sentence(&words).await?;
// Read response sentences until we get !done or !trap
self.read_response().await
}
/// Send a sentence (list of words) to the device
async fn send_sentence(&mut self, words: &[String]) -> MikrotikResult<()> {
let mut buf = Vec::new();
for word in words {
encode_word(&mut buf, word);
}
// Empty word to terminate sentence
encode_word(&mut buf, "");
self.stream
.write_all(&buf)
.await
.map_err(|e| MikrotikError::ConnectionFailed(format!("Write failed: {}", e)))?;
self.stream
.flush()
.await
.map_err(|e| MikrotikError::ConnectionFailed(format!("Flush failed: {}", e)))?;
Ok(())
}
/// Read response sentences from the device
async fn read_response(&mut self) -> MikrotikResult<CommandResponse> {
let mut response = CommandResponse::default();
loop {
let sentence = self.read_sentence().await?;
if sentence.is_empty() {
continue;
}
let first_word = &sentence[0];
match first_word.as_str() {
"!done" => {
// Parse any attributes in the done sentence
let attrs = parse_attributes(&sentence[1..]);
if !attrs.is_empty() {
response.sentences.push(Sentence {
attributes: attrs,
tag: None,
});
}
break;
}
"!trap" => {
// Error response
let attrs = parse_attributes(&sentence[1..]);
let error_msg = attrs
.get("message")
.cloned()
.unwrap_or_else(|| "Unknown error".to_string());
response.error = Some(error_msg);
// Continue reading until !done
}
"!re" => {
// Result sentence
let attrs = parse_attributes(&sentence[1..]);
response.sentences.push(Sentence {
attributes: attrs,
tag: None,
});
}
"!fatal" => {
let attrs = parse_attributes(&sentence[1..]);
let error_msg = attrs
.get("message")
.cloned()
.unwrap_or_else(|| "Fatal error".to_string());
return Err(MikrotikError::CommandFailed(error_msg));
}
_ => {
// Unknown sentence type, ignore
}
}
}
Ok(response)
}
/// Read a single sentence (list of words until empty word)
async fn read_sentence(&mut self) -> MikrotikResult<Vec<String>> {
let mut words = Vec::new();
loop {
let word = match timeout(READ_TIMEOUT, self.read_word()).await {
Ok(Ok(w)) => w,
Ok(Err(e)) => return Err(e),
Err(_) => return Err(MikrotikError::Timeout),
};
if word.is_empty() {
break;
}
words.push(word);
}
Ok(words)
}
/// Read a single word from the stream
async fn read_word(&mut self) -> MikrotikResult<String> {
let len = self.read_length().await?;
if len == 0 {
return Ok(String::new());
}
let mut buf = vec![0u8; len];
self.stream
.read_exact(&mut buf)
.await
.map_err(|e| MikrotikError::ConnectionFailed(format!("Read word failed: {}", e)))?;
String::from_utf8(buf)
.map_err(|e| MikrotikError::ProtocolError(format!("Invalid UTF-8: {}", e)))
}
/// Read the length prefix of a word
async fn read_length(&mut self) -> MikrotikResult<usize> {
let mut first = [0u8; 1];
self.stream
.read_exact(&mut first)
.await
.map_err(|e| MikrotikError::ConnectionFailed(format!("Read length failed: {}", e)))?;
let first_byte = first[0];
// RouterOS API length encoding:
// 0x00-0x7F: 1 byte, value is the length
// 0x80-0xBF: 2 bytes, length = ((b1 & 0x3F) << 8) | b2
// 0xC0-0xDF: 3 bytes, length = ((b1 & 0x1F) << 16) | (b2 << 8) | b3
// 0xE0-0xEF: 4 bytes, length = ((b1 & 0x0F) << 24) | (b2 << 16) | (b3 << 8) | b4
// 0xF0: 5 bytes, length = (b2 << 24) | (b3 << 16) | (b4 << 8) | b5
if first_byte < 0x80 {
Ok(first_byte as usize)
} else if first_byte < 0xC0 {
let mut buf = [0u8; 1];
self.stream.read_exact(&mut buf).await.map_err(|e| {
MikrotikError::ConnectionFailed(format!("Read length failed: {}", e))
})?;
Ok((((first_byte & 0x3F) as usize) << 8) | (buf[0] as usize))
} else if first_byte < 0xE0 {
let mut buf = [0u8; 2];
self.stream.read_exact(&mut buf).await.map_err(|e| {
MikrotikError::ConnectionFailed(format!("Read length failed: {}", e))
})?;
Ok((((first_byte & 0x1F) as usize) << 16)
| ((buf[0] as usize) << 8)
| (buf[1] as usize))
} else if first_byte < 0xF0 {
let mut buf = [0u8; 3];
self.stream.read_exact(&mut buf).await.map_err(|e| {
MikrotikError::ConnectionFailed(format!("Read length failed: {}", e))
})?;
Ok((((first_byte & 0x0F) as usize) << 24)
| ((buf[0] as usize) << 16)
| ((buf[1] as usize) << 8)
| (buf[2] as usize))
} else {
let mut buf = [0u8; 4];
self.stream.read_exact(&mut buf).await.map_err(|e| {
MikrotikError::ConnectionFailed(format!("Read length failed: {}", e))
})?;
Ok(((buf[0] as usize) << 24)
| ((buf[1] as usize) << 16)
| ((buf[2] as usize) << 8)
| (buf[3] as usize))
}
}
/// Close the connection
pub async fn close(&mut self) -> MikrotikResult<()> {
// Send quit command
let _ = self.execute("/quit", &[]).await;
Ok(())
}
}
/// Encode a word with its length prefix
fn encode_word(buf: &mut Vec<u8>, word: &str) {
let len = word.len();
encode_length(buf, len);
buf.extend_from_slice(word.as_bytes());
}
/// Encode a length using RouterOS API encoding
fn encode_length(buf: &mut Vec<u8>, len: usize) {
if len < 0x80 {
buf.push(len as u8);
} else if len < 0x4000 {
buf.push(((len >> 8) as u8) | 0x80);
buf.push((len & 0xFF) as u8);
} else if len < 0x200000 {
buf.push(((len >> 16) as u8) | 0xC0);
buf.push(((len >> 8) & 0xFF) as u8);
buf.push((len & 0xFF) as u8);
} else if len < 0x10000000 {
buf.push(((len >> 24) as u8) | 0xE0);
buf.push(((len >> 16) & 0xFF) as u8);
buf.push(((len >> 8) & 0xFF) as u8);
buf.push((len & 0xFF) as u8);
} else {
buf.push(0xF0);
buf.push(((len >> 24) & 0xFF) as u8);
buf.push(((len >> 16) & 0xFF) as u8);
buf.push(((len >> 8) & 0xFF) as u8);
buf.push((len & 0xFF) as u8);
}
}
/// Parse attributes from response words (=key=value format)
fn parse_attributes(words: &[String]) -> HashMap<String, String> {
let mut attrs = HashMap::new();
for word in words {
if let Some(kv) = word.strip_prefix('=') {
if let Some((key, value)) = kv.split_once('=') {
attrs.insert(key.to_string(), value.to_string());
}
}
}
attrs
}
/// Custom certificate verifier that accepts any certificate
/// RouterOS devices use self-signed certificates
#[derive(Debug)]
struct NoVerifier;
impl tokio_rustls::rustls::client::danger::ServerCertVerifier for NoVerifier {
fn verify_server_cert(
&self,
_end_entity: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
_intermediates: &[tokio_rustls::rustls::pki_types::CertificateDer<'_>],
_server_name: &tokio_rustls::rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: tokio_rustls::rustls::pki_types::UnixTime,
) -> Result<tokio_rustls::rustls::client::danger::ServerCertVerified, tokio_rustls::rustls::Error>
{
Ok(tokio_rustls::rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
_dss: &tokio_rustls::rustls::DigitallySignedStruct,
) -> Result<
tokio_rustls::rustls::client::danger::HandshakeSignatureValid,
tokio_rustls::rustls::Error,
> {
Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
_dss: &tokio_rustls::rustls::DigitallySignedStruct,
) -> Result<
tokio_rustls::rustls::client::danger::HandshakeSignatureValid,
tokio_rustls::rustls::Error,
> {
Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<tokio_rustls::rustls::SignatureScheme> {
vec![
tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA256,
tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA384,
tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA512,
tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA256,
tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA384,
tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA512,
tokio_rustls::rustls::SignatureScheme::ED25519,
]
}
}
#[cfg(test)]
mod tests {
use super::*;
// Tests for encode_length - the RouterOS API length encoding
#[test]
fn test_encode_length_single_byte() {
// Lengths 0-127 use single byte
let mut buf = Vec::new();
encode_length(&mut buf, 0);
assert_eq!(buf, vec![0x00]);
let mut buf = Vec::new();
encode_length(&mut buf, 1);
assert_eq!(buf, vec![0x01]);
let mut buf = Vec::new();
encode_length(&mut buf, 127);
assert_eq!(buf, vec![0x7F]);
}
#[test]
fn test_encode_length_two_bytes() {
// Lengths 128-16383 use two bytes (0x80-0xBF prefix)
let mut buf = Vec::new();
encode_length(&mut buf, 128);
assert_eq!(buf, vec![0x80, 0x80]);
let mut buf = Vec::new();
encode_length(&mut buf, 255);
assert_eq!(buf, vec![0x80, 0xFF]);
let mut buf = Vec::new();
encode_length(&mut buf, 256);
assert_eq!(buf, vec![0x81, 0x00]);
let mut buf = Vec::new();
encode_length(&mut buf, 16383);
assert_eq!(buf, vec![0xBF, 0xFF]);
}
#[test]
fn test_encode_length_three_bytes() {
// Lengths 16384-2097151 use three bytes (0xC0-0xDF prefix)
let mut buf = Vec::new();
encode_length(&mut buf, 16384);
assert_eq!(buf, vec![0xC0, 0x40, 0x00]);
let mut buf = Vec::new();
encode_length(&mut buf, 2097151);
assert_eq!(buf, vec![0xDF, 0xFF, 0xFF]);
}
#[test]
fn test_encode_length_four_bytes() {
// Lengths 2097152-268435455 use four bytes (0xE0-0xEF prefix)
let mut buf = Vec::new();
encode_length(&mut buf, 2097152);
assert_eq!(buf, vec![0xE0, 0x20, 0x00, 0x00]);
}
#[test]
fn test_encode_length_five_bytes() {
// Lengths >= 268435456 use five bytes (0xF0 prefix)
let mut buf = Vec::new();
encode_length(&mut buf, 268435456);
assert_eq!(buf, vec![0xF0, 0x10, 0x00, 0x00, 0x00]);
}
// Tests for encode_word
#[test]
fn test_encode_word_empty() {
let mut buf = Vec::new();
encode_word(&mut buf, "");
assert_eq!(buf, vec![0x00]); // Just length byte of 0
}
#[test]
fn test_encode_word_simple() {
let mut buf = Vec::new();
encode_word(&mut buf, "/login");
assert_eq!(buf, vec![0x06, b'/', b'l', b'o', b'g', b'i', b'n']);
}
#[test]
fn test_encode_word_with_argument() {
let mut buf = Vec::new();
encode_word(&mut buf, "=name=admin");
assert_eq!(
buf,
vec![0x0B, b'=', b'n', b'a', b'm', b'e', b'=', b'a', b'd', b'm', b'i', b'n']
);
}
// Tests for parse_attributes
#[test]
fn test_parse_attributes_empty() {
let words: Vec<String> = vec![];
let attrs = parse_attributes(&words);
assert!(attrs.is_empty());
}
#[test]
fn test_parse_attributes_single() {
let words = vec!["=name=MyRouter".to_string()];
let attrs = parse_attributes(&words);
assert_eq!(attrs.get("name"), Some(&"MyRouter".to_string()));
}
#[test]
fn test_parse_attributes_multiple() {
let words = vec![
"=name=MyRouter".to_string(),
"=model=RB450Gx4".to_string(),
"=version=7.10".to_string(),
];
let attrs = parse_attributes(&words);
assert_eq!(attrs.get("name"), Some(&"MyRouter".to_string()));
assert_eq!(attrs.get("model"), Some(&"RB450Gx4".to_string()));
assert_eq!(attrs.get("version"), Some(&"7.10".to_string()));
}
#[test]
fn test_parse_attributes_with_equals_in_value() {
// Value contains equals sign - split_once preserves the rest
let words = vec!["=comment=a=b=c".to_string()];
let attrs = parse_attributes(&words);
assert_eq!(attrs.get("comment"), Some(&"a=b=c".to_string()));
}
#[test]
fn test_parse_attributes_ignores_non_attribute() {
let words = vec![
"!re".to_string(), // Not an attribute
"=name=test".to_string(),
];
let attrs = parse_attributes(&words);
assert_eq!(attrs.len(), 1);
assert_eq!(attrs.get("name"), Some(&"test".to_string()));
}
#[test]
fn test_parse_attributes_empty_value() {
let words = vec!["=disabled=".to_string()];
let attrs = parse_attributes(&words);
assert_eq!(attrs.get("disabled"), Some(&"".to_string()));
}
}

6
src/mikrotik/mod.rs Normal file
View file

@ -0,0 +1,6 @@
mod client;
mod types;
pub use client::MikrotikClient;
#[allow(unused_imports)] // These will be used when integrating with websocket_client
pub use types::{CommandResponse, MikrotikError, MikrotikResult, SecretString, Sentence};

180
src/mikrotik/types.rs Normal file
View file

@ -0,0 +1,180 @@
use std::collections::HashMap;
/// A wrapper for sensitive strings (passwords, tokens) that prevents accidental logging.
/// - Debug and Display show "[REDACTED]" instead of the actual value
/// - The inner value is zeroed on drop
#[derive(Clone)]
pub struct SecretString(String);
impl SecretString {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
/// Access the secret value. Use sparingly and never log the result.
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[REDACTED]")
}
}
impl std::fmt::Display for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[REDACTED]")
}
}
impl Drop for SecretString {
fn drop(&mut self) {
// Zero out the memory before dropping
// SAFETY: We're replacing the string contents with zeros
unsafe {
let bytes = self.0.as_bytes_mut();
std::ptr::write_bytes(bytes.as_mut_ptr(), 0, bytes.len());
}
}
}
/// Error types for MikroTik operations
#[derive(Debug)]
pub enum MikrotikError {
ConnectionFailed(String),
AuthenticationFailed(String),
CommandFailed(String),
Timeout,
TlsError(String),
ProtocolError(String),
}
impl std::fmt::Display for MikrotikError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConnectionFailed(msg) => write!(f, "Connection failed: {}", msg),
Self::AuthenticationFailed(msg) => write!(f, "Authentication failed: {}", msg),
Self::CommandFailed(msg) => write!(f, "Command failed: {}", msg),
Self::Timeout => write!(f, "Operation timed out"),
Self::TlsError(msg) => write!(f, "TLS error: {}", msg),
Self::ProtocolError(msg) => write!(f, "Protocol error: {}", msg),
}
}
}
impl std::error::Error for MikrotikError {}
pub type MikrotikResult<T> = Result<T, MikrotikError>;
/// A sentence from a RouterOS API response (key-value pairs)
#[derive(Debug, Clone, Default)]
pub struct Sentence {
pub attributes: HashMap<String, String>,
#[allow(dead_code)] // Reserved for future use with tagged API commands
pub tag: Option<String>,
}
/// Response from a RouterOS API command
#[derive(Debug, Clone, Default)]
pub struct CommandResponse {
pub sentences: Vec<Sentence>,
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mikrotik_error_display_connection_failed() {
let err = MikrotikError::ConnectionFailed("refused".to_string());
assert_eq!(format!("{}", err), "Connection failed: refused");
}
#[test]
fn test_mikrotik_error_display_auth_failed() {
let err = MikrotikError::AuthenticationFailed("bad password".to_string());
assert_eq!(format!("{}", err), "Authentication failed: bad password");
}
#[test]
fn test_mikrotik_error_display_command_failed() {
let err = MikrotikError::CommandFailed("no such command".to_string());
assert_eq!(format!("{}", err), "Command failed: no such command");
}
#[test]
fn test_mikrotik_error_display_timeout() {
let err = MikrotikError::Timeout;
assert_eq!(format!("{}", err), "Operation timed out");
}
#[test]
fn test_mikrotik_error_display_tls_error() {
let err = MikrotikError::TlsError("certificate invalid".to_string());
assert_eq!(format!("{}", err), "TLS error: certificate invalid");
}
#[test]
fn test_mikrotik_error_display_protocol_error() {
let err = MikrotikError::ProtocolError("unexpected response".to_string());
assert_eq!(format!("{}", err), "Protocol error: unexpected response");
}
#[test]
fn test_mikrotik_error_is_error_trait() {
let err: &dyn std::error::Error = &MikrotikError::Timeout;
assert_eq!(format!("{}", err), "Operation timed out");
}
#[test]
fn test_sentence_default() {
let sentence = Sentence::default();
assert!(sentence.attributes.is_empty());
assert!(sentence.tag.is_none());
}
#[test]
fn test_command_response_default() {
let response = CommandResponse::default();
assert!(response.sentences.is_empty());
assert!(response.error.is_none());
}
#[test]
fn test_secret_string_expose() {
let secret = SecretString::new("my_password");
assert_eq!(secret.expose(), "my_password");
}
#[test]
fn test_secret_string_debug_is_redacted() {
let secret = SecretString::new("my_password");
let debug_output = format!("{:?}", secret);
assert_eq!(debug_output, "[REDACTED]");
assert!(!debug_output.contains("my_password"));
}
#[test]
fn test_secret_string_display_is_redacted() {
let secret = SecretString::new("my_password");
let display_output = format!("{}", secret);
assert_eq!(display_output, "[REDACTED]");
assert!(!display_output.contains("my_password"));
}
#[test]
fn test_secret_string_clone() {
let secret = SecretString::new("my_password");
let cloned = secret.clone();
assert_eq!(cloned.expose(), "my_password");
}
#[test]
fn test_secret_string_empty() {
let secret = SecretString::new("");
assert!(secret.expose().is_empty());
}
}