Newer
Older
KeeperJerry_Launcher / LaunchServer / source / LaunchServer.java
package launchserver;

import com.eclipsesource.json.Json;
import com.eclipsesource.json.JsonObject;
import launcher.Launcher;
import launcher.LauncherAPI;
import launcher.client.ClientProfile;
import launcher.hasher.HashedDir;
import launcher.helper.*;
import launcher.serialize.config.ConfigObject;
import launcher.serialize.config.TextConfigReader;
import launcher.serialize.config.TextConfigWriter;
import launcher.serialize.config.entry.*;
import launcher.serialize.signed.SignedObjectHolder;
import launchserver.auth.AuthException;
import launchserver.auth.AuthLimiter;
import launchserver.auth.MySQLSourceConfig;
import launchserver.auth.handler.AuthHandler;
import launchserver.auth.handler.CachedAuthHandler;
import launchserver.auth.handler.FileAuthHandler;
import launchserver.auth.provider.AuthProvider;
import launchserver.auth.provider.DigestAuthProvider;
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.helpers.HTTPRequestHelper;
import launchserver.response.Response;
import launchserver.response.Response.Factory;
import launchserver.response.ServerSocketHandler;
import launchserver.response.ServerSocketHandler.Listener;
import launchserver.texture.TextureProvider;

import javax.script.*;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.URL;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.util.*;
import java.util.Map.Entry;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.CRC32;

public final class LaunchServer implements Runnable, AutoCloseable
{
    // Constant paths
    @LauncherAPI
    public final Path dir;
    @LauncherAPI
    public final Path configFile;
    @LauncherAPI
    public final Path publicKeyFile;
    @LauncherAPI
    public final Path privateKeyFile;
    @LauncherAPI
    public final Path updatesDir;
    @LauncherAPI
    public final Path profilesDir;

    @LauncherAPI
    public final AuthLimiter limiter;

    // Server config
    @LauncherAPI
    public final Config config;
    @LauncherAPI
    public final RSAPublicKey publicKey;
    @LauncherAPI
    public final RSAPrivateKey privateKey;
    @LauncherAPI
    public final boolean portable;

    // Launcher binary
    @LauncherAPI
    public final LauncherBinary launcherBinary;
    @LauncherAPI
    public final LauncherBinary launcherEXEBinary;

    // Server
    @LauncherAPI
    public final CommandHandler commandHandler;
    @LauncherAPI
    public final ServerSocketHandler serverSocketHandler;
    @LauncherAPI
    public final ScriptEngine engine = CommonHelper.newScriptEngine();
    private final AtomicBoolean started = new AtomicBoolean(false);

    // Updates and profiles
    private volatile List<SignedObjectHolder<ClientProfile>> profilesList;
    private volatile Map<String, SignedObjectHolder<HashedDir>> updatesDirMap;

