Switch Xray to VLESS+XHTTP+TLS with Cloudflare DNS ACME (v3.1.1).

Issue Let's Encrypt via Cloudflare API token so install no longer needs port 80; keep HTTP-01 as fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohimaru2
2026-08-09 19:01:52 +03:00
co-authored by Cursor
parent 3ea638d9c5
commit 4720d8f2bd
11 changed files with 573 additions and 153 deletions
+354 -136
View File
@@ -1,5 +1,7 @@
import json
import os
import re
import secrets
import uuid
import logging
import base64
@@ -9,13 +11,23 @@ import urllib.parse
logger = logging.getLogger(__name__)
CERTBOT_IMAGE = 'certbot/certbot:latest'
CERTBOT_CF_IMAGE = 'certbot/dns-cloudflare:latest'
XRAY_RELEASE = 'v26.3.27'
def _q(value):
return shlex.quote(str(value))
class XrayManager:
"""Manages Xray (VLESS-Reality) protocol installation and client management."""
"""Manages Xray VLESS + XHTTP + TLS (Let's Encrypt) for DPI-resistant installs."""
PROTOCOL = 'xray'
CONTAINER_NAME = 'amnezia-xray'
IMAGE_NAME = 'amneziavpn/amnezia-xray' # or we can build it
IMAGE_NAME = 'amneziavpn/amnezia-xray'
DEFAULT_PORT = 443
def __init__(self, ssh_manager, protocol='xray'):
self.ssh = ssh_manager
self.protocol = protocol or self.PROTOCOL
@@ -155,35 +167,182 @@ class XrayManager:
'container_exists': exists,
'container_running': running,
'clients_count': len(clients),
'port': meta.get('port')
'port': meta.get('port'),
'domain': meta.get('domain') or meta.get('site_name'),
'transport': meta.get('transport') or 'xhttp',
'security': meta.get('security') or 'tls',
}
def install_protocol(self, port=443, site_name='yahoo.com'):
"""Full installation of Xray."""
def _validate_domain(self, domain):
domain = (domain or '').strip().lower().rstrip('.')
if not domain or not re.match(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$', domain):
raise ValueError('Valid domain is required for Xray XHTTP+TLS (e.g. vpn.example.com)')
return domain
def _validate_email(self, email):
email = (email or '').strip()
if not email or '@' not in email or '.' not in email.split('@')[-1]:
raise ValueError('Valid email is required for Let\'s Encrypt')
return email
def _certs_dir(self):
return f'{self._config_dir()}/certs'
def _letsencrypt_dir(self):
return f'{self._config_dir()}/letsencrypt'
def _cert_path(self):
return f'{self._certs_dir()}/fullchain.pem'
def _key_path(self):
return f'{self._certs_dir()}/privkey.pem'
def _random_xhttp_path(self):
# Looks like a static/CDN asset path — harder for simple DPI signatures.
return f'/assets/{secrets.token_hex(8)}/{secrets.token_hex(4)}.js'
def _cf_creds_path(self):
return f'{self._config_dir()}/cloudflare.ini'
def _write_cloudflare_ini(self, token):
token = (token or '').strip()
if not token:
raise ValueError('Cloudflare API token is required for DNS validation')
# Restrictive file for certbot dns plugin
content = f"dns_cloudflare_api_token = {token}\n"
path = self._cf_creds_path()
self.ssh.run_sudo_command(f"mkdir -p {_q(self._config_dir())}")
self.ssh.upload_file_sudo(content, path)
self.ssh.run_sudo_command(f"chmod 600 {_q(path)}")
return path
def _install_issued_certs(self, domain, le_dir):
copy_script = f"""
set -e
LIVE={_q(le_dir)}/live/{_q(domain)}
test -s "$LIVE/fullchain.pem"
test -s "$LIVE/privkey.pem"
mkdir -p {_q(self._certs_dir())}
cp -f "$LIVE/fullchain.pem" {_q(self._cert_path())}
cp -f "$LIVE/privkey.pem" {_q(self._key_path())}
chmod 644 {_q(self._cert_path())} {_q(self._key_path())}
"""
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(copy_script)}", timeout=30)
if code != 0:
raise RuntimeError(f'Failed to install certificate files: {err or out}')
def _issue_certificate(self, domain, email, *, acme_method='cloudflare', cloudflare_token=None):
"""Issue Let's Encrypt cert via Cloudflare DNS-01 (preferred) or HTTP-01 standalone."""
method = (acme_method or 'cloudflare').strip().lower()
if method in ('cf', 'dns', 'dns-01', 'cloudflare_dns'):
method = 'cloudflare'
if method in ('http', 'http-01', 'standalone', 'port80'):
method = 'http'
le_dir = self._letsencrypt_dir()
certs_dir = self._certs_dir()
self.ssh.run_sudo_command(f"mkdir -p {_q(le_dir)} {_q(certs_dir)}")
self.ssh.run_sudo_command("docker rm -fv amnezia-xray-certbot 2>/dev/null || true")
if method == 'cloudflare':
creds = self._write_cloudflare_ini(cloudflare_token)
self.ssh.run_sudo_command(f"docker pull {CERTBOT_CF_IMAGE}", timeout=180)
# DNS-01 — no TCP 80 needed. Token needs Zone:DNS:Edit on the domain zone.
cmd = (
f"docker run --rm --name amnezia-xray-certbot "
f"-v {_q(le_dir)}:/etc/letsencrypt "
f"-v {_q(creds)}:/cloudflare.ini:ro "
f"{CERTBOT_CF_IMAGE} certonly "
f"--dns-cloudflare "
f"--dns-cloudflare-credentials /cloudflare.ini "
f"--dns-cloudflare-propagation-seconds 30 "
f"--non-interactive --agree-tos --no-eff-email "
f"--email {_q(email)} -d {_q(domain)}"
)
out, err, code = self.ssh.run_sudo_command(cmd, timeout=420)
if code != 0:
raise RuntimeError(
f"Let's Encrypt (Cloudflare DNS) failed for {domain}. "
f"Check API token (Zone:DNS:Edit) and that the domain is on Cloudflare. {err or out}"
)
self._install_issued_certs(domain, le_dir)
return 'cloudflare'
# HTTP-01 standalone — needs free TCP 80
self.ssh.run_sudo_command(f"docker pull {CERTBOT_IMAGE}", timeout=180)
cmd = (
f"docker run --rm --name amnezia-xray-certbot "
f"-p 80:80 "
f"-v {_q(le_dir)}:/etc/letsencrypt "
f"{CERTBOT_IMAGE} certonly --standalone "
f"--non-interactive --agree-tos --no-eff-email "
f"--email {_q(email)} -d {_q(domain)}"
)
out, err, code = self.ssh.run_sudo_command(cmd, timeout=300)
if code != 0:
raise RuntimeError(
f"Let's Encrypt (HTTP-01) failed for {domain}. "
f"Point an A-record to this server and free TCP port 80. {err or out}"
)
self._install_issued_certs(domain, le_dir)
return 'http'
def _is_xhttp_tls_inbound(self, inbound):
stream = (inbound or {}).get('streamSettings') or {}
return (
str(stream.get('network') or '').lower() in ('xhttp', 'splithttp')
and str(stream.get('security') or '').lower() == 'tls'
)
def _is_reality_inbound(self, inbound):
stream = (inbound or {}).get('streamSettings') or {}
return str(stream.get('security') or '').lower() == 'reality'
def install_protocol(self, port=443, domain=None, email=None, site_name=None,
acme_method='cloudflare', cloudflare_token=None):
"""Install VLESS + XHTTP + TLS (Let's Encrypt via Cloudflare DNS or HTTP-01)."""
results = []
domain = self._validate_domain(domain or site_name)
email = self._validate_email(email)
port = int(port or self.DEFAULT_PORT)
method = (acme_method or 'cloudflare').strip().lower()
if method in ('cf', 'dns', 'dns-01', 'cloudflare_dns'):
method = 'cloudflare'
if method in ('http', 'http-01', 'standalone', 'port80'):
method = 'http'
if method == 'http' and port == 80:
raise RuntimeError('Port 80 is reserved for Let\'s Encrypt HTTP-01 validation')
if method == 'cloudflare' and not (cloudflare_token or '').strip():
raise ValueError('Cloudflare API token is required')
if not self.check_docker_installed():
results.append("Installing Docker...")
# Using same install method as AWGManager or assume it's installed
pass
results.append("Docker not detected — install may fail")
results.append("Removing old container if exists...")
if self.check_protocol_installed():
self.remove_container()
if method == 'cloudflare':
results.append(f"Issuing Let's Encrypt cert via Cloudflare DNS for {domain}...")
else:
results.append(f"Issuing Let's Encrypt cert via HTTP-01 (port 80) for {domain}...")
used = self._issue_certificate(
domain, email, acme_method=method, cloudflare_token=cloudflare_token,
)
results.append(f"TLS certificate ready ({used})")
results.append("Building Docker image...")
config_dir = self._config_dir()
dockerfile_folder = f"/opt/amnezia/{self.container_name}"
dockerfile_content = f"""FROM alpine:3.15
RUN apk add --no-cache curl unzip bash openssl netcat-openbsd dumb-init rng-tools xz iptables ip6tables
RUN apk add --no-cache curl unzip bash openssl netcat-openbsd dumb-init rng-tools xz iptables ip6tables ca-certificates
RUN apk --update upgrade --no-cache
RUN mkdir -p {config_dir}
RUN curl -L -H "Cache-Control: no-cache" -o /root/xray.zip "https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-64.zip" && \\
RUN curl -L -H "Cache-Control: no-cache" -o /root/xray.zip "https://github.com/XTLS/Xray-core/releases/download/{XRAY_RELEASE}/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 && \\
@@ -191,18 +350,12 @@ RUN echo "fs.file-max = 51200" >> /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
echo "net.ipv4.tcp_congestion_control = bbr" >> /etc/sysctl.conf
RUN mkdir -p /etc/security && \\
echo "* soft nofile 51200" >> /etc/security/limits.conf && \\
@@ -217,33 +370,20 @@ 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}")
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()
results.append("Generating XHTTP+TLS config...")
xhttp_path = self._random_xhttp_path()
# auto → packet-up under TLS (CDN/middlebox friendly); works direct too.
xhttp_mode = 'auto'
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"},
"log": {"loglevel": "warning"},
"stats": {},
"api": {
"services": ["StatsService", "LoggerService", "HandlerService"],
@@ -260,6 +400,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
},
"inbounds": [
{
"listen": "0.0.0.0",
"port": int(port),
"protocol": "vless",
"tag": "proxy",
@@ -268,14 +409,37 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"dest": f"{site_name}:443",
"serverNames": [site_name],
"privateKey": priv_key,
"shortIds": [short_id]
"network": "xhttp",
"security": "tls",
"tlsSettings": {
"serverName": domain,
"minVersion": "1.3",
"alpn": ["h2", "http/1.1"],
"certificates": [{
"certificateFile": self._cert_path(),
"keyFile": self._key_path()
}]
},
"xhttpSettings": {
"path": xhttp_path,
"host": domain,
"mode": xhttp_mode,
"headers": {
"User-Agent": [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
]
}
},
"sockopt": {
"tcpFastOpen": True,
"tcpNoDelay": True
}
},
"sniffing": {
"enabled": True,
"destOverride": ["http", "tls", "quic"],
"routeOnly": True
}
},
{
@@ -286,29 +450,48 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
"tag": "api"
}
],
"outbounds": [{"protocol": "freedom"}],
"outbounds": [
{"protocol": "freedom", "tag": "direct"},
{"protocol": "blackhole", "tag": "block"}
],
"routing": {
"domainStrategy": "AsIs",
"rules": [
{
"type": "field",
"inboundTag": ["api"],
"outboundTag": "api",
"type": "field"
"outboundTag": "api"
},
{
"type": "field",
"protocol": ["bittorrent"],
"outboundTag": "block"
}
]
}
}
self.ssh.run_sudo_command(f"mkdir -p {config_dir}")
self.ssh.upload_file_sudo(json.dumps(server_json, indent=2), f"{config_dir}/server.json")
# Native layout — separate key files matching the official Amnezia client install.
# See client/server_scripts/xray/configure_container.sh for the canonical layout.
self.ssh.upload_file_sudo(priv_key + '\n', "/opt/amnezia/xray/xray_private.key")
self.ssh.upload_file_sudo(pub_key + '\n', "/opt/amnezia/xray/xray_public.key")
self.ssh.upload_file_sudo(short_id + '\n', "/opt/amnezia/xray/xray_short_id.key")
# xray_uuid.key marks the install-time "default" client whose ID gets skipped on
# auto-import. Panel installs do not reserve such a client, so we leave it empty.
self.ssh.upload_file_sudo('\n', "/opt/amnezia/xray/xray_uuid.key")
self.ssh.upload_file_sudo("[]", "/opt/amnezia/xray/clientsTable")
meta = {
'transport': 'xhttp',
'security': 'tls',
'domain': domain,
'email': email,
'path': xhttp_path,
'mode': xhttp_mode,
'port': int(port),
'fingerprint': 'chrome',
'alpn': 'h2',
'site_name': domain,
'acme_method': used,
}
self.ssh.upload_file_sudo(json.dumps(meta, indent=2), f"{config_dir}/meta.json")
self.ssh.upload_file_sudo("[]", f"{config_dir}/clientsTable.json")
# Clear cached layout so we pick panel (meta.json) next.
if hasattr(self, '_cached_layout'):
delattr(self, '_cached_layout')
results.append("Starting container...")
run_cmd = f"""docker run -d \\
@@ -316,19 +499,26 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
--privileged \\
--cap-add=NET_ADMIN \\
-p {port}:{port}/tcp \\
-p {port}:{port}/udp \\
-v {config_dir}:{config_dir} \\
--name {self.container_name} \\
{self.image_name}"""
_, err, code = self.ssh.run_sudo_command(run_cmd)
if code != 0: raise RuntimeError(f"Failed to run container: {err}")
if code != 0:
raise RuntimeError(f"Failed to run container: {err}")
# Try to connect to network if needed
self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.container_name} || true")
results.append("Xray configured and running")
return {'status': 'success', 'protocol': self.protocol, 'port': port, 'log': results}
results.append("Xray VLESS+XHTTP+TLS configured and running")
return {
'status': 'success',
'protocol': self.protocol,
'port': port,
'domain': domain,
'path': xhttp_path,
'acme_method': used,
'log': results,
}
def remove_container(self):
self.ssh.run_sudo_command(f"docker stop {self.container_name}")
@@ -431,28 +621,61 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
return False
return True
def _client_object(self, client_id, inbound=None):
client = {'id': client_id, 'email': client_id}
# Vision flow only for classic TCP/Reality; XHTTP must not set flow.
if inbound and self._is_reality_inbound(inbound):
network = str((inbound.get('streamSettings') or {}).get('network') or 'tcp').lower()
if network in ('tcp', 'raw', ''):
client['flow'] = 'xtls-rprx-vision'
return client
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.
"""
"""Read protocol metadata from meta.json and/or server.json (XHTTP+TLS or legacy Reality)."""
config = self._get_server_json() or {}
inbound = self._get_vless_inbound(config) or {}
stream = inbound.get('streamSettings') or {}
port = inbound.get('port')
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
meta = {}
out = self._read_remote_file(f"{self._config_dir()}/meta.json")
if out:
try:
meta = json.loads(out)
except Exception:
meta = {}
if port is not None:
meta['port'] = port
network = str(stream.get('network') or '').lower()
security = str(stream.get('security') or '').lower()
if network in ('xhttp', 'splithttp') and security == 'tls':
xs = stream.get('xhttpSettings') or stream.get('splithttpSettings') or {}
tls = stream.get('tlsSettings') or {}
meta['transport'] = 'xhttp'
meta['security'] = 'tls'
meta['path'] = xs.get('path') or meta.get('path') or '/'
meta['mode'] = xs.get('mode') or meta.get('mode') or 'auto'
meta['domain'] = (
meta.get('domain')
or xs.get('host')
or tls.get('serverName')
or meta.get('site_name')
)
meta['site_name'] = meta.get('domain') or meta.get('site_name')
meta['fingerprint'] = meta.get('fingerprint') or 'chrome'
meta['alpn'] = meta.get('alpn') or 'h2'
return meta
# Legacy Reality
rs = stream.get('realitySettings') or {}
names = rs.get('serverNames') or []
site_name = names[0] if names else meta.get('site_name') or 'yahoo.com'
meta['transport'] = 'tcp'
meta['security'] = 'reality'
meta['site_name'] = site_name
if self._detect_layout() == 'native':
priv = (self._read_remote_file(f"{self._config_dir()}/xray_private.key") or '').strip()
@@ -465,26 +688,15 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
sid = sids[0] if sids else ''
if not pub:
pub = self._derive_pubkey_from_priv(priv)
return {
meta.update({
'private_key': priv,
'public_key': pub,
'short_id': sid,
'port': port,
'site_name': site_name or 'yahoo.com',
}
'site_name': site_name,
})
return meta
# 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'):
@@ -678,38 +890,55 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
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
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}
meta = self._get_meta_json() or {}
config = self._get_server_json() or {}
inbound = self._get_vless_inbound(config) or {}
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']}"
listen_port = meta.get('port', port)
if self._is_xhttp_tls_inbound(inbound) or (meta.get('transport') == 'xhttp' and meta.get('security') == 'tls'):
domain = meta.get('domain') or meta.get('site_name') or server_host
path = meta.get('path') or '/'
mode = meta.get('mode') or 'auto'
fp = meta.get('fingerprint') or 'chrome'
alpn = meta.get('alpn') or 'h2'
# Prefer domain for TLS SNI / cert match; fall back to connect host.
host = domain or server_host
return (
f"vless://{client_id}@{host}:{listen_port}"
f"?encryption=none&security=tls&type=xhttp"
f"&path={urllib.parse.quote(path, safe='')}"
f"&mode={urllib.parse.quote(str(mode), safe='')}"
f"&host={urllib.parse.quote(domain, safe='')}"
f"&sni={urllib.parse.quote(domain, safe='')}"
f"&fp={urllib.parse.quote(fp, safe='')}"
f"&alpn={urllib.parse.quote(alpn, safe='')}"
f"#{encoded_name}"
)
# Legacy Reality share link
sni = meta.get('site_name', 'yahoo.com')
try:
sni = inbound['streamSettings']['realitySettings']['serverNames'][0]
except Exception:
pass
return (
f"vless://{client_id}@{server_host}:{listen_port}"
f"?type=tcp&security=reality&pbk={meta.get('public_key', '')}"
f"&sni={sni}&fp=chrome&sid={meta.get('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.")
if not config:
raise RuntimeError("Xray server config not found.")
self._upgrade_config_for_stats(config, restart=False)
@@ -717,13 +946,8 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
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
}
client = self._client_object(client_id, inbound)
if not self._xray_api_add_user(config, client):
raise RuntimeError(
"Xray runtime API is not available for hot user updates. "
@@ -733,7 +957,6 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
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,
@@ -758,14 +981,9 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
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
}
client = self._client_object(client_id, inbound)
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)