Adds three new SD-WAN checks (wanAppRtpMos/Loss/Jitter) that measure
REAL voice-traffic quality on actual RTP frames via Prisma DPI, not
synthetic link probes. Graded against the WORST 5-minute window so
transient degradation the 24h link-probe averages smooth away
actually surfaces.
Voice-app selection is tenant-configurable via PRISMA_APP_ID_VOICE +
PRISMA_APP_NAME_VOICE (Webex_Calling_RTP recommended for Webex
Calling shops — the Webex-specific DPI signature excludes non-Webex
UDP noise). Legacy PRISMA_APP_ID_RTP_BASE still honored with a
one-time deprecation warning.
Widens the default WAN look-back from 24h to 7 days: per-app metrics
only get datapoints when calls actually happen, so sporadic Webex
Calling stores (3-4 calls/day) need a wider window for worst-window
statistics to be meaningful. Interval picker snaps 7d to 1hour
buckets (168 pts) to keep payloads bounded while preserving
worst-hour granularity. Hard-capped at 7d — beyond that Prisma
downsamples to 1-day buckets and the signal collapses.
Also:
- Client-side concurrency limiter (PRISMA_MAX_INFLIGHT, default 3)
to prevent 429 cascades when /voicediag fans out 10+ parallel
metric fetches
- "View in Prisma UI" deep links in both /phonestatus WAN follow-up
and /voicediag details, threading through a new
integrations/paloalto/urls.js builder
- humanizeMetricUnit maps raw API unit strings ("percentage",
"milliseconds") to display symbols ("%", "ms") to fix
"11.83percentage" leaking to the UI
- getAppAudio envelope distinguishes not-configured / fetch-failed /
no-traffic states so misleading "set env var" messages don't fire
when the real problem is a 429
Co-authored-by: Cursor <cursoragent@cursor.com>
443 lines
17 KiB
JavaScript
443 lines
17 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',
|
|
},
|
|
});
|
|
|
|
// ─── Global concurrency limiter ─────────────────────────────────────
|
|
//
|
|
// Prisma throttles bursts hard — fan-out patterns like /voicediag
|
|
// (which fires 10+ parallel metric calls per request) can trip the
|
|
// tenant rate limit and cause a cascade where retries compete with
|
|
// still-pending original calls, wasting the whole batch. Cap the
|
|
// in-flight count with a small semaphore so bursts are naturally
|
|
// serialized instead. Empirically 3 works on the observed tenant —
|
|
// bump `PRISMA_MAX_INFLIGHT` in the env if the ceiling is looser.
|
|
//
|
|
// The permit is acquired in the request interceptor and released in
|
|
// BOTH response paths (success + error) so retries don't hold the
|
|
// permit through the backoff window (that would let 429'd requests
|
|
// stall queued fresh requests). 429 retries reacquire on their way
|
|
// back through the request interceptor.
|
|
const MAX_INFLIGHT = Math.max(1, Number(process.env.PRISMA_MAX_INFLIGHT) || 3);
|
|
let inflightCount = 0;
|
|
const inflightWaiters = [];
|
|
|
|
function acquirePermit() {
|
|
if (inflightCount < MAX_INFLIGHT) {
|
|
inflightCount++;
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise((resolve) => inflightWaiters.push(resolve));
|
|
}
|
|
|
|
function releasePermit() {
|
|
const next = inflightWaiters.shift();
|
|
if (next) {
|
|
// Hand permit directly to the next waiter — don't decrement +
|
|
// increment (that's a race window in case a new caller arrives
|
|
// between the two ops).
|
|
next();
|
|
} else {
|
|
inflightCount = Math.max(0, inflightCount - 1);
|
|
}
|
|
}
|
|
|
|
/** @internal Test-only: reset the semaphore between tests. */
|
|
export function _resetPrismaConcurrency() {
|
|
inflightCount = 0;
|
|
inflightWaiters.length = 0;
|
|
}
|
|
|
|
/** @internal Test-only: peek at the current in-flight count. */
|
|
export function _prismaInflightCount() {
|
|
return inflightCount;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// Concurrency-cap acquire. This is the last step of the request
|
|
// interceptor so all pre-request work (baseURL, auth, session
|
|
// priming) happens WITHOUT holding a permit — otherwise a slow
|
|
// priming call would eat one of the three inflight slots for its
|
|
// whole duration and shrink the effective ceiling.
|
|
//
|
|
// `_permitAcquired` is a per-request flag consumed by the response
|
|
// interceptor. Priming (a nested paloAltoAxios call) is
|
|
// intentionally exempt via `_skipConcurrencyGate` so we don't
|
|
// deadlock the last permit waiting for its own dependency.
|
|
if (!cfg._skipConcurrencyGate) {
|
|
await acquirePermit();
|
|
cfg._permitAcquired = true;
|
|
}
|
|
|
|
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) => {
|
|
// Success path: release the concurrency permit acquired in the
|
|
// request interceptor before returning to the caller.
|
|
if (response.config?._permitAcquired) {
|
|
response.config._permitAcquired = false;
|
|
releasePermit();
|
|
}
|
|
return response;
|
|
},
|
|
async (error) => {
|
|
const status = error.response?.status;
|
|
|
|
// Release the concurrency permit BEFORE any retry (401/429). The
|
|
// retry re-enters the request interceptor and will reacquire —
|
|
// holding the permit across the backoff would let a single
|
|
// throttled request block fresh callers.
|
|
if (error.config?._permitAcquired) {
|
|
error.config._permitAcquired = false;
|
|
releasePermit();
|
|
}
|
|
|
|
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;
|