Add web push

This commit is contained in:
Jack Kingsman
2026-04-12 19:43:58 -07:00
parent 1db724073b
commit 31bd4a0744
23 changed files with 1881 additions and 9 deletions

View File

@@ -50,6 +50,10 @@ app/
├── events.py # Typed WS event payload serialization
├── websocket.py # WS manager + broadcast helpers
├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS
├── push/ # Web Push notification subsystem
│ ├── vapid.py # VAPID key generation, storage, caching
│ ├── send.py # pywebpush wrapper (async via thread executor)
│ └── manager.py # Push dispatch: filter, build payload, concurrent send
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
├── dependencies.py # Shared FastAPI dependency providers
├── path_utils.py # Path hex rendering and hop-width helpers
@@ -71,6 +75,7 @@ app/
├── fanout.py
├── repeaters.py
├── statistics.py
├── push.py
└── ws.py
```
@@ -168,6 +173,17 @@ app/
- Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes.
- See `app/fanout/AGENTS_fanout.md` for full architecture details and event payload shapes.
### Web Push notifications
Web Push is a standalone subsystem in `app/push/`, separate from the fanout module system. It sends browser push notifications for incoming messages even when the tab is closed.
- **Not a fanout module** — Web Push manages per-browser subscriptions (N browsers, each with own endpoint and preferences), unlike fanout which is one-config-to-one-destination.
- **VAPID keys**: auto-generated P-256 key pair on first startup, stored in `app_settings.vapid_private_key` / `vapid_public_key`. Cached in-module by `app/push/vapid.py`.
- **Dispatch**: `broadcast_event()` in `websocket.py` fires `push_manager.dispatch_message(data)` alongside fanout for `message` events. The manager loads all subscriptions, filters each by its `filter_mode` (`all_messages`, `all_dms`, `selected`), builds a notification payload, and sends concurrently via `pywebpush` (run in thread executor).
- **Stale cleanup**: HTTP 404/410 from the push service triggers immediate subscription deletion.
- **Subscriptions stored** in `push_subscriptions` table with `UNIQUE(endpoint)` for upsert semantics.
- Requires HTTPS (self-signed OK) and outbound internet to reach browser push services.
## API Surface (all under `/api`)
### Health
@@ -258,6 +274,14 @@ app/
### Statistics
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
### Push
- `GET /push/vapid-public-key` — VAPID public key for browser `PushManager.subscribe()`
- `POST /push/subscribe` — register/upsert push subscription (keyed by endpoint URL)
- `GET /push/subscriptions` — list all push subscriptions
- `PATCH /push/subscriptions/{id}` — update label or filter preferences
- `DELETE /push/subscriptions/{id}` — delete subscription
- `POST /push/subscriptions/{id}/test` — send test notification
### WebSocket
- `WS /ws`
@@ -290,7 +314,8 @@ Main tables:
- `contact_name_history` (tracks name changes over time)
- `repeater_telemetry_history` (time-series telemetry snapshots for tracked repeaters)
- `fanout_configs` (MQTT, bot, webhook, Apprise, SQS integration configs)
- `app_settings`
- `push_subscriptions` (Web Push browser subscriptions with per-conversation filter preferences; UNIQUE on endpoint)
- `app_settings` (includes `vapid_private_key` and `vapid_public_key` for Web Push VAPID signing)
Contact route state is canonicalized on the backend:
- stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*`

View File

