wulf-pulse/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-04-PLAN.md

20 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud 04 execute 2
24-01
lib/services/route53-dns-delegation.ts
lib/services/route53-dns-delegation.test.ts
lib/services/integration-health.ts
true
SC-6
truths artifacts key_links
SC-6: Route 53 appears in the integration health list under key 'route53', with the same not_configured / ok / auth_failed / unreachable status vocabulary every other integration uses
D-12: The health check compares each hosted zone's Route-53-authoritative NS records against a LIVE public DNS lookup for that domain, and a mismatch degrades the reported health — there is no manually-maintained 'expected NS' field anywhere
D-12: The live lookup uses a dedicated dns.Resolver() instance with setServers(['1.1.1.1','8.8.8.8']); the process-global dns.setServers() is never called, so internal service hostname resolution is unaffected
D-10: The 'route53' health result flows through the existing applyDisableOverlay(), so disabling Route 53 in /admin/integrations suppresses the health display only — no sync or CRUD path consults integration_settings
path provides exports
lib/services/route53-dns-delegation.ts NS normalization + mismatch detection + live resolver lookup, split so the pure half is unit-testable
normalizeNsList
compareNsDelegation
resolveLiveNs
checkAllZoneDelegations
path provides
lib/services/route53-dns-delegation.test.ts NS normalization and mismatch-detection coverage
path provides contains
lib/services/integration-health.ts checkRoute53() registered in checkIntegrationHealth()'s Promise.all checkRoute53
from to via pattern
lib/services/integration-health.ts lib/services/route53-factory.ts isRoute53Configured() gate + ListHostedZonesCommand auth probe isRoute53Configured
from to via pattern
lib/services/integration-health.ts lib/services/route53-dns-delegation.ts checkAllZoneDelegations() call inside checkRoute53() checkAllZoneDelegations
from to via pattern
lib/services/route53-dns-delegation.ts node:dns dedicated Resolver instance with setServers new Resolver(
Add Route 53 to the integration health system with the D-12 DNS-specific delegation check: beyond the standard auth probe and last-sync age, compare each hosted zone's Route-53-authoritative name servers against a live public DNS lookup and flag mismatches as degraded health.

Purpose: SC-6 (integration appears in the existing health/admin surface alongside the others) plus D-12's DNS-specific extension. Output: lib/services/route53-dns-delegation.ts (+ tests) and a checkRoute53() function registered in lib/services/integration-health.ts.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md

export type HealthStatus = /* union defined at line 18 — includes 'ok', 'not_configured', 'auth_failed', 'unreachable', 'unknown', 'disabled'; read the live union before writing */

export interface IntegrationHealth { key: string; name: string; category: 'psa' | 'rmm' | 'docs' | 'security' | 'backup' | 'network' | 'identity' | 'mdm' | 'mail' | 'finance' | 'productivity' | 'llm'; status: HealthStatus; configured: boolean; latencyMs?: number; error?: string | null; tokenExpiry?: TokenExpiry | null; checkedAt: string; }

export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Promise<IntegrationHealth[]> // line ~325: results = await Promise.all([ checkAutotask(), checkDattoRmm(), checkItglue(), checkS1(), ...checkConfigOnly wrappers ]) // line ~354: const overlaid = await applyDisableOverlay(results); <- D-10 disable overlay, already generic by key

lib/services/route53-factory.ts: isRoute53Configured(): boolean getRoute53Client(): Route53Client

Postgres (migration 102): route53_zones(id, name, authoritative_name_servers JSONB, is_deleted, synced_at, ...)

Task 1: NS normalization and delegation-comparison module lib/services/route53-dns-delegation.ts, lib/services/route53-dns-delegation.test.ts - lib/services/pipeline-steps/ping-flap-suppress.ts line 6 (the existing `import { promises as dns } from 'dns'` precedent in this codebase) - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Code Examples: checkNsDelegation; Pitfall 5: never call global dns.setServers) - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md (the recorded DNS-egress result — EGRESS-OK or EGRESS-BLOCKED — decides the resolver strategy in Task 2) - migrations/102_route53_tables.sql (route53_zones.authoritative_name_servers) - `normalizeNsList(['NS-123.AWSDNS-45.com.', 'ns-999.awsdns-01.org'])` returns `['ns-123.awsdns-45.com', 'ns-999.awsdns-01.org']` — lowercased, trailing dot stripped - `normalizeNsList` returns `[]` for `null`, `undefined`, and a non-array input - `normalizeNsList` de-duplicates and sorts, so ordering differences never register as a mismatch - `compareNsDelegation(authoritative, live)` with identical sets returns `{ mismatch: false, missingFromLive: [], extraInLive: [] }` - `compareNsDelegation` returns `mismatch: true` with a populated `missingFromLive` when an authoritative NS is absent from the live answer - `compareNsDelegation` returns `mismatch: true` with a populated `extraInLive` when the live answer contains an NS Route 53 does not consider authoritative - `compareNsDelegation(authoritative, [])` returns `mismatch: true` (a domain with no live NS answer is a delegation problem, not a pass) - `compareNsDelegation([], live)` returns `mismatch: false` — a zone with no recorded authoritative NS cannot be judged, so it must not produce a false alarm - Comparison is case-insensitive and trailing-dot-insensitive on both sides Create `lib/services/route53-dns-delegation.ts` split into a pure half and an I/O half so the comparison logic is unit-testable without network access.

