diff --git a/CHANGELOG.md b/CHANGELOG.md index 0219306..2ef26bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r ## [Unreleased] +### Fixed + +- **Console banner always shows the IP fallback.** On both the live ISO and + the installed system the `/etc/issue` welcome was written once, after + `network-online.target`, and simply omitted the `http://` line when no + address existed yet — so on a slow-DHCP NIC (first hardware bench, + 2026-08-24) the screen only ever offered `proksi.local`. The banner script + now runs from a timer (`furtka-issue.timer` on the ISO, + `furtka-welcome.timer` on the installed box, every 5 s) and always prints + a fallback line: every global IPv4 address once there is one, or a + "no IP address yet — check the cable / DHCP" hint until then. It only + rewrites `/etc/issue` and nudges agetty when the text changed, so the + login prompt doesn't flicker. The live ISO no longer auto-logs `root` in + on tty1 (releng default) — that handed the console to a shell before the + banner could update; tty1 now stays at the login prompt with a live + banner, and the banner tells you a shell is `root` + Enter away. Boxes + that self-update get the new timer linked + enabled automatically; the + refresh loop takes effect after their next reboot. + ## [26.19-alpha] - 2026-08-23 ### Fixed diff --git a/assets/bin/furtka-welcome b/assets/bin/furtka-welcome index 691cdde..2ddd3d6 100755 --- a/assets/bin/furtka-welcome +++ b/assets/bin/furtka-welcome @@ -1,22 +1,36 @@ #!/bin/bash # Regenerates /etc/issue on the installed system so the console tells the -# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern. -set -e +# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern: +# runs at boot and every few seconds from furtka-welcome.timer, so the IP +# fallback line is always on screen — with the address once DHCP handed one +# out, or with a "no IP yet" hint before that / after the cable is pulled. +# Only rewrites + nudges agetty when the text changed (no prompt flicker). +set -u -hostname=$(cat /etc/hostname) -ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1) +issue=/etc/issue -{ +hostname=$(cat /etc/hostname 2>/dev/null || hostname) +mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1) + +new=$( echo echo " Furtka is ready." echo echo " Open in a browser on another device on your network:" echo echo " http://${hostname}.local (easy — try this first)" - if [ -n "$ip" ]; then - echo " http://${ip} (fallback if the first doesn't work)" + if ((${#ips[@]})); then + for ip in "${ips[@]}"; do + echo " http://${ip} (fallback if the first doesn't work)" + done + else + echo " (fallback: no IP address yet — waiting for the network." + echo " Check the cable / DHCP. This line updates by itself.)" fi echo -} > /etc/issue +) -agetty --reload 2>/dev/null || true +if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then + printf '%s\n' "$new" > "$issue" + agetty --reload 2>/dev/null || true +fi diff --git a/assets/systemd/furtka-welcome.service b/assets/systemd/furtka-welcome.service index 1fc1e98..5e66b71 100644 --- a/assets/systemd/furtka-welcome.service +++ b/assets/systemd/furtka-welcome.service @@ -1,12 +1,12 @@ [Unit] Description=Furtka console welcome banner -After=network-online.target -Wants=network-online.target +# No network-online dependency on purpose: paint the banner (with a +# "no IP yet" fallback) as early as possible; furtka-welcome.timer re-runs +# it every few seconds so the address appears as soon as DHCP delivers. [Service] Type=oneshot ExecStart=/opt/furtka/current/assets/bin/furtka-welcome -RemainAfterExit=yes [Install] WantedBy=multi-user.target diff --git a/assets/systemd/furtka-welcome.timer b/assets/systemd/furtka-welcome.timer new file mode 100644 index 0000000..7687c9c --- /dev/null +++ b/assets/systemd/furtka-welcome.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Keep the Furtka console welcome (IP fallback) up to date + +[Timer] +OnBootSec=3s +OnUnitActiveSec=5s +AccuracySec=1s + +[Install] +WantedBy=timers.target diff --git a/iso/README.md b/iso/README.md index 64227f9..8dce49b 100644 --- a/iso/README.md +++ b/iso/README.md @@ -27,13 +27,15 @@ The build starts from Arch's stock `releng` profile (the same one used to build | `overlay/airootfs/opt/furtka/` | Directory where `webinstaller/` is copied at build time | | `overlay/airootfs/etc/hostname` | Live-ISO hostname (`proksi`) so mDNS advertises the installer as `proksi.local` | | `overlay/airootfs/etc/issue` | Welcome banner on the TTY pointing users at `http://proksi.local:5000` | -| `overlay/airootfs/usr/local/bin/furtka-update-issue` | Rewrites `/etc/issue` at runtime so the banner also shows the DHCP-assigned IP as a fallback URL | -| `overlay/airootfs/etc/systemd/system/` | `furtka-webinstaller.service` (Flask on :5000) + `furtka-issue.service` (runs the banner-updater on network-online), each symlinked into `multi-user.target.wants/` to auto-start on boot | +| `overlay/airootfs/usr/local/bin/furtka-update-issue` | Rewrites `/etc/issue` at runtime so the banner always carries a fallback line: the DHCP-assigned IP(s), or a "no IP yet — check cable / DHCP" hint until one arrives. Only touches the file (and `agetty --reload`s) when the text changed | +| `overlay/airootfs/etc/systemd/system/` | `furtka-webinstaller.service` (Flask on :5000) + `furtka-issue.service` (banner-updater, first run at boot) in `multi-user.target.wants/`, and `furtka-issue.timer` (re-runs the updater every 5 s) in `timers.target.wants/` | The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under `/opt/furtka`. The `0.0.0.0` binding is important — the Flask default is localhost-only, which wouldn't be reachable from another machine on the LAN. mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live ISO's hostname is `proksi`, and as soon as `systemd-networkd-wait-online` fires the installer is reachable at `http://proksi.local:5000`. The raw IP still shows on the console for fallback — some Windows clients need the Bonjour service for `.local` to resolve at all. +`build.sh` also deletes releng's `getty@tty1.service.d/autologin.conf`. With root auto-logged-in, tty1 belongs to a shell and `agetty --reload` can't redraw the banner, so the IP line was a snapshot of whatever DHCP had done by the time getty started. Without autologin tty1 sits at the login prompt showing the live banner; for a shell type `root` + Enter (releng's root has no password) — the banner says so. + ## Test flow 1. Build: `./iso/build.sh` diff --git a/iso/build.sh b/iso/build.sh index e87a5c2..1587633 100755 --- a/iso/build.sh +++ b/iso/build.sh @@ -45,6 +45,15 @@ cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh" cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/" +# releng auto-logs root in on tty1. That turns our /etc/issue banner into a +# one-shot snapshot: once agetty has handed tty1 to a shell, `agetty --reload` +# from furtka-update-issue has nothing left to redraw, so the IP fallback only +# shows if DHCP happened to beat getty. Drop the drop-in: tty1 stays at the +# login prompt with a live-refreshed banner (furtka-issue.timer), and a shell +# is one `root` + Enter away (releng's root has no password) — the banner says +# so. +rm -rf "$PROFILE_WORK/airootfs/etc/systemd/system/getty@tty1.service.d" + echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)" # releng ships menu entries labelled "Arch Linux install medium" across three # bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to diff --git a/iso/overlay/airootfs/etc/issue b/iso/overlay/airootfs/etc/issue index 42ea414..8c905a3 100644 --- a/iso/overlay/airootfs/etc/issue +++ b/iso/overlay/airootfs/etc/issue @@ -2,5 +2,6 @@ Furtka Live Installer starting… Once ready, open http://proksi.local:5000 on another device - on your network. The exact URL will appear below. + on your network. The exact URL (and an IP fallback) will + appear here in a few seconds. diff --git a/iso/overlay/airootfs/etc/systemd/system/furtka-issue.service b/iso/overlay/airootfs/etc/systemd/system/furtka-issue.service index d1768ed..78cde54 100644 --- a/iso/overlay/airootfs/etc/systemd/system/furtka-issue.service +++ b/iso/overlay/airootfs/etc/systemd/system/furtka-issue.service @@ -1,12 +1,12 @@ [Unit] Description=Write Furtka /etc/issue with current IP for the console welcome -After=network-online.target -Wants=network-online.target +# No network-online dependency on purpose: the first run should paint the +# banner (with a "no IP yet" fallback) as early as possible; the timer +# re-runs it until an address shows up. [Service] Type=oneshot ExecStart=/usr/local/bin/furtka-update-issue -RemainAfterExit=yes [Install] WantedBy=multi-user.target diff --git a/iso/overlay/airootfs/etc/systemd/system/furtka-issue.timer b/iso/overlay/airootfs/etc/systemd/system/furtka-issue.timer new file mode 100644 index 0000000..7687c9c --- /dev/null +++ b/iso/overlay/airootfs/etc/systemd/system/furtka-issue.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Keep the Furtka console welcome (IP fallback) up to date + +[Timer] +OnBootSec=3s +OnUnitActiveSec=5s +AccuracySec=1s + +[Install] +WantedBy=timers.target diff --git a/iso/overlay/airootfs/etc/systemd/system/timers.target.wants/furtka-issue.timer b/iso/overlay/airootfs/etc/systemd/system/timers.target.wants/furtka-issue.timer new file mode 120000 index 0000000..94ed0e9 --- /dev/null +++ b/iso/overlay/airootfs/etc/systemd/system/timers.target.wants/furtka-issue.timer @@ -0,0 +1 @@ +../furtka-issue.timer \ No newline at end of file diff --git a/iso/overlay/airootfs/usr/local/bin/furtka-update-issue b/iso/overlay/airootfs/usr/local/bin/furtka-update-issue index e0b649f..f63565f 100755 --- a/iso/overlay/airootfs/usr/local/bin/furtka-update-issue +++ b/iso/overlay/airootfs/usr/local/bin/furtka-update-issue @@ -2,22 +2,40 @@ # Regenerates /etc/issue so the live-ISO console tells the user which URL # to open in their browser. Shows proksi.local (via avahi/mDNS) as the # preferred URL and the raw IP as a fallback for networks where mDNS -# doesn't work. Reload at the end nudges agetty to redraw. -set -e +# doesn't work. +# +# Runs at boot and then every few seconds from furtka-issue.timer, so the +# fallback line is *always* on screen: with the address once DHCP has +# handed one out, or with a "no IP yet" hint before that / when the cable +# is unplugged. Only rewrites + nudges agetty when the text actually +# changed, so the login prompt doesn't flicker on every tick. +set -u -ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1) +issue=/etc/issue -{ +mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1) + +new=$( echo echo " Open Furtka in a browser on another device on your network:" echo echo " http://proksi.local:5000 (easy — try this first)" - if [ -n "$ip" ]; then - echo " http://${ip}:5000 (fallback if the first doesn't work)" + if ((${#ips[@]})); then + for ip in "${ips[@]}"; do + echo " http://${ip}:5000 (fallback if the first doesn't work)" + done + else + echo " (fallback: no IP address yet — waiting for the network." + echo " Check the cable / DHCP. This line updates by itself.)" fi echo echo " Then follow the wizard to install Furtka on this machine." echo -} > /etc/issue + echo " Need a shell? Log in as root (no password)." + echo +) -agetty --reload 2>/dev/null || true +if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then + printf '%s\n' "$new" > "$issue" + agetty --reload 2>/dev/null || true +fi diff --git a/tests/test_webinstaller_assets.py b/tests/test_webinstaller_assets.py index 041c210..6c80c48 100644 --- a/tests/test_webinstaller_assets.py +++ b/tests/test_webinstaller_assets.py @@ -227,6 +227,25 @@ def test_systemd_units_reference_current_paths(): ) +def test_welcome_banner_is_timer_driven(): + """The console banner must keep refreshing so the IP fallback is always + on screen. A oneshot with RemainAfterExit=yes never re-runs from a timer + (the unit stays 'active'), and a network-online dependency would delay the + first paint — both regressions the hardware bench hit in 26.19.""" + timer = (ASSETS / "systemd" / "furtka-welcome.timer").read_text() + assert "OnUnitActiveSec=" in timer + assert "furtka-welcome.timer" in app._FURTKA_UNITS + service = (ASSETS / "systemd" / "furtka-welcome.service").read_text() + assert "RemainAfterExit" not in service + assert "network-online.target" not in service + + +def test_welcome_banner_always_prints_fallback_line(): + script = (ASSETS / "bin" / "furtka-welcome").read_text() + assert "no IP address yet" in script + assert "fallback" in script + + def test_read_asset_raises_for_missing_file(): with pytest.raises(FileNotFoundError): app._read_asset("does/not/exist.html") diff --git a/webinstaller/app.py b/webinstaller/app.py index 08236a3..d19fcf1 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -279,6 +279,9 @@ def _resource_manager_commands(): file isn't present (dev box without an ISO build), returns [] so the rest of the install still works — the resource manager just won't be installed, and nothing else on the system references furtka-* units. + # Re-runs the welcome banner every few seconds so the console always + # shows the current IP fallback (or a "no IP yet" hint). + "furtka-welcome.timer", """ if not RESOURCE_MANAGER_PAYLOAD.exists(): print( @@ -478,7 +481,7 @@ def build_archinstall_config(s): "docker", # Base OS post-install services. Only packaged units go here — # archinstall runs `systemctl enable` on this list *before* - # custom_commands, so our own furtka-welcome + furtka-status.timer + # custom_commands, so our own furtka-welcome.timer + furtka-status.timer # units (written in custom_commands) are enabled there instead. "caddy", "avahi-daemon",