Template
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dd92a6a92 | ||
|
|
26a3aef760 | ||
|
|
d915b91f01 | ||
|
|
4720d8f2bd | ||
|
|
3ea638d9c5 | ||
|
|
abbd160dd8 | ||
|
|
01196c066a | ||
|
|
b3c7d91419 | ||
|
|
f9b9857460 |
@@ -23,7 +23,7 @@ import time
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import zipfile
|
import zipfile
|
||||||
import signal
|
import signal
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
import io
|
import io
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, StreamingResponse, FileResponse
|
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, StreamingResponse, FileResponse
|
||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
@@ -103,7 +103,7 @@ else:
|
|||||||
application_path = os.path.dirname(__file__)
|
application_path = os.path.dirname(__file__)
|
||||||
|
|
||||||
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
|
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.4"
|
||||||
RELEASES_REPO_URL = repo_url()
|
RELEASES_REPO_URL = repo_url()
|
||||||
RELEASES_API_LATEST = api_latest_url()
|
RELEASES_API_LATEST = api_latest_url()
|
||||||
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
|
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()
|
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):
|
def get_current_user(request: Request, data: Optional[dict] = None):
|
||||||
user_id = request.session.get('user_id')
|
user_id = request.session.get('user_id')
|
||||||
if not 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', {}))),
|
'translations_json': json.dumps(TRANSLATIONS.get(lang, TRANSLATIONS.get('en', {}))),
|
||||||
# Keep for legacy JS; prefer translations_json on new pages
|
# Keep for legacy JS; prefer translations_json on new pages
|
||||||
'all_translations_json': json.dumps(TRANSLATIONS),
|
'all_translations_json': json.dumps(TRANSLATIONS),
|
||||||
|
'releases_repo_url': RELEASES_REPO_URL,
|
||||||
}
|
}
|
||||||
ctx.update(kwargs)
|
ctx.update(kwargs)
|
||||||
return templates.TemplateResponse(template, ctx)
|
return templates.TemplateResponse(template, ctx)
|
||||||
@@ -2213,6 +2225,11 @@ class InstallProtocolRequest(BaseModel):
|
|||||||
# NaiveProxy
|
# NaiveProxy
|
||||||
naiveproxy_domain: Optional[str] = None
|
naiveproxy_domain: Optional[str] = None
|
||||||
naiveproxy_email: 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):
|
class Socks5SettingsRequest(BaseModel):
|
||||||
@@ -2389,6 +2406,7 @@ class GuestSettings(BaseModel):
|
|||||||
create_server_id: int = 0
|
create_server_id: int = 0
|
||||||
create_inbound_id: int = 0
|
create_inbound_id: int = 0
|
||||||
create_xui_panel_id: str = ''
|
create_xui_panel_id: str = ''
|
||||||
|
create_allow_server_choice: bool = True
|
||||||
|
|
||||||
|
|
||||||
class DonateMethodSettings(BaseModel):
|
class DonateMethodSettings(BaseModel):
|
||||||
@@ -2462,6 +2480,7 @@ class ShareAuthRequest(BaseModel):
|
|||||||
|
|
||||||
class GuestCreateRequest(BaseModel):
|
class GuestCreateRequest(BaseModel):
|
||||||
name: str = 'Guest VPN'
|
name: str = 'Guest VPN'
|
||||||
|
server_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class InviteCreateRequest(BaseModel):
|
class InviteCreateRequest(BaseModel):
|
||||||
@@ -2476,6 +2495,7 @@ class InviteCreateRequest(BaseModel):
|
|||||||
duration_days: int = 0 # client lifetime after redeem; 0 = no expiry
|
duration_days: int = 0 # client lifetime after redeem; 0 = no expiry
|
||||||
note: str = ''
|
note: str = ''
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
allow_server_choice: bool = True
|
||||||
|
|
||||||
|
|
||||||
class InviteUpdateRequest(BaseModel):
|
class InviteUpdateRequest(BaseModel):
|
||||||
@@ -2492,10 +2512,12 @@ class InviteUpdateRequest(BaseModel):
|
|||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
enabled: Optional[bool] = None
|
enabled: Optional[bool] = None
|
||||||
reset_used: bool = False
|
reset_used: bool = False
|
||||||
|
allow_server_choice: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class InviteRedeemRequest(BaseModel):
|
class InviteRedeemRequest(BaseModel):
|
||||||
name: str = 'Invite VPN'
|
name: str = 'Invite VPN'
|
||||||
|
server_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class TunnelStartRequest(BaseModel):
|
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
|
max_connections=req.max_connections if req.max_connections is not None else 0
|
||||||
)
|
)
|
||||||
elif install_base == 'xray':
|
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':
|
elif install_base == 'wireguard':
|
||||||
result = manager.install_protocol(port=req.port)
|
result = manager.install_protocol(port=req.port)
|
||||||
elif install_base == 'socks5':
|
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')
|
proto_record['email'] = result.get('email')
|
||||||
if result.get('port'):
|
if result.get('port'):
|
||||||
proto_record['port'] = str(result['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':
|
if install_base == 'naiveproxy':
|
||||||
info = server.setdefault('server_info', {})
|
info = server.setdefault('server_info', {})
|
||||||
if req.naiveproxy_domain:
|
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)
|
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"])
|
@app.post('/api/servers/{server_id}/backups/export-clients', tags=["Protocols"])
|
||||||
async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest):
|
async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest):
|
||||||
"""Download a ZIP with all reconstructable WireGuard/AWG client .conf files."""
|
"""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']):
|
if server_id >= len(data['servers']):
|
||||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
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_api import xui_create_vless_config
|
||||||
from managers.xui_servers import get_xui_server, ensure_xui_servers
|
from managers.xui_servers import get_xui_server, ensure_xui_servers
|
||||||
ensure_xui_servers(data.setdefault('settings', {}))
|
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)
|
save_data(data)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
server = data['servers'][server_id]
|
proto_info = server.get('protocols', {}).get(protocol, {})
|
||||||
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
|
||||||
port = proto_info.get('port', '55424')
|
port = proto_info.get('port', '55424')
|
||||||
ssh = get_ssh(server)
|
ssh = get_ssh(server)
|
||||||
ssh.connect()
|
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(
|
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_quota=req.telemt_quota,
|
||||||
telemt_max_ips=req.telemt_max_ips,
|
telemt_max_ips=req.telemt_max_ips,
|
||||||
telemt_expiry=req.telemt_expiry,
|
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,
|
user_ad_tag=req.telemt_ad_tag,
|
||||||
max_tcp_conns=req.telemt_max_conns
|
max_tcp_conns=req.telemt_max_conns
|
||||||
)
|
)
|
||||||
elif protocol_base(req.protocol) == 'wireguard':
|
elif protocol_base(protocol) == 'wireguard':
|
||||||
result = manager.add_client(req.name, get_server_connect_host(server, req.protocol))
|
result = manager.add_client(req.name, get_server_connect_host(server, protocol))
|
||||||
else:
|
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()
|
ssh.disconnect()
|
||||||
|
|
||||||
if result.get('config'):
|
if result.get('config'):
|
||||||
result['vpn_link'] = generate_vpn_link(result['config'])
|
result['vpn_link'] = generate_vpn_link(result['config'])
|
||||||
|
result['protocol'] = protocol
|
||||||
|
|
||||||
# Link connection to user if specified
|
# Link connection to user if specified
|
||||||
if req.user_id and result.get('client_id'):
|
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()),
|
'id': str(uuid.uuid4()),
|
||||||
'user_id': req.user_id,
|
'user_id': req.user_id,
|
||||||
'server_id': server_id,
|
'server_id': server_id,
|
||||||
'protocol': req.protocol,
|
'protocol': protocol,
|
||||||
'client_id': result['client_id'],
|
'client_id': result['client_id'],
|
||||||
'name': req.name,
|
'name': req.name,
|
||||||
'created_at': datetime.now().isoformat(),
|
'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)
|
user = next((u for u in data['users'] if u['id'] == user_id), None)
|
||||||
if not user:
|
if not user:
|
||||||
return JSONResponse({'error': 'User not found'}, status_code=404)
|
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(
|
return JSONResponse(
|
||||||
{'error': f'Protocol "{req.protocol}" does not support user connections'},
|
{'error': f'Protocol "{protocol}" does not support user connections'},
|
||||||
status_code=400,
|
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
|
from managers.xui_servers import get_xui_server, ensure_xui_servers
|
||||||
ensure_xui_servers(data.setdefault('settings', {}))
|
ensure_xui_servers(data.setdefault('settings', {}))
|
||||||
panel = get_xui_server(data.get('settings') or {}, req.xui_panel_id)
|
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']):
|
if req.server_id >= len(data['servers']):
|
||||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||||
server = data['servers'][req.server_id]
|
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')
|
port = proto_info.get('port', '55424')
|
||||||
ssh = get_ssh(server)
|
ssh = get_ssh(server)
|
||||||
await asyncio.to_thread(ssh.connect)
|
await asyncio.to_thread(ssh.connect)
|
||||||
try:
|
try:
|
||||||
manager = get_protocol_manager(ssh, req.protocol)
|
manager = get_protocol_manager(ssh, protocol)
|
||||||
|
|
||||||
if req.client_id:
|
if req.client_id:
|
||||||
# Link existing client
|
# Link existing client
|
||||||
config = await asyncio.to_thread(
|
config = await asyncio.to_thread(
|
||||||
_manager_call, manager, 'get_client_config',
|
_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}
|
result = {'client_id': req.client_id, 'config': config}
|
||||||
elif protocol_base(req.protocol) == 'telemt':
|
elif protocol_base(protocol) == 'telemt':
|
||||||
result = await asyncio.to_thread(
|
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_quota=req.telemt_quota,
|
||||||
telemt_max_ips=req.telemt_max_ips,
|
telemt_max_ips=req.telemt_max_ips,
|
||||||
telemt_expiry=req.telemt_expiry,
|
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,
|
user_ad_tag=req.telemt_ad_tag,
|
||||||
max_tcp_conns=req.telemt_max_conns,
|
max_tcp_conns=req.telemt_max_conns,
|
||||||
)
|
)
|
||||||
elif protocol_base(req.protocol) == 'wireguard':
|
elif protocol_base(protocol) == 'wireguard':
|
||||||
result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, req.protocol))
|
result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, protocol))
|
||||||
else:
|
else:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
_manager_call, manager, 'add_client',
|
_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:
|
finally:
|
||||||
await asyncio.to_thread(ssh.disconnect)
|
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()),
|
'id': str(uuid.uuid4()),
|
||||||
'user_id': user_id,
|
'user_id': user_id,
|
||||||
'server_id': req.server_id,
|
'server_id': req.server_id,
|
||||||
'protocol': req.protocol,
|
'protocol': protocol,
|
||||||
'client_id': result['client_id'],
|
'client_id': result['client_id'],
|
||||||
'name': req.name,
|
'name': req.name,
|
||||||
'created_at': datetime.now().isoformat(),
|
'created_at': datetime.now().isoformat(),
|
||||||
@@ -5509,6 +5735,7 @@ def _guest_settings(data: Optional[dict] = None) -> dict:
|
|||||||
'create_server_id': 0,
|
'create_server_id': 0,
|
||||||
'create_inbound_id': 0,
|
'create_inbound_id': 0,
|
||||||
'create_xui_panel_id': '',
|
'create_xui_panel_id': '',
|
||||||
|
'create_allow_server_choice': True,
|
||||||
}
|
}
|
||||||
defaults.update(guest)
|
defaults.update(guest)
|
||||||
return defaults
|
return defaults
|
||||||
@@ -5584,22 +5811,26 @@ async def api_guest_connections(token: str, request: Request):
|
|||||||
data, guest, holder, err = _resolve_guest(token, request)
|
data, guest, holder, err = _resolve_guest(token, request)
|
||||||
if err:
|
if err:
|
||||||
return 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:
|
if not holder:
|
||||||
return {
|
return {'connections': [], **meta}
|
||||||
'connections': [],
|
|
||||||
'allow_create': bool(guest.get('allow_create')),
|
|
||||||
'create_protocol': guest.get('create_protocol') or 'xui',
|
|
||||||
}
|
|
||||||
conns = [
|
conns = [
|
||||||
_enrich_guest_conn(c, data)
|
_enrich_guest_conn(c, data)
|
||||||
for c in data.get('user_connections', [])
|
for c in data.get('user_connections', [])
|
||||||
if c['user_id'] == holder['id']
|
if c['user_id'] == holder['id']
|
||||||
]
|
]
|
||||||
return {
|
return {'connections': conns, **meta}
|
||||||
'connections': conns,
|
|
||||||
'allow_create': bool(guest.get('allow_create')),
|
|
||||||
'create_protocol': guest.get('create_protocol') or 'xui',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/guest/{token}/config/{connection_id}', tags=["Guest"])
|
@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)
|
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
|
||||||
if maybe_start_user_expiration(data, holder['id']):
|
if maybe_start_user_expiration(data, holder['id']):
|
||||||
save_data(data)
|
save_data(data)
|
||||||
|
return await _fetch_connection_config_payload(data, conn, expires_at=holder.get('expiration_date'))
|
||||||
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')}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error getting guest config")
|
logger.exception("Error getting guest config")
|
||||||
return JSONResponse({'error': str(e)}, status_code=500)
|
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'
|
name = (req.name or 'Guest VPN').strip() or 'Guest VPN'
|
||||||
# Unique-ish name to avoid collisions
|
# Unique-ish name to avoid collisions
|
||||||
name = f"{name}_{secrets.token_hex(3)}"
|
name = f"{name}_{secrets.token_hex(3)}"
|
||||||
|
allow_choice = bool(guest.get('create_allow_server_choice', True))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
|
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
|
||||||
if user_is_expired(holder):
|
if user_is_expired(holder):
|
||||||
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
|
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':
|
if protocol_base(protocol) == 'xui':
|
||||||
from managers.xui_api import xui_create_vless_config
|
from managers.xui_api import xui_create_vless_config
|
||||||
from managers.xui_servers import get_xui_server, ensure_xui_servers
|
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
|
sid = 0
|
||||||
protocol = 'xui'
|
protocol = 'xui'
|
||||||
else:
|
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]
|
server = data['servers'][sid]
|
||||||
proto_info = server.get('protocols', {}).get(protocol, {})
|
proto_info = server.get('protocols', {}).get(protocol, {})
|
||||||
port = proto_info.get('port', '55424')
|
port = proto_info.get('port', '55424')
|
||||||
@@ -5767,12 +5990,90 @@ async def api_guest_regenerate_token(request: Request):
|
|||||||
|
|
||||||
# ======================== Invite links (limited config creation) ========================
|
# ======================== 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)
|
max_uses = int(link.get('max_uses') or 0)
|
||||||
used = int(link.get('used_count') or 0)
|
used = int(link.get('used_count') or 0)
|
||||||
remaining = None if max_uses <= 0 else max(0, max_uses - used)
|
remaining = None if max_uses <= 0 else max(0, max_uses - used)
|
||||||
exhausted = remaining is not None and remaining <= 0
|
exhausted = remaining is not None and remaining <= 0
|
||||||
duration_days = int(link.get('duration_days') or 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 {
|
return {
|
||||||
'id': link.get('id'),
|
'id': link.get('id'),
|
||||||
'name': link.get('name') or 'Invite',
|
'name': link.get('name') or 'Invite',
|
||||||
@@ -5785,8 +6086,10 @@ def _invite_public_view(link: dict) -> dict:
|
|||||||
'expired': False,
|
'expired': False,
|
||||||
'exhausted': exhausted,
|
'exhausted': exhausted,
|
||||||
'has_password': bool(link.get('password_hash')),
|
'has_password': bool(link.get('password_hash')),
|
||||||
'protocol': link.get('protocol') or 'awg',
|
'protocol': protocol,
|
||||||
'server_id': int(link.get('server_id') or 0),
|
'server_id': int(link.get('server_id') or 0),
|
||||||
|
'allow_server_choice': allow_server_choice,
|
||||||
|
'servers': servers,
|
||||||
'duration_days': duration_days,
|
'duration_days': duration_days,
|
||||||
'user_id': link.get('user_id') or '',
|
'user_id': link.get('user_id') or '',
|
||||||
'note': link.get('note') 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')}"))
|
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:
|
def _expiry_ms_from_duration_days(duration_days: int) -> int:
|
||||||
"""3x-ui expiryTime is unix ms; 0 means no expiry. Starts now (on redeem)."""
|
"""3x-ui expiryTime is unix ms; 0 means no expiry. Starts now (on redeem)."""
|
||||||
days = int(duration_days or 0)
|
days = int(duration_days or 0)
|
||||||
@@ -5825,6 +6180,12 @@ async def _create_config_for_protocol(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}."""
|
"""Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}."""
|
||||||
protocol = protocol or 'xui'
|
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':
|
if protocol_base(protocol) == 'xui':
|
||||||
from managers.xui_api import xui_create_vless_config
|
from managers.xui_api import xui_create_vless_config
|
||||||
from managers.xui_servers import get_xui_server, ensure_xui_servers
|
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,
|
'expires_at': None,
|
||||||
'duration_days': int(req.duration_days or 0),
|
'duration_days': int(req.duration_days or 0),
|
||||||
'note': req.note or '',
|
'note': req.note or '',
|
||||||
|
'allow_server_choice': bool(req.allow_server_choice),
|
||||||
'created_at': datetime.now().isoformat(),
|
'created_at': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
data.setdefault('invite_links', []).append(link)
|
data.setdefault('invite_links', []).append(link)
|
||||||
save_data(data)
|
save_data(data)
|
||||||
view = _invite_public_view(link)
|
view = _invite_public_view(link, data)
|
||||||
return {'status': 'success', 'invite': view, 'url': f"/invite/{link['token']}"}
|
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
|
link['note'] = req.note
|
||||||
if req.enabled is not None:
|
if req.enabled is not None:
|
||||||
link['enabled'] = bool(req.enabled)
|
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:
|
if req.reset_used:
|
||||||
link['used_count'] = 0
|
link['used_count'] = 0
|
||||||
if protocol_base(link.get('protocol') or 'xui') == 'xui':
|
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):
|
if not int(link.get('xui_inbound_id') or 0):
|
||||||
return JSONResponse({'error': 'Select a VLESS inbound from 3x-ui'}, status_code=400)
|
return JSONResponse({'error': 'Select a VLESS inbound from 3x-ui'}, status_code=400)
|
||||||
save_data(data)
|
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"])
|
@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>",
|
f"<h1>{_t('invite_not_found', lang)}</h1><p>{_t('invite_not_found_desc', lang)}</p>",
|
||||||
status_code=404,
|
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)
|
need_password = bool(link.get('password_hash')) and not _invite_auth_ok(link, request)
|
||||||
return tpl(
|
return tpl(
|
||||||
request,
|
request,
|
||||||
@@ -6089,7 +6453,7 @@ async def api_invite_info(token: str, request: Request):
|
|||||||
return JSONResponse({'error': 'Not found'}, status_code=404)
|
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||||
if not _invite_auth_ok(link, request):
|
if not _invite_auth_ok(link, request):
|
||||||
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
|
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
|
# Don't leak admin note / ids beyond what's needed
|
||||||
return {
|
return {
|
||||||
'name': view['name'],
|
'name': view['name'],
|
||||||
@@ -6102,9 +6466,64 @@ async def api_invite_info(token: str, request: Request):
|
|||||||
'max_uses': view['max_uses'],
|
'max_uses': view['max_uses'],
|
||||||
'used_count': view['used_count'],
|
'used_count': view['used_count'],
|
||||||
'protocol': view['protocol'],
|
'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"])
|
@app.post('/api/invite/{token}/create', tags=["Invites"])
|
||||||
async def api_invite_create_config(token: str, req: InviteRedeemRequest, request: Request):
|
async def api_invite_create_config(token: str, req: InviteRedeemRequest, request: Request):
|
||||||
data = load_data()
|
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):
|
if not _invite_auth_ok(link, request):
|
||||||
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
|
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
|
||||||
|
|
||||||
view = _invite_public_view(link)
|
view = _invite_public_view(link, data)
|
||||||
if not view['available']:
|
if not view['available']:
|
||||||
if view['expired']:
|
if view['expired']:
|
||||||
return JSONResponse({'error': 'Invite link expired'}, status_code=403)
|
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']):
|
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)
|
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
|
# Reserve a use slot under lock
|
||||||
async with DATA_LOCK:
|
async with DATA_LOCK:
|
||||||
data = load_data()
|
data = load_data()
|
||||||
link = _find_invite(data, token)
|
link = _find_invite(data, token)
|
||||||
if not link:
|
if not link:
|
||||||
return JSONResponse({'error': 'Not found'}, status_code=404)
|
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||||
view = _invite_public_view(link)
|
view = _invite_public_view(link, data)
|
||||||
if not view['available']:
|
if not view['available']:
|
||||||
return JSONResponse({'error': 'Invite link is no longer available'}, status_code=403)
|
return JSONResponse({'error': 'Invite link is no longer available'}, status_code=403)
|
||||||
link['used_count'] = int(link.get('used_count') or 0) + 1
|
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)}"
|
name = f"{name}_{secrets.token_hex(3)}"
|
||||||
try:
|
try:
|
||||||
data = load_data()
|
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(
|
created = await _create_config_for_protocol(
|
||||||
data,
|
data,
|
||||||
protocol=link.get('protocol') or 'xui',
|
protocol=protocol,
|
||||||
name=name,
|
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_inbound_id=int(link.get('xui_inbound_id') or 0) or None,
|
||||||
xui_panel_id=link.get('xui_panel_id') or None,
|
xui_panel_id=link.get('xui_panel_id') or None,
|
||||||
duration_days=int(link.get('duration_days') or 0),
|
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'],
|
'protocol': created['protocol'],
|
||||||
'client_id': created['client_id'],
|
'client_id': created['client_id'],
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'invite_id': link.get('id'),
|
||||||
'xui_panel_id': created.get('xui_panel_id') or link.get('xui_panel_id') or '',
|
'xui_panel_id': created.get('xui_panel_id') or link.get('xui_panel_id') or '',
|
||||||
'created_at': datetime.now().isoformat(),
|
'created_at': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
if created.get('expires_at'):
|
||||||
|
conn['expires_at'] = created['expires_at']
|
||||||
async with DATA_LOCK:
|
async with DATA_LOCK:
|
||||||
data = load_data()
|
data = load_data()
|
||||||
data.setdefault('user_connections', []).append(conn)
|
data.setdefault('user_connections', []).append(conn)
|
||||||
save_data(data)
|
save_data(data)
|
||||||
config = created.get('config') or ''
|
config = created.get('config') or ''
|
||||||
subscription_url = created.get('subscription_url') 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 '')
|
vpn_link = subscription_url or (generate_vpn_link(config) if config else '')
|
||||||
data = load_data()
|
data = load_data()
|
||||||
link = _find_invite(data, token) or link
|
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,
|
'subscription_url': subscription_url,
|
||||||
'vpn_link': vpn_link,
|
'vpn_link': vpn_link,
|
||||||
'expires_at': created.get('expires_at'),
|
'expires_at': created.get('expires_at'),
|
||||||
'connection': conn,
|
'connection': _enrich_invite_conn(conn, data),
|
||||||
'invite': _invite_public_view(link),
|
'invite': _invite_public_view(link, data),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Roll back reserved use
|
# Roll back reserved use
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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.'
|
||||||
|
),
|
||||||
|
}
|
||||||
+489
-137
@@ -1,5 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import base64
|
import base64
|
||||||
@@ -9,12 +11,22 @@ import urllib.parse
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
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'
|
PROTOCOL = 'xray'
|
||||||
CONTAINER_NAME = 'amnezia-xray'
|
CONTAINER_NAME = 'amnezia-xray'
|
||||||
IMAGE_NAME = 'amneziavpn/amnezia-xray' # or we can build it
|
IMAGE_NAME = 'amneziavpn/amnezia-xray'
|
||||||
|
DEFAULT_PORT = 8443
|
||||||
|
|
||||||
def __init__(self, ssh_manager, protocol='xray'):
|
def __init__(self, ssh_manager, protocol='xray'):
|
||||||
self.ssh = ssh_manager
|
self.ssh = ssh_manager
|
||||||
@@ -148,6 +160,11 @@ class XrayManager:
|
|||||||
|
|
||||||
def get_server_status(self, protocol):
|
def get_server_status(self, protocol):
|
||||||
exists = self.check_protocol_installed()
|
exists = self.check_protocol_installed()
|
||||||
|
if exists:
|
||||||
|
try:
|
||||||
|
self._heal_xhttp_config()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Xray config heal skipped: {e}")
|
||||||
running = self.check_container_running()
|
running = self.check_container_running()
|
||||||
clients = self.get_clients() if exists else []
|
clients = self.get_clients() if exists else []
|
||||||
meta = self._get_meta_json() if exists else {}
|
meta = self._get_meta_json() if exists else {}
|
||||||
@@ -155,35 +172,309 @@ class XrayManager:
|
|||||||
'container_exists': exists,
|
'container_exists': exists,
|
||||||
'container_running': running,
|
'container_running': running,
|
||||||
'clients_count': len(clients),
|
'clients_count': len(clients),
|
||||||
'port': meta.get('port')
|
'port': meta.get('port'),
|
||||||
|
'domain': meta.get('domain') or meta.get('site_name'),
|
||||||
|
'transport': meta.get('transport') or 'xhttp',
|
||||||
|
'security': meta.get('security') or 'tls',
|
||||||
|
'acme_method': meta.get('acme_method'),
|
||||||
|
'path': meta.get('path'),
|
||||||
}
|
}
|
||||||
|
|
||||||
def install_protocol(self, port=443, site_name='yahoo.com'):
|
def _normalize_xhttp_headers_in_config(self, config):
|
||||||
"""Full installation of Xray."""
|
"""Xray xhttp headers must be map[string]string — arrays crash the process."""
|
||||||
|
changed = False
|
||||||
|
for inbound in (config.get('inbounds') or []):
|
||||||
|
stream = inbound.get('streamSettings') or {}
|
||||||
|
for key in ('xhttpSettings', 'splithttpSettings'):
|
||||||
|
xs = stream.get(key)
|
||||||
|
if not isinstance(xs, dict):
|
||||||
|
continue
|
||||||
|
headers = xs.get('headers')
|
||||||
|
if headers is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(headers, dict):
|
||||||
|
xs.pop('headers', None)
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
fixed = {}
|
||||||
|
for hk, hv in headers.items():
|
||||||
|
if isinstance(hv, str):
|
||||||
|
fixed[hk] = hv
|
||||||
|
continue
|
||||||
|
changed = True
|
||||||
|
if isinstance(hv, list) and hv:
|
||||||
|
fixed[hk] = str(hv[0])
|
||||||
|
elif hv is not None and not isinstance(hv, (dict, list)):
|
||||||
|
fixed[hk] = str(hv)
|
||||||
|
if fixed:
|
||||||
|
xs['headers'] = fixed
|
||||||
|
else:
|
||||||
|
xs.pop('headers', None)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def _normalize_xhttp_stream_for_compat(self, config):
|
||||||
|
"""Make inbound XHTTP+TLS closer to known-working minimal configs."""
|
||||||
|
changed = self._normalize_xhttp_headers_in_config(config)
|
||||||
|
for inbound in (config.get('inbounds') or []):
|
||||||
|
if inbound.get('protocol') != 'vless':
|
||||||
|
continue
|
||||||
|
stream = inbound.get('streamSettings') or {}
|
||||||
|
network = str(stream.get('network') or '').lower()
|
||||||
|
security = str(stream.get('security') or '').lower()
|
||||||
|
if network not in ('xhttp', 'splithttp') or security != 'tls':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Prefer canonical network name
|
||||||
|
if network == 'splithttp':
|
||||||
|
stream['network'] = 'xhttp'
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
xs_key = 'xhttpSettings' if 'xhttpSettings' in stream else (
|
||||||
|
'splithttpSettings' if 'splithttpSettings' in stream else 'xhttpSettings'
|
||||||
|
)
|
||||||
|
xs = stream.get(xs_key)
|
||||||
|
if not isinstance(xs, dict):
|
||||||
|
xs = {}
|
||||||
|
stream[xs_key] = xs
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Migrate splithttpSettings → xhttpSettings
|
||||||
|
if xs_key == 'splithttpSettings':
|
||||||
|
stream['xhttpSettings'] = xs
|
||||||
|
stream.pop('splithttpSettings', None)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Server-side host rejects mismatched Host headers on some clients
|
||||||
|
if 'host' in xs:
|
||||||
|
xs.pop('host', None)
|
||||||
|
changed = True
|
||||||
|
if 'headers' in xs:
|
||||||
|
xs.pop('headers', None)
|
||||||
|
changed = True
|
||||||
|
if not xs.get('path'):
|
||||||
|
xs['path'] = '/'
|
||||||
|
changed = True
|
||||||
|
if xs.get('mode') not in (None, '', 'auto', 'packet-up', 'stream-up', 'stream-one'):
|
||||||
|
xs['mode'] = 'auto'
|
||||||
|
changed = True
|
||||||
|
elif not xs.get('mode'):
|
||||||
|
xs['mode'] = 'auto'
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
tls = stream.get('tlsSettings')
|
||||||
|
if not isinstance(tls, dict):
|
||||||
|
tls = {}
|
||||||
|
stream['tlsSettings'] = tls
|
||||||
|
changed = True
|
||||||
|
# minVersion / serverName are optional and can hurt older clients
|
||||||
|
if 'minVersion' in tls:
|
||||||
|
tls.pop('minVersion', None)
|
||||||
|
changed = True
|
||||||
|
alpn = tls.get('alpn')
|
||||||
|
if not isinstance(alpn, list) or not alpn:
|
||||||
|
tls['alpn'] = ['h2', 'http/1.1']
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
sockopt = stream.get('sockopt')
|
||||||
|
if isinstance(sockopt, dict):
|
||||||
|
# TCP Fast Open frequently breaks mobile/CGNAT paths
|
||||||
|
if sockopt.pop('tcpFastOpen', None) is not None:
|
||||||
|
changed = True
|
||||||
|
if not sockopt:
|
||||||
|
stream.pop('sockopt', None)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
inbound['streamSettings'] = stream
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def _heal_xhttp_config(self):
|
||||||
|
"""Fix crash/compat issues in existing XHTTP+TLS server.json."""
|
||||||
|
config = self._get_server_json()
|
||||||
|
if not config or not self._normalize_xhttp_stream_for_compat(config):
|
||||||
|
return False
|
||||||
|
path = self._config_path()
|
||||||
|
self.ssh.upload_file_sudo(json.dumps(config, indent=2), path)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker cp {_q(path)} {self.container_name}:{path} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker restart {self.container_name} 2>/dev/null || docker start {self.container_name} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
logger.info("Healed Xray XHTTP+TLS stream settings for client compatibility")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_domain(self, domain):
|
||||||
|
domain = (domain or '').strip().lower().rstrip('.')
|
||||||
|
if not domain or not re.match(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$', domain):
|
||||||
|
raise ValueError('Valid domain is required for Xray XHTTP+TLS (e.g. vpn.example.com)')
|
||||||
|
return domain
|
||||||
|
|
||||||
|
def _validate_email(self, email):
|
||||||
|
email = (email or '').strip()
|
||||||
|
if not email or '@' not in email or '.' not in email.split('@')[-1]:
|
||||||
|
raise ValueError('Valid email is required for Let\'s Encrypt')
|
||||||
|
return email
|
||||||
|
|
||||||
|
def _certs_dir(self):
|
||||||
|
return f'{self._config_dir()}/certs'
|
||||||
|
|
||||||
|
def _letsencrypt_dir(self):
|
||||||
|
return f'{self._config_dir()}/letsencrypt'
|
||||||
|
|
||||||
|
def _cert_path(self):
|
||||||
|
return f'{self._certs_dir()}/fullchain.pem'
|
||||||
|
|
||||||
|
def _key_path(self):
|
||||||
|
return f'{self._certs_dir()}/privkey.pem'
|
||||||
|
|
||||||
|
def _random_xhttp_path(self):
|
||||||
|
# Short opaque path — better client compatibility than nested “asset” URLs.
|
||||||
|
return f'/{secrets.token_hex(8)}'
|
||||||
|
|
||||||
|
def _cf_creds_path(self):
|
||||||
|
return f'{self._config_dir()}/cloudflare.ini'
|
||||||
|
|
||||||
|
def _write_cloudflare_ini(self, token):
|
||||||
|
token = (token or '').strip()
|
||||||
|
if not token:
|
||||||
|
raise ValueError('Cloudflare API token is required for DNS validation')
|
||||||
|
# Restrictive file for certbot dns plugin
|
||||||
|
content = f"dns_cloudflare_api_token = {token}\n"
|
||||||
|
path = self._cf_creds_path()
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {_q(self._config_dir())}")
|
||||||
|
self.ssh.upload_file_sudo(content, path)
|
||||||
|
self.ssh.run_sudo_command(f"chmod 600 {_q(path)}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _install_issued_certs(self, domain, le_dir):
|
||||||
|
copy_script = f"""
|
||||||
|
set -e
|
||||||
|
LIVE={_q(le_dir)}/live/{_q(domain)}
|
||||||
|
test -s "$LIVE/fullchain.pem"
|
||||||
|
test -s "$LIVE/privkey.pem"
|
||||||
|
mkdir -p {_q(self._certs_dir())}
|
||||||
|
cp -f "$LIVE/fullchain.pem" {_q(self._cert_path())}
|
||||||
|
cp -f "$LIVE/privkey.pem" {_q(self._key_path())}
|
||||||
|
chmod 644 {_q(self._cert_path())} {_q(self._key_path())}
|
||||||
|
"""
|
||||||
|
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(copy_script)}", timeout=30)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f'Failed to install certificate files: {err or out}')
|
||||||
|
|
||||||
|
def _issue_certificate(self, domain, email, *, acme_method='cloudflare', cloudflare_token=None):
|
||||||
|
"""Issue Let's Encrypt cert via Cloudflare DNS-01 (preferred) or HTTP-01 standalone."""
|
||||||
|
method = (acme_method or 'cloudflare').strip().lower()
|
||||||
|
if method in ('cf', 'dns', 'dns-01', 'cloudflare_dns'):
|
||||||
|
method = 'cloudflare'
|
||||||
|
if method in ('http', 'http-01', 'standalone', 'port80'):
|
||||||
|
method = 'http'
|
||||||
|
|
||||||
|
le_dir = self._letsencrypt_dir()
|
||||||
|
certs_dir = self._certs_dir()
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {_q(le_dir)} {_q(certs_dir)}")
|
||||||
|
self.ssh.run_sudo_command("docker rm -fv amnezia-xray-certbot 2>/dev/null || true")
|
||||||
|
|
||||||
|
if method == 'cloudflare':
|
||||||
|
creds = self._write_cloudflare_ini(cloudflare_token)
|
||||||
|
self.ssh.run_sudo_command(f"docker pull {CERTBOT_CF_IMAGE}", timeout=180)
|
||||||
|
# DNS-01 — no TCP 80 needed. Token needs Zone:DNS:Edit on the domain zone.
|
||||||
|
cmd = (
|
||||||
|
f"docker run --rm --name amnezia-xray-certbot "
|
||||||
|
f"-v {_q(le_dir)}:/etc/letsencrypt "
|
||||||
|
f"-v {_q(creds)}:/cloudflare.ini:ro "
|
||||||
|
f"{CERTBOT_CF_IMAGE} certonly "
|
||||||
|
f"--dns-cloudflare "
|
||||||
|
f"--dns-cloudflare-credentials /cloudflare.ini "
|
||||||
|
f"--dns-cloudflare-propagation-seconds 30 "
|
||||||
|
f"--non-interactive --agree-tos --no-eff-email "
|
||||||
|
f"--email {_q(email)} -d {_q(domain)}"
|
||||||
|
)
|
||||||
|
out, err, code = self.ssh.run_sudo_command(cmd, timeout=420)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Let's Encrypt (Cloudflare DNS) failed for {domain}. "
|
||||||
|
f"Check API token (Zone:DNS:Edit) and that the domain is on Cloudflare. {err or out}"
|
||||||
|
)
|
||||||
|
self._install_issued_certs(domain, le_dir)
|
||||||
|
return 'cloudflare'
|
||||||
|
|
||||||
|
# HTTP-01 standalone — needs free TCP 80
|
||||||
|
self.ssh.run_sudo_command(f"docker pull {CERTBOT_IMAGE}", timeout=180)
|
||||||
|
cmd = (
|
||||||
|
f"docker run --rm --name amnezia-xray-certbot "
|
||||||
|
f"-p 80:80 "
|
||||||
|
f"-v {_q(le_dir)}:/etc/letsencrypt "
|
||||||
|
f"{CERTBOT_IMAGE} certonly --standalone "
|
||||||
|
f"--non-interactive --agree-tos --no-eff-email "
|
||||||
|
f"--email {_q(email)} -d {_q(domain)}"
|
||||||
|
)
|
||||||
|
out, err, code = self.ssh.run_sudo_command(cmd, timeout=300)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Let's Encrypt (HTTP-01) failed for {domain}. "
|
||||||
|
f"Point an A-record to this server and free TCP port 80. {err or out}"
|
||||||
|
)
|
||||||
|
self._install_issued_certs(domain, le_dir)
|
||||||
|
return 'http'
|
||||||
|
|
||||||
|
def _is_xhttp_tls_inbound(self, inbound):
|
||||||
|
stream = (inbound or {}).get('streamSettings') or {}
|
||||||
|
return (
|
||||||
|
str(stream.get('network') or '').lower() in ('xhttp', 'splithttp')
|
||||||
|
and str(stream.get('security') or '').lower() == 'tls'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_reality_inbound(self, inbound):
|
||||||
|
stream = (inbound or {}).get('streamSettings') or {}
|
||||||
|
return str(stream.get('security') or '').lower() == 'reality'
|
||||||
|
|
||||||
|
def install_protocol(self, port=8443, domain=None, email=None, site_name=None,
|
||||||
|
acme_method='cloudflare', cloudflare_token=None):
|
||||||
|
"""Install VLESS + XHTTP + TLS (Let's Encrypt via Cloudflare DNS or HTTP-01)."""
|
||||||
results = []
|
results = []
|
||||||
|
domain = self._validate_domain(domain or site_name)
|
||||||
|
email = self._validate_email(email)
|
||||||
|
port = int(port or self.DEFAULT_PORT)
|
||||||
|
if port < 1 or port > 65535:
|
||||||
|
raise ValueError('Port must be between 1 and 65535')
|
||||||
|
method = (acme_method or 'cloudflare').strip().lower()
|
||||||
|
if method in ('cf', 'dns', 'dns-01', 'cloudflare_dns'):
|
||||||
|
method = 'cloudflare'
|
||||||
|
if method in ('http', 'http-01', 'standalone', 'port80'):
|
||||||
|
method = 'http'
|
||||||
|
if method == 'http' and port == 80:
|
||||||
|
raise RuntimeError('Port 80 is reserved for Let\'s Encrypt HTTP-01 validation')
|
||||||
|
if method == 'cloudflare' and not (cloudflare_token or '').strip():
|
||||||
|
raise ValueError('Cloudflare API token is required')
|
||||||
|
|
||||||
if not self.check_docker_installed():
|
if not self.check_docker_installed():
|
||||||
results.append("Installing Docker...")
|
results.append("Docker not detected — install may fail")
|
||||||
# Using same install method as AWGManager or assume it's installed
|
|
||||||
pass
|
|
||||||
|
|
||||||
results.append("Removing old container if exists...")
|
results.append("Removing old container if exists...")
|
||||||
if self.check_protocol_installed():
|
if self.check_protocol_installed():
|
||||||
self.remove_container()
|
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...")
|
results.append("Building Docker image...")
|
||||||
config_dir = self._config_dir()
|
config_dir = self._config_dir()
|
||||||
dockerfile_folder = f"/opt/amnezia/{self.container_name}"
|
dockerfile_folder = f"/opt/amnezia/{self.container_name}"
|
||||||
dockerfile_content = f"""FROM alpine:3.15
|
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 apk --update upgrade --no-cache
|
||||||
RUN mkdir -p {config_dir}
|
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/ && \\
|
unzip /root/xray.zip -d /usr/bin/ && \\
|
||||||
chmod a+x /usr/bin/xray && \\
|
chmod a+x /usr/bin/xray && \\
|
||||||
rm /root/xray.zip
|
rm /root/xray.zip
|
||||||
|
|
||||||
# Tune network
|
|
||||||
RUN echo "fs.file-max = 51200" >> /etc/sysctl.conf && \\
|
RUN echo "fs.file-max = 51200" >> /etc/sysctl.conf && \\
|
||||||
echo "net.core.rmem_max = 67108864" >> /etc/sysctl.conf && \\
|
echo "net.core.rmem_max = 67108864" >> /etc/sysctl.conf && \\
|
||||||
echo "net.core.wmem_max = 67108864" >> /etc/sysctl.conf && \\
|
echo "net.core.wmem_max = 67108864" >> /etc/sysctl.conf && \\
|
||||||
@@ -191,18 +482,12 @@ RUN echo "fs.file-max = 51200" >> /etc/sysctl.conf && \\
|
|||||||
echo "net.core.somaxconn = 4096" >> /etc/sysctl.conf && \\
|
echo "net.core.somaxconn = 4096" >> /etc/sysctl.conf && \\
|
||||||
echo "net.ipv4.tcp_syncookies = 1" >> /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_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_fin_timeout = 30" >> /etc/sysctl.conf && \\
|
||||||
echo "net.ipv4.tcp_keepalive_time = 1200" >> /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.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_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_fastopen = 3" >> /etc/sysctl.conf && \\
|
||||||
echo "net.ipv4.tcp_mem = 25600 51200 102400" >> /etc/sysctl.conf && \\
|
echo "net.ipv4.tcp_congestion_control = bbr" >> /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
|
|
||||||
|
|
||||||
RUN mkdir -p /etc/security && \\
|
RUN mkdir -p /etc/security && \\
|
||||||
echo "* soft nofile 51200" >> /etc/security/limits.conf && \\
|
echo "* soft nofile 51200" >> /etc/security/limits.conf && \\
|
||||||
@@ -221,29 +506,16 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
_, err, code = self.ssh.run_sudo_command(
|
_, err, code = self.ssh.run_sudo_command(
|
||||||
f"docker build --no-cache -t {self.image_name} {dockerfile_folder}", timeout=300
|
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...")
|
results.append("Generating XHTTP+TLS config...")
|
||||||
# We generate a base config using a temp container or directly if host has openssl
|
xhttp_path = self._random_xhttp_path()
|
||||||
|
# auto → packet-up under TLS (CDN/middlebox friendly); works direct too.
|
||||||
|
xhttp_mode = 'auto'
|
||||||
|
|
||||||
# Xray keypair generation using a temporary run overriding the entrypoint
|
|
||||||
keypair_cmd = f"docker run --rm --entrypoint=\"\" {self.image_name} /usr/bin/xray x25519"
|
|
||||||
out_kp, err_kp, code_kp = self.ssh.run_sudo_command(keypair_cmd)
|
|
||||||
if code_kp != 0: raise RuntimeError(f"Failed to generate x25519 keys: {err_kp}")
|
|
||||||
|
|
||||||
priv_key = ""
|
|
||||||
pub_key = ""
|
|
||||||
for line in out_kp.split('\n'):
|
|
||||||
if "Private" in line: priv_key = line.split(":", 1)[1].strip()
|
|
||||||
if "Public" in line: pub_key = line.split(":", 1)[1].strip()
|
|
||||||
|
|
||||||
short_id_cmd = f"docker run --rm --entrypoint=\"\" {self.image_name} openssl rand -hex 8"
|
|
||||||
out_sid, _, _ = self.ssh.run_sudo_command(short_id_cmd)
|
|
||||||
short_id = out_sid.strip()
|
|
||||||
|
|
||||||
# Generate initial server.json with Stats and API enabled
|
|
||||||
server_json = {
|
server_json = {
|
||||||
"log": {"loglevel": "error"},
|
"log": {"loglevel": "warning"},
|
||||||
"stats": {},
|
"stats": {},
|
||||||
"api": {
|
"api": {
|
||||||
"services": ["StatsService", "LoggerService", "HandlerService"],
|
"services": ["StatsService", "LoggerService", "HandlerService"],
|
||||||
@@ -260,6 +532,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
},
|
},
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
|
"listen": "0.0.0.0",
|
||||||
"port": int(port),
|
"port": int(port),
|
||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
"tag": "proxy",
|
"tag": "proxy",
|
||||||
@@ -268,14 +541,24 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
"decryption": "none"
|
"decryption": "none"
|
||||||
},
|
},
|
||||||
"streamSettings": {
|
"streamSettings": {
|
||||||
"network": "tcp",
|
"network": "xhttp",
|
||||||
"security": "reality",
|
"security": "tls",
|
||||||
"realitySettings": {
|
"tlsSettings": {
|
||||||
"dest": f"{site_name}:443",
|
"alpn": ["h2", "http/1.1"],
|
||||||
"serverNames": [site_name],
|
"certificates": [{
|
||||||
"privateKey": priv_key,
|
"certificateFile": self._cert_path(),
|
||||||
"shortIds": [short_id]
|
"keyFile": self._key_path()
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"xhttpSettings": {
|
||||||
|
"path": xhttp_path,
|
||||||
|
"mode": xhttp_mode
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sniffing": {
|
||||||
|
"enabled": True,
|
||||||
|
"destOverride": ["http", "tls", "quic"],
|
||||||
|
"routeOnly": True
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -286,13 +569,22 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
"tag": "api"
|
"tag": "api"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outbounds": [{"protocol": "freedom"}],
|
"outbounds": [
|
||||||
|
{"protocol": "freedom", "tag": "direct"},
|
||||||
|
{"protocol": "blackhole", "tag": "block"}
|
||||||
|
],
|
||||||
"routing": {
|
"routing": {
|
||||||
|
"domainStrategy": "AsIs",
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
|
"type": "field",
|
||||||
"inboundTag": ["api"],
|
"inboundTag": ["api"],
|
||||||
"outboundTag": "api",
|
"outboundTag": "api"
|
||||||
"type": "field"
|
},
|
||||||
|
{
|
||||||
|
"type": "field",
|
||||||
|
"protocol": ["bittorrent"],
|
||||||
|
"outboundTag": "block"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -300,15 +592,25 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
|
|
||||||
self.ssh.run_sudo_command(f"mkdir -p {config_dir}")
|
self.ssh.run_sudo_command(f"mkdir -p {config_dir}")
|
||||||
self.ssh.upload_file_sudo(json.dumps(server_json, indent=2), f"{config_dir}/server.json")
|
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.
|
meta = {
|
||||||
self.ssh.upload_file_sudo(priv_key + '\n', "/opt/amnezia/xray/xray_private.key")
|
'transport': 'xhttp',
|
||||||
self.ssh.upload_file_sudo(pub_key + '\n', "/opt/amnezia/xray/xray_public.key")
|
'security': 'tls',
|
||||||
self.ssh.upload_file_sudo(short_id + '\n', "/opt/amnezia/xray/xray_short_id.key")
|
'domain': domain,
|
||||||
# xray_uuid.key marks the install-time "default" client whose ID gets skipped on
|
'email': email,
|
||||||
# auto-import. Panel installs do not reserve such a client, so we leave it empty.
|
'path': xhttp_path,
|
||||||
self.ssh.upload_file_sudo('\n', "/opt/amnezia/xray/xray_uuid.key")
|
'mode': xhttp_mode,
|
||||||
self.ssh.upload_file_sudo("[]", "/opt/amnezia/xray/clientsTable")
|
'port': int(port),
|
||||||
|
'fingerprint': 'chrome',
|
||||||
|
'alpn': 'h2,http/1.1',
|
||||||
|
'site_name': domain,
|
||||||
|
'acme_method': used,
|
||||||
|
}
|
||||||
|
self.ssh.upload_file_sudo(json.dumps(meta, indent=2), f"{config_dir}/meta.json")
|
||||||
|
self.ssh.upload_file_sudo("[]", f"{config_dir}/clientsTable.json")
|
||||||
|
# Clear cached layout so we pick panel (meta.json) next.
|
||||||
|
if hasattr(self, '_cached_layout'):
|
||||||
|
delattr(self, '_cached_layout')
|
||||||
|
|
||||||
results.append("Starting container...")
|
results.append("Starting container...")
|
||||||
run_cmd = f"""docker run -d \\
|
run_cmd = f"""docker run -d \\
|
||||||
@@ -316,19 +618,26 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
--privileged \\
|
--privileged \\
|
||||||
--cap-add=NET_ADMIN \\
|
--cap-add=NET_ADMIN \\
|
||||||
-p {port}:{port}/tcp \\
|
-p {port}:{port}/tcp \\
|
||||||
-p {port}:{port}/udp \\
|
|
||||||
-v {config_dir}:{config_dir} \\
|
-v {config_dir}:{config_dir} \\
|
||||||
--name {self.container_name} \\
|
--name {self.container_name} \\
|
||||||
{self.image_name}"""
|
{self.image_name}"""
|
||||||
|
|
||||||
_, err, code = self.ssh.run_sudo_command(run_cmd)
|
_, 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")
|
self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.container_name} || true")
|
||||||
|
|
||||||
results.append("Xray configured and running")
|
results.append("Xray VLESS+XHTTP+TLS configured and running")
|
||||||
return {'status': 'success', 'protocol': self.protocol, 'port': port, 'log': results}
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'protocol': self.protocol,
|
||||||
|
'port': port,
|
||||||
|
'domain': domain,
|
||||||
|
'path': xhttp_path,
|
||||||
|
'acme_method': used,
|
||||||
|
'log': results,
|
||||||
|
}
|
||||||
|
|
||||||
def remove_container(self):
|
def remove_container(self):
|
||||||
self.ssh.run_sudo_command(f"docker stop {self.container_name}")
|
self.ssh.run_sudo_command(f"docker stop {self.container_name}")
|
||||||
@@ -354,18 +663,21 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
return self._write_server_json(data, restart=True)
|
return self._write_server_json(data, restart=True)
|
||||||
|
|
||||||
def _write_server_json(self, data, restart=True):
|
def _write_server_json(self, data, restart=True):
|
||||||
"""Write server.json into container via docker cp AND sync to host path."""
|
"""Write server.json to host path and into container when possible."""
|
||||||
|
if self._normalize_xhttp_stream_for_compat(data):
|
||||||
|
logger.info("Normalized XHTTP+TLS stream settings before write")
|
||||||
tmp_file = "/tmp/_xray_server.json"
|
tmp_file = "/tmp/_xray_server.json"
|
||||||
|
path = self._config_path()
|
||||||
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
|
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
|
||||||
|
# Host path first — volume mount survives crash loops
|
||||||
|
self.ssh.run_sudo_command(f"cp {tmp_file} {path}")
|
||||||
self.ssh.run_sudo_command(
|
self.ssh.run_sudo_command(
|
||||||
f"docker cp {tmp_file} {self.container_name}:{self._config_path()}"
|
f"docker cp {tmp_file} {self.container_name}:{path} 2>/dev/null || true"
|
||||||
)
|
|
||||||
# Also keep host copy in sync (handles both volume-mount and no-mount installs)
|
|
||||||
self.ssh.run_sudo_command(
|
|
||||||
f"cp {tmp_file} {self._config_path()} 2>/dev/null || true"
|
|
||||||
)
|
)
|
||||||
if restart:
|
if restart:
|
||||||
self.ssh.run_sudo_command(f"docker restart {self.container_name}")
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker restart {self.container_name} 2>/dev/null || docker start {self.container_name} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
|
||||||
def _get_vless_inbound(self, config):
|
def _get_vless_inbound(self, config):
|
||||||
for inbound in config.get('inbounds', []):
|
for inbound in config.get('inbounds', []):
|
||||||
@@ -431,28 +743,65 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
return False
|
return False
|
||||||
return True
|
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):
|
def _get_meta_json(self):
|
||||||
"""Read protocol metadata. Supports both layouts.
|
"""Read protocol metadata from meta.json and/or server.json (XHTTP+TLS or legacy Reality)."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
config = self._get_server_json() or {}
|
config = self._get_server_json() or {}
|
||||||
|
inbound = self._get_vless_inbound(config) or {}
|
||||||
|
stream = inbound.get('streamSettings') or {}
|
||||||
|
port = inbound.get('port')
|
||||||
|
|
||||||
port = None
|
meta = {}
|
||||||
site_name = None
|
out = self._read_remote_file(f"{self._config_dir()}/meta.json")
|
||||||
rs = {}
|
if out:
|
||||||
try:
|
try:
|
||||||
ib = next(b for b in config.get('inbounds', []) if b.get('protocol') == 'vless')
|
meta = json.loads(out)
|
||||||
port = ib.get('port')
|
except Exception:
|
||||||
rs = ib.get('streamSettings', {}).get('realitySettings', {}) or {}
|
meta = {}
|
||||||
names = rs.get('serverNames') or []
|
|
||||||
if names:
|
if port is not None:
|
||||||
site_name = names[0]
|
meta['port'] = port
|
||||||
except StopIteration:
|
|
||||||
pass
|
network = str(stream.get('network') or '').lower()
|
||||||
|
security = str(stream.get('security') or '').lower()
|
||||||
|
|
||||||
|
if network in ('xhttp', 'splithttp') and security == 'tls':
|
||||||
|
xs = stream.get('xhttpSettings') or stream.get('splithttpSettings') or {}
|
||||||
|
tls = stream.get('tlsSettings') or {}
|
||||||
|
meta['transport'] = 'xhttp'
|
||||||
|
meta['security'] = 'tls'
|
||||||
|
meta['path'] = xs.get('path') or meta.get('path') or '/'
|
||||||
|
meta['mode'] = xs.get('mode') or meta.get('mode') or 'auto'
|
||||||
|
meta['domain'] = (
|
||||||
|
meta.get('domain')
|
||||||
|
or xs.get('host')
|
||||||
|
or tls.get('serverName')
|
||||||
|
or meta.get('site_name')
|
||||||
|
)
|
||||||
|
meta['site_name'] = meta.get('domain') or meta.get('site_name')
|
||||||
|
meta['fingerprint'] = meta.get('fingerprint') or 'chrome'
|
||||||
|
meta['alpn'] = meta.get('alpn') or 'h2,http/1.1'
|
||||||
|
# Prefer TLS ALPN from live server config when present
|
||||||
|
tls_alpn = tls.get('alpn')
|
||||||
|
if isinstance(tls_alpn, list) and tls_alpn:
|
||||||
|
meta['alpn'] = ','.join(str(x) for x in tls_alpn if x)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
# Legacy Reality
|
||||||
|
rs = stream.get('realitySettings') or {}
|
||||||
|
names = rs.get('serverNames') or []
|
||||||
|
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':
|
if self._detect_layout() == 'native':
|
||||||
priv = (self._read_remote_file(f"{self._config_dir()}/xray_private.key") or '').strip()
|
priv = (self._read_remote_file(f"{self._config_dir()}/xray_private.key") or '').strip()
|
||||||
@@ -465,26 +814,15 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
sid = sids[0] if sids else ''
|
sid = sids[0] if sids else ''
|
||||||
if not pub:
|
if not pub:
|
||||||
pub = self._derive_pubkey_from_priv(priv)
|
pub = self._derive_pubkey_from_priv(priv)
|
||||||
return {
|
meta.update({
|
||||||
'private_key': priv,
|
'private_key': priv,
|
||||||
'public_key': pub,
|
'public_key': pub,
|
||||||
'short_id': sid,
|
'short_id': sid,
|
||||||
'port': port,
|
'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'):
|
if not meta.get('private_key'):
|
||||||
meta['private_key'] = rs.get('privateKey', '')
|
meta['private_key'] = rs.get('privateKey', '')
|
||||||
if not meta.get('short_id'):
|
if not meta.get('short_id'):
|
||||||
@@ -678,38 +1016,63 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
def get_client_config(self, protocol, client_id, server_host, port):
|
def get_client_config(self, protocol, client_id, server_host, port):
|
||||||
clients = self._get_clients_table()
|
clients = self._get_clients_table()
|
||||||
client = next((c for c in clients if c['clientId'] == client_id), None)
|
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')
|
name = client.get('userData', {}).get('clientName', 'vpn')
|
||||||
encoded_name = urllib.parse.quote(name)
|
encoded_name = urllib.parse.quote(name)
|
||||||
|
listen_port = meta.get('port', port)
|
||||||
|
|
||||||
url = (
|
if self._is_xhttp_tls_inbound(inbound) or (meta.get('transport') == 'xhttp' and meta.get('security') == 'tls'):
|
||||||
f"vless://{client_id}@{server_host}:{meta.get('port', port)}"
|
domain = (meta.get('domain') or meta.get('site_name') or server_host or '').strip()
|
||||||
f"?type=tcp&security=reality&pbk={meta['public_key']}"
|
path = meta.get('path') or '/'
|
||||||
f"&sni={sni}&fp=chrome&sid={meta['short_id']}"
|
if not str(path).startswith('/'):
|
||||||
|
path = '/' + str(path)
|
||||||
|
# TLS+XHTTP resolves auto → packet-up; pin it for older clients.
|
||||||
|
mode = meta.get('mode') or 'auto'
|
||||||
|
if str(mode).lower() in ('auto', ''):
|
||||||
|
mode = 'packet-up'
|
||||||
|
fp = meta.get('fingerprint') or 'chrome'
|
||||||
|
alpn = meta.get('alpn') or 'h2,http/1.1'
|
||||||
|
if isinstance(alpn, list):
|
||||||
|
alpn = ','.join(str(x) for x in alpn if x)
|
||||||
|
# Dial address: panel connect host (IP/domain). TLS identity: cert domain.
|
||||||
|
dial_host = (server_host or domain).strip()
|
||||||
|
path_q = urllib.parse.quote(str(path), safe='/')
|
||||||
|
return (
|
||||||
|
f"vless://{client_id}@{dial_host}:{listen_port}"
|
||||||
|
f"?encryption=none&security=tls&type=xhttp"
|
||||||
|
f"&path={path_q}"
|
||||||
|
f"&mode={urllib.parse.quote(str(mode), safe='')}"
|
||||||
|
f"&host={urllib.parse.quote(domain, safe='')}"
|
||||||
|
f"&sni={urllib.parse.quote(domain, safe='')}"
|
||||||
|
f"&fp={urllib.parse.quote(fp, safe='')}"
|
||||||
|
f"&alpn={urllib.parse.quote(alpn, safe=',')}"
|
||||||
|
f"#{encoded_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy Reality share link
|
||||||
|
sni = meta.get('site_name', 'yahoo.com')
|
||||||
|
try:
|
||||||
|
sni = inbound['streamSettings']['realitySettings']['serverNames'][0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return (
|
||||||
|
f"vless://{client_id}@{server_host}:{listen_port}"
|
||||||
|
f"?type=tcp&security=reality&pbk={meta.get('public_key', '')}"
|
||||||
|
f"&sni={sni}&fp=chrome&sid={meta.get('short_id', '')}"
|
||||||
f"&spx=%2F&flow=xtls-rprx-vision#{encoded_name}"
|
f"&spx=%2F&flow=xtls-rprx-vision#{encoded_name}"
|
||||||
)
|
)
|
||||||
return url
|
|
||||||
|
|
||||||
def add_client(self, protocol, client_name, server_host, port):
|
def add_client(self, protocol, client_name, server_host, port):
|
||||||
client_id = str(uuid.uuid4())
|
client_id = str(uuid.uuid4())
|
||||||
|
|
||||||
config = self._get_server_json()
|
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)
|
self._upgrade_config_for_stats(config, restart=False)
|
||||||
|
|
||||||
@@ -717,13 +1080,8 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
if not inbound:
|
if not inbound:
|
||||||
raise RuntimeError("Xray VLESS inbound not found.")
|
raise RuntimeError("Xray VLESS inbound not found.")
|
||||||
|
|
||||||
# Ensure clients structure exists
|
|
||||||
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
|
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
|
||||||
client = {
|
client = self._client_object(client_id, inbound)
|
||||||
"id": client_id,
|
|
||||||
"flow": "xtls-rprx-vision",
|
|
||||||
"email": client_id
|
|
||||||
}
|
|
||||||
if not self._xray_api_add_user(config, client):
|
if not self._xray_api_add_user(config, client):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Xray runtime API is not available for hot user updates. "
|
"Xray runtime API is not available for hot user updates. "
|
||||||
@@ -733,7 +1091,6 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
clients_node.append(client)
|
clients_node.append(client)
|
||||||
self._write_server_json(config, restart=False)
|
self._write_server_json(config, restart=False)
|
||||||
|
|
||||||
# Update table
|
|
||||||
clients_table = self._get_clients_table()
|
clients_table = self._get_clients_table()
|
||||||
clients_table.append({
|
clients_table.append({
|
||||||
'clientId': client_id,
|
'clientId': client_id,
|
||||||
@@ -758,14 +1115,9 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
raise RuntimeError("Xray VLESS inbound not found.")
|
raise RuntimeError("Xray VLESS inbound not found.")
|
||||||
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
|
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
|
||||||
|
|
||||||
# If toggling on and not present, we can re-add it from clientsTable
|
|
||||||
if enable:
|
if enable:
|
||||||
if not any(c['id'] == client_id for c in clients_node):
|
if not any(c['id'] == client_id for c in clients_node):
|
||||||
client = {
|
client = self._client_object(client_id, inbound)
|
||||||
"id": client_id,
|
|
||||||
"flow": "xtls-rprx-vision",
|
|
||||||
"email": client_id
|
|
||||||
}
|
|
||||||
if not self._xray_api_add_user(config, client):
|
if not self._xray_api_add_user(config, client):
|
||||||
raise RuntimeError("Xray runtime API failed to enable the client without restarting the container.")
|
raise RuntimeError("Xray runtime API failed to enable the client without restarting the container.")
|
||||||
clients_node.append(client)
|
clients_node.append(client)
|
||||||
|
|||||||
@@ -1162,6 +1162,20 @@ a:hover {
|
|||||||
text-shadow: 0 0 16px rgba(168, 85, 247, 0.6);
|
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) ----- */
|
/* ----- Theme: Reverse Proxy (amber/red, shield/firewall feel) ----- */
|
||||||
.promo-revproxy {
|
.promo-revproxy {
|
||||||
background:
|
background:
|
||||||
|
|||||||
+28
-1
@@ -29,6 +29,10 @@
|
|||||||
<div id="connectionsList">
|
<div id="connectionsList">
|
||||||
{% if allow_create %}
|
{% if allow_create %}
|
||||||
<div class="share-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">
|
<button class="btn btn-secondary share-btn-lg" type="button" onclick="createGuestConfig()" id="createBtn">
|
||||||
<span id="createBtnText">{{ _('guest_get_config') }}</span>
|
<span id="createBtnText">{{ _('guest_get_config') }}</span>
|
||||||
<div class="spinner hidden" id="createSpinner"></div>
|
<div class="spinner hidden" id="createSpinner"></div>
|
||||||
@@ -178,6 +182,23 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
document.getElementById('loadingState').classList.add('hidden');
|
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) {
|
if (!data.connections || data.connections.length === 0) {
|
||||||
document.getElementById('emptyState').classList.remove('hidden');
|
document.getElementById('emptyState').classList.remove('hidden');
|
||||||
document.getElementById('connectionsGrid').classList.add('hidden');
|
document.getElementById('connectionsGrid').classList.add('hidden');
|
||||||
@@ -221,10 +242,16 @@
|
|||||||
text.classList.add('hidden');
|
text.classList.add('hidden');
|
||||||
spinner.classList.remove('hidden');
|
spinner.classList.remove('hidden');
|
||||||
try {
|
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`, {
|
const res = await fetch(`/api/guest/${TOKEN}/create`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: 'Guest VPN' })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok || data.error) throw new Error(data.error || _('error'));
|
if (!res.ok || data.error) throw new Error(data.error || _('error'));
|
||||||
|
|||||||
+283
-106
@@ -1,102 +1,104 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros/icons.html" import icon %}
|
||||||
|
|
||||||
{% block title_extra %} — {{ _('invite_public_title') }}{% endblock %}
|
{% block title_extra %} — {{ _('invite_public_title') }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card" style="max-width: 520px; margin: 2.5rem auto; overflow:hidden;">
|
<div class="share-page">
|
||||||
<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);">
|
<header class="share-hero">
|
||||||
<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>
|
<h1 class="share-title">{{ invite.name }}</h1>
|
||||||
<h2 class="card-title" style="margin-bottom:6px;">{{ invite.name }}</h2>
|
<p class="share-user">{{ _('invite_public_subtitle') }}</p>
|
||||||
<p style="color: var(--text-muted); font-size: 0.92rem; margin:0;">{{ _('invite_public_subtitle') }}</p>
|
{% if not need_password %}
|
||||||
</div>
|
<p class="share-hint">{{ _('share_copy_hint') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
{% if need_password %}
|
{% if need_password %}
|
||||||
<div style="padding: var(--space-lg); text-align: center;">
|
<div class="share-auth card">
|
||||||
<p style="margin-bottom: var(--space-md);">{{ _('invite_protected_desc') }}</p>
|
<div class="share-auth-icon">{{ icon('lock') }}</div>
|
||||||
<form id="authForm" onsubmit="authInvite(event)"
|
<p>{{ _('invite_protected_desc') }}</p>
|
||||||
style="display: flex; flex-direction: column; gap: var(--space-md); max-width: 300px; margin: 0 auto;">
|
<form id="authForm" onsubmit="authInvite(event)" class="share-auth-form">
|
||||||
<div class="form-group">
|
<input type="password" id="invitePassword" class="form-input" placeholder="{{ _('password') }}" required autofocus>
|
||||||
<input type="password" id="invitePassword" class="form-input" placeholder="{{ _('password') }}" required autofocus>
|
<button type="submit" class="btn btn-primary share-btn-lg" id="authBtn">
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary" id="authBtn">
|
|
||||||
<span id="authBtnText">{{ _('login') }}</span>
|
<span id="authBtnText">{{ _('login') }}</span>
|
||||||
<div class="spinner hidden" id="authSpinner"></div>
|
<div class="spinner hidden" id="authSpinner"></div>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="padding: var(--space-lg);">
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm); margin-bottom:var(--space-md);">
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm); margin-bottom:var(--space-lg);">
|
<div style="background:var(--bg-card); border-radius:var(--radius-md); padding:var(--space-md); text-align:center;">
|
||||||
<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 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;">
|
||||||
<div id="usesValue" style="font-weight:700; font-size:1.15rem; margin-top:4px;">
|
{% if invite.unlimited %}∞{% else %}{{ invite.remaining }}{% endif %}
|
||||||
{% 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>
|
</div>
|
||||||
</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);">
|
<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 %}
|
{% if invite.duration_days %}
|
||||||
{{ _('invite_duration_starts_hint').replace('{}', invite.duration_days|string) }}
|
{{ _('invite_duration_starts_hint').replace('{}', invite.duration_days|string) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ _('invite_get_config_hint') }}
|
{{ _('invite_get_config_hint') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if invite.available %}
|
<div id="createBlock" {% if not invite.available %}style="display:none;"{% endif %}>
|
||||||
<button class="btn btn-primary" style="width:100%;" onclick="createInviteConfig()" id="createBtn">
|
<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>
|
<span id="createBtnText">{{ _('guest_get_config') }}</span>
|
||||||
<div class="spinner hidden" id="createSpinner" style="width:14px;height:14px;"></div>
|
<div class="spinner hidden" id="createSpinner" style="width:14px;height:14px;"></div>
|
||||||
</button>
|
</button>
|
||||||
{% elif invite.exhausted %}
|
</div>
|
||||||
<p style="text-align:center; color:#ef4444; margin:0;">{{ _('invite_exhausted') }}</p>
|
<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>
|
||||||
{% else %}
|
<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>
|
||||||
<p style="text-align:center; color:#ef4444; margin:0;">{{ _('disabled') }}</p>
|
|
||||||
{% endif %}
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-backdrop" id="configModal">
|
<div class="modal-backdrop" id="configModal">
|
||||||
<div class="modal" style="max-width: 600px;">
|
<div class="modal share-modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
|
<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">×</button>
|
||||||
</div>
|
</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 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">
|
<div class="share-modal-body">
|
||||||
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('invite_sub_tab') }}</button>
|
<button type="button" class="btn btn-primary share-btn-lg" id="modalCopyBtn" onclick="copyCurrentKey()">
|
||||||
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
|
{{ icon('copy') }}
|
||||||
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
|
<span>{{ _('copy_key_big') }}</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="config-panel active" id="panel-conf">
|
<div class="share-qr-wrap">
|
||||||
<div class="config-display">
|
<div id="qrcode"></div>
|
||||||
<textarea class="config-text" id="configText" readonly rows="8"
|
<p class="share-qr-caption">{{ _('qr_code_tab') }}</p>
|
||||||
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>
|
</div>
|
||||||
</div>
|
<div class="share-extra">
|
||||||
<div class="config-panel" id="panel-vpn">
|
<button type="button" class="btn btn-secondary w-full" id="downloadBtn">{{ _('download_config_file') }}</button>
|
||||||
<div class="vpn-link-box" id="vpnLinkText" style="min-height: 100px;"></div>
|
<button type="button" class="share-link-btn" onclick="toggleConfigText()">{{ _('show_config_text') }}</button>
|
||||||
<div class="config-actions" style="margin-top:var(--space-sm);">
|
<textarea class="config-text share-config-text hidden" id="configText" readonly rows="8"></textarea>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="copyVpnLink()" style="flex:1">{{ _('copy_key') }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-panel" id="panel-qr">
|
|
||||||
<div class="qr-container"><div id="qrcode"></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,6 +107,41 @@
|
|||||||
let currentConfig = '';
|
let currentConfig = '';
|
||||||
let currentVpnLink = '';
|
let currentVpnLink = '';
|
||||||
|
|
||||||
|
function escAttr(s) {
|
||||||
|
return String(s || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||||
|
}
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
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) {
|
async function authInvite(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const password = document.getElementById('invitePassword').value;
|
const password = document.getElementById('invitePassword').value;
|
||||||
@@ -132,22 +169,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchConfigTab(tab) {
|
function fillModal(name, config, vpnLink, expiresAt) {
|
||||||
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
|
currentConfig = config || '';
|
||||||
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
|
currentVpnLink = vpnLink || '';
|
||||||
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;
|
|
||||||
document.getElementById('configModalTitle').textContent = name;
|
document.getElementById('configModalTitle').textContent = name;
|
||||||
|
document.getElementById('configText').value = currentConfig;
|
||||||
|
document.getElementById('configText').classList.add('hidden');
|
||||||
const banner = document.getElementById('expiresBanner');
|
const banner = document.getElementById('expiresBanner');
|
||||||
if (expiresAt) {
|
if (expiresAt) {
|
||||||
banner.textContent = _('invite_config_expires_at').replace('{}', new Date(expiresAt).toLocaleString());
|
banner.textContent = _('invite_config_expires_at').replace('{}', new Date(expiresAt).toLocaleString());
|
||||||
@@ -155,29 +182,142 @@
|
|||||||
} else {
|
} else {
|
||||||
banner.classList.add('hidden');
|
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');
|
const qrContainer = document.getElementById('qrcode');
|
||||||
qrContainer.innerHTML = '';
|
qrContainer.innerHTML = '';
|
||||||
new QRCode(qrContainer, {
|
const qrPayload = pickKey(currentConfig, currentVpnLink) || currentConfig;
|
||||||
text: currentVpnLink || config, width: 256, height: 256,
|
if (qrPayload && typeof QRCode !== 'undefined') {
|
||||||
colorDark: '#000000', colorLight: '#ffffff',
|
new QRCode(qrContainer, {
|
||||||
correctLevel: QRCode.CorrectLevel.L
|
text: qrPayload, width: 220, height: 220,
|
||||||
});
|
colorDark: '#000000', colorLight: '#ffffff',
|
||||||
switchConfigTab('conf');
|
correctLevel: QRCode.CorrectLevel.L
|
||||||
openModal('configModal');
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyConfig() { copyToClipboard(currentConfig); }
|
async function copyCurrentKey() {
|
||||||
function copyVpnLink() { copyToClipboard(currentVpnLink); }
|
try { await copyKeyText(pickKey(currentConfig, currentVpnLink)); }
|
||||||
|
catch (err) { alert(`${_('error')}: ` + err.message); }
|
||||||
|
}
|
||||||
|
function toggleConfigText() {
|
||||||
|
document.getElementById('configText').classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
function updateStatus(invite) {
|
function updateStatus(invite) {
|
||||||
|
if (!invite) return;
|
||||||
const uses = document.getElementById('usesValue');
|
const uses = document.getElementById('usesValue');
|
||||||
if (uses && invite) {
|
if (uses) uses.textContent = invite.unlimited ? '∞' : String(invite.remaining ?? 0);
|
||||||
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 wrap = document.getElementById('serverPickWrap');
|
||||||
const btn = document.getElementById('createBtn');
|
const sel = document.getElementById('inviteServerPick');
|
||||||
if (btn) btn.style.display = 'none';
|
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');
|
text.classList.add('hidden');
|
||||||
spinner.classList.remove('hidden');
|
spinner.classList.remove('hidden');
|
||||||
try {
|
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`, {
|
const res = await fetch(`/api/invite/${TOKEN}/create`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: 'Invite VPN' })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok || data.error) throw new Error(data.error || _('error'));
|
if (!res.ok || data.error) throw new Error(data.error || _('error'));
|
||||||
const share = data.subscription_url || data.config || '';
|
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);
|
updateStatus(data.invite);
|
||||||
|
await loadConnections();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`${_('error')}: ` + err.message);
|
alert(`${_('error')}: ` + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -207,17 +358,43 @@
|
|||||||
spinner.classList.add('hidden');
|
spinner.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadConnections);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.spinner {
|
||||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
border: 3px solid rgba(255,255,255,0.1); border-top: 3px solid var(--accent-color);
|
||||||
border-top: 3px solid var(--accent-color);
|
border-radius: 50%; width: 20px; height: 20px; animation: spin 1s linear infinite; display: inline-block;
|
||||||
border-radius: 50%;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+20
-2
@@ -174,6 +174,14 @@
|
|||||||
<input class="form-input" type="text" id="inviteNote" placeholder="{{ _('invite_note_placeholder') }}">
|
<input class="form-input" type="text" id="inviteNote" placeholder="{{ _('invite_note_placeholder') }}">
|
||||||
</div>
|
</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;">
|
<div class="form-group" id="inviteResetUsedWrap" style="display:none;">
|
||||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
||||||
<input type="checkbox" id="inviteResetUsed"> {{ _('invite_reset_used') }}
|
<input type="checkbox" id="inviteResetUsed"> {{ _('invite_reset_used') }}
|
||||||
@@ -197,16 +205,17 @@
|
|||||||
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
||||||
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
|
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
|
||||||
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
|
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
|
||||||
const PROTO_TITLES = {
|
const PROTO_TITLES = {
|
||||||
awg2: 'AmneziaWG 2.0',
|
awg2: 'AmneziaWG 2.0',
|
||||||
awg: 'AmneziaWG',
|
awg: 'AmneziaWG',
|
||||||
awg_legacy: 'AWG Legacy',
|
awg_legacy: 'AWG Legacy',
|
||||||
wireguard: 'WireGuard',
|
wireguard: 'WireGuard',
|
||||||
xray: 'Xray (VLESS-Reality)',
|
xray: 'Xray (VLESS-XHTTP-TLS)',
|
||||||
telemt: 'Telemt',
|
telemt: 'Telemt',
|
||||||
hysteria: 'Hysteria 2',
|
hysteria: 'Hysteria 2',
|
||||||
naiveproxy: 'NaiveProxy',
|
naiveproxy: 'NaiveProxy',
|
||||||
mieru: 'Mieru',
|
mieru: 'Mieru',
|
||||||
|
aivpn: 'AIVPN (auto)',
|
||||||
xui: '3x-ui VLESS',
|
xui: '3x-ui VLESS',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,6 +273,12 @@
|
|||||||
return;
|
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 => {
|
installed.forEach(key => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = key;
|
opt.value = key;
|
||||||
@@ -348,6 +363,7 @@
|
|||||||
document.getElementById('inviteNote').value = '';
|
document.getElementById('inviteNote').value = '';
|
||||||
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
||||||
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
||||||
|
document.getElementById('inviteAllowServerChoice').checked = true;
|
||||||
const serverSel = document.getElementById('inviteServer');
|
const serverSel = document.getElementById('inviteServer');
|
||||||
if (xuiConfigured && [...serverSel.options].some(o => o.value === 'xui')) serverSel.value = 'xui';
|
if (xuiConfigured && [...serverSel.options].some(o => o.value === 'xui')) serverSel.value = 'xui';
|
||||||
else if (serverSel.options.length) serverSel.selectedIndex = 0;
|
else if (serverSel.options.length) serverSel.selectedIndex = 0;
|
||||||
@@ -377,6 +393,7 @@
|
|||||||
document.getElementById('inviteClearPassword').checked = false;
|
document.getElementById('inviteClearPassword').checked = false;
|
||||||
document.getElementById('inviteResetUsedWrap').style.display = 'block';
|
document.getElementById('inviteResetUsedWrap').style.display = 'block';
|
||||||
document.getElementById('inviteResetUsed').checked = false;
|
document.getElementById('inviteResetUsed').checked = false;
|
||||||
|
document.getElementById('inviteAllowServerChoice').checked = inv.allow_server_choice !== false;
|
||||||
setInviteSelection(inv.protocol || 'xui', inv.server_id || 0);
|
setInviteSelection(inv.protocol || 'xui', inv.server_id || 0);
|
||||||
const panelSel = document.getElementById('inviteXuiPanel');
|
const panelSel = document.getElementById('inviteXuiPanel');
|
||||||
if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) {
|
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_inbound_id: inbound,
|
||||||
xui_panel_id: document.getElementById('inviteXuiPanel')?.value || '',
|
xui_panel_id: document.getElementById('inviteXuiPanel')?.value || '',
|
||||||
note: document.getElementById('inviteNote').value || '',
|
note: document.getElementById('inviteNote').value || '',
|
||||||
|
allow_server_choice: document.getElementById('inviteAllowServerChoice').checked,
|
||||||
};
|
};
|
||||||
const pwd = document.getElementById('invitePassword').value;
|
const pwd = document.getElementById('invitePassword').value;
|
||||||
if (pwd) body.password = pwd;
|
if (pwd) body.password = pwd;
|
||||||
|
|||||||
+396
-31
@@ -272,7 +272,7 @@
|
|||||||
<div class="protocol-icon">{{ icon('zap') }}</div>
|
<div class="protocol-icon">{{ icon('zap') }}</div>
|
||||||
<div class="flex gap-sm" id="xray-ctrl" style="display:none!important;"></div>
|
<div class="flex gap-sm" id="xray-ctrl" style="display:none!important;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="protocol-name">Xray (VLESS-Reality)</div>
|
<div class="protocol-name">Xray (VLESS-XHTTP-TLS)</div>
|
||||||
<div class="protocol-desc">
|
<div class="protocol-desc">
|
||||||
{{ _('xray_desc') }}
|
{{ _('xray_desc') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -412,24 +412,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===== AIVPN teaser (locked, full grid width) ===== -->
|
<!-- ===== AIVPN (smart protocol picker) ===== -->
|
||||||
<div class="promo-block promo-aivpn" aria-disabled="true" role="article">
|
<div class="promo-block promo-aivpn" id="aivpnCard" role="article">
|
||||||
<div class="promo-orbs" aria-hidden="true">
|
<div class="promo-orbs" aria-hidden="true">
|
||||||
<span class="promo-orb"></span>
|
<span class="promo-orb"></span>
|
||||||
<span class="promo-orb"></span>
|
<span class="promo-orb"></span>
|
||||||
<span class="promo-orb"></span>
|
<span class="promo-orb"></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="promo-lock-badge">{{ icon('lock') }} {{ _('coming_soon') }}</span>
|
<span class="promo-lock-badge" id="aivpnBadge">{{ icon('brain') }} AIVPN</span>
|
||||||
<div class="promo-content">
|
<div class="promo-content" style="flex-wrap: wrap;">
|
||||||
<div class="promo-icon" aria-hidden="true">{{ icon('brain') }}</div>
|
<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-title">AIVPN</div>
|
||||||
<div class="promo-subtitle">{{ _('aivpn_subtitle') }}</div>
|
<div class="promo-subtitle">{{ _('aivpn_subtitle') }}</div>
|
||||||
</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>
|
⭐ <span>{{ _('promo_star_cta') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- DNS Card -->
|
<!-- DNS Card -->
|
||||||
@@ -551,25 +571,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Connections Section (renamed from Clients) -->
|
||||||
<div class="clients-section" id="connectionsSection" style="display:none;">
|
<div class="clients-section" id="connectionsSection" style="display:none;">
|
||||||
@@ -647,6 +648,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ===== -->
|
<!-- ===== Install Protocol Modal ===== -->
|
||||||
<div class="modal-backdrop" id="installModal">
|
<div class="modal-backdrop" id="installModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -761,6 +796,56 @@
|
|||||||
<div class="form-hint">{{ _('nginx_install_hint') }}</div>
|
<div class="form-hint">{{ _('nginx_install_hint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="xrayOptions"
|
||||||
|
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||||||
|
<div class="form-hint" id="xrayPortsWarning" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
|
||||||
|
{{ _('xray_ports_warning_cf') }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('xray_listen_port') }} *</label>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:8px;">
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer;">
|
||||||
|
<input type="radio" name="xrayPortMode" value="standard" onchange="updateXrayPortUi()">
|
||||||
|
<span>{{ _('xray_port_standard') }}</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer;">
|
||||||
|
<input type="radio" name="xrayPortMode" value="custom" checked onchange="updateXrayPortUi()">
|
||||||
|
<span>{{ _('xray_port_custom') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input class="form-input" type="number" id="installXrayPort" value="8443" min="1" max="65535" oninput="syncXrayPortToInstall()">
|
||||||
|
<div class="form-hint" id="xrayPortHint">{{ _('xray_port_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('xray_domain') }} *</label>
|
||||||
|
<input class="form-input" type="text" id="installXrayDomain" placeholder="vpn.example.com" oninput="updateXrayDnsHint()">
|
||||||
|
<div class="form-hint" id="xrayDnsHint"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('xray_email') }} *</label>
|
||||||
|
<input class="form-input" type="email" id="installXrayEmail" placeholder="admin@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('xray_acme_method') }}</label>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer;">
|
||||||
|
<input type="radio" name="xrayAcmeMethod" value="cloudflare" checked onchange="updateXrayAcmeUi()">
|
||||||
|
<span>{{ _('xray_acme_cloudflare') }}</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer;">
|
||||||
|
<input type="radio" name="xrayAcmeMethod" value="http" onchange="updateXrayAcmeUi()">
|
||||||
|
<span>{{ _('xray_acme_http') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="xrayCfTokenGroup">
|
||||||
|
<label class="form-label">{{ _('xray_cf_token') }} *</label>
|
||||||
|
<input class="form-input" type="password" id="installXrayCfToken" autocomplete="off" placeholder="Cloudflare API Token">
|
||||||
|
<div class="form-hint">{{ _('xray_cf_token_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-hint">{{ _('xray_install_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="hysteriaOptions"
|
<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);">
|
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);">
|
<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 +1278,12 @@
|
|||||||
const SERVER_CONNECT_DOMAIN = {{ ((server.server_info or {}).get('connect_domain') or '') | tojson }};
|
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_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
|
||||||
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') 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 = [
|
const MARKETPLACE_APPS = [
|
||||||
{ proto: 'awg2', category: 'protocols', icon: 'sparkles', title: 'AmneziaWG 2.0', descKey: 'awg_desc', badge: 'NEW' },
|
{ 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', category: 'protocols', icon: 'shield', title: 'AmneziaWG', descKey: 'awg_desc' },
|
||||||
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_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: '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: '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' },
|
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
|
||||||
@@ -1378,6 +1464,9 @@
|
|||||||
<button class="btn btn-secondary" onclick="closeModal('managementModal'); checkServer();" style="justify-content: flex-start; padding: 12px 20px;">
|
<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')}
|
<span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('refresh')}</span> ${_('check_server_services')}
|
||||||
</button>
|
</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;">
|
<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')}
|
<span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('refresh')}</span> ${_('reboot_server')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1391,6 +1480,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() {
|
function openManagementModal() {
|
||||||
renderManagementModal();
|
renderManagementModal();
|
||||||
openModal('managementModal');
|
openModal('managementModal');
|
||||||
@@ -1537,6 +1699,7 @@
|
|||||||
case 'naiveproxy': title = 'NaiveProxy'; break;
|
case 'naiveproxy': title = 'NaiveProxy'; break;
|
||||||
case 'mieru': title = 'Mieru'; break;
|
case 'mieru': title = 'Mieru'; break;
|
||||||
case 'wireguard': title = 'WireGuard'; break;
|
case 'wireguard': title = 'WireGuard'; break;
|
||||||
|
case 'aivpn': title = 'AIVPN'; break;
|
||||||
case 'dns': title = 'AmneziaDNS'; break;
|
case 'dns': title = 'AmneziaDNS'; break;
|
||||||
case 'socks5': title = 'SOCKS5 Proxy'; break;
|
case 'socks5': title = 'SOCKS5 Proxy'; break;
|
||||||
case 'adguard': title = 'AdGuard Home'; break;
|
case 'adguard': title = 'AdGuard Home'; break;
|
||||||
@@ -1833,6 +1996,16 @@
|
|||||||
if (protoBase(proto) === 'hysteria' && info.domain) {
|
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>`;
|
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) {
|
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>`;
|
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 +2454,71 @@
|
|||||||
hint.innerHTML = `${_('naiveproxy_dns_hint')} <code>A ${domain} ${SERVER_HOST}</code>`;
|
hint.innerHTML = `${_('naiveproxy_dns_hint')} <code>A ${domain} ${SERVER_HOST}</code>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getXrayAcmeMethod() {
|
||||||
|
const el = document.querySelector('input[name="xrayAcmeMethod"]:checked');
|
||||||
|
return el ? el.value : 'cloudflare';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXrayPortMode() {
|
||||||
|
const el = document.querySelector('input[name="xrayPortMode"]:checked');
|
||||||
|
return el ? el.value : 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncXrayPortToInstall() {
|
||||||
|
const portInput = document.getElementById('installPort');
|
||||||
|
const xrPort = document.getElementById('installXrayPort');
|
||||||
|
if (!portInput || !xrPort) return;
|
||||||
|
if (getXrayPortMode() === 'standard') {
|
||||||
|
portInput.value = '443';
|
||||||
|
xrPort.value = '443';
|
||||||
|
} else {
|
||||||
|
const v = parseInt(xrPort.value, 10);
|
||||||
|
portInput.value = (v >= 1 && v <= 65535) ? String(v) : '8443';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateXrayPortUi() {
|
||||||
|
const mode = getXrayPortMode();
|
||||||
|
const xrPort = document.getElementById('installXrayPort');
|
||||||
|
const hint = document.getElementById('xrayPortHint');
|
||||||
|
if (xrPort) {
|
||||||
|
if (mode === 'standard') {
|
||||||
|
xrPort.value = '443';
|
||||||
|
xrPort.disabled = true;
|
||||||
|
} else {
|
||||||
|
xrPort.disabled = false;
|
||||||
|
if (!xrPort.value || xrPort.value === '443') {
|
||||||
|
xrPort.value = currentInstallAnother
|
||||||
|
? String(nextSuggestedPort(currentInstallProto, 8443))
|
||||||
|
: '8443';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = mode === 'standard' ? _('xray_port_hint_standard') : _('xray_port_hint');
|
||||||
|
}
|
||||||
|
syncXrayPortToInstall();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateXrayAcmeUi() {
|
||||||
|
const method = getXrayAcmeMethod();
|
||||||
|
const cfGroup = document.getElementById('xrayCfTokenGroup');
|
||||||
|
const warn = document.getElementById('xrayPortsWarning');
|
||||||
|
if (cfGroup) cfGroup.style.display = method === 'cloudflare' ? '' : 'none';
|
||||||
|
if (warn) warn.textContent = method === 'cloudflare' ? _('xray_ports_warning_cf') : _('xray_ports_warning');
|
||||||
|
updateXrayDnsHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateXrayDnsHint() {
|
||||||
|
const input = document.getElementById('installXrayDomain');
|
||||||
|
const hint = document.getElementById('xrayDnsHint');
|
||||||
|
if (!input || !hint) return;
|
||||||
|
const domain = (input.value || '').trim() || 'vpn.example.com';
|
||||||
|
const method = getXrayAcmeMethod();
|
||||||
|
const prefix = method === 'cloudflare' ? _('xray_dns_hint_cf') : _('xray_dns_hint');
|
||||||
|
hint.innerHTML = `${prefix} <code>A ${domain} ${SERVER_HOST}</code>`;
|
||||||
|
}
|
||||||
|
|
||||||
function openInstallModal(proto, installAnother = false) {
|
function openInstallModal(proto, installAnother = false) {
|
||||||
const base = protoBase(proto);
|
const base = protoBase(proto);
|
||||||
currentInstallProto = installAnother ? base : proto;
|
currentInstallProto = installAnother ? base : proto;
|
||||||
@@ -2299,6 +2537,7 @@
|
|||||||
const hysteriaOpts = document.getElementById('hysteriaOptions');
|
const hysteriaOpts = document.getElementById('hysteriaOptions');
|
||||||
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
|
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
|
||||||
const mieruOpts = document.getElementById('mieruOptions');
|
const mieruOpts = document.getElementById('mieruOptions');
|
||||||
|
const xrayOpts = document.getElementById('xrayOptions');
|
||||||
|
|
||||||
telemtOpts.style.display = 'none';
|
telemtOpts.style.display = 'none';
|
||||||
socks5Opts.style.display = 'none';
|
socks5Opts.style.display = 'none';
|
||||||
@@ -2307,6 +2546,7 @@
|
|||||||
hysteriaOpts.style.display = 'none';
|
hysteriaOpts.style.display = 'none';
|
||||||
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
|
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
|
||||||
if (mieruOpts) mieruOpts.style.display = 'none';
|
if (mieruOpts) mieruOpts.style.display = 'none';
|
||||||
|
if (xrayOpts) xrayOpts.style.display = 'none';
|
||||||
if (portGroup) portGroup.style.display = '';
|
if (portGroup) portGroup.style.display = '';
|
||||||
|
|
||||||
if (base === 'dns') {
|
if (base === 'dns') {
|
||||||
@@ -2315,10 +2555,19 @@
|
|||||||
portInput.disabled = true;
|
portInput.disabled = true;
|
||||||
portHint.textContent = _('dns_internal_hint');
|
portHint.textContent = _('dns_internal_hint');
|
||||||
} else if (base === 'xray') {
|
} else if (base === 'xray') {
|
||||||
portLabel.textContent = _('port') + ' (TCP)';
|
if (portGroup) portGroup.style.display = 'none';
|
||||||
portInput.disabled = false;
|
portInput.disabled = false;
|
||||||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
|
if (xrayOpts) xrayOpts.style.display = 'block';
|
||||||
portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('port_xray_hint');
|
const xrDomain = document.getElementById('installXrayDomain');
|
||||||
|
const xrEmail = document.getElementById('installXrayEmail');
|
||||||
|
if (xrDomain && !xrDomain.value && SERVER_SSL_DOMAIN) xrDomain.value = SERVER_SSL_DOMAIN;
|
||||||
|
if (xrEmail && !xrEmail.value && SERVER_SSL_EMAIL) xrEmail.value = SERVER_SSL_EMAIL;
|
||||||
|
const stdRadio = document.querySelector('input[name="xrayPortMode"][value="standard"]');
|
||||||
|
const customRadio = document.querySelector('input[name="xrayPortMode"][value="custom"]');
|
||||||
|
if (customRadio) customRadio.checked = true;
|
||||||
|
if (stdRadio) stdRadio.checked = false;
|
||||||
|
updateXrayPortUi();
|
||||||
|
updateXrayAcmeUi();
|
||||||
} else if (base === 'telemt') {
|
} else if (base === 'telemt') {
|
||||||
portLabel.textContent = _('port') + ' (TCP)';
|
portLabel.textContent = _('port') + ' (TCP)';
|
||||||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
|
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
|
||||||
@@ -2400,6 +2649,26 @@
|
|||||||
|
|
||||||
async function installProtocol() {
|
async function installProtocol() {
|
||||||
const port = document.getElementById('installPort').value;
|
const port = document.getElementById('installPort').value;
|
||||||
|
if (protoBase(currentInstallProto) === 'xray') {
|
||||||
|
const xrDomain = (document.getElementById('installXrayDomain')?.value || '').trim();
|
||||||
|
const xrEmail = (document.getElementById('installXrayEmail')?.value || '').trim();
|
||||||
|
const acme = getXrayAcmeMethod();
|
||||||
|
const cfToken = (document.getElementById('installXrayCfToken')?.value || '').trim();
|
||||||
|
syncXrayPortToInstall();
|
||||||
|
const xrPort = parseInt(document.getElementById('installPort')?.value, 10);
|
||||||
|
if (!xrDomain || !xrEmail) {
|
||||||
|
showToast(_('xray_domain') + ' / ' + _('xray_email'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(xrPort >= 1 && xrPort <= 65535)) {
|
||||||
|
showToast(_('xray_listen_port'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (acme === 'cloudflare' && !cfToken) {
|
||||||
|
showToast(_('xray_cf_token'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const btn = document.getElementById('installBtn');
|
const btn = document.getElementById('installBtn');
|
||||||
const text = document.getElementById('installBtnText');
|
const text = document.getElementById('installBtnText');
|
||||||
const spinner = document.getElementById('installSpinner');
|
const spinner = document.getElementById('installSpinner');
|
||||||
@@ -2458,6 +2727,15 @@
|
|||||||
}
|
}
|
||||||
params.hysteria_domain = document.getElementById('installHysteriaDomain').value.trim();
|
params.hysteria_domain = document.getElementById('installHysteriaDomain').value.trim();
|
||||||
params.hysteria_email = document.getElementById('installHysteriaEmail').value.trim();
|
params.hysteria_email = document.getElementById('installHysteriaEmail').value.trim();
|
||||||
|
} else if (protoBase(currentInstallProto) === 'xray') {
|
||||||
|
syncXrayPortToInstall();
|
||||||
|
params.port = document.getElementById('installPort').value;
|
||||||
|
params.xray_domain = (document.getElementById('installXrayDomain')?.value || '').trim();
|
||||||
|
params.xray_email = (document.getElementById('installXrayEmail')?.value || '').trim();
|
||||||
|
params.xray_acme_method = getXrayAcmeMethod();
|
||||||
|
if (params.xray_acme_method === 'cloudflare') {
|
||||||
|
params.xray_cf_token = (document.getElementById('installXrayCfToken')?.value || '').trim();
|
||||||
|
}
|
||||||
} else if (protoBase(currentInstallProto) === 'naiveproxy') {
|
} else if (protoBase(currentInstallProto) === 'naiveproxy') {
|
||||||
params.port = '443';
|
params.port = '443';
|
||||||
params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').value.trim();
|
params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').value.trim();
|
||||||
@@ -2941,7 +3219,7 @@
|
|||||||
selectedConnectionIds.clear();
|
selectedConnectionIds.clear();
|
||||||
updateMoveSelection();
|
updateMoveSelection();
|
||||||
try {
|
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';
|
loading.style.display = 'none';
|
||||||
if (!data.clients || data.clients.length === 0) {
|
if (!data.clients || data.clients.length === 0) {
|
||||||
emptyEl.classList.remove('hidden');
|
emptyEl.classList.remove('hidden');
|
||||||
@@ -3289,9 +3567,96 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ========== AIVPN ==========
|
||||||
|
function formatAivpnRecommendation(rec) {
|
||||||
|
if (!rec || !rec.protocol) {
|
||||||
|
return _('aivpn_no_candidates');
|
||||||
|
}
|
||||||
|
const rtt = (rec.rtt_ms != null) ? ` · ${rec.rtt_ms} ms` : '';
|
||||||
|
const alts = (rec.alternatives || []).slice(0, 3)
|
||||||
|
.map(a => getProtoTitle(a.protocol))
|
||||||
|
.filter(Boolean);
|
||||||
|
const altTxt = alts.length ? ` · ${_('aivpn_alternatives')}: ${alts.join(', ')}` : '';
|
||||||
|
return `${_('aivpn_picked')}: <strong>${getProtoTitle(rec.protocol)}</strong> (score ${Math.round(rec.score || 0)}${rtt})${altTxt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAivpnUi(data) {
|
||||||
|
const settings = (data && data.settings) || {};
|
||||||
|
aivpnEnabled = !!settings.enabled;
|
||||||
|
const strat = document.getElementById('aivpnStrategy');
|
||||||
|
const en = document.getElementById('aivpnEnabled');
|
||||||
|
const probe = document.getElementById('aivpnProbe');
|
||||||
|
if (strat && settings.strategy) strat.value = settings.strategy;
|
||||||
|
if (en) en.checked = aivpnEnabled;
|
||||||
|
if (probe) probe.checked = settings.probe !== false;
|
||||||
|
const badge = document.getElementById('aivpnBadge');
|
||||||
|
if (badge) {
|
||||||
|
badge.innerHTML = aivpnEnabled
|
||||||
|
? `${uiIcon('brain')} AIVPN · ${_('aivpn_on')}`
|
||||||
|
: `${uiIcon('brain')} AIVPN`;
|
||||||
|
}
|
||||||
|
const resultEl = document.getElementById('aivpnResult');
|
||||||
|
if (resultEl) resultEl.innerHTML = formatAivpnRecommendation(data && data.recommendation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAivpnSettings(doProbe) {
|
||||||
|
try {
|
||||||
|
const q = doProbe ? '?probe=1' : '?probe=0';
|
||||||
|
const data = await apiCall(`/api/servers/${SERVER_ID}/aivpn${q}`);
|
||||||
|
applyAivpnUi(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('AIVPN load failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAivpnSettings() {
|
||||||
|
const btn = document.getElementById('aivpnSaveBtn');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const data = await apiCall(`/api/servers/${SERVER_ID}/aivpn`, 'POST', {
|
||||||
|
enabled: document.getElementById('aivpnEnabled').checked,
|
||||||
|
strategy: document.getElementById('aivpnStrategy').value,
|
||||||
|
probe: document.getElementById('aivpnProbe').checked,
|
||||||
|
});
|
||||||
|
applyAivpnUi(data);
|
||||||
|
showToast(_('settings_saved'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(_('error') + ': ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAivpnPick() {
|
||||||
|
const btn = document.getElementById('aivpnPickBtn');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
const resultEl = document.getElementById('aivpnResult');
|
||||||
|
if (resultEl) resultEl.textContent = _('aivpn_picking');
|
||||||
|
try {
|
||||||
|
const data = await apiCall(`/api/servers/${SERVER_ID}/aivpn?probe=1`);
|
||||||
|
applyAivpnUi(data);
|
||||||
|
const picked = data.recommendation && data.recommendation.protocol;
|
||||||
|
if (!picked) {
|
||||||
|
showToast(_('aivpn_no_candidates'), 'warning');
|
||||||
|
} else {
|
||||||
|
const sel = document.getElementById('connProtoSelect');
|
||||||
|
if (sel && [...sel.options].some(o => o.value === picked)) {
|
||||||
|
sel.value = picked;
|
||||||
|
loadConnections();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (resultEl) resultEl.textContent = '';
|
||||||
|
showToast(_('error') + ': ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Init ==========
|
// ========== Init ==========
|
||||||
applyInstalledAppsVisibility();
|
applyInstalledAppsVisibility();
|
||||||
checkServer();
|
checkServer();
|
||||||
loadServerStats();
|
loadServerStats();
|
||||||
|
loadAivpnSettings(false);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+16
-1
@@ -131,6 +131,13 @@
|
|||||||
</select>
|
</select>
|
||||||
<input type="hidden" id="guest_create_protocol_pref" value="{{ settings.guest.create_protocol or '' }}">
|
<input type="hidden" id="guest_create_protocol_pref" value="{{ settings.guest.create_protocol or '' }}">
|
||||||
</div>
|
</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 %}">
|
<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>
|
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
||||||
<select class="form-select" id="guest_create_xui_panel" onchange="loadGuestInbounds()">
|
<select class="form-select" id="guest_create_xui_panel" onchange="loadGuestInbounds()">
|
||||||
@@ -1516,6 +1523,7 @@
|
|||||||
create_server_id: createServerId,
|
create_server_id: createServerId,
|
||||||
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0'),
|
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0'),
|
||||||
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
|
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) => ({
|
const donateMethod = (key) => ({
|
||||||
@@ -1555,11 +1563,12 @@
|
|||||||
awg: 'AmneziaWG',
|
awg: 'AmneziaWG',
|
||||||
awg_legacy: 'AWG Legacy',
|
awg_legacy: 'AWG Legacy',
|
||||||
wireguard: 'WireGuard',
|
wireguard: 'WireGuard',
|
||||||
xray: 'Xray (VLESS-Reality)',
|
xray: 'Xray (VLESS-XHTTP-TLS)',
|
||||||
telemt: 'Telemt',
|
telemt: 'Telemt',
|
||||||
hysteria: 'Hysteria 2',
|
hysteria: 'Hysteria 2',
|
||||||
naiveproxy: 'NaiveProxy',
|
naiveproxy: 'NaiveProxy',
|
||||||
mieru: 'Mieru',
|
mieru: 'Mieru',
|
||||||
|
aivpn: 'AIVPN (auto)',
|
||||||
xui: '3x-ui VLESS',
|
xui: '3x-ui VLESS',
|
||||||
};
|
};
|
||||||
function protoTitle(key) {
|
function protoTitle(key) {
|
||||||
@@ -1602,6 +1611,12 @@
|
|||||||
opt.textContent = _('no_protocols');
|
opt.textContent = _('no_protocols');
|
||||||
protoSel.appendChild(opt);
|
protoSel.appendChild(opt);
|
||||||
} else {
|
} 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 => {
|
installed.forEach(key => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = key;
|
opt.value = key;
|
||||||
|
|||||||
+13
-1
@@ -637,7 +637,7 @@
|
|||||||
awg: 'AmneziaWG',
|
awg: 'AmneziaWG',
|
||||||
awg_legacy: 'AWG Legacy',
|
awg_legacy: 'AWG Legacy',
|
||||||
wireguard: 'WireGuard',
|
wireguard: 'WireGuard',
|
||||||
xray: 'Xray (VLESS-Reality)',
|
xray: 'Xray (VLESS-XHTTP-TLS)',
|
||||||
telemt: 'Telemt',
|
telemt: 'Telemt',
|
||||||
hysteria: 'Hysteria 2',
|
hysteria: 'Hysteria 2',
|
||||||
naiveproxy: 'NaiveProxy',
|
naiveproxy: 'NaiveProxy',
|
||||||
@@ -685,6 +685,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (count > 0) {
|
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 = '';
|
if (group) group.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
@@ -766,6 +771,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('ucProtocol').addEventListener('change', (e) => {
|
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') {
|
if (document.getElementById('ucMode').value === 'existing') {
|
||||||
fetchExistingClients();
|
fetchExistingClients();
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-2
@@ -72,7 +72,7 @@
|
|||||||
"docker_not_installed": "Docker not installed",
|
"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_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.",
|
"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.",
|
"wireguard_desc": "Standard and fastest VPN protocol. Supported natively on all modern OS, but easily detected by DPI.",
|
||||||
"not_checked": "Not checked",
|
"not_checked": "Not checked",
|
||||||
"connections": "Connections",
|
"connections": "Connections",
|
||||||
@@ -82,7 +82,27 @@
|
|||||||
"no_connections_desc": "Add your first connection to generate a VPN configuration",
|
"no_connections_desc": "Add your first connection to generate a VPN configuration",
|
||||||
"install_protocol": "Install protocol",
|
"install_protocol": "Install protocol",
|
||||||
"port_default_hint": "Default port: 55424. Make sure it\u0027s not busy",
|
"port_default_hint": "Default port: 55424. Make sure it\u0027s not busy",
|
||||||
"port_xray_hint": "Default port: 443 (recommended for Xray). Make sure it\u0027s not taken by another web server.",
|
"port_xray_hint": "Choose standard 443/TCP or any free custom TCP port. Prefer Cloudflare DNS ACME — port 80 is not needed.",
|
||||||
|
"port_xray_hint_cf": "Choose 443 or a custom TCP port. Certificate via Cloudflare DNS — port 80 is not used. Keep Cloudflare proxy off (grey cloud).",
|
||||||
|
"xray_domain": "Domain",
|
||||||
|
"xray_email": "Let\u0027s Encrypt email",
|
||||||
|
"xray_dns_hint": "Create DNS record:",
|
||||||
|
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
|
||||||
|
"xray_install_hint": "Installs VLESS + XHTTP + TLS (Xray-core). Prefer Cloudflare API token (DNS-01) so TCP 80 stays free. Port 443 is optional.",
|
||||||
|
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01). Listen port can be 443 or any free TCP port. Reinstall replaces the previous Xray stack.",
|
||||||
|
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Listen on 443 or any free TCP port. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
|
||||||
|
"xray_listen_port": "Listen port (TCP)",
|
||||||
|
"xray_port_standard": "Standard — 443/TCP",
|
||||||
|
"xray_port_custom": "Custom port",
|
||||||
|
"xray_port_hint": "Any free TCP port (default 8443). Open it in the firewall. Does not occupy 443.",
|
||||||
|
"xray_port_hint_standard": "Uses 443/TCP. Make sure nothing else is bound to 443 on this server.",
|
||||||
|
"xray_acme_method": "Certificate method",
|
||||||
|
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
|
||||||
|
"xray_acme_http": "HTTP-01 — needs free TCP 80",
|
||||||
|
"xray_acme_cloudflare_short": "Cloudflare DNS",
|
||||||
|
"xray_acme_http_short": "HTTP-01",
|
||||||
|
"xray_cf_token": "Cloudflare API token",
|
||||||
|
"xray_cf_token_hint": "Create a token with Zone:DNS:Edit for this domain zone. Token is used only to issue the cert and is not shown again.",
|
||||||
"reinstall": "Reinstall",
|
"reinstall": "Reinstall",
|
||||||
"uninstall_confirm": "Uninstall {}? All connections and configurations will be lost.",
|
"uninstall_confirm": "Uninstall {}? All connections and configurations will be lost.",
|
||||||
"stop_container_confirm": "Stop container {}?",
|
"stop_container_confirm": "Stop container {}?",
|
||||||
@@ -415,6 +435,20 @@
|
|||||||
"server_connect_domain_current": "Endpoint in configs",
|
"server_connect_domain_current": "Endpoint in configs",
|
||||||
"server_connect_domain_ssh": "SSH",
|
"server_connect_domain_ssh": "SSH",
|
||||||
"server_connect_domain_saved": "Connection domain saved",
|
"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",
|
"server_endpoint_label": "Endpoint",
|
||||||
"support_title": "Support the project",
|
"support_title": "Support the project",
|
||||||
"support_intro": "If this panel helps you, you can support development. Choose a convenient method below.",
|
"support_intro": "If this panel helps you, you can support development. Choose a convenient method below.",
|
||||||
@@ -511,6 +545,22 @@
|
|||||||
"coming_soon": "Coming soon",
|
"coming_soon": "Coming soon",
|
||||||
"promo_star_cta": "Star us on GitHub",
|
"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_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_title": "Reverse Proxy",
|
||||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
||||||
"management": "Management",
|
"management": "Management",
|
||||||
|
|||||||
+52
-2
@@ -71,7 +71,7 @@
|
|||||||
"docker_not_installed": "داکر نصب نیست",
|
"docker_not_installed": "داکر نصب نیست",
|
||||||
"awg_desc": "نسخه جدید پروتکل بر پایه awg-go. پشتیبانی از مبهمسازی پیشرفته (S3, S4).",
|
"awg_desc": "نسخه جدید پروتکل بر پایه awg-go. پشتیبانی از مبهمسازی پیشرفته (S3, S4).",
|
||||||
"awg_legacy_desc": "نسخه اصلی AWG. سازگار با نسخههای قدیمی کلاینت.",
|
"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": "بررسی نشده",
|
"not_checked": "بررسی نشده",
|
||||||
"connections": "اتصالها",
|
"connections": "اتصالها",
|
||||||
"add": "افزودن",
|
"add": "افزودن",
|
||||||
@@ -80,7 +80,27 @@
|
|||||||
"no_connections_desc": "اولین اتصال خود را برای ایجاد پیکربندی VPN اضافه کنید",
|
"no_connections_desc": "اولین اتصال خود را برای ایجاد پیکربندی VPN اضافه کنید",
|
||||||
"install_protocol": "نصب پروتکل",
|
"install_protocol": "نصب پروتکل",
|
||||||
"port_default_hint": "پورت پیشفرض: 55424. مطمئن شوید این پورت آزاد است.",
|
"port_default_hint": "پورت پیشفرض: 55424. مطمئن شوید این پورت آزاد است.",
|
||||||
"port_xray_hint": "پورت پیشنهادی: 443. مطمئن شوید توسط وبسرور دیگری اشغال نشده باشد.",
|
"port_xray_hint": "Choose standard 443/TCP or any free custom TCP port. Prefer Cloudflare DNS ACME — port 80 is not needed.",
|
||||||
|
"port_xray_hint_cf": "Choose 443 or a custom TCP port. Certificate via Cloudflare DNS — port 80 is not used.",
|
||||||
|
"xray_domain": "Domain",
|
||||||
|
"xray_email": "Let\u0027s Encrypt email",
|
||||||
|
"xray_dns_hint": "Create DNS record:",
|
||||||
|
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
|
||||||
|
"xray_install_hint": "Installs VLESS + XHTTP + TLS. Prefer Cloudflare API token (DNS-01) so TCP 80 stays free. Port 443 is optional.",
|
||||||
|
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01). Listen port can be 443 or any free TCP port.",
|
||||||
|
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Listen on 443 or any free TCP port. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
|
||||||
|
"xray_listen_port": "Listen port (TCP)",
|
||||||
|
"xray_port_standard": "Standard — 443/TCP",
|
||||||
|
"xray_port_custom": "Custom port",
|
||||||
|
"xray_port_hint": "Any free TCP port (default 8443). Open it in the firewall. Does not occupy 443.",
|
||||||
|
"xray_port_hint_standard": "Uses 443/TCP. Make sure nothing else is bound to 443 on this server.",
|
||||||
|
"xray_acme_method": "Certificate method",
|
||||||
|
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
|
||||||
|
"xray_acme_http": "HTTP-01 — needs free TCP 80",
|
||||||
|
"xray_acme_cloudflare_short": "Cloudflare DNS",
|
||||||
|
"xray_acme_http_short": "HTTP-01",
|
||||||
|
"xray_cf_token": "Cloudflare API token",
|
||||||
|
"xray_cf_token_hint": "Token with Zone:DNS:Edit for this domain zone. Used only to issue the cert.",
|
||||||
"reinstall": "نصب مجدد",
|
"reinstall": "نصب مجدد",
|
||||||
"uninstall_confirm": "حذف نصب {}؟ تمام اتصالها و پیکربندیها از بین خواهند رفت.",
|
"uninstall_confirm": "حذف نصب {}؟ تمام اتصالها و پیکربندیها از بین خواهند رفت.",
|
||||||
"stop_container_confirm": "توقف کانتینر {}؟",
|
"stop_container_confirm": "توقف کانتینر {}؟",
|
||||||
@@ -399,6 +419,20 @@
|
|||||||
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده میشود (Let\u0027s Encrypt).",
|
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده میشود (Let\u0027s Encrypt).",
|
||||||
"server_connect_domain": "دامنه اتصال کلاینت",
|
"server_connect_domain": "دامنه اتصال کلاینت",
|
||||||
"server_connect_domain_hint": "در پیکربندی VPN بهجای IP استفاده میشود. هنگام انتقال سرور، رکورد DNS A را بهروز کنید.",
|
"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": "لاگهای کانتینر",
|
"container_logs": "لاگهای کانتینر",
|
||||||
"logs_btn": "📋 Logs",
|
"logs_btn": "📋 Logs",
|
||||||
"logs_live": "زنده",
|
"logs_live": "زنده",
|
||||||
@@ -465,6 +499,22 @@
|
|||||||
"coming_soon": "Coming soon",
|
"coming_soon": "Coming soon",
|
||||||
"promo_star_cta": "Star us on GitHub",
|
"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_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_title": "Reverse Proxy",
|
||||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
"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.",
|
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||||
|
|||||||
+52
-2
@@ -71,7 +71,7 @@
|
|||||||
"docker_not_installed": "Docker non installé",
|
"docker_not_installed": "Docker non installé",
|
||||||
"awg_desc": "Version moderne basée sur awg-go. Obfuscation avancée (S3, S4).",
|
"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.",
|
"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é",
|
"not_checked": "Non vérifié",
|
||||||
"connections": "Connexions",
|
"connections": "Connexions",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
@@ -80,7 +80,27 @@
|
|||||||
"no_connections_desc": "Ajoutez votre première connexion pour générer un fichier VPN",
|
"no_connections_desc": "Ajoutez votre première connexion pour générer un fichier VPN",
|
||||||
"install_protocol": "Installer le protocole",
|
"install_protocol": "Installer le protocole",
|
||||||
"port_default_hint": "Port par défaut : 55424. Assurez-vous qu\u0027il est libre.",
|
"port_default_hint": "Port par défaut : 55424. Assurez-vous qu\u0027il est libre.",
|
||||||
"port_xray_hint": "Port recommandé : 443. Assurez-vous qu\u0027il n\u0027est pas utilisé par un serveur web.",
|
"port_xray_hint": "Choose standard 443/TCP or any free custom TCP port. Prefer Cloudflare DNS ACME — port 80 is not needed.",
|
||||||
|
"port_xray_hint_cf": "Choose 443 or a custom TCP port. Certificate via Cloudflare DNS — port 80 is not used.",
|
||||||
|
"xray_domain": "Domain",
|
||||||
|
"xray_email": "Let\u0027s Encrypt email",
|
||||||
|
"xray_dns_hint": "Create DNS record:",
|
||||||
|
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
|
||||||
|
"xray_install_hint": "Installs VLESS + XHTTP + TLS. Prefer Cloudflare API token (DNS-01) so TCP 80 stays free. Port 443 is optional.",
|
||||||
|
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01). Listen port can be 443 or any free TCP port.",
|
||||||
|
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Listen on 443 or any free TCP port. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
|
||||||
|
"xray_listen_port": "Listen port (TCP)",
|
||||||
|
"xray_port_standard": "Standard — 443/TCP",
|
||||||
|
"xray_port_custom": "Custom port",
|
||||||
|
"xray_port_hint": "Any free TCP port (default 8443). Open it in the firewall. Does not occupy 443.",
|
||||||
|
"xray_port_hint_standard": "Uses 443/TCP. Make sure nothing else is bound to 443 on this server.",
|
||||||
|
"xray_acme_method": "Certificate method",
|
||||||
|
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
|
||||||
|
"xray_acme_http": "HTTP-01 — needs free TCP 80",
|
||||||
|
"xray_acme_cloudflare_short": "Cloudflare DNS",
|
||||||
|
"xray_acme_http_short": "HTTP-01",
|
||||||
|
"xray_cf_token": "Cloudflare API token",
|
||||||
|
"xray_cf_token_hint": "Token with Zone:DNS:Edit for this domain zone. Used only to issue the cert.",
|
||||||
"reinstall": "Réinstaller",
|
"reinstall": "Réinstaller",
|
||||||
"uninstall_confirm": "Désinstaller {} ? Toutes les données seront perdues.",
|
"uninstall_confirm": "Désinstaller {} ? Toutes les données seront perdues.",
|
||||||
"stop_container_confirm": "Arrêter le conteneur {} ?",
|
"stop_container_confirm": "Arrêter le conteneur {} ?",
|
||||||
@@ -399,6 +419,20 @@
|
|||||||
"server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).",
|
"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": "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.",
|
"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",
|
"container_logs": "Logs du conteneur",
|
||||||
"logs_btn": "📋 Logs",
|
"logs_btn": "📋 Logs",
|
||||||
"logs_live": "Temps réel",
|
"logs_live": "Temps réel",
|
||||||
@@ -465,6 +499,22 @@
|
|||||||
"coming_soon": "Coming soon",
|
"coming_soon": "Coming soon",
|
||||||
"promo_star_cta": "Star us on GitHub",
|
"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_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_title": "Reverse Proxy",
|
||||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
"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.",
|
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||||
|
|||||||
+52
-2
@@ -72,7 +72,7 @@
|
|||||||
"docker_not_installed": "Docker не установлен",
|
"docker_not_installed": "Docker не установлен",
|
||||||
"awg_desc": "Новая версия протокола на основе awg-go. Поддерживает расширенную обфускацию с параметрами S3, S4.",
|
"awg_desc": "Новая версия протокола на основе awg-go. Поддерживает расширенную обфускацию с параметрами S3, S4.",
|
||||||
"awg_legacy_desc": "Оригинальная версия AWG на базе ядра WireGuard. Совместима с клиентами старых версий.",
|
"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.",
|
"wireguard_desc": "Стандартный и самый быстрый VPN-протокол. Встроен во все современные ОС, но легко блокируется DPI.",
|
||||||
"not_checked": "Не проверено",
|
"not_checked": "Не проверено",
|
||||||
"connections": "Подключения",
|
"connections": "Подключения",
|
||||||
@@ -82,7 +82,27 @@
|
|||||||
"no_connections_desc": "Добавьте первое подключение для генерации VPN конфигурации",
|
"no_connections_desc": "Добавьте первое подключение для генерации VPN конфигурации",
|
||||||
"install_protocol": "Установить протокол",
|
"install_protocol": "Установить протокол",
|
||||||
"port_default_hint": "Порт по умолчанию: 55424. Убедитесь, что он не занят",
|
"port_default_hint": "Порт по умолчанию: 55424. Убедитесь, что он не занят",
|
||||||
"port_xray_hint": "Порт по умолчанию: 443 (рекомендуется для Xray). Убедитесь, что он не занят другим веб-сервером.",
|
"port_xray_hint": "Можно взять стандартный 443/TCP или любой свободный порт. Лучше Cloudflare DNS ACME — порт 80 не нужен.",
|
||||||
|
"port_xray_hint_cf": "443 или свой TCP-порт. Сертификат через Cloudflare DNS — порт 80 не используется. Прокси Cloudflare выключите (серое облако).",
|
||||||
|
"xray_domain": "Домен",
|
||||||
|
"xray_email": "Email для Let\u0027s Encrypt",
|
||||||
|
"xray_dns_hint": "Создайте DNS-запись:",
|
||||||
|
"xray_dns_hint_cf": "Домен должен быть в Cloudflare. A-запись (только DNS):",
|
||||||
|
"xray_install_hint": "Ставит VLESS + XHTTP + TLS (Xray-core). Рекомендуется токен Cloudflare (DNS-01) — TCP 80 не занимается. Порт 443 необязателен.",
|
||||||
|
"xray_ports_warning": "На время установки TCP 80 должен быть свободен (Let\u0027s Encrypt HTTP-01). Слушающий порт — 443 или любой свободный. Переустановка заменяет предыдущий Xray.",
|
||||||
|
"xray_ports_warning_cf": "Cloudflare DNS ACME не использует порт 80. Слушайте на 443 или любом свободном TCP. Токену нужно Zone → DNS → Edit. Прокси выключите (серое облако).",
|
||||||
|
"xray_listen_port": "Порт прослушивания (TCP)",
|
||||||
|
"xray_port_standard": "Стандартный — 443/TCP",
|
||||||
|
"xray_port_custom": "Свой порт",
|
||||||
|
"xray_port_hint": "Любой свободный TCP-порт (по умолчанию 8443). Откройте его в файрволе. Порт 443 не занимает.",
|
||||||
|
"xray_port_hint_standard": "Использует 443/TCP. Убедитесь, что 443 на сервере свободен.",
|
||||||
|
"xray_acme_method": "Способ выпуска SSL",
|
||||||
|
"xray_acme_cloudflare": "Cloudflare DNS (API-токен) — рекомендуется, без порта 80",
|
||||||
|
"xray_acme_http": "HTTP-01 — нужен свободный TCP 80",
|
||||||
|
"xray_acme_cloudflare_short": "Cloudflare DNS",
|
||||||
|
"xray_acme_http_short": "HTTP-01",
|
||||||
|
"xray_cf_token": "API-токен Cloudflare",
|
||||||
|
"xray_cf_token_hint": "Создайте токен с правом Zone:DNS:Edit для зоны домена. Токен нужен только для выпуска сертификата и больше не показывается.",
|
||||||
"reinstall": "Переустановить",
|
"reinstall": "Переустановить",
|
||||||
"uninstall_confirm": "Удалить {}? Все подключения и конфигурации будут потеряны.",
|
"uninstall_confirm": "Удалить {}? Все подключения и конфигурации будут потеряны.",
|
||||||
"stop_container_confirm": "Остановить контейнер {}?",
|
"stop_container_confirm": "Остановить контейнер {}?",
|
||||||
@@ -415,6 +435,20 @@
|
|||||||
"server_connect_domain_current": "Endpoint в конфигах",
|
"server_connect_domain_current": "Endpoint в конфигах",
|
||||||
"server_connect_domain_ssh": "SSH",
|
"server_connect_domain_ssh": "SSH",
|
||||||
"server_connect_domain_saved": "Домен подключения сохранён",
|
"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",
|
"server_endpoint_label": "Endpoint",
|
||||||
"support_title": "Поддержать проект",
|
"support_title": "Поддержать проект",
|
||||||
"support_intro": "Если панель вам помогает, можно поддержать разработку. Выберите удобный способ ниже.",
|
"support_intro": "Если панель вам помогает, можно поддержать разработку. Выберите удобный способ ниже.",
|
||||||
@@ -511,6 +545,22 @@
|
|||||||
"coming_soon": "Скоро",
|
"coming_soon": "Скоро",
|
||||||
"promo_star_cta": "Поставить звезду на GitHub",
|
"promo_star_cta": "Поставить звезду на GitHub",
|
||||||
"aivpn_subtitle": "ИИ-подбор протокола, который сам выбирает правильный туннель под текущие условия. Поможете звездой — выйдет быстрее.",
|
"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_title": "Reverse Proxy",
|
||||||
"revproxy_subtitle": "Высокопроизводительная маскировка трафика: прячем VPN за обычным сайтом, снижаем заметность для DPI.",
|
"revproxy_subtitle": "Высокопроизводительная маскировка трафика: прячем VPN за обычным сайтом, снижаем заметность для DPI.",
|
||||||
"management": "Управление",
|
"management": "Управление",
|
||||||
|
|||||||
+52
-2
@@ -71,7 +71,7 @@
|
|||||||
"docker_not_installed": "Docker 未安装",
|
"docker_not_installed": "Docker 未安装",
|
||||||
"awg_desc": "基于 awg-go 的新版协议。支持 S3, S4 高级混淆。",
|
"awg_desc": "基于 awg-go 的新版协议。支持 S3, S4 高级混淆。",
|
||||||
"awg_legacy_desc": "原始 AWG 版本。兼容旧版客户端。",
|
"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": "未检查",
|
"not_checked": "未检查",
|
||||||
"connections": "连接",
|
"connections": "连接",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
@@ -80,7 +80,27 @@
|
|||||||
"no_connections_desc": "添加首个连接以生成 VPN 配置文件",
|
"no_connections_desc": "添加首个连接以生成 VPN 配置文件",
|
||||||
"install_protocol": "安装协议",
|
"install_protocol": "安装协议",
|
||||||
"port_default_hint": "默认端口: 55424。请确保端口未被占用。",
|
"port_default_hint": "默认端口: 55424。请确保端口未被占用。",
|
||||||
"port_xray_hint": "推荐端口: 443。请确保未被其他 Web 服务器使用。",
|
"port_xray_hint": "Choose standard 443/TCP or any free custom TCP port. Prefer Cloudflare DNS ACME — port 80 is not needed.",
|
||||||
|
"port_xray_hint_cf": "Choose 443 or a custom TCP port. Certificate via Cloudflare DNS — port 80 is not used.",
|
||||||
|
"xray_domain": "Domain",
|
||||||
|
"xray_email": "Let\u0027s Encrypt email",
|
||||||
|
"xray_dns_hint": "Create DNS record:",
|
||||||
|
"xray_dns_hint_cf": "Domain must be on Cloudflare. A-record (DNS only):",
|
||||||
|
"xray_install_hint": "Installs VLESS + XHTTP + TLS. Prefer Cloudflare API token (DNS-01) so TCP 80 stays free. Port 443 is optional.",
|
||||||
|
"xray_ports_warning": "TCP 80 must be free during install (Let\u0027s Encrypt HTTP-01). Listen port can be 443 or any free TCP port.",
|
||||||
|
"xray_ports_warning_cf": "Cloudflare DNS ACME does not use port 80. Listen on 443 or any free TCP port. Token needs Zone → DNS → Edit. Keep proxy off (grey cloud).",
|
||||||
|
"xray_listen_port": "Listen port (TCP)",
|
||||||
|
"xray_port_standard": "Standard — 443/TCP",
|
||||||
|
"xray_port_custom": "Custom port",
|
||||||
|
"xray_port_hint": "Any free TCP port (default 8443). Open it in the firewall. Does not occupy 443.",
|
||||||
|
"xray_port_hint_standard": "Uses 443/TCP. Make sure nothing else is bound to 443 on this server.",
|
||||||
|
"xray_acme_method": "Certificate method",
|
||||||
|
"xray_acme_cloudflare": "Cloudflare DNS (API token) — recommended, no port 80",
|
||||||
|
"xray_acme_http": "HTTP-01 — needs free TCP 80",
|
||||||
|
"xray_acme_cloudflare_short": "Cloudflare DNS",
|
||||||
|
"xray_acme_http_short": "HTTP-01",
|
||||||
|
"xray_cf_token": "Cloudflare API token",
|
||||||
|
"xray_cf_token_hint": "Token with Zone:DNS:Edit for this domain zone. Used only to issue the cert.",
|
||||||
"reinstall": "重新安装",
|
"reinstall": "重新安装",
|
||||||
"uninstall_confirm": "确定卸载 {}?所有连接和配置都将丢失。",
|
"uninstall_confirm": "确定卸载 {}?所有连接和配置都将丢失。",
|
||||||
"stop_container_confirm": "停止容器 {}?",
|
"stop_container_confirm": "停止容器 {}?",
|
||||||
@@ -399,6 +419,20 @@
|
|||||||
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",
|
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",
|
||||||
"server_connect_domain": "客户端连接域名",
|
"server_connect_domain": "客户端连接域名",
|
||||||
"server_connect_domain_hint": "在 VPN 配置中替代 IP 使用。迁移服务器时只需更新 DNS A 记录。",
|
"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": "容器日志",
|
"container_logs": "容器日志",
|
||||||
"logs_btn": "📋 日志",
|
"logs_btn": "📋 日志",
|
||||||
"logs_live": "实时",
|
"logs_live": "实时",
|
||||||
@@ -465,6 +499,22 @@
|
|||||||
"coming_soon": "Coming soon",
|
"coming_soon": "Coming soon",
|
||||||
"promo_star_cta": "Star us on GitHub",
|
"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_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_title": "Reverse Proxy",
|
||||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
"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.",
|
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||||
|
|||||||
Reference in New Issue
Block a user