package launchserver; import com.eclipsesource.json.Json; import com.eclipsesource.json.JsonObject; import launcher.Launcher; import launcher.LauncherAPI; import launcher.client.ClientProfile; import launcher.hasher.HashedDir; import launcher.helper.*; import launcher.serialize.config.ConfigObject; import launcher.serialize.config.TextConfigReader; import launcher.serialize.config.TextConfigWriter; import launcher.serialize.config.entry.*; import launcher.serialize.signed.SignedObjectHolder; import launchserver.auth.AuthException; import launchserver.auth.limiter.AuthLimiter; import launchserver.auth.MySQLSourceConfig; import launchserver.auth.handler.AuthHandler; import launchserver.auth.handler.CachedAuthHandler; import launchserver.auth.handler.FileAuthHandler; import launchserver.auth.limiter.AuthLimiterConfig; import launchserver.auth.limiter.AuthLimiterIPConfig; import launchserver.auth.provider.AuthProvider; import launchserver.auth.provider.DigestAuthProvider; import launchserver.binary.*; import launchserver.command.Command; import launchserver.command.CommandException; import launchserver.command.handler.CommandHandler; import launchserver.command.handler.JLineCommandHandler; import launchserver.command.handler.StdCommandHandler; import launchserver.helpers.HTTPRequestHelper; import launchserver.response.Response; import launchserver.response.Response.Factory; import launchserver.response.ServerSocketHandler; import launchserver.response.ServerSocketHandler.Listener; import launchserver.texture.TextureProvider; import javax.script.*; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.IOException; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URL; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.security.KeyPair; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.util.*; import java.util.Map.Entry; import java.util.concurrent.atomic.AtomicBoolean; import java.util.zip.CRC32; public final class LaunchServer implements Runnable, AutoCloseable { // Constant paths @LauncherAPI public final Path dir; @LauncherAPI public final Path configFile; @LauncherAPI public final Path ipConfigFile; @LauncherAPI public final Path publicKeyFile; @LauncherAPI public final Path privateKeyFile; @LauncherAPI public final Path updatesDir; @LauncherAPI public final Path profilesDir; @LauncherAPI public final AuthLimiter limiter; // Server config @LauncherAPI public final Config config; @LauncherAPI public final RSAPublicKey publicKey; @LauncherAPI public final RSAPrivateKey privateKey; @LauncherAPI public final boolean portable; // Launcher binary @LauncherAPI public final LauncherBinary launcherBinary; @LauncherAPI public final LauncherBinary launcherEXEBinary; // Server @LauncherAPI public final CommandHandler commandHandler; @LauncherAPI public final ServerSocketHandler serverSocketHandler; @LauncherAPI public final ScriptEngine engine = CommonHelper.newScriptEngine(); private final AtomicBoolean started = new AtomicBoolean(false); // Updates and profiles private volatile List<SignedObjectHolder<ClientProfile>> profilesList; private volatile Map<String, SignedObjectHolder<HashedDir>> updatesDirMap; public LaunchServer(Path dir, boolean portable) throws IOException, InvalidKeySpecException { setScriptBindings(); this.portable = portable; // Setup config locations this.dir = dir; configFile = dir.resolve("LaunchServer.cfg"); ipConfigFile = dir.resolve("ListIpConnection.json"); publicKeyFile = dir.resolve("public.key"); privateKeyFile = dir.resolve("private.key"); updatesDir = dir.resolve("updates"); profilesDir = dir.resolve("profiles"); // Set command handler CommandHandler localCommandHandler; if (portable) { localCommandHandler = new StdCommandHandler(this, false); } else { try { Class.forName("jline.Terminal"); // JLine2 available localCommandHandler = new JLineCommandHandler(this); LogHelper.info("JLine2 terminal enabled"); } catch (ClassNotFoundException ignored) { localCommandHandler = new StdCommandHandler(this, true); LogHelper.warning("JLine2 isn't in classpath, using std"); } } commandHandler = localCommandHandler; // Set key pair if (IOHelper.isFile(publicKeyFile) && IOHelper.isFile(privateKeyFile)) { LogHelper.info("Reading RSA keypair"); publicKey = SecurityHelper.toPublicRSAKey(IOHelper.read(publicKeyFile)); privateKey = SecurityHelper.toPrivateRSAKey(IOHelper.read(privateKeyFile)); if (!publicKey.getModulus().equals(privateKey.getModulus())) { throw new IOException("Private and public key modulus mismatch"); } } else { LogHelper.info("Generating RSA keypair"); KeyPair pair = SecurityHelper.genRSAKeyPair(); publicKey = (RSAPublicKey) pair.getPublic(); privateKey = (RSAPrivateKey) pair.getPrivate(); // Write key pair files LogHelper.info("Writing RSA keypair files"); IOHelper.write(publicKeyFile, publicKey.getEncoded()); IOHelper.write(privateKeyFile, privateKey.getEncoded()); } // Print keypair fingerprints CRC32 crc = new CRC32(); crc.update(publicKey.getModulus().toByteArray()); LogHelper.subInfo("Modulus CRC32: 0x%08x", crc.getValue()); // Read LaunchServer config generateConfigIfNotExists(); LogHelper.info("Reading LaunchServer config file"); try (BufferedReader reader = IOHelper.newReader(configFile)) { config = new Config(TextConfigReader.read(reader, true)); } config.verify(); // Read IpList config LogHelper.info("Reading IP Connection List file"); try { AuthLimiterIPConfig.load(ipConfigFile.toFile()); } catch (Exception error) { if (LogHelper.isDebugEnabled()) LogHelper.error(error); } // anti-brutforce limiter = new AuthLimiter(this); // Set launcher EXE binary launcherBinary = new JARLauncherBinary(this); launcherEXEBinary = binary(); syncLauncherBinaries(); if (config.checkServerUpdate) { LogHelper.info("Check updates from KeeperJerry..."); try { URL url = new URL("https://launcher-sashok724.keeperjerry.ru/versions.json"); String file = HTTPRequestHelper.getFile(url); JsonObject object = Json.parse(file).asObject(); String version = object.get("version").asString(); String date = object.get("date").asString(); String note = object.get("note").asString(); if (Launcher.VERSION.equals(version)) { LogHelper.info("You have the latest version!"); } else { LogHelper.info("================================"); LogHelper.info("FOUND NEW VERSION: " + version); LogHelper.info("Release data: " + date); LogHelper.info("Note: " + note); LogHelper.info("================================"); } } catch (Throwable exc) { LogHelper.subWarning("No connection with the update server! Maybe maintenance."); if (LogHelper.isDebugEnabled()) LogHelper.error(exc); } } else { LogHelper.info("Check for updates is disabled!"); } // Sync updates dir if (!IOHelper.isDir(updatesDir)) { Files.createDirectory(updatesDir); } syncUpdatesDir(null); // Sync profiles dir if (!IOHelper.isDir(profilesDir)) { Files.createDirectory(profilesDir); } syncProfilesDir(); // Set server socket thread serverSocketHandler = new ServerSocketHandler(this); } public static void main(String... args) throws Throwable { SecurityHelper.verifyCertificates(LaunchServer.class); JVMHelper.verifySystemProperties(LaunchServer.class, true); LogHelper.addOutput(IOHelper.WORKING_DIR.resolve("LaunchServer.log")); LogHelper.printVersion("LaunchServer"); // Start LaunchServer long start = System.currentTimeMillis(); try { new LaunchServer(IOHelper.WORKING_DIR, false).run(); } catch (Throwable exc) { LogHelper.error(exc); return; } long end = System.currentTimeMillis(); LogHelper.debug("LaunchServer started in %dms", end - start); } public static void addLaunchServerClassBindings(ScriptEngine engine, Map<String, Object> bindings) { Launcher.addClassBinding(engine, bindings, "LaunchServer", LaunchServer.class); // Set auth class bindings Launcher.addClassBinding(engine, bindings, "AuthHandler", AuthHandler.class); Launcher.addClassBinding(engine, bindings, "FileAuthHandler", FileAuthHandler.class); Launcher.addClassBinding(engine, bindings, "CachedAuthHandler", CachedAuthHandler.class); Launcher.addClassBinding(engine, bindings, "AuthProvider", AuthProvider.class); Launcher.addClassBinding(engine, bindings, "DigestAuthProvider", DigestAuthProvider.class); Launcher.addClassBinding(engine, bindings, "MySQLSourceConfig", MySQLSourceConfig.class); Launcher.addClassBinding(engine, bindings, "AuthException", AuthException.class); Launcher.addClassBinding(engine, bindings, "TextureProvider", TextureProvider.class); // Set command class bindings Launcher.addClassBinding(engine, bindings, "Command", Command.class); Launcher.addClassBinding(engine, bindings, "CommandHandler", CommandHandler.class); Launcher.addClassBinding(engine, bindings, "CommandException", CommandException.class); // Set response class bindings Launcher.addClassBinding(engine, bindings, "Response", Response.class); Launcher.addClassBinding(engine, bindings, "ResponseFactory", Factory.class); Launcher.addClassBinding(engine, bindings, "ServerSocketHandlerListener", Listener.class); } @Override public void close() { serverSocketHandler.close(); // Close handlers & providers try { config.authHandler.close(); } catch (IOException e) { LogHelper.error(e); } try { config.authProvider.close(); } catch (IOException e) { LogHelper.error(e); } try { config.textureProvider.close(); } catch (IOException e) { LogHelper.error(e); } // Notify script about closing try { ((Invocable) engine).invokeFunction("close"); } catch (NoSuchMethodException ignored) { // Do nothing if method simply doesn't exist } catch (Throwable exc) { LogHelper.error(exc); } // Print last message before death :( LogHelper.info("LaunchServer stopped"); } @Override public void run() { if (started.getAndSet(true)) { throw new IllegalStateException("LaunchServer has been already started"); } // Load plugin script if exist Path scriptFile = dir.resolve("plugin.js"); if (IOHelper.isFile(scriptFile)) { LogHelper.info("Loading plugin.js script"); try { loadScript(IOHelper.toURL(scriptFile)); } catch (Throwable exc) { throw new RuntimeException("Error while loading plugin.js", exc); } } // Add shutdown hook, then start LaunchServer if (!portable) { JVMHelper.RUNTIME.addShutdownHook(CommonHelper.newThread(null, false, this::close)); CommonHelper.newThread("Command Thread", true, commandHandler).start(); } rebindServerSocket(); } private LauncherBinary binary() { if (config.launch4J) return new EXEL4JLauncherBinary(this); return new EXELauncherBinary(this); } @LauncherAPI public void buildLauncherBinaries() throws IOException { launcherBinary.build(); launcherEXEBinary.build(); } @LauncherAPI @SuppressWarnings("ReturnOfCollectionOrArrayField") public Collection<SignedObjectHolder<ClientProfile>> getProfiles() { return profilesList; } @LauncherAPI public SignedObjectHolder<HashedDir> getUpdateDir(String name) { return updatesDirMap.get(name); } @LauncherAPI public Set<Entry<String, SignedObjectHolder<HashedDir>>> getUpdateDirs() { return updatesDirMap.entrySet(); } @LauncherAPI public Object loadScript(URL url) throws IOException, ScriptException { LogHelper.debug("Loading server script: '%s'", url); try (BufferedReader reader = IOHelper.newReader(url)) { return engine.eval(reader); } } @LauncherAPI public void rebindServerSocket() { serverSocketHandler.close(); CommonHelper.newThread("Server Socket Thread", false, serverSocketHandler).start(); } @LauncherAPI public void syncLauncherBinaries() throws IOException { LogHelper.info("Syncing launcher binaries"); // Syncing launcher binary LogHelper.subInfo("Syncing launcher binary file"); if (!launcherBinary.sync()) { LogHelper.subWarning("Missing launcher binary file"); } // Syncing launcher EXE binary LogHelper.subInfo("Syncing launcher EXE binary file"); if (!launcherEXEBinary.sync()) { LogHelper.subWarning("Missing launcher EXE binary file"); } } @LauncherAPI public void syncProfilesDir() throws IOException { LogHelper.info("Syncing profiles dir"); List<SignedObjectHolder<ClientProfile>> newProfies = new LinkedList<>(); IOHelper.walk(profilesDir, new ProfilesFileVisitor(newProfies), false); // Sort and set new profiles newProfies.sort(Comparator.comparing(a -> a.object)); profilesList = Collections.unmodifiableList(newProfies); } @LauncherAPI public void syncUpdatesDir(Collection<String> dirs) throws IOException { LogHelper.info("Syncing updates dir"); Map<String, SignedObjectHolder<HashedDir>> newUpdatesDirMap = new HashMap<>(16); try (DirectoryStream<Path> dirStream = Files.newDirectoryStream(updatesDir)) { for (Path updateDir : dirStream) { if (Files.isHidden(updateDir)) { continue; // Skip hidden } // Resolve name and verify is dir String name = IOHelper.getFileName(updateDir); if (!IOHelper.isDir(updateDir)) { LogHelper.subWarning("Not update dir: '%s'", name); continue; } // Add from previous map (it's guaranteed to be non-null) if (dirs != null && !dirs.contains(name)) { SignedObjectHolder<HashedDir> hdir = updatesDirMap.get(name); if (hdir != null) { newUpdatesDirMap.put(name, hdir); continue; } } // Sync and sign update dir LogHelper.subInfo("Syncing '%s' update dir", name); HashedDir updateHDir = new HashedDir(updateDir, null, true, true); newUpdatesDirMap.put(name, new SignedObjectHolder<>(updateHDir, privateKey)); } } updatesDirMap = Collections.unmodifiableMap(newUpdatesDirMap); } private void generateConfigIfNotExists() throws IOException { if (IOHelper.isFile(configFile)) { return; } // Create new config LogHelper.info("Creating LaunchServer config"); Config newConfig; try (BufferedReader reader = IOHelper.newReader(IOHelper.getResourceURL("launchserver/defaults/config.cfg"))) { newConfig = new Config(TextConfigReader.read(reader, false)); } // Set server address if (portable) { LogHelper.warning("Setting LaunchServer address to 'localhost'"); newConfig.setAddress("localhost"); } else { LogHelper.println("LaunchServer address: "); String host = commandHandler.readLine(); if (host.replaceAll(" ", "").isEmpty()) { host = "localhost"; LogHelper.info("Host is not entered, localhost is used"); } newConfig.setAddress(host); } // Write LaunchServer config LogHelper.info("Writing LaunchServer config file"); try (BufferedWriter writer = IOHelper.newWriter(configFile)) { TextConfigWriter.write(newConfig.block, writer, true); } } private void setScriptBindings() { LogHelper.info("Setting up server script engine bindings"); Bindings bindings = engine.getBindings(ScriptContext.ENGINE_SCOPE); bindings.put("server", this); // Add launcher and launchserver class bindings Launcher.addLauncherClassBindings(engine, bindings); addLaunchServerClassBindings(engine, bindings); } public static final class Config extends ConfigObject { @LauncherAPI public final int port; // Handlers & Providers @LauncherAPI public final AuthHandler authHandler; @LauncherAPI public final AuthProvider authProvider; @LauncherAPI public final TextureProvider textureProvider; // AuthLimiter @LauncherAPI public final Boolean authLimit; @LauncherAPI public final AuthLimiterConfig authLimitConfig; // Mirrors @LauncherAPI public final ListConfigEntry mirrors; // Update @LauncherAPI public final boolean checkServerUpdate; // BinaryName @LauncherAPI public final String binaryName; // Misc options @LauncherAPI public final boolean launch4J; @LauncherAPI public final EXEL4JLauncherConfig launch4JConfig; @LauncherAPI public final boolean compress; private final StringConfigEntry address; private final String bindAddress; private Config(BlockConfigEntry block) { super(block); address = block.getEntry("address", StringConfigEntry.class); port = VerifyHelper.verifyInt(block.getEntryValue("port", IntegerConfigEntry.class), VerifyHelper.range(0, 65535), "Illegal LaunchServer port"); bindAddress = block.hasEntry("bindAddress") ? block.getEntryValue("bindAddress", StringConfigEntry.class) : getAddress(); // Limit Autorization authLimit = block.getEntryValue("authLimit", BooleanConfigEntry.class); authLimitConfig = new AuthLimiterConfig(block.getEntry("authLimitConfig", BlockConfigEntry.class)); // Set handlers & providers authHandler = AuthHandler.newHandler(block.getEntryValue("authHandler", StringConfigEntry.class), block.getEntry("authHandlerConfig", BlockConfigEntry.class)); authProvider = AuthProvider.newProvider(block.getEntryValue("authProvider", StringConfigEntry.class), block.getEntry("authProviderConfig", BlockConfigEntry.class)); textureProvider = TextureProvider.newProvider(block.getEntryValue("textureProvider", StringConfigEntry.class), block.getEntry("textureProviderConfig", BlockConfigEntry.class)); // Check Update checkServerUpdate = block.getEntryValue("checkServerUpdate", BooleanConfigEntry.class); // Mirrors mirrors = block.getEntry("mirrors", ListConfigEntry.class); // Set misc config launch4J = block.getEntryValue("launch4J", BooleanConfigEntry.class); launch4JConfig = new EXEL4JLauncherConfig(block.getEntry("launch4JConfig", BlockConfigEntry.class)); binaryName = block.getEntryValue("binaryName", StringConfigEntry.class); compress = block.getEntryValue("compress", BooleanConfigEntry.class); } @LauncherAPI public String getAddress() { return address.getValue(); } @LauncherAPI public void setAddress(String address) { this.address.setValue(address); } @LauncherAPI public String getBindAddress() { return bindAddress; } @LauncherAPI public SocketAddress getSocketAddress() { return new InetSocketAddress(bindAddress, port); } @LauncherAPI public void verify() { VerifyHelper.verify(getAddress(), VerifyHelper.NOT_EMPTY, "LaunchServer address can't be empty"); } } private final class ProfilesFileVisitor extends SimpleFileVisitor<Path> { private final Collection<SignedObjectHolder<ClientProfile>> result; private ProfilesFileVisitor(Collection<SignedObjectHolder<ClientProfile>> result) { this.result = result; } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { LogHelper.subInfo("Syncing '%s' profile", IOHelper.getFileName(file)); // Read profile ClientProfile profile; try (BufferedReader reader = IOHelper.newReader(file)) { profile = new ClientProfile(TextConfigReader.read(reader, true)); } profile.verify(); // Add SIGNED profile to result list result.add(new SignedObjectHolder<>(profile, privateKey)); return super.visitFile(file, attrs); } } }