Add Amnezia Web Panel source with PostgreSQL 17 storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 00:02:18 +03:00
co-authored by Cursor
parent ead1c64dd1
commit 8b70d51c87
50 changed files with 21713 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
"""Protocol/service/SSH managers used by the web panel.
Modules in this package are imported either directly (`from managers.ssh_manager import SSHManager`)
or lazily by name through `app.get_protocol_manager`. Keeping them in a dedicated package
makes the project root easier to scan and prevents accidental name collisions with the
generic stdlib (e.g. `socks5_manager`, `dns_manager`).
"""
+264
View File
@@ -0,0 +1,264 @@
"""
AdGuard Home Manager — runs adguard/adguardhome in a Docker container,
joined to the same internal `amnezia-dns-net` network the rest of the panel
uses. Two install modes:
* 'replace' — removes the AmneziaDNS container (if present) and takes
its static IP (172.29.172.254) so VPN clients keep using
the same upstream.
* 'sidebyside' — runs alongside AmneziaDNS on a different static IP
(172.29.172.253). VPN users can hit it on demand
(e.g. via the web admin UI from inside the tunnel).
Initial setup of AdGuard itself runs through its built-in wizard on the web
UI port — we do not try to script it (the JSON setup API is unstable across
versions and trying to drive it programmatically tends to break on upgrade).
"""
import logging
logger = logging.getLogger(__name__)
class AdguardManager:
PROTOCOL = 'adguard'
CONTAINER_NAME = 'amnezia-adguard'
IMAGE_NAME = 'adguard/adguardhome:latest'
NETWORK_NAME = 'amnezia-dns-net'
NETWORK_SUBNET = '172.29.172.0/24'
REPLACE_IP = '172.29.172.254' # AmneziaDNS's slot
SIDEBYSIDE_IP = '172.29.172.253' # parallel to AmneziaDNS
HOST_DIR = '/opt/amnezia/adguard'
DEFAULT_DNS_PORT = 53
DEFAULT_WEB_PORT = 3000
DEFAULT_DOT_PORT = 853
DEFAULT_DOH_PORT = 443
def __init__(self, ssh):
self.ssh = ssh
# ===================== STATUS =====================
def check_docker_installed(self):
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
if code != 0:
return False
out2, _, _ = 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 check_protocol_installed(self, protocol_type='adguard'):
out, _, _ = 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 check_container_running(self):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def _container_ip(self):
out, _, _ = self.ssh.run_sudo_command(
f"docker inspect -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}' {self.CONTAINER_NAME} 2>/dev/null"
)
ip = out.strip().split()[0] if out.strip() else ''
return ip
def _detect_mode(self):
"""Return 'replace' or 'sidebyside' based on the running container's IP.
Returns None if not detectable (container not running)."""
ip = self._container_ip()
if ip == self.REPLACE_IP:
return 'replace'
if ip == self.SIDEBYSIDE_IP:
return 'sidebyside'
return None
def _container_web_port(self):
"""Read the AdGuard HTTP address configured inside the container.
During the first-run wizard AdGuard writes web.listen_addresses to
AdGuardHome.yaml. If the wizard has not been completed yet, the
container is started with --web-addr 0.0.0.0:<port> and this method can
read that port from docker inspect."""
grep_cmd = "grep -E '^[[:space:]]*-?[[:space:]]*[0-9.]+:[0-9]+$' /opt/adguardhome/conf/AdGuardHome.yaml 2>/dev/null | head -n1"
out, _, _ = self.ssh.run_sudo_command(
f'docker exec {self.CONTAINER_NAME} sh -c "{grep_cmd}"'
)
line = out.strip().split('\n')[0].strip().lstrip('-').strip() if out.strip() else ''
if ':' in line:
try:
return int(line.rsplit(':', 1)[1])
except ValueError:
pass
out, _, _ = self.ssh.run_sudo_command(
f"docker inspect -f '{{{{json .Config.Cmd}}}}' {self.CONTAINER_NAME} 2>/dev/null"
)
marker = '--web-addr'
if marker in out:
parts = out.replace('[', ' ').replace(']', ' ').replace(',', ' ').replace('\"', ' ').split()
for i, part in enumerate(parts):
if part == marker and i + 1 < len(parts) and ':' in parts[i + 1]:
try:
return int(parts[i + 1].rsplit(':', 1)[1])
except ValueError:
pass
return None
def _exposed_web_port(self, container_port=None):
"""Reads back the host->container port mapping for the web UI port,
so the panel can show the correct admin URL after install."""
container_port = int(container_port or self.DEFAULT_WEB_PORT)
for port in (container_port, self.DEFAULT_WEB_PORT):
out, _, _ = self.ssh.run_sudo_command(
f"docker port {self.CONTAINER_NAME} {port}/tcp 2>/dev/null"
)
if not out.strip():
continue
# output like "0.0.0.0:3000" — take the last colon-separated chunk
last = out.strip().split('\n')[0].split(':')[-1].strip()
try:
return int(last)
except ValueError:
continue
return None
def get_server_status(self, protocol_type='adguard'):
exists = self.check_protocol_installed()
running = self.check_container_running()
mode = self._detect_mode() if running else None
ip = self._container_ip() if running else ''
container_web_port = self._container_web_port() if running else None
exposed_port = self._exposed_web_port(container_web_port) if running else None
# When the web UI is not bound to the host the user still needs an
# admin URL (reachable via VPN) — use the actual container web port
# when known, otherwise fall back to AdGuard's default :3000.
return {
'container_exists': exists,
'container_running': running,
'mode': mode,
'internal_ip': ip,
'web_port': exposed_port or container_web_port or self.DEFAULT_WEB_PORT,
'web_exposed': exposed_port is not None,
'port': self.DEFAULT_DNS_PORT,
'protocol': protocol_type,
}
# ===================== INSTALL / REMOVE =====================
def _ensure_network(self):
self.ssh.run_sudo_command(
f"docker network ls | grep -q {self.NETWORK_NAME} || "
f"docker network create --subnet {self.NETWORK_SUBNET} {self.NETWORK_NAME}"
)
def install_protocol(
self,
protocol_type='adguard',
mode='sidebyside',
web_port=None,
expose_web=False,
dns_port=None,
dot_port=None,
doh_port=None,
expose_dns=False,
expose_dot=False,
expose_doh=False,
):
if not self.check_docker_installed():
return {'status': 'error', 'message': 'Docker not installed'}
if mode not in ('replace', 'sidebyside'):
return {'status': 'error', 'message': f"Invalid mode '{mode}'"}
web_port = int(web_port or self.DEFAULT_WEB_PORT)
dns_port = int(dns_port or self.DEFAULT_DNS_PORT)
dot_port = int(dot_port or self.DEFAULT_DOT_PORT)
doh_port = int(doh_port or self.DEFAULT_DOH_PORT)
# Persistent volumes — without these the AdGuard setup wizard would have
# to re-run on every container recreate.
self.ssh.run_sudo_command(f"mkdir -p {self.HOST_DIR}/work {self.HOST_DIR}/conf")
self._ensure_network()
# Replace mode: detach + remove AmneziaDNS so we can claim its IP.
if mode == 'replace':
self.ssh.run_sudo_command(
f"docker network disconnect {self.NETWORK_NAME} amnezia-dns 2>/dev/null || true"
)
self.ssh.run_sudo_command("docker stop amnezia-dns 2>/dev/null || true")
self.ssh.run_sudo_command("docker rm -fv amnezia-dns 2>/dev/null || true")
target_ip = self.REPLACE_IP
else:
target_ip = self.SIDEBYSIDE_IP
if self.check_protocol_installed():
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} 2>/dev/null || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true")
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}")
# Build port mapping. By default ports are reachable only inside
# `amnezia-dns-net` (so VPN clients hit them via the static IP).
# Optional `expose_*` flags add host port mappings for direct access.
ports = []
if expose_web:
ports.append(f"-p {web_port}:{web_port}/tcp")
if expose_dns:
ports.append(f"-p {dns_port}:53/tcp")
ports.append(f"-p {dns_port}:53/udp")
if expose_dot:
ports.append(f"-p {dot_port}:853/tcp")
if expose_doh:
ports.append(f"-p {doh_port}:443/tcp")
ports_str = ' '.join(ports)
run_cmd = (
f"docker run -d --name {self.CONTAINER_NAME} --restart always "
f"--network {self.NETWORK_NAME} --ip {target_ip} "
f"-v {self.HOST_DIR}/work:/opt/adguardhome/work "
f"-v {self.HOST_DIR}/conf:/opt/adguardhome/conf "
f"{ports_str} "
f"{self.IMAGE_NAME} --web-addr 0.0.0.0:{web_port}"
)
_, err, code = self.ssh.run_sudo_command(run_cmd)
if code != 0:
return {'status': 'error', 'message': f'Failed to start container: {err}'}
# Re-attach known VPN containers to the DNS network so they can reach
# AdGuard at target_ip (mirrors what dns_manager.py does on install).
for c in ('amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'amnezia-wireguard', 'telemt'):
self.ssh.run_sudo_command(
f"docker ps --format '{{{{.Names}}}}' | grep -q '^{c}$' && "
f"docker network connect {self.NETWORK_NAME} {c} 2>/dev/null || true"
)
url_host = self.ssh.host if expose_web else target_ip
admin_url = f"http://{url_host}:{web_port}"
return {
'status': 'success',
'protocol': 'adguard',
'mode': mode,
'internal_ip': target_ip,
'web_port': web_port,
'expose_web': bool(expose_web),
'admin_url': admin_url,
'message': 'AdGuard Home installed. Complete the setup wizard via the web UI.',
'log': [
f"AdGuard Home installed in '{mode}' mode",
f"Internal IP: {target_ip}",
f"Admin UI: {admin_url}" + ("" if expose_web else " (VPN-only — connect via VPN to reach it)"),
'Open the URL above to run the AdGuard setup wizard.',
],
}
def remove_container(self, protocol_type='adguard'):
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"rm -rf {self.HOST_DIR}")
return True
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
import json
import re
import shlex
class BackupManager:
"""Create/list downloadable protocol backups on the remote server.
Backups are intentionally allowlisted per protocol and include only files
needed to recreate protocol state: host config directories, matching
container config directories when present, and docker inspect metadata.
"""
BACKUP_ROOT = '/opt/amnezia/backups'
def __init__(self, ssh_manager):
self.ssh = ssh_manager
@staticmethod
def proto_base(protocol):
return str(protocol or '').split('__', 1)[0]
@staticmethod
def proto_instance(protocol):
match = re.search(r'__(\d+)$', str(protocol or ''))
return int(match.group(1)) if match else 1
@staticmethod
def safe_protocol(protocol):
safe = re.sub(r'[^a-zA-Z0-9_.-]+', '_', str(protocol or '').replace('__', '-'))
return safe.strip('._-') or 'protocol'
@staticmethod
def safe_filename(filename):
name = str(filename or '')
if not re.fullmatch(r'[A-Za-z0-9_.-]+\.tar\.gz', name):
return None
return name
def _paths_for(self, protocol, container_name):
base = self.proto_base(protocol)
idx = self.proto_instance(protocol)
paths = {
'host': [],
'container': [],
}
def inst_path(path, suffix_fmt='-{idx}'):
return path if idx <= 1 else f"{path}{suffix_fmt.format(idx=idx)}"
if base in ('awg', 'awg2', 'awg_legacy'):
paths['host'] = ['/opt/amnezia/awg', f'/opt/amnezia/{container_name}']
paths['container'] = ['/opt/amnezia/awg', '/opt/amnezia/start.sh']
elif base == 'wireguard':
paths['host'] = ['/opt/amnezia/wireguard', f'/opt/amnezia/{container_name}']
paths['container'] = ['/opt/amnezia/wireguard', '/opt/amnezia/start.sh']
elif base == 'xray':
config_dir = inst_path('/opt/amnezia/xray')
paths['host'] = [config_dir, f'/opt/amnezia/{container_name}']
paths['container'] = [config_dir]
elif base == 'telemt':
remote_dir = inst_path('/opt/amnezia/telemt')
paths['host'] = [remote_dir]
paths['container'] = [remote_dir]
elif base == 'dns':
paths['host'] = ['/opt/amnezia/dns']
paths['container'] = ['/opt/amnezia/dns']
elif base == 'adguard':
paths['host'] = ['/opt/amnezia/adguard']
paths['container'] = ['/opt/adguardhome/conf', '/opt/adguardhome/work']
elif base == 'socks5':
config_dir = inst_path('/opt/amnezia/socks5proxy')
paths['host'] = [config_dir]
paths['container'] = ['/etc/3proxy']
elif base == 'nginx':
paths['host'] = ['/opt/amnezia/nginx']
paths['container'] = ['/etc/nginx/conf.d', '/usr/share/nginx/html']
else:
paths['host'] = [f'/opt/amnezia/{base}']
paths['container'] = [f'/opt/amnezia/{base}']
return paths
def list_backups(self, protocol):
safe_proto = self.safe_protocol(protocol)
backup_dir = f'{self.BACKUP_ROOT}/{safe_proto}'
cmd = (
f"mkdir -p {shlex.quote(backup_dir)} && "
f"find {shlex.quote(backup_dir)} -maxdepth 1 -type f -name '*.tar.gz' "
"-printf '%f|%s|%T@\\n' 2>/dev/null | sort -t '|' -k3,3nr"
)
out, err, code = self.ssh.run_sudo_command(cmd)
if code != 0:
return {'status': 'error', 'message': err or out or 'Failed to list backups'}
backups = []
for line in (out or '').splitlines():
parts = line.split('|')
if len(parts) != 3:
continue
name, size, mtime = parts
backups.append({
'name': name,
'size': int(float(size or 0)),
'mtime': float(mtime or 0),
})
return {'status': 'success', 'protocol': protocol, 'backups': backups}
def create_backup(self, protocol, container_name):
safe_proto = self.safe_protocol(protocol)
backup_dir = f'{self.BACKUP_ROOT}/{safe_proto}'
paths = self._paths_for(protocol, container_name)
host_paths = ' '.join(shlex.quote(p) for p in paths['host'])
container_paths = ' '.join(shlex.quote(p) for p in paths['container'])
protocol_q = shlex.quote(str(protocol))
safe_proto_q = shlex.quote(safe_proto)
container_q = shlex.quote(str(container_name or ''))
backup_dir_q = shlex.quote(backup_dir)
script = f"""
set -eu
umask 077
protocol={protocol_q}
safe_proto={safe_proto_q}
container={container_q}
backup_dir={backup_dir_q}
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
work_dir=$(mktemp -d /tmp/amnezia-backup-${{safe_proto}}.XXXXXX)
cleanup() {{ rm -rf "$work_dir"; }}
trap cleanup EXIT
mkdir -p "$backup_dir" "$work_dir/host" "$work_dir/container" "$work_dir/docker"
cat > "$work_dir/backup-info.json" <<EOF
{{
"protocol": "$protocol",
"container": "$container",
"created_at_utc": "$timestamp",
"backup_format": "amnezia-web-panel-protocol-v1"
}}
EOF
copy_host_path() {{
src="$1"
if [ -e "$src" ]; then
mkdir -p "$work_dir/host$(dirname "$src")"
cp -a "$src" "$work_dir/host$src"
fi
}}
copy_container_path() {{
src="$1"
if [ -n "$container" ] && docker inspect "$container" >/dev/null 2>&1; then
mkdir -p "$work_dir/container$(dirname "$src")"
docker cp "$container:$src" "$work_dir/container$src" >/dev/null 2>&1 || true
fi
}}
for p in {host_paths}; do copy_host_path "$p"; done
for p in {container_paths}; do copy_container_path "$p"; done
if [ -n "$container" ] && docker inspect "$container" >/dev/null 2>&1; then
docker inspect "$container" > "$work_dir/docker/inspect.json" 2>/dev/null || true
docker logs --tail 300 "$container" > "$work_dir/docker/logs-tail.txt" 2>&1 || true
fi
archive="$backup_dir/${{safe_proto}}-${{timestamp}}.tar.gz"
tar -C "$work_dir" -czf "$archive" .
chmod 0644 "$archive"
printf '%s\n' "$archive"
""".strip()
out, err, code = self.ssh.run_sudo_command(script)
if code != 0:
return {'status': 'error', 'message': err or out or 'Failed to create backup'}
path = (out or '').strip().splitlines()[-1] if (out or '').strip() else ''
name = path.rsplit('/', 1)[-1] if path else ''
return {'status': 'success', 'protocol': protocol, 'backup': {'name': name, 'path': path}}
+83
View File
@@ -0,0 +1,83 @@
import os
import logging
logger = logging.getLogger(__name__)
class DNSManager:
def __init__(self, ssh):
self.ssh = ssh
def install_protocol(self, protocol_type='dns', port='53'):
"""Install AmneziaDNS service."""
try:
# 1. Check if docker is installed
out, _, _ = self.ssh.run_command("docker --version")
if "docker version" not in out.lower():
return {"status": "error", "message": "Docker not installed"}
# 2. Prepare directory
self.ssh.run_sudo_command("mkdir -p /opt/amnezia/dns")
# 3. Create Dockerfile matching official Amnezia implementation
# We use Unbound (mvance/unbound) with DNS-over-TLS to Cloudflare
forward_config = """forward-zone:
name: "."
forward-tls-upstream: yes
forward-addr: 1.1.1.1@853
forward-addr: 1.0.0.1@853
"""
self.ssh.write_file("/opt/amnezia/dns/forward-records.conf", forward_config)
dockerfile = """
FROM mvance/unbound:latest
LABEL maintainer="AmneziaVPN"
COPY forward-records.conf /opt/unbound/etc/unbound/forward-records.conf
"""
self.ssh.write_file("/opt/amnezia/dns/Dockerfile", dockerfile)
# 4. Build and run
self.ssh.run_sudo_command("docker build -t amnezia-dns /opt/amnezia/dns")
self.ssh.run_sudo_command("docker stop amnezia-dns || true")
self.ssh.run_sudo_command("docker rm amnezia-dns || true")
# Create internal network for DNS (like original Amnezia client)
self.ssh.run_sudo_command("docker network ls | grep -q amnezia-dns-net || docker network create --subnet 172.29.172.0/24 amnezia-dns-net")
# Use internal network with static IP. Do not expose 53 on host to avoid systemd-resolved conflict.
cmd = "docker run -d --name amnezia-dns --restart always --network amnezia-dns-net --ip=172.29.172.254 amnezia-dns"
self.ssh.run_sudo_command(cmd)
# Connect existing VPN containers to the DNS network
vpn_containers = ['amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'telemt']
for c in vpn_containers:
self.ssh.run_sudo_command(f"docker ps | grep -q {c} && docker network connect amnezia-dns-net {c} || true")
return {"status": "success", "message": "AmneziaDNS installed successfully"}
except Exception as e:
logger.exception("Error installing DNS")
return {"status": "error", "message": str(e)}
def get_server_status(self, protocol_type='dns'):
"""Check if AmneziaDNS container is running."""
try:
out, _, _ = self.ssh.run_sudo_command("docker ps --filter name=^amnezia-dns$ --format '{{.Status}}'")
is_running = 'Up' in out
out_exists, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
container_exists = 'amnezia-dns' in out_exists.strip().split('\n')
return {
"container_exists": container_exists,
"container_running": is_running,
"port": "53",
"protocol": protocol_type
}
except Exception as e:
return {"error": str(e)}
def remove_container(self, protocol_type='dns'):
"""Remove AmneziaDNS container."""
self.ssh.run_sudo_command("docker stop amnezia-dns || true")
self.ssh.run_sudo_command("docker rm amnezia-dns || true")
self.ssh.run_sudo_command("rm -rf /opt/amnezia/dns")
+383
View File
@@ -0,0 +1,383 @@
"""
NGINX Web Server Manager.
Runs an NGINX container with a single editable site (index.html) and a
Let's Encrypt certificate issued through the webroot HTTP-01 challenge.
The panel owns:
- nginx.conf: editable through the Config button
- index.html: editable through the Site button
"""
import json
import logging
import re
import shlex
logger = logging.getLogger(__name__)
def _q(value):
return shlex.quote(str(value))
class NginxManager:
PROTOCOL = 'nginx'
CONTAINER_NAME = 'amnezia-nginx'
IMAGE_NAME = 'nginx:alpine'
CERTBOT_IMAGE = 'certbot/certbot:latest'
CERTBOT_CONTAINER_NAME = 'amnezia-nginx-certbot'
BASE_DIR = '/opt/amnezia/nginx'
CONF_DIR = f'{BASE_DIR}/conf'
HTML_DIR = f'{BASE_DIR}/html'
CERTBOT_WWW_DIR = f'{BASE_DIR}/certbot/www'
LETSENCRYPT_DIR = f'{BASE_DIR}/letsencrypt'
META_PATH = f'{BASE_DIR}/metadata.json'
NGINX_CONF_PATH = f'{CONF_DIR}/default.conf'
INDEX_PATH = f'{HTML_DIR}/index.html'
DEFAULT_PORT = 443
def __init__(self, ssh, protocol='nginx'):
self.ssh = ssh
self.protocol = protocol or self.PROTOCOL
# ===================== STATUS =====================
def check_docker_installed(self):
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
if code != 0:
return False
out2, _, _ = 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 check_protocol_installed(self, protocol_type='nginx'):
out, _, _ = 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 check_container_running(self, protocol_type='nginx'):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def check_certbot_running(self):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{self.CERTBOT_CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def get_server_status(self, protocol_type='nginx'):
exists = self.check_protocol_installed(protocol_type)
running = self.check_container_running(protocol_type)
meta = self._read_metadata() if exists else {}
config = self._get_server_config(protocol_type) if exists else ''
domain = meta.get('domain') or self._parse_domain(config)
port = int(meta.get('port') or self.DEFAULT_PORT)
return {
'container_exists': exists,
'container_running': running,
'port': port,
'domain': domain,
'email': meta.get('email'),
'site_url': self._site_url(domain, port) if domain else None,
'protocol': protocol_type,
'base_protocol': self.PROTOCOL,
'instance': 1,
'container_name': self.CONTAINER_NAME,
'certbot_container_name': self.CERTBOT_CONTAINER_NAME,
'certbot_running': self.check_certbot_running() if exists else False,
}
# ===================== CONFIG BUILDERS =====================
def _validate_domain(self, domain):
domain = (domain or '').strip().lower()
if not domain or len(domain) > 253:
raise ValueError('Domain is required')
if not re.match(r'^(?!-)[a-z0-9.-]+(?<!-)$', domain) or '.' not in domain:
raise ValueError('Invalid domain name')
return domain
def _validate_email(self, email):
email = (email or '').strip()
if not email or '@' not in email:
raise ValueError('Valid Let\'s Encrypt email is required')
return email
def _site_url(self, domain, port):
if not domain:
return None
port = int(port or self.DEFAULT_PORT)
return f'https://{domain}' if port == 443 else f'https://{domain}:{port}'
def _build_initial_config(self, domain):
return f"""server {{
listen 80;
server_name {domain};
location /.well-known/acme-challenge/ {{
root /var/www/certbot;
}}
location / {{
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}}
}}
"""
def _build_ssl_config(self, domain, port):
redirect_target = 'https://$host$request_uri' if int(port) == 443 else f'https://$host:{int(port)}$request_uri'
return f"""server {{
listen 80;
server_name {domain};
location /.well-known/acme-challenge/ {{
root /var/www/certbot;
}}
location / {{
return 301 {redirect_target};
}}
}}
server {{
listen 443 ssl http2;
server_name {domain};
ssl_certificate /etc/letsencrypt/live/{domain}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{domain}/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
root /usr/share/nginx/html;
index index.html;
location / {{
try_files $uri $uri/ /index.html;
}}
}}
"""
def _default_index(self, domain):
return f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>{domain}</title>
<style>
body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: system-ui, -apple-system, Segoe UI, sans-serif; background: #0f172a; color: #e2e8f0; }}
main {{ text-align: center; padding: 32px; }}
h1 {{ font-size: clamp(2rem, 6vw, 4rem); margin: 0 0 12px; }}
p {{ color: #94a3b8; font-size: 1.1rem; }}
</style>
</head>
<body>
<main>
<h1>NGINX is running</h1>
<p>{domain}</p>
</main>
</body>
</html>
"""
def _parse_domain(self, config_text):
m = re.search(r'^\s*server_name\s+([^;\s]+)', config_text or '', re.MULTILINE)
return m.group(1) if m else None
# ===================== FILE I/O =====================
def _read_file(self, path):
out, _, code = self.ssh.run_sudo_command(f"cat {_q(path)} 2>/dev/null")
return out if code == 0 else ''
def _write_file(self, path, content):
parent = path.rsplit('/', 1)[0]
self.ssh.run_sudo_command(f"mkdir -p {_q(parent)}")
self.ssh.upload_file_sudo(content, path)
def _read_metadata(self):
raw = self._read_file(self.META_PATH)
if not raw.strip():
return {}
try:
return json.loads(raw)
except Exception:
return {}
def _write_metadata(self, meta):
self._write_file(self.META_PATH, json.dumps(meta, indent=2, ensure_ascii=False))
def _get_server_config(self, protocol_type='nginx'):
config = self._read_file(self.NGINX_CONF_PATH)
if config:
return config
out, _, code = self.ssh.run_sudo_command(
f"docker exec {self.CONTAINER_NAME} cat /etc/nginx/conf.d/default.conf 2>/dev/null"
)
return out if code == 0 else ''
def save_server_config(self, protocol_type='nginx', config_text=''):
old_config = self._get_server_config(protocol_type)
self._write_file(self.NGINX_CONF_PATH, config_text or '')
if self.check_container_running(protocol_type):
_, err, code = self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -t", timeout=30)
if code != 0:
self._write_file(self.NGINX_CONF_PATH, old_config)
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload 2>/dev/null || true")
raise RuntimeError(f'Invalid NGINX config: {err}')
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload")
return True
def get_site_index(self, protocol_type='nginx'):
return self._read_file(self.INDEX_PATH)
def save_site_index(self, protocol_type='nginx', html=''):
self._write_file(self.INDEX_PATH, html or '')
return True
# ===================== INSTALL / REMOVE =====================
def _run_nginx_container(self, port):
run_cmd = (
f"docker run -d --restart always "
f"--name {self.CONTAINER_NAME} "
f"-p 80:80/tcp "
f"-p {int(port)}:443/tcp "
f"-v {self.CONF_DIR}:/etc/nginx/conf.d:ro "
f"-v {self.HTML_DIR}:/usr/share/nginx/html:ro "
f"-v {self.CERTBOT_WWW_DIR}:/var/www/certbot:ro "
f"-v {self.LETSENCRYPT_DIR}:/etc/letsencrypt:ro "
f"{self.IMAGE_NAME}"
)
return self.ssh.run_sudo_command(run_cmd, timeout=60)
def _run_certbot_renewer(self, domain, email):
loop_script = f"""
set -eu
if [ ! -f /etc/letsencrypt/live/{domain}/fullchain.pem ]; then
certbot certonly --webroot --webroot-path /var/www/certbot --email {_q(email)} --agree-tos --no-eff-email -d {_q(domain)}
fi
kill -HUP 1 2>/dev/null || true
while :; do
certbot renew --webroot --webroot-path /var/www/certbot --quiet --deploy-hook 'kill -HUP 1 2>/dev/null || true'
sleep 12h & wait $!
done
"""
self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true")
run_cmd = (
f"docker run -d --restart always "
f"--name {self.CERTBOT_CONTAINER_NAME} "
f"--pid=container:{self.CONTAINER_NAME} "
f"-v {self.CERTBOT_WWW_DIR}:/var/www/certbot "
f"-v {self.LETSENCRYPT_DIR}:/etc/letsencrypt "
f"--entrypoint /bin/sh "
f"{self.CERTBOT_IMAGE} -c {_q(loop_script)}"
)
return self.ssh.run_sudo_command(run_cmd, timeout=60)
def _wait_for_certificate(self, domain):
wait_script = f"""
for i in $(seq 1 60); do
if [ -s {self.LETSENCRYPT_DIR}/live/{domain}/fullchain.pem ] && [ -s {self.LETSENCRYPT_DIR}/live/{domain}/privkey.pem ]; then
exit 0
fi
if ! docker ps --filter name=^{self.CERTBOT_CONTAINER_NAME}$ --format '{{{{.Names}}}}' | grep -qx {self.CERTBOT_CONTAINER_NAME}; then
docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>&1 || true
exit 1
fi
sleep 5
done
docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>&1 || true
exit 1
"""
return self.ssh.run_sudo_command(f"sh -c {_q(wait_script)}", timeout=330)
def install_protocol(self, protocol_type='nginx', port=None, email=None, domain=None):
if not self.check_docker_installed():
return {'status': 'error', 'message': 'Docker not installed'}
port = int(port or self.DEFAULT_PORT)
if port == 80:
return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'}
domain = self._validate_domain(domain)
email = self._validate_email(email)
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}", timeout=180)
self.ssh.run_sudo_command(f"docker pull {self.CERTBOT_IMAGE}", timeout=180)
if self.check_protocol_installed(protocol_type):
self.remove_container(protocol_type)
self.ssh.run_sudo_command(
f"mkdir -p {self.CONF_DIR} {self.HTML_DIR} {self.CERTBOT_WWW_DIR} {self.LETSENCRYPT_DIR}"
)
self._write_file(self.NGINX_CONF_PATH, self._build_initial_config(domain))
self._write_file(self.INDEX_PATH, self._default_index(domain))
self._write_metadata({'domain': domain, 'email': email, 'port': port})
_, err, code = self._run_nginx_container(port)
if code != 0:
return {'status': 'error', 'message': f'Failed to start NGINX container: {err}'}
_, err, code = self._run_certbot_renewer(domain, email)
if code != 0:
self.ssh.run_sudo_command(f"docker logs --tail 100 {self.CONTAINER_NAME} 2>/dev/null || true")
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
return {'status': 'error', 'message': f'Failed to start certbot renewer container: {err}'}
out, err, code = self._wait_for_certificate(domain)
if code != 0:
self.ssh.run_sudo_command(f"docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>/dev/null || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
return {
'status': 'error',
'message': f"Let's Encrypt certificate issue failed. Make sure {domain} points to this server and port 80 is reachable. {err or out}",
}
self._write_file(self.NGINX_CONF_PATH, self._build_ssl_config(domain, port))
_, err, code = self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -t", timeout=30)
if code != 0:
return {'status': 'error', 'message': f'Generated NGINX config is invalid: {err}'}
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload")
url = self._site_url(domain, port)
return {
'status': 'success',
'protocol': protocol_type,
'base_protocol': self.PROTOCOL,
'instance': 1,
'container_name': self.CONTAINER_NAME,
'certbot_container_name': self.CERTBOT_CONTAINER_NAME,
'certbot_running': self.check_certbot_running(),
'port': port,
'domain': domain,
'email': email,
'site_url': url,
'message': 'NGINX web server installed',
'log': [
f'NGINX HTTPS port: {port}/TCP',
'Port 80/TCP is used for Let\'s Encrypt HTTP-01 validation',
f'Certificate issued for {domain}',
f'Auto-renewal container: {self.CERTBOT_CONTAINER_NAME}',
f'Site URL: {url}',
],
}
def remove_container(self, protocol_type='nginx'):
self.ssh.run_sudo_command(f"docker stop {self.CERTBOT_CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"rm -rf {self.BASE_DIR}")
return True
+240
View File
@@ -0,0 +1,240 @@
"""
SOCKS5 Proxy Manager — runs 3proxy in a Docker container, modelled after the
official Amnezia client install (client/server_scripts/socks5_proxy/). Holds a
single user (port + username + password); credentials can be edited later from
the panel via update_credentials().
"""
import logging
import secrets
import string
import re
logger = logging.getLogger(__name__)
def _generate_password(length=16):
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
class Socks5Manager:
PROTOCOL = 'socks5'
CONTAINER_NAME = 'amnezia-socks5proxy'
IMAGE_NAME = '3proxy/3proxy:0.9.5'
CONFIG_DIR = '/opt/amnezia/socks5proxy'
CONFIG_PATH = '/etc/3proxy/3proxy.cfg'
DEFAULT_PORT = 38080
DEFAULT_USERNAME = 'proxy_user'
def __init__(self, ssh, protocol='socks5'):
self.ssh = ssh
self.protocol = protocol or self.PROTOCOL
self.instance = self._instance_index(self.protocol)
self.container_name = self._container_name(self.protocol)
self.config_dir = self._config_dir(self.protocol)
def _instance_index(self, protocol=None):
parts = str(protocol or self.protocol or '').split('__', 1)
if len(parts) == 2:
try:
return max(1, int(parts[1]))
except ValueError:
return 1
return 1
def _container_name(self, protocol=None):
idx = self._instance_index(protocol or self.protocol)
return self.CONTAINER_NAME if idx <= 1 else f'{self.CONTAINER_NAME}-{idx}'
def _config_dir(self, protocol=None):
idx = self._instance_index(protocol or self.protocol)
return self.CONFIG_DIR if idx <= 1 else f'{self.CONFIG_DIR}-{idx}'
# ===================== STATUS =====================
def check_docker_installed(self):
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
if code != 0:
return False
out2, _, _ = 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 check_protocol_installed(self, protocol_type='socks5'):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps -a --filter name=^{self._container_name(protocol_type)}$ --format '{{{{.Names}}}}'"
)
return self._container_name(protocol_type) in out.strip().split('\n')
def check_container_running(self, protocol_type='socks5'):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{self._container_name(protocol_type)}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def get_server_status(self, protocol_type='socks5'):
exists = self.check_protocol_installed(protocol_type)
running = self.check_container_running(protocol_type)
creds = self.get_credentials() if exists else {}
return {
'container_exists': exists,
'container_running': running,
'port': creds.get('port'),
'username': creds.get('username'),
'protocol': protocol_type,
'base_protocol': self.PROTOCOL,
'instance': self._instance_index(protocol_type),
'container_name': self._container_name(protocol_type),
}
# ===================== CONFIG I/O =====================
def _build_config(self, username, password, port):
# Mirrors client/server_scripts/socks5_proxy/configure_container.sh.
# 'auth strong' enforces username/password on every connection;
# 'allow {user}' restricts the ACL to our single user only.
return (
"#!/bin/3proxy\n"
f"config {self.CONFIG_PATH}\n"
"timeouts 1 5 30 60 180 1800 15 60\n"
f"users {username}:CL:{password}\n"
"log\n"
"auth strong\n"
f"allow {username}\n"
f"socks -p{int(port)}\n"
)
def _read_config(self):
out, _, code = self.ssh.run_sudo_command(
f"docker exec {self.container_name} cat {self.CONFIG_PATH} 2>/dev/null"
)
if code != 0 or not out.strip():
out, _, code = self.ssh.run_sudo_command(
f"cat {self.config_dir}/3proxy.cfg 2>/dev/null"
)
if code != 0 or not out.strip():
return ''
return out
def _write_config(self, config_text):
# Write to host first (so we have a stable copy outside the container),
# then docker cp into the running container at the path 3proxy expects.
self.ssh.run_sudo_command(f"mkdir -p {self.config_dir}")
self.ssh.upload_file_sudo(config_text, f"{self.config_dir}/3proxy.cfg")
self.ssh.run_sudo_command(
f"docker cp {self.config_dir}/3proxy.cfg {self.container_name}:{self.CONFIG_PATH} 2>/dev/null || true"
)
def _parse_credentials(self, config_text):
creds = {'port': None, 'username': None, 'password': None}
if not config_text:
return creds
m_user = re.search(r'^\s*users\s+([^:\s]+):CL:(\S+)', config_text, re.MULTILINE)
if m_user:
creds['username'] = m_user.group(1)
creds['password'] = m_user.group(2)
m_port = re.search(r'^\s*socks\s+-p(\d+)', config_text, re.MULTILINE)
if m_port:
creds['port'] = int(m_port.group(1))
return creds
def get_credentials(self):
return self._parse_credentials(self._read_config())
# ===================== INSTALL / UPDATE / REMOVE =====================
def install_protocol(self, protocol_type='socks5', port=None, username=None, password=None):
if not self.check_docker_installed():
return {'status': 'error', 'message': 'Docker not installed'}
port = int(port or self.DEFAULT_PORT)
username = (username or self.DEFAULT_USERNAME).strip() or self.DEFAULT_USERNAME
password = (password or _generate_password()).strip() or _generate_password()
# Pull image (idempotent — fast no-op if cached)
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}")
# Wipe any prior install, including the bind-mounted config dir, before
# writing a fresh config — leftover state would leak old credentials.
install_protocol = protocol_type or self.protocol
container_name = self._container_name(install_protocol)
config_dir = self._config_dir(install_protocol)
if self.check_protocol_installed(install_protocol):
self.remove_container(install_protocol)
config_text = self._build_config(username, password, port)
self.ssh.run_sudo_command(f"mkdir -p {config_dir}")
self.ssh.upload_file_sudo(config_text, f"{config_dir}/3proxy.cfg")
# The 3proxy image reads /etc/3proxy/3proxy.cfg by default.
# Do not pass the config path as the container command: Docker would try
# to execute the config file and fail with "permission denied".
run_cmd = (
f"docker run -d --restart always "
f"--name {container_name} "
f"-p {port}:{port}/tcp "
f"-v {config_dir}:/etc/3proxy:ro "
f"{self.IMAGE_NAME}"
)
_, err, code = self.ssh.run_sudo_command(run_cmd)
if code != 0:
return {'status': 'error', 'message': f'Failed to start container: {err}'}
return {
'status': 'success',
'protocol': install_protocol,
'base_protocol': self.PROTOCOL,
'instance': self._instance_index(install_protocol),
'container_name': container_name,
'port': port,
'username': username,
'password': password,
'message': 'SOCKS5 proxy installed',
'log': [
f'SOCKS5 proxy listening on port {port}/TCP',
f'Username: {username}',
f'Password: {password}',
'Save these credentials — the password can also be viewed later via "Change settings".',
],
}
def update_credentials(self, port=None, username=None, password=None):
"""Apply new connection settings: regenerates the config file and
restarts the container so the new port mapping takes effect."""
if not self.check_protocol_installed(self.protocol):
return {'status': 'error', 'message': 'SOCKS5 not installed'}
current = self.get_credentials()
new_port = int(port if port is not None else (current.get('port') or self.DEFAULT_PORT))
new_user = (username or current.get('username') or self.DEFAULT_USERNAME).strip()
new_pass = (password or current.get('password') or _generate_password()).strip()
old_port = current.get('port')
# If the port changed we must recreate the container — `docker run -p`
# mappings are immutable on existing containers.
if old_port and new_port != old_port:
return self.install_protocol(
protocol_type=self.protocol, port=new_port, username=new_user, password=new_pass
)
config_text = self._build_config(new_user, new_pass, new_port)
self._write_config(config_text)
self.ssh.run_sudo_command(f"docker restart {self.container_name}")
return {
'status': 'success',
'port': new_port,
'username': new_user,
'password': new_pass,
}
def remove_container(self, protocol_type='socks5'):
self.ssh.run_sudo_command(f"docker stop {self._container_name(protocol_type)} || true")
self.ssh.run_sudo_command(f"docker rm -fv {self._container_name(protocol_type)} || true")
self.ssh.run_sudo_command(f"rm -rf {self._config_dir(protocol_type)}")
return True
+227
View File
@@ -0,0 +1,227 @@
"""
SSH Manager - manages SSH connections to VPN servers.
Replicates the ServerController logic from the AmneziaVPN client.
"""
import paramiko
import io
import time
import logging
logger = logging.getLogger(__name__)
class SSHManager:
"""Manages SSH connections and command execution on remote servers."""
def __init__(self, host, port, username, password=None, private_key=None):
self.host = host
self.port = int(port)
self.username = username
self.password = password
self.private_key = private_key
self.client = None
self._is_root = (username == 'root')
def connect(self):
"""Establish SSH connection to the server."""
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
kwargs = {
'hostname': self.host,
'port': self.port,
'username': self.username,
'timeout': 15,
'allow_agent': False,
'look_for_keys': False,
}
if self.private_key:
key_file = io.StringIO(self.private_key)
try:
pkey = paramiko.RSAKey.from_private_key(key_file)
except paramiko.ssh_exception.SSHException:
key_file.seek(0)
try:
pkey = paramiko.Ed25519Key.from_private_key(key_file)
except paramiko.ssh_exception.SSHException:
key_file.seek(0)
pkey = paramiko.ECDSAKey.from_private_key(key_file)
kwargs['pkey'] = pkey
elif self.password:
kwargs['password'] = self.password
self.client.connect(**kwargs)
return True
def disconnect(self):
"""Close SSH connection."""
if self.client:
self.client.close()
self.client = None
def run_command(self, command, timeout=60):
"""Execute command on remote server."""
if not self.client:
raise ConnectionError("Not connected to server")
logger.info(f"Running command: {command[:100]}...")
stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout)
# Crucial: set timeout on the channel to prevent hanging indefinitely
stdout.channel.settimeout(timeout)
stderr.channel.settimeout(timeout)
try:
exit_code = stdout.channel.recv_exit_status()
out = stdout.read().decode('utf-8', errors='replace').strip()
err = stderr.read().decode('utf-8', errors='replace').strip()
except Exception as e:
logger.error(f"Command timed out or failed to read: {e}")
out, err, exit_code = "", str(e), -1
if exit_code != 0:
logger.warning(f"Command exited with code {exit_code}: {err}")
return out, err, exit_code
def _sudo_prefix(self):
"""Get the sudo command prefix with password handling."""
if self._is_root:
return ''
if self.password:
# Use sudo -S to read password from stdin
escaped_pass = self.password.replace("'", "'\\''")
return f"echo '{escaped_pass}' | sudo -S "
return 'sudo '
def run_sudo_command(self, command, timeout=60):
"""
Execute command with sudo, automatically handling password.
Strips 'sudo ' from the beginning of command if present,
and re-adds it with password piping.
"""
# Remove existing sudo prefix if present
clean_cmd = command
if clean_cmd.strip().startswith('sudo '):
clean_cmd = clean_cmd.strip()[5:]
if self._is_root:
return self.run_command(clean_cmd, timeout=timeout)
if self.password:
escaped_pass = self.password.replace("'", "'\\''")
# Pipe password directly to sudo -S, preserving original command quoting
# 2>/dev/null on echo suppresses '[sudo] password for...' prompt noise
full_cmd = f"echo '{escaped_pass}' | sudo -S -p '' {clean_cmd}"
else:
full_cmd = f"sudo {clean_cmd}"
return self.run_command(full_cmd, timeout=timeout)
def run_sudo_script(self, script, timeout=120):
"""
Execute a multi-line script with sudo/root privileges.
Writes script to /tmp via SFTP, then runs with sudo bash.
"""
if self._is_root:
return self.run_script(script, timeout=timeout)
# Write script to temp file via SFTP (avoids heredoc/pipe conflicts)
import hashlib
script_hash = hashlib.md5(script.encode()).hexdigest()[:8]
tmp_script = f"/tmp/_amnz_script_{script_hash}.sh"
self.upload_file(script, tmp_script)
# Run with sudo
if self.password:
escaped_pass = self.password.replace("'", "'\\''")
full_cmd = f"echo '{escaped_pass}' | sudo -S -p '' bash {tmp_script}; rm -f {tmp_script}"
else:
full_cmd = f"sudo bash {tmp_script}; rm -f {tmp_script}"
return self.run_command(full_cmd, timeout=timeout)
def run_script(self, script, timeout=120):
"""Execute a multi-line script on remote server."""
return self.run_command(script, timeout=timeout)
def upload_file(self, content, remote_path):
"""Upload text content to a remote file via SFTP."""
if not self.client:
raise ConnectionError("Not connected to server")
# Normalize line endings (Windows CRLF -> Unix LF)
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, remote_path):
"""
Upload text content to a remote file that requires root access.
Uses SFTP to write to /tmp, then sudo mv to the target path.
Also normalizes line endings to Unix-style (LF).
"""
if not self.client:
raise ConnectionError("Not connected to server")
# Normalize line endings (Windows CRLF -> Unix LF)
content = content.replace('\r\n', '\n')
# Write to temp file via SFTP (no sudo needed for /tmp)
import hashlib
tmp_name = f"/tmp/_amnz_{hashlib.md5(remote_path.encode()).hexdigest()[:8]}"
self.upload_file(content, tmp_name)
# Move to target with sudo
self.run_sudo_command(f"mv {tmp_name} {remote_path}")
self.run_sudo_command(f"chmod 644 {remote_path}")
return True
def download_file(self, remote_path):
"""Download text content from a remote file."""
if not self.client:
raise ConnectionError("Not connected to server")
sftp = self.client.open_sftp()
try:
with sftp.file(remote_path, 'r') as f:
return f.read().decode('utf-8', errors='replace')
finally:
sftp.close()
def file_exists(self, remote_path):
"""Check if a remote file exists."""
if not self.client:
raise ConnectionError("Not connected to server")
sftp = self.client.open_sftp()
try:
sftp.stat(remote_path)
return True
except FileNotFoundError:
return False
finally:
sftp.close()
def test_connection(self):
"""Test SSH connection and return server info."""
out, err, code = self.run_command("uname -sr && cat /etc/os-release 2>/dev/null | head -2")
return out
def write_file(self, remote_path, content):
"""Write content to a remote file with sudo."""
return self.upload_file_sudo(content, remote_path)
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.disconnect()
+571
View File
@@ -0,0 +1,571 @@
import json
import logging
import uuid
import re
import os
import secrets
from datetime import datetime
from .ssh_manager import SSHManager
logger = logging.getLogger(__name__)
class TelemtManager:
CONTAINER_NAME = "telemt"
API_URL = "http://127.0.0.1:9091"
def __init__(self, ssh_manager: SSHManager, protocol='telemt'):
self.ssh = ssh_manager
self.protocol = protocol or 'telemt'
self.instance = self._instance_index(self.protocol)
self.container_name = self._container_name(self.protocol)
self.remote_dir = self._remote_dir(self.protocol)
def _instance_index(self, protocol):
parts = str(protocol or '').split('__', 1)
if len(parts) == 2:
try:
return max(1, int(parts[1]))
except ValueError:
return 1
return 1
def _container_name(self, protocol=None):
idx = self._instance_index(protocol or self.protocol)
base_name = self.CONTAINER_NAME
return base_name if idx <= 1 else f'{base_name}-{idx}'
def _remote_dir(self, protocol=None):
idx = self._instance_index(protocol or self.protocol)
return '/opt/amnezia/telemt' if idx <= 1 else f'/opt/amnezia/telemt-{idx}'
def _config_path(self):
return f'{self.remote_dir}/config.toml'
def _api_host_ports(self):
# Host API mappings are only for diagnostics; panel talks via docker exec.
# Keep first instance backward-compatible, avoid 9090/9091 collisions later.
if self.instance <= 1:
return 9090, 9091
base = 9090 + (self.instance * 10)
return base, base + 1
def _api_request(self, method, path, data=None):
"""Execute a curl request inside the docker container."""
cmd = f"docker exec {self.container_name} curl -s -X {method} {self.API_URL}{path}"
if data:
js_data = json.dumps(data).replace('"', '\\"')
cmd += f" -H 'Content-Type: application/json' -d \"{js_data}\""
out, err, code = self.ssh.run_sudo_command(cmd)
if code != 0:
return None
try:
return json.loads(out)
except json.JSONDecodeError:
return None
def check_docker_installed(self):
out, _, _ = self.ssh.run_command("docker --version 2>/dev/null")
return bool(out.strip())
def check_protocol_installed(self):
out, _, _ = self.ssh.run_command(f"docker ps -a --filter name=^{self.container_name}$ --format '{{{{.Names}}}}'")
return out.strip() == self.container_name
def get_server_status(self, protocol_type):
exists = self.check_protocol_installed()
out, _, _ = self.ssh.run_command(f"docker inspect -f '{{{{.State.Running}}}}' {self.container_name} 2>/dev/null")
is_running = out.strip().lower() == 'true'
status = {
'container_exists': exists,
'container_running': is_running,
}
if is_running:
# get external docker port mapping for 443
out, _, _ = self.ssh.run_command(f"docker port {self.container_name} 443 2>/dev/null")
if out:
port = out.split(':')[-1].strip()
status['port'] = port
else:
status['port'] = None
config = self._get_server_config()
status['awg_params'] = self._parse_telemt_params(config)
# Count connections from API
clients = self.get_clients(protocol_type)
status['clients_count'] = len(clients)
return status
def _ensure_docker_compose(self):
"""Make sure `docker compose` is available, installing the plugin if needed.
Why: `docker-buildx-plugin` and `docker-compose-plugin` only ship in Docker's
official apt/yum repo. When Docker was installed from distro packages
(e.g. `docker.io` on Ubuntu), that repo is not configured and a plain
`apt-get install docker-compose-plugin` fails. So we add the repo,
refresh package lists, then install.
"""
out, _, code = self.ssh.run_command("docker compose version 2>/dev/null")
if code == 0 and out.strip():
return
script = r"""
if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -y || true
apt-get install -y ca-certificates curl gnupg || exit 1
install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
DOCKER_DISTRO="$ID"
case "$ID" in
linuxmint|pop|elementary|zorin) DOCKER_DISTRO="ubuntu" ;;
kali|parrot) DOCKER_DISTRO="debian" ;;
esac
if [ ! -s /etc/apt/keyrings/docker.asc ]; then
curl -fsSL "https://download.docker.com/linux/${DOCKER_DISTRO}/gpg" -o /etc/apt/keyrings/docker.asc || exit 1
chmod a+r /etc/apt/keyrings/docker.asc
fi
CODENAME="${UBUNTU_CODENAME:-$VERSION_CODENAME}"
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${DOCKER_DISTRO} ${CODENAME} stable" > /etc/apt/sources.list.d/docker.list
apt-get update -y || exit 1
apt-get install -y docker-buildx-plugin docker-compose-plugin || exit 1
elif command -v dnf >/dev/null 2>&1; then
dnf install -y dnf-plugins-core || exit 1
. /etc/os-release
dnf config-manager --add-repo "https://download.docker.com/linux/${ID}/docker-ce.repo" \
|| dnf config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" \
|| exit 1
dnf makecache || true
dnf install -y docker-buildx-plugin docker-compose-plugin || exit 1
elif command -v yum >/dev/null 2>&1; then
yum install -y yum-utils || exit 1
. /etc/os-release
yum-config-manager --add-repo "https://download.docker.com/linux/${ID}/docker-ce.repo" \
|| yum-config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" \
|| exit 1
yum makecache || true
yum install -y docker-buildx-plugin docker-compose-plugin || exit 1
else
echo "Unsupported package manager" >&2
exit 1
fi
docker compose version
"""
out, err, code = self.ssh.run_sudo_script(script, timeout=300)
if code != 0:
raise RuntimeError(f"Failed to install docker compose plugin: {err or out}")
def install_protocol(self, protocol_type='telemt', port='443', tls_emulation=True, tls_domain="", max_connections=0):
results = []
if not self.check_docker_installed():
results.append("Installing Docker...")
self.ssh.run_sudo_command("curl -fsSL https://get.docker.com | sh", timeout=300)
if self.check_protocol_installed():
self.ssh.run_sudo_command(f"docker rm -f {self.container_name}")
results.append("Ensuring docker compose plugin...")
self._ensure_docker_compose()
results.append("Uploading Telemt files...")
local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_telemt')
remote_dir = self.remote_dir
self.ssh.run_sudo_command(f"mkdir -p {remote_dir}")
self.ssh.run_sudo_command(f"chmod 755 {remote_dir}")
# Read and patch config.toml
with open(os.path.join(local_dir, 'config.toml'), 'r', encoding='utf-8') as f:
config_content = f.read()
tls_emul_str = "true" if tls_emulation else "false"
config_content = re.sub(r'tls_emulation\s*=\s*(true|false|True|False)', f'tls_emulation = {tls_emul_str}', config_content)
if tls_emulation and tls_domain:
config_content = re.sub(r'tls_domain\s*=\s*".*?"', f'tls_domain = "{tls_domain}"', config_content)
if max_connections is not None and max_connections > 0:
config_content = re.sub(r'max_connections\s*=\s*\d+', f'max_connections = {max_connections}', config_content)
# Patch public_host and public_port for links
if "public_host =" in config_content or "# public_host =" in config_content:
config_content = re.sub(r'#?\s*public_host\s*=\s*".*?"', f'public_host = "{self.ssh.host}"', config_content)
else:
config_content = config_content.replace('[general.links]', f'[general.links]\npublic_host = "{self.ssh.host}"')
config_content = re.sub(r'public_port\s*=\s*\d+', f'public_port = {port}', config_content)
# Remove default hello user
config_content = re.sub(r'^hello\s*=\s*".*?"', '', config_content, flags=re.MULTILINE)
self.ssh.upload_file_sudo(config_content, f"{remote_dir}/config.toml")
# Patch docker-compose.yml with proper port
with open(os.path.join(local_dir, 'docker-compose.yml'), 'r', encoding='utf-8') as f:
compose_content = f.read()
compose_content = re.sub(r'"443:443"', f'"{port}:443"', compose_content)
compose_content = re.sub(r'container_name:\s*telemt', f'container_name: {self.container_name}', compose_content)
if self.instance > 1:
api_port_9090, api_port_9091 = self._api_host_ports()
compose_content = re.sub(r'"127\.0\.0\.1:9090:9090"', f'"127.0.0.1:{api_port_9090}:9090"', compose_content)
compose_content = re.sub(r'"127\.0\.0\.1:9091:9091"', f'"127.0.0.1:{api_port_9091}:9091"', compose_content)
self.ssh.upload_file_sudo(compose_content, f"{remote_dir}/docker-compose.yml")
# Upload Dockerfile
with open(os.path.join(local_dir, 'Dockerfile'), 'r', encoding='utf-8') as f:
dockerfile = f.read()
self.ssh.upload_file_sudo(dockerfile, f"{remote_dir}/Dockerfile")
results.append("Starting Telemt container...")
out, err, code = self.ssh.run_sudo_command(f"sh -c 'cd {remote_dir} && docker compose up -d --build'", timeout=600)
if code != 0:
self.ssh.run_sudo_command(f"sh -c 'cd {remote_dir} && docker-compose up -d --build'", timeout=600)
return {
"status": "success",
"protocol": self.protocol,
"host": "",
"port": port,
"log": results
}
def _get_server_config(self):
out, _, code = self.ssh.run_sudo_command(f"cat {self._config_path()}")
if code != 0: return ""
return out
def save_server_config(self, protocol_type, config_content):
self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), f"{self._config_path()}")
# Use SIGHUP (HUP) to reload MTProxy config without restarting the process/container.
# This keeps the traffic statistics (octets) in memory.
self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}")
def _parse_telemt_params(self, config_text):
params = {}
m = re.search(r'tls_emulation\s*=\s*(true|false)', config_text, re.IGNORECASE)
if m: params['tls_emulation'] = m.group(1).lower() == 'true'
m = re.search(r'tls_domain\s*=\s*"([^"]+)"', config_text)
if m: params['tls_domain'] = m.group(1)
m = re.search(r'max_connections\s*=\s*(\d+)', config_text)
if m: params['max_connections'] = int(m.group(1))
return params
def remove_container(self, protocol_type=None):
self.ssh.run_sudo_command(f"docker rm -f {self.container_name}")
self.ssh.run_sudo_command(f"rm -rf {self.remote_dir}")
def get_clients(self, protocol_type):
api_data = {}
resp = self._api_request("GET", "/v1/users")
if resp and resp.get('ok'):
for u in resp.get('data', []):
api_data[u.get('username')] = u
config_text = self._get_server_config()
users = self._parse_users_from_config(config_text)
clients = []
needs_update = False
for username, secret in users.items():
user_stats = api_data.get(username.lstrip('#').strip(), {})
links = user_stats.get('links', {})
tg_link = ""
if links.get('tls'): tg_link = links['tls'][0]
elif links.get('secure'): tg_link = links['secure'][0]
elif links.get('classic'): tg_link = links['classic'][0]
enabled = not username.startswith('#')
clean_name = username.lstrip('#').strip()
total_octets = user_stats.get('total_octets', 0)
quota = user_stats.get('data_quota_bytes')
# AUTO-DISABLE IF QUOTA REACHED
if enabled and quota and total_octets >= quota:
logger.info(f"Auto-disabling client {clean_name} - quota reached: {total_octets} >= {quota}")
# We will trigger a toggle after we finish this loop to avoid re-reading config inside loop
enabled = False
needs_update = True
clients.append({
"clientId": clean_name,
"clientName": clean_name,
"enabled": enabled,
"creationDate": "",
"userData": {
"clientName": clean_name,
"token": secret,
"tg_link": tg_link,
"total_octets": total_octets,
"current_connections": user_stats.get('current_connections', 0),
"active_ips": user_stats.get('active_unique_ips', 0),
"quota": quota,
"expiry": user_stats.get('expiration_rfc3339')
}
})
if needs_update:
# Re-read and update config strictly at the end
for c in clients:
if not c['enabled']:
self.toggle_client(protocol_type, c['clientId'], False, restart=False)
self.ssh.run_sudo_command(f"docker restart {self.container_name}")
return clients
def _parse_users_from_config(self, config_text):
users = {}
lines = config_text.split('\n')
in_section = False
for line in lines:
stripped = line.strip()
if stripped == '[access.users]':
in_section = True
continue
if in_section and stripped.startswith('['):
break
if in_section and stripped:
commented = stripped.startswith('#')
content = stripped.lstrip('#').strip()
if '=' in content:
if content.lower().startswith('format:'): continue
name, secret = content.split('=', 1)
name = name.strip().strip('"').strip()
secret = secret.strip().strip('"').strip()
full_name = ("# " + name) if commented else name
users[full_name] = secret
return users
def add_client(self, protocol_type, name, host='', port='', **kwargs):
username = re.sub(r'[^a-zA-Z0-9_.-]', '', name.replace(' ', '_'))
if not username: username = "user_" + uuid.uuid4().hex[:8]
config_text = self._get_server_config()
current_users = self._parse_users_from_config(config_text)
idx = 1
base_username = username
while any(u.lstrip('#').strip() == username for u in current_users):
username = f"{base_username}_{idx}"
idx += 1
secret = kwargs.get('secret') or secrets.token_hex(16)
# 1. Update config file for persistence (but don't restart yet)
config_text = self._insert_into_section(config_text, "access.users", f'{username} = "{secret}"')
api_payload = {
"username": username,
"secret": secret
}
if kwargs.get('telemt_quota'):
val = int(kwargs['telemt_quota'])
config_text = self._insert_into_section(config_text, "access.user_data_quota", f'{username} = {val}')
api_payload['data_quota_bytes'] = val
if kwargs.get('telemt_max_ips'):
val = int(kwargs['telemt_max_ips'])
config_text = self._insert_into_section(config_text, "access.user_max_unique_ips", f'{username} = {val}')
api_payload['max_unique_ips'] = val
if kwargs.get('telemt_expiry'):
val = kwargs['telemt_expiry']
config_text = self._insert_into_section(config_text, "access.user_expirations", f'{username} = "{val}"')
api_payload['expiration_rfc3339'] = val
if kwargs.get('user_ad_tag'):
val = kwargs['user_ad_tag']
config_text = self._insert_into_section(config_text, "access.user_ad_tags", f'{username} = "{val}"')
api_payload['user_ad_tag'] = val
if kwargs.get('max_tcp_conns'):
val = int(kwargs['max_tcp_conns'])
config_text = self._insert_into_section(config_text, "access.user_max_tcp_conns", f'{username} = {val}')
api_payload['max_tcp_conns'] = val
# Save config to host
self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), f"{self._config_path()}")
# 2. Call API for immediate effect
self._api_request("POST", "/v1/users", data=api_payload)
# Fetch the official link from API (it includes TLS emulation padding like 'ee...' if enabled)
link = self.get_client_config(protocol_type, username, host, port)
# Extreme fallback if API is slow or 404
if link == "Not found":
link = f"tg://proxy?server={host}&port={port}&secret={secret}"
return {
"client_id": username,
"config": link,
"vpn_link": link
}
def edit_client(self, protocol_type, client_id, new_params):
"""Update existing client parameters via API and in config."""
config_text = self._get_server_config()
api_payload = {}
if 'telemt_quota' in new_params:
val = int(new_params['telemt_quota']) if new_params['telemt_quota'] else None
config_text = self._update_line_in_section(config_text, "access.user_data_quota", client_id, val)
api_payload['data_quota_bytes'] = val
if 'telemt_max_ips' in new_params:
val = int(new_params['telemt_max_ips']) if new_params['telemt_max_ips'] else None
config_text = self._update_line_in_section(config_text, "access.user_max_unique_ips", client_id, val)
api_payload['max_unique_ips'] = val
if 'telemt_expiry' in new_params:
val = new_params['telemt_expiry']
quoted_val = f'"{val}"' if val else None
config_text = self._update_line_in_section(config_text, "access.user_expirations", client_id, quoted_val)
api_payload['expiration_rfc3339'] = val
if 'secret' in new_params:
val = new_params['secret']
quoted_val = f'"{val}"' if val else None
config_text = self._update_line_in_section(config_text, "access.users", client_id, quoted_val)
api_payload['secret'] = val
if 'user_ad_tag' in new_params:
val = new_params['user_ad_tag']
quoted_val = f'"{val}"' if val else None
config_text = self._update_line_in_section(config_text, "access.user_ad_tags", client_id, quoted_val)
api_payload['user_ad_tag'] = val
if 'max_tcp_conns' in new_params:
val = int(new_params['max_tcp_conns']) if new_params['max_tcp_conns'] else None
config_text = self._update_line_in_section(config_text, "access.user_max_tcp_conns", client_id, val)
api_payload['max_tcp_conns'] = val
# Save config to host
self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), f"{self._config_path()}")
# API call
self._api_request("PATCH", f"/v1/users/{client_id}", data=api_payload)
return {"status": "success"}
def _update_line_in_section(self, config_text, section_name, client_id, value):
lines = config_text.split('\n')
section_start = -1
section_end = -1
for i, line in enumerate(lines):
if line.strip() == f"[{section_name}]":
section_start = i
elif section_start != -1 and line.strip().startswith('['):
section_end = i
break
if section_end == -1: section_end = len(lines)
if section_start == -1:
if value is not None:
lines.append(f"[{section_name}]")
lines.append(f'{client_id} = {value}')
lines.append("")
return '\n'.join(lines)
found = False
for i in range(section_start + 1, section_end):
line = lines[i].strip().lstrip('#').strip()
if line.startswith(f"{client_id} ") or line.startswith(f"{client_id}="):
if value is None: lines.pop(i)
else: lines[i] = f'{client_id} = {value}'
found = True
break
if not found and value is not None:
lines.insert(section_start + 1, f'{client_id} = {value}')
return '\n'.join(lines)
def _insert_into_section(self, config_text, section_name, line_to_insert):
lines = config_text.split('\n')
section_start = -1
for i, line in enumerate(lines):
if line.strip() == f"[{section_name}]":
section_start = i
break
if section_start == -1:
lines.append(f"[{section_name}]")
lines.append(line_to_insert)
lines.append("")
else:
lines.insert(section_start + 1, line_to_insert)
return '\n'.join(lines)
def remove_client(self, protocol_type, client_id):
# 1. API
self._api_request("DELETE", f"/v1/users/{client_id}")
# 2. Config
config_text = self._get_server_config()
lines = config_text.split('\n')
new_lines = []
for line in lines:
stripped = line.strip().lstrip('#').strip()
if stripped.startswith(f"{client_id} ") or stripped.startswith(f"{client_id}="):
continue
new_lines.append(line)
self.ssh.upload_file_sudo('\n'.join(new_lines).replace('\r\n', '\n'), f"{self._config_path()}")
def toggle_client(self, protocol_type, client_id, enable, restart=True):
# API doesn't have a direct "toggle", so we either set a huge quota or remove/re-add
# But for Telemt, commenting out in config is the persistent way.
# We'll use HUP after toggling in config.
config_text = self._get_server_config()
lines = config_text.split('\n')
new_lines = []
in_access_section = False
for line in lines:
stripped = line.strip()
if stripped.startswith('[access.'): in_access_section = True
elif stripped.startswith('['): in_access_section = False
if in_access_section:
base_line = line.lstrip('#').strip()
if base_line.startswith(f"{client_id} ") or base_line.startswith(f"{client_id}="):
line = base_line if enable else f"# {base_line}"
new_lines.append(line)
self.ssh.upload_file_sudo('\n'.join(new_lines).replace('\r\n', '\n'), f"{self._config_path()}")
if enable:
# If enabling, we re-add via API since it might have been deleted from memory
secret = ""
users = self._parse_users_from_config('\n'.join(new_lines))
secret = users.get(client_id, "")
if secret:
self._api_request("POST", "/v1/users", data={"username": client_id, "secret": secret})
else:
# If disabling, we just delete from memory
self._api_request("DELETE", f"/v1/users/{client_id}")
if restart:
self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}")
def get_client_config(self, protocol_type, client_id, host='', port=''):
resp = self._api_request("GET", f"/v1/users/{client_id}")
if resp and resp.get('ok'):
user = resp.get('data', {})
links = user.get('links', {})
if links.get('tls'): return links['tls'][0]
if links.get('secure'): return links['secure'][0]
if links.get('classic'): return links['classic'][0]
clients = self.get_clients(protocol_type)
c = next((c for c in clients if c['clientId'] == client_id), None)
if c:
secret = c.get('userData', {}).get('token', '')
if secret: return f"tg://proxy?server={host}&port={port}&secret={secret}"
return "Not found"
+823
View File
@@ -0,0 +1,823 @@
"""
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'
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\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):
"""Upload and execute the start script inside the container."""
subnet_ip = WG_DEFAULTS['subnet_ip']
subnet_cidr = 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
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")
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
def save_server_config(self, config_content):
"""Save the server WireGuard config and restart container."""
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_next_ip(self):
"""Calculate the next available IP for a new client."""
used_ips = self._get_used_ips()
if not used_ips:
base = WG_DEFAULTS['subnet_address']
parts = base.split('.')
parts[3] = '2'
return '.'.join(parts)
last_ip = used_ips[-1]
parts = last_ip.split('.')
last_octet = int(parts[3])
next_octet = last_octet + 1
if next_octet > 254:
next_octet = 2
parts[3] = str(next_octet)
return '.'.join(parts)
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()
dns1 = WG_DEFAULTS['dns1']
dns2 = WG_DEFAULTS['dns2']
# Check if AmneziaDNS is installed
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'
mtu = WG_DEFAULTS['mtu']
# Append peer to server config
peer_section = f"""
[Peer]
PublicKey = {client_pub_key}
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}'"
)
# 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 = {dns1}, {dns2}
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):
"""Reconstruct client config from stored data."""
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', {})
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 = self._get_listen_port()
dns1 = WG_DEFAULTS['dns1']
dns2 = WG_DEFAULTS['dns2']
# Check if AmneziaDNS is installed
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'
mtu = WG_DEFAULTS['mtu']
config = f"""[Interface]
Address = {client_ip}/32
DNS = {dns1}, {dns2}
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', '')
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_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,
}
+800
View File
@@ -0,0 +1,800 @@
import json
import os
import uuid
import logging
import base64
import shlex
from datetime import datetime
import urllib.parse
logger = logging.getLogger(__name__)
class XrayManager:
"""Manages Xray (VLESS-Reality) protocol installation and client management."""
PROTOCOL = 'xray'
CONTAINER_NAME = 'amnezia-xray'
IMAGE_NAME = 'amneziavpn/amnezia-xray' # or we can build it
def __init__(self, ssh_manager, protocol='xray'):
self.ssh = ssh_manager
self.protocol = protocol or self.PROTOCOL
self.base_protocol = str(self.protocol).split('__', 1)[0]
self.instance = self._instance_index(self.protocol)
self.container_name = self._container_name(self.protocol)
self.image_name = self._image_name(self.protocol)
def _instance_index(self, protocol):
parts = str(protocol or '').split('__', 1)
if len(parts) == 2:
try:
return max(1, int(parts[1]))
except ValueError:
return 1
return 1
def _container_name(self, protocol=None):
idx = self._instance_index(protocol or self.protocol)
base_name = self.CONTAINER_NAME
return base_name if idx <= 1 else f'{base_name}-{idx}'
def _image_name(self, protocol=None):
idx = self._instance_index(protocol or self.protocol)
base_name = self.IMAGE_NAME
return base_name if idx <= 1 else f'{base_name}-{idx}'
def _config_dir(self):
return '/opt/amnezia/xray' if self.instance <= 1 else f'/opt/amnezia/xray-{self.instance}'
def _config_path(self):
return f'{self._config_dir()}/server.json'
def _list_xray_files(self):
"""List filenames in the on-disk Xray config directory."""
out, _, code = self.ssh.run_sudo_command(f"ls -1 {self._config_dir()} 2>/dev/null")
if code != 0:
return []
return [f for f in out.strip().split('\n') if f]
def _detect_layout(self):
"""Pick which on-disk layout this installation uses.
'native' — official Amnezia client layout: xray_private.key,
xray_public.key, xray_short_id.key, xray_uuid.key plus a clientsTable
file without an extension.
'panel' — legacy web-panel layout: meta.json + clientsTable.json.
On a fresh node with no Xray files yet, defaults to 'native' so new
installs produce the same artifacts as the official client.
"""
if hasattr(self, '_cached_layout'):
return self._cached_layout
files = set(self._list_xray_files())
if {'xray_private.key', 'xray_public.key'} & files:
layout = 'native'
elif 'meta.json' in files:
layout = 'panel'
else:
layout = 'native'
self._cached_layout = layout
return layout
def _clients_table_filename(self):
return 'clientsTable' if self._detect_layout() == 'native' else 'clientsTable.json'
def _clients_table_path(self):
return f'{self._config_dir()}/{self._clients_table_filename()}'
def _read_remote_file(self, path):
"""Read a remote text file, preferring the running container's view."""
out, _, code = self.ssh.run_sudo_command(
f"docker exec {self.container_name} cat {path} 2>/dev/null"
)
if code != 0 or not out:
out, _, code = self.ssh.run_sudo_command(f"cat {path} 2>/dev/null")
if code != 0 or not out.strip():
return None
return out
def _derive_pubkey_from_priv(self, priv_key):
"""Derive the Reality public key from a private key via the xray binary.
Used as a fallback when xray_public.key is missing or unreadable.
"""
if not priv_key:
return ''
out, _, code = self.ssh.run_sudo_command(
f"docker exec {self.container_name} /usr/bin/xray x25519 -i {priv_key}"
)
if code != 0 or not out.strip():
out, _, code = self.ssh.run_sudo_command(
f"docker run --rm --entrypoint=\"\" {self.image_name} /usr/bin/xray x25519 -i {priv_key}"
)
if code != 0 or not out:
return ''
for line in out.split('\n'):
if 'Public' in line and ':' in line:
return line.split(':', 1)[1].strip()
return ''
def _get_default_xray_uuid(self):
"""UUID of the install-time default client (xray_uuid.key) — meaningful only
for native-layout installs. Imports skip this UUID, mirroring the official
Amnezia client behaviour (see usersController.cpp::getXrayClients).
"""
if self._detect_layout() != 'native':
return ''
out = self._read_remote_file(f"{self._config_dir()}/xray_uuid.key")
return (out or '').strip()
# ===================== INSTALLATION =====================
def check_docker_installed(self):
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 check_container_running(self):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{self.container_name}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def check_protocol_installed(self):
out, _, _ = 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 get_server_status(self, protocol):
exists = self.check_protocol_installed()
running = self.check_container_running()
clients = self.get_clients() if exists else []
meta = self._get_meta_json() if exists else {}
return {
'container_exists': exists,
'container_running': running,
'clients_count': len(clients),
'port': meta.get('port')
}
def install_protocol(self, port=443, site_name='yahoo.com'):
"""Full installation of Xray."""
results = []
if not self.check_docker_installed():
results.append("Installing Docker...")
# Using same install method as AWGManager or assume it's installed
pass
results.append("Removing old container if exists...")
if self.check_protocol_installed():
self.remove_container()
results.append("Building Docker image...")
config_dir = self._config_dir()
dockerfile_folder = f"/opt/amnezia/{self.container_name}"
dockerfile_content = f"""FROM alpine:3.15
RUN apk add --no-cache curl unzip bash openssl netcat-openbsd dumb-init rng-tools xz iptables ip6tables
RUN apk --update upgrade --no-cache
RUN mkdir -p {config_dir}
RUN curl -L -H "Cache-Control: no-cache" -o /root/xray.zip "https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-64.zip" && \\
unzip /root/xray.zip -d /usr/bin/ && \\
chmod a+x /usr/bin/xray && \\
rm /root/xray.zip
# Tune network
RUN echo "fs.file-max = 51200" >> /etc/sysctl.conf && \\
echo "net.core.rmem_max = 67108864" >> /etc/sysctl.conf && \\
echo "net.core.wmem_max = 67108864" >> /etc/sysctl.conf && \\
echo "net.core.netdev_max_backlog = 250000" >> /etc/sysctl.conf && \\
echo "net.core.somaxconn = 4096" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_syncookies = 1" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_tw_reuse = 1" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_tw_recycle = 0" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_fin_timeout = 30" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_keepalive_time = 1200" >> /etc/sysctl.conf && \\
echo "net.ipv4.ip_local_port_range = 10000 65000" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_max_syn_backlog = 8192" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_max_tw_buckets = 5000" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_fastopen = 3" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_mem = 25600 51200 102400" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_rmem = 4096 87380 67108864" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_wmem = 4096 65536 67108864" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_mtu_probing = 1" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_congestion_control = hybla" >> /etc/sysctl.conf
RUN mkdir -p /etc/security && \\
echo "* soft nofile 51200" >> /etc/security/limits.conf && \\
echo "* hard nofile 51200" >> /etc/security/limits.conf
RUN echo '#!/bin/bash' > /opt/amnezia/start.sh && \\
echo 'sysctl -p /etc/sysctl.conf 2>/dev/null' >> /opt/amnezia/start.sh && \\
echo '/usr/bin/xray -config {config_dir}/server.json' >> /opt/amnezia/start.sh && \\
chmod a+x /opt/amnezia/start.sh
ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
"""
self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}")
self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile")
_, err, code = self.ssh.run_sudo_command(
f"docker build --no-cache -t {self.image_name} {dockerfile_folder}", timeout=300
)
if code != 0: raise RuntimeError(f"Failed to build container: {err}")
results.append("Generating keys and config...")
# We generate a base config using a temp container or directly if host has openssl
# Xray keypair generation using a temporary run overriding the entrypoint
keypair_cmd = f"docker run --rm --entrypoint=\"\" {self.image_name} /usr/bin/xray x25519"
out_kp, err_kp, code_kp = self.ssh.run_sudo_command(keypair_cmd)
if code_kp != 0: raise RuntimeError(f"Failed to generate x25519 keys: {err_kp}")
priv_key = ""
pub_key = ""
for line in out_kp.split('\n'):
if "Private" in line: priv_key = line.split(":", 1)[1].strip()
if "Public" in line: pub_key = line.split(":", 1)[1].strip()
short_id_cmd = f"docker run --rm --entrypoint=\"\" {self.image_name} openssl rand -hex 8"
out_sid, _, _ = self.ssh.run_sudo_command(short_id_cmd)
short_id = out_sid.strip()
# Generate initial server.json with Stats and API enabled
server_json = {
"log": {"loglevel": "error"},
"stats": {},
"api": {
"services": ["StatsService", "LoggerService", "HandlerService"],
"tag": "api"
},
"policy": {
"levels": {
"0": {"statsUserUplink": True, "statsUserDownlink": True}
},
"system": {
"statsInboundUplink": True, "statsInboundDownlink": True,
"statsOutboundUplink": True, "statsOutboundDownlink": True
}
},
"inbounds": [
{
"port": int(port),
"protocol": "vless",
"tag": "proxy",
"settings": {
"clients": [],
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"dest": f"{site_name}:443",
"serverNames": [site_name],
"privateKey": priv_key,
"shortIds": [short_id]
}
}
},
{
"listen": "127.0.0.1",
"port": 10085,
"protocol": "dokodemo-door",
"settings": {"address": "127.0.0.1"},
"tag": "api"
}
],
"outbounds": [{"protocol": "freedom"}],
"routing": {
"rules": [
{
"inboundTag": ["api"],
"outboundTag": "api",
"type": "field"
}
]
}
}
self.ssh.run_sudo_command(f"mkdir -p {config_dir}")
self.ssh.upload_file_sudo(json.dumps(server_json, indent=2), f"{config_dir}/server.json")
# Native layout — separate key files matching the official Amnezia client install.
# See client/server_scripts/xray/configure_container.sh for the canonical layout.
self.ssh.upload_file_sudo(priv_key + '\n', "/opt/amnezia/xray/xray_private.key")
self.ssh.upload_file_sudo(pub_key + '\n', "/opt/amnezia/xray/xray_public.key")
self.ssh.upload_file_sudo(short_id + '\n', "/opt/amnezia/xray/xray_short_id.key")
# xray_uuid.key marks the install-time "default" client whose ID gets skipped on
# auto-import. Panel installs do not reserve such a client, so we leave it empty.
self.ssh.upload_file_sudo('\n', "/opt/amnezia/xray/xray_uuid.key")
self.ssh.upload_file_sudo("[]", "/opt/amnezia/xray/clientsTable")
results.append("Starting container...")
run_cmd = f"""docker run -d \\
--restart always \\
--privileged \\
--cap-add=NET_ADMIN \\
-p {port}:{port}/tcp \\
-p {port}:{port}/udp \\
-v {config_dir}:{config_dir} \\
--name {self.container_name} \\
{self.image_name}"""
_, err, code = self.ssh.run_sudo_command(run_cmd)
if code != 0: raise RuntimeError(f"Failed to run container: {err}")
# Try to connect to network if needed
self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.container_name} || true")
results.append("Xray configured and running")
return {'status': 'success', 'protocol': self.protocol, 'port': port, 'log': results}
def remove_container(self):
self.ssh.run_sudo_command(f"docker stop {self.container_name}")
self.ssh.run_sudo_command(f"docker rm -fv {self.container_name}")
if self.instance <= 1:
self.ssh.run_sudo_command(f"docker rmi {self.image_name}")
return True
# ===================== CLIENT MANAGEMENT =====================
def _get_server_json(self):
"""Read server.json — tries inside container first, falls back to host path."""
out, _, code = self.ssh.run_sudo_command(
f"docker exec {self.container_name} cat {self._config_path()}"
)
if code != 0:
out, _, code = self.ssh.run_sudo_command(f"cat {self._config_path()}")
if code != 0 or not out.strip():
return None
return json.loads(out)
def _save_server_json(self, data):
return self._write_server_json(data, restart=True)
def _write_server_json(self, data, restart=True):
"""Write server.json into container via docker cp AND sync to host path."""
tmp_file = "/tmp/_xray_server.json"
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
self.ssh.run_sudo_command(
f"docker cp {tmp_file} {self.container_name}:{self._config_path()}"
)
# Also keep host copy in sync (handles both volume-mount and no-mount installs)
self.ssh.run_sudo_command(
f"cp {tmp_file} {self._config_path()} 2>/dev/null || true"
)
if restart:
self.ssh.run_sudo_command(f"docker restart {self.container_name}")
def _get_vless_inbound(self, config):
for inbound in config.get('inbounds', []):
if inbound.get('protocol') == 'vless':
return inbound
return None
def _get_vless_inbound_tag(self, config):
inbound = self._get_vless_inbound(config)
return inbound.get('tag') if inbound else None
def _run_xray_api_json(self, subcommand, payload):
tmp_name = f"/tmp/_xray_api_{uuid.uuid4().hex}.json"
container_tmp = tmp_name
try:
self.ssh.upload_file_sudo(json.dumps(payload, indent=2), tmp_name)
_, err, code = self.ssh.run_sudo_command(
f"docker cp {tmp_name} {self.container_name}:{container_tmp}"
)
if code != 0:
return False, err
out, err, code = self.ssh.run_sudo_command(
f"docker exec -i {self.container_name} /usr/bin/xray api {subcommand} "
f"-server=127.0.0.1:10085 {container_tmp}"
)
return code == 0, err or out
finally:
self.ssh.run_sudo_command(f"rm -f {tmp_name}")
self.ssh.run_sudo_command(f"docker exec -i {self.container_name} rm -f {container_tmp} 2>/dev/null || true")
def _xray_api_add_user(self, config, client):
tag = self._get_vless_inbound_tag(config)
if not tag:
return False
payload = {
"inbounds": [{
"tag": tag,
"protocol": "vless",
"settings": {
"clients": [client],
"decryption": "none",
}
}]
}
ok, message = self._run_xray_api_json('adu', payload)
if not ok:
logger.warning(f"Xray API add user failed: {message}")
return False
return True
def _xray_api_remove_user(self, config, client_id):
tag = self._get_vless_inbound_tag(config)
if not tag:
return False
cmd = (
f"docker exec -i {self.container_name} /usr/bin/xray api rmu "
f"-server=127.0.0.1:10085 "
f"-tag={shlex.quote(tag)} {shlex.quote(client_id)}"
)
out, err, code = self.ssh.run_sudo_command(cmd)
if code != 0:
logger.warning(f"Xray API remove user failed: {err or out}")
return False
return True
def _get_meta_json(self):
"""Read protocol metadata. Supports both layouts.
Native layout pulls keys from xray_*.key files. Panel layout reads
meta.json. Either way, port and site_name come from server.json since
that is the authoritative runtime config — meta.json may go stale if
the user edits server.json directly via the panel.
"""
config = self._get_server_json() or {}
port = None
site_name = None
rs = {}
try:
ib = next(b for b in config.get('inbounds', []) if b.get('protocol') == 'vless')
port = ib.get('port')
rs = ib.get('streamSettings', {}).get('realitySettings', {}) or {}
names = rs.get('serverNames') or []
if names:
site_name = names[0]
except StopIteration:
pass
if self._detect_layout() == 'native':
priv = (self._read_remote_file(f"{self._config_dir()}/xray_private.key") or '').strip()
pub = (self._read_remote_file(f"{self._config_dir()}/xray_public.key") or '').strip()
sid = (self._read_remote_file(f"{self._config_dir()}/xray_short_id.key") or '').strip()
if not priv:
priv = rs.get('privateKey', '')
if not sid:
sids = rs.get('shortIds') or []
sid = sids[0] if sids else ''
if not pub:
pub = self._derive_pubkey_from_priv(priv)
return {
'private_key': priv,
'public_key': pub,
'short_id': sid,
'port': port,
'site_name': site_name or 'yahoo.com',
}
# Panel (legacy) layout
out = self._read_remote_file(f"{self._config_dir()}/meta.json")
meta = {}
if out:
try:
meta = json.loads(out)
except Exception:
meta = {}
if port is not None:
meta['port'] = port
if site_name:
meta['site_name'] = site_name
if not meta.get('private_key'):
meta['private_key'] = rs.get('privateKey', '')
if not meta.get('short_id'):
sids = rs.get('shortIds') or []
if sids:
meta['short_id'] = sids[0]
if not meta.get('public_key') and meta.get('private_key'):
meta['public_key'] = self._derive_pubkey_from_priv(meta['private_key'])
return meta
def _get_clients_table(self):
"""Read clientsTable, trying both layout filenames."""
layout = self._detect_layout()
primary = 'clientsTable' if layout == 'native' else 'clientsTable.json'
fallback = 'clientsTable.json' if layout == 'native' else 'clientsTable'
for fname in (primary, fallback):
out = self._read_remote_file(f"{self._config_dir()}/{fname}")
if not out or not out.strip():
continue
try:
return json.loads(out)
except Exception:
continue
return []
def _save_clients_table(self, data):
"""Write clientsTable to the file matching the current layout, in both
the container and the host bind-mount.
"""
path = self._clients_table_path()
tmp_file = "/tmp/_xray_clients.json"
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
self.ssh.run_sudo_command(
f"docker cp {tmp_file} {self.container_name}:{path}"
)
self.ssh.run_sudo_command(
f"cp {tmp_file} {path} 2>/dev/null || true"
)
def _upgrade_config_for_stats(self, config, restart=True):
"""Injects API and Stats blocks into older Xray configs transparently."""
dirty = False
if 'stats' not in config:
config['stats'] = {}
dirty = True
if 'api' not in config:
config['api'] = {"services": ["StatsService", "LoggerService", "HandlerService"], "tag": "api"}
dirty = True
else:
services = config['api'].setdefault('services', [])
if 'HandlerService' not in services:
services.append('HandlerService')
dirty = True
if 'policy' not in config:
config['policy'] = {
"levels": {"0": {"statsUserUplink": True, "statsUserDownlink": True}},
"system": {"statsInboundUplink": True, "statsInboundDownlink": True, "statsOutboundUplink": True, "statsOutboundDownlink": True}
}
dirty = True
if 'routing' not in config:
config['routing'] = {"rules": [{"inboundTag": ["api"], "outboundTag": "api", "type": "field"}]}
dirty = True
has_api_inbound = any(ib.get('tag') == 'api' for ib in config.get('inbounds', []))
if not has_api_inbound:
config.setdefault('inbounds', []).append({
"listen": "127.0.0.1",
"port": 10085,
"protocol": "dokodemo-door",
"settings": {"address": "127.0.0.1"},
"tag": "api"
})
dirty = True
for ib in config.get('inbounds', []):
if ib.get('protocol') == 'vless':
if not ib.get('tag'):
ib['tag'] = 'proxy'
dirty = True
for c in ib.get('settings', {}).get('clients', []):
if 'email' not in c:
c['email'] = c['id']
dirty = True
if dirty:
self._write_server_json(config, restart=restart)
return dirty
def _query_xray_stats(self):
"""Query Xray API for traffic stats using xray api command."""
out, _, code = self.ssh.run_sudo_command(
f"docker exec -i {self.container_name} /usr/bin/xray api statsquery -server=127.0.0.1:10085"
)
if code != 0 or not out.strip():
return {}
try:
stats_raw = json.loads(out)
except Exception:
return {}
results = {}
# Output format: {"stat": [{"name": "user>>>uid>>>traffic>>>downlink", "value": "123"}, ...]}
for item in stats_raw.get('stat', []):
name_parts = item.get('name', '').split('>>>')
if len(name_parts) == 4 and name_parts[0] == 'user':
uid = name_parts[1]
t_type = name_parts[3] # uplink or downlink
val = int(item.get('value', 0))
if uid not in results:
results[uid] = {'rx': 0, 'tx': 0}
if t_type == 'downlink':
results[uid]['rx'] = val
elif t_type == 'uplink':
results[uid]['tx'] = val
return results
def _format_bytes(self, size):
# Format bytes to string like AWG (e.g., 1.50 MiB)
power = 2**10
n = 0
powers = {0: 'B', 1: 'KiB', 2: 'MiB', 3: 'GiB', 4: 'TiB'}
while size > power:
size /= power
n += 1
v = round(size, 2)
if v == int(v):
v = int(v)
return f"{v} {powers.get(n, 'B')}"
def get_clients(self, protocol=None):
config = self._get_server_json()
if not config:
return []
self._upgrade_config_for_stats(config, restart=False)
# Collect all client IDs currently registered in the Xray server config
xray_clients = []
for ib in config.get('inbounds', []):
if ib.get('protocol') == 'vless':
xray_clients.extend(ib.get('settings', {}).get('clients', []))
clients_table = self._get_clients_table()
table_ids = {c['clientId'] for c in clients_table}
# Auto-import clients present in server.json but missing from clientsTable
# (e.g. added via the native Amnezia phone/desktop app). Skip the install-time
# default UUID for native-layout installs — the official client treats it as
# the device of the user who installed the server, not a manageable client.
default_uuid = self._get_default_xray_uuid()
updated = False
for xc in xray_clients:
uid = xc.get('id')
if not uid or uid in table_ids or uid == default_uuid:
continue
clients_table.append({
'clientId': uid,
'userData': {
'clientName': f'Imported_{uid[:8]}',
'creationDate': datetime.now().isoformat(),
'enabled': True
}
})
table_ids.add(uid)
updated = True
logger.info(f"Auto-imported Xray client {uid[:8]} from server.json")
if updated:
self._save_clients_table(clients_table)
stats = self._query_xray_stats()
for c in clients_table:
uid = c.get('clientId', '')
if uid in stats:
user_data = c.setdefault('userData', {})
rx = stats[uid]['rx']
tx = stats[uid]['tx']
if rx > 0 or tx > 0:
user_data['dataReceived'] = self._format_bytes(rx)
user_data['dataSent'] = self._format_bytes(tx)
user_data['dataReceivedBytes'] = rx
user_data['dataSentBytes'] = tx
return clients_table
def get_client_config(self, protocol, client_id, server_host, port):
clients = self._get_clients_table()
client = next((c for c in clients if c['clientId'] == client_id), None)
if not client: return None
meta = self._get_meta_json()
if not meta: return None
config = self._get_server_json()
sni = meta.get('site_name', 'yahoo.com')
if config:
try:
sni = config['inbounds'][0]['streamSettings']['realitySettings']['serverNames'][0]
except Exception:
pass
# Format URL
# vless://{id}@{host}:{port}?type=tcp&security=reality&pbk={public_key}&sni={sni}&fp=chrome&sid={short_id}&spx=%2F&flow=xtls-rprx-vision#{name}
name = client.get('userData', {}).get('clientName', 'vpn')
encoded_name = urllib.parse.quote(name)
url = (
f"vless://{client_id}@{server_host}:{meta.get('port', port)}"
f"?type=tcp&security=reality&pbk={meta['public_key']}"
f"&sni={sni}&fp=chrome&sid={meta['short_id']}"
f"&spx=%2F&flow=xtls-rprx-vision#{encoded_name}"
)
return url
def add_client(self, protocol, client_name, server_host, port):
client_id = str(uuid.uuid4())
config = self._get_server_json()
if not config: raise RuntimeError("Xray server config not found.")
self._upgrade_config_for_stats(config, restart=False)
inbound = self._get_vless_inbound(config)
if not inbound:
raise RuntimeError("Xray VLESS inbound not found.")
# Ensure clients structure exists
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
client = {
"id": client_id,
"flow": "xtls-rprx-vision",
"email": client_id
}
if not self._xray_api_add_user(config, client):
raise RuntimeError(
"Xray runtime API is not available for hot user updates. "
"The server config was upgraded, but the container must be restarted once to enable HandlerService. "
"Restart the Xray container manually and try again."
)
clients_node.append(client)
self._write_server_json(config, restart=False)
# Update table
clients_table = self._get_clients_table()
clients_table.append({
'clientId': client_id,
'userData': {
'clientName': client_name,
'creationDate': datetime.now().isoformat(),
'enabled': True
}
})
self._save_clients_table(clients_table)
return {
'client_id': client_id,
'config': self.get_client_config(protocol, client_id, server_host, port)
}
def toggle_client(self, protocol, client_id, enable):
config = self._get_server_json()
self._upgrade_config_for_stats(config, restart=False)
inbound = self._get_vless_inbound(config)
if not inbound:
raise RuntimeError("Xray VLESS inbound not found.")
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
# If toggling on and not present, we can re-add it from clientsTable
if enable:
if not any(c['id'] == client_id for c in clients_node):
client = {
"id": client_id,
"flow": "xtls-rprx-vision",
"email": client_id
}
if not self._xray_api_add_user(config, client):
raise RuntimeError("Xray runtime API failed to enable the client without restarting the container.")
clients_node.append(client)
else:
if not self._xray_api_remove_user(config, client_id):
raise RuntimeError("Xray runtime API failed to disable the client without restarting the container.")
inbound['settings']['clients'] = [c for c in clients_node if c['id'] != client_id]
self._write_server_json(config, restart=False)
clients_table = self._get_clients_table()
for c in clients_table:
if c['clientId'] == client_id:
c.setdefault('userData', {})['enabled'] = enable
self._save_clients_table(clients_table)
def remove_client(self, protocol, client_id):
config = self._get_server_json()
self._upgrade_config_for_stats(config, restart=False)
inbound = self._get_vless_inbound(config)
if not inbound:
raise RuntimeError("Xray VLESS inbound not found.")
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
if not self._xray_api_remove_user(config, client_id):
raise RuntimeError("Xray runtime API failed to remove the client without restarting the container.")
inbound['settings']['clients'] = [c for c in clients_node if c['id'] != client_id]
self._write_server_json(config, restart=False)
clients_table = self._get_clients_table()
clients_table = [c for c in clients_table if c['clientId'] != client_id]
self._save_clients_table(clients_table)
return True