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 (
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. +
++ {setupMsg.text} +
+ )} + +
- API keys are stored locally in the backend{' '}
- .env file. Keys marked with{' '}
-