diff --git a/.gitignore b/.gitignore index 7a89cb1..8d150ef 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ web target/* !target/ayungee-1.0-SNAPSHOT-jar-with-dependencies.jar +test diff --git a/README.md b/README.md index bc8d902..8fdb62b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ lightweight bungeecord alternative for eaglercraft servers running protocolsupport -Thanks to LAX1DUDE for very small snippets of EaglerBungee used in this project (specifically for the server icon) +Thanks to LAX1DUDE and md-5 for very small snippets of EaglerBungee used in this project (specifically for the server icon, skins, & entity remapping) -**TODO: skins & capes** +**TODO: ability for plugins to change a player's server & built-in auth system** + + + +**if you have questions about the license, please, reach out to me. i just put the license i put on most of my projects on this, but if you have a problem with it, let me know. diff --git a/src/main/java/me/ayunami2000/ayungee/Client.java b/src/main/java/me/ayunami2000/ayungee/Client.java index 5d9767e..f6c128c 100644 --- a/src/main/java/me/ayunami2000/ayungee/Client.java +++ b/src/main/java/me/ayunami2000/ayungee/Client.java @@ -16,10 +16,17 @@ public class Client { public String username; + public int server = 0; + + public byte[] handshake = null; + + public int clientEntityId; + public int serverEntityId; + public void setSocket(Socket sock) throws IOException { socket = sock; socketOut = sock.getOutputStream(); - socketIn = socket.getInputStream(); + socketIn = sock.getInputStream(); } public Client(String uname) { diff --git a/src/main/java/me/ayunami2000/ayungee/EntityMap.java b/src/main/java/me/ayunami2000/ayungee/EntityMap.java new file mode 100644 index 0000000..983febf --- /dev/null +++ b/src/main/java/me/ayunami2000/ayungee/EntityMap.java @@ -0,0 +1,81 @@ +package me.ayunami2000.ayungee; + +// https://github.com/LAX1DUDE/eaglercraft/blob/a8d5c856de28ba2a263abc055d7b26d50dc2bf7e/eaglercraftbungee/src/main/java/net/md_5/bungee/EntityMap.java + +public class EntityMap { + public static final int[][] entityIds; + + public static void rewrite(final byte[] packet, final int oldId, final int newId) { + final int packetId = packet[0] & 0xFF; + if (packetId == 29) { + for (int pos = 2; pos < (short) ((packet[1] << 8) + packet[2] & 0xff); pos += 4) { + final int readId = readInt(packet, pos); + if (readId == oldId) { + setInt(packet, pos, newId); + } else if (readId == newId) { + setInt(packet, pos, oldId); + } + } + } else { + final int[] idArray = EntityMap.entityIds[packetId]; + if (idArray != null) { + for (final int pos2 : idArray) { + final int readId2 = readInt(packet, pos2); + if (readId2 == oldId) { + setInt(packet, pos2, newId); + } else if (readId2 == newId) { + setInt(packet, pos2, oldId); + } + } + } + } + if (packetId == 23) { + final int type = packet[5] & 0xFF; + if (type == 60 || type == 90) { + final int index20 = readInt(packet, 20); + if (packet.length > 24 && index20 == oldId) { + setInt(packet, 20, newId); + } + } + } + } + + public static void setInt(final byte[] buf, final int pos, final int i) { + buf[pos] = (byte) (i >> 24); + buf[pos + 1] = (byte) (i >> 16); + buf[pos + 2] = (byte) (i >> 8); + buf[pos + 3] = (byte) i; + } + + public static int readInt(final byte[] buf, final int pos) { + return (buf[pos] & 0xFF) << 24 | (buf[pos + 1] & 0xFF) << 16 | (buf[pos + 2] & 0xFF) << 8 | (buf[pos + 3] & 0xFF); + } + + static { + (entityIds = new int[256][])[5] = new int[] { 1 }; + EntityMap.entityIds[7] = new int[] { 1, 5 }; + EntityMap.entityIds[17] = new int[] { 1 }; + EntityMap.entityIds[18] = new int[] { 1 }; + EntityMap.entityIds[19] = new int[] { 1 }; + EntityMap.entityIds[20] = new int[] { 1 }; + EntityMap.entityIds[22] = new int[] { 1, 5 }; + EntityMap.entityIds[23] = new int[] { 1 }; + EntityMap.entityIds[24] = new int[] { 1 }; + EntityMap.entityIds[25] = new int[] { 1 }; + EntityMap.entityIds[26] = new int[] { 1 }; + EntityMap.entityIds[28] = new int[] { 1 }; + EntityMap.entityIds[30] = new int[] { 1 }; + EntityMap.entityIds[31] = new int[] { 1 }; + EntityMap.entityIds[32] = new int[] { 1 }; + EntityMap.entityIds[33] = new int[] { 1 }; + EntityMap.entityIds[34] = new int[] { 1 }; + EntityMap.entityIds[35] = new int[] { 1 }; + EntityMap.entityIds[38] = new int[] { 1 }; + EntityMap.entityIds[39] = new int[] { 1, 5 }; + EntityMap.entityIds[40] = new int[] { 1 }; + EntityMap.entityIds[41] = new int[] { 1 }; + EntityMap.entityIds[42] = new int[] { 1 }; + EntityMap.entityIds[55] = new int[] { 1 }; + EntityMap.entityIds[71] = new int[] { 1 }; + } +} \ No newline at end of file diff --git a/src/main/java/me/ayunami2000/ayungee/Main.java b/src/main/java/me/ayunami2000/ayungee/Main.java index ea5a2e7..9349782 100644 --- a/src/main/java/me/ayunami2000/ayungee/Main.java +++ b/src/main/java/me/ayunami2000/ayungee/Main.java @@ -7,9 +7,7 @@ import org.yaml.snakeyaml.Yaml; import javax.imageio.ImageIO; import java.io.*; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLConnection; +import java.net.*; import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; @@ -19,8 +17,7 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; public class Main { - public static String hostname = "localhost"; - public static int port = 25569; + public static List servers = new ArrayList<>(); public static int webPort = 25565; public static String motdJson = ""; @@ -28,8 +25,6 @@ public class Main { public static boolean forwarded = false; - public static boolean eaglerPackets = false; - public static WebSocketServer webSocketServer = null; public static Map clients = new HashMap<>(); @@ -107,12 +102,19 @@ public class Main { }).start(); } + servers.add(new ServerItem("localhost", 25569)); + + List stringServers = (List) config.getOrDefault("servers", new ArrayList<>()); + + if (!stringServers.isEmpty()) { + servers.clear(); + for (String serverEntry : stringServers) { + servers.add(new ServerItem(serverEntry)); + } + } - hostname = (String) config.getOrDefault("hostname", "localhost"); - port = (int) config.getOrDefault("port", 25569); webPort = (int) config.getOrDefault("web_port", 25565); forwarded = (boolean) config.getOrDefault("forwarded", false); - eaglerPackets = (boolean) config.getOrDefault("eag_packets", false); List defaultMotd = new ArrayList<>(); @@ -168,7 +170,7 @@ public class Main { switch (pieces[0]) { case "help": case "?": - System.out.println("help ; unban ; banip ; ban ; stop"); + System.out.println("help ; unban ; banip ; ban ; send ; stop"); break; case "unban": case "pardon": @@ -229,6 +231,24 @@ public class Main { System.out.println("IP " + pieces[1] + " is already banned!"); } break; + case "send": + case "server": + if (pieces.length == 1 || pieces.length == 2) { + System.out.println("Usage: " + pieces[0] + " "); + break; + } + Client targetUser = clients.values().stream().filter(client -> client.username.equals(pieces[1])).findFirst().orElse(clients.values().stream().filter(client -> client.username.equalsIgnoreCase(pieces[1])).findFirst().orElse(null)); + if (targetUser == null) { + System.out.println("Unable to find any user with that username!"); + break; + } + try { + int destServer = Integer.parseInt(pieces[2]); + targetUser.server = Math.max(0, Math.min(servers.size() - 1, destServer)); + } catch (NumberFormatException e) { + System.out.println("That is not a valid number!"); + } + break; case "stop": case "end": case "exit": diff --git a/src/main/java/me/ayunami2000/ayungee/ServerItem.java b/src/main/java/me/ayunami2000/ayungee/ServerItem.java new file mode 100644 index 0000000..c9bd731 --- /dev/null +++ b/src/main/java/me/ayunami2000/ayungee/ServerItem.java @@ -0,0 +1,17 @@ +package me.ayunami2000.ayungee; + +public class ServerItem { + public String host; + public int port = 25565; + + public ServerItem(String h, int p) { + host = h; + port = p; + } + + public ServerItem(String hp) { + String[] pieces = hp.split(":"); + if (pieces.length > 1) port = Integer.parseInt(pieces[1]); + host = pieces[0]; + } +} diff --git a/src/main/java/me/ayunami2000/ayungee/Skins.java b/src/main/java/me/ayunami2000/ayungee/Skins.java new file mode 100644 index 0000000..872bed3 --- /dev/null +++ b/src/main/java/me/ayunami2000/ayungee/Skins.java @@ -0,0 +1,115 @@ +package me.ayunami2000.ayungee; + +import org.java_websocket.WebSocket; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; + +public class Skins { + private static final HashMap skinCollection = new HashMap(); + private static final HashMap capeCollection = new HashMap(); + private static final HashMap lastSkinLayerUpdate = new HashMap(); + + private static final int[] SKIN_DATA_SIZE = new int[] { 64*32*4, 64*64*4, -9, -9, 1, 64*64*4, -9 }; // 128 pixel skins crash clients + private static final int[] CAPE_DATA_SIZE = new int[] { 32*32*4, -9, 1 }; + + public static boolean setSkin(String user, WebSocket conn, byte[] initMsg) { + if(initMsg.length >= 3) { + try { + ByteBuffer bb = ByteBuffer.wrap(initMsg); + bb.get(); + int tagLen = bb.getShort(); + if (!(tagLen >= 0 && tagLen < initMsg.length - 1)) return false; + StringBuilder tagBuilder = new StringBuilder(); + for (int i = 0; i < tagLen; i++) tagBuilder.append(bb.getChar()); + //int dataLen = bb.getShort(); + int dataLen = bb.remaining() - 2; + String tag = tagBuilder.toString(); + int offset = 3 + tagLen * 2 + 2; + byte[] msg = new byte[dataLen]; + System.arraycopy(initMsg, offset, msg, 0, dataLen); + if("EAG|MySkin".equals(tag)) { + if(!skinCollection.containsKey(user)) { + int t = (int)msg[0] & 0xFF; + if(t >= 0 && t < SKIN_DATA_SIZE.length && msg.length == (SKIN_DATA_SIZE[t] + 1)) { + skinCollection.put(user, msg); + } + } + }else if("EAG|MyCape".equals(tag)) { + if(!capeCollection.containsKey(user)) { + int t = (int)msg[0] & 0xFF; + if(t >= 0 && t < CAPE_DATA_SIZE.length && msg.length == (CAPE_DATA_SIZE[t] + 2)) { + capeCollection.put(user, msg); + } + } + }else if("EAG|FetchSkin".equals(tag)) { + if(msg.length > 2) { + String fetch = new String(msg, 2, msg.length - 2, StandardCharsets.UTF_8); + byte[] data; + if((data = skinCollection.get(fetch)) != null) { + byte[] conc = new byte[data.length + 2]; + conc[0] = msg[0]; conc[1] = msg[1]; //synchronization cookie + System.arraycopy(data, 0, conc, 2, data.length); + if((data = capeCollection.get(fetch)) != null) { + byte[] conc2 = new byte[conc.length + data.length]; + System.arraycopy(conc, 0, conc2, 0, conc.length); + System.arraycopy(data, 0, conc2, conc.length, data.length); + conc = conc2; + } + byte[] packetPrefix = new byte[] { (byte) 250, 0, 12, 0, 69, 0, 65, 0, 71, 0, 124, 0, 85, 0, 115, 0, 101, 0, 114, 0, 83, 0, 107, 0, 105, 0, 110, (byte) ((conc.length >>> 8) & 0xFF), (byte) (conc.length & 0xFF) }; + byte[] fullPacket = new byte[packetPrefix.length + conc.length]; + System.arraycopy(packetPrefix, 0, fullPacket, 0, packetPrefix.length); + System.arraycopy(conc, 0, fullPacket, packetPrefix.length, conc.length); + conn.send(fullPacket); + } + } + }else if("EAG|SkinLayers".equals(tag)) { + long millis = System.currentTimeMillis(); + Long lsu = lastSkinLayerUpdate.get(user); + if(lsu != null && millis - lsu.longValue() < 700l) { // DoS protection + return false; + } + lastSkinLayerUpdate.put(user, millis); + byte[] data; + if((data = capeCollection.get(user)) != null) { + data[1] = msg[0]; + }else { + data = new byte[] { (byte)2, msg[0], (byte)0 }; + capeCollection.put(user, data); + } + ByteArrayOutputStream bao = new ByteArrayOutputStream(); + DataOutputStream dd = new DataOutputStream(bao); + dd.write(msg[0]); + dd.writeUTF(user); + byte[] bpacket = bao.toByteArray(); + byte[] packetPrefix = new byte[] { (byte) 250, 0, 14, 0, 69, 0, 65, 0, 71, 0, 124, 0, 83, 0, 107, 0, 105, 0, 110, 0, 76, 0, 97, 0, 121, 0, 101, 0, 114, 0, 115, (byte) ((bpacket.length >>> 8) & 0xFF), (byte) (bpacket.length & 0xFF) }; + int off = bpacket.length == 0 ? 2 : 0; + byte[] fullPacket = new byte[(packetPrefix.length - off) + bpacket.length]; + System.arraycopy(packetPrefix, 0, fullPacket, 0, packetPrefix.length - off); + if (bpacket.length != 0) System.arraycopy(bpacket, 0, fullPacket, packetPrefix.length, bpacket.length); + for (WebSocket pl : Main.clients.keySet()) { + if (pl.equals(conn)) continue; + if (pl.isOpen()) pl.send(fullPacket); + } + } else { + return false; + } + }catch(Throwable t) { + // hacker + } + } else { + return false; + } + + return true; + } + + public static void removeSkin(String username) { + skinCollection.remove(username); + capeCollection.remove(username); + lastSkinLayerUpdate.remove(username); + } +} \ No newline at end of file diff --git a/src/main/java/me/ayunami2000/ayungee/WebSocketProxy.java b/src/main/java/me/ayunami2000/ayungee/WebSocketProxy.java index 8e66f09..3cc4521 100644 --- a/src/main/java/me/ayunami2000/ayungee/WebSocketProxy.java +++ b/src/main/java/me/ayunami2000/ayungee/WebSocketProxy.java @@ -63,6 +63,7 @@ public class WebSocketProxy extends WebSocketServer { Client selfClient = Main.clients.remove(conn); if (selfClient != null) { System.out.println("Player " + selfClient.username + " (" + Main.getIp(conn) + ") left!"); + Skins.removeSkin(selfClient.username); if (selfClient.socket.isClosed()) { try { selfClient.socket.close(); @@ -88,7 +89,7 @@ public class WebSocketProxy extends WebSocketServer { } @Override - public void onMessage(WebSocket conn, ByteBuffer message) { + public void onMessage(WebSocket conn, ByteBuffer message) { // todo: make use of the fact that it's already a bytebuffer dumbass if (Main.bans.contains(Main.getIp(conn))) { conn.close(); return; @@ -97,60 +98,132 @@ public class WebSocketProxy extends WebSocketServer { byte[] msg = message.array(); if (!Main.clients.containsKey(conn)) { if (msg.length > 3 && msg[1] == (byte) 69) { - if (msg[3] < 3 || msg[3] > 16) { + // todo: it uses shorts dumbass, get with the system + short unameLen = (short) ((msg[2] << 8) + msg[3] & 0xff); + if (unameLen < 3 || unameLen > 16) { conn.close(); return; } - byte[] uname = new byte[msg[3]]; if (msg.length < 5 + msg[3] * 2) { conn.close(); return; } + byte[] uname = new byte[unameLen]; for (int i = 0; i < uname.length; i++) uname[i] = msg[5 + i * 2]; String username = new String(uname); - Main.clients.put(conn, new Client(username)); + Client selfClient = new Client(username); + Main.clients.put(conn, selfClient); new Thread(() -> { try { - Socket selfSocket = new Socket(Main.hostname, Main.port); - Client selfClient = Main.clients.get(conn); - selfClient.setSocket(selfSocket); - while (selfClient.msgCache.size() > 0) selfClient.socketOut.write(selfClient.msgCache.remove(0)); - while (conn.isOpen() && !selfSocket.isInputShutdown()) { - byte[] data = new byte[maxBuffSize]; - int read = selfClient.socketIn.read(data, 0, maxBuffSize); - if (read == maxBuffSize) { + boolean firstTime = true; + while (conn.isOpen()) { + int currServer = selfClient.server; + ServerItem chosenServer = Main.servers.get(currServer); + Socket selfSocket = new Socket(chosenServer.host, chosenServer.port); + selfClient.setSocket(selfSocket); + if (!firstTime) sendToServer(selfClient.handshake, selfClient); + while (selfClient.msgCache.size() > 0) sendToServer(selfClient.msgCache.remove(0), selfClient); + while (conn.isOpen() && !selfSocket.isInputShutdown() && selfClient.server == currServer) { + byte[] dataa = new byte[maxBuffSize]; + int read = selfClient.socketIn.read(dataa, 0, maxBuffSize); + byte[] data; + if (read == maxBuffSize) { + data = dataa; + } else if (read > 0) { + data = new byte[read]; + System.arraycopy(dataa, 0, data, 0, read); + } else { + continue; + } + if (firstTime && data[0] == 1) selfClient.clientEntityId = selfClient.serverEntityId = EntityMap.readInt(data, 1); + if (!firstTime && data[0] == 1) { + selfClient.serverEntityId = EntityMap.readInt(data, 1); + // assume server is giving valid data; we don't have to validate it because it isn't a potentially malicious client + byte[] worldByte = new byte[data[6] * 2 + 2]; + System.arraycopy(data, 5, worldByte, 0, worldByte.length); + byte gamemode = data[worldByte.length + 5]; + byte dimension = data[worldByte.length + 6]; + byte difficulty = data[worldByte.length + 7]; + Arrays.fill(data, (byte) 0); + data[0] = 9; + EntityMap.setInt(data, 1, dimension); + data[5] = difficulty; + data[6] = gamemode; + data[7] = (byte)(256 & 0xff); + data[8] = (byte)((256 >> 8) & 0xff); + System.arraycopy(worldByte, 0, data, 9, worldByte.length); + read = 9 + worldByte.length; + byte[] trimData = new byte[read]; + System.arraycopy(data, 0, trimData, 0, read); + data = trimData; + if (conn.isOpen()) conn.send(new byte[] { 9, 0, 0, 0, 1, 0, 0, 1, 0, 0, 7, 0, 100, 0, 101, 0, 102, 0, 97, 0, 117, 0, 108, 0, 116 }); + if (conn.isOpen()) conn.send(new byte[] { 9, 0, 0, 0, -1, 0, 0, 1, 0, 0, 7, 0, 100, 0, 101, 0, 102, 0, 97, 0, 117, 0, 108, 0, 116 }); + } + EntityMap.rewrite(data, selfClient.serverEntityId, selfClient.clientEntityId); if (conn.isOpen()) conn.send(data); - } else if (read > 0) { - byte[] trueData = new byte[read]; - System.arraycopy(data, 0, trueData, 0, read); - if (conn.isOpen()) conn.send(trueData); } + if (conn.isOpen() && selfClient.server == currServer) conn.close(); + if (!selfSocket.isClosed()) selfSocket.close(); + selfClient.socketOut = null; + firstTime = false; } - if (conn.isOpen()) conn.close(); - if (!selfSocket.isClosed()) selfSocket.close(); } catch (IOException ex) { conn.close(); } }).start(); msg[1] = (byte) 61; + selfClient.handshake = msg; System.out.println("Player " + username + " (" + Main.getIp(conn) + ") joined!"); } else { conn.close(); return; } } - byte[] packet = message.array(); - if (!Main.eaglerPackets && packet.length >= 11 && packet[0] == -6 && packet[2] >= 4 && packet[4] == 69 && packet[6] == 65 && packet[8] == 71 && packet[10] == 124) return; // EAG| Client currClient = Main.clients.get(conn); - if (currClient.socketOut == null) { - currClient.msgCache.add(packet); - } else if (!currClient.socket.isOutputShutdown()) { + if (msg.length >= 3 && msg[0] == 3) { + int msgLen = (short) ((msg[1] << 8) + msg[2] & 0xff); + if (msgLen != 0) { + if (msg.length >= 3 + msgLen * 2) { + byte[] chatBytes = new byte[msgLen]; + for (int i = 0; i < chatBytes.length; i++) chatBytes[i] = msg[4 + i * 2]; + String chatMsg = new String(chatBytes); + if (chatMsg.toLowerCase().startsWith("/server")) { + String msgArgs = chatMsg.substring(7 + (chatMsg.contains(" ") ? 1 : 0)); + if (msgArgs.isEmpty()) { + //usage msg + conn.send(new byte[] { 3, 0, 25, 0, (byte) 167, 0, 57, 0, 85, 0, 115, 0, 97, 0, 103, 0, 101, 0, 58, 0, 32, 0, 47, 0, 115, 0, 101, 0, 114, 0, 118, 0, 101, 0, 114, 0, 32, 0, 60, 0, 110, 0, 117, 0, 109, 0, 98, 0, 101, 0, 114, 0, 62 }); + } else { + try { + int destServer = Integer.parseInt(msgArgs); + currClient.server = Math.max(0, Math.min(Main.servers.size() - 1, destServer)); + } catch (NumberFormatException e) { + //not a number + conn.send(new byte[] { 3, 0, 29, 0, (byte) 167, 0, 57, 0, 84, 0, 104, 0, 97, 0, 116, 0, 32, 0, 105, 0, 115, 0, 32, 0, 110, 0, 111, 0, 116, 0, 32, 0, 97, 0, 32, 0, 118, 0, 97, 0, 108, 0, 105, 0, 100, 0, 32, 0, 110, 0, 117, 0, 109, 0, 98, 0, 101, 0, 114, 0, 33 }); + } + } + return; // don't send to underlying server + } + } + } + } + if (msg.length > 0 && msg[0] == (byte) 250) { + if (Skins.setSkin(currClient.username, conn, msg)) return; + } + if (currClient.socketOut == null || currClient.socket.isOutputShutdown()) { + currClient.msgCache.add(msg); + } else { try { - currClient.socketOut.write(packet); + sendToServer(msg, currClient); } catch (IOException ignored) {} } } + public void sendToServer(byte[] orig, Client client) throws IOException { + byte[] data = orig.clone(); + EntityMap.rewrite(data, client.clientEntityId, client.serverEntityId); + client.socketOut.write(data); + } + @Override public void onError(WebSocket conn, Exception ex) { // diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1ccadee..971bcc4 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,13 +1,10 @@ -# mc server ip -hostname: localhost -# mc server port -port: 25569 +# mc servers in network. first one is default/hub +servers: + - localhost:25569 # ayungee port web_port: 25565 # if this is behind a reverse proxy, such as caddy or nginx. uses X-Real-IP header. forwarded: false -# forward eagler-specific packets? -eag_packets: false # origin blacklist URL (leave empty to disable syncing) origin_blacklist: "https://g.eags.us/eaglercraft/origin_blacklist.txt" # whitelisted origins -- if specified, only allows the listed origins to connect diff --git a/target/ayungee-1.0-SNAPSHOT-jar-with-dependencies.jar b/target/ayungee-1.0-SNAPSHOT-jar-with-dependencies.jar index 5ad9ce7..c3ac5b3 100644 Binary files a/target/ayungee-1.0-SNAPSHOT-jar-with-dependencies.jar and b/target/ayungee-1.0-SNAPSHOT-jar-with-dependencies.jar differ