Skip to main content

max / mountaineer-sysop

Add sysop wifi -- selection + password + connect First interactive subcommand. List view shows ssid, signal bars, security, known marker. Open networks connect on Enter. PSK networks go to a minimal password prompt (append/backspace, no cursor movement, masked with a black-circle glyph). Esc returns to list. Connect attempt runs after the TUI tears down, with line-oriented result on stdout. Backend: iwctl shell-out + mock fallback for host dev. iwctl text parsing is best-effort and will be revisited against a real install; D-Bus is the upgrade path if needed. 8021x enterprise networks are rendered inactive and Enter is a no-op on them -- needs identity/cert plumbing that's out of MVP scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-18 02:09 UTC
Commit: 0c9fd213eec5e05074064156e0ad802d21404384
Parent: 8bb40cf
3 files changed, +369 insertions, -2 deletions
@@ -4,6 +4,7 @@ mod motd;
4 4 mod roles;
5 5 mod services;
6 6 mod storage;
7 + mod wifi;
7 8
8 9 use clap::{Parser, Subcommand};
9 10
@@ -38,6 +39,8 @@ enum Command {
38 39 #[arg(long)]
39 40 summary: bool,
40 41 },
42 + /// Connect to a wireless network
43 + Wifi,
41 44 }
42 45
43 46 #[derive(Subcommand)]
@@ -62,6 +65,7 @@ fn main() -> anyhow::Result<()> {
62 65 },
63 66 Command::Services { summary } => services::run(summary)?,
64 67 Command::Storage { summary } => storage::run(summary)?,
68 + Command::Wifi => wifi::run()?,
65 69 };
66 70 std::process::exit(code);
67 71 }
@@ -0,0 +1,354 @@
1 + use anyhow::{Context, Result};
2 + use crossterm::event::{self, Event, KeyCode};
3 + use ratatui::Frame;
4 + use ratatui::style::Style;
5 + use ratatui::text::{Line, Span};
6 + use ratatui::widgets::Paragraph;
7 + use std::process::Command;
8 + use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
9 +
10 + const WLAN_DEV: &str = "wlan0";
11 +
12 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
13 + pub enum Security {
14 + Open,
15 + Psk,
16 + Enterprise,
17 + }
18 +
19 + impl Security {
20 + fn parse(s: &str) -> Self {
21 + match s.to_lowercase().as_str() {
22 + "open" => Security::Open,
23 + "psk" => Security::Psk,
24 + "8021x" | "wpa-eap" | "eap" => Security::Enterprise,
25 + _ => Security::Psk,
26 + }
27 + }
28 +
29 + fn label(&self) -> &'static str {
30 + match self {
31 + Security::Open => "open",
32 + Security::Psk => "psk",
33 + Security::Enterprise => "8021x",
34 + }
35 + }
36 + }
37 +
38 + #[derive(Debug, Clone)]
39 + pub struct Network {
40 + pub ssid: String,
41 + pub security: Security,
42 + pub signal_bars: u8,
43 + pub known: bool,
44 + }
45 +
46 + pub trait Backend {
47 + fn list(&self) -> Result<Vec<Network>>;
48 + fn connect(&self, ssid: &str, password: Option<&str>) -> Result<()>;
49 + }
50 +
51 + fn detect() -> Box<dyn Backend> {
52 + if Iwd.probe().is_ok() {
53 + Box::new(Iwd)
54 + } else {
55 + Box::new(Mock)
56 + }
57 + }
58 +
59 + struct Iwd;
60 +
61 + impl Iwd {
62 + fn probe(&self) -> Result<()> {
63 + let out = Command::new("iwctl")
64 + .arg("--version")
65 + .output()
66 + .context("invoke iwctl")?;
67 + if out.status.success() {
68 + Ok(())
69 + } else {
70 + anyhow::bail!("iwctl unresponsive")
71 + }
72 + }
73 + }
74 +
75 + impl Backend for Iwd {
76 + fn list(&self) -> Result<Vec<Network>> {
77 + let out = Command::new("iwctl")
78 + .args(["station", WLAN_DEV, "get-networks"])
79 + .output()
80 + .context("iwctl get-networks")?;
81 + if !out.status.success() {
82 + anyhow::bail!("iwctl get-networks failed");
83 + }
84 + Ok(parse_networks(&String::from_utf8_lossy(&out.stdout)))
85 + }
86 +
87 + fn connect(&self, ssid: &str, password: Option<&str>) -> Result<()> {
88 + let mut cmd = Command::new("iwctl");
89 + if let Some(psk) = password {
90 + cmd.arg(format!("--passphrase={psk}"));
91 + }
92 + cmd.args(["station", WLAN_DEV, "connect", ssid]);
93 + let status = cmd.status().context("iwctl connect")?;
94 + if !status.success() {
95 + anyhow::bail!("iwctl exited {status}");
96 + }
97 + Ok(())
98 + }
99 + }
100 +
101 + fn parse_networks(text: &str) -> Vec<Network> {
102 + let mut out = Vec::new();
103 + for raw in text.lines() {
104 + let line = raw.trim_end();
105 + if line.is_empty()
106 + || line.contains("---")
107 + || line.contains("Network name")
108 + || line.contains("Available networks")
109 + {
110 + continue;
111 + }
112 + let trimmed = line.trim_start();
113 + let (known, rest) = match trimmed.strip_prefix('>') {
114 + Some(after) => (true, after.trim_start()),
115 + None => (false, trimmed),
116 + };
117 + let parts: Vec<&str> = rest.split_whitespace().collect();
118 + if parts.len() < 3 {
119 + continue;
120 + }
121 + let signal_str = parts.last().unwrap();
122 + if !signal_str.chars().all(|c| c == '*') {
123 + continue;
124 + }
125 + let signal_bars = signal_str.chars().count().min(4) as u8;
126 + let security_str = parts[parts.len() - 2];
127 + let ssid = parts[..parts.len() - 2].join(" ");
128 + out.push(Network {
129 + ssid,
130 + security: Security::parse(security_str),
131 + signal_bars,
132 + known,
133 + });
134 + }
135 + out
136 + }
137 +
138 + struct Mock;
139 +
140 + impl Backend for Mock {
141 + fn list(&self) -> Result<Vec<Network>> {
142 + Ok(vec![
143 + net("home-net", Security::Psk, 4, true),
144 + net("guest", Security::Open, 3, false),
145 + net("neighbor", Security::Psk, 2, false),
146 + net("ANNEX-corp", Security::Enterprise, 3, false),
147 + net("weak", Security::Psk, 1, false),
148 + ])
149 + }
150 +
151 + fn connect(&self, _ssid: &str, _password: Option<&str>) -> Result<()> {
152 + Ok(())
153 + }
154 + }
155 +
156 + fn net(ssid: &str, security: Security, signal_bars: u8, known: bool) -> Network {
157 + Network {
158 + ssid: ssid.to_string(),
159 + security,
160 + signal_bars,
161 + known,
162 + }
163 + }
164 +
165 + enum View {
166 + List,
167 + Password { ssid: String, input: String },
168 + }
169 +
170 + enum Outcome {
171 + Quit,
172 + Connect {
173 + ssid: String,
174 + password: Option<String>,
175 + },
176 + }
177 +
178 + pub fn run() -> Result<i32> {
179 + let backend = detect();
180 + let networks = backend.list()?;
181 + if networks.is_empty() {
182 + eprintln!("sysop wifi: no networks visible");
183 + eprintln!(" try: iwctl station {WLAN_DEV} scan");
184 + return Ok(1);
185 + }
186 +
187 + let mut terminal = ratatui::init();
188 + let result = event_loop(&mut terminal, &networks);
189 + ratatui::restore();
190 +
191 + match result? {
192 + Outcome::Quit => Ok(0),
193 + Outcome::Connect { ssid, password } => {
194 + println!("connecting to {ssid}...");
195 + match backend.connect(&ssid, password.as_deref()) {
196 + Ok(()) => {
197 + println!("connected");
198 + Ok(0)
199 + }
200 + Err(e) => {
201 + eprintln!("connect failed: {e}");
202 + Ok(1)
203 + }
204 + }
205 + }
206 + }
207 + }
208 +
209 + fn event_loop(terminal: &mut ratatui::DefaultTerminal, networks: &[Network]) -> Result<Outcome> {
210 + let mut selected: usize = 0;
211 + let mut view = View::List;
212 + loop {
213 + terminal.draw(|frame| draw(frame, networks, selected, &view))?;
214 + let Event::Key(key) = event::read()? else {
215 + continue;
216 + };
217 +
218 + match &mut view {
219 + View::List => match classify(key) {
220 + GlobalAction::Quit => return Ok(Outcome::Quit),
221 + GlobalAction::Passthrough => match key.code {
222 + KeyCode::Char('j') | KeyCode::Down if selected + 1 < networks.len() => {
223 + selected += 1;
224 + }
225 + KeyCode::Char('k') | KeyCode::Up => {
226 + selected = selected.saturating_sub(1);
227 + }
228 + KeyCode::Enter => match networks[selected].security {
229 + Security::Open => {
230 + return Ok(Outcome::Connect {
231 + ssid: networks[selected].ssid.clone(),
232 + password: None,
233 + });
234 + }
235 + Security::Psk => {
236 + view = View::Password {
237 + ssid: networks[selected].ssid.clone(),
238 + input: String::new(),
239 + };
240 + }
241 + Security::Enterprise => {} // unsupported, ignore
242 + },
243 + _ => {}
244 + },
245 + _ => {}
246 + },
247 + View::Password { ssid, input } => match classify(key) {
248 + GlobalAction::Quit => return Ok(Outcome::Quit),
249 + GlobalAction::Back => {
250 + view = View::List;
251 + }
252 + _ => match key.code {
253 + KeyCode::Enter => {
254 + return Ok(Outcome::Connect {
255 + ssid: ssid.clone(),
256 + password: Some(input.clone()),
257 + });
258 + }
259 + KeyCode::Backspace => {
260 + input.pop();
261 + }
262 + KeyCode::Char(c) => {
263 + input.push(c);
264 + }
265 + _ => {}
266 + },
267 + },
268 + }
269 + }
270 + }
271 +
272 + fn draw(frame: &mut Frame, networks: &[Network], selected: usize, view: &View) {
273 + let [body, foot] = layout::split(frame.area());
274 + match view {
275 + View::List => {
276 + frame.render_widget(list_widget(networks, selected), body);
277 + frame.render_widget(
278 + &Footer::new([
279 + hint("j/k", "move"),
280 + hint("Enter", "connect"),
281 + hint("q", "quit"),
282 + ]),
283 + foot,
284 + );
285 + }
286 + View::Password { ssid, input } => {
287 + frame.render_widget(password_widget(ssid, input), body);
288 + frame.render_widget(
289 + &Footer::new([
290 + hint("Enter", "submit"),
291 + hint("Esc", "back"),
292 + hint("q", "quit"),
293 + ]),
294 + foot,
295 + );
296 + }
297 + }
298 + }
299 +
300 + fn list_widget(networks: &[Network], selected: usize) -> Paragraph<'_> {
301 + let name_w = networks.iter().map(|n| n.ssid.len()).max().unwrap_or(0).max(8);
302 + let lines: Vec<Line> = networks
303 + .iter()
304 + .enumerate()
305 + .map(|(i, n)| {
306 + let is_sel = i == selected;
307 + let marker = if is_sel { selection::MARKER } else { " " };
308 + let known_mark = if n.known { "*" } else { " " };
309 + let signal = format!(
310 + "{:<4}",
311 + "▂▄▆█".chars().take(n.signal_bars as usize).collect::<String>()
312 + );
313 + let security_span = match n.security {
314 + Security::Enterprise => style::inactive(n.security.label()),
315 + _ => style::dim(n.security.label()),
316 + };
317 + let line = Line::from(vec![
318 + Span::raw(format!(" {marker} ")),
319 + style::dim(known_mark),
320 + Span::raw(" "),
321 + Span::raw(format!("{:<name_w$}", n.ssid)),
322 + Span::raw(" "),
323 + Span::raw(signal),
324 + Span::raw(" "),
325 + security_span,
326 + ]);
327 + if is_sel {
328 + line.style(selection::selected_style())
329 + } else {
330 + line
331 + }
332 + })
333 + .collect();
334 + Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
335 + }
336 +
337 + fn password_widget<'a>(ssid: &'a str, input: &'a str) -> Paragraph<'a> {
338 + let mask: String = "●".repeat(input.chars().count());
339 + let lines = vec![
340 + Line::from(""),
341 + Line::from(vec![
342 + Span::raw(" "),
343 + style::dim("network "),
344 + style::fg(ssid.to_string()),
345 + ]),
346 + Line::from(vec![
347 + Span::raw(" "),
348 + style::dim("password "),
349 + style::fg(mask),
350 + Span::styled("▏", Style::default().fg(palette::ACCENT)),
351 + ]),
352 + ];
353 + Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
354 + }
M docs/todo.md +11 -2
@@ -1,6 +1,6 @@
1 1 # mountaineer-sysop — todo
2 2
3 - Status: Pre-v0. Active: next subcommand TBD. `sysop info`, `sysop motd`, `sysop services`, `sysop storage` land end-to-end on host (mock backends when the real tool is unreachable). Primitives exercised by `cargo run --example smoketest -p sysop-tui`. Yambar `svc` and `zfs` segments now have producers.
3 + Status: Pre-v0. Active: next subcommand TBD. `sysop info`, `sysop motd`, `sysop services`, `sysop storage`, `sysop wifi` land end-to-end on host (mock backends when the real tool is unreachable). Primitives exercised by `cargo run --example smoketest -p sysop-tui`. Yambar `svc` and `zfs` segments have producers. `sysop wifi` is the first interactive subcommand (password input).
4 4
5 5 ## Phase 1 — sysop-tui primitives (open items)
6 6
@@ -27,6 +27,16 @@ Status: Pre-v0. Active: next subcommand TBD. `sysop info`, `sysop motd`, `sysop
27 27 - [ ] Reflect transient state from `s6-rc-update` runs (currently only Up/Down are derived from `s6-rc -a list` + `-d list`)
28 28 - [ ] Backend mismatch indicator — if mock is active on what looks like a Mountaineer system, surface that as a fault
29 29
30 + ## Phase 6 — `sysop wifi` (open items)
31 +
32 + - [ ] Pre-scan: `iwctl station wlan0 scan` + brief wait before `get-networks`, so the cache is fresh on first invocation
33 + - [ ] `r` to rescan from the list view (calls iwctl scan, waits, refreshes)
34 + - [ ] Auto-detect interface name (currently hardcoded `wlan0`); `iwctl device list` is the source
35 + - [ ] Enterprise (8021x) flow — out of scope for MVP, currently a no-op on Enter; needs identity + cert/PEAP plumbing
36 + - [ ] iwctl `get-networks` text parsing is fragile; verify on a real Mountaineer install and adjust. D-Bus interface is the upgrade path if the text format proves too noisy
37 + - [ ] Disconnect verb (`sysop wifi disconnect`)
38 + - [ ] Promote the password input to a `sysop-tui` primitive once a second consumer exists (probably `sysop id` for passphrase entry)
39 +
30 40 ## Phase 5 — `sysop storage` (open items)
31 41
32 42 - [ ] Detail view: vdev tree, error counters (read/write/cksum), scrub progress. Needs `zpool status -P <name>` parsing
@@ -36,7 +46,6 @@ Status: Pre-v0. Active: next subcommand TBD. `sysop info`, `sysop motd`, `sysop
36 46
37 47 ## Deferred (post-MVP scaffolding)
38 48
39 - - [ ] `sysop wifi` — TUI front for iwctl
40 49 - [ ] `sysop net status` — first-boot route dependency
41 50 - [ ] `sysop logs <service>` — lnav front
42 51 - [ ] `sysop id` — identity subsystem (full design in `mountaineer/todo.md`)