Add in-app local API key setup

Let fresh Docker and local installs enter OpenSky, AIS, and other provider keys directly in onboarding or Settings without manually creating .env files. Persist keys server-side in the backend data store, keep them write-only from the browser, reload runtime settings, and retain local-operator access controls.
This commit is contained in:
BigBodyCobain
2026-05-02 21:16:32 -06:00
parent eb0288ee4e
commit 707ca29220
6 changed files with 409 additions and 31 deletions
+22 -1
View File
@@ -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):
+147
View File
@@ -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(),
}
+5
View File
@@ -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()
+38
View File
@@ -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"
+88 -5
View File
@@ -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 (
<AnimatePresence>
{/* Backdrop */}
@@ -218,14 +257,58 @@ const OnboardingModal = React.memo(function OnboardingModal({
</p>
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
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.
</p>
</div>
</div>
</div>
<div className="border border-cyan-900/40 bg-cyan-950/10 p-4 space-y-3">
<div>
<p className="text-[11px] text-cyan-300 font-mono font-bold tracking-widest">
QUICK LOCAL SETUP
</p>
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed mt-1">
Paste keys here once. ShadowBroker stores them server-side only and never
displays the secret back in the browser.
</p>
</div>
{[
['OPENSKY_CLIENT_ID', 'OpenSky Client ID'],
['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'],
['AIS_API_KEY', 'AIS Stream API Key'],
].map(([key, label]) => (
<input
key={key}
type="password"
value={setupKeys[key as keyof typeof setupKeys]}
onChange={(event) =>
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 && (
<p
className={`text-sm font-mono ${
setupMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
}`}
>
{setupMsg.text}
</p>
)}
<button
onClick={() => void saveSetupKeys()}
disabled={setupSaving}
className="w-full py-2 bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-[11px] font-mono tracking-widest"
>
{setupSaving ? 'SAVING...' : 'SAVE KEYS LOCALLY'}
</button>
</div>
{API_GUIDES.map((api) => (
<div
key={api.name}
+109 -25
View File
@@ -120,6 +120,9 @@ interface EnvMeta {
env_path_writable: boolean;
env_example_path: string;
env_example_path_exists: boolean;
operator_keys_env_path?: string;
operator_keys_env_path_exists?: boolean;
operator_keys_env_path_writable?: boolean;
}
const WEIGHT_LABELS: Record<number, string> = {
@@ -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<ApiEntry[]>([]);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeySaving, setApiKeySaving] = useState<string | null>(null);
const [apiKeyMsg, setApiKeyMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['Aviation', 'Maritime']),
);
@@ -535,7 +540,9 @@ const SettingsPanel = React.memo(function SettingsPanel({
const fetchKeys = useCallback(async () => {
try {
setApis(await controlPlaneJson<ApiEntry[]>('/api/settings/api-keys'));
setApis(await controlPlaneJson<ApiEntry[]>('/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({
<div className="flex items-start gap-2">
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
API keys are stored locally in the backend{' '}
<span className="text-cyan-400">.env</span> file. Keys marked with{' '}
<Key size={8} className="inline text-yellow-500" /> 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{' '}
<Key size={8} className="inline text-yellow-500" /> unlock the richest live
aircraft and vessel feeds.
</p>
</div>
{envMeta && (
<div className="pl-5 text-[12px] font-mono text-[var(--text-muted)] leading-relaxed space-y-0.5">
<div>
<span className="text-cyan-500/70">.env path:</span>{' '}
<span className="text-cyan-300 break-all select-all">{envMeta.env_path}</span>{' '}
{envMeta.env_path_exists ? (
<span className="text-cyan-500/70">local key store:</span>{' '}
<span className="text-cyan-300 break-all select-all">
{envMeta.operator_keys_env_path || envMeta.env_path}
</span>{' '}
{envMeta.operator_keys_env_path_exists || envMeta.env_path_exists ? (
<span className="text-green-400/80">[exists]</span>
) : (
<span className="text-amber-400/80">[will be created on first save]</span>
@@ -2199,6 +2244,15 @@ const SettingsPanel = React.memo(function SettingsPanel({
)}
</div>
)}
{apiKeyMsg && (
<div
className={`pl-5 text-sm font-mono ${
apiKeyMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
}`}
>
{apiKeyMsg.text}
</div>
)}
</div>
{/* API List */}
@@ -2288,9 +2342,9 @@ const SettingsPanel = React.memo(function SettingsPanel({
{api.description}
</p>
{api.has_key && (
<div className="mt-2 flex items-center gap-2 text-[12px] font-mono">
<div className="mt-2 space-y-2 text-[12px] font-mono">
{api.is_set ? (
<>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 border border-green-500/40 bg-green-950/20 text-green-300 tracking-wider">
CONFIGURED
</span>
@@ -2299,23 +2353,53 @@ const SettingsPanel = React.memo(function SettingsPanel({
<span className="text-cyan-300 select-all break-all">
{api.env_key}
</span>{' '}
in the .env file (path shown above) and restart the backend.
Enter a replacement below if you need to rotate it.
</span>
</>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 border border-amber-500/40 bg-amber-950/20 text-amber-300 tracking-wider">
NOT CONFIGURED
</span>
<span className="text-[var(--text-muted)]">
add{' '}
<span className="text-amber-200 select-all break-all">
{api.env_key}=YOUR_VALUE
</span>{' '}
to the .env file (path shown above) and restart the backend.
Save {api.env_key} here to enable this source.
</span>
</>
</div>
)}
<div className="flex items-center gap-2">
<input
type="password"
value={api.env_key ? apiKeyInputs[api.env_key] || '' : ''}
onChange={(event) => {
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"
/>
<button
onClick={() => void saveApiKey(api.env_key)}
disabled={
!api.env_key ||
apiKeySaving === api.env_key ||
!String(
api.env_key ? apiKeyInputs[api.env_key] || '' : '',
).trim()
}
className="h-8 px-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-500/15 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 tracking-widest"
>
<Save size={12} />
{apiKeySaving === api.env_key ? 'SAVING' : 'SAVE'}
</button>
</div>
</div>
)}
</div>