feat: add TLS monitor type for mTLS endpoint monitoring

Add a new TLS monitor type that allows monitoring mTLS endpoints to verify
they properly reject connections without client certificates.

Features:
- New TLS monitor type with hostname and port configuration
- Expected TLS Alert dropdown to specify which TLS alert to expect
- Support for certificate_required (116) alert for mTLS verification
- Optional certificate expiry monitoring when connection succeeds
- Ignore TLS errors option

Closes #5837
This commit is contained in:
mkdev11 2026-01-05 14:27:36 +02:00
parent 11a2b8ed9b
commit 7920057207
6 changed files with 264 additions and 3 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(),

210
server/monitor-types/tls.js Normal file
View File

@ -0,0 +1,210 @@
const { MonitorType } = require("./monitor-type");
const { UP, log } = require("../../src/util");
const { checkCertificate } = require("../util-server");
const tls = require("tls");
/**
* 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) {
// Match patterns like "SSL alert number 116" or "alert number 116"
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}`;
}
class TLSMonitorType extends MonitorType {
name = "tls";
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
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 expectedTlsAlert = monitor.expected_tls_alert;
try {
const result = await new Promise((resolve, reject) => {
const socket = tls.connect(options);
const timeoutId = setTimeout(() => {
socket.destroy();
reject(new Error("Connection timed out"));
}, timeout);
socket.on("secureConnect", () => {
clearTimeout(timeoutId);
const responseTime = Date.now() - startTime;
// Connection succeeded - no TLS alert
let tlsInfo = null;
if (monitor.isEnabledExpiryNotification()) {
try {
tlsInfo = checkCertificate(socket);
} catch (e) {
log.debug("tls", `[${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();
// Try to parse TLS alert from error
const alertNumber = parseTlsAlertNumber(errorMessage);
const alertName = alertNumber !== null ? getTlsAlertName(alertNumber) : null;
log.debug("tls", `[${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("Connection timed out"));
});
});
heartbeat.ping = result.responseTime;
// Handle TLS info for certificate expiry monitoring
if (result.tlsInfo && monitor.isEnabledExpiryNotification()) {
await monitor.handleTlsInfo(result.tlsInfo);
}
// Determine if the result matches expectations
if (expectedTlsAlert && expectedTlsAlert !== "none") {
// User expects a specific TLS alert
if (result.alertName === expectedTlsAlert) {
// Got the expected alert - this is UP (server correctly rejects)
heartbeat.status = UP;
heartbeat.msg = `TLS alert received as expected: ${result.alertName} (${result.alertNumber})`;
} else if (result.success) {
// Connection succeeded but we expected an alert
throw new Error(`Expected TLS alert '${expectedTlsAlert}' but connection succeeded`);
} else if (result.alertNumber !== null) {
// Got a different alert than expected
throw new Error(`Expected TLS alert '${expectedTlsAlert}' but got '${result.alertName}' (${result.alertNumber})`);
} else {
// Connection failed without a TLS alert
throw new Error(`Expected TLS alert '${expectedTlsAlert}' but got error: ${result.errorMessage}`);
}
} else {
// User expects successful connection (no alert)
if (result.success) {
heartbeat.status = UP;
heartbeat.msg = `TLS connection successful (${result.responseTime} ms)`;
// Check certificate validity if enabled
if (result.tlsInfo && !result.tlsInfo.valid && !monitor.getIgnoreTls()) {
throw new Error("Certificate is invalid");
}
} else if (result.alertNumber !== null) {
throw new Error(`TLS alert received: ${result.alertName} (${result.alertNumber})`);
} else {
throw new Error(`TLS connection failed: ${result.errorMessage}`);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(message);
}
}
}
module.exports = {
TLSMonitorType,
TLS_ALERT_CODES,
};

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

@ -57,6 +57,9 @@
<option value="websocket-upgrade">
Websocket Upgrade
</option>
<option value="tls">
{{ $t("TLS") }}
</option>
</optgroup>
<optgroup :label="$t('Passive Monitor Type')">
@ -326,7 +329,7 @@
<!-- Hostname -->
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP / SMTP / SIP Options only -->
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'smtp' || monitor.type === 'snmp' || monitor.type ==='sip-options'" class="my-3">
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'smtp' || monitor.type === 'snmp' || monitor.type ==='sip-options' || monitor.type === 'tls'" class="my-3">
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
<input
id="hostname"
@ -347,7 +350,7 @@
<!-- Port -->
<!-- For TCP Port / Steam / MQTT / Radius Type / SNMP / SIP Options -->
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'smtp' || monitor.type === 'snmp' || monitor.type === 'sip-options'" class="my-3">
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'smtp' || monitor.type === 'snmp' || monitor.type === 'sip-options' || monitor.type === 'tls'" class="my-3">
<label for="port" class="form-label">{{ $t("Port") }}</label>
<input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
</div>
@ -400,6 +403,36 @@
</select>
</div>
<!-- TLS Monitor Type -->
<template v-if="monitor.type === 'tls'">
<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>
<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>
<div class="form-text">
{{ $t("expectedTlsAlertDescription") }}
</div>
</div>
<div class="my-3 form-check">
<input id="tls-ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox">
<label class="form-check-label" for="tls-ignore-tls">
{{ $t("ignoreTLSErrorGeneral") }}
</label>
</div>
</template>
<!-- Json Query -->
<!-- For Json Query / SNMP -->
<div v-if="monitor.type === 'json-query' || monitor.type === 'snmp'" class="my-3">