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'])),
|
||||
Promise.resolve(checkConfigOnly('qbo', 'QuickBooks Online', 'finance',
|
||||
['QBO_CLIENT_ID', 'QBO_CLIENT_SECRET'])),
|
||||
Promise.resolve(checkConfigOnly('appgate', 'AppGate SDP', 'security',
|
||||
['APPGATE_URL', 'APPGATE_USERNAME', 'APPGATE_PASSWORD', 'APPGATE_DEVICE_ID'])),
|
||||
Promise.resolve(checkConfigOnly('anthropic', 'Anthropic', 'llm',
|
||||
['ANTHROPIC_API_KEY'])),
|
||||
]);
|
||||
|
|
|
|||
130
lib/types/appgate.ts
Normal file
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/zoom/sync",
|
||||
"/api/qbo/sync",
|
||||
"/api/appgate/sync",
|
||||
"/api/reports/ticket-digest",
|
||||
"/api/notifications/morning-summary/send",
|
||||
// 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