diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..c4c41e8 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: Fmt · Clippy · Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Formato + run: cargo fmt --all -- --check + + - name: Test + run: cargo test --verbose diff --git a/.gitignore b/.gitignore index 6ffd2d9..a76c814 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,37 @@ -.cache/ -/build/ +/target/ +**/target/ + +Cargo.lock + +*.rlib +*.rmeta +*.rmeta.d +*.d + +.vscode/ +.idea/ +*.iml + +.idea/ + +.DS_Store +Thumbs.db + +*~ + +*.prof +*.perf +flamegraph.svg + +*.log + +.env +.env.* + +coverage/ +tarpaulin-report.html + +*.wasm + +*.pid +*.tmp diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index ae22efc..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,21 +0,0 @@ -cmake_minimum_required(VERSION 3.28) -project(lagger) - -set(CMAKE_CXX_STANDARD 23) -set(CMAKE_EXPORT_COMPILE_COMMANDS ON) - -add_executable(lagger) - -target_sources(lagger PRIVATE - ${CMAKE_SOURCE_DIR}/src/gui.cpp - ${CMAKE_SOURCE_DIR}/src/main.cpp - ${CMAKE_SOURCE_DIR}/src/utils.cpp -) - -target_include_directories(lagger PRIVATE - ${CMAKE_SOURCE_DIR}/src - ${CMAKE_SOURCE_DIR}/include - ${CMAKE_SOURCE_DIR}/include/lagger -) - - diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4ed538a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "NetLatencyInjector" +version = "1.0.0" +edition = "2024" + +[dependencies] +crossterm = "0.29.0" +ratatui = "0.30.1" +env_logger = "0.11.10" +log = "0.4.32" +thiserror = "2.0.18" diff --git a/include/lagger/gui.hpp b/include/lagger/gui.hpp deleted file mode 100644 index 8f2e3b4..0000000 --- a/include/lagger/gui.hpp +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include -#include - -namespace color { -constexpr const char* red = "\033[38;2;255;105;97m"; -constexpr const char* green = "\033[38;2;119;221;119m"; -constexpr const char* yellow = "\033[38;2;253;253;150m"; -constexpr const char* blue = "\033[38;2;174;198;207m"; -constexpr const char* reset = "\033[0m"; -} // namespace color - -class Gui { - public: - void start(); - - private: - std::string getDelay(); - std::expected setupInterface(); - void removeDelay(); - void setDelay(); - void clearGui(); - void drawGui(); - - std::string interface_{}; - bool running = false; -}; diff --git a/include/lagger/utils.hpp b/include/lagger/utils.hpp deleted file mode 100644 index a5f579d..0000000 --- a/include/lagger/utils.hpp +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include -#include - -namespace utils { - -std::expected checkTcInstalled(); -std::expected executeCommand(const char* command); -bool checkIfSudo(); - -} // namespace utils diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..a080ce2 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,29 @@ +use crate::utils::get_delay; +use crate::utils::list_interfaces; + +pub struct Interface { + pub name: String, + pub delay: u32, +} + +pub struct ProgramContext { + pub interf_vec: Vec, + pub interf_sel: usize, +} + +impl ProgramContext { + pub fn new() -> Result { + let mut interfaces = Vec::new(); + + for name in list_interfaces()? { + let delay = get_delay(&name)?.unwrap_or(0); + + interfaces.push(Interface { name, delay }); + } + + Ok(Self { + interf_vec: interfaces, + interf_sel: 0, + }) + } +} diff --git a/src/gui.cpp b/src/gui.cpp deleted file mode 100644 index 7a067cd..0000000 --- a/src/gui.cpp +++ /dev/null @@ -1,148 +0,0 @@ -#include "lagger/gui.hpp" - -#include - -#include -#include -#include -#include -#include -#include - -#include "lagger/utils.hpp" - -void Gui::clearGui() { std::cout << "\033[2J\033[H" << std::flush; } - -void Gui::removeDelay() { - auto cmd = "sudo tc qdisc del dev " + interface_ + " root"; - auto res = utils::executeCommand(cmd.c_str()); - if (!res) { - clearGui(); - std::cerr << color::red << res.error() << color::reset << "\n"; - } -} - -std::string Gui::getDelay() { - auto cmd = - "tc qdisc show dev " + interface_ + " | grep -oP 'delay \\K[0-9.]+ms'"; - auto res = utils::executeCommand(cmd.c_str()); - if (!res || res->empty()) { - return std::string(color::red) + "No delay" + color::reset; - } - return res.value(); -} - -void Gui::setDelay() { - clearGui(); - - int time; - std::cout << "Delay: "; - std::cin >> time; - const auto cmd = "sudo tc qdisc add dev " + interface_ + - " root netem delay " + std::to_string(time) + "ms"; - - auto res = utils::executeCommand(cmd.c_str()); - if (!res) { - removeDelay(); - std::cerr << color::red << res.error() << color::reset << "\n"; - } else { - std::cout << color::yellow << "New delay set\n" << color::reset; - } -} - -std::expected Gui::setupInterface() { - clearGui(); - - FILE* pipe{popen("ip -o link show | awk -F': ' '{print $2}'", "r")}; - if (!pipe) { - return std::unexpected("Failed to list interfaces"); - } - - std::vector interfaces; - char buffer[256]; - while (fgets(buffer, sizeof(buffer), pipe)) { - std::string iface(buffer); - iface.erase(iface.find_last_not_of(" \n\r\t") + 1); - if (iface != "lo") { - interfaces.push_back(iface); - } - } - pclose(pipe); - - if (interfaces.empty()) { - return std::unexpected("No network interfaces foudn"); - } - - std::cout << color::yellow << "\nAvailable network interfaces:\n" - << color::reset; - for (size_t i = 0; i < interfaces.size(); ++i) { - std::cout << color::green << " " << i + 1 << ") " << interfaces[i] - << "\n" - << color::reset; - } - - int choice = 0; - while (true) { - std::cout << color::yellow << "\nChoose interface (1-" - << interfaces.size() << "): " << color::reset; - std::cin >> choice; - if (std::cin.fail() || choice < 1 || - (size_t)choice > interfaces.size()) { - std::cin.clear(); - std::cin.ignore(10000, '\n'); - std::cout << "Invalid choice. Try again.\n"; - } else - break; - } - - interface_ = interfaces[choice - 1]; - return {}; -} - -void Gui::start() { - if (auto check = setupInterface(); !check) { - throw std::runtime_error(check.error()); - return; - } - - running = true; - while (running) { - drawGui(); - } -} - -void Gui::drawGui() { - // Draw gui - clearGui(); - std::cout << color::yellow << "Lagger settings: \n \n" << color::reset; - std::cout << color::blue << " Current delay: " << color::reset << getDelay() - << "\n" - << color::reset; - std::cout << color::green << " 1) Set new delay \n" << color::reset; - std::cout << color::green << " 2) Remove current delay \n" << color::reset; - std::cout << color::red << " 3) Exit\n" << color::reset; - - // Select option - int option; - std::cout << "\n\n" << color::reset << "Input: " << color::reset; - if (!(std::cin >> option)) { - std::cin.clear(); - std::cin.ignore(1000, '\n'); - return; - } - - // Elaborat - switch (option) { - case 1: - setDelay(); - break; - case 2: - removeDelay(); - break; - case 3: - clearGui(); - running = false; - break; - } -} - diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 0000000..10925a2 --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1 @@ +pub mod tui; diff --git a/src/gui/tui.rs b/src/gui/tui.rs new file mode 100644 index 0000000..7fa5ea3 --- /dev/null +++ b/src/gui/tui.rs @@ -0,0 +1,184 @@ +use crate::app::ProgramContext; +use crate::utils::{NetemError, set_delay}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph}; +use std::error::Error; +use std::ops::ControlFlow; + +const TITLE_HEIGHT: u16 = 3; + +pub struct Gui { + ctx: ProgramContext, + popup_open: bool, + popup_input: String, +} + +impl Gui { + pub fn run(ctx: ProgramContext) -> Result<(), Box> { + let mut gui = Self::new(ctx); + ratatui::run(|terminal| { + loop { + terminal.draw(|frame| gui.render(frame))?; + if gui.handle_events()?.is_break() { + break Ok::<(), Box>(()); + } + } + })?; + Ok(()) + } + + pub fn render(&self, frame: &mut ratatui::Frame) { + let general = Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .margin(1) + .constraints(vec![Constraint::Length(TITLE_HEIGHT), Constraint::Min(0)]) + .split(frame.area()); + + let rounded = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded); + + frame.render_widget( + Paragraph::new("NetLatencyInjector") + .alignment(Alignment::Center) + .block(rounded.clone()), + general[0], + ); + + let items: Vec = self + .ctx + .interf_vec + .iter() + .enumerate() + .map(|(idx, interf)| { + if idx == self.ctx.interf_sel { + ListItem::new(Line::from(format!( + " > {} [{}ms]", + interf.name, interf.delay + ))) + .style(Style::default().fg(Color::Black).bg(Color::LightBlue)) + } else { + ListItem::new(Line::from(format!(" {}", interf.name))) + } + }) + .collect(); + + frame.render_widget( + List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(" Interfaces "), + ), + general[1], + ); + + if self.popup_open { + let full = frame.area(); + + let area = Self::centered_rect(40, 3, full); + + frame.render_widget(Clear, area); + + frame.render_widget( + Paragraph::new(self.popup_input.as_str()).block( + Block::default() + .title(" Delay in ms — ESC close ") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Yellow)), + ), + area, + ); + } + } + + fn centered_rect(percent_x: u16, height_lines: u16, r: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(height_lines), + Constraint::Fill(1), + ]) + .split(r); + + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] + } + + pub fn handle_events(&mut self) -> Result, NetemError> { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if self.popup_open { + match key.code { + KeyCode::Enter => { + if let Ok(delay) = self.popup_input.trim().parse::() { + if let Some(iface) = + self.ctx.interf_vec.get_mut(self.ctx.interf_sel) + { + iface.delay = delay; + if let Err(error) = set_delay(&iface.name, delay) { + return Err(error); + } + } + } + self.close_popup(); + } + KeyCode::Esc => self.close_popup(), + KeyCode::Backspace => { + self.popup_input.pop(); + } + KeyCode::Char(c) if c.is_ascii_digit() => { + self.popup_input.push(c); + } + _ => {} + } + } else { + match key.code { + KeyCode::Char('q') => return Ok(ControlFlow::Break(())), + KeyCode::Enter => self.open_sel(), + KeyCode::Char('j') if !self.ctx.interf_vec.is_empty() => { + self.ctx.interf_sel = + (self.ctx.interf_sel + 1) % self.ctx.interf_vec.len(); + } + KeyCode::Char('k') if !self.ctx.interf_vec.is_empty() => { + if self.ctx.interf_sel == 0 { + self.ctx.interf_sel = self.ctx.interf_vec.len() - 1; + } else { + self.ctx.interf_sel -= 1; + } + } + _ => {} + } + } + } + _ => {} + } + Ok(ControlFlow::Continue(())) + } + + fn open_sel(&mut self) { + self.popup_input.clear(); + self.popup_open = true; + } + + fn close_popup(&mut self) { + self.popup_input.clear(); + self.popup_open = false; + } + + fn new(ctx: ProgramContext) -> Self { + Self { + ctx, + popup_open: false, + popup_input: String::new(), + } + } +} diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index 194f11d..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,32 +0,0 @@ -#include - -#include -#include - -#include "lagger/gui.hpp" -#include "lagger/utils.hpp" - -int main() { - if (!utils::checkIfSudo()) { - std::cerr << "The program should run as sudo\n"; - return EXIT_FAILURE; - } - - if (auto check = utils::checkTcInstalled(); !check) { - std::cerr << check.error() << "\n"; - return EXIT_FAILURE; - } - - try { - Gui gui; - gui.start(); - } catch (const std::runtime_error& e) { - std::cerr << color::red << "Critical Error: " << e.what() - << color::reset << "\n"; - return EXIT_FAILURE; - } catch (const std::exception& e) { - std::cerr << color::red << "Unexpected Error: " << e.what() - << color::reset << "\n"; - return EXIT_FAILURE; - } -} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1e00dd2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,36 @@ +#![warn(clippy::pedantic)] + +mod app; +mod gui; +mod utils; + +use std::env; +use std::process::ExitCode; + +use log::error; + +use app::ProgramContext; +use gui::tui::Gui; + +fn run_program() -> Result<(), Box> { + let ctx = ProgramContext::new()?; + + Gui::run(ctx) +} + +fn main() -> ExitCode { + env_logger::init(); + + if env::var("SUDO_USER").is_err() { + error!("Program require root privilege"); + return ExitCode::FAILURE; + } + + match run_program() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + error!("{e}"); + ExitCode::FAILURE + } + } +} diff --git a/src/utils.cpp b/src/utils.cpp deleted file mode 100644 index 9317262..0000000 --- a/src/utils.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#include "lagger/utils.hpp" - -#include - -#include -#include -#include -#include - -namespace utils { - -std::expected checkTcInstalled() { - const std::vector paths = { - "/usr/bin/", "/usr/sbin/", "/bin/", - "/sbin/", "/usr/local/bin/", "/usr/local/sbin/"}; - - for (const auto& dir : paths) { - auto candidate = std::filesystem::path(dir) / "tc"; - if (std::filesystem::exists(candidate) && - std::filesystem::is_regular_file(candidate)) { - return {}; - } - } - return std::unexpected("iproute-tc not installed"); -} - -std::expected executeCommand(const char* command) { - auto pipeCloser = [](FILE* f) { - if (f) pclose(f); - }; - - std::unique_ptr pipe(popen(command, "r"), - pipeCloser); - - if (!pipe) { - return std::unexpected( - std::string{"Can't open pipe for: " + std::string(command) + '\n'}); - } - - std::string output; - char buffer[256]; - while (fgets(buffer, sizeof(buffer), pipe.get())) output += buffer; - - return output; -} - -bool checkIfSudo() { return getuid() == 0; } - -} // namespace utils diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..a50e5cf --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,76 @@ +use std::process::Command; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum NetemError { + #[error("tc returned non-zero exit status: {0}")] + TcExitError(String), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +pub fn list_interfaces() -> std::io::Result> { + let mut ifaces: Vec = std::fs::read_dir("/sys/class/net/")? + .filter_map(std::io::Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|name| name != "lo") + .collect(); + ifaces.sort(); + Ok(ifaces) +} + +pub fn get_delay(iface: &str) -> Result, NetemError> { + let output = Command::new("tc") + .args(["qdisc", "show", "dev", iface]) + .output()?; + + if !output.status.success() { + return Err(NetemError::TcExitError( + String::from_utf8_lossy(&output.stderr).into_owned(), + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + for line in stdout.lines() { + if let Some(pos) = line.find("delay ") { + let part = &line[pos + 6..]; + + if let Some(end) = part.find("ms") { + let num = &part[..end]; + + if let Ok(val) = num.trim().parse::() { + return Ok(Some(val.round() as u32)); + } + } + } + } + + Ok(None) +} + +pub fn set_delay(iface: &str, ms: u32) -> Result<(), NetemError> { + let delay = format!("{ms}ms"); + + let output = Command::new("tc") + .args([ + "qdisc", + "replace", + "dev", + iface, + "root", + "netem", + "delay", + delay.as_str(), + ]) + .output()?; + + if !output.status.success() { + return Err(NetemError::TcExitError( + String::from_utf8_lossy(&output.stderr).into_owned(), + )); + } + + Ok(()) +}