refucktoring

master
Sergey Marinkevich 4 months ago
parent 16032df9b5
commit 40526b38bd

1
.gitignore vendored

@ -0,0 +1 @@
*.swp

@ -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: run:
bash -c 'nohup python3 main.py >nohup.out 2>&1 & disown' sudo bash -c 'nohup python3 main.py >nohup.out 2>&1 & disown'

@ -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

@ -1,61 +1,169 @@
#!/bin/bash
import os import os
import sys import sys
import socket import socket
import time import time
import subprocess 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: try:
output = subprocess.check_output(CHECK_USERS_CMD) # shell=True позволяет использовать пайпы (|) и другие возможности shell
# Maybe, server isn't running? # check=True бросит исключение CalledProcessError при ненулевом коде возврата
except subprocess.CalledProcessError as err: print(f"[{service_name}] run {check_cmd}.", flush=True)
print(f"Cannot access mcrcon: {err}", flush=True) subprocess.run(
check_cmd,
shell=True,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
executable='/bin/bash'
)
return True return True
header = output.decode().split("\n")[0] except (subprocess.CalledProcessError, FileNotFoundError):
if header.startswith("There are 0 of"): # CalledProcessError: команда вернула ненулевой код (считаем, что активности нет)
# FileNotFoundError: команда не найдена (например, опечатка в пути)
return False return False
return True
def server_shutdown(): def server_shutdown(stop_cmd, service_name):
subprocess.run("service minecraft stop".split(" ")) 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(): def server_startup(start_cmd, service_name):
subprocess.run("service minecraft start".split(" ")) 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: while True:
if are_users_there(): state_fn = state_map[state]
wasted_cnt_seconds = 0 state, interval = state_fn(ctx, config)
print("Users ok", flush=True) if interval > 0:
time.sleep(CHECK_INTERVAL) time.sleep(interval)
continue
def main():
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) config = configparser.ConfigParser()
server_shutdown() if not os.path.exists(CONFIG_FILE):
wait_client() print(f"Error: Configuration file '{CONFIG_FILE}' not found.", file=sys.stderr)
server_startup() sys.exit(1)
print("Server started", flush=True)
wasted_cnt_seconds = 0 config.read(CONFIG_FILE)
time.sleep(CHECK_INTERVAL) service_names = config.sections()
if not service_names:
def wait_client(): print(f"Error: No services defined in '{CONFIG_FILE}'.", file=sys.stderr)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sys.exit(1)
sock.bind(("0.0.0.0", 25565))
print("Listening...", flush=True) print(f"Found services: {', '.join(service_names)}", flush=True)
sock.listen(0) processes = []
connection, address = sock.accept()
print(f"Connection from: {address}", flush=True) for service_name in service_names:
connection.close() try:
sock.close() # Собираем конфигурацию для одного сервиса в словарь
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__': if __name__ == '__main__':
main() main()

@ -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
Loading…
Cancel
Save