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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user