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 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 23:07:30 -05:00
parent 7d532b82a6
commit 47c9cb39a2
2 changed files with 59 additions and 0 deletions
+2
View File
@@ -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)."""
+57
View File
@@ -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", "")