mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
settings: verify API keys via provider probes
This commit is contained in:
@@ -314,6 +314,253 @@ function makeCorsHeaders(req) {
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}, timeoutMs = 12000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function relayToHttpUrl(rawUrl) {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
|
||||
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
|
||||
return parsed.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthFailure(status, text = '') {
|
||||
if (status === 401 || status === 403) return true;
|
||||
return /unauthori[sz]ed|forbidden|invalid api key|invalid token|bad credentials/i.test(text);
|
||||
}
|
||||
|
||||
async function validateSecretAgainstProvider(key, rawValue, context = {}) {
|
||||
const value = String(rawValue || '').trim();
|
||||
if (!value) return { valid: false, message: 'Value is required' };
|
||||
|
||||
const fail = (message) => ({ valid: false, message });
|
||||
const ok = (message) => ({ valid: true, message });
|
||||
|
||||
try {
|
||||
switch (key) {
|
||||
case 'GROQ_API_KEY': {
|
||||
const response = await fetchWithTimeout('https://api.groq.com/openai/v1/models', {
|
||||
headers: { Authorization: `Bearer ${value}` },
|
||||
});
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('Groq rejected this key');
|
||||
if (!response.ok) return fail(`Groq probe failed (${response.status})`);
|
||||
return ok('Groq key verified');
|
||||
}
|
||||
|
||||
case 'OPENROUTER_API_KEY': {
|
||||
const response = await fetchWithTimeout('https://openrouter.ai/api/v1/models', {
|
||||
headers: { Authorization: `Bearer ${value}` },
|
||||
});
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('OpenRouter rejected this key');
|
||||
if (!response.ok) return fail(`OpenRouter probe failed (${response.status})`);
|
||||
return ok('OpenRouter key verified');
|
||||
}
|
||||
|
||||
case 'FRED_API_KEY': {
|
||||
const response = await fetchWithTimeout(
|
||||
`https://api.stlouisfed.org/fred/series?series_id=GDP&api_key=${encodeURIComponent(value)}&file_type=json`,
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const text = await response.text();
|
||||
if (!response.ok) return fail(`FRED probe failed (${response.status})`);
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(text); } catch { /* ignore */ }
|
||||
if (payload?.error_code || payload?.error_message) return fail('FRED rejected this key');
|
||||
if (!Array.isArray(payload?.seriess)) return fail('Unexpected FRED response');
|
||||
return ok('FRED key verified');
|
||||
}
|
||||
|
||||
case 'EIA_API_KEY': {
|
||||
const response = await fetchWithTimeout(
|
||||
`https://api.eia.gov/v2/seriesid/PET.RWTC.W?api_key=${encodeURIComponent(value)}&num=1`,
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('EIA rejected this key');
|
||||
if (!response.ok) return fail(`EIA probe failed (${response.status})`);
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(text); } catch { /* ignore */ }
|
||||
if (!Array.isArray(payload?.response?.data)) return fail('Unexpected EIA response');
|
||||
return ok('EIA key verified');
|
||||
}
|
||||
|
||||
case 'CLOUDFLARE_API_TOKEN': {
|
||||
const response = await fetchWithTimeout('https://api.cloudflare.com/client/v4/user/tokens/verify', {
|
||||
headers: { Authorization: `Bearer ${value}` },
|
||||
});
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('Cloudflare rejected this token');
|
||||
if (!response.ok) return fail(`Cloudflare probe failed (${response.status})`);
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(text); } catch { /* ignore */ }
|
||||
if (payload?.success !== true) return fail('Cloudflare token verification failed');
|
||||
return ok('Cloudflare token verified');
|
||||
}
|
||||
|
||||
case 'ACLED_ACCESS_TOKEN': {
|
||||
const response = await fetchWithTimeout('https://acleddata.com/api/acled/read?_format=json&limit=1', {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${value}`,
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('ACLED rejected this token');
|
||||
if (!response.ok) return fail(`ACLED probe failed (${response.status})`);
|
||||
return ok('ACLED token verified');
|
||||
}
|
||||
|
||||
case 'URLHAUS_AUTH_KEY': {
|
||||
const response = await fetchWithTimeout('https://urlhaus-api.abuse.ch/v1/urls/recent/limit/1/', {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Auth-Key': value,
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('URLhaus rejected this key');
|
||||
if (!response.ok) return fail(`URLhaus probe failed (${response.status})`);
|
||||
return ok('URLhaus key verified');
|
||||
}
|
||||
|
||||
case 'OTX_API_KEY': {
|
||||
const response = await fetchWithTimeout('https://otx.alienvault.com/api/v1/user/me', {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-OTX-API-KEY': value,
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('OTX rejected this key');
|
||||
if (!response.ok) return fail(`OTX probe failed (${response.status})`);
|
||||
return ok('OTX key verified');
|
||||
}
|
||||
|
||||
case 'ABUSEIPDB_API_KEY': {
|
||||
const response = await fetchWithTimeout('https://api.abuseipdb.com/api/v2/check?ipAddress=8.8.8.8&maxAgeInDays=90', {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Key: value,
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('AbuseIPDB rejected this key');
|
||||
if (!response.ok) return fail(`AbuseIPDB probe failed (${response.status})`);
|
||||
return ok('AbuseIPDB key verified');
|
||||
}
|
||||
|
||||
case 'WINGBITS_API_KEY': {
|
||||
const response = await fetchWithTimeout('https://customer-api.wingbits.com/v1/flights/details/3c6444', {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'x-api-key': value,
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('Wingbits rejected this key');
|
||||
if (response.status >= 500) return fail(`Wingbits probe failed (${response.status})`);
|
||||
return ok('Wingbits key accepted');
|
||||
}
|
||||
|
||||
case 'FINNHUB_API_KEY': {
|
||||
const response = await fetchWithTimeout(`https://finnhub.io/api/v1/quote?symbol=AAPL&token=${encodeURIComponent(value)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('Finnhub rejected this key');
|
||||
if (response.status === 429) return ok('Finnhub key accepted (rate limited)');
|
||||
if (!response.ok) return fail(`Finnhub probe failed (${response.status})`);
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(text); } catch { /* ignore */ }
|
||||
if (typeof payload?.error === 'string' && payload.error.toLowerCase().includes('invalid')) {
|
||||
return fail('Finnhub rejected this key');
|
||||
}
|
||||
if (typeof payload?.c !== 'number') return fail('Unexpected Finnhub response');
|
||||
return ok('Finnhub key verified');
|
||||
}
|
||||
|
||||
case 'NASA_FIRMS_API_KEY': {
|
||||
const response = await fetchWithTimeout(
|
||||
`https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(value)}/VIIRS_SNPP_NRT/22,44,40,53/1`,
|
||||
{ headers: { Accept: 'text/csv' } }
|
||||
);
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('NASA FIRMS rejected this key');
|
||||
if (!response.ok) return fail(`NASA FIRMS probe failed (${response.status})`);
|
||||
if (/invalid api key|not authorized|forbidden/i.test(text)) return fail('NASA FIRMS rejected this key');
|
||||
return ok('NASA FIRMS key verified');
|
||||
}
|
||||
|
||||
case 'WS_RELAY_URL':
|
||||
case 'VITE_WS_RELAY_URL':
|
||||
case 'VITE_OPENSKY_RELAY_URL': {
|
||||
const probeUrl = relayToHttpUrl(value);
|
||||
if (!probeUrl) return fail('Relay URL is invalid');
|
||||
const response = await fetchWithTimeout(probeUrl, { method: 'GET' });
|
||||
if (response.status >= 500) return fail(`Relay probe failed (${response.status})`);
|
||||
return ok('Relay URL is reachable');
|
||||
}
|
||||
|
||||
case 'OPENSKY_CLIENT_ID':
|
||||
case 'OPENSKY_CLIENT_SECRET': {
|
||||
const contextClientId = typeof context.OPENSKY_CLIENT_ID === 'string' ? context.OPENSKY_CLIENT_ID.trim() : '';
|
||||
const contextClientSecret = typeof context.OPENSKY_CLIENT_SECRET === 'string' ? context.OPENSKY_CLIENT_SECRET.trim() : '';
|
||||
const clientId = key === 'OPENSKY_CLIENT_ID'
|
||||
? value
|
||||
: (contextClientId || String(process.env.OPENSKY_CLIENT_ID || '').trim());
|
||||
const clientSecret = key === 'OPENSKY_CLIENT_SECRET'
|
||||
? value
|
||||
: (contextClientSecret || String(process.env.OPENSKY_CLIENT_SECRET || '').trim());
|
||||
if (!clientId || !clientSecret) {
|
||||
return fail('Set both OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET before verification');
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
});
|
||||
const response = await fetchWithTimeout(
|
||||
'https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
}
|
||||
);
|
||||
const text = await response.text();
|
||||
if (isAuthFailure(response.status, text)) return fail('OpenSky rejected these credentials');
|
||||
if (!response.ok) return fail(`OpenSky auth probe failed (${response.status})`);
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(text); } catch { /* ignore */ }
|
||||
if (!payload?.access_token) return fail('OpenSky auth response did not include an access token');
|
||||
return ok('OpenSky credentials verified');
|
||||
}
|
||||
|
||||
case 'AISSTREAM_API_KEY':
|
||||
return ok('AISSTREAM key stored (live verification not available in sidecar)');
|
||||
|
||||
default:
|
||||
return ok('Key stored');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'provider probe failed';
|
||||
return fail(`Verification request failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatch(requestUrl, req, routes, context) {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: makeCorsHeaders(req) });
|
||||
@@ -387,6 +634,25 @@ async function dispatch(requestUrl, req, routes, context) {
|
||||
return json({ error: 'POST required' }, 405);
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === '/api/local-validate-secret') {
|
||||
if (req.method !== 'POST') {
|
||||
return json({ error: 'POST required' }, 405);
|
||||
}
|
||||
const body = await readBody(req);
|
||||
if (!body) return json({ error: 'expected { key, value }' }, 400);
|
||||
try {
|
||||
const { key, value, context } = JSON.parse(body.toString());
|
||||
if (typeof key !== 'string' || !ALLOWED_ENV_KEYS.has(key)) {
|
||||
return json({ error: 'key not in allowlist' }, 403);
|
||||
}
|
||||
const safeContext = (context && typeof context === 'object') ? context : {};
|
||||
const result = await validateSecretAgainstProvider(key, value, safeContext);
|
||||
return json(result, result.valid ? 200 : 422);
|
||||
} catch {
|
||||
return json({ error: 'expected { key, value }' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.cloudFallback && cloudPreferred.has(requestUrl.pathname)) {
|
||||
const cloudResponse = await tryCloudFallback(requestUrl, req, context);
|
||||
if (cloudResponse) return cloudResponse;
|
||||
@@ -462,7 +728,10 @@ export async function createLocalApiServer(options = {}) {
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const skipRecord = requestUrl.pathname === '/api/local-traffic-log' || requestUrl.pathname === '/api/local-debug-toggle' || requestUrl.pathname === '/api/local-env-update';
|
||||
const skipRecord = requestUrl.pathname === '/api/local-traffic-log'
|
||||
|| requestUrl.pathname === '/api/local-debug-toggle'
|
||||
|| requestUrl.pathname === '/api/local-env-update'
|
||||
|| requestUrl.pathname === '/api/local-validate-secret';
|
||||
|
||||
try {
|
||||
const response = await dispatch(requestUrl, req, routes, context);
|
||||
|
||||
Reference in New Issue
Block a user