diff --git a/CHANGELOG.md b/CHANGELOG.md
index 309f744..172ba7a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,46 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased]
+## [26.11-alpha] - 2026-04-21
+
+### Added
+
+- **Login-auth for the Furtka web UI.** Every `/apps`, `/api/*`, `/`,
+ and `/settings/` route now requires a signed-in session. New
+ `/login` page serves a username/password form; `POST /login`
+ validates against `/var/lib/furtka/users.json` (werkzeug PBKDF2-
+ hashed), sets a `furtka_session` cookie (`HttpOnly`, `SameSite=
+ Strict`, 7-day TTL), and redirects to `/apps`. `POST /logout`
+ revokes the server-side session and clears the cookie.
+ Unauthenticated HTML requests get a 302 to `/login`; unauthenticated
+ API requests get 401 JSON. The old "No authentication on this UI
+ yet" banner is gone; the `/apps` header picks up a `Logout` link
+ instead.
+- **First-run setup fallback for upgrade-path boxes.** Boxes
+ upgrading from 26.10-alpha have no `users.json` yet — on the first
+ visit `/login` renders a setup form (username + password +
+ password-confirm) that creates the admin record on submit. Fresh
+ installs skip this: the webinstaller writes `users.json` during
+ the chroot post-install step using the step-1 password, so the
+ first browser visit after boot goes straight to the login form.
+- **Caddy proxy routes `/login` and `/logout`.** `assets/Caddyfile`
+ gets two new `handle` blocks in the shared `(furtka_routes)`
+ snippet so both the `:80` block and the `hostname.local, hostname`
+ HTTPS block forward the auth endpoints to the stdlib server on
+ `127.0.0.1:7000`. Without this Caddy would serve a 404 from the
+ static file server.
+
+### Fixed
+
+- `tests/test_installer.py` ruff-format nit — the 26.10-alpha
+ release commit had a misformatted list literal that failed
+ `ruff format --check`. Caught when the Release page on Forgejo
+ showed a red CI badge for the tag.
+- `pyproject.toml` version string bumped from the stale 26.8-alpha
+ to 26.11-alpha. Release pipeline uses `GITHUB_REF_NAME` as source
+ of truth for the artefact name, but having the two agree matters
+ for local dev runs that read `pyproject.toml`.
+
## [26.10-alpha] - 2026-04-21
### Added
@@ -182,7 +222,8 @@ 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.10-alpha...HEAD
+[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.11-alpha...HEAD
+[26.11-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.11-alpha
[26.10-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.10-alpha
[26.9-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.9-alpha
[26.8-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.8-alpha
diff --git a/assets/Caddyfile b/assets/Caddyfile
index 71ea0ca..36cc8ea 100644
--- a/assets/Caddyfile
+++ b/assets/Caddyfile
@@ -35,6 +35,12 @@
handle /apps* {
reverse_proxy localhost:7000
}
+ handle /login* {
+ reverse_proxy localhost:7000
+ }
+ handle /logout* {
+ reverse_proxy localhost:7000
+ }
# Runtime JSON lives under /var/lib/furtka/ so it survives self-updates
# (which only swap /opt/furtka/current).
handle /status.json {
diff --git a/assets/www/style.css b/assets/www/style.css
index dde1293..90a8cf9 100644
--- a/assets/www/style.css
+++ b/assets/www/style.css
@@ -310,7 +310,8 @@ details.log-details[open] > summary { color: var(--fg); }
}
.field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.field .req { color: var(--danger); margin-left: 0.25rem; }
-.modal .error {
+.modal .error,
+.login-wrap .error {
background: var(--warn);
color: var(--warn-fg);
padding: 0.5rem 0.75rem;
@@ -319,7 +320,15 @@ details.log-details[open] > summary { color: var(--fg); }
font-size: 0.9rem;
display: none;
}
-.modal .error.show { display: block; }
+.modal .error.show,
+.login-wrap .error.show { display: block; }
+
+/* Login + first-run setup page. Shares .wrap's max-width so the form
+ sits in the same column the rest of the app uses, just without the
+ Home/Apps/Settings nav. A bit of top padding so the H1 isn't glued
+ to the viewport edge. */
+.login-wrap { padding-top: 3rem; }
+.login-wrap .actions { margin-top: 0.5rem; }
.modal-actions {
display: flex;
justify-content: flex-end;
diff --git a/furtka/api.py b/furtka/api.py
index 851cbda..d3c84e3 100644
--- a/furtka/api.py
+++ b/furtka/api.py
@@ -2,20 +2,26 @@
# its lines hurts readability and the rendered output is what matters here.
"""Tiny HTTP API + management UI for the Furtka resource manager.
-Single stdlib http.server process, no Flask/no third-party deps so we don't
-have to pip-install anything on the target. Caddy reverse-proxies /apps and
-/api from :80 to here.
+Single stdlib http.server process, served behind Caddy (reverse-proxies
+/apps, /api, /login and /logout from :80 to here).
-Security: NO AUTH. Bound to 127.0.0.1 by default; the Caddy proxy makes it
-LAN-reachable. Anyone on the LAN can install/remove apps. The UI shouts this
-out at the top. Auth lands when Authentik does.
+Security: single-admin password login, cookie-session, werkzeug-hashed
+password stored at /var/lib/furtka/users.json (0600). Sessions live in
+memory — `systemctl restart furtka-api` invalidates everyone. Fresh
+installs pre-populate users.json from the webinstaller step-1 password;
+upgrades from pre-auth releases fall into a first-run setup form at
+/login where the admin password is created from the browser. Authentik
+integration remains the long-term plan; this is the pragmatic alpha
+stopgap.
"""
import json
import re
+import time
+from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, HTTPServer
-from furtka import dockerops, installer, reconciler, sources
+from furtka import auth, dockerops, installer, reconciler, sources
from furtka.manifest import ManifestError, load_manifest
from furtka.paths import apps_dir
from furtka.scanner import scan
@@ -77,12 +83,12 @@ _HTML = """
Home
Apps
Settings
+ Logout
Furtka Apps
Install or remove resource-manager apps on this Furtka box.
- No authentication on this UI yet. Anyone on your LAN can install or remove apps. Don't expose this to the wider internet.
Installed
@@ -120,6 +126,15 @@ function esc(s) {
return d.innerHTML;
}
+async function doLogout(ev) {
+ ev.preventDefault();
+ try {
+ await fetch('/logout', { method: 'POST', credentials: 'same-origin' });
+ } catch (e) { /* best-effort — server may already be down */ }
+ window.location.href = '/login';
+ return false;
+}
+
// Fallback when an app doesn't ship a parseable icon.svg. Simple
// stroked folder — currentColor so the tile's accent tint applies.
const FALLBACK_ICON = ' ';
@@ -387,6 +402,120 @@ refreshCatalog();
"""
+# Login / first-run setup page. Rendered standalone (no main-UI chrome) so
+# an unauthenticated visitor never gets a glimpse of the app list. Reuses
+# /style.css for the look — the page is just a form + optional error line.
+# The template has a {{ SETUP }} marker the server flips on/off depending
+# on whether users.json exists yet (first-run vs. normal login).
+_HTML_LOGIN = """
+
+
+
+Furtka · {{ TITLE }}
+
+
+
+
+
+ {{ HEADING }}
+ {{ LEDE }}
+
+
+
+
+
+"""
+
+
+def _render_login_html(setup: bool, default_username: str = "") -> str:
+ if setup:
+ password2_field = (
+ 'Repeat password '
+ '
'
+ )
+ subs = {
+ "TITLE": "First-run setup",
+ "HEADING": "Set admin password",
+ "LEDE": "No admin account exists yet on this box. Pick a username and password — you'll use them to sign in to the Furtka UI.",
+ "PWD_AUTOCOMPLETE": "new-password",
+ "PASSWORD2_FIELD": password2_field,
+ "SUBMIT_LABEL": "Create admin",
+ "DEFAULT_USERNAME": "admin",
+ "SETUP_JSON": "true",
+ }
+ else:
+ subs = {
+ "TITLE": "Login",
+ "HEADING": "Furtka login",
+ "LEDE": "Sign in with the admin credentials you set during install.",
+ "PWD_AUTOCOMPLETE": "current-password",
+ "PASSWORD2_FIELD": "",
+ "SUBMIT_LABEL": "Log in",
+ "DEFAULT_USERNAME": default_username,
+ "SETUP_JSON": "false",
+ }
+ out = _HTML_LOGIN
+ for key, val in subs.items():
+ out = out.replace("{{ " + key + " }}", val)
+ return out
+
+
+# Minimum password length enforced server-side (browser also enforces it
+# via the input's minlength, but don't rely on client-side only).
+_MIN_PASSWORD_LEN = 8
+
+
def _manifest_summary(m, app_dir=None):
return {
"name": m.name,
@@ -826,23 +955,134 @@ def _parse_settings_body(payload):
class _Handler(BaseHTTPRequestHandler):
- def _json(self, status, payload):
+ def _json(self, status, payload, extra_headers=None):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
+ for name, value in extra_headers or []:
+ self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
- def _html(self, status, body):
+ def _html(self, status, body, extra_headers=None):
b = body.encode()
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(b)))
+ for name, value in extra_headers or []:
+ self.send_header(name, value)
self.end_headers()
self.wfile.write(b)
+ def _redirect(self, location, extra_headers=None):
+ self.send_response(302)
+ self.send_header("Location", location)
+ self.send_header("Content-Length", "0")
+ for name, value in extra_headers or []:
+ self.send_header(name, value)
+ self.end_headers()
+
+ # ---- Auth helpers -------------------------------------------------
+
+ def _request_cookies(self) -> SimpleCookie:
+ cookies = SimpleCookie()
+ header = self.headers.get("Cookie")
+ if header:
+ try:
+ cookies.load(header)
+ except Exception:
+ # Malformed Cookie header — treat as no cookies rather
+ # than 500ing. Same posture as browsers.
+ return SimpleCookie()
+ return cookies
+
+ def _current_session(self):
+ cookies = self._request_cookies()
+ morsel = cookies.get(auth.COOKIE_NAME)
+ if morsel is None:
+ return None
+ return auth.SESSIONS.lookup(morsel.value)
+
+ def _session_cookie_header(self, token: str, max_age: int) -> tuple[str, str]:
+ secure = self.headers.get("X-Forwarded-Proto", "").lower() == "https"
+ parts = [
+ f"{auth.COOKIE_NAME}={token}",
+ "HttpOnly",
+ "SameSite=Strict",
+ "Path=/",
+ f"Max-Age={max_age}",
+ ]
+ if secure:
+ parts.append("Secure")
+ return ("Set-Cookie", "; ".join(parts))
+
+ def _clear_cookie_header(self) -> tuple[str, str]:
+ # Max-Age=0 with an empty value tells the browser to drop it.
+ return (
+ "Set-Cookie",
+ f"{auth.COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
+ )
+
+ def _handle_login(self, payload):
+ username = payload.get("username") if isinstance(payload, dict) else None
+ password = payload.get("password") if isinstance(payload, dict) else None
+ if not isinstance(username, str) or not username.strip():
+ return self._json(400, {"error": "username is required"})
+ if not isinstance(password, str) or not password:
+ return self._json(400, {"error": "password is required"})
+ username = username.strip()
+
+ if auth.setup_needed():
+ # First-run setup path — create the admin account, then log
+ # in. Require password2 so a typo doesn't lock the user out
+ # of their own box.
+ password2 = payload.get("password2")
+ if password2 != password:
+ return self._json(400, {"error": "passwords do not match"})
+ if len(password) < _MIN_PASSWORD_LEN:
+ return self._json(
+ 400,
+ {"error": f"password must be at least {_MIN_PASSWORD_LEN} characters"},
+ )
+ auth.create_admin(username, password)
+ else:
+ if not auth.authenticate(username, password):
+ # Cheap brute-force speed bump. werkzeug's PBKDF2 is
+ # already slow per attempt, but a fixed sleep makes
+ # "try 1000 passwords over the LAN" even less fun.
+ time.sleep(0.5)
+ return self._json(401, {"error": "invalid username or password"})
+
+ session = auth.SESSIONS.create(username)
+ cookie = self._session_cookie_header(session.token, auth.COOKIE_TTL_SECONDS)
+ return self._json(200, {"ok": True, "username": username}, extra_headers=[cookie])
+
+ def _handle_logout(self):
+ cookies = self._request_cookies()
+ morsel = cookies.get(auth.COOKIE_NAME)
+ if morsel is not None:
+ auth.SESSIONS.revoke(morsel.value)
+ return self._json(200, {"ok": True}, extra_headers=[self._clear_cookie_header()])
+
def do_GET(self): # noqa: N802 — http.server convention
+ # --- Public routes: login page + its assets ------------------
+ if self.path in ("/login", "/login/"):
+ # Already authed? Skip straight to the app list.
+ if self._current_session() is not None:
+ return self._redirect("/apps")
+ return self._html(200, _render_login_html(auth.setup_needed()))
+
+ # --- Auth guard for everything below -------------------------
+ session = self._current_session()
+ if session is None:
+ # API paths get a 401 JSON so fetch() callers see a clean
+ # error. HTML paths get a redirect to /login so the browser
+ # naturally ends up on the login form.
+ if self.path.startswith("/api/"):
+ return self._json(401, {"error": "not authenticated"})
+ return self._redirect("/login")
+
if self.path in ("/", "/apps", "/apps/"):
return self._html(200, _HTML)
if self.path == "/api/apps":
@@ -879,6 +1119,16 @@ class _Handler(BaseHTTPRequestHandler):
if not isinstance(payload, dict):
return self._json(400, {"error": "body must be a JSON object"})
+ # --- Public routes: login + logout ----------------------------
+ if self.path in ("/login", "/login/"):
+ return self._handle_login(payload)
+ if self.path in ("/logout", "/logout/"):
+ return self._handle_logout()
+
+ # --- Auth guard for every other POST --------------------------
+ if self._current_session() is None:
+ return self._json(401, {"error": "not authenticated"})
+
# Per-app settings update: /api/apps//settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]
diff --git a/furtka/auth.py b/furtka/auth.py
new file mode 100644
index 0000000..526760a
--- /dev/null
+++ b/furtka/auth.py
@@ -0,0 +1,175 @@
+"""Login-guard primitives for the Furtka UI.
+
+One admin, one password. Passwords are PBKDF2-hashed via werkzeug (already
+pulled in by the flask runtime dep), stored in /var/lib/furtka/users.json
+with mode 0600. Sessions live in memory — a systemctl restart logs
+everyone out again, which is fine for an alpha single-user box.
+
+On upgrade from 26.10-alpha the users.json file does not exist yet; the
+api's GET /login detects this via `setup_needed()` and renders a first-
+run form that POSTs to /login as if it were a setup submit. Fresh installs
+get the file pre-populated by the webinstaller so the setup step is
+skipped.
+"""
+
+from __future__ import annotations
+
+import json
+import secrets
+import threading
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+
+from werkzeug.security import check_password_hash, generate_password_hash
+
+from furtka.paths import users_file
+
+COOKIE_NAME = "furtka_session"
+COOKIE_TTL_SECONDS = 7 * 24 * 3600 # one week
+
+
+def hash_password(plain: str) -> str:
+ """PBKDF2-SHA256 via werkzeug. Cost default (~600k iterations)."""
+ return generate_password_hash(plain)
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ # werkzeug's check_password_hash is constant-time.
+ return check_password_hash(hashed, plain)
+
+
+def load_users() -> dict:
+ """Return the users dict, or {} if the file is missing or empty.
+
+ Missing-file is the expected state on first boot and on upgrades from
+ pre-auth versions — callers treat empty-dict as "setup required".
+ """
+ path = users_file()
+ if not path.exists():
+ return {}
+ try:
+ raw = path.read_text()
+ except OSError:
+ return {}
+ if not raw.strip():
+ return {}
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError:
+ return {}
+ if not isinstance(data, dict):
+ return {}
+ return data
+
+
+def save_users(users: dict) -> None:
+ """Atomically write users.json with mode 0600.
+
+ Same pattern as installer.write_env — write to .tmp, chmod, rename —
+ so a crash between open() and close() can't leave a world-readable
+ partial file.
+ """
+ path = users_file()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text(json.dumps(users, indent=2) + "\n")
+ tmp.chmod(0o600)
+ tmp.replace(path)
+
+
+def setup_needed() -> bool:
+ """True when no admin is registered yet — initial setup is required."""
+ users = load_users()
+ return not users or "admin" not in users
+
+
+def create_admin(username: str, password: str) -> None:
+ """Overwrite users.json with a single admin account.
+
+ The webinstaller calls this post-install (with the step-1 password) so
+ the installed system is login-guarded from first boot. The /login
+ route calls it on first setup for upgrade-path boxes that don't
+ already have a users.json.
+ """
+ users = {
+ "admin": {
+ "username": username,
+ "hash": hash_password(password),
+ "created_at": datetime.now(UTC).isoformat(timespec="seconds"),
+ }
+ }
+ save_users(users)
+
+
+def authenticate(username: str, password: str) -> bool:
+ """Return True iff the supplied credentials match the admin record."""
+ users = load_users()
+ admin = users.get("admin")
+ if not admin:
+ return False
+ if admin.get("username") != username:
+ return False
+ hashed = admin.get("hash")
+ if not isinstance(hashed, str) or not hashed:
+ return False
+ return verify_password(password, hashed)
+
+
+@dataclass(frozen=True)
+class Session:
+ token: str
+ username: str
+ expires_at: datetime
+
+
+class SessionStore:
+ """In-memory session table. Thread-safe (api.py uses the stdlib
+
+ HTTPServer which handles one request per thread — though the default
+ variant is single-threaded, we keep the lock so swapping to
+ ThreadingHTTPServer later doesn't require revisiting this).
+ """
+
+ def __init__(self, ttl_seconds: int = COOKIE_TTL_SECONDS) -> None:
+ self._ttl = timedelta(seconds=ttl_seconds)
+ self._by_token: dict[str, Session] = {}
+ self._lock = threading.Lock()
+
+ def create(self, username: str) -> Session:
+ token = secrets.token_urlsafe(32)
+ session = Session(
+ token=token,
+ username=username,
+ expires_at=datetime.now(UTC) + self._ttl,
+ )
+ with self._lock:
+ self._by_token[token] = session
+ return session
+
+ def lookup(self, token: str | None) -> Session | None:
+ if not token:
+ return None
+ with self._lock:
+ session = self._by_token.get(token)
+ if session is None:
+ return None
+ if datetime.now(UTC) >= session.expires_at:
+ # Expired — drop it on the floor so repeat lookups stay fast.
+ self._by_token.pop(token, None)
+ return None
+ return session
+
+ def revoke(self, token: str | None) -> None:
+ if not token:
+ return
+ with self._lock:
+ self._by_token.pop(token, None)
+
+ def clear(self) -> None:
+ """Test helper — wipe all sessions."""
+ with self._lock:
+ self._by_token.clear()
+
+
+# Module-level singleton used by the HTTP handler.
+SESSIONS = SessionStore()
diff --git a/furtka/paths.py b/furtka/paths.py
index db741c3..36e78a2 100644
--- a/furtka/paths.py
+++ b/furtka/paths.py
@@ -11,6 +11,11 @@ DEFAULT_BUNDLED_APPS_DIR = Path("/opt/furtka/current/apps")
# release tarball. Lives under /var/lib/furtka/ so it survives core self-
# updates — the resolver (furtka.sources) prefers it over the bundled seed.
DEFAULT_CATALOG_DIR = Path("/var/lib/furtka/catalog")
+# Users / auth state. One JSON file keyed by role — today only "admin" exists.
+# Lives under /var/lib/furtka/ so self-updates don't stomp it. Mode 0600 is
+# enforced by furtka.auth.save_users (same atomic-write pattern as the app
+# .env files).
+DEFAULT_USERS_FILE = Path("/var/lib/furtka/users.json")
def apps_dir() -> Path:
@@ -27,3 +32,7 @@ def catalog_dir() -> Path:
def catalog_apps_dir() -> Path:
return catalog_dir() / "apps"
+
+
+def users_file() -> Path:
+ return Path(os.environ.get("FURTKA_USERS_FILE", DEFAULT_USERS_FILE))
diff --git a/pyproject.toml b/pyproject.toml
index 76cfc69..d9123fc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "furtka"
-version = "26.8-alpha"
+version = "26.11-alpha"
description = "Open-source home server OS — simple enough for everyone."
requires-python = ">=3.11"
readme = "README.md"
diff --git a/tests/test_api.py b/tests/test_api.py
index 159cedb..fca872b 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -5,7 +5,7 @@ import urllib.request
import pytest
-from furtka import api, dockerops
+from furtka import api, auth, dockerops
VALID_MANIFEST = {
"name": "fileshare",
@@ -23,14 +23,28 @@ def fake_dirs(tmp_path, monkeypatch):
apps = tmp_path / "apps"
bundled = tmp_path / "bundled"
catalog = tmp_path / "catalog"
+ users_file = tmp_path / "users.json"
apps.mkdir()
bundled.mkdir()
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(catalog))
+ monkeypatch.setenv("FURTKA_USERS_FILE", str(users_file))
+ # Scrub any sessions that leaked from a prior test — the SESSIONS
+ # store is module-level.
+ auth.SESSIONS.clear()
return apps, bundled
+@pytest.fixture
+def admin_session(fake_dirs):
+ """Pre-create an admin account + live session. Returns a Cookie header
+ value ready to drop into urllib.request.Request(headers=...)."""
+ auth.create_admin("daniel", "hunter2-pw")
+ session = auth.SESSIONS.create("daniel")
+ return f"{auth.COOKIE_NAME}={session.token}"
+
+
@pytest.fixture
def no_docker(monkeypatch):
"""Stub docker calls so install/remove can run without a daemon."""
@@ -199,23 +213,39 @@ def test_remove_endpoint_happy_path(fake_dirs, no_docker):
assert not (apps / "fileshare").exists()
-def test_http_get_apps_route(fake_dirs, no_docker):
+def _request(port, path, cookie=None, method="GET", body=None):
+ headers = {}
+ if cookie is not None:
+ headers["Cookie"] = cookie
+ data = None
+ if body is not None:
+ headers["Content-Type"] = "application/json"
+ data = json.dumps(body).encode()
+ return urllib.request.Request(
+ f"http://127.0.0.1:{port}{path}",
+ data=data,
+ headers=headers,
+ method=method,
+ )
+
+
+def test_http_get_apps_route(fake_dirs, no_docker, admin_session):
"""Smoke test the actual HTTP server with a real socket, urllib client."""
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral
port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
- with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/apps") as r:
+ with urllib.request.urlopen(_request(port, "/api/apps", cookie=admin_session)) as r:
assert r.status == 200
data = json.loads(r.read())
assert data == []
- with urllib.request.urlopen(f"http://127.0.0.1:{port}/") as r:
+ with urllib.request.urlopen(_request(port, "/", cookie=admin_session)) as r:
assert r.status == 200
assert b"Furtka Apps" in r.read()
# Unknown route → 404 JSON.
try:
- urllib.request.urlopen(f"http://127.0.0.1:{port}/api/nope")
+ urllib.request.urlopen(_request(port, "/api/nope", cookie=admin_session))
raise AssertionError("expected 404")
except urllib.error.HTTPError as e:
assert e.code == 404
@@ -224,17 +254,18 @@ def test_http_get_apps_route(fake_dirs, no_docker):
server.server_close()
-def test_http_post_install_unknown_app(fake_dirs):
+def test_http_post_install_unknown_app(fake_dirs, admin_session):
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
- req = urllib.request.Request(
- f"http://127.0.0.1:{port}/api/apps/install",
- data=json.dumps({"name": "ghost"}).encode(),
- headers={"Content-Type": "application/json"},
+ req = _request(
+ port,
+ "/api/apps/install",
+ cookie=admin_session,
method="POST",
+ body={"name": "ghost"},
)
try:
urllib.request.urlopen(req)
@@ -248,6 +279,247 @@ def test_http_post_install_unknown_app(fake_dirs):
server.server_close()
+# --- Auth guard + login flow ------------------------------------------------
+
+
+def _start_server():
+ server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
+ port = server.server_address[1]
+ t = threading.Thread(target=server.serve_forever, daemon=True)
+ t.start()
+ return server, port
+
+
+def test_unauthenticated_api_returns_401(fake_dirs):
+ # No admin_session fixture → no cookie on the request.
+ server, port = _start_server()
+ try:
+ try:
+ urllib.request.urlopen(_request(port, "/api/apps"))
+ raise AssertionError("expected 401")
+ except urllib.error.HTTPError as e:
+ assert e.code == 401
+ body = json.loads(e.read())
+ assert body["error"] == "not authenticated"
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_unauthenticated_html_redirects_to_login(fake_dirs):
+ server, port = _start_server()
+ try:
+ # Disable redirect following so we can inspect the 302.
+ opener = urllib.request.build_opener(_NoRedirectHandler())
+ try:
+ opener.open(_request(port, "/apps"))
+ raise AssertionError("expected 302")
+ except urllib.error.HTTPError as e:
+ assert e.code == 302
+ assert e.headers["Location"] == "/login"
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, *args, **kwargs):
+ return None
+
+
+def test_get_login_renders_login_form_when_admin_exists(fake_dirs):
+ auth.create_admin("daniel", "hunter2-pw")
+ server, port = _start_server()
+ try:
+ with urllib.request.urlopen(_request(port, "/login")) as r:
+ html = r.read().decode()
+ assert r.status == 200
+ assert "Furtka login" in html
+ # No setup confirm-password field rendered in login mode.
+ assert 'id="password2"' not in html
+ assert "Repeat password" not in html
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_get_login_renders_setup_form_when_no_admin(fake_dirs):
+ server, port = _start_server()
+ try:
+ with urllib.request.urlopen(_request(port, "/login")) as r:
+ html = r.read().decode()
+ assert r.status == 200
+ assert "Set admin password" in html
+ assert "password2" in html # setup confirm field rendered
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_get_login_redirects_when_already_authed(fake_dirs, admin_session):
+ server, port = _start_server()
+ try:
+ opener = urllib.request.build_opener(_NoRedirectHandler())
+ try:
+ opener.open(_request(port, "/login", cookie=admin_session))
+ raise AssertionError("expected 302")
+ except urllib.error.HTTPError as e:
+ assert e.code == 302
+ assert e.headers["Location"] == "/apps"
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_post_login_setup_creates_admin(fake_dirs):
+ server, port = _start_server()
+ try:
+ req = _request(
+ port,
+ "/login",
+ method="POST",
+ body={
+ "username": "daniel",
+ "password": "a-real-password",
+ "password2": "a-real-password",
+ },
+ )
+ with urllib.request.urlopen(req) as r:
+ assert r.status == 200
+ set_cookie = r.headers["Set-Cookie"]
+ assert auth.COOKIE_NAME in set_cookie
+ assert "HttpOnly" in set_cookie
+ assert "SameSite=Strict" in set_cookie
+ # users.json got written.
+ assert auth.load_users()["admin"]["username"] == "daniel"
+ # And the password really works.
+ assert auth.authenticate("daniel", "a-real-password") is True
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_post_login_setup_rejects_password_mismatch(fake_dirs):
+ server, port = _start_server()
+ try:
+ req = _request(
+ port,
+ "/login",
+ method="POST",
+ body={"username": "x", "password": "abcdefgh", "password2": "different"},
+ )
+ try:
+ urllib.request.urlopen(req)
+ raise AssertionError("expected 400")
+ except urllib.error.HTTPError as e:
+ assert e.code == 400
+ body = json.loads(e.read())
+ assert "match" in body["error"].lower()
+ # No admin created.
+ assert auth.setup_needed() is True
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_post_login_setup_rejects_short_password(fake_dirs):
+ server, port = _start_server()
+ try:
+ req = _request(
+ port,
+ "/login",
+ method="POST",
+ body={"username": "x", "password": "short", "password2": "short"},
+ )
+ try:
+ urllib.request.urlopen(req)
+ raise AssertionError("expected 400")
+ except urllib.error.HTTPError as e:
+ assert e.code == 400
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_post_login_success_with_correct_credentials(fake_dirs):
+ auth.create_admin("daniel", "hunter2-pw")
+ server, port = _start_server()
+ try:
+ req = _request(
+ port,
+ "/login",
+ method="POST",
+ body={"username": "daniel", "password": "hunter2-pw"},
+ )
+ with urllib.request.urlopen(req) as r:
+ assert r.status == 200
+ set_cookie = r.headers["Set-Cookie"]
+ assert auth.COOKIE_NAME in set_cookie
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_post_login_rejects_wrong_password(fake_dirs):
+ auth.create_admin("daniel", "hunter2-pw")
+ server, port = _start_server()
+ try:
+ req = _request(
+ port,
+ "/login",
+ method="POST",
+ body={"username": "daniel", "password": "nope"},
+ )
+ try:
+ urllib.request.urlopen(req)
+ raise AssertionError("expected 401")
+ except urllib.error.HTTPError as e:
+ assert e.code == 401
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_post_logout_revokes_session(fake_dirs, admin_session):
+ server, port = _start_server()
+ try:
+ # Logout returns 200 and clears the cookie.
+ with urllib.request.urlopen(
+ _request(port, "/logout", cookie=admin_session, method="POST", body={})
+ ) as r:
+ assert r.status == 200
+ set_cookie = r.headers["Set-Cookie"]
+ assert "Max-Age=0" in set_cookie
+ # Subsequent API call with same cookie → 401 (session revoked).
+ try:
+ urllib.request.urlopen(_request(port, "/api/apps", cookie=admin_session))
+ raise AssertionError("expected 401")
+ except urllib.error.HTTPError as e:
+ assert e.code == 401
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_post_to_protected_route_without_auth_is_401(fake_dirs):
+ server, port = _start_server()
+ try:
+ req = _request(
+ port,
+ "/api/apps/install",
+ method="POST",
+ body={"name": "whatever"},
+ )
+ try:
+ urllib.request.urlopen(req)
+ raise AssertionError("expected 401")
+ except urllib.error.HTTPError as e:
+ assert e.code == 401
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
# --- Settings endpoints ------------------------------------------------------
SETTINGS_MANIFEST = dict(
@@ -329,7 +601,7 @@ def test_update_settings_unknown_app(fake_dirs):
assert status == 404
-def test_http_get_settings_route(fake_dirs, no_docker):
+def test_http_get_settings_route(fake_dirs, no_docker, admin_session):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
@@ -337,7 +609,9 @@ def test_http_get_settings_route(fake_dirs, no_docker):
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
- with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/apps/fileshare/settings") as r:
+ with urllib.request.urlopen(
+ _request(port, "/api/apps/fileshare/settings", cookie=admin_session)
+ ) as r:
assert r.status == 200
data = json.loads(r.read())
assert data["name"] == "fileshare"
@@ -549,7 +823,7 @@ def test_furtka_update_status_endpoint(stub_furtka_updater):
assert stub_furtka_updater["status_called"] == 1
-def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs):
+def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs, admin_session):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real")
api._do_install("fileshare")
@@ -560,11 +834,12 @@ def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs):
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
- req = urllib.request.Request(
- f"http://127.0.0.1:{port}/api/apps/fileshare/update",
- data=b"{}",
- headers={"Content-Type": "application/json"},
+ req = _request(
+ port,
+ "/api/apps/fileshare/update",
+ cookie=admin_session,
method="POST",
+ body={},
)
with urllib.request.urlopen(req) as r:
assert r.status == 200
@@ -576,7 +851,7 @@ def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs):
server.server_close()
-def test_http_post_install_with_settings(fake_dirs, no_docker):
+def test_http_post_install_with_settings(fake_dirs, no_docker, admin_session):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
@@ -584,16 +859,15 @@ def test_http_post_install_with_settings(fake_dirs, no_docker):
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
- req = urllib.request.Request(
- f"http://127.0.0.1:{port}/api/apps/install",
- data=json.dumps(
- {
- "name": "fileshare",
- "settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"},
- }
- ).encode(),
- headers={"Content-Type": "application/json"},
+ req = _request(
+ port,
+ "/api/apps/install",
+ cookie=admin_session,
method="POST",
+ body={
+ "name": "fileshare",
+ "settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"},
+ },
)
with urllib.request.urlopen(req) as r:
assert r.status == 200
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 0000000..21bea43
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,152 @@
+import json
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+from furtka import auth
+
+
+@pytest.fixture
+def tmp_users_file(tmp_path, monkeypatch):
+ path = tmp_path / "users.json"
+ monkeypatch.setenv("FURTKA_USERS_FILE", str(path))
+ # Sessions are module-level; wipe between tests so one doesn't leak a
+ # valid token into the next.
+ auth.SESSIONS.clear()
+ return path
+
+
+def test_hash_password_roundtrip():
+ h = auth.hash_password("hunter2")
+ assert h != "hunter2" # Not plain text.
+ assert auth.verify_password("hunter2", h) is True
+ assert auth.verify_password("hunter3", h) is False
+
+
+def test_hash_password_is_salted():
+ # Two calls with the same password must produce different hashes.
+ a = auth.hash_password("same")
+ b = auth.hash_password("same")
+ assert a != b
+ # But both verify against the original.
+ assert auth.verify_password("same", a)
+ assert auth.verify_password("same", b)
+
+
+def test_load_users_returns_empty_when_missing(tmp_users_file):
+ assert not tmp_users_file.exists()
+ assert auth.load_users() == {}
+
+
+def test_load_users_returns_empty_on_junk(tmp_users_file):
+ tmp_users_file.write_text("{not json")
+ assert auth.load_users() == {}
+
+
+def test_load_users_returns_empty_on_non_dict(tmp_users_file):
+ tmp_users_file.write_text("[]")
+ assert auth.load_users() == {}
+
+
+def test_save_users_atomic_and_0600(tmp_users_file):
+ auth.save_users({"admin": {"hash": "x", "username": "daniel"}})
+ assert tmp_users_file.exists()
+ mode = tmp_users_file.stat().st_mode & 0o777
+ assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
+ loaded = json.loads(tmp_users_file.read_text())
+ assert loaded["admin"]["username"] == "daniel"
+
+
+def test_setup_needed_true_on_missing_file(tmp_users_file):
+ assert auth.setup_needed() is True
+
+
+def test_setup_needed_true_on_empty_dict(tmp_users_file):
+ tmp_users_file.write_text("{}")
+ assert auth.setup_needed() is True
+
+
+def test_setup_needed_false_when_admin_exists(tmp_users_file):
+ auth.create_admin("daniel", "secret-pw")
+ assert auth.setup_needed() is False
+
+
+def test_create_admin_overwrites_file(tmp_users_file):
+ auth.create_admin("daniel", "secret-pw")
+ auth.create_admin("robert", "new-pw")
+ users = auth.load_users()
+ assert users["admin"]["username"] == "robert"
+
+
+def test_authenticate_happy(tmp_users_file):
+ auth.create_admin("daniel", "secret-pw")
+ assert auth.authenticate("daniel", "secret-pw") is True
+
+
+def test_authenticate_wrong_username(tmp_users_file):
+ auth.create_admin("daniel", "secret-pw")
+ assert auth.authenticate("robert", "secret-pw") is False
+
+
+def test_authenticate_wrong_password(tmp_users_file):
+ auth.create_admin("daniel", "secret-pw")
+ assert auth.authenticate("daniel", "wrong") is False
+
+
+def test_authenticate_no_admin(tmp_users_file):
+ assert auth.authenticate("daniel", "anything") is False
+
+
+# ---- Session store ---------------------------------------------------------
+
+
+def test_session_create_and_lookup(tmp_users_file):
+ s = auth.SESSIONS.create("daniel")
+ assert s.username == "daniel"
+ assert s.token
+ looked_up = auth.SESSIONS.lookup(s.token)
+ assert looked_up is not None
+ assert looked_up.username == "daniel"
+
+
+def test_session_lookup_unknown_token(tmp_users_file):
+ assert auth.SESSIONS.lookup("not-a-real-token") is None
+
+
+def test_session_lookup_none_token(tmp_users_file):
+ assert auth.SESSIONS.lookup(None) is None
+ assert auth.SESSIONS.lookup("") is None
+
+
+def test_session_revoke(tmp_users_file):
+ s = auth.SESSIONS.create("daniel")
+ auth.SESSIONS.revoke(s.token)
+ assert auth.SESSIONS.lookup(s.token) is None
+
+
+def test_session_expires(tmp_users_file, monkeypatch):
+ # Build a session store with a 0-second TTL so lookup immediately
+ # treats new sessions as expired.
+ store = auth.SessionStore(ttl_seconds=0)
+ s = store.create("daniel")
+ # Force the clock forward a hair so the > check fires.
+ monkeypatch.setattr(
+ auth,
+ "datetime",
+ _FakeDatetime(datetime.now(UTC) + timedelta(seconds=1)),
+ )
+ # The module-local datetime reference inside SessionStore.lookup
+ # resolves at call time. Verify that an expired session is dropped.
+ assert store.lookup(s.token) is None
+
+
+class _FakeDatetime:
+ """Tiny shim — only `.now(tz)` is used from SessionStore."""
+
+ def __init__(self, fixed_utc):
+ self._fixed = fixed_utc
+
+ def now(self, tz=None):
+ if tz is None:
+ return self._fixed.replace(tzinfo=None)
+ return self._fixed.astimezone(tz)
diff --git a/tests/test_webinstaller_assets.py b/tests/test_webinstaller_assets.py
index b4b022e..4d3783c 100644
--- a/tests/test_webinstaller_assets.py
+++ b/tests/test_webinstaller_assets.py
@@ -54,7 +54,7 @@ def install_cmds(tmp_path, monkeypatch):
fake = tmp_path / "payload.tar.gz"
fake.write_bytes(b"not a real tarball")
monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake)
- return app._post_install_commands("testhost")
+ return app._post_install_commands("testhost", "daniel", "test-admin-pw")
@pytest.mark.parametrize("target,asset_relpath", ASSET_TARGETS)
@@ -202,3 +202,28 @@ def test_read_asset_raises_for_missing_file():
def test_assets_dir_resolves_to_repo_tree():
assert app._ASSETS_DIR == ASSETS
+
+
+def test_post_install_writes_users_json_with_hashed_password(install_cmds):
+ """The Furtka-admin users.json is created during the chroot post-install.
+
+ Without this, a fresh-install box lands at /login in first-run setup
+ mode and the user has to go through the browser to set a password —
+ which defeats the "step-1 password works for everything" design. Also
+ check that the file is chmod 0600 (the PBKDF2 hash is a secret even
+ if it's slow to crack).
+ """
+ import json as _json
+
+ from werkzeug.security import check_password_hash
+
+ users_cmd = next((c for c in install_cmds if " > /var/lib/furtka/users.json" in c), None)
+ assert users_cmd is not None, "no command writes /var/lib/furtka/users.json"
+ assert "chmod 600" in users_cmd, "users.json must be chmod 0600"
+ body = _extract_written_content(users_cmd, "/var/lib/furtka/users.json")
+ parsed = _json.loads(body)
+ assert "admin" in parsed
+ assert parsed["admin"]["username"] == "daniel" # matches fixture
+ # Hash is a real werkzeug hash, not the plaintext password.
+ assert parsed["admin"]["hash"] != "test-admin-pw"
+ assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw")
diff --git a/webinstaller/app.py b/webinstaller/app.py
index 96adf01..6156441 100644
--- a/webinstaller/app.py
+++ b/webinstaller/app.py
@@ -8,6 +8,7 @@ import os
import re
import subprocess
import sys
+from datetime import UTC
from pathlib import Path
from drives import list_scored_devices
@@ -348,7 +349,35 @@ def _furtka_json_cmd(hostname):
)
-def _post_install_commands(hostname):
+def _users_json_cmd(username, password):
+ """Write /var/lib/furtka/users.json with the admin account hashed.
+
+ The core furtka-api reads this file on every login attempt; the
+ auth.py module treats `admin.username` + `admin.hash` as the only
+ credential. Hashing happens here in the webinstaller (werkzeug is a
+ flask transitive dep so it's already installed in this environment)
+ — the chroot doesn't need pip. Mode 0600 so nobody but root on the
+ installed box can read the PBKDF2 hash.
+ """
+ from datetime import datetime
+
+ from werkzeug.security import generate_password_hash
+
+ users = {
+ "admin": {
+ "username": username,
+ "hash": generate_password_hash(password),
+ "created_at": datetime.now(UTC).isoformat(timespec="seconds"),
+ }
+ }
+ return _write_file_cmd(
+ "/var/lib/furtka/users.json",
+ json.dumps(users, indent=2) + "\n",
+ mode="600",
+ )
+
+
+def _post_install_commands(hostname, admin_username, admin_password):
# nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on
# the hosts line so `*.local` works from the installed system too. Guarded
# so a re-run (or a future Arch default that already ships mdns) is a
@@ -389,6 +418,12 @@ def _post_install_commands(hostname):
# furtka.json depends on /opt/furtka/current/VERSION, so it has to
# run after the resource-manager commands.
_furtka_json_cmd(hostname),
+ # Admin account for the Furtka web UI. Hashed here (werkzeug is
+ # already in scope for the Flask webinstaller) and materialised
+ # into /var/lib/furtka/users.json at mode 0600 on the target
+ # partition — the installed core's auth.py picks it up on first
+ # login.
+ _users_json_cmd(admin_username, admin_password),
]
@@ -447,7 +482,7 @@ def build_archinstall_config(s):
# page, status timer, and welcome banner into place.
"custom_commands": [
f"gpasswd -a {s['username']} docker",
- *_post_install_commands(s["hostname"]),
+ *_post_install_commands(s["hostname"], s["username"], s["password"]),
],
"network_config": {"type": "iso"},
"ssh": True,