// 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'; /** Soft cap on overlay edges returned (renderer only lists downs). */ const MAX_TUNNELS = 64; /** Max status GETs when topology status is missing — prefer downs first. */ 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 []; } // Prefer down/unknown when capping; keep enough for an accurate // up/down summary (renderer only lists downs in chat). items = rankAndCapTunnels(items, MAX_TUNNELS); const needsStatus = fetchStatus && items.some((t) => t.up == null); if (needsStatus) { // Status GETs are expensive — hydrate downs/unknowns first. const statusIds = []; const rankedForStatus = [...items].sort((a, b) => scoreTunnel(b) - scoreTunnel(a)); for (const t of rankedForStatus) { if (t.up === true) 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]; }