mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-09-18 01:44:10 +02:00
37031761a1
Linking an existing WireGuard peer to a user crashed with 'WireGuardManager.get_client_config() takes 3 positional arguments but 5 were given': the user-link endpoint called the manager directly, bypassing the _manager_call shim, and passed the AWG-style argument list (protocol, client_id, host, port). - WireGuardManager.get_client_config now takes an optional port; when omitted it falls back to the listen port from the server config, so existing direct callers keep working. - The link endpoint goes through _manager_call like every other call site, so each manager gets its own signature. Adds tests/test_wireguard_get_client_config.py (3 cases).
1126 lines
44 KiB
Python
1126 lines
44 KiB
Python
"""
|
|
WireGuard Protocol Manager - handles standard WireGuard protocol
|
|
installation, configuration, and client management on remote servers.
|
|
|
|
Follows the same architecture as awg_manager.py, using:
|
|
- client/server_scripts/wireguard/ scripts as reference
|
|
- Docker container: amneziavpn/amnezia-wg (same as AWG Legacy)
|
|
- Standard wg/wg-quick tools
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import secrets
|
|
import logging
|
|
from base64 import b64encode
|
|
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WG_DEFAULTS = {
|
|
'port': '51820',
|
|
'mtu': '1420',
|
|
'subnet_address': '10.8.2.0',
|
|
'subnet_cidr': '24',
|
|
'subnet_ip': '10.8.2.1',
|
|
'dns1': '1.1.1.1',
|
|
'dns2': '1.0.0.1',
|
|
}
|
|
|
|
|
|
def generate_wg_keypair():
|
|
"""Generate a WireGuard X25519 keypair (private, public) as base64 strings."""
|
|
private_key = X25519PrivateKey.generate()
|
|
private_bytes = private_key.private_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PrivateFormat.Raw,
|
|
encryption_algorithm=serialization.NoEncryption()
|
|
)
|
|
public_bytes = private_key.public_key().public_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PublicFormat.Raw
|
|
)
|
|
return b64encode(private_bytes).decode(), b64encode(public_bytes).decode()
|
|
|
|
|
|
def generate_psk():
|
|
"""Generate a WireGuard preshared key."""
|
|
return b64encode(secrets.token_bytes(32)).decode()
|
|
|
|
|
|
class WireGuardManager:
|
|
"""Manages standard WireGuard protocol installation and client management."""
|
|
|
|
PROTOCOL = 'wireguard'
|
|
CONTAINER_NAME = 'amnezia-wireguard'
|
|
DOCKER_IMAGE = 'amneziavpn/amnezia-wg:latest'
|
|
CONFIG_PATH = '/opt/amnezia/wireguard/wg0.conf'
|
|
KEY_DIR = '/opt/amnezia/wireguard'
|
|
CLIENTS_TABLE_PATH = '/opt/amnezia/wireguard/clientsTable'
|
|
BWLIMITS_PATH = '/opt/amnezia/wireguard/bwlimits'
|
|
INTERFACE = 'wg0'
|
|
|
|
def __init__(self, ssh_manager):
|
|
self.ssh = ssh_manager
|
|
|
|
# ===================== INSTALLATION =====================
|
|
|
|
def check_docker_installed(self):
|
|
"""Check if Docker is installed and running."""
|
|
out, err, code = self.ssh.run_command("docker --version 2>/dev/null")
|
|
if code != 0:
|
|
return False
|
|
out2, _, code2 = self.ssh.run_command(
|
|
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
|
|
)
|
|
return 'active' in out2 or 'running' in out2.lower()
|
|
|
|
def install_docker(self):
|
|
"""Install Docker on the server."""
|
|
script = r"""
|
|
if which apt-get > /dev/null 2>&1; then pm=$(which apt-get); silent_inst="-yq install"; check_pkgs="-yq update"; docker_pkg="docker.io"; dist="debian";
|
|
elif which dnf > /dev/null 2>&1; then pm=$(which dnf); silent_inst="-yq install"; check_pkgs="-yq check-update"; docker_pkg="docker"; dist="fedora";
|
|
elif which yum > /dev/null 2>&1; then pm=$(which yum); silent_inst="-y -q install"; check_pkgs="-y -q check-update"; docker_pkg="docker"; dist="centos";
|
|
else echo "Packet manager not found"; exit 1; fi;
|
|
if [ "$dist" = "debian" ]; then export DEBIAN_FRONTEND=noninteractive; fi;
|
|
if ! command -v docker > /dev/null 2>&1; then
|
|
$pm $check_pkgs; $pm $silent_inst $docker_pkg;
|
|
sleep 5; systemctl enable --now docker; sleep 5;
|
|
fi;
|
|
if [ "$(systemctl is-active docker)" != "active" ]; then
|
|
$pm $check_pkgs; $pm $silent_inst $docker_pkg;
|
|
sleep 5; systemctl start docker; sleep 5;
|
|
fi;
|
|
docker --version
|
|
"""
|
|
out, err, code = self.ssh.run_sudo_script(script, timeout=180)
|
|
if code != 0:
|
|
raise RuntimeError(f"Failed to install Docker: {err}")
|
|
return out
|
|
|
|
def check_container_running(self):
|
|
"""Check if WireGuard container is running."""
|
|
out, _, code = self.ssh.run_sudo_command(
|
|
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
|
)
|
|
return 'Up' in out
|
|
|
|
def check_protocol_installed(self):
|
|
"""Check if protocol is installed (container exists)."""
|
|
out, _, code = self.ssh.run_sudo_command(
|
|
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
|
|
)
|
|
return self.CONTAINER_NAME in out.strip().split('\n')
|
|
|
|
def prepare_host(self):
|
|
"""Prepare host for container."""
|
|
dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}"
|
|
script = f"""
|
|
mkdir -p {dockerfile_folder}
|
|
mkdir -p {self.KEY_DIR}
|
|
if ! docker network ls | grep -q amnezia-dns-net; then
|
|
docker network create --driver bridge --subnet=172.29.172.0/24 --opt com.docker.network.bridge.name=amn0 amnezia-dns-net
|
|
fi
|
|
"""
|
|
out, err, code = self.ssh.run_sudo_script(script)
|
|
if code != 0:
|
|
logger.warning(f"prepare_host warning: {err}")
|
|
return True
|
|
|
|
def setup_firewall(self):
|
|
"""Setup host firewall."""
|
|
script = """
|
|
sysctl -w net.ipv4.ip_forward=1
|
|
iptables -C INPUT -p icmp --icmp-type echo-request -j DROP 2>/dev/null || iptables -A INPUT -p icmp --icmp-type echo-request -j DROP
|
|
iptables -C FORWARD -j DOCKER-USER 2>/dev/null || iptables -A FORWARD -j DOCKER-USER 2>/dev/null
|
|
"""
|
|
self.ssh.run_sudo_script(script)
|
|
return True
|
|
|
|
def install_protocol(self, port=None):
|
|
"""
|
|
Full installation of WireGuard protocol.
|
|
Steps: install docker -> prepare host -> build container ->
|
|
configure container -> run container -> setup firewall
|
|
"""
|
|
if port is None:
|
|
port = WG_DEFAULTS['port']
|
|
|
|
results = []
|
|
|
|
# Step 1: Install Docker
|
|
if not self.check_docker_installed():
|
|
results.append("Installing Docker...")
|
|
self.install_docker()
|
|
results.append("Docker installed successfully")
|
|
else:
|
|
results.append("Docker already installed")
|
|
|
|
# Step 2: Prepare host
|
|
results.append("Preparing host...")
|
|
self.prepare_host()
|
|
results.append("Host prepared")
|
|
|
|
# Step 3: Remove old container if exists
|
|
if self.check_protocol_installed():
|
|
results.append("Removing old container...")
|
|
self.remove_container()
|
|
results.append("Old container removed")
|
|
|
|
# Step 4: Build container
|
|
results.append("Building Docker image...")
|
|
dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}"
|
|
|
|
dockerfile_content = (
|
|
f"FROM {self.DOCKER_IMAGE}\n"
|
|
f"\n"
|
|
f'LABEL maintainer="AmneziaVPN"\n'
|
|
f"\n"
|
|
f"RUN apk add --no-cache curl wireguard-tools dumb-init iptables bash iproute2\n"
|
|
f"RUN apk --update upgrade --no-cache\n"
|
|
f"\n"
|
|
f"RUN mkdir -p /opt/amnezia\n"
|
|
f'RUN echo "#!/bin/bash" > /opt/amnezia/start.sh && '
|
|
f'echo "tail -f /dev/null" >> /opt/amnezia/start.sh\n'
|
|
f"RUN chmod a+x /opt/amnezia/start.sh\n"
|
|
f"\n"
|
|
f'ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]\n'
|
|
)
|
|
self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}")
|
|
self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile")
|
|
|
|
out, err, code = self.ssh.run_sudo_command(
|
|
f"docker build --no-cache --pull -t {self.CONTAINER_NAME} {dockerfile_folder}",
|
|
timeout=300
|
|
)
|
|
if code != 0:
|
|
raise RuntimeError(f"Failed to build container: {err}")
|
|
results.append("Docker image built successfully")
|
|
|
|
# Step 5: Run container
|
|
results.append("Starting container...")
|
|
run_cmd = f"""docker run -d \
|
|
--restart always \
|
|
--privileged \
|
|
--cap-add=NET_ADMIN \
|
|
--cap-add=SYS_MODULE \
|
|
-p {port}:{port}/udp \
|
|
-v /lib/modules:/lib/modules \
|
|
--sysctl="net.ipv4.conf.all.src_valid_mark=1" \
|
|
--name {self.CONTAINER_NAME} \
|
|
{self.CONTAINER_NAME}"""
|
|
|
|
out, err, code = self.ssh.run_sudo_command(run_cmd)
|
|
if code != 0:
|
|
raise RuntimeError(f"Failed to run container: {err}")
|
|
|
|
# Connect to DNS network
|
|
self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.CONTAINER_NAME}")
|
|
|
|
# Wait for container
|
|
results.append("Waiting for container to start...")
|
|
self._wait_container_running()
|
|
results.append("Container started")
|
|
|
|
# Step 6: Configure container
|
|
results.append("Configuring WireGuard...")
|
|
self._configure_container(port)
|
|
results.append("WireGuard configured")
|
|
|
|
# Step 7: Upload start script
|
|
results.append("Starting WireGuard service...")
|
|
self._upload_start_script(port)
|
|
results.append("WireGuard service started")
|
|
|
|
# Step 8: Setup firewall
|
|
results.append("Setting up firewall...")
|
|
self.setup_firewall()
|
|
results.append("Firewall configured")
|
|
|
|
return {
|
|
'status': 'success',
|
|
'protocol': self.PROTOCOL,
|
|
'port': port,
|
|
'log': results,
|
|
}
|
|
|
|
def _wait_container_running(self, timeout=30):
|
|
"""Wait for a container to be in 'running' state."""
|
|
import time
|
|
last_status = 'unknown'
|
|
for i in range(timeout // 2):
|
|
out, _, _ = self.ssh.run_sudo_command(
|
|
f"docker inspect --format='{{{{.State.Status}}}}' {self.CONTAINER_NAME}"
|
|
)
|
|
last_status = out.strip().strip("'\"")
|
|
if last_status == 'running':
|
|
time.sleep(1)
|
|
return True
|
|
time.sleep(2)
|
|
|
|
logs_out, _, _ = self.ssh.run_sudo_command(
|
|
f"docker logs --tail 50 {self.CONTAINER_NAME} 2>&1"
|
|
)
|
|
raise RuntimeError(
|
|
f"Container {self.CONTAINER_NAME} did not start within {timeout}s "
|
|
f"(status: {last_status}). Logs:\n{logs_out}"
|
|
)
|
|
|
|
def _configure_container(self, port):
|
|
"""Configure the WireGuard container (generate keys and server config)."""
|
|
subnet_ip = WG_DEFAULTS['subnet_ip']
|
|
subnet_cidr = WG_DEFAULTS['subnet_cidr']
|
|
|
|
config_script = f"""
|
|
mkdir -p {self.KEY_DIR}
|
|
cd {self.KEY_DIR}
|
|
WIREGUARD_SERVER_PRIVATE_KEY=$(wg genkey)
|
|
echo $WIREGUARD_SERVER_PRIVATE_KEY > {self.KEY_DIR}/wireguard_server_private_key.key
|
|
|
|
WIREGUARD_SERVER_PUBLIC_KEY=$(echo $WIREGUARD_SERVER_PRIVATE_KEY | wg pubkey)
|
|
echo $WIREGUARD_SERVER_PUBLIC_KEY > {self.KEY_DIR}/wireguard_server_public_key.key
|
|
|
|
WIREGUARD_PSK=$(wg genpsk)
|
|
echo $WIREGUARD_PSK > {self.KEY_DIR}/wireguard_psk.key
|
|
|
|
cat > {self.CONFIG_PATH} <<EOF
|
|
[Interface]
|
|
PrivateKey = $WIREGUARD_SERVER_PRIVATE_KEY
|
|
Address = {subnet_ip}/{subnet_cidr}
|
|
ListenPort = {port}
|
|
EOF
|
|
"""
|
|
out, err, code = self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} bash -c '{config_script}'"
|
|
)
|
|
if code != 0:
|
|
raise RuntimeError(f"Failed to configure container: {err}")
|
|
|
|
def _upload_start_script(self, port, subnet_ip=None, subnet_cidr=None):
|
|
"""Upload and execute the start script inside the container."""
|
|
subnet_ip = subnet_ip or WG_DEFAULTS['subnet_ip']
|
|
subnet_cidr = subnet_cidr or WG_DEFAULTS['subnet_cidr']
|
|
|
|
start_script = f"""#!/bin/bash
|
|
echo "WireGuard container startup"
|
|
|
|
# Kill existing wg-quick if running
|
|
wg-quick down {self.CONFIG_PATH} 2>/dev/null
|
|
|
|
# Start WireGuard
|
|
if [ -f {self.CONFIG_PATH} ]; then wg-quick up {self.CONFIG_PATH}; fi
|
|
|
|
# Allow traffic on the TUN interface
|
|
iptables -A INPUT -i {self.INTERFACE} -j ACCEPT
|
|
iptables -A FORWARD -i {self.INTERFACE} -j ACCEPT
|
|
iptables -A OUTPUT -o {self.INTERFACE} -j ACCEPT
|
|
|
|
iptables -A FORWARD -i {self.INTERFACE} -o eth0 -s {subnet_ip}/{subnet_cidr} -j ACCEPT
|
|
iptables -A FORWARD -i {self.INTERFACE} -o eth1 -s {subnet_ip}/{subnet_cidr} -j ACCEPT
|
|
|
|
iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|
|
|
iptables -t nat -A POSTROUTING -s {subnet_ip}/{subnet_cidr} -o eth0 -j MASQUERADE
|
|
iptables -t nat -A POSTROUTING -s {subnet_ip}/{subnet_cidr} -o eth1 -j MASQUERADE
|
|
|
|
# Re-apply per-peer bandwidth limits (flat file written by the panel)
|
|
if [ -f {self.BWLIMITS_PATH} ]; then
|
|
command -v tc >/dev/null 2>&1 || apk add --no-cache iproute2 >/dev/null 2>&1
|
|
BW={self.BWLIMITS_PATH}
|
|
IFACE={self.INTERFACE}
|
|
tc qdisc del dev $IFACE root 2>/dev/null
|
|
tc qdisc del dev $IFACE ingress 2>/dev/null
|
|
tc qdisc add dev $IFACE root handle 1: htb default 0 2>/dev/null
|
|
tc qdisc add dev $IFACE handle ffff: ingress 2>/dev/null
|
|
i=0
|
|
while read -r ip4 ip6 mbps; do
|
|
[ -z "$ip4" ] && continue
|
|
[ -z "$mbps" ] && continue
|
|
kbit=$(echo "$mbps" | awk '{{printf "%d", $1*1000}}')
|
|
[ "$kbit" -gt 0 ] 2>/dev/null || continue
|
|
i=$((i+1))
|
|
cid=$((100+i))
|
|
tc class add dev $IFACE parent 1: classid 1:$cid htb rate ${{kbit}}kbit ceil ${{kbit}}kbit 2>/dev/null
|
|
tc filter add dev $IFACE parent 1: protocol ip u32 match ip dst $ip4/32 flowid 1:$cid 2>/dev/null
|
|
tc filter add dev $IFACE parent ffff: protocol ip u32 match ip src $ip4/32 police rate ${{kbit}}kbit burst 64k drop 2>/dev/null
|
|
done < "$BW"
|
|
fi
|
|
|
|
tail -f /dev/null
|
|
"""
|
|
self.ssh.upload_file(start_script, "/tmp/_wg_start.sh")
|
|
self.ssh.run_sudo_command(f"docker cp /tmp/_wg_start.sh {self.CONTAINER_NAME}:/opt/amnezia/start.sh")
|
|
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} chmod +x /opt/amnezia/start.sh")
|
|
self.ssh.run_command("rm -f /tmp/_wg_start.sh")
|
|
|
|
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
|
|
import time
|
|
time.sleep(5)
|
|
|
|
def remove_container(self):
|
|
"""Remove WireGuard container."""
|
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME}")
|
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME}")
|
|
self.ssh.run_sudo_command(f"docker rmi {self.CONTAINER_NAME}")
|
|
return True
|
|
|
|
# ===================== CLIENT MANAGEMENT =====================
|
|
|
|
def _get_clients_table(self):
|
|
"""Get the clients table from the server."""
|
|
out, err, code = self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} cat {self.CLIENTS_TABLE_PATH} 2>/dev/null"
|
|
)
|
|
if code != 0 or not out.strip():
|
|
return []
|
|
try:
|
|
data = json.loads(out)
|
|
if isinstance(data, list):
|
|
return data
|
|
return []
|
|
except json.JSONDecodeError:
|
|
return []
|
|
|
|
def _save_clients_table(self, clients_table):
|
|
"""Save the clients table to the server."""
|
|
content = json.dumps(clients_table, indent=2)
|
|
self.ssh.upload_file(content, "/tmp/_wg_clients.json")
|
|
self.ssh.run_sudo_command(
|
|
f"docker cp /tmp/_wg_clients.json {self.CONTAINER_NAME}:{self.CLIENTS_TABLE_PATH}"
|
|
)
|
|
self.ssh.run_command("rm -f /tmp/_wg_clients.json")
|
|
|
|
# Keep per-peer bandwidth limits in sync (best effort)
|
|
try:
|
|
self._apply_bw_limits(clients_table)
|
|
except Exception as err:
|
|
logger.warning(f"apply bw limits warning: {err}")
|
|
|
|
# ===================== BANDWIDTH LIMITS =====================
|
|
|
|
def _apply_bw_limits(self, clients_table):
|
|
"""Write the flat bwlimits file into the container and apply via tc.
|
|
|
|
Same mechanism as AWGManager: HTB on egress + ingress policer per
|
|
peer IPv4. Older containers without iproute2 get it installed on
|
|
the fly (apk), so no image rebuild is required.
|
|
"""
|
|
lines = []
|
|
for client in clients_table:
|
|
ud = client.get('userData', {}) or {}
|
|
try:
|
|
mbps = float(ud.get('maxSpeed') or 0)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if mbps <= 0:
|
|
continue
|
|
ip4 = ud.get('clientIp') or ''
|
|
if not ip4:
|
|
continue
|
|
lines.append(f"{ip4} - {mbps:g}")
|
|
content = "\n".join(lines) + ("\n" if lines else "")
|
|
self.ssh.upload_file(content, "/tmp/_wg_bwlimits")
|
|
self.ssh.run_sudo_command(
|
|
f"docker cp /tmp/_wg_bwlimits {self.CONTAINER_NAME}:{self.BWLIMITS_PATH}"
|
|
)
|
|
self.ssh.run_command("rm -f /tmp/_wg_bwlimits")
|
|
if not self.check_container_running():
|
|
return
|
|
from managers.awg_manager import AWGManager
|
|
body = AWGManager._tc_apply_body(self.BWLIMITS_PATH, self.CONFIG_PATH)
|
|
# Install iproute2 on the fly for containers built before the
|
|
# Dockerfile included it; cheap no-op when tc already exists.
|
|
body = "command -v tc >/dev/null 2>&1 || apk add --no-cache iproute2 >/dev/null 2>&1\n" + body
|
|
self.ssh.upload_file(body, "/tmp/_wg_tc.sh")
|
|
# One `sh -c`: run_sudo_command only privileges the head of a chain,
|
|
# so `docker cp && docker exec` would run the exec unprivileged.
|
|
self.ssh.run_sudo_command(
|
|
"sh -c '"
|
|
f"docker cp /tmp/_wg_tc.sh {self.CONTAINER_NAME}:/tmp/_wg_tc.sh && "
|
|
f"docker exec {self.CONTAINER_NAME} bash /tmp/_wg_tc.sh"
|
|
"'",
|
|
timeout=60
|
|
)
|
|
self.ssh.run_command("rm -f /tmp/_wg_tc.sh")
|
|
|
|
def set_speed_limit(self, client_id, max_speed):
|
|
"""Set per-peer bandwidth limit in Mbit/s (0 = unlimited).
|
|
|
|
Persisted in clientsTable (userData.maxSpeed); _save_clients_table
|
|
applies it via tc and the start script re-applies it on boot.
|
|
"""
|
|
mbps = round(float(max_speed), 1)
|
|
if mbps < 0:
|
|
raise RuntimeError('max_speed must be >= 0')
|
|
clients_table = self._get_clients_table()
|
|
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
|
|
if client is None:
|
|
raise RuntimeError('Client not found')
|
|
ud = client.setdefault('userData', {})
|
|
if mbps == 0:
|
|
ud.pop('maxSpeed', None)
|
|
else:
|
|
ud['maxSpeed'] = mbps
|
|
self._save_clients_table(clients_table)
|
|
return {'status': 'success', 'max_speed': mbps}
|
|
|
|
def _get_server_config(self):
|
|
"""Get the server WireGuard config."""
|
|
out, err, code = self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} cat {self.CONFIG_PATH}"
|
|
)
|
|
if code != 0:
|
|
raise RuntimeError(f"Failed to get server config: {err}")
|
|
return out
|
|
|
|
@staticmethod
|
|
def _sanitize_server_config(config_content):
|
|
"""wg-quick chokes on a bare `DNS =` key in the server config (it calls
|
|
resolvconf, which is missing in the container). Convert active
|
|
`DNS = ...` lines into `# DNS = ...` comments (see AWGManager)."""
|
|
lines = []
|
|
for line in config_content.split('\n'):
|
|
stripped = line.strip()
|
|
if stripped.startswith('DNS') and '=' in stripped and not stripped.startswith('#'):
|
|
indent = line[:len(line) - len(line.lstrip())]
|
|
lines.append(f"{indent}# {stripped}")
|
|
else:
|
|
lines.append(line)
|
|
return '\n'.join(lines)
|
|
|
|
def save_server_config(self, config_content):
|
|
"""Save the server WireGuard config and restart container."""
|
|
config_content = self._sanitize_server_config(config_content)
|
|
self.ssh.upload_file(config_content.replace('\r\n', '\n'), "/tmp/_wg_edit_config.conf")
|
|
self.ssh.run_sudo_command(f"docker cp /tmp/_wg_edit_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}")
|
|
self.ssh.run_command("rm -f /tmp/_wg_edit_config.conf")
|
|
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
|
|
|
|
def _get_server_public_key(self):
|
|
"""Get server public key."""
|
|
out, err, code = self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} cat {self.KEY_DIR}/wireguard_server_public_key.key"
|
|
)
|
|
if code != 0:
|
|
raise RuntimeError(f"Failed to get server public key: {err}")
|
|
return out.strip()
|
|
|
|
def _get_server_psk(self):
|
|
"""Get server preshared key."""
|
|
out, err, code = self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} cat {self.KEY_DIR}/wireguard_psk.key"
|
|
)
|
|
if code != 0:
|
|
raise RuntimeError(f"Failed to get PSK: {err}")
|
|
return out.strip()
|
|
|
|
def _get_listen_port(self):
|
|
"""Extract ListenPort from server config."""
|
|
config = self._get_server_config()
|
|
for line in config.split('\n'):
|
|
if line.strip().startswith('ListenPort'):
|
|
return line.split('=', 1)[1].strip()
|
|
return WG_DEFAULTS['port']
|
|
|
|
def _get_used_ips(self):
|
|
"""Get list of IPs already assigned in the config."""
|
|
config = self._get_server_config()
|
|
ips = []
|
|
for line in config.split('\n'):
|
|
line = line.strip()
|
|
if line.startswith('AllowedIPs'):
|
|
match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line)
|
|
if match:
|
|
ips.append(match.group(1))
|
|
elif line.startswith('Address'):
|
|
match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line)
|
|
if match:
|
|
ips.append(match.group(1))
|
|
return ips
|
|
|
|
def _get_reserved_ips(self):
|
|
"""IPv4 addresses reserved in clientsTable by ANY client, disabled included.
|
|
|
|
A disabled client keeps its address, so allocation must never hand it
|
|
to someone else. Raises if the table cannot be read at all — silently
|
|
treating the reservation pool as empty would break that guarantee.
|
|
"""
|
|
out, err, code = self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} cat {self.CLIENTS_TABLE_PATH} 2>/dev/null"
|
|
)
|
|
if code != 0:
|
|
# cat fails both when docker exec is broken and when the file
|
|
# simply does not exist yet (fresh instance). Fail loudly only
|
|
# for the former; an absent table means no reservations.
|
|
_, terr, tcode = self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} true")
|
|
if tcode != 0:
|
|
raise RuntimeError(
|
|
f"Cannot read clients table from {self.CONTAINER_NAME}: {terr.strip() or err.strip()}")
|
|
return set()
|
|
if not out.strip():
|
|
return set()
|
|
try:
|
|
data = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
raise RuntimeError(
|
|
f"Clients table in {self.CONTAINER_NAME} is not valid JSON")
|
|
reserved = set()
|
|
for c in (data if isinstance(data, list) else []):
|
|
ud = c.get('userData') or {}
|
|
value = ud.get('allowedIps') or ud.get('clientIp') or ''
|
|
match = re.search(r'(\d+\.\d+\.\d+\.\d+)', str(value))
|
|
if match:
|
|
reserved.add(match.group(1))
|
|
return reserved
|
|
|
|
def _get_next_ip(self):
|
|
"""Return the first free IP in the subnet, filling gaps left by deleted clients.
|
|
|
|
The old implementation took the last IP in file order and incremented it,
|
|
which produced duplicate IPs when peers were not sorted by IP and never
|
|
reused addresses freed by deleted clients.
|
|
|
|
Occupied = active config peers + reservations in clientsTable (disabled
|
|
clients keep their IPs; only deletion releases an address).
|
|
"""
|
|
used_ips = set(self._get_used_ips()) | self._get_reserved_ips()
|
|
# The subnet comes from the live config (imported instances may use a
|
|
# subnet different from the default).
|
|
base = WG_DEFAULTS['subnet_address']
|
|
config = self._get_server_config()
|
|
for line in config.split('\n'):
|
|
line = line.strip()
|
|
if line.startswith('Address'):
|
|
match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line)
|
|
if match:
|
|
base = match.group(1)
|
|
break
|
|
parts = base.split('.')
|
|
prefix = '.'.join(parts[:3])
|
|
|
|
used_octets = set()
|
|
for ip in used_ips:
|
|
ip_parts = ip.split('.')
|
|
if len(ip_parts) != 4 or '.'.join(ip_parts[:3]) != prefix:
|
|
continue
|
|
try:
|
|
used_octets.add(int(ip_parts[3]))
|
|
except ValueError:
|
|
continue
|
|
|
|
for octet in range(2, 255):
|
|
if octet not in used_octets:
|
|
parts[3] = str(octet)
|
|
return '.'.join(parts)
|
|
|
|
raise RuntimeError("No free IP addresses left in the subnet")
|
|
|
|
@staticmethod
|
|
def _peer_block_ip(block):
|
|
"""Sort key for a [Peer] config block: its first AllowedIPs IPv4 address."""
|
|
match = re.search(r'AllowedIPs\s*=\s*(\d+)\.(\d+)\.(\d+)\.(\d+)', block)
|
|
if match:
|
|
return tuple(int(match.group(i)) for i in range(1, 5))
|
|
return (255, 255, 255, 255)
|
|
|
|
def _insert_peer_sorted(self, peer_section):
|
|
"""Insert a new [Peer] section into the server config keeping peers sorted by IP.
|
|
|
|
Creates a timestamped backup of the config inside the container before
|
|
overwriting it, then rewrites the file with all [Peer] sections ordered
|
|
by their AllowedIPs address.
|
|
"""
|
|
config = self._get_server_config()
|
|
|
|
# Backup current config inside the container before modifying it
|
|
ts = __import__('datetime').datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} cp {self.CONFIG_PATH} {self.CONFIG_PATH}.bak.{ts}"
|
|
)
|
|
|
|
head, _, rest = config.partition('[Peer]')
|
|
blocks = []
|
|
if rest:
|
|
for chunk in rest.split('[Peer]'):
|
|
chunk = chunk.strip()
|
|
if chunk:
|
|
blocks.append('[Peer]\n' + chunk)
|
|
|
|
blocks.append(peer_section.strip())
|
|
blocks.sort(key=self._peer_block_ip)
|
|
|
|
new_config = head.rstrip('\n') + '\n\n' + '\n\n'.join(blocks) + '\n'
|
|
|
|
self.ssh.upload_file(new_config, "/tmp/_wg_add_peer.conf")
|
|
self.ssh.run_sudo_command(
|
|
f"docker cp /tmp/_wg_add_peer.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}"
|
|
)
|
|
self.ssh.run_command("rm -f /tmp/_wg_add_peer.conf")
|
|
|
|
def _parse_peers_from_config(self):
|
|
"""Parse [Peer] sections from WireGuard server config."""
|
|
try:
|
|
config = self._get_server_config()
|
|
except Exception:
|
|
return {}
|
|
peers = {}
|
|
current_key = None
|
|
for line in config.split('\n'):
|
|
line = line.strip()
|
|
if line == '[Peer]':
|
|
current_key = None
|
|
elif current_key is None and line.startswith('PublicKey'):
|
|
current_key = line.split('=', 1)[1].strip()
|
|
peers[current_key] = {'allowedIps': ''}
|
|
elif current_key and line.startswith('AllowedIPs'):
|
|
peers[current_key]['allowedIps'] = line.split('=', 1)[1].strip()
|
|
return peers
|
|
|
|
def _parse_bytes(self, size_str):
|
|
"""Parse human readable size string into bytes."""
|
|
try:
|
|
parts = size_str.strip().split()
|
|
if len(parts) != 2:
|
|
return 0
|
|
val, unit = float(parts[0]), parts[1]
|
|
units = {'B': 1, 'KiB': 1024, 'MiB': 1024**2, 'GiB': 1024**3, 'TiB': 1024**4}
|
|
return int(val * units.get(unit, 1))
|
|
except Exception:
|
|
return 0
|
|
|
|
def _wg_show(self):
|
|
"""Run 'wg show all' and parse output."""
|
|
out, err, code = self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg show all'"
|
|
)
|
|
if code != 0 or not out.strip():
|
|
return {}
|
|
|
|
result = {}
|
|
current_peer = None
|
|
|
|
for line in out.split('\n'):
|
|
line = line.strip()
|
|
if line.startswith('peer:'):
|
|
current_peer = line.split(':', 1)[1].strip()
|
|
result[current_peer] = {}
|
|
elif current_peer and ':' in line:
|
|
key, value = line.split(':', 1)
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if key == 'latest handshake':
|
|
result[current_peer]['latestHandshake'] = value
|
|
elif key == 'transfer':
|
|
parts = value.split(',')
|
|
if len(parts) == 2:
|
|
received = parts[0].strip().replace(' received', '')
|
|
sent = parts[1].strip().replace(' sent', '')
|
|
result[current_peer]['dataReceived'] = received
|
|
result[current_peer]['dataSent'] = sent
|
|
result[current_peer]['dataReceivedBytes'] = self._parse_bytes(received)
|
|
result[current_peer]['dataSentBytes'] = self._parse_bytes(sent)
|
|
elif key == 'allowed ips':
|
|
result[current_peer]['allowedIps'] = value
|
|
return result
|
|
|
|
def get_clients(self):
|
|
"""Get list of all clients with live traffic data."""
|
|
clients_table = self._get_clients_table()
|
|
|
|
try:
|
|
wg_show_data = self._wg_show()
|
|
except Exception:
|
|
wg_show_data = {}
|
|
|
|
known_ids = set()
|
|
for client in clients_table:
|
|
client_id = client.get('clientId', '')
|
|
known_ids.add(client_id)
|
|
if client_id in wg_show_data:
|
|
show_data = wg_show_data[client_id]
|
|
user_data = client.get('userData', {})
|
|
user_data['latestHandshake'] = show_data.get('latestHandshake', '')
|
|
user_data['dataReceived'] = show_data.get('dataReceived', '')
|
|
user_data['dataSent'] = show_data.get('dataSent', '')
|
|
user_data['dataReceivedBytes'] = show_data.get('dataReceivedBytes', 0)
|
|
user_data['dataSentBytes'] = show_data.get('dataSentBytes', 0)
|
|
user_data['allowedIps'] = show_data.get('allowedIps', '')
|
|
client['userData'] = user_data
|
|
|
|
# Pick up peers from conf not in clientsTable (native app clients)
|
|
try:
|
|
conf_peers = self._parse_peers_from_config()
|
|
for pub_key, peer_info in conf_peers.items():
|
|
if pub_key in known_ids:
|
|
continue
|
|
show_data = wg_show_data.get(pub_key, {})
|
|
allowed_ip = peer_info.get('allowedIps', '') or show_data.get('allowedIps', '')
|
|
ip_part = ''
|
|
if allowed_ip:
|
|
m = re.search(r'(\d+\.\d+\.\d+\.\d+)', allowed_ip)
|
|
if m:
|
|
ip_part = m.group(1)
|
|
display_name = f'External ({ip_part})' if ip_part else 'External (native app)'
|
|
clients_table.append({
|
|
'clientId': pub_key,
|
|
'userData': {
|
|
'clientName': display_name,
|
|
'clientPrivateKey': '',
|
|
'externalClient': True,
|
|
'clientIp': ip_part,
|
|
'latestHandshake': show_data.get('latestHandshake', ''),
|
|
'dataReceived': show_data.get('dataReceived', ''),
|
|
'dataSent': show_data.get('dataSent', ''),
|
|
'dataReceivedBytes': show_data.get('dataReceivedBytes', 0),
|
|
'dataSentBytes': show_data.get('dataSentBytes', 0),
|
|
'allowedIps': allowed_ip,
|
|
}
|
|
})
|
|
except Exception as e:
|
|
logger.warning(f'get_clients: failed to parse conf peers: {e}')
|
|
|
|
return clients_table
|
|
|
|
def add_client(self, client_name, server_host):
|
|
"""
|
|
Add a new client/peer to the WireGuard config.
|
|
Returns the client config string.
|
|
"""
|
|
# Generate client keys
|
|
client_priv_key, client_pub_key = generate_wg_keypair()
|
|
|
|
# Get server info
|
|
server_pub_key = self._get_server_public_key()
|
|
psk = self._get_server_psk()
|
|
port = self._get_listen_port()
|
|
|
|
# Get next available IP
|
|
client_ip = self._get_next_ip()
|
|
|
|
dns = self._get_dns()
|
|
|
|
mtu = WG_DEFAULTS['mtu']
|
|
|
|
peer_section = f"""
|
|
[Peer]
|
|
PublicKey = {client_pub_key}
|
|
PresharedKey = {psk}
|
|
AllowedIPs = {client_ip}/32
|
|
|
|
"""
|
|
# Insert peer into server config, keeping peers sorted by IP (with backup)
|
|
self._insert_peer_sorted(peer_section)
|
|
|
|
# Sync config without restart
|
|
self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'"
|
|
)
|
|
|
|
# Update clients table
|
|
clients_table = self._get_clients_table()
|
|
new_client = {
|
|
'clientId': client_pub_key,
|
|
'userData': {
|
|
'clientName': client_name,
|
|
'creationDate': __import__('datetime').datetime.now().isoformat(),
|
|
'clientPrivateKey': client_priv_key,
|
|
'clientIp': client_ip,
|
|
'psk': psk,
|
|
'enabled': True,
|
|
}
|
|
}
|
|
clients_table.append(new_client)
|
|
self._save_clients_table(clients_table)
|
|
|
|
# Build client config
|
|
client_config = f"""[Interface]
|
|
Address = {client_ip}/32
|
|
DNS = {dns}
|
|
PrivateKey = {client_priv_key}
|
|
MTU = {mtu}
|
|
|
|
[Peer]
|
|
PublicKey = {server_pub_key}
|
|
PresharedKey = {psk}
|
|
AllowedIPs = 0.0.0.0/0, ::/0
|
|
Endpoint = {server_host}:{port}
|
|
PersistentKeepalive = 25
|
|
"""
|
|
return {
|
|
'client_name': client_name,
|
|
'client_id': client_pub_key,
|
|
'client_ip': client_ip,
|
|
'config': client_config,
|
|
}
|
|
|
|
def get_client_config(self, client_id, server_host, port=None):
|
|
"""Reconstruct client config from stored data.
|
|
|
|
`port` is optional: when omitted (direct calls) the listen port is
|
|
read from the server config; when passed by the panel (which already
|
|
knows the instance port) it is used for the Endpoint.
|
|
"""
|
|
clients_table = self._get_clients_table()
|
|
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
|
|
if not client:
|
|
raise RuntimeError(f"Client {client_id} not found")
|
|
|
|
ud = client.get('userData', {})
|
|
if ud.get('customConfig'):
|
|
return ud['customConfig']
|
|
client_priv_key = ud.get('clientPrivateKey', '')
|
|
client_ip = ud.get('clientIp', '')
|
|
psk = ud.get('psk', '')
|
|
|
|
if not client_priv_key:
|
|
raise RuntimeError("Client private key not stored. Config cannot be reconstructed.")
|
|
|
|
server_pub_key = self._get_server_public_key()
|
|
if not psk:
|
|
psk = self._get_server_psk()
|
|
|
|
port = port if port is not None else self._get_listen_port()
|
|
|
|
dns = self._get_dns(ud)
|
|
|
|
mtu = WG_DEFAULTS['mtu']
|
|
|
|
config = f"""[Interface]
|
|
Address = {client_ip}/32
|
|
DNS = {dns}
|
|
PrivateKey = {client_priv_key}
|
|
MTU = {mtu}
|
|
|
|
[Peer]
|
|
PublicKey = {server_pub_key}
|
|
PresharedKey = {psk}
|
|
AllowedIPs = 0.0.0.0/0, ::/0
|
|
Endpoint = {server_host}:{port}
|
|
PersistentKeepalive = 25
|
|
"""
|
|
return config
|
|
|
|
def toggle_client(self, client_id, enable):
|
|
"""Enable or disable a client by adding/removing their [Peer] from server config."""
|
|
if enable:
|
|
clients_table = self._get_clients_table()
|
|
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
|
|
if not client:
|
|
raise RuntimeError(f"Client {client_id} not found")
|
|
|
|
ud = client.get('userData', {})
|
|
psk = ud.get('psk', '') or self._get_server_psk()
|
|
client_ip = ud.get('clientIp', '')
|
|
if client_ip:
|
|
# A disabled client's address stays reserved in clientsTable.
|
|
# Refuse to re-enable when another client owns it now.
|
|
for other in clients_table:
|
|
if other.get('clientId') == client_id:
|
|
continue
|
|
other_ud = other.get('userData') or {}
|
|
other_val = other_ud.get('allowedIps') or other_ud.get('clientIp') or ''
|
|
m = re.search(r'(\d+\.\d+\.\d+\.\d+)', str(other_val))
|
|
if m and m.group(1) == client_ip:
|
|
raise RuntimeError(
|
|
f"Cannot enable client: IP {client_ip} is already "
|
|
f"reserved by another client. Resolve the conflict "
|
|
f"(delete one of them) first.")
|
|
if client_ip in self._get_used_ips():
|
|
raise RuntimeError(
|
|
f"Cannot enable client: IP {client_ip} is already "
|
|
f"present in the active server config")
|
|
if not client_ip:
|
|
client_ip = self._get_next_ip()
|
|
ud['clientIp'] = client_ip
|
|
client.setdefault('userData', {})['clientIp'] = client_ip
|
|
|
|
peer_section = f"""
|
|
[Peer]
|
|
PublicKey = {client_id}
|
|
PresharedKey = {psk}
|
|
AllowedIPs = {client_ip}/32
|
|
|
|
"""
|
|
escaped_peer = peer_section.replace("'", "'\\''")
|
|
self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} bash -c 'echo \"{escaped_peer}\" >> {self.CONFIG_PATH}'"
|
|
)
|
|
else:
|
|
# Remove peer from server config
|
|
config = self._get_server_config()
|
|
sections = config.split('[')
|
|
new_sections = [s for s in sections if s.strip() and client_id not in s]
|
|
new_config = '[' + '['.join(new_sections)
|
|
|
|
self.ssh.upload_file(new_config, "/tmp/_wg_config.conf")
|
|
self.ssh.run_sudo_command(
|
|
f"docker cp /tmp/_wg_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}"
|
|
)
|
|
self.ssh.run_command("rm -f /tmp/_wg_config.conf")
|
|
|
|
# Sync config
|
|
self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'"
|
|
)
|
|
|
|
# Update enabled status in clients table
|
|
clients_table = self._get_clients_table()
|
|
for c in clients_table:
|
|
if c.get('clientId') == client_id:
|
|
c.setdefault('userData', {})['enabled'] = enable
|
|
break
|
|
self._save_clients_table(clients_table)
|
|
|
|
def remove_client(self, client_id):
|
|
"""Remove a client from WireGuard config."""
|
|
config = self._get_server_config()
|
|
sections = config.split('[')
|
|
new_sections = [s for s in sections if s.strip() and client_id not in s]
|
|
new_config = '[' + '['.join(new_sections)
|
|
|
|
self.ssh.upload_file(new_config, "/tmp/_wg_config.conf")
|
|
self.ssh.run_sudo_command(
|
|
f"docker cp /tmp/_wg_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}"
|
|
)
|
|
self.ssh.run_command("rm -f /tmp/_wg_config.conf")
|
|
|
|
# Sync config
|
|
self.ssh.run_sudo_command(
|
|
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'"
|
|
)
|
|
|
|
# Update clients table
|
|
clients_table = self._get_clients_table()
|
|
clients_table = [c for c in clients_table if c.get('clientId') != client_id]
|
|
self._save_clients_table(clients_table)
|
|
return True
|
|
|
|
def _get_dns(self, user_data=None):
|
|
"""DNS servers for generated client configs.
|
|
|
|
Priority: per-client override (userData.dns) > `DNS = ...` line in the
|
|
server config > AmneziaDNS container address > built-in defaults.
|
|
"""
|
|
if user_data and user_data.get('dns'):
|
|
return user_data['dns']
|
|
try:
|
|
server_config = self._get_server_config()
|
|
for line in server_config.split('\n'):
|
|
stripped = line.strip()
|
|
if stripped.startswith('#'):
|
|
stripped = stripped.lstrip('#').strip()
|
|
if stripped.startswith('DNS') and '=' in stripped:
|
|
return stripped.split('=', 1)[1].strip()
|
|
except Exception:
|
|
pass
|
|
dns1 = WG_DEFAULTS['dns1']
|
|
dns2 = WG_DEFAULTS['dns2']
|
|
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
|
|
if 'amnezia-dns' in out:
|
|
dns1 = '172.29.172.254'
|
|
return f"{dns1}, {dns2}"
|
|
|
|
def save_client_config(self, client_id, config_text):
|
|
"""Persist a manually edited client config in clientsTable
|
|
(userData.customConfig + userData.dns override)."""
|
|
config_text = (config_text or '').strip()
|
|
if not config_text:
|
|
raise RuntimeError('Config is empty')
|
|
clients_table = self._get_clients_table()
|
|
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
|
|
if client is None:
|
|
raise RuntimeError('Client not found')
|
|
ud = client.setdefault('userData', {})
|
|
ud['customConfig'] = config_text
|
|
ud.pop('dns', None)
|
|
for line in config_text.split('\n'):
|
|
stripped = line.strip()
|
|
if stripped.startswith('DNS') and '=' in stripped:
|
|
ud['dns'] = stripped.split('=', 1)[1].strip()
|
|
break
|
|
self._save_clients_table(clients_table)
|
|
return {'status': 'success'}
|
|
|
|
def rename_client(self, client_id, new_name):
|
|
"""Rename a client. The name lives only in the clientsTable
|
|
(userData.clientName); keys, IPs and the WireGuard config itself
|
|
are untouched, so existing configs keep working."""
|
|
clients_table = self._get_clients_table()
|
|
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
|
|
if client is None:
|
|
# Peer added via the native app is not in the table yet —
|
|
# persist a minimal entry so the chosen name sticks.
|
|
conf_peers = self._parse_peers_from_config()
|
|
if client_id not in conf_peers:
|
|
raise RuntimeError('Client not found')
|
|
client = {
|
|
'clientId': client_id,
|
|
'userData': {
|
|
'clientName': new_name,
|
|
'clientPrivateKey': '',
|
|
'externalClient': True,
|
|
}
|
|
}
|
|
clients_table.append(client)
|
|
else:
|
|
client.setdefault('userData', {})['clientName'] = new_name
|
|
self._save_clients_table(clients_table)
|
|
return {'status': 'success', 'name': new_name}
|
|
|
|
def get_server_status(self):
|
|
"""Get detailed status of the WireGuard server."""
|
|
info = {
|
|
'container_exists': self.check_protocol_installed(),
|
|
'container_running': False,
|
|
'protocol': self.PROTOCOL,
|
|
}
|
|
|
|
if info['container_exists']:
|
|
info['container_running'] = self.check_container_running()
|
|
if info['container_running']:
|
|
try:
|
|
config = self._get_server_config()
|
|
for line in config.split('\n'):
|
|
if 'ListenPort' in line:
|
|
info['port'] = line.split('=')[1].strip()
|
|
break
|
|
info['clients_count'] = len(self._get_clients_table())
|
|
except Exception as e:
|
|
info['error'] = str(e)
|
|
|
|
return info
|
|
|
|
def get_traffic_stats(self):
|
|
"""Get aggregate traffic stats for all clients."""
|
|
try:
|
|
wg_data = self._wg_show()
|
|
except Exception:
|
|
return {}
|
|
|
|
total_rx = 0
|
|
total_tx = 0
|
|
active_peers = 0
|
|
active_ips = []
|
|
|
|
for peer_key, data in wg_data.items():
|
|
rx = data.get('dataReceivedBytes', 0)
|
|
tx = data.get('dataSentBytes', 0)
|
|
total_rx += rx
|
|
total_tx += tx
|
|
if rx > 0 or tx > 0:
|
|
active_peers += 1
|
|
allowed = data.get('allowedIps', '')
|
|
if allowed:
|
|
m = re.search(r'(\d+\.\d+\.\d+\.\d+)', allowed)
|
|
if m:
|
|
active_ips.append(m.group(1))
|
|
|
|
return {
|
|
'total_rx_bytes': total_rx,
|
|
'total_tx_bytes': total_tx,
|
|
'active_connections': active_peers,
|
|
'active_ips': active_ips,
|
|
}
|