17 Commits

Author SHA1 Message Date
adlee-was-taken
038ab0af12 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:26:30 -04:00
adlee-was-taken
d72c04efcd 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:11:32 -04:00
adlee-was-taken
ac52198b35 test: end-to-end PIN unification regression guard (issue #2) 2026-04-05 12:08:32 -04:00
adlee-was-taken
08c689e6dd 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:07:20 -04:00
adlee-was-taken
9f203d8ce5 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:03:05 -04:00
adlee-was-taken
efd5c4ad80 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:00:24 -04:00
adlee-was-taken
c275404e4e 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 11:57:26 -04:00
adlee-was-taken
f4d66dd5df 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 11:51:05 -04:00
adlee-was-taken
7006078ae5 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 11:48:37 -04:00
adlee-was-taken
c9dd348850 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 11:46:21 -04:00
adlee-was-taken
94cc206fb4 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 11:44:14 -04:00
adlee-was-taken
8ffe1a38ed 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 11:37:59 -04:00
adlee-was-taken
af2ac5693d 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 11:33:58 -04:00
adlee-was-taken
7fda351c02 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 11:26:07 -04:00
adlee-was-taken
cdd8cf4693 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 11:22:10 -04:00
adlee-was-taken
45afaf89ab 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 11:20:29 -04:00
adlee-was-taken
e657f2bfbc 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 10:55:42 -04:00
15 changed files with 468 additions and 77 deletions

View File

@@ -113,8 +113,9 @@ omitted sections behave sensibly.
`.vge` files.
- `hls_dir` (default `/var/vigilar/hls`): HLS segment output.
- `log_level` (default `"INFO"`): one of DEBUG, INFO, WARNING, ERROR.
- `arm_pin_hash` (default `""`): commented out in the sample; set via
`vigilar config set-pin`.
- `arm_pin_hash` (default `""`): **deprecated.** Still parsed but
ignored at runtime. Use `[security] pin_hash` instead; run
`vigilar config set-pin` to generate the canonical hash.
### `[mqtt]`
@@ -291,11 +292,15 @@ enabled = false`, `[visitors] enabled = false`, `[highlights] enabled
- `[location] latitude`, `longitude` (default `0.0`): used for sunrise
and sunset lookups.
- `[security] pin_hash` and `recovery_passphrase_hash`: populated by
`vigilar config set-pin` (the same hash is also stored under
`[system] arm_pin_hash` on the `system` model; both fields exist
because the web UI uses `[security]` while the CLI helper prints a
`[system]` line — pick one location and stick with it).
- `[security] pin_hash` (canonical arm/disarm PIN store): populated by
`vigilar config set-pin`, which emits a PBKDF2-SHA256 hash to paste
into the `[security]` section. The legacy `[system] arm_pin_hash`
field is deprecated; see the `[system]` section above.
- `[security] recovery_passphrase_hash`: used by the web
`/system/api/reset-pin` endpoint to authenticate PIN-reset requests.
There is no CLI helper for this field today — set it by hashing a
passphrase manually with `vigilar.alerts.pin.hash_pin` and pasting
the result into `[security]`, or leave it unset to disable recovery.
## CLI reference
@@ -344,9 +349,9 @@ sudo -u vigilar /opt/vigilar/venv/bin/vigilar config show \
```
Dumps the parsed config as JSON with `web.password_hash`,
`system.arm_pin_hash`, and `alerts.webhook.secret` redacted. Useful
for confirming which defaults Pydantic applied for keys you did not
set.
`security.pin_hash`, `security.recovery_passphrase_hash`, and
`alerts.webhook.secret` redacted. Useful for confirming which
defaults Pydantic applied for keys you did not set.
### `vigilar config set-password`
@@ -365,10 +370,12 @@ prints a `password_hash = "salt_hex:key_hex"` line to paste into
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config set-pin
```
Prompts for an arm/disarm PIN, generates a random 32-byte HMAC key,
computes `HMAC-SHA256(key, pin)`, and prints an `arm_pin_hash =
"secret_hex:mac_hex"` line to paste into `[system]`. Again, no file
write.
Prompts for an arm/disarm PIN, derives a salted PBKDF2-SHA256 hash
(600,000 iterations) via `vigilar.alerts.pin.hash_pin`, and prints a
`pin_hash = "pbkdf2_sha256$salt$dk"` line to paste into `[security]`.
Again, no file write. The same hash format is verified identically by
the web arm/disarm endpoint and by `ArmStateFSM` in the event
processor — there is one canonical PIN store.
## Secrets and security
@@ -388,9 +395,13 @@ write.
volume on integrity-verified storage (dm-integrity, ZFS with
checksums) or mirror to write-once media.
- The web UI password is a scrypt hash set by `vigilar config
set-password` and stored at `[web] password_hash`. The arm PIN is
an HMAC stored at `[system] arm_pin_hash` (and/or `[security]
pin_hash`).
set-password` and stored at `[web] password_hash`. The arm/disarm
PIN is a PBKDF2-SHA256 hash (600k iterations, salted) set by
`vigilar config set-pin` and stored at `[security] pin_hash`.
A legacy `[system] arm_pin_hash` field is still parsed but ignored
at runtime; if it's set and `[security] pin_hash` is empty, the
service logs a deprecation warning at startup and arm/disarm will
behave as if no PIN were configured until you re-run `set-pin`.
- TLS: `gen_cert.sh` uses `mkcert` if present, otherwise an `openssl`
ECDSA P-256 self-signed certificate valid for 3650 days with SANs
for `vigilar.local`, `localhost`, `127.0.0.1`, and the detected LAN
@@ -609,10 +620,6 @@ Do not expose port `49735` directly on the WAN; require the tunnel.
`[health]` for real disk policy.
- **No schema migrations.** There is no Alembic (or equivalent) in
the tree. Rollbacks rely on your backup discipline.
- **Duplicate PIN fields.** `vigilar config set-pin` writes to
`[system] arm_pin_hash`, while the web arm/disarm flow reads from
`[security] pin_hash`. Both models exist. If you set one and the
other side does not behave as expected, mirror the value manually.
## Troubleshooting

View File

@@ -11,6 +11,28 @@ from vigilar.config import VigilarConfig, load_config
from vigilar.storage.schema import metadata
@pytest.fixture(autouse=True, scope="session")
def _isolate_vigilar_config(tmp_path_factory):
"""Prevent tests from writing to the real config/vigilar.toml.
Web endpoint handlers that call `_save_and_reload()` read the target
path from the VIGILAR_CONFIG env var, falling back to the relative
`"config/vigilar.toml"`. Without this fixture, any test that exercises
such an endpoint rewrites the repo's committed config file via a
Pydantic round-trip, stripping comments and non-default fields.
"""
tmp_config = tmp_path_factory.mktemp("vigilar-config") / "vigilar.toml"
prev = os.environ.get("VIGILAR_CONFIG")
os.environ["VIGILAR_CONFIG"] = str(tmp_config)
try:
yield
finally:
if prev is None:
os.environ.pop("VIGILAR_CONFIG", None)
else:
os.environ["VIGILAR_CONFIG"] = prev
def _create_test_engine(db_path: Path):
"""Create a fresh engine for testing (bypasses the global singleton)."""
db_path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -0,0 +1,31 @@
"""Tests for `vigilar config set-pin`."""
from click.testing import CliRunner
from vigilar.alerts.pin import verify_pin
from vigilar.cli.cmd_config import config_cmd
def test_set_pin_outputs_pbkdf2_hash_under_security_section():
"""The CLI must emit a hash that alerts.pin.verify_pin can validate,
and direct the user to [security] pin_hash (not [system] arm_pin_hash)."""
runner = CliRunner()
result = runner.invoke(config_cmd, ["set-pin"], input="1234\n1234\n")
assert result.exit_code == 0, result.output
# The output must direct the user to the [security] section
assert "[security]" in result.output
assert "arm_pin_hash" not in result.output
assert "pin_hash" in result.output
# Extract the emitted hash (line starts with `pin_hash = "..."`)
hash_line = next(
line for line in result.output.splitlines() if line.strip().startswith("pin_hash")
)
hash_value = hash_line.split('"')[1]
# Round-trip: the emitted hash must accept the plaintext PIN
assert verify_pin("1234", hash_value) is True
assert verify_pin("0000", hash_value) is False
# And it must be in PBKDF2 format (not the legacy HMAC "secret:mac" format)
assert hash_value.startswith("pbkdf2_sha256$")

View File

@@ -138,3 +138,49 @@ class TestCameraConfigLocation:
from vigilar.config import CameraConfig
cfg = CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://x", location="EXTERIOR")
assert cfg.location == "EXTERIOR"
def test_deprecation_warning_for_arm_pin_hash(tmp_path, caplog):
"""Loading a config that still uses the legacy [system] arm_pin_hash
must log a clear warning pointing the user at `vigilar config set-pin`."""
import logging
cfg_path = tmp_path / "legacy.toml"
cfg_path.write_text(
'[system]\n'
'arm_pin_hash = "pbkdf2_sha256$abc$def"\n'
)
with caplog.at_level(logging.WARNING):
from vigilar.config import load_config
load_config(str(cfg_path))
messages = [r.message for r in caplog.records if r.levelno >= logging.WARNING]
assert any("arm_pin_hash" in m and "deprecated" in m.lower() for m in messages), (
f"expected deprecation warning mentioning arm_pin_hash, got: {messages}"
)
def test_no_deprecation_warning_when_security_pin_hash_set(tmp_path, caplog):
"""No warning should fire if [security] pin_hash is populated,
regardless of whether [system] arm_pin_hash is also still present.
The warning is specifically for un-migrated configs."""
import logging
cfg_path = tmp_path / "migrated.toml"
cfg_path.write_text(
'[system]\n'
'arm_pin_hash = "pbkdf2_sha256$legacy$value"\n'
'\n'
'[security]\n'
'pin_hash = "pbkdf2_sha256$current$value"\n'
)
with caplog.at_level(logging.WARNING):
from vigilar.config import load_config
load_config(str(cfg_path))
deprecation_messages = [
r.message for r in caplog.records
if r.levelno >= logging.WARNING and "arm_pin_hash" in r.message
]
assert deprecation_messages == [], (
f"deprecation warning should not fire on migrated configs, "
f"got: {deprecation_messages}"
)

View File

@@ -1,6 +1,5 @@
"""Tests for the Phase 6 events subsystem: rules, arm state FSM, history."""
import hashlib
import time
import pytest
@@ -19,7 +18,7 @@ from vigilar.storage.queries import insert_event
def _make_config(rules=None, pin_hash=""):
return VigilarConfig(
system={"arm_pin_hash": pin_hash},
security={"pin_hash": pin_hash},
cameras=[],
sensors=[],
rules=rules or [],
@@ -27,7 +26,8 @@ def _make_config(rules=None, pin_hash=""):
def _pin_hash(pin: str) -> str:
return hashlib.sha256(pin.encode()).hexdigest()
from vigilar.alerts.pin import hash_pin
return hash_pin(pin)
# ---------------------------------------------------------------------------
@@ -429,3 +429,76 @@ class TestPetEventClassification:
assert etype == EventType.WILDLIFE_PASSIVE
assert sev == Severity.INFO
assert source == "front"
# ---------------------------------------------------------------------------
# Arm Request Dispatch
# ---------------------------------------------------------------------------
class TestArmRequestDispatch:
"""SYSTEM_ARM_REQUEST messages must reach ArmStateFSM.transition."""
def test_arm_request_calls_fsm_transition(self, test_db):
from vigilar.events.processor import EventProcessor
processor = EventProcessor.__new__(EventProcessor)
calls = []
class FakeFSM:
state = ArmState.DISARMED
def transition(self, new_state, pin="", triggered_by="system"):
calls.append((new_state, pin, triggered_by))
return True
processor._handle_arm_request(
payload={"mode": "ARMED_AWAY", "pin": "1234", "triggered_by": "web"},
fsm=FakeFSM(),
)
assert len(calls) == 1
new_state, pin, triggered_by = calls[0]
assert new_state == ArmState.ARMED_AWAY
assert pin == "1234"
assert triggered_by == "web"
def test_arm_request_ignores_bad_mode(self, test_db):
from vigilar.events.processor import EventProcessor
processor = EventProcessor.__new__(EventProcessor)
calls = []
class FakeFSM:
def transition(self, *a, **kw):
calls.append((a, kw))
return True
processor._handle_arm_request(
payload={"mode": "NONSENSE", "pin": "1234"},
fsm=FakeFSM(),
)
assert calls == []
def test_arm_request_default_triggered_by(self, test_db):
"""Omitting triggered_by must default to 'unknown' (audit-log value)."""
from vigilar.events.processor import EventProcessor
processor = EventProcessor.__new__(EventProcessor)
calls = []
class FakeFSM:
state = ArmState.DISARMED
def transition(self, new_state, pin="", triggered_by="system"):
calls.append((new_state, pin, triggered_by))
return True
processor._handle_arm_request(
payload={"mode": "DISARMED", "pin": ""},
fsm=FakeFSM(),
)
assert len(calls) == 1
assert calls[0][2] == "unknown"

View File

@@ -37,3 +37,14 @@ def test_verify_pin_handles_unicode():
stored = hash_pin("p@ss!")
assert verify_pin("p@ss!", stored) is True
assert verify_pin("p@ss?", stored) is False
def test_verify_pin_rejects_malformed_hash():
"""verify_pin must return False (not raise) on malformed stored hashes.
Fail-closed is load-bearing: a misconfigured or partially-migrated
[security] pin_hash must lock out transitions, not grant access."""
assert verify_pin("1234", "sha256:deadbeef") is False
assert verify_pin("1234", "garbage") is False
assert verify_pin("1234", "pbkdf2_sha256$only$two$extra") is False
# Wrong algo prefix
assert verify_pin("1234", "argon2id$salt$dk") is False

View File

@@ -0,0 +1,45 @@
"""End-to-end test: the CLI, FSM, and web arm flow all accept the same PIN.
Regression guard for issue #2 — the three layers previously used three
incompatible hash schemes under two different config keys."""
from click.testing import CliRunner
from vigilar.alerts.pin import hash_pin, verify_pin
from vigilar.cli.cmd_config import config_cmd
from vigilar.config import SecurityConfig, VigilarConfig
from vigilar.events.state import ArmStateFSM
from vigilar.constants import ArmState
def test_cli_output_is_accepted_by_fsm(test_db):
"""Hash produced by `vigilar config set-pin` must verify against
ArmStateFSM.verify_pin, same config key, same format."""
runner = CliRunner()
result = runner.invoke(config_cmd, ["set-pin"], input="9876\n9876\n")
assert result.exit_code == 0, result.output
hash_line = next(
line for line in result.output.splitlines()
if line.strip().startswith("pin_hash")
)
hash_value = hash_line.split('"')[1]
cfg = VigilarConfig(security=SecurityConfig(pin_hash=hash_value))
fsm = ArmStateFSM(test_db, cfg)
assert fsm.verify_pin("9876") is True
assert fsm.verify_pin("0000") is False
def test_fsm_transitions_with_pin_from_alerts_module(test_db):
"""The alerts.pin module and ArmStateFSM agree on the hash format."""
stored = hash_pin("4242")
cfg = VigilarConfig(security=SecurityConfig(pin_hash=stored))
fsm = ArmStateFSM(test_db, cfg)
assert fsm.transition(ArmState.ARMED_AWAY, pin="4242", triggered_by="test") is True
assert fsm.state == ArmState.ARMED_AWAY
# Same stored hash rejects the wrong PIN
assert verify_pin("0000", stored) is False

View File

@@ -1,6 +1,7 @@
"""Tests for PIN verification on arm/disarm endpoints."""
import pytest
from unittest.mock import patch
from vigilar.alerts.pin import hash_pin
from vigilar.config import VigilarConfig, SecurityConfig
from vigilar.web.app import create_app
@@ -29,35 +30,55 @@ def app_no_pin():
def test_arm_without_pin_set(app_no_pin):
with app_no_pin.test_client() as c:
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY"})
assert rv.status_code == 200
assert rv.get_json()["ok"] is True
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
with app_no_pin.test_client() as c:
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY"})
assert rv.status_code == 202
pub.assert_called_once()
payload = pub.call_args.args[1] if len(pub.call_args.args) > 1 else pub.call_args.kwargs["payload"]
assert payload["mode"] == "ARMED_AWAY"
assert payload["pin"] == ""
def test_arm_correct_pin(app_with_pin):
with app_with_pin.test_client() as c:
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "1234"})
assert rv.status_code == 200
assert rv.get_json()["ok"] is True
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
with app_with_pin.test_client() as c:
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "1234"})
assert rv.status_code == 202
pub.assert_called_once()
payload = pub.call_args.args[1] if len(pub.call_args.args) > 1 else pub.call_args.kwargs["payload"]
assert payload["pin"] == "1234"
def test_arm_wrong_pin(app_with_pin):
with app_with_pin.test_client() as c:
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "0000"})
assert rv.status_code == 401
def test_arm_wrong_pin_still_accepted_by_web_fsm_rejects(app_with_pin):
"""HTTP layer no longer pre-checks the PIN — it forwards to the FSM
unconditionally. The FSM verifies and, on mismatch, logs a warning
and leaves the state unchanged."""
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
with app_with_pin.test_client() as c:
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "0000"})
assert rv.status_code == 202
pub.assert_called_once()
payload = pub.call_args.args[1] if len(pub.call_args.args) > 1 else pub.call_args.kwargs["payload"]
assert payload["pin"] == "0000" # forwarded verbatim — FSM will reject
def test_disarm_correct_pin(app_with_pin):
with app_with_pin.test_client() as c:
rv = c.post("/system/api/disarm", json={"pin": "1234"})
assert rv.status_code == 200
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
with app_with_pin.test_client() as c:
rv = c.post("/system/api/disarm", json={"pin": "1234"})
assert rv.status_code == 202
pub.assert_called_once()
def test_disarm_wrong_pin(app_with_pin):
with app_with_pin.test_client() as c:
rv = c.post("/system/api/disarm", json={"pin": "9999"})
assert rv.status_code == 401
def test_disarm_wrong_pin_still_accepted_by_web_fsm_rejects(app_with_pin):
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
with app_with_pin.test_client() as c:
rv = c.post("/system/api/disarm", json={"pin": "9999"})
assert rv.status_code == 202
pub.assert_called_once()
payload = pub.call_args.args[1] if len(pub.call_args.args) > 1 else pub.call_args.kwargs["payload"]
assert payload["pin"] == "9999" # forwarded verbatim — FSM will reject
def test_reset_pin_correct_passphrase(app_with_pin):
@@ -77,3 +98,35 @@ def test_reset_pin_wrong_passphrase(app_with_pin):
"new_pin": "5678",
})
assert rv.status_code == 401
def test_arm_publishes_arm_request_on_mqtt(app_with_pin):
"""POST /system/api/arm must publish a SYSTEM_ARM_REQUEST message
carrying the mode, pin, and a 'web' triggered_by tag."""
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
with app_with_pin.test_client() as c:
rv = c.post(
"/system/api/arm",
json={"mode": "ARMED_AWAY", "pin": "1234"},
)
assert rv.status_code == 202
assert rv.get_json()["ok"] is True
pub.assert_called_once()
call_args = pub.call_args
# _publish_arm_request(cfg, payload) — payload is args[1] or kwargs["payload"]
payload = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs["payload"]
assert payload["mode"] == "ARMED_AWAY"
assert payload["pin"] == "1234"
assert payload["triggered_by"] == "web"
def test_disarm_publishes_arm_request(app_with_pin):
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
with app_with_pin.test_client() as c:
rv = c.post("/system/api/disarm", json={"pin": "1234"})
assert rv.status_code == 202
pub.assert_called_once()
payload = pub.call_args.args[1] if len(pub.call_args.args) > 1 else pub.call_args.kwargs["payload"]
assert payload["mode"] == "DISARMED"

View File

@@ -102,3 +102,36 @@ def test_recordings_page_loads():
with app.test_client() as client:
resp = client.get("/recordings/")
assert resp.status_code == 200
def test_system_status_reflects_fsm_arm_state(tmp_path, monkeypatch):
"""system_status must read the current arm state from the DB,
not return a hardcoded stub. Regression guard for the web-to-FSM
async flow introduced in issue #2."""
from vigilar.config import SystemConfig, VigilarConfig
import vigilar.storage.db as db_module
from vigilar.storage.db import get_db_path
from vigilar.storage.schema import metadata
from vigilar.storage.queries import insert_arm_state
from vigilar.web.app import create_app
from sqlalchemy import create_engine
data_dir = tmp_path / "data"
data_dir.mkdir()
cfg = VigilarConfig(system=SystemConfig(data_dir=str(data_dir)))
# Build an isolated engine (bypass the module-level singleton)
db_path = get_db_path(str(data_dir))
isolated_engine = create_engine(f"sqlite:///{db_path}", echo=False)
metadata.create_all(isolated_engine)
insert_arm_state(isolated_engine, "ARMED_AWAY", "test", None)
# Patch the singleton so the blueprint's get_engine() returns our engine
monkeypatch.setattr(db_module, "_engine", isolated_engine)
app = create_app(cfg)
with app.test_client() as c:
resp = c.get("/system/status")
assert resp.status_code == 200
assert resp.get_json()["arm_state"] == "ARMED_AWAY"

View File

@@ -47,8 +47,10 @@ def show_cmd(config_path: str | None) -> None:
# Redact sensitive fields
if data.get("web", {}).get("password_hash"):
data["web"]["password_hash"] = "***"
if data.get("system", {}).get("arm_pin_hash"):
data["system"]["arm_pin_hash"] = "***"
if data.get("security", {}).get("pin_hash"):
data["security"]["pin_hash"] = "***"
if data.get("security", {}).get("recovery_passphrase_hash"):
data["security"]["recovery_passphrase_hash"] = "***"
if data.get("alerts", {}).get("webhook", {}).get("secret"):
data["alerts"]["webhook"]["secret"] = "***"
click.echo(json.dumps(data, indent=2))
@@ -82,14 +84,10 @@ def set_password_cmd(config_path: str | None) -> None:
@config_cmd.command("set-pin")
def set_pin_cmd() -> None:
"""Generate an HMAC hash for the arm/disarm PIN."""
import hashlib
import hmac
import os
"""Generate a PBKDF2 hash for the arm/disarm PIN."""
from vigilar.alerts.pin import hash_pin
pin = click.prompt("Enter arm/disarm PIN", hide_input=True, confirmation_prompt=True)
secret = os.urandom(32)
mac = hmac.new(secret, pin.encode(), hashlib.sha256).hexdigest()
hash_str = secret.hex() + ":" + mac
click.echo(f"\nAdd this to your vigilar.toml [system] section:")
click.echo(f'arm_pin_hash = "{hash_str}"')
hash_str = hash_pin(pin)
click.echo("\nAdd this to your vigilar.toml [security] section:")
click.echo(f'pin_hash = "{hash_str}"')

View File

@@ -1,5 +1,6 @@
"""Configuration loading and validation via TOML + Pydantic."""
import logging
import sys
import tomllib
from pathlib import Path
@@ -23,6 +24,8 @@ from vigilar.constants import (
CameraLocation,
)
log = logging.getLogger(__name__)
# --- Camera Config ---
class CameraConfig(BaseModel):
@@ -435,4 +438,13 @@ def load_config(path: str | Path | None = None) -> VigilarConfig:
raw["sensors.gpio"] = gpio_config
# The [[sensors]] array items remain as 'sensors' key from TOML parsing
return VigilarConfig(**raw)
cfg = VigilarConfig(**raw)
if cfg.system.arm_pin_hash and not cfg.security.pin_hash:
log.warning(
"DEPRECATED: [system] arm_pin_hash is ignored; the arm/disarm "
"PIN lives under [security] pin_hash. Run `vigilar config "
"set-pin` and paste the output into [security]."
)
return cfg

