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:
@@ -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:
|
||||
|
||||
+348
-130
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
import logging
|
||||
import base64
|
||||
@@ -9,12 +11,22 @@ 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
|
||||
@@ -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 && \\
|
||||
@@ -221,29 +374,16 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
||||
_, 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
|
||||
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'
|
||||
|
||||
# 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"},
|
||||
"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,13 +450,22 @@ 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -300,15 +473,25 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
||||
|
||||
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
|
||||
|
||||
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}
|
||||
if not client:
|
||||
return None
|
||||
|
||||
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)
|
||||
listen_port = meta.get('port', port)
|
||||
|
||||
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']}"
|
||||
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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
+104
-2
@@ -272,7 +272,7 @@
|
||||
<div class="protocol-icon">{{ icon('zap') }}</div>
|
||||
<div class="flex gap-sm" id="xray-ctrl" style="display:none!important;"></div>
|
||||
</div>
|
||||
<div class="protocol-name">Xray (VLESS-Reality)</div>
|
||||
<div class="protocol-name">Xray (VLESS-XHTTP-TLS)</div>
|
||||
<div class="protocol-desc">
|
||||
{{ _('xray_desc') }}
|
||||
</div>
|
||||
@@ -796,6 +796,41 @@
|
||||
<div class="form-hint">{{ _('nginx_install_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div id="xrayOptions"
|
||||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||||
<div class="form-hint" id="xrayPortsWarning" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
|
||||
{{ _('xray_ports_warning_cf') }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xray_domain') }} *</label>
|
||||
<input class="form-input" type="text" id="installXrayDomain" placeholder="vpn.example.com" oninput="updateXrayDnsHint()">
|
||||
<div class="form-hint" id="xrayDnsHint"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xray_email') }} *</label>
|
||||
<input class="form-input" type="email" id="installXrayEmail" placeholder="admin@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xray_acme_method') }}</label>
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer;">
|
||||
<input type="radio" name="xrayAcmeMethod" value="cloudflare" checked onchange="updateXrayAcmeUi()">
|
||||
<span>{{ _('xray_acme_cloudflare') }}</span>
|
||||
</label>
|
||||
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer;">
|
||||
<input type="radio" name="xrayAcmeMethod" value="http" onchange="updateXrayAcmeUi()">
|
||||
<span>{{ _('xray_acme_http') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="xrayCfTokenGroup">
|
||||
<label class="form-label">{{ _('xray_cf_token') }} *</label>
|
||||
<input class="form-input" type="password" id="installXrayCfToken" autocomplete="off" placeholder="Cloudflare API Token">
|
||||
<div class="form-hint">{{ _('xray_cf_token_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-hint">{{ _('xray_install_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div id="hysteriaOptions"
|
||||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||||
<div class="form-hint" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
|
||||
@@ -1233,7 +1268,7 @@
|
||||
{ proto: 'awg2', category: 'protocols', icon: 'sparkles', title: 'AmneziaWG 2.0', descKey: 'awg_desc', badge: 'NEW' },
|
||||
{ proto: 'awg', category: 'protocols', icon: 'shield', title: 'AmneziaWG', descKey: 'awg_desc' },
|
||||
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
|
||||
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
|
||||
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-XHTTP-TLS)', descKey: 'xray_desc' },
|
||||
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
|
||||
{ proto: 'mieru', category: 'protocols', icon: 'zap', title: 'Mieru', descKey: 'mieru_desc', badge: 'v3.28.0' },
|
||||
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
|
||||
@@ -1946,6 +1981,16 @@
|
||||
if (protoBase(proto) === 'hysteria' && info.domain) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('hysteria_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
|
||||
}
|
||||
if (protoBase(proto) === 'xray' && info.domain) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('xray_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
|
||||
}
|
||||
if (protoBase(proto) === 'xray' && info.acme_method) {
|
||||
const acmeLabel = info.acme_method === 'cloudflare' ? _('xray_acme_cloudflare_short') : _('xray_acme_http_short');
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('xray_acme_method')}</span><span class="protocol-info-value">${acmeLabel}</span></div>`;
|
||||
}
|
||||
if (protoBase(proto) === 'xray' && (info.transport || info.security)) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('protocol_label')}</span><span class="protocol-info-value">VLESS · ${(info.transport || 'xhttp').toUpperCase()}+${(info.security || 'tls').toUpperCase()}</span></div>`;
|
||||
}
|
||||
if (protoBase(proto) === 'naiveproxy' && info.domain) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('naiveproxy_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
|
||||
}
|
||||
@@ -2394,6 +2439,34 @@
|
||||
hint.innerHTML = `${_('naiveproxy_dns_hint')} <code>A ${domain} ${SERVER_HOST}</code>`;
|
||||
}
|
||||
|
||||
function getXrayAcmeMethod() {
|
||||
const el = document.querySelector('input[name="xrayAcmeMethod"]:checked');
|
||||
return el ? el.value : 'cloudflare';
|
||||
}
|
||||
|
||||
function updateXrayAcmeUi() {
|
||||
const method = getXrayAcmeMethod();
|
||||
const cfGroup = document.getElementById('xrayCfTokenGroup');
|
||||
const warn = document.getElementById('xrayPortsWarning');
|
||||
if (cfGroup) cfGroup.style.display = method === 'cloudflare' ? '' : 'none';
|
||||
if (warn) warn.textContent = method === 'cloudflare' ? _('xray_ports_warning_cf') : _('xray_ports_warning');
|
||||
updateXrayDnsHint();
|
||||
const portHint = document.getElementById('installPortHint');
|
||||
if (portHint && protoBase(currentInstallProto) === 'xray' && !currentInstallAnother) {
|
||||
portHint.textContent = method === 'cloudflare' ? _('port_xray_hint_cf') : _('port_xray_hint');
|
||||
}
|
||||
}
|
||||
|
||||
function updateXrayDnsHint() {
|
||||
const input = document.getElementById('installXrayDomain');
|
||||
const hint = document.getElementById('xrayDnsHint');
|
||||
if (!input || !hint) return;
|
||||
const domain = (input.value || '').trim() || 'vpn.example.com';
|
||||
const method = getXrayAcmeMethod();
|
||||
const prefix = method === 'cloudflare' ? _('xray_dns_hint_cf') : _('xray_dns_hint');
|
||||
hint.innerHTML = `${prefix} <code>A ${domain} ${SERVER_HOST}</code>`;
|
||||
}
|
||||
|
||||
function openInstallModal(proto, installAnother = false) {
|
||||
const base = protoBase(proto);
|
||||
currentInstallProto = installAnother ? base : proto;
|
||||
@@ -2412,6 +2485,7 @@
|
||||
const hysteriaOpts = document.getElementById('hysteriaOptions');
|
||||
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
|
||||
const mieruOpts = document.getElementById('mieruOptions');
|
||||
const xrayOpts = document.getElementById('xrayOptions');
|
||||
|
||||
telemtOpts.style.display = 'none';
|
||||
socks5Opts.style.display = 'none';
|
||||
@@ -2420,6 +2494,7 @@
|
||||
hysteriaOpts.style.display = 'none';
|
||||
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
|
||||
if (mieruOpts) mieruOpts.style.display = 'none';
|
||||
if (xrayOpts) xrayOpts.style.display = 'none';
|
||||
if (portGroup) portGroup.style.display = '';
|
||||
|
||||
if (base === 'dns') {
|
||||
@@ -2432,6 +2507,12 @@
|
||||
portInput.disabled = false;
|
||||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
|
||||
portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('port_xray_hint');
|
||||
if (xrayOpts) xrayOpts.style.display = 'block';
|
||||
const xrDomain = document.getElementById('installXrayDomain');
|
||||
const xrEmail = document.getElementById('installXrayEmail');
|
||||
if (xrDomain && !xrDomain.value && SERVER_SSL_DOMAIN) xrDomain.value = SERVER_SSL_DOMAIN;
|
||||
if (xrEmail && !xrEmail.value && SERVER_SSL_EMAIL) xrEmail.value = SERVER_SSL_EMAIL;
|
||||
updateXrayAcmeUi();
|
||||
} else if (base === 'telemt') {
|
||||
portLabel.textContent = _('port') + ' (TCP)';
|
||||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
|
||||
@@ -2513,6 +2594,20 @@
|
||||
|
||||
async function installProtocol() {
|
||||
const port = document.getElementById('installPort').value;
|
||||
if (protoBase(currentInstallProto) === 'xray') {
|
||||
const xrDomain = (document.getElementById('installXrayDomain')?.value || '').trim();
|
||||
const xrEmail = (document.getElementById('installXrayEmail')?.value || '').trim();
|
||||
const acme = getXrayAcmeMethod();
|
||||
const cfToken = (document.getElementById('installXrayCfToken')?.value || '').trim();
|
||||
if (!xrDomain || !xrEmail) {
|
||||
showToast(_('xray_domain') + ' / ' + _('xray_email'), 'error');
|
||||
return;
|
||||
}
|
||||
if (acme === 'cloudflare' && !cfToken) {
|
||||
showToast(_('xray_cf_token'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const btn = document.getElementById('installBtn');
|
||||
const text = document.getElementById('installBtnText');
|
||||
const spinner = document.getElementById('installSpinner');
|
||||
@@ -2571,6 +2666,13 @@
|
||||
}
|
||||
params.hysteria_domain = document.getElementById('installHysteriaDomain').value.trim();
|
||||
params.hysteria_email = document.getElementById('installHysteriaEmail').value.trim();
|
||||
} else if (protoBase(currentInstallProto) === 'xray') {
|
||||
params.xray_domain = (document.getElementById('installXrayDomain')?.value || '').trim();
|
||||
params.xray_email = (document.getElementById('installXrayEmail')?.value || '').trim();
|
||||
params.xray_acme_method = getXrayAcmeMethod();
|
||||
if (params.xray_acme_method === 'cloudflare') {
|
||||
params.xray_cf_token = (document.getElementById('installXrayCfToken')?.value || '').trim();
|
||||
}
|
||||
} else if (protoBase(currentInstallProto) === 'naiveproxy') {
|
||||
params.port = '443';
|
||||
params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').value.trim();
|
||||
|
||||
@@ -1563,7 +1563,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',
|
||||
|
||||
@@ -637,7 +637,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',
|
||||
|
||||
+17
-2
@@ -72,7 +72,7 @@
|
||||
"docker_not_installed": "Docker not installed",
|
||||
"awg_desc": "A newer version of the protocol based on awg-go. Supports advanced obfuscation with S3, S4 parameters.",
|
||||
"awg_legacy_desc": "Original AWG version based on WireGuard kernel. Compatible with older client versions.",
|
||||
"xray_desc": "Modern protocol that masks traffic as regular web traffic (XTLS-Reality). Resistant to deep packet analysis.",
|
||||
"xray_desc": "VLESS over XHTTP+TLS — traffic looks like normal HTTPS/HTTP2. Tuned for DPI resistance (no Vision flow). Needs a domain; SSL via Cloudflare DNS or HTTP-01.",
|
||||
"wireguard_desc": "Standard and fastest VPN protocol. Supported natively on all modern OS, but easily detected by DPI.",
|
||||
"not_checked": "Not checked",
|
||||
"connections": "Connections",
|
||||
@@ -82,7 +82,22 @@
|
||||
"no_connections_desc": "Add your first connection to generate a VPN configuration",
|
||||
"install_protocol": "Install protocol",
|
||||
"port_default_hint": "Default port: 55424. Make sure it\u0027s not busy",
|
||||
"port_xray_hint": "Default port: 443 (recommended for Xray). Make sure it\u0027s not taken by another web server.",
|
||||
"port_xray_hint": "Default 443/TCP. Prefer Cloudflare DNS ACME — port 80 is not needed. Point domain A-record (DNS only / grey cloud) to this server.",
|
||||
"port_xray_hint_cf": "Default 443/TCP. Certificate via Cloudflare DNS — port 80 is not used. Keep Cloudflare proxy off (grey cloud).",
|
||||
"xray_domain": "Domain",
|
||||
"xray_email": "Let\u0027s Encrypt email",
|
||||
"xray_dns_hint": "Create DNS record:",
|
||||
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
|
||||
"xray_install_hint": "Installs VLESS + XHTTP + TLS (Xray-core). Prefer Cloudflare API token (DNS-01) so TCP 80 stays free.",
|
||||
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01). Reinstall replaces the previous Xray stack.",
|
||||
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud). Reinstall replaces the previous Xray stack.",
|
||||
"xray_acme_method": "Certificate method",
|
||||
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
|
||||
"xray_acme_http": "HTTP-01 — needs free TCP 80",
|
||||
"xray_acme_cloudflare_short": "Cloudflare DNS",
|
||||
"xray_acme_http_short": "HTTP-01",
|
||||
"xray_cf_token": "Cloudflare API token",
|
||||
"xray_cf_token_hint": "Create a token with Zone:DNS:Edit for this domain zone. Token is used only to issue the cert and is not shown again.",
|
||||
"reinstall": "Reinstall",
|
||||
"uninstall_confirm": "Uninstall {}? All connections and configurations will be lost.",
|
||||
"stop_container_confirm": "Stop container {}?",
|
||||
|
||||
+17
-2
@@ -71,7 +71,7 @@
|
||||
"docker_not_installed": "داکر نصب نیست",
|
||||
"awg_desc": "نسخه جدید پروتکل بر پایه awg-go. پشتیبانی از مبهمسازی پیشرفته (S3, S4).",
|
||||
"awg_legacy_desc": "نسخه اصلی AWG. سازگار با نسخههای قدیمی کلاینت.",
|
||||
"xray_desc": "تغییر ظاهر ترافیک به ترافیک معمولی وب (XTLS-Reality). مقاوم در برابر فیلترینگ شدید.",
|
||||
"xray_desc": "VLESS over XHTTP+TLS — looks like normal HTTPS/HTTP2. Needs a domain + Let\u0027s Encrypt.",
|
||||
"not_checked": "بررسی نشده",
|
||||
"connections": "اتصالها",
|
||||
"add": "افزودن",
|
||||
@@ -80,7 +80,22 @@
|
||||
"no_connections_desc": "اولین اتصال خود را برای ایجاد پیکربندی VPN اضافه کنید",
|
||||
"install_protocol": "نصب پروتکل",
|
||||
"port_default_hint": "پورت پیشفرض: 55424. مطمئن شوید این پورت آزاد است.",
|
||||
"port_xray_hint": "پورت پیشنهادی: 443. مطمئن شوید توسط وبسرور دیگری اشغال نشده باشد.",
|
||||
"port_xray_hint": "Default 443/TCP. Prefer Cloudflare DNS ACME — port 80 is not needed.",
|
||||
"port_xray_hint_cf": "Default 443/TCP. Certificate via Cloudflare DNS — port 80 is not used.",
|
||||
"xray_domain": "Domain",
|
||||
"xray_email": "Let\u0027s Encrypt email",
|
||||
"xray_dns_hint": "Create DNS record:",
|
||||
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
|
||||
"xray_install_hint": "Installs VLESS + XHTTP + TLS. Prefer Cloudflare API token (DNS-01) so TCP 80 stays free.",
|
||||
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01).",
|
||||
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
|
||||
"xray_acme_method": "Certificate method",
|
||||
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
|
||||
"xray_acme_http": "HTTP-01 — needs free TCP 80",
|
||||
"xray_acme_cloudflare_short": "Cloudflare DNS",
|
||||
"xray_acme_http_short": "HTTP-01",
|
||||
"xray_cf_token": "Cloudflare API token",
|
||||
"xray_cf_token_hint": "Token with Zone:DNS:Edit for this domain zone. Used only to issue the cert.",
|
||||
"reinstall": "نصب مجدد",
|
||||
"uninstall_confirm": "حذف نصب {}؟ تمام اتصالها و پیکربندیها از بین خواهند رفت.",
|
||||
"stop_container_confirm": "توقف کانتینر {}؟",
|
||||
|
||||
+17
-2
@@ -71,7 +71,7 @@
|
||||
"docker_not_installed": "Docker non installé",
|
||||
"awg_desc": "Version moderne basée sur awg-go. Obfuscation avancée (S3, S4).",
|
||||
"awg_legacy_desc": "Version AWG originale. Compatible avec les anciens clients.",
|
||||
"xray_desc": "Masque le trafic en trafic web normal (XTLS-Reality). Résiste au DPI.",
|
||||
"xray_desc": "VLESS over XHTTP+TLS — looks like normal HTTPS/HTTP2. Needs a domain + Let\u0027s Encrypt.",
|
||||
"not_checked": "Non vérifié",
|
||||
"connections": "Connexions",
|
||||
"add": "Ajouter",
|
||||
@@ -80,7 +80,22 @@
|
||||
"no_connections_desc": "Ajoutez votre première connexion pour générer un fichier VPN",
|
||||
"install_protocol": "Installer le protocole",
|
||||
"port_default_hint": "Port par défaut : 55424. Assurez-vous qu\u0027il est libre.",
|
||||
"port_xray_hint": "Port recommandé : 443. Assurez-vous qu\u0027il n\u0027est pas utilisé par un serveur web.",
|
||||
"port_xray_hint": "Default 443/TCP. Prefer Cloudflare DNS ACME — port 80 is not needed.",
|
||||
"port_xray_hint_cf": "Default 443/TCP. Certificate via Cloudflare DNS — port 80 is not used.",
|
||||
"xray_domain": "Domain",
|
||||
"xray_email": "Let\u0027s Encrypt email",
|
||||
"xray_dns_hint": "Create DNS record:",
|
||||
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
|
||||
"xray_install_hint": "Installs VLESS + XHTTP + TLS. Prefer Cloudflare API token (DNS-01) so TCP 80 stays free.",
|
||||
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01).",
|
||||
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
|
||||
"xray_acme_method": "Certificate method",
|
||||
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
|
||||
"xray_acme_http": "HTTP-01 — needs free TCP 80",
|
||||
"xray_acme_cloudflare_short": "Cloudflare DNS",
|
||||
"xray_acme_http_short": "HTTP-01",
|
||||
"xray_cf_token": "Cloudflare API token",
|
||||
"xray_cf_token_hint": "Token with Zone:DNS:Edit for this domain zone. Used only to issue the cert.",
|
||||
"reinstall": "Réinstaller",
|
||||
"uninstall_confirm": "Désinstaller {} ? Toutes les données seront perdues.",
|
||||
"stop_container_confirm": "Arrêter le conteneur {} ?",
|
||||
|
||||
+17
-2
@@ -72,7 +72,7 @@
|
||||
"docker_not_installed": "Docker не установлен",
|
||||
"awg_desc": "Новая версия протокола на основе awg-go. Поддерживает расширенную обфускацию с параметрами S3, S4.",
|
||||
"awg_legacy_desc": "Оригинальная версия AWG на базе ядра WireGuard. Совместима с клиентами старых версий.",
|
||||
"xray_desc": "Современный протокол с маскировкой под обычный веб-трафик (XTLS-Reality). Устойчив к глубокому анализу пакетов.",
|
||||
"xray_desc": "VLESS поверх XHTTP+TLS — трафик как обычный HTTPS/HTTP2. Заточено под DPI (без Vision flow). Нужен домен; SSL через Cloudflare DNS или HTTP-01.",
|
||||
"wireguard_desc": "Стандартный и самый быстрый VPN-протокол. Встроен во все современные ОС, но легко блокируется DPI.",
|
||||
"not_checked": "Не проверено",
|
||||
"connections": "Подключения",
|
||||
@@ -82,7 +82,22 @@
|
||||
"no_connections_desc": "Добавьте первое подключение для генерации VPN конфигурации",
|
||||
"install_protocol": "Установить протокол",
|
||||
"port_default_hint": "Порт по умолчанию: 55424. Убедитесь, что он не занят",
|
||||
"port_xray_hint": "Порт по умолчанию: 443 (рекомендуется для Xray). Убедитесь, что он не занят другим веб-сервером.",
|
||||
"port_xray_hint": "По умолчанию 443/TCP. Лучше Cloudflare DNS ACME — порт 80 не нужен. A-запись домена (только DNS / серое облако) на этот сервер.",
|
||||
"port_xray_hint_cf": "По умолчанию 443/TCP. Сертификат через Cloudflare DNS — порт 80 не используется. Прокси Cloudflare выключите (серое облако).",
|
||||
"xray_domain": "Домен",
|
||||
"xray_email": "Email для Let\u0027s Encrypt",
|
||||
"xray_dns_hint": "Создайте DNS-запись:",
|
||||
"xray_dns_hint_cf": "Домен должен быть в Cloudflare. A-запись (только DNS):",
|
||||
"xray_install_hint": "Ставит VLESS + XHTTP + TLS (Xray-core). Рекомендуется токен Cloudflare (DNS-01) — TCP 80 не занимается.",
|
||||
"xray_ports_warning": "На время установки TCP 80 должен быть свободен (Let\u0027s Encrypt HTTP-01). Переустановка заменяет предыдущий Xray.",
|
||||
"xray_ports_warning_cf": "Cloudflare DNS ACME не использует порт 80. Токену нужно Zone → DNS → Edit. Прокси выключите (серое облако). Переустановка заменяет предыдущий Xray.",
|
||||
"xray_acme_method": "Способ выпуска SSL",
|
||||
"xray_acme_cloudflare": "Cloudflare DNS (API-токен) — рекомендуется, без порта 80",
|
||||
"xray_acme_http": "HTTP-01 — нужен свободный TCP 80",
|
||||
"xray_acme_cloudflare_short": "Cloudflare DNS",
|
||||
"xray_acme_http_short": "HTTP-01",
|
||||
"xray_cf_token": "API-токен Cloudflare",
|
||||
"xray_cf_token_hint": "Создайте токен с правом Zone:DNS:Edit для зоны домена. Токен нужен только для выпуска сертификата и больше не показывается.",
|
||||
"reinstall": "Переустановить",
|
||||
"uninstall_confirm": "Удалить {}? Все подключения и конфигурации будут потеряны.",
|
||||
"stop_container_confirm": "Остановить контейнер {}?",
|
||||
|
||||
+17
-2
@@ -71,7 +71,7 @@
|
||||
"docker_not_installed": "Docker 未安装",
|
||||
"awg_desc": "基于 awg-go 的新版协议。支持 S3, S4 高级混淆。",
|
||||
"awg_legacy_desc": "原始 AWG 版本。兼容旧版客户端。",
|
||||
"xray_desc": "将流量伪装成普通网页流量 (XTLS-Reality),抗封锁能力强。",
|
||||
"xray_desc": "VLESS over XHTTP+TLS — looks like normal HTTPS/HTTP2. Needs a domain + Let\u0027s Encrypt.",
|
||||
"not_checked": "未检查",
|
||||
"connections": "连接",
|
||||
"add": "添加",
|
||||
@@ -80,7 +80,22 @@
|
||||
"no_connections_desc": "添加首个连接以生成 VPN 配置文件",
|
||||
"install_protocol": "安装协议",
|
||||
"port_default_hint": "默认端口: 55424。请确保端口未被占用。",
|
||||
"port_xray_hint": "推荐端口: 443。请确保未被其他 Web 服务器使用。",
|
||||
"port_xray_hint": "Default 443/TCP. Prefer Cloudflare DNS ACME — port 80 is not needed.",
|
||||
"port_xray_hint_cf": "Default 443/TCP. Certificate via Cloudflare DNS — port 80 is not used.",
|
||||
"xray_domain": "Domain",
|
||||
"xray_email": "Let\u0027s Encrypt email",
|
||||
"xray_dns_hint": "Create DNS record:",
|
||||
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
|
||||
"xray_install_hint": "Installs VLESS + XHTTP + TLS. Prefer Cloudflare API token (DNS-01) so TCP 80 stays free.",
|
||||
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01).",
|
||||
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
|
||||
"xray_acme_method": "Certificate method",
|
||||
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
|
||||
"xray_acme_http": "HTTP-01 — needs free TCP 80",
|
||||
"xray_acme_cloudflare_short": "Cloudflare DNS",
|
||||
"xray_acme_http_short": "HTTP-01",
|
||||
"xray_cf_token": "Cloudflare API token",
|
||||
"xray_cf_token_hint": "Token with Zone:DNS:Edit for this domain zone. Used only to issue the cert.",
|
||||
"reinstall": "重新安装",
|
||||
"uninstall_confirm": "确定卸载 {}?所有连接和配置都将丢失。",
|
||||
"stop_container_confirm": "停止容器 {}?",
|
||||
|
||||
Reference in New Issue
Block a user