From eb330b2f6f2d335773807b9ff3c0d7672e492dda Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 2 Feb 2026 12:09:29 -0600 Subject: [PATCH] protobufs update for mikrotik things --- Cargo.lock | 103 ++++++- Cargo.toml | 3 +- proto/agent.proto | 30 ++ src/main.rs | 174 ++++++++++- src/mikrotik/client.rs | 639 +++++++++++++++++++++++++++++++++++++++++ src/mikrotik/mod.rs | 6 + src/mikrotik/types.rs | 180 ++++++++++++ 7 files changed, 1123 insertions(+), 12 deletions(-) create mode 100644 src/mikrotik/client.rs create mode 100644 src/mikrotik/mod.rs create mode 100644 src/mikrotik/types.rs diff --git a/Cargo.lock b/Cargo.lock index 42feee2..cb51779 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 6073673..d4a2c49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/proto/agent.proto b/proto/agent.proto index e7ea17d..3229cca 100644 --- a/proto/agent.proto +++ b/proto/agent.proto @@ -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 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 attributes = 1; +} diff --git a/src/main.rs b/src/main.rs index f487ad7..ae968e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, /// Agent authentication token - #[arg(long, env = "TOWEROPS_AGENT_TOKEN")] - token: String, + #[arg( + long, + env = "TOWEROPS_AGENT_TOKEN", + required_unless_present = "mikrotik_test" + )] + token: Option, /// 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, + + /// 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)] diff --git a/src/mikrotik/client.rs b/src/mikrotik/client.rs new file mode 100644 index 0000000..266396e --- /dev/null +++ b/src/mikrotik/client.rs @@ -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>), + Plain(TcpStream), +} + +impl AsyncRead for MikrotikStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + 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> { + 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> { + 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> { + 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 { + // 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::() { + 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 { + // 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 { + // 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 { + 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> { + 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 { + 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 { + 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, 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, 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 { + 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 + { + 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 { + 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 = 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())); + } +} diff --git a/src/mikrotik/mod.rs b/src/mikrotik/mod.rs new file mode 100644 index 0000000..ab91e60 --- /dev/null +++ b/src/mikrotik/mod.rs @@ -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}; diff --git a/src/mikrotik/types.rs b/src/mikrotik/types.rs new file mode 100644 index 0000000..8877416 --- /dev/null +++ b/src/mikrotik/types.rs @@ -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) -> 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 = Result; + +/// A sentence from a RouterOS API response (key-value pairs) +#[derive(Debug, Clone, Default)] +pub struct Sentence { + pub attributes: HashMap, + #[allow(dead_code)] // Reserved for future use with tagged API commands + pub tag: Option, +} + +/// Response from a RouterOS API command +#[derive(Debug, Clone, Default)] +pub struct CommandResponse { + pub sentences: Vec, + pub error: Option, +} + +#[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()); + } +}