Compare commits

..

No commits in common. "main" and "feat/ui-polish" have entirely different histories.

12 changed files with 16 additions and 180 deletions

View file

@ -7,19 +7,8 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased]
## [26.21-alpha] - 2026-10-23
### 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
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
@ -73,13 +62,6 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
full.
- **Apps page after an API restart** redirects to `/login` instead of
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
through `current`.** `_link_new_units()` enabled a timer a release
@ -98,12 +80,6 @@ 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
lowercased instead of rejected, system account names are refused.
- 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
@ -659,8 +635,7 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
- **Containers:** Docker + Compose
- **License:** AGPL-3.0
[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
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.20-alpha...HEAD
[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.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 |
| 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 |
| 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. |
| Gateway | Flexible | Own reverse proxy OR managed through our infrastructure |
| UI approach | UI-first | Design the simplest possible UI, then build everything to match |
## Landscape (Existing Projects)
@ -122,13 +122,10 @@ 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] **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] **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.
- [ ] 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
- [ ] 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)
- [ ] Managed gateway infrastructure — `ns1/ns2.furtka.org` + DNS-01 wildcard automation
- [ ] First containerized service (Nextcloud?) with auto-SSO + auto-subdomain
- [ ] Competitor hands-on testing on Proxmox — validate findings from docs/competitors.md
- [ ] UI mockups / drafts (Robert)

View file

@ -36,11 +36,7 @@ from pathlib import Path
CA_CERT_PATH = Path("/var/lib/caddy/pki/authorities/local/root.crt")
SNIPPET_DIR = Path("/etc/caddy/furtka.d")
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile"
# 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"
REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d")
HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile"
HOSTNAME_FILE = Path("/etc/hostname")
@ -66,19 +62,6 @@ def _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str:
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:
"""Caddy site block the HTTPS toggle installs at opt-in.
@ -168,9 +151,8 @@ def set_force_https(
previous_https = https_snippet.read_text() if had_https else None
if enabled:
hostname = _read_hostname(hostname_file)
snippet.write_text(_redirect_snippet_content(hostname))
https_snippet.write_text(_https_snippet_content(hostname))
snippet.write_text(REDIRECT_CONTENT)
https_snippet.write_text(_https_snippet_content(_read_hostname(hostname_file)))
else:
if had_redirect:
snippet.unlink()

View file

@ -192,20 +192,6 @@ 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:
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
if the file changed (so caddy needs more than a bare reload).
@ -229,7 +215,6 @@ def _refresh_caddyfile(source: Path) -> bool:
# hostname block is gone, so the redirect would 301 to a dead :443.
# Preserve intent by writing the HTTPS listener snippet too.
_maybe_migrate_preserve_https()
_maybe_migrate_redirect_snippet()
rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname())
if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text():
return False

View file

@ -34,17 +34,6 @@ 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.
### 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.
## Test flow

View file

@ -27,11 +27,7 @@ fi
# ---- inside the container from here on ----
echo "==> Syncing pacman, installing 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"
pacman -Syu --noconfirm --needed archiso
PROFILE_SRC="/usr/share/archiso/configs/releng"
PROFILE_WORK="/tmp/furtka-profile"
@ -47,37 +43,6 @@ cat "$SCRIPT_DIR/overlay/packages.extra" >> "$PROFILE_WORK/packages.x86_64"
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/"
# releng auto-logs root in on tty1. That turns our /etc/issue banner into a

View file

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

View file

@ -390,16 +390,3 @@ def test_error_page_offers_retry(monkeypatch, tmp_path):
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "Try again" 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)
assert result is True
assert p["snippet"].read_text() == https._redirect_snippet_content("testbox")
assert p["snippet"].read_text() == https.REDIRECT_CONTENT
written = p["https_snippet"].read_text()
assert "testbox.local, testbox" 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["snippet_dir"].mkdir()
p["https_snippet_dir"].mkdir()
p["snippet"].write_text(https._redirect_snippet_content("testbox"))
p["snippet"].write_text(https.REDIRECT_CONTENT)
p["https_snippet"].write_text("furtka.local { tls internal }\n")
result = https.set_force_https(False, reload_caddy=lambda: None, **p)
@ -199,16 +199,10 @@ def test_systemctl_missing_raises_and_rolls_back(tmp_path):
assert not p["https_snippet"].exists()
def test_redirect_snippet_only_covers_cert_names_and_is_temporary():
# Lock the exact directives. A regression here silently stops the
def test_redirect_snippet_content_is_caddy_redir_directive():
# Lock the exact directive. A regression here silently stops the
# redirect from taking effect even though the file-swap looks fine.
# 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"
)
assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent"
def test_https_snippet_content_has_tls_internal_and_routes(tmp_path):

View file

@ -539,31 +539,3 @@ def test_health_check_survives_read_timeout(updater, monkeypatch):
monkeypatch.setattr("urllib.request.urlopen", slow)
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
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,20 +515,10 @@ def _post_install_commands(hostname, admin_username, admin_password):
]
def _detect_bootloader(efi_dir=Path("/sys/firmware/efi")):
def _detect_bootloader():
# systemd-boot is UEFI-only; on BIOS/legacy it trips HardwareIncompatibilityError
# inside archinstall. /sys/firmware/efi exists iff we were booted via UEFI.
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"
return "Systemd-boot" if Path("/sys/firmware/efi").exists() else "Grub"
def build_archinstall_config(s):

View file

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