Files
awg-gus-bot/awg_gus/manage.py
T

260 lines
11 KiB
Python

"""Local CLI for independently managed systemd connections to GusVoice."""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import os
from pathlib import Path
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import time
from types import SimpleNamespace
from .core import Awg
from .main import check, configure, load_config
from .profiles import Layout, profile_name
def systemctl(*args, check=True):
result = subprocess.run(["systemctl", *args], capture_output=True, text=True)
if check and result.returncode:
raise RuntimeError(f"systemctl {args[0]} завершился с кодом {result.returncode}")
return result
@contextlib.contextmanager
def install_lock(layout):
import fcntl
path = layout.root / "run/lock/awg-gus-install.lock"
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise ValueError("Другая установка или настройка уже выполняется") from exc
yield
def install_units(layout):
folder = layout.root / "etc/systemd/system"
folder.mkdir(parents=True, exist_ok=True)
for unit, config in [
("awg-gus-bot.service", "/etc/awg-gus-bot.json"),
("awg-gus-bot@.service", "/etc/awg-gus-bot/instances/%i.json"),
]:
text = f"""[Unit]
Description=AWG Toolza bot for GusVoice ({'default' if '%' not in config else '%i'})
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=root
WorkingDirectory=/opt/awg-gus-bot/current
ExecStart={sys.executable} /opt/awg-gus-bot/current/run.py run --config {config}
Environment=PYTHONUNBUFFERED=1
UMask=0077
Restart=on-failure
RestartSec=10
TimeoutStopSec=20
KillMode=control-group
[Install]
WantedBy=multi-user.target
"""
path = folder / unit
if not path.exists() or path.read_text(encoding="utf-8") != text:
temporary = path.with_suffix(path.suffix + ".new")
temporary.write_text(text, encoding="utf-8")
temporary.chmod(0o644)
temporary.replace(path)
def database_copy(source, destination):
with contextlib.closing(sqlite3.connect(source)) as src:
with contextlib.closing(sqlite3.connect(destination)) as dst:
src.backup(dst)
def setup_profile(name, *, reconfigure=False, layout=None, setup_fn=configure, check_fn=check):
layout = layout or Layout()
name = profile_name(name)
path, state, unit = layout.config(name), layout.state(name), layout.unit(name)
exists = path.is_file()
if exists and not reconfigure:
raise ValueError(f"Подключение {name} уже существует. Для него используйте setup {name}.")
if reconfigure and not exists:
raise ValueError(f"Подключение {name} не найдено")
if exists:
previous = load_config(path)
awg2, state = previous["awg2"], Path(previous["state_dir"])
else:
awg2 = shutil.which("awg2")
if not awg2:
raise ValueError("Команда awg2 не найдена. Сначала установите AWG Toolza.")
# This is read-only and happens before stopping the selected connection.
asyncio.run(Awg(awg2).call("version", timeout=30))
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
state.mkdir(parents=True, exist_ok=True, mode=0o700)
state.chmod(0o700)
database = state / "state.sqlite3"
was_active = systemctl("is-active", "--quiet", unit, check=False).returncode == 0
was_enabled = systemctl("is-enabled", "--quiet", unit, check=False).returncode == 0
with tempfile.TemporaryDirectory(prefix=".setup-", dir=state) as temporary:
backup = Path(temporary)
if exists:
shutil.copy2(path, backup / "config")
had_database = database.exists()
database_saved = False
if was_active:
systemctl("stop", unit)
try:
if had_database:
database_copy(database, backup / "state")
database_saved = True
args = SimpleNamespace(config=str(path), awg2=awg2, state_dir=str(state),
label=name, layout=layout)
setup_fn(args)
asyncio.run(check_fn(args))
install_units(layout)
systemctl("daemon-reload")
systemctl("enable", unit)
systemctl("reset-failed", unit, check=False)
systemctl("restart", unit)
time.sleep(2)
if systemctl("is-active", "--quiet", unit, check=False).returncode:
raise RuntimeError(f"Служба {unit} не запустилась; откатываю это подключение")
except BaseException:
systemctl("stop", unit, check=False)
if exists:
shutil.copy2(backup / "config", path)
else:
path.unlink(missing_ok=True)
if database_saved:
database_copy(backup / "state", database)
elif not had_database:
for suffix in ("", "-journal", "-wal", "-shm"):
database.with_name(database.name + suffix).unlink(missing_ok=True)
if not was_enabled:
systemctl("disable", unit, check=False)
if was_active:
systemctl("reset-failed", unit, check=False)
systemctl("start", unit, check=False)
raise
print(f"Подключение {name} готово. Отправьте /menu боту на этом сервере Гуся.")
def choose_existing(layout, name):
if name:
profile_name(name)
if name not in layout.names():
raise ValueError(f"Подключение {name} не найдено")
return name
if "default" in layout.names():
return "default"
if len(layout.names()) == 1:
return layout.names()[0]
raise ValueError("Укажите имя подключения; список: awg-gus-bot list")
def list_profiles(layout):
if not layout.names():
print("Подключений пока нет.")
for name in layout.names():
try:
data = load_config(layout.config(name))
status = systemctl("is-active", layout.unit(name), check=False).stdout.strip()
print(f"{name}: {data['url']} — {status or 'inactive'}")
except (ValueError, KeyError):
print(f"{name}: ошибка настроек; проверьте это подключение отдельно")
def dispatch(command, name, layout):
if command == "menu":
list_profiles(layout)
print("\n1. Добавить ещё одного Гуся\n2. Обновить программу для всех подключений\n"
"3. Список подключений\n4. Перенастроить подключение\n0. Выход")
choice = input("Выберите действие: ").strip()
if choice == "0":
return
if choice == "2":
subprocess.run(["/usr/local/bin/awg-gus-bot", "update"], check=True)
return
command = {"1": "add", "3": "list", "4": "setup"}.get(choice)
if not command:
raise ValueError("Неизвестный пункт меню")
if command == "setup":
name = input("Имя подключения [default]: ").strip() or "default"
if command in {"list", "status"} and not name:
list_profiles(layout)
elif command in {"add", "setup"}:
if not sys.stdin.isatty():
raise ValueError("Для привязки нужен терминал")
if command == "add":
name = name or input("Имя нового подключения (например gus2): ").strip()
else:
name = choose_existing(layout, name)
with install_lock(layout):
setup_profile(name, reconfigure=command == "setup", layout=layout)
elif command == "services":
for name in layout.names():
print(layout.unit(name))
elif command == "validate":
for name in layout.names():
load_config(layout.config(name))
elif command == "install-units":
install_units(layout)
elif command == "check":
failures = []
names = [choose_existing(layout, name)] if name else layout.names()
for item in names:
try:
print(f"Проверяю {item}…", flush=True)
asyncio.run(check(SimpleNamespace(config=str(layout.config(item)))))
except Exception as exc:
print(f"{item}: {type(exc).__name__}; проверьте адрес, токен и доступность узла.")
failures.append(item)
if failures:
raise ValueError("Не прошли проверку: " + ", ".join(failures))
elif command in {"start", "stop", "restart", "enable", "disable", "status", "logs"}:
name = choose_existing(layout, name)
unit = layout.unit(name)
if command == "logs":
subprocess.run(["journalctl", "-u", unit, "-n", "80", "--no-pager"], check=True)
elif command == "status":
subprocess.run(["systemctl", "status", unit, "--no-pager"], check=True)
else:
with install_lock(layout):
systemctl(command, *(["--now"] if command in {"enable", "disable"} else []), unit)
else:
raise ValueError("Неизвестная команда")
def main(argv=None):
parser = argparse.ArgumentParser(description="Несколько Гусей → один AWG-сервер")
parser.add_argument("command", nargs="?", default="menu", choices=[
"menu", "add", "setup", "list", "check", "status", "logs", "start", "stop", "restart",
"enable", "disable", "services", "validate", "install-units"])
parser.add_argument("name", nargs="?")
args = parser.parse_args(argv)
os.umask(0o077)
try:
if os.name != "posix" or os.geteuid() != 0:
raise ValueError("Запустите на AWG-сервере через sudo awg-gus-bot")
dispatch(args.command, args.name, Layout())
except (ValueError, RuntimeError) as exc:
print(f"Ошибка: {exc}", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("Настройка отменена.", file=sys.stderr)
sys.exit(130)
except Exception as exc:
print(f"Ошибка: {type(exc).__name__}. Проверьте сеть и журнал выбранной службы.", file=sys.stderr)
sys.exit(1)