import argparse import requests import socket import yaml from flask import Flask, Response, request from typing import Any, Dict, Optional import urllib.parse as urlparse from bemani.protocol import EAmuseProtocol, Node # Application configuration app = Flask(__name__) config: Dict[str, Any] = {} def modify_request(config: Dict[str, Any], req_body: Node) -> Optional[Node]: # Not sure if there's any reason to modify requests, but its plumbed return None def modify_response(config: Dict[str, Any], resp_body: Node) -> Optional[Node]: # Figure out if we need to modify anything in the response if resp_body.name != 'response': # Not what we expected, bail return None # Now we get to the meat of packet detection body = resp_body.children[0] # Is this a services packet? We need to modify this to point the rest of the # game packets at this proxy :( if body.name == 'services': for child in body.children: if child.name == 'item': if child.attribute('name') == 'ntp': # Don't override this continue elif child.attribute('name') == 'keepalive': # Completely rewrite to point at the proxy server, otherwise if we're proxying # to a local backend, we will end up giving out a local address and we will get # Network NG. address = socket.gethostbyname(config['keepalive']) child.set_attribute( 'url', f'http://{address}/core/keepalive?pa={address}&ia={address}&ga={address}&ma={address}&t1=2&t2=10', ) else: # Get netloc to replace url = urlparse.urlparse(child.attribute('url')) defaultport = { 'http': 80, 'https': 443, }.get(url.scheme, 0) if config['local_port'] != defaultport: new_url = child.attribute('url').replace(url.netloc, f'{config["local_host"]}:{config["local_port"]}') else: new_url = child.attribute('url').replace(url.netloc, f'{config["local_host"]}') child.set_attribute('url', new_url) return resp_body return None @app.route('/', defaults={'path': ''}, methods=['GET']) @app.route('/', methods=['GET']) def receive_healthcheck(path: str) -> Response: if '*' in config['remote']: remote_host = config['remote']['*']['host'] remote_port = config['remote']['*']['port'] else: return Response("No route for default PCBID", 500) actual_path = f'/{path}' if request.query_string is not None and len(request.query_string) > 0: actual_path = actual_path + f'?{request.query_string.decode("ascii")}' # Make request to foreign service, using the same parameters r = requests.get( f'http://{remote_host}:{remote_port}{actual_path}', timeout=config['timeout'], allow_redirects=False, ) headers = {} for header in ['Location']: if header in r.headers: headers[header] = r.headers[header] return Response(r.content, r.status_code, headers) @app.route('/', defaults={'path': ''}, methods=['POST']) @app.route('/', methods=['POST']) def receive_request(path: str) -> Response: # First, parse the packet itself client_proto = EAmuseProtocol() server_proto = EAmuseProtocol() remote_address = request.headers.get('X-Remote-Address', None) request_compression = request.headers.get('X-Compress', None) request_encryption = request.headers.get('X-Eamuse-Info', None) request_client = request.headers.get('User-Agent', None) actual_path = f'/{path}' if request.query_string is not None and len(request.query_string) > 0: actual_path = actual_path + f'?{request.query_string.decode("ascii")}' if config['verbose']: print(f"HTTP request for URI {actual_path}") print(f"Compression is {request_compression}") print(f"Encryption key is {request_encryption}") req = client_proto.decode( request_compression, request_encryption, request.data, ) if req is None: # Nothing to do here return Response("Unrecognized packet!", 500) if config['verbose']: print("Original request to server:") print(req) # Grab PCBID for directing to mulitple servers pcbid = req.attribute('srcid') if pcbid in config['remote']: remote_host = config['remote'][pcbid]['host'] remote_port = config['remote'][pcbid]['port'] elif '*' in config['remote']: remote_host = config['remote']['*']['host'] remote_port = config['remote']['*']['port'] else: return Response(f"No route for PCBID {pcbid}", 500) modified_request = modify_request(config, req) if modified_request is None: # Return the original binary data instead of re-encoding it # to the exact same thing. req_binary = request.data else: if config['verbose']: print("Modified request to server:") print(modified_request) # Re-encode the modified packet req_binary = server_proto.encode( request_compression, request_encryption, modified_request, client_proto.last_text_encoding, client_proto.last_packet_encoding, ) # Set up custom headers for remote request. headers = { # For lobby functionality, make sure the request receives # the original IP address 'X-Remote-Address': remote_address or request.remote_addr, # Some remote servers can be somewhat buggy, so we make sure # to specify a range of encodings. 'Accept-Encoding': 'identity, deflate, compress, gzip', } # Copy over required headers that are sent by game client. if request_compression: headers['X-Compress'] = request_compression else: headers['X-Compress'] = 'none' if request_encryption: headers['X-Eamuse-Info'] = request_encryption # Make sure to copy the user agent as well. if request_client is not None: headers['User-Agent'] = request_client # Make request to foreign service, using the same parameters prep_req = requests.Request( 'POST', url=f'http://{remote_host}:{remote_port}{actual_path}', headers=headers, data=req_binary, ).prepare() sess = requests.Session() r = sess.send(prep_req, timeout=config['timeout']) if r.status_code != 200: # Failed on remote side return Response("Failed to get response!", 500) # Decode response, for modification if necessary response_compression = r.headers.get('X-Compress', None) response_encryption = r.headers.get('X-Eamuse-Info', None) resp = server_proto.decode( response_compression, response_encryption, r.content, ) if resp is None: # Nothing to do here return Response("Unrecognized packet!", 500) if config['verbose']: print("Original response from server:") print(resp) modified_response = modify_response(config, resp) if modified_response is None: # Return the original response data instead of re-encoding it # to the exact same thing. resp_binary = r.content else: if config['verbose']: print("Modified response from server:") print(modified_response) # Re-encode the modified packet resp_binary = client_proto.encode( response_compression, response_encryption, modified_response, ) # Some old clients are case sensitive, so be careful to capitalize # these responses here. flask_resp = Response(resp_binary) if response_compression is not None: flask_resp.headers['X-Compress'] = response_compression if response_encryption is not None: flask_resp.headers['X-Eamuse-Info'] = response_encryption return flask_resp def load_proxy_config(filename: str) -> None: global config config_data = yaml.safe_load(open(filename)) if 'pcbid' in config_data and config_data['pcbid'] is not None: for pcbid in config_data['pcbid']: remote_name = config_data['pcbid'][pcbid] remote_config = config_data['remote'][remote_name] config['remote'][pcbid] = remote_config def load_config(filename: str) -> None: global config config_data = yaml.safe_load(open(filename)) config.update({ 'local_host': config_data['local']['host'], 'local_port': config_data['local']['port'], 'verbose': config_data.get('verbose', False), 'timeout': config_data.get('timeout', 30), 'keepalive': config_data.get('keepalive', 'localhost'), }) if 'default' in config_data: remote_config = config_data['remote'][config_data['default']] config.update({ 'remote': { '*': remote_config, }, }) else: config.update({ 'remote': {} }) if 'pcbid' in config_data and config_data['pcbid'] is not None: load_proxy_config(filename) if __name__ == '__main__': parser = argparse.ArgumentParser(description="A utility to MITM non-SSL eAmusement connections.") parser.add_argument("-p", "--port", help="Port to listen on. Defaults to 9090", type=int, default=9090) parser.add_argument("-a", "--address", help="Address to listen on. Defaults to all addresses", type=str, default="0.0.0.0") parser.add_argument("-r", "--real-address", help="Real address we are listening on (for NAT and such)", type=str, default='127.0.0.1') parser.add_argument("-q", "--remote-port", help="Port to connect to.", type=int, required=True) parser.add_argument("-b", "--remote-address", help="Address to connect to.", type=str, required=True) parser.add_argument("-c", "--config", help="Configuration file for PCBID to remote server mapping.", type=str, default=None) parser.add_argument("-k", "--keepalive", help="Keepalive domain to advertise. Defaults to localhost", type=str, default='localhost') parser.add_argument("-v", "--verbose", help="Display verbose packet info.", action='store_true') parser.add_argument("-t", "--timeout", help="Timeout (in seconds) for proxy requests. Defaults to 30 seconds.", type=int, default=30) args = parser.parse_args() config.update({ 'local_host': args.real_address, 'local_port': args.port, 'remote': { '*': { 'host': args.remote_address, 'port': args.remote_port, }, }, 'verbose': args.verbose, 'timeout': args.timeout, 'keepalive': args.keepalive, }) # Fill in remote addresses for PCBIDs we should redirect to a non-default server if args.config is not None: load_proxy_config(args.config) app.run(host='0.0.0.0', port=args.port, debug=True)