nas-burnin/app/templates/components/drives_table.html
Brandon Walter d4c0770b9e feat: app-level login + hardening sweep (1.0.0-22 -> 1.0.0-23)
Two layered changes shipped in this branch:

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

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

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

== 1.0.0-23: hardening sweep ==

Closes the eight-item gap audit:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:08:29 -04:00

216 lines
11 KiB
HTML

{%- macro smart_cell(smart) -%}
<div class="smart-cell">
{%- if smart.state == 'running' -%}
<div class="progress-wrap">
<div class="progress-bar">
<div class="progress-fill" style="width:{{ smart.percent or 0 }}%"></div>
</div>
<span class="progress-pct">{{ smart.percent or 0 }}%</span>
</div>
{%- if smart.eta_seconds %}
<div class="eta-text">{{ smart.eta_seconds | format_eta }}</div>
{%- endif %}
{%- elif smart.state == 'passed' -%}
<span class="chip chip-passed">Passed</span>
{%- elif smart.state == 'failed' -%}
<span class="chip chip-failed" title="{{ smart.error_text or '' }}">Failed</span>
{%- elif smart.state == 'aborted' -%}
<span class="chip chip-aborted">Aborted</span>
{%- else -%}
<span class="cell-empty"></span>
{%- endif -%}
</div>
{%- endmacro -%}
{%- macro burnin_cell(bi) -%}
<div class="burnin-cell">
{%- if bi is none -%}
<span class="cell-empty"></span>
{%- elif bi.state == 'queued' -%}
<span class="chip chip-queued">Queued</span>
{%- elif bi.state == 'running' -%}
<div class="progress-wrap">
<div class="progress-bar">
<div class="progress-fill progress-fill-green" style="width:{{ bi.percent or 0 }}%"></div>
</div>
<span class="progress-pct">{{ bi.percent or 0 }}%</span>
</div>
<div class="burnin-meta">
{%- if bi.stage_name %}
<span class="stage-name">{{ bi.stage_name | replace('_', ' ') | title }}</span>
{%- endif %}
{%- if bi.started_at %}
<span class="elapsed-timer" data-started="{{ bi.started_at }}">{{ bi.started_at | format_elapsed }}</span>
{%- endif %}
</div>
{%- elif bi.state == 'passed' -%}
<span class="chip chip-passed">Passed</span>
{%- elif bi.state == 'failed' -%}
<span class="chip chip-failed">Failed{% if bi.stage_name %} ({{ bi.stage_name | replace('_',' ') }}){% endif %}</span>
{%- elif bi.state == 'cancelled' -%}
<span class="chip chip-aborted">Cancelled</span>
{%- elif bi.state == 'unknown' -%}
<span class="chip chip-unknown">Unknown</span>
{%- else -%}
<span class="cell-empty"></span>
{%- endif -%}
</div>
{%- endmacro -%}
<table>
<thead>
<tr>
<th class="col-check">
<input type="checkbox" id="select-all-cb" class="drive-cb" title="Select all idle drives">
</th>
<th class="col-drive">Drive</th>
<th class="col-serial">Serial</th>
<th class="col-size">Size</th>
<th class="col-temp">Temp</th>
<th class="col-health">Health</th>
<th class="col-smart">Short SMART</th>
<th class="col-smart">Long SMART</th>
<th class="col-burnin">Burn-In</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody id="drives-tbody">
{%- if drives %}
{%- for drive in drives %}
{%- set bi_active = drive.burnin and drive.burnin.state in ('queued', 'running') %}
{%- set short_busy = drive.smart_short and drive.smart_short.state == 'running' %}
{%- set long_busy = drive.smart_long and drive.smart_long.state == 'running' %}
{%- 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'))
or (drive.smart_long and drive.smart_long.state in ('passed','failed','aborted')) %}
{%- set can_reset = (bi_done or smart_done) and not bi_active and not short_busy and not long_busy and not pool_locked %}
<tr data-status="{{ drive.status }}" id="drive-{{ drive.id }}">
<td class="col-check">
{%- if selectable %}
<input type="checkbox" class="drive-checkbox" data-drive-id="{{ drive.id }}">
{%- endif %}
</td>
<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 %}">&#x1F512;</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 }} &middot; {{ drive.pool_role or 'data' }}{% endif %}</span>
{%- endif %}
{%- if drive.location %}
<span class="drive-location"
data-drive-id="{{ drive.id }}"
data-field="location"
title="Click to edit location">{{ drive.location }}</span>
{%- else %}
<span class="drive-location drive-location-empty"
data-drive-id="{{ drive.id }}"
data-field="location"
title="Click to set location">+ location</span>
{%- endif %}
</td>
<td class="col-serial mono">{{ drive.serial or "—" }}</td>
<td class="col-size">{{ drive.size_bytes | format_bytes }}</td>
<td class="col-temp">
{%- if drive.temperature_c is not none %}
<span class="temp {{ drive.temperature_c | temp_class }}">{{ drive.temperature_c }}°C</span>
{%- else %}
<span class="cell-empty"></span>
{%- endif %}
</td>
<td class="col-health">
<span class="chip chip-{{ drive.smart_health | lower }}">{{ drive.smart_health }}</span>
</td>
<td class="col-smart">{{ smart_cell(drive.smart_short) }}</td>
<td class="col-smart">{{ smart_cell(drive.smart_long) }}</td>
<td class="col-burnin">{{ burnin_cell(drive.burnin) }}</td>
<td class="col-actions">
<div class="action-group">
{%- if bi_active %}
<!-- Burn-in running/queued: only show cancel -->
<button class="btn-action btn-cancel"
data-job-id="{{ drive.burnin.id }}">✕ Burn-In</button>
{%- else %}
<!-- Short SMART: show cancel if running, else start button -->
{%- if short_busy %}
<button class="btn-action btn-cancel-smart"
data-drive-id="{{ drive.id }}"
data-test-type="short"
title="Cancel Short SMART test">✕ Short</button>
{%- else %}
<button class="btn-action btn-smart-short{% if long_busy %} btn-disabled{% endif %}"
data-drive-id="{{ drive.id }}"
data-test-type="SHORT"
{% if long_busy %}disabled{% endif %}
title="Start Short SMART test">Short</button>
{%- endif %}
<!-- Long SMART: show cancel if running, else start button -->
{%- if long_busy %}
<button class="btn-action btn-cancel-smart"
data-drive-id="{{ drive.id }}"
data-test-type="long"
title="Cancel Long SMART test">✕ Long</button>
{%- else %}
<button class="btn-action btn-smart-long{% if short_busy %} btn-disabled{% endif %}"
data-drive-id="{{ drive.id }}"
data-test-type="LONG"
{% if short_busy %}disabled{% endif %}
title="Start Long SMART test (~several hours)">Long</button>
{%- 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 %}"
data-drive-id="{{ drive.id }}"
data-devname="{{ drive.devname }}"
data-serial="{{ drive.serial or '' }}"
data-model="{{ drive.model or 'Unknown' }}"
data-size="{{ drive.size_bytes | format_bytes }}"
data-pool-name="{{ drive.pool_name }}"
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 %}">&#x1F512; Unlock</button>
{%- else %}
<!-- Burn-In -->
<button class="btn-action btn-start{% if short_busy or long_busy %} btn-disabled{% endif %}"
data-drive-id="{{ drive.id }}"
data-devname="{{ drive.devname }}"
data-serial="{{ drive.serial or '' }}"
data-model="{{ drive.model or 'Unknown' }}"
data-size="{{ drive.size_bytes | format_bytes }}"
data-health="{{ drive.smart_health }}"
data-pool-name="{{ drive.pool_name or '' }}"
data-pool-unlocked-until="{{ drive.pool_unlocked_until or '' }}"
{% if short_busy or long_busy %}disabled{% endif %}
title="Start Burn-In{% if drive.pool_name %} (UNLOCKED — pool drive){% endif %}">Burn-In{% if drive.pool_name %} <span class="unlock-countdown" data-expires="{{ drive.pool_unlocked_until }}">&#x1F513;</span>{% endif %}</button>
<!-- Reset — clears SMART state so drive can be re-tested from scratch -->
{%- if can_reset %}
<button class="btn-action btn-reset"
data-drive-id="{{ drive.id }}"
title="Reset SMART state — clears test results so drive shows as fresh">Reset</button>
{%- endif %}
{%- endif %}
{%- endif %}
</div>
</td>
</tr>
{%- endfor %}
{%- else %}
<tr>
<td colspan="10" class="empty-state">No drives found. Waiting for first poll…</td>
</tr>
{%- endif %}
</tbody>
</table>