fix: resolve Steam monitor hostnames (#7542)

This commit is contained in:
Yumin Kim
2026-06-25 20:04:02 +09:00
committed by GitHub
parent 9eb7a3a751
commit e1937a6d25
4 changed files with 276 additions and 51 deletions

View File

@@ -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`);

View File

@@ -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<string>} 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<string>} 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,
};

View File

@@ -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");

View File

@@ -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);
});
});