Skip to main content

max / alloy

install: offer the timezone without spending a step on it The wizard stays at four screens. The timezone folds into the hostname step as a second focus slot: Tab moves, Space toggles, Enter walks off the field and submits from the box, which is the shape the account step already uses. Off by default, and the box names get.geojs.io on screen next to itself, because ticking it is what sends this machine's address to a third party. Left alone, the install sets no timezone and the system comes up UTC, which the pane says along with where to change it. The summary names the timezone either way: a review screen that only listed the answers someone changed would hide the default it is asking them to confirm. curl rather than an HTTP crate. It is already in the image, this file already works by running commands, and one optional GET does not justify carrying a TLS stack in the console binary. The lookup can never fail the install. It runs after `bootc install to-disk --wipe`, so a failing stage would abort a finished install over its least important answer and read on screen as the install itself having failed. No network, a body that is not JSON, a truncated response, a zone the target does not carry: every path resolves to no stages and leaves the system on UTC. `no_answer_from_the_network_is_ever_an_error` pins that across seven bad responses. The response is a network string on its way into an argument, so it is checked rather than trusted: shaped like an IANA name, no `.` anywhere so `..` cannot walk out of zoneinfo, and present in the target's own tree rather than the live medium's.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 17:55 UTC
Signed with PGP, not checked
Commit: 471bd92cab81b219a5e447dfe09a1d6df7f22181
Parent: 58930cd
1 file changed, +328 insertions, -19 deletions
@@ -120,6 +120,15 @@
120 120 const FIELD_CONFIRM: usize = 2;
121 121 const ACCOUNT_FIELDS: usize = 3;
122 122
123 + /// Which slot of the hostname step has focus.
124 + ///
125 + /// Two, because the step carries the machine's name and the one question that
126 + /// would otherwise have been a step of its own. Folding the timezone in here
127 + /// rather than after it is what keeps the wizard at four screens.
128 + const SLOT_HOSTNAME: usize = 0;
129 + const SLOT_TIMEZONE: usize = 1;
130 + const HOSTNAME_SLOTS: usize = 2;
131 +
123 132 /// Longest username `useradd` accepts.
124 133 const USERNAME_MAX: usize = 32;
125 134
@@ -231,6 +240,16 @@
231 240 /// [`TextField`] until the plan is built, which keeps the number of
232 241 /// places holding a readable copy to one. See [`Secret`].
233 242 pub username: Option<String>,
243 + /// Whether the install may ask the network where this machine is, to set
244 + /// the timezone from it.
245 + ///
246 + /// `false` unless the user ticked the box, and `Default` gives that for
247 + /// free. Off is the honest default because the lookup is not local: it
248 + /// sends this machine's address to a third party (see [`GEO_HOST`]), and an
249 + /// installer that did that unasked would be doing the thing Alloy exists
250 + /// not to do. Left off, the install sets no timezone at all and the system
251 + /// comes up UTC, which the hostname pane says on screen.
252 + pub locate_timezone: bool,
234 253 }
235 254
236 255 /// Where the installer mounts the target's root filesystem to configure it.
@@ -585,19 +604,150 @@
585 604 /// overshooting costs.
586 605 const SALT_BYTES: usize = SALT_MAX / 4 * 3;
587 606
607 + // ---- timezone from location ----
608 +
609 + /// Who the optional location lookup asks.
610 + ///
611 + /// geojs.io answers an unauthenticated GET with the caller's approximate
612 + /// location as JSON, one field of which is an IANA zone name. No key and no
613 + /// account, so nothing identifies the install beyond the request itself, which
614 + /// is still a request to a third party and is why the box that sends it starts
615 + /// unticked.
616 + ///
617 + /// Split from the path because the host is the part the checkbox names on
618 + /// screen: the user is being asked to accept a party, not a URL. Naming it once
619 + /// keeps the screen and the request from drifting apart.
620 + const GEO_HOST: &str = "get.geojs.io";
621 +
622 + /// The endpoint on [`GEO_HOST`] that answers with a zone name.
623 + const GEO_PATH: &str = "/v1/ip/geo.json";
624 +
625 + /// How long the lookup may take before the install gives up on it, in seconds.
626 + ///
627 + /// Bounded because of *when* it runs: the disk has already been wiped and
628 + /// deployed. A curl left waiting on a network that will never answer would hang
629 + /// the install at its least interruptible moment, so an absent network has to
630 + /// cost a few seconds and then be over.
631 + const GEO_TIMEOUT: &str = "10";
632 +
633 + /// Where zone names live inside the target, relative to its deployment root.
634 + const ZONEINFO: &str = "usr/share/zoneinfo";
635 +
636 + /// Ask the network where this machine is.
637 + ///
638 + /// curl rather than an HTTP client crate: it is already in the image, the rest
639 + /// of this file already works by running commands, and one optional GET does
640 + /// not justify carrying a TLS stack in the console binary.
641 + fn geo_lookup() -> Invocation {
642 + Invocation::new("curl")
643 + .args(["--silent", "--show-error", "--fail"])
644 + .args(["--max-time", GEO_TIMEOUT])
645 + .arg(format!("https://{GEO_HOST}{GEO_PATH}"))
646 + }
647 +
648 + /// The timezone field of a geo response, if it holds a usable one.
649 + ///
650 + /// `Option` rather than `Result` on purpose: every caller treats a failure the
651 + /// same way, by leaving the timezone unset, so there is no error worth
652 + /// distinguishing. A truncated body, a JSON object without the field, an empty
653 + /// string, and a 404 page all mean the same thing here.
654 + fn timezone_of(response: &str) -> Option<String> {
655 + #[derive(serde::Deserialize)]
656 + struct Geo {
657 + timezone: Option<String>,
658 + }
659 +
660 + let geo: Geo = serde_json::from_str(response).ok()?;
661 + let zone = geo.timezone?;
662 + is_zone_name(&zone).then_some(zone)
663 + }
664 +
665 + /// Whether a string is shaped like an IANA zone name.
666 + ///
667 + /// This is a network response on its way into a command line, so it is checked
668 + /// rather than trusted. [`Invocation`] passes arguments as argv and never
669 + /// through a shell, so the risk is not injection so much as a nonsense value
670 + /// reaching `systemd-firstboot`, which would fail a stage that runs *after* the
671 + /// disk is written. Cheaper to refuse it here.
672 + ///
673 + /// The accepted shape is what the zoneinfo tree actually contains: one or more
674 + /// slash-separated components of letters, digits, `_`, `-` and `+`
675 + /// (`America/Argentina/Buenos_Aires`, `Etc/GMT+5`). No leading or trailing
676 + /// slash, no empty component, and no `.` anywhere, which is what keeps `..`
677 + /// from walking out of the zoneinfo directory.
678 + fn is_zone_name(zone: &str) -> bool {
679 + !zone.is_empty()
680 + && zone.split('/').all(|part| {
681 + !part.is_empty()
682 + && part
683 + .chars()
684 + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+'))
685 + })
686 + }
687 +
688 + /// Whether the target carries this zone.
689 + ///
690 + /// Checked against the installed tree rather than the live medium's, because
691 + /// the two are different filesystems and it is the target that has to resolve
692 + /// the name at boot.
693 + fn zone_available_in(root: &str, zone: &str) -> bool {
694 + std::path::Path::new(root)
695 + .join(ZONEINFO)
696 + .join(zone)
697 + .exists()
698 + }
699 +
700 + /// The stage that sets the timezone from the network, if asked for.
701 + ///
702 + /// Empty when the box was not ticked, which is the whole of the opt-in: no
703 + /// request is built, so none can be made.
704 + ///
705 + /// **The resolver never returns `Err`.** By the time it runs, `bootc install
706 + /// to-disk --wipe` has finished and the user's disk is gone. Failing the
707 + /// sequence over a timezone would abort a completed install for the least
708 + /// important answer it collected, and would read on screen as the install
709 + /// having failed. Every failure path — no network, a body that is not JSON, a
710 + /// zone the target does not carry — resolves to no stages, which leaves the
711 + /// system on UTC exactly as if the box had been left alone.
712 + fn timezone_stages(locate: bool, root: &str) -> Vec<Stage> {
713 + if !locate {
714 + return Vec::new();
715 + }
716 +
717 + let root = root.to_string();
718 + vec![Stage::Resolve {
719 + invocation: geo_lookup(),
720 + then: Box::new(move |response| {
721 + let Some(zone) = timezone_of(response).filter(|zone| zone_available_in(&root, zone))
722 + else {
723 + return Ok(Vec::new());
724 + };
725 + Ok(vec![Stage::Run(
726 + Invocation::new("systemd-firstboot")
727 + .arg(format!("--root={root}"))
728 + .arg(format!("--timezone={zone}"))
729 + // Same reason as the hostname call: without --force
730 + // firstboot skips a setting the image already carries.
731 + .arg("--force"),
732 + )])
733 + }),
734 + }]
735 + }
736 +
588 737 /// The commands that configure an already-deployed system.
589 738 ///
590 739 /// `root` is the ostree deployment directory, not the mountpoint. See
591 740 /// [`deployment_dir`] for why that distinction is the whole ballgame, and
592 741 /// [`stateroot_var`] for the half of it that `--root` does not cover.
593 742 ///
594 - /// [`Stage`]s rather than plain [`Invocation`]s because two of these depend on
595 - /// values that only exist once the ones before them have run: the account's
596 - /// numeric ids, and the password hash.
743 + /// [`Stage`]s rather than plain [`Invocation`]s because three of these depend
744 + /// on values that only exist once the ones before them have run: the account's
745 + /// numeric ids, the password hash, and the timezone the network was asked for.
597 746 fn configure_plan(
598 747 hostname: &str,
599 748 username: &str,
600 749 password: &str,
750 + locate_timezone: bool,
601 751 root: &str,
602 752 ) -> Result<Vec<Stage>, String> {
603 753 let var = stateroot_var(root)?;
@@ -612,7 +762,7 @@
612 762 let ids_user = username.to_string();
613 763 let ids_home = home.clone();
614 764
615 - Ok(vec![
765 + let mut stages = vec![
616 766 // Before anything is written: a shell the target will not hand out
617 767 // makes an account nobody can log into, and useradd will not catch it.
618 768 Stage::Resolve {
@@ -687,6 +837,14 @@
687 837 .arg("--encrypted")
688 838 .stdin(Secret::new(format!("{username}:{hash}\n"))),
689 839 ),
840 + ];
841 +
842 + // Last of the answers, and deliberately after the account: it is the only
843 + // stage that can decline to do anything, and the only one that touches the
844 + // network. Nothing below it depends on the result.
845 + stages.extend(timezone_stages(locate_timezone, root));
846 +
847 + stages.extend([
690 848 // Upstream: "optional, but recommended to run as the penultimate step
691 849 // before unmounting the target filesystem. This command will perform
692 850 // some basic sanity checks and may also perform fixups on the target
@@ -705,7 +863,9 @@
705 863 // Leaving the target mounted would strand the filesystem dirty across
706 864 // the reboot the user is about to perform.
707 865 Stage::Run(Invocation::new("umount").arg(TARGET_MOUNT)),
708 - ])
866 + ]);
867 +
868 + Ok(stages)
709 869 }
710 870
711 871 /// Where the installer ISO carries the image it installs.
@@ -758,7 +918,13 @@
758 918 /// while it partitions, and names the ostree deployment after a checksum that
759 919 /// does not exist until the deploy finishes. Neither can be an argument written
760 920 /// in advance, which is why this returns [`Stage`]s rather than a flat list.
761 - fn install_plan(disk: &str, hostname: &str, username: &str, password: &str) -> Vec<Stage> {
921 + fn install_plan(
922 + disk: &str,
923 + hostname: &str,
924 + username: &str,
925 + password: &str,
926 + locate_timezone: bool,
927 + ) -> Vec<Stage> {
762 928 let hostname = hostname.to_string();
763 929 let username = username.to_string();
764 930 let password = password.to_string();
@@ -838,7 +1004,13 @@
838 1004 if deployment.is_empty() {
839 1005 return Err("ostree reported no current deployment".into());
840 1006 }
841 - configure_plan(&hostname, &username, &password, deployment)
1007 + configure_plan(
1008 + &hostname,
1009 + &username,
1010 + &password,
1011 + locate_timezone,
1012 + deployment,
1013 + )
842 1014 }),
843 1015 }])
844 1016 }),
@@ -1184,6 +1356,10 @@
1184 1356 username: TextField,
1185 1357 password: TextField,
1186 1358 confirm: TextField,
1359 + /// Which of the two hostname-step slots has focus.
1360 + machine: FocusRing,
1361 + /// The checkbox, until the step is confirmed and it becomes an answer.
1362 + locate_timezone: bool,
1187 1363 /// Which of the three account fields has focus.
1188 1364 fields: FocusRing,
1189 1365 answers: Answers,
@@ -1218,6 +1394,8 @@
1218 1394 username: TextField::new(),
1219 1395 password: TextField::new(),
1220 1396 confirm: TextField::new(),
1397 + machine: FocusRing::new(HOSTNAME_SLOTS),
1398 + locate_timezone: false,
1221 1399 fields: FocusRing::new(ACCOUNT_FIELDS),
1222 1400 answers: Answers::default(),
1223 1401 error: None,
@@ -1281,6 +1459,7 @@
1281 1459 match validate_hostname(self.hostname.value()) {
1282 1460 Ok(()) => {
1283 1461 self.answers.hostname = Some(self.hostname.value().to_string());
1462 + self.answers.locate_timezone = self.locate_timezone;
1284 1463 self.error = None;
1285 1464 self.steps.advance();
1286 1465 }
@@ -1296,6 +1475,24 @@
1296 1475 /// this step is on screen.
1297 1476 fn edit_hostname(&mut self, key: KeyEvent) -> Flow {
1298 1477 match key.code {
1478 + KeyCode::Tab | KeyCode::Down => self.machine.next(),
1479 + KeyCode::BackTab | KeyCode::Up => self.machine.prev(),
1480 + // Enter advances off the field and submits from the checkbox, which
1481 + // is the shape the account step already uses: on both screens the
1482 + // last slot is the one that commits.
1483 + KeyCode::Enter => {
1484 + if self.machine.current() == SLOT_TIMEZONE {
1485 + return self.name_machine();
1486 + }
1487 + self.machine.next();
1488 + }
1489 + KeyCode::Char(' ') if self.machine.current() == SLOT_TIMEZONE => {
1490 + self.locate_timezone = !self.locate_timezone;
1491 + }
1492 + // Everything else is the field's, and reaches it only while the
1493 + // field has focus. Typing into a checkbox should do nothing rather
1494 + // than edit a name that is not on screen under the caret.
1495 + _ if self.machine.current() == SLOT_TIMEZONE => {}
1299 1496 KeyCode::Char(c) => self.hostname.insert(c),
1300 1497 KeyCode::Backspace => self.hostname.backspace(),
1301 1498 KeyCode::Delete => self.hostname.delete(),
@@ -1303,7 +1500,6 @@
1303 1500 KeyCode::Right => self.hostname.right(),
1304 1501 KeyCode::Home => self.hostname.home(),
1305 1502 KeyCode::End => self.hostname.end(),
1306 - KeyCode::Enter => return self.name_machine(),
1307 1503 _ => {}
1308 1504 }
1309 1505 Flow::Continue
@@ -1472,6 +1668,25 @@
1472 1668 self.answers.username.as_deref().unwrap_or("-"),
1473 1669 String::new(),
1474 1670 ),
1671 + // Named on the summary whichever way it went. "UTC" is a decision
1672 + // the install is about to make, and a review screen that only
1673 + // listed the answers someone changed would hide the defaults it is
1674 + // asking them to confirm.
1675 + if self.answers.locate_timezone {
1676 + row(
1677 + theme,
1678 + "timezone",
1679 + "from location",
1680 + format!(" asks {GEO_HOST}"),
1681 + )
1682 + } else {
1683 + row(
1684 + theme,
1685 + "timezone",
1686 + "UTC",
1687 + " change it with `alloy settings`".into(),
1688 + )
1689 + },
1475 1690 Line::default(),
1476 1691 Line::from(Span::styled(
1477 1692 format!("Everything on {disk} will be erased."),
@@ -1579,7 +1794,13 @@
1579 1794 return Vec::new();
1580 1795 };
1581 1796
1582 - install_plan(disk, hostname, username, password)
1797 + install_plan(
1798 + disk,
1799 + hostname,
1800 + username,
1801 + password,
1802 + self.answers.locate_timezone,
1803 + )
1583 1804 }
1584 1805
1585 1806 /// The same commands, as the summary shows them.
@@ -1605,21 +1826,65 @@
1605 1826 fn render_hostname(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
1606 1827 let (before, under, after) = self.hostname.split();
1607 1828 let caret = under.unwrap_or(' ');
1829 + let named = self.machine.is_focused(SLOT_HOSTNAME);
1608 1830
1609 - let lines = vec![
1831 + let mut name = vec![
1832 + if named {
1833 + text::bold(theme, "hostname ")
1834 + } else {
1835 + text::muted(theme, "hostname ")
1836 + },
1837 + text::primary(theme, before.to_string()),
1838 + ];
1839 + // Only the focused slot draws a caret, so the block cursor always says
1840 + // where typing lands. Same rule as the account step's three fields.
1841 + if named {
1842 + name.push(Span::styled(
1843 + caret.to_string(),
1844 + Style::default().add_modifier(Modifier::REVERSED),
1845 + ));
1846 + } else if let Some(c) = under {
1847 + name.push(text::primary(theme, c.to_string()));
1848 + }
1849 + name.push(text::primary(theme, after.to_string()));
1850 +
1851 + let ticked = self.machine.is_focused(SLOT_TIMEZONE);
1852 + let box_glyph = if self.locate_timezone { "[x]" } else { "[ ]" };
1853 + let label = "set timezone from my location";
1854 +
1855 + let mut lines = vec![
1610 1856 Line::from(text::muted(theme, "This machine's name on the network.")),
1611 1857 Line::default(),
1858 + Line::from(name),
1859 + Line::default(),
1612 1860 Line::from(vec![
1613 - text::secondary(theme, "hostname "),
1614 - text::primary(theme, before.to_string()),
1615 - Span::styled(
1616 - caret.to_string(),
1617 - Style::default().add_modifier(Modifier::REVERSED),
1618 - ),
1619 - text::primary(theme, after.to_string()),
1861 + if ticked {
1862 + text::bold(theme, format!("{box_glyph} "))
1863 + } else {
1864 + text::muted(theme, format!("{box_glyph} "))
1865 + },
1866 + if ticked {
1867 + text::bold(theme, label)
1868 + } else {
1869 + text::primary(theme, label)
1870 + },
1620 1871 ]),
1621 1872 ];
1622 1873
1874 + // What the box costs, on screen, next to the box. A checkbox whose
1875 + // consequence is a request to a third party has to say so where it is
1876 + // ticked, not in a manual nobody has read yet.
1877 + lines.push(Line::from(text::muted(
1878 + theme,
1879 + format!(" asks {GEO_HOST} where this machine is"),
1880 + )));
1881 + if !self.locate_timezone {
1882 + lines.push(Line::from(text::muted(
1883 + theme,
1884 + " otherwise UTC, set it later with `alloy settings`",
1885 + )));
1886 + }
1887 +
1623 1888 frame.render_widget(Paragraph::new(lines), area);
1624 1889 }
1625 1890
@@ -1662,7 +1927,11 @@
1662 1927 hint("enter", "choose"),
1663 1928 hint("r", "refresh"),
1664 1929 ],
1665 - Step::Hostname => vec![hint("enter", "confirm")],
1930 + Step::Hostname => vec![
1931 + hint("tab", "field"),
1932 + hint("space", "toggle"),
1933 + hint("enter", "next"),
1934 + ],
1666 1935 Step::Account => vec![hint("tab", "field"), hint("enter", "next")],
1667 1936 Step::Summary => vec![hint("enter", "install")],
1668 1937 };
@@ -2039,6 +2308,8 @@
2039 2308 username: TextField::new(),
2040 2309 password: TextField::new(),
2041 2310 confirm: TextField::new(),
2311 + machine: FocusRing::new(HOSTNAME_SLOTS),
2312 + locate_timezone: false,
2042 2313 fields: FocusRing::new(ACCOUNT_FIELDS),
2043 2314 answers: Answers::default(),
2044 2315 error: None,
@@ -2159,6 +2430,7 @@
2159 2430 }
2160 2431 type_into(&mut view, "workshop", &mut log);
2161 2432 view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
2433 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
2162 2434
2163 2435 assert_eq!(view.answers.hostname.as_deref(), Some("workshop"));
2164 2436 assert!(view.error.is_none());
@@ -2198,6 +2470,7 @@
2198 2470 let (mut view, mut log) = at_hostname();
2199 2471 type_into(&mut view, "_", &mut log);
2200 2472 view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
2473 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
2201 2474
2202 2475 assert_eq!(view.step(), Step::Hostname);
2203 2476 assert_eq!(view.answers.hostname, None);
@@ -2273,6 +2546,10 @@
2273 2546
2274 2547 fn at_account() -> (InstallView, CommandLog) {
2275 2548 let (mut view, mut log) = at_hostname();
2549 + // Twice: the step has two slots now, and Enter walks off the field
2550 + // before it submits. The checkbox is left alone, so this arrives at the
2551 + // account with the timezone answer at its default.
2552 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
2276 2553 view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
2277 2554 assert_eq!(view.step(), Step::Account, "stalled on the hostname");
2278 2555 (view, log)
@@ -2489,8 +2766,11 @@
2489 2766 const DEPLOYMENT: &str = "/mnt/alloy-target/ostree/deploy/default/deploy/abc123.0";
2490 2767
2491 2768 /// The configure half, against a deployment directory as discovered.
2769 + ///
2770 + /// Without the location lookup, which is the default and what almost every
2771 + /// assertion below is about. `configured_locating` is the other one.
2492 2772 fn configured() -> Vec<String> {
2493 - configure_plan("workshop", "max", "hunter2", DEPLOYMENT)
2773 + configure_plan("workshop", "max", "hunter2", false, DEPLOYMENT)
2494 2774 .expect("a well-formed deployment path")
2495 2775 .iter()
2496 2776 .map(Stage::display)
@@ -2810,6 +3090,182 @@
2810 3090 std::fs::remove_dir_all(&dir).unwrap();
2811 3091 }
2812 3092
3093 + // ---- timezone from location ----
3094 +
3095 + /// A target root carrying one zone, so the availability check has something
3096 + /// true to find. Returns the root and the zone in it.
3097 + fn target_with_zone(name: &str, zone: &str) -> std::path::PathBuf {
3098 + let root = std::env::temp_dir().join(name);
3099 + std::fs::create_dir_all(root.join(ZONEINFO).join(zone)).unwrap();
3100 + root
3101 + }
3102 +
3103 + /// Pull the resolver out of a one-stage plan and run it on `response`.
3104 + fn resolve(stages: Vec<Stage>, response: &str) -> Vec<String> {
3105 + let [stage] = <[Stage; 1]>::try_from(stages).ok().expect("one stage");
3106 + let Stage::Resolve { then, .. } = stage else {
3107 + panic!("the lookup is not a discovery");
3108 + };
3109 + then(response)
3110 + .expect("the lookup refused to fail")
3111 + .iter()
3112 + .map(Stage::display)
3113 + .collect()
3114 + }
3115 +
3116 + // The opt-in, stated as the absence of a request. Not "the checkbox
3117 + // defaults to false" — that a plan built without it contains nothing that
3118 + // could reach the network.
3119 + #[test]
3120 + fn an_install_that_was_not_asked_does_not_look_anything_up() {
3121 + for line in configured() {
Lines truncated