artemis/core/allnet.py

1033 lines
36 KiB
Python
Raw Normal View History

2023-02-16 22:13:41 +00:00
import base64
2023-07-23 16:47:10 +00:00
import json
2024-03-22 16:22:07 +00:00
import logging
import math
import urllib.parse
import zlib
from datetime import datetime
2023-07-23 16:47:10 +00:00
from enum import Enum
2024-03-22 16:22:07 +00:00
from logging.handlers import TimedRotatingFileHandler
from os import W_OK, access, environ, mkdir, path
from typing import Any, Dict, Final, List, Optional, Union
import coloredlogs
import pytz
import yaml
2023-02-18 05:00:30 +00:00
from Crypto.Hash import SHA
2024-03-22 16:22:07 +00:00
from Crypto.PublicKey import RSA
2023-02-18 05:00:30 +00:00
from Crypto.Signature import PKCS1_v1_5
2024-03-22 16:22:07 +00:00
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from .config import CoreConfig
from .const import *
2024-03-22 16:22:07 +00:00
from .data import Data
from .title import TitleServlet
2024-03-22 16:22:07 +00:00
from .utils import Utils
BILLING_DT_FORMAT: Final[str] = "%Y%m%d%H%M%S"
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
class DLIMG_TYPE(Enum):
app = 0
opt = 1
2023-03-09 16:38:58 +00:00
2024-03-22 16:22:07 +00:00
2023-08-08 16:35:38 +00:00
class ALLNET_STAT(Enum):
ok = 0
bad_game = -1
bad_machine = -2
bad_shop = -3
2024-03-22 16:22:07 +00:00
2023-11-02 00:08:20 +00:00
class DLI_STATUS(Enum):
START = 0
GET_DOWNLOAD_CONFIGURATION = 1
WAIT_DOWNLOAD = 2
DOWNLOADING = 3
NOT_SPECIFY_DLI = 100
ONLY_POST_REPORT = 101
STOPPED_BY_APP_RELEASE = 102
STOPPED_BY_OPT_RELEASE = 103
DOWNLOAD_COMPLETE_RECENTLY = 110
DOWNLOAD_COMPLETE_WAIT_RELEASE_TIME = 120
DOWNLOAD_COMPLETE_BUT_NOT_SYNC_SERVER = 121
DOWNLOAD_COMPLETE_BUT_NOT_FIRST_RESUME = 122
DOWNLOAD_COMPLETE_BUT_NOT_FIRST_LAUNCH = 123
DOWNLOAD_COMPLETE_WAIT_UPDATE = 124
DOWNLOAD_COMPLETE_AND_ALREADY_UPDATE = 130
ERROR_AUTH_FAILURE = 200
ERROR_GET_DLI_HTTP = 300
ERROR_GET_DLI = 301
ERROR_PARSE_DLI = 302
ERROR_INVALID_GAME_ID = 303
ERROR_INVALID_IMAGE_LIST = 304
ERROR_GET_DLI_APP = 305
ERROR_GET_BOOT_ID = 400
ERROR_ACCESS_SERVER = 401
ERROR_NO_IMAGE = 402
ERROR_ACCESS_IMAGE = 403
ERROR_DOWNLOAD_APP = 500
ERROR_DOWNLOAD_OPT = 501
ERROR_DISK_FULL = 600
ERROR_UNINSTALL = 601
ERROR_INSTALL_APP = 602
ERROR_INSTALL_OPT = 603
ERROR_GET_DLI_INTERNAL = 900
ERROR_ICF = 901
ERROR_CHECK_RELEASE_INTERNAL = 902
2024-03-22 16:22:07 +00:00
UNKNOWN = 999 # Not the actual enum val but it needs to be here as a catch-all
2023-11-02 00:08:20 +00:00
@classmethod
def from_int(cls, num: int) -> "DLI_STATUS":
try:
return cls(num)
except ValueError:
return cls.UNKNOWN
2024-03-22 16:22:07 +00:00
class AllnetServlet:
2024-03-22 16:22:07 +00:00
allnet_registry: Dict[str, Any] = {}
2023-03-09 16:38:58 +00:00
def __init__(self, core_cfg: CoreConfig, cfg_folder: str):
self.config = core_cfg
self.config_folder = cfg_folder
self.data = Data(core_cfg)
self.logger = logging.getLogger("allnet")
2023-02-16 22:13:41 +00:00
if not hasattr(self.logger, "initialized"):
log_fmt_str = "[%(asctime)s] Allnet | %(levelname)s | %(message)s"
2023-03-09 16:38:58 +00:00
log_fmt = logging.Formatter(log_fmt_str)
2023-03-09 16:38:58 +00:00
fileHandler = TimedRotatingFileHandler(
"{0}/{1}.log".format(self.config.server.log_dir, "allnet"),
when="d",
backupCount=10,
)
2023-02-16 22:13:41 +00:00
fileHandler.setFormatter(log_fmt)
2023-03-09 16:38:58 +00:00
2023-02-16 22:13:41 +00:00
consoleHandler = logging.StreamHandler()
consoleHandler.setFormatter(log_fmt)
self.logger.addHandler(fileHandler)
self.logger.addHandler(consoleHandler)
2023-03-09 16:38:58 +00:00
2023-02-16 22:13:41 +00:00
self.logger.setLevel(core_cfg.allnet.loglevel)
2023-03-09 16:38:58 +00:00
coloredlogs.install(
level=core_cfg.allnet.loglevel, logger=self.logger, fmt=log_fmt_str
)
2023-02-16 22:13:41 +00:00
self.logger.initialized = True
2024-03-22 16:22:07 +00:00
def startup(self) -> None:
2023-03-09 16:38:58 +00:00
self.logger.info(
2024-03-22 16:22:07 +00:00
f"Ready on port {self.config.allnet.port if self.config.allnet.standalone else self.config.server.port}"
2023-03-09 16:38:58 +00:00
)
2024-03-22 16:22:07 +00:00
if not TitleServlet.title_registry:
TitleServlet(self.config, self.config_folder)
2023-02-16 22:13:41 +00:00
2024-03-22 16:22:07 +00:00
async def handle_poweron(self, request: Request):
request_ip = Utils.get_ip_addr(request)
2024-03-22 16:22:07 +00:00
pragma_header = request.headers.get("Pragma", "")
2023-08-21 04:10:25 +00:00
is_dfi = pragma_header is not None and pragma_header == "DFI"
2024-03-22 16:22:07 +00:00
data = await request.body()
2023-02-16 22:13:41 +00:00
try:
2023-08-21 04:10:25 +00:00
if is_dfi:
2024-03-22 16:22:07 +00:00
req_urlencode = self.from_dfi(data)
2023-08-21 04:10:25 +00:00
else:
2024-03-22 16:22:07 +00:00
req_urlencode = data
2023-08-21 04:10:25 +00:00
req_dict = self.allnet_req_to_dict(req_urlencode)
2023-02-24 19:07:54 +00:00
if req_dict is None:
raise AllnetRequestException()
req = AllnetPowerOnRequest(req_dict[0])
2023-02-16 22:13:41 +00:00
# Validate the request. Currently we only validate the fields we plan on using
2024-03-22 16:22:07 +00:00
if (
not req.game_id
or not req.ver
or not req.serial
or not req.ip
or not req.firm_ver
or not req.boot_ver
):
2023-03-09 16:38:58 +00:00
raise AllnetRequestException(
f"Bad auth request params from {request_ip} - {vars(req)}"
)
2023-02-16 22:13:41 +00:00
except AllnetRequestException as e:
2023-02-24 19:07:54 +00:00
if e.message != "":
self.logger.error(e)
2024-03-22 16:22:07 +00:00
return PlainTextResponse()
2023-03-09 16:38:58 +00:00
if req.format_ver == 3:
2023-02-18 05:00:30 +00:00
resp = AllnetPowerOnResponse3(req.token)
elif req.format_ver == 2:
2023-02-18 05:00:30 +00:00
resp = AllnetPowerOnResponse2()
else:
resp = AllnetPowerOnResponse()
2023-02-18 05:00:30 +00:00
2023-08-08 16:35:38 +00:00
self.logger.debug(f"Allnet request: {vars(req)}")
2023-02-18 05:00:30 +00:00
2024-03-22 16:22:07 +00:00
machine = await self.data.arcade.get_machine(req.serial)
2023-02-18 05:00:30 +00:00
if machine is None and not self.config.server.allow_unregistered_serials:
msg = f"Unrecognised serial {req.serial} attempted allnet auth from {request_ip}."
2024-03-22 16:22:07 +00:00
await self.data.base.log_event(
2023-03-09 16:38:58 +00:00
"allnet", "ALLNET_AUTH_UNKNOWN_SERIAL", logging.WARN, msg
)
2023-08-08 14:17:56 +00:00
self.logger.warning(msg)
2023-02-18 05:00:30 +00:00
2023-08-08 16:35:38 +00:00
resp.stat = ALLNET_STAT.bad_machine.value
resp_dict = {k: v for k, v in vars(resp).items() if v is not None}
2024-03-22 16:22:07 +00:00
return PlainTextResponse(
urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n"
)
2023-03-09 16:38:58 +00:00
2023-02-18 05:00:30 +00:00
if machine is not None:
2024-03-22 16:22:07 +00:00
arcade = await self.data.arcade.get_arcade(machine["arcade"])
2023-08-08 16:35:38 +00:00
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']})."
2024-03-22 16:22:07 +00:00
await self.data.base.log_event(
2023-08-08 16:35:38 +00:00
"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}
2024-03-22 16:22:07 +00:00
return PlainTextResponse(
urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n"
)
elif (
not arcade["ip"] or arcade["ip"] is None
) and self.config.server.strict_ip_checking:
2023-08-08 16:35:38 +00:00
msg = f"Serial {req.serial} attempted allnet auth from bad IP {req.ip}, but arcade {arcade['id']} has no IP set! (strict checking enabled)."
2024-03-22 16:22:07 +00:00
await self.data.base.log_event(
2023-08-08 16:35:38 +00:00
"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}
2024-03-22 16:22:07 +00:00
return PlainTextResponse(
urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n"
)
2023-08-08 16:35:38 +00:00
2023-03-09 16:38:58 +00:00
country = (
arcade["country"] if machine["country"] is None else machine["country"]
)
2023-03-03 20:52:58 +00:00
if country is None:
country = AllnetCountryCode.JAPAN.value
resp.country = country
2024-03-22 16:22:07 +00:00
resp.place_id = f"{arcade['id']:04X}"
resp.allnet_id = machine["id"]
2023-03-03 20:45:21 +00:00
resp.name = arcade["name"] if arcade["name"] is not None else ""
resp.nickname = arcade["nickname"] if arcade["nickname"] is not None else ""
2023-03-09 16:38:58 +00:00
resp.region0 = (
arcade["region_id"]
if arcade["region_id"] is not None
else AllnetJapanRegionId.AICHI.value
)
resp.region_name0 = (
arcade["state"]
if arcade["state"] is not None
else AllnetJapanRegionId.AICHI.name
)
resp.region_name1 = (
arcade["country"]
if arcade["country"] is not None
else AllnetCountryCode.JAPAN.value
)
2023-03-03 20:45:21 +00:00
resp.region_name2 = arcade["city"] if arcade["city"] is not None else ""
2024-03-22 16:22:07 +00:00
resp.client_timezone = ( # lmao
arcade["timezone"]
if arcade["timezone"] is not None
else "+0900"
if req.format_ver == 3
else "+09:00"
2023-03-09 16:38:58 +00:00
)
2024-03-22 16:22:07 +00:00
if req.game_id not in TitleServlet.title_registry:
2023-08-08 16:35:38 +00:00
if not self.config.server.is_develop:
msg = f"Unrecognised game {req.game_id} attempted allnet auth from {request_ip}."
2024-03-22 16:22:07 +00:00
await self.data.base.log_event(
2023-08-08 16:35:38 +00:00
"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}
2024-03-22 16:22:07 +00:00
return PlainTextResponse(
urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n"
)
2023-08-08 16:35:38 +00:00
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}"
)
2024-03-22 16:22:07 +00:00
resp.uri = f"http://{self.config.server.hostname}:{self.config.server.port}/{req.game_id}/{req.ver.replace('.', '')}/"
resp.host = f"{self.config.server.hostname}:{self.config.server.port}"
2023-08-08 16:35:38 +00:00
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))
2024-03-22 16:22:07 +00:00
2023-08-08 16:35:38 +00:00
self.logger.debug(f"Allnet response: {resp_str}")
2024-03-22 16:22:07 +00:00
return PlainTextResponse(resp_str + "\n")
2023-08-08 16:35:38 +00:00
2023-02-19 04:40:19 +00:00
int_ver = req.ver.replace(".", "")
2024-03-22 16:22:07 +00:00
try:
resp.uri, resp.host = TitleServlet.title_registry[
req.game_id
].get_allnet_info(req.game_id, int(int_ver), req.serial)
except Exception as e:
self.logger.error(f"Error running get_allnet_info for {req.game_id} - {e}")
resp.stat = ALLNET_STAT.bad_game.value
resp_dict = {k: v for k, v in vars(resp).items() if v is not None}
return PlainTextResponse(
urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n"
)
2023-03-09 16:38:58 +00:00
2023-02-18 05:00:30 +00:00
msg = f"{req.serial} authenticated from {request_ip}: {req.game_id} v{req.ver}"
2024-03-22 16:22:07 +00:00
await self.data.base.log_event(
"allnet", "ALLNET_AUTH_SUCCESS", logging.INFO, msg
)
2023-02-18 05:00:30 +00:00
self.logger.info(msg)
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))
2024-03-22 16:22:07 +00:00
self.logger.debug(f"Allnet response: {resp_dict}")
resp_str += "\n"
2023-08-21 04:10:25 +00:00
"""if is_dfi:
request.responseHeaders.addRawHeader('Pragma', 'DFI')
return self.to_dfi(resp_str)"""
2024-03-22 16:22:07 +00:00
return PlainTextResponse(resp_str)
2023-02-16 22:13:41 +00:00
2024-03-22 16:22:07 +00:00
async def handle_dlorder(self, request: Request):
request_ip = Utils.get_ip_addr(request)
2024-03-22 16:22:07 +00:00
pragma_header = request.headers.get("Pragma", "")
2023-08-21 04:10:25 +00:00
is_dfi = pragma_header is not None and pragma_header == "DFI"
2024-03-22 16:22:07 +00:00
data = await request.body()
2023-02-18 05:00:30 +00:00
try:
2023-08-21 04:10:25 +00:00
if is_dfi:
2024-03-22 16:22:07 +00:00
req_urlencode = self.from_dfi(data)
2023-08-21 04:10:25 +00:00
else:
2024-03-22 16:22:07 +00:00
req_urlencode = data.decode()
2023-08-21 04:10:25 +00:00
req_dict = self.allnet_req_to_dict(req_urlencode)
2023-02-24 19:07:54 +00:00
if req_dict is None:
raise AllnetRequestException()
req = AllnetDownloadOrderRequest(req_dict[0])
2023-02-18 05:00:30 +00:00
# Validate the request. Currently we only validate the fields we plan on using
2023-02-19 05:01:39 +00:00
if not req.game_id or not req.ver or not req.serial:
2023-03-09 16:38:58 +00:00
raise AllnetRequestException(
f"Bad download request params from {request_ip} - {vars(req)}"
)
2023-02-18 05:00:30 +00:00
except AllnetRequestException as e:
2023-02-24 19:07:54 +00:00
if e.message != "":
self.logger.error(e)
2024-03-22 16:22:07 +00:00
return PlainTextResponse()
2023-02-18 05:00:30 +00:00
self.logger.info(
f"DownloadOrder from {request_ip} -> {req.game_id} v{req.ver} serial {req.serial}"
)
2023-05-09 07:53:31 +00:00
resp = AllnetDownloadOrderResponse(serial=req.serial)
if (
not self.config.allnet.allow_online_updates
or not self.config.allnet.update_cfg_folder
):
2024-03-22 16:22:07 +00:00
return PlainTextResponse(
urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\n"
)
2023-03-09 16:38:58 +00:00
else: # TODO: Keychip check
if path.exists(
2023-05-24 05:08:53 +00:00
f"{self.config.allnet.update_cfg_folder}/{req.game_id}-{req.ver.replace('.', '')}-app.ini"
):
2024-03-22 16:22:07 +00:00
resp.uri = f"http://{self.config.server.hostname}:{self.config.server.port}/dl/ini/{req.game_id}-{req.ver.replace('.', '')}-app.ini"
if path.exists(
2023-05-24 05:08:53 +00:00
f"{self.config.allnet.update_cfg_folder}/{req.game_id}-{req.ver.replace('.', '')}-opt.ini"
):
2024-03-22 16:22:07 +00:00
resp.uri += f"|http://{self.config.server.hostname}:{self.config.server.port}/dl/ini/{req.game_id}-{req.ver.replace('.', '')}-opt.ini"
self.logger.debug(f"Sending download uri {resp.uri}")
2024-03-22 16:22:07 +00:00
await self.data.base.log_event(
"allnet",
"DLORDER_REQ_SUCCESS",
logging.INFO,
f"{Utils.get_ip_addr(request)} requested DL Order for {req.serial} {req.game_id} v{req.ver}",
)
2023-08-21 04:10:25 +00:00
res_str = urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\n"
"""if is_dfi:
request.responseHeaders.addRawHeader('Pragma', 'DFI')
return self.to_dfi(res_str)"""
2024-03-22 16:22:07 +00:00
return PlainTextResponse(res_str)
2023-02-16 22:13:41 +00:00
2024-03-22 16:22:07 +00:00
async def handle_dlorder_ini(self, request: Request) -> bytes:
req_file = (
request.path_params.get("file", "").replace("%0A", "").replace("\n", "")
)
2024-03-22 16:22:07 +00:00
if not req_file:
return PlainTextResponse(status_code=404)
if path.exists(f"{self.config.allnet.update_cfg_folder}/{req_file}"):
2024-03-22 16:22:07 +00:00
self.logger.info(
f"Request for DL INI file {req_file} from {Utils.get_ip_addr(request)} successful"
)
await self.data.base.log_event(
"allnet",
"DLORDER_INI_SENT",
logging.INFO,
f"{Utils.get_ip_addr(request)} successfully recieved {req_file}",
)
return PlainTextResponse(
open(
f"{self.config.allnet.update_cfg_folder}/{req_file}",
"r",
encoding="utf-8",
).read()
)
self.logger.info(f"DL INI File {req_file} not found")
2024-03-22 16:22:07 +00:00
return PlainTextResponse()
2024-03-22 16:22:07 +00:00
async def handle_dlorder_report(self, request: Request) -> bytes:
req_raw = await request.body()
client_ip = Utils.get_ip_addr(request)
2023-07-23 16:47:10 +00:00
try:
req_dict: Dict = json.loads(req_raw)
except Exception as e:
2023-08-08 14:17:56 +00:00
self.logger.warning(f"Failed to parse DL Report: {e}")
2024-03-22 16:22:07 +00:00
return PlainTextResponse("NG")
2023-07-23 16:47:10 +00:00
dl_data_type = DLIMG_TYPE.app
dl_data = req_dict.get("appimage", {})
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
if dl_data is None or not dl_data:
dl_data_type = DLIMG_TYPE.opt
dl_data = req_dict.get("optimage", {})
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
if dl_data is None or not dl_data:
2024-03-22 16:22:07 +00:00
self.logger.warning(
f"Failed to parse DL Report: Invalid format - contains neither appimage nor optimage"
)
return PlainTextResponse("NG")
2023-07-23 16:47:10 +00:00
rep = DLReport(dl_data, dl_data_type)
2023-07-23 16:47:10 +00:00
if not rep.validate():
2024-03-22 16:22:07 +00:00
self.logger.warning(
f"Failed to parse DL Report: Invalid format - {rep.err}"
)
return PlainTextResponse("NG")
msg = (
f"{rep.serial} @ {client_ip} reported {rep.rep_type.name} download state {rep.rf_state.name} for {rep.gd} v{rep.dav}:"
f" {rep.tdsc}/{rep.tsc} segments downloaded for working files {rep.wfl} with {rep.dfl if rep.dfl else 'none'} complete."
)
await self.data.base.log_event(
"allnet", "DL_REPORT", logging.INFO, msg, dl_data
)
self.logger.info(msg)
2023-07-23 16:47:10 +00:00
2024-03-22 16:22:07 +00:00
return PlainTextResponse("OK")
2024-03-22 16:22:07 +00:00
async def handle_loaderstaterecorder(self, request: Request) -> bytes:
req_data = await request.body()
2023-06-30 05:34:46 +00:00
sections = req_data.decode("utf-8").split("\r\n")
2024-03-22 16:22:07 +00:00
2023-06-30 05:34:46 +00:00
req_dict = dict(urllib.parse.parse_qsl(sections[0]))
serial: Union[str, None] = req_dict.get("serial", None)
num_files_to_dl: Union[str, None] = req_dict.get("nb_ftd", None)
num_files_dld: Union[str, None] = req_dict.get("nb_dld", None)
dl_state: Union[str, None] = req_dict.get("dld_st", None)
ip = Utils.get_ip_addr(request)
2024-03-22 16:22:07 +00:00
if (
serial is None
or num_files_dld is None
or num_files_to_dl is None
or dl_state is None
):
return PlainTextResponse("NG")
self.logger.info(
f"LoaderStateRecorder Request from {ip} {serial}: {num_files_dld}/{num_files_to_dl} Files download (State: {dl_state})"
)
return PlainTextResponse("OK")
async def handle_alive(self, request: Request) -> bytes:
return PlainTextResponse("OK")
async def handle_naomitest(self, request: Request) -> bytes:
self.logger.info(f"Ping from {Utils.get_ip_addr(request)}")
return PlainTextResponse("naomi ok")
def allnet_req_to_dict(self, data: str) -> Optional[List[Dict[str, Any]]]:
"""
Parses an allnet request string into a python dictionary
"""
try:
sections = data.split("\r\n")
ret = []
for x in sections:
ret.append(dict(urllib.parse.parse_qsl(x)))
return ret
2024-03-22 16:22:07 +00:00
except Exception as e:
self.logger.error(f"allnet_req_to_dict: {e} while parsing {data}")
return None
def from_dfi(self, data: bytes) -> str:
zipped = base64.b64decode(data)
unzipped = zlib.decompress(zipped)
return unzipped.decode("utf-8")
def to_dfi(self, data: str) -> bytes:
unzipped = data.encode("utf-8")
zipped = zlib.compress(unzipped)
return base64.b64encode(zipped)
class BillingServlet:
def __init__(self, core_cfg: CoreConfig, cfg_folder: str) -> None:
self.config = core_cfg
self.config_folder = cfg_folder
self.data = Data(core_cfg)
self.logger = logging.getLogger("billing")
if not hasattr(self.logger, "initialized"):
log_fmt_str = "[%(asctime)s] Billing | %(levelname)s | %(message)s"
log_fmt = logging.Formatter(log_fmt_str)
fileHandler = TimedRotatingFileHandler(
"{0}/{1}.log".format(self.config.server.log_dir, "billing"),
when="d",
backupCount=10,
)
fileHandler.setFormatter(log_fmt)
consoleHandler = logging.StreamHandler()
consoleHandler.setFormatter(log_fmt)
2024-03-22 16:22:07 +00:00
self.logger.addHandler(fileHandler)
self.logger.addHandler(consoleHandler)
self.logger.setLevel(core_cfg.allnet.loglevel)
coloredlogs.install(
level=core_cfg.billing.loglevel, logger=self.logger, fmt=log_fmt_str
)
self.logger.initialized = True
def startup(self) -> None:
self.logger.info(
f"Ready on port {self.config.billing.port if self.config.billing.standalone else self.config.server.port}"
)
def billing_req_to_dict(self, data: bytes):
"""
Parses an billing request string into a python dictionary
"""
try:
sections = data.decode("ascii").split("\r\n")
ret = []
for x in sections:
ret.append(dict(urllib.parse.parse_qsl(x)))
return ret
except Exception as e:
self.logger.error(f"billing_req_to_dict: {e} while parsing {data}")
return None
async def handle_billing_request(self, request: Request):
req_raw = await request.body()
if request.headers.get("Content-Type", "") == "application/octet-stream":
2023-08-21 04:10:25 +00:00
req_unzip = zlib.decompressobj(-zlib.MAX_WBITS).decompress(req_raw)
else:
req_unzip = req_raw
2024-03-22 16:22:07 +00:00
2023-08-21 04:10:25 +00:00
req_dict = self.billing_req_to_dict(req_unzip)
2023-03-17 06:11:49 +00:00
request_ip = Utils.get_ip_addr(request)
2024-03-22 16:22:07 +00:00
2023-02-18 05:00:30 +00:00
if req_dict is None:
2024-03-22 16:22:07 +00:00
self.logger.error(f"Failed to parse request {req_raw}")
return PlainTextResponse()
2023-03-09 16:38:58 +00:00
2023-02-18 05:00:30 +00:00
self.logger.debug(f"request {req_dict}")
2023-03-09 16:38:58 +00:00
rsa = RSA.import_key(open(self.config.billing.signing_key, "rb").read())
2023-02-18 05:00:30 +00:00
signer = PKCS1_v1_5.new(rsa)
digest = SHA.new()
traces: List[TraceData] = []
2023-05-03 07:25:55 +00:00
try:
2023-12-03 02:01:33 +00:00
req = BillingInfo(req_dict[0])
except KeyError as e:
self.logger.error(f"Billing request failed to parse: {e}")
2024-03-22 16:22:07 +00:00
return PlainTextResponse(
"result=5&linelimit=&message=field is missing or formatting is incorrect\r\n"
)
2023-12-03 02:01:33 +00:00
for x in range(1, len(req_dict)):
if not req_dict[x]:
continue
2024-03-22 16:22:07 +00:00
2023-12-03 02:01:33 +00:00
try:
tmp = TraceData(req_dict[x])
if tmp.trace_type == TraceDataType.CHARGE:
tmp = TraceDataCharge(req_dict[x])
elif tmp.trace_type == TraceDataType.EVENT:
tmp = TraceDataEvent(req_dict[x])
elif tmp.trace_type == TraceDataType.CREDIT:
tmp = TraceDataCredit(req_dict[x])
2024-03-22 16:22:07 +00:00
traces.append(tmp)
2024-03-22 16:22:07 +00:00
2023-12-03 02:01:33 +00:00
except KeyError as e:
self.logger.warn(f"Tracelog failed to parse: {e}")
2023-12-03 02:01:33 +00:00
kc_serial_bytes = req.keychipid.encode()
2023-02-18 05:00:30 +00:00
2024-03-22 16:22:07 +00:00
machine = await self.data.arcade.get_machine(req.keychipid)
2023-02-18 05:00:30 +00:00
if machine is None and not self.config.server.allow_unregistered_serials:
msg = f"Unrecognised serial {req.keychipid} attempted billing checkin from {request_ip} for {req.gameid} v{req.gamever}."
2024-03-22 16:22:07 +00:00
await self.data.base.log_event(
2023-03-09 16:38:58 +00:00
"allnet", "BILLING_CHECKIN_NG_SERIAL", logging.WARN, msg
)
2023-08-08 14:17:56 +00:00
self.logger.warning(msg)
2023-02-18 05:00:30 +00:00
2024-03-22 16:22:07 +00:00
return PlainTextResponse(
f"result=1&requestno={req.requestno}&message=Keychip Serial bad\r\n"
)
2023-02-18 05:00:30 +00:00
2023-03-09 16:38:58 +00:00
msg = (
f"Billing checkin from {request_ip}: game {req.gameid} ver {req.gamever} keychip {req.keychipid} playcount "
f"{req.playcnt} billing_type {req.billingtype.name} nearfull {req.nearfull} playlimit {req.playlimit}"
2023-03-09 16:38:58 +00:00
)
2023-02-18 05:00:30 +00:00
self.logger.info(msg)
2024-03-22 16:22:07 +00:00
await self.data.base.log_event(
"billing", "BILLING_CHECKIN_OK", logging.INFO, msg
)
if req.traceleft > 0:
self.logger.warn(f"{req.traceleft} unsent tracelogs")
kc_playlimit = req.playlimit
kc_nearfull = req.nearfull
2023-02-18 05:00:30 +00:00
while req.playcnt > req.playlimit:
2023-02-18 05:00:30 +00:00
kc_playlimit += 1024
kc_nearfull += 1024
2023-03-09 16:38:58 +00:00
2023-02-18 05:00:30 +00:00
playlimit = kc_playlimit
nearfull = kc_nearfull + (req.billingtype.value * 0x00010000)
2023-02-18 05:00:30 +00:00
2023-03-09 16:38:58 +00:00
digest.update(playlimit.to_bytes(4, "little") + kc_serial_bytes)
2023-02-18 05:00:30 +00:00
playlimit_sig = signer.sign(digest).hex()
digest = SHA.new()
2023-03-09 16:38:58 +00:00
digest.update(nearfull.to_bytes(4, "little") + kc_serial_bytes)
2023-02-18 05:00:30 +00:00
nearfull_sig = signer.sign(digest).hex()
# TODO: playhistory
2024-03-22 16:22:07 +00:00
resp = BillingResponse(
playlimit,
playlimit_sig,
nearfull,
nearfull_sig,
req.requestno,
req.protocolver,
)
2023-02-18 05:00:30 +00:00
2023-08-21 04:10:25 +00:00
resp_str = urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\r\n"
2023-02-18 05:00:30 +00:00
self.logger.debug(f"response {vars(resp)}")
if req.traceleft > 0:
self.logger.info(f"Requesting 20 more of {req.traceleft} unsent tracelogs")
2024-03-22 16:22:07 +00:00
return PlainTextResponse("result=6&waittime=0&linelimit=20\r\n")
2023-08-21 04:10:25 +00:00
2024-03-22 16:22:07 +00:00
return PlainTextResponse(resp_str)
2023-02-16 22:13:41 +00:00
2023-03-09 16:38:58 +00:00
class AllnetPowerOnRequest:
2023-02-16 22:13:41 +00:00
def __init__(self, req: Dict) -> None:
if req is None:
raise AllnetRequestException("Request processing failed")
self.game_id: str = req.get("game_id", None)
self.ver: str = req.get("ver", None)
self.serial: str = req.get("serial", None)
self.ip: str = req.get("ip", None)
self.firm_ver: str = req.get("firm_ver", None)
self.boot_ver: str = req.get("boot_ver", None)
self.encode: str = req.get("encode", "EUC-JP")
self.hops = int(req.get("hops", "-1"))
self.format_ver = float(req.get("format_ver", "1.00"))
self.token: str = req.get("token", "0")
2024-03-22 16:22:07 +00:00
class AllnetPowerOnResponse:
def __init__(self) -> None:
2023-02-16 22:13:41 +00:00
self.stat = 1
self.uri = ""
self.host = ""
self.place_id = "0123"
self.name = "ARTEMiS"
self.nickname = "ARTEMiS"
2023-02-16 22:13:41 +00:00
self.region0 = "1"
self.region_name0 = "W"
self.region_name1 = ""
self.region_name2 = ""
2024-03-22 16:22:07 +00:00
self.region_name3 = ""
self.setting = "1"
self.year = datetime.now().year
self.month = datetime.now().month
self.day = datetime.now().day
self.hour = datetime.now().hour
self.minute = datetime.now().minute
self.second = datetime.now().second
2024-03-22 16:22:07 +00:00
class AllnetPowerOnResponse3(AllnetPowerOnResponse):
def __init__(self, token) -> None:
super().__init__()
# Added in v3
2023-02-16 22:13:41 +00:00
self.country = "JPN"
self.allnet_id = "123"
self.client_timezone = "+0900"
2023-03-09 16:38:58 +00:00
self.utc_time = datetime.now(tz=pytz.timezone("UTC")).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
2023-02-16 22:13:41 +00:00
self.res_ver = "3"
self.token = token
# Removed in v3
self.year = None
self.month = None
self.day = None
self.hour = None
self.minute = None
self.second = None
2023-02-16 22:13:41 +00:00
2023-03-09 16:38:58 +00:00
class AllnetPowerOnResponse2(AllnetPowerOnResponse):
2023-02-16 22:13:41 +00:00
def __init__(self) -> None:
super().__init__()
# Added in v2
2023-02-16 22:13:41 +00:00
self.country = "JPN"
self.timezone = "+09:00"
2023-02-16 22:13:41 +00:00
self.res_class = "PowerOnResponseV2"
2023-03-09 16:38:58 +00:00
class AllnetDownloadOrderRequest:
2023-02-16 22:13:41 +00:00
def __init__(self, req: Dict) -> None:
2023-03-09 17:17:10 +00:00
self.game_id = req.get("game_id", "")
self.ver = req.get("ver", "")
self.serial = req.get("serial", "")
self.encode = req.get("encode", "")
2023-02-16 22:13:41 +00:00
2023-03-09 16:38:58 +00:00
class AllnetDownloadOrderResponse:
def __init__(self, stat: int = 1, serial: str = "", uri: str = "") -> None:
2023-02-16 22:13:41 +00:00
self.stat = stat
self.serial = serial
self.uri = uri
2024-03-22 16:22:07 +00:00
class TraceDataType(Enum):
CHARGE = 0
EVENT = 1
CREDIT = 2
2024-03-22 16:22:07 +00:00
class BillingType(Enum):
A = 1
B = 0
2024-03-22 16:22:07 +00:00
class float5:
def __init__(self, n: str = "0") -> None:
nf = float(n)
if nf > 999.9 or nf < 0:
2024-03-22 16:22:07 +00:00
raise ValueError("float5 must be between 0.000 and 999.9 inclusive")
return nf
2024-03-22 16:22:07 +00:00
@classmethod
def to_str(cls, f: float):
2023-08-21 05:53:27 +00:00
return f"%.{2 - int(math.log10(f))+1}f" % f
2024-03-22 16:22:07 +00:00
class BillingInfo:
def __init__(self, data: Dict) -> None:
try:
self.keychipid = str(data.get("keychipid", None))
self.functype = int(data.get("functype", None))
self.gameid = str(data.get("gameid", None))
self.gamever = float(data.get("gamever", None))
self.boardid = str(data.get("boardid", None))
self.tenpoip = str(data.get("tenpoip", None))
self.libalibver = float(data.get("libalibver", None))
self.datamax = int(data.get("datamax", None))
self.billingtype = BillingType(int(data.get("billingtype", None)))
self.protocolver = float(data.get("protocolver", None))
self.operatingfix = bool(data.get("operatingfix", None))
self.traceleft = int(data.get("traceleft", None))
self.requestno = int(data.get("requestno", None))
self.datesync = bool(data.get("datesync", None))
self.timezone = str(data.get("timezone", None))
self.date = datetime.strptime(data.get("date", None), BILLING_DT_FORMAT)
self.crcerrcnt = int(data.get("crcerrcnt", None))
self.memrepair = bool(data.get("memrepair", None))
self.playcnt = int(data.get("playcnt", None))
self.playlimit = int(data.get("playlimit", None))
self.nearfull = int(data.get("nearfull", None))
except Exception as e:
raise KeyError(e)
2024-03-22 16:22:07 +00:00
class TraceData:
def __init__(self, data: Dict) -> None:
try:
self.crc_err_flg = bool(data.get("cs", None))
self.record_number = int(data.get("rn", None))
self.seq_number = int(data.get("sn", None))
self.trace_type = TraceDataType(int(data.get("tt", None)))
self.date_sync_flg = bool(data.get("ds", None))
2024-03-22 16:22:07 +00:00
2023-12-03 02:01:33 +00:00
dt = data.get("dt", None)
if dt is None:
raise KeyError("dt not present")
2024-03-22 16:22:07 +00:00
if dt == "20000000000000": # Not sure what causes it to send like this...
2023-12-03 02:01:33 +00:00
self.date = datetime(2000, 1, 1, 0, 0, 0, 0)
else:
self.date = datetime.strptime(data.get("dt", None), BILLING_DT_FORMAT)
2024-03-22 16:22:07 +00:00
self.keychip = str(data.get("kn", None))
2023-12-03 02:01:33 +00:00
self.lib_ver = float(data.get("alib", 0))
except Exception as e:
raise KeyError(e)
2024-03-22 16:22:07 +00:00
class TraceDataCharge(TraceData):
def __init__(self, data: Dict) -> None:
super().__init__(data)
try:
2024-03-22 16:22:07 +00:00
self.game_id = str(data.get("gi", None)) # these seem optional...?
2023-12-03 02:01:33 +00:00
self.game_version = float(data.get("gv", 0))
self.board_serial = str(data.get("bn", None))
self.shop_ip = str(data.get("ti", None))
self.play_count = int(data.get("pc", None))
self.play_limit = int(data.get("pl", None))
self.product_code = int(data.get("ic", None))
self.product_count = int(data.get("in", None))
self.func_type = int(data.get("kk", None))
self.player_number = int(data.get("playerno", None))
except Exception as e:
raise KeyError(e)
2024-03-22 16:22:07 +00:00
class TraceDataEvent(TraceData):
def __init__(self, data: Dict) -> None:
super().__init__(data)
try:
self.message = str(data.get("me", None))
except Exception as e:
raise KeyError(e)
2024-03-22 16:22:07 +00:00
class TraceDataCredit(TraceData):
def __init__(self, data: Dict) -> None:
super().__init__(data)
try:
self.chute_type = int(data.get("cct", None))
self.service_type = int(data.get("cst", None))
self.operation_type = int(data.get("cop", None))
self.coin_rate0 = int(data.get("cr0", None))
self.coin_rate1 = int(data.get("cr1", None))
self.bonus_addition = int(data.get("cba", None))
self.credit_rate = int(data.get("ccr", None))
self.credit0 = int(data.get("cc0", None))
self.credit1 = int(data.get("cc1", None))
self.credit2 = int(data.get("cc2", None))
self.credit3 = int(data.get("cc3", None))
self.credit4 = int(data.get("cc4", None))
self.credit5 = int(data.get("cc5", None))
self.credit6 = int(data.get("cc6", None))
self.credit7 = int(data.get("cc7", None))
except Exception as e:
raise KeyError(e)
2023-02-16 22:13:41 +00:00
2024-03-22 16:22:07 +00:00
2023-03-09 16:38:58 +00:00
class BillingResponse:
def __init__(
self,
playlimit: str = "",
playlimit_sig: str = "",
nearfull: str = "",
nearfull_sig: str = "",
request_num: int = 1,
protocol_ver: float = 1.000,
2023-03-09 16:38:58 +00:00
playhistory: str = "000000/0:000000/0:000000/0",
) -> None:
self.result = 0
self.requestno = request_num
self.traceerase = 1
self.fixinterval = 120
self.fixlogcnt = 100
2023-02-16 22:13:41 +00:00
self.playlimit = playlimit
self.playlimitsig = playlimit_sig
self.playhistory = playhistory
2023-02-16 22:13:41 +00:00
self.nearfull = nearfull
self.nearfullsig = nearfull_sig
self.linelimit = 100
self.protocolver = float5.to_str(protocol_ver)
2023-02-16 22:13:41 +00:00
# playhistory -> YYYYMM/C:...
# YYYY -> 4 digit year, MM -> 2 digit month, C -> Playcount during that period
2023-03-09 16:38:58 +00:00
2023-02-16 22:13:41 +00:00
class AllnetRequestException(Exception):
2023-02-24 19:07:54 +00:00
def __init__(self, message="") -> None:
2023-02-18 05:00:30 +00:00
self.message = message
super().__init__(self.message)
2023-07-23 16:47:10 +00:00
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
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")
2023-11-02 00:08:20 +00:00
self.rf_state = DLI_STATUS.from_int(data.get("rf_state"))
2023-07-23 16:47:10 +00:00
self.gd = data.get("gd")
self.dav = data.get("dav")
2024-03-22 16:22:07 +00:00
self.wdav = data.get("wdav") # app only
2023-07-23 16:47:10 +00:00
self.dov = data.get("dov")
2024-03-22 16:22:07 +00:00
self.wdov = data.get("wdov") # app only
2023-11-04 16:41:47 +00:00
self.rep_type = report_type
2023-07-23 16:47:10 +00:00
self.err = ""
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
def validate(self) -> bool:
2024-03-22 16:22:07 +00:00
if self.serial is None:
2023-07-23 16:47:10 +00:00
self.err = "serial not provided"
return False
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
if self.tsc is None:
self.err = "tsc not provided"
return False
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
if self.tdsc is None:
self.err = "tdsc not provided"
return False
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
if self.as_ is None:
self.err = "as not provided"
return False
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
if self.rf_state is None:
self.err = "rf_state not provided"
return False
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
if self.gd is None:
self.err = "gd not provided"
return False
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
if self.dav is None:
self.err = "dav not provided"
return False
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
if self.dov is None:
self.err = "dov not provided"
return False
2024-03-22 16:22:07 +00:00
2023-11-04 16:41:47 +00:00
if (self.wdav is None or self.wdov is None) and self.rep_type == DLIMG_TYPE.app:
2023-07-23 16:47:10 +00:00
self.err = "wdav or wdov not provided in app image"
return False
2024-03-22 16:22:07 +00:00
if (
self.wdav is not None or self.wdov is not None
) and self.rep_type == DLIMG_TYPE.opt:
2023-07-23 16:47:10 +00:00
self.err = "wdav or wdov provided in opt image"
return False
2024-03-22 16:22:07 +00:00
2023-07-23 16:47:10 +00:00
return True
2024-03-22 16:22:07 +00:00
cfg_dir = environ.get("DIANA_CFG_DIR", "config")
cfg: CoreConfig = CoreConfig()
if path.exists(f"{cfg_dir}/core.yaml"):
cfg.update(yaml.safe_load(open(f"{cfg_dir}/core.yaml")))
if not path.exists(cfg.server.log_dir):
mkdir(cfg.server.log_dir)
if not access(cfg.server.log_dir, W_OK):
print(f"Log directory {cfg.server.log_dir} NOT writable, please check permissions")
exit(1)
billing = BillingServlet(cfg, cfg_dir)
app_billing = Starlette(
cfg.server.is_develop,
[
Route("/request", billing.handle_billing_request, methods=["POST"]),
Route("/request/", billing.handle_billing_request, methods=["POST"]),
],
on_startup=[billing.startup],
)
allnet = AllnetServlet(cfg, cfg_dir)
route_lst = [
Route("/sys/servlet/PowerOn", allnet.handle_poweron, methods=["GET", "POST"]),
Route("/sys/servlet/DownloadOrder", allnet.handle_dlorder, methods=["GET", "POST"]),
Route(
"/sys/servlet/LoaderStateRecorder",
allnet.handle_loaderstaterecorder,
methods=["GET", "POST"],
),
Route("/sys/servlet/Alive", allnet.handle_alive, methods=["GET", "POST"]),
Route("/naomitest.html", allnet.handle_naomitest),
]
if cfg.allnet.allow_online_updates:
route_lst += [
Route("/report-api/Report", allnet.handle_dlorder_report, methods=["POST"]),
Route("/dl/ini/{file:str}", allnet.handle_dlorder_ini),
]
app_allnet = Starlette(cfg.server.is_develop, route_lst, on_startup=[allnet.startup])