Skip to main content

max / mountaineer

sysop net: default-route + resolver liveness Header rows above the interface list show: default-route <gw> via <dev> (or "none" warn) resolver 127.0.0.1:53 reachable/unreachable Iproute2 backend gains `default_route()` via `ip -json route show default`. Resolver probe is a 200ms TCP connect to 127.0.0.1:53. Mock supplies a plausible default route so the host-dev view shows the same shape.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-19 21:02 UTC
Commit: 54b1f0f872958eaff95432cde56c7b3b6fb4e608
Parent: ac50b7f
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 }