From 8616c6d064e97e6f95ae1c1d3082c08943c16f5f Mon Sep 17 00:00:00 2001 From: Hay1tsme Date: Sat, 18 Feb 2023 00:00:30 -0500 Subject: [PATCH] copy allnet functionality over --- core/allnet.py | 193 +++++++++++++++++++++++++++++++++++++-- core/config.py | 4 +- core/title.py | 7 +- docs/config.md | 2 +- example_config/core.yaml | 2 +- 5 files changed, 190 insertions(+), 18 deletions(-) diff --git a/core/allnet.py b/core/allnet.py index c5f95fa..3b371ce 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Any, Optional +from typing import Dict, List, Any, Optional, Tuple import logging, coloredlogs from logging.handlers import TimedRotatingFileHandler from twisted.web.http import Request @@ -6,6 +6,10 @@ from datetime import datetime import pytz import base64 import zlib +from Crypto.PublicKey import RSA +from Crypto.Hash import SHA +from Crypto.Signature import PKCS1_v1_5 +from time import strptime from core.config import CoreConfig from core.data import Data @@ -17,6 +21,7 @@ class AllnetServlet: self.config = core_cfg self.config_folder = cfg_folder self.data = Data(core_cfg) + self.uri_registry: Dict[str, Tuple[str, str]] = {} self.logger = logging.getLogger("allnet") if not hasattr(self.logger, "initialized"): @@ -36,28 +41,194 @@ class AllnetServlet: coloredlogs.install(level=core_cfg.allnet.loglevel, logger=self.logger, fmt=log_fmt_str) self.logger.initialized = True - if "game_registry" not in globals(): - globals()["game_registry"] = Utils.get_all_titles() + plugins = Utils.get_all_titles() - if len(globals()["game_registry"]) == 0: + if len(plugins) == 0: self.logger.error("No games detected!") + + for _, mod in plugins.items(): + for code in mod.game_codes: + if hasattr(mod, "use_default_title") and mod.use_default_title: + if hasattr(mod, "include_protocol") and mod.include_protocol: + if hasattr(mod, "title_secure") and mod.title_secure: + uri = "https://" + + else: + uri = "http://" + + else: + uri = "" + + if core_cfg.server.is_develop: + uri += f"{core_cfg.title.hostname}:{core_cfg.title.port}" + + else: + uri += f"{core_cfg.title.hostname}" + + uri += f"/{code}/$v" + + if hasattr(mod, "trailing_slash") and mod.trailing_slash: + uri += "/" + + else: + if hasattr(mod, "uri"): + uri = mod.uri + else: + uri = "" + + if hasattr(mod, "host"): + host = mod.host + + elif hasattr(mod, "use_default_host") and mod.use_default_host: + if core_cfg.server.is_develop: + host = f"{core_cfg.title.hostname}:{core_cfg.title.port}" + + else: + host = f"{core_cfg.title.hostname}" + + else: + host = "" + + self.uri_registry[code] = (uri, host) + self.logger.info(f"Allnet serving {len(self.uri_registry)} games on port {core_cfg.allnet.port}") def handle_poweron(self, request: Request): + request_ip = request.getClientAddress().host try: req = AllnetPowerOnRequest(self.allnet_req_to_dict(request.content.getvalue())) # Validate the request. Currently we only validate the fields we plan on using if not req.game_id or not req.ver or not req.token or not req.serial or not req.ip: - raise AllnetRequestException(f"Bad request params {vars(req)}") + raise AllnetRequestException(f"Bad auth request params from {request_ip} - {vars(req)}") + + except AllnetRequestException as e: + self.logger.error(e) + return b"" + + if req.format_ver == 3: + resp = AllnetPowerOnResponse3(req.token) + else: + resp = AllnetPowerOnResponse2() + + if req.game_id not in self.uri_registry: + msg = f"Unrecognised game {req.game_id} attempted allnet auth from {request_ip}." + self.data.base.log_event("allnet", "ALLNET_AUTH_UNKNOWN_GAME", logging.WARN, msg) + self.logger.warn(msg) + + resp.stat = 0 + return self.dict_to_http_form_string([vars(resp)]) + + resp.uri, resp.host = self.uri_registry[req.game_id] + + machine = self.data.arcade.get_machine(req.serial) + if machine is None and not self.config.server.allow_unregistered_serials: + msg = f"Unrecognised serial {req.serial} attempted allnet auth from {request_ip}." + self.data.base.log_event("allnet", "ALLNET_AUTH_UNKNOWN_SERIAL", logging.WARN, msg) + self.logger.warn(msg) + + resp.stat = 0 + return self.dict_to_http_form_string([vars(resp)]) + + if machine is not None: + arcade = self.data.arcade.get_arcade(machine["arcade"]) + req.country = arcade["country"] if machine["country"] is None else machine["country"] + req.place_id = arcade["id"] + req.allnet_id = machine["id"] + req.name = arcade["name"] + req.nickname = arcade["nickname"] + req.region0 = arcade["region_id"] + req.region_name0 = arcade["country"] + req.region_name1 = arcade["state"] + req.region_name2 = arcade["city"] + req.client_timezone = arcade["timezone"] if arcade["timezone"] is not None else "+0900" + + msg = f"{req.serial} authenticated from {request_ip}: {req.game_id} v{req.ver}" + self.data.base.log_event("allnet", "ALLNET_AUTH_SUCCESS", logging.INFO, msg) + self.logger.info(msg) + + return self.dict_to_http_form_string([vars(resp)]) + + def handle_dlorder(self, request: Request): + request_ip = request.getClientAddress().host + try: + req = AllnetDownloadOrderRequest(self.allnet_req_to_dict(request.content.getvalue())) + # Validate the request. Currently we only validate the fields we plan on using + + if not req.game_id or not req.ver or not req.token or not req.serial or not req.ip: + raise AllnetRequestException(f"Bad auth request params from {request_ip} - {vars(req)}") + except AllnetRequestException as e: self.logger.error(e) return b"" - def handle_dlorder(self, request: Request): - pass + resp = AllnetDownloadOrderResponse() + if not self.config.allnet.allow_online_updates: + return self.dict_to_http_form_string(vars(resp)) + + else: # TODO: Actual dlorder response + return self.dict_to_http_form_string(vars(resp)) def handle_billing_request(self, request: Request): - pass + req_dict = self.billing_req_to_dict(request.content.getvalue()) + request_ip = request.getClientAddress() + if req_dict is None: + self.logger.error(f"Failed to parse request {request.content.getvalue()}") + return b"" + + self.logger.debug(f"request {req_dict}") + + rsa = RSA.import_key(open(self.config.billing.sign_key, 'rb').read()) + signer = PKCS1_v1_5.new(rsa) + digest = SHA.new() + + kc_playlimit = int(req_dict[0]["playlimit"]) + kc_nearfull = int(req_dict[0]["nearfull"]) + kc_billigtype = int(req_dict[0]["billingtype"]) + kc_playcount = int(req_dict[0]["playcnt"]) + kc_serial: str = req_dict[0]["keychipid"] + kc_game: str = req_dict[0]["gameid"] + kc_date = strptime(req_dict[0]["date"], "%Y%m%d%H%M%S") + kc_serial_bytes = kc_serial.encode() + + machine = self.data.arcade.get_machine(kc_serial) + if machine is None and not self.config.server.allow_unregistered_serials: + msg = f"Unrecognised serial {kc_serial} attempted billing checkin from {request_ip} for game {kc_game}." + self.data.base.log_event("allnet", "BILLING_CHECKIN_NG_SERIAL", logging.WARN, msg) + self.logger.warn(msg) + + resp = BillingResponse("", "", "", "") + resp.result = "1" + return self.dict_to_http_form_string([vars(resp)]) + + msg = f"Billing checkin from {request.getClientIP()}: game {kc_game} keychip {kc_serial} playcount " \ + f"{kc_playcount} billing_type {kc_billigtype} nearfull {kc_nearfull} playlimit {kc_playlimit}" + self.logger.info(msg) + self.data.base.log_event('billing', 'BILLING_CHECKIN_OK', logging.INFO, msg) + + while kc_playcount > kc_playlimit: + kc_playlimit += 1024 + kc_nearfull += 1024 + + playlimit = kc_playlimit + nearfull = kc_nearfull + (kc_billigtype * 0x00010000) + + digest.update(playlimit.to_bytes(4, 'little') + kc_serial_bytes) + playlimit_sig = signer.sign(digest).hex() + + digest = SHA.new() + digest.update(nearfull.to_bytes(4, 'little') + kc_serial_bytes) + nearfull_sig = signer.sign(digest).hex() + + # TODO: playhistory + + resp = BillingResponse(playlimit, playlimit_sig, nearfull, nearfull_sig) + + resp_str = self.dict_to_http_form_string([vars(resp)]) + if resp_str is None: + self.logger.error(f"Failed to parse response {vars(resp)}") + + self.logger.debug(f"response {vars(resp)}") + return resp_str.encode("utf-8") def kvp_to_dict(self, *kvp: str) -> List[Dict[str, Any]]: ret: List[Dict[str, Any]] = [] @@ -207,7 +378,7 @@ class AllnetDownloadOrderResponse(): self.uri = uri class BillingResponse(): - def __init__(self, playlimit: str, playlimit_sig: str, nearfull: str, nearfull_sig: str, + def __init__(self, playlimit: str = "", playlimit_sig: str = "", nearfull: str = "", nearfull_sig: str = "", playhistory: str = "000000/0:000000/0:000000/0") -> None: self.result = "0" @@ -226,4 +397,6 @@ class BillingResponse(): # YYYY -> 4 digit year, MM -> 2 digit month, C -> Playcount during that period class AllnetRequestException(Exception): - pass + def __init__(self, message="Allnet Request Error") -> None: + self.message = message + super().__init__(self.message) diff --git a/core/config.py b/core/config.py index c97ee96..f8f57e4 100644 --- a/core/config.py +++ b/core/config.py @@ -14,8 +14,8 @@ class ServerConfig: return CoreConfig.get_config_field(self.__config, 'core', 'server', 'allow_user_registration', default=True) @property - def allow_unregistered_games(self) -> bool: - return CoreConfig.get_config_field(self.__config, 'core', 'server', 'allow_unregistered_games', default=True) + def allow_unregistered_serials(self) -> bool: + return CoreConfig.get_config_field(self.__config, 'core', 'server', 'allow_unregistered_serials', default=True) @property def name(self) -> str: diff --git a/core/title.py b/core/title.py index 4d475cb..dd47ec8 100644 --- a/core/title.py +++ b/core/title.py @@ -33,10 +33,9 @@ class TitleServlet(): coloredlogs.install(level=core_cfg.title.loglevel, logger=self.logger, fmt=log_fmt_str) self.logger.initialized = True - if "game_registry" not in globals(): - globals()["game_registry"] = Utils.get_all_titles() + plugins = Utils.get_all_titles() - for folder, mod in globals()["game_registry"].items(): + for folder, mod in plugins.items(): if hasattr(mod, "game_codes") and hasattr(mod, "index"): handler_cls = mod.index(self.config, self.config_folder) if hasattr(handler_cls, "setup"): @@ -48,7 +47,7 @@ class TitleServlet(): else: self.logger.error(f"{folder} missing game_code or index in __init__.py") - self.logger.info(f"Serving {len(globals()['game_registry'])} game codes") + self.logger.info(f"Serving {len(self.title_registry)} game codes on port {core_cfg.title.port}") def render_GET(self, request: Request, endpoints: dict) -> bytes: code = endpoints["game"] diff --git a/docs/config.md b/docs/config.md index 513ad4d..3e4ba8a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2,7 +2,7 @@ ## Server - `listen_address`: IP Address or hostname that the server will listen for connections on. Set to 127.0.0.1 for local only, or 0.0.0.0 for all interfaces. Default `127.0.0.1` - `allow_user_registration`: Allows users to register in-game via the AimeDB `register` function. Disable to be able to control who can use cards on your server. Default `True` -- `allow_unregistered_games`: Allows games that do not have registered keychips to connect and authenticate. Disable to restrict who can connect to your server. Recomended to disable for production setups. Default `True` +- `allow_unregistered_serials`: Allows games that do not have registered keychips to connect and authenticate. Disable to restrict who can connect to your server. Recomended to disable for production setups. Default `True` - `name`: Name for the server, used by some games in their default MOTDs. Default `ARTEMiS` - `is_develop`: Flags that the server is a development instance without a proxy standing in front of it. Setting to `False` tells the server not to listen for SSL, because the proxy should be handling all SSL-related things, among other things. Default `True` - `log_dir`: Directory to store logs. Server MUST have read and write permissions to this directory or you will have issues. Default `logs` diff --git a/example_config/core.yaml b/example_config/core.yaml index 86d31a5..94e89ba 100644 --- a/example_config/core.yaml +++ b/example_config/core.yaml @@ -1,7 +1,7 @@ server: listen_address: "127.0.0.1" allow_user_registration: True - allow_unregistered_games: True + allow_unregistered_serials: True name: "ARTEMiS" is_develop: True log_dir: "logs"