@@ -67,6 +67,7 @@ from app.routers import (
health,
messages,
packets,
push,
radio,
read_state,
repeaters,
@@ -102,6 +103,14 @@ async def lifespan(app: FastAPI):
await db.connect()
logger.info("Database connected")
# Initialize VAPID keys for Web Push (generates on first run)
from app.push.vapid import ensure_vapid_keys
try:
await ensure_vapid_keys()
except Exception:
logger.warning("Failed to initialize VAPID keys for Web Push", exc_info=True)
# Ensure default channels exist in the database even before the radio
# connects. Without this, a fresh or disconnected instance would return
# zero channels from GET /channels until the first successful radio sync.
@@ -185,6 +194,7 @@ app.include_router(packets.router, prefix="/api")
app.include_router(read_state.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
app.include_router(statistics.router, prefix="/api")
app.include_router(push.router, prefix="/api")
app.include_router(ws.router, prefix="/api")
# Serve frontend static files in production

View File

@@ -0,0 +1,47 @@
import logging
import aiosqlite
logger = logging.getLogger(__name__)
async def migrate(conn: aiosqlite.Connection) -> None:
"""Add VAPID key columns and push_subscriptions table for Web Push."""
# VAPID key pair stored in app_settings (one per instance)
table_check = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
)
if await table_check.fetchone():
cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await cursor.fetchall()}
if "vapid_private_key" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN vapid_private_key TEXT DEFAULT ''"
)
if "vapid_public_key" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN vapid_public_key TEXT DEFAULT ''"
)
# Push subscriptions — one row per browser
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
filter_mode TEXT NOT NULL DEFAULT 'all_messages',
filter_conversations TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
last_success_at INTEGER,
failure_count INTEGER DEFAULT 0,
UNIQUE(endpoint)
)
"""
)
await conn.commit()

0
app/push/__init__.py Normal file
View File

148
app/push/manager.py Normal file
View File

@@ -0,0 +1,148 @@
"""Web Push dispatch manager.
Handles filtering subscriptions by their preferences and sending push
notifications concurrently when a new message arrives.
"""
import asyncio
import json
import logging
from pywebpush import WebPushException
from app.push.send import send_push
from app.push.vapid import get_vapid_private_key
from app.repository.push_subscriptions import PushSubscriptionRepository
logger = logging.getLogger(__name__)
_SEND_TIMEOUT = 10 # seconds per push send
_VAPID_CLAIMS = {"sub": "mailto:noreply@meshcore.local"}
def _state_key_for_message(data: dict) -> str:
"""Derive the conversation state key from a message event payload."""
msg_type = data.get("type", "")
conversation_key = data.get("conversation_key", "")
if msg_type == "PRIV":
return f"contact-{conversation_key}"
return f"channel-{conversation_key}"
def _matches_filter(sub: dict, data: dict) -> bool:
"""Check whether a message event matches a subscription's filter."""
mode = sub.get("filter_mode", "all_messages")
if mode == "all_messages":
return True
if mode == "all_dms":
return data.get("type") == "PRIV"
if mode == "selected":
key = _state_key_for_message(data)
return key in (sub.get("filter_conversations") or [])
return False
def _build_payload(data: dict) -> str:
"""Build the push notification JSON payload from a message event."""
msg_type = data.get("type", "")
text = data.get("text", "")
sender_name = data.get("sender_name") or ""
channel_name = data.get("channel_name") or ""
if msg_type == "PRIV":
title = f"Message from {sender_name}" if sender_name else "New direct message"
body = text
else:
# Channel messages include "SenderName: text" in the text field
title = f"#{channel_name}" if channel_name else "Channel message"
body = text
conversation_key = data.get("conversation_key", "")
if msg_type == "PRIV":
url_hash = f"#contact/{conversation_key}"
else:
url_hash = f"#channel/{conversation_key}"
return json.dumps(
{
"title": title,
"body": body,
"tag": f"meshcore-{data.get('id', '')}",
"url_hash": url_hash,
}
)
def _subscription_info(sub: dict) -> dict:
"""Build the subscription_info dict that pywebpush expects."""
return {
"endpoint": sub["endpoint"],
"keys": {
"p256dh": sub["p256dh"],
"auth": sub["auth"],
},
}
class PushManager:
async def dispatch_message(self, data: dict) -> None:
"""Send push notifications for a message event to matching subscriptions."""
# Don't notify for messages the operator just sent themselves
if data.get("outgoing"):
return
try:
subs = await PushSubscriptionRepository.get_all()
except Exception:
logger.debug("Push dispatch: failed to load subscriptions", exc_info=True)
return
if not subs:
return
matching = [s for s in subs if _matches_filter(s, data)]
if not matching:
return
payload = _build_payload(data)
vapid_key = get_vapid_private_key()
if not vapid_key:
logger.debug("Push dispatch: no VAPID key configured, skipping")
return
tasks = [self._send_one(sub, payload, vapid_key) for sub in matching]
await asyncio.gather(*tasks, return_exceptions=True)
async def _send_one(self, sub: dict, payload: str, vapid_key: str) -> None:
sub_id = sub["id"]
try:
async with asyncio.timeout(_SEND_TIMEOUT):
await send_push(
subscription_info=_subscription_info(sub),
payload=payload,
vapid_private_key=vapid_key,
vapid_claims=_VAPID_CLAIMS,
)
await PushSubscriptionRepository.record_success(sub_id)
except WebPushException as e:
status = getattr(e, "response", None)
status_code = getattr(status, "status_code", 0) if status else 0
if status_code in (404, 410):
logger.info(
"Push subscription expired (HTTP %d), removing %s",
status_code,
sub_id,
)
await PushSubscriptionRepository.delete(sub_id)
else:
logger.warning("Push send failed for %s: %s", sub_id, e)
await PushSubscriptionRepository.record_failure(sub_id)
except TimeoutError:
logger.warning("Push send timed out for %s", sub_id)
await PushSubscriptionRepository.record_failure(sub_id)
except Exception:
logger.debug("Push send error for %s", sub_id, exc_info=True)
await PushSubscriptionRepository.record_failure(sub_id)
push_manager = PushManager()

