feat: the option to expect a certain TLS error for the TCP monitor (#6587)

This commit is contained in:
Frank Elsinga 2026-01-06 19:48:44 +01:00 committed by GitHub
commit adec2a7307
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 435 additions and 118 deletions

View File

@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.string("expected_tls_alert", 50).defaultTo(null);
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("expected_tls_alert");
});
};

View File

@ -165,6 +165,7 @@ class Monitor extends BeanModel {
rabbitmqNodes: JSON.parse(this.rabbitmqNodes),
conditions: JSON.parse(this.conditions),
ipFamily: this.ipFamily,
expectedTlsAlert: this.expected_tls_alert,
// ping advanced options
ping_numeric: this.isPingNumeric(),

View File

@ -5,6 +5,69 @@ const tls = require("tls");
const net = require("net");
const tcpp = require("tcp-ping");
/**
* TLS Alert codes as defined in RFC 5246 and RFC 8446
* @see https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-6
*/
const TLS_ALERT_CODES = {
0: "close_notify",
10: "unexpected_message",
20: "bad_record_mac",
21: "decryption_failed",
22: "record_overflow",
30: "decompression_failure",
40: "handshake_failure",
41: "no_certificate",
42: "bad_certificate",
43: "unsupported_certificate",
44: "certificate_revoked",
45: "certificate_expired",
46: "certificate_unknown",
47: "illegal_parameter",
48: "unknown_ca",
49: "access_denied",
50: "decode_error",
51: "decrypt_error",
60: "export_restriction",
70: "protocol_version",
71: "insufficient_security",
80: "internal_error",
86: "inappropriate_fallback",
90: "user_canceled",
100: "no_renegotiation",
109: "missing_extension",
110: "unsupported_extension",
111: "certificate_unobtainable",
112: "unrecognized_name",
113: "bad_certificate_status_response",
114: "bad_certificate_hash_value",
115: "unknown_psk_identity",
116: "certificate_required",
120: "no_application_protocol",
};
/**
* Parse TLS alert number from error message
* @param {string} errorMessage Error message from TLS connection
* @returns {number|null} TLS alert number or null if not found
*/
function parseTlsAlertNumber(errorMessage) {
const match = errorMessage.match(/alert number (\d+)/i);
if (match) {
return parseInt(match[1], 10);
}
return null;
}
/**
* Get TLS alert name from alert number
* @param {number} alertNumber TLS alert number
* @returns {string} TLS alert name or "unknown_alert"
*/
function getTlsAlertName(alertNumber) {
return TLS_ALERT_CODES[alertNumber] || `unknown_alert_${alertNumber}`;
}
/**
* Send TCP request to specified hostname and port
* @param {string} hostname Hostname / address of machine
@ -41,6 +104,25 @@ class TCPMonitorType extends MonitorType {
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
const expectedTlsAlert = monitor.expected_tls_alert;
// If expecting a TLS alert, use TLS connection with alert detection
if (expectedTlsAlert && expectedTlsAlert !== "none") {
await this.checkTlsAlert(monitor, heartbeat, expectedTlsAlert);
return;
}
// Standard TCP check
await this.checkTcp(monitor, heartbeat);
}
/**
* Standard TCP connectivity check
* @param {object} monitor Monitor object
* @param {object} heartbeat Heartbeat object
* @returns {Promise<void>}
*/
async checkTcp(monitor, heartbeat) {
try {
const resp = await tcping(monitor.hostname, monitor.port);
heartbeat.ping = resp;
@ -52,132 +134,272 @@ class TCPMonitorType extends MonitorType {
let socket_;
const preTLS = () =>
new Promise((resolve, reject) => {
let dialogTimeout;
let bannerTimeout;
socket_ = net.connect(monitor.port, monitor.hostname);
const onTimeout = () => {
log.debug(this.name, `[${monitor.name}] Pre-TLS connection timed out`);
doReject("Connection timed out");
};
const onBannerTimeout = () => {
log.debug(this.name, `[${monitor.name}] Pre-TLS timed out waiting for banner`);
// No banner. Could be a XMPP server?
socket_.write(`<stream:stream to='${monitor.hostname}' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>`);
};
const doResolve = () => {
dialogTimeout && clearTimeout(dialogTimeout);
bannerTimeout && clearTimeout(bannerTimeout);
resolve({ socket: socket_ });
};
const doReject = (error) => {
dialogTimeout && clearTimeout(dialogTimeout);
bannerTimeout && clearTimeout(bannerTimeout);
socket_.end();
reject(error);
};
socket_.on("connect", () => {
log.debug(this.name, `[${monitor.name}] Pre-TLS connection: ${JSON.stringify(socket_)}`);
});
socket_.on("data", data => {
const response = data.toString();
const response_ = response.toLowerCase();
log.debug(this.name, `[${monitor.name}] Pre-TLS response: ${response}`);
clearTimeout(bannerTimeout);
switch (true) {
case response_.includes("start tls") || response_.includes("begin tls"):
doResolve();
break;
case response.startsWith("* OK") || response.match(/CAPABILITY.+STARTTLS/):
socket_.write("a001 STARTTLS\r\n");
break;
case response.startsWith("220") || response.includes("ESMTP"):
socket_.write(`EHLO ${monitor.hostname}\r\n`);
break;
case response.includes("250-STARTTLS"):
socket_.write("STARTTLS\r\n");
break;
case response_.includes("<proceed"):
doResolve();
break;
case response_.includes("<starttls"):
socket_.write("<starttls xmlns=\"urn:ietf:params:xml:ns:xmpp-tls\"/>");
break;
case response_.includes("<stream:stream") || response_.includes("</stream:stream>"):
break;
default:
doReject(`Unexpected response: ${response}`);
}
});
socket_.on("error", error => {
log.debug(this.name, `[${monitor.name}] ${error.toString()}`);
reject(error);
});
socket_.setTimeout(1000 * TIMEOUT, onTimeout);
dialogTimeout = setTimeout(onTimeout, 1000 * TIMEOUT);
bannerTimeout = setTimeout(onBannerTimeout, 1000 * 1.5);
});
const reuseSocket = monitor.smtpSecurity === "starttls" ? await preTLS() : {};
// Handle TLS certificate checking for secure/starttls connections
if ([ "secure", "starttls" ].includes(monitor.smtpSecurity) && monitor.isEnabledExpiryNotification()) {
let socket = null;
try {
const options = {
host: monitor.hostname,
port: monitor.port,
servername: monitor.hostname,
...reuseSocket,
};
const tlsInfoObject = await new Promise((resolve, reject) => {
socket = tls.connect(options);
socket.on("secureConnect", () => {
try {
const info = checkCertificate(socket);
resolve(info);
} catch (error) {
reject(error);
}
});
socket.on("error", error => {
reject(error);
});
socket.setTimeout(1000 * TIMEOUT, () => {
reject(new Error("Connection timed out"));
});
});
await monitor.handleTlsInfo(tlsInfoObject);
if (!tlsInfoObject.valid) {
throw new Error("Certificate is invalid");
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(`TLS Connection failed: ${message}`);
} finally {
if (socket && !socket.destroyed) {
socket.end();
}
}
const reuseSocket = monitor.smtpSecurity === "starttls" ? await this.performStartTls(monitor) : {};
socket_ = reuseSocket.socket;
await this.checkTlsCertificate(monitor, reuseSocket);
}
if (socket_ && !socket_.destroyed) {
socket_.end();
}
}
/**
* Perform STARTTLS handshake for various protocols (SMTP, IMAP, XMPP)
* @param {object} monitor Monitor object
* @returns {Promise<{socket: net.Socket}>} Object containing the socket
*/
performStartTls(monitor) {
return new Promise((resolve, reject) => {
let dialogTimeout;
let bannerTimeout;
const socket_ = net.connect(monitor.port, monitor.hostname);
const onTimeout = () => {
log.debug(this.name, `[${monitor.name}] Pre-TLS connection timed out`);
doReject("Connection timed out");
};
const onBannerTimeout = () => {
log.debug(this.name, `[${monitor.name}] Pre-TLS timed out waiting for banner`);
// No banner. Could be a XMPP server?
socket_.write(`<stream:stream to='${monitor.hostname}' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>`);
};
const doResolve = () => {
dialogTimeout && clearTimeout(dialogTimeout);
bannerTimeout && clearTimeout(bannerTimeout);
resolve({ socket: socket_ });
};
const doReject = (error) => {
dialogTimeout && clearTimeout(dialogTimeout);
bannerTimeout && clearTimeout(bannerTimeout);
socket_.end();
reject(error);
};
socket_.on("connect", () => {
log.debug(this.name, `[${monitor.name}] Pre-TLS connection: ${JSON.stringify(socket_)}`);
});
socket_.on("data", data => {
const response = data.toString();
const response_ = response.toLowerCase();
log.debug(this.name, `[${monitor.name}] Pre-TLS response: ${response}`);
clearTimeout(bannerTimeout);
switch (true) {
case response_.includes("start tls") || response_.includes("begin tls"):
doResolve();
break;
case response.startsWith("* OK") || response.match(/CAPABILITY.+STARTTLS/):
socket_.write("a001 STARTTLS\r\n");
break;
case response.startsWith("220") || response.includes("ESMTP"):
socket_.write(`EHLO ${monitor.hostname}\r\n`);
break;
case response.includes("250-STARTTLS"):
socket_.write("STARTTLS\r\n");
break;
case response_.includes("<proceed"):
doResolve();
break;
case response_.includes("<starttls"):
socket_.write("<starttls xmlns=\"urn:ietf:params:xml:ns:xmpp-tls\"/>");
break;
case response_.includes("<stream:stream") || response_.includes("</stream:stream>"):
break;
default:
doReject(`Unexpected response: ${response}`);
}
});
socket_.on("error", error => {
log.debug(this.name, `[${monitor.name}] ${error.toString()}`);
reject(error);
});
socket_.setTimeout(1000 * TIMEOUT, onTimeout);
dialogTimeout = setTimeout(onTimeout, 1000 * TIMEOUT);
bannerTimeout = setTimeout(onBannerTimeout, 1000 * 1.5);
});
}
/**
* Check TLS certificate validity
* @param {object} monitor Monitor object
* @param {object} reuseSocket Socket to reuse for STARTTLS
* @returns {Promise<void>}
*/
async checkTlsCertificate(monitor, reuseSocket) {
let socket = null;
try {
const options = {
host: monitor.hostname,
port: monitor.port,
servername: monitor.hostname,
...reuseSocket,
};
const tlsInfoObject = await new Promise((resolve, reject) => {
socket = tls.connect(options);
socket.on("secureConnect", () => {
try {
const info = checkCertificate(socket);
resolve(info);
} catch (error) {
reject(error);
}
});
socket.on("error", error => {
reject(error);
});
socket.setTimeout(1000 * TIMEOUT, () => {
reject(new Error("Connection timed out"));
});
});
await monitor.handleTlsInfo(tlsInfoObject);
if (!tlsInfoObject.valid) {
throw new Error("Certificate is invalid");
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(`TLS Connection failed: ${message}`);
} finally {
if (socket && !socket.destroyed) {
socket.end();
}
}
}
/**
* Check for expected TLS alert (for mTLS verification)
* @param {object} monitor Monitor object
* @param {object} heartbeat Heartbeat object
* @param {string} expectedTlsAlert Expected TLS alert name
* @returns {Promise<void>}
*/
async checkTlsAlert(monitor, heartbeat, expectedTlsAlert) {
const timeout = monitor.timeout * 1000 || 30000;
const startTime = Date.now();
const options = {
host: monitor.hostname,
port: monitor.port || 443,
servername: monitor.hostname,
rejectUnauthorized: !monitor.getIgnoreTls(),
timeout: timeout,
};
// Add client certificate if provided (for mTLS testing with cert)
if (monitor.tlsCert && monitor.tlsKey) {
options.cert = monitor.tlsCert;
options.key = monitor.tlsKey;
if (monitor.tlsCa) {
options.ca = monitor.tlsCa;
}
}
const result = await this.attemptTlsConnection(monitor, options, startTime, timeout);
heartbeat.ping = result.responseTime;
// Handle TLS info for certificate expiry monitoring
if (result.tlsInfo && monitor.isEnabledExpiryNotification()) {
await monitor.handleTlsInfo(result.tlsInfo);
}
// Check if we got the expected alert
// Note: Error messages below could be translated, but alert names (e.g., certificate_required)
// are from RFC 8446 spec and should remain in English for consistency with the spec.
if (result.alertName === expectedTlsAlert) {
heartbeat.status = UP;
heartbeat.msg = `TLS alert received as expected: ${result.alertName} (${result.alertNumber})`;
} else if (result.success) {
throw new Error(`Expected TLS alert '${expectedTlsAlert}' but connection succeeded. The server accepted the connection without requiring a client certificate.`);
} else if (result.alertNumber !== null) {
throw new Error(`Expected TLS alert '${expectedTlsAlert}' but received '${result.alertName}' (${result.alertNumber})`);
} else {
throw new Error(`Expected TLS alert '${expectedTlsAlert}' but got unexpected error: ${result.errorMessage}`);
}
}
/**
* Attempt TLS connection and capture result/alert
* @param {object} monitor Monitor object
* @param {object} options TLS connection options
* @param {number} startTime Connection start timestamp
* @param {number} timeout Connection timeout in ms
* @returns {Promise<object>} Connection result with success, responseTime, tlsInfo, alertNumber, alertName, errorMessage
*/
attemptTlsConnection(monitor, options, startTime, timeout) {
return new Promise((resolve, reject) => {
const socket = tls.connect(options);
const timeoutId = setTimeout(() => {
socket.destroy();
reject(new Error("TLS connection timed out"));
}, timeout);
socket.on("secureConnect", () => {
clearTimeout(timeoutId);
const responseTime = Date.now() - startTime;
let tlsInfo = null;
if (monitor.isEnabledExpiryNotification()) {
try {
tlsInfo = checkCertificate(socket);
} catch (e) {
log.debug(this.name, `[${monitor.name}] Error checking certificate: ${e.message}`);
}
}
socket.end();
resolve({
success: true,
responseTime,
tlsInfo,
alertNumber: null,
alertName: null,
});
});
socket.on("error", (error) => {
clearTimeout(timeoutId);
const responseTime = Date.now() - startTime;
const errorMessage = error.message || error.toString();
const alertNumber = parseTlsAlertNumber(errorMessage);
const alertName = alertNumber !== null ? getTlsAlertName(alertNumber) : null;
log.debug(this.name, `[${monitor.name}] TLS error: ${errorMessage}, alert: ${alertNumber} (${alertName})`);
resolve({
success: false,
responseTime,
tlsInfo: null,
alertNumber,
alertName,
errorMessage,
});
});
socket.on("timeout", () => {
clearTimeout(timeoutId);
socket.destroy();
reject(new Error("TLS connection timed out"));
});
});
}
}
module.exports = {
TCPMonitorType,
TLS_ALERT_CODES,
parseTlsAlertNumber,
getTlsAlertName,
};

View File

@ -902,6 +902,7 @@ let needSetup = false;
bean.conditions = JSON.stringify(monitor.conditions);
bean.manual_status = monitor.manual_status;
bean.system_service_name = monitor.system_service_name;
bean.expected_tls_alert = monitor.expectedTlsAlert;
// ping advanced options
bean.ping_numeric = monitor.ping_numeric;

View File

@ -1295,5 +1295,10 @@
"End": "End",
"Show this Maintenance Message on which Status Pages": "Show this Maintenance Message on which Status Pages",
"Endpoint": "Endpoint",
"Details": "Details"
"Details": "Details",
"TLS Alerts": "TLS Alerts",
"Expected TLS Alert": "Expected TLS Alert",
"None (Successful Connection)": "None (Successful Connection)",
"expectedTlsAlertDescription": "Select the TLS alert you expect the server to return. Use {code} to verify mTLS endpoints reject connections without client certificates. See {link} for details.",
"TLS Alert Spec": "RFC 8446"
}

View File

@ -400,6 +400,35 @@
</select>
</div>
<!-- Expected TLS Alert (for TCP monitor mTLS verification) -->
<template v-if="monitor.type === 'port'">
<div class="my-3">
<label for="expected_tls_alert" class="form-label">{{ $t("Expected TLS Alert") }}</label>
<select id="expected_tls_alert" v-model="monitor.expectedTlsAlert" class="form-select">
<option value="none">{{ $t("None (Successful Connection)") }}</option>
<!-- TLS alert names are from RFC 8446 spec and should NOT be translated -->
<optgroup :label="$t('TLS Alerts')">
<option value="certificate_required">certificate_required (116)</option>
<option value="bad_certificate">bad_certificate (42)</option>
<option value="certificate_unknown">certificate_unknown (46)</option>
<option value="unknown_ca">unknown_ca (48)</option>
<option value="access_denied">access_denied (49)</option>
<option value="handshake_failure">handshake_failure (40)</option>
<option value="certificate_expired">certificate_expired (45)</option>
<option value="certificate_revoked">certificate_revoked (44)</option>
</optgroup>
</select>
<i18n-t tag="div" class="form-text" keypath="expectedTlsAlertDescription">
<template #code>
<code>certificate_required</code>
</template>
<template #link>
<a href="https://www.rfc-editor.org/rfc/rfc8446#section-6.2" target="_blank" rel="noopener noreferrer">{{ $t("TLS Alert Spec") }}</a>
</template>
</i18n-t>
</div>
</template>
<!-- Json Query -->
<!-- For Json Query / SNMP -->
<div v-if="monitor.type === 'json-query' || monitor.type === 'snmp'" class="my-3">

View File

@ -220,4 +220,52 @@ describe("TCP Monitor", () => {
}, heartbeat);
assert.strictEqual(heartbeat.status, UP);
});
// TLS Alert checking tests
test("check() rejects when expecting TLS alert but connection succeeds", async () => {
const tcpMonitor = new TCPMonitorType();
const monitor = {
hostname: "google.com",
port: 443,
expected_tls_alert: "certificate_required",
timeout: 10,
isEnabledExpiryNotification: () => false,
getIgnoreTls: () => false,
};
const heartbeat = {
msg: "",
status: PENDING,
};
// Retry with backoff for external service reliability, expecting rejection
await retryExternalService(async () => {
await assert.rejects(
tcpMonitor.check(monitor, heartbeat, {}),
/Expected TLS alert 'certificate_required' but connection succeeded/
);
}, heartbeat);
});
test("parseTlsAlertNumber() extracts alert number from error message", async () => {
const { parseTlsAlertNumber } = require("../../../server/monitor-types/tcp");
// Test various error message formats
assert.strictEqual(parseTlsAlertNumber("alert number 116"), 116);
assert.strictEqual(parseTlsAlertNumber("SSL alert number 42"), 42);
assert.strictEqual(parseTlsAlertNumber("TLS alert number 48"), 48);
assert.strictEqual(parseTlsAlertNumber("no alert here"), null);
assert.strictEqual(parseTlsAlertNumber(""), null);
});
test("getTlsAlertName() returns correct alert name for known codes", async () => {
const { getTlsAlertName } = require("../../../server/monitor-types/tcp");
assert.strictEqual(getTlsAlertName(116), "certificate_required");
assert.strictEqual(getTlsAlertName(42), "bad_certificate");
assert.strictEqual(getTlsAlertName(48), "unknown_ca");
assert.strictEqual(getTlsAlertName(40), "handshake_failure");
assert.strictEqual(getTlsAlertName(999), "unknown_alert_999");
});
});