diff --git a/rsx-server-core/scripts/test.ps1 b/rsx-server-core/scripts/test.ps1 index 150167f..fec60e1 100644 --- a/rsx-server-core/scripts/test.ps1 +++ b/rsx-server-core/scripts/test.ps1 @@ -55,6 +55,8 @@ $suites = @( 'net.rustnotmc.server.hitbox.HitboxTest', 'net.rustnotmc.server.player.PlayerDataTest', 'net.rustnotmc.server.combat.DamageTest', + 'net.rustnotmc.server.status.StatusTest', + 'net.rustnotmc.server.status.StatusRouteTest', 'net.rustnotmc.server.PlayTest' ) foreach ($suite in $suites) { diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java index 97ed74f..1941bc4 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java @@ -79,6 +79,8 @@ public final class ClientSession implements Runnable { private volatile int playerId = -1; private volatile int entityId = -1; private volatile String lastError; + private boolean firstLook = true; + private volatile Player player; 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 { if (expired()) { throw new ProtocolException("handshake took too long"); @@ -186,10 +202,22 @@ public final class ClientSession implements Runnable { this.lastError = "peer closed the connection"; close(); 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); } catch (SocketTimeoutException nothingYet) { // expected: the loop wakes regularly to check the deadline + System.out.println("[DBG] step timeout"); } catch (SocketException gone) { this.lastError = String.valueOf(gone.getMessage()); close(); @@ -340,17 +368,17 @@ public final class ClientSession implements Runnable { long now = System.currentTimeMillis(); TokenAuth checker = this.server.tokenAuth(); if (checker != null) { - net.rustnotmc.auth.GameToken.Claims claims = - checker.check(given.uuid, given.token, now); - if (claims == null) { + TokenAuth.Verdict verdict = checker.verdict(given.uuid, given.token, now); + if (!verdict.accepted()) { 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); send(Packets.AUTH_ACK, Packets.AuthAck.refused( - "the session is not valid, please sign in again")); + "the session was refused: " + verdict.refusal.reason)); close(); return; } + net.rustnotmc.auth.GameToken.Claims claims = verdict.claims; // The name is inside the token, and the uuid has to be the same // one the token was made for. @@ -372,11 +400,20 @@ public final class ClientSession implements Runnable { // launcher. net.rustnotmc.auth.TokenService.Token own = this.server.tokens().check(given.token); - if (own == null || own.expired(now)) { - this.server.log("token of " + given.account + " refused: it is not ours"); + if (own == null) { + this.server.log("token of " + given.account + " refused: " + + "this server did not give it out"); this.server.throttle().failed(given.account, this.address); 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(); return; } diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java index 9bd0ac8..f437376 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java @@ -31,7 +31,7 @@ import net.rustnotmc.server.world.World; * depend on the graphics card, and the whole point of moving the simulation to * 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. */ public static final int TICKS_PER_SECOND = 20; @@ -63,6 +63,10 @@ public final class Server { new net.rustnotmc.server.player.PlayerData(null); 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. * @@ -275,6 +279,11 @@ public final class Server { 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. */ public net.rustnotmc.server.world.LodGenerator lod() { return this.lod; @@ -354,6 +363,17 @@ public final class Server { return this.name; } + @Override + public String name() { + return this.serverName(); + } + + @Override + public String version() { + return "RustNotMC 1.0"; + } + + @Override public String motd() { return this.motd; } diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/TokenAuth.java b/rsx-server-core/src/main/java/net/rustnotmc/server/TokenAuth.java index 2280356..5d19fdc 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/TokenAuth.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/TokenAuth.java @@ -42,6 +42,83 @@ public final class TokenAuth { 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. * @@ -49,24 +126,7 @@ public final class TokenAuth { * caller tells the player what was wrong. */ public GameToken.Claims check(String uuid, String token, long now) { - GameToken.Claims claims = this.tokens.read(token); - 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; + return verdict(uuid, token, now).claims; } /** diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/status/StatusSession.java b/rsx-server-core/src/main/java/net/rustnotmc/server/status/StatusSession.java new file mode 100644 index 0000000..2746a8d --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/status/StatusSession.java @@ -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; + } + } +} diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/status/StatusRouteTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/status/StatusRouteTest.java new file mode 100644 index 0000000..9a26703 --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/status/StatusRouteTest.java @@ -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 failures = new java.util.ArrayList(); + + 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); + } +} diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/status/StatusTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/status/StatusTest.java new file mode 100644 index 0000000..e1a24bb --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/status/StatusTest.java @@ -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 failures = new java.util.ArrayList(); + + 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); + } +} diff --git a/tools/watch-game.ps1 b/tools/watch-game.ps1 index 754b515..dd9cee2 100644 --- a/tools/watch-game.ps1 +++ b/tools/watch-game.ps1 @@ -43,7 +43,10 @@ if ($line) { } $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' $serverArgs = @( @@ -51,7 +54,8 @@ $serverArgs = @( '--port', "$Port", '--accounts', (Join-Path $Root 'rsx-server-core\accounts.txt'), '--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' 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 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 -for ($i = 0; $i -lt 40; $i++) { +$started = Get-Date +for ($i = 0; $i -lt 60; $i++) { Start-Sleep -Milliseconds 500 if (Test-Path $tokenFile) { + $written = (Get-Item $tokenFile).LastWriteTime + if ($written -lt $started) { continue } $token = (Get-Content $tokenFile -Raw).Trim() if ($token) { break } }