Compare commits

..
Author SHA1 Message Date
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
orohi f9b9857460 Add server user/protocol migrate export-import for connect_domain moves. 2026-08-09 17:31:08 +03:00
16 changed files with 2548 additions and 363 deletions
+521 -78
View File
@@ -23,7 +23,7 @@ import time
import urllib.request
import zipfile
import signal
from datetime import datetime
from datetime import datetime, timezone
import io
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, StreamingResponse, FileResponse
from starlette.background import BackgroundTask
@@ -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.0"
CURRENT_VERSION = "v3.1.1"
RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
@@ -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:
@@ -4159,6 +4201,177 @@ async def api_protocol_backup_restore(request: Request, server_id: int, req: Bac
return JSONResponse({'error': str(e)}, status_code=500)
@app.get('/api/servers/{server_id}/migrate/export', tags=["Servers"])
async def api_server_migrate_export(
request: Request,
server_id: int,
include_protocols: bool = True,
):
"""Export users/connections (+ protocol state) for domain-preserving server migration."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
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]
from managers.migrate_manager import export_migrate_zip
def _do_export():
ssh = None
try:
if include_protocols:
ssh = get_ssh(server)
ssh.connect()
return export_migrate_zip(
ssh,
data,
server_id,
include_protocol_backups=include_protocols,
protocol_container_name_fn=protocol_container_name,
)
finally:
if ssh:
ssh.disconnect()
zip_bytes, summary = await asyncio.to_thread(_do_export)
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
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(
_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:
logger.exception('Server migrate export failed')
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/migrate/import', tags=["Servers"])
async def api_server_migrate_import(
request: Request,
server_id: int,
file: UploadFile = File(...),
restore_protocols: bool = True,
):
"""Import migrate ZIP onto this server (remap users/connections, restore protocol state)."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
content = await file.read()
if not content:
return JSONResponse({'error': 'Empty file'}, status_code=400)
from managers.migrate_manager import import_migrate_zip
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]
def _do_import():
ssh = None
try:
if restore_protocols:
ssh = get_ssh(server)
ssh.connect()
return import_migrate_zip(
ssh,
data,
server_id,
content,
restore_protocols=restore_protocols,
protocol_container_name_fn=protocol_container_name,
)
finally:
if ssh:
ssh.disconnect()
result = await asyncio.to_thread(_do_import)
save_data(data)
return {'status': 'success', **result}
except Exception as e:
logger.exception('Server migrate import failed')
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."""
@@ -4698,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', {}))
@@ -4732,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,
@@ -4749,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'):
@@ -4764,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(),
@@ -5202,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)
@@ -5264,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,
@@ -5288,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)
@@ -5303,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(),
@@ -5509,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
@@ -5584,22 +5811,26 @@ async def api_guest_connections(token: str, request: Request):
data, guest, holder, err = _resolve_guest(token, request)
if err:
return err
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': protocol,
'allow_server_choice': bool(servers),
'default_server_id': int(guest.get('create_server_id') or 0),
'servers': servers,
}
if not holder:
return {
'connections': [],
'allow_create': bool(guest.get('allow_create')),
'create_protocol': guest.get('create_protocol') or 'xui',
}
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"])
@@ -5621,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)
@@ -5668,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
@@ -5697,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')
@@ -5767,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',
@@ -5785,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 '',
@@ -5805,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)
@@ -5825,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
@@ -5967,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']}"}
@@ -6013,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':
@@ -6028,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"])
@@ -6054,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,
@@ -6089,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'],
@@ -6102,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()
@@ -6114,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)
@@ -6126,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
@@ -6142,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),
@@ -6158,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
@@ -6177,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'
+489
View File
@@ -0,0 +1,489 @@
"""Export/import server user data + protocol state for domain-preserving migration.
Use case: new VPS IP, same connect_domain. Users keep existing VPN configs if:
1. Protocol crypto state is restored on the new host
2. Panel user_connections keep the same client_id values
3. DNS A-record for connect_domain points to the new IP
"""
from __future__ import annotations
import io
import json
import logging
import secrets
import shlex
import uuid
import zipfile
from datetime import datetime, timezone
from typing import Any, Optional
from managers.backup_manager import BackupManager
logger = logging.getLogger(__name__)
MIGRATE_FORMAT = 'amnezia-web-panel-migrate-v1'
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _safe_user_public(user: dict) -> dict:
"""User row suitable for re-import (keeps password_hash / share tokens)."""
return {
'id': user.get('id'),
'username': user.get('username'),
'password_hash': user.get('password_hash') or '',
'role': user.get('role') or 'user',
'enabled': bool(user.get('enabled', True)),
'created_at': user.get('created_at'),
'telegramId': user.get('telegramId'),
'email': user.get('email'),
'description': user.get('description'),
'traffic_limit': user.get('traffic_limit', 0),
'traffic_used': user.get('traffic_used', 0),
'traffic_total': user.get('traffic_total', 0),
'traffic_reset_strategy': user.get('traffic_reset_strategy', 'never'),
'last_reset_at': user.get('last_reset_at'),
'expiration_date': user.get('expiration_date'),
'expire_after_first_use': bool(user.get('expire_after_first_use')),
'expiration_days': int(user.get('expiration_days') or 0),
'remnawave_uuid': user.get('remnawave_uuid'),
'xui_email': user.get('xui_email'),
'share_enabled': bool(user.get('share_enabled')),
'share_token': user.get('share_token'),
'share_password_hash': user.get('share_password_hash'),
}
def _server_public_slice(server: dict, server_id: int) -> dict:
info = dict(server.get('server_info') or {})
protocols = {}
for key, val in (server.get('protocols') or {}).items():
if not isinstance(val, dict):
continue
protocols[key] = {
'installed': bool(val.get('installed')),
'port': val.get('port'),
'connect_domain': val.get('connect_domain') or '',
'container_name': val.get('container_name') or '',
}
return {
'old_server_id': server_id,
'name': server.get('name') or '',
'host': server.get('host') or '',
'ssh_port': int(server.get('ssh_port') or 22),
'connect_domain': (info.get('connect_domain') or '').strip(),
'ssl_domain': (info.get('ssl_domain') or '').strip(),
'ssl_email': (info.get('ssl_email') or '').strip(),
'protocols': protocols,
}
def build_panel_payload(data: dict, server_id: int) -> dict:
servers = data.get('servers') or []
if server_id < 0 or server_id >= len(servers):
raise ValueError('Server not found')
server = servers[server_id]
conns = [
dict(c) for c in (data.get('user_connections') or [])
if isinstance(c, dict) and int(c.get('server_id', -1)) == server_id
]
user_ids = {c.get('user_id') for c in conns if c.get('user_id')}
users = [
_safe_user_public(u) for u in (data.get('users') or [])
if isinstance(u, dict) and u.get('id') in user_ids
]
invites = [
dict(inv) for inv in (data.get('invite_links') or [])
if isinstance(inv, dict) and int(inv.get('server_id', -1)) == server_id
]
return {
'format': MIGRATE_FORMAT,
'exported_at': _now_iso(),
'server': _server_public_slice(server, server_id),
'users': users,
'user_connections': conns,
'invite_links': invites,
}
def _upload_bytes_sudo(ssh, content: bytes, remote_path: str) -> None:
tmp = f'/tmp/_amnz_mig_{secrets.token_hex(6)}'
sftp = ssh.client.open_sftp()
try:
with sftp.file(tmp, 'wb') as f:
f.write(content)
finally:
sftp.close()
parent = remote_path.rsplit('/', 1)[0]
ssh.run_sudo_command(
f"mkdir -p {shlex.quote(parent)} && "
f"mv {shlex.quote(tmp)} {shlex.quote(remote_path)} && "
f"chmod 0644 {shlex.quote(remote_path)}"
)
def _download_bytes(ssh, remote_path: str) -> bytes:
tmp = f'/tmp/_amnz_dl_{secrets.token_hex(6)}'
quoted_remote = shlex.quote(remote_path)
quoted_tmp = shlex.quote(tmp)
_, err, code = ssh.run_sudo_command(
f"test -f {quoted_remote} && cp {quoted_remote} {quoted_tmp} && chmod 0644 {quoted_tmp}"
)
if code != 0:
raise RuntimeError(err or f'Failed to stage {remote_path}')
sftp = ssh.client.open_sftp()
try:
buf = io.BytesIO()
with sftp.file(tmp, 'rb') as f:
buf.write(f.read())
return buf.getvalue()
finally:
sftp.close()
ssh.run_sudo_command(f'rm -f {quoted_tmp}')
def export_migrate_zip(
ssh,
data: dict,
server_id: int,
*,
include_protocol_backups: bool = True,
protocol_container_name_fn=None,
) -> tuple[bytes, dict]:
"""Build a migrate ZIP. Returns (zip_bytes, summary)."""
payload = build_panel_payload(data, server_id)
server = data['servers'][server_id]
protocols = server.get('protocols') or {}
installed = [
p for p, info in protocols.items()
if isinstance(info, dict) and info.get('installed')
]
buf = io.BytesIO()
summary = {
'users': len(payload['users']),
'connections': len(payload['user_connections']),
'invites': len(payload['invite_links']),
'protocols': [],
'protocol_errors': [],
'connect_domain': payload['server'].get('connect_domain') or '',
}
with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr('panel.json', json.dumps(payload, indent=2, ensure_ascii=False))
protocol_files = []
if include_protocol_backups and installed and ssh is not None:
bm = BackupManager(ssh)
for proto in installed:
try:
container = ''
if protocol_container_name_fn:
container = protocol_container_name_fn(proto) or ''
info = protocols.get(proto) or {}
container = info.get('container_name') or container or ''
created = bm.create_backup(proto, container)
if created.get('status') != 'success':
summary['protocol_errors'].append({
'protocol': proto,
'error': created.get('message') or 'create backup failed',
})
continue
name = (created.get('backup') or {}).get('name')
path = (created.get('backup') or {}).get('path')
if not name or not path:
summary['protocol_errors'].append({
'protocol': proto,
'error': 'backup path missing',
})
continue
blob = _download_bytes(ssh, path)
arcname = f'protocols/{name}'
zf.writestr(arcname, blob)
protocol_files.append({
'protocol': proto,
'filename': name,
'archive': arcname,
'container': container,
})
summary['protocols'].append(proto)
except Exception as e:
logger.exception('Protocol backup failed for %s', proto)
summary['protocol_errors'].append({
'protocol': proto,
'error': str(e),
})
manifest = {
'format': MIGRATE_FORMAT,
'exported_at': payload['exported_at'],
'server': payload['server'],
'protocol_backups': protocol_files,
'counts': {
'users': summary['users'],
'connections': summary['connections'],
'protocol_backups': len(protocol_files),
},
'notes': [
'Point DNS A-record for connect_domain to the new server IP.',
'Import onto the new server after installing the same protocols.',
'Do not use Move Connections — it regenerates client keys.',
],
}
zf.writestr('manifest.json', json.dumps(manifest, indent=2, ensure_ascii=False))
return buf.getvalue(), summary
def _merge_users(data: dict, imported_users: list) -> dict[str, str]:
"""Merge users into panel data. Returns map old_user_id -> effective_user_id."""
id_map: dict[str, str] = {}
existing_by_id = {str(u.get('id')): u for u in data.get('users') or [] if u.get('id')}
existing_by_name = {
str(u.get('username') or '').lower(): u
for u in data.get('users') or []
if u.get('username')
}
for raw in imported_users:
if not isinstance(raw, dict):
continue
old_id = str(raw.get('id') or '')
username = (raw.get('username') or '').strip()
if not old_id and not username:
continue
if old_id and old_id in existing_by_id:
id_map[old_id] = old_id
continue
by_name = existing_by_name.get(username.lower()) if username else None
if by_name:
id_map[old_id] = str(by_name['id'])
continue
new_user = _safe_user_public(raw)
if not new_user.get('id'):
new_user['id'] = str(uuid.uuid4())
# Avoid unique username collisions with empty/duplicate names.
if not username:
username = f'user_{str(new_user["id"])[:8]}'
new_user['username'] = username
base = username
n = 2
while username.lower() in existing_by_name:
username = f'{base}_{n}'
n += 1
new_user['username'] = username
if new_user.get('role') == 'admin':
# Never import an extra admin silently — demote to user.
new_user['role'] = 'user'
data.setdefault('users', []).append(new_user)
existing_by_id[str(new_user['id'])] = new_user
existing_by_name[username.lower()] = new_user
if old_id:
id_map[old_id] = str(new_user['id'])
id_map[str(new_user['id'])] = str(new_user['id'])
return id_map
def _merge_connections(
data: dict,
imported_conns: list,
*,
target_server_id: int,
user_id_map: dict[str, str],
) -> dict:
existing = data.setdefault('user_connections', [])
existing_keys = {
(
str(c.get('user_id')),
str(c.get('client_id')),
str(c.get('protocol')),
int(c.get('server_id', -1)),
)
for c in existing
if isinstance(c, dict)
}
added = 0
skipped = 0
for raw in imported_conns:
if not isinstance(raw, dict):
continue
old_user = str(raw.get('user_id') or '')
user_id = user_id_map.get(old_user, old_user)
client_id = raw.get('client_id')
protocol = raw.get('protocol')
if not user_id or not client_id or not protocol:
skipped += 1
continue
key = (str(user_id), str(client_id), str(protocol), int(target_server_id))
if key in existing_keys:
skipped += 1
continue
conn = {
'id': raw.get('id') or str(uuid.uuid4()),
'user_id': user_id,
'server_id': target_server_id,
'protocol': protocol,
'client_id': client_id,
'name': raw.get('name') or '',
'xui_panel_id': raw.get('xui_panel_id') or '',
'created_at': raw.get('created_at') or _now_iso(),
'last_bytes': raw.get('last_bytes') or 0,
}
# Avoid id collisions
if any(c.get('id') == conn['id'] for c in existing):
conn['id'] = str(uuid.uuid4())
existing.append(conn)
existing_keys.add(key)
added += 1
return {'added': added, 'skipped': skipped}
def _apply_server_meta(target: dict, exported_server: dict) -> None:
"""Preserve connect_domain / protocol ports on target; never overwrite SSH host."""
info = dict(target.get('server_info') or {})
domain = (exported_server.get('connect_domain') or '').strip()
if domain and not (info.get('connect_domain') or '').strip():
info['connect_domain'] = domain
for key in ('ssl_domain', 'ssl_email'):
val = (exported_server.get(key) or '').strip()
if val and not (info.get(key) or '').strip():
info[key] = val
target['server_info'] = info
protocols = dict(target.get('protocols') or {})
for proto, meta in (exported_server.get('protocols') or {}).items():
if not isinstance(meta, dict):
continue
cur = dict(protocols.get(proto) or {})
if meta.get('port') and not cur.get('port'):
cur['port'] = meta.get('port')
if meta.get('connect_domain') and not cur.get('connect_domain'):
cur['connect_domain'] = meta.get('connect_domain')
if meta.get('installed'):
cur['installed'] = True
if meta.get('container_name') and not cur.get('container_name'):
cur['container_name'] = meta.get('container_name')
protocols[proto] = cur
target['protocols'] = protocols
def import_migrate_zip(
ssh,
data: dict,
target_server_id: int,
zip_bytes: bytes,
*,
restore_protocols: bool = True,
protocol_container_name_fn=None,
) -> dict:
if target_server_id < 0 or target_server_id >= len(data.get('servers') or []):
raise ValueError('Target server not found')
with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r') as zf:
names = set(zf.namelist())
if 'panel.json' not in names:
raise ValueError('Invalid migrate archive: missing panel.json')
panel = json.loads(zf.read('panel.json').decode('utf-8'))
if panel.get('format') != MIGRATE_FORMAT:
raise ValueError(f"Unsupported migrate format: {panel.get('format')}")
manifest = {}
if 'manifest.json' in names:
try:
manifest = json.loads(zf.read('manifest.json').decode('utf-8'))
except Exception:
manifest = {}
user_map = _merge_users(data, panel.get('users') or [])
conn_stats = _merge_connections(
data,
panel.get('user_connections') or [],
target_server_id=target_server_id,
user_id_map=user_map,
)
_apply_server_meta(data['servers'][target_server_id], panel.get('server') or {})
# Optional invite links (remap server_id)
invites_added = 0
for inv in panel.get('invite_links') or []:
if not isinstance(inv, dict):
continue
token = inv.get('token')
if not token:
continue
existing_tokens = {i.get('token') for i in data.get('invite_links') or []}
if token in existing_tokens:
continue
item = dict(inv)
item['server_id'] = target_server_id
if not item.get('id'):
item['id'] = str(uuid.uuid4())
data.setdefault('invite_links', []).append(item)
invites_added += 1
restored = []
restore_errors = []
if restore_protocols and ssh is not None:
bm = BackupManager(ssh)
backups = manifest.get('protocol_backups') or []
# Fallback: scan protocols/ folder
if not backups:
for name in names:
if name.startswith('protocols/') and name.endswith('.tar.gz'):
backups.append({
'protocol': name.rsplit('/', 1)[-1].split('-', 1)[0],
'filename': name.rsplit('/', 1)[-1],
'archive': name,
})
target = data['servers'][target_server_id]
protocols = target.get('protocols') or {}
for item in backups:
proto = item.get('protocol')
filename = item.get('filename')
archive = item.get('archive') or f'protocols/{filename}'
if not proto or not filename or archive not in names:
continue
try:
blob = zf.read(archive)
remote = f'{BackupManager.BACKUP_ROOT}/{bm.safe_protocol(proto)}/{bm.safe_filename(filename) or filename}'
_upload_bytes_sudo(ssh, blob, remote)
container = ''
if protocol_container_name_fn:
container = protocol_container_name_fn(proto) or ''
info = protocols.get(proto) or {}
container = info.get('container_name') or item.get('container') or container or ''
result = bm.restore_backup(proto, container, filename)
if result.get('status') == 'success':
restored.append(proto)
# Mark installed in panel metadata
pinfo = dict(protocols.get(proto) or {})
pinfo['installed'] = True
if container:
pinfo['container_name'] = container
protocols[proto] = pinfo
else:
restore_errors.append({
'protocol': proto,
'error': result.get('message') or 'restore failed',
})
except Exception as e:
logger.exception('Failed restoring protocol %s', proto)
restore_errors.append({'protocol': proto, 'error': str(e)})
target['protocols'] = protocols
return {
'users_mapped': len(user_map),
'connections': conn_stats,
'invites_added': invites_added,
'protocols_restored': restored,
'protocol_errors': restore_errors,
'connect_domain': (panel.get('server') or {}).get('connect_domain') or '',
'hint': (
'Update DNS A-record for connect_domain to this server IP. '
'Existing client configs keep working if protocol state was restored.'
),
}
+354 -136
View File
@@ -1,5 +1,7 @@
import json
import os
import re
import secrets
import uuid
import logging
import base64
@@ -9,13 +11,23 @@ import urllib.parse
logger = logging.getLogger(__name__)
CERTBOT_IMAGE = 'certbot/certbot:latest'
CERTBOT_CF_IMAGE = 'certbot/dns-cloudflare:latest'
XRAY_RELEASE = 'v26.3.27'
def _q(value):
return shlex.quote(str(value))
class XrayManager:
"""Manages Xray (VLESS-Reality) protocol installation and client management."""
"""Manages Xray VLESS + XHTTP + TLS (Let's Encrypt) for DPI-resistant installs."""
PROTOCOL = 'xray'
CONTAINER_NAME = 'amnezia-xray'
IMAGE_NAME = 'amneziavpn/amnezia-xray' # or we can build it
IMAGE_NAME = 'amneziavpn/amnezia-xray'
DEFAULT_PORT = 443
def __init__(self, ssh_manager, protocol='xray'):
self.ssh = ssh_manager
self.protocol = protocol or self.PROTOCOL
@@ -155,35 +167,182 @@ class XrayManager:
'container_exists': exists,
'container_running': running,
'clients_count': len(clients),
'port': meta.get('port')
'port': meta.get('port'),
'domain': meta.get('domain') or meta.get('site_name'),
'transport': meta.get('transport') or 'xhttp',
'security': meta.get('security') or 'tls',
}
def install_protocol(self, port=443, site_name='yahoo.com'):
"""Full installation of Xray."""
def _validate_domain(self, domain):
domain = (domain or '').strip().lower().rstrip('.')
if not domain or not re.match(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$', domain):
raise ValueError('Valid domain is required for Xray XHTTP+TLS (e.g. vpn.example.com)')
return domain
def _validate_email(self, email):
email = (email or '').strip()
if not email or '@' not in email or '.' not in email.split('@')[-1]:
raise ValueError('Valid email is required for Let\'s Encrypt')
return email
def _certs_dir(self):
return f'{self._config_dir()}/certs'
def _letsencrypt_dir(self):
return f'{self._config_dir()}/letsencrypt'
def _cert_path(self):
return f'{self._certs_dir()}/fullchain.pem'
def _key_path(self):
return f'{self._certs_dir()}/privkey.pem'
def _random_xhttp_path(self):
# Looks like a static/CDN asset path — harder for simple DPI signatures.
return f'/assets/{secrets.token_hex(8)}/{secrets.token_hex(4)}.js'
def _cf_creds_path(self):
return f'{self._config_dir()}/cloudflare.ini'
def _write_cloudflare_ini(self, token):
token = (token or '').strip()
if not token:
raise ValueError('Cloudflare API token is required for DNS validation')
# Restrictive file for certbot dns plugin
content = f"dns_cloudflare_api_token = {token}\n"
path = self._cf_creds_path()
self.ssh.run_sudo_command(f"mkdir -p {_q(self._config_dir())}")
self.ssh.upload_file_sudo(content, path)
self.ssh.run_sudo_command(f"chmod 600 {_q(path)}")
return path
def _install_issued_certs(self, domain, le_dir):
copy_script = f"""
set -e
LIVE={_q(le_dir)}/live/{_q(domain)}
test -s "$LIVE/fullchain.pem"
test -s "$LIVE/privkey.pem"
mkdir -p {_q(self._certs_dir())}
cp -f "$LIVE/fullchain.pem" {_q(self._cert_path())}
cp -f "$LIVE/privkey.pem" {_q(self._key_path())}
chmod 644 {_q(self._cert_path())} {_q(self._key_path())}
"""
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(copy_script)}", timeout=30)
if code != 0:
raise RuntimeError(f'Failed to install certificate files: {err or out}')
def _issue_certificate(self, domain, email, *, acme_method='cloudflare', cloudflare_token=None):
"""Issue Let's Encrypt cert via Cloudflare DNS-01 (preferred) or HTTP-01 standalone."""
method = (acme_method or 'cloudflare').strip().lower()
if method in ('cf', 'dns', 'dns-01', 'cloudflare_dns'):
method = 'cloudflare'
if method in ('http', 'http-01', 'standalone', 'port80'):
method = 'http'
le_dir = self._letsencrypt_dir()
certs_dir = self._certs_dir()
self.ssh.run_sudo_command(f"mkdir -p {_q(le_dir)} {_q(certs_dir)}")
self.ssh.run_sudo_command("docker rm -fv amnezia-xray-certbot 2>/dev/null || true")
if method == 'cloudflare':
creds = self._write_cloudflare_ini(cloudflare_token)
self.ssh.run_sudo_command(f"docker pull {CERTBOT_CF_IMAGE}", timeout=180)
# DNS-01 — no TCP 80 needed. Token needs Zone:DNS:Edit on the domain zone.
cmd = (
f"docker run --rm --name amnezia-xray-certbot "
f"-v {_q(le_dir)}:/etc/letsencrypt "
f"-v {_q(creds)}:/cloudflare.ini:ro "
f"{CERTBOT_CF_IMAGE} certonly "
f"--dns-cloudflare "
f"--dns-cloudflare-credentials /cloudflare.ini "
f"--dns-cloudflare-propagation-seconds 30 "
f"--non-interactive --agree-tos --no-eff-email "
f"--email {_q(email)} -d {_q(domain)}"
)
out, err, code = self.ssh.run_sudo_command(cmd, timeout=420)
if code != 0:
raise RuntimeError(
f"Let's Encrypt (Cloudflare DNS) failed for {domain}. "
f"Check API token (Zone:DNS:Edit) and that the domain is on Cloudflare. {err or out}"
)
self._install_issued_certs(domain, le_dir)
return 'cloudflare'
# HTTP-01 standalone — needs free TCP 80
self.ssh.run_sudo_command(f"docker pull {CERTBOT_IMAGE}", timeout=180)
cmd = (
f"docker run --rm --name amnezia-xray-certbot "
f"-p 80:80 "
f"-v {_q(le_dir)}:/etc/letsencrypt "
f"{CERTBOT_IMAGE} certonly --standalone "
f"--non-interactive --agree-tos --no-eff-email "
f"--email {_q(email)} -d {_q(domain)}"
)
out, err, code = self.ssh.run_sudo_command(cmd, timeout=300)
if code != 0:
raise RuntimeError(
f"Let's Encrypt (HTTP-01) failed for {domain}. "
f"Point an A-record to this server and free TCP port 80. {err or out}"
)
self._install_issued_certs(domain, le_dir)
return 'http'
def _is_xhttp_tls_inbound(self, inbound):
stream = (inbound or {}).get('streamSettings') or {}
return (
str(stream.get('network') or '').lower() in ('xhttp', 'splithttp')
and str(stream.get('security') or '').lower() == 'tls'
)
def _is_reality_inbound(self, inbound):
stream = (inbound or {}).get('streamSettings') or {}
return str(stream.get('security') or '').lower() == 'reality'
def install_protocol(self, port=443, domain=None, email=None, site_name=None,
acme_method='cloudflare', cloudflare_token=None):
"""Install VLESS + XHTTP + TLS (Let's Encrypt via Cloudflare DNS or HTTP-01)."""
results = []
domain = self._validate_domain(domain or site_name)
email = self._validate_email(email)
port = int(port or self.DEFAULT_PORT)
method = (acme_method or 'cloudflare').strip().lower()
if method in ('cf', 'dns', 'dns-01', 'cloudflare_dns'):
method = 'cloudflare'
if method in ('http', 'http-01', 'standalone', 'port80'):
method = 'http'
if method == 'http' and port == 80:
raise RuntimeError('Port 80 is reserved for Let\'s Encrypt HTTP-01 validation')
if method == 'cloudflare' and not (cloudflare_token or '').strip():
raise ValueError('Cloudflare API token is required')
if not self.check_docker_installed():
results.append("Installing Docker...")
# Using same install method as AWGManager or assume it's installed
pass
results.append("Docker not detected — install may fail")
results.append("Removing old container if exists...")
if self.check_protocol_installed():
self.remove_container()
if method == 'cloudflare':
results.append(f"Issuing Let's Encrypt cert via Cloudflare DNS for {domain}...")
else:
results.append(f"Issuing Let's Encrypt cert via HTTP-01 (port 80) for {domain}...")
used = self._issue_certificate(
domain, email, acme_method=method, cloudflare_token=cloudflare_token,
)
results.append(f"TLS certificate ready ({used})")
results.append("Building Docker image...")
config_dir = self._config_dir()
dockerfile_folder = f"/opt/amnezia/{self.container_name}"
dockerfile_content = f"""FROM alpine:3.15
RUN apk add --no-cache curl unzip bash openssl netcat-openbsd dumb-init rng-tools xz iptables ip6tables
RUN apk add --no-cache curl unzip bash openssl netcat-openbsd dumb-init rng-tools xz iptables ip6tables ca-certificates
RUN apk --update upgrade --no-cache
RUN mkdir -p {config_dir}
RUN curl -L -H "Cache-Control: no-cache" -o /root/xray.zip "https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-64.zip" && \\
RUN curl -L -H "Cache-Control: no-cache" -o /root/xray.zip "https://github.com/XTLS/Xray-core/releases/download/{XRAY_RELEASE}/Xray-linux-64.zip" && \\
unzip /root/xray.zip -d /usr/bin/ && \\
chmod a+x /usr/bin/xray && \\
rm /root/xray.zip
# Tune network
RUN echo "fs.file-max = 51200" >> /etc/sysctl.conf && \\
echo "net.core.rmem_max = 67108864" >> /etc/sysctl.conf && \\
echo "net.core.wmem_max = 67108864" >> /etc/sysctl.conf && \\
@@ -191,18 +350,12 @@ RUN echo "fs.file-max = 51200" >> /etc/sysctl.conf && \\
echo "net.core.somaxconn = 4096" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_syncookies = 1" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_tw_reuse = 1" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_tw_recycle = 0" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_fin_timeout = 30" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_keepalive_time = 1200" >> /etc/sysctl.conf && \\
echo "net.ipv4.ip_local_port_range = 10000 65000" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_max_syn_backlog = 8192" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_max_tw_buckets = 5000" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_fastopen = 3" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_mem = 25600 51200 102400" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_rmem = 4096 87380 67108864" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_wmem = 4096 65536 67108864" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_mtu_probing = 1" >> /etc/sysctl.conf && \\
echo "net.ipv4.tcp_congestion_control = hybla" >> /etc/sysctl.conf
echo "net.ipv4.tcp_congestion_control = bbr" >> /etc/sysctl.conf
RUN mkdir -p /etc/security && \\
echo "* soft nofile 51200" >> /etc/security/limits.conf && \\
@@ -217,33 +370,20 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
"""
self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}")
self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile")
_, err, code = self.ssh.run_sudo_command(
f"docker build --no-cache -t {self.image_name} {dockerfile_folder}", timeout=300
)
if code != 0: raise RuntimeError(f"Failed to build container: {err}")
if code != 0:
raise RuntimeError(f"Failed to build container: {err}")
results.append("Generating keys and config...")
# We generate a base config using a temp container or directly if host has openssl
# Xray keypair generation using a temporary run overriding the entrypoint
keypair_cmd = f"docker run --rm --entrypoint=\"\" {self.image_name} /usr/bin/xray x25519"
out_kp, err_kp, code_kp = self.ssh.run_sudo_command(keypair_cmd)
if code_kp != 0: raise RuntimeError(f"Failed to generate x25519 keys: {err_kp}")
priv_key = ""
pub_key = ""
for line in out_kp.split('\n'):
if "Private" in line: priv_key = line.split(":", 1)[1].strip()
if "Public" in line: pub_key = line.split(":", 1)[1].strip()
results.append("Generating XHTTP+TLS config...")
xhttp_path = self._random_xhttp_path()
# auto → packet-up under TLS (CDN/middlebox friendly); works direct too.
xhttp_mode = 'auto'
short_id_cmd = f"docker run --rm --entrypoint=\"\" {self.image_name} openssl rand -hex 8"
out_sid, _, _ = self.ssh.run_sudo_command(short_id_cmd)
short_id = out_sid.strip()
# Generate initial server.json with Stats and API enabled
server_json = {
"log": {"loglevel": "error"},
"log": {"loglevel": "warning"},
"stats": {},
"api": {
"services": ["StatsService", "LoggerService", "HandlerService"],
@@ -260,6 +400,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
},
"inbounds": [
{
"listen": "0.0.0.0",
"port": int(port),
"protocol": "vless",
"tag": "proxy",
@@ -268,14 +409,37 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"dest": f"{site_name}:443",
"serverNames": [site_name],
"privateKey": priv_key,
"shortIds": [short_id]
"network": "xhttp",
"security": "tls",
"tlsSettings": {
"serverName": domain,
"minVersion": "1.3",
"alpn": ["h2", "http/1.1"],
"certificates": [{
"certificateFile": self._cert_path(),
"keyFile": self._key_path()
}]
},
"xhttpSettings": {
"path": xhttp_path,
"host": domain,
"mode": xhttp_mode,
"headers": {
"User-Agent": [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
]
}
},
"sockopt": {
"tcpFastOpen": True,
"tcpNoDelay": True
}
},
"sniffing": {
"enabled": True,
"destOverride": ["http", "tls", "quic"],
"routeOnly": True
}
},
{
@@ -286,29 +450,48 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
"tag": "api"
}
],
"outbounds": [{"protocol": "freedom"}],
"outbounds": [
{"protocol": "freedom", "tag": "direct"},
{"protocol": "blackhole", "tag": "block"}
],
"routing": {
"domainStrategy": "AsIs",
"rules": [
{
"type": "field",
"inboundTag": ["api"],
"outboundTag": "api",
"type": "field"
"outboundTag": "api"
},
{
"type": "field",
"protocol": ["bittorrent"],
"outboundTag": "block"
}
]
}
}
self.ssh.run_sudo_command(f"mkdir -p {config_dir}")
self.ssh.upload_file_sudo(json.dumps(server_json, indent=2), f"{config_dir}/server.json")
# Native layout — separate key files matching the official Amnezia client install.
# See client/server_scripts/xray/configure_container.sh for the canonical layout.
self.ssh.upload_file_sudo(priv_key + '\n', "/opt/amnezia/xray/xray_private.key")
self.ssh.upload_file_sudo(pub_key + '\n', "/opt/amnezia/xray/xray_public.key")
self.ssh.upload_file_sudo(short_id + '\n', "/opt/amnezia/xray/xray_short_id.key")
# xray_uuid.key marks the install-time "default" client whose ID gets skipped on
# auto-import. Panel installs do not reserve such a client, so we leave it empty.
self.ssh.upload_file_sudo('\n', "/opt/amnezia/xray/xray_uuid.key")
self.ssh.upload_file_sudo("[]", "/opt/amnezia/xray/clientsTable")
meta = {
'transport': 'xhttp',
'security': 'tls',
'domain': domain,
'email': email,
'path': xhttp_path,
'mode': xhttp_mode,
'port': int(port),
'fingerprint': 'chrome',
'alpn': 'h2',
'site_name': domain,
'acme_method': used,
}
self.ssh.upload_file_sudo(json.dumps(meta, indent=2), f"{config_dir}/meta.json")
self.ssh.upload_file_sudo("[]", f"{config_dir}/clientsTable.json")
# Clear cached layout so we pick panel (meta.json) next.
if hasattr(self, '_cached_layout'):
delattr(self, '_cached_layout')
results.append("Starting container...")
run_cmd = f"""docker run -d \\
@@ -316,19 +499,26 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
--privileged \\
--cap-add=NET_ADMIN \\
-p {port}:{port}/tcp \\
-p {port}:{port}/udp \\
-v {config_dir}:{config_dir} \\
--name {self.container_name} \\
{self.image_name}"""
_, err, code = self.ssh.run_sudo_command(run_cmd)
if code != 0: raise RuntimeError(f"Failed to run container: {err}")
if code != 0:
raise RuntimeError(f"Failed to run container: {err}")
# Try to connect to network if needed
self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.container_name} || true")
results.append("Xray configured and running")
return {'status': 'success', 'protocol': self.protocol, 'port': port, 'log': results}
results.append("Xray VLESS+XHTTP+TLS configured and running")
return {
'status': 'success',
'protocol': self.protocol,
'port': port,
'domain': domain,
'path': xhttp_path,
'acme_method': used,
'log': results,
}
def remove_container(self):
self.ssh.run_sudo_command(f"docker stop {self.container_name}")
@@ -431,28 +621,61 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
return False
return True
def _client_object(self, client_id, inbound=None):
client = {'id': client_id, 'email': client_id}
# Vision flow only for classic TCP/Reality; XHTTP must not set flow.
if inbound and self._is_reality_inbound(inbound):
network = str((inbound.get('streamSettings') or {}).get('network') or 'tcp').lower()
if network in ('tcp', 'raw', ''):
client['flow'] = 'xtls-rprx-vision'
return client
def _get_meta_json(self):
"""Read protocol metadata. Supports both layouts.
Native layout pulls keys from xray_*.key files. Panel layout reads
meta.json. Either way, port and site_name come from server.json since
that is the authoritative runtime config — meta.json may go stale if
the user edits server.json directly via the panel.
"""
"""Read protocol metadata from meta.json and/or server.json (XHTTP+TLS or legacy Reality)."""
config = self._get_server_json() or {}
inbound = self._get_vless_inbound(config) or {}
stream = inbound.get('streamSettings') or {}
port = inbound.get('port')
port = None
site_name = None
rs = {}
try:
ib = next(b for b in config.get('inbounds', []) if b.get('protocol') == 'vless')
port = ib.get('port')
rs = ib.get('streamSettings', {}).get('realitySettings', {}) or {}
names = rs.get('serverNames') or []
if names:
site_name = names[0]
except StopIteration:
pass
meta = {}
out = self._read_remote_file(f"{self._config_dir()}/meta.json")
if out:
try:
meta = json.loads(out)
except Exception:
meta = {}
if port is not None:
meta['port'] = port
network = str(stream.get('network') or '').lower()
security = str(stream.get('security') or '').lower()
if network in ('xhttp', 'splithttp') and security == 'tls':
xs = stream.get('xhttpSettings') or stream.get('splithttpSettings') or {}
tls = stream.get('tlsSettings') or {}
meta['transport'] = 'xhttp'
meta['security'] = 'tls'
meta['path'] = xs.get('path') or meta.get('path') or '/'
meta['mode'] = xs.get('mode') or meta.get('mode') or 'auto'
meta['domain'] = (
meta.get('domain')
or xs.get('host')
or tls.get('serverName')
or meta.get('site_name')
)
meta['site_name'] = meta.get('domain') or meta.get('site_name')
meta['fingerprint'] = meta.get('fingerprint') or 'chrome'
meta['alpn'] = meta.get('alpn') or 'h2'
return meta
# Legacy Reality
rs = stream.get('realitySettings') or {}
names = rs.get('serverNames') or []
site_name = names[0] if names else meta.get('site_name') or 'yahoo.com'
meta['transport'] = 'tcp'
meta['security'] = 'reality'
meta['site_name'] = site_name
if self._detect_layout() == 'native':
priv = (self._read_remote_file(f"{self._config_dir()}/xray_private.key") or '').strip()
@@ -465,26 +688,15 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
sid = sids[0] if sids else ''
if not pub:
pub = self._derive_pubkey_from_priv(priv)
return {
meta.update({
'private_key': priv,
'public_key': pub,
'short_id': sid,
'port': port,
'site_name': site_name or 'yahoo.com',
}
'site_name': site_name,
})
return meta
# Panel (legacy) layout
out = self._read_remote_file(f"{self._config_dir()}/meta.json")
meta = {}
if out:
try:
meta = json.loads(out)
except Exception:
meta = {}
if port is not None:
meta['port'] = port
if site_name:
meta['site_name'] = site_name
if not meta.get('private_key'):
meta['private_key'] = rs.get('privateKey', '')
if not meta.get('short_id'):
@@ -678,38 +890,55 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
def get_client_config(self, protocol, client_id, server_host, port):
clients = self._get_clients_table()
client = next((c for c in clients if c['clientId'] == client_id), None)
if not client: return None
if not client:
return None
meta = self._get_meta_json()
if not meta: return None
config = self._get_server_json()
sni = meta.get('site_name', 'yahoo.com')
if config:
try:
sni = config['inbounds'][0]['streamSettings']['realitySettings']['serverNames'][0]
except Exception:
pass
# Format URL
# vless://{id}@{host}:{port}?type=tcp&security=reality&pbk={public_key}&sni={sni}&fp=chrome&sid={short_id}&spx=%2F&flow=xtls-rprx-vision#{name}
meta = self._get_meta_json() or {}
config = self._get_server_json() or {}
inbound = self._get_vless_inbound(config) or {}
name = client.get('userData', {}).get('clientName', 'vpn')
encoded_name = urllib.parse.quote(name)
url = (
f"vless://{client_id}@{server_host}:{meta.get('port', port)}"
f"?type=tcp&security=reality&pbk={meta['public_key']}"
f"&sni={sni}&fp=chrome&sid={meta['short_id']}"
listen_port = meta.get('port', port)
if self._is_xhttp_tls_inbound(inbound) or (meta.get('transport') == 'xhttp' and meta.get('security') == 'tls'):
domain = meta.get('domain') or meta.get('site_name') or server_host
path = meta.get('path') or '/'
mode = meta.get('mode') or 'auto'
fp = meta.get('fingerprint') or 'chrome'
alpn = meta.get('alpn') or 'h2'
# Prefer domain for TLS SNI / cert match; fall back to connect host.
host = domain or server_host
return (
f"vless://{client_id}@{host}:{listen_port}"
f"?encryption=none&security=tls&type=xhttp"
f"&path={urllib.parse.quote(path, safe='')}"
f"&mode={urllib.parse.quote(str(mode), safe='')}"
f"&host={urllib.parse.quote(domain, safe='')}"
f"&sni={urllib.parse.quote(domain, safe='')}"
f"&fp={urllib.parse.quote(fp, safe='')}"
f"&alpn={urllib.parse.quote(alpn, safe='')}"
f"#{encoded_name}"
)
# Legacy Reality share link
sni = meta.get('site_name', 'yahoo.com')
try:
sni = inbound['streamSettings']['realitySettings']['serverNames'][0]
except Exception:
pass
return (
f"vless://{client_id}@{server_host}:{listen_port}"
f"?type=tcp&security=reality&pbk={meta.get('public_key', '')}"
f"&sni={sni}&fp=chrome&sid={meta.get('short_id', '')}"
f"&spx=%2F&flow=xtls-rprx-vision#{encoded_name}"
)
return url
def add_client(self, protocol, client_name, server_host, port):
client_id = str(uuid.uuid4())
config = self._get_server_json()
if not config: raise RuntimeError("Xray server config not found.")
if not config:
raise RuntimeError("Xray server config not found.")
self._upgrade_config_for_stats(config, restart=False)
@@ -717,13 +946,8 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
if not inbound:
raise RuntimeError("Xray VLESS inbound not found.")
# Ensure clients structure exists
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
client = {
"id": client_id,
"flow": "xtls-rprx-vision",
"email": client_id
}
client = self._client_object(client_id, inbound)
if not self._xray_api_add_user(config, client):
raise RuntimeError(
"Xray runtime API is not available for hot user updates. "
@@ -733,7 +957,6 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
clients_node.append(client)
self._write_server_json(config, restart=False)
# Update table
clients_table = self._get_clients_table()
clients_table.append({
'clientId': client_id,
@@ -758,14 +981,9 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
raise RuntimeError("Xray VLESS inbound not found.")
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
# If toggling on and not present, we can re-add it from clientsTable
if enable:
if not any(c['id'] == client_id for c in clients_node):
client = {
"id": client_id,
"flow": "xtls-rprx-vision",
"email": client_id
}
client = self._client_object(client_id, inbound)
if not self._xray_api_add_user(config, client):
raise RuntimeError("Xray runtime API failed to enable the client without restarting the container.")
clients_node.append(client)
+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'));
+283 -106
View File
@@ -1,102 +1,104 @@
{% 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">
<input type="password" id="invitePassword" class="form-input" placeholder="{{ _('password') }}" required autofocus>
</div>
<button type="submit" class="btn btn-primary" id="authBtn">
<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>
<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="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="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 %}
</div>
<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-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 %}
</div>
</div>
</div>
<p id="statusHint" style="text-align:center; color:var(--text-muted); font-size:0.88rem; margin:0 0 var(--space-md);">
{% if invite.duration_days %}
{{ _('invite_duration_starts_hint').replace('{}', invite.duration_days|string) }}
{% else %}
{{ _('invite_get_config_hint') }}
{% endif %}
</p>
<p id="statusHint" style="text-align:center; color:var(--text-muted); font-size:0.88rem; margin:0 0 var(--space-md);">
{% if invite.duration_days %}
{{ _('invite_duration_starts_hint').replace('{}', invite.duration_days|string) }}
{% else %}
{{ _('invite_get_config_hint') }}
{% 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>
<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>
<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>
<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 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 class="config-panel" id="panel-qr">
<div class="qr-container"><div id="qrcode"></div></div>
</div>
</div>
</div>
@@ -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 = '';
new QRCode(qrContainer, {
text: currentVpnLink || config, width: 256, height: 256,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L
});
switchConfigTab('conf');
openModal('configModal');
const qrPayload = pickKey(currentConfig, currentVpnLink) || currentConfig;
if (qrPayload && typeof QRCode !== 'undefined') {
new QRCode(qrContainer, {
text: qrPayload, width: 220, height: 220,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L
});
}
}
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>
+20 -2
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') }}
@@ -197,16 +205,17 @@
const xuiDefaultInbound = {{ xui_default_inbound | int }};
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
const PROTO_TITLES = {
const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0',
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;
+330 -28
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;">
@@ -647,6 +648,40 @@
</div>
</div>
<!-- ===== Migrate users / protocol state ===== -->
<div class="modal-backdrop" id="migrateModal">
<div class="modal" style="max-width: 560px;">
<div class="modal-header">
<div>
<h2 class="modal-title">{{ _('migrate_title') }}</h2>
<div class="text-muted text-sm">{{ _('migrate_desc') }}</div>
</div>
<button class="modal-close" onclick="closeModal('migrateModal')">×</button>
</div>
<div class="form-hint" style="margin-bottom: var(--space-md);">{{ _('migrate_hint') }}</div>
<div class="flex" style="flex-direction: column; gap: var(--space-md);">
<div>
<div class="form-label">{{ _('migrate_export_label') }}</div>
<button type="button" class="btn btn-secondary" id="migrateExportBtn" onclick="exportServerMigrate()"
style="width:100%; justify-content:center; gap:var(--space-sm);">
⬇️ {{ _('migrate_export_btn') }}
</button>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<div class="form-label">{{ _('migrate_import_label') }}</div>
<input type="file" id="migrateFile" accept=".zip,application/zip" style="display:none;"
onchange="importServerMigrate(event)">
<button type="button" class="btn btn-primary" id="migrateImportBtn"
onclick="document.getElementById('migrateFile').click()"
style="width:100%; justify-content:center; gap:var(--space-sm);">
⬆️ {{ _('migrate_import_btn') }}
</button>
<div class="form-hint" style="margin-top: var(--space-xs);">{{ _('migrate_import_hint') }}</div>
</div>
</div>
</div>
</div>
<!-- ===== Install Protocol Modal ===== -->
<div class="modal-backdrop" id="installModal">
<div class="modal">
@@ -761,6 +796,41 @@
<div class="form-hint">{{ _('nginx_install_hint') }}</div>
</div>
<div id="xrayOptions"
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
<div class="form-hint" id="xrayPortsWarning" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
{{ _('xray_ports_warning_cf') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('xray_domain') }} *</label>
<input class="form-input" type="text" id="installXrayDomain" placeholder="vpn.example.com" oninput="updateXrayDnsHint()">
<div class="form-hint" id="xrayDnsHint"></div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xray_email') }} *</label>
<input class="form-input" type="email" id="installXrayEmail" placeholder="admin@example.com">
</div>
<div class="form-group">
<label class="form-label">{{ _('xray_acme_method') }}</label>
<div style="display:flex; flex-direction:column; gap:8px;">
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer;">
<input type="radio" name="xrayAcmeMethod" value="cloudflare" checked onchange="updateXrayAcmeUi()">
<span>{{ _('xray_acme_cloudflare') }}</span>
</label>
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer;">
<input type="radio" name="xrayAcmeMethod" value="http" onchange="updateXrayAcmeUi()">
<span>{{ _('xray_acme_http') }}</span>
</label>
</div>
</div>
<div class="form-group" id="xrayCfTokenGroup">
<label class="form-label">{{ _('xray_cf_token') }} *</label>
<input class="form-input" type="password" id="installXrayCfToken" autocomplete="off" placeholder="Cloudflare API Token">
<div class="form-hint">{{ _('xray_cf_token_hint') }}</div>
</div>
<div class="form-hint">{{ _('xray_install_hint') }}</div>
</div>
<div id="hysteriaOptions"
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
<div class="form-hint" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
@@ -1193,11 +1263,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' },
@@ -1378,6 +1449,9 @@
<button class="btn btn-secondary" onclick="closeModal('managementModal'); checkServer();" style="justify-content: flex-start; padding: 12px 20px;">
<span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('refresh')}</span> ${_('check_server_services')}
</button>
<button class="btn btn-primary" onclick="closeModal('managementModal'); openModal('migrateModal');" style="justify-content: flex-start; padding: 12px 20px;">
<span style="font-size: 1.2rem; margin-right: 8px;">📦</span> ${_('migrate_title')}
</button>
<button class="btn btn-warning" onclick="closeModal('managementModal'); rebootServer();" style="justify-content: flex-start; padding: 12px 20px;">
<span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('refresh')}</span> ${_('reboot_server')}
</button>
@@ -1391,6 +1465,79 @@
`;
}
async function exportServerMigrate() {
const btn = document.getElementById('migrateExportBtn');
if (btn) btn.disabled = true;
showToast(_('migrate_exporting'), 'info');
try {
const res = await fetch(`/api/servers/${SERVER_ID}/migrate/export?include_protocols=true`, {
credentials: 'same-origin',
});
if (!res.ok) {
let err = _('error');
try {
const data = await res.json();
err = data.error || err;
} catch (_) {}
throw new Error(err);
}
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const match = /filename="?([^"]+)"?/i.exec(cd);
const filename = match ? match[1] : `amnezia-migrate-${SERVER_ID}.zip`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showToast(_('migrate_export_done'), 'success');
} catch (err) {
showToast(`${_('error')}: ${err.message}`, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
async function importServerMigrate(e) {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
if (!confirm(_('migrate_import_confirm'))) return;
const btn = document.getElementById('migrateImportBtn');
if (btn) btn.disabled = true;
showToast(_('migrate_importing'), 'info');
try {
const form = new FormData();
form.append('file', file);
const res = await fetch(`/api/servers/${SERVER_ID}/migrate/import?restore_protocols=true`, {
method: 'POST',
body: form,
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.status !== 'success') {
throw new Error(data.error || `HTTP ${res.status}`);
}
const added = (data.connections && data.connections.added) || 0;
const protos = (data.protocols_restored || []).join(', ') || '—';
showToast(
`${_('migrate_import_done')} (+${added} ${_('connections') || 'conn'}; ${protos})`,
'success'
);
if (data.connect_domain) {
showToast(`${_('migrate_dns_hint')}: ${data.connect_domain}`, 'info');
}
setTimeout(() => checkServer(), 1500);
} catch (err) {
showToast(`${_('error')}: ${err.message}`, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function openManagementModal() {
renderManagementModal();
openModal('managementModal');
@@ -1537,6 +1684,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;
@@ -1833,6 +1981,16 @@
if (protoBase(proto) === 'hysteria' && info.domain) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('hysteria_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
}
if (protoBase(proto) === 'xray' && info.domain) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('xray_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
}
if (protoBase(proto) === 'xray' && info.acme_method) {
const acmeLabel = info.acme_method === 'cloudflare' ? _('xray_acme_cloudflare_short') : _('xray_acme_http_short');
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('xray_acme_method')}</span><span class="protocol-info-value">${acmeLabel}</span></div>`;
}
if (protoBase(proto) === 'xray' && (info.transport || info.security)) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('protocol_label')}</span><span class="protocol-info-value">VLESS · ${(info.transport || 'xhttp').toUpperCase()}+${(info.security || 'tls').toUpperCase()}</span></div>`;
}
if (protoBase(proto) === 'naiveproxy' && info.domain) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('naiveproxy_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
}
@@ -2281,6 +2439,34 @@
hint.innerHTML = `${_('naiveproxy_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`;
}
function getXrayAcmeMethod() {
const el = document.querySelector('input[name="xrayAcmeMethod"]:checked');
return el ? el.value : 'cloudflare';
}
function updateXrayAcmeUi() {
const method = getXrayAcmeMethod();
const cfGroup = document.getElementById('xrayCfTokenGroup');
const warn = document.getElementById('xrayPortsWarning');
if (cfGroup) cfGroup.style.display = method === 'cloudflare' ? '' : 'none';
if (warn) warn.textContent = method === 'cloudflare' ? _('xray_ports_warning_cf') : _('xray_ports_warning');
updateXrayDnsHint();
const portHint = document.getElementById('installPortHint');
if (portHint && protoBase(currentInstallProto) === 'xray' && !currentInstallAnother) {
portHint.textContent = method === 'cloudflare' ? _('port_xray_hint_cf') : _('port_xray_hint');
}
}
function updateXrayDnsHint() {
const input = document.getElementById('installXrayDomain');
const hint = document.getElementById('xrayDnsHint');
if (!input || !hint) return;
const domain = (input.value || '').trim() || 'vpn.example.com';
const method = getXrayAcmeMethod();
const prefix = method === 'cloudflare' ? _('xray_dns_hint_cf') : _('xray_dns_hint');
hint.innerHTML = `${prefix} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`;
}
function openInstallModal(proto, installAnother = false) {
const base = protoBase(proto);
currentInstallProto = installAnother ? base : proto;
@@ -2299,6 +2485,7 @@
const hysteriaOpts = document.getElementById('hysteriaOptions');
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
const mieruOpts = document.getElementById('mieruOptions');
const xrayOpts = document.getElementById('xrayOptions');
telemtOpts.style.display = 'none';
socks5Opts.style.display = 'none';
@@ -2307,6 +2494,7 @@
hysteriaOpts.style.display = 'none';
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
if (mieruOpts) mieruOpts.style.display = 'none';
if (xrayOpts) xrayOpts.style.display = 'none';
if (portGroup) portGroup.style.display = '';
if (base === 'dns') {
@@ -2319,6 +2507,12 @@
portInput.disabled = false;
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('port_xray_hint');
if (xrayOpts) xrayOpts.style.display = 'block';
const xrDomain = document.getElementById('installXrayDomain');
const xrEmail = document.getElementById('installXrayEmail');
if (xrDomain && !xrDomain.value && SERVER_SSL_DOMAIN) xrDomain.value = SERVER_SSL_DOMAIN;
if (xrEmail && !xrEmail.value && SERVER_SSL_EMAIL) xrEmail.value = SERVER_SSL_EMAIL;
updateXrayAcmeUi();
} else if (base === 'telemt') {
portLabel.textContent = _('port') + ' (TCP)';
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
@@ -2400,6 +2594,20 @@
async function installProtocol() {
const port = document.getElementById('installPort').value;
if (protoBase(currentInstallProto) === 'xray') {
const xrDomain = (document.getElementById('installXrayDomain')?.value || '').trim();
const xrEmail = (document.getElementById('installXrayEmail')?.value || '').trim();
const acme = getXrayAcmeMethod();
const cfToken = (document.getElementById('installXrayCfToken')?.value || '').trim();
if (!xrDomain || !xrEmail) {
showToast(_('xray_domain') + ' / ' + _('xray_email'), 'error');
return;
}
if (acme === 'cloudflare' && !cfToken) {
showToast(_('xray_cf_token'), 'error');
return;
}
}
const btn = document.getElementById('installBtn');
const text = document.getElementById('installBtnText');
const spinner = document.getElementById('installSpinner');
@@ -2458,6 +2666,13 @@
}
params.hysteria_domain = document.getElementById('installHysteriaDomain').value.trim();
params.hysteria_email = document.getElementById('installHysteriaEmail').value.trim();
} else if (protoBase(currentInstallProto) === 'xray') {
params.xray_domain = (document.getElementById('installXrayDomain')?.value || '').trim();
params.xray_email = (document.getElementById('installXrayEmail')?.value || '').trim();
params.xray_acme_method = getXrayAcmeMethod();
if (params.xray_acme_method === 'cloudflare') {
params.xray_cf_token = (document.getElementById('installXrayCfToken')?.value || '').trim();
}
} else if (protoBase(currentInstallProto) === 'naiveproxy') {
params.port = '443';
params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').value.trim();
@@ -2941,7 +3156,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');
@@ -3289,9 +3504,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();
}
+47 -2
View File
@@ -72,7 +72,7 @@
"docker_not_installed": "Docker not installed",
"awg_desc": "A newer version of the protocol based on awg-go. Supports advanced obfuscation with S3, S4 parameters.",
"awg_legacy_desc": "Original AWG version based on WireGuard kernel. Compatible with older client versions.",
"xray_desc": "Modern protocol that masks traffic as regular web traffic (XTLS-Reality). Resistant to deep packet analysis.",
"xray_desc": "VLESS over XHTTP+TLS — traffic looks like normal HTTPS/HTTP2. Tuned for DPI resistance (no Vision flow). Needs a domain; SSL via Cloudflare DNS or HTTP-01.",
"wireguard_desc": "Standard and fastest VPN protocol. Supported natively on all modern OS, but easily detected by DPI.",
"not_checked": "Not checked",
"connections": "Connections",
@@ -82,7 +82,22 @@
"no_connections_desc": "Add your first connection to generate a VPN configuration",
"install_protocol": "Install protocol",
"port_default_hint": "Default port: 55424. Make sure it\u0027s not busy",
"port_xray_hint": "Default port: 443 (recommended for Xray). Make sure it\u0027s not taken by another web server.",
"port_xray_hint": "Default 443/TCP. Prefer Cloudflare DNS ACME — port 80 is not needed. Point domain A-record (DNS only / grey cloud) to this server.",
"port_xray_hint_cf": "Default 443/TCP. Certificate via Cloudflare DNS — port 80 is not used. Keep Cloudflare proxy off (grey cloud).",
"xray_domain": "Domain",
"xray_email": "Let\u0027s Encrypt email",
"xray_dns_hint": "Create DNS record:",
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
"xray_install_hint": "Installs VLESS + XHTTP + TLS (Xray-core). Prefer Cloudflare API token (DNS-01) so TCP 80 stays free.",
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01). Reinstall replaces the previous Xray stack.",
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud). Reinstall replaces the previous Xray stack.",
"xray_acme_method": "Certificate method",
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
"xray_acme_http": "HTTP-01 — needs free TCP 80",
"xray_acme_cloudflare_short": "Cloudflare DNS",
"xray_acme_http_short": "HTTP-01",
"xray_cf_token": "Cloudflare API token",
"xray_cf_token_hint": "Create a token with Zone:DNS:Edit for this domain zone. Token is used only to issue the cert and is not shown again.",
"reinstall": "Reinstall",
"uninstall_confirm": "Uninstall {}? All connections and configurations will be lost.",
"stop_container_confirm": "Stop container {}?",
@@ -415,6 +430,20 @@
"server_connect_domain_current": "Endpoint in configs",
"server_connect_domain_ssh": "SSH",
"server_connect_domain_saved": "Connection domain saved",
"migrate_title": "Migrate users",
"migrate_desc": "Move clients to a new server with the same domain",
"migrate_hint": "Export keeps users, bindings and protocol state (keys/peers). On the new server: add it with the new IP and the same connect_domain → install the same protocols → import the ZIP → update the DNS A-record. Client configs stay unchanged.",
"migrate_export_label": "Export from this server",
"migrate_export_btn": "Download migration pack (.zip)",
"migrate_exporting": "Building migration pack…",
"migrate_export_done": "Migration pack downloaded",
"migrate_import_label": "Import onto this server",
"migrate_import_btn": "Import migration pack (.zip)",
"migrate_import_hint": "Install the same protocols on this server first. Do not use Move Connections — it regenerates keys.",
"migrate_import_confirm": "Import users and protocol state onto this server? Existing client_id values will be kept.",
"migrate_importing": "Importing migration…",
"migrate_import_done": "Migration imported",
"migrate_dns_hint": "Update the DNS A-record for domain",
"server_endpoint_label": "Endpoint",
"support_title": "Support the project",
"support_intro": "If this panel helps you, you can support development. Choose a convenient method below.",
@@ -511,6 +540,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",
+47 -2
View File
@@ -71,7 +71,7 @@
"docker_not_installed": "داکر نصب نیست",
"awg_desc": "نسخه جدید پروتکل بر پایه awg-go. پشتیبانی از مبهم‌سازی پیشرفته (S3, S4).",
"awg_legacy_desc": "نسخه اصلی AWG. سازگار با نسخه‌های قدیمی کلاینت.",
"xray_desc": "تغییر ظاهر ترافیک به ترافیک معمولی وب (XTLS-Reality). مقاوم در برابر فیلترینگ شدید.",
"xray_desc": "VLESS over XHTTP+TLS — looks like normal HTTPS/HTTP2. Needs a domain + Let\u0027s Encrypt.",
"not_checked": "بررسی نشده",
"connections": "اتصال‌ها",
"add": "افزودن",
@@ -80,7 +80,22 @@
"no_connections_desc": "اولین اتصال خود را برای ایجاد پیکربندی VPN اضافه کنید",
"install_protocol": "نصب پروتکل",
"port_default_hint": "پورت پیش‌فرض: 55424. مطمئن شوید این پورت آزاد است.",
"port_xray_hint": "پورت پیشنهادی: 443. مطمئن شوید توسط وب‌سرور دیگری اشغال نشده باشد.",
"port_xray_hint": "Default 443/TCP. Prefer Cloudflare DNS ACME — port 80 is not needed.",
"port_xray_hint_cf": "Default 443/TCP. Certificate via Cloudflare DNS — port 80 is not used.",
"xray_domain": "Domain",
"xray_email": "Let\u0027s Encrypt email",
"xray_dns_hint": "Create DNS record:",
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
"xray_install_hint": "Installs VLESS + XHTTP + TLS. Prefer Cloudflare API token (DNS-01) so TCP 80 stays free.",
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01).",
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
"xray_acme_method": "Certificate method",
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
"xray_acme_http": "HTTP-01 — needs free TCP 80",
"xray_acme_cloudflare_short": "Cloudflare DNS",
"xray_acme_http_short": "HTTP-01",
"xray_cf_token": "Cloudflare API token",
"xray_cf_token_hint": "Token with Zone:DNS:Edit for this domain zone. Used only to issue the cert.",
"reinstall": "نصب مجدد",
"uninstall_confirm": "حذف نصب {}؟ تمام اتصال‌ها و پیکربندی‌ها از بین خواهند رفت.",
"stop_container_confirm": "توقف کانتینر {}؟",
@@ -399,6 +414,20 @@
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).",
"server_connect_domain": "دامنه اتصال کلاینت",
"server_connect_domain_hint": "در پیکربندی VPN به‌جای IP استفاده می‌شود. هنگام انتقال سرور، رکورد DNS A را به‌روز کنید.",
"migrate_title": "Migrate users",
"migrate_desc": "Move clients to a new server with the same domain",
"migrate_hint": "Export keeps users, bindings and protocol state. On the new server install the same protocols, import the ZIP, then update DNS.",
"migrate_export_label": "Export from this server",
"migrate_export_btn": "Download migration pack (.zip)",
"migrate_exporting": "Building migration pack…",
"migrate_export_done": "Migration pack downloaded",
"migrate_import_label": "Import onto this server",
"migrate_import_btn": "Import migration pack (.zip)",
"migrate_import_hint": "Install the same protocols first. Do not use Move Connections.",
"migrate_import_confirm": "Import users and protocol state onto this server?",
"migrate_importing": "Importing migration…",
"migrate_import_done": "Migration imported",
"migrate_dns_hint": "Update the DNS A-record for domain",
"container_logs": "لاگ‌های کانتینر",
"logs_btn": "📋 Logs",
"logs_live": "زنده",
@@ -465,6 +494,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.",
+47 -2
View File
@@ -71,7 +71,7 @@
"docker_not_installed": "Docker non installé",
"awg_desc": "Version moderne basée sur awg-go. Obfuscation avancée (S3, S4).",
"awg_legacy_desc": "Version AWG originale. Compatible avec les anciens clients.",
"xray_desc": "Masque le trafic en trafic web normal (XTLS-Reality). Résiste au DPI.",
"xray_desc": "VLESS over XHTTP+TLS — looks like normal HTTPS/HTTP2. Needs a domain + Let\u0027s Encrypt.",
"not_checked": "Non vérifié",
"connections": "Connexions",
"add": "Ajouter",
@@ -80,7 +80,22 @@
"no_connections_desc": "Ajoutez votre première connexion pour générer un fichier VPN",
"install_protocol": "Installer le protocole",
"port_default_hint": "Port par défaut : 55424. Assurez-vous qu\u0027il est libre.",
"port_xray_hint": "Port recommandé : 443. Assurez-vous qu\u0027il n\u0027est pas utilisé par un serveur web.",
"port_xray_hint": "Default 443/TCP. Prefer Cloudflare DNS ACME — port 80 is not needed.",
"port_xray_hint_cf": "Default 443/TCP. Certificate via Cloudflare DNS — port 80 is not used.",
"xray_domain": "Domain",
"xray_email": "Let\u0027s Encrypt email",
"xray_dns_hint": "Create DNS record:",
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
"xray_install_hint": "Installs VLESS + XHTTP + TLS. Prefer Cloudflare API token (DNS-01) so TCP 80 stays free.",
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01).",
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
"xray_acme_method": "Certificate method",
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
"xray_acme_http": "HTTP-01 — needs free TCP 80",
"xray_acme_cloudflare_short": "Cloudflare DNS",
"xray_acme_http_short": "HTTP-01",
"xray_cf_token": "Cloudflare API token",
"xray_cf_token_hint": "Token with Zone:DNS:Edit for this domain zone. Used only to issue the cert.",
"reinstall": "Réinstaller",
"uninstall_confirm": "Désinstaller {} ? Toutes les données seront perdues.",
"stop_container_confirm": "Arrêter le conteneur {} ?",
@@ -399,6 +414,20 @@
"server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).",
"server_connect_domain": "Domaine de connexion client",
"server_connect_domain_hint": "Utilisé dans les configs VPN à la place de l\u0027IP. Lors d\u0027un déplacement du serveur, mettez à jour l\u0027enregistrement DNS A.",
"migrate_title": "Migrer les utilisateurs",
"migrate_desc": "Déplacer les clients vers un nouveau serveur avec le même domaine",
"migrate_hint": "L\u0027export conserve les utilisateurs, liaisons et l\u0027état des protocoles. Sur le nouveau serveur: installer les mêmes protocoles, importer le ZIP, puis mettre à jour le DNS.",
"migrate_export_label": "Exporter depuis ce serveur",
"migrate_export_btn": "Télécharger le pack (.zip)",
"migrate_exporting": "Création du pack…",
"migrate_export_done": "Pack téléchargé",
"migrate_import_label": "Importer sur ce serveur",
"migrate_import_btn": "Importer le pack (.zip)",
"migrate_import_hint": "Installez d\u0027abord les mêmes protocoles. N\u0027utilisez pas Move Connections.",
"migrate_import_confirm": "Importer les utilisateurs et l\u0027état des protocoles sur ce serveur ?",
"migrate_importing": "Import en cours…",
"migrate_import_done": "Migration importée",
"migrate_dns_hint": "Mettez à jour l\u0027enregistrement DNS A du domaine",
"container_logs": "Logs du conteneur",
"logs_btn": "📋 Logs",
"logs_live": "Temps réel",
@@ -465,6 +494,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.",
+47 -2
View File
@@ -72,7 +72,7 @@
"docker_not_installed": "Docker не установлен",
"awg_desc": "Новая версия протокола на основе awg-go. Поддерживает расширенную обфускацию с параметрами S3, S4.",
"awg_legacy_desc": "Оригинальная версия AWG на базе ядра WireGuard. Совместима с клиентами старых версий.",
"xray_desc": "Современный протокол с маскировкой под обычный веб-трафик (XTLS-Reality). Устойчив к глубокому анализу пакетов.",
"xray_desc": "VLESS поверх XHTTP+TLS — трафик как обычный HTTPS/HTTP2. Заточено под DPI (без Vision flow). Нужен домен; SSL через Cloudflare DNS или HTTP-01.",
"wireguard_desc": "Стандартный и самый быстрый VPN-протокол. Встроен во все современные ОС, но легко блокируется DPI.",
"not_checked": "Не проверено",
"connections": "Подключения",
@@ -82,7 +82,22 @@
"no_connections_desc": "Добавьте первое подключение для генерации VPN конфигурации",
"install_protocol": "Установить протокол",
"port_default_hint": "Порт по умолчанию: 55424. Убедитесь, что он не занят",
"port_xray_hint": "Порт по умолчанию: 443 (рекомендуется для Xray). Убедитесь, что он не занят другим веб-сервером.",
"port_xray_hint": "По умолчанию 443/TCP. Лучше Cloudflare DNS ACME — порт 80 не нужен. A-запись домена (только DNS / серое облако) на этот сервер.",
"port_xray_hint_cf": "По умолчанию 443/TCP. Сертификат через Cloudflare DNS — порт 80 не используется. Прокси Cloudflare выключите (серое облако).",
"xray_domain": "Домен",
"xray_email": "Email для Let\u0027s Encrypt",
"xray_dns_hint": "Создайте DNS-запись:",
"xray_dns_hint_cf": "Домен должен быть в Cloudflare. A-запись (только DNS):",
"xray_install_hint": "Ставит VLESS + XHTTP + TLS (Xray-core). Рекомендуется токен Cloudflare (DNS-01) — TCP 80 не занимается.",
"xray_ports_warning": "На время установки TCP 80 должен быть свободен (Let\u0027s Encrypt HTTP-01). Переустановка заменяет предыдущий Xray.",
"xray_ports_warning_cf": "Cloudflare DNS ACME не использует порт 80. Токену нужно Zone → DNS → Edit. Прокси выключите (серое облако). Переустановка заменяет предыдущий Xray.",
"xray_acme_method": "Способ выпуска SSL",
"xray_acme_cloudflare": "Cloudflare DNS (API-токен) — рекомендуется, без порта 80",
"xray_acme_http": "HTTP-01 — нужен свободный TCP 80",
"xray_acme_cloudflare_short": "Cloudflare DNS",
"xray_acme_http_short": "HTTP-01",
"xray_cf_token": "API-токен Cloudflare",
"xray_cf_token_hint": "Создайте токен с правом Zone:DNS:Edit для зоны домена. Токен нужен только для выпуска сертификата и больше не показывается.",
"reinstall": "Переустановить",
"uninstall_confirm": "Удалить {}? Все подключения и конфигурации будут потеряны.",
"stop_container_confirm": "Остановить контейнер {}?",
@@ -415,6 +430,20 @@
"server_connect_domain_current": "Endpoint в конфигах",
"server_connect_domain_ssh": "SSH",
"server_connect_domain_saved": "Домен подключения сохранён",
"migrate_title": "Миграция пользователей",
"migrate_desc": "Перенос клиентов на новый сервер с тем же доменом",
"migrate_hint": "Экспорт сохраняет пользователей, привязки и состояние протоколов (ключи/peers). На новом сервере: добавьте сервер с новым IP и тем же connect_domain → установите те же протоколы → импортируйте ZIP → обновите DNS A-запись. Конфиги клиентов не меняются.",
"migrate_export_label": "Экспорт с этого сервера",
"migrate_export_btn": "Скачать пакет миграции (.zip)",
"migrate_exporting": "Создаём пакет миграции…",
"migrate_export_done": "Пакет миграции скачан",
"migrate_import_label": "Импорт на этот сервер",
"migrate_import_btn": "Импортировать пакет (.zip)",
"migrate_import_hint": "Сначала установите те же протоколы на этом сервере. Не используйте «Перенос подключений» — он создаёт новые ключи.",
"migrate_import_confirm": "Импортировать пользователей и состояние протоколов на этот сервер? Существующие client_id сохранятся.",
"migrate_importing": "Импорт миграции…",
"migrate_import_done": "Миграция импортирована",
"migrate_dns_hint": "Обновите DNS A-запись для домена",
"server_endpoint_label": "Endpoint",
"support_title": "Поддержать проект",
"support_intro": "Если панель вам помогает, можно поддержать разработку. Выберите удобный способ ниже.",
@@ -511,6 +540,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": "Управление",
+47 -2
View File
@@ -71,7 +71,7 @@
"docker_not_installed": "Docker 未安装",
"awg_desc": "基于 awg-go 的新版协议。支持 S3, S4 高级混淆。",
"awg_legacy_desc": "原始 AWG 版本。兼容旧版客户端。",
"xray_desc": "将流量伪装成普通网页流量 (XTLS-Reality),抗封锁能力强。",
"xray_desc": "VLESS over XHTTP+TLS — looks like normal HTTPS/HTTP2. Needs a domain + Let\u0027s Encrypt.",
"not_checked": "未检查",
"connections": "连接",
"add": "添加",
@@ -80,7 +80,22 @@
"no_connections_desc": "添加首个连接以生成 VPN 配置文件",
"install_protocol": "安装协议",
"port_default_hint": "默认端口: 55424。请确保端口未被占用。",
"port_xray_hint": "推荐端口: 443。请确保未被其他 Web 服务器使用。",
"port_xray_hint": "Default 443/TCP. Prefer Cloudflare DNS ACME — port 80 is not needed.",
"port_xray_hint_cf": "Default 443/TCP. Certificate via Cloudflare DNS — port 80 is not used.",
"xray_domain": "Domain",
"xray_email": "Let\u0027s Encrypt email",
"xray_dns_hint": "Create DNS record:",
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
"xray_install_hint": "Installs VLESS + XHTTP + TLS. Prefer Cloudflare API token (DNS-01) so TCP 80 stays free.",
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01).",
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
"xray_acme_method": "Certificate method",
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
"xray_acme_http": "HTTP-01 — needs free TCP 80",
"xray_acme_cloudflare_short": "Cloudflare DNS",
"xray_acme_http_short": "HTTP-01",
"xray_cf_token": "Cloudflare API token",
"xray_cf_token_hint": "Token with Zone:DNS:Edit for this domain zone. Used only to issue the cert.",
"reinstall": "重新安装",
"uninstall_confirm": "确定卸载 {}?所有连接和配置都将丢失。",
"stop_container_confirm": "停止容器 {}",
@@ -399,6 +414,20 @@
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",
"server_connect_domain": "客户端连接域名",
"server_connect_domain_hint": "在 VPN 配置中替代 IP 使用。迁移服务器时只需更新 DNS A 记录。",
"migrate_title": "Migrate users",
"migrate_desc": "Move clients to a new server with the same domain",
"migrate_hint": "Export keeps users, bindings and protocol state. On the new server install the same protocols, import the ZIP, then update DNS.",
"migrate_export_label": "Export from this server",
"migrate_export_btn": "Download migration pack (.zip)",
"migrate_exporting": "Building migration pack…",
"migrate_export_done": "Migration pack downloaded",
"migrate_import_label": "Import onto this server",
"migrate_import_btn": "Import migration pack (.zip)",
"migrate_import_hint": "Install the same protocols first. Do not use Move Connections.",
"migrate_import_confirm": "Import users and protocol state onto this server?",
"migrate_importing": "Importing migration…",
"migrate_import_done": "Migration imported",
"migrate_dns_hint": "Update the DNS A-record for domain",
"container_logs": "容器日志",
"logs_btn": "📋 日志",
"logs_live": "实时",
@@ -465,6 +494,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.",