wulf-pulse/lib/services/appgate-client.ts

182 lines
6.3 KiB
TypeScript
Raw Normal View History

/**
* 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 };
}
}