diff --git a/.gitignore b/.gitignore index f83180d8..b83527b1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,6 @@ !app-config.json !main.py !requirements.txt -!scripts/ -!scripts/** !docs/ !docs/** diff --git a/README.md b/README.md index af0aa1e1..c1fdb9f1 100644 --- a/README.md +++ b/README.md @@ -91,24 +91,9 @@ sudo chown -R 1000:1000 ./logs ./data docker compose up -d # 5. Проверь статус -docker compose logs +docker compose logs ``` -### 🧰 Автоустановщик и мониторинг - -Для автоматической настройки сервера теперь доступен скрипт `scripts/install_monitor.py`. -Он скачает (или обновит) репозиторий, подготовит директории и права, соберёт базовые -значения `.env`, настроит обратный прокси (Caddy/Nginx или отдельный контейнер Caddy), -запустит Docker-сервисы и откроет интерактивный мониторинг с кнопками перезапуска. - -```bash -python3 scripts/install_monitor.py -``` - -Во время выполнения скрипт спросит домены для вебхуков, мини-приложения и страницы -редиректа, а также значения `BOT_TOKEN`, `ADMIN_IDS`, `REMNAWAVE_API_URL`, -`REMNAWAVE_API_KEY` и параметры авторизации панели. - --- ## ⚙️ Конфигурация diff --git a/scripts/install_monitor.py b/scripts/install_monitor.py deleted file mode 100755 index cb8997d0..00000000 --- a/scripts/install_monitor.py +++ /dev/null @@ -1,639 +0,0 @@ -#!/usr/bin/env python3 -"""Automated installer and monitoring utility for the Remnawave Bedolaga bot.""" - -from __future__ import annotations - -import json -import os -import shlex -import subprocess -import sys -import textwrap -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, Iterable, List, Optional - -REPO_URL = "https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot.git" -DEFAULT_INSTALL_DIR = Path("/opt/remnawave-bot") -DEFAULT_CADDY_DIR = Path("/opt/caddy") -BOT_SERVICE_NAME = "remnawave_bot" -REQUIRED_DIRECTORIES = [ - Path("logs"), - Path("data"), - Path("data/backups"), - Path("data/referral_qr"), -] -DIR_PERMISSIONS = "755" -DIR_OWNER = "1000:1000" -ENV_FIELDS = [ - ("BOT_TOKEN", True, "Введите токен Telegram-бота"), - ("ADMIN_IDS", True, "ID администраторов через запятую"), - ( - "REMNAWAVE_API_URL", - True, - "Укажите URL панели (например, https://panel.example.com или http://remnawave:3000)", - ), - ("REMNAWAVE_API_KEY", True, "Ключ доступа к панели"), -] -OPTIONAL_PROMPTS = [ - ( - "REMNAWAVE_AUTH_TYPE", - ["api_key", "basic_auth"], - "Тип авторизации панели (api_key/basic_auth)", - ), - ("REMNAWAVE_USERNAME", None, "Имя пользователя для Basic Auth (опционально)"), - ("REMNAWAVE_PASSWORD", None, "Пароль для Basic Auth (опционально)"), - ( - "REMNAWAVE_SECRET_KEY", - None, - "Секретный ключ панели (формат XXXXXXX:DDDDDDDD для установок eGames)", - ), -] -CADDY_MARKER_START = "# === REMNAWAVE BOT AUTOGENERATED START ===" -CADDY_MARKER_END = "# === REMNAWAVE BOT AUTOGENERATED END ===" -NGINX_MARKER_START = " # === REMNAWAVE BOT AUTOGENERATED START ===" -NGINX_MARKER_END = " # === REMNAWAVE BOT AUTOGENERATED END ===" - - -@dataclass -class ProxyConfig: - webhook_domain: Optional[str] - miniapp_domain: Optional[str] - redirect_domain: Optional[str] - - -@dataclass -class ProxySetupResult: - embedded_created: bool - restart_containers: List[str] - - -class CommandError(RuntimeError): - """Raised when a shell command fails.""" - - -def run_command( - command: Iterable[str], - *, - cwd: Optional[Path] = None, - check: bool = True, - capture_output: bool = False, - text: bool = True, -) -> subprocess.CompletedProcess: - """Execute a command and display it for transparency.""" - - cmd_list = list(command) - print(f"\n$ {' '.join(shlex.quote(part) for part in cmd_list)}") - result = subprocess.run( - cmd_list, - cwd=str(cwd) if cwd else None, - check=False, - capture_output=capture_output, - text=text, - ) - if check and result.returncode != 0: - raise CommandError( - f"Команда {' '.join(cmd_list)} завершилась с кодом {result.returncode}:\n" - f"STDOUT: {result.stdout}\nSTDERR: {result.stderr}" - ) - return result - - -def ensure_repo(target_dir: Path) -> Path: - """Clone the repository or update an existing checkout.""" - - if target_dir.exists(): - print(f"Каталог {target_dir} уже существует. Попытаемся обновить репозиторий.") - if (target_dir / ".git").exists(): - run_command(["git", "pull"], cwd=target_dir) - else: - print("Каталог существует, но не является git-репозиторием. Пропускаем обновление.") - else: - target_dir.parent.mkdir(parents=True, exist_ok=True) - run_command(["git", "clone", REPO_URL, str(target_dir)]) - return target_dir - - -def ensure_directories(base_dir: Path) -> None: - """Create required directories and ensure permissions.""" - - for rel_path in REQUIRED_DIRECTORIES: - full_path = base_dir / rel_path - full_path.mkdir(parents=True, exist_ok=True) - print("Настраиваем права доступа и владельцев для каталогов данных...") - run_command(["chmod", "-R", DIR_PERMISSIONS, "./logs", "./data"], cwd=base_dir) - chown_cmd = ["chown", "-R", DIR_OWNER, "./logs", "./data"] - if os.geteuid() != 0: - chown_cmd.insert(0, "sudo") - try: - run_command(chown_cmd, cwd=base_dir) - except CommandError as exc: - print(f"Не удалось изменить владельца: {exc}. Продолжим.") - - -def prompt( - text: str, - *, - default: Optional[str] = None, - required: bool = False, - validator: Optional[Iterable[str]] = None, -) -> Optional[str]: - """Prompt user for input with optional validation.""" - - allowed = set(validator) if validator else None - while True: - suffix = f" [{default}]" if default else "" - value = input(f"{text}{suffix}: ").strip() - if not value and default is not None: - value = default - if not value and required: - print("Это обязательное значение. Повторите ввод.") - continue - if allowed and value and value not in allowed: - print(f"Недопустимое значение. Возможные варианты: {', '.join(sorted(allowed))}") - continue - return value or None - - -def load_env(path: Path) -> Dict[str, str]: - env: Dict[str, str] = {} - if path.exists(): - for line in path.read_text().splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - if "=" in stripped: - key, value = stripped.split("=", 1) - env[key.strip()] = value - return env - - -def save_env(path: Path, updates: Dict[str, Optional[str]]) -> None: - """Update the .env file preserving comments and unknown keys.""" - - lines: List[str] = [] - existing_env = load_env(path) - updated_keys: set[str] = set() - - if path.exists(): - source_lines = path.read_text().splitlines() - else: - example_path = path.with_suffix(".example") - source_lines = example_path.read_text().splitlines() if example_path.exists() else [] - - for line in source_lines: - stripped = line.strip() - if stripped and not stripped.startswith("#") and "=" in line: - key, _ = line.split("=", 1) - key = key.strip() - if key in updates: - value = updates[key] - if value is None: - value = existing_env.get(key, "") - lines.append(f"{key}={value}") - updated_keys.add(key) - continue - lines.append(line) - - for key, value in updates.items(): - if key not in updated_keys: - lines.append(f"{key}={value or ''}") - - path.write_text("\n".join(lines) + "\n") - print(f"Файл {path} обновлён.") - - -def configure_env(base_dir: Path) -> None: - env_path = base_dir / ".env" - env_values = load_env(env_path) - print("\n=== Настройка файла .env ===") - - updates: Dict[str, Optional[str]] = {} - for key, required, description in ENV_FIELDS: - current = env_values.get(key) - updates[key] = prompt(description, default=current, required=required) - - auth_type_default = env_values.get("REMNAWAVE_AUTH_TYPE") - auth_type = prompt( - "Нужен тип авторизации панели? (оставьте пустым, чтобы пропустить)", - default=auth_type_default, - validator=["api_key", "basic_auth", ""], - ) - if auth_type: - updates["REMNAWAVE_AUTH_TYPE"] = auth_type - - if auth_type == "basic_auth": - for key, _, description in OPTIONAL_PROMPTS[1:3]: - current = env_values.get(key) - updates[key] = prompt(description, default=current) - else: - updates["REMNAWAVE_USERNAME"] = env_values.get("REMNAWAVE_USERNAME") - updates["REMNAWAVE_PASSWORD"] = env_values.get("REMNAWAVE_PASSWORD") - - secret_current = env_values.get("REMNAWAVE_SECRET_KEY") - updates["REMNAWAVE_SECRET_KEY"] = prompt( - OPTIONAL_PROMPTS[-1][2], default=secret_current - ) - - save_env(env_path, updates) - - -def ensure_docker_compose() -> List[str]: - for candidate in (["docker", "compose"], ["docker-compose"]): - try: - run_command(candidate + ["version"], capture_output=True) - return candidate - except CommandError: - continue - raise RuntimeError("Docker Compose не найден. Установите docker-compose или docker compose.") - - -def run_compose(base_dir: Path, command: List[str]) -> None: - compose = ensure_docker_compose() - run_command(compose + command, cwd=base_dir) - - -def detect_proxy_container(image_keywords: Iterable[str]) -> Optional[tuple[str, Dict]]: - result = run_command( - ["docker", "ps", "--format", "{{.ID}} {{.Image}} {{.Names}}"], - capture_output=True, - ) - lines = (result.stdout or "").splitlines() - for line in lines: - parts = line.split() - if len(parts) < 3: - continue - container_id, image, name = parts[0], parts[1], parts[2] - combined = f"{image} {name}".lower() - if any(keyword in combined for keyword in image_keywords): - inspect = run_command(["docker", "inspect", container_id], capture_output=True) - data = json.loads(inspect.stdout)[0] - return name, data - return None - - -def find_config_mount(data: Dict, target_filename: str) -> Optional[Path]: - mounts = data.get("Mounts", []) - for mount in mounts: - destination = mount.get("Destination", "") - if destination.endswith(target_filename) or destination.endswith(os.path.basename(target_filename)): - source = mount.get("Source") - if source: - return Path(source) - for mount in mounts: - destination = mount.get("Destination", "") - if destination.endswith(os.path.dirname(target_filename).rstrip("/")): - source = mount.get("Source") - if source: - return Path(source) / os.path.basename(target_filename) - return None - - -def render_caddy_snippet(proxy: ProxyConfig) -> str: - lines = [CADDY_MARKER_START] - if proxy.webhook_domain: - lines.extend( - [ - f"{proxy.webhook_domain} {{", - " encode gzip", - " @yookassa path /yookassa*", - " reverse_proxy @yookassa remnawave_bot:8082", - " @pal24 path /pal24-webhook*", - " reverse_proxy @pal24 remnawave_bot:8084", - " reverse_proxy remnawave_bot:8081", - "}", - ] - ) - if proxy.miniapp_domain: - lines.extend( - [ - f"{proxy.miniapp_domain} {{", - " encode gzip", - " reverse_proxy remnawave_bot:8080", - "}", - ] - ) - if proxy.redirect_domain: - lines.extend( - [ - f"{proxy.redirect_domain} {{", - " encode gzip", - " handle_path /* {", - " reverse_proxy remnawave_bot:8080", - " }", - "}", - ] - ) - lines.append(CADDY_MARKER_END) - return "\n".join(lines) + "\n" - - -def render_nginx_snippet(proxy: ProxyConfig) -> str: - snippets: List[str] = [] - if proxy.webhook_domain: - snippets.append( - textwrap.dedent( - f""" - server {{ - listen 80; - listen 443 ssl; - server_name {proxy.webhook_domain}; - - location /yookassa {{ - proxy_pass http://remnawave_bot:8082; - include /etc/nginx/proxy_params; - }} - - location /pal24-webhook {{ - proxy_pass http://remnawave_bot:8084; - include /etc/nginx/proxy_params; - }} - - location / {{ - proxy_pass http://remnawave_bot:8081; - include /etc/nginx/proxy_params; - }} - }} - """ - ).strip() - ) - if proxy.miniapp_domain: - snippets.append( - textwrap.dedent( - f""" - server {{ - listen 80; - listen 443 ssl; - server_name {proxy.miniapp_domain}; - - location / {{ - proxy_pass http://remnawave_bot:8080; - include /etc/nginx/proxy_params; - }} - }} - """ - ).strip() - ) - if proxy.redirect_domain: - snippets.append( - textwrap.dedent( - f""" - server {{ - listen 80; - listen 443 ssl; - server_name {proxy.redirect_domain}; - - location / {{ - proxy_pass http://remnawave_bot:8080; - include /etc/nginx/proxy_params; - }} - }} - """ - ).strip() - ) - if not snippets: - return "" - block = "\n\n".join(snippets) - indented = "\n".join(f" {line}" if line else line for line in block.splitlines()) - return "\n".join([NGINX_MARKER_START, indented, NGINX_MARKER_END, ""]) - - -def update_caddy_config(config_path: Path, snippet: str) -> bool: - if not snippet.strip(): - return False - config_path.parent.mkdir(parents=True, exist_ok=True) - content = config_path.read_text() if config_path.exists() else "" - if CADDY_MARKER_START in content and CADDY_MARKER_END in content: - start = content.index(CADDY_MARKER_START) - end = content.index(CADDY_MARKER_END) + len(CADDY_MARKER_END) - content = content[:start].rstrip() + "\n\n" + content[end:].lstrip() - new_content = (content.rstrip() + "\n\n" + snippet).strip() + "\n" - if config_path.exists() and config_path.read_text() == new_content: - return False - config_path.write_text(new_content) - print(f"Конфигурация Caddy обновлена: {config_path}") - return True - - -def update_nginx_config(config_path: Path, snippet: str) -> bool: - if not snippet.strip(): - return False - config_path.parent.mkdir(parents=True, exist_ok=True) - content = config_path.read_text() if config_path.exists() else "" - if NGINX_MARKER_START in content and NGINX_MARKER_END in content: - start = content.index(NGINX_MARKER_START) - end = content.index(NGINX_MARKER_END) + len(NGINX_MARKER_END) - content = content[:start].rstrip() + "\n" + content[end:].lstrip() - new_content = (content.rstrip() + "\n\n" + snippet).strip() + "\n" - if config_path.exists() and config_path.read_text() == new_content: - return False - config_path.write_text(new_content) - print(f"Конфигурация Nginx обновлена: {config_path}") - return True - - -def ensure_network(name: str) -> None: - result = run_command(["docker", "network", "ls", "--format", "{{.Name}}"], capture_output=True) - networks = set(filter(None, (result.stdout or "").splitlines())) - if name not in networks: - run_command(["docker", "network", "create", name]) - else: - print(f"Сеть {name} уже существует.") - - -def container_exists(name: str) -> bool: - result = run_command(["docker", "ps", "-a", "--format", "{{.Names}}"], capture_output=True) - containers = set(filter(None, (result.stdout or "").splitlines())) - return name in containers - - -def connect_container_to_network(container: str, network: str) -> None: - if not container_exists(container): - print(f"Контейнер {container} не найден. Пропускаем подключение к сети {network}.") - return - result = run_command(["docker", "network", "inspect", network], capture_output=True) - data = json.loads(result.stdout) - containers = data[0].get("Containers", {}) - if container not in containers: - run_command(["docker", "network", "connect", network, container]) - else: - print(f"Контейнер {container} уже находится в сети {network}.") - - -def ensure_bot_network(base_dir: Path) -> str: - result = run_command(["docker", "network", "ls", "--format", "{{.Name}}"], capture_output=True) - networks = (result.stdout or "").splitlines() - project_name = base_dir.name.replace(" ", "") - expected = f"{project_name}_bot_network" - for network in networks: - if network.endswith("bot_network"): - expected = network - break - ensure_network(expected) - return expected - - -def prepare_embedded_caddy(proxy: ProxyConfig) -> bool: - DEFAULT_CADDY_DIR.mkdir(parents=True, exist_ok=True) - compose_path = DEFAULT_CADDY_DIR / "docker-compose.yml" - caddyfile_path = DEFAULT_CADDY_DIR / "Caddyfile" - snippet = render_caddy_snippet(proxy) - changed = update_caddy_config(caddyfile_path, snippet) - compose_content = textwrap.dedent( - """ - version: '3.8' - services: - caddy: - image: caddy:2-alpine - container_name: remnawave_caddy - restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data - - caddy_config:/config - networks: - - remnawave_shared - volumes: - caddy_data: - caddy_config: - networks: - remnawave_shared: - external: true - """ - ).strip() + "\n" - compose_path.write_text(compose_content) - ensure_network("remnawave_shared") - print(f"Caddy будет запущен с конфигурацией в {DEFAULT_CADDY_DIR}") - return changed - - -def start_embedded_caddy() -> None: - compose = ensure_docker_compose() - run_command(compose + ["up", "-d"], cwd=DEFAULT_CADDY_DIR) - - -def setup_proxy(proxy: ProxyConfig) -> ProxySetupResult: - caddy_container = detect_proxy_container(["caddy"]) - nginx_container = detect_proxy_container(["nginx"]) - containers_to_restart: List[str] = [] - - if caddy_container: - name, data = caddy_container - print(f"Найден контейнер Caddy: {name}") - config_path = find_config_mount(data, "Caddyfile") - if config_path: - if update_caddy_config(config_path, render_caddy_snippet(proxy)): - containers_to_restart.append(name) - else: - print("Не удалось определить путь Caddyfile. Конфигурация не изменена.") - return ProxySetupResult(False, containers_to_restart) - if nginx_container: - name, data = nginx_container - print(f"Найден контейнер Nginx: {name}") - config_path = find_config_mount(data, "nginx.conf") - if config_path: - if update_nginx_config(config_path, render_nginx_snippet(proxy)): - containers_to_restart.append(name) - else: - print("Не удалось определить путь nginx.conf. Конфигурация не изменена.") - return ProxySetupResult(False, containers_to_restart) - print("Caddy/Nginx в Docker не обнаружены. Будет создан отдельный инстанс Caddy.") - changed = prepare_embedded_caddy(proxy) - return ProxySetupResult(changed, containers_to_restart) - - -def start_services(base_dir: Path) -> None: - print("\n=== Запуск контейнеров бота ===") - run_compose(base_dir, ["pull"]) - run_compose(base_dir, ["up", "-d", "--remove-orphans"]) - - network = ensure_bot_network(base_dir) - ensure_network("remnawave_shared") - connect_container_to_network(BOT_SERVICE_NAME, "remnawave_shared") - connect_container_to_network("remnawave_caddy", network) - - -def restart_container(name: str) -> None: - if not container_exists(name): - print(f"Контейнер {name} не найден, перезапуск невозможен.") - return - run_command(["docker", "restart", name]) - - -def compose_logs(base_dir: Path, service: str, tail: str = "100") -> None: - run_compose(base_dir, ["logs", service, "--tail", tail]) - - -def compose_status(base_dir: Path) -> None: - print("\n=== Состояние контейнеров ===") - run_compose(base_dir, ["ps"]) - - -def monitoring_loop(base_dir: Path) -> None: - while True: - compose_status(base_dir) - print( - "\nДоступные действия:\n" - " [R] Перезапустить бот\n" - " [L] Показать последние логи\n" - " [U] Обновить репозиторий и перезапустить\n" - " [Q] Выход" - ) - choice = input("Ваш выбор: ").strip().lower() - if choice == "r": - restart_container(BOT_SERVICE_NAME) - elif choice == "l": - compose_logs(base_dir, BOT_SERVICE_NAME) - elif choice == "u": - run_command(["git", "pull"], cwd=base_dir) - start_services(base_dir) - elif choice == "q": - print("Выход из мониторинга.") - break - else: - print("Неизвестная команда.") - - -def request_proxy_domains() -> ProxyConfig: - print("\n=== Настройка доменов для прокси ===") - webhook = prompt("Домен для вебхуков (например, hooks.example.com)") - miniapp = prompt("Домен мини-приложения (например, miniapp.example.com)") - redirect = prompt("Домен страницы редиректа (например, redirect.example.com)") - return ProxyConfig(webhook, miniapp, redirect) - - -def main() -> int: - install_dir_input = prompt( - "Каталог установки бота", default=str(DEFAULT_INSTALL_DIR), required=True - ) - if not install_dir_input: - print("Не указан каталог установки.") - return 1 - install_dir = Path(install_dir_input).expanduser() - - ensure_repo(install_dir) - ensure_directories(install_dir) - configure_env(install_dir) - - proxy_config = request_proxy_domains() - proxy_result = setup_proxy(proxy_config) - if proxy_result.embedded_created: - start_embedded_caddy() - for container in proxy_result.restart_containers: - restart_container(container) - - start_services(install_dir) - monitoring_loop(install_dir) - - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except KeyboardInterrupt: - print("\nОтменено пользователем.") - sys.exit(0)