Skip to main content

max / alloy

Do not hand the terminal to a sign-in that cannot reach anything MEASURED on fw12, 2026-09-07. A reinstall took the Wi-Fi credentials with everything else, so the machine came up with no route. Enrolling asked for a password, escalated, started tailscaled, and then sat on `tailscale up` with nothing on screen: with no route it does not fail, it retries, and it never reaches a control server so it never has an auth URL to print. The console had already torn down, so what the user saw was a blank terminal and no way to tell whether anything was wrong. That is the shape usr/bin/alloy-mesh-up was written to close, one layer out, and it fails in the same expensive place: after the password. So both enrollment paths check for a default route first and refuse with a message naming `alloy net`, which is the screen that fixes it. A default route rather than a reachability probe: the question is whether handing over the terminal is worth it, and a machine that has a route and still cannot reach the control plane has a problem `tailscale up` reports better than a preflight would. The mesh's own interfaces are skipped. tailscale0 carries a default route when an exit node is set, and counting it would let a machine whose only route is the mesh conclude it can go and join the mesh. A missing /proc/net/route answers yes. This gate catches a known state, and refusing to enroll because procfs could not be read would invent a second failure to explain the first.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-07 20:03 UTC
Signed with PGP, not checked
Commit: 8ad44a2ef626729dc0a2f9b0101ffcbaedaa6099
Parent: b5bbf59
3 files changed, +105 insertions, -0 deletions
@@ -221,6 +221,55 @@
221 221 ///
222 222 /// Returns the trimmed value, or `None` for the vendor's control plane, which
223 223 /// is what an empty field means.
224 + /// Is there a route off this machine, ignoring the mesh's own interface?
225 + ///
226 + /// `tailscale up` reaches a control server before it can print anything. With
227 + /// no route it does not fail, it retries: the console suspends, the user is
228 + /// handed a clean terminal, and nothing appears on it. Measured on fw12,
229 + /// 2026-09-07, after a reinstall took the Wi-Fi credentials with everything
230 + /// else. That is the same shape as the bug `usr/bin/alloy-mesh-up` exists to
231 + /// close and it fails in the same expensive place, *after* the password prompt,
232 + /// one layer further out.
233 + ///
234 + /// A default route rather than a reachability probe. What is being answered is
235 + /// "is it worth handing over the terminal", and a machine with no default route
236 + /// certainly is not; a machine that has one and still cannot reach the control
237 + /// plane has a problem `tailscale up` reports better than a preflight would.
238 + /// The alternative, dialling the control server here, means a network call on a
239 + /// keypress and a second timeout to explain.
240 + ///
241 + /// The mesh's own interfaces are skipped. `tailscale0` carries a default route
242 + /// when an exit node is set, and counting it would let a machine whose only
243 + /// route is the mesh conclude it can go and join the mesh.
244 + fn has_route(table: &str) -> bool {
245 + table
246 + .lines()
247 + .skip(1)
248 + .filter_map(|line| {
249 + let mut cols = line.split_whitespace();
250 + let iface = cols.next()?;
251 + let destination = cols.next()?;
252 + Some((iface, destination))
253 + })
254 + .any(|(iface, destination)| destination == "00000000" && !iface.starts_with("tailscale"))
255 + }
256 +
257 + /// The same question, of this machine.
258 + ///
259 + /// A missing `/proc/net/route` answers yes rather than no. This gate exists to
260 + /// catch a known state, and a console that refuses to enroll because it could
261 + /// not read a procfs file would be inventing a second failure to explain the
262 + /// first.
263 + pub(crate) fn machine_has_route() -> bool {
264 + match std::fs::read_to_string("/proc/net/route") {
265 + Ok(table) => has_route(&table),
266 + Err(_) => true,
267 + }
268 + }
269 +
270 + /// What to say when there is no route, naming the screen that fixes it.
271 + pub(crate) const NO_ROUTE: &str = "no network: signing in needs a route to the control server. Join one in `alloy net`, then enroll.";
272 +
224 273 pub(crate) fn validate_login_server(value: &str) -> Result<Option<String>, String> {
225 274 let value = value.trim();
226 275 if value.is_empty() {
@@ -707,6 +756,11 @@
707 756 return Flow::Continue;
708 757 }
709 758 };
759 + if !machine_has_route() {
760 + self.error = Some(NO_ROUTE.into());
761 + self.close_enrollment();
762 + return Flow::Continue;
763 + }
710 764 let invocation = self.backend.enroll(server.as_deref());
711 765 self.close_enrollment();
712 766 // Recorded before the handover, as in `alloy pkg`: the pane carries what
@@ -299,6 +299,11 @@
299 299 return Flow::Continue;
300 300 }
301 301 };
302 + if !mesh::machine_has_route() {
303 + self.error = Some(mesh::NO_ROUTE.into());
304 + self.server = None;
305 + return Flow::Continue;
306 + }
302 307 let invocation = self.mesh.enroll(server.as_deref());
303 308 self.server = None;
304 309 // Recorded before the handover, as `alloy mesh` and `alloy pkg` do: the
@@ -524,3 +524,49 @@
524 524 );
525 525 }
526 526 }
527 +
528 + /// A machine on Wi-Fi has a route, and the parse finds it.
529 + ///
530 + /// The fixture is `/proc/net/route` as fw12 reported it on 2026-09-07, tabs and
531 + /// all, rather than a tidied version: the columns are whitespace-separated in
532 + /// the file and a test over a prettified copy would not prove the split works.
533 + #[test]
534 + fn a_default_route_is_a_route() {
535 + let table = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
536 + wlp0s20f3\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n\
537 + wlp0s20f3\t0000A8C0\t00000000\t0001\t0\t0\t600\t00FFFFFF\t0\t0\t0\n";
538 + assert!(has_route(table));
539 + }
540 +
541 + /// A machine with interfaces but nothing to the world has none.
542 + #[test]
543 + fn a_subnet_route_alone_is_not_a_route() {
544 + let table = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
545 + wlp0s20f3\t0000A8C0\t00000000\t0001\t0\t0\t600\t00FFFFFF\t0\t0\t0\n";
546 + assert!(!has_route(table));
547 + }
548 +
549 + /// The mesh's own default route does not count as a way to reach the mesh.
550 + ///
551 + /// This is the case the filter exists for: `tailscale0` carries a default route
552 + /// when an exit node is set, and a machine whose only route is the mesh cannot
553 + /// use it to go and join the mesh.
554 + #[test]
555 + fn the_mesh_interface_does_not_count_as_a_route() {
556 + let table = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
557 + tailscale0\t00000000\t00000000\t0001\t0\t0\t0\t00000000\t0\t0\t0\n";
558 + assert!(!has_route(table));
559 + let with_wifi = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
560 + tailscale0\t00000000\t00000000\t0001\t0\t0\t0\t00000000\t0\t0\t0\n\
561 + wlp0s20f3\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
562 + assert!(has_route(with_wifi));
563 + }
564 +
565 + /// A header with no rows, and a file with nothing in it at all.
566 + #[test]
567 + fn an_empty_table_is_not_a_route() {
568 + assert!(!has_route(
569 + "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n"
570 + ));
571 + assert!(!has_route(""));
572 + }