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
33
app/api/appgate/health/route.ts
Normal file
33
app/api/appgate/health/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/api/appgate/sync/route.ts
Normal file
78
app/api/appgate/sync/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/services/appgate-factory.ts
Normal file
47
lib/services/appgate-factory.ts
Normal 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;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
@ -343,6 +343,8 @@ export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Pr
|
||||||
['ZABBIX_API_URL', 'ZABBIX_API_TOKEN'])),
|
['ZABBIX_API_URL', 'ZABBIX_API_TOKEN'])),
|
||||||
Promise.resolve(checkConfigOnly('qbo', 'QuickBooks Online', 'finance',
|
Promise.resolve(checkConfigOnly('qbo', 'QuickBooks Online', 'finance',
|
||||||
['QBO_CLIENT_ID', 'QBO_CLIENT_SECRET'])),
|
['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',
|
Promise.resolve(checkConfigOnly('anthropic', 'Anthropic', 'llm',
|
||||||
['ANTHROPIC_API_KEY'])),
|
['ANTHROPIC_API_KEY'])),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
130
lib/types/appgate.ts
Normal file
130
lib/types/appgate.ts
Normal 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[];
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,7 @@ const publicRoutes = [
|
||||||
"/api/engagement/sync",
|
"/api/engagement/sync",
|
||||||
"/api/zoom/sync",
|
"/api/zoom/sync",
|
||||||
"/api/qbo/sync",
|
"/api/qbo/sync",
|
||||||
|
"/api/appgate/sync",
|
||||||
"/api/reports/ticket-digest",
|
"/api/reports/ticket-digest",
|
||||||
"/api/notifications/morning-summary/send",
|
"/api/notifications/morning-summary/send",
|
||||||
// Duo Security sync and data endpoints
|
// Duo Security sync and data endpoints
|
||||||
|
|
|
||||||
150
migrations/089_appgate_tables.sql
Normal file
150
migrations/089_appgate_tables.sql
Normal 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');
|
||||||
Loading…
Add table
Add a link
Reference in a new issue