max / mountaineer
1 file changed,
+139 insertions,
-35 deletions
| @@ -4,9 +4,14 @@ use ratatui::style::Style; | |||
| 4 | 4 | use ratatui::text::{Line, Span}; | |
| 5 | 5 | use ratatui::widgets::Paragraph; | |
| 6 | 6 | use serde::Deserialize; | |
| 7 | + | use std::net::{SocketAddr, TcpStream}; | |
| 7 | 8 | use std::process::Command; | |
| 9 | + | use std::time::Duration; | |
| 8 | 10 | use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style}; | |
| 9 | 11 | ||
| 12 | + | const RESOLVER_ADDR: &str = "127.0.0.1:53"; | |
| 13 | + | const RESOLVER_TIMEOUT: Duration = Duration::from_millis(200); | |
| 14 | + | ||
| 10 | 15 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 11 | 16 | pub enum Kind { | |
| 12 | 17 | Wired, | |
| @@ -45,8 +50,27 @@ impl Interface { | |||
| 45 | 50 | } | |
| 46 | 51 | } | |
| 47 | 52 | ||
| 53 | + | #[derive(Debug, Clone)] | |
| 54 | + | pub struct DefaultRoute { | |
| 55 | + | pub gateway: String, | |
| 56 | + | pub dev: String, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 60 | + | pub enum ResolverState { | |
| 61 | + | Reachable, | |
| 62 | + | Unreachable, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | #[derive(Debug, Clone)] | |
| 66 | + | pub struct System { | |
| 67 | + | pub default_route: Option<DefaultRoute>, | |
| 68 | + | pub resolver: ResolverState, | |
| 69 | + | } | |
| 70 | + | ||
| 48 | 71 | pub trait Backend { | |
| 49 | 72 | fn list(&self) -> Result<Vec<Interface>>; | |
| 73 | + | fn default_route(&self) -> Result<Option<DefaultRoute>>; | |
| 50 | 74 | } | |
| 51 | 75 | ||
| 52 | 76 | fn detect() -> Box<dyn Backend> { | |
| @@ -88,6 +112,16 @@ struct IpAddrEntry { | |||
| 88 | 112 | prefixlen: u8, | |
| 89 | 113 | } | |
| 90 | 114 | ||
| 115 | + | #[derive(Deserialize)] | |
| 116 | + | struct IpRoute { | |
| 117 | + | #[serde(default)] | |
| 118 | + | dst: String, | |
| 119 | + | #[serde(default)] | |
| 120 | + | gateway: Option<String>, | |
| 121 | + | #[serde(default)] | |
| 122 | + | dev: Option<String>, | |
| 123 | + | } | |
| 124 | + | ||
| 91 | 125 | impl Backend for Iproute2 { | |
| 92 | 126 | fn list(&self) -> Result<Vec<Interface>> { | |
| 93 | 127 | let out = Command::new("ip") | |
| @@ -123,6 +157,25 @@ impl Backend for Iproute2 { | |||
| 123 | 157 | }) | |
| 124 | 158 | .collect()) | |
| 125 | 159 | } | |
| 160 | + | ||
| 161 | + | fn default_route(&self) -> Result<Option<DefaultRoute>> { | |
| 162 | + | let out = Command::new("ip") | |
| 163 | + | .args(["-json", "route", "show", "default"]) | |
| 164 | + | .output() | |
| 165 | + | .context("ip route show default")?; | |
| 166 | + | if !out.status.success() { | |
| 167 | + | anyhow::bail!("ip route show default failed"); | |
| 168 | + | } | |
| 169 | + | let routes: Vec<IpRoute> = | |
| 170 | + | serde_json::from_slice(&out.stdout).context("parse ip -json route output")?; | |
| 171 | + | Ok(routes | |
| 172 | + | .into_iter() | |
| 173 | + | .find(|r| r.dst == "default") | |
| 174 | + | .and_then(|r| match (r.gateway, r.dev) { | |
| 175 | + | (Some(gw), Some(dev)) => Some(DefaultRoute { gateway: gw, dev }), | |
| 176 | + | _ => None, | |
| 177 | + | })) | |
| 178 | + | } | |
| 126 | 179 | } | |
| 127 | 180 | ||
| 128 | 181 | fn classify_iface(name: &str, flags: &[String]) -> Kind { | |
| @@ -165,18 +218,42 @@ impl Backend for Mock { | |||
| 165 | 218 | }, | |
| 166 | 219 | ]) | |
| 167 | 220 | } | |
| 221 | + | ||
| 222 | + | fn default_route(&self) -> Result<Option<DefaultRoute>> { | |
| 223 | + | Ok(Some(DefaultRoute { | |
| 224 | + | gateway: "192.168.1.1".into(), | |
| 225 | + | dev: "wlan0".into(), | |
| 226 | + | })) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | fn probe_resolver() -> ResolverState { | |
| 231 | + | let addr: SocketAddr = match RESOLVER_ADDR.parse() { | |
| 232 | + | Ok(a) => a, | |
| 233 | + | Err(_) => return ResolverState::Unreachable, | |
| 234 | + | }; | |
| 235 | + | match TcpStream::connect_timeout(&addr, RESOLVER_TIMEOUT) { | |
| 236 | + | Ok(_) => ResolverState::Reachable, | |
| 237 | + | Err(_) => ResolverState::Unreachable, | |
| 238 | + | } | |
| 168 | 239 | } | |
| 169 | 240 | ||
| 170 | 241 | pub fn run(summary: bool) -> Result<i32> { | |
| 171 | - | let interfaces = detect().list()?; | |
| 242 | + | let backend = detect(); | |
| 243 | + | let interfaces = backend.list()?; | |
| 172 | 244 | ||
| 173 | 245 | if summary { | |
| 174 | 246 | println!("{}", summary_line(&interfaces)); | |
| 175 | 247 | return Ok(0); | |
| 176 | 248 | } | |
| 177 | 249 | ||
| 250 | + | let system = System { | |
| 251 | + | default_route: backend.default_route().unwrap_or(None), | |
| 252 | + | resolver: probe_resolver(), | |
| 253 | + | }; | |
| 254 | + | ||
| 178 | 255 | let mut terminal = ratatui::init(); | |
| 179 | - | let result = event_loop(&mut terminal, &interfaces); | |
| 256 | + | let result = event_loop(&mut terminal, &interfaces, &system); | |
| 180 | 257 | ratatui::restore(); | |
| 181 | 258 | result.map(|_| 0) | |
| 182 | 259 | } | |
| @@ -197,13 +274,14 @@ fn summary_line(interfaces: &[Interface]) -> String { | |||
| 197 | 274 | fn event_loop( | |
| 198 | 275 | terminal: &mut ratatui::DefaultTerminal, | |
| 199 | 276 | interfaces: &[Interface], | |
| 277 | + | system: &System, | |
| 200 | 278 | ) -> Result<()> { | |
| 201 | 279 | let mut selected: usize = 0; | |
| 202 | 280 | let footer = Footer::new([hint("j/k", "move"), hint("q", "quit")]); | |
| 203 | 281 | loop { | |
| 204 | 282 | terminal.draw(|frame| { | |
| 205 | 283 | let [body, foot] = layout::split(frame.area()); | |
| 206 | - | frame.render_widget(list_widget(interfaces, selected), body); | |
| 284 | + | frame.render_widget(list_widget(interfaces, selected, system), body); | |
| 207 | 285 | frame.render_widget(&footer, foot); | |
| 208 | 286 | })?; | |
| 209 | 287 | ||
| @@ -226,38 +304,64 @@ fn event_loop( | |||
| 226 | 304 | } | |
| 227 | 305 | } | |
| 228 | 306 | ||
| 229 | - | fn list_widget(interfaces: &[Interface], selected: usize) -> Paragraph<'_> { | |
| 307 | + | fn list_widget<'a>( | |
| 308 | + | interfaces: &'a [Interface], | |
| 309 | + | selected: usize, | |
| 310 | + | system: &'a System, | |
| 311 | + | ) -> Paragraph<'a> { | |
| 230 | 312 | let name_w = interfaces.iter().map(|i| i.name.len()).max().unwrap_or(0).max(4); | |
| 231 | - | let lines: Vec<Line> = interfaces | |
| 232 | - | .iter() | |
| 233 | - | .enumerate() | |
| 234 | - | .map(|(i, iface)| { | |
| 235 | - | let is_sel = i == selected; | |
| 236 | - | let marker = if is_sel { selection::MARKER } else { " " }; | |
| 237 | - | let addrs = if iface.addresses.is_empty() { | |
| 238 | - | String::new() | |
| 239 | - | } else { | |
| 240 | - | iface.addresses.join(", ") | |
| 241 | - | }; | |
| 242 | - | let ssid_seg = match &iface.ssid { | |
| 243 | - | Some(s) => format!(" {s}"), | |
| 244 | - | None => String::new(), | |
| 245 | - | }; | |
| 246 | - | let line = Line::from(vec![ | |
| 247 | - | Span::raw(format!(" {marker} ")), | |
| 248 | - | Span::raw(format!("{:<name_w$}", iface.name)), | |
| 249 | - | Span::raw(" "), | |
| 250 | - | iface.state.span(), | |
| 251 | - | style::fg(ssid_seg), | |
| 252 | - | Span::raw(" "), | |
| 253 | - | style::dim(addrs), | |
| 254 | - | ]); | |
| 255 | - | if is_sel { | |
| 256 | - | line.style(selection::selected_style()) | |
| 257 | - | } else { | |
| 258 | - | line | |
| 259 | - | } | |
| 260 | - | }) | |
| 261 | - | .collect(); | |
| 313 | + | let label_w = 14; | |
| 314 | + | ||
| 315 | + | let gateway_span: Span<'static> = match &system.default_route { | |
| 316 | + | Some(r) => style::fg(format!("{} via {}", r.gateway, r.dev)), | |
| 317 | + | None => style::warn("none"), | |
| 318 | + | }; | |
| 319 | + | let resolver_span: Span<'static> = match system.resolver { | |
| 320 | + | ResolverState::Reachable => style::ok(format!("{RESOLVER_ADDR} reachable")), | |
| 321 | + | ResolverState::Unreachable => style::fault(format!("{RESOLVER_ADDR} unreachable")), | |
| 322 | + | }; | |
| 323 | + | ||
| 324 | + | let mut lines: Vec<Line> = Vec::with_capacity(interfaces.len() + 3); | |
| 325 | + | lines.push(Line::from(vec![ | |
| 326 | + | Span::raw(" "), | |
| 327 | + | style::dim(format!("{:<label_w$}", "default-route")), | |
| 328 | + | gateway_span, | |
| 329 | + | ])); | |
| 330 | + | lines.push(Line::from(vec![ | |
| 331 | + | Span::raw(" "), | |
| 332 | + | style::dim(format!("{:<label_w$}", "resolver")), | |
| 333 | + | resolver_span, | |
| 334 | + | ])); | |
| 335 | + | lines.push(Line::from("")); | |
| 336 | + | ||
| 337 | + | for (i, iface) in interfaces.iter().enumerate() { | |
| 338 | + | let is_sel = i == selected; | |
| 339 | + | let marker = if is_sel { selection::MARKER } else { " " }; | |
| 340 | + | let addrs = if iface.addresses.is_empty() { | |
| 341 | + | String::new() | |
| 342 | + | } else { | |
| 343 | + | iface.addresses.join(", ") | |
| 344 | + | }; | |
| 345 | + | let ssid_seg = match &iface.ssid { | |
| 346 | + | Some(s) => format!(" {s}"), | |
| 347 | + | None => String::new(), | |
| 348 | + | }; | |
| 349 | + | let line = Line::from(vec![ | |
| 350 | + | Span::raw(format!(" {marker} ")), | |
| 351 | + | Span::raw(format!("{:<name_w$}", iface.name)), | |
| 352 | + | Span::raw(" "), | |
| 353 | + | iface.state.span(), | |
| 354 | + | style::fg(ssid_seg), | |
| 355 | + | Span::raw(" "), | |
| 356 | + | style::dim(addrs), | |
| 357 | + | ]); | |
| 358 | + | let line = if is_sel { | |
| 359 | + | line.style(selection::selected_style()) | |
| 360 | + | } else { | |
| 361 | + | line | |
| 362 | + | }; | |
| 363 | + | lines.push(line); | |
| 364 | + | } | |
| 365 | + | ||
| 262 | 366 | Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG)) | |
| 263 | 367 | } |