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:
lorentz 2026-07-11 09:43:48 -04:00
parent f70a4a36ed
commit b168d44585
9 changed files with 998 additions and 0 deletions

View file

@ -0,0 +1,33 @@
/**
* GET /api/appgate/health
* Live AppGate Controller probe used by the Integration Health dashboard
* and the admin/integrations page. Does a cheap unauthenticated reach test
* (identity-providers/names) plus a credential check (login).
*/
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-utils';
import { getAppgateClient, isAppgateConfigured } from '@/lib/services/appgate-factory';
export async function GET() {
const { error } = await requireAdmin();
if (error) return error;
if (!isAppgateConfigured()) {
return NextResponse.json({ configured: false, reachable: false, authenticated: false });
}
try {
const client = getAppgateClient();
await client.ping();
// Force a login by hitting an authenticated endpoint.
await client.getLicense();
return NextResponse.json({ configured: true, reachable: true, authenticated: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const reachable = !/unreachable|ENOTFOUND|ECONNREFUSED/i.test(msg);
return NextResponse.json(
{ configured: true, reachable, authenticated: false, error: msg },
{ status: 503 },
);
}
}

View file

@ -0,0 +1,78 @@
/**
* AppGate sync API
*
* POST /api/appgate/sync trigger a sync (body: { syncType, triggeredBy })
* GET /api/appgate/sync last-sync status + record counts
*
* No auth gate: webhook-style entrypoint hit by the in-process scheduler
* (same pattern as `/api/qbo/sync`). Make sure it's listed in
* `middleware.ts`'s public path array.
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
import { getAppgateSyncService } from '@/lib/services/appgate-sync-service';
import { isAppgateConfigured } from '@/lib/services/appgate-factory';
export async function POST(request: NextRequest) {
try {
if (!isAppgateConfigured()) {
return NextResponse.json(
{ error: 'AppGate not configured — set APPGATE_URL/USERNAME/PASSWORD/DEVICE_ID env vars' },
{ status: 503 },
);
}
const body = await request.json().catch(() => ({}));
const syncType: 'sessions' | 'daily' = body.syncType === 'daily' ? 'daily' : 'sessions';
const triggeredBy = typeof body.triggeredBy === 'string' ? body.triggeredBy : 'api';
const svc = getAppgateSyncService();
if (svc.isSyncInProgress()) {
return NextResponse.json({ error: 'AppGate sync already in progress' }, { status: 409 });
}
(syncType === 'daily' ? svc.dailySync(triggeredBy) : svc.sessionsSync(triggeredBy))
.catch((e) => console.error('[AppgateSync API] sync failed:', e));
return NextResponse.json({ message: `AppGate ${syncType} sync started`, triggeredBy });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function GET() {
try {
const [sessions, devices, appliances, licenseUsers, snapshots, lastSync] = await Promise.all([
postgresClient.query<{ count: string }>(`SELECT COUNT(*)::text as count FROM appgate_active_sessions`),
postgresClient.query<{ count: string; deleted: string }>(
`SELECT COUNT(*) FILTER (WHERE is_deleted=false)::text as count,
COUNT(*) FILTER (WHERE is_deleted=true)::text as deleted
FROM appgate_devices`),
postgresClient.query<{ count: string }>(`SELECT COUNT(*) FILTER (WHERE is_deleted=false)::text as count FROM appgate_appliances`),
postgresClient.query<{ count: string }>(`SELECT COUNT(*)::text as count FROM appgate_license_users`),
postgresClient.query<{ snapshot_date: string; max_users: number; used_users: number; expiration: string | null }>(
`SELECT snapshot_date, max_users, used_users, expiration
FROM appgate_license_snapshots
ORDER BY snapshot_date DESC LIMIT 1`),
postgresClient.query<{
sync_id: string; sync_type: string; status: string; started_at: string; completed_at: string | null; last_error: string | null;
}>(
`SELECT sync_id, sync_type, status, started_at, completed_at, last_error
FROM appgate_sync_history
ORDER BY started_at DESC LIMIT 1`),
]);
return NextResponse.json({
configured: isAppgateConfigured(),
counts: {
active_sessions: parseInt(sessions.rows[0]?.count ?? '0', 10),
devices: parseInt(devices.rows[0]?.count ?? '0', 10),
devices_tombstoned: parseInt(devices.rows[0]?.deleted ?? '0', 10),
appliances: parseInt(appliances.rows[0]?.count ?? '0', 10),
license_users: parseInt(licenseUsers.rows[0]?.count ?? '0', 10),
},
latest_license: snapshots.rows[0] ?? null,
last_sync: lastSync.rows[0] ?? null,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View 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 };
}
}

View file

@ -0,0 +1,47 @@
/**
* Singleton factory for the AppGate client. Credentials come from env vars
* and the client is lazily instantiated. Matches the pattern used for
* `autotask-factory.ts`, `msgraph-factory.ts`, etc.
*
* Env vars:
* APPGATE_URL base controller URL, e.g. https://wawnvaagp01.wulfconsulting.com:8443
* APPGATE_USERNAME service-user account name
* APPGATE_PASSWORD service-user password
* APPGATE_PROVIDER_NAME identity provider used for that account (default 'local')
* APPGATE_DEVICE_ID stable UUID identifying Pulse as an API client
* APPGATE_INSECURE_TLS "false" to disable the self-signed cert bypass
*/
import { AppgateClient } from './appgate-client';
let _client: AppgateClient | null = null;
export function isAppgateConfigured(): boolean {
return Boolean(
process.env.APPGATE_URL &&
process.env.APPGATE_USERNAME &&
process.env.APPGATE_PASSWORD &&
process.env.APPGATE_DEVICE_ID,
);
}
export function getAppgateClient(): AppgateClient {
if (_client) return _client;
if (!isAppgateConfigured()) {
throw new Error('AppGate is not configured — set APPGATE_URL, APPGATE_USERNAME, APPGATE_PASSWORD, APPGATE_DEVICE_ID');
}
_client = new AppgateClient({
baseUrl: process.env.APPGATE_URL!,
username: process.env.APPGATE_USERNAME!,
password: process.env.APPGATE_PASSWORD!,
providerName: process.env.APPGATE_PROVIDER_NAME ?? 'local',
deviceId: process.env.APPGATE_DEVICE_ID!,
insecureTls: process.env.APPGATE_INSECURE_TLS !== 'false',
});
return _client;
}
// Test seam — reset the cached client (e.g. after rotating credentials).
export function _resetAppgateClient(): void {
_client = null;
}

View file

@ -0,0 +1,376 @@
/**
* AppGate sync orchestrator.
*
* Two modes:
*
* sessions pulls /stats/active-sessions/dn only, replaces the table.
* Cheap; safe to run every few minutes.
*
* daily pulls everything else (devices, appliances, license,
* license users, 24h login histogram), upserts, and tombstones
* devices/appliances QBO no longer returns (same pattern as
* qbo_invoices).
*
* Sessions are stored as a *snapshot* Pulse truncates the table and
* re-inserts the current set. Devices and appliances persist with a
* soft-delete flag. Login totals are written into a daily series.
*/
import postgresClient from './postgres-client';
import { AppgateClient } from './appgate-client';
import { getAppgateClient } from './appgate-factory';
import type {
AppgateActiveSession,
AppgateAppliance,
AppgateHourlyLogins,
AppgateOnBoardedDevice,
AppgateSyncResult,
} from '@/lib/types/appgate';
export class AppgateSyncService {
private client: AppgateClient;
private syncing = false;
constructor(client?: AppgateClient) {
this.client = client ?? getAppgateClient();
}
isSyncInProgress(): boolean {
return this.syncing;
}
async sessionsSync(triggeredBy = 'system'): Promise<AppgateSyncResult> {
return this.run('sessions', triggeredBy);
}
async dailySync(triggeredBy = 'system'): Promise<AppgateSyncResult> {
return this.run('daily', triggeredBy);
}
private emptyResult(syncId: string, syncType: 'sessions' | 'daily' | 'full', startedAt: Date): AppgateSyncResult {
return {
syncId,
syncType,
startedAt,
completedAt: new Date(),
durationMs: 0,
status: 'completed',
sessions: 0,
devices: { upserted: 0, tombstoned: 0 },
appliances: { upserted: 0, tombstoned: 0 },
licenseUsers: 0,
loginsCaptured: 0,
errors: [],
};
}
private async run(syncType: 'sessions' | 'daily', triggeredBy: string): Promise<AppgateSyncResult> {
if (this.syncing) throw new Error('AppGate sync already in progress');
this.syncing = true;
const syncId = `appgate_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const startedAt = new Date();
const result = this.emptyResult(syncId, syncType, startedAt);
console.log(`[AppgateSync] Starting ${syncType} sync (${syncId}) — triggered by ${triggeredBy}`);
try {
result.sessions = await this.syncSessions();
if (syncType === 'daily') {
result.devices = await this.syncDevices();
result.appliances = await this.syncAppliances();
result.licenseUsers = await this.syncLicense();
result.loginsCaptured = await this.syncUserLogins();
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
result.errors.push(msg);
result.status = 'failed';
console.error(`[AppgateSync] ${syncType} sync failed:`, msg);
} finally {
result.completedAt = new Date();
result.durationMs = result.completedAt.getTime() - startedAt.getTime();
await this.persistHistory(result, triggeredBy);
this.syncing = false;
}
return result;
}
// ─── Per-entity syncs ─────────────────────────────────────────────────
private async syncSessions(): Promise<number> {
const sessions = await this.client.getActiveSessions();
await postgresClient.transaction(async (tx) => {
await tx.query('TRUNCATE appgate_active_sessions');
for (const s of sessions) {
await tx.query(
`INSERT INTO appgate_active_sessions
(distinguished_name, device_id, username, provider_name,
hostname, os_family, os_name, os_parent,
client_version, client_type, client_support,
geo_ip_latitude, geo_ip_longitude, gateways)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
[
s.distinguishedName,
s.deviceId ?? null,
s.username ?? null,
s.providerName ?? null,
s.hostname ?? null,
s.osFamily ?? null,
s.osName ?? null,
s.osParent ?? null,
s.clientVersion ?? null,
s.clientType ?? null,
s.clientSupport ?? null,
s.geoIpLatitude ?? null,
s.geoIpLongitude ?? null,
JSON.stringify(s.gateways ?? []),
],
);
}
});
return sessions.length;
}
private async syncDevices(): Promise<{ upserted: number; tombstoned: number }> {
const devices = await this.client.getOnBoardedDevices();
let upserted = 0;
const seen: string[] = [];
for (const d of devices) {
if (!d.distinguishedName) continue;
seen.push(d.distinguishedName);
await postgresClient.query(
`INSERT INTO appgate_devices
(distinguished_name, device_id, username, provider_name,
device_type, hostname, on_boarded_at, last_seen_at,
synced_at, is_deleted, deleted_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8, NOW(), false, NULL)
ON CONFLICT (distinguished_name) DO UPDATE SET
device_id = EXCLUDED.device_id,
username = EXCLUDED.username,
provider_name = EXCLUDED.provider_name,
device_type = EXCLUDED.device_type,
hostname = EXCLUDED.hostname,
on_boarded_at = EXCLUDED.on_boarded_at,
last_seen_at = EXCLUDED.last_seen_at,
synced_at = NOW(),
is_deleted = false,
deleted_at = NULL`,
[
d.distinguishedName,
d.deviceId ?? null,
d.username ?? null,
d.providerName ?? null,
(d as AppgateOnBoardedDevice & { deviceType?: string }).device_type
?? (d as AppgateOnBoardedDevice & { deviceType?: string }).deviceType
?? null,
d.hostname ?? null,
d.onBoardedAt ?? null,
d.lastSeenAt ?? null,
],
);
upserted++;
}
const tomb = seen.length === 0
? 0
: (await postgresClient.query(
`UPDATE appgate_devices SET is_deleted=true, deleted_at=NOW()
WHERE is_deleted=false AND distinguished_name <> ALL($1::text[])`,
[seen],
)).rowCount ?? 0;
if (tomb > 0) console.log(`[AppgateSync] Tombstoned ${tomb} device(s)`);
return { upserted, tombstoned: tomb };
}
private async syncAppliances(): Promise<{ upserted: number; tombstoned: number }> {
const items = await this.client.getAppliances();
let upserted = 0;
const seen: string[] = [];
for (const a of items) {
if (!a.id) continue;
seen.push(a.id);
const roles = {
controller: Boolean(a.controller?.enabled),
gateway: Boolean(a.gateway?.enabled),
logServer: Boolean(a.logServer?.enabled),
logForwarder: Boolean(a.logForwarder?.enabled),
metricsAggregator: Boolean(a.metricsAggregator?.enabled),
connector: Boolean(a.connector?.enabled),
portal: Boolean(a.portal?.enabled),
};
await postgresClient.query(
`INSERT INTO appgate_appliances
(id, name, hostname, notes, version, site, site_name,
activated, pending_certificate_renewal, tags, roles, raw,
created_at, updated_at, synced_at, is_deleted, deleted_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, NOW(), false, NULL)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
hostname = EXCLUDED.hostname,
notes = EXCLUDED.notes,
version = EXCLUDED.version,
site = EXCLUDED.site,
site_name = EXCLUDED.site_name,
activated = EXCLUDED.activated,
pending_certificate_renewal = EXCLUDED.pending_certificate_renewal,
tags = EXCLUDED.tags,
roles = EXCLUDED.roles,
raw = EXCLUDED.raw,
created_at = EXCLUDED.created_at,
updated_at = EXCLUDED.updated_at,
synced_at = NOW(),
is_deleted = false,
deleted_at = NULL`,
[
a.id, a.name, a.hostname ?? null, a.notes ?? null,
a.version ?? null, a.site ?? null, a.siteName ?? null,
a.activated ?? null, a.pendingCertificateRenewal ?? null,
JSON.stringify(a.tags ?? []), JSON.stringify(roles),
JSON.stringify(a),
a.created ?? null, a.updated ?? null,
],
);
upserted++;
}
const tomb = seen.length === 0
? 0
: (await postgresClient.query(
`UPDATE appgate_appliances SET is_deleted=true, deleted_at=NOW()
WHERE is_deleted=false AND id <> ALL($1::uuid[])`,
[seen],
)).rowCount ?? 0;
if (tomb > 0) console.log(`[AppgateSync] Tombstoned ${tomb} appliance(s)`);
return { upserted, tombstoned: tomb };
}
private async syncLicense(): Promise<number> {
const [license, users] = await Promise.all([
this.client.getLicense().catch(() => null),
this.client.getLicenseUsers().catch(() => [] as Awaited<ReturnType<AppgateClient['getLicenseUsers']>>),
]);
// Snapshot capacity / today's used count.
if (license) {
await postgresClient.query(
`INSERT INTO appgate_license_snapshots
(snapshot_date, max_users, max_portal_users, max_service_users,
used_users, expiration, license_type, license_version,
risk_engine_enabled, app_discovery_enabled)
VALUES (CURRENT_DATE, $1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (snapshot_date) DO UPDATE SET
max_users = EXCLUDED.max_users,
max_portal_users = EXCLUDED.max_portal_users,
max_service_users = EXCLUDED.max_service_users,
used_users = EXCLUDED.used_users,
expiration = EXCLUDED.expiration,
license_type = EXCLUDED.license_type,
license_version = EXCLUDED.license_version,
risk_engine_enabled = EXCLUDED.risk_engine_enabled,
app_discovery_enabled = EXCLUDED.app_discovery_enabled,
captured_at = NOW()`,
[
license.maxUsers ?? null,
license.maxPortalUsers ?? null,
license.maxServiceUsers ?? null,
users.length,
license.expiration ?? null,
license.type ?? null,
license.version ?? null,
license.riskEngine ?? null,
license.applicationDiscovery ?? null,
],
);
}
// Per-user license consumption — replace.
await postgresClient.transaction(async (tx) => {
await tx.query('TRUNCATE appgate_license_users');
for (const u of users) {
if (!u.userDistinguishedName) continue;
await tx.query(
`INSERT INTO appgate_license_users
(user_distinguished_name, username, provider_name,
license_type, profile_name, created_at, last_seen_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[
u.userDistinguishedName,
u.username ?? null,
u.providerName ?? null,
u.type ?? null,
u.profileName ?? null,
u.created ?? null,
u.lastSeenAt ?? null,
],
);
}
});
return users.length;
}
private async syncUserLogins(): Promise<number> {
const hourly = await this.client.getUserLoginsLast24h();
const totals = aggregateLogins(hourly);
let captured = 0;
for (const [date, total] of Object.entries(totals)) {
await postgresClient.query(
`INSERT INTO appgate_user_logins_daily (login_date, total_logins, captured_at)
VALUES ($1, $2, NOW())
ON CONFLICT (login_date) DO UPDATE SET
total_logins = EXCLUDED.total_logins,
captured_at = NOW()`,
[date, total],
);
captured++;
}
return captured;
}
// ─── History ──────────────────────────────────────────────────────────
private async persistHistory(r: AppgateSyncResult, triggeredBy: string): Promise<void> {
await postgresClient.query(
`INSERT INTO appgate_sync_history
(sync_id, sync_type, triggered_by, started_at, completed_at, status,
sessions_synced, devices_synced, devices_tombstoned,
appliances_synced, appliances_tombstoned,
license_users_synced, last_error)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
[
r.syncId, r.syncType, triggeredBy, r.startedAt, r.completedAt, r.status,
r.sessions, r.devices.upserted, r.devices.tombstoned,
r.appliances.upserted, r.appliances.tombstoned,
r.licenseUsers, r.errors[0] ?? null,
],
).catch((e) => console.error('[AppgateSync] history insert failed:', e));
}
}
let _instance: AppgateSyncService | null = null;
export function getAppgateSyncService(): AppgateSyncService {
if (!_instance) _instance = new AppgateSyncService();
return _instance;
}
/**
* Reduce the rolling-24h hourly login map to per-date totals. Hours that
* lie in the future (relative to UTC now) reflect yesterday's logins; the
* rest reflect today. Pulse only persists "today" and "yesterday".
*/
function aggregateLogins(hourly: AppgateHourlyLogins): Record<string, number> {
const nowUtc = new Date();
const todayDate = nowUtc.toISOString().slice(0, 10);
const yesterdayDate = new Date(nowUtc.getTime() - 86_400_000).toISOString().slice(0, 10);
const currentHourUtc = nowUtc.getUTCHours();
const totals: Record<string, number> = {};
for (const [key, val] of Object.entries(hourly)) {
const hour = parseInt(key, 10);
if (Number.isNaN(hour)) continue;
const bucket = hour > currentHourUtc ? yesterdayDate : todayDate;
const count = typeof val === 'number'
? val
: Number(val?.total ?? 0);
if (!Number.isFinite(count)) continue;
totals[bucket] = (totals[bucket] ?? 0) + count;
}
return totals;
}

View file

@ -343,6 +343,8 @@ export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Pr
['ZABBIX_API_URL', 'ZABBIX_API_TOKEN'])),
Promise.resolve(checkConfigOnly('qbo', 'QuickBooks Online', 'finance',
['QBO_CLIENT_ID', 'QBO_CLIENT_SECRET'])),
Promise.resolve(checkConfigOnly('appgate', 'AppGate SDP', 'security',
['APPGATE_URL', 'APPGATE_USERNAME', 'APPGATE_PASSWORD', 'APPGATE_DEVICE_ID'])),
Promise.resolve(checkConfigOnly('anthropic', 'Anthropic', 'llm',
['ANTHROPIC_API_KEY'])),
]);

