t2server/usr/local/bin/t2server

192 lines
8.5 KiB
Python
Executable file

#!/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/<mod>/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)