├── .vscode └── settings.json ├── LAN-ui-screenshot.png ├── LAN ├── .gitignore ├── Cargo.toml ├── src │ ├── config.rs │ └── main.rs └── systemd │ └── network-monitor.service ├── README.md └── WAN ├── html ├── index.html └── ping │ └── data.txt └── nginx └── ping.conf /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "bytecode", 4 | "threadlocal" 5 | ] 6 | } -------------------------------------------------------------------------------- /LAN-ui-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cjjeakle/network-monitor/99b8f49b8a5157eec9244c0c9b9c4d25ceff1d72/LAN-ui-screenshot.png -------------------------------------------------------------------------------- /LAN/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb -------------------------------------------------------------------------------- /LAN/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "network-monitor" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | actix-web = "4.0.0" 10 | byteorder = "1.4.3" 11 | chrono = "0.4" 12 | dns-lookup = "1.0.8" 13 | libc = "0.2.124" 14 | parse_duration = "2.1.1" 15 | rand = "0.8.5" 16 | socket2 = { version = "0.4.4", features = ["all"] } 17 | -------------------------------------------------------------------------------- /LAN/src/config.rs: -------------------------------------------------------------------------------- 1 | pub const SEC_BETWEEN_PINGS: u64 = 10; 2 | pub const PING_TIMEOUT_MSEC: u64 = 1_000; 3 | pub const MAX_ENTRIES_SAVED: usize = 7 * 24 * 60 * (60 / SEC_BETWEEN_PINGS as usize); // 1 week 4 | pub const WEB_UI_PORT: u16 = 8180; 5 | -------------------------------------------------------------------------------- /LAN/src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(cursor_remaining)] 2 | #![feature(maybe_uninit_slice)] 3 | 4 | use actix_web::{ 5 | http::header::ContentType, web, web::Query, App, HttpRequest, HttpResponse, HttpServer, 6 | }; 7 | use byteorder::{BigEndian, ReadBytesExt}; 8 | use chrono::Duration as chrono_Duration; 9 | use chrono::{DateTime, Datelike, Local, Timelike, Utc}; 10 | use dns_lookup::lookup_host; 11 | use parse_duration::parse; 12 | use rand::Rng; 13 | use socket2::{Domain, Protocol, Socket, Type}; 14 | use std::cmp; 15 | use std::collections::BTreeMap; 16 | use std::collections::HashMap; 17 | use std::io::Cursor; 18 | use std::mem::MaybeUninit; 19 | use std::net::{IpAddr, Ipv4Addr, SocketAddr}; 20 | use std::os::unix::io::AsRawFd; 21 | use std::sync::{Arc, Mutex}; 22 | use std::thread; 23 | use std::time::Duration; 24 | 25 | mod config; 26 | 27 | const IP_HEADER_SIZE: usize = 20; 28 | 29 | struct PingData { 30 | hostnames_in_order: Vec, 31 | data: BTreeMap, Duration>>, 32 | } 33 | impl PingData { 34 | fn add_hostname(&mut self, hostname: &str) { 35 | self.data.insert(hostname.to_string(), BTreeMap::new()); 36 | } 37 | fn add_entry(&mut self, hostname: &String, when: DateTime, how_long: Duration) { 38 | let ping_results = self.data.get_mut(hostname).unwrap(); 39 | if ping_results.len() >= config::MAX_ENTRIES_SAVED { 40 | ping_results.pop_first(); // Drop the oldest entry 41 | } 42 | ping_results.insert(when, how_long); 43 | } 44 | } 45 | 46 | #[derive(Debug)] 47 | struct IcmpEchoMessage { 48 | msg_type: u8, 49 | code: u8, 50 | checksum: u16, 51 | identifier: u16, 52 | sequence_number: u16, 53 | data: [u8; 56], // 56 bytes, to bring the message up to the standard 64B. 54 | } 55 | impl IcmpEchoMessage { 56 | fn new(identifier: u16, sequence_number: u16) -> IcmpEchoMessage { 57 | // Allocate an ICMP message for an ECHO, use boring default values. 58 | let mut message = IcmpEchoMessage { 59 | // https://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml 60 | // ECHO = 8, ECHO_REPLY = 0 61 | msg_type: 8, 62 | code: 0, 63 | checksum: 0, 64 | identifier: identifier, 65 | sequence_number: sequence_number, 66 | data: [0; 56], 67 | }; 68 | // Set some values in the data, just for fun. 69 | // A nice plus: this exercises the checksum's carry-out. 70 | for i in 0..56 { 71 | message.data[i] = 0xFF - i as u8; 72 | } 73 | // Set the checksum. 74 | message.populate_checksum(); 75 | return message; 76 | } 77 | 78 | // Takes the sum of this message as 16-bit words, adds back in any carry out, 79 | // takes the 1's complement. Then sets the resulting value in the checksum field. 80 | // http://www.faqs.org/rfcs/rfc1071.html is very helpful to understand the checksum's computation. 81 | fn populate_checksum(&mut self) { 82 | // Accumulate using a 32-bit variable so overflow is graceful. 83 | let mut sum: u32 = 0; 84 | // Take the sum of the message 16 bits at a time. 85 | let mut serialized = Cursor::new(self.serialize()); 86 | while !serialized.is_empty() { 87 | sum += u32::from(serialized.read_u16::().unwrap()); 88 | } 89 | // So long as there is overflow, add it back into the lower 16 bits. 90 | while (sum >> 16) > 0 { 91 | sum = (sum & 0xFFFF) + (sum >> 16); 92 | } 93 | // Take the 1's complement of the sum. 94 | sum = !sum; 95 | // Truncate to 16 bits. 96 | self.checksum = sum as u16; 97 | } 98 | 99 | // Marshall into a buffer using network byte order (big endian). 100 | fn serialize(&self) -> [u8; std::mem::size_of::()] { 101 | let mut buf_be: [u8; std::mem::size_of::()] = 102 | [0; std::mem::size_of::()]; 103 | buf_be[0] = self.msg_type; 104 | buf_be[1] = self.code; 105 | buf_be[2] = self.checksum.to_be_bytes()[0]; 106 | buf_be[3] = self.checksum.to_be_bytes()[1]; 107 | buf_be[4] = self.identifier.to_be_bytes()[0]; 108 | buf_be[5] = self.identifier.to_be_bytes()[1]; 109 | buf_be[6] = self.sequence_number.to_be_bytes()[0]; 110 | buf_be[7] = self.sequence_number.to_be_bytes()[1]; 111 | let buf_data_start = 8; 112 | for data_idx in 0..self.data.len() { 113 | buf_be[buf_data_start + data_idx] = self.data[data_idx]; 114 | } 115 | return buf_be; 116 | } 117 | 118 | // Marshall out of a network byte order (big endian) buffer. 119 | fn from(buf_be: &[u8]) -> IcmpEchoMessage { 120 | let mut buf_be_iter = Cursor::new(buf_be); 121 | let mut message = IcmpEchoMessage { 122 | msg_type: buf_be_iter.read_u8().unwrap(), 123 | code: buf_be_iter.read_u8().unwrap(), 124 | checksum: buf_be_iter.read_u16::().unwrap(), 125 | identifier: buf_be_iter.read_u16::().unwrap(), 126 | sequence_number: buf_be_iter.read_u16::().unwrap(), 127 | data: [0; 56], 128 | }; 129 | for data_offset in 0..message.data.len() { 130 | message.data[data_offset] = buf_be_iter.read_u8().unwrap(); 131 | } 132 | return message; 133 | } 134 | } 135 | 136 | // Configures `socket` to only listen for ICMP Echo Reply messages. 137 | // Also applies a filter so `socket` will only listen for 64B ICMP Echo Reply messages from 138 | // `src_ip_v4` that are annotated with ICMP ID == `echo_id` and ICMP Code == 0. 139 | fn filter_icmp_replies(socket: &Socket, src_ip_v4: Ipv4Addr, icmp_msg_size: usize, echo_id: u16) { 140 | // Filter so the socket will only recv Echo Reply ICMP messages. 141 | // Echo Reply is type 0. 142 | let icmp_types_to_listen_for_bitmask: libc::c_int = !(1 << 0/* ICMP Echo Reply */); 143 | unsafe { 144 | libc::setsockopt( 145 | socket.as_raw_fd(), 146 | libc::SOL_RAW, 147 | 1, /* ICMP_FILTER */ 148 | &icmp_types_to_listen_for_bitmask as *const libc::c_int as *const libc::c_void, 149 | 4, /* Size of the bitmask, it's 32 bits */ 150 | ); 151 | } 152 | // Use libc::BPF to filter yet further. Only recv 84B ICMP Echo Reply packets 153 | // (20B IP header + 64B ICMP message) that are from `src_ip_v4` and annotated with `echo_id`. 154 | // 155 | // About BPF and Packet memory layout: 156 | // https://www.kernel.org/doc/Documentation/networking/filter.txt 157 | // https://en.wikipedia.org/wiki/IPv4#/media/File:IPv4_Packet-en.svg 158 | // Additional reading that can be helpful but doesn't apply to the messages in this program: 159 | // https://en.wikipedia.org/wiki/Ethernet_frame 160 | // 161 | // The bytecode we use below was generated and tweaked starting with output from `tcpdump`: 162 | // `sudo tcpdump icmp and src 192.168.1.1 and ip[3] == 84 and icmp[icmptype] == 0 and icmp[icmpcode] == 0 and icmp[4:2] == 0x00FF -dd` 163 | // I used tcpdump's `-dd` output. You can use regex-replace to make that output into valid Rust: 164 | // find: `\{ (.*), (.*), (.*), (.*) \},` -> replace: `libc::sock_filter { code: $1, jt: $2, jf: $3, k: $4 },` 165 | // 166 | // Notes on using tcpdump's generated bytecode: 167 | // * tcpdump generates BPF bytecode targeting RAW AF_PACKET (the low-level packet interface) sockets. 168 | // This program uses Socket2's Domain::IPV4, which maps to AF_INET (a slightly hihger-level socket 169 | // type for IPv4 messaging). This socket type's higher level of abstraction means the kernel handles 170 | // a bit more and hides a bit more. 171 | // Practically speaking, this means BPF registered in this application will see fewer headers than TCP Dump's 172 | // BFP bytecode assumes are available. As a consequence, the BPF bytecode we get from TCP dump needs to be 173 | // modified before it can be used in this application. We need to remove any byte code interacting with the 174 | // Ethernet header (the first 14B) and all subsequent offsets need to be reduced by 14B. 175 | // * We can simplify out some of the checks in the BPF the command above generates, too. For example, if you 176 | // look in ping.c, the BPF used doesn't check the contents of the flags and fragment offset field of the 177 | // IP header (the 2B at offset 6). We can do the same and save a couple instructions as a consequence. 178 | // * We patch in variables like `dest_ip_v4` where appropriate. 179 | let mut bpf_bytecode = [ 180 | // Load 1B at offset 9 in the IP header (Protocol) 181 | libc::sock_filter { 182 | code: 0x30, /*ldb*/ 183 | jt: 0, 184 | jf: 0, 185 | k: 0x00000009, 186 | }, 187 | // Continue if the protocol is ICMP, otherwise exit. 188 | libc::sock_filter { 189 | code: 0x15, /*jeq*/ 190 | jt: 0, 191 | jf: 11, 192 | k: 0x00000001, /*IPPROTO_ICMP*/ 193 | }, 194 | // Load 4B at offset 12 in the IP header (Source Address). 195 | libc::sock_filter { 196 | code: 0x20, /*ld*/ 197 | jt: 0, 198 | jf: 0, 199 | k: 0x0000000c, 200 | }, 201 | // Continue if it's equal to the IP we are listening for, otherwise exit. 202 | libc::sock_filter { 203 | code: 0x15, /*jeq*/ 204 | jt: 0, 205 | jf: 9, 206 | k: u32::from_be_bytes(src_ip_v4.octets()), 207 | }, 208 | // Load 2B at offset 2 in the IP header (Total Length). 209 | libc::sock_filter { 210 | code: 0x28, /*ldh*/ 211 | jt: 0, 212 | jf: 0, 213 | k: 0x00000002, 214 | }, 215 | // Continue if the IP-layer message is 84B, otherwise exit. 216 | libc::sock_filter { 217 | code: 0x15, /*jeq*/ 218 | jt: 0, 219 | jf: 7, 220 | k: (IP_HEADER_SIZE + icmp_msg_size).try_into().unwrap(), 221 | }, 222 | // Load byte at offset 0 in the ICMP header (20B IP header + 0), the ICMP Type. 223 | libc::sock_filter { 224 | code: 0x30, /*ldb*/ 225 | jt: 0, 226 | jf: 0, 227 | k: 0x00000014, 228 | }, 229 | // Continue if the ICMP Type is 0 (Echo Reply), otherwise exit. 230 | libc::sock_filter { 231 | code: 0x15, /*jeq*/ 232 | jt: 0, 233 | jf: 5, 234 | k: 0x00000000, /*ICMP_ECHOREPLY*/ 235 | }, 236 | // Load byte at offset 1 in the ICMP header (20+1), the ICMP code. 237 | libc::sock_filter { 238 | code: 0x30, /*ldb*/ 239 | jt: 0, 240 | jf: 0, 241 | k: 0x00000016, 242 | }, 243 | // Continue if the ICMP Code is 0, otherwise exit. 244 | libc::sock_filter { 245 | code: 0x15, /*jeq*/ 246 | jt: 0, 247 | jf: 3 * 0, 248 | k: 0x00000000, 249 | }, 250 | // Load 2B at offset 4 in the ICMP header, the ICMP ID. 251 | libc::sock_filter { 252 | code: 0x28, /*ldh*/ 253 | jt: 0, 254 | jf: 0, 255 | k: 0x00000018, 256 | }, 257 | // Continue if the loaded message ID matches the required ID, otherwise exit. 258 | libc::sock_filter { 259 | code: 0x15, /*jeq*/ 260 | jt: 0, 261 | jf: 1, 262 | k: echo_id.into(), 263 | }, 264 | // Indicate success, the criteria were fulfilled. 265 | // The message's length will be truncated to the returned value, we return the full length 266 | // to keep the message intact. 267 | libc::sock_filter { 268 | code: 0x6, /*ret*/ 269 | jt: 0, 270 | jf: 0, 271 | k: (IP_HEADER_SIZE + icmp_msg_size).try_into().unwrap(), 272 | }, 273 | // Indicate we didn't fulfill the criteria. 274 | libc::sock_filter { 275 | code: 0x6, /*ret*/ 276 | jt: 0, 277 | jf: 0, 278 | k: 0x00000000, 279 | }, 280 | ]; 281 | let filter_program = libc::sock_fprog { 282 | len: bpf_bytecode.len().try_into().unwrap(), 283 | filter: bpf_bytecode.as_mut_ptr() as *mut libc::sock_filter, 284 | }; 285 | let res: i32; 286 | unsafe { 287 | res = libc::setsockopt( 288 | socket.as_raw_fd(), 289 | libc::SOL_SOCKET, 290 | libc::SO_ATTACH_FILTER, 291 | &filter_program as *const libc::sock_fprog as *const libc::c_void, 292 | std::mem::size_of::().try_into().unwrap(), 293 | ); 294 | } 295 | if res != 0 { 296 | eprintln!( 297 | "\nFailed to apply BPF filter for IP {} and ID {} - ret {} errno {}\n", 298 | src_ip_v4, 299 | echo_id, 300 | res, 301 | std::io::Error::last_os_error().raw_os_error().unwrap() 302 | ); 303 | // We can't just panic, it'll just crash the thread. Exit the whole process. 304 | std::process::exit(0x1); 305 | } 306 | } 307 | 308 | #[actix_web::main] 309 | async fn main() -> std::io::Result<()> { 310 | // Skip the program name, all other command line args are hosts to ping. 311 | let hostnames_to_ping: Vec = std::env::args().skip(1).collect(); 312 | 313 | let ping_data = Arc::new(Mutex::new(PingData { 314 | hostnames_in_order: hostnames_to_ping.clone(), 315 | data: BTreeMap::new(), 316 | })); 317 | 318 | if hostnames_to_ping.is_empty() { 319 | panic!("\nPlease provide hostnames to ping as command line args.\n"); 320 | } 321 | 322 | for hostname in hostnames_to_ping { 323 | ping_data.lock().unwrap().add_hostname(&hostname); 324 | let hostname_threadlocal = hostname.to_string(); 325 | let ping_data_threadlocal = ping_data.clone(); 326 | thread::spawn(move || repeatedly_ping(hostname_threadlocal, ping_data_threadlocal)); 327 | } 328 | 329 | let ping_data_read_clone = web::Data::new(Arc::clone(&ping_data)); 330 | return HttpServer::new(move || { 331 | App::new() 332 | .app_data(ping_data_read_clone.clone()) 333 | .route("/", web::get().to(index)) 334 | }) 335 | .bind(("0.0.0.0", config::WEB_UI_PORT))? 336 | .run() 337 | .await; 338 | } 339 | 340 | // Repeatedly pings a destination hostname. 341 | fn repeatedly_ping(hostname: String, ping_data: Arc>) { 342 | // Set up this thread's ping metadata. 343 | let unique_threadlocal_id: u16 = rand::thread_rng().gen::(); 344 | let mut sequence_number: u16 = 0; 345 | // Determine destination. 346 | // Only IPv4 is supported, the BPF filter and various header parsing depends on it. 347 | let dest_ip_v4 = *lookup_host(&hostname) 348 | .unwrap() 349 | .into_iter() 350 | .filter(|ip| match ip { 351 | IpAddr::V4(_) => true, 352 | _ => false, 353 | }) 354 | .map(|ip| match ip { 355 | IpAddr::V4(ip_v4) => ip_v4, 356 | _ => unreachable!(), 357 | }) 358 | .collect::>() 359 | .first() 360 | .unwrap(); 361 | let dest_addr_v1 = SocketAddr::new(IpAddr::V4(dest_ip_v4), 0); 362 | let dest_addr_v2: socket2::SockAddr = dest_addr_v1.into(); 363 | // Set up a socket. 364 | // This is a raw ICMPv4 socket, it will recv all ICMP traffic to this host. 365 | // We will apply filters to make it behave more reasonably. 366 | let socket = Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::ICMPV4)).unwrap(); 367 | // Apply filters so we only recv and process relevant packets. 368 | filter_icmp_replies( 369 | &socket, 370 | dest_ip_v4, 371 | std::mem::size_of::(), 372 | unique_threadlocal_id, 373 | ); 374 | // Set the ping timeout. 375 | let ping_timeout = Duration::from_millis(config::PING_TIMEOUT_MSEC); 376 | socket.set_write_timeout(Some(ping_timeout)).unwrap(); 377 | socket.set_read_timeout(Some(ping_timeout)).unwrap(); 378 | // Log important details. 379 | println!( 380 | "Pinging host {} (IP: {}) using ID {}", 381 | hostname, dest_ip_v4, unique_threadlocal_id 382 | ); 383 | // Ping repeatedly. 384 | loop { 385 | sequence_number += 1; 386 | let start_time = Utc::now(); 387 | let deadline = start_time + chrono_Duration::from_std(ping_timeout).unwrap(); 388 | // Construct an ICMP Ping message. 389 | let request = IcmpEchoMessage::new(unique_threadlocal_id, sequence_number); 390 | // Send the ping. 391 | let send_res = socket.send_to(&request.serialize(), &dest_addr_v2); 392 | match send_res { 393 | Ok(_size) => {} 394 | Err(err) => eprintln!("Error while sending to {} - {:?}", dest_ip_v4, err), 395 | } 396 | // Wait for the response. 397 | // We are using a raw ICMP socket. Even with filters may see ICMPv4 Echo Replies meant for other 398 | // threads or processes. Thus, we recv in a loop until our remote's response is the one we recv. 399 | let mut response_recvd: bool = false; 400 | while Utc::now() < deadline && !response_recvd { 401 | let mut recv_buf = [MaybeUninit::new(0); 1024]; 402 | let recv_res = socket.recv_from(&mut recv_buf); 403 | response_recvd = match recv_res { 404 | Ok((size, _origin_addr)) => { 405 | let response_buf = &unsafe { MaybeUninit::slice_assume_init_ref(&recv_buf) } 406 | [IP_HEADER_SIZE..size]; 407 | let response = IcmpEchoMessage::from(&response_buf); 408 | let matching_response_found: bool = response.msg_type == 0 409 | && response.code == 0 410 | && response.identifier == unique_threadlocal_id 411 | && response.sequence_number == sequence_number; 412 | if !matching_response_found { 413 | eprintln!( 414 | "An unexpected message got through the BPF filter: {:?}. Expected code={} id={} seq={}.", 415 | response, 416 | 0, 417 | unique_threadlocal_id, 418 | sequence_number 419 | ); 420 | } 421 | matching_response_found 422 | } 423 | Err(err) => { 424 | eprintln!("Error while recving from {} - {:?}", dest_ip_v4, err); 425 | false 426 | } 427 | } 428 | } 429 | // Determine how long the round trip took. 430 | let ping_duration = (Utc::now() - start_time).to_std().unwrap(); 431 | // Store the ping duration. 432 | ping_data 433 | .lock() 434 | .unwrap() 435 | .add_entry(&hostname, start_time, ping_duration); 436 | // Wait for the ping interval to elapse and repeat. 437 | let next_ping_time = 438 | start_time + chrono_Duration::seconds(config::SEC_BETWEEN_PINGS as i64); 439 | let cur_time = Utc::now(); 440 | if next_ping_time > cur_time { 441 | thread::sleep((next_ping_time - cur_time).to_std().unwrap()); 442 | } 443 | } 444 | } 445 | 446 | // The web UI. 447 | const START_OFFSET_PARAM: &str = "start_offset"; 448 | const HOW_MUCH_DATA: &str = "how_much_data"; 449 | async fn index(req: HttpRequest, ping_data: web::Data>>) -> HttpResponse { 450 | let cur_time = Utc::now(); 451 | let offset_params = Query::>::from_query(req.query_string()).unwrap(); 452 | let start_offset = match offset_params.get(START_OFFSET_PARAM) { 453 | Some(start_offset) => parse(start_offset.as_str()).unwrap(), 454 | None => Duration::from_secs(0), // Default to now. 455 | }; 456 | let newest_timestamp_in_scope = cur_time - chrono_Duration::from_std(start_offset).unwrap(); 457 | let how_much_data = match offset_params.get(HOW_MUCH_DATA) { 458 | Some(end_offset) => parse(end_offset.as_str()).unwrap(), 459 | None => Duration::from_secs(60 * 60 * 6), // Default to 6 hours of data. 460 | }; 461 | let oldest_timestamp_in_scope = 462 | newest_timestamp_in_scope - chrono_Duration::from_std(how_much_data).unwrap(); 463 | 464 | let mut html = String::new(); 465 | 466 | // Style the tables. 467 | html += " 468 | 469 | "; 517 | 518 | html += format!( 519 | "❮ newer data", 520 | if start_offset < how_much_data { 521 | Duration::from_secs(0) 522 | } else { 523 | start_offset - how_much_data 524 | }, 525 | how_much_data 526 | ) 527 | .as_str(); 528 | html += format!( 529 | "older data ❯", 530 | (start_offset + how_much_data), 531 | how_much_data 532 | ) 533 | .as_str(); 534 | 535 | // Create a table to display the data. 536 | html += ""; 537 | 538 | // Use a scope so we drop the lock as soon as possible. 539 | { 540 | let locked_ping_data = &ping_data.lock().unwrap(); 541 | 542 | // Add hostname headings, each will get a column. 543 | for hostname in &locked_ping_data.hostnames_in_order { 544 | html += format!("", hostname).as_str(); 545 | } 546 | html += ""; 547 | html += ""; 548 | // Add the per-host data. 549 | for hostname in &locked_ping_data.hostnames_in_order { 550 | let initial_timestamp = DateTime::::from(newest_timestamp_in_scope); 551 | let mut prev_day = initial_timestamp.day(); 552 | let mut prev_hour = initial_timestamp.hour(); 553 | let mut prev_minute = initial_timestamp.minute(); 554 | // Iterate the range in newest (highest datetime) to oldest order. 555 | // Filter to only data in the time-frame we want. 556 | let hostname_data_iter = locked_ping_data.data[hostname.as_str()] 557 | .range(..newest_timestamp_in_scope) 558 | .rev() 559 | .filter(|data| { 560 | data.0 >= &oldest_timestamp_in_scope && data.0 <= &newest_timestamp_in_scope 561 | }); 562 | // Label the per-host ping data fields. 563 | html += "" 613 | } 614 | } 615 | 616 | html += ""; 617 | html += "
{}
"; 564 | // Rows of per-host ping data. 565 | html += ""; 566 | for (timestamp, duration) in hostname_data_iter { 567 | let tens_of_ms = duration.as_millis() / 10; 568 | // Print a bar for every 10 ms, with a max of 10 bars. 569 | let mut num_bars = cmp::min(tens_of_ms, 10); 570 | let mut magnitude_bars = "".to_string(); 571 | while num_bars > 0 { 572 | magnitude_bars += "█"; 573 | num_bars -= 1; 574 | } 575 | let local_timestamp = DateTime::::from(timestamp.clone()); 576 | // Add some style to clearly delineate days, minutes, hours 577 | let mut class = "class=\"".to_string(); 578 | class += if local_timestamp.day() != prev_day { 579 | prev_day = local_timestamp.day(); 580 | prev_hour = local_timestamp.hour(); 581 | prev_minute = local_timestamp.minute(); 582 | " NewDay " 583 | } else if local_timestamp.hour() != prev_hour { 584 | prev_hour = local_timestamp.hour(); 585 | prev_minute = local_timestamp.minute(); 586 | " NewHour " 587 | } else if local_timestamp.minute() != prev_minute { 588 | prev_minute = local_timestamp.minute(); 589 | " NewMinute " 590 | } else { 591 | "" 592 | }; 593 | if duration >= &Duration::from_millis(config::PING_TIMEOUT_MSEC) { 594 | class += " TimedOut "; 595 | } 596 | class += "\""; 597 | // Add a row of ping data to the table. 598 | html += format!( 599 | "", 600 | class, 601 | local_timestamp.month(), 602 | local_timestamp.day(), 603 | local_timestamp.hour12().1, 604 | local_timestamp.minute(), 605 | local_timestamp.second(), 606 | if local_timestamp.hour12().0 { "PM" } else { "AM" }, 607 | duration.as_secs_f64() * 1000.0, 608 | magnitude_bars 609 | ) 610 | .as_str(); 611 | } 612 | html += "
timestampdurationmagnitude
{:02}-{:02} {:02}:{:02}:{:02} {}{:_>6.1} ms⎹{:_<10}
"; 618 | 619 | return HttpResponse::Ok() 620 | .content_type(ContentType::html()) 621 | .body(html); 622 | } 623 | -------------------------------------------------------------------------------- /LAN/systemd/network-monitor.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Network Monitor Ping Logger 3 | After=network-online.target 4 | 5 | [Service] 6 | Type=simple 7 | Restart=always 8 | User=network-monitor 9 | Group=network-monitor 10 | WorkingDirectory=/usr/bin/network-monitor/ 11 | ExecStart=/usr/bin/network-monitor/network-monitor 192.168.1.1 ping.projects.chrisjeakle.com ping.projects-west.chrisjeakle.com 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Network Monitor 2 | A utility to help monitor and assess network performance 3 | 4 | ## Build 5 | * [Install `rustup`](https://www.rust-lang.org/tools/install): `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` 6 | * This project uses nightly features: `rustup install nightly` 7 | * Ensure you're up-to-date (`rustup update`) 8 | * Build the LAN-side application: 9 | ``` 10 | cargo fmt --manifest-path=LAN/Cargo.toml && \ 11 | cargo +nightly build --manifest-path=LAN/Cargo.toml && \ 12 | sudo setcap cap_net_admin,cap_net_raw=eip LAN/target/debug/network-monitor 13 | ``` 14 | * Test the LAN-side application: 15 | ``` 16 | LAN/target/debug/network-monitor 192.168.1.1 ping.projects.chrisjeakle.com 17 | ``` 18 | * By default the UI is available at http://0.0.0.0:8180 19 | 20 | ## Deploy 21 | 22 | ### Web hosted latency test 23 | * Set up the web pages: 24 | * On the server: `ssh root@projects.chrisjeakle.com 'mkdir /var/www/html/ping/'` 25 | * Upload the latency test page to the server: `scp -pr WAN/html root@projects.chrisjeakle.com:/var/www/html/ping/` 26 | * Configure nginx: 27 | * `scp WAN/nginx/ping.conf root@projects.chrisjeakle.com:/etc/nginx/conf.d/ping.conf` 28 | * On the server: `ssh root@projects.chrisjeakle.com 'service nginx reload'` 29 | * Test the config 30 | * Ensure ICMP pings work (so the LAN tool can ping this WAN destination): `ping -c 4 ping.projects.chrisjeakle.com` 31 | * Ensure there's no redirects: `curl -H 'Cache-Control: no-cache' http://ping.projects.chrisjeakle.com/ping/ -I -k` 32 | * `curl http://ping.projects.chrisjeakle.com/ping/` 33 | * Visit in a browser: http://ping.projects.chrisjeakle.com/ 34 | 35 | ### LAN ping monitoring tool 36 | 37 | #### Initial Deploy 38 | * SSH into a LAN device to host the software 39 | * Configure the application by editing `LAN/config.rs` 40 | * Build the application 41 | * `cargo +nightly build --release --manifest-path=LAN/Cargo.toml` 42 | * Copy the binary to the appropriate folder on the LAN device 43 | * `sudo mkdir -p /usr/bin/network-monitor/` 44 | * `sudo cp LAN/target/release/network-monitor /usr/bin/network-monitor/network-monitor` 45 | * Apply capabilities so the program is permitted to create raw sockets 46 | * `sudo setcap cap_net_admin,cap_net_raw=eip /usr/bin/network-monitor/network-monitor` 47 | * Verify it worked using: `getcap /usr/bin/network-monitor/network-monitor` 48 | * Create a new non-root user to run the service 49 | * `sudo useradd --system network-monitor` 50 | * Create a `system` user, we have no need for interactive shell sessions or a home dir 51 | * Create a service to auto-start the application 52 | * `sudo cp LAN/systemd/network-monitor.service /etc/systemd/system/network-monitor.service` 53 | * Edit the service definition to ping the hosts you want to ping 54 | * `sudo vim /etc/systemd/system/network-monitor.service` 55 | * Edit the command line args at the end of the `ExecStart=` line under `[Service]` 56 | * Enable the service and start it 57 | * `sudo systemctl enable network-monitor.service && sudo systemctl start network-monitor.service` 58 | * Monitor service health: 59 | * `sudo systemctl status network-monitor.service` 60 | * `sudo journalctl -u network-monitor | less +G` 61 | * View the network ping logs in a browser at http://localhost:8180 or http://pi4.local:8180 62 | 63 | #### Updates 64 | Binary update script: 65 | ``` 66 | git pull && \ 67 | cargo +nightly build --release --manifest-path=LAN/Cargo.toml && \ 68 | sudo systemctl stop network-monitor.service && \ 69 | sudo cp LAN/target/release/network-monitor /usr/bin/network-monitor/ && \ 70 | sudo setcap cap_net_admin,cap_net_raw=eip /usr/bin/network-monitor/network-monitor && \ 71 | sudo systemctl start network-monitor.service && \ 72 | sudo getcap /usr/bin/network-monitor/network-monitor && \ 73 | sudo systemctl status network-monitor.service 74 | ``` 75 | 76 | #### Screenshots 77 | The UI: 78 | ![The LAN-side web UI](./LAN-ui-screenshot.png) 79 | -------------------------------------------------------------------------------- /WAN/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Measure latency 7 | 8 | 9 |

HTTP request latency: ... ms.

10 |

Note: this site times HTTP requests rather than ICMP pings, so there's some overhead included in the reported durations.

11 | 12 |

13 | By Chris Jeakle 14 |    |    15 | View project source 16 |

17 | 18 | 25 | 45 | 46 | -------------------------------------------------------------------------------- /WAN/html/ping/data.txt: -------------------------------------------------------------------------------- 1 | . -------------------------------------------------------------------------------- /WAN/nginx/ping.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name ping.projects.chrisjeakle.com; 3 | listen 80; 4 | listen [::]:80; 5 | root /var/www/html/ping; 6 | 7 | location / { 8 | index index.htm index.html; 9 | } 10 | 11 | location ping { 12 | # Expire cache immediately, so users call the server for pings. 13 | sendfile off; 14 | add_header Last-Modified $date_gmt; 15 | add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; 16 | if_modified_since off; 17 | expires off; 18 | etag off; 19 | proxy_no_cache 1; 20 | proxy_cache_bypass 1; 21 | } 22 | } 23 | --------------------------------------------------------------------------------