130
lib/types/appgate.ts Normal file
View file

@ -0,0 +1,130 @@
/**
* Type definitions for the AppGate SDP Controller REST API (v22.5) and the
* shapes Pulse persists. Source spec lives at
* `https://wawnvaagp01.wulfconsulting.com:8443/api_specs.html`.
*
* Only the slices Pulse consumes are typed the API exposes ~150 endpoints,
* most of which are administrative writes Pulse never makes.
*/
// ─── API response shapes ──────────────────────────────────────────────────
export interface AppgateLoginResponse {
user?: unknown;
token: string;
expires: string; // ISO timestamp
messageOfTheDay?: string;
ztpCollectiveType?: string;
ztpAccountType?: string;
crlEnabled?: boolean;
}
export interface AppgateDeviceAndUser {
distinguishedName: string;
deviceId?: string;
username?: string;
providerName?: string;
}
export interface AppgateActiveSession extends AppgateDeviceAndUser {
geoIpLatitude?: number;
geoIpLongitude?: number;
hostname?: string;
osFamily?: string;
osName?: string;
osParent?: string;
clientVersion?: string;
clientType?: string;
clientSupport?: 'full' | 'partial' | 'none';
gateways?: string[];
}
export interface AppgateOnBoardedDevice extends AppgateDeviceAndUser {
device_type?: string;
hostname?: string;
onBoardedAt?: string;
lastSeenAt?: string;
}
export interface AppgateAppliance {
id: string;
name: string;
notes?: string;
hostname?: string;
version?: number;
site?: string;
siteName?: string;
activated?: boolean;
pendingCertificateRenewal?: boolean;
tags?: string[];
created?: string;
updated?: string;
controller?: { enabled?: boolean };
gateway?: { enabled?: boolean };
logServer?: { enabled?: boolean };
logForwarder?: { enabled?: boolean };
metricsAggregator?: { enabled?: boolean };
connector?: { enabled?: boolean };
portal?: { enabled?: boolean };
[key: string]: unknown;
}
export interface AppgateUserLicense {
userDistinguishedName: string;
username?: string;
providerName?: string;
type?: string; // user | portal | service
profileName?: string;
created?: string;
lastSeenAt?: string;
}
export interface AppgateLicense {
id?: string;
version?: number;
type?: number; // 1 production, 2 install, 3 test, 4 built-in, 5 aws, 6 metered
expiration?: string;
error?: string;
maxUsers?: number;
maxPortalUsers?: number;
maxServiceUsers?: number;
maxSites?: number;
maxAccessPolicies?: number;
maxConnectorGroups?: number;
riskEngine?: boolean;
applicationDiscovery?: boolean;
digitalExperienceMonitoring?: boolean;
}
/**
* `/stats/user-logins` returns a 24-hour rolling map keyed by hour-of-day,
* each value either a number or `{ total: number }` per-controller breakdown.
* Hour keys past "now" represent yesterday; everything else is today.
*/
export type AppgateHourlyLogins = Record<
string,
number | { total?: number | string; [controller: string]: number | string | undefined }
>;
export interface AppgateResultList<T> {
range?: string;
totalCount?: number;
data: T[];
}
// ─── Sync orchestration ───────────────────────────────────────────────────
export interface AppgateSyncResult {
syncId: string;
syncType: 'sessions' | 'daily' | 'full';
startedAt: Date;
completedAt: Date;
durationMs: number;
status: 'completed' | 'failed';
sessions: number;
devices: { upserted: number; tombstoned: number };
appliances: { upserted: number; tombstoned: number };
licenseUsers: number;
loginsCaptured: number;
errors: string[];
}

