From 73d0ee277d9daf59805c1588bec9be0fa5ca9674 Mon Sep 17 00:00:00 2001 From: orohi Date: Tue, 28 Jul 2026 13:21:54 +0300 Subject: [PATCH] Add AWG/WireGuard connect-domain UI on server page and fix Traefik port for Dokploy. Co-authored-by: Cursor --- app.py | 136 ++++++++++++++++++++++++++++++++++-------- docker-compose.yml | 1 + templates/server.html | 117 ++++++++++++++++++++++++++++++++++++ translations/en.json | 6 ++ translations/ru.json | 6 ++ 5 files changed, 242 insertions(+), 24 deletions(-) diff --git a/app.py b/app.py index b145e5e..c0f4bb5 100644 --- a/app.py +++ b/app.py @@ -103,7 +103,7 @@ else: application_path = os.path.dirname(__file__) DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export -CURRENT_VERSION = "v2.6.4" +CURRENT_VERSION = "v2.6.5" RELEASES_REPO_URL = repo_url() RELEASES_API_LATEST = api_latest_url() BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) @@ -199,12 +199,26 @@ def get_ssh(server): ) -def get_server_connect_host(server: dict) -> str: +WG_AWG_PROTOCOLS = frozenset({'awg', 'awg2', 'awg_legacy', 'wireguard'}) + + +def get_server_connect_host(server: dict, protocol: Optional[str] = None) -> str: """Hostname embedded in client VPN configs (Endpoint, vless://, etc.). SSH still uses server['host']; this value can be a DNS name so clients keep working after the server IP changes — update the A-record only. + Per-protocol connect_domain (AWG / WireGuard) overrides the server default. """ + protocols = server.get('protocols') or {} + if protocol: + base = protocol_base(protocol) + if base in WG_AWG_PROTOCOLS: + for key in (protocol, base): + pinfo = protocols.get(key) if key else None + if isinstance(pinfo, dict): + domain = (pinfo.get('connect_domain') or '').strip() + if domain: + return domain.lower().rstrip('.') info = server.get('server_info') or {} for key in ('connect_domain', 'ssl_domain'): val = (info.get(key) or '').strip() @@ -1446,7 +1460,7 @@ def _create_remote_client(manager, protocol: str, server: dict, name: str, sourc base = protocol_base(protocol) if base == 'telemt': user_data = (source_client or {}).get('userData') or {} - connect_host = get_server_connect_host(server) + connect_host = get_server_connect_host(server, protocol) return manager.add_client( protocol, name, connect_host, port, telemt_quota=user_data.get('quota'), @@ -1455,7 +1469,7 @@ def _create_remote_client(manager, protocol: str, server: dict, name: str, sourc user_ad_tag=user_data.get('user_ad_tag'), max_tcp_conns=user_data.get('max_tcp_conns'), ) - connect_host = get_server_connect_host(server) + connect_host = get_server_connect_host(server, protocol) if base == 'wireguard': return manager.add_client(name, connect_host) return manager.add_client(protocol, name, connect_host, port) @@ -1663,9 +1677,9 @@ async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: Li manager = get_protocol_manager(ssh, c_req['protocol']) if c_req['protocol'] == 'wireguard': - res = await asyncio.to_thread(manager.add_client, c_req['name'], get_server_connect_host(srv)) + res = await asyncio.to_thread(manager.add_client, c_req['name'], get_server_connect_host(srv, 'wireguard')) else: - res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], get_server_connect_host(srv), port) + res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], get_server_connect_host(srv, c_req['protocol']), port) if res.get('client_id'): new_conn = { @@ -2129,6 +2143,11 @@ class EditServerRequest(BaseModel): connect_domain: Optional[str] = None +class ConnectDomainRequest(BaseModel): + connect_domain: str = '' + protocol: Optional[str] = None + + class ReorderServersRequest(BaseModel): # `order[i]` is the *old* server index now at position `i` in the new layout. order: List[int] @@ -3049,6 +3068,75 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque return JSONResponse({'error': str(e)}, status_code=500) +@app.get('/api/servers/{server_id}/connect-domain', tags=["Servers"]) +async def api_get_connect_domain(request: Request, server_id: int, protocol: Optional[str] = None): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + proto = (protocol or '').strip() + info = server.get('server_info') or {} + proto_domain = '' + if proto: + pinfo = (server.get('protocols') or {}).get(proto) or {} + proto_domain = (pinfo.get('connect_domain') or '').strip() + return { + 'connect_domain': (info.get('connect_domain') or '').strip(), + 'protocol_connect_domain': proto_domain, + 'protocol': proto, + 'effective_host': get_server_connect_host(server, proto or None), + 'ssh_host': server.get('host') or '', + } + + +@app.post('/api/servers/{server_id}/connect-domain', tags=["Servers"]) +async def api_set_connect_domain(request: Request, server_id: int, req: ConnectDomainRequest): + """Set client Endpoint hostname for AWG / WireGuard without re-verifying SSH.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + domain = (req.connect_domain or '').strip().lower().rstrip('.') + proto = (req.protocol or '').strip() + + if proto: + base = protocol_base(proto) + if base not in WG_AWG_PROTOCOLS: + return JSONResponse( + {'error': 'Per-protocol domain is only supported for AmneziaWG / WireGuard'}, + status_code=400, + ) + protocols = server.setdefault('protocols', {}) + proto_info = protocols.setdefault(proto, {}) + if domain: + proto_info['connect_domain'] = domain + else: + proto_info.pop('connect_domain', None) + else: + info = dict(server.get('server_info') or {}) + if domain: + info['connect_domain'] = domain + else: + info.pop('connect_domain', None) + server['server_info'] = info + + save_data(data) + return { + 'status': 'success', + 'connect_domain': domain, + 'protocol': proto, + 'effective_host': get_server_connect_host(server, proto or None), + } + except Exception as e: + logger.exception('Error saving connect domain') + return JSONResponse({'error': str(e)}, status_code=500) + + @app.get('/api/servers/{server_id}/ping', tags=["Servers"]) async def api_server_ping(request: Request, server_id: int): """Cheap reachability check: opens a TCP connection to the SSH port, @@ -3983,7 +4071,7 @@ async def api_protocol_export_clients(request: Request, server_id: int, req: Pro continue try: config = _manager_call( - manager, 'get_client_config', req.protocol, client_id, get_server_connect_host(server), port + manager, 'get_client_config', req.protocol, client_id, get_server_connect_host(server, req.protocol), port ) except Exception as exc: skipped.append(f'{client_name}: {exc}') @@ -4467,7 +4555,7 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio if protocol_base(req.protocol) == 'telemt': result = manager.add_client( - req.protocol, req.name, get_server_connect_host(server), port, + req.protocol, req.name, get_server_connect_host(server, req.protocol), port, telemt_quota=req.telemt_quota, telemt_max_ips=req.telemt_max_ips, telemt_expiry=req.telemt_expiry, @@ -4476,9 +4564,9 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio max_tcp_conns=req.telemt_max_conns ) elif protocol_base(req.protocol) == 'wireguard': - result = manager.add_client(req.name, get_server_connect_host(server)) + result = manager.add_client(req.name, get_server_connect_host(server, req.protocol)) else: - result = manager.add_client(req.protocol, req.name, get_server_connect_host(server), port) + result = manager.add_client(req.protocol, req.name, get_server_connect_host(server, req.protocol), port) ssh.disconnect() if result.get('config'): @@ -4601,7 +4689,7 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne ssh = get_ssh(server) ssh.connect() manager = get_protocol_manager(ssh, req.protocol) - config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, get_server_connect_host(server), port) + config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, get_server_connect_host(server, req.protocol), port) ssh.disconnect() vpn_link = generate_vpn_link(config) if config else '' return {'config': config, 'vpn_link': vpn_link} @@ -4784,7 +4872,7 @@ async def api_add_user(request: Request, req: AddUserRequest): manager = get_protocol_manager(ssh, req.protocol) if protocol_base(req.protocol) == 'telemt': conn_result = manager.add_client( - req.protocol, conn_name, get_server_connect_host(server), port, + req.protocol, conn_name, get_server_connect_host(server, req.protocol), port, telemt_quota=req.telemt_quota, telemt_max_ips=req.telemt_max_ips, telemt_expiry=req.telemt_expiry, @@ -4793,7 +4881,7 @@ async def api_add_user(request: Request, req: AddUserRequest): max_tcp_conns=req.telemt_max_conns ) else: - conn_result = manager.add_client(req.protocol, conn_name, get_server_connect_host(server), port) + conn_result = manager.add_client(req.protocol, conn_name, get_server_connect_host(server, req.protocol), port) ssh.disconnect() if conn_result.get('client_id'): @@ -5001,12 +5089,12 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo # Link existing client config = await asyncio.to_thread( _manager_call, manager, 'get_client_config', - req.protocol, req.client_id, get_server_connect_host(server), port, + req.protocol, req.client_id, get_server_connect_host(server, req.protocol), port, ) result = {'client_id': req.client_id, 'config': config} elif protocol_base(req.protocol) == 'telemt': result = await asyncio.to_thread( - manager.add_client, req.protocol, req.name, get_server_connect_host(server), port, + manager.add_client, req.protocol, req.name, get_server_connect_host(server, req.protocol), port, telemt_quota=req.telemt_quota, telemt_max_ips=req.telemt_max_ips, telemt_expiry=req.telemt_expiry, @@ -5015,11 +5103,11 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo max_tcp_conns=req.telemt_max_conns, ) elif protocol_base(req.protocol) == 'wireguard': - result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server)) + result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, req.protocol)) else: result = await asyncio.to_thread( _manager_call, manager, 'add_client', - req.protocol, req.name, get_server_connect_host(server), port, + req.protocol, req.name, get_server_connect_host(server, req.protocol), port, ) finally: await asyncio.to_thread(ssh.disconnect) @@ -5210,7 +5298,7 @@ async def api_share_config(token: str, connection_id: str, request: Request): ssh.connect() # Use appropriate manager for the protocol manager = get_protocol_manager(ssh, conn['protocol']) - config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server), port) + 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': user.get('expiration_date')} @@ -5370,7 +5458,7 @@ async def api_guest_config(token: str, connection_id: str, request: Request): 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), port) + 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')} @@ -5434,11 +5522,11 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request try: manager = get_protocol_manager(ssh, protocol) if protocol_base(protocol) == 'wireguard': - result = await asyncio.to_thread(manager.add_client, name, get_server_connect_host(server)) + result = await asyncio.to_thread(manager.add_client, name, get_server_connect_host(server, protocol)) else: result = await asyncio.to_thread( _manager_call, manager, 'add_client', - protocol, name, get_server_connect_host(server), port, + protocol, name, get_server_connect_host(server, protocol), port, ) finally: await asyncio.to_thread(ssh.disconnect) @@ -5596,11 +5684,11 @@ async def _create_config_for_protocol( try: manager = get_protocol_manager(ssh, protocol) if protocol_base(protocol) == 'wireguard': - result = await asyncio.to_thread(manager.add_client, name, get_server_connect_host(server)) + result = await asyncio.to_thread(manager.add_client, name, get_server_connect_host(server, protocol)) else: result = await asyncio.to_thread( _manager_call, manager, 'add_client', - protocol, name, get_server_connect_host(server), port, + protocol, name, get_server_connect_host(server, protocol), port, ) finally: await asyncio.to_thread(ssh.disconnect) @@ -5971,7 +6059,7 @@ async def api_my_connection_config(request: Request, connection_id: str): ssh.connect() # Use appropriate manager for the protocol (fixes Telemt/Xray not working for users) manager = get_protocol_manager(ssh, conn['protocol']) - config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server), port) + 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 '' expires_at = None diff --git a/docker-compose.yml b/docker-compose.yml index 15fb7c8..fb1a596 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,7 @@ services: labels: - traefik.enable=true - traefik.docker.network=dokploy-network + - traefik.http.services.amnezia_panel.loadbalancer.server.port=5000 expose: - "5000" healthcheck: diff --git a/templates/server.html b/templates/server.html index feb8833..a7b1387 100644 --- a/templates/server.html +++ b/templates/server.html @@ -155,6 +155,25 @@ + @@ -1132,6 +1151,7 @@ const SERVER_ID = {{ server_id }}; const ALL_SERVERS = {{ servers_for_move | tojson }}; const SERVER_HOST = "{{ server.host }}"; + const SERVER_CONNECT_DOMAIN = {{ ((server.server_info or {}).get('connect_domain') or '') | tojson }}; const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }}; const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }}; const MARKETPLACE_APPS = [ @@ -1153,6 +1173,8 @@ { id: 'services', icon: 'wrench', titleKey: 'services' }, { id: 'web_servers', icon: 'globe', titleKey: 'web_servers' }, ]; + const WG_AWG_BASES = new Set(['awg', 'awg2', 'awg_legacy', 'wireguard']); + let connectDomainEffective = {}; let currentInstallProto = 'awg'; let currentInstallAnother = false; let currentConfig = ''; @@ -1212,6 +1234,94 @@ return Object.keys(installedProtocols).some(key => protoBase(key) === base && installedProtocols[key]); } + function hasWgAwgInstalled() { + return Object.entries(installedProtocols || {}).some(([key, installed]) => installed && WG_AWG_BASES.has(protoBase(key))); + } + + function listInstalledWgAwgProtocols() { + const protos = new Set(); + Object.entries(installedProtocols || {}).forEach(([key, installed]) => { + if (installed && WG_AWG_BASES.has(protoBase(key))) protos.add(key); + }); + return Array.from(protos).sort((a, b) => { + const ao = MARKETPLACE_APPS.findIndex(app => app.proto === protoBase(a)); + const bo = MARKETPLACE_APPS.findIndex(app => app.proto === protoBase(b)); + if (ao !== bo) return ao - bo; + return protoInstance(a) - protoInstance(b); + }); + } + + function updateConnectDomainSection() { + const section = document.getElementById('connectDomainSection'); + if (!section) return; + const show = hasWgAwgInstalled(); + section.classList.toggle('hidden', !show); + if (!show) return; + const select = document.getElementById('connectDomainProto'); + if (!select) return; + const prev = select.value; + const installed = listInstalledWgAwgProtocols(); + select.innerHTML = `` + + installed.map(p => ``).join(''); + if (prev && [...select.options].some(opt => opt.value === prev)) { + select.value = prev; + } + refreshConnectDomainFields(); + } + + async function refreshConnectDomainFields() { + const proto = document.getElementById('connectDomainProto')?.value || ''; + const input = document.getElementById('connectDomainInput'); + const eff = document.getElementById('connectDomainEffective'); + try { + const query = proto ? `?protocol=${encodeURIComponent(proto)}` : ''; + const data = await apiCall(`/api/servers/${SERVER_ID}/connect-domain${query}`, 'GET'); + connectDomainEffective = data; + if (input) { + input.value = proto ? (data.protocol_connect_domain || '') : (data.connect_domain || ''); + } + if (eff) { + if (data.effective_host && data.effective_host !== data.ssh_host) { + eff.textContent = `${_('server_connect_domain_current')}: ${data.effective_host} (${_('server_connect_domain_ssh')}: ${data.ssh_host})`; + } else { + eff.textContent = `${_('server_connect_domain_current')}: ${data.ssh_host || SERVER_HOST}`; + } + } + Object.entries(currentProtocolStatus || {}).forEach(([p, info]) => { + if (WG_AWG_BASES.has(protoBase(p)) && isAppInstalled(info)) { + updateProtocolCard(p, info); + } + }); + } catch (err) { + if (input && !proto) input.value = SERVER_CONNECT_DOMAIN || ''; + if (eff) eff.textContent = ''; + } + } + + async function saveConnectDomain() { + const btn = document.getElementById('connectDomainSaveBtn'); + const proto = document.getElementById('connectDomainProto')?.value || ''; + const domain = document.getElementById('connectDomainInput')?.value.trim() || ''; + if (btn) btn.disabled = true; + try { + await apiCall(`/api/servers/${SERVER_ID}/connect-domain`, 'POST', { + connect_domain: domain, + protocol: proto || null, + }); + showToast(_('server_connect_domain_saved'), 'success'); + await refreshConnectDomainFields(); + Object.entries(currentProtocolStatus || {}).forEach(([p, info]) => { + if (WG_AWG_BASES.has(protoBase(p)) && isAppInstalled(info)) { + updateProtocolCard(p, info); + } + }); + } catch (err) { + showToast(err.message, 'error'); + } finally { + if (btn) btn.disabled = false; + } + } + function switchCategory(cat) { if (cat === 'management') { openManagementModal(); @@ -1370,6 +1480,7 @@ if (empty) empty.classList.toggle('hidden', installedAppsLoading || installedCount > 0); const loading = document.getElementById('installedAppsLoading'); if (loading) loading.classList.toggle('hidden', !installedAppsLoading); + updateConnectDomainSection(); } function getProtoTitle(proto) { @@ -1672,6 +1783,12 @@ grid = buildServiceInfoGrid(proto, info); } else if (info.port) { grid += `
${_('port')}${info.port}/${(['xray', 'telemt', 'naiveproxy'].includes(protoBase(proto))) ? 'TCP' : 'UDP'}
`; + if (WG_AWG_BASES.has(protoBase(proto))) { + const endpoint = (connectDomainEffective.effective_host && connectDomainEffective.effective_host !== SERVER_HOST) + ? connectDomainEffective.effective_host + : SERVER_HOST; + grid += `
${_('server_endpoint_label')}${endpoint}
`; + } if (protoBase(proto) === 'hysteria' && info.domain) { grid += `
${_('hysteria_domain')}${info.domain}
`; } diff --git a/translations/en.json b/translations/en.json index 0296729..c70935d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -399,6 +399,12 @@ "server_ssl_hint": "Used when installing Hysteria / NGINX on this server (Let's Encrypt).", "server_connect_domain": "Client connection domain", "server_connect_domain_hint": "Used in VPN configs instead of IP (Endpoint, vless://, etc.). When moving the server, update the DNS A-record — clients keep working.", + "server_connect_domain_awg_hint": "Used in AmneziaWG / WireGuard Endpoint instead of server IP. Leave empty to use SSH host IP.", + "server_connect_domain_all": "All AWG / WireGuard (default)", + "server_connect_domain_current": "Endpoint in configs", + "server_connect_domain_ssh": "SSH", + "server_connect_domain_saved": "Connection domain saved", + "server_endpoint_label": "Endpoint", "container_logs": "Container logs", "logs_btn": "📋 Logs", "logs_live": "Live", diff --git a/translations/ru.json b/translations/ru.json index f9844af..87399f1 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -399,6 +399,12 @@ "server_ssl_hint": "Подставляется при установке Hysteria / NGINX на этом сервере (Let's Encrypt).", "server_connect_domain": "Домен для подключения клиентов", "server_connect_domain_hint": "Подставляется в конфиги VPN вместо IP (Endpoint, vless:// и т.д.). При переносе сервера достаточно обновить A-запись DNS — клиенты продолжат работать.", + "server_connect_domain_awg_hint": "Подставляется в Endpoint конфигов AmneziaWG / WireGuard вместо IP сервера. Пусто — использовать IP из SSH.", + "server_connect_domain_all": "Все AWG / WireGuard (по умолчанию)", + "server_connect_domain_current": "Endpoint в конфигах", + "server_connect_domain_ssh": "SSH", + "server_connect_domain_saved": "Домен подключения сохранён", + "server_endpoint_label": "Endpoint", "container_logs": "Логи контейнера", "logs_btn": "📋 Логи", "logs_live": "В реальном времени",