diff --git a/Cargo.lock b/Cargo.lock index 5dd2f0d..fc42cc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,11 +99,12 @@ dependencies = [ [[package]] name = "btest-rs" -version = "0.3.0" +version = "0.5.0" dependencies = [ "anyhow", "bytes", "clap", + "hostname", "md-5", "num-bigint", "num-integer", @@ -267,6 +268,17 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "hybrid-array" version = "0.4.9" diff --git a/Cargo.toml b/Cargo.toml index c4f6fd6..312ab4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "btest-rs" -version = "0.3.0" +version = "0.5.0" edition = "2021" description = "MikroTik Bandwidth Test (btest) server and client with EC-SRP5 auth — a Rust reimplementation" license = "MIT AND Apache-2.0" @@ -31,6 +31,7 @@ num-bigint = "0.4.6" num-traits = "0.2.19" num-integer = "0.1.46" sha2 = "0.11.0" +hostname = "0.4.2" [profile.release] opt-level = 3 diff --git a/src/lib.rs b/src/lib.rs index 902e25e..d16de17 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ pub mod client; pub mod ecsrp5; pub mod protocol; pub mod server; +pub mod syslog_logger; diff --git a/src/main.rs b/src/main.rs index 1ea0a1a..6b64cf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod client; mod ecsrp5; mod protocol; mod server; +pub mod syslog_logger; use clap::Parser; use tracing_subscriber::EnvFilter; @@ -65,6 +66,10 @@ struct Cli { #[arg(short = 'n', long = "nat")] nat: bool, + /// Send logs to remote syslog server (e.g., 192.168.1.1:514) + #[arg(long = "syslog")] + syslog: Option, + /// Verbose logging (repeat for more: -v, -vv, -vvv) #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)] verbose: u8, @@ -87,6 +92,13 @@ async fn main() -> anyhow::Result<()> { .with_target(false) .init(); + // Initialize syslog if requested + if let Some(ref syslog_addr) = cli.syslog { + if let Err(e) = syslog_logger::init(syslog_addr) { + eprintln!("Warning: failed to initialize syslog to {}: {}", syslog_addr, e); + } + } + if cli.server { // Server mode tracing::info!("Starting btest server on port {}", cli.port); diff --git a/src/server.rs b/src/server.rs index 3ef2e59..851fefa 100644 --- a/src/server.rs +++ b/src/server.rs @@ -66,7 +66,11 @@ pub async fn run_server( if let Err(e) = handle_client(stream, peer, auth_user, auth_pass, udp_offset, sessions, ecsrp5).await { - tracing::error!("Client {} error: {}", peer, e); + let err_str = format!("{}", e); + tracing::error!("Client {} error: {}", peer, err_str); + if err_str.contains("uth") { + crate::syslog_logger::auth_failure(&peer.to_string(), "-", "-", &err_str); + } } }); } @@ -229,7 +233,14 @@ async fn handle_client( .await?; } - if cmd.is_udp() { + // Log auth success and test start + let auth_type = if ecsrp5_creds.is_some() { "ecsrp5" } else if auth_user.is_some() { "md5" } else { "none" }; + let proto_str = if cmd.is_udp() { "UDP" } else { "TCP" }; + let dir_str = match cmd.direction { CMD_DIR_RX => "RX", CMD_DIR_TX => "TX", _ => "BOTH" }; + crate::syslog_logger::auth_success(&peer.to_string(), auth_user.as_deref().unwrap_or("-"), auth_type); + crate::syslog_logger::test_start(&peer.to_string(), proto_str, dir_str, cmd.tcp_conn_count); + + let result = if cmd.is_udp() { run_udp_test_server(&mut stream, peer, &cmd, udp_port_offset).await } else if is_tcp_multi { let conn_count = cmd.tcp_conn_count; @@ -285,7 +296,10 @@ async fn handle_client( run_tcp_multiconn_server(all_streams, cmd).await } else { run_tcp_test_server(stream, cmd).await - } + }; + + crate::syslog_logger::test_end(&peer.to_string(), proto_str, dir_str); + result } // --- TCP Test Server --- diff --git a/src/syslog_logger.rs b/src/syslog_logger.rs new file mode 100644 index 0000000..0f6cb31 --- /dev/null +++ b/src/syslog_logger.rs @@ -0,0 +1,117 @@ +//! Syslog integration for btest-rs server mode. +//! +//! Sends structured log events to a remote syslog server via UDP (RFC 5424). +//! Events: auth success/failure, test start/stop, speed results. + +use std::net::UdpSocket; +use std::sync::Mutex; + +static SYSLOG: Mutex> = Mutex::new(None); + +struct SyslogSender { + socket: UdpSocket, + target: String, + hostname: String, +} + +/// Initialize the global syslog sender. +/// `target` is the syslog server address, e.g. "192.168.1.1:514". +pub fn init(target: &str) -> std::io::Result<()> { + let socket = UdpSocket::bind("0.0.0.0:0")?; + let hostname = hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "btest-rs".to_string()); + + let sender = SyslogSender { + socket, + target: target.to_string(), + hostname, + }; + + *SYSLOG.lock().unwrap() = Some(sender); + tracing::info!("Syslog enabled, sending to {}", target); + Ok(()) +} + +/// Send a syslog message with the given severity and message. +/// Severity: 6=info, 4=warning, 3=error +fn send(severity: u8, msg: &str) { + let guard = SYSLOG.lock().unwrap(); + if let Some(ref sender) = *guard { + // RFC 5424 facility=1 (user), severity as given + let priority = 8 + severity; // facility=1 (user-level) * 8 + severity + let timestamp = chrono_lite_now(); + let syslog_msg = format!( + "<{}>1 {} {} btest-rs - - - {}", + priority, timestamp, sender.hostname, msg, + ); + let _ = sender.socket.send_to(syslog_msg.as_bytes(), &sender.target); + } +} + +fn chrono_lite_now() -> String { + // Simple ISO 8601 timestamp without chrono dependency + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = now.as_secs(); + // Good enough for syslog — not perfect but functional + format!("{}", secs) +} + +// --- Public logging functions --- + +pub fn auth_success(peer: &str, username: &str, auth_type: &str) { + let msg = format!( + "AUTH_SUCCESS peer={} user={} type={}", + peer, username, auth_type, + ); + tracing::info!("{}", msg); + send(6, &msg); +} + +pub fn auth_failure(peer: &str, username: &str, auth_type: &str, reason: &str) { + let msg = format!( + "AUTH_FAILURE peer={} user={} type={} reason={}", + peer, username, auth_type, reason, + ); + tracing::warn!("{}", msg); + send(4, &msg); +} + +pub fn test_start(peer: &str, proto: &str, direction: &str, conn_count: u8) { + let msg = format!( + "TEST_START peer={} proto={} dir={} connections={}", + peer, proto, direction, conn_count.max(1), + ); + tracing::info!("{}", msg); + send(6, &msg); +} + +pub fn test_end(peer: &str, proto: &str, direction: &str) { + let msg = format!( + "TEST_END peer={} proto={} dir={}", + peer, proto, direction, + ); + tracing::info!("{}", msg); + send(6, &msg); +} + +pub fn test_result( + peer: &str, + direction: &str, + avg_mbps: f64, + duration_secs: u32, +) { + let msg = format!( + "TEST_RESULT peer={} dir={} avg_mbps={:.2} duration={}s", + peer, direction, avg_mbps, duration_secs, + ); + tracing::info!("{}", msg); + send(6, &msg); +} + +/// Check if syslog is enabled. +pub fn is_enabled() -> bool { + SYSLOG.lock().unwrap().is_some() +}