Implement Sentry scanning, notification state and worker cancellation through explicit policies and preferences.
This commit is contained in:
firestar5683
2026-09-25 14:53:20 -05:00
parent b282ced7fb
commit 2dd715a3d5
19 changed files with 2355 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# Sentry motion events
This is the parked motion-event part of Sentry. Galaxy can list local event
metadata at `#/cameras/events`. Camera capture, image galleries, notifications
and remote access are not implemented here. Motion detection has
not yet been qualified on a physical device or vehicle.
The manager runs it only on device hardware when both
`STARPILOT_SENTRY_DEVELOPMENT=1` and a valid enabled `SentryMotionPreferences`
document are present. Ordinary startup is unchanged. The same manager-owned
`sensord` supplies acceleration onroad and, under that explicit opt-in, while
parked. Sentry never starts another sensor, camera or power service.
The optional version-1 document contains `enabled`, `sensitivity` (0.005–1.0)
and `warningTimeSeconds` (0.1–10.0). Missing, unreadable or malformed settings
disable observation without replacing the saved bytes. There is no automatic
adoption of historical Sentry settings. Galaxy exposes the saved choices at
`#/cameras/sentry-settings`, through Cameras. Each edit requires a one-time
confirmation and fresh parked evidence, then compares the complete saved source
again before replacement. Invalid readable settings require an explicit reset
to Off and defaults; unreadable settings remain untouched. Saving preferences
does not bypass development opt-in. The shared settings page shows runtime
configuration blocks, a live arming countdown and motion-owner evidence failures.
Status expires after 1.5 seconds, is bound to the exact saved settings and boot,
and never grants sensor, camera or power authority. Heartbeats use namespace-local
shared memory on device hardware, not persistent event storage.
Arming requires 90 seconds of continuously fresh parked, ignition-off, power
and motion evidence. Observation runs at 10 Hz, preserving the magnitude-delta
threshold and warning-count behavior. An alarm requires more than 25 qualifying
deltas and at least 30 seconds within a 60-second motion window. A stale or
repeated sample, sensor loss, owner-loop gap, system resume, settings change,
ignition or shutdown request disarms the policy. Recovery requires rearming.
Hardwared retains battery accounting and shutdown authority; Sentry neither
extends parked runtime nor clears shutdown requests.
Warning and alarm records contain only kind, generated event/session IDs and
monotonic/system-clock timestamps. System time is not proof of an accurate wall
clock. Storage uses a private namespace-specific directory, at most 512 bounded
records, and atomic publication without overwriting an existing event. Full or
unavailable storage stops recording new events; it does not delete old evidence.
There are no image paths, location data or network requests. Authority and saved
settings are checked again immediately before publication. A publication whose
directory sync fails reports uncertain durability and is never blindly retried.
Tests cover continuous arming and thresholds, corrupt preferences, serialized
message timing, resume and power/ignition revocation, process selection, bounded
storage and interrupted writes. They do not establish physical motion sensitivity,
parked battery endurance, device wake behavior or complete Sentry parity.
@@ -0,0 +1 @@
"""Parked Sentry motion policy, guarded observation and local event metadata."""
@@ -0,0 +1,14 @@
"""One validated, exact-source saved Sentry document replacement."""
from collections.abc import Callable
from openpilot.starpilot.saved_document import WriteResult, commit_exact
from openpilot.starpilot.sentry_mode.preferences import KEY, MAX_BYTES, decode, encode
def commit(params, raw: bytes, expected: bytes | None, authorized: Callable[[], bool]) -> WriteResult:
preferences = decode(raw) if type(raw) is bytes else None
if preferences is None or encode(preferences) != raw:
return WriteResult(False, False)
return commit_exact(params, key=KEY, max_bytes=MAX_BYTES, raw=raw, expected=expected,
authorized=authorized, temp_prefix=".tmp_sentry_")
@@ -0,0 +1,451 @@
"""Private configuration and durable, bounded event delivery outside motion detection.
A successful receiver response is deduplicated durably. An interrupted request can
have been accepted remotely: retries reuse the event/channel Idempotency-Key, but
arbitrary webhooks and ntfy cannot promise exactly-once display.
"""
import copy
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import threading
import time
from urllib.parse import urlsplit
import uuid
import requests
from openpilot.starpilot.storage import starpilot_storage_root
from openpilot.starpilot.sentry_mode.storage import EventStore
from openpilot.starpilot.sentry_mode import web_push
CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy')
MAX_STATE_BYTES = 1024 * 1024
MAX_JOBS = 512
MAX_AGE = 86400
MAX_ATTEMPTS = 4
RETRY_SECONDS = (30, 120, 600, 600)
ID = re.compile(r'[0-9a-f]{32}\Z')
class NotificationUnavailable(Exception):
pass
def endpoint(value: object, *, allow_http: bool = False) -> str:
if type(value) is not str or not 1 <= len(value) <= 2048 or any(ord(c) <= 32 for c in value):
raise ValueError('Enter a valid notification URL')
try:
parsed = urlsplit(value)
if (parsed.scheme not in (('https', 'http') if allow_http else ('https',)) or not parsed.hostname or
parsed.username is not None or parsed.password is not None or parsed.fragment or parsed.port == 0):
raise ValueError
except ValueError:
raise ValueError('Use an HTTPS notification URL') from None
return value
def fresh_state() -> dict:
return {'version': 1, 'channels': {channel: {'enabled': False, 'url': '', 'token': '', 'generation': uuid.uuid4().hex,
'since': 0} for channel in CHANNELS},
'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False}
def wall_seconds() -> float:
# Durable retry deadlines and event timestamps must survive a process/device reboot.
return time.time_ns() / 1e9
class NotificationOwner:
def __init__(self, root: Path | None = None, store=None, *, clock=wall_seconds, transport=None, allow_http: bool = False):
self.root = Path(root) if root is not None else starpilot_storage_root() / "sentry/notifications"
self.store = store if store is not None else EventStore()
self.clock, self.allow_http = clock, allow_http
self.transport = transport if transport is not None else self._send
self.lock = threading.RLock()
self.stop = threading.Event()
self.thread = None
self.state = fresh_state()
self.available = True
self.lock_fd = None
self.saved_bytes = None
try:
directory = self._directory()
try:
self.lock_fd = os.open('.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600, dir_fd=directory)
lock_info = os.fstat(self.lock_fd)
if not stat.S_ISREG(lock_info.st_mode) or lock_info.st_uid != os.getuid() or stat.S_IMODE(lock_info.st_mode) & 0o077:
raise OSError('Invalid notification lock')
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
finally:
os.close(directory)
self._load()
except (OSError, ValueError, TypeError, RecursionError):
self.available = False
if self.lock_fd is not None:
os.close(self.lock_fd)
self.lock_fd = None
def _directory(self) -> int:
# Walk every component without following symlinks. This private state contains credentials.
if not self.root.is_absolute() or ".." in self.root.parts:
raise OSError('Private storage requires an absolute directory')
fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY)
try:
for component in self.root.parts[1:]:
try:
os.mkdir(component, 0o700, dir_fd=fd)
except FileExistsError:
pass
child = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
os.close(fd)
fd = child
info = os.fstat(fd)
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
raise OSError('Private storage permissions are invalid')
return fd
except BaseException:
os.close(fd)
raise
def _load(self) -> None:
directory = self._directory()
try:
try:
fd = os.open('state.json', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
except FileNotFoundError:
return
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
raise ValueError('Invalid private notification state')
raw = os.read(fd, MAX_STATE_BYTES + 1)
finally:
os.close(fd)
if len(raw) > MAX_STATE_BYTES:
raise ValueError('Notification state exceeds limit')
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError('Duplicate notification state')
result[key] = value
return result
state = json.loads(raw, object_pairs_hook=unique)
self._validate_state(state)
self.state = state
self.saved_bytes = json.dumps(state, separators=(',', ':'), allow_nan=False).encode()
finally:
os.close(directory)
def _validate_state(self, state: dict) -> None:
if (type(state) is not dict or set(state) != set(fresh_state()) or type(state['version']) is not int or state['version'] != 1 or
type(state['channels']) is not dict or set(state['channels']) != set(CHANNELS) or
type(state['subscriptions']) is not list or len(state['subscriptions']) > 16 or
type(state['jobs']) is not dict or len(state['jobs']) > MAX_JOBS or type(state['queueFull']) is not bool):
raise ValueError('Invalid notification state')
if type(state['vapid']) is not str or len(state['vapid']) > 4096:
raise ValueError('Invalid notification push key')
if state['vapid']:
web_push.load_key(state['vapid'])
for name, channel in state['channels'].items():
if (type(channel) is not dict or set(channel) != {'enabled', 'url', 'token', 'generation', 'since'} or
type(channel['enabled']) is not bool or type(channel['generation']) is not str or ID.fullmatch(channel['generation']) is None or
type(channel['since']) not in (int, float) or not 0 <= channel['since'] < 2**63 or
type(channel['url']) is not str or len(channel['url']) > 2048 or
type(channel['token']) is not str or len(channel['token']) > 512 or any(ord(c) < 32 for c in channel['token'])):
raise ValueError('Invalid notification channel')
if channel['url']:
endpoint(channel['url'], allow_http=self.allow_http)
if name != 'webPush' and channel['enabled'] and not channel['url']:
raise ValueError('Missing notification endpoint')
for sub in state['subscriptions']:
if type(sub) is not dict or set(sub) != {'id', 'subscription', 'since'} or type(sub['id']) is not str or ID.fullmatch(sub['id']) is None:
raise ValueError('Invalid browser subscription')
web_push.validate_subscription(sub['subscription'])
endpoint(sub['subscription']['endpoint'], allow_http=self.allow_http)
if type(sub['since']) not in (int, float) or not 0 <= sub['since'] < 2**63:
raise ValueError('Invalid subscription time')
for key, job in state['jobs'].items():
if (type(job) is not dict or set(job) != {'eventId', 'kind', 'channel', 'generation', 'subscriptionId', 'created',
'next', 'attempts', 'state', 'error'} or
type(key) is not str or len(key) != 64 or type(job['eventId']) is not str or ID.fullmatch(job['eventId']) is None or
job['kind'] not in ('warning', 'alarm', 'test') or job['channel'] not in CHANNELS or
type(job['generation']) is not str or ID.fullmatch(job['generation']) is None or
(type(job['subscriptionId']) is not str or (job['subscriptionId'] and ID.fullmatch(job['subscriptionId']) is None)) or
type(job['attempts']) is not int or not 0 <= job['attempts'] <= MAX_ATTEMPTS or
job['state'] not in ('queued', 'sending', 'sent', 'failed', 'cancelled') or
job['error'] not in ('', 'http', 'expired', 'timeout', 'transport', 'delivery_unknown', 'stale') or
any(type(job[k]) not in (float, int) or not 0 <= job[k] < 2**63 for k in ('created', 'next'))):
raise ValueError('Invalid notification job')
def _save(self) -> None:
raw = json.dumps(self.state, separators=(',', ':'), allow_nan=False).encode()
if len(raw) > MAX_STATE_BYTES:
raise NotificationUnavailable('Notification storage is full')
if raw == self.saved_bytes:
return
directory = self._directory()
temporary = '.' + uuid.uuid4().hex + '.tmp'
try:
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
with os.fdopen(fd, 'wb') as output:
output.write(raw)
output.flush()
os.fsync(output.fileno())
os.replace(temporary, 'state.json', src_dir_fd=directory, dst_dir_fd=directory)
os.fsync(directory)
self.saved_bytes = raw
except OSError:
self.available = False
raise NotificationUnavailable('Notification storage is unavailable') from None
finally:
try:
os.unlink(temporary, dir_fd=directory)
except FileNotFoundError:
pass
os.close(directory)
def snapshot(self) -> dict:
with self.lock:
if not self.available:
raise NotificationUnavailable('Notification storage is unavailable')
jobs = self.state['jobs']
result = {'schemaVersion': 1, 'channels': {}, 'queueFull': self.state['queueFull'],
'subscriptionCount': len(self.state['subscriptions']),
'subscriptions': [{'id': sub['id']} for sub in self.state['subscriptions']],
'deliverySemantics': 'Retries may repeat delivery after an interrupted remote response.'}
for name, cfg in self.state['channels'].items():
relevant = [j for j in jobs.values() if j['channel'] == name and j['generation'] == cfg['generation']]
latest = max(relevant, key=lambda j: j['created'], default=None)
result['channels'][name] = {'enabled': cfg['enabled'],
'configured': bool(self.state['subscriptions']) if name == 'webPush' else bool(cfg['url']),
'pending': sum(j['state'] in ('queued', 'sending') for j in relevant),
'lastState': latest['state'] if latest else 'idle',
'lastError': latest['error'] if latest else ''}
if self.state['vapid']:
result['applicationServerKey'] = web_push.b64(web_push.public(web_push.load_key(self.state['vapid'])))
return result
def action(self, payload: dict, *, permitted=lambda: True) -> dict:
with self.lock:
if not self.available:
raise NotificationUnavailable('Notification storage is unavailable')
if not permitted():
raise PermissionError('A current Galaxy session is required')
if type(payload) is not dict or type(payload.get('action')) is not str:
raise ValueError('Choose a notification action')
previous = copy.deepcopy(self.state)
action = payload['action']
now = self.clock()
if action == 'configure':
if set(payload) != {'action', 'channel', 'enabled', 'url', 'token'} or payload['channel'] not in CHANNELS:
raise ValueError('Invalid notification channel')
name = payload['channel']
if (type(payload['enabled']) is not bool or type(payload['token']) is not str or len(payload['token']) > 512 or
any(ord(c) < 32 for c in payload['token'])):
raise ValueError('Invalid notification setting')
url = payload['url']
if type(url) is not str:
raise ValueError('Enter a valid notification URL')
if url:
endpoint(url, allow_http=self.allow_http)
if name == 'webPush' and payload['enabled'] and not self.state['subscriptions']:
raise ValueError('Subscribe a browser first')
if name == 'webPush' and (url or payload['token']):
raise ValueError('Browser push uses a browser subscription')
old = self.state['channels'][name]
# Empty fields preserve configured credentials; explicit disable does not delete them.
url = url or old['url']
token = payload['token'] or old['token']
if payload['enabled'] and name != 'webPush' and not url:
raise ValueError('Enter a notification URL')
self.state['channels'][name] = {'enabled': payload['enabled'], 'url': url, 'token': token,
'generation': uuid.uuid4().hex, 'since': now}
elif action == 'forget':
if set(payload) != {'action', 'channel'} or payload['channel'] not in CHANNELS:
raise ValueError('Choose a notification channel')
self.state['channels'][payload['channel']] = fresh_state()['channels'][payload['channel']]
if payload['channel'] == 'webPush':
self.state['subscriptions'] = []
elif action == 'pushKey':
if set(payload) != {'action'}:
raise ValueError('Invalid push key request')
if not self.state['vapid']:
self.state['vapid'] = web_push.new_key()
elif action == 'subscribe':
if set(payload) != {'action', 'subscription'} or not self.state['vapid']:
raise ValueError('Prepare browser push first')
sub = payload['subscription']
web_push.validate_subscription(sub)
endpoint(sub['endpoint'], allow_http=self.allow_http)
current = self.state['subscriptions']
existing = next((s for s in current if s['subscription']['endpoint'] == sub['endpoint']), None)
if existing:
existing['subscription'] = sub
elif len(current) < 16:
current.append({'id': uuid.uuid4().hex, 'subscription': sub, 'since': now})
else:
raise ValueError('Remove a browser before adding another')
elif action == 'unsubscribe':
if set(payload) != {'action', 'id'} or type(payload['id']) is not str or ID.fullmatch(payload['id']) is None:
raise ValueError('Choose a browser subscription')
self.state['subscriptions'] = [s for s in self.state['subscriptions'] if s['id'] != payload['id']]
elif action == 'test':
if set(payload) != {'action', 'channel'} or payload['channel'] not in CHANNELS:
raise ValueError('Choose a test channel')
cfg = self.state['channels'][payload['channel']]
if not cfg['enabled']:
raise ValueError('Enable this notification channel first')
if payload['channel'] == 'webPush' and not self.state['subscriptions']:
raise ValueError('Subscribe a browser first')
if sum(j['kind'] == 'test' and j['created'] > now - 60 for j in self.state['jobs'].values()) >= 4:
raise ValueError('Wait a minute before sending more tests')
self._enqueue({'eventId': uuid.uuid4().hex, 'kind': 'test', 'wallTimeNs': int(now * 1e9)}, only=payload['channel'])
else:
raise ValueError('Choose a supported notification action')
if not permitted():
self.state = previous
raise PermissionError('Galaxy session changed')
self._save()
return self.snapshot()
def _enqueue(self, event: dict, *, only: str | None = None) -> None:
now = self.clock()
for name, cfg in self.state['channels'].items():
if not cfg['enabled'] or (only is not None and only != name):
continue
targets = self.state['subscriptions'] if name == 'webPush' else [{'id': '', 'since': cfg['since']}]
for target in targets:
if event['kind'] != 'test' and event['wallTimeNs'] / 1e9 <= max(cfg['since'], target['since']):
continue
key = hashlib.sha256((event['eventId'] + name + cfg['generation'] + target['id']).encode()).hexdigest()
if key in self.state['jobs']:
continue
if len(self.state['jobs']) >= MAX_JOBS:
self.state['queueFull'] = True
continue
self.state['jobs'][key] = {'eventId': event['eventId'], 'kind': event['kind'], 'channel': name,
'generation': cfg['generation'], 'subscriptionId': target['id'],
'created': event['wallTimeNs'] / 1e9, 'next': now, 'attempts': 0,
'state': 'queued', 'error': ''}
def tick(self) -> None:
with self.lock:
if not self.available:
return
now = self.clock()
self.state['jobs'] = {key: job for key, job in self.state['jobs'].items() if now - job['created'] <= MAX_AGE}
self.state['queueFull'] = False
if not any(cfg["enabled"] for cfg in self.state["channels"].values()):
return
snapshot = self.store.snapshot()
for event in snapshot['events']:
if 0 <= now - event['wallTimeNs'] / 1e9 <= MAX_AGE:
self._enqueue(event)
for key, job in self.state['jobs'].items():
if job['state'] not in ('queued', 'sending'):
continue
cfg = self.state['channels'][job['channel']]
sub = next((s['subscription'] for s in self.state['subscriptions'] if s['id'] == job['subscriptionId']), None)
if not cfg['enabled'] or cfg['generation'] != job['generation'] or (job['channel'] == 'webPush' and sub is None):
job['state'] = 'cancelled'
continue
if now < job['created'] or now - job['created'] > MAX_AGE:
job['state'], job['error'] = 'failed', 'stale'
continue
if job['state'] == 'sending':
job['error'] = 'delivery_unknown'
job['state'] = 'queued' if job['attempts'] < MAX_ATTEMPTS else 'failed'
job['next'] = now + RETRY_SECONDS[min(job['attempts'], MAX_ATTEMPTS) - 1]
if job['state'] != 'queued' or job['next'] > now:
continue
job['attempts'] += 1
job['state'] = 'sending'
self._save() # Claim durably before issuing external IO; secrets stay in private config.
try:
# Keep status/configuration responsive while network IO is in flight.
self.lock.release()
try:
code = self.transport(job['channel'], dict(cfg), sub, dict(job), key, self.state['vapid'])
finally:
self.lock.acquire()
job = self.state['jobs'].get(key, job)
if 200 <= code < 300:
job['state'], job['error'] = 'sent', ''
elif code in (404, 410) and job['channel'] == 'webPush':
job['state'], job['error'] = 'failed', 'expired'
self.state['subscriptions'] = [s for s in self.state['subscriptions']
if s['id'] != job['subscriptionId'] or s['subscription'] != sub]
else:
job['state'], job['error'] = ('queued' if code in (408, 429) or code >= 500 else 'failed'), 'http'
except (requests.Timeout, TimeoutError):
job['state'], job['error'] = 'queued', 'timeout'
except Exception:
# Never log response bodies, URLs, exception text, subscription secrets or auth headers.
job['state'], job['error'] = 'queued', 'transport'
if job['state'] == 'queued':
if job['attempts'] >= MAX_ATTEMPTS:
job['state'] = 'failed'
job['next'] = now + RETRY_SECONDS[job['attempts'] - 1]
self._save()
return # At most one HTTP attempt per tick, per owner.
self._save()
def _send(self, name: str, cfg: dict, subscription: dict | None, job: dict, key: str, pem: str) -> int:
message = 'This is a test StarPilot Sentry notification.' if job['kind'] == 'test' else f"Parked motion detected ({job['kind']})."
event = {'eventId': job['eventId'], 'kind': job['kind'], 'systemTimeMs': int(job['created'] * 1000)}
headers = {'Idempotency-Key': key}
kwargs = {'timeout': (3, 10), 'allow_redirects': False, 'stream': True}
if name == 'webPush':
body, push_headers = web_push.request(subscription, {'title': 'StarPilot Sentry Mode', 'body': message,
'eventId': job['eventId'], 'url': '/#/cameras/events'}, pem)
headers.update(push_headers)
response = requests.post(subscription['endpoint'], data=body, headers=headers, **kwargs)
else:
if cfg['token']:
headers['Authorization'] = 'Bearer ' + cfg['token']
if name == 'discord':
response = requests.post(cfg['url'], json={'content': 'StarPilot Sentry Mode: ' + message}, headers=headers, **kwargs)
elif name == 'webhook':
# Preserve the original general-webhook content/event form contract.
response = requests.post(cfg['url'], data={'content': 'StarPilot Sentry Mode: ' + message,
'event': json.dumps(event, separators=(',', ':'))}, headers=headers, **kwargs)
else:
headers.update({'Title': 'StarPilot Sentry Mode', 'Priority': 'urgent', 'Tags': 'warning,car'})
response = requests.post(cfg['url'], data=message.encode(), headers=headers, **kwargs)
try:
return response.status_code
finally:
response.close()
def start(self) -> None:
with self.lock:
if self.thread is not None:
return
def run():
while not self.stop.is_set():
try:
self.tick()
except Exception:
# Storage/scanner failure is visible, never rewritten as a successful delivery.
with self.lock:
self.available = False
return
self.stop.wait(2.0)
self.thread = threading.Thread(target=run, name='sentry-notifications', daemon=True)
self.thread.start()
def close(self) -> None:
self.stop.set()
if self.thread is not None:
self.thread.join(timeout=15)
if self.thread.is_alive():
return
if self.lock_fd is not None:
os.close(self.lock_fd)
self.lock_fd = None
+163
View File
@@ -0,0 +1,163 @@
"""Parked Sentry arming and motion escalation without hardware or I/O."""
from dataclasses import dataclass
import math
ARM_DELAY_SECONDS = 90.0
LOOP_INTERVAL_SECONDS = 0.1
ALARM_TRIGGER_COUNT = 25
ALARM_TIME_SECONDS = 30.0
RESET_TIME_SECONDS = 60.0
MAX_SAMPLE_AGE_SECONDS = 0.5
MAX_UPDATE_GAP_SECONDS = 0.5 # 10 Hz owner may drop a few ticks, not a sleep/resume interval.
MAX_ACCELERATION_AXIS = 200.0 # Larger than plausible vehicle acceleration; rejects overflow and corrupt samples.
@dataclass(frozen=True)
class Settings:
sensitivity: float = 0.04
warning_time_seconds: float = 1.0
def __post_init__(self) -> None:
if type(self.sensitivity) not in (int, float) or not math.isfinite(self.sensitivity) or \
not 0.005 <= self.sensitivity <= 1.0:
raise ValueError("Sentry sensitivity must be within 0.005–1.0")
if type(self.warning_time_seconds) not in (int, float) or not math.isfinite(self.warning_time_seconds) or \
not 0.1 <= self.warning_time_seconds <= 10.0:
raise ValueError("Sentry warning time must be within 0.1–10 seconds")
@property
def warning_trigger_count(self) -> int:
# Count 10 Hz samples; this threshold is not elapsed time.
return max(1, math.ceil(self.warning_time_seconds / LOOP_INTERVAL_SECONDS))
@dataclass(frozen=True)
class MotionSample:
mono_time: float
acceleration: tuple[float, float, float]
def __post_init__(self) -> None:
if type(self.mono_time) not in (int, float) or not math.isfinite(self.mono_time) or self.mono_time < 0 or \
not isinstance(self.acceleration, tuple) or len(self.acceleration) != 3 or \
any(type(axis) not in (int, float) or not math.isfinite(axis) or abs(axis) > MAX_ACCELERATION_AXIS
for axis in self.acceleration):
raise ValueError("Motion sample needs a finite monotonic timestamp and three finite axes")
@dataclass(frozen=True)
class Inputs:
enabled: bool
offroad: bool
voltage_ok: bool
device_fresh: bool
ignition_off: bool
sample: MotionSample | None = None
def __post_init__(self) -> None:
if any(type(value) is not bool for value in (self.enabled, self.offroad, self.voltage_ok,
self.device_fresh, self.ignition_off)) or \
(self.sample is not None and not isinstance(self.sample, MotionSample)):
raise ValueError("Sentry authority inputs must be explicit booleans and a validated motion sample")
@dataclass(frozen=True)
class Decision:
state: str
event: str | None = None
seconds_remaining: int | None = None
class SentryPolicy:
def __init__(self, settings: Settings | None = None):
self.settings = settings if settings is not None else Settings()
self.last_now: float | None = None
self.arm_started: float | None = None
self.last_sample_time: float | None = None
self.previous_magnitude: float | None = None
self.trigger_count = 0
self.trigger_started: float | None = None
self.alarm_triggered = False
def _clear_motion(self) -> None:
self.last_sample_time = None
self.previous_magnitude = None
self.trigger_count = 0
self.trigger_started = None
self.alarm_triggered = False
def _disarm(self) -> None:
self.arm_started = None
self._clear_motion()
def update(self, now: float, inputs: Inputs) -> Decision:
if type(now) not in (int, float) or not math.isfinite(now) or now < 0:
self._disarm()
self.last_now = None
return Decision("unavailable")
if self.last_now is not None and now < self.last_now:
self._disarm()
self.last_now = now
return Decision("unavailable")
if self.last_now is not None and now - self.last_now > MAX_UPDATE_GAP_SECONDS:
self._disarm()
self.last_now = now
return Decision("unavailable")
self.last_now = now
if not inputs.enabled:
self._disarm()
return Decision("disabled")
if not inputs.offroad:
self._disarm()
return Decision("disabled_onroad")
if not inputs.ignition_off:
self._disarm()
return Decision("disabled_ignition")
if not inputs.voltage_ok:
self._disarm()
return Decision("low_voltage")
if not inputs.device_fresh:
self._disarm()
return Decision("unavailable")
sample = inputs.sample
if sample is None or sample.mono_time > now or now - sample.mono_time > MAX_SAMPLE_AGE_SECONDS or \
(self.last_sample_time is not None and sample.mono_time <= self.last_sample_time):
last_sample_time = self.last_sample_time
self._disarm()
# A duplicate may still be younger than the age limit on the next tick.
self.last_sample_time = last_sample_time
return Decision("sensor_unavailable")
self.last_sample_time = sample.mono_time
if self.arm_started is None:
self.arm_started = now
elapsed = now - self.arm_started
if elapsed < ARM_DELAY_SECONDS:
return Decision("arming", seconds_remaining=max(0, int(ARM_DELAY_SECONDS - elapsed)))
magnitude = math.hypot(*sample.acceleration)
previous = self.previous_magnitude
self.previous_magnitude = magnitude
if previous is None:
return Decision("armed")
# An expired motion window cannot turn into a late alarm on this sample.
if self.trigger_started is not None and now - self.trigger_started >= RESET_TIME_SECONDS:
self.trigger_count = 0
self.trigger_started = None
self.alarm_triggered = False
if abs(magnitude - previous) <= self.settings.sensitivity:
return Decision("armed")
self.trigger_count += 1
if self.trigger_started is None:
self.trigger_started = now
if self.trigger_count == self.settings.warning_trigger_count:
return Decision("armed", event="warning")
if self.trigger_count > ALARM_TRIGGER_COUNT and now - self.trigger_started >= ALARM_TIME_SECONDS and not self.alarm_triggered:
self.alarm_triggered = True
return Decision("armed", event="alarm")
return Decision("armed")
@@ -0,0 +1,82 @@
"""Strict optional saved Sentry motion policy; no legacy-key adoption."""
from dataclasses import dataclass, field
import json
import math
from typing import Any
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.sentry_mode.policy import Settings
KEY = "SentryMotionPreferences"
MAX_BYTES = 512
@dataclass(frozen=True)
class Preferences:
enabled: bool = False
settings: Settings = field(default_factory=Settings)
@dataclass(frozen=True)
class SavedPreferences:
raw: bytes | None
readable: bool
valid: bool
preferences: Preferences = field(default_factory=Preferences)
def _unique(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate Sentry preference")
result[key] = value
return result
def decode(raw: bytes) -> Preferences | None:
if len(raw) > MAX_BYTES:
return None
try:
data = json.loads(raw, object_pairs_hook=_unique,
parse_constant=lambda _: (_ for _ in ()).throw(ValueError("nonfinite")))
if not isinstance(data, dict) or set(data) != {"version", "enabled", "sensitivity", "warningTimeSeconds"}:
return None
if type(data["version"]) is not int or data["version"] != 1 or type(data["enabled"]) is not bool:
return None
sensitivity, warning = data["sensitivity"], data["warningTimeSeconds"]
if (type(sensitivity) not in (float, int) or type(warning) not in (float, int) or
not math.isfinite(sensitivity) or not math.isfinite(warning)):
return None
return Preferences(data["enabled"], Settings(float(sensitivity), float(warning)))
except (UnicodeDecodeError, ValueError, TypeError, OverflowError, RecursionError):
return None
def encode(preferences: Preferences) -> bytes:
if not isinstance(preferences, Preferences) or type(preferences.enabled) is not bool:
raise ValueError("invalid Sentry preferences")
settings = Settings(preferences.settings.sensitivity, preferences.settings.warning_time_seconds)
data = {"version": 1, "enabled": preferences.enabled,
"sensitivity": settings.sensitivity, "warningTimeSeconds": settings.warning_time_seconds}
raw = json.dumps(data, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
if len(raw) > MAX_BYTES:
raise ValueError("oversized Sentry preferences")
return raw
def read_preferences(params: Any) -> SavedPreferences:
raw, readable = read_saved(params, KEY, MAX_BYTES)
if not readable:
return SavedPreferences(raw, False, False)
if raw is None:
return SavedPreferences(None, True, True)
preferences = decode(raw)
return SavedPreferences(raw, True, preferences is not None, preferences or Preferences())
def enabled(params: Any) -> bool:
saved = read_preferences(params)
return saved.readable and saved.valid and saved.preferences.enabled
+215
View File
@@ -0,0 +1,215 @@
"""Default-off parked motion observation; hardwared and sensord retain ownership."""
from collections.abc import Callable
import math
import os
import signal
import threading
import time
from typing import Any
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.starpilot.parked_evidence import ParkedEvidence, RESUME_SKEW_NS, fresh_parked
from openpilot.starpilot.power.offroad_preferences import read_saved as read_power, effective as effective_power
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.sentry_mode.policy import Inputs, MotionSample, SentryPolicy
from openpilot.starpilot.sentry_mode.preferences import read_preferences
from openpilot.starpilot.sentry_mode.status import RuntimeStatus
PERIPHERAL_TTL_NS = 1_500_000_000 # C++ 2 Hz publisher; one missed frame.
SAMPLE_TTL_NS = 500_000_000
TICK_SECONDS = 0.1
def boot_time_ns() -> int:
return time.clock_gettime_ns(getattr(time, "CLOCK_BOOTTIME", time.CLOCK_MONOTONIC))
class SentryRuntime:
def __init__(self, params: Params, messages: Any, *, mono_clock: Callable[[], int] = time.monotonic_ns,
boot_clock: Callable[[], int] = boot_time_ns,
record: Callable[[str, int, Callable[[], bool]], Any] | None = None, status: RuntimeStatus | None = None):
self.params, self.messages = params, messages
self.mono_clock, self.boot_clock = mono_clock, boot_clock
self.record = record
self.status = status
self.policy = SentryPolicy()
self.saved_raw: bytes | None = None
self.device_after_mono_ns = mono_clock()
self.sensor_after_mono_ns = self.device_after_mono_ns
self.device_offset_ns: int | None = None
self.last_pair_offset_ns: int | None = None
self.last_sample_ns = 0
self.recording_state = "idle"
def _recording_state(self, state: str) -> None:
if state != self.recording_state:
self.recording_state = state
if state not in ("idle", "stored"):
cloudlog.warning(f"sentry motion recording: {state}")
def _clocks(self) -> tuple[int, int] | None:
before = self.mono_clock()
boot = self.boot_clock()
after = self.mono_clock()
if after < before or after - before > RESUME_SKEW_NS:
self.device_after_mono_ns = max(self.device_after_mono_ns, after)
self.sensor_after_mono_ns = max(self.sensor_after_mono_ns, after)
self.device_offset_ns = None
self.policy = SentryPolicy(self.policy.settings)
return None
offset = boot - (before + after) // 2
if self.last_pair_offset_ns is not None and abs(offset - self.last_pair_offset_ns) > RESUME_SKEW_NS:
self.device_after_mono_ns = max(self.device_after_mono_ns, after)
self.sensor_after_mono_ns = max(self.sensor_after_mono_ns, after)
self.device_offset_ns = None
self.policy = SentryPolicy(self.policy.settings)
self.last_pair_offset_ns = offset
return after, boot
def _fresh(self, name: str, *, now_mono_ns: int, now_boot_ns: int, ttl_ns: int) -> bool:
sm = self.messages
stamp = int(sm.logMonoTime[name])
receipt = int(sm.recv_time[name] * 1e9)
if not (sm.seen[name] and sm.alive[name] and sm.valid[name] and stamp > 0 and receipt > 0):
return False
# C++ pandad stamps BOOTTIME; receipt is SubMaster MONOTONIC.
age = now_boot_ns - stamp
return 0 <= age <= ttl_ns and 0 <= now_mono_ns - receipt <= ttl_ns
def _authority(self) -> tuple[bool, bool, bool, bool, int]:
"""Return offroad, ignition-off, voltage-safe, device-fresh, current MONO ns."""
clocks = self._clocks()
if clocks is None:
return False, False, False, False, self.mono_clock()
now_ns, boot_ns = clocks
sm = self.messages
offroad, offroad_readable = read_saved(self.params, "IsOffroad", 8)
shutdown, shutdown_readable = read_saved(self.params, "DoShutdown", 8)
manager_offroad = offroad_readable and offroad == b"1" and shutdown_readable and shutdown in (None, b"0")
try:
pandas = sm["pandaStates"]
ignition = tuple(bool(p.ignitionLine or p.ignitionCan) for p in pandas)
known_pandas = bool(pandas) and all(str(p.pandaType) != "unknown" for p in pandas)
ignition_off = known_pandas and not any(ignition)
if sm.updated["deviceState"] and int(sm.logMonoTime["deviceState"]) > self.device_after_mono_ns:
self.device_offset_ns = self.last_pair_offset_ns
evidence = ParkedEvidence(
manager_offroad, bool(sm.seen["deviceState"]), bool(sm.alive["deviceState"]), bool(sm.valid["deviceState"]),
bool(sm["deviceState"].started), int(sm.logMonoTime["deviceState"]),
int(sm.recv_time["deviceState"] * 1e9), self.device_offset_ns, self.device_after_mono_ns,
bool(sm.seen["pandaStates"]), bool(sm.alive["pandaStates"]), bool(sm.valid["pandaStates"]),
int(sm.logMonoTime["pandaStates"]), int(sm.recv_time["pandaStates"] * 1e9), ignition,
)
device_fresh = known_pandas and fresh_parked(evidence, now_mono_ns=now_ns, now_boot_ns=boot_ns)
peripheral = sm["peripheralState"]
power_saved = read_power(self.params)
cutoff_mv = effective_power(power_saved).cutoff_tenths * 100
voltage_ok = (power_saved.readable and power_saved.valid and self._fresh("peripheralState", now_mono_ns=now_ns,
now_boot_ns=boot_ns, ttl_ns=PERIPHERAL_TTL_NS) and
str(peripheral.pandaType) != "unknown" and int(peripheral.voltage) > cutoff_mv and
int(sm["deviceState"].carBatteryCapacityUwh) > 0)
return bool(manager_offroad and not sm["deviceState"].started), ignition_off, bool(voltage_ok), device_fresh, now_ns
except (AttributeError, KeyError, TypeError, ValueError, OverflowError, OSError, RuntimeError):
return bool(manager_offroad), False, False, False, now_ns
def _sample(self, now_ns: int) -> MotionSample | None:
sm = self.messages
try:
if not (sm.updated["accelerometer"] and sm.seen["accelerometer"] and sm.alive["accelerometer"] and
sm.valid["accelerometer"]):
return None
event = sm["accelerometer"]
stamp = int(event.timestamp)
envelope = int(sm.logMonoTime["accelerometer"])
receipt = int(sm.recv_time["accelerometer"] * 1e9)
if (stamp <= max(self.last_sample_ns, self.sensor_after_mono_ns) or not 0 < stamp <= envelope <= now_ns or
now_ns - stamp > SAMPLE_TTL_NS or now_ns - envelope > SAMPLE_TTL_NS or
not 0 <= now_ns - receipt <= SAMPLE_TTL_NS):
return None
values = tuple(float(v) for v in event.acceleration.v)
if len(values) != 3 or any(not math.isfinite(v) for v in values):
return None
sample = MotionSample(stamp / 1e9, values)
self.last_sample_ns = stamp
return sample
except (AttributeError, KeyError, TypeError, ValueError, OverflowError):
return None
def _permitted(self, expected_raw: bytes) -> bool:
self.messages.update(0)
offroad, ignition_off, voltage_ok, fresh, now_ns = self._authority()
self._sample(now_ns)
saved = read_preferences(self.params)
return (saved.readable and saved.valid and saved.preferences.enabled and saved.raw == expected_raw and
offroad and ignition_off and voltage_ok and fresh and
self.policy.arm_started is not None and self.policy.last_sample_time is not None and
0 <= now_ns - self.last_sample_ns <= SAMPLE_TTL_NS)
def tick(self) -> Any:
self.messages.update(0)
saved = read_preferences(self.params)
active = (saved.readable and saved.valid and
saved.preferences.enabled)
if saved.raw != self.saved_raw:
self.policy = SentryPolicy(saved.preferences.settings)
self.saved_raw = saved.raw
offroad, ignition_off, voltage_ok, fresh, now_ns = self._authority()
sample = self._sample(now_ns) if active else None
decision = self.policy.update(now_ns / 1e9, Inputs(active, offroad, voltage_ok, fresh, ignition_off, sample))
expected_raw = saved.raw
if decision.event is not None and self.record is not None and expected_raw is not None and self._permitted(expected_raw):
try:
receipt = self.record(decision.event, now_ns, lambda: self._permitted(expected_raw))
self._recording_state("stored" if getattr(receipt, "durable", False) else "durability_unknown")
except Exception:
# No retry: storage may have published before its durability result failed.
self._recording_state("unavailable")
if self.status is not None:
self.status.publish(decision, saved.raw, self.recording_state)
return decision
def close(self) -> None:
for socket in getattr(self.messages, "sock", {}).values():
close = getattr(socket, "close", None)
if close is not None:
close()
def main() -> None:
from openpilot.cereal import messaging
from openpilot.starpilot.sentry_mode.storage import EventStore
stop = threading.Event()
previous = {number: signal.getsignal(number) for number in (signal.SIGINT, signal.SIGTERM)}
for number in previous:
signal.signal(number, lambda _signal, _frame: stop.set())
messages = messaging.SubMaster(["accelerometer", "deviceState", "pandaStates", "peripheralState"])
store = EventStore()
from openpilot.starpilot.galaxy.camera_snapshot import CameraSnapshot, SnapshotUnavailable
camera = CameraSnapshot()
def record(kind, stamp, permitted):
images = {}
for name in ("wide", "cabin"):
try:
images[name] = camera.capture(name, permitted=permitted)
except SnapshotUnavailable:
if not permitted():
raise
return store.record(kind, stamp, permitted=permitted, images=images)
runtime = SentryRuntime(Params(), messages, record=record, status=RuntimeStatus())
try:
while not stop.is_set():
started = time.monotonic()
runtime.tick()
stop.wait(max(0.0, TICK_SECONDS - (time.monotonic() - started)))
finally:
runtime.close()
for number, handler in previous.items():
signal.signal(number, handler)
if __name__ == "__main__":
main()
+113
View File
@@ -0,0 +1,113 @@
"""Expiring, namespace-bound motion-owner status; never grants runtime authority."""
import hashlib
import json
import os
from pathlib import Path
import time
import uuid
from openpilot.common.hardware.hw import Paths
from openpilot.starpilot.parked_evidence import RESUME_SKEW_NS
from openpilot.starpilot.sentry_mode.preferences import SavedPreferences
from openpilot.starpilot.sentry_mode.storage import EventStore, FILE_FLAGS, StorageUnavailable, _private
from openpilot.starpilot.storage import starpilot_storage_root
def boot_clock():
return time.clock_gettime_ns(getattr(time, "CLOCK_BOOTTIME", time.CLOCK_MONOTONIC))
TTL_NS = 1_500_000_000
STATES = {
"disabled": ("Off", "Motion monitoring is disabled"),
"disabled_onroad": ("Not armed", "The car is on or the device is shutting down"),
"disabled_ignition": ("Not armed", "Waiting for confirmation that the ignition is off"),
"low_voltage": ("Not armed", "Battery is low or its charge cannot be checked"),
"unavailable": ("Waiting", "Checking that the car is parked"),
"sensor_unavailable": ("Waiting for motion sensor", "No recent sensor readings"),
"arming": ("Arming", "Monitoring begins after 90 seconds parked"),
"armed": ("Monitoring motion", "Watching for movement while parked"),
}
def boot_id() -> str:
try:
return str(uuid.UUID(Path("/proc/sys/kernel/random/boot_id").read_text().strip()))
except (OSError, ValueError):
return ""
class RuntimeStatus(EventStore):
def __init__(self, root: Path | None = None, *, clock=time.monotonic_ns, boot=boot_id, boot_clock=boot_clock):
namespace = hashlib.sha256(str(starpilot_storage_root()).encode()).hexdigest()[:24]
super().__init__(root if root is not None else Path(Paths.shm_path()) / f"starpilot-sentry-{namespace}")
self.clock, self.boot, self.boot_clock = clock, boot, boot_clock
self.last_published = None
self.last_value = None
def publish(self, decision, source: bytes | None, recording: str) -> None:
identity = self.boot()
if not identity or decision.state not in STATES:
return
current = (decision, source, recording)
now = self.clock()
if current == self.last_value and self.last_published is not None and 0 <= now - self.last_published < 500_000_000:
return
value = {"state": decision.state, "remaining": decision.seconds_remaining,
"source": hashlib.sha256(source or b"").hexdigest(), "boot": identity,
"stamp": now, "bootStamp": self.boot_clock(), "recording": recording}
raw = json.dumps(value, separators=(",", ":")).encode()
temporary = f".{uuid.uuid4().hex}"
try:
with self._directory(create=True) as directory:
try:
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
with os.fdopen(fd, "wb") as stream:
stream.write(raw)
os.replace(temporary, "status.json", src_dir_fd=directory, dst_dir_fd=directory)
self.last_published, self.last_value = now, current
finally:
try:
os.unlink(temporary, dir_fd=directory)
except FileNotFoundError:
pass
except (OSError, StorageUnavailable):
pass
def snapshot(self, saved: SavedPreferences) -> tuple[str, str]:
if not saved.readable or not saved.valid:
return "Unavailable", "Repair the saved motion settings before monitoring"
if not saved.preferences.enabled:
return STATES["disabled"]
try:
with self._directory() as directory:
fd = os.open("status.json", FILE_FLAGS, dir_fd=directory)
with os.fdopen(fd, "rb") as stream:
if not _private(os.fstat(stream.fileno())):
raise ValueError("Unsafe status")
raw = stream.read(1025)
if len(raw) > 1024:
raise ValueError("Oversized status")
value = json.loads(raw)
now = self.clock()
boot_now = self.boot_clock()
if (type(value) is not dict or type(value.get("stamp")) is not int or
type(value.get("bootStamp")) is not int or
not 0 <= boot_now - value["bootStamp"] <= TTL_NS or
abs((boot_now - value["bootStamp"]) - (now - value["stamp"])) > RESUME_SKEW_NS or
not 0 <= now - value["stamp"] <= TTL_NS or not self.boot() or value.get("boot") != self.boot() or
value.get("source") != hashlib.sha256(saved.raw or b"").hexdigest() or
value.get("state") not in STATES):
raise ValueError("Stale status")
label, reason = STATES[value["state"]]
if value["state"] == "arming":
remaining = value.get("remaining")
if type(remaining) is not int or not 0 <= remaining <= 90:
raise ValueError("Invalid countdown")
label = f"Arming · {remaining}s"
if value.get("recording") in ("unavailable", "durability_unknown"):
reason += "; motion events could not be saved reliably"
return label, reason
except (OSError, StorageUnavailable, ValueError, TypeError, KeyError, RecursionError):
return "Waiting for monitor", "The monitor has not responded recently"
+285
View File
@@ -0,0 +1,285 @@
"""Bounded durable Sentry events and captured camera evidence."""
from collections.abc import Callable
from contextlib import contextmanager
from dataclasses import dataclass
import fcntl
import json
import os
from pathlib import Path
import re
import stat
import time
import uuid
from openpilot.starpilot.storage import starpilot_storage_root
MAX_EVENTS = 512
MAX_EVENT_BYTES = 512
ID = re.compile(r"[0-9a-f]{32}\Z")
FIELDS = {"version", "eventId", "sessionId", "kind", "monoTimeNs", "wallTimeNs"}
DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
class StorageUnavailable(Exception):
"""No event was published by this call; recording cannot continue."""
@dataclass(frozen=True)
class RecordReceipt:
event_id: str
# False means published but persistence across power loss was not confirmed.
durable: bool
def _private(info: os.stat_result, *, directory: bool = False) -> bool:
return (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode) and info.st_nlink == 1) and \
info.st_uid == os.geteuid() and info.st_mode & 0o077 == 0
def _valid(value: object, event_id: str) -> bool:
if type(value) is not dict:
return False
if any(type(key) is not str for key in value):
return False
fields = {key: item for key, item in value.items() if type(key) is str}
if set(fields) != FIELDS or ID.fullmatch(event_id) is None:
return False
version = fields["version"]
session_id = fields["sessionId"]
mono_time = fields["monoTimeNs"]
wall_time = fields["wallTimeNs"]
return (type(version) is int and version == 1 and
fields["eventId"] == event_id and
type(session_id) is str and ID.fullmatch(session_id) is not None and
fields["kind"] in ("warning", "alarm") and
type(mono_time) is int and 0 <= mono_time < 2**63 and
type(wall_time) is int and 0 <= wall_time < 2**63)
def _unique(pairs: list[tuple[str, object]]) -> dict:
result: dict = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate event field")
result[key] = value
return result
class EventStore:
def __init__(self, root: Path | None = None):
self.root = root if root is not None else starpilot_storage_root() / "sentry/events"
self.session_id = uuid.uuid4().hex
def _open_directory(self, *, create: bool = False) -> int:
# Traverse from an already-open filesystem root. mkdir(parents=True) would
# follow a replaced ancestor before the final O_NOFOLLOW check.
if not self.root.is_absolute() or ".." in self.root.parts:
raise StorageUnavailable("Event directory path is unavailable")
fd = os.open("/", DIR_FLAGS)
try:
for component in self.root.parts[1:]:
if create:
try:
os.mkdir(component, mode=0o700, dir_fd=fd)
except FileExistsError:
pass
child = os.open(component, DIR_FLAGS, dir_fd=fd)
os.close(fd)
fd = child
return fd
except BaseException:
os.close(fd)
raise
def _same_directory(self, fd: int) -> bool:
try:
current = self._open_directory()
try:
opened_info, current_info = os.fstat(fd), os.fstat(current)
return (opened_info.st_dev, opened_info.st_ino) == (current_info.st_dev, current_info.st_ino)
finally:
os.close(current)
except OSError:
return False
@contextmanager
def _directory(self, *, create: bool = False):
fd = self._open_directory(create=create)
try:
info = os.fstat(fd)
if not _private(info, directory=True) or not self._same_directory(fd):
raise StorageUnavailable("Event directory is unavailable")
yield fd
finally:
os.close(fd)
def record(self, kind: str, mono_time_ns: int, *, permitted: Callable[[], bool], images: dict | None = None) -> RecordReceipt:
event_id = uuid.uuid4().hex
value = {"version": 1, "eventId": event_id, "sessionId": self.session_id, "kind": kind,
"monoTimeNs": mono_time_ns, "wallTimeNs": time.time_ns()}
if not _valid(value, event_id):
raise ValueError("Invalid Sentry event")
raw = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
if len(raw) > MAX_EVENT_BYTES:
raise ValueError("Sentry event exceeds its storage limit")
if not permitted():
raise StorageUnavailable("Sentry is no longer armed")
published = False
try:
with self._directory(create=True) as directory:
lock = os.open(".lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600, dir_fd=directory)
try:
if not _private(os.fstat(lock)):
raise StorageUnavailable("Event lock is unavailable")
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
# Unknown entries and interrupted writes also consume capacity. Never
# delete evidence, or scan an unbounded directory to admit a new event.
count = 0
with os.scandir(directory) as entries:
for entry in entries:
if ID.fullmatch(entry.name) and entry.is_dir(follow_symlinks=False):
try:
metadata = os.stat(entry.name + ".json", dir_fd=directory, follow_symlinks=False)
if _private(metadata):
continue # Evidence directory and published metadata are one event.
except OSError:
pass # Interrupted evidence still consumes capacity.
if entry.name != ".lock":
count += 1
if count >= MAX_EVENTS:
raise StorageUnavailable("Event storage is full")
for camera, body in (images or {}).items():
if camera not in ("wide", "cabin") or not isinstance(body, bytes) or not 4 <= len(body) <= 1_000_000 or not body.startswith(b"\xff\xd8") or not body.endswith(b"\xff\xd9"):
raise ValueError("Invalid event image")
if images:
os.mkdir(event_id, mode=0o700, dir_fd=directory)
image_directory = os.open(event_id, DIR_FLAGS, dir_fd=directory)
try:
for camera, body in images.items():
fd = os.open(camera + ".jpg", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=image_directory)
with os.fdopen(fd, "wb") as output:
output.write(body)
output.flush()
os.fsync(output.fileno())
os.fsync(image_directory)
finally:
os.close(image_directory)
temporary = f".{event_id}.tmp"
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
try:
with os.fdopen(fd, "wb") as output:
output.write(raw)
output.flush()
os.fsync(output.fileno())
if not self._same_directory(directory) or not permitted():
raise StorageUnavailable("Sentry authority or event directory changed")
# An atomic no-replace publication: a coincident ID cannot overwrite
# an existing event, including an unexpected symlink.
os.link(temporary, event_id + ".json", src_dir_fd=directory, dst_dir_fd=directory, follow_symlinks=False)
published = True
finally:
os.unlink(temporary, dir_fd=directory)
os.fsync(directory)
finally:
os.close(lock)
except (OSError, StorageUnavailable) as error:
if published:
return RecordReceipt(event_id, durable=False)
raise StorageUnavailable(str(error)) from error
return RecordReceipt(event_id, durable=True)
def snapshot(self) -> dict:
result: dict = {"version": 1, "events": [], "incomplete": False, "capacity": MAX_EVENTS}
try:
with self._directory() as directory:
with os.scandir(directory) as entries:
count = 0
for entry in entries:
if entry.name == ".lock" or (ID.fullmatch(entry.name) and entry.is_dir(follow_symlinks=False)):
continue
count += 1
if count > MAX_EVENTS:
result["incomplete"] = True
break
event_id = entry.name.removesuffix(".json")
if entry.name != event_id + ".json" or ID.fullmatch(event_id) is None:
result["incomplete"] = True
continue
try:
fd = os.open(entry.name, FILE_FLAGS, dir_fd=directory)
try:
if not _private(os.fstat(fd)):
raise ValueError("Invalid event file")
raw = os.read(fd, MAX_EVENT_BYTES + 1)
finally:
os.close(fd)
if len(raw) > MAX_EVENT_BYTES:
raise ValueError("Event too large")
value = json.loads(raw, object_pairs_hook=_unique)
if not _valid(value, event_id):
raise ValueError("Invalid event metadata")
result["events"].append(value)
except (OSError, ValueError, TypeError, RecursionError):
result["incomplete"] = True
if not self._same_directory(directory):
raise StorageUnavailable("Event directory changed")
except FileNotFoundError:
return result
except OSError as error:
raise StorageUnavailable("Event storage unavailable") from error
result["events"].sort(key=lambda event: (event["wallTimeNs"], event["eventId"]), reverse=True)
return result
def image(self, event_id: str, camera: str) -> bytes:
if ID.fullmatch(event_id) is None or camera not in ("wide", "cabin"):
raise StorageUnavailable("Unknown event image")
with self._directory() as directory:
# Only published events can expose images.
event_fd = os.open(event_id + ".json", FILE_FLAGS, dir_fd=directory)
try:
if not _private(os.fstat(event_fd)):
raise StorageUnavailable("Invalid event")
finally:
os.close(event_fd)
images = os.open(event_id, DIR_FLAGS, dir_fd=directory)
try:
if not _private(os.fstat(images), directory=True):
raise StorageUnavailable("Invalid image directory")
fd = os.open(camera + ".jpg", FILE_FLAGS, dir_fd=images)
try:
if not _private(os.fstat(fd)):
raise StorageUnavailable("Invalid image")
body = os.read(fd, 1_000_001)
finally:
os.close(fd)
finally:
os.close(images)
if not 4 <= len(body) <= 1_000_000 or not body.startswith(b"\xff\xd8") or not body.endswith(b"\xff\xd9"):
raise StorageUnavailable("Invalid image")
return body
def images(self, event_id: str) -> list[str]:
if ID.fullmatch(event_id) is None:
return []
try:
with self._directory() as directory:
images = os.open(event_id, DIR_FLAGS, dir_fd=directory)
try:
if not _private(os.fstat(images), directory=True):
return []
result = []
for camera in ("wide", "cabin"):
try:
info = os.stat(camera + ".jpg", dir_fd=images, follow_symlinks=False)
if _private(info) and 4 <= info.st_size <= 1_000_000:
result.append(camera)
except OSError:
pass
return result
finally:
os.close(images)
except (OSError, StorageUnavailable):
return []
@@ -0,0 +1,37 @@
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from openpilot.common.params import Params
from openpilot.starpilot.sentry_mode.preferences import KEY, Preferences, encode
from openpilot.system.manager import process_config
class TestSentryManagerPredicate(unittest.TestCase):
def test_device_and_pc_matrix_requires_enabled_and_valid_saved_document(self):
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, PARAMS_ROOT=directory, OPENPILOT_PREFIX="sentrymanager",
STARPILOT_SENTRY_DEVELOPMENT="0"):
params = Params()
process = process_config.managed_processes["sentry_motion"]
sensor = process_config.managed_processes["sensord"]
self.assertEqual(process.enabled, not process_config.PC)
self.assertEqual(sensor.enabled, not process_config.PC)
with patch.dict(os.environ, STARPILOT_SENTRY_DEVELOPMENT="1"):
self.assertFalse(process_config.sentry_motion(False, params, object()))
self.assertFalse(process_config.sensord_run(False, params, object()))
self.assertTrue(process_config.sensord_run(True, params, object()))
source = Path(params.get_param_path(KEY))
source.write_bytes(b"invalid")
self.assertFalse(process_config.sentry_motion(False, params, object()))
source.write_bytes(encode(Preferences(True)))
self.assertTrue(process_config.sentry_motion(False, params, object()))
self.assertTrue(process_config.sensord_run(False, params, object()))
self.assertFalse(process_config.sentry_motion(True, params, object()))
self.assertTrue(process_config.sentry_motion(False, params, object()))
self.assertTrue(process_config.sensord_run(False, params, object()))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,214 @@
"""Private credentials, actual HTTP contracts, durable retries and RFC push crypto."""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import struct
import threading
from types import SimpleNamespace
from urllib.parse import parse_qs
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
import pytest
from openpilot.starpilot.sentry_mode.notifications import NotificationOwner, NotificationUnavailable, CHANNELS
from openpilot.starpilot.sentry_mode import web_push
def store(events=()):
return SimpleNamespace(snapshot=lambda: {'events': list(events)})
def configure(owner, channel, url='https://example.test/private-secret', enabled=True):
return owner.action({'action': 'configure', 'channel': channel, 'enabled': enabled, 'url': url, 'token': 'private-token'})
def test_private_configuration_redaction_validation_and_exclusive_owner(tmp_path):
owner = NotificationOwner(tmp_path / 'notifications', store())
try:
snapshot = configure(owner, 'ntfy')
assert snapshot['channels']['ntfy']['enabled'] and snapshot['channels']['ntfy']['configured']
assert 'private-secret' not in json.dumps(snapshot) and 'private-token' not in json.dumps(snapshot)
assert (tmp_path / 'notifications/state.json').stat().st_mode & 0o777 == 0o600
duplicate = NotificationOwner(tmp_path / 'notifications', store())
with pytest.raises(NotificationUnavailable):
duplicate.snapshot()
duplicate.close()
for url in ('http://example.test/a', 'https://u:p@example.test', 'https://example.test/#x', 'https://example.test/\n'):
with pytest.raises(ValueError):
configure(owner, 'webhook', url)
before = owner.snapshot()
with pytest.raises(PermissionError):
configure_kwargs = {'action': 'forget', 'channel': 'ntfy'}
owner.action(configure_kwargs, permitted=lambda: False)
assert owner.snapshot() == before
finally:
owner.close()
def test_event_scan_durable_dedupe_retry_and_disable(tmp_path):
clock = [100.]
calls = []
outcomes = [503, 202]
events = []
def transport(*args):
calls.append(args)
return outcomes.pop(0)
root = tmp_path / 'notifications'
owner = NotificationOwner(root, store(events), clock=lambda: clock[0], transport=transport)
configure(owner, 'webhook')
events.append({'eventId': 'a'*32, 'kind': 'alarm', 'wallTimeNs': int(101e9)})
clock[0] = 101.
owner.tick()
assert len(calls) == 1 and owner.snapshot()['channels']['webhook']['lastError'] == 'http'
owner.close()
owner = NotificationOwner(root, store(events), clock=lambda: clock[0], transport=transport)
owner.tick()
assert len(calls) == 1
clock[0] = 132.
owner.tick()
assert len(calls) == 2 and calls[0][4] == calls[1][4]
assert owner.snapshot()['channels']['webhook']['lastState'] == 'sent'
owner.tick()
assert len(calls) == 2
configure(owner, 'webhook', enabled=False)
events.append({'eventId': 'b'*32, 'kind': 'warning', 'wallTimeNs': int(133e9)})
clock[0] = 133.
owner.tick()
assert len(calls) == 2
owner.close()
def test_real_http_contracts_and_independent_channels(tmp_path):
received = []
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
received.append((self.path, self.headers, self.rfile.read(int(self.headers['Content-Length']))))
self.send_response(200)
self.send_header('Content-Length', '100000000')
self.end_headers()
self.wfile.write(b'x') # Sender must read status only, not this unbounded/incomplete body.
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
owner = NotificationOwner(tmp_path / 'notifications', store(), allow_http=True)
try:
for name in ('discord', 'webhook', 'ntfy'):
configure(owner, name, f'http://127.0.0.1:{server.server_port}/{name}')
owner.action({'action': 'test', 'channel': name})
owner.action({'action': 'pushKey'})
receiver = ec.generate_private_key(ec.SECP256R1())
owner.action({'action': 'subscribe', 'subscription': {'endpoint': f'http://127.0.0.1:{server.server_port}/push',
'keys': {'p256dh': web_push.b64(web_push.public(receiver)), 'auth': web_push.b64(b'0123456789abcdef')}}})
owner.action({'action': 'configure', 'channel': 'webPush', 'enabled': True, 'url': '', 'token': ''})
owner.action({'action': 'test', 'channel': 'webPush'})
for _ in range(4):
owner.tick()
assert len(received) == 4
paths = {path: (headers, body) for path, headers, body in received}
assert json.loads(paths['/discord'][1])['content'].startswith('StarPilot Sentry Mode:')
assert json.loads(parse_qs(paths['/webhook'][1].decode())['event'][0])['kind'] == 'test'
assert paths['/ntfy'][0]['Priority'] == 'urgent'
assert paths['/ntfy'][1].startswith(b'This is a test')
for path, (headers, _) in paths.items():
assert len(headers['Idempotency-Key']) == 64
if path != '/push':
assert headers['Authorization'] == 'Bearer private-token'
assert paths['/push'][0]['Authorization'].startswith('vapid t=')
assert paths['/push'][0]['Content-Encoding'] == 'aes128gcm'
assert len(paths['/push'][1]) > 100 and b'This is a test' not in paths['/push'][1]
assert all(owner.snapshot()['channels'][name]['lastState'] == 'sent' for name in CHANNELS)
finally:
owner.close()
server.shutdown()
server.server_close()
thread.join(timeout=5)
def test_push_encrypts_decryptable_payload_and_valid_signed_vapid():
receiver = ec.generate_private_key(ec.SECP256R1())
auth = b'0123456789abcdef'
subscription = {'endpoint': 'https://push.example.test/private', 'keys': {'p256dh': web_push.b64(web_push.public(receiver)), 'auth': web_push.b64(auth)}}
pem = web_push.new_key()
message = {'eventId': 'a'*32, 'body': 'Parked motion detected'}
body, headers = web_push.request(subscription, message, pem, now=100)
salt, rs, key_length = body[:16], struct.unpack('!I', body[16:20])[0], body[20]
assert rs == 4096 and key_length == 65
sender_public = body[21:86]
shared = receiver.exchange(ec.ECDH(), ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), sender_public))
ikm = HKDF(algorithm=hashes.SHA256(), length=32, salt=auth, info=b'WebPush: info\x00' + web_push.public(receiver) + sender_public).derive(shared)
cek = HKDF(algorithm=hashes.SHA256(), length=16, salt=salt, info=b'Content-Encoding: aes128gcm\x00').derive(ikm)
nonce = HKDF(algorithm=hashes.SHA256(), length=12, salt=salt, info=b'Content-Encoding: nonce\x00').derive(ikm)
plaintext = AESGCM(cek).decrypt(nonce, body[86:], None)
assert plaintext[-1] == 2 and json.loads(plaintext[:-1]) == message
assert b'Parked motion' not in body
token = headers['Authorization'].split('t=', 1)[1].split(',', 1)[0]
head, claims, sig = token.split('.')
claim_values = json.loads(web_push.unb64(claims))
assert claim_values['aud'] == 'https://push.example.test' and claim_values['exp'] == 43300
raw_signature = web_push.unb64(sig)
der = utils.encode_dss_signature(int.from_bytes(raw_signature[:32], 'big'), int.from_bytes(raw_signature[32:], 'big'))
web_push.load_key(pem).public_key().verify(der, (head + '.' + claims).encode(), ec.ECDSA(hashes.SHA256()))
def test_bounded_retry_and_expired_push_subscription(tmp_path):
clock = [100.]
calls = []
owner = NotificationOwner(tmp_path / 'notifications', store(), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 503)
try:
configure(owner, 'ntfy')
owner.action({'action': 'test', 'channel': 'ntfy'})
for stamp in (100., 130., 250., 850., 2000.):
clock[0] = stamp
owner.tick()
assert len(calls) == 4 and owner.snapshot()['channels']['ntfy']['lastState'] == 'failed'
owner.action({'action': 'pushKey'})
receiver = ec.generate_private_key(ec.SECP256R1())
owner.action({'action': 'subscribe', 'subscription': {'endpoint': 'https://push.example.test/private',
'keys': {'p256dh': web_push.b64(web_push.public(receiver)), 'auth': web_push.b64(b'0123456789abcdef')}}})
owner.action({'action': 'configure', 'channel': 'webPush', 'enabled': True, 'url': '', 'token': ''})
owner.action({'action': 'test', 'channel': 'webPush'})
owner.transport = lambda *args: 410
owner.tick()
assert owner.snapshot()['subscriptionCount'] == 0
assert owner.snapshot()['channels']['webPush']['lastError'] == 'expired'
finally:
owner.close()
def test_published_rfc8291_section5_exact_ciphertext():
# Independent published wire vector, https://www.rfc-editor.org/rfc/rfc8291#section-5
subscription = {'endpoint': 'https://push.example.net/push/test', 'keys': {
'auth': 'BTBZMqHH6r4Tts7J_aSIgg',
'p256dh': 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'}}
sender = ec.derive_private_key(int.from_bytes(web_push.unb64('yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw'), 'big'), ec.SECP256R1())
expected = web_push.unb64('DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml' +
'mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT' +
'pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN')
actual = web_push.encrypt(subscription, b'When I grow up, I want to be a watermelon', sender=sender, salt=expected[:16])
assert actual == expected
@pytest.mark.parametrize('broken', [None, [], {}, 'bad', float('nan')])
def test_corrupt_saved_generation_and_timestamp_never_repaired_or_sent(tmp_path, broken):
root = tmp_path / 'notifications'
owner = NotificationOwner(root, store())
configure(owner, 'webhook')
owner.close()
path = root / 'state.json'
value = json.loads(path.read_text())
value['channels']['webhook']['generation'] = broken
original = json.dumps(value).encode()
path.write_bytes(original)
calls = []
owner = NotificationOwner(root, store(), transport=lambda *args: calls.append(args) or 202)
try:
with pytest.raises(NotificationUnavailable):
owner.snapshot()
owner.tick()
assert calls == [] and path.read_bytes() == original
finally:
owner.close()
@@ -0,0 +1,140 @@
"""Frozen numeric motion behavior and fail-closed parked policy boundaries."""
from unittest.mock import Mock
import pytest
from openpilot.starpilot.sentry_mode.policy import (
Inputs, MotionSample, SentryPolicy, Settings,
)
def active(sample: MotionSample | None = None) -> Inputs:
return Inputs(enabled=True, offroad=True, voltage_ok=True, device_fresh=True, ignition_off=True, sample=sample)
def motion(now: float, z: float) -> MotionSample:
return MotionSample(now, (0.0, 0.0, z))
def fresh_through(policy: SentryPolicy, start: float, end: float, z: float = 9.8):
result = None
for tenth in range(round(start * 10) + 1, round(end * 10) + 1):
now = tenth / 10
result = policy.update(now, active(motion(now, z)))
return result
def armed(policy: SentryPolicy) -> None:
assert policy.update(0.0, active(motion(0.0, 9.8))).state == "arming"
assert fresh_through(policy, 0.0, 89.9).state == "arming"
assert policy.update(90.0, active(motion(90.0, 9.8))).state == "armed"
def test_frozen_arming_warning_and_alarm_numeric_sequence():
policy = SentryPolicy(Settings(sensitivity=0.1, warning_time_seconds=0.3))
armed(policy)
assert policy.settings.warning_trigger_count == 3
assert policy.update(90.1, active(motion(90.1, 10.0))).event is None
assert policy.update(90.2, active(motion(90.2, 9.8))).event is None
assert policy.update(90.3, active(motion(90.3, 10.0))).event == "warning"
assert policy.update(90.4, active(motion(90.4, 9.8))).event is None
# The frozen detector requires strictly more than 25 changes and 30 s from
# the first change. Feed 10 Hz fresh stable samples while that time elapses.
for index in range(5, 26):
now = 90.0 + index * 0.1
assert policy.update(now, active(motion(now, 10.0 if index % 2 else 9.8))).event is None
assert policy.trigger_count == 25
assert fresh_through(policy, 92.5, 120.0, 10.0).event is None
assert policy.update(120.1, active(motion(120.1, 9.8))).event == "alarm"
assert policy.update(120.2, active(motion(120.2, 10.0))).event is None
def test_duplicate_stale_invalid_and_out_of_order_samples_do_not_accumulate():
policy = SentryPolicy(Settings(sensitivity=0.1, warning_time_seconds=0.2))
armed(policy)
assert policy.update(90.1, active(motion(90.1, 10.0))).event is None
assert policy.update(90.2, active(motion(90.1, 9.8))).state == "sensor_unavailable"
assert policy.trigger_count == 0
assert policy.update(90.3, active(motion(90.1, 9.8))).state == "sensor_unavailable"
assert policy.update(90.4, active(motion(90.4, 9.8))).state == "arming"
assert policy.update(90.5, active(motion(90.3, 10.0))).state == "sensor_unavailable"
assert policy.update(90.6, active(motion(90.6, 9.8))).state == "arming"
def test_missing_sensor_during_arming_and_after_armed_requires_full_rearm():
policy = SentryPolicy()
assert policy.update(0.0, active()).state == "sensor_unavailable"
assert policy.update(0.1, active(motion(0.1, 9.8))).state == "arming"
assert fresh_through(policy, 0.1, 90.0).state == "arming"
assert policy.update(90.1, active()).state == "sensor_unavailable"
assert policy.update(90.2, active(motion(90.2, 9.8))).state == "arming"
assert fresh_through(policy, 90.2, 180.3).state == "armed"
assert policy.update(180.4, active()).state == "sensor_unavailable"
assert policy.update(180.5, active(motion(180.5, 9.8))).state == "arming"
def test_disabled_onroad_low_voltage_and_lost_device_rearm_for_full_90_seconds():
for stopped, expected in ((Inputs(False, True, True, True, True), "disabled"),
(Inputs(True, False, True, True, True), "disabled_onroad"),
(Inputs(True, True, False, True, True), "low_voltage"),
(Inputs(True, True, True, False, True), "unavailable"),
(Inputs(True, True, True, True, False), "disabled_ignition")):
policy = SentryPolicy()
armed(policy)
assert policy.update(90.1, active(motion(90.1, 10.0))).state == "armed"
assert policy.update(90.2, stopped).state == expected
assert policy.update(90.3, active(motion(90.3, 9.8))).state == "arming"
assert fresh_through(policy, 90.3, 180.2).state == "arming"
assert fresh_through(policy, 180.2, 180.4).state == "armed"
assert policy.trigger_count == 0
def test_monotonic_reversal_and_motion_window_expiry_fail_closed():
policy = SentryPolicy(Settings(sensitivity=0.1, warning_time_seconds=0.2))
armed(policy)
policy.update(90.1, active(motion(90.1, 10.0)))
assert policy.update(60.0, active(motion(60.0, 9.8))).state == "unavailable"
assert policy.update(60.1, active(motion(60.1, 9.8))).state == "arming"
assert fresh_through(policy, 60.1, 150.0).state == "arming"
assert fresh_through(policy, 150.0, 150.2).state == "armed"
assert policy.update(150.3, active(motion(150.3, 10.0))).event is None
assert fresh_through(policy, 150.3, 210.2, 10.0).event is None
assert policy.update(210.3, active(motion(210.3, 9.8))).event is None
assert policy.trigger_count == 1
def test_two_samples_90_seconds_apart_never_arm():
policy = SentryPolicy()
assert policy.update(0.0, active(motion(0.0, 9.8))).state == "arming"
assert policy.update(90.0, active(motion(90.0, 9.8))).state == "unavailable"
assert policy.update(90.1, active(motion(90.1, 9.8))).state == "arming"
assert fresh_through(policy, 90.1, 180.2).state == "armed"
@pytest.mark.parametrize("settings", [
{"sensitivity": 0.004}, {"sensitivity": float("nan")},
{"sensitivity": True}, {"sensitivity": "0.04"},
{"warning_time_seconds": 10.1}, {"warning_time_seconds": float("inf")},
{"warning_time_seconds": False},
])
def test_settings_reject_invalid_values(settings):
with pytest.raises(ValueError):
Settings(**settings)
@pytest.mark.parametrize("sample", [
(float("nan"), (0.0, 0.0, 9.8)),
(1.0, (0.0, float("inf"), 9.8)),
(1.0, (1e308, 0.0, 9.8)),
(1.0, (True, 0.0, 9.8)),
(1.0, (0.0, 9.8)),
])
def test_sample_requires_finite_time_and_three_axes(sample):
with pytest.raises(ValueError):
MotionSample(*sample)
def test_authority_inputs_reject_truthy_strings():
with pytest.raises(ValueError):
Mock(wraps=Inputs)("off", True, True, True, True)
@@ -0,0 +1,52 @@
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from openpilot.common.params import Params
from openpilot.starpilot.sentry_mode.policy import Settings
from openpilot.starpilot.sentry_mode.preferences import KEY, Preferences, decode, encode, enabled, read_preferences
class TestPreferences(unittest.TestCase):
def test_codec_requires_exact_finite_document(self):
value = Preferences(True, Settings(0.04, 1.0))
self.assertEqual(decode(encode(value)), value)
corrupt = (
b'{}', b'{"version":1,"enabled":true,"sensitivity":0.04,"warningTimeSeconds":1,"extra":0}',
b'{"version":1,"enabled":true,"enabled":false,"sensitivity":0.04,"warningTimeSeconds":1}',
b'{"version":1,"enabled":true,"sensitivity":NaN,"warningTimeSeconds":1}',
b'{"version":1,"enabled":true,"sensitivity":0.04,"warningTimeSeconds":1e400}',
b'{"version":1,"enabled":1,"sensitivity":0.04,"warningTimeSeconds":1}',
b'[' * 250 + b'0' + b']' * 250,
)
for raw in corrupt:
with self.subTest(raw=raw[:30]):
self.assertIsNone(decode(raw))
def test_actual_params_absent_corrupt_and_oversized_are_off_without_rewrite(self):
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, PARAMS_ROOT=directory, OPENPILOT_PREFIX="sentrytest"):
params = Params()
self.assertFalse(enabled(params))
raw = encode(Preferences(True))
Path(params.get_param_path(KEY)).write_bytes(raw)
self.assertTrue(enabled(params))
for invalid in (b'{"version":2}', b'x' * 513):
Path(params.get_param_path(KEY)).write_bytes(invalid)
saved = read_preferences(params)
self.assertFalse(saved.valid)
self.assertFalse(enabled(params))
self.assertEqual(Path(params.get_param_path(KEY)).read_bytes(), invalid)
def test_unreadable_sources_do_not_arm(self):
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, PARAMS_ROOT=directory, OPENPILOT_PREFIX="sentrytest"):
params = Params()
source = Path(params.get_param_path(KEY))
source.symlink_to(Path(directory) / "elsewhere")
self.assertFalse(read_preferences(params).readable)
self.assertFalse(enabled(params))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,215 @@
import os
import signal
from contextlib import ExitStack
from pathlib import Path
from types import SimpleNamespace as NS
import tempfile
import unittest
from unittest.mock import Mock, patch
from openpilot.cereal import log, messaging
from openpilot.common.params import Params
from openpilot.starpilot.sentry_mode.preferences import KEY, Preferences, encode
from openpilot.starpilot.sentry_mode.runtime import SentryRuntime
from openpilot.starpilot.sentry_mode import runtime as runtime_module
class Clock:
def __init__(self):
self.mono = 1_000_000_000_000
self.offset = 50_000_000_000
def boot(self):
return self.mono + self.offset
class Messages:
def __init__(self, clock):
self.clock = clock
self.messages = {}
names = ("deviceState", "pandaStates", "peripheralState", "accelerometer")
self.seen = dict.fromkeys(names, True)
self.alive = dict.fromkeys(names, True)
self.valid = dict.fromkeys(names, True)
self.updated = dict.fromkeys(names, True)
self.logMonoTime = dict.fromkeys(names, 0)
self.recv_time = dict.fromkeys(names, 0.0)
self.sock = {}
def __getitem__(self, name):
return self.messages[name]
def update(self, _timeout):
pass
def set_sources(self, *, sample_stamp=None, acceleration=(0.0, 0.0, 9.81)):
now = self.clock.mono
for name in self.logMonoTime:
self.recv_time[name] = (now - 10_000_000) / 1e9
self.logMonoTime[name] = now - 10_000_000 if name in ("deviceState", "accelerometer") else self.clock.boot() - 10_000_000
self.updated[name] = True
self.messages["deviceState"] = NS(started=False, carBatteryCapacityUwh=1_000_000)
self.messages["pandaStates"] = [NS(ignitionLine=False, ignitionCan=False, pandaType="uno")]
self.messages["peripheralState"] = NS(pandaType="uno", voltage=12_500)
# A real Cap'n Proto event is serialized and decoded before projection.
event = messaging.new_message("accelerometer", valid=True)
event.accelerometer.timestamp = sample_stamp if sample_stamp is not None else now - 20_000_000
event.accelerometer.acceleration.v = list(acceleration)
return event.to_bytes()
class TestRuntime(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.environment = patch.dict(os.environ, PARAMS_ROOT=self.directory.name, OPENPILOT_PREFIX="sentryruntime",
STARPILOT_SENTRY_DEVELOPMENT="1")
self.environment.start()
self.addCleanup(self.environment.stop)
self.params = Params()
self.params.put_bool("IsOffroad", True, block=True)
Path(self.params.get_param_path(KEY)).write_bytes(encode(Preferences(True)))
self.clock = Clock()
self.messages = Messages(self.clock)
self.events = []
self.runtime = SentryRuntime(self.params, self.messages, mono_clock=lambda: self.clock.mono,
boot_clock=self.clock.boot,
record=lambda kind, stamp, permitted: self.events.append((kind, stamp, permitted())))
def tick(self, *, stamp=None, acceleration=(0.0, 0.0, 9.81)):
self.clock.mono += 100_000_000
raw = self.messages.set_sources(sample_stamp=stamp, acceleration=acceleration)
with log.Event.from_bytes(raw) as parsed:
self.messages.messages["accelerometer"] = parsed.accelerometer
return self.runtime.tick()
def test_status_reports_actual_policy_and_immediate_sensor_revocation(self):
status = Mock()
self.runtime.status = status
decision = self.tick()
status.publish.assert_called_once_with(decision, encode(Preferences(True)), "idle")
self.assertEqual(decision.state, "arming")
self.messages.updated["accelerometer"] = False
self.clock.mono += 100_000_000
decision = self.runtime.tick()
self.assertEqual(decision.state, "sensor_unavailable")
self.assertEqual(status.publish.call_args.args[0], decision)
def test_serialized_sensor_and_fresh_parked_evidence(self):
first = self.tick()
self.assertEqual(first.state, "arming")
self.assertIsNotNone(self.runtime.policy.last_sample_time)
self.messages.updated["accelerometer"] = False
self.clock.mono += 100_000_000
self.assertEqual(self.runtime.tick().state, "sensor_unavailable")
self.assertIsNone(self.runtime.policy.arm_started)
self.assertEqual(self.events, [])
def test_serialized_device_panda_peripheral_use_actual_clock_domains(self):
self.clock.mono += 100_000_000
sensor_raw = self.messages.set_sources()
device = messaging.new_message("deviceState", valid=True)
device.deviceState.started = False
device.deviceState.carBatteryCapacityUwh = 1_000_000
panda = messaging.new_message("pandaStates", 1, valid=True)
panda.pandaStates[0].pandaType = "uno"
peripheral = messaging.new_message("peripheralState", valid=True)
peripheral.peripheralState.pandaType = "uno"
peripheral.peripheralState.voltage = 12_500
with ExitStack() as stack:
readers = [stack.enter_context(log.Event.from_bytes(raw)) for raw in
(sensor_raw, device.to_bytes(), panda.to_bytes(), peripheral.to_bytes())]
self.messages.messages.update(accelerometer=readers[0].accelerometer, deviceState=readers[1].deviceState,
pandaStates=readers[2].pandaStates, peripheralState=readers[3].peripheralState)
self.assertEqual(self.runtime.tick().state, "arming")
# A C++ pandad BOOTTIME stamp incorrectly supplied in MONOTONIC units is stale.
self.messages.logMonoTime["pandaStates"] = self.clock.mono - 10_000_000
self.assertNotEqual(self.runtime.tick().state, "armed")
def test_unknown_ignition_power_and_shutdown_disarm(self):
self.tick()
self.messages.messages["pandaStates"][0].ignitionCan = True
self.assertEqual(self.runtime.tick().state, "disabled_ignition")
self.messages.messages["pandaStates"][0].ignitionCan = False
self.messages.messages["peripheralState"].voltage = 11_700
self.assertEqual(self.runtime.tick().state, "low_voltage")
self.messages.messages["peripheralState"].voltage = 12_500
self.params.put_bool("DoShutdown", True, block=True)
self.assertNotEqual(self.runtime.tick().state, "armed")
self.assertEqual(self.events, [])
def test_resume_barrier_rejects_queued_pre_resume_sensor(self):
self.tick()
old_stamp = self.messages.messages["accelerometer"].timestamp
self.clock.offset += 100_000_000
self.clock.mono += 100_000_000
raw = self.messages.set_sources(sample_stamp=old_stamp)
with log.Event.from_bytes(raw) as parsed:
self.messages.messages["accelerometer"] = parsed.accelerometer
self.assertNotEqual(self.runtime.tick().state, "armed")
self.assertIsNone(self.runtime.policy.arm_started)
self.assertEqual(self.events, [])
self.assertEqual(self.tick().state, "arming")
def test_revoked_settings_and_source_preserved(self):
self.assertEqual(self.tick().state, "arming")
raw = b'{"version":1,"enabled":true,"sensitivity":NaN,"warningTimeSeconds":1}'
Path(self.params.get_param_path(KEY)).write_bytes(raw)
self.assertEqual(self.tick().state, "disabled")
self.assertEqual(Path(self.params.get_param_path(KEY)).read_bytes(), raw)
self.assertEqual(self.events, [])
def test_full_arming_to_warning_records_once_with_fresh_final_authority(self):
for _ in range(901):
self.tick()
self.assertIsNotNone(self.runtime.policy.arm_started)
for index in range(11):
value = 10.0 if index % 2 else 9.81
self.tick(acceleration=(0.0, 0.0, value))
self.assertEqual(len(self.events), 1)
self.assertEqual(self.events[0][0], "warning")
self.assertTrue(self.events[0][2])
self.assertEqual(self.runtime.recording_state, "durability_unknown") # Stub supplies no durability receipt.
self.params.put_bool("DoShutdown", True, block=True)
self.assertNotEqual(self.tick().state, "armed")
self.assertEqual(len(self.events), 1)
def test_store_callback_rechecks_saved_revision_and_sample_age(self):
self.tick()
self.runtime.policy.arm_started = self.clock.mono / 1e9 - 90
self.runtime.policy.last_sample_time = self.clock.mono / 1e9 - 0.02
original = Path(self.params.get_param_path(KEY)).read_bytes()
self.assertTrue(self.runtime._permitted(original))
Path(self.params.get_param_path(KEY)).write_bytes(encode(Preferences(True, self.runtime.policy.settings.__class__(0.05, 1.0))))
self.assertFalse(self.runtime._permitted(original))
Path(self.params.get_param_path(KEY)).write_bytes(original)
self.clock.mono += 600_000_000
self.assertFalse(self.runtime._permitted(original))
class TestMainShutdown(unittest.TestCase):
def test_both_signals_close_owned_subscription_and_restore_handlers(self):
from openpilot.starpilot.sentry_mode import storage
for requested in (signal.SIGINT, signal.SIGTERM):
with self.subTest(requested=requested):
installed = {}
prior = object()
close = Mock()
def install(number, handler, target=installed):
target[number] = handler
def tick(which=requested, target=installed):
target[which](which, None)
with patch.object(signal, "getsignal", return_value=prior), patch.object(signal, "signal", side_effect=install), \
patch.object(messaging, "SubMaster"), patch.object(runtime_module, "Params"), \
patch.object(storage, "EventStore"), patch.object(runtime_module, "SentryRuntime") as owner:
owner.return_value.tick.side_effect = tick
owner.return_value.close = close
runtime_module.main()
close.assert_called_once()
self.assertIs(installed[signal.SIGINT], prior)
self.assertIs(installed[signal.SIGTERM], prior)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,68 @@
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from openpilot.starpilot.sentry_mode.policy import Decision
from openpilot.starpilot.sentry_mode.preferences import Preferences, SavedPreferences, encode
from openpilot.starpilot.sentry_mode.status import RuntimeStatus, TTL_NS
class RuntimeStatusTest(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.now = 10_000_000_000
self.boot_now = self.now + 50_000_000_000
self.identity = "current-boot"
self.status = RuntimeStatus(Path(self.directory.name).resolve(), clock=lambda: self.now,
boot=lambda: self.identity, boot_clock=lambda: self.boot_now)
preferences = Preferences(True)
self.saved = SavedPreferences(encode(preferences), True, True, preferences)
self.environment = patch.dict(os.environ, STARPILOT_SENTRY_DEVELOPMENT="1")
self.environment.start()
self.addCleanup(self.environment.stop)
def test_saved_enable_is_not_armed_and_fresh_countdown_is_observed(self):
self.assertEqual(self.status.snapshot(self.saved)[0], "Waiting for monitor")
self.status.publish(Decision("arming", seconds_remaining=82), self.saved.raw, "idle")
self.assertEqual(self.status.snapshot(self.saved)[0], "Arming · 82s")
self.status.publish(Decision("armed"), self.saved.raw, "stored")
self.assertEqual(self.status.snapshot(self.saved)[0], "Monitoring motion")
self.status.publish(Decision("disabled_ignition"), self.saved.raw, "stored")
self.assertEqual(self.status.snapshot(self.saved)[0], "Not armed")
def test_expired_future_reboot_resume_or_changed_settings_never_show_armed(self):
for failure in ("expired", "future", "reboot", "resume", "settings"):
with self.subTest(failure=failure):
self.now = 10_000_000_000
self.boot_now = self.now + 50_000_000_000
self.identity = "current-boot"
preferences = Preferences(True)
self.saved = SavedPreferences(encode(preferences), True, True, preferences)
self.status.last_published = None
self.status.publish(Decision("armed"), self.saved.raw, "idle")
if failure == "expired":
self.now += TTL_NS + 1
elif failure == "future":
self.now -= 1
elif failure == "reboot":
self.identity = "another-boot"
elif failure == "resume":
self.boot_now += 300_000_000
else:
self.saved = SavedPreferences(self.saved.raw + b" ", True, True, self.saved.preferences)
self.assertEqual(self.status.snapshot(self.saved)[0], "Waiting for monitor")
def test_legacy_development_flag_does_not_gate_real_monitor_and_unsafe_status_is_rejected(self):
self.status.publish(Decision("armed"), self.saved.raw, "idle")
with patch.dict(os.environ, STARPILOT_SENTRY_DEVELOPMENT="0"):
self.assertEqual(self.status.snapshot(self.saved)[0], "Monitoring motion")
destination = Path(self.directory.name) / "status.json"
original = destination.read_bytes()
destination.unlink()
target = Path(self.directory.name) / "other"
target.write_bytes(original)
destination.symlink_to(target)
self.assertEqual(self.status.snapshot(self.saved)[0], "Waiting for monitor")
@@ -0,0 +1,142 @@
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from openpilot.starpilot.sentry_mode import storage
class EventStoreTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
# macOS TemporaryDirectory may use /var, itself a system symlink. The
# storage contract intentionally never follows application path symlinks.
self.root = Path(temporary.name).resolve() / "events"
self.store = storage.EventStore(self.root)
def record(self, kind="warning", now=90_000_000_000):
return self.store.record(kind, now, permitted=lambda: True)
def test_round_trip_private_metadata_only_and_missing_is_empty(self):
self.assertEqual(self.store.snapshot()["events"], [])
first = self.record()
second = self.record("alarm", 120_000_000_000)
self.assertTrue(first.durable and second.durable)
events = self.store.snapshot()["events"]
self.assertEqual([event["kind"] for event in events], ["alarm", "warning"])
self.assertEqual({event["sessionId"] for event in events}, {self.store.session_id})
self.assertEqual(set(events[0]), storage.FIELDS)
self.assertEqual(self.root.stat().st_mode & 0o777, 0o700)
for path in self.root.iterdir():
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertLessEqual(path.stat().st_size, storage.MAX_EVENT_BYTES)
def test_onroad_before_or_during_write_does_not_publish(self):
for decisions in ((False,), (True, False)):
with self.assertRaises(storage.StorageUnavailable):
self.store.record("warning", 100, permitted=iter(decisions).__next__)
self.assertEqual(self.store.snapshot()["events"], [])
self.assertFalse(any(path.name.endswith(".tmp") for path in self.root.iterdir()))
def test_capacity_stops_writes_without_deleting_existing_evidence(self):
with patch.object(storage, "MAX_EVENTS", 2):
self.record()
self.record("alarm")
before = {path.name: path.read_bytes() for path in self.root.iterdir()}
with self.assertRaisesRegex(storage.StorageUnavailable, "full"):
self.record()
self.assertEqual(before, {path.name: path.read_bytes() for path in self.root.iterdir()})
self.assertEqual(len(self.store.snapshot()["events"]), 2)
def test_refuses_symlink_directory_lock_or_event_without_touching_target(self):
target = self.root.parent / "unrelated"
target.mkdir(mode=0o700)
self.root.symlink_to(target)
with self.assertRaises(storage.StorageUnavailable):
self.record()
self.root.unlink()
self.root.mkdir(mode=0o700)
outside = target / "content"
outside.write_bytes(b"keep")
(self.root / ".lock").symlink_to(outside)
with self.assertRaises(storage.StorageUnavailable):
self.record()
(self.root / ".lock").unlink()
(self.root / ("a" * 32 + ".json")).symlink_to(outside)
snapshot = self.store.snapshot()
self.assertTrue(snapshot["incomplete"])
self.assertEqual(snapshot["events"], [])
self.assertEqual(outside.read_bytes(), b"keep")
def test_refuses_symlinked_parent_before_mkdir_or_publication(self):
outside = self.root.parent / "outside"
outside.mkdir(mode=0o700)
redirected = outside / "sentry"
redirected.mkdir(mode=0o700)
parent = self.root.parent / "alias"
parent.symlink_to(outside, target_is_directory=True)
store = storage.EventStore(parent / "sentry" / "events")
with self.assertRaises(storage.StorageUnavailable):
store.record("warning", 100, permitted=lambda: True)
with self.assertRaises(storage.StorageUnavailable):
store.snapshot()
self.assertFalse((redirected / "events").exists())
self.assertEqual(list(redirected.iterdir()), [])
def test_first_use_creates_only_private_directories_without_dotdot(self):
root = self.root.parent / "new" / "sentry" / "events"
store = storage.EventStore(root)
self.assertTrue(store.record("warning", 100, permitted=lambda: True).durable)
for parent in (root, root.parent, root.parent.parent):
self.assertEqual(parent.stat().st_mode & 0o777, 0o700)
escaped = storage.EventStore(self.root.parent / "new" / ".." / "outside" / "event")
with self.assertRaises(storage.StorageUnavailable):
escaped.record("warning", 100, permitted=lambda: True)
def test_invalid_duplicate_oversized_unknown_files_are_bounded_and_skipped(self):
first = self.record()
valid = self.root / (first.event_id + ".json")
value = json.loads(valid.read_bytes())
for index, raw in enumerate((b"{" + valid.read_bytes()[1:-1] + b',"version":1}',
b"x" * (storage.MAX_EVENT_BYTES + 1),
json.dumps({**value, "imagePaths": ["/private"]}).encode())):
path = self.root / (str(index) * 32 + ".json")
path.write_bytes(raw)
path.chmod(0o600)
(self.root / "unrecognized").write_bytes(b"keep")
result = self.store.snapshot()
self.assertTrue(result["incomplete"])
self.assertEqual(len(result["events"]), 1)
with patch.object(storage, "MAX_EVENTS", 1):
self.assertTrue(self.store.snapshot()["incomplete"])
def test_fsync_error_after_publication_reports_uncertain_durability_without_retry(self):
original = os.fsync
def fail_directory(fd):
if storage.stat.S_ISDIR(os.fstat(fd).st_mode):
raise OSError("disk sync failed")
original(fd)
with patch.object(storage.os, "fsync", side_effect=fail_directory):
receipt = self.record()
self.assertFalse(receipt.durable)
self.assertEqual([event["eventId"] for event in self.store.snapshot()["events"]], [receipt.event_id])
def test_fsync_error_before_publication_leaves_no_record(self):
with patch.object(storage.os, "fsync", side_effect=OSError("disk sync failed")):
with self.assertRaises(storage.StorageUnavailable):
self.record()
self.assertEqual(self.store.snapshot()["events"], [])
self.assertEqual([path.name for path in self.root.iterdir()], [".lock"])
def test_event_and_timestamps_are_strict(self):
for kind, stamp in (("image", 1), ("warning", True), ("warning", -1), ("alarm", 2**63)):
with self.assertRaises(ValueError):
self.record(kind, stamp)
self.assertFalse(self.root.exists())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,29 @@
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from openpilot.starpilot.sentry_mode import storage
class TestStorageCapacity(unittest.TestCase):
def test_camera_events_fill_advertised_capacity_and_survive_restart(self):
with tempfile.TemporaryDirectory() as folder, patch.object(storage, "MAX_EVENTS", 4):
owner = storage.EventStore(Path(folder).resolve() / "events")
image = b"\xff\xd8frame\xff\xd9"
for stamp in range(4):
self.assertTrue(owner.record("warning", stamp, permitted=lambda: True, images={"wide": image, "cabin": image}).durable)
restarted = storage.EventStore(owner.root)
self.assertEqual(len(restarted.snapshot()["events"]), 4)
self.assertEqual(restarted.snapshot()["capacity"], 4)
with self.assertRaises(storage.StorageUnavailable):
restarted.record("alarm", 5, permitted=lambda: True, images={"cabin": image})
def test_unpublished_image_directory_still_consumes_capacity(self):
with tempfile.TemporaryDirectory() as folder, patch.object(storage, "MAX_EVENTS", 2):
owner = storage.EventStore(Path(folder).resolve() / "events")
owner.root.mkdir(mode=0o700)
(owner.root / ("a" * 32)).mkdir(mode=0o700)
owner.record("warning", 1, permitted=lambda: True)
with self.assertRaises(storage.StorageUnavailable):
owner.record("warning", 2, permitted=lambda: True)
@@ -0,0 +1,85 @@
"""RFC8291 aes128gcm and RFC8292 VAPID using the existing JWT crypto dependency."""
import base64
import json
import os
import struct
import time
from urllib.parse import urlsplit
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
def b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip('=')
def unb64(value: str) -> bytes:
return base64.b64decode(value + '=' * (-len(value) % 4), altchars=b'-_', validate=True)
def public(key) -> bytes:
return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
def new_key() -> str:
key = ec.generate_private_key(ec.SECP256R1())
return key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()).decode()
def load_key(pem: str):
key = serialization.load_pem_private_key(pem.encode(), password=None)
if not isinstance(key, ec.EllipticCurvePrivateKey) or not isinstance(key.curve, ec.SECP256R1):
raise ValueError('Invalid push key')
return key
def validate_subscription(subscription: dict) -> None:
if type(subscription) is not dict or set(subscription) != {'endpoint', 'keys'}:
raise ValueError('Invalid push subscription')
keys = subscription['keys']
if type(keys) is not dict or set(keys) != {'p256dh', 'auth'}:
raise ValueError('Invalid push keys')
if any(type(value) is not str or len(value) > 256 for value in keys.values()):
raise ValueError('Invalid push keys')
receiver = unb64(keys['p256dh'])
if len(receiver) != 65 or len(unb64(keys['auth'])) != 16:
raise ValueError('Invalid push keys')
ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), receiver)
def encrypt(subscription: dict, payload: bytes, *, sender=None, salt: bytes | None = None) -> bytes:
validate_subscription(subscription)
receiver = unb64(subscription['keys']['p256dh'])
auth = unb64(subscription['keys']['auth'])
sender = sender if sender is not None else ec.generate_private_key(ec.SECP256R1())
sender_public = public(sender)
shared = sender.exchange(ec.ECDH(), ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), receiver))
ikm = HKDF(algorithm=hashes.SHA256(), length=32, salt=auth,
info=b'WebPush: info\x00' + receiver + sender_public).derive(shared)
salt = salt if salt is not None else os.urandom(16)
cek = HKDF(algorithm=hashes.SHA256(), length=16, salt=salt, info=b'Content-Encoding: aes128gcm\x00').derive(ikm)
nonce = HKDF(algorithm=hashes.SHA256(), length=12, salt=salt, info=b'Content-Encoding: nonce\x00').derive(ikm)
plaintext = payload + b'\x02'
if len(plaintext) > 3000:
raise ValueError('Push payload exceeds limit')
return salt + struct.pack('!I', 4096) + bytes([65]) + sender_public + AESGCM(cek).encrypt(nonce, plaintext, None)
def request(subscription: dict, message: dict, pem: str, *, now: int | None = None) -> tuple[bytes, dict]:
body = encrypt(subscription, json.dumps(message, separators=(',', ':')).encode())
parsed = urlsplit(subscription['endpoint'])
origin = f'{parsed.scheme}://{parsed.netloc}'
header = b64(b'{"typ":"JWT","alg":"ES256"}')
# RFC8292 expiration is a Unix epoch, independent of process monotonic time.
claims = b64(json.dumps({'aud': origin, 'exp': (time.time_ns() // 1_000_000_000 if now is None else now) + 43200,
'sub': os.getenv('STARPILOT_VAPID_SUBJECT', 'mailto:galaxy@firestar.link')}, separators=(',', ':')).encode())
signing = f'{header}.{claims}'.encode()
vapid = load_key(pem)
r, s = utils.decode_dss_signature(vapid.sign(signing, ec.ECDSA(hashes.SHA256())))
token = signing.decode() + '.' + b64(r.to_bytes(32, 'big') + s.to_bytes(32, 'big'))
return body, {'Authorization': f'vapid t={token}, k={b64(public(vapid))}', 'Content-Encoding': 'aes128gcm',
'Content-Type': 'application/octet-stream', 'TTL': '300', 'Topic': message['eventId'][:32]}