Merge branch 'main' into feat/email-config-delivery

This commit is contained in:
Public relations & Virtual technology authored and GitHub committed 2026-09-28 16:24:47 +03:00
commit 25e66b4b50
15 files changed
+872 -62

No files matched your search

+6
View File
@@ -75,6 +75,7 @@ Configuration panel for system parameters and preferences:
* **Add / Edit / Delete / Reorder** server entries — drag-and-drop reorder updates `server_id` references in saved connections automatically. * **Add / Edit / Delete / Reorder** server entries — drag-and-drop reorder updates `server_id` references in saved connections automatically.
* Every server carries a stable `uid` (assigned on add and backfilled for existing records at startup) for cross-server references that must survive reorder and delete. * Every server carries a stable `uid` (assigned on add and backfilled for existing records at startup) for cross-server references that must survive reorder and delete.
* **Live ping indicator** next to each server name — non-blocking TCP-connect probe to the SSH port, runs on the asyncio loop in parallel for all servers. * **Live ping indicator** next to each server name — non-blocking TCP-connect probe to the SSH port, runs on the asyncio loop in parallel for all servers.
* **Public address per protocol instance**: by default a client dials the same address the panel opens SSH to. When an instance answers somewhere else — a second IP on the box, a port forward, a domain name — set its own **public address** (🌐 on the instance card) and every config, `vless://` and `tg://` link the panel issues points there instead. Telemt prints its own links, so the address is written into its `config.toml` and applied without a restart. The setting belongs to the instance, not to the install: reinstalling the protocol keeps it, and re-issuing a config for an existing client yields the new address with the same keys.
* **Clear server** wipes every Amnezia-related container, image and `/opt/amnezia` directory in a single sudo script — works for any current or future `amnezia-*` protocol. * **Clear server** wipes every Amnezia-related container, image and `/opt/amnezia` directory in a single sudo script — works for any current or future `amnezia-*` protocol.
* **Reboot** the server directly from the UI. * **Reboot** the server directly from the UI.
* Strictly concurrent protocol status polling — all supported protocols/services checked in parallel for immediate feedback. * Strictly concurrent protocol status polling — all supported protocols/services checked in parallel for immediate feedback.
@@ -292,6 +293,11 @@ curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/jso
# Cheap reachability probe for monitoring # Cheap reachability probe for monitoring
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers/0/ping curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers/0/ping
# Publish an instance on another address (empty fields reset it to the server's own)
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"protocol":"telemt","public_host":"203.0.113.9","public_port":"443"}' \
http://your-panel:5000/api/servers/0/protocol/public-endpoint
``` ```
### Technology Stack ### Technology Stack
+172 -14
View File
@@ -1186,6 +1186,50 @@ def _manager_call(manager, method, protocol, *args, **kwargs):
AWG_PROTOCOLS = ('awg', 'awg2', 'awg3', 'awg_legacy') AWG_PROTOCOLS = ('awg', 'awg2', 'awg3', 'awg_legacy')
# A public host is an IP (v4, or v6 in the brackets an endpoint needs to stay
# readable next to the port) or a DNS name. Anything else -- a scheme, a path,
# a space, a bare IPv6 -- would be copied verbatim into client configs, where
# it reads as a valid endpoint and simply never connects.
PUBLIC_HOST_MAX = 253
PUBLIC_HOST_RE = re.compile(r'^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9][A-Za-z0-9._-]*)$')
def normalize_public_host(value):
"""Validated public host, or '' when the field is left empty."""
host = str(value or '').strip()
if not host:
return ''
if len(host) > PUBLIC_HOST_MAX or not PUBLIC_HOST_RE.match(host):
raise ValueError('Public host must be an IP address or a host name')
return host
def normalize_public_port(value):
"""Validated public port as a string, or '' when the field is left empty."""
port = str(value or '').strip()
if not port:
return ''
if not port.isdigit() or not 1 <= int(port) <= 65535:
raise ValueError('Public port must be a number between 1 and 65535')
return port
def protocol_public_endpoint(server, protocol):
"""The address clients must dial for one protocol instance: (host, port).
Both default to what the panel itself uses -- the server address it opens
SSH to, and the instance's own listen port (the returned port is None then,
so managers keep using it). An instance can live somewhere else: a second
IP on the same box, a port forward, a domain name. `public_host` and
`public_port` on the instance record carry that, and every config and link
handed to a client follows them.
"""
info = (server.get('protocols') or {}).get(protocol) or {}
host = str(info.get('public_host') or '').strip() or server.get('host', '')
public_port = str(info.get('public_port') or '').strip() or None
return host, public_port
def join_dns(dns1, dns2): def join_dns(dns1, dns2):
"""Join the two DNS fields into the `a, b` form used in configs.""" """Join the two DNS fields into the `a, b` form used in configs."""
parts = [str(value).strip() for value in (dns1, dns2) if value and str(value).strip()] parts = [str(value).strip() for value in (dns1, dns2) if value and str(value).strip()]
@@ -1567,6 +1611,7 @@ self_service_connections = ConnectionService(
get_protocol_manager=get_protocol_manager, get_protocol_manager=get_protocol_manager,
manager_call=_manager_call, manager_call=_manager_call,
generate_vpn_link=generate_vpn_link, generate_vpn_link=generate_vpn_link,
protocol_public_endpoint=protocol_public_endpoint,
) )
@@ -1805,12 +1850,13 @@ async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: Li
for c_req in ops['create']: for c_req in ops['create']:
proto_info = srv.get('protocols', {}).get(c_req['protocol'], {}) proto_info = srv.get('protocols', {}).get(c_req['protocol'], {})
port = proto_info.get('port', '55424') port = proto_info.get('port', '55424')
pub_host, pub_port = protocol_public_endpoint(srv, c_req['protocol'])
manager = get_protocol_manager(ssh, c_req['protocol']) manager = get_protocol_manager(ssh, c_req['protocol'])
if c_req['protocol'] == 'wireguard': if c_req['protocol'] == 'wireguard':
res = await asyncio.to_thread(manager.add_client, c_req['name'], srv['host']) res = await asyncio.to_thread(manager.add_client, c_req['name'], pub_host, public_port=pub_port)
else: else:
res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], srv['host'], port) res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], pub_host, port, public_port=pub_port)
if res.get('client_id'): if res.get('client_id'):
new_conn = { new_conn = {
@@ -2198,6 +2244,12 @@ class RenameProtocolRequest(BaseModel):
name: str = '' # empty = reset to default name: str = '' # empty = reset to default
class PublicEndpointRequest(BaseModel):
protocol: str = ''
public_host: str = '' # empty = the server's own address
public_port: str = '' # empty = the instance's listen port
class AddConnectionRequest(BaseModel): class AddConnectionRequest(BaseModel):
protocol: str = 'awg' protocol: str = 'awg'
name: str = 'Connection' name: str = 'Connection'
@@ -3549,6 +3601,13 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
install_base = protocol_base(install_protocol) install_base = protocol_base(install_protocol)
# A reinstalled entry keeps its exit link and is re-linked below # A reinstalled entry keeps its exit link and is re-linked below
previous_link = None previous_link = None
# Where an instance is published is a property of the network, not
# of this install: reinstalling must not move clients back to the
# address the panel happens to manage the box through.
previous_record = (server.get('protocols') or {}).get(install_protocol) or {}
previous_public = {key: previous_record[key]
for key in ('public_host', 'public_port')
if previous_record.get(key)}
# Reinstalling an instance is not the same as adding one: an instance a # Reinstalling an instance is not the same as adding one: an instance a
# user deliberately left unlinked must not be linked behind their back. # user deliberately left unlinked must not be linked behind their back.
reinstall = install_protocol in (server.get('protocols') or {}) reinstall = install_protocol in (server.get('protocols') or {})
@@ -3590,7 +3649,9 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
port=req.port, port=req.port,
tls_emulation=req.tls_emulation if req.tls_emulation is not None else True, tls_emulation=req.tls_emulation if req.tls_emulation is not None else True,
tls_domain=req.tls_domain, tls_domain=req.tls_domain,
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,
public_host=previous_public.get('public_host'),
public_port=previous_public.get('public_port'),
) )
elif install_base == 'xray': elif install_base == 'xray':
install_args = () install_args = ()
@@ -3689,6 +3750,7 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
proto_record['container_name'] = protocol_container_name(install_protocol) proto_record['container_name'] = protocol_container_name(install_protocol)
if previous_link: if previous_link:
proto_record['exit_link'] = previous_link proto_record['exit_link'] = previous_link
proto_record.update(previous_public)
server['protocols'][install_protocol] = proto_record server['protocols'][install_protocol] = proto_record
result['protocol'] = install_protocol result['protocol'] = install_protocol
result['base_protocol'] = install_base result['base_protocol'] = install_base
@@ -4447,6 +4509,35 @@ async def api_ssh_cooldown(request: Request, server_id: int):
return {'ok': True, 'ssh_cooldown_base': seconds} return {'ok': True, 'ssh_cooldown_base': seconds}
# Allowed live peer-list refresh intervals (seconds); 0 disables polling.
PEER_POLL_INTERVALS = (0, 5, 10, 15, 20, 45, 120, 300, 600)
@app.post('/api/servers/{server_id}/peer_poll_interval', tags=["Servers"])
async def api_peer_poll_interval(request: Request, server_id: int):
"""Set the per-server live peer-list refresh interval (0 = off).
Polling is opt-in: every poll is a full connections read over SSH, which
is cheap on fast servers but painful on slow/flaky ones."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
body = await request.json()
seconds = int(body.get('seconds', 0))
except Exception:
return JSONResponse({'error': 'Invalid value'}, status_code=400)
if seconds not in PEER_POLL_INTERVALS:
return JSONResponse(
{'error': 'Value must be one of ' + ','.join(map(str, PEER_POLL_INTERVALS))},
status_code=400)
data = load_data()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
data['servers'][server_id]['peer_poll_interval'] = seconds
save_data(data)
return {'ok': True, 'peer_poll_interval': seconds}
@app.post('/api/servers/{server_id}/host_tuning', tags=["Protocols"]) @app.post('/api/servers/{server_id}/host_tuning', tags=["Protocols"])
def api_host_tuning(request: Request, server_id: int): def api_host_tuning(request: Request, server_id: int):
"""Server-level network tuning summary (host sysctls + AWG containers).""" """Server-level network tuning summary (host sysctls + AWG containers)."""
@@ -4540,6 +4631,67 @@ def api_rename_protocol(request: Request, server_id: int, req: RenameProtocolReq
return JSONResponse({'error': str(e)}, status_code=500) return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/protocol/public-endpoint', tags=["Protocols"])
async def api_set_protocol_public_endpoint(request: Request, server_id: int, req: PublicEndpointRequest):
"""Set or clear the address clients dial for one protocol instance.
Both fields are optional and empty means "as before": the server's own
address, the instance's listen port. Useful when an instance answers on a
second IP of the same box, behind a port forward, or under a domain name.
"""
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]
proto = req.protocol.strip()
if proto not in server.get('protocols', {}):
return JSONResponse({'error': 'Protocol not found'}, status_code=404)
try:
host = normalize_public_host(req.public_host)
port = normalize_public_port(req.public_port)
except ValueError as e:
return JSONResponse({'error': str(e)}, status_code=400)
record = server['protocols'][proto]
for key, value in (('public_host', host), ('public_port', port)):
if value:
record[key] = value
else:
record.pop(key, None)
save_data(data)
# Telemt composes its own tg:// links from config.toml, so for that
# protocol the override reaches clients only after the file on the
# server carries it. The record is saved either way: a box that is down
# right now must not lose the setting, and the next install re-applies
# it.
warning = ''
if protocol_base(proto) == 'telemt':
ssh = None
try:
pub_host, pub_port = protocol_public_endpoint(server, proto)
ssh = await asyncio.to_thread(get_ssh, server)
await asyncio.to_thread(ssh.connect)
manager = get_protocol_manager(ssh, proto)
await asyncio.to_thread(
manager.set_public_endpoint, pub_host, pub_port or record.get('port', '443'))
except Exception as e:
logger.warning(f"Could not apply the public endpoint to {proto}: {e}")
warning = str(e)
finally:
if ssh is not None:
ssh.disconnect()
return {'status': 'success', 'protocol': proto,
'public_host': host, 'public_port': port, 'warning': warning}
except Exception as e:
logger.exception("Error setting protocol public endpoint")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/wgeasy/import', tags=["Protocols"]) @app.post('/api/servers/{server_id}/wgeasy/import', tags=["Protocols"])
def api_wgeasy_import(request: Request, server_id: int, req: WgEasyImportRequest): def api_wgeasy_import(request: Request, server_id: int, req: WgEasyImportRequest):
"""Migrate clients from a wg-easy panel on this server into a panel-managed """Migrate clients from a wg-easy panel on this server into a panel-managed
@@ -4740,13 +4892,14 @@ def api_add_connection(request: Request, server_id: int, req: AddConnectionReque
server = data['servers'][server_id] server = data['servers'][server_id]
proto_info = server.get('protocols', {}).get(req.protocol, {}) proto_info = server.get('protocols', {}).get(req.protocol, {})
port = proto_info.get('port', '55424') port = proto_info.get('port', '55424')
pub_host, pub_port = protocol_public_endpoint(server, req.protocol)
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() ssh.connect()
manager = get_protocol_manager(ssh, req.protocol) manager = get_protocol_manager(ssh, req.protocol)
if protocol_base(req.protocol) == 'telemt': if protocol_base(req.protocol) == 'telemt':
result = manager.add_client( result = manager.add_client(
req.protocol, req.name, server['host'], port, req.protocol, req.name, pub_host, pub_port or 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,
@@ -4755,9 +4908,9 @@ def api_add_connection(request: Request, server_id: int, req: AddConnectionReque
max_tcp_conns=req.telemt_max_conns max_tcp_conns=req.telemt_max_conns
) )
elif protocol_base(req.protocol) == 'wireguard': elif protocol_base(req.protocol) == 'wireguard':
result = manager.add_client(req.name, server['host']) result = manager.add_client(req.name, pub_host, public_port=pub_port)
else: else:
result = manager.add_client(req.protocol, req.name, server['host'], port) result = manager.add_client(req.protocol, req.name, pub_host, port, public_port=pub_port)
ssh.disconnect() ssh.disconnect()
if result.get('config'): if result.get('config'):
@@ -4957,10 +5110,11 @@ def api_get_connection_config(request: Request, server_id: int, req: ConnectionA
return JSONResponse({'error': 'Client ID is required'}, status_code=400) return JSONResponse({'error': 'Client ID is required'}, status_code=400)
proto_info = server.get('protocols', {}).get(req.protocol, {}) proto_info = server.get('protocols', {}).get(req.protocol, {})
port = proto_info.get('port', '55424') port = proto_info.get('port', '55424')
pub_host, pub_port = protocol_public_endpoint(server, req.protocol)
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() ssh.connect()
manager = get_protocol_manager(ssh, req.protocol) manager = get_protocol_manager(ssh, req.protocol)
config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, server['host'], port) config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, pub_host, port, public_port=pub_port)
ssh.disconnect() ssh.disconnect()
return {'config': config, **config_payloads(config, server, req.protocol)} return {'config': config, **config_payloads(config, server, req.protocol)}
except Exception as e: except Exception as e:
@@ -5099,13 +5253,14 @@ def api_add_user(request: Request, req: AddUserRequest):
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(req.protocol, {})
port = proto_info.get('port', '55424') port = proto_info.get('port', '55424')
pub_host, pub_port = protocol_public_endpoint(server, req.protocol)
conn_name = req.connection_name or f"{req.username}_vpn" conn_name = req.connection_name or f"{req.username}_vpn"
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() ssh.connect()
manager = get_protocol_manager(ssh, req.protocol) manager = get_protocol_manager(ssh, req.protocol)
if protocol_base(req.protocol) == 'telemt': if protocol_base(req.protocol) == 'telemt':
conn_result = manager.add_client( conn_result = manager.add_client(
req.protocol, conn_name, server['host'], port, req.protocol, conn_name, pub_host, pub_port or 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,
@@ -5114,7 +5269,7 @@ def api_add_user(request: Request, req: AddUserRequest):
max_tcp_conns=req.telemt_max_conns max_tcp_conns=req.telemt_max_conns
) )
else: else:
conn_result = manager.add_client(req.protocol, conn_name, server['host'], port) conn_result = manager.add_client(req.protocol, conn_name, pub_host, port, public_port=pub_port)
ssh.disconnect() ssh.disconnect()
if conn_result.get('client_id'): if conn_result.get('client_id'):
@@ -5287,6 +5442,7 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
return JSONResponse({'error': _t('peer_already_linked', lang).replace('{}', owner_name)}, status_code=400) return JSONResponse({'error': _t('peer_already_linked', lang).replace('{}', owner_name)}, status_code=400)
proto_info = server.get('protocols', {}).get(req.protocol, {}) proto_info = server.get('protocols', {}).get(req.protocol, {})
port = proto_info.get('port', '55424') port = proto_info.get('port', '55424')
pub_host, pub_port = protocol_public_endpoint(server, req.protocol)
ssh = await asyncio.to_thread(get_ssh, server) ssh = await asyncio.to_thread(get_ssh, server)
await asyncio.to_thread(ssh.connect) await asyncio.to_thread(ssh.connect)
manager = get_protocol_manager(ssh, req.protocol) manager = get_protocol_manager(ssh, req.protocol)
@@ -5295,13 +5451,13 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
# Use existing client # Use existing client
target_client_id = req.client_id target_client_id = req.client_id
# Retrieve config for existing client # Retrieve config for existing client
config = await asyncio.to_thread(_manager_call, manager, 'get_client_config', req.protocol, req.client_id, server['host'], port) config = await asyncio.to_thread(_manager_call, manager, 'get_client_config', req.protocol, req.client_id, pub_host, port, public_port=pub_port)
result = {'client_id': target_client_id, 'config': config} result = {'client_id': target_client_id, 'config': config}
else: else:
# Create new client # Create new client
if protocol_base(req.protocol) == 'telemt': if protocol_base(req.protocol) == 'telemt':
result = await asyncio.to_thread( result = await asyncio.to_thread(
manager.add_client, req.protocol, req.name, server['host'], port, manager.add_client, req.protocol, req.name, pub_host, pub_port or 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,
@@ -5310,7 +5466,7 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
max_tcp_conns=req.telemt_max_conns max_tcp_conns=req.telemt_max_conns
) )
else: else:
result = await asyncio.to_thread(manager.add_client, req.protocol, req.name, server['host'], port) result = await asyncio.to_thread(manager.add_client, req.protocol, req.name, pub_host, port, public_port=pub_port)
await asyncio.to_thread(ssh.disconnect) await asyncio.to_thread(ssh.disconnect)
@@ -5574,11 +5730,12 @@ def api_share_config(token: str, connection_id: str, request: Request):
server = data['servers'][sid] server = data['servers'][sid]
proto_info = server.get('protocols', {}).get(conn['protocol'], {}) proto_info = server.get('protocols', {}).get(conn['protocol'], {})
port = proto_info.get('port', '55424') port = proto_info.get('port', '55424')
pub_host, pub_port = protocol_public_endpoint(server, conn['protocol'])
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() ssh.connect()
# Use appropriate manager for the protocol # Use appropriate manager for the protocol
manager = get_protocol_manager(ssh, conn['protocol']) manager = get_protocol_manager(ssh, conn['protocol'])
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], pub_host, port, public_port=pub_port)
ssh.disconnect() ssh.disconnect()
return {'config': config, **config_payloads(config, server, conn['protocol'])} return {'config': config, **config_payloads(config, server, conn['protocol'])}
except Exception as e: except Exception as e:
@@ -5605,11 +5762,12 @@ def api_my_connection_config(request: Request, connection_id: str):
server = data['servers'][sid] server = data['servers'][sid]
proto_info = server.get('protocols', {}).get(conn['protocol'], {}) proto_info = server.get('protocols', {}).get(conn['protocol'], {})
port = proto_info.get('port', '55424') port = proto_info.get('port', '55424')
pub_host, pub_port = protocol_public_endpoint(server, conn['protocol'])
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() ssh.connect()
# Use appropriate manager for the protocol (fixes Telemt/Xray not working for users) # Use appropriate manager for the protocol (fixes Telemt/Xray not working for users)
manager = get_protocol_manager(ssh, conn['protocol']) manager = get_protocol_manager(ssh, conn['protocol'])
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], pub_host, port, public_port=pub_port)
ssh.disconnect() ssh.disconnect()
return {'config': config, **config_payloads(config, server, conn['protocol'])} return {'config': config, **config_payloads(config, server, conn['protocol'])}
except Exception as e: except Exception as e:
+8 -1
View File
@@ -111,6 +111,7 @@ class ConnectionService:
get_protocol_manager, get_protocol_manager,
manager_call, manager_call,
generate_vpn_link, generate_vpn_link,
protocol_public_endpoint=None,
): ):
self.load_data = load_data self.load_data = load_data
self.save_data = save_data self.save_data = save_data
@@ -119,6 +120,10 @@ class ConnectionService:
self.get_protocol_manager = get_protocol_manager self.get_protocol_manager = get_protocol_manager
self.manager_call = manager_call self.manager_call = manager_call
self.generate_vpn_link = generate_vpn_link self.generate_vpn_link = generate_vpn_link
# Optional: without it a connection is issued for the server's own
# address and the instance's listen port, as it always was.
self.protocol_public_endpoint = protocol_public_endpoint or (
lambda server, protocol: (server.get('host', ''), None))
self._provision_locks = defaultdict(asyncio.Lock) self._provision_locks = defaultdict(asyncio.Lock)
self._rate_events = defaultdict(list) self._rate_events = defaultdict(list)
@@ -165,6 +170,7 @@ class ConnectionService:
self._validate_create_request(data, settings, user_id, server_id, protocol, clean_name, source) self._validate_create_request(data, settings, user_id, server_id, protocol, clean_name, source)
server = dict(data['servers'][server_id]) server = dict(data['servers'][server_id])
port = server.get('protocols', {}).get(protocol, {}).get('port', '55424') port = server.get('protocols', {}).get(protocol, {}).get('port', '55424')
pub_host, pub_port = self.protocol_public_endpoint(server, protocol)
# get_ssh() blocks on ensure_connected — keep it off the event loop. # get_ssh() blocks on ensure_connected — keep it off the event loop.
ssh = await asyncio.to_thread(self.get_ssh, server) ssh = await asyncio.to_thread(self.get_ssh, server)
@@ -179,8 +185,9 @@ class ConnectionService:
'add_client', 'add_client',
protocol, protocol,
clean_name, clean_name,
server.get('host', ''), pub_host,
port, port,
public_port=pub_port,
) )
remote_client_id = result.get('client_id') remote_client_id = result.get('client_id')
if not remote_client_id: if not remote_client_id:
+45 -10
View File
@@ -1084,12 +1084,39 @@ done
self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}") self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}")
self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile") self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile")
out, err, code = self.ssh.run_sudo_command( # Run the build detached and poll for its exit code. On flaky links
f"docker build --no-cache --pull -t {container_name} {dockerfile_folder}", # the SSH channel dies seconds into the build while the docker daemon
timeout=300 # keeps building - a synchronous wait then reports a false failure
# for a build that actually succeeded. Detached, the build is immune
# to channel/transport drops; each poll is a fresh short channel.
build_log = f"/tmp/docker-build-{container_name}.log"
code_file = f"{build_log}.code"
self.ssh.run_sudo_command(f"rm -f {build_log} {code_file}")
self.ssh.run_sudo_command(
f"nohup sh -c 'docker build --no-cache --pull -t {container_name} "
f"{dockerfile_folder} > {build_log} 2>&1; echo $? > {code_file}' "
f">/dev/null 2>&1 &",
timeout=30
) )
if code != 0: build_code = None
raise RuntimeError(f"Failed to build container: {err}") deadline = time.time() + 900
while time.time() < deadline:
time.sleep(5)
out, err, code = self.ssh.run_sudo_command(
f"cat {code_file} 2>/dev/null", timeout=30)
if code == 0 and (out or '').strip().isdigit():
build_code = int(out.strip())
break
if build_code is None:
raise RuntimeError(
f"Build did not finish within 900s; "
f"full log on the server: {build_log}")
if build_code != 0:
out, err, _ = self.ssh.run_sudo_command(
f"tail -c 6000 {build_log}", timeout=30)
detail = (out or '').strip() or (
f"no output; full log on the server: {build_log}")
raise RuntimeError(f"Failed to build container: {detail}")
results.append("Docker image built successfully") results.append("Docker image built successfully")
# Step 5: Run container # Step 5: Run container
@@ -2577,10 +2604,14 @@ done < "$BW"
return result return result
def add_client(self, protocol_type, client_name, server_host, port): def add_client(self, protocol_type, client_name, server_host, port, public_port=None):
""" """
Add a new client/peer to the AWG config. Add a new client/peer to the AWG config.
Returns the client config as a string for the .conf file. Returns the client config as a string for the .conf file.
`public_port` is the port clients dial when it differs from the
listen port (instance published on another address, port forward);
the interface keeps listening on `port`.
""" """
container_name = self._container_name(protocol_type) container_name = self._container_name(protocol_type)
wg_bin = self._wg_binary(protocol_type) wg_bin = self._wg_binary(protocol_type)
@@ -2698,7 +2729,7 @@ AllowedIPs = {allowed_ips}
PublicKey = {server_pub_key} PublicKey = {server_pub_key}
PresharedKey = {psk} PresharedKey = {psk}
AllowedIPs = {peer_allowed_ips} AllowedIPs = {peer_allowed_ips}
Endpoint = {server_host}:{port} Endpoint = {server_host}:{public_port or port}
PersistentKeepalive = 25 PersistentKeepalive = 25
""" """
@@ -2709,8 +2740,12 @@ PersistentKeepalive = 25
'config': client_config, 'config': client_config,
} }
def get_client_config(self, protocol_type, client_id, server_host, port): def get_client_config(self, protocol_type, client_id, server_host, port, public_port=None):
"""Reconstruct client config from stored data.""" """Reconstruct client config from stored data.
`public_port` overrides the Endpoint port the same way it does in
add_client, so a reissued config matches the one handed out first.
"""
clients_table = self._get_clients_table(protocol_type) clients_table = self._get_clients_table(protocol_type)
client = None client = None
for c in clients_table: for c in clients_table:
@@ -2795,7 +2830,7 @@ PersistentKeepalive = 25
PublicKey = {server_pub_key} PublicKey = {server_pub_key}
PresharedKey = {psk} PresharedKey = {psk}
AllowedIPs = {peer_allowed_ips} AllowedIPs = {peer_allowed_ips}
Endpoint = {server_host}:{port} Endpoint = {server_host}:{public_port or port}
PersistentKeepalive = 25 PersistentKeepalive = 25
""" """
return config return config
+42 -9
View File
@@ -160,7 +160,8 @@ docker compose version
if code != 0: if code != 0:
raise RuntimeError(f"Failed to install docker compose plugin: {err or out}") raise RuntimeError(f"Failed to install docker compose plugin: {err or out}")
def install_protocol(self, protocol_type='telemt', port='443', tls_emulation=True, tls_domain="", max_connections=0): def install_protocol(self, protocol_type='telemt', port='443', tls_emulation=True, tls_domain="",
max_connections=0, public_host=None, public_port=None):
results = [] results = []
if not self.check_docker_installed(): if not self.check_docker_installed():
results.append("Installing Docker...") results.append("Installing Docker...")
@@ -192,12 +193,8 @@ docker compose version
config_content = re.sub(r'max_connections\s*=\s*\d+', f'max_connections = {max_connections}', config_content) config_content = re.sub(r'max_connections\s*=\s*\d+', f'max_connections = {max_connections}', config_content)
# Patch public_host and public_port for links # Patch public_host and public_port for links
if "public_host =" in config_content or "# public_host =" in config_content: config_content = self._patch_public_links(
config_content = re.sub(r'#?\s*public_host\s*=\s*".*?"', f'public_host = "{self.ssh.host}"', config_content) config_content, public_host or self.ssh.host, public_port or port)
else:
config_content = config_content.replace('[general.links]', f'[general.links]\npublic_host = "{self.ssh.host}"')
config_content = re.sub(r'public_port\s*=\s*\d+', f'public_port = {port}', config_content)
# Remove default hello user # Remove default hello user
config_content = re.sub(r'^hello\s*=\s*".*?"', '', config_content, flags=re.MULTILINE) config_content = re.sub(r'^hello\s*=\s*".*?"', '', config_content, flags=re.MULTILINE)
@@ -234,6 +231,42 @@ docker compose version
"log": results "log": results
} }
def _patch_public_links(self, config_content, host, port):
"""Point [general.links] at the address clients must dial.
Telemt composes its own tg:// links from these two keys, so an address
the panel knows about (a second IP on the box, a domain) reaches
clients only once it lands here. The keys are matched line by line: a
pattern allowed to span lines swallows the preceding newline and welds
two config lines together.
"""
host_re = re.compile(r'^[ \t]*#?[ \t]*public_host[ \t]*=[ \t]*".*?"', re.MULTILINE)
port_re = re.compile(r'^[ \t]*#?[ \t]*public_port[ \t]*=[ \t]*\d+', re.MULTILINE)
if host_re.search(config_content):
config_content = host_re.sub(f'public_host = "{host}"', config_content, count=1)
else:
config_content = config_content.replace(
'[general.links]', f'[general.links]\npublic_host = "{host}"', 1)
if port_re.search(config_content):
config_content = port_re.sub(f'public_port = {port}', config_content, count=1)
else:
config_content = config_content.replace(
'[general.links]', f'[general.links]\npublic_port = {port}', 1)
return config_content
def set_public_endpoint(self, host, port):
"""Re-point the links of a running instance, without reinstalling it.
Returns False when there is no config on the server to patch.
"""
config_text = self._get_server_config()
if not config_text.strip():
return False
patched = self._patch_public_links(config_text, host, port)
if patched != config_text:
self.save_server_config(self.protocol, patched)
return True
def _get_server_config(self): def _get_server_config(self):
out, _, code = self.ssh.run_sudo_command(f"cat {self._config_path()}") out, _, code = self.ssh.run_sudo_command(f"cat {self._config_path()}")
if code != 0: return "" if code != 0: return ""
@@ -601,7 +634,7 @@ docker compose version
self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}") self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}")
return {'status': 'success', 'name': new_username, 'client_id': new_username} return {'status': 'success', 'name': new_username, 'client_id': new_username}
def get_client_config(self, protocol_type, client_id, host='', port=''): def get_client_config(self, protocol_type, client_id, host='', port='', public_port=None):
resp = self._api_request("GET", f"/v1/users/{client_id}") resp = self._api_request("GET", f"/v1/users/{client_id}")
if resp and resp.get('ok'): if resp and resp.get('ok'):
user = resp.get('data', {}) user = resp.get('data', {})
@@ -614,5 +647,5 @@ docker compose version
c = next((c for c in clients if c['clientId'] == client_id), None) c = next((c for c in clients if c['clientId'] == client_id), None)
if c: if c:
secret = c.get('userData', {}).get('token', '') secret = c.get('userData', {}).get('token', '')
if secret: return f"tg://proxy?server={host}&port={port}&secret={secret}" if secret: return f"tg://proxy?server={host}&port={public_port or port}&secret={secret}"
return "Not found" return "Not found"
+9 -5
View File
@@ -783,10 +783,13 @@ tail -f /dev/null
return clients_table return clients_table
def add_client(self, client_name, server_host): def add_client(self, client_name, server_host, public_port=None):
""" """
Add a new client/peer to the WireGuard config. Add a new client/peer to the WireGuard config.
Returns the client config string. Returns the client config string.
`public_port` is the port clients dial when it differs from the
listen port read from the server config.
""" """
# Generate client keys # Generate client keys
client_priv_key, client_pub_key = generate_wg_keypair() client_priv_key, client_pub_key = generate_wg_keypair()
@@ -845,7 +848,7 @@ MTU = {mtu}
PublicKey = {server_pub_key} PublicKey = {server_pub_key}
PresharedKey = {psk} PresharedKey = {psk}
AllowedIPs = 0.0.0.0/0, ::/0 AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = {server_host}:{port} Endpoint = {server_host}:{public_port or port}
PersistentKeepalive = 25 PersistentKeepalive = 25
""" """
return { return {
@@ -855,12 +858,13 @@ PersistentKeepalive = 25
'config': client_config, 'config': client_config,
} }
def get_client_config(self, client_id, server_host, port=None): def get_client_config(self, client_id, server_host, port=None, public_port=None):
"""Reconstruct client config from stored data. """Reconstruct client config from stored data.
`port` is optional: when omitted (direct calls) the listen port is `port` is optional: when omitted (direct calls) the listen port is
read from the server config; when passed by the panel (which already read from the server config; when passed by the panel (which already
knows the instance port) it is used for the Endpoint. knows the instance port) it is used for the Endpoint. `public_port`
wins over both: it is the port clients must dial.
""" """
clients_table = self._get_clients_table() clients_table = self._get_clients_table()
client = next((c for c in clients_table if c.get('clientId') == client_id), None) client = next((c for c in clients_table if c.get('clientId') == client_id), None)
@@ -897,7 +901,7 @@ MTU = {mtu}
PublicKey = {server_pub_key} PublicKey = {server_pub_key}
PresharedKey = {psk} PresharedKey = {psk}
AllowedIPs = 0.0.0.0/0, ::/0 AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = {server_host}:{port} Endpoint = {server_host}:{public_port or port}
PersistentKeepalive = 25 PersistentKeepalive = 25
""" """
return config return config
+6 -4
View File
@@ -678,7 +678,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
return clients_table return clients_table
def get_client_config(self, protocol, client_id, server_host, port): def get_client_config(self, protocol, client_id, server_host, port, public_port=None):
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
@@ -701,14 +701,16 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
encoded_name = urllib.parse.quote(name) encoded_name = urllib.parse.quote(name)
url = ( url = (
f"vless://{client_id}@{server_host}:{meta.get('port', port)}" # meta.json is synced from the live server.json, so it beats the
# port the panel remembers; an explicit public port beats both.
f"vless://{client_id}@{server_host}:{public_port or meta.get('port', port)}"
f"?type=tcp&security=reality&pbk={meta['public_key']}" f"?type=tcp&security=reality&pbk={meta['public_key']}"
f"&sni={sni}&fp=chrome&sid={meta['short_id']}" f"&sni={sni}&fp=chrome&sid={meta['short_id']}"
f"&spx=%2F&flow=xtls-rprx-vision#{encoded_name}" f"&spx=%2F&flow=xtls-rprx-vision#{encoded_name}"
) )
return url return url
def add_client(self, protocol, client_name, server_host, port): def add_client(self, protocol, client_name, server_host, port, public_port=None):
client_id = str(uuid.uuid4()) client_id = str(uuid.uuid4())
config = self._get_server_json() config = self._get_server_json()
@@ -750,7 +752,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
return { return {
'client_id': client_id, 'client_id': client_id,
'config': self.get_client_config(protocol, client_id, server_host, port) 'config': self.get_client_config(protocol, client_id, server_host, port, public_port)
} }
def toggle_client(self, protocol, client_id, enable): def toggle_client(self, protocol, client_id, enable):
+144 -7
View File
@@ -644,6 +644,32 @@
</div> </div>
</div> </div>
<!-- Public Endpoint Modal -->
<div class="modal-backdrop" id="publicEndpointModal">
<div class="modal" style="max-width: 460px;">
<div class="modal-header">
<h2 class="modal-title">🌐 {{ _('public_endpoint_title') }}</h2>
<button class="modal-close" onclick="closeModal('publicEndpointModal')">&times;</button>
</div>
<div class="modal-body">
<div class="text-muted text-sm" style="margin-bottom: var(--space-md);" id="publicEndpointHint"></div>
<div class="form-group">
<label class="form-label">{{ _('public_endpoint_host') }}</label>
<input type="text" class="form-input" id="publicEndpointHost" maxlength="253" placeholder="{{ server.host }}">
</div>
<div class="form-group">
<label class="form-label">{{ _('public_endpoint_port') }}</label>
<input type="text" class="form-input" id="publicEndpointPort" maxlength="5" inputmode="numeric" placeholder="{{ _('public_endpoint_port_placeholder') }}">
</div>
<div class="text-muted text-sm">{{ _('public_endpoint_hint') }}</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('publicEndpointModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="savePublicEndpoint()" id="publicEndpointSaveBtn">{{ _('save') }}</button>
</div>
</div>
</div>
<!-- Rename Instance Modal --> <!-- Rename Instance Modal -->
<div class="modal-backdrop" id="renameProtoModal"> <div class="modal-backdrop" id="renameProtoModal">
<div class="modal" style="max-width: 420px;"> <div class="modal" style="max-width: 420px;">
@@ -1263,6 +1289,9 @@
<script> <script>
const SERVER_ID = {{ server_id }}; const SERVER_ID = {{ server_id }};
let SSH_COOLDOWN_BASE = {{ (server.get('ssh_cooldown_base') or 30) | tojson }}; let SSH_COOLDOWN_BASE = {{ (server.get('ssh_cooldown_base') or 30) | tojson }};
// Live peer-list refresh interval in seconds; 0 = off (opt-in per server).
let PEER_POLL_INTERVAL = {{ (server.get('peer_poll_interval') or 0) | tojson }};
const PEER_POLL_STEPS = [0, 5, 10, 15, 20, 45, 120, 300, 600];
document.body.dataset.serverUid = {{ (server.uid or '') | tojson }}; document.body.dataset.serverUid = {{ (server.uid or '') | tojson }};
const SERVER_HOST = "{{ server.host }}"; const SERVER_HOST = "{{ server.host }}";
const CUSTOM_PROTO_NAMES = { const CUSTOM_PROTO_NAMES = {
@@ -1270,6 +1299,13 @@
{{ proto|tojson }}: {{ info.get('custom_name')|tojson }}, {{ proto|tojson }}: {{ info.get('custom_name')|tojson }},
{%- endfor %} {%- endfor %}
}; };
// Per-instance public address. Absent = the instance is reached at the
// server's own address and its listen port.
const PUBLIC_ENDPOINTS = {
{%- for proto, info in (server.get('protocols') or {}).items() if info.get('public_host') or info.get('public_port') %}
{{ proto|tojson }}: { host: {{ (info.get('public_host') or '')|tojson }}, port: {{ (info.get('public_port') or '')|tojson }} },
{%- endfor %}
};
const MARKETPLACE_APPS = [ const MARKETPLACE_APPS = [
{ proto: 'awg3', category: 'protocols', icon: '🛡', title: 'AmneziaWG 3.1', descKey: 'awg3_desc', badge: 'NEW' }, { proto: 'awg3', category: 'protocols', icon: '🛡', title: 'AmneziaWG 3.1', descKey: 'awg3_desc', badge: 'NEW' },
{ proto: 'awg2', category: 'protocols', icon: '✨', title: 'AmneziaWG 2.0', descKey: 'awg_desc' }, { proto: 'awg2', category: 'protocols', icon: '✨', title: 'AmneziaWG 2.0', descKey: 'awg_desc' },
@@ -1302,6 +1338,9 @@
let currentAwgProto = 'awg2'; let currentAwgProto = 'awg2';
const AWG_PROTOCOLS = ['awg', 'awg2', 'awg3', 'awg_legacy']; const AWG_PROTOCOLS = ['awg', 'awg2', 'awg3', 'awg_legacy'];
// Protocols that hand out a client config or link, and therefore have an
// address a client dials.
const ENDPOINT_PROTOCOLS = [...AWG_PROTOCOLS, 'wireguard', 'xray', 'telemt'];
const SPECIAL_JUNK_KEYS = ['i1', 'i2', 'i3', 'i4', 'i5']; const SPECIAL_JUNK_KEYS = ['i1', 'i2', 'i3', 'i4', 'i5'];
// Same value the desktop client ships as its default I1. // Same value the desktop client ships as its default I1.
const AWG_DEFAULT_I1 = '<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>'; const AWG_DEFAULT_I1 = '<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>';
@@ -1458,10 +1497,46 @@
</div> </div>
<div class="text-muted" style="font-size:0.78rem; margin-top:6px;">${_('ssh_cooldown_hint')}</div> <div class="text-muted" style="font-size:0.78rem; margin-top:6px;">${_('ssh_cooldown_hint')}</div>
</div> </div>
<div style="margin-top: var(--space-md); padding-top: var(--space-md); border-top: 1px solid var(--border);">
<label for="peerPollSelect" style="display:block; font-size:0.85rem; margin-bottom:6px;">${_('peer_poll_title')}</label>
<div class="flex" style="gap:8px;">
<select class="form-select" id="peerPollSelect" style="max-width:160px;">
${PEER_POLL_STEPS.map(s => `<option value="${s}"${s === PEER_POLL_INTERVAL ? ' selected' : ''}>${s === 0 ? _('peer_poll_off') : s + ' ' + _('peer_poll_sec')}</option>`).join('')}
</select>
<button class="btn btn-secondary btn-sm" onclick="savePeerPollInterval()" id="peerPollSaveBtn">💾</button>
</div>
<div class="text-muted" style="font-size:0.78rem; margin-top:6px;">${_('peer_poll_hint')}</div>
</div>
</div> </div>
`; `;
} }
async function savePeerPollInterval() {
const select = document.getElementById('peerPollSelect');
const btn = document.getElementById('peerPollSaveBtn');
if (!select) return;
const seconds = parseInt(select.value, 10);
if (!PEER_POLL_STEPS.includes(seconds)) {
showToast(_('peer_poll_title') + ': ' + PEER_POLL_STEPS.join('/'), 'error');
return;
}
if (btn) { btn.disabled = true; btn.innerHTML = '<div class="spinner"></div>'; }
try {
const res = await apiCall(`/api/servers/${SERVER_ID}/peer_poll_interval`, 'POST', { seconds });
PEER_POLL_INTERVAL = res.peer_poll_interval;
// Reschedule (or stop) the live refresh with the new interval.
clearTimeout(window.connPollTimer);
if (PEER_POLL_INTERVAL > 0 && document.getElementById('connectionsSection').style.display !== 'none') {
window.connPollTimer = setTimeout(function() { loadConnections(true); }, PEER_POLL_INTERVAL * 1000);
}
showToast('✓', 'success');
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = '💾'; }
}
}
async function saveSshCooldown() { async function saveSshCooldown() {
const input = document.getElementById('sshCooldownInput'); const input = document.getElementById('sshCooldownInput');
const btn = document.getElementById('sshCooldownSaveBtn'); const btn = document.getElementById('sshCooldownSaveBtn');
@@ -2016,9 +2091,14 @@
const exitBtn = AWG_PROTOCOLS.includes(protoBase(proto)) const exitBtn = AWG_PROTOCOLS.includes(protoBase(proto))
? `<button class="btn btn-secondary btn-sm" onclick="openExitLinkModal('${proto}')" title="${_('exit_link_title')}">🚪${info.exit_link ? ' ' + escapeHtml(info.exit_link.exit_name || '') : ''}</button>` ? `<button class="btn btn-secondary btn-sm" onclick="openExitLinkModal('${proto}')" title="${_('exit_link_title')}">🚪${info.exit_link ? ' ' + escapeHtml(info.exit_link.exit_name || '') : ''}</button>`
: ''; : '';
const ep = PUBLIC_ENDPOINTS[proto];
const endpointBtn = ENDPOINT_PROTOCOLS.includes(protoBase(proto))
? `<button class="btn btn-secondary btn-sm" onclick="openPublicEndpointModal('${proto}')" title="${_('public_endpoint_title')}">🌐${ep ? ' ' + escapeHtml(publicEndpointLabel(ep)) : ''}</button>`
: '';
ctrlEl.innerHTML = ` ctrlEl.innerHTML = `
${awgBtn} ${awgBtn}
${exitBtn} ${exitBtn}
${endpointBtn}
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button> <button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button> <button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
`; `;
@@ -2215,6 +2295,55 @@
} }
} }
// ---------- public endpoint of an instance ----------
let publicEndpointTarget = null;
function publicEndpointLabel(ep) {
if (!ep) return '';
return ep.port ? `${ep.host || SERVER_HOST}:${ep.port}` : (ep.host || '');
}
function openPublicEndpointModal(proto) {
publicEndpointTarget = proto;
const ep = PUBLIC_ENDPOINTS[proto] || { host: '', port: '' };
document.getElementById('publicEndpointHost').value = ep.host || '';
document.getElementById('publicEndpointPort').value = ep.port || '';
document.getElementById('publicEndpointHint').textContent =
_('public_endpoint_for').replace('{}', getProtoTitle(proto));
openModal('publicEndpointModal');
}
async function savePublicEndpoint() {
const proto = publicEndpointTarget;
if (!proto) return;
const host = document.getElementById('publicEndpointHost').value.trim();
const port = document.getElementById('publicEndpointPort').value.trim();
const btn = document.getElementById('publicEndpointSaveBtn');
btn.disabled = true;
try {
const res = await apiCall(`/api/servers/${SERVER_ID}/protocol/public-endpoint`, 'POST',
{ protocol: proto, public_host: host, public_port: port });
if (host || port) {
PUBLIC_ENDPOINTS[proto] = { host, port };
} else {
delete PUBLIC_ENDPOINTS[proto];
}
closeModal('publicEndpointModal');
// The record is saved even when the protocol's own config could not
// be updated (server down); say so instead of a bare "saved".
if (res && res.warning) {
showToast(_('public_endpoint_not_applied') + ': ' + res.warning, 'error');
} else {
showToast(_('success') || 'Saved', 'success');
}
checkServer();
} catch (err) {
showToast((_('error') || 'Error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
}
}
// ---------- wg-easy import ---------- // ---------- wg-easy import ----------
let wgeasyClients = []; let wgeasyClients = [];
@@ -2531,6 +2660,7 @@
logEl.innerHTML = `<div class="log-line info">${_('install_starting')}</div>`; logEl.innerHTML = `<div class="log-line info">${_('install_starting')}</div>`;
fillEl.style.width = '10%'; fillEl.style.width = '10%';
fillEl.style.background = ''; fillEl.style.background = '';
let progressInterval = null;
try { try {
const progressSteps = [ const progressSteps = [
{ pct: 20, msg: _('check_docker') }, { pct: 20, msg: _('check_docker') },
@@ -2538,7 +2668,7 @@
{ pct: 50, msg: _('build_container') }, { pct: 50, msg: _('build_container') },
]; ];
let stepIdx = 0; let stepIdx = 0;
const progressInterval = setInterval(() => { progressInterval = setInterval(() => {
if (stepIdx < progressSteps.length) { if (stepIdx < progressSteps.length) {
fillEl.style.width = progressSteps[stepIdx].pct + '%'; fillEl.style.width = progressSteps[stepIdx].pct + '%';
logEl.innerHTML += `<div class="log-line info">${progressSteps[stepIdx].msg}</div>`; logEl.innerHTML += `<div class="log-line info">${progressSteps[stepIdx].msg}</div>`;
@@ -2599,9 +2729,15 @@
document.getElementById('installDone').style.display = ''; document.getElementById('installDone').style.display = '';
setTimeout(() => checkServer(), 800); setTimeout(() => checkServer(), 800);
} catch (err) { } catch (err) {
clearInterval(progressInterval);
fillEl.style.width = '100%'; fillEl.style.width = '100%';
fillEl.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)'; fillEl.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)';
logEl.innerHTML += `<div class="log-line error">✕ ${_('error')}: ${err.message}</div>`; // textContent, not innerHTML: build logs can contain sequences
// that parse as HTML and silently swallow the rest of the error.
const errDiv = document.createElement('div');
errDiv.className = 'log-line error';
errDiv.textContent = `✕ ${_('error')}: ${err.message}`;
logEl.appendChild(errDiv);
logEl.scrollTop = logEl.scrollHeight; logEl.scrollTop = logEl.scrollHeight;
showToast(_('install_error') + err.message, 'error'); showToast(_('install_error') + err.message, 'error');
document.getElementById('installDone').style.display = ''; document.getElementById('installDone').style.display = '';
@@ -3147,8 +3283,8 @@
// the editor's own save path, which wants an immediate silent refresh. // the editor's own save path, which wants an immediate silent refresh.
if (isPoll && !force && document.querySelector('#connectionsListInner .rename-editor')) { if (isPoll && !force && document.querySelector('#connectionsListInner .rename-editor')) {
clearTimeout(window.connPollTimer); clearTimeout(window.connPollTimer);
if (document.getElementById('connectionsSection').style.display !== 'none') { if (PEER_POLL_INTERVAL > 0 && document.getElementById('connectionsSection').style.display !== 'none') {
window.connPollTimer = setTimeout(function() { loadConnections(true); }, 5000); window.connPollTimer = setTimeout(function() { loadConnections(true); }, PEER_POLL_INTERVAL * 1000);
} }
return; return;
} }
@@ -3267,10 +3403,11 @@
} finally { } finally {
window.connLoading = false; window.connLoading = false;
highlightPeerFromQuery(); highlightPeerFromQuery();
// Auto-refresh every 5s while the connections section is visible (live speed/handshake) // Live refresh while the connections section is visible; the
// interval is opt-in per server (0 = off, management modal).
clearTimeout(window.connPollTimer); clearTimeout(window.connPollTimer);
if (document.getElementById('connectionsSection').style.display !== 'none') { if (PEER_POLL_INTERVAL > 0 && document.getElementById('connectionsSection').style.display !== 'none') {
window.connPollTimer = setTimeout(function() { loadConnections(true); }, 5000); window.connPollTimer = setTimeout(function() { loadConnections(true); }, PEER_POLL_INTERVAL * 1000);
} }
} }
} }
+14 -7
View File
@@ -20,8 +20,11 @@ class FakeManager:
def __init__(self, result=None): def __init__(self, result=None):
self.result = result or {'client_id': 'client-1', 'config': 'client config'} self.result = result or {'client_id': 'client-1', 'config': 'client config'}
self.removed = [] self.removed = []
self.calls = []
def add_client(self, protocol, name, host, port): def add_client(self, protocol, name, host, port, public_port=None):
self.calls.append({'protocol': protocol, 'name': name, 'host': host,
'port': port, 'public_port': public_port})
return dict(self.result) return dict(self.result)
def remove_client(self, protocol, client_id): def remove_client(self, protocol, client_id):
@@ -29,7 +32,7 @@ class FakeManager:
class FailingManager(FakeManager): class FailingManager(FakeManager):
def add_client(self, protocol, name, host, port): def add_client(self, protocol, name, host, port, public_port=None):
raise RuntimeError('remote failed') raise RuntimeError('remote failed')
@@ -66,8 +69,11 @@ def base_data():
class ConnectionServiceTest(unittest.IsolatedAsyncioTestCase): class ConnectionServiceTest(unittest.IsolatedAsyncioTestCase):
def make_service(self, data=None, manager=None, save_raises=False): def make_service(self, data=None, manager=None, save_raises=False,
public_endpoint=None):
state = copy.deepcopy(data or base_data()) state = copy.deepcopy(data or base_data())
public_endpoint = public_endpoint or (
lambda server, protocol: (server.get('host', ''), None))
fake_manager = manager or FakeManager() fake_manager = manager or FakeManager()
def load_data(): def load_data():
@@ -86,8 +92,9 @@ class ConnectionServiceTest(unittest.IsolatedAsyncioTestCase):
data_lock=asyncio.Lock(), data_lock=asyncio.Lock(),
get_ssh=lambda server: FakeSSH(), get_ssh=lambda server: FakeSSH(),
get_protocol_manager=lambda ssh, protocol: fake_manager, get_protocol_manager=lambda ssh, protocol: fake_manager,
manager_call=lambda manager, method, protocol, *args: getattr(manager, method)(protocol, *args), manager_call=lambda manager, method, protocol, *args, **kwargs: getattr(manager, method)(protocol, *args, **kwargs),
generate_vpn_link=lambda config: f'vpn://{config}', generate_vpn_link=lambda config: f'vpn://{config}',
protocol_public_endpoint=public_endpoint,
) )
return service, state, fake_manager return service, state, fake_manager
@@ -350,9 +357,9 @@ class ConnectionServiceTest(unittest.IsolatedAsyncioTestCase):
state.update(new_data) state.update(new_data)
class MutatingManager(FakeManager): class MutatingManager(FakeManager):
def add_client(self, protocol, name, host, port): def add_client(self, protocol, name, host, port, public_port=None):
state['users'][0]['enabled'] = False state['users'][0]['enabled'] = False
return super().add_client(protocol, name, host, port) return super().add_client(protocol, name, host, port, public_port)
fake_manager = MutatingManager() fake_manager = MutatingManager()
service = ConnectionService( service = ConnectionService(
@@ -361,7 +368,7 @@ class ConnectionServiceTest(unittest.IsolatedAsyncioTestCase):
data_lock=asyncio.Lock(), data_lock=asyncio.Lock(),
get_ssh=lambda server: FakeSSH(), get_ssh=lambda server: FakeSSH(),
get_protocol_manager=lambda ssh, protocol: fake_manager, get_protocol_manager=lambda ssh, protocol: fake_manager,
manager_call=lambda manager, method, protocol, *args: getattr(manager, method)(protocol, *args), manager_call=lambda manager, method, protocol, *args, **kwargs: getattr(manager, method)(protocol, *args, **kwargs),
generate_vpn_link=lambda config: f'vpn://{config}', generate_vpn_link=lambda config: f'vpn://{config}',
) )
+366
View File
@@ -0,0 +1,366 @@
"""Per-instance public address: the endpoint a client actually dials.
The panel handed out its own SSH address for every protocol on a server. A box
can publish an instance somewhere else -- a second IP, a port forward, a domain
name -- and the configs it issued were then unreachable; for telemt, which
prints its own tg:// links, they were wrong even when the tunnel worked. The
instance record now carries `public_host`/`public_port`, and these cover the
lookup, the API around it, and the places the address reaches a client.
"""
import json
import os
import re
import shutil
import tempfile
import unittest
from unittest import mock
from fastapi.testclient import TestClient
import app as panel
from managers.awg_manager import AWGManager
from managers.telemt_manager import TelemtManager
from managers.wireguard_manager import WireGuardManager
from managers.xray_manager import XrayManager
def server_with(protocols):
return {'name': 'Aeza', 'host': '198.51.100.1', 'ssh_port': 22, 'username': 'root',
'password': 'x', 'private_key': '', 'server_info': {}, 'uid': 'srv-uid',
'protocols': protocols}
class EndpointResolutionTests(unittest.TestCase):
def test_without_an_override_the_panel_address_is_used(self):
server = server_with({'awg2': {'port': '55424'}})
self.assertEqual(panel.protocol_public_endpoint(server, 'awg2'), ('198.51.100.1', None))
def test_public_host_replaces_the_panel_address(self):
server = server_with({'telemt': {'port': '8443', 'public_host': '203.0.113.9'}})
self.assertEqual(panel.protocol_public_endpoint(server, 'telemt'), ('203.0.113.9', None))
def test_public_port_comes_back_for_the_managers(self):
server = server_with({'telemt': {'port': '8443', 'public_host': '203.0.113.9',
'public_port': '443'}})
self.assertEqual(panel.protocol_public_endpoint(server, 'telemt'), ('203.0.113.9', '443'))
def test_blank_and_missing_records_fall_back(self):
server = server_with({'awg2': {'port': '55424', 'public_host': ' ', 'public_port': ''}})
self.assertEqual(panel.protocol_public_endpoint(server, 'awg2'), ('198.51.100.1', None))
# an instance the panel has no record of at all
self.assertEqual(panel.protocol_public_endpoint(server, 'xray'), ('198.51.100.1', None))
class ValidationTests(unittest.TestCase):
def test_addresses_and_names_pass(self):
for value in ('198.51.100.1', 'vpn.example.com', '[2a01:e5c0:7bc2::2]', 'a-b_c.example'):
self.assertEqual(panel.normalize_public_host(value), value)
self.assertEqual(panel.normalize_public_host(' 198.51.100.1 '), '198.51.100.1')
self.assertEqual(panel.normalize_public_host(''), '')
def test_anything_that_would_silently_break_a_config_is_rejected(self):
for value in ('https://vpn.example.com', 'vpn.example.com/path', 'vpn example com',
'host&other', 'a' * 254,
# a bare IPv6 would read as host:port and never connect
'2a01:e5c0:7bc2::2'):
with self.assertRaises(ValueError, msg=value):
panel.normalize_public_host(value)
def test_port_must_be_a_port(self):
self.assertEqual(panel.normalize_public_port(' 443 '), '443')
self.assertEqual(panel.normalize_public_port(''), '')
for value in ('0', '65536', '-1', '44a3', 'http'):
with self.assertRaises(ValueError, msg=value):
panel.normalize_public_port(value)
class ClientConfigTests(unittest.TestCase):
"""Every manager that builds an endpoint must honour the override."""
def wireguard(self):
mgr = WireGuardManager(ssh_manager=mock.Mock())
mgr._get_clients_table = lambda: [{'clientId': 'peer', 'userData': {
'clientPrivateKey': 'priv', 'clientIp': '10.8.0.6', 'psk': 'psk'}}]
mgr._get_server_public_key = lambda: 'serverpub'
mgr._get_server_psk = lambda: 'serverpsk'
mgr._get_listen_port = lambda: '51820'
mgr._get_dns = lambda ud=None: '1.1.1.1'
return mgr
def awg(self):
mgr = AWGManager(mock.Mock())
mgr._get_clients_table = lambda proto: [{'clientId': 'peer', 'userData': {
'clientPrivateKey': 'priv', 'clientIp': '10.8.1.6', 'psk': 'psk'}}]
mgr._get_client_ipv6 = lambda proto, ip: ''
mgr._get_server_public_key = lambda proto: 'serverpub'
mgr._get_server_psk = lambda proto: 'serverpsk'
mgr._get_awg_params_from_config = lambda proto: {}
mgr._get_dns = lambda proto, ud: '1.1.1.1'
mgr._get_mtu = lambda proto, ud: '1376'
return mgr
def xray(self):
mgr = XrayManager(mock.Mock())
mgr._get_clients_table = lambda: [{'clientId': 'uuid-1',
'userData': {'clientName': 'laptop'}}]
mgr._get_meta_json = lambda: {'port': 443, 'public_key': 'pbk', 'short_id': 'sid',
'site_name': 'yahoo.com'}
mgr._get_server_json = lambda: None
return mgr
def test_wireguard_endpoint_follows_the_public_port(self):
config = self.wireguard().get_client_config('peer', 'vpn.example.com', '51820',
public_port='443')
self.assertIn('Endpoint = vpn.example.com:443', config)
def test_wireguard_keeps_the_listen_port_without_an_override(self):
config = self.wireguard().get_client_config('peer', 'vpn.example.com', '51820')
self.assertIn('Endpoint = vpn.example.com:51820', config)
def test_awg_endpoint_follows_the_public_port(self):
config = self.awg().get_client_config('awg2', 'peer', '203.0.113.9', '55424',
public_port='443')
self.assertIn('Endpoint = 203.0.113.9:443', config)
def test_awg_keeps_the_listen_port_without_an_override(self):
config = self.awg().get_client_config('awg2', 'peer', '198.51.100.1', '55424')
self.assertIn('Endpoint = 198.51.100.1:55424', config)
def test_xray_link_prefers_the_public_port_over_the_live_config(self):
link = self.xray().get_client_config('xray', 'uuid-1', 'vpn.example.com', '55424',
public_port='8443')
self.assertIn('@vpn.example.com:8443?', link)
def test_xray_link_without_an_override_still_trusts_meta(self):
# meta.json is synced from the live server.json; the panel's own record
# can be stale, so an unset override must not promote it.
link = self.xray().get_client_config('xray', 'uuid-1', 'vpn.example.com', '55424')
self.assertIn('@vpn.example.com:443?', link)
TELEMT_CONFIG = """[general]
port = 443
[general.links]
show = "*"
# public_host = "proxy.example.com" # Host (IP or domain) for tg:// links
public_port = 443 # Port for tg:// links
[access.users]
alice = "deadbeef"
"""
class TelemtLinkConfigTests(unittest.TestCase):
"""Telemt prints its own links, so the address has to land in config.toml."""
def manager(self):
ssh = mock.Mock()
ssh.host = '198.51.100.1'
return TelemtManager(ssh)
def test_commented_host_is_activated_and_lines_stay_separate(self):
patched = self.manager()._patch_public_links(TELEMT_CONFIG, '203.0.113.9', '443')
self.assertIn('public_host = "203.0.113.9"', patched)
self.assertIn('public_port = 443', patched)
# A pattern allowed to span lines eats the newline before the key and
# welds it onto the previous line, quietly corrupting the config.
self.assertIn('show = "*"\n', patched)
self.assertEqual(len(TELEMT_CONFIG.splitlines()), len(patched.splitlines()))
def test_an_already_set_host_is_replaced_in_place(self):
once = self.manager()._patch_public_links(TELEMT_CONFIG, '203.0.113.9', '443')
twice = self.manager()._patch_public_links(once, '203.0.113.10', '8443')
self.assertIn('public_host = "203.0.113.10"', twice)
self.assertNotIn('203.0.113.9', twice)
self.assertEqual(twice.count('public_host'), 1)
self.assertEqual(len(once.splitlines()), len(twice.splitlines()))
def test_keys_are_added_when_the_section_has_neither(self):
bare = '[general.links]\nshow = "*"\n'
patched = self.manager()._patch_public_links(bare, 'vpn.example.com', '443')
self.assertIn('public_host = "vpn.example.com"', patched)
self.assertIn('public_port = 443', patched)
def test_set_public_endpoint_writes_and_reloads(self):
mgr = self.manager()
mgr._get_server_config = lambda: TELEMT_CONFIG
saved = []
mgr.save_server_config = lambda proto, content: saved.append(content)
self.assertTrue(mgr.set_public_endpoint('203.0.113.9', '443'))
self.assertEqual(len(saved), 1)
self.assertIn('public_host = "203.0.113.9"', saved[0])
def test_set_public_endpoint_reports_a_missing_config(self):
mgr = self.manager()
mgr._get_server_config = lambda: ''
mgr.save_server_config = lambda proto, content: self.fail('nothing to save')
self.assertFalse(mgr.set_public_endpoint('203.0.113.9', '443'))
class PublicEndpointApiTests(unittest.TestCase):
"""The API around the record, through the real app."""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self._data_file = panel.DATA_FILE
panel.DATA_FILE = os.path.join(self.tmp, 'data.json')
with open(panel.DATA_FILE, 'w', encoding='utf-8') as f:
json.dump({'servers': [server_with({
'awg2': {'installed': True, 'port': '55424', 'base_protocol': 'awg2'},
'telemt': {'installed': True, 'port': '8443', 'base_protocol': 'telemt'},
})], 'users': [], 'user_connections': []}, f)
self._monitor_flag = panel._conn_monitor_started
panel._conn_monitor_started = True
def tearDown(self):
panel.DATA_FILE = self._data_file
panel._conn_monitor_started = self._monitor_flag
shutil.rmtree(self.tmp, ignore_errors=True)
def stored(self, protocol):
with open(panel.DATA_FILE, encoding='utf-8') as f:
return json.load(f)['servers'][0]['protocols'][protocol]
def client(self):
client = TestClient(panel.app)
client.__enter__()
self.addCleanup(client.__exit__, None, None, None)
login = client.post('/api/auth/login', json={'username': 'admin', 'password': 'admin'})
self.assertEqual(login.status_code, 200, login.text)
return client
def set_endpoint(self, client, **body):
return client.post('/api/servers/0/protocol/public-endpoint', json=body)
def test_set_and_clear_round_trip(self):
client = self.client()
res = self.set_endpoint(client, protocol='awg2', public_host='203.0.113.9',
public_port='443')
self.assertEqual(res.status_code, 200, res.text)
self.assertEqual(res.json()['warning'], '')
self.assertEqual(self.stored('awg2')['public_host'], '203.0.113.9')
self.assertEqual(self.stored('awg2')['public_port'], '443')
# empty fields mean "back to the server address and the listen port":
# the keys go away rather than lingering as empty strings
res = self.set_endpoint(client, protocol='awg2', public_host='', public_port='')
self.assertEqual(res.status_code, 200, res.text)
self.assertNotIn('public_host', self.stored('awg2'))
self.assertNotIn('public_port', self.stored('awg2'))
self.assertEqual(self.stored('awg2')['port'], '55424', 'listen port must survive')
def test_bad_input_is_refused_before_it_reaches_a_config(self):
client = self.client()
res = self.set_endpoint(client, protocol='awg2', public_host='https://vpn.example.com')
self.assertEqual(res.status_code, 400, res.text)
res = self.set_endpoint(client, protocol='awg2', public_port='70000')
self.assertEqual(res.status_code, 400, res.text)
self.assertNotIn('public_host', self.stored('awg2'))
def test_unknown_protocol_and_server(self):
client = self.client()
self.assertEqual(self.set_endpoint(client, protocol='xray',
public_host='203.0.113.9').status_code, 404)
res = client.post('/api/servers/7/protocol/public-endpoint',
json={'protocol': 'awg2', 'public_host': '203.0.113.9'})
self.assertEqual(res.status_code, 404)
def test_anonymous_callers_are_refused(self):
with TestClient(panel.app) as client:
res = client.post('/api/servers/0/protocol/public-endpoint',
json={'protocol': 'awg2', 'public_host': '203.0.113.9'})
self.assertEqual(res.status_code, 403)
def test_telemt_override_is_pushed_into_the_running_config(self):
client = self.client()
manager = mock.Mock()
with mock.patch.object(panel, 'get_ssh', return_value=mock.Mock()), \
mock.patch.object(panel, 'get_protocol_manager', return_value=manager):
res = self.set_endpoint(client, protocol='telemt', public_host='203.0.113.9',
public_port='443')
self.assertEqual(res.status_code, 200, res.text)
manager.set_public_endpoint.assert_called_once_with('203.0.113.9', '443')
def test_telemt_falls_back_to_the_instance_port(self):
client = self.client()
manager = mock.Mock()
with mock.patch.object(panel, 'get_ssh', return_value=mock.Mock()), \
mock.patch.object(panel, 'get_protocol_manager', return_value=manager):
self.set_endpoint(client, protocol='telemt', public_host='203.0.113.9')
manager.set_public_endpoint.assert_called_once_with('203.0.113.9', '8443')
def test_an_unreachable_server_still_keeps_the_setting(self):
client = self.client()
with mock.patch.object(panel, 'get_ssh', side_effect=RuntimeError('host is down')):
res = self.set_endpoint(client, protocol='telemt', public_host='203.0.113.9')
self.assertEqual(res.status_code, 200, res.text)
self.assertIn('host is down', res.json()['warning'])
self.assertEqual(self.stored('telemt')['public_host'], '203.0.113.9')
def test_reinstall_keeps_the_public_address(self):
client = self.client()
manager = mock.Mock()
manager.install_protocol.return_value = {'status': 'success', 'awg_params': {}}
with mock.patch.object(panel, 'get_ssh', return_value=mock.Mock()), \
mock.patch.object(panel, 'get_protocol_manager', return_value=manager):
self.set_endpoint(client, protocol='awg2', public_host='203.0.113.9',
public_port='443')
with mock.patch.object(panel, 'ensure_docker_installed', return_value=''), \
mock.patch.object(panel, 'get_used_ports', return_value={}):
res = client.post('/api/servers/0/install',
json={'protocol': 'awg2', 'port': '55424'})
self.assertEqual(res.status_code, 200, res.text)
# The install rebuilds the record from scratch; where the instance is
# published is a property of the network, not of this install.
self.assertEqual(self.stored('awg2')['public_host'], '203.0.113.9')
self.assertEqual(self.stored('awg2')['public_port'], '443')
def test_telemt_reinstall_writes_the_override_into_the_fresh_config(self):
client = self.client()
manager = mock.Mock()
manager.install_protocol.return_value = {'status': 'success'}
with mock.patch.object(panel, 'get_ssh', return_value=mock.Mock()), \
mock.patch.object(panel, 'get_protocol_manager', return_value=manager):
self.set_endpoint(client, protocol='telemt', public_host='203.0.113.9',
public_port='443')
with mock.patch.object(panel, 'ensure_docker_installed', return_value=''), \
mock.patch.object(panel, 'get_used_ports', return_value={}):
res = client.post('/api/servers/0/install',
json={'protocol': 'telemt', 'port': '8443'})
self.assertEqual(res.status_code, 200, res.text)
kwargs = manager.install_protocol.call_args.kwargs
self.assertEqual(kwargs['public_host'], '203.0.113.9')
self.assertEqual(kwargs['public_port'], '443')
def test_issued_config_uses_the_override(self):
client = self.client()
manager = mock.Mock()
manager.add_client.return_value = {'client_id': 'peer', 'config': '[Interface]'}
with mock.patch.object(panel, 'get_ssh', return_value=mock.Mock()), \
mock.patch.object(panel, 'get_protocol_manager', return_value=manager):
self.set_endpoint(client, protocol='awg2', public_host='203.0.113.9',
public_port='443')
res = client.post('/api/servers/0/connections/add',
json={'protocol': 'awg2', 'name': 'laptop'})
self.assertEqual(res.status_code, 200, res.text)
args, kwargs = manager.add_client.call_args
self.assertEqual(args[2], '203.0.113.9', 'the client must dial the published address')
self.assertEqual(kwargs['public_port'], '443')
def test_server_page_carries_the_endpoints_and_the_modal(self):
client = self.client()
self.set_endpoint(client, protocol='awg2', public_host='203.0.113.9', public_port='443')
page = client.get('/server/0').text
self.assertIn('id="publicEndpointModal"', page)
self.assertIn('function openPublicEndpointModal', page)
endpoints = re.search(r'const PUBLIC_ENDPOINTS = (\{.*?\});', page, re.S).group(1)
self.assertIn('"awg2"', endpoints)
self.assertIn('203.0.113.9', endpoints)
self.assertIn('"443"', endpoints)
# a missing translation renders as the raw key
self.assertEqual(re.findall(r'>\s*public_endpoint_[a-z0-9_]+\s*<', page), [])
if __name__ == '__main__':
unittest.main()
+12 -1
View File
@@ -672,5 +672,16 @@
"mail_skip_no_email": "no email address", "mail_skip_no_email": "no email address",
"mail_skip_bad_email": "invalid email address", "mail_skip_bad_email": "invalid email address",
"mail_skip_disabled": "user disabled", "mail_skip_disabled": "user disabled",
"mail_test_hint": "The test uses the saved settings — press Save first." "mail_test_hint": "The test uses the saved settings — press Save first.",
"public_endpoint_title": "Public address",
"public_endpoint_for": "Address clients dial for {}",
"public_endpoint_host": "Host",
"public_endpoint_port": "Port",
"public_endpoint_port_placeholder": "instance port",
"public_endpoint_hint": "Leave empty to use the server address and the instance port. Fill this in when the instance answers on another IP of the same server, behind a port forward, or under a domain name.",
"public_endpoint_not_applied": "Saved, but not applied on the server",
"peer_poll_title": "Live peer refresh",
"peer_poll_hint": "How often the peer list refreshes itself. 0 = off (default). 5–10 is fine on fast servers; on slow ones use 300–600 or keep it off.",
"peer_poll_off": "off",
"peer_poll_sec": "sec"
} }
+12 -1
View File
@@ -573,5 +573,16 @@
"stop_peer_title": "توقف", "stop_peer_title": "توقف",
"start_peer_title": "فعال‌سازی", "start_peer_title": "فعال‌سازی",
"ssh_cooldown_title": "SSH cooldown (ثانیه)", "ssh_cooldown_title": "SSH cooldown (ثانیه)",
"ssh_cooldown_hint": "تأخیر اولیه قبل از اتصال مجدد به سرور در دسترس‌نبوده. اگر سرور درست کار می‌کند، تغییر ندهید." "ssh_cooldown_hint": "تأخیر اولیه قبل از اتصال مجدد به سرور در دسترس‌نبوده. اگر سرور درست کار می‌کند، تغییر ندهید.",
"public_endpoint_title": "نشانی عمومی",
"public_endpoint_for": "نشانی که کلاینت‌های {} به آن وصل می‌شوند",
"public_endpoint_host": "میزبان",
"public_endpoint_port": "درگاه",
"public_endpoint_port_placeholder": "درگاه نمونه",
"public_endpoint_hint": "خالی بگذارید تا نشانی سرور و درگاه همین نمونه استفاده شود. اگر این نمونه روی IP دیگری از همان سرور، پشت فوروارد درگاه یا با یک نام دامنه پاسخ می‌دهد، آن را پر کنید.",
"public_endpoint_not_applied": "ذخیره شد، اما روی سرور اعمال نشد",
"peer_poll_title": "به‌روزرسانی زنده پیرها",
"peer_poll_hint": "هر چند ثانیه فهرست پیرها خودکار تازه شود. 0 = خاموش (پیش‌فرض). در سرورهای سریع 5–10 کافی است؛ در سرورهای کند 300–600 بگذارید یا خاموش بماند.",
"peer_poll_off": "خاموش",
"peer_poll_sec": "ثانیه"
} }
+12 -1
View File
@@ -573,5 +573,16 @@
"stop_peer_title": "Arrêter", "stop_peer_title": "Arrêter",
"start_peer_title": "Activer", "start_peer_title": "Activer",
"ssh_cooldown_title": "SSH cooldown (s)", "ssh_cooldown_title": "SSH cooldown (s)",
"ssh_cooldown_hint": "Délai initial avant reconnexion à un serveur injoignable. Ne modifiez pas si le serveur fonctionne correctement." "ssh_cooldown_hint": "Délai initial avant reconnexion à un serveur injoignable. Ne modifiez pas si le serveur fonctionne correctement.",
"public_endpoint_title": "Adresse publique",
"public_endpoint_for": "Adresse utilisée par les clients de {}",
"public_endpoint_host": "Hôte",
"public_endpoint_port": "Port",
"public_endpoint_port_placeholder": "port de l'instance",
"public_endpoint_hint": "Laissez vide pour utiliser l'adresse du serveur et le port de l'instance. Renseignez ces champs si l'instance répond sur une autre IP du même serveur, derrière une redirection de port ou sous un nom de domaine.",
"public_endpoint_not_applied": "Enregistré, mais non appliqué sur le serveur",
"peer_poll_title": "Actualisation en direct des pairs",
"peer_poll_hint": "Fréquence de rafraîchissement automatique de la liste des pairs. 0 = désactivé (par défaut). 5–10 suffit sur les serveurs rapides ; sur les lents, utilisez 300–600 ou laissez désactivé.",
"peer_poll_off": "désactivé",
"peer_poll_sec": "s"
} }
+12 -1
View File
@@ -672,5 +672,16 @@
"mail_skip_no_email": "нет адреса почты", "mail_skip_no_email": "нет адреса почты",
"mail_skip_bad_email": "неверный адрес почты", "mail_skip_bad_email": "неверный адрес почты",
"mail_skip_disabled": "пользователь отключён", "mail_skip_disabled": "пользователь отключён",
"mail_test_hint": "Проверка идёт по сохранённым настройкам — сначала нажмите «Сохранить»." "mail_test_hint": "Проверка идёт по сохранённым настройкам — сначала нажмите «Сохранить».",
"public_endpoint_title": "Публичный адрес",
"public_endpoint_for": "Адрес, на который подключаются клиенты {}",
"public_endpoint_host": "Хост",
"public_endpoint_port": "Порт",
"public_endpoint_port_placeholder": "порт экземпляра",
"public_endpoint_hint": "Оставьте пустым — будут использованы адрес сервера и порт экземпляра. Заполните, если экземпляр отвечает на другом IP того же сервера, через проброс порта или по доменному имени.",
"public_endpoint_not_applied": "Сохранено, но не применено на сервере",
"peer_poll_title": "Живое обновление пиров",
"peer_poll_hint": "Как часто список пиров обновляется сам. 0 = выключено (по умолчанию). На быстрых серверах хватит 5–10, на медленных ставьте 300–600 или не включайте вовсе.",
"peer_poll_off": "выключено",
"peer_poll_sec": "сек"
} }
+12 -1
View File
@@ -573,5 +573,16 @@
"stop_peer_title": "停止", "stop_peer_title": "停止",
"start_peer_title": "启用", "start_peer_title": "启用",
"ssh_cooldown_title": "SSH 冷却时间(秒)", "ssh_cooldown_title": "SSH 冷却时间(秒)",
"ssh_cooldown_hint": "重新连接不可达服务器前的初始延迟。如果服务器工作正常,请勿更改。" "ssh_cooldown_hint": "重新连接不可达服务器前的初始延迟。如果服务器工作正常,请勿更改。",
"public_endpoint_title": "公网地址",
"public_endpoint_for": "{} 的客户端连接地址",
"public_endpoint_host": "主机",
"public_endpoint_port": "端口",
"public_endpoint_port_placeholder": "实例端口",
"public_endpoint_hint": "留空则使用服务器地址和实例端口。当实例通过同一服务器的另一个 IP、端口转发或域名对外提供服务时填写。",
"public_endpoint_not_applied": "已保存,但未在服务器上生效",
"peer_poll_title": "节点实时刷新",
"peer_poll_hint": "节点列表自动刷新的间隔。0 = 关闭(默认)。快的服务器用 5–10 即可;慢的服务器建议 300–600 或保持关闭。",
"peer_poll_off": "关闭",
"peer_poll_sec": "秒"
} }