241 lines
8.4 KiB
Python
241 lines
8.4 KiB
Python
"""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()
|