osint-dashboard/app/keystore.py
Sirius DevOps f779b1f225 feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.

- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00

225 lines
8.5 KiB
Python

"""OSINT Dashboard — API key store (keyv-style Postgres table).
Keys live in the ``api_keys`` table, shared between the dashboard app and the
ingest services (both connect to the same Postgres). Only the app WRITES keys
via the management API; ingest services read them with :func:`get_api_key`.
Storage: the table is created lazily with ``CREATE TABLE IF NOT EXISTS`` on
first use in each process (no alembic migration, so it can never fork the
migration chain or block container startup). DDL is idempotent and safe when
the app and ingester containers race on first boot.
Security contract:
* Values are stored in Postgres, never in the container image or frontend.
* ``GET /api/keys`` returns only set/missing status plus a masked
"****last4" hint. Full values are NEVER returned by the API.
* Registered keys get cheap format validation on save (regex), e.g. the
FIRMS map key must be a 32-char hex string.
"""
from __future__ import annotations
import asyncio
import re
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, String, Table, Text, func, select, text
from database import async_session, engine, metadata
# ── Table definition (bound to the shared metadata; created lazily) ───────
api_keys = Table(
"api_keys",
metadata,
Column("name", String(128), primary_key=True),
Column("value", Text, nullable=False),
Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
Column("updated_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
)
_CREATE_TABLE_SQL = text(
"""
CREATE TABLE IF NOT EXISTS api_keys (
name VARCHAR(128) PRIMARY KEY,
value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
# ── Registry of keys the ingest services understand ───────────────────────
# Each entry: human description (shown in the UI) + optional ``pattern`` for
# cheap format validation and an ``example`` for error/placeholder text.
KEY_REGISTRY: dict[str, dict] = {
"FIRMS_MAP_KEY": {
"description": "NASA FIRMS API key — active fire / satellite ingest.",
"pattern": r"^[0-9a-fA-F]{32}$",
"example": "32-char hex string (e.g. 5f3c…9a02)",
},
"GEMINI_API_KEY": {
"description": "Google Gemini API key — LLM event analysis / summarization.",
"pattern": r"^AIza[0-9A-Za-z_-]{35}$",
"example": "AIza… (Google API key, 39 chars)",
},
"TELEGRAM_TOKEN": {
"description": "Telegram bot token — push alert notifications to a channel.",
"pattern": r"^\d{8,10}:[0-9A-Za-z_-]{35}$",
"example": "123456789:AA… (bot token from @BotFather)",
},
"AISSTREAM_API_KEY": {
"description": "AISStream WebSocket key — live vessel positions (server-side only).",
"pattern": r"^.{8,}$",
"example": "key from https://aisstream.io/account (GitHub login)",
},
"OPENSKY_CLIENT_ID": {
"description": "OpenSky OAuth client id — optional ADS-B fallback (unused until enabled).",
"example": "client id from opensky-network.org account",
},
"OPENSKY_CLIENT_SECRET": {
"description": "OpenSky OAuth client secret — optional ADS-B fallback.",
"example": "client secret from the OpenSky account page",
},
}
# Any stored key must at least be a sane UPPER_SNAKE name.
_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
class KeyFormatError(ValueError):
"""Raised when a key name or value fails format validation."""
# ── Lazy table bootstrap ──────────────────────────────────────────────────
_ensure_lock = asyncio.Lock()
_ensured = False
async def ensure_api_keys_table() -> None:
"""Create the api_keys table if it doesn't exist (idempotent, per process)."""
global _ensured
if _ensured:
return
async with _ensure_lock:
if _ensured:
return
async with engine.begin() as conn:
await conn.execute(_CREATE_TABLE_SQL)
_ensured = True
# ── Validation ────────────────────────────────────────────────────────────
def is_registered(name: str) -> bool:
"""True if ``name`` has an entry in the registry (gets format validation)."""
return name in KEY_REGISTRY
def validate_name(name: str) -> None:
"""Reject malformed key names (must be UPPER_SNAKE)."""
if not _NAME_RE.match(name or ""):
raise KeyFormatError(
"Invalid key name — use UPPER_SNAKE_CASE (e.g. FIRMS_MAP_KEY)."
)
def validate_value(name: str, value: str) -> None:
"""Cheap format validation for registered keys; rejects empty values."""
if not value or not value.strip():
raise KeyFormatError("Value must not be empty.")
info = KEY_REGISTRY.get(name)
pattern = (info or {}).get("pattern")
if pattern and not re.match(pattern, value.strip()):
example = (info or {}).get("example", "a matching value")
raise KeyFormatError(f"'{name}' has an invalid format — expected {example}.")
def mask_value(value: str | None) -> str:
"""Mask a stored value as '****last4' (or '****' when shorter than 4)."""
if not value:
return ""
v = value.strip()
tail = v[-4:] if len(v) >= 4 else ""
return f"****{tail}"
# ── Store operations ──────────────────────────────────────────────────────
async def list_keys() -> list[dict]:
"""Registry keys + any extra stored keys, with masked set-status only."""
await ensure_api_keys_table()
async with async_session() as session:
rows = (await session.execute(select(api_keys))).mappings().all()
stored = {r["name"]: r["value"] for r in rows}
names = list(KEY_REGISTRY) + [n for n in stored if n not in KEY_REGISTRY]
out = []
for name in names:
value = stored.get(name)
info = KEY_REGISTRY.get(name, {})
out.append(
{
"name": name,
"set": value is not None,
"masked": mask_value(value) if value is not None else None,
"description": info.get("description", ""),
"example": info.get("example", ""),
"validated": name in KEY_REGISTRY,
}
)
out.sort(key=lambda k: k["name"].lower())
return out
async def set_key(name: str, value: str) -> dict:
"""Upsert a key value. Validates registered formats; rejects bad names.
Returns the masked status (never the raw value).
"""
validate_name(name)
validate_value(name, value)
value = value.strip()
now = datetime.now(timezone.utc)
await ensure_api_keys_table()
async with async_session() as session:
existing = (
await session.execute(select(api_keys).where(api_keys.c.name == name))
).mappings().one_or_none()
if existing:
await session.execute(
api_keys.update()
.where(api_keys.c.name == name)
.values(value=value, updated_at=now)
)
else:
await session.execute(
api_keys.insert().values(name=name, value=value, updated_at=now)
)
await session.commit()
return {"name": name, "set": True, "masked": mask_value(value)}
async def delete_key(name: str) -> bool:
"""Remove a stored key. Returns True if something was deleted."""
await ensure_api_keys_table()
async with async_session() as session:
result = await session.execute(api_keys.delete().where(api_keys.c.name == name))
await session.commit()
return bool(result.rowcount)
async def get_api_key(name: str) -> str | None:
"""Read a stored key value — used by ingest services, never by the API.
Returns the raw value (or None when unset) so producers can pass it to
external APIs (FIRMS, Gemini, Telegram, …). Reads live from Postgres, so a
key set via the dashboard is picked up on the next poll — no restart.
"""
await ensure_api_keys_table()
async with async_session() as session:
row = (
await session.execute(select(api_keys).where(api_keys.c.name == name))
).mappings().one_or_none()
return row["value"] if row else None