Server stuff

This commit is contained in:
Bottersnike 2022-11-17 01:56:45 +00:00
parent f3e9db8cb1
commit 4ca5e03f59
27 changed files with 2567 additions and 1147 deletions

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "server"]
path = eaapi/server
url = https://gitea.tendokyu.moe/eamuse/server.git

View File

@ -5,9 +5,14 @@ from .decoder import Decoder
from .wrapper import wrap, unwrap
from .misc import parse_model
from .exception import EAAPIException
from . import crypt
__all__ = (
"Type", "ServicesMode", "Compression",
"XMLNode", "Encoder", "Decoder",
"wrap", "unwrap",
"parse_model",
"EAAPIException",
"crypt",
)

View File

@ -8,17 +8,17 @@ from .keys import CARDCONV_KEY
from .const import CARD_ALPHABET
def enc_des(uid):
def enc_des(uid: bytes) -> bytes:
cipher = DES3.new(CARDCONV_KEY, DES3.MODE_CBC, iv=b'\0' * 8)
return cipher.encrypt(uid)
def dec_des(uid):
def dec_des(uid: bytes) -> bytes:
cipher = DES3.new(CARDCONV_KEY, DES3.MODE_CBC, iv=b'\0' * 8)
return cipher.decrypt(uid)
def checksum(data):
def checksum(data: bytes) -> int:
chk = sum(data[i] * (i % 3 + 1) for i in range(15))
while chk > 31:
@ -27,7 +27,7 @@ def checksum(data):
return chk
def uid_to_konami(uid):
def uid_to_konami(uid: str) -> str:
assert_true(len(uid) == 16, "UID must be 16 bytes", InvalidCard)
if uid.upper().startswith("E004"):
@ -52,7 +52,7 @@ def uid_to_konami(uid):
return "".join(CARD_ALPHABET[i] for i in out)
def konami_to_uid(konami_id):
def konami_to_uid(konami_id: str) -> str:
if konami_id[14] == "1":
card_type = 1
elif konami_id[14] == "2":
@ -62,7 +62,7 @@ def konami_to_uid(konami_id):
assert_true(len(konami_id) == 16, "ID must be 16 characters", InvalidCard)
assert_true(all(i in CARD_ALPHABET for i in konami_id), "ID contains invalid characters", InvalidCard)
card = [CARD_ALPHABET.index(i) for i in konami_id]
card = bytearray([CARD_ALPHABET.index(i) for i in konami_id])
assert_true(card[11] % 2 == card[12] % 2, "Parity check failed", InvalidCard)
assert_true(card[13] == card[12] ^ 1, "Card invalid", InvalidCard)
assert_true(card[15] == checksum(card), "Checksum failed", InvalidCard)

View File

@ -59,7 +59,7 @@ class _Type:
fmt: str
names: List[str]
c_name: str
convert: Callable
convert: Callable | None
size: int = 1
no_check: bool = False
@ -75,7 +75,14 @@ class _Type:
return (*map(self.convert, value),)
def parse_ip(ip):
def parse_ip(ip: int | str) -> tuple[int, int, int, int]:
if isinstance(ip, int):
return (
(ip >> 24) & 0xff,
(ip >> 16) & 0xff,
(ip >> 8) & 0xff,
(ip >> 0) & 0xff,
)
return (*map(int, ip.split(".")),)
@ -92,6 +99,7 @@ class Type(enum.Enum):
Blob = _Type(0x0a, "S", ["bin", "binary"], "char[]", bytes)
Str = _Type(0x0b, "s", ["str", "string"], "char[]", unescape)
IPv4 = _Type(0x0c, "4B", ["ip4"], "uint8[4]", parse_ip, 1, True)
IPv4_Int = _Type(0x0c, "I", ["ip4"], "uint8[4]", parse_ip, 1, True)
Time = _Type(0x0d, "I", ["time"], "uint32", int)
Float = _Type(0x0e, "f", ["float", "f"], "float", float)
Double = _Type(0x0f, "d", ["double", "d"], "double", float)

View File

@ -25,7 +25,8 @@ prng = new_prng()
def validate_key(info):
match = re.match(r"^(\d)-([0-9a-f]{8})-([0-9a-f]{4})$", info)
assert_true(match, "Invalid eamuse info key")
assert_true(match is not None, "Invalid eamuse info key")
assert match is not None
version = match.group(1)
assert_true(version == "1", f"Unsupported encryption version ({version})")

View File

