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:
parent
5da1a1704f
commit
d4c0770b9e
18 changed files with 1374 additions and 35 deletions
336
app/auth.py
Normal file
336
app/auth.py
Normal 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
99
app/auth_cli.py
Normal 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())
|
||||||
101
app/burnin.py
101
app/burnin.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
""")
|
""")
|
||||||
|
|
|
||||||
72
app/main.py
72
app/main.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
142
app/retention.py
Normal 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
|
||||||
201
app/routes.py
201
app/routes.py
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 %}">🔒</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 %}">🔒</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 }} · {{ 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 }} · {{ 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 %}">🔒 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 %}">🔒 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 %}"
|
||||||
|
|
|
||||||
32
app/templates/components/modal_password.html
Normal file
32
app/templates/components/modal_password.html
Normal 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">✕</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>
|
||||||
|
|
@ -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
67
app/templates/login.html
Normal 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 — 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 — 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 & 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 <user></code>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue