Compare commits
1 Commits
038ab0af12
...
plan/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f99472fc1a |
@@ -113,9 +113,8 @@ 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 `""`): **deprecated.** Still parsed but
|
- `arm_pin_hash` (default `""`): commented out in the sample; set via
|
||||||
ignored at runtime. Use `[security] pin_hash` instead; run
|
`vigilar config set-pin`.
|
||||||
`vigilar config set-pin` to generate the canonical hash.
|
|
||||||
|
|
||||||
### `[mqtt]`
|
### `[mqtt]`
|
||||||
|
|
||||||
@@ -292,15 +291,11 @@ 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` (canonical arm/disarm PIN store): populated by
|
- `[security] pin_hash` and `recovery_passphrase_hash`: populated by
|
||||||
`vigilar config set-pin`, which emits a PBKDF2-SHA256 hash to paste
|
`vigilar config set-pin` (the same hash is also stored under
|
||||||
into the `[security]` section. The legacy `[system] arm_pin_hash`
|
`[system] arm_pin_hash` on the `system` model; both fields exist
|
||||||
field is deprecated; see the `[system]` section above.
|
because the web UI uses `[security]` while the CLI helper prints a
|
||||||
- `[security] recovery_passphrase_hash`: used by the web
|
`[system]` line — pick one location and stick with it).
|
||||||
`/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
|
||||||
|
|
||||||
@@ -349,9 +344,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`,
|
||||||
`security.pin_hash`, `security.recovery_passphrase_hash`, and
|
`system.arm_pin_hash`, and `alerts.webhook.secret` redacted. Useful
|
||||||
`alerts.webhook.secret` redacted. Useful for confirming which
|
for confirming which defaults Pydantic applied for keys you did not
|
||||||
defaults Pydantic applied for keys you did not set.
|
set.
|
||||||
|
|
||||||
### `vigilar config set-password`
|
### `vigilar config set-password`
|
||||||
|
|
||||||
@@ -370,12 +365,10 @@ 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, derives a salted PBKDF2-SHA256 hash
|
Prompts for an arm/disarm PIN, generates a random 32-byte HMAC key,
|
||||||
(600,000 iterations) via `vigilar.alerts.pin.hash_pin`, and prints a
|
computes `HMAC-SHA256(key, pin)`, and prints an `arm_pin_hash =
|
||||||
`pin_hash = "pbkdf2_sha256$salt$dk"` line to paste into `[security]`.
|
"secret_hex:mac_hex"` line to paste into `[system]`. Again, no file
|
||||||
Again, no file write. The same hash format is verified identically by
|
write.
|
||||||
the web arm/disarm endpoint and by `ArmStateFSM` in the event
|
|
||||||
processor — there is one canonical PIN store.
|
|
||||||
|
|
||||||
## Secrets and security
|
## Secrets and security
|
||||||
|
|
||||||
@@ -395,13 +388,9 @@ processor — there is one canonical PIN store.
|
|||||||
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/disarm
|
set-password` and stored at `[web] password_hash`. The arm PIN is
|
||||||
PIN is a PBKDF2-SHA256 hash (600k iterations, salted) set by
|
an HMAC stored at `[system] arm_pin_hash` (and/or `[security]
|
||||||
`vigilar config set-pin` and stored at `[security] pin_hash`.
|
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
|
||||||
@@ -620,6 +609,10 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -11,28 +11,6 @@ from vigilar.config import VigilarConfig, load_config
|
|||||||
from vigilar.storage.schema import metadata
|
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):
|
def _create_test_engine(db_path: Path):
|
||||||
"""Create a fresh engine for testing (bypasses the global singleton)."""
|
"""Create a fresh engine for testing (bypasses the global singleton)."""
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
"""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,49 +138,3 @@ 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}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for the Phase 6 events subsystem: rules, arm state FSM, history."""
|
"""Tests for the Phase 6 events subsystem: rules, arm state FSM, history."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -18,7 +19,7 @@ from vigilar.storage.queries import insert_event
|
|||||||
|
|
||||||
def _make_config(rules=None, pin_hash=""):
|
def _make_config(rules=None, pin_hash=""):
|
||||||
return VigilarConfig(
|
return VigilarConfig(
|
||||||
security={"pin_hash": pin_hash},
|
system={"arm_pin_hash": pin_hash},
|
||||||
cameras=[],
|
cameras=[],
|
||||||
sensors=[],
|
sensors=[],
|
||||||
rules=rules or [],
|
rules=rules or [],
|
||||||
@@ -26,8 +27,7 @@ def _make_config(rules=None, pin_hash=""):
|
|||||||
|
|
||||||
|
|
||||||
def _pin_hash(pin: str) -> str:
|
def _pin_hash(pin: str) -> str:
|
||||||
from vigilar.alerts.pin import hash_pin
|
return hashlib.sha256(pin.encode()).hexdigest()
|
||||||
return hash_pin(pin)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -429,76 +429,3 @@ 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,14 +37,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
"""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,7 +1,6 @@
|
|||||||
"""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
|
||||||
@@ -30,55 +29,35 @@ def app_no_pin():
|
|||||||
|
|
||||||
|
|
||||||
def test_arm_without_pin_set(app_no_pin):
|
def test_arm_without_pin_set(app_no_pin):
|
||||||
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
|
with app_no_pin.test_client() as c:
|
||||||
with app_no_pin.test_client() as c:
|
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY"})
|
||||||
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY"})
|
assert rv.status_code == 200
|
||||||
assert rv.status_code == 202
|
assert rv.get_json()["ok"] is True
|
||||||
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 patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
|
with app_with_pin.test_client() as c:
|
||||||
with app_with_pin.test_client() as c:
|
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "1234"})
|
||||||
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "1234"})
|
assert rv.status_code == 200
|
||||||
assert rv.status_code == 202
|
assert rv.get_json()["ok"] is True
|
||||||
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_still_accepted_by_web_fsm_rejects(app_with_pin):
|
def test_arm_wrong_pin(app_with_pin):
|
||||||
"""HTTP layer no longer pre-checks the PIN — it forwards to the FSM
|
with app_with_pin.test_client() as c:
|
||||||
unconditionally. The FSM verifies and, on mismatch, logs a warning
|
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "0000"})
|
||||||
and leaves the state unchanged."""
|
assert rv.status_code == 401
|
||||||
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 patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
|
with app_with_pin.test_client() as c:
|
||||||
with app_with_pin.test_client() as c:
|
rv = c.post("/system/api/disarm", json={"pin": "1234"})
|
||||||
rv = c.post("/system/api/disarm", json={"pin": "1234"})
|
assert rv.status_code == 200
|
||||||
assert rv.status_code == 202
|
|
||||||
pub.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_disarm_wrong_pin_still_accepted_by_web_fsm_rejects(app_with_pin):
|
def test_disarm_wrong_pin(app_with_pin):
|
||||||
with patch("vigilar.web.blueprints.system._publish_arm_request") as pub:
|
with app_with_pin.test_client() as c:
|
||||||
with app_with_pin.test_client() as c:
|
rv = c.post("/system/api/disarm", json={"pin": "9999"})
|
||||||
rv = c.post("/system/api/disarm", json={"pin": "9999"})
|
assert rv.status_code == 401
|
||||||
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):
|
||||||
@@ -98,35 +77,3 @@ 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,36 +102,3 @@ 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"
|
|
||||||
|
|||||||
@@ -47,10 +47,8 @@ def show_cmd(config_path: str | None) -> None:
|
|||||||
# Redact sensitive fields
|
# Redact sensitive fields
|
||||||
if data.get("web", {}).get("password_hash"):
|
if data.get("web", {}).get("password_hash"):
|
||||||
data["web"]["password_hash"] = "***"
|
data["web"]["password_hash"] = "***"
|
||||||
if data.get("security", {}).get("pin_hash"):
|
if data.get("system", {}).get("arm_pin_hash"):
|
||||||
data["security"]["pin_hash"] = "***"
|
data["system"]["arm_pin_hash"] = "***"
|
||||||
if data.get("security", {}).get("recovery_passphrase_hash"):
|
|
||||||
data["security"]["recovery_passphrase_hash"] = "***"
|
|
||||||
if data.get("alerts", {}).get("webhook", {}).get("secret"):
|
if data.get("alerts", {}).get("webhook", {}).get("secret"):
|
||||||
data["alerts"]["webhook"]["secret"] = "***"
|
data["alerts"]["webhook"]["secret"] = "***"
|
||||||
click.echo(json.dumps(data, indent=2))
|
click.echo(json.dumps(data, indent=2))
|
||||||
@@ -84,10 +82,14 @@ def set_password_cmd(config_path: str | None) -> None:
|
|||||||
|
|
||||||
@config_cmd.command("set-pin")
|
@config_cmd.command("set-pin")
|
||||||
def set_pin_cmd() -> None:
|
def set_pin_cmd() -> None:
|
||||||
"""Generate a PBKDF2 hash for the arm/disarm PIN."""
|
"""Generate an HMAC hash for the arm/disarm PIN."""
|
||||||
from vigilar.alerts.pin import hash_pin
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
|
||||||
pin = click.prompt("Enter arm/disarm PIN", hide_input=True, confirmation_prompt=True)
|
pin = click.prompt("Enter arm/disarm PIN", hide_input=True, confirmation_prompt=True)
|
||||||
hash_str = hash_pin(pin)
|
secret = os.urandom(32)
|
||||||
click.echo("\nAdd this to your vigilar.toml [security] section:")
|
mac = hmac.new(secret, pin.encode(), hashlib.sha256).hexdigest()
|
||||||
click.echo(f'pin_hash = "{hash_str}"')
|
hash_str = secret.hex() + ":" + mac
|
||||||
|
click.echo(f"\nAdd this to your vigilar.toml [system] section:")
|
||||||
|
click.echo(f'arm_pin_hash = "{hash_str}"')
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""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
|
||||||
@@ -24,8 +23,6 @@ from vigilar.constants import (
|
|||||||
CameraLocation,
|
CameraLocation,
|
||||||
)
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# --- Camera Config ---
|
# --- Camera Config ---
|
||||||
|
|
||||||
class CameraConfig(BaseModel):
|
class CameraConfig(BaseModel):
|
||||||
@@ -438,13 +435,4 @@ 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
|
||||||
|
|
||||||
cfg = VigilarConfig(**raw)
|
return 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,8 +210,6 @@ class Topics:
|
|||||||
SYSTEM_ALERT = "vigilar/system/alert"
|
SYSTEM_ALERT = "vigilar/system/alert"
|
||||||
SYSTEM_SHUTDOWN = "vigilar/system/shutdown"
|
SYSTEM_SHUTDOWN = "vigilar/system/shutdown"
|
||||||
SYSTEM_RULES_UPDATED = "vigilar/system/rules_updated"
|
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
|
# Wildcard subscriptions
|
||||||
ALL = "vigilar/#"
|
ALL = "vigilar/#"
|
||||||
|
|||||||
@@ -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 ArmState, EventType, Severity, Topics
|
from vigilar.constants import 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,20 +58,12 @@ class EventProcessor:
|
|||||||
fsm.set_bus(bus)
|
fsm.set_bus(bus)
|
||||||
bus.connect()
|
bus.connect()
|
||||||
|
|
||||||
# Subscribe to all Vigilar topics (events/motion/sensors/etc.)
|
# Subscribe to all Vigilar topics
|
||||||
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
|
||||||
@@ -152,26 +144,6 @@ 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]:
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""Arm state finite state machine."""
|
"""Arm state finite state machine."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
from vigilar.alerts.pin import verify_pin
|
|
||||||
from vigilar.config import VigilarConfig
|
from vigilar.config import VigilarConfig
|
||||||
from vigilar.constants import ArmState, EventType, Severity, Topics
|
from vigilar.constants import ArmState, EventType, Severity, Topics
|
||||||
from vigilar.storage.queries import get_current_arm_state, insert_arm_state, insert_event
|
from vigilar.storage.queries import get_current_arm_state, insert_arm_state, insert_event
|
||||||
@@ -18,7 +19,7 @@ class ArmStateFSM:
|
|||||||
|
|
||||||
def __init__(self, engine: Engine, config: VigilarConfig):
|
def __init__(self, engine: Engine, config: VigilarConfig):
|
||||||
self._engine = engine
|
self._engine = engine
|
||||||
self._pin_hash = config.security.pin_hash
|
self._pin_hash = config.system.arm_pin_hash
|
||||||
self._state = ArmState.DISARMED
|
self._state = ArmState.DISARMED
|
||||||
self._bus = None
|
self._bus = None
|
||||||
self._load_initial_state()
|
self._load_initial_state()
|
||||||
@@ -42,11 +43,12 @@ class ArmStateFSM:
|
|||||||
return self._state
|
return self._state
|
||||||
|
|
||||||
def verify_pin(self, pin: str) -> bool:
|
def verify_pin(self, pin: str) -> bool:
|
||||||
"""Verify a PIN against the stored PBKDF2 hash."""
|
"""Verify a PIN against the stored hash using HMAC comparison."""
|
||||||
if not self._pin_hash:
|
if not self._pin_hash:
|
||||||
# No PIN configured — allow all transitions
|
# No PIN configured — allow all transitions
|
||||||
return True
|
return True
|
||||||
return verify_pin(pin, self._pin_hash)
|
candidate = hashlib.sha256(pin.encode()).hexdigest()
|
||||||
|
return hmac.compare_digest(candidate, self._pin_hash)
|
||||||
|
|
||||||
def transition(
|
def transition(
|
||||||
self,
|
self,
|
||||||
@@ -66,10 +68,9 @@ class ArmStateFSM:
|
|||||||
old_state = self._state
|
old_state = self._state
|
||||||
self._state = new_state
|
self._state = new_state
|
||||||
|
|
||||||
# pin_hash is always None here: PBKDF2 uses a random salt per call, so
|
# Log to database
|
||||||
# re-hashing the pin now would produce a value unrelated to the stored
|
pin_hash = hashlib.sha256(pin.encode()).hexdigest() if pin else None
|
||||||
# hash, making the column useless for audit correlation. See issue #2.
|
insert_arm_state(self._engine, new_state.value, triggered_by, pin_hash)
|
||||||
insert_arm_state(self._engine, new_state.value, triggered_by, None)
|
|
||||||
|
|
||||||
# Log event
|
# Log event
|
||||||
insert_event(
|
insert_event(
|
||||||
|
|||||||
@@ -36,20 +36,8 @@ 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": arm_state,
|
"arm_state": "DISARMED",
|
||||||
"ups": {"status": "UNKNOWN"},
|
"ups": {"status": "UNKNOWN"},
|
||||||
"cameras_online": 0,
|
"cameras_online": 0,
|
||||||
"cameras_total": len(cfg.cameras),
|
"cameras_total": len(cfg.cameras),
|
||||||
@@ -66,46 +54,27 @@ 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", "")
|
||||||
payload = {"mode": mode, "pin": pin, "triggered_by": "web"}
|
cfg = _get_cfg()
|
||||||
try:
|
pin_hash = cfg.security.pin_hash
|
||||||
_publish_arm_request(_get_cfg(), payload)
|
if pin_hash and not verify_pin(pin, pin_hash):
|
||||||
except Exception:
|
return jsonify({"error": "Invalid PIN"}), 401
|
||||||
current_app.logger.exception("Failed to publish arm request")
|
return jsonify({"ok": True, "state": mode})
|
||||||
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", "")
|
||||||
payload = {"mode": "DISARMED", "pin": pin, "triggered_by": "web"}
|
cfg = _get_cfg()
|
||||||
try:
|
pin_hash = cfg.security.pin_hash
|
||||||
_publish_arm_request(_get_cfg(), payload)
|
if pin_hash and not verify_pin(pin, pin_hash):
|
||||||
except Exception:
|
return jsonify({"error": "Invalid PIN"}), 401
|
||||||
current_app.logger.exception("Failed to publish arm request")
|
return jsonify({"ok": True, "state": "DISARMED"})
|
||||||
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