From 8ee0a7dab0a21e695e719f686fce8d4854a23657 Mon Sep 17 00:00:00 2001 From: tchapacan Date: Wed, 27 Mar 2024 23:56:47 +0100 Subject: [PATCH] update readme, refacto workflow and code --- .github/workflows/code-checks.yml | 8 +- README.md | 111 +++++++- src/livebox_client_rs/client.rs | 162 +++++++----- src/livebox_client_rs/devices.rs | 3 +- src/livebox_client_rs/metrics.rs | 2 +- src/livebox_client_rs/mod.rs | 4 +- src/livebox_client_rs/status.rs | 2 +- src/livebox_client_rs/wan.rs | 4 +- src/main.rs | 422 +++++++++++++++++++----------- 9 files changed, 491 insertions(+), 227 deletions(-) diff --git a/.github/workflows/code-checks.yml b/.github/workflows/code-checks.yml index a5843cc..ab32ca4 100644 --- a/.github/workflows/code-checks.yml +++ b/.github/workflows/code-checks.yml @@ -23,7 +23,7 @@ jobs: steps: - name: "Checkout repo 👉" - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: "Install toolchain 🦀" uses: actions-rs/toolchain@v1 @@ -57,3 +57,9 @@ jobs: with: command: clippy args: --all-features + + - name: "Build code 🎁" + uses: actions-rs/cargo@v1 + with: + command: build + args: --release --locked --target ${{ matrix.arch.target }} diff --git a/README.md b/README.md index bc17909..43993f8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,109 @@ -# livebox-exporter-rs -A livebox exporter built in rust for prometheus +# livebox-exporter-rs (️WIP) -WIP.. +A basic livebox exporter written in [rust](https://doc.rust-lang.org/book/title-page.html) 🦀. Start monitoring your livebox router's using [Prometheus](https://github.com/prometheus/prometheus) / [Grafana](https://github.com/grafana/grafana) 💪. + +> ℹ️ **Note:** this minimal, draft, poc, "tool" **almost work with some luck** as it is now 🤞, but for "cool" educational purpose only. Some alternatives already **[exists](#alternative)** (check below), but didn't find any **rust** version yet. As I always wanted to learn **rust**, and I have a **livebox router** next to me, let's fill the gap (I think lol)! **Contributions/help/suggestions are really welcome!** + + +## Features + +- **Should work** on livebox 4 and upper 🤷🏻‍ +- **Extracts metrics:** general status, wan configuration, devices status, bandwidth +- **Exposes metrics:** in Prometheus format, compatible with Grafana +- *Docker image soon available...* +- *Grafana dashboard template soon available...* + + +## Metrics + +| **Metric Name** | **Description** | **Type** | +|-------------------------------|-------------------------------------|-------| +| livebox_infos_status | Livebox general status | gauge | +| livebox_infos_uptime | Livebox uptime | gauge | +| livebox_infos_reboot | Livebox count of reboots | gauge | +| livebox_wan_status | Livebox wan status | gauge | +| livebox_link_status | Livebox link status | gauge | +| livebox_interface_bytes_rx | Livebox interface bytes received | gauge | +| livebox_interface_bytes_tx | Livebox interface bytes transmitted | gauge | +| livebox_device_status | Livebox connected devices status | gauge | + + +## Usage + +To use **livebox-exporter-rs**, follow these steps: + +1. **Clone the Repository:** to your local machine. + + ```bash + git clone https://github.com/tchapacan/livebox-exporter-rs.git + ``` + +2. **Build the Project:** go to the project directory and build the project. + + ```bash + cd livebox-exporter-rs + cargo build --release + ``` + +3. **Run the Exporter:** run the binary, using the options. + + ```bash + livebox-exporter-rs -P -p + ``` + +4. **Access Metrics:** Once the exporter is running, access the exposed metrics at: + + `http://localhost:/metrics` + + +## Options + +Supported command-line options (hope `-P` vs `-p` not to confusing): + +| Option | Description | Default Value | +|------------------------|-----------------------------------------------|---------------| +| -P, --password | Livebox password **(required)** | None | +| -p, --port | Exporter port | 9100 | +| -h, --host
| Listen address | 0.0.0.0 | +| -v, --verbose | Enable verbose logging (repeat for increased verbosity) | Off | +| -h, --help | Display help message | N/A | + +```bash +Usage: livebox-exporter-rs [OPTIONS] --password + +Options: + -p, --port exporter port [default: 9100] + -l, --listen
listen address [default: 0.0.0.0] + -v, --verbose... verbose logging + -P, --password Livebox password [required] + -h, --help Print help + -V, --version Print version +``` + +## Details + +- Use the [prometheus_exporter_base](https://github.com/MindFlavor/prometheus_exporter_base) crate for formatting Prometheus metrics. +- Based on a rework version of the [livebox](https://crates.io/crates/livebox/) rust client project to output additional metrics. + +> Schema soon available + +## Contributing + +Contributions are really welcome! If you encounter any issues, have suggestions, or would like to add/fix features, please do: + +- Open an issue to report bugs or request features. +- Fork the repository, make your changes, and submit a pull request. +- Contribution guidelines will be added soon. + + +## Alternatives + +- https://github.com/Tomy2e/livebox-exporter +- https://github.com/jeanfabrice/livebox-exporter +- https://la.robinjiang.com/cyr-ius/hass-livebox-component +- https://la.robinjiang.com/p-dor/LiveboxMonitor + + +## Legal + +`Livebox` is a trademark owned by France Telecom and Orange, and is their property. This tool only uses the name as it is the router this exporter is about. No intellectual property infrigement intended. This work has been done for educational purpose as a personal monitoring side project and shared to the community. If there's any issue with the use of this name here, please don't hesitate to contact me. diff --git a/src/livebox_client_rs/client.rs b/src/livebox_client_rs/client.rs index 1ba2c61..770d3d8 100644 --- a/src/livebox_client_rs/client.rs +++ b/src/livebox_client_rs/client.rs @@ -1,17 +1,20 @@ -use std::net::Ipv4Addr; -use std::collections::HashMap; -use cookie::Cookie; -use serde_json::{json, Value}; -use hyper::{ - body::{Body, Bytes}, client::HttpConnector, header::{AUTHORIZATION, CONTENT_TYPE, COOKIE, SET_COOKIE}, Method, Request -}; -use log::{trace, debug}; use crate::{ - livebox_client_rs::status::Status, - livebox_client_rs::wan::WANConfiguration, livebox_client_rs::devices::Device, - livebox_client_rs::metrics::{Metrics, DeviceMetrics} + livebox_client_rs::metrics::{DeviceMetrics, Metrics}, + livebox_client_rs::status::Status, + livebox_client_rs::wan::WANConfiguration, }; +use cookie::Cookie; +use hyper::{ + body::{Body, Bytes}, + client::HttpConnector, + header::{AUTHORIZATION, CONTENT_TYPE, COOKIE, SET_COOKIE}, + Method, Request, +}; +use log::{debug, trace}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::net::Ipv4Addr; #[derive(Debug, Clone)] pub struct Client { @@ -20,7 +23,7 @@ pub struct Client { password: String, cookies: Vec, context_id: Option, - client: hyper::Client + client: hyper::Client, } impl Client { @@ -33,11 +36,16 @@ impl Client { password: password.to_string(), cookies: Vec::new(), context_id: None, - client: hyper::Client::new() + client: hyper::Client::new(), } } - async fn post_request(&self, service: &str, method: &str, parameters: serde_json::Value) -> (hyper::http::response::Parts, Bytes) { + async fn post_request( + &self, + service: &str, + method: &str, + parameters: serde_json::Value, + ) -> (hyper::http::response::Parts, Bytes) { let post_data = json!({ "service": service, "method": method, @@ -50,7 +58,10 @@ impl Client { .header(AUTHORIZATION, "X-Sah-Login") .body(Body::from(post_data.to_string())) .expect("Could not build request."); - let (parts, body) = self.client.request(req).await + let (parts, body) = self + .client + .request(req) + .await .expect("There was an issue contacting the router.") .into_parts(); let body_bytes = hyper::body::to_bytes(body).await.unwrap(); @@ -60,30 +71,48 @@ impl Client { pub async fn login(&mut self) { trace!("Logging in."); - let (parts, body_bytes) = self.post_request("sah.Device.Information", "createContext", serde_json::json!({ - "applicationName": "so_sdkut", - "username": &self.username, - "password": &self.password - })).await; + let (parts, body_bytes) = self + .post_request( + "sah.Device.Information", + "createContext", + serde_json::json!({ + "applicationName": "so_sdkut", + "username": &self.username, + "password": &self.password + }), + ) + .await; debug!("Status is {}.", parts.status.as_str()); for ele in parts.headers.get_all(SET_COOKIE) { let cookie = Cookie::parse(ele.to_str().unwrap()).unwrap(); - self.cookies.push(format!("{}={}", cookie.name(), cookie.value())); + self.cookies + .push(format!("{}={}", cookie.name(), cookie.value())); } - assert!(!self.cookies.is_empty(), "No cookie detected on login, there should be an error."); - let json: serde_json::Value = serde_json::from_slice(&body_bytes) - .expect("Could not parse JSON."); + assert!( + !self.cookies.is_empty(), + "No cookie detected on login, there should be an error." + ); + let json: serde_json::Value = + serde_json::from_slice(&body_bytes).expect("Could not parse JSON."); assert_eq!(json["status"].as_u64().unwrap(), 0, "Status wasn't 0."); self.context_id = Some(json["data"]["contextID"].as_str().unwrap().to_string()); } - async fn authenticated_post_request(&self, service: &str, method: &str, parameters: serde_json::Value) -> (hyper::http::response::Parts, Bytes) { + async fn authenticated_post_request( + &self, + service: &str, + method: &str, + parameters: serde_json::Value, + ) -> (hyper::http::response::Parts, Bytes) { let post_data = json!({ "service": service, "method": method, "parameters": parameters }); - assert!(self.context_id.is_some(), "Cannot make authenticated request without logging in beforehand."); + assert!( + self.context_id.is_some(), + "Cannot make authenticated request without logging in beforehand." + ); let req = Request::builder() .method(Method::POST) .uri(format!("http://{}/ws", self.ip)) @@ -92,7 +121,10 @@ impl Client { .header(COOKIE, self.cookies.join("; ")) .body(Body::from(post_data.to_string())) .expect("Could not build request."); - let (parts, body) = self.client.request(req).await + let (parts, body) = self + .client + .request(req) + .await .expect("There was an issue contacting the router.") .into_parts(); let body_bytes = hyper::body::to_bytes(body).await.unwrap(); @@ -101,43 +133,58 @@ impl Client { } pub async fn get_status(&self) -> Status { - let (parts, body_bytes) = self.authenticated_post_request("DeviceInfo", "get", serde_json::json!({})).await; + let (parts, body_bytes) = self + .authenticated_post_request("DeviceInfo", "get", serde_json::json!({})) + .await; let json: Value = serde_json::from_slice(&body_bytes).expect("Could not parse JSON."); println!("Body was: '{}'.", std::str::from_utf8(&body_bytes).unwrap()); - assert!(parts.status.is_success(), "Router answered with something else than a success code."); + assert!( + parts.status.is_success(), + "Router answered with something else than a success code." + ); let status: Status = serde_json::from_value(json["status"].clone()) .expect("Looks like the deserialized data is incomplete."); debug!("Deserialized status is: {:?}", status); - return status; + status } pub async fn get_wan_config(&self) -> WANConfiguration { - let (parts, body_bytes) = self.authenticated_post_request("NMC", "getWANStatus", serde_json::json!({})).await; + let (parts, body_bytes) = self + .authenticated_post_request("NMC", "getWANStatus", serde_json::json!({})) + .await; let json: Value = serde_json::from_slice(&body_bytes).expect("Could not parse JSON."); println!("Body was: '{}'.", std::str::from_utf8(&body_bytes).unwrap()); - assert!(parts.status.is_success(), "Router answered with something else than a success code."); - let wan_config: WANConfiguration = serde_json::from_value(json["data"].clone()) + assert!( + parts.status.is_success(), + "Router answered with something else than a success code." + ); + let wan_config: WANConfiguration = serde_json::from_value(json["data"].clone()) .expect("Looks like the deserialized data is incomplete."); debug!("Deserialized wan is: {:?}", wan_config); - return wan_config; + wan_config } pub async fn get_devices(&self) -> Vec { - let (parts, body_bytes) = self.authenticated_post_request("Devices", "get", serde_json::json!({})).await; + let (parts, body_bytes) = self + .authenticated_post_request("Devices", "get", serde_json::json!({})) + .await; let json: Value = serde_json::from_slice(&body_bytes).expect("Could not parse JSON."); - assert!(parts.status.is_success() && json["status"].is_array(), - "Router answered with something else than a success code."); - let devices: Vec = serde_json::from_value(json["status"].clone()) + assert!( + parts.status.is_success() && json["status"].is_array(), + "Router answered with something else than a success code." + ); + let devices: Vec = serde_json::from_value(json["status"].clone()) .expect("Looks like the deserialized data is incomplete."); debug!("Deserialized devices is: {:?}", devices); - return devices; + devices } pub async fn get_metrics(&self) -> Vec { let post_data = json!({"Seconds": 0, "NumberOfReadings": 1}); - let (parts, body_bytes) = self.authenticated_post_request("HomeLan", "getResults", post_data).await; - let json: Value = serde_json::from_slice(&body_bytes) - .expect("Could not parse JSON."); + let (_parts, body_bytes) = self + .authenticated_post_request("HomeLan", "getResults", post_data) + .await; + let json: Value = serde_json::from_slice(&body_bytes).expect("Could not parse JSON."); println!("Body was: '{}'.", std::str::from_utf8(&body_bytes).unwrap()); let mut metrics: Vec = Vec::new(); if let Some(status) = json["status"].as_object() { @@ -150,7 +197,7 @@ impl Client { } } debug!("Deserialized metrics is: {:?}", metrics); - return metrics + metrics } pub async fn logout(&mut self) { @@ -163,12 +210,16 @@ impl Client { let req = Request::builder() .method(Method::POST) .uri(format!("http://{}/ws", self.ip)) - .header(AUTHORIZATION, - format!("X-Sah-Logout {}", self.context_id.clone().unwrap())) + .header( + AUTHORIZATION, + format!("X-Sah-Logout {}", self.context_id.clone().unwrap()), + ) .header(COOKIE, self.cookies.join("; ")) .body(Body::from(post_data.to_string())) .expect("Could not build request."); - self.client.request(req).await + self.client + .request(req) + .await .expect("There was an issue contacting the router."); trace!("Logged out."); self.cookies.clear(); @@ -176,11 +227,10 @@ impl Client { } } - #[cfg(test)] mod tests { use super::*; - use httpmock::{MockServer, Method::POST}; + use httpmock::{Method::POST, MockServer}; use serde_json::json; fn get_mock_status() -> &'static str { @@ -307,8 +357,7 @@ mod tests { when.method(POST) .path("/ws") .header("x-context", "test-context-id"); - then.status(200) - .body(mock_status); + then.status(200).body(mock_status); }); let mut client = Client::new("password"); client.ip = server.address().to_string(); @@ -326,8 +375,7 @@ mod tests { when.method(POST) .path("/ws") .header("x-context", "test-context-id"); - then.status(200) - .body(mock_wan_config); + then.status(200).body(mock_wan_config); }); let mut client = Client::new("password"); client.ip = server.address().to_string(); @@ -345,8 +393,7 @@ mod tests { when.method(POST) .path("/ws") .header("x-context", "test-context-id"); - then.status(200) - .body(mock_devices); + then.status(200).body(mock_devices); }); let mut client = Client::new("password"); client.ip = server.address().to_string(); @@ -364,8 +411,7 @@ mod tests { when.method(POST) .path("/ws") .header("x-context", "test-context-id"); - then.status(200) - .body(mock_metrics); + then.status(200).body(mock_metrics); }); let mut client = Client::new("password"); client.ip = server.address().to_string(); @@ -418,8 +464,7 @@ mod tests { when.method(POST) .path("/ws") .header("x-context", "test-context-id"); - then.status(500) - .body("Internal Server Error"); + then.status(500).body("Internal Server Error"); }); let mut client = Client::new("password"); client.ip = server.address().to_string(); @@ -427,5 +472,4 @@ mod tests { client.context_id = Some("test-context-id".to_string()); client.get_status().await; } - } diff --git a/src/livebox_client_rs/devices.rs b/src/livebox_client_rs/devices.rs index 415a2b9..fcd0ac9 100644 --- a/src/livebox_client_rs/devices.rs +++ b/src/livebox_client_rs/devices.rs @@ -1,8 +1,7 @@ - use serde::Deserialize; #[derive(Debug, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all="PascalCase")] +#[serde(rename_all = "PascalCase")] pub struct Device { pub key: String, pub name: String, diff --git a/src/livebox_client_rs/metrics.rs b/src/livebox_client_rs/metrics.rs index 244196f..e1cd595 100644 --- a/src/livebox_client_rs/metrics.rs +++ b/src/livebox_client_rs/metrics.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Debug, Serialize, Deserialize)] diff --git a/src/livebox_client_rs/mod.rs b/src/livebox_client_rs/mod.rs index 6bb3814..f2b148e 100644 --- a/src/livebox_client_rs/mod.rs +++ b/src/livebox_client_rs/mod.rs @@ -1,5 +1,5 @@ pub mod client; +pub mod devices; +pub mod metrics; pub mod status; pub mod wan; -pub mod devices; -pub mod metrics; \ No newline at end of file diff --git a/src/livebox_client_rs/status.rs b/src/livebox_client_rs/status.rs index 1241970..f37d15f 100644 --- a/src/livebox_client_rs/status.rs +++ b/src/livebox_client_rs/status.rs @@ -2,7 +2,7 @@ use serde::Deserialize; #[derive(Debug, Deserialize, Clone, PartialEq, Eq)] #[serde(deny_unknown_fields)] -#[serde(rename_all="PascalCase")] +#[serde(rename_all = "PascalCase")] pub struct Status { pub manufacturer: String, #[serde(rename(deserialize = "ManufacturerOUI"))] diff --git a/src/livebox_client_rs/wan.rs b/src/livebox_client_rs/wan.rs index 728f8af..a7f126c 100644 --- a/src/livebox_client_rs/wan.rs +++ b/src/livebox_client_rs/wan.rs @@ -1,7 +1,7 @@ use serde::Deserialize; #[derive(Debug, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all="PascalCase")] +#[serde(rename_all = "PascalCase")] pub struct WANConfiguration { pub wan_state: String, pub link_type: String, @@ -19,5 +19,5 @@ pub struct WANConfiguration { #[serde(rename(deserialize = "IPv6Address"))] pub ipv6_address: String, #[serde(rename(deserialize = "IPv6DelegatedPrefix"))] - pub ipv6_delegated_prefix: String + pub ipv6_delegated_prefix: String, } diff --git a/src/main.rs b/src/main.rs index 8a4f2f7..06db546 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,21 @@ mod livebox_client_rs; -use std::{ - env, - error::Error, - net::{IpAddr, SocketAddr}, - sync::Arc -}; -use clap::{ - value_parser, - Arg, - ArgMatches, - ArgAction, - Command -}; +use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; use hyper::{Body, Request}; +use livebox_client_rs::{ + client::Client, + devices::Device, + metrics::{Metrics, TrafficData}, + status::Status, + wan::WANConfiguration, +}; use log::{trace, LevelFilter}; use prometheus_exporter_base::prelude::*; -use livebox_client_rs::{ - client::Client, - devices::Device, - metrics::{Metrics, TrafficData}, - status::Status, - wan::WANConfiguration +use std::{ + env, + error::Error, + net::{IpAddr, SocketAddr}, + sync::Arc, }; #[derive(Debug, Clone, Default)] @@ -32,29 +26,37 @@ async fn main() { let matches = Command::new("livebox-exporter-rs") .version("0.1.0") .author("tchapacan") - .arg(Arg::new("port") - .short('p') - .long("port") - .help("exporter port") - .value_parser(value_parser!(u16)) - .default_value("9100")) - .arg(Arg::new("address") - .short('l') - .long("listen") - .help("listen address") - .value_parser(value_parser!(String)) - .default_value("0.0.0.0")) - .arg(Arg::new("verbose") - .short('v') - .long("verbose") - .help("verbose logging") - .action(ArgAction::Count)) - .arg(Arg::new("password") - .short('P') - .long("password") - .help("Livebox password") - .value_parser(value_parser!(String)) - .required(true)) + .arg( + Arg::new("port") + .short('p') + .long("port") + .help("exporter port") + .value_parser(value_parser!(u16)) + .default_value("9100"), + ) + .arg( + Arg::new("address") + .short('l') + .long("listen") + .help("listen address") + .value_parser(value_parser!(String)) + .default_value("0.0.0.0"), + ) + .arg( + Arg::new("verbose") + .short('v') + .long("verbose") + .help("verbose logging") + .action(ArgAction::Count), + ) + .arg( + Arg::new("password") + .short('P') + .long("password") + .help("Livebox password [required]") + .value_parser(value_parser!(String)) + .required(true), + ) .get_matches(); let verbosity = matches.get_count("verbose"); @@ -65,8 +67,14 @@ async fn main() { 2 => LevelFilter::Debug, _ => LevelFilter::Trace, }; - - env::set_var("RUST_LOG", format!("folder_size={},livebox-exporter-rs={}", log_level, log_level)); + + env::set_var( + "RUST_LOG", + format!( + "folder_size={},livebox-exporter-rs={}", + log_level, log_level + ), + ); env_logger::Builder::new().filter_level(log_level).init(); let bind: u16 = *matches.get_one("port").unwrap(); @@ -90,8 +98,16 @@ async fn main() { .await; } -async fn render_livebox_metrics(request: Request, _options: Arc, matches: ArgMatches) -> Result> { - trace!("In our render_prometheus(request == {:?}, options == {:?})", request, _options); +async fn render_livebox_metrics( + request: Request, + _options: Arc, + matches: ArgMatches, +) -> Result> { + trace!( + "In our render_prometheus(request == {:?}, options == {:?})", + request, + _options + ); let livebox_password = match matches.get_one::("password") { Some(password) => password.clone(), None => { @@ -106,14 +122,43 @@ async fn render_livebox_metrics(request: Request, _options: Arc let metrics = client.get_metrics().await; let devices = client.get_devices().await; let rendered_metrics = vec![ - render_livebox_info_metric(&status, "livebox_infos_status", "Livebox general status", |s| if s.device_status == "Up" { 1 } else { 0 }), - render_livebox_info_metric(&status, "livebox_infos_uptime", "Livebox uptime", |s| s.up_time.try_into().unwrap()), - render_livebox_info_metric(&status, "livebox_infos_reboot", "Livebox count of reboots", |s| s.number_of_reboots.try_into().unwrap()), + render_livebox_info_metric( + &status, + "livebox_infos_status", + "Livebox general status", + |s| if s.device_status == "Up" { 1 } else { 0 }, + ), + render_livebox_info_metric(&status, "livebox_infos_uptime", "Livebox uptime", |s| { + s.up_time.try_into().unwrap() + }), + render_livebox_info_metric( + &status, + "livebox_infos_reboot", + "Livebox count of reboots", + |s| s.number_of_reboots.try_into().unwrap(), + ), render_livebox_status_metric(&wan, "livebox_wan_status", "wan"), render_livebox_status_metric(&wan, "livebox_link_status", "link"), - render_livebox_interface_metric(&metrics, "livebox_interface_bytes_rx", "Livebox interface bytes RX", |e: &TrafficData| e.rx_counter.try_into().unwrap(), "rx"), - render_livebox_interface_metric(&metrics, "livebox_interface_bytes_tx", "Livebox interface bytes TX", |e: &TrafficData| e.tx_counter.try_into().unwrap(), "tx"), - render_livebox_devices_metric(&devices, "livebox_device_status", "Livebox connected devices status", |d| if d.active { 1 } else { 0 }), + render_livebox_interface_metric( + &metrics, + "livebox_interface_bytes_rx", + "Livebox interface bytes RX", + |e: &TrafficData| e.rx_counter.try_into().unwrap(), + "rx", + ), + render_livebox_interface_metric( + &metrics, + "livebox_interface_bytes_tx", + "Livebox interface bytes TX", + |e: &TrafficData| e.tx_counter.try_into().unwrap(), + "tx", + ), + render_livebox_devices_metric( + &devices, + "livebox_device_status", + "Livebox connected devices status", + |d| if d.active { 1 } else { 0 }, + ), ]; client.logout().await; @@ -133,42 +178,56 @@ where F: FnOnce(&Status) -> usize, { create_metric(name, help) - .render_and_append_instance(&PrometheusInstance::new() - .with_label("hardware", "livebox") - .with_label("manufacturer", &*status.manufacturer) - .with_label("manufacturer_oui", &*status.manufacturer_oui) - .with_label("model_name", &*status.model_name) - .with_label("product_class", &*status.product_class) - .with_label("serial_number", &*status.serial_number) - .with_label("hardware_version", &*status.hardware_version) - .with_label("software_version", &*status.software_version) - .with_label("country", &*status.country) - .with_label("external_ip_address", &*status.external_ip_address) - .with_label("base_mac", &*status.base_mac) - .with_value(value_fn(status)) - .with_current_timestamp() - .expect("Error getting the current UNIX epoch")) + .render_and_append_instance( + &PrometheusInstance::new() + .with_label("hardware", "livebox") + .with_label("manufacturer", &*status.manufacturer) + .with_label("manufacturer_oui", &*status.manufacturer_oui) + .with_label("model_name", &*status.model_name) + .with_label("product_class", &*status.product_class) + .with_label("serial_number", &*status.serial_number) + .with_label("hardware_version", &*status.hardware_version) + .with_label("software_version", &*status.software_version) + .with_label("country", &*status.country) + .with_label("external_ip_address", &*status.external_ip_address) + .with_label("base_mac", &*status.base_mac) + .with_value(value_fn(status)) + .with_current_timestamp() + .expect("Error getting the current UNIX epoch"), + ) .render() } fn render_livebox_status_metric(wan_config: &WANConfiguration, name: &str, port: &str) -> String { create_metric(name, &format!("Livebox {} status", port)) - .render_and_append_instance(&PrometheusInstance::new() - .with_label("port", port) - .with_label("link_type", &*wan_config.link_type) - .with_label("protocol", &*wan_config.protocol) - .with_label("mac_address", &*wan_config.mac_address) - .with_label("ip_address", &*wan_config.ip_address) - .with_label("remote_gateway", &*wan_config.remote_gateway) - .with_label("remote_gadns_serversteway", &*wan_config.dns_servers) - .with_label("ipv6_address", &*wan_config.ipv6_address) - .with_value(if port == "wan" { wan_config.wan_state == "up" } else { wan_config.link_state == "up" } as usize) - .with_current_timestamp() - .expect("Error getting the current UNIX epoch")) + .render_and_append_instance( + &PrometheusInstance::new() + .with_label("port", port) + .with_label("link_type", &*wan_config.link_type) + .with_label("protocol", &*wan_config.protocol) + .with_label("mac_address", &*wan_config.mac_address) + .with_label("ip_address", &*wan_config.ip_address) + .with_label("remote_gateway", &*wan_config.remote_gateway) + .with_label("remote_gadns_serversteway", &*wan_config.dns_servers) + .with_label("ipv6_address", &*wan_config.ipv6_address) + .with_value(if port == "wan" { + wan_config.wan_state == "up" + } else { + wan_config.link_state == "up" + } as usize) + .with_current_timestamp() + .expect("Error getting the current UNIX epoch"), + ) .render() } -fn render_livebox_interface_metric(metrics: &[Metrics], name: &str, help: &str, value_fn: F, direction: &str) -> String +fn render_livebox_interface_metric( + metrics: &[Metrics], + name: &str, + help: &str, + value_fn: F, + direction: &str, +) -> String where F: Fn(&TrafficData) -> usize, { @@ -176,19 +235,26 @@ where for metric in metrics { for (interface_name, interface_data) in metric.status.iter() { for entry in &interface_data.traffic { - rendered_metrics.render_and_append_instance(&PrometheusInstance::new() - .with_label("interface_name", &*interface_name.clone()) - .with_label("direction", direction) - .with_value(value_fn(entry)) - .with_current_timestamp() - .expect("Error getting the current UNIX epoch")); + rendered_metrics.render_and_append_instance( + &PrometheusInstance::new() + .with_label("interface_name", &*interface_name.clone()) + .with_label("direction", direction) + .with_value(value_fn(entry)) + .with_current_timestamp() + .expect("Error getting the current UNIX epoch"), + ); } } } rendered_metrics.render() } -fn render_livebox_devices_metric(devices: &[Device], name: &str, help: &str, value_fn: F) -> String +fn render_livebox_devices_metric( + devices: &[Device], + name: &str, + help: &str, + value_fn: F, +) -> String where F: Fn(&Device) -> usize, { @@ -199,7 +265,10 @@ where .with_label("device_name", &*device.name) .with_label("device_type", &*device.device_type) .with_label("discovery_source", &*device.discovery_source) - .with_label("ip_address", &*device.ip_address.clone().unwrap_or("".to_string())) + .with_label( + "ip_address", + &*device.ip_address.clone().unwrap_or("".to_string()), + ) .with_value(value_fn(device)) .with_current_timestamp() .expect("Error getting the current UNIX epoch"), @@ -208,41 +277,48 @@ where rendered_metrics.render() } - #[cfg(test)] mod tests { use super::*; - use maplit::hashmap; use crate::livebox_client_rs::metrics::DeviceMetrics; + use maplit::hashmap; fn parse_args(args: Vec<&str>) -> clap::ArgMatches { Command::new("livebox-exporter-rs") .version("0.1.0") .author("tchapacan") - .arg(Arg::new("port") - .short('p') - .long("port") - .help("exporter port") - .value_name("PORT") - .default_value("9100")) - .arg(Arg::new("address") - .short('l') - .long("listen") - .help("listen address") - .value_name("ADDRESS") - .default_value("0.0.0.0")) - .arg(Arg::new("verbose") - .short('v') - .long("verbose") - .help("verbose logging") - .action(ArgAction::Count)) - .arg(Arg::new("password") - .short('P') - .long("password") - .help("Livebox password") - .value_name("PASSWORD") - .required(true)) + .arg( + Arg::new("port") + .short('p') + .long("port") + .help("exporter port") + .value_name("PORT") + .default_value("9100"), + ) + .arg( + Arg::new("address") + .short('l') + .long("listen") + .help("listen address") + .value_name("ADDRESS") + .default_value("0.0.0.0"), + ) + .arg( + Arg::new("verbose") + .short('v') + .long("verbose") + .help("verbose logging") + .action(ArgAction::Count), + ) + .arg( + Arg::new("password") + .short('P') + .long("password") + .help("Livebox password") + .value_name("PASSWORD") + .required(true), + ) .get_matches_from(args) } @@ -250,20 +326,47 @@ mod tests { fn test_parse_args_default() { let args = vec!["livebox-exporter-rs", "-P", "mypassword"]; let matches = parse_args(args); - assert_eq!(matches.get_one::("port"), Some(&String::from("9100"))); - assert_eq!(matches.get_one::("address"), Some(&String::from("0.0.0.0"))); + assert_eq!( + matches.get_one::("port"), + Some(&String::from("9100")) + ); + assert_eq!( + matches.get_one::("address"), + Some(&String::from("0.0.0.0")) + ); assert_eq!(matches.get_count("verbose"), 0); - assert_eq!(matches.get_one::("password"), Some(&String::from("mypassword"))); + assert_eq!( + matches.get_one::("password"), + Some(&String::from("mypassword")) + ); } #[test] fn test_parse_args_custom() { - let args = vec!["livebox-exporter-rs", "-p", "1234", "--listen", "127.0.0.1", "-vvv", "-P", "mypassword"]; + let args = vec![ + "livebox-exporter-rs", + "-p", + "1234", + "--listen", + "127.0.0.1", + "-vvv", + "-P", + "mypassword", + ]; let matches = parse_args(args); - assert_eq!(matches.get_one::("port"), Some(&String::from("1234"))); - assert_eq!(matches.get_one::("address"), Some(&String::from("127.0.0.1"))); + assert_eq!( + matches.get_one::("port"), + Some(&String::from("1234")) + ); + assert_eq!( + matches.get_one::("address"), + Some(&String::from("127.0.0.1")) + ); assert_eq!(matches.get_count("verbose"), 3); - assert_eq!(matches.get_one::("password"), Some(&String::from("mypassword"))); + assert_eq!( + matches.get_one::("password"), + Some(&String::from("mypassword")) + ); } #[test] @@ -301,17 +404,20 @@ mod tests { base_mac: "test".to_string(), }; let expected_output = "# HELP test_name test_help\n# TYPE test_name gauge\ntest_name{hardware=\"livebox\",manufacturer=\"test\",manufacturer_oui=\"test\",model_name=\"test\",product_class=\"test\",serial_number=\"test\",hardware_version=\"test\",software_version=\"test\",country=\"test\",external_ip_address=\"test\",base_mac=\"test\"} 1 TIMESTAMP_PLACEHOLDER\n"; - let result = render_livebox_info_metric( - &status, - "test_name", - "test_help", - |s| if s.device_status == "Up" { 1 } else { 0 }, + let result = render_livebox_info_metric(&status, "test_name", "test_help", |s| { + if s.device_status == "Up" { + 1 + } else { + 0 + } + }); + let expected_output_with_timestamp = expected_output.replace( + "TIMESTAMP_PLACEHOLDER", + &result.split_whitespace().last().unwrap(), ); - let expected_output_with_timestamp = expected_output.replace("TIMESTAMP_PLACEHOLDER", &result.split_whitespace().last().unwrap()); assert_eq!(result, expected_output_with_timestamp); } - #[test] fn test_render_livebox_status_metric() { let wan = WANConfiguration { @@ -330,27 +436,28 @@ mod tests { }; let expected_output = "# HELP test_name Livebox wan status\n# TYPE test_name gauge\ntest_name{port=\"wan\",link_type=\"test\",protocol=\"test\",mac_address=\"test\",ip_address=\"test\",remote_gateway=\"test\",remote_gadns_serversteway=\"test\",ipv6_address=\"test\"} 1 TIMESTAMP_PLACEHOLDER\n"; let result = render_livebox_status_metric(&wan, "test_name", "wan"); - let expected_output_with_timestamp = expected_output.replace("TIMESTAMP_PLACEHOLDER", &result.split_whitespace().last().unwrap()); + let expected_output_with_timestamp = expected_output.replace( + "TIMESTAMP_PLACEHOLDER", + &result.split_whitespace().last().unwrap(), + ); assert_eq!(result, expected_output_with_timestamp); } #[test] fn test_render_livebox_interface_metric() { - let metrics = vec![ - Metrics { - status: hashmap! { - "test_interface".to_string() => DeviceMetrics { - traffic: vec![ - TrafficData { - rx_counter: 123, - tx_counter: 456, - timestamp: 789, - }, - ], - }, + let metrics = vec![Metrics { + status: hashmap! { + "test_interface".to_string() => DeviceMetrics { + traffic: vec![ + TrafficData { + rx_counter: 123, + tx_counter: 456, + timestamp: 789, + }, + ], }, }, - ]; + }]; let expected_output = "# HELP test_name test_help\n# TYPE test_name gauge\ntest_name{interface_name=\"test_interface\",direction=\"rx\"} 123 TIMESTAMP_PLACEHOLDER\n"; let result = render_livebox_interface_metric( &metrics, @@ -368,21 +475,25 @@ mod tests { #[test] fn test_render_livebox_devices_metric() { - let devices = vec![ - Device { - key: "test".to_string(), - name: "test".to_string(), - discovery_source: "test".to_string(), - active: true, - device_type: "test".to_string(), - tags: "test".to_string(), - ip_address: Some("test".to_string()), - ssid: Some("test".to_string()), - channel: Some(1), - }, - ]; + let devices = vec![Device { + key: "test".to_string(), + name: "test".to_string(), + discovery_source: "test".to_string(), + active: true, + device_type: "test".to_string(), + tags: "test".to_string(), + ip_address: Some("test".to_string()), + ssid: Some("test".to_string()), + channel: Some(1), + }]; let expected_output = "# HELP test_name test_help\n# TYPE test_name gauge\ntest_name{device_name=\"test\",device_type=\"test\",discovery_source=\"test\",ip_address=\"test\"} 1 TIMESTAMP_PLACEHOLDER\n"; - let result = render_livebox_devices_metric(&devices, "test_name", "test_help", |d| if d.active { 1 } else { 0 }); + let result = render_livebox_devices_metric(&devices, "test_name", "test_help", |d| { + if d.active { + 1 + } else { + 0 + } + }); let expected_output_with_timestamp = expected_output.replace( "TIMESTAMP_PLACEHOLDER", &result.split_whitespace().last().unwrap(), @@ -391,5 +502,4 @@ mod tests { } // TODO : WIP More to come.. - }