Серверная база со своим логином

Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него.

Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент
называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого
соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и
подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а
запись обрезать нельзя: сервер не знает исходного пароля.

Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе:
соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт
называется в Hello, а не в Auth. Иначе клиент получает соль не того
аккаунта и не может вывести ключ.

Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды
лежат рядом с записью, а не в исходниках: число, существующее только в
коде, однажды становится причиной того, что аккаунт невозможно проверить.

Имена уникальны независимо от регистра: Player и player — это один
аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его.

Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает
подбирать одно имя, другое не мешает перебирать много имён с разных
адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на
неверный пароль, и занимает то же время.

Токены живут только в памяти. Записанный на диск токен пережил бы процесс,
который его выдал, а украденный каталог сервера стал бы набором живых
сессий.

Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто
зашёл с одного IP, то есть всех игроков за одним NAT.

Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который
тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего
мы уходим, перенося симуляцию на свой сервер.

Регистрация — команда оператора, а не функция у клиента. Открытая
саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь
поддержки.

Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий
сокет: регистрация, вход, доказательство пароля, токен, его погашение и
появление игроком. Отдельно проверено, что неверный пароль отвергается, а
старое доказательство не принимается против нового вызова. Тик-цикл
замеряется числом тиков за секунду, а не наличием потока.

Всё одним JDK, без Gradle и без сети, как и весь проект.
This commit is contained in:
sasheg dev
2026-10-19 01:45:56 +03:00
parent 7fbb26092f
commit 620f136361
17 changed files with 2576 additions and 14 deletions
@@ -0,0 +1,404 @@
package net.rustnotmc.server;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import net.rustnotmc.protocol.Buf;
import net.rustnotmc.protocol.Frame;
import net.rustnotmc.protocol.FrameDecoder;
import net.rustnotmc.protocol.ProtocolException;
/**
* One connection, and the handshake that has to finish before it means
* anything.
*
* Each connection is a thread that blocks on its own socket. That is not the
* shape a two-hundred-player server wants in the long run, but it is the shape
* that is obviously correct, and the decoder underneath already produces whole
* frames from arbitrary chunking, so moving to a selector later touches this
* class and nothing below it.
*
* Every state transition is bounded by a deadline. A peer that connects and
* then says nothing must not be able to sit in the table holding memory.
*/
public final class ClientSession implements Runnable {
/** Longest a connection may spend before HELLO. */
public static final long CONNECT_TIMEOUT_MILLIS = 10_000L;
/** Longest a connection may spend in the handshake in total. */
public static final long HANDSHAKE_TIMEOUT_MILLIS = 20_000L;
private final Socket socket;
private final Server server;
private final FrameDecoder decoder;
private final long id;
private final String address;
private final AtomicBoolean closed = new AtomicBoolean();
private final BlockingQueue<Frame> inbox = new LinkedBlockingQueue<Frame>();
private volatile SessionState state = SessionState.CONNECTING;
private volatile long deadline;
private volatile String account;
private volatile String token;
private volatile int playerId = -1;
private volatile int entityId = -1;
private volatile String lastError;
private byte[] nonce;
private byte[] salt;
ClientSession(Socket socket, Server server, long id) {
this.socket = socket;
this.server = server;
this.decoder = new FrameDecoder();
this.id = id;
InetSocketAddress remote = (InetSocketAddress) socket.getRemoteSocketAddress();
this.address = remote == null ? "unknown" : remote.getAddress().getHostAddress();
this.deadline = System.currentTimeMillis() + CONNECT_TIMEOUT_MILLIS;
}
// ------------------------------------------------------------- accessors
public SessionState state() {
return this.state;
}
public String address() {
return this.address;
}
/**
* A connection's own identity.
*
* Not the address: every player behind one NAT, and every player in a test
* on localhost, arrives from the same one, and keying sessions by address
* silently evicts all but the last of them.
*/
public long id() {
return this.id;
}
public String account() {
return this.account;
}
public String token() {
return this.token;
}
public int playerId() {
return this.playerId;
}
public int entityId() {
return this.entityId;
}
public String lastError() {
return this.lastError;
}
public boolean isClosed() {
return this.closed.get();
}
void touch(long limit) {
this.deadline = System.currentTimeMillis() + limit;
}
// ----------------------------------------------------------------- flow
@Override
public void run() {
this.server.opened(this);
try {
this.socket.setTcpNoDelay(true);
this.socket.setSoTimeout(1);
while (!this.closed.get()) {
step();
}
} catch (IOException e) {
if (!this.closed.get()) {
this.lastError = String.valueOf(e.getMessage());
}
} catch (ProtocolException e) {
this.lastError = e.getMessage();
} finally {
this.shutdown();
}
}
private void step() throws IOException {
if (expired()) {
throw new ProtocolException("handshake took too long");
}
drainInbox();
try {
byte[] chunk = new byte[8192];
int read = this.socket.getInputStream().read(chunk);
if (read < 0) {
this.lastError = "peer closed the connection";
close();
return;
}
this.feed(chunk, 0, read);
} catch (SocketTimeoutException nothingYet) {
// expected: the loop wakes regularly to check the deadline
} catch (SocketException gone) {
this.lastError = String.valueOf(gone.getMessage());
close();
}
}
void feed(byte[] chunk, int offset, int length) throws IOException {
for (Frame frame : this.decoder.offer(chunk, offset, length)) {
this.inbox.add(frame);
}
drainInbox();
}
private void drainInbox() {
Frame frame;
while ((frame = this.inbox.poll()) != null) {
if (!handle(frame)) {
return;
}
}
}
private boolean handle(Frame frame) {
try {
switch (frame.packetId()) {
case net.rustnotmc.protocol.Packets.HELLO:
onHello(frame);
return true;
case net.rustnotmc.protocol.Packets.AUTH:
onAuth(frame);
return true;
case net.rustnotmc.protocol.Packets.LOGIN:
onLogin(frame);
return true;
case net.rustnotmc.protocol.Packets.DISCONNECT:
this.lastError = "peer said goodbye";
close();
return false;
default:
// an unknown id is not an error: new clients may talk to old
// servers, and the id space grows
return true;
}
} catch (ProtocolException e) {
this.lastError = e.getMessage();
close();
return false;
} catch (RuntimeException e) {
this.lastError = e.toString();
close();
return false;
}
}
private void onHello(Frame frame) {
if (this.state != SessionState.CONNECTING) {
return;
}
Buf in = new Buf(frame.body());
net.rustnotmc.protocol.Packets.Hello hello =
net.rustnotmc.protocol.Packets.Hello.read(in);
if (hello.protocolVersion != net.rustnotmc.protocol.Frame.VERSION) {
send(net.rustnotmc.protocol.Packets.HELLO_ACK,
new net.rustnotmc.protocol.Packets.HelloAck(
net.rustnotmc.protocol.Frame.VERSION,
this.server.serverName(), this.server.motd(),
this.server.online(), this.server.capacity(),
new byte[net.rustnotmc.protocol.Packets.CHALLENGE_BYTES],
new byte[net.rustnotmc.protocol.Packets.CHALLENGE_BYTES]));
this.lastError = "protocol version " + hello.protocolVersion + " is not ours";
close();
return;
}
this.nonce = net.rustnotmc.auth.Secure.bytes(
net.rustnotmc.protocol.Packets.CHALLENGE_BYTES);
// the salt has to belong to the account the client is about to claim,
// and the client cannot derive its key without it, so Hello has to name
// the account before this point
this.salt = this.server.saltFor(hello.account);
this.state = SessionState.HANDSHAKING;
touch(HANDSHAKE_TIMEOUT_MILLIS);
send(net.rustnotmc.protocol.Packets.HELLO_ACK,
new net.rustnotmc.protocol.Packets.HelloAck(
net.rustnotmc.protocol.Frame.VERSION,
this.server.serverName(), this.server.motd(),
this.server.online(), this.server.capacity(),
this.nonce, this.salt));
}
private void onAuth(Frame frame) {
if (this.state != SessionState.HANDSHAKING) {
return;
}
Buf in = new Buf(frame.body());
net.rustnotmc.protocol.Packets.Auth auth =
net.rustnotmc.protocol.Packets.Auth.read(in);
long wait = this.server.throttle().waitSeconds(auth.account, this.address);
if (wait > 0L) {
send(net.rustnotmc.protocol.Packets.AUTH_ACK,
net.rustnotmc.protocol.Packets.AuthAck.refused(
"too many attempts, wait " + wait + "s"));
close();
return;
}
String name = this.accountBehind(auth.account);
if (name == null) {
// the same work is done for an absent account as for a wrong
// password, so the reply does not say which it was
this.server.throttle().failed(auth.account, this.address);
send(net.rustnotmc.protocol.Packets.AUTH_ACK,
net.rustnotmc.protocol.Packets.AuthAck.refused(
"wrong name or password"));
close();
return;
}
if (!this.server.verifyProof(name, auth.account, this.salt, this.nonce, auth.proof)) {
this.server.throttle().failed(auth.account, this.address);
this.server.upgradeRecord(name);
send(net.rustnotmc.protocol.Packets.AUTH_ACK,
net.rustnotmc.protocol.Packets.AuthAck.refused(
"wrong name or password"));
close();
return;
}
this.server.throttle().succeeded(auth.account);
net.rustnotmc.auth.TokenService.Token issued = this.server.tokens().issue(name);
this.account = name;
this.token = issued.value;
this.state = SessionState.AUTHENTICATED;
send(net.rustnotmc.protocol.Packets.AUTH_ACK,
net.rustnotmc.protocol.Packets.AuthAck.ok(issued.value, issued.expiresAt));
this.server.log("auth ok: " + name + " from " + this.address);
}
private void onLogin(Frame frame) {
if (this.state != SessionState.AUTHENTICATED) {
return;
}
Buf in = new Buf(frame.body());
net.rustnotmc.protocol.Packets.Login login =
net.rustnotmc.protocol.Packets.Login.read(in);
if (!this.server.tokens().valid(login.token, login.account)) {
send(net.rustnotmc.protocol.Packets.LOGIN_ACK,
net.rustnotmc.protocol.Packets.LoginAck.refused("bad or expired token"));
close();
return;
}
if (this.server.full()) {
send(net.rustnotmc.protocol.Packets.LOGIN_ACK,
net.rustnotmc.protocol.Packets.LoginAck.refused("server is full"));
close();
return;
}
if (this.server.alreadyPlaying(this.account)) {
send(net.rustnotmc.protocol.Packets.LOGIN_ACK,
net.rustnotmc.protocol.Packets.LoginAck.refused(
"that account is already playing"));
close();
return;
}
this.playerId = this.server.nextPlayerId();
this.entityId = this.playerId;
this.state = SessionState.PLAYING;
touch(Long.MAX_VALUE / 4L);
send(net.rustnotmc.protocol.Packets.LOGIN_ACK,
net.rustnotmc.protocol.Packets.LoginAck.ok(this.playerId, this.entityId));
this.server.joined(this);
this.server.log("player in: " + this.account + " id " + this.playerId
+ " from " + this.address);
}
/** The canonical account name for the name the client typed. */
private String accountBehind(String typed) {
return this.server.canonicalAccount(typed);
}
// --------------------------------------------------------------- output
/** Sends a packet. Silently ignored once the socket is gone. */
public void send(int packetId, Object packet) {
if (this.closed.get()) {
return;
}
try {
Buf body;
if (packet instanceof net.rustnotmc.protocol.Packets.HelloAck) {
body = ((net.rustnotmc.protocol.Packets.HelloAck) packet).write();
} else if (packet instanceof net.rustnotmc.protocol.Packets.AuthAck) {
body = ((net.rustnotmc.protocol.Packets.AuthAck) packet).write();
} else if (packet instanceof net.rustnotmc.protocol.Packets.LoginAck) {
body = ((net.rustnotmc.protocol.Packets.LoginAck) packet).write();
} else if (packet instanceof net.rustnotmc.protocol.Packets.Disconnect) {
body = ((net.rustnotmc.protocol.Packets.Disconnect) packet).write();
} else {
throw new IllegalArgumentException("no codec for " + packet);
}
Frame frame = new Frame(packetId, body.toByteArray());
synchronized (this.socket) {
this.socket.getOutputStream().write(frame.encode().toByteArray());
this.socket.getOutputStream().flush();
}
} catch (IOException gone) {
close();
}
}
public void disconnect(String reason) {
if (this.closed.get()) {
return;
}
send(net.rustnotmc.protocol.Packets.DISCONNECT,
new net.rustnotmc.protocol.Packets.Disconnect(reason));
close();
}
private boolean expired() {
return System.currentTimeMillis() > this.deadline;
}
void shutdown() {
if (!this.closed.compareAndSet(false, true)) {
return;
}
this.state = SessionState.CLOSED;
try {
this.socket.close();
} catch (IOException ignored) {
// the peer is gone; there is nothing left to do about it
}
this.server.closed(this);
}
public void close() {
this.shutdown();
}
@Override
public String toString() {
return "ClientSession[" + this.address + " " + this.state
+ (this.account == null ? "" : " as " + this.account) + "]";
}
}
@@ -0,0 +1,357 @@
package net.rustnotmc.server;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import net.rustnotmc.auth.AccountFile;
import net.rustnotmc.auth.AccountStore;
import net.rustnotmc.auth.LoginThrottle;
import net.rustnotmc.auth.Passwords;
import net.rustnotmc.auth.TokenService;
import net.rustnotmc.protocol.Packets;
/**
* The server: a listening socket, a tick loop and the rules for who gets in.
*
* The tick loop runs on its own thread at a fixed rate and does not care how
* fast frames arrive. A world that ticks on frames is a world whose physics
* 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 {
/** Ticks per second. Fixed, and the same number the client counts. */
public static final int TICKS_PER_SECOND = 20;
/** How far the loop is allowed to drift before it gives up catching up. */
public static final long MAX_CATCHUP_TICKS = 5;
private final String name;
private final String motd;
private final int capacity;
private final int port;
private final AccountStore accounts = new AccountStore();
private final TokenService tokens = new TokenService();
private final LoginThrottle throttle = new LoginThrottle();
private final Map<Long, ClientSession> sessions = new ConcurrentHashMap<Long, ClientSession>();
private final AtomicInteger playerIds = new AtomicInteger(1);
private final AtomicLong sessionIds = new AtomicLong(1L);
private final AtomicLong ticks = new AtomicLong();
private final AtomicInteger peakOnline = new AtomicInteger();
private volatile ServerSocket listener;
private volatile Thread acceptThread;
private volatile Thread tickThread;
private volatile boolean running;
private volatile long tickNanos;
private volatile String tickBacklog = "";
public Server(String name, String motd, int capacity, int port) {
this.name = name;
this.motd = motd;
this.capacity = capacity;
this.port = port;
}
// -------------------------------------------------------------- getters
public AccountStore accounts() {
return this.accounts;
}
public TokenService tokens() {
return this.tokens;
}
public LoginThrottle throttle() {
return this.throttle;
}
public String serverName() {
return this.name;
}
public String motd() {
return this.motd;
}
public int capacity() {
return this.capacity;
}
public long ticks() {
return this.ticks.get();
}
public int peakOnline() {
return this.peakOnline.get();
}
public String tickBacklog() {
return this.tickBacklog;
}
public boolean running() {
return this.running;
}
/**
* The port actually bound.
*
* Not the one that was asked for: a request for port 0 asks the operating
* system to pick a free one, and a test that assumed it got back what it
* asked for would be testing the wrong thing.
*/
public int port() {
return this.listener == null ? this.port : this.listener.getLocalPort();
}
public int online() {
int n = 0;
for (ClientSession session : this.sessions.values()) {
if (session.state().mayPlay()) {
n++;
}
}
return n;
}
// ----------------------------------------------------------- lifecycle
public void loadAccounts(java.nio.file.Path path) throws IOException {
AccountFile.load(path, this.accounts);
}
public void saveAccounts(java.nio.file.Path path) throws IOException {
AccountFile.save(path, this.accounts);
}
public void start() throws IOException {
this.listener = new ServerSocket();
this.listener.setReuseAddress(true);
this.listener.bind(new InetSocketAddress("0.0.0.0", this.port));
this.running = true;
this.acceptThread = new Thread(this::acceptLoop, "rsx-accept");
this.acceptThread.setDaemon(true);
this.acceptThread.start();
this.tickThread = new Thread(this::tickLoop, "rsx-tick");
this.tickThread.setDaemon(true);
this.tickThread.start();
log("listening on " + this.listener.getLocalPort()
+ ", capacity " + this.capacity + ", accounts " + this.accounts.size());
}
public void stop() {
this.running = false;
try {
if (this.listener != null) {
this.listener.close();
}
} catch (IOException ignored) {
// shutting down anyway
}
for (ClientSession session : new ArrayList<ClientSession>(this.sessions.values())) {
session.close();
}
log("stopped after " + this.ticks.get() + " ticks");
}
private void acceptLoop() {
while (this.running) {
try {
Socket socket = this.listener.accept();
ClientSession session = new ClientSession(socket, this, nextSessionId());
this.sessions.put(Long.valueOf(session.id()), session);
Thread thread = new Thread(session, "rsx-session-" + session.id());
thread.setDaemon(true);
thread.start();
} catch (SocketException shuttingDown) {
return;
} catch (IOException e) {
if (this.running) {
log("accept failed: " + e);
}
}
}
}
private void tickLoop() {
long periodNanos = 1_000_000_000L / TICKS_PER_SECOND;
long next = System.nanoTime() + periodNanos;
int late = 0;
int skipped = 0;
long reported = 0L;
while (this.running) {
long now = System.nanoTime();
if (now < next) {
long sleep = next - now;
try {
Thread.sleep(sleep / 1_000_000L, (int) (sleep % 1_000_000L));
} catch (InterruptedException stopped) {
return;
}
continue;
}
// catching up one tick at a time is right; catching up forever is not
int behind = (int) ((now - next) / periodNanos) + 1;
int run = (int) Math.min((long) behind, (long) MAX_CATCHUP_TICKS);
if (behind > MAX_CATCHUP_TICKS) {
skipped += behind - run;
next = now;
if (skipped != reported) {
this.tickBacklog = "skipped " + skipped + " tick(s) to catch up";
reported = skipped;
}
}
for (int i = 0; i < run; i++) {
tickOnce();
}
if (behind <= 1 && late != 0) {
this.tickBacklog = "";
late = 0;
}
next += periodNanos * run;
if (next < now) {
next = now;
}
}
}
/** One server tick. The place the world will live. */
private void tickOnce() {
this.ticks.incrementAndGet();
if ((this.ticks.get() & 0x3FFFL) == 0L) {
this.tokens.sweep();
for (ClientSession session : this.sessions.values()) {
if (session.isClosed()) {
this.sessions.remove(Long.valueOf(session.id()));
}
}
}
}
// ------------------------------------------------------ session helpers
void opened(ClientSession session) {
}
void joined(ClientSession session) {
int n = online();
if (n > this.peakOnline.get()) {
this.peakOnline.set(n);
}
}
void closed(ClientSession session) {
this.sessions.remove(Long.valueOf(session.id()));
if (session.lastError() != null) {
log("closed " + session.address() + ": " + session.lastError());
}
}
public List<ClientSession> sessions() {
return new ArrayList<ClientSession>(this.sessions.values());
}
int nextPlayerId() {
return this.playerIds.getAndIncrement();
}
private long nextSessionId() {
return this.sessionIds.getAndIncrement();
}
public boolean full() {
return online() >= this.capacity;
}
boolean alreadyPlaying(String account) {
if (account == null) {
return false;
}
for (ClientSession session : this.sessions.values()) {
if (account.equalsIgnoreCase(session.account()) && session.state().mayPlay()) {
return true;
}
}
return false;
}
/**
* The salt to hand a client.
*
* A fresh random salt goes out when the account is not known, so an absent
* name still receives a plausible looking challenge.
*/
byte[] saltFor(String typedName) {
String canonical = canonicalAccount(typedName);
if (canonical == null) {
return net.rustnotmc.auth.Secure.bytes(Packets.SALT_BYTES);
}
return this.accounts.find(canonical).decode().salt;
}
public String canonicalAccount(String typedName) {
if (typedName == null) {
return null;
}
AccountStore.Account found = this.accounts.find(typedName);
return found == null ? null : found.name;
}
/**
* Checks the client's proof.
*
* The account name is taken from the store, not from the client: a client
* that sends {@code Player} and a proof for {@code player} must not get in,
* or the proof would no longer be bound to the name it was made for.
*/
boolean verifyProof(String canonicalName, String typedName, byte[] salt,
byte[] nonce, byte[] proof) {
if (canonicalName == null || salt == null || nonce == null || proof == null) {
return false;
}
AccountStore.Account account = this.accounts.find(canonicalName);
if (account == null) {
return false;
}
Passwords.Record record = account.decode();
try {
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(record.hash, "HmacSHA256"));
mac.update(nonce);
mac.update(canonicalName.getBytes(java.nio.charset.StandardCharsets.UTF_8));
byte[] expected = mac.doFinal();
return net.rustnotmc.auth.Secure.same(expected, proof);
} catch (java.security.NoSuchAlgorithmException | java.security.InvalidKeyException e) {
throw new IllegalStateException("HmacSHA256 is unavailable", e);
}
}
/** Rewrites a record with the current work factor while we hold the password. */
void upgradeRecord(String canonicalName) {
// deliberately a no-op placeholder: upgrading needs the plaintext, which
// we do not have at this point in the flow. The record carries its own
// iteration count, so an old account simply keeps working.
}
public void log(String message) {
System.out.println("[rsx] " + message);
}
}
@@ -0,0 +1,124 @@
package net.rustnotmc.server;
import java.io.IOException;
import java.nio.file.Path;
/**
* Command line entry point.
*
* Deliberately small: it loads accounts, opens the port and runs the tick loop.
* Everything worth testing lives in {@link Server} and is tested there, so this
* only has to be obviously correct rather than clever.
*
* run-server.ps1 [--port N] [--accounts path] [--name text] [--motd text]
* 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.
*/
public final class ServerMain {
private ServerMain() {
}
public static void main(String[] args) throws IOException, InterruptedException {
int port = 25565;
int capacity = 200;
String name = "RustNotMC";
String motd = "a survival game";
Path accounts = Path.of("accounts.txt");
String register = null;
String password = null;
for (int i = 0; i < args.length - 1; i++) {
String flag = args[i];
String value = args[i + 1];
if ("--port".equals(flag)) {
port = Integer.parseInt(value);
} else if ("--capacity".equals(flag)) {
capacity = Integer.parseInt(value);
} else if ("--accounts".equals(flag)) {
accounts = Path.of(value);
} else if ("--name".equals(flag)) {
name = value;
} else if ("--motd".equals(flag)) {
motd = value;
} else if ("--register".equals(flag)) {
register = value;
} else if ("--password".equals(flag)) {
password = value;
}
}
Server server = new Server(name, motd, capacity, port);
server.loadAccounts(accounts);
final Path accountFile = accounts.toAbsolutePath();
if (register != null) {
char[] typed = password == null
? readPassword("password for " + register + ": ")
: password.toCharArray();
try {
server.accounts().create(register, typed, System.currentTimeMillis());
server.saveAccounts(accountFile);
System.out.println("[rsx] created account " + register);
return;
} catch (net.rustnotmc.auth.AccountStore.AccountException refused) {
System.out.println("[rsx] refused: " + refused.getMessage());
System.exit(1);
} finally {
net.rustnotmc.auth.Passwords.wipe(typed);
}
}
server.start();
server.log("accounts file: " + accountFile);
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
public void run() {
server.log("saving accounts");
try {
server.saveAccounts(accountFile);
} catch (IOException e) {
server.log("could not save accounts: " + e);
}
server.stop();
}
}, "rsx-shutdown"));
long reportedAt = 0L;
while (server.running()) {
Thread.sleep(5_000L);
long ticks = server.ticks();
if (server.ticks() == reportedAt) {
server.log("the tick loop is not moving");
}
reportedAt = ticks;
server.log("tick " + ticks
+ ", players " + server.online()
+ ", peak " + server.peakOnline()
+ ", sessions " + server.sessions().size());
}
}
/** Reads a password without echo, or falls back to a visible line. */
private static char[] readPassword(String prompt) {
java.io.Console console = System.console();
if (console != null) {
System.out.print(prompt);
System.out.flush();
return console.readPassword();
}
System.out.print(prompt);
System.out.flush();
try {
java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(System.in));
String line = reader.readLine();
return line == null ? new char[0] : line.toCharArray();
} catch (IOException e) {
return new char[0];
}
}
}
@@ -0,0 +1,41 @@
package net.rustnotmc.server;
import java.util.Locale;
/**
* Where a connection is in the way to being a player.
*
* The order matters as much as the states do. Nothing that reads the world is
* reachable before AUTHENTICATED, and a connection that stops answering mid
* handshake is dropped by a timeout rather than kept open as a socket that
* occupies a slot forever.
*/
public enum SessionState {
/** Connected, nothing exchanged yet. */
CONNECTING,
/** Hello seen, waiting for Auth. */
HANDSHAKING,
/** Proof accepted and a token handed out. */
AUTHENTICATED,
/** Token redeemed, the player is in. */
PLAYING,
/** Closing, or closed. Nothing further is read. */
CLOSED;
public boolean readsPackets() {
return this != CLOSED;
}
public boolean mayPlay() {
return this == PLAYING;
}
public static SessionState parse(String text) {
return valueOf(text.trim().toUpperCase(Locale.ROOT));
}
}
@@ -0,0 +1,388 @@
package net.rustnotmc.server;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import net.rustnotmc.auth.AccountFile;
import net.rustnotmc.auth.AccountStore;
import net.rustnotmc.auth.LoginThrottle;
import net.rustnotmc.auth.Passwords;
import net.rustnotmc.auth.TokenService;
import net.rustnotmc.protocol.Frame;
import net.rustnotmc.protocol.Packets;
import net.rustnotmc.tools.LoginClient;
/**
* Tests the login path against a running server over a real socket.
*
* The point of going through a socket rather than calling methods is that the
* parts most likely to be wrong are the parts nobody calls directly: the
* framing, the state machine, the timeouts, and the order in which each side
* assumes the other has already done something.
*/
public final class ServerTest {
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 = Path.of(args.length > 0 ? args[0] : "build/selftest");
Files.createDirectories(work);
run("password records survive a round trip", new Throwing() {
public void go() throws Exception { testPasswordRecord(); }
});
run("wrong password is refused", new Throwing() {
public void go() throws Exception { testPasswordWrong(); }
});
run("names are unique regardless of case", new Throwing() {
public void go() throws Exception { testCaseUniqueness(); }
});
run("short passwords are refused", new Throwing() {
public void go() throws Exception { testPasswordPolicy(); }
});
run("throttle backs off and recovers", new Throwing() {
public void go() throws Exception { testThrottle(); }
});
run("tokens expire and can be revoked", new Throwing() {
public void go() throws Exception { testTokens(); }
});
run("accounts survive a save and load", new Throwing() {
public void go() throws Exception { testAccountFile(work); }
});
run("a client can log in end to end", new Throwing() {
public void go() throws Exception { testEndToEndLogin(work); }
});
run("a wrong password is refused over the wire", new Throwing() {
public void go() throws Exception { testEndToEndWrongPassword(work); }
});
run("a token cannot be replayed on a new connection", new Throwing() {
public void go() throws Exception { testReplayRejected(work); }
});
run("the tick loop actually ticks", new Throwing() {
public void go() throws Exception { testTickLoop(); }
});
run("the same account cannot play twice", new Throwing() {
public void go() throws Exception { testDoubleLogin(work); }
});
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);
}
}
private static void yes(String what, boolean condition) {
if (!condition) {
failed++;
failures.add(what);
}
}
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);
}
}
// ------------------------------------------------------------ unit level
private static void testPasswordRecord() {
char[] password = "correct horse".toCharArray();
Passwords.Record record = Passwords.create(password, 1000);
yes("right password verifies",
Passwords.verify("correct horse".toCharArray(), record));
yes("wrong password does not",
!Passwords.verify("correct horsf".toCharArray(), record));
Passwords.Record back = Passwords.Record.decode(record.encode());
eq("iterations survive", Integer.valueOf(record.iterations), Integer.valueOf(back.iterations));
yes("salt survives", java.util.Arrays.equals(record.salt, back.salt));
yes("hash survives", java.util.Arrays.equals(record.hash, back.hash));
yes("decoded record still verifies",
Passwords.verify("correct horse".toCharArray(), back));
}
private static void testPasswordWrong() {
Passwords.Record record = Passwords.create("hunter22".toCharArray(), 1000);
yes("a near miss is still a miss", !Passwords.verify("hunter23".toCharArray(), record));
yes("an empty password is a miss", !Passwords.verify(new char[0], record));
yes("null password is a miss", !Passwords.verify(null, record));
yes("a missing record is a miss", !Passwords.verify("anything".toCharArray(), null));
}
private static void testCaseUniqueness() {
AccountStore store = new AccountStore();
store.create("Player", "hunter22".toCharArray(), 1L);
try {
store.create("player", "other22".toCharArray(), 1L);
failed++;
failures.add("a differently cased duplicate was accepted");
} catch (AccountStore.AccountException expected) {
// correct
}
yes("found case insensitively", store.exists("PLAYER"));
}
private static void testPasswordPolicy() {
AccountStore store = new AccountStore();
try {
store.create("Shorty", "short".toCharArray(), 1L);
failed++;
failures.add("a short password was accepted");
} catch (AccountStore.AccountException expected) {
// correct
}
}
private static void testThrottle() {
final AtomicLong clock = new AtomicLong(1_000_000L);
LoginThrottle throttle = new LoginThrottle(500L, 60_000L, new LoginThrottle.Clock() {
public long now() {
return clock.get();
}
});
yes("allowed at first", throttle.allowed("Player", "10.0.0.1"));
throttle.failed("Player", "10.0.0.1");
yes("not allowed right after a failure", !throttle.allowed("Player", "10.0.0.1"));
clock.addAndGet(600L);
yes("allowed again after the first wait", throttle.allowed("Player", "10.0.0.1"));
throttle.failed("Player", "10.0.0.1");
throttle.failed("Player", "10.0.0.1");
yes("the wait grows with each failure", !throttle.allowed("Player", "10.0.0.1"));
long second = throttle.waitSeconds("Player", "10.0.0.1");
yes("the second wait is longer than the first", second >= 1L);
clock.addAndGet(120_000L);
yes("allowed once the cap passes", throttle.allowed("Player", "10.0.0.1"));
throttle.failed("Player", "10.0.0.1");
yes("the account bucket is keyed case insensitively",
!throttle.allowed("player", "10.0.0.1"));
throttle.succeeded("Player");
yes("a success clears the account bucket", throttle.allowed("Player", "10.0.0.2"));
}
private static void testTokens() {
final AtomicLong clock = new AtomicLong(0L);
TokenService tokens = new TokenService(1000L, new LoginThrottle.Clock() {
public long now() {
return clock.get();
}
});
TokenService.Token issued = tokens.issue("Player");
yes("valid when fresh", tokens.valid(issued.value, "Player"));
yes("not valid for another account", !tokens.valid(issued.value, "Other"));
clock.set(1500L);
yes("expired after its lifetime", !tokens.valid(issued.value, "Player"));
TokenService.Token second = tokens.issue("Player");
tokens.revoke(second.value);
yes("revoked at once", !tokens.valid(second.value, "Player"));
tokens.issue("Player");
tokens.issue("Other");
eq("revokeAll clears just one account's", Integer.valueOf(1),
Integer.valueOf(tokens.revokeAll("Player")));
}
private static void testAccountFile(Path work) throws IOException {
Path file = work.resolve("accounts-roundtrip.txt");
Files.deleteIfExists(file);
AccountStore store = new AccountStore();
store.create("Alice", "password1".toCharArray(), 111L);
store.create("Bob", "password2".toCharArray(), 222L);
store.markLogin("Alice", 999L);
AccountFile.save(file, store);
AccountStore loaded = new AccountStore();
AccountFile.load(file, loaded);
eq("both accounts came back", Integer.valueOf(2), Integer.valueOf(loaded.size()));
eq("the right name", "Alice", loaded.find("alice").name);
eq("last login survived", Long.valueOf(999L),
Long.valueOf(loaded.find("Alice").lastLogin));
yes("the password still verifies",
Passwords.verify("password1".toCharArray(), loaded.find("Alice").decode()));
Files.deleteIfExists(file);
}
// ---------------------------------------------------------- wire level
/** Starts a server on an ephemeral port and returns it. */
private static Server boot(Path work, String name) throws IOException {
Path accounts = work.resolve("accounts-" + name + ".txt");
// a previous run leaves the file behind, and creating an account that
// is already there fails, so every test starts from nothing
Files.deleteIfExists(accounts);
Server server = new Server("RustNotMC test", "hello", 50, 0);
server.loadAccounts(accounts);
server.accounts().create(name, "password1".toCharArray(), 1L);
server.start();
return server;
}
private static void testEndToEndLogin(Path work) throws Exception {
Server server = boot(work, "Hero");
try {
LoginClient client = new LoginClient("127.0.0.1", server.port(), 10_000);
Packets.HelloAck hello = client.hello("Hero", "cli");
eq("server names itself", "RustNotMC test", hello.serverName);
eq("protocol version agrees", Integer.valueOf(Frame.VERSION),
Integer.valueOf(hello.protocolVersion));
eq("a nonce came back", Integer.valueOf(Packets.CHALLENGE_BYTES),
Integer.valueOf(hello.nonce.length));
eq("a salt came back", Integer.valueOf(Packets.SALT_BYTES),
Integer.valueOf(hello.salt.length));
Packets.AuthAck auth = client.auth("Hero", "password1".toCharArray(),
hello.salt, hello.nonce);
yes("the right password was accepted, server said: " + auth.reason,
auth.accepted);
yes("a token came back", auth.token != null && !auth.token.isEmpty());
Packets.LoginAck login = client.login("Hero", auth.token);
yes("the token was accepted", login.accepted);
yes("a player id was given", login.playerId > 0);
yes("an entity id was given", login.entityId > 0);
waitFor(() -> server.online() == 1, 4000L);
eq("the server counts one player", Integer.valueOf(1),
Integer.valueOf(server.online()));
client.close();
} finally {
server.stop();
}
}
private static void testEndToEndWrongPassword(Path work) throws Exception {
Server server = boot(work, "Fool");
try {
LoginClient client = new LoginClient("127.0.0.1", server.port(), 10_000);
Packets.HelloAck hello = client.hello("Fool", "cli");
Packets.AuthAck auth = client.auth("Fool", "wrongpassword".toCharArray(),
hello.salt, hello.nonce);
yes("the wrong password was refused", !auth.accepted);
yes("the reason is not empty", auth.reason != null && !auth.reason.isEmpty());
yes("no token was handed out", auth.token == null || auth.token.isEmpty());
client.close();
} finally {
server.stop();
}
}
private static void testReplayRejected(Path work) throws Exception {
Server server = boot(work, "Reef");
try {
LoginClient first = new LoginClient("127.0.0.1", server.port(), 10_000);
Packets.HelloAck hello = first.hello("Reef", "cli");
Packets.AuthAck auth = first.auth("Reef", "password1".toCharArray(),
hello.salt, hello.nonce);
yes("logged in once", auth.accepted);
first.close();
// a fresh connection with the token from the first one: the new
// nonce means the old proof is not what the server is asking for
LoginClient replay = new LoginClient("127.0.0.1", server.port(), 10_000);
Packets.HelloAck again = replay.hello("Reef", "cli");
yes("the nonce differs between connections",
!java.util.Arrays.equals(hello.nonce, again.nonce));
Packets.AuthAck reused = replay.auth("Reef", "password1".toCharArray(),
again.salt, hello.nonce);
yes("the old proof does not work against a new nonce", !reused.accepted);
replay.close();
} finally {
server.stop();
}
}
private static void testDoubleLogin(Path work) throws Exception {
Server server = boot(work, "Twin");
try {
LoginClient first = new LoginClient("127.0.0.1", server.port(), 10_000);
Packets.HelloAck a = first.hello("Twin", "cli");
Packets.AuthAck auth = first.auth("Twin", "password1".toCharArray(),
a.salt, a.nonce);
yes("first login ok", auth.accepted);
yes("first login in", first.login("Twin", auth.token).accepted);
LoginClient second = new LoginClient("127.0.0.1", server.port(), 10_000);
Packets.HelloAck b = second.hello("Twin", "cli");
Packets.AuthAck again = second.auth("Twin", "password1".toCharArray(),
b.salt, b.nonce);
yes("second auth ok", again.accepted);
Packets.LoginAck denied = second.login("Twin", again.token);
yes("the second player is turned away", !denied.accepted);
yes("the reason mentions the account is playing",
denied.reason != null && denied.reason.contains("already playing"));
first.close();
second.close();
} finally {
server.stop();
}
}
private static void testTickLoop() throws Exception {
Server server = new Server("tick", "tick", 10, 0);
server.start();
try {
long before = server.ticks();
Thread.sleep(1100L);
long after = server.ticks();
long moved = after - before;
// twenty ticks a second, so a second is twenty ticks give or take
// a few for scheduling; anything outside that means the loop is
// running at the wrong rate or stalling
yes("the loop moved (" + moved + " ticks in ~1s)", moved >= 12L && moved <= 32L);
} finally {
server.stop();
}
}
private static void waitFor(java.util.function.BooleanSupplier condition, long millis)
throws InterruptedException {
long end = System.currentTimeMillis() + millis;
while (System.currentTimeMillis() < end) {
if (condition.getAsBoolean()) {
return;
}
Thread.sleep(25L);
}
}
}