Pure exports:

  • normalizeNsList(input: unknown): string[] — returns [] for non-arrays; otherwise maps each entry through String(x).trim().toLowerCase().replace(/\.$/, ''), drops empty strings, de-duplicates via a Set, and sorts.
  • compareNsDelegation(authoritative: unknown, live: unknown): { mismatch: boolean; authoritative: string[]; live: string[]; missingFromLive: string[]; extraInLive: string[] } — normalizes both sides, then computes set differences. Returns mismatch: false when the normalized authoritative list is empty (unjudgeable, not a failure). Returns mismatch: true when the normalized live list is empty but the authoritative list is not. Otherwise mismatch is missingFromLive.length > 0 || extraInLive.length > 0.

I/O exports:

  • resolveLiveNs(domain: string, timeoutMs = 5000): Promise<{ ok: true; nameServers: string[] } | { ok: false; error: string }> — construct new Resolver() from node:dns (import Resolver from 'dns', matching the existing codebase precedent in ping-flap-suppress.ts), call resolver.setServers(['1.1.1.1', '8.8.8.8']) on that instance, then resolveNs (promisified via util.promisify(resolver.resolveNs.bind(resolver)) or the resolver.resolveNs callback wrapped in a Promise). Race it against a timeout that calls resolver.cancel() and resolves { ok: false, error: 'DNS lookup timed out after Nms' }. Strip the trailing dot from domain before lookup. CRITICAL (24-RESEARCH.md Pitfall 5): never call the module-level dns.setServers() — that would repoint DNS resolution for the entire Node process, including Postgres and Redis hostname resolution. Add an inline comment stating this.
  • checkAllZoneDelegations(zones: Array<{ id: string; name: string; authoritativeNameServers: unknown }>, opts?: { concurrency?: number }): Promise<Array<{ zoneId: string; zoneName: string; mismatch: boolean; error?: string; missingFromLive: string[]; extraInLive: string[] }>> — resolve each zone's live NS and compare. Run at most concurrency (default 5) lookups in parallel so a large zone list does not open hundreds of concurrent UDP sockets. A lookup error yields { mismatch: false, error: <message> } — an unreachable resolver is an infrastructure problem, not evidence of delegation drift, and must not be reported as a mismatch. Skip zones whose authoritativeNameServers normalizes to an empty list.

Create lib/services/route53-dns-delegation.test.ts covering every pure-half <behavior> case. Do not test resolveLiveNs against a live resolver — network calls in unit tests are flaky; that path is covered by the manual verification in 24-VALIDATION.md. Import vitest primitives explicitly (globals: false). npx vitest run lib/services/route53-dns-delegation.test.ts && npx tsc --noEmit --pretty <acceptance_criteria> - npx vitest run lib/services/route53-dns-delegation.test.ts passes with at least 9 assertions covering every <behavior> bullet - grep -c 'dns.setServers\|setServers(\[.*\])' lib/services/route53-dns-delegation.ts shows setServers called only on a Resolver instance variable, never on the imported dns module namespace - grep -q 'new Resolver(' lib/services/route53-dns-delegation.ts - compareNsDelegation([], ['ns1.example.com']) returns mismatch: false — asserted in the test file (no false alarm on unjudgeable zones) - compareNsDelegation(['ns1.example.com'], []) returns mismatch: true — asserted in the test file - The test file contains no network call: grep -c 'resolveLiveNs' lib/services/route53-dns-delegation.test.ts returns 0 - npx tsc --noEmit --pretty exits 0 </acceptance_criteria> Pure NS comparison logic fully unit-tested; live resolver isolated to a dedicated instance; global DNS untouched.

