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] ## [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 ## [26.19-alpha] - 2026-08-23
### Fixed ### Fixed

View file

@ -1,22 +1,36 @@
#!/bin/bash #!/bin/bash
# Regenerates /etc/issue on the installed system so the console tells the # 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. # user which URL to open. Mirrors the live-ISO furtka-update-issue pattern:
set -e # 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) issue=/etc/issue
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
{ 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
echo " Furtka is ready." echo " Furtka is ready."
echo echo
echo " Open in a browser on another device on your network:" echo " Open in a browser on another device on your network:"
echo echo
echo " http://${hostname}.local (easy — try this first)" 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)" 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 fi
echo echo
} > /etc/issue )
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true agetty --reload 2>/dev/null || true
fi

View file

@ -1,12 +1,12 @@
[Unit] [Unit]
Description=Furtka console welcome banner Description=Furtka console welcome banner
After=network-online.target # No network-online dependency on purpose: paint the banner (with a
Wants=network-online.target # "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] [Service]
Type=oneshot Type=oneshot
ExecStart=/opt/furtka/current/assets/bin/furtka-welcome ExecStart=/opt/furtka/current/assets/bin/furtka-welcome
RemainAfterExit=yes
[Install] [Install]
WantedBy=multi-user.target 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/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/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/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/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` (runs the banner-updater on network-online), each symlinked into `multi-user.target.wants/` to auto-start on boot | | `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. 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. 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 ## Test flow
1. Build: `./iso/build.sh` 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/" 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)" echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)"
# releng ships menu entries labelled "Arch Linux install medium" across three # releng ships menu entries labelled "Arch Linux install medium" across three
# bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to # bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to

View file

@ -2,5 +2,6 @@
Furtka Live Installer starting… Furtka Live Installer starting…
Once ready, open http://proksi.local:5000 on another device 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] [Unit]
Description=Write Furtka /etc/issue with current IP for the console welcome Description=Write Furtka /etc/issue with current IP for the console welcome
After=network-online.target # No network-online dependency on purpose: the first run should paint the
Wants=network-online.target # banner (with a "no IP yet" fallback) as early as possible; the timer
# re-runs it until an address shows up.
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/bin/furtka-update-issue ExecStart=/usr/local/bin/furtka-update-issue
RemainAfterExit=yes
[Install] [Install]
WantedBy=multi-user.target 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 # 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 # 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 # preferred URL and the raw IP as a fallback for networks where mDNS
# doesn't work. Reload at the end nudges agetty to redraw. # doesn't work.
set -e #
# 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
echo " Open Furtka in a browser on another device on your network:" echo " Open Furtka in a browser on another device on your network:"
echo echo
echo " http://proksi.local:5000 (easy — try this first)" 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)" 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 fi
echo echo
echo " Then follow the wizard to install Furtka on this machine." echo " Then follow the wizard to install Furtka on this machine."
echo echo
} > /etc/issue echo " Need a shell? Log in as root (no password)."
echo
)
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true 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(): def test_read_asset_raises_for_missing_file():
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
app._read_asset("does/not/exist.html") 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 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, of the install still works the resource manager just won't be installed,
and nothing else on the system references furtka-* units. 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(): if not RESOURCE_MANAGER_PAYLOAD.exists():
print( print(
@ -478,7 +481,7 @@ def build_archinstall_config(s):
"docker", "docker",
# Base OS post-install services. Only packaged units go here — # Base OS post-install services. Only packaged units go here —
# archinstall runs `systemctl enable` on this list *before* # 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. # units (written in custom_commands) are enabled there instead.
"caddy", "caddy",
"avahi-daemon", "avahi-daemon",