From 47c9cb39a235e91a952614f080b1f83d88863190 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 2 Mar 2026 23:07:30 -0500 Subject: [PATCH] Auto-generate VAPID keys at startup; no manual configuration needed On first boot, ensure_vapid_keys() generates a fresh EC P-256 key pair and saves it to /data/vapid_keys.json (inside the app_data volume) so it survives container restarts. Subsequent boots load from that file. VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY env vars still take precedence for deployments that prefer to manage keys externally. Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/app.py | 2 + src/fabledassistant/services/push.py | 57 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 286b928..e54e5d6 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -112,10 +112,12 @@ def create_app() -> Quart: from fabledassistant.services.llm import ensure_model from fabledassistant.services.logging import start_log_retention_loop from fabledassistant.services.notifications import start_notification_loop + from fabledassistant.services.push import ensure_vapid_keys start_cleanup_loop() start_log_retention_loop() start_notification_loop() + ensure_vapid_keys() async def _warm_model(model: str) -> None: """Warm an already-installed model into VRAM (no pull).""" diff --git a/src/fabledassistant/services/push.py b/src/fabledassistant/services/push.py index 4496de0..be2619a 100644 --- a/src/fabledassistant/services/push.py +++ b/src/fabledassistant/services/push.py @@ -1,9 +1,11 @@ """Browser push notification service using VAPID/pywebpush.""" import asyncio +import base64 import json import logging from datetime import datetime, timezone from functools import lru_cache +from pathlib import Path from sqlalchemy import delete, select @@ -13,6 +15,9 @@ from fabledassistant.models.push_subscription import PushSubscription logger = logging.getLogger(__name__) +# Persisted alongside other app data so keys survive container restarts. +_VAPID_KEYS_FILE = Path(Config.IMAGE_CACHE_DIR).parent / "vapid_keys.json" + @lru_cache(maxsize=1) def _get_webpush(): @@ -28,6 +33,58 @@ def vapid_enabled() -> bool: return bool(Config.VAPID_PRIVATE_KEY and Config.VAPID_PUBLIC_KEY) +def ensure_vapid_keys() -> None: + """Load or auto-generate VAPID keys, storing them in the data volume. + + Called once at startup. If VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY env vars + are already set they take precedence and nothing is written to disk. + """ + if vapid_enabled(): + logger.info("VAPID keys loaded from environment variables") + return + + # Try to load previously generated keys from the data volume. + if _VAPID_KEYS_FILE.exists(): + try: + data = json.loads(_VAPID_KEYS_FILE.read_text()) + Config.VAPID_PRIVATE_KEY = data["private_key"] + Config.VAPID_PUBLIC_KEY = data["public_key"] + logger.info("VAPID keys loaded from %s", _VAPID_KEYS_FILE) + return + except Exception: + logger.warning("Failed to load VAPID keys from %s — regenerating", _VAPID_KEYS_FILE, exc_info=True) + + # Generate a fresh key pair. + try: + from cryptography.hazmat.primitives import serialization + from py_vapid import Vapid01 + + v = Vapid01() + v.generate_keys() + + private_pem = v.private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ).decode() + + pub_bytes = v.public_key.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + public_b64 = base64.urlsafe_b64encode(pub_bytes).rstrip(b"=").decode() + + # Persist so they survive container restarts. + _VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True) + _VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_pem, "public_key": public_b64})) + + Config.VAPID_PRIVATE_KEY = private_pem + Config.VAPID_PUBLIC_KEY = public_b64 + logger.info("Generated new VAPID keys and saved to %s", _VAPID_KEYS_FILE) + except Exception: + logger.warning("Failed to generate VAPID keys — push notifications will be unavailable", exc_info=True) + + async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription: """Upsert a push subscription by endpoint.""" endpoint = subscription_json.get("endpoint", "")