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>
216 lines
11 KiB
HTML
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 %}">🔒</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>
|
|
{%- 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 %}">🔒 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 }}">🔓</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>
|