mirror of
https://github.com/koichixD/uptime-kuma.git
synced 2026-09-04 17:23:21 +00:00
refactor: integrate TLS alert checking into TCP monitor
Per CommanderStorm's feedback, instead of creating a separate TLS monitor type, add the TLS alert checking functionality directly to the existing TCP monitor. Changes: - Add TLS_ALERT_CODES, parseTlsAlertNumber(), getTlsAlertName() to tcp.js - Add checkTlsAlert() method to TCPMonitorType for mTLS verification - Add 'Expected TLS Alert' dropdown to TCP monitor UI - Remove separate TLS monitor type (tls.js) This allows users to verify mTLS endpoints reject connections without client certificates by expecting specific TLS alerts like 'certificate_required'. Closes #5837
This commit is contained in:
@@ -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,15 @@ 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
|
||||
try {
|
||||
const resp = await tcping(monitor.hostname, monitor.port);
|
||||
heartbeat.ping = resp;
|
||||
@@ -176,8 +248,115 @@ class TCPMonitorType extends MonitorType {
|
||||
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 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;
|
||||
|
||||
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("Connection timed out"));
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
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`);
|
||||
} else if (result.alertNumber !== null) {
|
||||
throw new Error(`Expected TLS alert '${expectedTlsAlert}' but got '${result.alertName}' (${result.alertNumber})`);
|
||||
} else {
|
||||
throw new Error(`Expected TLS alert '${expectedTlsAlert}' but got error: ${result.errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TCPMonitorType,
|
||||
TLS_ALERT_CODES,
|
||||
};
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -57,9 +57,6 @@
|
||||
<option value="websocket-upgrade">
|
||||
Websocket Upgrade
|
||||
</option>
|
||||
<option value="tls">
|
||||
{{ $t("TLS") }}
|
||||
</option>
|
||||
</optgroup>
|
||||
|
||||
<optgroup :label="$t('Passive Monitor Type')">
|
||||
@@ -329,7 +326,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' || monitor.type === 'tls'" 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'" class="my-3">
|
||||
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
|
||||
<input
|
||||
id="hostname"
|
||||
@@ -350,7 +347,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' || monitor.type === 'tls'" 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'" 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>
|
||||
@@ -403,8 +400,8 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- TLS Monitor Type -->
|
||||
<template v-if="monitor.type === 'tls'">
|
||||
<!-- 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">
|
||||
@@ -424,13 +421,6 @@
|
||||
{{ $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 -->
|
||||
|
||||
Reference in New Issue
Block a user