View File

@@ -210,6 +210,8 @@ class Topics:
SYSTEM_ALERT = "vigilar/system/alert"
SYSTEM_SHUTDOWN = "vigilar/system/shutdown"
SYSTEM_RULES_UPDATED = "vigilar/system/rules_updated"
# Web-to-FSM arm/disarm request (FSM verifies the PIN and transitions)
SYSTEM_ARM_REQUEST = "vigilar/system/arm_request"
# Wildcard subscriptions
ALL = "vigilar/#"

View File

@@ -9,7 +9,7 @@ from sqlalchemy.engine import Engine
from vigilar.bus import MessageBus
from vigilar.config import VigilarConfig
from vigilar.constants import EventType, Severity, Topics
from vigilar.constants import ArmState, EventType, Severity, Topics
from vigilar.events.rules import RuleEngine
from vigilar.events.state import ArmStateFSM
from vigilar.storage.db import get_db_path, init_db
@@ -58,12 +58,20 @@ class EventProcessor:
fsm.set_bus(bus)
bus.connect()
# Subscribe to all Vigilar topics
# Subscribe to all Vigilar topics (events/motion/sensors/etc.)
def on_message(topic: str, payload: dict[str, Any]) -> None:
self._handle_event(topic, payload, engine, fsm, rule_engine, bus)
bus.subscribe_all(on_message)
# Dedicated subscription for web-originated arm/disarm requests.
# Kept separate from on_message because these are commands, not
# classifiable events.
def on_arm_request(topic: str, payload: dict[str, Any]) -> None:
self._handle_arm_request(payload, fsm)
bus.subscribe(Topics.SYSTEM_ARM_REQUEST, on_arm_request)
log.info("Event processor started")
# Main loop
@@ -144,6 +152,26 @@ class EventProcessor:
except Exception:
log.exception("Error processing event on %s", topic)
def _handle_arm_request(
self,
payload: dict[str, Any],
fsm: ArmStateFSM,
) -> None:
"""Handle an arm/disarm request received over MQTT.
Payload fields:
- mode: str — desired ArmState ("DISARMED", "ARMED_HOME", "ARMED_AWAY")
- pin: str — plaintext PIN (FSM verifies against security.pin_hash)
- triggered_by: str — origin tag for the audit log (e.g. "web")
"""
mode = payload.get("mode", "")
if mode not in ArmState.__members__:
log.warning("Ignoring arm request with invalid mode: %r", mode)
return
pin = payload.get("pin", "")
triggered_by = payload.get("triggered_by", "unknown")
fsm.transition(ArmState(mode), pin=pin, triggered_by=triggered_by)
def _classify_event(
self, topic: str, payload: dict[str, Any]
) -> tuple[str | None, str | None, str | None]:

