diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..010ab4e --- /dev/null +++ b/app/auth.py @@ -0,0 +1,336 @@ +""" +App-level username/password auth for the burn-in dashboard. + +Sessions are signed cookies (Starlette SessionMiddleware) that carry +{user_id, username}. Every request goes through `get_current_user_optional` +via the auth middleware in main.py; routes that need an authenticated user +import `get_current_user` instead, which raises 401 (or redirects to +/login for HTML requests) when there's no session. + +Passwords are bcrypt with the library's default 12-round cost. We never +store plaintext. + +Bootstrap: if the users table is empty AND `initial_admin_username` / +`initial_admin_password` are set, the lifespan creates that admin once at +startup. Otherwise, the login template renders the "first user" form when +visited and zero users exist. +""" + +from __future__ import annotations + +import logging +import secrets +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +import aiosqlite +import bcrypt +from fastapi import HTTPException, Request, status +from starlette.responses import RedirectResponse + +from app.config import settings + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Session secret — env var > persisted file > generated +# --------------------------------------------------------------------------- + +_SESSION_SECRET_FILE = "session_secret" + + +def get_session_secret() -> str: + """Return the HMAC key for SessionMiddleware. env var beats disk.""" + if settings.session_secret: + return settings.session_secret + path = Path(settings.db_path).parent / _SESSION_SECRET_FILE + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(secrets.token_urlsafe(64).encode()) + try: + path.chmod(0o600) + except OSError: + pass + log.warning( + "Generated and persisted session secret to %s. " + "Set SESSION_SECRET in env to override.", path, + ) + return path.read_text().strip() + + +# --------------------------------------------------------------------------- +# User model + storage +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class User: + id: int + username: str + full_name: str | None + is_admin: bool + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def hash_password(plain: str) -> str: + return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + try: + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + except (ValueError, TypeError): + return False + + +async def user_count() -> int: + async with aiosqlite.connect(settings.db_path) as db: + cur = await db.execute("SELECT COUNT(*) FROM users") + return (await cur.fetchone())[0] + + +async def get_user_by_username(username: str) -> tuple[User, str] | None: + """Returns (user, password_hash) or None. Hash is the only place + callers should ever see the raw bcrypt string — for verify_password.""" + async with aiosqlite.connect(settings.db_path) as db: + db.row_factory = aiosqlite.Row + cur = await db.execute( + "SELECT id, username, password_hash, full_name, is_admin " + "FROM users WHERE username = ? COLLATE NOCASE", + (username,), + ) + row = await cur.fetchone() + if not row: + return None + user = User( + id=row["id"], + username=row["username"], + full_name=row["full_name"], + is_admin=bool(row["is_admin"]), + ) + return user, row["password_hash"] + + +async def get_user_by_id(user_id: int) -> User | None: + async with aiosqlite.connect(settings.db_path) as db: + db.row_factory = aiosqlite.Row + cur = await db.execute( + "SELECT id, username, full_name, is_admin " + "FROM users WHERE id = ?", + (user_id,), + ) + row = await cur.fetchone() + if not row: + return None + return User( + id=row["id"], + username=row["username"], + full_name=row["full_name"], + is_admin=bool(row["is_admin"]), + ) + + +async def create_user(username: str, password: str, + full_name: str | None = None, + is_admin: bool = False) -> User: + """Insert a new user. Raises ValueError if the username collides.""" + username = (username or "").strip() + if not username: + raise ValueError("Username is required.") + if len(password) < 8: + raise ValueError("Password must be at least 8 characters.") + h = hash_password(password) + try: + async with aiosqlite.connect(settings.db_path) as db: + cur = await db.execute( + """INSERT INTO users + (username, password_hash, full_name, is_admin, created_at) + VALUES (?, ?, ?, ?, ?) + RETURNING id""", + (username, h, full_name or None, 1 if is_admin else 0, _now()), + ) + row = await cur.fetchone() + await db.commit() + except aiosqlite.IntegrityError: + raise ValueError(f"Username {username!r} already exists.") + return User( + id=row[0], + username=username, + full_name=full_name, + is_admin=is_admin, + ) + + +async def touch_last_login(user_id: int) -> None: + async with aiosqlite.connect(settings.db_path) as db: + await db.execute( + "UPDATE users SET last_login_at = ? WHERE id = ?", + (_now(), user_id), + ) + await db.commit() + + +async def change_password(user_id: int, current_password: str, + new_password: str) -> None: + """Verify current password and rotate. Raises ValueError on any failure.""" + if len(new_password) < 8: + raise ValueError("New password must be at least 8 characters.") + async with aiosqlite.connect(settings.db_path) as db: + db.row_factory = aiosqlite.Row + cur = await db.execute( + "SELECT username, password_hash FROM users WHERE id = ?", (user_id,) + ) + row = await cur.fetchone() + if not row or not verify_password(current_password, row["password_hash"]): + raise ValueError("Current password is incorrect.") + new_hash = hash_password(new_password) + await db.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (new_hash, user_id), + ) + await db.commit() + + +# --------------------------------------------------------------------------- +# Login rate limiting (in-memory, per-username + per-source-IP) +# --------------------------------------------------------------------------- + +import time as _time + +LOGIN_FAILURE_WINDOW_SECONDS = 600 # 10 min +LOGIN_FAILURE_THRESHOLD = 10 # this many failures within the window +LOGIN_LOCKOUT_SECONDS = 900 # then block for 15 min + +# {(key,): [(timestamp, ...), ...]} key = (kind, value), kind in {"user","ip"} +_login_failures: dict = {} +_login_lockouts: dict = {} # key -> unix expiry + + +def _gc_failures(key) -> None: + """Drop failure timestamps older than the window.""" + arr = _login_failures.get(key, []) + cutoff = _time.time() - LOGIN_FAILURE_WINDOW_SECONDS + fresh = [t for t in arr if t >= cutoff] + if fresh: + _login_failures[key] = fresh + elif key in _login_failures: + del _login_failures[key] + + +def login_locked_until(username: str, ip: str) -> float | None: + """Returns the lockout expiry (unix ts) if either dimension is locked, + else None. Lazily reaps expired lockouts.""" + now = _time.time() + soonest = None + for key in (("user", username.lower()), ("ip", ip)): + exp = _login_lockouts.get(key) + if exp is None: + continue + if now >= exp: + del _login_lockouts[key] + continue + soonest = exp if soonest is None else min(soonest, exp) + return soonest + + +def record_login_failure(username: str, ip: str) -> bool: + """Returns True if this failure tripped a lockout.""" + tripped = False + now = _time.time() + for key in (("user", username.lower()), ("ip", ip)): + _gc_failures(key) + _login_failures.setdefault(key, []).append(now) + if len(_login_failures[key]) >= LOGIN_FAILURE_THRESHOLD: + _login_lockouts[key] = now + LOGIN_LOCKOUT_SECONDS + _login_failures[key] = [] # reset counter once lockout armed + tripped = True + return tripped + + +def clear_login_failures(username: str, ip: str) -> None: + for key in (("user", username.lower()), ("ip", ip)): + _login_failures.pop(key, None) + + +# --------------------------------------------------------------------------- +# Audit events for auth flows +# --------------------------------------------------------------------------- + +async def audit_auth_event(event_type: str, username: str | None, + message: str) -> None: + """Write a row to audit_events. event_type is one of: + user_login / user_login_failed / user_logout / user_password_changed / + user_login_locked_out.""" + async with aiosqlite.connect(settings.db_path) as db: + await db.execute( + """INSERT INTO audit_events + (event_type, drive_id, burnin_job_id, operator, message) + VALUES (?,?,?,?,?)""", + (event_type, None, None, username or "?", message), + ) + await db.commit() + + +async def bootstrap_admin_if_empty() -> None: + """Create the env-supplied admin if the users table is empty.""" + if await user_count() > 0: + return + if not (settings.initial_admin_username and settings.initial_admin_password): + return + try: + await create_user( + settings.initial_admin_username, + settings.initial_admin_password, + full_name=None, + is_admin=True, + ) + log.warning( + "Bootstrapped initial admin user %r from env. " + "Change the password via the UI and remove the env vars from compose.", + settings.initial_admin_username, + ) + except ValueError as exc: + log.error("Failed to bootstrap initial admin: %s", exc) + + +# --------------------------------------------------------------------------- +# FastAPI dependencies +# --------------------------------------------------------------------------- + +async def get_current_user_optional(request: Request) -> User | None: + """Return the logged-in user, or None. Doesn't raise — for templates.""" + sess_user_id = request.session.get("user_id") if hasattr(request, "session") else None + if not sess_user_id: + return None + return await get_user_by_id(int(sess_user_id)) + + +async def get_current_user(request: Request) -> User: + """Strict version — for routes. 401 (or redirect for HTML) if missing.""" + user = await get_current_user_optional(request) + if user is None: + # HTML clients prefer a redirect; API clients need a clean 401. + accept = request.headers.get("accept", "") + if "text/html" in accept and request.method == "GET": + raise _RedirectToLogin(request.url.path) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + ) + return user + + +class _RedirectToLogin(Exception): + """Raised by get_current_user when an HTML page needs to bounce to /login.""" + def __init__(self, next_path: str): + self.next_path = next_path + + +def login_redirect(next_path: str = "/") -> RedirectResponse: + safe_next = next_path if next_path.startswith("/") else "/" + target = f"/login?next={safe_next}" if safe_next != "/" else "/login" + return RedirectResponse(url=target, status_code=303) diff --git a/app/auth_cli.py b/app/auth_cli.py new file mode 100644 index 0000000..2043cfa --- /dev/null +++ b/app/auth_cli.py @@ -0,0 +1,99 @@ +"""Password reset / user management CLI. + +Run inside the container: + docker exec -it truenas-burnin python -m app.auth_cli reset + docker exec -it truenas-burnin python -m app.auth_cli list + docker exec -it truenas-burnin python -m app.auth_cli add + +Reads the password from a TTY prompt — never accept it on the command +line so it doesn't leak into shell history. +""" + +from __future__ import annotations + +import asyncio +import getpass +import sys + +import aiosqlite + +from app import auth +from app.config import settings + + +async def _reset(username: str) -> int: + found = await auth.get_user_by_username(username) + if not found: + print(f"No such user: {username}", file=sys.stderr) + return 1 + pw1 = getpass.getpass(f"New password for {username}: ") + pw2 = getpass.getpass("Confirm: ") + if pw1 != pw2: + print("Passwords don't match.", file=sys.stderr) + return 2 + if len(pw1) < 8: + print("Password must be at least 8 characters.", file=sys.stderr) + return 3 + new_hash = auth.hash_password(pw1) + async with aiosqlite.connect(settings.db_path) as db: + await db.execute( + "UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE", + (new_hash, username), + ) + await db.commit() + print(f"Password updated for {username}.") + return 0 + + +async def _list() -> int: + async with aiosqlite.connect(settings.db_path) as db: + db.row_factory = aiosqlite.Row + cur = await db.execute( + "SELECT id, username, full_name, is_admin, created_at, last_login_at " + "FROM users ORDER BY username" + ) + rows = list(await cur.fetchall()) + if not rows: + print("(no users)") + return 0 + for r in rows: + flag = "admin" if r["is_admin"] else "user " + print(f" [{flag}] {r['username']:24s} created={r['created_at'][:19]} " + f"last_login={(r['last_login_at'] or '-')[:19]}") + return 0 + + +async def _add(username: str) -> int: + pw1 = getpass.getpass(f"Password for new user {username}: ") + pw2 = getpass.getpass("Confirm: ") + if pw1 != pw2: + print("Passwords don't match.", file=sys.stderr) + return 2 + full = input("Full name (optional, press enter to skip): ").strip() or None + is_admin = input("Admin? [y/N]: ").strip().lower() == "y" + try: + u = await auth.create_user(username, pw1, full, is_admin=is_admin) + except ValueError as exc: + print(f"Failed: {exc}", file=sys.stderr) + return 1 + print(f"Created user {u.username} (admin={u.is_admin}).") + return 0 + + +def main() -> int: + if len(sys.argv) < 2: + print(__doc__, file=sys.stderr) + return 64 + cmd = sys.argv[1] + if cmd == "list": + return asyncio.run(_list()) + if cmd == "reset" and len(sys.argv) == 3: + return asyncio.run(_reset(sys.argv[2])) + if cmd == "add" and len(sys.argv) == 3: + return asyncio.run(_add(sys.argv[2])) + print(__doc__, file=sys.stderr) + return 64 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/app/burnin.py b/app/burnin.py index 0a1ec13..8b019a3 100644 --- a/app/burnin.py +++ b/app/burnin.py @@ -186,6 +186,8 @@ BOOT_POOL_NAME = "boot-pool" BOOT_POOL_CONFIRM_TOKEN = "DESTROY BOOT POOL" EXPORTED_POOL_ROLE = "exported" EXPORTED_CONFIRM_TOKEN = "DESTROY EXPORTED POOL" +MOUNTED_ROLE = "mounted" +MOUNTED_CONFIRM_TOKEN = "DESTROY MOUNTED FILESYSTEM" @dataclass @@ -296,12 +298,15 @@ async def grant_pool_unlock(drive_id: int, confirm_token: str, "This drive is not part of any pool — no unlock needed." ) - # Boot-pool and exported pools both get dedicated, harder-to-fat- - # finger tokens. Active data pools just need their pool name typed. + # Boot-pool / exported / mounted-fs all get dedicated, harder-to- + # fat-finger tokens. Active data pools just need their pool name + # typed. if pool_name == BOOT_POOL_NAME: expected = BOOT_POOL_CONFIRM_TOKEN elif pool_role == EXPORTED_POOL_ROLE: expected = EXPORTED_CONFIRM_TOKEN + elif pool_role == MOUNTED_ROLE: + expected = MOUNTED_CONFIRM_TOKEN else: expected = pool_name if (confirm_token or "").strip() != expected: @@ -311,6 +316,8 @@ async def grant_pool_unlock(drive_id: int, confirm_token: str, evt = "boot_pool_drive_unlocked" elif pool_role == EXPORTED_POOL_ROLE: evt = "exported_pool_drive_unlocked" + elif pool_role == MOUNTED_ROLE: + evt = "mounted_drive_unlocked" else: evt = "pool_drive_unlocked" await db.execute( @@ -860,15 +867,22 @@ async def _stage_surface_validate(job_id: int, devname: str, drive_id: int) -> b """ Surface validation stage — auto-routes to the right implementation: - 1. SSH configured + badblocks available (TrueNAS SCALE / Linux): - → runs badblocks -wsv -b 4096 -p 1 /dev/{devname} directly over SSH. - 2. SSH configured + badblocks NOT available (TrueNAS CORE / FreeBSD): + 1. NVMe device + SSH + nvme-cli available (TrueNAS SCALE): + → `nvme format -s 1 /dev/{devname}` (cryptographic erase). + Far faster than badblocks on NVMe (seconds vs hours) and + exercises the controller's secure-erase path, not just user-LBA + writes. + 2. SSH configured + badblocks available (TrueNAS SCALE / Linux): + → badblocks -wsv -b N -c N -p N /dev/{devname} directly over SSH. + 3. SSH configured + badblocks NOT available (TrueNAS CORE / FreeBSD): → uses TrueNAS REST API disk.wipe FULL job + post-wipe SMART check. - 3. No SSH: + 4. No SSH: → simulated timed progress (dev/mock mode). """ from app import ssh_client if ssh_client.is_configured(): + if devname.startswith("nvme") and await _nvme_cli_available(): + return await _stage_surface_validate_nvme(job_id, devname, drive_id) if await _badblocks_available(): return await _stage_surface_validate_ssh(job_id, devname, drive_id) # TrueNAS CORE/FreeBSD: badblocks not available — use native wipe API @@ -881,6 +895,81 @@ async def _stage_surface_validate(job_id: int, devname: str, drive_id: int) -> b return await _stage_timed_simulate(job_id, "surface_validate", settings.surface_validate_seconds) +async def _nvme_cli_available() -> bool: + """Check if nvme-cli is installed on the remote host.""" + from app import ssh_client + try: + async with await ssh_client._connect() as conn: + r = await conn.run("which nvme", check=False) + return r.returncode == 0 + except Exception: + return False + + +async def _stage_surface_validate_nvme(job_id: int, devname: str, + drive_id: int) -> bool: + """NVMe destructive surface test via `nvme format -s 1` (crypto erase). + + Crypto-erase nukes the data encryption key on the drive's controller, + rendering all stored data unrecoverable in milliseconds; the actual + flash is then implicitly trim-able. This is the canonical destructive + burn-in for NVMe — badblocks would write the entire LBA space, which + is slower AND wears the flash unnecessarily. + + Post-format we re-read SMART attributes; the drive should report all + counters reset (life used + spare) and PASSED health. + """ + from app import ssh_client + + await _append_stage_log( + job_id, "surface_validate", + f"[START] nvme format -s 1 /dev/{devname}\n" + f"[NOTE] Cryptographic erase — destroys all data on /dev/{devname}.\n\n" + ) + + cmd = f"nvme format -s 1 --force /dev/{devname}" + try: + async with await ssh_client._connect() as conn: + r = await asyncio.wait_for( + conn.run(cmd, check=False), timeout=600 + ) + except Exception as exc: + await _append_stage_log( + job_id, "surface_validate", f"\n[SSH error] {exc}\n" + ) + await _set_stage_error( + job_id, "surface_validate", f"NVMe format SSH error: {exc}" + ) + return False + + output = (r.stdout or "") + (r.stderr or "") + await _append_stage_log(job_id, "surface_validate", output + "\n") + + if r.returncode != 0: + await _set_stage_error( + job_id, "surface_validate", + f"nvme format exited {r.returncode}: {output.strip()[:200]}" + ) + return False + + # Sanity-check post-format SMART health. + try: + attrs = await ssh_client.get_smart_attributes(devname) + if attrs.get("health") == "FAILED": + await _set_stage_error( + job_id, "surface_validate", + "NVMe SMART health FAILED after format" + ) + return False + except Exception as exc: + log.warning("Post-format SMART check error on %s: %s", devname, exc) + + await _update_stage_percent(job_id, "surface_validate", 100) + await _recalculate_progress(job_id) + _push_update() + return True + + async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int) -> bool: """Run badblocks over SSH, streaming output to stage log.""" from app import ssh_client diff --git a/app/config.py b/app/config.py index 6250bde..a5931cb 100644 --- a/app/config.py +++ b/app/config.py @@ -83,7 +83,29 @@ class Settings(BaseSettings): ssh_key: str = "" # PEM private key content (paste full key including headers) # Application version — used by the /api/v1/updates/check endpoint - app_version: str = "1.0.0-21" + app_version: str = "1.0.0-23" + + # ---- Authentication (1.0.0-22) ---- + # session_secret: HMAC key for signing session cookies. Empty = generate + # one and persist to /data/session_secret on first run (sessions survive + # restarts but rotate if the file is deleted). Set explicitly via + # SESSION_SECRET env var if you want to share secrets across replicas. + session_secret: str = "" + session_max_age_seconds: int = 60 * 60 * 24 * 7 # 7 days + # Initial admin bootstrap. If both env vars are set AND the users table + # is empty at startup, create that account immediately. After that the + # env vars are ignored — change passwords via the UI / database, not + # by editing compose.yml. + initial_admin_username: str = "" + initial_admin_password: str = "" + + # ---- Retention + backup (1.0.0-23) ---- + # log_days : burnin_stages.log_text NULLed out after this many days + # (history rows themselves are preserved). Default keeps + # ~5 weeks; long-soak burn-ins typically finish in <2. + # backup_keep: number of nightly DB snapshots to keep in /data/backups. + retention_log_days: int = 35 + retention_backup_keep: int = 14 settings = Settings() diff --git a/app/database.py b/app/database.py index ce02a64..0f13298 100644 --- a/app/database.py +++ b/app/database.py @@ -99,6 +99,16 @@ _MIGRATIONS = [ # both observe zero active jobs and both insert queued rows. """CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_burnin_per_drive ON burnin_jobs (drive_id) WHERE state IN ('queued', 'running')""", + # 1.0.0-22: app-level login (username + bcrypt password) + """CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + full_name TEXT, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_login_at TEXT + )""", ] diff --git a/app/mailer.py b/app/mailer.py index 642cc8e..8fdffa2 100644 --- a/app/mailer.py +++ b/app/mailer.py @@ -124,9 +124,11 @@ def _build_unlock_banner_html(events: list[dict]) -> str: evt = e.get("event_type") or "" is_boot = evt == "boot_pool_drive_unlocked" is_exported = evt == "exported_pool_drive_unlocked" + is_mounted = evt == "mounted_drive_unlocked" kind = ( "BOOT POOL" if is_boot else "EXPORTED ZFS" if is_exported + else "MOUNTED FILESYSTEM" if is_mounted else "pool" ) when = html.escape((e.get("created_at") or "")[:19]) @@ -355,7 +357,8 @@ async def _fetch_unlock_events_24h() -> list[dict]: WHERE ae.event_type IN ( 'pool_drive_unlocked', 'boot_pool_drive_unlocked', - 'exported_pool_drive_unlocked') + 'exported_pool_drive_unlocked', + 'mounted_drive_unlocked') AND julianday(ae.created_at) >= julianday('now', '-1 day') ORDER BY ae.created_at DESC """) diff --git a/app/main.py b/app/main.py index 97d9f75..99268a4 100644 --- a/app/main.py +++ b/app/main.py @@ -6,10 +6,11 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.sessions import SessionMiddleware from starlette.requests import Request -from starlette.responses import PlainTextResponse +from starlette.responses import JSONResponse, PlainTextResponse -from app import burnin, mailer, poller, settings_store +from app import auth, burnin, mailer, poller, retention, settings_store from app.config import settings from app.database import init_db from app.logging_config import configure as configure_logging @@ -94,16 +95,20 @@ async def lifespan(app: FastAPI): log.info("Starting up") await init_db() settings_store.init() + await auth.bootstrap_admin_if_empty() _client = TrueNASClient() await burnin.init(_client) - poll_task = asyncio.create_task(_supervised_poller(_client)) - mailer_task = asyncio.create_task(mailer.run()) + poll_task = asyncio.create_task(_supervised_poller(_client)) + mailer_task = asyncio.create_task(mailer.run()) + retention_task = asyncio.create_task(retention.run()) yield log.info("Shutting down") poll_task.cancel() mailer_task.cancel() + retention_task.cancel() try: - await asyncio.gather(poll_task, mailer_task, return_exceptions=True) + await asyncio.gather(poll_task, mailer_task, retention_task, + return_exceptions=True) except asyncio.CancelledError: pass await _client.close() @@ -115,6 +120,63 @@ async def lifespan(app: FastAPI): app = FastAPI(title="TrueNAS Burn-In Dashboard", lifespan=lifespan) + +# --------------------------------------------------------------------------- +# Auth gate — must be added BEFORE include_router so it runs first. +# Path-prefix allowlist below covers anything we want reachable without +# a session cookie. SSE streams + WebSockets fall through to the dependency +# in their handler so they 401 cleanly. +# --------------------------------------------------------------------------- + +_PUBLIC_PATHS = {"/login", "/logout", "/health", "/auth/setup"} +_PUBLIC_PREFIXES = ("/static/", "/api/v1/auth/") + + +class _AuthGateMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + path = request.url.path + # Always populate request.state.current_user from the session so + # templates and route handlers can both rely on it. None when + # unauthenticated. + user_id = request.session.get("user_id") + request.state.current_user = ( + await auth.get_user_by_id(int(user_id)) if user_id else None + ) + + if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PREFIXES): + return await call_next(request) + if request.state.current_user is not None: + return await call_next(request) + # Unauthenticated. HTML GETs bounce to /login with a `next` query + # arg so the user lands back where they tried to go after logging + # in. Anything else (API calls, SSE, POSTs) gets a 401. + accept = request.headers.get("accept", "") + if request.method == "GET" and "text/html" in accept: + return auth.login_redirect(path) + return JSONResponse( + {"detail": "Authentication required"}, status_code=401 + ) + + +app.add_middleware(_AuthGateMiddleware) +# SessionMiddleware must be added LAST (it wraps innermost so request.session +# is populated before AuthGate runs). +app.add_middleware( + SessionMiddleware, + secret_key=auth.get_session_secret(), + session_cookie="burnin_session", + max_age=settings.session_max_age_seconds, + https_only=False, # we sit behind nginx-proxy-manager; trust upstream + # SameSite=strict is the primary CSRF mitigation: the browser never + # sends the session cookie on cross-site requests, so an attacker + # page can't trigger any state-changing endpoint even if it knows + # the URL. Trade-off: an external link (email, chat) into the app + # won't carry the session — user has to re-auth via /login. For an + # internal-only tool that's the right default. + same_site="strict", +) + + if settings.allowed_ips: app.add_middleware(_IPAllowlistMiddleware, allowed_ips=settings.allowed_ips) log.info("IP allowlist active: %s", settings.allowed_ips) diff --git a/app/poller.py b/app/poller.py index 4c013d7..a3176df 100644 --- a/app/poller.py +++ b/app/poller.py @@ -373,16 +373,19 @@ async def poll_cycle(client: TrueNASClient) -> int: detection_ok = True pool_map: dict = {} zfs_member_set: set = set() + mounted_set: set = set() try: from app import ssh_client as _ssh if _ssh.is_configured(): pm = await _ssh.get_pool_membership() zs = await _ssh.get_zfs_member_drives() - if pm is None or zs is None: + ms = await _ssh.get_mounted_drives() + if pm is None or zs is None or ms is None: detection_ok = False else: pool_map = pm zfs_member_set = zs + mounted_set = ms # SSH unconfigured (mock/dev mode) — detection_ok stays True with # empty maps, so dev mode never artificially locks drives. except Exception: @@ -404,6 +407,14 @@ async def poll_cycle(client: TrueNASClient) -> int: for devname in zfs_member_set: if devname not in pool_map: pool_map[devname] = {"pool": "(exported)", "role": "exported"} + # Drives with a non-ZFS mount somewhere (XFS/ext4/scratch/etc.) + # also lock — wiping a mounted FS is just as catastrophic. Lower + # precedence than active pool membership, since a drive in `tank` + # would also show under findmnt for the pool's mountpoint via + # /dev/zd* or zvol — but those are filtered in the parser. + for devname in mounted_set: + if devname not in pool_map: + pool_map[devname] = {"pool": "(mounted)", "role": "mounted"} # Index running jobs by (devname, test_type) active: dict[tuple[str, str], dict] = {} diff --git a/app/retention.py b/app/retention.py new file mode 100644 index 0000000..c821c91 --- /dev/null +++ b/app/retention.py @@ -0,0 +1,142 @@ +""" +Background retention + backup tasks. + +* Stage-log pruning: each surface_validate burn-in stage can write tens of + MB of badblocks output to burnin_stages.log_text. Without retention the + DB grows unbounded — we observed 447 MB on the live host after a few + weeks of use. Nightly job nulls log_text on stages older than + `retention_days`, then VACUUMs to reclaim pages. + +* Automated DB backup: nightly `sqlite3 .backup` to `backups/app-YYYY- + MM-DD.db` inside the data dir. Retains the most recent + `backup_keep_count` files. Uses the online-backup API so the live DB + isn't locked. + +Both tasks share a single hourly tick — cheap and fits the existing +mailer-style background-loop pattern. Failures are logged but never +crash the supervisor. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import aiosqlite + +from app.config import settings + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Stage-log pruning +# --------------------------------------------------------------------------- + +async def prune_stage_logs(retention_days: int) -> int: + """NULL out log_text on burnin_stages older than retention_days. + Returns the number of rows updated.""" + cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).isoformat() + async with aiosqlite.connect(settings.db_path) as db: + cur = await db.execute( + """UPDATE burnin_stages + SET log_text = NULL + WHERE log_text IS NOT NULL + AND finished_at IS NOT NULL + AND finished_at < ?""", + (cutoff,), + ) + n = cur.rowcount or 0 + await db.commit() + if n > 0: + log.info("Retention: pruned log_text on %d stage row(s) older than %d days", + n, retention_days) + return n + + +async def vacuum_db() -> None: + """Reclaim pages freed by the prune. SQLite VACUUM rewrites the file + so it must run outside any transaction.""" + async with aiosqlite.connect(settings.db_path, isolation_level=None) as db: + await db.execute("VACUUM") + log.info("Retention: VACUUM completed") + + +# --------------------------------------------------------------------------- +# Backup +# --------------------------------------------------------------------------- + +def _backup_dir() -> Path: + return Path(settings.db_path).parent / "backups" + + +async def backup_db(keep_count: int) -> Path | None: + """Online-backup the live DB to backups/app-YYYY-MM-DD.db. Returns + the new file's path. Old backups beyond keep_count are deleted.""" + bdir = _backup_dir() + bdir.mkdir(parents=True, exist_ok=True) + today = datetime.now().strftime("%Y-%m-%d") + out = bdir / f"app-{today}.db" + + # aiosqlite.Connection.backup() is an async wrapper around + # sqlite3.Connection.backup — atomic online snapshot that doesn't + # block writers (it copies pages in batches and yields between). + async with aiosqlite.connect(settings.db_path) as src: + async with aiosqlite.connect(str(out)) as dst: + await src.backup(dst) + + log.info("Retention: DB backed up to %s (%d bytes)", out, out.stat().st_size) + + # Keep the N most recent backups; delete older. + snapshots = sorted(bdir.glob("app-*.db"), key=lambda p: p.stat().st_mtime, + reverse=True) + for old in snapshots[keep_count:]: + try: + old.unlink() + log.info("Retention: removed old backup %s", old.name) + except OSError as exc: + log.warning("Retention: could not remove %s: %s", old, exc) + + return out + + +# --------------------------------------------------------------------------- +# Scheduler — single hourly tick fires daily-grain work +# --------------------------------------------------------------------------- + +_RUN_HOUR = 3 # 03:00 local time — quiet for most homelabs +_state = {"last_run_date": None} + + +async def run() -> None: + """Background loop. Wakes every 5 min, runs the daily tasks once + when the local hour matches _RUN_HOUR and we haven't run today.""" + log.info( + "Retention loop started (run at %02d:00 local; prune>%d days; keep %d backups)", + _RUN_HOUR, + settings.retention_log_days, + settings.retention_backup_keep, + ) + while True: + try: + now = datetime.now() + today = now.strftime("%Y-%m-%d") + if now.hour == _RUN_HOUR and _state["last_run_date"] != today: + _state["last_run_date"] = today + try: + pruned = await prune_stage_logs(settings.retention_log_days) + if pruned: + await vacuum_db() + except Exception as exc: + log.exception("Retention: pruning failed: %s", exc) + try: + await backup_db(settings.retention_backup_keep) + except Exception as exc: + log.exception("Retention: backup failed: %s", exc) + except asyncio.CancelledError: + raise + except Exception as exc: + log.exception("Retention loop iteration failed: %s", exc) + await asyncio.sleep(300) # 5 min diff --git a/app/routes.py b/app/routes.py index 1cfadc2..4106cbe 100644 --- a/app/routes.py +++ b/app/routes.py @@ -6,10 +6,10 @@ from datetime import datetime, timezone import aiosqlite from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket -from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse from sse_starlette.sse import EventSourceResponse -from app import burnin, mailer, poller, settings_store +from app import auth, burnin, mailer, poller, settings_store from app.config import settings from app.database import get_db from app.models import ( @@ -229,6 +229,134 @@ def _stale_context(poller_state: dict) -> dict: return {"stale": False, "stale_seconds": 0} +# --------------------------------------------------------------------------- +# Auth — login / logout / first-user setup +# --------------------------------------------------------------------------- + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, next: str = "/", error: str | None = None): + needs_setup = (await auth.user_count()) == 0 + return templates.TemplateResponse(request, "login.html", { + "request": request, + "needs_setup": needs_setup, + "error": error, + "next": next if next.startswith("/") else "/", + }) + + +def _client_ip(request: Request) -> str: + fwd = (request.headers.get("X-Forwarded-For") or "").split(",")[0].strip() + return fwd or (request.client.host if request.client else "unknown") + + +@router.post("/login") +async def login_submit(request: Request): + form = await request.form() + username = (form.get("username") or "").strip() + password = form.get("password") or "" + next_url = form.get("next") or "/" + if not next_url.startswith("/"): + next_url = "/" + ip = _client_ip(request) + + # Rate-limit gate — checked BEFORE bcrypt so an attacker can't burn CPU. + locked_until = auth.login_locked_until(username, ip) + if locked_until is not None: + remaining = int(locked_until - __import__("time").time()) + return templates.TemplateResponse(request, "login.html", { + "request": request, + "needs_setup": False, + "error": f"Too many failed attempts. Try again in {remaining // 60} min.", + "next": next_url, + }, status_code=429) + + found = await auth.get_user_by_username(username) + if not found or not auth.verify_password(password, found[1]): + # Constant-ish-time: still call verify on a junk hash if user missing + # so the timing of "user not found" matches "wrong password." + if not found: + auth.verify_password(password, "$2b$12$" + "x" * 53) + tripped = auth.record_login_failure(username, ip) + await auth.audit_auth_event( + "user_login_locked_out" if tripped else "user_login_failed", + username, + f"Failed login from {ip}" + ( + f" — IP/user locked out for {auth.LOGIN_LOCKOUT_SECONDS // 60} min" + if tripped else "" + ), + ) + return templates.TemplateResponse(request, "login.html", { + "request": request, + "needs_setup": False, + "error": "Invalid username or password.", + "next": next_url, + }, status_code=401) + + user = found[0] + auth.clear_login_failures(username, ip) + request.session["user_id"] = user.id + request.session["username"] = user.username + await auth.touch_last_login(user.id) + await auth.audit_auth_event( + "user_login", user.username, f"Signed in from {ip}" + ) + return RedirectResponse(url=next_url, status_code=303) + + +@router.post("/api/v1/auth/setup") +async def auth_first_user_setup(request: Request): + """Create the first admin from the login page when the users table is + empty. Public endpoint — but only does anything when zero users exist.""" + if (await auth.user_count()) > 0: + raise HTTPException(status_code=409, detail="Users already exist.") + form = await request.form() + username = (form.get("username") or "").strip() + password = form.get("password") or "" + full_name = (form.get("full_name") or "").strip() or None + try: + user = await auth.create_user(username, password, full_name, is_admin=True) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + request.session["user_id"] = user.id + request.session["username"] = user.username + await auth.touch_last_login(user.id) + return RedirectResponse(url="/", status_code=303) + + +@router.get("/logout") +@router.post("/logout") +async def logout(request: Request): + user = request.state.current_user if hasattr(request.state, "current_user") else None + if user: + await auth.audit_auth_event( + "user_logout", user.username, f"Signed out from {_client_ip(request)}" + ) + request.session.clear() + return RedirectResponse(url="/login", status_code=303) + + +@router.post("/api/v1/auth/change-password") +async def change_password(request: Request): + user = request.state.current_user if hasattr(request.state, "current_user") else None + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + form = await request.form() + current = form.get("current_password") or "" + new_pw = form.get("new_password") or "" + confirm = form.get("confirm_password") or "" + if new_pw != confirm: + raise HTTPException(status_code=400, detail="New passwords do not match.") + try: + await auth.change_password(user.id, current, new_pw) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + await auth.audit_auth_event( + "user_password_changed", user.username, + f"Password changed from {_client_ip(request)}", + ) + return {"ok": True} + + # --------------------------------------------------------------------------- # Dashboard # --------------------------------------------------------------------------- @@ -313,18 +441,73 @@ async def sse_drives(request: Request): @router.get("/health") async def health(db: aiosqlite.Connection = Depends(get_db)): + """Real readiness check, not just process-is-running. + + Verifies (a) DB writable, (b) poller has succeeded recently relative + to the configured stale_threshold_seconds, (c) SSH reachable when + configured. Returns 503 when any check fails so a proxy/orchestrator + health probe can take the container out of rotation. + """ + from datetime import datetime, timezone + from fastapi.responses import JSONResponse + from app import ssh_client as _ssh + + checks: dict[str, dict] = {} + + # DB probe — confirm the journal is healthy (PRAGMA reads journal_mode + # and would fail loudly if WAL is wedged or the file is unreadable). + try: + cur = await db.execute("PRAGMA journal_mode") + await cur.fetchone() + checks["db"] = {"ok": True} + except Exception as exc: + checks["db"] = {"ok": False, "error": str(exc)} + ps = poller.get_state() + last = ps.get("last_poll_at") + poll_age = None + if last: + try: + t = datetime.fromisoformat(last) + if t.tzinfo is None: + t = t.replace(tzinfo=timezone.utc) + poll_age = (datetime.now(timezone.utc) - t).total_seconds() + except Exception: + poll_age = None + poll_ok = ps.get("healthy") and ( + poll_age is None or poll_age <= settings.stale_threshold_seconds * 3 + ) + checks["poller"] = { + "ok": bool(poll_ok), + "last_error": ps.get("last_error"), + "last_poll_at": last, + "age_seconds": int(poll_age) if poll_age is not None else None, + } + + # SSH probe — only when configured. Cheap (single sensors -j). + if _ssh.is_configured(): + try: + r = await _ssh.test_connection() + checks["ssh"] = {"ok": bool(r.get("ok")), + "error": r.get("error")} + except Exception as exc: + checks["ssh"] = {"ok": False, "error": str(exc)} + else: + checks["ssh"] = {"ok": True, "skipped": True} + cur = await db.execute("SELECT COUNT(*) FROM drives") row = await cur.fetchone() drives_tracked = row[0] if row else 0 - return { - "status": "ok" if ps["healthy"] else "degraded", - "last_poll_at": ps["last_poll_at"], - "last_error": ps["last_error"], - "consecutive_failures": ps.get("consecutive_failures", 0), - "poll_interval_seconds": settings.poll_interval_seconds, - "drives_tracked": drives_tracked, + + status_ok = all(c["ok"] for c in checks.values()) + body = { + "status": "ok" if status_ok else "degraded", + "checks": checks, + "drives_tracked": drives_tracked, + "poll_interval_s": settings.poll_interval_seconds, + "version": settings.app_version, } + return JSONResponse(body, status_code=200 if status_ok else 503) @router.get("/api/v1/drives", response_model=list[DriveResponse]) diff --git a/app/ssh_client.py b/app/ssh_client.py index f84ca9c..612e74e 100644 --- a/app/ssh_client.py +++ b/app/ssh_client.py @@ -366,6 +366,51 @@ async def get_pool_membership() -> dict | None: return _parse_zpool_list_output(r.stdout) +async def get_mounted_drives() -> set | None: + """Return base devnames of every drive whose partitions are mounted + anywhere right now. Defense-in-depth on top of pool detection — catches + XFS/ext4/etc. scratch disks the operator forgot about. Returns None on + any failure (caller treats that as 'preserve previous state').""" + if not is_configured(): + return set() + cmd = "findmnt -no SOURCE 2>/dev/null" + try: + async with await _connect() as conn: + r = await conn.run(cmd, check=False) + if r.returncode != 0 or not r.stdout: + # findmnt always has at least / mounted on a Linux host; + # empty output is itself suspicious. Treat as failure. + return None + except Exception: + return None + return _parse_findmnt_sources(r.stdout) + + +def _parse_findmnt_sources(stdout: str) -> set: + """Pure parser for findmnt output. Strips partitions; ignores tmpfs, + overlay, zfs (zfs is handled by pool detection).""" + import re as _re + out: set = set() + for raw in stdout.splitlines(): + s = raw.strip() + if not s.startswith("/dev/"): + continue + # Skip ZFS filesystems (those are pool/exported drives, handled + # separately and shouldn't double-lock as 'mounted'). + if "/dev/zd" in s or "/dev/zvol" in s: + continue + name = s[len("/dev/"):].split("[")[0] # bind mounts can have [subdir] + if name.startswith("nvme"): + m = _re.match(r"^(nvme\d+n\d+)", name) + if m: + out.add(m.group(1)) + else: + m = _re.match(r"^(sd[a-z]+)", name) + if m: + out.add(m.group(1)) + return out + + async def get_smart_health_map(devnames: list[str]) -> dict | None: """Return {devname: 'PASSED'|'FAILED'|'UNKNOWN'} for every devname. diff --git a/app/static/app.css b/app/static/app.css index 094f58f..f1b2fb1 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -2426,6 +2426,118 @@ tr.drawer-row-active { color: var(--yellow); } +/* ----------------------------------------------------------------------- + Login screen +----------------------------------------------------------------------- */ +.login-body { + background: var(--bg); + color: var(--text); + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + margin: 0; +} +.login-card { + width: min(420px, 92vw); + background: var(--bg-card, #161b22); + border: 1px solid var(--border); + border-radius: 10px; + padding: 28px 30px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); +} +.login-header { margin-bottom: 18px; } +.login-title { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.01em; +} +.login-sub { + margin-top: 2px; + color: var(--text-muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.login-blurb { + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; + margin: 0 0 18px; +} +.login-error { + background: color-mix(in srgb, var(--red, #e25555) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--red, #e25555) 50%, transparent); + color: var(--red, #e25555); + padding: 10px 12px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 14px; +} +.login-form { display: flex; flex-direction: column; gap: 4px; } +.login-label { + font-size: 12px; + color: var(--text-muted); + margin-top: 8px; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.login-optional { text-transform: none; opacity: 0.7; } +.login-input { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 9px 12px; + font-size: 14px; + font-family: inherit; + transition: border-color .15s; +} +.login-input:focus { + border-color: var(--accent, #3b82f6); + outline: none; +} +.login-submit { + margin-top: 18px; + background: var(--accent, #3b82f6); + color: #fff; + border: none; + border-radius: 6px; + padding: 11px 14px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity .15s; +} +.login-submit:hover { opacity: 0.9; } +.login-footer { + margin-top: 22px; + padding-top: 16px; + border-top: 1px solid var(--border); + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.55; +} +.login-code { + display: inline-block; + margin-top: 4px; + padding: 2px 6px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} +.header-user { + color: var(--text-muted); + font-size: 12px; + margin-left: 8px; + padding-left: 12px; + border-left: 1px solid var(--border); +} +.header-logout { font-size: 12px; } + /* ----------------------------------------------------------------------- Pool-membership lock indicators ----------------------------------------------------------------------- */ @@ -2465,6 +2577,21 @@ tr.drawer-row-active { .pool-lock-icon.pool-lock-exported { color: #e07a3f; } +.pool-pill.pool-pill-mounted { + background: color-mix(in srgb, #c477e0 16%, transparent); + color: #c477e0; + border-color: color-mix(in srgb, #c477e0 45%, transparent); +} +.pool-lock-icon.pool-lock-mounted { + color: #c477e0; +} +.btn-unlock-mounted { + border-color: color-mix(in srgb, #c477e0 55%, transparent); + color: #c477e0; +} +.btn-unlock-mounted:hover { + background: color-mix(in srgb, #c477e0 14%, transparent); +} .btn-unlock { background: transparent; border: 1px solid color-mix(in srgb, var(--yellow) 50%, transparent); diff --git a/app/static/app.js b/app/static/app.js index c840936..7baedb3 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1,6 +1,15 @@ (function () { 'use strict'; + // Default operator name — prefer the logged-in user (rendered into a + // by layout.html), fall back to the localStorage memory of the + // last-typed value, and empty string as last resort. + function defaultOperator() { + var meta = document.querySelector('meta[name="default-operator"]'); + if (meta && meta.content) return meta.content; + return localStorage.getItem('burnin_operator') || ''; + } + // ----------------------------------------------------------------------- // Filter bar + stats bar // ----------------------------------------------------------------------- @@ -417,7 +426,7 @@ async function startSmartTest(btn) { var driveId = btn.dataset.driveId; var testType = btn.dataset.testType; - var operator = localStorage.getItem('burnin_operator') || 'unknown'; + var operator = defaultOperator() || 'unknown'; btn.disabled = true; try { @@ -483,7 +492,7 @@ return; } if (!confirm('Cancel ALL ' + cancelBtns.length + ' active burn-in job(s)? This cannot be undone.')) return; - var operator = localStorage.getItem('burnin_operator') || 'unknown'; + var operator = defaultOperator() || 'unknown'; var count = 0; for (var i = 0; i < cancelBtns.length; i++) { var jobId = cancelBtns[i].dataset.jobId; @@ -557,7 +566,7 @@ document.getElementById('confirm-serial').value = ''; document.getElementById('confirm-hint').textContent = 'Expected: ' + modalSerial; - var savedOp = localStorage.getItem('burnin_operator') || ''; + var savedOp = defaultOperator(); document.getElementById('operator-input').value = savedOp; // Init drag on first open (list is in static DOM) @@ -673,6 +682,62 @@ return true; } + // ----------------------------------------------------------------------- + // Change-password modal + // ----------------------------------------------------------------------- + + function openPasswordModal() { + var m = document.getElementById('password-modal'); + if (!m) return; + document.getElementById('pw-current').value = ''; + document.getElementById('pw-new').value = ''; + document.getElementById('pw-confirm').value = ''; + document.getElementById('pw-hint').textContent = ''; + document.getElementById('password-modal-submit-btn').disabled = true; + m.removeAttribute('hidden'); + setTimeout(function () { document.getElementById('pw-current').focus(); }, 50); + } + function closePasswordModal() { + var m = document.getElementById('password-modal'); + if (m) m.setAttribute('hidden', ''); + } + function validatePasswordModal() { + var cur = document.getElementById('pw-current').value; + var nw = document.getElementById('pw-new').value; + var cf = document.getElementById('pw-confirm').value; + var hint = document.getElementById('pw-hint'); + var ok = cur.length > 0 && nw.length >= 8 && nw === cf; + if (nw.length > 0 && nw.length < 8) hint.textContent = 'Min 8 characters.'; + else if (nw.length >= 8 && cf.length > 0 && nw !== cf) hint.textContent = "Passwords don't match."; + else hint.textContent = ''; + document.getElementById('password-modal-submit-btn').disabled = !ok; + } + async function submitPasswordChange() { + var btn = document.getElementById('password-modal-submit-btn'); + btn.disabled = true; + var fd = new FormData(); + fd.append('current_password', document.getElementById('pw-current').value); + fd.append('new_password', document.getElementById('pw-new').value); + fd.append('confirm_password', document.getElementById('pw-confirm').value); + try { + var resp = await fetch('/api/v1/auth/change-password', { + method: 'POST', + body: fd, + }); + var data = await resp.json().catch(function () { return {}; }); + if (!resp.ok) { + showToast(data.detail || 'Password change failed', 'error'); + btn.disabled = false; + return; + } + closePasswordModal(); + showToast('Password updated.', 'success'); + } catch (err) { + showToast('Network error', 'error'); + btn.disabled = false; + } + } + // ----------------------------------------------------------------------- // Pool-drive Unlock modal // ----------------------------------------------------------------------- @@ -686,8 +751,10 @@ var poolRole = btn.dataset.poolRole || 'data'; var isBoot = btn.dataset.isBootPool === '1'; var isExported = btn.dataset.isExported === '1'; + var isMounted = btn.dataset.isMounted === '1'; if (isBoot) unlockExpectedToken = 'DESTROY BOOT POOL'; else if (isExported) unlockExpectedToken = 'DESTROY EXPORTED POOL'; + else if (isMounted) unlockExpectedToken = 'DESTROY MOUNTED FILESYSTEM'; else unlockExpectedToken = poolName; document.getElementById('unlock-devname').textContent = btn.dataset.devname || '—'; @@ -699,6 +766,9 @@ if (isExported) { chip.textContent = 'exported ZFS'; chip.className = 'chip chip-aborted'; + } else if (isMounted) { + chip.textContent = 'mounted FS'; + chip.className = 'chip chip-aborted'; } else { chip.textContent = poolName + ' · ' + poolRole; chip.className = 'chip ' + (isBoot ? 'chip-failed' : 'chip-aborted'); @@ -722,6 +792,13 @@ 'Burning it in will silently destroy whatever pool that data belongs to — including ' + 'pools that another system may be relying on. Confirm you have already evacuated or ' + 'reassigned the pool before continuing.'; + } else if (isMounted) { + titleEl.textContent = 'Unlock drive with MOUNTED filesystem'; + warnTitle.textContent = 'This drive has a non-ZFS filesystem currently mounted.'; + warnBody.textContent = + 'findmnt reports a partition on this drive is mounted right now. Burning it in will ' + + 'destroy whatever data is on that filesystem and almost certainly leave the mount ' + + 'point in a broken state. Unmount it first, or confirm you really mean to wipe it.'; } else { titleEl.textContent = 'Unlock pool drive'; warnTitle.textContent = "This drive belongs to zpool '" + poolName + "'."; @@ -735,7 +812,7 @@ document.getElementById('unlock-confirm-input').value = ''; document.getElementById('unlock-reason-input').value = ''; - var savedOp = localStorage.getItem('burnin_operator') || ''; + var savedOp = defaultOperator(); document.getElementById('unlock-operator-input').value = savedOp; validateUnlockModal(); @@ -886,7 +963,7 @@ function openBatchModal() { var modal = document.getElementById('batch-modal'); if (!modal) return; - var savedOp = localStorage.getItem('burnin_operator') || ''; + var savedOp = defaultOperator(); document.getElementById('batch-operator-input').value = savedOp; document.getElementById('batch-confirm-cb').checked = false; // Reset stages to all-on (keep user's drag order) @@ -1010,7 +1087,7 @@ async function cancelBurnin(btn) { var jobId = btn.dataset.jobId; - var operator = localStorage.getItem('burnin_operator') || 'unknown'; + var operator = defaultOperator() || 'unknown'; if (!confirm('Cancel this burn-in job? This cannot be undone.')) return; @@ -1048,6 +1125,20 @@ var cancelSmartBtn = e.target.closest('.btn-cancel-smart'); if (cancelSmartBtn && !cancelSmartBtn.disabled) { cancelSmartTest(cancelSmartBtn); return; } + // Change password header link + if (e.target.id === 'open-password-modal' || e.target.closest('#open-password-modal')) { + e.preventDefault(); + openPasswordModal(); + return; + } + if (e.target.closest('#password-modal-close-btn') || + e.target.closest('#password-modal-cancel-btn')) { + closePasswordModal(); + return; + } + if (e.target.id === 'password-modal') { closePasswordModal(); return; } + if (e.target.id === 'password-modal-submit-btn') { submitPasswordChange(); return; } + // Pool-drive unlock button (single drive) var unlockBtn = e.target.closest('.btn-unlock'); if (unlockBtn && !unlockBtn.disabled) { openUnlockModal(unlockBtn); return; } @@ -1105,6 +1196,7 @@ document.addEventListener('input', function (e) { var id = e.target.id; + if (id === 'pw-current' || id === 'pw-new' || id === 'pw-confirm') validatePasswordModal(); if (id === 'unlock-operator-input' || id === 'unlock-reason-input' || id === 'unlock-confirm-input') validateUnlockModal(); if (id === 'operator-input' || id === 'confirm-serial') validateModal(); @@ -1112,6 +1204,8 @@ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { + var pwModal = document.getElementById('password-modal'); + if (pwModal && !pwModal.hidden) { closePasswordModal(); return; } var uModal = document.getElementById('unlock-modal'); if (uModal && !uModal.hidden) { closeUnlockModal(); return; } var modal = document.getElementById('start-modal'); diff --git a/app/templates/components/drives_table.html b/app/templates/components/drives_table.html index 77ea2ff..64dc651 100644 --- a/app/templates/components/drives_table.html +++ b/app/templates/components/drives_table.html @@ -83,6 +83,7 @@ {%- set pool_locked = drive.pool_name and not drive.pool_unlocked_until %} {%- set is_boot_pool = drive.pool_name == 'boot-pool' %} {%- set is_exported = drive.pool_role == 'exported' %} + {%- set is_mounted = drive.pool_role == 'mounted' %} {%- set selectable = not bi_active and not short_busy and not long_busy and not pool_locked %} {%- set bi_done = drive.burnin and drive.burnin.state in ('passed', 'failed', 'cancelled', 'unknown') %} {%- set smart_done = (drive.smart_short and drive.smart_short.state in ('passed','failed','aborted')) @@ -97,15 +98,15 @@ {%- if drive.pool_name -%} - 🔒 + 🔒 {%- endif -%} {{ drive.devname }} {{ drive.model or "Unknown" }} {%- if drive.pool_name %} - {% if is_exported %}exported ZFS{% else %}{{ drive.pool_name }} · {{ drive.pool_role or 'data' }}{% endif %} + {% if is_exported %}exported ZFS{% elif is_mounted %}mounted FS{% else %}{{ drive.pool_name }} · {{ drive.pool_role or 'data' }}{% endif %} {%- endif %} {%- if drive.location %} - + data-is-mounted="{{ '1' if is_mounted else '0' }}" + title="{% if is_boot_pool %}Drive is in BOOT POOL '{{ drive.pool_name }}' — click to unlock{% elif is_exported %}Drive carries ZFS data from a previously-imported pool — click to unlock{% elif is_mounted %}Drive has a mounted filesystem — click to unlock{% else %}Drive is in pool '{{ drive.pool_name }}' — click to unlock{% endif %}">🔒 Unlock {%- else %} + + + + + + + diff --git a/app/templates/layout.html b/app/templates/layout.html index 4b783dc..3fe032d 100644 --- a/app/templates/layout.html +++ b/app/templates/layout.html @@ -5,6 +5,9 @@ {% block title %}TrueNAS Burn-In{% endblock %} + {% if request.state.current_user %} + + {% endif %} @@ -38,6 +41,11 @@ Audit Settings API + {% if request.state.current_user %} + {{ request.state.current_user.full_name or request.state.current_user.username }} + Change password + Logout + {% endif %} @@ -57,6 +65,10 @@ {% block content %}{% endblock %} +{% if request.state.current_user %} +{% include "components/modal_password.html" %} +{% endif %} +
diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..85925cd --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,67 @@ + + + + + + Sign in — TrueNAS Burn-In + + + + +
+ + + {% if error %} + + {% endif %} + + {% if needs_setup %} + + + {% else %} + + {% endif %} + + +
+ + + diff --git a/requirements.txt b/requirements.txt index 4c1f41d..9ece21f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,6 @@ pydantic-settings jinja2 sse-starlette asyncssh +itsdangerous>=2.1 +bcrypt>=4.0,<5.0 +python-multipart>=0.0.7