Files
cve-dashboard/backend/helpers/cardApi.js
Jordan Ramos 46dd2256f5 Fix CARD production timeout with dns.setDefaultResultOrder('ipv4first')
The family:4 option on individual requests wasn't sufficient.
Node.js 18 needs dns.setDefaultResultOrder('ipv4first') called
at module load time to prevent IPv6 resolution attempts to
card.charter.com which is unreachable via IPv6 from this network.
2026-05-28 13:44:20 -06:00

351 lines
12 KiB
JavaScript

// Shared CARD API helpers
// Centralizes HTTP calls for the CARD asset ownership API.
// Follows the same promise-based pattern as atlasApi.js, with the addition
// of OAuth Bearer token management (auto-acquire, cache, refresh, 401 retry).
//
// CARD API versioning:
// - Read endpoints (GET): /api/v1/...
// - Mutation endpoints (POST): /api/v2/...
const https = require('https');
const http = require('http');
const dns = require('dns');
// Force IPv4-first DNS resolution — card.charter.com has both IPv4 and IPv6
// records but IPv6 is unreachable from this network, causing timeouts.
dns.setDefaultResultOrder('ipv4first');
// ---------------------------------------------------------------------------
// Configuration — read from process.env at module load
// ---------------------------------------------------------------------------
const CARD_API_URL = process.env.CARD_API_URL || '';
const CARD_API_USER = process.env.CARD_API_USER || '';
const CARD_API_PASS = process.env.CARD_API_PASS || '';
const CARD_SKIP_TLS = process.env.CARD_SKIP_TLS === 'true';
const requiredVars = ['CARD_API_URL', 'CARD_API_USER', 'CARD_API_PASS'];
const missingVars = requiredVars.filter((v) => !process.env[v]);
if (missingVars.length > 0) {
console.warn(`[card-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. CARD API calls will fail.`);
}
const isConfigured = missingVars.length === 0;
// ---------------------------------------------------------------------------
// Token Manager — OAuth Bearer token with 1-hour TTL
// ---------------------------------------------------------------------------
let cachedToken = null; // { token: string, expiresAt: number (epoch ms) }
function tokenIsValid() {
if (!cachedToken) return false;
// Refresh if within 60 seconds of expiry
return cachedToken.expiresAt - Date.now() > 60_000;
}
function invalidateToken() {
cachedToken = null;
}
/**
* Acquire a new Bearer token from CARD /api/v1/auth/get_token using Basic Auth.
* Caches the token in memory with a 1-hour TTL.
*/
function acquireToken(timeout) {
const authString = Buffer.from(CARD_API_USER + ':' + CARD_API_PASS).toString('base64');
const fullUrl = new URL(CARD_API_URL + '/api/v1/auth/get_token');
const isHttps = fullUrl.protocol === 'https:';
const transport = isHttps ? https : http;
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: fullUrl.hostname,
port: fullUrl.port || (isHttps ? 443 : 80),
path: fullUrl.pathname + fullUrl.search,
method: 'POST',
family: 4, // Force IPv4 — IPv6 is unreachable from this network
headers: {
'accept': 'application/json',
'authorization': 'Basic ' + authString,
'content-length': '0',
},
timeout: timeout || 30000,
};
if (isHttps) {
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
}
const req = transport.request(reqOptions, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(
`[card-api] Token acquisition failed with HTTP ${res.statusCode}: ${data.substring(0, 500)}`
));
}
// The CARD API returns the token as a JSON string or object.
// Try to parse; fall back to raw body as the token string.
let token;
try {
const parsed = JSON.parse(data);
token = typeof parsed === 'string' ? parsed
: parsed.token || parsed.access_token || data.trim();
} catch (_) {
// Response may be a plain token string (unquoted)
token = data.trim();
}
if (!token) {
return reject(new Error('[card-api] Token parse failure: empty token in response body.'));
}
cachedToken = {
token,
expiresAt: Date.now() + 60 * 60 * 1000, // 1-hour TTL
};
resolve(cachedToken.token);
});
});
req.on('timeout', () => req.destroy(new Error('GET /api/v1/auth/get_token timed out')));
req.on('error', (err) => {
reject(new Error(`[card-api] GET /api/v1/auth/get_token failed: ${err.message}`));
});
req.end();
});
}
/**
* Ensure we have a valid Bearer token, acquiring or refreshing as needed.
*/
async function ensureToken(timeout) {
if (tokenIsValid()) return cachedToken.token;
return acquireToken(timeout);
}
// ---------------------------------------------------------------------------
// Generic request — supports GET and POST with Bearer auth + 401 retry
// ---------------------------------------------------------------------------
async function cardRequest(method, urlPath, body, options) {
const timeout = (options && options.timeout) || 30000;
const skipAuth = (options && options.skipAuth) || false;
async function doRequest(bearerToken) {
const fullUrl = new URL(CARD_API_URL + urlPath);
const isHttps = fullUrl.protocol === 'https:';
const transport = isHttps ? https : http;
const headers = { 'accept': 'application/json' };
if (bearerToken) {
headers['authorization'] = 'Bearer ' + bearerToken;
}
let bodyStr = null;
if (body !== null && body !== undefined) {
bodyStr = JSON.stringify(body);
headers['content-type'] = 'application/json';
headers['content-length'] = Buffer.byteLength(bodyStr);
}
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: fullUrl.hostname,
port: fullUrl.port || (isHttps ? 443 : 80),
path: fullUrl.pathname + fullUrl.search,
method,
family: 4, // Force IPv4 — IPv6 is unreachable from this network
headers,
timeout,
};
if (isHttps) {
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
}
const req = transport.request(reqOptions, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('timeout', () => req.destroy(new Error(`${method} ${urlPath} timed out`)));
req.on('error', (err) => {
reject(new Error(`[card-api] ${method} ${urlPath} failed: ${err.message}`));
});
if (bodyStr) req.write(bodyStr);
req.end();
});
}
// Skip auth for the token endpoint itself
if (skipAuth) {
return doRequest(null);
}
// Normal flow: ensure token → request → retry once on 401
let token = await ensureToken(timeout);
let result = await doRequest(token);
if (result.status === 401) {
// Invalidate and retry exactly once
invalidateToken();
token = await ensureToken(timeout);
result = await doRequest(token);
}
return result;
}
// ---------------------------------------------------------------------------
// Convenience wrappers
// ---------------------------------------------------------------------------
function cardGet(urlPath, options) {
return cardRequest('GET', urlPath, null, options);
}
function cardPost(urlPath, body, options) {
return cardRequest('POST', urlPath, body, options);
}
// ---------------------------------------------------------------------------
// High-level helpers used by the UAT test and routes
// ---------------------------------------------------------------------------
/**
* Test connection by acquiring a token. Returns { ok, token } or { ok, error }.
*/
async function testConnection() {
try {
const token = await acquireToken();
return { ok: true, token: token.substring(0, 12) + '...' };
} catch (err) {
return { ok: false, error: err.message };
}
}
/**
* GET /api/v1/teams — list all CARD teams.
*/
async function getTeams() {
const res = await cardGet('/api/v1/teams');
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* GET /api/v1/team/{teamName}/assets — list assets for a team.
*/
async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
const params = new URLSearchParams();
if (disposition) params.set('disposition', disposition);
if (page) params.set('page', String(page));
params.set('page_size', String(pageSize || 50));
const qs = params.toString();
const res = await cardGet(`/api/v1/team/${encodeURIComponent(teamName)}/assets${qs ? '?' + qs : ''}`);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* GET /api/v1/owner/{assetId} — get owner record including update_token.
*/
async function getOwner(assetId) {
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* POST /api/v2/owner/{assetId}/confirm — confirm asset to a team.
*/
async function confirmAsset(assetId, teamName, updateToken, comment) {
const params = new URLSearchParams({ update_token: updateToken });
if (comment) params.set('comment', comment);
const res = await cardPost(
`/api/v2/owner/${encodeURIComponent(assetId)}/confirm?${params.toString()}`,
{ name: teamName }
);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* POST /api/v2/owner/{assetId}/decline — decline asset from a team.
*/
async function declineAsset(assetId, teamName, updateToken, comment) {
const params = new URLSearchParams({ update_token: updateToken });
if (comment) params.set('comment', comment);
const res = await cardPost(
`/api/v2/owner/${encodeURIComponent(assetId)}/decline?${params.toString()}`,
{ name: teamName }
);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* POST /api/v2/owner/{assetId}/{fromTeam}/redirect — redirect asset between teams.
*/
async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
const params = new URLSearchParams({ update_token: updateToken });
const res = await cardPost(
`/api/v2/owner/${encodeURIComponent(assetId)}/${encodeURIComponent(fromTeam)}/redirect?${params.toString()}`,
{ name: toTeam }
);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* Resolve a bare IP address to a full CARD asset ID by trying known suffixes.
* Returns the first asset ID that returns a valid owner record, or null if none found.
*/
async function resolveAssetId(ip) {
const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
const trimmedIp = (ip || '').trim();
if (!trimmedIp) return null;
// If it already has a suffix (contains a dash followed by letters), use as-is
if (/\d+-[A-Z]+$/i.test(trimmedIp)) {
const result = await getOwner(trimmedIp);
if (result.ok) return trimmedIp;
}
// Try each suffix
for (const suffix of SUFFIXES) {
const candidate = `${trimmedIp}-${suffix}`;
try {
const result = await getOwner(candidate);
if (result.ok) return candidate;
} catch (_) {
// Continue to next suffix
}
}
// Try bare IP as last resort
try {
const result = await getOwner(trimmedIp);
if (result.ok) return trimmedIp;
} catch (_) {
// Not found
}
return null;
}
module.exports = {
isConfigured,
missingVars,
cardRequest,
cardGet,
cardPost,
testConnection,
getTeams,
getTeamAssets,
getOwner,
confirmAsset,
declineAsset,
redirectAsset,
invalidateToken,
resolveAssetId,
};