collabSupport/integrations/paloalto/client.js
jmcqueen 9d0dbb071e Add per-app DPI voice-quality checks + widen WAN window to 7d
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>
2026-07-09 13:54:15 -04:00

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;