diff --git a/package-lock.json b/package-lock.json index 12c1da897..ab56e410d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,7 @@ "node-cloudflared-tunnel": "~1.0.9", "node-fetch-cache": "^5.1.0", "node-radius-utils": "~1.2.0", - "nodemailer": "~6.9.13", + "nodemailer": "~7.0.12", "nostr-tools": "^2.10.4", "notp": "~2.0.3", "openid-client": "^5.4.2", @@ -74,7 +74,7 @@ "prometheus-api-metrics": "~3.2.1", "promisify-child-process": "~4.1.2", "protobufjs": "~7.2.4", - "qs": "~6.10.4", + "qs": "~6.14.1", "radius": "~1.1.4", "redbean-node": "~0.3.0", "redis": "~5.9.0", @@ -14338,9 +14338,9 @@ } }, "node_modules/nodemailer": { - "version": "6.9.16", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", - "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -15657,12 +15657,12 @@ } }, "node_modules/qs": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", - "integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" diff --git a/package.json b/package.json index 95e30049d..987b5af52 100644 --- a/package.json +++ b/package.json @@ -122,8 +122,8 @@ "net-snmp": "^3.11.2", "node-cloudflared-tunnel": "~1.0.9", "node-fetch-cache": "^5.1.0", + "nodemailer": "~7.0.12", "node-radius-utils": "~1.2.0", - "nodemailer": "~6.9.13", "nostr-tools": "^2.10.4", "notp": "~2.0.3", "openid-client": "^5.4.2", @@ -135,7 +135,7 @@ "prometheus-api-metrics": "~3.2.1", "promisify-child-process": "~4.1.2", "protobufjs": "~7.2.4", - "qs": "~6.10.4", + "qs": "~6.14.1", "radius": "~1.1.4", "redbean-node": "~0.3.0", "redis": "~5.9.0", diff --git a/server/model/monitor.js b/server/model/monitor.js index 7259fb4b7..4fbc1409c 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -20,7 +20,6 @@ const version = require("../../package.json").version; const apicache = require("../modules/apicache"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const { DockerHost } = require("../docker"); -const Gamedig = require("gamedig"); const jwt = require("jsonwebtoken"); const crypto = require("crypto"); const { UptimeCalculator } = require("../uptime-calculator"); @@ -718,21 +717,6 @@ class Monitor extends BeanModel { } else { throw new Error("Server not found on Steam"); } - } else if (this.type === "gamedig") { - try { - const state = await Gamedig.query({ - type: this.game, - host: this.hostname, - port: this.port, - givenPortOnly: this.getGameDigGivenPortOnly(), - }); - - bean.msg = state.name; - bean.status = UP; - bean.ping = state.ping; - } catch (e) { - throw new Error(e.message); - } } else if (this.type === "docker") { log.debug("monitor", `[${this.name}] Prepare Options for Axios`); diff --git a/server/modules/axios-ntlm/lib/hash.js b/server/modules/axios-ntlm/lib/hash.js index 4addb5f54..d55a33489 100644 --- a/server/modules/axios-ntlm/lib/hash.js +++ b/server/modules/axios-ntlm/lib/hash.js @@ -106,7 +106,7 @@ function createNTLMv2Response(type2message, username, ntlmhash, nonce, targetNam function createPseudoRandomValue(length) { var str = ''; while (str.length < length) { - str += Math.floor(Math.random() * 16).toString(16); + str += crypto.randomInt(16).toString(16); } return str; } diff --git a/server/monitor-types/gamedig.js b/server/monitor-types/gamedig.js new file mode 100644 index 000000000..b20c2435c --- /dev/null +++ b/server/monitor-types/gamedig.js @@ -0,0 +1,55 @@ +const { MonitorType } = require("./monitor-type"); +const { UP, DOWN } = require("../../src/util"); +const Gamedig = require("gamedig"); +const dns = require("dns").promises; +const net = require("net"); + +class GameDigMonitorType extends MonitorType { + name = "gamedig"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, server) { + heartbeat.status = DOWN; + + let host = monitor.hostname; + if (net.isIP(host) === 0) { + host = await this.resolveHostname(host); + } + + try { + const state = await Gamedig.query({ + type: monitor.game, + host: host, + port: monitor.port, + givenPortOnly: Boolean(monitor.gamedigGivenPortOnly), + }); + + heartbeat.msg = state.name; + heartbeat.status = UP; + heartbeat.ping = state.ping; + } catch (e) { + throw new Error(e.message); + } + } + + /** + * Resolves a domain name to its IPv4 address. + * @param {string} hostname - The domain name to resolve (e.g., "example.dyndns.org"). + * @returns {Promise} - The resolved IP address. + * @throws Will throw an error if the DNS resolution fails. + */ + async resolveHostname(hostname) { + try { + const result = await dns.lookup(hostname); + return result.address; + } catch (err) { + throw new Error(`DNS resolution failed for ${hostname}: ${err.message}`); + } + } +} + +module.exports = { + GameDigMonitorType, +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 38a7d9831..6ffd6a170 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -121,6 +121,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["grpc-keyword"] = new GrpcKeywordMonitorType(); UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType(); UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType(); + UptimeKumaServer.monitorTypeList["gamedig"] = new GameDigMonitorType(); UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType(); UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType(); UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType(); @@ -571,6 +572,7 @@ const { SNMPMonitorType } = require("./monitor-types/snmp"); const { GrpcKeywordMonitorType } = require("./monitor-types/grpc"); const { MongodbMonitorType } = require("./monitor-types/mongodb"); const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq"); +const { GameDigMonitorType } = require("./monitor-types/gamedig"); const { TCPMonitorType } = require("./monitor-types/tcp.js"); const { ManualMonitorType } = require("./monitor-types/manual"); const { RedisMonitorType } = require("./monitor-types/redis"); diff --git a/test/backend-test/monitors/test-gamedig.js b/test/backend-test/monitors/test-gamedig.js new file mode 100644 index 000000000..57e7a89dc --- /dev/null +++ b/test/backend-test/monitors/test-gamedig.js @@ -0,0 +1,264 @@ +const { describe, test, mock } = require("node:test"); +const assert = require("node:assert"); +const { GameDigMonitorType } = require("../../../server/monitor-types/gamedig"); +const { UP, DOWN, PENDING } = require("../../../src/util"); +const net = require("net"); +const Gamedig = require("gamedig"); + +describe("GameDig Monitor", () => { + test("check() sets status to UP when Gamedig.query returns valid server response", async () => { + const gamedigMonitor = new GameDigMonitorType(); + + mock.method(Gamedig, "query", async () => { + return { + name: "Test Minecraft Server", + ping: 42, + players: [], + }; + }); + + const monitor = { + hostname: "127.0.0.1", + port: 25565, + game: "minecraft", + gamedigGivenPortOnly: true, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await gamedigMonitor.check(monitor, heartbeat, {}); + + assert.strictEqual(heartbeat.status, UP); + assert.strictEqual(heartbeat.msg, "Test Minecraft Server"); + assert.strictEqual(heartbeat.ping, 42); + } finally { + mock.restoreAll(); + } + }); + + test("check() resolves hostname to IP address when hostname is not an IP", async () => { + const gamedigMonitor = new GameDigMonitorType(); + + mock.method(Gamedig, "query", async (options) => { + assert.ok( + net.isIP(options.host) !== 0, + `Expected IP address, got ${options.host}` + ); + return { + name: "Test Server", + ping: 50, + }; + }); + + const monitor = { + hostname: "localhost", + port: 25565, + game: "minecraft", + gamedigGivenPortOnly: false, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await gamedigMonitor.check(monitor, heartbeat, {}); + + assert.strictEqual(heartbeat.status, UP); + assert.strictEqual(heartbeat.msg, "Test Server"); + assert.strictEqual(heartbeat.ping, 50); + } finally { + mock.restoreAll(); + } + }); + + test("check() uses IP address directly without DNS resolution when hostname is IPv4", async () => { + const gamedigMonitor = new GameDigMonitorType(); + + let capturedOptions = null; + + mock.method(Gamedig, "query", async (options) => { + capturedOptions = options; + return { + name: "Test Server", + ping: 30, + }; + }); + + const monitor = { + hostname: "192.168.1.100", + port: 27015, + game: "valve", + gamedigGivenPortOnly: true, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await gamedigMonitor.check(monitor, heartbeat, {}); + + assert.strictEqual(capturedOptions.host, "192.168.1.100"); + assert.strictEqual(heartbeat.status, UP); + } finally { + mock.restoreAll(); + } + }); + + test("check() uses IP address directly without DNS resolution when hostname is IPv6", async () => { + const gamedigMonitor = new GameDigMonitorType(); + + let capturedOptions = null; + + mock.method(Gamedig, "query", async (options) => { + capturedOptions = options; + return { + name: "Test Server", + ping: 30, + }; + }); + + const monitor = { + hostname: "::1", + port: 27015, + game: "valve", + gamedigGivenPortOnly: true, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await gamedigMonitor.check(monitor, heartbeat, {}); + + assert.strictEqual(capturedOptions.host, "::1"); + assert.strictEqual(heartbeat.status, UP); + } finally { + mock.restoreAll(); + } + }); + + test("check() passes correct parameters to Gamedig.query", async () => { + const gamedigMonitor = new GameDigMonitorType(); + + let capturedOptions = null; + + mock.method(Gamedig, "query", async (options) => { + capturedOptions = options; + return { + name: "Test Server", + ping: 25, + }; + }); + + const monitor = { + hostname: "192.168.1.100", + port: 27015, + game: "valve", + gamedigGivenPortOnly: true, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await gamedigMonitor.check(monitor, heartbeat, {}); + + assert.strictEqual(capturedOptions.type, "valve"); + assert.strictEqual(capturedOptions.host, "192.168.1.100"); + assert.strictEqual(capturedOptions.port, 27015); + assert.strictEqual(capturedOptions.givenPortOnly, true); + } finally { + mock.restoreAll(); + } + }); + + test("check() converts gamedigGivenPortOnly to boolean when value is truthy non-boolean", async () => { + const gamedigMonitor = new GameDigMonitorType(); + + let capturedOptions = null; + + mock.method(Gamedig, "query", async (options) => { + capturedOptions = options; + return { + name: "Test Server", + ping: 30, + }; + }); + + const monitor = { + hostname: "127.0.0.1", + port: 25565, + game: "minecraft", + gamedigGivenPortOnly: 1, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await gamedigMonitor.check(monitor, heartbeat, {}); + + assert.strictEqual(capturedOptions.givenPortOnly, true); + assert.strictEqual(typeof capturedOptions.givenPortOnly, "boolean"); + } finally { + mock.restoreAll(); + } + }); + + test("check() sets status to DOWN and rejects when game server is unreachable", async () => { + const gamedigMonitor = new GameDigMonitorType(); + + const monitor = { + hostname: "127.0.0.1", + port: 54321, + game: "minecraft", + gamedigGivenPortOnly: true, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + await assert.rejects( + gamedigMonitor.check(monitor, heartbeat, {}), + /Error/ + ); + + assert.strictEqual(heartbeat.status, DOWN); + }); + + test("resolveHostname() returns IP address when given valid hostname", async () => { + const gamedigMonitor = new GameDigMonitorType(); + + const resolvedIP = await gamedigMonitor.resolveHostname("localhost"); + + assert.ok( + net.isIP(resolvedIP) !== 0, + `Expected valid IP address, got ${resolvedIP}` + ); + }); + + test("resolveHostname() rejects when DNS resolution fails for invalid hostname", async () => { + const gamedigMonitor = new GameDigMonitorType(); + + await assert.rejects( + gamedigMonitor.resolveHostname("this-domain-definitely-does-not-exist-12345.invalid"), + /DNS resolution failed/ + ); + }); +}); diff --git a/test/backend-test/notification-providers/test-ntlm.js b/test/backend-test/notification-providers/test-ntlm.js new file mode 100644 index 000000000..5243de482 --- /dev/null +++ b/test/backend-test/notification-providers/test-ntlm.js @@ -0,0 +1,27 @@ +const { describe, test } = require("node:test"); +const assert = require("node:assert"); + +const hash = require("../../../server/modules/axios-ntlm/lib/hash"); + +describe("createPseudoRandomValue()", () => { + test("returns a hexadecimal string with the requested length", () => { + for (const length of [ 0, 8, 16, 32, 64 ]) { + const result = hash.createPseudoRandomValue(length); + assert.strictEqual(typeof result, "string"); + assert.strictEqual(result.length, length); + assert.ok(/^[0-9a-f]*$/.test(result)); + } + }); + + test("returns unique values across multiple calls with the same length", () => { + const length = 16; + const iterations = 10; + const results = new Set(); + + for (let i = 0; i < iterations; i++) { + results.add(hash.createPseudoRandomValue(length)); + } + + assert.strictEqual(results.size, iterations); + }); +}); diff --git a/test/e2e/specs/monitor-form.spec.js b/test/e2e/specs/monitor-form.spec.js index c8734fa90..45f5c5475 100644 --- a/test/e2e/specs/monitor-form.spec.js +++ b/test/e2e/specs/monitor-form.spec.js @@ -73,7 +73,7 @@ test.describe("Monitor Form", () => { await page.getByTestId("save-button").click(); await page.waitForURL("/dashboard/*"); - expect(page.getByTestId("monitor-status")).toHaveText("up", { ignoreCase: true }); + await expect(page.getByTestId("monitor-status")).toHaveText("up", { ignoreCase: true }); await screenshot(testInfo, page); }); @@ -101,7 +101,7 @@ test.describe("Monitor Form", () => { await page.getByTestId("save-button").click(); await page.waitForURL("/dashboard/*"); - expect(page.getByTestId("monitor-status")).toHaveText("down", { ignoreCase: true }); + await expect(page.getByTestId("monitor-status")).toHaveText("down", { ignoreCase: true }); await screenshot(testInfo, page); });