"""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)