feat(24-04): register checkRoute53() in the integration health aggregator
- 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)
This commit is contained in:
parent
06ebae5a5c
commit
ea04672e5b
1 changed files with 124 additions and 3 deletions
|
|
@ -11,12 +11,19 @@
|
||||||
* Usage:
|
* Usage:
|
||||||
* const results = await checkIntegrationHealth();
|
* const results = await checkIntegrationHealth();
|
||||||
*
|
*
|
||||||
* Tools covered live: S1, Datto RMM, IT Glue, Autotask. Others report
|
* Tools covered live: S1, Datto RMM, IT Glue, Autotask, AWS Route 53. Others
|
||||||
* configured / not_configured only — extending to live checks is mechanical.
|
* 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 =
|
export type HealthStatus =
|
||||||
| 'ok' // configured, auth succeeded
|
| '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
|
| 'auth_failed' // configured, server returned 401/403
|
||||||
| 'unreachable' // configured, network/DNS/TLS error
|
| 'unreachable' // configured, network/DNS/TLS error
|
||||||
| 'not_configured' // env vars missing
|
| 'not_configured' // env vars missing
|
||||||
|
|
@ -40,6 +47,10 @@ export interface IntegrationHealth {
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
tokenExpiry?: TokenExpiry | null;
|
tokenExpiry?: TokenExpiry | null;
|
||||||
checkedAt: string;
|
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 {
|
interface CacheEntry {
|
||||||
|
|
@ -190,6 +201,115 @@ async function checkDattoRmm(): Promise<IntegrationHealth> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<IntegrationHealth> {
|
||||||
|
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<IntegrationHealth> {
|
async function checkItglue(): Promise<IntegrationHealth> {
|
||||||
const apiKey = process.env.ITGLUE_API_KEY;
|
const apiKey = process.env.ITGLUE_API_KEY;
|
||||||
const checkedAt = new Date().toISOString();
|
const checkedAt = new Date().toISOString();
|
||||||
|
|
@ -327,6 +447,7 @@ export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Pr
|
||||||
checkDattoRmm(),
|
checkDattoRmm(),
|
||||||
checkItglue(),
|
checkItglue(),
|
||||||
checkS1(),
|
checkS1(),
|
||||||
|
checkRoute53(),
|
||||||
Promise.resolve(checkConfigOnly('veeam', 'Veeam VSPC', 'backup',
|
Promise.resolve(checkConfigOnly('veeam', 'Veeam VSPC', 'backup',
|
||||||
['VEEAM_VSPC_URL', 'VEEAM_VSPC_API_KEY'])),
|
['VEEAM_VSPC_URL', 'VEEAM_VSPC_API_KEY'])),
|
||||||
Promise.resolve(checkConfigOnly('msgraph', 'Microsoft Graph', 'productivity',
|
Promise.resolve(checkConfigOnly('msgraph', 'Microsoft Graph', 'productivity',
|
||||||
|
|
@ -379,7 +500,7 @@ export function summarize(items: IntegrationHealth[]): HealthSummary {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (i.status === 'ok' || i.status === 'unknown') ok += 1;
|
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;
|
else if (i.status === 'not_configured') notConfigured += 1;
|
||||||
if (i.tokenExpiry) {
|
if (i.tokenExpiry) {
|
||||||
if (i.tokenExpiry.daysRemaining <= 0) expired += 1;
|
if (i.tokenExpiry.daysRemaining <= 0) expired += 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue