From 054d2f7aab566fd56ccfc6df9164b6834bf59a47 Mon Sep 17 00:00:00 2001
From: Jacques ROUSSEL <jacques.roussel@rouaje.com>
Date: Tue, 25 Mar 2025 15:41:20 +0100
Subject: [PATCH] feat: Refacto `TCP tls` monitor

---
 server/model/monitor.js       | 44 ------------------
 server/monitor-types/tcp.js   | 73 +++++++++++++++++++++++++++++
 server/uptime-kuma-server.js  |  2 +
 test/backend-test/test-tcp.js | 87 +++++++++++++++++++++++++++++++++++
 4 files changed, 162 insertions(+), 44 deletions(-)
 create mode 100644 server/monitor-types/tcp.js
 create mode 100644 test/backend-test/test-tcp.js

diff --git a/server/model/monitor.js b/server/model/monitor.js
index 08f08d868..5377f75f9 100644
--- a/server/model/monitor.js
+++ b/server/model/monitor.js
@@ -612,50 +612,6 @@ class Monitor extends BeanModel {
 
                     }
 
-                } else if (this.type === "port") {
-                    bean.ping = await tcping(this.hostname, this.port);
-                    if (this.isEnabledExpiryNotification()) {
-                        const host = this.hostname;
-                        const port = this.port || 443;
-                        try {
-                            const options = {
-                                host,
-                                port,
-                                servername: host,
-                            };
-
-                            // Convert TLS connect to a Promise and await it
-                            const tlsInfoObject = await new Promise((resolve, reject) => {
-                                const socket = tls.connect(options);
-
-                                socket.on("secureConnect", () => {
-                                    try {
-                                        const info = checkCertificate(socket);
-                                        socket.end();
-                                        resolve(info);
-                                    } catch (error) {
-                                        socket.end();
-                                        reject(error);
-                                    }
-                                });
-
-                                socket.on("error", (error) => {
-                                    reject(error);
-                                });
-
-                                socket.setTimeout(10000, () => {
-                                    socket.end();
-                                    reject(new Error("Connection timed out"));
-                                });
-                            });
-                            await this.handleTlsInfo(tlsInfoObject);
-                        } catch (error) {
-                            console.log("Retrieve certificate failed");
-                        }
-                    }
-                    bean.msg = "";
-                    bean.status = UP;
-
                 } else if (this.type === "ping") {
                     bean.ping = await ping(this.hostname, this.packetSize);
                     bean.msg = "";
diff --git a/server/monitor-types/tcp.js b/server/monitor-types/tcp.js
new file mode 100644
index 000000000..815be4db2
--- /dev/null
+++ b/server/monitor-types/tcp.js
@@ -0,0 +1,73 @@
+const { MonitorType } = require("./monitor-type");
+const { UP, DOWN, log, evaluateJsonQuery } = require("../../src/util");
+const { tcping, checkCertificate } = require("../util-server");
+const tls = require("tls");
+
+class TCPMonitorType extends MonitorType {
+    name = "port";
+
+    /**
+     * @inheritdoc
+     */
+    async check(monitor, heartbeat, _server) {
+        try {
+            heartbeat.ping = await tcping(monitor.hostname, monitor.port);
+            heartbeat.msg = "";
+            heartbeat.status = UP;
+        } catch (error) {
+            heartbeat.status = DOWN;
+            heartbeat.msg = "Connection failed";
+            return;
+        }
+
+        if (monitor.isEnabledExpiryNotification()) {
+            let socket = null;
+            try {
+                const options = {
+                    host: monitor.hostname,
+                    port: monitor.port,
+                    servername: monitor.hostname,
+                };
+
+                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(10000, () => {
+                        reject(new Error("Connection timed out"));
+                    });
+                });
+
+                await monitor.handleTlsInfo(tlsInfoObject);
+                if (!tlsInfoObject.valid) {
+                    heartbeat.status = DOWN;
+                    heartbeat.msg = "Certificate is invalid";
+                }
+            } catch (error) {
+                heartbeat.status = DOWN;
+                heartbeat.msg = "Connection failed";
+            } finally {
+                if (socket && !socket.destroyed) {
+                    socket.end();
+                }
+            }
+        }
+    }
+}
+
+module.exports = {
+    TCPMonitorType,
+};
+
diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js
index 062f098d7..b0fe4268e 100644
--- a/server/uptime-kuma-server.js
+++ b/server/uptime-kuma-server.js
@@ -116,6 +116,7 @@ class UptimeKumaServer {
         UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType();
         UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
         UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
+        UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType();
 
         // Allow all CORS origins (polling) in development
         let cors = undefined;
@@ -554,4 +555,5 @@ const { MqttMonitorType } = require("./monitor-types/mqtt");
 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 Monitor = require("./model/monitor");
diff --git a/test/backend-test/test-tcp.js b/test/backend-test/test-tcp.js
new file mode 100644
index 000000000..fd5d7dae9
--- /dev/null
+++ b/test/backend-test/test-tcp.js
@@ -0,0 +1,87 @@
+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");
+
+describe("TCP Monitor", () => {
+    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("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);
+            assert.strictEqual(heartbeat.msg, "");
+        } finally {
+            server.close();
+        }
+    });
+
+    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("TCP server with expired or invalid TLS certificate", async (t) => {
+        const tcpMonitor = new TCPMonitorType();
+        const monitor = {
+            hostname: "expired.badssl.com",
+            port: 443,
+            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", "Connection failed"].includes(heartbeat.msg));
+    });
+});