diff --git a/.gitignore b/.gitignore index a320255..8311cf1 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,8 @@ Data/ Logs/ **/Logs/ +### Diagnostic probe state - a runtime cursor, not configuration (see AnsVerifyProbe) ### +Config/AuthServer/AnsVerifyProbe.state + ### IntelliJ build artifacts (jars are built from source; no need to version them) ### out/ diff --git a/Config/AuthServer/AuthServer.ini b/Config/AuthServer/AuthServer.ini index 5c69af2..82195fa 100644 --- a/Config/AuthServer/AuthServer.ini +++ b/Config/AuthServer/AuthServer.ini @@ -2,14 +2,22 @@ # Found inside CLIENT_INFO.INI in the System.wpk on the SUN client. CLIENT_VERSION = 2.6.0.1 # Typically found in the bottom left corner of the login/server-select screen on the SUN client. This NEEDS to be checked! -CLIENT_PROTOCOL = 3.4.6 +# Original: 3.4.6 +CLIENT_PROTOCOL = 7.1.1 # Launcher's version. Found at Config/Launcher/Launcher.ini. LAUNCHER_VERSION = 1.0.0 [NETWORK] -# Determines whether one connection per IP address is allowed. +# Determines whether one connection per IP address is allowed on the client-facing listener. +# Was read by AuthServerConfig but never applied by NioServer until now; the filter used to be +# added unconditionally, so this line had no effect. +# KEEP THIS FALSE FOR LOCAL CLIENT TESTING. The launcher and the game client are both 127.0.0.1, +# and the launcher deliberately holds its AuthServer connection open across the handoff +# (LauncherController#onStartGame). With the filter on, the launcher owns the address and the +# game client is disconnected before it can send U2A_askVerify - see CLIENT-PROTOCOL-NOTES.md +# section 10. # DEFAULT: TRUE OPTIONS: TRUE | FALSE -UNIQUE_IP_FILTER = TRUE +UNIQUE_IP_FILTER = FALSE # This will force the connection to drop when read or write thread(s) have been idle for x-amount of time. Setting this to 0 will disable the disconnect feature. # DEFAULT: 300 (5 MINUTES) VALUE: SECONDS DISCONNECT = 300 diff --git a/auth-server/src/main/java/com/valiantgaming/authserver/network/packet/client/AnsSrvList.java b/auth-server/src/main/java/com/valiantgaming/authserver/network/packet/client/AnsSrvList.java index 7bf1acf..c47afe6 100644 --- a/auth-server/src/main/java/com/valiantgaming/authserver/network/packet/client/AnsSrvList.java +++ b/auth-server/src/main/java/com/valiantgaming/authserver/network/packet/client/AnsSrvList.java @@ -1,29 +1,175 @@ package com.valiantgaming.authserver.network.packet.client; +import com.valiantgaming.authserver.database.entity.server.ServerInfo; +import com.valiantgaming.authserver.network.session.server.GameServerRegistry; import com.valiantgaming.commons.network.packet.Category; import com.valiantgaming.commons.network.packet.Protocol; +import lombok.extern.log4j.Log4j2; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; /** - * Builds {@code A2U_ansSrvList_Srv} (server list) and {@code A2U_ansSrvList_Chn} (channel - * list for a selected server), sent in response to {@code U2A_askSrvList}. + * Builds {@code A2U_ansSrvList_Srv} (the server list) and {@code A2U_ansSrvList_Chn} (the channel + * list), sent in response to {@code U2A_askSrvList}. + * + *
Layouts come from the EP1 {@code ServerPackets} documentation, transcribed in + * {@code CLIENT-PROTOCOL-NOTES.md} §11.4. The 2-byte little-endian length header is added by + * {@code ClientPacketEncoder}, so everything below starts at the category byte: + * + *
+ * Servers 0x33 | 0x11 | count(1) | entry [ 0x00 entry ]* + * entry = Name(32, null-padded) | Unknown(1) | Server#(1) | Unknown(1) + * + * Channels 0x33 | 0x12 | count(1) | entry [ 0x00 entry ]* + * entry = Name(33, null-padded) | Server#(1) | Channel#(1) | Terminator(1, NOT 0x00) + *+ * + *
Note the asymmetry, which is easy to get wrong: server names are 32 bytes, channel + * names are 33. The single {@code 0x00} separator sits between entries only - there + * is none after the last one. * - *
Neither packet's payload layout has been captured, and there's no S2S query yet for - * auth-server to ask database-server for the live {@code ServerInfo} rows it would need to - * populate a real list (see {@code DatabaseManager}, which already loads them at startup on - * the database-server side). Both methods below are placeholders that report zero entries - - * revisit once both the wire format and that S2S query exist. + *
These layouts are EP1-era and unverified against the Classic client. §11 found the + * request side unchanged between generations and {@code U2A_askSrvList} confirmed exactly + * ({@code 33 0F}, no body - §13.3), but {@code askAuthUser} did change, so a response layout + * surviving is not guaranteed. The three unknown/terminator bytes are the likeliest thing to be + * wrong - see {@link #SERVER_NAME_TERMINATOR} and {@link #SERVER_ENTRY_TERMINATOR}. + * + *
Data source, and it is the wrong one. Servers come from {@link GameServerRegistry}, + * which holds the one {@code ServerInfo} row the S2S handshake already fetches - but + * {@code ServerInfo} is the infrastructure registry (AUTH/GAME/DB/WEB and their S2S ports), not + * player-facing worlds. Channels are worse: nothing queries them, so one is synthesised per server + * (see {@link #CHANNEL_NAME}). Both were enough to prove the layouts against a real client (§16) + * and neither is right. + * + *
The real sources are the {@code GameServerInfo} and {@code ChannelInfo} tables, which exist + * and are empty, and which need stored procedures, DAOs and an S2S list query before this can read + * them - see {@code CLIENT-PROTOCOL-NOTES.md} §18 for the full handoff. */ +@Log4j2 public class AnsSrvList { + /** Fixed width of a server name, null-padded. Channel names are one byte wider - see below. */ + private static final int SERVER_NAME_LENGTH = 32; + + /** Fixed width of a channel name, null-padded. Deliberately 33, not 32 - see the class comment. */ + private static final int CHANNEL_NAME_LENGTH = 33; + + /** Single {@code 0x00} written between entries, never after the last. */ + private static final byte ENTRY_SEPARATOR = 0x00; + + /** + * The documentation's first "Unknown (1 byte)" in a server entry, sitting between the name and + * the server number. Written as {@code 0x00}, which is what the documentation implies and also + * the reading that makes the server name field effectively 33 bytes like the channel one. + */ + private static final byte SERVER_NAME_TERMINATOR = 0x00; + + /** + * The documentation's second "Unknown (1 byte)", closing a server entry. + * + *
Prime suspect if the client rejects the server list. The documentation gives no
+ * value for it, but the channel entry has a byte in the same position that it explicitly says
+ * "cannot be 0x00". If these two fields are the same thing, this should be
+ * {@link #CHANNEL_TERMINATOR} instead. Left at the literal documented reading until a client
+ * says otherwise; flipping it is a one-line change.
+ */
+ private static final byte SERVER_ENTRY_TERMINATOR = 0x00;
+
+ /** Closes a channel entry. The documentation is explicit that this must not be {@code 0x00}. */
+ private static final byte CHANNEL_TERMINATOR = 0x01;
+
+ /**
+ * Placeholder channel name. Nothing in this stack models channels - there is no table, no
+ * config and no S2S query for them - so one channel is synthesised per server purely so the
+ * client has something selectable and can be driven on to {@code U2A_askSrvSelect}.
+ */
+ private static final String CHANNEL_NAME = "Channel 1";
+
+ /**
+ * Numbering handed to the client for both servers and channels. Zero-based on the assumption
+ * that these are indexes rather than display numbers - unconfirmed. The client echoes
+ * both back in {@code U2A_askSrvSelect} (see {@code SrvSelect}, which logs the payload), so the
+ * first selection a client makes settles the convention.
+ */
+ private static final int FIRST_NUMBER = 0;
+
public byte[] createServerListPacket()
{
- // TODO: replace with real ServerInfo entries once an S2S "list servers" query exists.
- return new byte[] { Category.AUTH, Protocol.A2U_ansSrvList_Srv, 0x00 };
+ List Today that is exactly one row, and it arrives for free: the S2S handshake already asks
+ * database-server for {@code "GAME SERVER"} ({@code AskServerInfo} -> {@code PacketHandler}'s
+ * {@code S2S_askServerInfo} branch) and gets back a full {@code ServerInfo} with its address and
+ * port. {@code ServerPacketHandler} used to decode that, log it and drop it on the floor; it is
+ * kept here instead so {@code AnsSrvList} has real data to answer with rather than a placeholder.
+ *
+ * This is a stopgap on the wrong table. {@code ServerInfo} is this stack's own
+ * infrastructure registry - its four rows are DATABASE/AUTH/GAME/WEB SERVER and their S2S
+ * addresses, not player-facing worlds. It was used because it is the only live data auth-server
+ * has, and it was enough to prove the {@code ansSrvList} layouts against a real client (§16).
+ *
+ * The right sources are the {@code GameServerInfo} and {@code ChannelInfo} tables, which already
+ * exist and are empty - see {@code CLIENT-PROTOCOL-NOTES.md} §18. The distinction bites at
+ * {@code A2U_ansSrvSelect}: the port in this row is the game server's S2S listener, and handing it
+ * to a client would point it at the wrong socket.
+ *
+ * Empty until the S2S handshake completes, which is normal at startup and for as long as
+ * database-server is unreachable - {@code NioServer#initS2S} connects once and never retries, so
+ * an auth-server started first stays empty until it is restarted.
+ */
+public final class GameServerRegistry
+{
+ private static final AtomicReference Several of the game-client packets' real payload layouts are unconfirmed and their
* answers are placeholders - see the class comments on {@code AnsVerify}/{@code AnsSrvList}/
* {@code AnsSrvSelect} and on {@code AuthUser} for exactly what's still stubbed.
+ *
+ * A heartbeat probe is now a DEBUG connect and a DEBUG close with {@code 0 packet(s)}; a real
+ * game client is the only thing that produces INFO {@code [conn-N] <- } lines. So "did the client
+ * answer our {@code A2U_ansVerify}?" is answered by whether {@code conn-N} logs a second inbound
+ * packet, and the close line for that same {@code conn-N} states the total either way.
*/
@Log4j2
public class ClientPacketHandler extends ChannelDuplexHandler
{
+ /** Per-JVM connection counter, so every log line can name which connection it belongs to. */
+ private static final AtomicLong CONNECTION_COUNTER = new AtomicLong();
+
+ /**
+ * How many times one connection may be sent {@code AnsVerifyProbe}'s re-trigger. Diagnostic
+ * only, and only a runaway guard: the client re-verifies on every re-trigger, so without a
+ * bound the two would ping-pong forever, spin the log and wrap the candidate rotation. Sized to
+ * comfortably clear the 32-entry opcode sweep in one launch.
+ */
+ private static final int MAX_PROBE_RETRIGGERS = 40;
+
+ // One handler instance per channel (NioServer#initC2S news one up in initChannel), so these
+ // are per-connection state, not shared.
+ private final long connectionId = CONNECTION_COUNTER.incrementAndGet();
+ private long connectedAt;
+ private int packetsReceived;
+ private int probeRetriggersSent;
+
@Override
public void channelActive(ChannelHandlerContext ctx)
{
- log.info("Client connected from " + ctx.channel().remoteAddress());
+ connectedAt = System.nanoTime();
+
+ // DEBUG, not INFO: most connections here are the launcher's five-second reachability
+ // probe, which connects and closes without speaking. channelRead announces anything that
+ // actually says something.
+ log.debug("[conn-{}] Client connected from {}", connectionId, ctx.channel().remoteAddress());
ClientSessionManager.getInstance().addSession(ctx);
ClientSession session = ClientSessionManager.getInstance().getSession(ctx);
@@ -61,7 +100,12 @@ public void channelActive(ChannelHandlerContext ctx)
public void channelRead(ChannelHandlerContext ctx, Object msg)
{
byte[] message = (byte[]) msg;
- log.info("Message: {}", Utility.byteArrayToHexString(message));
+
+ if(++packetsReceived == 1)
+ log.info("[conn-{}] First inbound packet from {} - this connection is a real client, not a health-check probe.",
+ connectionId, ctx.channel().remoteAddress());
+
+ log.info("[conn-{}] <- #{} {}", connectionId, packetsReceived, Utility.byteArrayToHexString(message));
ClientSession session = ClientSessionManager.getInstance().getSession(ctx);
@@ -71,7 +115,7 @@ public void channelRead(ChannelHandlerContext ctx, Object msg)
{
case Protocol.L2A_askUnknown1:
{
- log.info("Launcher handshake started by {}", ctx.channel().remoteAddress());
+ log.info("[conn-{}] Launcher handshake started by {}", connectionId, ctx.channel().remoteAddress());
ctx.writeAndFlush(new AnsLauncherReady().createPacket());
ctx.writeAndFlush(new AnsVerifyVersion().createPacket());
@@ -80,7 +124,14 @@ public void channelRead(ChannelHandlerContext ctx, Object msg)
case Protocol.U2A_askVerify:
{
byte[] payload = VerifyUser.decode(message);
- log.info("Received verify request, payload: {}", Utility.byteArrayToHexString(payload));
+ log.info("[conn-{}] Received verify request, payload: {}", connectionId, Utility.byteArrayToHexString(payload));
+
+ if(AuthServerConfig.isAnsVerifyProbe())
+ {
+ // Diagnostic mode: serve a different candidate per connection to find the
+ // real response format. See AnsVerifyProbe.
+ AnsVerifyProbe.Attempt attempt = AnsVerifyProbe.next(payload);
+ log.info("[conn-{}] PROBE serving ansVerify candidate {} -> {}", connectionId, attempt.label(), attempt.hex());
if(AuthServerConfig.isAnsVerifyProbe())
{
@@ -107,7 +158,7 @@ public void channelRead(ChannelHandlerContext ctx, Object msg)
if(dbChannel == null || !dbChannel.isActive())
{
- log.error("No active connection to Database Server - cannot authenticate {}", credentials.username());
+ log.error("[conn-{}] No active connection to Database Server - cannot authenticate {}", connectionId, credentials.username());
ctx.writeAndFlush(AnsAuthUser.createPacket(false));
break;
}
@@ -120,11 +171,8 @@ public void channelRead(ChannelHandlerContext ctx, Object msg)
}
case Protocol.U2A_askSrvList:
{
- if(!session.isAuthenticated())
- {
- log.warn("Client {} asked for the server list before authenticating - dropping.", ctx.channel().remoteAddress());
+ if(!isAllowed(session, ctx, "the server list"))
return;
- }
AnsSrvList ansSrvList = new AnsSrvList();
ctx.writeAndFlush(ansSrvList.createServerListPacket());
@@ -133,21 +181,18 @@ public void channelRead(ChannelHandlerContext ctx, Object msg)
}
case Protocol.U2A_askSrvSelect:
{
- if(!session.isAuthenticated())
- {
- log.warn("Client {} asked to select a server before authenticating - dropping.", ctx.channel().remoteAddress());
+ if(!isAllowed(session, ctx, "select a server"))
return;
- }
byte[] payload = SrvSelect.decode(message);
- log.info("Received server select request, payload: {}", Utility.byteArrayToHexString(payload));
+ log.info("[conn-{}] Received server select request, payload: {}", connectionId, Utility.byteArrayToHexString(payload));
ctx.writeAndFlush(new AnsSrvSelect().createPacket());
break;
}
default:
{
- log.warn("Unknown packet! Packet: {}", Utility.byteArrayToHexString(message));
+ log.warn("[conn-{}] Unknown packet! Packet: {}", connectionId, Utility.byteArrayToHexString(message));
}
}
}
@@ -207,6 +252,38 @@ public void channelRead(ChannelHandlerContext ctx, Object msg)
// }
}
+ /**
+ * Whether a post-login packet should be served. Normally that means the session authenticated;
+ * while the ansVerify probe is on, it does not.
+ *
+ * The probe reaches these packets by sending {@code 33 0E 00} ({@code A2U_ansAuthUser},
+ * success) directly, which the client acts on without ever sending {@code U2A_askAuthUser} - so
+ * the session never learns it is authenticated and the gate rejected exactly the traffic the
+ * probe exists to produce (see {@code CLIENT-PROTOCOL-NOTES.md} §14.2 and §14.3).
+ *
+ * Tied to the probe flag rather than a new setting of its own because the two are inseparable
+ * in practice: without the probe the client never gets past verify, so it never reaches here
+ * unauthenticated anyway. That also means this relaxation cannot be left on by accident in
+ * production - {@code ANS_VERIFY_PROBE} is off there, and it already announces itself loudly.
+ */
+ private boolean isAllowed(ClientSession session, ChannelHandlerContext ctx, String what)
+ {
+ if(session.isAuthenticated())
+ return true;
+
+ if(AuthServerConfig.isAnsVerifyProbe())
+ {
+ log.warn("[conn-{}] PROBE: client {} asked for {} without authenticating - answering anyway.",
+ connectionId, ctx.channel().remoteAddress(), what);
+ return true;
+ }
+
+ log.warn("[conn-{}] Client {} asked for {} before authenticating - dropping.",
+ connectionId, ctx.channel().remoteAddress(), what);
+
+ return false;
+ }
+
@Override
public void channelInactive(ChannelHandlerContext ctx)
{
@@ -237,7 +314,7 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
}
else
{
- log.error("Unexpected error on channel {}", ctx.channel().remoteAddress(), cause);
+ log.error("[conn-{}] Unexpected error on channel {}", connectionId, ctx.channel().remoteAddress(), cause);
}
ctx.close();
diff --git a/auth-server/src/main/java/com/valiantgaming/authserver/server/handler/ServerPacketHandler.java b/auth-server/src/main/java/com/valiantgaming/authserver/server/handler/ServerPacketHandler.java
index 4ccb539..6b270c1 100644
--- a/auth-server/src/main/java/com/valiantgaming/authserver/server/handler/ServerPacketHandler.java
+++ b/auth-server/src/main/java/com/valiantgaming/authserver/server/handler/ServerPacketHandler.java
@@ -7,6 +7,7 @@
import com.valiantgaming.authserver.network.packet.server.handler.GetServerInfo;
import com.valiantgaming.authserver.network.session.client.ClientSession;
import com.valiantgaming.authserver.network.session.client.ClientSessionManager;
+import com.valiantgaming.authserver.network.session.server.GameServerRegistry;
import com.valiantgaming.authserver.network.session.server.PendingAuthRequests;
import com.valiantgaming.authserver.network.session.server.ServerSession;
import com.valiantgaming.authserver.network.session.server.ServerSessionManager;
@@ -87,6 +88,10 @@ public void channelRead(ChannelHandlerContext ctx, Object msg)
{
ServerInfo serverInfo = GetServerInfo.decode(message);
log.info("Received ServerInfo response from " + ctx.channel().remoteAddress() + ": " + serverInfo);
+
+ // Kept rather than just logged: this is the row AnsSrvList offers the client, and it is
+ // the only live server data auth-server has - see GameServerRegistry.
+ GameServerRegistry.setGameServer(serverInfo);
}
else if(message[1] == Protocol.S2S_ansAuthUser)
{
Reading the log
+ * Every line carries a {@code [conn-N]} tag, and connections that never send a byte are logged
+ * at DEBUG rather than INFO. Both exist because this listener is not only spoken to by the game
+ * client: the launcher opens a persistent connection at startup ({@code NioClient}) and
+ * re-connects every five seconds as a reachability probe
+ * ({@code ServerHealthCheck#isReachable}), so at INFO the log used to be a stream of
+ * connect/disconnect pairs no different in shape from a real client's.
+ *
+ *