Commit Graph

35 Commits

Author SHA1 Message Date
jhodgkin c614768704 docs: update CLAUDE.md for Fingerbank integration
CI / web (push) Successful in 15s
CI / api (push) Successful in 20s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:41:24 -06:00
jhodgkin e5ace7692a Add Fingerbank e2e coverage; fix two test races found along the way
CI / web (push) Successful in 16s
CI / api (push) Successful in 22s
- deep-check.spec.ts: new test asserting a Fingerbank ID + confidence
  label appears (targets the Nintendo device specifically, skips if
  it's not currently on the network -- not something the test suite
  controls). First run false-skipped because it checked row.count()
  before waiting for the device table to actually render.
- device-ratio.spec.ts: badge counts and displayed text were read as
  two separate one-shot queries (.count()/.textContent() don't
  auto-retry like expect() matchers), which raced a background poll
  once and failed. Wrapped the whole comparison in expect().toPass()
  so it retries atomically instead. Confirmed fixed: 4/4 clean runs
  with retries disabled.

15/15 e2e tests green across 8 spec files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:40:47 -06:00
jhodgkin d0d6ae95f1 Add optional Fingerbank device fingerprinting to deep-check
CI / web (push) Successful in 16s
CI / api (push) Successful in 21s
Folded into the existing on-demand Deep check button: queries
Fingerbank's interrogate API with the device's MAC plus the SSDP
SERVER header when deep-check-device.sh finds one, showing the
confidence band alongside the result. Runs directly from the API
container (no host-level access needed, just an outbound HTTPS call),
unlike the SSDP/mDNS steps.

