@checkstack/healthcheck-tls-backend 0.0.3 → 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 CHANGED
@@ -1,5 +1,41 @@
1
1
  # @checkstack/healthcheck-tls-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
+
3
39
  ## 0.0.3
4
40
 
5
41
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-tls-backend",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { CertificateCollector } from "./certificate-collector";
3
+ import type {
4
+ TlsTransportClient,
5
+ TlsCertificateInfo,
6
+ } from "./transport-client";
7
+
8
+ describe("CertificateCollector", () => {
9
+ const createMockClient = (
10
+ response: Partial<TlsCertificateInfo> = {}
11
+ ): TlsTransportClient => ({
12
+ exec: mock(() =>
13
+ Promise.resolve({
14
+ isValid: response.isValid ?? true,
15
+ isSelfSigned: response.isSelfSigned ?? false,
16
+ subject: response.subject ?? "CN=example.com",
17
+ issuer: response.issuer ?? "CN=Let's Encrypt",
18
+ validFrom: response.validFrom ?? "2024-01-01T00:00:00Z",
19
+ validTo: response.validTo ?? "2025-01-01T00:00:00Z",
20
+ daysUntilExpiry: response.daysUntilExpiry ?? 365,
21
+ daysRemaining: response.daysRemaining ?? 365,
22
+ error: response.error,
23
+ })
24
+ ),
25
+ });
26
+
27
+ describe("execute", () => {
28
+ it("should get certificate info successfully", async () => {
29
+ const collector = new CertificateCollector();
30
+ const client = createMockClient({ daysRemaining: 90 });
31
+
32
+ const result = await collector.execute({
33
+ config: {},
34
+ client,
35
+ pluginId: "test",
36
+ });
37
+
38
+ expect(result.result.subject).toBe("CN=example.com");
39
+ expect(result.result.issuer).toBe("CN=Let's Encrypt");
40
+ expect(result.result.daysRemaining).toBe(90);
41
+ expect(result.result.valid).toBe(true);
42
+ expect(result.error).toBeUndefined();
43
+ });
44
+
45
+ it("should return error for failed TLS connection", async () => {
46
+ const collector = new CertificateCollector();
47
+ const client = createMockClient({
48
+ error: "Connection refused",
49
+ isValid: false,
50
+ daysRemaining: 0,
51
+ daysUntilExpiry: 0,
52
+ });
53
+
54
+ const result = await collector.execute({
55
+ config: {},
56
+ client,
57
+ pluginId: "test",
58
+ });
59
+
60
+ expect(result.result.valid).toBe(false);
61
+ expect(result.error).toBe("Connection refused");
62
+ });
63
+
64
+ it("should mark expired certificate as invalid", async () => {
65
+ const collector = new CertificateCollector();
66
+ const client = createMockClient({ daysRemaining: 0 });
67
+
68
+ const result = await collector.execute({
69
+ config: {},
70
+ client,
71
+ pluginId: "test",
72
+ });
73
+
74
+ expect(result.result.valid).toBe(false);
75
+ expect(result.result.daysRemaining).toBe(0);
76
+ });
77
+ });
78
+
79
+ describe("aggregateResult", () => {
80
+ it("should calculate average days remaining", () => {
81
+ const collector = new CertificateCollector();
82
+ const runs = [
83
+ {
84
+ id: "1",
85
+ status: "healthy" as const,
86
+ latencyMs: 100,
87
+ checkId: "c1",
88
+ timestamp: new Date(),
89
+ metadata: {
90
+ subject: "CN=a.com",
91
+ issuer: "CN=CA",
92
+ validFrom: "",
93
+ validTo: "",
94
+ daysRemaining: 30,
95
+ valid: true,
96
+ },
97
+ },
98
+ {
99
+ id: "2",
100
+ status: "healthy" as const,
101
+ latencyMs: 100,
102
+ checkId: "c1",
103
+ timestamp: new Date(),
104
+ metadata: {
105
+ subject: "CN=a.com",
106
+ issuer: "CN=CA",
107
+ validFrom: "",
108
+ validTo: "",
109
+ daysRemaining: 60,
110
+ valid: true,
111
+ },
112
+ },
113
+ ];
114
+
115
+ const aggregated = collector.aggregateResult(runs);
116
+
117
+ expect(aggregated.avgDaysRemaining).toBe(45);
118
+ expect(aggregated.validRate).toBe(100);
119
+ });
120
+
121
+ it("should calculate valid rate correctly", () => {
122
+ const collector = new CertificateCollector();
123
+ const runs = [
124
+ {
125
+ id: "1",
126
+ status: "healthy" as const,
127
+ latencyMs: 100,
128
+ checkId: "c1",
129
+ timestamp: new Date(),
130
+ metadata: {
131
+ subject: "CN=a.com",
132
+ issuer: "CN=CA",
133
+ validFrom: "",
134
+ validTo: "",
135
+ daysRemaining: 30,
136
+ valid: true,
137
+ },
138
+ },
139
+ {
140
+ id: "2",
141
+ status: "unhealthy" as const,
142
+ latencyMs: 100,
143
+ checkId: "c1",
144
+ timestamp: new Date(),
145
+ metadata: {
146
+ subject: "CN=a.com",
147
+ issuer: "CN=CA",
148
+ validFrom: "",
149
+ validTo: "",
150
+ daysRemaining: 0,
151
+ valid: false,
152
+ },
153
+ },
154
+ ];
155
+
156
+ const aggregated = collector.aggregateResult(runs);
157
+
158
+ expect(aggregated.validRate).toBe(50);
159
+ });
160
+ });
161
+
162
+ describe("metadata", () => {
163
+ it("should have correct static properties", () => {
164
+ const collector = new CertificateCollector();
165
+
166
+ expect(collector.id).toBe("certificate");
167
+ expect(collector.displayName).toBe("TLS Certificate");
168
+ expect(collector.allowMultiple).toBe(false);
169
+ expect(collector.supportedPlugins).toHaveLength(1);
170
+ });
171
+ });
172
+ });
@@ -0,0 +1,170 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ type HealthCheckRunForAggregation,
5
+ type CollectorResult,
6
+ type CollectorStrategy,
7
+ } from "@checkstack/backend-api";
8
+ import {
9
+ healthResultNumber,
10
+ healthResultString,
11
+ healthResultBoolean,
12
+ } from "@checkstack/healthcheck-common";
13
+ import { pluginMetadata } from "./plugin-metadata";
14
+ import type { TlsTransportClient } from "./transport-client";
15
+
16
+ // ============================================================================
17
+ // CONFIGURATION SCHEMA
18
+ // ============================================================================
19
+
20
+ const certificateConfigSchema = z.object({
21
+ // No config needed - just returns cert info from connection
22
+ });
23
+
24
+ export type CertificateConfig = z.infer<typeof certificateConfigSchema>;
25
+
26
+ // ============================================================================
27
+ // RESULT SCHEMAS
28
+ // ============================================================================
29
+
30
+ const certificateResultSchema = z.object({
31
+ subject: healthResultString({
32
+ "x-chart-type": "text",
33
+ "x-chart-label": "Subject",
34
+ }),
35
+ issuer: healthResultString({
36
+ "x-chart-type": "text",
37
+ "x-chart-label": "Issuer",
38
+ }),
39
+ validFrom: healthResultString({
40
+ "x-chart-type": "text",
41
+ "x-chart-label": "Valid From",
42
+ }),
43
+ validTo: healthResultString({
44
+ "x-chart-type": "text",
45
+ "x-chart-label": "Valid To",
46
+ }),
47
+ daysRemaining: healthResultNumber({
48
+ "x-chart-type": "gauge",
49
+ "x-chart-label": "Days Remaining",
50
+ "x-chart-unit": "days",
51
+ }),
52
+ valid: healthResultBoolean({
53
+ "x-chart-type": "boolean",
54
+ "x-chart-label": "Valid",
55
+ }),
56
+ });
57
+
58
+ export type CertificateResult = z.infer<typeof certificateResultSchema>;
59
+
60
+ const certificateAggregatedSchema = z.object({
61
+ avgDaysRemaining: healthResultNumber({
62
+ "x-chart-type": "gauge",
63
+ "x-chart-label": "Avg Days Remaining",
64
+ "x-chart-unit": "days",
65
+ }),
66
+ validRate: healthResultNumber({
67
+ "x-chart-type": "gauge",
68
+ "x-chart-label": "Valid Rate",
69
+ "x-chart-unit": "%",
70
+ }),
71
+ });
72
+
73
+ export type CertificateAggregatedResult = z.infer<
74
+ typeof certificateAggregatedSchema
75
+ >;
76
+
77
+ // ============================================================================
78
+ // CERTIFICATE COLLECTOR
79
+ // ============================================================================
80
+
81
+ /**
82
+ * Built-in TLS certificate collector.
83
+ * Returns certificate information from the TLS connection.
84
+ */
85
+ export class CertificateCollector
86
+ implements
87
+ CollectorStrategy<
88
+ TlsTransportClient,
89
+ CertificateConfig,
90
+ CertificateResult,
91
+ CertificateAggregatedResult
92
+ >
93
+ {
94
+ id = "certificate";
95
+ displayName = "TLS Certificate";
96
+ description = "Check TLS certificate validity and expiration";
97
+
98
+ supportedPlugins = [pluginMetadata];
99
+
100
+ allowMultiple = false;
101
+
102
+ config = new Versioned({ version: 1, schema: certificateConfigSchema });
103
+ result = new Versioned({ version: 1, schema: certificateResultSchema });
104
+ aggregatedResult = new Versioned({
105
+ version: 1,
106
+ schema: certificateAggregatedSchema,
107
+ });
108
+
109
+ async execute({
110
+ client,
111
+ }: {
112
+ config: CertificateConfig;
113
+ client: TlsTransportClient;
114
+ pluginId: string;
115
+ }): Promise<CollectorResult<CertificateResult>> {
116
+ const response = await client.exec({ action: "inspect" });
117
+
118
+ if (response.error) {
119
+ return {
120
+ result: {
121
+ subject: "",
122
+ issuer: "",
123
+ validFrom: "",
124
+ validTo: "",
125
+ daysRemaining: 0,
126
+ valid: false,
127
+ },
128
+ error: response.error,
129
+ };
130
+ }
131
+
132
+ return {
133
+ result: {
134
+ subject: response.subject ?? "",
135
+ issuer: response.issuer ?? "",
136
+ validFrom: response.validFrom ?? "",
137
+ validTo: response.validTo ?? "",
138
+ daysRemaining: response.daysRemaining ?? 0,
139
+ valid: (response.daysRemaining ?? 0) > 0,
140
+ },
141
+ };
142
+ }
143
+
144
+ aggregateResult(
145
+ runs: HealthCheckRunForAggregation<CertificateResult>[]
146
+ ): CertificateAggregatedResult {
147
+ const daysRemaining = runs
148
+ .map((r) => r.metadata?.daysRemaining)
149
+ .filter((v): v is number => typeof v === "number");
150
+
151
+ const validResults = runs
152
+ .map((r) => r.metadata?.valid)
153
+ .filter((v): v is boolean => typeof v === "boolean");
154
+
155
+ const validCount = validResults.filter(Boolean).length;
156
+
157
+ return {
158
+ avgDaysRemaining:
159
+ daysRemaining.length > 0
160
+ ? Math.round(
161
+ daysRemaining.reduce((a, b) => a + b, 0) / daysRemaining.length
162
+ )
163
+ : 0,
164
+ validRate:
165
+ validResults.length > 0
166
+ ? Math.round((validCount / validResults.length) * 100)
167
+ : 0,
168
+ };
169
+ }
170
+ }
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 { TlsHealthCheckStrategy } from "./strategy";
6
3
  import { pluginMetadata } from "./plugin-metadata";
