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>
570 lines
18 KiB
JavaScript
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];
|
|
}
|