19 Commits

Author SHA1 Message Date
adlee-was-taken
5745388880 fix: address final-review items (status endpoint, docs, tests)
Follow-up to the holistic review of the PIN-unification branch:

- /system/status now reads the real arm state from the arm_state_log
  table via get_current_arm_state, instead of returning a hardcoded
  'DISARMED' stub. Without this, polling after the new async 202
  arm/disarm flow was a UX dead-end — clients never saw the state
  change they just requested. DB read failures degrade gracefully.

- Operator guide: correct the claim that 'vigilar config set-pin'
  populates recovery_passphrase_hash. It doesn't. recovery_passphrase
  _hash has no CLI helper today; it must be set manually.

- Tests: add a fail-closed regression for verify_pin on malformed
  stored hashes, and a companion test confirming the deprecation
  warning stays silent on a fully migrated config.

All address specific review comments on the branch; no scope creep.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:58:09 -04:00
adlee-was-taken
eb281ad058 docs(operator-guide): PIN hashing is unified (issue #2)
Describes the canonical [security] pin_hash key, the PBKDF2 format
emitted by 'vigilar config set-pin', and the deprecation warning for
the legacy [system] arm_pin_hash. Drops the three-way mismatch
known-limitation.
2026-04-05 12:58:09 -04:00
adlee-was-taken
385bafc73f test: end-to-end PIN unification regression guard (issue #2) 2026-04-05 12:58:09 -04:00
adlee-was-taken
12821648ca fix(web): raise on MQTT connect timeout in _publish_arm_request
Code review on 9f203d8 caught a silent-failure mode: MessageBus.connect
logs and returns without raising when the MQTT handshake times out, so
an overloaded broker would let bus.publish() enqueue into paho's outbox
only to be discarded by the immediate disconnect(). The web endpoint
would return 202 even though the FSM never received the request.

Guard with 'if not bus.connected: raise RuntimeError'. The existing
try/except in arm_system/disarm_system catches the exception and turns
it into a 503 with the same log message as other bus failures.
2026-04-05 12:58:09 -04:00
adlee-was-taken
7b33cb7bb4 fix(web): align arm/disarm 202 response shape with {"ok": true} convention
Follow-up to efd5c4a. The plan invented {"accepted": True, ...} for
the new 202 responses, but every other 2xx endpoint in the Flask app
returns {"ok": True, ...} — including cameras.py:108 which is direct
prior art for a 202 with the same convention. The shared JS helper
at static/js/settings.js:54 does 'if (resp.ok && result.ok)' and was
falling into the error branch on our success responses, showing a
bogus "Save failed" toast after every arm/disarm click.

Keep the 202 status. Swap the body key from 'accepted' to 'ok'.
No JS change needed.
2026-04-05 12:58:09 -04:00
adlee-was-taken
4b0d547322 fix(web): arm/disarm actually transition the FSM via MQTT (issue #2)
Was: /system/api/arm verified the PIN against [security] pin_hash and
returned {ok: true} without ever calling the FSM. State never changed.
Now: the endpoint publishes a SYSTEM_ARM_REQUEST message to the local
MQTT broker. The event processor (see previous commit) picks it up,
ArmStateFSM verifies the PIN via alerts.pin.verify_pin and performs
the transition. Response is 202 Accepted; clients poll /system/status
for the new state.

Design: PIN travels over localhost-only MQTT, which matches the
existing trust boundary for the internal bus.
2026-04-05 12:58:09 -04:00
adlee-was-taken
e6069a68fc refactor(events): drop forward-ref quote and test triggered_by default
Code review follow-up on f4d66dd:
- _handle_arm_request signature used "ArmStateFSM" as a string forward
  reference even though the type is imported at module top.
  _handle_event uses the bare form; match it for consistency.
- Add a test asserting that omitting triggered_by in an arm-request
  payload defaults to "unknown". That value feeds the audit log, so
  it deserves explicit regression coverage.

No behavior change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:58:09 -04:00
adlee-was-taken
82ff7fb276 feat(events): processor handles SYSTEM_ARM_REQUEST over MQTT
Adds _handle_arm_request and a dedicated bus.subscribe on
Topics.SYSTEM_ARM_REQUEST. Payload {mode, pin, triggered_by} is
dispatched to ArmStateFSM.transition, which verifies the PIN via
alerts.pin.verify_pin and performs the state change.

This is the missing link for web /system/api/arm to actually move
the system into an armed state. Part of issue #2.
2026-04-05 12:58:09 -04:00
adlee-was-taken
17721eeaa7 style(config): move log handle below import block
Code review follow-up on c9dd348 — the log = logging.getLogger(__name__)
assignment was interleaved between 'import X' and 'from X import Y'
statements. Move it below all imports per standard ordering.

No behavior change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:57:20 -04:00
adlee-was-taken
e568f20871 feat(config): deprecation warning for [system] arm_pin_hash
If a config still has the legacy [system] arm_pin_hash set but no
[security] pin_hash, load_config logs a WARNING telling the operator
to re-run 'vigilar config set-pin'. The legacy field is still parsed
(so old configs don't fail validation) but ignored at runtime.

Part of issue #2 PIN hashing unification.
2026-04-05 12:57:20 -04:00
adlee-was-taken
2032fac227 fix(cli): redact security.recovery_passphrase_hash in show_cmd
Adjacent secret leak in show_cmd noticed during Task 3 code review.
SecurityConfig has two sensitive fields and the redaction block only
covered pin_hash. vigilar config show would print the recovery
passphrase hash verbatim whenever one was configured.

One-line fix; same redaction pattern as the surrounding secrets.
Part of issue #2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:57:20 -04:00
adlee-was-taken
c2976876ed fix(cli): set-pin emits PBKDF2 under [security] pin_hash (issue #2)
Was: HMAC-SHA256(random, pin) written to [system] arm_pin_hash —
no verifier in the codebase accepted this output.
Now: PBKDF2-SHA256 via alerts.pin.hash_pin written to [security]
pin_hash, matching what the web and FSM paths verify against.

Also fixes show_cmd to redact the new location.
2026-04-05 12:57:20 -04:00
adlee-was-taken
54ad58c870 refactor(events): drop verify_pin alias and clarify audit-log comment
Code review feedback on the Task 2 commit (7fda351):
- The 'verify_pin as _verify_pin_hash' alias was unnecessary — the
  method self.verify_pin and the module-level verify_pin do not
  collide (one is accessed via self, the other via the bare name).
  Removing the alias matches how web/blueprints/system.py already
  imports verify_pin and makes the call site read cleanly.
- The comment on the insert_arm_state None argument now explains
  WHY (PBKDF2 salt is fresh per call, so re-hashing is worthless for
  audit correlation) instead of only referencing the issue.

No behavior change. Part of issue #2.
2026-04-05 12:57:20 -04:00
adlee-was-taken
efa3ce4b1b fix(events): ArmStateFSM uses PBKDF2 via alerts.pin (issue #2)
Was: unsalted SHA-256 read from [system] arm_pin_hash.
Now: PBKDF2-SHA256 600k iterations read from [security] pin_hash,
matching the web arm/disarm path and the alerts/pin module.

Also drops the redundant pin re-hash on the arm_state_log audit row
(a fresh PBKDF2 salt made the column valueless for traceability).

Part of issue #2 PIN hashing unification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:57:20 -04:00
adlee-was-taken
c64f863741 feat(constants): add Topics.SYSTEM_ARM_REQUEST
Topic for web-originated arm/disarm requests that the event processor
will subscribe to and dispatch to ArmStateFSM.transition. Part of the
PIN unification work (issue #2).
2026-04-05 12:57:20 -04:00
adlee-was-taken
e048eb955e docs(plan): implementation plan for PIN hashing unification (issue #2)
Plan document for issue #2 — the three-way PIN hash mismatch across
CLI, events FSM, and web arm/disarm. Proposes canonicalizing on
PBKDF2-SHA256 via alerts/pin and [security] pin_hash, deprecating
[system] arm_pin_hash, and wiring web arm/disarm through MQTT to the
FSM so the web buttons actually transition state.

Nine tasks, TDD throughout. No code changes in this commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:57:20 -04:00
adlee-was-taken
09b59e3bb5 feat: wire MQTT → SSE bridge so the event timeline updates live
Closes #1.

The Flask event-timeline was dead: `broadcast_sse_event` existed in
`vigilar/web/blueprints/events.py` but had zero call sites. Clients
subscribed to `/events/stream`, received the initial "connected"
message, and then only keepalives — a page refresh was required to
see new events. (Web Push via VAPID was independent and already worked.)

The root cause was a process-boundary gap: the events subsystem runs
in its own OS process and emits to MQTT, while the Flask app runs in a
separate process with no MQTT client of its own.

This change adds a thin bridge:

- EventProcessor._handle_event now publishes a classified summary
  (id, ts, type, severity, source_id, payload) to a new topic
  `Topics.EVENTS_PUBLISHED = "vigilar/events/published"` right after
  `insert_event()`. Classification logic stays in one place.

- A new module `vigilar/web/sse_bridge.py` provides `forward_event`
  (MQTT handler) and `start_sse_bridge(cfg)` (creates a MessageBus,
  subscribes forward_event to EVENTS_PUBLISHED, connects, returns the
  bus).

- `vigilar/main.py:_run_web` starts the bridge after `create_app(cfg)`
  and disconnects it on shutdown. Bridge failure is logged but does
  not kill the web process — the UI still works without live updates.

- `create_app` is deliberately NOT changed. Keeping the bridge out of
  the app factory means no existing test triggers a real MQTT
  connection, and the bridge stays a production-only concern wired by
  the supervisor.

Tests (all added with TDD, RED verified before GREEN):

- tests/unit/test_events.py::TestEventsPublishedBroadcast — asserts
  `_handle_event` publishes the classified payload for a motion event
  and does NOT publish for unclassified topics (heartbeats).
- tests/unit/test_sse_bridge.py — asserts `forward_event` reaches SSE
  subscribers, and `start_sse_bridge` wires the handler to
  `Topics.EVENTS_PUBLISHED` on a connected bus (fake bus, no real
  MQTT in tests).

Also refreshes the docs that previously flagged the dead SSE as a
known limitation (operator guide, web architecture doc).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:55:27 +00:00
adlee-was-taken
9f959f8c78 test: isolate VIGILAR_CONFIG via autouse session fixture
Some web endpoint handlers call _save_and_reload(), which resolves the
target path via VIGILAR_CONFIG env var with a fallback to the relative
"config/vigilar.toml". Any test exercising such an endpoint without
setting the env var rewrites the repo's committed config file via a
Pydantic model_dump round-trip, stripping comments and non-default
fields. The culprit discovered was test_reset_pin_correct_passphrase
in tests/unit/test_system_pin.py.

Add an autouse session-scoped fixture in tests/conftest.py that points
VIGILAR_CONFIG at a path inside pytest's session tmp dir so no test
can touch the real file. Restore the previous env var value on teardown.

Fixes #3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:54:12 +00:00
adlee-was-taken
17bd403217 fix: correct set-password docstring (scrypt, not bcrypt)
The set_password_cmd docstring and inline comment claimed bcrypt /
SHA-256, but the implementation actually uses scrypt via
cryptography.hazmat.primitives.kdf.scrypt. Correct the docstring,
drop the misleading comment, and remove the now-unused hashlib import.

No behavior change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:41:10 -04:00
9 changed files with 221 additions and 13 deletions

View File

@@ -40,4 +40,4 @@ All access is read-mostly via `vigilar.storage.queries`. Touches essentially eve
## Notes
Templates use Jinja2 with a Bootstrap 5 dark theme; the camera grid uses `hls.js` for multi-camera HLS playback with an MJPEG fallback on the single-camera page. The app is a PWA (service worker + manifest) with VAPID Web Push for mobile notifications. The event-timeline SSE endpoint lives around line 93 of `blueprints/events.py` and holds a per-client `queue.Queue` fed by a module-level `broadcast_sse_event` function — that function is defined but has no call site in the repo at time of writing, so live SSE updates will only flow once the bridge from MQTT into that queue is wired.
Templates use Jinja2 with a Bootstrap 5 dark theme; the camera grid uses `hls.js` for multi-camera HLS playback with an MJPEG fallback on the single-camera page. The app is a PWA (service worker + manifest) with VAPID Web Push for mobile notifications. The event-timeline SSE endpoint lives around line 93 of `blueprints/events.py` and holds a per-client `queue.Queue` fed by a module-level `broadcast_sse_event` function. A dedicated MQTT → SSE bridge (`vigilar/web/sse_bridge.py`) runs inside the web process, subscribes to `Topics.EVENTS_PUBLISHED`, and calls `broadcast_sse_event` for every classified event emitted by the events subsystem — so the in-browser event timeline updates live without a page refresh.

View File

@@ -596,13 +596,6 @@ Do not expose port `49735` directly on the WAN; require the tunnel.
## Known limitations
- **Event timeline is not live.** The web UI event timeline requires
a page refresh to show new events. `broadcast_sse_event` exists in
`vigilar/web/blueprints/events.py` but has zero call sites today;
events are not pushed to browsers via SSE. Web Push notifications
via VAPID are independent of the timeline and do work: you will
get mobile alerts as motion happens, but the in-page timeline lags
until you reload.
- **Recording integrity is not authenticated.** AES-256-CTR gives you
confidentiality, not tamper-evidence. If an attacker reaches the
recordings directory they can modify ciphertext unnoticed. See the

View File

@@ -431,6 +431,88 @@ class TestPetEventClassification:
assert source == "front"
# ---------------------------------------------------------------------------
# Classified-event broadcast (for SSE bridge)
# ---------------------------------------------------------------------------
class _RecordingBus:
"""Minimal bus fake that records publishes instead of sending them."""
def __init__(self):
self.published: list[tuple[str, dict]] = []
def publish(self, topic: str, payload: dict, qos: int = 1) -> None:
self.published.append((topic, payload))
def publish_event(self, topic: str, **kwargs) -> None:
self.publish(topic, kwargs)
class _StubFSM:
state = ArmState.DISARMED
class _StubRuleEngine:
def evaluate(self, topic, payload, state):
return []
class TestEventsPublishedBroadcast:
"""EventProcessor should publish a classified-event summary to
Topics.EVENTS_PUBLISHED after inserting, so the Flask SSE bridge
can forward it to browser clients."""
def test_handle_event_publishes_classified_payload(self, test_db):
from vigilar.events.processor import EventProcessor
from vigilar.constants import Topics
processor = EventProcessor.__new__(EventProcessor)
bus = _RecordingBus()
processor._handle_event(
topic="vigilar/camera/cam1/motion/start",
payload={"ts": 12345, "detail": "x"},
engine=test_db,
fsm=_StubFSM(),
rule_engine=_StubRuleEngine(),
bus=bus,
)
published_on_bridge_topic = [
p for t, p in bus.published if t == Topics.EVENTS_PUBLISHED
]
assert len(published_on_bridge_topic) == 1, (
f"expected exactly one publish to {Topics.EVENTS_PUBLISHED}, "
f"got publishes: {bus.published}"
)
msg = published_on_bridge_topic[0]
assert msg["type"] == EventType.MOTION_START
assert msg["severity"] == Severity.WARNING
assert msg["source_id"] == "cam1"
assert "ts" in msg
assert "id" in msg and isinstance(msg["id"], int)
def test_unclassified_topic_does_not_publish(self, test_db):
"""Heartbeats and other non-event topics must not be forwarded."""
from vigilar.events.processor import EventProcessor
from vigilar.constants import Topics
processor = EventProcessor.__new__(EventProcessor)
bus = _RecordingBus()
processor._handle_event(
topic="vigilar/camera/cam1/heartbeat",
payload={"ts": 12345},
engine=test_db,
fsm=_StubFSM(),
rule_engine=_StubRuleEngine(),
bus=bus,
)
assert not any(t == Topics.EVENTS_PUBLISHED for t, _ in bus.published)
# ---------------------------------------------------------------------------
# Arm Request Dispatch
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,62 @@
"""Tests for the MQTT → Server-Sent Events bridge used by the web process."""
import json
import queue
def test_forward_event_delivers_payload_to_sse_subscribers():
"""forward_event should hand the payload to every connected SSE client."""
from vigilar.web.blueprints import events as events_bp
from vigilar.web.sse_bridge import forward_event
q: queue.Queue = queue.Queue(maxsize=10)
events_bp._sse_subscribers.append(q)
try:
payload = {
"type": "MOTION_START",
"source_id": "cam1",
"severity": "WARNING",
"ts": 1234,
}
forward_event("vigilar/events/published", payload)
raw = q.get_nowait()
assert raw.startswith("data: ")
# Strip "data: " prefix and trailing double newline
data = json.loads(raw[len("data: "):].rstrip())
assert data["type"] == "MOTION_START"
assert data["source_id"] == "cam1"
finally:
if q in events_bp._sse_subscribers:
events_bp._sse_subscribers.remove(q)
def test_start_sse_bridge_subscribes_to_events_published(monkeypatch):
"""start_sse_bridge must connect a bus and subscribe forward_event
to Topics.EVENTS_PUBLISHED — that is the single entry point for the
web process to observe classified events from the events subsystem."""
from vigilar.config import VigilarConfig
from vigilar.constants import Topics
from vigilar.web import sse_bridge
class FakeBus:
def __init__(self, config, client_id):
self.client_id = client_id
self.subscriptions: list[tuple[str, object]] = []
self.connected = False
def subscribe(self, topic, handler):
self.subscriptions.append((topic, handler))
def connect(self):
self.connected = True
monkeypatch.setattr(sse_bridge, "MessageBus", FakeBus)
bus = sse_bridge.start_sse_bridge(VigilarConfig())
assert bus.connected is True
assert len(bus.subscriptions) == 1
topic, handler = bus.subscriptions[0]
assert topic == Topics.EVENTS_PUBLISHED
assert handler is sse_bridge.forward_event

View File

@@ -62,12 +62,9 @@ def show_cmd(config_path: str | None) -> None:
@config_cmd.command("set-password")
@click.option("--config", "-c", "config_path", default=None, help="Path to vigilar.toml.")
def set_password_cmd(config_path: str | None) -> None:
"""Generate a bcrypt hash for the web UI password."""
"""Generate a scrypt hash for the web UI password."""
try:
import hashlib
password = click.prompt("Enter web UI password", hide_input=True, confirmation_prompt=True)
# Use SHA-256 hash (bcrypt requires external dep, but cryptography is available)
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
import os

View File

@@ -213,6 +213,10 @@ class Topics:
# Web-to-FSM arm/disarm request (FSM verifies the PIN and transitions)
SYSTEM_ARM_REQUEST = "vigilar/system/arm_request"
# Classified events forwarded to the web SSE bridge (see events.processor
# and web.sse_bridge).
EVENTS_PUBLISHED = "vigilar/events/published"
# Wildcard subscriptions
ALL = "vigilar/#"
ALL_CAMERAS = "vigilar/camera/#"

View File

@@ -111,6 +111,22 @@ class EventProcessor:
payload=payload,
)
# Broadcast a classified summary for the web SSE bridge. This is
# independent of rule actions and DB storage — the web process
# subscribes to Topics.EVENTS_PUBLISHED and forwards each message
# to connected browser clients.
bus.publish(
Topics.EVENTS_PUBLISHED,
{
"id": event_id,
"ts": int(time.time() * 1000),
"type": event_type,
"severity": severity,
"source_id": source_id,
"payload": payload,
},
)
# Insert pet/wildlife sightings
if event_type in (
EventType.PET_DETECTED, EventType.PET_ESCAPE, EventType.UNKNOWN_ANIMAL

View File

@@ -67,9 +67,27 @@ class SubsystemProcess:
def _run_web(cfg: VigilarConfig) -> None:
"""Run the Flask web server in a subprocess."""
from vigilar.web.app import create_app
from vigilar.web.sse_bridge import start_sse_bridge
app = create_app(cfg)
# Forward classified events from MQTT to browser SSE clients. Failure
# here must not kill the web process — the UI still works without
# live updates, it just requires a page refresh.
sse_bus = None
try:
sse_bus = start_sse_bridge(cfg)
except Exception:
log.exception("Failed to start SSE bridge; live event timeline will not update")
try:
app.run(host=cfg.web.host, port=cfg.web.port, debug=False, use_reloader=False)
finally:
if sse_bus is not None:
try:
sse_bus.disconnect()
except Exception:
log.exception("Error disconnecting SSE bridge bus")
def _run_event_processor(cfg: VigilarConfig) -> None:

36
vigilar/web/sse_bridge.py Normal file
View File

@@ -0,0 +1,36 @@
"""Bridge classified events from MQTT to Server-Sent Events subscribers.
The events subsystem (`vigilar.events.processor.EventProcessor`) publishes
classified events to `Topics.EVENTS_PUBLISHED`. The Flask web process runs
in its own OS process, so to make the in-browser event timeline update
live it must subscribe to that topic via its own `MessageBus` client and
forward every message to `broadcast_sse_event`.
"""
import logging
from typing import Any
from vigilar.bus import MessageBus
from vigilar.config import VigilarConfig
from vigilar.constants import Topics
from vigilar.web.blueprints.events import broadcast_sse_event
log = logging.getLogger(__name__)
def forward_event(topic: str, payload: dict[str, Any]) -> None:
"""MQTT handler: forward a classified event to SSE subscribers."""
broadcast_sse_event(payload)
def start_sse_bridge(cfg: VigilarConfig) -> MessageBus:
"""Create an MQTT client that forwards classified events to SSE clients.
Returns the connected `MessageBus` so the caller can disconnect it on
shutdown.
"""
bus = MessageBus(cfg.mqtt, client_id="vigilar-web-sse-bridge")
bus.subscribe(Topics.EVENTS_PUBLISHED, forward_event)
bus.connect()
log.info("SSE bridge started: subscribed to %s", Topics.EVENTS_PUBLISHED)
return bus