From 40526b38bd5b995b27be08c177e5835471dffbd3 Mon Sep 17 00:00:00 2001 From: grayhook Date: Sun, 24 Aug 2025 15:07:18 +0700 Subject: [PATCH] refucktoring --- .gitignore | 1 + Makefile | 9 ++- config.ini | 19 +++++ main.py | 196 ++++++++++++++++++++++++++++++++++++++++------------ port-butler.service | 11 +++ 5 files changed, 191 insertions(+), 45 deletions(-) create mode 100644 .gitignore create mode 100644 config.ini mode change 100644 => 100755 main.py create mode 100644 port-butler.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1377554 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.swp diff --git a/Makefile b/Makefile index 02e40e3..164a701 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,9 @@ +install: + @sudo cp -v main.py /usr/local/bin/butler.py + @sudo cp -v port-butler.service /etc/systemd/system/ + @sudo cp -v config.ini /etc/butler.ini + sudo chmod a+x /usr/local/bin/butler.py + sudo systemctl daemon-reload + run: - bash -c 'nohup python3 main.py >nohup.out 2>&1 & disown' + sudo bash -c 'nohup python3 main.py >nohup.out 2>&1 & disown' diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..77f4783 --- /dev/null +++ b/config.ini @@ -0,0 +1,19 @@ +[minecraft] +start_command = service minecraft start +stop_command = service minecraft stop +listen_port = 25565 +listen_host = 0.0.0.0 +check_command = grep "There are [1-9][0-9]* of" <(/var/local/minecraft/mcrcon -H 127.0.0.1 -p password list) +check_interval_sec = 10 +idle_limit_sec = 60 +startup_delay_sec = 20 + +[palworld] +start_command = service palworld start +stop_command = service palworld stop +listen_port = 8211 +listen_host = 0.0.0.0 +check_command = grep -q '^[1-9][0-9]*$' <(curl -sL -X GET 'http://admin:poopa@192.168.1.21:8212/v1/api/players' -H 'Accept: application/json' | jq -r '.players | length') +check_interval_sec = 60 +idle_limit_sec = 300 +startup_delay_sec = 60 diff --git a/main.py b/main.py old mode 100644 new mode 100755 index 0587141..9624d43 --- a/main.py +++ b/main.py @@ -1,61 +1,169 @@ +#!/bin/bash + import os import sys import socket import time import subprocess +import configparser +import multiprocessing + +CONFIG_FILE = "/etc/butler.ini" -CHECK_USERS_CMD = ["/root/mcrcon/mcrcon", "-H", "192.168.0.21", "-p", "password", "list"] -CHECK_INTERVAL = 60 -WASTED_LIMIT = 20 * 60 +# --- Функции, выполняющие действия --- -def are_users_there(): +def are_users_there(check_cmd, service_name): + """ + Выполняет команду проверки через shell. + Возвращает True, если код возврата 0 (успех). + Возвращает False, если код возврата не 0 или произошла ошибка. + """ try: - output = subprocess.check_output(CHECK_USERS_CMD) - # Maybe, server isn't running? - except subprocess.CalledProcessError as err: - print(f"Cannot access mcrcon: {err}", flush=True) + # shell=True позволяет использовать пайпы (|) и другие возможности shell + # check=True бросит исключение CalledProcessError при ненулевом коде возврата + print(f"[{service_name}] run {check_cmd}.", flush=True) + subprocess.run( + check_cmd, + shell=True, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + executable='/bin/bash' + ) return True - header = output.decode().split("\n")[0] - if header.startswith("There are 0 of"): + except (subprocess.CalledProcessError, FileNotFoundError): + # CalledProcessError: команда вернула ненулевой код (считаем, что активности нет) + # FileNotFoundError: команда не найдена (например, опечатка в пути) return False - return True -def server_shutdown(): - subprocess.run("service minecraft stop".split(" ")) +def server_shutdown(stop_cmd, service_name): + print(f"[{service_name}] Stopping server...", flush=True) + subprocess.run(stop_cmd, shell=True) + print(f"[{service_name}] Stop command sent.", flush=True) -def server_startup(): - subprocess.run("service minecraft start".split(" ")) +def server_startup(start_cmd, service_name): + print(f"[{service_name}] Starting server...", flush=True) + subprocess.run(start_cmd, shell=True) + print(f"[{service_name}] Start command sent.", flush=True) + +def wait_client(host, port, service_name): + """ + Слушает порт и ждет одного подключения. + Использует SO_REUSEADDR для немедленного освобождения порта. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + # Эта опция решает проблему "Address already in use" + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + sock.bind((host, port)) + sock.listen(0) + print(f"[{service_name}] Listening on {host}:{port}...", flush=True) + connection, address = sock.accept() + print(f"[{service_name}] Connection from: {address}", flush=True) + connection.close() + print(f"[{service_name}] Socket closed, proceeding to start.", flush=True) + +# --- Функции состояний для конечного автомата (FSM) --- + +def state_check_users(ctx, config): + service_name = config['service_name'] + if are_users_there(config['check_command'], service_name): + ctx["wasted"] = 0 + print(f"[{service_name}] Activity detected, check is OK.", flush=True) + return "check_users", config['check_interval_sec'] + else: + ctx["wasted"] += config['check_interval_sec'] + print(f"[{service_name}] No activity detected ({ctx['wasted']}/{config['idle_limit_sec']} sec).", flush=True) + if ctx["wasted"] >= config['idle_limit_sec']: + print(f"[{service_name}] Idle limit reached.", flush=True) + return "shutdown", 0 + else: + return "check_users", config['check_interval_sec'] + +def state_shutdown(ctx, config): + server_shutdown(config['stop_command'], config['service_name']) + return "wait_client", 0 + +def state_wait_client(ctx, config): + wait_client(config['listen_host'], config['listen_port'], config['service_name']) + return "startup", 0 + +def state_startup(ctx, config): + server_startup(config['start_command'], config['service_name']) + ctx["wasted"] = 0 + # Даем сервису время на инициализацию перед первой проверкой + return "check_users", config['startup_delay_sec'] + +# Карта переходов +state_map = { + "check_users": state_check_users, + "shutdown": state_shutdown, + "wait_client": state_wait_client, + "startup": state_startup, +} + +def run_service_butler(config): + """ + Основной цикл конечного автомата для одного сервиса. + """ + ctx = { "wasted": 0 } + state = "wait_client" # Всегда начинаем с ожидания клиента + + print(f"[{config['service_name']}] Butler process started.", flush=True) -def main(): - wasted_cnt_seconds = 0 while True: - if are_users_there(): - wasted_cnt_seconds = 0 - print("Users ok", flush=True) - time.sleep(CHECK_INTERVAL) - continue - - wasted_cnt_seconds += CHECK_INTERVAL - print(f"No users ({wasted_cnt_seconds})", flush=True) - if wasted_cnt_seconds > WASTED_LIMIT: - print("Wasted limit", flush=True) - server_shutdown() - wait_client() - server_startup() - print("Server started", flush=True) - wasted_cnt_seconds = 0 - - time.sleep(CHECK_INTERVAL) - -def wait_client(): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind(("0.0.0.0", 25565)) - print("Listening...", flush=True) - sock.listen(0) - connection, address = sock.accept() - print(f"Connection from: {address}", flush=True) - connection.close() - sock.close() + state_fn = state_map[state] + state, interval = state_fn(ctx, config) + if interval > 0: + time.sleep(interval) + +def main(): + """ + Главная функция: читает конфиг и запускает по процессу на каждый сервис. + """ + config = configparser.ConfigParser() + if not os.path.exists(CONFIG_FILE): + print(f"Error: Configuration file '{CONFIG_FILE}' not found.", file=sys.stderr) + sys.exit(1) + + config.read(CONFIG_FILE) + + service_names = config.sections() + if not service_names: + print(f"Error: No services defined in '{CONFIG_FILE}'.", file=sys.stderr) + sys.exit(1) + + print(f"Found services: {', '.join(service_names)}", flush=True) + processes = [] + + for service_name in service_names: + try: + # Собираем конфигурацию для одного сервиса в словарь + service_config = { + "service_name": service_name, + "start_command": config.get(service_name, 'start_command'), + "stop_command": config.get(service_name, 'stop_command'), + "listen_port": config.getint(service_name, 'listen_port'), + "listen_host": config.get(service_name, 'listen_host'), + "check_command": config.get(service_name, 'check_command'), + "check_interval_sec": config.getint(service_name, 'check_interval_sec'), + "idle_limit_sec": config.getint(service_name, 'idle_limit_sec'), + "startup_delay_sec": config.getint(service_name, 'startup_delay_sec'), + } + # Создаем и запускаем отдельный процесс для каждого сервиса + process = multiprocessing.Process(target=run_service_butler, args=(service_config,)) + process.start() + processes.append(process) + except (configparser.NoOptionError, configparser.NoSectionError) as e: + print(f"Error parsing config for service '{service_name}': {e}", file=sys.stderr) + # Можно остановить уже запущенные процессы, если один конфиг невалиден + for p in processes: + p.terminate() + sys.exit(1) + + # Главный процесс ждет завершения дочерних (бесконечных) процессов + for p in processes: + p.join() if __name__ == '__main__': main() diff --git a/port-butler.service b/port-butler.service new file mode 100644 index 0000000..2168cab --- /dev/null +++ b/port-butler.service @@ -0,0 +1,11 @@ +[Unit] +Description=Port Butler +After=network.target + +[Service] +ExecStart=/usr/bin/python3 /usr/local/bin/butler.py +Restart=always +Type=simple + +[Install] +WantedBy=multi-user.target