From ea04672e5b74fb257c9d1f6aabf107cf3eea581e Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 20:24:36 -0400 Subject: [PATCH] feat(24-04): register checkRoute53() in the integration health aggregator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checkRoute53(): config gate + ListHostedZonesCommand auth probe, mirrors checkDattoRmm()'s custom-body shape (key: 'route53', category: 'network') - Auth-probe errors classified via isAwsAuthError (InvalidClientTokenId, SignatureDoesNotMatch, AccessDenied, UnrecognizedClientException, 401/403) and redacted through a local sanitizeAwsError before reaching IntegrationHealth.error - D-12: queries route53_zones (is_deleted=false, capped at 50 by name), feeds checkAllZoneDelegations(); mismatches downgrade status to a new 'degraded' HealthStatus member; lookup failures reported separately via nsDelegationErrors, never counted as mismatches - Whole delegation step wrapped in try/catch so a Postgres failure or blocked resolver can never abort checkIntegrationHealth()'s Promise.all (T-24-16) - summarize() updated so 'degraded' counts toward failed/hasIssues instead of falling through uncounted (Rule 1 fix) - No changes to applyDisableOverlay() — route53 covered by the existing generic by-key overlay (D-10) --- lib/services/integration-health.ts | 127 ++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 3 deletions(-) diff --git a/lib/services/integration-health.ts b/lib/services/integration-health.ts index fe28934..709605e 100644 --- a/lib/services/integration-health.ts +++ b/lib/services/integration-health.ts @@ -11,12 +11,19 @@ * Usage: * const results = await checkIntegrationHealth(); * - * Tools covered live: S1, Datto RMM, IT Glue, Autotask. Others report - * configured / not_configured only — extending to live checks is mechanical. + * Tools covered live: S1, Datto RMM, IT Glue, Autotask, AWS Route 53. Others + * report configured / not_configured only — extending to live checks is + * mechanical. */ +import { ListHostedZonesCommand } from '@aws-sdk/client-route-53'; +import { isRoute53Configured, getRoute53Client } from '@/lib/services/route53-factory'; +import { checkAllZoneDelegations } from '@/lib/services/route53-dns-delegation'; + export type HealthStatus = | 'ok' // configured, auth succeeded + | 'degraded' // configured, auth succeeded, but a secondary check found a problem + // (e.g. Route 53 D-12 NS-delegation mismatch) — reachable, not fully healthy | 'auth_failed' // configured, server returned 401/403 | 'unreachable' // configured, network/DNS/TLS error | 'not_configured' // env vars missing @@ -40,6 +47,10 @@ export interface IntegrationHealth { error?: string | null; tokenExpiry?: TokenExpiry | null; checkedAt: string; + /** D-12: zone names whose live NS answer mismatches Route 53's authoritative NS list. */ + nsDelegationMismatches?: string[] | null; + /** D-12: zone names whose live NS lookup failed (infrastructure problem, not a mismatch). */ + nsDelegationErrors?: string[] | null; } interface CacheEntry { @@ -190,6 +201,115 @@ async function checkDattoRmm(): Promise { } } +/** + * Redact an AWS SDK error down to a message safe to surface in the health + * API response and (eventually) route53_audit_log.error_message. + * + * NOTE: this duplicates the `sanitizeAwsError` spec'd for + * `lib/services/route53-record-validation.ts` in plan 24-03 (T-24-03). That + * file did not exist in this plan's isolated worktree at execution time + * (24-03 runs in a sibling parallel worktree and depends_on for this plan + * only lists 24-01) — see this plan's SUMMARY "Deviations" section. This + * local copy uses the identical redaction rules so behavior is consistent + * regardless of which implementation ships; if 24-03 lands first in a + * future merge, this local copy should be replaced with an import from + * `@/lib/services/route53-record-validation` for a single source of truth. + */ +function sanitizeAwsError(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + return message + .replace(/AKIA[0-9A-Z]{16}/g, '[redacted-key-id]') + .replace(/arn:aws:[^\s"']+/g, '[redacted-arn]') + .replace(/\b[0-9]{12}\b/g, '[redacted-account-id]') + .slice(0, 500); +} + +const AWS_AUTH_ERROR_NAMES = new Set([ + 'InvalidClientTokenId', + 'SignatureDoesNotMatch', + 'AccessDenied', + 'UnrecognizedClientException', +]); + +function isAwsAuthError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const name = (err as Error & { name?: string }).name; + if (name && AWS_AUTH_ERROR_NAMES.has(name)) return true; + const httpStatusCode = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode; + return httpStatusCode === 401 || httpStatusCode === 403; +} + +const ROUTE53_ZONE_CHECK_LIMIT = 50; + +async function checkRoute53(): Promise { + const checkedAt = new Date().toISOString(); + if (!isRoute53Configured()) { + return { + key: 'route53', name: 'AWS Route 53', category: 'network', + status: 'not_configured', configured: false, checkedAt, + }; + } + + const start = Date.now(); + let status: HealthStatus; + let error: string | null = null; + try { + await getRoute53Client().send(new ListHostedZonesCommand({ MaxItems: 1 })); + status = 'ok'; + } catch (err) { + status = isAwsAuthError(err) ? 'auth_failed' : 'unreachable'; + error = sanitizeAwsError(err); + } + const latencyMs = Date.now() - start; + + // D-12: live NS-delegation check. Wrapped in its own try/catch — a + // Postgres failure or a blocked resolver must degrade to + // nsDelegationErrors, never throw out of checkIntegrationHealth()'s + // Promise.all (T-24-16). + let nsDelegationMismatches: string[] | null = null; + let nsDelegationErrors: string[] | null = null; + try { + // Lazy import to avoid pulling postgres-client into edge runtimes. + const { default: postgresClient } = await import('@/lib/services/postgres-client'); + const res = await postgresClient.query<{ id: string; name: string; authoritative_name_servers: unknown }>( + `SELECT id, name, authoritative_name_servers + FROM route53_zones + WHERE is_deleted = false + ORDER BY name + LIMIT ${ROUTE53_ZONE_CHECK_LIMIT}`, + ); + const zones = res.rows.map((r) => ({ + id: r.id, + name: r.name, + authoritativeNameServers: r.authoritative_name_servers, + })); + const delegationResults = await checkAllZoneDelegations(zones); + const mismatches = delegationResults.filter((r) => r.mismatch).map((r) => r.zoneName); + const errors = delegationResults.filter((r) => r.error).map((r) => r.zoneName); + if (mismatches.length > 0) nsDelegationMismatches = mismatches; + if (errors.length > 0) nsDelegationErrors = errors; + + if (mismatches.length > 0 && status === 'ok') { + status = 'degraded'; + const truncationNote = res.rowCount === ROUTE53_ZONE_CHECK_LIMIT + ? ` (checked first ${ROUTE53_ZONE_CHECK_LIMIT} zones by name)` + : ''; + error = `NS delegation mismatch for ${mismatches.length} zone(s): ${mismatches.join(', ')}${truncationNote}`; + } + } catch (err) { + // Postgres unreachable, migration not applied yet, or resolver blocked — + // an infrastructure problem, not evidence of delegation drift. Preserve + // the auth-probe status; just note the delegation check itself failed. + nsDelegationErrors = [err instanceof Error ? err.message : String(err)]; + } + + return { + key: 'route53', name: 'AWS Route 53', category: 'network', + status, configured: true, latencyMs, error, checkedAt, + nsDelegationMismatches, nsDelegationErrors, + }; +} + async function checkItglue(): Promise { const apiKey = process.env.ITGLUE_API_KEY; const checkedAt = new Date().toISOString(); @@ -327,6 +447,7 @@ export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Pr checkDattoRmm(), checkItglue(), checkS1(), + checkRoute53(), Promise.resolve(checkConfigOnly('veeam', 'Veeam VSPC', 'backup', ['VEEAM_VSPC_URL', 'VEEAM_VSPC_API_KEY'])), Promise.resolve(checkConfigOnly('msgraph', 'Microsoft Graph', 'productivity', @@ -379,7 +500,7 @@ export function summarize(items: IntegrationHealth[]): HealthSummary { continue; } if (i.status === 'ok' || i.status === 'unknown') ok += 1; - else if (i.status === 'auth_failed' || i.status === 'unreachable') failed += 1; + else if (i.status === 'auth_failed' || i.status === 'unreachable' || i.status === 'degraded') failed += 1; else if (i.status === 'not_configured') notConfigured += 1; if (i.tokenExpiry) { if (i.tokenExpiry.daysRemaining <= 0) expired += 1;