Compare commits
No commits in common. "c589e3c8e50f81bdf35f0817742f492f332b5444" and "5da1a1704f787d71604ceda49e73c29d1c3075ae" have entirely different histories.
c589e3c8e5
...
5da1a1704f
19 changed files with 35 additions and 1616 deletions
242
README.md
242
README.md
|
|
@ -1,242 +0,0 @@
|
||||||
# TrueNAS Burn-In Dashboard
|
|
||||||
|
|
||||||
Web dashboard for running disciplined burn-in tests on TrueNAS drives.
|
|
||||||
Sits next to the NAS, not on it — orchestrates `smartctl`, `badblocks`, and
|
|
||||||
`nvme-cli` over SSH and tracks every job in SQLite.
|
|
||||||
|
|
||||||
Inspired by the community `disk-burnin.sh` script (Spearfoot et al.) but
|
|
||||||
adds: concurrent burn-ins, pool-membership safety locks, login + audit,
|
|
||||||
live progress UI, daily email reports, and resumable state.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
FastAPI + HTMX (SSE) + asyncssh + SQLite, in one Docker container. No
|
|
||||||
external services beyond your TrueNAS host. Templates and static assets
|
|
||||||
are bind-mounted; Python source is baked into the image.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Configure
|
|
||||||
cp .env.example .env
|
|
||||||
# edit SSH_HOST / SSH_USER / SSH_KEY (see .env.example) and, optionally,
|
|
||||||
# INITIAL_ADMIN_USERNAME / INITIAL_ADMIN_PASSWORD for first-run setup.
|
|
||||||
|
|
||||||
# 2. Build + run
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
# 3. Open the dashboard
|
|
||||||
open http://localhost:8084 # or your host's IP
|
|
||||||
|
|
||||||
# 4. First time: the login page renders a "Create initial admin" form.
|
|
||||||
# Pick a username + password (>= 8 chars). Done.
|
|
||||||
```
|
|
||||||
|
|
||||||
If you set `INITIAL_ADMIN_*` env vars *and* the users table is empty, that
|
|
||||||
account is created on startup automatically. After that the env vars are
|
|
||||||
ignored — change passwords from the UI ("Change password" header link) or
|
|
||||||
the CLI (`docker exec -it truenas-burnin python -m app.auth_cli reset
|
|
||||||
<username>`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Burning in many drives at once
|
|
||||||
|
|
||||||
The dashboard runs **up to `max_parallel_burnins`** burn-ins concurrently
|
|
||||||
(configurable in Settings, default 4) and queues the rest. Submitting 14
|
|
||||||
drives doesn't take 14 separate clicks — you submit once and the queue
|
|
||||||
drains automatically as slots free up.
|
|
||||||
|
|
||||||
### The workflow
|
|
||||||
|
|
||||||
1. **Select all idle drives** — click the checkbox in the table header
|
|
||||||
(next to "DRIVE"). It auto-checks every drive that's currently
|
|
||||||
selectable: idle, no active SMART test, not pool-locked. Pool-locked
|
|
||||||
drives are intentionally excluded; if you really want to burn one of
|
|
||||||
them in, unlock it individually first (see [Drive locks](#drive-locks)
|
|
||||||
below).
|
|
||||||
2. **Click the Burn-In button** in the batch action bar that slides up
|
|
||||||
from the bottom — it shows the count of selected drives.
|
|
||||||
3. **In the batch modal**: pick the stages to run (Short SMART, Long
|
|
||||||
SMART, Surface Validate — drag to reorder), confirm your operator
|
|
||||||
name, and click Start.
|
|
||||||
4. **All selected drives are queued** in one POST. Up to
|
|
||||||
`max_parallel_burnins` enter `running`; the rest sit `queued`. As each
|
|
||||||
running job finishes, the next queued job picks up the freed slot
|
|
||||||
automatically — no operator action between batches.
|
|
||||||
5. The toast shows e.g. "12 burn-in(s) queued, 0 skipped, 0 pool-locked."
|
|
||||||
|
|
||||||
### Time estimate
|
|
||||||
|
|
||||||
| Drive size | Profile | Per-drive runtime (default block size) |
|
|
||||||
|-----------|-------------|----------------------------------------|
|
|
||||||
| 250 GB SSD | Short + Long SMART + Surface | ~1 hour |
|
|
||||||
| 14 TB HDD | Short + Long SMART + Surface | ~24 hours |
|
|
||||||
| 14 TB HDD | Short + Long SMART (no surface) | ~6–8 hours |
|
|
||||||
|
|
||||||
For 12× 14 TB drives at default 4-parallel: roughly **3–4 days** end-to-end.
|
|
||||||
Bumping `surface_validate_block_size` from 4096 to 8192 in Settings cuts
|
|
||||||
runtime roughly in half at ~2× RAM cost — matches the upstream
|
|
||||||
`disk-burnin.sh` recommendation.
|
|
||||||
|
|
||||||
### Watch out
|
|
||||||
|
|
||||||
- **Stuck-job timeout** — `stuck_job_hours` (default 24) marks any job
|
|
||||||
past that threshold as `unknown` and kills the remote process. If
|
|
||||||
you're burning in 14 TB drives with default block size, raise this to
|
|
||||||
**48** in Settings before starting, or you'll get false positives near
|
|
||||||
the end of surface_validate.
|
|
||||||
- **Thermal gate** — if drives currently under burn-in hit the
|
|
||||||
temperature warning threshold, new jobs wait up to 3 minutes before
|
|
||||||
acquiring a slot. Increase `temp_warn_c` if your chassis runs hot but
|
|
||||||
is otherwise fine.
|
|
||||||
|
|
||||||
### Cancelling
|
|
||||||
|
|
||||||
Click the red ✕ next to a running job. The orchestrator:
|
|
||||||
1. Marks the job `cancelled` in the DB.
|
|
||||||
2. Issues `kill -9 <remote_pid>` over a fresh SSH session (the badblocks
|
|
||||||
PID is captured at launch via `sh -c 'echo PID:$$; exec ...'`).
|
|
||||||
3. Cancels the asyncio task, releasing the semaphore slot for the next
|
|
||||||
queued job.
|
|
||||||
|
|
||||||
Cancellations are durable — restart the container and queued jobs resume,
|
|
||||||
cancelled jobs stay cancelled.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Drive locks
|
|
||||||
|
|
||||||
To prevent destroying live data, the dashboard refuses to start
|
|
||||||
destructive burn-in on drives ZFS or the kernel reports as in-use.
|
|
||||||
Detected lock states (with the typed-confirmation token required to
|
|
||||||
override):
|
|
||||||
|
|
||||||
| State | Detected via | Confirm token |
|
|
||||||
|---------------|---------------------------|------------------------------|
|
|
||||||
| Active pool | `zpool list -vHP` | the pool name (e.g. `tank`) |
|
|
||||||
| Boot pool | pool name = `boot-pool` | `DESTROY BOOT POOL` |
|
|
||||||
| Exported ZFS | `lsblk` `zfs_member` partitions not in any active pool | `DESTROY EXPORTED POOL` |
|
|
||||||
| Mounted FS | `findmnt -no SOURCE` | `DESTROY MOUNTED FILESYSTEM` |
|
|
||||||
|
|
||||||
Detection runs every poll cycle (~12 s). On any SSH or parser failure the
|
|
||||||
poller fails *closed*: previously-locked drives stay locked, previously-
|
|
||||||
unlocked drives stay unlocked, until detection recovers.
|
|
||||||
|
|
||||||
Unlock is in-memory only with a 10-minute TTL — bound to the
|
|
||||||
`(pool_name, pool_role)` observed at unlock time. If a subsequent poll
|
|
||||||
reclassifies the drive (e.g. `(exported)` → `tank` because someone
|
|
||||||
imported the pool), the grant is invalidated automatically.
|
|
||||||
|
|
||||||
Every unlock writes an audit event and surfaces in the next daily report
|
|
||||||
in a red banner.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings highlights
|
|
||||||
|
|
||||||
All settings live under `/settings` (header link). Key knobs:
|
|
||||||
|
|
||||||
- **`max_parallel_burnins`** (default 4) — semaphore cap. Restart container
|
|
||||||
for changes to take effect.
|
|
||||||
- **`surface_validate_block_size` / `_block_buffer` / `_passes`** —
|
|
||||||
badblocks `-b` / `-c` / `-p`. Defaults preserve original behaviour;
|
|
||||||
tune for speed vs paranoia.
|
|
||||||
- **`stuck_job_hours`** (default 24) — raise for big drives.
|
|
||||||
- **`temp_warn_c` / `temp_crit_c`** — thermal gating thresholds.
|
|
||||||
- **`bad_block_threshold`** (default 0) — number of bad blocks
|
|
||||||
surface_validate tolerates before failing the stage.
|
|
||||||
- **`retention_log_days`** (default 35) — when to NULL out
|
|
||||||
`burnin_stages.log_text`. Nightly job at 03:00 local.
|
|
||||||
- **`retention_backup_keep`** (default 14) — how many nightly DB
|
|
||||||
snapshots to keep in `/data/backups/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notifications
|
|
||||||
|
|
||||||
- **Daily SMTP report** at `smtp_report_hour` (default 08:00 local) with
|
|
||||||
drive-level summary, failed-health banner, and a red banner listing
|
|
||||||
every pool-drive unlock from the last 24 h.
|
|
||||||
- **Per-job email alerts** on pass/fail (configurable).
|
|
||||||
- **Webhook URL** posts JSON on every job state change.
|
|
||||||
|
|
||||||
Configure SMTP in Settings → Email. Includes a "Test SMTP" button.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Operations
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs -f truenas-burnin
|
|
||||||
# JSON-structured. Filter with jq:
|
|
||||||
docker logs truenas-burnin 2>&1 | jq -rR 'fromjson? | "\(.ts) \(.level) \(.msg)"'
|
|
||||||
```
|
|
||||||
|
|
||||||
### User management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it truenas-burnin python -m app.auth_cli list
|
|
||||||
docker exec -it truenas-burnin python -m app.auth_cli add <username>
|
|
||||||
docker exec -it truenas-burnin python -m app.auth_cli reset <username>
|
|
||||||
```
|
|
||||||
|
|
||||||
Passwords are read from a TTY prompt; never accept them on the command
|
|
||||||
line.
|
|
||||||
|
|
||||||
### Backups
|
|
||||||
|
|
||||||
Automated nightly to `/data/backups/app-YYYY-MM-DD.db` (online
|
|
||||||
`sqlite3.backup`, doesn't lock writers). To restore:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
cp data/backups/app-2026-05-01.db data/app.db
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health probe
|
|
||||||
|
|
||||||
`/health` is unauthenticated and returns 200 only when DB, poller, and
|
|
||||||
SSH (when configured) all check green; 503 otherwise. Use it for
|
|
||||||
container/orchestrator health checks.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sf http://localhost:8084/health | jq
|
|
||||||
```
|
|
||||||
|
|
||||||
### Resetting the DB
|
|
||||||
|
|
||||||
If you need to start over:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
sudo rm -f data/app.db data/session_secret
|
|
||||||
# keep data/settings_overrides.json if you want to preserve UI settings
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## See also
|
|
||||||
|
|
||||||
- `CLAUDE.md` — full architecture, file map, deploy workflow, and the
|
|
||||||
rationale behind every non-obvious design decision.
|
|
||||||
- `SPEC.md` — canonical feature reference per version.
|
|
||||||
- `tests/` — `python -m unittest discover tests/` (44 tests, stdlib-only).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known gaps / not-yet-built
|
|
||||||
|
|
||||||
- No multi-user RBAC — every user is effectively admin.
|
|
||||||
- No per-drive SMART attribute trend graphs (snapshots only).
|
|
||||||
- No scheduled burn-ins — jobs run immediately when queued.
|
|
||||||
- No CSRF tokens on state-changing endpoints (relies on
|
|
||||||
`SameSite=Strict` session cookie).
|
|
||||||
|
|
||||||
PRs welcome.
|
|
||||||
336
app/auth.py
336
app/auth.py
|
|
@ -1,336 +0,0 @@
|
||||||
"""
|
|
||||||
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)
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
"""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,8 +186,6 @@ 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
|
||||||
|
|
@ -298,15 +296,12 @@ 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 / exported / mounted-fs all get dedicated, harder-to-
|
# Boot-pool and exported pools both get dedicated, harder-to-fat-
|
||||||
# fat-finger tokens. Active data pools just need their pool name
|
# finger tokens. Active data pools just need their pool name typed.
|
||||||
# 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:
|
||||||
|
|
@ -316,8 +311,6 @@ 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(
|
||||||
|
|
@ -867,22 +860,15 @@ 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. NVMe device + SSH + nvme-cli available (TrueNAS SCALE):
|
1. SSH configured + badblocks available (TrueNAS SCALE / Linux):
|
||||||
→ `nvme format -s 1 /dev/{devname}` (cryptographic erase).
|
→ runs badblocks -wsv -b 4096 -p 1 /dev/{devname} directly over SSH.
|
||||||
Far faster than badblocks on NVMe (seconds vs hours) and
|
2. SSH configured + badblocks NOT available (TrueNAS CORE / FreeBSD):
|
||||||
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.
|
||||||
4. No SSH:
|
3. 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
|
||||||
|
|
@ -895,81 +881,6 @@ 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,29 +83,7 @@ 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-23"
|
app_version: str = "1.0.0-21"
|
||||||
|
|
||||||
# ---- 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,16 +99,6 @@ _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,11 +124,9 @@ 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])
|
||||||
|
|
@ -357,8 +355,7 @@ 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,11 +6,10 @@ 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 JSONResponse, PlainTextResponse
|
from starlette.responses import PlainTextResponse
|
||||||
|
|
||||||
from app import auth, burnin, mailer, poller, retention, settings_store
|
from app import burnin, mailer, poller, 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
|
||||||
|
|
@ -95,20 +94,16 @@ 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, retention_task,
|
await asyncio.gather(poll_task, mailer_task, return_exceptions=True)
|
||||||
return_exceptions=True)
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
await _client.close()
|
await _client.close()
|
||||||
|
|
@ -120,63 +115,6 @@ 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,19 +373,16 @@ 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()
|
||||||
ms = await _ssh.get_mounted_drives()
|
if pm is None or zs is None:
|
||||||
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:
|
||||||
|
|
@ -407,14 +404,6 @@ 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
142
app/retention.py
|
|
@ -1,142 +0,0 @@
|
||||||
"""
|
|
||||||
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, RedirectResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app import auth, burnin, mailer, poller, settings_store
|
from app import 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,134 +229,6 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -441,73 +313,18 @@ 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 = all(c["ok"] for c in checks.values())
|
"status": "ok" if ps["healthy"] else "degraded",
|
||||||
body = {
|
"last_poll_at": ps["last_poll_at"],
|
||||||
"status": "ok" if status_ok else "degraded",
|
"last_error": ps["last_error"],
|
||||||
"checks": checks,
|
"consecutive_failures": ps.get("consecutive_failures", 0),
|
||||||
"drives_tracked": drives_tracked,
|
"poll_interval_seconds": settings.poll_interval_seconds,
|
||||||
"poll_interval_s": settings.poll_interval_seconds,
|
"drives_tracked": drives_tracked,
|
||||||
"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,51 +366,6 @@ 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,118 +2426,6 @@ 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
|
||||||
----------------------------------------------------------------------- */
|
----------------------------------------------------------------------- */
|
||||||
|
|
@ -2577,21 +2465,6 @@ 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,15 +1,6 @@
|
||||||
(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
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -426,7 +417,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 = defaultOperator() || 'unknown';
|
var operator = localStorage.getItem('burnin_operator') || 'unknown';
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -492,7 +483,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 = defaultOperator() || 'unknown';
|
var operator = localStorage.getItem('burnin_operator') || '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;
|
||||||
|
|
@ -566,7 +557,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 = defaultOperator();
|
var savedOp = localStorage.getItem('burnin_operator') || '';
|
||||||
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)
|
||||||
|
|
@ -682,62 +673,6 @@
|
||||||
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
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -751,10 +686,8 @@
|
||||||
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 || '—';
|
||||||
|
|
@ -766,9 +699,6 @@
|
||||||
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');
|
||||||
|
|
@ -792,13 +722,6 @@
|
||||||
'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 + "'.";
|
||||||
|
|
@ -812,7 +735,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 = defaultOperator();
|
var savedOp = localStorage.getItem('burnin_operator') || '';
|
||||||
document.getElementById('unlock-operator-input').value = savedOp;
|
document.getElementById('unlock-operator-input').value = savedOp;
|
||||||
validateUnlockModal();
|
validateUnlockModal();
|
||||||
|
|
||||||
|
|
@ -963,7 +886,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 = defaultOperator();
|
var savedOp = localStorage.getItem('burnin_operator') || '';
|
||||||
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)
|
||||||
|
|
@ -1087,7 +1010,7 @@
|
||||||
|
|
||||||
async function cancelBurnin(btn) {
|
async function cancelBurnin(btn) {
|
||||||
var jobId = btn.dataset.jobId;
|
var jobId = btn.dataset.jobId;
|
||||||
var operator = defaultOperator() || 'unknown';
|
var operator = localStorage.getItem('burnin_operator') || '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;
|
||||||
|
|
||||||
|
|
@ -1125,20 +1048,6 @@
|
||||||
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; }
|
||||||
|
|
@ -1196,7 +1105,6 @@
|
||||||
|
|
||||||
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();
|
||||||
|
|
@ -1204,8 +1112,6 @@
|
||||||
|
|
||||||
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,7 +83,6 @@
|
||||||
{%- 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'))
|
||||||
|
|
@ -98,15 +97,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{% elif is_mounted %} pool-lock-mounted{% endif %}"
|
<span class="pool-lock-icon{% if is_boot_pool %} pool-lock-boot{% elif is_exported %} pool-lock-exported{% endif %}"
|
||||||
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>
|
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>
|
||||||
{%- 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{% elif is_mounted %} pool-pill-mounted{% endif %}"
|
<span class="pool-pill{% if is_boot_pool %} pool-pill-boot{% elif is_exported %} pool-pill-exported{% endif %}"
|
||||||
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>
|
title="ZFS pool membership">{% if is_exported %}exported ZFS{% 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"
|
||||||
|
|
@ -170,7 +169,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{% elif is_mounted %} btn-unlock-mounted{% endif %}"
|
<button class="btn-action btn-unlock{% if is_boot_pool %} btn-unlock-boot{% elif is_exported %} btn-unlock-exported{% 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 '' }}"
|
||||||
|
|
@ -180,8 +179,7 @@
|
||||||
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' }}"
|
||||||
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{% else %}Drive is in pool '{{ drive.pool_name }}' — click to unlock{% endif %}">🔒 Unlock</button>
|
||||||
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 %}"
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<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,9 +5,6 @@
|
||||||
<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>
|
||||||
|
|
||||||
|
|
@ -41,11 +38,6 @@
|
||||||
<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>
|
||||||
|
|
||||||
|
|
@ -65,10 +57,6 @@
|
||||||
{% 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>
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
<!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,6 +6,3 @@ 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