commit a744eb114ae00de9eb15c0cb94462eb5a0488253 Author: ayunami2000 Date: Tue Jul 5 17:09:14 2022 -0400 add first build diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a89cb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +web +target/* +!target/ayungee-1.0-SNAPSHOT-jar-with-dependencies.jar diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..241bc0f --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2022, ayunami2000 +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc8d902 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# ayungee + +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) + +**TODO: skins & capes** diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9397094 --- /dev/null +++ b/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + me.ayunami2000 + ayungee + 1.0-SNAPSHOT + + + 8 + 8 + + + + + org.java-websocket + Java-WebSocket + 1.5.3 + + + org.yaml + snakeyaml + 1.30 + + + com.googlecode.json-simple + json-simple + 1.1.1 + + + + + + + maven-assembly-plugin + + + + me.ayunami2000.ayungee.Main + + + + jar-with-dependencies + + + + + + \ No newline at end of file diff --git a/src/main/java/me/ayunami2000/ayungee/Client.java b/src/main/java/me/ayunami2000/ayungee/Client.java new file mode 100644 index 0000000..5d9767e --- /dev/null +++ b/src/main/java/me/ayunami2000/ayungee/Client.java @@ -0,0 +1,28 @@ +package me.ayunami2000.ayungee; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; + +public class Client { + public Socket socket = null; + public OutputStream socketOut = null; + public InputStream socketIn = null; + + public List msgCache =new ArrayList<>(); + + public String username; + + public void setSocket(Socket sock) throws IOException { + socket = sock; + socketOut = sock.getOutputStream(); + socketIn = socket.getInputStream(); + } + + public Client(String uname) { + username = uname; + } +} diff --git a/src/main/java/me/ayunami2000/ayungee/Main.java b/src/main/java/me/ayunami2000/ayungee/Main.java new file mode 100644 index 0000000..0ede63e --- /dev/null +++ b/src/main/java/me/ayunami2000/ayungee/Main.java @@ -0,0 +1,279 @@ +package me.ayunami2000.ayungee; + +import org.java_websocket.WebSocket; +import org.java_websocket.server.WebSocketServer; +import org.json.simple.JSONObject; +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.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.*; +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 int webPort = 25565; + + public static String motdJson = ""; + public static byte[] serverIcon = null; + + public static boolean forwarded = false; + + public static WebSocketServer webSocketServer = null; + + public static Map clients = new HashMap<>(); + + public static Set bans = new HashSet<>(); + public static Set originBlacklist = new HashSet<>(); + public static Set originWhitelist = new HashSet<>(); + + public static void main(String[] args) throws IOException, InterruptedException { + Yaml yaml = new Yaml(); + + Map config; + + try { + config = yaml.load(new FileReader("config.yml")); + } catch (FileNotFoundException e) { + Files.copy(Main.class.getResourceAsStream("/config.yml"), Paths.get("config.yml"), StandardCopyOption.REPLACE_EXISTING); + config = yaml.load(new FileReader("config.yml")); + } + + File iconFile = new File("icon.png"); + if (!iconFile.exists()) Files.copy(Main.class.getResourceAsStream("/icon.png"), Paths.get("icon.png"), StandardCopyOption.REPLACE_EXISTING); + int[] serverIconInt = ServerIcon.createServerIcon(ImageIO.read(iconFile)); + byte[] iconPixels = new byte[16384]; + for(int i = 0; i < 4096; ++i) { + iconPixels[i * 4] = (byte)((serverIconInt[i] >> 16) & 0xFF); + iconPixels[i * 4 + 1] = (byte)((serverIconInt[i] >> 8) & 0xFF); + iconPixels[i * 4 + 2] = (byte)(serverIconInt[i] & 0xFF); + iconPixels[i * 4 + 3] = (byte)((serverIconInt[i] >> 24) & 0xFF); + } + serverIcon = iconPixels; + + + try (BufferedReader br = new BufferedReader(new FileReader("bans.txt"))) { + String line; + while ((line = br.readLine()) != null) bans.add(line); + } catch (FileNotFoundException e) { + saveBans(); + } + + originWhitelist = new HashSet<>((List) config.getOrDefault("origins", new ArrayList<>())); + + String originBlacklistUrl = (String) config.getOrDefault("origin_blacklist", "https://g.eags.us/eaglercraft/origin_blacklist.txt"); + if (originBlacklistUrl.isEmpty()) { + new Thread(() -> { + while (true) { + readUrlBlacklist(); + try { + Thread.sleep(300000); + } catch (InterruptedException ignored) {} + } + }).start(); + } else { + URL blacklistUrl = new URL(originBlacklistUrl); + new Thread(() -> { + while (true) { + try { + URLConnection cc = blacklistUrl.openConnection(); + if (cc instanceof HttpURLConnection) { + HttpURLConnection ccc = (HttpURLConnection)cc; + ccc.setRequestProperty("Accept", "text/plain,text/html,application/xhtml+xml,application/xml"); + ccc.setRequestProperty("User-Agent", "Mozilla/5.0 ayungee"); + } + cc.connect(); + Files.copy(cc.getInputStream(), Paths.get("origin_blacklist.txt"), StandardCopyOption.REPLACE_EXISTING); + readUrlBlacklist(); + } catch (IOException e) { + System.out.println("An error occurred attempting to update the origin blacklist!"); + } + try { + Thread.sleep(300000); + } catch (InterruptedException ignored) { + } + } + }).start(); + } + + + 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); + + List defaultMotd = new ArrayList<>(); + + defaultMotd.add("Welcome to my"); + defaultMotd.add("ayungee-powered server!"); + + List defaultPlayersMotd = new ArrayList<>(); + + defaultPlayersMotd.add("whar?"); + + Map defaultConfigMotd = new HashMap<>(); + + defaultConfigMotd.put("lines", defaultMotd); + defaultConfigMotd.put("max", 20); + defaultConfigMotd.put("online", 4); + defaultConfigMotd.put("name", "An ayungee-powered Eaglercraft server"); + defaultConfigMotd.put("players", defaultPlayersMotd); + + Map configMotd = (LinkedHashMap) config.getOrDefault("motd", defaultConfigMotd); + + JSONObject motdObj = new JSONObject(); + JSONObject motdObjObj = new JSONObject(); + + motdObjObj.put("motd", configMotd.getOrDefault("lines", defaultMotd)); + motdObjObj.put("cache", true); + motdObjObj.put("max", configMotd.get("max")); + motdObjObj.put("icon", true); + motdObjObj.put("online", configMotd.get("online")); + motdObjObj.put("players", configMotd.getOrDefault("players", defaultPlayersMotd)); + + motdObj.put("data", motdObjObj); + motdObj.put("vers", "0.2.0"); + motdObj.put("name", configMotd.get("name")); + motdObj.put("time", Instant.now().toEpochMilli()); + motdObj.put("type", "motd"); + motdObj.put("brand", "Eagtek"); + motdObj.put("uuid", UUID.randomUUID().toString()); + motdObj.put("cracked", true); + + motdJson = motdObj.toJSONString(); + + webSocketServer = new WebSocketProxy(webPort); + webSocketServer.start(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + boolean running = true; + System.out.println("ayungee started!"); + while (running) { + //System.out.print("> "); + String cmd = reader.readLine(); + String[] pieces = cmd.split(" "); + pieces[0] = pieces[0].toLowerCase(); + switch (pieces[0]) { + case "help": + case "?": + System.out.println("help ; unban ; banip ; ban ; stop"); + break; + case "unban": + case "pardon": + case "unban-ip": + case "unbanip": + case "pardon-ip": + case "pardonip": + if (pieces.length == 1) { + System.out.println("Usage: " + pieces[0] + " "); + break; + } + if (bans.remove(pieces[1])) { + System.out.println("Successfully unbanned IP " + pieces[1]); + saveBans(); + } else { + System.out.println("IP " + pieces[1] + " is not banned!"); + } + break; + case "ban": + if (pieces.length == 1) { + System.out.println("Usage: " + pieces[0] + " "); + break; + } + //there should NEVER be duplicate usernames... + Client[] targetClients = clients.values().stream().filter(client -> client.username.equals(pieces[1])).toArray(Client[]::new); + if (targetClients.length == 0) targetClients = clients.values().stream().filter(client -> client.username.equalsIgnoreCase(pieces[1])).toArray(Client[]::new); + if (targetClients.length == 0) { + System.out.println("Unable to find any user with that username! (note: they must be online)"); + break; + } + for (Client targetClient : targetClients) { + WebSocket targetWebSocket = getKeysByValue(clients, targetClient).stream().findFirst().orElse(null); + if (targetWebSocket == null) { + System.out.println("An internal error occurred which should never happen! Oops..."); + return; + } + String ipToBan = getIp(targetWebSocket); + if (bans.add(ipToBan)) { + System.out.println("Successfully banned user " + targetClient.username + " with IP " + ipToBan); + try { + saveBans(); + } catch (IOException ignored) {} + } else { + System.out.println("IP " + ipToBan + " is already banned!"); + } + } + break; + case "ban-ip": + case "banip": + if (pieces.length == 1) { + System.out.println("Usage: " + pieces[0] + " "); + break; + } + if (bans.add(pieces[1])) { + System.out.println("Successfully banned IP " + pieces[1]); + saveBans(); + } else { + System.out.println("IP " + pieces[1] + " is already banned!"); + } + break; + case "stop": + case "end": + case "exit": + case "quit": + System.out.println("Stopping!"); + running = false; + webSocketServer.stop(10); + System.exit(0); + break; + default: + System.out.println("Command not found!"); + } + } + } + + public static String getIp(WebSocket conn) { + return conn.getAttachment(); + } + + // https://stackoverflow.com/a/2904266/6917520 + + public static Set getKeysByValue(Map map, E value) { + return map.entrySet() + .stream() + .filter(entry -> Objects.equals(entry.getValue(), value)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + private static void saveBans() throws IOException { + BufferedWriter writer = new BufferedWriter(new FileWriter("bans.txt")); + writer.write(String.join("\n", bans)); + writer.close(); + } + + private static void readUrlBlacklist() { + try { + try (BufferedReader br = new BufferedReader(new FileReader("origin_blacklist.txt"))) { + originBlacklist.clear(); + String line; + while ((line = br.readLine()) != null) { + if (line.startsWith("#")) continue; + if (line.trim().isEmpty()) continue; + originBlacklist.add(Pattern.compile(line, Pattern.CASE_INSENSITIVE)); + } + } catch (FileNotFoundException e) { + new File("origin_blacklist.txt").createNewFile(); + } + } catch (IOException ignored) {} + } +} diff --git a/src/main/java/me/ayunami2000/ayungee/ServerIcon.java b/src/main/java/me/ayunami2000/ayungee/ServerIcon.java new file mode 100644 index 0000000..8a8845e --- /dev/null +++ b/src/main/java/me/ayunami2000/ayungee/ServerIcon.java @@ -0,0 +1,45 @@ +package me.ayunami2000.ayungee; + +import java.awt.*; +import java.awt.image.BufferedImage; + +public class ServerIcon { + + // https://github.com/LAX1DUDE/eaglercraft/blob/bec1a03fa24bcb9e8d07fd67ba82a6a827f9c1d9/eaglercraftbungee/src/main/java/net/md_5/bungee/api/ServerIcon.java + + public static int[] createServerIcon(BufferedImage awtIcon) { + BufferedImage icon = awtIcon; + boolean gotScaled = false; + if(icon.getWidth() != 64 || icon.getHeight() != 64) { + icon = new BufferedImage(64, 64, awtIcon.getType()); + Graphics2D g = (Graphics2D) icon.getGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, (awtIcon.getWidth() < 64 || awtIcon.getHeight() < 64) ? + RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR : RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.setBackground(Color.BLACK); + g.clearRect(0, 0, 64, 64); + int ow = awtIcon.getWidth(); + int oh = awtIcon.getHeight(); + int nw, nh; + float aspectRatio = (float)oh / (float)ow; + if(aspectRatio >= 1.0f) { + nw = (int)(64 / aspectRatio); + nh = 64; + }else { + nw = 64; + nh = (int)(64 * aspectRatio); + } + g.drawImage(awtIcon, (64 - nw) / 2, (64 - nh) / 2, (64 - nw) / 2 + nw, (64 - nh) / 2 + nh, 0, 0, awtIcon.getWidth(), awtIcon.getHeight(), null); + g.dispose(); + gotScaled = true; + } + int[] pxls = icon.getRGB(0, 0, 64, 64, new int[4096], 0, 64); + if(gotScaled) { + for(int i = 0; i < pxls.length; ++i) { + if((pxls[i] & 0xFFFFFF) == 0) { + pxls[i] = 0; + } + } + } + return pxls; + } +} diff --git a/src/main/java/me/ayunami2000/ayungee/WebSocketProxy.java b/src/main/java/me/ayunami2000/ayungee/WebSocketProxy.java new file mode 100644 index 0000000..13a89fd --- /dev/null +++ b/src/main/java/me/ayunami2000/ayungee/WebSocketProxy.java @@ -0,0 +1,161 @@ +package me.ayunami2000.ayungee; + +import org.java_websocket.WebSocket; +import org.java_websocket.handshake.ClientHandshake; +import org.java_websocket.server.WebSocketServer; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.*; +import java.util.regex.Pattern; + +public class WebSocketProxy extends WebSocketServer { + private static final int maxBuffSize = 33000; // 4096; + + public WebSocketProxy(int port) { + super(new InetSocketAddress(port)); + } + + @Override + public void onOpen(WebSocket conn, ClientHandshake handshake) { + // anti-concurrentmodificationexception + Set snapshotOriginBlacklist = new HashSet<>(Main.originBlacklist); + if (!snapshotOriginBlacklist.isEmpty() || !Main.originWhitelist.isEmpty()) { + String origin = handshake.getFieldValue("Origin"); + if (origin == null) { + conn.close(); + return; + } + if (!Main.originWhitelist.isEmpty() && !Main.originWhitelist.contains(origin)) { + conn.close(); + return; + } + for (Pattern pattern : snapshotOriginBlacklist) { + if (pattern.matcher(origin).matches()) { + conn.close(); + return; + } + } + } + if (Main.forwarded) { + String forwardedIp = handshake.getFieldValue("X-Real-IP"); + if (forwardedIp == null) { + conn.close(); + return; + } + conn.setAttachment(forwardedIp); + } else { + conn.setAttachment(conn.getRemoteSocketAddress().getAddress().toString().split("/")[1]); + } + new Thread(() -> { + try { + Thread.sleep(5000); + } catch (InterruptedException ignored) {} + if (conn.isOpen() && (Main.bans.contains(Main.getIp(conn)) || !Main.clients.containsKey(conn))) conn.close(); + }).start(); + } + + @Override + public void onClose(WebSocket conn, int code, String reason, boolean remote) { + Client selfClient = Main.clients.remove(conn); + if (selfClient != null) { + System.out.println("Player " + selfClient.username + " (" + Main.getIp(conn) + ") left!"); + if (selfClient.socket.isClosed()) { + try { + selfClient.socket.close(); + } catch (IOException e) { + //e.printStackTrace(); + } + } + } + } + + @Override + public void onMessage(WebSocket conn, String s) { + if (!conn.isOpen()) return; + if (s.equals("Accept: MOTD")) { + if (Main.bans.contains(Main.getIp(conn))) { + conn.send("{\"data\":{\"motd\":[\"§cYour IP is §4banned §cfrom this server.\"],\"cache\":true,\"max\":0,\"icon\":false,\"online\":0,\"players\":[\"§4Banned...\"]},\"vers\":\"0.2.0\",\"name\":\"Your IP is banned from this server.\",\"time\":" + Instant.now().toEpochMilli() + ",\"type\":\"motd\",\"brand\":\"Eagtek\",\"uuid\":\"" + UUID.randomUUID().toString() + "\",\"cracked\":true}"); + } else { + conn.send(Main.motdJson); + conn.send(Main.serverIcon); + } + } + conn.close(); + } + + @Override + public void onMessage(WebSocket conn, ByteBuffer message) { + if (Main.bans.contains(Main.getIp(conn))) { + conn.close(); + return; + } + if (!conn.isOpen()) return; + byte[] msg = message.array(); + if (!Main.clients.containsKey(conn)) { + if (msg.length > 3 && msg[1] == (byte) 69) { + if (msg[3] < 3 || msg[3] > 16) { + conn.close(); + return; + } + byte[] uname = new byte[msg[3]]; + if (msg.length < 5 + msg[3] * 2) { + conn.close(); + return; + } + 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)); + 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) { + if (conn.isOpen()) conn.send(data); + } else { + byte[] trueData = new byte[read]; + System.arraycopy(data, 0, trueData, 0, read); + if (conn.isOpen()) conn.send(trueData); + } + } + if (conn.isOpen()) conn.close(); + if (!selfSocket.isClosed()) selfSocket.close(); + } catch (IOException ex) { + conn.close(); + } + }).start(); + msg[1] = (byte) 61; + System.out.println("Player " + username + " (" + Main.getIp(conn) + ") joined!"); + } else { + conn.close(); + return; + } + } + Client currClient = Main.clients.get(conn); + if (currClient.socketOut == null) { + currClient.msgCache.add(message.array()); + } else if (!currClient.socket.isOutputShutdown()) { + try { + currClient.socketOut.write(message.array()); + } catch (IOException ignored) {} + } + } + + @Override + public void onError(WebSocket conn, Exception ex) { + // + } + + @Override + public void onStart() { + setConnectionLostTimeout(0); + } +} \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..d41d1cf --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,29 @@ +# mc server ip +hostname: localhost +# mc server port +port: 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 +# 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 +# for example, +# - "https://g.eags.us" +origins: [] +# motd info +motd: + # the motd itself + lines: + - "Welcome to my" + - "ayungee-powered server!" + # max players (purely visual) + max: 20 + # online players (purely visual) + online: 4 + # name of server + name: An ayungee-powered Eaglercraft server + # players online (purely visual) + players: + - "whar?" \ No newline at end of file diff --git a/src/main/resources/icon.png b/src/main/resources/icon.png new file mode 100644 index 0000000..4fe5567 Binary files /dev/null and b/src/main/resources/icon.png differ diff --git a/target/ayungee-1.0-SNAPSHOT-jar-with-dependencies.jar b/target/ayungee-1.0-SNAPSHOT-jar-with-dependencies.jar new file mode 100644 index 0000000..bdf65e6 Binary files /dev/null and b/target/ayungee-1.0-SNAPSHOT-jar-with-dependencies.jar differ