Template
Add Amnezia Web Panel source with PostgreSQL 17 storage.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`).
|
||||
"""
|
||||
@@ -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
@@ -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}}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user