Integrate CARD's new v2 asset-search endpoint that accepts Ivanti Asset ID integers directly, eliminating the slow suffix-guessing resolution flow. Changes: - Add searchByIvantiHostId() helper to cardApi.js - Add GET /api/card/asset-search/:hostId endpoint - Update CARD queue confirm/decline/redirect to try host_id fast path first - Update owner-lookup to accept optional hostId query param for fast resolution - Pass hostId through CardOwnerTooltip and ReportingPage for tooltip lookups - Join ivanti_findings in todo queue GET to expose host_id on queue items - Update CardActionModal to pass host_id for faster owner-lookup
388 lines
14 KiB
JavaScript
388 lines
14 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, options) {
|
|
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options);
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* GET /api/v2/asset-search/{ivantiHostId}?search_param=deep_search
|
|
* Search CARD by Ivanti Asset ID (8-digit integer). Returns the CARD asset
|
|
* record directly — no suffix guessing required.
|
|
*
|
|
* @param {string|number} ivantiHostId - 8-character integer Ivanti Host ID
|
|
* @param {object} [options] - { timeout }
|
|
*/
|
|
async function searchByIvantiHostId(ivantiHostId, options) {
|
|
const hostId = String(ivantiHostId).trim();
|
|
if (!hostId || !/^\d+$/.test(hostId)) {
|
|
return { status: 400, body: '{"error":"Invalid Ivanti host ID — must be an integer."}', ok: false };
|
|
}
|
|
const res = await cardGet(`/api/v2/asset-search/${hostId}?search_param=deep_search`, options);
|
|
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.
|
|
*
|
|
* @param {string} ip - IP address or existing asset ID
|
|
* @param {object} [options] - { quick: true } to only try CTEC suffix (for tooltip/hover use)
|
|
*/
|
|
async function resolveAssetId(ip, options) {
|
|
const quick = options && options.quick;
|
|
const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
|
const timeout = quick ? 15000 : undefined; // 15s timeout for quick mode
|
|
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)) {
|
|
try {
|
|
const result = await getOwner(trimmedIp, timeout ? { timeout } : undefined);
|
|
if (result.ok) return trimmedIp;
|
|
} catch (err) {
|
|
// Timeout — throw so caller can distinguish from "not found"
|
|
if (quick && err.message && err.message.includes('timed out')) {
|
|
throw new Error('CARD_TIMEOUT');
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Try each suffix
|
|
for (const suffix of SUFFIXES) {
|
|
const candidate = `${trimmedIp}-${suffix}`;
|
|
try {
|
|
const result = await getOwner(candidate, timeout ? { timeout } : undefined);
|
|
if (result.ok) return candidate;
|
|
} catch (err) {
|
|
// Timeout — throw so caller can distinguish from "not found"
|
|
if (quick && err.message && err.message.includes('timed out')) {
|
|
throw new Error('CARD_TIMEOUT');
|
|
}
|
|
// Continue to next suffix
|
|
}
|
|
}
|
|
|
|
// Try bare IP as last resort (skip in quick mode to avoid extra delay)
|
|
if (!quick) {
|
|
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,
|
|
searchByIvantiHostId,
|
|
};
|