Пинг ванильным методом и причины отказа по токину
- 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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user