260 lines
11 KiB
Python
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)
|