From 428738d7c4d042cbccb21ad4954dbf47401403f4 Mon Sep 17 00:00:00 2001 From: Matisse Callewaert Date: Wed, 23 Oct 2024 10:51:43 +0200 Subject: [PATCH 1/2] :art: Format --- rustiflow/src/flows/basic_flow.rs | 18 +++++--- rustiflow/src/flows/custom_flow.rs | 17 ++++---- rustiflow/src/flows/nf_flow.rs | 13 ++++-- rustiflow/src/main.rs | 6 +-- rustiflow/src/packet_features.rs | 28 +++++++------ rustiflow/src/pcap.rs | 49 ++++++++++++++++++---- rustiflow/src/realtime.rs | 66 +++++++++++++++++++++--------- 7 files changed, 137 insertions(+), 60 deletions(-) diff --git a/rustiflow/src/flows/basic_flow.rs b/rustiflow/src/flows/basic_flow.rs index 847b65f4..eb0dd909 100644 --- a/rustiflow/src/flows/basic_flow.rs +++ b/rustiflow/src/flows/basic_flow.rs @@ -10,7 +10,7 @@ use super::flow::Flow; enum FlowState { Established, FinSent, - FinAcked + FinAcked, } /// A basic flow that stores the basic features of a flow. @@ -95,16 +95,24 @@ impl BasicFlow { if packet.fin_flag > 0 { if forward { self.state_fwd = FlowState::FinSent; - self.expected_ack_seq_bwd = Some(packet.sequence_number + packet.data_length as u32 + 1); + self.expected_ack_seq_bwd = + Some(packet.sequence_number + packet.data_length as u32 + 1); } else { self.state_bwd = FlowState::FinSent; - self.expected_ack_seq_fwd = Some(packet.sequence_number + packet.data_length as u32 + 1); + self.expected_ack_seq_fwd = + Some(packet.sequence_number + packet.data_length as u32 + 1); } } - if self.state_bwd == FlowState::FinSent && forward && Some(packet.sequence_number_ack) == self.expected_ack_seq_fwd { + if self.state_bwd == FlowState::FinSent + && forward + && Some(packet.sequence_number_ack) == self.expected_ack_seq_fwd + { self.state_bwd = FlowState::FinAcked; - } else if self.state_fwd == FlowState::FinSent && !forward && Some(packet.sequence_number_ack) == self.expected_ack_seq_bwd { + } else if self.state_fwd == FlowState::FinSent + && !forward + && Some(packet.sequence_number_ack) == self.expected_ack_seq_bwd + { self.state_fwd = FlowState::FinAcked; } diff --git a/rustiflow/src/flows/custom_flow.rs b/rustiflow/src/flows/custom_flow.rs index 8e8f00be..e7b669d0 100644 --- a/rustiflow/src/flows/custom_flow.rs +++ b/rustiflow/src/flows/custom_flow.rs @@ -8,7 +8,7 @@ use super::{basic_flow::BasicFlow, flow::Flow}; /// Represents a Custom Flow, encapsulating various metrics and states of a network flow. /// /// As an example, this flow has one feature that represents the sum of the inter arrival times of the first 10 packets for both egress and ingress direction. -/// +/// /// This struct is made so you can define your own features. #[derive(Clone)] pub struct CustomFlow { @@ -23,11 +23,11 @@ impl CustomFlow { fn update_inter_arrival_time_total(&mut self, packet: &PacketFeatures) { if (self.basic_flow.fwd_packet_count + self.basic_flow.bwd_packet_count) > 10 { let iat = packet - .timestamp - .signed_duration_since(self.basic_flow.last_timestamp) - .num_nanoseconds() - .unwrap() as f64 - / 1000.0; + .timestamp + .signed_duration_since(self.basic_flow.last_timestamp) + .num_nanoseconds() + .unwrap() as f64 + / 1000.0; self.inter_arrival_time_total += iat; } @@ -74,7 +74,10 @@ impl Flow for CustomFlow { fn dump(&self) -> String { // Add here the dump of the custom flow. - format!("{},{}", self.basic_flow.flow_key, self.inter_arrival_time_total) + format!( + "{},{}", + self.basic_flow.flow_key, self.inter_arrival_time_total + ) } fn get_features() -> String { diff --git a/rustiflow/src/flows/nf_flow.rs b/rustiflow/src/flows/nf_flow.rs index c3afed3f..1e42ab34 100644 --- a/rustiflow/src/flows/nf_flow.rs +++ b/rustiflow/src/flows/nf_flow.rs @@ -26,7 +26,10 @@ impl NfFlow { return 0; } - (self.bwd_last_timestamp.unwrap().signed_duration_since(self.bwd_first_timestamp.unwrap())) + (self + .bwd_last_timestamp + .unwrap() + .signed_duration_since(self.bwd_first_timestamp.unwrap())) .num_milliseconds() } @@ -185,10 +188,14 @@ impl Flow for NfFlow { {},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},\ {},{},{},{},{},{},{},{},{},{},{},{},{}", self.cic_flow.basic_flow.protocol, - self.last_timestamp.signed_duration_since(self.first_timestamp).num_milliseconds(), + self.last_timestamp + .signed_duration_since(self.first_timestamp) + .num_milliseconds(), self.cic_flow.basic_flow.fwd_packet_count + self.cic_flow.basic_flow.bwd_packet_count, self.cic_flow.fwd_pkt_len_tot + self.cic_flow.bwd_pkt_len_tot, - self.fwd_last_timestamp.signed_duration_since(self.fwd_first_timestamp).num_milliseconds(), + self.fwd_last_timestamp + .signed_duration_since(self.fwd_first_timestamp) + .num_milliseconds(), self.cic_flow.basic_flow.fwd_packet_count, self.cic_flow.fwd_pkt_len_tot, self.get_bwd_duration(), diff --git a/rustiflow/src/main.rs b/rustiflow/src/main.rs index b8c9cc53..a07914d8 100644 --- a/rustiflow/src/main.rs +++ b/rustiflow/src/main.rs @@ -118,7 +118,7 @@ async fn run_with_config(config: Config) { }); debug!("OutputWriter task finished"); }); - + debug!("Starting realtime processing..."); let start = Instant::now(); let result = handle_realtime::<$flow_ty>( @@ -143,7 +143,7 @@ async fn run_with_config(config: Config) { "Duration: {:.4} seconds", end.duration_since(start).as_secs_f64() ); - + // Now process the result and print the dropped packets match result { Ok(dropped_packets) => { @@ -238,4 +238,4 @@ async fn run_with_config(config: Config) { } } } -} \ No newline at end of file +} diff --git a/rustiflow/src/packet_features.rs b/rustiflow/src/packet_features.rs index 08f1d9ae..5c64c562 100644 --- a/rustiflow/src/packet_features.rs +++ b/rustiflow/src/packet_features.rs @@ -3,7 +3,15 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use chrono::{DateTime, Utc}; use common::{EbpfEventIpv4, EbpfEventIpv6}; use log::debug; -use pnet::packet::{icmp::IcmpPacket, ip::{IpNextHeaderProtocol, IpNextHeaderProtocols}, ipv4::Ipv4Packet, ipv6::Ipv6Packet, tcp::TcpPacket, udp::UdpPacket, Packet}; +use pnet::packet::{ + icmp::IcmpPacket, + ip::{IpNextHeaderProtocol, IpNextHeaderProtocols}, + ipv4::Ipv4Packet, + ipv6::Ipv6Packet, + tcp::TcpPacket, + udp::UdpPacket, + Packet, +}; // Define TCP flags const FIN_FLAG: u8 = 0b00000001; @@ -92,10 +100,7 @@ impl PacketFeatures { } // Constructor to create PacketFeatures from an IPv4 packet - pub fn from_ipv4_packet( - packet: &Ipv4Packet, - timestamp: DateTime, - ) -> Option { + pub fn from_ipv4_packet(packet: &Ipv4Packet, timestamp: DateTime) -> Option { extract_packet_features_transport( packet.get_source().into(), packet.get_destination().into(), @@ -107,10 +112,7 @@ impl PacketFeatures { } // Constructor to create PacketFeatures from an IPv6 packet - pub fn from_ipv6_packet( - packet: &Ipv6Packet, - timestamp: DateTime, - ) -> Option { + pub fn from_ipv6_packet(packet: &Ipv6Packet, timestamp: DateTime) -> Option { extract_packet_features_transport( packet.get_source().into(), packet.get_destination().into(), @@ -233,8 +235,8 @@ fn extract_packet_features_transport( data_length: udp_packet.payload().len() as u16, header_length: 8, // Fixed header size for UDP length: total_length, - window_size: 0, // No window size for UDP - sequence_number: 0, // No sequence number for UDP + window_size: 0, // No window size for UDP + sequence_number: 0, // No sequence number for UDP sequence_number_ack: 0, // No sequence number ACK for UDP }) } @@ -258,8 +260,8 @@ fn extract_packet_features_transport( data_length: icmp_packet.payload().len() as u16, header_length: 8, // Fixed header size for ICMP length: total_length, - window_size: 0, // No window size for ICMP - sequence_number: 0, // No sequence number for ICMP + window_size: 0, // No window size for ICMP + sequence_number: 0, // No sequence number for ICMP sequence_number_ack: 0, // No sequence number ACK for ICMP }) } diff --git a/rustiflow/src/pcap.rs b/rustiflow/src/pcap.rs index 70abe217..abb0c8ff 100644 --- a/rustiflow/src/pcap.rs +++ b/rustiflow/src/pcap.rs @@ -5,7 +5,10 @@ use crate::{flow_table::FlowTable, packet_features::PacketFeatures}; use chrono::{DateTime, Utc}; use log::{debug, error}; use pnet::packet::{ - ethernet::{EtherTypes, EthernetPacket}, ipv4::Ipv4Packet, ipv6::Ipv6Packet, Packet + ethernet::{EtherTypes, EthernetPacket}, + ipv4::Ipv4Packet, + ipv6::Ipv6Packet, + Packet, }; use tokio::sync::mpsc; use tokio::sync::mpsc::Sender; @@ -61,26 +64,54 @@ where match ethernet.get_ethertype() { EtherTypes::Ipv4 => { if let Some(packet) = Ipv4Packet::new(ethernet.payload()) { - process_packet::(&packet, timestamp, &shard_senders, num_threads, PacketFeatures::from_ipv4_packet).await; - } - }, + process_packet::( + &packet, + timestamp, + &shard_senders, + num_threads, + PacketFeatures::from_ipv4_packet, + ) + .await; + } + } EtherTypes::Ipv6 => { if let Some(packet) = Ipv6Packet::new(ethernet.payload()) { - process_packet::(&packet, timestamp, &shard_senders, num_threads, PacketFeatures::from_ipv6_packet).await; - } - }, + process_packet::( + &packet, + timestamp, + &shard_senders, + num_threads, + PacketFeatures::from_ipv6_packet, + ) + .await; + } + } _ => { // Check if it is a Linux cooked capture let ethertype = u16::from_be_bytes([packet.data[14], packet.data[15]]); match ethertype { SLL_IPV4 => { if let Some(packet) = Ipv4Packet::new(&packet.data[16..]) { - process_packet::(&packet, timestamp, &shard_senders, num_threads, PacketFeatures::from_ipv4_packet).await; + process_packet::( + &packet, + timestamp, + &shard_senders, + num_threads, + PacketFeatures::from_ipv4_packet, + ) + .await; } } SLL_IPV6 => { if let Some(packet) = Ipv6Packet::new(&packet.data[16..]) { - process_packet::(&packet, timestamp, &shard_senders, num_threads, PacketFeatures::from_ipv6_packet).await; + process_packet::( + &packet, + timestamp, + &shard_senders, + num_threads, + PacketFeatures::from_ipv6_packet, + ) + .await; } } _ => debug!("Failed to parse packet as IPv4 or IPv6..."), diff --git a/rustiflow/src/realtime.rs b/rustiflow/src/realtime.rs index db3e5175..54f4d612 100644 --- a/rustiflow/src/realtime.rs +++ b/rustiflow/src/realtime.rs @@ -5,14 +5,19 @@ use crate::{flow_table::FlowTable, flows::flow::Flow, packet_features::PacketFea use aya::maps::PerCpuValues; use aya::{ include_bytes_aligned, - maps::{RingBuf, PerCpuArray}, + maps::{PerCpuArray, RingBuf}, programs::{tc, SchedClassifier, TcAttachType}, Ebpf, }; use aya_log::EbpfLogger; use common::{EbpfEventIpv4, EbpfEventIpv6}; use log::{error, info}; -use tokio::{io::unix::AsyncFd, signal, sync::mpsc::{self, Sender}, task::JoinSet}; +use tokio::{ + io::unix::AsyncFd, + signal, + sync::mpsc::{self, Sender}, + task::JoinSet, +}; /// Starts the realtime processing of packets on the given interface. /// The function will return the number of packets dropped by the eBPF program. @@ -36,9 +41,11 @@ where let mut bpf_ingress_ipv4 = load_ebpf_ipv4(interface, TcAttachType::Ingress)?; let mut bpf_ingress_ipv6 = load_ebpf_ipv6(interface, TcAttachType::Ingress)?; let events_ingress_ipv4 = RingBuf::try_from(bpf_ingress_ipv4.take_map("EVENTS_IPV4").unwrap())?; - let dropped_packets_ingress_ipv4 = PerCpuArray::try_from(bpf_ingress_ipv4.take_map("DROPPED_PACKETS").unwrap())?; + let dropped_packets_ingress_ipv4 = + PerCpuArray::try_from(bpf_ingress_ipv4.take_map("DROPPED_PACKETS").unwrap())?; let events_ingress_ipv6 = RingBuf::try_from(bpf_ingress_ipv6.take_map("EVENTS_IPV6").unwrap())?; - let dropped_packets_ingress_ipv6 = PerCpuArray::try_from(bpf_ingress_ipv6.take_map("DROPPED_PACKETS").unwrap())?; + let dropped_packets_ingress_ipv6 = + PerCpuArray::try_from(bpf_ingress_ipv6.take_map("DROPPED_PACKETS").unwrap())?; let event_sources_v4; let event_sources_v6; let dropped_packet_counters; @@ -46,13 +53,22 @@ where if !ingress_only { let mut bpf_egress_ipv4 = load_ebpf_ipv4(interface, TcAttachType::Egress)?; let mut bpf_egress_ipv6 = load_ebpf_ipv6(interface, TcAttachType::Egress)?; - let events_egress_ipv4 = RingBuf::try_from(bpf_egress_ipv4.take_map("EVENTS_IPV4").unwrap())?; - let dropped_packets_egress_ipv4 = PerCpuArray::try_from(bpf_egress_ipv4.take_map("DROPPED_PACKETS").unwrap())?; - let events_egress_ipv6 = RingBuf::try_from(bpf_egress_ipv6.take_map("EVENTS_IPV6").unwrap())?; - let dropped_packets_egress_ipv6 = PerCpuArray::try_from(bpf_egress_ipv6.take_map("DROPPED_PACKETS").unwrap())?; + let events_egress_ipv4 = + RingBuf::try_from(bpf_egress_ipv4.take_map("EVENTS_IPV4").unwrap())?; + let dropped_packets_egress_ipv4 = + PerCpuArray::try_from(bpf_egress_ipv4.take_map("DROPPED_PACKETS").unwrap())?; + let events_egress_ipv6 = + RingBuf::try_from(bpf_egress_ipv6.take_map("EVENTS_IPV6").unwrap())?; + let dropped_packets_egress_ipv6 = + PerCpuArray::try_from(bpf_egress_ipv6.take_map("DROPPED_PACKETS").unwrap())?; event_sources_v4 = vec![events_egress_ipv4, events_ingress_ipv4]; event_sources_v6 = vec![events_egress_ipv6, events_ingress_ipv6]; - dropped_packet_counters = vec![dropped_packets_egress_ipv4, dropped_packets_ingress_ipv4, dropped_packets_egress_ipv6, dropped_packets_ingress_ipv6]; + dropped_packet_counters = vec![ + dropped_packets_egress_ipv4, + dropped_packets_ingress_ipv4, + dropped_packets_egress_ipv6, + dropped_packets_ingress_ipv6, + ]; } else { event_sources_v4 = vec![events_ingress_ipv4]; event_sources_v6 = vec![events_ingress_ipv6]; @@ -101,13 +117,17 @@ where let ring_buf = guard.get_inner_mut(); while let Some(event) = ring_buf.next() { - let ebpf_event_ipv4: EbpfEventIpv4 = unsafe { std::ptr::read(event.as_ptr() as *const _) }; + let ebpf_event_ipv4: EbpfEventIpv4 = + unsafe { std::ptr::read(event.as_ptr() as *const _) }; let packet_features = PacketFeatures::from_ebpf_event_ipv4(&ebpf_event_ipv4); let flow_key = packet_features.biflow_key(); let shard_index = compute_shard_index(&flow_key, num_threads); if let Err(e) = shard_senders_clone[shard_index].send(packet_features).await { - error!("Failed to send packet_features to shard {}: {}", shard_index, e); + error!( + "Failed to send packet_features to shard {}: {}", + shard_index, e + ); } } @@ -119,7 +139,7 @@ where for ebpf_event_source in event_sources_v6 { let shard_senders_clone = shard_senders.clone(); - + handle_set.spawn(async move { // Wrap the RingBuf in AsyncFd to poll it with tokio let mut async_ring_buf = AsyncFd::new(ebpf_event_source).unwrap(); @@ -130,13 +150,17 @@ where let ring_buf = guard.get_inner_mut(); while let Some(event) = ring_buf.next() { - let ebpf_event_ipv6: EbpfEventIpv6 = unsafe { std::ptr::read(event.as_ptr() as *const _) }; + let ebpf_event_ipv6: EbpfEventIpv6 = + unsafe { std::ptr::read(event.as_ptr() as *const _) }; let packet_features = PacketFeatures::from_ebpf_event_ipv6(&ebpf_event_ipv6); let flow_key = packet_features.biflow_key(); let shard_index = compute_shard_index(&flow_key, num_threads); if let Err(e) = shard_senders_clone[shard_index].send(packet_features).await { - error!("Failed to send packet_features to shard {}: {}", shard_index, e); + error!( + "Failed to send packet_features to shard {}: {}", + shard_index, e + ); } } @@ -218,18 +242,20 @@ fn load_ebpf_ipv4(interface: &str, tc_attach_type: TcAttachType) -> Result Date: Wed, 23 Oct 2024 10:52:33 +0200 Subject: [PATCH 2/2] :rocket: Load config file into tui --- rustiflow/src/tui.rs | 108 +++++++++++++++++++++++++++---------------- 1 file changed, 67 insertions(+), 41 deletions(-) diff --git a/rustiflow/src/tui.rs b/rustiflow/src/tui.rs index ad5043f7..66ee47da 100644 --- a/rustiflow/src/tui.rs +++ b/rustiflow/src/tui.rs @@ -1,22 +1,24 @@ +use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; use log::error; use serde::{Deserialize, Serialize}; -use strum::VariantNames; -use tui::text::{Span, Spans, Text}; use std::error::Error; use std::io; +use strum::VariantNames; use tui::backend::{Backend, CrosstermBackend}; use tui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use tui::style::{Color, Modifier, Style}; +use tui::text::{Span, Spans, Text}; use tui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; use tui::{Frame, Terminal}; -use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent}; -use crossterm::execute; -use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, -}; use crate::args::{Commands, ConfigFile, ExportConfig, ExportMethodType, FlowType, OutputConfig}; +const CONFIG_FILE_NAME: &str = "rustiflow.toml"; + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Config { pub config: ExportConfig, @@ -24,8 +26,8 @@ pub struct Config { pub command: Commands, } -impl Default for Config { - fn default() -> Self { +impl Config { + fn reset() -> Self { Config { config: ExportConfig { features: FlowType::Basic, @@ -49,6 +51,44 @@ impl Default for Config { } } +impl Default for Config { + fn default() -> Self { + let config: ConfigFile = match confy::load_path(CONFIG_FILE_NAME) { + Ok(config) => config, + Err(_) => { + return Config { + config: ExportConfig { + features: FlowType::Basic, + active_timeout: 3600, + idle_timeout: 120, + early_export: None, + threads: None, + expiration_check_interval: 60, + }, + output: OutputConfig { + output: ExportMethodType::Print, + export_path: None, + header: false, + drop_contaminant_features: false, + }, + command: Commands::Realtime { + interface: String::from("eth0"), + ingress_only: false, + }, + }; + } + }; + Config { + config: config.config, + output: config.output, + command: Commands::Realtime { + interface: String::from("eth0"), + ingress_only: false, + }, + } + } +} + pub async fn launch_tui() -> Result, Box> { // setup terminal enable_raw_mode()?; @@ -172,7 +212,7 @@ async fn run_app( if let Some(config) = handle_title_bar_input(key, app)? { return Ok(Some(config)); } - }, + } AppFocus::Menu => handle_menu_input(key, app)?, AppFocus::FlowType => handle_flow_type_input(key, app)?, AppFocus::ActiveTimeoutInput @@ -230,13 +270,13 @@ fn handle_title_bar_input(key: KeyEvent, app: &mut App) -> Result output: app.config.output.clone(), }; - if let Err(e) = confy::store_path("rustiflow.toml", config_file) { + if let Err(e) = confy::store_path(CONFIG_FILE_NAME, config_file) { error!("Error saving configuration file: {:?}", e); } } Some(3) => { // Reset config - app.config = Config::default(); + app.config = Config::reset(); } _ => {} }, @@ -589,25 +629,13 @@ fn ui_main_screen(f: &mut Frame, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length(3), - Constraint::Min(0), - ] - .as_ref(), - ) + .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) .split(size); // Title Bar let title_bar_layout = Layout::default() .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Percentage(50), - Constraint::Percentage(50), - ] - .as_ref(), - ) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) .split(chunks[0]); let rustiflow_art = " @@ -946,9 +974,7 @@ fn render_current_selections(f: &mut Frame, app: &App, area: Rect ), ])), ListItem::new(Text::from(vec![ - Spans::from(vec![ - Span::raw("Expiration Check"), - ]), + Spans::from(vec![Span::raw("Expiration Check")]), Spans::from(vec![ Span::raw("Interval: "), Span::styled( @@ -979,9 +1005,7 @@ fn render_current_selections(f: &mut Frame, app: &App, area: Rect ), ])), ListItem::new(Text::from(vec![ - Spans::from(vec![ - Span::raw("Drop Contaminant"), - ]), + Spans::from(vec![Span::raw("Drop Contaminant")]), Spans::from(vec![ Span::raw("Features: "), Span::styled( @@ -994,7 +1018,10 @@ fn render_current_selections(f: &mut Frame, app: &App, area: Rect // Build the Mode entry dynamically let mode_item = match &app.config.command { - Commands::Realtime { interface, ingress_only } => { + Commands::Realtime { + interface, + ingress_only, + } => { let mut text = Text::from(Spans::from(vec![ Span::raw("Mode: "), Span::styled("Realtime", Style::default().fg(Color::Yellow)), @@ -1063,22 +1090,21 @@ fn render_popups(f: &mut Frame, app: &App, size: Rect) { Commands::Realtime { ingress_only, .. } => *ingress_only, _ => false, }; - + let popup_area = centered_rect(50, 25, size); f.render_widget(Clear, popup_area); - + let boolean_input_block = Block::default() - .title("Ingress Only?") - .borders(Borders::ALL) - .style(Style::default().bg(Color::Black)) - .border_style(Style::default().fg(Color::Yellow)); + .title("Ingress Only?") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)) + .border_style(Style::default().fg(Color::Yellow)); let inner_area = boolean_input_block.inner(popup_area); f.render_widget(boolean_input_block, popup_area); render_boolean_choice(f, inner_area, is_true_selected); - } } @@ -1126,4 +1152,4 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { .as_ref(), ) .split(popup_layout[1])[1] -} \ No newline at end of file +}