Compare commits
10 Commits
94cc206fb4
...
038ab0af12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
038ab0af12 | ||
|
|
d72c04efcd | ||
|
|
ac52198b35 | ||
|
|
08c689e6dd | ||
|
|
9f203d8ce5 | ||
|
|
efd5c4ad80 | ||
|
|
c275404e4e | ||
|
|
f4d66dd5df | ||
|
|
7006078ae5 | ||
|
|
c9dd348850 |
@@ -113,8 +113,9 @@ omitted sections behave sensibly.
|
|||||||
`.vge` files.
|
`.vge` files.
|
||||||
- `hls_dir` (default `/var/vigilar/hls`): HLS segment output.
|
- `hls_dir` (default `/var/vigilar/hls`): HLS segment output.
|
||||||
- `log_level` (default `"INFO"`): one of DEBUG, INFO, WARNING, ERROR.
|
- `log_level` (default `"INFO"`): one of DEBUG, INFO, WARNING, ERROR.
|
||||||
- `arm_pin_hash` (default `""`): commented out in the sample; set via
|
- `arm_pin_hash` (default `""`): **deprecated.** Still parsed but
|
||||||
`vigilar config set-pin`.
|
ignored at runtime. Use `[security] pin_hash` instead; run
|
||||||
|
`vigilar config set-pin` to generate the canonical hash.
|
||||||
|
|
||||||
### `[mqtt]`
|
### `[mqtt]`
|
||||||
|
|
||||||
@@ -291,11 +292,15 @@ enabled = false`, `[visitors] enabled = false`, `[highlights] enabled
|
|||||||
|
|
||||||
- `[location] latitude`, `longitude` (default `0.0`): used for sunrise
|
- `[location] latitude`, `longitude` (default `0.0`): used for sunrise
|
||||||
and sunset lookups.
|
and sunset lookups.
|
||||||
- `[security] pin_hash` and `recovery_passphrase_hash`: populated by
|
- `[security] pin_hash` (canonical arm/disarm PIN store): populated by
|
||||||
`vigilar config set-pin` (the same hash is also stored under
|
`vigilar config set-pin`, which emits a PBKDF2-SHA256 hash to paste
|
||||||
`[system] arm_pin_hash` on the `system` model; both fields exist
|
into the `[security]` section. The legacy `[system] arm_pin_hash`
|
||||||
because the web UI uses `[security]` while the CLI helper prints a
|
field is deprecated; see the `[system]` section above.
|
||||||
`[system]` line — pick one location and stick with it).
|
- `[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
|
## 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`,
|
Dumps the parsed config as JSON with `web.password_hash`,
|
||||||
`system.arm_pin_hash`, and `alerts.webhook.secret` redacted. Useful
|
`security.pin_hash`, `security.recovery_passphrase_hash`, and
|
||||||
for confirming which defaults Pydantic applied for keys you did not
|
`alerts.webhook.secret` redacted. Useful for confirming which
|
||||||
set.
|
defaults Pydantic applied for keys you did not set.
|
||||||
|
|
||||||
### `vigilar config set-password`
|
### `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
|
sudo -u vigilar /opt/vigilar/venv/bin/vigilar config set-pin
|
||||||
```
|
```
|
||||||
|
|
||||||
Prompts for an arm/disarm PIN, generates a random 32-byte HMAC key,
|
Prompts for an arm/disarm PIN, derives a salted PBKDF2-SHA256 hash
|
||||||
computes `HMAC-SHA256(key, pin)`, and prints an `arm_pin_hash =
|
(600,000 iterations) via `vigilar.alerts.pin.hash_pin`, and prints a
|
||||||
"secret_hex:mac_hex"` line to paste into `[system]`. Again, no file
|
`pin_hash = "pbkdf2_sha256$salt$dk"` line to paste into `[security]`.
|
||||||
write.
|
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
|
## Secrets and security
|
||||||
|
|
||||||
@@ -388,9 +395,13 @@ write.
|
|||||||
volume on integrity-verified storage (dm-integrity, ZFS with
|
volume on integrity-verified storage (dm-integrity, ZFS with
|
||||||
checksums) or mirror to write-once media.
|
checksums) or mirror to write-once media.
|
||||||
- The web UI password is a scrypt hash set by `vigilar config
|
- The web UI password is a scrypt hash set by `vigilar config
|
||||||
set-password` and stored at `[web] password_hash`. The arm PIN is
|
set-password` and stored at `[web] password_hash`. The arm/disarm
|
||||||
an HMAC stored at `[system] arm_pin_hash` (and/or `[security]
|
PIN is a PBKDF2-SHA256 hash (600k iterations, salted) set by
|
||||||
pin_hash`).
|
`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`
|
- TLS: `gen_cert.sh` uses `mkcert` if present, otherwise an `openssl`
|
||||||
ECDSA P-256 self-signed certificate valid for 3650 days with SANs
|
ECDSA P-256 self-signed certificate valid for 3650 days with SANs
|
||||||
for `vigilar.local`, `localhost`, `127.0.0.1`, and the detected LAN
|
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.
|
`[health]` for real disk policy.
|
||||||
- **No schema migrations.** There is no Alembic (or equivalent) in
|
- **No schema migrations.** There is no Alembic (or equivalent) in
|
||||||
the tree. Rollbacks rely on your backup discipline.
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -138,3 +138,49 @@ class TestCameraConfigLocation:
|
|||||||
from vigilar.config import CameraConfig
|
from vigilar.config import CameraConfig
|
||||||
cfg = CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://x", location="EXTERIOR")
|
cfg = CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://x", location="EXTERIOR")
|
||||||
assert cfg.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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -429,3 +429,76 @@ class TestPetEventClassification:
|
|||||||
assert etype == EventType.WILDLIFE_PASSIVE
|
assert etype == EventType.WILDLIFE_PASSIVE
|
||||||
assert sev == Severity.INFO
|
assert sev == Severity.INFO
|
||||||
assert source == "front"
|
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!")
|
stored = hash_pin("p@ss!")
|
||||||
assert verify_pin("p@ss!", stored) is True
|
assert verify_pin("p@ss!", stored) is True
|
||||||
assert verify_pin("p@ss?", stored) is False
|
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."""
|
"""Tests for PIN verification on arm/disarm endpoints."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
from vigilar.alerts.pin import hash_pin
|
from vigilar.alerts.pin import hash_pin
|
||||||
from vigilar.config import VigilarConfig, SecurityConfig
|
from vigilar.config import VigilarConfig, SecurityConfig
|
||||||
from vigilar.web.app import create_app
|
from vigilar.web.app import create_app
|
||||||
@@ -29,35 +30,55 @@ def app_no_pin():
|
|||||||
|
|
||||||
|
|
||||||
def test_arm_without_pin_set(app_no_pin):
|
def test_arm_without_pin_set(app_no_pin):
|
||||||
with app_no_pin.test_client() as c:
|
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
|
||||||
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY"})
|
with app_no_pin.test_client() as c:
|
||||||
assert rv.status_code == 200
|
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY"})
|
||||||
assert rv.get_json()["ok"] is True
|
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):
|
def test_arm_correct_pin(app_with_pin):
|
||||||
with app_with_pin.test_client() as c:
|
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
|
||||||
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "1234"})
|
with app_with_pin.test_client() as c:
|
||||||
assert rv.status_code == 200
|
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "1234"})
|
||||||
assert rv.get_json()["ok"] is True
|
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):
|
def test_arm_wrong_pin_still_accepted_by_web_fsm_rejects(app_with_pin):
|
||||||
with app_with_pin.test_client() as c:
|
"""HTTP layer no longer pre-checks the PIN — it forwards to the FSM
|
||||||
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "0000"})
|
unconditionally. The FSM verifies and, on mismatch, logs a warning
|
||||||
assert rv.status_code == 401
|
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):
|
def test_disarm_correct_pin(app_with_pin):
|
||||||
with app_with_pin.test_client() as c:
|
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
|
||||||
rv = c.post("/system/api/disarm", json={"pin": "1234"})
|
with app_with_pin.test_client() as c:
|
||||||
assert rv.status_code == 200
|
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):
|
def test_disarm_wrong_pin_still_accepted_by_web_fsm_rejects(app_with_pin):
|
||||||
with app_with_pin.test_client() as c:
|
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
|
||||||
rv = c.post("/system/api/disarm", json={"pin": "9999"})
|
with app_with_pin.test_client() as c:
|
||||||
assert rv.status_code == 401
|
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):
|
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",
|
"new_pin": "5678",
|
||||||
})
|
})
|
||||||
assert rv.status_code == 401
|
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:
|
with app.test_client() as client:
|
||||||
resp = client.get("/recordings/")
|
resp = client.get("/recordings/")
|
||||||
assert resp.status_code == 200
|
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"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Configuration loading and validation via TOML + Pydantic."""
|
"""Configuration loading and validation via TOML + Pydantic."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -23,6 +24,8 @@ from vigilar.constants import (
|
|||||||
CameraLocation,
|
CameraLocation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# --- Camera Config ---
|
# --- Camera Config ---
|
||||||
|
|
||||||
class CameraConfig(BaseModel):
|
class CameraConfig(BaseModel):
|
||||||
@@ -435,4 +438,13 @@ def load_config(path: str | Path | None = None) -> VigilarConfig:
|
|||||||
raw["sensors.gpio"] = gpio_config
|
raw["sensors.gpio"] = gpio_config
|
||||||
# The [[sensors]] array items remain as 'sensors' key from TOML parsing
|
# 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
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.engine import Engine
|
|||||||
|
|
||||||
from vigilar.bus import MessageBus
|
from vigilar.bus import MessageBus
|
||||||
from vigilar.config import VigilarConfig
|
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.rules import RuleEngine
|
||||||
from vigilar.events.state import ArmStateFSM
|
from vigilar.events.state import ArmStateFSM
|
||||||
from vigilar.storage.db import get_db_path, init_db
|
from vigilar.storage.db import get_db_path, init_db
|
||||||
@@ -58,12 +58,20 @@ class EventProcessor:
|
|||||||
fsm.set_bus(bus)
|
fsm.set_bus(bus)
|
||||||
bus.connect()
|
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:
|
def on_message(topic: str, payload: dict[str, Any]) -> None:
|
||||||
self._handle_event(topic, payload, engine, fsm, rule_engine, bus)
|
self._handle_event(topic, payload, engine, fsm, rule_engine, bus)
|
||||||
|
|
||||||
bus.subscribe_all(on_message)
|
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")
|
log.info("Event processor started")
|
||||||
|
|
||||||
# Main loop
|
# Main loop
|
||||||
@@ -144,6 +152,26 @@ class EventProcessor:
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Error processing event on %s", topic)
|
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(
|
def _classify_event(
|
||||||
self, topic: str, payload: dict[str, Any]
|
self, topic: str, payload: dict[str, Any]
|
||||||
) -> tuple[str | None, str | None, str | None]:
|
) -> tuple[str | None, str | None, str | None]:
|
||||||
|
|||||||
@@ -36,8 +36,20 @@ def _save_and_reload(new_cfg: VigilarConfig) -> None:
|
|||||||
def system_status():
|
def system_status():
|
||||||
"""JSON API: overall system health."""
|
"""JSON API: overall system health."""
|
||||||
cfg = _get_cfg()
|
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({
|
return jsonify({
|
||||||
"arm_state": "DISARMED",
|
"arm_state": arm_state,
|
||||||
"ups": {"status": "UNKNOWN"},
|
"ups": {"status": "UNKNOWN"},
|
||||||
"cameras_online": 0,
|
"cameras_online": 0,
|
||||||
"cameras_total": len(cfg.cameras),
|
"cameras_total": len(cfg.cameras),
|
||||||
@@ -54,27 +66,46 @@ def settings_page():
|
|||||||
|
|
||||||
# --- Arm/Disarm ---
|
# --- 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"])
|
@system_bp.route("/api/arm", methods=["POST"])
|
||||||
def arm_system():
|
def arm_system():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
mode = data.get("mode", "ARMED_AWAY")
|
mode = data.get("mode", "ARMED_AWAY")
|
||||||
pin = data.get("pin", "")
|
pin = data.get("pin", "")
|
||||||
cfg = _get_cfg()
|
payload = {"mode": mode, "pin": pin, "triggered_by": "web"}
|
||||||
pin_hash = cfg.security.pin_hash
|
try:
|
||||||
if pin_hash and not verify_pin(pin, pin_hash):
|
_publish_arm_request(_get_cfg(), payload)
|
||||||
return jsonify({"error": "Invalid PIN"}), 401
|
except Exception:
|
||||||
return jsonify({"ok": True, "state": mode})
|
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"])
|
@system_bp.route("/api/disarm", methods=["POST"])
|
||||||
def disarm_system():
|
def disarm_system():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
pin = data.get("pin", "")
|
pin = data.get("pin", "")
|
||||||
cfg = _get_cfg()
|
payload = {"mode": "DISARMED", "pin": pin, "triggered_by": "web"}
|
||||||
pin_hash = cfg.security.pin_hash
|
try:
|
||||||
if pin_hash and not verify_pin(pin, pin_hash):
|
_publish_arm_request(_get_cfg(), payload)
|
||||||
return jsonify({"error": "Invalid PIN"}), 401
|
except Exception:
|
||||||
return jsonify({"ok": True, "state": "DISARMED"})
|
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"])
|
@system_bp.route("/api/reset-pin", methods=["POST"])
|
||||||
|
|||||||
Reference in New Issue
Block a user