4
+ import { CertificateCollector } from "./certificate-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 TLS/SSL Health Check Strategy...");
18
17
  const strategy = new TlsHealthCheckStrategy();
19
18
  healthCheckRegistry.register(strategy);
19
+ collectorRegistry.register(new CertificateCollector());
20
20
  },
21
21
  });
22
22
  },
23
23
  });
24
+
25
+ export { pluginMetadata } from "./plugin-metadata";
@@ -60,11 +60,11 @@ describe("TlsHealthCheckStrategy", () => {
60
60
  ),
61
61
  });
62
62
 
63
- describe("execute", () => {
64
- it("should return healthy for valid certificate", async () => {
63
+ describe("createClient", () => {
64
+ it("should return a connected client", async () => {
65
65
  const strategy = new TlsHealthCheckStrategy(createMockClient());
66
66
 
67
- const result = await strategy.execute({
67
+ const connectedClient = await strategy.createClient({
68
68
  host: "example.com",
69
69
  port: 443,
70
70
  timeout: 5000,
@@ -72,120 +72,69 @@ describe("TlsHealthCheckStrategy", () => {
72
72
  rejectUnauthorized: true,
73
73
  });
74
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
- });
75
+ expect(connectedClient.client).toBeDefined();
76
+ expect(connectedClient.client.exec).toBeDefined();
77
+ expect(connectedClient.close).toBeDefined();
92
78
 
93
- expect(result.status).toBe("unhealthy");
94
- expect(result.message).toContain("not valid");
79
+ connectedClient.close();
95
80
  });
96
81
 
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 () => {
82
+ it("should throw for connection error during client creation", async () => {
119
83
  const strategy = new TlsHealthCheckStrategy(
120
84
  createMockClient({ error: new Error("Connection refused") })
121
85
  );
122
86
 
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");
87
+ await expect(
88
+ strategy.createClient({
89
+ host: "example.com",
90
+ port: 443,
91
+ timeout: 5000,
92
+ minDaysUntilExpiry: 7,
93
+ rejectUnauthorized: true,
94
+ })
95
+ ).rejects.toThrow("Connection refused");
133
96
  });
97
+ });
134
98
 
135
- it("should pass daysUntilExpiry assertion", async () => {
99
+ describe("client.exec (inspect action)", () => {
100
+ it("should return valid certificate info", async () => {
136
101
  const strategy = new TlsHealthCheckStrategy(createMockClient());
137
102
 
138
- const result = await strategy.execute({
103
+ const connectedClient = await strategy.createClient({
139
104
  host: "example.com",
140
105
  port: 443,
141
106
  timeout: 5000,
142
107
  minDaysUntilExpiry: 7,
143
108
  rejectUnauthorized: true,
144
- assertions: [
145
- {
146
- field: "daysUntilExpiry",
147
- operator: "greaterThanOrEqual",
148
- value: 7,
149
- },
150
- ],
151
109
  });
152
110
 
153
- expect(result.status).toBe("healthy");
154
- });
111
+ const result = await connectedClient.client.exec({ action: "inspect" });
155
112
 
156
- it("should pass issuer assertion", async () => {
157
- const strategy = new TlsHealthCheckStrategy(createMockClient());
113
+ expect(result.isValid).toBe(true);
114
+ expect(result.daysRemaining).toBeGreaterThan(0);
115
+ expect(result.subject).toBe("example.com");
158
116
 
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");
117
+ connectedClient.close();
171
118
  });
172
119
 
173
- it("should fail isValid assertion when certificate is invalid", async () => {
120
+ it("should return invalid for unauthorized certificate", async () => {
174
121
  const strategy = new TlsHealthCheckStrategy(
175
122
  createMockClient({ authorized: false })
176
123
  );
177
124
 
178
- const result = await strategy.execute({
125
+ const connectedClient = await strategy.createClient({
179
126
  host: "example.com",
180
127
  port: 443,
181
128
  timeout: 5000,
182
129
  minDaysUntilExpiry: 7,
183
- rejectUnauthorized: false, // Don't reject to test assertion
184
- assertions: [{ field: "isValid", operator: "isTrue" }],
130
+ rejectUnauthorized: false, // Don't reject to allow connection
185
131
  });
186
132
 
187
- expect(result.status).toBe("unhealthy");
188
- expect(result.message).toContain("Assertion failed");
133
+ const result = await connectedClient.client.exec({ action: "inspect" });
134
+
135
+ expect(result.isValid).toBe(false);
136
+
137
+ connectedClient.close();
189
138
  });
190
139
 
191
140
  it("should detect self-signed certificates", async () => {
@@ -199,7 +148,7 @@ describe("TlsHealthCheckStrategy", () => {
199
148
  createMockClient({ cert: selfSignedCert, authorized: false })
200
149
  );
201
150
 
202
- const result = await strategy.execute({
151
+ const connectedClient = await strategy.createClient({
203
152
  host: "localhost",
204
153
  port: 443,
205
154
  timeout: 5000,
@@ -207,7 +156,11 @@ describe("TlsHealthCheckStrategy", () => {
207
156
  rejectUnauthorized: false,
208
157
  });
209
158
 
210
- expect(result.metadata?.isSelfSigned).toBe(true);
159
+ const result = await connectedClient.client.exec({ action: "inspect" });
160
+
161
+ expect(result.isSelfSigned).toBe(true);
162
+
163
+ connectedClient.close();
211
164
  });
212
165
  });
213
166
 
@@ -301,7 +254,7 @@ describe("TlsHealthCheckStrategy", () => {
301
254
 
302
255
  const aggregated = strategy.aggregateResult(runs);
303
256
 
304
- expect(aggregated.invalidCount).toBe(1);
257
+ expect(aggregated.invalidCount).toBe(2);
305
258
  expect(aggregated.errorCount).toBe(1);
306
259
  });
307
260
  });
package/src/strategy.ts CHANGED
@@ -1,38 +1,26 @@
1
1
  import * as tls from "node:tls";
2
2
  import {
3
3
  HealthCheckStrategy,
4
- HealthCheckResult,
5
4
  HealthCheckRunForAggregation,
6
5
  Versioned,
7
6
  z,
8
- numericField,
9
- stringField,
10
- booleanField,
11
- evaluateAssertions,
7
+ type ConnectedClient,
12
8
  } from "@checkstack/backend-api";
13
9
  import {
14
10
  healthResultBoolean,
15
11
  healthResultNumber,
16
12
  healthResultString,
17
13
  } from "@checkstack/healthcheck-common";
14
+ import type {
15
+ TlsTransportClient,
16
+ TlsInspectRequest,
17
+ TlsCertificateInfo,
18
+ } from "./transport-client";
18
19
 
19
20
  // ============================================================================
20
21
  // SCHEMAS
21
22
  // ============================================================================
22
23
 
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
24
  /**
37
25
  * Configuration schema for TLS health checks.
38
26
  */
@@ -42,7 +30,7 @@ export const tlsConfigSchema = z.object({
42
30
  servername: z
43
31
  .string()
44
32
  .optional()
45
- .describe("SNI hostname (defaults to host if not specified)"),
33
+ .describe("Server name for SNI (defaults to host)"),
46
34
  timeout: z
47
35
  .number()
48
36
  .min(100)
@@ -50,17 +38,14 @@ export const tlsConfigSchema = z.object({
50
38
  .describe("Connection timeout in milliseconds"),
51
39
  minDaysUntilExpiry: z
52
40
  .number()
41
+ .int()
53
42
  .min(0)
54
- .default(14)
55
- .describe("Minimum days until certificate expiry for healthy status"),
43
+ .default(30)
44
+ .describe("Minimum days before certificate expiry to consider healthy"),
56
45
  rejectUnauthorized: z
57
46
  .boolean()
58
47
  .default(true)
59
48
  .describe("Reject invalid/self-signed certificates"),
60
- assertions: z
61
- .array(tlsAssertionSchema)
62
- .optional()
63
- .describe("Validation conditions"),
64
49
  });
65
50
 
66
51
  export type TlsConfig = z.infer<typeof tlsConfigSchema>;
@@ -75,49 +60,24 @@ const tlsResultSchema = z.object({
75
60
  }),
76
61
  isValid: healthResultBoolean({
77
62
  "x-chart-type": "boolean",
78
- "x-chart-label": "Certificate Valid",
63
+ "x-chart-label": "Valid",
79
64
  }),
80
65
  isSelfSigned: healthResultBoolean({
81
66
  "x-chart-type": "boolean",
82
67
  "x-chart-label": "Self-Signed",
83
68
  }),
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
69
  daysUntilExpiry: healthResultNumber({
101
- "x-chart-type": "counter",
70
+ "x-chart-type": "line",
102
71
  "x-chart-label": "Days Until Expiry",
103
72
  "x-chart-unit": "days",
104
73
  }),
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
74
  error: healthResultString({
115
75
  "x-chart-type": "status",
116
76
  "x-chart-label": "Error",
117
77
  }).optional(),
118
78
  });
119
79
 
120
- export type TlsResult = z.infer<typeof tlsResultSchema>;
80
+ type TlsResult = z.infer<typeof tlsResultSchema>;
121
81
 
122
82
  /**
123
83
  * Aggregated metadata for buckets.
@@ -143,7 +103,7 @@ const tlsAggregatedSchema = z.object({
143
103
  }),
144
104
  });
145
105
 
146
- export type TlsAggregatedResult = z.infer<typeof tlsAggregatedSchema>;
106
+ type TlsAggregatedResult = z.infer<typeof tlsAggregatedSchema>;
147
107
 
148
108
  // ============================================================================
149
109
  // TLS CLIENT INTERFACE (for testability)
@@ -199,7 +159,7 @@ const defaultTlsClient: TlsClient = {
199
159
  );
200
160
 
201
161
  socket.on("error", reject);
202
- socket.on("timeout", () => {
162
+ socket.setTimeout(options.timeout, () => {
203
163
  socket.destroy();
204
164
  reject(new Error("Connection timeout"));
205
165
  });
@@ -212,7 +172,13 @@ const defaultTlsClient: TlsClient = {
212
172
  // ============================================================================
213
173
 
214
174
  export class TlsHealthCheckStrategy
215
- implements HealthCheckStrategy<TlsConfig, TlsResult, TlsAggregatedResult>
175
+ implements
176
+ HealthCheckStrategy<
177
+ TlsConfig,
178
+ TlsTransportClient,
179
+ TlsResult,
180
+ TlsAggregatedResult
181
+ >
216
182
  {
217
183
  id = "tls";
218
184
  displayName = "TLS/SSL Health Check";
@@ -225,13 +191,29 @@ export class TlsHealthCheckStrategy
225
191
  }
226
192
 
227
193
  config: Versioned<TlsConfig> = new Versioned({
228
- version: 1,
194
+ version: 2, // Bumped for createClient pattern
229
195
  schema: tlsConfigSchema,
196
+ migrations: [
197
+ {
198
+ fromVersion: 1,
199
+ toVersion: 2,
200
+ description: "Migrate to createClient pattern (no config changes)",
201
+ migrate: (data: unknown) => data,
202
+ },
203
+ ],
230
204
  });
231
205
 
232
206
  result: Versioned<TlsResult> = new Versioned({
233
- version: 1,
207
+ version: 2,
234
208
  schema: tlsResultSchema,
209
+ migrations: [
210
+ {
211
+ fromVersion: 1,
212
+ toVersion: 2,
213
+ description: "Migrate to createClient pattern (no result changes)",
214
+ migrate: (data: unknown) => data,
215
+ },
216
+ ],
235
217
  });
236
218
 
237
219
  aggregatedResult: Versioned<TlsAggregatedResult> = new Versioned({
@@ -242,151 +224,87 @@ export class TlsHealthCheckStrategy
242
224
  aggregateResult(
243
225
  runs: HealthCheckRunForAggregation<TlsResult>[]
244
226
  ): 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
- }
227
+ const validRuns = runs.filter((r) => r.metadata);
228
+
229
+ if (validRuns.length === 0) {
230
+ return {
231
+ avgDaysUntilExpiry: 0,
232
+ minDaysUntilExpiry: 0,
233
+ invalidCount: 0,
234
+ errorCount: 0,
235
+ };
266
236
  }
267
237
 
238
+ const daysValues = validRuns
239
+ .map((r) => r.metadata?.daysUntilExpiry)
240
+ .filter((d): d is number => typeof d === "number");
241
+
242
+ const avgDaysUntilExpiry =
243
+ daysValues.length > 0
244
+ ? Math.round(daysValues.reduce((a, b) => a + b, 0) / daysValues.length)
245
+ : 0;
246
+
247
+ const minDaysUntilExpiry =
248
+ daysValues.length > 0 ? Math.min(...daysValues) : 0;
249
+
250
+ const invalidCount = validRuns.filter(
251
+ (r) => r.metadata?.isValid === false
252
+ ).length;
253
+
254
+ const errorCount = validRuns.filter(
255
+ (r) => r.metadata?.error !== undefined
256
+ ).length;
257
+
268
258
  return {
269
- avgDaysUntilExpiry: validRuns > 0 ? totalDaysUntilExpiry / validRuns : 0,
270
- minDaysUntilExpiry:
271
- minDaysUntilExpiry === Number.POSITIVE_INFINITY
272
- ? 0
273
- : minDaysUntilExpiry,
259
+ avgDaysUntilExpiry,
260
+ minDaysUntilExpiry,
274
261
  invalidCount,
275
262
  errorCount,
276
263
  };
277
264
  }
278
265
 
279
- async execute(config: TlsConfig): Promise<HealthCheckResult<TlsResult>> {
266
+ async createClient(
267
+ config: TlsConfig
268
+ ): Promise<ConnectedClient<TlsTransportClient>> {
280
269
  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
270
 
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
- };
271
+ const connection = await this.tlsClient.connect({
272
+ host: validatedConfig.host,
273
+ port: validatedConfig.port,
274
+ servername: validatedConfig.servername ?? validatedConfig.host,
275
+ rejectUnauthorized: validatedConfig.rejectUnauthorized,
276
+ timeout: validatedConfig.timeout,
277
+ });
324
278
 
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
- });
279
+ const cert = connection.getPeerCertificate();
280
+ const validTo = new Date(cert.valid_to);
281
+ const daysUntilExpiry = Math.floor(
282
+ (validTo.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
283
+ );
284
+
285
+ const certInfo: TlsCertificateInfo = {
286
+ isValid: connection.authorized,
287
+ isSelfSigned: cert.issuer?.CN === cert.subject?.CN,
288
+ issuer: cert.issuer?.O || cert.issuer?.CN || "Unknown",
289
+ subject: cert.subject?.CN || "Unknown",
290
+ validFrom: cert.valid_from,
291
+ validTo: cert.valid_to,
292
+ daysUntilExpiry,
293
+ daysRemaining: daysUntilExpiry,
294
+ protocol: connection.getProtocol() ?? undefined,
295
+ cipher: connection.getCipher()?.name,
296
+ };
333
297
 
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
- }
298
+ const client: TlsTransportClient = {
299
+ async exec(_request: TlsInspectRequest): Promise<TlsCertificateInfo> {
300
+ // Certificate info is captured at connection time
301
+ return certInfo;
302
+ },
303
+ };
364
304
 
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
- }
305
+ return {
306
+ client,
307
+ close: () => connection.end(),
308
+ };
391
309
  }
392
310
  }
@@ -0,0 +1,34 @@
1
+ import type { TransportClient } from "@checkstack/common";
2
+
3
+ /**
4
+ * TLS inspection request.
5
+ */
6
+ export interface TlsInspectRequest {
7
+ /** Action to perform (inspect is the default and only action for now) */
8
+ action?: "inspect";
9
+ }
10
+
11
+ /**
12
+ * TLS certificate information.
13
+ */
14
+ export interface TlsCertificateInfo {
15
+ isValid: boolean;
16
+ isSelfSigned: boolean;
17
+ issuer: string;
18
+ subject: string;
19
+ validFrom: string;
20
+ validTo: string;
21
+ daysUntilExpiry: number;
22
+ daysRemaining: number; // Alias for daysUntilExpiry
23
+ protocol?: string;
24
+ cipher?: string;
25
+ error?: string;
26
+ }
27
+
28
+ /**
29
+ * TLS transport client for certificate inspection.
30
+ */
31
+ export type TlsTransportClient = TransportClient<
32
+ TlsInspectRequest,
33
+ TlsCertificateInfo
34
+ >;