Merge branch 'main' into feature/awg-ipv6

This commit is contained in:
Public relations & Virtual technology
2026-08-24 14:36:09 +03:00
committed by GitHub
11 changed files with 596 additions and 103 deletions
+72
View File
@@ -1506,6 +1506,17 @@ class ConnectionActionRequest(BaseModel):
client_id: str = ''
class RenameConnectionRequest(BaseModel):
protocol: str = 'awg'
client_id: str = ''
new_name: str = ''
class SaveConnectionConfigRequest(BaseModel):
protocol: str = 'awg'
client_id: str = ''
config: str = ''
class ToggleConnectionRequest(BaseModel):
protocol: str = 'awg'
client_id: str = ''
@@ -3082,6 +3093,67 @@ async def api_edit_connection(request: Request, server_id: int, req: EditConnect
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/connections/rename', tags=["Connections"])
async def api_rename_connection(request: Request, server_id: int, req: RenameConnectionRequest):
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]
new_name = (req.new_name or '').strip()
if not new_name:
return JSONResponse({'error': 'New name is required'}, status_code=400)
if not req.client_id:
return JSONResponse({'error': 'Client ID is required'}, status_code=400)
ssh = get_ssh(server)
ssh.connect()
manager = get_protocol_manager(ssh, req.protocol)
result = _manager_call(manager, 'rename_client', req.protocol, req.client_id, new_name) or {}
ssh.disconnect()
# Telemt rename may also change client_id (username is the identity there)
new_client_id = result.get('client_id', req.client_id)
stored_name = result.get('name', new_name)
changed = False
for conn in data.get('user_connections', []):
if conn.get('client_id') == req.client_id and conn.get('server_id') == server_id and conn.get('protocol') == req.protocol:
conn['name'] = stored_name
conn['client_id'] = new_client_id
changed = True
if changed:
save_data(data)
return {'status': 'success', 'name': stored_name, 'client_id': new_client_id}
except Exception as e:
logger.exception("Error renaming connection")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/connections/config/save', tags=["Connections"])
async def api_save_connection_config(request: Request, server_id: int, req: SaveConnectionConfigRequest):
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]
config_text = (req.config or '').strip()
if not config_text:
return JSONResponse({'error': 'Config is required'}, status_code=400)
if not req.client_id:
return JSONResponse({'error': 'Client ID is required'}, status_code=400)
ssh = get_ssh(server)
ssh.connect()
manager = get_protocol_manager(ssh, req.protocol)
_manager_call(manager, 'save_client_config', req.protocol, req.client_id, config_text)
ssh.disconnect()
return {'status': 'success', 'vpn_link': generate_vpn_link(config_text)}
except Exception as e:
logger.exception("Error saving connection config")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/connections/config', tags=["Connections"])
async def api_get_connection_config(request: Request, server_id: int, req: ConnectionActionRequest):
user = get_current_user(request)
+174 -44
View File
@@ -757,8 +757,25 @@ tail -f /dev/null
raise RuntimeError(f"Failed to get server config: {err}")
return out
@staticmethod
def _sanitize_server_config(config_content):
"""awg-quick chokes on a bare `DNS =` key in the server config (it calls
resolvconf, which is missing in the container, and the interface goes
down). Convert active `DNS = ...` lines into `# DNS = ...` comments:
the panel still reads them via _get_dns(), but quick-tools ignore them."""
lines = []
for line in config_content.split('\n'):
stripped = line.strip()
if stripped.startswith('DNS') and '=' in stripped and not stripped.startswith('#'):
indent = line[:len(line) - len(line.lstrip())]
lines.append(f"{indent}# {stripped}")
else:
lines.append(line)
return '\n'.join(lines)
def save_server_config(self, protocol_type, config_content):
"""Save the server WireGuard config and restart container."""
config_content = self._sanitize_server_config(config_content)
container_name = self._container_name(protocol_type)
config_path = self._resolve_config_path(protocol_type)
@@ -900,28 +917,78 @@ tail -f /dev/null
return ips
def _get_next_ip(self, protocol_type):
"""Calculate the next available IP for a new client."""
"""Return the first free IP in the subnet, filling gaps left by deleted clients.
The old implementation took the last IP in file order and incremented it,
which produced duplicate IPs when peers were not sorted by IP and never
reused addresses freed by deleted clients.
"""
used_ips = self._get_used_ips(protocol_type)
if not used_ips:
base = self._get_subnet_base(protocol_type)
parts = base.split('.')
parts[3] = '2'
return '.'.join(parts)
base = self._get_subnet_base(protocol_type)
parts = base.split('.')
prefix = '.'.join(parts[:3])
# Get the last used IP and increment
last_ip = used_ips[-1]
parts = last_ip.split('.')
last_octet = int(parts[3])
used_octets = set()
for ip in used_ips:
ip_parts = ip.split('.')
if len(ip_parts) != 4 or '.'.join(ip_parts[:3]) != prefix:
continue
try:
used_octets.add(int(ip_parts[3]))
except ValueError:
continue
if last_octet == 254:
next_octet = last_octet + 3
elif last_octet == 255:
next_octet = last_octet + 2
else:
next_octet = last_octet + 1
for octet in range(2, 255):
if octet not in used_octets:
parts[3] = str(octet)
return '.'.join(parts)
parts[3] = str(next_octet)
return '.'.join(parts)
raise RuntimeError("No free IP addresses left in the subnet")
@staticmethod
def _peer_block_ip(block):
"""Sort key for a [Peer] config block: its first AllowedIPs IPv4 address."""
match = re.search(r'AllowedIPs\s*=\s*(\d+)\.(\d+)\.(\d+)\.(\d+)', block)
if match:
return tuple(int(match.group(i)) for i in range(1, 5))
return (255, 255, 255, 255)
def _insert_peer_sorted(self, protocol_type, peer_section):
"""Insert a new [Peer] section into the server config keeping peers sorted by IP.
Creates a timestamped backup of the config inside the container before
overwriting it, then rewrites the file with all [Peer] sections ordered
by their AllowedIPs address.
"""
container_name = self._container_name(protocol_type)
config_path = self._resolve_config_path(protocol_type)
config = self._get_server_config(protocol_type)
# Backup current config inside the container before modifying it
ts = __import__('datetime').datetime.now().strftime('%Y%m%d_%H%M%S')
self.ssh.run_sudo_command(
f"docker exec -i {container_name} cp {config_path} {config_path}.bak.{ts}"
)
head, _, rest = config.partition('[Peer]')
blocks = []
if rest:
for chunk in rest.split('[Peer]'):
chunk = chunk.strip()
if chunk:
blocks.append('[Peer]\n' + chunk)
blocks.append(peer_section.strip())
blocks.sort(key=self._peer_block_ip)
new_config = head.rstrip('\n') + '\n\n' + '\n\n'.join(blocks) + '\n'
self.ssh.upload_file(new_config, "/tmp/_amnz_add_peer.conf")
self.ssh.run_sudo_command(
f"docker cp /tmp/_amnz_add_peer.conf {container_name}:{config_path}"
)
self.ssh.run_command("rm -f /tmp/_amnz_add_peer.conf")
def _extract_ipv4(self, value):
"""Extract the first IPv4 address from AllowedIPs/clientIp-like values."""
@@ -1104,11 +1171,8 @@ PresharedKey = {psk}
AllowedIPs = {allowed_ips}
"""
# Append peer to server config
escaped_peer = peer_section.replace("'", "'\\''")
self.ssh.run_sudo_command(
f"docker exec -i {container_name} bash -c 'echo \"{escaped_peer}\" >> {config_path}'"
)
# Insert peer into server config, keeping peers sorted by IP (with backup)
self._insert_peer_sorted(protocol_type, peer_section)
# Sync config without restart
self.ssh.run_sudo_command(
@@ -1138,22 +1202,16 @@ AllowedIPs = {allowed_ips}
if awg_params.get('port'):
port = awg_params['port']
dns1 = AWG_DEFAULTS['dns1']
dns2 = AWG_DEFAULTS['dns2']
# Check if AmneziaDNS is installed
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
if 'amnezia-dns' in out:
dns1 = '172.29.172.254'
dns = self._get_dns(protocol_type)
mtu = AWG_DEFAULTS['mtu']
# Standard fields (dual-stack when the client has an IPv6 address)
address_line = f"{client_ip}/32" + (f", {client_ipv6}/128" if client_ipv6 else "")
dns_line = f"{dns1}, {dns2}" + (", 2606:4700:4700::1111" if client_ipv6 else "")
config_lines = [
f"Address = {address_line}",
f"DNS = {dns_line}",
f"Address = {client_ip}/32",
f"DNS = {dns}",
f"PrivateKey = {client_priv_key}",
f"MTU = {mtu}"
]
@@ -1217,6 +1275,8 @@ PersistentKeepalive = 25
raise RuntimeError(f"Client {client_id} not found")
ud = client.get('userData', {})
if ud.get('customConfig'):
return ud['customConfig']
client_priv_key = ud.get('clientPrivateKey', '')
client_ip = ud.get('clientIp', '')
psk = ud.get('psk', '')
@@ -1234,22 +1294,16 @@ PersistentKeepalive = 25
if awg_params.get('port'):
port = awg_params['port']
dns1 = AWG_DEFAULTS['dns1']
dns2 = AWG_DEFAULTS['dns2']
# Check if AmneziaDNS is installed
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
if 'amnezia-dns' in out:
dns1 = '172.29.172.254'
dns = self._get_dns(protocol_type, ud)
mtu = AWG_DEFAULTS['mtu']
# Standard fields (dual-stack when the client has an IPv6 address)
address_line = f"{client_ip}/32" + (f", {client_ipv6}/128" if client_ipv6 else "")
dns_line = f"{dns1}, {dns2}" + (", 2606:4700:4700::1111" if client_ipv6 else "")
config_lines = [
f"Address = {address_line}",
f"DNS = {dns_line}",
f"Address = {client_ip}/32",
f"DNS = {dns}",
f"PrivateKey = {client_priv_key}",
f"MTU = {mtu}"
]
@@ -1453,6 +1507,82 @@ AllowedIPs = {allowed_ips}
return True
def _get_dns(self, protocol_type, user_data=None):
"""DNS servers for generated client configs.
Priority: per-client override (userData.dns, set by saving an edited
config) > `DNS = ...` line in the server config (wg-quick-style key,
stripped by awg-quick so it does not affect syncconf) > AmneziaDNS
container address > built-in defaults.
"""
if user_data and user_data.get('dns'):
return user_data['dns']
try:
server_config = self._get_server_config(protocol_type)
for line in server_config.split('\n'):
stripped = line.strip()
if stripped.startswith('#'):
stripped = stripped.lstrip('#').strip()
if stripped.startswith('DNS') and '=' in stripped:
return stripped.split('=', 1)[1].strip()
except Exception:
pass
dns1 = AWG_DEFAULTS['dns1']
dns2 = AWG_DEFAULTS['dns2']
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
if 'amnezia-dns' in out:
dns1 = '172.29.172.254'
return f"{dns1}, {dns2}"
def save_client_config(self, protocol_type, client_id, config_text):
"""Persist a manually edited client config. Stored verbatim in
clientsTable (userData.customConfig) and returned by get_client_config
from now on; the DNS line is indexed into userData.dns as the
per-client override for future regenerations."""
config_text = (config_text or '').strip()
if not config_text:
raise RuntimeError('Config is empty')
clients_table = self._get_clients_table(protocol_type)
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
if client is None:
raise RuntimeError('Client not found')
ud = client.setdefault('userData', {})
ud['customConfig'] = config_text
ud.pop('dns', None)
for line in config_text.split('\n'):
stripped = line.strip()
if stripped.startswith('DNS') and '=' in stripped:
ud['dns'] = stripped.split('=', 1)[1].strip()
break
self._save_clients_table(protocol_type, clients_table)
return {'status': 'success'}
def rename_client(self, protocol_type, client_id, new_name):
"""Rename a client. The name lives only in the clientsTable
(userData.clientName); keys, IPs and the WireGuard config itself
are untouched, so existing configs keep working."""
clients_table = self._get_clients_table(protocol_type)
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
if client is None:
# Peer added via the native Amnezia app is not in the table yet —
# persist a minimal entry so the chosen name sticks.
conf_peers = self._parse_peers_from_config(protocol_type)
if client_id not in conf_peers:
raise RuntimeError('Client not found')
client = {
'clientId': client_id,
'userData': {
'clientName': new_name,
'clientPrivateKey': '',
'externalClient': True,
}
}
clients_table.append(client)
else:
client.setdefault('userData', {})['clientName'] = new_name
self._save_clients_table(protocol_type, clients_table)
return {'status': 'success', 'name': new_name}
def get_server_status(self, protocol_type):
"""Get detailed status of the AWG server."""
container_name = self._container_name(protocol_type)
@@ -1479,4 +1609,4 @@ AllowedIPs = {allowed_ips}
except Exception as e:
info['error'] = str(e)
return info
return info
+50 -3
View File
@@ -392,7 +392,7 @@ docker compose version
api_payload['max_tcp_conns'] = val
# Save config to host
self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), f"{self._config_path()}")
self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), f"{self._config_path()}")
# 2. Call API for immediate effect
self._api_request("POST", "/v1/users", data=api_payload)
@@ -449,7 +449,7 @@ docker compose version
api_payload['max_tcp_conns'] = val
# Save config to host
self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), f"{self._config_path()}")
self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), f"{self._config_path()}")
# API call
self._api_request("PATCH", f"/v1/users/{client_id}", data=api_payload)
@@ -554,6 +554,53 @@ docker compose version
if restart:
self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}")
def rename_client(self, protocol_type, client_id, new_name):
"""Rename a Telemt user. The username *is* the client identity here,
so every `username = ...` line in all access.* sections is rewritten
(preserving the `#` disabled marker) and the runtime API user is
re-created under the new name with the same secret. Proxy links are
secret-based, so existing links keep working."""
new_username = re.sub(r'[^a-zA-Z0-9_.-]', '', (new_name or '').replace(' ', '_'))
if not new_username:
raise RuntimeError('Invalid name')
if new_username == client_id:
return {'status': 'success', 'name': new_username}
config_text = self._get_server_config()
users = self._parse_users_from_config(config_text)
entry_name = next((u for u in users if u.lstrip('#').strip() == client_id), None)
if entry_name is None:
raise RuntimeError('Client not found')
if any(u.lstrip('#').strip() == new_username for u in users):
raise RuntimeError('A client with this name already exists')
secret = users[entry_name]
enabled = not entry_name.startswith('#')
lines = config_text.split('\n')
in_access_section = False
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith('[access.'):
in_access_section = True
elif stripped.startswith('['):
in_access_section = False
if not in_access_section:
continue
commented = stripped.startswith('#')
content = stripped.lstrip('#').strip()
if content.startswith(f'{client_id} =') or content.startswith(f'{client_id}='):
new_line = content.replace(client_id, new_username, 1)
lines[i] = f'# {new_line}' if commented else new_line
self.ssh.upload_file_sudo('\n'.join(lines).replace('\r\n', '\n'), f"{self._config_path()}")
# Sync the runtime: the API keys users by username, so swap the identity.
self._api_request("DELETE", f"/v1/users/{client_id}")
if enabled:
self._api_request("POST", "/v1/users", data={"username": new_username, "secret": secret})
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}
def get_client_config(self, protocol_type, client_id, host='', port=''):
resp = self._api_request("GET", f"/v1/users/{client_id}")
if resp and resp.get('ok'):
@@ -568,4 +615,4 @@ docker compose version
if c:
secret = c.get('userData', {}).get('token', '')
if secret: return f"tg://proxy?server={host}&port={port}&secret={secret}"
return "Not found"
return "Not found"
+166 -38
View File
@@ -376,8 +376,24 @@ tail -f /dev/null
raise RuntimeError(f"Failed to get server config: {err}")
return out
@staticmethod
def _sanitize_server_config(config_content):
"""wg-quick chokes on a bare `DNS =` key in the server config (it calls
resolvconf, which is missing in the container). Convert active
`DNS = ...` lines into `# DNS = ...` comments (see AWGManager)."""
lines = []
for line in config_content.split('\n'):
stripped = line.strip()
if stripped.startswith('DNS') and '=' in stripped and not stripped.startswith('#'):
indent = line[:len(line) - len(line.lstrip())]
lines.append(f"{indent}# {stripped}")
else:
lines.append(line)
return '\n'.join(lines)
def save_server_config(self, config_content):
"""Save the server WireGuard config and restart container."""
config_content = self._sanitize_server_config(config_content)
self.ssh.upload_file(config_content.replace('\r\n', '\n'), "/tmp/_wg_edit_config.conf")
self.ssh.run_sudo_command(f"docker cp /tmp/_wg_edit_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}")
self.ssh.run_command("rm -f /tmp/_wg_edit_config.conf")
@@ -426,22 +442,75 @@ tail -f /dev/null
return ips
def _get_next_ip(self):
"""Calculate the next available IP for a new client."""
used_ips = self._get_used_ips()
if not used_ips:
base = WG_DEFAULTS['subnet_address']
parts = base.split('.')
parts[3] = '2'
return '.'.join(parts)
"""Return the first free IP in the subnet, filling gaps left by deleted clients.
last_ip = used_ips[-1]
parts = last_ip.split('.')
last_octet = int(parts[3])
next_octet = last_octet + 1
if next_octet > 254:
next_octet = 2
parts[3] = str(next_octet)
return '.'.join(parts)
The old implementation took the last IP in file order and incremented it,
which produced duplicate IPs when peers were not sorted by IP and never
reused addresses freed by deleted clients.
"""
used_ips = self._get_used_ips()
base = WG_DEFAULTS['subnet_address']
parts = base.split('.')
prefix = '.'.join(parts[:3])
used_octets = set()
for ip in used_ips:
ip_parts = ip.split('.')
if len(ip_parts) != 4 or '.'.join(ip_parts[:3]) != prefix:
continue
try:
used_octets.add(int(ip_parts[3]))
except ValueError:
continue
for octet in range(2, 255):
if octet not in used_octets:
parts[3] = str(octet)
return '.'.join(parts)
raise RuntimeError("No free IP addresses left in the subnet")
@staticmethod
def _peer_block_ip(block):
"""Sort key for a [Peer] config block: its first AllowedIPs IPv4 address."""
match = re.search(r'AllowedIPs\s*=\s*(\d+)\.(\d+)\.(\d+)\.(\d+)', block)
if match:
return tuple(int(match.group(i)) for i in range(1, 5))
return (255, 255, 255, 255)
def _insert_peer_sorted(self, peer_section):
"""Insert a new [Peer] section into the server config keeping peers sorted by IP.
Creates a timestamped backup of the config inside the container before
overwriting it, then rewrites the file with all [Peer] sections ordered
by their AllowedIPs address.
"""
config = self._get_server_config()
# Backup current config inside the container before modifying it
ts = __import__('datetime').datetime.now().strftime('%Y%m%d_%H%M%S')
self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} cp {self.CONFIG_PATH} {self.CONFIG_PATH}.bak.{ts}"
)
head, _, rest = config.partition('[Peer]')
blocks = []
if rest:
for chunk in rest.split('[Peer]'):
chunk = chunk.strip()
if chunk:
blocks.append('[Peer]\n' + chunk)
blocks.append(peer_section.strip())
blocks.sort(key=self._peer_block_ip)
new_config = head.rstrip('\n') + '\n\n' + '\n\n'.join(blocks) + '\n'
self.ssh.upload_file(new_config, "/tmp/_wg_add_peer.conf")
self.ssh.run_sudo_command(
f"docker cp /tmp/_wg_add_peer.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}"
)
self.ssh.run_command("rm -f /tmp/_wg_add_peer.conf")
def _parse_peers_from_config(self):
"""Parse [Peer] sections from WireGuard server config."""
@@ -583,17 +652,10 @@ tail -f /dev/null
# Get next available IP
client_ip = self._get_next_ip()
dns1 = WG_DEFAULTS['dns1']
dns2 = WG_DEFAULTS['dns2']
# Check if AmneziaDNS is installed
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
if 'amnezia-dns' in out:
dns1 = '172.29.172.254'
dns = self._get_dns()
mtu = WG_DEFAULTS['mtu']
# Append peer to server config
peer_section = f"""
[Peer]
PublicKey = {client_pub_key}
@@ -601,10 +663,8 @@ PresharedKey = {psk}
AllowedIPs = {client_ip}/32
"""
escaped_peer = peer_section.replace("'", "'\\''")
self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} bash -c 'echo \"{escaped_peer}\" >> {self.CONFIG_PATH}'"
)
# Insert peer into server config, keeping peers sorted by IP (with backup)
self._insert_peer_sorted(peer_section)
# Sync config without restart
self.ssh.run_sudo_command(
@@ -630,7 +690,7 @@ AllowedIPs = {client_ip}/32
# Build client config
client_config = f"""[Interface]
Address = {client_ip}/32
DNS = {dns1}, {dns2}
DNS = {dns}
PrivateKey = {client_priv_key}
MTU = {mtu}
@@ -656,6 +716,8 @@ PersistentKeepalive = 25
raise RuntimeError(f"Client {client_id} not found")
ud = client.get('userData', {})
if ud.get('customConfig'):
return ud['customConfig']
client_priv_key = ud.get('clientPrivateKey', '')
client_ip = ud.get('clientIp', '')
psk = ud.get('psk', '')
@@ -669,19 +731,13 @@ PersistentKeepalive = 25
port = self._get_listen_port()
dns1 = WG_DEFAULTS['dns1']
dns2 = WG_DEFAULTS['dns2']
# Check if AmneziaDNS is installed
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
if 'amnezia-dns' in out:
dns1 = '172.29.172.254'
dns = self._get_dns(ud)
mtu = WG_DEFAULTS['mtu']
config = f"""[Interface]
Address = {client_ip}/32
DNS = {dns1}, {dns2}
DNS = {dns}
PrivateKey = {client_priv_key}
MTU = {mtu}
@@ -767,6 +823,78 @@ AllowedIPs = {client_ip}/32
self._save_clients_table(clients_table)
return True
def _get_dns(self, user_data=None):
"""DNS servers for generated client configs.
Priority: per-client override (userData.dns) > `DNS = ...` line in the
server config > AmneziaDNS container address > built-in defaults.
"""
if user_data and user_data.get('dns'):
return user_data['dns']
try:
server_config = self._get_server_config()
for line in server_config.split('\n'):
stripped = line.strip()
if stripped.startswith('#'):
stripped = stripped.lstrip('#').strip()
if stripped.startswith('DNS') and '=' in stripped:
return stripped.split('=', 1)[1].strip()
except Exception:
pass
dns1 = WG_DEFAULTS['dns1']
dns2 = WG_DEFAULTS['dns2']
out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'")
if 'amnezia-dns' in out:
dns1 = '172.29.172.254'
return f"{dns1}, {dns2}"
def save_client_config(self, client_id, config_text):
"""Persist a manually edited client config in clientsTable
(userData.customConfig + userData.dns override)."""
config_text = (config_text or '').strip()
if not config_text:
raise RuntimeError('Config is empty')
clients_table = self._get_clients_table()
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
if client is None:
raise RuntimeError('Client not found')
ud = client.setdefault('userData', {})
ud['customConfig'] = config_text
ud.pop('dns', None)
for line in config_text.split('\n'):
stripped = line.strip()
if stripped.startswith('DNS') and '=' in stripped:
ud['dns'] = stripped.split('=', 1)[1].strip()
break
self._save_clients_table(clients_table)
return {'status': 'success'}
def rename_client(self, client_id, new_name):
"""Rename a client. The name lives only in the clientsTable
(userData.clientName); keys, IPs and the WireGuard config itself
are untouched, so existing configs keep working."""
clients_table = self._get_clients_table()
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
if client is None:
# Peer added via the native app is not in the table yet —
# persist a minimal entry so the chosen name sticks.
conf_peers = self._parse_peers_from_config()
if client_id not in conf_peers:
raise RuntimeError('Client not found')
client = {
'clientId': client_id,
'userData': {
'clientName': new_name,
'clientPrivateKey': '',
'externalClient': True,
}
}
clients_table.append(client)
else:
client.setdefault('userData', {})['clientName'] = new_name
self._save_clients_table(clients_table)
return {'status': 'success', 'name': new_name}
def get_server_status(self):
"""Get detailed status of the WireGuard server."""
info = {
+17 -1
View File
@@ -400,9 +400,12 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
tag = self._get_vless_inbound_tag(config)
if not tag:
return False
_ib = self._get_vless_inbound(config) or {}
payload = {
"inbounds": [{
"tag": tag,
"port": _ib.get('port', 443),
"listen": "0.0.0.0",
"protocol": "vless",
"settings": {
"clients": [client],
@@ -411,7 +414,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
}]
}
ok, message = self._run_xray_api_json('adu', payload)
if not ok:
if not ok or 'Added 0 user' in (message or ''):
logger.warning(f"Xray API add user failed: {message}")
return False
return True
@@ -798,3 +801,16 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
clients_table = [c for c in clients_table if c['clientId'] != client_id]
self._save_clients_table(clients_table)
return True
def rename_client(self, protocol, client_id, new_name):
"""Rename a client. The display name lives in clientsTable
(userData.clientName) and is embedded into the VLESS URL fragment on
the fly, so updating the table is enough — UUID, keys and server.json
stay untouched and existing links keep working."""
clients_table = self._get_clients_table()
client = next((c for c in clients_table if c.get('clientId') == client_id), None)
if client is None:
raise RuntimeError('Client not found')
client.setdefault('userData', {})['clientName'] = new_name
self._save_clients_table(clients_table)
return {'status': 'success', 'name': new_name}
+87 -7
View File
@@ -848,13 +848,16 @@
<!-- Config Panel -->
<div class="config-panel active" id="panel-conf">
<div class="config-display">
<div class="config-text" id="configText"></div>
<textarea class="config-text" id="configText" spellcheck="false" style="width:100%;min-height:220px;resize:vertical;font-family:monospace;"></textarea>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('configText').value)" style="flex:1">
{{ _('copy') }}
</button>
<button class="btn btn-primary btn-sm" id="saveConnConfigBtn" onclick="saveConnectionConfig()" style="flex:1">
{{ _('save') }}
</button>
<button class="btn btn-primary btn-sm"
onclick="downloadFile(currentConfig, currentConfigName + '.conf')" style="flex:1">
onclick="downloadFile(document.getElementById('configText').value, currentConfigName + '.conf')" style="flex:1">
{{ _('download_conf') }}
</button>
</div>
@@ -950,6 +953,7 @@
let currentConfig = '';
let currentVpnLink = '';
let currentConfigName = 'connection';
let currentConfigClientId = null;
let installedProtocols = {};
let currentProtocolStatus = {};
let installedAppsLoading = true;
@@ -2063,8 +2067,9 @@
</div>
</div>
<div class="client-actions">
<button class="btn btn-secondary btn-sm btn-icon" onclick="startRenameConnection('${escapeJs(client.clientId)}', this)" title="${_('rename')}">✏️</button>
<button class="btn btn-secondary btn-sm btn-icon" onclick="showConnectionConfig('${escapeJs(client.clientId)}', '${escapeJs(name)}', ${!!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt'})" title="${_('config')}">📄</button>
${proto === 'telemt' ? `<button class="btn btn-secondary btn-sm btn-icon" onclick="editConnection('${escapeJs(client.clientId)}')" title="${_('edit')}">✏️</button>` : ''}
${proto === 'telemt' ? `<button class="btn btn-secondary btn-sm btn-icon" onclick="editConnection('${escapeJs(client.clientId)}')" title="${_('edit')}">⚙️</button>` : ''}
<button class="btn btn-secondary btn-sm btn-icon" onclick="toggleConnection('${escapeJs(client.clientId)}', ${!enabled})" title="${toggleTitle}">${toggleIcon}</button>
<button class="btn btn-danger btn-sm btn-icon" onclick="removeConnection('${escapeJs(client.clientId)}')" title="${_('delete')}">🗑</button>
</div>
@@ -2112,8 +2117,9 @@
currentConfig = result.config;
currentVpnLink = result.vpn_link || '';
currentConfigName = connName.replace(/\s+/g, '_');
currentConfigClientId = result.client_id || null;
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
document.getElementById('configText').textContent = result.config;
const cfgTa = document.getElementById('configText'); cfgTa.value = result.config; cfgTa.readOnly = false;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : '');
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : '');
@@ -2190,8 +2196,81 @@
}
}
function startRenameConnection(clientId, btn) {
const item = btn.closest('.client-item');
const nameEl = item.querySelector('.client-name');
const oldName = nameEl.textContent;
nameEl.innerHTML = `
<span class="rename-editor" style="display:inline-flex;gap:4px;align-items:center;">
<input type="text" class="form-input" style="padding:2px 8px;font-size:0.85rem;width:160px;height:auto;" value="${escapeHtml(oldName)}">
<button class="btn btn-secondary btn-sm btn-icon" title="${_('save')}">✔</button>
<button class="btn btn-secondary btn-sm btn-icon rename-cancel" title="${_('cancel')}">✖</button>
</span>
`;
const input = nameEl.querySelector('input');
const saveBtn = nameEl.querySelector('button:not(.rename-cancel)');
const cancelBtn = nameEl.querySelector('.rename-cancel');
saveBtn.addEventListener('click', () => saveRenameConnection(clientId, input.value));
cancelBtn.addEventListener('click', () => loadConnections());
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') saveRenameConnection(clientId, input.value);
if (e.key === 'Escape') loadConnections();
});
input.focus();
input.select();
}
async function saveRenameConnection(clientId, rawName) {
const proto = document.getElementById('connProtoSelect').value;
const newName = (rawName || '').trim();
if (!newName) {
showToast(_('enter_connection_name'), 'error');
return;
}
try {
const result = await apiCall(`/api/servers/${SERVER_ID}/connections/rename`, 'POST', {
protocol: proto, client_id: clientId, new_name: newName,
});
showToast(_('connection_renamed'), 'success');
// If the config modal is open for this connection, retitle it too.
if (currentConfigClientId === clientId &&
document.getElementById('configModal').classList.contains('active')) {
document.getElementById('configModalTitle').textContent = `${_('config')} — ${result.name}`;
currentConfigName = result.name.replace(/\s+/g, '_');
}
loadConnections();
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
}
}
async function saveConnectionConfig() {
const proto = document.getElementById('connProtoSelect').value;
const text = document.getElementById('configText').value;
if (!text.trim()) {
showToast(_('error'), 'error');
return;
}
try {
const result = await apiCall(`/api/servers/${SERVER_ID}/connections/config/save`, 'POST', {
protocol: proto, client_id: currentConfigClientId, config: text,
});
currentConfig = text;
currentVpnLink = result.vpn_link || '';
document.getElementById('vpnLinkText').textContent = currentVpnLink;
generateQR(text);
showToast(_('config_saved'), 'success');
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
}
}
async function showConnectionConfig(clientId, connName, hasKey) {
const proto = document.getElementById('connProtoSelect').value;
currentConfigClientId = clientId;
const canEditCfg = ['awg', 'awg2', 'awg_legacy', 'wireguard'].includes(protoBase(proto));
document.getElementById('saveConnConfigBtn').style.display = canEditCfg ? '' : 'none';
document.getElementById('configText').readOnly = !canEditCfg;
// Restore tabs visibility first
document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : '');
@@ -2203,9 +2282,10 @@
if (!hasKey) {
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
currentConfig = '';
document.getElementById('saveConnConfigBtn').style.display = 'none';
currentVpnLink = '';
currentConfigName = connName.replace(/\s+/g, '_');
document.getElementById('configText').innerHTML = `<div style="padding:var(--space-md);background:var(--bg-primary);border-radius:var(--radius-md);border:1px solid var(--border-color);"><div style="font-size:1.4rem;margin-bottom:var(--space-sm);">⚠️</div><div style="font-weight:600;margin-bottom:var(--space-sm);">${_('config_unavailable')}</div><div style="font-size:0.85rem;color:var(--text-muted);line-height:1.6;">${_('config_unavailable_desc').replace(/\\n/g, '<br>')}<br><br><strong>${_('client_public_key')}</strong><br><code style="word-break:break-all;font-size:0.78rem;">${escapeHtml(clientId)}</code></div></div>`;
const cfgTa = document.getElementById('configText'); cfgTa.value = `${_('config_unavailable')}\n\n${_('client_public_key')}: ${clientId}`; cfgTa.readOnly = true;
document.getElementById('vpnLinkText').textContent = '';
document.getElementById('panel-vpn').style.display = 'none';
document.getElementById('panel-qr').style.display = 'none';
@@ -2226,7 +2306,7 @@
currentVpnLink = result.vpn_link || '';
currentConfigName = connName.replace(/\s+/g, '_');
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
document.getElementById('configText').textContent = result.config;
const cfgTa = document.getElementById('configText'); cfgTa.value = result.config; cfgTa.readOnly = false;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
switchConfigTab('conf');
openModal('configModal');
+6 -2
View File
@@ -419,5 +419,9 @@
"backup_created": "Backup created",
"no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.",
"refresh": "Refresh"
}
"refresh": "Refresh",
"rename": "Rename",
"connection_renamed": "Connection renamed",
"enter_connection_name": "Enter a new connection name",
"config_saved": "Config saved"
}
+6 -2
View File
@@ -386,5 +386,9 @@
"backup_created": "Backup created",
"no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.",
"refresh": "Refresh"
}
"refresh": "Refresh",
"rename": "تغییر نام",
"connection_renamed": "اتصال تغییر نام یافت",
"enter_connection_name": "نام جدید اتصال را وارد کنید",
"config_saved": "پیکربندی ذخیره شد"
}
+6 -2
View File
@@ -386,5 +386,9 @@
"backup_created": "Backup created",
"no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.",
"refresh": "Refresh"
}
"refresh": "Refresh",
"rename": "Renommer",
"connection_renamed": "Connexion renommée",
"enter_connection_name": "Saisir un nouveau nom de connexion",
"config_saved": "Configuration enregistrée"
}
+6 -2
View File
@@ -419,5 +419,9 @@
"backup_created": "Бекап создан",
"no_backups": "Бекапов пока нет",
"no_backups_desc": "Создайте первый бекап для этого протокола.",
"refresh": "Обновить"
}
"refresh": "Обновить",
"rename": "Переименовать",
"connection_renamed": "Подключение переименовано",
"enter_connection_name": "Введите новое имя подключения",
"config_saved": "Конфиг сохранён"
}
+6 -2
View File
@@ -386,5 +386,9 @@
"backup_created": "Backup created",
"no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.",
"refresh": "Refresh"
}
"refresh": "Refresh",
"rename": "重命名",
"connection_renamed": "连接已重命名",
"enter_connection_name": "请输入新的连接名称",
"config_saved": "配置已保存"
}