refactor: address CommanderStorm's review feedback

- Use i18n-t for description with code tag and RFC 8446 spec link
- Add comment that TLS alert names are from spec (not translatable)
- Refactor TCP monitor into smaller functions:
  - checkTcp() for standard TCP connectivity check
  - performStartTls() for STARTTLS handshake
  - checkTlsCertificate() for TLS certificate validation
  - attemptTlsConnection() for TLS connection with alert capture
- Improve error messages with more context
This commit is contained in:
mkdev11 2026-01-06 06:09:21 +02:00
parent 327b51f304
commit dc1e96f7d1
2 changed files with 187 additions and 142 deletions

View File

@ -113,6 +113,16 @@ class TCPMonitorType extends MonitorType {
}
// 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;
@ -124,124 +134,11 @@ 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) {
@ -249,6 +146,136 @@ class TCPMonitorType extends MonitorType {
}
}
/**
* 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
@ -277,12 +304,43 @@ class TCPMonitorType extends MonitorType {
}
}
const result = await new Promise((resolve, reject) => {
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
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("Connection timed out"));
reject(new Error("TLS connection timed out"));
}, timeout);
socket.on("secureConnect", () => {
@ -331,28 +389,9 @@ class TCPMonitorType extends MonitorType {
socket.on("timeout", () => {
clearTimeout(timeoutId);
socket.destroy();
reject(new Error("Connection timed out"));
reject(new Error("TLS 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}`);
}
}
}

View File

@ -406,6 +406,7 @@
<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>
@ -417,9 +418,14 @@
<option value="certificate_revoked">certificate_revoked (44)</option>
</optgroup>
</select>
<div class="form-text">
{{ $t("expectedTlsAlertDescription") }}
</div>
<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>