diff --git a/AGENTS.md b/AGENTS.md index b668bd0..d6ea63a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -511,6 +511,7 @@ mc.subscribe(EventType.ACK, handler) | `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE | | `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. | | `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). | +| `MESHCORE_VAPID_SUBJECT` | `mailto:noreply@meshcore.local` | Subject (`sub`) claim for Web Push VAPID tokens; must be a `mailto:` or `https:` contact. Apple's push service (APNs) rejects the default `.local` domain with `403 BadJwtToken`, so iOS/Safari operators must set this to a real address. Google FCM (Chrome/Android) accepts the default. | **Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`. diff --git a/README.md b/README.md index c4e8491..0e8e19c 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ Only one transport may be active at a time. If multiple are set, the server will | `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config; an intermediate security precaution, but not as good as basic auth) | | `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` | | `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` | +| `MESHCORE_VAPID_SUBJECT` | `mailto:noreply@meshcore.local` | Subject (`sub`) claim for Web Push VAPID tokens; must be a `mailto:` or `https:` contact. Apple's push service rejects the default `.local` domain, so iOS/Safari users must set this to a real address (e.g. `mailto:you@example.com`). | Common launch patterns: diff --git a/app/AGENTS.md b/app/AGENTS.md index 1a6e70a..6fd5257 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -182,6 +182,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu - **Not a fanout module** — Web Push manages per-browser subscriptions (N browsers, each with its own endpoint and delivery state), 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`. +- **VAPID subject**: the JWT `sub` claim comes from `get_vapid_claims()` in `app/push/vapid.py`, configurable via `MESHCORE_VAPID_SUBJECT` (default `mailto:noreply@meshcore.local`). Apple's APNs rejects `.local` subjects with `403 BadJwtToken`, so iOS/Safari deployments must set a real `mailto:`/`https:` contact. - **Dispatch**: `broadcast_event()` in `websocket.py` fires `push_manager.dispatch_message(data)` alongside fanout for `message` events. The manager checks the global `app_settings.push_conversations` list, then sends to all currently registered subscriptions via `pywebpush` (run in a 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. diff --git a/app/config.py b/app/config.py index b6d6032..74891ac 100644 --- a/app/config.py +++ b/app/config.py @@ -31,6 +31,7 @@ class Settings(BaseSettings): skip_post_connect_sync: bool = False basic_auth_username: str = "" basic_auth_password: str = "" + vapid_subject: str = "mailto:noreply@meshcore.local" @model_validator(mode="after") def validate_transport_exclusivity(self) -> "Settings": diff --git a/app/push/manager.py b/app/push/manager.py index 85d00a6..faab851 100644 --- a/app/push/manager.py +++ b/app/push/manager.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from pywebpush import WebPushException from app.push.send import send_push -from app.push.vapid import get_vapid_private_key +from app.push.vapid import get_vapid_claims, get_vapid_private_key from app.repository.channels import ChannelRepository from app.repository.push_subscriptions import PushSubscriptionRepository from app.repository.settings import AppSettingsRepository @@ -21,7 +21,6 @@ from app.repository.settings import AppSettingsRepository logger = logging.getLogger(__name__) _SEND_TIMEOUT = 15 # seconds per push send -_VAPID_CLAIMS = {"sub": "mailto:noreply@meshcore.local"} def _state_key_for_message(data: dict) -> str: @@ -161,7 +160,7 @@ class PushManager: subscription_info=_subscription_info(sub), payload=payload, vapid_private_key=vapid_key, - vapid_claims=_VAPID_CLAIMS, + vapid_claims=get_vapid_claims(), ) result.success = True except WebPushException as e: diff --git a/app/push/vapid.py b/app/push/vapid.py index cf0ef9f..fb6b9dd 100644 --- a/app/push/vapid.py +++ b/app/push/vapid.py @@ -11,6 +11,7 @@ import logging from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from py_vapid import Vapid +from app.config import settings from app.repository.settings import AppSettingsRepository logger = logging.getLogger(__name__) @@ -58,3 +59,14 @@ def get_vapid_public_key() -> str: def get_vapid_private_key() -> str: """Return the cached VAPID private key (base64url). Must call ensure_vapid_keys() first.""" return _cached_private_key + + +def get_vapid_claims() -> dict[str, str]: + """VAPID JWT claims for Web Push. + + The ``sub`` (subject) claim is configurable via ``MESHCORE_VAPID_SUBJECT``. + Apple's push service (APNs) rejects subjects on reserved TLDs such as + ``.local`` with ``403 BadJwtToken``, so iOS/Safari operators must set this + to a real ``mailto:`` or ``https:`` contact. + """ + return {"sub": settings.vapid_subject} diff --git a/app/routers/push.py b/app/routers/push.py index dc12675..14bb7af 100644 --- a/app/routers/push.py +++ b/app/routers/push.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, Field from pywebpush import WebPushException from app.push.send import send_push -from app.push.vapid import get_vapid_private_key, get_vapid_public_key +from app.push.vapid import get_vapid_claims, get_vapid_private_key, get_vapid_public_key from app.repository.push_subscriptions import PushSubscriptionRepository from app.repository.settings import AppSettingsRepository @@ -123,7 +123,7 @@ async def test_push(subscription_id: str) -> dict: }, payload=payload, vapid_private_key=vapid_key, - vapid_claims={"sub": "mailto:noreply@meshcore.local"}, + vapid_claims=get_vapid_claims(), ) return {"status": "sent"} except TimeoutError: diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 76c52da..1334295 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -46,6 +46,10 @@ services: # MESHCORE_BASIC_AUTH_PASSWORD: changeme # MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT: "false" + # Web Push + # Set a real mailto for iOS/Safari push; Apple's APNs rejects the default .local domain. + # MESHCORE_VAPID_SUBJECT: "mailto:you@example.com" + # Logging # MESHCORE_LOG_LEVEL: INFO restart: unless-stopped diff --git a/tests/test_push_send.py b/tests/test_push_send.py index feda328..0c2bb37 100644 --- a/tests/test_push_send.py +++ b/tests/test_push_send.py @@ -13,6 +13,7 @@ from app.push.send import ( IPv4HTTPAdapter, send_push, ) +from app.push.vapid import get_vapid_claims @pytest.mark.asyncio @@ -72,3 +73,14 @@ async def test_send_push_retries_with_ipv4_session_after_connect_timeout(): IPV4_FALLBACK_CONNECT_TIMEOUT_SECONDS, DEFAULT_PUSH_READ_TIMEOUT_SECONDS, ) + + +def test_get_vapid_claims_defaults_to_meshcore_local(): + """Default subject is unchanged so existing deployments behave identically.""" + assert get_vapid_claims() == {"sub": "mailto:noreply@meshcore.local"} + + +def test_get_vapid_claims_honors_configured_subject(monkeypatch): + """MESHCORE_VAPID_SUBJECT overrides the outgoing subject (required for APNs/iOS).""" + monkeypatch.setattr("app.config.settings.vapid_subject", "mailto:ops@example.net") + assert get_vapid_claims() == {"sub": "mailto:ops@example.net"}