diff --git a/app.py b/app.py index 39fec2f..39c08dc 100644 --- a/app.py +++ b/app.py @@ -103,7 +103,7 @@ else: application_path = os.path.dirname(__file__) DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export -CURRENT_VERSION = "v3.0.1" +CURRENT_VERSION = "v3.1.1" RELEASES_REPO_URL = repo_url() RELEASES_API_LATEST = api_latest_url() BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) @@ -2225,6 +2225,11 @@ class InstallProtocolRequest(BaseModel): # NaiveProxy naiveproxy_domain: Optional[str] = None naiveproxy_email: Optional[str] = None + # Xray (VLESS + XHTTP + TLS) + xray_domain: Optional[str] = None + xray_email: Optional[str] = None + xray_acme_method: Optional[str] = 'cloudflare' # cloudflare | http + xray_cf_token: Optional[str] = None class Socks5SettingsRequest(BaseModel): @@ -3708,7 +3713,13 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro max_connections=req.max_connections if req.max_connections is not None else 0 ) elif install_base == 'xray': - result = manager.install_protocol(port=req.port) + result = manager.install_protocol( + port=req.port, + domain=req.xray_domain, + email=req.xray_email, + acme_method=req.xray_acme_method or 'cloudflare', + cloudflare_token=req.xray_cf_token, + ) elif install_base == 'wireguard': result = manager.install_protocol(port=req.port) elif install_base == 'socks5': @@ -3795,6 +3806,20 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro proto_record['email'] = result.get('email') if result.get('port'): proto_record['port'] = str(result['port']) + if install_base == 'xray': + info = server.setdefault('server_info', {}) + if req.xray_domain: + info['ssl_domain'] = (req.xray_domain or '').strip().lower() + if req.xray_email: + info['ssl_email'] = (req.xray_email or '').strip() + save_data(data) + proto_record['domain'] = result.get('domain') + proto_record['path'] = result.get('path') + proto_record['transport'] = 'xhttp' + proto_record['security'] = 'tls' + proto_record['acme_method'] = result.get('acme_method') or req.xray_acme_method or 'cloudflare' + if result.get('port'): + proto_record['port'] = str(result['port']) if install_base == 'naiveproxy': info = server.setdefault('server_info', {}) if req.naiveproxy_domain: diff --git a/managers/xray_manager.py b/managers/xray_manager.py index 3483fa5..550eb1a 100644 --- a/managers/xray_manager.py +++ b/managers/xray_manager.py @@ -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) diff --git a/templates/invites.html b/templates/invites.html index 19b518f..4c49aa8 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -210,7 +210,7 @@ awg: 'AmneziaWG', awg_legacy: 'AWG Legacy', wireguard: 'WireGuard', - xray: 'Xray (VLESS-Reality)', + xray: 'Xray (VLESS-XHTTP-TLS)', telemt: 'Telemt', hysteria: 'Hysteria 2', naiveproxy: 'NaiveProxy', diff --git a/templates/server.html b/templates/server.html index b5fa2aa..b10a181 100644 --- a/templates/server.html +++ b/templates/server.html @@ -272,7 +272,7 @@
-