feat: app-level login + hardening sweep (1.0.0-22 -> 1.0.0-23)

Two layered changes shipped in this branch:

== 1.0.0-22: app-level authentication ==

The dashboard previously had only an IP allowlist. Adds username +
bcrypt password auth, signed-cookie sessions, and a "first user setup"
flow.

* New app/auth.py: User dataclass, bcrypt hash/verify, get_user_by_id/
  username, create_user, touch_last_login, FastAPI `get_current_user`
  dependency. Session secret loaded from SESSION_SECRET env or persisted
  to /data/session_secret.
* New app/auth_cli.py: `python -m app.auth_cli list|reset|add` for
  out-of-band user management. Passwords always read from a TTY prompt.
* Schema: idempotent ALTER for `users` table (id, username unique,
  password_hash, full_name, is_admin, created_at, last_login_at).
* main.py: SessionMiddleware (HMAC-signed cookie, max-age 7 days,
  SameSite=strict — see hardening section) + _AuthGateMiddleware that
  populates request.state.current_user and bounces unauth'd HTML GETs
  to /login while returning 401 JSON for everything else.
* Routes: GET /login renders first-user-setup form when users table is
  empty otherwise sign-in form; POST /login; POST /api/v1/auth/setup
  (only works while empty); GET|POST /logout.
* Bootstrap: env vars INITIAL_ADMIN_USERNAME + INITIAL_ADMIN_PASSWORD
  create the first admin on startup if both set AND users table empty.
  Ignored thereafter — change passwords via UI or CLI.
* Layout: header shows current_user.full_name|username + Logout link.
  Modal operator field auto-fills from the logged-in user via
  <meta name="default-operator"> rendered in layout (replaces the
  localStorage-only previous behaviour).
* requirements.txt: pinned bcrypt>=4.0,<5.0, itsdangerous>=2.1,
  python-multipart>=0.0.7. First step toward addressing the
  unpinned-deps gotcha.
* New app/templates/login.html with first-user-setup variant.

== 1.0.0-23: hardening sweep ==

Closes the eight-item gap audit:

* DB retention + automated backup. New app/retention.py runs daily at
  03:00 local. Nulls burnin_stages.log_text on stages older than
  retention_log_days (default 35), VACUUMs to reclaim pages, then runs
  `sqlite3 .backup` to /data/backups/app-YYYY-MM-DD.db keeping the
  retention_backup_keep most recent (default 14). Wired into the
  lifespan supervisor next to mailer/poller.

* CSRF mitigation. SessionMiddleware bumped to SameSite=strict so the
  browser refuses to send the session cookie on cross-site POSTs —
  removes the actual CSRF vector. Trade-off: external links into the
  app require re-auth.

* Login rate limiting. In-memory per-username AND per-source-IP failure
  counters in auth.py. 10 failures within 10 min trips a 15-min lockout
  for both keys. Returns HTTP 429 with a clear "try again in N min"
  message. Cleared on successful login.

* Login audit events. New event types in audit_events: user_login,
  user_login_failed, user_login_locked_out, user_logout,
  user_password_changed. All include source IP. Recorded via
  auth.audit_auth_event().

* Password change UI. Header link "Change password" opens
  templates/components/modal_password.html (current/new/confirm).
  Posts to POST /api/v1/auth/change-password — bcrypt-verifies current,
  requires >=8 char new pw, writes audit event.

* NVMe burn-in path. _stage_surface_validate now detects nvme*
  devnames and routes to _stage_surface_validate_nvme() which runs
  `nvme format -s 1 --force` (cryptographic erase). Seconds vs hours
  of badblocks, exercises the controller's secure-erase. Falls back
  to badblocks if nvme-cli isn't installed. Post-format SMART check.

* Mounted-FS detection. ssh_client.get_mounted_drives() runs
  `findmnt -no SOURCE`, parses non-ZFS sources back to base devnames.
  Poller treats them as pool_name='(mounted)', pool_role='mounted'.
  Confirm token DESTROY MOUNTED FILESYSTEM, distinct purple styling,
  audit event mounted_drive_unlocked, daily-report banner picks it up.