45
app/push/send.py Normal file
View File

@@ -0,0 +1,45 @@
"""Thin wrapper around pywebpush for sending push notifications.
Isolates the pywebpush dependency and runs the synchronous send in
a thread executor to avoid blocking the event loop.
"""
import asyncio
import logging
from pywebpush import webpush
logger = logging.getLogger(__name__)
async def send_push(
subscription_info: dict,
payload: str,
vapid_private_key: str,
vapid_claims: dict,
) -> int:
"""Send an encrypted push notification.
Args:
subscription_info: {"endpoint": ..., "keys": {"p256dh": ..., "auth": ...}}
payload: JSON string to encrypt and send
vapid_private_key: PEM-encoded VAPID private key
vapid_claims: {"sub": "mailto:..."} or {"sub": "https://..."}
Returns:
HTTP status code from the push service.
Raises:
WebPushException: on push service error (caller handles 404/410 cleanup).
"""
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None,
lambda: webpush(
subscription_info=subscription_info,
data=payload,
vapid_private_key=vapid_private_key,
vapid_claims=vapid_claims,
),
)
return response.status_code # type: ignore[union-attr]

65
app/push/vapid.py Normal file
View File

@@ -0,0 +1,65 @@
"""VAPID key management for Web Push.
Generates a P-256 key pair on first use and caches it in app_settings.
The public key is served to browsers for PushManager.subscribe().
"""
import base64
import logging
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from py_vapid import Vapid
from app.database import db
logger = logging.getLogger(__name__)
_cached_private_key: str = ""
_cached_public_key: str = ""
async def ensure_vapid_keys() -> tuple[str, str]:
"""Read or generate VAPID keys. Call once at startup after DB connect."""
global _cached_private_key, _cached_public_key
cursor = await db.conn.execute(
"SELECT vapid_private_key, vapid_public_key FROM app_settings WHERE id = 1"
)
row = await cursor.fetchone()
if row and row["vapid_private_key"] and row["vapid_public_key"]:
_cached_private_key = row["vapid_private_key"]
_cached_public_key = row["vapid_public_key"]
logger.info("VAPID keys loaded from database")
return _cached_private_key, _cached_public_key
# Generate new key pair
vapid = Vapid()
vapid.generate_keys()
# Private key as PEM for pywebpush
_cached_private_key = vapid.private_pem().decode("utf-8")
# Public key as uncompressed P-256 point, base64url-encoded (no padding)
# for the browser Push API's applicationServerKey
raw_pub = vapid.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) # type: ignore[union-attr]
_cached_public_key = base64.urlsafe_b64encode(raw_pub).rstrip(b"=").decode("ascii")
await db.conn.execute(
"UPDATE app_settings SET vapid_private_key = ?, vapid_public_key = ? WHERE id = 1",
(_cached_private_key, _cached_public_key),
)
await db.conn.commit()
logger.info("Generated and stored new VAPID key pair")
return _cached_private_key, _cached_public_key
def get_vapid_public_key() -> str:
"""Return the cached VAPID public key (base64url). Must call ensure_vapid_keys() first."""
return _cached_public_key
def get_vapid_private_key() -> str:
"""Return the cached VAPID private key (PEM). Must call ensure_vapid_keys() first."""
return _cached_private_key

