// 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 }; } /** * GET /api/v2/asset-search/{assetId}?search_param=deep_search * Search CARD by asset ID (e.g., "24.24.100.20-CTEC"). Returns the full * enriched asset record including ncim_discovery, netops_granite_allips, etc. * * @param {string} assetId - CARD asset identifier (IP-SUFFIX format) * @param {object} [options] - { timeout } */ async function searchByAssetId(assetId, options) { const id = (assetId || '').trim(); if (!id) { return { status: 400, body: '{"error":"Asset ID is required."}', ok: false }; } const res = await cardGet(`/api/v2/asset-search/${encodeURIComponent(id)}?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 ? 30000 : undefined; // 30s 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, searchByAssetId, };