* Deeper /health. Real readiness check — DB write probe (PRAGMA
  journal_mode), poller freshness (age <= 3x stale_threshold), SSH
  test_connection() when configured. Returns 503 when any check fails
  so a proxy/orchestrator can take the container out of rotation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Walter 2026-05-02 11:08:29 -04:00
parent 5da1a1704f
commit d4c0770b9e
18 changed files with 1374 additions and 35 deletions

336
app/auth.py Normal file
View file

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

99
app/auth_cli.py Normal file
View file

@ -0,0 +1,99 @@
"""Password reset / user management CLI.
Run inside the container:
docker exec -it truenas-burnin python -m app.auth_cli reset <username>
docker exec -it truenas-burnin python -m app.auth_cli list
docker exec -it truenas-burnin python -m app.auth_cli add <username>
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())

View file

@ -186,6 +186,8 @@ BOOT_POOL_NAME = "boot-pool"
BOOT_POOL_CONFIRM_TOKEN = "DESTROY BOOT POOL" BOOT_POOL_CONFIRM_TOKEN = "DESTROY BOOT POOL"
EXPORTED_POOL_ROLE = "exported" EXPORTED_POOL_ROLE = "exported"
EXPORTED_CONFIRM_TOKEN = "DESTROY EXPORTED POOL" EXPORTED_CONFIRM_TOKEN = "DESTROY EXPORTED POOL"
MOUNTED_ROLE = "mounted"
MOUNTED_CONFIRM_TOKEN = "DESTROY MOUNTED FILESYSTEM"
@dataclass @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." "This drive is not part of any pool — no unlock needed."
) )
# Boot-pool and exported pools both get dedicated, harder-to-fat- # Boot-pool / exported / mounted-fs all get dedicated, harder-to-
# finger tokens. Active data pools just need their pool name typed. # fat-finger tokens. Active data pools just need their pool name
# typed.
if pool_name == BOOT_POOL_NAME: if pool_name == BOOT_POOL_NAME:
expected = BOOT_POOL_CONFIRM_TOKEN expected = BOOT_POOL_CONFIRM_TOKEN
elif pool_role == EXPORTED_POOL_ROLE: elif pool_role == EXPORTED_POOL_ROLE:
expected = EXPORTED_CONFIRM_TOKEN expected = EXPORTED_CONFIRM_TOKEN
elif pool_role == MOUNTED_ROLE:
expected = MOUNTED_CONFIRM_TOKEN
else: else:
expected = pool_name expected = pool_name
if (confirm_token or "").strip() != expected: 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" evt = "boot_pool_drive_unlocked"
elif pool_role == EXPORTED_POOL_ROLE: elif pool_role == EXPORTED_POOL_ROLE:
evt = "exported_pool_drive_unlocked" evt = "exported_pool_drive_unlocked"
elif pool_role == MOUNTED_ROLE:
evt = "mounted_drive_unlocked"
else: else:
evt = "pool_drive_unlocked" evt = "pool_drive_unlocked"
await db.execute( 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: Surface validation stage auto-routes to the right implementation:
1. SSH configured + badblocks available (TrueNAS SCALE / Linux): 1. NVMe device + SSH + nvme-cli available (TrueNAS SCALE):
runs badblocks -wsv -b 4096 -p 1 /dev/{devname} directly over SSH. `nvme format -s 1 /dev/{devname}` (cryptographic erase).
2. SSH configured + badblocks NOT available (TrueNAS CORE / FreeBSD): 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. uses TrueNAS REST API disk.wipe FULL job + post-wipe SMART check.
3. No SSH: 4. No SSH:
simulated timed progress (dev/mock mode). simulated timed progress (dev/mock mode).
""" """
from app import ssh_client from app import ssh_client
if ssh_client.is_configured(): 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(): if await _badblocks_available():
return await _stage_surface_validate_ssh(job_id, devname, drive_id) return await _stage_surface_validate_ssh(job_id, devname, drive_id)
# TrueNAS CORE/FreeBSD: badblocks not available — use native wipe API # 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) 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: async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int) -> bool:
"""Run badblocks over SSH, streaming output to stage log.""" """Run badblocks over SSH, streaming output to stage log."""
from app import ssh_client from app import ssh_client

View file

@ -83,7 +83,29 @@ class Settings(BaseSettings):
ssh_key: str = "" # PEM private key content (paste full key including headers) ssh_key: str = "" # PEM private key content (paste full key including headers)
# Application version — used by the /api/v1/updates/check endpoint # 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() settings = Settings()

View file

@ -99,6 +99,16 @@ _MIGRATIONS = [
# both observe zero active jobs and both insert queued rows. # both observe zero active jobs and both insert queued rows.
"""CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_burnin_per_drive """CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_burnin_per_drive
ON burnin_jobs (drive_id) WHERE state IN ('queued', 'running')""", 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
)""",
] ]

View file

@ -124,9 +124,11 @@ def _build_unlock_banner_html(events: list[dict]) -> str:
evt = e.get("event_type") or "" evt = e.get("event_type") or ""
is_boot = evt == "boot_pool_drive_unlocked" is_boot = evt == "boot_pool_drive_unlocked"
is_exported = evt == "exported_pool_drive_unlocked" is_exported = evt == "exported_pool_drive_unlocked"
is_mounted = evt == "mounted_drive_unlocked"
kind = ( kind = (
"BOOT POOL" if is_boot "BOOT POOL" if is_boot
else "EXPORTED ZFS" if is_exported else "EXPORTED ZFS" if is_exported
else "MOUNTED FILESYSTEM" if is_mounted
else "pool" else "pool"
) )
when = html.escape((e.get("created_at") or "")[:19]) 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 ( WHERE ae.event_type IN (
'pool_drive_unlocked', 'pool_drive_unlocked',
'boot_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') AND julianday(ae.created_at) >= julianday('now', '-1 day')
ORDER BY ae.created_at DESC ORDER BY ae.created_at DESC
""") """)

View file

@ -6,10 +6,11 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.requests import Request 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.config import settings
from app.database import init_db from app.database import init_db
from app.logging_config import configure as configure_logging from app.logging_config import configure as configure_logging
@ -94,16 +95,20 @@ async def lifespan(app: FastAPI):
log.info("Starting up") log.info("Starting up")
await init_db() await init_db()
settings_store.init() settings_store.init()
await auth.bootstrap_admin_if_empty()
_client = TrueNASClient() _client = TrueNASClient()
await burnin.init(_client) await burnin.init(_client)
poll_task = asyncio.create_task(_supervised_poller(_client)) poll_task = asyncio.create_task(_supervised_poller(_client))
mailer_task = asyncio.create_task(mailer.run()) mailer_task = asyncio.create_task(mailer.run())
retention_task = asyncio.create_task(retention.run())
yield yield
log.info("Shutting down") log.info("Shutting down")
poll_task.cancel() poll_task.cancel()
mailer_task.cancel() mailer_task.cancel()
retention_task.cancel()
try: 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: except asyncio.CancelledError:
pass pass
await _client.close() await _client.close()
@ -115,6 +120,63 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="TrueNAS Burn-In Dashboard", lifespan=lifespan) 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: if settings.allowed_ips:
app.add_middleware(_IPAllowlistMiddleware, allowed_ips=settings.allowed_ips) app.add_middleware(_IPAllowlistMiddleware, allowed_ips=settings.allowed_ips)
log.info("IP allowlist active: %s", settings.allowed_ips) log.info("IP allowlist active: %s", settings.allowed_ips)

View file

@ -373,16 +373,19 @@ async def poll_cycle(client: TrueNASClient) -> int:
detection_ok = True detection_ok = True
pool_map: dict = {} pool_map: dict = {}
zfs_member_set: set = set() zfs_member_set: set = set()
mounted_set: set = set()
try: try:
from app import ssh_client as _ssh from app import ssh_client as _ssh
if _ssh.is_configured(): if _ssh.is_configured():
pm = await _ssh.get_pool_membership() pm = await _ssh.get_pool_membership()
zs = await _ssh.get_zfs_member_drives() 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 detection_ok = False
else: else:
pool_map = pm pool_map = pm
zfs_member_set = zs zfs_member_set = zs
mounted_set = ms
# SSH unconfigured (mock/dev mode) — detection_ok stays True with # SSH unconfigured (mock/dev mode) — detection_ok stays True with
# empty maps, so dev mode never artificially locks drives. # empty maps, so dev mode never artificially locks drives.
except Exception: except Exception:
@ -404,6 +407,14 @@ async def poll_cycle(client: TrueNASClient) -> int:
for devname in zfs_member_set: for devname in zfs_member_set:
if devname not in pool_map: if devname not in pool_map:
pool_map[devname] = {"pool": "(exported)", "role": "exported"} 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) # Index running jobs by (devname, test_type)
active: dict[tuple[str, str], dict] = {} active: dict[tuple[str, str], dict] = {}

142
app/retention.py Normal file
View file

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

View file

@ -6,10 +6,10 @@ from datetime import datetime, timezone
import aiosqlite import aiosqlite
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket 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 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.config import settings
from app.database import get_db from app.database import get_db
from app.models import ( from app.models import (
@ -229,6 +229,134 @@ def _stale_context(poller_state: dict) -> dict:
return {"stale": False, "stale_seconds": 0} 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 # Dashboard
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -313,18 +441,73 @@ async def sse_drives(request: Request):
@router.get("/health") @router.get("/health")
async def health(db: aiosqlite.Connection = Depends(get_db)): 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() 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") cur = await db.execute("SELECT COUNT(*) FROM drives")
row = await cur.fetchone() row = await cur.fetchone()
drives_tracked = row[0] if row else 0 drives_tracked = row[0] if row else 0
return {
"status": "ok" if ps["healthy"] else "degraded", status_ok = all(c["ok"] for c in checks.values())
"last_poll_at": ps["last_poll_at"], body = {
"last_error": ps["last_error"], "status": "ok" if status_ok else "degraded",
"consecutive_failures": ps.get("consecutive_failures", 0), "checks": checks,
"poll_interval_seconds": settings.poll_interval_seconds, "drives_tracked": drives_tracked,
"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]) @router.get("/api/v1/drives", response_model=list[DriveResponse])

View file

@ -366,6 +366,51 @@ async def get_pool_membership() -> dict | None:
return _parse_zpool_list_output(r.stdout) 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: async def get_smart_health_map(devnames: list[str]) -> dict | None:
"""Return {devname: 'PASSED'|'FAILED'|'UNKNOWN'} for every devname. """Return {devname: 'PASSED'|'FAILED'|'UNKNOWN'} for every devname.

View file

@ -2426,6 +2426,118 @@ tr.drawer-row-active {
color: var(--yellow); 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 Pool-membership lock indicators
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
@ -2465,6 +2577,21 @@ tr.drawer-row-active {
.pool-lock-icon.pool-lock-exported { .pool-lock-icon.pool-lock-exported {
color: #e07a3f; 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 { .btn-unlock {
background: transparent; background: transparent;
border: 1px solid color-mix(in srgb, var(--yellow) 50%, transparent); border: 1px solid color-mix(in srgb, var(--yellow) 50%, transparent);

View file

@ -1,6 +1,15 @@
(function () { (function () {
'use strict'; 'use strict';
// Default operator name — prefer the logged-in user (rendered into a
// <meta> 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 // Filter bar + stats bar
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -417,7 +426,7 @@
async function startSmartTest(btn) { async function startSmartTest(btn) {
var driveId = btn.dataset.driveId; var driveId = btn.dataset.driveId;
var testType = btn.dataset.testType; var testType = btn.dataset.testType;
var operator = localStorage.getItem('burnin_operator') || 'unknown'; var operator = defaultOperator() || 'unknown';
btn.disabled = true; btn.disabled = true;
try { try {
@ -483,7 +492,7 @@
return; return;
} }
if (!confirm('Cancel ALL ' + cancelBtns.length + ' active burn-in job(s)? This cannot be undone.')) 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; var count = 0;
for (var i = 0; i < cancelBtns.length; i++) { for (var i = 0; i < cancelBtns.length; i++) {
var jobId = cancelBtns[i].dataset.jobId; var jobId = cancelBtns[i].dataset.jobId;
@ -557,7 +566,7 @@
document.getElementById('confirm-serial').value = ''; document.getElementById('confirm-serial').value = '';
document.getElementById('confirm-hint').textContent = 'Expected: ' + modalSerial; document.getElementById('confirm-hint').textContent = 'Expected: ' + modalSerial;
var savedOp = localStorage.getItem('burnin_operator') || ''; var savedOp = defaultOperator();
document.getElementById('operator-input').value = savedOp; document.getElementById('operator-input').value = savedOp;
// Init drag on first open (list is in static DOM) // Init drag on first open (list is in static DOM)
@ -673,6 +682,62 @@
return true; 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 // Pool-drive Unlock modal
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -686,8 +751,10 @@
var poolRole = btn.dataset.poolRole || 'data'; var poolRole = btn.dataset.poolRole || 'data';
var isBoot = btn.dataset.isBootPool === '1'; var isBoot = btn.dataset.isBootPool === '1';
var isExported = btn.dataset.isExported === '1'; var isExported = btn.dataset.isExported === '1';
var isMounted = btn.dataset.isMounted === '1';
if (isBoot) unlockExpectedToken = 'DESTROY BOOT POOL'; if (isBoot) unlockExpectedToken = 'DESTROY BOOT POOL';
else if (isExported) unlockExpectedToken = 'DESTROY EXPORTED POOL'; else if (isExported) unlockExpectedToken = 'DESTROY EXPORTED POOL';
else if (isMounted) unlockExpectedToken = 'DESTROY MOUNTED FILESYSTEM';
else unlockExpectedToken = poolName; else unlockExpectedToken = poolName;
document.getElementById('unlock-devname').textContent = btn.dataset.devname || '—'; document.getElementById('unlock-devname').textContent = btn.dataset.devname || '—';
@ -699,6 +766,9 @@
if (isExported) { if (isExported) {
chip.textContent = 'exported ZFS'; chip.textContent = 'exported ZFS';
chip.className = 'chip chip-aborted'; chip.className = 'chip chip-aborted';
} else if (isMounted) {
chip.textContent = 'mounted FS';
chip.className = 'chip chip-aborted';
} else { } else {
chip.textContent = poolName + ' · ' + poolRole; chip.textContent = poolName + ' · ' + poolRole;
chip.className = 'chip ' + (isBoot ? 'chip-failed' : 'chip-aborted'); 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 ' + '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 ' + 'pools that another system may be relying on. Confirm you have already evacuated or ' +
'reassigned the pool before continuing.'; '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 { } else {
titleEl.textContent = 'Unlock pool drive'; titleEl.textContent = 'Unlock pool drive';
warnTitle.textContent = "This drive belongs to zpool '" + poolName + "'."; warnTitle.textContent = "This drive belongs to zpool '" + poolName + "'.";
@ -735,7 +812,7 @@
document.getElementById('unlock-confirm-input').value = ''; document.getElementById('unlock-confirm-input').value = '';
document.getElementById('unlock-reason-input').value = ''; document.getElementById('unlock-reason-input').value = '';
var savedOp = localStorage.getItem('burnin_operator') || ''; var savedOp = defaultOperator();
document.getElementById('unlock-operator-input').value = savedOp; document.getElementById('unlock-operator-input').value = savedOp;
validateUnlockModal(); validateUnlockModal();
@ -886,7 +963,7 @@
function openBatchModal() { function openBatchModal() {
var modal = document.getElementById('batch-modal'); var modal = document.getElementById('batch-modal');
if (!modal) return; if (!modal) return;
var savedOp = localStorage.getItem('burnin_operator') || ''; var savedOp = defaultOperator();
document.getElementById('batch-operator-input').value = savedOp; document.getElementById('batch-operator-input').value = savedOp;
document.getElementById('batch-confirm-cb').checked = false; document.getElementById('batch-confirm-cb').checked = false;
// Reset stages to all-on (keep user's drag order) // Reset stages to all-on (keep user's drag order)
@ -1010,7 +1087,7 @@
async function cancelBurnin(btn) { async function cancelBurnin(btn) {
var jobId = btn.dataset.jobId; 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; if (!confirm('Cancel this burn-in job? This cannot be undone.')) return;
@ -1048,6 +1125,20 @@
var cancelSmartBtn = e.target.closest('.btn-cancel-smart'); var cancelSmartBtn = e.target.closest('.btn-cancel-smart');
if (cancelSmartBtn && !cancelSmartBtn.disabled) { cancelSmartTest(cancelSmartBtn); return; } 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) // Pool-drive unlock button (single drive)
var unlockBtn = e.target.closest('.btn-unlock'); var unlockBtn = e.target.closest('.btn-unlock');
if (unlockBtn && !unlockBtn.disabled) { openUnlockModal(unlockBtn); return; } if (unlockBtn && !unlockBtn.disabled) { openUnlockModal(unlockBtn); return; }
@ -1105,6 +1196,7 @@
document.addEventListener('input', function (e) { document.addEventListener('input', function (e) {
var id = e.target.id; 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' || if (id === 'unlock-operator-input' || id === 'unlock-reason-input' ||
id === 'unlock-confirm-input') validateUnlockModal(); id === 'unlock-confirm-input') validateUnlockModal();
if (id === 'operator-input' || id === 'confirm-serial') validateModal(); if (id === 'operator-input' || id === 'confirm-serial') validateModal();
@ -1112,6 +1204,8 @@
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
var pwModal = document.getElementById('password-modal');
if (pwModal && !pwModal.hidden) { closePasswordModal(); return; }
var uModal = document.getElementById('unlock-modal'); var uModal = document.getElementById('unlock-modal');
if (uModal && !uModal.hidden) { closeUnlockModal(); return; } if (uModal && !uModal.hidden) { closeUnlockModal(); return; }
var modal = document.getElementById('start-modal'); var modal = document.getElementById('start-modal');

View file

@ -83,6 +83,7 @@
{%- set pool_locked = drive.pool_name and not drive.pool_unlocked_until %} {%- set pool_locked = drive.pool_name and not drive.pool_unlocked_until %}
{%- set is_boot_pool = drive.pool_name == 'boot-pool' %} {%- set is_boot_pool = drive.pool_name == 'boot-pool' %}
{%- set is_exported = drive.pool_role == 'exported' %} {%- 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 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 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')) {%- set smart_done = (drive.smart_short and drive.smart_short.state in ('passed','failed','aborted'))
@ -97,15 +98,15 @@
<td class="col-drive"> <td class="col-drive">
<span class="drive-name"> <span class="drive-name">
{%- if drive.pool_name -%} {%- if drive.pool_name -%}
<span class="pool-lock-icon{% if is_boot_pool %} pool-lock-boot{% elif is_exported %} pool-lock-exported{% endif %}" <span class="pool-lock-icon{% if is_boot_pool %} pool-lock-boot{% elif is_exported %} pool-lock-exported{% elif is_mounted %} pool-lock-mounted{% endif %}"
title="{% if is_boot_pool %}In BOOT POOL '{{ drive.pool_name }}'{% elif is_exported %}Carries ZFS data from a previously-imported pool{% else %}In pool '{{ drive.pool_name }}' ({{ drive.pool_role or 'data' }}){% endif %}">&#x1F512;</span> title="{% if is_boot_pool %}In BOOT POOL '{{ drive.pool_name }}'{% elif is_exported %}Carries ZFS data from a previously-imported pool{% elif is_mounted %}Has a mounted (non-ZFS) filesystem{% else %}In pool '{{ drive.pool_name }}' ({{ drive.pool_role or 'data' }}){% endif %}">&#x1F512;</span>
{%- endif -%} {%- endif -%}
{{ drive.devname }} {{ drive.devname }}
</span> </span>
<span class="drive-model">{{ drive.model or "Unknown" }}</span> <span class="drive-model">{{ drive.model or "Unknown" }}</span>
{%- if drive.pool_name %} {%- if drive.pool_name %}
<span class="pool-pill{% if is_boot_pool %} pool-pill-boot{% elif is_exported %} pool-pill-exported{% endif %}" <span class="pool-pill{% if is_boot_pool %} pool-pill-boot{% elif is_exported %} pool-pill-exported{% elif is_mounted %} pool-pill-mounted{% endif %}"
title="ZFS pool membership">{% if is_exported %}exported ZFS{% else %}{{ drive.pool_name }} &middot; {{ drive.pool_role or 'data' }}{% endif %}</span> title="Drive lock reason">{% if is_exported %}exported ZFS{% elif is_mounted %}mounted FS{% else %}{{ drive.pool_name }} &middot; {{ drive.pool_role or 'data' }}{% endif %}</span>
{%- endif %} {%- endif %}
{%- if drive.location %} {%- if drive.location %}
<span class="drive-location" <span class="drive-location"
@ -169,7 +170,7 @@
{%- endif %} {%- endif %}
{%- if pool_locked %} {%- if pool_locked %}
<!-- Drive is in a zpool — replace Burn-In with Unlock affordance --> <!-- Drive is in a zpool — replace Burn-In with Unlock affordance -->
<button class="btn-action btn-unlock{% if is_boot_pool %} btn-unlock-boot{% elif is_exported %} btn-unlock-exported{% endif %}" <button class="btn-action btn-unlock{% if is_boot_pool %} btn-unlock-boot{% elif is_exported %} btn-unlock-exported{% elif is_mounted %} btn-unlock-mounted{% endif %}"
data-drive-id="{{ drive.id }}" data-drive-id="{{ drive.id }}"
data-devname="{{ drive.devname }}" data-devname="{{ drive.devname }}"
data-serial="{{ drive.serial or '' }}" data-serial="{{ drive.serial or '' }}"
@ -179,7 +180,8 @@
data-pool-role="{{ drive.pool_role or 'data' }}" data-pool-role="{{ drive.pool_role or 'data' }}"
data-is-boot-pool="{{ '1' if is_boot_pool else '0' }}" data-is-boot-pool="{{ '1' if is_boot_pool else '0' }}"
data-is-exported="{{ '1' if is_exported else '0' }}" data-is-exported="{{ '1' if is_exported 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{% else %}Drive is in pool '{{ drive.pool_name }}' — click to unlock{% endif %}">&#x1F512; Unlock</button> 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 %}">&#x1F512; Unlock</button>
{%- else %} {%- else %}
<!-- Burn-In --> <!-- Burn-In -->
<button class="btn-action btn-start{% if short_busy or long_busy %} btn-disabled{% endif %}" <button class="btn-action btn-start{% if short_busy or long_busy %} btn-disabled{% endif %}"

View file

@ -0,0 +1,32 @@
<div id="password-modal" class="modal-overlay" hidden aria-modal="true" role="dialog">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">Change password</h2>
<button class="modal-close" id="password-modal-close-btn" aria-label="Close">&#x2715;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="pw-current">Current password</label>
<input class="form-input" type="password" id="pw-current"
autocomplete="current-password" required>
</div>
<div class="form-group">
<label class="form-label" for="pw-new">New password (8+ characters)</label>
<input class="form-input" type="password" id="pw-new"
autocomplete="new-password" required minlength="8">
</div>
<div class="form-group">
<label class="form-label" for="pw-confirm">Confirm new password</label>
<input class="form-input" type="password" id="pw-confirm"
autocomplete="new-password" required minlength="8">
<div class="confirm-hint" id="pw-hint"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" id="password-modal-cancel-btn">Cancel</button>
<button class="btn-danger" id="password-modal-submit-btn" disabled>Change password</button>
</div>
</div>
</div>

View file

@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}TrueNAS Burn-In{% endblock %}</title> <title>{% block title %}TrueNAS Burn-In{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
{% if request.state.current_user %}
<meta name="default-operator" content="{{ request.state.current_user.full_name or request.state.current_user.username }}">
{% endif %}
</head> </head>
<body> <body>
@ -38,6 +41,11 @@
<a class="header-link" href="/audit">Audit</a> <a class="header-link" href="/audit">Audit</a>
<a class="header-link" href="/settings">Settings</a> <a class="header-link" href="/settings">Settings</a>
<a class="header-link" href="/docs" target="_blank" rel="noopener">API</a> <a class="header-link" href="/docs" target="_blank" rel="noopener">API</a>
{% if request.state.current_user %}
<span class="header-user" title="Signed in">{{ request.state.current_user.full_name or request.state.current_user.username }}</span>
<a class="header-link header-pw" href="#" id="open-password-modal">Change password</a>
<a class="header-link header-logout" href="/logout">Logout</a>
{% endif %}
</div> </div>
</header> </header>
@ -57,6 +65,10 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% if request.state.current_user %}
{% include "components/modal_password.html" %}
{% endif %}
<div id="toast-container" aria-live="polite"></div> <div id="toast-container" aria-live="polite"></div>
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script> <script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script> <script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>

67
app/templates/login.html Normal file
View file

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in &mdash; TrueNAS Burn-In</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="login-body">
<main class="login-card">
<div class="login-header">
<div class="login-title">TrueNAS Burn-In</div>
<div class="login-sub">{% if needs_setup %}First-time setup{% else %}Sign in{% endif %}</div>
</div>
{% if error %}
<div class="login-error">{{ error }}</div>
{% endif %}
{% if needs_setup %}
<p class="login-blurb">
No users exist yet. Create the initial administrator account.
Username and password go straight into the burn-in DB &mdash; no email,
no recovery flow. Pick something memorable.
</p>
<form method="POST" action="/api/v1/auth/setup" class="login-form">
<label class="login-label" for="username">Username</label>
<input class="login-input" type="text" id="username" name="username"
autocomplete="username" required minlength="2" maxlength="64"
autofocus>
<label class="login-label" for="full_name">Full name <span class="login-optional">(optional)</span></label>
<input class="login-input" type="text" id="full_name" name="full_name"
autocomplete="name" maxlength="128">
<label class="login-label" for="password">Password</label>
<input class="login-input" type="password" id="password" name="password"
autocomplete="new-password" required minlength="8" maxlength="128">
<button class="login-submit" type="submit">Create account &amp; sign in</button>
</form>
{% else %}
<form method="POST" action="/login" class="login-form">
<input type="hidden" name="next" value="{{ next }}">
<label class="login-label" for="username">Username</label>
<input class="login-input" type="text" id="username" name="username"
autocomplete="username" required maxlength="64" autofocus>
<label class="login-label" for="password">Password</label>
<input class="login-input" type="password" id="password" name="password"
autocomplete="current-password" required maxlength="128">
<button class="login-submit" type="submit">Sign in</button>
</form>
{% endif %}
<div class="login-footer">
Authentication is local to this dashboard. Forgot your password?
Reset it via the container DB:<br>
<code class="login-code">docker exec truenas-burnin python -m app.auth_cli reset &lt;user&gt;</code>
</div>
</main>
</body>
</html>

View file

@ -6,3 +6,6 @@ pydantic-settings
jinja2 jinja2
sse-starlette sse-starlette
asyncssh asyncssh
itsdangerous>=2.1
bcrypt>=4.0,<5.0
python-multipart>=0.0.7