feat(auth): login-guard the Furtka UI with a cookie session
All checks were successful
Build ISO / build-iso (push) Successful in 17m30s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 43s
CI / validate-json (push) Successful in 31s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m38s

One-admin, one-password model — all of /apps, /api/*, /, and
/settings/ now require a signed-in session. Passwords are werkzeug
PBKDF2-hashed in /var/lib/furtka/users.json (mode 0600, atomic write
via the same .tmp+chmod+rename dance installer.write_env uses).
Sessions are secrets.token_urlsafe(32) tokens held in a module-level
SessionStore dict (thread-safe lock included for when we swap to
ThreadingHTTPServer). Cookies are HttpOnly, SameSite=Strict, and
Path=/, with Secure set when X-Forwarded-Proto from Caddy says HTTPS.

Two bootstrap paths:
  * Fresh install — webinstaller step-1 collects Linux user + password,
    the chroot post-install step hashes the password and writes
    users.json on the target partition. First browser visit lands on
    /login with the account already present.
  * Upgrade from 26.10-alpha — no users.json yet, so /login detects
    setup_needed() and renders a first-run setup form. POST creates
    the admin and immediately logs in.

POST /logout revokes the server session and clears the cookie.
Unauthenticated HTML requests 302 to /login; unauthenticated API
requests 401 JSON so fetch() callers see a clean error. A sleep(0.5)
on failed logins is the brute-force speed bump on top of werkzeug's
~600k-iter PBKDF2.

Caddyfile gains /login* and /logout* handle blocks in the shared
furtka_routes snippet so both :80 and the HTTPS hostname block
forward the auth endpoints to localhost:7000. Without this Caddy
would 404 from the static file server.

Test surface:
  * tests/test_auth.py (new, 19 cases): hash roundtrip, users.json
    I/O, session create/lookup/expire/revoke.
  * tests/test_api.py: new admin_session fixture; existing HTTP
    tests updated to send the cookie; new tests cover login setup,
    login success, wrong-password 401, logout revocation, and the
    guard's 302/401 split.
  * tests/test_webinstaller_assets.py: new case that unpacks the
    users.json _write_file_cmd body and verifies the werkzeug hash
    round-trips against the step-1 password.

Bumped version to 26.11-alpha and rolled CHANGELOG. Also folded in
the ruff-format fix that was pending from 26.10-alpha's lint red.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-21 13:01:17 +02:00
parent 577c2469f7
commit 470823b347
11 changed files with 1020 additions and 44 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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;

View file

@ -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 = """<!DOCTYPE html>
<a href="/">Home</a>
<a href="/apps" aria-current="page">Apps</a>
<a href="/settings/">Settings</a>
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
</div>
</nav>
<h1>Furtka Apps</h1>
<p class="lede">Install or remove resource-manager apps on this Furtka box.</p>
<div class="warn">No authentication on this UI yet. Anyone on your LAN can install or remove apps. Don't expose this to the wider internet.</div>
<h2>Installed</h2>
<div id="installed"></div>
@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
@ -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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Furtka · {{ TITLE }}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main class="wrap login-wrap">
<h1>{{ HEADING }}</h1>
<p class="lede">{{ LEDE }}</p>
<form id="login-form" onsubmit="return doLogin(event)">
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required value="{{ DEFAULT_USERNAME }}" autofocus>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="{{ PWD_AUTOCOMPLETE }}" required minlength="8">
</div>
{{ PASSWORD2_FIELD }}
<div id="login-error" class="error"></div>
<div class="actions">
<button type="submit" id="login-submit">{{ SUBMIT_LABEL }}</button>
</div>
</form>
</main>
<script>
const SETUP = {{ SETUP_JSON }};
const errBox = document.getElementById('login-error');
async function doLogin(ev) {
ev.preventDefault();
errBox.classList.remove('show');
errBox.textContent = '';
const btn = document.getElementById('login-submit');
btn.disabled = true;
const body = {
username: document.getElementById('username').value,
password: document.getElementById('password').value,
};
if (SETUP) body.password2 = document.getElementById('password2').value;
try {
const r = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (r.ok) {
window.location.href = '/apps';
return false;
}
const data = await r.json().catch(() => ({error: 'HTTP ' + r.status}));
errBox.textContent = data.error || 'Login failed';
errBox.classList.add('show');
} catch (e) {
errBox.textContent = 'Network error — is the box reachable?';
errBox.classList.add('show');
} finally {
btn.disabled = false;
}
return false;
}
</script>
</body>
</html>
"""
def _render_login_html(setup: bool, default_username: str = "") -> str:
if setup:
password2_field = (
'<div class="field"><label for="password2">Repeat password</label>'
'<input id="password2" name="password2" type="password" '
'autocomplete="new-password" required minlength="8"></div>'
)
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/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]

175
furtka/auth.py Normal file
View file

@ -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()

View file

@ -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))

View file

@ -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"

View file

@ -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(
{
req = _request(
port,
"/api/apps/install",
cookie=admin_session,
method="POST",
body={
"name": "fileshare",
"settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"},
}
).encode(),
headers={"Content-Type": "application/json"},
method="POST",
},
)
with urllib.request.urlopen(req) as r:
assert r.status == 200

152
tests/test_auth.py Normal file
View file

@ -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)

View file

@ -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")

View file

@ -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,