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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohimaru2
2026-08-09 19:01:52 +03:00
co-authored by Cursor
parent 3ea638d9c5
commit 4720d8f2bd
11 changed files with 573 additions and 153 deletions
+27 -2
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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&nbsp;&nbsp;${domain}&nbsp;&nbsp;${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&nbsp;&nbsp;${domain}&nbsp;&nbsp;${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();
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "停止容器 {}",