Confirmed via direct testing: without DHCP fingerprint data (which we
structurally don't have, not being the DHCP server), MAC-only queries
often can't get past manufacturer-level confidence -- same info the
free OUI lookup already provides. Documented honestly in
docs/device-discovery.md rather than overselling it. Still worth
having as opt-in enrichment for devices that do expose richer signals.

Gated behind optional FINGERBANK_API_KEY -- missing key, API errors,
or no match all degrade gracefully without affecting the rest of
deep-check's local findings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:35:35 -06:00
jhodgkin ee9b6d15cd Add e2e coverage for the known/unknown device ratio
CI / web (push) Successful in 16s
CI / api (push) Successful in 22s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:23:38 -06:00
jhodgkin c772ca95c9 Add known/unknown device ratio next to the device count
CI / web (push) Successful in 16s
CI / api (push) Successful in 22s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:22:04 -06:00
jhodgkin 6d3e255de3 docs: update CLAUDE.md for device identification feature
CI / web (push) Successful in 15s
CI / api (push) Successful in 22s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:19:14 -06:00
jhodgkin a2067581a5 Add deep-check e2e coverage
CI / web (push) Successful in 16s
CI / api (push) Successful in 23s
Verified via real HTTP path (not just the direct SSH test done while
building it): correctly identified Home Assistant via SSDP, 400 on
malformed IP, 401 unauthenticated, clean empty result for a device
with nothing to find (Echo-type devices deliberately minimize their
LAN footprint -- expected, not a bug).

Fixed the same substring-matching mistake caught earlier in
device-labeling.spec.ts, this time on "known" being a substring of
"unknown" -- switched to matching .device-badge.known specifically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:17:37 -06:00
jhodgkin 542a3d8ce0 Wire up on-demand deep-check: API route + dashboard button
CI / web (push) Successful in 18s
CI / api (push) Successful in 24s
POST /api/devices/:ip/deep-check runs deep-check-device.sh on the
CT122 host via SSH (reaches its own LAN IP), returns mDNS/SSDP/port
scan results. "Deep check" button on unknown device rows in the
dashboard shows results inline below the row.

Verified end-to-end via SSH before wiring into the API: correctly
identified Home Assistant via SSDP (friendlyName/manufacturer/model),
and confirmed both a shell-injection attempt and an out-of-subnet IP
get rejected cleanly by the forced command's input validation.

Closes #15 (all four pieces: OUI, mDNS, manual labels, deep check).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:15:24 -06:00
jhodgkin e0ef618f0a Add deep-check-device.sh (host-level, invoked via SSH forced-command)
CI / web (push) Successful in 16s
CI / api (push) Successful in 23s
Part of #15's fourth piece: on-demand active investigation of a
single unknown device, admin-triggered from the dashboard. mDNS
resolve, targeted SSDP/UPnP query (many smart-home devices announce a
friendlyName/manufacturer this way), curated port scan, HTTP
title/server grab on anything open. Runs on the CT122 host for the
same multicast-needs-real-network-access reason discover-devices.sh
does. Not wired into the API yet -- that's next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:09:28 -06:00
jhodgkin b84fd373c2 Add device labeling e2e test; add data-ip for reliable row targeting
CI / web (push) Successful in 17s
CI / api (push) Successful in 23s
First test run hit a bug in the test itself: Playwright's hasText
filter does substring matching, so IP 192.168.1.1 matched
192.168.1.10, 192.168.1.100, 192.168.1.171, etc -- flaky/wrong row
selection. Added a data-ip attribute to each row for exact targeting
instead of relying on text content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:08:06 -06:00
jhodgkin 7a685b9bd8 fix: SQLite has no ADD COLUMN IF NOT EXISTS, crashed on startup
CI / web (push) Successful in 18s
CI / api (push) Successful in 23s
Conflated CREATE TABLE/INDEX's IF NOT EXISTS support with ALTER
TABLE ADD COLUMN, which SQLite has never supported -- syntax error,
not a version issue (confirmed on 3.49.2). Check pragma table_info
for the column first instead. Verified against both a fresh DB and
one simulating the existing pre-migration production schema, and
confirmed idempotent on a second open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:05:54 -06:00
jhodgkin d614be4747 fix: mac-oui-lookup ESM/CJS interop crash on startup
CI / web (push) Successful in 17s
CI / api (push) Successful in 23s
Named import of a CommonJS module's export crashed the whole process
at boot (SyntaxError, not caught by tsc since it's a runtime module
resolution behavior, not a type error). Verified by actually running
the built output with node this time instead of trusting tsc alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:04:52 -06:00
jhodgkin 1caad69448 Identify unknown devices: OUI vendor lookup, mDNS, manual labels
CI / web (push) Successful in 20s
CI / api (push) Successful in 29s
- OUI: mac-oui-lookup package resolves vendor from the MAC prefix
  (computed on read, no storage needed). Already correctly identifies
  the LXC host prefix as "Proxmox Server Solutions GmbH" and several
  "unknown" devices as "Amazon Technologies Inc." -- likely the Echo
  Dots / Ring gear.
- mDNS: discover-devices.sh now runs avahi-resolve per discovered IP
  (parallel, bounded 2s timeout per host so one non-mDNS device can't
  stall the run), stored in a new devices.mdns_hostname column.
- Manual labels: new device_labels table keyed by MAC (survives DHCP
  IP changes), PUT/DELETE /api/devices/:mac/label, inline-editable
  Name cell in the dashboard. Deliberately separate from vendor/mDNS
  info -- those are shown as an italic *hint* for unlabeled devices,
  not treated as "known" until the admin actually confirms one.
- Fixed the Name column's sort comparator to match what's rendered
  (name, else vendor/mDNS hint) instead of just the raw name field --
  caught while reasoning through what the existing sort test would
  actually need to assert once hints appear in the column.

Part of #15 (OUI/mDNS/manual labels done; on-demand deep-check next).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:03:06 -06:00
jhodgkin cdbdd01541 Make the network device table sortable by column
CI / web (push) Successful in 17s
CI / api (push) Successful in 24s
Click a header to sort by it (ascending), click again to reverse.
Status defaults to known-first (its display string sorts that way
naturally, no special-casing needed). IP sorts numerically by octet,
not lexically. Ties fall back to IP order so the table doesn't
reshuffle mid-poll for devices sharing a sort value (e.g. many
unnamed unknowns).

Added a Playwright test verifying IP asc/desc and Name asc against
real rendered data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:48:51 -06:00
jhodgkin 669c51d348 docs: record Vaultwarden credential backup completion
CI / web (push) Successful in 18s
CI / api (push) Successful in 24s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:46:27 -06:00
jhodgkin cae8b7fc7e docs: update e2e/README.md coverage list
CI / api (push) Successful in 27s
CI / web (push) Successful in 13s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:38:25 -06:00
jhodgkin 48acfcd715 Broaden e2e coverage: invalid credentials, session persistence, API auth
CI / web (push) Successful in 17s
CI / api (push) Successful in 32s
Added: wrong-password rejection (local + OIDC), session survives a
page reload, and API-level checks that /api/hosts, /api/devices,
/api/auth/me all reject unauthenticated requests regardless of what
the UI does.

No new app bugs found this round -- one test assertion was itself
wrong (expected no session cookie on failed login; @fastify/session
issues an anonymous cookie on any response by design, that's normal).
Fixed to assert the property that actually matters: the cookie grants
no access. 9/9 tests green across 4 consecutive full-suite runs with
parallel workers, no flakiness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:38:10 -06:00
jhodgkin 13f69ea1aa docs: e2e suite writeup, update CLAUDE.md/README with OIDC verification status
CI / web (push) Successful in 17s
CI / api (push) Successful in 23s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:30:21 -06:00
jhodgkin 294be26500 fix: OIDC callback sent wrong scheme in token exchange redirect_uri
CI / web (push) Successful in 17s
CI / api (push) Successful in 24s
Fastify only sees plain HTTP -- TLS terminates at NPM/Cloudflare
before reaching this process. Building the callback's currentUrl from
req.headers.host with a hardcoded "http://" sent
redirect_uri=http://monitor.jerodrigged.com/... during the token
exchange, which Authentik rejects (logged as generic "invalid_client"
to the client, but its own event log said plainly: "Invalid redirect
URI used by provider"). Fixed by reusing the known-correct
redirectUri's origin and only taking the query string from the actual
request, instead of trying to infer scheme from headers.

Also fixes the Playwright OIDC test's selectors (Authentik's password
field has no <label> association -- placeholder text, not getByLabel).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:27:39 -06:00
jhodgkin 9b5051d3ac Add Playwright e2e tests; fix two real bugs they caught
CI / web (push) Successful in 17s
CI / api (push) Failing after 3h0m1s
fix: OIDC_ISSUER_URL used Authentik's LAN IP (192.168.1.208:9443).
Authentik's discovery doc echoes back whichever host you query it
through, so that LAN IP got baked into authorization_endpoint -- the
URL the *browser* gets redirected to. Anyone off the LAN got sent to
an address they couldn't reach. Authentik was already publicly
exposed at auth.jerodrigged.com (pre-existing NPM proxy host); switched
to that, which also has a real cert so OIDC_ALLOW_INSECURE_TLS could
go back to false. Reported as "signed in via Authentik, redirected to
the local IP, failed."

fix: frontend's request() helper always sent Content-Type:
application/json, even for logout's bodyless POST. Fastify's default
JSON parser rejects an empty body under that content-type (400) --
sign-out silently failed to log the user out. curl-based testing
missed this because curl doesn't set that header without -d. Caught
immediately by the new Playwright local-login test.

e2e/: Playwright suite for local auth and OIDC login. OIDC test uses
a dedicated Authentik test user (blueprint-provisioned, never a real
personal login) so the whole flow can run unattended and repeatedly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:22:34 -06:00
jhodgkin a49ce0ab5b chore: mark discover-devices.sh executable in git
CI / web (push) Successful in 16s
CI / api (push) Successful in 23s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:09:14 -06:00
jhodgkin 9750aa9000 docs: record backlog completion in CLAUDE.md
CI / web (push) Successful in 14s
CI / api (push) Successful in 20s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:08:54 -06:00
jhodgkin 5143840d90 fix: bind-mount config/ as a directory, not a single file
CI / web (push) Successful in 17s
CI / api (push) Successful in 23s
Single-file bind mounts pin the container to that file's inode at
mount time. sed -i and most editors write-then-rename (atomic write),
which swaps in a new inode at the same path -- the container kept
reading the orphaned original and never saw edits, silently breaking
the hot-reload from the previous commit. Caught by actually testing
the reload live instead of trusting the code. Directory mounts
resolve paths dynamically and don't have this problem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:06:52 -06:00
jhodgkin 1e1fb98afb Hot-reload sshHosts/knownDevices from hosts.yaml, no restart needed
CI / web (push) Successful in 16s
CI / api (push) Successful in 22s
Checks the file's mtime on each poll cycle (already running every
30s) rather than adding a separate file-watcher or admin UI. Proxmox
hosts already needed no config (auto-discovered every poll); this
covers the two lists that did. A parse failure logs and keeps the
previous config running instead of crashing the poller.

Closes #14.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:36:37 -06:00
jhodgkin 27c83d52b2 fix: ProxmoxCollector read wrong shape for node mem/disk
CI / web (push) Successful in 18s
CI / api (push) Successful in 24s
/nodes/{node}/status nests memory/rootfs objects; the LXC listing
endpoint uses flat mem/maxmem/disk/maxdisk. Code assumed the LXC
shape for both, so the Proxmox host's own memPct/diskPct were NaN ->
serialized as null the whole time. Found while checking sparkline
history data looked wrong for the host card specifically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:34:10 -06:00
jhodgkin f3ee4c2424 Add historical sparklines to host cards
CI / web (push) Successful in 17s
CI / api (push) Successful in 24s
24h of snapshots were already being retained but never read. Adds a
windowed query (last ~40 samples/host, one query total via
ROW_NUMBER() OVER PARTITION BY, not N+1) embedded in the existing
/api/hosts response, rendered as small hand-rolled SVG sparklines
(cpu/mem/disk overlaid) -- no charting library needed at this scale.

Closes #10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:32:36 -06:00
jhodgkin ce232c5a53 Finish public exposure: monitor.jerodrigged.com is live
CI / web (push) Successful in 17s
CI / api (push) Successful in 22s
Cloudflare tunnel route -> NPM -> dashboard, Let's Encrypt cert via
NPM's API, both public and LAN OIDC redirect URIs registered in
Authentik. Hit and fixed a Flexible-SSL redirect loop (ssl_forced
must stay false since Cloudflare terminates TLS at the edge and talks
plain HTTP to the origin) -- documented clearly so it doesn't get
"fixed" by accident later.

Closes #13.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:27:54 -06:00
jhodgkin 04131006ed docs: record Zabbix decision, CT122 conflict, OIDC/exposure status
CI / web (push) Successful in 18s
CI / api (push) Successful in 24s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:17:06 -06:00
jhodgkin a431b87f1f Add Authentik OIDC login as an additional sign-in option
CI / web (push) Successful in 19s
CI / api (push) Successful in 28s
Local auth stays the primary/always-available login (don't want to
lock out the saved admin password) — OIDC is additive, shown as a
second button when OIDC_ENABLED=true. Uses openid-client v6 with PKCE.

Authentik-side provider was set up via an authentik blueprint (its own
declarative automation, see docs/oidc-setup.md) rather than touching
any existing admin credentials.

Closes #12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:59:25 -06:00
jhodgkin 9891751d37 docs: update CLAUDE.md for device discovery
CI / web (push) Successful in 17s
CI / api (push) Successful in 22s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:50:50 -06:00
jhodgkin 04282232cc Add LAN device discovery (ping sweep + ARP, known/unknown labeling)
CI / web (push) Successful in 17s
CI / api (push) Successful in 23s
Runs as a host-level systemd timer on CT122 (scripts/discover-devices.sh)
rather than inside the api container, since real ARP entries live in the
host's network namespace, not Docker's bridge network. See
docs/device-discovery.md for the full writeup, including why literal
passive-only ARP reading was dropped (near-empty result in practice).

API reads the resulting JSON file each poll cycle, cross-references
config/hosts.yaml's knownDevices list by IP, and serves /api/devices.
Dashboard gets a new "Network Devices" table.

Closes #9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:49:22 -06:00
jhodgkin d75bd58792 docs: update CLAUDE.md for SSH collector + Vaultwarden entry
CI / web (push) Successful in 19s
CI / api (push) Successful in 24s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:42:05 -06:00
jhodgkin 7df08cd16d Add SshHostCollector for .180 (omv) and .171 (ripper)
CI / web (push) Successful in 20s
CI / api (push) Successful in 27s
Extends monitoring to the two bare-metal boxes Proxmox can't see.
Uses a dedicated ed25519 key with a forced authorized_keys command
(see docs/ssh-collector-key-setup.md) so a leaked key can only ever
run the fixed read-only stats script, never arbitrary commands.

CPU is approximated from 1-min load average / core count (a true
utilization % would need two /proc/stat samples).

Closes #8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:40:10 -06:00
jhodgkin ac768ed0db fix: decouple session cookie 'secure' flag from NODE_ENV
CI / web (push) Successful in 16s
CI / api (push) Successful in 17s
Cookie was silently never set because NODE_ENV=production forced
secure=true while the app is served over plain HTTP on the LAN (TLS
terminates at a reverse proxy later, not here). Add explicit
COOKIE_SECURE env var, default false.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:21:52 -06:00
jhodgkin a075488f4b Scaffold homelab-monitor: Fastify API + React dashboard + CI
CI / web (push) Failing after 1m11s
CI / api (push) Successful in 1m19s
Vertical slice for Phase 1 (v1-dashboard milestone): Proxmox collector,
SQLite storage, local auth, and a dashboard UI showing host/container
status cards. Config-driven collector registry so future data sources
(SSH-based hosts, Zabbix, network discovery) plug in without rewiring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:18:44 -06:00