Revert "Add automated installer and monitoring script"

This commit is contained in:
Egor
2025-10-01 21:33:11 +03:00
committed by GitHub
parent 9b2d9406d3
commit a41531944c
3 changed files with 1 additions and 657 deletions

2
.gitignore vendored
View File

@@ -8,8 +8,6 @@
!app-config.json
!main.py
!requirements.txt
!scripts/
!scripts/**
!docs/
!docs/**

View File

@@ -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` и параметры авторизации панели.
---
## ⚙️ Конфигурация

View File

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