feat: add SSL/STARTTLS option and certificate monitoring to TCP Port monitor (#6401)

Co-authored-by: Jacques ROUSSEL <jacques.roussel@rouaje.com>
Co-authored-by: rouja <jacques0roussel@gmail.com>
Co-authored-by: Nelson Chan <3271800+chakflying@users.noreply.github.com>
Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
This commit is contained in:
Shaan
2025-11-24 11:30:13 +06:00
committed by GitHub
parent 082e4b9712
commit 0eebe86f81
6 changed files with 353 additions and 35 deletions

View File

@@ -8,7 +8,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MI
PING_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT,
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT
} = require("../../src/util");
const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
} = require("../util-server");
const { R } = require("redbean-node");
@@ -621,11 +621,6 @@ class Monitor extends BeanModel {
}
} else if (this.type === "port") {
bean.ping = await tcping(this.hostname, this.port);
bean.msg = "";
bean.status = UP;
} else if (this.type === "ping") {
bean.ping = await ping(this.hostname, this.ping_count, "", this.ping_numeric, this.packetSize, this.timeout, this.ping_per_request_timeout);
bean.msg = "";

158
server/monitor-types/tcp.js Normal file
View File

@@ -0,0 +1,158 @@
const { MonitorType } = require("./monitor-type");
const { UP, DOWN, PING_GLOBAL_TIMEOUT_DEFAULT: TIMEOUT, log } = require("../../src/util");
const { checkCertificate } = require("../util-server");
const tls = require("tls");
const net = require("net");
const tcpp = require("tcp-ping");
/**
* Send TCP request to specified hostname and port
* @param {string} hostname Hostname / address of machine
* @param {number} port TCP port to test
* @returns {Promise<number>} Maximum time in ms rounded to nearest integer
*/
const tcping = (hostname, port) => {
return new Promise((resolve, reject) => {
tcpp.ping(
{
address: hostname,
port: port,
attempts: 1,
},
(err, data) => {
if (err) {
reject(err);
}
if (data.results.length >= 1 && data.results[0].err) {
reject(data.results[0].err);
}
resolve(Math.round(data.max));
}
);
});
};
class TCPMonitorType extends MonitorType {
name = "port";
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
try {
const resp = await tcping(monitor.hostname, monitor.port);
heartbeat.ping = resp;
heartbeat.msg = `${resp} ms`;
heartbeat.status = UP;
} catch {
heartbeat.status = DOWN;
heartbeat.msg = "Connection failed";
return;
}
let socket_;
const preTLS = () =>
new Promise((resolve, reject) => {
let timeout;
socket_ = net.connect(monitor.port, monitor.hostname);
const onTimeout = () => {
log.debug(this.name, `[${monitor.name}] Pre-TLS connection timed out`);
reject("Connection timed out");
};
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}`);
switch (true) {
case response_.includes("start tls") || response_.includes("begin tls"):
timeout && clearTimeout(timeout);
resolve({ socket: socket_ });
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;
default:
reject(`Unexpected response: ${response}`);
}
});
socket_.on("error", error => {
log.debug(this.name, `[${monitor.name}] ${error.toString()}`);
reject(error);
});
socket_.setTimeout(1000 * TIMEOUT, onTimeout);
timeout = setTimeout(onTimeout, 1000 * TIMEOUT);
});
const reuseSocket = monitor.smtpSecurity === "starttls" ? await preTLS() : {};
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) {
heartbeat.status = DOWN;
heartbeat.msg = "Certificate is invalid";
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
heartbeat.status = DOWN;
heartbeat.msg = `TLS Connection failed: ${message}`;
} finally {
if (socket && !socket.destroyed) {
socket.end();
}
}
}
if (socket_ && !socket_.destroyed) {
socket_.end();
}
}
}
module.exports = {
TCPMonitorType,
};

View File

@@ -118,6 +118,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType();
UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType();
UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType();
UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType();
@@ -560,6 +561,7 @@ const { GroupMonitorType } = require("./monitor-types/group");
const { SNMPMonitorType } = require("./monitor-types/snmp");
const { MongodbMonitorType } = require("./monitor-types/mongodb");
const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
const { TCPMonitorType } = require("./monitor-types/tcp.js");
const { ManualMonitorType } = require("./monitor-types/manual");
const { RedisMonitorType } = require("./monitor-types/redis");
const Monitor = require("./model/monitor");

View File

@@ -1,4 +1,3 @@
const tcpp = require("tcp-ping");
const ping = require("@louislam/ping");
const { R } = require("redbean-node");
const {
@@ -98,33 +97,6 @@ exports.getOidcTokenClientCredentials = async (tokenEndpoint, clientId, clientSe
return await client.grant(grantParams);
};
/**
* Send TCP request to specified hostname and port
* @param {string} hostname Hostname / address of machine
* @param {number} port TCP port to test
* @returns {Promise<number>} Maximum time in ms rounded to nearest integer
*/
exports.tcping = function (hostname, port) {
return new Promise((resolve, reject) => {
tcpp.ping({
address: hostname,
port: port,
attempts: 1,
}, function (err, data) {
if (err) {
reject(err);
}
if (data.results.length >= 1 && data.results[0].err) {
reject(data.results[0].err);
}
resolve(Math.round(data.max));
});
});
};
/**
* Ping the specified machine
* @param {string} destAddr Hostname / IP address of machine to ping

View File

@@ -366,6 +366,15 @@
</div>
</div>
<div v-if="monitor.type === 'port'" class="my-3">
<label for="port_security" class="form-label">{{ $t("SSL/TLS") }}</label>
<select id="port_security" v-model="monitor.smtpSecurity" class="form-select">
<option value="nostarttls">None</option>
<option value="secure">SSL</option>
<option value="starttls">STARTTLS</option>
</select>
</div>
<!-- Json Query -->
<!-- For Json Query / SNMP -->
<div v-if="monitor.type === 'json-query' || monitor.type === 'snmp'" class="my-3">
@@ -671,7 +680,7 @@
<h2 v-if="monitor.type !== 'push'" class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' " class="my-3 form-check" :title="monitor.ignoreTls ? $t('ignoredTLSError') : ''">
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || (monitor.type === 'port' && ['starttls', 'secure'].includes(monitor.smtpSecurity))" class="my-3 form-check" :title="monitor.ignoreTls ? $t('ignoredTLSError') : ''">
<input id="expiry-notification" v-model="monitor.expiryNotification" class="form-check-input" type="checkbox" :disabled="monitor.ignoreTls">
<label class="form-check-label" for="expiry-notification">
{{ $t("Certificate Expiry Notification") }}

View File

@@ -0,0 +1,182 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const { TCPMonitorType } = require("../../server/monitor-types/tcp");
const { UP, DOWN, PENDING } = require("../../src/util");
const net = require("net");
/**
* Test suite for TCP Monitor functionality
* This test suite checks the behavior of the TCPMonitorType class
* under different network connection scenarios.
*/
describe("TCP Monitor", () => {
/**
* Creates a TCP server on a specified port
* @param {number} port - The port number to listen on
* @returns {Promise<net.Server>} A promise that resolves with the created server
*/
async function createTCPServer(port) {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(port, () => {
resolve(server);
});
server.on("error", err => {
reject(err);
});
});
}
/**
* Test case to verify TCP monitor works when a server is running
* Checks that the monitor correctly identifies an active TCP server
*/
test("TCP server is running", async () => {
const port = 12345;
const server = await createTCPServer(port);
try {
const tcpMonitor = new TCPMonitorType();
const monitor = {
hostname: "localhost",
port: port,
isEnabledExpiryNotification: () => false,
};
const heartbeat = {
msg: "",
status: PENDING,
};
await tcpMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP);
} finally {
server.close();
}
});
/**
* Test case to verify TCP monitor handles non-running servers
* Checks that the monitor correctly identifies an inactive TCP server
*/
test("TCP server is not running", async () => {
const tcpMonitor = new TCPMonitorType();
const monitor = {
hostname: "localhost",
port: 54321,
isEnabledExpiryNotification: () => false,
};
const heartbeat = {
msg: "",
status: PENDING,
};
await tcpMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, DOWN);
});
/**
* Test case to verify TCP monitor handles servers with expired or invalid TLS certificates
* Checks that the monitor correctly identifies TLS certificate issues
*/
test("TCP server with expired or invalid TLS certificate", async t => {
const tcpMonitor = new TCPMonitorType();
const monitor = {
hostname: "expired.badssl.com",
port: 443,
smtpSecurity: "secure",
isEnabledExpiryNotification: () => true,
handleTlsInfo: async tlsInfo => {
return tlsInfo;
},
};
const heartbeat = {
msg: "",
status: PENDING,
};
await tcpMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, DOWN);
assert([ "Certificate is invalid", "TLS Connection failed:" ].some(prefix => heartbeat.msg.startsWith(prefix)));
});
test("TCP server with valid TLS certificate (SSL)", async t => {
const tcpMonitor = new TCPMonitorType();
const monitor = {
hostname: "smtp.gmail.com",
port: 465,
smtpSecurity: "secure",
isEnabledExpiryNotification: () => true,
handleTlsInfo: async tlsInfo => {
return tlsInfo;
},
};
const heartbeat = {
msg: "",
status: PENDING,
};
await tcpMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP);
});
test("TCP server with valid TLS certificate (STARTTLS)", async t => {
const tcpMonitor = new TCPMonitorType();
const monitor = {
hostname: "smtp.gmail.com",
port: 587,
smtpSecurity: "starttls",
isEnabledExpiryNotification: () => true,
handleTlsInfo: async tlsInfo => {
return tlsInfo;
},
};
const heartbeat = {
msg: "",
status: PENDING,
};
await tcpMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP);
});
test("TCP server with valid but name mismatching TLS certificate (STARTTLS)", async t => {
const tcpMonitor = new TCPMonitorType();
const monitor = {
hostname: "wr-in-f108.1e100.net",
port: 587,
smtpSecurity: "starttls",
isEnabledExpiryNotification: () => true,
handleTlsInfo: async tlsInfo => {
return tlsInfo;
},
};
const heartbeat = {
msg: "",
status: PENDING,
};
await tcpMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, DOWN);
assert([ "does not match certificate" ].some(msg => heartbeat.msg.includes(msg)));
});
});