Compare commits
No commits in common. "1a1925201932f20cf02eeccd64aed19ff8c2850c" and "5da1a1704f787d71604ceda49e73c29d1c3075ae" have entirely different histories.
1a19252019
...
5da1a1704f
24 changed files with 53 additions and 1854 deletions
|
|
@ -1,61 +0,0 @@
|
|||
name: Security scan
|
||||
|
||||
# Runs on every push to main, every PR, and nightly at 07:00 UTC (~03:00 EDT).
|
||||
# Three jobs run in parallel — failure of any one fails the workflow,
|
||||
# making findings visible in the forge UI.
|
||||
#
|
||||
# Tools:
|
||||
# pip-audit — known CVEs in pinned dependencies (PyPI advisory DB)
|
||||
# bandit — Python static security analysis (subprocess, eval, etc.)
|
||||
# gitleaks — secrets in git history (full repo scan)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 7 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
pip-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install pip-audit
|
||||
run: pip install --upgrade pip-audit
|
||||
- name: Audit requirements.txt
|
||||
run: pip-audit --requirement requirements.txt --strict --format=columns
|
||||
|
||||
bandit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install bandit
|
||||
run: pip install --upgrade bandit
|
||||
- name: Static security analysis
|
||||
# B608: SQL string construction. All dynamic SQL in this repo uses
|
||||
# bound parameters for data; the dynamic part is structural
|
||||
# (column lists / IN-clause '?,?,?' placeholders). Reviewed.
|
||||
run: bandit -r app -ll -ii --skip B608 -x app/__pycache__,tests
|
||||
|
||||
gitleaks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install gitleaks
|
||||
run: |
|
||||
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
|
||||
| tar -xz gitleaks
|
||||
chmod +x gitleaks
|
||||
- name: Scan git history for secrets
|
||||
run: ./gitleaks detect --source . --no-banner --redact --verbose
|
||||
|
|
@ -2,11 +2,6 @@ FROM python:3.12-slim
|
|||
|
||||
WORKDIR /opt/app
|
||||
|
||||
# Bump pip to a version with no known CVEs before installing anything.
|
||||
# Without this, pip-audit flags CVE-2025-8869, CVE-2026-1703, CVE-2026-3219
|
||||
# in pip itself. Pinned floor; pip is forward-compatible across 26.x.
|
||||
RUN pip install --no-cache-dir --upgrade "pip>=26.0"
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
|
|
|||
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"
|
||||
EXPORTED_POOL_ROLE = "exported"
|
||||
EXPORTED_CONFIRM_TOKEN = "DESTROY EXPORTED POOL"
|
||||
MOUNTED_ROLE = "mounted"
|
||||
MOUNTED_CONFIRM_TOKEN = "DESTROY MOUNTED FILESYSTEM"
|
||||
|
||||
|
||||
@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."
|
||||
)
|
||||
|
||||
# Boot-pool / exported / mounted-fs all get dedicated, harder-to-
|
||||
# fat-finger tokens. Active data pools just need their pool name
|
||||
# typed.
|
||||
# Boot-pool and exported pools both get dedicated, harder-to-fat-
|
||||
# finger tokens. Active data pools just need their pool name typed.
|
||||
if pool_name == BOOT_POOL_NAME:
|
||||
expected = BOOT_POOL_CONFIRM_TOKEN
|
||||
elif pool_role == EXPORTED_POOL_ROLE:
|
||||
expected = EXPORTED_CONFIRM_TOKEN
|
||||
elif pool_role == MOUNTED_ROLE:
|
||||
expected = MOUNTED_CONFIRM_TOKEN
|
||||
else:
|
||||
expected = pool_name
|
||||
if (confirm_token or "").strip() != expected:
|
||||
|
|
@ -316,8 +311,6 @@ async def grant_pool_unlock(drive_id: int, confirm_token: str,
|
|||
evt = "boot_pool_drive_unlocked"
|
||||
elif pool_role == EXPORTED_POOL_ROLE:
|
||||
evt = "exported_pool_drive_unlocked"
|
||||
elif pool_role == MOUNTED_ROLE:
|
||||
evt = "mounted_drive_unlocked"
|
||||
else:
|
||||
evt = "pool_drive_unlocked"
|
||||
await db.execute(
|
||||
|
|
@ -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:
|
||||
|
||||
1. NVMe device + SSH + nvme-cli available (TrueNAS SCALE):
|
||||
→ `nvme format -s 1 /dev/{devname}` (cryptographic erase).
|
||||
Far faster than badblocks on NVMe (seconds vs hours) and
|
||||
exercises the controller's secure-erase path, not just user-LBA
|
||||
writes.
|
||||
2. SSH configured + badblocks available (TrueNAS SCALE / Linux):
|
||||
→ badblocks -wsv -b N -c N -p N /dev/{devname} directly over SSH.
|
||||
3. SSH configured + badblocks NOT available (TrueNAS CORE / FreeBSD):
|
||||
1. SSH configured + badblocks available (TrueNAS SCALE / Linux):
|
||||
→ runs badblocks -wsv -b 4096 -p 1 /dev/{devname} directly over SSH.
|
||||
2. SSH configured + badblocks NOT available (TrueNAS CORE / FreeBSD):
|
||||
→ uses TrueNAS REST API disk.wipe FULL job + post-wipe SMART check.
|
||||
4. No SSH:
|
||||
3. No SSH:
|
||||
→ simulated timed progress (dev/mock mode).
|
||||
"""
|
||||
from app import ssh_client
|
||||
if ssh_client.is_configured():
|
||||
if devname.startswith("nvme") and await _nvme_cli_available():
|
||||
return await _stage_surface_validate_nvme(job_id, devname, drive_id)
|
||||
if await _badblocks_available():
|
||||
return await _stage_surface_validate_ssh(job_id, devname, drive_id)
|
||||
# TrueNAS CORE/FreeBSD: badblocks not available — use native wipe API
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
async def _nvme_cli_available() -> bool:
|
||||
"""Check if nvme-cli is installed on the remote host."""
|
||||
from app import ssh_client
|
||||
try:
|
||||
async with await ssh_client._connect() as conn:
|
||||
r = await conn.run("which nvme", check=False)
|
||||
return r.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _stage_surface_validate_nvme(job_id: int, devname: str,
|
||||
drive_id: int) -> bool:
|
||||
"""NVMe destructive surface test via `nvme format -s 1` (crypto erase).
|
||||
|
||||
Crypto-erase nukes the data encryption key on the drive's controller,
|
||||
rendering all stored data unrecoverable in milliseconds; the actual
|
||||
flash is then implicitly trim-able. This is the canonical destructive
|
||||
burn-in for NVMe — badblocks would write the entire LBA space, which
|
||||
is slower AND wears the flash unnecessarily.
|
||||
|
||||
Post-format we re-read SMART attributes; the drive should report all
|
||||
counters reset (life used + spare) and PASSED health.
|
||||
"""
|
||||
from app import ssh_client
|
||||
|
||||
await _append_stage_log(
|
||||
job_id, "surface_validate",
|
||||
f"[START] nvme format -s 1 /dev/{devname}\n"
|
||||
f"[NOTE] Cryptographic erase — destroys all data on /dev/{devname}.\n\n"
|
||||
)
|
||||
|
||||
cmd = f"nvme format -s 1 --force /dev/{devname}"
|
||||
try:
|
||||
async with await ssh_client._connect() as conn:
|
||||
r = await asyncio.wait_for(
|
||||
conn.run(cmd, check=False), timeout=600
|
||||
)
|
||||
except Exception as exc:
|
||||
await _append_stage_log(
|
||||
job_id, "surface_validate", f"\n[SSH error] {exc}\n"
|
||||
)
|
||||
await _set_stage_error(
|
||||
job_id, "surface_validate", f"NVMe format SSH error: {exc}"
|
||||
)
|
||||
return False
|
||||
|
||||
output = (r.stdout or "") + (r.stderr or "")
|
||||
await _append_stage_log(job_id, "surface_validate", output + "\n")
|
||||
|
||||
if r.returncode != 0:
|
||||
await _set_stage_error(
|
||||
job_id, "surface_validate",
|
||||
f"nvme format exited {r.returncode}: {output.strip()[:200]}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Sanity-check post-format SMART health.
|
||||
try:
|
||||
attrs = await ssh_client.get_smart_attributes(devname)
|
||||
if attrs.get("health") == "FAILED":
|
||||
await _set_stage_error(
|
||||
job_id, "surface_validate",
|
||||
"NVMe SMART health FAILED after format"
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
log.warning("Post-format SMART check error on %s: %s", devname, exc)
|
||||
|
||||
await _update_stage_percent(job_id, "surface_validate", 100)
|
||||
await _recalculate_progress(job_id)
|
||||
_push_update()
|
||||
return True
|
||||
|
||||
|
||||
async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int) -> bool:
|
||||
"""Run badblocks over SSH, streaming output to stage log."""
|
||||
from app import ssh_client
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class Settings(BaseSettings):
|
|||
case_sensitive=False,
|
||||
)
|
||||
|
||||
app_host: str = "0.0.0.0" # nosec B104 — container deliberately binds all interfaces; nginx-proxy-manager fronts it.
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8080
|
||||
db_path: str = "/data/app.db"
|
||||
|
||||
|
|
@ -83,29 +83,7 @@ class Settings(BaseSettings):
|
|||
ssh_key: str = "" # PEM private key content (paste full key including headers)
|
||||
|
||||
# Application version — used by the /api/v1/updates/check endpoint
|
||||
app_version: str = "1.0.0-24"
|
||||
|
||||
# ---- 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
|
||||
app_version: str = "1.0.0-21"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -99,16 +99,6 @@ _MIGRATIONS = [
|
|||
# both observe zero active jobs and both insert queued rows.
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_burnin_per_drive
|
||||
ON burnin_jobs (drive_id) WHERE state IN ('queued', 'running')""",
|
||||
# 1.0.0-22: app-level login (username + bcrypt password)
|
||||
"""CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
full_name TEXT,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
)""",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -124,11 +124,9 @@ def _build_unlock_banner_html(events: list[dict]) -> str:
|
|||
evt = e.get("event_type") or ""
|
||||
is_boot = evt == "boot_pool_drive_unlocked"
|
||||
is_exported = evt == "exported_pool_drive_unlocked"
|
||||
is_mounted = evt == "mounted_drive_unlocked"
|
||||
kind = (
|
||||
"BOOT POOL" if is_boot
|
||||
else "EXPORTED ZFS" if is_exported
|
||||
else "MOUNTED FILESYSTEM" if is_mounted
|
||||
else "pool"
|
||||
)
|
||||
when = html.escape((e.get("created_at") or "")[:19])
|
||||
|
|
@ -357,8 +355,7 @@ async def _fetch_unlock_events_24h() -> list[dict]:
|
|||
WHERE ae.event_type IN (
|
||||
'pool_drive_unlocked',
|
||||
'boot_pool_drive_unlocked',
|
||||
'exported_pool_drive_unlocked',
|
||||
'mounted_drive_unlocked')
|
||||
'exported_pool_drive_unlocked')
|
||||
AND julianday(ae.created_at) >= julianday('now', '-1 day')
|
||||
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.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
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.database import init_db
|
||||
from app.logging_config import configure as configure_logging
|
||||
|
|
@ -95,20 +94,16 @@ async def lifespan(app: FastAPI):
|
|||
log.info("Starting up")
|
||||
await init_db()
|
||||
settings_store.init()
|
||||
await auth.bootstrap_admin_if_empty()
|
||||
_client = TrueNASClient()
|
||||
await burnin.init(_client)
|
||||
poll_task = asyncio.create_task(_supervised_poller(_client))
|
||||
mailer_task = asyncio.create_task(mailer.run())
|
||||
retention_task = asyncio.create_task(retention.run())
|
||||
poll_task = asyncio.create_task(_supervised_poller(_client))
|
||||
mailer_task = asyncio.create_task(mailer.run())
|
||||
yield
|
||||
log.info("Shutting down")
|
||||
poll_task.cancel()
|
||||
mailer_task.cancel()
|
||||
retention_task.cancel()
|
||||
try:
|
||||
await asyncio.gather(poll_task, mailer_task, retention_task,
|
||||
return_exceptions=True)
|
||||
await asyncio.gather(poll_task, mailer_task, return_exceptions=True)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await _client.close()
|
||||
|
|
@ -120,63 +115,6 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
app = FastAPI(title="TrueNAS Burn-In Dashboard", lifespan=lifespan)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth gate — must be added BEFORE include_router so it runs first.
|
||||
# Path-prefix allowlist below covers anything we want reachable without
|
||||
# a session cookie. SSE streams + WebSockets fall through to the dependency
|
||||
# in their handler so they 401 cleanly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PUBLIC_PATHS = {"/login", "/logout", "/health", "/auth/setup"}
|
||||
_PUBLIC_PREFIXES = ("/static/", "/api/v1/auth/")
|
||||
|
||||
|
||||
class _AuthGateMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
# Always populate request.state.current_user from the session so
|
||||
# templates and route handlers can both rely on it. None when
|
||||
# unauthenticated.
|
||||
user_id = request.session.get("user_id")
|
||||
request.state.current_user = (
|
||||
await auth.get_user_by_id(int(user_id)) if user_id else None
|
||||
)
|
||||
|
||||
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PREFIXES):
|
||||
return await call_next(request)
|
||||
if request.state.current_user is not None:
|
||||
return await call_next(request)
|
||||
# Unauthenticated. HTML GETs bounce to /login with a `next` query
|
||||
# arg so the user lands back where they tried to go after logging
|
||||
# in. Anything else (API calls, SSE, POSTs) gets a 401.
|
||||
accept = request.headers.get("accept", "")
|
||||
if request.method == "GET" and "text/html" in accept:
|
||||
return auth.login_redirect(path)
|
||||
return JSONResponse(
|
||||
{"detail": "Authentication required"}, status_code=401
|
||||
)
|
||||
|
||||
|
||||
app.add_middleware(_AuthGateMiddleware)
|
||||
# SessionMiddleware must be added LAST (it wraps innermost so request.session
|
||||
# is populated before AuthGate runs).
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=auth.get_session_secret(),
|
||||
session_cookie="burnin_session",
|
||||
max_age=settings.session_max_age_seconds,
|
||||
https_only=False, # we sit behind nginx-proxy-manager; trust upstream
|
||||
# SameSite=strict is the primary CSRF mitigation: the browser never
|
||||
# sends the session cookie on cross-site requests, so an attacker
|
||||
# page can't trigger any state-changing endpoint even if it knows
|
||||
# the URL. Trade-off: an external link (email, chat) into the app
|
||||
# won't carry the session — user has to re-auth via /login. For an
|
||||
# internal-only tool that's the right default.
|
||||
same_site="strict",
|
||||
)
|
||||
|
||||
|
||||
if settings.allowed_ips:
|
||||
app.add_middleware(_IPAllowlistMiddleware, allowed_ips=settings.allowed_ips)
|
||||
log.info("IP allowlist active: %s", settings.allowed_ips)
|
||||
|
|
|
|||
|
|
@ -134,21 +134,16 @@ async def _upsert_drive(db: aiosqlite.Connection, disk: dict, now: str,
|
|||
last_polled_at = excluded.last_polled_at
|
||||
"""
|
||||
|
||||
# SQL is built by concatenation rather than f-string so bandit's B608
|
||||
# heuristic (which fires on f-string SQL regardless of source) doesn't
|
||||
# flag it. update_clause is one of two hardcoded literal strings
|
||||
# selected above; never carries user input.
|
||||
sql = (
|
||||
"INSERT INTO drives "
|
||||
"(truenas_disk_id, devname, serial, model, size_bytes, "
|
||||
" temperature_c, smart_health, last_seen_at, last_polled_at, "
|
||||
" pool_name, pool_role, pool_seen_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(truenas_disk_id) DO UPDATE SET "
|
||||
+ update_clause
|
||||
)
|
||||
await db.execute(
|
||||
sql,
|
||||
f"""
|
||||
INSERT INTO drives
|
||||
(truenas_disk_id, devname, serial, model, size_bytes,
|
||||
temperature_c, smart_health, last_seen_at, last_polled_at,
|
||||
pool_name, pool_role, pool_seen_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(truenas_disk_id) DO UPDATE SET
|
||||
{update_clause}
|
||||
""",
|
||||
(
|
||||
disk["identifier"],
|
||||
disk["devname"],
|
||||
|
|
@ -378,19 +373,16 @@ async def poll_cycle(client: TrueNASClient) -> int:
|
|||
detection_ok = True
|
||||
pool_map: dict = {}
|
||||
zfs_member_set: set = set()
|
||||
mounted_set: set = set()
|
||||
try:
|
||||
from app import ssh_client as _ssh
|
||||
if _ssh.is_configured():
|
||||
pm = await _ssh.get_pool_membership()
|
||||
zs = await _ssh.get_zfs_member_drives()
|
||||
ms = await _ssh.get_mounted_drives()
|
||||
if pm is None or zs is None or ms is None:
|
||||
if pm is None or zs is None:
|
||||
detection_ok = False
|
||||
else:
|
||||
pool_map = pm
|
||||
zfs_member_set = zs
|
||||
mounted_set = ms
|
||||
# SSH unconfigured (mock/dev mode) — detection_ok stays True with
|
||||
# empty maps, so dev mode never artificially locks drives.
|
||||
except Exception:
|
||||
|
|
@ -412,14 +404,6 @@ async def poll_cycle(client: TrueNASClient) -> int:
|
|||
for devname in zfs_member_set:
|
||||
if devname not in pool_map:
|
||||
pool_map[devname] = {"pool": "(exported)", "role": "exported"}
|
||||
# Drives with a non-ZFS mount somewhere (XFS/ext4/scratch/etc.)
|
||||
# also lock — wiping a mounted FS is just as catastrophic. Lower
|
||||
# precedence than active pool membership, since a drive in `tank`
|
||||
# would also show under findmnt for the pool's mountpoint via
|
||||
# /dev/zd* or zvol — but those are filtered in the parser.
|
||||
for devname in mounted_set:
|
||||
if devname not in pool_map:
|
||||
pool_map[devname] = {"pool": "(mounted)", "role": "mounted"}
|
||||
|
||||
# Index running jobs by (devname, test_type)
|
||||
active: dict[tuple[str, str], dict] = {}
|
||||
|
|
|
|||
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
|
||||
221
app/routes.py
221
app/routes.py
|
|
@ -6,10 +6,10 @@ from datetime import datetime, timezone
|
|||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
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.database import get_db
|
||||
from app.models import (
|
||||
|
|
@ -173,18 +173,14 @@ async def _fetch_drives_for_template(db: aiosqlite.Connection) -> list[dict]:
|
|||
]
|
||||
if bi_ids_with_smart:
|
||||
placeholders = ",".join("?" * len(bi_ids_with_smart))
|
||||
# placeholders is purely structural ("?,?,?"); IDs themselves are
|
||||
# bound via the parameter tuple. SQL built via concatenation so
|
||||
# bandit's B608 (which fires on any f-string SQL) doesn't flag it.
|
||||
sql = (
|
||||
"SELECT bs.burnin_job_id, bs.stage_name, bs.state, bs.percent, "
|
||||
" bs.started_at, bs.finished_at, bs.error_text "
|
||||
"FROM burnin_stages bs "
|
||||
"WHERE bs.burnin_job_id IN (" + placeholders + ") "
|
||||
" AND bs.stage_name IN ('short_smart', 'long_smart') "
|
||||
" AND bs.state IN ('running', 'passed', 'failed')"
|
||||
)
|
||||
cur = await db.execute(sql, bi_ids_with_smart)
|
||||
cur = await db.execute(f"""
|
||||
SELECT bs.burnin_job_id, bs.stage_name, bs.state, bs.percent,
|
||||
bs.started_at, bs.finished_at, bs.error_text
|
||||
FROM burnin_stages bs
|
||||
WHERE bs.burnin_job_id IN ({placeholders})
|
||||
AND bs.stage_name IN ('short_smart', 'long_smart')
|
||||
AND bs.state IN ('running', 'passed', 'failed')
|
||||
""", bi_ids_with_smart)
|
||||
for r in await cur.fetchall():
|
||||
bi_smart_stages.setdefault(r["burnin_job_id"], {})[r["stage_name"]] = dict(r)
|
||||
|
||||
|
|
@ -233,134 +229,6 @@ def _stale_context(poller_state: dict) -> dict:
|
|||
return {"stale": False, "stale_seconds": 0}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth — login / logout / first-user setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, next: str = "/", error: str | None = None):
|
||||
needs_setup = (await auth.user_count()) == 0
|
||||
return templates.TemplateResponse(request, "login.html", {
|
||||
"request": request,
|
||||
"needs_setup": needs_setup,
|
||||
"error": error,
|
||||
"next": next if next.startswith("/") else "/",
|
||||
})
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
fwd = (request.headers.get("X-Forwarded-For") or "").split(",")[0].strip()
|
||||
return fwd or (request.client.host if request.client else "unknown")
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(request: Request):
|
||||
form = await request.form()
|
||||
username = (form.get("username") or "").strip()
|
||||
password = form.get("password") or ""
|
||||
next_url = form.get("next") or "/"
|
||||
if not next_url.startswith("/"):
|
||||
next_url = "/"
|
||||
ip = _client_ip(request)
|
||||
|
||||
# Rate-limit gate — checked BEFORE bcrypt so an attacker can't burn CPU.
|
||||
locked_until = auth.login_locked_until(username, ip)
|
||||
if locked_until is not None:
|
||||
remaining = int(locked_until - __import__("time").time())
|
||||
return templates.TemplateResponse(request, "login.html", {
|
||||
"request": request,
|
||||
"needs_setup": False,
|
||||
"error": f"Too many failed attempts. Try again in {remaining // 60} min.",
|
||||
"next": next_url,
|
||||
}, status_code=429)
|
||||
|
||||
found = await auth.get_user_by_username(username)
|
||||
if not found or not auth.verify_password(password, found[1]):
|
||||
# Constant-ish-time: still call verify on a junk hash if user missing
|
||||
# so the timing of "user not found" matches "wrong password."
|
||||
if not found:
|
||||
auth.verify_password(password, "$2b$12$" + "x" * 53)
|
||||
tripped = auth.record_login_failure(username, ip)
|
||||
await auth.audit_auth_event(
|
||||
"user_login_locked_out" if tripped else "user_login_failed",
|
||||
username,
|
||||
f"Failed login from {ip}" + (
|
||||
f" — IP/user locked out for {auth.LOGIN_LOCKOUT_SECONDS // 60} min"
|
||||
if tripped else ""
|
||||
),
|
||||
)
|
||||
return templates.TemplateResponse(request, "login.html", {
|
||||
"request": request,
|
||||
"needs_setup": False,
|
||||
"error": "Invalid username or password.",
|
||||
"next": next_url,
|
||||
}, status_code=401)
|
||||
|
||||
user = found[0]
|
||||
auth.clear_login_failures(username, ip)
|
||||
request.session["user_id"] = user.id
|
||||
request.session["username"] = user.username
|
||||
await auth.touch_last_login(user.id)
|
||||
await auth.audit_auth_event(
|
||||
"user_login", user.username, f"Signed in from {ip}"
|
||||
)
|
||||
return RedirectResponse(url=next_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/setup")
|
||||
async def auth_first_user_setup(request: Request):
|
||||
"""Create the first admin from the login page when the users table is
|
||||
empty. Public endpoint — but only does anything when zero users exist."""
|
||||
if (await auth.user_count()) > 0:
|
||||
raise HTTPException(status_code=409, detail="Users already exist.")
|
||||
form = await request.form()
|
||||
username = (form.get("username") or "").strip()
|
||||
password = form.get("password") or ""
|
||||
full_name = (form.get("full_name") or "").strip() or None
|
||||
try:
|
||||
user = await auth.create_user(username, password, full_name, is_admin=True)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
request.session["user_id"] = user.id
|
||||
request.session["username"] = user.username
|
||||
await auth.touch_last_login(user.id)
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
user = request.state.current_user if hasattr(request.state, "current_user") else None
|
||||
if user:
|
||||
await auth.audit_auth_event(
|
||||
"user_logout", user.username, f"Signed out from {_client_ip(request)}"
|
||||
)
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/change-password")
|
||||
async def change_password(request: Request):
|
||||
user = request.state.current_user if hasattr(request.state, "current_user") else None
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
form = await request.form()
|
||||
current = form.get("current_password") or ""
|
||||
new_pw = form.get("new_password") or ""
|
||||
confirm = form.get("confirm_password") or ""
|
||||
if new_pw != confirm:
|
||||
raise HTTPException(status_code=400, detail="New passwords do not match.")
|
||||
try:
|
||||
await auth.change_password(user.id, current, new_pw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await auth.audit_auth_event(
|
||||
"user_password_changed", user.username,
|
||||
f"Password changed from {_client_ip(request)}",
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -445,73 +313,18 @@ async def sse_drives(request: Request):
|
|||
|
||||
@router.get("/health")
|
||||
async def health(db: aiosqlite.Connection = Depends(get_db)):
|
||||
"""Real readiness check, not just process-is-running.
|
||||
|
||||
Verifies (a) DB writable, (b) poller has succeeded recently relative
|
||||
to the configured stale_threshold_seconds, (c) SSH reachable when
|
||||
configured. Returns 503 when any check fails so a proxy/orchestrator
|
||||
health probe can take the container out of rotation.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from fastapi.responses import JSONResponse
|
||||
from app import ssh_client as _ssh
|
||||
|
||||
checks: dict[str, dict] = {}
|
||||
|
||||
# DB probe — confirm the journal is healthy (PRAGMA reads journal_mode
|
||||
# and would fail loudly if WAL is wedged or the file is unreadable).
|
||||
try:
|
||||
cur = await db.execute("PRAGMA journal_mode")
|
||||
await cur.fetchone()
|
||||
checks["db"] = {"ok": True}
|
||||
except Exception as exc:
|
||||
checks["db"] = {"ok": False, "error": str(exc)}
|
||||
|
||||
ps = poller.get_state()
|
||||
last = ps.get("last_poll_at")
|
||||
poll_age = None
|
||||
if last:
|
||||
try:
|
||||
t = datetime.fromisoformat(last)
|
||||
if t.tzinfo is None:
|
||||
t = t.replace(tzinfo=timezone.utc)
|
||||
poll_age = (datetime.now(timezone.utc) - t).total_seconds()
|
||||
except Exception:
|
||||
poll_age = None
|
||||
poll_ok = ps.get("healthy") and (
|
||||
poll_age is None or poll_age <= settings.stale_threshold_seconds * 3
|
||||
)
|
||||
checks["poller"] = {
|
||||
"ok": bool(poll_ok),
|
||||
"last_error": ps.get("last_error"),
|
||||
"last_poll_at": last,
|
||||
"age_seconds": int(poll_age) if poll_age is not None else None,
|
||||
}
|
||||
|
||||
# SSH probe — only when configured. Cheap (single sensors -j).
|
||||
if _ssh.is_configured():
|
||||
try:
|
||||
r = await _ssh.test_connection()
|
||||
checks["ssh"] = {"ok": bool(r.get("ok")),
|
||||
"error": r.get("error")}
|
||||
except Exception as exc:
|
||||
checks["ssh"] = {"ok": False, "error": str(exc)}
|
||||
else:
|
||||
checks["ssh"] = {"ok": True, "skipped": True}
|
||||
|
||||
cur = await db.execute("SELECT COUNT(*) FROM drives")
|
||||
row = await cur.fetchone()
|
||||
drives_tracked = row[0] if row else 0
|
||||
|
||||
status_ok = all(c["ok"] for c in checks.values())
|
||||
body = {
|
||||
"status": "ok" if status_ok else "degraded",
|
||||
"checks": checks,
|
||||
"drives_tracked": drives_tracked,
|
||||
"poll_interval_s": settings.poll_interval_seconds,
|
||||
"version": settings.app_version,
|
||||
return {
|
||||
"status": "ok" if ps["healthy"] else "degraded",
|
||||
"last_poll_at": ps["last_poll_at"],
|
||||
"last_error": ps["last_error"],
|
||||
"consecutive_failures": ps.get("consecutive_failures", 0),
|
||||
"poll_interval_seconds": settings.poll_interval_seconds,
|
||||
"drives_tracked": drives_tracked,
|
||||
}
|
||||
return JSONResponse(body, status_code=200 if status_ok else 503)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
async def get_mounted_drives() -> set | None:
|
||||
"""Return base devnames of every drive whose partitions are mounted
|
||||
anywhere right now. Defense-in-depth on top of pool detection — catches
|
||||
XFS/ext4/etc. scratch disks the operator forgot about. Returns None on
|
||||
any failure (caller treats that as 'preserve previous state')."""
|
||||
if not is_configured():
|
||||
return set()
|
||||
cmd = "findmnt -no SOURCE 2>/dev/null"
|
||||
try:
|
||||
async with await _connect() as conn:
|
||||
r = await conn.run(cmd, check=False)
|
||||
if r.returncode != 0 or not r.stdout:
|
||||
# findmnt always has at least / mounted on a Linux host;
|
||||
# empty output is itself suspicious. Treat as failure.
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return _parse_findmnt_sources(r.stdout)
|
||||
|
||||
|
||||
def _parse_findmnt_sources(stdout: str) -> set:
|
||||
"""Pure parser for findmnt output. Strips partitions; ignores tmpfs,
|
||||
overlay, zfs (zfs is handled by pool detection)."""
|
||||
import re as _re
|
||||
out: set = set()
|
||||
for raw in stdout.splitlines():
|
||||
s = raw.strip()
|
||||
if not s.startswith("/dev/"):
|
||||
continue
|
||||
# Skip ZFS filesystems (those are pool/exported drives, handled
|
||||
# separately and shouldn't double-lock as 'mounted').
|
||||
if "/dev/zd" in s or "/dev/zvol" in s:
|
||||
continue
|
||||
name = s[len("/dev/"):].split("[")[0] # bind mounts can have [subdir]
|
||||
if name.startswith("nvme"):
|
||||
m = _re.match(r"^(nvme\d+n\d+)", name)
|
||||
if m:
|
||||
out.add(m.group(1))
|
||||
else:
|
||||
m = _re.match(r"^(sd[a-z]+)", name)
|
||||
if m:
|
||||
out.add(m.group(1))
|
||||
return out
|
||||
|
||||
|
||||
async def get_smart_health_map(devnames: list[str]) -> dict | None:
|
||||
"""Return {devname: 'PASSED'|'FAILED'|'UNKNOWN'} for every devname.
|
||||
|
||||
|
|
|
|||
|
|
@ -2426,118 +2426,6 @@ tr.drawer-row-active {
|
|||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Login screen
|
||||
----------------------------------------------------------------------- */
|
||||
.login-body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.login-card {
|
||||
width: min(420px, 92vw);
|
||||
background: var(--bg-card, #161b22);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 28px 30px;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.login-header { margin-bottom: 18px; }
|
||||
.login-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.login-sub {
|
||||
margin-top: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.login-blurb {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.login-error {
|
||||
background: color-mix(in srgb, var(--red, #e25555) 16%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--red, #e25555) 50%, transparent);
|
||||
color: var(--red, #e25555);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.login-form { display: flex; flex-direction: column; gap: 4px; }
|
||||
.login-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.login-optional { text-transform: none; opacity: 0.7; }
|
||||
.login-input {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.login-input:focus {
|
||||
border-color: var(--accent, #3b82f6);
|
||||
outline: none;
|
||||
}
|
||||
.login-submit {
|
||||
margin-top: 18px;
|
||||
background: var(--accent, #3b82f6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 11px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.login-submit:hover { opacity: 0.9; }
|
||||
.login-footer {
|
||||
margin-top: 22px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.login-code {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.header-user {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.header-logout { font-size: 12px; }
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Pool-membership lock indicators
|
||||
----------------------------------------------------------------------- */
|
||||
|
|
@ -2577,21 +2465,6 @@ tr.drawer-row-active {
|
|||
.pool-lock-icon.pool-lock-exported {
|
||||
color: #e07a3f;
|
||||
}
|
||||
.pool-pill.pool-pill-mounted {
|
||||
background: color-mix(in srgb, #c477e0 16%, transparent);
|
||||
color: #c477e0;
|
||||
border-color: color-mix(in srgb, #c477e0 45%, transparent);
|
||||
}
|
||||
.pool-lock-icon.pool-lock-mounted {
|
||||
color: #c477e0;
|
||||
}
|
||||
.btn-unlock-mounted {
|
||||
border-color: color-mix(in srgb, #c477e0 55%, transparent);
|
||||
color: #c477e0;
|
||||
}
|
||||
.btn-unlock-mounted:hover {
|
||||
background: color-mix(in srgb, #c477e0 14%, transparent);
|
||||
}
|
||||
.btn-unlock {
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in srgb, var(--yellow) 50%, transparent);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
(function () {
|
||||
'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
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -426,7 +417,7 @@
|
|||
async function startSmartTest(btn) {
|
||||
var driveId = btn.dataset.driveId;
|
||||
var testType = btn.dataset.testType;
|
||||
var operator = defaultOperator() || 'unknown';
|
||||
var operator = localStorage.getItem('burnin_operator') || 'unknown';
|
||||
|
||||
btn.disabled = true;
|
||||
try {
|
||||
|
|
@ -492,7 +483,7 @@
|
|||
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;
|
||||
for (var i = 0; i < cancelBtns.length; i++) {
|
||||
var jobId = cancelBtns[i].dataset.jobId;
|
||||
|
|
@ -566,7 +557,7 @@
|
|||
document.getElementById('confirm-serial').value = '';
|
||||
document.getElementById('confirm-hint').textContent = 'Expected: ' + modalSerial;
|
||||
|
||||
var savedOp = defaultOperator();
|
||||
var savedOp = localStorage.getItem('burnin_operator') || '';
|
||||
document.getElementById('operator-input').value = savedOp;
|
||||
|
||||
// Init drag on first open (list is in static DOM)
|
||||
|
|
@ -682,62 +673,6 @@
|
|||
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
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -751,10 +686,8 @@
|
|||
var poolRole = btn.dataset.poolRole || 'data';
|
||||
var isBoot = btn.dataset.isBootPool === '1';
|
||||
var isExported = btn.dataset.isExported === '1';
|
||||
var isMounted = btn.dataset.isMounted === '1';
|
||||
if (isBoot) unlockExpectedToken = 'DESTROY BOOT POOL';
|
||||
else if (isExported) unlockExpectedToken = 'DESTROY EXPORTED POOL';
|
||||
else if (isMounted) unlockExpectedToken = 'DESTROY MOUNTED FILESYSTEM';
|
||||
else unlockExpectedToken = poolName;
|
||||
|
||||
document.getElementById('unlock-devname').textContent = btn.dataset.devname || '—';
|
||||
|
|
@ -766,9 +699,6 @@
|
|||
if (isExported) {
|
||||
chip.textContent = 'exported ZFS';
|
||||
chip.className = 'chip chip-aborted';
|
||||
} else if (isMounted) {
|
||||
chip.textContent = 'mounted FS';
|
||||
chip.className = 'chip chip-aborted';
|
||||
} else {
|
||||
chip.textContent = poolName + ' · ' + poolRole;
|
||||
chip.className = 'chip ' + (isBoot ? 'chip-failed' : 'chip-aborted');
|
||||
|
|
@ -792,13 +722,6 @@
|
|||
'Burning it in will silently destroy whatever pool that data belongs to — including ' +
|
||||
'pools that another system may be relying on. Confirm you have already evacuated or ' +
|
||||
'reassigned the pool before continuing.';
|
||||
} else if (isMounted) {
|
||||
titleEl.textContent = 'Unlock drive with MOUNTED filesystem';
|
||||
warnTitle.textContent = 'This drive has a non-ZFS filesystem currently mounted.';
|
||||
warnBody.textContent =
|
||||
'findmnt reports a partition on this drive is mounted right now. Burning it in will ' +
|
||||
'destroy whatever data is on that filesystem and almost certainly leave the mount ' +
|
||||
'point in a broken state. Unmount it first, or confirm you really mean to wipe it.';
|
||||
} else {
|
||||
titleEl.textContent = 'Unlock pool drive';
|
||||
warnTitle.textContent = "This drive belongs to zpool '" + poolName + "'.";
|
||||
|
|
@ -812,7 +735,7 @@
|
|||
|
||||
document.getElementById('unlock-confirm-input').value = '';
|
||||
document.getElementById('unlock-reason-input').value = '';
|
||||
var savedOp = defaultOperator();
|
||||
var savedOp = localStorage.getItem('burnin_operator') || '';
|
||||
document.getElementById('unlock-operator-input').value = savedOp;
|
||||
validateUnlockModal();
|
||||
|
||||
|
|
@ -963,7 +886,7 @@
|
|||
function openBatchModal() {
|
||||
var modal = document.getElementById('batch-modal');
|
||||
if (!modal) return;
|
||||
var savedOp = defaultOperator();
|
||||
var savedOp = localStorage.getItem('burnin_operator') || '';
|
||||
document.getElementById('batch-operator-input').value = savedOp;
|
||||
document.getElementById('batch-confirm-cb').checked = false;
|
||||
// Reset stages to all-on (keep user's drag order)
|
||||
|
|
@ -1087,7 +1010,7 @@
|
|||
|
||||
async function cancelBurnin(btn) {
|
||||
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;
|
||||
|
||||
|
|
@ -1125,20 +1048,6 @@
|
|||
var cancelSmartBtn = e.target.closest('.btn-cancel-smart');
|
||||
if (cancelSmartBtn && !cancelSmartBtn.disabled) { cancelSmartTest(cancelSmartBtn); return; }
|
||||
|
||||
// Change password header link
|
||||
if (e.target.id === 'open-password-modal' || e.target.closest('#open-password-modal')) {
|
||||
e.preventDefault();
|
||||
openPasswordModal();
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#password-modal-close-btn') ||
|
||||
e.target.closest('#password-modal-cancel-btn')) {
|
||||
closePasswordModal();
|
||||
return;
|
||||
}
|
||||
if (e.target.id === 'password-modal') { closePasswordModal(); return; }
|
||||
if (e.target.id === 'password-modal-submit-btn') { submitPasswordChange(); return; }
|
||||
|
||||
// Pool-drive unlock button (single drive)
|
||||
var unlockBtn = e.target.closest('.btn-unlock');
|
||||
if (unlockBtn && !unlockBtn.disabled) { openUnlockModal(unlockBtn); return; }
|
||||
|
|
@ -1196,7 +1105,6 @@
|
|||
|
||||
document.addEventListener('input', function (e) {
|
||||
var id = e.target.id;
|
||||
if (id === 'pw-current' || id === 'pw-new' || id === 'pw-confirm') validatePasswordModal();
|
||||
if (id === 'unlock-operator-input' || id === 'unlock-reason-input' ||
|
||||
id === 'unlock-confirm-input') validateUnlockModal();
|
||||
if (id === 'operator-input' || id === 'confirm-serial') validateModal();
|
||||
|
|
@ -1204,8 +1112,6 @@
|
|||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
var pwModal = document.getElementById('password-modal');
|
||||
if (pwModal && !pwModal.hidden) { closePasswordModal(); return; }
|
||||
var uModal = document.getElementById('unlock-modal');
|
||||
if (uModal && !uModal.hidden) { closeUnlockModal(); return; }
|
||||
var modal = document.getElementById('start-modal');
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@
|
|||
{%- set pool_locked = drive.pool_name and not drive.pool_unlocked_until %}
|
||||
{%- set is_boot_pool = drive.pool_name == 'boot-pool' %}
|
||||
{%- set is_exported = drive.pool_role == 'exported' %}
|
||||
{%- set is_mounted = drive.pool_role == 'mounted' %}
|
||||
{%- set selectable = not bi_active and not short_busy and not long_busy and not pool_locked %}
|
||||
{%- set bi_done = drive.burnin and drive.burnin.state in ('passed', 'failed', 'cancelled', 'unknown') %}
|
||||
{%- set smart_done = (drive.smart_short and drive.smart_short.state in ('passed','failed','aborted'))
|
||||
|
|
@ -98,15 +97,15 @@
|
|||
<td class="col-drive">
|
||||
<span class="drive-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 %}"
|
||||
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>
|
||||
<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{% else %}In pool '{{ drive.pool_name }}' ({{ drive.pool_role or 'data' }}){% endif %}">🔒</span>
|
||||
{%- endif -%}
|
||||
{{ drive.devname }}
|
||||
</span>
|
||||
<span class="drive-model">{{ drive.model or "Unknown" }}</span>
|
||||
{%- 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 %}"
|
||||
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>
|
||||
<span class="pool-pill{% if is_boot_pool %} pool-pill-boot{% elif is_exported %} pool-pill-exported{% endif %}"
|
||||
title="ZFS pool membership">{% if is_exported %}exported ZFS{% else %}{{ drive.pool_name }} · {{ drive.pool_role or 'data' }}{% endif %}</span>
|
||||
{%- endif %}
|
||||
{%- if drive.location %}
|
||||
<span class="drive-location"
|
||||
|
|
@ -170,7 +169,7 @@
|
|||
{%- endif %}
|
||||
{%- if pool_locked %}
|
||||
<!-- 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-devname="{{ drive.devname }}"
|
||||
data-serial="{{ drive.serial or '' }}"
|
||||
|
|
@ -180,8 +179,7 @@
|
|||
data-pool-role="{{ drive.pool_role or 'data' }}"
|
||||
data-is-boot-pool="{{ '1' if is_boot_pool 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{% 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>
|
||||
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>
|
||||
{%- else %}
|
||||
<!-- Burn-In -->
|
||||
<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">
|
||||
<title>{% block title %}TrueNAS Burn-In{% endblock %}</title>
|
||||
<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>
|
||||
<body>
|
||||
|
||||
|
|
@ -41,11 +38,6 @@
|
|||
<a class="header-link" href="/audit">Audit</a>
|
||||
<a class="header-link" href="/settings">Settings</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>
|
||||
</header>
|
||||
|
||||
|
|
@ -65,10 +57,6 @@
|
|||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% if request.state.current_user %}
|
||||
{% include "components/modal_password.html" %}
|
||||
{% endif %}
|
||||
|
||||
<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-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
|
||||
sse-starlette
|
||||
asyncssh
|
||||
itsdangerous>=2.1
|
||||
bcrypt>=4.0,<5.0
|
||||
python-multipart>=0.0.7
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
[Unit]
|
||||
Description=Security scan of truenas-burnin (pip-audit + bandit + gitleaks)
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Wire SECURITY_SCAN_WEBHOOK here if you want findings POSTed somewhere.
|
||||
# Environment=SECURITY_SCAN_WEBHOOK=https://chat.example/hooks/abc
|
||||
ExecStart=%h/docker/stacks/truenas-burnin/scripts/security-scan.sh
|
||||
# Tools cache + container pulls — give them headroom.
|
||||
TimeoutStartSec=600
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Daily security scan of the deployed truenas-burnin source on maple.
|
||||
# Mirrors the .forgejo/workflows/security-scan.yml CI pipeline so a finding
|
||||
# the runner-less forge would have flagged still surfaces here.
|
||||
#
|
||||
# Tools all run in containers — nothing installed on the host.
|
||||
# pip-audit — known CVEs in installed packages (scans the LIVE container)
|
||||
# bandit — Python static security analysis on host source tree
|
||||
# gitleaks — secrets across the full git history
|
||||
#
|
||||
# Output:
|
||||
# ~/security-scans/scan-YYYY-MM-DD/{pip-audit,bandit,gitleaks}.txt
|
||||
# ~/security-scans/findings.log — appended one line per scan with findings
|
||||
#
|
||||
# Wiring:
|
||||
# Daily systemd user timer at 03:30 local (after the in-app retention job
|
||||
# so backups are fresh). See scripts/security-scan.{service,timer}.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO_URL="${REPO_URL:-https://git.hellocomputer.xyz/brandon/truenas-burnin.git}"
|
||||
REPO="${REPO:-$HOME/scan-checkouts/truenas-burnin}"
|
||||
OUT_BASE="${OUT_BASE:-$HOME/security-scans}"
|
||||
DATE="$(date +%Y-%m-%d)"
|
||||
OUT_DIR="$OUT_BASE/scan-$DATE"
|
||||
SUMMARY="$OUT_BASE/findings.log"
|
||||
GITLEAKS_VERSION="${GITLEAKS_VERSION:-8.21.2}"
|
||||
|
||||
mkdir -p "$OUT_DIR" "$(dirname "$REPO")"
|
||||
|
||||
# Maintain a dedicated checkout for scanning. The deploy at
|
||||
# ~/docker/stacks/truenas-burnin/ is just the bind-mounted source — no
|
||||
# .git, no history — so gitleaks can't scan there. We keep a separate
|
||||
# clone, fast-forward it to origin/main each run.
|
||||
if [ ! -d "$REPO/.git" ]; then
|
||||
echo "Cloning $REPO_URL to $REPO ..."
|
||||
git clone --quiet "$REPO_URL" "$REPO" || {
|
||||
echo "fatal: git clone failed" >&2
|
||||
exit 65
|
||||
}
|
||||
fi
|
||||
|
||||
cd "$REPO"
|
||||
git fetch --quiet --prune origin 2>&1 || true
|
||||
git checkout --quiet main 2>&1 || true
|
||||
git reset --hard --quiet origin/main 2>&1 || true
|
||||
|
||||
echo "=== Security scan $DATE ===" > "$OUT_DIR/summary.txt"
|
||||
date -Iseconds >> "$OUT_DIR/summary.txt"
|
||||
echo >> "$OUT_DIR/summary.txt"
|
||||
|
||||
# --- pip-audit against the LIVE container's installed packages ----------
|
||||
# Catches CVEs that hit a transitive dep we don't pin in requirements.txt.
|
||||
echo "--- pip-audit (live container) ---" | tee -a "$OUT_DIR/summary.txt"
|
||||
docker exec truenas-burnin sh -c \
|
||||
"pip install --quiet --no-cache-dir --disable-pip-version-check pip-audit 2>/dev/null && pip-audit --strict --format=columns" \
|
||||
> "$OUT_DIR/pip-audit.txt" 2>&1
|
||||
PIPS=$?
|
||||
echo " exit=$PIPS ($OUT_DIR/pip-audit.txt)" | tee -a "$OUT_DIR/summary.txt"
|
||||
|
||||
# --- bandit against the LIVE deploy dir ---------------------------------
|
||||
# Scan what's actually running, not what's in git — catches drift between
|
||||
# forge HEAD and maple. B608 (SQL injection via dynamic strings) is
|
||||
# skipped globally: every dynamic SQL build in this codebase uses
|
||||
# bound parameters for data and structural placeholders only.
|
||||
DEPLOY_DIR="${DEPLOY_DIR:-$HOME/docker/stacks/truenas-burnin}"
|
||||
echo "--- bandit (deploy: $DEPLOY_DIR) ---" | tee -a "$OUT_DIR/summary.txt"
|
||||
docker run --rm \
|
||||
-v "$DEPLOY_DIR/app:/src:ro" \
|
||||
python:3.12-slim sh -c \
|
||||
"pip install --quiet --no-cache-dir --disable-pip-version-check bandit 2>/dev/null && bandit -r /src -ll -ii --skip B608" \
|
||||
> "$OUT_DIR/bandit.txt" 2>&1
|
||||
BANDITS=$?
|
||||
echo " exit=$BANDITS ($OUT_DIR/bandit.txt)" | tee -a "$OUT_DIR/summary.txt"
|
||||
|
||||
# --- gitleaks against the full git history ------------------------------
|
||||
echo "--- gitleaks ---" | tee -a "$OUT_DIR/summary.txt"
|
||||
docker run --rm \
|
||||
-v "$REPO:/repo:ro" \
|
||||
"zricethezav/gitleaks:v$GITLEAKS_VERSION" \
|
||||
detect --source /repo --no-banner --redact --verbose \
|
||||
> "$OUT_DIR/gitleaks.txt" 2>&1
|
||||
LEAKS=$?
|
||||
echo " exit=$LEAKS ($OUT_DIR/gitleaks.txt)" | tee -a "$OUT_DIR/summary.txt"
|
||||
|
||||
# --- summary + notification --------------------------------------------
|
||||
TOTAL_EXIT=$(( PIPS + BANDITS + LEAKS ))
|
||||
{
|
||||
echo
|
||||
echo "Total findings exit-code sum: $TOTAL_EXIT"
|
||||
echo " pip-audit: $PIPS"
|
||||
echo " bandit: $BANDITS"
|
||||
echo " gitleaks: $LEAKS"
|
||||
} >> "$OUT_DIR/summary.txt"
|
||||
|
||||
if [ "$TOTAL_EXIT" -ne 0 ]; then
|
||||
printf '%s — findings (pip-audit=%d bandit=%d gitleaks=%d) — see %s\n' \
|
||||
"$DATE" "$PIPS" "$BANDITS" "$LEAKS" "$OUT_DIR" >> "$SUMMARY"
|
||||
# Hook for downstream notification — wire to your existing Mattermost
|
||||
# / Fastmail / webhook chain. Stays a no-op until SECURITY_SCAN_WEBHOOK
|
||||
# is set in the systemd unit's Environment=.
|
||||
if [ -n "${SECURITY_SCAN_WEBHOOK:-}" ]; then
|
||||
curl -fsS -X POST -H 'Content-Type: text/plain' \
|
||||
--data-binary "@$OUT_DIR/summary.txt" \
|
||||
"$SECURITY_SCAN_WEBHOOK" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Retention — keep last 30 daily directories, prune older.
|
||||
find "$OUT_BASE" -maxdepth 1 -type d -name "scan-*" -mtime +30 \
|
||||
-exec rm -rf {} \;
|
||||
|
||||
exit "$TOTAL_EXIT"
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
[Unit]
|
||||
Description=Daily security scan of truenas-burnin
|
||||
Requires=security-scan.service
|
||||
|
||||
[Timer]
|
||||
# 03:30 local — runs after the in-app retention/backup job (03:00) so the
|
||||
# nightly DB snapshot has already landed.
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
# If maple was off at 03:30, fire on next boot — we'd rather have a stale
|
||||
# scan than miss a day entirely.
|
||||
Persistent=true
|
||||
RandomizedDelaySec=10m
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Loading…
Add table
Reference in a new issue