diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 37d7104..e8595fb 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -28,13 +28,34 @@ class TimeMachineToggle(BaseModel): enabled: bool -@router.get("/api/settings/api-keys", dependencies=[Depends(require_admin)]) +@router.get("/api/settings/api-keys", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_get_keys(request: Request): from services.api_settings import get_api_keys return get_api_keys() +@router.put("/api/settings/api-keys", dependencies=[Depends(require_local_operator)]) +@limiter.limit("10/minute") +async def api_save_keys(request: Request): + from services.api_settings import save_api_keys + body = await request.json() + if not isinstance(body, dict): + return Response( + content=json_mod.dumps({"ok": False, "detail": "Expected a JSON object."}), + status_code=400, + media_type="application/json", + ) + result = save_api_keys({str(k): str(v) for k, v in body.items()}) + if result.get("ok"): + return result + return Response( + content=json_mod.dumps(result), + status_code=400, + media_type="application/json", + ) + + @router.get("/api/settings/api-keys/meta") @limiter.limit("30/minute") async def api_get_keys_meta(request: Request): diff --git a/backend/services/api_settings.py b/backend/services/api_settings.py index 473f091..530f95b 100644 --- a/backend/services/api_settings.py +++ b/backend/services/api_settings.py @@ -4,12 +4,21 @@ Keys are stored in the backend .env file and loaded via python-dotenv. """ import os +import re +import tempfile from pathlib import Path # Path to the backend .env file ENV_PATH = Path(__file__).parent.parent / ".env" # Path to the example template that ships with the repo ENV_EXAMPLE_PATH = Path(__file__).parent.parent.parent / ".env.example" +DATA_DIR = Path(os.environ.get("SB_DATA_DIR", str(Path(__file__).parent.parent / "data"))) +if not DATA_DIR.is_absolute(): + DATA_DIR = Path(__file__).parent.parent / DATA_DIR +OPERATOR_KEYS_ENV_PATH = Path( + os.environ.get("SHADOWBROKER_OPERATOR_KEYS_ENV", str(DATA_DIR / "operator_api_keys.env")) +) +_ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") # --------------------------------------------------------------------------- # API Registry — every external service the dashboard depends on @@ -143,6 +152,85 @@ API_REGISTRY = [ }, ] +ALLOWED_ENV_KEYS = { + str(api["env_key"]) + for api in API_REGISTRY + if api.get("env_key") +} + + +def _parse_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.exists(): + return values + try: + text = path.read_text(encoding="utf-8") + except OSError: + return values + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if not _ENV_KEY_RE.match(key): + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + values[key] = value + return values + + +def _quote_env_value(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _write_env_values(path: Path, updates: dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else [] + seen: set[str] = set() + next_lines: list[str] = [] + for raw_line in lines: + stripped = raw_line.strip() + if "=" not in stripped or stripped.startswith("#"): + next_lines.append(raw_line) + continue + key = stripped.split("=", 1)[0].strip() + if key in updates: + next_lines.append(f"{key}={_quote_env_value(updates[key])}") + seen.add(key) + else: + next_lines.append(raw_line) + for key, value in updates.items(): + if key not in seen: + next_lines.append(f"{key}={_quote_env_value(value)}") + + fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f"{path.name}.tmp.", text=True) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + handle.write("\n".join(next_lines).rstrip() + "\n") + if os.name != "nt": + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, path) + if os.name != "nt": + os.chmod(path, 0o600) + finally: + try: + if tmp_path.exists(): + tmp_path.unlink() + except OSError: + pass + + +def load_persisted_api_keys_into_environ() -> None: + """Load persisted operator API keys if no process env value exists.""" + for key, value in _parse_env_file(OPERATOR_KEYS_ENV_PATH).items(): + if key in ALLOWED_ENV_KEYS and value and not os.environ.get(key): + os.environ[key] = value + def get_env_path_info() -> dict: """Return absolute paths for the backend .env and .env.example template. @@ -160,6 +248,10 @@ def get_env_path_info() -> dict: and (not env_path.exists() or os.access(env_path, os.W_OK)), "env_example_path": str(example_path), "env_example_path_exists": example_path.exists(), + "operator_keys_env_path": str(OPERATOR_KEYS_ENV_PATH.resolve()), + "operator_keys_env_path_exists": OPERATOR_KEYS_ENV_PATH.exists(), + "operator_keys_env_path_writable": os.access(OPERATOR_KEYS_ENV_PATH.parent, os.W_OK) + and (not OPERATOR_KEYS_ENV_PATH.exists() or os.access(OPERATOR_KEYS_ENV_PATH, os.W_OK)), } @@ -171,6 +263,7 @@ def get_api_keys(): `is_set` to render a CONFIGURED / NOT CONFIGURED badge and the path info from `get_env_path_info()` to tell them where to put each key. """ + load_persisted_api_keys_into_environ() result = [] for api in API_REGISTRY: entry = { @@ -189,3 +282,57 @@ def get_api_keys(): entry["is_set"] = bool(raw) result.append(entry) return result + + +def save_api_keys(updates: dict[str, str]) -> dict: + """Persist allowed API keys from a local operator request. + + Values are accepted write-only: the response includes only configured flags. + """ + clean: dict[str, str] = {} + for key, value in updates.items(): + env_key = str(key or "").strip().upper() + if env_key not in ALLOWED_ENV_KEYS: + continue + clean_value = str(value or "").strip() + if clean_value: + clean[env_key] = clean_value + if not clean: + return {"ok": False, "detail": "No supported API keys were provided."} + + _write_env_values(OPERATOR_KEYS_ENV_PATH, clean) + try: + _write_env_values(ENV_PATH, clean) + except OSError: + # The persistent operator key file is the source of truth for Docker. + pass + for key, value in clean.items(): + os.environ[key] = value + if "AIS_API_KEY" in clean: + try: + from services import ais_stream + ais_stream.API_KEY = clean["AIS_API_KEY"] + except Exception: + pass + if "OPENSKY_CLIENT_ID" in clean or "OPENSKY_CLIENT_SECRET" in clean: + try: + from services.fetchers import flights + flights.opensky_client.client_id = os.environ.get("OPENSKY_CLIENT_ID", "") + flights.opensky_client.client_secret = os.environ.get("OPENSKY_CLIENT_SECRET", "") + flights.opensky_client.token = None + flights.opensky_client.expires_at = 0 + except Exception: + pass + + try: + from services.config import get_settings + get_settings.cache_clear() + except Exception: + pass + + return { + "ok": True, + "updated": sorted(clean.keys()), + "keys": get_api_keys(), + "env": get_env_path_info(), + } diff --git a/backend/services/config.py b/backend/services/config.py index 9684ad1..80f3f59 100644 --- a/backend/services/config.py +++ b/backend/services/config.py @@ -302,6 +302,11 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: + try: + from services.api_settings import load_persisted_api_keys_into_environ + load_persisted_api_keys_into_environ() + except Exception: + pass return Settings() diff --git a/backend/tests/test_api_settings.py b/backend/tests/test_api_settings.py new file mode 100644 index 0000000..b9b2a79 --- /dev/null +++ b/backend/tests/test_api_settings.py @@ -0,0 +1,38 @@ +import os + + +def test_save_api_keys_persists_write_only(tmp_path, monkeypatch): + from services import api_settings + + key_store = tmp_path / "operator_api_keys.env" + backend_env = tmp_path / ".env" + monkeypatch.setattr(api_settings, "OPERATOR_KEYS_ENV_PATH", key_store) + monkeypatch.setattr(api_settings, "ENV_PATH", backend_env) + monkeypatch.delenv("OPENSKY_CLIENT_ID", raising=False) + + result = api_settings.save_api_keys( + { + "OPENSKY_CLIENT_ID": "client-id-value", + "NOT_ALLOWED": "ignore-me", + } + ) + + assert result["ok"] is True + assert result["updated"] == ["OPENSKY_CLIENT_ID"] + assert "client-id-value" not in str(result) + assert os.environ["OPENSKY_CLIENT_ID"] == "client-id-value" + assert 'OPENSKY_CLIENT_ID="client-id-value"' in key_store.read_text(encoding="utf-8") + assert "NOT_ALLOWED" not in key_store.read_text(encoding="utf-8") + + +def test_persisted_api_keys_load_when_process_env_blank(tmp_path, monkeypatch): + from services import api_settings + + key_store = tmp_path / "operator_api_keys.env" + key_store.write_text('AIS_API_KEY="saved-ais-key"\n', encoding="utf-8") + monkeypatch.setattr(api_settings, "OPERATOR_KEYS_ENV_PATH", key_store) + monkeypatch.setenv("AIS_API_KEY", "") + + api_settings.load_persisted_api_keys_into_environ() + + assert os.environ["AIS_API_KEY"] == "saved-ais-key" diff --git a/frontend/src/components/OnboardingModal.tsx b/frontend/src/components/OnboardingModal.tsx index 3893be7..176f0a4 100644 --- a/frontend/src/components/OnboardingModal.tsx +++ b/frontend/src/components/OnboardingModal.tsx @@ -19,7 +19,7 @@ const API_GUIDES = [ 'Create a free account at opensky-network.org', 'Go to Dashboard → OAuth → Create Client', 'Copy your Client ID and Client Secret', - 'Set OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET in your .env file, then restart ShadowBroker', + 'Paste both into Quick Local Setup above or Settings → API Keys', ], url: 'https://opensky-network.org/index.php?option=com_users&view=registration', color: 'cyan', @@ -33,7 +33,7 @@ const API_GUIDES = [ 'Register at aisstream.io', 'Navigate to your API Keys page', 'Generate a new API key', - 'Set AIS_API_KEY in your .env file, then restart ShadowBroker', + 'Paste it into Quick Local Setup above or Settings → API Keys', ], url: 'https://aisstream.io/authenticate', color: 'blue', @@ -61,6 +61,13 @@ const OnboardingModal = React.memo(function OnboardingModal({ onOpenSettings, }: OnboardingModalProps) { const [step, setStep] = useState(0); + const [setupKeys, setSetupKeys] = useState({ + OPENSKY_CLIENT_ID: '', + OPENSKY_CLIENT_SECRET: '', + AIS_API_KEY: '', + }); + const [setupSaving, setSetupSaving] = useState(false); + const [setupMsg, setSetupMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const handleDismiss = () => { localStorage.setItem(STORAGE_KEY, 'true'); @@ -75,6 +82,38 @@ const OnboardingModal = React.memo(function OnboardingModal({ onOpenSettings(); }; + const saveSetupKeys = async () => { + const payload = Object.fromEntries( + Object.entries(setupKeys).filter(([, value]) => value.trim()), + ); + if (!Object.keys(payload).length) { + setSetupMsg({ type: 'err', text: 'Enter at least one API key first.' }); + return; + } + setSetupSaving(true); + setSetupMsg(null); + try { + const res = await fetch('/api/settings/api-keys', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.ok === false) { + throw new Error(data?.detail || 'Could not save API keys.'); + } + setSetupKeys({ OPENSKY_CLIENT_ID: '', OPENSKY_CLIENT_SECRET: '', AIS_API_KEY: '' }); + setSetupMsg({ type: 'ok', text: 'Keys saved locally. Restart or refresh feeds to use them.' }); + } catch (error) { + setSetupMsg({ + type: 'err', + text: error instanceof Error ? error.message : 'Could not save API keys.', + }); + } finally { + setSetupSaving(false); + } + }; + return ( {/* Backdrop */} @@ -218,14 +257,58 @@ const OnboardingModal = React.memo(function OnboardingModal({

OpenSky Network and AIS Stream are the free keys that make ShadowBroker - useful immediately: live aircraft and vessel tracking. For Docker installs, - create or edit the .env file next to docker-compose.yml, then run docker - compose up -d. For local source installs, edit backend/.env and restart. + useful immediately: live aircraft and vessel tracking. Paste them below or + use Settings later; secrets stay on the local backend.

+
+
+

+ QUICK LOCAL SETUP +

+

+ Paste keys here once. ShadowBroker stores them server-side only and never + displays the secret back in the browser. +

+
+ {[ + ['OPENSKY_CLIENT_ID', 'OpenSky Client ID'], + ['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'], + ['AIS_API_KEY', 'AIS Stream API Key'], + ].map(([key, label]) => ( + + setSetupKeys((prev) => ({ ...prev, [key]: event.target.value })) + } + placeholder={label} + className="w-full bg-[var(--bg-primary)] border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-primary)] font-mono outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/60" + autoComplete="off" + /> + ))} + {setupMsg && ( +

+ {setupMsg.text} +

+ )} + +
+ {API_GUIDES.map((api) => (
= { @@ -493,10 +496,12 @@ const SettingsPanel = React.memo(function SettingsPanel({ }, [adminKey, refreshAdminSession]); // --- API Keys state --- - // API keys are intentionally NOT editable in-app. The panel is read-only and - // tells the user where the .env file lives so they can edit it directly. - // This keeps secrets off the wire and out of the browser process. + // API keys are write-only in-app. Values are sent once to the local backend, + // stored server-side, and never returned to the browser. const [apis, setApis] = useState([]); + const [apiKeyInputs, setApiKeyInputs] = useState>({}); + const [apiKeySaving, setApiKeySaving] = useState(null); + const [apiKeyMsg, setApiKeyMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [expandedCategories, setExpandedCategories] = useState>( new Set(['Aviation', 'Maritime']), ); @@ -535,7 +540,9 @@ const SettingsPanel = React.memo(function SettingsPanel({ const fetchKeys = useCallback(async () => { try { - setApis(await controlPlaneJson('/api/settings/api-keys')); + setApis(await controlPlaneJson('/api/settings/api-keys', { + requireAdminSession: false, + })); return true; } catch (e) { await handleProtectedSettingsError(e); @@ -543,6 +550,40 @@ const SettingsPanel = React.memo(function SettingsPanel({ } }, [handleProtectedSettingsError]); + const saveApiKey = useCallback( + async (envKey: string | null) => { + if (!envKey) return; + const value = String(apiKeyInputs[envKey] || '').trim(); + if (!value) { + setApiKeyMsg({ type: 'err', text: `Enter a value for ${envKey}.` }); + return; + } + setApiKeySaving(envKey); + setApiKeyMsg(null); + try { + const result = await controlPlaneJson<{ + keys?: ApiEntry[]; + env?: EnvMeta; + }>('/api/settings/api-keys', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [envKey]: value }), + requireAdminSession: false, + }); + if (result.keys) setApis(result.keys); + if (result.env) setEnvMeta(result.env); + setApiKeyInputs((prev) => ({ ...prev, [envKey]: '' })); + setApiKeyMsg({ type: 'ok', text: `${envKey} saved locally. Restart or refresh feeds to use it.` }); + } catch (e) { + const message = e instanceof Error ? e.message : 'Could not save API key'; + setApiKeyMsg({ type: 'err', text: message }); + } finally { + setApiKeySaving(null); + } + }, + [apiKeyInputs], + ); + const fetchEnvMeta = useCallback(async () => { try { const res = await fetch('/api/settings/api-keys/meta'); @@ -663,10 +704,10 @@ const SettingsPanel = React.memo(function SettingsPanel({ } void (async () => { const ready = await refreshAdminSession(); + await fetchKeys(); if (ready) { - await Promise.all([fetchKeys(), fetchFeeds()]); + await fetchFeeds(); } else { - setApis([]); setFeeds([]); setFeedsDirty(false); } @@ -713,12 +754,13 @@ const SettingsPanel = React.memo(function SettingsPanel({ }, [onClose, wormholeEnabled, wormholeSaving, wormholeStatus]); useEffect(() => { - if (!isOpen || !adminSessionReady) return; + if (!isOpen) return; if (activeTab === 'api-keys') { void fetchKeys(); void fetchEnvMeta(); return; } + if (!adminSessionReady) return; if (activeTab === 'news-feeds') { void fetchFeeds(); } @@ -2166,18 +2208,21 @@ const SettingsPanel = React.memo(function SettingsPanel({

- API keys are stored locally in the backend{' '} - .env file. Keys marked with{' '} - are required for full - functionality. Public APIs need no key. + API keys are saved locally by this backend. Values are write-only: the app + stores the key and shows CONFIGURED, but it never reads the secret back into + the browser. Keys marked with{' '} + unlock the richest live + aircraft and vessel feeds.

{envMeta && (
- .env path:{' '} - {envMeta.env_path}{' '} - {envMeta.env_path_exists ? ( + local key store:{' '} + + {envMeta.operator_keys_env_path || envMeta.env_path} + {' '} + {envMeta.operator_keys_env_path_exists || envMeta.env_path_exists ? ( [exists] ) : ( [will be created on first save] @@ -2199,6 +2244,15 @@ const SettingsPanel = React.memo(function SettingsPanel({ )}
)} + {apiKeyMsg && ( +
+ {apiKeyMsg.text} +
+ )}
{/* API List */} @@ -2288,9 +2342,9 @@ const SettingsPanel = React.memo(function SettingsPanel({ {api.description}

{api.has_key && ( -
+
{api.is_set ? ( - <> +
CONFIGURED @@ -2299,23 +2353,53 @@ const SettingsPanel = React.memo(function SettingsPanel({ {api.env_key} {' '} - in the .env file (path shown above) and restart the backend. + Enter a replacement below if you need to rotate it. - +
) : ( - <> +
NOT CONFIGURED - add{' '} - - {api.env_key}=YOUR_VALUE - {' '} - to the .env file (path shown above) and restart the backend. + Save {api.env_key} here to enable this source. - +
)} +
+ { + if (!api.env_key) return; + setApiKeyInputs((prev) => ({ + ...prev, + [api.env_key as string]: event.target.value, + })); + }} + placeholder={ + api.is_set + ? 'Enter replacement key...' + : `Enter ${api.env_key}...` + } + className="min-w-0 flex-1 bg-[var(--bg-primary)] border border-[var(--border-primary)] px-2 py-1.5 text-sm text-[var(--text-primary)] outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/50" + autoComplete="off" + /> + +
)}