From 3f7d4ef7d68c7f9f41823b3598d713c616bcaab8 Mon Sep 17 00:00:00 2001 From: Jacques ROUSSEL <jacques.roussel@rouaje.com> Date: Tue, 4 Mar 2025 11:57:04 +0100 Subject: [PATCH 1/3] feat: add `TCP tls` monitor --- server/monitor-types/tls.js | 77 ++++++++++++++++++++++++++++++++++++ server/uptime-kuma-server.js | 2 + src/pages/EditMonitor.vue | 9 +++-- 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 server/monitor-types/tls.js diff --git a/server/monitor-types/tls.js b/server/monitor-types/tls.js new file mode 100644 index 000000000..ea9b239ed --- /dev/null +++ b/server/monitor-types/tls.js @@ -0,0 +1,77 @@ +const { MonitorType } = require("./monitor-type"); +const { UP, DOWN } = require("../../src/util"); +const { checkCertificate, setting, setSetting } = require("../util-server"); +const tls = require("tls"); + +class TlsCertificateMonitorType extends MonitorType { + name = "tlsCheck"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, server) { + const host = monitor.hostname; + const port = monitor.port || 443; + let notifyDays = await setting("tlsExpiryNotifyDays"); + if (notifyDays == null || !Array.isArray(notifyDays)) { + // Reset Default + await setSetting("tlsExpiryNotifyDays", [ 7, 14, 21 ], "general"); + notifyDays = [ 7, 14, 21 ]; + } + + 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")); + }); + }); + + const certInfo = tlsInfoObject.certInfo; + + await monitor.updateTlsInfo(tlsInfoObject); + const alertDays = notifyDays.filter(targetDays => targetDays >= certInfo.daysRemaining); + + if (alertDays.length === 0) { + heartbeat.status = UP; + heartbeat.msg = ""; + } else { + const alertDay = Math.min(...alertDays); + heartbeat.status = DOWN; + heartbeat.msg = `Certificate expires in less thant ${alertDay} days`; + } + } catch (error) { + heartbeat.status = DOWN; + heartbeat.msg = `Error checking SSL certificate: ${error.message}`; + } + } +} + +module.exports = { + TlsCertificateMonitorType, +}; + diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 062f098d7..324a6c0ed 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["tls"] = new TlsCertificateMonitorType(); // 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 { TlsCertificateMonitorType } = require("./monitor-types/tls"); const Monitor = require("./model/monitor"); diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index a83f91cab..70cec0325 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -21,6 +21,9 @@ <option value="port"> TCP Port </option> + <option value="tls"> + TCP Port (tls check) + </option> <option value="ping"> Ping </option> @@ -282,7 +285,7 @@ <!-- Hostname --> <!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP 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 === 'snmp'" 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 === 'snmp' || monitor.type === 'tls'" class="my-3"> <label for="hostname" class="form-label">{{ $t("Hostname") }}</label> <input id="hostname" @@ -297,7 +300,7 @@ <!-- Port --> <!-- For TCP Port / Steam / MQTT / Radius Type / SNMP --> - <div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'snmp'" class="my-3"> + <div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'snmp' || monitor.type === 'tls'" 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> @@ -612,7 +615,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 === 'tls' " 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") }} From 2e2acbd48af4aec981a7d47c9d52b1e80d26f86d Mon Sep 17 00:00:00 2001 From: Jacques ROUSSEL <jacques.roussel@rouaje.com> Date: Mon, 17 Mar 2025 15:19:01 +0100 Subject: [PATCH 2/3] feat: add `TCP tls` monitor (new proposition) --- server/model/monitor.js | 40 +++++++++++++++++++ server/monitor-types/tls.js | 77 ------------------------------------ server/uptime-kuma-server.js | 2 - src/pages/EditMonitor.vue | 9 ++--- 4 files changed, 43 insertions(+), 85 deletions(-) delete mode 100644 server/monitor-types/tls.js diff --git a/server/model/monitor.js b/server/model/monitor.js index 5999d93e7..08f08d868 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -24,6 +24,7 @@ const { CookieJar } = require("tough-cookie"); const { HttpsCookieAgent } = require("http-cookie-agent/http"); const https = require("https"); const http = require("http"); +const tls = require("tls"); const rootCertificates = rootCertificatesFingerprints(); @@ -613,6 +614,45 @@ 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; diff --git a/server/monitor-types/tls.js b/server/monitor-types/tls.js deleted file mode 100644 index ea9b239ed..000000000 --- a/server/monitor-types/tls.js +++ /dev/null @@ -1,77 +0,0 @@ -const { MonitorType } = require("./monitor-type"); -const { UP, DOWN } = require("../../src/util"); -const { checkCertificate, setting, setSetting } = require("../util-server"); -const tls = require("tls"); - -class TlsCertificateMonitorType extends MonitorType { - name = "tlsCheck"; - - /** - * @inheritdoc - */ - async check(monitor, heartbeat, server) { - const host = monitor.hostname; - const port = monitor.port || 443; - let notifyDays = await setting("tlsExpiryNotifyDays"); - if (notifyDays == null || !Array.isArray(notifyDays)) { - // Reset Default - await setSetting("tlsExpiryNotifyDays", [ 7, 14, 21 ], "general"); - notifyDays = [ 7, 14, 21 ]; - } - - 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")); - }); - }); - - const certInfo = tlsInfoObject.certInfo; - - await monitor.updateTlsInfo(tlsInfoObject); - const alertDays = notifyDays.filter(targetDays => targetDays >= certInfo.daysRemaining); - - if (alertDays.length === 0) { - heartbeat.status = UP; - heartbeat.msg = ""; - } else { - const alertDay = Math.min(...alertDays); - heartbeat.status = DOWN; - heartbeat.msg = `Certificate expires in less thant ${alertDay} days`; - } - } catch (error) { - heartbeat.status = DOWN; - heartbeat.msg = `Error checking SSL certificate: ${error.message}`; - } - } -} - -module.exports = { - TlsCertificateMonitorType, -}; - diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 324a6c0ed..062f098d7 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -116,7 +116,6 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType(); UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType(); UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType(); - UptimeKumaServer.monitorTypeList["tls"] = new TlsCertificateMonitorType(); // Allow all CORS origins (polling) in development let cors = undefined; @@ -555,5 +554,4 @@ 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 { TlsCertificateMonitorType } = require("./monitor-types/tls"); const Monitor = require("./model/monitor"); diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 70cec0325..a8b481de3 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -21,9 +21,6 @@ <option value="port"> TCP Port </option> - <option value="tls"> - TCP Port (tls check) - </option> <option value="ping"> Ping </option> @@ -285,7 +282,7 @@ <!-- Hostname --> <!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP 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 === 'snmp' || 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 === 'snmp'" class="my-3"> <label for="hostname" class="form-label">{{ $t("Hostname") }}</label> <input id="hostname" @@ -300,7 +297,7 @@ <!-- Port --> <!-- For TCP Port / Steam / MQTT / Radius Type / SNMP --> - <div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'snmp' || 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 === 'snmp'" 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> @@ -615,7 +612,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' || monitor.type === 'tls' " 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' " 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") }} 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 3/3] 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)); + }); +});