@checkstack/healthcheck-dns-backend 0.0.2 → 0.1.0
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 +43 -0
- package/package.json +1 -1
- package/src/index.ts +7 -5
- package/src/lookup-collector.test.ts +139 -0
- package/src/lookup-collector.ts +147 -0
- package/src/strategy.test.ts +52 -92
- package/src/strategy.ts +130 -172
- package/src/transport-client.ts +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# @checkstack/healthcheck-dns-backend
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f5b1f49: Refactored health check strategies to use `createClient()` pattern with built-in collectors.
|
|
8
|
+
|
|
9
|
+
**Strategy Changes:**
|
|
10
|
+
|
|
11
|
+
- Replaced `execute()` with `createClient()` that returns a transport client
|
|
12
|
+
- Strategy configs now only contain connection parameters
|
|
13
|
+
- Collector configs handle what to do with the connection
|
|
14
|
+
|
|
15
|
+
**Built-in Collectors Added:**
|
|
16
|
+
|
|
17
|
+
- DNS: `LookupCollector` for hostname resolution
|
|
18
|
+
- gRPC: `HealthCollector` for gRPC health protocol
|
|
19
|
+
- HTTP: `RequestCollector` for HTTP requests
|
|
20
|
+
- MySQL: `QueryCollector` for database queries
|
|
21
|
+
- Ping: `PingCollector` for ICMP ping
|
|
22
|
+
- Postgres: `QueryCollector` for database queries
|
|
23
|
+
- Redis: `CommandCollector` for Redis commands
|
|
24
|
+
- Script: `ExecuteCollector` for script execution
|
|
25
|
+
- SSH: `CommandCollector` for SSH commands
|
|
26
|
+
- TCP: `BannerCollector` for TCP banner grabbing
|
|
27
|
+
- TLS: `CertificateCollector` for certificate inspection
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- Updated dependencies [f5b1f49]
|
|
32
|
+
- Updated dependencies [f5b1f49]
|
|
33
|
+
- Updated dependencies [f5b1f49]
|
|
34
|
+
- Updated dependencies [f5b1f49]
|
|
35
|
+
- @checkstack/backend-api@0.1.0
|
|
36
|
+
- @checkstack/healthcheck-common@0.1.0
|
|
37
|
+
- @checkstack/common@0.0.3
|
|
38
|
+
|
|
39
|
+
## 0.0.3
|
|
40
|
+
|
|
41
|
+
### Patch Changes
|
|
42
|
+
|
|
43
|
+
- Updated dependencies [cb82e4d]
|
|
44
|
+
- @checkstack/healthcheck-common@0.0.3
|
|
45
|
+
|
|
3
46
|
## 0.0.2
|
|
4
47
|
|
|
5
48
|
### Patch Changes
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createBackendPlugin,
|
|
3
|
-
coreServices,
|
|
4
|
-
} from "@checkstack/backend-api";
|
|
1
|
+
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
5
2
|
import { DnsHealthCheckStrategy } from "./strategy";
|
|
6
3
|
import { pluginMetadata } from "./plugin-metadata";
|
|
4
|
+
import { LookupCollector } from "./lookup-collector";
|
|
7
5
|
|
|
8
6
|
export default createBackendPlugin({
|
|
9
7
|
metadata: pluginMetadata,
|
|
@@ -11,13 +9,17 @@ export default createBackendPlugin({
|
|
|
11
9
|
env.registerInit({
|
|
12
10
|
deps: {
|
|
13
11
|
healthCheckRegistry: coreServices.healthCheckRegistry,
|
|
12
|
+
collectorRegistry: coreServices.collectorRegistry,
|
|
14
13
|
logger: coreServices.logger,
|
|
15
14
|
},
|
|
16
|
-
init: async ({ healthCheckRegistry, logger }) => {
|
|
15
|
+
init: async ({ healthCheckRegistry, collectorRegistry, logger }) => {
|
|
17
16
|
logger.debug("🔌 Registering DNS Health Check Strategy...");
|
|
18
17
|
const strategy = new DnsHealthCheckStrategy();
|
|
19
18
|
healthCheckRegistry.register(strategy);
|
|
19
|
+
collectorRegistry.register(new LookupCollector());
|
|
20
20
|
},
|
|
21
21
|
});
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
export { pluginMetadata } from "./plugin-metadata";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { LookupCollector, type LookupConfig } from "./lookup-collector";
|
|
3
|
+
import type { DnsTransportClient } from "./transport-client";
|
|
4
|
+
|
|
5
|
+
describe("LookupCollector", () => {
|
|
6
|
+
const createMockClient = (
|
|
7
|
+
response: { values: string[]; error?: string } = { values: ["192.168.1.1"] }
|
|
8
|
+
): DnsTransportClient => ({
|
|
9
|
+
exec: mock(() => Promise.resolve(response)),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("execute", () => {
|
|
13
|
+
it("should resolve DNS records successfully", async () => {
|
|
14
|
+
const collector = new LookupCollector();
|
|
15
|
+
const client = createMockClient({
|
|
16
|
+
values: ["192.168.1.1", "192.168.1.2"],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const result = await collector.execute({
|
|
20
|
+
config: { hostname: "example.com", recordType: "A" },
|
|
21
|
+
client,
|
|
22
|
+
pluginId: "test",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(result.result.values).toEqual(["192.168.1.1", "192.168.1.2"]);
|
|
26
|
+
expect(result.result.recordCount).toBe(2);
|
|
27
|
+
expect(result.result.resolutionTimeMs).toBeGreaterThanOrEqual(0);
|
|
28
|
+
expect(result.error).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should return error for failed lookups", async () => {
|
|
32
|
+
const collector = new LookupCollector();
|
|
33
|
+
const client = createMockClient({ values: [], error: "NXDOMAIN" });
|
|
34
|
+
|
|
35
|
+
const result = await collector.execute({
|
|
36
|
+
config: { hostname: "nonexistent.invalid", recordType: "A" },
|
|
37
|
+
client,
|
|
38
|
+
pluginId: "test",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result.result.recordCount).toBe(0);
|
|
42
|
+
expect(result.error).toBe("NXDOMAIN");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should pass correct parameters to client", async () => {
|
|
46
|
+
const collector = new LookupCollector();
|
|
47
|
+
const client = createMockClient();
|
|
48
|
+
|
|
49
|
+
await collector.execute({
|
|
50
|
+
config: { hostname: "example.com", recordType: "MX" },
|
|
51
|
+
client,
|
|
52
|
+
pluginId: "test",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(client.exec).toHaveBeenCalledWith({
|
|
56
|
+
hostname: "example.com",
|
|
57
|
+
recordType: "MX",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("aggregateResult", () => {
|
|
63
|
+
it("should calculate average resolution time", () => {
|
|
64
|
+
const collector = new LookupCollector();
|
|
65
|
+
const runs = [
|
|
66
|
+
{
|
|
67
|
+
id: "1",
|
|
68
|
+
status: "healthy" as const,
|
|
69
|
+
latencyMs: 10,
|
|
70
|
+
checkId: "c1",
|
|
71
|
+
timestamp: new Date(),
|
|
72
|
+
metadata: {
|
|
73
|
+
values: ["1.1.1.1"],
|
|
74
|
+
recordCount: 1,
|
|
75
|
+
resolutionTimeMs: 50,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "2",
|
|
80
|
+
status: "healthy" as const,
|
|
81
|
+
latencyMs: 15,
|
|
82
|
+
checkId: "c1",
|
|
83
|
+
timestamp: new Date(),
|
|
84
|
+
metadata: {
|
|
85
|
+
values: ["1.1.1.1"],
|
|
86
|
+
recordCount: 1,
|
|
87
|
+
resolutionTimeMs: 100,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const aggregated = collector.aggregateResult(runs);
|
|
93
|
+
|
|
94
|
+
expect(aggregated.avgResolutionTimeMs).toBe(75);
|
|
95
|
+
expect(aggregated.successRate).toBe(100);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should calculate success rate correctly", () => {
|
|
99
|
+
const collector = new LookupCollector();
|
|
100
|
+
const runs = [
|
|
101
|
+
{
|
|
102
|
+
id: "1",
|
|
103
|
+
status: "healthy" as const,
|
|
104
|
+
latencyMs: 10,
|
|
105
|
+
checkId: "c1",
|
|
106
|
+
timestamp: new Date(),
|
|
107
|
+
metadata: {
|
|
108
|
+
values: ["1.1.1.1"],
|
|
109
|
+
recordCount: 1,
|
|
110
|
+
resolutionTimeMs: 50,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "2",
|
|
115
|
+
status: "unhealthy" as const,
|
|
116
|
+
latencyMs: 15,
|
|
117
|
+
checkId: "c1",
|
|
118
|
+
timestamp: new Date(),
|
|
119
|
+
metadata: { values: [], recordCount: 0, resolutionTimeMs: 100 },
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
const aggregated = collector.aggregateResult(runs);
|
|
124
|
+
|
|
125
|
+
expect(aggregated.successRate).toBe(50);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("metadata", () => {
|
|
130
|
+
it("should have correct static properties", () => {
|
|
131
|
+
const collector = new LookupCollector();
|
|
132
|
+
|
|
133
|
+
expect(collector.id).toBe("lookup");
|
|
134
|
+
expect(collector.displayName).toBe("DNS Lookup");
|
|
135
|
+
expect(collector.allowMultiple).toBe(true);
|
|
136
|
+
expect(collector.supportedPlugins).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Versioned,
|
|
3
|
+
z,
|
|
4
|
+
type HealthCheckRunForAggregation,
|
|
5
|
+
type CollectorResult,
|
|
6
|
+
type CollectorStrategy,
|
|
7
|
+
} from "@checkstack/backend-api";
|
|
8
|
+
import { healthResultNumber } from "@checkstack/healthcheck-common";
|
|
9
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
10
|
+
import type { DnsTransportClient } from "./transport-client";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// CONFIGURATION SCHEMA
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
const lookupConfigSchema = z.object({
|
|
17
|
+
hostname: z.string().min(1).describe("Hostname to resolve"),
|
|
18
|
+
recordType: z
|
|
19
|
+
.enum(["A", "AAAA", "CNAME", "MX", "TXT", "NS"])
|
|
20
|
+
.default("A")
|
|
21
|
+
.describe("DNS record type"),
|
|
22
|
+
nameserver: z.string().optional().describe("Custom nameserver (optional)"),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type LookupConfig = z.infer<typeof lookupConfigSchema>;
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// RESULT SCHEMAS
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
const lookupResultSchema = z.object({
|
|
32
|
+
values: z.array(z.string()).meta({
|
|
33
|
+
"x-chart-type": "text",
|
|
34
|
+
"x-chart-label": "Resolved Values",
|
|
35
|
+
}),
|
|
36
|
+
recordCount: healthResultNumber({
|
|
37
|
+
"x-chart-type": "counter",
|
|
38
|
+
"x-chart-label": "Record Count",
|
|
39
|
+
}),
|
|
40
|
+
resolutionTimeMs: healthResultNumber({
|
|
41
|
+
"x-chart-type": "line",
|
|
42
|
+
"x-chart-label": "Resolution Time",
|
|
43
|
+
"x-chart-unit": "ms",
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type LookupResult = z.infer<typeof lookupResultSchema>;
|
|
48
|
+
|
|
49
|
+
const lookupAggregatedSchema = z.object({
|
|
50
|
+
avgResolutionTimeMs: healthResultNumber({
|
|
51
|
+
"x-chart-type": "line",
|
|
52
|
+
"x-chart-label": "Avg Resolution Time",
|
|
53
|
+
"x-chart-unit": "ms",
|
|
54
|
+
}),
|
|
55
|
+
successRate: healthResultNumber({
|
|
56
|
+
"x-chart-type": "gauge",
|
|
57
|
+
"x-chart-label": "Success Rate",
|
|
58
|
+
"x-chart-unit": "%",
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export type LookupAggregatedResult = z.infer<typeof lookupAggregatedSchema>;
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// LOOKUP COLLECTOR
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Built-in DNS lookup collector.
|
|
70
|
+
* Resolves DNS records and checks results.
|
|
71
|
+
*/
|
|
72
|
+
export class LookupCollector
|
|
73
|
+
implements
|
|
74
|
+
CollectorStrategy<
|
|
75
|
+
DnsTransportClient,
|
|
76
|
+
LookupConfig,
|
|
77
|
+
LookupResult,
|
|
78
|
+
LookupAggregatedResult
|
|
79
|
+
>
|
|
80
|
+
{
|
|
81
|
+
id = "lookup";
|
|
82
|
+
displayName = "DNS Lookup";
|
|
83
|
+
description = "Resolve DNS records and check the results";
|
|
84
|
+
|
|
85
|
+
supportedPlugins = [pluginMetadata];
|
|
86
|
+
|
|
87
|
+
allowMultiple = true;
|
|
88
|
+
|
|
89
|
+
config = new Versioned({ version: 1, schema: lookupConfigSchema });
|
|
90
|
+
result = new Versioned({ version: 1, schema: lookupResultSchema });
|
|
91
|
+
aggregatedResult = new Versioned({
|
|
92
|
+
version: 1,
|
|
93
|
+
schema: lookupAggregatedSchema,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
async execute({
|
|
97
|
+
config,
|
|
98
|
+
client,
|
|
99
|
+
}: {
|
|
100
|
+
config: LookupConfig;
|
|
101
|
+
client: DnsTransportClient;
|
|
102
|
+
pluginId: string;
|
|
103
|
+
}): Promise<CollectorResult<LookupResult>> {
|
|
104
|
+
const startTime = Date.now();
|
|
105
|
+
|
|
106
|
+
const response = await client.exec({
|
|
107
|
+
hostname: config.hostname,
|
|
108
|
+
recordType: config.recordType,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const resolutionTimeMs = Date.now() - startTime;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
result: {
|
|
115
|
+
values: response.values,
|
|
116
|
+
recordCount: response.values.length,
|
|
117
|
+
resolutionTimeMs,
|
|
118
|
+
},
|
|
119
|
+
error: response.error,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
aggregateResult(
|
|
124
|
+
runs: HealthCheckRunForAggregation<LookupResult>[]
|
|
125
|
+
): LookupAggregatedResult {
|
|
126
|
+
const times = runs
|
|
127
|
+
.map((r) => r.metadata?.resolutionTimeMs)
|
|
128
|
+
.filter((v): v is number => typeof v === "number");
|
|
129
|
+
|
|
130
|
+
const recordCounts = runs
|
|
131
|
+
.map((r) => r.metadata?.recordCount)
|
|
132
|
+
.filter((v): v is number => typeof v === "number");
|
|
133
|
+
|
|
134
|
+
const successCount = recordCounts.filter((c) => c > 0).length;
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
avgResolutionTimeMs:
|
|
138
|
+
times.length > 0
|
|
139
|
+
? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
|
|
140
|
+
: 0,
|
|
141
|
+
successRate:
|
|
142
|
+
recordCounts.length > 0
|
|
143
|
+
? Math.round((successCount / recordCounts.length) * 100)
|
|
144
|
+
: 0,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/strategy.test.ts
CHANGED
|
@@ -53,102 +53,83 @@ describe("DnsHealthCheckStrategy", () => {
|
|
|
53
53
|
} as DnsResolver);
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
describe("
|
|
57
|
-
it("should return
|
|
58
|
-
const strategy = new DnsHealthCheckStrategy(
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
describe("createClient", () => {
|
|
57
|
+
it("should return a connected client", async () => {
|
|
58
|
+
const strategy = new DnsHealthCheckStrategy(createMockResolver());
|
|
59
|
+
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
60
|
+
|
|
61
|
+
expect(connectedClient.client).toBeDefined();
|
|
62
|
+
expect(connectedClient.client.exec).toBeDefined();
|
|
63
|
+
expect(connectedClient.close).toBeDefined();
|
|
64
|
+
});
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
timeout: 5000,
|
|
66
|
-
});
|
|
66
|
+
it("should allow closing the client", async () => {
|
|
67
|
+
const strategy = new DnsHealthCheckStrategy(createMockResolver());
|
|
68
|
+
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
67
69
|
|
|
68
|
-
expect(
|
|
69
|
-
expect(result.metadata?.resolvedValues).toEqual(["1.2.3.4", "5.6.7.8"]);
|
|
70
|
-
expect(result.metadata?.recordCount).toBe(2);
|
|
70
|
+
expect(() => connectedClient.close()).not.toThrow();
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
it("should
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
it("should use custom nameserver when provided", async () => {
|
|
74
|
+
const setServersMock = mock(() => {});
|
|
75
|
+
const strategy = new DnsHealthCheckStrategy(() => ({
|
|
76
|
+
setServers: setServersMock,
|
|
77
|
+
resolve4: mock(() => Promise.resolve(["1.2.3.4"])),
|
|
78
|
+
resolve6: mock(() => Promise.resolve([])),
|
|
79
|
+
resolveCname: mock(() => Promise.resolve([])),
|
|
80
|
+
resolveMx: mock(() => Promise.resolve([])),
|
|
81
|
+
resolveTxt: mock(() => Promise.resolve([])),
|
|
82
|
+
resolveNs: mock(() => Promise.resolve([])),
|
|
83
|
+
}));
|
|
77
84
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
recordType: "A",
|
|
85
|
+
const connectedClient = await strategy.createClient({
|
|
86
|
+
nameserver: "8.8.8.8",
|
|
81
87
|
timeout: 5000,
|
|
82
88
|
});
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
|
|
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({
|
|
90
|
+
// Execute to trigger resolver setup
|
|
91
|
+
await connectedClient.client.exec({
|
|
95
92
|
hostname: "example.com",
|
|
96
93
|
recordType: "A",
|
|
97
|
-
timeout: 5000,
|
|
98
|
-
assertions: [{ field: "recordExists", operator: "isTrue" }],
|
|
99
94
|
});
|
|
100
95
|
|
|
101
|
-
expect(
|
|
96
|
+
expect(setServersMock).toHaveBeenCalledWith(["8.8.8.8"]);
|
|
97
|
+
|
|
98
|
+
connectedClient.close();
|
|
102
99
|
});
|
|
100
|
+
});
|
|
103
101
|
|
|
104
|
-
|
|
102
|
+
describe("client.exec", () => {
|
|
103
|
+
it("should return resolved values for successful A record resolution", async () => {
|
|
105
104
|
const strategy = new DnsHealthCheckStrategy(
|
|
106
|
-
createMockResolver({ resolve4: [] })
|
|
105
|
+
createMockResolver({ resolve4: ["1.2.3.4", "5.6.7.8"] })
|
|
107
106
|
);
|
|
107
|
+
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
108
108
|
|
|
109
|
-
const result = await
|
|
109
|
+
const result = await connectedClient.client.exec({
|
|
110
110
|
hostname: "example.com",
|
|
111
111
|
recordType: "A",
|
|
112
|
-
timeout: 5000,
|
|
113
|
-
assertions: [{ field: "recordExists", operator: "isTrue" }],
|
|
114
112
|
});
|
|
115
113
|
|
|
116
|
-
expect(result.
|
|
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
|
-
});
|
|
114
|
+
expect(result.values).toEqual(["1.2.3.4", "5.6.7.8"]);
|
|
133
115
|
|
|
134
|
-
|
|
116
|
+
connectedClient.close();
|
|
135
117
|
});
|
|
136
118
|
|
|
137
|
-
it("should
|
|
119
|
+
it("should return error for DNS error", async () => {
|
|
138
120
|
const strategy = new DnsHealthCheckStrategy(
|
|
139
|
-
createMockResolver({ resolve4:
|
|
121
|
+
createMockResolver({ resolve4: new Error("NXDOMAIN") })
|
|
140
122
|
);
|
|
123
|
+
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
141
124
|
|
|
142
|
-
const result = await
|
|
143
|
-
hostname: "example.com",
|
|
125
|
+
const result = await connectedClient.client.exec({
|
|
126
|
+
hostname: "nonexistent.example.com",
|
|
144
127
|
recordType: "A",
|
|
145
|
-
timeout: 5000,
|
|
146
|
-
assertions: [
|
|
147
|
-
{ field: "recordCount", operator: "greaterThanOrEqual", value: 2 },
|
|
148
|
-
],
|
|
149
128
|
});
|
|
150
129
|
|
|
151
|
-
expect(result.
|
|
130
|
+
expect(result.error).toContain("NXDOMAIN");
|
|
131
|
+
|
|
132
|
+
connectedClient.close();
|
|
152
133
|
});
|
|
153
134
|
|
|
154
135
|
it("should resolve MX records correctly", async () => {
|
|
@@ -160,37 +141,16 @@ describe("DnsHealthCheckStrategy", () => {
|
|
|
160
141
|
],
|
|
161
142
|
})
|
|
162
143
|
);
|
|
144
|
+
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
163
145
|
|
|
164
|
-
const result = await
|
|
146
|
+
const result = await connectedClient.client.exec({
|
|
165
147
|
hostname: "example.com",
|
|
166
148
|
recordType: "MX",
|
|
167
|
-
timeout: 5000,
|
|
168
149
|
});
|
|
169
150
|
|
|
170
|
-
expect(result.
|
|
171
|
-
expect(result.metadata?.resolvedValues).toContain("0 mail1.example.com");
|
|
172
|
-
});
|
|
151
|
+
expect(result.values).toContain("0 mail1.example.com");
|
|
173
152
|
|
|
174
|
-
|
|
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"]);
|
|
153
|
+
connectedClient.close();
|
|
194
154
|
});
|
|
195
155
|
});
|
|
196
156
|
|
|
@@ -264,7 +224,7 @@ describe("DnsHealthCheckStrategy", () => {
|
|
|
264
224
|
const aggregated = strategy.aggregateResult(runs);
|
|
265
225
|
|
|
266
226
|
expect(aggregated.errorCount).toBe(1);
|
|
267
|
-
expect(aggregated.failureCount).toBe(
|
|
227
|
+
expect(aggregated.failureCount).toBe(2);
|
|
268
228
|
});
|
|
269
229
|
});
|
|
270
230
|
});
|
package/src/strategy.ts
CHANGED
|
@@ -1,63 +1,48 @@
|
|
|
1
1
|
import * as dns from "node:dns/promises";
|
|
2
2
|
import {
|
|
3
3
|
HealthCheckStrategy,
|
|
4
|
-
HealthCheckResult,
|
|
5
4
|
HealthCheckRunForAggregation,
|
|
6
5
|
Versioned,
|
|
7
6
|
z,
|
|
8
|
-
|
|
9
|
-
stringField,
|
|
10
|
-
numericField,
|
|
11
|
-
timeThresholdField,
|
|
12
|
-
evaluateAssertions,
|
|
7
|
+
type ConnectedClient,
|
|
13
8
|
} from "@checkstack/backend-api";
|
|
14
9
|
import {
|
|
15
10
|
healthResultNumber,
|
|
16
11
|
healthResultString,
|
|
17
12
|
} from "@checkstack/healthcheck-common";
|
|
13
|
+
import type {
|
|
14
|
+
DnsTransportClient,
|
|
15
|
+
DnsLookupRequest,
|
|
16
|
+
DnsLookupResult,
|
|
17
|
+
} from "./transport-client";
|
|
18
18
|
|
|
19
19
|
// ============================================================================
|
|
20
20
|
// SCHEMAS
|
|
21
21
|
// ============================================================================
|
|
22
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
23
|
/**
|
|
36
24
|
* Configuration schema for DNS health checks.
|
|
25
|
+
* Resolver configuration only - action params moved to LookupCollector.
|
|
37
26
|
*/
|
|
38
27
|
export const dnsConfigSchema = z.object({
|
|
39
|
-
|
|
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)"),
|
|
28
|
+
nameserver: z.string().optional().describe("Custom nameserver (optional)"),
|
|
48
29
|
timeout: z
|
|
49
30
|
.number()
|
|
50
31
|
.min(100)
|
|
51
32
|
.default(5000)
|
|
52
33
|
.describe("Timeout in milliseconds"),
|
|
53
|
-
assertions: z
|
|
54
|
-
.array(dnsAssertionSchema)
|
|
55
|
-
.optional()
|
|
56
|
-
.describe("Conditions for validation"),
|
|
57
34
|
});
|
|
58
35
|
|
|
59
36
|
export type DnsConfig = z.infer<typeof dnsConfigSchema>;
|
|
60
37
|
|
|
38
|
+
// Legacy config type for migrations
|
|
39
|
+
interface DnsConfigV1 {
|
|
40
|
+
hostname: string;
|
|
41
|
+
recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS";
|
|
42
|
+
nameserver?: string;
|
|
43
|
+
timeout: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
61
46
|
/**
|
|
62
47
|
* Per-run result metadata.
|
|
63
48
|
*/
|
|
@@ -70,23 +55,18 @@ const dnsResultSchema = z.object({
|
|
|
70
55
|
"x-chart-type": "counter",
|
|
71
56
|
"x-chart-label": "Record Count",
|
|
72
57
|
}),
|
|
73
|
-
nameserver: healthResultString({
|
|
74
|
-
"x-chart-type": "text",
|
|
75
|
-
"x-chart-label": "Nameserver",
|
|
76
|
-
}).optional(),
|
|
77
58
|
resolutionTimeMs: healthResultNumber({
|
|
78
59
|
"x-chart-type": "line",
|
|
79
60
|
"x-chart-label": "Resolution Time",
|
|
80
61
|
"x-chart-unit": "ms",
|
|
81
62
|
}),
|
|
82
|
-
failedAssertion: dnsAssertionSchema.optional(),
|
|
83
63
|
error: healthResultString({
|
|
84
64
|
"x-chart-type": "status",
|
|
85
65
|
"x-chart-label": "Error",
|
|
86
66
|
}).optional(),
|
|
87
67
|
});
|
|
88
68
|
|
|
89
|
-
|
|
69
|
+
type DnsResult = z.infer<typeof dnsResultSchema>;
|
|
90
70
|
|
|
91
71
|
/**
|
|
92
72
|
* Aggregated metadata for buckets.
|
|
@@ -107,7 +87,7 @@ const dnsAggregatedSchema = z.object({
|
|
|
107
87
|
}),
|
|
108
88
|
});
|
|
109
89
|
|
|
110
|
-
|
|
90
|
+
type DnsAggregatedResult = z.infer<typeof dnsAggregatedSchema>;
|
|
111
91
|
|
|
112
92
|
// ============================================================================
|
|
113
93
|
// RESOLVER INTERFACE (for testability)
|
|
@@ -128,21 +108,25 @@ export interface DnsResolver {
|
|
|
128
108
|
export type ResolverFactory = () => DnsResolver;
|
|
129
109
|
|
|
130
110
|
// Default factory using Node.js dns module
|
|
131
|
-
const defaultResolverFactory: ResolverFactory = () =>
|
|
132
|
-
new dns.Resolver() as DnsResolver;
|
|
111
|
+
const defaultResolverFactory: ResolverFactory = () => new dns.Resolver();
|
|
133
112
|
|
|
134
113
|
// ============================================================================
|
|
135
114
|
// STRATEGY
|
|
136
115
|
// ============================================================================
|
|
137
116
|
|
|
138
117
|
export class DnsHealthCheckStrategy
|
|
139
|
-
implements
|
|
118
|
+
implements
|
|
119
|
+
HealthCheckStrategy<
|
|
120
|
+
DnsConfig,
|
|
121
|
+
DnsTransportClient,
|
|
122
|
+
DnsResult,
|
|
123
|
+
DnsAggregatedResult
|
|
124
|
+
>
|
|
140
125
|
{
|
|
141
126
|
id = "dns";
|
|
142
127
|
displayName = "DNS Health Check";
|
|
143
128
|
description = "DNS record resolution with response validation";
|
|
144
129
|
|
|
145
|
-
// Injected resolver factory for testing
|
|
146
130
|
private resolverFactory: ResolverFactory;
|
|
147
131
|
|
|
148
132
|
constructor(resolverFactory: ResolverFactory = defaultResolverFactory) {
|
|
@@ -150,13 +134,32 @@ export class DnsHealthCheckStrategy
|
|
|
150
134
|
}
|
|
151
135
|
|
|
152
136
|
config: Versioned<DnsConfig> = new Versioned({
|
|
153
|
-
version:
|
|
137
|
+
version: 2,
|
|
154
138
|
schema: dnsConfigSchema,
|
|
139
|
+
migrations: [
|
|
140
|
+
{
|
|
141
|
+
fromVersion: 1,
|
|
142
|
+
toVersion: 2,
|
|
143
|
+
description: "Remove hostname/recordType (moved to LookupCollector)",
|
|
144
|
+
migrate: (data: DnsConfigV1): DnsConfig => ({
|
|
145
|
+
nameserver: data.nameserver,
|
|
146
|
+
timeout: data.timeout,
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
],
|
|
155
150
|
});
|
|
156
151
|
|
|
157
152
|
result: Versioned<DnsResult> = new Versioned({
|
|
158
|
-
version:
|
|
153
|
+
version: 2,
|
|
159
154
|
schema: dnsResultSchema,
|
|
155
|
+
migrations: [
|
|
156
|
+
{
|
|
157
|
+
fromVersion: 1,
|
|
158
|
+
toVersion: 2,
|
|
159
|
+
description: "Migrate to createClient pattern (no result changes)",
|
|
160
|
+
migrate: (data: unknown) => data,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
160
163
|
});
|
|
161
164
|
|
|
162
165
|
aggregatedResult: Versioned<DnsAggregatedResult> = new Versioned({
|
|
@@ -167,148 +170,103 @@ export class DnsHealthCheckStrategy
|
|
|
167
170
|
aggregateResult(
|
|
168
171
|
runs: HealthCheckRunForAggregation<DnsResult>[]
|
|
169
172
|
): DnsAggregatedResult {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
}
|
|
173
|
+
const validRuns = runs.filter((r) => r.metadata);
|
|
174
|
+
|
|
175
|
+
if (validRuns.length === 0) {
|
|
176
|
+
return { avgResolutionTime: 0, failureCount: 0, errorCount: 0 };
|
|
187
177
|
}
|
|
188
178
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
179
|
+
const resolutionTimes = validRuns
|
|
180
|
+
.map((r) => r.metadata?.resolutionTimeMs)
|
|
181
|
+
.filter((t): t is number => typeof t === "number");
|
|
182
|
+
|
|
183
|
+
const avgResolutionTime =
|
|
184
|
+
resolutionTimes.length > 0
|
|
185
|
+
? Math.round(
|
|
186
|
+
resolutionTimes.reduce((a, b) => a + b, 0) / resolutionTimes.length
|
|
187
|
+
)
|
|
188
|
+
: 0;
|
|
189
|
+
|
|
190
|
+
const failureCount = validRuns.filter(
|
|
191
|
+
(r) => r.metadata?.recordCount === 0
|
|
192
|
+
).length;
|
|
193
|
+
|
|
194
|
+
const errorCount = validRuns.filter(
|
|
195
|
+
(r) => r.metadata?.error !== undefined
|
|
196
|
+
).length;
|
|
197
|
+
|
|
198
|
+
return { avgResolutionTime, failureCount, errorCount };
|
|
194
199
|
}
|
|
195
200
|
|
|
196
|
-
async
|
|
201
|
+
async createClient(
|
|
202
|
+
config: DnsConfig
|
|
203
|
+
): Promise<ConnectedClient<DnsTransportClient>> {
|
|
197
204
|
const validatedConfig = this.config.validate(config);
|
|
198
|
-
const
|
|
205
|
+
const resolver = this.resolverFactory();
|
|
199
206
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (validatedConfig.nameserver) {
|
|
204
|
-
resolver.setServers([validatedConfig.nameserver]);
|
|
205
|
-
}
|
|
207
|
+
if (validatedConfig.nameserver) {
|
|
208
|
+
resolver.setServers([validatedConfig.nameserver]);
|
|
209
|
+
}
|
|
206
210
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
}
|
|
211
|
+
const client: DnsTransportClient = {
|
|
212
|
+
exec: async (request: DnsLookupRequest): Promise<DnsLookupResult> => {
|
|
213
|
+
const timeout = validatedConfig.timeout;
|
|
214
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
215
|
+
setTimeout(() => reject(new Error("DNS resolution timeout")), timeout)
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const resolvePromise = this.resolveRecords(
|
|
220
|
+
resolver,
|
|
221
|
+
request.hostname,
|
|
222
|
+
request.recordType
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const values = await Promise.race([resolvePromise, timeoutPromise]);
|
|
226
|
+
return { values };
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return {
|
|
229
|
+
values: [],
|
|
230
|
+
error: error instanceof Error ? error.message : String(error),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
};
|
|
243
235
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
}
|
|
236
|
+
return {
|
|
237
|
+
client,
|
|
238
|
+
close: () => {
|
|
239
|
+
// DNS resolver is stateless, nothing to close
|
|
240
|
+
},
|
|
241
|
+
};
|
|
270
242
|
}
|
|
271
243
|
|
|
272
|
-
/**
|
|
273
|
-
* Resolve DNS records based on type.
|
|
274
|
-
*/
|
|
275
244
|
private async resolveRecords(
|
|
276
245
|
resolver: DnsResolver,
|
|
277
246
|
hostname: string,
|
|
278
|
-
recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS"
|
|
279
|
-
timeout: number
|
|
247
|
+
recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS"
|
|
280
248
|
): Promise<string[]> {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
}
|
|
249
|
+
switch (recordType) {
|
|
250
|
+
case "A": {
|
|
251
|
+
return resolver.resolve4(hostname);
|
|
309
252
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
253
|
+
case "AAAA": {
|
|
254
|
+
return resolver.resolve6(hostname);
|
|
255
|
+
}
|
|
256
|
+
case "CNAME": {
|
|
257
|
+
return resolver.resolveCname(hostname);
|
|
258
|
+
}
|
|
259
|
+
case "MX": {
|
|
260
|
+
const records = await resolver.resolveMx(hostname);
|
|
261
|
+
return records.map((r) => `${r.priority} ${r.exchange}`);
|
|
262
|
+
}
|
|
263
|
+
case "TXT": {
|
|
264
|
+
const records = await resolver.resolveTxt(hostname);
|
|
265
|
+
return records.map((r) => r.join(""));
|
|
266
|
+
}
|
|
267
|
+
case "NS": {
|
|
268
|
+
return resolver.resolveNs(hostname);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
313
271
|
}
|
|
314
272
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TransportClient } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DNS lookup request.
|
|
5
|
+
*/
|
|
6
|
+
export interface DnsLookupRequest {
|
|
7
|
+
hostname: string;
|
|
8
|
+
recordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DNS lookup result.
|
|
13
|
+
*/
|
|
14
|
+
export interface DnsLookupResult {
|
|
15
|
+
values: string[];
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* DNS transport client for record lookups.
|
|
21
|
+
*/
|
|
22
|
+
export type DnsTransportClient = TransportClient<
|
|
23
|
+
DnsLookupRequest,
|
|
24
|
+
DnsLookupResult
|
|
25
|
+
>;
|