@checkstack/healthcheck-tls-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 +308 -0
- package/src/strategy.ts +392 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @checkstack/healthcheck-tls-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-tls-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 { TlsHealthCheckStrategy } 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 TLS/SSL Health Check Strategy...");
|
|
18
|
+
const strategy = new TlsHealthCheckStrategy();
|
|
19
|
+
healthCheckRegistry.register(strategy);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
TlsHealthCheckStrategy,
|
|
4
|
+
TlsClient,
|
|
5
|
+
TlsConnection,
|
|
6
|
+
CertificateInfo,
|
|
7
|
+
} from "./strategy";
|
|
8
|
+
|
|
9
|
+
describe("TlsHealthCheckStrategy", () => {
|
|
10
|
+
// Create a valid certificate info (30 days until expiry)
|
|
11
|
+
const createCertInfo = (
|
|
12
|
+
overrides: Partial<{
|
|
13
|
+
subject: string;
|
|
14
|
+
issuer: string;
|
|
15
|
+
issuerOrg: string | undefined;
|
|
16
|
+
validFrom: Date;
|
|
17
|
+
validTo: Date;
|
|
18
|
+
}> = {}
|
|
19
|
+
): CertificateInfo => {
|
|
20
|
+
const validFrom =
|
|
21
|
+
overrides.validFrom ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
22
|
+
const validTo =
|
|
23
|
+
overrides.validTo ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
24
|
+
|
|
25
|
+
// Check if issuerOrg was explicitly set (even to undefined)
|
|
26
|
+
const hasIssuerOrg = "issuerOrg" in overrides;
|
|
27
|
+
const issuerOrg = hasIssuerOrg ? overrides.issuerOrg : "DigiCert Inc";
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
subject: { CN: overrides.subject ?? "example.com" },
|
|
31
|
+
issuer: {
|
|
32
|
+
CN: overrides.issuer ?? "DigiCert",
|
|
33
|
+
O: issuerOrg,
|
|
34
|
+
},
|
|
35
|
+
valid_from: validFrom.toISOString(),
|
|
36
|
+
valid_to: validTo.toISOString(),
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Helper to create mock TLS client
|
|
41
|
+
const createMockClient = (
|
|
42
|
+
config: {
|
|
43
|
+
authorized?: boolean;
|
|
44
|
+
cert?: CertificateInfo;
|
|
45
|
+
protocol?: string;
|
|
46
|
+
cipher?: string;
|
|
47
|
+
error?: Error;
|
|
48
|
+
} = {}
|
|
49
|
+
): TlsClient => ({
|
|
50
|
+
connect: mock(() =>
|
|
51
|
+
config.error
|
|
52
|
+
? Promise.reject(config.error)
|
|
53
|
+
: Promise.resolve({
|
|
54
|
+
authorized: config.authorized ?? true,
|
|
55
|
+
getPeerCertificate: () => config.cert ?? createCertInfo(),
|
|
56
|
+
getProtocol: () => config.protocol ?? "TLSv1.3",
|
|
57
|
+
getCipher: () => (config.cipher ? { name: config.cipher } : null),
|
|
58
|
+
end: mock(() => {}),
|
|
59
|
+
} as TlsConnection)
|
|
60
|
+
),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("execute", () => {
|
|
64
|
+
it("should return healthy for valid certificate", async () => {
|
|
65
|
+
const strategy = new TlsHealthCheckStrategy(createMockClient());
|
|
66
|
+
|
|
67
|
+
const result = await strategy.execute({
|
|
68
|
+
host: "example.com",
|
|
69
|
+
port: 443,
|
|
70
|
+
timeout: 5000,
|
|
71
|
+
minDaysUntilExpiry: 7,
|
|
72
|
+
rejectUnauthorized: true,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(result.status).toBe("healthy");
|
|
76
|
+
expect(result.metadata?.isValid).toBe(true);
|
|
77
|
+
expect(result.metadata?.daysUntilExpiry).toBeGreaterThan(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should return unhealthy for unauthorized certificate", async () => {
|
|
81
|
+
const strategy = new TlsHealthCheckStrategy(
|
|
82
|
+
createMockClient({ authorized: false })
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const result = await strategy.execute({
|
|
86
|
+
host: "example.com",
|
|
87
|
+
port: 443,
|
|
88
|
+
timeout: 5000,
|
|
89
|
+
minDaysUntilExpiry: 7,
|
|
90
|
+
rejectUnauthorized: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.status).toBe("unhealthy");
|
|
94
|
+
expect(result.message).toContain("not valid");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should return unhealthy when certificate expires soon", async () => {
|
|
98
|
+
const expiringCert = createCertInfo({
|
|
99
|
+
validTo: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), // 5 days
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const strategy = new TlsHealthCheckStrategy(
|
|
103
|
+
createMockClient({ cert: expiringCert })
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const result = await strategy.execute({
|
|
107
|
+
host: "example.com",
|
|
108
|
+
port: 443,
|
|
109
|
+
timeout: 5000,
|
|
110
|
+
minDaysUntilExpiry: 14,
|
|
111
|
+
rejectUnauthorized: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(result.status).toBe("unhealthy");
|
|
115
|
+
expect(result.message).toContain("expires in");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("should return unhealthy for connection error", async () => {
|
|
119
|
+
const strategy = new TlsHealthCheckStrategy(
|
|
120
|
+
createMockClient({ error: new Error("Connection refused") })
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const result = await strategy.execute({
|
|
124
|
+
host: "example.com",
|
|
125
|
+
port: 443,
|
|
126
|
+
timeout: 5000,
|
|
127
|
+
minDaysUntilExpiry: 7,
|
|
128
|
+
rejectUnauthorized: true,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result.status).toBe("unhealthy");
|
|
132
|
+
expect(result.message).toContain("Connection refused");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should pass daysUntilExpiry assertion", async () => {
|
|
136
|
+
const strategy = new TlsHealthCheckStrategy(createMockClient());
|
|
137
|
+
|
|
138
|
+
const result = await strategy.execute({
|
|
139
|
+
host: "example.com",
|
|
140
|
+
port: 443,
|
|
141
|
+
timeout: 5000,
|
|
142
|
+
minDaysUntilExpiry: 7,
|
|
143
|
+
rejectUnauthorized: true,
|
|
144
|
+
assertions: [
|
|
145
|
+
{
|
|
146
|
+
field: "daysUntilExpiry",
|
|
147
|
+
operator: "greaterThanOrEqual",
|
|
148
|
+
value: 7,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result.status).toBe("healthy");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should pass issuer assertion", async () => {
|
|
157
|
+
const strategy = new TlsHealthCheckStrategy(createMockClient());
|
|
158
|
+
|
|
159
|
+
const result = await strategy.execute({
|
|
160
|
+
host: "example.com",
|
|
161
|
+
port: 443,
|
|
162
|
+
timeout: 5000,
|
|
163
|
+
minDaysUntilExpiry: 7,
|
|
164
|
+
rejectUnauthorized: true,
|
|
165
|
+
assertions: [
|
|
166
|
+
{ field: "issuer", operator: "contains", value: "DigiCert" },
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result.status).toBe("healthy");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should fail isValid assertion when certificate is invalid", async () => {
|
|
174
|
+
const strategy = new TlsHealthCheckStrategy(
|
|
175
|
+
createMockClient({ authorized: false })
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const result = await strategy.execute({
|
|
179
|
+
host: "example.com",
|
|
180
|
+
port: 443,
|
|
181
|
+
timeout: 5000,
|
|
182
|
+
minDaysUntilExpiry: 7,
|
|
183
|
+
rejectUnauthorized: false, // Don't reject to test assertion
|
|
184
|
+
assertions: [{ field: "isValid", operator: "isTrue" }],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(result.status).toBe("unhealthy");
|
|
188
|
+
expect(result.message).toContain("Assertion failed");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should detect self-signed certificates", async () => {
|
|
192
|
+
const selfSignedCert = createCertInfo({
|
|
193
|
+
subject: "localhost",
|
|
194
|
+
issuer: "localhost",
|
|
195
|
+
issuerOrg: undefined,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const strategy = new TlsHealthCheckStrategy(
|
|
199
|
+
createMockClient({ cert: selfSignedCert, authorized: false })
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const result = await strategy.execute({
|
|
203
|
+
host: "localhost",
|
|
204
|
+
port: 443,
|
|
205
|
+
timeout: 5000,
|
|
206
|
+
minDaysUntilExpiry: 0,
|
|
207
|
+
rejectUnauthorized: false,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(result.metadata?.isSelfSigned).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("aggregateResult", () => {
|
|
215
|
+
it("should calculate averages correctly", () => {
|
|
216
|
+
const strategy = new TlsHealthCheckStrategy();
|
|
217
|
+
const runs = [
|
|
218
|
+
{
|
|
219
|
+
id: "1",
|
|
220
|
+
status: "healthy" as const,
|
|
221
|
+
latencyMs: 100,
|
|
222
|
+
checkId: "c1",
|
|
223
|
+
timestamp: new Date(),
|
|
224
|
+
metadata: {
|
|
225
|
+
connected: true,
|
|
226
|
+
isValid: true,
|
|
227
|
+
isSelfSigned: false,
|
|
228
|
+
issuer: "DigiCert",
|
|
229
|
+
subject: "example.com",
|
|
230
|
+
validFrom: "2024-01-01",
|
|
231
|
+
validTo: "2025-01-01",
|
|
232
|
+
daysUntilExpiry: 30,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "2",
|
|
237
|
+
status: "healthy" as const,
|
|
238
|
+
latencyMs: 150,
|
|
239
|
+
checkId: "c1",
|
|
240
|
+
timestamp: new Date(),
|
|
241
|
+
metadata: {
|
|
242
|
+
connected: true,
|
|
243
|
+
isValid: true,
|
|
244
|
+
isSelfSigned: false,
|
|
245
|
+
issuer: "DigiCert",
|
|
246
|
+
subject: "example.com",
|
|
247
|
+
validFrom: "2024-01-01",
|
|
248
|
+
validTo: "2025-01-01",
|
|
249
|
+
daysUntilExpiry: 20,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
255
|
+
|
|
256
|
+
expect(aggregated.avgDaysUntilExpiry).toBe(25);
|
|
257
|
+
expect(aggregated.minDaysUntilExpiry).toBe(20);
|
|
258
|
+
expect(aggregated.invalidCount).toBe(0);
|
|
259
|
+
expect(aggregated.errorCount).toBe(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should count invalid and errors", () => {
|
|
263
|
+
const strategy = new TlsHealthCheckStrategy();
|
|
264
|
+
const runs = [
|
|
265
|
+
{
|
|
266
|
+
id: "1",
|
|
267
|
+
status: "unhealthy" as const,
|
|
268
|
+
latencyMs: 100,
|
|
269
|
+
checkId: "c1",
|
|
270
|
+
timestamp: new Date(),
|
|
271
|
+
metadata: {
|
|
272
|
+
connected: true,
|
|
273
|
+
isValid: false,
|
|
274
|
+
isSelfSigned: false,
|
|
275
|
+
issuer: "",
|
|
276
|
+
subject: "",
|
|
277
|
+
validFrom: "",
|
|
278
|
+
validTo: "",
|
|
279
|
+
daysUntilExpiry: 0,
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: "2",
|
|
284
|
+
status: "unhealthy" as const,
|
|
285
|
+
latencyMs: 0,
|
|
286
|
+
checkId: "c1",
|
|
287
|
+
timestamp: new Date(),
|
|
288
|
+
metadata: {
|
|
289
|
+
connected: false,
|
|
290
|
+
isValid: false,
|
|
291
|
+
isSelfSigned: false,
|
|
292
|
+
issuer: "",
|
|
293
|
+
subject: "",
|
|
294
|
+
validFrom: "",
|
|
295
|
+
validTo: "",
|
|
296
|
+
daysUntilExpiry: 0,
|
|
297
|
+
error: "Connection refused",
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
303
|
+
|
|
304
|
+
expect(aggregated.invalidCount).toBe(1);
|
|
305
|
+
expect(aggregated.errorCount).toBe(1);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
package/src/strategy.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import * as tls from "node:tls";
|
|
2
|
+
import {
|
|
3
|
+
HealthCheckStrategy,
|
|
4
|
+
HealthCheckResult,
|
|
5
|
+
HealthCheckRunForAggregation,
|
|
6
|
+
Versioned,
|
|
7
|
+
z,
|
|
8
|
+
numericField,
|
|
9
|
+
stringField,
|
|
10
|
+
booleanField,
|
|
11
|
+
evaluateAssertions,
|
|
12
|
+
} from "@checkstack/backend-api";
|
|
13
|
+
import {
|
|
14
|
+
healthResultBoolean,
|
|
15
|
+
healthResultNumber,
|
|
16
|
+
healthResultString,
|
|
17
|
+
} from "@checkstack/healthcheck-common";
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// SCHEMAS
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Assertion schema for TLS health checks using shared factories.
|
|
25
|
+
*/
|
|
26
|
+
const tlsAssertionSchema = z.discriminatedUnion("field", [
|
|
27
|
+
numericField("daysUntilExpiry", { min: 0 }),
|
|
28
|
+
stringField("issuer"),
|
|
29
|
+
stringField("subject"),
|
|
30
|
+
booleanField("isValid"),
|
|
31
|
+
booleanField("isSelfSigned"),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export type TlsAssertion = z.infer<typeof tlsAssertionSchema>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Configuration schema for TLS health checks.
|
|
38
|
+
*/
|
|
39
|
+
export const tlsConfigSchema = z.object({
|
|
40
|
+
host: z.string().describe("Hostname to connect to"),
|
|
41
|
+
port: z.number().int().min(1).max(65_535).default(443).describe("TLS port"),
|
|
42
|
+
servername: z
|
|
43
|
+
.string()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("SNI hostname (defaults to host if not specified)"),
|
|
46
|
+
timeout: z
|
|
47
|
+
.number()
|
|
48
|
+
.min(100)
|
|
49
|
+
.default(10_000)
|
|
50
|
+
.describe("Connection timeout in milliseconds"),
|
|
51
|
+
minDaysUntilExpiry: z
|
|
52
|
+
.number()
|
|
53
|
+
.min(0)
|
|
54
|
+
.default(14)
|
|
55
|
+
.describe("Minimum days until certificate expiry for healthy status"),
|
|
56
|
+
rejectUnauthorized: z
|
|
57
|
+
.boolean()
|
|
58
|
+
.default(true)
|
|
59
|
+
.describe("Reject invalid/self-signed certificates"),
|
|
60
|
+
assertions: z
|
|
61
|
+
.array(tlsAssertionSchema)
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("Validation conditions"),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export type TlsConfig = z.infer<typeof tlsConfigSchema>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Per-run result metadata.
|
|
70
|
+
*/
|
|
71
|
+
const tlsResultSchema = z.object({
|
|
72
|
+
connected: healthResultBoolean({
|
|
73
|
+
"x-chart-type": "boolean",
|
|
74
|
+
"x-chart-label": "Connected",
|
|
75
|
+
}),
|
|
76
|
+
isValid: healthResultBoolean({
|
|
77
|
+
"x-chart-type": "boolean",
|
|
78
|
+
"x-chart-label": "Certificate Valid",
|
|
79
|
+
}),
|
|
80
|
+
isSelfSigned: healthResultBoolean({
|
|
81
|
+
"x-chart-type": "boolean",
|
|
82
|
+
"x-chart-label": "Self-Signed",
|
|
83
|
+
}),
|
|
84
|
+
issuer: healthResultString({
|
|
85
|
+
"x-chart-type": "text",
|
|
86
|
+
"x-chart-label": "Issuer",
|
|
87
|
+
}),
|
|
88
|
+
subject: healthResultString({
|
|
89
|
+
"x-chart-type": "text",
|
|
90
|
+
"x-chart-label": "Subject",
|
|
91
|
+
}),
|
|
92
|
+
validFrom: healthResultString({
|
|
93
|
+
"x-chart-type": "text",
|
|
94
|
+
"x-chart-label": "Valid From",
|
|
95
|
+
}),
|
|
96
|
+
validTo: healthResultString({
|
|
97
|
+
"x-chart-type": "text",
|
|
98
|
+
"x-chart-label": "Valid To",
|
|
99
|
+
}),
|
|
100
|
+
daysUntilExpiry: healthResultNumber({
|
|
101
|
+
"x-chart-type": "counter",
|
|
102
|
+
"x-chart-label": "Days Until Expiry",
|
|
103
|
+
"x-chart-unit": "days",
|
|
104
|
+
}),
|
|
105
|
+
protocol: healthResultString({
|
|
106
|
+
"x-chart-type": "text",
|
|
107
|
+
"x-chart-label": "Protocol",
|
|
108
|
+
}).optional(),
|
|
109
|
+
cipher: healthResultString({
|
|
110
|
+
"x-chart-type": "text",
|
|
111
|
+
"x-chart-label": "Cipher",
|
|
112
|
+
}).optional(),
|
|
113
|
+
failedAssertion: tlsAssertionSchema.optional(),
|
|
114
|
+
error: healthResultString({
|
|
115
|
+
"x-chart-type": "status",
|
|
116
|
+
"x-chart-label": "Error",
|
|
117
|
+
}).optional(),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export type TlsResult = z.infer<typeof tlsResultSchema>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Aggregated metadata for buckets.
|
|
124
|
+
*/
|
|
125
|
+
const tlsAggregatedSchema = z.object({
|
|
126
|
+
avgDaysUntilExpiry: healthResultNumber({
|
|
127
|
+
"x-chart-type": "line",
|
|
128
|
+
"x-chart-label": "Avg Days Until Expiry",
|
|
129
|
+
"x-chart-unit": "days",
|
|
130
|
+
}),
|
|
131
|
+
minDaysUntilExpiry: healthResultNumber({
|
|
132
|
+
"x-chart-type": "line",
|
|
133
|
+
"x-chart-label": "Min Days Until Expiry",
|
|
134
|
+
"x-chart-unit": "days",
|
|
135
|
+
}),
|
|
136
|
+
invalidCount: healthResultNumber({
|
|
137
|
+
"x-chart-type": "counter",
|
|
138
|
+
"x-chart-label": "Invalid Certificates",
|
|
139
|
+
}),
|
|
140
|
+
errorCount: healthResultNumber({
|
|
141
|
+
"x-chart-type": "counter",
|
|
142
|
+
"x-chart-label": "Errors",
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
export type TlsAggregatedResult = z.infer<typeof tlsAggregatedSchema>;
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// TLS CLIENT INTERFACE (for testability)
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
export interface CertificateInfo {
|
|
153
|
+
subject: { CN?: string };
|
|
154
|
+
issuer: { CN?: string; O?: string };
|
|
155
|
+
valid_from: string;
|
|
156
|
+
valid_to: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface TlsConnection {
|
|
160
|
+
authorized: boolean;
|
|
161
|
+
getPeerCertificate(): CertificateInfo;
|
|
162
|
+
getProtocol(): string | null;
|
|
163
|
+
getCipher(): { name: string } | null;
|
|
164
|
+
end(): void;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface TlsClient {
|
|
168
|
+
connect(options: {
|
|
169
|
+
host: string;
|
|
170
|
+
port: number;
|
|
171
|
+
servername: string;
|
|
172
|
+
rejectUnauthorized: boolean;
|
|
173
|
+
timeout: number;
|
|
174
|
+
}): Promise<TlsConnection>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Default client using Node.js tls module
|
|
178
|
+
const defaultTlsClient: TlsClient = {
|
|
179
|
+
connect(options): Promise<TlsConnection> {
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const socket = tls.connect(
|
|
182
|
+
{
|
|
183
|
+
host: options.host,
|
|
184
|
+
port: options.port,
|
|
185
|
+
servername: options.servername,
|
|
186
|
+
rejectUnauthorized: options.rejectUnauthorized,
|
|
187
|
+
timeout: options.timeout,
|
|
188
|
+
},
|
|
189
|
+
() => {
|
|
190
|
+
resolve({
|
|
191
|
+
authorized: socket.authorized,
|
|
192
|
+
getPeerCertificate: () =>
|
|
193
|
+
socket.getPeerCertificate() as unknown as CertificateInfo,
|
|
194
|
+
getProtocol: () => socket.getProtocol(),
|
|
195
|
+
getCipher: () => socket.getCipher(),
|
|
196
|
+
end: () => socket.end(),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
socket.on("error", reject);
|
|
202
|
+
socket.on("timeout", () => {
|
|
203
|
+
socket.destroy();
|
|
204
|
+
reject(new Error("Connection timeout"));
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// STRATEGY
|
|
212
|
+
// ============================================================================
|
|
213
|
+
|
|
214
|
+
export class TlsHealthCheckStrategy
|
|
215
|
+
implements HealthCheckStrategy<TlsConfig, TlsResult, TlsAggregatedResult>
|
|
216
|
+
{
|
|
217
|
+
id = "tls";
|
|
218
|
+
displayName = "TLS/SSL Health Check";
|
|
219
|
+
description = "SSL/TLS certificate validation and expiry monitoring";
|
|
220
|
+
|
|
221
|
+
private tlsClient: TlsClient;
|
|
222
|
+
|
|
223
|
+
constructor(tlsClient: TlsClient = defaultTlsClient) {
|
|
224
|
+
this.tlsClient = tlsClient;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
config: Versioned<TlsConfig> = new Versioned({
|
|
228
|
+
version: 1,
|
|
229
|
+
schema: tlsConfigSchema,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
result: Versioned<TlsResult> = new Versioned({
|
|
233
|
+
version: 1,
|
|
234
|
+
schema: tlsResultSchema,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
aggregatedResult: Versioned<TlsAggregatedResult> = new Versioned({
|
|
238
|
+
version: 1,
|
|
239
|
+
schema: tlsAggregatedSchema,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
aggregateResult(
|
|
243
|
+
runs: HealthCheckRunForAggregation<TlsResult>[]
|
|
244
|
+
): TlsAggregatedResult {
|
|
245
|
+
let totalDaysUntilExpiry = 0;
|
|
246
|
+
let minDaysUntilExpiry = Number.POSITIVE_INFINITY;
|
|
247
|
+
let invalidCount = 0;
|
|
248
|
+
let errorCount = 0;
|
|
249
|
+
let validRuns = 0;
|
|
250
|
+
|
|
251
|
+
for (const run of runs) {
|
|
252
|
+
if (run.metadata?.error) {
|
|
253
|
+
errorCount++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (run.metadata && !run.metadata.isValid) {
|
|
257
|
+
invalidCount++;
|
|
258
|
+
}
|
|
259
|
+
if (run.metadata) {
|
|
260
|
+
totalDaysUntilExpiry += run.metadata.daysUntilExpiry;
|
|
261
|
+
if (run.metadata.daysUntilExpiry < minDaysUntilExpiry) {
|
|
262
|
+
minDaysUntilExpiry = run.metadata.daysUntilExpiry;
|
|
263
|
+
}
|
|
264
|
+
validRuns++;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
avgDaysUntilExpiry: validRuns > 0 ? totalDaysUntilExpiry / validRuns : 0,
|
|
270
|
+
minDaysUntilExpiry:
|
|
271
|
+
minDaysUntilExpiry === Number.POSITIVE_INFINITY
|
|
272
|
+
? 0
|
|
273
|
+
: minDaysUntilExpiry,
|
|
274
|
+
invalidCount,
|
|
275
|
+
errorCount,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async execute(config: TlsConfig): Promise<HealthCheckResult<TlsResult>> {
|
|
280
|
+
const validatedConfig = this.config.validate(config);
|
|
281
|
+
const start = performance.now();
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const connection = await this.tlsClient.connect({
|
|
285
|
+
host: validatedConfig.host,
|
|
286
|
+
port: validatedConfig.port,
|
|
287
|
+
servername: validatedConfig.servername ?? validatedConfig.host,
|
|
288
|
+
rejectUnauthorized: validatedConfig.rejectUnauthorized,
|
|
289
|
+
timeout: validatedConfig.timeout,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const cert = connection.getPeerCertificate();
|
|
293
|
+
const protocol = connection.getProtocol();
|
|
294
|
+
const cipher = connection.getCipher();
|
|
295
|
+
|
|
296
|
+
connection.end();
|
|
297
|
+
|
|
298
|
+
const end = performance.now();
|
|
299
|
+
const latencyMs = Math.round(end - start);
|
|
300
|
+
|
|
301
|
+
// Calculate days until expiry
|
|
302
|
+
const validTo = new Date(cert.valid_to);
|
|
303
|
+
const now = new Date();
|
|
304
|
+
const daysUntilExpiry = Math.floor(
|
|
305
|
+
(validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
// Determine if self-signed
|
|
309
|
+
const isSelfSigned =
|
|
310
|
+
cert.issuer.CN === cert.subject.CN && cert.issuer.O === undefined;
|
|
311
|
+
|
|
312
|
+
const result: Omit<TlsResult, "failedAssertion" | "error"> = {
|
|
313
|
+
connected: true,
|
|
314
|
+
isValid: connection.authorized,
|
|
315
|
+
isSelfSigned,
|
|
316
|
+
issuer: cert.issuer.CN ?? cert.issuer.O ?? "Unknown",
|
|
317
|
+
subject: cert.subject.CN ?? "Unknown",
|
|
318
|
+
validFrom: cert.valid_from,
|
|
319
|
+
validTo: cert.valid_to,
|
|
320
|
+
daysUntilExpiry,
|
|
321
|
+
protocol: protocol ?? undefined,
|
|
322
|
+
cipher: cipher?.name,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Evaluate assertions using shared utility
|
|
326
|
+
const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
|
|
327
|
+
daysUntilExpiry,
|
|
328
|
+
issuer: result.issuer,
|
|
329
|
+
subject: result.subject,
|
|
330
|
+
isValid: result.isValid,
|
|
331
|
+
isSelfSigned,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (failedAssertion) {
|
|
335
|
+
return {
|
|
336
|
+
status: "unhealthy",
|
|
337
|
+
latencyMs,
|
|
338
|
+
message: `Assertion failed: ${failedAssertion.field} ${
|
|
339
|
+
failedAssertion.operator
|
|
340
|
+
}${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
|
|
341
|
+
metadata: { ...result, failedAssertion },
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Check minimum days until expiry
|
|
346
|
+
if (daysUntilExpiry < validatedConfig.minDaysUntilExpiry) {
|
|
347
|
+
return {
|
|
348
|
+
status: "unhealthy",
|
|
349
|
+
latencyMs,
|
|
350
|
+
message: `Certificate expires in ${daysUntilExpiry} days (minimum: ${validatedConfig.minDaysUntilExpiry})`,
|
|
351
|
+
metadata: result,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Check certificate validity
|
|
356
|
+
if (!connection.authorized && validatedConfig.rejectUnauthorized) {
|
|
357
|
+
return {
|
|
358
|
+
status: "unhealthy",
|
|
359
|
+
latencyMs,
|
|
360
|
+
message: "Certificate is not valid or not trusted",
|
|
361
|
+
metadata: result,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
status: "healthy",
|
|
367
|
+
latencyMs,
|
|
368
|
+
message: `Certificate valid for ${daysUntilExpiry} days (${result.subject} issued by ${result.issuer})`,
|
|
369
|
+
metadata: result,
|
|
370
|
+
};
|
|
371
|
+
} catch (error: unknown) {
|
|
372
|
+
const end = performance.now();
|
|
373
|
+
const isError = error instanceof Error;
|
|
374
|
+
return {
|
|
375
|
+
status: "unhealthy",
|
|
376
|
+
latencyMs: Math.round(end - start),
|
|
377
|
+
message: isError ? error.message : "TLS connection failed",
|
|
378
|
+
metadata: {
|
|
379
|
+
connected: false,
|
|
380
|
+
isValid: false,
|
|
381
|
+
isSelfSigned: false,
|
|
382
|
+
issuer: "",
|
|
383
|
+
subject: "",
|
|
384
|
+
validFrom: "",
|
|
385
|
+
validTo: "",
|
|
386
|
+
daysUntilExpiry: 0,
|
|
387
|
+
error: isError ? error.name : "UnknownError",
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|