Compare commits

..

8 commits

Author SHA1 Message Date
0996262fee docs(readme): gateway shipped in 26.21-alpha, roadmap + key decision
All checks were successful
CI / lint (push) Successful in 27s
CI / test (push) Successful in 1m46s
CI / validate-json (push) Successful in 25s
CI / markdown-links (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:07:04 +02:00
c00a01e940 docs(changelog): add Furtka Gateway to 26.21-alpha
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / validate-json (push) Waiting to run
CI / markdown-links (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:06:40 +02:00
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
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 180 additions and 16 deletions

View file

@ -7,8 +7,19 @@ 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
- **Furtka Gateway: apps reachable from the internet** (behind NAT/CGNAT,
no port forwarding). Settings → *Furtka Gateway*: enter the gateway URL
and registration token, the box registers and keeps a WireGuard tunnel to
a [furtka-gateway](https://forgejo.sourcegate.online/robert/furtka-gateway)
server (Traefik + wg-easy + control plane). Apps → ⋯ → *Expose to
Internet* publishes an app under its own hostname with Let's Encrypt;
*Stop exposing* takes it down again. Only apps whose manifest declares
`internet.viable: true` can be exposed. Client in `furtka/gateway.py` +
`furtka/wireguard.py`, CLI `furtka gateway …`.
- **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
the furtka.org vote (29 of 42 votes). It is now in the website header, the furtka.org vote (29 of 42 votes). It is now in the website header,
favicon and share card, the installer header and tab icon, the on-box UI favicon and share card, the installer header and tab icon, the on-box UI
@ -62,6 +73,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 +98,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 +659,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

@ -56,7 +56,7 @@ Furtka aims to be:
| Identity provider | Authentik | Bundled SSO from day one — every app template auto-wires to it at install | | Identity provider | Authentik | Bundled SSO from day one — every app template auto-wires to it at install |
| Managed gateway DNS | NS delegation to `ns1.furtka.org` | User delegates once at registrar; we handle wildcard cert + subdomain creation | | Managed gateway DNS | NS delegation to `ns1.furtka.org` | User delegates once at registrar; we handle wildcard cert + subdomain creation |
| Local HTTPS | Local CA | One-click CA install → green padlock on every service, no browser warnings | | Local HTTPS | Local CA | One-click CA install → green padlock on every service, no browser warnings |
| Gateway | Flexible | Own reverse proxy OR managed through our infrastructure | | Gateway | Traefik + wg-easy ([furtka-gateway](https://forgejo.sourcegate.online/robert/furtka-gateway)) | Decided 2026-10-23. Self-hosted on any VPS with a public IPv4 via `deploy.sh`, or managed through our infrastructure later. Boxes dial out over WireGuard, so no port forwarding at home. |
| UI approach | UI-first | Design the simplest possible UI, then build everything to match | | UI approach | UI-first | Design the simplest possible UI, then build everything to match |
## Landscape (Existing Projects) ## Landscape (Existing Projects)
@ -122,10 +122,13 @@ None of these nail the "your dad can set this up" experience. The installer wiza
- [x] **Phase 2 Furtka self-update** — `/settings` → Check → Update now. Downloads signed tarball (SHA256), stages, atomic symlink flip, reloads Caddy, daemon-reload, restarts services, health-checks the new api with auto-rollback on failure. CLI: `furtka update [--check]` + `furtka rollback`. Validated end-to-end on VM 2026-04-16 (`26.0-alpha` → `26.3-alpha` → rollback → reboot). - [x] **Phase 2 Furtka self-update** — `/settings` → Check → Update now. Downloads signed tarball (SHA256), stages, atomic symlink flip, reloads Caddy, daemon-reload, restarts services, health-checks the new api with auto-rollback on failure. CLI: `furtka update [--check]` + `furtka rollback`. Validated end-to-end on VM 2026-04-16 (`26.0-alpha` → `26.3-alpha` → rollback → reboot).
- [x] **Local HTTPS Phase 1** — Caddy `tls internal` on `:443` is fully opt-in via the `/settings` toggle (26.15-alpha); fresh installs stay HTTP-only so a half-trusted cert chain can't lock the user out. Per-box root CA generated on first enable, `rootCA.crt` downloadable from `/settings`, per-OS install guide at `/https-install/`. The "force HTTPS" sub-toggle still only appears once the current browser already trusts the cert. - [x] **Local HTTPS Phase 1** — Caddy `tls internal` on `:443` is fully opt-in via the `/settings` toggle (26.15-alpha); fresh installs stay HTTP-only so a half-trusted cert chain can't lock the user out. Per-box root CA generated on first enable, `rootCA.crt` downloadable from `/settings`, per-OS install guide at `/https-install/`. The "force HTTPS" sub-toggle still only appears once the current browser already trusts the cert.
- [x] **Post-build smoke VM on Proxmox** — `.forgejo/workflows/build-iso.yml` hands the freshly built ISO to `scripts/smoke-vm.sh`, which boots it in a throwaway VM on `pollux` (192.168.178.165) and curls the webinstaller on `:5000`. VMID range 9000–9099, last 5 kept. Green end-to-end since 26.4-alpha. - [x] **Post-build smoke VM on Proxmox** — `.forgejo/workflows/build-iso.yml` hands the freshly built ISO to `scripts/smoke-vm.sh`, which boots it in a throwaway VM on `pollux` (192.168.178.165) and curls the webinstaller on `:5000`. VMID range 9000–9099, last 5 kept. Green end-to-end since 26.4-alpha.
- [x] **Furtka Gateway (Access Gate)** — boxes behind NAT/CGNAT register with a [furtka-gateway](https://forgejo.sourcegate.online/robert/furtka-gateway) server (Traefik + wg-easy + control plane) over a WireGuard tunnel; Settings → *Furtka Gateway* connects, Apps → ⋯ → *Expose to Internet* publishes an app under its own hostname with Let's Encrypt. Only apps with `internet.viable: true` in the manifest can be exposed. Shipped in `26.21-alpha`; first gateway running at `gw.furtka.org` (Hetzner, 2026-10-23).
- [x] **`26.21-alpha`** — security fixes (app-name validation, `.env` injection, fail-closed `users.json`, API hardening), installer fixes, Mark M logo, furtka.org/download, on-box UI polish. See [CHANGELOG](CHANGELOG.md).
- [ ] Gateway dogfooding on real hardware — two old laptops as boxes, one per brother, both behind CGNAT
- [ ] Installer wizard screens S3–S7 — per-device purpose, network, domain, SSL, diagnostic. S5/S6 blocked on managed-gateway DNS infra not yet built. - [ ] Installer wizard screens S3–S7 — per-device purpose, network, domain, SSL, diagnostic. S5/S6 blocked on managed-gateway DNS infra not yet built.
- [ ] Local HTTPS Phase 2 — dedicated local CA (not Caddy's `tls internal`), streamlined one-click install across Win/Mac/Linux/Android, and HTTPS on the live-installer wizard (`https://proksi.local:5000`). - [ ] Local HTTPS Phase 2 — dedicated local CA (not Caddy's `tls internal`), streamlined one-click install across Win/Mac/Linux/Android, and HTTPS on the live-installer wizard (`https://proksi.local:5000`).
- [ ] Caddy + Authentik wired into first-boot bootstrap - [ ] Caddy + Authentik wired into first-boot bootstrap
- [ ] Managed gateway infrastructure — `ns1/ns2.furtka.org` + DNS-01 wildcard automation - [ ] Managed gateway DNS — `ns1/ns2.furtka.org` + DNS-01 wildcard automation (the gateway itself works today with your own domain and per-app or wildcard A records)
- [ ] First containerized service (Nextcloud?) with auto-SSO + auto-subdomain - [ ] First containerized service (Nextcloud?) with auto-SSO + auto-subdomain
- [ ] Competitor hands-on testing on Proxmox — validate findings from docs/competitors.md - [ ] Competitor hands-on testing on Proxmox — validate findings from docs/competitors.md
- [ ] UI mockups / drafts (Robert) - [ ] UI mockups / drafts (Robert)

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