Клиент говорит с сервером по нашему протоколу

Раньше клиент умел только показывать меню. Теперь он заходит на сервер,
получает наш мир и рисует его.

Как это устроено:

- Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и
  переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть
  мир, игрок, сущности и рендер, и все они работают. Не переписывать их
  ради того, чтобы нарисовать тот же мир иначе, незачем.
- Пакеты игры, которые не имеют соответствия у нас, не отправляются, а
  наоборот: перемещение игрока и ломание блока переводятся в наши.
- Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера,
  кем пришёл игрок, и подставляет это в рукопожатие.
- Соединение качается из тика игры, а не из экрана. Рукопожатие, которое
  зависит от того, что экран ещё на экране, ломается alt-tab'ом.

Вход теперь через лаунчер, а не внутри игры:

- Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен
  (--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в
  ванилле, только лаунчер наш.
- Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает
  бан-лист. Токен используется один раз и не хранится.
- Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя
  дверями не имеет дверей. Открыть их можно флагом, но намеренно.
- Если игру запустили без лаунчера, экран всё равно спрашивает имя и
  пароль: это путь для своего сервера и для разработки.

Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил
лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и
лежат на диске, рельеф нарисован.

Что чинилось по дороге:

- Minecraft.setIngameFocus() возвращал на главное меню, когда окно
  получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и
  экран входа исчезал в этот самый момент.
- Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и
  {"valid":true} это один и тот же ответ, и сервер, который понимает
  только второй, ломает работающий лаунчер.
This commit is contained in:
sasheg dev
2026-10-19 20:10:19 +03:00
parent 68dfea27b5
commit 46784aa88f
8 changed files with 541 additions and 5 deletions
@@ -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;
}
@@ -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++) {
@@ -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());
@@ -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<String> 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;
}
}
@@ -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;
@@ -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() {
@@ -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
@@ -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 {