View file

@ -36,6 +36,7 @@ const publicRoutes = [
"/api/engagement/sync",
"/api/zoom/sync",
"/api/qbo/sync",
"/api/appgate/sync",
"/api/reports/ticket-digest",
"/api/notifications/morning-summary/send",
// Duo Security sync and data endpoints

View file

@ -0,0 +1,150 @@
-- AppGate SDP integration — Postgres schema.
--
-- Mirrors the slices of Appgate SDP Controller REST API v22.5 that Pulse
-- surfaces for the manager-on-the-go view:
--
-- • Active sessions (current snapshot) -> appgate_active_sessions
-- • On-boarded devices (slowly changing list) -> appgate_devices
-- • Controllers / Gateways -> appgate_appliances
-- • Per-user license consumption -> appgate_license_users
-- • Daily license capacity rollup -> appgate_license_snapshots
-- • Daily login totals (history series) -> appgate_user_logins_daily
-- Current active sessions — replaced each sync (no history beyond appliances log).
CREATE TABLE IF NOT EXISTS appgate_active_sessions (
distinguished_name TEXT PRIMARY KEY,
device_id UUID,
username TEXT,
provider_name TEXT,
hostname TEXT,
os_family TEXT,
os_name TEXT,
os_parent TEXT,
client_version TEXT,
client_type TEXT,
client_support TEXT,
geo_ip_latitude DOUBLE PRECISION,
geo_ip_longitude DOUBLE PRECISION,
gateways JSONB,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_appgate_sessions_username ON appgate_active_sessions(username);
CREATE INDEX IF NOT EXISTS idx_appgate_sessions_provider ON appgate_active_sessions(provider_name);
-- On-boarded devices — persistent inventory. Uses the same soft-delete
-- pattern as qbo_invoices: a full sync tombstones any row not returned.
CREATE TABLE IF NOT EXISTS appgate_devices (
distinguished_name TEXT PRIMARY KEY,
device_id UUID,
username TEXT,
provider_name TEXT,
device_type TEXT,
hostname TEXT,
on_boarded_at TIMESTAMPTZ,
last_seen_at TIMESTAMPTZ,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_appgate_devices_username ON appgate_devices(username);
CREATE INDEX IF NOT EXISTS idx_appgate_devices_is_deleted ON appgate_devices(is_deleted);
CREATE INDEX IF NOT EXISTS idx_appgate_devices_last_seen ON appgate_devices(last_seen_at);
-- Appliances (controllers, gateways, log servers, etc.).
CREATE TABLE IF NOT EXISTS appgate_appliances (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
hostname TEXT,
notes TEXT,
version INTEGER,
site TEXT,
site_name TEXT,
activated BOOLEAN,
pending_certificate_renewal BOOLEAN,
tags JSONB,
roles JSONB, -- {controller, gateway, logServer, ...} subset flags
raw JSONB, -- full payload — versioned schema changes
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_appgate_appliances_is_deleted ON appgate_appliances(is_deleted);
CREATE INDEX IF NOT EXISTS idx_appgate_appliances_site ON appgate_appliances(site);
-- Per-user license consumption — current snapshot, replaced each sync.
CREATE TABLE IF NOT EXISTS appgate_license_users (
user_distinguished_name TEXT PRIMARY KEY,
username TEXT,
provider_name TEXT,
license_type TEXT,
profile_name TEXT,
created_at TIMESTAMPTZ,
last_seen_at TIMESTAMPTZ,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_appgate_license_users_username ON appgate_license_users(username);
-- Daily license capacity rollup — one row per day, history retained.
CREATE TABLE IF NOT EXISTS appgate_license_snapshots (
snapshot_date DATE PRIMARY KEY,
max_users INTEGER,
max_portal_users INTEGER,
max_service_users INTEGER,
used_users INTEGER,
expiration TIMESTAMPTZ,
license_type INTEGER,
license_version INTEGER,
risk_engine_enabled BOOLEAN,
app_discovery_enabled BOOLEAN,
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Daily login totals — derived from the rolling 24h /stats/user-logins endpoint.
-- Sync replaces yesterday/today rows; older days never change.
CREATE TABLE IF NOT EXISTS appgate_user_logins_daily (
login_date DATE PRIMARY KEY,
total_logins INTEGER NOT NULL DEFAULT 0,
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Sync history.
CREATE TABLE IF NOT EXISTS appgate_sync_history (
id SERIAL PRIMARY KEY,
sync_id TEXT NOT NULL,
sync_type TEXT NOT NULL,
triggered_by TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
status TEXT NOT NULL,
sessions_synced INTEGER NOT NULL DEFAULT 0,
devices_synced INTEGER NOT NULL DEFAULT 0,
devices_tombstoned INTEGER NOT NULL DEFAULT 0,
appliances_synced INTEGER NOT NULL DEFAULT 0,
appliances_tombstoned INTEGER NOT NULL DEFAULT 0,
license_users_synced INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_appgate_sync_history_started ON appgate_sync_history(started_at DESC);
-- Scheduler entries — disabled by default until credentials configured.
-- sync_schedules has no unique constraint on name, so guard with NOT EXISTS.
INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, is_enabled)
SELECT 'appgate-sessions',
'AppGate Sessions',
'Active session snapshot every 5 minutes during business hours.',
'*/5 11-23 * * 1-5', 'appgate-sessions', false
WHERE NOT EXISTS (SELECT 1 FROM sync_schedules WHERE name = 'AppGate Sessions');
INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, is_enabled)
SELECT 'appgate-daily',
'AppGate Daily',
'Full AppGate sync — devices, appliances, license, login totals.',
'15 6 * * *', 'appgate-daily', false
WHERE NOT EXISTS (SELECT 1 FROM sync_schedules WHERE name = 'AppGate Daily');