@checkstack/healthcheck-dns-backend 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/package.json +22 -0
- package/src/index.ts +23 -0
- package/src/plugin-metadata.ts +8 -0
- package/src/strategy.test.ts +270 -0
- package/src/strategy.ts +314 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @checkstack/healthcheck-dns-backend
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
|
|
8
|
+
- Updated dependencies [d20d274]
|
|
9
|
+
- @checkstack/backend-api@0.0.2
|
|
10
|
+
- @checkstack/common@0.0.2
|
|
11
|
+
- @checkstack/healthcheck-common@0.0.2
|
|
12
|
+
|
|
13
|
+
## 0.0.3
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [b4eb432]
|
|
18
|
+
- Updated dependencies [a65e002]
|
|
19
|
+
- @checkstack/backend-api@1.1.0
|
|
20
|
+
- @checkstack/common@0.2.0
|
|
21
|
+
- @checkstack/healthcheck-common@0.1.1
|
|
22
|
+
|
|
23
|
+
## 0.0.2
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [ffc28f6]
|
|
28
|
+
- Updated dependencies [4dd644d]
|
|
29
|
+
- Updated dependencies [71275dd]
|
|
30
|
+
- Updated dependencies [ae19ff6]
|
|
31
|
+
- Updated dependencies [0babb9c]
|
|
32
|
+
- Updated dependencies [b55fae6]
|
|
33
|
+
- Updated dependencies [b354ab3]
|
|
34
|
+
- Updated dependencies [81f3f85]
|
|
35
|
+
- @checkstack/common@0.1.0
|
|
36
|
+
- @checkstack/backend-api@1.0.0
|
|
37
|
+
- @checkstack/healthcheck-common@0.1.0
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/healthcheck-dns-backend",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"lint": "bun run lint:code",
|
|
9
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@checkstack/backend-api": "workspace:*",
|
|
13
|
+
"@checkstack/common": "workspace:*",
|
|
14
|
+
"@checkstack/healthcheck-common": "workspace:*"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/bun": "^1.0.0",
|
|
18
|
+
"typescript": "^5.0.0",
|
|
19
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
20
|
+
"@checkstack/scripts": "workspace:*"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBackendPlugin,
|
|
3
|
+
coreServices,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
5
|
+
import { DnsHealthCheckStrategy } from "./strategy";
|
|
6
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
7
|
+
|
|
8
|
+
export default createBackendPlugin({
|
|
9
|
+
metadata: pluginMetadata,
|
|
10
|
+
register(env) {
|
|
11
|
+
env.registerInit({
|
|
12
|
+
deps: {
|
|
13
|
+
healthCheckRegistry: coreServices.healthCheckRegistry,
|
|
14
|
+
logger: coreServices.logger,
|
|
15
|
+
},
|
|
16
|
+
init: async ({ healthCheckRegistry, logger }) => {
|
|
17
|
+
logger.debug("🔌 Registering DNS Health Check Strategy...");
|
|
18
|
+
const strategy = new DnsHealthCheckStrategy();
|
|
19
|
+
healthCheckRegistry.register(strategy);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DnsHealthCheckStrategy,
|
|
4
|
+
DnsResolver,
|
|
5
|
+
ResolverFactory,
|
|
6
|
+
} from "./strategy";
|
|
7
|
+
|
|
8
|
+
describe("DnsHealthCheckStrategy", () => {
|
|
9
|
+
// Helper to create mock resolver factory
|
|
10
|
+
const createMockResolver = (
|
|
11
|
+
config: {
|
|
12
|
+
resolve4?: string[] | Error;
|
|
13
|
+
resolve6?: string[] | Error;
|
|
14
|
+
resolveCname?: string[] | Error;
|
|
15
|
+
resolveMx?: { priority: number; exchange: string }[] | Error;
|
|
16
|
+
resolveTxt?: string[][] | Error;
|
|
17
|
+
resolveNs?: string[] | Error;
|
|
18
|
+
} = {}
|
|
19
|
+
): ResolverFactory => {
|
|
20
|
+
return () =>
|
|
21
|
+
({
|
|
22
|
+
setServers: mock(() => {}),
|
|
23
|
+
resolve4: mock(() =>
|
|
24
|
+
config.resolve4 instanceof Error
|
|
25
|
+
? Promise.reject(config.resolve4)
|
|
26
|
+
: Promise.resolve(config.resolve4 ?? [])
|
|
27
|
+
),
|
|
28
|
+
resolve6: mock(() =>
|
|
29
|
+
config.resolve6 instanceof Error
|
|
30
|
+
? Promise.reject(config.resolve6)
|
|
31
|
+
: Promise.resolve(config.resolve6 ?? [])
|
|
32
|
+
),
|
|
33
|
+
resolveCname: mock(() =>
|
|
34
|
+
config.resolveCname instanceof Error
|
|
35
|
+
? Promise.reject(config.resolveCname)
|
|
36
|
+
: Promise.resolve(config.resolveCname ?? [])
|
|
37
|
+
),
|
|
38
|
+
resolveMx: mock(() =>
|
|
39
|
+
config.resolveMx instanceof Error
|
|
40
|
+
? Promise.reject(config.resolveMx)
|
|
41
|
+
: Promise.resolve(config.resolveMx ?? [])
|
|
42
|
+
),
|
|
43
|
+
resolveTxt: mock(() =>
|
|
44
|
+
config.resolveTxt instanceof Error
|
|
45
|
+
? Promise.reject(config.resolveTxt)
|
|
46
|
+
: Promise.resolve(config.resolveTxt ?? [])
|
|
47
|
+
),
|
|
48
|
+
resolveNs: mock(() =>
|
|
49
|
+
config.resolveNs instanceof Error
|
|
50
|
+
? Promise.reject(config.resolveNs)
|
|
51
|
+
: Promise.resolve(config.resolveNs ?? [])
|
|
52
|
+
),
|
|
53
|
+
} as DnsResolver);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("execute", () => {
|
|
57
|
+
it("should return healthy for successful A record resolution", async () => {
|
|
58
|
+
const strategy = new DnsHealthCheckStrategy(
|
|
59
|
+
createMockResolver({ resolve4: ["1.2.3.4", "5.6.7.8"] })
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const result = await strategy.execute({
|
|
63
|
+
hostname: "example.com",
|
|
64
|
+
recordType: "A",
|
|
65
|
+
timeout: 5000,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result.status).toBe("healthy");
|
|
69
|
+
expect(result.metadata?.resolvedValues).toEqual(["1.2.3.4", "5.6.7.8"]);
|
|
70
|
+
expect(result.metadata?.recordCount).toBe(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should return unhealthy for DNS error", async () => {
|
|
74
|
+
const strategy = new DnsHealthCheckStrategy(
|
|
75
|
+
createMockResolver({ resolve4: new Error("NXDOMAIN") })
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const result = await strategy.execute({
|
|
79
|
+
hostname: "nonexistent.example.com",
|
|
80
|
+
recordType: "A",
|
|
81
|
+
timeout: 5000,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result.status).toBe("unhealthy");
|
|
85
|
+
expect(result.message).toContain("NXDOMAIN");
|
|
86
|
+
expect(result.metadata?.error).toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should pass recordExists assertion when records found", async () => {
|
|
90
|
+
const strategy = new DnsHealthCheckStrategy(
|
|
91
|
+
createMockResolver({ resolve4: ["1.2.3.4"] })
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const result = await strategy.execute({
|
|
95
|
+
hostname: "example.com",
|
|
96
|
+
recordType: "A",
|
|
97
|
+
timeout: 5000,
|
|
98
|
+
assertions: [{ field: "recordExists", operator: "isTrue" }],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result.status).toBe("healthy");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should fail recordExists assertion when no records", async () => {
|
|
105
|
+
const strategy = new DnsHealthCheckStrategy(
|
|
106
|
+
createMockResolver({ resolve4: [] })
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const result = await strategy.execute({
|
|
110
|
+
hostname: "example.com",
|
|
111
|
+
recordType: "A",
|
|
112
|
+
timeout: 5000,
|
|
113
|
+
assertions: [{ field: "recordExists", operator: "isTrue" }],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.status).toBe("unhealthy");
|
|
117
|
+
expect(result.message).toContain("Assertion failed");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should pass recordValue assertion with matching value", async () => {
|
|
121
|
+
const strategy = new DnsHealthCheckStrategy(
|
|
122
|
+
createMockResolver({ resolveCname: ["cdn.example.com"] })
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const result = await strategy.execute({
|
|
126
|
+
hostname: "example.com",
|
|
127
|
+
recordType: "CNAME",
|
|
128
|
+
timeout: 5000,
|
|
129
|
+
assertions: [
|
|
130
|
+
{ field: "recordValue", operator: "contains", value: "cdn" },
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.status).toBe("healthy");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should pass recordCount assertion", async () => {
|
|
138
|
+
const strategy = new DnsHealthCheckStrategy(
|
|
139
|
+
createMockResolver({ resolve4: ["1.2.3.4", "5.6.7.8", "9.10.11.12"] })
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const result = await strategy.execute({
|
|
143
|
+
hostname: "example.com",
|
|
144
|
+
recordType: "A",
|
|
145
|
+
timeout: 5000,
|
|
146
|
+
assertions: [
|
|
147
|
+
{ field: "recordCount", operator: "greaterThanOrEqual", value: 2 },
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result.status).toBe("healthy");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should resolve MX records correctly", async () => {
|
|
155
|
+
const strategy = new DnsHealthCheckStrategy(
|
|
156
|
+
createMockResolver({
|
|
157
|
+
resolveMx: [
|
|
158
|
+
{ priority: 0, exchange: "mail1.example.com" },
|
|
159
|
+
{ priority: 10, exchange: "mail2.example.com" },
|
|
160
|
+
],
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const result = await strategy.execute({
|
|
165
|
+
hostname: "example.com",
|
|
166
|
+
recordType: "MX",
|
|
167
|
+
timeout: 5000,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result.status).toBe("healthy");
|
|
171
|
+
expect(result.metadata?.resolvedValues).toContain("0 mail1.example.com");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("should use custom nameserver when provided", async () => {
|
|
175
|
+
const setServersMock = mock(() => {});
|
|
176
|
+
const strategy = new DnsHealthCheckStrategy(() => ({
|
|
177
|
+
setServers: setServersMock,
|
|
178
|
+
resolve4: mock(() => Promise.resolve(["1.2.3.4"])),
|
|
179
|
+
resolve6: mock(() => Promise.resolve([])),
|
|
180
|
+
resolveCname: mock(() => Promise.resolve([])),
|
|
181
|
+
resolveMx: mock(() => Promise.resolve([])),
|
|
182
|
+
resolveTxt: mock(() => Promise.resolve([])),
|
|
183
|
+
resolveNs: mock(() => Promise.resolve([])),
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
await strategy.execute({
|
|
187
|
+
hostname: "example.com",
|
|
188
|
+
recordType: "A",
|
|
189
|
+
nameserver: "8.8.8.8",
|
|
190
|
+
timeout: 5000,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(setServersMock).toHaveBeenCalledWith(["8.8.8.8"]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("aggregateResult", () => {
|
|
198
|
+
it("should calculate averages correctly", () => {
|
|
199
|
+
const strategy = new DnsHealthCheckStrategy();
|
|
200
|
+
const runs = [
|
|
201
|
+
{
|
|
202
|
+
id: "1",
|
|
203
|
+
status: "healthy" as const,
|
|
204
|
+
latencyMs: 10,
|
|
205
|
+
checkId: "c1",
|
|
206
|
+
timestamp: new Date(),
|
|
207
|
+
metadata: {
|
|
208
|
+
resolvedValues: ["1.2.3.4"],
|
|
209
|
+
recordCount: 1,
|
|
210
|
+
resolutionTimeMs: 10,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
id: "2",
|
|
215
|
+
status: "healthy" as const,
|
|
216
|
+
latencyMs: 20,
|
|
217
|
+
checkId: "c1",
|
|
218
|
+
timestamp: new Date(),
|
|
219
|
+
metadata: {
|
|
220
|
+
resolvedValues: ["5.6.7.8"],
|
|
221
|
+
recordCount: 1,
|
|
222
|
+
resolutionTimeMs: 20,
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
228
|
+
|
|
229
|
+
expect(aggregated.avgResolutionTime).toBe(15);
|
|
230
|
+
expect(aggregated.failureCount).toBe(0);
|
|
231
|
+
expect(aggregated.errorCount).toBe(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("should count failures and errors", () => {
|
|
235
|
+
const strategy = new DnsHealthCheckStrategy();
|
|
236
|
+
const runs = [
|
|
237
|
+
{
|
|
238
|
+
id: "1",
|
|
239
|
+
status: "unhealthy" as const,
|
|
240
|
+
latencyMs: 10,
|
|
241
|
+
checkId: "c1",
|
|
242
|
+
timestamp: new Date(),
|
|
243
|
+
metadata: {
|
|
244
|
+
resolvedValues: [],
|
|
245
|
+
recordCount: 0,
|
|
246
|
+
resolutionTimeMs: 10,
|
|
247
|
+
error: "NXDOMAIN",
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "2",
|
|
252
|
+
status: "unhealthy" as const,
|
|
253
|
+
latencyMs: 20,
|
|
254
|
+
checkId: "c1",
|
|
255
|
+
timestamp: new Date(),
|
|
256
|
+
metadata: {
|
|
257
|
+
resolvedValues: [],
|
|
258
|
+
recordCount: 0,
|
|
259
|
+
resolutionTimeMs: 20,
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
265
|
+
|
|
266
|
+
expect(aggregated.errorCount).toBe(1);
|
|
267
|
+
expect(aggregated.failureCount).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
package/src/strategy.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import * as dns from "node:dns/promises";
|
|
2
|
+
import {
|
|
3
|
+
HealthCheckStrategy,
|
|
4
|
+
HealthCheckResult,
|
|
5
|
+
HealthCheckRunForAggregation,
|
|
6
|
+
Versioned,
|
|
7
|
+
z,
|
|
8
|
+
booleanField,
|
|
9
|
+
stringField,
|
|
10
|
+
numericField,
|
|
11
|
+
timeThresholdField,
|
|
12
|
+
evaluateAssertions,
|
|
13
|
+
} from "@checkstack/backend-api";
|
|
14
|
+
import {
|
|
15
|
+
healthResultNumber,
|
|
16
|
+
healthResultString,
|
|
17
|
+
} from "@checkstack/healthcheck-common";
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// SCHEMAS
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Assertion schema for DNS health checks using shared factories.
|
|
25
|
+
*/
|
|
26
|
+
const dnsAssertionSchema = z.discriminatedUnion("field", [
|
|
27
|
+
booleanField("recordExists"),
|
|
28
|
+
stringField("recordValue"),
|
|
29
|
+
numericField("recordCount", { min: 0 }),
|
|
30
|
+
timeThresholdField("resolutionTime"),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
export type DnsAssertion = z.infer<typeof dnsAssertionSchema>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Configuration schema for DNS health checks.
|
|
37
|
+
*/
|
|
38
|
+
export const dnsConfigSchema = z.object({
|
|
39
|
+
hostname: z.string().describe("Hostname to resolve"),
|
|
40
|
+
recordType: z
|
|
41
|
+
.enum(["A", "AAAA", "CNAME", "MX", "TXT", "NS"])
|
|
42
|
+
.default("A")
|
|
43
|
+
.describe("DNS record type to query"),
|
|
44
|
+
nameserver: z
|
|
45
|
+
.string()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Custom nameserver (optional, uses system default)"),
|
|
48
|
+
timeout: z
|
|
49
|
+
.number()
|
|
50
|
+
.min(100)
|
|
51
|
+
.default(5000)
|
|
52
|
+
.describe("Timeout in milliseconds"),
|
|
53
|
+
assertions: z
|
|
54
|
+
.array(dnsAssertionSchema)
|
|
55
|
+
.optional()
|
|
56
|
+
.describe("Conditions for validation"),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export type DnsConfig = z.infer<typeof dnsConfigSchema>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Per-run result metadata.
|
|
63
|
+
*/
|
|
64
|
+
const dnsResultSchema = z.object({
|
|
65
|
+
resolvedValues: z.array(z.string()).meta({
|
|
66
|
+
"x-chart-type": "text",
|
|
67
|
+
"x-chart-label": "Resolved Values",
|
|
68
|
+
}),
|
|
69
|
+
recordCount: healthResultNumber({
|
|
70
|
+
"x-chart-type": "counter",
|
|
71
|
+
"x-chart-label": "Record Count",
|
|
72
|
+
}),
|
|
73
|
+
nameserver: healthResultString({
|
|
74
|
+
"x-chart-type": "text",
|
|
75
|
+
"x-chart-label": "Nameserver",
|
|
76
|
+
}).optional(),
|
|
77
|
+
resolutionTimeMs: healthResultNumber({
|
|
78
|
+
"x-chart-type": "line",
|
|
79
|
+
"x-chart-label": "Resolution Time",
|
|
80
|
+
"x-chart-unit": "ms",
|
|
81
|
+
}),
|
|
82
|
+
failedAssertion: dnsAssertionSchema.optional(),
|
|
83
|
+
error: healthResultString({
|
|
84
|
+
"x-chart-type": "status",
|
|
85
|
+
"x-chart-label": "Error",
|
|
86
|
+
}).optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export type DnsResult = z.infer<typeof dnsResultSchema>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Aggregated metadata for buckets.
|
|
93
|
+
*/
|
|
94
|
+
const dnsAggregatedSchema = z.object({
|
|
95
|
+
avgResolutionTime: healthResultNumber({
|
|
96
|
+
"x-chart-type": "line",
|
|
97
|
+
"x-chart-label": "Avg Resolution Time",
|
|
98
|
+
"x-chart-unit": "ms",
|
|
99
|
+
}),
|
|
100
|
+
failureCount: healthResultNumber({
|
|
101
|
+
"x-chart-type": "counter",
|
|
102
|
+
"x-chart-label": "Failures",
|
|
103
|
+
}),
|
|
104
|
+
errorCount: healthResultNumber({
|
|
105
|
+
"x-chart-type": "counter",
|
|
106
|
+
"x-chart-label": "Errors",
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export type DnsAggregatedResult = z.infer<typeof dnsAggregatedSchema>;
|
|
111
|
+
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// RESOLVER INTERFACE (for testability)
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
export interface DnsResolver {
|
|
117
|
+
setServers(servers: string[]): void;
|
|
118
|
+
resolve4(hostname: string): Promise<string[]>;
|
|
119
|
+
resolve6(hostname: string): Promise<string[]>;
|
|
120
|
+
resolveCname(hostname: string): Promise<string[]>;
|
|
121
|
+
resolveMx(
|
|
122
|
+
hostname: string
|
|
123
|
+
): Promise<{ priority: number; exchange: string }[]>;
|
|
124
|
+
resolveTxt(hostname: string): Promise<string[][]>;
|
|
125
|
+
resolveNs(hostname: string): Promise<string[]>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ResolverFactory = () => DnsResolver;
|
|
129
|
+
|
|
130
|
+
// Default factory using Node.js dns module
|
|
131
|
+
const defaultResolverFactory: ResolverFactory = () =>
|
|
132
|
+
new dns.Resolver() as DnsResolver;
|
|
133
|
+
|
|
134
|
+
// ============================================================================
|
|
135
|
+
// STRATEGY
|
|
136
|
+
// ============================================================================
|
|
137
|
+
|
|
138
|
+
export class DnsHealthCheckStrategy
|
|
139
|
+
implements HealthCheckStrategy<DnsConfig, DnsResult, DnsAggregatedResult>
|
|
140
|
+
{
|
|
141
|
+
id = "dns";
|
|
142
|
+
displayName = "DNS Health Check";
|
|
143
|
+
description = "DNS record resolution with response validation";
|
|
144
|
+
|
|
145
|
+
// Injected resolver factory for testing
|
|
146
|
+
private resolverFactory: ResolverFactory;
|
|
147
|
+
|
|
148
|
+
constructor(resolverFactory: ResolverFactory = defaultResolverFactory) {
|
|
149
|
+
this.resolverFactory = resolverFactory;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
config: Versioned<DnsConfig> = new Versioned({
|
|
153
|
+
version: 1,
|
|
154
|
+
schema: dnsConfigSchema,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
result: Versioned<DnsResult> = new Versioned({
|
|
158
|
+
version: 1,
|
|
159
|
+
schema: dnsResultSchema,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
aggregatedResult: Versioned<DnsAggregatedResult> = new Versioned({
|
|
163
|
+
version: 1,
|
|
164
|
+
schema: dnsAggregatedSchema,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
aggregateResult(
|
|
168
|
+
runs: HealthCheckRunForAggregation<DnsResult>[]
|
|
169
|
+
): DnsAggregatedResult {
|
|
170
|
+
let totalResolutionTime = 0;
|
|
171
|
+
let failureCount = 0;
|
|
172
|
+
let errorCount = 0;
|
|
173
|
+
let validRuns = 0;
|
|
174
|
+
|
|
175
|
+
for (const run of runs) {
|
|
176
|
+
if (run.metadata?.error) {
|
|
177
|
+
errorCount++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (run.status === "unhealthy") {
|
|
181
|
+
failureCount++;
|
|
182
|
+
}
|
|
183
|
+
if (run.metadata) {
|
|
184
|
+
totalResolutionTime += run.metadata.resolutionTimeMs;
|
|
185
|
+
validRuns++;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
avgResolutionTime: validRuns > 0 ? totalResolutionTime / validRuns : 0,
|
|
191
|
+
failureCount,
|
|
192
|
+
errorCount,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async execute(config: DnsConfig): Promise<HealthCheckResult<DnsResult>> {
|
|
197
|
+
const validatedConfig = this.config.validate(config);
|
|
198
|
+
const start = performance.now();
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
// Configure resolver with custom nameserver if provided
|
|
202
|
+
const resolver = this.resolverFactory();
|
|
203
|
+
if (validatedConfig.nameserver) {
|
|
204
|
+
resolver.setServers([validatedConfig.nameserver]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Perform DNS lookup based on record type
|
|
208
|
+
const resolvedValues = await this.resolveRecords(
|
|
209
|
+
resolver,
|
|
210
|
+
validatedConfig.hostname,
|
|
211
|
+
validatedConfig.recordType,
|
|
212
|
+
validatedConfig.timeout
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const end = performance.now();
|
|
216
|
+
const resolutionTimeMs = Math.round(end - start);
|
|
217
|
+
|
|
218
|
+
const result: Omit<DnsResult, "failedAssertion" | "error"> = {
|
|
219
|
+
resolvedValues,
|
|
220
|
+
recordCount: resolvedValues.length,
|
|
221
|
+
nameserver: validatedConfig.nameserver,
|
|
222
|
+
resolutionTimeMs,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Evaluate assertions using shared utility
|
|
226
|
+
const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
|
|
227
|
+
recordExists: resolvedValues.length > 0,
|
|
228
|
+
recordValue: resolvedValues[0] ?? "",
|
|
229
|
+
recordCount: resolvedValues.length,
|
|
230
|
+
resolutionTime: resolutionTimeMs,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (failedAssertion) {
|
|
234
|
+
return {
|
|
235
|
+
status: "unhealthy",
|
|
236
|
+
latencyMs: resolutionTimeMs,
|
|
237
|
+
message: `Assertion failed: ${failedAssertion.field} ${
|
|
238
|
+
failedAssertion.operator
|
|
239
|
+
}${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
|
|
240
|
+
metadata: { ...result, failedAssertion },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
status: "healthy",
|
|
246
|
+
latencyMs: resolutionTimeMs,
|
|
247
|
+
message: `Resolved ${validatedConfig.hostname} (${
|
|
248
|
+
validatedConfig.recordType
|
|
249
|
+
}): ${resolvedValues.slice(0, 3).join(", ")}${
|
|
250
|
+
resolvedValues.length > 3 ? "..." : ""
|
|
251
|
+
}`,
|
|
252
|
+
metadata: result,
|
|
253
|
+
};
|
|
254
|
+
} catch (error: unknown) {
|
|
255
|
+
const end = performance.now();
|
|
256
|
+
const isError = error instanceof Error;
|
|
257
|
+
return {
|
|
258
|
+
status: "unhealthy",
|
|
259
|
+
latencyMs: Math.round(end - start),
|
|
260
|
+
message: isError ? error.message : "DNS resolution failed",
|
|
261
|
+
metadata: {
|
|
262
|
+
resolvedValues: [],
|
|
263
|
+
recordCount: 0,
|
|
264
|
+
nameserver: validatedConfig.nameserver,
|
|
265
|
+
resolutionTimeMs: Math.round(end - start),
|
|
266
|
+
error: isError ? error.name : "UnknownError",
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resolve DNS records based on type.
|
|
274
|
+
*/
|
|
275
|
+
private async resolveRecords(
|
|
276
|
+
resolver: DnsResolver,
|
|
277
|
+
hostname: string,
|
|
278
|
+
recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS",
|
|
279
|
+
timeout: number
|
|
280
|
+
): Promise<string[]> {
|
|
281
|
+
// Create timeout promise
|
|
282
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
283
|
+
setTimeout(() => reject(new Error("DNS resolution timeout")), timeout);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Resolve based on record type
|
|
287
|
+
const resolvePromise = (async () => {
|
|
288
|
+
switch (recordType) {
|
|
289
|
+
case "A": {
|
|
290
|
+
return await resolver.resolve4(hostname);
|
|
291
|
+
}
|
|
292
|
+
case "AAAA": {
|
|
293
|
+
return await resolver.resolve6(hostname);
|
|
294
|
+
}
|
|
295
|
+
case "CNAME": {
|
|
296
|
+
return await resolver.resolveCname(hostname);
|
|
297
|
+
}
|
|
298
|
+
case "MX": {
|
|
299
|
+
const records = await resolver.resolveMx(hostname);
|
|
300
|
+
return records.map((r) => `${r.priority} ${r.exchange}`);
|
|
301
|
+
}
|
|
302
|
+
case "TXT": {
|
|
303
|
+
const records = await resolver.resolveTxt(hostname);
|
|
304
|
+
return records.map((r) => r.join(""));
|
|
305
|
+
}
|
|
306
|
+
case "NS": {
|
|
307
|
+
return await resolver.resolveNs(hostname);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
})();
|
|
311
|
+
|
|
312
|
+
return Promise.race([resolvePromise, timeoutPromise]);
|
|
313
|
+
}
|
|
314
|
+
}
|