Skip to main content

max / alloy

Join a wifi network from the console, without putting the passphrase in argv alloy net could connect a device to a network NetworkManager already knew and nothing else, so joining a new one meant leaving the console. Three modes now, walked back to front by Esc: the devices, the networks in range, the passphrase for the one chosen. The passphrase never becomes an argument. `nmcli device wifi connect SSID password PW` is the form everyone writes and it shows the passphrase in `ps` to every user on the machine; `--ask` makes nmcli prompt instead, and a prompt reads stdin, which Invocation::stdin already carries privately. Measured on nmcli 1.46 that a piped answer is consumed, since "it prompts" and "it reads a pipe" are different claims. Mode's Debug is hand-written and redacts for the same reason Secret's does. The scan parser undoes nmcli's terse-tabular escaping, which the device parser beside it deliberately does not: multiline output escapes nothing, tabular output writes a colon inside an SSID as \:, and splitting naively turns `Cafe: Free Wifi` into a network called Cafe. Joining is settings.modify.system, which 50-alloy-settings.rules deliberately does not grant, and tier 2 cannot reach it: pkttyagent inherits stdio, so a suspend has no pipe to carry the passphrase on. A secret and a suspend cannot both be had. The failure says so rather than suspending into a command that would arrive without its passphrase. Tier 3 is next.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-16 00:27 UTC
Signed with PGP, not checked
Commit: 55d4491b8e0572ef743f2ec2c6bb3cd089c4c566
Parent: f357bf5
1 file changed, +414 insertions, -13 deletions
@@ -8,7 +8,7 @@
8 8
9 9 use alloy_tui::keys::Action;
10 10 use alloy_tui::{
11 - AlloyBlock, AlloyList, Cursor, Hint, KeyGroup, Severity, Theme, binding, hint, text,
11 + AlloyBlock, AlloyList, Cursor, Hint, KeyGroup, Severity, TextField, Theme, binding, hint, text,
12 12 unavailable,
13 13 };
14 14 use anyhow::Result;
@@ -17,7 +17,7 @@
17 17 use ratatui::layout::Rect;
18 18 use ratatui::text::{Line, Span};
19 19
20 - use crate::cli::{CommandLog, Invocation};
20 + use crate::cli::{CommandLog, Invocation, Secret};
21 21 use crate::shell::{Flow, View, block_title};
22 22
23 23 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -106,6 +106,29 @@
106 106 pub addresses: Vec<String>,
107 107 }
108 108
109 + /// A wireless network in range.
110 + ///
111 + /// Distinct from [`Interface`], and the distinction is the whole join flow: an
112 + /// interface is a device this machine has, a network is something in the air
113 + /// near it. Everything the screen did before this was about the first.
114 + #[derive(Debug, Clone, PartialEq, Eq)]
115 + pub(crate) struct Network {
116 + pub ssid: String,
117 + /// NM's 0-100 signal figure. Kept as the number rather than the bars,
118 + /// because the bars are a rendering of it and this is the sort key.
119 + pub signal: u8,
120 + /// NM's security column verbatim (`WPA2`, `WPA3`, `WPA1 WPA2`, `802.1X`),
121 + /// or `None` for an open network.
122 + ///
123 + /// Verbatim rather than parsed into an enum: the console's only decision is
124 + /// whether to ask for a passphrase, which is `is_some`, and NM's vocabulary
125 + /// here grows with every new standard. Showing what NM said keeps the row
126 + /// honest about a network the console does not have a word for.
127 + pub security: Option<String>,
128 + /// Whether this is the network the machine is already on.
129 + pub in_use: bool,
130 + }
131 +
109 132 /// A source of interface state, and the actions on it.
110 133 ///
111 134 /// The actions are `Option<Invocation>` for the same reason `alloy pkg`'s
@@ -144,6 +167,27 @@
144 167 fn wifi_enabled(&self, _log: &mut CommandLog) -> Option<bool> {
145 168 None
146 169 }
170 +
171 + /// The networks in range.
172 + ///
173 + /// Three answers rather than two, and the middle one is why this is not a
174 + /// plain `Result`. `None` is a backend that cannot scan at all, which is
175 + /// the mock and is a fact about the backend; `Some(Err)` is a backend that
176 + /// tried and failed, which is a fact about this moment. The view offers the
177 + /// key on the first and reports the message on the second.
178 + fn networks(&self, _log: &mut CommandLog) -> Option<Result<Vec<Network>>> {
179 + None
180 + }
181 +
182 + /// Join a network by name, with a passphrase for a secured one.
183 + ///
184 + /// The passphrase is moved in rather than borrowed: it ends up inside the
185 + /// [`Invocation`], which owns its [`Secret`] and scrubs it on drop. A
186 + /// borrow would leave the caller holding the only copy and no reason to
187 + /// think it mattered.
188 + fn join(&self, _ssid: &str, _passphrase: Option<Secret>) -> Option<Invocation> {
189 + None
190 + }
147 191 }
148 192
149 193 /// Pick a backend: the real one when `nmcli` answers, the mock otherwise.
@@ -209,6 +253,58 @@
209 253 .ok()?;
210 254 parse_radio(&raw)
211 255 }
256 +
257 + // `--rescan yes` and not the default. Without it nmcli answers out of NM's
258 + // cache, which on a device that has been sitting disconnected is empty or
259 + // minutes stale, and a scan screen that shows what was in the air a while
260 + // ago is worse than one that takes a moment. The cost is the moment: the
261 + // command blocks while the radio sweeps.
262 + fn networks(&self, log: &mut CommandLog) -> Option<Result<Vec<Network>>> {
263 + Some(
264 + Invocation::new("nmcli")
265 + .args([
266 + "-t",
267 + "-f",
268 + "IN-USE,SSID,SIGNAL,SECURITY",
269 + "device",
270 + "wifi",
271 + "list",
272 + "--rescan",
273 + "yes",
274 + ])
275 + .run(log)
276 + .map(|raw| parse_wifi_list(&raw)),
277 + )
278 + }
279 +
280 + /// `nmcli --ask device wifi connect <ssid>`, with the passphrase piped.
281 + ///
282 + /// **The passphrase does not go in argv, and that is the whole reason for
283 + /// `--ask`.** `nmcli device wifi connect SSID password PW` is the form
284 + /// everyone writes, and it puts the passphrase where `ps` shows it to every
285 + /// user on the machine for as long as the command runs. `--ask` makes nmcli
286 + /// prompt for the secret instead, and a prompt reads stdin, which
287 + /// [`Invocation::stdin`] already carries privately. Same reasoning as
288 + /// `chpasswd` in the installer, and the same mechanism.
289 + ///
290 + /// Measured on nmcli 1.46 rather than assumed, because "it prompts" and "it
291 + /// reads a pipe" are different claims and only the second one is any use
292 + /// here: fed a pipe, `--ask` consumes it and carries on, so a console with
293 + /// no terminal to prompt on can still answer.
294 + ///
295 + /// `--ask` is passed for an open network too. Nothing is prompted there and
296 + /// the empty pipe is never read, so the cost is nothing; what it buys is
297 + /// that a network the console read as open but which actually wants a
298 + /// secret asks for one, rather than failing with the sort of message that
299 + /// sends someone to a search engine.
300 + fn join(&self, ssid: &str, passphrase: Option<Secret>) -> Option<Invocation> {
301 + let invocation =
302 + Invocation::new("nmcli").args(["--ask", "device", "wifi", "connect", ssid]);
303 + Some(match passphrase {
304 + Some(secret) => invocation.stdin(secret),
305 + None => invocation,
306 + })
307 + }
212 308 }
213 309
214 310 /// Whether connect and disconnect mean anything for a device.
@@ -234,6 +330,93 @@
234 330 }
235 331 }
236 332
333 + /// Parse `nmcli -t -f IN-USE,SSID,SIGNAL,SECURITY device wifi list`.
334 + ///
335 + /// **This is terse *tabular* output, which is a different format from the
336 + /// multiline output [`parse_device_show`] reads, in the one way that matters.**
337 + /// Tabular fields are colon-separated, so a colon inside a value has to be
338 + /// escaped, and nmcli escapes it as `\:` (and a literal backslash as `\\`).
339 + /// Multiline output has no such problem and escapes nothing, which is why the
340 + /// other parser takes its values verbatim and this one cannot. An SSID is
341 + /// arbitrary bytes chosen by whoever runs the access point, so a colon in one
342 + /// is not a curiosity: split naively and `Cafe: Free Wifi` becomes a network
343 + /// called `Cafe` with a signal of ` Free Wifi`.
344 + ///
345 + /// Hidden networks come back with an empty SSID and are dropped. There is no
346 + /// name to show and `device wifi connect` takes a name, so a blank row would be
347 + /// a row that cannot be acted on.
348 + ///
349 + /// One row per SSID, strongest wins. A network with three access points is
350 + /// three rows here, identical but for the signal, and NM connects to a *name*
351 + /// rather than to the row that was selected — so showing the same name three
352 + /// times would offer three choices that do the same thing.
353 + fn parse_wifi_list(raw: &str) -> Vec<Network> {
354 + let mut networks: Vec<Network> = Vec::new();
355 +
356 + for line in raw.lines() {
357 + let fields = split_terse(line);
358 + let [in_use, ssid, signal, security] = fields.as_slice() else {
359 + continue;
360 + };
361 + if ssid.is_empty() {
362 + continue;
363 + }
364 +
365 + let network = Network {
366 + ssid: ssid.clone(),
367 + // A row whose signal will not parse is kept at zero rather than
368 + // dropped: the network is there and joinable, and the number is
369 + // only the sort key.
370 + signal: signal.trim().parse().unwrap_or(0),
371 + security: (!security.trim().is_empty()).then(|| security.trim().to_string()),
372 + in_use: in_use.trim() == "*",
373 + };
374 +
375 + match networks.iter_mut().find(|seen| seen.ssid == network.ssid) {
376 + // `in_use` is sticky across the merge. It is a property of the
377 + // network rather than of the access point, and the row we are on
378 + // is not necessarily the strongest one.
379 + Some(seen) => {
380 + seen.in_use |= network.in_use;
381 + if network.signal > seen.signal {
382 + seen.signal = network.signal;
383 + seen.security = network.security;
384 + }
385 + }
386 + None => networks.push(network),
387 + }
388 + }
389 +
390 + networks.sort_by(|a, b| b.signal.cmp(&a.signal).then_with(|| a.ssid.cmp(&b.ssid)));
391 + networks
392 + }
393 +
394 + /// Split one line of nmcli terse tabular output into its fields.
395 + ///
396 + /// `\:` is a colon inside a value and `\\` is a backslash. Everything else
397 + /// passes through, including a trailing lone backslash, which nmcli does not
398 + /// emit and which is dropped rather than treated as the start of an escape
399 + /// nobody finished.
400 + fn split_terse(line: &str) -> Vec<String> {
401 + let mut fields = vec![String::new()];
402 + let mut escaped = false;
403 +
404 + for c in line.chars() {
405 + match c {
406 + '\\' if !escaped => escaped = true,
407 + ':' if !escaped => fields.push(String::new()),
408 + _ => {
409 + escaped = false;
410 + if let Some(field) = fields.last_mut() {
411 + field.push(c);
412 + }
413 + }
414 + }
415 + }
416 +
417 + fields
418 + }
419 +
237 420 /// Fixed sample state, for machines without NetworkManager.
238 421 pub(crate) struct Mock;
239 422
@@ -350,6 +533,47 @@
350 533 interfaces
351 534 }
352 535
536 + /// What the screen is showing, and what its keys mean.
537 + ///
538 + /// A mode rather than a tab, and rather than a second view. The three are one
539 + /// question asked in three steps — which device, which network, what is the
540 + /// passphrase — so Esc walking back through them is the whole navigation model,
541 + /// and [`View::cancel`] gives that for free. Tabs would put a passphrase field
542 + /// on a tab someone can page away from mid-word.
543 + ///
544 + /// The passphrase lives in a [`TextField`] here for as long as the user is
545 + /// typing it, and moves into a [`Secret`] at the moment the command is built.
546 + /// That is the same arrangement the installer's account pane settled on: a
547 + /// scrubbing buffer helps only once there is a buffer to scrub, and until then
548 + /// the value is a `String` that the widget owns.
549 + enum Mode {
550 + /// The interface inventory. What this screen was before the join flow.
551 + Devices,
552 + /// The networks in range, from the last scan.
553 + Networks {
554 + networks: Vec<Network>,
555 + cursor: Cursor,
556 + },
557 + /// Asking for the passphrase of a network already chosen.
558 + Passphrase { ssid: String, field: TextField },
559 + }
560 +
561 + /// Redacted, and hand-written for that reason.
562 + ///
563 + /// A derived `Debug` would print the passphrase, and the places a `{:?}` ends
564 + /// up are exactly the ones nobody audits: a test failure message, a panic, a
565 + /// log line added in a hurry. [`Secret`] makes the same choice for the same
566 + /// reason, and this is the buffer that feeds it.
567 + impl std::fmt::Debug for Mode {
568 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
569 + match self {
570 + Mode::Devices => f.write_str("Devices"),
571 + Mode::Networks { networks, .. } => write!(f, "Networks({})", networks.len()),
572 + Mode::Passphrase { ssid, .. } => write!(f, "Passphrase({ssid}, <redacted>)"),
573 + }
574 + }
575 + }
576 +
353 577 /// The `alloy net` screen.
354 578 pub(crate) struct NetView {
355 579 backend: Box<dyn Backend>,
@@ -360,6 +584,7 @@
360 584 /// every refresh, since toggling it is one of the two things this screen
361 585 /// does.
362 586 wifi: Option<bool>,
587 + mode: Mode,
363 588 }
364 589
365 590 impl NetView {
@@ -370,11 +595,123 @@
370 595 cursor: Cursor::new(),
371 596 error: None,
372 597 wifi: None,
598 + mode: Mode::Devices,
373 599 };
374 600 view.refresh(log);
375 601 view
376 602 }
377 603
604 + /// Whether a scan is worth offering: the backend can do one, and there is a
605 + /// wireless device for it to happen on.
606 + ///
607 + /// The radio being off is deliberately *not* part of this. A key that
608 + /// disappears when the radio is switched off teaches that the console
609 + /// cannot scan; a key that says "wifi radio is off; press w" teaches which
610 + /// key to press.
611 + fn can_scan(&self) -> bool {
612 + self.wifi.is_some() && self.interfaces.iter().any(|i| i.kind == Kind::Wireless)
613 + }
614 +
615 + /// Look for networks, and show them if any came back.
616 + fn scan(&mut self, log: &mut CommandLog) {
617 + if self.wifi == Some(false) {
618 + self.error = Some("wifi radio is off; press w".to_string());
619 + return;
620 + }
621 + let Some(result) = self.backend.networks(log) else {
622 + self.error = Some(format!("{} cannot scan", self.backend.name()));
623 + return;
624 + };
625 + match result {
626 + Ok(networks) if networks.is_empty() => {
627 + // Not an error: a scan that finds nothing is a true answer, and
628 + // an empty list behind a mode switch reads as a broken screen.
629 + self.error = Some("no networks in range".to_string());
630 + }
631 + Ok(networks) => {
632 + let mut cursor = Cursor::new();
633 + cursor.resize(networks.len());
634 + self.mode = Mode::Networks { networks, cursor };
635 + self.error = None;
636 + }
637 + Err(err) => self.error = Some(err.to_string()),
638 + }
639 + }
640 +
641 + /// Act on the selected network: join an open one, ask about a secured one.
642 + fn choose(&mut self, log: &mut CommandLog) {
643 + let Mode::Networks { networks, cursor } = &self.mode else {
644 + return;
645 + };
646 + let Some(network) = cursor.selected().and_then(|index| networks.get(index)) else {
647 + return;
648 + };
649 +
650 + if network.security.is_none() {
651 + let ssid = network.ssid.clone();
652 + self.join(&ssid, None, log);
653 + return;
654 + }
655 + self.mode = Mode::Passphrase {
656 + ssid: network.ssid.clone(),
657 + field: TextField::new(),
658 + };
659 + }
660 +
661 + /// Join with what has been typed.
662 + fn submit(&mut self, log: &mut CommandLog) {
663 + let Mode::Passphrase { ssid, field } = &self.mode else {
664 + return;
665 + };
666 + let ssid = ssid.clone();
667 + let secret = Secret::new(field.value().as_bytes().to_vec());
668 + self.join(&ssid, Some(secret), log);
669 + }
670 +
671 + /// Run the join, and say what happened.
672 + ///
673 + /// Success returns to the device list, because that is where the answer is:
674 + /// the interface the user was looking at now says `connected` and names the
675 + /// network. Staying on the scan would mean reporting the outcome in a
676 + /// sentence beside a list that has not changed.
677 + fn join(&mut self, ssid: &str, passphrase: Option<Secret>, log: &mut CommandLog) {
678 + let Some(invocation) = self.backend.join(ssid, passphrase) else {
679 + self.error = Some(format!("{} cannot join a network", self.backend.name()));
680 + return;
681 + };
682 +
683 + // stdout is dropped rather than reported, and that is not tidiness.
684 + // Fed a pipe, nmcli's prompt echoes what it reads, so the passphrase
685 + // can be in the output of a command that carried it privately in every
686 + // other respect. `capture` reports failures out of stderr, which the
687 + // echo does not reach, so nothing that surfaces has been near it.
688 + match invocation.run(log).map(drop) {
689 + Ok(()) => {
690 + self.mode = Mode::Devices;
691 + self.error = None;
692 + log.quiet(|log| self.refresh(log));
693 + }
694 + Err(err) => {
695 + // Tier 2 of wiki `alloy-privilege` cannot help here, and this
696 + // is the one place in the console where that is true. Joining a
697 + // network NM has not saved is `settings.modify.system`, which
698 + // `50-alloy-settings.rules` deliberately does not grant, so it
699 + // always wants an answer; and `Flow::Authorize` reaches it by
700 + // suspending the TUI and handing the command to `pkttyagent`,
701 + // which inherits stdio and so has no pipe to carry the
702 + // passphrase on. A secret and a suspend cannot both be had.
703 + // That is what tier 3 is for, and until it lands the honest
704 + // thing is to name the wall rather than to suspend into a
705 + // command that would arrive without its passphrase.
706 + self.error = Some(if crate::cli::wants_authentication(&err) {
707 + format!("joining {ssid} needs authorization this console cannot ask for yet")
708 + } else {
709 + err.to_string()
710 + });
711 + }
712 + }
713 + }
714 +
378 715 /// Whether the selected interface has a connect action behind it, which on
379 716 /// the mock and on loopback it does not.
380 717 fn can_connect(&self) -> bool {
@@ -482,25 +819,89 @@
482 819 text::secondary(theme, address),
483 820 ])
484 821 }
822 +
823 + /// One network in the scan list.
824 + ///
825 + /// The security column says `open` rather than staying blank, because blank
826 + /// is what a missing value looks like and this one is a warning: an open
827 + /// network is the row where nothing will be asked for and nothing will be
828 + /// encrypted.
829 + fn network_row<'a>(theme: &Theme, network: &'a Network) -> Line<'a> {
830 + let security = network.security.clone().unwrap_or_else(|| "open".into());
831 + Line::from(vec![
832 + text::bold(
833 + theme,
834 + format!("{:<3}", if network.in_use { "*" } else { "" }),
835 + ),
836 + text::primary(theme, format!("{:<32}", network.ssid)),
837 + text::muted(theme, format!("{:>3}% ", network.signal)),
838 + Span::styled(
839 + security,
840 + if network.security.is_some() {
841 + Severity::Healthy.style(theme)
842 + } else {
843 + Severity::Warn.style(theme)
844 + },
845 + ),
846 + ])
847 + }
848 +
849 + /// The passphrase field: dots, and a caret on the one under it.
850 + ///
851 + /// Masked here rather than in [`TextField`] for the reason the installer
852 + /// gives for its own copy of this: a widget that knows how to hide itself
853 + /// has to be trusted to do it everywhere, and a plain buffer only has to be
854 + /// drawn carefully in the places that draw it.
855 + fn passphrase_line<'a>(theme: &Theme, field: &TextField) -> Line<'a> {
856 + let (before, under, after) = field.split();
857 + Line::from(vec![
858 + text::muted(theme, " passphrase "),
859 + text::primary(theme, "•".repeat(before.chars().count())),
860 + Span::styled(
861 + under.map_or(' ', |_| '•').to_string(),
862 + Severity::Healthy.style(theme),
863 + ),
864 + text::primary(theme, "•".repeat(after.chars().count())),
865 + ])
866 + }
485 867 }
486 868
487 869 impl View for NetView {
488 870 fn title(&self) -> String {
489 - format!("network ({})", self.backend.name())
871 + match &self.mode {
872 + Mode::Devices => format!("network ({})", self.backend.name()),
873 + Mode::Networks { .. } => format!("networks in range ({})", self.backend.name()),
874 + Mode::Passphrase { ssid, .. } => format!("join {ssid}"),
875 + }
490 876 }
491 877
492 878 fn hints(&self) -> Vec<Hint> {
493 - let mut hints = vec![hint("j/k", "select")];
494 - // The footer has one row and shows what is live. What the pane *can* do,
495 - // including the parts it cannot do right now, is `?`'s job: see `keys`.
496 - if self.can_connect() {
497 - hints.push(hint("s", "connect/disconnect"));
879 + match &self.mode {
880 + Mode::Devices => {
881 + let mut hints = vec![hint("j/k", "select")];
882 + // The footer has one row and shows what is live. What the pane
883 + // *can* do, including the parts it cannot do right now, is
884 + // `?`'s job: see `keys`.
885 + if self.can_connect() {
886 + hints.push(hint("s", "connect/disconnect"));
887 + }
888 + if self.can_scan() {
889 + hints.push(hint("n", "join a network"));
890 + }
891 + if self.wifi.is_some() {
892 + hints.push(hint("w", "wifi radio"));
893 + }
894 + hints.push(hint("r", "refresh"));
895 + hints
896 + }
897 + Mode::Networks { .. } => vec![
898 + hint("j/k", "select"),
899 + hint("enter", "join"),
900 + hint("n", "scan again"),
901 + hint("esc", "back"),
902 + ],
903 + Mode::Passphrase { .. } => vec![hint("enter", "join"), hint("esc", "back")],
498 904 }
499 - if self.wifi.is_some() {
500 - hints.push(hint("w", "wifi radio"));
501 - }
502 - hints.push(hint("r", "refresh"));
503 - hints
504 905 }
505 906
506 907 /// Every key this pane has, including the ones that are unavailable on the
@@ -511,25 +912,54 @@
Lines truncated