    public LaunchServer(Path dir, boolean portable) throws IOException, InvalidKeySpecException
    {
        setScriptBindings();
        this.portable = portable;

        // Setup config locations
        this.dir = dir;
        configFile = dir.resolve("LaunchServer.cfg");
        publicKeyFile = dir.resolve("public.key");
        privateKeyFile = dir.resolve("private.key");
        updatesDir = dir.resolve("updates");
        profilesDir = dir.resolve("profiles");

        // Set command handler
        CommandHandler localCommandHandler;
        if (portable)
        {
            localCommandHandler = new StdCommandHandler(this, false);
        }
        else
        {
            try
            {
                Class.forName("jline.Terminal");

                // JLine2 available
                localCommandHandler = new JLineCommandHandler(this);
                LogHelper.info("JLine2 terminal enabled");
            }
            catch (ClassNotFoundException ignored)
            {
                localCommandHandler = new StdCommandHandler(this, true);
                LogHelper.warning("JLine2 isn't in classpath, using std");
            }
        }
        commandHandler = localCommandHandler;

        // Set key pair
        if (IOHelper.isFile(publicKeyFile) && IOHelper.isFile(privateKeyFile))
        {
            LogHelper.info("Reading RSA keypair");
            publicKey = SecurityHelper.toPublicRSAKey(IOHelper.read(publicKeyFile));
            privateKey = SecurityHelper.toPrivateRSAKey(IOHelper.read(privateKeyFile));
            if (!publicKey.getModulus().equals(privateKey.getModulus()))
            {
                throw new IOException("Private and public key modulus mismatch");
            }
        }
        else
        {
            LogHelper.info("Generating RSA keypair");
            KeyPair pair = SecurityHelper.genRSAKeyPair();
            publicKey = (RSAPublicKey) pair.getPublic();
            privateKey = (RSAPrivateKey) pair.getPrivate();

            // Write key pair files
            LogHelper.info("Writing RSA keypair files");
            IOHelper.write(publicKeyFile, publicKey.getEncoded());
            IOHelper.write(privateKeyFile, privateKey.getEncoded());
        }

        // Print keypair fingerprints
        CRC32 crc = new CRC32();
        crc.update(publicKey.getModulus().toByteArray());
        LogHelper.subInfo("Modulus CRC32: 0x%08x", crc.getValue());

        // Read LaunchServer config
        generateConfigIfNotExists();
        LogHelper.info("Reading LaunchServer config file");
        try (BufferedReader reader = IOHelper.newReader(configFile))
        {
            config = new Config(TextConfigReader.read(reader, true));
        }
        config.verify();

        // anti-brutforce
        limiter = new AuthLimiter(this);

        // Set launcher EXE binary
        launcherBinary = new JARLauncherBinary(this);
        launcherEXEBinary = binary();
        syncLauncherBinaries();

        if (config.checkServerUpdate)
        {
            LogHelper.info("Check updates from KeeperJerry...");
            try
            {
                URL url = new URL("https://launcher-sashok724.keeperjerry.ru/versions.json");
                String file = HTTPRequestHelper.getFile(url);
                JsonObject object = Json.parse(file).asObject();
                String version = object.get("version").asString();
                String date = object.get("date").asString();
                String note = object.get("note").asString();

                if (Launcher.VERSION.equals(version))
                {
                    LogHelper.info("You have the latest version!");
                }
                else
                {
                    LogHelper.info("================================");
                    LogHelper.info("FOUND NEW VERSION: " + version);
                    LogHelper.info("Release data: " + date);
                    LogHelper.info("Note: " + note);
                    LogHelper.info("================================");
                }
            }
            catch (Throwable exc)
            {
                LogHelper.error(exc);
            }
        }
        else
        {
            LogHelper.info("Check for updates is disabled!");
        }

        // Sync updates dir
        if (!IOHelper.isDir(updatesDir))
        {
            Files.createDirectory(updatesDir);
        }
        syncUpdatesDir(null);

        // Sync profiles dir
        if (!IOHelper.isDir(profilesDir))
        {
            Files.createDirectory(profilesDir);
        }
        syncProfilesDir();

        // Set server socket thread
        serverSocketHandler = new ServerSocketHandler(this);
    }

    public static void main(String... args) throws Throwable
    {
        SecurityHelper.verifyCertificates(LaunchServer.class);
        JVMHelper.verifySystemProperties(LaunchServer.class, true);
        LogHelper.addOutput(IOHelper.WORKING_DIR.resolve("LaunchServer.log"));
        LogHelper.printVersion("LaunchServer");

        // Start LaunchServer
        long start = System.currentTimeMillis();
        try
        {
            new LaunchServer(IOHelper.WORKING_DIR, false).run();
        }
        catch (Throwable exc)
        {
            LogHelper.error(exc);
            return;
        }
        long end = System.currentTimeMillis();
        LogHelper.debug("LaunchServer started in %dms", end - start);
    }

    public static void addLaunchServerClassBindings(ScriptEngine engine, Map<String, Object> bindings)
    {
        Launcher.addClassBinding(engine, bindings, "LaunchServer", LaunchServer.class);

        // Set auth class bindings
        Launcher.addClassBinding(engine, bindings, "AuthHandler", AuthHandler.class);
        Launcher.addClassBinding(engine, bindings, "FileAuthHandler", FileAuthHandler.class);
        Launcher.addClassBinding(engine, bindings, "CachedAuthHandler", CachedAuthHandler.class);
        Launcher.addClassBinding(engine, bindings, "AuthProvider", AuthProvider.class);
        Launcher.addClassBinding(engine, bindings, "DigestAuthProvider", DigestAuthProvider.class);
        Launcher.addClassBinding(engine, bindings, "MySQLSourceConfig", MySQLSourceConfig.class);
        Launcher.addClassBinding(engine, bindings, "AuthException", AuthException.class);
        Launcher.addClassBinding(engine, bindings, "TextureProvider", TextureProvider.class);

        // Set command class bindings
        Launcher.addClassBinding(engine, bindings, "Command", Command.class);
        Launcher.addClassBinding(engine, bindings, "CommandHandler", CommandHandler.class);
        Launcher.addClassBinding(engine, bindings, "CommandException", CommandException.class);

        // Set response class bindings
        Launcher.addClassBinding(engine, bindings, "Response", Response.class);
        Launcher.addClassBinding(engine, bindings, "ResponseFactory", Factory.class);
        Launcher.addClassBinding(engine, bindings, "ServerSocketHandlerListener", Listener.class);
    }

