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,36 @@
|
|||||||
|
"""Add AWG S3/S4, MTU and vpn_installed
|
||||||
|
|
||||||
|
Revision ID: 0004_real_vpn
|
||||||
|
Revises: 0003_ssh_status
|
||||||
|
Create Date: 2026-07-25
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0004_real_vpn"
|
||||||
|
down_revision: Union[str, None] = "0003_ssh_status"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("vpn_servers", sa.Column("s3", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("vpn_servers", sa.Column("s4", sa.Integer(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"vpn_servers",
|
||||||
|
sa.Column("mtu", sa.Integer(), nullable=False, server_default="1420"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"vpn_servers",
|
||||||
|
sa.Column("vpn_installed", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("vpn_servers", "vpn_installed")
|
||||||
|
op.drop_column("vpn_servers", "mtu")
|
||||||
|
op.drop_column("vpn_servers", "s4")
|
||||||
|
op.drop_column("vpn_servers", "s3")
|
||||||
@@ -56,10 +56,14 @@ class VpnServer(Base):
|
|||||||
jmax: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
jmax: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
s1: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
s1: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
s2: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
s2: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
s3: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
s4: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
h1: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
h1: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
h2: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
h2: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
h3: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
h3: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
h4: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
h4: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
mtu: Mapped[int] = mapped_column(Integer, default=1420)
|
||||||
|
vpn_installed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,27 @@ async def check_ssh(
|
|||||||
return RedirectResponse("/admin/servers?flash=ssh_ok", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse("/admin/servers?flash=ssh_ok", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers/{server_id}/install-vpn")
|
||||||
|
async def install_vpn(
|
||||||
|
server_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
):
|
||||||
|
if _is_redirect(admin):
|
||||||
|
return admin
|
||||||
|
try:
|
||||||
|
await vpn_service.install_vpn_on_server(db, server_id)
|
||||||
|
return RedirectResponse(
|
||||||
|
"/admin/servers?flash=vpn_installed",
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/servers?flash=error:{exc}",
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/servers/{server_id}/delete")
|
@router.post("/servers/{server_id}/delete")
|
||||||
async def delete_server(
|
async def delete_server(
|
||||||
server_id: int,
|
server_id: int,
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from app.services import crypto, vpn, ssh
|
from app.services import crypto, vpn, ssh, remote
|
||||||
|
|
||||||
__all__ = ["crypto", "vpn", "ssh"]
|
__all__ = ["crypto", "vpn", "ssh", "remote"]
|
||||||
|
|||||||
+63
-15
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -17,6 +18,21 @@ class KeyPair:
|
|||||||
public_key: str
|
public_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AwgParams:
|
||||||
|
jc: int
|
||||||
|
jmin: int
|
||||||
|
jmax: int
|
||||||
|
s1: int
|
||||||
|
s2: int
|
||||||
|
s3: int
|
||||||
|
s4: int
|
||||||
|
h1: int
|
||||||
|
h2: int
|
||||||
|
h3: int
|
||||||
|
h4: int
|
||||||
|
|
||||||
|
|
||||||
def generate_keypair() -> KeyPair:
|
def generate_keypair() -> KeyPair:
|
||||||
private = X25519PrivateKey.generate()
|
private = X25519PrivateKey.generate()
|
||||||
private_bytes = private.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
private_bytes = private.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
||||||
@@ -31,17 +47,36 @@ def generate_preshared_key() -> str:
|
|||||||
return base64.b64encode(secrets.token_bytes(32)).decode("ascii")
|
return base64.b64encode(secrets.token_bytes(32)).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_awg_params() -> AwgParams:
|
||||||
|
"""Real AmneziaWG-style obfuscation params (not 1/2/3/4 placeholders)."""
|
||||||
|
jc = random.randint(3, 10)
|
||||||
|
jmin = random.randint(10, 40)
|
||||||
|
jmax = random.randint(jmin + 10, jmin + 60)
|
||||||
|
s1 = random.randint(15, 70)
|
||||||
|
s2 = random.randint(15, 70)
|
||||||
|
s3 = random.randint(15, 70)
|
||||||
|
s4 = random.randint(15, 70)
|
||||||
|
# Distinct large magic headers
|
||||||
|
headers = random.sample(range(100_000_000, 2_000_000_000), 4)
|
||||||
|
return AwgParams(
|
||||||
|
jc=jc,
|
||||||
|
jmin=jmin,
|
||||||
|
jmax=jmax,
|
||||||
|
s1=s1,
|
||||||
|
s2=s2,
|
||||||
|
s3=s3,
|
||||||
|
s4=s4,
|
||||||
|
h1=headers[0],
|
||||||
|
h2=headers[1],
|
||||||
|
h3=headers[2],
|
||||||
|
h4=headers[3],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def next_client_ip(subnet: str, used_addresses: list[str]) -> str:
|
def next_client_ip(subnet: str, used_addresses: list[str]) -> str:
|
||||||
network = ipaddress.ip_network(subnet, strict=False)
|
network = ipaddress.ip_network(subnet, strict=False)
|
||||||
used = set()
|
used = {addr.split("/")[0] for addr in used_addresses}
|
||||||
for addr in used_addresses:
|
|
||||||
ip = addr.split("/")[0]
|
|
||||||
used.add(ip)
|
|
||||||
|
|
||||||
# .1 is reserved for server
|
|
||||||
for host in network.hosts():
|
for host in network.hosts():
|
||||||
if str(host).endswith(".0") or str(host).endswith(".255"):
|
|
||||||
continue
|
|
||||||
if str(host) == str(network.network_address + 1):
|
if str(host) == str(network.network_address + 1):
|
||||||
continue
|
continue
|
||||||
if str(host) not in used:
|
if str(host) not in used:
|
||||||
@@ -65,32 +100,38 @@ def render_client_config(
|
|||||||
endpoint_host: str,
|
endpoint_host: str,
|
||||||
endpoint_port: int,
|
endpoint_port: int,
|
||||||
allowed_ips: str,
|
allowed_ips: str,
|
||||||
|
mtu: int = 1420,
|
||||||
jc: int | None = None,
|
jc: int | None = None,
|
||||||
jmin: int | None = None,
|
jmin: int | None = None,
|
||||||
jmax: int | None = None,
|
jmax: int | None = None,
|
||||||
s1: int | None = None,
|
s1: int | None = None,
|
||||||
s2: int | None = None,
|
s2: int | None = None,
|
||||||
|
s3: int | None = None,
|
||||||
|
s4: int | None = None,
|
||||||
h1: int | None = None,
|
h1: int | None = None,
|
||||||
h2: int | None = None,
|
h2: int | None = None,
|
||||||
h3: int | None = None,
|
h3: int | None = None,
|
||||||
h4: int | None = None,
|
h4: int | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
addr = client_address if "/" in client_address else f"{client_address}/32"
|
||||||
lines = [
|
lines = [
|
||||||
"[Interface]",
|
"[Interface]",
|
||||||
f"PrivateKey = {client_private_key}",
|
f"PrivateKey = {client_private_key}",
|
||||||
f"Address = {client_address}",
|
f"Address = {addr}",
|
||||||
f"DNS = {dns}",
|
f"DNS = {dns}",
|
||||||
|
f"MTU = {mtu}",
|
||||||
]
|
]
|
||||||
|
|
||||||
if protocol == "awg2":
|
if protocol == "awg2":
|
||||||
# AmneziaWG 2.0 obfuscation parameters
|
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
f"Jc = {jc if jc is not None else 4}",
|
f"Jc = {jc if jc is not None else 4}",
|
||||||
f"Jmin = {jmin if jmin is not None else 40}",
|
f"Jmin = {jmin if jmin is not None else 40}",
|
||||||
f"Jmax = {jmax if jmax is not None else 70}",
|
f"Jmax = {jmax if jmax is not None else 70}",
|
||||||
f"S1 = {s1 if s1 is not None else 0}",
|
f"S1 = {s1 if s1 is not None else 15}",
|
||||||
f"S2 = {s2 if s2 is not None else 0}",
|
f"S2 = {s2 if s2 is not None else 18}",
|
||||||
|
f"S3 = {s3 if s3 is not None else 20}",
|
||||||
|
f"S4 = {s4 if s4 is not None else 23}",
|
||||||
f"H1 = {h1 if h1 is not None else 1}",
|
f"H1 = {h1 if h1 is not None else 1}",
|
||||||
f"H2 = {h2 if h2 is not None else 2}",
|
f"H2 = {h2 if h2 is not None else 2}",
|
||||||
f"H3 = {h3 if h3 is not None else 3}",
|
f"H3 = {h3 if h3 is not None else 3}",
|
||||||
@@ -126,6 +167,8 @@ def render_server_config(
|
|||||||
jmax: int | None = None,
|
jmax: int | None = None,
|
||||||
s1: int | None = None,
|
s1: int | None = None,
|
||||||
s2: int | None = None,
|
s2: int | None = None,
|
||||||
|
s3: int | None = None,
|
||||||
|
s4: int | None = None,
|
||||||
h1: int | None = None,
|
h1: int | None = None,
|
||||||
h2: int | None = None,
|
h2: int | None = None,
|
||||||
h3: int | None = None,
|
h3: int | None = None,
|
||||||
@@ -146,8 +189,10 @@ def render_server_config(
|
|||||||
f"Jc = {jc if jc is not None else 4}",
|
f"Jc = {jc if jc is not None else 4}",
|
||||||
f"Jmin = {jmin if jmin is not None else 40}",
|
f"Jmin = {jmin if jmin is not None else 40}",
|
||||||
f"Jmax = {jmax if jmax is not None else 70}",
|
f"Jmax = {jmax if jmax is not None else 70}",
|
||||||
f"S1 = {s1 if s1 is not None else 0}",
|
f"S1 = {s1 if s1 is not None else 15}",
|
||||||
f"S2 = {s2 if s2 is not None else 0}",
|
f"S2 = {s2 if s2 is not None else 18}",
|
||||||
|
f"S3 = {s3 if s3 is not None else 20}",
|
||||||
|
f"S4 = {s4 if s4 is not None else 23}",
|
||||||
f"H1 = {h1 if h1 is not None else 1}",
|
f"H1 = {h1 if h1 is not None else 1}",
|
||||||
f"H2 = {h2 if h2 is not None else 2}",
|
f"H2 = {h2 if h2 is not None else 2}",
|
||||||
f"H3 = {h3 if h3 is not None else 3}",
|
f"H3 = {h3 if h3 is not None else 3}",
|
||||||
@@ -156,6 +201,9 @@ def render_server_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for peer in peers:
|
for peer in peers:
|
||||||
|
peer_addr = peer["address"]
|
||||||
|
if not peer_addr.endswith("/32"):
|
||||||
|
peer_addr = f"{peer_addr.split('/')[0]}/32"
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
"",
|
"",
|
||||||
@@ -163,7 +211,7 @@ def render_server_config(
|
|||||||
"[Peer]",
|
"[Peer]",
|
||||||
f"PublicKey = {peer['public_key']}",
|
f"PublicKey = {peer['public_key']}",
|
||||||
f"PresharedKey = {peer['preshared_key']}",
|
f"PresharedKey = {peer['preshared_key']}",
|
||||||
f"AllowedIPs = {peer['address']}",
|
f"AllowedIPs = {peer_addr}",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -108,6 +108,43 @@ class SSHManager:
|
|||||||
full = f"sudo {clean}"
|
full = f"sudo {clean}"
|
||||||
return self.run_command(full, timeout=timeout)
|
return self.run_command(full, timeout=timeout)
|
||||||
|
|
||||||
|
def upload_file(self, content: str, remote_path: str) -> None:
|
||||||
|
if not self.client:
|
||||||
|
raise ConnectionError("Not connected to server")
|
||||||
|
content = content.replace("\r\n", "\n")
|
||||||
|
sftp = self.client.open_sftp()
|
||||||
|
try:
|
||||||
|
with sftp.file(remote_path, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
finally:
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
def upload_file_sudo(self, content: str, remote_path: str) -> None:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
tmp = f"/tmp/_vpnpanel_{hashlib.md5(remote_path.encode()).hexdigest()[:8]}"
|
||||||
|
self.upload_file(content, tmp)
|
||||||
|
self.run_sudo_command(f"mv {tmp} {remote_path}")
|
||||||
|
self.run_sudo_command(f"chmod 644 {remote_path}")
|
||||||
|
|
||||||
|
def run_sudo_script(self, script: str, timeout: int = 180) -> tuple[str, str, int]:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
script = script.replace("\r\n", "\n")
|
||||||
|
tmp = f"/tmp/_vpnpanel_script_{hashlib.md5(script.encode()).hexdigest()[:8]}.sh"
|
||||||
|
self.upload_file(script, tmp)
|
||||||
|
if self._is_root:
|
||||||
|
out = self.run_command(f"bash {tmp}; rm -f {tmp}", timeout=timeout)
|
||||||
|
elif self.password:
|
||||||
|
escaped = self.password.replace("'", "'\\''")
|
||||||
|
out = self.run_command(
|
||||||
|
f"echo '{escaped}' | sudo -S -p '' bash {tmp}; rm -f {tmp}",
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
out = self.run_command(f"sudo bash {tmp}; rm -f {tmp}", timeout=timeout)
|
||||||
|
return out
|
||||||
|
|
||||||
def test_connection(self) -> str:
|
def test_connection(self) -> str:
|
||||||
out, err, code = self.run_command(
|
out, err, code = self.run_command(
|
||||||
"uname -sr && cat /etc/os-release 2>/dev/null | head -2"
|
"uname -sr && cat /etc/os-release 2>/dev/null | head -2"
|
||||||
|
|||||||
+61
-16
@@ -11,6 +11,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import Protocol, VpnClient, VpnServer
|
from app.models import Protocol, VpnClient, VpnServer
|
||||||
from app.services.crypto import (
|
from app.services.crypto import (
|
||||||
|
generate_awg_params,
|
||||||
generate_keypair,
|
generate_keypair,
|
||||||
generate_preshared_key,
|
generate_preshared_key,
|
||||||
next_client_ip,
|
next_client_ip,
|
||||||
@@ -18,6 +19,7 @@ from app.services.crypto import (
|
|||||||
render_server_config,
|
render_server_config,
|
||||||
server_address,
|
server_address,
|
||||||
)
|
)
|
||||||
|
from app.services.remote import deploy_config, install_vpn
|
||||||
from app.services.ssh import probe_tcp, verify_ssh
|
from app.services.ssh import probe_tcp, verify_ssh
|
||||||
|
|
||||||
|
|
||||||
@@ -80,29 +82,36 @@ async def ensure_default_servers(session: AsyncSession) -> None:
|
|||||||
|
|
||||||
def _defaults_for_protocol(protocol: str) -> dict:
|
def _defaults_for_protocol(protocol: str) -> dict:
|
||||||
if protocol == Protocol.AWG2.value:
|
if protocol == Protocol.AWG2.value:
|
||||||
|
awg = generate_awg_params()
|
||||||
return {
|
return {
|
||||||
"interface_name": settings.awg_interface,
|
"interface_name": settings.awg_interface,
|
||||||
"subnet": settings.awg_subnet,
|
"subnet": settings.awg_subnet,
|
||||||
"public_port": settings.awg_public_port,
|
"public_port": settings.awg_public_port,
|
||||||
"jc": 4,
|
"mtu": 1280,
|
||||||
"jmin": 40,
|
"jc": awg.jc,
|
||||||
"jmax": 70,
|
"jmin": awg.jmin,
|
||||||
"s1": 0,
|
"jmax": awg.jmax,
|
||||||
"s2": 0,
|
"s1": awg.s1,
|
||||||
"h1": 1,
|
"s2": awg.s2,
|
||||||
"h2": 2,
|
"s3": awg.s3,
|
||||||
"h3": 3,
|
"s4": awg.s4,
|
||||||
"h4": 4,
|
"h1": awg.h1,
|
||||||
|
"h2": awg.h2,
|
||||||
|
"h3": awg.h3,
|
||||||
|
"h4": awg.h4,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"interface_name": settings.wg_interface,
|
"interface_name": settings.wg_interface,
|
||||||
"subnet": settings.wg_subnet,
|
"subnet": settings.wg_subnet,
|
||||||
"public_port": settings.public_port,
|
"public_port": settings.public_port,
|
||||||
|
"mtu": 1420,
|
||||||
"jc": None,
|
"jc": None,
|
||||||
"jmin": None,
|
"jmin": None,
|
||||||
"jmax": None,
|
"jmax": None,
|
||||||
"s1": None,
|
"s1": None,
|
||||||
"s2": None,
|
"s2": None,
|
||||||
|
"s3": None,
|
||||||
|
"s4": None,
|
||||||
"h1": None,
|
"h1": None,
|
||||||
"h2": None,
|
"h2": None,
|
||||||
"h3": None,
|
"h3": None,
|
||||||
@@ -185,10 +194,14 @@ async def create_server(
|
|||||||
jmax=defaults["jmax"],
|
jmax=defaults["jmax"],
|
||||||
s1=defaults["s1"],
|
s1=defaults["s1"],
|
||||||
s2=defaults["s2"],
|
s2=defaults["s2"],
|
||||||
|
s3=defaults["s3"],
|
||||||
|
s4=defaults["s4"],
|
||||||
h1=defaults["h1"],
|
h1=defaults["h1"],
|
||||||
h2=defaults["h2"],
|
h2=defaults["h2"],
|
||||||
h3=defaults["h3"],
|
h3=defaults["h3"],
|
||||||
h4=defaults["h4"],
|
h4=defaults["h4"],
|
||||||
|
mtu=int(defaults["mtu"]),
|
||||||
|
vpn_installed=False,
|
||||||
is_enabled=True,
|
is_enabled=True,
|
||||||
)
|
)
|
||||||
session.add(server)
|
session.add(server)
|
||||||
@@ -356,21 +369,27 @@ async def get_client_config(session: AsyncSession, client_id: int) -> str:
|
|||||||
if not client:
|
if not client:
|
||||||
raise ValueError("Client not found")
|
raise ValueError("Client not found")
|
||||||
server = client.server
|
server = client.server
|
||||||
|
dns = server.dns
|
||||||
|
if "," not in dns:
|
||||||
|
dns = f"{dns}, 1.0.0.1"
|
||||||
return render_client_config(
|
return render_client_config(
|
||||||
protocol=server.protocol,
|
protocol=server.protocol,
|
||||||
client_private_key=client.private_key,
|
client_private_key=client.private_key,
|
||||||
client_address=client.address,
|
client_address=client.address,
|
||||||
dns=server.dns,
|
dns=dns,
|
||||||
server_public_key=server.server_public_key,
|
server_public_key=server.server_public_key,
|
||||||
preshared_key=client.preshared_key,
|
preshared_key=client.preshared_key,
|
||||||
endpoint_host=server.public_host,
|
endpoint_host=server.public_host,
|
||||||
endpoint_port=server.public_port,
|
endpoint_port=server.public_port,
|
||||||
allowed_ips=client.allowed_ips,
|
allowed_ips=client.allowed_ips,
|
||||||
|
mtu=server.mtu or (1280 if server.protocol == Protocol.AWG2.value else 1420),
|
||||||
jc=server.jc,
|
jc=server.jc,
|
||||||
jmin=server.jmin,
|
jmin=server.jmin,
|
||||||
jmax=server.jmax,
|
jmax=server.jmax,
|
||||||
s1=server.s1,
|
s1=server.s1,
|
||||||
s2=server.s2,
|
s2=server.s2,
|
||||||
|
s3=server.s3,
|
||||||
|
s4=server.s4,
|
||||||
h1=server.h1,
|
h1=server.h1,
|
||||||
h2=server.h2,
|
h2=server.h2,
|
||||||
h3=server.h3,
|
h3=server.h3,
|
||||||
@@ -378,11 +397,7 @@ async def get_client_config(session: AsyncSession, client_id: int) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def sync_server_config(session: AsyncSession, server_id: int) -> Path:
|
def _build_server_config_text(server: VpnServer) -> str:
|
||||||
result = await session.execute(
|
|
||||||
select(VpnServer).options(selectinload(VpnServer.clients)).where(VpnServer.id == server_id)
|
|
||||||
)
|
|
||||||
server = result.scalar_one()
|
|
||||||
peers = [
|
peers = [
|
||||||
{
|
{
|
||||||
"name": c.name,
|
"name": c.name,
|
||||||
@@ -393,7 +408,7 @@ async def sync_server_config(session: AsyncSession, server_id: int) -> Path:
|
|||||||
for c in server.clients
|
for c in server.clients
|
||||||
if c.is_enabled
|
if c.is_enabled
|
||||||
]
|
]
|
||||||
content = render_server_config(
|
return render_server_config(
|
||||||
protocol=server.protocol,
|
protocol=server.protocol,
|
||||||
interface_name=server.interface_name,
|
interface_name=server.interface_name,
|
||||||
private_key=server.server_private_key,
|
private_key=server.server_private_key,
|
||||||
@@ -405,14 +420,44 @@ async def sync_server_config(session: AsyncSession, server_id: int) -> Path:
|
|||||||
jmax=server.jmax,
|
jmax=server.jmax,
|
||||||
s1=server.s1,
|
s1=server.s1,
|
||||||
s2=server.s2,
|
s2=server.s2,
|
||||||
|
s3=server.s3,
|
||||||
|
s4=server.s4,
|
||||||
h1=server.h1,
|
h1=server.h1,
|
||||||
h2=server.h2,
|
h2=server.h2,
|
||||||
h3=server.h3,
|
h3=server.h3,
|
||||||
h4=server.h4,
|
h4=server.h4,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_server_config(session: AsyncSession, server_id: int, push_remote: bool = True) -> Path:
|
||||||
|
result = await session.execute(
|
||||||
|
select(VpnServer).options(selectinload(VpnServer.clients)).where(VpnServer.id == server_id)
|
||||||
|
)
|
||||||
|
server = result.scalar_one()
|
||||||
|
content = _build_server_config_text(server)
|
||||||
|
|
||||||
base = Path(settings.wg_config_dir if server.protocol == Protocol.WIREGUARD.value else settings.awg_config_dir)
|
base = Path(settings.wg_config_dir if server.protocol == Protocol.WIREGUARD.value else settings.awg_config_dir)
|
||||||
base.mkdir(parents=True, exist_ok=True)
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
path = base / f"{server.interface_name}.conf"
|
path = base / f"{server.interface_name}.conf"
|
||||||
path.write_text(content, encoding="utf-8")
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
if push_remote and server.vpn_installed and server.ssh_host:
|
||||||
|
await asyncio.to_thread(deploy_config, server, content)
|
||||||
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
async def install_vpn_on_server(session: AsyncSession, server_id: int) -> list[str]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(VpnServer).options(selectinload(VpnServer.clients)).where(VpnServer.id == server_id)
|
||||||
|
)
|
||||||
|
server = result.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
raise ValueError("Server not found")
|
||||||
|
content = _build_server_config_text(server)
|
||||||
|
# Always save locally first
|
||||||
|
await sync_server_config(session, server_id, push_remote=False)
|
||||||
|
logs = await asyncio.to_thread(install_vpn, server, content)
|
||||||
|
server.vpn_installed = True
|
||||||
|
await session.commit()
|
||||||
|
return logs
|
||||||
|
|||||||
@@ -23,7 +23,12 @@
|
|||||||
<div class="panel-head"><strong>QR‑код</strong></div>
|
<div class="panel-head"><strong>QR‑код</strong></div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<img class="qr" src="/admin/clients/{{ client.id }}/qr" alt="QR config" />
|
<img class="qr" src="/admin/clients/{{ client.id }}/qr" alt="QR config" />
|
||||||
<p class="muted" style="margin-top:0.8rem">Отсканируйте в клиенте WireGuard / AmneziaWG.</p>
|
<p class="muted" style="margin-top:0.8rem">
|
||||||
|
Готовый рабочий конфиг: скачайте `.conf` или отсканируйте QR в WireGuard / AmneziaWG.
|
||||||
|
{% if not client.server.vpn_installed %}
|
||||||
|
<br><strong>Сначала нажмите «Установить VPN на сервер» в разделе Серверы</strong> — иначе пиры не будут на VPS.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
{% if client.notes %}
|
{% if client.notes %}
|
||||||
<p><strong>Заметка:</strong> {{ client.notes }}</p>
|
<p><strong>Заметка:</strong> {{ client.notes }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
<div class="flash">Сервер удалён</div>
|
<div class="flash">Сервер удалён</div>
|
||||||
{% elif flash == 'ssh_ok' %}
|
{% elif flash == 'ssh_ok' %}
|
||||||
<div class="flash">SSH проверка успешна</div>
|
<div class="flash">SSH проверка успешна</div>
|
||||||
|
{% elif flash == 'vpn_installed' %}
|
||||||
|
<div class="flash">VPN установлен на VPS — можно создавать клиентов и скачивать .conf</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -85,13 +87,17 @@
|
|||||||
{{ s.name }}
|
{{ s.name }}
|
||||||
<span class="badge badge-proto">{{ s.protocol }}</span>
|
<span class="badge badge-proto">{{ s.protocol }}</span>
|
||||||
{% if s.ssh_reachable %}<span class="badge badge-ok">SSH ok</span>{% else %}<span class="badge badge-off">SSH ?</span>{% endif %}
|
{% if s.ssh_reachable %}<span class="badge badge-ok">SSH ok</span>{% else %}<span class="badge badge-off">SSH ?</span>{% endif %}
|
||||||
|
{% if s.vpn_installed %}<span class="badge badge-ok">VPN installed</span>{% else %}<span class="badge badge-off">VPN not installed</span>{% endif %}
|
||||||
</strong>
|
</strong>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" action="/admin/servers/{{ s.id }}/check-ssh" class="inline-form">
|
<form method="post" action="/admin/servers/{{ s.id }}/check-ssh" class="inline-form">
|
||||||
<button class="btn btn-sm btn-ghost" type="submit">Проверить SSH</button>
|
<button class="btn btn-sm btn-ghost" type="submit">Проверить SSH</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form method="post" action="/admin/servers/{{ s.id }}/install-vpn" class="inline-form" onsubmit="return confirm('Установить Docker + VPN контейнер на VPS? Это займёт 1–3 минуты.')">
|
||||||
|
<button class="btn btn-sm btn-accent" type="submit">{% if s.vpn_installed %}Переустановить VPN{% else %}Установить VPN на сервер{% endif %}</button>
|
||||||
|
</form>
|
||||||
<form method="post" action="/admin/servers/{{ s.id }}/sync" class="inline-form">
|
<form method="post" action="/admin/servers/{{ s.id }}/sync" class="inline-form">
|
||||||
<button class="btn btn-sm btn-ghost" type="submit">Синхр. conf</button>
|
<button class="btn btn-sm btn-ghost" type="submit">Синхр. conf → VPS</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/admin/servers/{{ s.id }}/delete" class="inline-form" onsubmit="return confirm('Удалить сервер и всех клиентов?')">
|
<form method="post" action="/admin/servers/{{ s.id }}/delete" class="inline-form" onsubmit="return confirm('Удалить сервер и всех клиентов?')">
|
||||||
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
|
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user