mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-09-18 01:44:10 +02:00
Merge branch 'main' into feature/awg-ipv6
This commit is contained in:
committed by
GitHub
commit
facef95fb4
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "پیکربندی ذخیره شد"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -419,5 +419,9 @@
|
||||
"backup_created": "Бекап создан",
|
||||
"no_backups": "Бекапов пока нет",
|
||||
"no_backups_desc": "Создайте первый бекап для этого протокола.",
|
||||
"refresh": "Обновить"
|
||||
}
|
||||
"refresh": "Обновить",
|
||||
"rename": "Переименовать",
|
||||
"connection_renamed": "Подключение переименовано",
|
||||
"enter_connection_name": "Введите новое имя подключения",
|
||||
"config_saved": "Конфиг сохранён"
|
||||
}
|
||||
@@ -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": "配置已保存"
|
||||
}
|
||||
Reference in New Issue
Block a user