@openstatus/health-tls 0.1.4-dev.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/README.md +78 -0
- package/dist/_virtual/rolldown_runtime.cjs +30 -0
- package/dist/mod.cjs +88 -0
- package/dist/mod.d.cts +60 -0
- package/dist/mod.d.cts.map +1 -0
- package/dist/mod.d.ts +60 -0
- package/dist/mod.d.ts.map +1 -0
- package/dist/mod.js +84 -0
- package/dist/mod.js.map +1 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @openstatus/health-tls
|
|
2
|
+
|
|
3
|
+
TLS probe for [`@openstatus/health`](https://jsr.io/@openstatus/health).
|
|
4
|
+
Completes a TLS handshake with `host:port`, requires the certificate to
|
|
5
|
+
chain to a trusted CA and fails the check when it expires within
|
|
6
|
+
`minDaysValid` days — so a certificate that is about to lapse turns the
|
|
7
|
+
report `degraded` two weeks before browsers start refusing it.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
deno add jsr:@openstatus/health jsr:@openstatus/health-tls
|
|
11
|
+
npm install @openstatus/health @openstatus/health-tls
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { createHealthHandler } from "@openstatus/health";
|
|
16
|
+
import { tlsProbe } from "@openstatus/health-tls";
|
|
17
|
+
|
|
18
|
+
Deno.serve(
|
|
19
|
+
createHealthHandler({
|
|
20
|
+
probes: [
|
|
21
|
+
tlsProbe({ host: "api.example.com" }),
|
|
22
|
+
tlsProbe({ name: "db-tls", host: "db.example.com", port: 5432 }),
|
|
23
|
+
],
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`host` is sent as the SNI server name, so the probe sees the same
|
|
29
|
+
certificate a client would. The socket is closed as soon as the handshake
|
|
30
|
+
completes; no application data is sent. A self-signed or mis-chained
|
|
31
|
+
certificate fails with the runtime's verification error
|
|
32
|
+
(`SELF_SIGNED_CERT_IN_CHAIN`, `CERT_HAS_EXPIRED`, …), and one that is
|
|
33
|
+
about to expire fails with `TlsCertificateExpiryError`, which carries
|
|
34
|
+
`expiresAt` and `daysLeft`.
|
|
35
|
+
|
|
36
|
+
Point it at your own public hostname to catch a renewal that did not run,
|
|
37
|
+
or at a dependency to be warned before its certificate takes you down.
|
|
38
|
+
Give each probe a `name` when you mount more than one.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
tlsProbe({
|
|
42
|
+
host: "api.example.com",
|
|
43
|
+
port: 443,
|
|
44
|
+
minDaysValid: 30,
|
|
45
|
+
// optional overrides from the Probe contract
|
|
46
|
+
name: "certificate",
|
|
47
|
+
critical: false,
|
|
48
|
+
timeoutMs: 3000,
|
|
49
|
+
skip: () => env.NODE_ENV !== "production",
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Non-critical by default: an expiring certificate is something to act on,
|
|
54
|
+
not a reason to take the instance out of rotation today. Set
|
|
55
|
+
`critical: true` when the handshake is with a dependency requests cannot
|
|
56
|
+
proceed without.
|
|
57
|
+
|
|
58
|
+
This probe uses `node:tls`, so it runs on Node.js, Deno and Bun but not on
|
|
59
|
+
edge runtimes without a socket API. `connect` is typed structurally and can
|
|
60
|
+
be replaced for tests.
|
|
61
|
+
|
|
62
|
+
## About openstatus
|
|
63
|
+
|
|
64
|
+
[openstatus](https://www.openstatus.dev/) is the open-source uptime monitoring
|
|
65
|
+
and status page platform. This package is part of
|
|
66
|
+
[`@openstatus/health`](https://github.com/openstatusHQ/health), the `/health`
|
|
67
|
+
endpoints behind openstatus's own services, extracted so any JavaScript server
|
|
68
|
+
can expose one. Point an
|
|
69
|
+
[openstatus monitor](https://www.openstatus.dev/docs/reference/http-monitor)
|
|
70
|
+
at the endpoint and assert on `status` in the body to be alerted on
|
|
71
|
+
`degraded` before it becomes `unhealthy`.
|
|
72
|
+
|
|
73
|
+
Source: [github.com/openstatusHQ/health](https://github.com/openstatusHQ/health).
|
|
74
|
+
Issues and PRs welcome.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
[MIT](https://github.com/openstatusHQ/health/blob/main/LICENSE)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region rolldown:runtime
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
10
|
+
key = keys[i];
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
12
|
+
get: ((k) => from[k]).bind(null, key),
|
|
13
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
19
|
+
value: mod,
|
|
20
|
+
enumerable: true
|
|
21
|
+
}) : target, mod));
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
|
|
25
|
+
Object.defineProperty(exports, '__toESM', {
|
|
26
|
+
enumerable: true,
|
|
27
|
+
get: function () {
|
|
28
|
+
return __toESM;
|
|
29
|
+
}
|
|
30
|
+
});
|
package/dist/mod.cjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
|
|
2
|
+
const node_tls = require_rolldown_runtime.__toESM(require("node:tls"));
|
|
3
|
+
const __openstatus_health = require_rolldown_runtime.__toESM(require("@openstatus/health"));
|
|
4
|
+
|
|
5
|
+
//#region src/mod.ts
|
|
6
|
+
/** Probe name when `name` is unset. */
|
|
7
|
+
const tlsDefaultName = "tls";
|
|
8
|
+
/** Port when `port` is unset. */
|
|
9
|
+
const tlsDefaultPort = 443;
|
|
10
|
+
/** Minimum remaining validity when `minDaysValid` is unset. */
|
|
11
|
+
const tlsDefaultMinDaysValid = 14;
|
|
12
|
+
/** Thrown by the probe when the certificate expires within `minDaysValid` days. */
|
|
13
|
+
var TlsCertificateExpiryError = class extends Error {
|
|
14
|
+
/** When the certificate expires. */
|
|
15
|
+
expiresAt;
|
|
16
|
+
/** Days left, rounded down; negative once expired. */
|
|
17
|
+
daysLeft;
|
|
18
|
+
/** Build the error for a certificate expiring at `expiresAt` against `minDaysValid`. */
|
|
19
|
+
constructor(expiresAt, daysLeft, minDaysValid) {
|
|
20
|
+
super(daysLeft < 0 ? `certificate expired ${expiresAt.toISOString()}` : `certificate expires in ${daysLeft} days, fewer than ${minDaysValid}`);
|
|
21
|
+
this.name = "TlsCertificateExpiryError";
|
|
22
|
+
this.expiresAt = expiresAt;
|
|
23
|
+
this.daysLeft = daysLeft;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** A probe that completes a TLS handshake and checks certificate trust and expiry; non-critical by default. Throws `ProbeConfigError` for an empty `host`, an invalid `port` or a negative `minDaysValid`. */
|
|
27
|
+
function tlsProbe(options) {
|
|
28
|
+
const host = options.host;
|
|
29
|
+
if (typeof host !== "string" || host.length === 0) throw new __openstatus_health.ProbeConfigError("tlsProbe", "host", typeof host !== "string" ? `must be a string, got ${String(host)}` : "must not be empty");
|
|
30
|
+
const port = options.port ?? tlsDefaultPort;
|
|
31
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new __openstatus_health.ProbeConfigError("tlsProbe", "port", `must be an integer between 1 and 65535, got ${String(port)}`);
|
|
32
|
+
const minDaysValid = options.minDaysValid ?? tlsDefaultMinDaysValid;
|
|
33
|
+
if (!Number.isFinite(minDaysValid) || minDaysValid < 0) throw new __openstatus_health.ProbeConfigError("tlsProbe", "minDaysValid", `must be a non-negative number, got ${String(minDaysValid)}`);
|
|
34
|
+
const connect = options.connect ?? node_tls.connect;
|
|
35
|
+
return {
|
|
36
|
+
name: options.name ?? tlsDefaultName,
|
|
37
|
+
critical: options.critical ?? false,
|
|
38
|
+
timeoutMs: options.timeoutMs,
|
|
39
|
+
skip: options.skip,
|
|
40
|
+
run: (signal) => new Promise((resolve, reject) => {
|
|
41
|
+
const socket = connect({
|
|
42
|
+
host,
|
|
43
|
+
port,
|
|
44
|
+
servername: host
|
|
45
|
+
});
|
|
46
|
+
const settle = (finish) => {
|
|
47
|
+
signal.removeEventListener("abort", onAbort);
|
|
48
|
+
socket.destroy();
|
|
49
|
+
finish();
|
|
50
|
+
};
|
|
51
|
+
const onAbort = () => settle(() => reject(signal.reason));
|
|
52
|
+
socket.once("secureConnect", () => {
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
result = inspect(socket, minDaysValid);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
settle(() => reject(error));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
settle(() => resolve(result));
|
|
61
|
+
});
|
|
62
|
+
socket.once("error", (error) => settle(() => reject(error ?? /* @__PURE__ */ new Error("socket error"))));
|
|
63
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
64
|
+
})
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function inspect(socket, minDaysValid) {
|
|
68
|
+
if (!socket.authorized) {
|
|
69
|
+
const reason = socket.authorizationError;
|
|
70
|
+
throw reason instanceof Error ? reason : new Error(reason == null ? "certificate not trusted" : String(reason));
|
|
71
|
+
}
|
|
72
|
+
const expiresAt = new Date(socket.getPeerCertificate().valid_to);
|
|
73
|
+
if (Number.isNaN(expiresAt.getTime())) throw new Error("certificate has no readable expiry");
|
|
74
|
+
const msLeft = expiresAt.getTime() - Date.now();
|
|
75
|
+
const daysLeft = Math.floor(msLeft / 864e5);
|
|
76
|
+
if (msLeft < minDaysValid * 864e5) throw new TlsCertificateExpiryError(expiresAt, daysLeft, minDaysValid);
|
|
77
|
+
return {
|
|
78
|
+
expiresAt: expiresAt.toISOString(),
|
|
79
|
+
daysLeft
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
84
|
+
exports.TlsCertificateExpiryError = TlsCertificateExpiryError;
|
|
85
|
+
exports.tlsDefaultMinDaysValid = tlsDefaultMinDaysValid;
|
|
86
|
+
exports.tlsDefaultName = tlsDefaultName;
|
|
87
|
+
exports.tlsDefaultPort = tlsDefaultPort;
|
|
88
|
+
exports.tlsProbe = tlsProbe;
|
package/dist/mod.d.cts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Probe, ProbeOverrides } from "@openstatus/health";
|
|
2
|
+
|
|
3
|
+
//#region src/mod.d.ts
|
|
4
|
+
|
|
5
|
+
/** Probe name when `name` is unset. */
|
|
6
|
+
declare const tlsDefaultName = "tls";
|
|
7
|
+
/** Port when `port` is unset. */
|
|
8
|
+
declare const tlsDefaultPort = 443;
|
|
9
|
+
/** Minimum remaining validity when `minDaysValid` is unset. */
|
|
10
|
+
declare const tlsDefaultMinDaysValid = 14;
|
|
11
|
+
/** The subset of a peer certificate the probe reads. */
|
|
12
|
+
interface TlsPeerCertificate {
|
|
13
|
+
/** Expiry, as the date string `getPeerCertificate()` returns. */
|
|
14
|
+
readonly valid_to: string;
|
|
15
|
+
}
|
|
16
|
+
/** The subset of a `tls.TLSSocket` the probe uses. */
|
|
17
|
+
interface TlsLikeSocket {
|
|
18
|
+
/** Listen once for `secureConnect` (handshake done) or `error` (failed). */
|
|
19
|
+
once(event: "secureConnect" | "error", listener: (error?: Error) => void): TlsLikeSocket;
|
|
20
|
+
/** Whether the peer certificate chained to a trusted CA. */
|
|
21
|
+
readonly authorized: boolean;
|
|
22
|
+
/** Why it did not, when `authorized` is false. */
|
|
23
|
+
readonly authorizationError?: Error | string | null;
|
|
24
|
+
/** The peer certificate after the handshake. */
|
|
25
|
+
getPeerCertificate(): TlsPeerCertificate;
|
|
26
|
+
/** Close the socket. */
|
|
27
|
+
destroy(): void;
|
|
28
|
+
}
|
|
29
|
+
/** What the probe connects with; `tls.connect` by default. */
|
|
30
|
+
type TlsConnect = (options: {
|
|
31
|
+
readonly host: string;
|
|
32
|
+
readonly port: number;
|
|
33
|
+
readonly servername: string;
|
|
34
|
+
}) => TlsLikeSocket;
|
|
35
|
+
/** Options for `tlsProbe()`. */
|
|
36
|
+
interface TlsProbeOptions extends ProbeOverrides {
|
|
37
|
+
/** Hostname; also sent as the SNI server name. */
|
|
38
|
+
readonly host: string;
|
|
39
|
+
/** Port. Default `tlsDefaultPort`. */
|
|
40
|
+
readonly port?: number;
|
|
41
|
+
/** Fail when the certificate expires in fewer days than this. Default `tlsDefaultMinDaysValid`. */
|
|
42
|
+
readonly minDaysValid?: number;
|
|
43
|
+
/** Replacement `connect`, for tests. */
|
|
44
|
+
readonly connect?: TlsConnect;
|
|
45
|
+
}
|
|
46
|
+
/** Thrown by the probe when the certificate expires within `minDaysValid` days. */
|
|
47
|
+
declare class TlsCertificateExpiryError extends Error {
|
|
48
|
+
/** When the certificate expires. */
|
|
49
|
+
readonly expiresAt: Date;
|
|
50
|
+
/** Days left, rounded down; negative once expired. */
|
|
51
|
+
readonly daysLeft: number;
|
|
52
|
+
/** Build the error for a certificate expiring at `expiresAt` against `minDaysValid`. */
|
|
53
|
+
constructor(expiresAt: Date, daysLeft: number, minDaysValid: number);
|
|
54
|
+
}
|
|
55
|
+
/** A probe that completes a TLS handshake and checks certificate trust and expiry; non-critical by default. Throws `ProbeConfigError` for an empty `host`, an invalid `port` or a negative `minDaysValid`. */
|
|
56
|
+
declare function tlsProbe(options: TlsProbeOptions): Probe;
|
|
57
|
+
//# sourceMappingURL=mod.d.ts.map
|
|
58
|
+
//#endregion
|
|
59
|
+
export { TlsCertificateExpiryError, TlsConnect, TlsLikeSocket, TlsPeerCertificate, TlsProbeOptions, tlsDefaultMinDaysValid, tlsDefaultName, tlsDefaultPort, tlsProbe };
|
|
60
|
+
//# sourceMappingURL=mod.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mod.d.cts","names":[],"sources":["../src/mod.ts"],"sourcesContent":[],"mappings":";;;;;AAuEqB,cA/CR,cAAA,GA+CQ,KAAA;;AARkC,cArC1C,cAAA,GAqC0C,GAAA;AAYvD;AAAuC,cA/C1B,sBAAA,GA+C0B,EAAA;;AAOd,UAnDR,kBAAA,CAmDQ;EAAI;EAPuB,SAAA,QAAA,EAAA,MAAA;AAoBpD;;AAAkC,UA1DjB,aAAA,CA0DiB;EAAe;EAAQ,IAAA,CAAA,KAAA,EAAA,eAAA,GAAA,OAAA,EAAA,QAAA,EAAA,CAAA,KAAA,CAAA,EAtDlC,KAsDkC,EAAA,GAAA,IAAA,CAAA,EArDpD,aAqDoD;;;;gCAjDzB;;wBAER;;;;;KAMZ,UAAA;;;;MAMP;;UAGY,eAAA,SAAwB;;;;;;;;qBAQpB;;;cAIR,yBAAA,SAAkC,KAAA;;sBAEzB;;;;yBAKG;;;iBAaT,QAAA,UAAkB,kBAAkB"}
|
package/dist/mod.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Probe, ProbeOverrides } from "@openstatus/health";
|
|
2
|
+
|
|
3
|
+
//#region src/mod.d.ts
|
|
4
|
+
|
|
5
|
+
/** Probe name when `name` is unset. */
|
|
6
|
+
declare const tlsDefaultName = "tls";
|
|
7
|
+
/** Port when `port` is unset. */
|
|
8
|
+
declare const tlsDefaultPort = 443;
|
|
9
|
+
/** Minimum remaining validity when `minDaysValid` is unset. */
|
|
10
|
+
declare const tlsDefaultMinDaysValid = 14;
|
|
11
|
+
/** The subset of a peer certificate the probe reads. */
|
|
12
|
+
interface TlsPeerCertificate {
|
|
13
|
+
/** Expiry, as the date string `getPeerCertificate()` returns. */
|
|
14
|
+
readonly valid_to: string;
|
|
15
|
+
}
|
|
16
|
+
/** The subset of a `tls.TLSSocket` the probe uses. */
|
|
17
|
+
interface TlsLikeSocket {
|
|
18
|
+
/** Listen once for `secureConnect` (handshake done) or `error` (failed). */
|
|
19
|
+
once(event: "secureConnect" | "error", listener: (error?: Error) => void): TlsLikeSocket;
|
|
20
|
+
/** Whether the peer certificate chained to a trusted CA. */
|
|
21
|
+
readonly authorized: boolean;
|
|
22
|
+
/** Why it did not, when `authorized` is false. */
|
|
23
|
+
readonly authorizationError?: Error | string | null;
|
|
24
|
+
/** The peer certificate after the handshake. */
|
|
25
|
+
getPeerCertificate(): TlsPeerCertificate;
|
|
26
|
+
/** Close the socket. */
|
|
27
|
+
destroy(): void;
|
|
28
|
+
}
|
|
29
|
+
/** What the probe connects with; `tls.connect` by default. */
|
|
30
|
+
type TlsConnect = (options: {
|
|
31
|
+
readonly host: string;
|
|
32
|
+
readonly port: number;
|
|
33
|
+
readonly servername: string;
|
|
34
|
+
}) => TlsLikeSocket;
|
|
35
|
+
/** Options for `tlsProbe()`. */
|
|
36
|
+
interface TlsProbeOptions extends ProbeOverrides {
|
|
37
|
+
/** Hostname; also sent as the SNI server name. */
|
|
38
|
+
readonly host: string;
|
|
39
|
+
/** Port. Default `tlsDefaultPort`. */
|
|
40
|
+
readonly port?: number;
|
|
41
|
+
/** Fail when the certificate expires in fewer days than this. Default `tlsDefaultMinDaysValid`. */
|
|
42
|
+
readonly minDaysValid?: number;
|
|
43
|
+
/** Replacement `connect`, for tests. */
|
|
44
|
+
readonly connect?: TlsConnect;
|
|
45
|
+
}
|
|
46
|
+
/** Thrown by the probe when the certificate expires within `minDaysValid` days. */
|
|
47
|
+
declare class TlsCertificateExpiryError extends Error {
|
|
48
|
+
/** When the certificate expires. */
|
|
49
|
+
readonly expiresAt: Date;
|
|
50
|
+
/** Days left, rounded down; negative once expired. */
|
|
51
|
+
readonly daysLeft: number;
|
|
52
|
+
/** Build the error for a certificate expiring at `expiresAt` against `minDaysValid`. */
|
|
53
|
+
constructor(expiresAt: Date, daysLeft: number, minDaysValid: number);
|
|
54
|
+
}
|
|
55
|
+
/** A probe that completes a TLS handshake and checks certificate trust and expiry; non-critical by default. Throws `ProbeConfigError` for an empty `host`, an invalid `port` or a negative `minDaysValid`. */
|
|
56
|
+
declare function tlsProbe(options: TlsProbeOptions): Probe;
|
|
57
|
+
//# sourceMappingURL=mod.d.ts.map
|
|
58
|
+
//#endregion
|
|
59
|
+
export { TlsCertificateExpiryError, TlsConnect, TlsLikeSocket, TlsPeerCertificate, TlsProbeOptions, tlsDefaultMinDaysValid, tlsDefaultName, tlsDefaultPort, tlsProbe };
|
|
60
|
+
//# sourceMappingURL=mod.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mod.d.ts","names":[],"sources":["../src/mod.ts"],"sourcesContent":[],"mappings":";;;;;AAuEqB,cA/CR,cAAA,GA+CQ,KAAA;;AARkC,cArC1C,cAAA,GAqC0C,GAAA;AAYvD;AAAuC,cA/C1B,sBAAA,GA+C0B,EAAA;;AAOd,UAnDR,kBAAA,CAmDQ;EAAI;EAPuB,SAAA,QAAA,EAAA,MAAA;AAoBpD;;AAAkC,UA1DjB,aAAA,CA0DiB;EAAe;EAAQ,IAAA,CAAA,KAAA,EAAA,eAAA,GAAA,OAAA,EAAA,QAAA,EAAA,CAAA,KAAA,CAAA,EAtDlC,KAsDkC,EAAA,GAAA,IAAA,CAAA,EArDpD,aAqDoD;;;;gCAjDzB;;wBAER;;;;;KAMZ,UAAA;;;;MAMP;;UAGY,eAAA,SAAwB;;;;;;;;qBAQpB;;;cAIR,yBAAA,SAAkC,KAAA;;sBAEzB;;;;yBAKG;;;iBAaT,QAAA,UAAkB,kBAAkB"}
|
package/dist/mod.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { connect } from "node:tls";
|
|
2
|
+
import { ProbeConfigError } from "@openstatus/health";
|
|
3
|
+
|
|
4
|
+
//#region src/mod.ts
|
|
5
|
+
/** Probe name when `name` is unset. */
|
|
6
|
+
const tlsDefaultName = "tls";
|
|
7
|
+
/** Port when `port` is unset. */
|
|
8
|
+
const tlsDefaultPort = 443;
|
|
9
|
+
/** Minimum remaining validity when `minDaysValid` is unset. */
|
|
10
|
+
const tlsDefaultMinDaysValid = 14;
|
|
11
|
+
/** Thrown by the probe when the certificate expires within `minDaysValid` days. */
|
|
12
|
+
var TlsCertificateExpiryError = class extends Error {
|
|
13
|
+
/** When the certificate expires. */
|
|
14
|
+
expiresAt;
|
|
15
|
+
/** Days left, rounded down; negative once expired. */
|
|
16
|
+
daysLeft;
|
|
17
|
+
/** Build the error for a certificate expiring at `expiresAt` against `minDaysValid`. */
|
|
18
|
+
constructor(expiresAt, daysLeft, minDaysValid) {
|
|
19
|
+
super(daysLeft < 0 ? `certificate expired ${expiresAt.toISOString()}` : `certificate expires in ${daysLeft} days, fewer than ${minDaysValid}`);
|
|
20
|
+
this.name = "TlsCertificateExpiryError";
|
|
21
|
+
this.expiresAt = expiresAt;
|
|
22
|
+
this.daysLeft = daysLeft;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** A probe that completes a TLS handshake and checks certificate trust and expiry; non-critical by default. Throws `ProbeConfigError` for an empty `host`, an invalid `port` or a negative `minDaysValid`. */
|
|
26
|
+
function tlsProbe(options) {
|
|
27
|
+
const host = options.host;
|
|
28
|
+
if (typeof host !== "string" || host.length === 0) throw new ProbeConfigError("tlsProbe", "host", typeof host !== "string" ? `must be a string, got ${String(host)}` : "must not be empty");
|
|
29
|
+
const port = options.port ?? tlsDefaultPort;
|
|
30
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ProbeConfigError("tlsProbe", "port", `must be an integer between 1 and 65535, got ${String(port)}`);
|
|
31
|
+
const minDaysValid = options.minDaysValid ?? tlsDefaultMinDaysValid;
|
|
32
|
+
if (!Number.isFinite(minDaysValid) || minDaysValid < 0) throw new ProbeConfigError("tlsProbe", "minDaysValid", `must be a non-negative number, got ${String(minDaysValid)}`);
|
|
33
|
+
const connect$1 = options.connect ?? connect;
|
|
34
|
+
return {
|
|
35
|
+
name: options.name ?? tlsDefaultName,
|
|
36
|
+
critical: options.critical ?? false,
|
|
37
|
+
timeoutMs: options.timeoutMs,
|
|
38
|
+
skip: options.skip,
|
|
39
|
+
run: (signal) => new Promise((resolve, reject) => {
|
|
40
|
+
const socket = connect$1({
|
|
41
|
+
host,
|
|
42
|
+
port,
|
|
43
|
+
servername: host
|
|
44
|
+
});
|
|
45
|
+
const settle = (finish) => {
|
|
46
|
+
signal.removeEventListener("abort", onAbort);
|
|
47
|
+
socket.destroy();
|
|
48
|
+
finish();
|
|
49
|
+
};
|
|
50
|
+
const onAbort = () => settle(() => reject(signal.reason));
|
|
51
|
+
socket.once("secureConnect", () => {
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = inspect(socket, minDaysValid);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
settle(() => reject(error));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
settle(() => resolve(result));
|
|
60
|
+
});
|
|
61
|
+
socket.once("error", (error) => settle(() => reject(error ?? /* @__PURE__ */ new Error("socket error"))));
|
|
62
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
63
|
+
})
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function inspect(socket, minDaysValid) {
|
|
67
|
+
if (!socket.authorized) {
|
|
68
|
+
const reason = socket.authorizationError;
|
|
69
|
+
throw reason instanceof Error ? reason : new Error(reason == null ? "certificate not trusted" : String(reason));
|
|
70
|
+
}
|
|
71
|
+
const expiresAt = new Date(socket.getPeerCertificate().valid_to);
|
|
72
|
+
if (Number.isNaN(expiresAt.getTime())) throw new Error("certificate has no readable expiry");
|
|
73
|
+
const msLeft = expiresAt.getTime() - Date.now();
|
|
74
|
+
const daysLeft = Math.floor(msLeft / 864e5);
|
|
75
|
+
if (msLeft < minDaysValid * 864e5) throw new TlsCertificateExpiryError(expiresAt, daysLeft, minDaysValid);
|
|
76
|
+
return {
|
|
77
|
+
expiresAt: expiresAt.toISOString(),
|
|
78
|
+
daysLeft
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { TlsCertificateExpiryError, tlsDefaultMinDaysValid, tlsDefaultName, tlsDefaultPort, tlsProbe };
|
|
84
|
+
//# sourceMappingURL=mod.js.map
|
package/dist/mod.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mod.js","names":["expiresAt: Date","daysLeft: number","minDaysValid: number","options: TlsProbeOptions","connect","tlsConnect","finish: () => void","result: { expiresAt: string; daysLeft: number }","socket: TlsLikeSocket"],"sources":["../src/mod.ts"],"sourcesContent":["/**\n * TLS probe for `@openstatus/health`: completes a handshake with\n * `host:port`, requires a trusted certificate and fails when it expires\n * within `minDaysValid` days.\n *\n * ```ts\n * import { tlsProbe } from \"@openstatus/health-tls\";\n *\n * const probe = tlsProbe({ host: \"api.example.com\" });\n * ```\n *\n * Node.js, Deno and Bun only: it uses `node:tls`.\n *\n * @module\n */\n\nimport { connect as tlsConnect } from \"node:tls\";\nimport {\n type Probe,\n ProbeConfigError,\n type ProbeOverrides,\n} from \"@openstatus/health\";\n\n/** Probe name when `name` is unset. */\nexport const tlsDefaultName = \"tls\";\n/** Port when `port` is unset. */\nexport const tlsDefaultPort = 443;\n/** Minimum remaining validity when `minDaysValid` is unset. */\nexport const tlsDefaultMinDaysValid = 14;\n\n/** The subset of a peer certificate the probe reads. */\nexport interface TlsPeerCertificate {\n /** Expiry, as the date string `getPeerCertificate()` returns. */\n readonly valid_to: string;\n}\n\n/** The subset of a `tls.TLSSocket` the probe uses. */\nexport interface TlsLikeSocket {\n /** Listen once for `secureConnect` (handshake done) or `error` (failed). */\n once(\n event: \"secureConnect\" | \"error\",\n listener: (error?: Error) => void,\n ): TlsLikeSocket;\n /** Whether the peer certificate chained to a trusted CA. */\n readonly authorized: boolean;\n /** Why it did not, when `authorized` is false. */\n readonly authorizationError?: Error | string | null;\n /** The peer certificate after the handshake. */\n getPeerCertificate(): TlsPeerCertificate;\n /** Close the socket. */\n destroy(): void;\n}\n\n/** What the probe connects with; `tls.connect` by default. */\nexport type TlsConnect = (\n options: {\n readonly host: string;\n readonly port: number;\n readonly servername: string;\n },\n) => TlsLikeSocket;\n\n/** Options for `tlsProbe()`. */\nexport interface TlsProbeOptions extends ProbeOverrides {\n /** Hostname; also sent as the SNI server name. */\n readonly host: string;\n /** Port. Default `tlsDefaultPort`. */\n readonly port?: number;\n /** Fail when the certificate expires in fewer days than this. Default `tlsDefaultMinDaysValid`. */\n readonly minDaysValid?: number;\n /** Replacement `connect`, for tests. */\n readonly connect?: TlsConnect;\n}\n\n/** Thrown by the probe when the certificate expires within `minDaysValid` days. */\nexport class TlsCertificateExpiryError extends Error {\n /** When the certificate expires. */\n readonly expiresAt: Date;\n /** Days left, rounded down; negative once expired. */\n readonly daysLeft: number;\n\n /** Build the error for a certificate expiring at `expiresAt` against `minDaysValid`. */\n constructor(expiresAt: Date, daysLeft: number, minDaysValid: number) {\n super(\n daysLeft < 0\n ? `certificate expired ${expiresAt.toISOString()}`\n : `certificate expires in ${daysLeft} days, fewer than ${minDaysValid}`,\n );\n this.name = \"TlsCertificateExpiryError\";\n this.expiresAt = expiresAt;\n this.daysLeft = daysLeft;\n }\n}\n\n/** A probe that completes a TLS handshake and checks certificate trust and expiry; non-critical by default. Throws `ProbeConfigError` for an empty `host`, an invalid `port` or a negative `minDaysValid`. */\nexport function tlsProbe(options: TlsProbeOptions): Probe {\n const host = options.host;\n if (typeof host !== \"string\" || host.length === 0) {\n throw new ProbeConfigError(\n \"tlsProbe\",\n \"host\",\n typeof host !== \"string\"\n ? `must be a string, got ${String(host)}`\n : \"must not be empty\",\n );\n }\n const port = options.port ?? tlsDefaultPort;\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n throw new ProbeConfigError(\n \"tlsProbe\",\n \"port\",\n `must be an integer between 1 and 65535, got ${String(port)}`,\n );\n }\n const minDaysValid = options.minDaysValid ?? tlsDefaultMinDaysValid;\n if (!Number.isFinite(minDaysValid) || minDaysValid < 0) {\n throw new ProbeConfigError(\n \"tlsProbe\",\n \"minDaysValid\",\n `must be a non-negative number, got ${String(minDaysValid)}`,\n );\n }\n const connect = options.connect ?? tlsConnect;\n return {\n name: options.name ?? tlsDefaultName,\n critical: options.critical ?? false,\n timeoutMs: options.timeoutMs,\n skip: options.skip,\n run: (signal) =>\n new Promise<{ expiresAt: string; daysLeft: number }>(\n (resolve, reject) => {\n const socket = connect({ host, port, servername: host });\n const settle = (finish: () => void) => {\n signal.removeEventListener(\"abort\", onAbort);\n socket.destroy();\n finish();\n };\n const onAbort = () => settle(() => reject(signal.reason));\n socket.once(\"secureConnect\", () => {\n let result: { expiresAt: string; daysLeft: number };\n try {\n result = inspect(socket, minDaysValid);\n } catch (error) {\n settle(() => reject(error));\n return;\n }\n settle(() => resolve(result));\n });\n socket.once(\n \"error\",\n (error) => settle(() => reject(error ?? new Error(\"socket error\"))),\n );\n signal.addEventListener(\"abort\", onAbort, { once: true });\n },\n ),\n };\n}\n\nfunction inspect(\n socket: TlsLikeSocket,\n minDaysValid: number,\n): { expiresAt: string; daysLeft: number } {\n if (!socket.authorized) {\n const reason = socket.authorizationError;\n throw reason instanceof Error\n ? reason\n : new Error(reason == null ? \"certificate not trusted\" : String(reason));\n }\n const expiresAt = new Date(socket.getPeerCertificate().valid_to);\n if (Number.isNaN(expiresAt.getTime())) {\n throw new Error(\"certificate has no readable expiry\");\n }\n const msLeft = expiresAt.getTime() - Date.now();\n const daysLeft = Math.floor(msLeft / 86_400_000);\n if (msLeft < minDaysValid * 86_400_000) {\n throw new TlsCertificateExpiryError(expiresAt, daysLeft, minDaysValid);\n }\n return { expiresAt: expiresAt.toISOString(), daysLeft };\n}\n"],"mappings":";;;;;AAwBA,MAAa,iBAAiB;;AAE9B,MAAa,iBAAiB;;AAE9B,MAAa,yBAAyB;;AA+CtC,IAAa,4BAAb,cAA+C,MAAM;;CAEnD,AAAS;;CAET,AAAS;;CAGT,YAAYA,WAAiBC,UAAkBC,cAAsB;AACnE,QACE,WAAW,KACN,sBAAsB,UAAU,aAAa,CAAC,KAC9C,yBAAyB,SAAS,oBAAoB,aAAa,EACzE;AACD,OAAK,OAAO;AACZ,OAAK,YAAY;AACjB,OAAK,WAAW;CACjB;AACF;;AAGD,SAAgB,SAASC,SAAiC;CACxD,MAAM,OAAO,QAAQ;AACrB,YAAW,SAAS,YAAY,KAAK,WAAW,EAC9C,OAAM,IAAI,iBACR,YACA,eACO,SAAS,YACX,wBAAwB,OAAO,KAAK,CAAC,IACtC;CAGR,MAAM,OAAO,QAAQ,QAAQ;AAC7B,MAAK,OAAO,UAAU,KAAK,IAAI,OAAO,KAAK,OAAO,MAChD,OAAM,IAAI,iBACR,YACA,SACC,8CAA8C,OAAO,KAAK,CAAC;CAGhE,MAAM,eAAe,QAAQ,gBAAgB;AAC7C,MAAK,OAAO,SAAS,aAAa,IAAI,eAAe,EACnD,OAAM,IAAI,iBACR,YACA,iBACC,qCAAqC,OAAO,aAAa,CAAC;CAG/D,MAAMC,YAAU,QAAQ,WAAWC;AACnC,QAAO;EACL,MAAM,QAAQ,QAAQ;EACtB,UAAU,QAAQ,YAAY;EAC9B,WAAW,QAAQ;EACnB,MAAM,QAAQ;EACd,KAAK,CAAC,WACJ,IAAI,QACF,CAAC,SAAS,WAAW;GACnB,MAAM,SAAS,UAAQ;IAAE;IAAM;IAAM,YAAY;GAAM,EAAC;GACxD,MAAM,SAAS,CAACC,WAAuB;AACrC,WAAO,oBAAoB,SAAS,QAAQ;AAC5C,WAAO,SAAS;AAChB,YAAQ;GACT;GACD,MAAM,UAAU,MAAM,OAAO,MAAM,OAAO,OAAO,OAAO,CAAC;AACzD,UAAO,KAAK,iBAAiB,MAAM;IACjC,IAAIC;AACJ,QAAI;AACF,cAAS,QAAQ,QAAQ,aAAa;IACvC,SAAQ,OAAO;AACd,YAAO,MAAM,OAAO,MAAM,CAAC;AAC3B;IACD;AACD,WAAO,MAAM,QAAQ,OAAO,CAAC;GAC9B,EAAC;AACF,UAAO,KACL,SACA,CAAC,UAAU,OAAO,MAAM,OAAO,yBAAS,IAAI,MAAM,gBAAgB,CAAC,CACpE;AACD,UAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAM,EAAC;EAC1D;CAEN;AACF;AAED,SAAS,QACPC,QACAN,cACyC;AACzC,MAAK,OAAO,YAAY;EACtB,MAAM,SAAS,OAAO;AACtB,QAAM,kBAAkB,QACpB,SACA,IAAI,MAAM,UAAU,OAAO,4BAA4B,OAAO,OAAO;CAC1E;CACD,MAAM,YAAY,IAAI,KAAK,OAAO,oBAAoB,CAAC;AACvD,KAAI,OAAO,MAAM,UAAU,SAAS,CAAC,CACnC,OAAM,IAAI,MAAM;CAElB,MAAM,SAAS,UAAU,SAAS,GAAG,KAAK,KAAK;CAC/C,MAAM,WAAW,KAAK,MAAM,SAAS,MAAW;AAChD,KAAI,SAAS,eAAe,MAC1B,OAAM,IAAI,0BAA0B,WAAW,UAAU;AAE3D,QAAO;EAAE,WAAW,UAAU,aAAa;EAAE;CAAU;AACxD"}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openstatus/health-tls",
|
|
3
|
+
"version": "0.1.4-dev.0",
|
|
4
|
+
"description": "TLS certificate probe for @openstatus/health",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"openstatus",
|
|
7
|
+
"health",
|
|
8
|
+
"healthcheck",
|
|
9
|
+
"tls",
|
|
10
|
+
"ssl",
|
|
11
|
+
"certificate",
|
|
12
|
+
"expiry"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "openstatus",
|
|
17
|
+
"url": "https://www.openstatus.dev/"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/openstatusHQ/health",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/openstatusHQ/health.git",
|
|
23
|
+
"directory": "packages/tls/"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/openstatusHQ/health/issues"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"module": "./dist/mod.js",
|
|
30
|
+
"main": "./dist/mod.cjs",
|
|
31
|
+
"types": "./dist/mod.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": {
|
|
35
|
+
"import": "./dist/mod.d.ts",
|
|
36
|
+
"require": "./dist/mod.d.cts"
|
|
37
|
+
},
|
|
38
|
+
"import": "./dist/mod.js",
|
|
39
|
+
"require": "./dist/mod.cjs"
|
|
40
|
+
},
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"sideEffects": false,
|
|
44
|
+
"files": [
|
|
45
|
+
"dist/"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@openstatus/health": "^0.1.4-dev.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": ">=22",
|
|
55
|
+
"tsdown": "^0.12.7",
|
|
56
|
+
"typescript": "^5.8.3"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsdown",
|
|
60
|
+
"prepack": "tsdown",
|
|
61
|
+
"test": "node --experimental-transform-types --test"
|
|
62
|
+
}
|
|
63
|
+
}
|