Introduces a full Palo Alto Prisma SD-WAN integration (dual-mode SASE OAuth 2.0 / legacy CloudGenix auth, pagination, 429 backoff, session priming) that surfaces per-path latency/jitter/loss/MOS, site healthscore, link state, and alarm data for a store. Wired into the /phonestatus WAN follow-up and eight new /voicediag WAN checks graded against ITU-T G.114 / RFC 3550 defaults (env-overridable via WAN_STANDARD_*). Also adds a shape-aware detail renderer for /voicediag (per-link tables with verdict icons instead of a stringified JSON dump) and a --window flag (15m / 1h / 6h / 24h / 1d, env default via WAN_STANDARD_WINDOW_MINUTES) so operators can widen the look-back without redeploying. scripts/prismaProbe.js is bundled as a CLI for schema iteration against a live tenant. Co-authored-by: Cursor <cursoragent@cursor.com>
361 lines
14 KiB
JavaScript
361 lines
14 KiB
JavaScript
// src/integrations/paloalto/client.js
|
|
//
|
|
// Palo Alto Prisma SD-WAN (formerly CloudGenix) HTTP client. Wraps
|
|
// axios with dual-mode auth so the same client works against the
|
|
// unified SASE OAuth 2.0 surface (`api.sase.paloaltonetworks.com`)
|
|
// and the legacy CloudGenix session-token surface
|
|
// (`api.cloudgenix.com`) without any caller-visible differences.
|
|
//
|
|
// Auth mode selection lives in `PRISMA_AUTH_MODE`:
|
|
// - `sase` (default, recommended for production) — service-app
|
|
// OAuth 2.0 client_credentials grant. Bearer token echoed on
|
|
// every request. Access tokens are short-lived (~15 min); we
|
|
// refresh 5 min ahead of expiry and forcibly refresh on the
|
|
// first 401 with a one-shot retry.
|
|
// - `legacy` — CloudGenix session-token flow. Interactive-user
|
|
// credential (email + password), captured as `x-auth-token`
|
|
// for every subsequent call. Kept for admins who only have
|
|
// legacy creds provisioned; new deployments should prefer
|
|
// `sase`.
|
|
//
|
|
// The single `paloAltoAxios` export is what business modules
|
|
// import — same pattern as `scAxios` / `merakiAxios`. Mutex around
|
|
// token refresh (same reason as Webex — a race would double-spend
|
|
// the refresh and leave one caller with a stale token).
|
|
//
|
|
// Only READ operations are used by /voicediag today (POSTs to the
|
|
// `/monitor/v2.0/api/monitor/*` metric endpoints). No PUT/DELETE
|
|
// surface is invoked from this client, so a config mishap during
|
|
// token refresh cannot mutate SD-WAN state.
|
|
|
|
import axios from 'axios';
|
|
import { Mutex } from 'async-mutex';
|
|
import { logger } from '../../utils/logger.js';
|
|
|
|
const AUTH_MODE_SASE = 'sase';
|
|
const AUTH_MODE_LEGACY = 'legacy';
|
|
|
|
const DEFAULT_SASE_BASE_URL = 'https://api.sase.paloaltonetworks.com';
|
|
const DEFAULT_SASE_AUTH_URL = 'https://auth.apps.paloaltonetworks.com/oauth2/access_token';
|
|
const DEFAULT_LEGACY_BASE_URL = 'https://api.cloudgenix.com';
|
|
|
|
// Refresh window: 5 minutes before actual token expiry. Prisma
|
|
// SASE tokens are 900s by default; a request landing at 895s could
|
|
// otherwise trip a 401 that we then re-authenticate through — not
|
|
// wrong, just wasteful.
|
|
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
|
|
const mutex = new Mutex();
|
|
let cachedToken = null;
|
|
let tokenExpiresAt = 0;
|
|
// SASE unified SD-WAN calls require a one-shot GET /sdwan/v2.1/api/profile
|
|
// immediately after every new token is acquired — otherwise subsequent
|
|
// SD-WAN calls come back with 403 (or 503 "target url lookup failed"
|
|
// depending on which edge processes the miss). See pan.dev "Unified
|
|
// SASE SD-WAN APIs" page. This flag tracks whether the priming call
|
|
// has run since the last token acquisition; it's cleared on refresh.
|
|
let saseSessionPrimed = false;
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Auth mode + base URL resolution
|
|
// ──────────────────────────────────────────────
|
|
|
|
function resolveAuthMode() {
|
|
const raw = String(process.env.PRISMA_AUTH_MODE || AUTH_MODE_SASE).toLowerCase().trim();
|
|
if (raw !== AUTH_MODE_SASE && raw !== AUTH_MODE_LEGACY) {
|
|
throw new Error(
|
|
`PRISMA_AUTH_MODE must be "${AUTH_MODE_SASE}" or "${AUTH_MODE_LEGACY}" (got "${raw}")`,
|
|
);
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
function resolveBaseUrl() {
|
|
const mode = resolveAuthMode();
|
|
if (mode === AUTH_MODE_SASE) {
|
|
return process.env.PRISMA_SASE_BASE_URL || DEFAULT_SASE_BASE_URL;
|
|
}
|
|
return process.env.PRISMA_LEGACY_BASE_URL || DEFAULT_LEGACY_BASE_URL;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Axios instance — baseURL resolved at import time
|
|
// ──────────────────────────────────────────────
|
|
|
|
export const paloAltoAxios = axios.create({
|
|
// baseURL resolved lazily on the first request so tests can set
|
|
// PRISMA_SASE_BASE_URL / PRISMA_LEGACY_BASE_URL after import.
|
|
timeout: 20000,
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
// Request interceptor: (a) apply resolved baseURL on the fly if the
|
|
// caller passed a relative URL; (b) inject the correct auth header
|
|
// for the current auth mode; (c) prime the SASE unified SD-WAN
|
|
// session on first use of a new token.
|
|
paloAltoAxios.interceptors.request.use(async (cfg) => {
|
|
const mode = resolveAuthMode();
|
|
|
|
if (cfg.url && !/^https?:\/\//i.test(cfg.url)) {
|
|
cfg.baseURL = resolveBaseUrl();
|
|
}
|
|
|
|
const token = await getPrismaToken();
|
|
|
|
if (mode === AUTH_MODE_SASE) {
|
|
if (!cfg.headers.Authorization) {
|
|
cfg.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
// Prime the unified SD-WAN session — but skip when the current
|
|
// request IS the profile call itself (avoids infinite recursion)
|
|
// or when it's the auth POST (which uses raw axios, not this
|
|
// instance, but belt-and-braces).
|
|
if (!saseSessionPrimed && !cfg._skipSasePrime && !/\/sdwan\/v2\.1\/api\/profile$/.test(cfg.url || '')) {
|
|
try {
|
|
await primeSaseSession(token);
|
|
} catch (err) {
|
|
// Priming failed — log and continue. Downstream call will
|
|
// 403 loudly which is a better signal than swallowing here.
|
|
logger('paloalto:client', `SASE session prime failed: ${err.message}`, 'warn');
|
|
}
|
|
}
|
|
} else {
|
|
if (!cfg.headers['x-auth-token']) {
|
|
cfg.headers['x-auth-token'] = token;
|
|
}
|
|
}
|
|
|
|
logger('paloalto:request', `${cfg.method?.toUpperCase()} ${cfg.baseURL || ''}${cfg.url}`, 'debug');
|
|
return cfg;
|
|
});
|
|
|
|
// Maximum attempts on a 429 (rate limit). One retry with backoff has
|
|
// been empirically enough in the observed Prisma tenant — the second
|
|
// attempt lands after the burst window resets. Bumping past 2 would
|
|
// stack requests behind an already-throttled endpoint and make things
|
|
// worse. `Retry-After` (seconds) is honored when present; otherwise
|
|
// exponential backoff kicks in.
|
|
const MAX_429_RETRIES = 2;
|
|
const BASE_429_BACKOFF_MS = 1500;
|
|
|
|
paloAltoAxios.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
const status = error.response?.status;
|
|
|
|
if (status === 401 && !error.config?._retry) {
|
|
logger('paloalto:client', '401 → forcing token refresh + one-shot retry', 'warn');
|
|
try {
|
|
await getPrismaToken(true);
|
|
} catch (refreshErr) {
|
|
logger('paloalto:client', `Token refresh failed on 401: ${refreshErr.message}`, 'error');
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
const originalRequest = error.config;
|
|
originalRequest._retry = true;
|
|
const mode = resolveAuthMode();
|
|
if (mode === AUTH_MODE_SASE) {
|
|
originalRequest.headers.Authorization = `Bearer ${cachedToken}`;
|
|
} else {
|
|
originalRequest.headers['x-auth-token'] = cachedToken;
|
|
}
|
|
return paloAltoAxios(originalRequest);
|
|
}
|
|
|
|
// 429 — Prisma throttles bursts of parallel metric calls. Honor
|
|
// the `Retry-After` header when present; otherwise exponential
|
|
// backoff (1.5s / 3s / 6s). Only retry idempotent verbs (GET,
|
|
// POST-with-body-that's-a-query — Prisma metric endpoints are
|
|
// read-only despite being POSTs). Capped at MAX_429_RETRIES.
|
|
if (status === 429) {
|
|
const cfg = error.config || {};
|
|
cfg._429retries = (cfg._429retries || 0) + 1;
|
|
if (cfg._429retries <= MAX_429_RETRIES) {
|
|
const retryAfterHdr = error.response?.headers?.['retry-after'];
|
|
const retryAfterMs = retryAfterHdr
|
|
? Math.max(0, Number(retryAfterHdr) * 1000) || null
|
|
: null;
|
|
const waitMs = retryAfterMs != null
|
|
? retryAfterMs
|
|
: BASE_429_BACKOFF_MS * Math.pow(2, cfg._429retries - 1);
|
|
logger(
|
|
'paloalto:client',
|
|
`429 on ${cfg.method?.toUpperCase() || 'REQ'} ${cfg.url || ''} — retry ${cfg._429retries}/${MAX_429_RETRIES} in ${waitMs}ms` +
|
|
(retryAfterMs != null ? ' (Retry-After honored)' : ''),
|
|
'warn',
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
return paloAltoAxios(cfg);
|
|
}
|
|
logger(
|
|
'paloalto:client',
|
|
`429 on ${cfg.method?.toUpperCase() || 'REQ'} ${cfg.url || ''} — retries exhausted (${MAX_429_RETRIES})`,
|
|
'warn',
|
|
);
|
|
}
|
|
|
|
const body = error.response?.data;
|
|
const msg = status
|
|
? `${status} — ${typeof body === 'string' ? body : JSON.stringify(body)}`
|
|
: error.message;
|
|
logger('paloalto:error', msg, status === 429 ? 'warn' : 'error');
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Token acquisition (dispatches on auth mode)
|
|
// ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Return a valid Prisma auth token, refreshing if needed. Concurrent
|
|
* callers coalesce onto a single refresh through the shared mutex.
|
|
* Any refresh clears `saseSessionPrimed` so the interceptor knows to
|
|
* re-run the mandatory GET /sdwan/v2.1/api/profile priming call on
|
|
* the next SD-WAN request.
|
|
*/
|
|
export async function getPrismaToken(forceRefresh = false) {
|
|
const release = await mutex.acquire();
|
|
try {
|
|
const now = Date.now();
|
|
if (!forceRefresh && cachedToken && now < tokenExpiresAt) {
|
|
return cachedToken;
|
|
}
|
|
|
|
const mode = resolveAuthMode();
|
|
if (mode === AUTH_MODE_SASE) {
|
|
await refreshSaseToken();
|
|
} else {
|
|
await refreshLegacyToken();
|
|
}
|
|
saseSessionPrimed = false;
|
|
return cachedToken;
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mandatory session-init for the SASE unified SD-WAN surface. Per
|
|
* pan.dev's "Unified SASE SD-WAN APIs" page, this exact GET call
|
|
* MUST run immediately after obtaining a new access token — without
|
|
* it, every subsequent SD-WAN call is rejected (usually 403, but
|
|
* SASE Edge can also respond with 503 "target url lookup failed
|
|
* for proxy: null in environment prod-global" if the priming state
|
|
* is missing at the routing layer).
|
|
*
|
|
* We bypass the axios instance for this call (raw axios) to avoid
|
|
* the request interceptor's own priming check firing recursively.
|
|
* `_skipSasePrime` also protects the instance path if a caller ever
|
|
* hits the profile URL directly.
|
|
*/
|
|
async function primeSaseSession(token) {
|
|
const baseUrl = process.env.PRISMA_SASE_BASE_URL || DEFAULT_SASE_BASE_URL;
|
|
logger('paloalto:client', 'Priming SASE SD-WAN session (GET /sdwan/v2.1/api/profile)', 'debug');
|
|
await axios.get(`${baseUrl}/sdwan/v2.1/api/profile`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Accept': 'application/json',
|
|
},
|
|
timeout: 10000,
|
|
});
|
|
saseSessionPrimed = true;
|
|
logger('paloalto:client', 'SASE SD-WAN session primed');
|
|
}
|
|
|
|
async function refreshSaseToken() {
|
|
const clientId = process.env.PRISMA_CLIENT_ID;
|
|
const clientSecret = process.env.PRISMA_CLIENT_SECRET;
|
|
const tsgId = process.env.PRISMA_TSG_ID;
|
|
const authUrl = process.env.PRISMA_AUTH_URL || DEFAULT_SASE_AUTH_URL;
|
|
|
|
if (!clientId || !clientSecret || !tsgId) {
|
|
throw new Error(
|
|
'PRISMA_AUTH_MODE=sase requires PRISMA_CLIENT_ID, PRISMA_CLIENT_SECRET, and PRISMA_TSG_ID.',
|
|
);
|
|
}
|
|
|
|
logger('paloalto:client', 'Fetching new SASE OAuth token', 'debug');
|
|
|
|
const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
const body = new URLSearchParams({
|
|
grant_type: 'client_credentials',
|
|
// SASE requires the TSG (Tenant Service Group) scope so the
|
|
// token is bound to the right tenant.
|
|
scope: `tsg_id:${tsgId}`,
|
|
}).toString();
|
|
|
|
const res = await axios.post(authUrl, body, {
|
|
headers: {
|
|
'Authorization': `Basic ${basicAuth}`,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
timeout: 10000,
|
|
});
|
|
|
|
const { access_token, expires_in } = res.data || {};
|
|
if (!access_token) {
|
|
throw new Error(`SASE token response missing access_token: ${JSON.stringify(res.data)}`);
|
|
}
|
|
|
|
cachedToken = access_token;
|
|
tokenExpiresAt = Date.now() + ((Number(expires_in) || 900) * 1000) - REFRESH_MARGIN_MS;
|
|
logger(
|
|
'paloalto:client',
|
|
`SASE token acquired (expires in ~${Math.round((Number(expires_in) || 900) / 60)} min)`,
|
|
);
|
|
}
|
|
|
|
async function refreshLegacyToken() {
|
|
const email = process.env.PRISMA_EMAIL;
|
|
const password = process.env.PRISMA_PASSWORD;
|
|
const baseUrl = process.env.PRISMA_LEGACY_BASE_URL || DEFAULT_LEGACY_BASE_URL;
|
|
|
|
if (!email || !password) {
|
|
throw new Error('PRISMA_AUTH_MODE=legacy requires PRISMA_EMAIL and PRISMA_PASSWORD.');
|
|
}
|
|
|
|
logger('paloalto:client', 'Fetching new legacy CloudGenix session token', 'debug');
|
|
|
|
const res = await axios.post(
|
|
`${baseUrl}/v2.0/api/login`,
|
|
{ email, password },
|
|
{
|
|
headers: { 'Content-Type': 'application/json' },
|
|
timeout: 10000,
|
|
},
|
|
);
|
|
|
|
const token =
|
|
res.data?.x_auth_token ||
|
|
res.data?.token ||
|
|
res.headers?.['x-auth-token'];
|
|
|
|
if (!token) {
|
|
throw new Error(`Legacy login response missing token: ${JSON.stringify(res.data)}`);
|
|
}
|
|
|
|
cachedToken = token;
|
|
// Legacy tokens don't declare expiry in the response body; treat
|
|
// as 8h with the same refresh margin. If a mid-day 401 happens
|
|
// the response interceptor still catches it and forces refresh.
|
|
tokenExpiresAt = Date.now() + (8 * 60 * 60 * 1000) - REFRESH_MARGIN_MS;
|
|
logger('paloalto:client', 'Legacy session token acquired (~8h validity)');
|
|
}
|
|
|
|
/**
|
|
* Test-only helper — clears the in-memory token cache so a fresh
|
|
* auth attempt happens on the next request. Not for production use
|
|
* (the mutex-guarded refresh path already handles all real cases).
|
|
*/
|
|
export function _resetPrismaAuthCache() {
|
|
cachedToken = null;
|
|
tokenExpiresAt = 0;
|
|
saseSessionPrimed = false;
|
|
}
|
|
|
|
export default paloAltoAxios;
|