├── GameserverLister ├── __init__.py ├── commands │ ├── __init__.py │ ├── options │ │ ├── __init__.py │ │ ├── name.py │ │ ├── gameport.py │ │ ├── queryport.py │ │ ├── http.py │ │ └── common.py │ ├── medalofhonor.py │ ├── bfbc2.py │ ├── quake3.py │ ├── gametools.py │ ├── unreal2.py │ ├── battlelog.py │ ├── valve.py │ └── gamespy.py ├── common │ ├── __init__.py │ ├── logger.py │ ├── constants.py │ ├── weblinks.py │ ├── helpers.py │ ├── types.py │ └── servers.py ├── games │ ├── __init__.py │ ├── gametools.py │ ├── battlelog.py │ ├── unreal2.py │ ├── valve.py │ ├── quake3.py │ └── gamespy.py ├── providers │ ├── __init__.py │ ├── provider.py │ └── gamespy.py ├── __main__.py └── listers │ ├── __init__.py │ ├── gametools.py │ ├── medalofhonor.py │ ├── unreal2.py │ ├── valve.py │ ├── bfbc2.py │ ├── quake3.py │ ├── battlelog.py │ ├── gamespy.py │ └── common.py ├── .github ├── FUNDING.yml └── workflows │ ├── publish.yml │ └── ci.yml ├── setup.py ├── pyproject.toml ├── requirements.txt ├── renovate.json ├── LICENSE.md ├── tests └── helpers.py ├── setup.cfg ├── .gitignore └── README.md /GameserverLister/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /GameserverLister/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /GameserverLister/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /GameserverLister/games/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ cetteup ] 2 | -------------------------------------------------------------------------------- /GameserverLister/commands/options/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /GameserverLister/games/gametools.py: -------------------------------------------------------------------------------- 1 | GAMETOOLS_BASE_URI = 'https://api.gametools.network' 2 | -------------------------------------------------------------------------------- /GameserverLister/common/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger('GameserverLister') 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools==80.9.0", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | gevent==25.9.1 2 | nslookup==1.8.1 3 | requests[socks]==2.32.5 4 | pyq3serverlist==0.4.0 5 | pyut2serverlist==0.2.0 6 | pyvpsq==0.1.2 7 | click==8.3.1 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":semanticCommits", 6 | ":pinVersions" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /GameserverLister/commands/options/name.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | add = click.option( 4 | '--add-name', 5 | default=False, 6 | is_flag=True, 7 | help='(Attempt to) add the (host-)name for each server' 8 | ) 9 | -------------------------------------------------------------------------------- /GameserverLister/commands/options/gameport.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | add = click.option( 4 | '--add-game-port', 5 | default=False, 6 | is_flag=True, 7 | help='(Attempt to) add the game port for each server' 8 | ) 9 | -------------------------------------------------------------------------------- /GameserverLister/common/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime, timezone 3 | 4 | ROOT_DIR = rootDir = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..') 5 | UNIX_EPOCH_START = datetime(1970, 1, 1, tzinfo=timezone.utc) 6 | -------------------------------------------------------------------------------- /GameserverLister/providers/__init__.py: -------------------------------------------------------------------------------- 1 | from .gamespy import GamespyProvider, GamespyListProtocolProvider, CrympAPIProvider 2 | from .provider import Provider 3 | 4 | __all__ = [ 5 | 'Provider', 6 | 'GamespyProvider', 7 | 'GamespyListProtocolProvider', 8 | 'CrympAPIProvider' 9 | ] 10 | 11 | -------------------------------------------------------------------------------- /GameserverLister/providers/provider.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List 3 | 4 | from GameserverLister.common.servers import Server 5 | from GameserverLister.common.types import Game, Principal, Platform 6 | 7 | 8 | class Provider(ABC): 9 | @abstractmethod 10 | def list(self, principal: Principal, game: Game, platform: Platform, **kwargs) -> List[Server]: 11 | pass 12 | -------------------------------------------------------------------------------- /GameserverLister/games/battlelog.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from GameserverLister.common.types import BattlelogGame 4 | 5 | BATTLELOG_GAME_BASE_URIS: Dict[BattlelogGame, str] = { 6 | BattlelogGame.BF3: 'https://battlelog.battlefield.com/bf3/servers/getAutoBrowseServers/', 7 | BattlelogGame.BF4: 'https://battlelog.battlefield.com/bf4/servers/getServers', 8 | BattlelogGame.BFH: 'https://battlelog.battlefield.com/bfh/servers/getServers', 9 | } 10 | -------------------------------------------------------------------------------- /GameserverLister/commands/options/queryport.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | find = click.option( 4 | '--find-query-port', 5 | default=False, 6 | is_flag=True, 7 | help='(Attempt to) find the query port for each server' 8 | ) 9 | gamedig_bin = click.option( 10 | '--gamedig-bin', 11 | type=str, 12 | default='/usr/bin/gamedig', 13 | help='Path to gamedig binary' 14 | ) 15 | gamedig_concurrency = click.option( 16 | '--gamedig-concurrency', 17 | type=int, 18 | default=12, 19 | help='Number of gamedig queries to run in parallel' 20 | ) 21 | -------------------------------------------------------------------------------- /GameserverLister/__main__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from GameserverLister.commands import gamespy, battlelog, unreal2, valve, quake3, gametools, bfbc2, medalofhonor 4 | 5 | 6 | @click.group() 7 | def cli(): 8 | pass 9 | 10 | 11 | cli.add_command(battlelog.run, 'battlelog') 12 | cli.add_command(bfbc2.run, 'bfbc2') 13 | cli.add_command(gamespy.run, 'gamespy') 14 | cli.add_command(gametools.run, 'gametools') 15 | cli.add_command(medalofhonor.run, 'medalofhonor') 16 | cli.add_command(quake3.run, 'quake3') 17 | cli.add_command(unreal2.run, 'unreal2') 18 | cli.add_command(valve.run, 'valve') 19 | 20 | if __name__ == '__main__': 21 | cli() 22 | -------------------------------------------------------------------------------- /GameserverLister/listers/__init__.py: -------------------------------------------------------------------------------- 1 | from .battlelog import BattlelogServerLister 2 | from .bfbc2 import BadCompany2ServerLister 3 | from .gamespy import GamespyServerLister 4 | from .gametools import GametoolsServerLister 5 | from .medalofhonor import MedalOfHonorServerLister 6 | from .quake3 import Quake3ServerLister 7 | from .unreal2 import Unreal2ServerLister 8 | from .valve import ValveServerLister 9 | 10 | __all__ = [ 11 | 'BadCompany2ServerLister', 12 | 'BattlelogServerLister', 13 | 'GamespyServerLister', 14 | 'GametoolsServerLister', 15 | 'MedalOfHonorServerLister', 16 | 'Quake3ServerLister', 17 | 'Unreal2ServerLister', 18 | 'ValveServerLister' 19 | ] 20 | -------------------------------------------------------------------------------- /GameserverLister/commands/options/http.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | page_limit = click.option( 4 | '-p', 5 | '--page-limit', 6 | type=int, 7 | default=10, 8 | help='Number of pages to get after retrieving the last unique server' 9 | ) 10 | sleep = click.option( 11 | '--sleep', 12 | type=float, 13 | default=0, 14 | help='Number of seconds to sleep between requests' 15 | ) 16 | max_attempts = click.option( 17 | '--max-attempts', 18 | type=int, 19 | default=3, 20 | help='Max number of attempts for fetching a page of servers' 21 | ) 22 | proxy = click.option( 23 | '--proxy', 24 | type=str, 25 | help='Proxy to use for requests (format: [protocol]://[username]:[password]@[hostname]:[port]' 26 | ) 27 | -------------------------------------------------------------------------------- /GameserverLister/games/unreal2.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from GameserverLister.common.types import Unreal2Game 4 | 5 | UNREAL2_CONFIGS: Dict[Unreal2Game, dict] = { 6 | Unreal2Game.UT2003: { 7 | 'servers': { 8 | 'openspy.net': { 9 | 'hostname': 'utmaster.openspy.net', 10 | 'port': 28902 11 | } 12 | } 13 | }, 14 | Unreal2Game.UT2004: { 15 | 'servers': { 16 | 'openspy.net': { 17 | 'hostname': 'utmaster.openspy.net', 18 | 'port': 28902 19 | }, 20 | '333networks.com': { 21 | 'hostname': 'ut2004master.333networks.com', 22 | 'port': 28902 23 | }, 24 | 'errorist.eu': { 25 | 'hostname': 'ut2004master.errorist.eu', 26 | 'port': 28902 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-n-publish: 9 | name: Build and publish Python 🐍 distributions 📦 to PyPI 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@master 13 | - name: Set up Python 14 | uses: actions/setup-python@v6 15 | with: 16 | python-version: "3.13.3" 17 | - name: Install pypa/build 18 | run: >- 19 | python -m 20 | pip install 21 | build 22 | --user 23 | - name: Build a binary wheel and a source tarball 24 | run: >- 25 | python -m 26 | build 27 | --sdist 28 | --wheel 29 | --outdir dist/ 30 | - name: Publish distribution 📦 to PyPI 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | with: 33 | password: ${{ secrets.PYPI_API_TOKEN }} 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2020 cetteup 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from GameserverLister.common.helpers import is_valid_public_ip, is_valid_port, guid_from_ip_port 4 | 5 | 6 | class IsValidPublicIPTest(unittest.TestCase): 7 | def test_public_valid(self): 8 | self.assertTrue(is_valid_public_ip('1.1.1.1')) 9 | 10 | def test_localhost(self): 11 | self.assertFalse(is_valid_public_ip('127.0.0.1')) 12 | 13 | def test_private(self): 14 | self.assertFalse(is_valid_public_ip('192.168.1.1')) 15 | 16 | def test_link_local(self): 17 | self.assertFalse(is_valid_public_ip('169.254.1.1')) 18 | 19 | def test_invalid(self): 20 | self.assertFalse(is_valid_public_ip('not-an-ip-address')) 21 | 22 | 23 | class IsValidPortTest(unittest.TestCase): 24 | def test_valid(self): 25 | self.assertTrue(is_valid_port(443)) 26 | 27 | def test_low(self): 28 | self.assertFalse(is_valid_port(0)) 29 | 30 | def test_high(self): 31 | self.assertFalse(is_valid_port(65536)) 32 | 33 | 34 | class GuidTest(unittest.TestCase): 35 | def test_guid_from_ip_port(self): 36 | actual = guid_from_ip_port('1.1.1.1', '443') 37 | self.assertEqual('1f2-1f2-1f2-1f2', actual) 38 | 39 | 40 | if __name__ == '__main__': 41 | unittest.main() 42 | -------------------------------------------------------------------------------- /GameserverLister/commands/options/common.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | expire = click.option( 4 | '--no-expire', 5 | 'expire', 6 | default=True, 7 | is_flag=True, 8 | help='Keep servers in list, even after they disappeared from the source principal(s)' 9 | ) 10 | expired_ttl = click.option( 11 | '-e', 12 | '--expired-ttl', 13 | type=float, 14 | default=12.0, 15 | help='How long to keep a server in list after it was last seen (in hours)' 16 | ) 17 | recover = click.option( 18 | '--no-recover', 19 | 'recover', 20 | default=True, 21 | is_flag=True, 22 | help='Remove servers that were not returned by the source after they expired, ' 23 | 'do not attempt to contact/access server directly to check if they are still online' 24 | ) 25 | add_links = click.option( 26 | '--add-links', 27 | default=False, 28 | is_flag=True, 29 | help='Enrich server list entries with links to websites showing more details about the server' 30 | ) 31 | list_dir = click.option( 32 | '-d', 33 | '--list-dir', 34 | type=str, 35 | default='lists', 36 | help='Path to directory in which servers lists will be stored' 37 | ) 38 | txt = click.option( 39 | '--txt', 40 | default=False, 41 | is_flag=True, 42 | help='Additionally output plain text server list in format "[ip] [game port] [[query port]]\n"' 43 | ) 44 | debug = click.option( 45 | '--debug', 46 | default=False, 47 | is_flag=True, 48 | help='Log lots of debugging information', 49 | ) 50 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = GameserverLister 3 | version = 2.2.1 4 | description = Python command line tool to retrieve game server lists for various games 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/cetteup/GameserverLister 8 | project_urls = 9 | Bug Tracker = https://github.com/cetteup/GameserverLister/issues 10 | author = cetteup 11 | author_email = me@cetteup.com 12 | license = MIT 13 | classifiers = 14 | Development Status :: 5 - Production/Stable 15 | Environment :: Console 16 | Intended Audience :: Developers 17 | Intended Audience :: Other Audience 18 | Intended Audience :: System Administrators 19 | License :: OSI Approved :: MIT License 20 | Natural Language :: English 21 | Operating System :: Microsoft :: Windows 22 | Operating System :: POSIX :: Linux 23 | Programming Language :: Python :: 3 24 | Programming Language :: Python :: 3 :: Only 25 | Programming Language :: Python :: 3.10 26 | Programming Language :: Python :: 3.11 27 | Programming Language :: Python :: 3.12 28 | Programming Language :: Python :: 3.13 29 | Topic :: Games/Entertainment 30 | Topic :: Internet 31 | 32 | [options] 33 | packages = find: 34 | python_requires = >=3.10 35 | install_requires = 36 | gevent==25.9.1 37 | nslookup==1.8.1 38 | requests[socks]==2.32.5 39 | pyq3serverlist==0.4.0 40 | pyut2serverlist==0.2.0 41 | pyvpsq==0.1.2 42 | click==8.3.1 43 | 44 | [options.packages.find] 45 | include = 46 | GameserverLister 47 | GameserverLister.* 48 | 49 | [options.entry_points] 50 | console_scripts = 51 | gameserverlister = GameserverLister.__main__:cli 52 | -------------------------------------------------------------------------------- /GameserverLister/commands/medalofhonor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import click 5 | 6 | from GameserverLister.commands.options import common 7 | from GameserverLister.common.logger import logger 8 | from GameserverLister.common.types import MedalOfHonorGame 9 | from GameserverLister.listers import MedalOfHonorServerLister 10 | 11 | 12 | @click.command 13 | @click.option( 14 | '-g', 15 | '--game', 16 | type=click.Choice(MedalOfHonorGame), 17 | required=True, 18 | help='Game to list servers for' 19 | ) 20 | @common.expire 21 | @common.expired_ttl 22 | @common.list_dir 23 | @common.recover 24 | @common.add_links 25 | @common.txt 26 | @common.debug 27 | def run( 28 | game: MedalOfHonorGame, 29 | expire: bool, 30 | expired_ttl: int, 31 | recover: bool, 32 | add_links: bool, 33 | txt: bool, 34 | list_dir: str, 35 | debug: bool 36 | ): 37 | logging.basicConfig(level=logging.DEBUG if debug else logging.INFO, stream=sys.stdout, 38 | format='%(asctime)s %(levelname)-8s %(message)s') 39 | 40 | logger.info(f'Listing servers for {game} via mohaaservers.tk') 41 | 42 | lister = MedalOfHonorServerLister( 43 | game, 44 | expire, 45 | expired_ttl, 46 | recover, 47 | add_links, 48 | txt, 49 | list_dir 50 | ) 51 | 52 | before = len(lister.servers) 53 | lister.update_server_list() 54 | 55 | removed, recovered = lister.remove_expired_servers() 56 | lister.write_to_file() 57 | 58 | logger.info(f'Server list updated (' 59 | f'total: {len(lister.servers)}, ' 60 | f'added: {len(lister.servers) + removed - before}, ' 61 | f'removed: {removed}, ' 62 | f'recovered: {recovered})') 63 | -------------------------------------------------------------------------------- /GameserverLister/commands/bfbc2.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import click 5 | 6 | from GameserverLister.commands.options import common, queryport 7 | from GameserverLister.common.logger import logger 8 | from GameserverLister.listers import BadCompany2ServerLister 9 | 10 | 11 | @click.command 12 | @click.option( 13 | '-t', 14 | '--timeout', 15 | type=int, 16 | default=10, 17 | help='Timeout to use for server list retrieval request' 18 | ) 19 | @queryport.find 20 | @queryport.gamedig_bin 21 | @queryport.gamedig_concurrency 22 | @common.expire 23 | @common.expired_ttl 24 | @common.list_dir 25 | @common.recover 26 | @common.add_links 27 | @common.txt 28 | @common.debug 29 | def run( 30 | timeout: int, 31 | find_query_port: bool, 32 | gamedig_bin: str, 33 | gamedig_concurrency: int, 34 | expire: bool, 35 | expired_ttl: int, 36 | recover: bool, 37 | add_links: bool, 38 | txt: bool, 39 | list_dir: str, 40 | debug: bool 41 | ): 42 | logging.basicConfig(level=logging.DEBUG if debug else logging.INFO, stream=sys.stdout, 43 | format='%(asctime)s %(levelname)-8s %(message)s') 44 | 45 | logger.info('Listing servers for bfbc2 via fesl.cetteup.com') 46 | 47 | lister = BadCompany2ServerLister( 48 | expire, 49 | expired_ttl, 50 | recover, 51 | add_links, 52 | txt, 53 | list_dir, 54 | timeout 55 | ) 56 | 57 | before = len(lister.servers) 58 | lister.update_server_list() 59 | 60 | if find_query_port: 61 | lister.find_query_ports(gamedig_bin, gamedig_concurrency, expired_ttl) 62 | 63 | removed, recovered = lister.remove_expired_servers() 64 | lister.write_to_file() 65 | 66 | logger.info(f'Server list updated (' 67 | f'total: {len(lister.servers)}, ' 68 | f'added: {len(lister.servers) + removed - before}, ' 69 | f'removed: {removed}, ' 70 | f'recovered: {recovered})') 71 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [ "main", "wip/package-structure" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | pull-requests: read 12 | 13 | jobs: 14 | lint: 15 | strategy: 16 | matrix: 17 | python-version: [ "3.10", "3.14" ] 18 | os: [ ubuntu-latest ] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v6 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v6 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | cache: 'pip' # caching pip dependencies 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install flake8 33 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 34 | 35 | - name: Install package/self 36 | run: | 37 | pip install -e . 38 | 39 | - name: Lint with flake8 40 | run: | 41 | # stop if there are Python syntax errors or undefined names 42 | flake8 GameserverLister/** tests/** --count --select=E9,F63,F7,F82 --show-source --statistics 43 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 44 | flake8 GameserverLister/** tests/** --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 45 | 46 | unit-test: 47 | strategy: 48 | matrix: 49 | python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14" ] 50 | os: [ ubuntu-latest ] 51 | runs-on: ${{ matrix.os }} 52 | steps: 53 | - uses: actions/checkout@v6 54 | 55 | - name: Set up Python 56 | uses: actions/setup-python@v6 57 | with: 58 | python-version: ${{ matrix.python-version }} 59 | cache: 'pip' # caching pip dependencies 60 | 61 | - name: Install dependencies 62 | run: | 63 | python -m pip install --upgrade pip 64 | pip install pytest 65 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 66 | 67 | - name: Install package/self 68 | run: | 69 | pip install -e . 70 | 71 | - name: Test with pytest 72 | run: | 73 | pytest tests/** 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | .idea/ 127 | 128 | # Game server list files 129 | lists/ 130 | -------------------------------------------------------------------------------- /GameserverLister/commands/quake3.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import click 5 | 6 | from GameserverLister.commands.options import common 7 | from GameserverLister.common.logger import logger 8 | from GameserverLister.common.types import Quake3Game 9 | from GameserverLister.games.quake3 import QUAKE3_CONFIGS 10 | from GameserverLister.listers import Quake3ServerLister 11 | 12 | 13 | @click.command 14 | @click.option( 15 | '-g', 16 | '--game', 17 | type=click.Choice(Quake3Game), 18 | required=True, 19 | help='Game to list servers for' 20 | ) 21 | @click.option( 22 | '-p', 23 | '--principal', 24 | type=click.Choice([p for g in QUAKE3_CONFIGS for p in QUAKE3_CONFIGS[g]['servers'].keys()]), 25 | required=True, 26 | help='Principal server to query' 27 | ) 28 | @common.expire 29 | @common.expired_ttl 30 | @common.list_dir 31 | @common.recover 32 | @common.add_links 33 | @common.txt 34 | @common.debug 35 | def run( 36 | game: Quake3Game, 37 | principal: str, 38 | expire: bool, 39 | expired_ttl: int, 40 | recover: bool, 41 | add_links: bool, 42 | txt: bool, 43 | list_dir: str, 44 | debug: bool 45 | ): 46 | logging.basicConfig(level=logging.DEBUG if debug else logging.INFO, stream=sys.stdout, 47 | format='%(asctime)s %(levelname)-8s %(message)s') 48 | 49 | # Set principal 50 | available_principals = list(QUAKE3_CONFIGS[game]['servers'].keys()) 51 | if principal.lower() not in available_principals: 52 | # Given principal is invalid => use default principal 53 | logging.warning( 54 | f'Principal {principal} is not available for {game}, ' 55 | f'defaulting to {available_principals[0]} instead' 56 | ) 57 | principal = available_principals[0] 58 | 59 | logger.info(f'Listing servers for {game} via quake3/{principal}') 60 | 61 | lister = Quake3ServerLister( 62 | game, 63 | principal, 64 | expire, 65 | expired_ttl, 66 | recover, 67 | add_links, 68 | txt, 69 | list_dir 70 | ) 71 | 72 | before = len(lister.servers) 73 | lister.update_server_list() 74 | removed, recovered = lister.remove_expired_servers() 75 | lister.write_to_file() 76 | 77 | logger.info(f'Server list updated (' 78 | f'total: {len(lister.servers)}, ' 79 | f'added: {len(lister.servers) + removed - before}, ' 80 | f'removed: {removed}, ' 81 | f'recovered: {recovered})') 82 | -------------------------------------------------------------------------------- /GameserverLister/commands/gametools.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import click 5 | 6 | from GameserverLister.commands.options import common, http 7 | from GameserverLister.common.logger import logger 8 | from GameserverLister.common.types import GametoolsGame, GametoolsPlatform 9 | from GameserverLister.listers import GametoolsServerLister 10 | 11 | 12 | @click.command 13 | @click.option( 14 | '-g', 15 | '--game', 16 | type=click.Choice(GametoolsGame), 17 | required=True, 18 | help='Game to list servers for' 19 | ) 20 | @click.option( 21 | '-pf', 22 | '--platform', 23 | type=click.Choice(GametoolsPlatform), 24 | required=True, 25 | help='Platform to list servers for', 26 | default=GametoolsPlatform.PC 27 | ) 28 | @click.option( 29 | '--include-official', 30 | default=False, 31 | is_flag=True, 32 | help='Include DICE official servers in list (not recommended due to auto scaling official servers)' 33 | ) 34 | @http.page_limit 35 | @http.sleep 36 | @http.max_attempts 37 | @common.expire 38 | @common.expired_ttl 39 | @common.list_dir 40 | @common.recover 41 | @common.add_links 42 | @common.txt 43 | @common.debug 44 | def run( 45 | game: GametoolsGame, 46 | platform: GametoolsPlatform, 47 | page_limit: int, 48 | sleep: float, 49 | max_attempts: int, 50 | include_official: bool, 51 | expire: bool, 52 | expired_ttl: int, 53 | recover: bool, 54 | add_links: bool, 55 | txt: bool, 56 | list_dir: str, 57 | debug: bool 58 | ): 59 | logging.basicConfig(level=logging.DEBUG if debug else logging.INFO, stream=sys.stdout, 60 | format='%(asctime)s %(levelname)-8s %(message)s') 61 | 62 | logger.info(f'Listing servers for {game} via gametools') 63 | 64 | lister = GametoolsServerLister( 65 | game, 66 | platform, 67 | page_limit, 68 | expire, 69 | expired_ttl, 70 | recover, 71 | add_links, 72 | txt, 73 | list_dir, 74 | sleep, 75 | max_attempts, 76 | include_official 77 | ) 78 | 79 | before = len(lister.servers) 80 | lister.update_server_list() 81 | 82 | removed, recovered = lister.remove_expired_servers() 83 | lister.write_to_file() 84 | 85 | logger.info(f'Server list updated (' 86 | f'total: {len(lister.servers)}, ' 87 | f'added: {len(lister.servers) + removed - before}, ' 88 | f'removed: {removed}, ' 89 | f'recovered: {recovered})') 90 | -------------------------------------------------------------------------------- /GameserverLister/commands/unreal2.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import click 5 | 6 | from GameserverLister.commands.options import common 7 | from GameserverLister.common.logger import logger 8 | from GameserverLister.common.types import Unreal2Game 9 | from GameserverLister.games.unreal2 import UNREAL2_CONFIGS 10 | from GameserverLister.listers import Unreal2ServerLister 11 | 12 | 13 | @click.command 14 | @click.option( 15 | '-g', 16 | '--game', 17 | type=click.Choice(Unreal2Game), 18 | required=True, 19 | help='Game to list servers for' 20 | ) 21 | @click.option( 22 | '-p', 23 | '--principal', 24 | type=click.Choice([p for g in UNREAL2_CONFIGS for p in UNREAL2_CONFIGS[g]['servers'].keys()]), 25 | required=True, 26 | help='Principal server to query' 27 | ) 28 | @click.option( 29 | '-c', 30 | '--cd-key', 31 | type=str, 32 | required=True, 33 | help='CD key for game' 34 | ) 35 | @click.option( 36 | '-t', 37 | '--timeout', 38 | type=int, 39 | default=5, 40 | help='Timeout to use for principal query' 41 | ) 42 | @common.expire 43 | @common.expired_ttl 44 | @common.list_dir 45 | @common.recover 46 | @common.add_links 47 | @common.txt 48 | @common.debug 49 | def run( 50 | game: Unreal2Game, 51 | principal: str, 52 | cd_key: str, 53 | timeout: int, 54 | expire: bool, 55 | expired_ttl: int, 56 | recover: bool, 57 | add_links: bool, 58 | txt: bool, 59 | list_dir: str, 60 | debug: bool 61 | ): 62 | logging.basicConfig(level=logging.DEBUG if debug else logging.INFO, stream=sys.stdout, 63 | format='%(asctime)s %(levelname)-8s %(message)s') 64 | 65 | # Set principal 66 | available_principals = list(UNREAL2_CONFIGS[game]['servers'].keys()) 67 | if principal.lower() not in available_principals: 68 | # Given principal is invalid => use default principal 69 | logging.warning( 70 | f'Principal {principal} is not available for {game}, ' 71 | f'defaulting to {available_principals[0]} instead' 72 | ) 73 | principal = available_principals[0] 74 | 75 | logger.info(f'Listing servers for {game} via unreal2/{principal}') 76 | 77 | lister = Unreal2ServerLister( 78 | game, 79 | principal, 80 | cd_key, 81 | timeout, 82 | expire, 83 | expired_ttl, 84 | recover, 85 | add_links, 86 | txt, 87 | list_dir 88 | ) 89 | 90 | before = len(lister.servers) 91 | lister.update_server_list() 92 | removed, recovered = lister.remove_expired_servers() 93 | lister.write_to_file() 94 | 95 | logger.info(f'Server list updated (' 96 | f'total: {len(lister.servers)}, ' 97 | f'added: {len(lister.servers) + removed - before}, ' 98 | f'removed: {removed}, ' 99 | f'recovered: {recovered})') 100 | -------------------------------------------------------------------------------- /GameserverLister/commands/battlelog.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from typing import Optional 4 | 5 | import click 6 | 7 | from GameserverLister.commands.options import common, http, queryport 8 | from GameserverLister.common.logger import logger 9 | from GameserverLister.common.types import BattlelogGame, BattlelogPlatform 10 | from GameserverLister.listers import BattlelogServerLister 11 | 12 | 13 | @click.command 14 | @click.option( 15 | '-g', 16 | '--game', 17 | type=click.Choice(BattlelogGame), 18 | required=True, 19 | help='Game to list servers for' 20 | ) 21 | @click.option( 22 | '-pf', 23 | '--platform', 24 | type=click.Choice(BattlelogPlatform), 25 | required=True, 26 | help='Platform to list servers for', 27 | default=BattlelogPlatform.PC 28 | ) 29 | @http.page_limit 30 | @http.sleep 31 | @http.max_attempts 32 | @http.proxy 33 | @queryport.find 34 | @queryport.gamedig_bin 35 | @queryport.gamedig_concurrency 36 | @common.expire 37 | @common.expired_ttl 38 | @common.list_dir 39 | @common.recover 40 | @common.add_links 41 | @common.txt 42 | @common.debug 43 | def run( 44 | game: BattlelogGame, 45 | platform: BattlelogPlatform, 46 | page_limit: int, 47 | sleep: float, 48 | max_attempts: int, 49 | proxy: Optional[str], 50 | find_query_port: bool, 51 | gamedig_bin: str, 52 | gamedig_concurrency: int, 53 | expire: bool, 54 | expired_ttl: int, 55 | recover: bool, 56 | add_links: bool, 57 | txt: bool, 58 | list_dir: str, 59 | debug: bool 60 | ): 61 | logging.basicConfig(level=logging.DEBUG if debug else logging.INFO, stream=sys.stdout, 62 | format='%(asctime)s %(levelname)-8s %(message)s') 63 | 64 | if game is BattlelogGame.BF3 and platform is not BattlelogPlatform.PC: 65 | logger.warning(f'Platform {platform} is not available for {game}, defaulting to {BattlelogPlatform.PC} instead') 66 | platform = BattlelogPlatform.PC 67 | 68 | logger.info(f'Listing servers for {game} on {platform} via battlelog') 69 | lister = BattlelogServerLister( 70 | game, 71 | platform, 72 | page_limit, 73 | expire, 74 | expired_ttl, 75 | recover, 76 | add_links, 77 | txt, 78 | list_dir, 79 | sleep, 80 | max_attempts, 81 | proxy 82 | ) 83 | 84 | 85 | 86 | before = len(lister.servers) 87 | lister.update_server_list() 88 | 89 | if find_query_port: 90 | lister.find_query_ports(gamedig_bin, gamedig_concurrency, expired_ttl) 91 | 92 | removed, recovered = lister.remove_expired_servers() 93 | lister.write_to_file() 94 | 95 | logger.info(f'Server list updated (' 96 | f'total: {len(lister.servers)}, ' 97 | f'added: {len(lister.servers) + removed - before}, ' 98 | f'removed: {removed}, ' 99 | f'recovered: {recovered})') 100 | -------------------------------------------------------------------------------- /GameserverLister/commands/valve.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import click 5 | 6 | from GameserverLister.commands.options import common, gameport 7 | from GameserverLister.common.logger import logger 8 | from GameserverLister.common.types import ValveGame, ValvePrincipal 9 | from GameserverLister.games.valve import VALVE_GAME_CONFIGS 10 | from GameserverLister.listers import ValveServerLister 11 | 12 | 13 | @click.command 14 | @click.option( 15 | '-g', 16 | '--game', 17 | type=click.Choice(ValveGame), 18 | required=True, 19 | help='Game to list servers for' 20 | ) 21 | @click.option( 22 | '-p', 23 | '--principal', 24 | type=click.Choice(ValvePrincipal), 25 | default=ValvePrincipal.VALVE, 26 | help='Principal server to query' 27 | ) 28 | @click.option( 29 | '-f', 30 | '--filter', 31 | 'filters', 32 | type=str, 33 | default='', 34 | help='Filter to apply to server list' 35 | ) 36 | @click.option( 37 | '-t', 38 | '--timeout', 39 | type=int, 40 | default=5, 41 | help='Timeout to use for principal query' 42 | ) 43 | @click.option( 44 | '-m', 45 | '--max-pages', 46 | type=int, 47 | default=10, 48 | help='Maximum number of pages to retrieve from the server list (per region)' 49 | ) 50 | @gameport.add 51 | @common.expire 52 | @common.expired_ttl 53 | @common.list_dir 54 | @common.recover 55 | @common.add_links 56 | @common.txt 57 | @common.debug 58 | def run( 59 | game: ValveGame, 60 | principal: str, 61 | filters: str, 62 | timeout: int, 63 | max_pages: int, 64 | add_game_port: bool, 65 | expire: bool, 66 | expired_ttl: int, 67 | recover: bool, 68 | add_links: bool, 69 | txt: bool, 70 | list_dir: str, 71 | debug: bool 72 | ): 73 | logging.basicConfig(level=logging.DEBUG if debug else logging.INFO, stream=sys.stdout, 74 | format='%(asctime)s %(levelname)-8s %(message)s') 75 | 76 | # Set principal 77 | available_principals = VALVE_GAME_CONFIGS[game].principals 78 | if principal not in VALVE_GAME_CONFIGS[game].principals: 79 | # Given principal is invalid => use default principal 80 | logging.warning(f'Principal {principal} is not available for {game}, ' 81 | f'defaulting to {available_principals[0]} instead') 82 | principal = available_principals[0] 83 | 84 | logger.info(f'Listing servers for {game} via valve/{principal}') 85 | 86 | lister = ValveServerLister( 87 | game, 88 | principal, 89 | timeout, 90 | filters, 91 | max_pages, 92 | add_game_port, 93 | expire, 94 | expired_ttl, 95 | recover, 96 | add_links, 97 | txt, 98 | list_dir 99 | ) 100 | 101 | before = len(lister.servers) 102 | lister.update_server_list() 103 | removed, recovered = lister.remove_expired_servers() 104 | lister.write_to_file() 105 | 106 | logger.info(f'Server list updated (' 107 | f'total: {len(lister.servers)}, ' 108 | f'added: {len(lister.servers) + removed - before}, ' 109 | f'removed: {removed}, ' 110 | f'recovered: {recovered})') 111 | -------------------------------------------------------------------------------- /GameserverLister/commands/gamespy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import click 5 | 6 | from GameserverLister.commands.options import common, gameport 7 | from GameserverLister.common.logger import logger 8 | from GameserverLister.common.types import GamespyGame, GamespyPrincipal 9 | from GameserverLister.games.gamespy import GAMESPY_GAME_CONFIGS 10 | from GameserverLister.listers import GamespyServerLister 11 | from GameserverLister.providers import GamespyListProtocolProvider, CrympAPIProvider 12 | 13 | 14 | @click.command 15 | @click.option( 16 | '-g', 17 | '--game', 18 | type=click.Choice(GamespyGame), 19 | required=True, 20 | help='Game to list servers for' 21 | ) 22 | @click.option( 23 | '-p', 24 | '--principal', 25 | type=click.Choice(GamespyPrincipal), 26 | required=True, 27 | help='Principal server to query' 28 | ) 29 | @click.option( 30 | '-b', 31 | '--gslist', 32 | 'gslist_path', 33 | type=str, 34 | default='gslist', 35 | help='Path to gslist binary' 36 | ) 37 | @click.option( 38 | '-f', 39 | '--filter', 40 | 'gslist_filter', 41 | type=str, 42 | default='', 43 | help='Filter to apply to server list' 44 | ) 45 | @click.option( 46 | '-s', 47 | '--super-query', 48 | 'gslist_super_query', 49 | default=False, 50 | is_flag=True, 51 | help='Query each server in the list for it\'s status' 52 | ) 53 | @click.option( 54 | '-t', 55 | '--timeout', 56 | 'gslist_timeout', 57 | type=int, 58 | default=10, 59 | help='Timeout to use for gslist command' 60 | ) 61 | @click.option( 62 | '-v', 63 | '--verify', 64 | default=False, 65 | is_flag=True, 66 | help='(Attempt to) verify game servers returned by principal are game servers for the current game' 67 | ) 68 | @gameport.add 69 | @common.expire 70 | @common.expired_ttl 71 | @common.list_dir 72 | @common.recover 73 | @common.add_links 74 | @common.txt 75 | @common.debug 76 | def run( 77 | game: GamespyGame, 78 | principal: GamespyPrincipal, 79 | gslist_path: str, 80 | gslist_filter: str, 81 | gslist_super_query: bool, 82 | gslist_timeout: int, 83 | verify: bool, 84 | add_game_port: bool, 85 | expire: bool, 86 | expired_ttl: int, 87 | recover: bool, 88 | add_links: bool, 89 | txt: bool, 90 | list_dir: str, 91 | debug: bool 92 | ): 93 | logging.basicConfig(level=logging.DEBUG if debug else logging.INFO, stream=sys.stdout, 94 | format='%(asctime)s %(levelname)-8s %(message)s') 95 | 96 | # Set principal 97 | available_principals = GAMESPY_GAME_CONFIGS[game].principals 98 | if principal not in GAMESPY_GAME_CONFIGS[game].principals: 99 | # Given principal is invalid => use default principal 100 | logging.warning(f'Principal {principal} is not available for {game}, ' 101 | f'defaulting to {available_principals[0]} instead') 102 | principal = available_principals[0] 103 | 104 | logger.info(f'Listing servers for {game} via gamespy/{principal}') 105 | 106 | # Determine which provider to use 107 | if principal is GamespyPrincipal.Crymp_org: 108 | provider = CrympAPIProvider() 109 | else: 110 | provider = GamespyListProtocolProvider(gslist_path) 111 | 112 | lister = GamespyServerLister( 113 | game, 114 | principal, 115 | provider, 116 | gslist_path, 117 | gslist_filter, 118 | gslist_super_query, 119 | gslist_timeout, 120 | verify, 121 | add_game_port, 122 | expire, 123 | expired_ttl, 124 | recover, 125 | add_links, 126 | txt, 127 | list_dir 128 | ) 129 | 130 | before = len(lister.servers) 131 | 132 | try: 133 | lister.update_server_list() 134 | except Exception as e: 135 | logging.critical(f'Failed to update server list: {e}') 136 | sys.exit(1) 137 | 138 | removed, recovered = lister.remove_expired_servers() 139 | lister.write_to_file() 140 | 141 | logger.info(f'Server list updated (' 142 | f'total: {len(lister.servers)}, ' 143 | f'added: {len(lister.servers) + removed - before}, ' 144 | f'removed: {removed}, ' 145 | f'recovered: {recovered})') 146 | -------------------------------------------------------------------------------- /GameserverLister/games/valve.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from GameserverLister.common.types import ValvePrincipal, ValvePrincipalConfig, ValveGame, ValveGameConfig 4 | 5 | VALVE_PRINCIPAL_CONFIGS: Dict[ValvePrincipal, ValvePrincipalConfig] = { 6 | ValvePrincipal.VALVE: ValvePrincipalConfig( 7 | hostname='hl2master.steampowered.com', 8 | port=27011 9 | ) 10 | } 11 | VALVE_GAME_CONFIGS: Dict[ValveGame, ValveGameConfig] = { 12 | ValveGame.AmericasArmyProvingGrounds: ValveGameConfig( 13 | app_id=203290, 14 | principals=[ 15 | ValvePrincipal.VALVE 16 | ], 17 | distinct_query_port=True 18 | ), 19 | ValveGame.ARKSurvivalEvolved: ValveGameConfig( 20 | app_id=346110, 21 | principals=[ 22 | ValvePrincipal.VALVE 23 | ] 24 | ), 25 | ValveGame.Arma2: ValveGameConfig( 26 | app_id=33930, 27 | principals=[ 28 | ValvePrincipal.VALVE 29 | ], 30 | distinct_query_port=True 31 | ), 32 | ValveGame.Arma3: ValveGameConfig( 33 | app_id=107410, 34 | principals=[ 35 | ValvePrincipal.VALVE 36 | ], 37 | distinct_query_port=True 38 | ), 39 | ValveGame.CounterStrike: ValveGameConfig( 40 | app_id=10, 41 | principals=[ 42 | ValvePrincipal.VALVE 43 | ] 44 | ), 45 | ValveGame.CounterStrikeConditionZero: ValveGameConfig( 46 | app_id=80, 47 | principals=[ 48 | ValvePrincipal.VALVE 49 | ] 50 | ), 51 | ValveGame.CounterStrikeSource: ValveGameConfig( 52 | app_id=240, 53 | principals=[ 54 | ValvePrincipal.VALVE 55 | ] 56 | ), 57 | ValveGame.CounterStrikeGlobalOffensive: ValveGameConfig( 58 | app_id=730, 59 | principals=[ 60 | ValvePrincipal.VALVE 61 | ] 62 | ), 63 | ValveGame.DayZ: ValveGameConfig( 64 | app_id=221100, 65 | principals=[ 66 | ValvePrincipal.VALVE 67 | ], 68 | distinct_query_port=True 69 | ), 70 | ValveGame.DayZMod: ValveGameConfig( 71 | app_id=224580, 72 | principals=[ 73 | ValvePrincipal.VALVE 74 | ], 75 | distinct_query_port=True 76 | ), 77 | ValveGame.DoD: ValveGameConfig( 78 | app_id=30, 79 | principals=[ 80 | ValvePrincipal.VALVE 81 | ] 82 | ), 83 | ValveGame.DoDS: ValveGameConfig( 84 | app_id=300, 85 | principals=[ 86 | ValvePrincipal.VALVE 87 | ] 88 | ), 89 | ValveGame.GarrysMod: ValveGameConfig( 90 | app_id=4000, 91 | principals=[ 92 | ValvePrincipal.VALVE 93 | ] 94 | ), 95 | ValveGame.Insurgency: ValveGameConfig( 96 | app_id=222880, 97 | principals=[ 98 | ValvePrincipal.VALVE 99 | ] 100 | ), 101 | ValveGame.InsurgencySandstorm: ValveGameConfig( 102 | app_id=581320, 103 | principals=[ 104 | ValvePrincipal.VALVE 105 | ], 106 | distinct_query_port=True 107 | ), 108 | ValveGame.Left4Dead: ValveGameConfig( 109 | app_id=500, 110 | principals=[ 111 | ValvePrincipal.VALVE 112 | ] 113 | ), 114 | ValveGame.Left4Dead2: ValveGameConfig( 115 | app_id=550, 116 | principals=[ 117 | ValvePrincipal.VALVE 118 | ] 119 | ), 120 | ValveGame.RS2: ValveGameConfig( 121 | app_id=418460, 122 | principals=[ 123 | ValvePrincipal.VALVE 124 | ] 125 | ), 126 | ValveGame.Rust: ValveGameConfig( 127 | app_id=252490, 128 | principals=[ 129 | ValvePrincipal.VALVE 130 | ] 131 | ), 132 | ValveGame.SevenD2D: ValveGameConfig( 133 | app_id=251570, 134 | principals=[ 135 | ValvePrincipal.VALVE 136 | ] 137 | ), 138 | ValveGame.Squad: ValveGameConfig( 139 | app_id=393380, 140 | principals=[ 141 | ValvePrincipal.VALVE 142 | ], 143 | distinct_query_port=True 144 | ), 145 | ValveGame.TFC: ValveGameConfig( 146 | app_id=20, 147 | principals=[ 148 | ValvePrincipal.VALVE 149 | ] 150 | ), 151 | ValveGame.TF2: ValveGameConfig( 152 | app_id=440, 153 | principals=[ 154 | ValvePrincipal.VALVE 155 | ] 156 | ) 157 | } 158 | -------------------------------------------------------------------------------- /GameserverLister/common/weblinks.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta 3 | from typing import Dict, Optional 4 | 5 | from GameserverLister.common.constants import UNIX_EPOCH_START 6 | 7 | 8 | class WebLink: 9 | site: str 10 | url: str 11 | official: bool 12 | as_of: datetime 13 | 14 | def __init__(self, site: str, url: str, official: bool, as_of: datetime = datetime.now().astimezone()): 15 | self.site = site 16 | self.url = url 17 | self.official = official 18 | self.as_of = as_of 19 | 20 | def is_expired(self, expired_ttl: float) -> bool: 21 | return datetime.now().astimezone() > self.as_of + timedelta(hours=expired_ttl) 22 | 23 | def update(self, updated: 'WebLink') -> None: 24 | self.url = updated.url 25 | self.official = updated.official 26 | self.as_of = updated.as_of 27 | 28 | @staticmethod 29 | def load(parsed: dict) -> 'WebLink': 30 | as_of = datetime.fromisoformat(parsed['asOf']) \ 31 | if parsed.get('asOf') is not None else UNIX_EPOCH_START 32 | return WebLink( 33 | parsed['site'], 34 | parsed['url'], 35 | parsed['official'], 36 | as_of 37 | ) 38 | 39 | @staticmethod 40 | def is_json_repr(parsed: dict) -> bool: 41 | return 'site' in parsed and 'url' in parsed and 'official' in parsed 42 | 43 | def dump(self) -> dict: 44 | return { 45 | 'site': self.site, 46 | 'url': self.url, 47 | 'official': self.official, 48 | 'asOf': self.as_of.isoformat() 49 | } 50 | 51 | def __eq__(self, other): 52 | return isinstance(other, WebLink) and \ 53 | other.site == self.site and \ 54 | other.url == self.url and \ 55 | other.official == self.official and \ 56 | other.as_of == self.as_of 57 | 58 | def __iter__(self): 59 | yield from self.dump().items() 60 | 61 | def __str__(self): 62 | return json.dumps(dict(self)) 63 | 64 | def __repr__(self): 65 | return self.__str__() 66 | 67 | 68 | class WebLinkTemplate: 69 | site: str 70 | url_template: str 71 | official: bool 72 | 73 | def __init__(self, site: str, url_template: str, official: bool): 74 | self.site = site 75 | self.url_template = url_template 76 | self.official = official 77 | 78 | def render(self, game: str, platform: str, uid: str, ip: Optional[str] = None, port: Optional[int] = None) -> WebLink: 79 | return WebLink( 80 | self.site, 81 | self.url_template.format(game=game, platform=platform, uid=uid, ip=ip, port=port), 82 | self.official 83 | ) 84 | 85 | 86 | """ 87 | For URL templates: 88 | 0: game name/key 89 | 1: server uid 90 | 2: server ip 91 | 3: server port 92 | """ 93 | WEB_LINK_TEMPLATES: Dict[str, WebLinkTemplate] = { 94 | 'arena.sh': WebLinkTemplate( 95 | 'arena.sh', 96 | 'https://arena.sh/game/{ip}:{port}/', 97 | False 98 | ), 99 | 'battlelog': WebLinkTemplate( 100 | 'battlelog.com', 101 | 'https://battlelog.battlefield.com/{game}/servers/show/{platform}/{uid}', 102 | True 103 | ), 104 | 'b2bf2': WebLinkTemplate( 105 | 'b2bf2.net', 106 | 'https://b2bf2.net/server?sid={ip}:{port}', 107 | True 108 | ), 109 | 'bf2.tv': WebLinkTemplate( 110 | 'bf2.tv', 111 | 'https://bf2.tv/servers/{ip}:{port}', 112 | False 113 | ), 114 | 'bf2hub': WebLinkTemplate( 115 | 'bf2hub.com', 116 | 'https://www.bf2hub.com/server/{ip}:{port}/', 117 | True 118 | ), 119 | 'cod.pm': WebLinkTemplate( 120 | 'cod.pm', 121 | 'https://cod.pm/server/{ip}/{port}', 122 | False 123 | ), 124 | # deathmask.net shows servers from their own as well as other masters, 125 | # so they are not the official source for all servers 126 | 'deathmask.net-official': WebLinkTemplate( 127 | 'deathmask.net', 128 | 'https://dpmaster.deathmask.net/?game={game}&server={ip}:{port}', 129 | True 130 | ), 131 | 'deathmask.net-unofficial': WebLinkTemplate( 132 | 'deathmask.net', 133 | 'https://dpmaster.deathmask.net/?game={game}&server={ip}:{port}', 134 | False 135 | ), 136 | 'gametools': WebLinkTemplate( 137 | 'gametools.network', 138 | 'https://gametools.network/servers/{game}/gameid/{uid}/{platform}', 139 | False 140 | ), 141 | 'swat4stats.com': WebLinkTemplate( 142 | 'swat4stats.com', 143 | 'https://swat4stats.com/servers/{ip}:{port}/', 144 | False 145 | ) 146 | } 147 | -------------------------------------------------------------------------------- /GameserverLister/listers/gametools.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import List, Tuple, Optional, Union 4 | 5 | import requests 6 | 7 | from GameserverLister.common.servers import GametoolsServer 8 | from GameserverLister.common.types import GametoolsGame, GametoolsPlatform 9 | from GameserverLister.common.weblinks import WebLink, WEB_LINK_TEMPLATES 10 | from GameserverLister.games.gametools import GAMETOOLS_BASE_URI 11 | from .common import HttpServerLister 12 | 13 | 14 | class GametoolsServerLister(HttpServerLister): 15 | game: GametoolsGame 16 | platform: GametoolsPlatform 17 | include_official: bool 18 | 19 | def __init__( 20 | self, 21 | game: GametoolsGame, 22 | platform: GametoolsPlatform, 23 | page_limit: int, 24 | expire: bool, 25 | expired_ttl: float, 26 | recover: bool, 27 | add_links: bool, 28 | txt: bool, 29 | list_dir: str, 30 | sleep: float, 31 | max_attempts: int, 32 | include_official: bool 33 | ): 34 | super().__init__( 35 | game, 36 | platform, 37 | GametoolsServer, 38 | page_limit, 39 | 100, 40 | expire, 41 | expired_ttl, 42 | recover, 43 | add_links, 44 | txt, 45 | list_dir, 46 | sleep, 47 | max_attempts 48 | ) 49 | # Allow non-ascii characters in server list (mostly used by server names for Asia servers) 50 | self.ensure_ascii = False 51 | self.include_official = include_official 52 | 53 | def get_server_list_url(self, per_page: int) -> str: 54 | return f'{GAMETOOLS_BASE_URI}/{self.game}/servers/?platform={self.platform}®ion=all&name=&limit={per_page}' \ 55 | f'&nocache={datetime.now().timestamp()}' 56 | 57 | def add_page_found_servers(self, found_servers: List[GametoolsServer], page_response_data: dict) -> List[GametoolsServer]: 58 | for server in page_response_data['servers']: 59 | found_server = GametoolsServer( 60 | server['gameId'], 61 | server['prefix'], 62 | ) 63 | 64 | if self.add_links: 65 | found_server.add_links(self.build_server_links(found_server.uid)) 66 | 67 | # Add/update servers (ignoring official servers unless include_official is set) 68 | server_game_ids = [s.uid for s in found_servers] 69 | if found_server.uid not in server_game_ids and \ 70 | (not server['official'] or self.include_official): 71 | logging.debug(f'Got new server {found_server.uid}, adding it') 72 | found_servers.append(found_server) 73 | elif not server['official'] or self.include_official: 74 | logging.debug(f'Got duplicate server {found_server.uid}, updating last seen at') 75 | index = server_game_ids.index(found_server.uid) 76 | found_servers[index].last_seen_at = datetime.now().astimezone() 77 | else: 78 | logging.debug(f'Got official server {found_server.uid}, ignoring it') 79 | 80 | return found_servers 81 | 82 | def check_if_server_still_exists(self, server: GametoolsServer, checks_since_last_ok: int) -> Tuple[bool, bool, int]: 83 | check_ok = True 84 | found = False 85 | try: 86 | response = self.session.get(f'{GAMETOOLS_BASE_URI}/{self.game}/detailedserver/' 87 | f'?gameid={server.uid}', timeout=self.request_timeout) 88 | if response.status_code == 200: 89 | # Server was found on gametools => make sure it still not official (or include_official is set) 90 | parsed = response.json() 91 | found = not parsed['official'] or self.include_official 92 | elif response.status_code == 404: 93 | # gametools responded with, server was not found 94 | found = False 95 | else: 96 | # gametools responded with some other status code (504 gateway timeout for example) 97 | check_ok = False 98 | # Reset requests since last ok counter if server returned info/not found, 99 | # else increase counter and sleep 100 | if check_ok: 101 | checks_since_last_ok = 0 102 | else: 103 | checks_since_last_ok += 1 104 | except requests.exceptions.RequestException as e: 105 | logging.debug(e) 106 | logging.error(f'Failed to fetch server {server.uid} for expiration check') 107 | check_ok = False 108 | 109 | return check_ok, found, checks_since_last_ok 110 | 111 | def build_server_links( 112 | self, 113 | uid: str, 114 | ip: Optional[str] = None, 115 | port: Optional[int] = None 116 | ) -> Union[List[WebLink], WebLink]: 117 | return [WEB_LINK_TEMPLATES['gametools'].render(self.game, self.platform, uid)] 118 | -------------------------------------------------------------------------------- /GameserverLister/listers/medalofhonor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from typing import Tuple 4 | 5 | import pyq3serverlist 6 | import requests 7 | 8 | from GameserverLister.common.helpers import is_valid_public_ip, is_valid_port, guid_from_ip_port 9 | from GameserverLister.common.servers import ClassicServer, ViaStatus 10 | from GameserverLister.common.types import MedalOfHonorGame, MedalOfHonorPlatform 11 | from .common import ServerLister 12 | 13 | 14 | class MedalOfHonorServerLister(ServerLister): 15 | """ 16 | The defacto standard principal server for Medal of Honor games uses a query GameSpy protocol implementation that 17 | does not work with gslist. However, they also provide server lists as text files, which unfortunately only contain 18 | the game port. So, instead of extending the GameSpy server lister and trying to find the query port, we use the 19 | text files to find servers. Since the Medal of Honor games support Quake3-like queries on the game port, we can use 20 | that to query servers with the information we have. 21 | """ 22 | game: MedalOfHonorGame 23 | platform: MedalOfHonorPlatform 24 | 25 | def __init__( 26 | self, 27 | game: MedalOfHonorGame, 28 | expire: bool, 29 | expired_ttl: float, 30 | recover: bool, 31 | add_links: bool, 32 | txt: bool, 33 | list_dir: str 34 | ): 35 | super().__init__( 36 | game, 37 | MedalOfHonorPlatform.PC, 38 | ClassicServer, 39 | expire, 40 | expired_ttl, 41 | recover, 42 | add_links, 43 | txt, 44 | list_dir 45 | ) 46 | 47 | def update_server_list(self): 48 | request_ok = False 49 | attempt = 0 50 | max_attempts = 3 51 | raw_server_list = None 52 | while not request_ok and attempt < max_attempts: 53 | try: 54 | logging.info('Fetching server list from mohaaservers.tk') 55 | resp = self.session.get(self.get_server_list_url(), timeout=self.request_timeout) 56 | 57 | if resp.ok: 58 | raw_server_list = resp.text 59 | request_ok = True 60 | else: 61 | attempt += 1 62 | except requests.exceptions.RequestException as e: 63 | logging.debug(e) 64 | logging.error(f'Failed to fetch servers from mohaaservers.tk, attempt {attempt + 1}/{max_attempts}') 65 | attempt += 1 66 | 67 | # Make sure any servers were found 68 | if raw_server_list is None: 69 | logging.error('Failed to retrieve server list, exiting') 70 | sys.exit(1) 71 | 72 | # Parse list 73 | found_servers = [] 74 | for line in raw_server_list.strip(' \n').split('\n'): 75 | # Silently skip any completely empty lines 76 | if len(line) == 0: 77 | continue 78 | 79 | elems = line.strip().split(':') 80 | 81 | if len(elems) != 2: 82 | logging.warning(f'Principal returned malformed server list entry ({line}), skipping it') 83 | continue 84 | 85 | ip, query_port = elems 86 | if not is_valid_public_ip(ip) or not is_valid_port(int(query_port)): 87 | logging.warning(f'Principal returned invalid server entry ({ip}:{query_port}), skipping it') 88 | continue 89 | 90 | via = ViaStatus('mohaaservers.tk') 91 | found_server = ClassicServer( 92 | guid_from_ip_port(ip, query_port), 93 | ip, 94 | int(query_port), 95 | via, 96 | int(query_port) # query port = game port for MOH games 97 | ) 98 | 99 | if self.add_links: 100 | found_server.add_links(self.build_server_links( 101 | found_server.uid, 102 | found_server.ip, 103 | found_server.game_port 104 | )) 105 | 106 | found_servers.append(found_server) 107 | 108 | self.add_update_servers(found_servers) 109 | 110 | def get_server_list_url(self) -> str: 111 | # mohaaservers uses a "key" without the "moh" prefix, e.g. "servers_aa" for "mohaa" 112 | return f'https://mohaaservers.tk/servlist/servers_{self.game[3:]}.txt' 113 | 114 | def check_if_server_still_exists(self, server: ClassicServer, checks_since_last_ok: int) -> Tuple[bool, bool, int]: 115 | # Since we query the server directly, there is no way of handling HTTP server errors differently then 116 | # actually failed checks, so even if the query fails, we have to treat it as "check ok" 117 | check_ok = True 118 | found = False 119 | try: 120 | pyq3serverlist.MedalOfHonorServer(server.ip, server.query_port).get_status() 121 | 122 | # get_status will raise an exception if the server cannot be contacted, 123 | # so this will only be reached if the query succeeds 124 | found = True 125 | except pyq3serverlist.PyQ3SLError as e: 126 | logging.debug(e) 127 | logging.debug(f'Failed to query server {server.uid} for expiration check') 128 | 129 | return check_ok, found, checks_since_last_ok 130 | -------------------------------------------------------------------------------- /GameserverLister/common/helpers.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import json 3 | import logging 4 | from typing import Callable, List 5 | 6 | import gevent.subprocess 7 | from nslookup import Nslookup 8 | 9 | from GameserverLister.common.servers import FrostbiteServer, BadCompany2Server 10 | from GameserverLister.common.types import GamespyGame 11 | 12 | 13 | def find_query_port( 14 | gamedig_path: str, 15 | game: str, 16 | server: FrostbiteServer, 17 | ports_to_try: list, 18 | validator: Callable[[FrostbiteServer, dict], bool] 19 | ) -> int: 20 | query_port = -1 21 | for port_to_try in ports_to_try: 22 | if not is_valid_port(port_to_try): 23 | logging.warning(f'Skipping query port to try which is outside of valid port range ({port_to_try})') 24 | continue 25 | 26 | gamedig_result = gevent.subprocess.run( 27 | args=[ 28 | # split path to allow commands to be passed (e.g. "mise x -- gamedig") 29 | *gamedig_path.split(' '), 30 | '--type', 31 | game, 32 | f'{server.ip}:{port_to_try}', 33 | '--maxAttempts 2', 34 | '--socketTimeout 2000', 35 | '--givenPortOnly', 36 | '--checkOldIDs', 37 | ], 38 | capture_output=True 39 | ) 40 | 41 | # Make sure gamedig did not log any errors to stderr and has some output in stdout 42 | if len(gamedig_result.stderr) > 0 or len(gamedig_result.stdout) == 0: 43 | continue 44 | 45 | # Try to parse JSON returned by gamedig 46 | try: 47 | parsed_result = json.loads(gamedig_result.stdout) 48 | except json.JSONDecodeError as e: 49 | logging.debug(e) 50 | logging.error('Failed to parse gamedig command output') 51 | continue 52 | 53 | # Stop searching if query was successful and response came from the correct server 54 | # (some servers run on the same IP, so make sure ip and game_port match) 55 | if not parsed_result.get('error', '').startswith('Failed all') and validator(server, parsed_result): 56 | query_port = port_to_try 57 | break 58 | 59 | return query_port 60 | 61 | 62 | def resolve_host(host: str) -> List[str]: 63 | if is_valid_ip(host): 64 | return [host] 65 | 66 | looker_upper = Nslookup() 67 | dns_result = looker_upper.dns_lookup(host) 68 | 69 | return dns_result.answer 70 | 71 | 72 | def is_valid_ip(ip: str) -> bool: 73 | try: 74 | ipaddress.ip_address(ip) 75 | return True 76 | except ValueError: 77 | return False 78 | 79 | 80 | def is_valid_public_ip(ip: str) -> bool: 81 | try: 82 | ip_address = ipaddress.ip_address(ip) 83 | return ip_address.is_global 84 | except ValueError: 85 | return False 86 | 87 | 88 | def is_valid_port(port: int) -> bool: 89 | return 0 < port < 65536 90 | 91 | 92 | def is_server_for_gamespy_game(game: GamespyGame, game_name: str, parsed_result: dict) -> bool: 93 | """ 94 | Check if a GameSpy query result matches key contents/structure we expect for a server of the given game 95 | :param game: Game the server should be from/for 96 | :param game_name: Name of the game as referenced by gslist 97 | :param parsed_result: Parsed result of a gslist GameSpy query against the server 98 | :return: True, if the results matches expected key content/structure, else false 99 | """ 100 | if game is GamespyGame.BFVietnam: 101 | # Battlefield Vietnam does not reliably contain the gamename anywhere, but as some quite unique keys 102 | return 'allow_nose_cam' in parsed_result and 'name_tag_distance_scope' in parsed_result and \ 103 | 'soldier_friendly_fire_on_splash' in parsed_result and 'all_active_mods' in parsed_result 104 | elif game is GamespyGame.Crysis: 105 | # Crysis uses the same keys as Crysiswars, but the "gamename" key is missing 106 | return 'voicecomm' in parsed_result and 'dx10' in parsed_result and \ 107 | 'gamepadsonly' in parsed_result and 'gamename' not in parsed_result 108 | elif game is GamespyGame.Vietcong: 109 | # Vietcong uses many of the same keys as Vietcong 2, but the "extinfo" key is missing (amongst others) 110 | return 'uver' in parsed_result and 'dedic' in parsed_result and 'extinfo' not in parsed_result 111 | elif game is GamespyGame.Vietcong2: 112 | return 'uver' in parsed_result and 'dedic' in parsed_result and 'extinfo' in parsed_result 113 | elif game is GamespyGame.FH2: 114 | # Check mod value, since Forgotten Hope 2 is technically a Battlefield 2 mod 115 | return parsed_result.get('gamename') == game_name and parsed_result.get('gamevariant') == 'fh2' 116 | elif game is GamespyGame.SWAT4: 117 | # SWAT 4 has a very limited set of keys, so we need to look at values 118 | return parsed_result.get('gamevariant') in ['SWAT 4', 'SWAT 4X', 'SEF', 'FR'] 119 | elif game is GamespyGame.UT3: 120 | # UT3 has some *very* unique keys, see https://github.com/gamedig/node-gamedig/blob/master/protocols/ut3.js#L7 121 | return 'p1073741825' in parsed_result and 'p1073741826' in parsed_result 122 | else: 123 | return parsed_result.get('gamename', '').lower() == game_name 124 | 125 | 126 | def guid_from_ip_port(ip: str, port: str) -> str: 127 | int_port = int(port) 128 | guid = '-'.join([f'{int((pow(int(octet) + 2, 2)*pow(int_port, 2))/(int_port*8)):0>x}' for 129 | (index, octet) in enumerate(ip.split('.'))]) 130 | 131 | return guid 132 | -------------------------------------------------------------------------------- /GameserverLister/listers/unreal2.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Tuple, Optional, Union 3 | 4 | import pyut2serverlist 5 | 6 | from GameserverLister.common.helpers import is_valid_public_ip, is_valid_port, guid_from_ip_port 7 | from GameserverLister.common.servers import ClassicServer, ViaStatus 8 | from GameserverLister.common.types import Unreal2Game, Unreal2Platform 9 | from GameserverLister.common.weblinks import WebLink, WEB_LINK_TEMPLATES 10 | from GameserverLister.games.unreal2 import UNREAL2_CONFIGS 11 | from .common import ServerLister 12 | 13 | 14 | class Unreal2ServerLister(ServerLister): 15 | game: Unreal2Game 16 | platform: Unreal2Platform 17 | principal: str 18 | cd_key: str 19 | 20 | principal_timeout: float 21 | 22 | def __init__( 23 | self, 24 | game: Unreal2Game, 25 | principal: str, 26 | cd_key: str, 27 | principal_timeout: float, 28 | expire: bool, 29 | expired_ttl: float, 30 | recover: bool, 31 | add_links: bool, 32 | txt: bool, 33 | list_dir: str 34 | ): 35 | super().__init__( 36 | game, 37 | Unreal2Platform.PC, 38 | ClassicServer, 39 | expire, 40 | expired_ttl, 41 | recover, 42 | add_links, 43 | txt, 44 | list_dir 45 | ) 46 | self.principal = principal 47 | self.cd_key = cd_key 48 | self.principal_timeout = principal_timeout 49 | 50 | def update_server_list(self): 51 | hostname, port = UNREAL2_CONFIGS[self.game]['servers'][self.principal].values() 52 | principal = pyut2serverlist.PrincipalServer( 53 | hostname, 54 | port, 55 | pyut2serverlist.Game(self.game), 56 | self.cd_key, 57 | timeout=self.principal_timeout 58 | ) 59 | 60 | found_servers = [] 61 | raw_servers = self.get_servers(principal) 62 | for raw_server in raw_servers: 63 | if not is_valid_public_ip(raw_server.ip) or not is_valid_port(raw_server.query_port): 64 | logging.warning( 65 | f'Principal returned invalid server entry ' 66 | f'({raw_server.ip}:{raw_server.query_port}), skipping it' 67 | ) 68 | continue 69 | 70 | via = ViaStatus(self.principal) 71 | found_server = ClassicServer( 72 | guid_from_ip_port(raw_server.ip, str(raw_server.query_port)), 73 | raw_server.ip, 74 | raw_server.query_port, 75 | via, 76 | raw_server.game_port 77 | ) 78 | 79 | if self.add_links: 80 | found_server.add_links(self.build_server_links( 81 | found_server.uid, 82 | found_server.ip, 83 | raw_server.game_port 84 | )) 85 | 86 | found_servers.append(found_server) 87 | 88 | self.add_update_servers(found_servers) 89 | 90 | @staticmethod 91 | def get_servers(principal: pyut2serverlist.PrincipalServer) -> List[pyut2serverlist.Server]: 92 | query_ok = False 93 | attempt = 0 94 | max_attempts = 3 95 | servers = [] 96 | while not query_ok and attempt < max_attempts: 97 | try: 98 | servers = principal.get_servers() 99 | query_ok = True 100 | except pyut2serverlist.TimeoutError: 101 | logging.error(f'Principal server query timed out, attempt {attempt + 1}/{max_attempts}') 102 | attempt += 1 103 | except pyut2serverlist.Error as e: 104 | logging.debug(e) 105 | logging.error(f'Failed to query principal server, attempt {attempt + 1}/{max_attempts}') 106 | attempt += 1 107 | 108 | return servers 109 | 110 | def check_if_server_still_exists(self, server: ClassicServer, checks_since_last_ok: int) -> Tuple[bool, bool, int]: 111 | # Since we query the server directly, there is no way of handling HTTP server errors differently then 112 | # actually failed checks, so even if the query fails, we have to treat it as "check ok" 113 | check_ok = True 114 | found = False 115 | try: 116 | pyut2serverlist.Server(server.ip, server.query_port).get_info() 117 | 118 | # get_info will raise an exception if the server cannot be contacted, 119 | # so this will only be reached if the query succeeds 120 | found = True 121 | except pyut2serverlist.Error as e: 122 | logging.debug(e) 123 | logging.debug(f'Failed to query server {server.uid} for expiration check') 124 | 125 | return check_ok, found, checks_since_last_ok 126 | 127 | def build_server_links( 128 | self, 129 | uid: str, 130 | ip: Optional[str] = None, 131 | port: Optional[int] = None 132 | ) -> Union[List[WebLink], WebLink]: 133 | template_refs = UNREAL2_CONFIGS[self.game].get('linkTemplateRefs', {}) 134 | # Add principal-scoped links first, then add game-scoped links 135 | templates = [ 136 | *[WEB_LINK_TEMPLATES.get(ref) for ref in template_refs.get(self.principal, [])], 137 | *[WEB_LINK_TEMPLATES.get(ref) for ref in template_refs.get('_any', [])] 138 | ] 139 | 140 | links = [] 141 | for template in templates: 142 | links.append(template.render(self.game, self.platform, uid, ip=ip, port=port)) 143 | 144 | return links 145 | -------------------------------------------------------------------------------- /GameserverLister/listers/valve.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Tuple, Optional 3 | 4 | import pyvpsq 5 | 6 | from GameserverLister.common.helpers import is_valid_public_ip, is_valid_port, guid_from_ip_port 7 | from GameserverLister.common.servers import ClassicServer, ViaStatus 8 | from GameserverLister.common.types import ValveGame, ValvePrincipal, ValveGameConfig, ValvePlatform 9 | from GameserverLister.games.valve import VALVE_PRINCIPAL_CONFIGS, VALVE_GAME_CONFIGS 10 | from GameserverLister.listers.common import ServerLister 11 | 12 | 13 | class ValveServerLister(ServerLister): 14 | game: ValveGame 15 | platform: ValvePlatform 16 | servers: List[ClassicServer] 17 | principal: ValvePrincipal 18 | config: ValveGameConfig 19 | 20 | principal_timeout: float 21 | filters: str 22 | max_pages: int 23 | 24 | add_game_port: bool 25 | 26 | def __init__( 27 | self, 28 | game: ValveGame, 29 | principal: ValvePrincipal, 30 | principal_timeout: float, 31 | filters: str, 32 | max_pages: int, 33 | add_game_port: bool, 34 | expire: bool, 35 | expired_ttl: float, 36 | recover: bool, 37 | add_links: bool, 38 | txt: bool, 39 | list_dir: str 40 | ): 41 | super().__init__( 42 | game, 43 | ValvePlatform.PC, 44 | ClassicServer, 45 | expire, 46 | expired_ttl, 47 | recover, 48 | add_links, 49 | txt, 50 | list_dir 51 | ) 52 | self.principal = principal 53 | self.config = VALVE_GAME_CONFIGS[self.game] 54 | self.principal_timeout = principal_timeout 55 | self.filters = filters 56 | self.max_pages = max_pages 57 | self.add_game_port = add_game_port 58 | 59 | def update_server_list(self): 60 | principal_config = VALVE_PRINCIPAL_CONFIGS[self.principal] 61 | principal = pyvpsq.PrincipalServer( 62 | principal_config.hostname, 63 | principal_config.port, 64 | timeout=self.principal_timeout 65 | ) 66 | 67 | found_servers = [] 68 | # Try to reduce the consecutive number of requests by iterating over regions 69 | for region in pyvpsq.Region: 70 | for raw_server in self.get_servers(principal, self.config.app_id, region, self.filters, self.max_pages): 71 | if not is_valid_public_ip(raw_server.ip) or not is_valid_port(raw_server.query_port): 72 | logging.warning( 73 | f'Principal returned invalid server entry ' 74 | f'({raw_server.ip}:{raw_server.query_port}), skipping it' 75 | ) 76 | continue 77 | 78 | via = ViaStatus(self.principal) 79 | found_server = ClassicServer( 80 | guid_from_ip_port(raw_server.ip, str(raw_server.query_port)), 81 | raw_server.ip, 82 | raw_server.query_port, 83 | via 84 | ) 85 | 86 | if found_server not in found_servers: 87 | if self.add_links or self.add_game_port: 88 | game_port = self.get_server_game_port(found_server) 89 | if game_port is not None: 90 | if self.add_links: 91 | found_server.add_links(self.build_server_links( 92 | found_server.uid, 93 | found_server.ip, 94 | game_port 95 | )) 96 | if self.add_game_port: 97 | found_server.game_port = game_port 98 | found_servers.append(found_server) 99 | 100 | self.add_update_servers(found_servers) 101 | 102 | @staticmethod 103 | def get_servers( 104 | principal: pyvpsq.PrincipalServer, 105 | app_id: int, 106 | region: pyvpsq.Region, 107 | filters: str, 108 | max_pages: int 109 | ) -> List[pyvpsq.Server]: 110 | servers = [] 111 | try: 112 | for server in principal.get_servers(fr'\appid\{app_id}{filters}', region, max_pages): 113 | servers.append(server) 114 | except pyvpsq.TimeoutError: 115 | logging.error('Principal server query timed out') 116 | except pyvpsq.Error: 117 | logging.error('Failed to query principal server') 118 | 119 | return servers 120 | 121 | def get_server_game_port(self, server: ClassicServer) -> Optional[int]: 122 | if not self.config.distinct_query_port: 123 | return server.query_port 124 | 125 | responded, info = self.query_server(server) 126 | if responded and info.game_port is not None: 127 | return info.game_port 128 | 129 | def check_if_server_still_exists(self, server: ClassicServer, checks_since_last_ok: int) -> Tuple[bool, bool, int]: 130 | found, _ = self.query_server(server) 131 | return True, found, checks_since_last_ok 132 | 133 | @staticmethod 134 | def query_server(server: ClassicServer) -> Tuple[bool, Optional[pyvpsq.ServerInfo]]: 135 | try: 136 | info = pyvpsq.Server(server.ip, server.query_port).get_info() 137 | return True, info 138 | except pyvpsq.Error as e: 139 | logging.debug(e) 140 | logging.debug(f'Failed to query server {server.uid}') 141 | return False, None 142 | -------------------------------------------------------------------------------- /GameserverLister/common/types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import List, Optional, Dict, Union 4 | 5 | 6 | # https://stackoverflow.com/a/54919285/9395553 7 | class ExtendedEnum(Enum): 8 | @classmethod 9 | def list(cls): 10 | return list(map(lambda c: c.value, cls)) 11 | 12 | def __str__(self): 13 | return self.value 14 | 15 | 16 | class Principal(str, ExtendedEnum): 17 | pass 18 | 19 | 20 | class GamespyPrincipal(Principal): 21 | TripleThreeNetworks_com_1 = '333networks.com-1' 22 | TripleThreeNetworks_com_2 = '333networks.com-2' 23 | B2BF2_net = 'b2bf2.net' 24 | BF1942_org = 'bf1942.org' 25 | BF2Hub_com = 'bf2hub.com' 26 | Crymp_org = 'crymp.org' 27 | Errorist_eu = 'errorist.eu' 28 | FH2_dev = 'fh2.dev' 29 | Jedi95_us = 'jedi95.us' 30 | Newbiesplayground_net = 'newbiesplayground.net' 31 | NightfirePC_com = 'nightfirepc.com' 32 | NovGames_ru = 'novgames.ru' 33 | OldUnreal_com_1 = 'oldunreal.com-1' 34 | OldUnreal_com_2 = 'oldunreal.com-2' 35 | OpenSpy_net = 'openspy.net' 36 | Play2142_ru = 'play2142.ru' 37 | PlayBF2_ru = 'playbf2.ru' 38 | Qtracker_com = 'qtracker.com' 39 | SWAT4Stats_com = 'swat4stats.com' 40 | Vietcong_tk = 'vietcong.tk' 41 | Vietcong1_eu = 'vietcong1.eu' 42 | 43 | 44 | class ValvePrincipal(Principal): 45 | VALVE = 'valve' 46 | 47 | 48 | @dataclass 49 | class GamespyPrincipalConfig: 50 | hostname: str 51 | port_offset: Optional[int] = None 52 | 53 | def get_port_offset(self) -> int: 54 | if self.port_offset is None: 55 | return 0 56 | return self.port_offset 57 | 58 | 59 | @dataclass 60 | class ValvePrincipalConfig: 61 | hostname: str 62 | port: int 63 | 64 | 65 | class Game(str, ExtendedEnum): 66 | pass 67 | 68 | 69 | class GamespyGame(Game): 70 | BF1942 = 'bf1942' 71 | BFVietnam = 'bfvietnam' 72 | BF2 = 'bf2' 73 | FH2 = 'fh2' 74 | BF2142 = 'bf2142' 75 | Crysis = 'crysis' 76 | CrysisWars = 'crysiswars' 77 | DeusEx = 'deusex' 78 | DukeNukemForever = 'dnf' 79 | JBNightfire = 'jbnightfire' 80 | Paraworld = 'paraworld' 81 | Postal2 = 'postal2' 82 | Rune = 'rune' 83 | SeriousSam = 'serioussam' 84 | SeriousSamSE = 'serioussamse' 85 | SWAT4 = 'swat4' 86 | Unreal = 'unreal' 87 | UT = 'ut' 88 | UT3 = 'ut3' 89 | Vietcong = 'vietcong' 90 | Vietcong2 = 'vietcong2' 91 | WheelOfTime = 'wot' 92 | 93 | 94 | class Quake3Game(Game): 95 | CoD = 'cod' 96 | CoDUO = 'coduo' 97 | CoD2 = 'cod2' 98 | CoD4 = 'cod4' 99 | CoD4X = 'cod4x' 100 | Nexuiz = 'nexuiz' 101 | OpenArena = 'openarena' 102 | Q3Rally = 'q3rally' 103 | Quake = 'quake' 104 | Quake3Arena = 'quake3arena' 105 | RTCW = 'rtcw' 106 | SOF2 = 'sof2' 107 | SWJKJA = 'swjkja' 108 | SWJKJO = 'swjkjo' 109 | Tremulous = 'tremulous' 110 | UrbanTerror = 'urbanterror' 111 | Warfork = 'warfork' 112 | Warsow = 'warsow' 113 | WolfensteinET = 'wolfensteinet' 114 | Xonotic = 'xonotic' 115 | 116 | 117 | class MedalOfHonorGame(Game): 118 | AA = 'mohaa' 119 | BT = 'mohbt' 120 | PA = 'mohpa' 121 | SH = 'mohsh' 122 | 123 | 124 | class Unreal2Game(Game): 125 | UT2003 = 'ut2003' 126 | UT2004 = 'ut2004' 127 | 128 | 129 | class ValveGame(Game): 130 | AmericasArmyProvingGrounds = 'aapg' 131 | ARKSurvivalEvolved = 'arkse' 132 | Arma2 = 'arma2' 133 | Arma3 = 'arma3' 134 | CounterStrike = 'cs' 135 | CounterStrikeConditionZero = 'cscz' 136 | CounterStrikeSource = 'css' 137 | CounterStrikeGlobalOffensive = 'csgo' 138 | DayZ = 'dayz' 139 | DayZMod = 'dayzmod' 140 | DoD = 'dod' 141 | DoDS = 'dods' 142 | GarrysMod = 'gmod' 143 | Insurgency = 'insurgency' 144 | InsurgencySandstorm = 'insurgency-sandstorm' 145 | Left4Dead = 'left4dead' 146 | Left4Dead2 = 'left4dead2' 147 | RS2 = 'rs2' 148 | Rust = 'rust' 149 | SevenD2D = '7d2d' 150 | Squad = 'squad' 151 | TFC = 'tfc' 152 | TF2 = 'tf2' 153 | 154 | 155 | class TheaterGame(Game): 156 | BFBC2 = 'bfbc2' 157 | 158 | 159 | class BattlelogGame(Game): 160 | BF3 = 'bf3' 161 | BF4 = 'bf4' 162 | BFH = 'bfh' 163 | 164 | 165 | class GametoolsGame(Game): 166 | BF1 = 'bf1' 167 | BFV = 'bfv' 168 | 169 | 170 | class Platform(str, ExtendedEnum): 171 | pass 172 | 173 | 174 | class GamespyPlatform(Platform): 175 | PC = 'pc' 176 | 177 | 178 | class Quake3Platform(Platform): 179 | PC = 'pc' 180 | 181 | 182 | class MedalOfHonorPlatform(Platform): 183 | PC = 'pc' 184 | 185 | 186 | class Unreal2Platform(Platform): 187 | PC = 'pc' 188 | 189 | 190 | class ValvePlatform(Platform): 191 | PC = 'pc' 192 | 193 | 194 | class TheaterPlatform(Platform): 195 | PC = 'pc' 196 | 197 | 198 | class BattlelogPlatform(Platform): 199 | PC = 'pc' 200 | PS3 = 'ps3' 201 | PS4 = 'ps4' 202 | Xbox360 = 'xbox360' 203 | XboxOne = 'xboxone' 204 | 205 | 206 | class GametoolsPlatform(Platform): 207 | PC = 'pc' 208 | PS4 = 'ps4' 209 | XboxOne = 'xboxone' 210 | 211 | 212 | @dataclass 213 | class GamespyGameConfig: 214 | game_name: str 215 | game_key: str 216 | enc_type: int 217 | query_type: int 218 | port: int 219 | principals: List[GamespyPrincipal] 220 | list_type: Optional[int] = None 221 | info_query: Optional[str] = None 222 | link_template_refs: Optional[Dict[Union[str, GamespyPrincipal], List[str]]] = None 223 | 224 | 225 | @dataclass 226 | class ValveGameConfig: 227 | app_id: int 228 | principals: List[ValvePrincipal] 229 | distinct_query_port: bool = False 230 | link_template_refs: Optional[Dict[Union[str, ValvePrincipal], List[str]]] = None 231 | -------------------------------------------------------------------------------- /GameserverLister/listers/bfbc2.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from random import randint 4 | from typing import Tuple, Callable, List 5 | 6 | import requests 7 | 8 | from GameserverLister.common.helpers import guid_from_ip_port 9 | from GameserverLister.common.servers import BadCompany2Server 10 | from GameserverLister.common.types import TheaterGame, TheaterPlatform 11 | from .common import FrostbiteServerLister 12 | 13 | 14 | class BadCompany2ServerLister(FrostbiteServerLister): 15 | game: TheaterGame 16 | platform: TheaterPlatform 17 | 18 | def __init__( 19 | self, 20 | expire: bool, 21 | expired_ttl: float, 22 | recover: bool, 23 | add_links: bool, 24 | txt: bool, 25 | list_dir: str, 26 | timeout: float 27 | ): 28 | super().__init__( 29 | TheaterGame.BFBC2, 30 | TheaterPlatform.PC, 31 | BadCompany2Server, 32 | expire, 33 | expired_ttl, 34 | recover, 35 | add_links, 36 | txt, 37 | list_dir, 38 | timeout 39 | ) 40 | 41 | def update_server_list(self): 42 | request_ok = False 43 | attempt = 0 44 | max_attempts = 3 45 | servers = None 46 | while not request_ok and attempt < max_attempts: 47 | try: 48 | logging.info('Fetching server list from Project Rome API') 49 | resp = self.session.get('https://fesl.cetteup.com/v1/bfbc2/servers/rome-pc', timeout=self.request_timeout) 50 | 51 | if resp.ok: 52 | servers = resp.json() 53 | request_ok = True 54 | else: 55 | attempt += 1 56 | except requests.exceptions.RequestException as e: 57 | logging.debug(e) 58 | logging.error(f'Failed to fetch servers from API, attempt {attempt + 1}/{max_attempts}') 59 | attempt += 1 60 | 61 | # Make sure any servers were found 62 | if servers is None: 63 | logging.error('Failed to retrieve server list, exiting') 64 | sys.exit(1) 65 | 66 | # Add servers from list 67 | found_servers = [] 68 | for server in servers: 69 | found_server = BadCompany2Server( 70 | guid_from_ip_port(server['I'], server['P']), 71 | server['N'], 72 | server['LID'], 73 | server['GID'], 74 | server['I'], 75 | server['P'] 76 | ) 77 | 78 | if self.add_links: 79 | found_server.add_links(self.build_server_links( 80 | found_server.uid, 81 | found_server.ip, 82 | found_server.game_port 83 | )) 84 | 85 | found_servers.append(found_server) 86 | 87 | self.add_update_servers(found_servers) 88 | 89 | def check_if_server_still_exists(self, server: BadCompany2Server, checks_since_last_ok: int) -> Tuple[bool, bool, int]: 90 | check_ok = True 91 | found = False 92 | try: 93 | response = self.session.get(f'https://fesl.cetteup.com/v1/bfbc2/servers/rome-pc/{server.lid}/{server.gid}', 94 | timeout=self.request_timeout) 95 | 96 | if response.ok: 97 | found = True 98 | elif response.status_code == 404: 99 | found = False 100 | else: 101 | check_ok = False 102 | # Reset requests since last ok counter if server returned info/not found, 103 | # else increase counter and sleep 104 | if check_ok: 105 | checks_since_last_ok = 0 106 | else: 107 | checks_since_last_ok += 1 108 | except requests.RequestException as e: 109 | logging.debug(e) 110 | logging.error(f'Failed to fetch server {server.uid} for expiration check') 111 | check_ok = False 112 | 113 | return check_ok, found, checks_since_last_ok 114 | 115 | def build_port_to_try_list(self, game_port: int) -> List[int]: 116 | """ 117 | Most Bad Company 2 server seem to be hosted directly by members of the community, resulting in pretty random 118 | query ports as well as strange/incorrect server configurations. So, try a bunch of ports and validate found 119 | query ports using the connect property OR the server name 120 | """ 121 | return [ 122 | 48888, # default query port 123 | game_port + 29321, # game port + default port offset (mirror gamedig behavior) 124 | game_port, # game port (some servers use same port for game + query) 125 | game_port + 100, # game port + 100 (nitrado) 126 | game_port + 10, # game port + 10 127 | game_port + 5, # game port + 5 (several hosters) 128 | game_port + 1, # game port + 1 129 | game_port + 29233, # game port + 29233 (i3D.net) 130 | game_port + 29000, # game port + 29000 131 | game_port + 29323, # game port + 29323 132 | randint(48880, 48890), # random port around default query port 133 | randint(48601, 48605), # random port around 48600 134 | randint(19567, 48888), # random port between default game port and default query port 135 | randint(game_port, game_port + 29321) # random port between game port and game port + default offset 136 | ] 137 | 138 | def get_validator(self) -> Callable[[BadCompany2Server, dict], bool]: 139 | def validator(server: BadCompany2Server, parsed_result: dict) -> bool: 140 | # Consider server valid if game port matches 141 | if parsed_result.get('connect', '').endswith(f':{server.game_port}'): 142 | return True 143 | 144 | # Consider server valid if (unique) name matches 145 | names = [s.name for s in self.servers if s.name == server.name] 146 | if parsed_result.get('name') == server.name and len(names) == 1: 147 | return True 148 | 149 | return False 150 | 151 | return validator 152 | 153 | -------------------------------------------------------------------------------- /GameserverLister/listers/quake3.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | from typing import List, Tuple, Optional, Union 4 | 5 | import pyq3serverlist 6 | 7 | from GameserverLister.common.helpers import is_valid_public_ip, is_valid_port, guid_from_ip_port 8 | from GameserverLister.common.servers import ClassicServer, ViaStatus 9 | from GameserverLister.common.types import Quake3Game, Quake3Platform 10 | from GameserverLister.common.weblinks import WebLink, WEB_LINK_TEMPLATES 11 | from GameserverLister.games.quake3 import QUAKE3_CONFIGS 12 | from .common import ServerLister 13 | 14 | 15 | class Quake3ServerLister(ServerLister): 16 | game: Quake3Game 17 | platform: Quake3Platform 18 | principal: str 19 | protocols: List[int] 20 | network_protocol: int 21 | game_name: str 22 | keywords: str 23 | server_entry_prefix: bytes 24 | 25 | def __init__( 26 | self, 27 | game: Quake3Game, 28 | principal: str, 29 | expire: bool, 30 | expired_ttl: float, 31 | recover: bool, 32 | add_links: bool, 33 | txt: bool, 34 | list_dir: str 35 | ): 36 | super().__init__( 37 | game, 38 | Quake3Platform.PC, 39 | ClassicServer, 40 | expire, 41 | expired_ttl, 42 | recover, 43 | add_links, 44 | txt, 45 | list_dir 46 | ) 47 | # Merge default config with given principal config 48 | default_config = { 49 | 'keywords': 'full empty', 50 | 'game_name': '', 51 | 'network_protocol': socket.SOCK_DGRAM, 52 | 'server_entry_prefix': b'' 53 | } 54 | principal_config = {key: value for (key, value) in QUAKE3_CONFIGS[self.game].items() 55 | if key in default_config.keys()} 56 | self.principal = principal 57 | # TODO Move network protocol to server 58 | self.keywords, self.game_name, self.network_protocol, self.server_entry_prefix = {**default_config, **principal_config}.values() 59 | self.protocols = QUAKE3_CONFIGS[self.game]['protocols'] 60 | 61 | def update_server_list(self): 62 | # Use same connection to principal for all queries 63 | config = QUAKE3_CONFIGS[self.game]['servers'][self.principal] 64 | reader = config.get('reader', pyq3serverlist.EOFReader) 65 | principal = pyq3serverlist.PrincipalServer( 66 | config['hostname'], 67 | config['port'], 68 | reader=reader(), 69 | network_protocol=self.network_protocol 70 | ) 71 | 72 | # Fetch servers for all protocols 73 | found_servers = [] 74 | for protocol in self.protocols: 75 | raw_servers = self.get_servers(principal, protocol) 76 | for raw_server in raw_servers: 77 | if not is_valid_public_ip(raw_server.ip) or not is_valid_port(raw_server.port): 78 | logging.warning( 79 | f'Principal returned invalid server entry ' 80 | f'({raw_server.ip}:{raw_server.port}), skipping it' 81 | ) 82 | continue 83 | 84 | via = ViaStatus(self.principal) 85 | found_server = ClassicServer( 86 | guid_from_ip_port(raw_server.ip, str(raw_server.port)), 87 | raw_server.ip, 88 | raw_server.port, 89 | via, 90 | raw_server.port 91 | ) 92 | 93 | if self.add_links: 94 | found_server.add_links(self.build_server_links( 95 | found_server.uid, 96 | found_server.ip, 97 | found_server.query_port 98 | )) 99 | 100 | found_servers.append(found_server) 101 | 102 | self.add_update_servers(found_servers) 103 | 104 | def get_servers(self, principal: pyq3serverlist.PrincipalServer, protocol: int) -> List[pyq3serverlist.Server]: 105 | query_ok = False 106 | attempt = 0 107 | max_attempts = 3 108 | servers = [] 109 | while not query_ok and attempt < max_attempts: 110 | try: 111 | servers = principal.get_servers(protocol, self.game_name, self.keywords, self.server_entry_prefix) 112 | query_ok = True 113 | except pyq3serverlist.PyQ3SLTimeoutError: 114 | logging.error(f'Principal server query timed out using protocol {protocol}, ' 115 | f'attempt {attempt + 1}/{max_attempts}') 116 | attempt += 1 117 | except pyq3serverlist.PyQ3SLError as e: 118 | logging.debug(e) 119 | logging.error(f'Failed to query principal server using protocol {protocol}, ' 120 | f'attempt {attempt + 1}/{max_attempts}') 121 | attempt += 1 122 | 123 | return servers 124 | 125 | def check_if_server_still_exists(self, server: ClassicServer, checks_since_last_ok: int) -> Tuple[bool, bool, int]: 126 | # Since we query the server directly, there is no way of handling HTTP server errors differently then 127 | # actually failed checks, so even if the query fails, we have to treat it as "check ok" 128 | check_ok = True 129 | found = False 130 | try: 131 | pyq3serverlist.Server(server.ip, server.query_port).get_status() 132 | 133 | # get_status will raise an exception if the server cannot be contacted, 134 | # so this will only be reached if the query succeeds 135 | found = True 136 | except pyq3serverlist.PyQ3SLError as e: 137 | logging.debug(e) 138 | logging.debug(f'Failed to query server {server.uid} for expiration check') 139 | 140 | return check_ok, found, checks_since_last_ok 141 | 142 | def build_server_links( 143 | self, 144 | uid: str, 145 | ip: Optional[str] = None, 146 | port: Optional[int] = None 147 | ) -> Union[List[WebLink], WebLink]: 148 | template_refs = QUAKE3_CONFIGS[self.game].get('linkTemplateRefs', {}) 149 | # Add principal-scoped links first, then add game-scoped links 150 | templates = [ 151 | *[WEB_LINK_TEMPLATES.get(ref) for ref in template_refs.get(self.principal, [])], 152 | *[WEB_LINK_TEMPLATES.get(ref) for ref in template_refs.get('_any', [])] 153 | ] 154 | 155 | links = [] 156 | for template in templates: 157 | links.append(template.render(self.game, self.platform, uid, ip=ip, port=port)) 158 | 159 | return links 160 | -------------------------------------------------------------------------------- /GameserverLister/providers/gamespy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import subprocess 4 | from abc import abstractmethod 5 | from typing import List 6 | 7 | import requests 8 | 9 | from GameserverLister.common.helpers import resolve_host, guid_from_ip_port 10 | from GameserverLister.common.servers import ClassicServer, ViaStatus, Server 11 | from GameserverLister.common.types import GamespyPrincipal, GamespyGame, GamespyPlatform, Principal, Game, Platform 12 | from GameserverLister.games.gamespy import GAMESPY_PRINCIPAL_CONFIGS, GAMESPY_GAME_CONFIGS 13 | from GameserverLister.providers.provider import Provider 14 | 15 | 16 | class GamespyProvider(Provider): 17 | @abstractmethod 18 | def list(self, principal: GamespyPrincipal, game: GamespyGame, platform: GamespyPlatform, **kwargs) -> List[ClassicServer]: 19 | pass 20 | 21 | 22 | class GamespyListProtocolProvider(GamespyProvider): 23 | gslist_bin_path: str 24 | 25 | def __init__(self, gslist_bin_path: str): 26 | self.gslist_bin_path = gslist_bin_path 27 | 28 | def list( 29 | self, 30 | principal: GamespyPrincipal, 31 | game: GamespyGame, 32 | platform: GamespyPlatform, 33 | **kwargs 34 | ) -> List[ClassicServer]: 35 | principal_config = GAMESPY_PRINCIPAL_CONFIGS[principal] 36 | game_config = GAMESPY_GAME_CONFIGS[game] 37 | # Format hostname using game name (following old GameSpy format [game].master.gamespy.com) 38 | hostname = principal_config.hostname.format(game_config.game_name) 39 | # Combine game port and principal-specific port offset (defaults to an offset of 0) 40 | port = game_config.port + principal_config.get_port_offset() 41 | 42 | # Manually look up hostname to be able to spread retries across servers 43 | ips = resolve_host(hostname) 44 | if len(ips) == 0: 45 | raise Exception(f'Failed to resolve principal hostname: {hostname}') 46 | 47 | cwd = kwargs.get('cwd', os.getcwd()) 48 | timeout = kwargs.get('timeout', 10) 49 | 50 | # Run gslist and capture output 51 | command_ok = False 52 | attempt = 0 53 | max_attempts = 3 54 | gslist_result = None 55 | while not command_ok and attempt < max_attempts: 56 | # Alternate between first and last found A record 57 | ip = ips[0] if attempt % 2 == 0 else ips[-1] 58 | 59 | command = [ 60 | self.gslist_bin_path, 61 | '-n', game_config.game_name, 62 | '-x', f'{ip}:{port}', 63 | '-Y', game_config.game_name, game_config.game_key, 64 | '-t', str(game_config.enc_type), 65 | '-o', '1', 66 | ] 67 | 68 | # Add filter if one was given 69 | if isinstance(kwargs.get('filter'), str): 70 | command.extend(['-f', kwargs['filter']]) 71 | 72 | # Some principals do not respond with the default query list type byte (1), 73 | # so we need to explicitly set a different type byte 74 | if game_config.list_type is not None: 75 | command.extend(['-T', str(game_config.list_type)]) 76 | 77 | # Some principals do not respond unless an info query is sent (e.g. FH2 principal) 78 | if game_config.info_query is not None: 79 | command.extend(['-X', game_config.info_query]) 80 | 81 | # Add super query argument if requested 82 | if kwargs.get('super_query'): 83 | command.extend(['-Q', str(game_config.query_type)]) 84 | # Extend timeout to account for server queries 85 | timeout += 10 86 | 87 | try: 88 | logging.debug(f'Running gslist command against {ip}') 89 | gslist_result = subprocess.run( 90 | command, 91 | capture_output=True, 92 | cwd=cwd, 93 | timeout=timeout, 94 | ) 95 | command_ok = True 96 | except subprocess.TimeoutExpired: 97 | logging.error(f'gslist timed out, attempt {attempt + 1}/{max_attempts}') 98 | attempt += 1 99 | 100 | # Make sure any server were found (gslist sends all output to stderr so check there) 101 | if gslist_result is None or 'servers found' not in str(gslist_result.stderr): 102 | raise Exception('Failed to retrieve any servers via gslist') 103 | 104 | # Read gslist output file 105 | logging.debug('Reading gslist output file') 106 | with open(os.path.join(cwd, f'{game_config.game_name}.gsl'), 'r') as gslist_file: 107 | raw_server_list = gslist_file.read() 108 | 109 | # Parse server list 110 | # Format: [ip-address]:[port] 111 | logging.debug(f'Parsing server list') 112 | servers: List[ClassicServer] = [] 113 | for line in raw_server_list.splitlines(): 114 | connect, *_ = line.split(' ', 1) 115 | ip, query_port = connect.strip().split(':', 1) 116 | servers.append(ClassicServer( 117 | guid_from_ip_port(ip, str(query_port)), 118 | ip, 119 | int(query_port), 120 | ViaStatus(principal) 121 | )) 122 | 123 | return servers 124 | 125 | 126 | class CrympAPIProvider(GamespyProvider): 127 | session: requests.Session 128 | 129 | def __init__(self): 130 | self.session = requests.Session() 131 | 132 | def list( 133 | self, 134 | principal: GamespyPrincipal, 135 | game: GamespyGame, 136 | platform: GamespyPlatform, 137 | **kwargs 138 | ) -> List[ClassicServer]: 139 | if principal is not GamespyPrincipal.Crymp_org: 140 | raise Exception(f'Unsupported principal for {self.__class__.__name__}: {principal}') 141 | if game is not GamespyGame.Crysis: 142 | raise Exception(f'Unsupported game for {self.__class__.__name__}: {game}') 143 | 144 | principal_config = GAMESPY_PRINCIPAL_CONFIGS[principal] 145 | game_config = GAMESPY_GAME_CONFIGS[game] 146 | 147 | # Format hostname using game name (following old GameSpy format [game].master.gamespy.com) 148 | hostname = principal_config.hostname.format(game_config.game_name) 149 | 150 | try: 151 | resp = self.session.get( 152 | f'https://{hostname}/api/servers', 153 | timeout=kwargs.get('timeout', 10) 154 | ) 155 | resp.raise_for_status() 156 | except requests.exceptions.RequestException as e: 157 | raise Exception(f'Failed to fetch server list: {e}') from None 158 | 159 | servers: List[ClassicServer] = [] 160 | for server in resp.json(): 161 | servers.append( 162 | ClassicServer( 163 | guid_from_ip_port(server['ip'], str(server['gamespy_port'])), 164 | server['ip'], 165 | server['gamespy_port'], 166 | ViaStatus(principal) 167 | ) 168 | ) 169 | 170 | return servers 171 | -------------------------------------------------------------------------------- /GameserverLister/listers/battlelog.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from random import randint 4 | from typing import List, Tuple, Optional, Union, Callable 5 | 6 | import requests 7 | 8 | from GameserverLister.common.servers import FrostbiteServer 9 | from GameserverLister.common.types import BattlelogGame, BattlelogPlatform 10 | from GameserverLister.common.weblinks import WEB_LINK_TEMPLATES, WebLink 11 | from GameserverLister.games.battlelog import BATTLELOG_GAME_BASE_URIS 12 | from .common import HttpServerLister, FrostbiteServerLister 13 | 14 | 15 | class BattlelogServerLister(HttpServerLister, FrostbiteServerLister): 16 | game: BattlelogGame 17 | 18 | def __init__( 19 | self, 20 | game: BattlelogGame, 21 | platform: BattlelogPlatform, 22 | page_limit: int, 23 | expire: bool, 24 | expired_ttl: float, 25 | recover: bool, 26 | add_links: bool, 27 | txt: bool, 28 | list_dir: str, 29 | sleep: float, 30 | max_attempts: int, 31 | proxy: str = None 32 | ): 33 | super().__init__( 34 | game, 35 | platform, 36 | FrostbiteServer, 37 | page_limit, 38 | 60, 39 | expire, 40 | expired_ttl, 41 | recover, 42 | add_links, 43 | txt, 44 | list_dir, 45 | sleep, 46 | max_attempts 47 | ) 48 | 49 | # Set up headers 50 | self.session.headers = { 51 | 'X-Requested-With': 'XMLHttpRequest' 52 | } 53 | # Set up proxy if given 54 | if proxy is not None: 55 | # All requests are sent via https, so just set up https proxy 56 | self.session.proxies = { 57 | 'https': proxy 58 | } 59 | 60 | def get_server_list_url(self, per_page: int) -> str: 61 | return f'{BATTLELOG_GAME_BASE_URIS[self.game]}/{self.platform}/?count={per_page}&offset=0' 62 | 63 | def add_page_found_servers(self, found_servers: List[FrostbiteServer], page_response_data: dict) -> List[FrostbiteServer]: 64 | for server in page_response_data['data']: 65 | found_server = FrostbiteServer( 66 | server['guid'], 67 | server['name'], 68 | server['ip'], 69 | server['port'], 70 | ) 71 | 72 | if self.add_links: 73 | found_server.add_links(self.build_server_links(found_server.uid)) 74 | # Gametools uses the gameid for BF4 server URLs, so add that separately 75 | if self.game is BattlelogGame.BF4: 76 | found_server.add_links(WEB_LINK_TEMPLATES['gametools'].render(self.game, self.platform, server['gameId'])) 77 | 78 | # Add non-private servers (servers with an IP) that are new 79 | server_uids = [s.uid for s in found_servers] 80 | if len(found_server.ip) > 0 and found_server.uid not in server_uids: 81 | logging.debug(f'Got new server {found_server.uid}, adding it') 82 | found_servers.append(found_server) 83 | elif len(found_server.ip) > 0: 84 | logging.debug(f'Got duplicate server {found_server.uid}, updating last seen at') 85 | index = server_uids.index(found_server.uid) 86 | found_servers[index].last_seen_at = datetime.now().astimezone() 87 | else: 88 | logging.debug(f'Got private server {found_server.uid}, ignoring it') 89 | 90 | return found_servers 91 | 92 | def check_if_server_still_exists(self, server: FrostbiteServer, checks_since_last_ok: int) -> Tuple[bool, bool, int]: 93 | check_ok = True 94 | found = False 95 | try: 96 | response = self.session.get(f'https://battlelog.battlefield.com/{self.game}/' 97 | f'servers/show/{self.platform}/{server.uid}?json=1', 98 | timeout=self.request_timeout) 99 | if response.status_code == 200: 100 | # Server was found on Battlelog => make sure it is still public 101 | # TODO use server validator here 102 | parsed = response.json() 103 | found = parsed['message']['SERVER_INFO']['ip'] != '' 104 | elif response.status_code == 422: 105 | # Battlelog responded with 422, explicitly indicating that the server was not found 106 | found = False 107 | else: 108 | # Battlelog responded with some other status code (rate limit 403 for example) 109 | check_ok = False 110 | # Reset requests since last ok counter if server returned info/not found, 111 | # else increase counter and sleep 112 | if check_ok: 113 | checks_since_last_ok = 0 114 | else: 115 | checks_since_last_ok += 1 116 | except requests.exceptions.RequestException as e: 117 | logging.debug(e) 118 | logging.error(f'Failed to fetch server {server.uid} for expiration check') 119 | check_ok = False 120 | 121 | return check_ok, found, checks_since_last_ok 122 | 123 | def build_server_links( 124 | self, 125 | uid: str, 126 | ip: Optional[str] = None, 127 | port: Optional[int] = None 128 | ) -> Union[List[WebLink], WebLink]: 129 | links = [WEB_LINK_TEMPLATES['battlelog'].render(self.game, self.platform, uid)] 130 | 131 | # Gametools uses the guid as the "gameid" for BF3 and BFH, so we can add links for those 132 | # (BF4 uses the real gameid, so we need to handle those links separately) 133 | if self.game in ['bf3', 'bfh']: 134 | links.append(WEB_LINK_TEMPLATES['gametools'].render(self.game, self.platform, uid)) 135 | 136 | return links 137 | 138 | def build_port_to_try_list(self, game_port: int) -> List[int]: 139 | return [ 140 | 47200, # default query port 141 | game_port + 22000, # default port offset (mirror gamedig behavior) 142 | game_port, # some servers use the same port for game + query 143 | game_port + 100, # nitrado 144 | game_port + 5, # several hosters 145 | game_port + 1, 146 | 48888, # gamed 147 | game_port + 6, # i3D 148 | game_port + 8, # i3D 149 | game_port + 10, # Servers.com/4netplayers 150 | game_port + 15, # i3D 151 | game_port + 50, 152 | game_port - 5, # i3D 153 | game_port - 15, # i3D 154 | game_port - 23000, # G4G.pl 155 | randint(47190, 47210), # random port around default query port 156 | 25200 + randint(0, 22000), # random port between default game port and default query port 157 | randint(game_port - 10, game_port + 10), # random port around default game port 158 | game_port + randint(0, 22000), # random port between game port and game port + default offset 159 | ] 160 | 161 | def get_validator(self) -> Callable[[FrostbiteServer, dict], bool]: 162 | def validator(server: FrostbiteServer, parsed_result: dict) -> bool: 163 | return parsed_result.get('connect') == f'{server.ip}:{server.game_port}' 164 | 165 | return validator 166 | -------------------------------------------------------------------------------- /GameserverLister/listers/gamespy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | from typing import List, Tuple, Optional, Union 4 | 5 | from GameserverLister.common.helpers import is_valid_public_ip, is_valid_port, is_server_for_gamespy_game 6 | from GameserverLister.common.servers import ClassicServer 7 | from GameserverLister.common.types import GamespyGame, GamespyPrincipal, GamespyGameConfig, GamespyPlatform 8 | from GameserverLister.common.weblinks import WebLink, WEB_LINK_TEMPLATES 9 | from GameserverLister.games.gamespy import GAMESPY_GAME_CONFIGS 10 | from GameserverLister.listers.common import ServerLister 11 | from GameserverLister.providers import GamespyProvider 12 | 13 | 14 | class GamespyServerLister(ServerLister): 15 | game: GamespyGame 16 | platform: GamespyPlatform 17 | servers: List[ClassicServer] 18 | principal: GamespyPrincipal 19 | provider: GamespyProvider 20 | config: GamespyGameConfig 21 | gslist_bin_path: str 22 | gslist_filter: str 23 | gslist_super_query: bool 24 | gslist_timeout: int 25 | verify: bool 26 | add_game_port: bool 27 | 28 | def __init__( 29 | self, 30 | game: GamespyGame, 31 | principal: GamespyPrincipal, 32 | provider: GamespyProvider, 33 | gslist_bin_path: str, 34 | gslist_filter: str, 35 | gslist_super_query: bool, 36 | gslist_timeout: int, 37 | verify: bool, 38 | add_game_port: bool, 39 | expire: bool, 40 | expired_ttl: float, 41 | recover: bool, 42 | add_links: bool, 43 | txt: bool, 44 | list_dir: str 45 | ): 46 | super().__init__( 47 | game, 48 | GamespyPlatform.PC, 49 | ClassicServer, 50 | expire, 51 | expired_ttl, 52 | recover, 53 | add_links, 54 | txt, 55 | list_dir 56 | ) 57 | self.principal = principal 58 | self.provider = provider 59 | self.config = GAMESPY_GAME_CONFIGS[self.game] 60 | self.gslist_bin_path = gslist_bin_path 61 | self.gslist_filter = gslist_filter 62 | self.gslist_super_query = gslist_super_query 63 | self.gslist_timeout = gslist_timeout 64 | self.verify = verify 65 | self.add_game_port = add_game_port 66 | 67 | def update_server_list(self): 68 | found_servers = [] 69 | for server in self.get_servers(): 70 | if not is_valid_public_ip(server.ip) or not is_valid_port(server.query_port): 71 | logging.warning(f'Ignoring invalid server entry ({server.ip}:{server.query_port})') 72 | continue 73 | 74 | if self.verify or self.add_links or self.add_game_port: 75 | # Attempt to query server in order to verify is a server for the current game 76 | # (some principals return servers for other games than what we queried) 77 | logging.debug(f'Querying server {server.uid}/{server.ip}:{server.query_port}') 78 | responded, query_response = self.query_server(server) 79 | logging.debug(f'Query {"was successful" if responded else "did not receive a response"}') 80 | 81 | if responded: 82 | if self.verify and not is_server_for_gamespy_game(self.game, self.config.game_name, query_response): 83 | logging.warning(f'Server does not seem to be a {self.game} server, ignoring it ' 84 | f'({server.ip}:{server.query_port})') 85 | continue 86 | 87 | if self.add_links or self.add_game_port: 88 | if query_response.get('hostport', '').isnumeric(): 89 | game_port = int(query_response['hostport']) 90 | if self.add_links: 91 | server.add_links(self.build_server_links( 92 | server.uid, 93 | server.ip, 94 | game_port 95 | )) 96 | if self.add_game_port: 97 | server.game_port = game_port 98 | elif 'hostport' in query_response: 99 | logging.warning(f'Server returned an invalid hostport (\'{query_response["hostport"]}\', ' 100 | f'not adding links/game port ({server.ip}:{server.query_port})') 101 | 102 | found_servers.append(server) 103 | 104 | self.add_update_servers(found_servers) 105 | 106 | def get_servers(self) -> List[ClassicServer]: 107 | return self.provider.list( 108 | self.principal, 109 | self.game, 110 | self.platform, 111 | filter=self.gslist_filter, 112 | super_query=self.gslist_super_query, 113 | cwd=self.server_list_dir_path, 114 | timeout=self.gslist_timeout 115 | ) 116 | 117 | def check_if_server_still_exists(self, server: ClassicServer, checks_since_last_ok: int) -> Tuple[bool, bool, int]: 118 | # Since we query the server directly, there is no way of handling HTTP server errors differently then 119 | # actually failed checks, so even if the query fails, we have to treat it as "check ok" 120 | check_ok = True 121 | responded, query_response = self.query_server(server) 122 | # Treat as server for game if verify is turned off 123 | server_for_game = not self.verify or is_server_for_gamespy_game(self.game, self.config.game_name, query_response) 124 | found = responded and server_for_game 125 | if responded and not server_for_game: 126 | logging.warning(f'Server {server.uid} does not seem to be a {self.game} server, treating as not found') 127 | 128 | return check_ok, found, checks_since_last_ok 129 | 130 | def build_server_links( 131 | self, 132 | uid: str, 133 | ip: Optional[str] = None, 134 | port: Optional[int] = None 135 | ) -> Union[List[WebLink], WebLink]: 136 | template_refs = self.config.link_template_refs if self.config.link_template_refs is not None else {} 137 | # Add principal-scoped links first, then add game-scoped links 138 | templates = [ 139 | *[WEB_LINK_TEMPLATES.get(ref) for ref in template_refs.get(self.principal, [])], 140 | *[WEB_LINK_TEMPLATES.get(ref) for ref in template_refs.get('_any', [])] 141 | ] 142 | 143 | links = [] 144 | for template in templates: 145 | links.append(template.render(self.game, self.platform, uid, ip=ip, port=port)) 146 | 147 | return links 148 | 149 | def query_server(self, server: ClassicServer) -> Tuple[bool, dict]: 150 | try: 151 | command = [self.gslist_bin_path, '-d', str(self.config.query_type), server.ip, str(server.query_port), '-0'] 152 | # Timeout should never fire since gslist uses about a three-second timeout for the query 153 | gslist_result = subprocess.run(command, capture_output=True, timeout=self.gslist_timeout) 154 | 155 | # gslist will simply return an empty byte string (b'') if the server could not be queried 156 | if gslist_result.stdout != b'' and b'error' not in gslist_result.stderr.lower(): 157 | parsed = {} 158 | for line in gslist_result.stdout.decode('latin1').strip('\n').split('\n'): 159 | elements = line.lstrip().split(' ', 1) 160 | if len(elements) != 2: 161 | continue 162 | key, value = elements 163 | parsed[key.lower()] = value 164 | return True, parsed 165 | except subprocess.TimeoutExpired as e: 166 | logging.debug(e) 167 | logging.error(f'Failed to query server {server.uid} for expiration check') 168 | 169 | return False, {} 170 | -------------------------------------------------------------------------------- /GameserverLister/games/quake3.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from typing import Dict 3 | 4 | import pyq3serverlist 5 | 6 | from GameserverLister.common.types import Quake3Game 7 | 8 | QUAKE3_CONFIGS: Dict[Quake3Game, dict] = { 9 | Quake3Game.CoD: { 10 | 'protocols': [ 11 | 1, # version 1.1 12 | 2, # version 1.2 13 | 4, # version 1.3 14 | 5, # version 1.4 15 | 6, # version 1.5 16 | ], 17 | 'keywords': '', 18 | 'servers': { 19 | 'activision': { 20 | 'hostname': 'codmaster.activision.com', 21 | 'port': 20510 22 | }, 23 | 'cod.pm': { 24 | 'hostname': 'master.cod.pm', 25 | 'port': 20510 26 | }, 27 | 'comu-mvzg.com': { 28 | 'hostname': 'codmaster.comu-mvzg.com', 29 | 'port': 20510 30 | } 31 | }, 32 | 'linkTemplateRefs': { 33 | 'activision': ['cod.pm'] 34 | } 35 | }, 36 | Quake3Game.CoDUO: { 37 | 'protocols': [ 38 | 21, # version 1.41 39 | 22, # version 1.51 40 | ], 41 | 'servers': { 42 | 'activision': { 43 | 'hostname': 'coduomaster.activision.com', 44 | 'port': 20610 45 | } 46 | }, 47 | 'linkTemplateRefs': { 48 | 'activision': ['cod.pm'] 49 | } 50 | }, 51 | Quake3Game.CoD2: { 52 | 'protocols': [ 53 | 115, # version 1.0 54 | 117, # version 1.2 55 | 118, # version 1.3 56 | ], 57 | 'servers': { 58 | 'activision': { 59 | 'hostname': 'cod2master.activision.com', 60 | 'port': 20710 61 | } 62 | }, 63 | 'linkTemplateRefs': { 64 | 'activision': ['cod.pm'] 65 | } 66 | }, 67 | Quake3Game.CoD4: { 68 | 'protocols': [ 69 | 1, # version 1.0 70 | 6, # version 1.7 71 | 7, # version 1.8 72 | ], 73 | 'servers': { 74 | 'activision': { 75 | 'hostname': 'cod4master.activision.com', 76 | 'port': 20810 77 | } 78 | }, 79 | 'linkTemplateRefs': { 80 | 'activision': ['cod.pm'] 81 | } 82 | }, 83 | Quake3Game.CoD4X: { 84 | 'protocols': [ 85 | 6 # cod4 does not support different protocols, you seem to get the same servers regardless 86 | ], 87 | 'keywords': 'full empty \x00', 88 | 'game_name': 'cod4x', 89 | 'network_protocol': socket.SOCK_STREAM, 90 | 'server_entry_prefix': b'\x00\x00\x00\x00\x04', 91 | 'servers': { 92 | 'cod4x.ovh': { 93 | 'hostname': 'cod4master.cod4x.ovh', 94 | 'port': 20810 95 | } 96 | } 97 | }, 98 | Quake3Game.Nexuiz: { 99 | 'protocols': [ 100 | 3 101 | ], 102 | 'game_name': 'Nexuiz', 103 | 'servers': { 104 | 'deathmask.net': { 105 | 'hostname': 'dpmaster.deathmask.net', 106 | 'port': 27950 107 | } 108 | }, 109 | 'linkTemplateRefs': { 110 | 'deathmask.net': ['deathmask.net-official'] 111 | } 112 | }, 113 | Quake3Game.OpenArena: { 114 | 'protocols': [ 115 | 71 116 | ], 117 | 'game_name': 'Quake3Arena', 118 | 'servers': { 119 | 'deathmask.net': { 120 | 'hostname': 'dpmaster.deathmask.net', 121 | 'port': 27950 122 | } 123 | }, 124 | 'linkTemplateRefs': { 125 | 'deathmask.net': ['deathmask.net-official', 'arena.sh'] 126 | } 127 | }, 128 | Quake3Game.Q3Rally: { 129 | 'protocols': [ 130 | 71 131 | ], 132 | 'game_name': 'Q3Rally', 133 | 'servers': { 134 | 'deathmask.net': { 135 | 'hostname': 'dpmaster.deathmask.net', 136 | 'port': 27950 137 | } 138 | }, 139 | 'linkTemplateRefs': { 140 | 'deathmask.net': ['deathmask.net-official'] 141 | } 142 | }, 143 | Quake3Game.Quake: { 144 | 'protocols': [ 145 | 3 146 | ], 147 | 'game_name': 'DarkPlaces-Quake', 148 | 'servers': { 149 | 'deathmask.net': { 150 | 'hostname': 'dpmaster.deathmask.net', 151 | 'port': 27950 152 | } 153 | }, 154 | 'linkTemplateRefs': { 155 | 'deathmask.net': ['deathmask.net-official'] 156 | } 157 | }, 158 | Quake3Game.Quake3Arena: { 159 | 'protocols': [ 160 | 68 161 | ], 162 | 'servers': { 163 | 'quake3arena.com': { 164 | 'hostname': 'master.quake3arena.com', 165 | 'port': 27950, 166 | 'reader': pyq3serverlist.TimeoutReader 167 | }, 168 | 'urbanterror.info-1': { 169 | 'hostname': 'master.urbanterror.info', 170 | 'port': 27900 171 | }, 172 | 'urbanterror.info-2': { 173 | 'hostname': 'master2.urbanterror.info', 174 | 'port': 27900 175 | }, 176 | 'excessiveplus.net': { 177 | 'hostname': 'master0.excessiveplus.net', 178 | 'port': 27950 179 | }, 180 | 'ioquake3.org': { 181 | 'hostname': 'master.ioquake3.org', 182 | 'port': 27950 183 | }, 184 | 'huxxer.de': { 185 | 'hostname': 'master.huxxer.de', 186 | 'port': 27950 187 | }, 188 | 'maverickservers.com': { 189 | 'hostname': 'master.maverickservers.com', 190 | 'port': 27950 191 | }, 192 | 'deathmask.net': { 193 | 'hostname': 'dpmaster.deathmask.net', 194 | 'port': 27950 195 | } 196 | } 197 | }, 198 | Quake3Game.RTCW: { 199 | 'protocols': [ 200 | 57 201 | ], 202 | 'servers': { 203 | 'idsoftware': { 204 | 'hostname': 'wolfmaster.idsoftware.com', 205 | 'port': 27950, 206 | 'reader': pyq3serverlist.TimeoutReader 207 | } 208 | } 209 | }, 210 | Quake3Game.SOF2: { 211 | 'protocols': [ 212 | 2001, # version sof2mp-1.02t (demo) 213 | 2002, # version sof2mp-1.00 (full) 214 | 2004, # version sof2mp-1.02 ("gold") 215 | ], 216 | 'servers': { 217 | 'ravensoft': { 218 | 'hostname': 'master.sof2.ravensoft.com', 219 | 'port': 20110 220 | } 221 | } 222 | }, 223 | Quake3Game.SWJKJA: { 224 | 'protocols': [ 225 | 25, # version 1.00 226 | 26, # version 1.01 227 | ], 228 | 'servers': { 229 | 'ravensoft': { 230 | 'hostname': 'masterjk3.ravensoft.com', 231 | 'port': 29060 232 | }, 233 | 'jkhub.org': { 234 | 'hostname': 'master.jkhub.org', 235 | 'port': 29060 236 | } 237 | } 238 | }, 239 | Quake3Game.SWJKJO: { 240 | 'protocols': [ 241 | 15, # version 1.02 242 | 16, # version 1.04 243 | ], 244 | 'servers': { 245 | 'ravensoft': { 246 | 'hostname': 'masterjk2.ravensoft.com', 247 | 'port': 28060 248 | }, 249 | 'jkhub.org': { 250 | 'hostname': 'master.jkhub.org', 251 | 'port': 28060 252 | } 253 | } 254 | }, 255 | Quake3Game.Tremulous: { 256 | 'protocols': [ 257 | 69 258 | ], 259 | 'servers': { 260 | 'tremulous.net': { 261 | 'hostname': 'master.tremulous.net', 262 | 'port': 30710, 263 | 'reader': pyq3serverlist.TimeoutReader 264 | } 265 | }, 266 | 'linkTemplateRefs': { 267 | 'tremulous.net': ['deathmask.net-unofficial'] 268 | } 269 | }, 270 | Quake3Game.UrbanTerror: { 271 | 'protocols': [ 272 | 68 273 | ], 274 | 'servers': { 275 | 'urbanterror.info': { 276 | 'hostname': 'master.urbanterror.info', 277 | 'port': 27900 278 | } 279 | }, 280 | 'linkTemplateRefs': { 281 | 'urbanterror.info': ['deathmask.net-unofficial'] 282 | } 283 | }, 284 | Quake3Game.Warfork: { 285 | 'protocols': [ 286 | 23 287 | ], 288 | 'game_name': 'Warfork', 289 | 'servers': { 290 | 'deathmask.net': { 291 | 'hostname': 'dpmaster.deathmask.net', 292 | 'port': 27950 293 | } 294 | }, 295 | 'linkTemplateRefs': { 296 | 'deathmask.net': ['deathmask.net-official'] 297 | } 298 | }, 299 | Quake3Game.Warsow: { 300 | 'protocols': [ 301 | 22 302 | ], 303 | 'game_name': 'Warsow', 304 | 'servers': { 305 | 'deathmask.net': { 306 | 'hostname': 'dpmaster.deathmask.net', 307 | 'port': 27950 308 | } 309 | }, 310 | 'linkTemplateRefs': { 311 | 'deathmask.net': ['deathmask.net-official', 'arena.sh'] 312 | } 313 | }, 314 | Quake3Game.WolfensteinET: { 315 | 'protocols': [ 316 | 84 317 | ], 318 | 'servers': { 319 | 'idsoftware': { 320 | 'hostname': 'etmaster.idsoftware.com', 321 | 'port': 27950, 322 | 'reader': pyq3serverlist.TimeoutReader 323 | }, 324 | 'etlegacy.com': { 325 | 'hostname': 'master.etlegacy.com', 326 | 'port': 27950 327 | }, 328 | 'etmaster.net': { 329 | 'hostname': 'master0.etmaster.net', 330 | 'port': 27950, 331 | 'reader': pyq3serverlist.TimeoutReader 332 | } 333 | } 334 | }, 335 | Quake3Game.Xonotic: { 336 | 'protocols': [ 337 | 3 338 | ], 339 | 'game_name': 'Xonotic', 340 | 'servers': { 341 | 'deathmask.net': { 342 | 'hostname': 'dpmaster.deathmask.net', 343 | 'port': 27950 344 | }, 345 | 'tchr.no': { 346 | 'hostname': 'dpmaster.tchr.no', 347 | 'port': 27950 348 | } 349 | }, 350 | 'linkTemplateRefs': { 351 | 'deathmask.net': ['deathmask.net-official', 'arena.sh'] 352 | } 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /GameserverLister/games/gamespy.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from GameserverLister.common.types import GamespyPrincipal, GamespyPrincipalConfig, GamespyGame, GamespyGameConfig 4 | 5 | GAMESPY_PRINCIPAL_CONFIGS: Dict[GamespyPrincipal, GamespyPrincipalConfig] = { 6 | GamespyPrincipal.TripleThreeNetworks_com_1: GamespyPrincipalConfig( 7 | hostname='master.333networks.com' 8 | ), 9 | GamespyPrincipal.TripleThreeNetworks_com_2: GamespyPrincipalConfig( 10 | hostname='rhea.333networks.com' 11 | ), 12 | GamespyPrincipal.B2BF2_net: GamespyPrincipalConfig( 13 | hostname='gsapi.b2bf2.net' 14 | ), 15 | GamespyPrincipal.BF1942_org: GamespyPrincipalConfig( 16 | hostname='master.bf1942.org' 17 | ), 18 | GamespyPrincipal.BF2Hub_com: GamespyPrincipalConfig( 19 | hostname='{0}.master.bf2hub.com' 20 | ), 21 | GamespyPrincipal.Crymp_org: GamespyPrincipalConfig( 22 | hostname='crymp.org' 23 | ), 24 | GamespyPrincipal.Errorist_eu: GamespyPrincipalConfig( 25 | hostname='master.errorist.eu' 26 | ), 27 | GamespyPrincipal.FH2_dev: GamespyPrincipalConfig( 28 | hostname='ms.fh2.dev' 29 | ), 30 | GamespyPrincipal.Jedi95_us: GamespyPrincipalConfig( 31 | hostname='master.g.jedi95.us' 32 | ), 33 | GamespyPrincipal.Newbiesplayground_net: GamespyPrincipalConfig( 34 | hostname='master.newbiesplayground.net' 35 | ), 36 | GamespyPrincipal.NightfirePC_com: GamespyPrincipalConfig( 37 | hostname='master.nightfirepc.com' # This (currently) just points at openspy 38 | ), 39 | GamespyPrincipal.NovGames_ru: GamespyPrincipalConfig( 40 | hostname='2142.novgames.ru' 41 | ), 42 | GamespyPrincipal.OldUnreal_com_1: GamespyPrincipalConfig( 43 | hostname='master.oldunreal.com' 44 | ), 45 | GamespyPrincipal.OldUnreal_com_2: GamespyPrincipalConfig( 46 | hostname='master2.oldunreal.com' 47 | ), 48 | GamespyPrincipal.OpenSpy_net: GamespyPrincipalConfig( 49 | hostname='{0}.master.openspy.net' 50 | ), 51 | GamespyPrincipal.Play2142_ru: GamespyPrincipalConfig( 52 | hostname='{0}.ms.play2142.ru' 53 | ), 54 | GamespyPrincipal.PlayBF2_ru: GamespyPrincipalConfig( 55 | hostname='{0}.ms.playbf2.ru' 56 | ), 57 | GamespyPrincipal.Qtracker_com: GamespyPrincipalConfig( 58 | hostname='master2.qtracker.com' 59 | ), 60 | GamespyPrincipal.SWAT4Stats_com: GamespyPrincipalConfig( 61 | hostname='master.swat4stats.com' 62 | ), 63 | GamespyPrincipal.Vietcong_tk: GamespyPrincipalConfig( 64 | hostname='51.68.46.73' # From http://vcfiles.free.fr/r.php?r=files/patch_serverlist.bat, no hostname available 65 | ), 66 | GamespyPrincipal.Vietcong1_eu: GamespyPrincipalConfig( 67 | hostname='vietcong1.eu' 68 | ) 69 | } 70 | GAMESPY_GAME_CONFIGS: Dict[GamespyGame, GamespyGameConfig] = { 71 | GamespyGame.BF1942: GamespyGameConfig( 72 | game_name='bfield1942', 73 | game_key='HpWx9z', 74 | enc_type=2, 75 | query_type=0, 76 | port=28900, 77 | principals=[ 78 | GamespyPrincipal.BF1942_org, 79 | GamespyPrincipal.OpenSpy_net, 80 | GamespyPrincipal.Qtracker_com 81 | ] 82 | ), 83 | GamespyGame.BFVietnam: GamespyGameConfig( 84 | game_name='bfvietnam', 85 | game_key='h2P9dJ', 86 | enc_type=2, 87 | query_type=0, 88 | port=28900, 89 | principals=[ 90 | GamespyPrincipal.OpenSpy_net, 91 | GamespyPrincipal.Qtracker_com 92 | ] 93 | ), 94 | GamespyGame.BF2: GamespyGameConfig( 95 | game_name='battlefield2', 96 | game_key='hW6m9a', 97 | enc_type=-1, 98 | query_type=8, 99 | port=28910, 100 | principals=[ 101 | GamespyPrincipal.B2BF2_net, 102 | GamespyPrincipal.BF2Hub_com, 103 | GamespyPrincipal.OpenSpy_net, 104 | GamespyPrincipal.PlayBF2_ru 105 | ], 106 | link_template_refs={ 107 | '_any': ['bf2.tv'], 108 | GamespyPrincipal.B2BF2_net: ['b2bf2'], 109 | GamespyPrincipal.BF2Hub_com: ['bf2hub'] 110 | }, 111 | # BF2Hub recently stopped responding to "plain" queries, making info queries a requirement 112 | # Luckily, all other providers are compatible with info queries 113 | info_query='\\hostname' 114 | ), 115 | GamespyGame.FH2: GamespyGameConfig( 116 | game_name='battlefield2', 117 | game_key='hW6m9a', 118 | enc_type=-1, 119 | query_type=8, 120 | port=28910, 121 | principals=[ 122 | GamespyPrincipal.FH2_dev 123 | ], 124 | info_query='\\hostname' 125 | ), 126 | GamespyGame.BF2142: GamespyGameConfig( 127 | game_name='stella', 128 | game_key='M8o1Qw', 129 | enc_type=-1, 130 | query_type=8, 131 | port=28910, 132 | principals=[ 133 | GamespyPrincipal.NovGames_ru, 134 | GamespyPrincipal.OpenSpy_net, 135 | GamespyPrincipal.Play2142_ru 136 | ] 137 | ), 138 | GamespyGame.Crysis: GamespyGameConfig( 139 | game_name='crysis', 140 | game_key='ZvZDcL', 141 | enc_type=-1, 142 | query_type=8, 143 | port=28910, 144 | principals=[ 145 | GamespyPrincipal.Crymp_org 146 | ] 147 | ), 148 | GamespyGame.CrysisWars: GamespyGameConfig( 149 | game_name='crysiswars', 150 | game_key='zKbZiM', 151 | enc_type=-1, 152 | query_type=8, 153 | port=28910, 154 | principals=[ 155 | GamespyPrincipal.Jedi95_us 156 | ] 157 | ), 158 | GamespyGame.DeusEx: GamespyGameConfig( 159 | game_name='deusex', 160 | game_key='Av3M99', 161 | enc_type=0, 162 | query_type=0, 163 | port=28900, 164 | principals=[ 165 | GamespyPrincipal.TripleThreeNetworks_com_1, 166 | GamespyPrincipal.Errorist_eu, 167 | GamespyPrincipal.Newbiesplayground_net, 168 | GamespyPrincipal.OldUnreal_com_1 169 | ] 170 | ), 171 | GamespyGame.DukeNukemForever: GamespyGameConfig( 172 | game_name='dnf', 173 | game_key=' ', 174 | enc_type=0, 175 | query_type=0, 176 | port=28900, 177 | principals=[ 178 | GamespyPrincipal.TripleThreeNetworks_com_1 179 | ] 180 | ), 181 | GamespyGame.JBNightfire: GamespyGameConfig( 182 | game_name='jbnightfire', 183 | game_key='S9j3L2', 184 | enc_type=-1, 185 | query_type=0, 186 | port=28910, 187 | principals=[ 188 | GamespyPrincipal.OpenSpy_net, 189 | GamespyPrincipal.NightfirePC_com 190 | ] 191 | ), 192 | GamespyGame.Paraworld: GamespyGameConfig( 193 | game_name='paraworld', 194 | game_key='EUZpQF', 195 | enc_type=-1, 196 | query_type=8, 197 | port=28910, 198 | principals=[ 199 | GamespyPrincipal.OpenSpy_net 200 | ] 201 | ), 202 | GamespyGame.Postal2: GamespyGameConfig( 203 | game_name='postal2', 204 | game_key='yw3R9c', 205 | enc_type=0, 206 | query_type=0, 207 | port=28900, 208 | principals=[ 209 | GamespyPrincipal.TripleThreeNetworks_com_1 210 | ] 211 | ), 212 | GamespyGame.Rune: GamespyGameConfig( 213 | game_name='rune', 214 | game_key='BnA4a3', 215 | enc_type=0, 216 | query_type=0, 217 | port=28900, 218 | principals=[ 219 | GamespyPrincipal.TripleThreeNetworks_com_1, 220 | GamespyPrincipal.Errorist_eu, 221 | GamespyPrincipal.Newbiesplayground_net, 222 | GamespyPrincipal.OldUnreal_com_1 223 | ] 224 | ), 225 | GamespyGame.SeriousSam: GamespyGameConfig( 226 | game_name='serioussam', 227 | game_key='AKbna4', 228 | enc_type=0, 229 | query_type=0, 230 | port=28900, 231 | principals=[ 232 | GamespyPrincipal.TripleThreeNetworks_com_1, 233 | GamespyPrincipal.Errorist_eu, 234 | GamespyPrincipal.Newbiesplayground_net, 235 | GamespyPrincipal.OldUnreal_com_1 236 | ] 237 | ), 238 | GamespyGame.SeriousSamSE: GamespyGameConfig( 239 | game_name='serioussamse', 240 | game_key='AKbna4', 241 | enc_type=0, 242 | query_type=0, 243 | port=28900, 244 | principals=[ 245 | GamespyPrincipal.TripleThreeNetworks_com_1, 246 | GamespyPrincipal.Errorist_eu, 247 | GamespyPrincipal.Newbiesplayground_net, 248 | GamespyPrincipal.OldUnreal_com_1 249 | ] 250 | ), 251 | GamespyGame.SWAT4: GamespyGameConfig( 252 | game_name='swat4', 253 | game_key='tG3j8c', 254 | enc_type=-1, 255 | query_type=0, 256 | port=28910, 257 | principals=[ 258 | GamespyPrincipal.SWAT4Stats_com 259 | ], 260 | info_query='\\hostname', 261 | link_template_refs={ 262 | GamespyPrincipal.SWAT4Stats_com: ['swat4stats.com'] 263 | } 264 | ), 265 | GamespyGame.Unreal: GamespyGameConfig( 266 | game_name='unreal', 267 | game_key='DAncRK', 268 | enc_type=0, 269 | query_type=0, 270 | port=28900, 271 | principals=[ 272 | GamespyPrincipal.TripleThreeNetworks_com_1, 273 | GamespyPrincipal.OldUnreal_com_1, 274 | GamespyPrincipal.Errorist_eu, 275 | GamespyPrincipal.OpenSpy_net, 276 | GamespyPrincipal.Qtracker_com 277 | ] 278 | ), 279 | GamespyGame.UT: GamespyGameConfig( 280 | game_name='ut', 281 | game_key='Z5Nfb0', 282 | enc_type=0, 283 | query_type=0, 284 | port=28900, 285 | principals=[ 286 | GamespyPrincipal.TripleThreeNetworks_com_1, 287 | GamespyPrincipal.OldUnreal_com_1, 288 | GamespyPrincipal.Errorist_eu, 289 | GamespyPrincipal.OpenSpy_net, 290 | GamespyPrincipal.Qtracker_com 291 | ] 292 | ), 293 | GamespyGame.UT3: GamespyGameConfig( 294 | game_name='ut3pc', 295 | game_key='nT2Mtz', 296 | enc_type=-1, 297 | query_type=11, 298 | port=28910, 299 | principals=[ 300 | GamespyPrincipal.OpenSpy_net 301 | ] 302 | ), 303 | GamespyGame.Vietcong: GamespyGameConfig( 304 | game_name='vietcong', 305 | game_key='bq98mE', 306 | enc_type=2, 307 | query_type=0, 308 | port=28900, 309 | principals=[ 310 | GamespyPrincipal.Vietcong_tk, 311 | GamespyPrincipal.Vietcong1_eu, 312 | GamespyPrincipal.Qtracker_com 313 | ] 314 | ), 315 | GamespyGame.Vietcong2: GamespyGameConfig( 316 | game_name='vietcong2', 317 | game_key='zX2pq6', 318 | enc_type=-1, 319 | query_type=8, 320 | port=28910, 321 | principals=[ 322 | GamespyPrincipal.OpenSpy_net 323 | ] 324 | ), 325 | GamespyGame.WheelOfTime: GamespyGameConfig( 326 | game_name='wot', 327 | game_key='RSSSpA', 328 | enc_type=0, 329 | query_type=0, 330 | port=28900, 331 | principals=[ 332 | GamespyPrincipal.TripleThreeNetworks_com_1, 333 | GamespyPrincipal.Errorist_eu, 334 | GamespyPrincipal.Newbiesplayground_net, 335 | GamespyPrincipal.OldUnreal_com_1 336 | ] 337 | ), 338 | } 339 | -------------------------------------------------------------------------------- /GameserverLister/listers/common.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import sys 5 | import time 6 | from datetime import datetime, timedelta 7 | from random import shuffle 8 | from typing import Type, List, Tuple, Optional, Union, Callable 9 | 10 | import gevent 11 | import requests 12 | from gevent.pool import Pool 13 | 14 | from GameserverLister.common.helpers import is_valid_port, find_query_port 15 | from GameserverLister.common.servers import Server, ObjectJSONEncoder, FrostbiteServer 16 | from GameserverLister.common.types import Game, Platform 17 | from GameserverLister.common.weblinks import WebLink 18 | 19 | 20 | class ServerLister: 21 | game: Game 22 | platform: Platform 23 | server_list_dir_path: str 24 | server_list_file_path: str 25 | expire: bool 26 | expired_ttl: float 27 | recover: bool 28 | add_links: bool 29 | txt: bool 30 | ensure_ascii: bool 31 | server_class: Type[Server] 32 | servers: List[Server] 33 | 34 | session: requests.Session 35 | request_timeout: float 36 | 37 | def __init__( 38 | self, 39 | game: Game, 40 | platform: Platform, 41 | server_class: Type[Server], 42 | expire: bool, 43 | expired_ttl: float, 44 | recover: bool, 45 | add_links: bool, 46 | txt: bool, 47 | list_dir: str, 48 | request_timeout: float = 5.0 49 | ): 50 | self.game = game 51 | self.platform = platform 52 | self.server_list_dir_path = os.path.realpath(list_dir) 53 | self.server_list_file_path = self.build_server_list_file_path('json') 54 | 55 | self.expire = expire 56 | self.expired_ttl = expired_ttl 57 | self.recover = recover 58 | self.add_links = add_links 59 | self.txt = txt 60 | 61 | self.ensure_ascii = True 62 | self.server_class = server_class 63 | self.servers = [] 64 | 65 | # Init session 66 | self.session = requests.session() 67 | self.request_timeout = request_timeout 68 | 69 | # Create list dir if it does not exist 70 | if not os.path.isdir(self.server_list_dir_path): 71 | try: 72 | os.mkdir(self.server_list_dir_path) 73 | except IOError as e: 74 | logging.debug(e) 75 | logging.error(f'Failed to create missing server list directory at {self.server_list_dir_path}') 76 | sys.exit(1) 77 | 78 | # Init server list with servers from existing list or empty one 79 | if os.path.isfile(self.server_list_file_path): 80 | try: 81 | with open(self.server_list_file_path, 'r') as serverListFile: 82 | logging.info('Loading existing server list') 83 | self.servers = json.load(serverListFile, object_hook=self.server_class.load) 84 | except IOError as e: 85 | logging.debug(e) 86 | logging.error('Failed to read existing server list file') 87 | sys.exit(1) 88 | except json.decoder.JSONDecodeError as e: 89 | logging.debug(e) 90 | logging.error('Failed to parse existing server list file contents') 91 | sys.exit(1) 92 | 93 | def update_server_list(self): 94 | pass 95 | 96 | def add_update_servers(self, found_servers: List[Server]): 97 | # Add/update found servers to/in known servers 98 | logging.info(f'Updating server list with {len(found_servers)} found servers') 99 | for found_server in found_servers: 100 | known_server_uids = [s.uid for s in self.servers] 101 | # Update existing server entry or add new one 102 | if found_server.uid in known_server_uids: 103 | logging.debug(f'Found server {found_server.uid} already known, updating') 104 | index = known_server_uids.index(found_server.uid) 105 | self.servers[index].update(found_server) 106 | self.servers[index].trim(self.expired_ttl) 107 | else: 108 | logging.debug(f'Found server {found_server.uid} is new, adding') 109 | # Add new server entry 110 | self.servers.append(found_server) 111 | 112 | def remove_expired_servers(self) -> tuple: 113 | # Skip removal if expiration is disabled 114 | if not self.expire: 115 | logging.info('Skipping expiration ttl check') 116 | return 0, 0 117 | 118 | # Iterate over copy of server list and remove any expired servers from the (actual) server list 119 | logging.info(f'Checking expiration ttl for {len(self.servers)} servers') 120 | checks_since_last_ok = 0 121 | expired_servers_removed = 0 122 | expired_servers_recovered = 0 123 | for server in self.servers[:]: 124 | expired = datetime.now().astimezone() > server.last_seen_at + timedelta(hours=self.expired_ttl) 125 | if expired and self.recover: 126 | # Attempt to recover expired server by contacting/accessing it directly 127 | time.sleep(self.get_backoff_timeout(checks_since_last_ok)) 128 | # Check if server can be accessed directly 129 | check_ok, found, checks_since_last_ok = self.check_if_server_still_exists( 130 | server, checks_since_last_ok 131 | ) 132 | 133 | # Remove server if request was sent successfully but server was not found 134 | if check_ok and not found: 135 | logging.debug(f'Server {server.uid} has not been seen in ' 136 | f'{self.expired_ttl} hours and could not be recovered, removing it') 137 | self.servers.remove(server) 138 | expired_servers_removed += 1 139 | elif check_ok and found: 140 | logging.debug(f'Server {server.uid} did not appear in list but is still online, ' 141 | f'updating last seen at') 142 | index = self.servers.index(server) 143 | self.servers[index].last_seen_at = datetime.now().astimezone() 144 | self.servers[index].trim(self.expired_ttl) 145 | 146 | expired_servers_recovered += 1 147 | elif expired: 148 | logging.debug(f'Server {server.uid} has not been seen in ' 149 | f'{self.expired_ttl} hours, removing it') 150 | self.servers.remove(server) 151 | expired_servers_removed += 1 152 | 153 | return expired_servers_removed, expired_servers_recovered 154 | 155 | def check_if_server_still_exists(self, server: Server, checks_since_last_ok: int) -> Tuple[bool, bool, int]: 156 | pass 157 | 158 | def build_server_links( 159 | self, 160 | uid: str, 161 | ip: Optional[str] = None, 162 | port: Optional[int] = None 163 | ) -> Union[List[WebLink], WebLink]: 164 | # Default to no links 165 | return [] 166 | 167 | def get_backoff_timeout(self, checks_since_last_ok: int) -> int: 168 | # Default to no backoff 169 | return 0 170 | 171 | def build_server_list_file_path(self, extension: str) -> str: 172 | return os.path.join(self.server_list_dir_path, f'{self.game}-servers-{self.platform}.{extension}') 173 | 174 | def write_to_file(self): 175 | logging.info(f'Writing {len(self.servers)} servers to output file') 176 | with open(self.server_list_file_path, 'w') as output_file: 177 | json.dump(self.servers, output_file, indent=2, ensure_ascii=self.ensure_ascii, cls=ObjectJSONEncoder) 178 | 179 | if self.txt: 180 | txt_file_path = self.build_server_list_file_path('txt') 181 | with open(txt_file_path, 'w') as txt_file: 182 | txt_file.writelines('\n'.join([s.txt() for s in self.servers])) 183 | 184 | 185 | class FrostbiteServerLister(ServerLister): 186 | servers: List[FrostbiteServer] 187 | 188 | def __init__( 189 | self, 190 | game: Game, 191 | platform: Platform, 192 | server_class: Type[Server], 193 | expire: bool, 194 | expired_ttl: float, 195 | recover: bool, 196 | add_links: bool, 197 | txt: bool, 198 | list_dir: str, 199 | request_timeout: float = 5.0 200 | ): 201 | super().__init__(game, platform, server_class, expire, expired_ttl, recover, add_links, txt, list_dir, request_timeout) 202 | 203 | def find_query_ports(self, gamedig_bin_path: str, gamedig_concurrency: int, expired_ttl: float): 204 | logging.info(f'Searching query port for {len(self.servers)} servers') 205 | 206 | search_stats = { 207 | 'totalSearches': len(self.servers), 208 | 'queryPortFound': 0, 209 | 'queryPortReset': 0 210 | } 211 | pool = Pool(gamedig_concurrency) 212 | jobs = [] 213 | for server in self.servers: 214 | ports_to_try = self.build_port_to_try_list(server.game_port) 215 | 216 | # Add ports to try based on offsets used by other servers on the same ip 217 | for s in self.servers: 218 | if s.ip == server.ip and s.game_port != server.game_port and s.query_port != -1: 219 | offset = s.query_port - s.game_port 220 | ports_to_try.insert(1, server.game_port + offset) 221 | 222 | # Shuffle all but the first port (which should be the default port) 223 | shuffled = ports_to_try[1:] 224 | shuffle(shuffled) 225 | ports_to_try[1:] = shuffled 226 | 227 | # Add current query port at index 0 if valid 228 | if server.query_port != -1: 229 | ports_to_try.insert(0, server.query_port) 230 | 231 | # Remove any invalid/duplicate ports (not using set() to dedup as it changes the order of elements) 232 | ports_to_try = [ 233 | p for [i, p] in enumerate(ports_to_try) 234 | if is_valid_port(p) and i == ports_to_try.index(p) 235 | ] 236 | 237 | # Get six candidates from the selection ([current], default, random...) 238 | ports_to_try = ports_to_try[:6] 239 | 240 | jobs.append( 241 | pool.spawn(find_query_port, gamedig_bin_path, self.game, server, ports_to_try, self.get_validator()) 242 | ) 243 | # Wait for all jobs to complete 244 | gevent.joinall(jobs) 245 | for index, job in enumerate(jobs): 246 | server = self.servers[index] 247 | logging.debug(f'Checking query port search result for {server.uid}') 248 | if job.value != -1: 249 | logging.debug(f'Query port found ({job.value}), updating server') 250 | server.query_port = job.value 251 | server.last_queried_at = datetime.now().astimezone() 252 | search_stats['queryPortFound'] += 1 253 | elif server.query_port != -1 and \ 254 | (server.last_queried_at is None or 255 | datetime.now().astimezone() > server.last_queried_at + timedelta(hours=expired_ttl)): 256 | logging.debug(f'Query port expired, resetting to -1 (was {server.query_port})') 257 | server.query_port = -1 258 | # TODO Reset last queried at here? 259 | search_stats['queryPortReset'] += 1 260 | logging.info(f'Query port search stats: {search_stats}') 261 | 262 | # Function has to be public to overrideable by derived classes 263 | def build_port_to_try_list(self, game_port: int) -> list: 264 | pass 265 | 266 | def get_validator(self) -> Callable[[FrostbiteServer, dict], bool]: 267 | pass 268 | 269 | 270 | class HttpServerLister(ServerLister): 271 | page_limit: int 272 | per_page: int 273 | sleep: float 274 | max_attempts: int 275 | 276 | def __init__( 277 | self, 278 | game: Game, 279 | platform: Platform, 280 | server_class: Type[Server], 281 | page_limit: int, 282 | per_page: int, 283 | expire: bool, 284 | expired_ttl: float, 285 | recover: bool, 286 | add_links: bool, 287 | txt: bool, 288 | list_dir: str, 289 | sleep: float, 290 | max_attempts: int 291 | ): 292 | super().__init__( 293 | game, 294 | platform, 295 | server_class, 296 | expire, 297 | expired_ttl, 298 | recover, 299 | add_links, 300 | txt, 301 | list_dir, 302 | request_timeout=10 303 | ) 304 | self.page_limit = page_limit 305 | self.per_page = per_page 306 | self.sleep = sleep 307 | self.max_attempts = max_attempts 308 | 309 | def update_server_list(self): 310 | offset = 0 311 | """ 312 | The Frostbite server browsers returns tons of duplicate servers (pagination is completely broken/non-existent). 313 | You basically just a [per_page] random servers every time. Thus, there is no way of telling when to stop. 314 | As a workaround, just stop after not retrieving a new/unique server for [args.page_limit] pages 315 | """ 316 | pages_since_last_unique_server = 0 317 | attempt = 0 318 | """ 319 | Since pagination of the server list is completely broken, just get the first "page" over and over again until 320 | no servers have been found in [args.page_limit] "pages". 321 | """ 322 | found_servers = [] 323 | logging.info('Starting server list retrieval') 324 | while pages_since_last_unique_server < self.page_limit and attempt < self.max_attempts: 325 | # Sleep when requesting anything but offset 0 (use increased sleep when retrying) 326 | if offset > 0: 327 | time.sleep(pow(self.sleep, attempt + 1)) 328 | 329 | try: 330 | response = self.session.get( 331 | self.get_server_list_url(self.per_page), 332 | timeout=self.request_timeout 333 | ) 334 | except requests.exceptions.RequestException as e: 335 | logging.debug(e) 336 | logging.error(f'Request failed, retrying {attempt + 1}/{self.max_attempts}') 337 | # Count try and start over 338 | attempt += 1 339 | continue 340 | 341 | if response.status_code == 200: 342 | # Reset tries 343 | attempt = 0 344 | # Parse response 345 | parsed = response.json() 346 | server_total_before = len(found_servers) 347 | # Add all servers in response (if they are new) 348 | found_servers = self.add_page_found_servers(found_servers, parsed) 349 | if len(found_servers) == server_total_before: 350 | pages_since_last_unique_server += 1 351 | logging.info(f'Got nothing but duplicates (page: {int(offset / self.per_page)},' 352 | f' pages since last unique: {pages_since_last_unique_server})') 353 | else: 354 | logging.info(f'Got {len(found_servers) - server_total_before} new servers') 355 | # Found new unique server, reset 356 | pages_since_last_unique_server = 0 357 | offset += self.per_page 358 | else: 359 | logging.error(f'Server responded with {response.status_code}, ' 360 | f'retrying {attempt + 1}/{self.max_attempts}') 361 | attempt += 1 362 | 363 | self.add_update_servers(found_servers) 364 | 365 | def get_server_list_url(self, per_page: int) -> str: 366 | pass 367 | 368 | def add_page_found_servers(self, found_servers: List[Server], page_response_data: dict) -> List[Server]: 369 | pass 370 | 371 | def get_backoff_timeout(self, checks_since_last_ok: int) -> int: 372 | return 1 + pow(self.sleep, checks_since_last_ok % self.max_attempts) 373 | -------------------------------------------------------------------------------- /GameserverLister/common/servers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta 3 | from typing import Union, Optional, Any, List 4 | 5 | from GameserverLister.common.constants import UNIX_EPOCH_START 6 | from GameserverLister.common.weblinks import WebLink 7 | 8 | 9 | class ObjectJSONEncoder(json.JSONEncoder): 10 | def default(self, obj): 11 | return obj.dump() 12 | 13 | 14 | class Server: 15 | uid: str 16 | # Only optional because lists may still contain entries without this attribute 17 | first_seen_at: Optional[datetime] 18 | last_seen_at: datetime 19 | links: List[WebLink] 20 | 21 | def __init__( 22 | self, 23 | guid: str, 24 | links: Union[List[WebLink], WebLink], 25 | first_seen_at: Optional[datetime] = datetime.now().astimezone(), 26 | last_seen_at: datetime = datetime.now().astimezone(), 27 | ): 28 | self.uid = guid 29 | self.first_seen_at = first_seen_at 30 | self.last_seen_at = last_seen_at 31 | self.links = links if isinstance(links, list) else [links] 32 | 33 | def add_links(self, links: Union[List[WebLink], WebLink]) -> None: 34 | link_list = links if isinstance(links, list) else [links] 35 | for web_link in link_list: 36 | web_sites_self = [web_link.site for web_link in self.links] 37 | if web_link.site not in web_sites_self: 38 | self.links.append(web_link) 39 | else: 40 | index = web_sites_self.index(web_link.site) 41 | self.links[index].update(web_link) 42 | 43 | def trim(self, expired_ttl: float) -> None: 44 | """ 45 | "Trim"/remove expired attributes (no longer valid, old via status) 46 | :param expired_ttl: Number of hours attributes remain valid after being last updated 47 | :return: 48 | """ 49 | self.links = [link for link in self.links if not link.is_expired(expired_ttl)] 50 | 51 | def update(self, updated: 'Server') -> None: 52 | self.last_seen_at = updated.last_seen_at 53 | # Merge links "manually" 54 | self.add_links(updated.links) 55 | 56 | @staticmethod 57 | def load(parsed: dict) -> Union['Server', dict]: 58 | pass 59 | 60 | @staticmethod 61 | def is_json_repr(parsed: dict) -> bool: 62 | pass 63 | 64 | # Should be called "__dict__" but that confused the PyCharm debugger and 65 | # makes it impossible to inspect any instance variables 66 | # https://youtrack.jetbrains.com/issue/PY-43955 67 | def dump(self) -> dict: 68 | pass 69 | 70 | def txt(self) -> str: 71 | pass 72 | 73 | def __iter__(self): 74 | yield from self.dump().items() 75 | 76 | def __str__(self): 77 | return json.dumps(dict(self), cls=ObjectJSONEncoder) 78 | 79 | def __repr__(self): 80 | return self.__str__() 81 | 82 | def __eq__(self, other: Any) -> bool: 83 | return isinstance(other, type(self)) and \ 84 | other.uid == self.uid and \ 85 | other.first_seen_at == self.first_seen_at and \ 86 | other.last_seen_at == self.last_seen_at 87 | 88 | 89 | class QueryableServer(Server): 90 | ip: str 91 | query_port: int 92 | 93 | def __init__( 94 | self, 95 | guid: str, 96 | ip: str, 97 | query_port: int, 98 | links: Union[List[WebLink], WebLink], 99 | first_seen_at: Optional[datetime] = datetime.now().astimezone(), 100 | last_seen_at: datetime = datetime.now().astimezone() 101 | ): 102 | super().__init__(guid, links, first_seen_at, last_seen_at) 103 | self.ip = ip 104 | self.query_port = query_port 105 | 106 | def update(self, updated: 'QueryableServer') -> None: 107 | Server.update(self, updated) 108 | self.ip = updated.ip 109 | self.query_port = updated.query_port 110 | 111 | @staticmethod 112 | def is_json_repr(parsed: dict) -> bool: 113 | return 'guid' in parsed and 'ip' in parsed and 'queryPort' in parsed 114 | 115 | def txt(self) -> str: 116 | return f'{self.ip} {self.query_port}' 117 | 118 | def __eq__(self, other: Any) -> bool: 119 | return Server.__eq__(self, other) and other.ip == self.ip and other.query_port == self.query_port 120 | 121 | 122 | class ViaStatus: 123 | principal: str 124 | first_seen_at: datetime 125 | last_seen_at: datetime 126 | 127 | def __init__(self, principal: str, first_seen_at: datetime = datetime.now().astimezone(), 128 | last_seen_at: datetime = datetime.now().astimezone()): 129 | self.principal = principal 130 | self.first_seen_at = first_seen_at 131 | self.last_seen_at = last_seen_at 132 | 133 | def is_expired(self, expired_ttl: float) -> bool: 134 | return datetime.now().astimezone() > self.last_seen_at + timedelta(hours=expired_ttl) 135 | 136 | def update(self, updated: 'ViaStatus') -> None: 137 | self.last_seen_at = updated.last_seen_at 138 | 139 | @staticmethod 140 | def load(parsed: dict) -> 'ViaStatus': 141 | first_seen_at = datetime.fromisoformat(parsed['firstSeenAt']) 142 | last_seen_at = datetime.fromisoformat(parsed['lastSeenAt']) 143 | 144 | return ViaStatus( 145 | parsed['principal'], 146 | first_seen_at, 147 | last_seen_at 148 | ) 149 | 150 | @staticmethod 151 | def is_json_repr(parsed: dict) -> bool: 152 | return 'principal' in parsed and 'firstSeenAt' in parsed and 'lastSeenAt' in parsed 153 | 154 | def dump(self) -> dict: 155 | return { 156 | 'principal': self.principal, 157 | 'firstSeenAt': self.first_seen_at.isoformat(), 158 | 'lastSeenAt': self.last_seen_at.isoformat() 159 | } 160 | 161 | def __eq__(self, other): 162 | return isinstance(other, ViaStatus) and \ 163 | other.principal == self.principal and \ 164 | other.first_seen_at == self.first_seen_at and \ 165 | other.last_seen_at == self.last_seen_at 166 | 167 | def __iter__(self): 168 | yield from self.dump().items() 169 | 170 | def __str__(self): 171 | return json.dumps(dict(self)) 172 | 173 | def __repr__(self): 174 | return self.__str__() 175 | 176 | 177 | class ClassicServer(QueryableServer): 178 | """ 179 | Server for "classic" games whose principals which return a server list 180 | containing ips and query ports of game servers (GameSpy, Quake3) 181 | """ 182 | game_port: int 183 | via: List[ViaStatus] 184 | 185 | def __init__( 186 | self, 187 | guid: str, 188 | ip: str, 189 | query_port: int, 190 | via: Union[List[ViaStatus], ViaStatus], 191 | game_port: int = -1, 192 | first_seen_at: Optional[datetime] = datetime.now().astimezone(), 193 | last_seen_at: datetime = datetime.now().astimezone() 194 | ): 195 | # Leave link list empty for now (query port is usually not sufficient to build any links) 196 | super().__init__(guid, ip, query_port, [], first_seen_at, last_seen_at) 197 | self.game_port = game_port 198 | self.via = via if isinstance(via, list) else [via] 199 | 200 | def trim(self, expired_ttl: float) -> None: 201 | super().trim(expired_ttl) 202 | self.via = [via for via in self.via if not via.is_expired(expired_ttl)] 203 | 204 | def update(self, updated: 'ClassicServer') -> None: 205 | QueryableServer.update(self, updated) 206 | self.game_port = updated.game_port 207 | # Merge via statuses "manually" 208 | for via_status in updated.via: 209 | via_principals_self = [via_status.principal for via_status in self.via] 210 | if via_status.principal not in via_principals_self: 211 | self.via.append(via_status) 212 | else: 213 | index = via_principals_self.index(via_status.principal) 214 | self.via[index].update(via_status) 215 | 216 | @staticmethod 217 | def load(parsed: dict) -> Union['ClassicServer', dict]: 218 | # Return data as is if it's not a JSON representation 219 | if not ClassicServer.is_json_repr(parsed): 220 | return parsed 221 | 222 | first_seen_at = datetime.fromisoformat(parsed['firstSeenAt']) \ 223 | if parsed.get('firstSeenAt') is not None else None 224 | last_seen_at = datetime.fromisoformat(parsed['lastSeenAt']) \ 225 | if parsed.get('lastSeenAt') is not None else UNIX_EPOCH_START 226 | game_port = parsed.get('gamePort', -1) 227 | via = [ 228 | ViaStatus.load(via_parsed) for via_parsed in parsed.get('via', []) if 229 | ViaStatus.is_json_repr(via_parsed) 230 | ] 231 | 232 | server = ClassicServer( 233 | parsed['guid'], 234 | parsed['ip'], 235 | parsed['queryPort'], 236 | via, 237 | game_port, 238 | first_seen_at, 239 | last_seen_at 240 | ) 241 | server.links = [ 242 | WebLink.load(link_parsed) for link_parsed in parsed.get('links', []) 243 | if WebLink.is_json_repr(link_parsed) 244 | ] 245 | 246 | return server 247 | 248 | def dump(self) -> dict: 249 | return { 250 | 'guid': self.uid, 251 | 'ip': self.ip, 252 | 'gamePort': self.game_port, 253 | 'queryPort': self.query_port, 254 | 'firstSeenAt': self.first_seen_at.isoformat() if self.first_seen_at is not None else self.first_seen_at, 255 | 'lastSeenAt': self.last_seen_at.isoformat(), 256 | 'via': [via_status.dump() for via_status in self.via], 257 | 'links': [link.dump() for link in self.links] 258 | } 259 | 260 | def txt(self) -> str: 261 | return f'{self.ip} {self.game_port} {self.query_port}' 262 | 263 | def __eq__(self, other): 264 | return QueryableServer.__eq__(self, other) and \ 265 | self.game_port == other.game_port and \ 266 | all(via_status in self.via for via_status in other.via) 267 | 268 | 269 | class FrostbiteServer(QueryableServer): 270 | """ 271 | Server for Frostbite-era games whose server lists are centralized (contain all relevant server info rather than 272 | just the query port) 273 | """ 274 | name: str 275 | game_port: int 276 | last_queried_at: Optional[datetime] 277 | 278 | def __init__( 279 | self, 280 | guid: str, 281 | name: str, 282 | ip: str, 283 | game_port: int, 284 | query_port: int = -1, 285 | first_seen_at: Optional[datetime] = datetime.now().astimezone(), 286 | last_seen_at: datetime = datetime.now().astimezone(), 287 | last_queried_at: Optional[datetime] = None 288 | ): 289 | # Leave link list empty for now (adding links is optional after all) 290 | super().__init__(guid, ip, query_port, [], first_seen_at, last_seen_at) 291 | self.name = name 292 | self.game_port = game_port 293 | self.last_queried_at = last_queried_at 294 | 295 | def update(self, updated: 'FrostbiteServer') -> None: 296 | Server.update(self, updated) 297 | # Cannot use parent's update function here, since that would always overwrite the query port 298 | # (which may be -1 for updated if query failed or was not attempted/enabled) 299 | self.ip = updated.ip 300 | if updated.query_port != -1: 301 | self.query_port = updated.query_port 302 | self.name = updated.name 303 | self.game_port = updated.game_port 304 | # Only update timestamp if not none (don't reset) 305 | if updated.last_queried_at is not None: 306 | self.last_queried_at = updated.last_queried_at 307 | 308 | @staticmethod 309 | def load(parsed: dict) -> Union['FrostbiteServer', dict]: 310 | if not FrostbiteServer.is_json_repr(parsed): 311 | return parsed 312 | 313 | first_seen_at = datetime.fromisoformat(parsed['firstSeenAt']) \ 314 | if parsed.get('firstSeenAt') is not None else None 315 | last_seen_at = datetime.fromisoformat(parsed['lastSeenAt']) \ 316 | if parsed.get('lastSeenAt') is not None else UNIX_EPOCH_START 317 | last_queried_at = datetime.fromisoformat(parsed['lastQueriedAt']) \ 318 | if parsed.get('lastQueriedAt') not in [None, ''] else None 319 | 320 | server = FrostbiteServer( 321 | parsed['guid'], 322 | parsed['name'], 323 | parsed['ip'], 324 | parsed['gamePort'], 325 | parsed['queryPort'], 326 | first_seen_at, 327 | last_seen_at, 328 | last_queried_at 329 | ) 330 | server.links = [ 331 | WebLink.load(link_parsed) for link_parsed in parsed.get('links', []) 332 | if WebLink.is_json_repr(link_parsed) 333 | ] 334 | 335 | return server 336 | 337 | @staticmethod 338 | def is_json_repr(parsed: dict) -> bool: 339 | return QueryableServer.is_json_repr(parsed) and 'name' in parsed and 'gamePort' in parsed 340 | 341 | def dump(self) -> dict: 342 | return { 343 | 'guid': self.uid, 344 | 'name': self.name, 345 | 'ip': self.ip, 346 | 'gamePort': self.game_port, 347 | 'queryPort': self.query_port, 348 | 'firstSeenAt': self.first_seen_at.isoformat() if self.first_seen_at is not None else self.first_seen_at, 349 | 'lastSeenAt': self.last_seen_at.isoformat(), 350 | 'lastQueriedAt': self.last_queried_at.isoformat() if self.last_queried_at is not None else self.last_queried_at, 351 | 'links': [link.dump() for link in self.links] 352 | } 353 | 354 | def txt(self) -> str: 355 | return f'{self.ip} {self.game_port} {self.query_port}' 356 | 357 | 358 | class BadCompany2Server(FrostbiteServer): 359 | lid: int 360 | gid: int 361 | 362 | def __init__( 363 | self, 364 | guid: str, 365 | name: str, 366 | lid: int, 367 | gid: int, 368 | ip: str, 369 | game_port: int, 370 | query_port: int = -1, 371 | first_seen_at: Optional[datetime] = datetime.now().astimezone(), 372 | last_seen_at: datetime = datetime.now().astimezone(), 373 | last_queried_at: Optional[datetime] = None 374 | ): 375 | # Leave link list empty for now (adding links is optional after all) 376 | super().__init__(guid, name, ip, game_port, query_port, first_seen_at, last_seen_at, last_queried_at) 377 | self.lid = lid 378 | self.gid = gid 379 | 380 | def update(self, updated: 'BadCompany2Server') -> None: 381 | super().update(updated) 382 | self.lid = updated.lid 383 | self.gid = updated.gid 384 | 385 | @staticmethod 386 | def load(parsed: dict) -> Union['BadCompany2Server', dict]: 387 | # Will change to own validation in future 388 | if not FrostbiteServer.is_json_repr(parsed): 389 | return parsed 390 | 391 | first_seen_at = datetime.fromisoformat(parsed['firstSeenAt']) \ 392 | if parsed.get('firstSeenAt') is not None else None 393 | last_seen_at = datetime.fromisoformat(parsed['lastSeenAt']) \ 394 | if parsed.get('lastSeenAt') is not None else UNIX_EPOCH_START 395 | last_queried_at = datetime.fromisoformat(parsed['lastQueriedAt']) \ 396 | if parsed.get('lastQueriedAt') not in [None, ''] else None 397 | 398 | server = BadCompany2Server( 399 | parsed['guid'], 400 | parsed['name'], 401 | parsed.get('lid', -1), 402 | parsed.get('gid', -1), 403 | parsed['ip'], 404 | parsed['gamePort'], 405 | parsed['queryPort'], 406 | first_seen_at, 407 | last_seen_at, 408 | last_queried_at 409 | ) 410 | server.links = [ 411 | WebLink.load(link_parsed) for link_parsed in parsed.get('links', []) 412 | if WebLink.is_json_repr(link_parsed) 413 | ] 414 | 415 | return server 416 | 417 | def dump(self) -> dict: 418 | return { 419 | 'guid': self.uid, 420 | 'name': self.name, 421 | 'ip': self.ip, 422 | 'gamePort': self.game_port, 423 | 'queryPort': self.query_port, 424 | 'lid': self.lid, 425 | 'gid': self.gid, 426 | 'firstSeenAt': self.first_seen_at.isoformat() if self.first_seen_at is not None else self.first_seen_at, 427 | 'lastSeenAt': self.last_seen_at.isoformat(), 428 | 'lastQueriedAt': self.last_queried_at.isoformat() if self.last_queried_at is not None else self.last_queried_at, 429 | 'links': [link.dump() for link in self.links] 430 | } 431 | 432 | 433 | class GametoolsServer(Server): 434 | name: str 435 | 436 | def __init__( 437 | self, 438 | game_id: str, 439 | name: str, 440 | first_seen_at: Optional[datetime] = datetime.now().astimezone(), 441 | last_seen_at: datetime = datetime.now().astimezone(), 442 | ): 443 | # Leave link list empty for now (adding links is optional after all) 444 | super().__init__(game_id, [], first_seen_at, last_seen_at) 445 | self.name = name 446 | 447 | def update(self, updated: 'GametoolsServer') -> None: 448 | Server.update(self, updated) 449 | self.name = updated.name 450 | 451 | @staticmethod 452 | def load(parsed: dict) -> Union['GametoolsServer', dict]: 453 | if not GametoolsServer.is_json_repr(parsed): 454 | return parsed 455 | 456 | first_seen_at = datetime.fromisoformat(parsed['firstSeenAt']) \ 457 | if parsed.get('firstSeenAt') is not None else None 458 | last_seen_at = datetime.fromisoformat(parsed['lastSeenAt']) \ 459 | if parsed.get('lastSeenAt') is not None else UNIX_EPOCH_START 460 | 461 | server = GametoolsServer(parsed['gameId'], parsed['name'], first_seen_at, last_seen_at) 462 | server.links = [ 463 | WebLink.load(link_parsed) for link_parsed in parsed.get('links', []) 464 | if WebLink.is_json_repr(link_parsed) 465 | ] 466 | 467 | return server 468 | 469 | @staticmethod 470 | def is_json_repr(parsed: dict) -> bool: 471 | return 'gameId' in parsed and 'name' in parsed 472 | 473 | def dump(self) -> dict: 474 | return { 475 | 'gameId': self.uid, 476 | 'name': self.name, 477 | 'firstSeenAt': self.first_seen_at.isoformat() if self.first_seen_at is not None else self.first_seen_at, 478 | 'lastSeenAt': self.last_seen_at.isoformat(), 479 | 'links': [link.dump() for link in self.links] 480 | } 481 | 482 | def txt(self) -> str: 483 | return self.uid 484 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GameserverLister 2 | 3 | [![ci](https://img.shields.io/github/actions/workflow/status/cetteup/GameserverLister/ci.yml?label=ci)](https://github.com/cetteup/GameserverLister/actions?query=workflow%3Aci) 4 | [![License](https://img.shields.io/github/license/cetteup/GameserverLister)](/LICENSE) 5 | [![Package](https://img.shields.io/pypi/v/GameserverLister)](https://pypi.org/project/GameserverLister/) 6 | [![Last commit](https://img.shields.io/github/last-commit/cetteup/GameserverLister)](https://github.com/cetteup/GameserverLister/commits/main) 7 | 8 | A Python 🐍 command line tool to retrieve game server lists for various games. 9 | 10 | ## Features 11 | 12 | - create/update lists of game servers stored as JSON 13 | - removal of servers not seen in a configurable timespan 14 | - search game server's query ports if not contained in server list 15 | - handle completely broken pagination on Battlelog 16 | - proxy support for requests to Battlelog 17 | 18 | ## Usage 19 | 20 | You can easily install GameserverLister via pip. 21 | 22 | ```bash 23 | pip install GameserverLister 24 | ``` 25 | 26 | Upgrading from an older version is equally simple. 27 | 28 | ```bash 29 | pip install --upgrade GameserverLister 30 | ``` 31 | 32 | After installing through pip, you can get some help for the command line options through 33 | 34 | ```bash 35 | $ python3 -m GameserverLister --help 36 | Usage: python -m GameserverLister [OPTIONS] COMMAND [ARGS]... 37 | 38 | Options: 39 | --help Show this message and exit. 40 | 41 | Commands: 42 | battlelog 43 | bfbc2 44 | gamespy 45 | gametools 46 | medalofhonor 47 | quake3 48 | unreal2 49 | valve 50 | ``` 51 | 52 | ## Required tools 53 | 54 | The server list retrieval for GameSpy-games requires an external tool. In order to retrieve GameSpy servers, you need to set up [gslist](http://aluigi.altervista.org/papers.htm#gslist). `gslist` was developed by Luigi Auriemma. 55 | 56 | ## Supported games 57 | 58 | The scripts support retrieval for following games from the listed sources. If you know more sources for any of the listed games or know other games that support the listed protocols, please create an issue, and I will add them. 59 | 60 | | Game | Platforms | Source type/protocol | Server list source(s) | 61 | |----------------------------------------|----------------------------------|----------------------|-------------------------------------------------------------------------------------------------------------------| 62 | | 7 Days to Die | PC | Valve | Valve ¹ | 63 | | America's Army: Proving Grounds | PC | Valve | Valve ¹ | 64 | | ARK: Survival Evolved | PC | Valve | Valve ¹ | 65 | | Arma 2 | PC | Valve | Valve ¹ | 66 | | Arma 3 | PC | Valve | Valve ¹ | 67 | | Battlefield 1942 | PC | GameSpy | bf1942.org, openspy.net, qtracker.com | 68 | | Battlefield Vietnam | PC | GameSpy | openspy.net, qtracker.com | 69 | | Battlefield 2 | PC | GameSpy | bf2hub.com, playbf2.ru, openspy.net, b2bf2.net | 70 | | Battlefield 2142 | PC | GameSpy | novgames.ru, openspy.net, play2142.ru | 71 | | Battlefield: Bad Company 2 | PC | fesl/theater | Project Rome (emulatornexus.com) | 72 | | Battlefield 3 | PC | Battlelog | battlelog.com | 73 | | Battlefield 4 | PC, PS3, PS4, Xbox 360, Xbox One | Battlelog | battlelog.com | 74 | | Battlefield Hardline | PC, PS3, PS4, Xbox 360, Xbox One | Battlelog | battlelog.com | 75 | | Battlefield 1 | PC | Gametools API | api.gametools.network | 76 | | Battlefield 5 | PC | Gametools API | api.gametools.network | 77 | | Call of Duty | PC | Quake3 | Activision | 78 | | Call of Duty: United Offensive | PC | Quake3 | Activision | 79 | | Call of Duty 2 | PC | Quake3 | Activision | 80 | | Call of Duty 4: Modern Warfare | PC | Quake3 | Activision | 81 | | CoD4x Mod | PC | Quake3 | cod4x.ovh | 82 | | Counter Strike | PC | Valve | Valve ¹ | 83 | | Counter Strike: Condition Zero | PC | Valve | Valve ¹ | 84 | | Counter Strike: Source | PC | Valve | Valve ¹ | 85 | | Counter Strike: Global Offensive | PC | Valve | Valve ¹ | 86 | | Crysis | PC | CryMP.org API | crymp.org | 87 | | Crysis Wars | PC | GameSpy | jedi95.us | 88 | | Day of Defeat | PC | Valve | Valve ¹ | 89 | | Day of Defeat: Source | PC | Valve | Valve ¹ | 90 | | DayZ | PC | Valve | Valve ¹ | 91 | | DayZ (Arma 2 mod) | PC | Valve | Valve ¹ | 92 | | Deus Ex | PC | GameSpy | 333networks.com, errorist.eu, newbiesplayground.net, oldunreal.com | 93 | | Duke Nukem Forever | PC | GameSpy | 333networks.com | 94 | | Forgotten Hope 2 | PC | GameSpy | fh2.dev | 95 | | Garry's Mod | PC | Valve | Valve ¹ | 96 | | Insurgency | PC | Valve | Valve ¹ | 97 | | Insurgency: Sandstorm | PC | Valve | Valve ¹ | 98 | | James Bond 007: Nightfire | PC | GameSpy | openspy.net, nightfirepc.com | 99 | | Left 4 Dead | PC | Valve | Valve ¹ | 100 | | Left 4 Dead 2 | PC | Valve | Valve ¹ | 101 | | Nexuiz | PC | Quake3 | deathmask.net | 102 | | OpenArena | PC | Quake3 | deathmask.net | 103 | | ParaWorld | PC | GameSpy | openspy.net | 104 | | Postal 2 | PC | GameSpy | 333networks.com | 105 | | Q3Rally | PC | Quake3 | deathmask.net | 106 | | Quake | PC | Quake3 | deathmask.net | 107 | | Quake 3 Arena | PC | Quake3 | quake3arena.com, urbanterror.info, excessiveplus.net, ioquake3.org, huxxer.de, maverickservers.com, deathmask.net | 108 | | Return to Castle Wolfenstein | PC | Quake3 | id Software | 109 | | Rising Storm 2: Vietnam | PC | Valve | Valve ¹ | 110 | | Rune | PC | GameSpy | 333networks.com, errorist.eu, newbiesplayground.net, oldunreal.com | 111 | | Rust | PC | Valve | Valve ¹ | 112 | | Serious Sam: The First Encounter | PC | GameSpy | 333networks.com, errorist.eu, newbiesplayground.net, oldunreal.com | 113 | | Serious Sam: Second Encounter | PC | GameSpy | 333networks.com, errorist.eu, newbiesplayground.net, oldunreal.com | 114 | | Soldier of Fortune II: Double Helix | PC | Quake3 | Raven Software | 115 | | Squad | PC | Valve | Valve ¹ | 116 | | Star Wars Jedi Knight II: Jedi Outcast | PC | Quake3 | Raven Software, jkhub.org | 117 | | Star Wars Jedi Knight: Jedi Academy | PC | Quake3 | Raven Software, jkhub.org | 118 | | SWAT 4 | PC | GameSpy | swat4stats.com | 119 | | Team Fortress Classic | PC | Valve | Valve ¹ | 120 | | Team Fortress 2 | PC | Valve | Valve ¹ | 121 | | Tremulous | PC | Quake3 | tremulous.net | 122 | | Unreal | PC | GameSpy | 333networks.com, errorist.eu, openspy.net, oldunreal.com, qtracker.com | 123 | | Unreal Tournament | PC | GameSpy | 333networks.com, errorist.eu, openspy.net, oldunreal.com, qtracker.com | 124 | | Unreal Tournament 2003 | PC | Unreal2 | openspy.net | 125 | | Unreal Tournament 2004 | PC | Unreal2 | openspy.net, 333networks.com, errorist.eu | 126 | | Unreal Tournament 3 | PC | GameSpy | openspy.net | 127 | | UrbanTerror | PC | Quake3 | FrozenSand | 128 | | Vietcong | PC | GameSpy | vietcong.tk, vietcong1.eu, qtracker.com | 129 | | Vietcong 2 | PC | GameSpy | openspy.net | 130 | | Warfork | PC | Quake3 | deathmask.net | 131 | | Warsow | PC | Quake3 | deathmask.net | 132 | | Wheel of Time | PC | GameSpy | 333networks.com, errorist.eu, newbiesplayground.net, oldunreal.com | 133 | | Wolfenstein: Enemy Territory | PC | Quake3 | id Software, etlegacy.com | 134 | | Xonotic | PC | Quake3 | deathmask.net, tchr.no | 135 | 136 | ¹ Valve's principal servers are rate limited. If you do not use additional filters to only retrieve matching servers, you will get blocked/timed out. You can pass filters via the `-f`/`--filter` argument, e.g. use `-f "\dedicated\1\password\0\empty\1\full\1"` to only retrieve dedicated servers without a password which are neither full nor empty. You can find a full list of filter options [here](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol#Filter) (the `\appid\` filter is applied automatically). 137 | 138 | ## Game server query ports 139 | 140 | After obtaining a server list, you may want request current details directly from the game server via different query protocols. However, only the GameSpy and Quake3 principal servers return the game server's query port. Battlelog and the EA fesl/theater do not provide details about the server's query port. So, the respective scripts attempt to find the query port if run with the `--find-query-port` flag. 141 | --------------------------------------------------------------------------------