feat(appgate): add AppGate SDP integration health check and sync service
Registers AppGate as a checkConfigOnly integration-health row and public sync route, matching the existing factory + is<Name>Configured() pattern. Committed now so Phase 13's worktree-isolated executors fork from a HEAD that includes this integration-health.ts entry, since Plan 13-02 inserts the PAX8 row immediately after it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR
This commit is contained in:
parent
f70a4a36ed
commit
b168d44585
9 changed files with 998 additions and 0 deletions
181
lib/services/appgate-client.ts
Normal file
181
lib/services/appgate-client.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* AppGate SDP Controller REST API client.
|
||||
*
|
||||
* Implements the subset of v22.5 endpoints Pulse needs:
|
||||
* - POST /admin/login (token acquisition)
|
||||
* - GET /admin/stats/active-sessions/dn (current sessions)
|
||||
* - GET /admin/stats/user-logins (24h login histogram)
|
||||
* - GET /admin/on-boarded-devices (device inventory)
|
||||
* - GET /admin/appliances (controllers / gateways)
|
||||
* - GET /admin/license (capacity + expiry)
|
||||
* - GET /admin/license/users (per-user license usage)
|
||||
*
|
||||
* Token caching: AppGate `LoginResponse` returns an explicit `expires`
|
||||
* timestamp; we re-authenticate ~60s before it lapses.
|
||||
*
|
||||
* TLS: AppGate controllers ship with a self-signed cert from the Controller
|
||||
* CA. Pulse goes through Node's `https` module (not global `fetch`) so it can
|
||||
* scope `rejectUnauthorized:false` to this client only — other outbound
|
||||
* HTTPS calls keep their strict verification.
|
||||
*/
|
||||
|
||||
import * as https from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import type {
|
||||
AppgateActiveSession,
|
||||
AppgateAppliance,
|
||||
AppgateHourlyLogins,
|
||||
AppgateLicense,
|
||||
AppgateLoginResponse,
|
||||
AppgateOnBoardedDevice,
|
||||
AppgateResultList,
|
||||
AppgateUserLicense,
|
||||
} from '@/lib/types/appgate';
|
||||
|
||||
export interface AppgateClientConfig {
|
||||
baseUrl: string; // e.g. https://wawnvaagp01.wulfconsulting.com:8443
|
||||
username: string;
|
||||
password: string;
|
||||
providerName: string; // e.g. "local"
|
||||
deviceId: string; // any stable UUID for this Pulse instance
|
||||
insecureTls?: boolean; // default: true (self-signed)
|
||||
}
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
expiresAt: number; // epoch ms
|
||||
}
|
||||
|
||||
const ACCEPT_HEADER = 'application/vnd.appgate.peer-v22+json';
|
||||
const REFRESH_BUFFER_MS = 60_000;
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
export class AppgateClient {
|
||||
private readonly config: AppgateClientConfig;
|
||||
private readonly agent: https.Agent;
|
||||
private token: CachedToken | null = null;
|
||||
|
||||
constructor(config: AppgateClientConfig) {
|
||||
this.config = config;
|
||||
this.agent = new https.Agent({
|
||||
rejectUnauthorized: config.insecureTls === false,
|
||||
keepAlive: true,
|
||||
});
|
||||
}
|
||||
|
||||
private rawRequest<T>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: unknown,
|
||||
auth?: { token?: string },
|
||||
): Promise<T> {
|
||||
const url = new URL(`${this.config.baseUrl}${path}`);
|
||||
const payload = body !== undefined ? JSON.stringify(body) : undefined;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: ACCEPT_HEADER,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (auth?.token) headers.Authorization = `Bearer ${auth.token}`;
|
||||
if (payload) headers['Content-Length'] = Buffer.byteLength(payload).toString();
|
||||
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname + url.search,
|
||||
method,
|
||||
headers,
|
||||
agent: this.agent,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
const status = res.statusCode ?? 0;
|
||||
if (status < 200 || status >= 300) {
|
||||
reject(new Error(`AppGate ${method} ${path} → ${status}: ${text.slice(0, 300)}`));
|
||||
return;
|
||||
}
|
||||
if (status === 204 || text.length === 0) {
|
||||
resolve(undefined as unknown as T);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(text) as T);
|
||||
} catch (e) {
|
||||
reject(new Error(`AppGate ${path}: invalid JSON response — ${(e as Error).message}`));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', (e) => reject(new Error(`AppGate ${path}: ${e.message}`)));
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error(`AppGate ${path}: timeout after ${REQUEST_TIMEOUT_MS}ms`));
|
||||
});
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.token && this.token.expiresAt - Date.now() > REFRESH_BUFFER_MS) {
|
||||
return this.token.token;
|
||||
}
|
||||
const resp = await this.rawRequest<AppgateLoginResponse>('/admin/login', 'POST', {
|
||||
providerName: this.config.providerName,
|
||||
username: this.config.username,
|
||||
password: this.config.password,
|
||||
deviceId: this.config.deviceId,
|
||||
});
|
||||
if (!resp.token || !resp.expires) {
|
||||
throw new Error('AppGate login response missing token/expires');
|
||||
}
|
||||
this.token = { token: resp.token, expiresAt: new Date(resp.expires).getTime() };
|
||||
return this.token.token;
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const token = await this.getToken();
|
||||
return this.rawRequest<T>(`/admin${path}`, 'GET', undefined, { token });
|
||||
}
|
||||
|
||||
// ─── Endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
async getActiveSessions(): Promise<AppgateActiveSession[]> {
|
||||
const r = await this.get<AppgateResultList<AppgateActiveSession>>('/stats/active-sessions/dn');
|
||||
return r.data ?? [];
|
||||
}
|
||||
|
||||
async getUserLoginsLast24h(): Promise<AppgateHourlyLogins> {
|
||||
const r = await this.get<{ data: AppgateHourlyLogins }>('/stats/user-logins');
|
||||
return r.data ?? {};
|
||||
}
|
||||
|
||||
async getOnBoardedDevices(): Promise<AppgateOnBoardedDevice[]> {
|
||||
const r = await this.get<AppgateResultList<AppgateOnBoardedDevice>>('/on-boarded-devices');
|
||||
return r.data ?? [];
|
||||
}
|
||||
|
||||
async getAppliances(): Promise<AppgateAppliance[]> {
|
||||
const r = await this.get<AppgateResultList<AppgateAppliance>>('/appliances');
|
||||
return r.data ?? [];
|
||||
}
|
||||
|
||||
async getLicense(): Promise<AppgateLicense> {
|
||||
return this.get<AppgateLicense>('/license');
|
||||
}
|
||||
|
||||
async getLicenseUsers(): Promise<AppgateUserLicense[]> {
|
||||
const r = await this.get<AppgateResultList<AppgateUserLicense>>('/license/users');
|
||||
return r.data ?? [];
|
||||
}
|
||||
|
||||
// Cheap connectivity probe — does not require auth.
|
||||
async ping(): Promise<{ ok: true }> {
|
||||
await this.rawRequest<unknown>('/admin/identity-providers/names', 'GET');
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue