Серверная база со своим логином
Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него. Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а запись обрезать нельзя: сервер не знает исходного пароля. Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе: соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт называется в Hello, а не в Auth. Иначе клиент получает соль не того аккаунта и не может вывести ключ. Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды лежат рядом с записью, а не в исходниках: число, существующее только в коде, однажды становится причиной того, что аккаунт невозможно проверить. Имена уникальны независимо от регистра: Player и player — это один аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его. Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает подбирать одно имя, другое не мешает перебирать много имён с разных адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на неверный пароль, и занимает то же время. Токены живут только в памяти. Записанный на диск токен пережил бы процесс, который его выдал, а украденный каталог сервера стал бы набором живых сессий. Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто зашёл с одного IP, то есть всех игроков за одним NAT. Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего мы уходим, перенося симуляцию на свой сервер. Регистрация — команда оператора, а не функция у клиента. Открытая саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь поддержки. Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий сокет: регистрация, вход, доказательство пароля, токен, его погашение и появление игроком. Отдельно проверено, что неверный пароль отвергается, а старое доказательство не принимается против нового вызова. Тик-цикл замеряется числом тиков за секунду, а не наличием потока. Всё одним JDK, без Gradle и без сети, как и весь проект.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user