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:
parent
11a2b8ed9b
commit
7920057207
11
db/knex_migrations/2026-01-05-0000-add-tls-monitor.js
Normal file
11
db/knex_migrations/2026-01-05-0000-add-tls-monitor.js
Normal 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");
|
||||
});
|
||||
};
|
||||
@ -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
210
server/monitor-types/tls.js
Normal 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,
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user