Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions app/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
5 changes: 2 additions & 3 deletions app/push/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@
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

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:
Expand Down Expand Up @@ -161,7 +160,7 @@ async def _send_one(self, sub: dict, payload: str, vapid_key: str) -> _SendResul
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:
Expand Down
12 changes: 12 additions & 0 deletions app/push/vapid.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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__)
Expand Down Expand Up @@ -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}
4 changes: 2 additions & 2 deletions app/routers/push.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions tests/test_push_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
IPv4HTTPAdapter,
send_push,
)
from app.push.vapid import get_vapid_claims


@pytest.mark.asyncio
Expand Down Expand Up @@ -72,3 +73,14 @@ def fake_webpush(**kwargs):
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"}
Loading