Task 2: Register checkRoute53() in the integration health aggregator lib/services/integration-health.ts - lib/services/integration-health.ts (read in full — HealthStatus union at line ~18, IntegrationHealth interface at line ~33, checkDattoRmm() at lines 145-191 for the custom-body live-check pattern, checkItglue() at lines 193-209, checkConfigOnly() at lines 238-252, applyDisableOverlay() at lines ~310-319, checkIntegrationHealth() Promise.all at lines 325-352) - lib/services/route53-dns-delegation.ts (created in Task 1) - lib/services/route53-factory.ts (created in plan 24-01) - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md (DNS-egress result) - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md (integration-health section) Modify `lib/services/integration-health.ts` only — do not create a parallel health module.

Extend the IntegrationHealth interface with two optional fields (optional so no existing check function needs changing): nsDelegationMismatches?: string[] | null (zone names with a delegation mismatch) and nsDelegationErrors?: string[] | null (zone names whose live lookup failed). Do NOT overload the existing error field for this — 24-PATTERNS.md calls this out explicitly.

Add async function checkRoute53(): Promise<IntegrationHealth> placed next to checkDattoRmm(), using key: 'route53', name: 'AWS Route 53', category: 'network' (an existing member of the category union — do not add a new category value). Behavior:

  1. Config gate, mirroring checkDattoRmm()'s early return: if isRoute53Configured() is false, return status: 'not_configured', configured: false with checkedAt set. Do not construct the client.
  2. Auth probe: getRoute53Client().send(new ListHostedZonesCommand({ MaxItems: '1' })) wrapped in try/catch, timing it for latencyMs. On an AWS SDK error whose name or $metadata.httpStatusCode indicates a credential/authorization problem (InvalidClientTokenId, SignatureDoesNotMatch, AccessDenied, UnrecognizedClientException, or HTTP 401/403), return status: 'auth_failed'. On any other error return status: 'unreachable'. In both branches set error to the sanitized message from sanitizeAwsError in lib/services/route53-record-validation.ts (T-24-03) — never the raw AWS error object, which carries $metadata including request ids.
  3. D-12 delegation check: query SELECT id, name, authoritative_name_servers FROM route53_zones WHERE is_deleted = false and pass the rows (transformed to the { id, name, authoritativeNameServers } camelCase shape) to checkAllZoneDelegations(). Collect zone names where mismatch === true into nsDelegationMismatches and zone names with an error into nsDelegationErrors. If nsDelegationMismatches is non-empty, downgrade the returned status from 'ok' to the existing degraded-status member of the HealthStatus union — read the union at line ~18 and use the member that already represents "reachable but not healthy"; if the union has no such member, add 'degraded' to it and confirm every consumer that switches on HealthStatus (grep for status === across app/ and components/) renders an unknown value without crashing. Set error to a summary such as 'NS delegation mismatch for N zone(s): example.com, other.com' when mismatches exist.
  4. Bound the total cost: if the zone list exceeds 50 zones, check only the first 50 by name order and note the truncation in error. The health check runs behind a 5-minute cache and must not become the slowest call in the aggregate.
  5. Wrap the whole delegation step in try/catch — a Postgres failure or a blocked resolver must degrade to nsDelegationErrors and leave the auth-probe status intact, never throw out of checkIntegrationHealth()'s Promise.all.

If plan 24-01's SUMMARY recorded EGRESS-BLOCKED for the DNS smoke test, implement resolveLiveNs's fallback path instead: a DoH GET to https://cloudflare-dns.com/dns-query?name=<domain>&type=NS with header Accept: application/dns-json, parsing Answer[].data — same normalized output shape, no new npm dependency (uses fetch). Note which path was taken in the SUMMARY.

Register the check in checkIntegrationHealth()'s Promise.all array (line ~325) as a bare checkRoute53(), call alongside checkAutotask() / checkDattoRmm() — not wrapped in Promise.resolve(), which is only used for the synchronous checkConfigOnly() helpers.

