mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
590 lines
30 KiB
Python
590 lines
30 KiB
Python
"""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', 'appPush')
|
|
APP_PUSH_RELAY_URL = ''
|
|
APP_IDS = ('com.starpilot.galaxy', 'link.firestar.companion', 'link.galaxy.bluetooth.natehuby76')
|
|
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},
|
|
'appSubscriptions': [], '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)
|
|
if type(state) is dict and 'appSubscriptions' not in state:
|
|
state['appSubscriptions'] = []
|
|
if type(state.get('channels')) is dict:
|
|
state['channels']['appPush'] = fresh_state()['channels']['appPush']
|
|
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 not in ('webPush', 'appPush') and channel['enabled'] and not channel['url']:
|
|
raise ValueError('Missing notification endpoint')
|
|
if type(state['appSubscriptions']) is not list or len(state['appSubscriptions']) > 8:
|
|
raise ValueError('Invalid app subscriptions')
|
|
for sub in state['appSubscriptions']:
|
|
if (type(sub) is not dict or set(sub) != {'id', 'token', 'appId', 'label', 'addedAt', 'since', 'lastResult', 'lastAt'} or
|
|
type(sub['token']) is not str or not 1 <= len(sub['token']) <= 4096 or any(ord(c) <= 32 for c in sub['token']) or
|
|
sub['id'] != hashlib.sha256(sub['token'].encode()).hexdigest()[:12] or sub['appId'] not in APP_IDS or
|
|
type(sub['label']) is not str or len(sub['label']) > 64 or
|
|
type(sub['lastResult']) is not str or len(sub['lastResult']) > 64 or
|
|
any(type(sub[key]) not in (int, float) or not 0 <= sub[key] < 2**63 for key in ('addedAt', 'since', 'lastAt'))):
|
|
raise ValueError('Invalid app subscription')
|
|
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 (re.fullmatch(r'[0-9a-f]{12}' if job['channel'] == 'appPush' else
|
|
r'[0-9a-f]{32}' if job['channel'] == 'webPush' else r'', 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.'}
|
|
result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')}
|
|
for sub in self.state['appSubscriptions']]
|
|
result['removedAppSubscriptions'] = [
|
|
{'id': job['subscriptionId'], 'lastResult': 'Phone unregistered — removed', 'lastAt': job['next']}
|
|
for job in jobs.values() if job['channel'] == 'appPush' and job['error'] == 'expired'][-8:]
|
|
result['appPushAvailable'] = self.state['channels']['appPush']['enabled'] and bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL)
|
|
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'] = []
|
|
if payload['channel'] == 'appPush':
|
|
self.state['appSubscriptions'] = []
|
|
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 == 'appSubscribe':
|
|
if (set(payload) != {'action', 'token', 'appId', 'label'} or type(payload['token']) is not str or
|
|
not 1 <= len(payload['token']) <= 4096 or any(ord(c) <= 32 for c in payload['token']) or
|
|
type(payload['appId']) is not str or payload['appId'] not in APP_IDS or
|
|
type(payload['label']) is not str or len(payload['label']) > 64):
|
|
raise ValueError('Invalid app subscription')
|
|
current = self.state['appSubscriptions']
|
|
existing = next((sub for sub in current if sub['token'] == payload['token']), None)
|
|
if existing:
|
|
existing.update(appId=payload['appId'], label=payload['label'])
|
|
elif len(current) < 8:
|
|
current.append({'id': hashlib.sha256(payload['token'].encode()).hexdigest()[:12], 'token': payload['token'],
|
|
'appId': payload['appId'], 'label': payload['label'], 'addedAt': now, 'since': now,
|
|
'lastResult': '', 'lastAt': 0})
|
|
else:
|
|
raise ValueError('Remove a phone before adding another')
|
|
cfg = self.state['channels']['appPush']
|
|
cfg['enabled'] = bool(cfg['url'] or APP_PUSH_RELAY_URL)
|
|
elif action == 'appUnsubscribe':
|
|
if set(payload) not in ({'action', 'token'}, {'action', 'id'}):
|
|
raise ValueError('Choose a phone subscription')
|
|
key = 'token' if 'token' in payload else 'id'
|
|
if type(payload[key]) is not str:
|
|
raise ValueError('Choose a phone subscription')
|
|
self.state['appSubscriptions'] = [sub for sub in self.state['appSubscriptions'] if sub[key] != payload[key]]
|
|
elif action == 'test' and 'channel' not in payload:
|
|
if set(payload) - {'action', 'target', 'id'} or payload.get('target', 'all') not in ('all', 'app'):
|
|
raise ValueError('Choose a test target')
|
|
target_id = payload.get('id')
|
|
if payload.get('target') == 'app' and not any(sub['id'] == target_id for sub in self.state['appSubscriptions']):
|
|
raise LookupError('Phone subscription not found')
|
|
if sum(j['kind'] == 'test' and j['created'] > now - 60 for j in self.state['jobs'].values()) >= 32:
|
|
raise ValueError('Wait a minute before sending more tests')
|
|
self._enqueue({'eventId': uuid.uuid4().hex, 'kind': 'test', 'wallTimeNs': int(now * 1e9)},
|
|
only='appPush' if payload.get('target') == 'app' else None, target_id=target_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, target_id: str | None = None) -> None:
|
|
if event['kind'] == 'selfie':
|
|
return
|
|
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 self.state['appSubscriptions']
|
|
if name == 'appPush' else [{'id': '', 'since': cfg['since']}])
|
|
for target in targets:
|
|
if target_id is not None and target['id'] != target_id:
|
|
continue
|
|
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 delete_events(self, remove, event_id=None):
|
|
with self.lock:
|
|
if not self.available:
|
|
raise NotificationUnavailable('Sentry notifications are unavailable')
|
|
original_jobs = self.state['jobs']
|
|
if event_id is None:
|
|
self.state['jobs'] = {key: job for key, job in original_jobs.items() if job['kind'] == 'test'}
|
|
else:
|
|
self.state['jobs'] = {key: job for key, job in original_jobs.items() if job['eventId'] != event_id}
|
|
try:
|
|
self._save() # Durable pruning precedes any event/media removal.
|
|
except BaseException:
|
|
self.state['jobs'] = original_jobs
|
|
raise
|
|
return remove()
|
|
|
|
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 key not in self.state['jobs']:
|
|
continue
|
|
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 job['channel'] == 'appPush':
|
|
sub = next((s for s in self.state['appSubscriptions'] if s['id'] == job['subscriptionId']), None)
|
|
if not cfg['enabled'] or cfg['generation'] != job['generation'] or (job['channel'] in ('webPush', 'appPush') 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()
|
|
current_job = self.state['jobs'].get(key)
|
|
if current_job is None:
|
|
continue
|
|
job = current_job
|
|
if job['channel'] == 'appPush':
|
|
current_sub = next((s for s in self.state['appSubscriptions'] if s['id'] == job['subscriptionId']), None)
|
|
if current_sub is not None:
|
|
current_sub['lastAt'] = self.clock()
|
|
current_sub['lastResult'] = ('Delivered to relay' if 200 <= code < 300 else 'Relay not configured' if code == 0 else
|
|
'Phone unregistered — removed' if code == 410 else 'Rate limited' if code == 429 else f'Relay error {code}')
|
|
if code == 410:
|
|
self.state['appSubscriptions'] = [s for s in self.state['appSubscriptions'] if s['id'] != job['subscriptionId']]
|
|
if 200 <= code < 300:
|
|
job['state'], job['error'] = 'sent', ''
|
|
elif code == 410 and job['channel'] == 'appPush':
|
|
job['state'], job['error'], job['next'] = 'failed', 'expired', now
|
|
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'
|
|
if job['channel'] == 'appPush' and sub is not None:
|
|
sub['lastAt'], sub['lastResult'] = self.clock(), 'Relay timeout'
|
|
except Exception:
|
|
# Never log response bodies, URLs, exception text, subscription secrets or auth headers.
|
|
job['state'], job['error'] = 'queued', 'transport'
|
|
if job['channel'] == 'appPush' and sub is not None:
|
|
sub['lastAt'], sub['lastResult'] = self.clock(), 'Relay unavailable'
|
|
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 _attachments(self, job: dict) -> list:
|
|
"""Camera images saved with the event, one files[n] field each (Discord keeps one file per field name)."""
|
|
if job['kind'] == 'test':
|
|
return []
|
|
files = []
|
|
try:
|
|
cameras = self.store.images(job['eventId'])
|
|
except Exception:
|
|
return []
|
|
for camera in cameras:
|
|
try:
|
|
body = self.store.image(job['eventId'], camera)
|
|
except Exception:
|
|
continue # A missing or invalid image never blocks the text alert.
|
|
files.append((f'files[{len(files)}]', (camera + '.jpg', body, 'image/jpeg')))
|
|
return files
|
|
|
|
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}
|
|
if name == 'appPush':
|
|
if subscription is None:
|
|
raise ValueError('Missing app subscription')
|
|
relay = cfg['url'] or APP_PUSH_RELAY_URL
|
|
if not relay:
|
|
return 0
|
|
response = requests.post(relay.rstrip('/') + '/v1/sentry', json={
|
|
'token': subscription['token'], 'appId': subscription['appId'], 'kind': job['kind'], 'eventId': job['eventId'],
|
|
'wallTimeNs': int(job['created'] * 1e9), 'label': subscription['label']}, headers=headers,
|
|
timeout=(3, 10), allow_redirects=False, stream=True)
|
|
elif name == 'webPush':
|
|
if subscription is None:
|
|
raise ValueError('Missing browser subscription')
|
|
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, timeout=(3, 10), allow_redirects=False, stream=True)
|
|
else:
|
|
if cfg['token']:
|
|
headers['Authorization'] = 'Bearer ' + cfg['token']
|
|
files = self._attachments(job) if name in ('discord', 'webhook') else []
|
|
if name == 'discord':
|
|
content = {'content': 'StarPilot Sentry Mode: ' + message}
|
|
if files:
|
|
# Discord takes multipart attachments with the message in payload_json.
|
|
response = requests.post(cfg['url'], data={'payload_json': json.dumps(content)}, files=files,
|
|
headers=headers, timeout=(3, 10), allow_redirects=False, stream=True)
|
|
else:
|
|
response = requests.post(cfg['url'], json=content, headers=headers, timeout=(3, 10), allow_redirects=False, stream=True)
|
|
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=(',', ':'))}, files=files or None,
|
|
headers=headers, timeout=(3, 10), allow_redirects=False, stream=True)
|
|
else:
|
|
headers.update({'Title': 'StarPilot Sentry Mode', 'Priority': 'urgent', 'Tags': 'warning,car'})
|
|
response = requests.post(cfg['url'], data=message.encode(), headers=headers, timeout=(3, 10), allow_redirects=False, stream=True)
|
|
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
|