mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-12-05 21:10:25 -08:00
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:
@@ -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
158
server/monitor-types/tcp.js
Normal 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,
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
182
test/backend-test/test-tcp.js
Normal file
182
test/backend-test/test-tcp.js
Normal 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)));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user