Template
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:
+354
-136
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user