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:
@@ -0,0 +1,7 @@
|
||||
"""Protocol/service/SSH managers used by the web panel.
|
||||
|
||||
Modules in this package are imported either directly (`from managers.ssh_manager import SSHManager`)
|
||||
or lazily by name through `app.get_protocol_manager`. Keeping them in a dedicated package
|
||||
makes the project root easier to scan and prevents accidental name collisions with the
|
||||
generic stdlib (e.g. `socks5_manager`, `dns_manager`).
|
||||
"""
|
||||
@@ -0,0 +1,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
@@ -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
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DNSManager:
|
||||
def __init__(self, ssh):
|
||||
self.ssh = ssh
|
||||
|
||||
def install_protocol(self, protocol_type='dns', port='53'):
|
||||
"""Install AmneziaDNS service."""
|
||||
try:
|
||||
# 1. Check if docker is installed
|
||||
out, _, _ = self.ssh.run_command("docker --version")
|
||||
if "docker version" not in out.lower():
|
||||
return {"status": "error", "message": "Docker not installed"}
|
||||
|
||||
# 2. Prepare directory
|
||||
self.ssh.run_sudo_command("mkdir -p /opt/amnezia/dns")
|
||||
|
||||
# 3. Create Dockerfile matching official Amnezia implementation
|
||||
# We use Unbound (mvance/unbound) with DNS-over-TLS to Cloudflare
|
||||
forward_config = """forward-zone:
|
||||
name: "."
|
||||
forward-tls-upstream: yes
|
||||
forward-addr: 1.1.1.1@853
|
||||
forward-addr: 1.0.0.1@853
|
||||
"""
|
||||
self.ssh.write_file("/opt/amnezia/dns/forward-records.conf", forward_config)
|
||||
|
||||
dockerfile = """
|
||||
FROM mvance/unbound:latest
|
||||
LABEL maintainer="AmneziaVPN"
|
||||
COPY forward-records.conf /opt/unbound/etc/unbound/forward-records.conf
|
||||
"""
|
||||
self.ssh.write_file("/opt/amnezia/dns/Dockerfile", dockerfile)
|
||||
|
||||
# 4. Build and run
|
||||
self.ssh.run_sudo_command("docker build -t amnezia-dns /opt/amnezia/dns")
|
||||
self.ssh.run_sudo_command("docker stop amnezia-dns || true")
|
||||
self.ssh.run_sudo_command("docker rm amnezia-dns || true")
|
||||
|
||||
# Create internal network for DNS (like original Amnezia client)
|
||||
self.ssh.run_sudo_command("docker network ls | grep -q amnezia-dns-net || docker network create --subnet 172.29.172.0/24 amnezia-dns-net")
|
||||
|
||||
# Use internal network with static IP. Do not expose 53 on host to avoid systemd-resolved conflict.
|
||||
cmd = "docker run -d --name amnezia-dns --restart always --network amnezia-dns-net --ip=172.29.172.254 amnezia-dns"
|
||||
self.ssh.run_sudo_command(cmd)
|
||||
|
||||
# Connect existing VPN containers to the DNS network
|
||||
vpn_containers = ['amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'telemt']
|
||||
for c in vpn_containers:
|
||||
self.ssh.run_sudo_command(f"docker ps | grep -q {c} && docker network connect amnezia-dns-net {c} || true")
|
||||
|
||||
return {"status": "success", "message": "AmneziaDNS installed successfully"}
|
||||
except Exception as e:
|
||||
logger.exception("Error installing DNS")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def get_server_status(self, protocol_type='dns'):
|
||||
"""Check if AmneziaDNS container is running."""
|
||||
try:
|
||||
out, _, _ = self.ssh.run_sudo_command("docker ps --filter name=^amnezia-dns$ --format '{{.Status}}'")
|
||||
is_running = 'Up' in out
|
||||
|
||||
out_exists, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
|
||||
container_exists = 'amnezia-dns' in out_exists.strip().split('\n')
|
||||
|
||||
return {
|
||||
"container_exists": container_exists,
|
||||
"container_running": is_running,
|
||||
"port": "53",
|
||||
"protocol": protocol_type
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def remove_container(self, protocol_type='dns'):
|
||||
"""Remove AmneziaDNS container."""
|
||||
self.ssh.run_sudo_command("docker stop amnezia-dns || true")
|
||||
self.ssh.run_sudo_command("docker rm amnezia-dns || true")
|
||||
self.ssh.run_sudo_command("rm -rf /opt/amnezia/dns")
|
||||
@@ -0,0 +1,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('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
SSH Manager - manages SSH connections to VPN servers.
|
||||
Replicates the ServerController logic from the AmneziaVPN client.
|
||||
"""
|
||||
|
||||
import paramiko
|
||||
import io
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SSHManager:
|
||||
"""Manages SSH connections and command execution on remote servers."""
|
||||
|
||||
def __init__(self, host, port, username, password=None, private_key=None):
|
||||
self.host = host
|
||||
self.port = int(port)
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.private_key = private_key
|
||||
self.client = None
|
||||
self._is_root = (username == 'root')
|
||||
|
||||
def connect(self):
|
||||
"""Establish SSH connection to the server."""
|
||||
self.client = paramiko.SSHClient()
|
||||
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
kwargs = {
|
||||
'hostname': self.host,
|
||||
'port': self.port,
|
||||
'username': self.username,
|
||||
'timeout': 15,
|
||||
'allow_agent': False,
|
||||
'look_for_keys': False,
|
||||
}
|
||||
|
||||
if self.private_key:
|
||||
key_file = io.StringIO(self.private_key)
|
||||
try:
|
||||
pkey = paramiko.RSAKey.from_private_key(key_file)
|
||||
except paramiko.ssh_exception.SSHException:
|
||||
key_file.seek(0)
|
||||
try:
|
||||
pkey = paramiko.Ed25519Key.from_private_key(key_file)
|
||||
except paramiko.ssh_exception.SSHException:
|
||||
key_file.seek(0)
|
||||
pkey = paramiko.ECDSAKey.from_private_key(key_file)
|
||||
kwargs['pkey'] = pkey
|
||||
elif self.password:
|
||||
kwargs['password'] = self.password
|
||||
|
||||
self.client.connect(**kwargs)
|
||||
return True
|
||||
|
||||
def disconnect(self):
|
||||
"""Close SSH connection."""
|
||||
if self.client:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
def run_command(self, command, timeout=60):
|
||||
"""Execute command on remote server."""
|
||||
if not self.client:
|
||||
raise ConnectionError("Not connected to server")
|
||||
|
||||
logger.info(f"Running command: {command[:100]}...")
|
||||
stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout)
|
||||
|
||||
# Crucial: set timeout on the channel to prevent hanging indefinitely
|
||||
stdout.channel.settimeout(timeout)
|
||||
stderr.channel.settimeout(timeout)
|
||||
|
||||
try:
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out = stdout.read().decode('utf-8', errors='replace').strip()
|
||||
err = stderr.read().decode('utf-8', errors='replace').strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Command timed out or failed to read: {e}")
|
||||
out, err, exit_code = "", str(e), -1
|
||||
|
||||
if exit_code != 0:
|
||||
logger.warning(f"Command exited with code {exit_code}: {err}")
|
||||
|
||||
return out, err, exit_code
|
||||
|
||||
def _sudo_prefix(self):
|
||||
"""Get the sudo command prefix with password handling."""
|
||||
if self._is_root:
|
||||
return ''
|
||||
if self.password:
|
||||
# Use sudo -S to read password from stdin
|
||||
escaped_pass = self.password.replace("'", "'\\''")
|
||||
return f"echo '{escaped_pass}' | sudo -S "
|
||||
return 'sudo '
|
||||
|
||||
def run_sudo_command(self, command, timeout=60):
|
||||
"""
|
||||
Execute command with sudo, automatically handling password.
|
||||
Strips 'sudo ' from the beginning of command if present,
|
||||
and re-adds it with password piping.
|
||||
"""
|
||||
# Remove existing sudo prefix if present
|
||||
clean_cmd = command
|
||||
if clean_cmd.strip().startswith('sudo '):
|
||||
clean_cmd = clean_cmd.strip()[5:]
|
||||
|
||||
if self._is_root:
|
||||
return self.run_command(clean_cmd, timeout=timeout)
|
||||
|
||||
if self.password:
|
||||
escaped_pass = self.password.replace("'", "'\\''")
|
||||
# Pipe password directly to sudo -S, preserving original command quoting
|
||||
# 2>/dev/null on echo suppresses '[sudo] password for...' prompt noise
|
||||
full_cmd = f"echo '{escaped_pass}' | sudo -S -p '' {clean_cmd}"
|
||||
else:
|
||||
full_cmd = f"sudo {clean_cmd}"
|
||||
|
||||
return self.run_command(full_cmd, timeout=timeout)
|
||||
|
||||
def run_sudo_script(self, script, timeout=120):
|
||||
"""
|
||||
Execute a multi-line script with sudo/root privileges.
|
||||
Writes script to /tmp via SFTP, then runs with sudo bash.
|
||||
"""
|
||||
if self._is_root:
|
||||
return self.run_script(script, timeout=timeout)
|
||||
|
||||
# Write script to temp file via SFTP (avoids heredoc/pipe conflicts)
|
||||
import hashlib
|
||||
script_hash = hashlib.md5(script.encode()).hexdigest()[:8]
|
||||
tmp_script = f"/tmp/_amnz_script_{script_hash}.sh"
|
||||
self.upload_file(script, tmp_script)
|
||||
|
||||
# Run with sudo
|
||||
if self.password:
|
||||
escaped_pass = self.password.replace("'", "'\\''")
|
||||
full_cmd = f"echo '{escaped_pass}' | sudo -S -p '' bash {tmp_script}; rm -f {tmp_script}"
|
||||
else:
|
||||
full_cmd = f"sudo bash {tmp_script}; rm -f {tmp_script}"
|
||||
|
||||
return self.run_command(full_cmd, timeout=timeout)
|
||||
|
||||
def run_script(self, script, timeout=120):
|
||||
"""Execute a multi-line script on remote server."""
|
||||
return self.run_command(script, timeout=timeout)
|
||||
|
||||
def upload_file(self, content, remote_path):
|
||||
"""Upload text content to a remote file via SFTP."""
|
||||
if not self.client:
|
||||
raise ConnectionError("Not connected to server")
|
||||
|
||||
# Normalize line endings (Windows CRLF -> Unix LF)
|
||||
content = content.replace('\r\n', '\n')
|
||||
|
||||
sftp = self.client.open_sftp()
|
||||
try:
|
||||
with sftp.file(remote_path, 'w') as f:
|
||||
f.write(content)
|
||||
finally:
|
||||
sftp.close()
|
||||
|
||||
def upload_file_sudo(self, content, remote_path):
|
||||
"""
|
||||
Upload text content to a remote file that requires root access.
|
||||
Uses SFTP to write to /tmp, then sudo mv to the target path.
|
||||
Also normalizes line endings to Unix-style (LF).
|
||||
"""
|
||||
if not self.client:
|
||||
raise ConnectionError("Not connected to server")
|
||||
|
||||
# Normalize line endings (Windows CRLF -> Unix LF)
|
||||
content = content.replace('\r\n', '\n')
|
||||
|
||||
# Write to temp file via SFTP (no sudo needed for /tmp)
|
||||
import hashlib
|
||||
tmp_name = f"/tmp/_amnz_{hashlib.md5(remote_path.encode()).hexdigest()[:8]}"
|
||||
self.upload_file(content, tmp_name)
|
||||
|
||||
# Move to target with sudo
|
||||
self.run_sudo_command(f"mv {tmp_name} {remote_path}")
|
||||
self.run_sudo_command(f"chmod 644 {remote_path}")
|
||||
return True
|
||||
|
||||
def download_file(self, remote_path):
|
||||
"""Download text content from a remote file."""
|
||||
if not self.client:
|
||||
raise ConnectionError("Not connected to server")
|
||||
|
||||
sftp = self.client.open_sftp()
|
||||
try:
|
||||
with sftp.file(remote_path, 'r') as f:
|
||||
return f.read().decode('utf-8', errors='replace')
|
||||
finally:
|
||||
sftp.close()
|
||||
|
||||
def file_exists(self, remote_path):
|
||||
"""Check if a remote file exists."""
|
||||
if not self.client:
|
||||
raise ConnectionError("Not connected to server")
|
||||
|
||||
sftp = self.client.open_sftp()
|
||||
try:
|
||||
sftp.stat(remote_path)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
finally:
|
||||
sftp.close()
|
||||
|
||||
def test_connection(self):
|
||||
"""Test SSH connection and return server info."""
|
||||
out, err, code = self.run_command("uname -sr && cat /etc/os-release 2>/dev/null | head -2")
|
||||
return out
|
||||
|
||||
def write_file(self, remote_path, content):
|
||||
"""Write content to a remote file with sudo."""
|
||||
return self.upload_file_sudo(content, remote_path)
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.disconnect()
|
||||
@@ -0,0 +1,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"
|
||||
@@ -0,0 +1,823 @@
|
||||
"""
|
||||
WireGuard Protocol Manager - handles standard WireGuard protocol
|
||||
installation, configuration, and client management on remote servers.
|
||||
|
||||
Follows the same architecture as awg_manager.py, using:
|
||||
- client/server_scripts/wireguard/ scripts as reference
|
||||
- Docker container: amneziavpn/amnezia-wg (same as AWG Legacy)
|
||||
- Standard wg/wg-quick tools
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import logging
|
||||
from base64 import b64encode
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WG_DEFAULTS = {
|
||||
'port': '51820',
|
||||
'mtu': '1420',
|
||||
'subnet_address': '10.8.2.0',
|
||||
'subnet_cidr': '24',
|
||||
'subnet_ip': '10.8.2.1',
|
||||
'dns1': '1.1.1.1',
|
||||
'dns2': '1.0.0.1',
|
||||
}
|
||||
|
||||
|
||||
def generate_wg_keypair():
|
||||
"""Generate a WireGuard X25519 keypair (private, public) as base64 strings."""
|
||||
private_key = X25519PrivateKey.generate()
|
||||
private_bytes = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PrivateFormat.Raw,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
public_bytes = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw
|
||||
)
|
||||
return b64encode(private_bytes).decode(), b64encode(public_bytes).decode()
|
||||
|
||||
|
||||
def generate_psk():
|
||||
"""Generate a WireGuard preshared key."""
|
||||
return b64encode(secrets.token_bytes(32)).decode()
|
||||
|
||||
|
||||
class WireGuardManager:
|
||||
"""Manages standard WireGuard protocol installation and client management."""
|
||||
|
||||
PROTOCOL = 'wireguard'
|
||||
CONTAINER_NAME = 'amnezia-wireguard'
|
||||
DOCKER_IMAGE = 'amneziavpn/amnezia-wg:latest'
|
||||
CONFIG_PATH = '/opt/amnezia/wireguard/wg0.conf'
|
||||
KEY_DIR = '/opt/amnezia/wireguard'
|
||||
CLIENTS_TABLE_PATH = '/opt/amnezia/wireguard/clientsTable'
|
||||
INTERFACE = 'wg0'
|
||||
|
||||
def __init__(self, ssh_manager):
|
||||
self.ssh = ssh_manager
|
||||
|
||||
# ===================== INSTALLATION =====================
|
||||
|
||||
def check_docker_installed(self):
|
||||
"""Check if Docker is installed and running."""
|
||||
out, err, code = self.ssh.run_command("docker --version 2>/dev/null")
|
||||
if code != 0:
|
||||
return False
|
||||
out2, _, code2 = self.ssh.run_command(
|
||||
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
|
||||
)
|
||||
return 'active' in out2 or 'running' in out2.lower()
|
||||
|
||||
def install_docker(self):
|
||||
"""Install Docker on the server."""
|
||||
script = r"""
|
||||
if which apt-get > /dev/null 2>&1; then pm=$(which apt-get); silent_inst="-yq install"; check_pkgs="-yq update"; docker_pkg="docker.io"; dist="debian";
|
||||
elif which dnf > /dev/null 2>&1; then pm=$(which dnf); silent_inst="-yq install"; check_pkgs="-yq check-update"; docker_pkg="docker"; dist="fedora";
|
||||
elif which yum > /dev/null 2>&1; then pm=$(which yum); silent_inst="-y -q install"; check_pkgs="-y -q check-update"; docker_pkg="docker"; dist="centos";
|
||||
else echo "Packet manager not found"; exit 1; fi;
|
||||
if [ "$dist" = "debian" ]; then export DEBIAN_FRONTEND=noninteractive; fi;
|
||||
if ! command -v docker > /dev/null 2>&1; then
|
||||
$pm $check_pkgs; $pm $silent_inst $docker_pkg;
|
||||
sleep 5; systemctl enable --now docker; sleep 5;
|
||||
fi;
|
||||
if [ "$(systemctl is-active docker)" != "active" ]; then
|
||||
$pm $check_pkgs; $pm $silent_inst $docker_pkg;
|
||||
sleep 5; systemctl start docker; sleep 5;
|
||||
fi;
|
||||
docker --version
|
||||
"""
|
||||
out, err, code = self.ssh.run_sudo_script(script, timeout=180)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to install Docker: {err}")
|
||||
return out
|
||||
|
||||
def check_container_running(self):
|
||||
"""Check if WireGuard container is running."""
|
||||
out, _, code = self.ssh.run_sudo_command(
|
||||
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
||||
)
|
||||
return 'Up' in out
|
||||
|
||||
def check_protocol_installed(self):
|
||||
"""Check if protocol is installed (container exists)."""
|
||||
out, _, code = self.ssh.run_sudo_command(
|
||||
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
|
||||
)
|
||||
return self.CONTAINER_NAME in out.strip().split('\n')
|
||||
|
||||
def prepare_host(self):
|
||||
"""Prepare host for container."""
|
||||
dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}"
|
||||
script = f"""
|
||||
mkdir -p {dockerfile_folder}
|
||||
mkdir -p {self.KEY_DIR}
|
||||
if ! docker network ls | grep -q amnezia-dns-net; then
|
||||
docker network create --driver bridge --subnet=172.29.172.0/24 --opt com.docker.network.bridge.name=amn0 amnezia-dns-net
|
||||
fi
|
||||
"""
|
||||
out, err, code = self.ssh.run_sudo_script(script)
|
||||
if code != 0:
|
||||
logger.warning(f"prepare_host warning: {err}")
|
||||
return True
|
||||
|
||||
def setup_firewall(self):
|
||||
"""Setup host firewall."""
|
||||
script = """
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
iptables -C INPUT -p icmp --icmp-type echo-request -j DROP 2>/dev/null || iptables -A INPUT -p icmp --icmp-type echo-request -j DROP
|
||||
iptables -C FORWARD -j DOCKER-USER 2>/dev/null || iptables -A FORWARD -j DOCKER-USER 2>/dev/null
|
||||
"""
|
||||
self.ssh.run_sudo_script(script)
|
||||
return True
|
||||
|
||||
def install_protocol(self, port=None):
|
||||
"""
|
||||
Full installation of WireGuard protocol.
|
||||
Steps: install docker -> prepare host -> build container ->
|
||||
configure container -> run container -> setup firewall
|
||||
"""
|
||||
if port is None:
|
||||
port = WG_DEFAULTS['port']
|
||||
|
||||
results = []
|
||||
|
||||
# Step 1: Install Docker
|
||||
if not self.check_docker_installed():
|
||||
results.append("Installing Docker...")
|
||||
self.install_docker()
|
||||
results.append("Docker installed successfully")
|
||||
else:
|
||||
results.append("Docker already installed")
|
||||
|
||||
# Step 2: Prepare host
|
||||
results.append("Preparing host...")
|
||||
self.prepare_host()
|
||||
results.append("Host prepared")
|
||||
|
||||
# Step 3: Remove old container if exists
|
||||
if self.check_protocol_installed():
|
||||
results.append("Removing old container...")
|
||||
self.remove_container()
|
||||
results.append("Old container removed")
|
||||
|
||||
# Step 4: Build container
|
||||
results.append("Building Docker image...")
|
||||
dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}"
|
||||
|
||||
dockerfile_content = (
|
||||
f"FROM {self.DOCKER_IMAGE}\n"
|
||||
f"\n"
|
||||
f'LABEL maintainer="AmneziaVPN"\n'
|
||||
f"\n"
|
||||
f"RUN apk add --no-cache curl wireguard-tools dumb-init iptables bash\n"
|
||||
f"RUN apk --update upgrade --no-cache\n"
|
||||
f"\n"
|
||||
f"RUN mkdir -p /opt/amnezia\n"
|
||||
f'RUN echo "#!/bin/bash" > /opt/amnezia/start.sh && '
|
||||
f'echo "tail -f /dev/null" >> /opt/amnezia/start.sh\n'
|
||||
f"RUN chmod a+x /opt/amnezia/start.sh\n"
|
||||
f"\n"
|
||||
f'ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]\n'
|
||||
)
|
||||
self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}")
|
||||
self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile")
|
||||
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker build --no-cache --pull -t {self.CONTAINER_NAME} {dockerfile_folder}",
|
||||
timeout=300
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to build container: {err}")
|
||||
results.append("Docker image built successfully")
|
||||
|
||||
# Step 5: Run container
|
||||
results.append("Starting container...")
|
||||
run_cmd = f"""docker run -d \
|
||||
--restart always \
|
||||
--privileged \
|
||||
--cap-add=NET_ADMIN \
|
||||
--cap-add=SYS_MODULE \
|
||||
-p {port}:{port}/udp \
|
||||
-v /lib/modules:/lib/modules \
|
||||
--sysctl="net.ipv4.conf.all.src_valid_mark=1" \
|
||||
--name {self.CONTAINER_NAME} \
|
||||
{self.CONTAINER_NAME}"""
|
||||
|
||||
out, err, code = self.ssh.run_sudo_command(run_cmd)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to run container: {err}")
|
||||
|
||||
# Connect to DNS network
|
||||
self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.CONTAINER_NAME}")
|
||||
|
||||
# Wait for container
|
||||
results.append("Waiting for container to start...")
|
||||
self._wait_container_running()
|
||||
results.append("Container started")
|
||||
|
||||
# Step 6: Configure container
|
||||
results.append("Configuring WireGuard...")
|
||||
self._configure_container(port)
|
||||
results.append("WireGuard configured")
|
||||
|
||||
# Step 7: Upload start script
|
||||
results.append("Starting WireGuard service...")
|
||||
self._upload_start_script(port)
|
||||
results.append("WireGuard service started")
|
||||
|
||||
# Step 8: Setup firewall
|
||||
results.append("Setting up firewall...")
|
||||
self.setup_firewall()
|
||||
results.append("Firewall configured")
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'protocol': self.PROTOCOL,
|
||||
'port': port,
|
||||
'log': results,
|
||||
}
|
||||
|
||||
def _wait_container_running(self, timeout=30):
|
||||
"""Wait for a container to be in 'running' state."""
|
||||
import time
|
||||
last_status = 'unknown'
|
||||
for i in range(timeout // 2):
|
||||
out, _, _ = self.ssh.run_sudo_command(
|
||||
f"docker inspect --format='{{{{.State.Status}}}}' {self.CONTAINER_NAME}"
|
||||
)
|
||||
last_status = out.strip().strip("'\"")
|
||||
if last_status == 'running':
|
||||
time.sleep(1)
|
||||
return True
|
||||
time.sleep(2)
|
||||
|
||||
logs_out, _, _ = self.ssh.run_sudo_command(
|
||||
f"docker logs --tail 50 {self.CONTAINER_NAME} 2>&1"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Container {self.CONTAINER_NAME} did not start within {timeout}s "
|
||||
f"(status: {last_status}). Logs:\n{logs_out}"
|
||||
)
|
||||
|
||||
def _configure_container(self, port):
|
||||
"""Configure the WireGuard container (generate keys and server config)."""
|
||||
subnet_ip = WG_DEFAULTS['subnet_ip']
|
||||
subnet_cidr = WG_DEFAULTS['subnet_cidr']
|
||||
|
||||
config_script = f"""
|
||||
mkdir -p {self.KEY_DIR}
|
||||
cd {self.KEY_DIR}
|
||||
WIREGUARD_SERVER_PRIVATE_KEY=$(wg genkey)
|
||||
echo $WIREGUARD_SERVER_PRIVATE_KEY > {self.KEY_DIR}/wireguard_server_private_key.key
|
||||
|
||||
WIREGUARD_SERVER_PUBLIC_KEY=$(echo $WIREGUARD_SERVER_PRIVATE_KEY | wg pubkey)
|
||||
echo $WIREGUARD_SERVER_PUBLIC_KEY > {self.KEY_DIR}/wireguard_server_public_key.key
|
||||
|
||||
WIREGUARD_PSK=$(wg genpsk)
|
||||
echo $WIREGUARD_PSK > {self.KEY_DIR}/wireguard_psk.key
|
||||
|
||||
cat > {self.CONFIG_PATH} <<EOF
|
||||
[Interface]
|
||||
PrivateKey = $WIREGUARD_SERVER_PRIVATE_KEY
|
||||
Address = {subnet_ip}/{subnet_cidr}
|
||||
ListenPort = {port}
|
||||
EOF
|
||||
"""
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} bash -c '{config_script}'"
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to configure container: {err}")
|
||||
|
||||
def _upload_start_script(self, port):
|
||||
"""Upload and execute the start script inside the container."""
|
||||
subnet_ip = WG_DEFAULTS['subnet_ip']
|
||||
subnet_cidr = WG_DEFAULTS['subnet_cidr']
|
||||
|
||||
start_script = f"""#!/bin/bash
|
||||
echo "WireGuard container startup"
|
||||
|
||||
# Kill existing wg-quick if running
|
||||
wg-quick down {self.CONFIG_PATH} 2>/dev/null
|
||||
|
||||
# Start WireGuard
|
||||
if [ -f {self.CONFIG_PATH} ]; then wg-quick up {self.CONFIG_PATH}; fi
|
||||
|
||||
# Allow traffic on the TUN interface
|
||||
iptables -A INPUT -i {self.INTERFACE} -j ACCEPT
|
||||
iptables -A FORWARD -i {self.INTERFACE} -j ACCEPT
|
||||
iptables -A OUTPUT -o {self.INTERFACE} -j ACCEPT
|
||||
|
||||
iptables -A FORWARD -i {self.INTERFACE} -o eth0 -s {subnet_ip}/{subnet_cidr} -j ACCEPT
|
||||
iptables -A FORWARD -i {self.INTERFACE} -o eth1 -s {subnet_ip}/{subnet_cidr} -j ACCEPT
|
||||
|
||||
iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
iptables -t nat -A POSTROUTING -s {subnet_ip}/{subnet_cidr} -o eth0 -j MASQUERADE
|
||||
iptables -t nat -A POSTROUTING -s {subnet_ip}/{subnet_cidr} -o eth1 -j MASQUERADE
|
||||
|
||||
tail -f /dev/null
|
||||
"""
|
||||
self.ssh.upload_file(start_script, "/tmp/_wg_start.sh")
|
||||
self.ssh.run_sudo_command(f"docker cp /tmp/_wg_start.sh {self.CONTAINER_NAME}:/opt/amnezia/start.sh")
|
||||
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} chmod +x /opt/amnezia/start.sh")
|
||||
self.ssh.run_command("rm -f /tmp/_wg_start.sh")
|
||||
|
||||
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
|
||||
import time
|
||||
time.sleep(5)
|
||||
|
||||
def remove_container(self):
|
||||
"""Remove WireGuard container."""
|
||||
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME}")
|
||||
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME}")
|
||||
self.ssh.run_sudo_command(f"docker rmi {self.CONTAINER_NAME}")
|
||||
return True
|
||||
|
||||
# ===================== CLIENT MANAGEMENT =====================
|
||||
|
||||
def _get_clients_table(self):
|
||||
"""Get the clients table from the server."""
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} cat {self.CLIENTS_TABLE_PATH} 2>/dev/null"
|
||||
)
|
||||
if code != 0 or not out.strip():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(out)
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
def _save_clients_table(self, clients_table):
|
||||
"""Save the clients table to the server."""
|
||||
content = json.dumps(clients_table, indent=2)
|
||||
self.ssh.upload_file(content, "/tmp/_wg_clients.json")
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker cp /tmp/_wg_clients.json {self.CONTAINER_NAME}:{self.CLIENTS_TABLE_PATH}"
|
||||
)
|
||||
self.ssh.run_command("rm -f /tmp/_wg_clients.json")
|
||||
|
||||
def _get_server_config(self):
|
||||
"""Get the server WireGuard config."""
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} cat {self.CONFIG_PATH}"
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to get server config: {err}")
|
||||
return out
|
||||
|
||||
def save_server_config(self, config_content):
|
||||
"""Save the server WireGuard config and restart container."""
|
||||
self.ssh.upload_file(config_content.replace('\r\n', '\n'), "/tmp/_wg_edit_config.conf")
|
||||
self.ssh.run_sudo_command(f"docker cp /tmp/_wg_edit_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}")
|
||||
self.ssh.run_command("rm -f /tmp/_wg_edit_config.conf")
|
||||
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
|
||||
|
||||
def _get_server_public_key(self):
|
||||
"""Get server public key."""
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} cat {self.KEY_DIR}/wireguard_server_public_key.key"
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to get server public key: {err}")
|
||||
return out.strip()
|
||||
|
||||
def _get_server_psk(self):
|
||||
"""Get server preshared key."""
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} cat {self.KEY_DIR}/wireguard_psk.key"
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to get PSK: {err}")
|
||||
return out.strip()
|
||||
|
||||
def _get_listen_port(self):
|
||||
"""Extract ListenPort from server config."""
|
||||
config = self._get_server_config()
|
||||
for line in config.split('\n'):
|
||||
if line.strip().startswith('ListenPort'):
|
||||
return line.split('=', 1)[1].strip()
|
||||
return WG_DEFAULTS['port']
|
||||
|
||||
def _get_used_ips(self):
|
||||
"""Get list of IPs already assigned in the config."""
|
||||
config = self._get_server_config()
|
||||
ips = []
|
||||
for line in config.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('AllowedIPs'):
|
||||
match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line)
|
||||
if match:
|
||||
ips.append(match.group(1))
|
||||
elif line.startswith('Address'):
|
||||
match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line)
|
||||
if match:
|
||||
ips.append(match.group(1))
|
||||
return ips
|
||||
|
||||
def _get_next_ip(self):
|
||||
"""Calculate the next available IP for a new client."""
|
||||
used_ips = self._get_used_ips()
|
||||
if not used_ips:
|
||||
base = WG_DEFAULTS['subnet_address']
|
||||
parts = base.split('.')
|
||||
parts[3] = '2'
|
||||
return '.'.join(parts)
|
||||
|
||||
last_ip = used_ips[-1]
|
||||
parts = last_ip.split('.')
|
||||
last_octet = int(parts[3])
|
||||
next_octet = last_octet + 1
|
||||
if next_octet > 254:
|
||||
next_octet = 2
|
||||
parts[3] = str(next_octet)
|
||||
return '.'.join(parts)
|
||||
|
||||
def _parse_peers_from_config(self):
|
||||
"""Parse [Peer] sections from WireGuard server config."""
|
||||
try:
|
||||
config = self._get_server_config()
|
||||
except Exception:
|
||||
return {}
|
||||
peers = {}
|
||||
current_key = None
|
||||
for line in config.split('\n'):
|
||||
line = line.strip()
|
||||
if line == '[Peer]':
|
||||
current_key = None
|
||||
elif current_key is None and line.startswith('PublicKey'):
|
||||
current_key = line.split('=', 1)[1].strip()
|
||||
peers[current_key] = {'allowedIps': ''}
|
||||
elif current_key and line.startswith('AllowedIPs'):
|
||||
peers[current_key]['allowedIps'] = line.split('=', 1)[1].strip()
|
||||
return peers
|
||||
|
||||
def _parse_bytes(self, size_str):
|
||||
"""Parse human readable size string into bytes."""
|
||||
try:
|
||||
parts = size_str.strip().split()
|
||||
if len(parts) != 2:
|
||||
return 0
|
||||
val, unit = float(parts[0]), parts[1]
|
||||
units = {'B': 1, 'KiB': 1024, 'MiB': 1024**2, 'GiB': 1024**3, 'TiB': 1024**4}
|
||||
return int(val * units.get(unit, 1))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def _wg_show(self):
|
||||
"""Run 'wg show all' and parse output."""
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg show all'"
|
||||
)
|
||||
if code != 0 or not out.strip():
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
current_peer = None
|
||||
|
||||
for line in out.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('peer:'):
|
||||
current_peer = line.split(':', 1)[1].strip()
|
||||
result[current_peer] = {}
|
||||
elif current_peer and ':' in line:
|
||||
key, value = line.split(':', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key == 'latest handshake':
|
||||
result[current_peer]['latestHandshake'] = value
|
||||
elif key == 'transfer':
|
||||
parts = value.split(',')
|
||||
if len(parts) == 2:
|
||||
received = parts[0].strip().replace(' received', '')
|
||||
sent = parts[1].strip().replace(' sent', '')
|
||||
result[current_peer]['dataReceived'] = received
|
||||
result[current_peer]['dataSent'] = sent
|
||||
result[current_peer]['dataReceivedBytes'] = self._parse_bytes(received)
|
||||
result[current_peer]['dataSentBytes'] = self._parse_bytes(sent)
|
||||
elif key == 'allowed ips':
|
||||
result[current_peer]['allowedIps'] = value
|
||||
return result
|
||||
|
||||
def get_clients(self):
|
||||
"""Get list of all clients with live traffic data."""
|
||||
clients_table = self._get_clients_table()
|
||||
|
||||
try:
|
||||
wg_show_data = self._wg_show()
|
||||
except Exception:
|
||||
wg_show_data = {}
|
||||
|
||||
known_ids = set()
|
||||
for client in clients_table:
|
||||
client_id = client.get('clientId', '')
|
||||
known_ids.add(client_id)
|
||||
if client_id in wg_show_data:
|
||||
show_data = wg_show_data[client_id]
|
||||
user_data = client.get('userData', {})
|
||||
user_data['latestHandshake'] = show_data.get('latestHandshake', '')
|
||||
user_data['dataReceived'] = show_data.get('dataReceived', '')
|
||||
user_data['dataSent'] = show_data.get('dataSent', '')
|
||||
user_data['dataReceivedBytes'] = show_data.get('dataReceivedBytes', 0)
|
||||
user_data['dataSentBytes'] = show_data.get('dataSentBytes', 0)
|
||||
user_data['allowedIps'] = show_data.get('allowedIps', '')
|
||||
client['userData'] = user_data
|
||||
|
||||
# Pick up peers from conf not in clientsTable (native app clients)
|
||||
try:
|
||||
conf_peers = self._parse_peers_from_config()
|
||||
for pub_key, peer_info in conf_peers.items():
|
||||
if pub_key in known_ids:
|
||||
continue
|
||||
show_data = wg_show_data.get(pub_key, {})
|
||||
allowed_ip = peer_info.get('allowedIps', '') or show_data.get('allowedIps', '')
|
||||
ip_part = ''
|
||||
if allowed_ip:
|
||||
m = re.search(r'(\d+\.\d+\.\d+\.\d+)', allowed_ip)
|
||||
if m:
|
||||
ip_part = m.group(1)
|
||||
display_name = f'External ({ip_part})' if ip_part else 'External (native app)'
|
||||
clients_table.append({
|
||||
'clientId': pub_key,
|
||||
'userData': {
|
||||
'clientName': display_name,
|
||||
'clientPrivateKey': '',
|
||||
'externalClient': True,
|
||||
'clientIp': ip_part,
|
||||
'latestHandshake': show_data.get('latestHandshake', ''),
|
||||
'dataReceived': show_data.get('dataReceived', ''),
|
||||
'dataSent': show_data.get('dataSent', ''),
|
||||
'dataReceivedBytes': show_data.get('dataReceivedBytes', 0),
|
||||
'dataSentBytes': show_data.get('dataSentBytes', 0),
|
||||
'allowedIps': allowed_ip,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f'get_clients: failed to parse conf peers: {e}')
|
||||
|
||||
return clients_table
|
||||
|
||||
def add_client(self, client_name, server_host):
|
||||
"""
|
||||
Add a new client/peer to the WireGuard config.
|
||||
Returns the client config string.
|
||||
"""
|
||||
# Generate client keys
|
||||
client_priv_key, client_pub_key = generate_wg_keypair()
|
||||
|
||||
# Get server info
|
||||
server_pub_key = self._get_server_public_key()
|
||||
psk = self._get_server_psk()
|
||||
port = self._get_listen_port()
|
||||
|
||||
# Get next available IP
|
||||
client_ip = self._get_next_ip()
|
||||
|
||||
dns1 = WG_DEFAULTS['dns1']
|
||||
dns2 = WG_DEFAULTS['dns2']
|
||||
|
||||
# Check if AmneziaDNS is installed
|
||||
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
|
||||
if 'amnezia-dns' in out:
|
||||
dns1 = '172.29.172.254'
|
||||
|
||||
mtu = WG_DEFAULTS['mtu']
|
||||
|
||||
# Append peer to server config
|
||||
peer_section = f"""
|
||||
[Peer]
|
||||
PublicKey = {client_pub_key}
|
||||
PresharedKey = {psk}
|
||||
AllowedIPs = {client_ip}/32
|
||||
|
||||
"""
|
||||
escaped_peer = peer_section.replace("'", "'\\''")
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} bash -c 'echo \"{escaped_peer}\" >> {self.CONFIG_PATH}'"
|
||||
)
|
||||
|
||||
# Sync config without restart
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'"
|
||||
)
|
||||
|
||||
# Update clients table
|
||||
clients_table = self._get_clients_table()
|
||||
new_client = {
|
||||
'clientId': client_pub_key,
|
||||
'userData': {
|
||||
'clientName': client_name,
|
||||
'creationDate': __import__('datetime').datetime.now().isoformat(),
|
||||
'clientPrivateKey': client_priv_key,
|
||||
'clientIp': client_ip,
|
||||
'psk': psk,
|
||||
'enabled': True,
|
||||
}
|
||||
}
|
||||
clients_table.append(new_client)
|
||||
self._save_clients_table(clients_table)
|
||||
|
||||
# Build client config
|
||||
client_config = f"""[Interface]
|
||||
Address = {client_ip}/32
|
||||
DNS = {dns1}, {dns2}
|
||||
PrivateKey = {client_priv_key}
|
||||
MTU = {mtu}
|
||||
|
||||
[Peer]
|
||||
PublicKey = {server_pub_key}
|
||||
PresharedKey = {psk}
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = {server_host}:{port}
|
||||
PersistentKeepalive = 25
|
||||
"""
|
||||
return {
|
||||
'client_name': client_name,
|
||||
'client_id': client_pub_key,
|
||||
'client_ip': client_ip,
|
||||
'config': client_config,
|
||||
}
|
||||
|
||||
def get_client_config(self, client_id, server_host):
|
||||
"""Reconstruct client config from stored data."""
|
||||
clients_table = self._get_clients_table()
|
||||
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
|
||||
if not client:
|
||||
raise RuntimeError(f"Client {client_id} not found")
|
||||
|
||||
ud = client.get('userData', {})
|
||||
client_priv_key = ud.get('clientPrivateKey', '')
|
||||
client_ip = ud.get('clientIp', '')
|
||||
psk = ud.get('psk', '')
|
||||
|
||||
if not client_priv_key:
|
||||
raise RuntimeError("Client private key not stored. Config cannot be reconstructed.")
|
||||
|
||||
server_pub_key = self._get_server_public_key()
|
||||
if not psk:
|
||||
psk = self._get_server_psk()
|
||||
|
||||
port = self._get_listen_port()
|
||||
|
||||
dns1 = WG_DEFAULTS['dns1']
|
||||
dns2 = WG_DEFAULTS['dns2']
|
||||
|
||||
# Check if AmneziaDNS is installed
|
||||
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
|
||||
if 'amnezia-dns' in out:
|
||||
dns1 = '172.29.172.254'
|
||||
|
||||
mtu = WG_DEFAULTS['mtu']
|
||||
|
||||
config = f"""[Interface]
|
||||
Address = {client_ip}/32
|
||||
DNS = {dns1}, {dns2}
|
||||
PrivateKey = {client_priv_key}
|
||||
MTU = {mtu}
|
||||
|
||||
[Peer]
|
||||
PublicKey = {server_pub_key}
|
||||
PresharedKey = {psk}
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = {server_host}:{port}
|
||||
PersistentKeepalive = 25
|
||||
"""
|
||||
return config
|
||||
|
||||
def toggle_client(self, client_id, enable):
|
||||
"""Enable or disable a client by adding/removing their [Peer] from server config."""
|
||||
if enable:
|
||||
clients_table = self._get_clients_table()
|
||||
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
|
||||
if not client:
|
||||
raise RuntimeError(f"Client {client_id} not found")
|
||||
|
||||
ud = client.get('userData', {})
|
||||
psk = ud.get('psk', '') or self._get_server_psk()
|
||||
client_ip = ud.get('clientIp', '')
|
||||
|
||||
peer_section = f"""
|
||||
[Peer]
|
||||
PublicKey = {client_id}
|
||||
PresharedKey = {psk}
|
||||
AllowedIPs = {client_ip}/32
|
||||
|
||||
"""
|
||||
escaped_peer = peer_section.replace("'", "'\\''")
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} bash -c 'echo \"{escaped_peer}\" >> {self.CONFIG_PATH}'"
|
||||
)
|
||||
else:
|
||||
# Remove peer from server config
|
||||
config = self._get_server_config()
|
||||
sections = config.split('[')
|
||||
new_sections = [s for s in sections if s.strip() and client_id not in s]
|
||||
new_config = '[' + '['.join(new_sections)
|
||||
|
||||
self.ssh.upload_file(new_config, "/tmp/_wg_config.conf")
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker cp /tmp/_wg_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}"
|
||||
)
|
||||
self.ssh.run_command("rm -f /tmp/_wg_config.conf")
|
||||
|
||||
# Sync config
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'"
|
||||
)
|
||||
|
||||
# Update enabled status in clients table
|
||||
clients_table = self._get_clients_table()
|
||||
for c in clients_table:
|
||||
if c.get('clientId') == client_id:
|
||||
c.setdefault('userData', {})['enabled'] = enable
|
||||
break
|
||||
self._save_clients_table(clients_table)
|
||||
|
||||
def remove_client(self, client_id):
|
||||
"""Remove a client from WireGuard config."""
|
||||
config = self._get_server_config()
|
||||
sections = config.split('[')
|
||||
new_sections = [s for s in sections if s.strip() and client_id not in s]
|
||||
new_config = '[' + '['.join(new_sections)
|
||||
|
||||
self.ssh.upload_file(new_config, "/tmp/_wg_config.conf")
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker cp /tmp/_wg_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}"
|
||||
)
|
||||
self.ssh.run_command("rm -f /tmp/_wg_config.conf")
|
||||
|
||||
# Sync config
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'"
|
||||
)
|
||||
|
||||
# Update clients table
|
||||
clients_table = self._get_clients_table()
|
||||
clients_table = [c for c in clients_table if c.get('clientId') != client_id]
|
||||
self._save_clients_table(clients_table)
|
||||
return True
|
||||
|
||||
def get_server_status(self):
|
||||
"""Get detailed status of the WireGuard server."""
|
||||
info = {
|
||||
'container_exists': self.check_protocol_installed(),
|
||||
'container_running': False,
|
||||
'protocol': self.PROTOCOL,
|
||||
}
|
||||
|
||||
if info['container_exists']:
|
||||
info['container_running'] = self.check_container_running()
|
||||
if info['container_running']:
|
||||
try:
|
||||
config = self._get_server_config()
|
||||
for line in config.split('\n'):
|
||||
if 'ListenPort' in line:
|
||||
info['port'] = line.split('=')[1].strip()
|
||||
break
|
||||
info['clients_count'] = len(self._get_clients_table())
|
||||
except Exception as e:
|
||||
info['error'] = str(e)
|
||||
|
||||
return info
|
||||
|
||||
def get_traffic_stats(self):
|
||||
"""Get aggregate traffic stats for all clients."""
|
||||
try:
|
||||
wg_data = self._wg_show()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
total_rx = 0
|
||||
total_tx = 0
|
||||
active_peers = 0
|
||||
active_ips = []
|
||||
|
||||
for peer_key, data in wg_data.items():
|
||||
rx = data.get('dataReceivedBytes', 0)
|
||||
tx = data.get('dataSentBytes', 0)
|
||||
total_rx += rx
|
||||
total_tx += tx
|
||||
if rx > 0 or tx > 0:
|
||||
active_peers += 1
|
||||
allowed = data.get('allowedIps', '')
|
||||
if allowed:
|
||||
m = re.search(r'(\d+\.\d+\.\d+\.\d+)', allowed)
|
||||
if m:
|
||||
active_ips.append(m.group(1))
|
||||
|
||||
return {
|
||||
'total_rx_bytes': total_rx,
|
||||
'total_tx_bytes': total_tx,
|
||||
'active_connections': active_peers,
|
||||
'active_ips': active_ips,
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user