@ -14,7 +14,7 @@ except ModuleNotFoundError:
from .packer import Packer
from .const import (
NAME_MAX_COMPRESSED, NAME_MAX_DECOMPRESSED, ATTR, PACK_ALPHABET, END_NODE, END_DOC, ARRAY_BIT,
ENCODING, CONTENT, CONTENT_COMP, CONTENT_FULL, XML_ENCODING, Type
ENCODING, CONTENT, CONTENT_COMP, CONTENT_FULL, XML_ENCODING, DEFAULT_ENCODING, Type
)
from .misc import unpack, py_encoding, assert_true
from .node import XMLNode
@ -45,7 +45,7 @@ class Decoder:
if self.packer:
self.packer.notify_skipped(length)
raw = self.stream.read(length)
return raw.decode(py_encoding(self.encoding)).rstrip("\0")
return raw.decode(py_encoding(self.encoding or DEFAULT_ENCODING)).rstrip("\0")
length = struct.calcsize("=" + s_format)
if self.packer and align:
@ -55,22 +55,27 @@ class Decoder:
value = struct.unpack(">" + s_format, data)
return value[0] if single else value
def _read_node_value(self, node):
def _read_node_value(self, node: XMLNode) -> None:
fmt = node.type.value.fmt
count = 1
if node.is_array:
length = struct.calcsize("=" + fmt)
count = self.read("I") // length
nbytes = self.read("I")
assert isinstance(nbytes, int)
count = nbytes // length
values = []
for _ in range(count):
values.append(self.read(fmt, single=len(fmt) == 1, align=False))
self.packer.notify_skipped(count * length)
return values
assert self.packer is not None
self.packer.notify_skipped(count * length)
node.value = values
else:
node.value = self.read(fmt, single=len(fmt) == 1)
def _read_metadata_name(self):
def _read_metadata_name(self) -> str:
length = self.read("B")
assert isinstance(length, int)
if not self.compressed:
if length < 0x80:
@ -78,14 +83,16 @@ class Decoder:
# i.e. length = (length & ~0x40) + 1
length -= 0x3f
else:
length = (length << 8) | self.read("B")
extra = self.read("B")
assert isinstance(extra, int)
length = (length << 8) | extra
# i.e. length = (length & ~0x8000) + 0x41
length -= 0x7fbf
assert_true(length <= NAME_MAX_DECOMPRESSED, "Name length too long", DecodeError)
name = self.stream.read(length)
assert_true(len(name) == length, "Not enough bytes to read name", DecodeError)
return name.decode(self.encoding)
return name.decode(self.encoding or "")
out = ""
if length == 0:
@ -99,7 +106,7 @@ class Decoder:
def _read_metadata(self, type_):
name = self._read_metadata_name()
node = XMLNode(name, type_, None, encoding=self.encoding)
node = XMLNode(name, type_, None, encoding=self.encoding or DEFAULT_ENCODING)
while (child := self.read("B")) != END_NODE:
if child == ATTR:
@ -109,8 +116,8 @@ class Decoder:
node.children.append(attr)
else:
node.children.append(self._read_metadata(child))
is_array = not not (type_ & ARRAY_BIT)
if is_array:
if type_ & ARRAY_BIT:
node.value = []
return node
@ -140,6 +147,8 @@ class Decoder:
def _read_xml_string(self):
assert_true(etree is not None, "lxml missing", DecodeError)
assert etree is not None
parser = etree.XMLParser(remove_comments=True)
tree = etree.XML(self.stream.read(), parser)
self.encoding = XML_ENCODING[tree.getroottree().docinfo.encoding.upper()]
@ -164,7 +173,11 @@ class Decoder:
d_type = type_.value
if d_type.size == 1 and not is_array:
try:
value = d_type._parse(node.text or "")
except ValueError:
print(f"Failed to parse {node.tag} ({d_type.names[0]}): {repr(node.text)}")
raise
else:
data = node.text.split(" ")
@ -174,7 +187,7 @@ class Decoder:
if not is_array:
value = value[0]
xml_node = XMLNode(node.tag, type_, value, encoding=self.encoding)
xml_node = XMLNode(node.tag, type_, value, encoding=self.encoding or DEFAULT_ENCODING)
for i in node.getchildren():
xml_node.children.append(walk(i))
@ -198,21 +211,27 @@ class Decoder:
self._read_magic()
header_len = self.read("I")
assert isinstance(header_len, int)
start = self.stream.tell()
schema = self._read_metadata(self.read("B"))
assert_true(self.read("B") == END_DOC, "Unterminated schema", DecodeError)
padding = header_len - (self.stream.tell() - start)
assert_true(padding >= 0, "Invalid schema definition", DecodeError)
assert_true(all(i == 0 for i in self.stream.read(padding)), "Invalid schema padding", DecodeError)
assert_true(
all(i == 0 for i in self.stream.read(padding)), "Invalid schema padding", DecodeError
)
body_len = self.read("I")
assert isinstance(body_len, int)
start = self.stream.tell()
self.packer = Packer(start)
data = self._read_databody(schema)
self.stream.seek(self.packer.request_allocation(0))
padding = body_len - (self.stream.tell() - start)
assert_true(padding >= 0, "Data shape not match schema", DecodeError)
assert_true(all(i == 0 for i in self.stream.read(padding)), "Invalid data padding", DecodeError)
assert_true(
all(i == 0 for i in self.stream.read(padding)), "Invalid data padding", DecodeError
)
assert_true(self.stream.read(1) == b"", "Trailing data unconsumed", DecodeError)

View File

@ -9,6 +9,7 @@ from .const import (
CONTENT_ASCII_SCHEMA
)
from .exception import EncodeError
from .node import XMLNode
class Encoder:
@ -36,11 +37,13 @@ class Encoder:
def write(self, s_format, value, single=True):
if s_format == "S":
assert self.packer is not None
self.write("L", len(value))
self.stream.write(value)
self.packer.notify_skipped(len(value))
return
if s_format == "s":
assert self.packer is not None
value = value.encode(py_encoding(ENCODING[self.encoding])) + b"\0"
self.write("L", len(value))
self.stream.write(value)
@ -49,17 +52,12 @@ class Encoder:
length = struct.calcsize("=" + s_format)
if not isinstance(value, list):
value = [value]
count = len(value)
if count != 1:
self.write("L", count * length)
self.packer.notify_skipped(count * length)
if isinstance(value, list):
assert self.packer is not None
self.write("L", len(value) * length)
self.packer.notify_skipped(len(value) * length)
for x in value:
if self.packer and count == 1:
self.stream.seek(self.packer.request_allocation(length))
try:
if single:
self.stream.write(struct.pack(f">{s_format}", x))
@ -67,6 +65,17 @@ class Encoder:
self.stream.write(struct.pack(f">{s_format}", *x))
except struct.error:
raise ValueError(f"Failed to pack {s_format}: {repr(x)}")
else:
if self.packer:
self.stream.seek(self.packer.request_allocation(length))
try:
if single:
self.stream.write(struct.pack(f">{s_format}", value))
else:
self.stream.write(struct.pack(f">{s_format}", *value))
except struct.error:
raise ValueError(f"Failed to pack {s_format}: {repr(value)}")
def _write_node_value(self, type_, value):
fmt = type_.value.fmt
@ -105,7 +114,7 @@ class Encoder:
self._write_metadata(child)
self.write("B", END_NODE)
def _write_databody(self, data):
def _write_databody(self, data: XMLNode):
self._write_node_value(data.type, data.value)
for attr in data.attributes:

View File

@ -20,3 +20,15 @@ class EncodeError(CheckFailed):
class InvalidModel(EAAPIException):
pass
class XMLStrutureError(EAAPIException):
pass
class NodeNotFound(XMLStrutureError, IndexError):
pass
class AttributeNotFound(XMLStrutureError, KeyError):
pass

View File

@ -10,7 +10,7 @@ MAX_LEN = 0xF + THRESHOLD
MAX_BUFFER = 0x10 + 1
def match_current(window, pos, max_len, data, dpos):
def match_current(window: bytes, pos: int, max_len: int, data: bytes, dpos: int) -> int:
length = 0
while (
dpos + length < len(data)
@ -22,7 +22,7 @@ def match_current(window, pos, max_len, data, dpos):
return length
def match_window(window, pos, data, d_pos):
def match_window(window: bytes, pos: int, data: bytes, d_pos: int) -> None | tuple[int, int]:
max_pos = 0
max_len = 0
for i in range(THRESHOLD, LOOK_RANGE):
@ -37,9 +37,9 @@ def match_window(window, pos, data, d_pos):
return None
def lz77_compress(data):
def lz77_compress(data: bytes) -> bytes:
output = bytearray()
window = [0] * WINDOW_SIZE
window = bytearray(WINDOW_SIZE)
current_pos = 0
current_window = 0
current_buffer = 0
@ -95,10 +95,10 @@ def lz77_compress(data):
return bytes(output)
def lz77_decompress(data):
def lz77_decompress(data: bytes) -> bytes:
output = bytearray()
cur_byte = 0
window = [0] * WINDOW_SIZE
window = bytearray(WINDOW_SIZE)
window_cursor = 0
while cur_byte < len(data):

View File

@ -1,24 +1,27 @@
import inspect
import re
from typing import Type
from .exception import CheckFailed, InvalidModel
def assert_true(check, reason, exc=CheckFailed):
def assert_true(check: bool, reason: str, exc: Type[Exception] = CheckFailed):
if not check:
line = inspect.stack()[1].code_context
if line:
print()
print("\n".join(line))
raise exc(reason)
def py_encoding(name):
def py_encoding(name: str) -> str:
if name.startswith("shift-jis"):
return "shift-jis"
return name
def parse_model(model):
def parse_model(model: str) -> tuple[str, str, str, str, str]:
# e.g. KFC:J:A:A:2019020600
match = re.match(r"^([A-Z0-9]{3}):([A-Z]):([A-Z]):([A-Z])(?::(\d{10}))?$", model)
if match is None:
@ -27,7 +30,7 @@ def parse_model(model):
return gamecode, dest, spec, rev, datecode
def pack(data, width):
def pack(data, width: int) -> bytes:
assert_true(1 <= width <= 8, "Invalid pack size")
assert_true(all(i < (1 << width) for i in data), "Data too large for packing")
bit_buf = in_buf = 0
@ -48,7 +51,7 @@ def pack(data, width):
return bytes(output)
def unpack(data, width):
def unpack(data, width: int) -> bytes:
assert_true(1 <= width <= 8, "Invalid pack size")
bit_buf = in_buf = 0
output = bytearray()

View File

@ -1,17 +1,20 @@
import binascii
import re
from typing import Generator, Any
from html import escape
from .misc import assert_true
from .const import DEFAULT_ENCODING, NAME_MAX_COMPRESSED, XML_ENCODING_BACK, Type
from .exception import XMLStrutureError, NodeNotFound, AttributeNotFound
class XMLNode:
def __init__(self, name, type_, value, attributes=None, encoding=DEFAULT_ENCODING):
self.name = name
self.type = type_ if isinstance(type_, Type) else Type.from_val(type_)
self.value = value
self.value: Any = value # TODO: A stricter way to do this. Subclassing?
self.children = []
self.attributes = {}
if attributes is not None:
@ -41,12 +44,12 @@ class XMLNode:
for i in self.children:
if i.name == child:
return i._xpath(attr, path)
raise IndexError
raise NodeNotFound
if not attr:
return self
if attr in self.attributes:
return self.attributes[attr]
raise IndexError
raise AttributeNotFound
def xpath(self, path):
match = re.match(r"^(?:@([\w:]+)/)?((?:[\w:]+(?:/|$))+)", path)
@ -64,22 +67,26 @@ class XMLNode:
def __len__(self):
return len(self.children)
def __iter__(self):
def __iter__(self) -> Generator["XMLNode", None, None]:
for i in self.children:
yield i
def get(self, name, default=None):
try:
return self[name]
except IndexError:
return default
except KeyError:
except XMLStrutureError:
return default
def __getitem__(self, name):
if isinstance(name, int):
try:
return self.children[name]
except IndexError:
raise NodeNotFound
try:
return self.attributes[name]
except KeyError:
raise AttributeNotFound
def __setitem__(self, name, value):
self.attributes[name] = value
@ -94,12 +101,19 @@ class XMLNode:
def __str__(self):
return self.to_str(pretty=True)
def _value_str(self, value):
def _value_str(self, value: Any) -> str:
if isinstance(value, list):
return " ".join(map(self._value_str, value))
if self.type == Type.Blob:
return binascii.hexlify(value).decode()
if self.type == Type.IPv4:
if self.type == Type.IPv4 or self.type == Type.IPv4_Int:
if isinstance(value, int):
value = (
(value >> 24) & 0xff,
(value >> 16) & 0xff,
(value >> 8) & 0xff,
(value >> 0) & 0xff,
)
return f"{value[0]}.{value[1]}.{value[2]}.{value[3]}"
if self.type in (Type.Float, Type.TwoFloat, Type.ThreeFloat):
return f"{value:.6f}"

View File

@ -2,17 +2,17 @@ import math
class Packer:
def __init__(self, offset=0):
def __init__(self, offset: int = 0):
self._word_cursor = offset
self._short_cursor = offset
self._byte_cursor = offset
self._boundary = offset % 4
def _next_block(self):
def _next_block(self) -> int:
self._word_cursor += 4
return self._word_cursor - 4
def request_allocation(self, size):
def request_allocation(self, size: int) -> int:
if size == 0:
return self._word_cursor
elif size == 1:
@ -33,7 +33,7 @@ class Packer:
self._word_cursor += 4
return old_cursor
def notify_skipped(self, no_bytes):
def notify_skipped(self, no_bytes: int) -> None:
for _ in range(math.ceil(no_bytes / 4)):
self.request_allocation(4)

@ -1 +0,0 @@
Subproject commit dec7ca3536cf459be21b7284358bf2bea4eb3d14

2
eaapi/server/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.pyc
__pycache__/

43
eaapi/server/README.md Normal file
View File

@ -0,0 +1,43 @@
# eaapi.server
## Quickstart
```py
server = EAMServer("http://127.0.0.1:5000")
@server.handler("message", "get")
def message(ctx):
ctx.resp.append("message", expire="300", status="0")
server.run("0.0.0.0", 5000)
```
```py
EAMServer(
# The URL this server can be access at. Used for services
public_url,
# Add `/<service>/` as a prefix when generating service urls (useful when debugging games)
prefix_services: bool = False,
# If both the URL and the query params match, which one gets the final say?
prioritise_params: bool = False,
# Include e-Amusement specific details of why requests failed in the responses
verbose_errors: bool = False,
# The operation mode in services.get's response
services_mode: eaapi.const.ServicesMode = eaapi.const.ServicesMode.Operation,
# The NTP server to use in services.get
ntp_server: str = "ntp://pool.ntp.org/",
# Keepalive server to use in serices.get. We'll use our own if one is not specified
keepalive_server: str = None
)
@handler(
# Module name to handle. Will curry if method is not provided
module,
# Method name to handle
method=None,
# The datecode prefix to match during routing
dc_prefix=None,
# The service to use. Likely `local` or `local2` when handling game functions
service=None
)
```

13
eaapi/server/__init__.py Normal file
View File

@ -0,0 +1,13 @@
from .server import EAMServer
from .context import CallContext
from .model import Model, ModelMatcher, DatecodeMatcher
from .exceptions import EAMHTTPException
from .controller import Controller
__all__ = (
"EAMServer",
"CallContext",
"Model", "ModelMatcher", "DatecodeMatcher",
"EAMHTTPException",
"Controller",
)

4
eaapi/server/__main__.py Normal file
View File

@ -0,0 +1,4 @@
from .server import EAMServer
app = EAMServer("http://127.0.0.1:5000", verbose_errors=True)
app.run("0.0.0.0", 5000, debug=True)

45
eaapi/server/const.py Normal file
View File

@ -0,0 +1,45 @@
# Services where the module and service name match
TRIVIAL_SERVICES = [
"pcbtracker",
"message",
"facility",
"pcbevent",
"cardmng",
"package",
"userdata",
"userid",
"dlstatus",
"eacoin",
# "traceroute",
"apsmanager",
"sidmgr",
]
# Just chilling here until I figure out where these route
UNMAPPED_SERVICES = {
"???0": "numbering",
"???1": "pkglist",
"???2": "posevent",
"???3": "lobby",
"???4": "lobby2",
"???5": "netlog", # ins.netlog (?)
"???6": "globby",
"???7": "matching",
"???8": "netsci",
}
MODULE_SERVICES = "services"
RESERVED_MODULES = [
MODULE_SERVICES,
]
METHOD_SERVICES_GET = "get"
SERVICE_SERVICES = "services"
SERVICE_KEEPALIVE = "keepalive"
SERVICE_NTP = "ntp"
RESERVED_SERVICES = [
SERVICE_SERVICES,
SERVICE_KEEPALIVE,
SERVICE_NTP,
]

105
eaapi/server/context.py Normal file
View File

@ -0,0 +1,105 @@
import eaapi
from . import exceptions as exc
from .model import Model
NODE_CALL = "call"
NODE_RESP = "response"
class CallContext:
def __init__(self, request, decoder, call, eainfo, compressed):
if call.name != NODE_CALL:
raise exc.CallNodeMissing
self._request = request
self._decoder = decoder
self._call: eaapi.XMLNode = call
self._eainfo: str | None = eainfo
self._compressed: bool = compressed
self._resp: eaapi.XMLNode = eaapi.XMLNode.void(NODE_RESP)
self._module: str | None = None
self._method: str | None = None
self._url_slash: bool | None = None
self._model: Model = Model.from_model_str(call.get("model"))
@property
def module(self):
return self._module
@property
def method(self):
return self._method
@property
def url_slash(self):
return self._url_slash
@property
def request(self):
return self._request
@property
def was_xml_string(self):
return self._decoder.is_xml_string
@property
def was_compressed(self):
return self._compressed
@property
def call(self):
return self._call
@property
def resp(self):
return self._resp
@property
def model(self):
return self._model
@property
def srcid(self):
return self._call.get("srcid")
@property
def tag(self):
return self._call.get("tag")
def get_root(self):
return self.call.xpath(self.module)
def abort(self, status="1"):
return self.resp.append(self.module, status=status)
def ok(self):
return self.abort("0")
class ResponseContext:
def __init__(self, resp, decoder, response, compressed):
if response.name != NODE_RESP:
raise exc.CallNodeMissing
self._resp = resp
self._decoder = decoder
self._response = response
self._compressed = compressed
@property
def resp(self):
return self._resp
@property
def decoder(self):
return self._decoder
@property
def response(self):
return self._response
@property
def compressed(self):
return self._compressed

210
eaapi/server/controller.py Normal file
View File

@ -0,0 +1,210 @@
from typing import Callable
from collections import defaultdict
from abc import ABC
from .context import CallContext
from .model import ModelMatcher
from .const import RESERVED_MODULES, RESERVED_SERVICES, TRIVIAL_SERVICES, MODULE_SERVICES, METHOD_SERVICES_GET
import eaapi
Handler = Callable[[CallContext], None]
class IController(ABC):
_name: str
def get_handler(self, ctx: CallContext) -> Handler | None:
raise NotImplementedError
def get_service_routes(self, ctx: CallContext | None) -> dict[str, str]:
raise NotImplementedError
def serviced_prefixes(self) -> list[str]:
raise NotImplementedError
class Controller(IController):
def __init__(self, server, endpoint="", matcher: None | ModelMatcher = None):
from .server import EAMServer
self._server: EAMServer = server
server.controllers.append(self)
import inspect
caller = inspect.getmodule(inspect.stack()[1][0])
assert caller is not None
self._name = caller.__name__
self._handlers: dict[
tuple[str | None, str | None],
list[tuple[ModelMatcher, Handler]]
] = defaultdict(lambda: [])
self._endpoint = endpoint
self._matcher = matcher
self._services: set[tuple[ModelMatcher, str]] = set()
self._pre_handler: list[Callable[[CallContext, Handler], Handler]] = []
@property
def server(self):
return self._server
def on_pre(self, callback):
if callback not in self._pre_handler:
self._pre_handler.append(callback)
def add_dummy_service(
self,
service: str,
matcher: ModelMatcher | None = None,
unsafe_force_bypass_reserved: bool = False
):
if not unsafe_force_bypass_reserved:
if service in RESERVED_SERVICES:
raise KeyError(
f"{service} is a reserved service provided by default.\n"
"Pass unsafe_force_bypass_reserved=True to override this implementation"
)
if matcher is None:
matcher = ModelMatcher()
self._services.add((matcher, service))
def register_handler(
self,
handler: Handler,
module: str,
method: str,
matcher: ModelMatcher | None = None,
service: str | None = None,
unsafe_force_bypass_reserved: bool = False
):
if not unsafe_force_bypass_reserved:
if module in RESERVED_MODULES:
raise KeyError(
f"{module} is a reserved module provided by default.\n"
"Pass unsafe_force_bypass_reserved=True to override this implementation"
)
if service is not None and service in RESERVED_SERVICES:
raise KeyError(
f"{service} is a reserved service provided by default.\n"
"Pass unsafe_force_bypass_reserved=True to override this implementation"
)
if service is None:
if module not in TRIVIAL_SERVICES:
raise ValueError(f"Unable to identify service for {module}")
service = module
handlers = self._handlers[(module, method)]
for i in handlers:
if matcher is None and i[0] is None:
raise ValueError(f"Duplicate default handler for {module}.{method}")
if matcher == i[0]:
raise ValueError(f"Duplicate handler for {module}.{method} ({matcher})")
matcher_ = matcher or ModelMatcher()
handlers.append((matcher_, handler))
handlers.sort(key=lambda x: x[0])
self._services.add((matcher_, service))
def handler(
self,
module: str,
method: str | None = None,
matcher: ModelMatcher | None = None,
service: str | None = None,
unsafe_force_bypass_reserved: bool = False
):
if method is None:
def h2(method):
return self.handler(module, method, matcher, service)
return h2
# Commented out for MachineCallContext bodge
# def decorator(handler: Handler):
def decorator(handler):
self.register_handler(handler, module, method, matcher, service, unsafe_force_bypass_reserved)
return handler
return decorator
def get_handler(self, ctx):
if self._matcher is not None:
if not self._matcher.matches(ctx.model):
return None
handlers = self._handlers[(ctx.module, ctx.method)]
if not handlers:
return None
for matcher, handler in handlers:
if matcher.matches(ctx.model):
for i in self._pre_handler:
handler = i(ctx, handler)
return handler
return None
def get_service_routes(self, ctx: CallContext | None):
endpoint = self._server.expand_url(self._endpoint)
if ctx is None:
return {
service: endpoint
for _, service in self._services
}
if self._matcher is not None and not self._matcher.matches(ctx.model):
return {}
return {
service: endpoint
for matcher, service in self._services
if matcher.matches(ctx.model)
}
def serviced_prefixes(self) -> list[str]:
return [self._endpoint]
class ServicesController(IController):
def __init__(self, server, services_mode: eaapi.const.ServicesMode):
from .server import EAMServer
self._server: EAMServer = server
self.services_mode = services_mode
self._name = __name__ + "." + self.__class__.__name__
def service_routes_for(self, ctx: CallContext):
services = defaultdict(lambda: self._server.public_url)
for service, route in self._server.get_service_routes(ctx):
services[service] = self._server.expand_url(route)
return services
def get_handler(self, ctx: CallContext) -> Handler | None:
if ctx.module == MODULE_SERVICES and ctx.method == METHOD_SERVICES_GET:
return self.services_get
return None
def service_route(self, for_service: str, ctx: CallContext):
routes = self.service_routes_for(ctx)
return routes[for_service]
def services_get(self, ctx: CallContext):
services = ctx.resp.append(
MODULE_SERVICES, expire="600", mode=self.services_mode.value, status="0"
)
routes = self._server.get_service_routes(ctx)
for service in routes:
services.append("item", name=service, url=routes[service])
def get_service_routes(self, ctx: CallContext | None) -> dict[str, str]:
return {}
def serviced_prefixes(self) -> list[str]:
return [""]

View File

@ -0,0 +1,88 @@
import time
from eaapi import Type
from ..server import EAMServer
from ..model import ModelMatcher
server = EAMServer("http://127.0.0.1:5000", verbose_errors=True)
@server.handler("pcbtracker", "alive")
def pcbtracker(ctx):
ecflag = ctx.call.xpath("@ecflag/pcbtracker")
ctx.resp.append(
"pcbtracker",
status="0", expire="1200",
ecenable=ecflag, eclimit="1000", limit="1000",
time=str(round(time.time()))
)
@server.handler("message", "get", matcher=ModelMatcher("KFC"))
def message(ctx):
ctx.resp.append("message", expire="300", status="0")
@server.handler("facility", "get")
def facility_get(ctx):
facility = ctx.resp.append("facility", status="0")
location = facility.append("location")
location.append("id", Type.Str, "")
location.append("country", Type.Str, "UK")
location.append("region", Type.Str, "")
location.append("name", Type.Str, "Hello Flask")
location.append("type", Type.U8, 0)
location.append("countryname", Type.Str, "UK-c")
location.append("countryjname", Type.Str, "")
location.append("regionname", Type.Str, "UK-r")
location.append("regionjname", Type.Str, "")
location.append("customercode", Type.Str, "")
location.append("companycode", Type.Str, "")
location.append("latitude", Type.S32, 0)
location.append("longitude", Type.S32, 0)
location.append("accuracy", Type.U8, 0)
line = facility.append("line")
line.append("id", Type.Str, "")
line.append("class", Type.U8, 0)
portfw = facility.append("portfw")
portfw.append("globalip", Type.IPv4, (*map(int, ctx.request.remote_addr.split(".")),))
portfw.append("globalport", Type.U16, ctx.request.environ.get('REMOTE_PORT'))
portfw.append("privateport", Type.U16, ctx.request.environ.get('REMOTE_PORT'))
public = facility.append("public")
public.append("flag", Type.U8, 1)
public.append("name", Type.Str, "")
public.append("latitude", Type.S32, 0)
public.append("longitude", Type.S32, 0)
share = facility.append("share")
eacoin = share.append("eacoin")
eacoin.append("notchamount", Type.S32, 0)
eacoin.append("notchcount", Type.S32, 0)
eacoin.append("supplylimit", Type.S32, 100000)
url = share.append("url")
url.append("eapass", Type.Str, "www.ea-pass.konami.net")
url.append("arcadefan", Type.Str, "www.konami.jp/am")
url.append("konaminetdx", Type.Str, "http://am.573.jp")
url.append("konamiid", Type.Str, "http://id.konami.jp")
url.append("eagate", Type.Str, "http://eagate.573.jp")
@server.handler("pcbevent", "put")
def pcbevent(ctx):
ctx.resp.append("pcbevent", status="0")
server.route_service_to("cardmng", server.service_route("cardmng"))
server.route_service_to("package", server.service_route("package"))
server.route_service_to("message", "message/KFC", matcher=ModelMatcher("KFC"))
if __name__ == "__main__":
server.run("0.0.0.0", 5000, debug=True)

162
eaapi/server/demo/proxy.py Normal file
View File

@ -0,0 +1,162 @@
import base64
import urllib.parse
import requests
import eaapi
from werkzeug.routing import Rule
from werkzeug.wrappers import Response
from .. import exceptions as exc
from ..model import ModelMatcher
from ..context import ResponseContext
from ..server import (
METHOD_SERVICES_GET, MODULE_SERVICES, SERVICE_KEEPALIVE, SERVICE_NTP, EAMServer, HEADER_ENCRYPTION,
HEADER_COMPRESSION, TRIVIAL_SERVICES
)
class EAMProxyServer(EAMServer):
def __init__(self, upstream, *args, **kwargs):
super().__init__(*args, **kwargs)
self._upstream_fallback = upstream
self._taps = []
self._tapped_services = set()
if not kwargs.get("disable_routes"):
self._rules.add(Rule("/__tap/<upstream>//<model>/<module>/<method>", endpoint="tap_request"))
self._rules.add(Rule("/__tap/<upstream>", endpoint="tap_request"))
def tap_url(self, ctx, url):
url = base64.b64encode(url.encode("latin-1")).decode()
return urllib.parse.urljoin(self._public_url, f"__tap/{url}/")
def _call_upstream(self, ctx, upstream):
base = upstream or self._upstream_fallback
if ctx.url_slash:
url = f"{base}/{ctx.model}/{ctx.module}/{ctx.method}"
else:
url = f"{base}?model={ctx.model}&module={ctx.module}&method={ctx.method}"
return requests.post(url, headers=ctx.request.headers, data=ctx.request.data)
def tap(self, module, method=None, matcher=None, service=None):
if method is None:
def h2(method):
return self.handler(module, method, matcher, service)
return h2
def decorator(handler):
matcher_ = matcher or ModelMatcher()
self._taps.append((module, method, matcher_, handler))
if service is None:
if module in TRIVIAL_SERVICES:
self._tapped_services.add((matcher_, module))
else:
raise ValueError(f"Unable to identify service for {module}")
else:
self._tapped_services.add((matcher_, service))
return handler
return decorator
def _decode_us_request(self, resp):
ea_info = resp.headers.get(HEADER_ENCRYPTION)
compression = resp.headers.get(HEADER_COMPRESSION)
compressed = False
if compression == eaapi.Compression.Lz77.value:
compressed = True
elif compression != eaapi.Compression.None_.value:
raise exc.UnknownCompression
payload = eaapi.unwrap(resp.content, ea_info, compressed)
decoder = eaapi.Decoder(payload)
try:
response = decoder.unpack()
except eaapi.exception.EAAPIException:
raise exc.InvalidPacket
return ResponseContext(resp, decoder, response, compressed)
def call_upstream(self, ctx, upstream):
resp = self._call_upstream(ctx, upstream)
return self._decode_us_request(resp)
def _services_handler(self, ctx, upstream=None):
upstream_ctx = self.call_upstream(ctx, upstream)
super()._services_handler(ctx)
services = ctx.resp.xpath("services")
# Never proxy NTP or keepalive
services.children = [
i for i in services.children
if i.get("name") not in (SERVICE_NTP, SERVICE_KEEPALIVE)
]
added = set(i.get("name") for i in services)
for i in upstream_ctx.response.xpath("services"):
name = i.get("name")
if name in added:
continue
url = i.get("url")
# for matcher, service in self._tapped_services:
# if name == service and matcher.matches(ctx.model):
# url = self.tap_url(ctx, url)
if name != "keepalive":
url = self.tap_url(ctx, url)
services.append("item", name=name, url=url)
def _handle_request(self, upstream, url_slash, request, model, module, method):
ctx = self._create_ctx(url_slash, request, model, module, method)
if ctx.module == MODULE_SERVICES:
if ctx.method != METHOD_SERVICES_GET:
raise exc.NoMethodHandler
self._services_handler(ctx, upstream)
return self._encode_response(ctx)
try:
return super()._handle_request(ctx)
except exc.NoMethodHandler:
try:
us_resp = self._call_upstream(ctx, upstream)
except requests.RequestException:
raise exc.UpstreamFailed
try:
resp_ctx = self._decode_us_request(us_resp)
for tap in self._taps:
if tap[0] == module and tap[1] == method and tap[2].matches(ctx.model):
tap[3](ctx, resp_ctx)
except Exception:
import traceback
traceback.print_exc()
finally:
# Return the response the upstream sent
response = Response(us_resp.content, us_resp.status_code)
for i in us_resp.headers:
response.headers[i] = us_resp.headers[i]
return response
def on_xrpc_request(self, request, service=None, model=None, module=None, method=None):
return self.on_tap_request(request, None, service, model, module, method)
def on_tap_request(self, request, upstream, service=None, model=None, module=None, method=None):
url_slash, service, model, module, method = self.parse_request(request, service, model, module, method)
if request.method != "POST":
return self.on_xrpc_other(request, service, model, module, method)
if upstream is not None:
try:
upstream = base64.b64decode(upstream).decode("latin-1")
except Exception:
raise exc.InvalidUpstream
return self._handle_request(upstream, url_slash, request, model, module, method)

View File

@ -0,0 +1,32 @@
from .proxy import EAMProxyServer
from ..model import ModelMatcher
server = EAMProxyServer(
upstream="http://127.0.0.1:8083",
public_url="http://127.0.0.1:5000",
verbose_errors=True
)
@server.tap("game", "sv4_save_m", matcher=ModelMatcher("KFC"), service="local2")
def sv4_save_m(ctx_in, ctx_out):
print("SAVE M")
print(ctx_in.call)
print(ctx_out.response)
game = ctx_in.call.xpath("game")
print("mid:", game.xpath("music_id").value)
print("score:", game.xpath("score").value)
print("clear:", game.xpath("clear_type").value)
just = game.xpath("just_checker")
print(
just.xpath("before_3").value, just.xpath("before_2").value, just.xpath("before_1").value,
just.xpath("just").value,
just.xpath("after_1").value, just.xpath("after_2").value, just.xpath("after_3").value
)
if __name__ == "__main__":
server.run("0.0.0.0", 5000, debug=True)

View File

@ -0,0 +1,51 @@
from werkzeug.exceptions import HTTPException
class EAMHTTPException(HTTPException):
code = None
eam_description = None
class InvalidUpstream(EAMHTTPException):
code = 400
eam_description = "Upstream URL invalid"
class UpstreamFailed(EAMHTTPException):
code = 400
eam_description = "Upstream request failed"
class UnknownCompression(EAMHTTPException):
code = 400
eam_description = "Unknown compression type"
class InvalidPacket(EAMHTTPException):
code = 400
eam_description = "Invalid XML packet"
class InvalidModel(EAMHTTPException):
code = 400
eam_description = "Invalid model"
class ModelMissmatch(EAMHTTPException):
code = 400
eam_description = "Model missmatched"
class ModuleMethodMissing(EAMHTTPException):
code = 400
eam_description = "Module or method missing"
class CallNodeMissing(EAMHTTPException):
code = 400
eam_description = "<call> node missing"
class NoMethodHandler(EAMHTTPException):
code = 404
eam_description = "No handler found for module/method"

201
eaapi/server/model.py Normal file
View File

@ -0,0 +1,201 @@
from dataclasses import dataclass
import eaapi
class Model:
def __init__(self, gamecode: str, dest: str, spec: str, rev: str, datecode: str):
self._gamecode = gamecode
self._dest = dest
self._spec = spec
self._rev = rev
self._datecode = datecode
@classmethod
def from_model_str(cls, model: str) -> "Model":
return cls(*eaapi.parse_model(model))
@property
def gamecode(self):
return self._gamecode
@property
def dest(self):
return self._dest
@property
def spec(self):
return self._spec
@property
def rev(self):
return self._rev
@property
def datecode(self):
return int(self._datecode)
@property
def year(self):
return int(self._datecode[:4])
@property
def month(self):
return int(self._datecode[4:6])
@property
def day(self):
return int(self._datecode[6:8])
@property
def minor(self):
return int(self._datecode[8:10])
def __hash__(self):
return hash(str(self))
def __eq__(self, other):
if not isinstance(other, Model):
return False
return str(other) == str(self)
def __str__(self):
return f"{self.gamecode}:{self.dest}:{self.spec}:{self.rev}:{self.datecode}"
def __repr__(self):
return f"<Model {self} at 0x{id(self):016X}>"
@dataclass
class DatecodeMatcher:
year: int | None = None
month: int | None = None
day: int | None = None
minor: int | None = None
@classmethod
def from_str(cls, datecode: str):
if len(datecode) != 10 or not datecode.isdigit():
raise ValueError("Not a valid datecode")
return cls(
int(datecode[0:4]),
int(datecode[4:6]),
int(datecode[6:8]),
int(datecode[8:10])
)
def _num_filters(self):
num = 0
if self.year is not None:
num += 1
if self.month is not None:
num += 1
if self.day is not None:
num += 1
if self.minor is not None:
num += 1
return num
def __lt__(self, other):
if self._num_filters() < other._num_filters():
return False
if self.minor is None and other.minor is not None:
return False
if self.day is None and other.day is not None:
return False
if self.month is None and other.month is not None:
return False
if self.year is None and other.year is not None:
return False
return True
def __hash__(self):
return hash(str(self))
def __str__(self):
year = self.year if self.year is not None else "----"
month = self.month if self.month is not None else "--"
day = self.day if self.day is not None else "--"
minor = self.minor if self.minor is not None else "--"
return f"{year:04}{month:02}{day:02}{minor:02}"
def matches(self, model):
if self.year is not None and model.year != self.year:
return False
if self.month is not None and model.month != self.month:
return False
if self.day is not None and model.day != self.day:
return False
if self.minor is not None and model.minor != self.minor:
return False
return True
@dataclass
class ModelMatcher:
gamecode: str | None = None
dest: str | None = None
spec: str | None = None
rev: str | None = None
datecode: list[DatecodeMatcher] | DatecodeMatcher | None = None
def _num_filters(self):
num = 0
if self.gamecode is not None:
num += 1
if self.dest is not None:
num += 1
if self.spec is not None:
num += 1
if self.rev is not None:
num += 1
if isinstance(self.datecode, list):
num += sum(i._num_filters() for i in self.datecode)
elif self.datecode is not None:
num += self.datecode._num_filters()
return num
def __lt__(self, other):
if self._num_filters() < other._num_filters():
return False
if self.datecode is None and other.datecode is not None:
return False
if self.rev is None and other.rev is not None:
return False
if self.spec is None and other.spec is not None:
return False
if self.dest is None and other.dest is not None:
return False
if self.gamecode is None and other.gamecode is not None:
return False
return True
def __hash__(self):
return hash(str(self))
def __str__(self):
gamecode = self.gamecode if self.gamecode is not None else "---"
dest = self.dest if self.dest is not None else "-"
spec = self.spec if self.spec is not None else "-"
rev = self.rev if self.rev is not None else "-"
datecode = self.datecode if self.datecode is not None else "-" * 10
if isinstance(self.datecode, list):
datecode = "/".join(str(i) for i in self.datecode)
if not datecode:
datecode = "-" * 10
return f"{gamecode:3}:{dest}:{spec}:{rev}:{datecode}"
def matches(self, model):
if self.gamecode is not None and model.gamecode != self.gamecode:
return False
if self.dest is not None and model.dest != self.dest:
return False
if self.spec is not None and model.spec != self.spec:
return False
if self.rev is not None and model.rev != self.rev:
return False
if isinstance(self.datecode, list):
return any(i.matches(model) for i in self.datecode)
if self.datecode is not None:
return self.datecode.matches(model)
return True

397
eaapi/server/server.py Normal file
View File

@ -0,0 +1,397 @@
import traceback
import urllib.parse
import urllib
import sys
import os
from typing import Callable
from collections import defaultdict
from werkzeug.exceptions import HTTPException, MethodNotAllowed
from werkzeug.wrappers import Request, Response
from werkzeug.routing import Map, Rule
import eaapi
from . import exceptions as exc
from .context import CallContext
from .model import Model
from .controller import IController, ServicesController
from .const import SERVICE_NTP, SERVICE_KEEPALIVE
Handler = Callable[[CallContext], None]
HEADER_ENCRYPTION = "X-Eamuse-Info"
HEADER_COMPRESSION = "X-Compress"
PINGABLE_IP = "127.0.0.1"
class NetworkState:
def __init__(self):
self._pa = PINGABLE_IP # TODO: what does this one mean?
self.router_ip = PINGABLE_IP
self.gateway_ip = PINGABLE_IP
self.center_ip = PINGABLE_IP
def format_ka(self, base):
return base + "?" + urllib.parse.urlencode({
"pa": self.pa,
"ia": self.ia,
"ga": self.ga,
"ma": self.ma,
"t1": self.t1,
"t2": self.t2,
})
@property
def pa(self) -> str:
return self._pa
@property
def ia(self) -> str:
return self.router_ip
@property
def ga(self) -> str:
return self.gateway_ip
@property
def ma(self) -> str:
return self.center_ip
# TODO: Identify what these values are. Ping intervals?
@property
def t1(self):
return 2
@property
def t2(self):
return 10
class EAMServer:
def __init__(
self,
public_url: str,
prioritise_params: bool = False,
verbose_errors: bool = False,
services_mode: eaapi.const.ServicesMode = eaapi.const.ServicesMode.Operation,
ntp_server: str = "ntp://pool.ntp.org/",
keepalive_server: str | None = None,
no_keepalive_route: bool = False,
disable_routes: bool = False,
no_services_handler: bool = False,
):
self.network = NetworkState()
self.verbose_errors = verbose_errors
self._prioritise_params = prioritise_params
self._public_url = public_url
self.disable_routes = disable_routes
self._no_keepalive_route = no_keepalive_route
self.ntp = ntp_server
self.keepalive = keepalive_server or f"{public_url}/keepalive"
self._prng = eaapi.crypt.new_prng()
self._setup = []
self._pre_handlers_check = []
self._teardown = []
self._einfo_ctx: CallContext | None = None
self._einfo_controller: str | None = None
self.controllers: list[IController] = []
if not no_services_handler:
self.controllers.append(ServicesController(self, services_mode))
def on_setup(self, callback):
if callback not in self._setup:
self._setup.append(callback)
def on_pre_handlers_check(self, callback):
if callback not in self._pre_handlers_check:
self._pre_handlers_check.append(callback)
def on_teardown(self, callback):
if callback not in self._teardown:
self._teardown.append(callback)
def build_rules_map(self) -> Map:
if self.disable_routes:
return Map([])
rules = Map([], strict_slashes=False, merge_slashes=False)
prefixes = {"/"}
for i in self.controllers:
for prefix in i.serviced_prefixes():
prefix = self.expand_url(prefix)
if not prefix.startswith(self._public_url):
continue
prefix = prefix[len(self._public_url):]
if prefix == "":
prefix = "/"
prefixes.add(prefix)
for i in prefixes:
rules.add(Rule(f"{i}/<model>/<module>/<method>", endpoint="xrpc_request"))
# WSGI flattens the // at the start
if i == "/":
rules.add(Rule("/<model>/<module>/<method>", endpoint="xrpc_request"))
rules.add(Rule(f"{i}", endpoint="xrpc_request"))
if not self._no_keepalive_route:
rules.add(Rule("/keepalive", endpoint="keepalive_request"))
return rules
def expand_url(self, url: str) -> str:
return urllib.parse.urljoin(self._public_url, url)
@property
def public_url(self) -> str:
return self._public_url
def get_service_routes(self, ctx: CallContext | None) -> dict[str, str]:
services: dict[str, str] = defaultdict(lambda: self.public_url)
services[SERVICE_NTP] = self.ntp
services[SERVICE_KEEPALIVE] = self.network.format_ka(self.keepalive)
for i in self.controllers:
services.update(i.get_service_routes(ctx))
return services
def _decode_request(self, request: Request) -> CallContext:
ea_info = request.headers.get(HEADER_ENCRYPTION)
compression = request.headers.get(HEADER_COMPRESSION)
compressed = False
if compression == eaapi.Compression.Lz77.value:
compressed = True
elif compression != eaapi.Compression.None_.value:
raise exc.UnknownCompression
payload = eaapi.unwrap(request.data, ea_info, compressed)
decoder = eaapi.Decoder(payload)
try:
call = decoder.unpack()
except eaapi.EAAPIException:
raise exc.InvalidPacket
return CallContext(request, decoder, call, ea_info, compressed)
def _encode_response(self, ctx: CallContext) -> Response:
if ctx._eainfo is None:
ea_info = None
else:
ea_info = eaapi.crypt.get_key(self._prng)
encoded = eaapi.Encoder.encode(ctx.resp, ctx.was_xml_string)
wrapped = eaapi.wrap(encoded, ea_info, ctx.was_compressed)
response = Response(wrapped, 200)
if ea_info:
response.headers[HEADER_ENCRYPTION] = ea_info
response.headers[HEADER_COMPRESSION] = (
eaapi.Compression.Lz77 if ctx.was_compressed
else eaapi.Compression.None_
).value
return response
def _create_ctx(
self,
url_slash: bool,
request: Request,
model: Model | None,
module: str,
method: str
) -> CallContext:
ctx = self._decode_request(request)
ctx._module = module
ctx._method = method
ctx._url_slash = url_slash
self._einfo_ctx = ctx
if ctx.model != model:
raise exc.ModelMissmatch
return ctx
def _handle_request(self, ctx: CallContext) -> Response:
for controller in self.controllers:
if (handler := controller.get_handler(ctx)) is not None:
self._einfo_controller = (
f"{controller._name}"
)
handler(ctx)
break
else:
raise exc.NoMethodHandler
return self._encode_response(ctx)
def on_xrpc_other(
self,
request: Request,
service: str | None = None,
model: str | None = None,
module: str | None = None,
method: str | None = None
):
if request.method != "GET" or not self.verbose_errors:
raise MethodNotAllowed
return Response(
f"XRPC running. model {model}, call {module}.{method} ({service})"
)
def keepalive_request(self) -> Response:
return Response(None)
def parse_request(
self,
request: Request,
service: str | None = None,
model: str | None = None,
module: str | None = None,
method: str | None = None
):
url_slash = bool(module and module and method)
model_param = request.args.get("model", None)
module_param = request.args.get("module", None)
method_param = request.args.get("method", None)
if "f" in request.args:
module_param, _, method_param = request.args.get("f", "").partition(".")
if self._prioritise_params:
model = model_param or model
module = module_param or module
method = method_param or method
else:
model = model or model_param
module = module or module_param
method = method or method_param
if module is None or method is None:
raise exc.ModuleMethodMissing
if model is None:
model_obj = None
else:
try:
model_obj = Model.from_model_str(model)
except eaapi.exception.InvalidModel:
raise exc.InvalidModel
return url_slash, service, model_obj, module, method
def on_xrpc_request(
self,
request: Request,
service: str | None = None,
model: str | None = None,
module: str | None = None,
method: str | None = None
):
url_slash, service, model_obj, module, method = self.parse_request(request, service, model, module, method)
if request.method != "POST":
return self.on_xrpc_other(request, service, model, module, method)
ctx = self._create_ctx(url_slash, request, model_obj, module, method)
for i in self._pre_handlers_check:
i(ctx)
return self._handle_request(ctx)
def _make_error(self, status: int | None = None, message: str | None = None) -> Response:
response = eaapi.XMLNode.void("response")
if status is not None:
response["status"] = str(status)
if self.verbose_errors:
if message:
response.append("details", eaapi.Type.Str, message)
context = response.append("context")
if self._einfo_ctx is not None:
context.append("module", eaapi.Type.Str, self._einfo_ctx.module)
context.append("method", eaapi.Type.Str, self._einfo_ctx.method)
context.append("game", eaapi.Type.Str, str(self._einfo_ctx.model))
if self._einfo_controller is not None:
context.append("controller", eaapi.Type.Str, self._einfo_controller)
encoded = eaapi.Encoder.encode(response, False)
wrapped = eaapi.wrap(encoded, None, False)
response = Response(wrapped, status or 500)
response.headers[HEADER_COMPRESSION] = eaapi.Compression.None_.value
return response
def _eamhttp_error(self, exc: exc.EAMHTTPException) -> Response:
return self._make_error(exc.code, exc.eam_description)
def _structure_error(self, e: eaapi.exception.XMLStrutureError) -> Response:
summary = traceback.extract_tb(e.__traceback__)
for frame_summary in summary:
filename = frame_summary.filename
frame_summary.filename = os.path.relpath(filename)
# The first three entries are within the controller, and the last one is us
summary = summary[3:-1]
tb = "".join(traceback.format_list(traceback.StackSummary.from_list(summary)))
tb += f"{e.__module__}.{e.__class__.__name__}"
return self._make_error(400, tb)
def _generic_error(self, exc: Exception) -> Response:
return self._make_error(500, str(exc))
def dispatch_request(self, request):
self._einfo_ctx = None
self._einfo_controller = None
adapter = self.build_rules_map().bind_to_environ(request.environ)
try:
endpoint, values = adapter.match()
return getattr(self, f"on_{endpoint}")(request, **values)
except exc.EAMHTTPException as e:
return self._eamhttp_error(e)
except HTTPException as e:
return e
except eaapi.exception.XMLStrutureError as e:
traceback.print_exc(file=sys.stderr)
return self._structure_error(e)
except Exception as e:
traceback.print_exc(file=sys.stderr)
return self._generic_error(e)
def wsgi_app(self, environ, start_response):
request = Request(environ)
response = self.dispatch_request(request)
return response(environ, start_response)
def __call__(self, environ, start_response):
for i in self._setup:
i()
try:
response = self.wsgi_app(environ, start_response)
for i in self._teardown:
i(None)
return response
except Exception as e:
for i in self._teardown:
i(e)
raise e
def run(self, host="127.0.0.1", port=5000, debug=False):
from werkzeug.serving import run_simple
run_simple(host, port, self, use_debugger=debug, use_reloader=debug)

View File

@ -2,7 +2,7 @@ from .crypt import ea_symmetric_crypt
from .lz77 import lz77_compress, lz77_decompress
def wrap(packet, info=None, compressed=True):
def wrap(packet: bytes, info: str | None = None, compressed: bool = True) -> bytes:
if compressed:
packet = lz77_compress(packet)
if info is None:
@ -10,7 +10,7 @@ def wrap(packet, info=None, compressed=True):
return ea_symmetric_crypt(packet, info)
def unwrap(packet, info=None, compressed=True):
def unwrap(packet: bytes, info: str | None = None, compressed: bool = True) -> bytes:
if info is None:
decrypted = packet
else: