Compare commits

..

7 commits

Author SHA1 Message Date
cdc0c3c6fd chore: release 26.21-alpha
All checks were successful
Deploy site / deploy (push) Successful in 5s
CI / lint (push) Successful in 30s
CI / test (push) Successful in 2m55s
CI / validate-json (push) Successful in 33s
CI / markdown-links (push) Successful in 21s
Build ISO / build-iso (push) Successful in 22m11s
Release / release (push) Successful in 16m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016cfh8U3WV6jgc1MHH1FyAB
2026-10-23 16:57:05 +02:00
7053ef290c Merge pull request 'fix: pin archinstall, Limine on 32-bit UEFI, hostname-scoped 302 HTTPS redirect' (#20) from fix/followups-archinstall-ia32-https into main 2026-10-23 16:53:57 +02:00
24beffeaf4 style: polish on-box UI (sticky nav, softer cards, status tiles, app tile hover)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:42:46 +02:00
ff5fe6f1c6 docs(changelog): archinstall pin, 32-bit UEFI, HTTPS redirect
All checks were successful
CI / lint (pull_request) Successful in 26s
CI / test (pull_request) Successful in 1m45s
CI / validate-json (pull_request) Successful in 24s
CI / markdown-links (pull_request) Successful in 21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
2026-09-28 14:24:16 +02:00
2468cb3ded fix(https): redirect only the certificate's hostnames, with 302
The force-HTTPS snippet was 'redir https://{host}{uri} permanent':
- every Host was redirected, including a bare IP that the tls-internal
  block has no certificate for — devices reaching the box by IP (no mDNS
  on Android / older Windows, the console IP fallback) couldn't load the
  UI at all
- 301 is cached by browsers, so switching HTTPS off kept bouncing them to
  a dead :443

Now a host matcher limits the redirect to <hostname>.local and <hostname>
and it's a 302. Self-update rewrites the old snippet if it is still the
untouched legacy content. Browsers that already cached the old 301 keep it
until their cache is cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
2026-09-28 14:21:14 +02:00
455150f97f fix(installer): use Limine on 32-bit UEFI firmware
Atom Bay Trail / Cherry Trail mini-PCs and tablets have a 64-bit CPU behind
32-bit UEFI. We picked systemd-boot there (Arch ships no ia32 build), and
archinstall 4.4's GRUB path hardcodes --target=x86_64-efi, so the installed
system could not boot. archinstall's Limine path copies both BOOTIA32.EFI
and BOOTX64.EFI and chooses the entry by /sys/firmware/efi/fw_platform_size;
the default layout's FAT /boot satisfies its layout check. 64-bit UEFI and
BIOS keep systemd-boot / GRUB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
2026-09-28 14:21:14 +02:00
bfa7ee8cba build(iso): pin archinstall 4.4-1 from the Arch archive
The webinstaller imports archinstall internals and sed-patches one of its
files, so every ISO build silently picked up whatever archinstall Arch
shipped that day — any upstream release could break all installs with no
change on our side. build.sh now downloads the validated build from
archive.archlinux.org, checks its sha256, and serves it from a local
[furtka-pinned] repo ahead of [core] in the build's pacman.conf.

The build fails with a clear message once Arch's Python moves past the one
the pinned package targets. Verified in an archlinux:latest container:
pacman resolves 'furtka-pinned archinstall 4.4-1'. Bump steps in
iso/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
2026-09-28 14:14:59 +02:00
12 changed files with 272 additions and 35 deletions

View file

@ -7,6 +7,8 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased] ## [Unreleased]
## [26.21-alpha] - 2026-10-23
### Added ### Added
- **New logo: Mark M** (a lowercase *f* cut out of an arched gate), winner of - **New logo: Mark M** (a lowercase *f* cut out of an arched gate), winner of
@ -62,6 +64,13 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
full. full.
- **Apps page after an API restart** redirects to `/login` instead of - **Apps page after an API restart** redirects to `/login` instead of
showing an empty app list. showing an empty app list.
- **32-bit UEFI machines** (Atom Bay Trail / Cherry Trail) get Limine
instead of systemd-boot: Arch has no ia32 systemd-boot and archinstall's
GRUB path hardcodes `x86_64-efi`, so these boxes installed but didn't boot.
- **Force-HTTPS redirect** only covers `<hostname>.local` / `<hostname>` and
is a 302. The old 301 for every host sent devices that use the IP to a
TLS handshake that can't succeed, and stayed cached after HTTPS was
turned off. Self-update migrates the old snippet.
- **Self-update starts newly added timers immediately and links units - **Self-update starts newly added timers immediately and links units
through `current`.** `_link_new_units()` enabled a timer a release through `current`.** `_link_new_units()` enabled a timer a release
@ -80,6 +89,12 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
password hint explains it's also the SSH/console password, names are password hint explains it's also the SSH/console password, names are
lowercased instead of rejected, system account names are refused. lowercased instead of rejected, system account names are refused.
- Live console banner says a network cable with internet is required. - Live console banner says a network cable with internet is required.
- ISO builds pin archinstall (4.4-1, sha256-checked from the Arch archive)
instead of taking whatever the repos ship that day; the build fails
clearly when Arch's Python moves on. Bump steps in `iso/README.md`.
- On-box UI polish: sticky top nav, bordered cards and status tiles, app
tile hover lift, brand-red section markers; animations respect
`prefers-reduced-motion`.
## [26.20-alpha] - 2026-08-25 ## [26.20-alpha] - 2026-08-25
@ -635,7 +650,8 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
- **Containers:** Docker + Compose - **Containers:** Docker + Compose
- **License:** AGPL-3.0 - **License:** AGPL-3.0
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.20-alpha...HEAD [Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.21-alpha...HEAD
[26.21-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.21-alpha
[26.20-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.20-alpha [26.20-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.20-alpha
[26.19-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.19-alpha [26.19-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.19-alpha
[26.18-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.18-alpha [26.18-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.18-alpha

View file

@ -21,7 +21,13 @@
--r-pill: 999px; --r-pill: 999px;
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-lift: 0 8px 24px rgba(0, 0, 0, 0.35);
--ring: 0 0 0 2px var(--accent); --ring: 0 0 0 2px var(--accent);
/* Brand red from the logo, used only for small highlights. */
--brand: #ff6b56;
--glow-a: rgba(255, 107, 86, 0.10);
--glow-b: rgba(110, 231, 183, 0.08);
--nav-bg: rgba(15, 17, 21, 0.72);
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
@ -38,6 +44,11 @@
--warn-fg: #5a2a10; --warn-fg: #5a2a10;
--danger: #c03a28; --danger: #c03a28;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08); --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-lift: 0 8px 24px rgba(0, 0, 0, 0.10);
--brand: #c03a28;
--glow-a: rgba(192, 58, 40, 0.07);
--glow-b: rgba(15, 138, 95, 0.06);
--nav-bg: rgba(247, 246, 243, 0.8);
} }
} }
@ -45,9 +56,16 @@
body { body {
margin: 0; margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); /* Two soft glows at the top (logo red, accent green) give the page some
depth without competing with the content. */
background:
radial-gradient(60rem 28rem at 15% -10%, var(--glow-a), transparent 70%),
radial-gradient(50rem 24rem at 95% -5%, var(--glow-b), transparent 70%),
var(--bg);
background-attachment: fixed;
color: var(--fg); color: var(--fg);
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased;
} }
/* Shared page container — both landing and /apps wrap content in /* Shared page container — both landing and /apps wrap content in
@ -59,9 +77,16 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding-bottom: 1.25rem; padding: 0.85rem 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
margin-bottom: 2rem; margin-bottom: 2rem;
/* Stays in reach while scrolling long pages like /settings. */
position: sticky;
top: 0;
z-index: 20;
background: var(--nav-bg);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
} }
.brand { .brand {
font-weight: 700; font-weight: 700;
@ -91,30 +116,52 @@ body {
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.9rem;
padding: 0.35rem 0.75rem; padding: 0.35rem 0.75rem;
border-radius: var(--r-sm); border-radius: var(--r-pill);
transition: color 120ms, background 120ms;
} }
.nav-links a:hover { color: var(--fg); } .nav-links a:hover { color: var(--fg); background: var(--card); }
.nav-links a[aria-current="page"] { .nav-links a[aria-current="page"] {
color: var(--fg); color: var(--fg);
background: var(--accent-soft); background: var(--accent-soft);
} }
/* -- Landing page ---------------------------------------------- */ /* -- Landing page ---------------------------------------------- */
header h1 { margin: 0 0 0.5rem; font-size: 2.5rem; } header h1 {
margin: 0 0 0.5rem;
font-size: clamp(2rem, 6vw, 2.75rem);
letter-spacing: -0.02em;
line-height: 1.1;
background: linear-gradient(100deg, var(--fg) 40%, var(--accent));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; } .lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; }
.host { color: var(--muted); margin: 0 0 3rem; } .host { color: var(--muted); margin: 0 0 3rem; }
.host code { .host code {
background: var(--card); background: var(--accent-soft);
padding: 0.15rem 0.5rem; padding: 0.2rem 0.65rem;
border-radius: var(--r-sm); border-radius: var(--r-pill);
color: var(--accent); color: var(--accent);
font-size: 0.9em;
} }
section h2 { section h2 {
font-size: 1.1rem; font-size: 0.85rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.12em;
color: var(--muted); color: var(--muted);
margin: 2rem 0 1rem; margin: 2.5rem 0 1rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
/* Small brand-red marker in front of each landing-page section title. */
section h2::before {
content: "";
width: 0.45rem;
height: 0.45rem;
border-radius: var(--r-pill);
background: var(--brand);
} }
.tiles { .tiles {
display: grid; display: grid;
@ -124,9 +171,21 @@ section h2 {
.tile { .tile {
background: var(--card); background: var(--card);
padding: 1.25rem; padding: 1.25rem;
border-radius: var(--r-md); border: 1px solid var(--border);
border-radius: var(--r-lg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
overflow: hidden;
}
/* Thin accent line along the top edge of each status tile. */
.tile::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), transparent);
opacity: 0.7;
} }
.tile .label { .tile .label {
font-size: 0.8rem; font-size: 0.8rem;
@ -134,7 +193,12 @@ section h2 {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.tile .value { font-size: 1.25rem; margin-top: 0.5rem; } .tile .value {
font-size: 1.35rem;
font-weight: 600;
margin-top: 0.5rem;
font-variant-numeric: tabular-nums;
}
.updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; } .updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; }
.soon { .soon {
background: var(--card); background: var(--card);
@ -172,7 +236,9 @@ h2 {
.app { .app {
background: var(--card); background: var(--card);
padding: 1rem; padding: 1rem;
border-radius: var(--r-md); border: 1px solid var(--border);
border-radius: var(--r-lg);
transition: border-color 120ms;
margin: 0.5rem 0; margin: 0.5rem 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -219,7 +285,10 @@ button, .btn {
text-decoration: none; text-decoration: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
transition: filter 120ms, transform 80ms;
} }
button:hover:not(:disabled), .btn:hover { filter: brightness(1.08); }
button:active:not(:disabled), .btn:active { transform: translateY(1px); }
button.secondary, .btn.secondary { button.secondary, .btn.secondary {
background: var(--card); background: var(--card);
color: var(--fg); color: var(--fg);
@ -451,7 +520,8 @@ details.log-details[open] > summary { color: var(--fg); }
.card { .card {
background: var(--card); background: var(--card);
padding: 1.25rem; padding: 1.25rem;
border-radius: var(--r-md); border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
.card + .card { margin-top: 1rem; } .card + .card { margin-top: 1rem; }
@ -506,9 +576,9 @@ details.log-details[open] > summary { color: var(--fg); }
gap: 0.75rem; gap: 0.75rem;
} }
.app-tile { .app-tile {
background: var(--bg); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--r-md); border-radius: var(--r-lg);
padding: 1rem; padding: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -516,12 +586,20 @@ details.log-details[open] > summary { color: var(--fg); }
align-items: flex-start; align-items: flex-start;
text-decoration: none; text-decoration: none;
color: var(--fg); color: var(--fg);
transition: border-color 120ms, background 120ms; transition: border-color 120ms, background 120ms, transform 120ms, box-shadow 120ms;
}
.app-tile:hover {
border-color: var(--accent);
background: var(--card-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
} }
.app-tile:hover { border-color: var(--accent); background: var(--card-hover); }
.app-tile .icon { .app-tile .icon {
width: 40px; width: 48px;
height: 40px; height: 48px;
padding: 6px;
border-radius: var(--r-md);
background: var(--accent-soft);
color: var(--accent); color: var(--accent);
display: flex; display: flex;
align-items: center; align-items: center;
@ -549,6 +627,13 @@ details.log-details[open] > summary { color: var(--fg); }
.app-icon svg { width: 36px; height: 36px; } .app-icon svg { width: 36px; height: 36px; }
.login-brand { margin: 0 0 1.5rem; font-size: 1.2rem; } .login-brand { margin: 0 0 1.5rem; font-size: 1.2rem; }
.app:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; }
.app-tile:hover { transform: none; }
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
.brand::before { background: #c03a28; } .brand::before { background: #c03a28; }
} }

View file

@ -36,7 +36,11 @@ from pathlib import Path
CA_CERT_PATH = Path("/var/lib/caddy/pki/authorities/local/root.crt") CA_CERT_PATH = Path("/var/lib/caddy/pki/authorities/local/root.crt")
SNIPPET_DIR = Path("/etc/caddy/furtka.d") SNIPPET_DIR = Path("/etc/caddy/furtka.d")
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile" REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile"
REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n" # Pre-26.21 redirect: permanent (browsers cache a 301 indefinitely, so
# turning HTTPS off left them bouncing to a dead :443) and for every Host —
# including a bare IP, which has no certificate, so devices that reach the
# box by IP (no mDNS on Android / older Windows) couldn't load the UI at all.
LEGACY_REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d") HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d")
HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile" HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile"
HOSTNAME_FILE = Path("/etc/hostname") HOSTNAME_FILE = Path("/etc/hostname")
@ -62,6 +66,19 @@ def _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str:
return value or "furtka" return value or "furtka"
def _redirect_snippet_content(hostname: str) -> str:
"""HTTP→HTTPS redirect for the names the HTTPS block has a cert for.
Only those hosts are redirected: an IP or any other name keeps plain
HTTP instead of landing on a TLS handshake that can't succeed. 302 so
switching HTTPS off takes effect in browsers immediately.
"""
return (
f"@furtka_https_host host {hostname}.local {hostname}\n"
"redir @furtka_https_host https://{host}{uri} 302\n"
)
def _https_snippet_content(hostname: str) -> str: def _https_snippet_content(hostname: str) -> str:
"""Caddy site block the HTTPS toggle installs at opt-in. """Caddy site block the HTTPS toggle installs at opt-in.
@ -151,8 +168,9 @@ def set_force_https(
previous_https = https_snippet.read_text() if had_https else None previous_https = https_snippet.read_text() if had_https else None
if enabled: if enabled:
snippet.write_text(REDIRECT_CONTENT) hostname = _read_hostname(hostname_file)
https_snippet.write_text(_https_snippet_content(_read_hostname(hostname_file))) snippet.write_text(_redirect_snippet_content(hostname))
https_snippet.write_text(_https_snippet_content(hostname))
else: else:
if had_redirect: if had_redirect:
snippet.unlink() snippet.unlink()

View file

@ -192,6 +192,20 @@ def _maybe_migrate_preserve_https() -> None:
) )
def _maybe_migrate_redirect_snippet() -> None:
"""26.21 migration: swap the old catch-all 301 redirect for the
hostname-scoped 302 one, so boxes reached by IP can load the UI again
and turning HTTPS off stops sticking in browser caches."""
from furtka import https
redirect_snippet = _CADDY_SNIPPET_DIR / "redirect.caddyfile"
if not redirect_snippet.is_file():
return
if redirect_snippet.read_text() != https.LEGACY_REDIRECT_CONTENT:
return
redirect_snippet.write_text(https._redirect_snippet_content(_current_hostname()))
def _refresh_caddyfile(source: Path) -> bool: def _refresh_caddyfile(source: Path) -> bool:
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True """Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
if the file changed (so caddy needs more than a bare reload). if the file changed (so caddy needs more than a bare reload).
@ -215,6 +229,7 @@ def _refresh_caddyfile(source: Path) -> bool:
# hostname block is gone, so the redirect would 301 to a dead :443. # hostname block is gone, so the redirect would 301 to a dead :443.
# Preserve intent by writing the HTTPS listener snippet too. # Preserve intent by writing the HTTPS listener snippet too.
_maybe_migrate_preserve_https() _maybe_migrate_preserve_https()
_maybe_migrate_redirect_snippet()
rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname()) rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname())
if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text(): if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text():
return False return False

View file

@ -34,6 +34,17 @@ The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under
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.
### archinstall is pinned
The webinstaller imports archinstall internals and `furtka-webinstaller.service` sed-patches one of its files, so `build.sh` does not take whatever archinstall the Arch repos currently ship. It downloads the version in `ARCHINSTALL_PIN` from `archive.archlinux.org`, checks it against `ARCHINSTALL_SHA256`, and serves it from a local `[furtka-pinned]` repo listed before `[core]` in the build's `pacman.conf` (pacman takes a package from the first repo that has it). Only the build uses that repo; the live system's own `pacman.conf` is untouched.
The build fails with "pinned archinstall … targets Python X, Arch now ships Y" once Arch moves to a new Python — the pinned package would install into a dead `site-packages`. To bump:
1. Pick the new version from <https://archive.archlinux.org/packages/a/archinstall/>.
2. `curl -LO` the `.pkg.tar.zst`, `sha256sum` it, and update `ARCHINSTALL_PIN` + `ARCHINSTALL_SHA256` at the top of `build.sh`.
3. Check that `webinstaller/app.py:build_disk_config` still imports and that the `ExecStartPre` sed in `furtka-webinstaller.service` still matches (`journalctl -u furtka-webinstaller` on the live ISO).
4. Full VM install (UEFI and BIOS) before merging.
`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. `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

View file

@ -27,7 +27,11 @@ fi
# ---- inside the container from here on ---- # ---- inside the container from here on ----
echo "==> Syncing pacman, installing archiso" echo "==> Syncing pacman, installing archiso"
pacman -Syu --noconfirm --needed archiso pacman -Syu --noconfirm --needed archiso curl zstd
# archinstall build the webinstaller is validated against (see pin step below).
ARCHINSTALL_PIN="4.4-1"
ARCHINSTALL_SHA256="6cb5da75c108ef2f5d25a7a20baf6c52cc927d5e06890dd10182e6eb4be815cb"
PROFILE_SRC="/usr/share/archiso/configs/releng" PROFILE_SRC="/usr/share/archiso/configs/releng"
PROFILE_WORK="/tmp/furtka-profile" PROFILE_WORK="/tmp/furtka-profile"
@ -43,6 +47,37 @@ cat "$SCRIPT_DIR/overlay/packages.extra" >> "$PROFILE_WORK/packages.x86_64"
cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh" cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh"
echo "==> Pinning archinstall ${ARCHINSTALL_PIN}"
# The webinstaller imports archinstall internals (build_disk_config) and
# sed-patches one of its files at service start, so a silent archinstall
# upgrade in the Arch repos can break every install with no change on our
# side. Serve the validated build from a local repo listed *before* [core]:
# pacman takes a package from the first repo that has it, whatever the
# version elsewhere. Bump deliberately: new version + sha256 below, rebuild,
# run the smoke VM (see iso/README.md).
PINNED_REPO="/tmp/furtka-pinned-repo"
rm -rf "$PINNED_REPO" && mkdir -p "$PINNED_REPO"
ARCHINSTALL_PKG="archinstall-${ARCHINSTALL_PIN}-any.pkg.tar.zst"
curl -fsSL --retry 3 -o "$PINNED_REPO/$ARCHINSTALL_PKG" \
"https://archive.archlinux.org/packages/a/archinstall/$ARCHINSTALL_PKG"
echo "${ARCHINSTALL_SHA256} $PINNED_REPO/$ARCHINSTALL_PKG" | sha256sum -c -
# archinstall is an "any" package installed under /usr/lib/python3.X; after
# Arch moves to a new Python it would install into a dead directory. Fail
# the build with a clear message instead of shipping a broken installer.
pkg_py=$(tar --zstd -tf "$PINNED_REPO/$ARCHINSTALL_PKG" \
| sed -n 's|^usr/lib/python\(3\.[0-9]*\)/.*|\1|p' | sort -u)
repo_py=$(pacman -Si python | awk -F': ' '/^Version/ {split($2, v, "."); print v[1] "." v[2]}')
if [ "$pkg_py" != "$repo_py" ]; then
echo "ERROR: pinned archinstall ${ARCHINSTALL_PIN} targets Python $pkg_py, Arch now ships $repo_py." >&2
echo " Bump ARCHINSTALL_PIN/ARCHINSTALL_SHA256 in iso/build.sh (see iso/README.md)." >&2
exit 1
fi
repo-add -q "$PINNED_REPO/furtka-pinned.db.tar.gz" "$PINNED_REPO/$ARCHINSTALL_PKG"
# The sha256 above already pins the exact bytes.
sed -i "0,/^\[core\]/s||[furtka-pinned]\nSigLevel = Optional TrustAll\nServer = file://$PINNED_REPO\n\n[core]|" \
"$PROFILE_WORK/pacman.conf"
grep -q '^\[furtka-pinned\]' "$PROFILE_WORK/pacman.conf"
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 # releng auto-logs root in on tty1. That turns our /etc/issue banner into a

View file

@ -1,6 +1,6 @@
[project] [project]
name = "furtka" name = "furtka"
version = "26.20-alpha" version = "26.21-alpha"
description = "Open-source home server OS — simple enough for everyone." description = "Open-source home server OS — simple enough for everyone."
requires-python = ">=3.11" requires-python = ">=3.11"
readme = "README.md" readme = "README.md"

View file

@ -390,3 +390,16 @@ def test_error_page_offers_retry(monkeypatch, tmp_path):
html = app_module.app.test_client().get("/install/log").get_data(as_text=True) html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "Try again" in html assert "Try again" in html
assert "next-steps" not in html assert "next-steps" not in html
def test_detect_bootloader_by_firmware(tmp_path):
import app as app_module
efi = tmp_path / "efi"
assert app_module._detect_bootloader(efi) == "Grub" # BIOS / legacy
efi.mkdir()
assert app_module._detect_bootloader(efi) == "Systemd-boot" # no bitness file
(efi / "fw_platform_size").write_text("64\n")
assert app_module._detect_bootloader(efi) == "Systemd-boot"
(efi / "fw_platform_size").write_text("32\n")
assert app_module._detect_bootloader(efi) == "Limine"

View file

@ -113,7 +113,7 @@ def test_set_force_enable_writes_both_snippets_and_reloads(tmp_path):
result = https.set_force_https(True, reload_caddy=fake_reload, **p) result = https.set_force_https(True, reload_caddy=fake_reload, **p)
assert result is True assert result is True
assert p["snippet"].read_text() == https.REDIRECT_CONTENT assert p["snippet"].read_text() == https._redirect_snippet_content("testbox")
written = p["https_snippet"].read_text() written = p["https_snippet"].read_text()
assert "testbox.local, testbox" in written assert "testbox.local, testbox" in written
assert "tls internal" in written assert "tls internal" in written
@ -135,7 +135,7 @@ def test_set_force_disable_removes_both_snippets(tmp_path):
p = _paths(tmp_path) p = _paths(tmp_path)
p["snippet_dir"].mkdir() p["snippet_dir"].mkdir()
p["https_snippet_dir"].mkdir() p["https_snippet_dir"].mkdir()
p["snippet"].write_text(https.REDIRECT_CONTENT) p["snippet"].write_text(https._redirect_snippet_content("testbox"))
p["https_snippet"].write_text("furtka.local { tls internal }\n") p["https_snippet"].write_text("furtka.local { tls internal }\n")
result = https.set_force_https(False, reload_caddy=lambda: None, **p) result = https.set_force_https(False, reload_caddy=lambda: None, **p)
@ -199,10 +199,16 @@ def test_systemctl_missing_raises_and_rolls_back(tmp_path):
assert not p["https_snippet"].exists() assert not p["https_snippet"].exists()
def test_redirect_snippet_content_is_caddy_redir_directive(): def test_redirect_snippet_only_covers_cert_names_and_is_temporary():
# Lock the exact directive. A regression here silently stops the # Lock the exact directives. A regression here silently stops the
# redirect from taking effect even though the file-swap looks fine. # redirect from taking effect even though the file-swap looks fine.
assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent" # Only the names the tls-internal block has a cert for are redirected
# (an IP would hit a failing TLS handshake), and 302 so switching HTTPS
# off isn't stuck in browser caches like the old 301 was.
assert https._redirect_snippet_content("mybox") == (
"@furtka_https_host host mybox.local mybox\n"
"redir @furtka_https_host https://{host}{uri} 302\n"
)
def test_https_snippet_content_has_tls_internal_and_routes(tmp_path): def test_https_snippet_content_has_tls_internal_and_routes(tmp_path):

View file

@ -539,3 +539,31 @@ def test_health_check_survives_read_timeout(updater, monkeypatch):
monkeypatch.setattr("urllib.request.urlopen", slow) monkeypatch.setattr("urllib.request.urlopen", slow)
monkeypatch.setattr(updater.time, "sleep", lambda _s: None) monkeypatch.setattr(updater.time, "sleep", lambda _s: None)
assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=0.05) is False assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=0.05) is False
def test_refresh_caddyfile_migrates_legacy_301_redirect(updater, tmp_path):
from furtka import https
snippet_dir = tmp_path / "etc_caddy" / "furtka.d"
snippet_dir.mkdir(parents=True)
(tmp_path / "etc_caddy" / "furtka-https.d").mkdir()
(tmp_path / "etc_caddy" / "furtka-https.d" / "https.caddyfile").write_text("x\n")
redirect = snippet_dir / "redirect.caddyfile"
redirect.write_text(https.LEGACY_REDIRECT_CONTENT)
src = tmp_path / "Caddyfile.src"
src.write_text("# caddy\n")
updater._refresh_caddyfile(src)
assert redirect.read_text() == https._redirect_snippet_content("testbox")
def test_refresh_caddyfile_leaves_custom_redirect_alone(updater, tmp_path):
snippet_dir = tmp_path / "etc_caddy" / "furtka.d"
snippet_dir.mkdir(parents=True)
(tmp_path / "etc_caddy" / "furtka-https.d").mkdir()
(tmp_path / "etc_caddy" / "furtka-https.d" / "https.caddyfile").write_text("x\n")
redirect = snippet_dir / "redirect.caddyfile"
redirect.write_text("# hand-edited\n")
src = tmp_path / "Caddyfile.src"
src.write_text("# caddy\n")
updater._refresh_caddyfile(src)
assert redirect.read_text() == "# hand-edited\n"

View file

@ -515,10 +515,20 @@ def _post_install_commands(hostname, admin_username, admin_password):
] ]
def _detect_bootloader(): def _detect_bootloader(efi_dir=Path("/sys/firmware/efi")):
# systemd-boot is UEFI-only; on BIOS/legacy it trips HardwareIncompatibilityError # systemd-boot is UEFI-only; on BIOS/legacy it trips HardwareIncompatibilityError
# inside archinstall. /sys/firmware/efi exists iff we were booted via UEFI. # inside archinstall. /sys/firmware/efi exists iff we were booted via UEFI.
return "Systemd-boot" if Path("/sys/firmware/efi").exists() else "Grub" if not efi_dir.exists():
return "Grub"
# 64-bit CPU behind 32-bit UEFI (Atom Bay Trail / Cherry Trail mini-PCs
# and tablets): Arch ships no ia32 systemd-boot, and archinstall's GRUB
# path hardcodes --target=x86_64-efi. Its Limine path installs both
# BOOTIA32.EFI and BOOTX64.EFI and picks the entry by firmware bitness.
try:
bitness = (efi_dir / "fw_platform_size").read_text().strip()
except OSError:
bitness = "64"
return "Limine" if bitness == "32" else "Systemd-boot"
def build_archinstall_config(s): def build_archinstall_config(s):

View file

@ -6,7 +6,7 @@ enableRobotsTXT = true
[params] [params]
description = "Open-source home server OS — simple enough for everyone." description = "Open-source home server OS — simple enough for everyone."
version = "26.20-alpha" version = "26.21-alpha"
contactEmail = "hallo@furtka.org" contactEmail = "hallo@furtka.org"
# Forgejo repo the download page reads its latest release (ISO asset, # Forgejo repo the download page reads its latest release (ISO asset,
# size, date, checksum) from at build time. `version` above is only the # size, date, checksum) from at build time. `version` above is only the