diff --git a/server/model/monitor.js b/server/model/monitor.js index 8e1843cd6..32b30cdb9 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -32,7 +32,6 @@ const { checkCertificate, checkStatusCode, getTotalClientInRoom, - setting, httpNtlm, radius, kafkaProducerAsync, @@ -772,56 +771,6 @@ class Monitor extends BeanModel { bean.duration = beatInterval; throw new Error("No heartbeat in the time window"); } - } else if (this.type === "steam") { - const steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/"; - const steamAPIKey = await setting("steamAPIKey"); - const filter = `addr\\${this.hostname}:${this.port}`; - - if (!steamAPIKey) { - throw new Error("Steam API Key not found"); - } - - let res = await axios.get(steamApiUrl, { - timeout: this.timeout * 1000, - headers: { - Accept: "*/*", - }, - httpsAgent: new https.Agent({ - maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) - rejectUnauthorized: !this.getIgnoreTls(), - secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, - }), - httpAgent: new http.Agent({ - maxCachedSessions: 0, - }), - maxRedirects: this.maxredirects, - validateStatus: (status) => { - return checkStatusCode(status, this.getAcceptedStatuscodes()); - }, - params: { - filter: filter, - key: steamAPIKey, - }, - }); - - if (res.data.response && res.data.response.servers && res.data.response.servers.length > 0) { - bean.status = UP; - bean.msg = res.data.response.servers[0].name; - - try { - bean.ping = await ping( - this.hostname, - PING_COUNT_DEFAULT, - "", - true, - this.packetSize, - PING_GLOBAL_TIMEOUT_DEFAULT, - PING_PER_REQUEST_TIMEOUT_DEFAULT - ); - } catch (_) {} - } else { - throw new Error("Server not found on Steam"); - } } else if (this.type === "docker") { log.debug("monitor", `[${this.name}] Prepare Options for Axios`); diff --git a/server/monitor-types/steam.js b/server/monitor-types/steam.js new file mode 100644 index 000000000..ec797eaa4 --- /dev/null +++ b/server/monitor-types/steam.js @@ -0,0 +1,134 @@ +const { MonitorType } = require("./monitor-type"); +const { + UP, + PING_COUNT_DEFAULT, + PING_GLOBAL_TIMEOUT_DEFAULT, + PING_PER_REQUEST_TIMEOUT_DEFAULT, +} = require("../../src/util"); +const { Settings } = require("../settings"); +const { ping, checkStatusCode } = require("../util-server"); +const axios = require("axios"); +const crypto = require("crypto"); +const dns = require("node:dns/promises"); +const http = require("http"); +const https = require("https"); +const net = require("node:net"); + +class SteamMonitorType extends MonitorType { + name = "steam"; + + /** + * Creates a Steam monitor type. + * @param {object} options Optional dependencies for tests. + * @param {object} options.steamApiClient Axios-compatible Steam API client. + * @param {Function} options.lookup DNS lookup function. + * @param {Function} options.getSteamAPIKey Steam API key provider. + * @param {Function} options.ping Steam server ping function. + */ + constructor(options = {}) { + super(); + + this.steamApiClient = options.steamApiClient || axios; + this.lookup = options.lookup || dns.lookup; + this.getSteamAPIKey = options.getSteamAPIKey || (() => Settings.get("steamAPIKey")); + this.ping = options.ping || ping; + } + + /** + * @inheritdoc + */ + async check(monitor, heartbeat) { + const steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/"; + const steamAPIKey = await this.getSteamAPIKey(); + + if (!steamAPIKey) { + throw new Error("Steam API Key not found"); + } + + const filter = await this.buildServerFilter(monitor.hostname, monitor.port); + + let res = await this.steamApiClient.get(steamApiUrl, { + timeout: monitor.timeout * 1000, + headers: { + Accept: "*/*", + }, + httpsAgent: new https.Agent({ + maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) + rejectUnauthorized: !monitor.getIgnoreTls(), + secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, + }), + httpAgent: new http.Agent({ + maxCachedSessions: 0, + }), + maxRedirects: monitor.maxredirects, + validateStatus: (status) => { + return checkStatusCode(status, monitor.getAcceptedStatuscodes()); + }, + params: { + filter: filter, + key: steamAPIKey, + }, + }); + + if (res.data.response && res.data.response.servers && res.data.response.servers.length > 0) { + heartbeat.status = UP; + heartbeat.msg = res.data.response.servers[0].name; + + try { + heartbeat.ping = await this.ping( + monitor.hostname, + PING_COUNT_DEFAULT, + "", + true, + monitor.packetSize, + PING_GLOBAL_TIMEOUT_DEFAULT, + PING_PER_REQUEST_TIMEOUT_DEFAULT + ); + } catch (_) {} + } else { + throw new Error("Server not found on Steam"); + } + } + + /** + * Builds the Steam API server filter. + * @param {string} hostname Steam server hostname or IP address. + * @param {number} port Steam server port. + * @returns {Promise} Steam API addr filter. + */ + async buildServerFilter(hostname, port) { + const resolvedHostname = await this.resolveSteamHostname(hostname); + return `addr\\${resolvedHostname}:${port}`; + } + + /** + * Resolves hostnames before passing them to Steam's addr filter. + * @param {string} hostname Steam server hostname or IP address. + * @returns {Promise} IP address accepted by the Steam API. + * @throws {Error} When the hostname cannot be resolved. + */ + async resolveSteamHostname(hostname) { + if (net.isIP(hostname)) { + return hostname; + } + + try { + const lookupResult = await this.lookup(hostname, { all: true }); + const addresses = Array.isArray(lookupResult) ? lookupResult : [lookupResult]; + const ipv4Address = addresses.find(({ address }) => net.isIP(address) === 4); + const resolvedAddress = ipv4Address?.address || addresses[0]?.address; + + if (!resolvedAddress) { + throw new Error("DNS lookup returned no addresses"); + } + + return resolvedAddress; + } catch (error) { + throw new Error(`Unable to resolve Steam server hostname "${hostname}": ${error.message}`); + } + } +} + +module.exports = { + SteamMonitorType, +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index a1ee80485..2d2961161 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -124,6 +124,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType(); UptimeKumaServer.monitorTypeList["sip-options"] = new SIPMonitorType(); UptimeKumaServer.monitorTypeList["gamedig"] = new GameDigMonitorType(); + UptimeKumaServer.monitorTypeList["steam"] = new SteamMonitorType(); UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType(); UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType(); UptimeKumaServer.monitorTypeList["globalping"] = new GlobalpingMonitorType(this.getUserAgent()); @@ -576,6 +577,7 @@ const { MongodbMonitorType } = require("./monitor-types/mongodb"); const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq"); const { SIPMonitorType } = require("./monitor-types/sip-options"); const { GameDigMonitorType } = require("./monitor-types/gamedig"); +const { SteamMonitorType } = require("./monitor-types/steam"); const { TCPMonitorType } = require("./monitor-types/tcp.js"); const { ManualMonitorType } = require("./monitor-types/manual"); const { GlobalpingMonitorType } = require("./monitor-types/globalping"); diff --git a/test/backend-test/monitors/test-steam.js b/test/backend-test/monitors/test-steam.js new file mode 100644 index 000000000..254005513 --- /dev/null +++ b/test/backend-test/monitors/test-steam.js @@ -0,0 +1,140 @@ +const { describe, test } = require("node:test"); +const assert = require("node:assert"); +const { SteamMonitorType } = require("../../../server/monitor-types/steam"); +const { UP, PENDING } = require("../../../src/util"); + +describe("Steam Monitor", () => { + test("resolveSteamHostname() returns IP addresses without DNS lookup", async () => { + let lookupCalled = false; + const steamMonitor = new SteamMonitorType({ + lookup: async () => { + lookupCalled = true; + }, + }); + + assert.strictEqual(await steamMonitor.resolveSteamHostname("192.0.2.10"), "192.0.2.10"); + assert.strictEqual(await steamMonitor.resolveSteamHostname("2001:db8::10"), "2001:db8::10"); + assert.strictEqual(lookupCalled, false); + }); + + test("buildServerFilter() resolves hostnames before building the Steam API addr filter", async () => { + let capturedHostname = null; + let capturedOptions = null; + const steamMonitor = new SteamMonitorType({ + lookup: async (hostname, options) => { + capturedHostname = hostname; + capturedOptions = options; + return [ + { + address: "203.0.113.10", + family: 4, + }, + ]; + }, + }); + + const filter = await steamMonitor.buildServerFilter("server.example.com", 27015); + + assert.strictEqual(filter, "addr\\203.0.113.10:27015"); + assert.strictEqual(capturedHostname, "server.example.com"); + assert.deepStrictEqual(capturedOptions, { all: true }); + }); + + test("resolveSteamHostname() prefers IPv4 addresses returned by DNS lookup", async () => { + const steamMonitor = new SteamMonitorType({ + lookup: async () => { + return [ + { + address: "2001:db8::20", + family: 6, + }, + { + address: "203.0.113.20", + family: 4, + }, + ]; + }, + }); + + assert.strictEqual(await steamMonitor.resolveSteamHostname("server.example.com"), "203.0.113.20"); + }); + + test("check() uses the resolved IP address in the Steam API filter", async () => { + let capturedUrl = null; + let capturedOptions = null; + const steamMonitor = new SteamMonitorType({ + lookup: async () => { + return [ + { + address: "203.0.113.30", + family: 4, + }, + ]; + }, + getSteamAPIKey: async () => "test-steam-api-key", + steamApiClient: { + get: async (url, options) => { + capturedUrl = url; + capturedOptions = options; + return { + data: { + response: { + servers: [ + { + name: "Test Steam Server", + }, + ], + }, + }, + }; + }, + }, + ping: async () => 42, + }); + + const monitor = { + hostname: "server.example.com", + port: 27015, + timeout: 30, + maxredirects: 10, + packetSize: 56, + getIgnoreTls: () => false, + getAcceptedStatuscodes: () => ["200"], + }; + const heartbeat = { + msg: "", + status: PENDING, + }; + + await steamMonitor.check(monitor, heartbeat); + + assert.strictEqual(capturedUrl, "https://api.steampowered.com/IGameServersService/GetServerList/v1/"); + assert.strictEqual(capturedOptions.params.filter, "addr\\203.0.113.30:27015"); + assert.strictEqual(capturedOptions.params.key, "test-steam-api-key"); + assert.strictEqual(heartbeat.status, UP); + assert.strictEqual(heartbeat.msg, "Test Steam Server"); + assert.strictEqual(heartbeat.ping, 42); + }); + + test("check() does not resolve hostnames when the Steam API key is missing", async () => { + let lookupCalled = false; + const steamMonitor = new SteamMonitorType({ + lookup: async () => { + lookupCalled = true; + }, + getSteamAPIKey: async () => "", + }); + + const monitor = { + hostname: "server.example.com", + port: 27015, + }; + const heartbeat = { + msg: "", + status: PENDING, + }; + + await assert.rejects(steamMonitor.check(monitor, heartbeat), /Steam API Key not found/); + assert.strictEqual(lookupCalled, false); + }); +});