diff --git a/alembic/versions/0004_real_vpn.py b/alembic/versions/0004_real_vpn.py new file mode 100644 index 0000000..a08158e --- /dev/null +++ b/alembic/versions/0004_real_vpn.py @@ -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") diff --git a/app/models.py b/app/models.py index 1b43055..62b6eec 100644 --- a/app/models.py +++ b/app/models.py @@ -56,10 +56,14 @@ class VpnServer(Base): jmax: 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) + 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) h2: 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) + 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) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/routers/admin.py b/app/routers/admin.py index c175a2d..dc7ac51 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -147,6 +147,27 @@ async def check_ssh( 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") async def delete_server( server_id: int, diff --git a/app/services/__init__.py b/app/services/__init__.py index 4caf32d..0f773ed 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -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"] diff --git a/app/services/crypto.py b/app/services/crypto.py index 4ddd8ad..2dd2fd9 100644 --- a/app/services/crypto.py +++ b/app/services/crypto.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 import ipaddress +import random import secrets from dataclasses import dataclass @@ -17,6 +18,21 @@ class KeyPair: 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: private = X25519PrivateKey.generate() 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") +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: network = ipaddress.ip_network(subnet, strict=False) - used = set() - for addr in used_addresses: - ip = addr.split("/")[0] - used.add(ip) - - # .1 is reserved for server + used = {addr.split("/")[0] for addr in used_addresses} for host in network.hosts(): - if str(host).endswith(".0") or str(host).endswith(".255"): - continue if str(host) == str(network.network_address + 1): continue if str(host) not in used: @@ -65,32 +100,38 @@ def render_client_config( endpoint_host: str, endpoint_port: int, allowed_ips: str, + mtu: int = 1420, jc: int | None = None, jmin: int | None = None, jmax: int | None = None, s1: int | None = None, s2: int | None = None, + s3: int | None = None, + s4: int | None = None, h1: int | None = None, h2: int | None = None, h3: int | None = None, h4: int | None = None, ) -> str: + addr = client_address if "/" in client_address else f"{client_address}/32" lines = [ "[Interface]", f"PrivateKey = {client_private_key}", - f"Address = {client_address}", + f"Address = {addr}", f"DNS = {dns}", + f"MTU = {mtu}", ] if protocol == "awg2": - # AmneziaWG 2.0 obfuscation parameters lines.extend( [ f"Jc = {jc if jc is not None else 4}", f"Jmin = {jmin if jmin is not None else 40}", f"Jmax = {jmax if jmax is not None else 70}", - f"S1 = {s1 if s1 is not None else 0}", - f"S2 = {s2 if s2 is not None else 0}", + f"S1 = {s1 if s1 is not None else 15}", + 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"H2 = {h2 if h2 is not None else 2}", f"H3 = {h3 if h3 is not None else 3}", @@ -126,6 +167,8 @@ def render_server_config( jmax: int | None = None, s1: int | None = None, s2: int | None = None, + s3: int | None = None, + s4: int | None = None, h1: int | None = None, h2: 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"Jmin = {jmin if jmin is not None else 40}", f"Jmax = {jmax if jmax is not None else 70}", - f"S1 = {s1 if s1 is not None else 0}", - f"S2 = {s2 if s2 is not None else 0}", + f"S1 = {s1 if s1 is not None else 15}", + 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"H2 = {h2 if h2 is not None else 2}", f"H3 = {h3 if h3 is not None else 3}", @@ -156,6 +201,9 @@ def render_server_config( ) for peer in peers: + peer_addr = peer["address"] + if not peer_addr.endswith("/32"): + peer_addr = f"{peer_addr.split('/')[0]}/32" lines.extend( [ "", @@ -163,7 +211,7 @@ def render_server_config( "[Peer]", f"PublicKey = {peer['public_key']}", f"PresharedKey = {peer['preshared_key']}", - f"AllowedIPs = {peer['address']}", + f"AllowedIPs = {peer_addr}", ] ) diff --git a/app/services/remote.py b/app/services/remote.py new file mode 100644 index 0000000..6d970b8 --- /dev/null +++ b/app/services/remote.py @@ -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() diff --git a/app/services/ssh.py b/app/services/ssh.py index e283309..dfb2d5c 100644 --- a/app/services/ssh.py +++ b/app/services/ssh.py @@ -108,6 +108,43 @@ class SSHManager: full = f"sudo {clean}" 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: out, err, code = self.run_command( "uname -sr && cat /etc/os-release 2>/dev/null | head -2" diff --git a/app/services/vpn.py b/app/services/vpn.py index 9287d55..63ee02e 100644 --- a/app/services/vpn.py +++ b/app/services/vpn.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import selectinload from app.config import settings from app.models import Protocol, VpnClient, VpnServer from app.services.crypto import ( + generate_awg_params, generate_keypair, generate_preshared_key, next_client_ip, @@ -18,6 +19,7 @@ from app.services.crypto import ( render_server_config, server_address, ) +from app.services.remote import deploy_config, install_vpn 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: if protocol == Protocol.AWG2.value: + awg = generate_awg_params() return { "interface_name": settings.awg_interface, "subnet": settings.awg_subnet, "public_port": settings.awg_public_port, - "jc": 4, - "jmin": 40, - "jmax": 70, - "s1": 0, - "s2": 0, - "h1": 1, - "h2": 2, - "h3": 3, - "h4": 4, + "mtu": 1280, + "jc": awg.jc, + "jmin": awg.jmin, + "jmax": awg.jmax, + "s1": awg.s1, + "s2": awg.s2, + "s3": awg.s3, + "s4": awg.s4, + "h1": awg.h1, + "h2": awg.h2, + "h3": awg.h3, + "h4": awg.h4, } return { "interface_name": settings.wg_interface, "subnet": settings.wg_subnet, "public_port": settings.public_port, + "mtu": 1420, "jc": None, "jmin": None, "jmax": None, "s1": None, "s2": None, + "s3": None, + "s4": None, "h1": None, "h2": None, "h3": None, @@ -185,10 +194,14 @@ async def create_server( jmax=defaults["jmax"], s1=defaults["s1"], s2=defaults["s2"], + s3=defaults["s3"], + s4=defaults["s4"], h1=defaults["h1"], h2=defaults["h2"], h3=defaults["h3"], h4=defaults["h4"], + mtu=int(defaults["mtu"]), + vpn_installed=False, is_enabled=True, ) session.add(server) @@ -356,21 +369,27 @@ async def get_client_config(session: AsyncSession, client_id: int) -> str: if not client: raise ValueError("Client not found") server = client.server + dns = server.dns + if "," not in dns: + dns = f"{dns}, 1.0.0.1" return render_client_config( protocol=server.protocol, client_private_key=client.private_key, client_address=client.address, - dns=server.dns, + dns=dns, server_public_key=server.server_public_key, preshared_key=client.preshared_key, endpoint_host=server.public_host, endpoint_port=server.public_port, allowed_ips=client.allowed_ips, + mtu=server.mtu or (1280 if server.protocol == Protocol.AWG2.value else 1420), jc=server.jc, jmin=server.jmin, jmax=server.jmax, s1=server.s1, s2=server.s2, + s3=server.s3, + s4=server.s4, h1=server.h1, h2=server.h2, 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: - result = await session.execute( - select(VpnServer).options(selectinload(VpnServer.clients)).where(VpnServer.id == server_id) - ) - server = result.scalar_one() +def _build_server_config_text(server: VpnServer) -> str: peers = [ { "name": c.name, @@ -393,7 +408,7 @@ async def sync_server_config(session: AsyncSession, server_id: int) -> Path: for c in server.clients if c.is_enabled ] - content = render_server_config( + return render_server_config( protocol=server.protocol, interface_name=server.interface_name, private_key=server.server_private_key, @@ -405,14 +420,44 @@ async def sync_server_config(session: AsyncSession, server_id: int) -> Path: jmax=server.jmax, s1=server.s1, s2=server.s2, + s3=server.s3, + s4=server.s4, h1=server.h1, h2=server.h2, h3=server.h3, 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.mkdir(parents=True, exist_ok=True) path = base / f"{server.interface_name}.conf" 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 + + +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 diff --git a/app/templates/admin/client_detail.html b/app/templates/admin/client_detail.html index a4805c7..d089e24 100644 --- a/app/templates/admin/client_detail.html +++ b/app/templates/admin/client_detail.html @@ -23,7 +23,12 @@
Отсканируйте в клиенте WireGuard / AmneziaWG.
+
+ Готовый рабочий конфиг: скачайте `.conf` или отсканируйте QR в WireGuard / AmneziaWG.
+ {% if not client.server.vpn_installed %}
+
Сначала нажмите «Установить VPN на сервер» в разделе Серверы — иначе пиры не будут на VPS.
+ {% endif %}
+
Заметка: {{ client.notes }}
{% endif %} diff --git a/app/templates/admin/servers.html b/app/templates/admin/servers.html index 3fc7e4f..8f7728b 100644 --- a/app/templates/admin/servers.html +++ b/app/templates/admin/servers.html @@ -16,6 +16,8 @@