Add reverse proxy masking and Cloudflare panel SSL via DNS-01.

Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
test2
2026-07-08 00:41:50 +03:00
co-authored by Cursor
commit 7ef408afe7
46 changed files with 19886 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
"""Protocol/service/SSH managers used by the web panel.
Modules in this package are imported either directly (`from managers.ssh_manager import SSHManager`)
or lazily by name through `app.get_protocol_manager`. Keeping them in a dedicated package
makes the project root easier to scan and prevents accidental name collisions with the
generic stdlib (e.g. `socks5_manager`, `dns_manager`).
"""
+229
View File
@@ -0,0 +1,229 @@
"""
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 _exposed_web_port(self):
"""Reads back the host->container port mapping for the web UI port,
so the panel can show the correct admin URL after install."""
out, _, _ = self.ssh.run_sudo_command(
f"docker port {self.CONTAINER_NAME} 3000/tcp 2>/dev/null"
)
if not out.strip():
return None
# 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:
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 ''
exposed_port = self._exposed_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) — fall back to the default :3000 the
# container listens on inside the docker network.
return {
'container_exists': exists,
'container_running': running,
'mode': mode,
'internal_ip': ip,
'web_port': exposed_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}:3000/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}"
)
_, 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
+411
View File
@@ -0,0 +1,411 @@
"""
Cloudflare DNS-01 ACME client for issuing the panel's own Let's Encrypt
certificate WITHOUT binding port 80/443.
Why not certbot: the panel ships as a single PyInstaller binary, so we implement
a minimal ACME v2 (RFC 8555) flow using `cryptography` + `httpx` only, both of
which are already dependencies. Validation is done via a Cloudflare DNS TXT
record (dns-01), which never touches the HTTP port — so the panel can keep
running on any port while the reverse proxy owns 443.
Public entry points:
issue_certificate(domain, cf_token, email, ...) -> {cert_pem, key_pem, ...}
get_cert_expiry(cert_pem) -> datetime | None
"""
import base64
import hashlib
import json
import logging
import time
from datetime import datetime, timezone
import httpx
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
from cryptography.x509.oid import NameOID
logger = logging.getLogger(__name__)
# Let's Encrypt production directory. Staging is available for testing but its
# certs are not browser-trusted, so we default to production.
ACME_DIRECTORY_PROD = "https://acme-v02.api.letsencrypt.org/directory"
ACME_DIRECTORY_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
CF_API_BASE = "https://api.cloudflare.com/client/v4"
USER_AGENT = "amnezia-panel-acme/1.0"
def _b64(data: bytes) -> str:
"""base64url without padding, per JWS spec."""
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
class CloudflareError(Exception):
pass
class ACMEError(Exception):
pass
# ============================================================
# Cloudflare DNS API
# ============================================================
class CloudflareDNS:
def __init__(self, token: str, timeout: float = 30.0):
self.token = token.strip()
self._client = httpx.Client(
base_url=CF_API_BASE,
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
timeout=timeout,
)
def close(self):
self._client.close()
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
def _check(self, resp: httpx.Response) -> dict:
try:
body = resp.json()
except Exception:
raise CloudflareError(f"Cloudflare returned non-JSON ({resp.status_code}): {resp.text[:200]}")
if not body.get("success", False):
errors = body.get("errors", [])
msg = "; ".join(e.get("message", str(e)) for e in errors) or resp.text[:200]
raise CloudflareError(f"Cloudflare API error: {msg}")
return body
def verify_token(self) -> bool:
resp = self._client.get("/user/tokens/verify")
self._check(resp)
return True
def find_zone_id(self, domain: str) -> str:
"""Resolve the zone that owns `domain`. Tries the domain and each parent
(sub.example.com -> example.com) so subdomains resolve to the apex zone."""
parts = domain.split(".")
candidates = [".".join(parts[i:]) for i in range(len(parts) - 1)]
for zone_name in candidates:
resp = self._client.get("/zones", params={"name": zone_name})
body = self._check(resp)
results = body.get("result", [])
if results:
return results[0]["id"]
raise CloudflareError(
f"No Cloudflare zone found for '{domain}'. Ensure the domain is added to this Cloudflare account and the token has Zone:Read."
)
def create_txt_record(self, zone_id: str, name: str, content: str) -> str:
resp = self._client.post(
f"/zones/{zone_id}/dns_records",
json={"type": "TXT", "name": name, "content": content, "ttl": 60},
)
body = self._check(resp)
return body["result"]["id"]
def delete_record(self, zone_id: str, record_id: str):
try:
self._client.delete(f"/zones/{zone_id}/dns_records/{record_id}")
except Exception as e:
logger.warning("Failed to delete Cloudflare TXT record %s: %s", record_id, e)
# ============================================================
# Minimal ACME v2 client (RFC 8555) — dns-01 only
# ============================================================
class ACMEClient:
def __init__(self, directory_url: str = ACME_DIRECTORY_PROD, timeout: float = 60.0):
self.directory_url = directory_url
self._client = httpx.Client(
headers={"User-Agent": USER_AGENT, "Content-Type": "application/jose+json"},
timeout=timeout,
)
self._directory = None
self._nonce = None
self._account_key = None
self._kid = None
def close(self):
self._client.close()
# ---- account key (RSA 2048) ----
def _gen_account_key(self):
self._account_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
def _jwk(self) -> dict:
pub = self._account_key.public_key().public_numbers()
e = pub.e.to_bytes((pub.e.bit_length() + 7) // 8, "big")
n = pub.n.to_bytes((pub.n.bit_length() + 7) // 8, "big")
return {"kty": "RSA", "e": _b64(e), "n": _b64(n)}
def _thumbprint(self) -> str:
jwk = self._jwk()
canonical = json.dumps({"e": jwk["e"], "kty": jwk["kty"], "n": jwk["n"]}, separators=(",", ":"), sort_keys=True)
return _b64(hashlib.sha256(canonical.encode()).digest())
# ---- low-level JWS transport ----
def _load_directory(self):
resp = self._client.get(self.directory_url)
resp.raise_for_status()
self._directory = resp.json()
def _get_nonce(self) -> str:
if self._nonce:
n, self._nonce = self._nonce, None
return n
resp = self._client.head(self._directory["newNonce"])
return resp.headers["Replay-Nonce"]
def _signed_request(self, url: str, payload, use_jwk: bool = False):
protected = {"alg": "RS256", "nonce": self._get_nonce(), "url": url}
if use_jwk:
protected["jwk"] = self._jwk()
else:
protected["kid"] = self._kid
protected_b64 = _b64(json.dumps(protected, separators=(",", ":")).encode())
if payload == "":
payload_b64 = "" # POST-as-GET
else:
payload_b64 = _b64(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{protected_b64}.{payload_b64}".encode()
signature = self._account_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
jose = {"protected": protected_b64, "payload": payload_b64, "signature": _b64(signature)}
resp = self._client.post(url, content=json.dumps(jose))
if "Replay-Nonce" in resp.headers:
self._nonce = resp.headers["Replay-Nonce"]
if resp.status_code >= 400:
try:
err = resp.json()
raise ACMEError(f"ACME error {resp.status_code}: {err.get('detail', resp.text)}")
except (ValueError, KeyError):
raise ACMEError(f"ACME error {resp.status_code}: {resp.text[:300]}")
return resp
# ---- ACME flow ----
def register_account(self, email: str):
payload = {"termsOfServiceAgreed": True}
if email:
payload["contact"] = [f"mailto:{email}"]
resp = self._signed_request(self._directory["newAccount"], payload, use_jwk=True)
self._kid = resp.headers["Location"]
def new_order(self, domains: list) -> dict:
payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]}
resp = self._signed_request(self._directory["newOrder"], payload)
order = resp.json()
order["_url"] = resp.headers["Location"]
return order
def get_authorization(self, auth_url: str) -> dict:
resp = self._signed_request(auth_url, "")
return resp.json()
def dns_challenge_value(self, token: str) -> str:
key_auth = f"{token}.{self._thumbprint()}"
return _b64(hashlib.sha256(key_auth.encode()).digest())
def trigger_challenge(self, challenge_url: str):
self._signed_request(challenge_url, {})
def poll_authorization(self, auth_url: str, attempts: int = 30, delay: float = 5.0) -> str:
for _ in range(attempts):
auth = self.get_authorization(auth_url)
status = auth.get("status")
if status == "valid":
return "valid"
if status == "invalid":
challenges = auth.get("challenges", [])
detail = ""
for c in challenges:
if c.get("error"):
detail = c["error"].get("detail", "")
raise ACMEError(f"DNS validation failed: {detail or 'authorization invalid'}")
time.sleep(delay)
raise ACMEError("Timed out waiting for DNS validation")
def finalize_order(self, finalize_url: str, csr_der: bytes) -> dict:
resp = self._signed_request(finalize_url, {"csr": _b64(csr_der)})
return resp.json()
def poll_order(self, order_url: str, attempts: int = 20, delay: float = 3.0) -> dict:
for _ in range(attempts):
resp = self._signed_request(order_url, "")
order = resp.json()
if order.get("status") == "valid":
return order
if order.get("status") == "invalid":
raise ACMEError("Order became invalid during finalization")
time.sleep(delay)
raise ACMEError("Timed out waiting for certificate issuance")
def download_certificate(self, cert_url: str) -> str:
resp = self._signed_request(cert_url, "")
return resp.text
def _build_csr(domains: list):
"""Generate an EC P-256 leaf key + CSR for the given domains."""
key = ec.generate_private_key(ec.SECP256R1())
builder = x509.CertificateSigningRequestBuilder().subject_name(
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domains[0])])
)
san = x509.SubjectAlternativeName([x509.DNSName(d) for d in domains])
builder = builder.add_extension(san, critical=False)
csr = builder.sign(key, hashes.SHA256())
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode()
return csr.public_bytes(serialization.Encoding.DER), key_pem
def issue_certificate(
domain: str,
cf_token: str,
email: str = "",
staging: bool = False,
dns_propagation_wait: int = 20,
progress=None,
) -> dict:
"""
Issue a Let's Encrypt certificate for `domain` using Cloudflare dns-01.
Returns dict: {cert_pem, key_pem, domain, expires_at (iso), issued_at (iso)}
Raises CloudflareError / ACMEError on failure.
"""
def log(msg):
logger.info(msg)
if progress:
try:
progress(msg)
except Exception:
pass
domain = domain.strip().lower().rstrip(".")
if not domain:
raise ACMEError("Domain is required")
directory = ACME_DIRECTORY_STAGING if staging else ACME_DIRECTORY_PROD
cf = CloudflareDNS(cf_token)
acme = ACMEClient(directory)
txt_record_id = None
zone_id = None
try:
log("Verifying Cloudflare API token...")
cf.verify_token()
zone_id = cf.find_zone_id(domain)
log(f"Cloudflare zone resolved for {domain}")
log("Setting up ACME account...")
acme._gen_account_key()
acme._load_directory()
acme.register_account(email)
log("Creating certificate order...")
order = acme.new_order([domain])
for auth_url in order["authorizations"]:
auth = acme.get_authorization(auth_url)
identifier = auth["identifier"]["value"]
dns_challenge = next(
(c for c in auth["challenges"] if c["type"] == "dns-01"), None
)
if not dns_challenge:
raise ACMEError("No dns-01 challenge offered by ACME server")
txt_value = acme.dns_challenge_value(dns_challenge["token"])
record_name = f"_acme-challenge.{identifier}"
log(f"Creating DNS TXT record {record_name} ...")
txt_record_id = cf.create_txt_record(zone_id, record_name, txt_value)
log(f"Waiting {dns_propagation_wait}s for DNS propagation...")
time.sleep(dns_propagation_wait)
log("Asking Let's Encrypt to validate...")
acme.trigger_challenge(dns_challenge["url"])
acme.poll_authorization(auth_url)
log("Domain validated ✓")
if txt_record_id:
cf.delete_record(zone_id, txt_record_id)
txt_record_id = None
log("Generating key and CSR...")
csr_der, key_pem = _build_csr([domain])
log("Finalizing order...")
acme.finalize_order(order["finalize"], csr_der)
final_order = acme.poll_order(order["_url"])
log("Downloading certificate...")
cert_pem = acme.download_certificate(final_order["certificate"])
expires_at = get_cert_expiry(cert_pem)
result = {
"cert_pem": cert_pem,
"key_pem": key_pem,
"domain": domain,
"issued_at": datetime.now(timezone.utc).isoformat(),
"expires_at": expires_at.isoformat() if expires_at else None,
"staging": staging,
}
log("Certificate issued successfully ✓")
return result
finally:
if txt_record_id and zone_id:
cf.delete_record(zone_id, txt_record_id)
cf.close()
acme.close()
def get_cert_expiry(cert_pem: str):
"""Return the notAfter datetime (UTC) of the first cert in the PEM chain."""
try:
cert = x509.load_pem_x509_certificate(cert_pem.encode())
try:
return cert.not_valid_after_utc
except AttributeError:
return cert.not_valid_after.replace(tzinfo=timezone.utc)
except Exception as e:
logger.warning("Could not parse certificate expiry: %s", e)
return None
def needs_renewal(expires_at_iso: str, days_before: int = 30) -> bool:
"""True if the cert expires within `days_before` days (or is unparseable)."""
if not expires_at_iso:
return True
try:
expires = datetime.fromisoformat(expires_at_iso)
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
remaining = (expires - datetime.now(timezone.utc)).days
return remaining <= days_before
except Exception:
return True
+83
View File
@@ -0,0 +1,83 @@
import os
import logging
logger = logging.getLogger(__name__)
class DNSManager:
def __init__(self, ssh):
self.ssh = ssh
def install_protocol(self, protocol_type='dns', port='53'):
"""Install AmneziaDNS service."""
try:
# 1. Check if docker is installed
out, _, _ = self.ssh.run_command("docker --version")
if "docker version" not in out.lower():
return {"status": "error", "message": "Docker not installed"}
# 2. Prepare directory
self.ssh.run_sudo_command("mkdir -p /opt/amnezia/dns")
# 3. Create Dockerfile matching official Amnezia implementation
# We use Unbound (mvance/unbound) with DNS-over-TLS to Cloudflare
forward_config = """forward-zone:
name: "."
forward-tls-upstream: yes
forward-addr: 1.1.1.1@853
forward-addr: 1.0.0.1@853
"""
self.ssh.write_file("/opt/amnezia/dns/forward-records.conf", forward_config)
dockerfile = """
FROM mvance/unbound:latest
LABEL maintainer="AmneziaVPN"
COPY forward-records.conf /opt/unbound/etc/unbound/forward-records.conf
"""
self.ssh.write_file("/opt/amnezia/dns/Dockerfile", dockerfile)
# 4. Build and run
self.ssh.run_sudo_command("docker build -t amnezia-dns /opt/amnezia/dns")
self.ssh.run_sudo_command("docker stop amnezia-dns || true")
self.ssh.run_sudo_command("docker rm amnezia-dns || true")
# Create internal network for DNS (like original Amnezia client)
self.ssh.run_sudo_command("docker network ls | grep -q amnezia-dns-net || docker network create --subnet 172.29.172.0/24 amnezia-dns-net")
# Use internal network with static IP. Do not expose 53 on host to avoid systemd-resolved conflict.
cmd = "docker run -d --name amnezia-dns --restart always --network amnezia-dns-net --ip=172.29.172.254 amnezia-dns"
self.ssh.run_sudo_command(cmd)
# Connect existing VPN containers to the DNS network
vpn_containers = ['amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'telemt']
for c in vpn_containers:
self.ssh.run_sudo_command(f"docker ps | grep -q {c} && docker network connect amnezia-dns-net {c} || true")
return {"status": "success", "message": "AmneziaDNS installed successfully"}
except Exception as e:
logger.exception("Error installing DNS")
return {"status": "error", "message": str(e)}
def get_server_status(self, protocol_type='dns'):
"""Check if AmneziaDNS container is running."""
try:
out, _, _ = self.ssh.run_sudo_command("docker ps --filter name=^amnezia-dns$ --format '{{.Status}}'")
is_running = 'Up' in out
out_exists, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
container_exists = 'amnezia-dns' in out_exists.strip().split('\n')
return {
"container_exists": container_exists,
"container_running": is_running,
"port": "53",
"protocol": protocol_type
}
except Exception as e:
return {"error": str(e)}
def remove_container(self, protocol_type='dns'):
"""Remove AmneziaDNS container."""
self.ssh.run_sudo_command("docker stop amnezia-dns || true")
self.ssh.run_sudo_command("docker rm amnezia-dns || true")
self.ssh.run_sudo_command("rm -rf /opt/amnezia/dns")
+410
View File
@@ -0,0 +1,410 @@
"""
Reverse Proxy Manager — Caddy 2 front for traffic masking.
Serves a polished decoy website on ports 80/443 and optionally reverse-proxies
a secret path to VPN backends (Xray, Telemt) on the internal Docker network.
Reduces DPI footprint: probes see a normal HTTPS site instead of a VPN endpoint.
"""
import json
import logging
import os
import re
logger = logging.getLogger(__name__)
BACKEND_UPSTREAMS = {
'none': None,
'xray': 'amnezia-xray',
'telemt': 'telemt',
}
DEFAULT_VPN_PATH = '/cdn-cgi/challenge'
DEFAULT_BACKEND_PORT = 8443
class RevproxyManager:
PROTOCOL = 'revproxy'
CONTAINER_NAME = 'amnezia-revproxy'
REMOTE_DIR = '/opt/amnezia/revproxy'
SETTINGS_FILE = 'settings.json'
def __init__(self, ssh):
self.ssh = ssh
# ===================== STATUS =====================
def check_docker_installed(self):
out, _, code = self.ssh.run_command('docker --version 2>/dev/null')
return code == 0 and bool(out.strip())
def check_protocol_installed(self, protocol_type='revproxy'):
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 get_server_status(self, protocol_type='revproxy'):
exists = self.check_protocol_installed()
running = self.check_container_running()
settings = self._read_settings() if exists else {}
site_url = self._build_site_url(settings)
return {
'container_exists': exists,
'container_running': running,
'port': 443,
'protocol': protocol_type,
'domain': settings.get('domain', ''),
'site_title': settings.get('site_title', 'CloudEdge'),
'backend': settings.get('backend', 'none'),
'backend_port': settings.get('backend_port', DEFAULT_BACKEND_PORT),
'vpn_path': settings.get('vpn_path', DEFAULT_VPN_PATH),
'tls_mode': settings.get('tls_mode', 'internal'),
'site_url': site_url,
'mask_enabled': settings.get('backend') in ('xray', 'telemt'),
}
# ===================== INSTALL / UPDATE / REMOVE =====================
def install_protocol(
self,
protocol_type='revproxy',
domain=None,
site_title=None,
backend='none',
backend_port=None,
vpn_path=None,
tls_email=None,
enable_telemt_mask=False,
):
if not self.check_docker_installed():
return {'status': 'error', 'message': 'Docker not installed'}
backend = (backend or 'none').lower()
if backend not in BACKEND_UPSTREAMS:
backend = 'none'
backend_port = int(backend_port or DEFAULT_BACKEND_PORT)
vpn_path = self._normalize_path(vpn_path or DEFAULT_VPN_PATH)
site_title = (site_title or 'CloudEdge').strip() or 'CloudEdge'
domain = (domain or '').strip().lower()
tls_email = (tls_email or '').strip()
tls_mode = 'acme' if domain else 'internal'
settings = {
'domain': domain,
'site_title': site_title,
'backend': backend,
'backend_port': backend_port,
'vpn_path': vpn_path,
'tls_email': tls_email,
'tls_mode': tls_mode,
}
log = []
if self.check_protocol_installed():
log.append('Removing previous reverse proxy container...')
self.remove_container()
log.append('Ensuring Docker network amnezia-dns-net...')
self._ensure_network()
log.append('Ensuring docker compose plugin...')
self._ensure_docker_compose()
log.append('Uploading reverse proxy files...')
self._upload_bundle(settings)
log.append('Starting Caddy reverse proxy...')
out, err, code = self.ssh.run_sudo_command(
f"sh -c 'cd {self.REMOTE_DIR} && docker compose up -d'", timeout=180
)
if code != 0:
out2, err2, code2 = self.ssh.run_sudo_command(
f"sh -c 'cd {self.REMOTE_DIR} && docker-compose up -d'", timeout=180
)
if code2 != 0:
return {
'status': 'error',
'message': f'Failed to start reverse proxy: {err or err2 or out or out2}',
}
if backend == 'telemt' and enable_telemt_mask:
log.append('Enabling Telemt site masking (mask → Caddy)...')
self._configure_telemt_mask()
elif backend == 'xray':
self.ssh.run_sudo_command(
'docker network connect amnezia-dns-net amnezia-xray 2>/dev/null || true'
)
site_url = self._build_site_url(settings)
log.append(f'Decoy site: {site_url}')
if backend != 'none':
log.append(f'VPN tunnel path: {vpn_path}{BACKEND_UPSTREAMS[backend]}:{backend_port}')
log.append('Ensure the backend listens on the internal port, not host 443.')
return {
'status': 'success',
'protocol': 'revproxy',
'port': 443,
'domain': domain,
'site_url': site_url,
'backend': backend,
'vpn_path': vpn_path,
'tls_mode': tls_mode,
'message': 'Reverse proxy installed',
'log': log,
}
def update_settings(
self,
domain=None,
site_title=None,
backend=None,
backend_port=None,
vpn_path=None,
tls_email=None,
enable_telemt_mask=False,
):
if not self.check_protocol_installed():
return {'status': 'error', 'message': 'Reverse proxy not installed'}
current = self._read_settings()
settings = {
'domain': (domain if domain is not None else current.get('domain', '')).strip().lower(),
'site_title': (site_title if site_title is not None else current.get('site_title', 'CloudEdge')).strip(),
'backend': (backend if backend is not None else current.get('backend', 'none')).lower(),
'backend_port': int(backend_port if backend_port is not None else current.get('backend_port', DEFAULT_BACKEND_PORT)),
'vpn_path': self._normalize_path(vpn_path if vpn_path is not None else current.get('vpn_path', DEFAULT_VPN_PATH)),
'tls_email': (tls_email if tls_email is not None else current.get('tls_email', '')).strip(),
'tls_mode': 'acme' if (domain if domain is not None else current.get('domain')) else 'internal',
}
if settings['backend'] not in BACKEND_UPSTREAMS:
settings['backend'] = 'none'
self._write_settings(settings)
self._write_caddyfile(settings)
self._write_site_html(settings)
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
if settings['backend'] == 'telemt' and enable_telemt_mask:
self._configure_telemt_mask()
return {
'status': 'success',
**settings,
'site_url': self._build_site_url(settings),
}
def save_server_config(self, protocol_type, config_content):
"""Save raw Caddyfile and hot-reload."""
path = f"{self.REMOTE_DIR}/Caddyfile"
self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), path)
self.ssh.run_sudo_command(
f"docker exec {self.CONTAINER_NAME} caddy reload --config /etc/caddy/Caddyfile 2>/dev/null "
f"|| docker restart {self.CONTAINER_NAME}"
)
def remove_container(self, protocol_type='revproxy'):
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.REMOTE_DIR}")
return True
def get_settings(self):
return self._read_settings()
def get_caddyfile(self):
out, _, code = self.ssh.run_sudo_command(f"cat {self.REMOTE_DIR}/Caddyfile 2>/dev/null")
if code != 0:
return ''
return out
# ===================== INTERNALS =====================
def _ensure_network(self):
script = """
docker network inspect amnezia-dns-net >/dev/null 2>&1 || \
docker network create --subnet=172.29.172.0/24 amnezia-dns-net
"""
self.ssh.run_sudo_script(script)
def _ensure_docker_compose(self):
out, _, code = self.ssh.run_command('docker compose version 2>/dev/null')
if code == 0 and out.strip():
return
self.ssh.run_sudo_command(
'apt-get update -y && apt-get install -y docker-compose-plugin 2>/dev/null || true',
timeout=120,
)
def _upload_bundle(self, settings):
local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_revproxy')
remote = self.REMOTE_DIR
self.ssh.run_sudo_command(f'mkdir -p {remote}/site')
self.ssh.run_sudo_command(f'chmod -R 755 {remote}')
with open(os.path.join(local_dir, 'docker-compose.yml'), 'r', encoding='utf-8') as f:
self.ssh.upload_file_sudo(f.read(), f'{remote}/docker-compose.yml')
self._write_settings(settings)
self._write_caddyfile(settings)
self._write_site_html(settings)
for fname in ('index.html', 'style.css'):
local_path = os.path.join(local_dir, 'site', fname)
if os.path.exists(local_path) and fname != 'index.html':
with open(local_path, 'r', encoding='utf-8') as f:
self.ssh.upload_file_sudo(f.read(), f'{remote}/site/{fname}')
def _write_settings(self, settings):
content = json.dumps(settings, indent=2, ensure_ascii=False)
self.ssh.upload_file_sudo(content, f'{self.REMOTE_DIR}/{self.SETTINGS_FILE}')
def _read_settings(self):
out, _, code = self.ssh.run_sudo_command(
f"cat {self.REMOTE_DIR}/{self.SETTINGS_FILE} 2>/dev/null"
)
if code != 0 or not out.strip():
return {}
try:
return json.loads(out)
except json.JSONDecodeError:
return {}
def _write_site_html(self, settings):
local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_revproxy', 'site')
with open(os.path.join(local_dir, 'index.html'), 'r', encoding='utf-8') as f:
html = f.read()
title = settings.get('site_title', 'CloudEdge')
domain = settings.get('domain') or self.ssh.host
html = html.replace('{{SITE_TITLE}}', self._escape_html(title))
html = html.replace('{{SITE_DOMAIN}}', self._escape_html(domain))
self.ssh.upload_file_sudo(html, f'{self.REMOTE_DIR}/site/index.html')
style_path = os.path.join(local_dir, 'style.css')
if os.path.exists(style_path):
with open(style_path, 'r', encoding='utf-8') as f:
self.ssh.upload_file_sudo(f.read(), f'{self.REMOTE_DIR}/site/style.css')
def _write_caddyfile(self, settings):
caddyfile = self._build_caddyfile(settings)
self.ssh.upload_file_sudo(caddyfile, f'{self.REMOTE_DIR}/Caddyfile')
def _build_caddyfile(self, settings):
domain = settings.get('domain', '')
site_title = settings.get('site_title', 'CloudEdge')
backend = settings.get('backend', 'none')
backend_port = int(settings.get('backend_port', DEFAULT_BACKEND_PORT))
vpn_path = self._normalize_path(settings.get('vpn_path', DEFAULT_VPN_PATH))
tls_email = settings.get('tls_email', '')
site_address = domain if domain else ':443'
global_block = '{\n\tadmin off\n\tservers {\n\t\tprotocols h1 h2 h3\n\t}\n'
if tls_email and domain:
global_block += f'\temail {tls_email}\n'
global_block += '}\n\n'
tls_line = ''
if not domain:
tls_line = '\n\ttls internal'
backend_block = ''
if backend != 'none' and BACKEND_UPSTREAMS.get(backend):
upstream = f'{BACKEND_UPSTREAMS[backend]}:{backend_port}'
backend_block = f"""
\t@vpn path {vpn_path}*
\thandle @vpn {{
\t\treverse_proxy {upstream} {{
\t\t\theader_up Host {{host}}
\t\t\theader_up X-Real-IP {{remote_host}}
\t\t\theader_up X-Forwarded-For {{remote_host}}
\t\t\theader_up X-Forwarded-Proto {{scheme}}
\t\t\ttransport http {{
\t\t\t\tkeepalive 30s
\t\t\t\tkeepalive_idle_conns 100
\t\t\t}}
\t\t}}
\t}}
"""
return f"""{global_block}{site_address} {{{tls_line}
\troot * /srv/site
\tencode gzip zstd
\theader {{
\t\tStrict-Transport-Security "max-age=31536000; includeSubDomains; preload"
\t\tX-Content-Type-Options nosniff
\t\tX-Frame-Options SAMEORIGIN
\t\tReferrer-Policy strict-origin-when-cross-origin
\t\t-Server
\t}}
{backend_block}
\thandle {{
\t\ttry_files {{path}} {{path}}/ /index.html
\t\tfile_server
\t}}
}}
:80 {{
\tredir https://{{host}}{{uri}} permanent
}}
"""
def _configure_telemt_mask(self):
"""Point Telemt mask at the Caddy container on the Docker network."""
config_path = '/opt/amnezia/telemt/config.toml'
out, _, code = self.ssh.run_sudo_command(f"test -f {config_path} && cat {config_path}")
if code != 0:
return
config = out
config = re.sub(r'mask\s*=\s*(true|false)', 'mask = true', config)
if 'mask_host' in config:
config = re.sub(r'#?\s*mask_host\s*=\s*".*?"', 'mask_host = "amnezia-revproxy"', config)
else:
config = config.replace('[censorship]', '[censorship]\nmask_host = "amnezia-revproxy"')
if 'mask_port' in config:
config = re.sub(r'#?\s*mask_port\s*=\s*\d+', 'mask_port = 443', config)
else:
config = config.replace('mask_host = "amnezia-revproxy"', 'mask_host = "amnezia-revproxy"\nmask_port = 443')
self.ssh.upload_file_sudo(config.replace('\r\n', '\n'), config_path)
self.ssh.run_sudo_command(
'docker network connect amnezia-dns-net telemt 2>/dev/null || true'
)
self.ssh.run_sudo_command(
'docker kill -s HUP telemt 2>/dev/null || docker restart telemt 2>/dev/null || true'
)
def _build_site_url(self, settings):
domain = settings.get('domain', '')
if domain:
return f'https://{domain}'
return f'https://{self.ssh.host}'
@staticmethod
def _normalize_path(path):
path = (path or DEFAULT_VPN_PATH).strip()
if not path.startswith('/'):
path = '/' + path
return path.rstrip('/') or '/'
@staticmethod
def _escape_html(text):
return (
str(text)
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
)
+208
View File
@@ -0,0 +1,208 @@
"""
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 = '/usr/local/3proxy/conf/3proxy.cfg'
DEFAULT_PORT = 38080
DEFAULT_USERNAME = 'proxy_user'
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='socks5'):
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 get_server_status(self, protocol_type='socks5'):
exists = self.check_protocol_installed()
running = self.check_container_running()
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,
}
# ===================== 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 /usr/local/3proxy/logs/3proxy.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.
if self.check_protocol_installed():
self.remove_container()
config_text = self._build_config(username, password, port)
self.ssh.run_sudo_command(f"mkdir -p {self.CONFIG_DIR}")
self.ssh.upload_file_sudo(config_text, f"{self.CONFIG_DIR}/3proxy.cfg")
# Bind-mount our config in place of the image's default. 3proxy reads
# /usr/local/3proxy/conf/3proxy.cfg by convention.
run_cmd = (
f"docker run -d --restart always "
f"--name {self.CONTAINER_NAME} "
f"-p {port}:{port}/tcp "
f"-v {self.CONFIG_DIR}/3proxy.cfg:{self.CONFIG_PATH}:ro "
f"{self.IMAGE_NAME} {self.CONFIG_PATH}"
)
_, 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': 'socks5',
'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():
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(
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} || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"rm -rf {self.CONFIG_DIR}")
return True
+227
View File
@@ -0,0 +1,227 @@
"""
SSH Manager - manages SSH connections to VPN servers.
Replicates the ServerController logic from the AmneziaVPN client.
"""
import paramiko
import io
import time
import logging
logger = logging.getLogger(__name__)
class SSHManager:
"""Manages SSH connections and command execution on remote servers."""
def __init__(self, host, port, username, password=None, private_key=None):
self.host = host
self.port = int(port)
self.username = username
self.password = password
self.private_key = private_key
self.client = None
self._is_root = (username == 'root')
def connect(self):
"""Establish SSH connection to the server."""
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
kwargs = {
'hostname': self.host,
'port': self.port,
'username': self.username,
'timeout': 15,
'allow_agent': False,
'look_for_keys': False,
}
if self.private_key:
key_file = io.StringIO(self.private_key)
try:
pkey = paramiko.RSAKey.from_private_key(key_file)
except paramiko.ssh_exception.SSHException:
key_file.seek(0)
try:
pkey = paramiko.Ed25519Key.from_private_key(key_file)
except paramiko.ssh_exception.SSHException:
key_file.seek(0)
pkey = paramiko.ECDSAKey.from_private_key(key_file)
kwargs['pkey'] = pkey
elif self.password:
kwargs['password'] = self.password
self.client.connect(**kwargs)
return True
def disconnect(self):
"""Close SSH connection."""
if self.client:
self.client.close()
self.client = None
def run_command(self, command, timeout=60):
"""Execute command on remote server."""
if not self.client:
raise ConnectionError("Not connected to server")
logger.info(f"Running command: {command[:100]}...")
stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout)
# Crucial: set timeout on the channel to prevent hanging indefinitely
stdout.channel.settimeout(timeout)
stderr.channel.settimeout(timeout)
try:
exit_code = stdout.channel.recv_exit_status()
out = stdout.read().decode('utf-8', errors='replace').strip()
err = stderr.read().decode('utf-8', errors='replace').strip()
except Exception as e:
logger.error(f"Command timed out or failed to read: {e}")
out, err, exit_code = "", str(e), -1
if exit_code != 0:
logger.warning(f"Command exited with code {exit_code}: {err}")
return out, err, exit_code
def _sudo_prefix(self):
"""Get the sudo command prefix with password handling."""
if self._is_root:
return ''
if self.password:
# Use sudo -S to read password from stdin
escaped_pass = self.password.replace("'", "'\\''")
return f"echo '{escaped_pass}' | sudo -S "
return 'sudo '
def run_sudo_command(self, command, timeout=60):
"""
Execute command with sudo, automatically handling password.
Strips 'sudo ' from the beginning of command if present,
and re-adds it with password piping.
"""
# Remove existing sudo prefix if present
clean_cmd = command
if clean_cmd.strip().startswith('sudo '):
clean_cmd = clean_cmd.strip()[5:]
if self._is_root:
return self.run_command(clean_cmd, timeout=timeout)
if self.password:
escaped_pass = self.password.replace("'", "'\\''")
# Pipe password directly to sudo -S, preserving original command quoting
# 2>/dev/null on echo suppresses '[sudo] password for...' prompt noise
full_cmd = f"echo '{escaped_pass}' | sudo -S -p '' {clean_cmd}"
else:
full_cmd = f"sudo {clean_cmd}"
return self.run_command(full_cmd, timeout=timeout)
def run_sudo_script(self, script, timeout=120):
"""
Execute a multi-line script with sudo/root privileges.
Writes script to /tmp via SFTP, then runs with sudo bash.
"""
if self._is_root:
return self.run_script(script, timeout=timeout)
# Write script to temp file via SFTP (avoids heredoc/pipe conflicts)
import hashlib
script_hash = hashlib.md5(script.encode()).hexdigest()[:8]
tmp_script = f"/tmp/_amnz_script_{script_hash}.sh"
self.upload_file(script, tmp_script)
# Run with sudo
if self.password:
escaped_pass = self.password.replace("'", "'\\''")
full_cmd = f"echo '{escaped_pass}' | sudo -S -p '' bash {tmp_script}; rm -f {tmp_script}"
else:
full_cmd = f"sudo bash {tmp_script}; rm -f {tmp_script}"
return self.run_command(full_cmd, timeout=timeout)
def run_script(self, script, timeout=120):
"""Execute a multi-line script on remote server."""
return self.run_command(script, timeout=timeout)
def upload_file(self, content, remote_path):
"""Upload text content to a remote file via SFTP."""
if not self.client:
raise ConnectionError("Not connected to server")
# Normalize line endings (Windows CRLF -> Unix LF)
content = content.replace('\r\n', '\n')
sftp = self.client.open_sftp()
try:
with sftp.file(remote_path, 'w') as f:
f.write(content)
finally:
sftp.close()
def upload_file_sudo(self, content, remote_path):
"""
Upload text content to a remote file that requires root access.
Uses SFTP to write to /tmp, then sudo mv to the target path.
Also normalizes line endings to Unix-style (LF).
"""
if not self.client:
raise ConnectionError("Not connected to server")
# Normalize line endings (Windows CRLF -> Unix LF)
content = content.replace('\r\n', '\n')
# Write to temp file via SFTP (no sudo needed for /tmp)
import hashlib
tmp_name = f"/tmp/_amnz_{hashlib.md5(remote_path.encode()).hexdigest()[:8]}"
self.upload_file(content, tmp_name)
# Move to target with sudo
self.run_sudo_command(f"mv {tmp_name} {remote_path}")
self.run_sudo_command(f"chmod 644 {remote_path}")
return True
def download_file(self, remote_path):
"""Download text content from a remote file."""
if not self.client:
raise ConnectionError("Not connected to server")
sftp = self.client.open_sftp()
try:
with sftp.file(remote_path, 'r') as f:
return f.read().decode('utf-8', errors='replace')
finally:
sftp.close()
def file_exists(self, remote_path):
"""Check if a remote file exists."""
if not self.client:
raise ConnectionError("Not connected to server")
sftp = self.client.open_sftp()
try:
sftp.stat(remote_path)
return True
except FileNotFoundError:
return False
finally:
sftp.close()
def test_connection(self):
"""Test SSH connection and return server info."""
out, err, code = self.run_command("uname -sr && cat /etc/os-release 2>/dev/null | head -2")
return out
def write_file(self, remote_path, content):
"""Write content to a remote file with sudo."""
return self.upload_file_sudo(content, remote_path)
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.disconnect()
+532
View File
@@ -0,0 +1,532 @@
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):
self.ssh = ssh_manager
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 = "/opt/amnezia/telemt"
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)
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",
"host": "",
"port": port,
"log": results
}
def _get_server_config(self):
out, _, code = self.ssh.run_sudo_command(f"cat /opt/amnezia/telemt/config.toml")
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'), "/opt/amnezia/telemt/config.toml")
# 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("rm -rf /opt/amnezia/telemt")
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'), "/opt/amnezia/telemt/config.toml")
# 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'), "/opt/amnezia/telemt/config.toml")
# 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'), "/opt/amnezia/telemt/config.toml")
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'), "/opt/amnezia/telemt/config.toml")
if enable:
# If enabling, we re-add via API since it might have been deleted from memory
secret = ""
users = self._parse_users_from_config('\n'.join(new_lines))
secret = users.get(client_id, "")
if secret:
self._api_request("POST", "/v1/users", data={"username": client_id, "secret": secret})
else:
# If disabling, we just delete from memory
self._api_request("DELETE", f"/v1/users/{client_id}")
if restart:
self.ssh.run_sudo_command(f"docker kill -s HUP {self.CONTAINER_NAME} || docker restart {self.CONTAINER_NAME}")
def get_client_config(self, protocol_type, client_id, host='', port=''):
resp = self._api_request("GET", f"/v1/users/{client_id}")
if resp and resp.get('ok'):
user = resp.get('data', {})
links = user.get('links', {})
if links.get('tls'): return links['tls'][0]
if links.get('secure'): return links['secure'][0]
if links.get('classic'): return links['classic'][0]
clients = self.get_clients(protocol_type)
c = next((c for c in clients if c['clientId'] == client_id), None)
if c:
secret = c.get('userData', {}).get('token', '')
if secret: return f"tg://proxy?server={host}&port={port}&secret={secret}"
return "Not found"
+823
View File
@@ -0,0 +1,823 @@
"""
WireGuard Protocol Manager - handles standard WireGuard protocol
installation, configuration, and client management on remote servers.
Follows the same architecture as awg_manager.py, using:
- client/server_scripts/wireguard/ scripts as reference
- Docker container: amneziavpn/amnezia-wg (same as AWG Legacy)
- Standard wg/wg-quick tools
"""
import json
import re
import secrets
import logging
from base64 import b64encode
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives import serialization
logger = logging.getLogger(__name__)
WG_DEFAULTS = {
'port': '51820',
'mtu': '1420',
'subnet_address': '10.8.2.0',
'subnet_cidr': '24',
'subnet_ip': '10.8.2.1',
'dns1': '1.1.1.1',
'dns2': '1.0.0.1',
}
def generate_wg_keypair():
"""Generate a WireGuard X25519 keypair (private, public) as base64 strings."""
private_key = X25519PrivateKey.generate()
private_bytes = private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption()
)
public_bytes = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
return b64encode(private_bytes).decode(), b64encode(public_bytes).decode()
def generate_psk():
"""Generate a WireGuard preshared key."""
return b64encode(secrets.token_bytes(32)).decode()
class WireGuardManager:
"""Manages standard WireGuard protocol installation and client management."""
PROTOCOL = 'wireguard'
CONTAINER_NAME = 'amnezia-wireguard'
DOCKER_IMAGE = 'amneziavpn/amnezia-wg:latest'
CONFIG_PATH = '/opt/amnezia/wireguard/wg0.conf'
KEY_DIR = '/opt/amnezia/wireguard'
CLIENTS_TABLE_PATH = '/opt/amnezia/wireguard/clientsTable'
INTERFACE = 'wg0'
def __init__(self, ssh_manager):
self.ssh = ssh_manager
# ===================== INSTALLATION =====================
def check_docker_installed(self):
"""Check if Docker is installed and running."""
out, err, code = self.ssh.run_command("docker --version 2>/dev/null")
if code != 0:
return False
out2, _, code2 = self.ssh.run_command(
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
)
return 'active' in out2 or 'running' in out2.lower()
def install_docker(self):
"""Install Docker on the server."""
script = r"""
if which apt-get > /dev/null 2>&1; then pm=$(which apt-get); silent_inst="-yq install"; check_pkgs="-yq update"; docker_pkg="docker.io"; dist="debian";
elif which dnf > /dev/null 2>&1; then pm=$(which dnf); silent_inst="-yq install"; check_pkgs="-yq check-update"; docker_pkg="docker"; dist="fedora";
elif which yum > /dev/null 2>&1; then pm=$(which yum); silent_inst="-y -q install"; check_pkgs="-y -q check-update"; docker_pkg="docker"; dist="centos";
else echo "Packet manager not found"; exit 1; fi;
if [ "$dist" = "debian" ]; then export DEBIAN_FRONTEND=noninteractive; fi;
if ! command -v docker > /dev/null 2>&1; then
$pm $check_pkgs; $pm $silent_inst $docker_pkg;
sleep 5; systemctl enable --now docker; sleep 5;
fi;
if [ "$(systemctl is-active docker)" != "active" ]; then
$pm $check_pkgs; $pm $silent_inst $docker_pkg;
sleep 5; systemctl start docker; sleep 5;
fi;
docker --version
"""
out, err, code = self.ssh.run_sudo_script(script, timeout=180)
if code != 0:
raise RuntimeError(f"Failed to install Docker: {err}")
return out
def check_container_running(self):
"""Check if WireGuard container is running."""
out, _, code = self.ssh.run_sudo_command(
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def check_protocol_installed(self):
"""Check if protocol is installed (container exists)."""
out, _, code = self.ssh.run_sudo_command(
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
)
return self.CONTAINER_NAME in out.strip().split('\n')
def prepare_host(self):
"""Prepare host for container."""
dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}"
script = f"""
mkdir -p {dockerfile_folder}
mkdir -p {self.KEY_DIR}
if ! docker network ls | grep -q amnezia-dns-net; then
docker network create --driver bridge --subnet=172.29.172.0/24 --opt com.docker.network.bridge.name=amn0 amnezia-dns-net
fi
"""
out, err, code = self.ssh.run_sudo_script(script)
if code != 0:
logger.warning(f"prepare_host warning: {err}")
return True
def setup_firewall(self):
"""Setup host firewall."""
script = """
sysctl -w net.ipv4.ip_forward=1
iptables -C INPUT -p icmp --icmp-type echo-request -j DROP 2>/dev/null || iptables -A INPUT -p icmp --icmp-type echo-request -j DROP
iptables -C FORWARD -j DOCKER-USER 2>/dev/null || iptables -A FORWARD -j DOCKER-USER 2>/dev/null
"""
self.ssh.run_sudo_script(script)
return True
def install_protocol(self, port=None):
"""
Full installation of WireGuard protocol.
Steps: install docker -> prepare host -> build container ->
configure container -> run container -> setup firewall
"""
if port is None:
port = WG_DEFAULTS['port']
results = []
# Step 1: Install Docker
if not self.check_docker_installed():
results.append("Installing Docker...")
self.install_docker()
results.append("Docker installed successfully")
else:
results.append("Docker already installed")
# Step 2: Prepare host
results.append("Preparing host...")
self.prepare_host()
results.append("Host prepared")
# Step 3: Remove old container if exists
if self.check_protocol_installed():
results.append("Removing old container...")
self.remove_container()
results.append("Old container removed")
# Step 4: Build container
results.append("Building Docker image...")
dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}"
dockerfile_content = (
f"FROM {self.DOCKER_IMAGE}\n"
f"\n"
f'LABEL maintainer="AmneziaVPN"\n'
f"\n"
f"RUN apk add --no-cache curl wireguard-tools dumb-init iptables bash\n"
f"RUN apk --update upgrade --no-cache\n"
f"\n"
f"RUN mkdir -p /opt/amnezia\n"
f'RUN echo "#!/bin/bash" > /opt/amnezia/start.sh && '
f'echo "tail -f /dev/null" >> /opt/amnezia/start.sh\n'
f"RUN chmod a+x /opt/amnezia/start.sh\n"
f"\n"
f'ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]\n'
)
self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}")
self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile")
out, err, code = self.ssh.run_sudo_command(
f"docker build --no-cache --pull -t {self.CONTAINER_NAME} {dockerfile_folder}",
timeout=300
)
if code != 0:
raise RuntimeError(f"Failed to build container: {err}")
results.append("Docker image built successfully")
# Step 5: Run container
results.append("Starting container...")
run_cmd = f"""docker run -d \
--restart always \
--privileged \
--cap-add=NET_ADMIN \
--cap-add=SYS_MODULE \
-p {port}:{port}/udp \
-v /lib/modules:/lib/modules \
--sysctl="net.ipv4.conf.all.src_valid_mark=1" \
--name {self.CONTAINER_NAME} \
{self.CONTAINER_NAME}"""
out, err, code = self.ssh.run_sudo_command(run_cmd)
if code != 0:
raise RuntimeError(f"Failed to run container: {err}")
# Connect to DNS network
self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.CONTAINER_NAME}")
# Wait for container
results.append("Waiting for container to start...")
self._wait_container_running()
results.append("Container started")
# Step 6: Configure container
results.append("Configuring WireGuard...")
self._configure_container(port)
results.append("WireGuard configured")
# Step 7: Upload start script
results.append("Starting WireGuard service...")
self._upload_start_script(port)
results.append("WireGuard service started")
# Step 8: Setup firewall
results.append("Setting up firewall...")
self.setup_firewall()
results.append("Firewall configured")
return {
'status': 'success',
'protocol': self.PROTOCOL,
'port': port,
'log': results,
}
def _wait_container_running(self, timeout=30):
"""Wait for a container to be in 'running' state."""
import time
last_status = 'unknown'
for i in range(timeout // 2):
out, _, _ = self.ssh.run_sudo_command(
f"docker inspect --format='{{{{.State.Status}}}}' {self.CONTAINER_NAME}"
)
last_status = out.strip().strip("'\"")
if last_status == 'running':
time.sleep(1)
return True
time.sleep(2)
logs_out, _, _ = self.ssh.run_sudo_command(
f"docker logs --tail 50 {self.CONTAINER_NAME} 2>&1"
)
raise RuntimeError(
f"Container {self.CONTAINER_NAME} did not start within {timeout}s "
f"(status: {last_status}). Logs:\n{logs_out}"
)
def _configure_container(self, port):
"""Configure the WireGuard container (generate keys and server config)."""
subnet_ip = WG_DEFAULTS['subnet_ip']
subnet_cidr = WG_DEFAULTS['subnet_cidr']
config_script = f"""
mkdir -p {self.KEY_DIR}
cd {self.KEY_DIR}
WIREGUARD_SERVER_PRIVATE_KEY=$(wg genkey)
echo $WIREGUARD_SERVER_PRIVATE_KEY > {self.KEY_DIR}/wireguard_server_private_key.key
WIREGUARD_SERVER_PUBLIC_KEY=$(echo $WIREGUARD_SERVER_PRIVATE_KEY | wg pubkey)
echo $WIREGUARD_SERVER_PUBLIC_KEY > {self.KEY_DIR}/wireguard_server_public_key.key
WIREGUARD_PSK=$(wg genpsk)
echo $WIREGUARD_PSK > {self.KEY_DIR}/wireguard_psk.key
cat > {self.CONFIG_PATH} <<EOF
[Interface]
PrivateKey = $WIREGUARD_SERVER_PRIVATE_KEY
Address = {subnet_ip}/{subnet_cidr}
ListenPort = {port}
EOF
"""
out, err, code = self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} bash -c '{config_script}'"
)
if code != 0:
raise RuntimeError(f"Failed to configure container: {err}")
def _upload_start_script(self, port):
"""Upload and execute the start script inside the container."""
subnet_ip = WG_DEFAULTS['subnet_ip']
subnet_cidr = WG_DEFAULTS['subnet_cidr']
start_script = f"""#!/bin/bash
echo "WireGuard container startup"
# Kill existing wg-quick if running
wg-quick down {self.CONFIG_PATH} 2>/dev/null
# Start WireGuard
if [ -f {self.CONFIG_PATH} ]; then wg-quick up {self.CONFIG_PATH}; fi
# Allow traffic on the TUN interface
iptables -A INPUT -i {self.INTERFACE} -j ACCEPT
iptables -A FORWARD -i {self.INTERFACE} -j ACCEPT
iptables -A OUTPUT -o {self.INTERFACE} -j ACCEPT
iptables -A FORWARD -i {self.INTERFACE} -o eth0 -s {subnet_ip}/{subnet_cidr} -j ACCEPT
iptables -A FORWARD -i {self.INTERFACE} -o eth1 -s {subnet_ip}/{subnet_cidr} -j ACCEPT
iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -t nat -A POSTROUTING -s {subnet_ip}/{subnet_cidr} -o eth0 -j MASQUERADE
iptables -t nat -A POSTROUTING -s {subnet_ip}/{subnet_cidr} -o eth1 -j MASQUERADE
tail -f /dev/null
"""
self.ssh.upload_file(start_script, "/tmp/_wg_start.sh")
self.ssh.run_sudo_command(f"docker cp /tmp/_wg_start.sh {self.CONTAINER_NAME}:/opt/amnezia/start.sh")
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} chmod +x /opt/amnezia/start.sh")
self.ssh.run_command("rm -f /tmp/_wg_start.sh")
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
import time
time.sleep(5)
def remove_container(self):
"""Remove WireGuard container."""
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME}")
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME}")
self.ssh.run_sudo_command(f"docker rmi {self.CONTAINER_NAME}")
return True
# ===================== CLIENT MANAGEMENT =====================
def _get_clients_table(self):
"""Get the clients table from the server."""
out, err, code = self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} cat {self.CLIENTS_TABLE_PATH} 2>/dev/null"
)
if code != 0 or not out.strip():
return []
try:
data = json.loads(out)
if isinstance(data, list):
return data
return []
except json.JSONDecodeError:
return []
def _save_clients_table(self, clients_table):
"""Save the clients table to the server."""
content = json.dumps(clients_table, indent=2)
self.ssh.upload_file(content, "/tmp/_wg_clients.json")
self.ssh.run_sudo_command(
f"docker cp /tmp/_wg_clients.json {self.CONTAINER_NAME}:{self.CLIENTS_TABLE_PATH}"
)
self.ssh.run_command("rm -f /tmp/_wg_clients.json")
def _get_server_config(self):
"""Get the server WireGuard config."""
out, err, code = self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} cat {self.CONFIG_PATH}"
)
if code != 0:
raise RuntimeError(f"Failed to get server config: {err}")
return out
def save_server_config(self, config_content):
"""Save the server WireGuard config and restart container."""
self.ssh.upload_file(config_content.replace('\r\n', '\n'), "/tmp/_wg_edit_config.conf")
self.ssh.run_sudo_command(f"docker cp /tmp/_wg_edit_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}")
self.ssh.run_command("rm -f /tmp/_wg_edit_config.conf")
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
def _get_server_public_key(self):
"""Get server public key."""
out, err, code = self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} cat {self.KEY_DIR}/wireguard_server_public_key.key"
)
if code != 0:
raise RuntimeError(f"Failed to get server public key: {err}")
return out.strip()
def _get_server_psk(self):
"""Get server preshared key."""
out, err, code = self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} cat {self.KEY_DIR}/wireguard_psk.key"
)
if code != 0:
raise RuntimeError(f"Failed to get PSK: {err}")
return out.strip()
def _get_listen_port(self):
"""Extract ListenPort from server config."""
config = self._get_server_config()
for line in config.split('\n'):
if line.strip().startswith('ListenPort'):
return line.split('=', 1)[1].strip()
return WG_DEFAULTS['port']
def _get_used_ips(self):
"""Get list of IPs already assigned in the config."""
config = self._get_server_config()
ips = []
for line in config.split('\n'):
line = line.strip()
if line.startswith('AllowedIPs'):
match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line)
if match:
ips.append(match.group(1))
elif line.startswith('Address'):
match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line)
if match:
ips.append(match.group(1))
return ips
def _get_next_ip(self):
"""Calculate the next available IP for a new client."""
used_ips = self._get_used_ips()
if not used_ips:
base = WG_DEFAULTS['subnet_address']
parts = base.split('.')
parts[3] = '2'
return '.'.join(parts)
last_ip = used_ips[-1]
parts = last_ip.split('.')
last_octet = int(parts[3])
next_octet = last_octet + 1
if next_octet > 254:
next_octet = 2
parts[3] = str(next_octet)
return '.'.join(parts)
def _parse_peers_from_config(self):
"""Parse [Peer] sections from WireGuard server config."""
try:
config = self._get_server_config()
except Exception:
return {}
peers = {}
current_key = None
for line in config.split('\n'):
line = line.strip()
if line == '[Peer]':
current_key = None
elif current_key is None and line.startswith('PublicKey'):
current_key = line.split('=', 1)[1].strip()
peers[current_key] = {'allowedIps': ''}
elif current_key and line.startswith('AllowedIPs'):
peers[current_key]['allowedIps'] = line.split('=', 1)[1].strip()
return peers
def _parse_bytes(self, size_str):
"""Parse human readable size string into bytes."""
try:
parts = size_str.strip().split()
if len(parts) != 2:
return 0
val, unit = float(parts[0]), parts[1]
units = {'B': 1, 'KiB': 1024, 'MiB': 1024**2, 'GiB': 1024**3, 'TiB': 1024**4}
return int(val * units.get(unit, 1))
except Exception:
return 0
def _wg_show(self):
"""Run 'wg show all' and parse output."""
out, err, code = self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg show all'"
)
if code != 0 or not out.strip():
return {}
result = {}
current_peer = None
for line in out.split('\n'):
line = line.strip()
if line.startswith('peer:'):
current_peer = line.split(':', 1)[1].strip()
result[current_peer] = {}
elif current_peer and ':' in line:
key, value = line.split(':', 1)
key = key.strip()
value = value.strip()
if key == 'latest handshake':
result[current_peer]['latestHandshake'] = value
elif key == 'transfer':
parts = value.split(',')
if len(parts) == 2:
received = parts[0].strip().replace(' received', '')
sent = parts[1].strip().replace(' sent', '')
result[current_peer]['dataReceived'] = received
result[current_peer]['dataSent'] = sent
result[current_peer]['dataReceivedBytes'] = self._parse_bytes(received)
result[current_peer]['dataSentBytes'] = self._parse_bytes(sent)
elif key == 'allowed ips':
result[current_peer]['allowedIps'] = value
return result
def get_clients(self):
"""Get list of all clients with live traffic data."""
clients_table = self._get_clients_table()
try:
wg_show_data = self._wg_show()
except Exception:
wg_show_data = {}
known_ids = set()
for client in clients_table:
client_id = client.get('clientId', '')
known_ids.add(client_id)
if client_id in wg_show_data:
show_data = wg_show_data[client_id]
user_data = client.get('userData', {})
user_data['latestHandshake'] = show_data.get('latestHandshake', '')
user_data['dataReceived'] = show_data.get('dataReceived', '')
user_data['dataSent'] = show_data.get('dataSent', '')
user_data['dataReceivedBytes'] = show_data.get('dataReceivedBytes', 0)
user_data['dataSentBytes'] = show_data.get('dataSentBytes', 0)
user_data['allowedIps'] = show_data.get('allowedIps', '')
client['userData'] = user_data
# Pick up peers from conf not in clientsTable (native app clients)
try:
conf_peers = self._parse_peers_from_config()
for pub_key, peer_info in conf_peers.items():
if pub_key in known_ids:
continue
show_data = wg_show_data.get(pub_key, {})
allowed_ip = peer_info.get('allowedIps', '') or show_data.get('allowedIps', '')
ip_part = ''
if allowed_ip:
m = re.search(r'(\d+\.\d+\.\d+\.\d+)', allowed_ip)
if m:
ip_part = m.group(1)
display_name = f'External ({ip_part})' if ip_part else 'External (native app)'
clients_table.append({
'clientId': pub_key,
'userData': {
'clientName': display_name,
'clientPrivateKey': '',
'externalClient': True,
'clientIp': ip_part,
'latestHandshake': show_data.get('latestHandshake', ''),
'dataReceived': show_data.get('dataReceived', ''),
'dataSent': show_data.get('dataSent', ''),
'dataReceivedBytes': show_data.get('dataReceivedBytes', 0),
'dataSentBytes': show_data.get('dataSentBytes', 0),
'allowedIps': allowed_ip,
}
})
except Exception as e:
logger.warning(f'get_clients: failed to parse conf peers: {e}')
return clients_table
def add_client(self, client_name, server_host):
"""
Add a new client/peer to the WireGuard config.
Returns the client config string.
"""
# Generate client keys
client_priv_key, client_pub_key = generate_wg_keypair()
# Get server info
server_pub_key = self._get_server_public_key()
psk = self._get_server_psk()
port = self._get_listen_port()
# Get next available IP
client_ip = self._get_next_ip()
dns1 = WG_DEFAULTS['dns1']
dns2 = WG_DEFAULTS['dns2']
# Check if AmneziaDNS is installed
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
if 'amnezia-dns' in out:
dns1 = '172.29.172.254'
mtu = WG_DEFAULTS['mtu']
# Append peer to server config
peer_section = f"""
[Peer]
PublicKey = {client_pub_key}
PresharedKey = {psk}
AllowedIPs = {client_ip}/32
"""
escaped_peer = peer_section.replace("'", "'\\''")
self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} bash -c 'echo \"{escaped_peer}\" >> {self.CONFIG_PATH}'"
)
# Sync config without restart
self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'"
)
# Update clients table
clients_table = self._get_clients_table()
new_client = {
'clientId': client_pub_key,
'userData': {
'clientName': client_name,
'creationDate': __import__('datetime').datetime.now().isoformat(),
'clientPrivateKey': client_priv_key,
'clientIp': client_ip,
'psk': psk,
'enabled': True,
}
}
clients_table.append(new_client)
self._save_clients_table(clients_table)
# Build client config
client_config = f"""[Interface]
Address = {client_ip}/32
DNS = {dns1}, {dns2}
PrivateKey = {client_priv_key}
MTU = {mtu}
[Peer]
PublicKey = {server_pub_key}
PresharedKey = {psk}
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = {server_host}:{port}
PersistentKeepalive = 25
"""
return {
'client_name': client_name,
'client_id': client_pub_key,
'client_ip': client_ip,
'config': client_config,
}
def get_client_config(self, client_id, server_host):
"""Reconstruct client config from stored data."""
clients_table = self._get_clients_table()
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
if not client:
raise RuntimeError(f"Client {client_id} not found")
ud = client.get('userData', {})
client_priv_key = ud.get('clientPrivateKey', '')
client_ip = ud.get('clientIp', '')
psk = ud.get('psk', '')
if not client_priv_key:
raise RuntimeError("Client private key not stored. Config cannot be reconstructed.")
server_pub_key = self._get_server_public_key()
if not psk:
psk = self._get_server_psk()
port = self._get_listen_port()
dns1 = WG_DEFAULTS['dns1']
dns2 = WG_DEFAULTS['dns2']
# Check if AmneziaDNS is installed
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
if 'amnezia-dns' in out:
dns1 = '172.29.172.254'
mtu = WG_DEFAULTS['mtu']
config = f"""[Interface]
Address = {client_ip}/32
DNS = {dns1}, {dns2}
PrivateKey = {client_priv_key}
MTU = {mtu}
[Peer]
PublicKey = {server_pub_key}
PresharedKey = {psk}
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = {server_host}:{port}
PersistentKeepalive = 25
"""
return config
def toggle_client(self, client_id, enable):
"""Enable or disable a client by adding/removing their [Peer] from server config."""
if enable:
clients_table = self._get_clients_table()
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
if not client:
raise RuntimeError(f"Client {client_id} not found")
ud = client.get('userData', {})
psk = ud.get('psk', '') or self._get_server_psk()
client_ip = ud.get('clientIp', '')
peer_section = f"""
[Peer]
PublicKey = {client_id}
PresharedKey = {psk}
AllowedIPs = {client_ip}/32
"""
escaped_peer = peer_section.replace("'", "'\\''")
self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} bash -c 'echo \"{escaped_peer}\" >> {self.CONFIG_PATH}'"
)
else:
# Remove peer from server config
config = self._get_server_config()
sections = config.split('[')
new_sections = [s for s in sections if s.strip() and client_id not in s]
new_config = '[' + '['.join(new_sections)
self.ssh.upload_file(new_config, "/tmp/_wg_config.conf")
self.ssh.run_sudo_command(
f"docker cp /tmp/_wg_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}"
)
self.ssh.run_command("rm -f /tmp/_wg_config.conf")
# Sync config
self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'"
)
# Update enabled status in clients table
clients_table = self._get_clients_table()
for c in clients_table:
if c.get('clientId') == client_id:
c.setdefault('userData', {})['enabled'] = enable
break
self._save_clients_table(clients_table)
def remove_client(self, client_id):
"""Remove a client from WireGuard config."""
config = self._get_server_config()
sections = config.split('[')
new_sections = [s for s in sections if s.strip() and client_id not in s]
new_config = '[' + '['.join(new_sections)
self.ssh.upload_file(new_config, "/tmp/_wg_config.conf")
self.ssh.run_sudo_command(
f"docker cp /tmp/_wg_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}"
)
self.ssh.run_command("rm -f /tmp/_wg_config.conf")
# Sync config
self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'"
)
# Update clients table
clients_table = self._get_clients_table()
clients_table = [c for c in clients_table if c.get('clientId') != client_id]
self._save_clients_table(clients_table)
return True
def get_server_status(self):
"""Get detailed status of the WireGuard server."""
info = {
'container_exists': self.check_protocol_installed(),
'container_running': False,
'protocol': self.PROTOCOL,
}
if info['container_exists']:
info['container_running'] = self.check_container_running()
if info['container_running']:
try:
config = self._get_server_config()
for line in config.split('\n'):
if 'ListenPort' in line:
info['port'] = line.split('=')[1].strip()
break
info['clients_count'] = len(self._get_clients_table())
except Exception as e:
info['error'] = str(e)
return info
def get_traffic_stats(self):
"""Get aggregate traffic stats for all clients."""
try:
wg_data = self._wg_show()
except Exception:
return {}
total_rx = 0
total_tx = 0
active_peers = 0
active_ips = []
for peer_key, data in wg_data.items():
rx = data.get('dataReceivedBytes', 0)
tx = data.get('dataSentBytes', 0)
total_rx += rx
total_tx += tx
if rx > 0 or tx > 0:
active_peers += 1
allowed = data.get('allowedIps', '')
if allowed:
m = re.search(r'(\d+\.\d+\.\d+\.\d+)', allowed)
if m:
active_ips.append(m.group(1))
return {
'total_rx_bytes': total_rx,
'total_tx_bytes': total_tx,
'active_connections': active_peers,
'active_ips': active_ips,
}
+774
View File
@@ -0,0 +1,774 @@
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):
self.ssh = ssh_manager
def _config_dir(self):
return '/opt/amnezia/xray'
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...")
dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}"
dockerfile_content = """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 /opt/amnezia/xray
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 /opt/amnezia/xray/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("mkdir -p /opt/amnezia/xray")
self.ssh.upload_file_sudo(json.dumps(server_json, indent=2), "/opt/amnezia/xray/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 /opt/amnezia/xray:/opt/amnezia/xray \\
--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': 'xray', '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}")
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