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:
@@ -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)."""
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user