"""Deploy real WireGuard / AmneziaWG configs to remote VPS over SSH.""" from __future__ import annotations import logging import time from app.models import Protocol, VpnServer from app.services.ssh import SSHManager logger = logging.getLogger(__name__) DOCKER_IMAGE = "amneziavpn/amnezia-wg:latest" def ssh_from_server(server: VpnServer) -> SSHManager: if not server.ssh_host or not server.ssh_username: raise ValueError("SSH данные сервера не заданы") if not server.ssh_password and not server.ssh_private_key: raise ValueError("Нужен пароль или ключ SSH") return SSHManager( host=server.ssh_host, port=server.ssh_port or 22, username=server.ssh_username, password=server.ssh_password, private_key=server.ssh_private_key, ) def container_name(server: VpnServer) -> str: safe = "".join(c if c.isalnum() else "-" for c in server.interface_name) return f"vpn-panel-{safe}" def remote_dir(server: VpnServer) -> str: return f"/opt/vpn-panel/{server.interface_name}" def remote_conf_path(server: VpnServer) -> str: return f"{remote_dir(server)}/{server.interface_name}.conf" def wg_binary(protocol: str) -> str: return "awg" if protocol == Protocol.AWG2.value else "wg" def ensure_docker(ssh: SSHManager) -> None: out, _, code = ssh.run_command("docker --version 2>/dev/null") if code == 0 and out: return script = r""" set -e export DEBIAN_FRONTEND=noninteractive if command -v apt-get >/dev/null 2>&1; then apt-get update -yq apt-get install -yq docker.io curl systemctl enable --now docker elif command -v dnf >/dev/null 2>&1; then dnf install -y docker systemctl enable --now docker elif command -v yum >/dev/null 2>&1; then yum install -y docker systemctl enable --now docker else echo "No supported package manager"; exit 1 fi docker --version """ out, err, code = ssh.run_sudo_script(script, timeout=300) if code != 0: raise RuntimeError(f"Не удалось установить Docker: {err or out}") def install_vpn(server: VpnServer, server_config: str) -> list[str]: """Install Docker container and apply initial server config on VPS.""" logs: list[str] = [] ssh = ssh_from_server(server) ssh.connect() try: logs.append("Проверка Docker...") ensure_docker(ssh) logs.append("Docker готов") name = container_name(server) conf_dir = remote_dir(server) conf_path = remote_conf_path(server) port = server.public_port binary = wg_binary(server.protocol) ssh.run_sudo_command(f"mkdir -p {conf_dir}") ssh.upload_file_sudo(server_config.replace("\r\n", "\n"), conf_path) logs.append(f"Конфиг записан: {conf_path}") # Stop old container if exists ssh.run_sudo_command(f"docker rm -f {name} 2>/dev/null || true") logs.append(f"Pull образа {DOCKER_IMAGE}...") out, err, code = ssh.run_sudo_command(f"docker pull {DOCKER_IMAGE}", timeout=300) if code != 0: raise RuntimeError(f"docker pull failed: {err or out}") # start.sh brings interface up and keeps container alive start_script = f"""#!/bin/bash set -e CONF="{conf_path}" IFACE="{server.interface_name}" BIN="{binary}" sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true # Normalize config path inside container (mounted at /config) if [ -f /config/$IFACE.conf ]; then CONF=/config/$IFACE.conf fi mkdir -p /etc/amnezia/$BIN 2>/dev/null || mkdir -p /etc/wireguard 2>/dev/null || true cp -f "$CONF" /etc/amnezia/$BIN/$IFACE.conf 2>/dev/null || cp -f "$CONF" /etc/wireguard/$IFACE.conf 2>/dev/null || true $BIN-quick down "$CONF" 2>/dev/null || true $BIN-quick up "$CONF" # NAT iptables -t nat -C POSTROUTING -s {server.subnet} -o eth0 -j MASQUERADE 2>/dev/null \\ || iptables -t nat -A POSTROUTING -s {server.subnet} -o eth0 -j MASQUERADE iptables -C FORWARD -i $IFACE -j ACCEPT 2>/dev/null || iptables -A FORWARD -i $IFACE -j ACCEPT iptables -C FORWARD -o $IFACE -j ACCEPT 2>/dev/null || iptables -A FORWARD -o $IFACE -j ACCEPT tail -f /dev/null """ ssh.upload_file_sudo(start_script, f"{conf_dir}/start.sh") ssh.run_sudo_command(f"chmod +x {conf_dir}/start.sh") run_cmd = ( f"docker run -d --name {name} --restart always --privileged " f"--cap-add=NET_ADMIN --cap-add=SYS_MODULE " f"-p {port}:{port}/udp " f"-v {conf_dir}:/config " f"-v /lib/modules:/lib/modules " f'--sysctl="net.ipv4.conf.all.src_valid_mark=1" ' f'--sysctl="net.ipv4.ip_forward=1" ' f"--entrypoint /bin/bash {DOCKER_IMAGE} " f"/config/start.sh" ) logs.append("Запуск контейнера...") out, err, code = ssh.run_sudo_command(run_cmd, timeout=120) if code != 0: raise RuntimeError(f"Не удалось запустить контейнер: {err or out}") # wait running for _ in range(15): status, _, _ = ssh.run_sudo_command( f"docker inspect --format='{{{{.State.Status}}}}' {name}" ) if "running" in status: break time.sleep(2) else: logs_out, _, _ = ssh.run_sudo_command(f"docker logs --tail 40 {name} 2>&1") raise RuntimeError(f"Контейнер не запустился:\n{logs_out}") time.sleep(3) iface_check, _, _ = ssh.run_sudo_command( f"docker exec {name} {binary} show {server.interface_name} 2>&1 || true" ) logs.append("Контейнер запущен") if iface_check.strip(): logs.append(f"Интерфейс: OK ({server.interface_name})") else: logs.append("Предупреждение: интерфейс ещё поднимается, конфиг на диске готов") # open firewall on host if ufw ssh.run_sudo_command(f"ufw allow {port}/udp 2>/dev/null || true") ssh.run_sudo_command( "sysctl -w net.ipv4.ip_forward=1; " "grep -q '^net.ipv4.ip_forward=1' /etc/sysctl.conf || " "echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf" ) logs.append("Готово: VPN установлен, клиентские .conf можно выдавать") return logs finally: ssh.disconnect() def deploy_config(server: VpnServer, server_config: str) -> None: """Push updated server.conf to VPS and sync peers without full reinstall.""" ssh = ssh_from_server(server) ssh.connect() try: name = container_name(server) conf_path = remote_conf_path(server) conf_dir = remote_dir(server) binary = wg_binary(server.protocol) ssh.run_sudo_command(f"mkdir -p {conf_dir}") ssh.upload_file_sudo(server_config.replace("\r\n", "\n"), conf_path) # container exists? names, _, _ = ssh.run_sudo_command( f"docker ps -a --filter name=^{name}$ --format '{{{{.Names}}}}'" ) if name not in names: raise RuntimeError( "VPN контейнер не установлен. Нажмите «Установить VPN на сервер»." ) # copy into running paths and sync sync_script = f""" set -e NAME="{name}" CONF="{conf_path}" IFACE="{server.interface_name}" BIN="{binary}" docker cp "$CONF" "$NAME:/config/$IFACE.conf" docker exec "$NAME" bash -c "cp -f /config/$IFACE.conf /etc/amnezia/$BIN/$IFACE.conf 2>/dev/null || cp -f /config/$IFACE.conf /etc/wireguard/$IFACE.conf 2>/dev/null || true" # try syncconf; fallback restart if docker exec "$NAME" bash -c "$BIN syncconf $IFACE <($BIN-quick strip /config/$IFACE.conf)" 2>/dev/null; then echo synced else docker restart "$NAME" echo restarted fi """ out, err, code = ssh.run_sudo_script(sync_script, timeout=90) if code != 0: raise RuntimeError(f"Не удалось применить конфиг: {err or out}") finally: ssh.disconnect() def container_running(server: VpnServer) -> bool: ssh = ssh_from_server(server) ssh.connect() try: name = container_name(server) out, _, _ = ssh.run_sudo_command( f"docker ps --filter name=^{name}$ --format '{{{{.Status}}}}'" ) return "Up" in out finally: ssh.disconnect()