View File

@@ -0,0 +1,145 @@
"""Repository for push_subscriptions table."""
import json
import logging
import time
import uuid
from typing import Any
from app.database import db
logger = logging.getLogger(__name__)
def _row_to_dict(row: Any) -> dict[str, Any]:
result = {
"id": row["id"],
"endpoint": row["endpoint"],
"p256dh": row["p256dh"],
"auth": row["auth"],
"label": row["label"] or "",
"filter_mode": row["filter_mode"] or "all_messages",
"filter_conversations": json.loads(row["filter_conversations"])
if row["filter_conversations"]
else [],
"created_at": row["created_at"] or 0,
"last_success_at": row["last_success_at"],
"failure_count": row["failure_count"] or 0,
}
return result
class PushSubscriptionRepository:
@staticmethod
async def create(
endpoint: str,
p256dh: str,
auth: str,
label: str = "",
filter_mode: str = "all_messages",
filter_conversations: list[str] | None = None,
) -> dict[str, Any]:
"""Create or upsert a push subscription (keyed by endpoint)."""
sub_id = str(uuid.uuid4())
now = int(time.time())
convos_json = json.dumps(filter_conversations or [])
# Upsert: if endpoint already exists, update keys/label but keep the ID
await db.conn.execute(
"""
INSERT INTO push_subscriptions
(id, endpoint, p256dh, auth, label, filter_mode,
filter_conversations, created_at, failure_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(endpoint) DO UPDATE SET
p256dh = excluded.p256dh,
auth = excluded.auth,
label = CASE WHEN excluded.label != '' THEN excluded.label ELSE push_subscriptions.label END,
failure_count = 0
""",
(sub_id, endpoint, p256dh, auth, label, filter_mode, convos_json, now),
)
await db.conn.commit()
# Return the actual row (may be existing on upsert)
return await PushSubscriptionRepository.get_by_endpoint(endpoint) # type: ignore[return-value]
@staticmethod
async def get(subscription_id: str) -> dict[str, Any] | None:
cursor = await db.conn.execute(
"SELECT * FROM push_subscriptions WHERE id = ?", (subscription_id,)
)
row = await cursor.fetchone()
return _row_to_dict(row) if row else None
@staticmethod
async def get_by_endpoint(endpoint: str) -> dict[str, Any] | None:
cursor = await db.conn.execute(
"SELECT * FROM push_subscriptions WHERE endpoint = ?", (endpoint,)
)
row = await cursor.fetchone()
return _row_to_dict(row) if row else None
@staticmethod
async def get_all() -> list[dict[str, Any]]:
cursor = await db.conn.execute("SELECT * FROM push_subscriptions ORDER BY created_at DESC")
rows = await cursor.fetchall()
return [_row_to_dict(row) for row in rows]
@staticmethod
async def update(subscription_id: str, **fields: Any) -> dict[str, Any] | None:
updates: list[str] = []
params: list[Any] = []
for key in ("label", "filter_mode"):
if key in fields:
updates.append(f"{key} = ?")
params.append(fields[key])
if "filter_conversations" in fields:
updates.append("filter_conversations = ?")
params.append(json.dumps(fields["filter_conversations"]))
if not updates:
return await PushSubscriptionRepository.get(subscription_id)
params.append(subscription_id)
await db.conn.execute(
f"UPDATE push_subscriptions SET {', '.join(updates)} WHERE id = ?",
params,
)
await db.conn.commit()
return await PushSubscriptionRepository.get(subscription_id)
@staticmethod
async def delete(subscription_id: str) -> bool:
cursor = await db.conn.execute(
"DELETE FROM push_subscriptions WHERE id = ?", (subscription_id,)
)
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def delete_by_endpoint(endpoint: str) -> bool:
cursor = await db.conn.execute(
"DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,)
)
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def record_success(subscription_id: str) -> None:
now = int(time.time())
await db.conn.execute(
"UPDATE push_subscriptions SET last_success_at = ?, failure_count = 0 WHERE id = ?",
(now, subscription_id),
)
await db.conn.commit()
@staticmethod
async def record_failure(subscription_id: str) -> None:
await db.conn.execute(
"UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = ?",
(subscription_id,),
)
await db.conn.commit()

