Compare commits
17 Commits
plan/issue
...
038ab0af12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
038ab0af12 | ||
|
|
d72c04efcd | ||
|
|
ac52198b35 | ||
|
|
08c689e6dd | ||
|
|
9f203d8ce5 | ||
|
|
efd5c4ad80 | ||
|
|
c275404e4e | ||
|
|
f4d66dd5df | ||
|
|
7006078ae5 | ||
|
|
c9dd348850 | ||
|
|
94cc206fb4 | ||
|
|
8ffe1a38ed | ||
|
|
af2ac5693d | ||
|
|
7fda351c02 | ||
|
|
cdd8cf4693 | ||
|
|
45afaf89ab | ||
|
|
e657f2bfbc |
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
31
tests/unit/test_cli_set_pin.py
Normal file
31
tests/unit/test_cli_set_pin.py
Normal 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$")
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
45
tests/unit/test_pin_unification.py
Normal file
45
tests/unit/test_pin_unification.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/#"
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user