fix(console): always show the IP fallback on the welcome banner

The /etc/issue banner on both the live ISO and the installed system was a
oneshot after network-online.target that simply dropped the http://<ip>
line when DHCP hadn't delivered yet — so a slow NIC (first hardware bench,
2026-08-24) left the screen offering only proksi.local. On the ISO,
releng's root autologin on tty1 also made agetty --reload a no-op.

- furtka-issue.timer (ISO) / furtka-welcome.timer (target) re-run the
  banner script every 5 s; services drop RemainAfterExit + network wait
- fallback line is always present: every global IPv4, or a 'no IP yet —
  check cable / DHCP' hint; only rewrites + reloads on change
- build.sh removes releng's getty@tty1 autologin drop-in; banner tells
  users a shell is root + Enter away

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-08-25 15:18:39 +02:00
parent e1968facef
commit 0e05bef667
13 changed files with 133 additions and 27 deletions

View file

@ -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://<ip>` 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

View file

@ -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
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

View file

@ -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

View file

@ -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

View file

@ -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`

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
../furtka-issue.timer

View file

@ -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
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

View file

@ -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")

View file

@ -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",