Do NOT add any Route 53 branch to applyDisableOverlay() — it already keys off item.key, so the 'route53' result is covered automatically (D-10, display-only). npx tsc --noEmit --pretty && grep -q "checkRoute53()," lib/services/integration-health.ts && grep -q "key: 'route53'" lib/services/integration-health.ts && npm test <acceptance_criteria> - lib/services/integration-health.ts contains an async function checkRoute53() returning key: 'route53', name: 'AWS Route 53', category: 'network' - checkRoute53(), appears inside checkIntegrationHealth()'s Promise.all([...]) array, unwrapped - IntegrationHealth gained nsDelegationMismatches? and nsDelegationErrors? as optional fields; npx tsc --noEmit --pretty exits 0 with no changes required in any other check function - grep -c 'integration_settings' lib/services/integration-health.ts is unchanged from before this task (the disable overlay already existed; no new Route-53-specific disable logic added — D-10) - The auth-probe catch branch passes its error through sanitizeAwsError: grep -q 'sanitizeAwsError' lib/services/integration-health.ts - curl -s localhost:3100/api/admin/integration-health (or whichever route already serves checkIntegrationHealth, found by grep -rl checkIntegrationHealth app/api) returns a JSON array containing an object with "key":"route53" - npm test full suite exits 0 </acceptance_criteria> Route 53 appears in the health aggregate with an auth probe plus D-12 delegation check; disable overlay works via the existing generic path; no other integration's check regressed.

<threat_model>

Trust Boundaries

Boundary Description
Pulse container → public DNS resolvers (1.1.1.1 / 8.8.8.8, UDP/53 or DoH/443) Outbound network call to a third party whose answer influences a health verdict
AWS Route 53 API → health check Auth probe error text may carry request metadata

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-24-13 Denial of Service process-global DNS resolver configuration mitigate resolveLiveNs calls setServers on a dedicated new Resolver() instance only. The process-global dns.setServers() is never invoked, so Postgres/Redis/AWS hostname resolution inside the container is unaffected. Asserted in Task 1 acceptance criteria.
T-24-14 Denial of Service unbounded parallel NS lookups across a large zone list mitigate checkAllZoneDelegations runs at most 5 concurrent lookups, each with a 5s timeout and resolver.cancel(), and checkRoute53 caps the checked zone list at 50. The whole check sits behind the existing 5-minute health cache.
T-24-03 Information Disclosure auth-probe error surfaced in the health API response (readable by any authenticated user) mitigate Auth-probe errors pass through sanitizeAwsError before being placed in IntegrationHealth.error, redacting key ids, ARNs, and account ids.
T-24-15 Spoofing a third-party public resolver returning a forged NS answer accept The check is advisory health signalling, not an enforcement gate — a false mismatch degrades a status badge and triggers human investigation; it cannot cause a DNS mutation. Two independent resolvers (1.1.1.1 and 8.8.8.8) are configured, and lookup failures are reported as nsDelegationErrors rather than mismatches so an unreachable/hostile resolver cannot manufacture a false-positive drift alarm.
T-24-16 Denial of Service an exception in the delegation step aborting Promise.all and blanking every integration's health mitigate The entire delegation step is wrapped in try/catch inside checkRoute53; failures degrade to nsDelegationErrors while preserving the auth-probe status.
</threat_model>
- `npx vitest run lib/services/route53-dns-delegation.test.ts` green - `npm test` full suite green - `npx tsc --noEmit --pretty` exits 0 - The health endpoint returns a `route53` entry (curl assertion in Task 2 acceptance criteria) - Toggling `route53` off at `/admin/integrations` flips its status to `disabled` within the 5-minute cache while a manual `POST /api/route53/sync` still works (D-10) — confirmed at the plan 24-07 checkpoint

<success_criteria>

  • route53 present in the integration health list with the standard status vocabulary
  • D-12 live NS comparison implemented against a dedicated resolver instance
  • Delegation mismatches degrade the reported status and are enumerated in nsDelegationMismatches
  • Lookup failures are reported separately and never counted as mismatches
  • No process-global DNS mutation; no Route-53-specific disable gating </success_criteria>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-04-SUMMARY.md` when done. Record whether the Node `dns` path or the DoH fallback was used, and the exact `HealthStatus` union member chosen for the degraded state.