package launcher.client;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.regex.Pattern;
import com.eclipsesource.json.Json;
import com.eclipsesource.json.JsonObject;
import launcher.LauncherAPI;
import launcher.helper.IOHelper;
import launcher.helper.LogHelper;
import launcher.helper.VerifyHelper;
import launcher.serialize.HInput;
import launcher.serialize.HOutput;
public final class ServerPinger {
// Constants
private static final String LEGACY_PING_HOST_MAGIC = "ยง1";
private static final String LEGACY_PING_HOST_CHANNEL = "MC|PingHost";
private static final Pattern LEGACY_PING_HOST_DELIMETER = Pattern.compile("\0", Pattern.LITERAL);
// Instance
private final InetSocketAddress address;
private final ClientProfile.Version version;
// Cache
private final Object cacheLock = new Object();
private Result cache;
private Instant cacheTime;
@LauncherAPI
public ServerPinger(InetSocketAddress address, ClientProfile.Version version) {
this.address = address;
this.version = version;
}
@LauncherAPI
public Result ping() throws IOException {
Instant now = Instant.now();
synchronized (cacheLock) {
if (cache == null || cacheTime == null || Duration.between(now, cacheTime).getSeconds() >= 30) {
cache = doPing();
cacheTime = now;
}
return cache;
}
}
private Result doPing() throws IOException {
try (Socket socket = IOHelper.newSocket()) {
socket.connect(IOHelper.resolve(address), IOHelper.SOCKET_TIMEOUT);
try (HInput input = new HInput(socket.getInputStream());
HOutput output = new HOutput(socket.getOutputStream())) {
return version.compareTo(ClientProfile.Version.MC172) >= 0 ?
modernPing(input, output) : legacyPing(input, output);
}
}
}
private Result legacyPing(HInput input, HOutput output) throws IOException {
output.writeUnsignedByte(0xFE); // 254 packet ID, Server list ping
output.writeUnsignedByte(0x01); // Server ping payload
output.writeUnsignedByte(0xFA); // 250 packet ID, Custom payload
writeUTF16String(output, LEGACY_PING_HOST_CHANNEL); // Custom payload name
// Prepare custom payload packet
byte[] customPayloadPacket;
try (ByteArrayOutputStream packetArray = IOHelper.newByteArrayOutput()) {
try (HOutput packetOutput = new HOutput(packetArray)) {
packetOutput.writeUnsignedByte(version.protocol); // Protocol version
writeUTF16String(packetOutput, address.getHostString()); // Server address
packetOutput.writeInt(address.getPort()); // Server port
}
customPayloadPacket = packetArray.toByteArray();
}
// Write custom payload packet
output.writeShort((short) customPayloadPacket.length);
output.stream.write(customPayloadPacket);
output.flush();
// Raed kick (response) packet
int kickPacketID = input.readUnsignedByte();
if (kickPacketID != 0xFF) {
throw new IOException("Illegal kick packet ID: " + kickPacketID);
}
// Read and parse response
String response = readUTF16String(input);
LogHelper.debug("Ping response (legacy): '%s'", response);
String[] splitted = LEGACY_PING_HOST_DELIMETER.split(response);
if (splitted.length != 6) {
throw new IOException("Tokens count mismatch");
}
// Verify all parts
String magic = splitted[0];
if (!magic.equals(LEGACY_PING_HOST_MAGIC)) {
throw new IOException("Magic string mismatch: " + magic);
}
int protocol = Integer.parseInt(splitted[1]);
if (protocol != version.protocol) {
throw new IOException("Protocol mismatch: " + protocol);
}
String clientVersion = splitted[2];
if (!clientVersion.equals(version.name)) {
throw new IOException(String.format("Version mismatch: '%s'", clientVersion));
}
String title = splitted[3];
int onlinePlayers = VerifyHelper.verifyInt(Integer.parseInt(splitted[4]),
VerifyHelper.NOT_NEGATIVE, "onlinePlayers can't be < 0");
int maxPlayers = VerifyHelper.verifyInt(Integer.parseInt(splitted[5]),
VerifyHelper.NOT_NEGATIVE, "maxPlayers can't be < 0");
// Return ping status
return new Result(onlinePlayers, maxPlayers, title, response);
}
private Result modernPing(HInput input, HOutput output) throws IOException {
// Prepare handshake packet
byte[] handshakePacket;
try (ByteArrayOutputStream packetArray = IOHelper.newByteArrayOutput()) {
try (HOutput packetOutput = new HOutput(packetArray)) {
packetOutput.writeVarInt(0x0); // Handshake packet ID
packetOutput.writeVarInt(version.protocol); // Protocol version
packetOutput.writeString(address.getHostString(), 0); // Server address
packetOutput.writeShort((short) address.getPort()); // Server port
packetOutput.writeVarInt(0x1); // Next state - status
}
handshakePacket = packetArray.toByteArray();
}
// Write handshake packet
output.writeByteArray(handshakePacket, IOHelper.BUFFER_SIZE);
// Request status packet
output.writeVarInt(1); // Status packet size (single byte)
output.writeVarInt(0x0); // Status packet ID
output.flush();
// Read outer status response packet ID
// ab is a dirty fix for some servers (noticed KCauldron 1.7.10)
String response;
int ab = IOHelper.verifyLength(input.readVarInt(), IOHelper.BUFFER_SIZE);
byte[] statusPacket = ab == 0x0 ? input.readByteArray(IOHelper.BUFFER_SIZE) : input.readByteArray(-ab);
try (HInput packetInput = new HInput(statusPacket)) {
int statusPacketID = packetInput.readVarInt();
if (statusPacketID != 0x0) {
throw new IOException("Illegal status packet ID: " + statusPacketID);
}
response = packetInput.readString(IOHelper.BUFFER_SIZE);
LogHelper.debug("Ping response (modern): '%s'", response);
}
// Parse JSON response
JsonObject object = Json.parse(response).asObject();
String description = object.get("description").asString();
JsonObject playersObject = object.get("players").asObject();
int online = playersObject.get("online").asInt();
int max = playersObject.get("max").asInt();
// Return ping status
return new Result(online, max, description, response);
}
private static String readUTF16String(HInput input) throws IOException {
int length = input.readUnsignedShort() << 1;
byte[] encoded = input.readByteArray(-length);
return new String(encoded, StandardCharsets.UTF_16BE);
}
private static void writeUTF16String(HOutput output, String s) throws IOException {
output.writeShort((short) s.length());
output.stream.write(s.getBytes(StandardCharsets.UTF_16BE));
}
public static final class Result {
private static final Pattern CODES_PATTERN = Pattern.compile("ยง[0-9a-fkmnor]", Pattern.CASE_INSENSITIVE);
// Instance
@LauncherAPI public final int onlinePlayers;
@LauncherAPI public final int maxPlayers;
@LauncherAPI public final String description;
@LauncherAPI public final String raw;
public Result(int onlinePlayers, int maxPlayers, CharSequence description, String raw) {
this.onlinePlayers = VerifyHelper.verifyInt(onlinePlayers,
VerifyHelper.NOT_NEGATIVE, "onlinePlayers can't be < 0");
this.maxPlayers = VerifyHelper.verifyInt(maxPlayers,
VerifyHelper.NOT_NEGATIVE, "maxPlayers can't be < 0");
this.description = stripColorCodes(description);
this.raw = raw;
}
@LauncherAPI
public boolean isOverfilled() {
return onlinePlayers >= maxPlayers;
}
private static String stripColorCodes(CharSequence s) {
return CODES_PATTERN.matcher(s).replaceAll("");
}
}
}