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
376
lib/services/appgate-sync-service.ts
Normal file
376
lib/services/appgate-sync-service.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue