#!/usr/bin/env -S python3 -B import yaml from os import unlink, symlink, chdir, getppid, makedirs from os.path import isfile, islink, isdir, getmtime from re import match from glob import iglob from subprocess import run, PIPE from time import sleep from requests import get from threading import Thread from t2support import * winecmd=["/usr/bin/wineconsole", "--backend=curses"] argbase=["Tribes2.exe","-dedicated"] basecmd=winecmd+argbase parent=getppid() def dso_cleanup(): """ This function finds all .dso files (compiled .cs scripts) and deletes them if they are older than their associated .cs script so that Tribes 2 will recompile them at startup. """ for dso_file in iglob(f"{install_dir}/**/*.dso", recursive=True): cs_file=dso_file[:-4] if isfile(cs_file): cs_mtime=getmtime(cs_file) dso_mtime=getmtime(dso_file) if cs_mtime > dso_mtime: print(f"Deleting {dso_file} so it can be rebuilt.") unlink(dso_file) def build_args(basecmd,config): """ This function assembles the command line to launch the server based on the config.yaml file. """ server_command=basecmd if config['Public']: server_command.append("-online") else: server_command.append("-nologin") if config['Mod'] != "base": server_command.extend(["-mod", config['Mod']]) if config['ServerPrefs']: server_command.extend(["-serverprefs","prefs\\ServerPrefs.cs"]) return server_command def server_files(config): """ This function creates a symlink to the specified /etc/t2server/serverprefs file in the GameData//prefs directory where Tribes 2 expects to find it. If MapList and MissionType are also configured, this funtion writes GameData/base/prefs/missions.txt with the appropriate values for the GameData/base/scripts/autoexec/missioncycle.cs script. If either MapList or MissionType is False, an empty missions.txt is written. """ if config['ServerPrefs']: if isfile(f"{install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs") or islink(f"{install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs"): print(f"Deleting {install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs") unlink(f"{install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs") print(f"Linking {install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs -> {etc_dir}/serverprefs/{config['ServerPrefs']}") makedirs(f"{install_dir}/GameData/{config['Mod']}/prefs", mode=0o755, exist_ok=True) symlink(f"{etc_dir}/serverprefs/{config['ServerPrefs']}", f"{install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs") if config["MapList"] and config["MissionType"]: print(f"Writing {install_dir}/GameData/base/prefs/missions.txt") makedirs(f"{install_dir}/GameData/base/prefs", mode=0o755, exist_ok=True) with open(f"{install_dir}/GameData/base/prefs/missions.txt", 'w') as mlist: for mission in config["MapList"]: mlist.write(f"{config['MissionType']} {mission}\n") else: # missions.txt needs to exist or the missioncycle.cs script will hang, so create/overwrite with an empty file print(f"Purging {install_dir}/GameData/base/prefs/missions.txt") with open(f"{install_dir}/GameData/base/prefs/missions.txt", 'w') as mlist: mlist.write("") def runaway_control(): """ When run in the background, wine will spawn a 'wineconsole --use-event=52' process that will consume all available CPU. This function finds that process and uses cpulimit to keep it under control. """ for x in range(20): sleep(15) print("Checking for runaway wineconsole process...") runaway_pid=run(["/usr/bin/pgrep","-f","wineconsole --use-event=52"],stdout=PIPE).stdout if runaway_pid: runaway_pid=str(int(runaway_pid)) print(f"Limiting runaway wineconsole process: {runaway_pid}") run(["/usr/bin/cpulimit","-bp",runaway_pid,"-l2"]) break def master_heartbeat(): """ A public Tribes 2 server should send a regular heartbeat to the TribexNext master server so that it appears in the list, however this seems inconsistent, so this function takes over that responsibility. """ print("Starting TribesNext heartbeat thread...") while True: get('http://master.tribesnext.com/add/28000') sleep(240) def is_valid_ip(ip): """Check if an ip looks like a valid IP address.""" return bool(match(r"^(\d{1,3}\.){3}\d{1,3}$", ip)) def override_mitm(): """ Tribes 2 servers try to detect descrepencies between their own IP and the IP that the client believes it's connecting to as possible man-in-the-middle attacks, however this often interfers with connections when the server is NATed or multi-homed. This function gets the public-facing IP of the host and writes an autoexec script to effectively disable this detection. """ for ip_service in ["http://api.ipify.org","http://ifconfig.me","http://ipinfo.io/ip"]: r=get(ip_service) if r.status_code == 200 and is_valid_ip(r.text): print(f"Got public IP address {r.text}") break if r.status_code != 200: bail("Could not get this server's public IP address.") print(f"Overriding Man-in-the-Middle attack detection.") with open(f'{install_dir}/GameData/base/scripts/autoexec/noMITM.cs', 'w') as nomitm_script: nomitm_script.write(f'$IPv4::InetAddress = "{r.text}";\n') if __name__ == "__main__": # If run interactively, warn user that they probably don't want to do this unless troubleshooting. if parent == 1: print(f"Started by init") else: interactive_run=menu(['[Y]es, run t2server interactively.',"~~[N]o, abort."],header="Running t2server directly can be helpful for troubleshooting but generally it's best to manage your server with 'systemctl'. Do you still want to run t2server?",footer="Choose [N] and run 't2help' if you're unsure of what to do.") if interactive_run == 'N': bail() chdir(f"{install_dir}/GameData") # Set default configuration config_defaults = { 'ServerPrefs' : 'Classic_CTF.cs', 'Mod' : 'Classic', 'Public' : False, 'OverrideMITM': True, 'MissionType' : 'CTF', 'MapList' : False } # Read configuration from config.yaml with open(f'{etc_dir}/config.yaml', 'r') as f: loaded_config = yaml.full_load(f) # Merge config_defaults and loaded_config, with loaded_config taking precedence where there are conflicts. # This ensures there are no undefined values in the case of a user removing one from config.yaml. config = {**config_defaults, **loaded_config} # Validate the mod directory and serverprefs file if not isdir(f"{install_dir}/GameData/{config['Mod']}"): bail(f"Invalid Mod directory: {config['Mod']}") if not isfile(f"{etc_dir}/serverprefs/{config['ServerPrefs']}"): bail(f"Invalid ServerPrefs file: {config['ServerPrefs']}") # Delete any pre-existing noMITM script/dso. It will be recreated below, if needed. if isfile(f"{install_dir}/GameData/base/scripts/autoexec/noMITM.cs"): unlink(f"{install_dir}/GameData/base/scripts/autoexec/noMITM.cs") if isfile(f"{install_dir}/GameData/base/scripts/autoexec/noMITM.cs.dso"): unlink(f"{install_dir}/GameData/base/scripts/autoexec/noMITM.cs.dso") # Create serverprefs symlink and missions.txt (if appropriate), clean out stale dso files, then assemble the command line arguments to launch the server server_files(config) dso_cleanup() server_command=build_args(basecmd,config) # If this is a public server, start a hearbeat thread. Also write the MITM override file if configured. if config['Public']: print("Starting heartbeat...") if config['OverrideMITM']: override_mitm() heartbeat=Thread(target=master_heartbeat) heartbeat.daemon=True heartbeat.start() # Cap the CPU of the runaway wineconsole process wcpid_limit=Thread(target=runaway_control) wcpid_limit.start() # Open the console log file if running as service and start the Tribes 2 server print(f"Starting Tribes 2 server: " + " ".join(server_command)) if parent == 1: with open(f"{log_dir}/console.log", 'w') as consolelog: run(server_command,stdout=consolelog) else: run(server_command)