diff --git a/mapsync-mod/build.gradle.kts b/mapsync-mod/build.gradle.kts index e0e9fe62..8926c349 100644 --- a/mapsync-mod/build.gradle.kts +++ b/mapsync-mod/build.gradle.kts @@ -30,6 +30,12 @@ dependencies { } modImplementation(libs.fabricLoader) modImplementation(libs.fabricApi) + + project(":dep-websockets", configuration = "shadedElements").also { + implementation(it) + include(it) + } + modLocalDep(libs.fixChat) modImplementation(libs.modmenu) diff --git a/mapsync-mod/dep-websockets/build.gradle.kts b/mapsync-mod/dep-websockets/build.gradle.kts new file mode 100644 index 00000000..104fbeaa --- /dev/null +++ b/mapsync-mod/dep-websockets/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + id("java-library") + alias(libs.plugins.shadow) +} + +version = libs.java.ws.get().version!! + +dependencies { + implementation(libs.java.ws) +} + +repositories { + mavenCentral() +} + +tasks { + shadowJar { + include("org/java_websocket/**") + include("META-INF/LICENSE.txt") + rename("LICENSE.txt", "LICENSE_JavaWebSockets") + relocate( + "org.java_websocket", + "gjum.minecraft.mapsync.mod.deps.websockets" + ) + } +} + +val shadedElements by configurations.creating { + isCanBeConsumed = true + isCanBeResolved = false +} + +artifacts { + add(shadedElements.name, tasks.shadowJar) +} diff --git a/mapsync-mod/gradle/libs.versions.toml b/mapsync-mod/gradle/libs.versions.toml index 2b6d44d7..c899afbb 100644 --- a/mapsync-mod/gradle/libs.versions.toml +++ b/mapsync-mod/gradle/libs.versions.toml @@ -22,6 +22,10 @@ voxelmap = { group = "maven.modrinth", name = "voxelmap-updated", version = "KgO journeymap = { group = "maven.modrinth", name = "journeymap", version = "6vu2HyQi" } # https://modrinth.com/mod/xaeros-world-map/versions?l=fabric xaerosmap = { group = "maven.modrinth", name = "xaeros-world-map", version = "CkZVhVE0" } +# https://mvnrepository.com/artifact/org.java-websocket/Java-WebSocket +java-ws = { group = "org.java-websocket", name = "Java-WebSocket", version = "1.6.0" } [plugins] fabricLoom = { id = "fabric-loom", version.ref = "fabricLoom" } +# https://plugins.gradle.org/plugin/com.gradleup.shadow +shadow = { id = "com.gradleup.shadow", version = "9.4.1" } diff --git a/mapsync-mod/settings.gradle.kts b/mapsync-mod/settings.gradle.kts index 9c1eedef..a66b34a7 100644 --- a/mapsync-mod/settings.gradle.kts +++ b/mapsync-mod/settings.gradle.kts @@ -10,3 +10,5 @@ pluginManagement { } rootProject.name = "MapSync" + +include(":dep-websockets"); diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java index 7138fbd1..6c56e659 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java @@ -5,8 +5,7 @@ import gjum.minecraft.mapsync.mod.data.ChunkTile; import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import gjum.minecraft.mapsync.mod.utils.Shortcuts; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; +import java.io.ByteArrayOutputStream; import java.security.MessageDigest; import java.util.ArrayList; import net.minecraft.client.Minecraft; @@ -36,10 +35,10 @@ public static ChunkTile chunkTileFromLevel(Level level, int cx, int cz) { // TODO speedup: don't serialize twice (once here, once later when writing to network) final byte[] dataHash; { - final ByteBuf columnsBuf = Unpooled.buffer(); - Failable.run(() -> ChunkTile.writeColumns(columns, new BufferWriter(columnsBuf))); + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + Failable.run(() -> ChunkTile.writeColumns(columns, new BufferWriter(os))); final MessageDigest md = Shortcuts.shaHash(); - md.update(columnsBuf.nioBuffer()); + md.update(os.toByteArray()); dataHash = md.digest(); } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java index ad3dcd0a..241dfa7d 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java @@ -8,31 +8,32 @@ import gjum.minecraft.mapsync.mod.data.CatchupChunk; import gjum.minecraft.mapsync.mod.data.ChunkTile; import gjum.minecraft.mapsync.mod.data.RegionPos; +import gjum.minecraft.mapsync.mod.net.CloseContext; +import gjum.minecraft.mapsync.mod.net.Packet; import gjum.minecraft.mapsync.mod.net.SyncClient; +import gjum.minecraft.mapsync.mod.net.SyncClients; +import gjum.minecraft.mapsync.mod.net.UnexpectedPacketException; +import gjum.minecraft.mapsync.mod.net.auth.AuthProcess; +import gjum.minecraft.mapsync.mod.net.packet.ChunkTilePacket; import gjum.minecraft.mapsync.mod.net.packet.ClientboundChunkTimestampsResponsePacket; +import gjum.minecraft.mapsync.mod.net.packet.ClientboundIdentityRequestPacket; import gjum.minecraft.mapsync.mod.net.packet.ClientboundRegionTimestampsPacket; +import gjum.minecraft.mapsync.mod.net.packet.ClientboundWelcomePacket; import gjum.minecraft.mapsync.mod.net.packet.ServerboundCatchupRequestPacket; import gjum.minecraft.mapsync.mod.net.packet.ServerboundChunkTimestampsRequestPacket; import it.unimi.dsi.fastutil.objects.Object2LongArrayMap; import it.unimi.dsi.fastutil.objects.Object2LongMap; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; -import net.fabricmc.loader.api.FabricLoader; import net.minecraft.client.KeyMapping; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ServerData; -import net.minecraft.network.protocol.game.ClientboundLoginPacket; import net.minecraft.network.protocol.game.ClientboundRespawnPacket; import net.minecraft.resources.Identifier; import net.minecraft.world.level.ChunkPos; @@ -43,19 +44,6 @@ import org.lwjgl.glfw.GLFW; public final class MapSyncMod implements ClientModInitializer { - public static final String VERSION; static { - final InputStream in = MapSyncMod.class.getResourceAsStream("/mapsync.version.const"); - if (in == null) { - throw new ExceptionInInitializerError(new NullPointerException("'mapsync.version.const' const is missing!")); - } - try (in) { - VERSION = new String(in.readAllBytes(), StandardCharsets.UTF_8).trim(); - } - catch (final IOException e) { - throw new ExceptionInInitializerError(e); - } - } - private static final Minecraft mc = Minecraft.getInstance(); public static final Logger logger = LogManager.getLogger(MapSyncMod.class); @@ -81,8 +69,6 @@ public static MapSyncMod getMod() { //"category.map-sync" ); - private @NotNull List syncClients = new ArrayList<>(); - /** * Tracks state and render thread for current mc dimension. * Never access this directly; always go through `getDimensionState()`. @@ -115,18 +101,7 @@ public void onInitializeClient() { e.printStackTrace(); } }); - } - - - /** - * for example: 1.0.0+forge - */ - public String getVersion() { - return VERSION + "+fabric"; - } - - public boolean isDevMode() { - return FabricLoader.getInstance().isDevelopmentEnvironment(); + SyncClients.initEvents(); } public void handleTick( @@ -140,8 +115,36 @@ public void handleTick( if (dimensionState != null) dimensionState.onTick(); } - public void handleConnectedToServer(ClientboundLoginPacket packet) { - getSyncClients(); + public void handleSyncConnection( + final @NotNull SyncClient client + ) throws Exception { + client.authState.set(null); + AuthProcess.sendHandshake( + client, + this.getDimensionState() + ); + } + + public void handleSyncDisconnection( + final @NotNull SyncClient client, + final @NotNull CloseContext context + ) { + client.authState.set(null); + } + + /// BEWARE: This is called from whatever thread the given SyncClient websocket is using for reads. + public void handleSyncPacket( + final @NotNull SyncClient client, + final @NotNull Packet received + ) throws Exception { + switch (received) { + case ChunkTilePacket(ChunkTile chunkTile) -> handleSharedChunk(client, chunkTile); + case ClientboundIdentityRequestPacket packet -> AuthProcess.handleIdentityRequest(client, packet); + case ClientboundWelcomePacket packet -> AuthProcess.handleWelcome(client, packet); + case ClientboundRegionTimestampsPacket packet -> handleRegionTimestamps(client, packet); + case ClientboundChunkTimestampsResponsePacket packet -> handleCatchupData(client, packet); + default -> throw new UnexpectedPacketException(received); + } } public void handleRespawn(ClientboundRespawnPacket packet) { @@ -167,48 +170,6 @@ public void handleRespawn(ClientboundRespawnPacket packet) { return serverConfig; } - public @NotNull List getSyncClients() { - var serverConfig = getServerConfig(); - if (serverConfig == null) return shutDownSyncClients(); - - var syncServerAddresses = serverConfig.getSyncServerAddresses(); - if (syncServerAddresses.isEmpty()) return shutDownSyncClients(); - - // will be filled with clients that are still wanted (address) and are still connected - var existingClients = new HashMap(); - - for (SyncClient client : syncClients) { - if (client.isShutDown) continue; - // avoid reconnecting to same sync server, to keep shared state (expensive to resync) - if (!client.gameAddress.equals(serverConfig.gameAddress)) { - debugLog("Disconnecting sync client; different game server"); - client.shutDown(); - } else if (!syncServerAddresses.contains(client.address)) { - debugLog("Disconnecting sync client; different sync address"); - client.shutDown(); - } else { - existingClients.put(client.address, client); - } - } - - syncClients = syncServerAddresses.stream().map(address -> { - var client = existingClients.get(address); - if (client == null) client = new SyncClient(address, serverConfig.gameAddress); - client.autoReconnect = true; - return client; - }).collect(Collectors.toList()); - - return syncClients; - } - - public List shutDownSyncClients() { - for (SyncClient client : syncClients) { - client.shutDown(); - } - syncClients.clear(); - return Collections.emptyList(); - } - /** * for current dimension */ @@ -253,7 +214,7 @@ public void handleMcFullChunk(int cx, int cz) { if (RenderQueue.areAllMapModsMapping()) { dimensionState.setChunkTimestamp(chunkTile.chunkPos(), chunkTile.timestamp()); } - for (SyncClient client : getSyncClients()) { + for (SyncClient client : SyncClients.get().orElseThrow()) { client.sendChunkTile(chunkTile); } } @@ -271,7 +232,8 @@ public void handleSyncServerEncryptionSuccess() { // TODO tell server our current dimension } - public void handleRegionTimestamps(ClientboundRegionTimestampsPacket packet, SyncClient client) { + public void handleRegionTimestamps(SyncClient client, ClientboundRegionTimestampsPacket packet) { + client.authState.requireWelcomed(); DimensionState dimension = getDimensionState(); if (dimension == null) return; if (!dimension.dimension.identifier().toString().equals(packet.dimension())) { @@ -294,9 +256,10 @@ public void handleRegionTimestamps(ClientboundRegionTimestampsPacket packet, Syn } } - public void handleSharedChunk(ChunkTile chunkTile) { + public void handleSharedChunk(SyncClient client, ChunkTile chunkTile) { + client.authState.requireWelcomed(); debugLog("received shared chunk: " + chunkTile.chunkPos()); - for (SyncClient syncClient : getSyncClients()) { + for (SyncClient syncClient : SyncClients.get().orElseThrow()) { syncClient.setServerKnownChunkHash(chunkTile.chunkPos(), chunkTile.dataHash()); } @@ -305,10 +268,14 @@ public void handleSharedChunk(ChunkTile chunkTile) { dimensionState.processSharedChunk(chunkTile); } - public void handleCatchupData(ClientboundChunkTimestampsResponsePacket packet) { + public void handleCatchupData(SyncClient client, ClientboundChunkTimestampsResponsePacket packet) { + client.authState.requireWelcomed(); + for (CatchupChunk chunk : packet.chunks()) { + chunk.syncClient = client; + } var dimensionState = getDimensionState(); if (dimensionState == null) return; - debugLog("received catchup: " + packet.chunks().size() + " " + packet.chunks().get(0).syncClient.address); + debugLog("received catchup: " + packet.chunks().size() + " " + client.syncAddress); dimensionState.addCatchupChunks(packet.chunks()); } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/ModGui.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/ModGui.java index 2e4d639b..74514f13 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/ModGui.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/ModGui.java @@ -3,6 +3,8 @@ import static gjum.minecraft.mapsync.mod.MapSyncMod.getMod; import gjum.minecraft.mapsync.mod.config.ServerConfig; +import gjum.minecraft.mapsync.mod.net.SyncClients; +import java.util.HashSet; import java.util.List; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; @@ -98,10 +100,9 @@ protected void init() { public void connectClicked(Button btn) { try { if (syncServerAddressField == null) return; - var addresses = List.of(syncServerAddressField.getValue().split("[^-_.:A-Za-z0-9]+")); + var addresses = List.of(syncServerAddressField.getValue().split("[^-_.:A-Za-z0-9/]+")); serverConfig.setSyncServerAddresses(addresses); - getMod().shutDownSyncClients(); - getMod().getSyncClients(); + SyncClients.get().orElseThrow().setAll(new HashSet<>(addresses)); btn.active = false; syncServerDisconnectBtn.active = true; } catch (Throwable e) { @@ -112,7 +113,7 @@ public void connectClicked(Button btn) { // TODO: not working public void disconnectClicked(Button btn) { if (syncServerAddressField == null) return; - getMod().shutDownSyncClients(); + SyncClients.get().orElseThrow().closeAll(true); btn.active = false; } @@ -148,21 +149,31 @@ public void render(@NotNull GuiGraphics guiGraphics, int i, int j, float f) { } int msgY = syncServerAddressField.getY() + 25; - var syncClients = getMod().getSyncClients(); - for (var client : syncClients) { + for (var client : SyncClients.get().orElseThrow()) { int statusColor; String statusText; - if (client.isEncrypted()) { - statusColor = 0xFF008800; - statusText = "Connected"; - } else if (client.getError() != null) { - statusColor = 0xFFff8888; - statusText = client.getError(); - } else { - statusColor = 0xFFffffff; - statusText = "Connecting..."; + + var connectionState = client.state(); + switch (connectionState) { + case DISCONNECTED -> { + statusColor = 0xFFff8888; + statusText = "Disconnected"; + } + case CONNECTED -> { + statusColor = 0xFF8888ff; + statusText = "Connected (not authed)"; + } + case WELCOMED -> { + statusColor = 0xFF88ff88; + statusText = "Connected and authed"; + } + default -> { + statusColor = 0xFFFFFF00; + statusText = "Unknown state: " + connectionState; + } } - statusText = client.address + " " + statusText; + + statusText = client.syncAddress + " " + statusText; guiGraphics.drawString(font, statusText, left, msgY, statusColor); msgY += 10; } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/JsonConfig.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/JsonConfig.java index 6686ae9c..7d12d2e7 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/JsonConfig.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/JsonConfig.java @@ -1,5 +1,7 @@ package gjum.minecraft.mapsync.mod.config; +import static gjum.minecraft.mapsync.mod.MapSyncMod.logger; + import com.google.gson.Gson; import com.google.gson.GsonBuilder; import java.io.File; @@ -34,16 +36,16 @@ public class JsonConfig { try (FileReader reader = new FileReader(file)) { T config = GSON.fromJson(reader, clazz); config.configFile = file; - System.out.println("[map-sync] Loaded existing " + file); + logger.info("Loaded existing {}", file); return config; } catch (FileNotFoundException ignored) { } catch (IOException e) { - e.printStackTrace(); + logger.error("Failed to load config file {}", file, e); } try { final T config = clazz.getConstructor().newInstance(); config.configFile = file; - System.out.println("[map-sync] Created default " + file); + logger.info("Created default {}", file); return config; } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException ex) { @@ -66,14 +68,14 @@ public void run() { public void saveNow() { try { lastSaveTime = System.currentTimeMillis(); - System.out.println("Saving " + getClass().getSimpleName() + " to " + configFile); + logger.info("Saving {} to {}", getClass().getSimpleName(), configFile); configFile.getParentFile().mkdirs(); String json = GSON.toJson(this); FileOutputStream fos = new FileOutputStream(configFile); fos.write(json.getBytes()); fos.close(); } catch (IOException e) { - e.printStackTrace(); + logger.error("Failed to save config file {}", configFile, e); } } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ServerConfig.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ServerConfig.java index 93012791..9590b7b2 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ServerConfig.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ServerConfig.java @@ -1,7 +1,5 @@ package gjum.minecraft.mapsync.mod.config; -import static gjum.minecraft.mapsync.mod.MapSyncMod.getMod; - import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.annotations.Expose; @@ -34,8 +32,6 @@ public void setSyncServerAddresses(@NotNull List addresses) { .collect(Collectors.toCollection(ArrayList::new)); saveLater(); - - getMod().getSyncClients(); // trigger dis/connection if address changed } public static ServerConfig load(String gameAddress) { diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/integrations/journeymap/JourneyMapHelperReal.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/integrations/journeymap/JourneyMapHelperReal.java index e2304b0b..a8f1b834 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/integrations/journeymap/JourneyMapHelperReal.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/integrations/journeymap/JourneyMapHelperReal.java @@ -1,5 +1,6 @@ package gjum.minecraft.mapsync.mod.integrations.journeymap; +import static gjum.minecraft.mapsync.mod.MapSyncMod.logger; import static gjum.minecraft.mapsync.mod.Utils.mc; import gjum.minecraft.mapsync.mod.data.BlockColumn; @@ -42,15 +43,15 @@ static boolean updateWithChunkTile(ChunkTile chunkTile) { final boolean renderedDay = renderController.renderChunk(rCoord, MapType.day(chunkTile.dimension()), chunkMd, regionData); - if (!renderedDay) System.out.println("Failed rendering day at " + chunkTile.chunkPos()); + if (!renderedDay) logger.warn("Failed rendering day at {}", chunkTile.chunkPos()); final boolean renderedBiome = renderController.renderChunk(rCoord, MapType.biome(chunkTile.dimension()), chunkMd, regionData); - if (!renderedBiome) System.out.println("Failed rendering biome at " + chunkTile.chunkPos()); + if (!renderedBiome) logger.warn("Failed rendering biome at {}", chunkTile.chunkPos()); final boolean renderedTopo = renderController.renderChunk(rCoord, MapType.topo(chunkTile.dimension()), chunkMd, regionData); - if (!renderedTopo) System.out.println("Failed rendering topo at " + chunkTile.chunkPos()); + if (!renderedTopo) logger.warn("Failed rendering topo at {}", chunkTile.chunkPos()); return renderedDay && renderedBiome && renderedTopo; } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/mixins/MixinClientPacketListener.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/mixins/MixinClientPacketListener.java index 75093ad4..04f3865d 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/mixins/MixinClientPacketListener.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/mixins/MixinClientPacketListener.java @@ -9,7 +9,6 @@ import net.minecraft.network.protocol.game.ClientboundBlockDestructionPacket; import net.minecraft.network.protocol.game.ClientboundBlockUpdatePacket; import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket; -import net.minecraft.network.protocol.game.ClientboundLoginPacket; import net.minecraft.network.protocol.game.ClientboundRespawnPacket; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -19,16 +18,6 @@ @Mixin(ClientPacketListener.class) public abstract class MixinClientPacketListener { - @Inject(method = "handleLogin", at = @At("RETURN")) - protected void onHandleLogin(ClientboundLoginPacket packet, CallbackInfo ci) { - if (!Minecraft.getInstance().isSameThread()) return; // will be called again on mc thread in a moment - try { - getMod().handleConnectedToServer(packet); - } catch (Throwable e) { - printErrorRateLimited(e); - } - } - @Inject(method = "handleRespawn", at = @At("RETURN")) protected void onHandleRespawn(ClientboundRespawnPacket packet, CallbackInfo ci) { if (!Minecraft.getInstance().isSameThread()) return; // will be called again on mc thread in a moment diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientHandler.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientHandler.java deleted file mode 100644 index d5a8cc76..00000000 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientHandler.java +++ /dev/null @@ -1,64 +0,0 @@ -package gjum.minecraft.mapsync.mod.net; - -import static gjum.minecraft.mapsync.mod.MapSyncMod.getMod; - -import gjum.minecraft.mapsync.mod.data.CatchupChunk; -import gjum.minecraft.mapsync.mod.net.packet.ChunkTilePacket; -import gjum.minecraft.mapsync.mod.net.packet.ClientboundChunkTimestampsResponsePacket; -import gjum.minecraft.mapsync.mod.net.packet.ClientboundEncryptionRequestPacket; -import gjum.minecraft.mapsync.mod.net.packet.ClientboundRegionTimestampsPacket; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelInboundHandlerAdapter; -import java.io.IOException; -import java.net.ConnectException; - -/** - * tightly coupled to {@link SyncClient} - */ -public class ClientHandler extends ChannelInboundHandlerAdapter { - private final SyncClient client; - - public ClientHandler(SyncClient client) { - this.client = client; - } - - @Override - public void channelRead(ChannelHandlerContext ctx, Object packet) { - try { - if (!client.isEncrypted()) { - if (packet instanceof ClientboundEncryptionRequestPacket pktEncryptionRequest) { - client.setUpEncryption(ctx, pktEncryptionRequest); - } else throw new Error("Expected encryption request, got " + packet); - } else if (packet instanceof ChunkTilePacket pktChunkTile) { - getMod().handleSharedChunk(pktChunkTile.chunkTile()); - } else if (packet instanceof ClientboundRegionTimestampsPacket pktRegionTimestamps) { - getMod().handleRegionTimestamps(pktRegionTimestamps, client); - } else if (packet instanceof ClientboundChunkTimestampsResponsePacket pktCatchup) { - for (CatchupChunk chunk : pktCatchup.chunks()) { - chunk.syncClient = this.client; - } - getMod().handleCatchupData((ClientboundChunkTimestampsResponsePacket) packet); - } else throw new Error("Expected packet, got " + packet); - } catch (Throwable err) { - err.printStackTrace(); - ctx.close(); - } - } - - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable err) throws Exception { - if (err instanceof IOException && "Connection reset by peer".equals(err.getMessage())) return; - if (err instanceof ConnectException && err.getMessage().startsWith("Connection refused: ")) return; - - SyncClient.logger.info("[map-sync] Network Error: " + err); - err.printStackTrace(); - ctx.close(); - super.exceptionCaught(ctx, err); - } - - @Override - public void channelInactive(ChannelHandlerContext ctx) throws Exception { - client.handleDisconnect(new RuntimeException("Channel inactive")); - super.channelInactive(ctx); - } -} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientboundPacketDecoder.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientboundPacketDecoder.java deleted file mode 100644 index bc33097b..00000000 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientboundPacketDecoder.java +++ /dev/null @@ -1,40 +0,0 @@ -package gjum.minecraft.mapsync.mod.net; - -import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; -import gjum.minecraft.mapsync.mod.net.packet.ChunkTilePacket; -import gjum.minecraft.mapsync.mod.net.packet.ClientboundChunkTimestampsResponsePacket; -import gjum.minecraft.mapsync.mod.net.packet.ClientboundEncryptionRequestPacket; -import gjum.minecraft.mapsync.mod.net.packet.ClientboundRegionTimestampsPacket; -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.ReplayingDecoder; -import java.util.List; -import org.jetbrains.annotations.Nullable; - -public class ClientboundPacketDecoder extends ReplayingDecoder { - public static @Nullable Packet constructServerPacket(int id, BufferReader reader) throws Exception { - if (id == ChunkTilePacket.PACKET_ID) return ChunkTilePacket.read(reader); - if (id == ClientboundEncryptionRequestPacket.PACKET_ID) return ClientboundEncryptionRequestPacket.read(reader); - if (id == ClientboundChunkTimestampsResponsePacket.PACKET_ID) return ClientboundChunkTimestampsResponsePacket.read(reader); - if (id == ClientboundRegionTimestampsPacket.PACKET_ID) return ClientboundRegionTimestampsPacket.read(reader); - return null; - } - - @Override - protected void decode(ChannelHandlerContext ctx, ByteBuf buf, List out) { - try { - byte id = buf.readByte(); - final Packet packet = constructServerPacket(id, new BufferReader(buf)); - if (packet == null) { - SyncClient.logger.error("[ServerPacketDecoder] " + - "Unknown server packet id " + id + " 0x" + Integer.toHexString(id)); - ctx.close(); - return; - } - out.add(packet); - } catch (Throwable err) { - err.printStackTrace(); - ctx.close(); - } - } -} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/CloseContext.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/CloseContext.java new file mode 100644 index 00000000..eb07f24f --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/CloseContext.java @@ -0,0 +1,24 @@ +package gjum.minecraft.mapsync.mod.net; + +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +public interface CloseContext { + // https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1 + public static final int CLOSE_1000_NORMAL_CLOSE = 1000; + public static final int CLOSE_1001_GOING_AWAY = 1001; + public static final int CUSTOM_CLOSE_4000_ERROR = 4000; + + public record Error( + @NotNull Throwable thrown + ) implements CloseContext { + public Error { + Objects.requireNonNull(thrown); + } + } + + public record Closed( + int closeCode, + String reason + ) implements CloseContext {} +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/Packet.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/Packet.java index 38129ee5..547a9ccf 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/Packet.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/Packet.java @@ -1,6 +1,16 @@ package gjum.minecraft.mapsync.mod.net; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; +import gjum.minecraft.mapsync.mod.net.packet.ChunkTilePacket; +import gjum.minecraft.mapsync.mod.net.packet.ClientboundChunkTimestampsResponsePacket; +import gjum.minecraft.mapsync.mod.net.packet.ClientboundIdentityRequestPacket; +import gjum.minecraft.mapsync.mod.net.packet.ClientboundRegionTimestampsPacket; +import gjum.minecraft.mapsync.mod.net.packet.ClientboundWelcomePacket; +import gjum.minecraft.mapsync.mod.net.packet.ServerboundCatchupRequestPacket; +import gjum.minecraft.mapsync.mod.net.packet.ServerboundChunkTimestampsRequestPacket; +import gjum.minecraft.mapsync.mod.net.packet.ServerboundHandshakePacket; +import gjum.minecraft.mapsync.mod.net.packet.ServerboundIdentityResponsePacket; import org.apache.commons.lang3.NotImplementedException; import org.jetbrains.annotations.NotNull; @@ -10,4 +20,33 @@ public default void write( ) throws Exception { throw new NotImplementedException(); } + + public static @NotNull Packet decodePacket( + final @NotNull BufferReader reader + ) throws Exception { + final int packetId = reader.readUnt8(); + return switch (packetId) { + case ChunkTilePacket.PACKET_ID -> ChunkTilePacket.read(reader); + case ClientboundIdentityRequestPacket.PACKET_ID -> ClientboundIdentityRequestPacket.read(reader); + case ClientboundWelcomePacket.PACKET_ID -> ClientboundWelcomePacket.read(reader); + case ClientboundChunkTimestampsResponsePacket.PACKET_ID -> ClientboundChunkTimestampsResponsePacket.read(reader); + case ClientboundRegionTimestampsPacket.PACKET_ID -> ClientboundRegionTimestampsPacket.read(reader); + default -> throw new UnexpectedPacketException((byte) packetId); + }; + } + + public static void encodePacket( + final @NotNull BufferWriter writer, + final @NotNull Packet packet + ) throws Exception { + writer.writeUnt8(switch (packet) { + case ChunkTilePacket $ -> ChunkTilePacket.PACKET_ID; + case ServerboundHandshakePacket $ -> ServerboundHandshakePacket.PACKET_ID; + case ServerboundIdentityResponsePacket $ -> ServerboundIdentityResponsePacket.PACKET_ID; + case ServerboundChunkTimestampsRequestPacket $ -> ServerboundChunkTimestampsRequestPacket.PACKET_ID; + case ServerboundCatchupRequestPacket $ -> ServerboundCatchupRequestPacket.PACKET_ID; + default -> throw new UnexpectedPacketException(packet); + }); + packet.write(writer); + } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ServerboundPacketEncoder.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ServerboundPacketEncoder.java deleted file mode 100644 index 7023c229..00000000 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ServerboundPacketEncoder.java +++ /dev/null @@ -1,33 +0,0 @@ -package gjum.minecraft.mapsync.mod.net; - -import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; -import gjum.minecraft.mapsync.mod.net.packet.ChunkTilePacket; -import gjum.minecraft.mapsync.mod.net.packet.ServerboundCatchupRequestPacket; -import gjum.minecraft.mapsync.mod.net.packet.ServerboundChunkTimestampsRequestPacket; -import gjum.minecraft.mapsync.mod.net.packet.ServerboundEncryptionResponsePacket; -import gjum.minecraft.mapsync.mod.net.packet.ServerboundHandshakePacket; -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.MessageToByteEncoder; - -public class ServerboundPacketEncoder extends MessageToByteEncoder { - public static int getClientPacketId(Packet packet) { - if (packet instanceof ChunkTilePacket) return ChunkTilePacket.PACKET_ID; - if (packet instanceof ServerboundHandshakePacket) return ServerboundHandshakePacket.PACKET_ID; - if (packet instanceof ServerboundEncryptionResponsePacket) return ServerboundEncryptionResponsePacket.PACKET_ID; - if (packet instanceof ServerboundCatchupRequestPacket) return ServerboundCatchupRequestPacket.PACKET_ID; - if (packet instanceof ServerboundChunkTimestampsRequestPacket) return ServerboundChunkTimestampsRequestPacket.PACKET_ID; - throw new IllegalArgumentException("Unknown client packet class " + packet); - } - - @Override - protected void encode(ChannelHandlerContext ctx, Packet packet, ByteBuf out) { - try { - out.writeByte(getClientPacketId(packet)); - packet.write(new BufferWriter(out)); - } catch (Throwable err) { - err.printStackTrace(); - ctx.close(); - } - } -} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClient.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClient.java index ff07e25f..445a68bb 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClient.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClient.java @@ -1,67 +1,43 @@ package gjum.minecraft.mapsync.mod.net; -import static gjum.minecraft.mapsync.mod.MapSyncMod.debugLog; -import static gjum.minecraft.mapsync.mod.MapSyncMod.getMod; - -import com.mojang.authlib.exceptions.AuthenticationException; import gjum.minecraft.mapsync.mod.MapSyncMod; import gjum.minecraft.mapsync.mod.data.ChunkTile; -import gjum.minecraft.mapsync.mod.net.encryption.EncryptionDecoder; -import gjum.minecraft.mapsync.mod.net.encryption.EncryptionEncoder; +import gjum.minecraft.mapsync.mod.deps.websockets.client.WebSocketClient; +import gjum.minecraft.mapsync.mod.deps.websockets.drafts.Draft; +import gjum.minecraft.mapsync.mod.deps.websockets.drafts.Draft_6455; +import gjum.minecraft.mapsync.mod.deps.websockets.exceptions.WebsocketNotConnectedException; +import gjum.minecraft.mapsync.mod.deps.websockets.handshake.ServerHandshake; +import gjum.minecraft.mapsync.mod.net.auth.AuthStateHolder; +import gjum.minecraft.mapsync.mod.net.auth.Welcomed; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import gjum.minecraft.mapsync.mod.net.packet.ChunkTilePacket; -import gjum.minecraft.mapsync.mod.net.packet.ClientboundEncryptionRequestPacket; -import gjum.minecraft.mapsync.mod.net.packet.ServerboundEncryptionResponsePacket; -import gjum.minecraft.mapsync.mod.net.packet.ServerboundHandshakePacket; -import gjum.minecraft.mapsync.mod.utils.Shortcuts; -import io.netty.bootstrap.Bootstrap; -import io.netty.channel.Channel; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelOption; -import io.netty.channel.nio.NioEventLoopGroup; -import io.netty.channel.socket.SocketChannel; -import io.netty.channel.socket.nio.NioSocketChannel; -import io.netty.handler.codec.LengthFieldBasedFrameDecoder; -import io.netty.handler.codec.LengthFieldPrepender; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.PublicKey; -import java.security.spec.MGF1ParameterSpec; -import java.util.ArrayList; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; import java.util.Arrays; import java.util.HashMap; -import java.util.HexFormat; -import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import javax.crypto.BadPaddingException; -import javax.crypto.Cipher; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; -import javax.crypto.SecretKey; -import javax.crypto.spec.OAEPParameterSpec; -import javax.crypto.spec.PSource; -import javax.crypto.spec.SecretKeySpec; -import net.minecraft.client.Minecraft; -import net.minecraft.client.User; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; import net.minecraft.world.level.ChunkPos; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -/** - * handles reconnection, authentication, encryption - */ +/// handles reconnection, authentication, encryption public class SyncClient { private final HashMap serverKnownChunkHashes = new HashMap<>(); public synchronized void sendChunkTile(ChunkTile chunkTile) { + if (this.state() != ConnectionState.WELCOMED) { + return; + } + var serverKnownHash = getServerKnownChunkHash(chunkTile.chunkPos()); if (Arrays.equals(chunkTile.dataHash(), serverKnownHash)) { - debugLog("server already has chunk (hash) " + chunkTile.chunkPos()); + MapSyncMod.debugLog("server already has chunk (hash) " + chunkTile.chunkPos()); return; // server already has this chunk } @@ -81,237 +57,182 @@ public synchronized void setServerKnownChunkHash(ChunkPos chunkPos, byte[] hash) // XXX end of hotfix - public static final Logger logger = LogManager.getLogger(SyncClient.class); - - public int retrySec = 5; - - public final @NotNull String address; - public final @NotNull String gameAddress; - - /** - * false = don't auto-reconnect but maintain connection as long as it stays up. - * can be set to true again later. - */ - public boolean autoReconnect = true; - /** - * false = don't reconnect under any circumstances, - * and disconnect when coming across this during a check - */ - public boolean isShutDown = false; - private boolean isEncrypted = false; - private @Nullable String lastError; - /** - * limited (on insert) to 199 entries - */ - private ArrayList queue = new ArrayList<>(); - private @Nullable Channel channel; - private static @Nullable NioEventLoopGroup workerGroup; - - public SyncClient(@NotNull String address, @NotNull String gameAddress) { - if (!address.contains(":")) address = address + ":12312"; - this.address = address; - this.gameAddress = gameAddress; - connect(); + public static final Logger LOGGER = LoggerFactory.getLogger(SyncClient.class); + private static final AtomicLong LAST_CLIENT_ID = new AtomicLong(0L); + private static final int MAX_PAYLOAD_LENGTH = (1 << Short.SIZE) - 1; + + public final long clientId; + public final String syncAddress; + public final String gameAddress; + + /// false = don't auto-reconnect but maintain connection as long as it stays up. + /// can be set to true again later. + public boolean shouldReconnect = true; + public volatile String lastError = null; + + public final AuthStateHolder authState = new AuthStateHolder(); + + public SyncClient( + final @NotNull String syncAddress, + final @NotNull String gameAddress + ) { + this.clientId = LAST_CLIENT_ID.incrementAndGet(); + this.syncAddress = Objects.requireNonNull(syncAddress); + this.gameAddress = Objects.requireNonNull(gameAddress); + this.websocket = new WsClient(URI.create(syncAddress)); } - private void connect() { - try { - if (isShutDown) return; - - if (workerGroup != null && !workerGroup.isShuttingDown()) { - // end any tasks of the old connection - workerGroup.shutdownGracefully(); - } - workerGroup = new NioEventLoopGroup(); - isEncrypted = false; - - var bootstrap = new Bootstrap(); - bootstrap.group(workerGroup); - bootstrap.channel(NioSocketChannel.class); - bootstrap.option(ChannelOption.SO_KEEPALIVE, true); - bootstrap.handler(new ChannelInitializer() { - public void initChannel(SocketChannel ch) { - ch.pipeline().addLast( - new LengthFieldPrepender(4), - new LengthFieldBasedFrameDecoder(1 << 20, 0, 4, 0, 4), - new ClientboundPacketDecoder(), - new ServerboundPacketEncoder(), - new ClientHandler(SyncClient.this)); - } - }); - - String[] hostPortArr = address.split(":"); - int port = Integer.parseInt(hostPortArr[1]); - - final var channelFuture = bootstrap.connect(hostPortArr[0], port); - channel = channelFuture.channel(); - channelFuture.addListener(future -> { - if (future.isSuccess()) { - logger.info("[map-sync] Connected to " + address); - channelFuture.channel().writeAndFlush(new ServerboundHandshakePacket( - getMod().getVersion(), - Minecraft.getInstance().getUser().getName(), - gameAddress, - getMod().getDimensionState().dimension.identifier().toString())); - } else { - handleDisconnect(future.cause()); - } - }); - } catch (Throwable e) { - e.printStackTrace(); - handleDisconnect(e); - } + public @NotNull String name() { + return "Client%d".formatted(this.clientId); } - void handleDisconnect(Throwable err) { - isEncrypted = false; - - if (Minecraft.getInstance().level == null) shutDown(); - - String errMsg = err.getMessage(); - if (errMsg == null) errMsg = err.toString(); - lastError = errMsg; - if (isShutDown) { - logger.warn("[map-sync] Got disconnected from '" + address + "'." + - " Won't retry (has shut down)"); - if (!errMsg.contains("Channel inactive")) err.printStackTrace(); - } else if (!autoReconnect) { - logger.warn("[map-sync] Got disconnected from '" + address + "'." + - " Won't retry (autoReconnect=false)"); - if (!errMsg.contains("Channel inactive")) err.printStackTrace(); - } else if (workerGroup == null) { - logger.warn("[map-sync] Got disconnected from '" + address + "'." + - " Won't retry (workerGroup=null)"); - err.printStackTrace(); - } else { - workerGroup.schedule(this::connect, retrySec, TimeUnit.SECONDS); - - if (!errMsg.startsWith("Connection refused: ")) { // reduce spam - logger.warn("[map-sync] Got disconnected from '" + address + "'." + - " Retrying in " + retrySec + " sec"); - if (!errMsg.contains("Channel inactive")) err.printStackTrace(); - } - } + public enum ConnectionState { DISCONNECTED, CONNECTED, WELCOMED } + public synchronized @NotNull ConnectionState state() { + return switch (this.websocket.getReadyState()) { + case NOT_YET_CONNECTED, CLOSING, CLOSED -> ConnectionState.DISCONNECTED; + case OPEN -> switch (this.authState.get()) { + case final Welcomed $ -> ConnectionState.WELCOMED; + case null, default -> ConnectionState.CONNECTED; + }; + }; } - public synchronized void handleEncryptionSuccess() { - if (channel == null) return; - - lastError = null; - isEncrypted = true; - getMod().handleSyncServerEncryptionSuccess(); - - for (Packet packet : queue) { - channel.write(packet); + @ApiStatus.Internal + public final WsClient websocket; + public final class WsClient extends WebSocketClient { + private WsClient( + final @NotNull URI syncAddress + ) { + super(Objects.requireNonNull(syncAddress), createDraft()); + this.setConnectionLostTimeout(30); + this.setAttachment(SyncClient.this); } - queue.clear(); - channel.flush(); - } - - public boolean isEncrypted() { - return isEncrypted; - } - public String getError() { - return lastError; - } - - /** - * Send if encrypted, or queue and send once encryption is set up. - */ - public void send(Packet packet) { - send(packet, true); - } + private static @NotNull Draft createDraft() { + return new Draft_6455(); + } - /** - * Send if encrypted, or queue and send once encryption is set up. - */ - public synchronized void send(Packet packet, boolean flush) { - try { - if (isEncrypted() && channel != null && channel.isActive()) { - if (flush) channel.writeAndFlush(packet); - else channel.write(packet); - } else { - queue.add(packet); - // don't let the queue occupy too much memory - if (queue.size() > 200) { - logger.warn("[map-sync] Dropping 100 oldest packets from queue"); - queue = queue.stream() - .skip(100) - .collect(Collectors.toCollection(ArrayList::new)); - } + @Override + public void onOpen( + final @NotNull ServerHandshake handshake + ) { + LOGGER.info("[{}] Connected to {}", SyncClient.this.name(), this.uri); + SyncClient.this.lastError = null; + try { + MapSyncMod.getMod().handleSyncConnection(SyncClient.this); + } + catch (final Exception e) { + this.onError(e); } - } catch (Throwable e) { - e.printStackTrace(); } - } - public synchronized void shutDown() { - isShutDown = true; - if (channel != null) { - channel.disconnect(); - channel.eventLoop().shutdownGracefully(); - channel = null; + @Override + public void onClose( + final int closeCode, + final String reason, + final boolean wasKicked + ) { + LOGGER.info("[{}] Closing... {}:{} (kicked: {})", SyncClient.this.name(), closeCode, reason, wasKicked); + if (wasKicked) { + SyncClient.this.shouldReconnect = false; + } + SyncClient.this.lastError = null; + MapSyncMod.getMod().handleSyncDisconnection(SyncClient.this, new CloseContext.Closed(closeCode, reason)); } - if (workerGroup != null && !workerGroup.isShuttingDown()) { - // this also stops any ongoing reconnect timeout - workerGroup.shutdownGracefully(); - workerGroup = null; + + @Override + public void onError( + final @NotNull Exception e + ) { + LOGGER.warn("[{}] Closing due to error...", SyncClient.this.name(), e); + SyncClient.this.shouldReconnect = false; + SyncClient.this.lastError = e.getMessage(); + this.close(CloseContext.CUSTOM_CLOSE_4000_ERROR); } - } - void setUpEncryption(ChannelHandlerContext ctx, ClientboundEncryptionRequestPacket packet) { - byte[] sharedSecret = new byte[16]; - ThreadLocalRandom.current().nextBytes(sharedSecret); + @Override + public void onMessage( + final @NotNull String payload + ) { + this.onError(new IOException("server sent a text message")); + } - if (!MapSyncMod.getMod().isDevMode()) { - // note that this is different from minecraft (we get no negative hashes) - final String shaHex; { - final MessageDigest md = Shortcuts.shaHash(); - md.update(sharedSecret); - md.update(packet.publicKey().getEncoded()); - shaHex = HexFormat.of().formatHex(md.digest()); + @Override + public void onMessage( + final @NotNull ByteBuffer payload + ) { + final int payloadLength = payload.remaining(); + if (payloadLength > MAX_PAYLOAD_LENGTH) { + this.onError(new IOException("server sent a payload too large! [%d > %d]".formatted( + payloadLength, + MAX_PAYLOAD_LENGTH + ))); + return; + } + final Packet packet; + try { + packet = Packet.decodePacket(new BufferReader(payload)); + } + catch (final Exception e) { + this.onError(e); + return; + } + MapSyncMod.debugLog("[%s] Received %s".formatted( + SyncClient.this.name(), + packet + )); + if (payload.hasRemaining()) { + this.onError(new IllegalStateException("packet didn't consume all payload bytes! [remaining: %d]".formatted( + payload.remaining() + ))); + return; } - - final User session = Minecraft.getInstance().getUser(); try { - Minecraft.getInstance().services().sessionService().joinServer( - session.getProfileId(), - session.getAccessToken(), - shaHex - ); - } catch (AuthenticationException e) { - SyncClient.logger.warn("Auth error (probably cracked): " + e.getMessage()); + MapSyncMod.getMod().handleSyncPacket(SyncClient.this, packet); + } + catch (final Exception e) { + this.onError(e); + return; } } + } + public synchronized void send( + final @NotNull Packet packet + ) { + Objects.requireNonNull(packet); + final byte[] packetBytes; try { - ctx.channel().writeAndFlush(new ServerboundEncryptionResponsePacket( - encrypt(packet.publicKey(), sharedSecret), - encrypt(packet.publicKey(), packet.verifyToken()))); - } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException | BadPaddingException | - IllegalBlockSizeException | InvalidAlgorithmParameterException e) { - shutDown(); - throw new RuntimeException(e); + final var out = new ByteArrayOutputStream(); + Packet.encodePacket(new BufferWriter(out), packet); + packetBytes = out.toByteArray(); } - - SecretKey secretKey = new SecretKeySpec(sharedSecret, "AES"); - ctx.pipeline() - .addFirst("encrypt", new EncryptionEncoder(secretKey)) - .addFirst("decrypt", new EncryptionDecoder(secretKey)); - - handleEncryptionSuccess(); - } - - private static byte[] encrypt(PublicKey key, byte[] data) throws NoSuchPaddingException, NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException, InvalidKeyException, InvalidAlgorithmParameterException { - Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); - // https://docs.openssl.org/master/man3/RSA_public_encrypt/#description - cipher.init(Cipher.ENCRYPT_MODE, key, new OAEPParameterSpec( - "SHA-256", - "MGF1", - new MGF1ParameterSpec("SHA-256"), - PSource.PSpecified.DEFAULT + catch (final Exception e) { + this.websocket.onError(e); + return; + } + if (packetBytes.length > MAX_PAYLOAD_LENGTH) { + this.websocket.onError(new IOException("encoded packet[%s] exceeds maximum payload length! [%d > %d]".formatted( + packet.getClass().getSimpleName(), + packetBytes.length, + MAX_PAYLOAD_LENGTH + ))); + return; + } + try { + this.websocket.send(packetBytes); + } + catch (final WebsocketNotConnectedException e) { + LOGGER.warn("[{}] Dropping packet[{}] as websocket is not connected!", this.name(), packet.getClass().getSimpleName(), e); + return; + } + catch (final Exception e) { + this.websocket.onError(e); + return; + } + MapSyncMod.debugLog("[%s] Sent %s".formatted( + this.name(), + packet )); - return cipher.doFinal(data); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClients.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClients.java new file mode 100644 index 00000000..79875395 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClients.java @@ -0,0 +1,163 @@ +package gjum.minecraft.mapsync.mod.net; + +import com.google.common.net.HostAndPort; +import gjum.minecraft.mapsync.mod.MapSyncMod; +import gjum.minecraft.mapsync.mod.config.ServerConfig; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.minecraft.client.multiplayer.ServerData; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class SyncClients implements Iterable { + private static final Logger LOGGER = LoggerFactory.getLogger(SyncClients.class); + + public final String gameAddress; + public final ServerConfig serverConfig; + private final Map clients = new ConcurrentHashMap<>(); + + public SyncClients( + final @NotNull String gameAddress, + final @NotNull ServerConfig serverConfig + ) { + this.gameAddress = Objects.requireNonNull(gameAddress); + this.serverConfig = Objects.requireNonNull(serverConfig); + } + + @Override + public @NonNull Iterator iterator() { + return this.clients.values().iterator(); + } + + public void setAll( + final @NotNull Set<@NotNull String> syncAddresses + ) { + final var syncAddressesCopy = Set.copyOf(syncAddresses); + this.clients.values().removeIf((syncClient) -> { + if (syncAddressesCopy.contains(syncClient.syncAddress)) { + MapSyncMod.debugLog("Closing sync client as %s is not contained within %s".formatted( + syncClient.syncAddress, + syncAddressesCopy + )); + return false; + } + syncClient.shouldReconnect = false; + syncClient.websocket.close(); + return true; + }); + for (final String syncAddress : syncAddressesCopy) { + this.clients.compute(syncAddress, this::computeClient); + } + } + + private @Nullable SyncClient computeClient( + final @NotNull String syncAddress, + SyncClient syncClient + ) { + if (syncClient != null) { + if (!Objects.equals(this.gameAddress, syncClient.gameAddress)) { + MapSyncMod.debugLog("Closing client %s as it doesn't match game address %s".formatted( + syncClient.name(), + this.gameAddress + )); + syncClient.websocket.close(); + return null; + } + switch (syncClient.websocket.getReadyState()) { + case NOT_YET_CONNECTED: + syncClient.websocket.connect(); + // fallthrough + case OPEN: + return syncClient; + case CLOSING: + case CLOSED: + if (!syncClient.shouldReconnect) { + return null; + } + syncClient.websocket.reconnect(); + return syncClient; + } + } + syncClient = new SyncClient(syncAddress, this.gameAddress); + syncClient.websocket.connect(); + return syncClient; + } + + public void closeAll( + final boolean preventReconnect + ) { + MapSyncMod.debugLog("Closing all sync clients (preventReconnect=%s)".formatted( + preventReconnect + )); + this.clients.values().removeIf((syncClient) -> { + if (preventReconnect) { + syncClient.shouldReconnect = false; + } + syncClient.websocket.close(); + return true; + }); + } + + // ============================================================ + // Event Hooks + // ============================================================ + + private static volatile SyncClients instance = null; + private static final VarHandle INSTANCE; static { + try { + INSTANCE = MethodHandles.lookup().findStaticVarHandle(SyncClients.class, "instance", SyncClients.class); + } + catch (final ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + public static Optional get() { + return Optional.ofNullable(instance); + } + + public static void initEvents() { + ClientPlayConnectionEvents.JOIN.register((gameConnection, sender, minecraft) -> { + if (INSTANCE.getAndSet((Object) null) instanceof final SyncClients syncClients) { + syncClients.closeAll(true); + } + if (!(gameConnection.getServerData() instanceof final ServerData serverData)) { + LOGGER.error("Connection doesn't have server data yet... backing out"); + return; + } + final String gameAddress; { + final String ip = serverData.ip; + try { + gameAddress = HostAndPort.fromString(ip).withDefaultPort(25565).toString(); + } + catch (final Exception e) { + LOGGER.error("Weirdly could not pass {} as a game address... backing out", ip, e); + return; + } + } + final ServerConfig serverConfig; + try { + serverConfig = ServerConfig.load(gameAddress); + } + catch (final Exception e) { + LOGGER.error("Could not load server config for {}... backing out", gameAddress, e); + return; + } + final SyncClients syncClients = instance = new SyncClients( + gameAddress, + serverConfig + ); + syncClients.setAll(new HashSet<>(serverConfig.getSyncServerAddresses())); + }); + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/UnexpectedPacketException.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/UnexpectedPacketException.java new file mode 100644 index 00000000..a90a4b34 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/UnexpectedPacketException.java @@ -0,0 +1,27 @@ +package gjum.minecraft.mapsync.mod.net; + +import org.jetbrains.annotations.NotNull; + +public final class UnexpectedPacketException extends Exception { + public UnexpectedPacketException( + final @NotNull Object packet + ) { + this("Unexpected packet [%s]!".formatted( + packet.getClass().getName() + )); + } + + public UnexpectedPacketException( + final byte packetId + ) { + this("Unexpected packet id [%d]!".formatted( + packetId + )); + } + + public UnexpectedPacketException( + final @NotNull String message + ) { + super(message); + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthProcess.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthProcess.java new file mode 100644 index 00000000..64e17182 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthProcess.java @@ -0,0 +1,78 @@ +package gjum.minecraft.mapsync.mod.net.auth; + +import gjum.minecraft.mapsync.mod.DimensionState; +import gjum.minecraft.mapsync.mod.net.SyncClient; +import gjum.minecraft.mapsync.mod.net.UnexpectedPacketException; +import gjum.minecraft.mapsync.mod.net.packet.ClientboundIdentityRequestPacket; +import gjum.minecraft.mapsync.mod.net.packet.ClientboundWelcomePacket; +import gjum.minecraft.mapsync.mod.net.packet.ServerboundHandshakePacket; +import gjum.minecraft.mapsync.mod.net.packet.ServerboundIdentityResponsePacket; +import gjum.minecraft.mapsync.mod.utils.MagicValues; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; +import net.minecraft.client.Minecraft; +import net.minecraft.client.User; +import org.jetbrains.annotations.NotNull; + +public final class AuthProcess { + private record AwaitingIdentityRequest() implements AuthState {} + public static void sendHandshake( + final @NotNull SyncClient client, + final DimensionState dimensionState + ) throws Exception { + if (dimensionState == null) { + throw new IllegalStateException("no dimension state"); + } + if (!client.authState.setIf(Objects::isNull, AwaitingIdentityRequest::new)) { + throw new IllegalStateException("already authenticated"); + } + client.send(new ServerboundHandshakePacket( + MagicValues.VERSION, + client.gameAddress, + dimensionState.dimension.identifier().toString() + )); + } + + private record AwaitingWelcome() implements AuthState {} + public static void handleIdentityRequest( + final @NotNull SyncClient client, + final @NotNull ClientboundIdentityRequestPacket packet + ) throws Exception { + if (!client.authState.setIf((state) -> state instanceof AwaitingIdentityRequest, AwaitingWelcome::new)) { + throw new UnexpectedPacketException(packet); + } + final User session = Minecraft.getInstance().getUser(); + final byte[] serverSalt = packet.serverSalt(); + final byte[] clientSalt = new byte[serverSalt.length]; + final boolean requiresAuth = clientSalt.length > 0; + if (requiresAuth) { + ThreadLocalRandom.current().nextBytes(clientSalt); + final String serverIdHex; { + final MessageDigest md = MessageDigest.getInstance("SHA-1"); + md.update(serverSalt); + md.update(clientSalt); + serverIdHex = HexFormat.of().formatHex(md.digest()); + } + Minecraft.getInstance().services().sessionService().joinServer( + session.getProfileId(), + session.getAccessToken(), + serverIdHex + ); + } + client.send(new ServerboundIdentityResponsePacket( + session.getName(), + clientSalt + )); + } + + public static void handleWelcome( + final @NotNull SyncClient client, + final @NotNull ClientboundWelcomePacket packet + ) throws Exception { + if (!client.authState.setIf((state) -> state instanceof AwaitingWelcome, Welcomed::new)) { + throw new UnexpectedPacketException(packet); + } + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthState.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthState.java new file mode 100644 index 00000000..c8cccd41 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthState.java @@ -0,0 +1,5 @@ +package gjum.minecraft.mapsync.mod.net.auth; + +public interface AuthState { + +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthStateHolder.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthStateHolder.java new file mode 100644 index 00000000..7445d3e5 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthStateHolder.java @@ -0,0 +1,46 @@ +package gjum.minecraft.mapsync.mod.net.auth; + +import java.util.function.Predicate; +import java.util.function.Supplier; +import org.jetbrains.annotations.NotNull; + +public final class AuthStateHolder { + public AuthState ref; + + public AuthState get() { + synchronized (this) { + return this.ref; + } + } + + public AuthState set( + final AuthState replacement + ) { + synchronized (this) { + final AuthState previous = this.ref; + this.ref = replacement; + return previous; + } + } + + public boolean setIf( + final @NotNull Predicate requiredState, + final @NotNull Supplier nextValue + ) { + synchronized (this) { + final AuthState value = this.ref; + if (!requiredState.test(value)) { + return false; + } + this.ref = nextValue.get(); + return true; + } + } + + public @NotNull Welcomed requireWelcomed() { + if (this.get() instanceof final Welcomed welcomed) { + return welcomed; + } + throw new IllegalStateException("not welcomed yet!"); + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/Welcomed.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/Welcomed.java new file mode 100644 index 00000000..83e8be33 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/Welcomed.java @@ -0,0 +1,5 @@ +package gjum.minecraft.mapsync.mod.net.auth; + +public record Welcomed() implements AuthState { + +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferReader.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferReader.java index e1fa68e4..b8c0b512 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferReader.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferReader.java @@ -2,7 +2,7 @@ import gjum.minecraft.mapsync.mod.utils.Assertions; import gjum.minecraft.mapsync.mod.utils.MagicValues; -import io.netty.buffer.ByteBuf; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.Objects; import net.minecraft.core.Registry; @@ -11,20 +11,24 @@ import org.jetbrains.annotations.NotNull; public final class BufferReader { - private final ByteBuf internal; + private final ByteBuffer internal; public BufferReader( - final @NotNull ByteBuf internal + final @NotNull ByteBuffer internal ) { this.internal = Objects.requireNonNull(internal); } + public boolean readBoolean() throws Exception { + return Assertions.assertMasked(MagicValues.UNT1_MASK, this.readUnt8()) == 1L; + } + public int readUnt5() throws Exception { return (int) Assertions.assertMasked(MagicValues.UNT5_MASK, this.readUnt8()); } public int readUnt8() throws Exception { - return this.internal.readUnsignedByte(); + return Byte.toUnsignedInt(this.internal.get()); } public int readUnt10() throws Exception { @@ -32,27 +36,27 @@ public int readUnt10() throws Exception { } public int readUnt16() throws Exception { - return this.internal.readUnsignedShort(); + return Short.toUnsignedInt(this.internal.getShort()); } public int readInt16() throws Exception { - return this.internal.readShort(); + return this.internal.getShort(); } public int readInt32() throws Exception { - return this.internal.readInt(); + return this.internal.getInt(); } public long readInt64() throws Exception { - return this.internal.readLong(); + return this.internal.getLong(); } public byte @NotNull [] readBytesOfLength( - final int size + final int length ) throws Exception { - final var bytes = new byte[size]; - if (size > 0) { - this.internal.readBytes(bytes); + final var bytes = new byte[length]; + if (length > 0) { + this.internal.get(bytes); } return bytes; } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferWriter.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferWriter.java index 9249b812..27ee135e 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferWriter.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferWriter.java @@ -2,47 +2,55 @@ import gjum.minecraft.mapsync.mod.utils.Assertions; import gjum.minecraft.mapsync.mod.utils.MagicValues; -import io.netty.buffer.ByteBuf; +import java.io.ByteArrayOutputStream; +import java.io.DataOutput; +import java.io.DataOutputStream; import java.nio.charset.StandardCharsets; import java.util.Objects; +import org.apache.commons.lang3.ArrayUtils; import org.jetbrains.annotations.NotNull; public final class BufferWriter { - private final ByteBuf internal; + private final DataOutput internal; public BufferWriter( - final @NotNull ByteBuf internal + final @NotNull ByteArrayOutputStream internal ) { - this.internal = Objects.requireNonNull(internal); + this.internal = new DataOutputStream(Objects.requireNonNull(internal)); } public void writeUnt5( final int value ) throws Exception { - this.internal.writeByte((int) Assertions.assertMasked(MagicValues.UNT5_MASK, value)); + Assertions.assertIntRange(MagicValues.UNT5_RANGE, value); + this.internal.writeByte(value); } public void writeUnt8( final int value ) throws Exception { - this.internal.writeByte((int) Assertions.assertMasked(MagicValues.UNT8_MASK, value)); + Assertions.assertIntRange(MagicValues.UNT8_RANGE, value); + this.internal.writeByte(value); } public void writeUnt10( final int value ) throws Exception { - this.internal.writeShort((int) Assertions.assertMasked(MagicValues.UNT10_MASK, value)); + Assertions.assertIntRange(MagicValues.UNT10_RANGE, value); + this.internal.writeShort(value); } public void writeUnt16( final int value ) throws Exception { - this.internal.writeShort((int) Assertions.assertMasked(MagicValues.UNT16_MASK, value)); + Assertions.assertIntRange(MagicValues.UNT16_RANGE, value); + this.internal.writeShort(value); } public void writeInt16( - final short value + final int value ) throws Exception { + Assertions.assertIntRange(MagicValues.INT16_RANGE, value); this.internal.writeShort(value); } @@ -59,18 +67,18 @@ public void writeInt64( } public void writeBytes( - final byte @NotNull [] array + final byte[] array ) throws Exception { - if (array.length > 0) { - this.internal.writeBytes(array); + if (ArrayUtils.getLength(array) > 0) { + this.internal.write(array); } } public void writeLengthPrefixedBytes( final @NotNull LengthPrefixSetter lengthSetter, - final byte @NotNull [] array + final byte[] array ) throws Exception { - lengthSetter.writeLength(this, array.length); + lengthSetter.writeLength(this, ArrayUtils.getLength(array)); this.writeBytes(array); } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/encryption/EncryptionDecoder.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/encryption/EncryptionDecoder.java deleted file mode 100644 index d757e36e..00000000 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/encryption/EncryptionDecoder.java +++ /dev/null @@ -1,30 +0,0 @@ -package gjum.minecraft.mapsync.mod.net.encryption; - -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.MessageToMessageDecoder; -import java.security.GeneralSecurityException; -import java.security.Key; -import java.util.List; -import javax.crypto.Cipher; -import javax.crypto.ShortBufferException; -import javax.crypto.spec.IvParameterSpec; - -public class EncryptionDecoder extends MessageToMessageDecoder { - private final EncryptionTranslator decryptionCodec; - - public EncryptionDecoder(Key key) { - try { - Cipher cipher = Cipher.getInstance("AES/CFB8/NoPadding"); - cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(key.getEncoded())); - decryptionCodec = new EncryptionTranslator(cipher); - } catch (GeneralSecurityException e) { - throw new RuntimeException(e); - } - } - - @Override - protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws ShortBufferException { - out.add(decryptionCodec.decipher(ctx, in)); - } -} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/encryption/EncryptionEncoder.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/encryption/EncryptionEncoder.java deleted file mode 100644 index b309cf1a..00000000 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/encryption/EncryptionEncoder.java +++ /dev/null @@ -1,29 +0,0 @@ -package gjum.minecraft.mapsync.mod.net.encryption; - -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.MessageToByteEncoder; -import java.security.GeneralSecurityException; -import java.security.Key; -import javax.crypto.Cipher; -import javax.crypto.ShortBufferException; -import javax.crypto.spec.IvParameterSpec; - -public class EncryptionEncoder extends MessageToByteEncoder { - private final EncryptionTranslator encryptionCodec; - - public EncryptionEncoder(Key key) { - try { - Cipher cipher = Cipher.getInstance("AES/CFB8/NoPadding"); - cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(key.getEncoded())); - encryptionCodec = new EncryptionTranslator(cipher); - } catch (GeneralSecurityException e) { - throw new RuntimeException(e); - } - } - - @Override - protected void encode(ChannelHandlerContext ctx, ByteBuf in, ByteBuf out) throws ShortBufferException { - encryptionCodec.encipher(in, out); - } -} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/encryption/EncryptionTranslator.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/encryption/EncryptionTranslator.java deleted file mode 100644 index 030523e7..00000000 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/encryption/EncryptionTranslator.java +++ /dev/null @@ -1,47 +0,0 @@ -package gjum.minecraft.mapsync.mod.net.encryption; - -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelHandlerContext; -import javax.crypto.Cipher; -import javax.crypto.ShortBufferException; - -public class EncryptionTranslator { - private final Cipher cipher; - private byte[] inputBuffer = new byte[0]; - private byte[] outputBuffer = new byte[0]; - - protected EncryptionTranslator(Cipher cipher) { - this.cipher = cipher; - } - - private byte[] bufToBytes(ByteBuf buf) { - int i = buf.readableBytes(); - - if (this.inputBuffer.length < i) { - this.inputBuffer = new byte[i]; - } - - buf.readBytes(this.inputBuffer, 0, i); - return this.inputBuffer; - } - - protected ByteBuf decipher(ChannelHandlerContext ctx, ByteBuf buffer) throws ShortBufferException { - int i = buffer.readableBytes(); - byte[] bytes = this.bufToBytes(buffer); - ByteBuf bytebuf = ctx.alloc().heapBuffer(this.cipher.getOutputSize(i)); - bytebuf.writerIndex(this.cipher.update(bytes, 0, i, bytebuf.array(), bytebuf.arrayOffset())); - return bytebuf; - } - - protected void encipher(ByteBuf in, ByteBuf out) throws ShortBufferException { - int i = in.readableBytes(); - byte[] bytes = this.bufToBytes(in); - int j = this.cipher.getOutputSize(i); - - if (this.outputBuffer.length < j) { - this.outputBuffer = new byte[j]; - } - - out.writeBytes(this.outputBuffer, 0, this.cipher.update(bytes, 0, i, this.outputBuffer)); - } -} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundEncryptionRequestPacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundEncryptionRequestPacket.java deleted file mode 100644 index 445082b1..00000000 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundEncryptionRequestPacket.java +++ /dev/null @@ -1,34 +0,0 @@ -package gjum.minecraft.mapsync.mod.net.packet; - -import gjum.minecraft.mapsync.mod.net.Packet; -import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; -import gjum.minecraft.mapsync.mod.utils.Assertions; -import java.security.KeyFactory; -import java.security.PublicKey; -import java.security.spec.X509EncodedKeySpec; -import org.jetbrains.annotations.NotNull; - -/// - Prev: [ServerboundHandshakePacket] -/// - Next: [ServerboundEncryptionResponsePacket] -public record ClientboundEncryptionRequestPacket( - @NotNull PublicKey publicKey, - byte @NotNull [] verifyToken -) implements Packet { - public static final int PACKET_ID = 2; - - public ClientboundEncryptionRequestPacket { - Assertions.assertNotNull(publicKey); - Assertions.assertNotNull(verifyToken); - } - - public static @NotNull Packet read( - final @NotNull BufferReader reader - ) throws Exception { - return new ClientboundEncryptionRequestPacket( - KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec( - reader.readBytesOfLength(reader.readUnt16()) - )), - reader.readBytesOfLength(reader.readUnt8()) - ); - } -} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundIdentityRequestPacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundIdentityRequestPacket.java new file mode 100644 index 00000000..7d3a76f3 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundIdentityRequestPacket.java @@ -0,0 +1,29 @@ +package gjum.minecraft.mapsync.mod.net.packet; + +import gjum.minecraft.mapsync.mod.net.Packet; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; +import gjum.minecraft.mapsync.mod.utils.Assertions; +import org.jetbrains.annotations.NotNull; + +/// This is sent by the server in response to a [ServerboundHandshakePacket]. The salt is the server's portion of the +/// sha-hex that'll be used during authentication. If the salt is empty, the server does not require authentication. +/// +/// - Prev: [ServerboundHandshakePacket] +/// - Next: [ServerboundIdentityResponsePacket] +public record ClientboundIdentityRequestPacket( + byte @NotNull [] serverSalt +) implements Packet { + public static final int PACKET_ID = 2; + + public ClientboundIdentityRequestPacket { + Assertions.assertNotNull(serverSalt); + } + + public static @NotNull Packet read( + final @NotNull BufferReader reader + ) throws Exception { + return new ClientboundIdentityRequestPacket( + reader.readBytesOfLength(reader.readUnt8()) + ); + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundWelcomePacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundWelcomePacket.java new file mode 100644 index 00000000..25ad26ae --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundWelcomePacket.java @@ -0,0 +1,20 @@ +package gjum.minecraft.mapsync.mod.net.packet; + +import gjum.minecraft.mapsync.mod.net.Packet; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; +import org.jetbrains.annotations.NotNull; + +/// This is sent by the server to indicate a successful connection: that the client can begin sending chunk data. The +/// server will immediately follow up this packet with a [ClientboundRegionTimestampsPacket]. +/// +/// - Prev: [ServerboundIdentityResponsePacket] +/// - Next: [ClientboundRegionTimestampsPacket] +public record ClientboundWelcomePacket() implements Packet { + public static final int PACKET_ID = 9; + + public static @NotNull Packet read( + final @NotNull BufferReader reader + ) throws Exception { + return new ClientboundWelcomePacket(); + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundCatchupRequestPacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundCatchupRequestPacket.java index 1159aaeb..e2c6e707 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundCatchupRequestPacket.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundCatchupRequestPacket.java @@ -27,7 +27,7 @@ public record ServerboundCatchupRequestPacket( public ServerboundCatchupRequestPacket { Assertions.assertNotNull(dimension); chunks = Assertions.assertNonNullMap(chunks); - Assertions.assertRange(LongRange.of(1, MagicValues.REGION_GRID), chunks.size()); + Assertions.assertLongRange(LongRange.of(1, MagicValues.REGION_GRID), chunks.size()); } @Override diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundEncryptionResponsePacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundEncryptionResponsePacket.java deleted file mode 100644 index 2d267c35..00000000 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundEncryptionResponsePacket.java +++ /dev/null @@ -1,34 +0,0 @@ -package gjum.minecraft.mapsync.mod.net.packet; - -import gjum.minecraft.mapsync.mod.net.Packet; -import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; -import gjum.minecraft.mapsync.mod.utils.Assertions; -import org.jetbrains.annotations.NotNull; - -/// This is sent to the server in response to a [ClientboundEncryptionRequestPacket], after which, if the connection -/// persists, you are considered authenticated with the server. You should then receive a [ClientboundRegionTimestampsPacket]. -/// -/// - Prev: [ClientboundEncryptionRequestPacket] -/// - Next: [ClientboundRegionTimestampsPacket] -/// -/// @param sharedSecret encrypted with server's public key -/// @param verifyToken encrypted with server's public key -public record ServerboundEncryptionResponsePacket( - byte @NotNull [] sharedSecret, - byte @NotNull [] verifyToken -) implements Packet { - public static final int PACKET_ID = 3; - - public ServerboundEncryptionResponsePacket { - Assertions.assertNotNull(sharedSecret); - Assertions.assertNotNull(verifyToken); - } - - @Override - public void write( - final @NotNull BufferWriter writer - ) throws Exception { - writer.writeLengthPrefixedBytes(BufferWriter::writeUnt8, this.sharedSecret()); - writer.writeLengthPrefixedBytes(BufferWriter::writeUnt8, this.verifyToken()); - } -} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundHandshakePacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundHandshakePacket.java index a4826db3..12c1ee4b 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundHandshakePacket.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundHandshakePacket.java @@ -6,12 +6,11 @@ import org.jetbrains.annotations.NotNull; /// The client should send this to the server *IMMEDIATELY* upon a successful connection. The server should respond -/// with a [ClientboundEncryptionRequestPacket]. +/// with a [ClientboundIdentityRequestPacket]. /// -/// - Next: [ClientboundEncryptionRequestPacket] +/// - Next: [ClientboundIdentityRequestPacket] public record ServerboundHandshakePacket( @NotNull String modVersion, - @NotNull String username, @NotNull String gameAddress, @NotNull String dimension ) implements Packet { @@ -19,7 +18,6 @@ public record ServerboundHandshakePacket( public ServerboundHandshakePacket { Assertions.assertNotNull(modVersion); - Assertions.assertNotNull(username); Assertions.assertNotNull(gameAddress); Assertions.assertNotNull(dimension); } @@ -29,7 +27,6 @@ public void write( final @NotNull BufferWriter writer ) throws Exception { writer.writeString(this.modVersion()); - writer.writeString(this.username()); writer.writeString(this.gameAddress()); writer.writeString(this.dimension()); } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundIdentityResponsePacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundIdentityResponsePacket.java new file mode 100644 index 00000000..fdce9cf5 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundIdentityResponsePacket.java @@ -0,0 +1,31 @@ +package gjum.minecraft.mapsync.mod.net.packet; + +import gjum.minecraft.mapsync.mod.net.Packet; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; +import gjum.minecraft.mapsync.mod.utils.Assertions; +import org.jetbrains.annotations.NotNull; + +/// This is sent to the server in response to a [ClientboundIdentityRequestPacket]. The salt is the client's portion of +/// the sha-hex that'll be used during authentication. The salt MUST be empty if the server's salt was empty. +/// +/// - Prev: [ClientboundIdentityRequestPacket] +/// - Next: [ClientboundWelcomePacket] +public record ServerboundIdentityResponsePacket( + @NotNull String claimedUsername, + byte @NotNull [] clientSalt +) implements Packet { + public static final int PACKET_ID = 3; + + public ServerboundIdentityResponsePacket { + Assertions.assertNotNull(claimedUsername); + Assertions.assertNotNull(clientSalt); + } + + @Override + public void write( + final @NotNull BufferWriter writer + ) throws Exception { + writer.writeString(this.claimedUsername()); + writer.writeLengthPrefixedBytes(BufferWriter::writeUnt8, this.clientSalt()); + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/Assertions.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/Assertions.java index ed348d80..bb0e0275 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/Assertions.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/Assertions.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import org.apache.commons.lang3.IntegerRange; import org.apache.commons.lang3.LongRange; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Unmodifiable; @@ -70,7 +71,20 @@ public static long assertMasked( } } - public static void assertRange( + public static void assertIntRange( + final @NotNull IntegerRange range, + final int value + ) { + if (!range.contains(value)) { + throw new AssertionException("%d is not within range %d..%d".formatted( + value, + range.getMinimum(), + range.getMaximum() + )); + } + } + + public static void assertLongRange( final @NotNull LongRange range, final long value ) { @@ -82,6 +96,16 @@ public static void assertRange( )); } } + + public static void assertPositive( + final long value + ) { + if (value < 1) { + throw new AssertionException("%d is not positive!".formatted( + value + )); + } + } } final class AssertionException extends RuntimeException { diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/MagicValues.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/MagicValues.java index 4a5e2078..c112466b 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/MagicValues.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/MagicValues.java @@ -1,6 +1,25 @@ package gjum.minecraft.mapsync.mod.utils; +import gjum.minecraft.mapsync.mod.MapSyncMod; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.apache.commons.lang3.IntegerRange; + public final class MagicValues { + public static final String VERSION; static { + final InputStream in = MapSyncMod.class.getResourceAsStream("/mapsync.version.const"); + if (in == null) { + throw new ExceptionInInitializerError(new NullPointerException("'mapsync.version.const' const is missing!")); + } + try (in) { + VERSION = new String(in.readAllBytes(), StandardCharsets.UTF_8).trim(); + } + catch (final IOException e) { + throw new ExceptionInInitializerError(e); + } + } + public static final int REGION_AXIS = 32; public static final int REGION_GRID = REGION_AXIS * REGION_AXIS; @@ -8,8 +27,18 @@ public final class MagicValues { // https://en.wikipedia.org/wiki/SHA-1 public static final int SHA1_HASH_LENGTH = 20; + public static final int UNT1_MASK = 0x01; public static final int UNT5_MASK = 0x1F; public static final int UNT8_MASK = 0xFF; public static final int UNT10_MASK = 0x03_FF; public static final int UNT16_MASK = 0xFF_FF; + + public static final IntegerRange UNT5_RANGE = IntegerRange.of(0, UNT5_MASK); + public static final IntegerRange UNT8_RANGE = IntegerRange.of(0, UNT8_MASK); + public static final IntegerRange UNT10_RANGE = IntegerRange.of(0, UNT10_MASK); + public static final IntegerRange UNT16_RANGE = IntegerRange.of(0, UNT16_MASK); + public static final IntegerRange INT16_RANGE = IntegerRange.of(Short.MIN_VALUE, Short.MAX_VALUE); + + // https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1 + public static final int CLOSE_CODE_KICKED = 4001; } diff --git a/mapsync-mod/src/main/resources/default-config.json b/mapsync-mod/src/main/resources/default-config.json index c86f8511..054e88e0 100644 --- a/mapsync-mod/src/main/resources/default-config.json +++ b/mapsync-mod/src/main/resources/default-config.json @@ -2,7 +2,7 @@ "servers": { "localhost:25565": { "syncServerAddresses": [ - "localhost:12312" + "ws://localhost:12312" ] } } diff --git a/mapsync-server/.prettierignore b/mapsync-server/.prettierignore new file mode 100644 index 00000000..fba82fa7 --- /dev/null +++ b/mapsync-server/.prettierignore @@ -0,0 +1,3 @@ +README.md +pnpm-lock.yaml +*.yaml diff --git a/mapsync-server/package.json b/mapsync-server/package.json index 2f605270..8228fb01 100644 --- a/mapsync-server/package.json +++ b/mapsync-server/package.json @@ -1,31 +1,37 @@ { "name": "mapsync-server", "version": "SNAPSHOT", + "type": "module", "private": true, "author": "Gjum", "license": "GPL-3.0-only", "scripts": { "build": "tsc", - "typecheck": "tsc --noEmit", + "check:types": "tsc --noEmit --checkJs", + "check:style": "prettier --check .", "format": "prettier -w .", "test": "true", "start": "node -r source-map-support/register dist/main.js", - "start:dev": "tsc && npm run start --inspect" + "start:dev": "node --inspect -r source-map-support/register dist/main.js" }, "dependencies": { - "async-mutex": "^0.4.0", - "better-sqlite3": "^12.8.0", - "kysely": "^0.28.14", - "source-map-support": "^0.5.21", - "zod": "^3.21.4", - "zod-validation-error": "^1.3.1" + "async-mutex": "0.5.0", + "better-sqlite3": "12.8.0", + "kysely": "0.28.14", + "source-map-support": "0.5.21", + "ws": "8.20.0", + "zod": "3.25.76", + "zod-validation-error": "1.5.0" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.4", - "@types/node": "^24.12.0", - "dotenv": "^16.0.1", - "prettier": "^3.0.1", - "typescript": "^5.1.6" + "@types/better-sqlite3": "7.6.13", + "@types/node": "24.12.0", + "@types/ws": "8.18.1", + "prettier": "3.8.1", + "typescript": "6.0.2" + }, + "optionalDependencies": { + "bufferutil": "4.1.0" }, "engines": { "node": "24.x" diff --git a/mapsync-server/pnpm-lock.yaml b/mapsync-server/pnpm-lock.yaml index 003a70cf..e6b649bf 100644 --- a/mapsync-server/pnpm-lock.yaml +++ b/mapsync-server/pnpm-lock.yaml @@ -9,39 +9,46 @@ importers: .: dependencies: async-mutex: - specifier: ^0.4.0 - version: 0.4.1 + specifier: 0.5.0 + version: 0.5.0 better-sqlite3: - specifier: ^12.8.0 + specifier: 12.8.0 version: 12.8.0 kysely: - specifier: ^0.28.14 + specifier: 0.28.14 version: 0.28.14 source-map-support: - specifier: ^0.5.21 + specifier: 0.5.21 version: 0.5.21 + ws: + specifier: 8.20.0 + version: 8.20.0(bufferutil@4.1.0) zod: - specifier: ^3.21.4 + specifier: 3.25.76 version: 3.25.76 zod-validation-error: - specifier: ^1.3.1 + specifier: 1.5.0 version: 1.5.0(zod@3.25.76) devDependencies: '@types/better-sqlite3': - specifier: ^7.6.4 + specifier: 7.6.13 version: 7.6.13 '@types/node': - specifier: ^24.12.0 + specifier: 24.12.0 version: 24.12.0 - dotenv: - specifier: ^16.0.1 - version: 16.6.1 + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 prettier: - specifier: ^3.0.1 + specifier: 3.8.1 version: 3.8.1 typescript: - specifier: ^5.1.6 - version: 5.9.3 + specifier: 6.0.2 + version: 6.0.2 + optionalDependencies: + bufferutil: + specifier: 4.1.0 + version: 4.1.0 packages: @@ -51,8 +58,11 @@ packages: '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} - async-mutex@0.4.1: - resolution: {integrity: sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -73,6 +83,10 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} + engines: {node: '>=6.14.2'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -88,10 +102,6 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -138,6 +148,10 @@ packages: resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} engines: {node: '>=10'} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -204,8 +218,8 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} engines: {node: '>=14.17'} hasBin: true @@ -218,6 +232,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + zod-validation-error@1.5.0: resolution: {integrity: sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==} engines: {node: '>=16.0.0'} @@ -237,7 +263,11 @@ snapshots: dependencies: undici-types: 7.16.0 - async-mutex@0.4.1: + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.12.0 + + async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -265,6 +295,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bufferutil@4.1.0: + dependencies: + node-gyp-build: 4.8.4 + optional: true + chownr@1.1.4: {} decompress-response@6.0.0: @@ -275,8 +310,6 @@ snapshots: detect-libc@2.1.2: {} - dotenv@16.6.1: {} - end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -309,6 +342,9 @@ snapshots: dependencies: semver: 7.7.4 + node-gyp-build@4.8.4: + optional: true + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -394,7 +430,7 @@ snapshots: dependencies: safe-buffer: 5.2.1 - typescript@5.9.3: {} + typescript@6.0.2: {} undici-types@7.16.0: {} @@ -402,6 +438,10 @@ snapshots: wrappy@1.0.2: {} + ws@8.20.0(bufferutil@4.1.0): + optionalDependencies: + bufferutil: 4.1.0 + zod-validation-error@1.5.0(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/mapsync-server/pnpm-workspace.yaml b/mapsync-server/pnpm-workspace.yaml index 7ef759cf..fc26b9ca 100644 --- a/mapsync-server/pnpm-workspace.yaml +++ b/mapsync-server/pnpm-workspace.yaml @@ -1,8 +1,13 @@ allowBuilds: - better-sqlite3: true + better-sqlite3: true + bufferutil: true blockExoticSubdeps: true # prevent packages newer than 1 day old from being installed minimumReleaseAge: 1440 # https://pnpm.io/settings#trustpolicy +onlyBuiltDependencies: + - better-sqlite3 + - bufferutil + trustPolicy: no-downgrade diff --git a/mapsync-server/src/auth.ts b/mapsync-server/src/auth.ts new file mode 100644 index 00000000..9ddca029 --- /dev/null +++ b/mapsync-server/src/auth.ts @@ -0,0 +1,25 @@ +export abstract class AuthState { + protected constructor(public readonly logName: string | null) {} +} + +export class AwaitingHandshake extends AuthState { + public constructor() { + super(null); + } +} + +export class AwaitingIdentityResponse extends AuthState { + public constructor(public readonly serverSalt: Buffer) { + super(null); + } +} + +export class Welcomed extends AuthState { + public constructor( + public readonly name: string, + public readonly uuid: string, + public readonly authed: boolean, + ) { + super(name + (authed ? "" : "?")); + } +} diff --git a/mapsync-server/src/protocol/buffers.ts b/mapsync-server/src/buffers.ts similarity index 96% rename from mapsync-server/src/protocol/buffers.ts rename to mapsync-server/src/buffers.ts index 531b3405..c4cc88cb 100644 --- a/mapsync-server/src/protocol/buffers.ts +++ b/mapsync-server/src/buffers.ts @@ -7,16 +7,16 @@ import { asUnt31, asUnt5, asUnt8, - int16, - int32, - int64, - numeric, - unt10, - unt16, - unt31, - unt5, - unt8, -} from "../deps/ints"; + type int16, + type int32, + type int64, + type numeric, + type unt10, + type unt16, + type unt31, + type unt5, + type unt8, +} from "./deps/ints.ts"; /** Each read advances the internal offset into the buffer. */ export class BufferReader { diff --git a/mapsync-server/src/cli.ts b/mapsync-server/src/cli.ts index 085bd2f3..8a893b50 100644 --- a/mapsync-server/src/cli.ts +++ b/mapsync-server/src/cli.ts @@ -1,11 +1,12 @@ import lib_readline from "readline"; import lib_stream from "stream"; -import * as metadata from "./metadata"; -import { TcpServer } from "./server"; +import * as metadata from "./metadata.ts"; +import { WSServer } from "./server.ts"; -import * as database from "./database"; -import { ClientboundRegionTimestampsPacket } from "./protocol"; +import * as database from "./database.ts"; +import { ClientboundRegionTimestampsPacket } from "./packets.ts"; +import { Welcomed } from "./auth.ts"; //idk where these come from lol interface TerminalExtras { @@ -18,9 +19,9 @@ const term = lib_readline.createInterface({ output: process.stdout, }) as TermType; -let tcpServer: TcpServer; -export function setServer(server: TcpServer): void { - tcpServer = server; +let wsServer: WSServer; +export function setServer(server: WSServer): void { + wsServer = server; } if (!("MAPSYNC_DUMB_TERM" in process.env)) { @@ -129,14 +130,15 @@ async function handle_input(input: string): Promise { await metadata.saveWhitelist(); } else if (command === "list") { let i = 1; - for (const key in tcpServer.clients) { - let client = tcpServer.clients[key]; - console.log(`${i++}. ${client.mcName}: ${client.uuid}`); + for (const client of wsServer.clients.values()) { + console.log( + `${i++}. ${client.name}: ${client.auth instanceof Welcomed ? client.auth.uuid : "?"}`, + ); } } else if (command === "send") { const target = extras.trim(); // IGN or UUID - const client = Object.values(tcpServer.clients).find( + const client = Object.values(wsServer.clients).find( (c) => c.mcName === target || c.uuid === target, ); if (!client) { @@ -166,12 +168,15 @@ async function handle_input(input: string): Promise { } else if (command === "kick") { const target = extras.trim(); // IGN or UUID - const client = Object.values(tcpServer.clients).find( + const client = Object.values(wsServer.clients).find( (c) => c.mcName === target || c.uuid === target, ); client?.kick("Kicked by administrator"); } else { - throw new Error(`Unknown command "${command}"`); + console.log( + `Unknown command "${command}". Type "help" for a list of commands.`, + ); + // throw new Error(`Unknown command "${command}"`); } } diff --git a/mapsync-server/src/constants.ts b/mapsync-server/src/constants.ts index 817eeb52..852aae28 100644 --- a/mapsync-server/src/constants.ts +++ b/mapsync-server/src/constants.ts @@ -1,4 +1,4 @@ -export const SUPPORTED_VERSIONS = new Set(["2.2.0-SNAPSHOT-1.21.11+fabric"]); +export const SUPPORTED_VERSIONS = new Set(["2.2.0-SNAPSHOT-1.21.11"]); // SHA1 produces 160-bit (20-byte) hashes // https://en.wikipedia.org/wiki/SHA-1 diff --git a/mapsync-server/src/database.ts b/mapsync-server/src/database.ts index dc74d3bb..217a72b2 100644 --- a/mapsync-server/src/database.ts +++ b/mapsync-server/src/database.ts @@ -1,17 +1,17 @@ import * as kysely from "kysely"; import sqlite from "better-sqlite3"; -import { DATA_FOLDER } from "./metadata"; +import { DATA_FOLDER } from "./metadata.ts"; import { asInt16, asInt32, asInt64, asUnt16, - int16, - int32, - int64, - unt16, -} from "./deps/ints"; -import { CatchupChunk, CatchupRegion, StoredChunk } from "./model"; + type int16, + type int32, + type int64, + type unt16, +} from "./deps/ints.ts"; +import type { CatchupChunk, CatchupRegion, StoredChunk } from "./model.ts"; let database: kysely.Kysely | null = null; diff --git a/mapsync-server/src/main.ts b/mapsync-server/src/main.ts index 051b96b2..ecd6ed71 100644 --- a/mapsync-server/src/main.ts +++ b/mapsync-server/src/main.ts @@ -1,20 +1,31 @@ +import node_crypto from "crypto"; import node_utils from "node:util"; -import "./cli"; -import { setServer } from "./cli"; -import * as database from "./database"; -import * as metadata from "./metadata"; -import { TcpClient, TcpServer } from "./server"; +import { setServer } from "./cli.ts"; +import * as database from "./database.ts"; +import * as metadata from "./metadata.ts"; +import { WSServer, WSClient } from "./server.ts"; import { ChunkTilePacket, ClientboundChunkTimestampsResponsePacket, ClientboundRegionTimestampsPacket, ServerboundCatchupRequestPacket, ServerboundChunkTimestampsRequestPacket, - ServerboundPacket, -} from "./protocol"; + ServerboundIdentityResponsePacket, + ServerboundHandshakePacket, + type ServerboundPacket, + ClientboundWelcomePacket, + UnexpectedPacketError, + ClientboundIdentityRequestPacket, +} from "./packets.ts"; +import { + AwaitingHandshake, + AwaitingIdentityResponse, + Welcomed, +} from "./auth.ts"; +import { SUPPORTED_VERSIONS } from "./constants.ts"; let config: metadata.Config = null!; -let main: Main = null!; +let main: ProtocolHandler = null!; Promise.resolve().then(async () => { await database.setup(); @@ -25,58 +36,37 @@ Promise.resolve().then(async () => { await metadata.loadWhitelist(); await metadata.loadUuidCache(); - main = new Main(); + main = new ProtocolHandler(config); setServer(main.server); }); -type ProtocolClient = TcpClient; // TODO cleanup - -export class Main { - server: TcpServer = new TcpServer(this); - - //Cannot be async, as it's caled from a synchronous constructor - handleClientConnected(client: ProtocolClient) {} - - async handleClientAuthenticated(client: ProtocolClient) { - if (!client.uuid) throw new Error("Client not authenticated"); +export class ProtocolHandler { + public readonly server: WSServer; - metadata.cachePlayerUuid(client.mcName!, client.uuid!); - await metadata.saveUuidCache(); + public constructor(private readonly config: metadata.Config) { + this.server = new WSServer(config, this); + } - if (config.whitelist) { - if (!metadata.whitelist.has(client.uuid)) { - client.log( - `Rejected unwhitelisted user ${client.mcName} (${client.uuid})`, - ); - client.kick(`Not whitelisted`); - return; - } + public async handleClientConnected(client: WSClient) { + if (client.auth !== null) { + throw new Error("Client has already started authing!"); } - - // TODO check version, mc server, user access - - const regions = await database.getRegionTimestamps(client.dimension!); - await Promise.allSettled( - regions.map((region) => - client.send( - new ClientboundRegionTimestampsPacket( - client.dimension!, - region.regionX, - region.regionZ, - region.timestamp, - ), - ), - ), - ); + client.auth = new AwaitingHandshake(); } - handleClientDisconnected(client: ProtocolClient) {} + public async handleClientDisconnected(client: WSClient) { + client.auth = null; + } - async handleClientPacketReceived( - client: ProtocolClient, + public async handleClientPacketReceived( + client: WSClient, pkt: ServerboundPacket, ) { switch (true) { + case pkt instanceof ServerboundHandshakePacket: + return this.handleHandshake(client, pkt); + case pkt instanceof ServerboundIdentityResponsePacket: + return this.handleIdentityResponse(client, pkt); case pkt instanceof ServerboundChunkTimestampsRequestPacket: return this.handleChunkTimestampsRequest(client, pkt); case pkt instanceof ServerboundCatchupRequestPacket: @@ -90,9 +80,118 @@ export class Main { } } - async handleChunkTilePacket(client: ProtocolClient, pkt: ChunkTilePacket) { - if (!client.uuid) - throw new Error(`${client.name} is not authenticated`); + private async handleHandshake( + client: WSClient, + packet: ServerboundHandshakePacket, + ) { + if (!(client.auth instanceof AwaitingHandshake)) { + throw new UnexpectedPacketError(packet); + } + if (!SUPPORTED_VERSIONS.has(packet.modVersion)) { + throw new Error( + `Connected with unsupported version [${packet.modVersion}]`, + ); + } + // TODO: Check whether the game address is supported + client.gameAddress = packet.gameAddress; + // TODO: Make this its own packet + client.dimension = packet.dimension; + const serverSalt: Buffer = this.config.auth + ? node_crypto.randomBytes(32) + : Buffer.allocUnsafe(0); + client.auth = new AwaitingIdentityResponse(serverSalt); + client.send(new ClientboundIdentityRequestPacket(serverSalt)); + } + + private async handleIdentityResponse( + client: WSClient, + packet: ServerboundIdentityResponsePacket, + ) { + if (!(client.auth instanceof AwaitingIdentityResponse)) { + throw new UnexpectedPacketError(packet); + } + if (this.config.auth) { + if (packet.clientSalt.length === 0) { + throw new Error( + "Client sent an empty clientSalt despite being required to auth!", + ); + } + const serverIdHex = node_crypto + .createHash("sha1") + .update(client.auth.serverSalt) + .update(packet.clientSalt) + .digest() + .toString("hex"); + client.auth = await fetch( + `https://sessionserver.mojang.com/session/minecraft/hasJoined?username=${packet.claimedUsername}&serverId=${serverIdHex}`, + ) + .then(async (res) => { + if (res.status === 204) { + throw new Error( + `Failed to authenticate as [${packet.claimedUsername}]!`, + ); + } + return res.json(); + }) + .then((json: any) => ({ + name: json.name as string, + uuid: (json.id as string).replace( + /^(........)-?(....)-?(....)-?(....)-?(............)$/, + "$1-$2-$3-$4-$5", + ), + })) + .then((res) => new Welcomed(res.name, res.uuid, true)); + } else { + if (packet.clientSalt.length !== 0) { + throw new Error( + "Client sent a non-empty clientSalt despite no auth!", + ); + } + client.auth = new Welcomed( + packet.claimedUsername, + `AUTH-DISABLED-${packet.claimedUsername}`, + false, + ); + } + await this.handleClientAuthenticated(client); + } + + private async handleClientAuthenticated(client: WSClient) { + const welcome = client.requireWelcomed(); + + if (welcome.authed) { + metadata.cachePlayerUuid(welcome.name, welcome.uuid); + await metadata.saveUuidCache(); + } + + if (config.whitelist && !metadata.whitelist.has(welcome.uuid)) { + client.kick(`Not whitelisted`); + return; + } + + // TODO check version, mc server, user access + + client.send(new ClientboundWelcomePacket()); + + for (const region of await database.getRegionTimestamps( + client.dimension!, + )) { + client.send( + new ClientboundRegionTimestampsPacket( + client.dimension!, + region.regionX, + region.regionZ, + region.timestamp, + ), + ); + } + } + + private async handleChunkTilePacket( + client: WSClient, + pkt: ChunkTilePacket, + ) { + const welcome = client.requireWelcomed(); // TODO ignore if same chunk hash exists in db @@ -101,7 +200,7 @@ export class Main { pkt.dimension, pkt.chunkX, pkt.chunkZ, - client.uuid, + welcome.uuid, pkt.timestamp, pkt.dataVersion, pkt.dataHash, @@ -110,18 +209,21 @@ export class Main { .catch(console.error); // TODO small timeout, then skip if other client already has it - for (const otherClient of Object.values(this.server.clients)) { - if (client === otherClient) continue; + for (const otherClient of this.server.clients.values()) { + if ( + client === otherClient || + !(otherClient.auth instanceof Welcomed) + ) + continue; otherClient.send(pkt); } } - async handleCatchupRequest( - client: ProtocolClient, + private async handleCatchupRequest( + client: WSClient, pkt: ServerboundCatchupRequestPacket, ) { - if (!client.uuid) - throw new Error(`${client.name} is not authenticated`); + const welcome = client.requireWelcomed(); for (const req of pkt.chunks) { let chunk = await database.getChunkData( @@ -154,12 +256,11 @@ export class Main { } } - async handleChunkTimestampsRequest( - client: ProtocolClient, + private async handleChunkTimestampsRequest( + client: WSClient, pkt: ServerboundChunkTimestampsRequestPacket, ) { - if (!client.uuid) - throw new Error(`${client.name} is not authenticated`); + const welcome = client.requireWelcomed(); const chunks = await database.getChunkTimestamps( pkt.dimension, diff --git a/mapsync-server/src/metadata.ts b/mapsync-server/src/metadata.ts index b4b4aa9c..5dc5ea44 100644 --- a/mapsync-server/src/metadata.ts +++ b/mapsync-server/src/metadata.ts @@ -1,8 +1,8 @@ import node_fs from "node:fs"; import node_path from "node:path"; import { Mutex } from "async-mutex"; -import * as errors from "./deps/errors"; -import * as json from "./deps/json"; +import * as errors from "./deps/errors.ts"; +import * as json from "./deps/json.ts"; import * as z from "zod"; import { fromZodError } from "zod-validation-error"; diff --git a/mapsync-server/src/model.ts b/mapsync-server/src/model.ts index 38390c03..18a24062 100644 --- a/mapsync-server/src/model.ts +++ b/mapsync-server/src/model.ts @@ -1,4 +1,4 @@ -import { int16, int32, int64, unt16 } from "./deps/ints"; +import type { int16, int32, int64, unt16 } from "./deps/ints.ts"; export interface CatchupRegion { readonly regionX: int16; diff --git a/mapsync-server/src/protocol/index.ts b/mapsync-server/src/packets.ts similarity index 82% rename from mapsync-server/src/protocol/index.ts rename to mapsync-server/src/packets.ts index 577d242a..c1c65cc2 100644 --- a/mapsync-server/src/protocol/index.ts +++ b/mapsync-server/src/packets.ts @@ -1,29 +1,39 @@ -import { BufferReader, BufferWriter } from "./buffers"; -import type { CatchupChunk } from "../model"; -import { SHA1_HASH_LENGTH } from "../constants"; +import node_utils from "node:util"; +import { BufferReader, BufferWriter } from "./buffers.ts"; +import type { CatchupChunk } from "./model.ts"; +import { SHA1_HASH_LENGTH } from "./constants.ts"; import { asInt32, asUnt8, - int16, - int32, - int64, - unt16, - unt8, -} from "../deps/ints"; + type int16, + type int32, + type int64, + type unt16, + type unt8, +} from "./deps/ints.ts"; export type ServerboundPacket = | ServerboundHandshakePacket - | ServerboundEncryptionResponsePacket + | ServerboundIdentityResponsePacket | ServerboundChunkTimestampsRequestPacket | ServerboundCatchupRequestPacket | ChunkTilePacket; export type ClientboundPacket = - | ClientboundEncryptionRequestPacket + | ClientboundIdentityRequestPacket + | ClientboundWelcomePacket | ClientboundRegionTimestampsPacket | ClientboundChunkTimestampsResponsePacket | ChunkTilePacket; +export class UnexpectedPacketError extends Error { + public constructor(packet: Packet) { + super( + `Unexpected packet [${packet.name}]: ${node_utils.inspect(packet)}`, + ); + } +} + abstract class Packet { protected constructor(public readonly packetId: unt8) {} @@ -37,7 +47,6 @@ export class ServerboundHandshakePacket extends Packet { public constructor( public readonly modVersion: string, - public readonly mojangName: string, public readonly gameAddress: string, public readonly dimension: string, ) { @@ -49,55 +58,55 @@ export class ServerboundHandshakePacket extends Packet { reader.readString(), reader.readString(), reader.readString(), - reader.readString(), ); } } -export class ClientboundEncryptionRequestPacket extends Packet { +export class ClientboundIdentityRequestPacket extends Packet { public static readonly PACKET_ID = asUnt8(2); - public constructor( - public readonly publicKey: Buffer, - public readonly verifyToken: Buffer, - ) { - super(ClientboundEncryptionRequestPacket.PACKET_ID); + public constructor(public readonly serverSalt: Buffer) { + super(ClientboundIdentityRequestPacket.PACKET_ID); } public encode(writer: BufferWriter) { - writer.writeLengthPrefixedBytes( - BufferWriter.prototype.writeUnt16, - this.publicKey, - ); writer.writeLengthPrefixedBytes( BufferWriter.prototype.writeUnt8, - this.verifyToken, + this.serverSalt, ); } } -export class ServerboundEncryptionResponsePacket extends Packet { +export class ServerboundIdentityResponsePacket extends Packet { public static readonly PACKET_ID = asUnt8(3); public constructor( - /** encrypted with server's public key */ - public readonly sharedSecret: Buffer, - /** encrypted with server's public key */ - public readonly verifyToken: Buffer, + public readonly claimedUsername: string, + public readonly clientSalt: Buffer, ) { - super(ServerboundEncryptionResponsePacket.PACKET_ID); + super(ServerboundIdentityResponsePacket.PACKET_ID); } public static decode( reader: BufferReader, - ): ServerboundEncryptionResponsePacket { - return new ServerboundEncryptionResponsePacket( - reader.readBytesOfLength(reader.readUnt8()), + ): ServerboundIdentityResponsePacket { + return new ServerboundIdentityResponsePacket( + reader.readString(), reader.readBytesOfLength(reader.readUnt8()), ); } } +export class ClientboundWelcomePacket extends Packet { + public static readonly PACKET_ID = asUnt8(9); + + public constructor() { + super(ClientboundWelcomePacket.PACKET_ID); + } + + public encode(writer: BufferWriter) {} +} + export class ClientboundRegionTimestampsPacket extends Packet { public static readonly PACKET_ID = asUnt8(7); @@ -242,8 +251,8 @@ export function decodePacket(reader: BufferReader): ServerboundPacket { switch (packetId) { case ServerboundHandshakePacket.PACKET_ID: return ServerboundHandshakePacket.decode(reader); - case ServerboundEncryptionResponsePacket.PACKET_ID: - return ServerboundEncryptionResponsePacket.decode(reader); + case ServerboundIdentityResponsePacket.PACKET_ID: + return ServerboundIdentityResponsePacket.decode(reader); case ServerboundChunkTimestampsRequestPacket.PACKET_ID: return ServerboundChunkTimestampsRequestPacket.decode(reader); case ServerboundCatchupRequestPacket.PACKET_ID: diff --git a/mapsync-server/src/server.ts b/mapsync-server/src/server.ts index ba27abe1..4bf6975c 100644 --- a/mapsync-server/src/server.ts +++ b/mapsync-server/src/server.ts @@ -1,363 +1,144 @@ -import crypto from "crypto"; -import net from "net"; -import { Main } from "./main"; +import { WebSocketServer, WebSocket } from "ws"; +import { ProtocolHandler } from "./main.ts"; import { decodePacket, encodePacket, - type ServerboundPacket, type ClientboundPacket, - ServerboundHandshakePacket, - ServerboundEncryptionResponsePacket, - ClientboundEncryptionRequestPacket, -} from "./protocol"; -import { BufferReader, BufferWriter } from "./protocol/buffers"; -import { SUPPORTED_VERSIONS } from "./constants"; -import * as metadata from "./metadata"; -import { asUnt31 } from "./deps/ints"; - -const { PORT = "12312", HOST = "127.0.0.1" } = process.env; - -type ProtocolHandler = Main; // TODO cleanup - -export class TcpServer { - server: net.Server; - clients: Record = {}; - - keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 1024 }); - // precomputed for networking - publicKeyBuffer = this.keyPair.publicKey.export({ - type: "spki", - format: "der", - }); - - constructor(readonly handler: ProtocolHandler) { - this.server = net.createServer({}, (socket) => { - const client = new TcpClient(socket, this, handler); - this.clients[client.id] = client; - socket.on("close", () => delete this.clients[client.id]); - }); +} from "./packets.ts"; +import { BufferReader, BufferWriter } from "./buffers.ts"; +import { Welcomed, AuthState } from "./auth.ts"; +import { type Config } from "./metadata.ts"; + +export class WSServer { + public readonly wss: WebSocketServer; + public readonly clients = new Map(); + + public constructor( + public readonly config: Config, + public readonly handler: ProtocolHandler, + ) { + this.wss = new WebSocketServer( + { + port: config.port, + path: "/", + maxPayload: (1 << 16) - 1, // sizeof(u16) + }, + () => { + console.log("[WSServer] Listening on", config.port); + }, + ); - this.server.on("error", (err: Error) => { - console.error("[TcpServer] Error:", err); - this.server.close(); + this.wss.on("connection", (ws, req) => { + const client = new WSClient(ws, this); + this.clients.set(client.id, client); + ws.on("close", () => this.clients.delete(client.id)); }); - this.server.listen({ port: PORT, hostname: HOST }, () => { - console.log("[TcpServer] Listening on", HOST, PORT); + this.wss.on("error", (err: Error) => { + console.error("[WSServer] Error:", err); + this.wss.close(); }); } - - decrypt(buf: Buffer) { - return crypto.privateDecrypt( - { - key: this.keyPair.privateKey, - padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, - oaepHash: "sha256" - }, - buf, - ); - } } let nextClientId = 1; -/** Prefixes packets with their length (UInt32BE); - * handles Mojang authentication */ -export class TcpClient { - readonly id = nextClientId++; - /** contains mojang name once logged in */ - name = "Client" + this.id; +export class WSClient { + public readonly id = nextClientId++; - modVersion: string | undefined; - gameAddress: string | undefined; - uuid: string | undefined; - mcName: string | undefined; - dimension: string | undefined; - - /** prevent Out of Memory when client sends a large packet */ - maxFrameSize = 2 ** 21; + public get name() { + let name = "Client" + this.id; + const suffix = this.auth?.logName ?? null; + if (suffix !== null) { + name += ":" + suffix; + } + return name; + } - /** sent by client during handshake */ - private claimedMojangName?: string; - private verifyToken?: Buffer; - /** we need to wait for the mojang auth response - * before we can en/decrypt packets following the handshake */ - private cryptoPromise?: Promise<{ - cipher: crypto.Cipheriv; - decipher: crypto.Decipheriv; - }>; + public auth: AuthState | null = null; + public gameAddress: string | null = null; + public dimension: string | null = null; - constructor( - private socket: net.Socket, - private server: TcpServer, - private handler: ProtocolHandler, + public constructor( + private readonly ws: WebSocket, + server: WSServer, ) { - this.log("Connected from", socket.remoteAddress); - handler.handleClientConnected(this); - - /** Accumulates received data, containing none, one, or multiple frames; the last frame may be partial only. */ - let accBuf: Buffer = Buffer.alloc(0); + this.log("Connected..."); + server.handler.handleClientConnected(this); - socket.on("data", async (data: Buffer) => { + ws.on("message", async (data, isBinary) => { + if (!isBinary) { + ws.close(1003); + return; + } + switch (true) { + case data instanceof ArrayBuffer: + data = Buffer.from(data); + break; + case Array.isArray(data): + data = Buffer.concat(data); + break; + default: + data = data as Buffer; + break; + } try { - if (this.cryptoPromise) { - const { decipher } = await this.cryptoPromise; - data = decipher.update(data); - } - - // creating a new buffer every time is fine in our case, because we expect most frames to be large - accBuf = Buffer.concat([accBuf, data]); - - // we may receive multiple frames in one call - while (true) { - if (accBuf.length <= 4) return; // wait for more data - const frameSize = accBuf.readUInt32BE(); - - // prevent Out of Memory - if (frameSize > this.maxFrameSize) { - this.kick( - `Frame's length [${frameSize}] is too large, max is [${this.maxFrameSize}]!`, - ); - return; - } - - if (accBuf.length < 4 + frameSize) return; // wait for more data - - const frameReader = new BufferReader(accBuf); - frameReader.readUnt31(); // skip frame size - let pktBuf = frameReader.readBytesOfLength(frameSize); - accBuf = frameReader.readRemainder(); - - const reader = new BufferReader(pktBuf); - - try { - const packet = decodePacket(reader); - await this.handlePacketReceived(packet); - } catch (err) { - this.warn(err); - return this.kick("Error in packet handler"); - } - } + const packet = decodePacket(new BufferReader(data)); + this.debug("Received", packet); + await server.handler.handleClientPacketReceived(this, packet); } catch (err) { this.warn(err); - return this.kick("Error in data handler"); + return this.kick("Error in packet handler"); } }); - socket.on("close", (hadError: boolean) => { - this.log("Closed.", { hadError }); + ws.on("close", (code, reason) => { + this.log("Closed.", { code, reason: reason.toString() }); + server.handler.handleClientDisconnected(this); }); - socket.on("end", () => { - // This event is called when the other end signals the end of transmission, meaning this client is - // still writeable, but no longer readable. In this situation we just want to close the socket. - // https://nodejs.org/dist/latest-v18.x/docs/api/net.html#event-end - this.kick("Ended"); - }); - - socket.on("timeout", () => { - // As per the docs, the socket needs to be manually closed. - // https://nodejs.org/dist/latest-v18.x/docs/api/net.html#event-timeout - this.kick("Timed out"); - }); - - socket.on("error", (err: Error) => { + ws.on("error", (err) => { this.warn("Error:", err); - this.kick("Socket error"); + this.kick("WebSocket error"); }); } - private async handlePacketReceived(pkt: ServerboundPacket) { - this.debug(`Received ${pkt.name}:`, pkt); - if (!this.uuid) { - switch (true) { - case pkt instanceof ServerboundHandshakePacket: - return await this.handleHandshakePacket(pkt); - case pkt instanceof ServerboundEncryptionResponsePacket: - return await this.handleEncryptionResponsePacket(pkt); - default: - throw new Error( - `Packet ${pkt.name} from unauth'd client ${this.id}`, - ); - } - } else { - return await this.handler.handleClientPacketReceived(this, pkt); + public requireWelcomed(): Welcomed { + if (this.auth instanceof Welcomed) { + return this.auth; } + throw new Error("Client is not authenticated!"); } - kick(internalReason: string) { - this.log(`Kicking:`, internalReason); - this.socket.destroy(); + public kick(internalReason: string) { + this.log("Kicking:", internalReason); + this.ws.close(1000); } - async send(pkt: ClientboundPacket) { - if (!this.cryptoPromise) { - this.debug("Not encrypted, dropping packet", pkt); - return; - } - if (!this.uuid) { - this.debug("Not authenticated, dropping packet", pkt); - return; + public send(pkt: ClientboundPacket) { + if (this.ws.readyState !== WebSocket.OPEN) { + return this.debug("WebSocket not open, dropping", pkt); } - await this.sendInternal(pkt, true); - } - - /** - * Sends a packet to the client through the socket, optionally encrypting it. - * - * @param pkt - The packet to send, encoded as a `ServerPacket`. - * @param doCrypto - If `true`, the packet is encrypted before sending. Defaults to `false`. - * @throws If encryption is requested but the handshake is not finished. - * - * @remarks - * - Reserves space for the packet length, encodes the packet, and writes the actual length. - * - If encryption is enabled, waits for the handshake to complete and encrypts the buffer. - * - Drops the packet if the socket is not writable. - */ - private async sendInternal(pkt: ClientboundPacket, doCrypto = false) { - if (!this.socket.writable) - return this.debug("Socket closed, dropping", pkt); - if (doCrypto && !this.cryptoPromise) - throw new Error(`Can't encrypt: handshake not finished`); - this.debug(`Sending ${pkt.name}:`, pkt); + this.debug("Sending", pkt); let buf: Buffer; { const packetWriter = new BufferWriter(); encodePacket(pkt, packetWriter); buf = packetWriter.getBuffer(); } - { - const frameWriter = new BufferWriter(asUnt31(4 + buf.length)); - frameWriter.writeUnt31(buf.length); - frameWriter.writeBytes(buf); - buf = frameWriter.getBuffer(); - } - if (doCrypto) { - const { cipher } = await this.cryptoPromise!; - buf = cipher!.update(buf); - } - this.socket.write(buf); - } - - private async handleHandshakePacket(packet: ServerboundHandshakePacket) { - if (this.cryptoPromise) throw new Error(`Already authenticated`); - if (this.verifyToken) throw new Error(`Encryption already started`); - - if (!SUPPORTED_VERSIONS.has(packet.modVersion)) { - this.kick( - "Connected with unsupported version [" + - packet.modVersion + - "]", - ); - return; - } - - this.gameAddress = packet.gameAddress; - this.claimedMojangName = packet.mojangName; - this.dimension = packet.dimension; - this.verifyToken = crypto.randomBytes(4); - - await this.sendInternal( - new ClientboundEncryptionRequestPacket( - this.server.publicKeyBuffer, - this.verifyToken, - ), - ); - } - - private async handleEncryptionResponsePacket( - pkt: ServerboundEncryptionResponsePacket, - ) { - if (this.cryptoPromise) throw new Error(`Already authenticated`); - if (!this.claimedMojangName) - throw new Error(`Encryption has not started: no mojangName`); - if (!this.verifyToken) - throw new Error(`Encryption has not started: no verifyToken`); - - const verifyToken = this.server.decrypt(pkt.verifyToken); - if (!this.verifyToken.equals(verifyToken)) { - throw new Error( - `verifyToken mismatch: got ${verifyToken} expected ${this.verifyToken}`, - ); - } - - const secret = this.server.decrypt(pkt.sharedSecret); - - const shaHex = crypto - .createHash("sha1") - .update(secret) - .update(this.server.publicKeyBuffer) - .digest() - .toString("hex"); - - this.cryptoPromise = fetchHasJoined({ - username: this.claimedMojangName, - shaHex, - }).then(async (mojangAuth) => { - if (!mojangAuth?.uuid) { - this.kick(`Mojang auth failed`); - throw new Error(`Mojang auth failed`); - } - - this.log("Authenticated as", mojangAuth); - - this.uuid = mojangAuth.uuid; - this.mcName = mojangAuth.name; - this.name += ":" + mojangAuth.name; - - return { - cipher: crypto.createCipheriv("aes-128-cfb8", secret, secret), - decipher: crypto.createDecipheriv( - "aes-128-cfb8", - secret, - secret, - ), - }; - }); - - await this.cryptoPromise.then(async () => { - await this.handler.handleClientAuthenticated(this); - }); + this.ws.send(buf); } - debug(...args: any[]) { + public debug(...args: any[]) { if (process.env.NODE_ENV === "production") return; console.debug(`[${this.name}]`, ...args); } - log(...args: any[]) { + public log(...args: any[]) { console.log(`[${this.name}]`, ...args); } - warn(...args: any[]) { + public warn(...args: any[]) { console.error(`[${this.name}]`, ...args); } } - -async function fetchHasJoined(args: { - username: string; - shaHex: string; - clientIp?: string; -}) { - const { username, shaHex, clientIp } = args; - - // if auth is disabled, return a "usable" item - if (!metadata.getConfig().auth) - return { name: username, uuid: `AUTH-DISABLED-${username}` }; - - let url = `https://sessionserver.mojang.com/session/minecraft/hasJoined?username=${username}&serverId=${shaHex}`; - if (clientIp) url += `&ip=${clientIp}`; - const res = await fetch(url); - try { - if (res.status === 204) return null; - let { id, name } = (await res.json()) as { id: string; name: string }; - const uuid = id.replace( - /^(........)-?(....)-?(....)-?(....)-?(............)$/, - "$1-$2-$3-$4-$5", - ); - return { uuid, name }; - } catch (err) { - console.error(res); - throw err; - } -} diff --git a/mapsync-server/tsconfig.json b/mapsync-server/tsconfig.json index a21b0116..edecc549 100644 --- a/mapsync-server/tsconfig.json +++ b/mapsync-server/tsconfig.json @@ -5,9 +5,9 @@ "experimentalDecorators": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["esnext", "webworker"], - "module": "CommonJS", - "moduleResolution": "node", + "lib": ["esnext"], + "module": "nodenext", + "moduleResolution": "nodenext", "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noImplicitAny": true, @@ -16,7 +16,9 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "target": "ESNext" - }, - "include": ["src"] + "target": "ESNext", + "rootDir": "./src", + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true + } }