Пинг ванильным методом и причины отказа по токину

- StatusSession: ответ на ванильный статус на том же порту.
  Первый байт потока различает наш протокол и гостей: R - наш кадр,
  иначе старый пинг (FE) или новый handshake + request + ping.
  Логин в старом стиле получает вежливый отказ словами.
- Reader: первый байт, уже прочитанный вручную, теперь правильно
  исключается из головы - раньше счётчик дважды пропускал его.
- TokenAuth: точные причины отказа (не подписано, другой сервер,
  не тот вид, истёк, не тот игрок) и Verdict для каждой сессии.
- ClientSession: первое чтение смотрит на байт и отдаёт гостей статусу;
  остальное идёт через прежнее расщепление.
- StatusTest: 5 проверок (json, modern, pong, legacy, отказ). Легаси-пинг
  читался как знаковый байт; исправлено на беззнаковый.
- StatusRouteTest: 3 проверки через настоящий сервер и настоящие порты.
- watch-game.ps1: удаляет устаревший dev-token перед стартом сервера.
This commit is contained in:
sasheg dev
2026-10-22 19:25:06 +03:00
parent 6019654188
commit d98cd83fe5
8 changed files with 1012 additions and 31 deletions
+2
View File
@@ -55,6 +55,8 @@ $suites = @(
'net.rustnotmc.server.hitbox.HitboxTest', 'net.rustnotmc.server.hitbox.HitboxTest',
'net.rustnotmc.server.player.PlayerDataTest', 'net.rustnotmc.server.player.PlayerDataTest',
'net.rustnotmc.server.combat.DamageTest', 'net.rustnotmc.server.combat.DamageTest',
'net.rustnotmc.server.status.StatusTest',
'net.rustnotmc.server.status.StatusRouteTest',
'net.rustnotmc.server.PlayTest' 'net.rustnotmc.server.PlayTest'
) )
foreach ($suite in $suites) { foreach ($suite in $suites) {
@@ -79,6 +79,8 @@ public final class ClientSession implements Runnable {
private volatile int playerId = -1; private volatile int playerId = -1;
private volatile int entityId = -1; private volatile int entityId = -1;
private volatile String lastError; private volatile String lastError;
private boolean firstLook = true;
private volatile Player player; private volatile Player player;
private byte[] nonce; private byte[] nonce;
@@ -174,6 +176,20 @@ public final class ClientSession implements Runnable {
} }
} }
/**
* Whether the stream starts like our protocol.
*
* Our frames start with the magic "RSX1". The vanilla status question
* starts with something else, and the two are told apart by the first
* byte, so a port can serve both without waiting for the whole frame.
*/
private static boolean looksLikeOurs(byte[] chunk, int read) {
if (read <= 0) {
return true;
}
return (chunk[0] & 0xFF) == (net.rustnotmc.protocol.Frame.MAGIC >>> 24);
}
private void step() throws IOException { private void step() throws IOException {
if (expired()) { if (expired()) {
throw new ProtocolException("handshake took too long"); throw new ProtocolException("handshake took too long");
@@ -186,10 +202,22 @@ public final class ClientSession implements Runnable {
this.lastError = "peer closed the connection"; this.lastError = "peer closed the connection";
close(); close();
return; return;
}
if (this.firstLook) {
this.firstLook = false;
System.out.println("[DBG] first read=" + read + " b0=" + (read > 0 ? Integer.toHexString(chunk[0] & 0xFF) : "-"));
if (!looksLikeOurs(chunk, read)) {
// Not our protocol: the other side is asking about
// the server, so the server answers that question.
this.server.status().serve(this.socket, chunk, 0, read, this.address);
close();
return;
}
} }
this.feed(chunk, 0, read); this.feed(chunk, 0, read);
} catch (SocketTimeoutException nothingYet) { } catch (SocketTimeoutException nothingYet) {
// expected: the loop wakes regularly to check the deadline // expected: the loop wakes regularly to check the deadline
System.out.println("[DBG] step timeout");
} catch (SocketException gone) { } catch (SocketException gone) {
this.lastError = String.valueOf(gone.getMessage()); this.lastError = String.valueOf(gone.getMessage());
close(); close();
@@ -340,17 +368,17 @@ public final class ClientSession implements Runnable {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
TokenAuth checker = this.server.tokenAuth(); TokenAuth checker = this.server.tokenAuth();
if (checker != null) { if (checker != null) {
net.rustnotmc.auth.GameToken.Claims claims = TokenAuth.Verdict verdict = checker.verdict(given.uuid, given.token, now);
checker.check(given.uuid, given.token, now); if (!verdict.accepted()) {
if (claims == null) {
this.server.log("token of " + given.account + " refused: " this.server.log("token of " + given.account + " refused: "
+ "it is not ours, it is for another server, or it has run out"); + verdict.refusal.reason);
this.server.throttle().failed(given.account, this.address); this.server.throttle().failed(given.account, this.address);
send(Packets.AUTH_ACK, Packets.AuthAck.refused( send(Packets.AUTH_ACK, Packets.AuthAck.refused(
"the session is not valid, please sign in again")); "the session was refused: " + verdict.refusal.reason));
close(); close();
return; return;
} }
net.rustnotmc.auth.GameToken.Claims claims = verdict.claims;
// The name is inside the token, and the uuid has to be the same // The name is inside the token, and the uuid has to be the same
// one the token was made for. // one the token was made for.
@@ -372,11 +400,20 @@ public final class ClientSession implements Runnable {
// launcher. // launcher.
net.rustnotmc.auth.TokenService.Token own = net.rustnotmc.auth.TokenService.Token own =
this.server.tokens().check(given.token); this.server.tokens().check(given.token);
if (own == null || own.expired(now)) { if (own == null) {
this.server.log("token of " + given.account + " refused: it is not ours"); this.server.log("token of " + given.account + " refused: "
+ "this server did not give it out");
this.server.throttle().failed(given.account, this.address); this.server.throttle().failed(given.account, this.address);
send(Packets.AUTH_ACK, Packets.AuthAck.refused( send(Packets.AUTH_ACK, Packets.AuthAck.refused(
"the session is not valid, please sign in again")); "the session was refused: this server did not give out that token"));
close();
return;
}
if (own.expired(now)) {
this.server.log("token of " + given.account + " refused: it has run out");
this.server.throttle().failed(given.account, this.address);
send(Packets.AUTH_ACK, Packets.AuthAck.refused(
"the session was refused: the token has run out"));
close(); close();
return; return;
} }
@@ -31,7 +31,7 @@ import net.rustnotmc.server.world.World;
* depend on the graphics card, and the whole point of moving the simulation to * depend on the graphics card, and the whole point of moving the simulation to
* our own server is that the client stops being trusted. * our own server is that the client stops being trusted.
*/ */
public final class Server { public final class Server implements net.rustnotmc.server.status.StatusSession.Info {
/** Ticks per second. Fixed, and the same number the client counts. */ /** Ticks per second. Fixed, and the same number the client counts. */
public static final int TICKS_PER_SECOND = 20; public static final int TICKS_PER_SECOND = 20;
@@ -63,6 +63,10 @@ public final class Server {
new net.rustnotmc.server.player.PlayerData(null); new net.rustnotmc.server.player.PlayerData(null);
private volatile net.rustnotmc.server.world.LodGenerator lod; private volatile net.rustnotmc.server.world.LodGenerator lod;
/** The status of the server, in the way the game asks for it. */
private final net.rustnotmc.server.status.StatusSession status =
new net.rustnotmc.server.status.StatusSession(this);
/** /**
* The address the server listens on. * The address the server listens on.
* *
@@ -275,6 +279,11 @@ public final class Server {
this.bindAddress = address; this.bindAddress = address;
} }
/** The status of the server, in the way the game asks for it. */
public net.rustnotmc.server.status.StatusSession status() {
return this.status;
}
/** The far view of the world. */ /** The far view of the world. */
public net.rustnotmc.server.world.LodGenerator lod() { public net.rustnotmc.server.world.LodGenerator lod() {
return this.lod; return this.lod;
@@ -354,6 +363,17 @@ public final class Server {
return this.name; return this.name;
} }
@Override
public String name() {
return this.serverName();
}
@Override
public String version() {
return "RustNotMC 1.0";
}
@Override
public String motd() { public String motd() {
return this.motd; return this.motd;
} }
@@ -42,6 +42,83 @@ public final class TokenAuth {
return this.audience; return this.audience;
} }
/** Why a token was not taken. */
public enum Refusal {
/** Nothing is wrong with the token. */
NONE(""),
/** The signature does not match: the token is not from this server. */
NOT_SIGNED("the token is not signed by this server"),
/** The token was made for another server. */
WRONG_AUDIENCE("the token is meant for another server"),
/** The token is a reload token, not a session token. */
WRONG_KIND("that token is not a session token"),
/** The token has run out. */
EXPIRED("the token has run out"),
/** The token was made for another player. */
WRONG_PLAYER("the token belongs to another player");
/** What to tell the player. */
public final String reason;
Refusal(String reason) {
this.reason = reason;
}
}
/** The answer to a token: what it says, or why it was refused. */
public static final class Verdict {
/** What the token says, or null when it was refused. */
public final GameToken.Claims claims;
/** Why the token was refused. */
public final Refusal refusal;
Verdict(GameToken.Claims claims, Refusal refusal) {
this.claims = claims;
this.refusal = refusal;
}
/** Whether the token was taken. */
public boolean accepted() {
return this.claims != null;
}
}
/**
* Checks a token and says what was wrong.
*
* The reason matters: "the token is not ours" tells a player nothing, and
* a player who cannot tell a stale token from a foreign one cannot fix
* anything either.
*/
public Verdict verdict(String uuid, String token, long now) {
GameToken.Claims claims = this.tokens.read(token);
if (claims == null) {
return new Verdict(null, Refusal.NOT_SIGNED);
}
if (!GameToken.KIND_SESSION.equals(claims.kind)) {
return new Verdict(null, Refusal.WRONG_KIND);
}
if (!this.audience.equals(claims.audience)) {
return new Verdict(null, Refusal.WRONG_AUDIENCE);
}
if (claims.expiresAt <= now) {
return new Verdict(null, Refusal.EXPIRED);
}
if (uuid != null && !uuid.isEmpty() && claims.uuid != null
&& !uuid.equals(claims.uuid)) {
return new Verdict(null, Refusal.WRONG_PLAYER);
}
return new Verdict(claims, Refusal.NONE);
}
/** /**
* Checks a token. * Checks a token.
* *
@@ -49,24 +126,7 @@ public final class TokenAuth {
* caller tells the player what was wrong. * caller tells the player what was wrong.
*/ */
public GameToken.Claims check(String uuid, String token, long now) { public GameToken.Claims check(String uuid, String token, long now) {
GameToken.Claims claims = this.tokens.read(token); return verdict(uuid, token, now).claims;
if (claims == null) {
return null;
}
if (!GameToken.KIND_SESSION.equals(claims.kind)) {
return null;
}
if (!this.audience.equals(claims.audience)) {
return null;
}
if (claims.expiresAt <= now) {
return null;
}
if (uuid != null && !uuid.isEmpty() && claims.uuid != null
&& !uuid.equals(claims.uuid)) {
return null;
}
return claims;
} }
/** /**
@@ -0,0 +1,334 @@
package net.rustnotmc.server.status;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
/**
* The status of the server, in the way the game asks for it.
*
* The vanilla client checks a server before it lets anybody in: it sends a
* handshake, then a request, and waits for an answer that says how the server
* calls itself, how many players are on it and how many may come. The answer
* is a small piece of text in a shape everybody knows.
*
* Our own protocol has a magic of its own, so a port can tell at once that it
* is being spoken nonsense. But a server that cannot be seen is a server that
* does not exist for half of the tools around, so the port answers the
* vanilla question too: the first bytes say which of the two conversations
* this is.
*
* The vanilla client cannot join through this door: the game speaks our
* protocol, not the vanilla one. It is told so in words instead of being cut
* off in the middle of a handshake.
*/
public final class StatusSession {
/** The packet id of a handshake in the vanilla protocol. */
private static final int VANILLA_HANDSHAKE = 0x00;
/** The packet id of a status request in the vanilla protocol. */
private static final int VANILLA_REQUEST = 0x00;
/** The packet id of a ping in the vanilla protocol. */
private static final int VANILLA_PING = 0x01;
/** The byte a legacy ping starts with. */
private static final int LEGACY_PING = 0xFE;
/** The byte a legacy answer starts with. */
private static final int LEGACY_ANSWER = 0xFF;
/** The state of the vanilla protocol that means "the status is wanted". */
private static final int STATE_STATUS = 1;
/** The state of the vanilla protocol that means "somebody wants in". */
private static final int STATE_LOGIN = 2;
/** How long the answer may take. */
private static final int TIMEOUT_MILLIS = 4000;
/** What the server says about itself. */
public interface Info {
/** The name of the server. */
String name();
/** The line under the name. */
String motd();
/** How many players are on the server right now. */
int online();
/** How many players may come. */
int capacity();
/** The version the server calls itself. */
String version();
}
private final Info info;
public StatusSession(Info info) {
this.info = info;
}
/**
* Answers one status conversation.
*
* The bytes that were already read are handed over, so that nothing is
* lost between the first look and the rest of the stream.
*/
public void serve(Socket socket, byte[] head, int offset, int length, String address) {
try {
socket.setSoTimeout(TIMEOUT_MILLIS);
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
// The first byte says which of the two conversations this is.
int first;
if (length > 0) {
first = head[offset] & 0xFF;
} else {
first = in.read();
if (first < 0) {
return;
}
}
Reader reader = new Reader(head, offset, length, in, first);
if (first == LEGACY_PING) {
// the marker is read; the rest of the old packet is not
reader.skipPending();
legacy(reader, out);
return;
}
modern(reader, out);
} catch (IOException broken) {
// the other side went away; nothing to say about it
} catch (RuntimeException broken) {
// a broken status request must never take the server down
}
}
/** The old way of asking: one packet, one answer. */
private void legacy(Reader reader, OutputStream out) throws IOException {
reader.readUnsignedByte(); // 0x01
reader.readUnsignedByte(); // 0xFA
int length = reader.readUnsignedByte(); // length of the rest
for (int i = 0; i < length; i++) {
reader.readUnsignedByte();
}
String text = "§1\000" + vanillaProtocol() + "\000" + this.info.version()
+ "\000" + this.info.motd() + "\000" + this.info.online()
+ "\000" + this.info.capacity();
byte[] body = text.getBytes(StandardCharsets.UTF_8);
out.write(LEGACY_ANSWER);
out.write((body.length >> 8) & 0xFF);
out.write(body.length & 0xFF);
out.write(body);
out.flush();
}
/** The way the game asks today: a handshake, a request, a ping. */
private void modern(Reader reader, OutputStream out) throws IOException {
int packetId = reader.readUnsignedByte();
if (packetId != VANILLA_HANDSHAKE) {
return;
}
reader.readVarInt(); // the protocol version of the other side
reader.readString(); // the name the player typed
reader.readShort(); // the port
int state = reader.readVarInt();
if (state == STATE_LOGIN) {
// Somebody wants in through the vanilla door. They cannot come in
// that way, but they can be told why in words they will see.
drain(reader, 512);
String reason = "{\"text\":\"This is a RustNotMC server. "
+ "It is played with the RustNotMC client, not with the vanilla one.\"}";
byte[] body = reason.getBytes(StandardCharsets.UTF_8);
out.write(0x00);
writeVarInt(out, body.length);
out.write(body);
out.flush();
return;
}
if (state != STATE_STATUS) {
return;
}
// The answer goes out right away: the client asks for the status and
// for the latency at the same time, and the latency is a ping that
// comes after the answer.
byte[] json = statusJson().getBytes(StandardCharsets.UTF_8);
out.write(VANILLA_REQUEST);
writeVarInt(out, json.length);
out.write(json);
out.flush();
// and now the ping, if it comes. The request itself is of no use
// here, so it is passed over.
try {
while (true) {
int next = reader.readUnsignedByte();
if (next != VANILLA_PING) {
continue;
}
long stamp = 0L;
for (int i = 0; i < 8; i++) {
stamp = (stamp << 8) | reader.readUnsignedByte();
}
out.write(VANILLA_PING);
for (int i = 0; i < 8; i++) {
out.write((int) ((stamp >>> (8 * (7 - i))) & 0xFFL));
}
out.flush();
return;
}
} catch (IOException nothingMore) {
// the client did not want the latency; the status was enough
}
}
/** The status, in the shape the game expects. */
public String statusJson() {
StringBuilder out = new StringBuilder(256);
out.append("{\"version\":{\"name\":")
.append(net.rustnotmc.server.util.Json.quote(this.info.version()))
.append(",\"protocol\":").append(vanillaProtocol())
.append("},\"players\":{\"max\":").append(this.info.capacity())
.append(",\"online\":").append(this.info.online())
.append(",\"sample\":[]}")
.append(",\"description\":{\"text\":")
.append(net.rustnotmc.server.util.Json.quote(this.info.motd()))
.append("}}");
return out.toString();
}
/**
* The protocol number the game knows.
*
* The server does not speak the vanilla protocol, but the number is what
* the tools that list servers show next to the name.
*/
private int vanillaProtocol() {
return 404;
}
/** Reads what is there and throws the rest away. */
private static void drain(Reader reader, int limit) {
try {
for (int i = 0; i < limit; i++) {
reader.readUnsignedByte();
}
} catch (IOException nothingMore) {
// there is nothing more to read
}
}
/** Writes a varint the way the game does: seven bits at a time. */
private static void writeVarInt(OutputStream out, int value) throws IOException {
int left = value;
while (true) {
int part = left & 0x7F;
left >>>= 7;
if (left != 0) {
part |= 0x80;
}
out.write(part);
if (left == 0) {
return;
}
}
}
/**
* Reads the little pieces of the vanilla protocol.
*
* The bytes that were already read from the socket come first, and the
* rest is taken from the stream.
*/
private static final class Reader {
/** The bytes that were read already. */
private final byte[] head;
/** The next byte of those to read. */
private int pos;
/** Where the bytes that were read already end. */
private final int limit;
/** The rest of the stream. */
private final InputStream in;
/** A byte that was read before the reader was made. */
private int pending;
Reader(byte[] head, int offset, int length, InputStream in, int pending) {
this.head = head;
// When the first byte came out of the head, the head still holds a
// copy of it that was already counted in `pending` (it is read
// again from `pending`, not from the head).
this.pos = length > 0 ? offset + 1 : offset;
this.limit = offset + length;
this.in = in;
this.pending = pending;
}
/** Throws away a byte that was read before the reader was made. */
void skipPending() {
this.pending = -1;
}
int readUnsignedByte() throws IOException {
if (this.pending >= 0) {
int b = this.pending;
this.pending = -1;
return b;
}
if (this.pos < this.limit) {
return this.head[this.pos++] & 0xFF;
}
int b = this.in.read();
if (b < 0) {
throw new IOException("the other side closed the connection");
}
return b;
}
int readVarInt() throws IOException {
int result = 0;
int shift = 0;
while (true) {
int b = readUnsignedByte();
result |= (b & 0x7F) << shift;
if ((b & 0x80) == 0) {
return result;
}
shift += 7;
if (shift > 35) {
throw new IOException("the varint is too long");
}
}
}
String readString() throws IOException {
int length = readVarInt();
byte[] bytes = new byte[length];
for (int i = 0; i < length; i++) {
bytes[i] = (byte) readUnsignedByte();
}
return new String(bytes, StandardCharsets.UTF_8);
}
int readShort() throws IOException {
int a = readUnsignedByte();
int b = readUnsignedByte();
return (a << 8) | b;
}
}
}
@@ -0,0 +1,220 @@
package net.rustnotmc.server.status;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
import net.rustnotmc.server.Server;
import net.rustnotmc.server.world.ChunkStorage;
import net.rustnotmc.server.world.World;
/**
* Tests the status question the way it really arrives: the bytes go to a live
* server, a session takes them and hands them over to the status.
*/
public final class StatusRouteTest {
private static int checks;
private static int failed;
private static final java.util.List<String> failures = new java.util.ArrayList<String>();
public static void main(String[] args) throws Exception {
Path work = Files.createTempDirectory("status-route");
Server server = StatusRouteTest.boot(work);
try {
legacy(server.port());
modern(server.port());
} finally {
server.stop();
}
System.out.println();
for (int i = 0; i < failures.size(); i++) {
System.out.println(" FAIL " + failures.get(i));
}
System.out.println();
System.out.println("checks: " + checks + ", failed: " + failed);
if (failed != 0) {
System.out.println("FAILED");
System.exit(1);
}
System.out.println("OK");
}
/** A small server that only has to answer the question of the status. */
private static Server boot(Path work) throws Exception {
Server server = new Server("RustNotMC route", "hello", 200, 0,
new World(1234L, ChunkStorage.memoryOnly()));
Path accounts = work.resolve("route-accounts.txt");
server.loadAccounts(accounts);
server.start();
return server;
}
private interface Throwing {
void go() throws Exception;
}
private static void run(String name, Throwing body) {
checks++;
int before = failed;
try {
body.go();
} catch (Throwable t) {
failed++;
failures.add(name + ": threw " + t);
t.printStackTrace();
return;
}
if (failed == before) {
System.out.println(" ok " + name);
} else {
System.out.println(" FAIL " + name);
}
}
private static void eq(Object what, Object expected, Object actual) {
if (expected == null ? actual != null : !expected.equals(actual)) {
failed++;
failures.add(what + ": expected " + expected + " but got " + actual);
}
}
private static void yes(String what, boolean condition) {
if (!condition) {
failed++;
failures.add(what);
}
}
private static void legacy(int port) throws Exception {
run("the old question is answered on a live port", new Throwing() {
public void go() throws Exception {
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(5000);
DataOutputStream out = new DataOutputStream(
socket.getOutputStream());
out.writeByte(0xFE);
out.writeByte(0x01);
out.writeByte(0xFA);
out.writeByte(0x01);
out.writeByte(0x00);
out.flush();
DataInputStream in = new DataInputStream(
socket.getInputStream());
eq("the answer is a legacy one", Integer.valueOf(0xFF),
Integer.valueOf(in.readUnsignedByte()));
int length = in.readUnsignedShort();
byte[] body = new byte[length];
in.readFully(body);
String text = new String(body,
java.nio.charset.StandardCharsets.UTF_8);
yes("the answer says the players", text.contains("2"));
yes("the answer says the places", text.contains("200"));
}
}
});
}
private static void modern(int port) throws Exception {
run("the new question is answered on a live port", new Throwing() {
public void go() throws Exception {
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(5000);
DataOutputStream out = new DataOutputStream(
socket.getOutputStream());
out.writeByte(0x00); // the handshake packet
writeVarInt(out, 404); // the protocol version
writeString(out, "127.0.0.1");
out.writeShort(port);
writeVarInt(out, 1); // the status is wanted
out.writeByte(0x00); // the request
out.flush();
DataInputStream in = new DataInputStream(
socket.getInputStream());
eq("the answer is a status", Integer.valueOf(0x00),
Integer.valueOf(in.readUnsignedByte()));
String json = readString(in);
yes("the answer names the server", json.contains("hello"));
yes("the answer says the players", json.contains("\"online\":0"));
long mark = 0x0102030405060708L;
out.writeByte(0x01);
out.writeLong(mark);
out.flush();
eq("the answer is a pong", Integer.valueOf(0x01),
Integer.valueOf(in.readUnsignedByte()));
eq("the mark comes back", Long.valueOf(mark),
Long.valueOf(in.readLong()));
}
}
});
run("a game in vanilla clothes is turned away in words", new Throwing() {
public void go() throws Exception {
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(5000);
DataOutputStream out = new DataOutputStream(
socket.getOutputStream());
out.writeByte(0x00); // the handshake packet
writeVarInt(out, 404);
writeString(out, "127.0.0.1");
out.writeShort(port);
writeVarInt(out, 2); // somebody wants in
out.writeByte(0x00); // the login packet
writeString(out, "Player");
out.flush();
DataInputStream in = new DataInputStream(
socket.getInputStream());
eq("the answer is a disconnect", Integer.valueOf(0x00),
Integer.valueOf(in.readUnsignedByte()));
String reason = readString(in);
yes("the reason says what to use", reason.contains("RustNotMC"));
}
}
});
}
private static void writeVarInt(DataOutputStream out, int value) throws IOException {
int left = value;
while (true) {
int part = left & 0x7F;
left >>>= 7;
if (left != 0) {
part |= 0x80;
}
out.writeByte(part);
if (left == 0) {
return;
}
}
}
private static void writeString(DataOutputStream out, String value) throws IOException {
byte[] bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8);
writeVarInt(out, bytes.length);
out.write(bytes);
}
private static String readString(DataInputStream in) throws IOException {
int length = 0;
int shift = 0;
while (true) {
int b = in.readByte() & 0xFF;
length |= (b & 0x7F) << shift;
if ((b & 0x80) == 0) {
break;
}
shift += 7;
}
byte[] bytes = new byte[length];
in.readFully(bytes);
return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,300 @@
package net.rustnotmc.server.status;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
/**
* Tests the answer to the vanilla status question.
*
* The game checks a server before it lets anybody in, and the tools that list
* servers do the same. The server has to answer that question on the same
* port it plays on, and the answer has to say how the server calls itself,
* how many players are on it and how many may come.
*/
public final class StatusTest {
private static int checks;
private static int failed;
private static final java.util.List<String> failures = new java.util.ArrayList<String>();
public static void main(String[] args) throws Exception {
run("the status says who the server is", new Throwing() {
public void go() throws Exception { testJson(); }
});
run("the vanilla question is answered", new Throwing() {
public void go() throws Exception { testModern(); }
});
run("the latency is measured", new Throwing() {
public void go() throws Exception { testPing(); }
});
run("the old way of asking is answered too", new Throwing() {
public void go() throws Exception { testLegacy(); }
});
run("a vanilla login is turned away in words", new Throwing() {
public void go() throws Exception { testLogin(); }
});
System.out.println();
for (int i = 0; i < failures.size(); i++) {
System.out.println(" FAIL " + failures.get(i));
}
System.out.println();
System.out.println("checks: " + checks + ", failed: " + failed);
if (failed != 0) {
System.out.println("FAILED");
System.exit(1);
}
System.out.println("OK");
}
private interface Throwing {
void go() throws Exception;
}
private static void run(String name, Throwing body) {
checks++;
int before = failed;
try {
body.go();
} catch (Throwable t) {
failed++;
failures.add(name + ": threw " + t);
t.printStackTrace();
return;
}
if (failed == before) {
System.out.println(" ok " + name);
} else {
System.out.println(" FAIL " + name);
}
}
private static void eq(Object what, Object expected, Object actual) {
if (expected == null ? actual != null : !expected.equals(actual)) {
failed++;
failures.add(what + ": expected " + expected + " but got " + actual);
}
}
private static void yes(String what, boolean condition) {
if (!condition) {
failed++;
failures.add(what);
}
}
/** A server that calls itself one thing and holds two players. */
private static final class Fake implements StatusSession.Info {
public String name() {
return "RustNotMC";
}
public String motd() {
return "an island in the sea";
}
public int online() {
return 2;
}
public int capacity() {
return 200;
}
public String version() {
return "RustNotMC 1.0";
}
}
private static void testJson() {
String json = new StatusSession(new Fake()).statusJson();
yes("the name is in there", json.contains("\"RustNotMC 1.0\""));
yes("the line under the name is in there", json.contains("an island in the sea"));
yes("the players are in there", json.contains("\"online\":2"));
yes("the places are in there", json.contains("\"max\":200"));
yes("it is one piece of text", json.startsWith("{") && json.endsWith("}"));
}
private static void testModern() throws Exception {
try (ServerSocket listener = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
Thread server = new Thread(() -> {
try (Socket socket = listener.accept()) {
new StatusSession(new Fake()).serve(socket,
new byte[0], 0, 0, "127.0.0.1");
} catch (IOException ignored) {
// the test is over anyway
}
});
server.start();
try (Socket socket = new Socket("127.0.0.1", listener.getLocalPort())) {
socket.setSoTimeout(5000);
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
out.writeByte(0x00); // the handshake
writeVarInt(out, 404); // the protocol version
writeString(out, "127.0.0.1");
out.writeShort(listener.getLocalPort());
writeVarInt(out, 1); // the status is wanted
out.writeByte(0x00); // the request
out.flush();
DataInputStream in = new DataInputStream(socket.getInputStream());
eq("the answer is a status", Integer.valueOf(0x00), Integer.valueOf(in.readByte()));
String json = readString(in);
yes("the answer names the server", json.contains("RustNotMC 1.0"));
yes("the answer says how many players", json.contains("\"online\":2"));
}
server.join(5000);
}
}
private static void testPing() throws Exception {
try (ServerSocket listener = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
Thread server = new Thread(() -> {
try (Socket socket = listener.accept()) {
new StatusSession(new Fake()).serve(socket,
new byte[0], 0, 0, "127.0.0.1");
} catch (IOException ignored) {
// the test is over anyway
}
});
server.start();
try (Socket socket = new Socket("127.0.0.1", listener.getLocalPort())) {
socket.setSoTimeout(5000);
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
out.writeByte(0x00);
writeVarInt(out, 404);
writeString(out, "127.0.0.1");
out.writeShort(listener.getLocalPort());
writeVarInt(out, 1);
out.writeByte(0x00);
out.flush();
DataInputStream in = new DataInputStream(socket.getInputStream());
in.readByte();
readString(in);
// and now the ping, with a mark of its own
long mark = 0x0102030405060708L;
out.writeByte(0x01);
out.writeLong(mark);
out.flush();
eq("the answer is a pong", Integer.valueOf(0x01), Integer.valueOf(in.readByte()));
eq("the mark comes back", Long.valueOf(mark), Long.valueOf(in.readLong()));
}
server.join(5000);
}
}
private static void testLegacy() throws Exception {
try (ServerSocket listener = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
Thread server = new Thread(() -> {
try (Socket socket = listener.accept()) {
new StatusSession(new Fake()).serve(socket,
new byte[0], 0, 0, "127.0.0.1");
} catch (IOException ignored) {
ignored.printStackTrace();
}
});
server.start();
try (Socket socket = new Socket("127.0.0.1", listener.getLocalPort())) {
socket.setSoTimeout(5000);
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
out.writeByte(0xFE);
out.writeByte(0x01);
out.writeByte(0xFA);
out.writeByte(0x01);
out.writeByte(0x00);
out.flush();
DataInputStream in = new DataInputStream(socket.getInputStream());
eq("the answer is a legacy one", Integer.valueOf(0xFF),
Integer.valueOf(in.readUnsignedByte()));
int length = in.readUnsignedShort();
byte[] body = new byte[length];
in.readFully(body);
String text = new String(body, java.nio.charset.StandardCharsets.UTF_8);
yes("the answer says how many players", text.contains("2"));
yes("the answer says how many places", text.contains("200"));
}
server.join(5000);
}
}
private static void testLogin() throws Exception {
try (ServerSocket listener = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
Thread server = new Thread(() -> {
try (Socket socket = listener.accept()) {
new StatusSession(new Fake()).serve(socket,
new byte[0], 0, 0, "127.0.0.1");
} catch (IOException ignored) {
// the test is over anyway
}
});
server.start();
try (Socket socket = new Socket("127.0.0.1", listener.getLocalPort())) {
socket.setSoTimeout(5000);
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
out.writeByte(0x00);
writeVarInt(out, 404);
writeString(out, "127.0.0.1");
out.writeShort(listener.getLocalPort());
writeVarInt(out, 2); // somebody wants in
out.writeByte(0x00); // the login
writeString(out, "Player");
out.flush();
DataInputStream in = new DataInputStream(socket.getInputStream());
eq("the answer is a disconnect", Integer.valueOf(0x00),
Integer.valueOf(in.readByte()));
String reason = readString(in);
yes("the reason says what to use", reason.contains("RustNotMC"));
}
server.join(5000);
}
}
private static void writeVarInt(DataOutputStream out, int value) throws IOException {
int left = value;
while (true) {
int part = left & 0x7F;
left >>>= 7;
if (left != 0) {
part |= 0x80;
}
out.writeByte(part);
if (left == 0) {
return;
}
}
}
private static void writeString(DataOutputStream out, String value) throws IOException {
byte[] bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8);
writeVarInt(out, bytes.length);
out.write(bytes);
}
private static String readString(DataInputStream in) throws IOException {
int length = 0;
int shift = 0;
while (true) {
int b = in.readByte() & 0xFF;
length |= (b & 0x7F) << shift;
if ((b & 0x80) == 0) {
break;
}
shift += 7;
}
byte[] bytes = new byte[length];
in.readFully(bytes);
return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
}
}
+12 -4
View File
@@ -43,7 +43,10 @@ if ($line) {
} }
$tokenFile = Join-Path $Root 'rsx-server-core\world\dev-token.txt' $tokenFile = Join-Path $Root 'rsx-server-core\world\dev-token.txt'
if ($Fresh -and (Test-Path $tokenFile)) { Remove-Item $tokenFile -Force } # The server makes a new secret every time it starts, so the token of the
# last run is of no use to it. It has to go, or the client will be given a
# token the server does not know and will be turned away at the door.
if (Test-Path $tokenFile) { Remove-Item $tokenFile -Force }
Write-Host 'starting the server' Write-Host 'starting the server'
$serverArgs = @( $serverArgs = @(
@@ -51,7 +54,8 @@ $serverArgs = @(
'--port', "$Port", '--port', "$Port",
'--accounts', (Join-Path $Root 'rsx-server-core\accounts.txt'), '--accounts', (Join-Path $Root 'rsx-server-core\accounts.txt'),
'--world', (Join-Path $Root 'rsx-server-core\world'), '--world', (Join-Path $Root 'rsx-server-core\world'),
'--dev-token', $User '--dev-token', $User,
'--bind', '127.0.0.1'
) )
$java = 'D:\jdk-21\bin\java.exe' $java = 'D:\jdk-21\bin\java.exe'
if (-not (Test-Path $java)) { $java = (Get-Command java).Source } if (-not (Test-Path $java)) { $java = (Get-Command java).Source }
@@ -59,11 +63,15 @@ $server = Start-Process -FilePath $java -ArgumentList $serverArgs `
-RedirectStandardOutput $ServerLog -RedirectStandardError "$ServerLog.err" -PassThru -RedirectStandardOutput $ServerLog -RedirectStandardError "$ServerLog.err" -PassThru
Write-Host "server pid $($server.Id), log $ServerLog" Write-Host "server pid $($server.Id), log $ServerLog"
# the server writes its own token, and the client needs it # The server writes its own token, and the client needs it. The file has to be
# the one this run wrote, not the one of the run before.
$token = $null $token = $null
for ($i = 0; $i -lt 40; $i++) { $started = Get-Date
for ($i = 0; $i -lt 60; $i++) {
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
if (Test-Path $tokenFile) { if (Test-Path $tokenFile) {
$written = (Get-Item $tokenFile).LastWriteTime
if ($written -lt $started) { continue }
$token = (Get-Content $tokenFile -Raw).Trim() $token = (Get-Content $tokenFile -Raw).Trim()
if ($token) { break } if ($token) { break }
} }