├── .gitignore ├── .envrc ├── .github ├── dependency-review-config.yml ├── workflows │ └── dependency-review.yml ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── future.md │ └── bug.md ├── AUTHORS ├── .config └── tuxtape-dashboard-config.toml ├── src ├── dashboard │ ├── main.rs │ ├── cli.rs │ ├── action.rs │ ├── tui │ │ ├── popups │ │ │ ├── alert.rs │ │ │ └── cve_edit_preview.rs │ │ ├── popups.rs │ │ ├── pages.rs │ │ ├── components.rs │ │ ├── pages │ │ │ ├── configs.rs │ │ │ └── home.rs │ │ └── background.rs │ ├── logging.rs │ ├── errors.rs │ ├── README.md │ ├── database.rs │ ├── tui.rs │ ├── app.rs │ └── config.rs ├── parser │ ├── nist_api_types.rs │ └── rate_limiter.rs ├── server │ └── build_queue.rs └── kernel_builder │ └── main.rs ├── SECURITY.md ├── Cargo.toml ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── proto └── tuxtape_server.proto ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .vscode/ 3 | *.DS_Store 4 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export TUXTAPE_DASHBOARD_CONFIG=`pwd`/.config 2 | export TUXTAPE_DASHBOARD_DATA=`pwd`/.data 3 | export TUXTAPE_DASHBOARD_LOG_LEVEL=debug 4 | -------------------------------------------------------------------------------- /.github/dependency-review-config.yml: -------------------------------------------------------------------------------- 1 | fail-on-severity: 'critical' 2 | allow-licenses: 3 | - Apache-2.0 4 | - 0BSD 5 | - BSD-3-Clause 6 | - ISC 7 | - MIT 8 | - MPL-2.0 9 | - Unicode-3.0 10 | - Zlib 11 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Unless mentioned otherwise in a specific file's header, all code in this 2 | project is released under the Apache 2.0 license. 3 | 4 | The list of authors and contributors can be retrieved from the git 5 | commit history and in some cases, the file headers. 6 | 7 | -------------------------------------------------------------------------------- /.config/tuxtape-dashboard-config.toml: -------------------------------------------------------------------------------- 1 | [keybindings.Normal] 2 | q = "Quit" 3 | shift-h = "TabLeft" 4 | shift-l = "TabRight" 5 | h = "PaneLeft" 6 | l = "PaneRight" 7 | j = "ScrollDown" 8 | k = "ScrollUp" 9 | "" = "Select" 10 | 11 | [database] 12 | server_url = "127.0.0.1:50051" 13 | use_tls = false 14 | # tls_cert_path = "put path here if use_tls = true" 15 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | dependency-review: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 'Checkout Repository' 12 | uses: actions/checkout@v4 13 | - name: 'Dependency Review' 14 | uses: actions/dependency-review-action@v4 15 | with: 16 | config-file: './.github/dependency-review-config.yml' 17 | -------------------------------------------------------------------------------- /src/dashboard/main.rs: -------------------------------------------------------------------------------- 1 | use crate::app::App; 2 | use clap::Parser; 3 | use cli::Cli; 4 | use color_eyre::Result; 5 | 6 | mod action; 7 | mod app; 8 | mod cli; 9 | mod config; 10 | mod database; 11 | mod errors; 12 | mod logging; 13 | mod tui; 14 | 15 | #[tokio::main] 16 | async fn main() -> Result<()> { 17 | crate::errors::init()?; 18 | crate::logging::init()?; 19 | 20 | // TODO - decide if we should remove CLI args entirely 21 | let _args = Cli::parse(); 22 | let mut app = App::new().await?; 23 | app.run().await?; 24 | Ok(()) 25 | } 26 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | **Issue Number:** 7 | _Reference the issue this PR fixes._ 8 | 9 | 10 | **Description of Changes:** 11 | _Provide a clear and concise explanation of what changes you made and why._ 12 | 13 | 14 | **Testing Done:** 15 | _How did you test your changes? Share details like steps, tools used, or results._ 16 | 17 | 18 | **Terms of contribution:** 19 | 20 | By submitting this pull request, I agree that this contribution is licensed under the terms of the Apache License, Version 2.0. 21 | 22 | 23 | --- 24 | 25 | Thanks for submitting your pull request! We will review it as soon as possible. 26 | 27 | -------------------------------------------------------------------------------- /src/dashboard/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | use crate::config::{get_config_dir, get_data_dir}; 4 | 5 | #[derive(Parser, Debug)] 6 | #[command(author, version = version(), about)] 7 | pub struct Cli { 8 | // todo 9 | } 10 | 11 | const VERSION_MESSAGE: &str = concat!( 12 | env!("CARGO_PKG_VERSION"), 13 | "-", 14 | env!("VERGEN_GIT_DESCRIBE"), 15 | " (", 16 | env!("VERGEN_BUILD_DATE"), 17 | ")" 18 | ); 19 | 20 | pub fn version() -> String { 21 | let author = clap::crate_authors!(); 22 | 23 | let config_dir_path = get_config_dir().display().to_string(); 24 | let data_dir_path = get_data_dir().display().to_string(); 25 | 26 | format!( 27 | "\ 28 | {VERSION_MESSAGE} 29 | 30 | Authors: {author} 31 | 32 | Config directory: {config_dir_path} 33 | Data directory: {data_dir_path}" 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/dashboard/action.rs: -------------------------------------------------------------------------------- 1 | use crate::database::DatabaseAction; 2 | use crate::tui::pages::configs::ConfigsPageAction; 3 | use crate::tui::pages::home::HomePageAction; 4 | use crate::tui::pages::PageType; 5 | use crate::tui::popups::PopupType; 6 | use serde::Deserialize; 7 | use strum::Display; 8 | 9 | #[derive(Debug, Clone, PartialEq, Display, Deserialize)] 10 | pub enum Action { 11 | Tick, 12 | Render, 13 | Resize(u16, u16), 14 | Suspend, 15 | Resume, 16 | Quit, 17 | ClearScreen, 18 | Error(String), 19 | Help, 20 | TabLeft, 21 | TabRight, 22 | PaneLeft, 23 | PaneRight, 24 | ScrollDown, 25 | ScrollUp, 26 | Select, 27 | ChangePage(PageType), 28 | Database(DatabaseAction), 29 | EditPatchAtPath(String), 30 | Popup(PopupType), 31 | HomePage(HomePageAction), 32 | ConfigsPage(ConfigsPageAction), 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/future.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea or improvement for the project 4 | labels: status/needs-triage, type/enhancement 5 | --- 6 | 7 | 13 | 14 | ### **What would you like?** 15 | _Tell us about the feature or improvement you have in mind. What problem does it solve, or how will it help you?_ 16 | 17 | 18 | ### **Why do you need this?** 19 | _Share how this feature will make your work easier, more efficient, or just more fun!_ 20 | 21 | ### **Anything else to add?** 22 | _If you have extra details, screenshots, or context, we would love to see it!_ 23 | 24 | 25 | ## **Your setup:** 26 | -**Version**: 27 | -**Operating System**: 28 | -**Anything else?**: 29 | 30 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Reporting a Vulnerability 2 | 3 | If you identify a potential security vulnerability in this project, please adhere to the following guidelines to ensure it is handled responsibly: 4 | 5 | 1. **Do not disclose the vulnerability publicly.** Avoid creating a GitHub issue or sharing details in public forums to prevent exploitation before a fix is implemented. 6 | 2. **Contact us directly via email** at [OpenSourceSoftwareSecurity@geico.com](mailto:OpenSourceSoftwareSecurity@geico.com) with the following information: 7 | - A detailed description of the vulnerability. 8 | - Steps to reproduce the issue (if applicable). 9 | - Any relevant logs, screenshots, or supporting data. 10 | - Your contact information for follow-up (optional). 11 | 3. **Allow us sufficient time to address the issue.** We will acknowledge receipt of your report and work to resolve it as quickly as possible. 12 | 4. **Adhere to responsible disclosure practices.** Please refrain from publicly disclosing the issue until we confirm it has been mitigated. 13 | 14 | We greatly appreciate your contribution to improving the security of this project. 15 | 16 | -------------------------------------------------------------------------------- /src/dashboard/tui/popups/alert.rs: -------------------------------------------------------------------------------- 1 | /// A generic popup used for alerting the user without panicking. 2 | use super::PopupFrame; 3 | use crate::{action::Action, tui::components::Component}; 4 | use color_eyre::Result; 5 | use ratatui::{prelude::*, widgets::*}; 6 | 7 | pub struct Alert<'a> { 8 | frame: PopupFrame<'a>, 9 | text: Paragraph<'a>, 10 | } 11 | 12 | impl Alert<'_> { 13 | pub fn new(text: String) -> Self { 14 | let frame = PopupFrame::new("Alert".to_string(), Some(Color::Red)); 15 | let text = Paragraph::new(text).centered(); 16 | 17 | Self { frame, text } 18 | } 19 | } 20 | 21 | impl Component for Alert<'_> { 22 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 23 | self.frame.draw(frame, area)?; 24 | frame.render_widget(&self.text, self.frame.get_content_area(area)); 25 | 26 | Ok(()) 27 | } 28 | 29 | fn update(&mut self, action: Action) -> Result> { 30 | // Consume navigation actions, pass through all else 31 | match action { 32 | Action::ScrollDown | Action::ScrollUp | Action::TabLeft | Action::TabRight => Ok(None), 33 | _ => Ok(Some(action)), 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report - TuxTape 3 | about: Tell us about the issue you've encountered with TuxTape, and we'll do our best to help you! 4 | labels: status/needs-triage, type/bug 5 | --- 6 | 7 | 13 | 14 | 15 | 16 | ### **What did you expect to happen?** 17 | _Describe what you thought would happen._ 18 | 19 | 20 | ### **What actually happened?** 21 | _Tell us what happened instead, including any error messages, screenshots, or logs._ 22 | 23 | 24 | 25 | ### **Steps to reproduce the issue:** 26 | _Help us understand how to recreate the problem. The more detailed, the better!_ 27 | 28 | 1. Step 1 29 | 2. Step 2 30 | 3. ... 31 | 32 | 33 | 34 | ### **Your setup details:** 35 | - **TuxTape version**: 36 | - **Operating System**: 37 | - **Anything else we should know?**: 38 | 39 | 40 | 41 | --- 42 | 43 | Thanks for taking the time to report this! We'll review it and get back to you as soon as we can.i 44 | 45 | -------------------------------------------------------------------------------- /src/dashboard/logging.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use color_eyre::Result; 3 | use tracing_error::ErrorLayer; 4 | use tracing_subscriber::{fmt, prelude::*, EnvFilter}; 5 | 6 | lazy_static::lazy_static! { 7 | pub static ref LOG_ENV: String = format!("{}_LOG_LEVEL", config::PROJECT_NAME.clone()); 8 | pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); 9 | } 10 | 11 | pub fn init() -> Result<()> { 12 | let directory = config::get_data_dir(); 13 | std::fs::create_dir_all(directory.clone())?; 14 | let log_path = directory.join(LOG_FILE.clone()); 15 | let log_file = std::fs::File::create(log_path)?; 16 | let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into()); 17 | // If the `RUST_LOG` environment variable is set, use that as the default, otherwise use the 18 | // value of the `LOG_ENV` environment variable. If the `LOG_ENV` environment variable contains 19 | // errors, then this will return an error. 20 | let env_filter = env_filter 21 | .try_from_env() 22 | .or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?; 23 | let file_subscriber = fmt::layer() 24 | .with_file(true) 25 | .with_line_number(true) 26 | .with_writer(log_file) 27 | .with_target(false) 28 | .with_ansi(false) 29 | .with_filter(env_filter); 30 | tracing_subscriber::registry() 31 | .with(file_subscriber) 32 | .with(ErrorLayer::default()) 33 | .try_init()?; 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /src/dashboard/tui/popups.rs: -------------------------------------------------------------------------------- 1 | mod alert; 2 | mod cve_edit_preview; 3 | 4 | use crate::{action::Action, database::Cve, tui::components::Component}; 5 | pub use alert::Alert; 6 | use color_eyre::Result; 7 | pub use cve_edit_preview::CveEditPreview; 8 | use ratatui::{prelude::*, widgets::*}; 9 | use serde::Deserialize; 10 | use std::sync::Arc; 11 | use strum::Display; 12 | 13 | #[derive(Debug, Clone, PartialEq, Display, Deserialize)] 14 | pub enum PopupType { 15 | Alert(String), 16 | CveEditPreview(Arc), 17 | } 18 | 19 | /// The frame in which all Popups should be drawn 20 | pub struct PopupFrame<'a> { 21 | frame: Block<'a>, 22 | } 23 | 24 | impl PopupFrame<'_> { 25 | fn new(header_text: String, fg_color: Option) -> Self { 26 | let fg_color = fg_color.unwrap_or(Color::White); 27 | 28 | let frame = Block::bordered() 29 | .title_top(Line::from(header_text).centered()) 30 | .borders(Borders::ALL) 31 | .border_set(symbols::border::DOUBLE) 32 | .fg(fg_color); 33 | 34 | Self { frame } 35 | } 36 | 37 | /// Returns the area that the Popup content should be drawn in. 38 | fn get_content_area(&self, area: Rect) -> Rect { 39 | self.frame.inner(area) 40 | } 41 | } 42 | 43 | impl Component for PopupFrame<'_> { 44 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 45 | frame.render_widget(Clear, area); 46 | frame.render_widget(&self.frame, area); 47 | 48 | Ok(()) 49 | } 50 | 51 | fn update(&mut self, action: Action) -> Result> { 52 | Ok(Some(action)) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tuxtape-poc" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [[bin]] 7 | name = "tuxtape-cve-parser" 8 | path = "src/parser/parser.rs" 9 | 10 | [[bin]] 11 | name = "tuxtape-server" 12 | path = "src/server/server.rs" 13 | 14 | [[bin]] 15 | name = "tuxtape-kernel-builder" 16 | path = "src/kernel_builder/main.rs" 17 | 18 | [[bin]] 19 | name = "tuxtape-dashboard" 20 | path = "src/dashboard/main.rs" 21 | 22 | [dependencies] 23 | anyhow = "1.0" 24 | chrono = "0.4" 25 | clap = { version = "4.5", features = [ 26 | "derive", 27 | "cargo", 28 | "wrap_help", 29 | "unicode", 30 | "string", 31 | "unstable-styles" 32 | ] } 33 | const_format = "0.2" 34 | git2 = "0.19" 35 | prost = "0.13" 36 | rusqlite = { version = "0.32", features = ["bundled", "array"] } 37 | serde = { version = "1.0", features = ["derive", "rc"] } 38 | serde_derive = "1.0" 39 | serde_json = "1.0" 40 | tokio = { version = "1.40", features = ["full"] } 41 | tonic = {version = "0.12", features = ["tls", "gzip"] } 42 | tonic-reflection = "0.12" 43 | tonic-health = "0.12" 44 | ureq = { version = "2.10", features = ["json"] } 45 | 46 | # Dependencies specific to tuxtape-dashboard 47 | better-panic = "0.3" 48 | color-eyre = "0.6" 49 | config = "0.14" 50 | crossterm = { version = "0.28", features = ["serde", "event-stream"] } 51 | derive_deref = "1.1" 52 | directories = "5.0" 53 | edit = "0.1" 54 | futures = "0.3" 55 | human-panic = "2.0" 56 | lazy_static = "1.5" 57 | libc = "0.2" 58 | pretty_assertions = "1.4" 59 | ratatui = { version = "0.29", features = ["serde", "macros", "unstable-rendered-line-info"] } 60 | signal-hook = "0.3" 61 | strip-ansi-escapes = "0.2" 62 | strum = { version = "0.26", features = ["derive"] } 63 | toml = "0.8" 64 | tokio-util = "0.7" 65 | tracing = "0.1" 66 | tracing-error = "0.2" 67 | tracing-subscriber = { version = "0.3", features = ["env-filter", "serde"] } 68 | 69 | [build-dependencies] 70 | tonic-build = "0.12" 71 | # dependencies specific to tuxtape-dashboard 72 | vergen-gix = { version = "1.0", features = ["build", "cargo"] } 73 | -------------------------------------------------------------------------------- /src/parser/nist_api_types.rs: -------------------------------------------------------------------------------- 1 | /// JSON types for the NIST CVE API 2 | /// https://csrc.nist.gov/schema/nvd/api/2.0/cve_api_json_2.0.schema 3 | use serde_derive::{Deserialize, Serialize}; 4 | 5 | pub type NistResponse = Root; 6 | 7 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct Root { 10 | pub total_results: usize, 11 | pub vulnerabilities: Vec, 12 | } 13 | 14 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 15 | #[serde(rename_all = "camelCase")] 16 | pub struct Vulnerability { 17 | pub cve: Cve, 18 | } 19 | 20 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 21 | #[serde(rename_all = "camelCase")] 22 | pub struct Cve { 23 | pub id: String, 24 | pub metrics: Metrics, 25 | pub published: String, 26 | pub last_modified: String, 27 | pub descriptions: Vec, 28 | } 29 | 30 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 31 | #[serde(rename_all = "camelCase")] 32 | pub struct Description { 33 | pub lang: String, 34 | pub value: String, 35 | } 36 | 37 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 38 | #[serde(rename_all = "camelCase")] 39 | pub struct Metrics { 40 | pub cvss_metric_v31: Option>, 41 | } 42 | 43 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 44 | #[serde(rename_all = "camelCase")] 45 | pub struct CvssMetricV31 { 46 | pub cvss_data: CvssData, 47 | pub exploitability_score: f64, 48 | pub impact_score: f64, 49 | } 50 | 51 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 52 | #[serde(rename_all = "camelCase")] 53 | pub struct CvssData { 54 | pub version: String, 55 | pub vector_string: String, 56 | pub attack_vector: String, 57 | pub attack_complexity: String, 58 | pub privileges_required: String, 59 | pub user_interaction: String, 60 | pub scope: String, 61 | pub confidentiality_impact: String, 62 | pub integrity_impact: String, 63 | pub availability_impact: String, 64 | pub base_score: f64, 65 | pub base_severity: String, 66 | } 67 | -------------------------------------------------------------------------------- /src/dashboard/tui/pages.rs: -------------------------------------------------------------------------------- 1 | use crate::{action::Action, config::Config, tui::components::*}; 2 | use color_eyre::Result; 3 | use configs::ConfigsPage; 4 | use home::HomePage; 5 | use serde::{Deserialize, Serialize}; 6 | use std::{collections::HashMap, hash::Hash, sync::Arc}; 7 | use strum::{Display, EnumCount, EnumIter, FromRepr}; 8 | use tokio::sync::mpsc::UnboundedSender; 9 | 10 | pub mod configs; 11 | pub mod home; 12 | 13 | #[derive( 14 | Default, 15 | Debug, 16 | Clone, 17 | Copy, 18 | PartialEq, 19 | Eq, 20 | Hash, 21 | Display, 22 | FromRepr, 23 | EnumIter, 24 | EnumCount, 25 | Serialize, 26 | Deserialize, 27 | )] 28 | pub enum PageType { 29 | #[default] 30 | #[strum(to_string = "Home")] 31 | Home, 32 | #[strum(to_string = "Configs")] 33 | Configs, 34 | } 35 | 36 | pub struct PageManager { 37 | pages: HashMap>, 38 | current_page: PageType, 39 | } 40 | 41 | impl PageManager { 42 | pub fn new(config: Arc) -> Self { 43 | let mut pages: HashMap> = HashMap::new(); 44 | pages.insert(PageType::Home, Box::new(HomePage::new(config))); 45 | pages.insert(PageType::Configs, Box::new(ConfigsPage::new())); 46 | 47 | Self { 48 | pages, 49 | current_page: PageType::Home, 50 | } 51 | } 52 | 53 | pub fn update(&mut self, action: Action) -> Result> { 54 | match action { 55 | Action::ChangePage(page_type) => self.current_page = page_type, 56 | _ => return self.get_current_page().update(action), 57 | } 58 | 59 | Ok(Some(action)) 60 | } 61 | 62 | pub fn get_current_page(&mut self) -> &mut Box { 63 | self.pages 64 | .get_mut(&self.current_page) 65 | .expect("Attempted to get a Page that isn't registered") 66 | } 67 | 68 | pub fn register_action_handler(&mut self, tx: UnboundedSender) { 69 | for (_, page) in self.pages.iter_mut() { 70 | page.register_action_handler(tx.clone()) 71 | .expect("Registering page should never fail"); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/parser/rate_limiter.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | use std::time::{Duration, SystemTime}; 3 | 4 | /// Used to allow a rolling-window type rate limit. 5 | /// For example, the NIST API allows up to 5 requests in a 30 second window without an API key, so 6 | /// RateLimiter would be initialized with `RateLimiter::new(window_size: 5, window_duration: Duration::from_secs(30))` 7 | pub struct RateLimiter { 8 | /// A LIFO queue containing the SystemTime that each run of limit() was called 9 | run_times: VecDeque, 10 | /// The amount of runs that should be allowed within window_duration 11 | window_size: usize, 12 | /// The duration of time each 13 | window_duration: Duration, 14 | /// Initially `false`, but becomes `true` once the window is saturated. 15 | window_saturated: bool, 16 | } 17 | 18 | impl RateLimiter { 19 | pub fn new(window_size: usize, window_duration: Duration) -> Self { 20 | let window_size = if window_size == 0 { 21 | eprintln!( 22 | "RateLimiter::window_size must be >0. Returning an instance with a window_size of 1" 23 | ); 24 | 1 25 | } else { 26 | window_size 27 | }; 28 | 29 | Self { 30 | run_times: VecDeque::new(), 31 | window_size, 32 | window_duration, 33 | window_saturated: false, 34 | } 35 | } 36 | 37 | /// If the window is saturated, calls `thread::sleep` for the amount of time that needs to be limited. 38 | pub fn limit(&mut self) { 39 | if self.run_times.len() == (self.window_size - 1) { 40 | self.window_saturated = true; 41 | } 42 | 43 | if self.window_saturated { 44 | let oldest_run_time = self 45 | .run_times 46 | .pop_front() 47 | .expect("run_times will always have a >1 length if this block is hit"); 48 | 49 | let current_time = SystemTime::now(); 50 | let duration_since_oldest = current_time 51 | .duration_since(oldest_run_time) 52 | .expect("Time never flows backwards"); 53 | 54 | if duration_since_oldest < self.window_duration { 55 | let sleep_duration = self.window_duration - duration_since_oldest; 56 | std::thread::sleep(sleep_duration); 57 | } 58 | } 59 | 60 | self.run_times.push_back(SystemTime::now()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/dashboard/errors.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use std::env; 3 | use tracing::error; 4 | 5 | pub fn init() -> Result<()> { 6 | let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() 7 | .panic_section(format!( 8 | "This is a bug. Consider reporting it at {}", 9 | env!("CARGO_PKG_REPOSITORY") 10 | )) 11 | .capture_span_trace_by_default(false) 12 | .display_location_section(false) 13 | .display_env_section(false) 14 | .into_hooks(); 15 | eyre_hook.install()?; 16 | std::panic::set_hook(Box::new(move |panic_info| { 17 | if let Ok(mut t) = crate::tui::Tui::new() { 18 | if let Err(r) = t.exit() { 19 | error!("Unable to exit Terminal: {:?}", r); 20 | } 21 | } 22 | 23 | #[cfg(not(debug_assertions))] 24 | { 25 | use human_panic::{handle_dump, metadata, print_msg}; 26 | let metadata = metadata!(); 27 | let file_path = handle_dump(&metadata, panic_info); 28 | // prints human-panic message 29 | print_msg(file_path, &metadata) 30 | .expect("human-panic: printing error message to console failed"); 31 | eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr 32 | } 33 | let msg = format!("{}", panic_hook.panic_report(panic_info)); 34 | error!("Error: {}", strip_ansi_escapes::strip_str(msg)); 35 | 36 | #[cfg(debug_assertions)] 37 | { 38 | // Better Panic stacktrace that is only enabled when debugging. 39 | better_panic::Settings::auto() 40 | .most_recent_first(false) 41 | .lineno_suffix(true) 42 | .verbosity(better_panic::Verbosity::Full) 43 | .create_panic_handler()(panic_info); 44 | } 45 | 46 | std::process::exit(libc::EXIT_FAILURE); 47 | })); 48 | Ok(()) 49 | } 50 | 51 | /// Similar to the `std::dbg!` macro, but generates `tracing` events rather 52 | /// than printing to stdout. 53 | /// 54 | /// By default, the verbosity level for the generated events is `DEBUG`, but 55 | /// this can be customized. 56 | #[macro_export] 57 | macro_rules! trace_dbg { 58 | (target: $target:expr, level: $level:expr, $ex:expr) => {{ 59 | match $ex { 60 | value => { 61 | tracing::event!(target: $target, $level, ?value, stringify!($ex)); 62 | value 63 | } 64 | } 65 | }}; 66 | (level: $level:expr, $ex:expr) => { 67 | trace_dbg!(target: module_path!(), level: $level, $ex) 68 | }; 69 | (target: $target:expr, $ex:expr) => { 70 | trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) 71 | }; 72 | ($ex:expr) => { 73 | trace_dbg!(level: tracing::Level::DEBUG, $ex) 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 4 | 5 | The TuxTape team appreciates contributions to the project through pull requests and issues on the GitHub repository. 6 | 7 | Check the following guidelines before contributing to the project. 8 | 9 | ## Code of Conduct 10 | 11 | When contributing, you must adhere to the Code of Conduct. 12 | 13 | ## License and copyright 14 | 15 | By default, any contribution to this project is made under the Apache 16 | 2.0 license. 17 | 18 | The author of a change remains the copyright holder of their code 19 | (no copyright assignment). 20 | 21 | ## Pull requests 22 | 23 | Changes to this project should be proposed as pull requests on GitHub. 24 | 25 | Proposed changes will then go through review there and once approved, 26 | be merged in the main branch. 27 | 28 | ### Developer Certificate of Origin 29 | 30 | To improve tracking of contributions to this project we use the DCO 1.1 31 | and use a "sign-off" procedure for all changes going into the branch. 32 | 33 | The sign-off is a simple line at the end of the explanation for the 34 | commit which certifies that you wrote it or otherwise have the right 35 | to pass it on as an open-source contribution. 36 | 37 | ``` 38 | Developer Certificate of Origin 39 | Version 1.1 40 | 41 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 42 | 660 York Street, Suite 102, 43 | San Francisco, CA 94110 USA 44 | 45 | Everyone is permitted to copy and distribute verbatim copies of this 46 | license document, but changing it is not allowed. 47 | 48 | Developer's Certificate of Origin 1.1 49 | 50 | By making a contribution to this project, I certify that: 51 | 52 | (a) The contribution was created in whole or in part by me and I 53 | have the right to submit it under the open source license 54 | indicated in the file; or 55 | 56 | (b) The contribution is based upon previous work that, to the best 57 | of my knowledge, is covered under an appropriate open source 58 | license and I have the right under that license to submit that 59 | work with modifications, whether created in whole or in part 60 | by me, under the same open source license (unless I am 61 | permitted to submit under a different license), as indicated 62 | in the file; or 63 | 64 | (c) The contribution was provided directly to me by some other 65 | person who certified (a), (b) or (c) and I have not modified 66 | it. 67 | 68 | (d) I understand and agree that this project and the contribution 69 | are public and that a record of the contribution (including all 70 | personal information I submit with it, including my sign-off) is 71 | maintained indefinitely and may be redistributed consistent with 72 | this project or the open source license(s) involved. 73 | ``` 74 | 75 | An example of a valid sign-off line is: 76 | 77 | ``` 78 | Signed-off-by: Random J Developer 79 | ``` 80 | 81 | Use a known identity and a valid e-mail address. 82 | Sorry, no anonymous contributions are allowed. 83 | 84 | We also require each commit be individually signed-off by their author, 85 | even when part of a larger set. You may find `git commit -s` useful. 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 26 | * Trolling, insulting/derogatory comments, and personal or political attacks 27 | * Public or private harassment 28 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 29 | * Other conduct which could reasonably be considered inappropriate in a professional setting 30 | 31 | ## Our Responsibilities 32 | 33 | Project maintainers are responsible for clarifying the standards of acceptable 34 | behavior and are expected to take appropriate and fair corrective action in 35 | response to any instances of unacceptable behavior. 36 | 37 | Project maintainers have the right and responsibility to remove, edit, or 38 | reject comments, commits, code, wiki edits, issues, and other contributions 39 | that are not aligned to this Code of Conduct, or to ban temporarily or 40 | permanently any contributor for other behaviors that they deem inappropriate, 41 | threatening, offensive, or harmful. 42 | 43 | ## Scope 44 | 45 | This Code of Conduct applies both within project spaces and in public spaces 46 | when an individual is representing the project or its community. Examples of 47 | representing a project or community include using an official project e-mail 48 | address, posting via an official social media account, or acting as an appointed 49 | representative at an online or offline event. Representation of a project may be 50 | further defined and clarified by project maintainers. 51 | 52 | ## Enforcement 53 | 54 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 55 | reported by contacting the project team at tuxtape@geico.com. All 56 | complaints will be reviewed and investigated and will result in a response that 57 | is deemed necessary and appropriate to the circumstances. The project team is 58 | obligated to maintain confidentiality with regard to the reporter of an incident. 59 | Further details of specific enforcement policies may be posted separately. 60 | 61 | Project maintainers who do not follow or enforce the Code of Conduct in good 62 | faith may face temporary or permanent repercussions as determined by other 63 | members of the project's leadership. 64 | 65 | ## Attribution 66 | 67 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 68 | available at 69 | 70 | [homepage]: https://www.contributor-covenant.org 71 | 72 | For answers to common questions about this code of conduct, see 73 | 74 | -------------------------------------------------------------------------------- /src/dashboard/tui/components.rs: -------------------------------------------------------------------------------- 1 | use crate::{action::Action, tui::Event}; 2 | use color_eyre::Result; 3 | use crossterm::event::{KeyEvent, MouseEvent}; 4 | use ratatui::{ 5 | layout::{Rect, Size}, 6 | Frame, 7 | }; 8 | use tokio::sync::mpsc::UnboundedSender; 9 | 10 | /// `Component` is a trait that represents a visual and interactive element of the user interface. 11 | /// 12 | /// Implementors of this trait can be registered with the main application loop and will be able to 13 | /// receive events, update state, and be rendered on the screen. 14 | pub trait Component { 15 | /// Register an action handler that can send actions for processing if necessary. 16 | /// 17 | /// # Arguments 18 | /// 19 | /// * `tx` - An unbounded sender that can send actions. 20 | /// 21 | /// # Returns 22 | /// 23 | /// * `Result<()>` - An Ok result or an error. 24 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 25 | let _ = tx; // to appease clippy 26 | Ok(()) 27 | } 28 | /// Initialize the component with a specified area if necessary. 29 | /// 30 | /// # Arguments 31 | /// 32 | /// * `area` - Rectangular area to initialize the component within. 33 | /// 34 | /// # Returns 35 | /// 36 | /// * `Result<()>` - An Ok result or an error. 37 | fn init(&mut self, area: Size) -> Result<()> { 38 | let _ = area; // to appease clippy 39 | Ok(()) 40 | } 41 | /// Handle incoming events and produce actions if necessary. 42 | /// 43 | /// # Arguments 44 | /// 45 | /// * `event` - An optional event to be processed. 46 | /// 47 | /// # Returns 48 | /// 49 | /// * `Result>` - An action to be processed or none. 50 | fn handle_events(&mut self, event: Option) -> Result> { 51 | let action = match event { 52 | Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, 53 | Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, 54 | _ => None, 55 | }; 56 | Ok(action) 57 | } 58 | /// Handle key events and produce actions if necessary. 59 | /// 60 | /// # Arguments 61 | /// 62 | /// * `key` - A key event to be processed. 63 | /// 64 | /// # Returns 65 | /// 66 | /// * `Result>` - An action to be processed or none. 67 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 68 | let _ = key; // to appease clippy 69 | Ok(None) 70 | } 71 | /// Handle mouse events and produce actions if necessary. 72 | /// 73 | /// # Arguments 74 | /// 75 | /// * `mouse` - A mouse event to be processed. 76 | /// 77 | /// # Returns 78 | /// 79 | /// * `Result>` - An action to be processed or none. 80 | fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result> { 81 | let _ = mouse; // to appease clippy 82 | Ok(None) 83 | } 84 | /// Update the state of the component based on a received action. (REQUIRED) 85 | /// 86 | /// # Arguments 87 | /// 88 | /// * `action` - An action that may modify the state of the component. 89 | /// 90 | /// # Returns 91 | /// 92 | /// * `Result>` - An action to be processed or none. 93 | fn update(&mut self, action: Action) -> Result> { 94 | // Just pass through Action by default 95 | Ok(Some(action)) 96 | } 97 | /// Render the component on the screen. (REQUIRED) 98 | /// 99 | /// # Arguments 100 | /// 101 | /// * `f` - A frame used for rendering. 102 | /// * `area` - The area in which the component should be drawn. 103 | /// 104 | /// # Returns 105 | /// 106 | /// * `Result<()>` - An Ok result or an error. 107 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>; 108 | } 109 | -------------------------------------------------------------------------------- /src/dashboard/README.md: -------------------------------------------------------------------------------- 1 | # tuxtape-dashboard 2 | 3 | A TUI dashboard for creating, reviewing, and submitting kpatch-compatible livepatches. 4 | 5 | ## Design 6 | 7 | This project is based on the [`ratatui` Component template](https://ratatui.rs/templates/component/), but has some modifications to make the solution more domain specific and easier to maintain based on the needs of this project. Before contributing to this project, I suggest reading through the linked documentation as it breaks down most of the project structure of this program. The following documentation in this README is mostly meant to supplement that documentation and explain the changes made to that template. 8 | 9 | ### Component 10 | 11 | A `Component` is an object that can be rendered onto the terminal (or to use `ratatui` lingo, `Frame`, which refers to the visible portion in your terminal emulator). 12 | 13 | > Note: the term `Widget` is used in the `ratatui` ecosystem to refer to something similar. For our purposes in this project, just consider a `Widget` to be a `Component` designed by the `ratatui` community. 14 | 15 | ### Page 16 | 17 | A "Page" is a term which is specific to this project. It refers to a collection of `Component`s that work together to create something akin to a webpage. For example, the `HomePage` consists of the table of CVEs that the user utilizes to select CVEs they wish to edit. 18 | 19 | ### Background 20 | 21 | Also specific to this project, the `Background` is the "anchor point" that all Pages are rendered onto. The `Background` should _always_ be rendered, so it's treated specially here. 22 | 23 | The `Background` visually consists of three things: 24 | 25 | 1. `PageTabs` (used to navigate between the different Pages) 26 | 2. `PageManager::current_page` (the main thing the user is looking at and operating on) 27 | 3. `Footer` (used to display keybindings) 28 | 29 | The `Background` is the parent of `PageManager` which instantiates all the Pages and forwards `draw()` calls to the current Page. 30 | 31 | ### Popup 32 | 33 | A `Popup` is a special kind of component that takes priority over everything. When a `Popup` is displayed, it intercepts all `Actions` that would be consumed by either the `Background` or current Page. It gets rendered on top of the Page as well. 34 | 35 | ## Message passing 36 | 37 | We should avoid blocking for long times on the TUI thread as it will make the program feel unresponsive. To get around this, we use a message passing scheme. The root `Action` enum is located at `action.rs` and this represents any action that can occur in the program, be that a tick, call to render, an event from the terminal resizing, the user wishing to quit, a request to query the database, etc. When a module in the program has an `Action` that is specific to itself (e.g. `DatabaseAction`), it gets declared in that module (`database.rs::DatabaseAction`) and wrapped in a global `Action` in `actions.rs` (e.g. `Database(DatabaseAction)`). 38 | 39 | In the Components boilerplate, `KeyEvent`s (the user pressing a key) are interpereted into `Action`s in `App::handle_events()`. The `Action`s are received by `App::handle_actions()` and get sent "downstream" to things that can consume them. The stream looks as follows: 40 | 41 | ``` 42 | App -> 43 | Background -> 44 | (if exists) Popup -> 45 | PageTabs -> 46 | PageManager -> 47 | PageManager[current_page] -> 48 | Database (if DatabaseAction) 49 | ``` 50 | 51 | When writing a consumer of `Action`s, it should either fully consume an `Action` in its `update()`, change some of its own state based on the `Action` and forward the `Action` downstream, or ignore the `Action` entirely and forward it downstream. **Always** make sure that you are not fully consuming `Action`s that your `Component` doesn't need or that other `Component`s expect as they may be downstream from your consumer. 52 | 53 | For example, `HomePage::update()` may look like this: 54 | 55 | ``` 56 | fn update(&mut self, action: Action) -> Result> { 57 | match &action { 58 | Action::ScrollDown => self.table_state.scroll_down_by(1), 59 | Action::ScrollUp => self.table_state.scroll_up_by(1), 60 | Action::Database(DatabaseAction::Response(response)) => match response { 61 | Response::PopulateTable(cve_instances) => self.build_table(cve_instances.clone()), 62 | }, 63 | Action::Select => self.edit_patch()?, 64 | _ => return Ok(Some(action)), 65 | } 66 | Ok(None) 67 | } 68 | ``` 69 | 70 | It cares about only four types of `Action`s: `ScrollDown`/`ScrollUp`/`Select` (used for navigation) and `Database(DatabaseAction::Response(PopulateTable))` (sent by `Database` after `HomePage` requests the information it needs to draw its table). The only time it returns `Ok(None)` is when it has fully consumed the action (since `HomePage` is the only thing in the stream that consumes for those four `Actions`, it doesn't need to forward them on). If it receives an `Action` it *doesn't* consume, the `_ => return Ok(Some(action))` case gets hit and the `Action` is forwarded down the stream. 71 | 72 | Every `Component` should have a member `command_tx: Option>` (it's an `Option` because the Components template registers all of the `command_tx` after the TUI is fully running, which I'm not sure is entirely necessary and may remove later). When an `Action` needs to be emitted by a `Component` (e.g. `HomePage` emitting an `Action::Database(DatabaseAction::Request))`), it should be sent via `self.command_tx.send()`. There is a single listener for the commands in `App` that will push all `Action`s emitted since the previous loop downstream in the main loop at `App::run()`. 73 | -------------------------------------------------------------------------------- /src/dashboard/database.rs: -------------------------------------------------------------------------------- 1 | /// This file should hold the single connection to the TuxTape database needed for this dashboard. 2 | /// Every function here should be `async` so requests to the database do not block the TUI. 3 | mod tuxtape_server { 4 | tonic::include_proto!("tuxtape_server"); 5 | } 6 | 7 | use crate::action::Action; 8 | use crate::config::Config; 9 | use color_eyre::Result; 10 | use serde::{Deserialize, Serialize}; 11 | use std::sync::Arc; 12 | use strum::Display; 13 | use tokio::sync::mpsc::UnboundedSender; 14 | use tonic::{ 15 | codec::CompressionEncoding, 16 | transport::{Certificate, Channel, ClientTlsConfig}, 17 | }; 18 | use tuxtape_server::{database_client::DatabaseClient, FetchCvesRequest, PutKernelConfigRequest}; 19 | pub use tuxtape_server::{Cve, CveInstance, KernelConfig, KernelConfigMetadata, KernelVersion}; 20 | 21 | #[derive(Clone)] 22 | pub struct Database { 23 | client: DatabaseClient, 24 | command_tx: UnboundedSender, 25 | } 26 | 27 | impl Database { 28 | pub async fn new(config: Arc, command_tx: UnboundedSender) -> Result { 29 | let url = match config.database.use_tls { 30 | true => format!("https://{}", config.database.server_url), 31 | false => format!("http://{}", config.database.server_url), 32 | }; 33 | 34 | // Strip port from URL if one was provided 35 | let domain_name = if let Some(domain_name) = config 36 | .database 37 | .server_url 38 | .split(':') 39 | .collect::>() 40 | .first() 41 | { 42 | *domain_name 43 | } else { 44 | &config.database.server_url 45 | }; 46 | 47 | let channel = 48 | match config.database.use_tls { 49 | true => { 50 | // TODO - improve error handling 51 | let pem = 52 | std::fs::read_to_string(config.database.tls_cert_path.as_ref().expect( 53 | "tls cert does not exist but option use_tls = true in config", 54 | ))?; 55 | let ca = Certificate::from_pem(pem); 56 | 57 | let tls = ClientTlsConfig::new() 58 | .ca_certificate(ca) 59 | .domain_name(domain_name); 60 | 61 | Channel::from_shared(url)? 62 | .tls_config(tls)? 63 | .connect() 64 | .await? 65 | } 66 | false => Channel::from_shared(url)?.connect().await?, 67 | }; 68 | 69 | let client = DatabaseClient::new(channel) 70 | .accept_compressed(CompressionEncoding::Gzip) 71 | .send_compressed(CompressionEncoding::Gzip) 72 | .max_decoding_message_size(usize::MAX) 73 | .max_encoding_message_size(usize::MAX); 74 | 75 | Ok(Self { client, command_tx }) 76 | } 77 | 78 | pub fn handle_request(&self, request: &Request) -> Result<()> { 79 | match request { 80 | Request::PopulateTable() => { 81 | tokio::task::spawn(fetch_all_relevant_cves(self.clone())); 82 | } 83 | Request::PutKernelConfig(kernel_config) => { 84 | tokio::task::spawn(put_kernel_config(self.clone(), kernel_config.clone())); 85 | } 86 | } 87 | 88 | Ok(()) 89 | } 90 | } 91 | 92 | async fn fetch_all_relevant_cves(mut db: Database) { 93 | let request = create_fetch_cves_request(None, false, false); 94 | let response = db.client.fetch_cves(request).await.unwrap(); 95 | let cves = response 96 | .into_inner() 97 | .cves 98 | .iter() 99 | .map(|cve| Arc::new(cve.clone())) 100 | .collect(); 101 | 102 | let database_response = Response::PopulateTable(cves); 103 | let action = Action::Database(DatabaseAction::Response(database_response)); 104 | let _ = db.command_tx.send(action); 105 | } 106 | 107 | fn create_fetch_cves_request( 108 | kernel_configs_metadata: Option>, 109 | exclude_unpatched: bool, 110 | exclude_deployable_patched: bool, 111 | ) -> tonic::Request { 112 | let kernel_configs_metadata = kernel_configs_metadata.unwrap_or_default(); 113 | 114 | let fetch_cve_req = FetchCvesRequest { 115 | kernel_configs_metadata, 116 | exclude_unpatched, 117 | exclude_deployable_patched, 118 | }; 119 | tonic::Request::new(fetch_cve_req) 120 | } 121 | 122 | async fn put_kernel_config(mut db: Database, kernel_config: KernelConfig) { 123 | let request = PutKernelConfigRequest { 124 | kernel_config: Some(kernel_config.clone()), 125 | }; 126 | let response = db.client.put_kernel_config(request).await; 127 | 128 | let database_response = Response::PutKernelConfig { 129 | kernel_config_metadata: kernel_config.metadata, 130 | success: response.is_ok(), 131 | }; 132 | let action = Action::Database(DatabaseAction::Response(database_response)); 133 | db.command_tx.send(action).expect("Should not fail to send"); 134 | } 135 | 136 | #[derive(Debug, Clone, PartialEq, Display, Serialize, Deserialize)] 137 | pub enum DatabaseAction { 138 | Request(Request), 139 | Response(Response), 140 | } 141 | 142 | #[derive(Debug, Clone, PartialEq, Display, Serialize, Deserialize)] 143 | pub enum Request { 144 | PopulateTable(), 145 | PutKernelConfig(KernelConfig), 146 | } 147 | 148 | #[derive(Debug, Clone, PartialEq, Display, Serialize, Deserialize)] 149 | pub enum Response { 150 | PopulateTable(Vec>), 151 | PutKernelConfig { 152 | kernel_config_metadata: Option, 153 | success: bool, 154 | }, 155 | } 156 | -------------------------------------------------------------------------------- /proto/tuxtape_server.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package tuxtape_server; 3 | 4 | service Database { 5 | // Fetch CVEs sorted by base score. 6 | rpc FetchCves(FetchCvesRequest) returns (FetchCvesReponse); 7 | // Fetch all kernel configs metadata in the database. 8 | rpc FetchKernelConfigsMetadata(FetchKernelConfigsMetadataRequest) returns (FetchKernelConfigsMetadataResponse); 9 | // Fetch a kernel config from the database. 10 | rpc FetchKernelConfig(FetchKernelConfigRequest) returns (FetchKernelConfigResponse); 11 | // Put a kernel config into the database. 12 | rpc PutKernelConfig(PutKernelConfigRequest) returns (PutKernelConfigResponse); 13 | // Register a kernel builder. Called by tuxtape-kernel-builder. 14 | rpc RegisterKernelBuilder(RegisterKernelBuilderRequest) returns (RegisterKernelBuilderResponse); 15 | } 16 | 17 | service Builder { 18 | // Builds a kernel then calls PutKernelBuild on the database server. 19 | rpc BuildKernel(BuildKernelRequest) returns (BuildKernelResponse); 20 | } 21 | 22 | message FetchCvesRequest { 23 | // The metadata for the KernelConfigs which you are requesting CVEs for. 24 | // If empty, returns CVEs for all KernelConfig in the database. 25 | repeated KernelConfigMetadata kernel_configs_metadata = 1; 26 | // If true, excludes CVEs which never received a patch in a later KernelVersion. 27 | bool exclude_unpatched = 2; 28 | // If true, excludes CVEs which already have a deployable patch. 29 | bool exclude_deployable_patched = 3; 30 | } 31 | 32 | message FetchCvesReponse { 33 | repeated Cve cves = 1; 34 | } 35 | 36 | message FetchKernelConfigsMetadataRequest { 37 | } 38 | 39 | message FetchKernelConfigsMetadataResponse { 40 | repeated KernelConfigMetadata metadata = 1; 41 | } 42 | 43 | message FetchKernelConfigRequest { 44 | KernelConfigMetadata metadata = 1; 45 | } 46 | 47 | message FetchKernelConfigResponse { 48 | KernelConfig kernel_config = 1; 49 | } 50 | 51 | message KernelConfig { 52 | // Metadata about the kernel config 53 | KernelConfigMetadata metadata = 1; 54 | // The config file itself. 55 | string config_file = 2; 56 | } 57 | 58 | message KernelConfigMetadata { 59 | // The name of the kernel config file. 60 | string config_name = 1; 61 | // The kernel version that this config is to be built on. 62 | KernelVersion kernel_version = 2; 63 | } 64 | 65 | message Cve { 66 | // The ID of the CVE. 67 | string id = 1; 68 | // Will be null if the CVE has not yet been evaluated by NIST. 69 | optional float severity = 2; 70 | // Will be null if the CVE has not yet been evaluated by NIST. 71 | optional string attack_vector = 3; 72 | // Will be null if the CVE has not yet been evaluated by NIST. 73 | optional string attack_complexity = 4; 74 | // Will be null if the CVE has not yet been evaluated by NIST. 75 | optional string privileges_required = 5; 76 | // Will be null if the CVE has not yet been evaluated by NIST. 77 | optional string user_interaction = 6; 78 | // Will be null if the CVE has not yet been evaluated by NIST. 79 | optional string scope = 7; 80 | // Will be null if the CVE has not yet been evaluated by NIST. 81 | optional string confidentiality_impact = 8; 82 | // Will be null if the CVE has not yet been evaluated by NIST. 83 | optional string integrity_impact = 9; 84 | // Will be null if the CVE has not yet been evaluated by NIST. 85 | optional string availability_impact = 10; 86 | // Will be null if the CVE has not yet been evaluated by NIST. 87 | optional string description = 11; 88 | // Instances of this CVE across different KernelVersions. 89 | repeated CveInstance instances = 12; 90 | } 91 | 92 | message CveInstance { 93 | // The unique title for this specific instance of the CVE. 94 | // Formatted {cve_id}-{introduced}-{fixed_commit-prefix}. 95 | string title = 1; 96 | // The KernelVersion in which the CVE was introduced. 97 | KernelVersion introduced = 2; 98 | // The KernelVersion that patched the CVE. 99 | // Will be null if the CVE was not patched in a later KernelVersion. 100 | optional KernelVersion fixed = 3; 101 | // The prefix of the commit hash (first 12 characters) that 102 | // patched the CVE in the fixed KernelVersion. 103 | // Will be null if the CVE was not patched in a later KernelVersion. 104 | optional string fixed_commit_prefix = 4; 105 | // All files affected in this instance of the CVE. 106 | // Will be empty if the CVE was not patched in a later KernelVersion. 107 | repeated string affected_files = 5; 108 | // Metadata on all kernel configs affected by this CveInstance. 109 | // Will be empty if the CVE was not patched in a later KernelVerision or if no 110 | // kernel build on the fleet contains the affected_files. 111 | repeated KernelConfigMetadata affected_configs = 6; 112 | // The raw git diff of the commit that patched the CVE in the fixed KernelVersion. 113 | // Will be null if the CVE was not patched in a later KernelVersion. 114 | optional string raw_patch = 7; 115 | // The kpatch-compatible patch approved for deployment to the fleet. 116 | // Will be null if no deployable patch was approved. 117 | optional string deployable_patch = 8; 118 | } 119 | 120 | message KernelVersion { 121 | uint32 major = 1; 122 | uint32 minor = 2; 123 | optional uint32 patch = 3; 124 | } 125 | 126 | message PutKernelConfigRequest { 127 | // The kernel_config you wish to add to the database. 128 | KernelConfig kernel_config = 1; 129 | } 130 | 131 | message PutKernelConfigResponse { 132 | } 133 | 134 | message PutKernelBuildResponse { 135 | } 136 | 137 | message BuildKernelRequest { 138 | // The KernelConfig to be built. 139 | KernelConfig kernel_config = 1; 140 | } 141 | 142 | message BuildKernelResponse { 143 | // A list of file paths (from the root of the kernel source tree) 144 | // that were included in this build. 145 | repeated string included_files = 1; 146 | } 147 | 148 | message RegisterKernelBuilderRequest { 149 | // The address to the kernel builder. 150 | string builder_address = 1; 151 | } 152 | 153 | message RegisterKernelBuilderResponse { 154 | } 155 | -------------------------------------------------------------------------------- /src/dashboard/tui.rs: -------------------------------------------------------------------------------- 1 | // TODO - remove this once we're out of PoC 2 | #![allow(dead_code)] 3 | 4 | pub mod background; 5 | pub mod components; 6 | pub mod pages; 7 | pub mod popups; 8 | 9 | use color_eyre::Result; 10 | use crossterm::{ 11 | cursor, 12 | event::{ 13 | DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, 14 | Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent, 15 | }, 16 | terminal::{EnterAlternateScreen, LeaveAlternateScreen}, 17 | }; 18 | use futures::{FutureExt, StreamExt}; 19 | use ratatui::backend::CrosstermBackend as Backend; 20 | use serde::{Deserialize, Serialize}; 21 | use std::{ 22 | io::{stdout, Stdout}, 23 | ops::{Deref, DerefMut}, 24 | time::Duration, 25 | }; 26 | use tokio::{ 27 | sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, 28 | task::JoinHandle, 29 | time::interval, 30 | }; 31 | use tokio_util::sync::CancellationToken; 32 | use tracing::error; 33 | 34 | #[derive(Clone, Debug, Serialize, Deserialize)] 35 | pub enum Event { 36 | Init, 37 | Quit, 38 | Error, 39 | Closed, 40 | Tick, 41 | Render, 42 | FocusGained, 43 | FocusLost, 44 | Paste(String), 45 | Key(KeyEvent), 46 | Mouse(MouseEvent), 47 | Resize(u16, u16), 48 | } 49 | 50 | pub struct Tui { 51 | pub terminal: ratatui::Terminal>, 52 | pub task: JoinHandle<()>, 53 | pub cancellation_token: CancellationToken, 54 | pub event_rx: UnboundedReceiver, 55 | pub event_tx: UnboundedSender, 56 | pub frame_rate: f64, 57 | pub tick_rate: f64, 58 | pub mouse: bool, 59 | pub paste: bool, 60 | } 61 | 62 | impl Tui { 63 | pub fn new() -> Result { 64 | let (event_tx, event_rx) = mpsc::unbounded_channel(); 65 | Ok(Self { 66 | terminal: ratatui::Terminal::new(Backend::new(stdout()))?, 67 | task: tokio::spawn(async {}), 68 | cancellation_token: CancellationToken::new(), 69 | event_rx, 70 | event_tx, 71 | frame_rate: 60.0, 72 | tick_rate: 4.0, 73 | mouse: false, 74 | paste: false, 75 | }) 76 | } 77 | 78 | pub fn mouse(mut self, mouse: bool) -> Self { 79 | self.mouse = mouse; 80 | self 81 | } 82 | 83 | pub fn paste(mut self, paste: bool) -> Self { 84 | self.paste = paste; 85 | self 86 | } 87 | 88 | pub fn start(&mut self) { 89 | self.cancel(); // Cancel any existing task 90 | self.cancellation_token = CancellationToken::new(); 91 | let event_loop = Self::event_loop( 92 | self.event_tx.clone(), 93 | self.cancellation_token.clone(), 94 | self.tick_rate, 95 | self.frame_rate, 96 | ); 97 | self.task = tokio::spawn(async { 98 | event_loop.await; 99 | }); 100 | } 101 | 102 | async fn event_loop( 103 | event_tx: UnboundedSender, 104 | cancellation_token: CancellationToken, 105 | tick_rate: f64, 106 | frame_rate: f64, 107 | ) { 108 | let mut event_stream = EventStream::new(); 109 | let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); 110 | let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); 111 | 112 | // if this fails, then it's likely a bug in the calling code 113 | event_tx 114 | .send(Event::Init) 115 | .expect("failed to send init event"); 116 | loop { 117 | let event = tokio::select! { 118 | _ = cancellation_token.cancelled() => { 119 | break; 120 | } 121 | _ = tick_interval.tick() => Event::Tick, 122 | _ = render_interval.tick() => Event::Render, 123 | crossterm_event = event_stream.next().fuse() => match crossterm_event { 124 | Some(Ok(event)) => match event { 125 | CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), 126 | CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), 127 | CrosstermEvent::Resize(x, y) => Event::Resize(x, y), 128 | CrosstermEvent::FocusLost => Event::FocusLost, 129 | CrosstermEvent::FocusGained => Event::FocusGained, 130 | CrosstermEvent::Paste(s) => Event::Paste(s), 131 | _ => continue, // ignore other events 132 | } 133 | Some(Err(_)) => Event::Error, 134 | None => break, // the event stream has stopped and will not produce any more events 135 | }, 136 | }; 137 | if event_tx.send(event).is_err() { 138 | // the receiver has been dropped, so there's no point in continuing the loop 139 | break; 140 | } 141 | } 142 | cancellation_token.cancel(); 143 | } 144 | 145 | pub fn stop(&self) -> Result<()> { 146 | self.cancel(); 147 | let mut counter = 0; 148 | while !self.task.is_finished() { 149 | std::thread::sleep(Duration::from_millis(1)); 150 | counter += 1; 151 | if counter > 50 { 152 | self.task.abort(); 153 | } 154 | if counter > 100 { 155 | error!("Failed to abort task in 100 milliseconds for unknown reason"); 156 | break; 157 | } 158 | } 159 | Ok(()) 160 | } 161 | 162 | pub fn enter(&mut self) -> Result<()> { 163 | crossterm::terminal::enable_raw_mode()?; 164 | crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; 165 | if self.mouse { 166 | crossterm::execute!(stdout(), EnableMouseCapture)?; 167 | } 168 | if self.paste { 169 | crossterm::execute!(stdout(), EnableBracketedPaste)?; 170 | } 171 | self.start(); 172 | Ok(()) 173 | } 174 | 175 | pub fn exit(&mut self) -> Result<()> { 176 | self.stop()?; 177 | if crossterm::terminal::is_raw_mode_enabled()? { 178 | self.flush()?; 179 | if self.paste { 180 | crossterm::execute!(stdout(), DisableBracketedPaste)?; 181 | } 182 | if self.mouse { 183 | crossterm::execute!(stdout(), DisableMouseCapture)?; 184 | } 185 | crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; 186 | crossterm::terminal::disable_raw_mode()?; 187 | } 188 | Ok(()) 189 | } 190 | 191 | pub fn cancel(&self) { 192 | self.cancellation_token.cancel(); 193 | } 194 | 195 | pub fn suspend(&mut self) -> Result<()> { 196 | self.exit()?; 197 | #[cfg(not(windows))] 198 | signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; 199 | Ok(()) 200 | } 201 | 202 | pub fn resume(&mut self) -> Result<()> { 203 | self.enter()?; 204 | Ok(()) 205 | } 206 | 207 | pub async fn next_event(&mut self) -> Option { 208 | self.event_rx.recv().await 209 | } 210 | } 211 | 212 | impl Deref for Tui { 213 | type Target = ratatui::Terminal>; 214 | 215 | fn deref(&self) -> &Self::Target { 216 | &self.terminal 217 | } 218 | } 219 | 220 | impl DerefMut for Tui { 221 | fn deref_mut(&mut self) -> &mut Self::Target { 222 | &mut self.terminal 223 | } 224 | } 225 | 226 | impl Drop for Tui { 227 | fn drop(&mut self) { 228 | self.exit().unwrap(); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/dashboard/app.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | action::Action, 3 | config::Config, 4 | database::{Database, DatabaseAction}, 5 | tui::{ 6 | background::Background, components::Component, pages::configs::ConfigsPageAction, Event, 7 | Tui, 8 | }, 9 | }; 10 | use color_eyre::Result; 11 | use crossterm::event::KeyEvent; 12 | use ratatui::prelude::Rect; 13 | use serde::{Deserialize, Serialize}; 14 | use std::{path::Path, process::Command, sync::Arc}; 15 | use tokio::sync::mpsc; 16 | use tracing::{debug, info}; 17 | 18 | pub const CACHE_PATH: &str = concat!(env!("HOME"), "/.cache/tuxtape-dashboard"); 19 | pub const GIT_PATH: &str = const_format::concatcp!(CACHE_PATH, "/git"); 20 | pub const LINUX_REPO_PATH: &str = const_format::concatcp!(GIT_PATH, "/linux"); 21 | pub const LINUX_REPO_URL: &str = "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git"; 22 | 23 | pub struct App { 24 | config: Arc, 25 | db: Database, 26 | background: Background, 27 | should_quit: bool, 28 | mode: Mode, 29 | last_tick_key_events: Vec, 30 | action_tx: mpsc::UnboundedSender, 31 | action_rx: mpsc::UnboundedReceiver, 32 | } 33 | 34 | #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 35 | pub enum Mode { 36 | #[default] 37 | /// The Background and Pages are displayed 38 | Normal, 39 | /// The TUI aspects of this program are suspended in favor of 40 | /// displaying another program (such as a text editor) in the terminal 41 | Suspended, 42 | } 43 | 44 | impl App { 45 | pub async fn new() -> Result { 46 | init_linux_repo()?; 47 | 48 | let (action_tx, action_rx) = mpsc::unbounded_channel(); 49 | let config = Arc::new(Config::new()?); 50 | let db = Database::new(config.clone(), action_tx.clone()).await?; 51 | Ok(Self { 52 | config: config.clone(), 53 | db, 54 | background: Background::new(config)?, 55 | should_quit: false, 56 | mode: Mode::Normal, 57 | last_tick_key_events: Vec::new(), 58 | action_tx, 59 | action_rx, 60 | }) 61 | } 62 | 63 | pub async fn run(&mut self) -> Result<()> { 64 | let mut tui = Tui::new()?; 65 | tui.enter()?; 66 | 67 | self.background 68 | .register_action_handler(self.action_tx.clone())?; 69 | 70 | self.background.init(tui.size()?)?; 71 | 72 | loop { 73 | self.handle_events(&mut tui).await?; 74 | self.handle_actions(&mut tui)?; 75 | if self.should_quit { 76 | tui.stop()?; 77 | break; 78 | } 79 | } 80 | tui.exit()?; 81 | Ok(()) 82 | } 83 | 84 | async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { 85 | let Some(event) = tui.next_event().await else { 86 | return Ok(()); 87 | }; 88 | let action_tx = self.action_tx.clone(); 89 | match event { 90 | Event::Quit => action_tx.send(Action::Quit)?, 91 | Event::Tick => action_tx.send(Action::Tick)?, 92 | Event::Render => action_tx.send(Action::Render)?, 93 | Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, 94 | Event::Key(key) => self.handle_key_event(key)?, 95 | _ => {} 96 | } 97 | if let Some(action) = self.background.handle_events(Some(event.clone()))? { 98 | action_tx.send(action)?; 99 | } 100 | Ok(()) 101 | } 102 | 103 | fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { 104 | let action_tx = self.action_tx.clone(); 105 | let Some(keymap) = self.config.keybindings.get(&self.mode) else { 106 | return Ok(()); 107 | }; 108 | match keymap.get(&vec![key]) { 109 | Some(action) => { 110 | info!("Got action: {action:?}"); 111 | action_tx.send(action.clone())?; 112 | } 113 | _ => { 114 | // If the key was not handled as a single key action, 115 | // then consider it for multi-key combinations. 116 | self.last_tick_key_events.push(key); 117 | 118 | // Check for multi-key combinations 119 | if let Some(action) = keymap.get(&self.last_tick_key_events) { 120 | info!("Got action: {action:?}"); 121 | action_tx.send(action.clone())?; 122 | } 123 | } 124 | } 125 | Ok(()) 126 | } 127 | 128 | fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> { 129 | while let Ok(action) = self.action_rx.try_recv() { 130 | if action != Action::Tick && action != Action::Render { 131 | debug!("{action:?}"); 132 | } 133 | 134 | // Handle Actions that require TUI state changes before being pushed downstream 135 | // (such as suspending the TUI) 136 | // 137 | // NOTE: Remove this clippy override when we add a second condition to this block. 138 | // I anticipate CreateNewConfig will not be the only thing we need to check for here. 139 | #[allow(clippy::single_match)] 140 | match action { 141 | Action::ConfigsPage(ref action) => match action { 142 | ConfigsPageAction::CreateNewConfig(_) => { 143 | self.suspend(tui)?; 144 | } 145 | }, 146 | _ => {} 147 | } 148 | 149 | // Forward Action to Background first and see if anything downstream consumes it. 150 | let unconsumed_action = match self.background.update(action)? { 151 | Some(action) => action, 152 | None => continue, 153 | }; 154 | 155 | // Handle Actions that were not consumed by downstream or returned from downstream 156 | match unconsumed_action { 157 | Action::Tick => { 158 | self.last_tick_key_events.drain(..); 159 | } 160 | Action::Suspend => { 161 | self.suspend(tui)?; 162 | } 163 | Action::Resume => { 164 | self.resume(tui)?; 165 | } 166 | Action::ClearScreen => { 167 | tui.terminal.clear()?; 168 | } 169 | Action::Resize(w, h) => { 170 | self.handle_resize(tui, w, h)?; 171 | } 172 | Action::Render => { 173 | self.render(tui)?; 174 | } 175 | Action::Database(DatabaseAction::Request(ref request)) => { 176 | self.db.handle_request(request)?; 177 | } 178 | Action::EditPatchAtPath(ref path) => { 179 | // TODO - I see no use in Modes right now. Maybe remove? 180 | self.mode = Mode::Suspended; 181 | tui.exit()?; 182 | edit::edit_file(Path::new(path))?; 183 | self.mode = Mode::Normal; 184 | tui.enter()?; 185 | tui.terminal.clear()?; 186 | } 187 | Action::Quit => self.should_quit = true, 188 | _ => {} 189 | } 190 | } 191 | 192 | Ok(()) 193 | } 194 | 195 | fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> { 196 | tui.resize(Rect::new(0, 0, w, h))?; 197 | self.render(tui)?; 198 | Ok(()) 199 | } 200 | 201 | fn render(&mut self, tui: &mut Tui) -> Result<()> { 202 | tui.draw(|frame| { 203 | if let Err(err) = self.background.draw(frame, frame.area()) { 204 | let _ = self 205 | .action_tx 206 | .send(Action::Error(format!("Failed to draw: {:?}", err))); 207 | } 208 | })?; 209 | Ok(()) 210 | } 211 | 212 | fn suspend(&mut self, tui: &mut Tui) -> Result<()> { 213 | self.mode = Mode::Suspended; 214 | tui.exit() 215 | } 216 | 217 | fn resume(&mut self, tui: &mut Tui) -> Result<()> { 218 | self.mode = Mode::Normal; 219 | tui.enter()?; 220 | tui.terminal.clear()?; 221 | Ok(()) 222 | } 223 | } 224 | 225 | fn init_linux_repo() -> Result<()> { 226 | if !Path::new(LINUX_REPO_PATH).exists() { 227 | println!( 228 | "Linux repo dir '{}' does not exist. Creating.", 229 | LINUX_REPO_PATH 230 | ); 231 | std::fs::create_dir_all(LINUX_REPO_PATH)?; 232 | } 233 | 234 | if !Path::new(format!("{}/.git", LINUX_REPO_PATH).as_str()).exists() { 235 | Command::new("git") 236 | .current_dir(LINUX_REPO_PATH) 237 | .args(["clone", LINUX_REPO_URL, LINUX_REPO_PATH]) 238 | .spawn()? 239 | .wait()?; 240 | } 241 | 242 | Ok(()) 243 | } 244 | -------------------------------------------------------------------------------- /src/dashboard/tui/pages/configs.rs: -------------------------------------------------------------------------------- 1 | use crate::action::Action; 2 | use crate::app::LINUX_REPO_PATH; 3 | use crate::database::Request::PutKernelConfig; 4 | use crate::database::{DatabaseAction, KernelConfig, KernelConfigMetadata, KernelVersion}; 5 | use crate::tui::components::Component; 6 | use color_eyre::eyre::eyre; 7 | use color_eyre::Result; 8 | use ratatui::layout::Rect; 9 | use ratatui::prelude::*; 10 | use ratatui::widgets::*; 11 | use ratatui::Frame; 12 | use serde::{Deserialize, Serialize}; 13 | use std::path::PathBuf; 14 | use std::process::Command; 15 | use tokio::sync::mpsc::UnboundedSender; 16 | 17 | /// The page for creating new configs (and eventually for viewing already-made configs) 18 | #[derive(Default)] 19 | pub struct ConfigsPage { 20 | command_tx: Option>, 21 | versions_list_state: ListState, 22 | kernel_versions: Vec, 23 | } 24 | 25 | impl ConfigsPage { 26 | pub fn new() -> Self { 27 | let git_versions = Command::new("git") 28 | .current_dir(LINUX_REPO_PATH) 29 | .arg("--no-pager") 30 | .arg("tag") 31 | .args(["--sort", "-v:refname"]) 32 | .output() 33 | .unwrap() 34 | .stdout; 35 | 36 | let mut kernel_version_strings: Vec = Vec::new(); 37 | let mut current_string = "".to_string(); 38 | for character in git_versions { 39 | match character { 40 | b'\n' => { 41 | let binding = ¤t_string; 42 | if !binding.contains("-") { 43 | // Filter out strings that are in w.x.y.z format (occured in v2.x) 44 | if current_string.chars().filter(|c| *c == '.').count() < 3 { 45 | kernel_version_strings.push(current_string.clone()); 46 | } 47 | } 48 | current_string.clear(); 49 | } 50 | b'v' => { 51 | // Don't push 'v' into our array as we only want version numbers 52 | } 53 | _ => { 54 | current_string.push( 55 | char::from_u32(character.into()) 56 | .expect("Git should never give us a bad value"), 57 | ); 58 | } 59 | } 60 | } 61 | 62 | let kernel_versions = Vec::from_iter( 63 | kernel_version_strings 64 | .iter() 65 | .flat_map(|git_version_str| str_to_kernel_version(git_version_str)), 66 | ); 67 | 68 | let mut list_state = ListState::default(); 69 | list_state.select_first(); 70 | 71 | Self { 72 | command_tx: None, 73 | versions_list_state: list_state, 74 | kernel_versions, 75 | } 76 | } 77 | } 78 | 79 | impl Component for ConfigsPage { 80 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 81 | self.command_tx = Some(tx); 82 | 83 | Ok(()) 84 | } 85 | 86 | fn update(&mut self, action: Action) -> Result> { 87 | match action { 88 | Action::ScrollDown => { 89 | self.versions_list_state.select_next(); 90 | } 91 | Action::ScrollUp => { 92 | self.versions_list_state.select_previous(); 93 | } 94 | Action::Select => { 95 | // Defer CreateNewConfig action because the TUI must be suspended first 96 | if let Some(command_tx) = &self.command_tx { 97 | let kernel_version = self.kernel_versions[self 98 | .versions_list_state 99 | .selected() 100 | .expect("Something is always selected")]; 101 | command_tx.send(Action::ConfigsPage(ConfigsPageAction::CreateNewConfig( 102 | kernel_version, 103 | )))?; 104 | } 105 | } 106 | Action::ConfigsPage(ref action) => match action { 107 | ConfigsPageAction::CreateNewConfig(kernel_version) => { 108 | let config = create_new_config(kernel_version)?; 109 | if let Some(command_tx) = &self.command_tx { 110 | command_tx.send(Action::Resume)?; 111 | command_tx.send(Action::Database(DatabaseAction::Request( 112 | PutKernelConfig(config), 113 | )))?; 114 | } 115 | } 116 | }, 117 | _ => return Ok(Some(action)), 118 | } 119 | Ok(Some(action)) 120 | } 121 | 122 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 123 | let list: List<'_> = List::new( 124 | self.kernel_versions 125 | .iter() 126 | .map(kernel_version_to_string) 127 | .clone(), 128 | ) 129 | .highlight_symbol(">>") 130 | .highlight_style(Style::new().reversed()); 131 | frame.render_stateful_widget(list, area, &mut self.versions_list_state); 132 | Ok(()) 133 | } 134 | } 135 | 136 | fn create_new_config(kernel_version: &KernelVersion) -> Result { 137 | // TODO (MVP) - make this configurable 138 | let config_name = format!( 139 | "{}-tuxtape-poc.config", 140 | kernel_version_to_string(kernel_version) 141 | ); 142 | 143 | checkout_kernel_version(kernel_version)?; 144 | 145 | Command::new("make") 146 | .arg("menuconfig") 147 | .current_dir(LINUX_REPO_PATH) 148 | .spawn()? 149 | .wait()?; 150 | 151 | let config_path = PathBuf::from(LINUX_REPO_PATH).join(".config"); 152 | if !config_path.is_file() { 153 | return Err(eyre!(".config does not exist at path {:?}.", config_path)); 154 | } 155 | 156 | let config_file = std::fs::read_to_string(config_path)?; 157 | let metadata = KernelConfigMetadata { 158 | config_name, 159 | kernel_version: Some(*kernel_version), 160 | }; 161 | let kernel_config = KernelConfig { 162 | metadata: Some(metadata), 163 | config_file, 164 | }; 165 | 166 | Ok(kernel_config) 167 | } 168 | 169 | fn checkout_kernel_version(kernel_version: &KernelVersion) -> Result<()> { 170 | // Clean repo so checkout goes smoothly 171 | let result = Command::new("make") 172 | .current_dir(LINUX_REPO_PATH) 173 | .arg("distclean") 174 | .spawn()? 175 | .wait()?; 176 | 177 | match result.success() { 178 | true => {} 179 | false => return Err(eyre!("Failed to run make distclean on Linux repo.",)), 180 | }; 181 | 182 | // Checkout version at tag matching kernel_version 183 | let tag_string = kernel_version_to_tag_string(kernel_version); 184 | let result = Command::new("git") 185 | .current_dir(LINUX_REPO_PATH) 186 | .args(["checkout", tag_string.as_str()]) 187 | .spawn()? 188 | .wait_with_output()?; 189 | 190 | match result.status.success() { 191 | true => Ok(()), 192 | false => Err(eyre!( 193 | "Git failed to checkout tag {} with output: {}", 194 | tag_string, 195 | String::from_utf8(result.stdout)? 196 | )), 197 | } 198 | } 199 | 200 | fn str_to_kernel_version(kernel_version_str: &str) -> Option { 201 | let split = kernel_version_str.split('.'); 202 | let mut major = None; 203 | let mut minor = None; 204 | let mut patch = None; 205 | for part in split { 206 | if major.is_none() { 207 | major = if let Ok(major) = part.parse::() { 208 | Some(major) 209 | } else { 210 | return None; 211 | }; 212 | } else if minor.is_none() { 213 | minor = if let Ok(minor) = part.parse::() { 214 | Some(minor) 215 | } else { 216 | return None; 217 | }; 218 | } else if patch.is_none() { 219 | patch = if let Ok(patch) = part.parse::() { 220 | Some(patch) 221 | } else { 222 | return None; 223 | }; 224 | } else { 225 | // If there are more than 3 parts (major, minor, patch) in the version number, it's invalid. 226 | // This appears to only happen on v2.x. 227 | return None; 228 | } 229 | } 230 | 231 | let major = major?; 232 | let minor = minor?; 233 | 234 | Some(KernelVersion { 235 | major, 236 | minor, 237 | patch, 238 | }) 239 | } 240 | 241 | fn kernel_version_to_tag_string(kernel_version: &KernelVersion) -> String { 242 | format!( 243 | "v{}.{}{}", 244 | kernel_version.major, 245 | kernel_version.minor, 246 | match kernel_version.patch { 247 | Some(patch) => format!(".{patch}"), 248 | None => "".to_string(), 249 | } 250 | ) 251 | } 252 | 253 | fn kernel_version_to_string(kernel_version: &KernelVersion) -> String { 254 | format!( 255 | "{}.{}{}", 256 | kernel_version.major, 257 | kernel_version.minor, 258 | match kernel_version.patch { 259 | Some(patch) => format!(".{patch}"), 260 | None => "".to_string(), 261 | } 262 | ) 263 | } 264 | 265 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 266 | pub enum ConfigsPageAction { 267 | CreateNewConfig(KernelVersion), 268 | } 269 | -------------------------------------------------------------------------------- /src/dashboard/tui/background.rs: -------------------------------------------------------------------------------- 1 | use crate::tui::components::*; 2 | use crate::{ 3 | action::Action, app::Mode, config::Config, tui::pages::*, tui::popups, tui::popups::PopupType, 4 | }; 5 | use color_eyre::{eyre::eyre, Result}; 6 | use crossterm::event::{KeyCode, KeyEvent}; 7 | use ratatui::{ 8 | prelude::*, 9 | style::{Color, Stylize}, 10 | widgets::*, 11 | }; 12 | use std::collections::HashMap; 13 | use std::sync::Arc; 14 | use strum::{EnumCount, IntoEnumIterator}; 15 | use tokio::sync::mpsc::UnboundedSender; 16 | 17 | /// The root from which all other `Page`s will be mounted. 18 | /// The background will run at all times. 19 | pub struct Background { 20 | command_tx: Option>, 21 | tabs: PageTabs, 22 | page_manager: PageManager, 23 | footer: Footer, 24 | popup: Option>, 25 | } 26 | 27 | impl Background { 28 | pub fn new(config: Arc) -> Result { 29 | Ok(Self { 30 | command_tx: None, 31 | tabs: PageTabs::default(), 32 | page_manager: PageManager::new(config.clone()), 33 | footer: Footer::new(config), 34 | popup: None, 35 | }) 36 | } 37 | 38 | fn display_popup(&mut self, popup_type: PopupType) -> Result<()> { 39 | match popup_type { 40 | PopupType::Alert(alert_text) => { 41 | self.popup = Some(Box::new(popups::Alert::new(alert_text))); 42 | } 43 | PopupType::CveEditPreview(cve_instance) => { 44 | let command_tx = match self.command_tx.as_ref() { 45 | Some(command_tx) => command_tx, 46 | None => { 47 | return Err(eyre!( 48 | "self.command_tx should always exist by the time this is called" 49 | )) 50 | } 51 | }; 52 | 53 | self.popup = Some(Box::new(popups::CveEditPreview::new( 54 | cve_instance, 55 | command_tx.clone(), 56 | ))); 57 | } 58 | } 59 | 60 | Ok(()) 61 | } 62 | } 63 | 64 | impl Component for Background { 65 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 66 | self.command_tx = Some(tx.clone()); 67 | self.page_manager.register_action_handler(tx.clone()); 68 | self.tabs.register_action_handler(tx) 69 | } 70 | 71 | fn update(&mut self, action: Action) -> Result> { 72 | // If there is a Popup, let it be the first consumer of events. 73 | let maybe_unconsumed_action = match &mut self.popup { 74 | // If there is a Popup, check first if the user is trying to close it before forwarding the action into the Popup 75 | Some(popup) => match action { 76 | Action::Quit => { 77 | self.popup = None; 78 | // Consume the Quit event 79 | None 80 | } 81 | _ => popup.update(action)?, 82 | }, 83 | None => Some(action), 84 | }; 85 | let unconsumed_action = match maybe_unconsumed_action { 86 | Some(action) => action, 87 | None => return Ok(None), 88 | }; 89 | 90 | let unconsumed_action = match self.tabs.update(unconsumed_action)? { 91 | Some(action) => action, 92 | None => return Ok(None), 93 | }; 94 | 95 | let unconsumed_action = match self.page_manager.update(unconsumed_action)? { 96 | Some(action) => action, 97 | None => return Ok(None), 98 | }; 99 | 100 | // Consume Background-specific actions here 101 | match unconsumed_action { 102 | Action::Popup(popup_type) => { 103 | self.display_popup(popup_type)?; 104 | Ok(None) 105 | } 106 | _ => Ok(Some(unconsumed_action)), 107 | } 108 | } 109 | 110 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 111 | let chunks = Layout::default() 112 | .direction(Direction::Vertical) 113 | .constraints([ 114 | Constraint::Percentage(2), 115 | Constraint::Percentage(98), 116 | Constraint::Percentage(2), 117 | ]) 118 | .split(area); 119 | 120 | self.tabs.draw(frame, chunks[0])?; 121 | self.page_manager 122 | .get_current_page() 123 | .draw(frame, chunks[1])?; 124 | self.footer.draw(frame, chunks[2])?; 125 | 126 | if let Some(popup) = &mut self.popup { 127 | let popup_area = chunks[1].inner(Margin::new(1, 1)); 128 | 129 | if let Err(err) = popup.draw(frame, popup_area) { 130 | if let Some(command_tx) = &mut self.command_tx { 131 | let _ = command_tx.send(Action::Error(format!("Failed to draw: {:?}", err))); 132 | } 133 | } 134 | } 135 | 136 | Ok(()) 137 | } 138 | } 139 | 140 | // Implement extra functionality to Page enum for use in PageTabs. 141 | impl PageType { 142 | /// Get the next tab. If there is no next tab, loop around to first tab. 143 | fn next(self) -> Self { 144 | let current_index = self as usize; 145 | const MAX_PAGE_INDEX: usize = PageType::COUNT - 1; 146 | let next_index = match current_index { 147 | MAX_PAGE_INDEX => 0, 148 | _ => current_index + 1, 149 | }; 150 | Self::from_repr(next_index).unwrap_or(self) 151 | } 152 | 153 | /// Get the previous tab. If there is no previous tab, loop around to last tab. 154 | fn previous(self) -> Self { 155 | let current_index = self as usize; 156 | let previous_index = match current_index { 157 | 0 => PageType::COUNT - 1, 158 | _ => current_index - 1, 159 | }; 160 | Self::from_repr(previous_index).unwrap_or(self) 161 | } 162 | 163 | /// Return tab's name as a styled `Line` 164 | fn title(self) -> Line<'static> { 165 | format!(" {self} ") 166 | .fg(Color::White) 167 | .bg(Color::DarkGray) 168 | .into() 169 | } 170 | } 171 | 172 | #[derive(Default)] 173 | struct PageTabs { 174 | command_tx: Option>, 175 | selected_tab: PageType, 176 | } 177 | 178 | impl Component for PageTabs { 179 | fn update(&mut self, action: Action) -> Result> { 180 | match action { 181 | Action::TabLeft => { 182 | self.selected_tab = self.selected_tab.previous(); 183 | Ok(Some(Action::ChangePage(self.selected_tab))) 184 | } 185 | Action::TabRight => { 186 | self.selected_tab = self.selected_tab.next(); 187 | Ok(Some(Action::ChangePage(self.selected_tab))) 188 | } 189 | _ => Ok(Some(action)), 190 | } 191 | } 192 | 193 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 194 | self.command_tx = Some(tx.clone()); 195 | Ok(()) 196 | } 197 | 198 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 199 | let titles = PageType::iter().map(PageType::title); 200 | let highlight_style = (Color::White, Color::Green); 201 | let selected_tab_index = self.selected_tab as usize; 202 | Tabs::new(titles) 203 | .highlight_style(highlight_style) 204 | .select(selected_tab_index) 205 | .padding("", "") 206 | .divider(" ") 207 | .render(area, frame.buffer_mut()); 208 | 209 | Ok(()) 210 | } 211 | } 212 | 213 | struct Footer { 214 | text: String, 215 | } 216 | 217 | impl Footer { 218 | fn new(config: Arc) -> Self { 219 | let keybindings = config 220 | .keybindings 221 | .0 222 | .get(&Mode::Normal) 223 | .expect("Program should have panicked by now if config didn't exist"); 224 | 225 | let tab_left_keys = Footer::find_keycodes_for_action(keybindings, &Action::TabLeft); 226 | let tab_right_keys = Footer::find_keycodes_for_action(keybindings, &Action::TabRight); 227 | let pane_left_keys = Footer::find_keycodes_for_action(keybindings, &Action::PaneLeft); 228 | let pane_right_keys = Footer::find_keycodes_for_action(keybindings, &Action::PaneRight); 229 | let scroll_down_keys = Footer::find_keycodes_for_action(keybindings, &Action::ScrollDown); 230 | let scroll_up_keys = Footer::find_keycodes_for_action(keybindings, &Action::ScrollUp); 231 | let select_keys = Footer::find_keycodes_for_action(keybindings, &Action::Select); 232 | let quit_keys = Footer::find_keycodes_for_action(keybindings, &Action::Quit); 233 | 234 | let text = format!( 235 | "[Pane ←/→: {}/{}] [Tab ←/→: {}/{}] [Scroll ↑/↓: {}/{}] [Select: {}] [Quit: {}]", 236 | Footer::keycodes_to_display_text(pane_left_keys), 237 | Footer::keycodes_to_display_text(pane_right_keys), 238 | Footer::keycodes_to_display_text(tab_left_keys), 239 | Footer::keycodes_to_display_text(tab_right_keys), 240 | Footer::keycodes_to_display_text(scroll_up_keys), 241 | Footer::keycodes_to_display_text(scroll_down_keys), 242 | Footer::keycodes_to_display_text(select_keys), 243 | Footer::keycodes_to_display_text(quit_keys) 244 | ); 245 | 246 | Self { text } 247 | } 248 | 249 | /// Return the `KeyCode`(s) (plural if a key combo is used) that map to an `Action` 250 | fn find_keycodes_for_action( 251 | map: &HashMap, Action>, 252 | value: &Action, 253 | ) -> Vec { 254 | let vec: Option> = map.iter().find_map(|(key, val)| { 255 | if val == value { 256 | Some(key.iter().map(|key| key.code).collect()) 257 | } else { 258 | None 259 | } 260 | }); 261 | 262 | vec.unwrap_or_default() 263 | } 264 | 265 | /// Return a String of displayable text for all `KeyCode`(s) in the vec. 266 | fn keycodes_to_display_text(keycodes: Vec) -> String { 267 | let mut retval = String::new(); 268 | for (i, keycode) in keycodes.iter().enumerate() { 269 | if i > 0 { 270 | retval.push('+'); 271 | } 272 | retval.push_str(keycode.to_string().as_str()); 273 | } 274 | 275 | retval 276 | } 277 | } 278 | 279 | impl Component for Footer { 280 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 281 | frame.render_widget(Paragraph::new(self.text.clone()).centered(), area); 282 | 283 | Ok(()) 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/dashboard/tui/popups/cve_edit_preview.rs: -------------------------------------------------------------------------------- 1 | /// The Popup that displays when a CVE is selected to be edited. 2 | use super::PopupFrame; 3 | use crate::database::CveInstance; 4 | use crate::tui::pages::home::HomePageAction::EditPatch; 5 | use crate::{ 6 | action::Action, 7 | database::{Cve, KernelVersion}, 8 | tui::components::Component, 9 | }; 10 | use color_eyre::Result; 11 | use ratatui::{prelude::*, widgets::*}; 12 | use std::cell::RefCell; 13 | use std::rc::Rc; 14 | use std::sync::Arc; 15 | use strum::{EnumCount, EnumIter, FromRepr}; 16 | use tokio::sync::mpsc::UnboundedSender; 17 | 18 | pub struct CveEditPreview<'a> { 19 | frame: PopupFrame<'a>, 20 | cve: Arc, 21 | cve_instances_table_state: TableState, 22 | selected_cve_instance: Rc>, 23 | selected_pane: Pane, 24 | affects_pane: AffectsPane, 25 | command_tx: UnboundedSender, 26 | } 27 | 28 | impl CveEditPreview<'_> { 29 | pub fn new(cve: Arc, command_tx: UnboundedSender) -> Self { 30 | let frame = PopupFrame::new(cve.id.clone(), None); 31 | 32 | let mut cve_instances_table_state = TableState::default(); 33 | cve_instances_table_state.select_first(); 34 | 35 | let selected_cve_instance = Rc::new(RefCell::new(cve.instances[0].clone())); 36 | 37 | Self { 38 | frame, 39 | cve, 40 | cve_instances_table_state, 41 | selected_cve_instance: selected_cve_instance.clone(), 42 | selected_pane: Pane::InstancePane, 43 | affects_pane: AffectsPane::new(selected_cve_instance), 44 | command_tx, 45 | } 46 | } 47 | } 48 | 49 | impl Component for CveEditPreview<'_> { 50 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 51 | self.frame.draw(frame, area)?; 52 | 53 | let cve_instances_table_rows: Vec = self 54 | .cve 55 | .instances 56 | .iter() 57 | .map(|cve_instance| { 58 | Row::new(vec![ 59 | cve_instance.title.clone(), 60 | if let Some(introduced_version) = &cve_instance.introduced { 61 | kernel_version_to_string(introduced_version) 62 | } else { 63 | "NULL".to_string() 64 | }, 65 | if let Some(fixed_version) = &cve_instance.fixed { 66 | kernel_version_to_string(fixed_version) 67 | } else { 68 | "NULL".to_string() 69 | }, 70 | ]) 71 | }) 72 | .collect(); 73 | 74 | let cve_instances_table_widths = vec![ 75 | Constraint::Percentage(60), 76 | Constraint::Percentage(20), 77 | Constraint::Percentage(20), 78 | ]; 79 | 80 | let cve_instances_table = Table::new(cve_instances_table_rows, cve_instances_table_widths) 81 | .highlight_symbol(">>") 82 | .row_highlight_style(Style::new().reversed()) 83 | .header( 84 | Row::new(vec!["CVE Instance", "Introduced", "Fixed"]) 85 | .style(Style::new().bg(Color::Blue).fg(Color::White)), 86 | ) 87 | .block( 88 | Block::bordered() 89 | .title_top(Line::from("CVE Instance").centered()) 90 | .style(match self.selected_pane { 91 | Pane::InstancePane => Style::new().green(), 92 | _ => Style::new().white(), 93 | }), 94 | ); 95 | 96 | let chunks = Layout::default() 97 | .direction(Direction::Horizontal) 98 | .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) 99 | .split(self.frame.get_content_area(area)); 100 | 101 | frame.render_stateful_widget( 102 | cve_instances_table, 103 | chunks[0], 104 | &mut self.cve_instances_table_state, 105 | ); 106 | 107 | self.affects_pane.draw( 108 | frame, 109 | chunks[1], 110 | matches!(self.selected_pane, Pane::AffectsPane), 111 | ) 112 | } 113 | 114 | fn update(&mut self, action: Action) -> Result> { 115 | // Consume navigation actions, pass through all else 116 | match action { 117 | Action::Select => { 118 | let selected_instance = self.cve.instances[self 119 | .cve_instances_table_state 120 | .selected() 121 | .expect("Something will always be selected")] 122 | .clone(); 123 | 124 | self.command_tx 125 | .send(Action::HomePage(EditPatch(selected_instance)))?; 126 | } 127 | Action::PaneLeft => self.selected_pane = self.selected_pane.previous(), 128 | Action::PaneRight => self.selected_pane = self.selected_pane.next(), 129 | Action::TabLeft | Action::TabRight => return Ok(None), 130 | Action::ScrollDown | Action::ScrollUp => match self.selected_pane { 131 | Pane::InstancePane => { 132 | match action { 133 | Action::ScrollDown => { 134 | self.cve_instances_table_state.select_next(); 135 | if self.cve.instances.len() 136 | <= self.cve_instances_table_state.selected().unwrap() 137 | { 138 | self.cve_instances_table_state.select_first(); 139 | } 140 | 141 | self.selected_cve_instance.replace( 142 | self.cve.instances[self 143 | .cve_instances_table_state 144 | .selected() 145 | .expect("An item will always be selected")] 146 | .clone(), 147 | ); 148 | } 149 | Action::ScrollUp => { 150 | if self.cve_instances_table_state.selected().unwrap() == 0 { 151 | // Note: ListState::select_last() does not work as expected. 152 | self.cve_instances_table_state 153 | .select(Some(self.cve.instances.len() - 1)); 154 | } else { 155 | self.cve_instances_table_state.select_previous(); 156 | } 157 | 158 | self.selected_cve_instance.replace( 159 | self.cve.instances[self 160 | .cve_instances_table_state 161 | .selected() 162 | .expect("An item will always be selected")] 163 | .clone(), 164 | ); 165 | } 166 | _ => {} // This can't get hit but appeases the compiler 167 | } 168 | } 169 | Pane::AffectsPane => return self.affects_pane.update(action), 170 | }, 171 | _ => return Ok(Some(action)), 172 | } 173 | Ok(None) 174 | } 175 | } 176 | 177 | #[derive(EnumIter, EnumCount, FromRepr, Clone, Copy)] 178 | enum Pane { 179 | InstancePane, 180 | AffectsPane, 181 | } 182 | 183 | impl Pane { 184 | /// Get the next pane. If there is no next pane, loop around to first. 185 | fn next(self) -> Self { 186 | let current_index = self as usize; 187 | const MAX_INDEX: usize = Pane::COUNT - 1; 188 | let next_index = match current_index { 189 | MAX_INDEX => 0, 190 | _ => current_index + 1, 191 | }; 192 | Self::from_repr(next_index).unwrap_or(self) 193 | } 194 | 195 | /// Get the previous pane. If there is no previous pane, loop around to last. 196 | fn previous(self) -> Self { 197 | let current_index = self as usize; 198 | let previous_index = match current_index { 199 | 0 => Pane::COUNT - 1, 200 | _ => current_index - 1, 201 | }; 202 | Self::from_repr(previous_index).unwrap_or(self) 203 | } 204 | } 205 | 206 | struct AffectsPane { 207 | cve_instance: Rc>, 208 | affects_list_state: ListState, 209 | } 210 | 211 | impl AffectsPane { 212 | fn new(cve_instance: Rc>) -> Self { 213 | let mut affects_list_state = ListState::default(); 214 | affects_list_state.select_first(); 215 | 216 | Self { 217 | cve_instance, 218 | affects_list_state, 219 | } 220 | } 221 | 222 | fn draw(&mut self, frame: &mut Frame, area: Rect, is_selected: bool) -> Result<()> { 223 | let cve_instance = self.cve_instance.borrow(); 224 | 225 | let cve_instance_affects_items: Vec<&str> = cve_instance 226 | .affected_configs 227 | .iter() 228 | .map(|affected_config| affected_config.config_name.as_str()) 229 | .collect(); 230 | 231 | let cve_instance_affects_list = List::new(cve_instance_affects_items) 232 | .block( 233 | match is_selected { 234 | true => Block::bordered().style(Style::new().green()), 235 | false => Block::bordered().style(Style::new().white()), 236 | } 237 | .title("Affected Configs"), 238 | ) 239 | .highlight_symbol(">>"); 240 | 241 | frame.render_stateful_widget( 242 | cve_instance_affects_list, 243 | area, 244 | &mut self.affects_list_state, 245 | ); 246 | 247 | Ok(()) 248 | } 249 | 250 | fn update(&mut self, action: Action) -> Result> { 251 | match action { 252 | Action::ScrollDown => { 253 | self.affects_list_state.select_next(); 254 | Ok(None) 255 | } 256 | Action::ScrollUp => { 257 | self.affects_list_state.select_previous(); 258 | Ok(None) 259 | } 260 | _ => Ok(Some(action)), 261 | } 262 | } 263 | } 264 | 265 | // TODO - find a more global place to put this if repeated 266 | fn kernel_version_to_string(kernel_version: &KernelVersion) -> String { 267 | format!( 268 | "{}.{}{}", 269 | kernel_version.major, 270 | kernel_version.minor, 271 | if let Some(patch) = kernel_version.patch { 272 | format!(".{}", patch) 273 | } else { 274 | "".to_string() 275 | } 276 | ) 277 | } 278 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TuxTape 2 | 3 | TuxTape is an ecosystem for generating, compiling, deploying, and installing Linux kernel livepatches. It is a toolchain for simplifying the workflow of [kpatch](https://github.com/dynup/kpatch). 4 | 5 | Kernel livepatching is a service provided by many large companies (Canonical, Red Hat, Oracle, SuSE, TuxCare, etc), but as of today, no open source toolchain exists to allow individuals to self manage such a service. Additionally, most of these companies (with the exception of TuxCare) only provide livepatching services for their own custom kernel, e.g. Red Hat will only provide livepatches for the RHEL kernel. 6 | 7 | The mission of TuxTape is not to invalidate these services. Reviewing patches, monitoring the success of patch application, and maintaining infrastructure to distribute patches are tasks that will make sense for many system administrators to outsource. 8 | 9 | One should consider TuxTape if they, whether for security reasons, cost reasons, or requirements to maintain custom kernels, have the need to maintain their own livepatching solution. 10 | 11 | ## Development Status 12 | 13 | ⚠️ **WARNING: This branch currently contains the proof-of-concept (PoC) of TuxTape. This is not meant to be utilized in production, and the project is expected to change dramatically from this state in the upcoming months. The PoC code is only shared for reference.** ⚠️ 14 | 15 | At this point in time, planning for the minimum viable product (MVP) is still in progress so implementation specifics are not yet available. 16 | 17 | For more information on TuxTape, please review our FOSDEM 2025 talk [here](https://fosdem.org/2025/schedule/event/fosdem-2025-5689-tuxtape-a-kernel-livepatching-solution/). 18 | 19 | ## Pieces 20 | 21 | The full livepatch solution, once developed, will consist of the following pieces: 22 | 23 | 1. Common Vulnerabilities and Exposures (CVE) Scanner: The kernel community is its own CVE Numbering Authority (CNA) and publishes all CVE information in a [public mailing list](https://lore.kernel.org/linux-cve-announce/) and in [a git tree](https://git.kernel.org/pub/scm/linux/security/vulns.git). The CVE scanner will monitor this list for vulnerabilities which affect files which are compiled into our kernel. Fortunately, each email lists links to the patches fixing the vulnerability. The scanner can be run as a cronjob. 24 | 25 | 1. CVE Prioritizer: Unfortunately, since the kernel community believes that every bug is a possible security bug, the mailing list is very active. A method of prioritizing CVEs is still being devised. 26 | 27 | 1. Applicability Gauge: For any CVE which is deemed high enough priority, we must also decide whether it is applicable. This step is separated from the prioritizer because a basic priority applies for the CVE across all kernels, while applicability is per kernel. Since TuxTape is built to support multiple kernel configurations and distributions besides just mainline, some CVEs will stem from source files which are built into some but not all kernels. The applicability gauge will determine, for each kernel, whether a CVE is applicable. 28 | 29 | 1. Patch Generator: Once a CVE has been identified as worthy of live-patching, the Patch Generator will fetch the fixing commits and automatically generate a loadable module for the fix. In case the generator is unable to do so, it will send a notice to the system administrators to manually attempt to generate a livepatch module. Patches which are auto-generated will need to be carefully vetted through some combination of CI, heuristics, AI review, and human review. 30 | 31 | 1. Kernel Log Parser: Analyzes kernel warnings to determine whether a livepatch module has misbehaved. 32 | 33 | 1. Patch Archive: There is a need to publish all livepatch modules, as well as per-kernel and per-version lists of applicable modules. We are considering signing these using the [The Update Framework (TUF)](https://theupdateframework.io/) approach – signing using short-lived keys so that clients can be sure not to be handed stale data. The final state of the Patch Archive is still in discussion. 34 | 35 | 1. Fleet Client: Every node in the fleet will run a lightweight client which tracks the kernel version and livepatch status of the node on which it runs. It will periodically fetch the latest information from the Patch Archive. See below for details about how we intend to handle cases like [a system being buggy after a livepatch update](https://web.archive.org/web/20240913235734/https://ubuntu.com/security/livepatch/docs/livepatch/reference/what_if_my_system_crashes). 36 | 37 | --- 38 | 39 | # tuxtape-poc 40 | 41 | This repo contains a proof of concept for TuxTape: a Linux kernel livepatching solution. 42 | 43 | > Note: TuxTape only supports kernels based on minor versions currently supported by the mainline kernel maintainers. Do not expect TuxTape to provide backported LTS-like support to non-LTS kernels. 44 | 45 | This branch does not contain all of the future aspects of TuxTape which will compile the patches and distribute them to clients, nor the client which makes requests for and installs those patches. 46 | 47 | The proof of concept builds four different binaries, which are detailed below. 48 | 49 | ## tuxtape-cve-parser 50 | 51 | Parses the CVEs catalogued by the Linux kernel maintainers and generates a sqlite database of patch files. 52 | Since this project requires the full Linux Stable branch to be pulled and thousands of patches to be generated and CVE data pulled from NIST APIs, 53 | the first run will take a decent amount of time to complete (likely over an hour). Each successive run takes less time as the commit history of the kernel will only be pulled on first run, and successive runs only build patches 54 | from the diff of the `HEAD` of the `vulns` repo at the last run and the current `HEAD`. 55 | This should be run as a cronjob to update the database periodically. This database can be used in livepatching 56 | solutions. 57 | 58 | The database is hardcoded to reside at `~/.cache/tuxtape-server/db.db3`. 59 | 60 | > WARNING: Since the patch files are automatically generated, this program should undergo extensive testing 61 | which has not yet been done before being used in production. 62 | 63 | ## tuxtape-server 64 | 65 | The server is used to query the sqlite database created by `tuxtape-cve-parser` and provide a gRPC API for clients like `tuxtape-dashboard` to utilize for the creation of `kpatch`-compatible patches (referred to as "deployable" patches). 66 | 67 | ## tuxtape-kernel-builder 68 | 69 | This is an additional server that registers itself to `tuxtape-server` upon startup and serves requests to build kernels from configs generated by `tuxtape-dashboard`. Once it is done, it reports the build profile (what files were included in the build) back to `tuxtape-server` and it gets added to the database. 70 | 71 | > Note: This also requires the full git history of the Linux kernel to be pulled, so the first open will take a rather long time. If you already have the repo cloned, feel free to copy it to `~/.cache/tuxtape-kernel-builder/git/linux`. 72 | 73 | ## tuxtape-dashboard 74 | 75 | A TUI dashboard used to create deployable patches from the "raw" patches stored in `tuxtape-server` and added to its database created by `tuxtape-cve-parser`. It will also be used to review deployable patches written by other kernel developers and deploy them to the fleet once approved. This dashboard is also used to create new kernel configs. 76 | 77 | > Note: This also requires the full git history of the Linux kernel to be pulled, so the first open will take a rather long time. If you already have the repo cloned, feel free to copy it to `~/.cache/tuxtape-dashboard/git/linux`. 78 | 79 | More detailed information about the TUI architecture can be found at `src/dashboard/README.md`. 80 | 81 | ## Dependencies (Ubuntu 24.04) 82 | 83 | Build dependencies: 84 | ``` 85 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh 86 | 87 | sudo apt install build-essential pkg-config libssl-dev protobuf-compiler 88 | ``` 89 | 90 | Runtime dependencies for both `tuxtape-kernel-builder` and `tuxtape-dashboard` 91 | ``` 92 | sudo apt install libncurses-dev bison flex libelf-dev 93 | ``` 94 | 95 | Additional runtime dependency for `tuxtape-kernel-builder` 96 | ``` 97 | sudo apt install remake 98 | ``` 99 | 100 | ## Running instructions 101 | 102 | ``` 103 | # To build 104 | cargo build --all 105 | 106 | # For the parser 107 | cargo run --bin tuxtape-cve-parser 108 | 109 | # For the server 110 | cargo run --bin tuxtape-server 111 | 112 | # For the kernel builder 113 | cargo run --bin tuxtape-kernel-builder 114 | 115 | # For the dashboard 116 | cargo run --bin tuxtape-dashboard 117 | ``` 118 | 119 | ## Testing TLS 120 | 121 | If you wish to test TLS, you will need certificates. To create self-signed certificates, follow the directions below: 122 | 123 | > Note: The following contains instructions for running only `tuxtape-server` and `tuxtape-dashboard` with TLS. To run `tuxtape-kernel-builder` with TLS, follow the same instructions, but keep in mind that you will need to generate a new CA with a different domain (like `tuxtape-kernel-builder.com`) as that is also a server, and you will need to create another entry for that domain in `/etc/hosts`. 124 | 125 | 1. Create an encrypted certificate authority 126 | 127 | ``` 128 | openssl genrsa -aes256 -out ca-key.pem 4096 129 | ``` 130 | 131 | 2. Decrypt the certificate authority 132 | 133 | ``` 134 | openssl req -new -x509 -sha256 -days 365 -key ca-key.pem -out ca.pem 135 | ``` 136 | 137 | 3. Extract the public certificate from the cert key 138 | 139 | ``` 140 | openssl genrsa -out cert-key.pem 4096 141 | ``` 142 | 143 | 4. Create a certificate signing request 144 | 145 | ``` 146 | openssl req -new -sha256 -subj "/CN=tuxtapecn" -key cert-key.pem -out cert.csr 147 | ``` 148 | 149 | 5. Create an extfile 150 | 151 | ``` 152 | echo "subjectAltName=DNS:tuxtape-server.com,IP:127.0.0.1" >> extfile.cnf 153 | ``` 154 | 155 | 6. Create a complete certificate authority 156 | 157 | ``` 158 | openssl x509 -req -sha256 -days 365 -in cert.csr -CA ca.pem -CAkey ca-key.pem -out cert.pem -extfile extfile.cnf -CAcreateserial 159 | ``` 160 | 161 | 7. Create a full chain 162 | 163 | ``` 164 | cat cert.pem > fullchain.pem 165 | cat ca.pem >> fullchain.pem 166 | ``` 167 | 168 | 8. Create a local domain name for the server in `/etc/hosts`. 169 | 170 | ``` 171 | sudo sh -c "echo '127.0.0.1 tuxtape-server.com' >> /etc/hosts" 172 | ``` 173 | 174 | 9. Modify the `tuxtape-dashboard` config file at `.config/tuxtape-dashboard-config.toml` (this will eventually be moved to a config directory that doesn't reside in the source code) to enable TLS by setting the following values: 175 | 176 | ``` 177 | [database] 178 | server_url = "tuxtape-server.com:50051" 179 | use_tls = true 180 | tls_cert_path = "ca.pem" 181 | ``` 182 | 183 | 184 | 10. Run the server and client with the following arguments. 185 | 186 | ``` 187 | cargo run --bin tuxtape-server -- -t --tls-cert-path fullchain.pem --tls-key-path cert-key.pem --tls-ca-path ca.pem 188 | 189 | cargo run --bin tuxtape-dashboard 190 | ``` 191 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/server/build_queue.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | get_db_connection, 3 | tuxtape_server::{ 4 | builder_client::BuilderClient, BuildKernelRequest, BuildKernelResponse, KernelConfig, 5 | PutKernelConfigRequest, RegisterKernelBuilderRequest, 6 | }, 7 | Args, 8 | }; 9 | use std::{ 10 | collections::{HashMap, VecDeque}, 11 | path::Path, 12 | sync::Arc, 13 | time::SystemTime, 14 | }; 15 | use tokio::sync::{ 16 | mpsc::{self, UnboundedReceiver, UnboundedSender}, 17 | RwLock, 18 | }; 19 | use tonic::{ 20 | codec::CompressionEncoding, 21 | transport::{Certificate, Channel, ClientTlsConfig}, 22 | Status, 23 | }; 24 | use tonic_health::pb::{ 25 | health_check_response::ServingStatus, health_client::HealthClient, HealthCheckRequest, 26 | }; 27 | 28 | pub enum BuildAction { 29 | RegisterKernelBuilder { 30 | request: RegisterKernelBuilderRequest, 31 | }, 32 | RemoveKernelBuilder { 33 | builder_address: String, 34 | }, 35 | AddJob { 36 | request: PutKernelConfigRequest, 37 | }, 38 | BuildCompleted { 39 | builder_address: String, 40 | resp: BuildKernelResponse, 41 | }, 42 | BuildFailed { 43 | builder_address: String, 44 | status: Status, 45 | }, 46 | } 47 | 48 | pub struct BuildQueue { 49 | args: Arc, 50 | builders: Arc>>>, 51 | job_queue: Arc>>, 52 | pub rx: UnboundedReceiver, 53 | pub tx: UnboundedSender, 54 | } 55 | 56 | struct Builder { 57 | client: Arc>>, 58 | address: String, 59 | job: Arc>>, 60 | tx: UnboundedSender, 61 | } 62 | 63 | #[derive(Clone)] 64 | struct Job { 65 | kernel_config: KernelConfig, 66 | // TODO (MVP) - rename _time_started to time_started and 67 | // use it in monitoring messages 68 | _time_started: SystemTime, 69 | } 70 | 71 | impl BuildQueue { 72 | pub fn new(args: Arc) -> Self { 73 | let (tx, rx) = mpsc::unbounded_channel::(); 74 | 75 | Self { 76 | args, 77 | builders: Arc::new(RwLock::new(HashMap::new())), 78 | job_queue: Arc::new(RwLock::new(VecDeque::new())), 79 | rx, 80 | tx, 81 | } 82 | } 83 | 84 | pub fn handle_action(&mut self, action: &BuildAction) { 85 | match action { 86 | BuildAction::AddJob { request } => { 87 | let kernel_config = if let Some(kernel_config) = &request.kernel_config { 88 | kernel_config 89 | } else { 90 | eprintln!("Attempted to add build job from PutKernelConfigRequest with no kernel_config field"); 91 | return; 92 | }; 93 | 94 | tokio::spawn(enqueue_job(self.job_queue.clone(), kernel_config.clone())); 95 | } 96 | BuildAction::BuildCompleted { 97 | builder_address, 98 | resp, 99 | } => { 100 | tokio::spawn(write_build_profile_to_db( 101 | self.args.clone(), 102 | self.builders.clone(), 103 | builder_address.clone(), 104 | resp.clone(), 105 | )); 106 | } 107 | BuildAction::BuildFailed { 108 | builder_address, 109 | status, 110 | } => { 111 | eprintln!( 112 | "Build failed! Removing kernel builder with address {}. Status: {}", 113 | builder_address, status 114 | ); 115 | 116 | tokio::spawn(remove_kernel_builder( 117 | self.builders.clone(), 118 | self.job_queue.clone(), 119 | builder_address.to_string(), 120 | )); 121 | } 122 | BuildAction::RegisterKernelBuilder { request } => { 123 | tokio::spawn(register_kernel_builder( 124 | self.args.clone(), 125 | self.builders.clone(), 126 | self.tx.clone(), 127 | request.clone(), 128 | )); 129 | } 130 | BuildAction::RemoveKernelBuilder { builder_address } => { 131 | tokio::spawn(remove_kernel_builder( 132 | self.builders.clone(), 133 | self.job_queue.clone(), 134 | builder_address.clone(), 135 | )); 136 | } 137 | } 138 | } 139 | 140 | pub async fn assign_jobs(&mut self) { 141 | let mut job_queue = self.job_queue.write().await; 142 | let mut builders = self.builders.write().await; 143 | 'jobs: while let Some(job) = job_queue.pop_front() { 144 | for (address, builder) in builders.iter_mut() { 145 | if builder.job.read().await.is_none() { 146 | println!( 147 | "Assigning job {} to builder {}", 148 | job.kernel_config 149 | .metadata 150 | .as_ref() 151 | .expect("metadata must exist here") 152 | .config_name, 153 | address 154 | ); 155 | 156 | tokio::spawn(build_kernel(builder.clone(), job)); 157 | continue 'jobs; 158 | } 159 | } 160 | 161 | // We weren't able to assign the popped job to a builder. Put it back at highest priority. 162 | job_queue.push_front(job); 163 | return; 164 | } 165 | } 166 | } 167 | 168 | async fn register_kernel_builder( 169 | args: Arc, 170 | builders: Arc>>>, 171 | tx: UnboundedSender, 172 | request: RegisterKernelBuilderRequest, 173 | ) { 174 | let url = match args.tls { 175 | true => format!("https://{}", request.builder_address), 176 | false => format!("http://{}", request.builder_address), 177 | }; 178 | println!("Getting connection to KernelBuilder at URL: {}", url); 179 | 180 | // Strip port from URL if one was provided 181 | let domain_name = if let Some(domain_name) = request 182 | .builder_address 183 | .split(':') 184 | .collect::>() 185 | .first() 186 | { 187 | *domain_name 188 | } else { 189 | &url 190 | }; 191 | 192 | let channel = if args.tls { 193 | let ca_path = &args.tls_ca_path; 194 | let pem = std::fs::read_to_string(ca_path).expect("ca_path does not exist"); 195 | let ca = Certificate::from_pem(pem); 196 | 197 | let tls = ClientTlsConfig::new() 198 | .ca_certificate(ca) 199 | .domain_name(domain_name); 200 | 201 | let endpoint = match Channel::from_shared(url.clone()) { 202 | Ok(endpoint) => endpoint, 203 | Err(e) => { 204 | eprintln!("{}", e); 205 | return; 206 | } 207 | }; 208 | let endpoint = match endpoint.tls_config(tls) { 209 | Ok(endpoint) => endpoint, 210 | Err(e) => { 211 | eprintln!("Failed to create endpoint: {}", e); 212 | return; 213 | } 214 | }; 215 | 216 | endpoint.connect().await 217 | } else { 218 | let endpoint = match Channel::from_shared(url.clone()) { 219 | Ok(endpoint) => endpoint, 220 | Err(e) => { 221 | eprintln!("Failed to create endpoint: {}", e); 222 | return; 223 | } 224 | }; 225 | 226 | endpoint.connect().await 227 | }; 228 | 229 | let channel = match channel { 230 | Ok(channel) => channel, 231 | Err(e) => { 232 | eprintln!("Failed to create endpoint: {}", e); 233 | return; 234 | } 235 | }; 236 | 237 | let client = Arc::new(RwLock::new( 238 | BuilderClient::new(channel.clone()) 239 | .accept_compressed(CompressionEncoding::Gzip) 240 | .send_compressed(CompressionEncoding::Gzip) 241 | .max_decoding_message_size(usize::MAX) 242 | .max_encoding_message_size(usize::MAX), 243 | )); 244 | 245 | let job = Arc::new(RwLock::new(None)); 246 | 247 | let builder = Arc::new(Builder { 248 | client, 249 | address: url.clone(), 250 | job, 251 | tx: tx.clone(), 252 | }); 253 | 254 | { 255 | let mut builders = builders.write().await; 256 | builders.insert(url.clone(), builder); 257 | } 258 | 259 | tokio::spawn(watch_builder_health(url, channel.clone(), tx)); 260 | } 261 | 262 | async fn enqueue_job(job_queue: Arc>>, kernel_config: KernelConfig) { 263 | let metadata = if let Some(metadata) = &kernel_config.metadata { 264 | metadata 265 | } else { 266 | eprintln!("Attempted to enqueue job for kernel_config without metadata field"); 267 | return; 268 | }; 269 | 270 | println!("Enqueueing job for {}", metadata.config_name); 271 | 272 | let job = Job { 273 | kernel_config, 274 | _time_started: SystemTime::now(), 275 | }; 276 | job_queue.write().await.push_back(job); 277 | } 278 | 279 | async fn remove_kernel_builder( 280 | builders: Arc>>>, 281 | job_queue: Arc>>, 282 | builder_address: String, 283 | ) { 284 | println!("Removing builder {} from queue.", builder_address); 285 | 286 | let mut builders = builders.write().await; 287 | let builder = if let Some(builder) = builders.get(&builder_address) { 288 | builder 289 | } else { 290 | eprintln!("No builder matching address {} found", &builder_address); 291 | return; 292 | }; 293 | 294 | // If builder had a job, add it to the front of the queue 295 | if let Some(job) = builder.job.write().await.take() { 296 | let mut job_queue = job_queue.write().await; 297 | job_queue.push_front(job); 298 | } 299 | 300 | builders.remove(&builder_address); 301 | } 302 | 303 | async fn build_kernel(builder: Arc, job: Job) { 304 | println!("Building kernel"); 305 | 306 | let mut builder_job = builder.job.write().await; 307 | builder_job.replace(job.clone()); 308 | 309 | let request = BuildKernelRequest { 310 | kernel_config: Some(job.kernel_config.clone()), 311 | }; 312 | 313 | let builder_clone = builder.clone(); 314 | tokio::spawn(async move { 315 | let mut builder_client = builder_clone.client.write().await; 316 | let result = builder_client.build_kernel(request).await; 317 | 318 | match result { 319 | Ok(resp) => builder_clone 320 | .tx 321 | .send(BuildAction::BuildCompleted { 322 | builder_address: builder_clone.address.clone(), 323 | resp: resp.into_inner(), 324 | }) 325 | .expect("Send should never fail"), 326 | Err(status) => builder_clone 327 | .tx 328 | .send(BuildAction::BuildFailed { 329 | builder_address: builder_clone.address.clone(), 330 | status, 331 | }) 332 | .expect("Send should never fail"), 333 | }; 334 | }); 335 | } 336 | 337 | async fn write_build_profile_to_db( 338 | args: Arc, 339 | builders: Arc>>>, 340 | builder_address: String, 341 | resp: BuildKernelResponse, 342 | ) { 343 | let mut builders = builders.write().await; 344 | let builder = if let Some(builder) = builders.get_mut(&builder_address) { 345 | builder 346 | } else { 347 | eprintln!("Failed to find builder at address: {}", builder_address); 348 | return; 349 | }; 350 | 351 | // Take ownership of Job from builder 352 | let job = builder 353 | .job 354 | .write() 355 | .await 356 | .take() 357 | .expect("We know there was a job here"); 358 | 359 | let metadata = job 360 | .kernel_config 361 | .metadata 362 | .as_ref() 363 | .expect("metadata should always exist here"); 364 | 365 | println!( 366 | "Build job for {} finished on builder {}", 367 | metadata.config_name, builder_address 368 | ); 369 | 370 | let included_files = resp.included_files; 371 | let kernel_config = &job.kernel_config; 372 | let metadata = kernel_config 373 | .metadata 374 | .as_ref() 375 | .expect("metadata must exist here"); 376 | let kernel_version = metadata 377 | .kernel_version 378 | .expect("kernel version must exist here"); 379 | 380 | let db = match get_db_connection(Path::new(&args.db_path)) { 381 | Ok(db) => db, 382 | Err(e) => { 383 | eprintln!("Failed to get connection to database: {}", e); 384 | return; 385 | } 386 | }; 387 | 388 | match db.execute( 389 | "REPLACE INTO kernel_config (config_name, major, minor, patch, config_file) VALUES (?1, ?2, ?3, ?4, ?5)", 390 | rusqlite::params![ 391 | metadata.config_name, 392 | kernel_version.major, 393 | kernel_version.minor, 394 | kernel_version.patch, 395 | kernel_config.config_file 396 | ], 397 | ) { 398 | Ok(_) => {}, 399 | Err(e) => { 400 | eprintln!("Failed to write job for {} into database. Error: {}", metadata.config_name, e); 401 | return; 402 | } 403 | } 404 | 405 | for file in included_files { 406 | match db.execute( 407 | "REPLACE INTO kernel_file (file_path, config_name) VALUES (?1, ?2)", 408 | rusqlite::params![file, metadata.config_name], 409 | ) { 410 | Ok(_) => {} 411 | Err(e) => { 412 | eprintln!( 413 | "Failed to write job for {} into database. Error: {}", 414 | metadata.config_name, e 415 | ); 416 | return; 417 | } 418 | } 419 | } 420 | 421 | println!( 422 | "Finished adding {} profile to database", 423 | metadata.config_name 424 | ) 425 | } 426 | 427 | async fn watch_builder_health( 428 | builder_address: String, 429 | channel: Channel, 430 | tx: UnboundedSender, 431 | ) { 432 | let mut health_client = HealthClient::new(channel) 433 | .accept_compressed(CompressionEncoding::Gzip) 434 | .send_compressed(CompressionEncoding::Gzip); 435 | 436 | let result = health_client 437 | .watch(HealthCheckRequest { 438 | service: "tuxtape_server.Builder".to_string(), 439 | }) 440 | .await; 441 | 442 | match result { 443 | Ok(resp) => { 444 | let mut stream = resp.into_inner(); 445 | while let Some(message) = stream.message().await.transpose() { 446 | match message { 447 | Ok(resp) => match resp.status() { 448 | ServingStatus::Serving => {} 449 | _ => { 450 | eprintln!( 451 | "Kernel builder at {} no longer serving requests.", 452 | &builder_address 453 | ); 454 | tx.send(BuildAction::RemoveKernelBuilder { builder_address }) 455 | .expect("Send should never fail"); 456 | 457 | return; 458 | } 459 | }, 460 | Err(_) => { 461 | println!("Lost connection to kernel builder at {}", &builder_address); 462 | tx.send(BuildAction::RemoveKernelBuilder { builder_address }) 463 | .expect("Send should never fail"); 464 | 465 | return; 466 | } 467 | } 468 | } 469 | } 470 | Err(e) => { 471 | eprintln!( 472 | "Could not connect to health service on kernel builder at {}. Error: {}", 473 | &builder_address, e 474 | ); 475 | tx.send(BuildAction::RemoveKernelBuilder { builder_address }) 476 | .expect("Send should never fail") 477 | } 478 | } 479 | } 480 | -------------------------------------------------------------------------------- /src/kernel_builder/main.rs: -------------------------------------------------------------------------------- 1 | /// A kernel builder that registers itself to tuxtape-server and anticipates BuildKernelRequests. 2 | /// 3 | /// Note: Currently, this program caches the Linux kernel repo as its build source instead of grabbing 4 | /// a tarball. The idea is for a builder to be relatively ephimeral so we can scale this up/down as 5 | /// we need, and fetching the whole Linux source code history is not ideal for that. In future versions, 6 | /// we probably should store Linux kernels in the artifactory and fetch from there instead. 7 | 8 | mod tuxtape_server { 9 | tonic::include_proto!("tuxtape_server"); 10 | } 11 | 12 | use clap::Parser; 13 | use color_eyre::eyre::{eyre, Result}; 14 | use serde::{Deserialize, Serialize}; 15 | use std::collections::HashSet; 16 | use std::fs::File; 17 | use std::path::{Path, PathBuf}; 18 | use std::process::Command; 19 | use std::sync::Arc; 20 | use std::time::Duration; 21 | use tonic::codec::CompressionEncoding; 22 | use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity, Server, ServerTlsConfig}; 23 | use tonic::{Request, Response, Status}; 24 | use tonic_health::pb::{ 25 | health_check_response::ServingStatus, 26 | health_client::HealthClient, 27 | health_server::{Health, HealthServer}, 28 | HealthCheckRequest, 29 | }; 30 | use tuxtape_server::builder_server::{Builder, BuilderServer}; 31 | use tuxtape_server::database_client::DatabaseClient; 32 | use tuxtape_server::{ 33 | BuildKernelRequest, BuildKernelResponse, KernelVersion, RegisterKernelBuilderRequest, 34 | }; 35 | 36 | const CACHE_PATH: &str = concat!(env!("HOME"), "/.cache/tuxtape-kernel-builder"); 37 | const BUILD_PATH: &str = const_format::concatcp!(CACHE_PATH, "/builds"); 38 | const BUILD_PROFILES_PATH: &str = const_format::concatcp!(CACHE_PATH, "/build-profiles"); 39 | const GIT_PATH: &str = const_format::concatcp!(CACHE_PATH, "/git"); 40 | const LINUX_REPO_PATH: &str = const_format::concatcp!(GIT_PATH, "/linux"); 41 | const LINUX_REPO_URL: &str = "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git"; 42 | 43 | #[derive(Parser)] 44 | #[command(version, about, long_about = None)] 45 | #[command(about = "A kernel builder that connects to tuxtape-server.", long_about = None)] 46 | struct Args { 47 | /// The socket address for this server, either IPv4 or IPv6. 48 | #[arg(short('a'), long, default_value = "127.0.0.1:50052")] 49 | addr: String, 50 | 51 | /// The web URL for this server (like tuxtape-kernel-builder.com) 52 | #[arg(short('u'), long, default_value = "")] 53 | url: String, 54 | 55 | /// The URL (and port) to tuxtape-server, either IPv4, IPv6, or domain name. 56 | #[arg(short('s'), long, default_value = "127.0.0.1:50051")] 57 | tuxtape_server_url: String, 58 | 59 | /// Enables TLS support 60 | #[arg(short('t'), long, requires_all(["tls_cert_path", "tls_key_path"]), default_value = "false")] 61 | tls: bool, 62 | 63 | /// Path to TLS CA (requires -t) 64 | #[arg(long, requires("tls"), default_value = "")] 65 | tls_ca_path: String, 66 | 67 | /// Path to TLS certificate (requires -t) 68 | #[arg(long, requires("tls"), default_value = "")] 69 | tls_cert_path: String, 70 | 71 | /// Path to TLS key (requires -t) 72 | #[arg(long, requires("tls"), default_value = "")] 73 | tls_key_path: String, 74 | } 75 | 76 | #[tokio::main] 77 | async fn main() -> Result<()> { 78 | let mut args = Args::parse(); 79 | if args.url.is_empty() { 80 | args.url = args.addr.clone(); 81 | } 82 | let args = Arc::new(args); 83 | 84 | if !Path::new(LINUX_REPO_PATH).exists() { 85 | println!( 86 | "Linux repo dir '{}' does not exist. Creating.", 87 | LINUX_REPO_PATH 88 | ); 89 | std::fs::create_dir_all(LINUX_REPO_PATH)?; 90 | } 91 | 92 | if !Path::new(format!("{}/.git", LINUX_REPO_PATH).as_str()).exists() { 93 | Command::new("git") 94 | .current_dir(LINUX_REPO_PATH) 95 | .args(["clone", LINUX_REPO_URL]) 96 | .spawn()? 97 | .wait()?; 98 | } 99 | 100 | loop { 101 | println!("Attempting to connect to tuxtape-server at {}", &args.url); 102 | 103 | // This will return if the server crashes for any reason, so we want to keep this in a loop. 104 | // In the future, we should log the errors should the server crash. 105 | let result = start_server(args.clone()).await; 106 | match result { 107 | Ok(()) => {} 108 | Err(e) => eprintln!("Connection to tuxtape-server failed with error: {}", e), 109 | } 110 | 111 | // Wait for a bit then try to reconnect to server. 112 | tokio::time::sleep(Duration::from_secs(5)).await; 113 | } 114 | } 115 | 116 | struct MyBuilder {} 117 | 118 | #[tonic::async_trait] 119 | impl Builder for MyBuilder { 120 | async fn build_kernel( 121 | &self, 122 | request: Request, 123 | ) -> Result, Status> { 124 | println!( 125 | "New request to build kernel from {:?}", 126 | request.remote_addr() 127 | ); 128 | 129 | if let Some(kernel_config) = &request.into_inner().kernel_config { 130 | if let Some(metadata) = &kernel_config.metadata { 131 | if let Some(kernel_version) = &metadata.kernel_version { 132 | let result = build_kernel( 133 | &kernel_config.config_file, 134 | &metadata.config_name, 135 | kernel_version, 136 | ); 137 | 138 | match result { 139 | Ok(_) => { 140 | let included_files = get_included_files(&metadata.config_name); 141 | match included_files { 142 | Ok(included_files) => { 143 | Ok(Response::new(BuildKernelResponse { included_files })) 144 | } 145 | Err(e) => Err(Status::from_error(e.into())), 146 | } 147 | } 148 | Err(e) => Err(Status::from_error(e.into())), 149 | } 150 | } else { 151 | Err(Status::invalid_argument( 152 | "Request missing kernel_config.metadata.kernel_version", 153 | )) 154 | } 155 | } else { 156 | Err(Status::invalid_argument( 157 | "Request missing kernel_config.metadata", 158 | )) 159 | } 160 | } else { 161 | Err(Status::invalid_argument("Request missing kernel_config")) 162 | } 163 | } 164 | } 165 | 166 | async fn start_server(args: Arc) -> Result<()> { 167 | let builder = MyBuilder {}; 168 | 169 | let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); 170 | health_reporter 171 | .set_serving::>() 172 | .await; 173 | 174 | let tuxtape_server_url = match args.tls { 175 | true => format!("https://{}", &args.tuxtape_server_url), 176 | false => format!("http://{}", &args.tuxtape_server_url), 177 | }; 178 | 179 | // Strip port from URL if one was provided 180 | let domain_name = if let Some(domain_name) = args 181 | .tuxtape_server_url 182 | .split(':') 183 | .collect::>() 184 | .first() 185 | { 186 | *domain_name 187 | } else { 188 | &tuxtape_server_url 189 | }; 190 | 191 | let channel = match args.tls { 192 | true => { 193 | let pem = std::fs::read_to_string(&args.tls_ca_path)?; 194 | let ca = Certificate::from_pem(pem); 195 | 196 | let tls = ClientTlsConfig::new() 197 | .ca_certificate(ca) 198 | .domain_name(domain_name); 199 | 200 | Channel::from_shared(tuxtape_server_url)? 201 | .tls_config(tls)? 202 | .connect() 203 | .await? 204 | } 205 | false => Channel::from_shared(tuxtape_server_url)?.connect().await?, 206 | }; 207 | 208 | println!("Starting kernel builder server."); 209 | 210 | let builder_service = BuilderServer::new(builder); 211 | 212 | let mut join_set = tokio::task::JoinSet::new(); 213 | join_set.spawn(register_to_tuxtape_server(args.clone(), channel.clone())); 214 | join_set.spawn(host_server(args, health_service, builder_service)); 215 | join_set.spawn(watch_server_health(channel)); 216 | 217 | while let Some(join_result) = join_set.join_next().await { 218 | join_result?? 219 | } 220 | 221 | Ok(()) 222 | } 223 | 224 | async fn register_to_tuxtape_server(args: Arc, channel: Channel) -> Result<()> { 225 | let builder_address: String = args.url.clone(); 226 | let mut tuxtape_server_client = DatabaseClient::new(channel) 227 | .accept_compressed(CompressionEncoding::Gzip) 228 | .send_compressed(CompressionEncoding::Gzip) 229 | .max_decoding_message_size(usize::MAX) 230 | .max_encoding_message_size(usize::MAX); 231 | 232 | tuxtape_server_client 233 | .register_kernel_builder(RegisterKernelBuilderRequest { builder_address }) 234 | .await?; 235 | 236 | println!("Registered to tuxtape-server."); 237 | 238 | Ok(()) 239 | } 240 | 241 | async fn host_server( 242 | args: Arc, 243 | health_service: HealthServer, 244 | builder_service: BuilderServer, 245 | ) -> Result<()> { 246 | let addr = args.addr.parse()?; 247 | 248 | if args.tls { 249 | let cert = std::fs::read_to_string(&args.tls_cert_path)?; 250 | let key = std::fs::read_to_string(&args.tls_key_path)?; 251 | let identity = Identity::from_pem(cert, key); 252 | 253 | Server::builder() 254 | .tls_config(ServerTlsConfig::new().identity(identity))? 255 | .add_service( 256 | health_service 257 | .accept_compressed(CompressionEncoding::Gzip) 258 | .send_compressed(CompressionEncoding::Gzip), 259 | ) 260 | .add_service( 261 | builder_service 262 | .accept_compressed(CompressionEncoding::Gzip) 263 | .send_compressed(CompressionEncoding::Gzip) 264 | .max_decoding_message_size(usize::MAX) 265 | .max_encoding_message_size(usize::MAX), 266 | ) 267 | .serve(addr) 268 | .await?; 269 | } else { 270 | Server::builder() 271 | .add_service( 272 | health_service 273 | .accept_compressed(CompressionEncoding::Gzip) 274 | .send_compressed(CompressionEncoding::Gzip), 275 | ) 276 | .add_service( 277 | builder_service 278 | .accept_compressed(CompressionEncoding::Gzip) 279 | .send_compressed(CompressionEncoding::Gzip) 280 | .max_decoding_message_size(usize::MAX) 281 | .max_encoding_message_size(usize::MAX), 282 | ) 283 | .serve(addr) 284 | .await?; 285 | } 286 | 287 | Ok(()) 288 | } 289 | 290 | async fn watch_server_health(channel: Channel) -> Result<()> { 291 | let mut health_client = HealthClient::new(channel) 292 | .accept_compressed(CompressionEncoding::Gzip) 293 | .send_compressed(CompressionEncoding::Gzip); 294 | 295 | let result = health_client 296 | .watch(HealthCheckRequest { 297 | service: "tuxtape_server.Database".to_string(), 298 | }) 299 | .await; 300 | match result { 301 | Ok(resp) => { 302 | let mut stream = resp.into_inner(); 303 | while let Some(message) = stream.message().await.transpose() { 304 | match message { 305 | Ok(resp) => match resp.status() { 306 | ServingStatus::Serving => {} 307 | _ => { 308 | return Err(eyre!("tuxtape-server stopped serving requests")); 309 | } 310 | }, 311 | Err(_) => { 312 | return Err(eyre!("Lost connection to tuxtape-server")); 313 | } 314 | } 315 | } 316 | } 317 | Err(e) => { 318 | return Err(eyre!( 319 | "Could not connect to health service on tuxtape-server. Error: {}", 320 | e 321 | )); 322 | } 323 | } 324 | 325 | Ok(()) 326 | } 327 | 328 | fn checkout_kernel_version(kernel_version: &KernelVersion) -> Result<()> { 329 | let result = Command::new("git") 330 | .current_dir(LINUX_REPO_PATH) 331 | .args(["clean", "-f", "-d", "-x"]) 332 | .spawn()? 333 | .wait_with_output()?; 334 | 335 | match result.status.success() { 336 | true => {} 337 | false => { 338 | return Err(eyre!( 339 | "git clean -f -d failed with output: {}", 340 | String::from_utf8(result.stdout)? 341 | )) 342 | } 343 | } 344 | 345 | let result = Command::new("git") 346 | .current_dir(LINUX_REPO_PATH) 347 | .args(["reset", "--hard", "HEAD"]) 348 | .spawn()? 349 | .wait_with_output()?; 350 | 351 | match result.status.success() { 352 | true => {} 353 | false => { 354 | return Err(eyre!( 355 | "git reset --hard HEAD failed with output: {}", 356 | String::from_utf8(result.stdout)? 357 | )) 358 | } 359 | } 360 | 361 | let tag_string = format!( 362 | "v{}.{}{}", 363 | kernel_version.major, 364 | kernel_version.minor, 365 | match kernel_version.patch { 366 | Some(patch) => format!(".{}", patch), 367 | None => "".to_string(), 368 | } 369 | ); 370 | 371 | let result = Command::new("git") 372 | .current_dir(LINUX_REPO_PATH) 373 | .args(["checkout", tag_string.as_str()]) 374 | .spawn()? 375 | .wait_with_output()?; 376 | 377 | match result.status.success() { 378 | true => Ok(()), 379 | false => Err(eyre!( 380 | "Git failed to checkout tag {} with output: {}", 381 | tag_string, 382 | String::from_utf8(result.stdout)? 383 | )), 384 | } 385 | } 386 | 387 | fn build_kernel( 388 | config_file: &str, 389 | config_name: &str, 390 | kernel_version: &KernelVersion, 391 | ) -> Result<()> { 392 | println!("Building kernel"); 393 | 394 | // Sanity check before running rm -rf 395 | let build_path = Path::new(BUILD_PATH); 396 | let build_profiles_path = Path::new(BUILD_PROFILES_PATH); 397 | let cache_path: &Path = Path::new(CACHE_PATH); 398 | 399 | if build_path 400 | .parent() 401 | .is_none_or(|parent| parent != cache_path) 402 | { 403 | return Err(eyre!( 404 | "BUILD_PATH ({}) is not a child of CACHE_PATH ({})", 405 | BUILD_PATH, 406 | CACHE_PATH 407 | )); 408 | } 409 | 410 | if build_profiles_path 411 | .parent() 412 | .is_none_or(|parent| parent != cache_path) 413 | { 414 | return Err(eyre!( 415 | "BUILD_PROFILES_PATH ({}) is not a child of CACHE_PATH ({})", 416 | BUILD_PROFILES_PATH, 417 | CACHE_PATH 418 | )); 419 | } 420 | 421 | let build_output_path = PathBuf::from(format!("{}/{}", BUILD_PATH, config_name)); 422 | let build_profile_output_path = 423 | PathBuf::from(format!("{}/{}", BUILD_PROFILES_PATH, config_name).as_str()); 424 | 425 | // Clear out build_output_path and build_profile_output_path if they already exist 426 | Command::new("rm") 427 | .arg("-r") 428 | .arg("-f") 429 | .arg(&build_output_path) 430 | .spawn()? 431 | .wait()?; 432 | 433 | Command::new("rm") 434 | .arg("-r") 435 | .arg("-f") 436 | .arg(&build_profile_output_path) 437 | .spawn()? 438 | .wait()?; 439 | 440 | if !build_output_path.exists() { 441 | std::fs::create_dir_all(&build_output_path)?; 442 | } 443 | if !build_profile_output_path.exists() { 444 | std::fs::create_dir_all(&build_profile_output_path)?; 445 | } 446 | 447 | checkout_kernel_version(kernel_version)?; 448 | 449 | Command::new("make") 450 | .arg("distclean") 451 | .current_dir(LINUX_REPO_PATH) 452 | .spawn()? 453 | .wait()?; 454 | 455 | let config_file_path = build_output_path.join(".config"); 456 | std::fs::write(config_file_path, config_file)?; 457 | 458 | // For some reason, Command is sometimes capturing a " character, so this filters 459 | // out all non-numeric characters 460 | let nproc_retval = Command::new("nproc") 461 | .output()? 462 | .stdout 463 | .iter() 464 | .filter(|char| char.is_ascii_digit()) 465 | .copied() 466 | .collect(); 467 | let threads = String::from_utf8(nproc_retval)?; 468 | let threads_arg = format!("-j{threads}"); 469 | 470 | Command::new("make") 471 | .args([ 472 | "-C", 473 | LINUX_REPO_PATH, 474 | "defconfig", 475 | format!("O={}", build_output_path.to_str().unwrap()).as_str(), 476 | ]) 477 | .spawn()? 478 | .wait()?; 479 | 480 | Command::new("remake") 481 | .args([ 482 | "--profile=json", 483 | format!( 484 | "--profile-directory={}", 485 | &build_profile_output_path.to_str().unwrap() 486 | ) 487 | .as_str(), 488 | threads_arg.as_str(), 489 | "-C", 490 | LINUX_REPO_PATH, 491 | format!("O={}", build_output_path.to_str().unwrap()).as_str(), 492 | ]) 493 | .spawn()? 494 | .wait()?; 495 | 496 | println!("Done building kernel"); 497 | 498 | Ok(()) 499 | } 500 | 501 | fn get_included_files(config_name: &str) -> Result> { 502 | let build_profile_output_path = 503 | PathBuf::from(format!("{}/{}", BUILD_PROFILES_PATH, config_name).as_str()); 504 | if !build_profile_output_path.exists() { 505 | std::fs::create_dir_all(&build_profile_output_path)?; 506 | } 507 | 508 | let mut included_files: HashSet = HashSet::new(); 509 | 510 | for file in build_profile_output_path.read_dir()?.flatten() { 511 | // Only operate on .json files 512 | if !file.path().extension().map_or(false, |s| s == "json") { 513 | continue; 514 | } 515 | 516 | let json = File::open(file.path())?; 517 | let kernel_profile: KernelProfileJson = serde_json::from_reader(json) 518 | .unwrap_or_else(|_| panic!("Failed at file: {:?}", file.path())); 519 | 520 | for target in kernel_profile.targets { 521 | if let Some(file) = target.file { 522 | let stripped_file = file.strip_prefix(LINUX_REPO_PATH).unwrap_or(&file); 523 | 524 | if Path::new(LINUX_REPO_PATH) 525 | .join(Path::new(stripped_file)) 526 | .is_file() 527 | { 528 | included_files.insert(stripped_file.to_string()); 529 | } 530 | } 531 | 532 | for depend in target.depends { 533 | let stripped_file = depend.strip_prefix(LINUX_REPO_PATH).unwrap_or(&depend); 534 | 535 | if Path::new(LINUX_REPO_PATH) 536 | .join(Path::new(stripped_file)) 537 | .is_file() 538 | { 539 | included_files.insert(stripped_file.to_string()); 540 | } 541 | } 542 | } 543 | } 544 | 545 | Ok(included_files.into_iter().collect()) 546 | } 547 | 548 | #[derive(Serialize, Deserialize)] 549 | struct KernelProfileJson { 550 | targets: Vec, 551 | } 552 | 553 | #[derive(Serialize, Deserialize)] 554 | struct Target { 555 | file: Option, 556 | depends: Vec, 557 | } 558 | -------------------------------------------------------------------------------- /src/dashboard/config.rs: -------------------------------------------------------------------------------- 1 | // TODO - remove this once we're out of PoC 2 | #![allow(dead_code)] 3 | 4 | use crate::{action::Action, app::Mode}; 5 | use color_eyre::Result; 6 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 7 | use derive_deref::{Deref, DerefMut}; 8 | use directories::ProjectDirs; 9 | use lazy_static::lazy_static; 10 | use ratatui::style::{Color, Modifier, Style}; 11 | use serde::{de::Deserializer, Deserialize}; 12 | use std::{collections::HashMap, env, path::PathBuf}; 13 | 14 | const CONFIG: &str = include_str!("../../.config/tuxtape-dashboard-config.toml"); 15 | 16 | #[derive(Clone, Debug, Deserialize, Default)] 17 | pub struct AppConfig { 18 | #[serde(default)] 19 | pub data_dir: PathBuf, 20 | #[serde(default)] 21 | pub config_dir: PathBuf, 22 | } 23 | 24 | #[derive(Clone, Debug, Deserialize, Default)] 25 | pub struct DatabaseConfig { 26 | #[serde(default)] 27 | pub server_url: String, 28 | #[serde(default)] 29 | pub use_tls: bool, 30 | #[serde(default)] 31 | pub tls_cert_path: Option, 32 | } 33 | 34 | #[derive(Clone, Debug, Default, Deserialize)] 35 | pub struct Config { 36 | #[serde(default, flatten)] 37 | pub config: AppConfig, 38 | #[serde(default)] 39 | pub keybindings: KeyBindings, 40 | #[serde(default)] 41 | pub styles: Styles, 42 | #[serde(default)] 43 | pub database: DatabaseConfig, 44 | } 45 | 46 | lazy_static! { 47 | pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); 48 | pub static ref DATA_FOLDER: Option = 49 | env::var(format!("{}_DATA", PROJECT_NAME.clone())) 50 | .ok() 51 | .map(PathBuf::from); 52 | pub static ref CONFIG_FOLDER: Option = 53 | env::var(format!("{}_CONFIG", PROJECT_NAME.clone())) 54 | .ok() 55 | .map(PathBuf::from); 56 | } 57 | 58 | impl Config { 59 | pub fn new() -> Result { 60 | let config_file = "config.toml"; 61 | let config_format = config::FileFormat::Toml; 62 | 63 | let default_config: Config = 64 | toml::de::from_str(CONFIG).expect(".config/tuxtape-dashboard-config.toml should exist"); 65 | let data_dir = get_data_dir(); 66 | let config_dir = get_config_dir(); 67 | let mut builder = config::Config::builder() 68 | .set_default("data_dir", data_dir.to_str().unwrap())? 69 | .set_default("config_dir", config_dir.to_str().unwrap())?; 70 | 71 | let source = config::File::from(config_dir.join(config_file)) 72 | .format(config_format) 73 | .required(false); 74 | builder = builder.add_source(source); 75 | 76 | let mut cfg: Self = builder.build()?.try_deserialize()?; 77 | 78 | for (mode, default_bindings) in default_config.keybindings.iter() { 79 | let user_bindings = cfg.keybindings.entry(*mode).or_default(); 80 | for (key, cmd) in default_bindings.iter() { 81 | user_bindings 82 | .entry(key.clone()) 83 | .or_insert_with(|| cmd.clone()); 84 | } 85 | } 86 | for (mode, default_styles) in default_config.styles.iter() { 87 | let user_styles = cfg.styles.entry(*mode).or_default(); 88 | for (style_key, style) in default_styles.iter() { 89 | user_styles.entry(style_key.clone()).or_insert(*style); 90 | } 91 | } 92 | 93 | // TODO - don't overwrite user config with default config 94 | // Should probably just force copy the default config to user config destination 95 | // if it doesn't exist and then only read from that. 96 | cfg.database.server_url = default_config.database.server_url; 97 | cfg.database.use_tls = default_config.database.use_tls; 98 | cfg.database.tls_cert_path = default_config.database.tls_cert_path; 99 | 100 | Ok(cfg) 101 | } 102 | } 103 | 104 | pub fn get_data_dir() -> PathBuf { 105 | let directory = if let Some(s) = DATA_FOLDER.clone() { 106 | s 107 | } else if let Some(proj_dirs) = project_directory() { 108 | proj_dirs.data_local_dir().to_path_buf() 109 | } else { 110 | PathBuf::from(".").join(".data") 111 | }; 112 | directory 113 | } 114 | 115 | pub fn get_config_dir() -> PathBuf { 116 | let directory = if let Some(s) = CONFIG_FOLDER.clone() { 117 | s 118 | } else if let Some(proj_dirs) = project_directory() { 119 | proj_dirs.config_local_dir().to_path_buf() 120 | } else { 121 | PathBuf::from(".").join(".config") 122 | }; 123 | directory 124 | } 125 | 126 | fn project_directory() -> Option { 127 | ProjectDirs::from("com", "tuxtape", env!("CARGO_PKG_NAME")) 128 | } 129 | 130 | #[derive(Clone, Debug, Default, Deref, DerefMut)] 131 | pub struct KeyBindings(pub HashMap, Action>>); 132 | 133 | impl<'de> Deserialize<'de> for KeyBindings { 134 | fn deserialize(deserializer: D) -> Result 135 | where 136 | D: Deserializer<'de>, 137 | { 138 | let parsed_map = HashMap::>::deserialize(deserializer)?; 139 | 140 | let keybindings = parsed_map 141 | .into_iter() 142 | .map(|(mode, inner_map)| { 143 | let converted_inner_map = inner_map 144 | .into_iter() 145 | .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)) 146 | .collect(); 147 | (mode, converted_inner_map) 148 | }) 149 | .collect(); 150 | 151 | Ok(KeyBindings(keybindings)) 152 | } 153 | } 154 | 155 | fn parse_key_event(raw: &str) -> Result { 156 | let raw_lower = raw.to_ascii_lowercase(); 157 | let (remaining, modifiers) = extract_modifiers(&raw_lower); 158 | parse_key_code_with_modifiers(remaining, modifiers) 159 | } 160 | 161 | fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { 162 | let mut modifiers = KeyModifiers::empty(); 163 | let mut current = raw; 164 | 165 | loop { 166 | match current { 167 | rest if rest.starts_with("ctrl-") => { 168 | modifiers.insert(KeyModifiers::CONTROL); 169 | current = &rest[5..]; 170 | } 171 | rest if rest.starts_with("alt-") => { 172 | modifiers.insert(KeyModifiers::ALT); 173 | current = &rest[4..]; 174 | } 175 | rest if rest.starts_with("shift-") => { 176 | modifiers.insert(KeyModifiers::SHIFT); 177 | current = &rest[6..]; 178 | } 179 | _ => break, // break out of the loop if no known prefix is detected 180 | }; 181 | } 182 | 183 | (current, modifiers) 184 | } 185 | 186 | fn parse_key_code_with_modifiers( 187 | raw: &str, 188 | mut modifiers: KeyModifiers, 189 | ) -> Result { 190 | let c = match raw { 191 | "esc" => KeyCode::Esc, 192 | "enter" => KeyCode::Enter, 193 | "left" => KeyCode::Left, 194 | "right" => KeyCode::Right, 195 | "up" => KeyCode::Up, 196 | "down" => KeyCode::Down, 197 | "home" => KeyCode::Home, 198 | "end" => KeyCode::End, 199 | "pageup" => KeyCode::PageUp, 200 | "pagedown" => KeyCode::PageDown, 201 | "backtab" => { 202 | modifiers.insert(KeyModifiers::SHIFT); 203 | KeyCode::BackTab 204 | } 205 | "backspace" => KeyCode::Backspace, 206 | "delete" => KeyCode::Delete, 207 | "insert" => KeyCode::Insert, 208 | "f1" => KeyCode::F(1), 209 | "f2" => KeyCode::F(2), 210 | "f3" => KeyCode::F(3), 211 | "f4" => KeyCode::F(4), 212 | "f5" => KeyCode::F(5), 213 | "f6" => KeyCode::F(6), 214 | "f7" => KeyCode::F(7), 215 | "f8" => KeyCode::F(8), 216 | "f9" => KeyCode::F(9), 217 | "f10" => KeyCode::F(10), 218 | "f11" => KeyCode::F(11), 219 | "f12" => KeyCode::F(12), 220 | "space" => KeyCode::Char(' '), 221 | "hyphen" => KeyCode::Char('-'), 222 | "minus" => KeyCode::Char('-'), 223 | "tab" => KeyCode::Tab, 224 | c if c.len() == 1 => { 225 | let mut c = c.chars().next().unwrap(); 226 | if modifiers.contains(KeyModifiers::SHIFT) { 227 | c = c.to_ascii_uppercase(); 228 | } 229 | KeyCode::Char(c) 230 | } 231 | _ => return Err(format!("Unable to parse {raw}")), 232 | }; 233 | Ok(KeyEvent::new(c, modifiers)) 234 | } 235 | 236 | pub fn key_event_to_string(key_event: &KeyEvent) -> String { 237 | let char; 238 | let key_code = match key_event.code { 239 | KeyCode::Backspace => "backspace", 240 | KeyCode::Enter => "enter", 241 | KeyCode::Left => "left", 242 | KeyCode::Right => "right", 243 | KeyCode::Up => "up", 244 | KeyCode::Down => "down", 245 | KeyCode::Home => "home", 246 | KeyCode::End => "end", 247 | KeyCode::PageUp => "pageup", 248 | KeyCode::PageDown => "pagedown", 249 | KeyCode::Tab => "tab", 250 | KeyCode::BackTab => "backtab", 251 | KeyCode::Delete => "delete", 252 | KeyCode::Insert => "insert", 253 | KeyCode::F(c) => { 254 | char = format!("f({c})"); 255 | &char 256 | } 257 | KeyCode::Char(' ') => "space", 258 | KeyCode::Char(c) => { 259 | char = c.to_string(); 260 | &char 261 | } 262 | KeyCode::Esc => "esc", 263 | KeyCode::Null => "", 264 | KeyCode::CapsLock => "", 265 | KeyCode::Menu => "", 266 | KeyCode::ScrollLock => "", 267 | KeyCode::Media(_) => "", 268 | KeyCode::NumLock => "", 269 | KeyCode::PrintScreen => "", 270 | KeyCode::Pause => "", 271 | KeyCode::KeypadBegin => "", 272 | KeyCode::Modifier(_) => "", 273 | }; 274 | 275 | let mut modifiers = Vec::with_capacity(3); 276 | 277 | if key_event.modifiers.intersects(KeyModifiers::CONTROL) { 278 | modifiers.push("ctrl"); 279 | } 280 | 281 | if key_event.modifiers.intersects(KeyModifiers::SHIFT) { 282 | modifiers.push("shift"); 283 | } 284 | 285 | if key_event.modifiers.intersects(KeyModifiers::ALT) { 286 | modifiers.push("alt"); 287 | } 288 | 289 | let mut key = modifiers.join("-"); 290 | 291 | if !key.is_empty() { 292 | key.push('-'); 293 | } 294 | key.push_str(key_code); 295 | 296 | key 297 | } 298 | 299 | pub fn parse_key_sequence(raw: &str) -> Result, String> { 300 | if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { 301 | return Err(format!("Unable to parse `{}`", raw)); 302 | } 303 | let raw = if !raw.contains("><") { 304 | let raw = raw.strip_prefix('<').unwrap_or(raw); 305 | let raw = raw.strip_prefix('>').unwrap_or(raw); 306 | raw 307 | } else { 308 | raw 309 | }; 310 | let sequences = raw 311 | .split("><") 312 | .map(|seq| { 313 | if let Some(s) = seq.strip_prefix('<') { 314 | s 315 | } else if let Some(s) = seq.strip_suffix('>') { 316 | s 317 | } else { 318 | seq 319 | } 320 | }) 321 | .collect::>(); 322 | 323 | sequences.into_iter().map(parse_key_event).collect() 324 | } 325 | 326 | #[derive(Clone, Debug, Default, Deref, DerefMut)] 327 | pub struct Styles(pub HashMap>); 328 | 329 | impl<'de> Deserialize<'de> for Styles { 330 | fn deserialize(deserializer: D) -> Result 331 | where 332 | D: Deserializer<'de>, 333 | { 334 | let parsed_map = HashMap::>::deserialize(deserializer)?; 335 | 336 | let styles = parsed_map 337 | .into_iter() 338 | .map(|(mode, inner_map)| { 339 | let converted_inner_map = inner_map 340 | .into_iter() 341 | .map(|(str, style)| (str, parse_style(&style))) 342 | .collect(); 343 | (mode, converted_inner_map) 344 | }) 345 | .collect(); 346 | 347 | Ok(Styles(styles)) 348 | } 349 | } 350 | 351 | pub fn parse_style(line: &str) -> Style { 352 | let (foreground, background) = 353 | line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); 354 | let foreground = process_color_string(foreground); 355 | let background = process_color_string(&background.replace("on ", "")); 356 | 357 | let mut style = Style::default(); 358 | if let Some(fg) = parse_color(&foreground.0) { 359 | style = style.fg(fg); 360 | } 361 | if let Some(bg) = parse_color(&background.0) { 362 | style = style.bg(bg); 363 | } 364 | style = style.add_modifier(foreground.1 | background.1); 365 | style 366 | } 367 | 368 | fn process_color_string(color_str: &str) -> (String, Modifier) { 369 | let color = color_str 370 | .replace("grey", "gray") 371 | .replace("bright ", "") 372 | .replace("bold ", "") 373 | .replace("underline ", "") 374 | .replace("inverse ", ""); 375 | 376 | let mut modifiers = Modifier::empty(); 377 | if color_str.contains("underline") { 378 | modifiers |= Modifier::UNDERLINED; 379 | } 380 | if color_str.contains("bold") { 381 | modifiers |= Modifier::BOLD; 382 | } 383 | if color_str.contains("inverse") { 384 | modifiers |= Modifier::REVERSED; 385 | } 386 | 387 | (color, modifiers) 388 | } 389 | 390 | fn parse_color(s: &str) -> Option { 391 | let s = s.trim_start(); 392 | let s = s.trim_end(); 393 | if s.contains("bright color") { 394 | let s = s.trim_start_matches("bright "); 395 | let c = s 396 | .trim_start_matches("color") 397 | .parse::() 398 | .unwrap_or_default(); 399 | Some(Color::Indexed(c.wrapping_shl(8))) 400 | } else if s.contains("color") { 401 | let c = s 402 | .trim_start_matches("color") 403 | .parse::() 404 | .unwrap_or_default(); 405 | Some(Color::Indexed(c)) 406 | } else if s.contains("gray") { 407 | let c = 232 408 | + s.trim_start_matches("gray") 409 | .parse::() 410 | .unwrap_or_default(); 411 | Some(Color::Indexed(c)) 412 | } else if s.contains("rgb") { 413 | let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; 414 | let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; 415 | let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; 416 | let c = 16 + red * 36 + green * 6 + blue; 417 | Some(Color::Indexed(c)) 418 | } else if s == "bold black" { 419 | Some(Color::Indexed(8)) 420 | } else if s == "bold red" { 421 | Some(Color::Indexed(9)) 422 | } else if s == "bold green" { 423 | Some(Color::Indexed(10)) 424 | } else if s == "bold yellow" { 425 | Some(Color::Indexed(11)) 426 | } else if s == "bold blue" { 427 | Some(Color::Indexed(12)) 428 | } else if s == "bold magenta" { 429 | Some(Color::Indexed(13)) 430 | } else if s == "bold cyan" { 431 | Some(Color::Indexed(14)) 432 | } else if s == "bold white" { 433 | Some(Color::Indexed(15)) 434 | } else if s == "black" { 435 | Some(Color::Indexed(0)) 436 | } else if s == "red" { 437 | Some(Color::Indexed(1)) 438 | } else if s == "green" { 439 | Some(Color::Indexed(2)) 440 | } else if s == "yellow" { 441 | Some(Color::Indexed(3)) 442 | } else if s == "blue" { 443 | Some(Color::Indexed(4)) 444 | } else if s == "magenta" { 445 | Some(Color::Indexed(5)) 446 | } else if s == "cyan" { 447 | Some(Color::Indexed(6)) 448 | } else if s == "white" { 449 | Some(Color::Indexed(7)) 450 | } else { 451 | None 452 | } 453 | } 454 | 455 | #[cfg(test)] 456 | mod tests { 457 | use pretty_assertions::assert_eq; 458 | 459 | use super::*; 460 | 461 | #[test] 462 | fn test_parse_style_default() { 463 | let style = parse_style(""); 464 | assert_eq!(style, Style::default()); 465 | } 466 | 467 | #[test] 468 | fn test_parse_style_foreground() { 469 | let style = parse_style("red"); 470 | assert_eq!(style.fg, Some(Color::Indexed(1))); 471 | } 472 | 473 | #[test] 474 | fn test_parse_style_background() { 475 | let style = parse_style("on blue"); 476 | assert_eq!(style.bg, Some(Color::Indexed(4))); 477 | } 478 | 479 | #[test] 480 | fn test_parse_style_modifiers() { 481 | let style = parse_style("underline red on blue"); 482 | assert_eq!(style.fg, Some(Color::Indexed(1))); 483 | assert_eq!(style.bg, Some(Color::Indexed(4))); 484 | } 485 | 486 | #[test] 487 | fn test_process_color_string() { 488 | let (color, modifiers) = process_color_string("underline bold inverse gray"); 489 | assert_eq!(color, "gray"); 490 | assert!(modifiers.contains(Modifier::UNDERLINED)); 491 | assert!(modifiers.contains(Modifier::BOLD)); 492 | assert!(modifiers.contains(Modifier::REVERSED)); 493 | } 494 | 495 | #[test] 496 | fn test_parse_color_rgb() { 497 | let color = parse_color("rgb123"); 498 | let expected = 16 + 36 + 2 * 6 + 3; 499 | assert_eq!(color, Some(Color::Indexed(expected))); 500 | } 501 | 502 | #[test] 503 | fn test_parse_color_unknown() { 504 | let color = parse_color("unknown"); 505 | assert_eq!(color, None); 506 | } 507 | 508 | #[test] 509 | fn test_config() -> Result<()> { 510 | let c = Config::new()?; 511 | assert_eq!( 512 | c.keybindings 513 | .get(&Mode::Normal) 514 | .unwrap() 515 | .get(&parse_key_sequence("").unwrap_or_default()) 516 | .unwrap(), 517 | &Action::Quit 518 | ); 519 | Ok(()) 520 | } 521 | 522 | #[test] 523 | fn test_simple_keys() { 524 | assert_eq!( 525 | parse_key_event("a").unwrap(), 526 | KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()) 527 | ); 528 | 529 | assert_eq!( 530 | parse_key_event("enter").unwrap(), 531 | KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()) 532 | ); 533 | 534 | assert_eq!( 535 | parse_key_event("esc").unwrap(), 536 | KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()) 537 | ); 538 | } 539 | 540 | #[test] 541 | fn test_with_modifiers() { 542 | assert_eq!( 543 | parse_key_event("ctrl-a").unwrap(), 544 | KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) 545 | ); 546 | 547 | assert_eq!( 548 | parse_key_event("alt-enter").unwrap(), 549 | KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) 550 | ); 551 | 552 | assert_eq!( 553 | parse_key_event("shift-esc").unwrap(), 554 | KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT) 555 | ); 556 | } 557 | 558 | #[test] 559 | fn test_multiple_modifiers() { 560 | assert_eq!( 561 | parse_key_event("ctrl-alt-a").unwrap(), 562 | KeyEvent::new( 563 | KeyCode::Char('a'), 564 | KeyModifiers::CONTROL | KeyModifiers::ALT 565 | ) 566 | ); 567 | 568 | assert_eq!( 569 | parse_key_event("ctrl-shift-enter").unwrap(), 570 | KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) 571 | ); 572 | } 573 | 574 | #[test] 575 | fn test_reverse_multiple_modifiers() { 576 | assert_eq!( 577 | key_event_to_string(&KeyEvent::new( 578 | KeyCode::Char('a'), 579 | KeyModifiers::CONTROL | KeyModifiers::ALT 580 | )), 581 | "ctrl-alt-a".to_string() 582 | ); 583 | } 584 | 585 | #[test] 586 | fn test_invalid_keys() { 587 | assert!(parse_key_event("invalid-key").is_err()); 588 | assert!(parse_key_event("ctrl-invalid-key").is_err()); 589 | } 590 | 591 | #[test] 592 | fn test_case_insensitivity() { 593 | assert_eq!( 594 | parse_key_event("CTRL-a").unwrap(), 595 | KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) 596 | ); 597 | 598 | assert_eq!( 599 | parse_key_event("AlT-eNtEr").unwrap(), 600 | KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) 601 | ); 602 | } 603 | } 604 | -------------------------------------------------------------------------------- /src/dashboard/tui/pages/home.rs: -------------------------------------------------------------------------------- 1 | use crate::database::*; 2 | use crate::tui::components::Component; 3 | use crate::tui::popups::PopupType::{Alert, CveEditPreview}; 4 | use crate::{action::Action, config::Config}; 5 | use color_eyre::eyre::eyre; 6 | use color_eyre::Result; 7 | use ratatui::{prelude::*, widgets::*}; 8 | use serde::{Deserialize, Serialize}; 9 | use std::fs::File; 10 | use std::io::prelude::*; 11 | use std::sync::Arc; 12 | use strum::{Display, EnumCount, EnumIter, FromRepr}; 13 | use tokio::sync::mpsc::UnboundedSender; 14 | 15 | /// The main page for tuxtape-dashboard. This is where all CVEs will be listed and selectable for editing. 16 | pub struct HomePage { 17 | command_tx: Option>, 18 | config: Arc, 19 | cves: Option>>, 20 | cve_list_state: ListState, 21 | fetching_cves: bool, 22 | cve_affects_pane: CveAffectsPane, 23 | cve_details_pane: CveDetailsPane, 24 | selected_pane: Pane, 25 | selected_cve: Option>, 26 | } 27 | 28 | impl HomePage { 29 | pub fn new(config: Arc) -> Self { 30 | let selected_cve = None; 31 | 32 | Self { 33 | command_tx: None, 34 | config, 35 | cves: None, 36 | cve_list_state: ListState::default(), 37 | fetching_cves: false, 38 | cve_affects_pane: CveAffectsPane::new(selected_cve.clone()), 39 | cve_details_pane: CveDetailsPane::new(selected_cve.clone()), 40 | selected_pane: Pane::CveList, 41 | selected_cve: None, 42 | } 43 | } 44 | 45 | fn build_cve_list(&mut self, cves: Vec>) { 46 | self.cves = Some(cves); 47 | self.cve_list_state.select_first(); 48 | self.update_selected_cve(); 49 | } 50 | 51 | fn preview_edit_patch(&self) -> Result<()> { 52 | if let Some(cves) = &self.cves { 53 | if let Some(command_tx) = &self.command_tx { 54 | let selected_row = self 55 | .cve_list_state 56 | .selected() 57 | .expect("A row on the table will always be highlighted"); 58 | let selected_cve = cves 59 | .get(selected_row) 60 | .expect("The selected row should always align with a CVE instance in the Vec") 61 | .clone(); 62 | 63 | command_tx.send(Action::Popup(CveEditPreview(selected_cve)))?; 64 | 65 | return Ok(()); 66 | } 67 | } 68 | 69 | Ok(()) 70 | } 71 | 72 | fn edit_patch(&self, cve_instance: &CveInstance) -> Result<()> { 73 | let command_tx = match self.command_tx.as_ref() { 74 | Some(command_tx) => command_tx, 75 | None => { 76 | return Err(eyre!( 77 | "self.command_tx should always exist by the time this is called" 78 | )) 79 | } 80 | }; 81 | 82 | let raw_patch = match &cve_instance.raw_patch { 83 | Some(patch) => patch, 84 | None => { 85 | command_tx.send(Action::Popup(Alert( 86 | "This CVE is missing a raw patch, so there's nothing to edit.".to_string(), 87 | )))?; 88 | return Ok(()); 89 | } 90 | }; 91 | 92 | let mut patch_file_path = self.config.config.data_dir.clone(); 93 | patch_file_path.push("patches"); 94 | 95 | match std::fs::create_dir_all(&patch_file_path) { 96 | Ok(()) => {} 97 | Err(e) => { 98 | command_tx.send(Action::Popup(Alert( 99 | format!("Failed to create directories to {:?}.", patch_file_path).to_string(), 100 | )))?; 101 | return Err(e.into()); 102 | } 103 | } 104 | 105 | patch_file_path.push(format!("{}.patch", cve_instance.title)); 106 | 107 | let mut file = match File::create(&patch_file_path) { 108 | Ok(file) => file, 109 | Err(e) => { 110 | command_tx.send(Action::Popup(Alert( 111 | format!("Failed to create file {:?}", patch_file_path).to_string(), 112 | )))?; 113 | return Err(e.into()); 114 | } 115 | }; 116 | 117 | match file.write_all(raw_patch.as_bytes()) { 118 | Ok(()) => {} 119 | Err(e) => { 120 | command_tx.send(Action::Popup(Alert( 121 | format!("Failed to write patch to {:?}", patch_file_path).to_string(), 122 | )))?; 123 | return Err(e.into()); 124 | } 125 | }; 126 | 127 | let action = Action::EditPatchAtPath(patch_file_path.to_str().unwrap().to_string()); 128 | command_tx.send(action)?; 129 | 130 | Ok(()) 131 | } 132 | 133 | fn update_selected_cve(&mut self) { 134 | let selection_index = if let Some(selection_index) = self.cve_list_state.selected() { 135 | selection_index 136 | } else { 137 | self.selected_cve = None; 138 | return; 139 | }; 140 | 141 | let cves = if let Some(cves) = self.cves.as_ref() { 142 | cves 143 | } else { 144 | self.selected_cve = None; 145 | return; 146 | }; 147 | 148 | if selection_index >= cves.len() { 149 | self.selected_cve = None; 150 | } else { 151 | self.selected_cve = Some(cves[selection_index].clone()); 152 | } 153 | } 154 | } 155 | 156 | impl Component for HomePage { 157 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 158 | self.command_tx = Some(tx); 159 | Ok(()) 160 | } 161 | 162 | fn update(&mut self, action: Action) -> Result> { 163 | match &action { 164 | Action::HomePage(action) => match action { 165 | HomePageAction::EditPatch(cve_instance) => self.edit_patch(cve_instance)?, 166 | }, 167 | Action::PaneLeft => self.selected_pane = self.selected_pane.previous(), 168 | Action::PaneRight => self.selected_pane = self.selected_pane.next(), 169 | Action::Database(DatabaseAction::Response(response)) => match response { 170 | Response::PopulateTable(cve_instances) => { 171 | self.build_cve_list(cve_instances.clone()) 172 | } 173 | Response::PutKernelConfig { 174 | kernel_config_metadata: _, 175 | success: _, 176 | } => { 177 | // Do nothing as the corresponding Request was not sent by HomePage 178 | } 179 | }, 180 | _ => match self.selected_pane { 181 | Pane::CveList => match &action { 182 | Action::ScrollDown => { 183 | if let Some(cves) = &self.cves { 184 | self.cve_list_state.select_next(); 185 | 186 | if cves.len() 187 | <= self 188 | .cve_list_state 189 | .selected() 190 | .expect("Something should always be selected") 191 | { 192 | self.cve_list_state.select_first(); 193 | } 194 | 195 | self.update_selected_cve(); 196 | } 197 | } 198 | Action::ScrollUp => { 199 | if let Some(cves) = &self.cves { 200 | if self 201 | .cve_list_state 202 | .selected() 203 | .expect("Something should always be selected") 204 | == 0 205 | { 206 | // Note: ListState::select_last() does not work as expected. 207 | self.cve_list_state.select(Some(cves.len() - 1)); 208 | } else { 209 | self.cve_list_state.select_previous(); 210 | } 211 | 212 | self.update_selected_cve(); 213 | } 214 | } 215 | Action::Select => self.preview_edit_patch()?, 216 | _ => return Ok(Some(action)), 217 | }, 218 | Pane::Affects => return self.cve_affects_pane.update(action), 219 | Pane::Details => return self.cve_details_pane.update(action), 220 | }, 221 | } 222 | 223 | Ok(None) 224 | } 225 | 226 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 227 | if let Some(cves) = self.cves.as_ref() { 228 | let chunks = Layout::default() 229 | .direction(Direction::Horizontal) 230 | .constraints([ 231 | Constraint::Percentage(15), 232 | Constraint::Percentage(25), 233 | Constraint::Percentage(60), 234 | ]) 235 | .split(area); 236 | 237 | let cve_list = List::default() 238 | .items(cves.iter().map(|cve| cve.id.as_str())) 239 | .style(Style::new().blue()) 240 | .highlight_style(Style::new().reversed()) 241 | .highlight_symbol(">>") 242 | .block( 243 | match self.selected_pane { 244 | Pane::CveList => Block::bordered().style(Style::new().green()), 245 | _ => Block::bordered().style(Style::new().white()), 246 | } 247 | .title("CVEs"), 248 | ); 249 | 250 | frame.render_stateful_widget(cve_list, chunks[0], &mut self.cve_list_state); 251 | 252 | self.cve_affects_pane.draw( 253 | frame, 254 | chunks[1], 255 | self.selected_cve.clone(), 256 | matches!(self.selected_pane, Pane::Affects), 257 | )?; 258 | 259 | self.cve_details_pane.draw( 260 | frame, 261 | chunks[2], 262 | self.selected_cve.clone(), 263 | matches!(self.selected_pane, Pane::Details), 264 | )?; 265 | 266 | Ok(()) 267 | } else { 268 | // Display "Loading" text instead of CVEs until they're retrieved from the server 269 | if !self.fetching_cves { 270 | if let Some(command_tx) = self.command_tx.as_mut() { 271 | command_tx.send(Action::Database(DatabaseAction::Request( 272 | Request::PopulateTable(), 273 | )))?; 274 | self.fetching_cves = true; 275 | } 276 | } 277 | 278 | frame.render_widget(Paragraph::new("Loading"), area); 279 | Ok(()) 280 | } 281 | } 282 | } 283 | 284 | #[derive(Debug, Clone, PartialEq, Display, Serialize, Deserialize)] 285 | pub enum HomePageAction { 286 | EditPatch(CveInstance), 287 | } 288 | 289 | #[derive(EnumIter, EnumCount, FromRepr, Clone, Copy)] 290 | enum Pane { 291 | CveList, 292 | Affects, 293 | Details, 294 | } 295 | 296 | impl Pane { 297 | /// Get the next pane. If there is no next pane, loop around to first. 298 | fn next(self) -> Self { 299 | let current_index = self as usize; 300 | const MAX_INDEX: usize = Pane::COUNT - 1; 301 | let next_index = match current_index { 302 | MAX_INDEX => 0, 303 | _ => current_index + 1, 304 | }; 305 | Self::from_repr(next_index).unwrap_or(self) 306 | } 307 | 308 | /// Get the previous pane. If there is no previous pane, loop around to last. 309 | fn previous(self) -> Self { 310 | let current_index = self as usize; 311 | let previous_index = match current_index { 312 | 0 => Pane::COUNT - 1, 313 | _ => current_index - 1, 314 | }; 315 | Self::from_repr(previous_index).unwrap_or(self) 316 | } 317 | } 318 | 319 | #[derive(Default)] 320 | struct CveAffectsPane { 321 | list_state: ListState, 322 | selected_cve: Option>, 323 | } 324 | 325 | impl CveAffectsPane { 326 | fn new(selected_cve: Option>) -> Self { 327 | Self { 328 | list_state: ListState::default(), 329 | selected_cve, 330 | } 331 | } 332 | 333 | fn update(&mut self, action: Action) -> Result> { 334 | match &action { 335 | Action::ScrollDown => { 336 | self.list_state.select_next(); 337 | Ok(None) 338 | } 339 | Action::ScrollUp => { 340 | self.list_state.select_previous(); 341 | Ok(None) 342 | } 343 | _ => Ok(Some(action)), 344 | } 345 | } 346 | 347 | fn draw( 348 | &mut self, 349 | frame: &mut Frame, 350 | area: Rect, 351 | selected_cve: Option>, 352 | is_selected: bool, 353 | ) -> Result<()> { 354 | if self.selected_cve != selected_cve { 355 | self.selected_cve = selected_cve; 356 | self.list_state.select_first(); 357 | } 358 | 359 | if let Some(selected_cve) = self.selected_cve.as_ref() { 360 | let items: Vec = selected_cve 361 | .instances 362 | .iter() 363 | .flat_map(|instance| { 364 | instance 365 | .affected_configs 366 | .iter() 367 | .map(|affected_config| affected_config.config_name.clone()) 368 | .collect::>() 369 | }) 370 | .collect(); 371 | 372 | let list = List::new(items).highlight_symbol(">>").block( 373 | match is_selected { 374 | true => Block::bordered().style(Style::new().green()), 375 | false => Block::bordered().style(Style::new().white()), 376 | } 377 | .title("Affected Configs"), 378 | ); 379 | frame.render_stateful_widget(list, area, &mut self.list_state); 380 | } 381 | 382 | Ok(()) 383 | } 384 | } 385 | 386 | #[derive(Default)] 387 | struct CveDetailsPane { 388 | scrollbar_state: ScrollbarState, 389 | scroll_position: u16, 390 | selected_cve: Option>, 391 | } 392 | 393 | impl CveDetailsPane { 394 | fn new(selected_cve: Option>) -> Self { 395 | Self { 396 | scrollbar_state: ScrollbarState::default(), 397 | scroll_position: 0, 398 | selected_cve, 399 | } 400 | } 401 | 402 | fn update(&mut self, action: Action) -> Result> { 403 | match &action { 404 | Action::ScrollDown => { 405 | self.scroll_position = self.scroll_position.saturating_add(1); 406 | self.scrollbar_state = self.scrollbar_state.position(self.scroll_position.into()); 407 | Ok(None) 408 | } 409 | Action::ScrollUp => { 410 | self.scroll_position = self.scroll_position.saturating_sub(1); 411 | self.scrollbar_state = self.scrollbar_state.position(self.scroll_position.into()); 412 | Ok(None) 413 | } 414 | _ => Ok(Some(action)), 415 | } 416 | } 417 | 418 | fn draw( 419 | &mut self, 420 | frame: &mut Frame, 421 | area: Rect, 422 | selected_cve: Option>, 423 | is_selected: bool, 424 | ) -> Result<()> { 425 | if self.selected_cve != selected_cve { 426 | self.selected_cve = selected_cve; 427 | self.scroll_position = 0; 428 | self.scrollbar_state = self.scrollbar_state.position(self.scroll_position.into()); 429 | } 430 | 431 | if let Some(cve) = self.selected_cve.as_ref() { 432 | let not_available_text = "N/A".to_string(); 433 | 434 | let severity = if let Some(severity) = cve.severity { 435 | format!("{:.1}", severity) 436 | } else { 437 | not_available_text.clone() 438 | }; 439 | 440 | let items = vec![ 441 | ListItem::new(format!("Severity: {}", severity)), 442 | ListItem::new(format!( 443 | "Attack Vector: {}", 444 | cve.attack_vector.as_ref().unwrap_or(¬_available_text) 445 | )), 446 | ListItem::new(format!( 447 | "Attack Complexity: {}", 448 | cve.attack_complexity 449 | .as_ref() 450 | .unwrap_or(¬_available_text) 451 | )), 452 | ListItem::new(format!( 453 | "Privileges Required: {}", 454 | cve.attack_complexity 455 | .as_ref() 456 | .unwrap_or(¬_available_text) 457 | )), 458 | ListItem::new(format!( 459 | "User Interaction: {}", 460 | cve.user_interaction.as_ref().unwrap_or(¬_available_text) 461 | )), 462 | ListItem::new(format!( 463 | "Scope: {}", 464 | cve.scope.as_ref().unwrap_or(¬_available_text) 465 | )), 466 | ListItem::new(format!( 467 | "Confidentiality Impact: {}", 468 | cve.confidentiality_impact 469 | .as_ref() 470 | .unwrap_or(¬_available_text) 471 | )), 472 | ListItem::new(format!( 473 | "Integrity Impact: {}", 474 | cve.integrity_impact.as_ref().unwrap_or(¬_available_text) 475 | )), 476 | ListItem::new(format!( 477 | "Availability Impact: {}", 478 | cve.availability_impact 479 | .as_ref() 480 | .unwrap_or(¬_available_text) 481 | )), 482 | ]; 483 | 484 | let block = match is_selected { 485 | true => Block::bordered().style(Style::new().green()), 486 | false => Block::bordered().style(Style::new().white()), 487 | } 488 | .title("CVE Details"); 489 | 490 | let inner_area = block.inner(area); 491 | frame.render_widget(block, area); 492 | 493 | let chunks = Layout::default() 494 | .direction(Direction::Vertical) 495 | .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) 496 | .split(inner_area); 497 | 498 | frame.render_widget( 499 | List::new(items).block(match is_selected { 500 | true => Block::bordered().style(Style::new().green()), 501 | false => Block::bordered().style(Style::new().white()), 502 | }), 503 | chunks[0], 504 | ); 505 | 506 | let cve_description = Paragraph::new(self.get_formatted_description(cve.description())) 507 | .wrap(Wrap { trim: true }) 508 | .block( 509 | match is_selected { 510 | true => Block::bordered().style(Style::new().green()), 511 | false => Block::bordered().style(Style::new().white()), 512 | } 513 | .title("Description"), 514 | ) 515 | .scroll((self.scroll_position, 0)); 516 | 517 | // Note: as of ratatui 0.29.0, Paragraph::content_length() is unstable/experimental and 518 | // is giving slightly wrong values, causing bigger scroll areas than necessary. 519 | // Currently this is the best solution as we need to know the width of wrapped text, 520 | // but it should be changed down the line. 521 | self.scrollbar_state = self 522 | .scrollbar_state 523 | .content_length(cve_description.line_count(chunks[1].width)); 524 | 525 | frame.render_widget(cve_description, chunks[1]); 526 | frame.render_stateful_widget( 527 | Scrollbar::new(ScrollbarOrientation::VerticalRight) 528 | .symbols(symbols::scrollbar::VERTICAL), 529 | chunks[1], 530 | &mut self.scrollbar_state, 531 | ); 532 | } 533 | 534 | Ok(()) 535 | } 536 | 537 | fn get_formatted_description(&self, description: &str) -> String { 538 | // Filter out single line breaks (used for email formatting but messes with our display) and replace them with spaces. 539 | // We shouldn't filter out all line breaks as double line breaks are for splitting paragraphs. 540 | // Note: the `regex` crate currently does not support lookaround, which is why this is done manually. 541 | // 542 | // Also replace tabs with spaces as tabs break the formatting in ratatui. 543 | let mut formatted_description = String::new(); 544 | let mut chars = description.chars(); 545 | while let Some(char) = chars.next() { 546 | match char { 547 | '\n' => { 548 | if let Some(next_char) = chars.next() { 549 | match next_char { 550 | '\n' => { 551 | formatted_description.push(char); 552 | formatted_description.push('\n'); 553 | } 554 | '\t' => { 555 | formatted_description.push(' '); 556 | } 557 | _ => { 558 | formatted_description.push(' '); 559 | formatted_description.push(next_char); 560 | } 561 | } 562 | } 563 | } 564 | '\t' => formatted_description.push(' '), 565 | _ => formatted_description.push(char), 566 | } 567 | } 568 | 569 | formatted_description 570 | } 571 | } 572 | --------------------------------------------------------------------------------