From 46784aa88f99dcea314b9c021e95ec31dc53e80f Mon Sep 17 00:00:00 2001 From: sasheg dev <152582704+SashegDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:10:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=20=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D1=80=D0=B8=D1=82=20=D1=81=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=BC=20=D0=BF=D0=BE=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=88=D0=B5=D0=BC=D1=83=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше клиент умел только показывать меню. Теперь он заходит на сервер, получает наш мир и рисует его. Как это устроено: - Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть мир, игрок, сущности и рендер, и все они работают. Не переписывать их ради того, чтобы нарисовать тот же мир иначе, незачем. - Пакеты игры, которые не имеют соответствия у нас, не отправляются, а наоборот: перемещение игрока и ломание блока переводятся в наши. - Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера, кем пришёл игрок, и подставляет это в рукопожатие. - Соединение качается из тика игры, а не из экрана. Рукопожатие, которое зависит от того, что экран ещё на экране, ломается alt-tab'ом. Вход теперь через лаунчер, а не внутри игры: - Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен (--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в ванилле, только лаунчер наш. - Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает бан-лист. Токен используется один раз и не хранится. - Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя дверями не имеет дверей. Открыть их можно флагом, но намеренно. - Если игру запустили без лаунчера, экран всё равно спрашивает имя и пароль: это путь для своего сервера и для разработки. Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и лежат на диске, рельеф нарисован. Что чинилось по дороге: - Minecraft.setIngameFocus() возвращал на главное меню, когда окно получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и экран входа исчезал в этот самый момент. - Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и {"valid":true} это один и тот же ответ, и сервер, который понимает только второй, ломает работающий лаунчер. --- .../java/net/rustnotmc/protocol/Packets.java | 43 ++++ .../net/rustnotmc/protocol/ProtocolTest.java | 10 +- .../net/rustnotmc/server/ClientSession.java | 62 ++++- .../net/rustnotmc/server/LauncherAuth.java | 211 ++++++++++++++++++ .../java/net/rustnotmc/server/Server.java | 32 +++ .../java/net/rustnotmc/server/ServerMain.java | 31 ++- .../java/net/rustnotmc/server/PlayClient.java | 15 +- .../java/net/rustnotmc/server/PlayTest.java | 142 ++++++++++++ 8 files changed, 541 insertions(+), 5 deletions(-) create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/LauncherAuth.java diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java index 95c4d46..f63a054 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java @@ -92,6 +92,16 @@ public final class Packets { public static final int ENTITY_SPAWN = 0x18; public static final int ENTITY_REMOVE = 0x19; + /** + * The launcher's session, presented to the game server. + * + * The launcher logs the player in once and hands the game a token. The + * game server asks the launcher whether that token is good, and the player + * is in. This is the part of the handshake that Yggdrasil does in + * vanilla, done with our own launcher instead of Mojang's. + */ + public static final int LAUNCHER_AUTH = 0x1A; + public static final int DISCONNECT = 0x7F; /** The client introducing itself, and naming the account it wants. */ @@ -740,6 +750,38 @@ public final class Packets { } } + /** + * The launcher's session, as the game server sees it. + * + * The token is a bearer credential: whoever holds it is the player. It is + * sent to the game server and nowhere else, and the game server uses it + * once, to ask the launcher whether it is good. + */ + public static final class LauncherAuth implements Packet { + public final String account; + public final String uuid; + public final String token; + + public LauncherAuth(String account, String uuid, String token) { + this.account = account; + this.uuid = uuid; + this.token = token; + } + + public Buf write() { + Buf buf = new Buf(512); + buf.writeString(this.account); + buf.writeString(this.uuid); + buf.writeString(this.token); + return buf; + } + + public static LauncherAuth read(Buf in) { + return new LauncherAuth(in.readString(MAX_TEXT), in.readString(MAX_TEXT), + in.readString(MAX_TEXT)); + } + } + /** * Every packet id this build knows, and what it decodes into. * @@ -764,6 +806,7 @@ public final class Packets { map.put(Integer.valueOf(PONG), "Pong"); map.put(Integer.valueOf(ENTITY_SPAWN), "EntitySpawn"); map.put(Integer.valueOf(ENTITY_REMOVE), "EntityRemove"); + map.put(Integer.valueOf(LAUNCHER_AUTH), "LauncherAuth"); map.put(Integer.valueOf(DISCONNECT), "Disconnect"); return map; } diff --git a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java index e3edfac..819c9eb 100644 --- a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java +++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java @@ -470,6 +470,14 @@ public final class ProtocolTest { Packets.EntityRemove remove = new Packets.EntityRemove(11); eq("remove", Integer.valueOf(11), Integer.valueOf( Packets.EntityRemove.read(new Buf(remove.write().toByteArray())).entityId)); + + Packets.LauncherAuth launcher = new Packets.LauncherAuth("Player", + "00000000-0000-0000-0000-000000000000", "a.b.c"); + Packets.LauncherAuth launcherBack = + Packets.LauncherAuth.read(new Buf(launcher.write().toByteArray())); + eq("launcher account", "Player", launcherBack.account); + eq("launcher uuid", "00000000-0000-0000-0000-000000000000", launcherBack.uuid); + eq("launcher token", "a.b.c", launcherBack.token); } private static void testChunkCompression() { @@ -519,7 +527,7 @@ public final class ProtocolTest { Packets.LOGIN, Packets.LOGIN_ACK, Packets.JOIN_WORLD, Packets.WORLD_JOIN, Packets.CHUNK_DATA, Packets.BLOCK_CHANGE, Packets.PLAYER_MOVE, Packets.ENTITY_MOVE, Packets.PING, Packets.PONG, - Packets.ENTITY_SPAWN, Packets.ENTITY_REMOVE, + Packets.ENTITY_SPAWN, Packets.ENTITY_REMOVE, Packets.LAUNCHER_AUTH, Packets.DISCONNECT, }; for (int i = 0; i < ids.length; i++) { 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 95cfe9c..312787a 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 @@ -195,6 +195,9 @@ public final class ClientSession implements Runnable { case Packets.AUTH: onAuth(frame); return true; + case Packets.LAUNCHER_AUTH: + onLauncherAuth(frame); + return true; case Packets.LOGIN: onLogin(frame); return true; @@ -268,10 +271,64 @@ public final class ClientSession implements Runnable { this.nonce, this.salt)); } + /** + * The launcher's session, presented instead of a password. + * + * The launcher said who the player is and the game server asks the + * launcher whether it is telling the truth. The token is not kept. + */ + private void onLauncherAuth(Frame frame) { + if (this.state != SessionState.HANDSHAKING) { + return; + } + Packets.LauncherAuth launcher = Packets.LauncherAuth.read(new Buf(frame.body())); + + LauncherAuth checker = this.server.launcherAuth(); + if (checker == null) { + send(Packets.AUTH_ACK, Packets.AuthAck.refused( + "this server does not accept launcher sessions")); + close(); + return; + } + + String canonical = this.server.canonicalAccount(launcher.account); + if (canonical == null) { + // the launcher knows the account, but the game server has never + // seen it. That is not a failure: the first time a player joins is + // the moment the game server learns about them. + canonical = launcher.account; + } + + String refusal = checker.check(canonical, launcher.uuid, launcher.token); + this.server.log("launcher check for " + canonical + ": " + + (refusal == null ? "accepted" : "refused, " + refusal)); + if (refusal != null) { + this.server.throttle().failed(launcher.account, this.address); + send(Packets.AUTH_ACK, Packets.AuthAck.refused(refusal)); + close(); + return; + } + + this.server.throttle().succeeded(launcher.account); + net.rustnotmc.auth.TokenService.Token issued = this.server.tokens().issue(canonical); + this.account = canonical; + this.token = issued.value; + this.state = SessionState.AUTHENTICATED; + send(Packets.AUTH_ACK, Packets.AuthAck.ok(issued.value, issued.expiresAt)); + this.server.log("launcher session ok: " + canonical + " from " + this.address); + } + private void onAuth(Frame frame) { if (this.state != SessionState.HANDSHAKING) { return; } + if (!this.server.localAccounts()) { + // the launcher is the way in, and this is not it + send(Packets.AUTH_ACK, Packets.AuthAck.refused( + "this server only lets in players who signed in through the launcher")); + close(); + return; + } Buf in = new Buf(frame.body()); Packets.Auth auth = Packets.Auth.read(in); @@ -318,7 +375,10 @@ public final class ClientSession implements Runnable { } private void onLogin(Frame frame) { - if (this.state != SessionState.AUTHENTICATED) { + // a token may be redeemed straight after the hello, without the + // password step. A client that logged in once keeps its token and + // joins with it, and the token is proof enough on its own. + if (this.state != SessionState.AUTHENTICATED && this.state != SessionState.HANDSHAKING) { return; } Buf in = new Buf(frame.body()); diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/LauncherAuth.java b/rsx-server-core/src/main/java/net/rustnotmc/server/LauncherAuth.java new file mode 100644 index 0000000..d621b50 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/LauncherAuth.java @@ -0,0 +1,211 @@ +package net.rustnotmc.server; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** + * Asks the launcher whether a player's token is good. + * + * The launcher is where accounts live: it has the passwords, the roles, the + * bans and the friends. The game server has none of that and does not want a + * second copy of it, so it asks. + * + * The launcher answers with a verdict and a reason, and the reason is shown to + * the player as it is. A player who was banned is told they were banned, not + * that something went wrong. + * + * The token is used once and then dropped. It is a bearer credential, and a + * game server that keeps it can do things the player could not. + */ +public final class LauncherAuth { + + /** How long the game server waits for the launcher to answer. */ + public static final Duration TIMEOUT = Duration.ofSeconds(10L); + + private final String endpoint; + private final HttpClient http; + + public LauncherAuth(String baseUrl) { + this.endpoint = trim(baseUrl) + "/auth/validate"; + this.http = HttpClient.newBuilder() + .connectTimeout(TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } + + /** + * Checks a token. + * + * Returns null when the token is good, and a reason when it is not. A + * launcher that cannot be reached is a reason too: the player is told the + * server cannot check them, which is true. + */ + public String check(String account, String uuid, String token) { + if (token == null || token.isEmpty()) { + return "no session was given"; + } + String body = "{\"username\":" + quote(account) + ",\"uuid\":" + quote(uuid) + "}"; + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(this.endpoint)) + .timeout(TIMEOUT) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = + this.http.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 401) { + return "the session has expired, please sign in again"; + } + if (response.statusCode() == 403) { + return "the session does not belong to this player"; + } + if (response.statusCode() / 100 != 2) { + return "the launcher answered with " + response.statusCode(); + } + String text = response.body() == null ? "" : response.body(); + Boolean valid = readBoolean(text, "valid"); + if (Boolean.TRUE.equals(valid)) { + return null; + } + if (Boolean.FALSE.equals(valid)) { + String reason = readString(text, "reason"); + return reason == null || reason.isEmpty() + ? "the launcher refused the session" : reason; + } + return "the launcher gave an answer we do not understand"; + } catch (IOException e) { + return "the launcher could not be reached"; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return "the launcher check was interrupted"; + } + } + + /** + * A boolean field of a flat JSON object. + * + * The answer is read rather than matched: a launcher that writes + * {@code {"valid": true}} with a space and one that writes + * {@code {"valid":true}} without one are the same answer, and a server + * that only understands the second turns a working launcher into a broken + * one. + */ + private static Boolean readBoolean(String text, String key) { + int at = text.indexOf('"' + key + '"'); + if (at < 0) { + return null; + } + int i = skipToValue(text, at + key.length() + 2); + if (i < 0) { + return null; + } + if (text.startsWith("true", i)) { + return Boolean.TRUE; + } + if (text.startsWith("false", i)) { + return Boolean.FALSE; + } + return null; + } + + /** A string field of a flat JSON object. */ + private static String readString(String text, String key) { + int at = text.indexOf('"' + key + '"'); + if (at < 0) { + return null; + } + int i = skipToValue(text, at + key.length() + 2); + if (i < 0 || i >= text.length() || text.charAt(i) != '"') { + return null; + } + StringBuilder out = new StringBuilder(); + i++; + while (i < text.length()) { + char c = text.charAt(i++); + if (c == '"') { + return out.toString(); + } + if (c != '\\' || i >= text.length()) { + out.append(c); + continue; + } + char escaped = text.charAt(i++); + switch (escaped) { + case 'n': out.append('\n'); break; + case 't': out.append('\t'); break; + case 'r': out.append('\r'); break; + case 'b': out.append('\b'); break; + case 'f': out.append('\f'); break; + case 'u': + if (i + 4 <= text.length()) { + try { + out.append((char) Integer.parseInt(text.substring(i, i + 4), 16)); + i += 4; + } catch (NumberFormatException notHex) { + out.append(escaped); + } + } + break; + default: + out.append(escaped); + } + } + return null; + } + + /** The place where a field's value starts, or -1. */ + private static int skipToValue(String text, int from) { + int i = from; + while (i < text.length() && Character.isWhitespace(text.charAt(i))) { + i++; + } + if (i < text.length() && text.charAt(i) == ':') { + i++; + } + while (i < text.length() && Character.isWhitespace(text.charAt(i))) { + i++; + } + return i < text.length() ? i : -1; + } + + /** A string in JSON, with the quotes and the backslashes put right. */ + private static String quote(String value) { + if (value == null) { + return "\"\""; + } + StringBuilder out = new StringBuilder(value.length() + 2); + out.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"' || c == '\\') { + out.append('\\'); + } + out.append(c); + } + out.append('"'); + return out.toString(); + } + + private static String trim(String url) { + if (url == null) { + return ""; + } + String trimmed = url.trim(); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + /** The address the check goes to. */ + public String endpoint() { + return this.endpoint; + } +} 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 a4c0d7a..9df65c1 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 @@ -54,6 +54,8 @@ public final class Server { private final AtomicInteger peakOnline = new AtomicInteger(); private final World world; + private volatile LauncherAuth launcherAuth; + private volatile boolean localAccounts = true; private volatile ServerSocket listener; private volatile Thread acceptThread; @@ -92,6 +94,36 @@ public final class Server { return this.viewDistance; } + /** + * The launcher this server takes sessions from. + * + * Null when there is none, which means the launcher path is closed and + * only the server's own accounts work. + */ + public LauncherAuth launcherAuth() { + return this.launcherAuth; + } + + public void launcherAuth(LauncherAuth launcherAuth) { + this.launcherAuth = launcherAuth; + } + + /** + * Whether the server's own accounts may be used. + * + * Off by default once a launcher is configured. A server that lets people + * in on its own accounts while also having a launcher in front of it is a + * server with two doors, and the whole point of the launcher is that there + * is one. + */ + public boolean localAccounts() { + return this.localAccounts; + } + + public void localAccounts(boolean allowed) { + this.localAccounts = allowed; + } + public void viewDistance(int chunks) { if (chunks < 1) { chunks = 1; diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java index 2855bb6..da7f2b1 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java @@ -13,12 +13,19 @@ import net.rustnotmc.server.world.World; * only has to be obviously correct rather than clever. * * run-server.ps1 [--port N] [--accounts path] [--name text] [--motd text] - * [--world path] [--seed N] + * [--world path] [--seed N] [--launcher-api url] + * [--allow-local-accounts] * run-server.ps1 --register Player [--password text] * * Registration is an operator action and not something a client can do over the * wire. Open self-registration turns an account store into a spam target and a * support queue, and neither is a problem worth having at the start. + * + * With --launcher-api the launcher is where accounts live and therefore the + * way in: the game server asks it whether a player is one of ours, and it + * also gets the ban list for free. The server's own accounts are then shut, + * because a server with two doors has no doors; --allow-local-accounts opens + * them again on purpose. */ public final class ServerMain { @@ -33,6 +40,8 @@ public final class ServerMain { Path accounts = Path.of("accounts.txt"); Path worldDir = Path.of("world"); long seed = 0L; + String launcherApi = null; + boolean allowLocalAccounts = false; String register = null; String password = null; @@ -49,6 +58,10 @@ public final class ServerMain { worldDir = Path.of(value); } else if ("--seed".equals(flag)) { seed = Long.parseLong(value); + } else if ("--launcher-api".equals(flag)) { + launcherApi = value; + } else if ("--allow-local-accounts".equals(flag)) { + allowLocalAccounts = true; } else if ("--name".equals(flag)) { name = value; } else if ("--motd".equals(flag)) { @@ -62,6 +75,16 @@ public final class ServerMain { World world = new World(seed, new ChunkStorage(worldDir)); Server server = new Server(name, motd, capacity, port, world); + + if (launcherApi != null) { + // the launcher is where accounts live, so it is the way in. The + // server's own accounts stay shut unless they are opened on + // purpose, because a server with two doors has no doors. + server.launcherAuth(new LauncherAuth(launcherApi)); + server.localAccounts(allowLocalAccounts); + } else { + server.localAccounts(true); + } server.loadAccounts(accounts); final Path accountFile = accounts.toAbsolutePath(); @@ -85,6 +108,12 @@ public final class ServerMain { server.start(); server.log("accounts file: " + accountFile); server.log("world folder: " + worldDir.toAbsolutePath() + ", seed " + seed); + if (server.launcherAuth() != null) { + server.log("launcher: " + server.launcherAuth().endpoint() + + (server.localAccounts() + ? " (local accounts are open as well)" + : " (local accounts are shut)")); + } Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { public void run() { diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java index 0152b42..5adb538 100644 --- a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java @@ -140,8 +140,19 @@ final class PlayClient { return this; } - void close() { - try { + /** Waits for the server's answer to a login attempt. */ + Packets.AuthAck awaitAuthAck(long millis) throws IOException { + Frame frame = await(Packets.AUTH_ACK, millis); + return frame == null ? null + : Packets.AuthAck.read(new net.rustnotmc.protocol.Buf(frame.body())); + } + + /** The launcher's session, offered instead of a password. */ + void launcherAuth(String account, String uuid, String token) throws IOException { + send(Packets.LAUNCHER_AUTH, new Packets.LauncherAuth(account, uuid, token)); + } + + void close() { try { this.socket.close(); } catch (IOException ignored) { // the peer is gone; there is nothing left to do about it diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java index 2b0e1c3..02e6d84 100644 --- a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java @@ -57,6 +57,15 @@ public final class PlayTest { run("the world survives a restart", new Throwing() { public void go() throws Exception { testRestart(work); } }); + run("the launcher decides who gets in", new Throwing() { + public void go() throws Exception { testLauncherAuth(work); } + }); + run("a launcher session that is not ours is refused", new Throwing() { + public void go() throws Exception { testLauncherRefusal(work); } + }); + run("the server's own accounts are shut when a launcher is set", new Throwing() { + public void go() throws Exception { testLocalAccountsShut(work); } + }); System.out.println(); for (int i = 0; i < failures.size(); i++) { @@ -406,6 +415,139 @@ public final class PlayTest { } } + /** + * A launcher, stood up for the test. + * + * The real one is a service of its own with accounts, roles and bans in + * it. What the game server needs from it is one question answered one + * way, and that is what this answers. + */ + private static final class FakeLauncher implements AutoCloseable { + + private final com.sun.net.httpserver.HttpServer http; + + /** The token the launcher accepts. */ + static final String GOOD = "a-good-token"; + + FakeLauncher() throws IOException { + this.http = com.sun.net.httpserver.HttpServer.create( + new java.net.InetSocketAddress("127.0.0.1", 0), 0); + this.http.createContext("/auth/validate", exchange -> { + String authorization = exchange.getRequestHeaders().getFirst("Authorization"); + String body = new String(exchange.getRequestBody().readAllBytes(), + java.nio.charset.StandardCharsets.UTF_8); + + String answer; + int status; + if (authorization == null || !authorization.equals("Bearer " + GOOD)) { + status = 401; + answer = "{\"valid\": false, \"reason\": \"unknown token\"}"; + } else if (!body.contains("\"username\":\"Player\"")) { + status = 403; + answer = "{\"valid\": false, \"reason\": \"not your token\"}"; + } else { + status = 200; + // a space after the colon, on purpose: the server has to + // read the answer, not match it + answer = "{\"valid\": true}"; + } + + byte[] bytes = answer.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + this.http.start(); + } + + String url() { + return "http://127.0.0.1:" + this.http.getAddress().getPort(); + } + + @Override + public void close() { + this.http.stop(0); + } + } + + /** The launcher lets one of ours in and turns away the rest. */ + private static void testLauncherAuth(Path work) throws Exception { + try (FakeLauncher launcher = new FakeLauncher()) { + Server server = launcherServer(work, "First", launcher.url()); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + client.hello("Player", "test"); + client.launcherAuth("Player", "uuid-1", FakeLauncher.GOOD); + + Packets.AuthAck auth = client.awaitAuthAck(5000L); + yes("the launcher session was accepted", auth != null && auth.accepted); + if (auth != null && auth.accepted) { + Packets.LoginAck login = client.login("Player", auth.token); + yes("the player got in", login != null && login.accepted); + } + client.close(); + } finally { + server.stop(); + } + } + } + + /** A token that is not ours does not get in. */ + private static void testLauncherRefusal(Path work) throws Exception { + try (FakeLauncher launcher = new FakeLauncher()) { + Server server = launcherServer(work, "Second", launcher.url()); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + client.hello("Player", "test"); + client.launcherAuth("Player", "uuid-1", "somebody-elses-token"); + + Packets.AuthAck auth = client.awaitAuthAck(5000L); + yes("the session was refused", auth != null && !auth.accepted); + yes("and the reason says so", auth != null && auth.reason != null + && !auth.reason.isEmpty()); + client.close(); + } finally { + server.stop(); + } + } + } + + /** With a launcher in front, the server's own accounts are not a way in. */ + private static void testLocalAccountsShut(Path work) throws Exception { + try (FakeLauncher launcher = new FakeLauncher()) { + Server server = launcherServer(work, "Third", launcher.url()); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + Packets.HelloAck hello = client.hello("Third", "test"); + Packets.AuthAck auth = client.auth("Third", "password1".toCharArray(), + hello.salt, hello.nonce); + yes("the password was refused", auth != null && !auth.accepted); + yes("and the reason says why", auth != null && auth.reason != null + && auth.reason.contains("launcher")); + client.close(); + } finally { + server.stop(); + } + } + } + + /** A server that takes its sessions from a launcher. */ + private static Server launcherServer(Path work, String account, String launcherUrl) + throws IOException { + Server server = new Server("RustNotMC play", "hello", 20, 0, + new World(1234L, ChunkStorage.memoryOnly())); + server.viewDistance(2); + server.launcherAuth(new LauncherAuth(launcherUrl)); + server.localAccounts(false); + Path accounts = work.resolve("launcher-accounts-" + account + ".txt"); + Files.deleteIfExists(accounts); + server.loadAccounts(accounts); + server.accounts().create(account, "password1".toCharArray(), 1L); + server.start(); + return server; + } + /** A block, or a byte that is not a block if it cannot be read. */ private static byte blockAt(World world, int x, int y, int z) { try {