Add connection retry to CARD API for DNS round-robin dead nodes

nidl.charter.com resolves to multiple A records via round-robin but not all
are reachable from this network. Both acquireToken() and cardRequest() now
retry up to 2 times on ETIMEDOUT/ECONNREFUSED/ECONNRESET before failing.

Also prunes obsolete metric categories from compliance_config.json and fixes
a comment referencing the correct CARD API hostname.
This commit is contained in:
Jordan Ramos
2026-08-10 14:32:23 -06:00
parent 1d42e09b9b
commit bd5eb2b47a
3 changed files with 130 additions and 112 deletions

View File

@@ -11,8 +11,8 @@ 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.
// Force IPv4-first DNS resolution — nidl.charter.com (and card.charter.com) have
// IPv6 AAAA records that are unreachable from this network, causing timeouts.
dns.setDefaultResultOrder('ipv4first');
// ---------------------------------------------------------------------------
@@ -36,6 +36,10 @@ const isConfigured = missingVars.length === 0;
// ---------------------------------------------------------------------------
let cachedToken = null; // { token: string, expiresAt: number (epoch ms) }
// Connection retry count for DNS round-robin with unreachable nodes
// (nidl.charter.com has multiple A records, not all reachable from this network)
const MAX_CONNECT_RETRIES = 2; // Retry up to 2 times on ETIMEDOUT/ECONNREFUSED
function tokenIsValid() {
if (!cachedToken) return false;
// Refresh if within 60 seconds of expiry
@@ -49,72 +53,91 @@ function invalidateToken() {
/**
* 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.
* Retries on connection-level failures (DNS round-robin dead nodes).
*/
function acquireToken(timeout) {
async 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;
const reqTimeout = timeout || 30000;
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
let lastError;
for (let attempt = 0; attempt <= MAX_CONNECT_RETRIES; attempt++) {
try {
const token = await 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: reqTimeout,
};
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();
});
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.'));
}
resolve(token);
});
});
req.on('timeout', () => req.destroy(new Error('POST /api/v1/auth/get_token timed out')));
req.on('error', (err) => reject(err));
req.end();
});
cachedToken = {
token,
expiresAt: Date.now() + 60 * 60 * 1000, // 1-hour TTL
};
return cachedToken.token;
} catch (err) {
lastError = err;
const isRetryable = err.code === 'ETIMEDOUT' || err.code === 'ECONNREFUSED'
|| err.code === 'ECONNRESET' || (err.message && err.message.includes('timed out'));
if (isRetryable && attempt < MAX_CONNECT_RETRIES) {
console.warn(`[card-api] Token acquisition attempt ${attempt + 1} failed (${err.code || 'timeout'}), retrying...`);
continue;
}
// Non-retryable error (e.g., HTTP 401) or retries exhausted — throw as-is
if (err.message && err.message.startsWith('[card-api]')) throw err;
throw new Error(`[card-api] POST /api/v1/auth/get_token failed: ${err.message}`);
}
}
throw new Error(`[card-api] Token acquisition failed after ${MAX_CONNECT_RETRIES + 1} attempts: ${lastError.message}`);
}
/**
@@ -127,7 +150,10 @@ async function ensureToken(timeout) {
// ---------------------------------------------------------------------------
// Generic request — supports GET and POST with Bearer auth + 401 retry
// Includes connection retry for DNS round-robin with unreachable nodes
// (nidl.charter.com has multiple A records, not all reachable from this network)
// ---------------------------------------------------------------------------
async function cardRequest(method, urlPath, body, options) {
const timeout = (options && options.timeout) || 30000;
const skipAuth = (options && options.skipAuth) || false;
@@ -150,35 +176,51 @@ async function cardRequest(method, urlPath, body, options) {
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,
};
// Retry wrapper for connection-level failures (DNS round-robin dead nodes)
let lastError;
for (let attempt = 0; attempt <= MAX_CONNECT_RETRIES; attempt++) {
try {
return await 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;
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(err);
});
if (bodyStr) req.write(bodyStr);
req.end();
});
} catch (err) {
lastError = err;
const isRetryable = err.code === 'ETIMEDOUT' || err.code === 'ECONNREFUSED'
|| err.code === 'ECONNRESET' || (err.message && err.message.includes('timed out'));
if (isRetryable && attempt < MAX_CONNECT_RETRIES) {
console.warn(`[card-api] ${method} ${urlPath} attempt ${attempt + 1} failed (${err.code || 'timeout'}), retrying...`);
continue;
}
throw new Error(`[card-api] ${method} ${urlPath} failed: ${err.message}`);
}
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();
});
}
throw new Error(`[card-api] ${method} ${urlPath} failed after ${MAX_CONNECT_RETRIES + 1} attempts: ${lastError.message}`);
}
// Skip auth for the token endpoint itself

View File

@@ -1,51 +1,27 @@
{
"metric_categories": {
"1.1.1": "Asset Data Quality",
"1.1.2": "Asset Data Quality",
"1.1.3": "Disaster Recovery",
"1.2.2": "Logging & Monitoring",
"1.2.4": "End-of-Life OS",
"1.2.5": "Endpoint Protection",
"1.2.5All": "Endpoint Protection",
"1.4.1": "Disaster Recovery",
"1.4.2": "Disaster Recovery",
"1.5.1B": "Application Security",
"1.5.2": "Application Security",
"2.3.3i": "Vulnerability Management",
"2.3.4i": "Vulnerability Management",
"2.3.5i": "Vulnerability Management",
"2.3.6i": "Vulnerability Management",
"2.3.7i": "Vulnerability Management",
"2.3.8i": "Vulnerability Management",
"2.3.9i": "Vulnerability Management",
"5.2.3": "Access & MFA",
"5.2.4": "Access & MFA",
"5.2.5": "Access & MFA",
"5.2.6": "Access & MFA",
"5.2.7": "Access & MFA",
"5.2.8": "Access & MFA",
"5.3.4": "Endpoint Protection",
"5.4.2": "Endpoint Protection",
"5.4.3": "Endpoint Protection",
"5.4.6": "Vulnerability Management",
"5.4.6i": "Vulnerability Management",
"5.5.2": "End-of-Life OS",
"5.5.4": "Vulnerability Management",
"5.5.4i": "Vulnerability Management",
"5.5.5": "Decommissioned Assets",
"5.6.2A": "Vulnerability Management",
"5.6.3": "Asset Data Quality",
"5.6.3B": "Access & MFA",
"5.7.1": "Logging & Monitoring",
"5.8.1": "Application Security",
"7.1.1": "Logging & Monitoring",
"7.1.4": "Asset Data Quality",
"7.6.13": "Disaster Recovery",
"7.6.15": "Disaster Recovery",
"7.6.16": "Disaster Recovery",
"Missing_AppID": "Asset Data Quality",
"Missing_DF": "Asset Data Quality",
"Missing_EOS": "End-of-Life OS",
"Missing_OS": "Asset Data Quality",
"Vulns_Aging": "Vulnerability Management"
},

View File

@@ -2,7 +2,7 @@
// Install: npm install express pg multer cors dotenv bcryptjs cookie-parser
// Force IPv4-first DNS resolution globally — must be set before any network modules load.
// card.charter.com has IPv6 AAAA records that are unreachable from this network.
// nidl.charter.com (CARD API) has IPv6 AAAA records that are unreachable from this network.
require('dns').setDefaultResultOrder('ipv4first');
require('dotenv').config();