    @Override
    public void close()
    {
        serverSocketHandler.close();

        // Close handlers & providers
        try
        {
            config.authHandler.close();
        }
        catch (IOException e)
        {
            LogHelper.error(e);
        }
        try
        {
            config.authProvider.close();
        }
        catch (IOException e)
        {
            LogHelper.error(e);
        }
        try
        {
            config.textureProvider.close();
        }
        catch (IOException e)
        {
            LogHelper.error(e);
        }

        // Notify script about closing
        try
        {
            ((Invocable) engine).invokeFunction("close");
        }
        catch (NoSuchMethodException ignored)
        {
            // Do nothing if method simply doesn't exist
        }
        catch (Throwable exc)
        {
            LogHelper.error(exc);
        }

        // Print last message before death :(
        LogHelper.info("LaunchServer stopped");
    }

    @Override
    public void run()
    {
        if (started.getAndSet(true))
        {
            throw new IllegalStateException("LaunchServer has been already started");
        }

        // Load plugin script if exist
        Path scriptFile = dir.resolve("plugin.js");
        if (IOHelper.isFile(scriptFile))
        {
            LogHelper.info("Loading plugin.js script");
            try
            {
                loadScript(IOHelper.toURL(scriptFile));
            }
            catch (Throwable exc)
            {
                throw new RuntimeException("Error while loading plugin.js", exc);
            }
        }

        // Add shutdown hook, then start LaunchServer
        if (!portable)
        {
            JVMHelper.RUNTIME.addShutdownHook(CommonHelper.newThread(null, false, this::close));
            CommonHelper.newThread("Command Thread", true, commandHandler).start();
        }
        rebindServerSocket();
    }

    private LauncherBinary binary() {
        if (config.launch4J.enabled) return new EXEL4JLauncherBinary(this);
        return new EXELauncherBinary(this);
    }

    @LauncherAPI
    public void buildLauncherBinaries() throws IOException
    {
        launcherBinary.build();
        launcherEXEBinary.build();
    }

    @LauncherAPI
    @SuppressWarnings("ReturnOfCollectionOrArrayField")
    public Collection<SignedObjectHolder<ClientProfile>> getProfiles()
    {
        return profilesList;
    }

    @LauncherAPI
    public SignedObjectHolder<HashedDir> getUpdateDir(String name)
    {
        return updatesDirMap.get(name);
    }

    @LauncherAPI
    public Set<Entry<String, SignedObjectHolder<HashedDir>>> getUpdateDirs()
    {
        return updatesDirMap.entrySet();
    }

    @LauncherAPI
    public Object loadScript(URL url) throws IOException, ScriptException
    {
        LogHelper.debug("Loading server script: '%s'", url);
        try (BufferedReader reader = IOHelper.newReader(url))
        {
            return engine.eval(reader);
        }
    }

    @LauncherAPI
    public void rebindServerSocket()
    {
        serverSocketHandler.close();
        CommonHelper.newThread("Server Socket Thread", false, serverSocketHandler).start();
    }

    @LauncherAPI
    public void syncLauncherBinaries() throws IOException
    {
        LogHelper.info("Syncing launcher binaries");

        // Syncing launcher binary
        LogHelper.subInfo("Syncing launcher binary file");
        if (!launcherBinary.sync())
        {
            LogHelper.subWarning("Missing launcher binary file");
        }

        // Syncing launcher EXE binary
        LogHelper.subInfo("Syncing launcher EXE binary file");
        if (!launcherEXEBinary.sync())
        {
            LogHelper.subWarning("Missing launcher EXE binary file");
        }
    }

    @LauncherAPI
    public void syncProfilesDir() throws IOException
    {
        LogHelper.info("Syncing profiles dir");
        List<SignedObjectHolder<ClientProfile>> newProfies = new LinkedList<>();
        IOHelper.walk(profilesDir, new ProfilesFileVisitor(newProfies), false);

        // Sort and set new profiles
        newProfies.sort(Comparator.comparing(a -> a.object));
        profilesList = Collections.unmodifiableList(newProfies);
    }

    @LauncherAPI
    public void syncUpdatesDir(Collection<String> dirs) throws IOException
    {
        LogHelper.info("Syncing updates dir");
        Map<String, SignedObjectHolder<HashedDir>> newUpdatesDirMap = new HashMap<>(16);
        try (DirectoryStream<Path> dirStream = Files.newDirectoryStream(updatesDir))
        {
            for (Path updateDir : dirStream)
            {
                if (Files.isHidden(updateDir))
                {
                    continue; // Skip hidden
                }

                // Resolve name and verify is dir
                String name = IOHelper.getFileName(updateDir);
                if (!IOHelper.isDir(updateDir))
                {
                    LogHelper.subWarning("Not update dir: '%s'", name);
                    continue;
                }

                // Add from previous map (it's guaranteed to be non-null)
                if (dirs != null && !dirs.contains(name))
                {
                    SignedObjectHolder<HashedDir> hdir = updatesDirMap.get(name);
                    if (hdir != null)
                    {
                        newUpdatesDirMap.put(name, hdir);
                        continue;
                    }
                }

                // Sync and sign update dir
                LogHelper.subInfo("Syncing '%s' update dir", name);
                HashedDir updateHDir = new HashedDir(updateDir, null, true, true);
                newUpdatesDirMap.put(name, new SignedObjectHolder<>(updateHDir, privateKey));
            }
        }
        updatesDirMap = Collections.unmodifiableMap(newUpdatesDirMap);
    }

    private void generateConfigIfNotExists() throws IOException
    {
        if (IOHelper.isFile(configFile))
        {
            return;
        }

        // Create new config
        LogHelper.info("Creating LaunchServer config");
        Config newConfig;
        try (BufferedReader reader = IOHelper.newReader(IOHelper.getResourceURL("launchserver/defaults/config.cfg")))
        {
            newConfig = new Config(TextConfigReader.read(reader, false));
        }

        // Set server address
        if (portable)
        {
            LogHelper.warning("Setting LaunchServer address to 'localhost'");
            newConfig.setAddress("localhost");
        }
        else
        {
            LogHelper.println("LaunchServer address: ");
            newConfig.setAddress(commandHandler.readLine());
        }

        // Write LaunchServer config
        LogHelper.info("Writing LaunchServer config file");
        try (BufferedWriter writer = IOHelper.newWriter(configFile))
        {
            TextConfigWriter.write(newConfig.block, writer, true);
        }
    }

    private void setScriptBindings()
    {
        LogHelper.info("Setting up server script engine bindings");
        Bindings bindings = engine.getBindings(ScriptContext.ENGINE_SCOPE);
        bindings.put("server", this);

        // Add launcher and launchserver class bindings
        Launcher.addLauncherClassBindings(engine, bindings);
        addLaunchServerClassBindings(engine, bindings);
    }

    public static final class Config extends ConfigObject
    {
        @LauncherAPI
        public final int port;

        // Handlers & Providers
        @LauncherAPI
        public final AuthHandler authHandler;
        @LauncherAPI
        public final AuthProvider authProvider;
        @LauncherAPI
        public final TextureProvider textureProvider;

        // AuthLimiter
        @LauncherAPI
        public final int authRateLimit;
        @LauncherAPI
        public final int authRateLimitMilis;
        @LauncherAPI
        public final String authRejectString;

        // Mirrors
        @LauncherAPI
        public final ListConfigEntry mirrors;

        // Update
        @LauncherAPI
        public final boolean checkServerUpdate;

        // BinaryName
        @LauncherAPI
        public final String binaryName;

        // Misc options
        @LauncherAPI
        public final ExeConf launch4J;
        @LauncherAPI
        public final boolean compress;
        private final StringConfigEntry address;
        private final String bindAddress;

        private Config(BlockConfigEntry block)
        {
            super(block);
            address = block.getEntry("address", StringConfigEntry.class);
            port = VerifyHelper.verifyInt(block.getEntryValue("port", IntegerConfigEntry.class),
                    VerifyHelper.range(0, 65535), "Illegal LaunchServer port");
            bindAddress = block.hasEntry("bindAddress") ?
                    block.getEntryValue("bindAddress", StringConfigEntry.class) : getAddress();

            // Limit Autorization
            authRateLimit = VerifyHelper.verifyInt(block.getEntryValue("authRateLimit", IntegerConfigEntry.class),
                    VerifyHelper.range(0, 1000000), "Illegal authRateLimit");
            authRateLimitMilis = VerifyHelper.verifyInt(block.getEntryValue("authRateLimitMilis", IntegerConfigEntry.class),
                    VerifyHelper.range(10, 10000000), "Illegal authRateLimitMillis");
            authRejectString = block.hasEntry("authRejectString") ?
                    block.getEntryValue("authRejectString", StringConfigEntry.class) : "Превышен лимит авторизаций. Подождите некоторое время перед повторной попыткой";

            // Set handlers & providers
            authHandler = AuthHandler.newHandler(block.getEntryValue("authHandler", StringConfigEntry.class),
                    block.getEntry("authHandlerConfig", BlockConfigEntry.class));
            authProvider = AuthProvider.newProvider(block.getEntryValue("authProvider", StringConfigEntry.class),
                    block.getEntry("authProviderConfig", BlockConfigEntry.class));
            textureProvider = TextureProvider.newProvider(block.getEntryValue("textureProvider", StringConfigEntry.class),
                    block.getEntry("textureProviderConfig", BlockConfigEntry.class));

            // Check Update
            checkServerUpdate = block.getEntryValue("checkServerUpdate", BooleanConfigEntry.class);

            // Mirrors
            mirrors = block.getEntry("mirrors", ListConfigEntry.class);

            // Set misc config
            launch4J = new ExeConf(block.getEntry("launch4J", BlockConfigEntry.class));
            binaryName = block.getEntryValue("binaryName", StringConfigEntry.class);
            compress = block.getEntryValue("compress", BooleanConfigEntry.class);
        }

        public static class ExeConf extends ConfigObject {
            public final boolean enabled;
            public String productName;
            public String fileDesc;
            public String internalName;
            public String copyright;
            public String trademarks;

            private ExeConf(BlockConfigEntry block)
            {
                super(block);
                enabled = block.getEntryValue("enabled", BooleanConfigEntry.class);
                productName = block.hasEntry("productName") ? block.getEntryValue("productName", StringConfigEntry.class)
                        : "LauncherSchool";
                fileDesc = block.hasEntry("fileDesc") ? block.getEntryValue("fileDesc", StringConfigEntry.class)
                        : "LauncherSchool by KeeperJerry";
                internalName = block.hasEntry("internalName") ? block.getEntryValue("internalName", StringConfigEntry.class)
                        : "Launcher";
                copyright = block.hasEntry("copyright") ? block.getEntryValue("copyright", StringConfigEntry.class)
                        : "© KeeperJerry";
                trademarks = block.hasEntry("trademarks") ? block.getEntryValue("trademarks", StringConfigEntry.class)
                        : "This product is licensed under GNU v3.0";
            }
        }

        @LauncherAPI
        public String getAddress()
        {
            return address.getValue();
        }

        @LauncherAPI
        public void setAddress(String address)
        {
            this.address.setValue(address);
        }

        @LauncherAPI
        public String getBindAddress()
        {
            return bindAddress;
        }

        @LauncherAPI
        public SocketAddress getSocketAddress()
        {
            return new InetSocketAddress(bindAddress, port);
        }

        @LauncherAPI
        public void verify()
        {
            VerifyHelper.verify(getAddress(), VerifyHelper.NOT_EMPTY, "LaunchServer address can't be empty");
        }
    }

    private final class ProfilesFileVisitor extends SimpleFileVisitor<Path>
    {
        private final Collection<SignedObjectHolder<ClientProfile>> result;

        private ProfilesFileVisitor(Collection<SignedObjectHolder<ClientProfile>> result)
        {
            this.result = result;
        }

        @Override
        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException
        {
            LogHelper.subInfo("Syncing '%s' profile", IOHelper.getFileName(file));

            // Read profile
            ClientProfile profile;
            try (BufferedReader reader = IOHelper.newReader(file))
            {
                profile = new ClientProfile(TextConfigReader.read(reader, true));
            }
            profile.verify();

            // Add SIGNED profile to result list
            result.add(new SignedObjectHolder<>(profile, privateKey));
            return super.visitFile(file, attrs);
        }
    }
}