Compare commits

...
Author SHA1 Message Date
orohimaru2andCursor 9dd92a6a92 Fix Xray XHTTP+TLS client share links and simplify server stream (v3.1.4).
Drop incompatible inbound options, heal existing configs, and emit packet-up links clients accept.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:50:58 +03:00
orohimaru2andCursor 26a3aef760 Fix Xray crash from invalid xhttp headers arrays (v3.1.3).
Use string headers only and auto-heal broken server.json on status check.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:36:52 +03:00
orohimaru2andCursor d915b91f01 Let Xray pick standard 443 or a custom listen port (v3.1.2).
Default to custom 8443 so 443 stays free unless the user chooses standard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:23:49 +03:00
orohimaru2andCursor 4720d8f2bd 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>
2026-08-09 19:01:52 +03:00
orohimaru2andCursor 3ea638d9c5 Allow re-copying invite configs and end-user server choice (v3.0.1).
Invite pages keep created configs for repeat copy; guest/invite can pick a server when enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 18:31:46 +03:00
orohimaru2andCursor abbd160dd8 Ship AIVPN smart protocol picker and bump to v3.0.0.
Heuristic selection (stealth/balanced/speed) for invites, guest create, and user connections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 18:11:24 +03:00
orohi 01196c066a Bump version to v2.7.2 after migrate filename fix. 2026-08-09 17:46:14 +03:00
orohi b3c7d91419 Fix migrate export crash on non-ASCII server names in download headers. 2026-08-09 17:45:56 +03:00
15 changed files with 2015 additions and 374 deletions
+424 -78
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 = "v2.7.1"
CURRENT_VERSION = "v3.1.4"
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'))
@@ -227,6 +227,17 @@ def get_server_connect_host(server: dict, protocol: Optional[str] = None) -> str
return (server.get('host') or '').strip()
def _ascii_filename_component(value: str, *, fallback: str = 'file') -> str:
"""HTTP headers are latin-1; keep only ASCII for Content-Disposition filenames."""
text = str(value or '').strip()
# NFKD strip accents, then drop non-ASCII leftovers (e.g. Cyrillic names).
import unicodedata
text = unicodedata.normalize('NFKD', text)
text = text.encode('ascii', 'ignore').decode('ascii')
text = re.sub(r'[^A-Za-z0-9._-]+', '_', text).strip('._-')
return text or fallback
def get_current_user(request: Request, data: Optional[dict] = None):
user_id = request.session.get('user_id')
if not user_id:
@@ -255,6 +266,7 @@ def tpl(request, template, **kwargs):
'translations_json': json.dumps(TRANSLATIONS.get(lang, TRANSLATIONS.get('en', {}))),
# Keep for legacy JS; prefer translations_json on new pages
'all_translations_json': json.dumps(TRANSLATIONS),
'releases_repo_url': RELEASES_REPO_URL,
}
ctx.update(kwargs)
return templates.TemplateResponse(template, ctx)
@@ -2213,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):
@@ -2389,6 +2406,7 @@ class GuestSettings(BaseModel):
create_server_id: int = 0
create_inbound_id: int = 0
create_xui_panel_id: str = ''
create_allow_server_choice: bool = True
class DonateMethodSettings(BaseModel):
@@ -2462,6 +2480,7 @@ class ShareAuthRequest(BaseModel):
class GuestCreateRequest(BaseModel):
name: str = 'Guest VPN'
server_id: Optional[int] = None
class InviteCreateRequest(BaseModel):
@@ -2476,6 +2495,7 @@ class InviteCreateRequest(BaseModel):
duration_days: int = 0 # client lifetime after redeem; 0 = no expiry
note: str = ''
enabled: bool = True
allow_server_choice: bool = True
class InviteUpdateRequest(BaseModel):
@@ -2492,10 +2512,12 @@ class InviteUpdateRequest(BaseModel):
note: Optional[str] = None
enabled: Optional[bool] = None
reset_used: bool = False
allow_server_choice: Optional[bool] = None
class InviteRedeemRequest(BaseModel):
name: str = 'Invite VPN'
server_id: Optional[int] = None
class TunnelStartRequest(BaseModel):
@@ -3691,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':
@@ -3778,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:
@@ -4194,13 +4236,19 @@ async def api_server_migrate_export(
zip_bytes, summary = await asyncio.to_thread(_do_export)
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
safe_name = re.sub(r'[^\w.-]+', '_', server.get('name') or server.get('host') or 'server')
safe_name = _ascii_filename_component(
server.get('name') or server.get('host') or 'server',
fallback='server',
)
filename = f'amnezia-migrate-{safe_name}-{stamp}.zip'
headers = {
'Content-Disposition': f'attachment; filename="{filename}"',
'X-Migrate-Users': str(summary.get('users', 0)),
'X-Migrate-Connections': str(summary.get('connections', 0)),
'X-Migrate-Protocols': ','.join(summary.get('protocols') or []),
'X-Migrate-Protocols': ','.join(
_ascii_filename_component(p, fallback='proto')
for p in (summary.get('protocols') or [])
),
}
return StreamingResponse(io.BytesIO(zip_bytes), media_type='application/zip', headers=headers)
except Exception as e:
@@ -4256,6 +4304,74 @@ async def api_server_migrate_import(
return JSONResponse({'error': str(e)}, status_code=400)
class AivpnSettingsRequest(BaseModel):
enabled: Optional[bool] = None
strategy: Optional[str] = None # stealth | balanced | speed
probe: Optional[bool] = None
@app.get('/api/servers/{server_id}/aivpn', tags=["Servers"])
async def api_aivpn_get(request: Request, server_id: int, probe: bool = False):
"""AIVPN settings + ranked protocol recommendation for this server."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
from managers.aivpn_manager import get_aivpn_settings, pick_protocol
data = await load_data_async()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][server_id]
settings = get_aivpn_settings(server)
live_status = {}
try:
# Reuse last known protocol metadata; optional live SSH probe of ports only.
for key, info in (server.get('protocols') or {}).items():
if isinstance(info, dict):
live_status[key] = {
'installed': bool(info.get('installed')),
'port': info.get('port'),
'container_running': bool(info.get('running')),
'container_exists': bool(info.get('installed')),
}
except Exception:
pass
do_probe = bool(probe) if probe else bool(settings.get('probe'))
picked = await asyncio.to_thread(
pick_protocol,
server,
strategy=settings.get('strategy'),
probe=do_probe,
live_status=live_status,
)
return {
'settings': settings,
'recommendation': picked,
'connect_domain': get_server_connect_host(server, (picked or {}).get('protocol')),
}
@app.post('/api/servers/{server_id}/aivpn', tags=["Servers"])
async def api_aivpn_save(request: Request, server_id: int, req: AivpnSettingsRequest):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
from managers.aivpn_manager import set_aivpn_settings, pick_protocol, get_aivpn_settings
async with DATA_LOCK:
data = load_data()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][server_id]
settings = set_aivpn_settings(
server,
enabled=req.enabled,
strategy=req.strategy,
probe=req.probe,
)
save_data(data)
picked = pick_protocol(server, probe=False)
return {'status': 'success', 'settings': settings, 'recommendation': picked}
@app.post('/api/servers/{server_id}/backups/export-clients', tags=["Protocols"])
async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest):
"""Download a ZIP with all reconstructable WireGuard/AWG client .conf files."""
@@ -4795,7 +4911,13 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
if protocol_base(req.protocol) == 'xui':
protocol = req.protocol
server = data['servers'][server_id]
if protocol_base(protocol) == 'aivpn':
from managers.aivpn_manager import resolve_provision_protocol
protocol = resolve_provision_protocol(server, 'aivpn')
if protocol_base(protocol) == 'xui':
from managers.xui_api import xui_create_vless_config
from managers.xui_servers import get_xui_server, ensure_xui_servers
ensure_xui_servers(data.setdefault('settings', {}))
@@ -4829,16 +4951,15 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
save_data(data)
return result
server = data['servers'][server_id]
proto_info = server.get('protocols', {}).get(req.protocol, {})
proto_info = server.get('protocols', {}).get(protocol, {})
port = proto_info.get('port', '55424')
ssh = get_ssh(server)
ssh.connect()
manager = get_protocol_manager(ssh, req.protocol)
manager = get_protocol_manager(ssh, protocol)
if protocol_base(req.protocol) == 'telemt':
if protocol_base(protocol) == 'telemt':
result = manager.add_client(
req.protocol, req.name, get_server_connect_host(server, req.protocol), port,
protocol, req.name, get_server_connect_host(server, protocol), port,
telemt_quota=req.telemt_quota,
telemt_max_ips=req.telemt_max_ips,
telemt_expiry=req.telemt_expiry,
@@ -4846,14 +4967,15 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
user_ad_tag=req.telemt_ad_tag,
max_tcp_conns=req.telemt_max_conns
)
elif protocol_base(req.protocol) == 'wireguard':
result = manager.add_client(req.name, get_server_connect_host(server, req.protocol))
elif protocol_base(protocol) == 'wireguard':
result = manager.add_client(req.name, get_server_connect_host(server, protocol))
else:
result = manager.add_client(req.protocol, req.name, get_server_connect_host(server, req.protocol), port)
result = manager.add_client(protocol, req.name, get_server_connect_host(server, protocol), port)
ssh.disconnect()
if result.get('config'):
result['vpn_link'] = generate_vpn_link(result['config'])
result['protocol'] = protocol
# Link connection to user if specified
if req.user_id and result.get('client_id'):
@@ -4861,7 +4983,7 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
'id': str(uuid.uuid4()),
'user_id': req.user_id,
'server_id': server_id,
'protocol': req.protocol,
'protocol': protocol,
'client_id': result['client_id'],
'name': req.name,
'created_at': datetime.now().isoformat(),
@@ -5299,13 +5421,20 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
user = next((u for u in data['users'] if u['id'] == user_id), None)
if not user:
return JSONResponse({'error': 'User not found'}, status_code=404)
if protocol_base(req.protocol) not in CLIENT_VPN_BASES:
protocol = req.protocol
if protocol_base(protocol) == 'aivpn':
from managers.aivpn_manager import resolve_provision_protocol
if req.server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
protocol = resolve_provision_protocol(data['servers'][req.server_id], 'aivpn')
if protocol_base(protocol) not in CLIENT_VPN_BASES:
return JSONResponse(
{'error': f'Protocol "{req.protocol}" does not support user connections'},
{'error': f'Protocol "{protocol}" does not support user connections'},
status_code=400,
)
if protocol_base(req.protocol) == 'xui':
if protocol_base(protocol) == 'xui':
from managers.xui_servers import get_xui_server, ensure_xui_servers
ensure_xui_servers(data.setdefault('settings', {}))
panel = get_xui_server(data.get('settings') or {}, req.xui_panel_id)
@@ -5361,23 +5490,23 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
if req.server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][req.server_id]
proto_info = server.get('protocols', {}).get(req.protocol, {})
proto_info = server.get('protocols', {}).get(protocol, {})
port = proto_info.get('port', '55424')
ssh = get_ssh(server)
await asyncio.to_thread(ssh.connect)
try:
manager = get_protocol_manager(ssh, req.protocol)
manager = get_protocol_manager(ssh, protocol)
if req.client_id:
# Link existing client
config = await asyncio.to_thread(
_manager_call, manager, 'get_client_config',
req.protocol, req.client_id, get_server_connect_host(server, req.protocol), port,
protocol, req.client_id, get_server_connect_host(server, protocol), port,
)
result = {'client_id': req.client_id, 'config': config}
elif protocol_base(req.protocol) == 'telemt':
elif protocol_base(protocol) == 'telemt':
result = await asyncio.to_thread(
manager.add_client, req.protocol, req.name, get_server_connect_host(server, req.protocol), port,
manager.add_client, protocol, req.name, get_server_connect_host(server, protocol), port,
telemt_quota=req.telemt_quota,
telemt_max_ips=req.telemt_max_ips,
telemt_expiry=req.telemt_expiry,
@@ -5385,12 +5514,12 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
user_ad_tag=req.telemt_ad_tag,
max_tcp_conns=req.telemt_max_conns,
)
elif protocol_base(req.protocol) == 'wireguard':
result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, req.protocol))
elif protocol_base(protocol) == 'wireguard':
result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, protocol))
else:
result = await asyncio.to_thread(
_manager_call, manager, 'add_client',
req.protocol, req.name, get_server_connect_host(server, req.protocol), port,
protocol, req.name, get_server_connect_host(server, protocol), port,
)
finally:
await asyncio.to_thread(ssh.disconnect)
@@ -5400,7 +5529,7 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
'id': str(uuid.uuid4()),
'user_id': user_id,
'server_id': req.server_id,
'protocol': req.protocol,
'protocol': protocol,
'client_id': result['client_id'],
'name': req.name,
'created_at': datetime.now().isoformat(),
@@ -5606,6 +5735,7 @@ def _guest_settings(data: Optional[dict] = None) -> dict:
'create_server_id': 0,
'create_inbound_id': 0,
'create_xui_panel_id': '',
'create_allow_server_choice': True,
}
defaults.update(guest)
return defaults
@@ -5681,22 +5811,26 @@ async def api_guest_connections(token: str, request: Request):
data, guest, holder, err = _resolve_guest(token, request)
if err:
return err
if not holder:
return {
'connections': [],
allow_choice = bool(guest.get('create_allow_server_choice', True))
protocol = guest.get('create_protocol') or 'xui'
servers = _pickable_servers_for_protocol(data, protocol) if (
guest.get('allow_create') and allow_choice and protocol_base(protocol) != 'xui'
) else []
meta = {
'allow_create': bool(guest.get('allow_create')),
'create_protocol': guest.get('create_protocol') or 'xui',
'create_protocol': protocol,
'allow_server_choice': bool(servers),
'default_server_id': int(guest.get('create_server_id') or 0),
'servers': servers,
}
if not holder:
return {'connections': [], **meta}
conns = [
_enrich_guest_conn(c, data)
for c in data.get('user_connections', [])
if c['user_id'] == holder['id']
]
return {
'connections': conns,
'allow_create': bool(guest.get('allow_create')),
'create_protocol': guest.get('create_protocol') or 'xui',
}
return {'connections': conns, **meta}
@app.post('/api/guest/{token}/config/{connection_id}', tags=["Guest"])
@@ -5718,33 +5852,7 @@ async def api_guest_config(token: str, connection_id: str, request: Request):
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
if maybe_start_user_expiration(data, holder['id']):
save_data(data)
if protocol_base(conn.get('protocol', '')) == 'xui':
from managers.xui_api import xui_get_config
config = await xui_get_config(
data.get('settings', {}),
conn['client_id'],
panel_id=conn.get('xui_panel_id') or None,
)
vpn_link = config if str(config).startswith('http') else (generate_vpn_link(config) if config else '')
return {
'config': config,
'vpn_link': vpn_link,
'subscription_url': config if str(config).startswith('http') else '',
'expires_at': holder.get('expiration_date'),
}
sid = conn['server_id']
server = data['servers'][sid]
proto_info = server.get('protocols', {}).get(conn['protocol'], {})
port = proto_info.get('port', '55424')
ssh = get_ssh(server)
ssh.connect()
manager = get_protocol_manager(ssh, conn['protocol'])
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server, conn['protocol']), port)
ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')}
return await _fetch_connection_config_payload(data, conn, expires_at=holder.get('expiration_date'))
except Exception as e:
logger.exception("Error getting guest config")
return JSONResponse({'error': str(e)}, status_code=500)
@@ -5765,12 +5873,33 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
name = (req.name or 'Guest VPN').strip() or 'Guest VPN'
# Unique-ish name to avoid collisions
name = f"{name}_{secrets.token_hex(3)}"
allow_choice = bool(guest.get('create_allow_server_choice', True))
try:
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
if user_is_expired(holder):
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
if protocol_base(protocol) == 'xui':
sid = 0
else:
try:
sid = _resolve_chosen_server_id(
data,
protocol=protocol,
default_server_id=int(guest.get('create_server_id') or 0),
requested_server_id=req.server_id,
allow_choice=allow_choice,
)
except ValueError as ve:
return JSONResponse({'error': str(ve)}, status_code=400)
if protocol_base(protocol) == 'aivpn':
from managers.aivpn_manager import resolve_provision_protocol
protocol = resolve_provision_protocol(data['servers'][sid], 'aivpn')
elif protocol_base(protocol) != 'xui':
protocol = _match_protocol_on_server(data['servers'][sid], protocol)
if protocol_base(protocol) == 'xui':
from managers.xui_api import xui_create_vless_config
from managers.xui_servers import get_xui_server, ensure_xui_servers
@@ -5794,9 +5923,6 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
sid = 0
protocol = 'xui'
else:
sid = int(guest.get('create_server_id') or 0)
if sid >= len(data['servers']):
return JSONResponse({'error': 'Guest server not found'}, status_code=400)
server = data['servers'][sid]
proto_info = server.get('protocols', {}).get(protocol, {})
port = proto_info.get('port', '55424')
@@ -5864,12 +5990,90 @@ async def api_guest_regenerate_token(request: Request):
# ======================== Invite links (limited config creation) ========================
def _invite_public_view(link: dict) -> dict:
def _server_supports_protocol(server: dict, protocol: str) -> bool:
"""True if server has an installed client VPN matching protocol (or any for aivpn)."""
base = protocol_base(protocol)
if base == 'xui':
return False
protocols = server.get('protocols') or {}
for key, info in protocols.items():
if not isinstance(info, dict) or not info.get('installed'):
continue
kb = protocol_base(key)
if kb not in CLIENT_VPN_BASES or kb == 'xui':
continue
if base == 'aivpn' or kb == base or key == protocol:
return True
return False
def _match_protocol_on_server(server: dict, protocol: str) -> str:
"""Map requested protocol to an installed key on this server (prefer exact)."""
protocols = server.get('protocols') or {}
if protocol in protocols and isinstance(protocols.get(protocol), dict) and protocols[protocol].get('installed'):
return protocol
base = protocol_base(protocol)
if base == 'aivpn':
return protocol
candidates = [
key for key, info in protocols.items()
if isinstance(info, dict) and info.get('installed') and protocol_base(key) == base
]
if not candidates:
return protocol
candidates.sort()
return candidates[0]
def _pickable_servers_for_protocol(data: dict, protocol: str) -> list:
"""Safe server list for end-user pickers (name/host/id only)."""
out = []
for idx, server in enumerate(data.get('servers') or []):
if not isinstance(server, dict):
continue
if not _server_supports_protocol(server, protocol):
continue
out.append({
'id': idx,
'name': server.get('name') or server.get('host') or f'Server {idx + 1}',
'host': server.get('host') or '',
})
return out
def _resolve_chosen_server_id(
data: dict,
*,
protocol: str,
default_server_id: int,
requested_server_id: Optional[int],
allow_choice: bool,
) -> int:
"""Pick server_id for guest/invite create; validate against installed protocols."""
servers = data.get('servers') or []
default_sid = int(default_server_id or 0)
if not allow_choice or requested_server_id is None:
sid = default_sid
else:
sid = int(requested_server_id)
if sid < 0 or sid >= len(servers):
raise ValueError('Server not found')
if protocol_base(protocol) != 'xui' and not _server_supports_protocol(servers[sid], protocol):
raise ValueError('Selected server does not have the required protocol')
return sid
def _invite_public_view(link: dict, data: Optional[dict] = None) -> dict:
max_uses = int(link.get('max_uses') or 0)
used = int(link.get('used_count') or 0)
remaining = None if max_uses <= 0 else max(0, max_uses - used)
exhausted = remaining is not None and remaining <= 0
duration_days = int(link.get('duration_days') or 0)
protocol = link.get('protocol') or 'awg'
allow_server_choice = bool(link.get('allow_server_choice', True)) and protocol_base(protocol) != 'xui'
servers = []
if data is not None and allow_server_choice:
servers = _pickable_servers_for_protocol(data, protocol)
return {
'id': link.get('id'),
'name': link.get('name') or 'Invite',
@@ -5882,8 +6086,10 @@ def _invite_public_view(link: dict) -> dict:
'expired': False,
'exhausted': exhausted,
'has_password': bool(link.get('password_hash')),
'protocol': link.get('protocol') or 'awg',
'protocol': protocol,
'server_id': int(link.get('server_id') or 0),
'allow_server_choice': allow_server_choice,
'servers': servers,
'duration_days': duration_days,
'user_id': link.get('user_id') or '',
'note': link.get('note') or '',
@@ -5902,6 +6108,58 @@ def _invite_auth_ok(link: dict, request: Request) -> bool:
return bool(request.session.get(f"invite_auth_{link.get('token')}"))
def _enrich_invite_conn(c: dict, data: dict) -> dict:
out = dict(c)
if protocol_base(out.get('protocol', '')) == 'xui':
out['server_name'] = '3x-ui'
else:
sid = int(out.get('server_id') or 0)
if sid < len(data.get('servers') or []):
srv = data['servers'][sid]
out['server_name'] = srv.get('name') or srv.get('host') or ''
else:
out['server_name'] = 'Unknown'
return out
async def _fetch_connection_config_payload(data: dict, conn: dict, expires_at: Optional[str] = None) -> dict:
"""Shared config fetch for guest/invite/share self-service surfaces."""
if protocol_base(conn.get('protocol', '')) == 'xui':
from managers.xui_api import xui_get_config
config = await xui_get_config(
data.get('settings', {}),
conn['client_id'],
panel_id=conn.get('xui_panel_id') or None,
)
vpn_link = config if str(config).startswith('http') else (generate_vpn_link(config) if config else '')
return {
'config': config,
'vpn_link': vpn_link,
'subscription_url': config if str(config).startswith('http') else '',
'expires_at': expires_at,
}
sid = int(conn.get('server_id') or 0)
if sid >= len(data.get('servers') or []):
raise RuntimeError('Server not found')
server = data['servers'][sid]
protocol = conn['protocol']
proto_info = server.get('protocols', {}).get(protocol, {})
port = proto_info.get('port', '55424')
ssh = get_ssh(server)
await asyncio.to_thread(ssh.connect)
try:
manager = get_protocol_manager(ssh, protocol)
config = await asyncio.to_thread(
_manager_call, manager, 'get_client_config',
protocol, conn['client_id'], get_server_connect_host(server, protocol), port,
)
finally:
await asyncio.to_thread(ssh.disconnect)
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link, 'expires_at': expires_at}
def _expiry_ms_from_duration_days(duration_days: int) -> int:
"""3x-ui expiryTime is unix ms; 0 means no expiry. Starts now (on redeem)."""
days = int(duration_days or 0)
@@ -5922,6 +6180,12 @@ async def _create_config_for_protocol(
) -> dict:
"""Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}."""
protocol = protocol or 'xui'
if protocol_base(protocol) == 'aivpn':
from managers.aivpn_manager import resolve_provision_protocol
sid = int(server_id or 0)
if sid >= len(data.get('servers') or []):
raise RuntimeError('Server not found')
protocol = resolve_provision_protocol(data['servers'][sid], 'aivpn')
if protocol_base(protocol) == 'xui':
from managers.xui_api import xui_create_vless_config
from managers.xui_servers import get_xui_server, ensure_xui_servers
@@ -6064,11 +6328,12 @@ async def api_create_invite(request: Request, req: InviteCreateRequest):
'expires_at': None,
'duration_days': int(req.duration_days or 0),
'note': req.note or '',
'allow_server_choice': bool(req.allow_server_choice),
'created_at': datetime.now().isoformat(),
}
data.setdefault('invite_links', []).append(link)
save_data(data)
view = _invite_public_view(link)
view = _invite_public_view(link, data)
return {'status': 'success', 'invite': view, 'url': f"/invite/{link['token']}"}
@@ -6110,6 +6375,8 @@ async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateR
link['note'] = req.note
if req.enabled is not None:
link['enabled'] = bool(req.enabled)
if req.allow_server_choice is not None:
link['allow_server_choice'] = bool(req.allow_server_choice)
if req.reset_used:
link['used_count'] = 0
if protocol_base(link.get('protocol') or 'xui') == 'xui':
@@ -6125,7 +6392,7 @@ async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateR
if not int(link.get('xui_inbound_id') or 0):
return JSONResponse({'error': 'Select a VLESS inbound from 3x-ui'}, status_code=400)
save_data(data)
return {'status': 'success', 'invite': _invite_public_view(link)}
return {'status': 'success', 'invite': _invite_public_view(link, data)}
@app.delete('/api/invites/{invite_id}', tags=["Invites"])
@@ -6151,7 +6418,7 @@ async def invite_public_page(token: str, request: Request):
f"<h1>{_t('invite_not_found', lang)}</h1><p>{_t('invite_not_found_desc', lang)}</p>",
status_code=404,
)
view = _invite_public_view(link)
view = _invite_public_view(link, data)
need_password = bool(link.get('password_hash')) and not _invite_auth_ok(link, request)
return tpl(
request,
@@ -6186,7 +6453,7 @@ async def api_invite_info(token: str, request: Request):
return JSONResponse({'error': 'Not found'}, status_code=404)
if not _invite_auth_ok(link, request):
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
view = _invite_public_view(link)
view = _invite_public_view(link, data)
# Don't leak admin note / ids beyond what's needed
return {
'name': view['name'],
@@ -6199,9 +6466,64 @@ async def api_invite_info(token: str, request: Request):
'max_uses': view['max_uses'],
'used_count': view['used_count'],
'protocol': view['protocol'],
'allow_server_choice': view['allow_server_choice'],
'server_id': view['server_id'],
'servers': view.get('servers') or [],
'duration_days': view['duration_days'],
}
@app.get('/api/invite/{token}/connections', tags=["Invites"])
async def api_invite_connections(token: str, request: Request):
"""List configs created via this invite (re-copy without consuming another use)."""
data = load_data()
link = _find_invite(data, token)
if not link:
return JSONResponse({'error': 'Not found'}, status_code=404)
if not _invite_auth_ok(link, request):
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
invite_id = link.get('id')
holder_id = link.get('user_id') or ''
conns = [
_enrich_invite_conn(c, data)
for c in data.get('user_connections', [])
if c.get('invite_id') == invite_id and (not holder_id or c.get('user_id') == holder_id)
]
conns.sort(key=lambda c: c.get('created_at') or '', reverse=True)
return {
'connections': conns,
'invite': _invite_public_view(link, data),
}
@app.post('/api/invite/{token}/config/{connection_id}', tags=["Invites"])
async def api_invite_config(token: str, connection_id: str, request: Request):
data = load_data()
link = _find_invite(data, token)
if not link:
return JSONResponse({'error': 'Not found'}, status_code=404)
if not _invite_auth_ok(link, request):
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
invite_id = link.get('id')
holder_id = link.get('user_id') or ''
conn = next(
(
c for c in data.get('user_connections', [])
if c.get('id') == connection_id
and c.get('invite_id') == invite_id
and (not holder_id or c.get('user_id') == holder_id)
),
None,
)
if not conn:
return JSONResponse({'error': 'Not found'}, status_code=404)
try:
return await _fetch_connection_config_payload(data, conn, expires_at=conn.get('expires_at'))
except Exception as e:
logger.exception('Error getting invite config')
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/invite/{token}/create', tags=["Invites"])
async def api_invite_create_config(token: str, req: InviteRedeemRequest, request: Request):
data = load_data()
@@ -6211,7 +6533,7 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request
if not _invite_auth_ok(link, request):
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
view = _invite_public_view(link)
view = _invite_public_view(link, data)
if not view['available']:
if view['expired']:
return JSONResponse({'error': 'Invite link expired'}, status_code=403)
@@ -6223,13 +6545,29 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request
if not holder_id or not any(u['id'] == holder_id for u in data['users']):
return JSONResponse({'error': 'Invite holder user is not configured'}, status_code=400)
protocol = link.get('protocol') or 'xui'
allow_choice = bool(link.get('allow_server_choice', True))
try:
if protocol_base(protocol) == 'xui':
sid = int(link.get('server_id') or 0)
else:
sid = _resolve_chosen_server_id(
data,
protocol=protocol,
default_server_id=int(link.get('server_id') or 0),
requested_server_id=req.server_id,
allow_choice=allow_choice,
)
except ValueError as ve:
return JSONResponse({'error': str(ve)}, status_code=400)
# Reserve a use slot under lock
async with DATA_LOCK:
data = load_data()
link = _find_invite(data, token)
if not link:
return JSONResponse({'error': 'Not found'}, status_code=404)
view = _invite_public_view(link)
view = _invite_public_view(link, data)
if not view['available']:
return JSONResponse({'error': 'Invite link is no longer available'}, status_code=403)
link['used_count'] = int(link.get('used_count') or 0) + 1
@@ -6239,11 +6577,17 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request
name = f"{name}_{secrets.token_hex(3)}"
try:
data = load_data()
protocol = link.get('protocol') or 'xui'
if protocol_base(protocol) == 'aivpn' and sid < len(data.get('servers') or []):
from managers.aivpn_manager import resolve_provision_protocol
protocol = resolve_provision_protocol(data['servers'][sid], 'aivpn')
elif protocol_base(protocol) != 'xui' and sid < len(data.get('servers') or []):
protocol = _match_protocol_on_server(data['servers'][sid], protocol)
created = await _create_config_for_protocol(
data,
protocol=link.get('protocol') or 'xui',
protocol=protocol,
name=name,
server_id=int(link.get('server_id') or 0),
server_id=sid,
xui_inbound_id=int(link.get('xui_inbound_id') or 0) or None,
xui_panel_id=link.get('xui_panel_id') or None,
duration_days=int(link.get('duration_days') or 0),
@@ -6255,16 +6599,18 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request
'protocol': created['protocol'],
'client_id': created['client_id'],
'name': name,
'invite_id': link.get('id'),
'xui_panel_id': created.get('xui_panel_id') or link.get('xui_panel_id') or '',
'created_at': datetime.now().isoformat(),
}
if created.get('expires_at'):
conn['expires_at'] = created['expires_at']
async with DATA_LOCK:
data = load_data()
data.setdefault('user_connections', []).append(conn)
save_data(data)
config = created.get('config') or ''
subscription_url = created.get('subscription_url') or ''
# For subscription URLs, vpn_link is the same shareable string
vpn_link = subscription_url or (generate_vpn_link(config) if config else '')
data = load_data()
link = _find_invite(data, token) or link
@@ -6274,8 +6620,8 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request
'subscription_url': subscription_url,
'vpn_link': vpn_link,
'expires_at': created.get('expires_at'),
'connection': conn,
'invite': _invite_public_view(link),
'connection': _enrich_invite_conn(conn, data),
'invite': _invite_public_view(link, data),
}
except Exception as e:
# Roll back reserved use
+245
View File
@@ -0,0 +1,245 @@
"""AIVPN — heuristic protocol picker for Amnezia Web Panel.
Picks the best installed VPN protocol on a server by strategy
(stealth / balanced / speed), optionally probing TCP reachability of ports.
This is panel-side selection (no remote AI daemon): invites, guest create,
and manual "pick now" use the same scorer.
"""
from __future__ import annotations
import logging
import socket
import time
from typing import Any, Optional
logger = logging.getLogger(__name__)
CLIENT_VPN_BASES = frozenset({
'awg', 'awg2', 'awg_legacy', 'wireguard',
'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru',
})
# Higher = preferred for that strategy (0..100 base).
STRATEGY_WEIGHTS = {
'stealth': {
'xray': 100,
'mieru': 96,
'hysteria': 92,
'naiveproxy': 88,
'telemt': 70,
'awg2': 58,
'awg': 52,
'awg_legacy': 48,
'wireguard': 40,
},
'speed': {
'awg2': 100,
'awg': 96,
'wireguard': 92,
'hysteria': 86,
'mieru': 78,
'xray': 70,
'naiveproxy': 62,
'awg_legacy': 58,
'telemt': 45,
},
'balanced': {
'hysteria': 94,
'mieru': 92,
'xray': 90,
'awg2': 88,
'awg': 82,
'naiveproxy': 78,
'wireguard': 70,
'telemt': 65,
'awg_legacy': 60,
},
}
STRATEGIES = frozenset(STRATEGY_WEIGHTS.keys())
def protocol_base(protocol: str) -> str:
return str(protocol or '').split('__', 1)[0]
def get_aivpn_settings(server: dict) -> dict:
info = server.get('server_info') or {}
raw = info.get('aivpn') if isinstance(info, dict) else None
if not isinstance(raw, dict):
raw = {}
strategy = str(raw.get('strategy') or 'balanced').lower()
if strategy not in STRATEGIES:
strategy = 'balanced'
return {
'enabled': bool(raw.get('enabled')),
'strategy': strategy,
'probe': bool(raw.get('probe', True)),
}
def set_aivpn_settings(server: dict, *, enabled: Optional[bool] = None,
strategy: Optional[str] = None,
probe: Optional[bool] = None) -> dict:
info = dict(server.get('server_info') or {})
cur = get_aivpn_settings(server)
if enabled is not None:
cur['enabled'] = bool(enabled)
if strategy is not None:
s = str(strategy).lower()
cur['strategy'] = s if s in STRATEGIES else 'balanced'
if probe is not None:
cur['probe'] = bool(probe)
info['aivpn'] = cur
server['server_info'] = info
return cur
def _tcp_rtt_ms(host: str, port: int, timeout: float = 1.2) -> Optional[float]:
if not host or not port:
return None
try:
port = int(port)
except (TypeError, ValueError):
return None
if port < 1 or port > 65535:
return None
t0 = time.perf_counter()
try:
with socket.create_connection((host, port), timeout=timeout):
return round((time.perf_counter() - t0) * 1000, 1)
except OSError:
return None
def _candidate_protocols(server: dict) -> list[str]:
protocols = server.get('protocols') or {}
out = []
for key, info in protocols.items():
if not isinstance(info, dict):
continue
if not info.get('installed'):
continue
if protocol_base(key) not in CLIENT_VPN_BASES:
continue
out.append(key)
return out
def score_protocols(
server: dict,
*,
strategy: str = 'balanced',
probe: bool = False,
live_status: Optional[dict] = None,
) -> list[dict[str, Any]]:
"""Return ranked protocol candidates with scores and reasons."""
strategy = strategy if strategy in STRATEGIES else 'balanced'
weights = STRATEGY_WEIGHTS[strategy]
host = (server.get('host') or '').strip()
protocols = server.get('protocols') or {}
live_status = live_status or {}
ranked = []
for key in _candidate_protocols(server):
base = protocol_base(key)
info = protocols.get(key) or {}
live = live_status.get(key) or {}
reasons = []
score = float(weights.get(base, 50))
reasons.append(f'base:{strategy}={int(score)}')
running = bool(live.get('container_running') or live.get('running'))
exists = bool(
live.get('container_exists')
or live.get('installed')
or info.get('installed')
)
if running:
score += 18
reasons.append('+running')
elif exists:
score -= 8
reasons.append('-not_running')
else:
score -= 40
reasons.append('-missing')
port = live.get('port') or info.get('port')
rtt = None
if probe and host and port and base != 'telemt':
# Telemt often shares 443 with other services; skip noisy probes.
rtt = _tcp_rtt_ms(host, int(port))
if rtt is None:
score -= 25
reasons.append('-port_unreachable')
elif rtt < 40:
score += 12
reasons.append(f'+rtt:{rtt}ms')
elif rtt < 120:
score += 6
reasons.append(f'+rtt:{rtt}ms')
elif rtt < 250:
reasons.append(f'rtt:{rtt}ms')
else:
score -= 8
reasons.append(f'-slow:{rtt}ms')
ranked.append({
'protocol': key,
'base': base,
'score': round(score, 1),
'port': port,
'running': running,
'rtt_ms': rtt,
'reasons': reasons,
})
ranked.sort(key=lambda x: (-x['score'], x['protocol']))
return ranked
def pick_protocol(
server: dict,
*,
strategy: Optional[str] = None,
probe: Optional[bool] = None,
live_status: Optional[dict] = None,
) -> Optional[dict[str, Any]]:
cfg = get_aivpn_settings(server)
strat = strategy or cfg['strategy']
do_probe = cfg['probe'] if probe is None else bool(probe)
ranked = score_protocols(
server,
strategy=strat,
probe=do_probe,
live_status=live_status,
)
if not ranked:
return None
best = ranked[0]
return {
'protocol': best['protocol'],
'base': best['base'],
'score': best['score'],
'strategy': strat if strat in STRATEGIES else 'balanced',
'port': best.get('port'),
'rtt_ms': best.get('rtt_ms'),
'reasons': best.get('reasons') or [],
'alternatives': ranked[1:5],
'all': ranked,
}
def resolve_provision_protocol(server: dict, requested: Optional[str] = None) -> str:
"""If requested is 'aivpn' (or empty while AIVPN enabled), pick automatically."""
req = (requested or '').strip()
cfg = get_aivpn_settings(server)
if req and protocol_base(req) != 'aivpn':
return req
if req == 'aivpn' or (not req and cfg.get('enabled')):
picked = pick_protocol(server, probe=False)
if picked and picked.get('protocol'):
return picked['protocol']
return req or 'awg'
+487 -135
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 = 8443
def __init__(self, ssh_manager, protocol='xray'):
self.ssh = ssh_manager
@@ -148,6 +160,11 @@ class XrayManager:
def get_server_status(self, protocol):
exists = self.check_protocol_installed()
if exists:
try:
self._heal_xhttp_config()
except Exception as e:
logger.warning(f"Xray config heal skipped: {e}")
running = self.check_container_running()
clients = self.get_clients() if exists else []
meta = self._get_meta_json() if exists else {}
@@ -155,35 +172,309 @@ 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',
'acme_method': meta.get('acme_method'),
'path': meta.get('path'),
}
def install_protocol(self, port=443, site_name='yahoo.com'):
"""Full installation of Xray."""
def _normalize_xhttp_headers_in_config(self, config):
"""Xray xhttp headers must be map[string]string — arrays crash the process."""
changed = False
for inbound in (config.get('inbounds') or []):
stream = inbound.get('streamSettings') or {}
for key in ('xhttpSettings', 'splithttpSettings'):
xs = stream.get(key)
if not isinstance(xs, dict):
continue
headers = xs.get('headers')
if headers is None:
continue
if not isinstance(headers, dict):
xs.pop('headers', None)
changed = True
continue
fixed = {}
for hk, hv in headers.items():
if isinstance(hv, str):
fixed[hk] = hv
continue
changed = True
if isinstance(hv, list) and hv:
fixed[hk] = str(hv[0])
elif hv is not None and not isinstance(hv, (dict, list)):
fixed[hk] = str(hv)
if fixed:
xs['headers'] = fixed
else:
xs.pop('headers', None)
return changed
def _normalize_xhttp_stream_for_compat(self, config):
"""Make inbound XHTTP+TLS closer to known-working minimal configs."""
changed = self._normalize_xhttp_headers_in_config(config)
for inbound in (config.get('inbounds') or []):
if inbound.get('protocol') != 'vless':
continue
stream = inbound.get('streamSettings') or {}
network = str(stream.get('network') or '').lower()
security = str(stream.get('security') or '').lower()
if network not in ('xhttp', 'splithttp') or security != 'tls':
continue
# Prefer canonical network name
if network == 'splithttp':
stream['network'] = 'xhttp'
changed = True
xs_key = 'xhttpSettings' if 'xhttpSettings' in stream else (
'splithttpSettings' if 'splithttpSettings' in stream else 'xhttpSettings'
)
xs = stream.get(xs_key)
if not isinstance(xs, dict):
xs = {}
stream[xs_key] = xs
changed = True
# Migrate splithttpSettings → xhttpSettings
if xs_key == 'splithttpSettings':
stream['xhttpSettings'] = xs
stream.pop('splithttpSettings', None)
changed = True
# Server-side host rejects mismatched Host headers on some clients
if 'host' in xs:
xs.pop('host', None)
changed = True
if 'headers' in xs:
xs.pop('headers', None)
changed = True
if not xs.get('path'):
xs['path'] = '/'
changed = True
if xs.get('mode') not in (None, '', 'auto', 'packet-up', 'stream-up', 'stream-one'):
xs['mode'] = 'auto'
changed = True
elif not xs.get('mode'):
xs['mode'] = 'auto'
changed = True
tls = stream.get('tlsSettings')
if not isinstance(tls, dict):
tls = {}
stream['tlsSettings'] = tls
changed = True
# minVersion / serverName are optional and can hurt older clients
if 'minVersion' in tls:
tls.pop('minVersion', None)
changed = True
alpn = tls.get('alpn')
if not isinstance(alpn, list) or not alpn:
tls['alpn'] = ['h2', 'http/1.1']
changed = True
sockopt = stream.get('sockopt')
if isinstance(sockopt, dict):
# TCP Fast Open frequently breaks mobile/CGNAT paths
if sockopt.pop('tcpFastOpen', None) is not None:
changed = True
if not sockopt:
stream.pop('sockopt', None)
changed = True
inbound['streamSettings'] = stream
return changed
def _heal_xhttp_config(self):
"""Fix crash/compat issues in existing XHTTP+TLS server.json."""
config = self._get_server_json()
if not config or not self._normalize_xhttp_stream_for_compat(config):
return False
path = self._config_path()
self.ssh.upload_file_sudo(json.dumps(config, indent=2), path)
self.ssh.run_sudo_command(
f"docker cp {_q(path)} {self.container_name}:{path} 2>/dev/null || true"
)
self.ssh.run_sudo_command(
f"docker restart {self.container_name} 2>/dev/null || docker start {self.container_name} 2>/dev/null || true"
)
logger.info("Healed Xray XHTTP+TLS stream settings for client compatibility")
return True
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):
# Short opaque path — better client compatibility than nested “asset” URLs.
return f'/{secrets.token_hex(8)}'
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=8443, 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)
if port < 1 or port > 65535:
raise ValueError('Port must be between 1 and 65535')
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 +482,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 +506,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 +532,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
},
"inbounds": [
{
"listen": "0.0.0.0",
"port": int(port),
"protocol": "vless",
"tag": "proxy",
@@ -268,14 +541,24 @@ 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": {
"alpn": ["h2", "http/1.1"],
"certificates": [{
"certificateFile": self._cert_path(),
"keyFile": self._key_path()
}]
},
"xhttpSettings": {
"path": xhttp_path,
"mode": xhttp_mode
}
},
"sniffing": {
"enabled": True,
"destOverride": ["http", "tls", "quic"],
"routeOnly": True
}
},
{
@@ -286,13 +569,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 +592,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,http/1.1',
'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 +618,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}")
@@ -354,18 +663,21 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
return self._write_server_json(data, restart=True)
def _write_server_json(self, data, restart=True):
"""Write server.json into container via docker cp AND sync to host path."""
"""Write server.json to host path and into container when possible."""
if self._normalize_xhttp_stream_for_compat(data):
logger.info("Normalized XHTTP+TLS stream settings before write")
tmp_file = "/tmp/_xray_server.json"
path = self._config_path()
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
# Host path first — volume mount survives crash loops
self.ssh.run_sudo_command(f"cp {tmp_file} {path}")
self.ssh.run_sudo_command(
f"docker cp {tmp_file} {self.container_name}:{self._config_path()}"
)
# Also keep host copy in sync (handles both volume-mount and no-mount installs)
self.ssh.run_sudo_command(
f"cp {tmp_file} {self._config_path()} 2>/dev/null || true"
f"docker cp {tmp_file} {self.container_name}:{path} 2>/dev/null || true"
)
if restart:
self.ssh.run_sudo_command(f"docker restart {self.container_name}")
self.ssh.run_sudo_command(
f"docker restart {self.container_name} 2>/dev/null || docker start {self.container_name} 2>/dev/null || true"
)
def _get_vless_inbound(self, config):
for inbound in config.get('inbounds', []):
@@ -431,28 +743,65 @@ 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 = {}
meta = {}
out = self._read_remote_file(f"{self._config_dir()}/meta.json")
if out:
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 {}
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,http/1.1'
# Prefer TLS ALPN from live server config when present
tls_alpn = tls.get('alpn')
if isinstance(tls_alpn, list) and tls_alpn:
meta['alpn'] = ','.join(str(x) for x in tls_alpn if x)
return meta
# Legacy Reality
rs = stream.get('realitySettings') or {}
names = rs.get('serverNames') or []
if names:
site_name = names[0]
except StopIteration:
pass
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 +814,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 +1016,63 @@ 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 or '').strip()
path = meta.get('path') or '/'
if not str(path).startswith('/'):
path = '/' + str(path)
# TLS+XHTTP resolves auto → packet-up; pin it for older clients.
mode = meta.get('mode') or 'auto'
if str(mode).lower() in ('auto', ''):
mode = 'packet-up'
fp = meta.get('fingerprint') or 'chrome'
alpn = meta.get('alpn') or 'h2,http/1.1'
if isinstance(alpn, list):
alpn = ','.join(str(x) for x in alpn if x)
# Dial address: panel connect host (IP/domain). TLS identity: cert domain.
dial_host = (server_host or domain).strip()
path_q = urllib.parse.quote(str(path), safe='/')
return (
f"vless://{client_id}@{dial_host}:{listen_port}"
f"?encryption=none&security=tls&type=xhttp"
f"&path={path_q}"
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 +1080,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 +1091,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 +1115,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)
+14
View File
@@ -1162,6 +1162,20 @@ a:hover {
text-shadow: 0 0 16px rgba(168, 85, 247, 0.6);
}
.promo-aivpn .aivpn-panel .btn-primary {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.35);
color: #fff;
}
.promo-aivpn .aivpn-panel .btn-primary:hover {
background: rgba(255, 255, 255, 0.3);
}
.promo-aivpn .aivpn-panel .btn-secondary {
background: rgba(0, 0, 0, 0.25);
border-color: rgba(255, 255, 255, 0.25);
color: #fff;
}
/* ----- Theme: Reverse Proxy (amber/red, shield/firewall feel) ----- */
.promo-revproxy {
background:
+28 -1
View File
@@ -29,6 +29,10 @@
<div id="connectionsList">
{% if allow_create %}
<div class="share-create">
<div id="guestServerPickWrap" class="form-group" style="text-align:left; margin-bottom:var(--space-sm); display:none;">
<label class="form-label">{{ _('choose_server') }}</label>
<select class="form-select" id="guestServerPick"></select>
</div>
<button class="btn btn-secondary share-btn-lg" type="button" onclick="createGuestConfig()" id="createBtn">
<span id="createBtnText">{{ _('guest_get_config') }}</span>
<div class="spinner hidden" id="createSpinner"></div>
@@ -178,6 +182,23 @@
const data = await res.json();
document.getElementById('loadingState').classList.add('hidden');
const wrap = document.getElementById('guestServerPickWrap');
const sel = document.getElementById('guestServerPick');
if (wrap && sel && data.allow_server_choice && (data.servers || []).length) {
const prev = sel.value;
sel.innerHTML = '';
(data.servers || []).forEach(s => {
const opt = document.createElement('option');
opt.value = String(s.id);
opt.textContent = s.host ? `${s.name} (${s.host})` : s.name;
if (String(s.id) === String(prev) || String(s.id) === String(data.default_server_id)) opt.selected = true;
sel.appendChild(opt);
});
wrap.style.display = '';
} else if (wrap) {
wrap.style.display = 'none';
}
if (!data.connections || data.connections.length === 0) {
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('connectionsGrid').classList.add('hidden');
@@ -221,10 +242,16 @@
text.classList.add('hidden');
spinner.classList.remove('hidden');
try {
const body = { name: 'Guest VPN' };
const wrap = document.getElementById('guestServerPickWrap');
const sel = document.getElementById('guestServerPick');
if (wrap && wrap.style.display !== 'none' && sel && sel.value !== '') {
body.server_id = parseInt(sel.value, 10);
}
const res = await fetch(`/api/guest/${TOKEN}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Guest VPN' })
body: JSON.stringify(body)
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || _('error'));
+262 -85
View File
@@ -1,39 +1,39 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon %}
{% block title_extra %} — {{ _('invite_public_title') }}{% endblock %}
{% block content %}
<div class="card" style="max-width: 520px; margin: 2.5rem auto; overflow:hidden;">
<div style="padding: var(--space-xl) var(--space-lg) var(--space-md); text-align:center; background: linear-gradient(180deg, rgba(59,130,246,0.12), transparent);">
<div style="width:56px;height:56px;border-radius:16px;margin:0 auto var(--space-md);display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:1.6rem;">🔗</div>
<h2 class="card-title" style="margin-bottom:6px;">{{ invite.name }}</h2>
<p style="color: var(--text-muted); font-size: 0.92rem; margin:0;">{{ _('invite_public_subtitle') }}</p>
</div>
<div class="share-page">
<header class="share-hero">
<h1 class="share-title">{{ invite.name }}</h1>
<p class="share-user">{{ _('invite_public_subtitle') }}</p>
{% if not need_password %}
<p class="share-hint">{{ _('share_copy_hint') }}</p>
{% endif %}
</header>
{% if need_password %}
<div style="padding: var(--space-lg); text-align: center;">
<p style="margin-bottom: var(--space-md);">{{ _('invite_protected_desc') }}</p>
<form id="authForm" onsubmit="authInvite(event)"
style="display: flex; flex-direction: column; gap: var(--space-md); max-width: 300px; margin: 0 auto;">
<div class="form-group">
<div class="share-auth card">
<div class="share-auth-icon">{{ icon('lock') }}</div>
<p>{{ _('invite_protected_desc') }}</p>
<form id="authForm" onsubmit="authInvite(event)" class="share-auth-form">
<input type="password" id="invitePassword" class="form-input" placeholder="{{ _('password') }}" required autofocus>
</div>
<button type="submit" class="btn btn-primary" id="authBtn">
<button type="submit" class="btn btn-primary share-btn-lg" id="authBtn">
<span id="authBtnText">{{ _('login') }}</span>
<div class="spinner hidden" id="authSpinner"></div>
</button>
</form>
</div>
{% else %}
<div style="padding: var(--space-lg);">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm); margin-bottom:var(--space-lg);">
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-md); text-align:center;">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm); margin-bottom:var(--space-md);">
<div style="background:var(--bg-card); border-radius:var(--radius-md); padding:var(--space-md); text-align:center;">
<div style="font-size:0.72rem; color:var(--text-muted); text-transform:uppercase;">{{ _('invite_uses') }}</div>
<div id="usesValue" style="font-weight:700; font-size:1.15rem; margin-top:4px;">
{% if invite.unlimited %}∞{% else %}{{ invite.remaining }}{% endif %}
</div>
</div>
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-md); text-align:center;">
<div style="background:var(--bg-card); border-radius:var(--radius-md); padding:var(--space-md); text-align:center;">
<div style="font-size:0.72rem; color:var(--text-muted); text-transform:uppercase;">{{ _('invite_duration_short') }}</div>
<div style="font-weight:700; font-size:1.15rem; margin-top:4px;">
{% if invite.duration_days %}{{ invite.duration_days }} {{ _('days_short') }}{% else %}∞{% endif %}
@@ -49,55 +49,57 @@
{% endif %}
</p>
{% if invite.available %}
<button class="btn btn-primary" style="width:100%;" onclick="createInviteConfig()" id="createBtn">
<div id="createBlock" {% if not invite.available %}style="display:none;"{% endif %}>
<div id="serverPickWrap" class="form-group" style="margin-bottom:var(--space-md); {% if not invite.allow_server_choice or not invite.servers %}display:none;{% endif %}">
<label class="form-label">{{ _('choose_server') }}</label>
<select class="form-select" id="inviteServerPick">
{% for s in invite.servers or [] %}
<option value="{{ s.id }}" {% if s.id == invite.server_id %}selected{% endif %}>{{ s.name }}{% if s.host %} ({{ s.host }}){% endif %}</option>
{% endfor %}
</select>
</div>
<button class="btn btn-primary share-btn-lg" style="width:100%;" onclick="createInviteConfig()" id="createBtn">
<span id="createBtnText">{{ _('guest_get_config') }}</span>
<div class="spinner hidden" id="createSpinner" style="width:14px;height:14px;"></div>
</button>
{% elif invite.exhausted %}
<p style="text-align:center; color:#ef4444; margin:0;">{{ _('invite_exhausted') }}</p>
{% else %}
<p style="text-align:center; color:#ef4444; margin:0;">{{ _('disabled') }}</p>
{% endif %}
</div>
<p id="exhaustedMsg" style="text-align:center; color:#ef4444; margin:0 0 var(--space-md); {% if invite.available or not invite.exhausted %}display:none;{% endif %}">{{ _('invite_exhausted') }}</p>
<p id="disabledMsg" style="text-align:center; color:#ef4444; margin:0 0 var(--space-md); {% if invite.available or invite.exhausted %}display:none;{% endif %}">{{ _('disabled') }}</p>
<div class="share-loading" id="loadingState" style="margin-top:var(--space-lg);">
<div class="spinner share-spinner"></div>
<p>{{ _('loading_share_conns') }}</p>
</div>
<div id="connectionsGrid" class="share-grid hidden"></div>
<div id="emptyState" class="share-empty hidden">
<p>{{ _('invite_no_saved_configs') }}</p>
</div>
{% endif %}
</div>
<div class="modal-backdrop" id="configModal">
<div class="modal" style="max-width: 600px;">
<div class="modal share-modal">
<div class="modal-header">
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
<button class="modal-close" onclick="closeModal('configModal')">×</button>
<button class="modal-close" onclick="closeModal('configModal')" type="button">&times;</button>
</div>
<div id="expiresBanner" class="hidden" style="margin:0 var(--space-md) var(--space-sm); padding:var(--space-sm) var(--space-md); border-radius:var(--radius-md); background:rgba(59,130,246,0.1); color:var(--text-muted); font-size:0.85rem;"></div>
<div class="config-tabs">
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('invite_sub_tab') }}</button>
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
<div class="share-modal-body">
<button type="button" class="btn btn-primary share-btn-lg" id="modalCopyBtn" onclick="copyCurrentKey()">
{{ icon('copy') }}
<span>{{ _('copy_key_big') }}</span>
</button>
<div class="share-qr-wrap">
<div id="qrcode"></div>
<p class="share-qr-caption">{{ _('qr_code_tab') }}</p>
</div>
<div class="config-panel active" id="panel-conf">
<div class="config-display">
<textarea class="config-text" id="configText" readonly rows="8"
style="width:100%; border:none; background:transparent; color:inherit; font-family:monospace; resize:none; outline:none;"></textarea>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" onclick="copyConfig()" style="flex:1">{{ _('copy_config') }}</button>
<a id="downloadBtn" class="btn btn-primary btn-sm"
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center;">
{{ _('download_conf') }}
</a>
<div class="share-extra">
<button type="button" class="btn btn-secondary w-full" id="downloadBtn">{{ _('download_config_file') }}</button>
<button type="button" class="share-link-btn" onclick="toggleConfigText()">{{ _('show_config_text') }}</button>
<textarea class="config-text share-config-text hidden" id="configText" readonly rows="8"></textarea>
</div>
</div>
</div>
<div class="config-panel" id="panel-vpn">
<div class="vpn-link-box" id="vpnLinkText" style="min-height: 100px;"></div>
<div class="config-actions" style="margin-top:var(--space-sm);">
<button class="btn btn-secondary btn-sm" onclick="copyVpnLink()" style="flex:1">{{ _('copy_key') }}</button>
</div>
</div>
<div class="config-panel" id="panel-qr">
<div class="qr-container"><div id="qrcode"></div></div>
</div>
</div>
</div>
<script>
@@ -105,6 +107,41 @@
let currentConfig = '';
let currentVpnLink = '';
function escAttr(s) {
return String(s || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
function escHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function protoLabel(p) {
const base = String(p || '').split('__')[0];
const map = {
awg: 'AmneziaWG', awg2: 'AmneziaWG 2.0', awg_legacy: 'AWG Legacy',
xray: 'Xray', xui: '3x-ui VLESS', hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy', mieru: 'Mieru', telemt: 'Telemt', wireguard: 'WireGuard',
};
return map[base] || (base || '').toUpperCase();
}
function pickKey(config, vpnLink) {
const link = (vpnLink || '').trim();
return link || (config || '').trim();
}
async function copyKeyText(text) {
if (!text) throw new Error(_('error'));
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
showToast(_('key_copied'), 'success');
}
async function authInvite(e) {
e.preventDefault();
const password = document.getElementById('invitePassword').value;
@@ -132,22 +169,12 @@
}
}
function switchConfigTab(tab) {
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
const tabs = document.querySelectorAll('.config-tab');
const panels = { conf: 'panel-conf', vpn: 'panel-vpn', qr: 'panel-qr' };
const tabIdx = { conf: 0, vpn: 1, qr: 2 };
tabs[tabIdx[tab]].classList.add('active');
document.getElementById(panels[tab]).classList.add('active');
}
function openConfigModal(name, config, vpnLink, expiresAt) {
currentConfig = config;
currentVpnLink = vpnLink || config || '';
document.getElementById('configText').value = config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
function fillModal(name, config, vpnLink, expiresAt) {
currentConfig = config || '';
currentVpnLink = vpnLink || '';
document.getElementById('configModalTitle').textContent = name;
document.getElementById('configText').value = currentConfig;
document.getElementById('configText').classList.add('hidden');
const banner = document.getElementById('expiresBanner');
if (expiresAt) {
banner.textContent = _('invite_config_expires_at').replace('{}', new Date(expiresAt).toLocaleString());
@@ -155,29 +182,142 @@
} else {
banner.classList.add('hidden');
}
document.getElementById('downloadBtn').onclick = () => downloadFile(config, `${name}.txt`);
document.getElementById('downloadBtn').onclick = () => downloadFile(currentConfig, `${name || 'vpn'}.conf`);
const qrContainer = document.getElementById('qrcode');
qrContainer.innerHTML = '';
const qrPayload = pickKey(currentConfig, currentVpnLink) || currentConfig;
if (qrPayload && typeof QRCode !== 'undefined') {
new QRCode(qrContainer, {
text: currentVpnLink || config, width: 256, height: 256,
text: qrPayload, width: 220, height: 220,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L
});
switchConfigTab('conf');
openModal('configModal');
}
}
function copyConfig() { copyToClipboard(currentConfig); }
function copyVpnLink() { copyToClipboard(currentVpnLink); }
async function copyCurrentKey() {
try { await copyKeyText(pickKey(currentConfig, currentVpnLink)); }
catch (err) { alert(`${_('error')}: ` + err.message); }
}
function toggleConfigText() {
document.getElementById('configText').classList.toggle('hidden');
}
function updateStatus(invite) {
if (!invite) return;
const uses = document.getElementById('usesValue');
if (uses && invite) {
uses.textContent = invite.unlimited ? '∞' : String(invite.remaining ?? 0);
if (uses) uses.textContent = invite.unlimited ? '∞' : String(invite.remaining ?? 0);
const createBlock = document.getElementById('createBlock');
const exhaustedMsg = document.getElementById('exhaustedMsg');
const disabledMsg = document.getElementById('disabledMsg');
if (invite.available) {
if (createBlock) createBlock.style.display = '';
if (exhaustedMsg) exhaustedMsg.style.display = 'none';
if (disabledMsg) disabledMsg.style.display = 'none';
} else {
if (createBlock) createBlock.style.display = 'none';
if (invite.exhausted) {
if (exhaustedMsg) exhaustedMsg.style.display = '';
if (disabledMsg) disabledMsg.style.display = 'none';
} else {
if (exhaustedMsg) exhaustedMsg.style.display = 'none';
if (disabledMsg) disabledMsg.style.display = '';
}
if (invite && !invite.available) {
const btn = document.getElementById('createBtn');
if (btn) btn.style.display = 'none';
}
const wrap = document.getElementById('serverPickWrap');
const sel = document.getElementById('inviteServerPick');
if (wrap && sel && invite.allow_server_choice && (invite.servers || []).length) {
const prev = sel.value;
sel.innerHTML = '';
(invite.servers || []).forEach(s => {
const opt = document.createElement('option');
opt.value = String(s.id);
opt.textContent = s.host ? `${s.name} (${s.host})` : s.name;
if (String(s.id) === String(prev) || String(s.id) === String(invite.server_id)) opt.selected = true;
sel.appendChild(opt);
});
wrap.style.display = '';
} else if (wrap) {
wrap.style.display = 'none';
}
}
async function fetchInviteConfig(connId) {
const res = await fetch(`/api/invite/${TOKEN}/config/${connId}`, { method: 'POST' });
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || _('error'));
return data;
}
async function loadConnections() {
const loading = document.getElementById('loadingState');
if (!loading) return;
try {
const res = await fetch(`/api/invite/${TOKEN}/connections`);
if (res.status === 401) return;
const data = await res.json();
loading.classList.add('hidden');
if (data.invite) updateStatus(data.invite);
const grid = document.getElementById('connectionsGrid');
const empty = document.getElementById('emptyState');
if (!data.connections || !data.connections.length) {
empty.classList.remove('hidden');
grid.classList.add('hidden');
grid.innerHTML = '';
return;
}
empty.classList.add('hidden');
grid.classList.remove('hidden');
grid.innerHTML = data.connections.map(c => `
<article class="share-card" data-conn-id="${escHtml(c.id)}">
<div class="share-card-top">
<div>
<div class="share-card-name">${escHtml(c.name)}</div>
<div class="share-card-meta">${escHtml(protoLabel(c.protocol))} · ${escHtml(c.server_name || '')}</div>
</div>
<span class="badge badge-success share-badge">${_('active')}</span>
</div>
<button type="button" class="btn btn-primary share-btn-lg share-copy-btn"
onclick="copyKeyFromCard(this, '${escAttr(c.id)}')">
${typeof uiIcon === 'function' ? uiIcon('copy') : '📋'}
<span class="share-copy-label">${_('copy_key_big')}</span>
</button>
<button type="button" class="share-link-btn"
onclick="showDetails('${escAttr(c.id)}', '${escAttr(c.name)}')">
${_('more_qr_download')}
</button>
</article>
`).join('');
} catch (err) {
console.error(err);
loading.classList.add('hidden');
}
}
async function copyKeyFromCard(btn, connId) {
const label = btn.querySelector('.share-copy-label');
const prev = label ? label.textContent : '';
btn.disabled = true;
if (label) label.textContent = _('copying_key');
try {
const data = await fetchInviteConfig(connId);
await copyKeyText(pickKey(data.config, data.vpn_link));
} catch (err) {
alert(`${_('error')}: ` + err.message);
} finally {
btn.disabled = false;
if (label) label.textContent = prev || _('copy_key_big');
}
}
async function showDetails(connId, name) {
try {
const data = await fetchInviteConfig(connId);
fillModal(name, data.config, data.vpn_link || '', data.expires_at);
openModal('configModal');
} catch (err) {
alert(`${_('error')}: ` + err.message);
}
}
@@ -189,16 +329,27 @@
text.classList.add('hidden');
spinner.classList.remove('hidden');
try {
const body = { name: 'Invite VPN' };
const wrap = document.getElementById('serverPickWrap');
const sel = document.getElementById('inviteServerPick');
if (wrap && wrap.style.display !== 'none' && sel && sel.value !== '') {
body.server_id = parseInt(sel.value, 10);
}
const res = await fetch(`/api/invite/${TOKEN}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Invite VPN' })
body: JSON.stringify(body)
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || _('error'));
const share = data.subscription_url || data.config || '';
if (share) openConfigModal(data.connection?.name || 'VPN', share, data.vpn_link || share, data.expires_at);
if (share || data.vpn_link) {
fillModal(data.connection?.name || 'VPN', share || data.config || '', data.vpn_link || share, data.expires_at);
await copyKeyText(pickKey(share || data.config || '', data.vpn_link || share));
openModal('configModal');
}
updateStatus(data.invite);
await loadConnections();
} catch (err) {
alert(`${_('error')}: ` + err.message);
} finally {
@@ -207,17 +358,43 @@
spinner.classList.add('hidden');
}
}
document.addEventListener('DOMContentLoaded', loadConnections);
</script>
<style>
.share-page { max-width: 440px; margin: 1.25rem auto 2.5rem; padding: 0 var(--space-md); }
.share-hero { text-align: center; margin-bottom: var(--space-lg); }
.share-title { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 var(--space-xs); }
.share-user { color: var(--text-muted); font-size: 0.95rem; margin: 0 0 var(--space-sm); }
.share-hint {
margin: 0 auto; max-width: 22rem; padding: 0.75rem 1rem; border-radius: 12px;
background: var(--accent-glow); color: var(--text-primary); font-size: 0.95rem; line-height: 1.4;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
}
.share-grid { display: flex; flex-direction: column; gap: var(--space-md); margin-top: var(--space-lg); }
.share-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: var(--space-md); display: flex; flex-direction: column; gap: var(--space-sm);
}
.share-card-top { display: flex; justify-content: space-between; gap: var(--space-sm); align-items: flex-start; }
.share-card-name { font-weight: 600; }
.share-card-meta { font-size: 0.8rem; color: var(--text-muted); margin-top: 2px; }
.share-btn-lg { width: 100%; justify-content: center; min-height: 48px; }
.share-link-btn {
background: none; border: none; color: var(--accent); cursor: pointer; font-size: 0.85rem; padding: 4px 0;
}
.share-loading, .share-empty { text-align: center; color: var(--text-muted); padding: var(--space-lg) 0; }
.share-modal { max-width: 420px; }
.share-modal-body { padding: var(--space-md) var(--space-lg) var(--space-lg); display: flex; flex-direction: column; gap: var(--space-md); }
.share-qr-wrap { text-align: center; }
.share-qr-caption { font-size: 0.8rem; color: var(--text-muted); margin-top: var(--space-xs); }
.share-config-text { width: 100%; font-family: monospace; font-size: 0.75rem; }
.share-auth { max-width: 360px; margin: 0 auto; text-align: center; padding: var(--space-xl); }
.share-auth-form { display: flex; flex-direction: column; gap: var(--space-md); margin-top: var(--space-md); }
.spinner {
border: 3px solid rgba(255, 255, 255, 0.1);
border-top: 3px solid var(--accent-color);
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
display: inline-block;
border: 3px solid rgba(255,255,255,0.1); border-top: 3px solid var(--accent-color);
border-radius: 50%; width: 20px; height: 20px; animation: spin 1s linear infinite; display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+19 -1
View File
@@ -174,6 +174,14 @@
<input class="form-input" type="text" id="inviteNote" placeholder="{{ _('invite_note_placeholder') }}">
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="inviteAllowServerChoice" checked>
{{ _('allow_server_choice') }}
</label>
<div class="form-hint">{{ _('allow_server_choice_hint') }}</div>
</div>
<div class="form-group" id="inviteResetUsedWrap" style="display:none;">
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="inviteResetUsed"> {{ _('invite_reset_used') }}
@@ -202,11 +210,12 @@
awg: 'AmneziaWG',
awg_legacy: 'AWG Legacy',
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
xray: 'Xray (VLESS-XHTTP-TLS)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
mieru: 'Mieru',
aivpn: 'AIVPN (auto)',
xui: '3x-ui VLESS',
};
@@ -264,6 +273,12 @@
return;
}
const aivpnOpt = document.createElement('option');
aivpnOpt.value = 'aivpn';
aivpnOpt.textContent = _('aivpn_protocol_option') || 'AIVPN (auto)';
if (preferProtocol === 'aivpn') aivpnOpt.selected = true;
sel.appendChild(aivpnOpt);
installed.forEach(key => {
const opt = document.createElement('option');
opt.value = key;
@@ -348,6 +363,7 @@
document.getElementById('inviteNote').value = '';
document.getElementById('inviteClearPwdWrap').style.display = 'none';
document.getElementById('inviteResetUsedWrap').style.display = 'none';
document.getElementById('inviteAllowServerChoice').checked = true;
const serverSel = document.getElementById('inviteServer');
if (xuiConfigured && [...serverSel.options].some(o => o.value === 'xui')) serverSel.value = 'xui';
else if (serverSel.options.length) serverSel.selectedIndex = 0;
@@ -377,6 +393,7 @@
document.getElementById('inviteClearPassword').checked = false;
document.getElementById('inviteResetUsedWrap').style.display = 'block';
document.getElementById('inviteResetUsed').checked = false;
document.getElementById('inviteAllowServerChoice').checked = inv.allow_server_choice !== false;
setInviteSelection(inv.protocol || 'xui', inv.server_id || 0);
const panelSel = document.getElementById('inviteXuiPanel');
if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) {
@@ -410,6 +427,7 @@
xui_inbound_id: inbound,
xui_panel_id: document.getElementById('inviteXuiPanel')?.value || '',
note: document.getElementById('inviteNote').value || '',
allow_server_choice: document.getElementById('inviteAllowServerChoice').checked,
};
const pwd = document.getElementById('invitePassword').value;
if (pwd) body.password = pwd;
+286 -31
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>
@@ -412,24 +412,44 @@
</div>
</div>
<!-- ===== AIVPN teaser (locked, full grid width) ===== -->
<div class="promo-block promo-aivpn" aria-disabled="true" role="article">
<!-- ===== AIVPN (smart protocol picker) ===== -->
<div class="promo-block promo-aivpn" id="aivpnCard" role="article">
<div class="promo-orbs" aria-hidden="true">
<span class="promo-orb"></span>
<span class="promo-orb"></span>
<span class="promo-orb"></span>
</div>
<span class="promo-lock-badge">{{ icon('lock') }} {{ _('coming_soon') }}</span>
<div class="promo-content">
<span class="promo-lock-badge" id="aivpnBadge">{{ icon('brain') }} AIVPN</span>
<div class="promo-content" style="flex-wrap: wrap;">
<div class="promo-icon" aria-hidden="true">{{ icon('brain') }}</div>
<div class="promo-text">
<div class="promo-text" style="flex: 1; min-width: 200px;">
<div class="promo-title">AIVPN</div>
<div class="promo-subtitle">{{ _('aivpn_subtitle') }}</div>
</div>
<a class="promo-cta" href="https://github.com/PRVTPRO/Amnezia-Web-Panel" target="_blank" rel="noopener">
<a class="promo-cta" href="{{ releases_repo_url }}" target="_blank" rel="noopener">
<span>{{ _('promo_star_cta') }}</span>
</a>
</div>
<div class="aivpn-panel" style="position:relative; z-index:1; margin-top: var(--space-md); display:flex; flex-direction:column; gap: var(--space-sm);">
<div style="display:flex; flex-wrap:wrap; gap: var(--space-sm); align-items:center;">
<select class="form-input" id="aivpnStrategy" style="width:auto; min-width:140px; background:rgba(0,0,0,0.25); color:#fff; border-color:rgba(255,255,255,0.25);">
<option value="balanced">{{ _('aivpn_strategy_balanced') }}</option>
<option value="stealth">{{ _('aivpn_strategy_stealth') }}</option>
<option value="speed">{{ _('aivpn_strategy_speed') }}</option>
</select>
<label style="display:flex; align-items:center; gap:6px; color:#fff; font-size:0.85rem;">
<input type="checkbox" id="aivpnEnabled"> {{ _('aivpn_enable') }}
</label>
<label style="display:flex; align-items:center; gap:6px; color:#fff; font-size:0.85rem;">
<input type="checkbox" id="aivpnProbe" checked> {{ _('aivpn_probe') }}
</label>
</div>
<div style="display:flex; flex-wrap:wrap; gap: var(--space-sm);">
<button type="button" class="btn btn-secondary btn-sm" onclick="saveAivpnSettings()" id="aivpnSaveBtn">{{ _('save') }}</button>
<button type="button" class="btn btn-primary btn-sm" onclick="runAivpnPick()" id="aivpnPickBtn">{{ _('aivpn_pick_now') }}</button>
</div>
<div class="text-sm" id="aivpnResult" style="color: rgba(255,255,255,0.9);"></div>
</div>
</div>
<!-- DNS Card -->
@@ -551,25 +571,6 @@
</div>
</div>
<div class="promo-block promo-aivpn" aria-disabled="true" role="article">
<div class="promo-orbs" aria-hidden="true">
<span class="promo-orb"></span>
<span class="promo-orb"></span>
<span class="promo-orb"></span>
</div>
<span class="promo-lock-badge">{{ icon('lock') }} Coming soon</span>
<div class="promo-content">
<div class="promo-icon" aria-hidden="true">{{ icon('brain') }}</div>
<div class="promo-text">
<div class="promo-title">AIVPN</div>
<div class="promo-subtitle">AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.</div>
</div>
<a class="promo-cta" href="https://github.com/PRVTPRO/Amnezia-Web-Panel" target="_blank" rel="noopener">
<span>Star us on GitHub</span>
</a>
</div>
</div>
<!-- Connections Section (renamed from Clients) -->
<div class="clients-section" id="connectionsSection" style="display:none;">
@@ -795,6 +796,56 @@
<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_listen_port') }} *</label>
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:8px;">
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer;">
<input type="radio" name="xrayPortMode" value="standard" onchange="updateXrayPortUi()">
<span>{{ _('xray_port_standard') }}</span>
</label>
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer;">
<input type="radio" name="xrayPortMode" value="custom" checked onchange="updateXrayPortUi()">
<span>{{ _('xray_port_custom') }}</span>
</label>
</div>
<input class="form-input" type="number" id="installXrayPort" value="8443" min="1" max="65535" oninput="syncXrayPortToInstall()">
<div class="form-hint" id="xrayPortHint">{{ _('xray_port_hint') }}</div>
</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);">
@@ -1227,11 +1278,12 @@
const SERVER_CONNECT_DOMAIN = {{ ((server.server_info or {}).get('connect_domain') or '') | tojson }};
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
let aivpnEnabled = {{ (((server.server_info or {}).get('aivpn') or {}).get('enabled') or false) | tojson }};
const MARKETPLACE_APPS = [
{ 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' },
@@ -1647,6 +1699,7 @@
case 'naiveproxy': title = 'NaiveProxy'; break;
case 'mieru': title = 'Mieru'; break;
case 'wireguard': title = 'WireGuard'; break;
case 'aivpn': title = 'AIVPN'; break;
case 'dns': title = 'AmneziaDNS'; break;
case 'socks5': title = 'SOCKS5 Proxy'; break;
case 'adguard': title = 'AdGuard Home'; break;
@@ -1943,6 +1996,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>`;
}
@@ -2391,6 +2454,71 @@
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 getXrayPortMode() {
const el = document.querySelector('input[name="xrayPortMode"]:checked');
return el ? el.value : 'custom';
}
function syncXrayPortToInstall() {
const portInput = document.getElementById('installPort');
const xrPort = document.getElementById('installXrayPort');
if (!portInput || !xrPort) return;
if (getXrayPortMode() === 'standard') {
portInput.value = '443';
xrPort.value = '443';
} else {
const v = parseInt(xrPort.value, 10);
portInput.value = (v >= 1 && v <= 65535) ? String(v) : '8443';
}
}
function updateXrayPortUi() {
const mode = getXrayPortMode();
const xrPort = document.getElementById('installXrayPort');
const hint = document.getElementById('xrayPortHint');
if (xrPort) {
if (mode === 'standard') {
xrPort.value = '443';
xrPort.disabled = true;
} else {
xrPort.disabled = false;
if (!xrPort.value || xrPort.value === '443') {
xrPort.value = currentInstallAnother
? String(nextSuggestedPort(currentInstallProto, 8443))
: '8443';
}
}
}
if (hint) {
hint.textContent = mode === 'standard' ? _('xray_port_hint_standard') : _('xray_port_hint');
}
syncXrayPortToInstall();
}
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();
}
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;
@@ -2409,6 +2537,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';
@@ -2417,6 +2546,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') {
@@ -2425,10 +2555,19 @@
portInput.disabled = true;
portHint.textContent = _('dns_internal_hint');
} else if (base === 'xray') {
portLabel.textContent = _('port') + ' (TCP)';
if (portGroup) portGroup.style.display = 'none';
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;
const stdRadio = document.querySelector('input[name="xrayPortMode"][value="standard"]');
const customRadio = document.querySelector('input[name="xrayPortMode"][value="custom"]');
if (customRadio) customRadio.checked = true;
if (stdRadio) stdRadio.checked = false;
updateXrayPortUi();
updateXrayAcmeUi();
} else if (base === 'telemt') {
portLabel.textContent = _('port') + ' (TCP)';
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
@@ -2510,6 +2649,26 @@
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();
syncXrayPortToInstall();
const xrPort = parseInt(document.getElementById('installPort')?.value, 10);
if (!xrDomain || !xrEmail) {
showToast(_('xray_domain') + ' / ' + _('xray_email'), 'error');
return;
}
if (!(xrPort >= 1 && xrPort <= 65535)) {
showToast(_('xray_listen_port'), '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');
@@ -2568,6 +2727,15 @@
}
params.hysteria_domain = document.getElementById('installHysteriaDomain').value.trim();
params.hysteria_email = document.getElementById('installHysteriaEmail').value.trim();
} else if (protoBase(currentInstallProto) === 'xray') {
syncXrayPortToInstall();
params.port = document.getElementById('installPort').value;
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();
@@ -3051,7 +3219,7 @@
selectedConnectionIds.clear();
updateMoveSelection();
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${encodeURIComponent(proto)}`);
loading.style.display = 'none';
if (!data.clients || data.clients.length === 0) {
emptyEl.classList.remove('hidden');
@@ -3399,9 +3567,96 @@
// ========== AIVPN ==========
function formatAivpnRecommendation(rec) {
if (!rec || !rec.protocol) {
return _('aivpn_no_candidates');
}
const rtt = (rec.rtt_ms != null) ? ` · ${rec.rtt_ms} ms` : '';
const alts = (rec.alternatives || []).slice(0, 3)
.map(a => getProtoTitle(a.protocol))
.filter(Boolean);
const altTxt = alts.length ? ` · ${_('aivpn_alternatives')}: ${alts.join(', ')}` : '';
return `${_('aivpn_picked')}: <strong>${getProtoTitle(rec.protocol)}</strong> (score ${Math.round(rec.score || 0)}${rtt})${altTxt}`;
}
function applyAivpnUi(data) {
const settings = (data && data.settings) || {};
aivpnEnabled = !!settings.enabled;
const strat = document.getElementById('aivpnStrategy');
const en = document.getElementById('aivpnEnabled');
const probe = document.getElementById('aivpnProbe');
if (strat && settings.strategy) strat.value = settings.strategy;
if (en) en.checked = aivpnEnabled;
if (probe) probe.checked = settings.probe !== false;
const badge = document.getElementById('aivpnBadge');
if (badge) {
badge.innerHTML = aivpnEnabled
? `${uiIcon('brain')} AIVPN · ${_('aivpn_on')}`
: `${uiIcon('brain')} AIVPN`;
}
const resultEl = document.getElementById('aivpnResult');
if (resultEl) resultEl.innerHTML = formatAivpnRecommendation(data && data.recommendation);
}
async function loadAivpnSettings(doProbe) {
try {
const q = doProbe ? '?probe=1' : '?probe=0';
const data = await apiCall(`/api/servers/${SERVER_ID}/aivpn${q}`);
applyAivpnUi(data);
} catch (err) {
console.warn('AIVPN load failed:', err);
}
}
async function saveAivpnSettings() {
const btn = document.getElementById('aivpnSaveBtn');
if (btn) btn.disabled = true;
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/aivpn`, 'POST', {
enabled: document.getElementById('aivpnEnabled').checked,
strategy: document.getElementById('aivpnStrategy').value,
probe: document.getElementById('aivpnProbe').checked,
});
applyAivpnUi(data);
showToast(_('settings_saved'), 'success');
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
async function runAivpnPick() {
const btn = document.getElementById('aivpnPickBtn');
if (btn) btn.disabled = true;
const resultEl = document.getElementById('aivpnResult');
if (resultEl) resultEl.textContent = _('aivpn_picking');
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/aivpn?probe=1`);
applyAivpnUi(data);
const picked = data.recommendation && data.recommendation.protocol;
if (!picked) {
showToast(_('aivpn_no_candidates'), 'warning');
} else {
const sel = document.getElementById('connProtoSelect');
if (sel && [...sel.options].some(o => o.value === picked)) {
sel.value = picked;
loadConnections();
}
}
} catch (err) {
if (resultEl) resultEl.textContent = '';
showToast(_('error') + ': ' + err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
// ========== Init ==========
applyInstalledAppsVisibility();
checkServer();
loadServerStats();
loadAivpnSettings(false);
</script>
{% endblock %}
+16 -1
View File
@@ -131,6 +131,13 @@
</select>
<input type="hidden" id="guest_create_protocol_pref" value="{{ settings.guest.create_protocol or '' }}">
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" id="guest_allow_server_choice" {% if settings.guest.create_allow_server_choice is not defined or settings.guest.create_allow_server_choice %}checked{% endif %}>
{{ _('allow_server_choice') }}
</label>
<div class="form-hint">{{ _('allow_server_choice_hint') }}</div>
</div>
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
<label class="form-label">{{ _('xui_server_select_label') }}</label>
<select class="form-select" id="guest_create_xui_panel" onchange="loadGuestInbounds()">
@@ -1516,6 +1523,7 @@
create_server_id: createServerId,
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0'),
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
create_allow_server_choice: !!(document.getElementById('guest_allow_server_choice') && document.getElementById('guest_allow_server_choice').checked),
};
const donateMethod = (key) => ({
@@ -1555,11 +1563,12 @@
awg: 'AmneziaWG',
awg_legacy: 'AWG Legacy',
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
xray: 'Xray (VLESS-XHTTP-TLS)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
mieru: 'Mieru',
aivpn: 'AIVPN (auto)',
xui: '3x-ui VLESS',
};
function protoTitle(key) {
@@ -1602,6 +1611,12 @@
opt.textContent = _('no_protocols');
protoSel.appendChild(opt);
} else {
const aivpnOpt = document.createElement('option');
aivpnOpt.value = 'aivpn';
aivpnOpt.textContent = _('aivpn_protocol_option') || 'AIVPN (auto)';
if (prefer === 'aivpn') aivpnOpt.selected = true;
protoSel.appendChild(aivpnOpt);
installed.forEach(key => {
const opt = document.createElement('option');
opt.value = key;
+13 -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',
@@ -685,6 +685,11 @@
});
if (count > 0) {
const aivpnOpt = document.createElement('option');
aivpnOpt.value = 'aivpn';
aivpnOpt.textContent = _('aivpn_protocol_option') || 'AIVPN (auto)';
select.insertBefore(aivpnOpt, select.firstChild);
count++;
if (group) group.style.display = '';
} else {
const opt = document.createElement('option');
@@ -766,6 +771,13 @@
});
document.getElementById('ucProtocol').addEventListener('change', (e) => {
if (e.target.value === 'aivpn') {
const mode = document.getElementById('ucMode');
if (mode && mode.value === 'existing') {
mode.value = 'new';
toggleUCMode();
}
}
if (document.getElementById('ucMode').value === 'existing') {
fetchExistingClients();
}
+38 -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,27 @@
"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": "Choose standard 443/TCP or any free custom TCP port. Prefer Cloudflare DNS ACME — port 80 is not needed.",
"port_xray_hint_cf": "Choose 443 or a custom TCP port. 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. Port 443 is optional.",
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01). Listen port can be 443 or any free TCP port. Reinstall replaces the previous Xray stack.",
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Listen on 443 or any free TCP port. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
"xray_listen_port": "Listen port (TCP)",
"xray_port_standard": "Standard — 443/TCP",
"xray_port_custom": "Custom port",
"xray_port_hint": "Any free TCP port (default 8443). Open it in the firewall. Does not occupy 443.",
"xray_port_hint_standard": "Uses 443/TCP. Make sure nothing else is bound to 443 on this server.",
"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 {}?",
@@ -525,6 +545,22 @@
"coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub",
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
"aivpn_enable": "Enabled",
"aivpn_probe": "Probe ports",
"aivpn_pick_now": "Pick now",
"aivpn_strategy_balanced": "Balanced",
"aivpn_strategy_stealth": "Stealth",
"aivpn_strategy_speed": "Speed",
"aivpn_picked": "Best protocol",
"aivpn_alternatives": "also",
"aivpn_no_candidates": "No installed VPN protocols to pick from",
"aivpn_picking": "Scoring protocols…",
"aivpn_on": "On",
"aivpn_protocol_option": "AIVPN (auto)",
"choose_server": "Choose server",
"allow_server_choice": "Allow user to choose server",
"allow_server_choice_hint": "End users pick a server when creating a config. Default server is used as fallback.",
"invite_no_saved_configs": "No configs yet. Create one above — you can copy it again anytime.",
"revproxy_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
"management": "Management",
+38 -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,27 @@
"no_connections_desc": "اولین اتصال خود را برای ایجاد پیکربندی VPN اضافه کنید",
"install_protocol": "نصب پروتکل",
"port_default_hint": "پورت پیش‌فرض: 55424. مطمئن شوید این پورت آزاد است.",
"port_xray_hint": "پورت پیشنهادی: 443. مطمئن شوید توسط وب‌سرور دیگری اشغال نشده باشد.",
"port_xray_hint": "Choose standard 443/TCP or any free custom TCP port. Prefer Cloudflare DNS ACME — port 80 is not needed.",
"port_xray_hint_cf": "Choose 443 or a custom TCP port. 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. Port 443 is optional.",
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01). Listen port can be 443 or any free TCP port.",
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Listen on 443 or any free TCP port. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
"xray_listen_port": "Listen port (TCP)",
"xray_port_standard": "Standard — 443/TCP",
"xray_port_custom": "Custom port",
"xray_port_hint": "Any free TCP port (default 8443). Open it in the firewall. Does not occupy 443.",
"xray_port_hint_standard": "Uses 443/TCP. Make sure nothing else is bound to 443 on this server.",
"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": "توقف کانتینر {}؟",
@@ -479,6 +499,22 @@
"coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub",
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
"aivpn_enable": "Enabled",
"aivpn_probe": "Probe ports",
"aivpn_pick_now": "Pick now",
"aivpn_strategy_balanced": "Balanced",
"aivpn_strategy_stealth": "Stealth",
"aivpn_strategy_speed": "Speed",
"aivpn_picked": "Best protocol",
"aivpn_alternatives": "also",
"aivpn_no_candidates": "No installed VPN protocols to pick from",
"aivpn_picking": "Scoring protocols…",
"aivpn_on": "On",
"aivpn_protocol_option": "AIVPN (auto)",
"choose_server": "Choose server",
"allow_server_choice": "Allow user to choose server",
"allow_server_choice_hint": "End users pick a server when creating a config. Default server is used as fallback.",
"invite_no_saved_configs": "No configs yet. Create one above — you can copy it again anytime.",
"revproxy_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
+38 -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,27 @@
"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": "Choose standard 443/TCP or any free custom TCP port. Prefer Cloudflare DNS ACME — port 80 is not needed.",
"port_xray_hint_cf": "Choose 443 or a custom TCP port. 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. Port 443 is optional.",
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01). Listen port can be 443 or any free TCP port.",
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Listen on 443 or any free TCP port. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
"xray_listen_port": "Listen port (TCP)",
"xray_port_standard": "Standard — 443/TCP",
"xray_port_custom": "Custom port",
"xray_port_hint": "Any free TCP port (default 8443). Open it in the firewall. Does not occupy 443.",
"xray_port_hint_standard": "Uses 443/TCP. Make sure nothing else is bound to 443 on this server.",
"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 {} ?",
@@ -479,6 +499,22 @@
"coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub",
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
"aivpn_enable": "Enabled",
"aivpn_probe": "Probe ports",
"aivpn_pick_now": "Pick now",
"aivpn_strategy_balanced": "Balanced",
"aivpn_strategy_stealth": "Stealth",
"aivpn_strategy_speed": "Speed",
"aivpn_picked": "Best protocol",
"aivpn_alternatives": "also",
"aivpn_no_candidates": "No installed VPN protocols to pick from",
"aivpn_picking": "Scoring protocols…",
"aivpn_on": "On",
"aivpn_protocol_option": "AIVPN (auto)",
"choose_server": "Choose server",
"allow_server_choice": "Allow user to choose server",
"allow_server_choice_hint": "End users pick a server when creating a config. Default server is used as fallback.",
"invite_no_saved_configs": "No configs yet. Create one above — you can copy it again anytime.",
"revproxy_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
+38 -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,27 @@
"no_connections_desc": "Добавьте первое подключение для генерации VPN конфигурации",
"install_protocol": "Установить протокол",
"port_default_hint": "Порт по умолчанию: 55424. Убедитесь, что он не занят",
"port_xray_hint": "Порт по умолчанию: 443 (рекомендуется для Xray). Убедитесь, что он не занят другим веб-сервером.",
"port_xray_hint": "Можно взять стандартный 443/TCP или любой свободный порт. Лучше Cloudflare DNS ACME — порт 80 не нужен.",
"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 не занимается. Порт 443 необязателен.",
"xray_ports_warning": "На время установки TCP 80 должен быть свободен (Let\u0027s Encrypt HTTP-01). Слушающий порт — 443 или любой свободный. Переустановка заменяет предыдущий Xray.",
"xray_ports_warning_cf": "Cloudflare DNS ACME не использует порт 80. Слушайте на 443 или любом свободном TCP. Токену нужно Zone → DNS → Edit. Прокси выключите (серое облако).",
"xray_listen_port": "Порт прослушивания (TCP)",
"xray_port_standard": "Стандартный — 443/TCP",
"xray_port_custom": "Свой порт",
"xray_port_hint": "Любой свободный TCP-порт (по умолчанию 8443). Откройте его в файрволе. Порт 443 не занимает.",
"xray_port_hint_standard": "Использует 443/TCP. Убедитесь, что 443 на сервере свободен.",
"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": "Остановить контейнер {}?",
@@ -525,6 +545,22 @@
"coming_soon": "Скоро",
"promo_star_cta": "Поставить звезду на GitHub",
"aivpn_subtitle": "ИИ-подбор протокола, который сам выбирает правильный туннель под текущие условия. Поможете звездой — выйдет быстрее.",
"aivpn_enable": "Включено",
"aivpn_probe": "Проверять порты",
"aivpn_pick_now": "Подобрать сейчас",
"aivpn_strategy_balanced": "Баланс",
"aivpn_strategy_stealth": "Скрытность",
"aivpn_strategy_speed": "Скорость",
"aivpn_picked": "Лучший протокол",
"aivpn_alternatives": "также",
"aivpn_no_candidates": "Нет установленных VPN-протоколов для выбора",
"aivpn_picking": "Оценка протоколов…",
"aivpn_on": "Вкл",
"aivpn_protocol_option": "AIVPN (авто)",
"choose_server": "Выберите сервер",
"allow_server_choice": "Разрешить пользователю выбрать сервер",
"allow_server_choice_hint": "Пользователь сам выбирает сервер при создании конфига. Сервер по умолчанию — запасной вариант.",
"invite_no_saved_configs": "Пока нет конфигов. Создайте выше — копировать можно повторно в любой момент.",
"revproxy_title": "Reverse Proxy",
"revproxy_subtitle": "Высокопроизводительная маскировка трафика: прячем VPN за обычным сайтом, снижаем заметность для DPI.",
"management": "Управление",
+38 -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,27 @@
"no_connections_desc": "添加首个连接以生成 VPN 配置文件",
"install_protocol": "安装协议",
"port_default_hint": "默认端口: 55424。请确保端口未被占用。",
"port_xray_hint": "推荐端口: 443。请确保未被其他 Web 服务器使用。",
"port_xray_hint": "Choose standard 443/TCP or any free custom TCP port. Prefer Cloudflare DNS ACME — port 80 is not needed.",
"port_xray_hint_cf": "Choose 443 or a custom TCP port. 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. Port 443 is optional.",
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01). Listen port can be 443 or any free TCP port.",
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Listen on 443 or any free TCP port. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
"xray_listen_port": "Listen port (TCP)",
"xray_port_standard": "Standard — 443/TCP",
"xray_port_custom": "Custom port",
"xray_port_hint": "Any free TCP port (default 8443). Open it in the firewall. Does not occupy 443.",
"xray_port_hint_standard": "Uses 443/TCP. Make sure nothing else is bound to 443 on this server.",
"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": "停止容器 {}",
@@ -479,6 +499,22 @@
"coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub",
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
"aivpn_enable": "Enabled",
"aivpn_probe": "Probe ports",
"aivpn_pick_now": "Pick now",
"aivpn_strategy_balanced": "Balanced",
"aivpn_strategy_stealth": "Stealth",
"aivpn_strategy_speed": "Speed",
"aivpn_picked": "Best protocol",
"aivpn_alternatives": "also",
"aivpn_no_candidates": "No installed VPN protocols to pick from",
"aivpn_picking": "Scoring protocols…",
"aivpn_on": "On",
"aivpn_protocol_option": "AIVPN (auto)",
"choose_server": "Choose server",
"allow_server_choice": "Allow user to choose server",
"allow_server_choice_hint": "End users pick a server when creating a config. Default server is used as fallback.",
"invite_no_saved_configs": "No configs yet. Create one above — you can copy it again anytime.",
"revproxy_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",