collabSupport/integrations/paloalto/topology.js
jmcqueen 90a56c4640 Fix Prisma overlay tunnel discovery with site-scoped query_params.
Use topology/links and anynetlinks filters with eq/in operators so
store WAN follow-ups return real peer tunnels instead of unscoped dumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:01:40 -04:00

570 lines
18 KiB
JavaScript

// integrations/paloalto/topology.js
//
// Overlay / VPN tunnel discovery for Prisma SD-WAN.
//
// Live tenant (2026-07-28, store 1005 / CG01005):
// • Query bodies MUST use `query_params` with operator objects
// ({ eq } / { in }). A plain `query: { field: [id] }` is silently
// ignored and returns an unscoped first page.
// • Prefer POST /sdwan/v2.0/api/topology/links/query — rows include
// status, peer site names, wan-if ids, and vpnlinks[].
// • Fallback: POST /sdwan/v4.0/api/anynetlinks/query filtered by
// ep1_site_id / ep2_site_id (config rows; no runtime status).
// • vpnlinks themselves have no site fields (al_id / vep*_id only).
//
// Failures never throw — callers get [] + log.
import { paloAltoAxios } from './client.js';
import { logger } from '../../utils/logger.js';
/** Max overlay edges kept per site after ranking. */
const MAX_TUNNELS = 12;
/** Max status GETs when topology status is missing. */
const MAX_STATUS_FETCH = 8;
/** Topology link types we surface as "overlay tunnels". */
const OVERLAY_TYPES = new Set([
'public-anynet',
'private-anynet',
'vpn',
'anynet',
]);
function isSase() {
return String(process.env.PRISMA_AUTH_MODE || 'sase').toLowerCase().trim() === 'sase';
}
function anynetlinksQueryPath() {
return isSase()
? '/sdwan/v4.0/api/anynetlinks/query'
: '/v4.0/api/anynetlinks/query';
}
function vpnlinksQueryPath() {
return isSase()
? '/sdwan/v2.0/api/vpnlinks/query'
: '/v2.0/api/vpnlinks/query';
}
function vpnlinkStatusPath(vpnLinkId) {
return isSase()
? `/sdwan/v2.2/api/vpnlinks/${vpnLinkId}/status`
: `/v2.2/api/vpnlinks/${vpnLinkId}/status`;
}
function topologyLinksQueryPath() {
return isSase()
? '/sdwan/v2.0/api/topology/links/query'
: '/v2.0/api/topology/links/query';
}
/**
* Collect every site-id-ish field we know Prisma has used across
* anynet / topology-link schemas (vpnlinks themselves do NOT carry these).
*/
export function siteIdsFromTunnelRaw(raw) {
if (!raw || typeof raw !== 'object') return [];
const ids = [];
const push = (v) => {
if (v == null || v === '') return;
if (Array.isArray(v)) {
for (const x of v) push(x);
return;
}
if (typeof v === 'object') {
push(v.id || v.site_id || v.siteId);
return;
}
ids.push(String(v));
};
push(raw.source_site_id);
push(raw.target_site_id);
push(raw.dest_site_id);
push(raw.remote_site_id);
push(raw.peer_site_id);
push(raw.site_id);
push(raw.site1_id);
push(raw.site2_id);
push(raw.src_site_id);
push(raw.ep1_site_id);
push(raw.ep2_site_id);
push(raw.sites);
push(raw.site_ids);
if (raw.ep1) push(raw.ep1.site_id || raw.ep1.siteId);
if (raw.ep2) push(raw.ep2.site_id || raw.ep2.siteId);
if (raw.path) push(raw.path.site_id);
push(raw.source_site);
push(raw.target_site);
push(raw.site1);
push(raw.site2);
return [...new Set(ids)];
}
function peerSiteIdForLocal(raw, localSiteId) {
const local = String(localSiteId);
if (raw.source_site_id && String(raw.source_site_id) === local) {
return raw.target_site_id || raw.dest_site_id || raw.ep2_site_id || null;
}
if (raw.target_site_id && String(raw.target_site_id) === local) {
return raw.source_site_id || raw.ep1_site_id || null;
}
if (raw.ep1_site_id && String(raw.ep1_site_id) === local) {
return raw.ep2_site_id || null;
}
if (raw.ep2_site_id && String(raw.ep2_site_id) === local) {
return raw.ep1_site_id || null;
}
const ids = siteIdsFromTunnelRaw(raw).filter((id) => id !== local);
return ids[0] || null;
}
function peerNameFromRaw(raw, localSiteId = null) {
const local = localSiteId ? String(localSiteId) : null;
if (local && raw.source_site_id && String(raw.source_site_id) === local) {
return raw.target_site_name || raw.sep_name || raw.target_site?.name || null;
}
if (local && raw.target_site_id && String(raw.target_site_id) === local) {
return raw.source_site_name || raw.source_site?.name || null;
}
if (local && raw.ep2_site_id && String(raw.ep2_site_id) === local) {
return raw.ep1_site_name || null;
}
if (local && raw.ep1_site_id && String(raw.ep1_site_id) === local) {
return raw.ep2_site_name || null;
}
return raw.target_site_name
|| raw.source_site_name
|| raw.dest_site_name
|| raw.remote_site_name
|| raw.peer_site_name
|| raw.ep2_site_name
|| raw.ep1_site_name
|| raw.sep_name
|| raw.name
|| null;
}
function localWanIfId(raw, localSiteId) {
const local = String(localSiteId);
if (raw.source_site_id && String(raw.source_site_id) === local) {
return raw.source_wan_if_id || null;
}
if (raw.target_site_id && String(raw.target_site_id) === local) {
return raw.target_wan_if_id || null;
}
if (raw.ep1_site_id && String(raw.ep1_site_id) === local) {
return raw.ep1_wan_interface_id || null;
}
if (raw.ep2_site_id && String(raw.ep2_site_id) === local) {
return raw.ep2_wan_interface_id || null;
}
return raw.source_wan_if_id
|| raw.target_wan_if_id
|| raw.ep1_wan_interface_id
|| raw.ep2_wan_interface_id
|| null;
}
/**
* Normalize a tunnel / anynet / topology-link / vpnlink row.
*
* @param {object} raw
* @param {object|null} [status]
* @param {object} [opts]
* @param {string} [opts.localSiteId]
* @param {string} [opts.peerLabelOverride]
* @param {string} [opts.peerSiteIdOverride]
*/
export function normalizeTunnelRow(raw, status = null, opts = {}) {
if (!raw || typeof raw !== 'object') return null;
const id = raw.id || raw.vpn_link_id || raw.link_id || raw.path_id || raw.al_id || null;
if (!id) return null;
const localSiteId = opts.localSiteId || null;
const peerSiteId = opts.peerSiteIdOverride
|| (localSiteId ? peerSiteIdForLocal(raw, localSiteId) : null)
|| raw.target_site_id
|| raw.dest_site_id
|| raw.remote_site_id
|| raw.peer_site_id
|| null;
const peerSiteName = opts.peerLabelOverride
|| peerNameFromRaw(raw, localSiteId);
const relatedInterfaceId = (localSiteId ? localWanIfId(raw, localSiteId) : null)
|| raw.source_wan_if_id
|| raw.wan_interface_id
|| raw.src_wan_if_id
|| raw.ep1_wan_interface_id
|| status?.wan_interface_id
|| null;
const adminUp = pickBool(
raw.admin_up,
raw.al_admin_state === 'up' ? true : (raw.al_admin_state === 'down' ? false : null),
status?.admin_up,
status?.adminUp,
);
const operational = pickBool(
status?.operational_up,
status?.operationalUp,
status?.up,
raw.operational_up,
raw.al_state === 'up' ? true : (raw.al_state === 'down' ? false : null),
typeof raw.active === 'boolean' ? raw.active : null,
typeof raw.usable === 'boolean' ? raw.usable : null,
status?.status === 'up' ? true : (status?.status === 'down' ? false : null),
normalizeStatusString(raw.status),
normalizeStatusString(raw.link_status),
normalizeStatusString(status?.link_status || status?.state),
);
const state = String(
status?.link_status
|| status?.state
|| raw.al_state
|| raw.status
|| raw.link_status
|| (operational === true ? 'up' : (operational === false ? 'down' : 'unknown')),
).toLowerCase();
const vpnLinkIds = Array.isArray(raw.vpnlinks)
? raw.vpnlinks.map(String)
: (raw.vpn_link_id ? [String(raw.vpn_link_id)] : []);
return {
id: String(id),
anynetId: raw.al_id ? String(raw.al_id) : (raw.anynet_id ? String(raw.anynet_id) : null),
peerSiteId: peerSiteId ? String(peerSiteId) : null,
peerLabel: peerSiteName || (peerSiteId ? String(peerSiteId) : 'peer'),
state,
up: operational,
adminUp,
relatedInterfaceId: relatedInterfaceId ? String(relatedInterfaceId) : null,
linkType: raw.type || raw.link_type || raw.sub_type || null,
circuitName: (() => {
if (!localSiteId) {
return raw.target_circuit_name || raw.source_circuit_name || null;
}
const local = String(localSiteId);
if (raw.target_site_id && String(raw.target_site_id) === local) {
return raw.target_circuit_name || null;
}
if (raw.source_site_id && String(raw.source_site_id) === local) {
return raw.source_circuit_name || null;
}
return raw.target_circuit_name || raw.source_circuit_name || null;
})(),
vpnLinkIds,
vep1Id: raw.vep1_id ? String(raw.vep1_id) : null,
vep2Id: raw.vep2_id ? String(raw.vep2_id) : null,
raw,
statusRaw: status,
};
}
function pickBool(...candidates) {
for (const c of candidates) {
if (typeof c === 'boolean') return c;
}
return null;
}
function normalizeStatusString(s) {
if (typeof s !== 'string') return null;
const v = s.toLowerCase().trim();
if (['up', 'active', 'connected', 'established'].includes(v)) return true;
if (['down', 'inactive', 'disconnected', 'failed'].includes(v)) return false;
return null;
}
function tunnelInvolvesSite(raw, siteId) {
return siteIdsFromTunnelRaw(raw).includes(String(siteId));
}
function isOverlayTopologyLink(raw) {
const t = String(raw?.type || '').toLowerCase();
if (!t) return true;
if (OVERLAY_TYPES.has(t)) return true;
// Keep unknown types that look like site-to-site fabric; drop
// Prisma service links (SEP) unless nothing else exists.
if (t === 'servicelink' || t === 'standard-vpn') return false;
return true;
}
/**
* Site-scoped overlay discovery via topology/links (preferred) or
* anynetlinks. Uses CloudGenix `query_params` + operators.
*/
export async function getVpnLinksForSite(siteId, opts = {}) {
if (!siteId) return [];
const fetchStatus = opts.fetchStatus !== false;
const concurrency = Math.max(1, Number(opts.statusConcurrency) || 2);
const siteNames = opts.siteNames instanceof Map ? opts.siteNames : null;
let items = [];
let source = 'topology/links';
const topo = await queryTopologyLinksForSite(siteId);
if (topo.length > 0) {
const overlay = topo.filter(isOverlayTopologyLink);
const use = overlay.length > 0 ? overlay : topo;
items = use.map((raw) => {
const peerSiteId = peerSiteIdForLocal(raw, siteId);
const peerLabel = (siteNames && peerSiteId && siteNames.get(String(peerSiteId)))
|| peerNameFromRaw(raw, siteId)
|| peerSiteId
|| 'peer';
return normalizeTunnelRow(raw, null, {
localSiteId: siteId,
peerSiteIdOverride: peerSiteId,
peerLabelOverride: peerLabel,
});
}).filter(Boolean);
}
if (items.length === 0) {
source = 'anynet';
const anynets = await queryAnynetLinksForSite(siteId);
items = anynets.map((al) => {
const peerSiteId = peerSiteIdForLocal(al, siteId);
const peerLabel = (siteNames && peerSiteId && siteNames.get(String(peerSiteId)))
|| peerNameFromRaw(al, siteId)
|| peerSiteId
|| 'peer';
return normalizeTunnelRow(al, null, {
localSiteId: siteId,
peerSiteIdOverride: peerSiteId,
peerLabelOverride: peerLabel,
});
}).filter(Boolean);
}
if (items.length === 0) {
logger('paloalto:topology', `Site ${siteId}: 0 tunnels after site-scoped discovery`);
return [];
}
// Rank: down / unknown first, then cap — never dump dozens of hub paths.
items = rankAndCapTunnels(items, MAX_TUNNELS);
const needsStatus = fetchStatus
&& items.some((t) => t.up == null);
if (needsStatus) {
const statusIds = [];
for (const t of items) {
if (t.up != null) continue;
if (t.vpnLinkIds?.length) statusIds.push(t.vpnLinkIds[0]);
else if (t.id) statusIds.push(t.id);
if (statusIds.length >= MAX_STATUS_FETCH) break;
}
const statusById = await fetchVpnLinkStatuses(statusIds, concurrency);
items = items.map((row) => {
const sid = row.vpnLinkIds?.[0] || row.id;
const status = statusById.get(String(sid));
if (!status) return row;
return normalizeTunnelRow(row.raw || row, status, {
localSiteId: siteId,
peerSiteIdOverride: row.peerSiteId,
peerLabelOverride: row.peerLabel,
}) || row;
});
}
logger('paloalto:topology',
`Site ${siteId}: ${items.length} tunnel(s) via ${source}`);
return items;
}
function rankAndCapTunnels(items, max) {
const ranked = [...items].sort((a, b) => scoreTunnel(b) - scoreTunnel(a));
if (ranked.length <= max) return ranked;
logger('paloalto:topology',
`Capping tunnels ${ranked.length}${max} (down/unknown preferred)`, 'debug');
return ranked.slice(0, max);
}
function scoreTunnel(t) {
if (t.up === false) return 4;
if (t.recentAlarm) return 3;
if (t.up == null) return 2;
if (t.up === true) return 1;
return 0;
}
/**
* POST topology/links/query with query_params operators.
* Queries both source_site_id and target_site_id (spoke may be either side).
*/
export async function queryTopologyLinksForSite(siteId) {
const path = topologyLinksQueryPath();
const sid = String(siteId);
const bodies = [
{ query_params: { target_site_id: { in: [sid] } }, limit: 50 },
{ query_params: { source_site_id: { in: [sid] } }, limit: 50 },
];
const collected = [];
for (const body of bodies) {
try {
const res = await paloAltoAxios.post(path, body);
const items = res.data?.items || res.data?.data || res.data?.links || [];
if (!Array.isArray(items) || items.length === 0) continue;
const matched = items.filter((l) => tunnelInvolvesSite(l, siteId));
// Server filtered via query_params — trust page even if site fields
// are oddly shaped, but prefer verified matches.
const keep = matched.length > 0 ? matched : items;
collected.push(...keep);
logger('paloalto:topology',
`Site ${siteId}: topology/links returned ${keep.length}` +
(res.data?.total_count != null ? ` (total_count=${res.data.total_count})` : ''),
'debug');
} catch (err) {
logger('paloalto:topology',
`topology/links/query failed: ${err.message}`, 'debug');
}
}
return dedupeById(collected);
}
/**
* POST anynetlinks/query with query_params { ep1|ep2_site_id: { eq } }.
*/
export async function queryAnynetLinksForSite(siteId) {
const path = anynetlinksQueryPath();
const sid = String(siteId);
const bodies = [
{ query_params: { ep2_site_id: { eq: sid } }, limit: 50 },
{ query_params: { ep1_site_id: { eq: sid } }, limit: 50 },
{ query_params: { ep2_site_id: { in: [sid] } }, limit: 50 },
{ query_params: { ep1_site_id: { in: [sid] } }, limit: 50 },
];
const collected = [];
for (const body of bodies) {
try {
const res = await paloAltoAxios.post(path, body);
const items = res.data?.items || res.data?.data || [];
if (!Array.isArray(items) || items.length === 0) continue;
const matched = items.filter((l) => tunnelInvolvesSite(l, siteId));
if (matched.length === 0) {
logger('paloalto:topology',
`anynetlinks/query returned ${items.length} but 0 matched site ${siteId}; ` +
`sample keys=[${Object.keys(items[0] || {}).join(',')}]`,
'warn');
continue;
}
collected.push(...matched);
logger('paloalto:topology',
`Site ${siteId}: ${matched.length} anynetlink(s)` +
(res.data?.total_count != null ? ` (total_count=${res.data.total_count})` : ''),
'debug');
// Spoke sites are usually ep2; stop once we have matches.
break;
} catch (err) {
logger('paloalto:topology',
`anynetlinks/query failed: ${err.message}`, 'debug');
}
}
return dedupeById(collected);
}
/**
* POST vpnlinks/query filtered by al_id.
* Note: vpnlinks/query also needs query_params on some tenants; try both.
*/
export async function queryVpnLinksByAnynetId(alId) {
if (!alId) return [];
const path = vpnlinksQueryPath();
const id = String(alId);
const candidates = [
{ query_params: { al_id: { eq: id } }, limit: 20 },
{ query_params: { al_id: { in: [id] } }, limit: 20 },
{ query: { al_id: [id] }, limit: 20 },
];
for (const body of candidates) {
try {
const res = await paloAltoAxios.post(path, body);
const items = res.data?.items || res.data?.data || [];
if (Array.isArray(items) && items.length > 0) return items;
} catch (err) {
logger('paloalto:topology',
`vpnlinks/query by al_id failed: ${err.message}`, 'debug');
}
}
return [];
}
/** @deprecated prefer getVpnLinksForSite */
export async function queryVpnLinksForSite(siteId) {
const anynets = await queryAnynetLinksForSite(siteId);
if (anynets.length === 0) return [];
const out = [];
for (const al of anynets.slice(0, MAX_TUNNELS)) {
const alId = al.id || al.al_id;
if (!alId) continue;
out.push(...await queryVpnLinksByAnynetId(alId));
}
return dedupeById(out);
}
function dedupeById(items) {
const seen = new Set();
const out = [];
for (const item of items) {
const id = String(item?.id || item?.vpn_link_id || item?.path_id || item?.al_id || '');
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(item);
}
return out;
}
export async function fetchVpnLinkStatuses(ids, concurrency = 2) {
const map = new Map();
const list = [...new Set((ids || []).map(String).filter(Boolean))];
let i = 0;
async function worker() {
while (i < list.length) {
const idx = i;
i += 1;
const id = list[idx];
try {
const res = await paloAltoAxios.get(vpnlinkStatusPath(id));
map.set(id, res.data || {});
} catch (err) {
logger('paloalto:topology',
`vpnlink status ${id} failed: ${err.message}`, 'debug');
}
}
}
if (list.length === 0) return map;
await Promise.all(
Array.from({ length: Math.min(concurrency, list.length) }, () => worker()),
);
return map;
}
export function extractVpnLinkIdsFromAlarmInfo(info) {
if (!info) return [];
let obj = info;
if (typeof info === 'string') {
try { obj = JSON.parse(info); } catch { return []; }
}
if (typeof obj !== 'object') return [];
const ids = new Set();
if (obj.vpn_link_id) ids.add(String(obj.vpn_link_id));
if (obj.vpnlink_id) ids.add(String(obj.vpnlink_id));
const reasons = obj.vpn_reasons || obj.vpnReasons || [];
if (Array.isArray(reasons)) {
for (const r of reasons) {
if (r?.vpn_link_id) ids.add(String(r.vpn_link_id));
if (r?.vpnlink_id) ids.add(String(r.vpnlink_id));
}
}
return [...ids];
}