diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dbe5ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +build +buildnumber + +Launcher.jar +LauncherAuthlib.jar +LaunchServer.jar \ No newline at end of file diff --git a/LaunchServer/MANIFEST.MF b/LaunchServer/MANIFEST.MF new file mode 100644 index 0000000..006a2bd --- /dev/null +++ b/LaunchServer/MANIFEST.MF @@ -0,0 +1,13 @@ +Manifest-Version: 1.0 +Class-Path: libraries/jansi.jar libraries/jline2.jar libraries/mysql.j + ar libraries/hikaricp/hikaricp.jar libraries/hikaricp/javassist.jar l + ibraries/hikaricp/slf4j-api.jar libraries/hikaricp/slf4j-simple.jar l + ibraries/launch4j/launch4j.jar +Main-Class: launchserver.LaunchServer + +Name: launchserver/ +Sealed: true + +Name: launcher/ +Sealed: true + diff --git a/LaunchServer/resources/launchserver/defaults/config.cfg b/LaunchServer/resources/launchserver/defaults/config.cfg new file mode 100644 index 0000000..42c1461 --- /dev/null +++ b/LaunchServer/resources/launchserver/defaults/config.cfg @@ -0,0 +1,28 @@ +address: "x"; +bindAddress: "0.0.0.0"; +port: 7240; + +# Textures (for clientside) +skinsURL: "http://skins.minecraft.net/MinecraftSkins/%username%.png"; +cloaksURL: "http://skins.minecraft.net/MinecraftCloaks/%username%.png"; + +# Auth handler +authHandler: "textFile"; +authHandlerConfig: { + file: "authHandler.cfg"; + md5UUIDs: false; +}; + +# Auth provider +authProvider: "reject"; +authProviderConfig: { + message: "You need to change auth provider in LaunchServer.cfg"; +}; + +# Launch4J EXE binary building +launch4J: false; + +# Allow collecting some system info (OS, Specs, Java version, etc) +# It doesn't send any personal data. Statistics can be viewed at +# http://launcher.sashok724.net/stats +metrics: true; diff --git a/LaunchServer/resources/launchserver/defaults/profile1.6.4.cfg b/LaunchServer/resources/launchserver/defaults/profile1.6.4.cfg new file mode 100644 index 0000000..ef61ede --- /dev/null +++ b/LaunchServer/resources/launchserver/defaults/profile1.6.4.cfg @@ -0,0 +1,42 @@ +version: "1.6.4"; +assetIndex: "---"; # 1.7.10+ only + +# Runtime-dependent params +dir: "XXXXX"; +assetDir: "asset1.6.4"; + +# Client params +sortIndex: 0; +title: "XXXXX"; +serverAddress: "server.tld"; +serverPort: 25565; + +# Updater and client watch service +update: [ + "servers\\.dat" +]; +updateVerify: [ + "libraries", "natives", "mods", + "minecraft\\.jar", "forge\\.jar" +]; +updateExclusions: [ + # "mods/carpentersblocks", + # "mods/ic2", + # "mods/railcraft" +]; + +# Client launcher params +mainClass: "net.minecraft.launchwrapper.Launch"; +classPath: [ "forge.jar", "minecraft.jar", "libraries" ]; +jvmArgs: [ + "-Dfml.ignorePatchDiscrepancies=true", + "-Dfml.ignoreInvalidMinecraftCertificates=true", + "-Dorg.lwjgl.opengl.Display.allowSoftwareOpenGL=true", + + # Legacy bridge (for 1.6.4 & lower) settings + "-Dlauncher.legacy.skinsURL=http://skins.minecraft.net/MinecraftSkins/%username%.png", + "-Dlauncher.legacy.cloaksURL=http://skins.minecraft.net/MinecraftCloaks/%username%.png" +]; +clientArgs: [ + "--tweakClass", "cpw.mods.fml.common.launcher.FMLTweaker" +]; diff --git a/LaunchServer/resources/launchserver/defaults/profile1.7.10.cfg b/LaunchServer/resources/launchserver/defaults/profile1.7.10.cfg new file mode 100644 index 0000000..1fda1e3 --- /dev/null +++ b/LaunchServer/resources/launchserver/defaults/profile1.7.10.cfg @@ -0,0 +1,38 @@ +version: "1.7.10"; +assetIndex: "1.7.10"; # 1.7.10+ only + +# Runtime-dependent params +dir: "XXXXX"; +assetDir: "asset1.7.10"; + +# Client params +sortIndex: 0; +title: "XXXXX"; +serverAddress: "server.tld"; +serverPort: 25565; + +# Updater and client watch service +update: [ + "servers\\.dat" +]; +updateVerify: [ + "libraries", "natives", "mods", + "minecraft\\.jar", "forge\\.jar" +]; +updateExclusions: [ + # "mods/carpentersblocks", + # "mods/ic2", + # "mods/railcraft" +]; + +# Client launcher params +mainClass: "net.minecraft.launchwrapper.Launch"; +classPath: [ "forge.jar", "minecraft.jar", "libraries" ]; +jvmArgs: [ + "-Dfml.ignorePatchDiscrepancies=true", + "-Dfml.ignoreInvalidMinecraftCertificates=true", + "-Dorg.lwjgl.opengl.Display.allowSoftwareOpenGL=true" +]; +clientArgs: [ + "--tweakClass", "cpw.mods.fml.common.launcher.FMLTweaker" +]; diff --git a/LaunchServer/resources/launchserver/defaults/profile1.7.2.cfg b/LaunchServer/resources/launchserver/defaults/profile1.7.2.cfg new file mode 100644 index 0000000..6f7e99a --- /dev/null +++ b/LaunchServer/resources/launchserver/defaults/profile1.7.2.cfg @@ -0,0 +1,38 @@ +version: "1.7.2"; +assetIndex: "---"; # 1.7.10+ only + +# Runtime-dependent params +dir: "XXXXX"; +assetDir: "asset1.7.2"; + +# Client params +sortIndex: 0; +title: "XXXXX"; +serverAddress: "server.tld"; +serverPort: 25565; + +# Updater and client watch service +update: [ + "servers\\.dat" +]; +updateVerify: [ + "libraries", "natives", "mods", + "minecraft\\.jar", "forge\\.jar" +]; +updateExclusions: [ + # "mods/carpentersblocks", + # "mods/ic2", + # "mods/railcraft" +]; + +# Client launcher params +mainClass: "net.minecraft.launchwrapper.Launch"; +classPath: [ "forge.jar", "minecraft.jar", "libraries" ]; +jvmArgs: [ + "-Dfml.ignorePatchDiscrepancies=true", + "-Dfml.ignoreInvalidMinecraftCertificates=true", + "-Dorg.lwjgl.opengl.Display.allowSoftwareOpenGL=true" +]; +clientArgs: [ + "--tweakClass", "cpw.mods.fml.common.launcher.FMLTweaker" +]; diff --git a/LaunchServer/resources/launchserver/defaults/profile1.8.8.cfg b/LaunchServer/resources/launchserver/defaults/profile1.8.8.cfg new file mode 100644 index 0000000..b41e828 --- /dev/null +++ b/LaunchServer/resources/launchserver/defaults/profile1.8.8.cfg @@ -0,0 +1,30 @@ +version: "1.8.8"; +assetIndex: "1.8.8"; # 1.7.10+ only + +# Runtime-dependent params +dir: "XXXXX"; +assetDir: "asset1.8.8"; + +# Client params +sortIndex: 0; +title: "XXXXX"; +serverAddress: "server.tld"; +serverPort: 25565; + +# Updater and client watch service +update: [ + "servers\\.dat" +]; +updateVerify: [ + "libraries", "natives", + "minecraft\\.jar", "optifine\\.jar" +]; +updateExclusions: []; + +# Client launcher params +mainClass: "net.minecraft.client.main.Main"; +classPath: [ "optifine.jar", "minecraft.jar", "libraries" ]; +jvmArgs: [ + "-Dorg.lwjgl.opengl.Display.allowSoftwareOpenGL=true" +]; +clientArgs: []; diff --git a/LaunchServer/source-testing/LaunchServerWrap.java b/LaunchServer/source-testing/LaunchServerWrap.java new file mode 100644 index 0000000..0af7c53 --- /dev/null +++ b/LaunchServer/source-testing/LaunchServerWrap.java @@ -0,0 +1,10 @@ +package launchserver; + +public final class LaunchServerWrap { + private LaunchServerWrap() { + } + + public static void main(String... args) throws Throwable { + LaunchServer.main(args); // Just for test runtime + } +} diff --git a/LaunchServer/source/LaunchServer.java b/LaunchServer/source/LaunchServer.java new file mode 100644 index 0000000..8261aa9 --- /dev/null +++ b/LaunchServer/source/LaunchServer.java @@ -0,0 +1,550 @@ +package launchserver; + +import javax.script.Bindings; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptException; +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.DirectoryStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +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.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.zip.CRC32; + +import launcher.Launcher; +import launcher.LauncherAPI; +import launcher.client.ClientLauncher; +import launcher.client.ClientProfile; +import launcher.hasher.HashedDir; +import launcher.helper.CommonHelper; +import launcher.helper.IOHelper; +import launcher.helper.JVMHelper; +import launcher.helper.LogHelper; +import launcher.helper.SecurityHelper; +import launcher.helper.VerifyHelper; +import launcher.serialize.config.ConfigObject; +import launcher.serialize.config.TextConfigReader; +import launcher.serialize.config.TextConfigWriter; +import launcher.serialize.config.entry.BlockConfigEntry; +import launcher.serialize.config.entry.BooleanConfigEntry; +import launcher.serialize.config.entry.IntegerConfigEntry; +import launcher.serialize.config.entry.StringConfigEntry; +import launcher.serialize.signed.SignedObjectHolder; +import launchserver.auth.AuthException; +import launchserver.auth.handler.AuthHandler; +import launchserver.auth.handler.CachedAuthHandler; +import launchserver.auth.handler.FileAuthHandler; +import launchserver.auth.provider.AuthProvider; +import launchserver.binary.EXEL4JLauncherBinary; +import launchserver.binary.EXELauncherBinary; +import launchserver.binary.JARLauncherBinary; +import launchserver.binary.LauncherBinary; +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.response.ServerSocketHandler; + +public final class LaunchServer implements Runnable { + // Constant paths + @LauncherAPI public static final Path CONFIG_FILE = IOHelper.WORKING_DIR.resolve("LaunchServer.cfg"); + @LauncherAPI public static final Path PUBLIC_KEY_FILE = IOHelper.WORKING_DIR.resolve("public.key"); + @LauncherAPI public static final Path PRIVATE_KEY_FILE = IOHelper.WORKING_DIR.resolve("private.key"); + @LauncherAPI public static final Path UPDATES_DIR = IOHelper.WORKING_DIR.resolve("updates"); + @LauncherAPI public static final Path PROFILES_DIR = IOHelper.WORKING_DIR.resolve("profiles"); + + // Launcher binary + @LauncherAPI public final LauncherBinary launcherBinary = new JARLauncherBinary(this); + private volatile LauncherBinary launcherEXEBinary; + + // Server + @LauncherAPI public final CommandHandler commandHandler; + @LauncherAPI public final ServerSocketHandler serverSocketHandler; + private final AtomicBoolean started = new AtomicBoolean(false); + private final ScriptEngine engine = CommonHelper.newScriptEngine(); + + // Launcher config + private volatile Config config; + private volatile RSAPublicKey publicKey; + private volatile RSAPrivateKey privateKey; + + // Updates and profiles + private volatile List> profilesList; + private volatile Map> updatesDirMap; + + private LaunchServer() throws IOException, InvalidKeySpecException { + setScriptBindings(); + + // Set command handler + CommandHandler localCommandHandler; + try { + Class.forName("jline.Terminal"); + + // JLine2 available + localCommandHandler = new JLineCommandHandler(this); + LogHelper.info("JLine2 terminal enabled"); + } catch (ClassNotFoundException ignored) { + localCommandHandler = new StdCommandHandler(this); + LogHelper.warning("JLine2 isn't in classpath, using std"); + } + commandHandler = localCommandHandler; + + // Setup + reloadKeyPair(); + reloadConfig(); + hashLauncherBinaries(); + + // Hash updates dir + if (!IOHelper.isDir(UPDATES_DIR)) { + Files.createDirectory(UPDATES_DIR); + } + hashUpdatesDir(null); + + // Hash profiles dir + if (!IOHelper.isDir(PROFILES_DIR)) { + Files.createDirectory(PROFILES_DIR); + } + hashProfilesDir(); + + // Set server socket thread + serverSocketHandler = new ServerSocketHandler(this); + } + + @Override + public void run() { + if (started.getAndSet(true)) { + throw new IllegalStateException("LaunchServer has been already started"); + } + + // Load plugin script if exist + Path scriptFile = IOHelper.WORKING_DIR.resolve("plugin.js"); + if (IOHelper.isFile(scriptFile)) { + LogHelper.info("Loading plugin.js script"); + try { + loadScript(IOHelper.toURL(scriptFile)); + } catch (Throwable exc) { + LogHelper.error(exc); + } + } + + // Add shutdown hook, then start LaunchServer + JVMHelper.RUNTIME.addShutdownHook(CommonHelper.newThread(null, false, this::shutdownHook)); + CommonHelper.newThread("Command Thread", true, commandHandler).start(); + rebindServerSocket(); + } + + @LauncherAPI + public void buildLauncherBinaries() throws IOException { + launcherBinary.build(); + launcherEXEBinary.build(); + } + + @LauncherAPI + public Config getConfig() { + return config; + } + + @LauncherAPI + public LauncherBinary getEXEBinary() { + return launcherEXEBinary; + } + + @LauncherAPI + public RSAPrivateKey getPrivateKey() { + return privateKey; + } + + @LauncherAPI + @SuppressWarnings("ReturnOfCollectionOrArrayField") + public Collection> getProfiles() { + return profilesList; + } + + @LauncherAPI + public RSAPublicKey getPublicKey() { + return publicKey; + } + + @LauncherAPI + public SignedObjectHolder getUpdateDir(String name) { + return updatesDirMap.get(name); + } + + @LauncherAPI + public void hashLauncherBinaries() throws IOException { + LogHelper.info("Hashing launcher binaries"); + + // Hash launcher binary + LogHelper.subInfo("Hashing launcher binary file"); + if (!launcherBinary.hash()) { + LogHelper.subWarning("Missing launcher binary file"); + } + + // Hash launcher EXE binary + LogHelper.subInfo("Hashing launcher EXE binary file"); + if (!launcherEXEBinary.hash()) { + LogHelper.subWarning("Missing launcher EXE binary file"); + } + } + + @LauncherAPI + public void hashProfilesDir() throws IOException { + LogHelper.info("Hashing profiles dir"); + List> newProfies = new LinkedList<>(); + IOHelper.walk(PROFILES_DIR, new ProfilesFileVisitor(newProfies), false); + + // Sort and set new profiles + Collections.sort(newProfies, (a, b) -> a.object.compareTo(b.object)); + profilesList = Collections.unmodifiableList(newProfies); + } + + @LauncherAPI + public void hashUpdatesDir(Collection dirs) throws IOException { + LogHelper.info("Hashing updates dir"); + Map> newUpdatesDirMap = new HashMap<>(16); + try (DirectoryStream dirStream = Files.newDirectoryStream(UPDATES_DIR)) { + 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 hdir = updatesDirMap.get(name); + if (hdir != null) { + newUpdatesDirMap.put(name, hdir); + continue; + } + } + + // Hash and sign update dir + LogHelper.subInfo("Hashing '%s' update dir", name); + HashedDir updateHDir = new HashedDir(updateDir, null); + newUpdatesDirMap.put(name, new SignedObjectHolder<>(updateHDir, privateKey)); + } + } + updatesDirMap = Collections.unmodifiableMap(newUpdatesDirMap); + } + + @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 reloadConfig() throws IOException { + Config oldConfig = config; + + // Create LaunchServer config if not exist + Config newConfig; + if (!IOHelper.isFile(CONFIG_FILE)) { + LogHelper.info("Creating LaunchServer config"); + try (BufferedReader reader = IOHelper.newReader(IOHelper.getResourceURL("launchserver/defaults/config.cfg"))) { + newConfig = new Config(TextConfigReader.read(reader, false)); + } + + // Set server address + LogHelper.println("LaunchServer address: "); + newConfig.setAddress(commandHandler.readLine()); + + // Write LaunchServer config + LogHelper.info("Writing LaunchServer config file"); + try (BufferedWriter writer = IOHelper.newWriter(CONFIG_FILE)) { + TextConfigWriter.write(newConfig.block, writer, true); + } + } + + // Read LaunchServer config (also re-read after setup for RO) + LogHelper.info("Reading LaunchServer config file"); + try (BufferedReader reader = IOHelper.newReader(CONFIG_FILE)) { + newConfig = new Config(TextConfigReader.read(reader, true)); + if (oldConfig != null && !oldConfig.address.equals(newConfig.address)) { + LogHelper.warning("To bind new address, use 'rebind' command"); + } + } + newConfig.verify(); + + // Flush old config providers + if (oldConfig != null) { + // Flush auth handler + try { + config.authHandler.flush(); + } catch (IOException e) { + LogHelper.error(e); + } + + // Flush auth provider + try { + config.authProvider.flush(); + } catch (IOException e) { + LogHelper.error(e); + } + } + + // Apply changes + config = newConfig; + launcherEXEBinary = newConfig.launch4J ? new EXEL4JLauncherBinary(this) : new EXELauncherBinary(this); + } + + @LauncherAPI + public void reloadKeyPair() throws IOException, InvalidKeySpecException { + RSAPublicKey newPublicKey; + RSAPrivateKey newPrivateKey; + if (IOHelper.isFile(PUBLIC_KEY_FILE) && IOHelper.isFile(PRIVATE_KEY_FILE)) { + LogHelper.info("Reading RSA keypair"); + newPublicKey = SecurityHelper.toPublicRSAKey(IOHelper.read(PUBLIC_KEY_FILE)); + newPrivateKey = SecurityHelper.toPrivateRSAKey(IOHelper.read(PRIVATE_KEY_FILE)); + if (!newPublicKey.getModulus().equals(newPrivateKey.getModulus())) { + throw new IOException("Private and public key modulus mismatch"); + } + + // Print keypair fingerprints + CRC32 crc = new CRC32(); + crc.update(newPublicKey.getModulus().toByteArray()); + LogHelper.subInfo("Modulus CRC32: 0x%08x", crc.getValue()); + } else { + LogHelper.info("Generating RSA keypair"); + KeyPair pair = SecurityHelper.genRSAKeyPair(); + newPublicKey = (RSAPublicKey) pair.getPublic(); + newPrivateKey = (RSAPrivateKey) pair.getPrivate(); + + // Write key pair files + LogHelper.info("Writing RSA keypair files"); + IOHelper.write(PUBLIC_KEY_FILE, newPublicKey.getEncoded()); + IOHelper.write(PRIVATE_KEY_FILE, newPrivateKey.getEncoded()); + } + + // Apply changes + publicKey = newPublicKey; + privateKey = newPrivateKey; + } + + 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(bindings); + addLaunchServerClassBindings(bindings); + } + + private void shutdownHook() { + serverSocketHandler.close(); + + // Flush auth handler and provider + try { + config.authHandler.flush(); + } catch (IOException e) { + LogHelper.error(e); + } + try { + config.authProvider.flush(); + } catch (IOException e) { + LogHelper.error(e); + } + + // Print last message before death :( + LogHelper.info("LaunchServer stopped"); + } + + public static void main(String... args) throws Throwable { + JVMHelper.verifySystemProperties(LaunchServer.class); + SecurityHelper.verifyCertificates(LaunchServer.class); + LogHelper.addOutput(IOHelper.WORKING_DIR.resolve("LaunchServer.log")); + LogHelper.printVersion("LaunchServer"); + + // Start LaunchServer + Instant start = Instant.now(); + try { + new LaunchServer().run(); + } catch (Exception e) { + LogHelper.error(e); + return; + } + Instant end = Instant.now(); + LogHelper.debug("LaunchServer started in %dms", Duration.between(start, end).toMillis()); + } + + private static void addLaunchServerClassBindings(Map bindings) { + bindings.put("LaunchServerClass", LaunchServer.class); + + // Set auth class bindings + bindings.put("AuthHandlerClass", AuthHandler.class); + bindings.put("FileAuthHandlerClass", FileAuthHandler.class); + bindings.put("CachedAuthHandlerClass", CachedAuthHandler.class); + bindings.put("AuthProviderClass", AuthProvider.class); + bindings.put("DigestAuthProviderClass", AuthProvider.class); + bindings.put("AuthExceptionClass", AuthException.class); + + // Set command class bindings + bindings.put("CommandClass", Command.class); + bindings.put("CommandHandlerClass", CommandHandler.class); + bindings.put("CommandExceptionClass", CommandException.class); + + // Set response class bindings + bindings.put("ResponseClass", Command.class); + bindings.put("ResponseFactoryClass", CommandHandler.class); + } + + private final class ProfilesFileVisitor extends SimpleFileVisitor { + private final Collection> result; + + private ProfilesFileVisitor(Collection> result) { + this.result = result; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + LogHelper.subInfo("Hashing '%s' profile", IOHelper.getFileName(file)); + + // Read profile + ClientProfile profile; + try (BufferedReader reader = IOHelper.newReader(file)) { + profile = new ClientProfile(TextConfigReader.read(reader, true)); + } + + // Add SIGNED profile to result list + result.add(new SignedObjectHolder<>(profile, privateKey)); + return super.visitFile(file, attrs); + } + } + + public static final class Config extends ConfigObject { + private static final UUID ZERO_UUID = new UUID(0, 0); + + // Instance + @LauncherAPI public final int port; + @LauncherAPI public final boolean metrics; + private final StringConfigEntry address; + private final String bindAddress; + + // Auth + @LauncherAPI public final AuthHandler authHandler; + @LauncherAPI public final AuthProvider authProvider; + + // EXE binary building + @LauncherAPI public final boolean launch4J; + + // Skin system + private final String skinsURL; + private final String cloaksURL; + + private Config(BlockConfigEntry block) { + super(block); + address = block.getEntry("address", StringConfigEntry.class); + port = block.getEntryValue("port", IntegerConfigEntry.class); + bindAddress = block.hasEntry("bindAddress") ? + block.getEntryValue("bindAddress", StringConfigEntry.class) : getAddress(); + metrics = block.getEntryValue("metrics", BooleanConfigEntry.class); + + // Skin system + skinsURL = block.getEntryValue("skinsURL", StringConfigEntry.class); + cloaksURL = block.getEntryValue("cloaksURL", StringConfigEntry.class); + + // Set auth handler and provider + String authHandlerName = block.getEntryValue("authHandler", StringConfigEntry.class); + authHandler = AuthHandler.newHandler(authHandlerName, block.getEntry("authHandlerConfig", BlockConfigEntry.class)); + String authProviderName = block.getEntryValue("authProvider", StringConfigEntry.class); + authProvider = AuthProvider.newProvider(authProviderName, block.getEntry("authProviderConfig", BlockConfigEntry.class)); + + // Set launch4J config + launch4J = block.getEntryValue("launch4J", BooleanConfigEntry.class); + } + + @Override + public void verify() { + VerifyHelper.verifyInt(port, VerifyHelper.range(0, 65535), "Illegal LaunchServer port: " + port); + + // Verify textures info + String skinURL = getSkinURL("skinUsername", ZERO_UUID); + if (skinURL != null) { + IOHelper.verifyURL(skinURL); + } + String cloakURL = getCloakURL("cloakUsername", ZERO_UUID); + if (cloakURL != null) { + IOHelper.verifyURL(cloakURL); + } + + // Verify auth handler and provider + authHandler.verify(); + authProvider.verify(); + } + + @LauncherAPI + public String getAddress() { + return address.getValue(); + } + + @LauncherAPI + public String getBindAddress() { + return bindAddress; + } + + @LauncherAPI + public String getCloakURL(String username, UUID uuid) { + return getTextureURL(cloaksURL, username, uuid); + } + + @LauncherAPI + public String getSkinURL(String username, UUID uuid) { + return getTextureURL(skinsURL, username, uuid); + } + + @LauncherAPI + public SocketAddress getSocketAddress() { + return new InetSocketAddress(bindAddress, port); + } + + @LauncherAPI + public void setAddress(String address) { + this.address.setValue(address); + } + + @LauncherAPI + public static String getTextureURL(String url, String username, UUID uuid) { + if (url.isEmpty()) { + return null; + } + return CommonHelper.replace(url, "username", username, "uuid", uuid.toString(), "hash", ClientLauncher.toHash(uuid)); + } + } +} diff --git a/LaunchServer/source/auth/AuthException.java b/LaunchServer/source/auth/AuthException.java new file mode 100644 index 0000000..36cc84c --- /dev/null +++ b/LaunchServer/source/auth/AuthException.java @@ -0,0 +1,19 @@ +package launchserver.auth; + +import java.io.IOException; + +import launcher.LauncherAPI; + +public final class AuthException extends IOException { + private static final long serialVersionUID = -2586107832847245863L; + + @LauncherAPI + public AuthException(String message) { + super(message); + } + + @Override + public String toString() { + return getMessage(); + } +} diff --git a/LaunchServer/source/auth/handler/AuthHandler.java b/LaunchServer/source/auth/handler/AuthHandler.java new file mode 100644 index 0000000..0d6657a --- /dev/null +++ b/LaunchServer/source/auth/handler/AuthHandler.java @@ -0,0 +1,58 @@ +package launchserver.auth.handler; + +import java.io.Flushable; +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import launcher.LauncherAPI; +import launcher.helper.VerifyHelper; +import launcher.serialize.config.ConfigObject; +import launcher.serialize.config.entry.BlockConfigEntry; + +public abstract class AuthHandler extends ConfigObject implements Flushable { + private static final Map> AUTH_HANDLERS = new ConcurrentHashMap<>(4); + + @LauncherAPI + protected AuthHandler(BlockConfigEntry block) { + super(block); + } + + @LauncherAPI + public abstract UUID auth(String username, String accessToken) throws IOException; + + @LauncherAPI + public abstract UUID checkServer(String username, String serverID) throws IOException; + + @LauncherAPI + public abstract boolean joinServer(String username, String accessToken, String serverID) throws IOException; + + @LauncherAPI + public abstract UUID usernameToUUID(String username) throws IOException; + + @LauncherAPI + public abstract String uuidToUsername(UUID uuid) throws IOException; + + @LauncherAPI + public static AuthHandler newHandler(String name, BlockConfigEntry block) { + Adapter authHandlerAdapter = VerifyHelper.getMapValue(AUTH_HANDLERS, name, + String.format("Unknown auth handler: '%s'", name)); + return authHandlerAdapter.convert(block); + } + + @LauncherAPI + public static void registerHandler(String name, Adapter adapter) { + VerifyHelper.verifyIDName(name); + VerifyHelper.verify(AUTH_HANDLERS.putIfAbsent(name, Objects.requireNonNull(adapter, "adapter")), + a -> a == null, String.format("Auth handler already registered: '%s'", name)); + } + + static { + registerHandler("null", NullAuthHandler::new); + registerHandler("binaryFile", BinaryFileAuthHandler::new); + registerHandler("textFile", TextFileAuthHandler::new); + registerHandler("mysql", MySQLAuthHandler::new); + } +} diff --git a/LaunchServer/source/auth/handler/BinaryFileAuthHandler.java b/LaunchServer/source/auth/handler/BinaryFileAuthHandler.java new file mode 100644 index 0000000..7b2ec15 --- /dev/null +++ b/LaunchServer/source/auth/handler/BinaryFileAuthHandler.java @@ -0,0 +1,41 @@ +package launchserver.auth.handler; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import launcher.helper.IOHelper; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launcher.serialize.config.entry.BlockConfigEntry; + +public final class BinaryFileAuthHandler extends FileAuthHandler { + public BinaryFileAuthHandler(BlockConfigEntry block) { + super(block); + } + + @Override + protected void readAuthFile() throws IOException { + try (HInput input = new HInput(IOHelper.newInput(file))) { + int count = input.readLength(0); + for (int i = 0; i < count; i++) { + UUID uuid = input.readUUID(); + Auth auth = new Auth(input); + addAuth(uuid, auth); + } + } + } + + @Override + protected void writeAuthFile() throws IOException { + Set> entrySet = entrySet(); + try (HOutput output = new HOutput(IOHelper.newOutput(file))) { + output.writeLength(entrySet.size(), 0); + for (Map.Entry entry : entrySet) { + output.writeUUID(entry.getKey()); + entry.getValue().write(output); + } + } + } +} diff --git a/LaunchServer/source/auth/handler/CachedAuthHandler.java b/LaunchServer/source/auth/handler/CachedAuthHandler.java new file mode 100644 index 0000000..767ecea --- /dev/null +++ b/LaunchServer/source/auth/handler/CachedAuthHandler.java @@ -0,0 +1,135 @@ +package launchserver.auth.handler; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +import launcher.LauncherAPI; +import launcher.helper.IOHelper; +import launcher.helper.SecurityHelper; +import launcher.request.auth.JoinServerRequest; +import launcher.serialize.config.entry.BlockConfigEntry; + +public abstract class CachedAuthHandler extends AuthHandler { + private final Map entryCache = new HashMap<>(IOHelper.BUFFER_SIZE); + private final Map usernamesCache = new HashMap<>(IOHelper.BUFFER_SIZE); + + @LauncherAPI + protected CachedAuthHandler(BlockConfigEntry block) { + super(block); + } + + @Override + public final synchronized UUID auth(String username, String accessToken) throws IOException { + Entry entry = getEntry(username); + if (entry == null || !updateAccessToken(entry.uuid, accessToken)) { + return null; // Account doesn't exist + } + + // Update cached access token (and username case) + entry.username = username; + entry.accessToken = accessToken; + return entry.uuid; + } + + @Override + public final synchronized UUID checkServer(String username, String serverID) throws IOException { + Entry entry = getEntry(username); + return entry != null && username.equals(entry.username) && + serverID.equals(entry.serverID) ? entry.uuid : null; + } + + @Override + public final synchronized boolean joinServer(String username, String accessToken, String serverID) throws IOException { + Entry entry = getEntry(username); + if (entry == null || !username.equals(entry.username) || + !accessToken.equals(entry.accessToken) || !updateServerID(entry.uuid, serverID)) { + return false; // Account doesn't exist or invalid access token + } + + // Update cached server ID + entry.serverID = serverID; + return true; + } + + @Override + public final synchronized UUID usernameToUUID(String username) throws IOException { + Entry entry = getEntry(username); + return entry == null ? null : entry.uuid; + } + + @Override + public final synchronized String uuidToUsername(UUID uuid) throws IOException { + Entry entry = getEntry(uuid); + return entry == null ? null : entry.username; + } + + @LauncherAPI + protected void addEntry(Entry entry) { + entryCache.putIfAbsent(entry.uuid, entry); + usernamesCache.put(low(entry.username), entry.uuid); + } + + @LauncherAPI + protected abstract Entry fetchEntry(UUID uuid) throws IOException; + + @LauncherAPI + protected abstract Entry fetchEntry(String username) throws IOException; + + @LauncherAPI + protected abstract boolean updateAccessToken(UUID uuid, String accessToken) throws IOException; + + @LauncherAPI + protected abstract boolean updateServerID(UUID uuid, String serverID) throws IOException; + + private Entry getEntry(UUID uuid) throws IOException { + Entry entry = entryCache.get(uuid); + if (entry == null) { + entry = fetchEntry(uuid); + if (entry != null) { + addEntry(entry); + } + } + return entry; + } + + private Entry getEntry(String username) throws IOException { + UUID uuid = usernamesCache.get(low(username)); + if (uuid != null) { + return getEntry(uuid); + } + + // Fetch entry by username + Entry entry = fetchEntry(username); + if (entry != null) { + addEntry(entry); + } + + // Return what we got + return entry; + } + + private static String low(String username) { + return username.toLowerCase(Locale.US); + } + + public final class Entry { + @LauncherAPI public final UUID uuid; + private String username; + private String accessToken; + private String serverID; + + @LauncherAPI + public Entry(UUID uuid, String username, String accessToken, String serverID) { + this.uuid = Objects.requireNonNull(uuid, "uuid"); + this.username = Objects.requireNonNull(username, "username"); + this.accessToken = accessToken == null ? + null : SecurityHelper.verifyToken(accessToken); + this.serverID = serverID == null ? + null : JoinServerRequest.verifyServerID(serverID); + } + } +} diff --git a/LaunchServer/source/auth/handler/FileAuthHandler.java b/LaunchServer/source/auth/handler/FileAuthHandler.java new file mode 100644 index 0000000..adaf9fb --- /dev/null +++ b/LaunchServer/source/auth/handler/FileAuthHandler.java @@ -0,0 +1,268 @@ +package launchserver.auth.handler; + +import java.io.IOException; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import launcher.LauncherAPI; +import launcher.client.PlayerProfile; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.helper.SecurityHelper; +import launcher.helper.VerifyHelper; +import launcher.request.auth.JoinServerRequest; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launcher.serialize.config.entry.BlockConfigEntry; +import launcher.serialize.config.entry.BooleanConfigEntry; +import launcher.serialize.config.entry.StringConfigEntry; +import launcher.serialize.stream.StreamObject; + +public abstract class FileAuthHandler extends AuthHandler { + @LauncherAPI public final Path file; + @LauncherAPI public final boolean md5UUIDs; + + // Instance + private final SecureRandom random = SecurityHelper.newRandom(); + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + + // Storage + private final Map authsMap = new HashMap<>(IOHelper.BUFFER_SIZE); + private final Map usernamesMap = new HashMap<>(IOHelper.BUFFER_SIZE); + + @LauncherAPI + protected FileAuthHandler(BlockConfigEntry block) { + super(block); + file = IOHelper.toPath(block.getEntryValue("file", StringConfigEntry.class)); + md5UUIDs = block.getEntryValue("md5UUIDs", BooleanConfigEntry.class); + if (IOHelper.isFile(file)) { + LogHelper.info("Reading auth handler file"); + try { + readAuthFile(); + } catch (IOException e) { + LogHelper.error(e); + } + } + } + + @Override + public final UUID auth(String username, String accessToken) { + lock.writeLock().lock(); + try { + UUID uuid = usernameToUUID(username); + Auth auth = authsMap.get(uuid); + + // Not registered? Fix it! + if (auth == null) { + auth = new Auth(username); + + // Generate UUID + uuid = genUUIDFor(username); + authsMap.put(uuid, auth); + usernamesMap.put(low(username), uuid); + } + + // Authenticate + auth.auth(username, accessToken); + return uuid; + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public final UUID checkServer(String username, String serverID) { + lock.writeLock().lock(); + try { + UUID uuid = usernameToUUID(username); + Auth auth = authsMap.get(uuid); + + // Check server (if has such account of course) + return auth != null && auth.checkServer(username, serverID) ? uuid : null; + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public final void flush() throws IOException { + lock.readLock().lock(); + try { + LogHelper.info("Writing auth handler file (%d entries)", authsMap.size()); + writeAuthFile(); + } finally { + lock.readLock().unlock(); + } + } + + @Override + public final boolean joinServer(String username, String accessToken, String serverID) { + lock.writeLock().lock(); + try { + Auth auth = authsMap.get(usernameToUUID(username)); + return auth != null && auth.joinServer(username, accessToken, serverID); + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public final UUID usernameToUUID(String username) { + lock.readLock().lock(); + try { + return usernamesMap.get(low(username)); + } finally { + lock.readLock().unlock(); + } + } + + @Override + public final String uuidToUsername(UUID uuid) { + lock.readLock().lock(); + try { + Auth auth = authsMap.get(uuid); + return auth == null ? null : auth.username; + } finally { + lock.readLock().unlock(); + } + } + + @Override + public final void verify() { + // Do nothing? + } + + @LauncherAPI + public final Set> entrySet() { + return Collections.unmodifiableMap(authsMap).entrySet(); + } + + @LauncherAPI + protected final void addAuth(UUID uuid, Auth entry) throws IOException { + lock.writeLock().lock(); + try { + authsMap.put(uuid, entry); + usernamesMap.put(low(entry.username), uuid); + } finally { + lock.writeLock().unlock(); + } + } + + @LauncherAPI + protected abstract void readAuthFile() throws IOException; + + @LauncherAPI + protected abstract void writeAuthFile() throws IOException; + + private UUID genUUIDFor(String username) { + if (md5UUIDs) { + UUID md5UUID = PlayerProfile.md5UUID(username); + if (!authsMap.containsKey(md5UUID)) { + return md5UUID; + } + LogHelper.warning("MD5 UUID has been already registered, using random: '%s'", username); + } + + // Pick random UUID + UUID uuid; + do { + uuid = new UUID(random.nextLong(), random.nextLong()); + } while (authsMap.containsKey(uuid)); + return uuid; + } + + private static String low(String username) { + return username.toLowerCase(Locale.US); + } + + public static final class Auth extends StreamObject { + private String username; + private String accessToken; + private String serverID; + + @LauncherAPI + public Auth(String username) { + this.username = VerifyHelper.verifyUsername(username); + } + + @LauncherAPI + public Auth(String username, String accessToken, String serverID) { + this(username); + if (accessToken == null && serverID != null) { + throw new IllegalArgumentException("Can't set accessToken while serverID is null"); + } + + // Set and verify accessToken + this.accessToken = accessToken == null ? + null : SecurityHelper.verifyToken(accessToken); + this.serverID = serverID == null ? + null : JoinServerRequest.verifyServerID(serverID); + } + + @LauncherAPI + public Auth(HInput input) throws IOException { + username = VerifyHelper.verifyUsername(input.readASCII(16)); + if (input.readBoolean()) { + accessToken = SecurityHelper.verifyToken(input.readASCII(-SecurityHelper.TOKEN_STRING_LENGTH)); + if (input.readBoolean()) { + serverID = JoinServerRequest.verifyServerID(input.readASCII(41)); + } + } + } + + @Override + public void write(HOutput output) throws IOException { + output.writeASCII(username, 16); + output.writeBoolean(accessToken != null); + if (accessToken != null) { + output.writeASCII(accessToken, -SecurityHelper.TOKEN_STRING_LENGTH); + output.writeBoolean(serverID != null); + if (serverID != null) { + output.writeASCII(serverID, 41); + } + } + } + + @LauncherAPI + public String getAccessToken() { + return accessToken; + } + + @LauncherAPI + public String getServerID() { + return serverID; + } + + @LauncherAPI + public String getUsername() { + return username; + } + + private void auth(String username, String accessToken) { + this.username = username; // Update username case + this.accessToken = accessToken; + serverID = null; + } + + private boolean checkServer(String username, String serverID) { + return username.equals(this.username) && serverID.equals(this.serverID); + } + + private boolean joinServer(String username, String accessToken, String serverID) { + if (!username.equals(this.username) || !accessToken.equals(this.accessToken)) { + return false; // Username or access token mismatch + } + + // Update server ID + this.serverID = serverID; + return true; + } + } +} diff --git a/LaunchServer/source/auth/handler/MySQLAuthHandler.java b/LaunchServer/source/auth/handler/MySQLAuthHandler.java new file mode 100644 index 0000000..f086d38 --- /dev/null +++ b/LaunchServer/source/auth/handler/MySQLAuthHandler.java @@ -0,0 +1,124 @@ +package launchserver.auth.handler; + +import java.io.IOException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; + +import launcher.helper.LogHelper; +import launcher.helper.VerifyHelper; +import launcher.serialize.config.entry.BlockConfigEntry; +import launcher.serialize.config.entry.BooleanConfigEntry; +import launcher.serialize.config.entry.StringConfigEntry; +import launchserver.helper.MySQLSourceConfig; + +public final class MySQLAuthHandler extends CachedAuthHandler { + private final MySQLSourceConfig mySQLHolder; + private final String table; + private final String uuidColumn; + private final String usernameColumn; + private final String accessTokenColumn; + private final String serverIDColumn; + + // Prepared SQL queries + private final String queryAllSQL; + private final String queryByUUIDSQL; + private final String queryByUsernameSQL; + private final String updateServerIDSQL; + private final String updateAccessTokenSQL; + + public MySQLAuthHandler(BlockConfigEntry block) { + super(block); + mySQLHolder = new MySQLSourceConfig("authHandlerPool", block); + table = block.getEntryValue("table", StringConfigEntry.class); + uuidColumn = block.getEntryValue("uuidColumn", StringConfigEntry.class); + usernameColumn = block.getEntryValue("usernameColumn", StringConfigEntry.class); + accessTokenColumn = block.getEntryValue("accessTokenColumn", StringConfigEntry.class); + serverIDColumn = block.getEntryValue("serverIDColumn", StringConfigEntry.class); + + // Prepare SQL queries + queryAllSQL = String.format("SELECT %s, %s, %s, %s FROM %s", + uuidColumn, usernameColumn, accessTokenColumn, serverIDColumn, table); + queryByUUIDSQL = String.format("SELECT %s, %s, %s, %s FROM %s WHERE %s=?", + uuidColumn, usernameColumn, accessTokenColumn, serverIDColumn, table, uuidColumn); + queryByUsernameSQL = String.format("SELECT %s, %s, %s, %s FROM %s WHERE %s=?", + uuidColumn, usernameColumn, accessTokenColumn, serverIDColumn, table, usernameColumn); + updateServerIDSQL = String.format("UPDATE %s SET %s=? WHERE %s=?", table, serverIDColumn, uuidColumn); + updateAccessTokenSQL = String.format("UPDATE %s SET %s=? WHERE %s=?", table, accessTokenColumn, uuidColumn); + + // Fetch all entries + if (block.getEntryValue("fetchAll", BooleanConfigEntry.class)) { + LogHelper.info("Fetching all AuthHandler entries"); + try (Connection c = mySQLHolder.getConnection(); ResultSet set = c.createStatement().executeQuery(queryAllSQL)) { + for (Entry entry = constructEntry(set); entry != null; entry = constructEntry(set)) { + addEntry(entry); + } + } catch (SQLException e) { + LogHelper.error(e); + } + } + } + + @Override + public void flush() { + mySQLHolder.flush(); + } + + @Override + public void verify() { + mySQLHolder.verify(); + VerifyHelper.verifyIDName(table); + VerifyHelper.verifyIDName(uuidColumn); + VerifyHelper.verifyIDName(usernameColumn); + VerifyHelper.verifyIDName(accessTokenColumn); + VerifyHelper.verifyIDName(serverIDColumn); + } + + @Override + protected Entry fetchEntry(String username) throws IOException { + return query(queryByUsernameSQL, username); + } + + @Override + protected Entry fetchEntry(UUID uuid) throws IOException { + return query(queryByUUIDSQL, uuid.toString()); + } + + @Override + protected boolean updateAccessToken(UUID uuid, String accessToken) throws IOException { + return update(updateAccessTokenSQL, uuid.toString(), accessToken); + } + + @Override + protected boolean updateServerID(UUID uuid, String serverID) throws IOException { + return update(updateServerIDSQL, uuid.toString(), serverID); + } + + private Entry constructEntry(ResultSet set) throws SQLException { + return set.next() ? new Entry(UUID.fromString(set.getString(uuidColumn)), set.getString(usernameColumn), + set.getString(accessTokenColumn), set.getString(serverIDColumn)) : null; + } + + private Entry query(String sql, String value) throws IOException { + try (Connection c = mySQLHolder.getConnection(); PreparedStatement s = c.prepareStatement(sql)) { + s.setString(1, value); + try (ResultSet set = s.executeQuery()) { + return constructEntry(set); + } + } catch (SQLException e) { + throw new IOException(e); + } + } + + private boolean update(String sql, String key, String newValue) throws IOException { + try (Connection c = mySQLHolder.getConnection(); PreparedStatement s = c.prepareStatement(sql)) { + s.setString(1, newValue); + s.setString(2, key); + return s.executeUpdate() > 0; + } catch (SQLException e) { + throw new IOException(e); + } + } +} diff --git a/LaunchServer/source/auth/handler/NullAuthHandler.java b/LaunchServer/source/auth/handler/NullAuthHandler.java new file mode 100644 index 0000000..c5d7d2f --- /dev/null +++ b/LaunchServer/source/auth/handler/NullAuthHandler.java @@ -0,0 +1,66 @@ +package launchserver.auth.handler; + +import java.io.IOException; +import java.util.UUID; + +import launcher.LauncherAPI; +import launcher.helper.VerifyHelper; +import launcher.serialize.config.entry.BlockConfigEntry; + +public final class NullAuthHandler extends AuthHandler { + private volatile AuthHandler handler; + + public NullAuthHandler(BlockConfigEntry block) { + super(block); + } + + @Override + public UUID auth(String username, String accessToken) throws IOException { + return getHandler().auth(username, accessToken); + } + + @Override + public UUID checkServer(String username, String serverID) throws IOException { + return getHandler().checkServer(username, serverID); + } + + @Override + public void flush() throws IOException { + AuthHandler handler = this.handler; + if (handler != null) { + handler.flush(); + } + } + + @Override + public boolean joinServer(String username, String accessToken, String serverID) throws IOException { + return getHandler().joinServer(username, accessToken, serverID); + } + + @Override + public UUID usernameToUUID(String username) throws IOException { + return getHandler().usernameToUUID(username); + } + + @Override + public String uuidToUsername(UUID uuid) throws IOException { + return getHandler().uuidToUsername(uuid); + } + + @Override + public void verify() { + AuthHandler handler = this.handler; + if (handler != null) { + handler.verify(); + } + } + + @LauncherAPI + public void setBackend(AuthHandler handler) { + this.handler = handler; + } + + private AuthHandler getHandler() { + return VerifyHelper.verify(handler, a -> a != null, "Backend auth handler wasn't set"); + } +} diff --git a/LaunchServer/source/auth/handler/TextFileAuthHandler.java b/LaunchServer/source/auth/handler/TextFileAuthHandler.java new file mode 100644 index 0000000..b941e59 --- /dev/null +++ b/LaunchServer/source/auth/handler/TextFileAuthHandler.java @@ -0,0 +1,101 @@ +package launchserver.auth.handler; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import launcher.helper.IOHelper; +import launcher.serialize.config.TextConfigReader; +import launcher.serialize.config.TextConfigWriter; +import launcher.serialize.config.entry.BlockConfigEntry; +import launcher.serialize.config.entry.ConfigEntry; +import launcher.serialize.config.entry.StringConfigEntry; + +public final class TextFileAuthHandler extends FileAuthHandler { + public TextFileAuthHandler(BlockConfigEntry block) { + super(block); + } + + @Override + protected void readAuthFile() throws IOException { + BlockConfigEntry authFile; + try (BufferedReader reader = IOHelper.newReader(file)) { + authFile = TextConfigReader.read(reader, false); + } + + // Read auths from config block + Set>> entrySet = authFile.getValue().entrySet(); + for (Map.Entry> entry : entrySet) { + UUID uuid = UUID.fromString(entry.getKey()); + ConfigEntry value = entry.getValue(); + if (value.getType() != ConfigEntry.Type.BLOCK) { + throw new IOException("Unsupported config entry type: " + uuid); + } + + // Get auth entry data + BlockConfigEntry authBlock = (BlockConfigEntry) value; + String username = authBlock.getEntryValue("username", StringConfigEntry.class); + String accessToken = authBlock.hasEntry("accessToken") ? + authBlock.getEntryValue("accessToken", StringConfigEntry.class) : null; + String serverID = authBlock.hasEntry("serverID") ? + authBlock.getEntryValue("serverID", StringConfigEntry.class) : null; + + // Add auth entry + addAuth(uuid, new Auth(username, accessToken, serverID)); + } + } + + @Override + protected void writeAuthFile() throws IOException { + boolean next = false; + + // Write auth blocks to map + Set> entrySet = entrySet(); + Map> map = new LinkedHashMap<>(entrySet.size()); + for (Map.Entry entry : entrySet) { + UUID uuid = entry.getKey(); + Auth auth = entry.getValue(); + + // Set auth entry data + Map> authMap = new LinkedHashMap<>(entrySet.size()); + authMap.put("username", cc(auth.getUsername())); + String accessToken = auth.getAccessToken(); + if (accessToken != null) { + authMap.put("accessToken", cc(accessToken)); + } + String serverID = auth.getServerID(); + if (serverID != null) { + authMap.put("serverID", cc(serverID)); + } + + // Create and add auth block + BlockConfigEntry authBlock = new BlockConfigEntry(authMap, true, 5); + if (next) { + authBlock.setComment(0, "\n"); // Pre-name + } else { + next = true; + } + authBlock.setComment(2, " "); // Pre-value + authBlock.setComment(4, "\n"); // Post-comment + map.put(uuid.toString(), authBlock); + } + + // Write auth handler file + try (BufferedWriter writer = IOHelper.newWriter(file)) { + BlockConfigEntry authFile = new BlockConfigEntry(map, true, 1); + authFile.setComment(0, "\n"); + TextConfigWriter.write(authFile, writer, true); + } + } + + private static StringConfigEntry cc(String value) { + StringConfigEntry entry = new StringConfigEntry(value, true, 4); + entry.setComment(0, "\n\t"); // Pre-name + entry.setComment(2, " "); // Pre-value + return entry; + } +} diff --git a/LaunchServer/source/auth/provider/AcceptAuthProvider.java b/LaunchServer/source/auth/provider/AcceptAuthProvider.java new file mode 100644 index 0000000..446ff02 --- /dev/null +++ b/LaunchServer/source/auth/provider/AcceptAuthProvider.java @@ -0,0 +1,24 @@ +package launchserver.auth.provider; + +import launcher.serialize.config.entry.BlockConfigEntry; + +public final class AcceptAuthProvider extends AuthProvider { + public AcceptAuthProvider(BlockConfigEntry block) { + super(block); + } + + @Override + public String auth(String login, String password) { + return login; // Same as login + } + + @Override + public void flush() { + // Do nothing + } + + @Override + public void verify() { + // Do nothing + } +} \ No newline at end of file diff --git a/LaunchServer/source/auth/provider/AuthProvider.java b/LaunchServer/source/auth/provider/AuthProvider.java new file mode 100644 index 0000000..fcba93b --- /dev/null +++ b/LaunchServer/source/auth/provider/AuthProvider.java @@ -0,0 +1,46 @@ +package launchserver.auth.provider; + +import java.io.Flushable; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +import launcher.LauncherAPI; +import launcher.helper.VerifyHelper; +import launcher.serialize.config.ConfigObject; +import launcher.serialize.config.entry.BlockConfigEntry; + +public abstract class AuthProvider extends ConfigObject implements Flushable { + private static final Map> AUTH_PROVIDERS = new ConcurrentHashMap<>(8); + + @LauncherAPI + protected AuthProvider(BlockConfigEntry block) { + super(block); + } + + @LauncherAPI + public abstract String auth(String login, String password) throws Exception; + + @LauncherAPI + public static AuthProvider newProvider(String name, BlockConfigEntry block) { + VerifyHelper.verifyIDName(name); + Adapter authHandlerAdapter = VerifyHelper.getMapValue(AUTH_PROVIDERS, name, + String.format("Unknown auth provider: '%s'", name)); + return authHandlerAdapter.convert(block); + } + + @LauncherAPI + public static void registerProvider(String name, Adapter adapter) { + VerifyHelper.verify(AUTH_PROVIDERS.putIfAbsent(name, Objects.requireNonNull(adapter, "adapter")), + a -> a == null, String.format("Auth provider already registered: '%s'", name)); + } + + static { + registerProvider("null", NullAuthProvider::new); + registerProvider("accept", AcceptAuthProvider::new); + registerProvider("reject", RejectAuthProvider::new); + registerProvider("mysql", MySQLAuthProvider::new); + registerProvider("http", HTTPAuthProvider::new); + registerProvider("file", FileAuthProvider::new); + } +} diff --git a/LaunchServer/source/auth/provider/DigestAuthProvider.java b/LaunchServer/source/auth/provider/DigestAuthProvider.java new file mode 100644 index 0000000..e5d5d6d --- /dev/null +++ b/LaunchServer/source/auth/provider/DigestAuthProvider.java @@ -0,0 +1,47 @@ +package launchserver.auth.provider; + +import launcher.LauncherAPI; +import launcher.helper.SecurityHelper; +import launcher.serialize.config.entry.BlockConfigEntry; +import launcher.serialize.config.entry.StringConfigEntry; +import launchserver.auth.AuthException; + +public abstract class DigestAuthProvider extends AuthProvider { + private final String digest; + + @LauncherAPI + protected DigestAuthProvider(BlockConfigEntry block) { + super(block); + digest = block.getEntryValue("digest", StringConfigEntry.class); + } + + @Override + @SuppressWarnings("DesignForExtension") + public void verify() { + getDigest(); + } + + @LauncherAPI + public final SecurityHelper.DigestAlgorithm getDigest() { + return SecurityHelper.DigestAlgorithm.byName(digest); + } + + @LauncherAPI + protected final void verifyDigest(String validDigest, String password) throws AuthException { + boolean valid; + SecurityHelper.DigestAlgorithm algorithm = getDigest(); + if (algorithm == SecurityHelper.DigestAlgorithm.PLAIN) { + valid = password.equals(validDigest); + } else if (validDigest == null) { + valid = false; + } else { + byte[] actualDigest = SecurityHelper.digest(getDigest(), password); + valid = SecurityHelper.toHex(actualDigest).equals(validDigest); + } + + // Verify is valid + if (!valid) { + throw new AuthException("Incorrect username or password"); + } + } +} diff --git a/LaunchServer/source/auth/provider/FileAuthProvider.java b/LaunchServer/source/auth/provider/FileAuthProvider.java new file mode 100644 index 0000000..f8eda3a --- /dev/null +++ b/LaunchServer/source/auth/provider/FileAuthProvider.java @@ -0,0 +1,79 @@ +package launchserver.auth.provider; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.HashMap; +import java.util.Map; + +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.serialize.config.entry.BlockConfigEntry; +import launcher.serialize.config.entry.StringConfigEntry; +import launchserver.helper.LineReader; + +public final class FileAuthProvider extends DigestAuthProvider { + private final Path file; + + // Cache + private final Map cache = new HashMap<>(8192); + private final Object cacheLock = new Object(); + private FileTime cacheLastModified; + + public FileAuthProvider(BlockConfigEntry block) { + super(block); + file = IOHelper.toPath(block.getEntryValue("file", StringConfigEntry.class)); + } + + @Override + public String auth(String login, String password) throws IOException { + String validDigest; + synchronized (cacheLock) { + updateCache(); + validDigest = cache.get(login); + } + verifyDigest(validDigest, password); + return login; + } + + @Override + public void flush() { + // Do nothing + } + + private void updateCache() throws IOException { + FileTime lastModified = IOHelper.readAttributes(file).lastModifiedTime(); + if (lastModified.equals(cacheLastModified)) { + return; // Not modified, so cache is up-to-date + } + + // Read file + cache.clear(); + LogHelper.info("Recaching users file: '%s'", file); + try (BufferedReader reader = new LineReader(IOHelper.newReader(file))) { + for (String line = reader.readLine(); line != null; line = reader.readLine()) { + // Get and verify split index + int splitIndex = line.indexOf(':'); + if (splitIndex < 0) { + throw new IOException(String.format("Illegal line in users file: '%s'", line)); + } + + // Split and verify username and password + String username = line.substring(0, splitIndex).trim(); + String password = line.substring(splitIndex + 1).trim(); + if (username.isEmpty() || password.isEmpty()) { + throw new IOException(String.format("Empty username or password in users file: '%s'", line)); + } + + // Try put to cache + if (cache.put(username, password) != null) { + throw new IOException(String.format("Duplicate username in users file: '%s'", username)); + } + } + } + + // Update last modified time + cacheLastModified = lastModified; + } +} diff --git a/LaunchServer/source/auth/provider/HTTPAuthProvider.java b/LaunchServer/source/auth/provider/HTTPAuthProvider.java new file mode 100644 index 0000000..7e7ce0f --- /dev/null +++ b/LaunchServer/source/auth/provider/HTTPAuthProvider.java @@ -0,0 +1,47 @@ +package launchserver.auth.provider; + +import java.io.IOException; +import java.net.URL; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import launcher.helper.CommonHelper; +import launcher.helper.IOHelper; +import launcher.serialize.config.entry.BlockConfigEntry; +import launcher.serialize.config.entry.StringConfigEntry; +import launchserver.auth.AuthException; + +public final class HTTPAuthProvider extends AuthProvider { + private final String url; + private final Pattern response; + + public HTTPAuthProvider(BlockConfigEntry block) { + super(block); + url = block.getEntryValue("url", StringConfigEntry.class); + response = Pattern.compile(block.getEntryValue("response", StringConfigEntry.class)); + } + + @Override + public String auth(String login, String password) throws IOException { + String currentResponse = IOHelper.request(new URL(getFormattedURL(login, password))); + Matcher matcher = response.matcher(currentResponse); + if (!matcher.matches() || matcher.groupCount() < 1) { + throw new AuthException(currentResponse); + } + return matcher.group("username"); + } + + @Override + public void flush() { + // Do nothing + } + + @Override + public void verify() { + IOHelper.verifyURL(getFormattedURL("httpAuthLogin", "httpAuthPassword")); + } + + private String getFormattedURL(String login, String password) { + return CommonHelper.replace(url, "login", login, "password", password); + } +} diff --git a/LaunchServer/source/auth/provider/MySQLAuthProvider.java b/LaunchServer/source/auth/provider/MySQLAuthProvider.java new file mode 100644 index 0000000..e19f9cc --- /dev/null +++ b/LaunchServer/source/auth/provider/MySQLAuthProvider.java @@ -0,0 +1,62 @@ +package launchserver.auth.provider; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import launcher.helper.CommonHelper; +import launcher.helper.VerifyHelper; +import launcher.serialize.config.entry.BlockConfigEntry; +import launcher.serialize.config.entry.ListConfigEntry; +import launcher.serialize.config.entry.StringConfigEntry; +import launchserver.auth.AuthException; +import launchserver.helper.MySQLSourceConfig; + +public final class MySQLAuthProvider extends AuthProvider { + private final MySQLSourceConfig mySQLHolder; + private final String query; + private final String[] queryParams; + + public MySQLAuthProvider(BlockConfigEntry block) { + super(block); + mySQLHolder = new MySQLSourceConfig("authProviderPool", block); + query = block.getEntryValue("query", StringConfigEntry.class); + queryParams = block.getEntry("queryParams", ListConfigEntry.class).stream(StringConfigEntry.class).toArray(String[]::new); + } + + @Override + public String auth(String login, String password) throws SQLException, AuthException { + try (Connection c = mySQLHolder.getConnection()) { + try (PreparedStatement statement = c.prepareStatement(query)) { + String[] replaceParams = { "login", login, "password", password }; + for (int i = 0; i < queryParams.length; i++) { + statement.setString(i + 1, CommonHelper.replace(queryParams[i], replaceParams)); + } + + // Execute SQL query + try (ResultSet set = statement.executeQuery()) { + if (!set.next()) { + throw new AuthException("Incorrect username or password"); + } + + // Get username + return set.getString(1); + } + } + } + } + + @Override + public void flush() { + // Do nothing + } + + @Override + public void verify() { + mySQLHolder.verify(); + + // Verify auth provider-specific + VerifyHelper.verify(query, VerifyHelper.NOT_EMPTY, "MySQL query can't be empty"); + } +} diff --git a/LaunchServer/source/auth/provider/NullAuthProvider.java b/LaunchServer/source/auth/provider/NullAuthProvider.java new file mode 100644 index 0000000..0d3a941 --- /dev/null +++ b/LaunchServer/source/auth/provider/NullAuthProvider.java @@ -0,0 +1,45 @@ +package launchserver.auth.provider; + +import java.io.IOException; + +import launcher.LauncherAPI; +import launcher.helper.VerifyHelper; +import launcher.serialize.config.entry.BlockConfigEntry; + +public final class NullAuthProvider extends AuthProvider { + private volatile AuthProvider provider; + + public NullAuthProvider(BlockConfigEntry block) { + super(block); + } + + @Override + public String auth(String login, String password) throws Exception { + return getProvider().auth(login, password); + } + + @Override + public void flush() throws IOException { + AuthProvider provider = this.provider; + if (provider != null) { + provider.flush(); + } + } + + @Override + public void verify() { + AuthProvider provider = this.provider; + if (provider != null) { + provider.verify(); + } + } + + @LauncherAPI + public void setBackend(AuthProvider provider) { + this.provider = provider; + } + + private AuthProvider getProvider() { + return VerifyHelper.verify(provider, a -> a != null, "Backend auth provider wasn't set"); + } +} diff --git a/LaunchServer/source/auth/provider/RejectAuthProvider.java b/LaunchServer/source/auth/provider/RejectAuthProvider.java new file mode 100644 index 0000000..6773ebd --- /dev/null +++ b/LaunchServer/source/auth/provider/RejectAuthProvider.java @@ -0,0 +1,30 @@ +package launchserver.auth.provider; + +import launcher.helper.VerifyHelper; +import launcher.serialize.config.entry.BlockConfigEntry; +import launcher.serialize.config.entry.StringConfigEntry; +import launchserver.auth.AuthException; + +public final class RejectAuthProvider extends AuthProvider { + private final String message; + + public RejectAuthProvider(BlockConfigEntry block) { + super(block); + message = block.getEntryValue("message", StringConfigEntry.class); + } + + @Override + public String auth(String login, String password) throws AuthException { + throw new AuthException(message); + } + + @Override + public void flush() { + // Do nothing + } + + @Override + public void verify() { + VerifyHelper.verify(message, VerifyHelper.NOT_EMPTY, "Auth error message can't be empty"); + } +} diff --git a/LaunchServer/source/binary/EXEL4JLauncherBinary.java b/LaunchServer/source/binary/EXEL4JLauncherBinary.java new file mode 100644 index 0000000..d4dbc00 --- /dev/null +++ b/LaunchServer/source/binary/EXEL4JLauncherBinary.java @@ -0,0 +1,91 @@ +package launchserver.binary; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +import launcher.LauncherAPI; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import net.sf.launch4j.Builder; +import net.sf.launch4j.Log; +import net.sf.launch4j.config.Config; +import net.sf.launch4j.config.ConfigPersister; +import net.sf.launch4j.config.Jre; + +public final class EXEL4JLauncherBinary extends LauncherBinary { + // URL constants + private static final String DOWNLOAD_URL = "http://www.oracle.com/technetwork/java/javase/downloads/jre8-downloads-2133155.html"; // Oracle JRE 8 + + // File constants + private static final Path EXE_BINARY_FILE = IOHelper.WORKING_DIR.resolve(EXELauncherBinary.EXE_BINARY_FILE); + private static final Path FAVICON_FILE = IOHelper.WORKING_DIR.resolve("favicon.ico"); + + @LauncherAPI + public EXEL4JLauncherBinary(LaunchServer server) { + super(server, EXE_BINARY_FILE); + } + + @Override + public void build() throws IOException { + LogHelper.info("Building launcher EXE binary file (Using Launch4J)"); + + // Start building + Builder builder = new Builder(Launch4JLog.INSTANCE); + try { + builder.build(); + } catch (Throwable e) { + throw new IOException(e); + } + } + + static { + Config config = new Config(); + + // Set string options + config.setChdir("."); + config.setErrTitle("JVM Error"); + config.setDownloadUrl(DOWNLOAD_URL); + + // Set boolean options + config.setPriorityIndex(0); + config.setHeaderTypeIndex(0); + config.setStayAlive(false); + config.setRestartOnCrash(false); + + // Prepare JRE + Jre jre = new Jre(); + jre.setMinVersion("1.8.0"); + jre.setRuntimeBits(Jre.RUNTIME_BITS_64_AND_32); + jre.setJdkPreference(Jre.JDK_PREFERENCE_PREFER_JRE); + config.setJre(jre); + + // Set JAR wrapping options + config.setDontWrapJar(false); + config.setJar(JARLauncherBinary.JAR_BINARY_FILE.toFile()); + config.setOutfile(EXE_BINARY_FILE.toFile()); + if (IOHelper.isFile(FAVICON_FILE)) { + config.setIcon(new File("favicon.ico")); + } else { + LogHelper.warning("Missing favicon.ico file"); + } + + // Return prepared config + ConfigPersister.getInstance().setAntConfig(config, null); + } + + private static final class Launch4JLog extends Log { + private static final Launch4JLog INSTANCE = new Launch4JLog(); + + @Override + public void append(String s) { + LogHelper.subInfo(s); + } + + @Override + public void clear() { + // Do nothing + } + } +} diff --git a/LaunchServer/source/binary/EXELauncherBinary.java b/LaunchServer/source/binary/EXELauncherBinary.java new file mode 100644 index 0000000..15bc9c8 --- /dev/null +++ b/LaunchServer/source/binary/EXELauncherBinary.java @@ -0,0 +1,27 @@ +package launchserver.binary; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import launcher.LauncherAPI; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launchserver.LaunchServer; + +public final class EXELauncherBinary extends LauncherBinary { + @LauncherAPI public static final Path EXE_BINARY_FILE = IOHelper.toPath("Launcher.exe"); + + @LauncherAPI + public EXELauncherBinary(LaunchServer server) { + super(server, IOHelper.WORKING_DIR.resolve(EXE_BINARY_FILE)); + } + + @Override + public void build() throws IOException { + if (IOHelper.isFile(binaryFile)) { + LogHelper.subWarning("Deleting obsolete launcher EXE binary file"); + Files.delete(binaryFile); + } + } +} diff --git a/LaunchServer/source/binary/JARLauncherBinary.java b/LaunchServer/source/binary/JARLauncherBinary.java new file mode 100644 index 0000000..a32d561 --- /dev/null +++ b/LaunchServer/source/binary/JARLauncherBinary.java @@ -0,0 +1,130 @@ +package launchserver.binary; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashMap; +import java.util.Map; +import java.util.jar.JarOutputStream; +import java.util.jar.Pack200; +import java.util.zip.Deflater; +import java.util.zip.GZIPInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import launcher.Launcher; +import launcher.LauncherAPI; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.helper.SecurityHelper; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; + +public final class JARLauncherBinary extends LauncherBinary { + @LauncherAPI public static final Path RUNTIME_DIR = IOHelper.WORKING_DIR.resolve(Launcher.RUNTIME_DIR); + @LauncherAPI public static final Path INIT_SCRIPT_FILE = RUNTIME_DIR.resolve(Launcher.INIT_SCRIPT_FILE); + @LauncherAPI public static final Path JAR_BINARY_FILE = IOHelper.WORKING_DIR.resolve("Launcher.jar"); + + @LauncherAPI + public JARLauncherBinary(LaunchServer server) throws IOException { + super(server, JAR_BINARY_FILE); + tryUnpackRuntime(); + } + + @Override + public void build() throws IOException { + LogHelper.info("Building launcher binary file"); + try (JarOutputStream output = new JarOutputStream(IOHelper.newOutput(JAR_BINARY_FILE))) { + output.setMethod(ZipOutputStream.DEFLATED); + output.setLevel(Deflater.BEST_COMPRESSION); + try (InputStream input = new GZIPInputStream(IOHelper.newInput(IOHelper.getResourceURL("Launcher.pack.gz")), IOHelper.BUFFER_SIZE)) { + Pack200.newUnpacker().unpack(input, output); + } + + // Verify has init script file + if (!IOHelper.isFile(INIT_SCRIPT_FILE)) { + throw new IOException(String.format("Missing init script file ('%s')", Launcher.INIT_SCRIPT_FILE)); + } + + // Write launcher runtime dir + Map runtime = new HashMap<>(IOHelper.BUFFER_SIZE); + IOHelper.walk(RUNTIME_DIR, new RuntimeDirVisitor(output, runtime), false); + + // Create launcher config file + byte[] launcherConfigBytes; + try (ByteArrayOutputStream configArray = IOHelper.newByteArrayOutput()) { + try (HOutput configOutput = new HOutput(configArray)) { + LaunchServer.Config config = server.getConfig(); + new Launcher.Config(config.getAddress(), config.port, server.getPublicKey(), runtime).write(configOutput); + } + launcherConfigBytes = configArray.toByteArray(); + } + + // Write launcher config file + output.putNextEntry(IOHelper.newZipEntry(Launcher.CONFIG_FILE)); + output.write(launcherConfigBytes); + } + } + + @LauncherAPI + public void tryUnpackRuntime() throws IOException { + // Verify is runtime dir unpacked + if (IOHelper.isDir(RUNTIME_DIR)) { + return; // Already unpacked + } + + // Unpack launcher runtime files + Files.createDirectory(RUNTIME_DIR); + LogHelper.info("Unpacking launcher runtime files"); + try (ZipInputStream input = IOHelper.newZipInput(IOHelper.getResourceURL("launchserver/defaults/runtime.zip"))) { + for (ZipEntry entry = input.getNextEntry(); entry != null; entry = input.getNextEntry()) { + if (entry.isDirectory()) { + continue; // Skip dirs + } + + // Unpack runtime file + IOHelper.transfer(input, RUNTIME_DIR.resolve(IOHelper.toPath(entry.getName()))); + } + } + } + + private static final class RuntimeDirVisitor extends SimpleFileVisitor { + private final ZipOutputStream output; + private final Map runtime; + + private RuntimeDirVisitor(ZipOutputStream output, Map runtime) { + this.output = output; + this.runtime = runtime; + } + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + String dirName = IOHelper.toString(RUNTIME_DIR.relativize(dir)); + output.putNextEntry(newEntry(dirName + '/')); + return super.preVisitDirectory(dir, attrs); + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + String fileName = IOHelper.toString(RUNTIME_DIR.relativize(file)); + runtime.put(fileName, SecurityHelper.digest(SecurityHelper.DigestAlgorithm.MD5, file)); + + // Create zip entry and transfer contents + output.putNextEntry(newEntry(fileName)); + IOHelper.transfer(file, output); + + // Return result + return super.visitFile(file, attrs); + } + + private static ZipEntry newEntry(String fileName) { + return IOHelper.newZipEntry(Launcher.RUNTIME_DIR + IOHelper.CROSS_SEPARATOR + fileName); + } + } +} diff --git a/LaunchServer/source/binary/LauncherBinary.java b/LaunchServer/source/binary/LauncherBinary.java new file mode 100644 index 0000000..0235982 --- /dev/null +++ b/LaunchServer/source/binary/LauncherBinary.java @@ -0,0 +1,41 @@ +package launchserver.binary; + +import java.io.IOException; +import java.nio.file.Path; + +import launcher.LauncherAPI; +import launcher.helper.IOHelper; +import launcher.serialize.signed.SignedBytesHolder; +import launchserver.LaunchServer; + +public abstract class LauncherBinary { + @LauncherAPI protected final LaunchServer server; + @LauncherAPI protected final Path binaryFile; + private volatile SignedBytesHolder binary; + + @LauncherAPI + protected LauncherBinary(LaunchServer server, Path binaryFile) { + this.server = server; + this.binaryFile = binaryFile; + } + + @LauncherAPI + public abstract void build() throws IOException; + + @LauncherAPI + public final boolean exists() { + return IOHelper.isFile(binaryFile); + } + + @LauncherAPI + public final SignedBytesHolder getBytes() { + return binary; + } + + @LauncherAPI + public final boolean hash() throws IOException { + boolean exists = exists(); + binary = exists ? new SignedBytesHolder(IOHelper.read(binaryFile), server.getPrivateKey()) : null; + return exists; + } +} diff --git a/LaunchServer/source/command/Command.java b/LaunchServer/source/command/Command.java new file mode 100644 index 0000000..6559811 --- /dev/null +++ b/LaunchServer/source/command/Command.java @@ -0,0 +1,50 @@ +package launchserver.command; + +import java.util.UUID; + +import launcher.LauncherAPI; +import launcher.helper.VerifyHelper; +import launchserver.LaunchServer; + +public abstract class Command { + @LauncherAPI protected final LaunchServer server; + + @LauncherAPI + protected Command(LaunchServer server) { + this.server = server; + } + + @LauncherAPI + public abstract String getArgsDescription(); // " [optional]" + + @LauncherAPI + public abstract String getUsageDescription(); + + @LauncherAPI + public abstract void invoke(String... args) throws Exception; + + @LauncherAPI + protected final void verifyArgs(String[] args, int min) throws CommandException { + if (args.length < min) { + throw new CommandException("Command usage: " + getArgsDescription()); + } + } + + @LauncherAPI + protected static UUID parseUUID(String s) throws CommandException { + try { + return UUID.fromString(s); + } catch (IllegalArgumentException ignored) { + throw new CommandException(String.format("Invalid UUID: '%s'", s)); + } + } + + @LauncherAPI + protected static String parseUsername(String username) throws CommandException { + try { + return VerifyHelper.verifyUsername(username); + } catch (IllegalArgumentException e) { + throw new CommandException(e.getMessage()); + } + } +} diff --git a/LaunchServer/source/command/CommandException.java b/LaunchServer/source/command/CommandException.java new file mode 100644 index 0000000..2de0f2e --- /dev/null +++ b/LaunchServer/source/command/CommandException.java @@ -0,0 +1,22 @@ +package launchserver.command; + +import launcher.LauncherAPI; + +public final class CommandException extends Exception { + private static final long serialVersionUID = -6588814993972117772L; + + @LauncherAPI + public CommandException(String message) { + super(message); + } + + @LauncherAPI + public CommandException(Throwable exc) { + super(exc); + } + + @Override + public String toString() { + return getMessage(); + } +} \ No newline at end of file diff --git a/LaunchServer/source/command/auth/AuthCommand.java b/LaunchServer/source/command/auth/AuthCommand.java new file mode 100644 index 0000000..730615e --- /dev/null +++ b/LaunchServer/source/command/auth/AuthCommand.java @@ -0,0 +1,45 @@ +package launchserver.command.auth; + +import java.util.UUID; + +import launcher.helper.LogHelper; +import launcher.helper.SecurityHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; +import launchserver.command.CommandException; + +public final class AuthCommand extends Command { + public AuthCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return " "; + } + + @Override + public String getUsageDescription() { + return "Try to auth with specified login and password"; + } + + @Override + public void invoke(String... args) throws Exception { + verifyArgs(args, 2); + String login = args[0]; + String password = args[1]; + + // Authenticate + String username = server.getConfig().authProvider.auth(login, password); + + // Authenticate on server (and get UUID) + String accessToken = SecurityHelper.randomStringToken(); + UUID uuid = server.getConfig().authHandler.auth(username, accessToken); + if (uuid == null) { + throw new CommandException("Can't assing UUID (Command)"); + } + + // Print auth successful message + LogHelper.subInfo("UUID: %s, Username: '%s', Access Token: '%s'", uuid, username, accessToken); + } +} diff --git a/LaunchServer/source/command/auth/UUIDToUsernameCommand.java b/LaunchServer/source/command/auth/UUIDToUsernameCommand.java new file mode 100644 index 0000000..a87b2cd --- /dev/null +++ b/LaunchServer/source/command/auth/UUIDToUsernameCommand.java @@ -0,0 +1,40 @@ +package launchserver.command.auth; + +import java.io.IOException; +import java.util.UUID; + +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; +import launchserver.command.CommandException; + +public final class UUIDToUsernameCommand extends Command { + public UUIDToUsernameCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return ""; + } + + @Override + public String getUsageDescription() { + return "Convert player UUID to username"; + } + + @Override + public void invoke(String... args) throws CommandException, IOException { + verifyArgs(args, 1); + UUID uuid = parseUUID(args[0]); + + // Get UUID by username + String username = server.getConfig().authHandler.uuidToUsername(uuid); + if (username == null) { + throw new CommandException("Unknown UUID: " + uuid); + } + + // Print username + LogHelper.subInfo("Username of player %s: '%s'", uuid, username); + } +} diff --git a/LaunchServer/source/command/auth/UsernameToUUIDCommand.java b/LaunchServer/source/command/auth/UsernameToUUIDCommand.java new file mode 100644 index 0000000..3c887a1 --- /dev/null +++ b/LaunchServer/source/command/auth/UsernameToUUIDCommand.java @@ -0,0 +1,40 @@ +package launchserver.command.auth; + +import java.io.IOException; +import java.util.UUID; + +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; +import launchserver.command.CommandException; + +public final class UsernameToUUIDCommand extends Command { + public UsernameToUUIDCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return ""; + } + + @Override + public String getUsageDescription() { + return "Convert player username to UUID"; + } + + @Override + public void invoke(String... args) throws CommandException, IOException { + verifyArgs(args, 1); + String username = parseUsername(args[0]); + + // Get UUID by username + UUID uuid = server.getConfig().authHandler.usernameToUUID(username); + if (uuid == null) { + throw new CommandException(String.format("Unknown username: '%s'", username)); + } + + // Print UUID + LogHelper.subInfo("UUID of player '%s': %s", username, uuid); + } +} diff --git a/LaunchServer/source/command/basic/BuildCommand.java b/LaunchServer/source/command/basic/BuildCommand.java new file mode 100644 index 0000000..be15084 --- /dev/null +++ b/LaunchServer/source/command/basic/BuildCommand.java @@ -0,0 +1,26 @@ +package launchserver.command.basic; + +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class BuildCommand extends Command { + public BuildCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return null; + } + + @Override + public String getUsageDescription() { + return "Build launcher binaries"; + } + + @Override + public void invoke(String... args) throws Exception { + server.buildLauncherBinaries(); + server.hashLauncherBinaries(); + } +} diff --git a/LaunchServer/source/command/basic/ClearCommand.java b/LaunchServer/source/command/basic/ClearCommand.java new file mode 100644 index 0000000..271c510 --- /dev/null +++ b/LaunchServer/source/command/basic/ClearCommand.java @@ -0,0 +1,27 @@ +package launchserver.command.basic; + +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class ClearCommand extends Command { + public ClearCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return null; + } + + @Override + public String getUsageDescription() { + return "Clear terminal"; + } + + @Override + public void invoke(String... args) throws Exception { + server.commandHandler.clear(); + LogHelper.subInfo("Terminal cleared"); + } +} diff --git a/LaunchServer/source/command/basic/DebugCommand.java b/LaunchServer/source/command/basic/DebugCommand.java new file mode 100644 index 0000000..708996d --- /dev/null +++ b/LaunchServer/source/command/basic/DebugCommand.java @@ -0,0 +1,29 @@ +package launchserver.command.basic; + +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class DebugCommand extends Command { + public DebugCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return "[true/false]"; + } + + @Override + public String getUsageDescription() { + return "Enable or disable debug logging at runtime"; + } + + @Override + public void invoke(String... args) { + if (args.length >= 1) { + LogHelper.setDebugEnabled(Boolean.parseBoolean(args[0])); + } + LogHelper.subInfo("Debug enabled: " + LogHelper.isDebugEnabled()); + } +} diff --git a/LaunchServer/source/command/basic/EvalCommand.java b/LaunchServer/source/command/basic/EvalCommand.java new file mode 100644 index 0000000..07886d5 --- /dev/null +++ b/LaunchServer/source/command/basic/EvalCommand.java @@ -0,0 +1,42 @@ +package launchserver.command.basic; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.Path; + +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; +import launchserver.command.CommandException; +import launchserver.helper.LineReader; + +public final class EvalCommand extends Command { + public EvalCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return ""; + } + + @Override + public String getUsageDescription() { + return "Evaluate input file (external flag enabled)"; + } + + @Override + public void invoke(String... args) throws IOException, CommandException { + verifyArgs(args, 1); + + // Evaluate input file + Path file = IOHelper.toPath(args[0]); + LogHelper.subInfo("Evaluating file: '%s'", file); + try (BufferedReader reader = new LineReader(IOHelper.newReader(file))) { + for (String line = reader.readLine(); line != null; line = reader.readLine()) { + server.commandHandler.eval(line, false); + } + } + } +} diff --git a/LaunchServer/source/command/basic/GCCommand.java b/LaunchServer/source/command/basic/GCCommand.java new file mode 100644 index 0000000..3ba2f2a --- /dev/null +++ b/LaunchServer/source/command/basic/GCCommand.java @@ -0,0 +1,35 @@ +package launchserver.command.basic; + +import launcher.helper.JVMHelper; +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class GCCommand extends Command { + public GCCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return null; + } + + @Override + public String getUsageDescription() { + return "Perform Garbage Collection and print memory usage"; + } + + @Override + public void invoke(String... args) throws Exception { + LogHelper.subInfo("Performing full GC"); + JVMHelper.fullGC(); + + // Print memory usage + long max = JVMHelper.RUNTIME.maxMemory() >> 20; + long free = JVMHelper.RUNTIME.freeMemory() >> 20; + long total = JVMHelper.RUNTIME.totalMemory() >> 20; + long used = total - free; + LogHelper.subInfo("Heap usage: %d / %d / %d MiB", used, total, max); + } +} diff --git a/LaunchServer/source/command/basic/HelpCommand.java b/LaunchServer/source/command/basic/HelpCommand.java new file mode 100644 index 0000000..edacfd1 --- /dev/null +++ b/LaunchServer/source/command/basic/HelpCommand.java @@ -0,0 +1,50 @@ +package launchserver.command.basic; + +import java.util.Map; + +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; +import launchserver.command.CommandException; + +public final class HelpCommand extends Command { + public HelpCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return "[command name]"; + } + + @Override + public String getUsageDescription() { + return "Print command usage"; + } + + @Override + public void invoke(String... args) throws CommandException { + if (args.length < 1) { + printCommands(); + return; + } + + // Print command help + printCommand(args[0]); + } + + private void printCommand(String name) throws CommandException { + printCommand(name, server.commandHandler.lookup(name)); + } + + private void printCommands() { + for (Map.Entry entry : server.commandHandler.commandsMap().entrySet()) { + printCommand(entry.getKey(), entry.getValue()); + } + } + + private static void printCommand(String name, Command command) { + String args = command.getArgsDescription(); + LogHelper.subInfo("%s %s - %s", name, args == null ? "[nothing]" : args, command.getUsageDescription()); + } +} diff --git a/LaunchServer/source/command/basic/RebindCommand.java b/LaunchServer/source/command/basic/RebindCommand.java new file mode 100644 index 0000000..531d2c2 --- /dev/null +++ b/LaunchServer/source/command/basic/RebindCommand.java @@ -0,0 +1,25 @@ +package launchserver.command.basic; + +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class RebindCommand extends Command { + public RebindCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return null; + } + + @Override + public String getUsageDescription() { + return "Rebind server socket"; + } + + @Override + public void invoke(String... args) throws Exception { + server.rebindServerSocket(); + } +} diff --git a/LaunchServer/source/command/basic/ReloadConfigCommand.java b/LaunchServer/source/command/basic/ReloadConfigCommand.java new file mode 100644 index 0000000..db47ed1 --- /dev/null +++ b/LaunchServer/source/command/basic/ReloadConfigCommand.java @@ -0,0 +1,25 @@ +package launchserver.command.basic; + +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class ReloadConfigCommand extends Command { + public ReloadConfigCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return null; + } + + @Override + public String getUsageDescription() { + return "Reload LaunchServer.cfg file"; + } + + @Override + public void invoke(String... args) throws Exception { + server.reloadConfig(); + } +} \ No newline at end of file diff --git a/LaunchServer/source/command/basic/ReloadKeyPairCommand.java b/LaunchServer/source/command/basic/ReloadKeyPairCommand.java new file mode 100644 index 0000000..62c3b7b --- /dev/null +++ b/LaunchServer/source/command/basic/ReloadKeyPairCommand.java @@ -0,0 +1,27 @@ +package launchserver.command.basic; + +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class ReloadKeyPairCommand extends Command { + public ReloadKeyPairCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return null; + } + + @Override + public String getUsageDescription() { + return "Reload public.key and private.key files"; + } + + @Override + public void invoke(String... args) throws Exception { + server.reloadKeyPair(); + LogHelper.subInfo("Key pair reloaded"); + } +} \ No newline at end of file diff --git a/LaunchServer/source/command/basic/StopCommand.java b/LaunchServer/source/command/basic/StopCommand.java new file mode 100644 index 0000000..bd27b97 --- /dev/null +++ b/LaunchServer/source/command/basic/StopCommand.java @@ -0,0 +1,27 @@ +package launchserver.command.basic; + +import launcher.helper.JVMHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class StopCommand extends Command { + public StopCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return null; + } + + @Override + public String getUsageDescription() { + return "Stop LaunchServer"; + } + + @Override + @SuppressWarnings("CallToSystemExit") + public void invoke(String... args) { + JVMHelper.RUNTIME.exit(0); + } +} diff --git a/LaunchServer/source/command/basic/VersionCommand.java b/LaunchServer/source/command/basic/VersionCommand.java new file mode 100644 index 0000000..d9fc705 --- /dev/null +++ b/LaunchServer/source/command/basic/VersionCommand.java @@ -0,0 +1,27 @@ +package launchserver.command.basic; + +import launcher.Launcher; +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class VersionCommand extends Command { + public VersionCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return null; + } + + @Override + public String getUsageDescription() { + return "Print LaunchServer version"; + } + + @Override + public void invoke(String... args) throws Exception { + LogHelper.subInfo("LaunchServer version: %s (build #%s)", Launcher.VERSION, Launcher.BUILD); + } +} diff --git a/LaunchServer/source/command/handler/CommandHandler.java b/LaunchServer/source/command/handler/CommandHandler.java new file mode 100644 index 0000000..0f31ebb --- /dev/null +++ b/LaunchServer/source/command/handler/CommandHandler.java @@ -0,0 +1,195 @@ +package launchserver.command.handler; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +import launcher.LauncherAPI; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.helper.VerifyHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; +import launchserver.command.CommandException; +import launchserver.command.auth.AuthCommand; +import launchserver.command.auth.UUIDToUsernameCommand; +import launchserver.command.auth.UsernameToUUIDCommand; +import launchserver.command.basic.BuildCommand; +import launchserver.command.basic.ClearCommand; +import launchserver.command.basic.DebugCommand; +import launchserver.command.basic.EvalCommand; +import launchserver.command.basic.GCCommand; +import launchserver.command.basic.HelpCommand; +import launchserver.command.basic.RebindCommand; +import launchserver.command.basic.ReloadConfigCommand; +import launchserver.command.basic.ReloadKeyPairCommand; +import launchserver.command.basic.StopCommand; +import launchserver.command.basic.VersionCommand; +import launchserver.command.hash.DownloadAssetCommand; +import launchserver.command.hash.DownloadClientCommand; +import launchserver.command.hash.HashBinariesCommand; +import launchserver.command.hash.HashProfilesCommand; +import launchserver.command.hash.HashUpdatesCommand; +import launchserver.command.hash.IndexAssetCommand; +import launchserver.command.hash.UnindexAssetCommand; + +public abstract class CommandHandler implements Runnable { + private final Map commands = new ConcurrentHashMap<>(32); + + protected CommandHandler(LaunchServer server) { + // Register basic commands + register("help", new HelpCommand(server)); + register("version", new VersionCommand(server)); + register("build", new BuildCommand(server)); + register("stop", new StopCommand(server)); + register("rebind", new RebindCommand(server)); + register("reloadConfig", new ReloadConfigCommand(server)); + register("reloadKeyPair", new ReloadKeyPairCommand(server)); + register("eval", new EvalCommand(server)); + register("debug", new DebugCommand(server)); + register("clear", new ClearCommand(server)); + register("gc", new GCCommand(server)); + + // Register hash commands + register("indexAsset", new IndexAssetCommand(server)); + register("unindexAsset", new UnindexAssetCommand(server)); + register("downloadAsset", new DownloadAssetCommand(server)); + register("downloadClient", new DownloadClientCommand(server)); + register("hashBinaries", new HashBinariesCommand(server)); + register("hashUpdates", new HashUpdatesCommand(server)); + register("hashProfiles", new HashProfilesCommand(server)); + + // Register auth commands + register("auth", new AuthCommand(server)); + register("usernameToUUID", new UsernameToUUIDCommand(server)); + register("uuidToUsername", new UUIDToUsernameCommand(server)); + } + + @Override + public final void run() { + try { + readLoop(); + } catch (IOException e) { + LogHelper.error(e); + } + } + + @LauncherAPI + public abstract void bell() throws IOException; + + @LauncherAPI + public abstract void clear() throws IOException; + + @LauncherAPI + public final Map commandsMap() { + return Collections.unmodifiableMap(commands); + } + + @LauncherAPI + public final void eval(String line, boolean bell) { + Instant startTime = Instant.now(); + try { + String[] args = parse(line); + if (args.length == 0) { + return; + } + + // Invoke command + LogHelper.info("Command '%s'", line); + lookup(args[0]).invoke(Arrays.copyOfRange(args, 1, args.length)); + } catch (Exception e) { + LogHelper.error(e); + } + + // Bell if invocation took > 1s + Instant endTime = Instant.now(); + if (bell && Duration.between(startTime, endTime).getSeconds() >= 5) { + try { + bell(); + } catch (IOException e) { + LogHelper.error(e); + } + } + } + + @LauncherAPI + public final Command lookup(String name) throws CommandException { + Command command = commands.get(name); + if (command == null) { + throw new CommandException(String.format("Unknown command: '%s'", name)); + } + return command; + } + + @LauncherAPI + public abstract String readLine() throws IOException; + + @LauncherAPI + public final void register(String name, Command command) { + VerifyHelper.verifyIDName(name); + VerifyHelper.verify(commands.putIfAbsent(name, Objects.requireNonNull(command, "command")), + c -> c == null, String.format("Command has been already registered: '%s'", name)); + } + + private void readLoop() throws IOException { + for (String line = readLine(); line != null; line = readLine()) { + eval(line, true); + } + } + + private static String[] parse(CharSequence line) throws CommandException { + boolean quoted = false; + boolean wasQuoted = false; + + // Read line char by char + Collection result = new LinkedList<>(); + StringBuilder builder = new StringBuilder(IOHelper.BUFFER_SIZE); + for (int i = 0; i < line.length() + 1; i++) { + boolean end = i >= line.length(); + char ch = end ? 0 : line.charAt(i); + + // Maybe we should read next argument? + if (end || !quoted && Character.isWhitespace(ch)) { + if (end && quoted) { // Quotes should be closed + throw new CommandException("Quotes wasn't closed"); + } + + // Empty args are ignored (except if was quoted) + if (wasQuoted || builder.length() > 0) { + result.add(builder.toString()); + } + + // Reset string builder + wasQuoted = false; + builder.setLength(0); + continue; + } + + // Append next char + switch (ch) { + case '"': // "abc"de, "abc""de" also allowed + quoted = !quoted; + wasQuoted = true; + break; + case '\\': // All escapes, including spaces etc + char next = line.charAt(i + 1); + builder.append(next); + i++; + break; + default: // Default char, simply append + builder.append(ch); + break; + } + } + + // Return result as array + return result.toArray(new String[result.size()]); + } +} diff --git a/LaunchServer/source/command/handler/JLineCommandHandler.java b/LaunchServer/source/command/handler/JLineCommandHandler.java new file mode 100644 index 0000000..2c0c3d6 --- /dev/null +++ b/LaunchServer/source/command/handler/JLineCommandHandler.java @@ -0,0 +1,55 @@ +package launchserver.command.handler; + +import java.io.IOException; + +import jline.console.ConsoleReader; +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import org.fusesource.jansi.Ansi; + +public final class JLineCommandHandler extends CommandHandler { + private final ConsoleReader reader; + + public JLineCommandHandler(LaunchServer server) throws IOException { + super(server); + + // Set reader + reader = new ConsoleReader(); + reader.setExpandEvents(false); + + // Replace writer + LogHelper.removeStdOutput(); + LogHelper.addOutput(new JLineOutput()); + } + + @Override + public void bell() throws IOException { + reader.beep(); + } + + @Override + public void clear() throws IOException { + reader.clearScreen(); + } + + @Override + public String readLine() throws IOException { + return reader.readLine("> "); + } + + private final class JLineOutput implements LogHelper.Output { + private final String ANSI_RESET = LogHelper.JANSI ? + Ansi.ansi().reset().toString() : "\u0027[m"; + + @Override + public void println(String message) { + try { + reader.println(ConsoleReader.RESET_LINE + message + ANSI_RESET); + reader.drawLine(); + reader.flush(); + } catch (IOException ignored) { + // Ignored + } + } + } +} diff --git a/LaunchServer/source/command/handler/StdCommandHandler.java b/LaunchServer/source/command/handler/StdCommandHandler.java new file mode 100644 index 0000000..107c29d --- /dev/null +++ b/LaunchServer/source/command/handler/StdCommandHandler.java @@ -0,0 +1,31 @@ +package launchserver.command.handler; + +import java.io.IOException; + +import launcher.helper.IOHelper; +import launchserver.LaunchServer; +import launchserver.helper.LineReader; + +public final class StdCommandHandler extends CommandHandler { + private final LineReader reader; + + public StdCommandHandler(LaunchServer server) { + super(server); + reader = new LineReader(IOHelper.newReader(System.in)); + } + + @Override + public void bell() { + // Do nothing, unsupported + } + + @Override + public void clear() { + throw new UnsupportedOperationException("clear terminal"); + } + + @Override + public String readLine() throws IOException { + return reader.readLine(); + } +} diff --git a/LaunchServer/source/command/hash/DownloadAssetCommand.java b/LaunchServer/source/command/hash/DownloadAssetCommand.java new file mode 100644 index 0000000..26ee5bb --- /dev/null +++ b/LaunchServer/source/command/hash/DownloadAssetCommand.java @@ -0,0 +1,68 @@ +package launchserver.command.hash; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import launcher.client.ClientProfile; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class DownloadAssetCommand extends Command { + private static final String ASSET_URL_MASK = "http://launcher.sashok724.net/download/assets/%s.zip"; + + public DownloadAssetCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return " "; + } + + @Override + public String getUsageDescription() { + return "Download asset dir"; + } + + @Override + public void invoke(String... args) throws Exception { + verifyArgs(args, 2); + ClientProfile.Version version = ClientProfile.Version.byName(args[0]); + String dirName = IOHelper.verifyFileName(args[1]); + Path assetDir = LaunchServer.UPDATES_DIR.resolve(dirName); + + // Create asset dir + LogHelper.subInfo("Creating asset dir: '%s'", dirName); + Files.createDirectory(assetDir); + + // Download required asset + LogHelper.subInfo("Downloading asset, it may take some time"); + unpack(new URL(String.format(ASSET_URL_MASK, version.name)), assetDir); + + // Finished + server.hashUpdatesDir(Collections.singleton(dirName)); + LogHelper.subInfo("Asset successfully downloaded: '%s'", dirName); + } + + public static void unpack(URL url, Path dir) throws IOException { + try (ZipInputStream input = IOHelper.newZipInput(url)) { + for (ZipEntry entry = input.getNextEntry(); entry != null; entry = input.getNextEntry()) { + if (entry.isDirectory()) { + continue; // Skip directories + } + + // Unpack entry + String name = entry.getName(); + LogHelper.subInfo("Downloading file: '%s'", name); + IOHelper.transfer(input, dir.resolve(IOHelper.toPath(name))); + } + } + } +} diff --git a/LaunchServer/source/command/hash/DownloadClientCommand.java b/LaunchServer/source/command/hash/DownloadClientCommand.java new file mode 100644 index 0000000..b322906 --- /dev/null +++ b/LaunchServer/source/command/hash/DownloadClientCommand.java @@ -0,0 +1,71 @@ +package launchserver.command.hash; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +import launcher.client.ClientProfile; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.serialize.config.TextConfigReader; +import launcher.serialize.config.TextConfigWriter; +import launcher.serialize.config.entry.StringConfigEntry; +import launchserver.LaunchServer; +import launchserver.command.Command; +import launchserver.command.CommandException; + +public final class DownloadClientCommand extends Command { + private static final String CLIENT_URL_MASK = "http://launcher.sashok724.net/download/clients/%s.zip"; + + public DownloadClientCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return " "; + } + + @Override + public String getUsageDescription() { + return "Download client dir"; + } + + @Override + public void invoke(String... args) throws IOException, CommandException { + verifyArgs(args, 2); + ClientProfile.Version version = ClientProfile.Version.byName(args[0]); + String dirName = IOHelper.verifyFileName(args[1]); + Path clientDir = LaunchServer.UPDATES_DIR.resolve(args[1]); + + // Create client dir + LogHelper.subInfo("Creating client dir: '%s'", dirName); + Files.createDirectory(clientDir); + + // Download required client + LogHelper.subInfo("Downloading client, it may take some time"); + DownloadAssetCommand.unpack(new URL(String.format(CLIENT_URL_MASK, version.name)), clientDir); + + // Create profile file + LogHelper.subInfo("Creaing profile file: '%s'", dirName); + ClientProfile client; + String profilePath = String.format("launchserver/defaults/profile%s.cfg", version.name); + try (BufferedReader reader = IOHelper.newReader(IOHelper.getResourceURL(profilePath))) { + client = new ClientProfile(TextConfigReader.read(reader, false)); + } + client.setTitle(dirName); + client.block.getEntry("dir", StringConfigEntry.class).setValue(dirName); + try (BufferedWriter writer = IOHelper.newWriter(IOHelper.resolveIncremental(LaunchServer.PROFILES_DIR, dirName, "cfg"))) { + TextConfigWriter.write(client.block, writer, true); + } + + // Finished + server.hashProfilesDir(); + server.hashUpdatesDir(Collections.singleton(dirName)); + LogHelper.subInfo("Client successfully downloaded: '%s'", dirName); + } +} diff --git a/LaunchServer/source/command/hash/HashBinariesCommand.java b/LaunchServer/source/command/hash/HashBinariesCommand.java new file mode 100644 index 0000000..186e7e0 --- /dev/null +++ b/LaunchServer/source/command/hash/HashBinariesCommand.java @@ -0,0 +1,29 @@ +package launchserver.command.hash; + +import java.io.IOException; + +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class HashBinariesCommand extends Command { + public HashBinariesCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return null; + } + + @Override + public String getUsageDescription() { + return "Rehash launcher binaries"; + } + + @Override + public void invoke(String... args) throws IOException { + server.hashLauncherBinaries(); + LogHelper.subInfo("Binaries successfully rehashed"); + } +} diff --git a/LaunchServer/source/command/hash/HashProfilesCommand.java b/LaunchServer/source/command/hash/HashProfilesCommand.java new file mode 100644 index 0000000..011178c --- /dev/null +++ b/LaunchServer/source/command/hash/HashProfilesCommand.java @@ -0,0 +1,29 @@ +package launchserver.command.hash; + +import java.io.IOException; + +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class HashProfilesCommand extends Command { + public HashProfilesCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return null; + } + + @Override + public String getUsageDescription() { + return "Rehash profiles dir"; + } + + @Override + public void invoke(String... args) throws IOException { + server.hashProfilesDir(); + LogHelper.subInfo("Profiles successfully rehashed"); + } +} diff --git a/LaunchServer/source/command/hash/HashUpdatesCommand.java b/LaunchServer/source/command/hash/HashUpdatesCommand.java new file mode 100644 index 0000000..938cc77 --- /dev/null +++ b/LaunchServer/source/command/hash/HashUpdatesCommand.java @@ -0,0 +1,39 @@ +package launchserver.command.hash; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; + +public final class HashUpdatesCommand extends Command { + public HashUpdatesCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return "[subdirs...]"; + } + + @Override + public String getUsageDescription() { + return "Rehash updates dir"; + } + + @Override + public void invoke(String... args) throws IOException { + Set dirs = null; + if (args.length > 0) { // Hash all updates dirs + dirs = new HashSet<>(args.length); + Collections.addAll(dirs, args); + } + + // Hash updates dir + server.hashUpdatesDir(dirs); + LogHelper.subInfo("Updates dir successfully rehashed"); + } +} diff --git a/LaunchServer/source/command/hash/IndexAssetCommand.java b/LaunchServer/source/command/hash/IndexAssetCommand.java new file mode 100644 index 0000000..b0bbced --- /dev/null +++ b/LaunchServer/source/command/hash/IndexAssetCommand.java @@ -0,0 +1,115 @@ +package launchserver.command.hash; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collections; + +import launcher.LauncherAPI; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.helper.SecurityHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; +import launchserver.command.CommandException; +import org.json.JSONObject; + +public final class IndexAssetCommand extends Command { + public static final String INDEXES_DIR = "indexes"; + public static final String OBJECTS_DIR = "objects"; + private static final String JSON_EXTENSION = ".json"; + + public IndexAssetCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return " "; + } + + @Override + public String getUsageDescription() { + return "Index asset dir (1.7.10+)"; + } + + @Override + public void invoke(String... args) throws Exception { + verifyArgs(args, 2); + String inputAssetDirName = IOHelper.verifyFileName(args[0]); + String outputAssetDirName = IOHelper.verifyFileName(args[1]); + Path inputAssetDir = LaunchServer.UPDATES_DIR.resolve(inputAssetDirName); + Path outputAssetDir = LaunchServer.UPDATES_DIR.resolve(outputAssetDirName); + if (outputAssetDir.equals(inputAssetDir)) { + throw new CommandException("Unindexed and indexed asset dirs can't be same"); + } + + // Create new asset dir + LogHelper.subInfo("Creating indexed asset dir: '%s'", outputAssetDirName); + if (!IOHelper.isDir(outputAssetDir)) { + Files.createDirectory(outputAssetDir); + } + + // Index objects + LogHelper.subInfo("Indexing objects"); + JSONObject objects = new JSONObject(); + IOHelper.walk(inputAssetDir, new IndexAssetVisitor(objects, inputAssetDir, outputAssetDir), false); + + // Write index file + LogHelper.subInfo("Writing asset index file: '%s'", outputAssetDirName); + try (BufferedWriter writer = IOHelper.newWriter(resolveIndexFile(outputAssetDir, outputAssetDirName))) { + JSONObject root = new JSONObject(); + root.put(OBJECTS_DIR, objects); + root.write(writer); + } + + // Finished + server.hashUpdatesDir(Collections.singleton(outputAssetDirName)); + LogHelper.subInfo("Asset successfully indexed: '%s'", inputAssetDirName); + } + + @LauncherAPI + public static Path resolveIndexFile(Path assetDir, String name) { + return assetDir.resolve(INDEXES_DIR).resolve(name + JSON_EXTENSION); + } + + @LauncherAPI + public static Path resolveObjectFile(Path assetDir, String hash) { + return assetDir.resolve(OBJECTS_DIR).resolve(hash.substring(0, 2)).resolve(hash); + } + + private static final class IndexAssetVisitor extends SimpleFileVisitor { + private final JSONObject objects; + private final Path inputAssetDir; + private final Path outputAssetDir; + + private IndexAssetVisitor(JSONObject objects, Path inputAssetDir, Path outputAssetDir) { + this.objects = objects; + this.inputAssetDir = inputAssetDir; + this.outputAssetDir = outputAssetDir; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + String name = IOHelper.toString(inputAssetDir.relativize(file)); + LogHelper.subInfo("Indexing: '%s'", name); + + // Calculate SHA-1 hash sum and get size + String hash = SecurityHelper.toHex(SecurityHelper.digest(SecurityHelper.DigestAlgorithm.SHA1, file)); + + // Add to objects + JSONObject object = new JSONObject(); + object.put("size", attrs.size()); + object.put("hash", hash); + objects.put(name, object); + + // Copy file + IOHelper.copy(file, resolveObjectFile(outputAssetDir, hash)); + return super.visitFile(file, attrs); + } + } +} diff --git a/LaunchServer/source/command/hash/UnindexAssetCommand.java b/LaunchServer/source/command/hash/UnindexAssetCommand.java new file mode 100644 index 0000000..d2ca90b --- /dev/null +++ b/LaunchServer/source/command/hash/UnindexAssetCommand.java @@ -0,0 +1,72 @@ +package launchserver.command.hash; + +import java.io.BufferedReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launchserver.LaunchServer; +import launchserver.command.Command; +import launchserver.command.CommandException; +import org.json.JSONObject; +import org.json.JSONTokener; + +public final class UnindexAssetCommand extends Command { + public UnindexAssetCommand(LaunchServer server) { + super(server); + } + + @Override + public String getArgsDescription() { + return " "; + } + + @Override + public String getUsageDescription() { + return "Unindex asset dir (1.7.10+)"; + } + + @Override + public void invoke(String... args) throws Exception { + verifyArgs(args, 3); + String inputAssetDirName = IOHelper.verifyFileName(args[0]); + String indexFileName = IOHelper.verifyFileName(args[1]); + String outputAssetDirName = IOHelper.verifyFileName(args[2]); + Path inputAssetDir = LaunchServer.UPDATES_DIR.resolve(inputAssetDirName); + Path outputAssetDir = LaunchServer.UPDATES_DIR.resolve(outputAssetDirName); + if (outputAssetDir.equals(inputAssetDir)) { + throw new CommandException("Indexed and unindexed asset dirs can't be same"); + } + + // Create new asset dir + LogHelper.subInfo("Creating unindexed asset dir: '%s'", outputAssetDirName); + Files.createDirectory(outputAssetDir); + + // Read JSON file + LogHelper.subInfo("Reading asset index file: '%s'", inputAssetDirName); + JSONObject objects; + try (BufferedReader reader = IOHelper.newReader(IndexAssetCommand.resolveIndexFile(inputAssetDir, indexFileName))) { + objects = new JSONObject(new JSONTokener(reader)).getJSONObject(IndexAssetCommand.OBJECTS_DIR); + } + + // Restore objects + LogHelper.subInfo("Unindexing %d objects", objects.length()); + for (String name : objects.keySet()) { + LogHelper.subInfo("Unindexing: '%s'", name); + + // Get JSON object + JSONObject object = objects.getJSONObject(name); + String hash = object.getString("hash"); + + // Copy hashed file to target + Path source = IndexAssetCommand.resolveObjectFile(inputAssetDir, hash); + IOHelper.copy(source, outputAssetDir.resolve(name)); + } + + // Finished + server.hashUpdatesDir(Collections.singleton(outputAssetDirName)); + LogHelper.subInfo("Asset successfully unindexed: '%s'", inputAssetDirName); + } +} diff --git a/LaunchServer/source/helper/LineReader.java b/LaunchServer/source/helper/LineReader.java new file mode 100644 index 0000000..b5c9ed5 --- /dev/null +++ b/LaunchServer/source/helper/LineReader.java @@ -0,0 +1,36 @@ +package launchserver.helper; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; + +import launcher.LauncherAPI; +import launcher.helper.IOHelper; + +public final class LineReader extends BufferedReader { + @LauncherAPI + public LineReader(Reader in) { + super(in, IOHelper.BUFFER_SIZE); + } + + @Override + public String readLine() throws IOException { + String line; + do { + line = super.readLine(); + if (line == null) { + return null; + } + + // Trim comments + int commentIndex = line.indexOf('#'); + if (commentIndex >= 0) { + line = line.substring(0, commentIndex); + } + + // Trim + line = line.trim(); + } while (line.isEmpty()); + return line; + } +} diff --git a/LaunchServer/source/helper/MySQLSourceConfig.java b/LaunchServer/source/helper/MySQLSourceConfig.java new file mode 100644 index 0000000..6037296 --- /dev/null +++ b/LaunchServer/source/helper/MySQLSourceConfig.java @@ -0,0 +1,110 @@ +package launchserver.helper; + +import javax.sql.DataSource; +import java.io.Flushable; +import java.sql.Connection; +import java.sql.SQLException; + +import com.mysql.jdbc.jdbc2.optional.MysqlDataSource; +import com.zaxxer.hikari.HikariDataSource; +import launcher.LauncherAPI; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.helper.VerifyHelper; +import launcher.serialize.config.ConfigObject; +import launcher.serialize.config.entry.BlockConfigEntry; +import launcher.serialize.config.entry.IntegerConfigEntry; +import launcher.serialize.config.entry.StringConfigEntry; + +public final class MySQLSourceConfig extends ConfigObject implements Flushable { + private static final int MAX_POOL_SIZE = VerifyHelper.verifyInt( + Integer.parseUnsignedInt(System.getProperty("launcher.mysql.maxPoolSize", Integer.toString(10))), + VerifyHelper.POSITIVE, "launcher.mysql.maxPoolSize can't be <= 0"); + private static final int STMT_CACHE_SIZE = VerifyHelper.verifyInt( + Integer.parseUnsignedInt(System.getProperty("launcher.mysql.stmtCacheSize", Integer.toString(250))), + VerifyHelper.NOT_NEGATIVE, "launcher.mysql.stmtCacheSize can't be < 0"); + + // Instance + private final String poolName; + + // Config + private final String address; + private final int port; + private final String username; + private final String password; + private final String database; + + // Cache + private DataSource source; + private boolean hikari; + + @LauncherAPI + public MySQLSourceConfig(String poolName, BlockConfigEntry block) { + super(block); + this.poolName = poolName; + address = block.getEntryValue("address", StringConfigEntry.class); + port = block.getEntryValue("port", IntegerConfigEntry.class); + username = block.getEntryValue("username", StringConfigEntry.class); + password = block.getEntryValue("password", StringConfigEntry.class); + database = block.getEntryValue("database", StringConfigEntry.class); + } + + @Override + public synchronized void flush() { + if (hikari) { // Shutdown hikari pool + ((HikariDataSource) source).close(); + } + } + + @Override + public void verify() { + // Verify MySQL address + VerifyHelper.verify(address, VerifyHelper.NOT_EMPTY, "MySQL address can't be empty"); + VerifyHelper.verify(username, VerifyHelper.NOT_EMPTY, "MySQL username can't be empty"); + VerifyHelper.verify(database, VerifyHelper.NOT_EMPTY, "MySQL database can't be empty"); + VerifyHelper.verifyInt(port, VerifyHelper.range(0, 65535), "Illegal MySQL port: " + port); + + // Don't verify password, it can be empty + } + + @LauncherAPI + public synchronized Connection getConnection() throws SQLException { + if (source == null) { // New data source + MysqlDataSource mysqlSource = new MysqlDataSource(); + mysqlSource.setUseUnicode(true); + mysqlSource.setLoginTimeout(IOHelper.TIMEOUT); + mysqlSource.setCachePrepStmts(true); + mysqlSource.setPrepStmtCacheSize(STMT_CACHE_SIZE); + mysqlSource.setPrepStmtCacheSqlLimit(IOHelper.BUFFER_SIZE); + + // Set credentials + mysqlSource.setServerName(address); + mysqlSource.setPortNumber(port); + mysqlSource.setUser(username); + mysqlSource.setPassword(password); + mysqlSource.setDatabaseName(database); + + // Try using HikariCP + source = mysqlSource; + try { + Class.forName("com.zaxxer.hikari.HikariDataSource"); + hikari = true; // Used for shutdown. Not instanceof because of possible classpath error + + // Set HikariCP pool + HikariDataSource hikariSource = new HikariDataSource(); + hikariSource.setDataSource(source); + + // Set pool settings + hikariSource.setPoolName(poolName); + hikariSource.setMaximumPoolSize(MAX_POOL_SIZE); + + // Replace source with hds + source = hikariSource; + LogHelper.info("HikariCP pooling enabled for '%s'", poolName); + } catch (ClassNotFoundException ignored) { + LogHelper.warning("HikariCP isn't in classpath for '%s'", poolName); + } + } + return source.getConnection(); + } +} diff --git a/LaunchServer/source/response/PingResponse.java b/LaunchServer/source/response/PingResponse.java new file mode 100644 index 0000000..6fa5fba --- /dev/null +++ b/LaunchServer/source/response/PingResponse.java @@ -0,0 +1,18 @@ +package launchserver.response; + +import java.io.IOException; + +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; + +public final class PingResponse extends Response { + public PingResponse(LaunchServer server, HInput input, HOutput output) { + super(server, input, output); + } + + @Override + public void reply() throws IOException { + output.writeUnsignedByte(123); + } +} \ No newline at end of file diff --git a/LaunchServer/source/response/Response.java b/LaunchServer/source/response/Response.java new file mode 100644 index 0000000..dad62b9 --- /dev/null +++ b/LaunchServer/source/response/Response.java @@ -0,0 +1,35 @@ +package launchserver.response; + +import java.io.IOException; + +import launcher.LauncherAPI; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; + +public abstract class Response { + @LauncherAPI protected final LaunchServer server; + @LauncherAPI protected final HInput input; + @LauncherAPI protected final HOutput output; + + @LauncherAPI + protected Response(LaunchServer server, HInput input, HOutput output) { + this.server = server; + this.input = input; + this.output = output; + } + + @LauncherAPI + public abstract void reply() throws Exception; + + @LauncherAPI + protected final void writeNoError(HOutput output) throws IOException { + output.writeString("", 0); + } + + @FunctionalInterface + public interface Factory { + @LauncherAPI + Response newResponse(LaunchServer server, HInput input, HOutput output); + } +} diff --git a/LaunchServer/source/response/ResponseThread.java b/LaunchServer/source/response/ResponseThread.java new file mode 100644 index 0000000..d2d75b0 --- /dev/null +++ b/LaunchServer/source/response/ResponseThread.java @@ -0,0 +1,131 @@ +package launchserver.response; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.Socket; +import java.net.SocketException; + +import launcher.Launcher; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.helper.SecurityHelper; +import launcher.helper.VerifyHelper; +import launcher.request.Request; +import launcher.request.RequestException; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; +import launchserver.response.auth.AuthResponse; +import launchserver.response.auth.CheckServerResponse; +import launchserver.response.auth.JoinServerResponse; +import launchserver.response.profile.BatchProfileByUsernameResponse; +import launchserver.response.profile.ProfileByUUIDResponse; +import launchserver.response.profile.ProfileByUsernameResponse; +import launchserver.response.update.LauncherResponse; +import launchserver.response.update.UpdateResponse; + +public final class ResponseThread implements Runnable { + private static final boolean LOG_CONNECTIONS = Boolean.getBoolean("launcher.logConnections"); + + // Instance + private final LaunchServer server; + private final Socket socket; + + public ResponseThread(LaunchServer server, Socket socket) throws SocketException { + this.server = server; + this.socket = socket; + IOHelper.setSocketFlags(socket); + } + + @Override + public void run() { + LogHelper.debug("Connection from %s", IOHelper.getIP(socket.getRemoteSocketAddress())); + try (InputStream is = socket.getInputStream(); OutputStream os = socket.getOutputStream(); + HInput input = new HInput(is); HOutput output = new HOutput(os)) { + readHandshake(input, output); + try { + respond(input, output); + } catch (RequestException e) { + output.writeString(e.toString(), 0); + } + } catch (Exception e) { + LogHelper.error(e); + } finally { + server.serverSocketHandler.onDisconnected(socket); + IOHelper.close(socket); + } + } + + private void readHandshake(HInput input, HOutput output) throws IOException { + // Verify magic number + int magicNumber = input.readInt(); + if (magicNumber != Launcher.PROTOCOL_MAGIC) { + output.writeBoolean(false); + throw new IOException("Protocol magic mismatch"); + } + + // Verify key modulus + BigInteger keyModulus = input.readBigInteger(SecurityHelper.RSA_KEY_LENGTH + 1); + if (!keyModulus.equals(server.getPrivateKey().getModulus())) { + output.writeBoolean(false); + throw new IOException("Key modulus mismatch"); + } + + // Protocol successfully verified + output.writeBoolean(true); + output.flush(); + } + + private void respond(HInput input, HOutput output) throws Exception { + Request.Type type = Request.Type.read(input); + if (LOG_CONNECTIONS) { + LogHelper.info("Connection from %s: %s", IOHelper.getIP(socket.getRemoteSocketAddress()), type.name()); + } else { + LogHelper.subDebug("Type: " + type.name()); + } + + // Choose response based on type + Response response; + switch (type) { + case PING: + response = new PingResponse(server, input, output); + break; + case AUTH: + response = new AuthResponse(server, input, output); + break; + case JOIN_SERVER: + response = new JoinServerResponse(server, input, output); + break; + case CHECK_SERVER: + response = new CheckServerResponse(server, input, output); + break; + case LAUNCHER: + response = new LauncherResponse(server, input, output); + break; + case UPDATE: + response = new UpdateResponse(server, input, output); + break; + case PROFILE_BY_USERNAME: + response = new ProfileByUsernameResponse(server, input, output); + break; + case PROFILE_BY_UUID: + response = new ProfileByUUIDResponse(server, input, output); + break; + case BATCH_PROFILE_BY_USERNAME: + response = new BatchProfileByUsernameResponse(server, input, output); + break; + case CUSTOM: + String name = VerifyHelper.verifyIDName(input.readASCII(255)); + response = server.serverSocketHandler.newCustomResponse(name, input, output); + break; + default: + throw new AssertionError("Unsupported request type: " + type.name()); + } + + // Reply + response.reply(); + LogHelper.subDebug("Successfully replied"); + } +} diff --git a/LaunchServer/source/response/ServerSocketHandler.java b/LaunchServer/source/response/ServerSocketHandler.java new file mode 100644 index 0000000..5448b9c --- /dev/null +++ b/LaunchServer/source/response/ServerSocketHandler.java @@ -0,0 +1,120 @@ +package launchserver.response; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import launcher.LauncherAPI; +import launcher.helper.CommonHelper; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.helper.VerifyHelper; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; + +public final class ServerSocketHandler implements Runnable, AutoCloseable { + private static final ThreadFactory THREAD_FACTORY = r -> CommonHelper.newThread("Network Thread", true, r); + + // Instance + private final LaunchServer server; + private final AtomicReference serverSocket = new AtomicReference<>(); + private final ExecutorService threadPool = Executors.newCachedThreadPool(THREAD_FACTORY); + + // API + private final Map customResponses = new ConcurrentHashMap<>(2); + private volatile Predicate connectListener; + private volatile Consumer disconnectListener; + + public ServerSocketHandler(LaunchServer server) { + this.server = server; + } + + @Override + public void close() { + ServerSocket socket = serverSocket.getAndSet(null); + if (socket != null) { + LogHelper.info("Closing server socket listener"); + try { + socket.close(); + } catch (IOException e) { + LogHelper.error(e); + } + } + } + + @Override + public void run() { + LogHelper.info("Starting server socket thread"); + try (ServerSocket serverSocket = new ServerSocket()) { + if (!this.serverSocket.compareAndSet(null, serverSocket)) { + throw new IOException("Previous socket wasn'sizet closed"); + } + + // Set socket params + serverSocket.setReuseAddress(true); + serverSocket.setPerformancePreferences(2, 1, 0); + serverSocket.setReceiveBufferSize(IOHelper.BUFFER_SIZE); + serverSocket.bind(server.getConfig().getSocketAddress()); + LogHelper.info("Server socket thread successfully started"); + + // Listen for incoming connections + while (serverSocket.isBound()) { + Socket socket = serverSocket.accept(); + if (connectListener != null && !connectListener.test(socket)) { + IOHelper.close(socket); + continue; + } + + // Filter passed + threadPool.execute(new ResponseThread(server, socket)); + } + } catch (IOException e) { + // Ignore error after close/rebind + if (serverSocket.get() != null) { + LogHelper.error(e); + } + } + } + + @LauncherAPI + public Response newCustomResponse(String name, HInput input, HOutput output) throws IOException { + Response.Factory factory = customResponses.get(name); + if (factory == null) { + throw new IOException(String.format("Unknown custom response: '%s'", name)); + } + return factory.newResponse(server, input, output); + } + + @LauncherAPI + public void registerCustomResponse(String name, Response.Factory factory) { + VerifyHelper.verifyIDName(name); + VerifyHelper.verify(customResponses.putIfAbsent(name, Objects.requireNonNull(factory, "factory")), + c -> c == null, String.format("Custom response has been already registered: '%s'", name)); + } + + @LauncherAPI + public void setConnectListener(Predicate connectListener) { + this.connectListener = connectListener; + } + + @LauncherAPI + public void setDisconnectListener(Consumer disconnectListener) { + this.disconnectListener = disconnectListener; + } + + /*package*/ void onDisconnected(Socket socket) { + if (disconnectListener != null) { + disconnectListener.accept(socket); + } + } +} diff --git a/LaunchServer/source/response/auth/AuthResponse.java b/LaunchServer/source/response/auth/AuthResponse.java new file mode 100644 index 0000000..b6741e1 --- /dev/null +++ b/LaunchServer/source/response/auth/AuthResponse.java @@ -0,0 +1,70 @@ +package launchserver.response.auth; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import java.util.Arrays; +import java.util.UUID; + +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.helper.SecurityHelper; +import launcher.helper.VerifyHelper; +import launcher.request.RequestException; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; +import launchserver.auth.AuthException; +import launchserver.response.Response; +import launchserver.response.profile.ProfileByUUIDResponse; + +public final class AuthResponse extends Response { + public AuthResponse(LaunchServer server, HInput input, HOutput output) { + super(server, input, output); + } + + @Override + public void reply() throws Exception { + String login = input.readString(255); + byte[] encryptedPassword = input.readByteArray(IOHelper.BUFFER_SIZE); + + // Decrypt password + String password; + try { + password = IOHelper.decode(SecurityHelper.newRSADecryptCipher(server.getPrivateKey()).doFinal(encryptedPassword)); + } catch (IllegalBlockSizeException | BadPaddingException ignored) { + throw new RequestException("Password decryption error"); + } + + // Authenticate + LaunchServer.Config config = server.getConfig(); + LogHelper.subDebug("Login: '%s', password: '%s'", login, echo(password.length())); + String username; + try { + username = VerifyHelper.verifyUsername(config.authProvider.auth(login, password)); + } catch (AuthException e) { + throw new RequestException(e); + } catch (Exception e) { + LogHelper.error(e); + throw new RequestException("Internal auth error", e); + } + LogHelper.subDebug("Auth: '%s' -> '%s'", login, username); + + // Authenticate on server (and get UUID) + String accessToken = SecurityHelper.randomStringToken(); + UUID uuid = config.authHandler.auth(username, accessToken); + if (uuid == null) { + throw new RequestException("Can't assign UUID"); + } + writeNoError(output); + + // Write profile and UUID + ProfileByUUIDResponse.getProfile(server, uuid, username).write(output); + output.writeASCII(accessToken, -SecurityHelper.TOKEN_STRING_LENGTH); + } + + private static String echo(int length) { + char[] chars = new char[length]; + Arrays.fill(chars, '*'); + return new String(chars); + } +} diff --git a/LaunchServer/source/response/auth/CheckServerResponse.java b/LaunchServer/source/response/auth/CheckServerResponse.java new file mode 100644 index 0000000..9026ea2 --- /dev/null +++ b/LaunchServer/source/response/auth/CheckServerResponse.java @@ -0,0 +1,39 @@ +package launchserver.response.auth; + +import java.io.IOException; +import java.util.UUID; + +import launcher.helper.LogHelper; +import launcher.helper.VerifyHelper; +import launcher.request.auth.JoinServerRequest; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; +import launchserver.response.Response; +import launchserver.response.profile.ProfileByUUIDResponse; + +public final class CheckServerResponse extends Response { + public CheckServerResponse(LaunchServer server, HInput input, HOutput output) { + super(server, input, output); + } + + @Override + public void reply() throws IOException { + String username = VerifyHelper.verifyUsername(input.readASCII(16)); + String serverID = JoinServerRequest.verifyServerID(input.readASCII(41)); // With minus sign + + // Debug print message + LogHelper.subDebug("Username: '%s', server ID: %s", username, serverID); + + // Check server + UUID uuid = server.getConfig().authHandler.checkServer(username, serverID); + if (uuid == null) { + output.writeBoolean(false); + return; + } + + // Return server ID + output.writeBoolean(true); + ProfileByUUIDResponse.getProfile(server, uuid, username).write(output); + } +} diff --git a/LaunchServer/source/response/auth/JoinServerResponse.java b/LaunchServer/source/response/auth/JoinServerResponse.java new file mode 100644 index 0000000..89fbfbd --- /dev/null +++ b/LaunchServer/source/response/auth/JoinServerResponse.java @@ -0,0 +1,31 @@ +package launchserver.response.auth; + +import java.io.IOException; + +import launcher.helper.LogHelper; +import launcher.helper.SecurityHelper; +import launcher.helper.VerifyHelper; +import launcher.request.auth.JoinServerRequest; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; +import launchserver.response.Response; + +public final class JoinServerResponse extends Response { + public JoinServerResponse(LaunchServer server, HInput input, HOutput output) { + super(server, input, output); + } + + @Override + public void reply() throws IOException { + String username = VerifyHelper.verifyUsername(input.readASCII(16)); + String accessToken = SecurityHelper.verifyToken(input.readASCII(-SecurityHelper.TOKEN_STRING_LENGTH)); + String serverID = JoinServerRequest.verifyServerID(input.readASCII(41)); // With minus sign + + // Debug print message + LogHelper.subDebug("Username: '%s', access token: %s, server ID: %s", username, accessToken, serverID); + + // Try join server with auth manager + output.writeBoolean(server.getConfig().authHandler.joinServer(username, accessToken, serverID)); + } +} diff --git a/LaunchServer/source/response/profile/BatchProfileByUsernameResponse.java b/LaunchServer/source/response/profile/BatchProfileByUsernameResponse.java new file mode 100644 index 0000000..de8a1c5 --- /dev/null +++ b/LaunchServer/source/response/profile/BatchProfileByUsernameResponse.java @@ -0,0 +1,32 @@ +package launchserver.response.profile; + +import java.io.IOException; +import java.util.Arrays; + +import launcher.helper.LogHelper; +import launcher.helper.VerifyHelper; +import launcher.request.uuid.BatchProfileByUsernameRequest; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; +import launchserver.response.Response; + +public final class BatchProfileByUsernameResponse extends Response { + public BatchProfileByUsernameResponse(LaunchServer server, HInput input, HOutput output) { + super(server, input, output); + } + + @Override + public void reply() throws IOException { + String[] usernames = new String[input.readLength(BatchProfileByUsernameRequest.MAX_BATCH_SIZE)]; + for (int i = 0; i < usernames.length; i++) { + usernames[i] = VerifyHelper.verifyUsername(input.readASCII(16)); + } + LogHelper.subDebug("Usernames: " + Arrays.toString(usernames)); + + // Respond with profiles array + for (String username : usernames) { + ProfileByUsernameResponse.writeProfile(server, output, username); + } + } +} diff --git a/LaunchServer/source/response/profile/ProfileByUUIDResponse.java b/LaunchServer/source/response/profile/ProfileByUUIDResponse.java new file mode 100644 index 0000000..513e7ad --- /dev/null +++ b/LaunchServer/source/response/profile/ProfileByUUIDResponse.java @@ -0,0 +1,41 @@ +package launchserver.response.profile; + +import java.io.IOException; +import java.util.UUID; + +import launcher.client.PlayerProfile; +import launcher.helper.LogHelper; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; +import launchserver.response.Response; + +public final class ProfileByUUIDResponse extends Response { + public ProfileByUUIDResponse(LaunchServer server, HInput input, HOutput output) { + super(server, input, output); + } + + @Override + public void reply() throws IOException { + UUID uuid = input.readUUID(); + LogHelper.subDebug("UUID: " + uuid); + + // Verify has such profile + String username = server.getConfig().authHandler.uuidToUsername(uuid); + if (username == null) { + output.writeBoolean(false); + return; + } + + // Write profile + output.writeBoolean(true); + getProfile(server, uuid, username).write(output); + } + + public static PlayerProfile getProfile(LaunchServer server, UUID uuid, String username) { + LaunchServer.Config config = server.getConfig(); + String skinURL = config.getSkinURL(username, uuid); + String cloakURL = config.getCloakURL(username, uuid); + return new PlayerProfile(uuid, username, skinURL, cloakURL); + } +} diff --git a/LaunchServer/source/response/profile/ProfileByUsernameResponse.java b/LaunchServer/source/response/profile/ProfileByUsernameResponse.java new file mode 100644 index 0000000..69a3e4e --- /dev/null +++ b/LaunchServer/source/response/profile/ProfileByUsernameResponse.java @@ -0,0 +1,36 @@ +package launchserver.response.profile; + +import java.io.IOException; +import java.util.UUID; + +import launcher.helper.LogHelper; +import launcher.helper.VerifyHelper; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launchserver.LaunchServer; +import launchserver.response.Response; + +public final class ProfileByUsernameResponse extends Response { + public ProfileByUsernameResponse(LaunchServer server, HInput input, HOutput output) { + super(server, input, output); + } + + @Override + public void reply() throws IOException { + String username = VerifyHelper.verifyUsername(input.readASCII(16)); + LogHelper.subDebug("Username: " + username); + writeProfile(server, output, username); + } + + public static void writeProfile(LaunchServer server, HOutput output, String username) throws IOException { + UUID uuid = server.getConfig().authHandler.usernameToUUID(username); + if (uuid == null) { + output.writeBoolean(false); + return; + } + + // Write profile + output.writeBoolean(true); + ProfileByUUIDResponse.getProfile(server, uuid, username).write(output); + } +} diff --git a/LaunchServer/source/response/update/LauncherResponse.java b/LaunchServer/source/response/update/LauncherResponse.java new file mode 100644 index 0000000..a2d1ea0 --- /dev/null +++ b/LaunchServer/source/response/update/LauncherResponse.java @@ -0,0 +1,45 @@ +package launchserver.response.update; + +import java.io.IOException; +import java.util.Collection; + +import launcher.client.ClientProfile; +import launcher.helper.SecurityHelper; +import launcher.request.RequestException; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launcher.serialize.signed.SignedBytesHolder; +import launcher.serialize.signed.SignedObjectHolder; +import launchserver.LaunchServer; +import launchserver.response.Response; + +public final class LauncherResponse extends Response { + public LauncherResponse(LaunchServer server, HInput input, HOutput output) { + super(server, input, output); + } + + @Override + public void reply() throws IOException { + // Resolve launcher binary + SignedBytesHolder bytes = (input.readBoolean() ? server.getEXEBinary() : server.launcherBinary).getBytes(); + if (bytes == null) { + throw new RequestException("Missing launcher binary"); + } + writeNoError(output); + + // Update launcher binary + output.writeByteArray(bytes.getSign(), -SecurityHelper.RSA_KEY_LENGTH); + output.flush(); + if (input.readBoolean()) { + output.writeByteArray(bytes.getBytes(), 0); + return; // Launcher will be restarted + } + + // Write clients profiles list + Collection> profiles = server.getProfiles(); + output.writeLength(profiles.size(), 0); + for (SignedObjectHolder profile : profiles) { + profile.write(output); + } + } +} diff --git a/LaunchServer/source/response/update/UpdateResponse.java b/LaunchServer/source/response/update/UpdateResponse.java new file mode 100644 index 0000000..06dbefe --- /dev/null +++ b/LaunchServer/source/response/update/UpdateResponse.java @@ -0,0 +1,113 @@ +package launchserver.response.update; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Deque; +import java.util.LinkedList; + +import launcher.hasher.HashedDir; +import launcher.hasher.HashedEntry; +import launcher.helper.IOHelper; +import launcher.helper.LogHelper; +import launcher.request.RequestException; +import launcher.request.update.UpdateRequest; +import launcher.serialize.HInput; +import launcher.serialize.HOutput; +import launcher.serialize.signed.SignedObjectHolder; +import launchserver.LaunchServer; +import launchserver.response.Response; + +public final class UpdateResponse extends Response { + public UpdateResponse(LaunchServer server, HInput input, HOutput output) { + super(server, input, output); + } + + @Override + public void reply() throws IOException { + // Read update dir name + String updateDirName = IOHelper.verifyFileName(input.readString(255)); + SignedObjectHolder hdir = server.getUpdateDir(updateDirName); + if (hdir == null) { + throw new RequestException(String.format("Unknown update dir: %s", updateDirName)); + } + writeNoError(output); + + // Write update hdir + LogHelper.subDebug("Update dir: '%s'", updateDirName); + hdir.write(output); + output.flush(); + + // Prepare variables for actions queue + Path dir = LaunchServer.UPDATES_DIR.resolve(updateDirName); + Deque dirStack = new LinkedList<>(); + dirStack.add(hdir.object); + + // Perform update + UpdateRequest.Action[] actionsSlice = new UpdateRequest.Action[UpdateRequest.MAX_QUEUE_SIZE]; + loop: + while (true) { + // Read actions slice + int length = input.readLength(actionsSlice.length); + for (int i = 0; i < length; i++) { + actionsSlice[i] = new UpdateRequest.Action(input); + } + + // Perform actions + for (int i = 0; i < length; i++) { + UpdateRequest.Action action = actionsSlice[i]; + switch (action.type) { + case CD: + LogHelper.subDebug("CD '%s'", action.name); + + // Get hashed dir (for validation) + HashedEntry hSubdir = dirStack.getLast().getEntry(action.name); + if (hSubdir == null || hSubdir.getType() != HashedEntry.Type.DIR) { + throw new IOException("Unknown hashed dir: " + action.name); + } + dirStack.add((HashedDir) hSubdir); + + // Resolve dir + dir = dir.resolve(action.name); + break; + case GET: + LogHelper.subDebug("GET '%s'", action.name); + + // Get hashed file (for validation) + HashedEntry hFile = dirStack.getLast().getEntry(action.name); + if (hFile == null || hFile.getType() != HashedEntry.Type.FILE) { + throw new IOException("Unknown hashed file: " + action.name); + } + + // Resolve and write file + Path file = dir.resolve(action.name); + try (InputStream fileInput = IOHelper.newInput(file)) { + IOHelper.transfer(fileInput, output.stream); + } + break; + case CD_BACK: + LogHelper.subDebug("CD .."); + + // Remove from hashed dir stack + dirStack.removeLast(); + if (dirStack.isEmpty()) { + throw new IOException("Empty hDir stack"); + } + + // Get parent + dir = dir.getParent(); + break; + case FINISH: + break loop; + default: + throw new AssertionError(String.format("Unsupported action type: '%s'", action.type.name())); + } + } + + // Flush all actions + output.flush(); + } + + // So we've updated :) + } +} diff --git a/Launcher/MANIFEST.MF b/Launcher/MANIFEST.MF new file mode 100644 index 0000000..60cd9e0 --- /dev/null +++ b/Launcher/MANIFEST.MF @@ -0,0 +1,5 @@ +Manifest-Version: 1.0 +Main-Class: launcher.Launcher + +Name: launcher/ +Sealed: true diff --git a/Launcher/runtime/config.js b/Launcher/runtime/config.js new file mode 100755 index 0000000..f78bfee --- /dev/null +++ b/Launcher/runtime/config.js @@ -0,0 +1,32 @@ +// ====== LAUNCHER CONFIG ====== // +var config = { + dir: "sashok724", // Launcher directory + title: "sashok724's Launcher", // Window title + icons: [ "favicon.png" ], // Window icon paths + + // Auth config + newsURL: "http://launcher.sashok724.net/", // News WebView URL + linkText: "Бесплатные окна", // Text for link under "Auth" button + linkURL: new java.net.URL("http://bit.ly/1SP0Rl8"), // URL for link under "Auth" button + + // Settings defaults + settingsMagic: 0xBEEF, // Ancient magic, don't touch + autoEnterDefault: false, // Should autoEnter be enabled by default? + fullScreenDefault: false, // Should fullScreen be enabled by default? + ramDefault: 1024, // Default RAM amount (0 for auto) + + // Custom JRE config (!!! DON'T CHANGE !!!) + jvmMustdie32Dir: "jre-8u60-win32", jvmMustdie64Dir: "jre-8u60-win64", + jvmLinux32Dir: "jre-8u60-linux32", jvmLinux64Dir: "jre-8u60-linux64", + jvmMacOSXDir: "jre-8u60-macosx", jvmUnknownDir: "jre-8u60-unknown" +}; + +// ====== DON'T TOUCH! ====== // +var dir = IOHelper.HOME_DIR.resolve(config.dir); +if(!IOHelper.isDir(dir)) { + java.nio.file.Files.createDirectory(dir); +} +var updatesDir = dir.resolve("updates"); +if(!IOHelper.isDir(updatesDir)) { + java.nio.file.Files.createDirectory(updatesDir); +} diff --git a/Launcher/runtime/dialog/dialog.css b/Launcher/runtime/dialog/dialog.css new file mode 100755 index 0000000..70b2a07 --- /dev/null +++ b/Launcher/runtime/dialog/dialog.css @@ -0,0 +1,41 @@ +@import url(styles/common.css); + +/* Header styles */ +#layout { + -fx-background-color: white; +} + +/* Auth pane styles */ +#layout > #authPane > #password.hasSaved { + -fx-prompt-text-fill: black; +} + +#layout > #authPane > #profiles > .arrow-button { + -fx-padding: 0; +} + +#layout > #authPane > #profiles > .arrow-button > .arrow { + -fx-padding: 0; + -fx-shape: none; +} + +#layout > #authPane > #profiles > .list-cell { + -fx-padding: 5px 5px 5px 8px; +} + +#layout > #authPane > #goSettings { + -fx-padding: 0; + -fx-graphic: url(settings.png); +} + +/* Overlay styles */ +#layout > #dim { + -fx-opacity: 0.0; + -fx-background-color: rgba(0, 0, 0, 0.5); +} + +#layout > #dim > #overlay { + -fx-opacity: 0.0; + -fx-background-color: white; + -fx-background-radius: 5px; +} diff --git a/Launcher/runtime/dialog/dialog.fxml b/Launcher/runtime/dialog/dialog.fxml new file mode 100755 index 0000000..1f7a49d --- /dev/null +++ b/Launcher/runtime/dialog/dialog.fxml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +