diff --git a/core/adb_handlers/__init__.py b/core/adb_handlers/__init__.py new file mode 100644 index 0000000..a5c401f --- /dev/null +++ b/core/adb_handlers/__init__.py @@ -0,0 +1,6 @@ +from .base import ADBBaseRequest, ADBBaseResponse, ADBHeader, ADBHeaderException, PortalRegStatus, LogStatus, ADBStatus +from .base import CompanyCodes, ReaderFwVer, CMD_CODE_GOODBYE, HEADER_SIZE +from .lookup import ADBLookupRequest, ADBLookupResponse, ADBLookupExResponse +from .campaign import ADBCampaignClearRequest, ADBCampaignClearResponse, ADBCampaignResponse, ADBOldCampaignRequest, ADBOldCampaignResponse +from .felica import ADBFelicaLookupRequest, ADBFelicaLookupResponse, ADBFelicaLookup2Request, ADBFelicaLookup2Response +from .log import ADBLogExRequest, ADBLogRequest, ADBStatusLogRequest diff --git a/core/adb_handlers/base.py b/core/adb_handlers/base.py new file mode 100644 index 0000000..32fd1c0 --- /dev/null +++ b/core/adb_handlers/base.py @@ -0,0 +1,163 @@ +import struct +from construct import Struct, Int16ul, Int32ul, PaddedString +from enum import Enum +import re +from typing import Union, Final + +class LogStatus(Enum): + NONE = 0 + START = 1 + CONTINUE = 2 + END = 3 + OTHER = 4 + +class PortalRegStatus(Enum): + NO_REG = 0 + PORTAL = 1 + SEGA_ID = 2 + +class ADBStatus(Enum): + UNKNOWN = 0 + GOOD = 1 + BAD_AMIE_ID = 2 + ALREADY_REG = 3 + BAN_SYS_USER = 4 + BAN_SYS = 5 + BAN_USER = 6 + BAN_GEN = 7 + LOCK_SYS_USER = 8 + LOCK_SYS = 9 + LOCK_USER = 10 + +class CompanyCodes(Enum): + NONE = 0 + SEGA = 1 + BAMCO = 2 + KONAMI = 3 + TAITO = 4 + +class ReaderFwVer(Enum): # Newer readers use a singly byte value + NONE = 0 + TN32_10 = 1 + TN32_12 = 2 + OTHER = 9 + + def __str__(self) -> str: + if self == self.TN32_10: + return "TN32MSEC003S F/W Ver1.0" + elif self == self.TN32_12: + return "TN32MSEC003S F/W Ver1.2" + elif self == self.NONE: + return "Not Specified" + elif self == self.OTHER: + return "Unknown/Other" + else: + raise ValueError(f"Bad ReaderFwVer value {self.value}") + + @classmethod + def from_byte(self, byte: bytes) -> Union["ReaderFwVer", int]: + try: + i = int.from_bytes(byte, 'little') + try: + return ReaderFwVer(i) + except ValueError: + return i + except TypeError: + return 0 + +class ADBHeaderException(Exception): + pass + +HEADER_SIZE: Final[int] = 0x20 +CMD_CODE_GOODBYE: Final[int] = 0x66 + +# everything is LE +class ADBHeader: + def __init__(self, magic: int, protocol_ver: int, cmd: int, length: int, status: int, game_id: Union[str, bytes], store_id: int, keychip_id: Union[str, bytes]) -> None: + self.magic = magic # u16 + self.protocol_ver = protocol_ver # u16 + self.cmd = cmd # u16 + self.length = length # u16 + self.status = ADBStatus(status) # u16 + self.game_id = game_id # 4 char + \x00 + self.store_id = store_id # u32 + self.keychip_id = keychip_id# 11 char + \x00 + + if type(self.game_id) == bytes: + self.game_id = self.game_id.decode() + + if type(self.keychip_id) == bytes: + self.keychip_id = self.keychip_id.decode() + + self.game_id = self.game_id.replace("\0", "") + self.keychip_id = self.keychip_id.replace("\0", "") + if self.cmd != CMD_CODE_GOODBYE: # Games for some reason send no data with goodbye + self.validate() + + @classmethod + def from_data(cls, data: bytes) -> "ADBHeader": + magic, protocol_ver, cmd, length, status, game_id, store_id, keychip_id = struct.unpack_from("<5H6sI12s", data) + head = cls(magic, protocol_ver, cmd, length, status, game_id, store_id, keychip_id) + + if head.length != len(data): + raise ADBHeaderException(f"Length is incorrect! Expect {head.length}, got {len(data)}") + + return head + + def validate(self) -> bool: + if self.magic != 0xa13e: + raise ADBHeaderException(f"Magic {self.magic} != 0xa13e") + + if self.protocol_ver < 0x1000: + raise ADBHeaderException(f"Protocol version {hex(self.protocol_ver)} is invalid!") + + if re.fullmatch(r"^S[0-9A-Z]{3}[P]?$", self.game_id) is None: + raise ADBHeaderException(f"Game ID {self.game_id} is invalid!") + + if self.store_id == 0: + raise ADBHeaderException(f"Store ID cannot be 0!") + + if re.fullmatch(r"^A[0-9]{2}[E|X][0-9]{2}[A-HJ-NP-Z][0-9]{4}$", self.keychip_id) is None: + raise ADBHeaderException(f"Keychip ID {self.keychip_id} is invalid!") + + return True + + def make(self) -> bytes: + resp_struct = Struct( + "magic" / Int16ul, + "unknown" / Int16ul, + "response_code" / Int16ul, + "length" / Int16ul, + "status" / Int16ul, + "game_id" / PaddedString(6, 'utf_8'), + "store_id" / Int32ul, + "keychip_id" / PaddedString(12, 'utf_8'), + ) + + return resp_struct.build(dict( + magic=self.magic, + unknown=self.protocol_ver, + response_code=self.cmd, + length=self.length, + status=self.status.value, + game_id = self.game_id, + store_id = self.store_id, + keychip_id = self.keychip_id, + )) + +class ADBBaseRequest: + def __init__(self, data: bytes) -> None: + self.head = ADBHeader.from_data(data) + +class ADBBaseResponse: + def __init__(self, code: int = 0, length: int = 0x20, status: int = 1, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888") -> None: + self.head = ADBHeader(0xa13e, 0x3087, code, length, status, game_id, store_id, keychip_id) + + def append_padding(self, data: bytes): + """Appends 0s to the end of the data until it's at the correct size""" + padding_size = self.head.length - len(data) + data += bytes(padding_size) + return data + + def make(self) -> bytes: + return self.head.make() diff --git a/core/adb_handlers/campaign.py b/core/adb_handlers/campaign.py new file mode 100644 index 0000000..936a4c3 --- /dev/null +++ b/core/adb_handlers/campaign.py @@ -0,0 +1,114 @@ +from construct import Struct, Int16ul, Padding, Bytes, Int32ul, Int32sl + +from .base import * + +class Campaign: + def __init__(self) -> None: + self.id = 0 + self.name = "" + self.announce_date = 0 + self.start_date = 0 + self.end_date = 0 + self.distrib_start_date = 0 + self.distrib_end_date = 0 + + def make(self) -> bytes: + name_padding = bytes(128 - len(self.name)) + return Struct( + "id" / Int32ul, + "name" / Bytes(128), + "announce_date" / Int32ul, + "start_date" / Int32ul, + "end_date" / Int32ul, + "distrib_start_date" / Int32ul, + "distrib_end_date" / Int32ul, + Padding(8), + ).build(dict( + id = self.id, + name = self.name.encode() + name_padding, + announce_date = self.announce_date, + start_date = self.start_date, + end_date = self.end_date, + distrib_start_date = self.distrib_start_date, + distrib_end_date = self.distrib_end_date, + )) + +class CampaignClear: + def __init__(self) -> None: + self.id = 0 + self.entry_flag = 0 + self.clear_flag = 0 + + def make(self) -> bytes: + return Struct( + "id" / Int32ul, + "entry_flag" / Int32ul, + "clear_flag" / Int32ul, + Padding(4), + ).build(dict( + id = self.id, + entry_flag = self.entry_flag, + clear_flag = self.clear_flag, + )) + +class ADBCampaignResponse(ADBBaseResponse): + def __init__(self, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x0C, length: int = 0x200, status: int = 1) -> None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.campaigns = [Campaign(), Campaign(), Campaign()] + + def make(self) -> bytes: + body = b"" + + for c in self.campaigns: + body += c.make() + + self.head.length = HEADER_SIZE + len(body) + return self.head.make() + body + +class ADBOldCampaignRequest(ADBBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + self.campaign_id = struct.unpack_from(" None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.info0 = 0 + self.info1 = 0 + self.info2 = 0 + self.info3 = 0 + + def make(self) -> bytes: + resp_struct = Struct( + "info0" / Int32sl, + "info1" / Int32sl, + "info2" / Int32sl, + "info3" / Int32sl, + ).build( + info0 = self.info0, + info1 = self.info1, + info2 = self.info2, + info3 = self.info3, + ) + + self.head.length = HEADER_SIZE + len(resp_struct) + return self.head.make() + resp_struct + +class ADBCampaignClearRequest(ADBBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + self.aime_id = struct.unpack_from(" None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.campaign_clear_status = [CampaignClear(), CampaignClear(), CampaignClear()] + + def make(self) -> bytes: + body = b"" + + for c in self.campaign_clear_status: + body += c.make() + + self.head.length = HEADER_SIZE + len(body) + return self.head.make() + body diff --git a/core/adb_handlers/felica.py b/core/adb_handlers/felica.py new file mode 100644 index 0000000..04a8961 --- /dev/null +++ b/core/adb_handlers/felica.py @@ -0,0 +1,72 @@ +from construct import Struct, Int32sl, Padding, Int8ub, Int16sl +from typing import Union +from .base import * + +class ADBFelicaLookupRequest(ADBBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + idm, pmm = struct.unpack_from(">QQ", data, 0x20) + self.idm = hex(idm)[2:].upper() + self.pmm = hex(pmm)[2:].upper() + +class ADBFelicaLookupResponse(ADBBaseResponse): + def __init__(self, access_code: str = None, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x03, length: int = 0x30, status: int = 1) -> None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.access_code = access_code if access_code is not None else "00000000000000000000" + + def make(self) -> bytes: + resp_struct = Struct( + "felica_idx" / Int32ul, + "access_code" / Int8ub[10], + Padding(2) + ).build(dict( + felica_idx = 0, + access_code = bytes.fromhex(self.access_code) + )) + + self.head.length = HEADER_SIZE + len(resp_struct) + + return self.head.make() + resp_struct + +class ADBFelicaLookup2Request(ADBBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + self.random = struct.unpack_from("<16s", data, 0x20)[0] + idm, pmm = struct.unpack_from(">QQ", data, 0x30) + self.card_key_ver, self.write_ct, self.maca, company, fw_ver, self.dfc = struct.unpack_from("<16s16sQccH", data, 0x40) + self.idm = hex(idm)[2:].upper() + self.pmm = hex(pmm)[2:].upper() + self.company = CompanyCodes(int.from_bytes(company, 'little')) + self.fw_ver = ReaderFwVer.from_byte(fw_ver) + +class ADBFelicaLookup2Response(ADBBaseResponse): + def __init__(self, user_id: Union[int, None] = None, access_code: Union[str, None] = None, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x12, length: int = 0x130, status: int = 1) -> None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.user_id = user_id if user_id is not None else -1 + self.access_code = access_code if access_code is not None else "00000000000000000000" + self.company = CompanyCodes.SEGA + self.portal_status = PortalRegStatus.NO_REG + + def make(self) -> bytes: + resp_struct = Struct( + "user_id" / Int32sl, + "relation1" / Int32sl, + "relation2" / Int32sl, + "access_code" / Int8ub[10], + "portal_status" / Int8ub, + "company_code" / Int8ub, + Padding(8), + "auth_key" / Int8ub[256], + ).build(dict( + user_id = self.user_id, + relation1 = -1, # Unsupported + relation2 = -1, # Unsupported + access_code = bytes.fromhex(self.access_code), + portal_status = self.portal_status.value, + company_code = self.company.value, + auth_key = [0] * 256 # Unsupported + )) + + self.head.length = HEADER_SIZE + len(resp_struct) + + return self.head.make() + resp_struct diff --git a/core/adb_handlers/log.py b/core/adb_handlers/log.py new file mode 100644 index 0000000..8be36f9 --- /dev/null +++ b/core/adb_handlers/log.py @@ -0,0 +1,23 @@ +from construct import Struct, Int32sl, Padding, Int8sl +from typing import Union + +from .base import * + +class ADBStatusLogRequest(ADBBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + self.aime_id, status = struct.unpack_from(" None: + super().__init__(data) + self.aime_id, status, self.user_id, self.credit_ct, self.bet_ct, self.won_ct = struct.unpack_from(" None: + super().__init__(data) + self.aime_id, status, self.user_id, self.credit_ct, self.bet_ct, self.won_ct, self.local_time, \ + self.tseq, self.place_id, self.num_logs = struct.unpack_from(" None: + super().__init__(data) + self.access_code = data[0x20:0x2A].hex() + company_code, fw_version, self.serial_number = struct.unpack_from(" None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.user_id = user_id if user_id is not None else -1 + self.portal_reg = PortalRegStatus.NO_REG + + def make(self): + resp_struct = Struct( + "user_id" / Int32sl, + "portal_reg" / Int8sl, + Padding(11) + ) + + body = resp_struct.build(dict( + user_id = self.user_id, + portal_reg = self.portal_reg.value + )) + + self.head.length = HEADER_SIZE + len(body) + return self.head.make() + body + +class ADBLookupExResponse(ADBBaseResponse): + def __init__(self, user_id: Union[int, None], game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", + code: int = 0x10, length: int = 0x130, status: int = 1) -> None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.user_id = user_id if user_id is not None else -1 + self.portal_reg = PortalRegStatus.NO_REG + + def make(self): + resp_struct = Struct( + "user_id" / Int32sl, + "portal_reg" / Int8sl, + Padding(3), + "auth_key" / Int8sl[256], + "relation1" / Int32sl, + "relation2" / Int32sl, + ) + + body = resp_struct.build(dict( + user_id = self.user_id, + portal_reg = self.portal_reg.value, + auth_key = [0] * 256, + relation1 = -1, + relation2 = -1 + )) + + self.head.length = HEADER_SIZE + len(body) + return self.head.make() + body diff --git a/core/aimedb.py b/core/aimedb.py index 39d6373..970eef5 100644 --- a/core/aimedb.py +++ b/core/aimedb.py @@ -2,27 +2,17 @@ from twisted.internet.protocol import Factory, Protocol import logging, coloredlogs from Crypto.Cipher import AES import struct -from typing import Dict, Any +from typing import Dict, Tuple, Callable, Union +from typing_extensions import Final from logging.handlers import TimedRotatingFileHandler from core.config import CoreConfig from core.data import Data +from .adb_handlers import * class AimedbProtocol(Protocol): - AIMEDB_RESPONSE_CODES = { - "felica_lookup": 0x03, - "lookup": 0x06, - "log": 0x0A, - "campaign": 0x0C, - "touch": 0x0E, - "lookup2": 0x10, - "felica_lookup2": 0x12, - "log2": 0x14, - "hello": 0x65, - } - - request_list: Dict[int, Any] = {} + request_list: Dict[int, Tuple[Callable[[bytes, int], Union[ADBBaseResponse, bytes]], int, str]] = {} def __init__(self, core_cfg: CoreConfig) -> None: self.logger = logging.getLogger("aimedb") @@ -32,16 +22,27 @@ class AimedbProtocol(Protocol): self.logger.error("!!!KEY NOT SET!!!") exit(1) - self.request_list[0x01] = self.handle_felica_lookup - self.request_list[0x04] = self.handle_lookup - self.request_list[0x05] = self.handle_register - self.request_list[0x09] = self.handle_log - self.request_list[0x0B] = self.handle_campaign - self.request_list[0x0D] = self.handle_touch - self.request_list[0x0F] = self.handle_lookup2 - self.request_list[0x11] = self.handle_felica_lookup2 - self.request_list[0x13] = self.handle_log2 - self.request_list[0x64] = self.handle_hello + self.register_handler(0x01, 0x03, self.handle_felica_lookup, 'felica_lookup') + self.register_handler(0x02, 0x03, self.handle_felica_register, 'felica_register') + + self.register_handler(0x04, 0x06, self.handle_lookup, 'lookup') + self.register_handler(0x05, 0x06, self.handle_register, 'register') + + self.register_handler(0x07, 0x08, self.handle_status_log, 'status_log') + self.register_handler(0x09, 0x0A, self.handle_log, 'aime_log') + + self.register_handler(0x0B, 0x0C, self.handle_campaign, 'campaign') + self.register_handler(0x0D, 0x0E, self.handle_campaign_clear, 'campaign_clear') + + self.register_handler(0x0F, 0x10, self.handle_lookup_ex, 'lookup_ex') + self.register_handler(0x11, 0x12, self.handle_felica_lookup_ex, 'felica_lookup_ex') + + self.register_handler(0x13, 0x14, self.handle_log_ex, 'aime_log_ex') + self.register_handler(0x64, 0x65, self.handle_hello, 'hello') + self.register_handler(0x66, 0, self.handle_goodbye, 'goodbye') + + def register_handler(self, cmd: int, resp:int, handler: Callable[[bytes, int], Union[ADBBaseResponse, bytes]], name: str) -> None: + self.request_list[cmd] = (handler, resp, name) def append_padding(self, data: bytes): """Appends 0s to the end of the data until it's at the correct size""" @@ -63,202 +64,226 @@ class AimedbProtocol(Protocol): try: decrypted = cipher.decrypt(data) - except Exception: - self.logger.error(f"Failed to decrypt {data.hex()}") + + except Exception as e: + self.logger.error(f"Failed to decrypt {data.hex()} because {e}") return None self.logger.debug(f"{self.transport.getPeer().host} wrote {decrypted.hex()}") - if not decrypted[1] == 0xA1 and not decrypted[0] == 0x3E: - self.logger.error(f"Bad magic") - return None + try: + head = ADBHeader.from_data(decrypted) + + except ADBHeaderException as e: + self.logger.error(f"Error parsing ADB header: {e}") + try: + encrypted = cipher.encrypt(ADBBaseResponse().make()) + self.transport.write(encrypted) - req_code = decrypted[4] - - if req_code == 0x66: - self.logger.info(f"goodbye from {self.transport.getPeer().host}") - self.transport.loseConnection() + except Exception as e: + self.logger.error(f"Failed to encrypt default response because {e}") + return - try: - resp = self.request_list[req_code](decrypted) - encrypted = cipher.encrypt(resp) - self.logger.debug(f"Response {resp.hex()}") + handler, resp_code, name = self.request_list.get(head.cmd, (self.handle_default, None, 'default')) + + if resp_code is None: + self.logger.warning(f"No handler for cmd {hex(head.cmd)}") + + elif resp_code > 0: + self.logger.info(f"{name} from {head.keychip_id} ({head.game_id}) @ {self.transport.getPeer().host}") + + resp = handler(decrypted, resp_code) + + if type(resp) == ADBBaseResponse or issubclass(type(resp), ADBBaseResponse): + resp_bytes = resp.make() + if len(resp_bytes) != resp.head.length: + resp_bytes = self.append_padding(resp_bytes) + + elif type(resp) == bytes: + resp_bytes = resp + + elif resp is None: # Nothing to send, probably a goodbye + return + + else: + raise TypeError(f"Unsupported type returned by ADB handler for {name}: {type(resp)}") + + try: + encrypted = cipher.encrypt(resp_bytes) + self.logger.debug(f"Response {resp_bytes.hex()}") self.transport.write(encrypted) - except KeyError: - self.logger.error(f"Unknown command code {hex(req_code)}") - return None + except Exception as e: + self.logger.error(f"Failed to encrypt {resp_bytes.hex()} because {e}") + + def handle_default(self, data: bytes, resp_code: int, length: int = 0x20) -> ADBBaseResponse: + req = ADBHeader.from_data(data) + return ADBBaseResponse(resp_code, length, 1, req.game_id, req.store_id, req.keychip_id) - except ValueError as e: - self.logger.error(f"Failed to encrypt {resp.hex()} because {e}") - return None + def handle_hello(self, data: bytes, resp_code: int) -> ADBBaseResponse: + return self.handle_default(data, resp_code) - def handle_campaign(self, data: bytes) -> bytes: - self.logger.info(f"campaign from {self.transport.getPeer().host}") - ret = struct.pack( - "<5H", - 0xA13E, - 0x3087, - self.AIMEDB_RESPONSE_CODES["campaign"], - 0x0200, - 0x0001, - ) - return self.append_padding(ret) + def handle_campaign(self, data: bytes, resp_code: int) -> ADBBaseResponse: + h = ADBHeader.from_data(data) + if h.protocol_ver >= 0x3030: + req = h + resp = ADBCampaignResponse(req.game_id, req.store_id, req.keychip_id) - def handle_hello(self, data: bytes) -> bytes: - self.logger.info(f"hello from {self.transport.getPeer().host}") - ret = struct.pack( - "<5H", 0xA13E, 0x3087, self.AIMEDB_RESPONSE_CODES["hello"], 0x0020, 0x0001 - ) - return self.append_padding(ret) - - def handle_lookup(self, data: bytes) -> bytes: - luid = data[0x20:0x2A].hex() - user_id = self.data.card.get_user_id_from_card(access_code=luid) - - if user_id is None: - user_id = -1 - - self.logger.info( - f"lookup from {self.transport.getPeer().host}: luid {luid} -> user_id {user_id}" - ) - - ret = struct.pack( - "<5H", 0xA13E, 0x3087, self.AIMEDB_RESPONSE_CODES["lookup"], 0x0130, 0x0001 - ) - ret += bytes(0x20 - len(ret)) - - if user_id is None: - ret += struct.pack(" bytes: - self.logger.info(f"lookup2") + def handle_lookup(self, data: bytes, resp_code: int) -> ADBBaseResponse: + req = ADBLookupRequest(data) + user_id = self.data.card.get_user_id_from_card(req.access_code) - ret = bytearray(self.handle_lookup(data)) - ret[4] = self.AIMEDB_RESPONSE_CODES["lookup2"] - - return bytes(ret) - - def handle_felica_lookup(self, data: bytes) -> bytes: - idm = data[0x20:0x28].hex() - pmm = data[0x28:0x30].hex() - access_code = self.data.card.to_access_code(idm) + ret = ADBLookupResponse(user_id, req.head.game_id, req.head.store_id, req.head.keychip_id) + self.logger.info( - f"felica_lookup from {self.transport.getPeer().host}: idm {idm} pmm {pmm} -> access_code {access_code}" + f"access_code {req.access_code} -> user_id {ret.user_id}" ) + return ret - ret = struct.pack( - "<5H", - 0xA13E, - 0x3087, - self.AIMEDB_RESPONSE_CODES["felica_lookup"], - 0x0030, - 0x0001, + def handle_lookup_ex(self, data: bytes, resp_code: int) -> ADBBaseResponse: + req = ADBLookupRequest(data) + user_id = self.data.card.get_user_id_from_card(req.access_code) + + ret = ADBLookupExResponse(user_id, req.head.game_id, req.head.store_id, req.head.keychip_id) + + self.logger.info( + f"access_code {req.access_code} -> user_id {ret.user_id}" ) - ret += bytes(26) - ret += bytes.fromhex(access_code) + return ret - return self.append_padding(ret) + def handle_felica_lookup(self, data: bytes, resp_code: int) -> bytes: + """ + On official, I think a card has to be registered for this to actually work, but + I'm making the executive decision to not implement that and just kick back our + faux generated access code. The real felica IDm -> access code conversion is done + on the ADB server, which we do not and will not ever have access to. Because we can + assure that all IDms will be unique, this basic 0-padded hex -> int conversion will + be fine. + """ + req = ADBFelicaLookupRequest(data) + ac = self.data.card.to_access_code(req.idm) + self.logger.info( + f"idm {req.idm} ipm {req.pmm} -> access_code {ac}" + ) + return ADBFelicaLookupResponse(ac, req.head.game_id, req.head.store_id, req.head.keychip_id) - def handle_felica_lookup2(self, data: bytes) -> bytes: - idm = data[0x30:0x38].hex() - pmm = data[0x38:0x40].hex() - access_code = self.data.card.to_access_code(idm) + def handle_felica_register(self, data: bytes, resp_code: int) -> bytes: + """ + I've never seen this used. + """ + req = ADBFelicaLookupRequest(data) + ac = self.data.card.to_access_code(req.idm) + + if self.config.server.allow_user_registration: + user_id = self.data.user.create_user() + + if user_id is None: + self.logger.error("Failed to register user!") + user_id = -1 + + else: + card_id = self.data.card.create_card(user_id, ac) + + if card_id is None: + self.logger.error("Failed to register card!") + user_id = -1 + + self.logger.info( + f"Register access code {ac} (IDm: {req.idm} PMm: {req.pmm}) -> user_id {user_id}" + ) + + else: + self.logger.info( + f"Registration blocked!: access code {ac} (IDm: {req.idm} PMm: {req.pmm})" + ) + + return ADBFelicaLookupResponse(ac, req.head.game_id, req.head.store_id, req.head.keychip_id) + + def handle_felica_lookup_ex(self, data: bytes, resp_code: int) -> bytes: + req = ADBFelicaLookup2Request(data) + access_code = self.data.card.to_access_code(req.idm) user_id = self.data.card.get_user_id_from_card(access_code=access_code) if user_id is None: user_id = -1 self.logger.info( - f"felica_lookup2 from {self.transport.getPeer().host}: idm {idm} ipm {pmm} -> access_code {access_code} user_id {user_id}" + f"idm {req.idm} ipm {req.pmm} -> access_code {access_code} user_id {user_id}" ) - ret = struct.pack( - "<5H", - 0xA13E, - 0x3087, - self.AIMEDB_RESPONSE_CODES["felica_lookup2"], - 0x0140, - 0x0001, - ) - ret += bytes(22) - ret += struct.pack(" ADBBaseResponse: + req = ADBCampaignClearRequest(data) - def handle_touch(self, data: bytes) -> bytes: - self.logger.info(f"touch from {self.transport.getPeer().host}") - ret = struct.pack( - "<5H", 0xA13E, 0x3087, self.AIMEDB_RESPONSE_CODES["touch"], 0x0050, 0x0001 - ) - ret += bytes(5) - ret += struct.pack("<3H", 0x6F, 0, 1) + resp = ADBCampaignClearResponse(req.head.game_id, req.head.store_id, req.head.keychip_id) - return self.append_padding(ret) + # We don't support campaign stuff + return resp - def handle_register(self, data: bytes) -> bytes: - luid = data[0x20:0x2A].hex() + def handle_register(self, data: bytes, resp_code: int) -> bytes: + req = ADBLookupRequest(data) + user_id = -1 + if self.config.server.allow_user_registration: user_id = self.data.user.create_user() if user_id is None: - user_id = -1 self.logger.error("Failed to register user!") + user_id = -1 else: - card_id = self.data.card.create_card(user_id, luid) + card_id = self.data.card.create_card(user_id, req.access_code) if card_id is None: - user_id = -1 self.logger.error("Failed to register card!") + user_id = -1 self.logger.info( - f"register from {self.transport.getPeer().host}: luid {luid} -> user_id {user_id}" + f"Register access code {req.access_code} -> user_id {user_id}" ) else: self.logger.info( - f"register from {self.transport.getPeer().host} blocked!: luid {luid}" + f"Registration blocked!: access code {req.access_code}" ) - user_id = -1 - ret = struct.pack( - "<5H", - 0xA13E, - 0x3087, - self.AIMEDB_RESPONSE_CODES["lookup"], - 0x0030, - 0x0001 if user_id > -1 else 0, - ) - ret += bytes(0x20 - len(ret)) - ret += struct.pack(" bytes: - # TODO: Save aimedb logs - self.logger.info(f"log from {self.transport.getPeer().host}") - ret = struct.pack( - "<5H", 0xA13E, 0x3087, self.AIMEDB_RESPONSE_CODES["log"], 0x0020, 0x0001 - ) - return self.append_padding(ret) + # TODO: Save these in some capacity, as deemed relevant + def handle_status_log(self, data: bytes, resp_code: int) -> bytes: + req = ADBStatusLogRequest(data) + self.logger.info(f"User {req.aime_id} logged {req.status.name} event") + return ADBBaseResponse(resp_code, 0x20, 1, req.head.game_id, req.head.store_id, req.head.keychip_id) - def handle_log2(self, data: bytes) -> bytes: - self.logger.info(f"log2 from {self.transport.getPeer().host}") - ret = struct.pack( - "<5H", 0xA13E, 0x3087, self.AIMEDB_RESPONSE_CODES["log2"], 0x0040, 0x0001 - ) - ret += bytes(22) - ret += struct.pack("H", 1) + def handle_log(self, data: bytes, resp_code: int) -> bytes: + req = ADBLogRequest(data) + self.logger.info(f"User {req.aime_id} logged {req.status.name} event, credit_ct: {req.credit_ct} bet_ct: {req.bet_ct} won_ct: {req.won_ct}") + return ADBBaseResponse(resp_code, 0x20, 1, req.head.game_id, req.head.store_id, req.head.keychip_id) - return self.append_padding(ret) + def handle_log_ex(self, data: bytes, resp_code: int) -> bytes: + req = ADBLogExRequest(data) + self.logger.info(f"User {req.aime_id} logged {req.status.name} event, credit_ct: {req.credit_ct} bet_ct: {req.bet_ct} won_ct: {req.won_ct}") + return ADBBaseResponse(resp_code, 0x20, 1, req.head.game_id, req.head.store_id, req.head.keychip_id) + def handle_goodbye(self, data: bytes, resp_code: int) -> None: + self.logger.info(f"goodbye from {self.transport.getPeer().host}") + self.transport.loseConnection() + return class AimedbFactory(Factory): protocol = AimedbProtocol diff --git a/core/allnet.py b/core/allnet.py index 9ad5949..e935359 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -6,6 +6,8 @@ from datetime import datetime import pytz import base64 import zlib +import json +from enum import Enum from Crypto.PublicKey import RSA from Crypto.Hash import SHA from Crypto.Signature import PKCS1_v1_5 @@ -18,6 +20,15 @@ from core.utils import Utils from core.data import Data from core.const import * +class DLIMG_TYPE(Enum): + app = 0 + opt = 1 + +class ALLNET_STAT(Enum): + ok = 0 + bad_game = -1 + bad_machine = -2 + bad_shop = -3 class AllnetServlet: def __init__(self, core_cfg: CoreConfig, cfg_folder: str): @@ -97,33 +108,7 @@ class AllnetServlet: else: resp = AllnetPowerOnResponse() - self.logger.debug(f"Allnet request: {vars(req)}") - if req.game_id not in self.uri_registry: - if not self.config.server.is_develop: - 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 = -1 - resp_dict = {k: v for k, v in vars(resp).items() if v is not None} - return (urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n").encode("utf-8") - - else: - self.logger.info( - f"Allowed unknown game {req.game_id} v{req.ver} to authenticate from {request_ip} due to 'is_develop' being enabled. S/N: {req.serial}" - ) - resp.uri = f"http://{self.config.title.hostname}:{self.config.title.port}/{req.game_id}/{req.ver.replace('.', '')}/" - resp.host = f"{self.config.title.hostname}:{self.config.title.port}" - - resp_dict = {k: v for k, v in vars(resp).items() if v is not None} - resp_str = urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) - - self.logger.debug(f"Allnet response: {resp_str}") - return (resp_str + "\n").encode("utf-8") - - resp.uri, resp.host = self.uri_registry[req.game_id] + self.logger.debug(f"Allnet request: {vars(req)}") machine = self.data.arcade.get_machine(req.serial) if machine is None and not self.config.server.allow_unregistered_serials: @@ -131,14 +116,38 @@ class AllnetServlet: self.data.base.log_event( "allnet", "ALLNET_AUTH_UNKNOWN_SERIAL", logging.WARN, msg ) - self.logger.warn(msg) + self.logger.warning(msg) - resp.stat = -2 + resp.stat = ALLNET_STAT.bad_machine.value resp_dict = {k: v for k, v in vars(resp).items() if v is not None} return (urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n").encode("utf-8") if machine is not None: arcade = self.data.arcade.get_arcade(machine["arcade"]) + if self.config.server.check_arcade_ip: + if arcade["ip"] and arcade["ip"] is not None and arcade["ip"] != req.ip: + msg = f"Serial {req.serial} attempted allnet auth from bad IP {req.ip} (expected {arcade['ip']})." + self.data.base.log_event( + "allnet", "ALLNET_AUTH_BAD_IP", logging.ERROR, msg + ) + self.logger.warning(msg) + + resp.stat = ALLNET_STAT.bad_shop.value + resp_dict = {k: v for k, v in vars(resp).items() if v is not None} + return (urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n").encode("utf-8") + + elif not arcade["ip"] or arcade["ip"] is None and self.config.server.strict_ip_checking: + msg = f"Serial {req.serial} attempted allnet auth from bad IP {req.ip}, but arcade {arcade['id']} has no IP set! (strict checking enabled)." + self.data.base.log_event( + "allnet", "ALLNET_AUTH_NO_SHOP_IP", logging.ERROR, msg + ) + self.logger.warning(msg) + + resp.stat = ALLNET_STAT.bad_shop.value + resp_dict = {k: v for k, v in vars(resp).items() if v is not None} + return (urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n").encode("utf-8") + + country = ( arcade["country"] if machine["country"] is None else machine["country"] ) @@ -169,6 +178,33 @@ class AllnetServlet: resp.client_timezone = ( arcade["timezone"] if arcade["timezone"] is not None else "+0900" ) + + if req.game_id not in self.uri_registry: + if not self.config.server.is_develop: + 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.warning(msg) + + resp.stat = ALLNET_STAT.bad_game.value + resp_dict = {k: v for k, v in vars(resp).items() if v is not None} + return (urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n").encode("utf-8") + + else: + self.logger.info( + f"Allowed unknown game {req.game_id} v{req.ver} to authenticate from {request_ip} due to 'is_develop' being enabled. S/N: {req.serial}" + ) + resp.uri = f"http://{self.config.title.hostname}:{self.config.title.port}/{req.game_id}/{req.ver.replace('.', '')}/" + resp.host = f"{self.config.title.hostname}:{self.config.title.port}" + + resp_dict = {k: v for k, v in vars(resp).items() if v is not None} + resp_str = urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + + self.logger.debug(f"Allnet response: {resp_str}") + return (resp_str + "\n").encode("utf-8") + + resp.uri, resp.host = self.uri_registry[req.game_id] int_ver = req.ver.replace(".", "") resp.uri = resp.uri.replace("$v", int_ver) @@ -241,6 +277,7 @@ class AllnetServlet: if path.exists(f"{self.config.allnet.update_cfg_folder}/{req_file}"): self.logger.info(f"Request for DL INI file {req_file} from {Utils.get_ip_addr(request)} successful") self.data.base.log_event("allnet", "DLORDER_INI_SENT", logging.INFO, f"{Utils.get_ip_addr(request)} successfully recieved {req_file}") + return open( f"{self.config.allnet.update_cfg_folder}/{req_file}", "rb" ).read() @@ -249,10 +286,31 @@ class AllnetServlet: return b"" def handle_dlorder_report(self, request: Request, match: Dict) -> bytes: - self.logger.info( - f"DLI Report from {Utils.get_ip_addr(request)}: {request.content.getvalue()}" - ) - return b"" + req_raw = request.content.getvalue() + try: + req_dict: Dict = json.loads(req_raw) + except Exception as e: + self.logger.warning(f"Failed to parse DL Report: {e}") + return "NG" + + dl_data_type = DLIMG_TYPE.app + dl_data = req_dict.get("appimage", {}) + + if dl_data is None or not dl_data: + dl_data_type = DLIMG_TYPE.opt + dl_data = req_dict.get("optimage", {}) + + if dl_data is None or not dl_data: + self.logger.warning(f"Failed to parse DL Report: Invalid format - contains neither appimage nor optimage") + return "NG" + + dl_report_data = DLReport(dl_data, dl_data_type) + + if not dl_report_data.validate(): + self.logger.warning(f"Failed to parse DL Report: Invalid format - {dl_report_data.err}") + return "NG" + + return "OK" def handle_loaderstaterecorder(self, request: Request, match: Dict) -> bytes: req_data = request.content.getvalue() @@ -307,7 +365,7 @@ class AllnetServlet: self.data.base.log_event( "allnet", "BILLING_CHECKIN_NG_SERIAL", logging.WARN, msg ) - self.logger.warn(msg) + self.logger.warning(msg) resp = BillingResponse("", "", "", "") resp.result = "1" @@ -529,3 +587,86 @@ class AllnetRequestException(Exception): def __init__(self, message="") -> None: self.message = message super().__init__(self.message) + +class DLReport: + def __init__(self, data: Dict, report_type: DLIMG_TYPE) -> None: + self.serial = data.get("serial") + self.dfl = data.get("dfl") + self.wfl = data.get("wfl") + self.tsc = data.get("tsc") + self.tdsc = data.get("tdsc") + self.at = data.get("at") + self.ot = data.get("ot") + self.rt = data.get("rt") + self.as_ = data.get("as") + self.rf_state = data.get("rf_state") + self.gd = data.get("gd") + self.dav = data.get("dav") + self.wdav = data.get("wdav") # app only + self.dov = data.get("dov") + self.wdov = data.get("wdov") # app only + self.__type = report_type + self.err = "" + + def validate(self) -> bool: + if self.serial is None: + self.err = "serial not provided" + return False + + if self.dfl is None: + self.err = "dfl not provided" + return False + + if self.wfl is None: + self.err = "wfl not provided" + return False + + if self.tsc is None: + self.err = "tsc not provided" + return False + + if self.tdsc is None: + self.err = "tdsc not provided" + return False + + if self.at is None: + self.err = "at not provided" + return False + + if self.ot is None: + self.err = "ot not provided" + return False + + if self.rt is None: + self.err = "rt not provided" + return False + + if self.as_ is None: + self.err = "as not provided" + return False + + if self.rf_state is None: + self.err = "rf_state not provided" + return False + + if self.gd is None: + self.err = "gd not provided" + return False + + if self.dav is None: + self.err = "dav not provided" + return False + + if self.dov is None: + self.err = "dov not provided" + return False + + if (self.wdav is None or self.wdov is None) and self.__type == DLIMG_TYPE.app: + self.err = "wdav or wdov not provided in app image" + return False + + if (self.wdav is not None or self.wdov is not None) and self.__type == DLIMG_TYPE.opt: + self.err = "wdav or wdov provided in opt image" + return False + + return True diff --git a/core/config.py b/core/config.py index 8b85353..3376c3c 100644 --- a/core/config.py +++ b/core/config.py @@ -48,6 +48,18 @@ class ServerConfig: self.__config, "core", "server", "log_dir", default="logs" ) + @property + def check_arcade_ip(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "core", "server", "check_arcade_ip", default=False + ) + + @property + def strict_ip_checking(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "core", "server", "strict_ip_checking", default=False + ) + class TitleConfig: def __init__(self, parent_config: "CoreConfig") -> None: diff --git a/core/data/database.py b/core/data/database.py index 9fb2606..e39d864 100644 --- a/core/data/database.py +++ b/core/data/database.py @@ -15,7 +15,7 @@ from core.utils import Utils class Data: - current_schema_version = 4 + current_schema_version = 6 engine = None session = None user = None @@ -163,7 +163,7 @@ class Data: version = mod.current_schema_version else: - self.logger.warn( + self.logger.warning( f"current_schema_version not found for {folder}" ) @@ -171,7 +171,7 @@ class Data: version = self.current_schema_version if version is None: - self.logger.warn( + self.logger.warning( f"Could not determine latest version for {game}, please specify --version" ) @@ -254,7 +254,7 @@ class Data: self.logger.error(f"Failed to create card for owner with id {user_id}") return - self.logger.warn( + self.logger.warning( f"Successfully created owner with email {email}, access code 00000000000000000000, and password {pw} Make sure to change this password and assign a real card ASAP!" ) @@ -269,7 +269,7 @@ class Data: return if not should_force: - self.logger.warn( + self.logger.warning( f"Card already exists for access code {new_ac} (id {new_card['id']}). If you wish to continue, rerun with the '--force' flag." f" All exiting data on the target card {new_ac} will be perminently erased and replaced with data from card {old_ac}." ) @@ -307,7 +307,7 @@ class Data: def autoupgrade(self) -> None: all_game_versions = self.base.get_all_schema_vers() if all_game_versions is None: - self.logger.warn("Failed to get schema versions") + self.logger.warning("Failed to get schema versions") return all_games = Utils.get_all_titles() diff --git a/core/data/schema/arcade.py b/core/data/schema/arcade.py index e1d9b1f..31d48f6 100644 --- a/core/data/schema/arcade.py +++ b/core/data/schema/arcade.py @@ -1,9 +1,10 @@ -from typing import Optional, Dict -from sqlalchemy import Table, Column +from typing import Optional, Dict, List +from sqlalchemy import Table, Column, and_, or_ from sqlalchemy.sql.schema import ForeignKey, PrimaryKeyConstraint -from sqlalchemy.types import Integer, String, Boolean +from sqlalchemy.types import Integer, String, Boolean, JSON from sqlalchemy.sql import func, select from sqlalchemy.dialects.mysql import insert +from sqlalchemy.engine import Row import re from core.data.schema.base import BaseData, metadata @@ -21,6 +22,7 @@ arcade = Table( Column("city", String(255)), Column("region_id", Integer), Column("timezone", String(255)), + Column("ip", String(39)), mysql_charset="utf8mb4", ) @@ -40,6 +42,9 @@ machine = Table( Column("timezone", String(255)), Column("ota_enable", Boolean), Column("is_cab", Boolean), + Column("memo", String(255)), + Column("is_cab", Boolean), + Column("data", JSON), mysql_charset="utf8mb4", ) @@ -65,7 +70,7 @@ arcade_owner = Table( class ArcadeData(BaseData): - def get_machine(self, serial: str = None, id: int = None) -> Optional[Dict]: + def get_machine(self, serial: str = None, id: int = None) -> Optional[Row]: if serial is not None: serial = serial.replace("-", "") if len(serial) == 11: @@ -130,12 +135,19 @@ class ArcadeData(BaseData): f"Failed to update board id for machine {machine_id} -> {boardid}" ) - def get_arcade(self, id: int) -> Optional[Dict]: + def get_arcade(self, id: int) -> Optional[Row]: sql = arcade.select(arcade.c.id == id) result = self.execute(sql) if result is None: return None return result.fetchone() + + def get_arcade_machines(self, id: int) -> Optional[List[Row]]: + sql = machine.select(machine.c.arcade == id) + result = self.execute(sql) + if result is None: + return None + return result.fetchall() def put_arcade( self, @@ -165,7 +177,21 @@ class ArcadeData(BaseData): return None return result.lastrowid - def get_arcade_owners(self, arcade_id: int) -> Optional[Dict]: + def get_arcades_managed_by_user(self, user_id: int) -> Optional[List[Row]]: + sql = select(arcade).join(arcade_owner, arcade_owner.c.arcade == arcade.c.id).where(arcade_owner.c.user == user_id) + result = self.execute(sql) + if result is None: + return False + return result.fetchall() + + def get_manager_permissions(self, user_id: int, arcade_id: int) -> Optional[int]: + sql = select(arcade_owner.c.permissions).where(and_(arcade_owner.c.user == user_id, arcade_owner.c.arcade == arcade_id)) + result = self.execute(sql) + if result is None: + return False + return result.fetchone() + + def get_arcade_owners(self, arcade_id: int) -> Optional[Row]: sql = select(arcade_owner).where(arcade_owner.c.arcade == arcade_id) result = self.execute(sql) @@ -187,33 +213,14 @@ class ArcadeData(BaseData): return f"{platform_code}{platform_rev:02d}A{serial_num:04d}{append:04d}" # 0x41 = A, 0x52 = R def validate_keychip_format(self, serial: str) -> bool: - serial = serial.replace("-", "") - if len(serial) != 11 or len(serial) != 15: - self.logger.error( - f"Serial validate failed: Incorrect length for {serial} (len {len(serial)})" - ) + if re.fullmatch(r"^A[0-9]{2}[E|X][-]?[0-9]{2}[A-HJ-NP-Z][0-9]{4}([0-9]{4})?$", serial) is None: return False - - platform_code = serial[:4] - platform_rev = serial[4:6] - const_a = serial[6] - num = serial[7:11] - append = serial[11:15] - - if re.match("A[7|6]\d[E|X][0|1][0|1|2]A\d{4,8}", serial) is None: - self.logger.error(f"Serial validate failed: {serial} failed regex") - return False - - if len(append) != 0 or len(append) != 4: - self.logger.error( - f"Serial validate failed: {serial} had malformed append {append}" - ) - return False - - if len(num) != 4: - self.logger.error( - f"Serial validate failed: {serial} had malformed number {num}" - ) - return False - + return True + + def find_arcade_by_name(self, name: str) -> List[Row]: + sql = arcade.select(or_(arcade.c.name.like(f"%{name}%"), arcade.c.nickname.like(f"%{name}%"))) + result = self.execute(sql) + if result is None: + return False + return result.fetchall() diff --git a/core/data/schema/user.py b/core/data/schema/user.py index 6a95005..221ba81 100644 --- a/core/data/schema/user.py +++ b/core/data/schema/user.py @@ -107,3 +107,17 @@ class UserData(BaseData): if result is None: return None return result.fetchall() + + def find_user_by_email(self, email: str) -> Row: + sql = select(aime_user).where(aime_user.c.email == email) + result = self.execute(sql) + if result is None: + return False + return result.fetchone() + + def find_user_by_username(self, username: str) -> List[Row]: + sql = aime_user.select(aime_user.c.username.like(f"%{username}%")) + result = self.execute(sql) + if result is None: + return False + return result.fetchall() diff --git a/core/data/schema/versions/CORE_4_rollback.sql b/core/data/schema/versions/CORE_4_rollback.sql new file mode 100644 index 0000000..4464915 --- /dev/null +++ b/core/data/schema/versions/CORE_4_rollback.sql @@ -0,0 +1,3 @@ +ALTER TABLE machine DROP COLUMN memo; +ALTER TABLE machine DROP COLUMN is_blacklisted; +ALTER TABLE machine DROP COLUMN `data`; diff --git a/core/data/schema/versions/CORE_5_rollback.sql b/core/data/schema/versions/CORE_5_rollback.sql new file mode 100644 index 0000000..2462fee --- /dev/null +++ b/core/data/schema/versions/CORE_5_rollback.sql @@ -0,0 +1 @@ +ALTER TABLE arcade DROP COLUMN 'ip'; \ No newline at end of file diff --git a/core/data/schema/versions/CORE_5_upgrade.sql b/core/data/schema/versions/CORE_5_upgrade.sql new file mode 100644 index 0000000..8e88b00 --- /dev/null +++ b/core/data/schema/versions/CORE_5_upgrade.sql @@ -0,0 +1,3 @@ +ALTER TABLE machine ADD memo varchar(255) NULL; +ALTER TABLE machine ADD is_blacklisted tinyint(1) NULL; +ALTER TABLE machine ADD `data` longtext NULL; diff --git a/core/data/schema/versions/CORE_6_upgrade.sql b/core/data/schema/versions/CORE_6_upgrade.sql new file mode 100644 index 0000000..013728c --- /dev/null +++ b/core/data/schema/versions/CORE_6_upgrade.sql @@ -0,0 +1 @@ +ALTER TABLE arcade ADD ip varchar(39) NULL; \ No newline at end of file diff --git a/core/frontend.py b/core/frontend.py index f01be50..7b028ee 100644 --- a/core/frontend.py +++ b/core/frontend.py @@ -9,6 +9,9 @@ from zope.interface import Interface, Attribute, implementer from twisted.python.components import registerAdapter import jinja2 import bcrypt +import re +from enum import Enum +from urllib import parse from core import CoreConfig, Utils from core.data import Data @@ -19,6 +22,13 @@ class IUserSession(Interface): current_ip = Attribute("User's current ip address") permissions = Attribute("User's permission level") +class PermissionOffset(Enum): + USER = 0 # Regular user + USERMOD = 1 # Can moderate other users + ACMOD = 2 # Can add arcades and cabs + SYSADMIN = 3 # Can change settings + # 4 - 6 reserved for future use + OWNER = 7 # Can do anything @implementer(IUserSession) class UserSession(object): @@ -80,6 +90,9 @@ class FrontendServlet(resource.Resource): self.environment.globals["game_list"] = self.game_list self.putChild(b"gate", FE_Gate(cfg, self.environment)) self.putChild(b"user", FE_User(cfg, self.environment)) + self.putChild(b"sys", FE_System(cfg, self.environment)) + self.putChild(b"arcade", FE_Arcade(cfg, self.environment)) + self.putChild(b"cab", FE_Machine(cfg, self.environment)) self.putChild(b"game", fe_game) self.logger.info( @@ -154,6 +167,7 @@ class FE_Gate(FE_Base): passwd = None uid = self.data.card.get_user_id_from_card(access_code) + user = self.data.user.get_user(uid) if uid is None: return redirectTo(b"/gate?e=1", request) @@ -175,6 +189,7 @@ class FE_Gate(FE_Base): usr_sesh = IUserSession(sesh) usr_sesh.userId = uid usr_sesh.current_ip = ip + usr_sesh.permissions = user['permissions'] return redirectTo(b"/user", request) @@ -192,7 +207,7 @@ class FE_Gate(FE_Base): hashed = bcrypt.hashpw(passwd, salt) result = self.data.user.create_user( - uid, username, email, hashed.decode(), 1 + uid, username, email.lower(), hashed.decode(), 1 ) if result is None: return redirectTo(b"/gate?e=3", request) @@ -210,17 +225,29 @@ class FE_Gate(FE_Base): return redirectTo(b"/gate?e=2", request) ac = request.args[b"ac"][0].decode() + card = self.data.card.get_card_by_access_code(ac) + if card is None: + return redirectTo(b"/gate?e=1", request) + + user = self.data.user.get_user(card['user']) + if user is None: + self.logger.warning(f"Card {ac} exists with no/invalid associated user ID {card['user']}") + return redirectTo(b"/gate?e=0", request) + + if user['password'] is not None: + return redirectTo(b"/gate?e=1", request) template = self.environment.get_template("core/frontend/gate/create.jinja") return template.render( title=f"{self.core_config.server.name} | Create User", code=ac, - sesh={"userId": 0}, + sesh={"userId": 0, "permissions": 0}, ).encode("utf-16") class FE_User(FE_Base): def render_GET(self, request: Request): + uri = request.uri.decode() template = self.environment.get_template("core/frontend/user/index.jinja") sesh: Session = request.getSession() @@ -228,9 +255,26 @@ class FE_User(FE_Base): if usr_sesh.userId == 0: return redirectTo(b"/gate", request) - cards = self.data.card.get_user_cards(usr_sesh.userId) - user = self.data.user.get_user(usr_sesh.userId) + m = re.match("\/user\/(\d*)", uri) + + if m is not None: + usrid = m.group(1) + if usr_sesh.permissions < 1 << PermissionOffset.USERMOD.value or not usrid == usr_sesh.userId: + return redirectTo(b"/user", request) + + else: + usrid = usr_sesh.userId + + user = self.data.user.get_user(usrid) + if user is None: + return redirectTo(b"/user", request) + + cards = self.data.card.get_user_cards(usrid) + arcades = self.data.arcade.get_arcades_managed_by_user(usrid) + card_data = [] + arcade_data = [] + for c in cards: if c['is_locked']: status = 'Locked' @@ -240,9 +284,104 @@ class FE_User(FE_Base): status = 'Active' card_data.append({'access_code': c['access_code'], 'status': status}) + + for a in arcades: + arcade_data.append({'id': a['id'], 'name': a['name']}) return template.render( - title=f"{self.core_config.server.name} | Account", sesh=vars(usr_sesh), cards=card_data, username=user['username'] + title=f"{self.core_config.server.name} | Account", + sesh=vars(usr_sesh), + cards=card_data, + username=user['username'], + arcades=arcade_data + ).encode("utf-16") + + def render_POST(self, request: Request): + pass + + +class FE_System(FE_Base): + def render_GET(self, request: Request): + uri = request.uri.decode() + template = self.environment.get_template("core/frontend/sys/index.jinja") + usrlist = [] + aclist = [] + cablist = [] + + sesh: Session = request.getSession() + usr_sesh = IUserSession(sesh) + if usr_sesh.userId == 0 or usr_sesh.permissions < 1 << PermissionOffset.USERMOD.value: + return redirectTo(b"/gate", request) + + if uri.startswith("/sys/lookup.user?"): + uri_parse = parse.parse_qs(uri.replace("/sys/lookup.user?", "")) # lop off the first bit + uid_search = uri_parse.get("usrId") + email_search = uri_parse.get("usrEmail") + uname_search = uri_parse.get("usrName") + + if uid_search is not None: + u = self.data.user.get_user(uid_search[0]) + if u is not None: + usrlist.append(u._asdict()) + + elif email_search is not None: + u = self.data.user.find_user_by_email(email_search[0]) + if u is not None: + usrlist.append(u._asdict()) + + elif uname_search is not None: + ul = self.data.user.find_user_by_username(uname_search[0]) + for u in ul: + usrlist.append(u._asdict()) + + elif uri.startswith("/sys/lookup.arcade?"): + uri_parse = parse.parse_qs(uri.replace("/sys/lookup.arcade?", "")) # lop off the first bit + ac_id_search = uri_parse.get("arcadeId") + ac_name_search = uri_parse.get("arcadeName") + ac_user_search = uri_parse.get("arcadeUser") + + if ac_id_search is not None: + u = self.data.arcade.get_arcade(ac_id_search[0]) + if u is not None: + aclist.append(u._asdict()) + + elif ac_name_search is not None: + ul = self.data.arcade.find_arcade_by_name(ac_name_search[0]) + for u in ul: + aclist.append(u._asdict()) + + elif ac_user_search is not None: + ul = self.data.arcade.get_arcades_managed_by_user(ac_user_search[0]) + for u in ul: + aclist.append(u._asdict()) + + elif uri.startswith("/sys/lookup.cab?"): + uri_parse = parse.parse_qs(uri.replace("/sys/lookup.cab?", "")) # lop off the first bit + cab_id_search = uri_parse.get("cabId") + cab_serial_search = uri_parse.get("cabSerial") + cab_acid_search = uri_parse.get("cabAcId") + + if cab_id_search is not None: + u = self.data.arcade.get_machine(id=cab_id_search[0]) + if u is not None: + cablist.append(u._asdict()) + + elif cab_serial_search is not None: + u = self.data.arcade.get_machine(serial=cab_serial_search[0]) + if u is not None: + cablist.append(u._asdict()) + + elif cab_acid_search is not None: + ul = self.data.arcade.get_arcade_machines(cab_acid_search[0]) + for u in ul: + cablist.append(u._asdict()) + + return template.render( + title=f"{self.core_config.server.name} | System", + sesh=vars(usr_sesh), + usrlist=usrlist, + aclist=aclist, + cablist=cablist, ).encode("utf-16") @@ -257,3 +396,54 @@ class FE_Game(FE_Base): def render_GET(self, request: Request) -> bytes: return redirectTo(b"/user", request) + + +class FE_Arcade(FE_Base): + def render_GET(self, request: Request): + uri = request.uri.decode() + template = self.environment.get_template("core/frontend/arcade/index.jinja") + managed = [] + + sesh: Session = request.getSession() + usr_sesh = IUserSession(sesh) + if usr_sesh.userId == 0: + return redirectTo(b"/gate", request) + + m = re.match("\/arcade\/(\d*)", uri) + + if m is not None: + arcadeid = m.group(1) + perms = self.data.arcade.get_manager_permissions(usr_sesh.userId, arcadeid) + arcade = self.data.arcade.get_arcade(arcadeid) + + if perms is None: + perms = 0 + + else: + return redirectTo(b"/user", request) + + return template.render( + title=f"{self.core_config.server.name} | Arcade", + sesh=vars(usr_sesh), + error=0, + perms=perms, + arcade=arcade._asdict() + ).encode("utf-16") + + +class FE_Machine(FE_Base): + def render_GET(self, request: Request): + uri = request.uri.decode() + template = self.environment.get_template("core/frontend/machine/index.jinja") + + sesh: Session = request.getSession() + usr_sesh = IUserSession(sesh) + if usr_sesh.userId == 0: + return redirectTo(b"/gate", request) + + return template.render( + title=f"{self.core_config.server.name} | Machine", + sesh=vars(usr_sesh), + arcade={}, + error=0, + ).encode("utf-16") \ No newline at end of file diff --git a/core/frontend/arcade/index.jinja b/core/frontend/arcade/index.jinja new file mode 100644 index 0000000..20a1f46 --- /dev/null +++ b/core/frontend/arcade/index.jinja @@ -0,0 +1,4 @@ +{% extends "core/frontend/index.jinja" %} +{% block content %} +

{{ arcade.name }}

+{% endblock content %} \ No newline at end of file diff --git a/core/frontend/machine/index.jinja b/core/frontend/machine/index.jinja new file mode 100644 index 0000000..01e90a0 --- /dev/null +++ b/core/frontend/machine/index.jinja @@ -0,0 +1,5 @@ +{% extends "core/frontend/index.jinja" %} +{% block content %} +{% include "core/frontend/widgets/err_banner.jinja" %} +

Machine Management

+{% endblock content %} \ No newline at end of file diff --git a/core/frontend/sys/index.jinja b/core/frontend/sys/index.jinja new file mode 100644 index 0000000..2da821e --- /dev/null +++ b/core/frontend/sys/index.jinja @@ -0,0 +1,98 @@ +{% extends "core/frontend/index.jinja" %} +{% block content %} +

System Management

+ +
+ {% if sesh.permissions >= 2 %} +
+
+

User Search

+
+ + +
+ OR +
+ + +
+ OR +
+ + +
+
+ +
+
+ {% endif %} + {% if sesh.permissions >= 4 %} +
+
+

Arcade Search

+
+ + +
+ OR +
+ + +
+ OR +
+ + +
+
+ +
+
+
+
+

Machine Search

+
+ + +
+ OR +
+ + +
+ OR +
+ + +
+
+ +
+
+ {% endif %} +
+
+ {% if sesh.permissions >= 2 %} +
+ {% for usr in usrlist %} +
{{ usr.id }} | {{ usr.username }}
+ {% endfor %} +
+ {% endif %} + {% if sesh.permissions >= 4 %} +
+ {% for ac in aclist %} +
{{ ac.id }} | {{ ac.name }}
+ {% endfor %} +
+ {% endif %} +
+
+ +
+{% endblock content %} \ No newline at end of file diff --git a/core/frontend/user/index.jinja b/core/frontend/user/index.jinja index 2911e67..2f76b14 100644 --- a/core/frontend/user/index.jinja +++ b/core/frontend/user/index.jinja @@ -2,11 +2,21 @@ {% block content %}

Management for {{ username }}

Cards

-
    +
      {% for c in cards %} -
    • {{ c.access_code }}: {{ c.status }}
    • +
    • {{ c.access_code }}: {{ c.status }} {% if c.status == 'Active'%}{% elif c.status == 'Locked' %}{% endif %} 
    • {% endfor %}
    + +{% if arcades is defined %} +

    Arcades

    + +{% endif %} +