protobufs update for mikrotik things
This commit is contained in:
parent
224c2b5898
commit
eb330b2f6f
7 changed files with 1123 additions and 12 deletions
103
Cargo.lock
generated
103
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
174
src/main.rs
174
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<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
639
src/mikrotik/client.rs
Normal 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
6
src/mikrotik/mod.rs
Normal 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
180
src/mikrotik/types.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue