From 840f4706b5d7d39b2d6319a25c32ee7c522c5fa2 Mon Sep 17 00:00:00 2001 From: KingRainbow44 Date: Fri, 13 May 2022 11:39:40 -0400 Subject: [PATCH] Refactor dispatch (now called HTTP) server (pt. 1) --- .../java/emu/grasscutter/Configuration.java | 7 +- .../java/emu/grasscutter/Grasscutter.java | 150 +++++++++----- .../server/dispatch/DispatchServer.java | 20 +- .../grasscutter/server/http/HttpServer.java | 176 +++++++++++++++++ .../emu/grasscutter/server/http/Router.java | 16 ++ .../server/http/dispatch/DispatchHandler.java | 100 ++++++++++ .../server/http/dispatch/RegionHandler.java | 186 ++++++++++++++++++ .../http/handlers/AnnouncementsHandler.java | 58 ++++++ .../server/http/handlers/GenericHandler.java | 47 +++++ .../server/http/handlers/LogHandler.java | 18 ++ .../http/objects/ComboTokenReqJson.java | 15 ++ .../http/objects/ComboTokenResJson.java | 17 ++ .../http/objects/LoginAccountRequestJson.java | 7 + .../server/http/objects/LoginResultJson.java | 38 ++++ .../http/objects/LoginTokenRequestJson.java | 6 + .../grasscutter/utils/ConfigContainer.java | 31 ++- src/main/resources/languages/en-US.json | 3 +- 17 files changed, 828 insertions(+), 67 deletions(-) create mode 100644 src/main/java/emu/grasscutter/server/http/HttpServer.java create mode 100644 src/main/java/emu/grasscutter/server/http/Router.java create mode 100644 src/main/java/emu/grasscutter/server/http/dispatch/DispatchHandler.java create mode 100644 src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java create mode 100644 src/main/java/emu/grasscutter/server/http/handlers/AnnouncementsHandler.java create mode 100644 src/main/java/emu/grasscutter/server/http/handlers/GenericHandler.java create mode 100644 src/main/java/emu/grasscutter/server/http/handlers/LogHandler.java create mode 100644 src/main/java/emu/grasscutter/server/http/objects/ComboTokenReqJson.java create mode 100644 src/main/java/emu/grasscutter/server/http/objects/ComboTokenResJson.java create mode 100644 src/main/java/emu/grasscutter/server/http/objects/LoginAccountRequestJson.java create mode 100644 src/main/java/emu/grasscutter/server/http/objects/LoginResultJson.java create mode 100644 src/main/java/emu/grasscutter/server/http/objects/LoginTokenRequestJson.java diff --git a/src/main/java/emu/grasscutter/Configuration.java b/src/main/java/emu/grasscutter/Configuration.java index 7adc334c1..52bfa65aa 100644 --- a/src/main/java/emu/grasscutter/Configuration.java +++ b/src/main/java/emu/grasscutter/Configuration.java @@ -34,11 +34,12 @@ public final class Configuration extends ConfigContainer { public static final Database DATABASE = config.databaseInfo; public static final Account ACCOUNT = config.account; - public static final Dispatch DISPATCH_INFO = config.server.dispatch; + public static final HTTP HTTP_INFO = config.server.http; public static final Game GAME_INFO = config.server.game; + public static final Dispatch DISPATCH_INFO = config.server.dispatch; - public static final Encryption DISPATCH_ENCRYPTION = config.server.dispatch.encryption; - public static final Policies DISPATCH_POLICIES = config.server.dispatch.policies; + public static final Encryption HTTP_ENCRYPTION = config.server.http.encryption; + public static final Policies HTTP_POLICIES = config.server.http.policies; public static final GameOptions GAME_OPTIONS = config.server.game.gameOptions; public static final GameOptions.InventoryLimits INVENTORY_LIMITS = config.server.game.gameOptions.inventoryLimits; diff --git a/src/main/java/emu/grasscutter/Grasscutter.java b/src/main/java/emu/grasscutter/Grasscutter.java index 73e761e6e..bddfa9964 100644 --- a/src/main/java/emu/grasscutter/Grasscutter.java +++ b/src/main/java/emu/grasscutter/Grasscutter.java @@ -3,10 +3,18 @@ package emu.grasscutter; import java.io.*; import java.util.Calendar; +import emu.grasscutter.auth.AuthenticationSystem; +import emu.grasscutter.auth.DefaultAuthentication; import emu.grasscutter.command.CommandMap; import emu.grasscutter.plugin.PluginManager; import emu.grasscutter.plugin.api.ServerHook; import emu.grasscutter.scripts.ScriptLoader; +import emu.grasscutter.server.http.HttpServer; +import emu.grasscutter.server.http.dispatch.DispatchHandler; +import emu.grasscutter.server.http.handlers.AnnouncementsHandler; +import emu.grasscutter.server.http.handlers.GenericHandler; +import emu.grasscutter.server.http.handlers.LogHandler; +import emu.grasscutter.server.http.dispatch.RegionHandler; import emu.grasscutter.utils.ConfigContainer; import emu.grasscutter.utils.Utils; import org.jline.reader.EndOfFileException; @@ -47,8 +55,10 @@ public final class Grasscutter { private static int day; // Current day of week. private static DispatchServer dispatchServer; + private static HttpServer httpServer; private static GameServer gameServer; private static PluginManager pluginManager; + private static AuthenticationSystem authenticationSystem; public static final Reflections reflector = new Reflections("emu.grasscutter"); public static ConfigContainer config; @@ -98,14 +108,27 @@ public final class Grasscutter { // Initialize database. DatabaseManager.initialize(); + + // Initialize the default authentication system. + authenticationSystem = new DefaultAuthentication(); // Create server instances. dispatchServer = new DispatchServer(); + httpServer = new HttpServer(); gameServer = new GameServer(); // Create a server hook instance with both servers. new ServerHook(gameServer, dispatchServer); + // Create plugin manager instance. pluginManager = new PluginManager(); + // Add HTTP routes after loading plugins. + httpServer.addRouter(HttpServer.UnhandledRequestRouter.class); + httpServer.addRouter(HttpServer.DefaultRequestRouter.class); + httpServer.addRouter(RegionHandler.class); + httpServer.addRouter(LogHandler.class); + httpServer.addRouter(GenericHandler.class); + httpServer.addRouter(AnnouncementsHandler.class); + httpServer.addRouter(DispatchHandler.class); // Start servers. var runMode = SERVER.runMode; @@ -114,6 +137,7 @@ public final class Grasscutter { gameServer.start(); } else if (runMode == ServerRunMode.DISPATCH_ONLY) { dispatchServer.start(); + httpServer.start(); } else if (runMode == ServerRunMode.GAME_ONLY) { gameServer.start(); } else { @@ -141,6 +165,19 @@ public final class Grasscutter { pluginManager.disablePlugins(); } + /* + * Methods for the language system component. + */ + + public static void loadLanguage() { + var locale = config.language.language; + language = Language.getLanguage(Utils.getLanguageCode(locale)); + } + + /* + * Methods for the configuration system component. + */ + /** * Attempts to load the configuration from a file. */ @@ -157,11 +194,6 @@ public final class Grasscutter { } } - public static void loadLanguage() { - var locale = config.language.language; - language = Language.getLanguage(Utils.getLanguageCode(locale)); - } - /** * Saves the provided server configuration. * @param config The configuration to save, or null for a new one. @@ -178,44 +210,10 @@ public final class Grasscutter { } } - public static void startConsole() { - // Console should not start in dispatch only mode. - if (SERVER.runMode == ServerRunMode.DISPATCH_ONLY) { - getLogger().info(translate("messages.dispatch.no_commands_error")); - return; - } - - getLogger().info(translate("messages.status.done")); - String input = null; - boolean isLastInterrupted = false; - while (true) { - try { - input = consoleLineReader.readLine("> "); - } catch (UserInterruptException e) { - if (!isLastInterrupted) { - isLastInterrupted = true; - Grasscutter.getLogger().info("Press Ctrl-C again to shutdown."); - continue; - } else { - Runtime.getRuntime().exit(0); - } - } catch (EndOfFileException e) { - Grasscutter.getLogger().info("EOF detected."); - continue; - } catch (IOError e) { - Grasscutter.getLogger().error("An IO error occurred.", e); - continue; - } - - isLastInterrupted = false; - try { - CommandMap.getInstance().invoke(null, null, input); - } catch (Exception e) { - Grasscutter.getLogger().error(translate("messages.game.command_error"), e); - } - } - } - + /* + * Getters for the various server components. + */ + public static ConfigContainer getConfig() { return config; } @@ -271,16 +269,74 @@ public final class Grasscutter { public static PluginManager getPluginManager() { return pluginManager; } - - public static void updateDayOfWeek() { - Calendar calendar = Calendar.getInstance(); - day = calendar.get(Calendar.DAY_OF_WEEK); + + public static AuthenticationSystem getAuthenticationSystem() { + return authenticationSystem; } public static int getCurrentDayOfWeek() { return day; } + + /* + * Utility methods. + */ + + public static void updateDayOfWeek() { + Calendar calendar = Calendar.getInstance(); + day = calendar.get(Calendar.DAY_OF_WEEK); + } + public static void startConsole() { + // Console should not start in dispatch only mode. + if (SERVER.runMode == ServerRunMode.DISPATCH_ONLY) { + getLogger().info(translate("messages.dispatch.no_commands_error")); + return; + } + + getLogger().info(translate("messages.status.done")); + String input = null; + boolean isLastInterrupted = false; + while (true) { + try { + input = consoleLineReader.readLine("> "); + } catch (UserInterruptException e) { + if (!isLastInterrupted) { + isLastInterrupted = true; + Grasscutter.getLogger().info("Press Ctrl-C again to shutdown."); + continue; + } else { + Runtime.getRuntime().exit(0); + } + } catch (EndOfFileException e) { + Grasscutter.getLogger().info("EOF detected."); + continue; + } catch (IOError e) { + Grasscutter.getLogger().error("An IO error occurred.", e); + continue; + } + + isLastInterrupted = false; + try { + CommandMap.getInstance().invoke(null, null, input); + } catch (Exception e) { + Grasscutter.getLogger().error(translate("messages.game.command_error"), e); + } + } + } + + /** + * Sets the authentication system for the server. + * @param authenticationSystem The authentication system to use. + */ + public static void setAuthenticationSystem(AuthenticationSystem authenticationSystem) { + Grasscutter.authenticationSystem = authenticationSystem; + } + + /* + * Enums for the configuration. + */ + public enum ServerRunMode { HYBRID, DISPATCH_ONLY, GAME_ONLY } diff --git a/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java b/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java index 4e09f8881..8b8a9a185 100644 --- a/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java +++ b/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java @@ -117,7 +117,7 @@ public final class DispatchServer { .setTitle(DISPATCH_INFO.defaultName) .setType("DEV_PUBLIC") .setDispatchUrl( - "http" + (DISPATCH_ENCRYPTION.useInRouting ? "s" : "") + "://" + "http" + (HTTP_ENCRYPTION.useInRouting ? "s" : "") + "://" + lr(DISPATCH_INFO.accessAddress, DISPATCH_INFO.bindAddress) + ":" + lr(DISPATCH_INFO.accessPort, DISPATCH_INFO.bindPort) + "/query_cur_region/" + defaultServerName) @@ -150,7 +150,7 @@ public final class DispatchServer { .setTitle(regionInfo.Title) .setType("DEV_PUBLIC") .setDispatchUrl( - "http" + (DISPATCH_ENCRYPTION.useInRouting ? "s" : "") + "://" + "http" + (HTTP_ENCRYPTION.useInRouting ? "s" : "") + "://" + lr(DISPATCH_INFO.accessAddress, DISPATCH_INFO.bindAddress) + ":" + lr(DISPATCH_INFO.accessPort, DISPATCH_INFO.bindPort) + "/query_cur_region/" + regionInfo.Name) @@ -189,14 +189,14 @@ public final class DispatchServer { Server server = new Server(); ServerConnector serverConnector; - if(DISPATCH_ENCRYPTION.useEncryption) { + if(HTTP_ENCRYPTION.useEncryption) { SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); - File keystoreFile = new File(DISPATCH_ENCRYPTION.keystore); + File keystoreFile = new File(HTTP_ENCRYPTION.keystore); if(keystoreFile.exists()) { try { sslContextFactory.setKeyStorePath(keystoreFile.getPath()); - sslContextFactory.setKeyStorePassword(DISPATCH_ENCRYPTION.keystorePassword); + sslContextFactory.setKeyStorePassword(HTTP_ENCRYPTION.keystorePassword); } catch (Exception e) { e.printStackTrace(); Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.password_error")); @@ -214,7 +214,7 @@ public final class DispatchServer { serverConnector = new ServerConnector(server, sslContextFactory); } else { Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.no_keystore_error")); - DISPATCH_ENCRYPTION.useEncryption = false; + HTTP_ENCRYPTION.useEncryption = false; serverConnector = new ServerConnector(server); } @@ -227,18 +227,19 @@ public final class DispatchServer { return server; }); - config.enforceSsl = DISPATCH_ENCRYPTION.useEncryption; + config.enforceSsl = HTTP_ENCRYPTION.useEncryption; if(SERVER.debugLevel == ServerDebugMode.ALL) { config.enableDevLogging(); } - if (DISPATCH_POLICIES.cors.enabled) { - var corsPolicy = DISPATCH_POLICIES.cors; + if (HTTP_POLICIES.cors.enabled) { + var corsPolicy = HTTP_POLICIES.cors; if (corsPolicy.allowedOrigins.length > 0) config.enableCorsForOrigin(corsPolicy.allowedOrigins); else config.enableCorsForAllOrigins(); } }); + httpServer.get("/", (req, res) -> res.send("" + translate("messages.status.welcome") + "")); httpServer.raw().error(404, ctx -> { @@ -279,6 +280,7 @@ public final class DispatchServer { res.send(event.getRegionList()); }); + // /server/:id -> 2.6.5x httpServer.get("/query_cur_region/:id", (req, res) -> { String regionName = req.params("id"); // Log diff --git a/src/main/java/emu/grasscutter/server/http/HttpServer.java b/src/main/java/emu/grasscutter/server/http/HttpServer.java new file mode 100644 index 000000000..dc0d396a6 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/HttpServer.java @@ -0,0 +1,176 @@ +package emu.grasscutter.server.http; + +import emu.grasscutter.Grasscutter; +import emu.grasscutter.Grasscutter.ServerDebugMode; +import express.Express; +import io.javalin.Javalin; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +import java.io.File; + +import static emu.grasscutter.Configuration.*; +import static emu.grasscutter.utils.Language.translate; + +/** + * Manages all HTTP-related classes. + * (including dispatch, announcements, gacha, etc.) + */ +public final class HttpServer { + private final Express express; + + /** + * Configures the Express application. + */ + public HttpServer() { + this.express = new Express(config -> { + // Set the Express HTTP server. + config.server(HttpServer::createServer); + + // Configure encryption/HTTPS/SSL. + config.enforceSsl = HTTP_ENCRYPTION.useEncryption; + + // Configure HTTP policies. + if(HTTP_POLICIES.cors.enabled) { + var allowedOrigins = HTTP_POLICIES.cors.allowedOrigins; + if (allowedOrigins.length > 0) + config.enableCorsForOrigin(allowedOrigins); + else config.enableCorsForAllOrigins(); + } + + // Configure debug logging. + if(SERVER.debugLevel == ServerDebugMode.ALL) + config.enableDevLogging(); + + // Disable compression on static files. + config.precompressStaticFiles = false; + }); + } + + /** + * Creates an HTTP(S) server. + * @return A server instance. + */ + @SuppressWarnings("resource") + private static Server createServer() { + Server server = new Server(); + ServerConnector serverConnector + = new ServerConnector(server); + + if(HTTP_ENCRYPTION.useEncryption) { + var sslContextFactory = new SslContextFactory.Server(); + var keystoreFile = new File(HTTP_ENCRYPTION.keystore); + + if(!keystoreFile.exists()) {; + HTTP_ENCRYPTION.useEncryption = false; + HTTP_ENCRYPTION.useInRouting = false; + + Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.no_keystore_error")); + } else try { + sslContextFactory.setKeyStorePath(keystoreFile.getPath()); + sslContextFactory.setKeyStorePassword(HTTP_ENCRYPTION.keystorePassword); + } catch (Exception ignored) { + Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.password_error")); + + try { + sslContextFactory.setKeyStorePath(keystoreFile.getPath()); + sslContextFactory.setKeyStorePassword("123456"); + + Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.default_password")); + } catch (Exception exception) { + Grasscutter.getLogger().warn(translate("messages.dispatch.keystore.general_error"), exception); + } + } finally { + serverConnector = new ServerConnector(server, sslContextFactory); + } + } + + serverConnector.setPort(HTTP_INFO.bindPort); + server.setConnectors(new ServerConnector[]{serverConnector}); + + return server; + } + + /** + * Returns the handle for the Express application. + * @return A Javalin instance. + */ + public Javalin getHandle() { + return this.express.raw(); + } + + /** + * Initializes the provided class. + * @param router The router class. + * @return Method chaining. + */ + @SuppressWarnings("UnusedReturnValue") + public HttpServer addRouter(Class router, Object... args) { + // Get all constructor parameters. + Class[] types = new Class[args.length]; + for(var argument : args) + types[args.length - 1] = argument.getClass(); + + try { // Create a router instance & apply routes. + var constructor = router.getDeclaredConstructor(types); // Get the constructor. + var routerInstance = constructor.newInstance(args); // Create instance. + routerInstance.applyRoutes(this.express, this.getHandle()); // Apply routes. + } catch (Exception exception) { + Grasscutter.getLogger().warn(translate("messages.dispatch.router_error"), exception); + } return this; + } + + /** + * Starts listening on the HTTP server. + */ + public void start() { + // Attempt to start the HTTP server. + this.express.listen(HTTP_INFO.bindAddress, HTTP_INFO.bindPort); + + // Log bind information. + Grasscutter.getLogger().info(translate("messages.dispatch.port_bind", Integer.toString(this.express.raw().port()))); + } + + /** + * Handles the '/' (index) endpoint on the Express application. + */ + public static class DefaultRequestRouter implements Router { + @Override public void applyRoutes(Express express, Javalin handle) { + express.get("/", (req, res) -> res.send(""" + + + + + + %s + + """.formatted(translate("messages.status.welcome")))); + } + } + + /** + * Handles unhandled endpoints on the Express application. + */ + public static class UnhandledRequestRouter implements Router { + @Override public void applyRoutes(Express express, Javalin handle) { + handle.error(404, context -> { + if(SERVER.debugLevel == ServerDebugMode.MISSING) + Grasscutter.getLogger().info(translate("messages.dispatch.unhandled_request_error", context.method(), context.url())); + context.contentType("text/html"); + context.result(""" + + + + + + + + + + + """); + }); + } + } +} diff --git a/src/main/java/emu/grasscutter/server/http/Router.java b/src/main/java/emu/grasscutter/server/http/Router.java new file mode 100644 index 000000000..1720d7ca0 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/Router.java @@ -0,0 +1,16 @@ +package emu.grasscutter.server.http; + +import express.Express; +import io.javalin.Javalin; + +/** + * Defines routes for an {@link Express} instance. + */ +public interface Router { + + /** + * Called when the router is initialized by Express. + * @param express An Express instance. + */ + void applyRoutes(Express express, Javalin handle); +} diff --git a/src/main/java/emu/grasscutter/server/http/dispatch/DispatchHandler.java b/src/main/java/emu/grasscutter/server/http/dispatch/DispatchHandler.java new file mode 100644 index 000000000..22a31fe6a --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/dispatch/DispatchHandler.java @@ -0,0 +1,100 @@ +package emu.grasscutter.server.http.dispatch; + +import emu.grasscutter.Grasscutter; +import emu.grasscutter.auth.AuthenticationSystem; +import emu.grasscutter.server.http.Router; +import emu.grasscutter.server.http.objects.*; +import emu.grasscutter.server.http.objects.ComboTokenReqJson.LoginTokenData; +import emu.grasscutter.utils.Utils; +import express.Express; +import express.http.Request; +import express.http.Response; +import io.javalin.Javalin; + +import static emu.grasscutter.utils.Language.translate; + +/** + * Handles requests related to authentication. (aka dispatch) + */ +public final class DispatchHandler implements Router { + @Override public void applyRoutes(Express express, Javalin handle) { + // Username & Password login (from client). + express.post("/hk4e_global/mdk/shield/api/login", DispatchHandler::clientLogin); + // Cached token login (from registry). + express.post("/hk4e_global/mdk/shield/api/verify", DispatchHandler::tokenLogin); + // Combo token login (from session key). + express.post("/hk4e_global/combo/granter/login/v2/login", DispatchHandler::sessionKeyLogin); + } + + /** + * @route /hk4e_global/mdk/shield/api/login + */ + private static void clientLogin(Request request, Response response) { + // Parse body data. + String rawBodyData = request.ctx().body(); + var bodyData = Utils.jsonDecode(rawBodyData, LoginAccountRequestJson.class); + + // Validate body data. + if(bodyData == null) + return; + + // Pass data to authentication handler. + var responseData = Grasscutter.getAuthenticationSystem() + .getPasswordAuthenticator() + .authenticate(AuthenticationSystem.fromPasswordRequest(request, bodyData)); + // Send response. + response.send(responseData); + + // Log to console. + Grasscutter.getLogger().info(translate("messages.dispatch.account.login_attempt", request.ip())); + } + + /** + * @route /hk4e_global/mdk/shield/api/verify + */ + private static void tokenLogin(Request request, Response response) { + // Parse body data. + String rawBodyData = request.ctx().body(); + var bodyData = Utils.jsonDecode(rawBodyData, LoginTokenRequestJson.class); + + // Validate body data. + if(bodyData == null) + return; + + // Pass data to authentication handler. + var responseData = Grasscutter.getAuthenticationSystem() + .getTokenAuthenticator() + .authenticate(AuthenticationSystem.fromTokenRequest(request, bodyData)); + // Send response. + response.send(responseData); + + // Log to console. + Grasscutter.getLogger().info(translate("messages.dispatch.account.login_attempt", request.ip())); + } + + /** + * @route /hk4e_global/combo/granter/login/v2/login + */ + private static void sessionKeyLogin(Request request, Response response) { + // Parse body data. + String rawBodyData = request.ctx().body(); + var bodyData = Utils.jsonDecode(rawBodyData, ComboTokenReqJson.class); + + // Validate body data. + if(bodyData == null || bodyData.data == null) + return; + + // Decode additional body data. + var tokenData = Utils.jsonDecode(bodyData.data, LoginTokenData.class); + + // Pass data to authentication handler. + var responseData = Grasscutter.getAuthenticationSystem() + .getSessionKeyAuthenticator() + .authenticate(AuthenticationSystem.fromComboTokenRequest(request, bodyData, tokenData)); + // Send response. + response.send(responseData); + + // Log to console. + Grasscutter.getLogger().info(translate("messages.dispatch.account.login_attempt", request.ip())); + } +} diff --git a/src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java b/src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java new file mode 100644 index 000000000..e720a4b15 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/dispatch/RegionHandler.java @@ -0,0 +1,186 @@ +package emu.grasscutter.server.http.dispatch; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import emu.grasscutter.Grasscutter; +import emu.grasscutter.Grasscutter.ServerRunMode; +import emu.grasscutter.net.proto.QueryCurrRegionHttpRspOuterClass.*; +import emu.grasscutter.net.proto.RegionSimpleInfoOuterClass.RegionSimpleInfo; +import emu.grasscutter.server.event.dispatch.QueryAllRegionsEvent; +import emu.grasscutter.server.event.dispatch.QueryCurrentRegionEvent; +import emu.grasscutter.server.http.Router; +import emu.grasscutter.utils.FileUtils; +import emu.grasscutter.utils.Utils; +import express.Express; +import express.http.Request; +import express.http.Response; +import io.javalin.Javalin; + +import java.io.File; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static emu.grasscutter.Configuration.*; +import static emu.grasscutter.net.proto.QueryRegionListHttpRspOuterClass.*; + +/** + * Handles requests related to region queries. + */ +public final class RegionHandler implements Router { + private String regionQuery = ""; + private String regionList = ""; + + private static final Map regions = new ConcurrentHashMap<>(); + private static String regionListResponse; + + public RegionHandler() { + try { // Read & initialize region data. + this.readRegionData(); + this.initialize(); + } catch (Exception exception) { + Grasscutter.getLogger().error("Failed to initialize region data.", exception); + } + } + + /** + * Loads initial region data. + */ + private void readRegionData() { + File file; + + file = new File(DATA("query_region_list.txt")); + if (file.exists()) + this.regionList = new String(FileUtils.read(file)); + else Grasscutter.getLogger().error("[Dispatch] 'query_region_list' not found!"); + + file = new File(DATA("query_cur_region.txt")); + if (file.exists()) + regionQuery = new String(FileUtils.read(file)); + else Grasscutter.getLogger().warn("[Dispatch] 'query_cur_region' not found!"); + } + + /** + * Configures region data according to configuration. + */ + private void initialize() throws InvalidProtocolBufferException { + // Decode the initial region query. + byte[] queryBase64 = Base64.getDecoder().decode(this.regionQuery); + QueryCurrRegionHttpRsp regionQuery = QueryCurrRegionHttpRsp.parseFrom(queryBase64); + + // Create regions. + List servers = new ArrayList<>(); + List usedNames = new ArrayList<>(); // List to check for potential naming conflicts. + + var configuredRegions = new ArrayList<>(List.of(DISPATCH_INFO.regions)); + if(SERVER.runMode != ServerRunMode.HYBRID && configuredRegions.size() == 0) { + Grasscutter.getLogger().error("[Dispatch] There are no game servers available. Exiting due to unplayable state."); + System.exit(1); + } else configuredRegions.add(new Region("os_usa", DISPATCH_INFO.defaultName, + lr(GAME_INFO.accessAddress, GAME_INFO.bindAddress), + lr(GAME_INFO.accessPort, GAME_INFO.bindPort))); + + configuredRegions.forEach(region -> { + if (usedNames.contains(region.Name)) { + Grasscutter.getLogger().error("Region name already in use."); + return; + } + + // Create a region identifier. + var identifier = RegionSimpleInfo.newBuilder() + .setName(region.Name).setTitle(region.Title) + .setType("DEV_PUBLIC").setDispatchUrl( + "http" + (HTTP_ENCRYPTION.useInRouting ? "s" : "") + "://" + + lr(HTTP_INFO.accessAddress, HTTP_INFO.bindAddress) + ":" + + lr(HTTP_INFO.accessPort, HTTP_INFO.bindPort) + + "/query_cur_region/" + region.Name) + .build(); + usedNames.add(region.Name); servers.add(identifier); + + // Create a region info object. + var regionInfo = regionQuery.getRegionInfo().toBuilder() + .setGateserverIp(region.Ip).setGateserverPort(region.Port) + .setSecretKey(ByteString.copyFrom(FileUtils.read(KEYS_FOLDER + "/dispatchSeed.bin"))) + .build(); + // Create an updated region query. + var updatedQuery = regionQuery.toBuilder().setRegionInfo(regionInfo).build(); + regions.put(region.Name, new RegionData(updatedQuery, Utils.base64Encode(updatedQuery.toByteString().toByteArray()))); + }); + + // Decode the initial region list. + byte[] listBase64 = Base64.getDecoder().decode(this.regionList); + QueryRegionListHttpRsp regionList = QueryRegionListHttpRsp.parseFrom(listBase64); + + // Create an updated region list. + QueryRegionListHttpRsp updatedRegionList = QueryRegionListHttpRsp.newBuilder() + .addAllRegionList(servers) + .setClientSecretKey(regionList.getClientSecretKey()) + .setClientCustomConfigEncrypted(regionList.getClientCustomConfigEncrypted()) + .setEnableLoginPc(true).build(); + + // Set the region list response. + regionListResponse = Utils.base64Encode(updatedRegionList.toByteString().toByteArray()); + } + + @Override public void applyRoutes(Express express, Javalin handle) { + express.get("/query_region_list", RegionHandler::queryRegionList); + express.get("/query_cur_region/:region", RegionHandler::queryCurrentRegion ); + } + + /** + * @route /query_region_list + */ + private static void queryRegionList(Request request, Response response) { + // Invoke event. + QueryAllRegionsEvent event = new QueryAllRegionsEvent(regionListResponse); event.call(); + // Respond with event result. + response.send(event.getRegionList()); + + // Log to console. + Grasscutter.getLogger().info(String.format("[Dispatch] Client %s request: query_region_list", request.ip())); + } + + /** + * @route /query_cur_region/:region + */ + private static void queryCurrentRegion(Request request, Response response) { + // Get region to query. + String regionName = request.params("region"); + + // Get region data. + String regionData = "CAESGE5vdCBGb3VuZCB2ZXJzaW9uIGNvbmZpZw=="; + if (request.query().values().size() > 0) + regionData = regions.get(regionName).getBase64(); + + // Invoke event. + QueryCurrentRegionEvent event = new QueryCurrentRegionEvent(regionData); event.call(); + // Respond with event result. + response.send(event.getRegionInfo()); + + // Log to console. + Grasscutter.getLogger().info(String.format("Client %s request: query_cur_region/%s", request.ip(), regionName)); + } + + /** + * Region data container. + */ + public static class RegionData { + private final QueryCurrRegionHttpRsp regionQuery; + private final String base64; + + public RegionData(QueryCurrRegionHttpRsp prq, String b64) { + this.regionQuery = prq; + this.base64 = b64; + } + + public QueryCurrRegionHttpRsp getRegionQuery() { + return this.regionQuery; + } + + public String getBase64() { + return this.base64; + } + } +} diff --git a/src/main/java/emu/grasscutter/server/http/handlers/AnnouncementsHandler.java b/src/main/java/emu/grasscutter/server/http/handlers/AnnouncementsHandler.java new file mode 100644 index 000000000..a64e0552a --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/handlers/AnnouncementsHandler.java @@ -0,0 +1,58 @@ +package emu.grasscutter.server.http.handlers; + +import emu.grasscutter.Grasscutter; +import emu.grasscutter.server.dispatch.DispatchHttpJsonHandler; +import emu.grasscutter.server.http.Router; +import express.Express; +import express.http.Request; +import express.http.Response; +import io.javalin.Javalin; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.Objects; + +import static emu.grasscutter.Configuration.DATA; + +/** + * Handles requests related to the announcements page. + */ +public final class AnnouncementsHandler implements Router { + @Override public void applyRoutes(Express express, Javalin handle) { + // hk4e-api-os.hoyoverse.com + express.all("/common/hk4e_global/announcement/api/getAlertPic", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"total\":0,\"list\":[]}}")); + // hk4e-api-os.hoyoverse.com + express.all("/common/hk4e_global/announcement/api/getAlertAnn", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"alert\":false,\"alert_id\":0,\"remind\":true}}")); + // hk4e-api-os.hoyoverse.com + express.all("/common/hk4e_global/announcement/api/getAnnList", AnnouncementsHandler::getAnnouncement); + // hk4e-api-os-static.hoyoverse.com + express.all("/common/hk4e_global/announcement/api/getAnnContent", AnnouncementsHandler::getAnnouncement); + // hk4e-sdk-os.hoyoverse.com + express.all("/hk4e_global/mdk/shopwindow/shopwindow/listPriceTier", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"suggest_currency\":\"USD\",\"tiers\":[]}}")); + } + + private static void getAnnouncement(Request request, Response response) { + if (Objects.equals(request.baseUrl(), "/common/hk4e_global/announcement/api/getAnnContent")) { + response.send("{\"retcode\":0,\"message\":\"OK\",\"data\":" + readToString(new File(DATA("GameAnnouncement.json"))) +"}"); + } else if (Objects.equals(request.baseUrl(), "/common/hk4e_global/announcement/api/getAnnList")) { + String data = readToString(new File(DATA("GameAnnouncementList.json"))).replace("System.currentTimeMillis()",String.valueOf(System.currentTimeMillis())); + response.send("{\"retcode\":0,\"message\":\"OK\",\"data\": "+data +"}"); + } + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private static String readToString(File file) { + long length = file.length(); + byte[] content = new byte[(int) length]; + + try { + FileInputStream in = new FileInputStream(file); + in.read(content); in.close(); + } catch (IOException ignored) { + Grasscutter.getLogger().warn("File not found: " + file.getAbsolutePath()); + } + + return new String(content); + } +} diff --git a/src/main/java/emu/grasscutter/server/http/handlers/GenericHandler.java b/src/main/java/emu/grasscutter/server/http/handlers/GenericHandler.java new file mode 100644 index 000000000..bb0bc8eea --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/handlers/GenericHandler.java @@ -0,0 +1,47 @@ +package emu.grasscutter.server.http.handlers; + +import emu.grasscutter.server.dispatch.DispatchHttpJsonHandler; +import emu.grasscutter.server.http.Router; +import express.Express; +import io.javalin.Javalin; + +/** + * Handles all generic, hard-coded responses. + */ +public final class GenericHandler implements Router { + @Override public void applyRoutes(Express express, Javalin handle) { + // hk4e-sdk-os.hoyoverse.com + express.get("/hk4e_global/mdk/agreement/api/getAgreementInfos", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"marketing_agreements\":[]}}")); + // hk4e-sdk-os.hoyoverse.com + // this could be either GET or POST based on the observation of different clients + express.all("/hk4e_global/combo/granter/api/compareProtocolVersion", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"modified\":true,\"protocol\":{\"id\":0,\"app_id\":4,\"language\":\"en\",\"user_proto\":\"\",\"priv_proto\":\"\",\"major\":7,\"minimum\":0,\"create_time\":\"0\",\"teenager_proto\":\"\",\"third_proto\":\"\"}}}")); + + // api-account-os.hoyoverse.com + express.post("/account/risky/api/check", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"id\":\"none\",\"action\":\"ACTION_NONE\",\"geetest\":null}}")); + + // sdk-os-static.hoyoverse.com + express.get("/combo/box/api/config/sdk/combo", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"vals\":{\"disable_email_bind_skip\":\"false\",\"email_bind_remind_interval\":\"7\",\"email_bind_remind\":\"true\"}}}")); + // hk4e-sdk-os-static.hoyoverse.com + express.get("/hk4e_global/combo/granter/api/getConfig", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"protocol\":true,\"qr_enabled\":false,\"log_level\":\"INFO\",\"announce_url\":\"https://webstatic-sea.hoyoverse.com/hk4e/announcement/index.html?sdk_presentation_style=fullscreen\\u0026sdk_screen_transparent=true\\u0026game_biz=hk4e_global\\u0026auth_appid=announcement\\u0026game=hk4e#/\",\"push_alias_type\":2,\"disable_ysdk_guard\":false,\"enable_announce_pic_popup\":true}}")); + // hk4e-sdk-os-static.hoyoverse.com + express.get("/hk4e_global/mdk/shield/api/loadConfig", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"id\":6,\"game_key\":\"hk4e_global\",\"client\":\"PC\",\"identity\":\"I_IDENTITY\",\"guest\":false,\"ignore_versions\":\"\",\"scene\":\"S_NORMAL\",\"name\":\"原神海外\",\"disable_regist\":false,\"enable_email_captcha\":false,\"thirdparty\":[\"fb\",\"tw\"],\"disable_mmt\":false,\"server_guest\":false,\"thirdparty_ignore\":{\"tw\":\"\",\"fb\":\"\"},\"enable_ps_bind_account\":false,\"thirdparty_login_configs\":{\"tw\":{\"token_type\":\"TK_GAME_TOKEN\",\"game_token_expires_in\":604800},\"fb\":{\"token_type\":\"TK_GAME_TOKEN\",\"game_token_expires_in\":604800}}}}")); + // Test api? + // abtest-api-data-sg.hoyoverse.com + express.post("/data_abtest_api/config/experiment/list", new DispatchHttpJsonHandler("{\"retcode\":0,\"success\":true,\"message\":\"\",\"data\":[{\"code\":1000,\"type\":2,\"config_id\":\"14\",\"period_id\":\"6036_99\",\"version\":\"1\",\"configs\":{\"cardType\":\"old\"}}]}")); + + // hk4e-api-os.hoyoverse.com + express.all("/common/hk4e_global/announcement/api/getAlertPic", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"total\":0,\"list\":[]}}")); + // hk4e-api-os.hoyoverse.com + express.all("/common/hk4e_global/announcement/api/getAlertAnn", new DispatchHttpJsonHandler("{\"retcode\":0,\"message\":\"OK\",\"data\":{\"alert\":false,\"alert_id\":0,\"remind\":true}}")); + + // log-upload-os.mihoyo.com + express.all("/log/sdk/upload", new DispatchHttpJsonHandler("{\"code\":0}")); + express.all("/sdk/upload", new DispatchHttpJsonHandler("{\"code\":0}")); + express.post("/sdk/dataUpload", new DispatchHttpJsonHandler("{\"code\":0}")); + // /perf/config/verify?device_id=xxx&platform=x&name=xxx + express.all("/perf/config/verify", new DispatchHttpJsonHandler("{\"code\":0}")); + + // webstatic-sea.hoyoverse.com + express.get("/admin/mi18n/plat_oversea/m202003048/m202003048-version.json", new DispatchHttpJsonHandler("{\"version\":51}")); + } +} diff --git a/src/main/java/emu/grasscutter/server/http/handlers/LogHandler.java b/src/main/java/emu/grasscutter/server/http/handlers/LogHandler.java new file mode 100644 index 000000000..4f52c0826 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/handlers/LogHandler.java @@ -0,0 +1,18 @@ +package emu.grasscutter.server.http.handlers; + +import emu.grasscutter.server.dispatch.ClientLogHandler; +import emu.grasscutter.server.http.Router; +import express.Express; +import io.javalin.Javalin; + +/** + * Handles logging requests made to the server. + */ +public final class LogHandler implements Router { + @Override public void applyRoutes(Express express, Javalin handle) { + // overseauspider.yuanshen.com + express.post("/log", new ClientLogHandler()); + // log-upload-os.mihoyo.com + express.post("/crash/dataUpload", new ClientLogHandler()); + } +} diff --git a/src/main/java/emu/grasscutter/server/http/objects/ComboTokenReqJson.java b/src/main/java/emu/grasscutter/server/http/objects/ComboTokenReqJson.java new file mode 100644 index 000000000..5642f159a --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/objects/ComboTokenReqJson.java @@ -0,0 +1,15 @@ +package emu.grasscutter.server.http.objects; + +public class ComboTokenReqJson { + public int app_id; + public int channel_id; + public String data; + public String device; + public String sign; + + public static class LoginTokenData { + public String uid; + public String token; + public boolean guest; + } +} diff --git a/src/main/java/emu/grasscutter/server/http/objects/ComboTokenResJson.java b/src/main/java/emu/grasscutter/server/http/objects/ComboTokenResJson.java new file mode 100644 index 000000000..b592fa163 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/objects/ComboTokenResJson.java @@ -0,0 +1,17 @@ +package emu.grasscutter.server.http.objects; + +public class ComboTokenResJson { + public String message; + public int retcode; + public LoginData data = new LoginData(); + + public static class LoginData { + public int account_type = 1; + public boolean heartbeat; + public String combo_id; + public String combo_token; + public String open_id; + public String data = "{\"guest\":false}"; + public String fatigue_remind = null; // ? + } +} diff --git a/src/main/java/emu/grasscutter/server/http/objects/LoginAccountRequestJson.java b/src/main/java/emu/grasscutter/server/http/objects/LoginAccountRequestJson.java new file mode 100644 index 000000000..3a8193a97 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/objects/LoginAccountRequestJson.java @@ -0,0 +1,7 @@ +package emu.grasscutter.server.http.objects; + +public class LoginAccountRequestJson { + public String account; + public String password; + public boolean is_crypto; +} diff --git a/src/main/java/emu/grasscutter/server/http/objects/LoginResultJson.java b/src/main/java/emu/grasscutter/server/http/objects/LoginResultJson.java new file mode 100644 index 000000000..5601c1c29 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/objects/LoginResultJson.java @@ -0,0 +1,38 @@ +package emu.grasscutter.server.http.objects; + +public class LoginResultJson { + public String message; + public int retcode; + public VerifyData data = new VerifyData(); + + public static class VerifyData { + public VerifyAccountData account = new VerifyAccountData(); + public boolean device_grant_required = false; + public String realname_operation = "NONE"; + public boolean realperson_required = false; + public boolean safe_mobile_required = false; + } + + public static class VerifyAccountData { + public String uid; + public String name = ""; + public String email = ""; + public String mobile = ""; + public String is_email_verify = "0"; + public String realname = ""; + public String identity_card = ""; + public String token; + public String safe_mobile = ""; + public String facebook_name = ""; + public String twitter_name = ""; + public String game_center_name = ""; + public String google_name = ""; + public String apple_name = ""; + public String sony_name = ""; + public String tap_name = ""; + public String country = "US"; + public String reactivate_ticket = ""; + public String area_code = "**"; + public String device_grant_ticket = ""; + } +} diff --git a/src/main/java/emu/grasscutter/server/http/objects/LoginTokenRequestJson.java b/src/main/java/emu/grasscutter/server/http/objects/LoginTokenRequestJson.java new file mode 100644 index 000000000..d01c60401 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/http/objects/LoginTokenRequestJson.java @@ -0,0 +1,6 @@ +package emu.grasscutter.server.http.objects; + +public class LoginTokenRequestJson { + public String uid; + public String token; +} diff --git a/src/main/java/emu/grasscutter/utils/ConfigContainer.java b/src/main/java/emu/grasscutter/utils/ConfigContainer.java index 76556700c..5a06b90be 100644 --- a/src/main/java/emu/grasscutter/utils/ConfigContainer.java +++ b/src/main/java/emu/grasscutter/utils/ConfigContainer.java @@ -96,8 +96,10 @@ public class ConfigContainer { public ServerDebugMode debugLevel = ServerDebugMode.NONE; public ServerRunMode runMode = ServerRunMode.HYBRID; - public Dispatch dispatch = new Dispatch(); + public HTTP http = new HTTP(); public Game game = new Game(); + + public Dispatch dispatch = new Dispatch(); } public static class Language { @@ -111,8 +113,8 @@ public class ConfigContainer { } /* Server options. */ - - public static class Dispatch { + + public static class HTTP { public String bindAddress = "0.0.0.0"; /* This is the address used in URLs. */ public String accessAddress = "127.0.0.1"; @@ -120,12 +122,9 @@ public class ConfigContainer { public int bindPort = 443; /* This is the port used in URLs. */ public int accessPort = 0; - + public Encryption encryption = new Encryption(); public Policies policies = new Policies(); - public Region[] regions = {}; - - public String defaultName = "Grasscutter"; } public static class Game { @@ -144,6 +143,12 @@ public class ConfigContainer { /* Data containers. */ + public static class Dispatch { + public Region[] regions = {}; + + public String defaultName = "Grasscutter"; + } + public static class Encryption { public boolean useEncryption = true; /* Should 'https' be appended to URLs? */ @@ -226,6 +231,18 @@ public class ConfigContainer { /* Objects. */ public static class Region { + public Region() { } + + public Region( + String name, String title, + String address, int port + ) { + this.Name = name; + this.Title = title; + this.Ip = address; + this.Port = port; + } + public String Name = "os_usa"; public String Title = "Grasscutter"; public String Ip = "127.0.0.1"; diff --git a/src/main/resources/languages/en-US.json b/src/main/resources/languages/en-US.json index c9c3c0c70..b23f2913d 100644 --- a/src/main/resources/languages/en-US.json +++ b/src/main/resources/languages/en-US.json @@ -33,7 +33,8 @@ "session_key_error": "Wrong session key.", "username_error": "Username not found.", "username_create_error": "Username not found, create failed." - } + }, + "router_error": "[Dispatch] Unable to attach router." }, "status": { "free_software": "Grasscutter is FREE software. If you have paid for this, you may have been scammed. Homepage: https://github.com/Grasscutters/Grasscutter",