Клиент говорит с сервером по нашему протоколу
Раньше клиент умел только показывать меню. Теперь он заходит на сервер,
получает наш мир и рисует его.
Как это устроено:
- Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и
переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть
мир, игрок, сущности и рендер, и все они работают. Не переписывать их
ради того, чтобы нарисовать тот же мир иначе, незачем.
- Пакеты игры, которые не имеют соответствия у нас, не отправляются, а
наоборот: перемещение игрока и ломание блока переводятся в наши.
- Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера,
кем пришёл игрок, и подставляет это в рукопожатие.
- Соединение качается из тика игры, а не из экрана. Рукопожатие, которое
зависит от того, что экран ещё на экране, ломается alt-tab'ом.
Вход теперь через лаунчер, а не внутри игры:
- Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен
(--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в
ванилле, только лаунчер наш.
- Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает
бан-лист. Токен используется один раз и не хранится.
- Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя
дверями не имеет дверей. Открыть их можно флагом, но намеренно.
- Если игру запустили без лаунчера, экран всё равно спрашивает имя и
пароль: это путь для своего сервера и для разработки.
Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил
лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и
лежат на диске, рельеф нарисован.
Что чинилось по дороге:
- Minecraft.setIngameFocus() возвращал на главное меню, когда окно
получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и
экран входа исчезал в этот самый момент.
- Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и
{"valid":true} это один и тот же ответ, и сервер, который понимает
только второй, ломает работающий лаунчер.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user