mineping/lib/java.js
Timofey Gelazoniya 23299a9a07
fix: add timeout to SRV record lookup
The `dns.promises.resolveSrv` function used for SRV record lookups
does not have a user-configurable timeout. This could cause the entire
`pingJava` operation to hang for an extended period if the target's
DNS server unresponsive.

To make the SRV lookup more robust and respect the user-provided
timeout, this commit switches to using the `dns.promises.Resolver`
class.
2025-06-25 01:09:07 +03:00

268 lines
8.7 KiB
JavaScript

/**
* Implementation of the Java Minecraft ping protocol.
* @see https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping
*/
"use strict";
import net from "node:net";
import { Resolver } from "node:dns/promises";
import createDebug from "debug";
import * as varint from "./varint.js";
const debug = createDebug("mineping:java");
/**
* Represents the structured and user-friendly response from a server ping.
* The fields and their optionality are based on the protocol documentation.
* See [Status Response Documentation](https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping#Status_Response) for more details.
* @typedef {object} JavaPingResponse
* @property {{ name: string, protocol: number }} version - Contains the server's version name and protocol number.
* @property {{ max: number, online: number, sample?: Array<{ name: string, id: string }> }} [players] - Player count and a sample of online players.
* @property {object | string} [description] - The server's Message of the Day (MOTD).
* @property {string} [favicon] - A Base64-encoded 64x64 PNG image data URI.
* @property {boolean} [enforcesSecureChat] - True if the server requires clients to have a Mojang-signed public key.
* @property {boolean} [preventsChatReports] - True if a mod is installed to disable chat reporting.
*/
/**
* @typedef {object} JavaPingOptions
* @property {number} [port=25565] - The fallback port if an SRV record is not found.
* @property {number} [timeout=5000] - The connection timeout in milliseconds.
* @property {number} [protocolVersion=-1] - The protocol version to use in the handshake. `-1` is for auto-detection.
*/
/**
* Creates the Handshake packet.
* @param {string} host The hostname to connect to
* @param {number} port The port to connect to
* @param {number} protocolVersion The protocol version to use
* @returns {Buffer} The complete Handshake packet
*/
function createHandshakePacket(host, port, protocolVersion) {
const hostBuffer = varint.encodeString(host);
const payload = [
varint.encodeVarInt(0x00), // Packet ID
varint.encodeVarInt(protocolVersion),
varint.encodeVarInt(hostBuffer.length),
hostBuffer,
varint.encodeUShort(port),
varint.encodeVarInt(1), // Next state: 1 for Status
];
return varint.concatPackets(payload);
}
/**
* Creates the Status Request packet.
* @returns {Buffer} The complete Status Request packet
*/
function createStatusRequestPacket() {
const payload = [
varint.encodeVarInt(0x00), // Packet ID
];
return varint.concatPackets(payload);
}
/**
* Attempts to parse the server status response from the buffer.
* @param {Buffer} buffer The incoming data buffer
* @returns {{ response: JavaPingResponse, remainder: Buffer } | null} The parsed response and the remaining buffer, or null if the packet is incomplete
*/
function processResponse(buffer) {
let offset = 0;
try {
const packetLengthResult = varint.decodeVarInt(buffer, offset);
const packetLength = packetLengthResult.value;
offset += packetLengthResult.bytesRead;
// Check if the full packet has arrived yet.
if (buffer.length < offset + packetLength) {
debug("packet incomplete, waiting for more data");
return null; // Incomplete packet, wait for more data.
}
const packetIdResult = varint.decodeVarInt(buffer, offset);
if (packetIdResult.value !== 0x00) {
throw new Error(
`Unexpected packet ID: ${packetIdResult.value}. Expected 0x00.`
);
}
offset += packetIdResult.bytesRead;
const jsonLengthResult = varint.decodeVarInt(buffer, offset);
const jsonLength = jsonLengthResult.value;
offset += jsonLengthResult.bytesRead;
if (buffer.length < offset + jsonLength) {
debug("JSON string incomplete, waiting for more data");
return null; // Incomplete JSON string, wait for more data.
}
const jsonString = buffer
.subarray(offset, offset + jsonLength)
.toString("utf8");
debug("received raw JSON response");
const response = JSON.parse(jsonString);
// Return the response and any data that came after this packet.
const remainder = buffer.subarray(offset + jsonLength);
return { response, remainder };
} catch (err) {
// If the buffer is too short for a VarInt, it's a recoverable state.
if (err instanceof varint.VarIntError) {
if (err.code === varint.ERR_VARINT_BUFFER_UNDERFLOW) {
debug("buffer underflow while parsing VarInt, waiting for more data");
return null; // Wait for more data.
}
// For malformed VarInts or JSON, throw the error to reject the promise.
throw err;
}
throw err;
}
}
/**
* Asynchronously Pings a Minecraft Java Edition server.
* This function performs an SRV lookup and then attempts to connect and retrieve the server status.
* @param {string} host - The server address to ping.
* @param {JavaPingOptions} [options={}] - Optional configuration.
* @returns {Promise<JavaPingResponse>} A promise that resolves with the server's status.
*/
export async function pingJava(host, options = {}) {
if (typeof host !== "string" || host.trim() === "") {
throw new Error("Host argument is required.");
}
const {
port: fallbackPort = 25565,
timeout = 5000,
protocolVersion = -1,
} = options;
debug("pinging Java server %s with options: %o", host, options);
let targetHost = host;
let targetPort = fallbackPort;
try {
debug(
"attempting SRV lookup for _minecraft._tcp.%s with %dms timeout",
host,
timeout
);
const resolver = new Resolver({ timeout, tries: 3 });
const srvRecords = await resolver.resolveSrv(`_minecraft._tcp.${host}`);
if (srvRecords.length > 0) {
targetHost = srvRecords[0].name;
targetPort = srvRecords[0].port;
debug("SRV lookup successful, new target: %s:%d", targetHost, targetPort);
}
} catch (err) {
// Common errors like ENODATA, ENOTFOUND, or a DNS timeout (ETIMEOUT) are expected
// when a server does not have an SRV record, so we ignore them and proceed.
const nonFatalDnsCodes = ["ENODATA", "ENOTFOUND", "ETIMEOUT"];
if (
err instanceof Error &&
"code" in err &&
nonFatalDnsCodes.includes(err.code)
) {
debug("SRV lookup for %s failed (%s), using fallback.", host, err.code);
} else {
// Re-throw anything else to fail the operation
debug("SRV lookup for %s failed unexpectedly, re-throwing.", host, err);
throw err;
}
}
return new Promise((resolve, reject) => {
debug("creating TCP connection to %s:%d", targetHost, targetPort);
const socket = net.createConnection({ host: targetHost, port: targetPort });
// Prevent cleanup tasks from running more than once
// in case of multiple error callbacks
let isCleanupCompleted = false;
// Set a manual timeout interval to ensure
// the connection will NEVER hang regardless of internal state
const timeoutTask = setTimeout(() => {
socket.emit("error", new Error("Socket timeout"));
}, timeout);
// Idempotent function to handle cleanup tasks, we can safely call it multiple times without side effects
const cleanup = () => {
if (isCleanupCompleted) return;
isCleanupCompleted = true;
debug("cleaning up resources for %s:%d", targetHost, targetPort);
clearTimeout(timeoutTask);
socket.destroy();
};
// #setNoDelay instantly flushes data during read/writes
// This prevents the runtime from delaying the write at all
socket.setNoDelay(true);
// Generic error handler
socket.on("error", (err) => {
debug("socket error for %s:%d - %s", targetHost, targetPort, err.message);
cleanup();
reject(err);
});
socket.on("close", () => {
if (!isCleanupCompleted) {
debug("socket for %s:%d closed prematurely", targetHost, targetPort);
cleanup();
reject(new Error("Socket closed unexpectedly without a response."));
}
});
socket.on("connect", () => {
debug(
"socket connected to %s:%d, sending packets...",
targetHost,
targetPort
);
try {
const handshakePacket = createHandshakePacket(
host,
targetPort,
protocolVersion
);
const statusRequestPacket = createStatusRequestPacket();
socket.write(handshakePacket);
socket.write(statusRequestPacket);
} catch (err) {
// Handle synchronous errors during packet creation/writing
socket.emit("error", err);
}
});
let incomingBuffer = Buffer.alloc(0);
socket.on("data", (data) => {
debug(
"received %d bytes of data, total buffer size is now %d bytes",
data.length,
incomingBuffer.length + data.length
);
incomingBuffer = Buffer.concat([incomingBuffer, data]);
try {
const result = processResponse(incomingBuffer);
if (result) {
debug("successfully parsed full response");
// We successfully parsed a response. Clean up before resolving.
cleanup();
resolve(result.response);
}
// If result is null, we just wait for more data to arrive.
} catch (err) {
socket.emit("error", err);
}
});
});
}