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 |
|
|
true |
|
|
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.mdexport 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 throughString(x).trim().toLowerCase().replace(/\.$/, ''), drops empty strings, de-duplicates via aSet, and sorts.compareNsDelegation(authoritative: unknown, live: unknown): { mismatch: boolean; authoritative: string[]; live: string[]; missingFromLive: string[]; extraInLive: string[] }— normalizes both sides, then computes set differences. Returnsmismatch: falsewhen the normalized authoritative list is empty (unjudgeable, not a failure). Returnsmismatch: truewhen the normalized live list is empty but the authoritative list is not. OtherwisemismatchismissingFromLive.length > 0 || extraInLive.length > 0.
I/O exports:
resolveLiveNs(domain: string, timeoutMs = 5000): Promise<{ ok: true; nameServers: string[] } | { ok: false; error: string }>— constructnew Resolver()fromnode:dns(importResolverfrom'dns', matching the existing codebase precedent inping-flap-suppress.ts), callresolver.setServers(['1.1.1.1', '8.8.8.8'])on that instance, thenresolveNs(promisified viautil.promisify(resolver.resolveNs.bind(resolver))or theresolver.resolveNscallback wrapped in aPromise). Race it against a timeout that callsresolver.cancel()and resolves{ ok: false, error: 'DNS lookup timed out after Nms' }. Strip the trailing dot fromdomainbefore lookup. CRITICAL (24-RESEARCH.md Pitfall 5): never call the module-leveldns.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 mostconcurrency(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 whoseauthoritativeNameServersnormalizes 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.
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:
- Config gate, mirroring
checkDattoRmm()'s early return: ifisRoute53Configured()is false, returnstatus: 'not_configured', configured: falsewithcheckedAtset. Do not construct the client. - Auth probe:
getRoute53Client().send(new ListHostedZonesCommand({ MaxItems: '1' }))wrapped in try/catch, timing it forlatencyMs. On an AWS SDK error whosenameor$metadata.httpStatusCodeindicates a credential/authorization problem (InvalidClientTokenId,SignatureDoesNotMatch,AccessDenied,UnrecognizedClientException, or HTTP 401/403), returnstatus: 'auth_failed'. On any other error returnstatus: 'unreachable'. In both branches seterrorto the sanitized message fromsanitizeAwsErrorinlib/services/route53-record-validation.ts(T-24-03) — never the raw AWS error object, which carries$metadataincluding request ids. - D-12 delegation check: query
SELECT id, name, authoritative_name_servers FROM route53_zones WHERE is_deleted = falseand pass the rows (transformed to the{ id, name, authoritativeNameServers }camelCase shape) tocheckAllZoneDelegations(). Collect zone names wheremismatch === trueintonsDelegationMismatchesand zone names with anerrorintonsDelegationErrors. IfnsDelegationMismatchesis non-empty, downgrade the returnedstatusfrom'ok'to the existing degraded-status member of theHealthStatusunion — 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 onHealthStatus(grep forstatus ===acrossapp/andcomponents/) renders an unknown value without crashing. Seterrorto a summary such as'NS delegation mismatch for N zone(s): example.com, other.com'when mismatches exist. - 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. - Wrap the whole delegation step in try/catch — a Postgres failure or a blocked resolver
must degrade to
nsDelegationErrorsand leave the auth-probe status intact, never throw out ofcheckIntegrationHealth()'sPromise.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> |
<success_criteria>
route53present 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>