132
app/routers/push.py Normal file
View File

@@ -0,0 +1,132 @@
"""Web Push subscription management endpoints."""
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.push.send import send_push
from app.push.vapid import get_vapid_private_key, get_vapid_public_key
from app.repository.push_subscriptions import PushSubscriptionRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/push", tags=["push"])
# ── Request/response models ─────────────────────────────────────────────
class VapidPublicKeyResponse(BaseModel):
public_key: str
class PushSubscribeRequest(BaseModel):
endpoint: str = Field(min_length=1)
p256dh: str = Field(min_length=1)
auth: str = Field(min_length=1)
label: str = ""
class PushSubscriptionUpdate(BaseModel):
label: str | None = None
filter_mode: str | None = None
filter_conversations: list[str] | None = None
# ── Endpoints ────────────────────────────────────────────────────────────
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
async def vapid_public_key() -> VapidPublicKeyResponse:
"""Return the VAPID public key for browser PushManager.subscribe()."""
key = get_vapid_public_key()
if not key:
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
return VapidPublicKeyResponse(public_key=key)
@router.post("/subscribe")
async def subscribe(body: PushSubscribeRequest) -> dict:
"""Register or update a push subscription. Upserts by endpoint."""
sub = await PushSubscriptionRepository.create(
endpoint=body.endpoint,
p256dh=body.p256dh,
auth=body.auth,
label=body.label,
)
return sub
@router.get("/subscriptions")
async def list_subscriptions() -> list[dict]:
"""List all push subscriptions."""
return await PushSubscriptionRepository.get_all()
@router.patch("/subscriptions/{subscription_id}")
async def update_subscription(subscription_id: str, body: PushSubscriptionUpdate) -> dict:
"""Update a subscription's label or filter preferences."""
existing = await PushSubscriptionRepository.get(subscription_id)
if not existing:
raise HTTPException(status_code=404, detail="Subscription not found")
updates = {}
if body.label is not None:
updates["label"] = body.label
if body.filter_mode is not None:
if body.filter_mode not in ("all_messages", "all_dms", "selected"):
raise HTTPException(status_code=400, detail="Invalid filter_mode")
updates["filter_mode"] = body.filter_mode
if body.filter_conversations is not None:
updates["filter_conversations"] = body.filter_conversations
result = await PushSubscriptionRepository.update(subscription_id, **updates)
return result or existing
@router.delete("/subscriptions/{subscription_id}")
async def unsubscribe(subscription_id: str) -> dict:
"""Delete a push subscription."""
deleted = await PushSubscriptionRepository.delete(subscription_id)
if not deleted:
raise HTTPException(status_code=404, detail="Subscription not found")
return {"deleted": True}
@router.post("/subscriptions/{subscription_id}/test")
async def test_push(subscription_id: str) -> dict:
"""Send a test notification to a subscription."""
sub = await PushSubscriptionRepository.get(subscription_id)
if not sub:
raise HTTPException(status_code=404, detail="Subscription not found")
vapid_key = get_vapid_private_key()
if not vapid_key:
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
import json
payload = json.dumps(
{
"title": "RemoteTerm Test",
"body": "Push notifications are working!",
"tag": "meshcore-test",
"url_hash": "",
}
)
try:
await send_push(
subscription_info={
"endpoint": sub["endpoint"],
"keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]},
},
payload=payload,
vapid_private_key=vapid_key,
vapid_claims={"sub": "mailto:noreply@meshcore.local"},
)
return {"status": "sent"}
except Exception as e:
logger.warning("Test push failed: %s", e)
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None

View File

@@ -108,6 +108,10 @@ def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> No
if event_type == "message":
asyncio.create_task(fanout_manager.broadcast_message(data))
from app.push.manager import push_manager
asyncio.create_task(push_manager.dispatch_message(data))
elif event_type == "raw_packet":
asyncio.create_task(fanout_manager.broadcast_raw(data))
elif event_type == "contact":