diff --git a/Daemon/kf2-server.py b/Daemon/kf2-server.py new file mode 100755 index 0000000..18c36ee --- /dev/null +++ b/Daemon/kf2-server.py @@ -0,0 +1,164 @@ +#!/usr/bin/python3 + +import os +import sys +import signal +import shutil +import logging +import tempfile +import configparser + +from subprocess import Popen, PIPE + +### функция перехвата сигнала от системы +def receiveSignal(signalNumber, frame): + print(f"I'm killed - {os.getpid()}") + raise KeyboardInterrupt() + +### функция для преобразования элемента в список +def ini_item(value: str=""): + tmp=value.split(',') + if len(tmp) > 1: + return tmp + return value + +### функция вычисления номера порта +def set_port(value: list, c: int): + if len(value) > 1: + try: + factor=int(value[2]) + except Exception: + factor=1 + port=int(value[0])+1*factor*c + if int(value[1])>int(value[0]) and port<=int(value[1]): + return port + elif c 0: + c_ini = configparser.ConfigParser(strict=False) + c_ini.optionxform = str # для правельного учета регистра + # пробегаем парметры деррективы + for key2, value in c_key: + # разделяем ключ и значение + c_param=ini_item(key2) + # изменяем параметр + if not c_param[0] in c_ini: + c_ini[c_param[0]] = {} + # дописываем индекс в название сервера + if c_param[1] == 'ServerName' or c_param[1] == 'ClanMotto': + value+=f" {server_id}" + # обработка мараметра динамической конфигурации + if c_param[1] == 'bUsedForTakeover': + if int(value) > 0 and count < int(value): + value="FALSE" + else: + value="TRUE" + c_ini[c_param[0]][c_param[1]] = value + # пишем изменения в файл + with open(c_file_ini, "w") as f: + c_ini.write(f) + c_ini.clear() + + # запускаем бинарник сервера + command=f"{cfg_settings['bin_server']} kf-bioticslab?Game={game}?Difficulty={dif} -Port={set_port(cfg_settings['port_game'], count)} -QueryPort={set_port(cfg_settings['port_query'], count)} -WebAdminPort={set_port(cfg_settings['port_webadmin'], count)} -ConfigSubDir={server_id}" # команды запуска + while True: + with Popen(command, stdout=PIPE, stderr=PIPE, shell=True, user=cfg_settings['sys_user'], group=cfg_settings['sys_group'], ) as process: # запускаем процесс от пользователя steam + logging.info(f"Start: {command}") + process.communicate() # ждём, пока работает процеес и есть вывод в терминал + logging.info(f"Stop: {command}") + except KeyboardInterrupt: + None + except Exception as err: + logging.info(f"Unexpected termination: {err}") + # при завершение подчищаем за собой + tmp_dir.cleanup() + try: + shutil.rmtree(c_dir_ini, True) + os.remove(c_dir_ini) + except FileNotFoundError: + None + # убиваемся + logging.info(f"Stop: {command}") + os.killpg(os.getpgid(process.pid), signal.SIGINT) + +### главная функция +if __name__ == '__main__': + sys.stderr = open('/dev/null') + # ключи асоциативного массива + c_args_name=['script', 'count', 'game', 'dif', 'config'] + # если 2 аргумента, вызов из run скрипта + if len(sys.argv) == 2: + c_args_value=sys.argv[1].split() + c_args_value.insert(0, sys.argv[0]) + else: + # если 4 аргумента, вызов из shell + c_args_value=sys.argv + # проверяем кол-во передынных параветров вызова + if len(c_args_value) <= 4: + print("Incorrect number of arguments") + raise KeyError() + # получаем ассоциативный массив аргументов + c_args=dict(zip(c_args_name, c_args_value)) + # перехватываем сигналы от системы + signal.signal(signal.SIGHUP, receiveSignal) + #signal.signal(signal.SIGINT, receiveSignal) + signal.signal(signal.SIGQUIT, receiveSignal) + signal.signal(signal.SIGILL, receiveSignal) + signal.signal(signal.SIGTRAP, receiveSignal) + signal.signal(signal.SIGABRT, receiveSignal) + signal.signal(signal.SIGBUS, receiveSignal) + signal.signal(signal.SIGFPE, receiveSignal) + #signal.signal(signal.SIGKILL, receiveSignal) + signal.signal(signal.SIGUSR1, receiveSignal) + signal.signal(signal.SIGSEGV, receiveSignal) + signal.signal(signal.SIGUSR2, receiveSignal) + signal.signal(signal.SIGPIPE, receiveSignal) + signal.signal(signal.SIGALRM, receiveSignal) + signal.signal(signal.SIGTERM, receiveSignal) + # запускаем экземпляр сервера + daemon_server(int(c_args['count']), c_args['game'], int(c_args['dif']), c_args['config']) diff --git a/Daemon/run.py b/Daemon/run.py new file mode 100755 index 0000000..ab8792d --- /dev/null +++ b/Daemon/run.py @@ -0,0 +1,180 @@ +#!/usr/bin/python3 + +import os +import sys +import time +import json +import psutil +import signal +import logging +import requests +import argparse +import configparser + +from subprocess import Popen, PIPE +from multiprocessing.pool import ThreadPool + +### функция для преобразования элемента в список +def ini_item(value: str=""): + tmp=value.split(',') + if len(tmp) > 1: + return tmp + return value + +### функция запуска сервера +def server_start(param): + return Popen(["/usr/bin/python3", param[0], f"{param[1]} {str(param[2])} {param[3]} {str(param[4])} >/dev/null 2>&1"], stdout=PIPE, stderr=PIPE, shell=False).pid + +### функция проверки версии игры +def server_version(ver): + try: + if 'steamcmd_update_check' in cfg_settings and cfg_settings['steamcmd_update_check'] != "": + # делаем запрос к api steam + result = requests.get(cfg_settings['steamcmd_update_check'] + str(ver)) + if result.status_code == 200: + result=result.json() + print(result) + # если версия устарела, возвращаем текущую + if not result['response']['up_to_date']: + return str(result['response']['required_version']) + elif 'steamcmd_news_update_check' in cfg_settings and cfg_settings['steamcmd_news_update_check'] != "": + # делаем запрос к api steam + result = requests.get(cfg_settings['steamcmd_news_update_check']) + if result.status_code == 200: + result=result.json()['appnews']['newsitems'][0] + # проверяем связана ли новость с обновлением + title=str(result['title']).lower() + contents=str(result['contents']).lower() + if title and contents and \ + ('fix' in title or 'fix' in contents or \ + 'version' in title or 'version' in contents or \ + 'changelog' in title or 'changelog' in contents or \ + 'update' in title or 'update' in contents): + # если дата новости не равна текущей + if str(ver) != str(result['date']): + return str(result['date']) + except: + None + # если версия актуальна, или код ответа с ошибкой + return True + +### главная функция +if __name__ == "__main__": + sys.stderr = open('/dev/null') + # парсим аргументы + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("action", choices=["start", "stop", "restart", "status"]) + parser.add_argument("config") + args = parser.parse_args() + + # конфигурационный файл серверов + c_cfg = configparser.ConfigParser() + c_cfg.optionxform = str # для правельного учета регистра + c_cfg.read(args.config) + cfg_settings={} + + # пробегаем основные параметры + for key, value in c_cfg.items("SETTINGS"): + cfg_settings[key]=ini_item(value) + + # создаем объект класса logging + logging.basicConfig(filename=cfg_settings['log'], encoding='utf-8', level=logging.INFO,format='%(asctime)s.%(msecs)03d %(levelname)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S',) + + # пытаемся прочитать текущую версию из файла + try: + with open(cfg_settings['steamcmd_game_version'], "r") as f: + c_version = f.read() + except FileNotFoundError: + c_version="" + + ## функция запуска сервиса + def s_start(c_version, cfg_settings, args_config): + print("Start ...") + count=0 + c_pid=str(os.getppid()) + pool = ThreadPool() + for game in cfg_settings['modes_game']: + for dif in cfg_settings['difficulty_game']: + time.sleep(0.5) + c=pool.map(server_start, ([os.path.join(cfg_settings['dir_work'],"Daemon") + "/kf2-server.py", count, game, dif, args_config] ,)) + c_pid+=f" {c[0]}" + count+=1 + if game in ['KFGameContent.KFGameInfo_WeeklySurvival', 'KFGameContent.KFGameInfo_VersusSurvival']: + break + pool.close() + pool.join() + # пишем в файл pid процессов + with open(cfg_settings['pid_kf2'], "w") as f: + f.write(str(c_pid)) + while True: + # проверяем наличие обновлений в цикле + version=server_version(c_version) + if version != True: + # если прилетели обновления, обновляем + command=f"echo {cfg_settings['sys_user']} | su --pty - {cfg_settings['sys_user']} -c \"steamcmd +force_install_dir {cfg_settings['dir_server_data']} {cfg_settings['steamcmd_update_key']}\"" + with Popen(command, stdout=PIPE, stderr=PIPE, shell=True, user=cfg_settings['sys_user'], group=cfg_settings['sys_group'], ) as process: # запускаем процесс от пользователя steam + print("Update ...") + logging.info(f"Update: New version - {version}, old - {c_version}") + c_version=str(version) + process.communicate() + # после обновления пишим новую версию в файл + with open(cfg_settings['steamcmd_game_version'], "w") as f: + f.write(str(version)) + # после обновления перезапускаемся + s_stop(1) + return s_start(c_version, cfg_settings, args_config) + time.sleep(3600) # проверяем обновление каждый час + + ## функция остановки сервиса + def s_stop(split: int=0): + global c_version,cfg_settings + print("Stop ...") + with open(cfg_settings['pid_kf2'], "r") as f: + c_pid = f.read() + c_pid=c_pid.split() + for i in range(len(c_pid)-split): + try: + os.killpg(os.getpgid(int(c_pid[(i+split)])), signal.SIGINT) + time.sleep(0.5) + except: + None + + # если старт + if args.action == "start": + try: + # проверка на запущенность сервиса, проверяем первый pid + with open(cfg_settings['pid_kf2'], "r") as f: + c_pid = f.read() + c_pid=c_pid.split() + if str(os.getppid()) != c_pid[0]: + try: + # если запущен, выходим + os.getpgid(int(c_pid[0])) + raise KeyboardInterrupt() + except OSError: + # если не запущен, запускаем + s_start(c_version, cfg_settings, args.config) + # если запускаем из терминала + s_start(c_version, cfg_settings, args.config) + except Exception: + # если файл pid не существует, запускаем + s_start(c_version, cfg_settings, args.config) + # если стоп + elif args.action == "stop": + s_stop() + # если перезапуск + elif args.action == "restart": + s_stop() + time.sleep(1) + s_start() + # если информация о работе + elif args.action == "status": + bin_name=os.path.basename(cfg_settings['bin_server']) + # проверяем все системные процессы и показываем наши сервера + for pid in psutil.pids(): + p = psutil.Process(pid) + if p.name() == bin_name: + print(f"[{pid}] {' '.join(p.cmdline())}") + # значение по умолчанию + else: + parser.print_help() diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..f827899 --- /dev/null +++ b/config.ini @@ -0,0 +1,86 @@ +; глобальные параметры +[SETTINGS] + ; имя пользователя от которого запущен сервер + sys_user=steam + ; группа пользователя от которой запущен сервер + sys_group=steam + ; pid файл сервиса + pid_kf2=/var/run/kf2.pid + ; рабочая папка со всеми файлами сервера + dir_work=/opt/KillingFloor2 + ; путь установки сервера игры + dir_server_data=/opt/KillingFloor2/Data + ; bin файл сервера + bin_server=/opt/KillingFloor2/Data/Binaries/Win64/KFGameSteamServer.bin.x86_64 + ; файл версии игры, нужно для проверки обновлений по API + steamcmd_game_version=/tmp/kf2_version.txt + ; дополнительные параметры для выгрузки обновлений, путь берем из предыдущего параметра + steamcmd_update_key=+login anonymous +app_update 232130 validate +exit + ; ссылка Api Steam, для проверки обновлений. Json формат ответа, приоритет steamcmd_update_check + ;steamcmd_update_check=http://api.steampowered.com/ISteamApps/UpToDateCheck/v1?appid=232130&version= + steamcmd_news_update_check=http://api.steampowered.com/ISteamNews/GetNewsForApp/v0002/?appid=232090&count=1&maxlength=0&format=json + ; порты сервера, вторым параметром можно указать кол-во итераций + port_game=7780,30,2 + ; порты для Query, вторым параметром можно указать кол-во итераций + port_query=27100,30 + ; порты для Web Admin(ки), вторым параметром можно указать кол-во итераций + port_webadmin=9100,30 + ; сложность игры: 0 = Нормально, 1 = Тяжело, 2 = Суицидально, 3 = Ад на Земле + difficulty_game=1,2,3 + ; режимы игры: + ; KFGameContent.KFGameInfo_Endless - Бесконечный + ; KFGameContent.KFGameInfo_Objective - Цель + ; KFGameContent.KFGameInfo_Survival - Выживание + ; KFGameContent.KFGameInfo_VersusSurvival - Выживания на 2 команды + ; KFGameContent.KFGameInfo_WeeklySurvival - Еженедельные задания + modes_game=KFGameContent.KFGameInfo_Endless,KFGameContent.KFGameInfo_Objective,KFGameContent.KFGameInfo_Survival,KFGameContent.KFGameInfo_WeeklySurvival,KFGameContent.KFGameInfo_VersusSurvival,KFGameContent.KFGameInfo_Endless,KFGameContent.KFGameInfo_Objective,KFGameContent.KFGameInfo_Survival,KFGameContent.KFGameInfo_WeeklySurvival,KFGameContent.KFGameInfo_VersusSurvival + ; файл лога + log=/opt/KillingFloor2/Log/killingfloor2.log + ; временная tpmfs деррриктория + tmp=/opt/KillingFloor2/Tmp + +; custom параметры в файле KFAI.ini +[KFAI.ini] + +; custom параметры в файле KFWeb.ini +[KFWeb.ini] + ; включить веб админку + IpDrv.WebServer,bEnabled=true + +; custom параметры в файле KFWebAdmin.ini +[KFWebAdmin.ini] + +; custom параметры в файле LinuxServer-KFEngine.ini +[LinuxServer-KFEngine.ini] + +; custom параметры в файле LinuxServer-KFGame.ini +[LinuxServer-KFGame.ini] + ; имя сервера + Engine.GameReplicationInfo,ServerName=KillingFloor2 + ; пароль администратора + Engine.AccessControl,AdminPassword=1q2w3e4r5t + ; время голосования за карту + Engine.GameInfo,VoteTime=5.0 + ; кол-во волн + ; 0 - Short (4 волны) + ; 1 - Normal (7 волн) + ; 2 - Long (10 волн) + ; 3 - Custom + KFGame.KFGameInfo,GameLength=2 + ; банер + KFGame.KFGameInfo,BannerLink=http://art.tripwirecdn.com/TestItemIcons/MOTDServer.png + ; преведствие + KFGame.KFGameInfo,ServerMOTD=\n Welcome to our server:\n KillingFloor2\n + KFGame.KFGameInfo,ClanMotto=KillingFloor2 + ; сайт + KFGame.KFGameInfo,WebsiteLink=https://killingfloor2.com/ + ; указываем кол-во серверов с фиксированной конфигурацией, + ; остальные с открытой конфигурацией + ; если не хотим фиксировать, убираем параметр или ставим в 0 + Engine.GameEngine,bUsedForTakeover=11 + +; custom параметры в файле LinuxServer-KFInput.ini +[LinuxServer-KFInput.ini] + +; custom параметры в файле LinuxServer-KFSystemSettings.ini +[LinuxServer-KFSystemSettings.ini] diff --git a/kf2.service b/kf2.service new file mode 100644 index 0000000..34b27b5 --- /dev/null +++ b/kf2.service @@ -0,0 +1,20 @@ +[Unit] +Description=KF2 game service +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/KillingFloor2 +RestartSec=60 +KillSignal=SIGINT +ExecStart=/usr/bin/sh -c '/opt/KillingFloor2/Daemon/run.py start "/opt/KillingFloor2/config.ini"' +ExecStop=/usr/bin/sh -c '/opt/KillingFloor2/Daemon/run.py stop "/opt/KillingFloor2/config.ini"' +#ExecRestart=/opt/KillingFloor2/Daemon/run.py restart $CFG +#ExecStatus=/opt/KillingFloor2/Daemon/run.py status $CFG +Restart=always +User=root +Group=root +TimeoutStartSec=15s + +[Install] +WantedBy=multi-user.target diff --git a/steamcmd b/steamcmd new file mode 100755 index 0000000..4bc6ead --- /dev/null +++ b/steamcmd @@ -0,0 +1,32 @@ +#!/bin/sh +# Copyright (C) 2015 Alexandre Detiste +# License: MIT + +# check for old install < 0~20180105-4 +if [ -e ~/.steam/steamcmd ] +then + exec ~/.steam/steamcmd/steamcmd.sh $@ +fi + +# create a fake Steam installation to avoid +# that steamcmd uses "/home/$user/Steam" instead + +STEAMROOT="${XDG_DATA_HOME:-"$HOME/.local/share"}/Steam" +if [ ! -e ~/.steam ] +then + mkdir -p "$STEAMROOT/.steam/appcache/" + mkdir -p "$STEAMROOT/.steam/config/" + mkdir -p "$STEAMROOT/.steam/logs/" + mkdir -p "$STEAMROOT/.steam/SteamApps/common/" + ln -s "$STEAMROOT" ~/.steam/root + ln -s "$STEAMROOT" ~/.steam/steam +fi + +if [ ! -e "$STEAMROOT/steamcmd" ] +then + mkdir -p "$STEAMROOT/steamcmd/linux32" + # steamcmd will replace these files with newer ones itself on first run + cp /usr/lib/games/steam/steamcmd.sh "$STEAMROOT/steamcmd/" + cp /usr/lib/games/steam/steamcmd "$STEAMROOT/steamcmd/linux32/" +fi +exec "$STEAMROOT/steamcmd/steamcmd.sh" $@