tmobile-internet-tools 0.8.0

Set of tools for working with T-Mobile Home Internet gateways
use std::{
    fs, process, fmt,
    ffi::OsStr,
    collections::HashSet,
    ops::Deref
};
use chrono::{Datelike, DateTime, Local, Timelike};
use reqwest::blocking;
use rust_utils::{
    utils,
    logging::LogLevel
};
use lazy_static::lazy_static;
use cfg_if::cfg_if;
use crate::{
    LOG,
    DEBUG,
    PRINT_STDOUT,
    LOG_DIR,
    stats::*,
    devices::DeviceList,
    config::{GlobalConfig, Config}
};
use cursive::{
    theme::{BaseColor, Color, ColorStyle},
    utils::markup::StyledString
};
use json::JsonValue;

#[cfg(feature = "sagemcom")]
use qrcode::QrCode;

pub mod tracker;
pub mod archive;

cfg_if! {
    if #[cfg(feature = "nokia")] {
        mod trashcan;
        use trashcan::TrashcanClient;
    }
}

cfg_if! {
    if #[cfg(feature = "sagemcom")] {
        mod sagemcom;
        use sagemcom::SagemcomClient;
    }
}

lazy_static! {
    static ref NEED_AUTH: bool = !utils::run_command("nmcli", false, ["networking", "connectivity", "check"]).success;
}

#[derive(Debug)]
pub enum ClientError {
    LoginFailed,
    GatewayUnreachable(String),
    JSONParseError(json::Error, String),
    JSONValueNotFound(&'static str, String),
    NotSupported
}

#[derive(PartialEq, Eq)]
pub enum GatewayModel {
    Nokia,
    Sagemcom
}

impl actix_web::ResponseError for ClientError { }

impl fmt::Display for ClientError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            Self::LoginFailed => write!(f, "Unable to log into the T-Mobile gateway. Is the username/password in global.toml correct?"),
            Self::GatewayUnreachable(ref why) => write!(f, "Unable to reach the T-Mobile gateway: {why}"),
            Self::JSONParseError(ref why, ref data) => write!(f, "JSON data failed to parse correctly!\nError: {why}\nJSON data:\n{data}"),
            Self::JSONValueNotFound(ref name, ref data) => write!(f, "JSON value \"{name}\" was not found! JSON data:\n{data}"),
            Self::NotSupported => write!(f, "This gateway does not support this feature")
        }
    }
}

// this trait allows Option<T> to return an error and debug info
// if a JSON value cannot be found
pub trait JSONUnwrappable {
    type Output;
    fn unwrap_json(self, name: &'static str, data: &JsonValue) -> ClientResult<Self::Output>;
}

impl<T> JSONUnwrappable for Option<T> {
    type Output = T;

    fn unwrap_json(self, name: &'static str, data: &JsonValue) -> ClientResult<T> {
        match self {
            Some(t) => Ok(t),
            None => Err(ClientError::JSONValueNotFound(name, data.to_string()))
        }
    }
}

pub type ClientResult<T> = Result<T, ClientError>;

pub trait GatewayClient: Send + Sync {
    // request the gateway to reboot (no log output)
    fn reboot_gateway_no_log(&self) -> ClientResult<()>;

    // get a list of devices and their ipv4 addresses connected to the gateway
    fn get_devices(&self) -> ClientResult<DeviceList>;

    // get the 5G metrics
    fn get_5g_stats(&self) -> ClientResult<CellInfo>;

    // get the 4G (LTE) metrics
    fn get_4g_stats(&self) -> ClientResult<CellInfo>;

    // get the connection status as a boolean
    fn get_conn_status(&self) -> bool;

    // get info about the gateway
    fn get_info(&self) -> ClientResult<GatewayInfo>;

    // get advanced gateway stats
    fn get_adv_stats(&self) -> ClientResult<AdvancedGatewayStats>;

    // get download data usage (nokia only)
    #[cfg(feature = "nokia")]
    fn get_download(&self) -> ClientResult<u128> { Err(ClientError::NotSupported) }

    // get upload data usage (nokia only)
    #[cfg(feature = "nokia")]
    fn get_upload(&self) -> ClientResult<u128> { Err(ClientError::NotSupported) }

    // get list of active networks
    fn get_networks(&self) -> ClientResult<Vec<NetworkInfo>>;

    // set network config for gateway (sagemcom only)
    #[cfg(feature = "sagemcom")]
    fn set_networks(&self, _networks: &[NetworkInfo]) -> ClientResult<()> { Err(ClientError::NotSupported) }

    // set admin password (sagemcom only)
    #[cfg(feature = "sagemcom")]
    fn set_admin_pswd(&self, _password: &str) -> ClientResult<()> { Err(ClientError::NotSupported) }

    // gateway picture for the web interface
    fn webui_pic(&self) -> &'static str;

