dzgui/dzgui/api/servers.py
2026-05-15 23:26:47 +09:00

523 lines
13 KiB
Python

import ipaddress
import logging
import math
import os
import re
import requests
import socket
import subprocess
import threading
from dataclasses import dataclass
from typing import Any, Optional, TYPE_CHECKING, Union
from dzgui.api.bm import map_id_to_record
from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT
from dzgui.const.endpoints import STEAM_SERVERS
from dzgui.util import strings
import a2s
import dayzquery
if TYPE_CHECKING:
from a2s import SourceInfo
from dayzquery import DayzMod
logger = logging.getLogger(APP_NAME)
# TODO: confirm that patches from testing are incorporated here
# particularly around malformed values in JSON response. check commit log
params = [
r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", # noqa
r"\map\namalsk\empty\1",
r"\map\namalsk\noplayers\1",
r"\map\chernarusplus\empty\1",
r"\map\chernarusplus\noplayers\1",
r"\map\sakhal\empty\1",
r"\map\sakhal\noplayers\1",
r"\map\enoch\empty\1",
r"\map\enoch\noplayers\1",
]
class InvalidIpError(Exception):
pass
@dataclass(slots=True, frozen=True)
class Res:
status: int
parsed: bool
json: Union[dict[str, Any], None]
@dataclass(slots=True, frozen=True)
class Details:
data: Union[list, None]
description: str
success: bool
@dataclass(slots=True)
class Record:
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str
gameport: int
qport: int
class A2SInfo:
def __init__(self, record: Record, info: a2s.SourceInfo | None) -> None:
self.record = record
self.info = info
def get_record(self) -> Record:
return self.record
def get_info(self) -> a2s.SourceInfo | None:
return self.info
def as_row(self) -> dict[str, Any]:
if self.info is None:
raise AttributeError("No record to convert")
ip = self.record.ip
qport = self.record.qport
try:
return source_info_to_dict(ip, qport, self.info)
except Exception as e:
logger.warning(e)
raise e
def is_modded(self) -> bool:
if self.info is None:
raise ValueError("Cannot call this method on Nonetype")
try:
kw = self.info.keywords.split(",")
state = True if "mod" in kw else False
return state
except Exception as e:
logger.warning(e)
raise e
def get_netmask() -> str:
hostname = os.uname()[1]
i = socket.gethostbyname(hostname)
netmask = i.rsplit(".", 1)[0]
return netmask
# TODO: rename
def test_ip(suffix: int, port: int, event: threading.Event) -> dict | None:
if event.is_set():
return None
netmask = get_netmask()
hostname = f"{netmask}.{str(suffix)}"
ping = ["ping", "-c1", "-i", "0.1", "-w", "1"]
output = subprocess.run(ping + [hostname], capture_output=True)
if output.returncode == 0:
return query_direct(hostname, port)
return None
def sanitize(name: str) -> str:
"""
Strips extraneous padding and spammy junk chars in server names
"""
name = re.sub(r"\r", r"", name)
name = re.sub(r"\n", r"", name)
name = re.sub(r"\x01", r"", name)
name = re.sub(r"\ufeff", r"", name)
name = re.sub(r"(^!\s*)", r"-", name)
name = re.sub(r"(^-\s*)", r"", name)
name = re.sub(r"(^-)", r"", name)
name = re.sub(r"(^\s*)", r"", name)
name = re.sub(r"\t", "", name)
return name
def parse_json(json: list) -> list:
"""
Server metadata is underspecified and server operators
tend to insert random garbage in the headers. In case
sanitization failed, discard malformed rows rather than
aborting outright.
"""
rows = []
for row in json:
try:
name = sanitize(row["name"])
if name == "":
continue
except KeyError:
continue
malformed = False
for key in [
"map",
"gametype",
"players",
"max_players",
"addr",
"gameport",
]:
try:
row[key]
except KeyError:
malformed = True
break
if malformed is True:
continue
try:
r = row["gametype"].split(",")
except KeyError:
continue
if "no3rd" in r:
view = strings.filter_1pp
else:
view = strings.filter_3pp
if "external" in r:
provider = strings.filter_unofficial
else:
provider = strings.filter_official
if "mod" in r:
modded = True
else:
modded = False
try:
r = row["gametype"].split("lqs")
queue = r[1].split(",")[0]
if int(queue) > 255:
continue
except IndexError:
queue = 0
try:
test_time = re.search(r"[0-9]{2}:[0-9]{2}", row["gametype"])
if test_time:
time = test_time.group(0)
else:
time = strings.unknown
except AttributeError:
time = strings.unknown
try:
ip = row["addr"].split(":")[0] + ":" + str(row["gameport"])
qport = row["addr"].split(":")[1]
except IndexError:
continue
try:
ping = row["ping"]
except KeyError:
ping = 9999
mapname = row["map"].lower()
players = row["players"]
max_players = row["max_players"]
raw = (
name,
mapname,
view,
time,
int(players),
int(max_players),
int(queue),
ip,
int(qport),
ping,
provider,
modded,
)
rows.append(raw)
return rows
def source_info_to_dict(ip: str, qport: int, info: "SourceInfo") -> dict[str, Any]:
try:
name = info.server_name
mapname = info.map_name
address = ip + ":" + str(qport)
gameport = str(info.port)
players = info.player_count
max_players = info.max_players
keywords = info.keywords
try:
ping = info.ping
ping = math.floor(info.ping * 1000)
except AttributeError:
ping = 9999
res = {}
res["name"] = name
res["map"] = mapname
res["gametype"] = keywords
res["players"] = players
res["max_players"] = max_players
res["addr"] = address
res["gameport"] = gameport
res["ping"] = ping
return res
except Exception as e:
# TODO: generalized function
logger.critical(f"{type(e).__name__}: {e} ({ip}:{qport})")
raise e
def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] | None:
try:
info = a2s.info((ip, qport), timeout)
return source_info_to_dict(ip, qport, info)
except Exception as e:
logger.warning(e)
return None
def get_details(record: Record) -> Details:
ip = record.ip
qport = record.qport
default_str = strings.none_provided
try:
info = a2s.info((ip, qport))
except TimeoutError:
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, qport))
except Exception:
return Details(None, default_str, False)
try:
keywords = info.keywords.split(",")
except AttributeError:
return Details(None, default_str, False)
battleye = strings.disabled
if "battleye" in keywords:
battleye = strings.enabled
day_accel = 0.0
night_accel = 0.0
for keyword in keywords:
if "etm" in keyword:
day_accel = float(keyword.lstrip("etm"))
day_accel = float(f"{day_accel:g}")
if "entm" in keywords:
night_accel = float(keyword.lstrip("entm"))
night_accel = float(f"{night_accel:g}")
try:
password = info.password_protected
if password is False:
password = strings.disabled
else:
password = strings.enabled
except AttributeError:
password = "-"
try:
vac = info.vac_enabled
if vac is False:
vac = strings.disabled
else:
vac = strings.enabled
except AttributeError:
vac = strings.null
try:
version = info.version
except AttributeError:
version = strings.null
try:
dlc = rules.dlc_flags
if dlc == 0:
dlc = strings.none
if dlc == 2:
dlc = strings.dlc_frostline
except AttributeError:
dlc = strings.unspecified
try:
platform = rules.platform
if platform == "win":
platform = strings.windows
if platform == "?":
platform = strings.linux
except AttributeError:
platform = strings.unspecified
try:
description = rules.description.strip()
if description == "":
description = default_str
except AttributeError:
description = default_str
rows = [
["Battleye", battleye],
["Daytime acceleration", f"{day_accel}x"],
["DLC", dlc],
["Night-time acceleration", f"{night_accel}x"],
["Password", password],
["Platform", platform],
["Valve Anti-Cheat", vac],
["Version", version],
]
return Details(rows, description, True)
def ping(ip: str, qport: int) -> int:
try:
res = query_direct(ip, qport, 0.5)
if res is None:
return 9999
else:
return int(res["ping"])
except Exception:
return 9999
def query_api(key: str, appid: int, param: str) -> Res:
LIMIT = 10000
payload: dict[str, Union[int, str]] = {
"filter": r"\appid" + rf"\{appid}" + param,
"limit": LIMIT,
"key": key,
}
try:
"""
NOTE: the global default timeout is None;
using a low timeout (~5s) sometimes fails
"""
res = requests.get(STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT)
res.raise_for_status()
parsed = True
status = 200
json = res.json()
except Exception:
parsed = False
json = None
status = 403
finally:
return Res(status, parsed, json)
def validate_ip(addr: str) -> Record:
fields = addr.split(":")
if len(fields) != 2:
raise InvalidIpError("Address must be formatted as IP:Queryport")
ip = fields[0]
port = fields[1]
try:
int(port)
except ValueError:
raise InvalidIpError(f"'{port}' is not a valid port")
if int(port) > 65535 or int(port) < 0:
raise InvalidIpError(f"'{port}' is not a valid port")
try:
ipaddress.ip_address(ip)
except ValueError:
raise InvalidIpError(f"'{ip}' is not a valid IP")
ip = addr.split(":")[0]
qport = int(addr.split(":")[1])
record = Record(ip, 0, qport)
return record
def get_rules(record: Record) -> list["DayzMod"]:
ip = record.ip
qport = record.qport
mods = dayzquery.dayz_rules((ip, qport)).mods
return [mod for mod in mods]
def query_playercount(record: Record) -> tuple[int, int] | None:
try:
res = query_direct(record.ip, record.qport)
if res is None:
return None
players = int(res["players"])
r = res["gametype"].split("lqs")
try:
queue = int(r[1].split(",")[0])
except IndexError:
queue = 0
return players, queue
except Exception as e:
logger.critical(e)
return None
def query_by_ip(addr: str) -> A2SInfo:
record = short_ip_to_record(addr)
if record is None:
return A2SInfo(Record("0", 0, 0), None)
return query_by_record(record, update_gameport=True)
def query_by_id(server_id: int, key: str) -> A2SInfo:
"""
Used with numeric Battlemetrics IDs
"""
record = map_id_to_record(key, server_id)
if record is None:
return A2SInfo(Record("0", 0, 0), None)
return query_by_record(record)
def query_by_record(record: Record, update_gameport: bool = False) -> A2SInfo:
try:
info = a2s.info((record.ip, record.qport), 3.0)
if update_gameport:
record = Record(record.ip, info.port, record.qport)
return A2SInfo(record, info)
except Exception as e:
logger.warning(e)
return A2SInfo(record, None)
def short_ip_to_record(addr: str) -> Optional[Record]:
r = addr.split(":")
# TODO: raise exceptions instead of Nonetype
if len(r) != 2:
return None
return Record(r[0], 0, int(r[1]))
def fqip_to_record(addr: str) -> Optional[Record]:
r = addr.split(":")
if len(r) != 3:
return None
return Record(r[0], int(r[1]), int(r[2]))
def response_to_fqip(res: dict) -> str:
ip = res["addr"].split(":")[0]
gameport = res["gameport"]
qport = res["addr"].split(":")[1]
return f"{ip}:{gameport}:{qport}"
def response_to_record(res: dict) -> Record:
ip = res["addr"].split(":")[0]
gameport = res["gameport"]
qport = res["addr"].split(":")[1]
return Record(ip, int(gameport), int(qport))