From b168d44585074cd000e1b6ab3ca679a08788720a Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 11 Jul 2026 09:43:48 -0400 Subject: [PATCH] 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 + isConfigured() 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 Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR --- app/api/appgate/health/route.ts | 33 +++ app/api/appgate/sync/route.ts | 78 ++++++ lib/services/appgate-client.ts | 181 +++++++++++++ lib/services/appgate-factory.ts | 47 ++++ lib/services/appgate-sync-service.ts | 376 +++++++++++++++++++++++++++ lib/services/integration-health.ts | 2 + lib/types/appgate.ts | 130 +++++++++ middleware.ts | 1 + migrations/089_appgate_tables.sql | 150 +++++++++++ 9 files changed, 998 insertions(+) create mode 100644 app/api/appgate/health/route.ts create mode 100644 app/api/appgate/sync/route.ts create mode 100644 lib/services/appgate-client.ts create mode 100644 lib/services/appgate-factory.ts create mode 100644 lib/services/appgate-sync-service.ts create mode 100644 lib/types/appgate.ts create mode 100644 migrations/089_appgate_tables.sql diff --git a/app/api/appgate/health/route.ts b/app/api/appgate/health/route.ts new file mode 100644 index 0000000..a4b0c39 --- /dev/null +++ b/app/api/appgate/health/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/appgate/sync/route.ts b/app/api/appgate/sync/route.ts new file mode 100644 index 0000000..99a4877 --- /dev/null +++ b/app/api/appgate/sync/route.ts @@ -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 }); + } +} diff --git a/lib/services/appgate-client.ts b/lib/services/appgate-client.ts new file mode 100644 index 0000000..02587b4 --- /dev/null +++ b/lib/services/appgate-client.ts @@ -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( + path: string, + method: 'GET' | 'POST', + body?: unknown, + auth?: { token?: string }, + ): Promise { + const url = new URL(`${this.config.baseUrl}${path}`); + const payload = body !== undefined ? JSON.stringify(body) : undefined; + return new Promise((resolve, reject) => { + const headers: Record = { + 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 { + if (this.token && this.token.expiresAt - Date.now() > REFRESH_BUFFER_MS) { + return this.token.token; + } + const resp = await this.rawRequest('/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(path: string): Promise { + const token = await this.getToken(); + return this.rawRequest(`/admin${path}`, 'GET', undefined, { token }); + } + + // ─── Endpoints ───────────────────────────────────────────────────────── + + async getActiveSessions(): Promise { + const r = await this.get>('/stats/active-sessions/dn'); + return r.data ?? []; + } + + async getUserLoginsLast24h(): Promise { + const r = await this.get<{ data: AppgateHourlyLogins }>('/stats/user-logins'); + return r.data ?? {}; + } + + async getOnBoardedDevices(): Promise { + const r = await this.get>('/on-boarded-devices'); + return r.data ?? []; + } + + async getAppliances(): Promise { + const r = await this.get>('/appliances'); + return r.data ?? []; + } + + async getLicense(): Promise { + return this.get('/license'); + } + + async getLicenseUsers(): Promise { + const r = await this.get>('/license/users'); + return r.data ?? []; + } + + // Cheap connectivity probe — does not require auth. + async ping(): Promise<{ ok: true }> { + await this.rawRequest('/admin/identity-providers/names', 'GET'); + return { ok: true }; + } +} diff --git a/lib/services/appgate-factory.ts b/lib/services/appgate-factory.ts new file mode 100644 index 0000000..deadc44 --- /dev/null +++ b/lib/services/appgate-factory.ts @@ -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; +} diff --git a/lib/services/appgate-sync-service.ts b/lib/services/appgate-sync-service.ts new file mode 100644 index 0000000..9cf89b1 --- /dev/null +++ b/lib/services/appgate-sync-service.ts @@ -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 { + return this.run('sessions', triggeredBy); + } + + async dailySync(triggeredBy = 'system'): Promise { + 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 { + 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 { + 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 { + const [license, users] = await Promise.all([ + this.client.getLicense().catch(() => null), + this.client.getLicenseUsers().catch(() => [] as Awaited>), + ]); + + // 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 { + 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 { + 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 { + 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 = {}; + 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; +} diff --git a/lib/services/integration-health.ts b/lib/services/integration-health.ts index e6c2f86..371e735 100644 --- a/lib/services/integration-health.ts +++ b/lib/services/integration-health.ts @@ -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'])), ]); diff --git a/lib/types/appgate.ts b/lib/types/appgate.ts new file mode 100644 index 0000000..0d71ffb --- /dev/null +++ b/lib/types/appgate.ts @@ -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 { + 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[]; +} diff --git a/middleware.ts b/middleware.ts index 1ea73bb..d288bb0 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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 diff --git a/migrations/089_appgate_tables.sql b/migrations/089_appgate_tables.sql new file mode 100644 index 0000000..16c0789 --- /dev/null +++ b/migrations/089_appgate_tables.sql @@ -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');