    // get the model of the gateway
    fn model(&self) -> GatewayModel;

    // request the gateway to reboot
    fn reboot_gateway(&self, print_stdout: bool) -> ClientResult<()> {
        LOG.line(LogLevel::Info, "Rebooting gateway...", print_stdout);
        self.reboot_gateway_no_log()
    }

    // get all stats
    fn get_all_stats(&self) -> ClientResult<GatewayStats> {
        let conn_status = self.get_conn_status();
        let info_5g = self.get_5g_stats()?;
        let info_4g = self.get_4g_stats()?;

        #[cfg(feature = "nokia")]
        let download = self.get_download().unwrap_or(0);

        #[cfg(feature = "nokia")]
        let upload = self.get_upload().unwrap_or(0);

        let networks = self.get_networks()?;
        let devices = self.get_devices()?;

        Ok(GatewayStats {
            conn_status,
            devices,

            #[cfg(feature = "nokia")]
            download,

            #[cfg(feature = "nokia")]
            upload,

            info_4g,
            info_5g,
            networks
        })
    }

    // get signal tracker entry
    fn get_tracker_entry(&self, now: DateTime<Local>, reboot: bool) -> ClientResult<tracker::SignalTrackerEntry> {
        let info_4g = self.get_4g_stats()?;
        let info_5g = self.get_5g_stats()?;
        let conn_stat = self.get_conn_status();
        let time = (now.hour(), now.minute());
        let date = (now.year() as u32, now.month(), now.day());
        Ok(tracker::SignalTrackerEntry {
            date,
            time,
            conn_stat,
            info_4g,
            info_5g,
            reboot
        })
    }
}

pub struct BoxedClient(Box<dyn GatewayClient>);

#[allow(clippy::new_without_default)]
impl BoxedClient {
    pub fn new() -> BoxedClient {
        let config = GlobalConfig::load();
        let exe = utils::get_execname();
        let print_stdout = &exe == "gatewaymon" || &exe == "tmo-webui";
        let client: Box<dyn GatewayClient> =
            if blocking::get(format!("http://{}/TMI/v1/gateway?get=all", config.gateway_ip)).is_ok() {
                #[cfg(feature = "sagemcom")]
                {
                    LOG.line_basic("Gateway model: Sagemcom", print_stdout);
                    Box::new(SagemcomClient::new())
                }

                #[cfg(not(feature = "sagemcom"))]
                {
                    LOG.line(LogLevel::Error, "The Sagemcom gateway model is not supported!", true);
                    process::exit(101);
                }
            }
            else if blocking::get(format!("http://{}/fastmile_radio_status_web_app.cgi", config.gateway_ip)).is_ok() {
                #[cfg(feature = "nokia")]
                {
                    LOG.line_basic("Gateway model: Nokia", print_stdout);
                    Box::new(TrashcanClient::new())
                }
                #[cfg(not(feature = "nokia"))]
                {
                    LOG.line(LogLevel::Error, "The Nokia gateway model is not supported!", true);
                    process::exit(101);
                }
            }
            else {
                LOG.line(LogLevel::Error, "This is not a T-Mobile Home Internet network!", true);
                process::exit(101);
            };

        BoxedClient(client)
    }
}

impl Deref for BoxedClient {
    type Target = Box<dyn GatewayClient>;
    fn deref(&self) -> &Box<dyn GatewayClient> { &self.0 }
}

// parse text as json
fn parse_json(source: &str) -> ClientResult<JsonValue> {
    match json::parse(source) {
        Ok(j) => Ok(j),
        Err(why) => Err(ClientError::JSONParseError(why, source.to_string()))
    }
}

// parse json objects for logging into the gateway
fn parse_login_json(source: &str) -> ClientResult<JsonValue> {
    match parse_json(source) {
        Ok(j) => Ok(j),
        Err(why) => {
            LOG.line(LogLevel::Error, format!("JSON Error: {why}"), false);
            LOG.line(LogLevel::Error, "Received input:", false);
            for line in source.lines() {
                LOG.line(LogLevel::Error, line, false);
            }
            Err(ClientError::LoginFailed)
        }
    }
}

// get the dates of all the stored logs
pub fn get_log_dates(archive: bool) -> Vec<(u32, u32, u32)> {
    if archive {
        if let Some(dates) = archive::get_log_dates() {
            return dates;
        }
    }
    let files = fs::read_dir(&*LOG_DIR).unwrap().map(|file| {
        let name_t = file.unwrap().file_name();
        name_t.to_str().unwrap().to_string()
    }).collect::<Vec<String>>();
    let mut dates = Vec::new();
    for file in files.iter().rev().filter(|file| file.contains("gatewaymon")) {
        let mut split1 = file.split('.');
        let mut split2 = split1.next().unwrap().split('-');
        split2.next();
        dates.push((
            split2.next().unwrap().parse().unwrap(),
            split2.next().unwrap().parse().unwrap(),
            split2.next().unwrap().parse().unwrap()
        ));
    }
    let mut uniques = HashSet::new();
    dates.retain(|d| uniques.insert(*d));
    dates.sort_unstable();
    dates.reverse();
    dates
}

// get a log file by date
pub fn get_log(date: (u32, u32, u32), archive: bool) -> StyledString {
    let raw = get_log_plain(date, archive);
    let mut styled = StyledString::new();
    for line in raw.lines() {
        let style = if line.contains("DEBUG]") {
            ColorStyle::from(Color::Dark(BaseColor::Cyan))
        }
        else if line.contains("WARN]") {
            ColorStyle::from(Color::Light(BaseColor::Yellow))
        }
        else if line.contains("ERROR]") {
            ColorStyle::from(Color::Light(BaseColor::Red))
        }
        else if line.contains("FATAL]") {
            ColorStyle::from((Color::Light(BaseColor::Red), Color::Dark(BaseColor::Black)))
        }
        else {
            ColorStyle::from(Color::Dark(BaseColor::Green))  
        };
        styled.append_styled(line, style);
        styled.append("\n");
    }
    styled
}

pub fn get_log_plain(date: (u32, u32, u32), archive: bool) -> String {
    if archive {
        if let Some(log) = archive::get_log(date) {
            return log;
        }
    }

    if let Ok(log) = fs::read_to_string(format!("{}/gatewaymon-{}-{}-{}.log", *LOG_DIR, date.0, date.1, date.2)) {
        return log;
    }

    String::new()
}

// check the network environment
// 0: network is fine
// 1: gateway needs rebooted
// 2: network is disconnected
pub fn check_net_env(full: bool) -> usize {
    let output_str = if full {
        LOG.line(*DEBUG, "Performing full check of network environment...", true);
        if ping_req() {
            return 0;
        }
        else {
            nmcli(["networking", "connectivity", "check"], false)
        }
    }
    else {
        nmcli(["-t", "-f", "CONNECTIVITY", "general"], false)
    };
    if output_str.contains("none") { 2 }
    else if output_str.contains("full") { 0 }
    else { 1 }
}

// are we connected to a valid network ?
pub fn is_valid_net() -> bool {
    let config = GlobalConfig::load();
    let networks = connected_networks();
    if networks.is_empty() {
        return false;
    }

    for network in networks {
        if network.1.contains("ethernet") {
            return true;
        }

        for name in &config.valid_networks {
            if network.0 == *name {
                return true;
            }
        }
    }

    false
}

// nmcli network list
pub fn connected_networks() -> Vec<(String, String)> {
    let out = nmcli(["-t", "-f", "NAME,TYPE", "con", "show", "--active"], true);
    if !out.contains(':') || out.is_empty() {
        return vec![];
    }
    out.split('\n').map(|line: &str| {
        let vals: Vec<&str> = line.split(':').collect();
        (vals[0].to_string(), vals[1].to_string())
    }).collect()
}

// execute a nmcli command
pub fn nmcli<A: IntoIterator<Item = S> + Clone, S: AsRef<OsStr>>(args: A, report_err: bool) -> String {
    let args_dbg = args.clone();
    let cmd = utils::run_command("nmcli", *NEED_AUTH, args);

    if !cmd.success && report_err {
        let err_msg = format!("Error executing nmcli command: {}", cmd.output);
        for line in err_msg.lines() {
            LOG.line(LogLevel::Error, line, true)
        }
        let mut cmd_ex = String::from("Command executed: nmcli");
        for arg in args_dbg {
            let arg_fmt = format!(" {}", arg.as_ref().to_str().unwrap());
            cmd_ex.push_str(&arg_fmt);
        }

        LOG.line(LogLevel::Error, cmd_ex, true)
    }
    cmd.output
}

// create a QR code for a wifi network (sagemcom only)
#[cfg(feature = "sagemcom")]
pub fn net_qr_code(name: &str, password: &str, security: &str) -> QrCode { QrCode::new(format!("WIFI:T:{security};S:{name};P:{password};H:;;")).unwrap() }

// perform a ping request
// true: the ping was successful
// false: there a was problem
fn ping_req() -> bool {
    let ping_cmd = utils::run_command("ping", false, ["paradisemod.net", "-c1", "-w10"]);
    if let LogLevel::Debug(dbg) = *DEBUG {
        if dbg {
            LOG.line(LogLevel::Debug(true), "Ping output:", *PRINT_STDOUT);
            for line in ping_cmd.output.lines() {
                LOG.line(LogLevel::Debug(true), line, *PRINT_STDOUT);
            }
        }
    }
    ping_cmd.success
}