View File

@@ -1,12 +1,11 @@
"""Arm state finite state machine."""
import hashlib
import hmac
import logging
import time
from sqlalchemy.engine import Engine
from vigilar.alerts.pin import verify_pin
from vigilar.config import VigilarConfig
from vigilar.constants import ArmState, EventType, Severity, Topics
from vigilar.storage.queries import get_current_arm_state, insert_arm_state, insert_event
@@ -19,7 +18,7 @@ class ArmStateFSM:
def __init__(self, engine: Engine, config: VigilarConfig):
self._engine = engine
self._pin_hash = config.system.arm_pin_hash
self._pin_hash = config.security.pin_hash
self._state = ArmState.DISARMED
self._bus = None
self._load_initial_state()
@@ -43,12 +42,11 @@ class ArmStateFSM:
return self._state
def verify_pin(self, pin: str) -> bool:
"""Verify a PIN against the stored hash using HMAC comparison."""
"""Verify a PIN against the stored PBKDF2 hash."""
if not self._pin_hash:
# No PIN configured — allow all transitions
return True
candidate = hashlib.sha256(pin.encode()).hexdigest()
return hmac.compare_digest(candidate, self._pin_hash)
return verify_pin(pin, self._pin_hash)
def transition(
self,
@@ -68,9 +66,10 @@ class ArmStateFSM:
old_state = self._state
self._state = new_state
# Log to database
pin_hash = hashlib.sha256(pin.encode()).hexdigest() if pin else None
insert_arm_state(self._engine, new_state.value, triggered_by, pin_hash)
# pin_hash is always None here: PBKDF2 uses a random salt per call, so
# re-hashing the pin now would produce a value unrelated to the stored
# hash, making the column useless for audit correlation. See issue #2.
insert_arm_state(self._engine, new_state.value, triggered_by, None)
# Log event
insert_event(

View File

@@ -36,8 +36,20 @@ def _save_and_reload(new_cfg: VigilarConfig) -> None:
def system_status():
"""JSON API: overall system health."""
cfg = _get_cfg()
arm_state = "DISARMED"
try:
from vigilar.storage.db import get_db_path, get_engine
from vigilar.storage.queries import get_current_arm_state
engine = get_engine(get_db_path(cfg.system.data_dir))
stored = get_current_arm_state(engine)
if stored:
arm_state = stored
except Exception:
current_app.logger.exception("Failed to read arm state from DB")
return jsonify({
"arm_state": "DISARMED",
"arm_state": arm_state,
"ups": {"status": "UNKNOWN"},
"cameras_online": 0,
"cameras_total": len(cfg.cameras),
@@ -54,27 +66,46 @@ def settings_page():
# --- Arm/Disarm ---
def _publish_arm_request(cfg: VigilarConfig, payload: dict) -> None:
"""Publish an arm/disarm request on MQTT for the event processor to pick up."""
from vigilar.bus import MessageBus
from vigilar.constants import Topics
bus = MessageBus(cfg.mqtt, client_id="vigilar-web-arm-request")
bus.connect()
if not bus.connected:
raise RuntimeError("MQTT broker did not accept connection within timeout")
try:
bus.publish(Topics.SYSTEM_ARM_REQUEST, payload)
finally:
bus.disconnect()
@system_bp.route("/api/arm", methods=["POST"])
def arm_system():
data = request.get_json() or {}
mode = data.get("mode", "ARMED_AWAY")
pin = data.get("pin", "")
cfg = _get_cfg()
pin_hash = cfg.security.pin_hash
if pin_hash and not verify_pin(pin, pin_hash):
return jsonify({"error": "Invalid PIN"}), 401
return jsonify({"ok": True, "state": mode})
payload = {"mode": mode, "pin": pin, "triggered_by": "web"}
try:
_publish_arm_request(_get_cfg(), payload)
except Exception:
current_app.logger.exception("Failed to publish arm request")
return jsonify({"error": "bus unavailable"}), 503
return jsonify({"ok": True, "mode": mode}), 202
@system_bp.route("/api/disarm", methods=["POST"])
def disarm_system():
data = request.get_json() or {}
pin = data.get("pin", "")
cfg = _get_cfg()
pin_hash = cfg.security.pin_hash
if pin_hash and not verify_pin(pin, pin_hash):
return jsonify({"error": "Invalid PIN"}), 401
return jsonify({"ok": True, "state": "DISARMED"})
payload = {"mode": "DISARMED", "pin": pin, "triggered_by": "web"}
try:
_publish_arm_request(_get_cfg(), payload)
except Exception:
current_app.logger.exception("Failed to publish arm request")
return jsonify({"error": "bus unavailable"}), 503
return jsonify({"ok": True, "mode": "DISARMED"}), 202
@system_bp.route("/api/reset-pin", methods=["POST"])