Generate and deploy real WG/AWG configs to VPS over SSH
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user