mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-10-23 03:37:36 +09:00
feat(exit): route client traffic through another panel server
Today a client leaves to the internet from the same server it connected to.
Exit nodes split the two: clients connect to an *entry* node (reachable,
possibly behind DPI) and egress from an *exit* node (clean IP, other country).
One exit serves many entries and keeps serving its own AWG clients.
client --AWG--> [entry] amnezia-awg2 --WG/AWG link--> [exit] amnezia-exit --> internet
awg0 10.8.1.0/24 transit0 10.9.0.0/24
exit0 10.9.0.N (Table=off) one [Peer] per entry, /32
Exit side: a new protocol `exit` = container `amnezia-exit`, an AmneziaWG
listener on 55520/udp over the transit subnet 10.9.0.0/24. Peers are panel
nodes, never end users, and live in their own peersTable. ExitManager
subclasses AWGManager and closes every client-facing method.
Entry side: a second interface `exit0` inside the existing AWG container with
`Table = off`, plus policy routing - `from <client subnet> lookup 200`,
`default dev exit0` in table 200, and `blackhole default metric 1000` as a
kill-switch installed *before* the client tunnel comes up, so a container
restart cannot leak. Clients are MASQUERADEd into the entry's transit /32, so
the identical 10.8.1.0/24 of every instance can never collide at the exit.
TCP MSS is clamped, and rules 190/191 keep client<->client and AmneziaDNS
traffic local. State is file-driven: exit0.conf present means linked, and the
same shell block runs at container start and on a live (un)link.
Link crypto is WireGuard; AWG obfuscation is a per-exit-node property for hops
that cross DPI, and S3/S4 are deliberately not written on the transit - they
pad every transport packet and would eat the MTU budget of a tunnel inside a
tunnel. Private keys are generated inside the containers; the panel carries
public keys and a per-peer PSK only.
exit_link_service.py orchestrates linking with per-(entry, protocol) locks,
SSH off the event loop, a 15-second handshake gate that rolls back, and
lifecycle hooks for every way a link can go stale: exit uninstalled, stopped,
deleted, host cleared, entry reinstalled, container gone, backup restored.
Servers get a stable `uid` (already upstream) so reorder and delete cannot
break a link.
IPv4 only for now: while linked, client IPv6 is refused with `unreachable`
rather than leaking out of the entry.
Also included: an egress check that curls a public IP service bound to the
tunnel gateway (the client's exact path) and compares it with the direct one;
optional DNS resolution at the exit node; a default exit node applied to new
AWG installs; and reconciliation after a protocol restore.
244 tests pass. CURRENT_VERSION is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
30 files changed
+4428
-35
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Amnezia Web Panel
|
||||
|
||||
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, and **NGINX + Let's Encrypt** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
|
||||
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, **NGINX + Let's Encrypt** and **exit nodes** (entry ≠ egress) services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
|
||||
|
||||
> ### 🔄 Compatibility with Official Amnezia Client
|
||||
>
|
||||
@@ -65,6 +65,7 @@ Configuration panel for system parameters and preferences:
|
||||
* **Xray (XTLS-Reality)**: Stealthy protocol that masks VPN traffic as standard HTTPS browsing. Pinned to **Xray-core v26.x**; transparently reads both the **panel layout** (`meta.json` + `clientsTable.json`) and the **native Amnezia client layout** (`xray_*.key` files + `clientsTable`), so a node first installed via the official mobile/desktop app can be attached to the panel without re-installation.
|
||||
* **Telemt (Telegram MTProxy)**: High-performance Telegram MTProxy with TLS emulation and comprehensive management (quotas, IP limits, real-time session tracking). Robust install path that auto-configures Docker's official apt/yum repository when needed.
|
||||
* **Cloudflare WARP**: Add and manage WARP-powered connectivity from the panel for routing and network flexibility.
|
||||
* **Exit nodes (entry ≠ egress)**: install the **Exit Node** service on the server that should be the egress (`amnezia-exit`, an AmneziaWG listener on `55520/udp` with a private transit subnet, optional obfuscation for hops crossing DPI), then link any AmneziaWG instance on another server to it from its card. The entry keeps its clients and their configs, SNATs them into its transit address and routes them through a second interface (`exit0`) inside the same container; a kill-switch is installed before the client tunnel comes up, so a dead exit blocks traffic instead of leaking the entry's IP. Links survive container restarts, server reorder and reinstalls of either side; the exit's Peers page shows handshake and transfer per entry. Open the transit UDP port for the entry nodes in the exit server's firewall. Settings can name a **default exit node**, which every AmneziaWG instance installed afterwards is linked to automatically. Restoring a protocol backup re-establishes the links the archive touched (and drops a link the panel does not track). Client DNS stays on the entry node by default; a switch on the link routes it through the exit instead, so a resolver never sees the entry's country (requires AmneziaDNS on the exit node). MTU chain: client 1376 → `exit0` 1420 → +60 bytes (IPv4 endpoint) ≤ 1500. IPv4 only for now: while linked, client IPv6 is refused rather than leaking.
|
||||
* **🛠 Services**:
|
||||
* **AmneziaDNS**: Internal DNS resolver on a private docker network (`amnezia-dns-net`, IP `172.29.172.254`) to prevent DNS leaks and blockings.
|
||||
* **AdGuard Home**: DNS-based ad blocker with a web admin UI. Two install modes: **Replace AmneziaDNS** (takes its IP, all VPN clients use AdGuard immediately) or **Side-by-side** (parallel deployment on `172.29.172.253`, web UI accessible only over the VPN by default). Optional opt-in checkboxes to expose the web UI / DoT / DoH on the host.
|
||||
@@ -306,7 +307,8 @@ web-panel/
|
||||
│ ├── telemt_manager.py # Telegram MTProxy
|
||||
│ ├── dns_manager.py # AmneziaDNS (Unbound)
|
||||
│ ├── adguard_manager.py # AdGuard Home
|
||||
│ └── socks5_manager.py # 3proxy-based SOCKS5
|
||||
│ ├── socks5_manager.py # 3proxy-based SOCKS5
|
||||
│ └── exit_manager.py # Exit-node transit endpoint (amnezia-exit)
|
||||
├── static/ # CSS / favicon / PWA icons / SW / vendored JS
|
||||
├── templates/ # Jinja2 templates
|
||||
├── translations/ # en / ru / fr / zh / fa
|
||||
|
||||
@@ -47,6 +47,7 @@ from managers.wireguard_manager import WireGuardManager
|
||||
from managers.backup_manager import BackupManager
|
||||
import telegram_bot as tg_bot
|
||||
|
||||
from exit_link_service import ExitLinkError, ExitLinkService
|
||||
from pwa import build_manifest
|
||||
from connection_service import (
|
||||
ConnectionService,
|
||||
@@ -66,7 +67,7 @@ OPENAPI_TAGS = [
|
||||
{"name": "System Templates", "description": "HTML pages served to browsers. These return Jinja-rendered templates rather than a JSON contract — they are not part of the public API and are listed here only for completeness."},
|
||||
{"name": "Authentication", "description": "Login, captcha, and session lifecycle."},
|
||||
{"name": "Servers", "description": "Server inventory, lifecycle and host-level operations (add, edit, delete, ping, reorder, reboot, clear, stats, status check)."},
|
||||
{"name": "Protocols", "description": "Install, uninstall, container start/stop and raw config editing for the protocols/services on a server (AWG, Xray, WireGuard, Telemt, AmneziaDNS, AdGuard Home, SOCKS5)."},
|
||||
{"name": "Protocols", "description": "Install, uninstall, container start/stop and raw config editing for the protocols/services on a server (AWG, Xray, WireGuard, Telemt, AmneziaDNS, AdGuard Home, SOCKS5, Exit node)."},
|
||||
{"name": "Connections", "description": "Per-protocol VPN client connections on a server (CRUD plus enable/disable and config retrieval)."},
|
||||
{"name": "Users", "description": "Panel user accounts and the connections assigned to them."},
|
||||
{"name": "Self-service", "description": "Endpoints called by a regular user for their own data (the /my surface)."},
|
||||
@@ -193,6 +194,7 @@ def load_data():
|
||||
'remnawave_protocol': 'awg'
|
||||
})
|
||||
settings.setdefault('captcha', {'enabled': False})
|
||||
settings.setdefault('exit_nodes', {'default_exit_uid': ''})
|
||||
settings.setdefault('telegram', {'token': '', 'enabled': False})
|
||||
settings.setdefault('ssl', {
|
||||
'enabled': False,
|
||||
@@ -932,7 +934,7 @@ async def wait_for_tunnel_url(provider: str, seconds: int = 20):
|
||||
return get_tunnel_status(provider)
|
||||
|
||||
|
||||
BASE_PROTOCOLS = ['awg', 'awg2', 'awg3', 'awg_legacy', 'xray', 'telemt', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx']
|
||||
BASE_PROTOCOLS = ['awg', 'awg2', 'awg3', 'awg_legacy', 'xray', 'telemt', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx', 'exit']
|
||||
MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg3', 'awg_legacy', 'xray', 'telemt', 'socks5'}
|
||||
|
||||
|
||||
@@ -949,6 +951,14 @@ def backfill_server_uids(servers) -> bool:
|
||||
return changed
|
||||
|
||||
|
||||
def should_link_default_exit(default_uid, server_uid, is_awg, reinstall, previous_link):
|
||||
"""Whether a just-installed instance should join the default exit node:
|
||||
only a newly added AWG instance on another server, and only when nothing
|
||||
(a link of its own, or an earlier deliberate unlink) already speaks for it."""
|
||||
return bool(default_uid) and is_awg and not reinstall and not previous_link \
|
||||
and default_uid != server_uid
|
||||
|
||||
|
||||
def find_server_by_uid(data, uid):
|
||||
"""Return (index, server) for a stable server uid, or (None, None) when the
|
||||
uid is empty or unknown. Index-based server_id values shift on reorder and
|
||||
@@ -1004,6 +1014,7 @@ def protocol_display_name(protocol: str) -> str:
|
||||
'socks5': 'SOCKS5',
|
||||
'adguard': 'AdGuard Home',
|
||||
'nginx': 'NGINX',
|
||||
'exit': 'Exit Node',
|
||||
}
|
||||
name = names.get(base, base)
|
||||
return name if idx <= 1 else f'{name} #{idx}'
|
||||
@@ -1024,6 +1035,7 @@ def protocol_container_name(protocol: str) -> Optional[str]:
|
||||
'socks5': 'amnezia-socks5proxy',
|
||||
'adguard': 'amnezia-adguard',
|
||||
'nginx': 'amnezia-nginx',
|
||||
'exit': 'amnezia-exit',
|
||||
}
|
||||
name = base_names.get(base)
|
||||
if not name:
|
||||
@@ -1058,6 +1070,9 @@ def get_protocol_manager(ssh, protocol: str):
|
||||
elif base == 'nginx':
|
||||
from managers.nginx_manager import NginxManager
|
||||
return NginxManager(ssh, protocol)
|
||||
elif base == 'exit':
|
||||
from managers.exit_manager import ExitManager
|
||||
return ExitManager(ssh, protocol)
|
||||
from managers.awg_manager import AWGManager
|
||||
return AWGManager(ssh)
|
||||
|
||||
@@ -1167,6 +1182,7 @@ def protocol_short_name(protocol: str) -> str:
|
||||
'dns': 'DNS',
|
||||
'adguard': 'AdGuard',
|
||||
'nginx': 'NGINX',
|
||||
'exit': 'Exit',
|
||||
}
|
||||
name = names.get(base, base.upper())
|
||||
return name if idx <= 1 else f'{name}#{idx}'
|
||||
@@ -1501,6 +1517,25 @@ self_service_connections = ConnectionService(
|
||||
)
|
||||
|
||||
|
||||
def _exit_manager_factory(ssh):
|
||||
from managers.exit_manager import ExitManager
|
||||
return ExitManager(ssh)
|
||||
|
||||
|
||||
exit_link_svc = ExitLinkService(
|
||||
load_data=load_data,
|
||||
save_data=save_data,
|
||||
data_lock=DATA_LOCK,
|
||||
get_ssh=get_ssh,
|
||||
awg_manager_factory=lambda ssh: AWGManager(ssh),
|
||||
exit_manager_factory=_exit_manager_factory,
|
||||
protocol_base=protocol_base,
|
||||
awg_protocols=AWG_PROTOCOLS,
|
||||
protocol_display_name=protocol_display_name,
|
||||
find_server_by_uid=find_server_by_uid,
|
||||
)
|
||||
|
||||
|
||||
def _self_service_error_response(exc):
|
||||
if isinstance(exc, RateLimitError):
|
||||
return JSONResponse({'error': str(exc)}, status_code=429)
|
||||
@@ -2005,6 +2040,9 @@ class InstallProtocolRequest(BaseModel):
|
||||
# NGINX
|
||||
nginx_domain: Optional[str] = None
|
||||
nginx_email: Optional[str] = None
|
||||
# Exit node
|
||||
exit_subnet: Optional[str] = None # transit subnet, default 10.9.0.0/24
|
||||
exit_obfuscation: Optional[bool] = None # AmneziaWG obfuscation on the transit link
|
||||
# AmneziaWG: values that end up in the generated client configs
|
||||
awg_mtu: Optional[str] = None
|
||||
awg_dns1: Optional[str] = None
|
||||
@@ -2028,6 +2066,30 @@ class AwgSettingsRequest(BaseModel):
|
||||
i5: Optional[str] = None
|
||||
|
||||
|
||||
class ExitPeerAddRequest(BaseModel):
|
||||
protocol: str = 'exit'
|
||||
peer_id: str = ''
|
||||
name: str = ''
|
||||
public_key: str = ''
|
||||
|
||||
|
||||
class ExitPeerRemoveRequest(BaseModel):
|
||||
protocol: str = 'exit'
|
||||
public_key: str = ''
|
||||
peer_id: str = ''
|
||||
|
||||
|
||||
class ExitLinkRequest(BaseModel):
|
||||
protocol: str = 'awg'
|
||||
exit_uid: str = ''
|
||||
force: Optional[bool] = False
|
||||
|
||||
|
||||
class ExitDnsRequest(BaseModel):
|
||||
protocol: str = 'awg'
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class Socks5SettingsRequest(BaseModel):
|
||||
protocol: str = 'socks5'
|
||||
port: Optional[int] = None
|
||||
@@ -2039,6 +2101,11 @@ class ProtocolRequest(BaseModel):
|
||||
protocol: str = 'awg'
|
||||
|
||||
|
||||
class ContainerToggleRequest(ProtocolRequest):
|
||||
# Stopping an exit node that entries route through needs an explicit yes
|
||||
force: Optional[bool] = False
|
||||
|
||||
|
||||
class WgEasyPreviewRequest(BaseModel):
|
||||
web_port: int = 51821
|
||||
password: str = ''
|
||||
@@ -2207,6 +2274,10 @@ class UpdateUserRequest(BaseModel):
|
||||
|
||||
|
||||
|
||||
class ExitNodesSettings(BaseModel):
|
||||
default_exit_uid: str = ''
|
||||
|
||||
|
||||
class SaveSettingsRequest(BaseModel):
|
||||
appearance: AppearanceSettings
|
||||
sync: SyncSettings
|
||||
@@ -2215,6 +2286,7 @@ class SaveSettingsRequest(BaseModel):
|
||||
ssl: SSLSettings
|
||||
auto_backup: AutoBackupSettings = AutoBackupSettings()
|
||||
self_service: SelfServiceSettings = SelfServiceSettings()
|
||||
exit_nodes: ExitNodesSettings = ExitNodesSettings()
|
||||
|
||||
|
||||
class ToggleUserRequest(BaseModel):
|
||||
@@ -2967,6 +3039,18 @@ async def api_delete_server(request: Request, server_id: int):
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
server = data['servers'][server_id]
|
||||
# Exit-node links: restore direct egress on entries routed through
|
||||
# this server, and drop this server's own peers on the exits it used.
|
||||
try:
|
||||
if (server.get('protocols') or {}).get('exit'):
|
||||
await exit_link_svc.detach_entries_for_exit(server.get('uid'), 'exit_server_deleted')
|
||||
await exit_link_svc.forget_entry_peers(server)
|
||||
except Exception as e:
|
||||
logger.warning(f"exit-link cleanup before delete failed: {e}")
|
||||
data = load_data()
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
server = data['servers'][server_id]
|
||||
drop_ssh(server)
|
||||
data['servers'].pop(server_id)
|
||||
# Clean up connections for this server
|
||||
@@ -3015,6 +3099,14 @@ async def api_clear_server(request: Request, server_id: int):
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
server = data['servers'][server_id]
|
||||
try:
|
||||
if (server.get('protocols') or {}).get('exit'):
|
||||
await exit_link_svc.detach_entries_for_exit(server.get('uid'), 'exit_cleared')
|
||||
await exit_link_svc.forget_entry_peers(server)
|
||||
except Exception as e:
|
||||
logger.warning(f"exit-link cleanup before clear failed: {e}")
|
||||
data = load_data()
|
||||
server = data['servers'][server_id]
|
||||
ssh = get_ssh(server)
|
||||
ssh.connect()
|
||||
# Match every Amnezia container by name prefix (catches awg/awg2/awg-legacy,
|
||||
@@ -3150,6 +3242,12 @@ async def api_check_server(request: Request, server_id: int):
|
||||
for key in ('domain', 'email', 'site_url'):
|
||||
if db_proto.get(key) not in (None, ''):
|
||||
merged[key] = db_proto[key]
|
||||
if protocol_base(proto) == 'exit':
|
||||
for key in ('subnet', 'public_key', 'obfuscation'):
|
||||
if db_proto.get(key) not in (None, ''):
|
||||
merged.setdefault(key, db_proto[key])
|
||||
if protocol_base(proto) in AWG_PROTOCOLS and db_proto.get('exit_link'):
|
||||
merged['exit_link'] = db_proto['exit_link']
|
||||
return merged
|
||||
|
||||
def should_preserve_saved_protocol(proto, result=None, err=None):
|
||||
@@ -3157,6 +3255,10 @@ async def api_check_server(request: Request, server_id: int):
|
||||
db_proto = server.get('protocols', {}).get(proto)
|
||||
if not db_proto:
|
||||
return False
|
||||
# An instance routed through an exit node keeps its record: the
|
||||
# exit still holds its peer and the admin needs Unlink/Repair.
|
||||
if db_proto.get('exit_link'):
|
||||
return True
|
||||
# Additional AWG-family instances are only known by their saved
|
||||
# dynamic keys (awg__2/awg2__2/awg_legacy__2). Keep them unless
|
||||
# the user explicitly uninstalls them.
|
||||
@@ -3172,6 +3274,12 @@ async def api_check_server(request: Request, server_id: int):
|
||||
try:
|
||||
p_manager = get_protocol_manager(ssh, proto)
|
||||
result = _manager_call(p_manager, 'get_server_status', proto)
|
||||
db_proto = server.get('protocols', {}).get(proto, {}) or {}
|
||||
if db_proto.get('exit_link') and result.get('container_running'):
|
||||
try:
|
||||
result['exit_link_status'] = AWGManager(ssh).exit_link_status(proto)
|
||||
except Exception as e:
|
||||
result['exit_link_status'] = {'up': False, 'error': str(e)}
|
||||
return proto, merge_saved_protocol_status(proto, result), None
|
||||
except Exception as e:
|
||||
return proto, merge_saved_protocol_status(proto, {}, str(e)), str(e)
|
||||
@@ -3209,6 +3317,12 @@ async def api_check_server(request: Request, server_id: int):
|
||||
'email': result.get('email'),
|
||||
'site_url': result.get('site_url'),
|
||||
})
|
||||
if protocol_base(proto) == 'exit':
|
||||
server['protocols'][proto].update({
|
||||
'subnet': result.get('subnet'),
|
||||
'public_key': result.get('public_key'),
|
||||
'obfuscation': result.get('obfuscation'),
|
||||
})
|
||||
changed = True
|
||||
else:
|
||||
if proto in server['protocols']:
|
||||
@@ -3217,6 +3331,11 @@ async def api_check_server(request: Request, server_id: int):
|
||||
status['protocols'][proto]['container_exists'] = True
|
||||
status['protocols'][proto].setdefault('container_running', False)
|
||||
status['protocols'][proto]['status_preserved'] = True
|
||||
link = (server['protocols'][proto] or {}).get('exit_link')
|
||||
if link and not err and not link.get('stale') and result and not result.get('container_exists'):
|
||||
# Container gone but the exit still has our peer
|
||||
link['stale'] = 'entry_container_missing'
|
||||
changed = True
|
||||
else:
|
||||
del server['protocols'][proto]
|
||||
changed = True
|
||||
@@ -3264,7 +3383,7 @@ def get_used_ports(ssh):
|
||||
# mapped to their transport. dns/adguard are skipped (internal bindings).
|
||||
INSTALL_PORT_TRANSPORT = {
|
||||
'awg': 'udp', 'awg2': 'udp', 'awg3': 'udp', 'awg_legacy': 'udp',
|
||||
'wireguard': 'udp',
|
||||
'wireguard': 'udp', 'exit': 'udp',
|
||||
'xray': 'tcp', 'telemt': 'tcp', 'socks5': 'tcp', 'nginx': 'tcp',
|
||||
}
|
||||
|
||||
@@ -3307,6 +3426,13 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
|
||||
else:
|
||||
install_protocol = req.protocol
|
||||
install_base = protocol_base(install_protocol)
|
||||
# A reinstalled entry keeps its exit link and is re-linked below
|
||||
previous_link = None
|
||||
# Reinstalling an instance is not the same as adding one: an instance a
|
||||
# user deliberately left unlinked must not be linked behind their back.
|
||||
reinstall = install_protocol in (server.get('protocols') or {})
|
||||
if install_base in AWG_PROTOCOLS:
|
||||
previous_link = ((server.get('protocols') or {}).get(install_protocol) or {}).get('exit_link')
|
||||
|
||||
awg_special_junk = awg_special_junk_from(req) if install_base in AWG_PROTOCOLS else None
|
||||
if awg_special_junk is not None:
|
||||
@@ -3374,6 +3500,13 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
|
||||
domain=req.nginx_domain,
|
||||
email=req.nginx_email,
|
||||
)
|
||||
elif install_base == 'exit':
|
||||
result = manager.install_protocol(
|
||||
protocol_type='exit',
|
||||
port=req.port,
|
||||
subnet=req.exit_subnet,
|
||||
obfuscation=bool(req.exit_obfuscation),
|
||||
)
|
||||
elif install_base in AWG_PROTOCOLS:
|
||||
result = manager.install_protocol(
|
||||
install_protocol,
|
||||
@@ -3412,10 +3545,18 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
|
||||
proto_record['domain'] = result.get('domain')
|
||||
proto_record['email'] = result.get('email')
|
||||
proto_record['site_url'] = result.get('site_url')
|
||||
if install_base == 'exit':
|
||||
# req.port may be empty: the manager applied the default
|
||||
proto_record['port'] = result.get('port') or req.port
|
||||
proto_record['subnet'] = result.get('subnet')
|
||||
proto_record['public_key'] = result.get('public_key')
|
||||
proto_record['obfuscation'] = result.get('obfuscation')
|
||||
proto_record['base_protocol'] = install_base
|
||||
proto_record['instance'] = protocol_instance(install_protocol)
|
||||
proto_record['display_name'] = protocol_display_name(install_protocol)
|
||||
proto_record['container_name'] = protocol_container_name(install_protocol)
|
||||
if previous_link:
|
||||
proto_record['exit_link'] = previous_link
|
||||
server['protocols'][install_protocol] = proto_record
|
||||
result['protocol'] = install_protocol
|
||||
result['base_protocol'] = install_base
|
||||
@@ -3423,6 +3564,39 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
|
||||
result['container_name'] = proto_record['container_name']
|
||||
save_data(data)
|
||||
ssh.disconnect()
|
||||
|
||||
# A new AWG instance joins the default exit node, when one is set.
|
||||
default_uid = ((data.get('settings', {}).get('exit_nodes') or {}).get('default_exit_uid') or '').strip()
|
||||
if should_link_default_exit(default_uid, server.get('uid'), install_base in AWG_PROTOCOLS,
|
||||
reinstall, previous_link):
|
||||
try:
|
||||
linked = await exit_link_svc.link(server_id, install_protocol, default_uid)
|
||||
result.setdefault('log', []).append(
|
||||
f"Linked to the default exit node {linked['exit_link']['exit_name']}")
|
||||
except Exception as e:
|
||||
# the instance is installed either way; the link is an extra
|
||||
logger.warning(f"default exit link after install failed: {e}")
|
||||
result.setdefault('log', []).append(f"! Could not link to the default exit node: {e}")
|
||||
|
||||
# Exit-node links survive reinstalls: bring them back now.
|
||||
if install_base == 'exit':
|
||||
for item in await exit_link_svc.relink_entries_for_exit(server.get('uid')):
|
||||
result.setdefault('log', []).append(
|
||||
f"Re-linked {item['name']}/{item['protocol']}" if item['status'] == 'success'
|
||||
else f"! Failed to re-link {item['name']}/{item['protocol']}: {item['error']}")
|
||||
elif previous_link:
|
||||
try:
|
||||
await exit_link_svc.relink_entry(server_id, install_protocol)
|
||||
result.setdefault('log', []).append(f"Re-linked to exit node {previous_link.get('exit_name')}")
|
||||
except Exception as e:
|
||||
logger.warning(f"re-link after reinstall failed: {e}")
|
||||
fresh = load_data()
|
||||
rec = (fresh['servers'][server_id].get('protocols') or {}).get(install_protocol) if server_id < len(fresh['servers']) else None
|
||||
if rec and rec.get('exit_link'):
|
||||
rec['exit_link']['stale'] = 'relink_failed'
|
||||
save_data(fresh)
|
||||
result.setdefault('log', []).append(
|
||||
f"! Could not re-link to exit node {previous_link.get('exit_name')}: {e}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("Error installing protocol")
|
||||
@@ -3487,6 +3661,190 @@ async def api_socks5_update_credentials(request: Request, server_id: int, req: S
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
def _exit_manager_for(ssh):
|
||||
from managers.exit_manager import ExitManager
|
||||
return ExitManager(ssh)
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/exit/peers', tags=["Protocols"])
|
||||
async def api_exit_peers(request: Request, server_id: int, req: ProtocolRequest):
|
||||
"""Exit node endpoint data (public key, port, transit subnet, obfuscation)
|
||||
and its peers - the entry nodes linked to it - with live handshake and
|
||||
transfer counters."""
|
||||
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)
|
||||
ssh = get_ssh(data['servers'][server_id])
|
||||
ssh.connect()
|
||||
try:
|
||||
manager = _exit_manager_for(ssh)
|
||||
info = manager.get_info()
|
||||
peers = manager.list_peers()
|
||||
finally:
|
||||
ssh.disconnect()
|
||||
return {'status': 'success', 'info': info, 'peers': peers}
|
||||
except Exception as e:
|
||||
logger.exception("Error listing exit peers")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/exit/peers/add', tags=["Protocols"])
|
||||
async def api_exit_peer_add(request: Request, server_id: int, req: ExitPeerAddRequest):
|
||||
"""Register a peer on the exit node by hand (a node not managed by this
|
||||
panel). Upserts by `peer_id`; returns the transit address, a fresh PSK and
|
||||
the exit's endpoint data for the peer's own WireGuard config."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
if not req.peer_id.strip() or not req.public_key.strip():
|
||||
return JSONResponse({'error': 'peer_id and public_key are required'}, status_code=400)
|
||||
try:
|
||||
data = load_data()
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
ssh = get_ssh(data['servers'][server_id])
|
||||
ssh.connect()
|
||||
try:
|
||||
manager = _exit_manager_for(ssh)
|
||||
peer = manager.add_peer(req.peer_id.strip(), req.name.strip() or req.peer_id.strip(),
|
||||
req.public_key.strip())
|
||||
finally:
|
||||
ssh.disconnect()
|
||||
return {'status': 'success', 'peer': peer}
|
||||
except ValueError as e:
|
||||
return JSONResponse({'error': str(e)}, status_code=400)
|
||||
except Exception as e:
|
||||
logger.exception("Error adding exit peer")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/exit/peers/remove', tags=["Protocols"])
|
||||
async def api_exit_peer_remove(request: Request, server_id: int, req: ExitPeerRemoveRequest):
|
||||
"""Drop a peer from the exit node by public key or by peer id."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
if not req.public_key.strip() and not req.peer_id.strip():
|
||||
return JSONResponse({'error': 'public_key or peer_id is required'}, status_code=400)
|
||||
try:
|
||||
data = load_data()
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
ssh = get_ssh(data['servers'][server_id])
|
||||
ssh.connect()
|
||||
try:
|
||||
manager = _exit_manager_for(ssh)
|
||||
removed = manager.remove_peer(public_key=req.public_key.strip() or None,
|
||||
peer_id=req.peer_id.strip() or None)
|
||||
finally:
|
||||
ssh.disconnect()
|
||||
return {'status': 'success', 'removed': removed}
|
||||
except Exception as e:
|
||||
logger.exception("Error removing exit peer")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
def _exit_link_error(e: ExitLinkError):
|
||||
return JSONResponse({'error': e.code, 'message': str(e)}, status_code=e.status_code)
|
||||
|
||||
|
||||
@app.get('/api/exit-nodes', tags=["Servers"])
|
||||
async def api_exit_nodes(request: Request):
|
||||
"""Servers with an installed exit node, from data.json (no SSH)."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
return {'status': 'success', 'exit_nodes': exit_link_svc.list_exit_nodes(load_data())}
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/exit-link', tags=["Protocols"])
|
||||
async def api_exit_link(request: Request, server_id: int, req: ExitLinkRequest):
|
||||
"""Route all clients of an AWG instance through an exit node (by its
|
||||
server uid). Rolled back unless the exit answers within 15 s; `force`
|
||||
keeps the link anyway."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
try:
|
||||
return await exit_link_svc.link(server_id, req.protocol, req.exit_uid.strip(), force=bool(req.force))
|
||||
except ExitLinkError as e:
|
||||
return _exit_link_error(e)
|
||||
except Exception as e:
|
||||
logger.exception("Error linking to exit node")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/exit-link/remove', tags=["Protocols"])
|
||||
async def api_exit_unlink(request: Request, server_id: int, req: ProtocolRequest):
|
||||
"""Restore direct egress for an AWG instance and drop its peer on the exit."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
try:
|
||||
return await exit_link_svc.unlink(server_id, req.protocol)
|
||||
except ExitLinkError as e:
|
||||
return _exit_link_error(e)
|
||||
except Exception as e:
|
||||
logger.exception("Error unlinking from exit node")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/exit-link/relink', tags=["Protocols"])
|
||||
async def api_exit_relink(request: Request, server_id: int, req: ProtocolRequest):
|
||||
"""Re-establish an existing link (after a reinstall or a stale state)."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
try:
|
||||
return await exit_link_svc.relink_entry(server_id, req.protocol)
|
||||
except ExitLinkError as e:
|
||||
return _exit_link_error(e)
|
||||
except Exception as e:
|
||||
logger.exception("Error re-linking to exit node")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/exit-link/dns', tags=["Protocols"])
|
||||
async def api_exit_link_dns(request: Request, server_id: int, req: ExitDnsRequest):
|
||||
"""Resolve client DNS at the exit node instead of this one (requires
|
||||
AmneziaDNS on the exit)."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
try:
|
||||
return await exit_link_svc.set_dns_via_exit(server_id, req.protocol, req.enabled)
|
||||
except ExitLinkError as e:
|
||||
return _exit_link_error(e)
|
||||
except Exception as e:
|
||||
logger.exception("Error switching the DNS route of an exit link")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/exit-link/status', tags=["Protocols"])
|
||||
async def api_exit_link_status(request: Request, server_id: int, req: ProtocolRequest):
|
||||
"""Saved link plus live handshake/transfer, client MTU and IPv6 flags."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
try:
|
||||
return await exit_link_svc.status(server_id, req.protocol)
|
||||
except ExitLinkError as e:
|
||||
return _exit_link_error(e)
|
||||
except Exception as e:
|
||||
logger.exception("Error reading exit link status")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/exit-link/check-egress', tags=["Protocols"])
|
||||
async def api_exit_link_check_egress(request: Request, server_id: int, req: ProtocolRequest):
|
||||
"""Public IP the clients of this instance leave from, compared with the
|
||||
exit node address (two HTTP probes from inside the container)."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
try:
|
||||
return await exit_link_svc.check_egress(server_id, req.protocol)
|
||||
except ExitLinkError as e:
|
||||
return _exit_link_error(e)
|
||||
except Exception as e:
|
||||
logger.exception("Error checking exit egress")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/awg/settings', tags=["Protocols"])
|
||||
async def api_awg_settings_get(request: Request, server_id: int, req: ProtocolRequest):
|
||||
"""Return MTU, DNS and the special junk packets I1-I5 of an AWG server."""
|
||||
@@ -3570,10 +3928,18 @@ async def api_uninstall_protocol(request: Request, server_id: int, req: Protocol
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
server = data['servers'][server_id]
|
||||
base = protocol_base(req.protocol)
|
||||
if base in AWG_PROTOCOLS and ((server.get('protocols') or {}).get(req.protocol) or {}).get('exit_link'):
|
||||
# Drop our peer on the exit while the container still exists
|
||||
try:
|
||||
await exit_link_svc.unlink(server_id, req.protocol)
|
||||
except Exception as e:
|
||||
logger.warning(f"unlink before uninstall failed: {e}")
|
||||
data = load_data()
|
||||
server = data['servers'][server_id]
|
||||
ssh = get_ssh(server)
|
||||
ssh.connect()
|
||||
manager = get_protocol_manager(ssh, req.protocol)
|
||||
base = protocol_base(req.protocol)
|
||||
if base in ('xray', 'wireguard'):
|
||||
manager.remove_container()
|
||||
else:
|
||||
@@ -3582,6 +3948,14 @@ async def api_uninstall_protocol(request: Request, server_id: int, req: Protocol
|
||||
del server['protocols'][req.protocol]
|
||||
save_data(data)
|
||||
ssh.disconnect()
|
||||
if base == 'exit':
|
||||
detached = await exit_link_svc.detach_entries_for_exit(server.get('uid'), 'exit_uninstalled')
|
||||
return {'status': 'success', 'detached': detached}
|
||||
if base == 'dns':
|
||||
# queries sent here through a link would go nowhere now
|
||||
restored = await exit_link_svc.disable_dns_via_exit_for_exit(server.get('uid'))
|
||||
if restored:
|
||||
return {'status': 'success', 'dns_restored': restored}
|
||||
return {'status': 'success'}
|
||||
except Exception as e:
|
||||
logger.exception("Error uninstalling protocol")
|
||||
@@ -3791,6 +4165,14 @@ async def api_protocol_backup_restore(request: Request, server_id: int, req: Bac
|
||||
result = BackupManager(ssh).restore_backup(req.protocol, container, filename)
|
||||
if result.get('status') == 'error':
|
||||
return JSONResponse({'error': result.get('message', 'Failed to restore backup')}, status_code=500)
|
||||
ssh.disconnect()
|
||||
ssh = None
|
||||
# The archive owns the files an exit link lives in, so the restored
|
||||
# container and data.json can now disagree. Never fails the restore.
|
||||
try:
|
||||
result.update(await exit_link_svc.reconcile_after_restore(server_id, req.protocol))
|
||||
except Exception as e:
|
||||
logger.warning(f"exit-link reconcile after restore failed: {e}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("Error restoring protocol backup")
|
||||
@@ -3831,8 +4213,10 @@ async def api_protocol_backup_delete(request: Request, server_id: int, req: Back
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/container/toggle', tags=["Protocols"])
|
||||
async def api_container_toggle(request: Request, server_id: int, req: ProtocolRequest):
|
||||
"""Start or stop a protocol Docker container."""
|
||||
async def api_container_toggle(request: Request, server_id: int, req: ContainerToggleRequest):
|
||||
"""Start or stop a protocol Docker container. Stopping an exit node that
|
||||
entries route through is refused (409 `exit_in_use`) unless `force` is
|
||||
set - their clients would silently land on the kill-switch."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
try:
|
||||
@@ -3850,6 +4234,11 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe
|
||||
f"docker inspect -f '{{{{.State.Running}}}}' {container} 2>/dev/null"
|
||||
)
|
||||
is_running = out.strip().lower() == 'true'
|
||||
if is_running and protocol_base(req.protocol) == 'exit' and not req.force:
|
||||
entries = exit_link_svc.linked_entries(data, server.get('uid'))
|
||||
if entries:
|
||||
ssh.disconnect()
|
||||
return JSONResponse({'error': 'exit_in_use', 'entries': entries}, status_code=409)
|
||||
if is_running:
|
||||
ssh.run_sudo_command(f"docker stop {container}")
|
||||
action = 'stopped'
|
||||
@@ -5141,6 +5530,15 @@ async def save_settings(request: Request, payload: SaveSettingsRequest):
|
||||
self_service = payload.self_service.dict()
|
||||
self_service['allowed_protocols'] = sanitize_allowed_protocols(self_service.get('allowed_protocols'))
|
||||
settings['self_service'] = self_service
|
||||
|
||||
warnings = []
|
||||
default_exit_uid = (payload.exit_nodes.default_exit_uid or '').strip()
|
||||
if default_exit_uid and not any(n['uid'] == default_exit_uid
|
||||
for n in exit_link_svc.list_exit_nodes(data)):
|
||||
# the node was uninstalled or deleted between opening and saving
|
||||
default_exit_uid = ''
|
||||
warnings.append('exit_default_cleared')
|
||||
settings['exit_nodes'] = {'default_exit_uid': default_exit_uid}
|
||||
save_data(data)
|
||||
logger.info("Settings saved (including captcha, telegram and auto backup)")
|
||||
|
||||
@@ -5155,7 +5553,7 @@ async def save_settings(request: Request, payload: SaveSettingsRequest):
|
||||
logger.info("Stopping Telegram bot (settings save)...")
|
||||
asyncio.create_task(tg_bot.stop_bot())
|
||||
|
||||
return {"status": "success", "bot_running": tg_bot.is_running()}
|
||||
return {"status": "success", "bot_running": tg_bot.is_running(), "warnings": warnings}
|
||||
|
||||
|
||||
@app.post('/api/settings/telegram/toggle', tags=["Settings"])
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Orchestration of exit-node links between two panel servers.
|
||||
|
||||
An *entry* AWG instance is linked to an *exit* node in three SSH steps on two
|
||||
servers: the entry generates its key pair, the exit registers it as a peer,
|
||||
the entry writes exit0.conf and reroutes its clients. Every step is idempotent
|
||||
and a failed link is rolled back, so a link is recorded in data.json only
|
||||
when the tunnel is really up (or the caller insisted with force=True).
|
||||
|
||||
Dependencies are injected (like ConnectionService) so the whole flow is unit
|
||||
tested with fake managers and an in-memory data.json.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXIT_MTU_LIMIT = 1420 # exit0 MTU; client MTU must not exceed it
|
||||
HANDSHAKE_WAIT_SECONDS = 15
|
||||
HANDSHAKE_POLL_SECONDS = 2
|
||||
|
||||
|
||||
class ExitLinkError(Exception):
|
||||
"""A user-facing failure: `code` is a stable identifier for the UI/i18n."""
|
||||
|
||||
def __init__(self, code, message, status_code=400):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
IPV4_RE = re.compile(r'^\d{1,3}(?:\.\d{1,3}){3}$')
|
||||
|
||||
|
||||
def peer_id_for(entry_uid, protocol):
|
||||
"""Identity of an entry instance on the exit: one link per AWG instance."""
|
||||
return f"{entry_uid}:{protocol}"
|
||||
|
||||
|
||||
class ExitLinkService:
|
||||
def __init__(self, *, load_data, save_data, data_lock, get_ssh,
|
||||
awg_manager_factory, exit_manager_factory,
|
||||
protocol_base, awg_protocols, protocol_display_name,
|
||||
find_server_by_uid, sleep=None):
|
||||
self.load_data = load_data
|
||||
self.save_data = save_data
|
||||
self.data_lock = data_lock
|
||||
self.get_ssh = get_ssh
|
||||
self.awg_manager_factory = awg_manager_factory
|
||||
self.exit_manager_factory = exit_manager_factory
|
||||
self.protocol_base = protocol_base
|
||||
self.awg_protocols = awg_protocols
|
||||
self.protocol_display_name = protocol_display_name
|
||||
self.find_server_by_uid = find_server_by_uid
|
||||
self._sleep = sleep or asyncio.sleep
|
||||
# Two SSH sessions and no cross-server lock: serialize per (entry, protocol)
|
||||
self._locks = defaultdict(asyncio.Lock)
|
||||
|
||||
# ----- read-only helpers (no SSH) -----
|
||||
|
||||
def list_exit_nodes(self, data):
|
||||
nodes = []
|
||||
for idx, server in enumerate(data.get('servers', [])):
|
||||
rec = (server.get('protocols') or {}).get('exit') or {}
|
||||
if not rec.get('installed'):
|
||||
continue
|
||||
nodes.append({
|
||||
'uid': server.get('uid', ''),
|
||||
'server_id': idx,
|
||||
'name': server.get('name') or server.get('host', ''),
|
||||
'host': server.get('host', ''),
|
||||
'port': rec.get('port'),
|
||||
'subnet': rec.get('subnet'),
|
||||
'obfuscation': bool(rec.get('obfuscation')),
|
||||
})
|
||||
return nodes
|
||||
|
||||
def linked_entries(self, data, exit_uid):
|
||||
"""Every (server, protocol) whose exit_link points at `exit_uid`."""
|
||||
entries = []
|
||||
if not exit_uid:
|
||||
return entries
|
||||
for idx, server in enumerate(data.get('servers', [])):
|
||||
for proto, rec in (server.get('protocols') or {}).items():
|
||||
link = (rec or {}).get('exit_link') or {}
|
||||
if link.get('exit_uid') == exit_uid:
|
||||
entries.append({
|
||||
'server_id': idx,
|
||||
'server_uid': server.get('uid', ''),
|
||||
'name': server.get('name') or server.get('host', ''),
|
||||
'protocol': proto,
|
||||
'stale': link.get('stale'),
|
||||
})
|
||||
return entries
|
||||
|
||||
# ----- validation -----
|
||||
|
||||
def _entry(self, data, server_id, protocol):
|
||||
servers = data.get('servers', [])
|
||||
if not isinstance(server_id, int) or server_id < 0 or server_id >= len(servers):
|
||||
raise ExitLinkError('server_not_found', 'Server not found', 404)
|
||||
server = servers[server_id]
|
||||
if self.protocol_base(protocol) not in self.awg_protocols:
|
||||
raise ExitLinkError('protocol_not_awg', 'Only AmneziaWG instances can be linked to an exit node')
|
||||
rec = (server.get('protocols') or {}).get(protocol)
|
||||
if not rec or not rec.get('installed'):
|
||||
raise ExitLinkError('protocol_not_installed', f'{protocol} is not installed on this server')
|
||||
if not server.get('uid'):
|
||||
raise ExitLinkError('entry_no_uid', 'This server has no uid yet; restart the panel once', 500)
|
||||
return server, rec
|
||||
|
||||
def _exit(self, data, exit_uid):
|
||||
_, server = self.find_server_by_uid(data, exit_uid)
|
||||
rec = ((server or {}).get('protocols') or {}).get('exit') or {}
|
||||
if not server or not rec.get('installed'):
|
||||
raise ExitLinkError('exit_not_found', 'Exit node not found or not installed', 404)
|
||||
return server, rec
|
||||
|
||||
def _awg(self, server):
|
||||
return self.awg_manager_factory(self.get_ssh(server))
|
||||
|
||||
def _exit_manager(self, server):
|
||||
return self.exit_manager_factory(self.get_ssh(server))
|
||||
|
||||
# ----- persistence under the lock, resolved by uid (indices may shift) -----
|
||||
|
||||
async def _update_record(self, entry_uid, protocol, mutate):
|
||||
async with self.data_lock:
|
||||
data = self.load_data()
|
||||
_, server = self.find_server_by_uid(data, entry_uid)
|
||||
rec = ((server or {}).get('protocols') or {}).get(protocol)
|
||||
if rec is None:
|
||||
return None
|
||||
mutate(rec)
|
||||
self.save_data(data)
|
||||
return rec
|
||||
|
||||
# ----- link / unlink -----
|
||||
|
||||
async def _wait_handshake(self, awg, protocol):
|
||||
polls = max(1, HANDSHAKE_WAIT_SECONDS // HANDSHAKE_POLL_SECONDS)
|
||||
for attempt in range(polls):
|
||||
status = await asyncio.to_thread(awg.exit_link_status, protocol)
|
||||
if status and status.get('handshake_age') is not None:
|
||||
return status
|
||||
if attempt < polls - 1:
|
||||
await self._sleep(HANDSHAKE_POLL_SECONDS)
|
||||
return None
|
||||
|
||||
async def _rollback(self, awg, protocol, exit_manager, peer_id):
|
||||
for what, call in (('entry unlink', lambda: awg.exit_unlink(protocol)),
|
||||
('exit remove_peer', lambda: exit_manager.remove_peer(peer_id=peer_id))):
|
||||
try:
|
||||
await asyncio.to_thread(call)
|
||||
except Exception as err: # the original error is what the user must see
|
||||
logger.warning("exit link rollback: %s failed: %s", what, err)
|
||||
|
||||
@staticmethod
|
||||
def _require_exit_dns(exit_srv):
|
||||
"""DNS via the exit only works when that node runs AmneziaDNS: the
|
||||
clients keep resolving 172.29.172.254, which is answered on whichever
|
||||
node the query comes out of."""
|
||||
dns_rec = (exit_srv.get('protocols') or {}).get('dns') or {}
|
||||
if not dns_rec.get('installed'):
|
||||
raise ExitLinkError('exit_dns_needs_dns',
|
||||
'Install AmneziaDNS on the exit node before routing client DNS through it')
|
||||
|
||||
async def set_dns_via_exit(self, entry_server_id, protocol, enabled):
|
||||
"""Turn client DNS resolution at the exit node on or off for a linked
|
||||
instance. Off (the default) keeps DNS on the entry node."""
|
||||
data = self.load_data()
|
||||
entry, rec = self._entry(data, entry_server_id, protocol)
|
||||
link = rec.get('exit_link')
|
||||
if not link:
|
||||
raise ExitLinkError('not_linked', 'This instance is not linked to an exit node')
|
||||
enabled = bool(enabled)
|
||||
if enabled:
|
||||
exit_srv, _ = self._exit(data, link.get('exit_uid'))
|
||||
self._require_exit_dns(exit_srv)
|
||||
async with self._locks[(entry['uid'], protocol)]:
|
||||
awg = self._awg(entry)
|
||||
try:
|
||||
log = await asyncio.to_thread(awg.exit_set_dns_via_exit, protocol, enabled)
|
||||
except Exception as err:
|
||||
raise ExitLinkError('exit_apply_failed', str(err))
|
||||
await self._update_record(
|
||||
entry['uid'], protocol,
|
||||
lambda r: (r.get('exit_link') or {}).__setitem__('dns_via_exit', enabled))
|
||||
return {'status': 'success', 'dns_via_exit': enabled, 'log': [log]}
|
||||
|
||||
async def disable_dns_via_exit_for_exit(self, exit_uid):
|
||||
"""AmneziaDNS is gone from an exit node: bring the DNS of every entry
|
||||
linked to it back home instead of leaving queries in a black hole."""
|
||||
results = []
|
||||
data = self.load_data()
|
||||
for item in self.linked_entries(data, exit_uid):
|
||||
_, rec = self._entry(data, item['server_id'], item['protocol'])
|
||||
if not (rec.get('exit_link') or {}).get('dns_via_exit'):
|
||||
continue
|
||||
try:
|
||||
await self.set_dns_via_exit(item['server_id'], item['protocol'], False)
|
||||
results.append({**item, 'status': 'success'})
|
||||
except Exception as err:
|
||||
logger.warning("could not bring DNS back to entry %s/%s: %s",
|
||||
item['server_id'], item['protocol'], err)
|
||||
results.append({**item, 'status': 'error', 'error': str(err)})
|
||||
return results
|
||||
|
||||
async def link(self, entry_server_id, protocol, exit_uid, force=False, dns_via_exit=None):
|
||||
data = self.load_data()
|
||||
entry, rec = self._entry(data, entry_server_id, protocol)
|
||||
exit_srv, exit_rec = self._exit(data, exit_uid)
|
||||
if exit_srv.get('uid') == entry['uid']:
|
||||
raise ExitLinkError('exit_self', 'A server cannot be its own exit node')
|
||||
if self.protocol_base(protocol) == 'awg_legacy' and exit_rec.get('obfuscation'):
|
||||
raise ExitLinkError('exit_legacy_obfuscation',
|
||||
'AmneziaWG Legacy can only link to a non-obfuscated exit node')
|
||||
previous = rec.get('exit_link') or {}
|
||||
explicit_dns = dns_via_exit is not None
|
||||
dns_via_exit = bool(previous.get('dns_via_exit') if dns_via_exit is None else dns_via_exit)
|
||||
peer_id = peer_id_for(entry['uid'], protocol)
|
||||
warnings, log = [], []
|
||||
if dns_via_exit:
|
||||
try:
|
||||
self._require_exit_dns(exit_srv)
|
||||
except ExitLinkError:
|
||||
# Repairing a link must not fail over an option the caller did
|
||||
# not ask for; the flag inherited from the saved link is dropped
|
||||
# so DNS comes back to this node instead of a black hole.
|
||||
if explicit_dns:
|
||||
raise
|
||||
dns_via_exit = False
|
||||
warnings.append('exit_dns_disabled_no_dns')
|
||||
|
||||
async with self._locks[(entry['uid'], protocol)]:
|
||||
awg = self._awg(entry)
|
||||
entry_pub = await asyncio.to_thread(awg.exit_prepare_keys, protocol)
|
||||
try:
|
||||
mtu = int(await asyncio.to_thread(awg._get_mtu, protocol) or 0)
|
||||
except (TypeError, ValueError):
|
||||
mtu = 0
|
||||
ipv6 = bool(await asyncio.to_thread(awg._get_subnet_ipv6_ip, protocol))
|
||||
if mtu > EXIT_MTU_LIMIT and not force:
|
||||
raise ExitLinkError('exit_mtu_too_big',
|
||||
f'Client MTU {mtu} exceeds the link MTU {EXIT_MTU_LIMIT}; '
|
||||
f'lower it in the AWG settings or link anyway')
|
||||
|
||||
# The peer on a previous exit is dropped only after the new link is
|
||||
# confirmed (below): until then the old exit is what Repair falls
|
||||
# back to if this attempt has to be rolled back.
|
||||
switching = bool(previous.get('exit_uid')) and previous['exit_uid'] != exit_uid
|
||||
|
||||
async def rolled_back():
|
||||
# The rollback restored direct egress, so a saved link (to the
|
||||
# previous exit or to this one) no longer describes reality.
|
||||
await self._rollback(awg, protocol, exit_manager, peer_id)
|
||||
if previous.get('exit_uid'):
|
||||
reason = 'switch_failed' if switching else 'relink_failed'
|
||||
await self._update_record(entry['uid'], protocol,
|
||||
lambda r: (r.get('exit_link') or {}).__setitem__('stale', reason))
|
||||
|
||||
exit_manager = self._exit_manager(exit_srv)
|
||||
name = f"{entry.get('name') or entry.get('host', '')} / {self.protocol_display_name(protocol)}"
|
||||
peer = await asyncio.to_thread(exit_manager.add_peer, peer_id, name, entry_pub)
|
||||
link = {
|
||||
'exit_uid': exit_uid,
|
||||
'exit_name': exit_srv.get('name') or exit_srv.get('host', ''),
|
||||
'transit_ip': peer['transit_ip'],
|
||||
'subnet_cidr': str(peer.get('subnet') or '10.9.0.0/24').split('/')[1],
|
||||
'exit_public_key': peer['public_key'],
|
||||
'psk': peer['psk'],
|
||||
'endpoint_host': exit_srv['host'],
|
||||
'endpoint_port': peer['port'] or exit_rec.get('port'),
|
||||
'obfuscation': bool(peer.get('obfuscation')),
|
||||
'awg_params': peer.get('awg_params') or {},
|
||||
'dns_via_exit': dns_via_exit,
|
||||
}
|
||||
try:
|
||||
log.append(await asyncio.to_thread(awg.exit_link, protocol, link))
|
||||
except Exception as err:
|
||||
await rolled_back()
|
||||
raise ExitLinkError('exit_apply_failed', str(err))
|
||||
|
||||
handshake = await self._wait_handshake(awg, protocol)
|
||||
if not handshake:
|
||||
if not force:
|
||||
await rolled_back()
|
||||
raise ExitLinkError('exit_no_handshake',
|
||||
f"No handshake from the exit node within {HANDSHAKE_WAIT_SECONDS} s "
|
||||
f"(is {link['endpoint_port']}/udp reachable on {link['endpoint_host']}?). "
|
||||
f"The link was rolled back")
|
||||
warnings.append('no_handshake_yet')
|
||||
|
||||
if switching:
|
||||
# New link confirmed: drop our peer on the previous exit, best effort.
|
||||
try:
|
||||
old_exit, _ = self._exit(data, previous['exit_uid'])
|
||||
await asyncio.to_thread(self._exit_manager(old_exit).remove_peer, None, peer_id)
|
||||
except Exception as err:
|
||||
logger.warning("previous exit %s unreachable, peer left behind: %s", previous['exit_uid'], err)
|
||||
warnings.append('old_exit_unreachable_orphan_peer')
|
||||
|
||||
record = {
|
||||
'exit_uid': exit_uid,
|
||||
'exit_name': link['exit_name'],
|
||||
'transit_ip': link['transit_ip'],
|
||||
'exit_public_key': link['exit_public_key'],
|
||||
'endpoint_host': link['endpoint_host'],
|
||||
'endpoint_port': link['endpoint_port'],
|
||||
'obfuscation': link['obfuscation'],
|
||||
'dns_via_exit': dns_via_exit,
|
||||
'linked_at': datetime.now().isoformat(),
|
||||
'stale': None,
|
||||
}
|
||||
await self._update_record(entry['uid'], protocol, lambda r: r.__setitem__('exit_link', record))
|
||||
return {'status': 'success', 'exit_link': record, 'ipv6_refused': ipv6,
|
||||
'warnings': warnings, 'log': log}
|
||||
|
||||
async def unlink(self, entry_server_id, protocol):
|
||||
data = self.load_data()
|
||||
entry, rec = self._entry(data, entry_server_id, protocol)
|
||||
link = rec.get('exit_link')
|
||||
if not link:
|
||||
return {'status': 'success', 'warnings': [], 'unlinked': False}
|
||||
warnings = []
|
||||
async with self._locks[(entry['uid'], protocol)]:
|
||||
# Entry first: direct egress must come back even when the exit is dead.
|
||||
await asyncio.to_thread(self._awg(entry).exit_unlink, protocol)
|
||||
try:
|
||||
exit_srv, _ = self._exit(data, link.get('exit_uid'))
|
||||
await asyncio.to_thread(self._exit_manager(exit_srv).remove_peer, None,
|
||||
peer_id_for(entry['uid'], protocol))
|
||||
except Exception as err:
|
||||
logger.warning("exit %s unreachable on unlink, peer left behind: %s", link.get('exit_uid'), err)
|
||||
warnings.append('exit_unreachable_orphan_peer')
|
||||
await self._update_record(entry['uid'], protocol, lambda r: r.pop('exit_link', None))
|
||||
return {'status': 'success', 'warnings': warnings, 'unlinked': True}
|
||||
|
||||
async def relink_entry(self, entry_server_id, protocol):
|
||||
data = self.load_data()
|
||||
_, rec = self._entry(data, entry_server_id, protocol)
|
||||
link = rec.get('exit_link')
|
||||
if not link:
|
||||
raise ExitLinkError('not_linked', 'This instance is not linked to an exit node')
|
||||
return await self.link(entry_server_id, protocol, link['exit_uid'], force=True)
|
||||
|
||||
async def relink_entries_for_exit(self, exit_uid):
|
||||
"""After an exit node was reinstalled: re-register every linked entry."""
|
||||
results = []
|
||||
for item in self.linked_entries(self.load_data(), exit_uid):
|
||||
try:
|
||||
await self.relink_entry(item['server_id'], item['protocol'])
|
||||
results.append({**item, 'status': 'success', 'error': None})
|
||||
except Exception as err:
|
||||
logger.warning("re-link %s/%s failed: %s", item['name'], item['protocol'], err)
|
||||
results.append({**item, 'status': 'error', 'error': str(err)})
|
||||
return results
|
||||
|
||||
async def reconcile_after_restore(self, server_id, protocol):
|
||||
"""A protocol restore overwrites the very files a link lives in: the
|
||||
entry keeps exit0.conf inside its AWG config directory, the exit keeps
|
||||
its peer table. So an archive can bring back a link the panel does not
|
||||
know about, drop the one it does, or restore a peer list that no longer
|
||||
matches the entries. Called after a successful restore to make the two
|
||||
sides agree again."""
|
||||
data = self.load_data()
|
||||
base = self.protocol_base(protocol)
|
||||
|
||||
if base == 'exit':
|
||||
servers = data.get('servers', [])
|
||||
if not isinstance(server_id, int) or server_id < 0 or server_id >= len(servers):
|
||||
return {}
|
||||
uid = servers[server_id].get('uid')
|
||||
entries = await self.relink_entries_for_exit(uid) if uid else []
|
||||
return {'exit_entries_relinked': entries} if entries else {}
|
||||
|
||||
if base not in self.awg_protocols:
|
||||
return {}
|
||||
try:
|
||||
entry, rec = self._entry(data, server_id, protocol)
|
||||
except ExitLinkError:
|
||||
return {}
|
||||
|
||||
if rec.get('exit_link'):
|
||||
try:
|
||||
await self.relink_entry(server_id, protocol)
|
||||
return {'exit_link_restored': 'relinked'}
|
||||
except Exception as err:
|
||||
logger.warning("re-link after restore failed for %s/%s: %s",
|
||||
entry.get('name'), protocol, err)
|
||||
await self._update_record(
|
||||
entry['uid'], protocol,
|
||||
lambda r: (r.get('exit_link') or {}).__setitem__('stale', 'restore_relink_failed'))
|
||||
return {'exit_link_restored': 'stale', 'exit_link_error': str(err)}
|
||||
|
||||
# The panel knows of no link, so a link file coming out of the archive
|
||||
# would silently route clients through an exit nobody is tracking.
|
||||
try:
|
||||
info = await asyncio.to_thread(self._awg(entry).exit_link_info, protocol)
|
||||
except Exception as err:
|
||||
logger.warning("could not read the link state after restore: %s", err)
|
||||
return {}
|
||||
if not info:
|
||||
return {}
|
||||
try:
|
||||
await asyncio.to_thread(self._awg(entry).exit_unlink, protocol)
|
||||
return {'exit_link_restored': 'removed', 'exit_name': info.get('exit_name', '')}
|
||||
except Exception as err:
|
||||
logger.warning("could not remove the restored link file: %s", err)
|
||||
return {'exit_link_restored': 'orphan', 'exit_name': info.get('exit_name', '')}
|
||||
|
||||
async def detach_entries_for_exit(self, exit_uid, reason):
|
||||
"""The exit is gone (uninstalled, deleted, cleared): restore direct
|
||||
egress on every linked entry; unreachable ones keep the record marked
|
||||
stale so the UI offers Unlink/Repair and the kill-switch keeps
|
||||
protecting their clients."""
|
||||
results = []
|
||||
data = self.load_data()
|
||||
for item in self.linked_entries(data, exit_uid):
|
||||
entry = data['servers'][item['server_id']]
|
||||
try:
|
||||
await asyncio.to_thread(self._awg(entry).exit_unlink, item['protocol'])
|
||||
await self._update_record(item['server_uid'], item['protocol'], lambda r: r.pop('exit_link', None))
|
||||
results.append({**item, 'status': 'success', 'error': None})
|
||||
except Exception as err:
|
||||
logger.warning("detach %s/%s failed, marking stale: %s", item['name'], item['protocol'], err)
|
||||
await self._update_record(item['server_uid'], item['protocol'],
|
||||
lambda r: (r.get('exit_link') or {}).__setitem__('stale', reason))
|
||||
results.append({**item, 'status': 'error', 'error': str(err)})
|
||||
return results
|
||||
|
||||
async def forget_entry_peers(self, server):
|
||||
"""An entry server is deleted or cleared: drop its peers on the exits."""
|
||||
results = []
|
||||
data = self.load_data()
|
||||
for proto, rec in (server.get('protocols') or {}).items():
|
||||
link = (rec or {}).get('exit_link') or {}
|
||||
if not link.get('exit_uid') or not server.get('uid'):
|
||||
continue
|
||||
try:
|
||||
exit_srv, _ = self._exit(data, link['exit_uid'])
|
||||
await asyncio.to_thread(self._exit_manager(exit_srv).remove_peer, None,
|
||||
peer_id_for(server['uid'], proto))
|
||||
results.append({'protocol': proto, 'exit_uid': link['exit_uid'], 'status': 'success'})
|
||||
except Exception as err:
|
||||
logger.warning("could not drop peer for %s on exit %s: %s", proto, link['exit_uid'], err)
|
||||
results.append({'protocol': proto, 'exit_uid': link['exit_uid'], 'status': 'error', 'error': str(err)})
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _endpoint_ip(status):
|
||||
"""IPv4 of the live `host:port` endpoint of the link, or '' (an IPv6
|
||||
endpoint has no bearing on the IPv4-only egress probe)."""
|
||||
endpoint = (status or {}).get('endpoint') or ''
|
||||
host = endpoint.rsplit(':', 1)[0]
|
||||
return host if IPV4_RE.match(host) else ''
|
||||
|
||||
async def check_egress(self, entry_server_id, protocol):
|
||||
"""Ask the entry container which public IP its clients leave from and
|
||||
compare it with the exit node's address. `leaks` means the clients
|
||||
still leave from the entry node itself."""
|
||||
data = self.load_data()
|
||||
entry, rec = self._entry(data, entry_server_id, protocol)
|
||||
link = rec.get('exit_link')
|
||||
if not link:
|
||||
raise ExitLinkError('not_linked', 'This instance is not linked to an exit node')
|
||||
awg = self._awg(entry)
|
||||
try:
|
||||
probe = await asyncio.to_thread(awg.exit_check_egress, protocol)
|
||||
except RuntimeError as err:
|
||||
raise ExitLinkError('exit_egress_failed', str(err))
|
||||
via, direct = probe.get('via_exit', ''), probe.get('direct', '')
|
||||
expected = link.get('endpoint_host', '')
|
||||
# `endpoint_host` is whatever the admin typed when adding the server and
|
||||
# is often a hostname; the probe always answers with an address. The
|
||||
# live WireGuard endpoint is the resolved form of that same host, so it
|
||||
# is what the comparison uses when available.
|
||||
status = await asyncio.to_thread(awg.exit_link_status, protocol)
|
||||
expected_ip = self._endpoint_ip(status) or (expected if IPV4_RE.match(expected or '') else '')
|
||||
return {
|
||||
'status': 'success',
|
||||
'via_exit': via,
|
||||
'direct': direct,
|
||||
'expected': expected,
|
||||
'expected_ip': expected_ip,
|
||||
'exit_name': link.get('exit_name', ''),
|
||||
# A NATed exit answers from another address, so a mismatch is a
|
||||
# warning, not a failure; leaving from the entry address is not.
|
||||
'matches': bool(via) and bool(expected_ip) and via == expected_ip,
|
||||
'leaks': bool(via) and via == direct,
|
||||
}
|
||||
|
||||
async def status(self, entry_server_id, protocol):
|
||||
data = self.load_data()
|
||||
entry, rec = self._entry(data, entry_server_id, protocol)
|
||||
awg = self._awg(entry)
|
||||
link_status = await asyncio.to_thread(awg.exit_link_status, protocol)
|
||||
try:
|
||||
mtu = int(await asyncio.to_thread(awg._get_mtu, protocol) or 0)
|
||||
except (TypeError, ValueError):
|
||||
mtu = 0
|
||||
ipv6 = bool(await asyncio.to_thread(awg._get_subnet_ipv6_ip, protocol))
|
||||
return {
|
||||
'status': 'success',
|
||||
'exit_link': rec.get('exit_link'),
|
||||
'exit_link_status': link_status,
|
||||
'ipv6': ipv6,
|
||||
'mtu': mtu,
|
||||
'exit_mtu': EXIT_MTU_LIMIT,
|
||||
}
|
||||
+419
-10
@@ -101,6 +101,43 @@ AWG_QUICK_FORCE_USERSPACE_PATCH = (
|
||||
' && grep -q WG_FORCE_USERSPACE /usr/bin/awg-quick\n'
|
||||
)
|
||||
|
||||
# Entry side of an exit-node link: a second WireGuard interface inside the
|
||||
# protocol container plus policy routing that sends the client subnet through
|
||||
# it. State is file-driven - exit0.conf present means "linked" - so the same
|
||||
# shell block runs at container start and when the panel (un)links live.
|
||||
EXIT_DIR = '/opt/amnezia/awg/exit'
|
||||
EXIT_CONF = f'{EXIT_DIR}/exit0.conf'
|
||||
EXIT_KEY = f'{EXIT_DIR}/exit_private.key'
|
||||
EXIT_IFACE = 'exit0'
|
||||
EXIT_TABLE = 200
|
||||
EXIT_MTU = 1420
|
||||
EXIT_HANDSHAKE_UP_SECONDS = 300 # REJECT_AFTER_TIME (180) + keepalive slack; 180 itself flaps on idle links
|
||||
DNS_NET = '172.29.172.0/24' # amnezia-dns-net: AmneziaDNS / AdGuard live here, reached via eth1
|
||||
ENTRY_PRIVATE_KEY_PLACEHOLDER = '__ENTRY_PRIVATE_KEY__'
|
||||
|
||||
# Egress check: the container asks a public "what is my IP" service twice -
|
||||
# once bound to the tunnel gateway (that source address is what policy routing
|
||||
# sends through exit0, i.e. exactly the client path) and once unbound. Two
|
||||
# services, so a single one being down does not read as "no egress".
|
||||
EGRESS_CHECK_URLS = ('https://api.ipify.org', 'https://ifconfig.me/ip')
|
||||
EGRESS_CHECK_TIMEOUT = 8
|
||||
IPV4_RE = re.compile(r'^\d{1,3}(?:\.\d{1,3}){3}$')
|
||||
|
||||
# Obfuscation keys carried on a transit link (exit node <-> entry node). S3/S4
|
||||
# pad every cookie/transport packet and would eat the MTU budget of a tunnel
|
||||
# inside a tunnel, so only the handshake-side keys are used on that hop.
|
||||
TRANSIT_PARAM_KEYS = (
|
||||
('junk_packet_count', 'Jc'),
|
||||
('junk_packet_min_size', 'Jmin'),
|
||||
('junk_packet_max_size', 'Jmax'),
|
||||
('init_packet_junk_size', 'S1'),
|
||||
('response_packet_junk_size', 'S2'),
|
||||
('init_packet_magic_header', 'H1'),
|
||||
('response_packet_magic_header', 'H2'),
|
||||
('underload_packet_magic_header', 'H3'),
|
||||
('transport_packet_magic_header', 'H4'),
|
||||
)
|
||||
|
||||
# Runs inside the awg3 container before awg-quick. Creating a throwaway
|
||||
# interface both loads the module (a module present on disk but not yet loaded
|
||||
# would otherwise read as absent) and proves the container can reach it.
|
||||
@@ -875,7 +912,7 @@ echo "HOST_CONNTRACK_COUNT=$(cat /proc/sys/net/netfilter/nf_conntrack_count 2>/d
|
||||
echo "HOST_BACKLOG=$(sysctl -n net.core.netdev_max_backlog 2>/dev/null)"
|
||||
echo "HOST_SOMAXCONN=$(sysctl -n net.core.somaxconn 2>/dev/null)"
|
||||
echo "HOST_AWG_MODULE=$(modinfo amneziawg 2>/dev/null | awk '/^version:/{print $2; exit}')"
|
||||
for c in $(docker ps -a --format '{{.Names}}' | grep '^amnezia-awg' | sort); do
|
||||
for c in $(docker ps -a --format '{{.Names}}' | grep -E '^amnezia-(awg|exit)' | sort); do
|
||||
echo "CT_NAME=$c"
|
||||
if docker ps --format '{{.Names}}' | grep -qx "$c"; then
|
||||
echo "CT_RUNNING=1"
|
||||
@@ -1221,7 +1258,7 @@ H4 = {awg_params['transport_packet_magic_header']}
|
||||
f"\n"
|
||||
f'LABEL maintainer="AmneziaVPN"\n'
|
||||
f"\n"
|
||||
f"RUN apk add --no-cache bash curl dumb-init iptables\n"
|
||||
f"RUN apk add --no-cache bash curl dumb-init iptables iproute2 conntrack-tools\n"
|
||||
f"RUN apk --update upgrade --no-cache\n"
|
||||
f"\n"
|
||||
f"{userspace_patch}"
|
||||
@@ -1286,9 +1323,25 @@ H4 = {awg_params['transport_packet_magic_header']}
|
||||
{image}"""
|
||||
|
||||
@staticmethod
|
||||
def _start_script_body(config_path, quick_bin, userspace_guard, subnet_default, bwlimits_path):
|
||||
def _mss_clamp_body(iface):
|
||||
"""Clamp TCP MSS on a transit link in both directions, so a tunnel
|
||||
carried inside another tunnel never depends on PMTU discovery."""
|
||||
return (
|
||||
f"# Clamp TCP MSS on the transit link so entry->exit->internet never needs fragmentation\n"
|
||||
f"iptables -t mangle -A FORWARD -i {iface} -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true\n"
|
||||
f"iptables -t mangle -A FORWARD -o {iface} -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true\n"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _start_script_body(config_path, quick_bin, userspace_guard, subnet_default, bwlimits_path,
|
||||
extra_blocks=(), pre_blocks=()):
|
||||
"""/opt/amnezia/start.sh of a protocol container: brings the tunnel
|
||||
up, sets up forwarding/NAT and applies per-peer bandwidth limits."""
|
||||
up, sets up forwarding/NAT and applies per-peer bandwidth limits.
|
||||
`pre_blocks` go in before the tunnel comes up (the exit-node
|
||||
kill-switch must exist before awg0 can forward anything),
|
||||
`extra_blocks` are appended verbatim before the final `tail -f`."""
|
||||
pre = ''.join(f"{block}\n" for block in pre_blocks)
|
||||
extra = ''.join(f"{block}\n" for block in extra_blocks)
|
||||
return f"""#!/bin/bash
|
||||
echo "Container startup"
|
||||
|
||||
@@ -1303,7 +1356,7 @@ fi
|
||||
|
||||
# IPv6 subnet, if the tunnel is dual-stack (second part of the Address line)
|
||||
SUBNET6=$(grep '^Address' {config_path} | head -1 | tr ',' '\n' | grep ':' | sed 's/^[^=]*=//' | tr -d ' ' | head -1)
|
||||
{userspace_guard}
|
||||
{pre}{userspace_guard}
|
||||
# kill daemons in case of restart
|
||||
{quick_bin} down {config_path} 2>/dev/null
|
||||
|
||||
@@ -1345,19 +1398,28 @@ if [ -f {bwlimits_path} ]; then
|
||||
)
|
||||
fi
|
||||
|
||||
tail -f /dev/null
|
||||
{extra}tail -f /dev/null
|
||||
"""
|
||||
|
||||
def _render_start_script(self, protocol_type, config_path=None):
|
||||
"""start.sh for one protocol instance. `config_path` overrides the
|
||||
default location - save_server_config passes the resolved path of an
|
||||
existing container."""
|
||||
existing container. The exit-node block is always emitted: it is
|
||||
inert without exit0.conf and makes the container restart-safe for a
|
||||
link written later."""
|
||||
config_path = config_path or self._config_path(protocol_type)
|
||||
quick_bin = self._quick_binary(protocol_type)
|
||||
subnet_default = f"{AWG_DEFAULTS['subnet_ip']}/{AWG_DEFAULTS['subnet_cidr']}"
|
||||
functions = self._exit_shell_functions(config_path, quick_bin, subnet_default)
|
||||
return self._start_script_body(
|
||||
config_path or self._config_path(protocol_type),
|
||||
self._quick_binary(protocol_type),
|
||||
config_path,
|
||||
quick_bin,
|
||||
self._userspace_guard(protocol_type),
|
||||
f"{AWG_DEFAULTS['subnet_ip']}/{AWG_DEFAULTS['subnet_cidr']}",
|
||||
subnet_default,
|
||||
self._bwlimits_path(),
|
||||
pre_blocks=(functions + '[ -f "$EXIT_CONF" ] && x_killswitch_on\n',),
|
||||
extra_blocks=('# ---- Exit-node link: bring it up now that the client tunnel exists ----\n'
|
||||
'if [ -f "$EXIT_CONF" ]; then x_link_up || echo "! exit link not applied, clients stay on the kill-switch"; fi\n',),
|
||||
)
|
||||
|
||||
def _write_start_script(self, protocol_type, config_path=None):
|
||||
@@ -1374,6 +1436,349 @@ tail -f /dev/null
|
||||
self.ssh.run_sudo_command(f"docker restart {self._container_name(protocol_type)}")
|
||||
time.sleep(5)
|
||||
|
||||
# ===================== EXIT-NODE LINK (ENTRY SIDE) =====================
|
||||
|
||||
_EXIT_SHELL_TEMPLATE = """# ---- Exit-node link (panel-managed; driven by __EXIT_CONF__) ----
|
||||
EXIT_CONF=__EXIT_CONF__
|
||||
EXIT_IF=__EXIT_IF__
|
||||
EXIT_TABLE=__EXIT_TABLE__
|
||||
EXIT_PREF=__EXIT_TABLE__ # slot 0: 190/191 exceptions, catch-all at the table number
|
||||
DNS_NET=__DNS_NET__
|
||||
X_CONF=__CONF__
|
||||
X_QUICK=__QUICK__
|
||||
X_IFACE=$(basename $X_CONF .conf)
|
||||
X_SUBNET=$(grep '^Address' $X_CONF | head -1 | cut -d'=' -f2 | cut -d',' -f1 | tr -d ' ')
|
||||
[ -n "$X_SUBNET" ] || X_SUBNET=__SUBNET_DEFAULT__
|
||||
X_SUBNET6=$(grep '^Address' $X_CONF | head -1 | tr ',' '\\n' | grep ':' | sed 's/^[^=]*=//' | tr -d ' ' | head -1)
|
||||
|
||||
x_rule() { ip "$1" rule show 2>/dev/null | grep -q "^$2:" || ip "$1" rule add pref "$2" "${@:3}"; }
|
||||
x_unrule() { while ip "$1" rule del pref "$2" 2>/dev/null; do :; done; }
|
||||
x_ipt_add() { iptables -t "$1" -C "${@:2}" 2>/dev/null || iptables -t "$1" -A "${@:2}"; }
|
||||
x_ipt_del() { while iptables -t "$1" -D "${@:2}" 2>/dev/null; do :; done; }
|
||||
|
||||
x_killswitch_on() {
|
||||
# Everything from the client subnet is answered by the exit table. Until
|
||||
# exit0 is up that table holds only a blackhole, so nothing falls through
|
||||
# to eth0 - this runs before the client tunnel comes up on purpose.
|
||||
ip -4 route replace blackhole default metric 1000 table $EXIT_TABLE
|
||||
x_rule -4 190 from $X_SUBNET to $X_SUBNET lookup main
|
||||
if grep -qs '^# DnsViaExit = on' "$EXIT_CONF"; then x_unrule -4 191
|
||||
else x_rule -4 191 from $X_SUBNET to $DNS_NET lookup main; fi
|
||||
x_rule -4 $EXIT_PREF from $X_SUBNET lookup $EXIT_TABLE
|
||||
if [ -n "$X_SUBNET6" ]; then
|
||||
# No IPv6 path through the exit yet: refuse fast instead of leaking via eth0
|
||||
ip -6 route replace unreachable default table $EXIT_TABLE
|
||||
x_rule -6 190 from $X_SUBNET6 to $X_SUBNET6 lookup main
|
||||
x_rule -6 $EXIT_PREF from $X_SUBNET6 lookup $EXIT_TABLE
|
||||
fi
|
||||
}
|
||||
x_killswitch_off() {
|
||||
for p in 190 191 $EXIT_PREF; do x_unrule -4 $p; x_unrule -6 $p; done
|
||||
ip -4 route flush table $EXIT_TABLE 2>/dev/null
|
||||
ip -6 route flush table $EXIT_TABLE 2>/dev/null
|
||||
}
|
||||
x_link_up() {
|
||||
$X_QUICK down $EXIT_CONF 2>/dev/null
|
||||
if ! $X_QUICK up $EXIT_CONF; then
|
||||
echo "! $EXIT_IF failed to start; clients stay on the kill-switch until the link is repaired or removed"
|
||||
return 3
|
||||
fi
|
||||
# Loose reverse-path check on the link only: containers inherit rp_filter
|
||||
# from the host, and per-client exit tables (later) can make the reverse
|
||||
# lookup resolve to another exitN.
|
||||
sysctl -w net.ipv4.conf.$EXIT_IF.rp_filter=2 >/dev/null 2>&1 || true
|
||||
ip -4 route replace default dev $EXIT_IF table $EXIT_TABLE
|
||||
x_ipt_add filter FORWARD -i $X_IFACE -o $EXIT_IF -s $X_SUBNET -j ACCEPT
|
||||
# SNAT clients into this node's transit address: the exit sees one /32 per entry
|
||||
x_ipt_add nat POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE
|
||||
x_ipt_add mangle FORWARD -o $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true
|
||||
x_ipt_add mangle FORWARD -i $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true
|
||||
# NAT is decided once per flow: flows opened before the link were SNATed to
|
||||
# eth0 and would leave exit0 with the wrong source. Drop them so they reopen.
|
||||
if command -v conntrack >/dev/null 2>&1; then conntrack -D -s $X_SUBNET >/dev/null 2>&1
|
||||
else echo "! conntrack-tools missing: client flows opened before the link keep leaving via eth0 until they end"; fi
|
||||
# Never report success while clients could still leave via eth0
|
||||
if ! ip -4 rule show | grep -q "^$EXIT_PREF:" \\
|
||||
|| ! ip -4 route show table $EXIT_TABLE | grep -q "^default dev $EXIT_IF" \\
|
||||
|| ! iptables -t nat -C POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE 2>/dev/null; then
|
||||
echo "! FATAL: policy routing for $EXIT_IF is not in place"
|
||||
return 3
|
||||
fi
|
||||
echo "exit link up: $(ip -4 -o addr show dev $EXIT_IF | awk '{print $4}') -> table $EXIT_TABLE"
|
||||
}
|
||||
x_link_down() {
|
||||
# Take the interface down first: MASQUERADE forgets the flows NATed through
|
||||
# it (device notifier) and the table keeps blackholing until the rules go.
|
||||
[ -f "$EXIT_CONF" ] && $X_QUICK down $EXIT_CONF 2>/dev/null
|
||||
ip link del $EXIT_IF 2>/dev/null
|
||||
x_ipt_del filter FORWARD -i $X_IFACE -o $EXIT_IF -s $X_SUBNET -j ACCEPT
|
||||
x_ipt_del nat POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE
|
||||
x_ipt_del mangle FORWARD -o $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
x_ipt_del mangle FORWARD -i $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
}
|
||||
x_exit_sync() {
|
||||
if [ -f "$EXIT_CONF" ]; then x_killswitch_on && x_link_up
|
||||
else x_link_down; x_killswitch_off; fi
|
||||
}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _exit_shell_functions(config_path, quick_bin, subnet_default):
|
||||
"""Shell functions of the entry side of an exit-node link (no
|
||||
side effects by themselves): kill-switch, link up/down and the sync
|
||||
entry point used both by start.sh and by the live apply."""
|
||||
return (AWGManager._EXIT_SHELL_TEMPLATE
|
||||
.replace('__EXIT_CONF__', EXIT_CONF)
|
||||
.replace('__EXIT_IF__', EXIT_IFACE)
|
||||
.replace('__EXIT_TABLE__', str(EXIT_TABLE))
|
||||
.replace('__DNS_NET__', DNS_NET)
|
||||
.replace('__CONF__', config_path)
|
||||
.replace('__QUICK__', quick_bin)
|
||||
.replace('__SUBNET_DEFAULT__', subnet_default))
|
||||
|
||||
@staticmethod
|
||||
def _exit_conf_body(link):
|
||||
"""exit0.conf of the entry side. The private key is substituted
|
||||
inside the container (it never leaves it); `Table = off` keeps
|
||||
awg-quick from touching the routing - the shell block owns it."""
|
||||
lines = [
|
||||
'[Interface]',
|
||||
'# Managed by Amnezia Web Panel - exit node link',
|
||||
f"# ExitUid = {link.get('exit_uid', '')}",
|
||||
f"# ExitName = {link.get('exit_name', '')}",
|
||||
f"# Obfuscation = {'on' if link.get('obfuscation') else 'off'}",
|
||||
f"# DnsViaExit = {'on' if link.get('dns_via_exit') else 'off'}",
|
||||
f"PrivateKey = {ENTRY_PRIVATE_KEY_PLACEHOLDER}",
|
||||
f"Address = {link['transit_ip']}/{link.get('subnet_cidr', 24)}",
|
||||
f"MTU = {EXIT_MTU}",
|
||||
'Table = off',
|
||||
]
|
||||
if link.get('obfuscation'):
|
||||
params = link.get('awg_params') or {}
|
||||
lines += [f"{config_key} = {params[param_key]}"
|
||||
for param_key, config_key in TRANSIT_PARAM_KEYS if params.get(param_key)]
|
||||
lines += [
|
||||
'',
|
||||
'[Peer]',
|
||||
f"PublicKey = {link['exit_public_key']}",
|
||||
f"PresharedKey = {link['psk']}",
|
||||
'AllowedIPs = 0.0.0.0/0',
|
||||
f"Endpoint = {link['endpoint_host']}:{link['endpoint_port']}",
|
||||
'PersistentKeepalive = 25',
|
||||
]
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
def exit_prepare_keys(self, protocol_type):
|
||||
"""Create the entry-side key pair inside the container (kept if it
|
||||
exists) and return the public key."""
|
||||
container_name = self._container_name(protocol_type)
|
||||
wg_bin = self._wg_binary(protocol_type)
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {container_name} bash -c 'umask 077; mkdir -p {EXIT_DIR}; "
|
||||
f"[ -s {EXIT_KEY} ] || {wg_bin} genkey > {EXIT_KEY}; {wg_bin} pubkey < {EXIT_KEY}'"
|
||||
)
|
||||
if code != 0 or not out.strip():
|
||||
raise RuntimeError(f"Failed to prepare the exit link key: {err or out}")
|
||||
return out.strip()
|
||||
|
||||
def _exit_apply(self, protocol_type, teardown=False):
|
||||
"""Run the exit block live inside the container (same snippet as in
|
||||
start.sh). Raises when the link could not be brought up, so a caller
|
||||
never records a link whose clients would still leave via eth0."""
|
||||
container_name = self._container_name(protocol_type)
|
||||
config_path = self._resolve_config_path(protocol_type)
|
||||
functions = self._exit_shell_functions(
|
||||
config_path, self._quick_binary(protocol_type),
|
||||
f"{AWG_DEFAULTS['subnet_ip']}/{AWG_DEFAULTS['subnet_cidr']}")
|
||||
action = ('x_link_down\nx_killswitch_off\nrm -f "$EXIT_CONF"\n' if teardown
|
||||
else 'x_exit_sync\n')
|
||||
# One docker step per sudo call: the sudo prefix covers only the first
|
||||
# command of a `&&` chain, the rest would run unprivileged.
|
||||
self.ssh.upload_file(functions + action, "/tmp/_amnz_exit.sh")
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker cp /tmp/_amnz_exit.sh {container_name}:/tmp/_amnz_exit.sh")
|
||||
if code != 0:
|
||||
self.ssh.run_command("rm -f /tmp/_amnz_exit.sh")
|
||||
raise RuntimeError(f"exit link apply failed: {err or out or f'SSH exit code {code}'}")
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec {container_name} bash /tmp/_amnz_exit.sh", timeout=60)
|
||||
self.ssh.run_command("rm -f /tmp/_amnz_exit.sh")
|
||||
if code != 0:
|
||||
raise RuntimeError(f"exit link apply failed: {out or err or f'SSH exit code {code}'}")
|
||||
return out
|
||||
|
||||
def exit_link(self, protocol_type, link):
|
||||
"""Write exit0.conf into the container, refresh start.sh (no restart)
|
||||
and bring the link up live. `link` carries transit_ip, subnet_cidr,
|
||||
exit_public_key, psk, endpoint_host, endpoint_port, obfuscation,
|
||||
awg_params, exit_uid, exit_name, dns_via_exit."""
|
||||
container_name = self._container_name(protocol_type)
|
||||
self.ssh.upload_file(self._exit_conf_body(link), "/tmp/_amnz_exit0.conf")
|
||||
# One docker step per sudo call (the sudo prefix covers only the first
|
||||
# command of a `&&` chain).
|
||||
steps = (
|
||||
f"docker exec -i {container_name} mkdir -p {EXIT_DIR}",
|
||||
f"docker cp /tmp/_amnz_exit0.conf {container_name}:{EXIT_CONF}",
|
||||
f"docker exec -i {container_name} bash -c 'k=$(cat {EXIT_KEY}) && "
|
||||
f"sed -i \"s|{ENTRY_PRIVATE_KEY_PLACEHOLDER}|$k|\" {EXIT_CONF} && chmod 600 {EXIT_CONF}'",
|
||||
)
|
||||
try:
|
||||
for step in steps:
|
||||
out, err, code = self.ssh.run_sudo_command(step)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to write exit0.conf: {err or out or f'SSH exit code {code}'}")
|
||||
finally:
|
||||
self.ssh.run_command("rm -f /tmp/_amnz_exit0.conf")
|
||||
# Resolved path: an older legacy install may keep its config at awg0.conf.
|
||||
self._write_start_script(protocol_type, config_path=self._resolve_config_path(protocol_type))
|
||||
return self._exit_apply(protocol_type)
|
||||
|
||||
def exit_set_dns_via_exit(self, protocol_type, enabled):
|
||||
"""Flip the DnsViaExit marker in exit0.conf and re-apply the block.
|
||||
With it on, the exception that keeps client DNS on this node is not
|
||||
installed, so DNS queries follow the client subnet through the link
|
||||
and are answered by the AmneziaDNS of the exit node. Re-applying
|
||||
recreates exit0, so flows opened before the change reopen."""
|
||||
container_name = self._container_name(protocol_type)
|
||||
value = 'on' if enabled else 'off'
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {container_name} sh -c 'test -f {EXIT_CONF} || exit 9; "
|
||||
f"sed -i \"s|^# DnsViaExit = .*|# DnsViaExit = {value}|\" {EXIT_CONF}'")
|
||||
if code == 9:
|
||||
raise RuntimeError('This instance has no exit link file')
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to update the DNS route of the link: {err or out}")
|
||||
return self._exit_apply(protocol_type)
|
||||
|
||||
def exit_unlink(self, protocol_type):
|
||||
"""Tear the link down live and delete exit0.conf; the key pair stays
|
||||
so a re-link reuses the public key already known to the exit."""
|
||||
return self._exit_apply(protocol_type, teardown=True)
|
||||
|
||||
def exit_link_info(self, protocol_type):
|
||||
"""What the container believes it is linked to, or None. Reads only
|
||||
the panel metadata, the address and the endpoint - never the key."""
|
||||
container_name = self._container_name(protocol_type)
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {container_name} sh -c 'test -f {EXIT_CONF} || exit 9; "
|
||||
f"grep -E \"^# (ExitUid|ExitName|Obfuscation|DnsViaExit) =|^Address|^Endpoint\" {EXIT_CONF}'"
|
||||
)
|
||||
if code != 0:
|
||||
return None
|
||||
info = {}
|
||||
for line in out.split('\n'):
|
||||
stripped = line.strip().lstrip('#').strip()
|
||||
if '=' not in stripped:
|
||||
continue
|
||||
key, _, value = stripped.partition('=')
|
||||
info[key.strip()] = value.strip()
|
||||
return {
|
||||
'exit_uid': info.get('ExitUid', ''),
|
||||
'exit_name': info.get('ExitName', ''),
|
||||
'obfuscation': info.get('Obfuscation') == 'on',
|
||||
'dns_via_exit': info.get('DnsViaExit') == 'on',
|
||||
'address': info.get('Address', ''),
|
||||
'endpoint': info.get('Endpoint', ''),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_wg_peer_lists(text):
|
||||
"""Parse `wg show <if> latest-handshakes; ---; transfer; ---; endpoints`
|
||||
(tab-separated, one peer per line) into per-peer dicts. `dump` is
|
||||
avoided on purpose: its first line carries the interface private key."""
|
||||
sections = [s.strip() for s in text.split('---')]
|
||||
sections += [''] * (3 - len(sections))
|
||||
peers = {}
|
||||
|
||||
def rows(section):
|
||||
for line in section.split('\n'):
|
||||
parts = line.strip().split('\t')
|
||||
if len(parts) >= 2 and parts[0]:
|
||||
yield parts
|
||||
|
||||
for parts in rows(sections[0]):
|
||||
try:
|
||||
peers.setdefault(parts[0], {})['latest_handshake'] = int(parts[1])
|
||||
except ValueError:
|
||||
peers.setdefault(parts[0], {})['latest_handshake'] = 0
|
||||
for parts in rows(sections[1]):
|
||||
peer = peers.setdefault(parts[0], {})
|
||||
try:
|
||||
peer['rx_bytes'] = int(parts[1])
|
||||
peer['tx_bytes'] = int(parts[2]) if len(parts) > 2 else 0
|
||||
except ValueError:
|
||||
peer['rx_bytes'], peer['tx_bytes'] = 0, 0
|
||||
for parts in rows(sections[2]):
|
||||
peers.setdefault(parts[0], {})['endpoint'] = '' if parts[1] == '(none)' else parts[1]
|
||||
return peers
|
||||
|
||||
def exit_link_status(self, protocol_type, now=None):
|
||||
"""Live state of the link: None when the container has no link file,
|
||||
otherwise up/handshake age/transfer/endpoint of the exit peer."""
|
||||
container_name = self._container_name(protocol_type)
|
||||
wg_bin = self._wg_binary(protocol_type)
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {container_name} sh -c 'test -f {EXIT_CONF} || exit 9; "
|
||||
f"{wg_bin} show {EXIT_IFACE} latest-handshakes; echo ---; "
|
||||
f"{wg_bin} show {EXIT_IFACE} transfer; echo ---; {wg_bin} show {EXIT_IFACE} endpoints'"
|
||||
)
|
||||
if code == 9:
|
||||
return None
|
||||
if code != 0:
|
||||
return {'up': False, 'handshake_age': None, 'rx_bytes': 0, 'tx_bytes': 0,
|
||||
'endpoint': '', 'error': (err or out).strip()}
|
||||
peers = self._parse_wg_peer_lists(out)
|
||||
peer = next(iter(peers.values()), {})
|
||||
handshake = peer.get('latest_handshake', 0)
|
||||
age = int((now or time.time()) - handshake) if handshake else None
|
||||
return {
|
||||
'up': age is not None and 0 <= age < EXIT_HANDSHAKE_UP_SECONDS,
|
||||
'handshake_age': age,
|
||||
'rx_bytes': peer.get('rx_bytes', 0),
|
||||
'tx_bytes': peer.get('tx_bytes', 0),
|
||||
'endpoint': peer.get('endpoint', ''),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _egress_probe_script(subnet_ip):
|
||||
"""POSIX sh (single-quoted into `docker exec sh -c`, so no quotes of
|
||||
its own) printing the client-path egress IP, `---`, and the container's
|
||||
own egress IP."""
|
||||
urls = ' '.join(EGRESS_CHECK_URLS)
|
||||
probe = (
|
||||
'x_ip() { for u in %s; do '
|
||||
'r=$(curl -fsS -4 --max-time %d "$@" "$u" 2>/dev/null | tr -d "[:space:]"); '
|
||||
'case "$r" in ""|*[!0-9.]*) ;; *) echo "$r"; return 0;; esac; '
|
||||
'done; echo ""; }' % (urls, EGRESS_CHECK_TIMEOUT)
|
||||
)
|
||||
return f'{probe}; x_ip --interface {subnet_ip}; echo ---; x_ip'
|
||||
|
||||
@staticmethod
|
||||
def _first_ipv4(text):
|
||||
"""First line of `text` that is a bare IPv4 address, or ''."""
|
||||
for line in (text or '').split('\n'):
|
||||
line = line.strip()
|
||||
if IPV4_RE.match(line):
|
||||
return line
|
||||
return ''
|
||||
|
||||
def exit_check_egress(self, protocol_type):
|
||||
"""Which public IP the clients of this instance leave from, and which
|
||||
one this node leaves from. The client probe is bound to the tunnel
|
||||
gateway address, so it takes the client path (ip rule -> table 200 ->
|
||||
exit0) instead of the container's default route; when no link is up it
|
||||
simply reports the node's own address twice."""
|
||||
container_name = self._container_name(protocol_type)
|
||||
script = self._egress_probe_script(self._get_subnet_ip(protocol_type))
|
||||
# both probes may fall through every URL before giving up
|
||||
timeout = EGRESS_CHECK_TIMEOUT * len(EGRESS_CHECK_URLS) * 2 + 20
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {container_name} sh -c '{script}'", timeout=timeout)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Egress check failed: {(err or out).strip()}")
|
||||
via, _, direct = out.partition('---')
|
||||
return {'via_exit': self._first_ipv4(via), 'direct': self._first_ipv4(direct)}
|
||||
|
||||
def remove_container(self, protocol_type):
|
||||
"""Remove AWG container (mirrors remove_container.sh)."""
|
||||
container_name = self._container_name(protocol_type)
|
||||
@@ -2559,11 +2964,15 @@ AllowedIPs = {allowed_ips}
|
||||
def get_awg_settings(self, protocol_type):
|
||||
"""Client-facing AWG settings currently stored in the server config."""
|
||||
params = self._get_awg_params_from_config(protocol_type)
|
||||
link_info = self.exit_link_info(protocol_type)
|
||||
settings = {
|
||||
'mtu': self._get_mtu(protocol_type),
|
||||
'dns': self._get_dns(protocol_type),
|
||||
'default_i1': AWG_DEFAULT_I1,
|
||||
'supports_special_junk': self._base_protocol(protocol_type) != self.AWG_LEGACY,
|
||||
# an instance behind an exit link cannot carry more than the link
|
||||
'exit_linked': bool(link_info),
|
||||
'exit_mtu': EXIT_MTU,
|
||||
}
|
||||
for key in SPECIAL_JUNK_KEYS:
|
||||
settings[key] = params.get(key, '')
|
||||
|
||||
@@ -130,6 +130,9 @@ class BackupManager:
|
||||
elif base == 'nginx':
|
||||
paths['host'] = ['/opt/amnezia/nginx']
|
||||
paths['container'] = ['/etc/nginx/conf.d', '/usr/share/nginx/html']
|
||||
elif base == 'exit':
|
||||
paths['host'] = [f'/opt/amnezia/{container_name}']
|
||||
paths['container'] = ['/opt/amnezia/exit', '/opt/amnezia/start.sh']
|
||||
else:
|
||||
paths['host'] = [f'/opt/amnezia/{base}']
|
||||
paths['container'] = [f'/opt/amnezia/{base}']
|
||||
|
||||
@@ -49,7 +49,7 @@ COPY forward-records.conf /opt/unbound/etc/unbound/forward-records.conf
|
||||
self.ssh.run_sudo_command(cmd)
|
||||
|
||||
# Connect existing VPN containers to the DNS network
|
||||
vpn_containers = ['amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'telemt']
|
||||
vpn_containers = ['amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'telemt', 'amnezia-exit']
|
||||
for c in vpn_containers:
|
||||
self.ssh.run_sudo_command(f"docker ps | grep -q {c} && docker network connect amnezia-dns-net {c} || true")
|
||||
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Exit node: an AmneziaWG transit endpoint whose peers are other panel servers.
|
||||
|
||||
An entry node routes its clients through the exit and SNATs them into its own
|
||||
transit address, so the exit sees exactly one /32 per entry, NATs the transit
|
||||
subnet to the internet and never carries end-user peers. The container is the
|
||||
same image and start script as an AWG server (see AWGManager builders) with its
|
||||
own paths, one fixed instance and a peers table instead of a clients table.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from managers.awg_manager import (
|
||||
AWG_QUICK_FORCE_USERSPACE_PATCH,
|
||||
TRANSIT_PARAM_KEYS,
|
||||
AWGManager,
|
||||
generate_awg_params,
|
||||
generate_psk,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXIT_DEFAULTS = {
|
||||
'port': '55520',
|
||||
'subnet': '10.9.0.0/24',
|
||||
'mtu': '1420',
|
||||
}
|
||||
|
||||
|
||||
class ExitManager(AWGManager):
|
||||
"""Install and manage the `amnezia-exit` transit container."""
|
||||
|
||||
PROTOCOL = 'exit'
|
||||
CONTAINER_NAME = 'amnezia-exit'
|
||||
DOCKER_IMAGE = 'amneziavpn/amneziawg-go:latest'
|
||||
CONFIG_DIR = '/opt/amnezia/exit'
|
||||
CONFIG_PATH = '/opt/amnezia/exit/transit0.conf'
|
||||
INTERFACE = 'transit0'
|
||||
PEERS_TABLE = '/opt/amnezia/exit/peersTable'
|
||||
PRIVATE_KEY_PATH = '/opt/amnezia/exit/wireguard_server_private_key.key'
|
||||
PUBLIC_KEY_PATH = '/opt/amnezia/exit/wireguard_server_public_key.key'
|
||||
|
||||
def __init__(self, ssh, protocol='exit'):
|
||||
super().__init__(ssh)
|
||||
self.protocol = self.PROTOCOL
|
||||
# telegram_bot falls back to these attributes for docker inspect
|
||||
self.container_name = self.CONTAINER_NAME
|
||||
|
||||
# ----- single fixed instance: every AWGManager lookup resolves here -----
|
||||
|
||||
def _base_protocol(self, protocol_type=None):
|
||||
return self.PROTOCOL
|
||||
|
||||
def _instance_index(self, protocol_type=None):
|
||||
return 1
|
||||
|
||||
def _container_name(self, protocol_type=None):
|
||||
return self.CONTAINER_NAME
|
||||
|
||||
def _config_path(self, protocol_type=None):
|
||||
return self.CONFIG_PATH
|
||||
|
||||
def _config_path_candidates(self, protocol_type=None):
|
||||
return [self.CONFIG_PATH]
|
||||
|
||||
def _resolve_config_path(self, protocol_type=None):
|
||||
# One fixed location, nothing to probe over SSH.
|
||||
return self.CONFIG_PATH
|
||||
|
||||
def _interface_name(self, protocol_type=None, config_path=None):
|
||||
return self.INTERFACE
|
||||
|
||||
def _docker_image(self, protocol_type=None):
|
||||
return self.DOCKER_IMAGE
|
||||
|
||||
def _wg_binary(self, protocol_type=None):
|
||||
return 'awg'
|
||||
|
||||
def _quick_binary(self, protocol_type=None):
|
||||
return 'awg-quick'
|
||||
|
||||
def _userspace_guard(self, protocol_type=None):
|
||||
# The transit config carries no AWG 3.1 keys, so an old kernel module
|
||||
# accepts it; a missing module is caught by awg-quick's own fallback.
|
||||
return ''
|
||||
|
||||
def _clients_table_path(self):
|
||||
return self.PEERS_TABLE
|
||||
|
||||
def _bwlimits_path(self):
|
||||
return f'{self.CONFIG_DIR}/bwlimits'
|
||||
|
||||
def _apply_bw_limits(self, protocol_type, clients_table):
|
||||
# No per-peer shaping on the transit link.
|
||||
return None
|
||||
|
||||
def _get_server_public_key(self, protocol_type=None):
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} cat {self.PUBLIC_KEY_PATH}"
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to get exit public key: {err}")
|
||||
return out.strip()
|
||||
|
||||
def _get_server_psk(self, protocol_type=None):
|
||||
raise RuntimeError('Exit node uses a per-peer preshared key')
|
||||
|
||||
# ----- no end users on an exit node: every client path is closed -----
|
||||
|
||||
def _no_clients(self, *args, **kwargs):
|
||||
raise RuntimeError('Exit node has no clients; manage its peers instead')
|
||||
|
||||
add_client = _no_clients
|
||||
get_client_config = _no_clients
|
||||
toggle_client = _no_clients
|
||||
rename_client = _no_clients
|
||||
save_client_config = _no_clients
|
||||
set_speed_limit = _no_clients
|
||||
|
||||
# ===================== INSTALLATION =====================
|
||||
|
||||
@staticmethod
|
||||
def _subnet_gateway(net):
|
||||
"""Address line of the exit itself: first host of the transit subnet."""
|
||||
return f"{net.network_address + 1}/{net.prefixlen}"
|
||||
|
||||
def _render_start_script(self, protocol_type=None, config_path=None):
|
||||
net = ipaddress.ip_network(EXIT_DEFAULTS['subnet'])
|
||||
return self._start_script_body(
|
||||
self.CONFIG_PATH,
|
||||
'awg-quick',
|
||||
'',
|
||||
self._subnet_gateway(net),
|
||||
self._bwlimits_path(),
|
||||
extra_blocks=(self._mss_clamp_body(self.INTERFACE),),
|
||||
)
|
||||
|
||||
def _transit_config(self, port, net, awg_params):
|
||||
"""transit0.conf. Panel metadata lives inside [Interface] as comments
|
||||
(remove_client() splits the file on `[` and would drop anything before
|
||||
the first section)."""
|
||||
obfuscation_lines = ''.join(
|
||||
f"{config_key} = {awg_params[param_key]}\n"
|
||||
for param_key, config_key in TRANSIT_PARAM_KEYS
|
||||
if awg_params and awg_params.get(param_key)
|
||||
)
|
||||
return (
|
||||
"[Interface]\n"
|
||||
"# Managed by Amnezia Web Panel - exit node transit endpoint\n"
|
||||
f"# Subnet = {net}\n"
|
||||
f"# Obfuscation = {'on' if awg_params else 'off'}\n"
|
||||
"PrivateKey = $(cat wireguard_server_private_key.key)\n"
|
||||
f"Address = {self._subnet_gateway(net)}\n"
|
||||
f"ListenPort = {port}\n"
|
||||
f"MTU = {EXIT_DEFAULTS['mtu']}\n"
|
||||
f"{obfuscation_lines}"
|
||||
)
|
||||
|
||||
def _configure_exit_container(self, port, net, awg_params):
|
||||
"""Generate the key pair (kept if present) and write transit0.conf."""
|
||||
script = f"""
|
||||
mkdir -p {self.CONFIG_DIR} && cd {self.CONFIG_DIR} && umask 077
|
||||
[ -s wireguard_server_private_key.key ] || awg genkey > wireguard_server_private_key.key
|
||||
awg pubkey < wireguard_server_private_key.key > wireguard_server_public_key.key
|
||||
cat > {self.CONFIG_PATH} <<EOF
|
||||
{self._transit_config(port, net, awg_params)}EOF
|
||||
"""
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec -i {self.CONTAINER_NAME} bash -c '{script}'"
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to configure the transit endpoint: {err}")
|
||||
|
||||
def install_protocol(self, protocol_type='exit', port=None, subnet=None,
|
||||
obfuscation=False, **_ignored):
|
||||
"""Install the exit-node container.
|
||||
|
||||
`subnet` is the transit subnet shared with the entry nodes (must be an
|
||||
IPv4 /24: the peer allocator hands out the last octet); `obfuscation`
|
||||
turns the transit link into AmneziaWG with junk/handshake obfuscation,
|
||||
otherwise it speaks plain WireGuard.
|
||||
"""
|
||||
port = str(port or EXIT_DEFAULTS['port']).strip()
|
||||
try:
|
||||
net = ipaddress.ip_network(subnet or EXIT_DEFAULTS['subnet'], strict=False)
|
||||
except ValueError:
|
||||
return {'status': 'error', 'message': f'Invalid transit subnet: {subnet}'}
|
||||
if net.version != 4 or net.prefixlen != 24:
|
||||
return {'status': 'error',
|
||||
'message': 'Transit subnet must be an IPv4 /24 (peers get the last octet)'}
|
||||
|
||||
results = []
|
||||
if not self.check_docker_installed():
|
||||
results.append("Installing Docker...")
|
||||
self.install_docker()
|
||||
results.append("Docker installed successfully")
|
||||
else:
|
||||
results.append("Docker already installed")
|
||||
|
||||
results.append("Preparing host...")
|
||||
docker_ipv6 = self.prepare_host(self.PROTOCOL)
|
||||
results.append("Host prepared")
|
||||
if docker_ipv6 == 'pending':
|
||||
results.append(
|
||||
"! Docker IPv6 was enabled in /etc/docker/daemon.json, but Docker "
|
||||
"was NOT restarted because other containers are running. The transit "
|
||||
"stays IPv4-only until you run: systemctl restart docker"
|
||||
)
|
||||
elif docker_ipv6 == 'restarted':
|
||||
results.append("Docker restarted to apply IPv6 config")
|
||||
|
||||
if self.check_protocol_installed(self.PROTOCOL):
|
||||
results.append("Removing old container...")
|
||||
self.remove_container(self.PROTOCOL)
|
||||
results.append("Old container removed")
|
||||
|
||||
results.append("Pulling Docker image...")
|
||||
dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}"
|
||||
self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}")
|
||||
self.ssh.upload_file_sudo(
|
||||
self._dockerfile_content(self.DOCKER_IMAGE, AWG_QUICK_FORCE_USERSPACE_PATCH),
|
||||
f"{dockerfile_folder}/Dockerfile",
|
||||
)
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker build --no-cache --pull -t {self.CONTAINER_NAME} {dockerfile_folder}",
|
||||
timeout=300,
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to build container: {err}")
|
||||
results.append("Docker image built successfully")
|
||||
|
||||
results.append("Starting container...")
|
||||
ipv6_enabled = self._detect_server_ipv6()
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
self._docker_run_cmd(self.CONTAINER_NAME, self.CONTAINER_NAME, port, ipv6_enabled)
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to run container: {err}")
|
||||
self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.CONTAINER_NAME}")
|
||||
results.append("Waiting for container to start...")
|
||||
self._wait_container_running(self.CONTAINER_NAME)
|
||||
results.append("Container started")
|
||||
|
||||
awg_params = None
|
||||
if obfuscation:
|
||||
wanted = {param_key for param_key, _ in TRANSIT_PARAM_KEYS}
|
||||
awg_params = {k: v for k, v in generate_awg_params(use_ranges=False).items() if k in wanted}
|
||||
|
||||
try:
|
||||
results.append("Configuring transit endpoint...")
|
||||
self._configure_exit_container(port, net, awg_params)
|
||||
results.append("Transit endpoint configured ("
|
||||
+ ("AmneziaWG obfuscation on" if awg_params else "plain WireGuard") + ")")
|
||||
results.append("Starting transit service...")
|
||||
self._upload_start_script(self.PROTOCOL)
|
||||
self._verify_interface_up(self.PROTOCOL)
|
||||
results.append("Transit service started")
|
||||
except RuntimeError:
|
||||
# Do not leave a running container with no working interface behind.
|
||||
self.remove_container(self.PROTOCOL)
|
||||
raise
|
||||
|
||||
results.append("Setting up firewall...")
|
||||
self.setup_firewall()
|
||||
results.append("Firewall configured")
|
||||
results.append("Applying host network tuning (BBR)...")
|
||||
self.setup_host_tuning()
|
||||
results.append("Host network tuning applied")
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'protocol': self.PROTOCOL,
|
||||
'port': port,
|
||||
'subnet': str(net),
|
||||
'public_key': self._get_server_public_key(),
|
||||
'obfuscation': bool(awg_params),
|
||||
'awg_params': awg_params or {},
|
||||
'log': results,
|
||||
}
|
||||
|
||||
# ===================== STATUS / INFO =====================
|
||||
|
||||
def get_server_status(self, protocol_type='exit'):
|
||||
info = super().get_server_status(self.PROTOCOL)
|
||||
if info.get('container_running') and 'error' not in info:
|
||||
info['peers_count'] = info.pop('clients_count', 0)
|
||||
info['subnet'] = self._read_config_key(self.PROTOCOL, 'Subnet')
|
||||
info['obfuscation'] = (self._read_config_key(self.PROTOCOL, 'Obfuscation') or 'off') == 'on'
|
||||
try:
|
||||
info['public_key'] = self._get_server_public_key()
|
||||
except RuntimeError as err:
|
||||
info['error'] = str(err)
|
||||
info['base_protocol'] = self.PROTOCOL
|
||||
info['instance'] = 1
|
||||
info['container_name'] = self.CONTAINER_NAME
|
||||
return info
|
||||
|
||||
def get_info(self):
|
||||
"""What an entry node needs to build its side of the link."""
|
||||
params = self._get_awg_params_from_config(self.PROTOCOL)
|
||||
port = params.pop('port', None)
|
||||
return {
|
||||
'public_key': self._get_server_public_key(),
|
||||
'port': port,
|
||||
'subnet': self._read_config_key(self.PROTOCOL, 'Subnet'),
|
||||
'obfuscation': (self._read_config_key(self.PROTOCOL, 'Obfuscation') or 'off') == 'on',
|
||||
'awg_params': params,
|
||||
}
|
||||
|
||||
# ===================== PEERS =====================
|
||||
|
||||
def list_peers(self):
|
||||
"""Peers from the peers table enriched with live handshake/transfer
|
||||
data; blocks present only in transit0.conf are reported as external."""
|
||||
table = self._get_clients_table(self.PROTOCOL)
|
||||
try:
|
||||
live = self._wg_show(self.PROTOCOL)
|
||||
except Exception:
|
||||
live = {}
|
||||
conf_peers = self._parse_peers_from_config(self.PROTOCOL)
|
||||
|
||||
peers = []
|
||||
seen = set()
|
||||
for entry in table:
|
||||
public_key = entry.get('clientId', '')
|
||||
user_data = entry.get('userData', {}) or {}
|
||||
show = live.get(public_key, {})
|
||||
seen.add(public_key)
|
||||
peers.append({
|
||||
'peer_id': user_data.get('peerId', ''),
|
||||
'name': user_data.get('clientName', ''),
|
||||
'transit_ip': user_data.get('clientIp', ''),
|
||||
'public_key': public_key,
|
||||
'created_at': user_data.get('creationDate', ''),
|
||||
'latest_handshake': show.get('latestHandshake', ''),
|
||||
'rx': show.get('dataReceived', ''),
|
||||
'tx': show.get('dataSent', ''),
|
||||
'rx_bytes': show.get('dataReceivedBytes', 0),
|
||||
'tx_bytes': show.get('dataSentBytes', 0),
|
||||
'external': False,
|
||||
})
|
||||
for public_key, peer in conf_peers.items():
|
||||
if public_key in seen:
|
||||
continue
|
||||
ip = self._extract_ipv4(peer.get('allowedIps', ''))
|
||||
show = live.get(public_key, {})
|
||||
peers.append({
|
||||
'peer_id': '',
|
||||
'name': f'External ({ip})' if ip else 'External',
|
||||
'transit_ip': ip or '',
|
||||
'public_key': public_key,
|
||||
'created_at': '',
|
||||
'latest_handshake': show.get('latestHandshake', ''),
|
||||
'rx': show.get('dataReceived', ''),
|
||||
'tx': show.get('dataSent', ''),
|
||||
'rx_bytes': show.get('dataReceivedBytes', 0),
|
||||
'tx_bytes': show.get('dataSentBytes', 0),
|
||||
'external': True,
|
||||
})
|
||||
return peers
|
||||
|
||||
def _find_peer(self, peer_id):
|
||||
for entry in self._get_clients_table(self.PROTOCOL):
|
||||
if (entry.get('userData') or {}).get('peerId') == peer_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def add_peer(self, peer_id, name, public_key):
|
||||
"""Register (or re-register) an entry node as a peer.
|
||||
|
||||
Upsert by `peer_id`: an existing peer keeps its transit address and is
|
||||
rewritten with the new key. Returns what the entry side needs for its
|
||||
exit0.conf - the transit address, a fresh per-peer PSK and the exit's
|
||||
own endpoint data.
|
||||
"""
|
||||
peer_id = (peer_id or '').strip()
|
||||
public_key = (public_key or '').strip()
|
||||
if not peer_id or not public_key:
|
||||
raise ValueError('peer_id and public_key are required')
|
||||
|
||||
keep_ip = None
|
||||
existing = self._find_peer(peer_id)
|
||||
if existing:
|
||||
keep_ip = (existing.get('userData') or {}).get('clientIp')
|
||||
self.remove_client(self.PROTOCOL, existing.get('clientId', ''))
|
||||
if public_key in self._parse_peers_from_config(self.PROTOCOL):
|
||||
# Same key already present under another (or no) peer id.
|
||||
self.remove_client(self.PROTOCOL, public_key)
|
||||
|
||||
transit_ip = keep_ip or self._get_next_ip(self.PROTOCOL)
|
||||
psk = generate_psk()
|
||||
peer_section = (
|
||||
f"\n[Peer]\n"
|
||||
f"# peerId = {peer_id}\n"
|
||||
f"PublicKey = {public_key}\n"
|
||||
f"PresharedKey = {psk}\n"
|
||||
f"AllowedIPs = {transit_ip}/32\n"
|
||||
)
|
||||
self._insert_peer_sorted(self.PROTOCOL, peer_section)
|
||||
self._sync_server_config(self.PROTOCOL)
|
||||
try:
|
||||
table = self._get_clients_table(self.PROTOCOL)
|
||||
table.append({
|
||||
'clientId': public_key,
|
||||
'userData': {
|
||||
'clientName': name or peer_id,
|
||||
'peerId': peer_id,
|
||||
'clientIp': transit_ip,
|
||||
'creationDate': datetime.now().isoformat(),
|
||||
'enabled': True,
|
||||
},
|
||||
})
|
||||
self._save_clients_table(self.PROTOCOL, table)
|
||||
except Exception:
|
||||
self.remove_client(self.PROTOCOL, public_key)
|
||||
raise
|
||||
|
||||
info = self.get_info()
|
||||
info.update({'transit_ip': transit_ip, 'psk': psk})
|
||||
return info
|
||||
|
||||
def remove_peer(self, public_key=None, peer_id=None):
|
||||
"""Drop a peer by key or by peer id; True when something was removed."""
|
||||
if not public_key and peer_id:
|
||||
existing = self._find_peer(peer_id)
|
||||
public_key = existing.get('clientId', '') if existing else ''
|
||||
if not public_key:
|
||||
return False
|
||||
known = public_key in self._parse_peers_from_config(self.PROTOCOL) or any(
|
||||
e.get('clientId') == public_key for e in self._get_clients_table(self.PROTOCOL)
|
||||
)
|
||||
if not known:
|
||||
return False
|
||||
self.remove_client(self.PROTOCOL, public_key)
|
||||
return True
|
||||
@@ -1118,6 +1118,11 @@ a:hover {
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.protocol-exit .protocol-icon {
|
||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.2), rgba(234, 179, 8, 0.1));
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.protocol-nginx .protocol-icon {
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(59, 130, 246, 0.1));
|
||||
color: #38bdf8;
|
||||
|
||||
+10
-2
@@ -36,10 +36,11 @@ _pending_inputs = {}
|
||||
_EXTRA_PROTOCOL_DISPLAY_NAMES = {
|
||||
"dns": "AmneziaDNS",
|
||||
"socks5": "SOCKS5",
|
||||
"exit": "Exit Node",
|
||||
"adguard": "AdGuard Home",
|
||||
"nginx": "NGINX",
|
||||
}
|
||||
SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"}
|
||||
SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx", "exit"}
|
||||
|
||||
TG_TRANSLATIONS = {
|
||||
"en": {
|
||||
@@ -654,6 +655,7 @@ def _get_ssh_and_manager(server: dict, proto: str):
|
||||
from managers.socks5_manager import Socks5Manager
|
||||
from managers.adguard_manager import AdguardManager
|
||||
from managers.nginx_manager import NginxManager
|
||||
from managers.exit_manager import ExitManager
|
||||
|
||||
ssh = SSHManager(
|
||||
server["host"],
|
||||
@@ -677,6 +679,8 @@ def _get_ssh_and_manager(server: dict, proto: str):
|
||||
manager = AdguardManager(ssh)
|
||||
elif base == "nginx":
|
||||
manager = NginxManager(ssh, proto)
|
||||
elif base == "exit":
|
||||
manager = ExitManager(ssh, proto)
|
||||
else:
|
||||
manager = AWGManager(ssh)
|
||||
return ssh, manager
|
||||
@@ -1036,7 +1040,11 @@ async def _admin_protocol_detail(api: TelegramAPI, chat_id: int, message_id: int
|
||||
f"{_tt(lang, 'servers_title')}: <b>{_e(server.get('name') or server.get('host'))}</b>",
|
||||
f"{_tt(lang, 'protocol_status')}: <b>{_protocol_status_text(info, lang)}</b>",
|
||||
]
|
||||
for key in ("port", "container_name", "domain", "site_url", "web_port", "mode"):
|
||||
exit_link = info.get("exit_link") or {}
|
||||
if exit_link:
|
||||
lines.append(f"exit: <code>{_e(exit_link.get('exit_name'))}</code>"
|
||||
+ (f" ({_e(exit_link.get('stale'))})" if exit_link.get("stale") else ""))
|
||||
for key in ("port", "container_name", "domain", "site_url", "web_port", "mode", "subnet", "peers_count"):
|
||||
if info.get(key) not in (None, ""):
|
||||
lines.append(f"{_e(key)}: <code>{_e(info.get(key))}</code>")
|
||||
if info.get("status_error"):
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<span class="badge badge-success">
|
||||
<span class="badge-dot"></span>
|
||||
{{ 'AWG' if proto_key == 'awg' else ('AWG-Legacy' if proto_key == 'awg_legacy' else ('Xray' if
|
||||
proto_key == 'xray' else proto_key | upper)) }}
|
||||
proto_key == 'xray' else ('Exit' if proto_key == 'exit' else proto_key | upper))) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
+370
-4
@@ -468,6 +468,31 @@
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Exit Node Card -->
|
||||
<div class="card card-hover protocol-card protocol-exit" id="proto-exit">
|
||||
<div class="protocol-card-head">
|
||||
<div class="protocol-icon">🚪</div>
|
||||
<div class="protocol-name">Exit Node</div>
|
||||
</div>
|
||||
<div class="flex gap-sm protocol-ctrl" id="exit-ctrl" style="display:none!important;"></div>
|
||||
<div class="protocol-desc">
|
||||
{{ _('exit_desc') }}
|
||||
</div>
|
||||
<div class="protocol-status" id="exit-status">
|
||||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||||
</div>
|
||||
<div id="exit-info" class="hidden">
|
||||
<div class="protocol-info" id="exit-info-grid"></div>
|
||||
</div>
|
||||
<div class="flex gap-sm" id="exit-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('exit')" id="exit-install-btn"
|
||||
style="flex:1">
|
||||
{{ _('install') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- NGINX Card -->
|
||||
<div class="card card-hover protocol-card protocol-nginx" id="proto-nginx">
|
||||
<div class="protocol-card-head">
|
||||
@@ -740,6 +765,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="exitOptions"
|
||||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('exit_subnet') }}</label>
|
||||
<input class="form-input" type="text" id="installExitSubnet" value="10.9.0.0/24" spellcheck="false">
|
||||
<div class="form-hint">{{ _('exit_subnet_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||
<input type="checkbox" id="installExitObfuscation"> {{ _('exit_obfuscation') }}
|
||||
</label>
|
||||
<div class="form-hint">{{ _('exit_obfuscation_hint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="socks5Options"
|
||||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||||
<div class="form-group">
|
||||
@@ -853,6 +892,7 @@
|
||||
<label class="form-label">{{ _('awg_mtu') }}</label>
|
||||
<input class="form-input" type="number" id="awgSetMtu" min="576" max="65535">
|
||||
<div class="form-hint">{{ _('awg_mtu_hint') }}</div>
|
||||
<div class="form-hint hidden" id="awgSetMtuExitHint"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">DNS 1</label>
|
||||
@@ -887,6 +927,57 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Exit Node: peers ===== -->
|
||||
<div class="modal-backdrop" id="exitPeersModal">
|
||||
<div class="modal" style="max-width:760px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">{{ _('exit_peers_title') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('exitPeersModal')">×</button>
|
||||
</div>
|
||||
<div id="exitPeersInfo" class="form-hint" style="margin-bottom:var(--space-md);"></div>
|
||||
<div id="exitPeersList"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="showExitPeers(currentExitPeersProto)">{{ _('refresh') }}</button>
|
||||
<button class="btn btn-primary" onclick="closeModal('exitPeersModal')">{{ _('cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Exit Node: link an AWG instance ===== -->
|
||||
<div class="modal-backdrop" id="exitLinkModal">
|
||||
<div class="modal" style="max-width:560px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="exitLinkTitle">{{ _('exit_link_title') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('exitLinkModal')">×</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('exit_link_desc') }}</div>
|
||||
<div id="exitLinkState" style="margin-bottom:var(--space-md);"></div>
|
||||
<div id="exitLinkEgress"></div>
|
||||
<div id="exitLinkWarnings"></div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('exit_link_select') }}</label>
|
||||
<select class="form-input" id="exitLinkSelect"></select>
|
||||
<div class="form-hint" id="exitLinkSelectHint"></div>
|
||||
</div>
|
||||
<div class="form-group hidden" id="exitDnsGroup">
|
||||
<label class="form-label" style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||
<input type="checkbox" id="exitDnsViaExit" onchange="saveExitDns(this.checked)"> {{ _('exit_dns_via_exit') }}
|
||||
</label>
|
||||
<div class="form-hint">{{ _('exit_dns_via_exit_hint') }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('exitLinkModal')">{{ _('cancel') }}</button>
|
||||
<button class="btn btn-secondary hidden" onclick="checkExitEgress()" id="exitEgressBtn">{{ _('exit_egress_check') }}</button>
|
||||
<button class="btn btn-secondary hidden" onclick="repairExitLink()" id="exitRepairBtn">{{ _('exit_link_repair') }}</button>
|
||||
<button class="btn btn-danger hidden" onclick="removeExitLink()" id="exitUnlinkBtn">{{ _('exit_unlink_btn') }}</button>
|
||||
<button class="btn btn-primary" onclick="saveExitLink(false)" id="exitLinkBtn">
|
||||
<span id="exitLinkBtnText">{{ _('exit_link_btn') }}</span>
|
||||
<div class="spinner hidden" id="exitLinkSpinner"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SOCKS5 Settings Modal ===== -->
|
||||
<div class="modal-backdrop" id="socks5SettingsModal">
|
||||
<div class="modal">
|
||||
@@ -1161,6 +1252,7 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
|
||||
<script>
|
||||
const SERVER_ID = {{ server_id }};
|
||||
document.body.dataset.serverUid = {{ (server.uid or '') | tojson }};
|
||||
const SERVER_HOST = "{{ server.host }}";
|
||||
const CUSTOM_PROTO_NAMES = {
|
||||
{%- for proto, info in (server.get('protocols') or {}).items() if info.get('custom_name') %}
|
||||
@@ -1178,6 +1270,7 @@
|
||||
{ proto: 'dns', category: 'services', icon: '🔍', title: 'AmneziaDNS', descKey: 'dns_desc' },
|
||||
{ proto: 'adguard', category: 'services', icon: '🛡️', title: 'AdGuard Home', descKey: 'adguard_desc' },
|
||||
{ proto: 'socks5', category: 'services', icon: '🧦', title: 'SOCKS5 Proxy', descKey: 'socks5_desc' },
|
||||
{ proto: 'exit', category: 'services', icon: '🚪', title: 'Exit Node', descKey: 'exit_desc', badge: 'NEW' },
|
||||
{ proto: 'nginx', category: 'web_servers', icon: '🌐', title: 'NGINX', descKey: 'nginx_desc' },
|
||||
];
|
||||
const MARKETPLACE_CATEGORIES = [
|
||||
@@ -1248,7 +1341,7 @@
|
||||
// ---- Used-port awareness for the install modal ----
|
||||
// Fetches real listening ports from the server (not just same-family
|
||||
// protocol records) so the suggested port is actually free.
|
||||
const UDP_INSTALL_PROTOS = new Set(['awg', 'awg2', 'awg3', 'awg_legacy', 'wireguard']);
|
||||
const UDP_INSTALL_PROTOS = new Set(['awg', 'awg2', 'awg3', 'awg_legacy', 'wireguard', 'exit']);
|
||||
const SKIP_PORT_CHECK = new Set(['dns', 'adguard']); // internal bindings
|
||||
window.usedPorts = { ts: 0, udp: {}, tcp: {} };
|
||||
|
||||
@@ -1502,6 +1595,7 @@
|
||||
case 'socks5': title = 'SOCKS5 Proxy'; break;
|
||||
case 'adguard': title = 'AdGuard Home'; break;
|
||||
case 'nginx': title = 'NGINX'; break;
|
||||
case 'exit': title = 'Exit Node'; break;
|
||||
default: title = proto;
|
||||
}
|
||||
return idx > 1 ? `${title} #${idx}` : title;
|
||||
@@ -1701,7 +1795,7 @@
|
||||
|
||||
// Services that don't have per-user "connections" — they expose a single
|
||||
// shared endpoint (DNS/proxy/admin UI) rather than per-client configs.
|
||||
const SERVICE_PROTOS = ['dns', 'socks5', 'adguard', 'nginx'];
|
||||
const SERVICE_PROTOS = ['dns', 'socks5', 'adguard', 'nginx', 'exit'];
|
||||
|
||||
function buildServiceInfoGrid(proto, info) {
|
||||
// Custom info-grid markup for non-VPN services. Returns HTML for the grid body.
|
||||
@@ -1729,6 +1823,16 @@
|
||||
<div class="protocol-info-item"><span class="protocol-info-label">${_('adguard_web_ui')}</span><span class="protocol-info-value"><a href="${url}" target="_blank" rel="noopener">${url}</a></span></div>
|
||||
`;
|
||||
}
|
||||
if (base === 'exit') {
|
||||
const key = info.public_key ? `<span title="${escapeHtml(info.public_key)}">${escapeHtml(info.public_key.slice(0, 12))}…</span>` : '—';
|
||||
return `
|
||||
<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port || '—'}/UDP</span></div>
|
||||
<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_subnet')}</span><span class="protocol-info-value">${escapeHtml(info.subnet || '—')}</span></div>
|
||||
<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_peers')}</span><span class="protocol-info-value">${info.peers_count ?? 0}</span></div>
|
||||
<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_obfuscation_short')}</span><span class="protocol-info-value">${info.obfuscation ? _('on') : _('off')}</span></div>
|
||||
<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_public_key')}</span><span class="protocol-info-value">${key}</span></div>
|
||||
`;
|
||||
}
|
||||
if (base === 'nginx') {
|
||||
const domain = info.domain || '—';
|
||||
const port = info.port || 443;
|
||||
@@ -1803,6 +1907,9 @@
|
||||
if (!isService && info.clients_count !== undefined) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`;
|
||||
}
|
||||
if (!isService && info.exit_link) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_link_via')}</span><span class="protocol-info-value">${escapeHtml(info.exit_link.exit_name || '')} ${exitStatusBadge(info.exit_link_status, info.exit_link)}</span></div>`;
|
||||
}
|
||||
infoGrid.innerHTML = grid;
|
||||
}
|
||||
if (protoBase(proto) === 'socks5') {
|
||||
@@ -1833,6 +1940,12 @@
|
||||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')" style="flex:1">${_('backup')}</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||||
`;
|
||||
} else if (protoBase(proto) === 'exit') {
|
||||
actionsEl.innerHTML = `
|
||||
<button class="btn btn-secondary btn-sm" onclick="showExitPeers('${proto}')" style="flex:1">${_('exit_peers')}</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||||
`;
|
||||
} else {
|
||||
actionsEl.innerHTML = `
|
||||
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
|
||||
@@ -1859,8 +1972,12 @@
|
||||
const awgBtn = AWG_PROTOCOLS.includes(protoBase(proto))
|
||||
? `<button class="btn btn-secondary btn-sm" onclick="openAwgSettings('${proto}')" title="${_('awg_settings_title')}">${_('awg_settings_btn')}</button>`
|
||||
: '';
|
||||
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>`
|
||||
: '';
|
||||
ctrlEl.innerHTML = `
|
||||
${awgBtn}
|
||||
${exitBtn}
|
||||
<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>
|
||||
`;
|
||||
@@ -1935,7 +2052,15 @@
|
||||
if (!confirm(`${isRunning ? _('stop_container_confirm') : _('start_container_confirm')}`.replace('{}', name))) return;
|
||||
try {
|
||||
showToast(`${action} ${name}...`, 'info');
|
||||
const res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto });
|
||||
let res;
|
||||
try {
|
||||
res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto });
|
||||
} catch (err) {
|
||||
if (err.message !== 'exit_in_use') throw err;
|
||||
// Entry nodes route through this exit: their clients land on the kill-switch
|
||||
if (!confirm(_('exit_stop_confirm'))) return;
|
||||
res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto, force: true });
|
||||
}
|
||||
showToast(`${name} ${res.action === 'stopped' ? _('stopped') : _('started')}`, 'success');
|
||||
// Recheck after a short delay
|
||||
setTimeout(() => checkServer(), 1500);
|
||||
@@ -2257,12 +2382,14 @@
|
||||
const adguardOpts = document.getElementById('adguardOptions');
|
||||
const nginxOpts = document.getElementById('nginxOptions');
|
||||
const awgOpts = document.getElementById('awgOptions');
|
||||
const exitOpts = document.getElementById('exitOptions');
|
||||
|
||||
telemtOpts.style.display = 'none';
|
||||
socks5Opts.style.display = 'none';
|
||||
adguardOpts.style.display = 'none';
|
||||
nginxOpts.style.display = 'none';
|
||||
awgOpts.style.display = 'none';
|
||||
exitOpts.style.display = 'none';
|
||||
|
||||
if (base === 'dns') {
|
||||
portLabel.textContent = _('port') + ' (Internal)';
|
||||
@@ -2299,6 +2426,12 @@
|
||||
portHint.textContent = _('nginx_port_hint');
|
||||
nginxOpts.style.display = 'block';
|
||||
updateNginxDnsHint();
|
||||
} else if (base === 'exit') {
|
||||
portLabel.textContent = _('port') + ' (UDP)';
|
||||
portInput.value = '55520';
|
||||
portInput.disabled = false;
|
||||
portHint.textContent = _('exit_port_hint');
|
||||
exitOpts.style.display = 'block';
|
||||
} else {
|
||||
portLabel.textContent = _('port') + ' (UDP)';
|
||||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424';
|
||||
@@ -2385,6 +2518,9 @@
|
||||
} else if (protoBase(currentInstallProto) === 'nginx') {
|
||||
params.nginx_domain = document.getElementById('installNginxDomain').value.trim();
|
||||
params.nginx_email = document.getElementById('installNginxEmail').value.trim();
|
||||
} else if (protoBase(currentInstallProto) === 'exit') {
|
||||
params.exit_subnet = document.getElementById('installExitSubnet').value.trim();
|
||||
params.exit_obfuscation = document.getElementById('installExitObfuscation').checked;
|
||||
} else if (AWG_PROTOCOLS.includes(protoBase(currentInstallProto))) {
|
||||
params.awg_mtu = document.getElementById('installAwgMtu').value.trim();
|
||||
params.awg_dns1 = document.getElementById('installAwgDns1').value.trim();
|
||||
@@ -2569,11 +2705,20 @@
|
||||
async function restoreProtocolBackup(proto, filename) {
|
||||
if (!confirm(_('restore_confirm_protocol'))) return;
|
||||
try {
|
||||
await apiCall(`/api/servers/${SERVER_ID}/backups/restore`, 'POST', {
|
||||
const res = await apiCall(`/api/servers/${SERVER_ID}/backups/restore`, 'POST', {
|
||||
protocol: proto,
|
||||
filename: filename
|
||||
});
|
||||
showToast(_('restore_success'), 'success');
|
||||
// an archive can carry a different exit-link state than the panel has
|
||||
const relinked = (res.exit_entries_relinked || []).filter(e => e.status === 'success').length;
|
||||
const failed = (res.exit_entries_relinked || []).filter(e => e.status !== 'success').length;
|
||||
if (relinked) showToast(_('exit_restore_entries_relinked').replace('{n}', relinked), 'info');
|
||||
if (failed) showToast(_('exit_restore_entries_failed').replace('{n}', failed), 'error');
|
||||
if (res.exit_link_restored === 'relinked') showToast(_('exit_restore_relinked'), 'info');
|
||||
if (res.exit_link_restored === 'stale') showToast(_('exit_restore_stale'), 'error');
|
||||
if (res.exit_link_restored === 'removed') showToast(_('exit_restore_removed'), 'info');
|
||||
if (res.exit_link_restored === 'orphan') showToast(_('exit_restore_orphan'), 'error');
|
||||
await loadProtocolBackups();
|
||||
if (typeof loadConnections === 'function') {
|
||||
await loadConnections();
|
||||
@@ -2618,6 +2763,13 @@
|
||||
document.getElementById('awgSetMtu').value = data.mtu || '1376';
|
||||
document.getElementById('awgSetDns1').value = data.dns1 || '';
|
||||
document.getElementById('awgSetDns2').value = data.dns2 || '';
|
||||
const exitHint = document.getElementById('awgSetMtuExitHint');
|
||||
exitHint.classList.toggle('hidden', !data.exit_linked);
|
||||
if (data.exit_linked) {
|
||||
const over = Number(data.mtu) > Number(data.exit_mtu);
|
||||
exitHint.textContent = _('awg_mtu_exit_hint').replace('{exit_mtu}', data.exit_mtu);
|
||||
exitHint.style.color = over ? 'var(--danger, #f87171)' : '';
|
||||
}
|
||||
const junkGroup = document.getElementById('awgSetJunkGroup');
|
||||
junkGroup.style.display = data.supports_special_junk ? '' : 'none';
|
||||
SPECIAL_JUNK_KEYS.forEach(key => {
|
||||
@@ -2661,6 +2813,220 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Exit node ==========
|
||||
let currentExitPeersProto = 'exit';
|
||||
let currentExitLinkProto = null;
|
||||
let currentExitLinkState = null;
|
||||
const EXIT_LINK_ERROR_KEYS = ['exit_no_handshake', 'exit_mtu_too_big', 'exit_self', 'exit_legacy_obfuscation',
|
||||
'exit_not_found', 'exit_apply_failed', 'exit_in_use', 'exit_egress_failed', 'exit_dns_needs_dns',
|
||||
'not_linked'];
|
||||
|
||||
function exitStatusBadge(status, link) {
|
||||
const badge = (cls, text, title = '') => `<span class="badge ${cls}" title="${escapeHtml(title)}"><span class="badge-dot"></span> ${escapeHtml(text)}</span>`;
|
||||
if (link && link.stale) return badge('badge-danger', _('exit_status_down'), _('exit_link_stale'));
|
||||
if (!status) return badge('badge-secondary', _('exit_status_down'));
|
||||
if (status.error) return badge('badge-danger', _('exit_status_down'), status.error);
|
||||
const age = status.handshake_age;
|
||||
const ago = (age === null || age === undefined) ? '' : ' · ' + _('exit_handshake_ago').replace('{s}', age);
|
||||
if (status.up) return badge('badge-success', _('exit_status_up') + ago, `${formatBytes(status.rx_bytes || 0)} ↓ ${formatBytes(status.tx_bytes || 0)} ↑`);
|
||||
return badge('badge-warn', _('exit_status_stale') + ago);
|
||||
}
|
||||
|
||||
function exitLinkErrorText(err) {
|
||||
return EXIT_LINK_ERROR_KEYS.includes(err.message) ? _(err.message) : err.message;
|
||||
}
|
||||
|
||||
async function showExitPeers(proto = 'exit') {
|
||||
currentExitPeersProto = proto || 'exit';
|
||||
const list = document.getElementById('exitPeersList');
|
||||
const infoEl = document.getElementById('exitPeersInfo');
|
||||
list.innerHTML = `<div class="empty-state">${_('loading')}</div>`;
|
||||
openModal('exitPeersModal');
|
||||
try {
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/exit/peers`, 'POST', { protocol: currentExitPeersProto });
|
||||
const info = data.info || {};
|
||||
infoEl.textContent = `${_('port')}: ${info.port || '—'}/UDP · ${_('exit_subnet')}: ${info.subnet || '—'} · ${_('exit_obfuscation_short')}: ${info.obfuscation ? _('on') : _('off')}`;
|
||||
const e = escapeHtml;
|
||||
if (!data.peers || !data.peers.length) {
|
||||
list.innerHTML = `<div class="empty-state">${_('exit_no_peers')}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = `<div class="table-wrap"><table class="table"><thead><tr>
|
||||
<th>${_('exit_peer_name')}</th><th>${_('exit_transit_ip')}</th><th>${_('exit_public_key')}</th><th>${_('exit_handshake')}</th><th>${_('exit_transfer')}</th><th></th>
|
||||
</tr></thead><tbody>` + data.peers.map(p => `<tr${p.external ? ' style="opacity:.7"' : ''}>
|
||||
<td>${e(p.name || p.peer_id || '—')}${p.external ? ` <span class="badge badge-warn">${_('exit_peer_external')}</span>` : ''}</td>
|
||||
<td>${e(p.transit_ip || '—')}</td>
|
||||
<td><code title="${e(p.public_key)}">${e((p.public_key || '').slice(0, 12))}…</code></td>
|
||||
<td>${e(p.latest_handshake || '—')}</td>
|
||||
<td>${e(p.rx || '0 B')} ↓ ${e(p.tx || '0 B')} ↑</td>
|
||||
<td><button class="btn btn-danger btn-sm" data-key="${e(p.public_key)}" onclick="removeExitPeer(this.dataset.key)">${_('uninstall')}</button></td>
|
||||
</tr>`).join('') + `</tbody></table></div>`;
|
||||
} catch (err) {
|
||||
list.innerHTML = `<div class="empty-state">${_('error')}: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeExitPeer(publicKey) {
|
||||
if (!confirm(_('exit_peer_remove_confirm'))) return;
|
||||
try {
|
||||
await apiCall(`/api/servers/${SERVER_ID}/exit/peers/remove`, 'POST', { protocol: currentExitPeersProto, public_key: publicKey });
|
||||
showToast(_('exit_peer_removed'), 'success');
|
||||
showExitPeers(currentExitPeersProto);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderExitLinkState(state) {
|
||||
const el = document.getElementById('exitLinkState');
|
||||
const warn = document.getElementById('exitLinkWarnings');
|
||||
const link = state.exit_link;
|
||||
const unlinkBtn = document.getElementById('exitUnlinkBtn');
|
||||
const repairBtn = document.getElementById('exitRepairBtn');
|
||||
unlinkBtn.classList.toggle('hidden', !link);
|
||||
document.getElementById('exitEgressBtn').classList.toggle('hidden', !link);
|
||||
document.getElementById('exitDnsGroup').classList.toggle('hidden', !link);
|
||||
document.getElementById('exitDnsViaExit').checked = !!(link && link.dns_via_exit);
|
||||
repairBtn.classList.toggle('hidden', !(link && (link.stale || !(state.exit_link_status && state.exit_link_status.up))));
|
||||
if (!link) {
|
||||
el.innerHTML = `<div class="form-hint">${_('exit_link_none')}</div>`;
|
||||
} else {
|
||||
el.innerHTML = `<div>${escapeHtml(_('exit_link_current').replace('{name}', link.exit_name || '').replace('{ip}', link.transit_ip || ''))} ${exitStatusBadge(state.exit_link_status, link)}</div>`
|
||||
+ (link.stale ? `<div class="form-hint" style="color:var(--danger, #f87171)">${_('exit_link_stale')}</div>` : '');
|
||||
}
|
||||
let warnings = '';
|
||||
if (state.ipv6) warnings += `<div class="form-hint">⚠ ${_('exit_link_ipv6_warning')}</div>`;
|
||||
if (state.mtu && state.exit_mtu && state.mtu > state.exit_mtu) {
|
||||
warnings += `<div class="form-hint" style="color:var(--danger, #f87171)">⚠ ${_('exit_link_mtu_warning').replace('{mtu}', state.mtu).replace('{exit_mtu}', state.exit_mtu)}</div>`;
|
||||
}
|
||||
warn.innerHTML = warnings;
|
||||
}
|
||||
|
||||
async function openExitLinkModal(proto) {
|
||||
currentExitLinkProto = proto;
|
||||
document.getElementById('exitLinkTitle').textContent = `${_('exit_link_title')} — ${getProtoTitle(proto)}`;
|
||||
const select = document.getElementById('exitLinkSelect');
|
||||
const hint = document.getElementById('exitLinkSelectHint');
|
||||
select.innerHTML = '';
|
||||
hint.textContent = '';
|
||||
document.getElementById('exitLinkState').innerHTML = `<div class="form-hint">${_('loading')}</div>`;
|
||||
document.getElementById('exitLinkWarnings').innerHTML = '';
|
||||
document.getElementById('exitLinkEgress').innerHTML = '';
|
||||
// hidden until the state says this instance is linked
|
||||
document.getElementById('exitEgressBtn').classList.add('hidden');
|
||||
openModal('exitLinkModal');
|
||||
try {
|
||||
const [nodes, state] = await Promise.all([
|
||||
apiCall('/api/exit-nodes'),
|
||||
apiCall(`/api/servers/${SERVER_ID}/exit-link/status`, 'POST', { protocol: proto }),
|
||||
]);
|
||||
currentExitLinkState = state;
|
||||
const own = document.body.dataset.serverUid || '';
|
||||
const candidates = (nodes.exit_nodes || []).filter(n => n.uid !== own);
|
||||
if (!candidates.length) {
|
||||
hint.textContent = _('exit_no_exit_nodes');
|
||||
}
|
||||
const legacy = protoBase(proto) === 'awg_legacy';
|
||||
candidates.forEach(n => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = n.uid;
|
||||
opt.textContent = `${n.name} (${n.host}:${n.port || '?'})${n.obfuscation ? ' [AWG]' : ''}`;
|
||||
if (legacy && n.obfuscation) { opt.disabled = true; opt.title = _('exit_legacy_obfuscation'); }
|
||||
if (state.exit_link && state.exit_link.exit_uid === n.uid) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
document.getElementById('exitLinkBtn').disabled = !candidates.length;
|
||||
renderExitLinkState(state);
|
||||
} catch (err) {
|
||||
document.getElementById('exitLinkState').innerHTML = `<div class="form-hint">${_('error')}: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function exitLinkRequest(path, body, successKey) {
|
||||
const btn = document.getElementById('exitLinkBtn');
|
||||
const spinner = document.getElementById('exitLinkSpinner');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
const res = await apiCall(`/api/servers/${SERVER_ID}/exit-link${path}`, 'POST', body);
|
||||
(res.warnings || []).forEach(w => showToast(_(w), 'info'));
|
||||
showToast(_(successKey), 'success');
|
||||
closeModal('exitLinkModal');
|
||||
setTimeout(() => checkServer(), 500);
|
||||
return res;
|
||||
} catch (err) {
|
||||
const text = exitLinkErrorText(err);
|
||||
if (err.message === 'exit_no_handshake' || err.message === 'exit_mtu_too_big') {
|
||||
if (confirm(`${text}\n\n${_('exit_link_force')}?`)) {
|
||||
return exitLinkRequest(path, { ...body, force: true }, successKey);
|
||||
}
|
||||
} else {
|
||||
showToast(_('error') + ': ' + text, 'error');
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveExitLink(force = false) {
|
||||
const uid = document.getElementById('exitLinkSelect').value;
|
||||
if (!uid) return;
|
||||
if (currentExitLinkState && currentExitLinkState.ipv6 && !confirm(_('exit_link_ipv6_confirm'))) return;
|
||||
await exitLinkRequest('', { protocol: currentExitLinkProto, exit_uid: uid, force }, 'exit_link_saved');
|
||||
}
|
||||
|
||||
async function removeExitLink() {
|
||||
if (!confirm(_('exit_unlink_confirm'))) return;
|
||||
await exitLinkRequest('/remove', { protocol: currentExitLinkProto }, 'exit_unlinked');
|
||||
}
|
||||
|
||||
async function checkExitEgress() {
|
||||
const box = document.getElementById('exitLinkEgress');
|
||||
const btn = document.getElementById('exitEgressBtn');
|
||||
box.innerHTML = `<div class="form-hint">${_('exit_egress_checking')}</div>`;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await apiCall(`/api/servers/${SERVER_ID}/exit-link/check-egress`, 'POST', { protocol: currentExitLinkProto });
|
||||
const via = escapeHtml(r.via_exit || '');
|
||||
let text, color;
|
||||
if (!r.via_exit) {
|
||||
text = _('exit_egress_unknown'); color = 'var(--danger, #f87171)';
|
||||
} else if (r.leaks) {
|
||||
text = _('exit_egress_direct').replace('{ip}', via); color = 'var(--danger, #f87171)';
|
||||
} else if (r.matches) {
|
||||
text = _('exit_egress_match').replace('{ip}', via).replace('{name}', escapeHtml(r.exit_name || '')); color = 'var(--success, #4ade80)';
|
||||
} else {
|
||||
text = _('exit_egress_mismatch').replace('{ip}', via).replace('{expected}', escapeHtml(r.expected_ip || r.expected || '')); color = 'var(--warning, #fbbf24)';
|
||||
}
|
||||
box.innerHTML = `<div class="form-hint" style="color:${color}">${text}</div>`;
|
||||
} catch (err) {
|
||||
box.innerHTML = `<div class="form-hint" style="color:var(--danger, #f87171)">${_('error')}: ${escapeHtml(exitLinkErrorText(err))}</div>`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveExitDns(enabled) {
|
||||
const box = document.getElementById('exitDnsViaExit');
|
||||
box.disabled = true;
|
||||
try {
|
||||
await apiCall(`/api/servers/${SERVER_ID}/exit-link/dns`, 'POST',
|
||||
{ protocol: currentExitLinkProto, enabled: !!enabled });
|
||||
showToast(_(enabled ? 'exit_dns_via_exit_on' : 'exit_dns_via_exit_off'), 'success');
|
||||
if (currentExitLinkState && currentExitLinkState.exit_link) currentExitLinkState.exit_link.dns_via_exit = !!enabled;
|
||||
} catch (err) {
|
||||
box.checked = !enabled;
|
||||
showToast(_('error') + ': ' + exitLinkErrorText(err), 'error');
|
||||
} finally {
|
||||
box.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function repairExitLink() {
|
||||
await exitLinkRequest('/relink', { protocol: currentExitLinkProto }, 'exit_link_saved');
|
||||
}
|
||||
|
||||
async function openSocks5Settings(proto = 'socks5') {
|
||||
currentSocks5Proto = proto || 'socks5';
|
||||
try {
|
||||
|
||||
+35
-2
@@ -20,6 +20,7 @@
|
||||
<a href="#settings-import">{{ _('import_users_title') }}</a>
|
||||
<a href="#settings-backup">{{ _('backup_title') }}</a>
|
||||
<a href="#settings-tokens">{{ _('api_tokens_title') }}</a>
|
||||
<a href="#settings-exit-nodes">{{ _('exit_defaults_title') }}</a>
|
||||
<a href="#settings-self-service">{{ _('self_service_title') }}</a>
|
||||
<a href="#settings-about">{{ _('about_title') }}</a>
|
||||
</nav>
|
||||
@@ -442,6 +443,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK: Exit nodes -->
|
||||
<div class="card" id="settings-exit-nodes" style="margin-top: var(--space-lg);">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🚪 {{ _('exit_defaults_title') }}</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('exit_default_node') }}</label>
|
||||
<select class="form-input" id="exitDefaultNode"><option value="">{{ _('exit_default_none') }}</option></select>
|
||||
<p class="form-hint">{{ _('exit_default_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK: Self-Service -->
|
||||
<div class="card" id="settings-self-service" style="margin-top: var(--space-lg);">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right: 6px; vertical-align: -2px;"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>{{ _('self_service_title') }}</h3>
|
||||
@@ -594,7 +605,7 @@
|
||||
let count = 0;
|
||||
|
||||
for (const [key, info] of Object.entries(protocols)) {
|
||||
if (info.installed) {
|
||||
if (info.installed && key !== 'exit') { // exit node has no client connections
|
||||
const opt = document.createElement('option');
|
||||
opt.value = key;
|
||||
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : key.toUpperCase())));
|
||||
@@ -935,13 +946,17 @@
|
||||
allowed_protocols: Array.from(document.querySelectorAll('input[name="ss_proto"]:checked')).map(el => el.value),
|
||||
};
|
||||
|
||||
const exit_nodes = { default_exit_uid: document.getElementById('exitDefaultNode').value || '' };
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, auto_backup, self_service })
|
||||
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, auto_backup, self_service, exit_nodes })
|
||||
});
|
||||
if (!res.ok) throw new Error(_('error'));
|
||||
const saved = await res.json();
|
||||
(saved.warnings || []).forEach(w => showToast(_(w), 'info'));
|
||||
showToast(_('settings_saved'), 'success');
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} catch (err) {
|
||||
@@ -1156,9 +1171,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExitNodes() {
|
||||
const select = document.getElementById('exitDefaultNode');
|
||||
const current = {{ (settings.exit_nodes.default_exit_uid if settings.exit_nodes else '') | tojson }};
|
||||
try {
|
||||
const data = await (await fetch('/api/exit-nodes')).json();
|
||||
(data.exit_nodes || []).forEach(node => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = node.uid;
|
||||
opt.textContent = `${node.name} (${node.host}:${node.port || '?'})`;
|
||||
if (node.uid === current) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
select.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTokens();
|
||||
loadTunnelStatus();
|
||||
loadExitNodes();
|
||||
tunnelStatusTimer = setInterval(loadTunnelStatus, 10000);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -623,7 +623,7 @@
|
||||
let count = 0;
|
||||
|
||||
for (const [key, info] of Object.entries(protocols)) {
|
||||
if (info.installed) {
|
||||
if (info.installed && key !== 'exit') { // exit node has no client connections
|
||||
const opt = document.createElement('option');
|
||||
opt.value = key;
|
||||
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : key.toUpperCase())));
|
||||
|
||||
Vendored
+1
-1
@@ -2,7 +2,7 @@ FROM amneziavpn/amneziawg-go:latest
|
||||
|
||||
LABEL maintainer="AmneziaVPN"
|
||||
|
||||
RUN apk add --no-cache bash curl dumb-init iptables
|
||||
RUN apk add --no-cache bash curl dumb-init iptables iproute2 conntrack-tools
|
||||
RUN apk --update upgrade --no-cache
|
||||
|
||||
RUN sed -i 's|if ! cmd ip link add "$INTERFACE" type amneziawg; then|if [ -n "$WG_FORCE_USERSPACE" ]; then cmd "${WG_QUICK_USERSPACE_IMPLEMENTATION:-amneziawg-go}" "$INTERFACE"; return 0; fi; if ! cmd ip link add "$INTERFACE" type amneziawg; then|' /usr/bin/awg-quick && grep -q WG_FORCE_USERSPACE /usr/bin/awg-quick
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ FROM amneziavpn/amnezia-wg:latest
|
||||
|
||||
LABEL maintainer="AmneziaVPN"
|
||||
|
||||
RUN apk add --no-cache bash curl dumb-init iptables
|
||||
RUN apk add --no-cache bash curl dumb-init iptables iproute2 conntrack-tools
|
||||
RUN apk --update upgrade --no-cache
|
||||
|
||||
|
||||
|
||||
Vendored
+87
@@ -13,6 +13,90 @@ fi
|
||||
# IPv6 subnet, if the tunnel is dual-stack (second part of the Address line)
|
||||
SUBNET6=$(grep '^Address' /opt/amnezia/awg/awg0.conf | head -1 | tr ',' '
|
||||
' | grep ':' | sed 's/^[^=]*=//' | tr -d ' ' | head -1)
|
||||
# ---- Exit-node link (panel-managed; driven by /opt/amnezia/awg/exit/exit0.conf) ----
|
||||
EXIT_CONF=/opt/amnezia/awg/exit/exit0.conf
|
||||
EXIT_IF=exit0
|
||||
EXIT_TABLE=200
|
||||
EXIT_PREF=200 # slot 0: 190/191 exceptions, catch-all at the table number
|
||||
DNS_NET=172.29.172.0/24
|
||||
X_CONF=/opt/amnezia/awg/awg0.conf
|
||||
X_QUICK=awg-quick
|
||||
X_IFACE=$(basename $X_CONF .conf)
|
||||
X_SUBNET=$(grep '^Address' $X_CONF | head -1 | cut -d'=' -f2 | cut -d',' -f1 | tr -d ' ')
|
||||
[ -n "$X_SUBNET" ] || X_SUBNET=10.8.1.1/24
|
||||
X_SUBNET6=$(grep '^Address' $X_CONF | head -1 | tr ',' '\n' | grep ':' | sed 's/^[^=]*=//' | tr -d ' ' | head -1)
|
||||
|
||||
x_rule() { ip "$1" rule show 2>/dev/null | grep -q "^$2:" || ip "$1" rule add pref "$2" "${@:3}"; }
|
||||
x_unrule() { while ip "$1" rule del pref "$2" 2>/dev/null; do :; done; }
|
||||
x_ipt_add() { iptables -t "$1" -C "${@:2}" 2>/dev/null || iptables -t "$1" -A "${@:2}"; }
|
||||
x_ipt_del() { while iptables -t "$1" -D "${@:2}" 2>/dev/null; do :; done; }
|
||||
|
||||
x_killswitch_on() {
|
||||
# Everything from the client subnet is answered by the exit table. Until
|
||||
# exit0 is up that table holds only a blackhole, so nothing falls through
|
||||
# to eth0 - this runs before the client tunnel comes up on purpose.
|
||||
ip -4 route replace blackhole default metric 1000 table $EXIT_TABLE
|
||||
x_rule -4 190 from $X_SUBNET to $X_SUBNET lookup main
|
||||
if grep -qs '^# DnsViaExit = on' "$EXIT_CONF"; then x_unrule -4 191
|
||||
else x_rule -4 191 from $X_SUBNET to $DNS_NET lookup main; fi
|
||||
x_rule -4 $EXIT_PREF from $X_SUBNET lookup $EXIT_TABLE
|
||||
if [ -n "$X_SUBNET6" ]; then
|
||||
# No IPv6 path through the exit yet: refuse fast instead of leaking via eth0
|
||||
ip -6 route replace unreachable default table $EXIT_TABLE
|
||||
x_rule -6 190 from $X_SUBNET6 to $X_SUBNET6 lookup main
|
||||
x_rule -6 $EXIT_PREF from $X_SUBNET6 lookup $EXIT_TABLE
|
||||
fi
|
||||
}
|
||||
x_killswitch_off() {
|
||||
for p in 190 191 $EXIT_PREF; do x_unrule -4 $p; x_unrule -6 $p; done
|
||||
ip -4 route flush table $EXIT_TABLE 2>/dev/null
|
||||
ip -6 route flush table $EXIT_TABLE 2>/dev/null
|
||||
}
|
||||
x_link_up() {
|
||||
$X_QUICK down $EXIT_CONF 2>/dev/null
|
||||
if ! $X_QUICK up $EXIT_CONF; then
|
||||
echo "! $EXIT_IF failed to start; clients stay on the kill-switch until the link is repaired or removed"
|
||||
return 3
|
||||
fi
|
||||
# Loose reverse-path check on the link only: containers inherit rp_filter
|
||||
# from the host, and per-client exit tables (later) can make the reverse
|
||||
# lookup resolve to another exitN.
|
||||
sysctl -w net.ipv4.conf.$EXIT_IF.rp_filter=2 >/dev/null 2>&1 || true
|
||||
ip -4 route replace default dev $EXIT_IF table $EXIT_TABLE
|
||||
x_ipt_add filter FORWARD -i $X_IFACE -o $EXIT_IF -s $X_SUBNET -j ACCEPT
|
||||
# SNAT clients into this node's transit address: the exit sees one /32 per entry
|
||||
x_ipt_add nat POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE
|
||||
x_ipt_add mangle FORWARD -o $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true
|
||||
x_ipt_add mangle FORWARD -i $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true
|
||||
# NAT is decided once per flow: flows opened before the link were SNATed to
|
||||
# eth0 and would leave exit0 with the wrong source. Drop them so they reopen.
|
||||
if command -v conntrack >/dev/null 2>&1; then conntrack -D -s $X_SUBNET >/dev/null 2>&1
|
||||
else echo "! conntrack-tools missing: client flows opened before the link keep leaving via eth0 until they end"; fi
|
||||
# Never report success while clients could still leave via eth0
|
||||
if ! ip -4 rule show | grep -q "^$EXIT_PREF:" \
|
||||
|| ! ip -4 route show table $EXIT_TABLE | grep -q "^default dev $EXIT_IF" \
|
||||
|| ! iptables -t nat -C POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE 2>/dev/null; then
|
||||
echo "! FATAL: policy routing for $EXIT_IF is not in place"
|
||||
return 3
|
||||
fi
|
||||
echo "exit link up: $(ip -4 -o addr show dev $EXIT_IF | awk '{print $4}') -> table $EXIT_TABLE"
|
||||
}
|
||||
x_link_down() {
|
||||
# Take the interface down first: MASQUERADE forgets the flows NATed through
|
||||
# it (device notifier) and the table keeps blackholing until the rules go.
|
||||
[ -f "$EXIT_CONF" ] && $X_QUICK down $EXIT_CONF 2>/dev/null
|
||||
ip link del $EXIT_IF 2>/dev/null
|
||||
x_ipt_del filter FORWARD -i $X_IFACE -o $EXIT_IF -s $X_SUBNET -j ACCEPT
|
||||
x_ipt_del nat POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE
|
||||
x_ipt_del mangle FORWARD -o $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
x_ipt_del mangle FORWARD -i $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
}
|
||||
x_exit_sync() {
|
||||
if [ -f "$EXIT_CONF" ]; then x_killswitch_on && x_link_up
|
||||
else x_link_down; x_killswitch_off; fi
|
||||
}
|
||||
[ -f "$EXIT_CONF" ] && x_killswitch_on
|
||||
|
||||
|
||||
# kill daemons in case of restart
|
||||
awg-quick down /opt/amnezia/awg/awg0.conf 2>/dev/null
|
||||
@@ -79,4 +163,7 @@ done < "$BW"
|
||||
)
|
||||
fi
|
||||
|
||||
# ---- Exit-node link: bring it up now that the client tunnel exists ----
|
||||
if [ -f "$EXIT_CONF" ]; then x_link_up || echo "! exit link not applied, clients stay on the kill-switch"; fi
|
||||
|
||||
tail -f /dev/null
|
||||
Vendored
+87
@@ -13,6 +13,90 @@ fi
|
||||
# IPv6 subnet, if the tunnel is dual-stack (second part of the Address line)
|
||||
SUBNET6=$(grep '^Address' /opt/amnezia/awg/awg0.conf | head -1 | tr ',' '
|
||||
' | grep ':' | sed 's/^[^=]*=//' | tr -d ' ' | head -1)
|
||||
# ---- Exit-node link (panel-managed; driven by /opt/amnezia/awg/exit/exit0.conf) ----
|
||||
EXIT_CONF=/opt/amnezia/awg/exit/exit0.conf
|
||||
EXIT_IF=exit0
|
||||
EXIT_TABLE=200
|
||||
EXIT_PREF=200 # slot 0: 190/191 exceptions, catch-all at the table number
|
||||
DNS_NET=172.29.172.0/24
|
||||
X_CONF=/opt/amnezia/awg/awg0.conf
|
||||
X_QUICK=awg-quick
|
||||
X_IFACE=$(basename $X_CONF .conf)
|
||||
X_SUBNET=$(grep '^Address' $X_CONF | head -1 | cut -d'=' -f2 | cut -d',' -f1 | tr -d ' ')
|
||||
[ -n "$X_SUBNET" ] || X_SUBNET=10.8.1.1/24
|
||||
X_SUBNET6=$(grep '^Address' $X_CONF | head -1 | tr ',' '\n' | grep ':' | sed 's/^[^=]*=//' | tr -d ' ' | head -1)
|
||||
|
||||
x_rule() { ip "$1" rule show 2>/dev/null | grep -q "^$2:" || ip "$1" rule add pref "$2" "${@:3}"; }
|
||||
x_unrule() { while ip "$1" rule del pref "$2" 2>/dev/null; do :; done; }
|
||||
x_ipt_add() { iptables -t "$1" -C "${@:2}" 2>/dev/null || iptables -t "$1" -A "${@:2}"; }
|
||||
x_ipt_del() { while iptables -t "$1" -D "${@:2}" 2>/dev/null; do :; done; }
|
||||
|
||||
x_killswitch_on() {
|
||||
# Everything from the client subnet is answered by the exit table. Until
|
||||
# exit0 is up that table holds only a blackhole, so nothing falls through
|
||||
# to eth0 - this runs before the client tunnel comes up on purpose.
|
||||
ip -4 route replace blackhole default metric 1000 table $EXIT_TABLE
|
||||
x_rule -4 190 from $X_SUBNET to $X_SUBNET lookup main
|
||||
if grep -qs '^# DnsViaExit = on' "$EXIT_CONF"; then x_unrule -4 191
|
||||
else x_rule -4 191 from $X_SUBNET to $DNS_NET lookup main; fi
|
||||
x_rule -4 $EXIT_PREF from $X_SUBNET lookup $EXIT_TABLE
|
||||
if [ -n "$X_SUBNET6" ]; then
|
||||
# No IPv6 path through the exit yet: refuse fast instead of leaking via eth0
|
||||
ip -6 route replace unreachable default table $EXIT_TABLE
|
||||
x_rule -6 190 from $X_SUBNET6 to $X_SUBNET6 lookup main
|
||||
x_rule -6 $EXIT_PREF from $X_SUBNET6 lookup $EXIT_TABLE
|
||||
fi
|
||||
}
|
||||
x_killswitch_off() {
|
||||
for p in 190 191 $EXIT_PREF; do x_unrule -4 $p; x_unrule -6 $p; done
|
||||
ip -4 route flush table $EXIT_TABLE 2>/dev/null
|
||||
ip -6 route flush table $EXIT_TABLE 2>/dev/null
|
||||
}
|
||||
x_link_up() {
|
||||
$X_QUICK down $EXIT_CONF 2>/dev/null
|
||||
if ! $X_QUICK up $EXIT_CONF; then
|
||||
echo "! $EXIT_IF failed to start; clients stay on the kill-switch until the link is repaired or removed"
|
||||
return 3
|
||||
fi
|
||||
# Loose reverse-path check on the link only: containers inherit rp_filter
|
||||
# from the host, and per-client exit tables (later) can make the reverse
|
||||
# lookup resolve to another exitN.
|
||||
sysctl -w net.ipv4.conf.$EXIT_IF.rp_filter=2 >/dev/null 2>&1 || true
|
||||
ip -4 route replace default dev $EXIT_IF table $EXIT_TABLE
|
||||
x_ipt_add filter FORWARD -i $X_IFACE -o $EXIT_IF -s $X_SUBNET -j ACCEPT
|
||||
# SNAT clients into this node's transit address: the exit sees one /32 per entry
|
||||
x_ipt_add nat POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE
|
||||
x_ipt_add mangle FORWARD -o $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true
|
||||
x_ipt_add mangle FORWARD -i $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true
|
||||
# NAT is decided once per flow: flows opened before the link were SNATed to
|
||||
# eth0 and would leave exit0 with the wrong source. Drop them so they reopen.
|
||||
if command -v conntrack >/dev/null 2>&1; then conntrack -D -s $X_SUBNET >/dev/null 2>&1
|
||||
else echo "! conntrack-tools missing: client flows opened before the link keep leaving via eth0 until they end"; fi
|
||||
# Never report success while clients could still leave via eth0
|
||||
if ! ip -4 rule show | grep -q "^$EXIT_PREF:" \
|
||||
|| ! ip -4 route show table $EXIT_TABLE | grep -q "^default dev $EXIT_IF" \
|
||||
|| ! iptables -t nat -C POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE 2>/dev/null; then
|
||||
echo "! FATAL: policy routing for $EXIT_IF is not in place"
|
||||
return 3
|
||||
fi
|
||||
echo "exit link up: $(ip -4 -o addr show dev $EXIT_IF | awk '{print $4}') -> table $EXIT_TABLE"
|
||||
}
|
||||
x_link_down() {
|
||||
# Take the interface down first: MASQUERADE forgets the flows NATed through
|
||||
# it (device notifier) and the table keeps blackholing until the rules go.
|
||||
[ -f "$EXIT_CONF" ] && $X_QUICK down $EXIT_CONF 2>/dev/null
|
||||
ip link del $EXIT_IF 2>/dev/null
|
||||
x_ipt_del filter FORWARD -i $X_IFACE -o $EXIT_IF -s $X_SUBNET -j ACCEPT
|
||||
x_ipt_del nat POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE
|
||||
x_ipt_del mangle FORWARD -o $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
x_ipt_del mangle FORWARD -i $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
}
|
||||
x_exit_sync() {
|
||||
if [ -f "$EXIT_CONF" ]; then x_killswitch_on && x_link_up
|
||||
else x_link_down; x_killswitch_off; fi
|
||||
}
|
||||
[ -f "$EXIT_CONF" ] && x_killswitch_on
|
||||
|
||||
|
||||
# AWG 3.1 needs amneziawg kernel module 3.0+; an older one accepts the
|
||||
# interface but rejects the config, and awg-quick will not fall back once
|
||||
@@ -92,4 +176,7 @@ done < "$BW"
|
||||
)
|
||||
fi
|
||||
|
||||
# ---- Exit-node link: bring it up now that the client tunnel exists ----
|
||||
if [ -f "$EXIT_CONF" ]; then x_link_up || echo "! exit link not applied, clients stay on the kill-switch"; fi
|
||||
|
||||
tail -f /dev/null
|
||||
Vendored
+87
@@ -13,6 +13,90 @@ fi
|
||||
# IPv6 subnet, if the tunnel is dual-stack (second part of the Address line)
|
||||
SUBNET6=$(grep '^Address' /opt/amnezia/awg/wg0.conf | head -1 | tr ',' '
|
||||
' | grep ':' | sed 's/^[^=]*=//' | tr -d ' ' | head -1)
|
||||
# ---- Exit-node link (panel-managed; driven by /opt/amnezia/awg/exit/exit0.conf) ----
|
||||
EXIT_CONF=/opt/amnezia/awg/exit/exit0.conf
|
||||
EXIT_IF=exit0
|
||||
EXIT_TABLE=200
|
||||
EXIT_PREF=200 # slot 0: 190/191 exceptions, catch-all at the table number
|
||||
DNS_NET=172.29.172.0/24
|
||||
X_CONF=/opt/amnezia/awg/wg0.conf
|
||||
X_QUICK=wg-quick
|
||||
X_IFACE=$(basename $X_CONF .conf)
|
||||
X_SUBNET=$(grep '^Address' $X_CONF | head -1 | cut -d'=' -f2 | cut -d',' -f1 | tr -d ' ')
|
||||
[ -n "$X_SUBNET" ] || X_SUBNET=10.8.1.1/24
|
||||
X_SUBNET6=$(grep '^Address' $X_CONF | head -1 | tr ',' '\n' | grep ':' | sed 's/^[^=]*=//' | tr -d ' ' | head -1)
|
||||
|
||||
x_rule() { ip "$1" rule show 2>/dev/null | grep -q "^$2:" || ip "$1" rule add pref "$2" "${@:3}"; }
|
||||
x_unrule() { while ip "$1" rule del pref "$2" 2>/dev/null; do :; done; }
|
||||
x_ipt_add() { iptables -t "$1" -C "${@:2}" 2>/dev/null || iptables -t "$1" -A "${@:2}"; }
|
||||
x_ipt_del() { while iptables -t "$1" -D "${@:2}" 2>/dev/null; do :; done; }
|
||||
|
||||
x_killswitch_on() {
|
||||
# Everything from the client subnet is answered by the exit table. Until
|
||||
# exit0 is up that table holds only a blackhole, so nothing falls through
|
||||
# to eth0 - this runs before the client tunnel comes up on purpose.
|
||||
ip -4 route replace blackhole default metric 1000 table $EXIT_TABLE
|
||||
x_rule -4 190 from $X_SUBNET to $X_SUBNET lookup main
|
||||
if grep -qs '^# DnsViaExit = on' "$EXIT_CONF"; then x_unrule -4 191
|
||||
else x_rule -4 191 from $X_SUBNET to $DNS_NET lookup main; fi
|
||||
x_rule -4 $EXIT_PREF from $X_SUBNET lookup $EXIT_TABLE
|
||||
if [ -n "$X_SUBNET6" ]; then
|
||||
# No IPv6 path through the exit yet: refuse fast instead of leaking via eth0
|
||||
ip -6 route replace unreachable default table $EXIT_TABLE
|
||||
x_rule -6 190 from $X_SUBNET6 to $X_SUBNET6 lookup main
|
||||
x_rule -6 $EXIT_PREF from $X_SUBNET6 lookup $EXIT_TABLE
|
||||
fi
|
||||
}
|
||||
x_killswitch_off() {
|
||||
for p in 190 191 $EXIT_PREF; do x_unrule -4 $p; x_unrule -6 $p; done
|
||||
ip -4 route flush table $EXIT_TABLE 2>/dev/null
|
||||
ip -6 route flush table $EXIT_TABLE 2>/dev/null
|
||||
}
|
||||
x_link_up() {
|
||||
$X_QUICK down $EXIT_CONF 2>/dev/null
|
||||
if ! $X_QUICK up $EXIT_CONF; then
|
||||
echo "! $EXIT_IF failed to start; clients stay on the kill-switch until the link is repaired or removed"
|
||||
return 3
|
||||
fi
|
||||
# Loose reverse-path check on the link only: containers inherit rp_filter
|
||||
# from the host, and per-client exit tables (later) can make the reverse
|
||||
# lookup resolve to another exitN.
|
||||
sysctl -w net.ipv4.conf.$EXIT_IF.rp_filter=2 >/dev/null 2>&1 || true
|
||||
ip -4 route replace default dev $EXIT_IF table $EXIT_TABLE
|
||||
x_ipt_add filter FORWARD -i $X_IFACE -o $EXIT_IF -s $X_SUBNET -j ACCEPT
|
||||
# SNAT clients into this node's transit address: the exit sees one /32 per entry
|
||||
x_ipt_add nat POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE
|
||||
x_ipt_add mangle FORWARD -o $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true
|
||||
x_ipt_add mangle FORWARD -i $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true
|
||||
# NAT is decided once per flow: flows opened before the link were SNATed to
|
||||
# eth0 and would leave exit0 with the wrong source. Drop them so they reopen.
|
||||
if command -v conntrack >/dev/null 2>&1; then conntrack -D -s $X_SUBNET >/dev/null 2>&1
|
||||
else echo "! conntrack-tools missing: client flows opened before the link keep leaving via eth0 until they end"; fi
|
||||
# Never report success while clients could still leave via eth0
|
||||
if ! ip -4 rule show | grep -q "^$EXIT_PREF:" \
|
||||
|| ! ip -4 route show table $EXIT_TABLE | grep -q "^default dev $EXIT_IF" \
|
||||
|| ! iptables -t nat -C POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE 2>/dev/null; then
|
||||
echo "! FATAL: policy routing for $EXIT_IF is not in place"
|
||||
return 3
|
||||
fi
|
||||
echo "exit link up: $(ip -4 -o addr show dev $EXIT_IF | awk '{print $4}') -> table $EXIT_TABLE"
|
||||
}
|
||||
x_link_down() {
|
||||
# Take the interface down first: MASQUERADE forgets the flows NATed through
|
||||
# it (device notifier) and the table keeps blackholing until the rules go.
|
||||
[ -f "$EXIT_CONF" ] && $X_QUICK down $EXIT_CONF 2>/dev/null
|
||||
ip link del $EXIT_IF 2>/dev/null
|
||||
x_ipt_del filter FORWARD -i $X_IFACE -o $EXIT_IF -s $X_SUBNET -j ACCEPT
|
||||
x_ipt_del nat POSTROUTING -s $X_SUBNET -o $EXIT_IF -j MASQUERADE
|
||||
x_ipt_del mangle FORWARD -o $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
x_ipt_del mangle FORWARD -i $EXIT_IF -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
}
|
||||
x_exit_sync() {
|
||||
if [ -f "$EXIT_CONF" ]; then x_killswitch_on && x_link_up
|
||||
else x_link_down; x_killswitch_off; fi
|
||||
}
|
||||
[ -f "$EXIT_CONF" ] && x_killswitch_on
|
||||
|
||||
|
||||
# kill daemons in case of restart
|
||||
wg-quick down /opt/amnezia/awg/wg0.conf 2>/dev/null
|
||||
@@ -79,4 +163,7 @@ done < "$BW"
|
||||
)
|
||||
fi
|
||||
|
||||
# ---- Exit-node link: bring it up now that the client tunnel exists ----
|
||||
if [ -f "$EXIT_CONF" ]; then x_link_up || echo "! exit link not applied, clients stay on the kill-switch"; fi
|
||||
|
||||
tail -f /dev/null
|
||||
@@ -0,0 +1,338 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from managers.awg_manager import (
|
||||
DNS_NET,
|
||||
EGRESS_CHECK_URLS,
|
||||
EXIT_CONF,
|
||||
EXIT_HANDSHAKE_UP_SECONDS,
|
||||
EXIT_KEY,
|
||||
ENTRY_PRIVATE_KEY_PLACEHOLDER,
|
||||
AWGManager,
|
||||
)
|
||||
|
||||
LINK = {
|
||||
'exit_uid': 'abc123',
|
||||
'exit_name': 'Berlin-1',
|
||||
'transit_ip': '10.9.0.7',
|
||||
'subnet_cidr': '24',
|
||||
'exit_public_key': 'EXITPUB',
|
||||
'psk': 'PSK',
|
||||
'endpoint_host': '203.0.113.5',
|
||||
'endpoint_port': '55520',
|
||||
'obfuscation': False,
|
||||
'dns_via_exit': False,
|
||||
'awg_params': {},
|
||||
}
|
||||
|
||||
OBFUSCATED_LINK = dict(LINK, obfuscation=True, awg_params={
|
||||
'junk_packet_count': '4', 'junk_packet_min_size': '8', 'junk_packet_max_size': '40',
|
||||
'init_packet_junk_size': '27', 'response_packet_junk_size': '19',
|
||||
'init_packet_magic_header': '1', 'response_packet_magic_header': '2',
|
||||
'underload_packet_magic_header': '3', 'transport_packet_magic_header': '4',
|
||||
'cookie_reply_packet_junk_size': '20', 'transport_packet_junk_size': '23',
|
||||
})
|
||||
|
||||
WG_LISTS = """EXITPUB\t1700000000
|
||||
---
|
||||
EXITPUB\t1536\t4096
|
||||
---
|
||||
EXITPUB\t203.0.113.5:55520
|
||||
"""
|
||||
|
||||
|
||||
class RecordingSSH:
|
||||
def __init__(self):
|
||||
self.uploads = {}
|
||||
self.commands = []
|
||||
self.answers = {} # substring -> (out, err, code)
|
||||
|
||||
def upload_file(self, content, remote_path):
|
||||
self.uploads[remote_path] = content
|
||||
|
||||
def run_command(self, command, timeout=60):
|
||||
self.commands.append(command)
|
||||
return '', '', 0
|
||||
|
||||
def run_sudo_command(self, command, timeout=60):
|
||||
self.commands.append(command)
|
||||
for needle, answer in self.answers.items():
|
||||
if needle in command:
|
||||
return answer
|
||||
if 'for p in ' in command:
|
||||
return '/opt/amnezia/awg/awg0.conf\n', '', 0
|
||||
return '', '', 0
|
||||
|
||||
|
||||
class ExitShellBlockTests(unittest.TestCase):
|
||||
def test_start_script_orders_killswitch_tunnel_link(self):
|
||||
script = AWGManager(None)._render_start_script('awg2')
|
||||
killswitch = script.index('[ -f "$EXIT_CONF" ] && x_killswitch_on')
|
||||
tunnel_up = script.index('awg-quick up /opt/amnezia/awg/awg0.conf')
|
||||
link_up = script.index('if [ -f "$EXIT_CONF" ]; then x_link_up')
|
||||
self.assertLess(killswitch, tunnel_up)
|
||||
self.assertLess(tunnel_up, link_up)
|
||||
self.assertLess(link_up, script.index('tail -f /dev/null'))
|
||||
self.assertIn(f'EXIT_CONF={EXIT_CONF}', script)
|
||||
self.assertIn(f'DNS_NET={DNS_NET}', script)
|
||||
self.assertIn('X_QUICK=awg-quick', script)
|
||||
|
||||
def test_legacy_uses_wg_quick_for_the_link(self):
|
||||
script = AWGManager(None)._render_start_script('awg_legacy')
|
||||
self.assertIn('X_QUICK=wg-quick', script)
|
||||
self.assertIn('X_CONF=/opt/amnezia/awg/wg0.conf', script)
|
||||
|
||||
def test_killswitch_and_link_invariants(self):
|
||||
block = AWGManager._exit_shell_functions('/opt/amnezia/awg/awg0.conf', 'awg-quick', '10.8.1.1/24')
|
||||
# blackhole first, catch-all rule last, exceptions in between
|
||||
on = block[block.index('x_killswitch_on()'):block.index('x_killswitch_off()')]
|
||||
self.assertLess(on.index('blackhole default'), on.index('x_rule -4 190'))
|
||||
self.assertLess(on.index('x_rule -4 191'), on.index('x_rule -4 $EXIT_PREF from $X_SUBNET lookup $EXIT_TABLE'))
|
||||
self.assertIn('unreachable default table $EXIT_TABLE', on)
|
||||
self.assertIn("grep -qs '^# DnsViaExit = on'", on)
|
||||
# link up verifies itself and never masquerades without the client subnet
|
||||
up = block[block.index('x_link_up()'):block.index('x_link_down()')]
|
||||
self.assertIn('return 3', up)
|
||||
self.assertIn('FATAL', up)
|
||||
self.assertIn('-s $X_SUBNET -o $EXIT_IF -j MASQUERADE', up)
|
||||
self.assertNotIn('conf.all.rp_filter', up)
|
||||
self.assertIn('conf.$EXIT_IF.rp_filter=2', up)
|
||||
self.assertIn('conntrack -D -s $X_SUBNET', up)
|
||||
self.assertEqual(up.count('TCPMSS --clamp-mss-to-pmtu'), 2)
|
||||
# teardown takes the interface down before touching the rules
|
||||
down = block[block.index('x_link_down()'):block.index('x_exit_sync()')]
|
||||
self.assertLess(down.index('ip link del $EXIT_IF'), down.index('x_ipt_del'))
|
||||
off = block[block.index('x_killswitch_off()'):block.index('x_link_up()')]
|
||||
self.assertIn('for p in 190 191 $EXIT_PREF; do x_unrule -4 $p; x_unrule -6 $p; done', off)
|
||||
self.assertIn('ip -4 route flush table $EXIT_TABLE', off)
|
||||
self.assertIn('ip -6 route flush table $EXIT_TABLE', off)
|
||||
|
||||
@unittest.skipUnless(shutil.which('bash'), 'bash not available')
|
||||
def test_live_apply_scripts_parse_as_bash(self):
|
||||
functions = AWGManager._exit_shell_functions('/opt/amnezia/awg/awg0.conf', 'awg-quick', '10.8.1.1/24')
|
||||
for tail in ('x_exit_sync\n', 'x_link_down\nx_killswitch_off\nrm -f "$EXIT_CONF"\n'):
|
||||
with tempfile.NamedTemporaryFile('w', suffix='.sh', delete=False) as f:
|
||||
f.write(functions + tail)
|
||||
try:
|
||||
result = subprocess.run(['bash', '-n', f.name], capture_output=True, text=True)
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
|
||||
class ExitConfBodyTests(unittest.TestCase):
|
||||
def test_plain_link(self):
|
||||
conf = AWGManager._exit_conf_body(LINK)
|
||||
self.assertTrue(conf.startswith('[Interface]\n'))
|
||||
for line in ('# ExitUid = abc123', '# ExitName = Berlin-1', '# Obfuscation = off', '# DnsViaExit = off',
|
||||
f'PrivateKey = {ENTRY_PRIVATE_KEY_PLACEHOLDER}', 'Address = 10.9.0.7/24', 'MTU = 1420',
|
||||
'Table = off', 'PublicKey = EXITPUB', 'PresharedKey = PSK', 'AllowedIPs = 0.0.0.0/0',
|
||||
'Endpoint = 203.0.113.5:55520', 'PersistentKeepalive = 25'):
|
||||
self.assertIn(line + '\n', conf)
|
||||
self.assertNotIn('Jc', conf)
|
||||
self.assertLess(conf.index('[Interface]'), conf.index('[Peer]'))
|
||||
|
||||
def test_obfuscated_link_carries_handshake_keys_only(self):
|
||||
conf = AWGManager._exit_conf_body(OBFUSCATED_LINK)
|
||||
self.assertIn('# Obfuscation = on\n', conf)
|
||||
interface = conf.split('[Peer]')[0]
|
||||
for line in ('Jc = 4', 'Jmin = 8', 'Jmax = 40', 'S1 = 27', 'S2 = 19', 'H1 = 1', 'H2 = 2', 'H3 = 3', 'H4 = 4'):
|
||||
self.assertIn(line + '\n', interface)
|
||||
self.assertNotIn('S3 = ', conf)
|
||||
self.assertNotIn('S4 = ', conf)
|
||||
self.assertLess(interface.index('Table = off'), interface.index('Jc = 4'))
|
||||
|
||||
def test_dns_via_exit_marker(self):
|
||||
self.assertIn('# DnsViaExit = on\n', AWGManager._exit_conf_body(dict(LINK, dns_via_exit=True)))
|
||||
|
||||
|
||||
class ExitLinkMethodsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ssh = RecordingSSH()
|
||||
self.manager = AWGManager(self.ssh)
|
||||
|
||||
def test_prepare_keys_generates_once_and_returns_public_key(self):
|
||||
self.ssh.answers['pubkey <'] = ('ENTRYPUB\n', '', 0)
|
||||
self.assertEqual(self.manager.exit_prepare_keys('awg2'), 'ENTRYPUB')
|
||||
cmd = self.ssh.commands[-1]
|
||||
self.assertIn('docker exec -i amnezia-awg2', cmd)
|
||||
self.assertIn(f'[ -s {EXIT_KEY} ] || awg genkey > {EXIT_KEY}', cmd)
|
||||
self.assertIn('umask 077', cmd)
|
||||
|
||||
def test_prepare_keys_raises_on_failure(self):
|
||||
self.ssh.answers['pubkey <'] = ('', 'boom', 1)
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.manager.exit_prepare_keys('awg2')
|
||||
|
||||
def test_link_writes_conf_refreshes_start_script_and_applies(self):
|
||||
self.ssh.answers['_amnz_exit.sh'] = ('exit link up: 10.9.0.7/24 -> table 200', '', 0)
|
||||
|
||||
out = self.manager.exit_link('awg2', LINK)
|
||||
|
||||
self.assertEqual(out, 'exit link up: 10.9.0.7/24 -> table 200')
|
||||
self.assertEqual(self.ssh.uploads['/tmp/_amnz_exit0.conf'], AWGManager._exit_conf_body(LINK))
|
||||
# every docker step is its own sudo call (sudo only covers the first
|
||||
# command of a `&&` chain) and none of them chains another docker call
|
||||
steps = [c for c in self.ssh.commands if 'amnezia-awg2' in c and '_amnz_exit0.conf' in c or 'exit_private.key' in c]
|
||||
self.assertTrue(any(c == f'docker cp /tmp/_amnz_exit0.conf amnezia-awg2:{EXIT_CONF}' for c in steps))
|
||||
substitute = next(c for c in steps if 'sed -i' in c)
|
||||
self.assertIn(f'sed -i "s|{ENTRY_PRIVATE_KEY_PLACEHOLDER}|$k|" {EXIT_CONF}', substitute)
|
||||
self.assertIn(f'chmod 600 {EXIT_CONF}', substitute)
|
||||
for c in self.ssh.commands:
|
||||
self.assertFalse(c.count('docker ') > 1 and '&&' in c, f'chained docker calls under one sudo: {c}')
|
||||
# start.sh refreshed for the resolved path, without a restart
|
||||
self.assertEqual(self.ssh.uploads['/tmp/_amnz_start.sh'],
|
||||
self.manager._render_start_script('awg2', '/opt/amnezia/awg/awg0.conf'))
|
||||
self.assertFalse(any(c.startswith('docker restart') for c in self.ssh.commands))
|
||||
# live apply runs the sync entry point with the same functions
|
||||
apply_script = self.ssh.uploads['/tmp/_amnz_exit.sh']
|
||||
self.assertTrue(apply_script.endswith('x_exit_sync\n'))
|
||||
self.assertIn('x_killswitch_on()', apply_script)
|
||||
self.assertIn('docker cp /tmp/_amnz_exit.sh amnezia-awg2:/tmp/_amnz_exit.sh', self.ssh.commands)
|
||||
self.assertIn('docker exec amnezia-awg2 bash /tmp/_amnz_exit.sh', self.ssh.commands)
|
||||
|
||||
def test_link_raises_when_apply_fails(self):
|
||||
self.ssh.answers['_amnz_exit.sh'] = ('! FATAL: policy routing for exit0 is not in place', '', 3)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.manager.exit_link('awg2', LINK)
|
||||
self.assertIn('FATAL', str(ctx.exception))
|
||||
|
||||
def test_unlink_tears_down_and_removes_the_file(self):
|
||||
self.manager.exit_unlink('awg2')
|
||||
apply_script = self.ssh.uploads['/tmp/_amnz_exit.sh']
|
||||
self.assertTrue(apply_script.endswith('x_link_down\nx_killswitch_off\nrm -f "$EXIT_CONF"\n'))
|
||||
self.assertNotIn('x_exit_sync\n', apply_script)
|
||||
|
||||
def test_link_info_parses_metadata_only(self):
|
||||
self.ssh.answers['ExitUid|ExitName'] = (
|
||||
'# ExitUid = abc123\n# ExitName = Berlin-1\n# Obfuscation = on\n# DnsViaExit = off\n'
|
||||
'Address = 10.9.0.7/24\nEndpoint = 203.0.113.5:55520\n', '', 0)
|
||||
info = self.manager.exit_link_info('awg2')
|
||||
self.assertEqual(info, {'exit_uid': 'abc123', 'exit_name': 'Berlin-1', 'obfuscation': True,
|
||||
'dns_via_exit': False, 'address': '10.9.0.7/24', 'endpoint': '203.0.113.5:55520'})
|
||||
self.assertNotIn('PrivateKey', self.ssh.commands[-1])
|
||||
|
||||
def test_link_info_none_without_link(self):
|
||||
self.ssh.answers['ExitUid|ExitName'] = ('', '', 9)
|
||||
self.assertIsNone(self.manager.exit_link_info('awg2'))
|
||||
|
||||
def test_parse_wg_peer_lists(self):
|
||||
peers = AWGManager._parse_wg_peer_lists(WG_LISTS + 'OTHER\t0\n')
|
||||
self.assertEqual(peers['EXITPUB'], {'latest_handshake': 1700000000, 'rx_bytes': 1536,
|
||||
'tx_bytes': 4096, 'endpoint': '203.0.113.5:55520'})
|
||||
self.assertEqual(AWGManager._parse_wg_peer_lists('NEVER\t0\n---\nNEVER\t0\t0\n---\nNEVER\t(none)\n')['NEVER'],
|
||||
{'latest_handshake': 0, 'rx_bytes': 0, 'tx_bytes': 0, 'endpoint': ''})
|
||||
|
||||
def test_link_status_states(self):
|
||||
self.ssh.answers['latest-handshakes'] = (WG_LISTS, '', 0)
|
||||
fresh = self.manager.exit_link_status('awg2', now=1700000010)
|
||||
self.assertEqual(fresh, {'up': True, 'handshake_age': 10, 'rx_bytes': 1536, 'tx_bytes': 4096,
|
||||
'endpoint': '203.0.113.5:55520'})
|
||||
self.assertNotIn(' dump', self.ssh.commands[-1])
|
||||
stale = self.manager.exit_link_status('awg2', now=1700000000 + EXIT_HANDSHAKE_UP_SECONDS)
|
||||
self.assertFalse(stale['up'])
|
||||
self.assertEqual(stale['handshake_age'], EXIT_HANDSHAKE_UP_SECONDS)
|
||||
|
||||
self.ssh.answers['latest-handshakes'] = ('NEVER\t0\n---\nNEVER\t0\t0\n---\nNEVER\t(none)\n', '', 0)
|
||||
never = self.manager.exit_link_status('awg2', now=1700000000)
|
||||
self.assertEqual(never['handshake_age'], None)
|
||||
self.assertFalse(never['up'])
|
||||
|
||||
self.ssh.answers['latest-handshakes'] = ('', '', 9)
|
||||
self.assertIsNone(self.manager.exit_link_status('awg2'))
|
||||
|
||||
self.ssh.answers['latest-handshakes'] = ('', 'Unable to access interface: No such device', 1)
|
||||
broken = self.manager.exit_link_status('awg2')
|
||||
self.assertFalse(broken['up'])
|
||||
self.assertIn('No such device', broken['error'])
|
||||
|
||||
|
||||
class ExitEgressCheckTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ssh = RecordingSSH()
|
||||
self.manager = AWGManager(self.ssh)
|
||||
# the subnet IP comes from the server config the probe binds to
|
||||
self.ssh.answers['cat /opt/amnezia/awg/awg0.conf'] = (
|
||||
'[Interface]\nAddress = 172.16.21.1/24\nListenPort = 55424\n', '', 0)
|
||||
|
||||
def test_probe_script_is_posix_sh_and_quote_free(self):
|
||||
script = AWGManager._egress_probe_script('10.8.1.1')
|
||||
# it is single-quoted into `docker exec sh -c '...'`
|
||||
self.assertNotIn("'", script)
|
||||
self.assertIn('--interface 10.8.1.1', script)
|
||||
for url in EGRESS_CHECK_URLS:
|
||||
self.assertIn(url, script)
|
||||
if shutil.which('sh'):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, 'probe.sh')
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(script + '\n')
|
||||
self.assertEqual(subprocess.run(['sh', '-n', path]).returncode, 0)
|
||||
|
||||
def test_check_egress_splits_the_two_probes(self):
|
||||
self.ssh.answers['echo ---'] = ('203.0.113.5\n---\n198.51.100.1\n', '', 0)
|
||||
self.assertEqual(self.manager.exit_check_egress('awg2'),
|
||||
{'via_exit': '203.0.113.5', 'direct': '198.51.100.1'})
|
||||
cmd = next(c for c in self.ssh.commands if 'echo ---' in c)
|
||||
self.assertIn('docker exec -i amnezia-awg2', cmd)
|
||||
# bound to the gateway of this instance, not to the default subnet
|
||||
self.assertIn('--interface 172.16.21.1', cmd)
|
||||
|
||||
def test_check_egress_tolerates_empty_and_garbage_answers(self):
|
||||
self.ssh.answers['echo ---'] = ('\n---\n<html>error</html>\n', '', 0)
|
||||
self.assertEqual(self.manager.exit_check_egress('awg2'), {'via_exit': '', 'direct': ''})
|
||||
|
||||
def test_check_egress_raises_when_the_container_is_gone(self):
|
||||
self.ssh.answers['echo ---'] = ('', 'No such container: amnezia-awg2', 1)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.manager.exit_check_egress('awg2')
|
||||
self.assertIn('No such container', str(ctx.exception))
|
||||
class ExitDnsViaExitTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ssh = RecordingSSH()
|
||||
self.manager = AWGManager(self.ssh)
|
||||
|
||||
def test_toggle_rewrites_the_marker_and_reapplies(self):
|
||||
self.ssh.answers['_amnz_exit.sh'] = ('exit link up: 10.9.0.7/24 -> table 200', '', 0)
|
||||
out = self.manager.exit_set_dns_via_exit('awg2', True)
|
||||
self.assertEqual(out, 'exit link up: 10.9.0.7/24 -> table 200')
|
||||
sed = next(c for c in self.ssh.commands if 'DnsViaExit' in c)
|
||||
self.assertIn(f'sed -i "s|^# DnsViaExit = .*|# DnsViaExit = on|" {EXIT_CONF}', sed)
|
||||
self.assertIn(f'test -f {EXIT_CONF} || exit 9', sed)
|
||||
# the same block that runs at container start puts the rules back
|
||||
self.assertTrue(self.ssh.uploads['/tmp/_amnz_exit.sh'].endswith('x_exit_sync\n'))
|
||||
|
||||
self.manager.exit_set_dns_via_exit('awg2', False)
|
||||
self.assertIn('# DnsViaExit = off|', [c for c in self.ssh.commands if 'DnsViaExit' in c][-1])
|
||||
|
||||
def test_toggle_without_a_link_file_raises(self):
|
||||
self.ssh.answers['DnsViaExit'] = ('', '', 9)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.manager.exit_set_dns_via_exit('awg2', True)
|
||||
self.assertIn('no exit link file', str(ctx.exception))
|
||||
|
||||
def test_dns_exception_rule_follows_the_marker(self):
|
||||
block = AWGManager._exit_shell_functions('/opt/amnezia/awg/awg0.conf', 'awg-quick', '10.8.1.1/24')
|
||||
# on -> the DNS exception is removed, off -> it is (re)installed
|
||||
self.assertIn("if grep -qs '^# DnsViaExit = on' \"$EXIT_CONF\"; then x_unrule -4 191", block)
|
||||
self.assertIn('x_rule -4 191 from $X_SUBNET to $DNS_NET lookup main', block)
|
||||
|
||||
def test_awg_settings_report_the_link_ceiling(self):
|
||||
self.ssh.answers['ExitUid|ExitName'] = (
|
||||
'# ExitUid = abc123\n# ExitName = Berlin-1\n# Obfuscation = off\n# DnsViaExit = on\n'
|
||||
'Address = 10.9.0.7/24\nEndpoint = 203.0.113.5:55520\n', '', 0)
|
||||
self.ssh.answers['cat /opt/amnezia/awg/awg0.conf'] = (
|
||||
'[Interface]\nAddress = 10.8.1.1/24\n# MTU = 1376\n', '', 0)
|
||||
settings = self.manager.get_awg_settings('awg2')
|
||||
self.assertTrue(settings['exit_linked'])
|
||||
self.assertEqual(settings['exit_mtu'], 1420)
|
||||
|
||||
self.ssh.answers['ExitUid|ExitName'] = ('', '', 9)
|
||||
self.assertFalse(self.manager.get_awg_settings('awg2')['exit_linked'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -40,9 +40,10 @@ class RecordingSSH:
|
||||
|
||||
|
||||
class GoldenBuilderTests(unittest.TestCase):
|
||||
"""The fixtures were captured from the pre-refactor code by running
|
||||
install_protocol()/save_server_config() against a fake SSH, so equality
|
||||
proves the builders reproduce the previous output byte for byte."""
|
||||
"""The fixtures pin the generated container files byte for byte, so they
|
||||
change only on purpose (originally captured from the pre-builder code;
|
||||
refreshed when the image gained iproute2/conntrack-tools and the start
|
||||
script gained the entry-side exit-node block)."""
|
||||
|
||||
def test_start_script_awg(self):
|
||||
self.assertEqual(AWGManager(None)._render_start_script('awg'), fixture('start_awg.sh'))
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as panel
|
||||
from app import should_link_default_exit
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def seed_servers():
|
||||
return [
|
||||
{'name': 'Paris', 'host': '198.51.100.1', 'ssh_port': 22, 'username': 'root', 'password': 'x',
|
||||
'private_key': '', 'server_info': {}, 'uid': 'entry-uid',
|
||||
'protocols': {'awg2': {'installed': True, 'port': '55424'}}},
|
||||
{'name': 'Berlin-1', 'host': '203.0.113.5', 'ssh_port': 22, 'username': 'root', 'password': 'x',
|
||||
'private_key': '', 'server_info': {}, 'uid': 'exit-uid',
|
||||
'protocols': {'exit': {'installed': True, 'port': '55520', 'subnet': '10.9.0.0/24'}}},
|
||||
]
|
||||
|
||||
|
||||
SETTINGS_PAYLOAD = {
|
||||
'appearance': {'title': 'Amnezia', 'logo': '❤️', 'subtitle': 'Web Panel'},
|
||||
'sync': {},
|
||||
'captcha': {'enabled': False},
|
||||
'telegram': {'token': '', 'enabled': False},
|
||||
'ssl': {'enabled': False},
|
||||
}
|
||||
|
||||
|
||||
class AutoLinkDecisionTests(unittest.TestCase):
|
||||
"""Which installs join the default exit node."""
|
||||
|
||||
def decide(self, **kw):
|
||||
args = dict(default_uid='exit-uid', server_uid='entry-uid', is_awg=True,
|
||||
reinstall=False, previous_link=None)
|
||||
args.update(kw)
|
||||
return should_link_default_exit(args['default_uid'], args['server_uid'], args['is_awg'],
|
||||
args['reinstall'], args['previous_link'])
|
||||
|
||||
def test_a_newly_added_awg_instance_joins(self):
|
||||
self.assertTrue(self.decide())
|
||||
|
||||
def test_reinstalling_an_unlinked_instance_stays_unlinked(self):
|
||||
# the instance was deliberately left (or set) unlinked - a reinstall
|
||||
# must not route its clients through the default exit behind our back
|
||||
self.assertFalse(self.decide(reinstall=True))
|
||||
|
||||
def test_an_instance_with_its_own_link_is_left_to_the_relink_path(self):
|
||||
self.assertFalse(self.decide(reinstall=True, previous_link={'exit_uid': 'other'}))
|
||||
self.assertFalse(self.decide(previous_link={'exit_uid': 'other'}))
|
||||
|
||||
def test_no_default_no_self_link_no_other_protocols(self):
|
||||
self.assertFalse(self.decide(default_uid=''))
|
||||
self.assertFalse(self.decide(server_uid='exit-uid')) # the exit's own server
|
||||
self.assertFalse(self.decide(is_awg=False))
|
||||
|
||||
|
||||
class DefaultExitNodeSettingsTests(unittest.TestCase):
|
||||
"""The default exit node is stored in settings and only ever points at an
|
||||
exit node that is actually installed."""
|
||||
|
||||
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': seed_servers(), '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 save(self, client, uid):
|
||||
response = client.post('/api/settings/save', json=dict(SETTINGS_PAYLOAD,
|
||||
exit_nodes={'default_exit_uid': uid}))
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
return response.json()
|
||||
|
||||
def test_default_is_kept_only_while_the_node_exists(self):
|
||||
with TestClient(panel.app) as client:
|
||||
login = client.post('/api/auth/login', json={'username': 'admin', 'password': 'admin'})
|
||||
self.assertEqual(login.status_code, 200, login.text)
|
||||
|
||||
# a real exit node is stored as given
|
||||
self.assertEqual(self.save(client, ' exit-uid ')['warnings'], [])
|
||||
self.assertEqual(panel.load_data()['settings']['exit_nodes']['default_exit_uid'], 'exit-uid')
|
||||
|
||||
# a uid that names no installed exit node is dropped, with a warning
|
||||
saved = self.save(client, 'gone-uid')
|
||||
self.assertEqual(saved['warnings'], ['exit_default_cleared'])
|
||||
self.assertEqual(panel.load_data()['settings']['exit_nodes']['default_exit_uid'], '')
|
||||
|
||||
# so is a server that has no exit installed
|
||||
self.assertEqual(self.save(client, 'entry-uid')['warnings'], ['exit_default_cleared'])
|
||||
|
||||
# empty means "no default" and is not a warning
|
||||
self.assertEqual(self.save(client, '')['warnings'], [])
|
||||
|
||||
def test_settings_default_is_present_for_old_data_files(self):
|
||||
self.assertEqual(panel.load_data()['settings']['exit_nodes'], {'default_exit_uid': ''})
|
||||
|
||||
def test_settings_page_renders_the_exit_card(self):
|
||||
with TestClient(panel.app) as client:
|
||||
login = client.post('/api/auth/login', json={'username': 'admin', 'password': 'admin'})
|
||||
self.assertEqual(login.status_code, 200, login.text)
|
||||
page = client.get('/settings')
|
||||
self.assertEqual(page.status_code, 200, page.text)
|
||||
for marker in ('id="exitDefaultNode"', 'loadExitNodes()', 'Default exit node'):
|
||||
self.assertIn(marker, page.text, f'/settings lacks {marker}')
|
||||
# the stored uid reaches the page as JSON, not as raw interpolation
|
||||
self.assertIn('const current = ""', page.text)
|
||||
|
||||
def test_settings_page_wiring(self):
|
||||
with open(os.path.join(ROOT, 'templates', 'settings.html'), encoding='utf-8') as f:
|
||||
page = f.read()
|
||||
self.assertIn('id="exitDefaultNode"', page)
|
||||
self.assertRegex(page, r"async function loadExitNodes\(")
|
||||
self.assertIn("const exit_nodes = { default_exit_uid:", page)
|
||||
self.assertIn('self_service, exit_nodes })', page)
|
||||
# the saved uid comes from the server, so it is not interpolated raw
|
||||
self.assertIn("| tojson }};", page[page.index('async function loadExitNodes'):][:600])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,591 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import unittest
|
||||
|
||||
from exit_link_service import ExitLinkError, ExitLinkService, peer_id_for
|
||||
from app import backfill_server_uids, find_server_by_uid, protocol_base
|
||||
|
||||
AWG_PROTOCOLS = ('awg', 'awg2', 'awg3', 'awg_legacy')
|
||||
|
||||
|
||||
def base_data():
|
||||
data = {
|
||||
'servers': [
|
||||
{'name': 'Paris', 'host': '198.51.100.1', 'uid': 'entry-uid', 'protocols': {
|
||||
'awg2': {'installed': True, 'port': '55424'},
|
||||
'awg_legacy': {'installed': True, 'port': '55426'},
|
||||
}},
|
||||
{'name': 'Berlin-1', 'host': '203.0.113.5', 'uid': 'exit-a', 'protocols': {
|
||||
'exit': {'installed': True, 'port': '55520', 'subnet': '10.9.0.0/24', 'obfuscation': False},
|
||||
}},
|
||||
{'name': 'Oslo-obf', 'host': '203.0.113.9', 'uid': 'exit-b', 'protocols': {
|
||||
'exit': {'installed': True, 'port': '55520', 'subnet': '10.9.0.0/24', 'obfuscation': True},
|
||||
}},
|
||||
],
|
||||
'users': [],
|
||||
'user_connections': [],
|
||||
}
|
||||
backfill_server_uids(data['servers'])
|
||||
return data
|
||||
|
||||
|
||||
class FakeAWG:
|
||||
def __init__(self, host, events=None):
|
||||
self.host = host
|
||||
self.calls = []
|
||||
self.events = events if events is not None else []
|
||||
self.mtu = '1376'
|
||||
self.ipv6 = ''
|
||||
self.link_error = None
|
||||
self.status_sequence = None # list of exit_link_status answers
|
||||
self.fail_unlink = False
|
||||
self.egress = {'via_exit': '203.0.113.5', 'direct': '198.51.100.1'}
|
||||
self.egress_error = None
|
||||
self.endpoint = '203.0.113.5:55520'
|
||||
self.link_info = None # what exit0.conf inside the container says
|
||||
self.link_info_error = None
|
||||
self.dns_error = None
|
||||
|
||||
def exit_prepare_keys(self, protocol):
|
||||
self.calls.append(('exit_prepare_keys', protocol))
|
||||
return f'PUB-{self.host}-{protocol}'
|
||||
|
||||
def _get_mtu(self, protocol):
|
||||
return self.mtu
|
||||
|
||||
def _get_subnet_ipv6_ip(self, protocol):
|
||||
return self.ipv6
|
||||
|
||||
def exit_link(self, protocol, link):
|
||||
self.calls.append(('exit_link', protocol, link))
|
||||
self.events.append((self.host, 'exit_link'))
|
||||
if self.link_error:
|
||||
raise RuntimeError(self.link_error)
|
||||
return 'exit link up: 10.9.0.7/24 -> table 200'
|
||||
|
||||
def exit_link_status(self, protocol, now=None):
|
||||
self.calls.append(('exit_link_status', protocol))
|
||||
if self.status_sequence:
|
||||
return self.status_sequence.pop(0)
|
||||
return {'up': True, 'handshake_age': 3, 'rx_bytes': 1, 'tx_bytes': 2, 'endpoint': self.endpoint}
|
||||
|
||||
def exit_link_info(self, protocol):
|
||||
self.calls.append(('exit_link_info', protocol))
|
||||
if self.link_info_error:
|
||||
raise RuntimeError(self.link_info_error)
|
||||
return self.link_info
|
||||
|
||||
def exit_check_egress(self, protocol):
|
||||
self.calls.append(('exit_check_egress', protocol))
|
||||
if self.egress_error:
|
||||
raise RuntimeError(self.egress_error)
|
||||
return dict(self.egress)
|
||||
|
||||
def exit_set_dns_via_exit(self, protocol, enabled):
|
||||
self.calls.append(('exit_set_dns_via_exit', protocol, enabled))
|
||||
if self.dns_error:
|
||||
raise RuntimeError(self.dns_error)
|
||||
return 'exit link up: 10.9.0.7/24 -> table 200'
|
||||
|
||||
def exit_unlink(self, protocol):
|
||||
self.calls.append(('exit_unlink', protocol))
|
||||
if self.fail_unlink:
|
||||
raise RuntimeError('entry unreachable')
|
||||
return ''
|
||||
|
||||
|
||||
class FakeExit:
|
||||
def __init__(self, host, events=None):
|
||||
self.host = host
|
||||
self.calls = []
|
||||
self.events = events if events is not None else []
|
||||
self.add_error = None
|
||||
self.remove_error = None
|
||||
self.next_ip = '10.9.0.7'
|
||||
|
||||
def add_peer(self, peer_id, name, public_key):
|
||||
self.calls.append(('add_peer', peer_id, name, public_key))
|
||||
self.events.append((self.host, 'add_peer'))
|
||||
if self.add_error:
|
||||
raise RuntimeError(self.add_error)
|
||||
return {'transit_ip': self.next_ip, 'psk': 'PSK', 'public_key': f'EXITPUB-{self.host}', 'port': '55520',
|
||||
'subnet': '10.9.0.0/24', 'obfuscation': self.host == '203.0.113.9', 'awg_params': {}}
|
||||
|
||||
def remove_peer(self, public_key=None, peer_id=None):
|
||||
self.calls.append(('remove_peer', public_key, peer_id))
|
||||
self.events.append((self.host, 'remove_peer'))
|
||||
if self.remove_error:
|
||||
raise RuntimeError(self.remove_error)
|
||||
return True
|
||||
|
||||
|
||||
class Harness:
|
||||
"""In-memory data.json plus one fake manager per host, injected the way app.py wires the real ones."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.data = data or base_data()
|
||||
self.awgs = {}
|
||||
self.exits = {}
|
||||
self.unreachable = set()
|
||||
self.sleeps = []
|
||||
self.events = []
|
||||
|
||||
async def sleep(seconds):
|
||||
self.sleeps.append(seconds)
|
||||
|
||||
def get_ssh(server):
|
||||
if server['host'] in self.unreachable:
|
||||
raise ConnectionError(f"SSH to {server['host']} failed")
|
||||
return server['host']
|
||||
|
||||
self.service = ExitLinkService(
|
||||
load_data=lambda: copy.deepcopy(self.data),
|
||||
save_data=self._save,
|
||||
data_lock=asyncio.Lock(),
|
||||
get_ssh=get_ssh,
|
||||
awg_manager_factory=lambda host: self.awgs.setdefault(host, FakeAWG(host, self.events)),
|
||||
exit_manager_factory=lambda host: self.exits.setdefault(host, FakeExit(host, self.events)),
|
||||
protocol_base=protocol_base,
|
||||
awg_protocols=AWG_PROTOCOLS,
|
||||
protocol_display_name=lambda p: {'awg2': 'AmneziaWG 2.0'}.get(p, p),
|
||||
find_server_by_uid=find_server_by_uid,
|
||||
sleep=sleep,
|
||||
)
|
||||
|
||||
def _save(self, data):
|
||||
self.data = copy.deepcopy(data)
|
||||
|
||||
def awg(self, host='198.51.100.1'):
|
||||
return self.awgs.setdefault(host, FakeAWG(host, self.events))
|
||||
|
||||
def exit(self, host='203.0.113.5'):
|
||||
return self.exits.setdefault(host, FakeExit(host, self.events))
|
||||
|
||||
def record(self, server_idx=0, protocol='awg2'):
|
||||
return self.data['servers'][server_idx]['protocols'][protocol]
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class LinkTests(unittest.TestCase):
|
||||
def test_happy_path_persists_the_link(self):
|
||||
h = Harness()
|
||||
result = run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
|
||||
self.assertEqual(result['status'], 'success')
|
||||
self.assertEqual([c[0] for c in h.awg().calls], ['exit_prepare_keys', 'exit_link', 'exit_link_status'])
|
||||
self.assertEqual(h.exit().calls, [('add_peer', 'entry-uid:awg2', 'Paris / AmneziaWG 2.0', 'PUB-198.51.100.1-awg2')])
|
||||
link = h.awg().calls[1][2]
|
||||
self.assertEqual(link['transit_ip'], '10.9.0.7')
|
||||
self.assertEqual(link['subnet_cidr'], '24')
|
||||
self.assertEqual(link['endpoint_host'], '203.0.113.5')
|
||||
self.assertEqual(link['endpoint_port'], '55520')
|
||||
self.assertEqual(link['psk'], 'PSK')
|
||||
self.assertFalse(link['dns_via_exit'])
|
||||
saved = h.record()['exit_link']
|
||||
self.assertEqual(saved['exit_uid'], 'exit-a')
|
||||
self.assertEqual(saved['exit_name'], 'Berlin-1')
|
||||
self.assertEqual(saved['transit_ip'], '10.9.0.7')
|
||||
self.assertIsNone(saved['stale'])
|
||||
self.assertNotIn('psk', saved)
|
||||
self.assertEqual(result['warnings'], [])
|
||||
self.assertFalse(result['ipv6_refused'])
|
||||
|
||||
def test_validation_errors(self):
|
||||
h = Harness()
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.link(0, 'xray', 'exit-a'))
|
||||
self.assertEqual(ctx.exception.code, 'protocol_not_awg')
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.link(0, 'awg3', 'exit-a'))
|
||||
self.assertEqual(ctx.exception.code, 'protocol_not_installed')
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.link(0, 'awg2', 'no-such-uid'))
|
||||
self.assertEqual(ctx.exception.code, 'exit_not_found')
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.link(9, 'awg2', 'exit-a'))
|
||||
self.assertEqual(ctx.exception.status_code, 404)
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.link(0, 'awg_legacy', 'exit-b'))
|
||||
self.assertEqual(ctx.exception.code, 'exit_legacy_obfuscation')
|
||||
# a server cannot be its own exit
|
||||
h.data['servers'][1]['protocols']['awg2'] = {'installed': True}
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.link(1, 'awg2', 'exit-a'))
|
||||
self.assertEqual(ctx.exception.code, 'exit_self')
|
||||
self.assertEqual(h.exit().calls, [])
|
||||
|
||||
def test_add_peer_failure_leaves_everything_untouched(self):
|
||||
h = Harness()
|
||||
h.exit().add_error = 'exit down'
|
||||
with self.assertRaises(RuntimeError):
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
self.assertNotIn('exit_link', h.record())
|
||||
self.assertEqual([c[0] for c in h.awg().calls], ['exit_prepare_keys'])
|
||||
|
||||
def test_apply_failure_rolls_back_both_sides(self):
|
||||
h = Harness()
|
||||
h.awg().link_error = '! FATAL: policy routing for exit0 is not in place'
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
self.assertEqual(ctx.exception.code, 'exit_apply_failed')
|
||||
self.assertIn(('exit_unlink', 'awg2'), h.awg().calls)
|
||||
self.assertEqual(h.exit().calls[-1], ('remove_peer', None, 'entry-uid:awg2'))
|
||||
self.assertNotIn('exit_link', h.record())
|
||||
|
||||
def test_no_handshake_rolls_back_unless_forced(self):
|
||||
h = Harness()
|
||||
never = {'up': False, 'handshake_age': None, 'rx_bytes': 0, 'tx_bytes': 0, 'endpoint': ''}
|
||||
h.awg().status_sequence = [never] * 20
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
self.assertEqual(ctx.exception.code, 'exit_no_handshake')
|
||||
self.assertIn('55520/udp', str(ctx.exception))
|
||||
self.assertIn(('exit_unlink', 'awg2'), h.awg().calls)
|
||||
self.assertNotIn('exit_link', h.record())
|
||||
|
||||
h = Harness()
|
||||
h.awg().status_sequence = [never] * 20
|
||||
result = run(h.service.link(0, 'awg2', 'exit-a', force=True))
|
||||
self.assertEqual(result['warnings'], ['no_handshake_yet'])
|
||||
self.assertEqual(h.record()['exit_link']['exit_uid'], 'exit-a')
|
||||
|
||||
def test_handshake_wait_polls_until_it_arrives(self):
|
||||
h = Harness()
|
||||
never = {'up': False, 'handshake_age': None, 'rx_bytes': 0, 'tx_bytes': 0, 'endpoint': ''}
|
||||
h.awg().status_sequence = [never, never, {'up': True, 'handshake_age': 1, 'rx_bytes': 0, 'tx_bytes': 0, 'endpoint': 'x'}]
|
||||
result = run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
self.assertEqual(result['status'], 'success')
|
||||
self.assertEqual(h.sleeps, [2, 2])
|
||||
|
||||
def test_mtu_gate(self):
|
||||
h = Harness()
|
||||
h.awg().mtu = '1500'
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
self.assertEqual(ctx.exception.code, 'exit_mtu_too_big')
|
||||
self.assertEqual(h.exit().calls, [])
|
||||
result = run(h.service.link(0, 'awg2', 'exit-a', force=True))
|
||||
self.assertEqual(result['status'], 'success')
|
||||
|
||||
def test_ipv6_instances_are_flagged(self):
|
||||
h = Harness()
|
||||
h.awg().ipv6 = 'fd42:8:1::1'
|
||||
self.assertTrue(run(h.service.link(0, 'awg2', 'exit-a'))['ipv6_refused'])
|
||||
|
||||
def test_switching_exits_drops_the_old_peer_only_after_the_new_link_is_confirmed(self):
|
||||
h = Harness()
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
h.events.clear()
|
||||
result = run(h.service.link(0, 'awg2', 'exit-b'))
|
||||
self.assertEqual(result['status'], 'success')
|
||||
self.assertEqual(h.events, [('203.0.113.9', 'add_peer'), ('198.51.100.1', 'exit_link'),
|
||||
('203.0.113.5', 'remove_peer')])
|
||||
self.assertEqual(h.record()['exit_link']['exit_uid'], 'exit-b')
|
||||
|
||||
def test_failed_switch_keeps_the_old_peer_and_marks_the_record_stale(self):
|
||||
h = Harness()
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
h.events.clear()
|
||||
h.awg().link_error = 'apply failed'
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.link(0, 'awg2', 'exit-b'))
|
||||
self.assertEqual(ctx.exception.code, 'exit_apply_failed')
|
||||
# rollback touched the new exit and the entry, never the old exit
|
||||
self.assertNotIn(('203.0.113.5', 'remove_peer'), h.events)
|
||||
self.assertIn(('203.0.113.9', 'remove_peer'), h.events)
|
||||
link = h.record()['exit_link']
|
||||
self.assertEqual(link['exit_uid'], 'exit-a')
|
||||
self.assertEqual(link['stale'], 'switch_failed')
|
||||
# Repair goes back to the old exit, whose peer is still there
|
||||
h.awg().link_error = None
|
||||
self.assertEqual(run(h.service.relink_entry(0, 'awg2'))['exit_link']['exit_uid'], 'exit-a')
|
||||
self.assertIsNone(h.record()['exit_link']['stale'])
|
||||
|
||||
def test_failed_relink_to_the_same_exit_marks_the_record_stale(self):
|
||||
h = Harness()
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
h.awg().link_error = 'apply failed'
|
||||
with self.assertRaises(ExitLinkError):
|
||||
run(h.service.link(0, 'awg2', 'exit-a', force=True))
|
||||
self.assertEqual(h.record()['exit_link']['stale'], 'relink_failed')
|
||||
|
||||
def test_switching_exits_tolerates_an_unreachable_old_exit(self):
|
||||
h = Harness()
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
h.unreachable.add('203.0.113.5')
|
||||
result = run(h.service.link(0, 'awg2', 'exit-b'))
|
||||
self.assertEqual(result['warnings'], ['old_exit_unreachable_orphan_peer'])
|
||||
self.assertEqual(h.record()['exit_link']['exit_uid'], 'exit-b')
|
||||
|
||||
def test_persist_resolves_the_entry_by_uid_after_a_reorder(self):
|
||||
h = Harness()
|
||||
original_save = h._save
|
||||
|
||||
def reorder_then_save(data):
|
||||
# someone reordered servers while we were busy with SSH
|
||||
data['servers'] = [data['servers'][2], data['servers'][0], data['servers'][1]]
|
||||
original_save(data)
|
||||
|
||||
h.service.save_data = reorder_then_save
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
entry = next(s for s in h.data['servers'] if s['uid'] == 'entry-uid')
|
||||
self.assertEqual(entry['protocols']['awg2']['exit_link']['exit_uid'], 'exit-a')
|
||||
|
||||
|
||||
class UnlinkAndLifecycleTests(unittest.TestCase):
|
||||
def linked(self):
|
||||
h = Harness()
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
h.awg().calls.clear()
|
||||
h.exit().calls.clear()
|
||||
return h
|
||||
|
||||
def test_unlink_entry_first_then_exit(self):
|
||||
h = self.linked()
|
||||
result = run(h.service.unlink(0, 'awg2'))
|
||||
self.assertEqual(result, {'status': 'success', 'warnings': [], 'unlinked': True})
|
||||
self.assertEqual(h.awg().calls, [('exit_unlink', 'awg2')])
|
||||
self.assertEqual(h.exit().calls, [('remove_peer', None, 'entry-uid:awg2')])
|
||||
self.assertNotIn('exit_link', h.record())
|
||||
|
||||
def test_unlink_with_a_dead_exit_still_restores_the_entry(self):
|
||||
h = self.linked()
|
||||
h.unreachable.add('203.0.113.5')
|
||||
result = run(h.service.unlink(0, 'awg2'))
|
||||
self.assertEqual(result['warnings'], ['exit_unreachable_orphan_peer'])
|
||||
self.assertEqual(h.awg().calls, [('exit_unlink', 'awg2')])
|
||||
self.assertNotIn('exit_link', h.record())
|
||||
|
||||
def test_unlink_is_idempotent(self):
|
||||
h = Harness()
|
||||
self.assertEqual(run(h.service.unlink(0, 'awg2')), {'status': 'success', 'warnings': [], 'unlinked': False})
|
||||
|
||||
def test_relink_entry_reuses_the_saved_exit_and_forces(self):
|
||||
h = self.linked()
|
||||
h.data['servers'][1]['protocols']['dns'] = {'installed': True}
|
||||
h.data['servers'][0]['protocols']['awg2']['exit_link']['dns_via_exit'] = True
|
||||
never = {'up': False, 'handshake_age': None, 'rx_bytes': 0, 'tx_bytes': 0, 'endpoint': ''}
|
||||
h.awg().status_sequence = [never] * 20
|
||||
result = run(h.service.relink_entry(0, 'awg2'))
|
||||
self.assertEqual(result['warnings'], ['no_handshake_yet'])
|
||||
self.assertTrue(h.awg().calls[1][2]['dns_via_exit'])
|
||||
self.assertTrue(h.record()['exit_link']['dns_via_exit'])
|
||||
with self.assertRaises(ExitLinkError):
|
||||
run(h.service.relink_entry(0, 'awg_legacy'))
|
||||
|
||||
def test_relink_drops_dns_via_exit_when_the_exit_lost_its_dns(self):
|
||||
# repairing a link must not fail over an option the caller did not ask
|
||||
# for: the saved flag is dropped so DNS comes home instead of nowhere
|
||||
h = self.linked()
|
||||
h.data['servers'][0]['protocols']['awg2']['exit_link']['dns_via_exit'] = True
|
||||
result = run(h.service.relink_entry(0, 'awg2'))
|
||||
self.assertIn('exit_dns_disabled_no_dns', result['warnings'])
|
||||
self.assertFalse(h.awg().calls[1][2]['dns_via_exit'])
|
||||
self.assertFalse(h.record()['exit_link']['dns_via_exit'])
|
||||
|
||||
def test_relink_entries_for_exit_reports_per_entry(self):
|
||||
h = Harness()
|
||||
h.data['servers'].append({'name': 'Rome', 'host': '198.51.100.2', 'uid': 'entry-2',
|
||||
'protocols': {'awg': {'installed': True}}})
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
run(h.service.link(3, 'awg', 'exit-a'))
|
||||
h.unreachable.add('198.51.100.2')
|
||||
results = run(h.service.relink_entries_for_exit('exit-a'))
|
||||
self.assertEqual([(r['name'], r['protocol'], r['status']) for r in results],
|
||||
[('Paris', 'awg2', 'success'), ('Rome', 'awg', 'error')])
|
||||
self.assertIn('SSH to 198.51.100.2 failed', results[1]['error'])
|
||||
|
||||
def test_detach_marks_unreachable_entries_stale(self):
|
||||
h = Harness()
|
||||
h.data['servers'].append({'name': 'Rome', 'host': '198.51.100.2', 'uid': 'entry-2',
|
||||
'protocols': {'awg': {'installed': True}}})
|
||||
run(h.service.link(0, 'awg2', 'exit-a'))
|
||||
run(h.service.link(3, 'awg', 'exit-a'))
|
||||
h.unreachable.add('198.51.100.2')
|
||||
results = run(h.service.detach_entries_for_exit('exit-a', 'exit_uninstalled'))
|
||||
self.assertEqual([r['status'] for r in results], ['success', 'error'])
|
||||
self.assertNotIn('exit_link', h.record(0, 'awg2'))
|
||||
self.assertEqual(h.record(3, 'awg')['exit_link']['stale'], 'exit_uninstalled')
|
||||
self.assertEqual(h.service.linked_entries(h.data, 'exit-a')[0]['stale'], 'exit_uninstalled')
|
||||
|
||||
def test_forget_entry_peers(self):
|
||||
h = self.linked()
|
||||
results = run(h.service.forget_entry_peers(h.data['servers'][0]))
|
||||
self.assertEqual(results, [{'protocol': 'awg2', 'exit_uid': 'exit-a', 'status': 'success'}])
|
||||
self.assertEqual(h.exit().calls, [('remove_peer', None, 'entry-uid:awg2')])
|
||||
|
||||
def test_status_and_listings(self):
|
||||
h = self.linked()
|
||||
status = run(h.service.status(0, 'awg2'))
|
||||
self.assertEqual(status['exit_link']['exit_uid'], 'exit-a')
|
||||
self.assertTrue(status['exit_link_status']['up'])
|
||||
self.assertEqual(status['mtu'], 1376)
|
||||
self.assertEqual(status['exit_mtu'], 1420)
|
||||
nodes = h.service.list_exit_nodes(h.data)
|
||||
self.assertEqual([(n['uid'], n['server_id'], n['obfuscation']) for n in nodes],
|
||||
[('exit-a', 1, False), ('exit-b', 2, True)])
|
||||
self.assertEqual(h.service.linked_entries(h.data, 'exit-a'),
|
||||
[{'server_id': 0, 'server_uid': 'entry-uid', 'name': 'Paris', 'protocol': 'awg2', 'stale': None}])
|
||||
self.assertEqual(h.service.linked_entries(h.data, ''), [])
|
||||
|
||||
def test_check_egress_matches_the_exit_address(self):
|
||||
h = self.linked()
|
||||
result = run(h.service.check_egress(0, 'awg2'))
|
||||
self.assertIn(('exit_check_egress', 'awg2'), h.awg().calls)
|
||||
self.assertTrue(result['matches'])
|
||||
self.assertFalse(result['leaks'])
|
||||
self.assertEqual(result['expected'], '203.0.113.5')
|
||||
self.assertEqual(result['exit_name'], 'Berlin-1')
|
||||
|
||||
def test_check_egress_flags_a_leak_and_a_natted_exit(self):
|
||||
h = self.linked()
|
||||
# clients still leave from the entry node
|
||||
h.awg().egress = {'via_exit': '198.51.100.1', 'direct': '198.51.100.1'}
|
||||
leaking = run(h.service.check_egress(0, 'awg2'))
|
||||
self.assertTrue(leaking['leaks'])
|
||||
self.assertFalse(leaking['matches'])
|
||||
# a NATed exit answers from another address: neither a match nor a leak
|
||||
h.awg().egress = {'via_exit': '203.0.113.77', 'direct': '198.51.100.1'}
|
||||
natted = run(h.service.check_egress(0, 'awg2'))
|
||||
self.assertFalse(natted['matches'])
|
||||
self.assertFalse(natted['leaks'])
|
||||
# nothing answered at all
|
||||
h.awg().egress = {'via_exit': '', 'direct': ''}
|
||||
blind = run(h.service.check_egress(0, 'awg2'))
|
||||
self.assertFalse(blind['matches'])
|
||||
self.assertFalse(blind['leaks'])
|
||||
|
||||
def test_check_egress_compares_against_the_resolved_endpoint(self):
|
||||
# a server added by hostname: the probe answers with an address, so the
|
||||
# comparison uses the endpoint WireGuard resolved that hostname to
|
||||
h = self.linked()
|
||||
h.data['servers'][1]['host'] = 'exit.example.com'
|
||||
run(h.service.relink_entry(0, 'awg2'))
|
||||
h.awg().status_sequence = None
|
||||
h.awg().egress = {'via_exit': '203.0.113.5', 'direct': '198.51.100.1'}
|
||||
h.awg().endpoint = '203.0.113.5:55520'
|
||||
result = run(h.service.check_egress(0, 'awg2'))
|
||||
self.assertEqual(result['expected'], 'exit.example.com')
|
||||
self.assertEqual(result['expected_ip'], '203.0.113.5')
|
||||
self.assertTrue(result['matches'])
|
||||
|
||||
# no live endpoint and a hostname to compare with: not a match, but the
|
||||
# clients are provably not leaving from the entry either
|
||||
h.awg().endpoint = ''
|
||||
unresolved = run(h.service.check_egress(0, 'awg2'))
|
||||
self.assertEqual(unresolved['expected_ip'], '')
|
||||
self.assertFalse(unresolved['matches'])
|
||||
self.assertFalse(unresolved['leaks'])
|
||||
|
||||
def test_check_egress_requires_a_link_and_maps_failures(self):
|
||||
h = Harness()
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.check_egress(0, 'awg2'))
|
||||
self.assertEqual(ctx.exception.code, 'not_linked')
|
||||
|
||||
h = self.linked()
|
||||
h.awg().egress_error = 'No such container: amnezia-awg2'
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.check_egress(0, 'awg2'))
|
||||
self.assertEqual(ctx.exception.code, 'exit_egress_failed')
|
||||
def test_dns_via_exit_needs_amnezia_dns_on_the_exit(self):
|
||||
h = self.linked()
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.set_dns_via_exit(0, 'awg2', True))
|
||||
self.assertEqual(ctx.exception.code, 'exit_dns_needs_dns')
|
||||
self.assertIsNone(h.record().get('exit_link', {}).get('dns_via_exit') or None)
|
||||
# linking with the flag is refused for the same reason
|
||||
h2 = Harness()
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h2.service.link(0, 'awg2', 'exit-a', dns_via_exit=True))
|
||||
self.assertEqual(ctx.exception.code, 'exit_dns_needs_dns')
|
||||
|
||||
def test_dns_via_exit_toggles_and_persists(self):
|
||||
h = self.linked()
|
||||
h.data['servers'][1]['protocols']['dns'] = {'installed': True}
|
||||
result = run(h.service.set_dns_via_exit(0, 'awg2', True))
|
||||
self.assertTrue(result['dns_via_exit'])
|
||||
self.assertEqual(h.awg().calls[-1], ('exit_set_dns_via_exit', 'awg2', True))
|
||||
self.assertTrue(h.record()['exit_link']['dns_via_exit'])
|
||||
|
||||
run(h.service.set_dns_via_exit(0, 'awg2', False))
|
||||
self.assertEqual(h.awg().calls[-1], ('exit_set_dns_via_exit', 'awg2', False))
|
||||
self.assertFalse(h.record()['exit_link']['dns_via_exit'])
|
||||
|
||||
def test_dns_via_exit_survives_a_relink_and_is_reported_by_the_apply_error(self):
|
||||
h = self.linked()
|
||||
h.data['servers'][1]['protocols']['dns'] = {'installed': True}
|
||||
run(h.service.set_dns_via_exit(0, 'awg2', True))
|
||||
run(h.service.relink_entry(0, 'awg2'))
|
||||
self.assertTrue(h.awg().calls[-2][2]['dns_via_exit'] if h.awg().calls[-2][0] == 'exit_link'
|
||||
else h.record()['exit_link']['dns_via_exit'])
|
||||
self.assertTrue(h.record()['exit_link']['dns_via_exit'])
|
||||
|
||||
h.awg().dns_error = 'container is gone'
|
||||
with self.assertRaises(ExitLinkError) as ctx:
|
||||
run(h.service.set_dns_via_exit(0, 'awg2', False))
|
||||
self.assertEqual(ctx.exception.code, 'exit_apply_failed')
|
||||
# the record still says what the container actually has
|
||||
self.assertTrue(h.record()['exit_link']['dns_via_exit'])
|
||||
|
||||
def test_removing_dns_from_the_exit_brings_entry_dns_back(self):
|
||||
h = self.linked()
|
||||
h.data['servers'][1]['protocols']['dns'] = {'installed': True}
|
||||
run(h.service.set_dns_via_exit(0, 'awg2', True))
|
||||
del h.data['servers'][1]['protocols']['dns']
|
||||
|
||||
restored = run(h.service.disable_dns_via_exit_for_exit('exit-a'))
|
||||
self.assertEqual([(r['protocol'], r['status']) for r in restored], [('awg2', 'success')])
|
||||
self.assertFalse(h.record()['exit_link']['dns_via_exit'])
|
||||
# nothing to do the second time
|
||||
self.assertEqual(run(h.service.disable_dns_via_exit_for_exit('exit-a')), [])
|
||||
|
||||
def test_restore_of_a_linked_entry_re_establishes_the_link(self):
|
||||
h = self.linked()
|
||||
result = run(h.service.reconcile_after_restore(0, 'awg2'))
|
||||
self.assertEqual(result, {'exit_link_restored': 'relinked'})
|
||||
self.assertIn('exit_link', [c[0] for c in h.awg().calls])
|
||||
self.assertIsNone(h.record()['exit_link']['stale'])
|
||||
|
||||
def test_restore_marks_the_link_stale_when_it_cannot_be_re_established(self):
|
||||
h = self.linked()
|
||||
h.awg().link_error = 'container is gone'
|
||||
result = run(h.service.reconcile_after_restore(0, 'awg2'))
|
||||
self.assertEqual(result['exit_link_restored'], 'stale')
|
||||
self.assertEqual(h.record()['exit_link']['stale'], 'restore_relink_failed')
|
||||
|
||||
def test_restore_drops_a_link_the_panel_does_not_track(self):
|
||||
# the archive predates the unlink: the container comes back linked
|
||||
h = Harness()
|
||||
h.awg().link_info = {'exit_uid': 'exit-a', 'exit_name': 'Berlin-1', 'obfuscation': False,
|
||||
'dns_via_exit': False, 'address': '10.9.0.7/24', 'endpoint': '203.0.113.5:55520'}
|
||||
result = run(h.service.reconcile_after_restore(0, 'awg2'))
|
||||
self.assertEqual(result, {'exit_link_restored': 'removed', 'exit_name': 'Berlin-1'})
|
||||
self.assertIn(('exit_unlink', 'awg2'), h.awg().calls)
|
||||
|
||||
def test_restore_of_an_unlinked_entry_without_a_link_file_does_nothing(self):
|
||||
h = Harness()
|
||||
self.assertEqual(run(h.service.reconcile_after_restore(0, 'awg2')), {})
|
||||
self.assertNotIn('exit_unlink', [c[0] for c in h.awg().calls])
|
||||
|
||||
def test_restore_of_an_exit_node_re_registers_its_entries(self):
|
||||
h = self.linked()
|
||||
result = run(h.service.reconcile_after_restore(1, 'exit'))
|
||||
self.assertEqual([(e['protocol'], e['status']) for e in result['exit_entries_relinked']],
|
||||
[('awg2', 'success')])
|
||||
# nothing to do when no entry points at this exit
|
||||
self.assertEqual(run(h.service.reconcile_after_restore(2, 'exit')), {})
|
||||
|
||||
def test_restore_of_an_unrelated_protocol_is_ignored(self):
|
||||
h = self.linked()
|
||||
self.assertEqual(run(h.service.reconcile_after_restore(0, 'xray')), {})
|
||||
|
||||
def test_peer_id_format(self):
|
||||
self.assertEqual(peer_id_for('abc', 'awg2'), 'abc:awg2')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,317 @@
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
|
||||
import managers.awg_manager as awg_module
|
||||
from managers.awg_manager import AWG_QUICK_FORCE_USERSPACE_PATCH, AWGManager
|
||||
from managers.exit_manager import EXIT_DEFAULTS, ExitManager
|
||||
|
||||
CONF = ExitManager.CONFIG_PATH
|
||||
TABLE = ExitManager.PEERS_TABLE
|
||||
PUB = ExitManager.PUBLIC_KEY_PATH
|
||||
|
||||
TRANSIT_CONF = """[Interface]
|
||||
# Managed by Amnezia Web Panel - exit node transit endpoint
|
||||
# Subnet = 10.9.0.0/24
|
||||
# Obfuscation = off
|
||||
PrivateKey = EXITPRIVATEKEY
|
||||
Address = 10.9.0.1/24
|
||||
ListenPort = 55520
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
# peerId = uid-a:awg2
|
||||
PublicKey = PEER_A
|
||||
PresharedKey = PSK_A
|
||||
AllowedIPs = 10.9.0.2/32
|
||||
|
||||
[Peer]
|
||||
# peerId = uid-b:awg
|
||||
PublicKey = PEER_B
|
||||
PresharedKey = PSK_B
|
||||
AllowedIPs = 10.9.0.4/32
|
||||
"""
|
||||
|
||||
PEERS_TABLE = [
|
||||
{'clientId': 'PEER_A', 'userData': {'clientName': 'Paris / AmneziaWG 2.0', 'peerId': 'uid-a:awg2', 'clientIp': '10.9.0.2', 'creationDate': 't', 'enabled': True}},
|
||||
{'clientId': 'PEER_B', 'userData': {'clientName': 'Oslo / AmneziaWG', 'peerId': 'uid-b:awg', 'clientIp': '10.9.0.4', 'creationDate': 't', 'enabled': True}},
|
||||
]
|
||||
|
||||
WG_SHOW = """interface: transit0
|
||||
public key: EXITPUB
|
||||
listening port: 55520
|
||||
|
||||
peer: PEER_A
|
||||
endpoint: 203.0.113.7:41234
|
||||
allowed ips: 10.9.0.2/32
|
||||
latest handshake: 12 seconds ago
|
||||
transfer: 1.50 MiB received, 3.00 MiB sent
|
||||
|
||||
peer: PEER_B
|
||||
allowed ips: 10.9.0.4/32
|
||||
"""
|
||||
|
||||
|
||||
class FakeSSH:
|
||||
"""In-memory container filesystem plus canned docker/ip answers, so the
|
||||
real manager methods run end to end without a server."""
|
||||
|
||||
def __init__(self, files=None, installed=False):
|
||||
self.files = dict(files or {})
|
||||
self.installed = installed
|
||||
self.uploads = {}
|
||||
self.sudo_uploads = {}
|
||||
self.commands = []
|
||||
|
||||
def upload_file(self, content, path):
|
||||
self.uploads[path] = content
|
||||
|
||||
def upload_file_sudo(self, content, path):
|
||||
self.sudo_uploads[path] = content
|
||||
|
||||
def run_command(self, cmd, timeout=60):
|
||||
self.commands.append(cmd)
|
||||
if 'docker --version' in cmd:
|
||||
return 'Docker version 27.0', '', 0
|
||||
if 'systemctl is-active' in cmd:
|
||||
return 'active', '', 0
|
||||
return '', '', 0
|
||||
|
||||
def run_sudo_script(self, script, timeout=120):
|
||||
return '', '', 0
|
||||
|
||||
def run_sudo_command(self, cmd, timeout=60):
|
||||
self.commands.append(cmd)
|
||||
m = re.search(r"docker cp (\S+) \S+?:(\S+)", cmd)
|
||||
if m:
|
||||
self.files[m.group(2)] = self.uploads[m.group(1)]
|
||||
return '', '', 0
|
||||
if 'docker ps -a' in cmd and 'amnezia-exit' in cmd:
|
||||
return ('amnezia-exit\n' if self.installed else ''), '', 0
|
||||
if cmd.startswith('docker ps') and 'amnezia-exit' in cmd:
|
||||
return ('Up 5 minutes' if self.installed else ''), '', 0
|
||||
if 'docker inspect --format' in cmd:
|
||||
return "'running'", '', 0
|
||||
if 'ip -6 route show default' in cmd or 'ip -6 addr show' in cmd:
|
||||
return '', '', 0
|
||||
if cmd.startswith('docker run'):
|
||||
self.installed = True
|
||||
return 'cid', '', 0
|
||||
if 'docker rm -fv' in cmd:
|
||||
self.installed = False
|
||||
return '', '', 0
|
||||
if 'cat > ' + CONF + ' <<EOF' in cmd:
|
||||
body = cmd.split('<<EOF\n', 1)[1].split('\nEOF', 1)[0]
|
||||
self.files[CONF] = body.replace('$(cat wireguard_server_private_key.key)', 'EXITPRIVATEKEY') + '\n'
|
||||
self.files[PUB] = 'EXITPUB\n'
|
||||
return '', '', 0
|
||||
if 'ip link show transit0' in cmd:
|
||||
return 'transit0: <POINTOPOINT> mtu 1420', '', 0
|
||||
if 'awg show all' in cmd:
|
||||
return WG_SHOW, '', 0
|
||||
if 'syncconf' in cmd:
|
||||
return '', '', 0
|
||||
m = re.search(r"docker exec -i \S+ cp (\S+) (\S+)", cmd)
|
||||
if m:
|
||||
self.files[m.group(2)] = self.files[m.group(1)]
|
||||
return '', '', 0
|
||||
m = re.search(r"docker exec -i \S+ cat (\S+)", cmd)
|
||||
if m:
|
||||
if m.group(1) in self.files:
|
||||
return self.files[m.group(1)], '', 0
|
||||
return '', 'No such file or directory', 1
|
||||
return '', '', 0
|
||||
|
||||
|
||||
def installed_ssh():
|
||||
return FakeSSH({CONF: TRANSIT_CONF, TABLE: json.dumps(PEERS_TABLE), PUB: 'EXITPUB\n'}, installed=True)
|
||||
|
||||
|
||||
class ExitInstallTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._sleep = awg_module.time.sleep
|
||||
awg_module.time.sleep = lambda seconds: None
|
||||
|
||||
def tearDown(self):
|
||||
awg_module.time.sleep = self._sleep
|
||||
|
||||
def test_install_plain_wireguard(self):
|
||||
ssh = FakeSSH()
|
||||
result = ExitManager(ssh).install_protocol('exit', port='55520')
|
||||
|
||||
self.assertEqual(result['status'], 'success')
|
||||
self.assertEqual(result['port'], '55520')
|
||||
self.assertEqual(result['subnet'], '10.9.0.0/24')
|
||||
self.assertEqual(result['public_key'], 'EXITPUB')
|
||||
self.assertFalse(result['obfuscation'])
|
||||
self.assertEqual(result['awg_params'], {})
|
||||
|
||||
build = next(c for c in ssh.commands if c.startswith('docker build'))
|
||||
self.assertIn('-t amnezia-exit /opt/amnezia/amnezia-exit', build)
|
||||
run = next(c for c in ssh.commands if c.startswith('docker run'))
|
||||
self.assertIn('-p 55520:55520/udp', run)
|
||||
self.assertEqual(run.count('--name amnezia-exit'), 1)
|
||||
self.assertIn('docker network connect amnezia-dns-net amnezia-exit', ssh.commands)
|
||||
self.assertEqual(ssh.sudo_uploads['/opt/amnezia/amnezia-exit/Dockerfile'],
|
||||
AWGManager._dockerfile_content(ExitManager.DOCKER_IMAGE, AWG_QUICK_FORCE_USERSPACE_PATCH))
|
||||
|
||||
conf = ssh.files[CONF]
|
||||
self.assertIn('Address = 10.9.0.1/24', conf)
|
||||
self.assertIn('ListenPort = 55520', conf)
|
||||
self.assertIn('MTU = 1420', conf)
|
||||
self.assertIn('# Subnet = 10.9.0.0/24', conf)
|
||||
self.assertIn('# Obfuscation = off', conf)
|
||||
self.assertNotIn('Jc', conf)
|
||||
self.assertTrue(conf.startswith('[Interface]'), 'metadata must live inside [Interface]')
|
||||
self.assertIn('docker restart amnezia-exit', ssh.commands)
|
||||
self.assertTrue(any('ip link show transit0' in c for c in ssh.commands))
|
||||
|
||||
def test_install_with_obfuscation_writes_handshake_keys_only(self):
|
||||
ssh = FakeSSH()
|
||||
result = ExitManager(ssh).install_protocol('exit', port='55520', obfuscation=True)
|
||||
|
||||
conf = ssh.files[CONF]
|
||||
self.assertTrue(result['obfuscation'])
|
||||
for key in ('Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'):
|
||||
self.assertRegex(conf, rf'(?m)^{key} = \S+$')
|
||||
for key in ('S3', 'S4', 'I1'):
|
||||
self.assertNotRegex(conf, rf'(?m)^{key} = ')
|
||||
self.assertIn('# Obfuscation = on', conf)
|
||||
self.assertEqual(set(result['awg_params']), {'junk_packet_count', 'junk_packet_min_size', 'junk_packet_max_size', 'init_packet_junk_size', 'response_packet_junk_size', 'init_packet_magic_header', 'response_packet_magic_header', 'underload_packet_magic_header', 'transport_packet_magic_header'})
|
||||
|
||||
def test_install_rejects_non_slash_24_subnet(self):
|
||||
ssh = FakeSSH()
|
||||
result = ExitManager(ssh).install_protocol('exit', subnet='10.9.0.0/23')
|
||||
self.assertEqual(result['status'], 'error')
|
||||
self.assertIn('/24', result['message'])
|
||||
self.assertFalse(any(c.startswith('docker') for c in ssh.commands))
|
||||
|
||||
def test_install_uses_defaults_and_custom_subnet(self):
|
||||
ssh = FakeSSH()
|
||||
result = ExitManager(ssh).install_protocol('exit', port=None, subnet='10.77.5.0/24')
|
||||
self.assertEqual(result['port'], EXIT_DEFAULTS['port'])
|
||||
self.assertIn('Address = 10.77.5.1/24', ssh.files[CONF])
|
||||
self.assertIn('# Subnet = 10.77.5.0/24', ssh.files[CONF])
|
||||
|
||||
def test_start_script_is_the_awg_script_with_exit_paths_and_mss_clamp(self):
|
||||
script = ExitManager(None)._render_start_script()
|
||||
self.assertIn('awg-quick up /opt/amnezia/exit/transit0.conf', script)
|
||||
self.assertIn('SUBNET=10.9.0.1/24', script)
|
||||
self.assertNotIn('awgprobe', script)
|
||||
self.assertEqual(script.count('TCPMSS --clamp-mss-to-pmtu'), 2)
|
||||
self.assertIn('-i transit0 -p tcp', script)
|
||||
self.assertIn('-o transit0 -p tcp', script)
|
||||
self.assertLess(script.index('TCPMSS'), script.index('tail -f /dev/null'))
|
||||
# the plain AWG script carries the entry-side block, not the exit clamp
|
||||
self.assertNotIn('transit0', AWGManager(None)._render_start_script('awg'))
|
||||
|
||||
|
||||
class ExitPeerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ssh = installed_ssh()
|
||||
self.manager = ExitManager(self.ssh)
|
||||
|
||||
def test_status_reports_peers_subnet_and_key(self):
|
||||
info = self.manager.get_server_status('exit')
|
||||
self.assertTrue(info['container_exists'])
|
||||
self.assertTrue(info['container_running'])
|
||||
self.assertEqual(info['port'], '55520')
|
||||
self.assertEqual(info['peers_count'], 2)
|
||||
self.assertNotIn('clients_count', info)
|
||||
self.assertEqual(info['subnet'], '10.9.0.0/24')
|
||||
self.assertFalse(info['obfuscation'])
|
||||
self.assertEqual(info['public_key'], 'EXITPUB')
|
||||
self.assertEqual(info['container_name'], 'amnezia-exit')
|
||||
self.assertEqual(info['base_protocol'], 'exit')
|
||||
|
||||
def test_info_for_entry_nodes(self):
|
||||
info = self.manager.get_info()
|
||||
self.assertEqual(info, {'public_key': 'EXITPUB', 'port': '55520', 'subnet': '10.9.0.0/24',
|
||||
'obfuscation': False, 'awg_params': {}})
|
||||
|
||||
def test_list_peers_merges_table_live_data_and_external_blocks(self):
|
||||
self.ssh.files[CONF] += "\n[Peer]\nPublicKey = PEER_X\nAllowedIPs = 10.9.0.9/32\n"
|
||||
|
||||
peers = {p['public_key']: p for p in self.manager.list_peers()}
|
||||
|
||||
self.assertEqual(set(peers), {'PEER_A', 'PEER_B', 'PEER_X'})
|
||||
self.assertEqual(peers['PEER_A']['peer_id'], 'uid-a:awg2')
|
||||
self.assertEqual(peers['PEER_A']['transit_ip'], '10.9.0.2')
|
||||
self.assertEqual(peers['PEER_A']['latest_handshake'], '12 seconds ago')
|
||||
self.assertEqual(peers['PEER_A']['rx_bytes'], int(1.5 * 1024 * 1024))
|
||||
self.assertFalse(peers['PEER_A']['external'])
|
||||
self.assertEqual(peers['PEER_B']['latest_handshake'], '')
|
||||
self.assertTrue(peers['PEER_X']['external'])
|
||||
self.assertEqual(peers['PEER_X']['name'], 'External (10.9.0.9)')
|
||||
self.assertEqual(peers['PEER_X']['transit_ip'], '10.9.0.9')
|
||||
|
||||
def test_add_peer_fills_the_first_free_address(self):
|
||||
peer = self.manager.add_peer('uid-c:awg3', 'Berlin / AmneziaWG 3.1', 'PEER_C')
|
||||
|
||||
self.assertEqual(peer['transit_ip'], '10.9.0.3')
|
||||
self.assertEqual(peer['public_key'], 'EXITPUB')
|
||||
self.assertEqual(peer['port'], '55520')
|
||||
self.assertEqual(peer['subnet'], '10.9.0.0/24')
|
||||
self.assertTrue(peer['psk'])
|
||||
conf = self.ssh.files[CONF]
|
||||
block = conf.split('[Peer]')
|
||||
self.assertEqual(len(block) - 1, 3)
|
||||
self.assertIn('# peerId = uid-c:awg3\nPublicKey = PEER_C\nPresharedKey = ' + peer['psk'] + '\nAllowedIPs = 10.9.0.3/32', conf)
|
||||
# sorted by address: PEER_C (.3) sits between PEER_A (.2) and PEER_B (.4)
|
||||
self.assertLess(conf.index('PEER_A'), conf.index('PEER_C'))
|
||||
self.assertLess(conf.index('PEER_C'), conf.index('PEER_B'))
|
||||
table = json.loads(self.ssh.files[TABLE])
|
||||
self.assertEqual([e['clientId'] for e in table], ['PEER_A', 'PEER_B', 'PEER_C'])
|
||||
self.assertEqual(table[2]['userData']['peerId'], 'uid-c:awg3')
|
||||
self.assertEqual(table[2]['userData']['clientIp'], '10.9.0.3')
|
||||
self.assertTrue(any('syncconf transit0' in c for c in self.ssh.commands))
|
||||
|
||||
def test_add_peer_upserts_by_peer_id_keeping_the_address(self):
|
||||
peer = self.manager.add_peer('uid-a:awg2', 'Paris / AmneziaWG 2.0', 'PEER_A_NEWKEY')
|
||||
|
||||
self.assertEqual(peer['transit_ip'], '10.9.0.2')
|
||||
conf = self.ssh.files[CONF]
|
||||
self.assertNotIn('PEER_A\n', conf)
|
||||
self.assertEqual(conf.count('AllowedIPs = 10.9.0.2/32'), 1)
|
||||
self.assertEqual(conf.count('[Peer]'), 2)
|
||||
table = json.loads(self.ssh.files[TABLE])
|
||||
self.assertEqual(sorted(e['clientId'] for e in table), ['PEER_A_NEWKEY', 'PEER_B'])
|
||||
|
||||
def test_add_peer_with_a_key_already_in_conf_replaces_that_block(self):
|
||||
self.ssh.files[CONF] += "\n[Peer]\nPublicKey = PEER_X\nAllowedIPs = 10.9.0.9/32\n"
|
||||
|
||||
self.manager.add_peer('uid-x:awg', 'X', 'PEER_X')
|
||||
|
||||
conf = self.ssh.files[CONF]
|
||||
self.assertEqual(conf.count('PublicKey = PEER_X'), 1)
|
||||
self.assertNotIn('10.9.0.9', conf) # the stray block is gone, a panel-managed one replaced it
|
||||
|
||||
def test_add_peer_validates_input(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.manager.add_peer('', 'x', 'PEER_C')
|
||||
with self.assertRaises(ValueError):
|
||||
self.manager.add_peer('uid-c:awg', 'x', '')
|
||||
|
||||
def test_remove_peer_by_key_and_by_id(self):
|
||||
self.assertTrue(self.manager.remove_peer(public_key='PEER_A'))
|
||||
self.assertNotIn('PEER_A', self.ssh.files[CONF])
|
||||
self.assertEqual([e['clientId'] for e in json.loads(self.ssh.files[TABLE])], ['PEER_B'])
|
||||
|
||||
self.assertTrue(self.manager.remove_peer(peer_id='uid-b:awg'))
|
||||
self.assertNotIn('[Peer]', self.ssh.files[CONF])
|
||||
self.assertEqual(json.loads(self.ssh.files[TABLE]), [])
|
||||
|
||||
self.assertFalse(self.manager.remove_peer(peer_id='uid-b:awg'))
|
||||
self.assertFalse(self.manager.remove_peer(public_key='NOPE'))
|
||||
self.assertFalse(self.manager.remove_peer())
|
||||
|
||||
def test_client_paths_are_closed(self):
|
||||
for method in ('add_client', 'get_client_config', 'toggle_client', 'rename_client', 'save_client_config', 'set_speed_limit'):
|
||||
with self.assertRaises(RuntimeError):
|
||||
getattr(self.manager, method)('exit', 'x')
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.manager._get_server_psk()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,101 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def read(*parts):
|
||||
with open(os.path.join(ROOT, *parts), encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class ExitNodeUiWiringTests(unittest.TestCase):
|
||||
"""Text-level checks of the wiring that makes the exit-node UI appear at
|
||||
all (a card that is not in MARKETPLACE_APPS is never shown, an install
|
||||
branch that is missing falls through to the AWG form)."""
|
||||
|
||||
def setUp(self):
|
||||
self.page = read('templates', 'server.html')
|
||||
|
||||
def test_marketplace_and_static_card(self):
|
||||
self.assertRegex(self.page, r"\{ proto: 'exit', category: 'services'")
|
||||
self.assertIn('id="proto-exit"', self.page)
|
||||
for el in ('exit-ctrl', 'exit-status', 'exit-info-grid', 'exit-actions', 'exit-install-btn'):
|
||||
self.assertIn(f'id="{el}"', self.page)
|
||||
|
||||
def test_protocol_sets(self):
|
||||
self.assertRegex(self.page, r"const SERVICE_PROTOS = \[.*'exit'.*\];")
|
||||
self.assertRegex(self.page, r"const UDP_INSTALL_PROTOS = new Set\(\[.*'exit'.*\]\);")
|
||||
self.assertIn("case 'exit': title = 'Exit Node'; break;", self.page)
|
||||
# never a client protocol
|
||||
self.assertNotRegex(self.page, r"const isVPN = \[[^\]]*'exit'")
|
||||
|
||||
def test_install_modal_has_an_exit_branch_and_options(self):
|
||||
self.assertIn('id="exitOptions"', self.page)
|
||||
self.assertIn('id="installExitSubnet"', self.page)
|
||||
self.assertIn('id="installExitObfuscation"', self.page)
|
||||
self.assertIn("} else if (base === 'exit') {", self.page)
|
||||
self.assertIn("params.exit_subnet = ", self.page)
|
||||
self.assertIn("params.exit_obfuscation = ", self.page)
|
||||
# the exit branch must come before the trailing AWG form fallback
|
||||
self.assertLess(self.page.index("} else if (base === 'exit') {"),
|
||||
self.page.index("awgOpts.style.display = 'block';"))
|
||||
|
||||
def test_link_controls_only_on_awg_cards(self):
|
||||
idx = self.page.index("const exitBtn = AWG_PROTOCOLS.includes(protoBase(proto))")
|
||||
self.assertIn("openExitLinkModal('${proto}')", self.page[idx:idx + 400])
|
||||
for fn in ('openExitLinkModal', 'saveExitLink', 'removeExitLink', 'repairExitLink', 'showExitPeers',
|
||||
'removeExitPeer', 'exitStatusBadge'):
|
||||
self.assertRegex(self.page, rf"function {fn}\(")
|
||||
self.assertIn('id="exitLinkModal"', self.page)
|
||||
self.assertIn('id="exitPeersModal"', self.page)
|
||||
self.assertIn("if (err.message !== 'exit_in_use') throw err;", self.page)
|
||||
# server-provided strings never reach innerHTML unescaped
|
||||
peers_table = self.page[self.page.index('function showExitPeers'):self.page.index('function removeExitPeer')]
|
||||
self.assertNotRegex(peers_table, r'\$\{p\.[a-z_]+( \|\| [^}]*)?\}')
|
||||
self.assertIn("document.body.dataset.serverUid = {{ (server.uid or '') | tojson }};", self.page)
|
||||
self.assertIn("{ protocol: proto, force: true }", self.page)
|
||||
|
||||
def test_egress_check_is_wired_to_a_linked_instance_only(self):
|
||||
self.assertIn('id="exitEgressBtn"', self.page)
|
||||
self.assertIn('id="exitLinkEgress"', self.page)
|
||||
self.assertRegex(self.page, r"function checkExitEgress\(")
|
||||
self.assertIn("exit-link/check-egress", self.page)
|
||||
# the button appears only when the instance actually has a link
|
||||
self.assertIn("document.getElementById('exitEgressBtn').classList.toggle('hidden', !link);", self.page)
|
||||
# the probed address comes from the server, so it is escaped
|
||||
body = self.page[self.page.index('function checkExitEgress'):self.page.index('async function repairExitLink')]
|
||||
self.assertNotRegex(body, r'\$\{r\.[a-z_]+( \|\| [^}]*)?\}')
|
||||
def test_dns_via_exit_toggle_and_mtu_hint(self):
|
||||
self.assertIn('id="exitDnsGroup"', self.page)
|
||||
self.assertIn('id="exitDnsViaExit"', self.page)
|
||||
self.assertRegex(self.page, r"function saveExitDns\(")
|
||||
self.assertIn("exit-link/dns", self.page)
|
||||
# the toggle belongs to a linked instance only
|
||||
self.assertIn("document.getElementById('exitDnsGroup').classList.toggle('hidden', !link);", self.page)
|
||||
# AWG settings warn about the link ceiling
|
||||
self.assertIn('id="awgSetMtuExitHint"', self.page)
|
||||
self.assertIn("_('awg_mtu_exit_hint').replace('{exit_mtu}', data.exit_mtu)", self.page)
|
||||
|
||||
def test_restore_reports_the_exit_link_outcome(self):
|
||||
body = self.page[self.page.index('backups/restore'):][:1600]
|
||||
for key in ('exit_entries_relinked', 'exit_link_restored'):
|
||||
self.assertIn(key, body, f'restore handler ignores {key}')
|
||||
for state in ('relinked', 'stale', 'removed', 'orphan'):
|
||||
self.assertIn(f"_('exit_restore_{state}')", body)
|
||||
|
||||
def test_translations_carry_every_ui_key_in_all_languages(self):
|
||||
keys = set(re.findall(r"_\('(exit_[a-z0-9_]+)'\)", self.page))
|
||||
keys |= set(re.findall(r"\{\{ _\('(exit_[a-z0-9_]+)'\) \}\}", self.page))
|
||||
keys |= {'exit_dns_disabled_no_dns'} # rendered through _(w) from the API warnings
|
||||
self.assertGreater(len(keys), 20)
|
||||
for lang in ('en', 'ru', 'fr', 'fa', 'zh'):
|
||||
data = json.loads(read('translations', f'{lang}.json'))
|
||||
missing = sorted(k for k in keys if k not in data)
|
||||
self.assertEqual(missing, [], f'{lang}: {missing}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as panel
|
||||
|
||||
|
||||
def seed_servers():
|
||||
return [
|
||||
{'name': 'Paris', 'host': '198.51.100.1', 'ssh_port': 22, 'username': 'root', 'password': 'x',
|
||||
'private_key': '', 'server_info': {}, 'uid': 'entry-uid',
|
||||
'protocols': {'awg2': {'installed': True, 'port': '55424', 'awg_params': {}, 'base_protocol': 'awg2',
|
||||
'instance': 1, 'display_name': 'AmneziaWG 2.0', 'container_name': 'amnezia-awg2',
|
||||
'exit_link': {'exit_uid': 'exit-uid', 'exit_name': 'Berlin-1', 'transit_ip': '10.9.0.7',
|
||||
'exit_public_key': 'k', 'endpoint_host': '203.0.113.5',
|
||||
'endpoint_port': '55520', 'obfuscation': False, 'dns_via_exit': False,
|
||||
'linked_at': 't', 'stale': None}}}},
|
||||
{'name': 'Berlin-1', 'host': '203.0.113.5', 'ssh_port': 22, 'username': 'root', 'password': 'x',
|
||||
'private_key': '', 'server_info': {}, 'uid': 'exit-uid',
|
||||
'protocols': {'exit': {'installed': True, 'port': '55520', 'awg_params': {}, 'subnet': '10.9.0.0/24',
|
||||
'public_key': 'EXITPUB', 'obfuscation': False, 'base_protocol': 'exit',
|
||||
'instance': 1, 'display_name': 'Exit Node', 'container_name': 'amnezia-exit'}}},
|
||||
]
|
||||
|
||||
|
||||
class ServerPageRenderTests(unittest.TestCase):
|
||||
"""Render the real server page through the app (startup creates the
|
||||
default admin, the session comes from a real login) and make sure the
|
||||
exit-node markup, scripts and translations come out of Jinja intact."""
|
||||
|
||||
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': seed_servers(), 'users': [], 'user_connections': []}, f)
|
||||
# keep the background SSH monitor out of the test
|
||||
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 render(self, client, path, lang='en'):
|
||||
client.cookies.set('lang', lang)
|
||||
response = client.get(path)
|
||||
self.assertEqual(response.status_code, 200, path)
|
||||
return response.text
|
||||
|
||||
def test_server_pages_render_with_exit_node_ui(self):
|
||||
with TestClient(panel.app) as client:
|
||||
login = client.post('/api/auth/login', json={'username': 'admin', 'password': 'admin'})
|
||||
self.assertEqual(login.status_code, 200, login.text)
|
||||
|
||||
for sid in (0, 1):
|
||||
page = self.render(client, f'/server/{sid}')
|
||||
for marker in ('id="proto-exit"', 'id="exitLinkModal"', 'id="exitPeersModal"', 'id="exitOptions"',
|
||||
"proto: 'exit'", 'function openExitLinkModal', 'function showExitPeers'):
|
||||
self.assertTrue(marker in page, f'/server/{sid} lacks {marker}')
|
||||
# a missing translation renders as the raw key
|
||||
self.assertEqual(re.findall(r'>\s*exit_[a-z0-9_]+\s*<', page), [], f'/server/{sid} leaks raw keys')
|
||||
self.assertTrue('Exit node: a transit endpoint' in page, f'/server/{sid} lacks the English description')
|
||||
|
||||
page = self.render(client, '/server/0', lang='ru')
|
||||
self.assertTrue('Выходная нода' in page, 'ru page lacks the Russian card text')
|
||||
i18n = json.loads(re.search(r'window\.I18N = (\{.*?\});', page).group(1))
|
||||
self.assertEqual(i18n['exit_link_saved'], 'Выходная нода привязана')
|
||||
self.assertTrue('dataset.serverUid = "entry-uid"' in page, 'own server uid not stamped on the page')
|
||||
|
||||
exits = client.get('/api/exit-nodes').json()
|
||||
self.assertEqual([n['uid'] for n in exits['exit_nodes']], ['exit-uid'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -476,6 +476,85 @@
|
||||
"max_speed": "Max speed (Mbps)",
|
||||
"speed_limit_unlim": "Speed limit (0 = unlim.)",
|
||||
"port_in_use_by": "Port {port} is already used by {owner}. Choose another port.",
|
||||
"exit_desc": "Exit node: a transit endpoint for other panel servers. Client traffic that enters elsewhere leaves to the internet from this server.",
|
||||
"exit_port_hint": "UDP port the transit link listens on. Only other panel servers connect here, never end users. Open it in the server's firewall for the entry nodes.",
|
||||
"exit_subnet": "Transit subnet",
|
||||
"exit_subnet_hint": "Private IPv4 /24 shared between this exit and its entry nodes. Must not overlap the client subnets (10.8.x.x).",
|
||||
"exit_obfuscation": "Obfuscate the inter-node link (AmneziaWG)",
|
||||
"exit_obfuscation_short": "Obfuscation",
|
||||
"exit_obfuscation_hint": "Enable when the hop between the nodes crosses DPI. Plain WireGuard otherwise. AmneziaWG Legacy entries can only use a non-obfuscated exit.",
|
||||
"exit_peers": "Peers",
|
||||
"exit_peers_title": "Exit node — linked entry nodes",
|
||||
"exit_peer_name": "Entry node",
|
||||
"exit_transit_ip": "Transit IP",
|
||||
"exit_public_key": "Public key",
|
||||
"exit_handshake": "Handshake",
|
||||
"exit_transfer": "Transfer",
|
||||
"exit_peer_external": "not managed by this panel",
|
||||
"exit_peer_remove_confirm": "Remove this peer? The entry node behind it loses its route through this exit.",
|
||||
"exit_peer_removed": "Peer removed",
|
||||
"exit_no_peers": "No entry nodes are linked to this exit yet.",
|
||||
"exit_stop_confirm": "Entry nodes route their clients through this exit. Stopping it puts those clients on the kill-switch (no traffic). Stop anyway?",
|
||||
"exit_in_use": "Entry nodes route through this exit node",
|
||||
"exit_link_title": "Exit node",
|
||||
"exit_link_desc": "Route all clients of this instance through another panel server. The link between the nodes is encrypted; clients keep their configs. IPv4 only: while linked, client IPv6 is refused rather than leaking from this server.",
|
||||
"exit_link_select": "Exit node",
|
||||
"exit_link_none": "Traffic leaves directly from this server.",
|
||||
"exit_link_current": "Linked to {name} (transit {ip})",
|
||||
"exit_link_via": "Exit node",
|
||||
"exit_link_btn": "Link",
|
||||
"exit_unlink_btn": "Unlink",
|
||||
"exit_unlink_confirm": "Unlink from the exit node? Clients will leave to the internet from this server again.",
|
||||
"exit_link_saved": "Exit node linked",
|
||||
"exit_unlinked": "Exit node unlinked",
|
||||
"exit_link_stale": "The exit node is unreachable or was removed. Clients are blocked by the kill-switch until you unlink or repair the link.",
|
||||
"exit_link_repair": "Repair link",
|
||||
"exit_no_exit_nodes": "No exit nodes installed yet. Install the Exit Node service on the server that should be the egress.",
|
||||
"exit_no_handshake": "No handshake from the exit node within 15 s — is the transit UDP port reachable? The link was rolled back.",
|
||||
"exit_apply_failed": "The routing could not be applied on this server; the link was rolled back.",
|
||||
"exit_not_found": "Exit node not found or not installed.",
|
||||
"not_linked": "This instance is not linked to an exit node.",
|
||||
"exit_mtu_too_big": "The client MTU of this instance exceeds the link MTU (1420). Lower it in the AWG settings (1376 is the default).",
|
||||
"exit_link_force": "Link anyway",
|
||||
"exit_self": "A server cannot be its own exit node.",
|
||||
"exit_legacy_obfuscation": "AmneziaWG Legacy can only link to a non-obfuscated exit node.",
|
||||
"exit_link_ipv6_warning": "This instance is dual-stack. While linked, client IPv6 is refused so nothing leaks from this server; clients fall back to IPv4 automatically.",
|
||||
"exit_link_ipv6_confirm": "Client IPv6 will be refused while linked. Continue?",
|
||||
"exit_link_mtu_warning": "Client MTU {mtu} exceeds the link MTU {exit_mtu}. Lower it in the AWG settings before linking.",
|
||||
"exit_status_up": "up",
|
||||
"exit_status_stale": "no handshake",
|
||||
"exit_status_down": "down",
|
||||
"exit_handshake_ago": "handshake {s}s ago",
|
||||
"no_handshake_yet": "No handshake yet — the link stays up, check the exit's firewall.",
|
||||
"old_exit_unreachable_orphan_peer": "The previous exit node was unreachable; its peer entry was left behind.",
|
||||
"exit_unreachable_orphan_peer": "The exit node was unreachable; its peer entry was left behind.",
|
||||
"exit_egress_check": "Check egress",
|
||||
"exit_egress_checking": "Checking the egress IP…",
|
||||
"exit_egress_match": "✓ Clients leave from {ip} — the exit node {name}.",
|
||||
"exit_egress_mismatch": "⚠ Clients leave from {ip}, the exit node address is {expected}. Expected when the exit is behind NAT.",
|
||||
"exit_egress_direct": "✗ Clients still leave from this server ({ip}) — their traffic is not going through the link.",
|
||||
"exit_egress_unknown": "Could not determine the egress IP: the check services did not answer.",
|
||||
"exit_egress_failed": "The egress check could not run on this server.",
|
||||
"exit_dns_via_exit": "Resolve client DNS at the exit node",
|
||||
"exit_dns_via_exit_hint": "Off by default: DNS queries stay on this server. On, they follow the link and are answered by the AmneziaDNS of the exit node, so a resolver never sees this server's country. Requires AmneziaDNS on the exit node.",
|
||||
"exit_dns_via_exit_on": "Client DNS now resolves at the exit node",
|
||||
"exit_dns_via_exit_off": "Client DNS resolves on this server again",
|
||||
"exit_dns_needs_dns": "Install AmneziaDNS on the exit node before routing client DNS through it.",
|
||||
"awg_mtu_exit_hint": "This instance is linked to an exit node: the link carries at most {exit_mtu}, so keep the client MTU at or below it.",
|
||||
"exit_dns_disabled_no_dns": "The exit node has no AmneziaDNS any more — client DNS was brought back to this server.",
|
||||
"exit_defaults_title": "Exit nodes",
|
||||
"exit_default_node": "Default exit node",
|
||||
"exit_default_none": "None — new instances keep their own egress",
|
||||
"exit_default_hint": "Every AmneziaWG instance installed from now on is linked to this exit node right after the install. Existing instances are left alone, and an install still succeeds if the link fails.",
|
||||
"exit_default_cleared": "The default exit node no longer exists and was cleared.",
|
||||
"exit_restore_entries_relinked": "Restored the exit-node peers of {n} linked entry node(s).",
|
||||
"exit_restore_entries_failed": "{n} entry node(s) could not be re-registered on this exit — repair them from their own page.",
|
||||
"exit_restore_relinked": "The exit-node link of this instance was re-established after the restore.",
|
||||
"exit_restore_stale": "The exit-node link could not be re-established after the restore — clients stay on the kill-switch until you repair or remove it.",
|
||||
"exit_restore_removed": "The archive carried an exit-node link this panel does not track; it was removed and clients leave from this server again.",
|
||||
"exit_restore_orphan": "The archive carried an unknown exit-node link and it could not be removed — check this instance manually.",
|
||||
"on": "on",
|
||||
"off": "off",
|
||||
"restore": "Restore",
|
||||
"restore_confirm_protocol": "Are you sure? The current protocol configuration will be overwritten with this backup.",
|
||||
"restore_success": "Backup restored successfully!",
|
||||
|
||||
@@ -443,6 +443,85 @@
|
||||
"max_speed": "حداکثر سرعت (Mbps)",
|
||||
"speed_limit_unlim": "محدودیت سرعت (0 = نامحدود)",
|
||||
"port_in_use_by": "پورت {port} قبلاً توسط {owner} استفاده شده است. پورت دیگری انتخاب کنید.",
|
||||
"exit_desc": "Exit node: a transit endpoint for other panel servers. Client traffic that enters elsewhere leaves to the internet from this server.",
|
||||
"exit_port_hint": "UDP port the transit link listens on. Only other panel servers connect here, never end users. Open it in the server's firewall for the entry nodes.",
|
||||
"exit_subnet": "Transit subnet",
|
||||
"exit_subnet_hint": "Private IPv4 /24 shared between this exit and its entry nodes. Must not overlap the client subnets (10.8.x.x).",
|
||||
"exit_obfuscation": "Obfuscate the inter-node link (AmneziaWG)",
|
||||
"exit_obfuscation_short": "Obfuscation",
|
||||
"exit_obfuscation_hint": "Enable when the hop between the nodes crosses DPI. Plain WireGuard otherwise. AmneziaWG Legacy entries can only use a non-obfuscated exit.",
|
||||
"exit_peers": "Peers",
|
||||
"exit_peers_title": "Exit node — linked entry nodes",
|
||||
"exit_peer_name": "Entry node",
|
||||
"exit_transit_ip": "Transit IP",
|
||||
"exit_public_key": "Public key",
|
||||
"exit_handshake": "Handshake",
|
||||
"exit_transfer": "Transfer",
|
||||
"exit_peer_external": "not managed by this panel",
|
||||
"exit_peer_remove_confirm": "Remove this peer? The entry node behind it loses its route through this exit.",
|
||||
"exit_peer_removed": "Peer removed",
|
||||
"exit_no_peers": "No entry nodes are linked to this exit yet.",
|
||||
"exit_stop_confirm": "Entry nodes route their clients through this exit. Stopping it puts those clients on the kill-switch (no traffic). Stop anyway?",
|
||||
"exit_in_use": "Entry nodes route through this exit node",
|
||||
"exit_link_title": "Exit node",
|
||||
"exit_link_desc": "Route all clients of this instance through another panel server. The link between the nodes is encrypted; clients keep their configs. IPv4 only: while linked, client IPv6 is refused rather than leaking from this server.",
|
||||
"exit_link_select": "Exit node",
|
||||
"exit_link_none": "Traffic leaves directly from this server.",
|
||||
"exit_link_current": "Linked to {name} (transit {ip})",
|
||||
"exit_link_via": "Exit node",
|
||||
"exit_link_btn": "Link",
|
||||
"exit_unlink_btn": "Unlink",
|
||||
"exit_unlink_confirm": "Unlink from the exit node? Clients will leave to the internet from this server again.",
|
||||
"exit_link_saved": "Exit node linked",
|
||||
"exit_unlinked": "Exit node unlinked",
|
||||
"exit_link_stale": "The exit node is unreachable or was removed. Clients are blocked by the kill-switch until you unlink or repair the link.",
|
||||
"exit_link_repair": "Repair link",
|
||||
"exit_no_exit_nodes": "No exit nodes installed yet. Install the Exit Node service on the server that should be the egress.",
|
||||
"exit_no_handshake": "No handshake from the exit node within 15 s — is the transit UDP port reachable? The link was rolled back.",
|
||||
"exit_apply_failed": "The routing could not be applied on this server; the link was rolled back.",
|
||||
"exit_not_found": "Exit node not found or not installed.",
|
||||
"not_linked": "This instance is not linked to an exit node.",
|
||||
"exit_mtu_too_big": "The client MTU of this instance exceeds the link MTU (1420). Lower it in the AWG settings (1376 is the default).",
|
||||
"exit_link_force": "Link anyway",
|
||||
"exit_self": "A server cannot be its own exit node.",
|
||||
"exit_legacy_obfuscation": "AmneziaWG Legacy can only link to a non-obfuscated exit node.",
|
||||
"exit_link_ipv6_warning": "This instance is dual-stack. While linked, client IPv6 is refused so nothing leaks from this server; clients fall back to IPv4 automatically.",
|
||||
"exit_link_ipv6_confirm": "Client IPv6 will be refused while linked. Continue?",
|
||||
"exit_link_mtu_warning": "Client MTU {mtu} exceeds the link MTU {exit_mtu}. Lower it in the AWG settings before linking.",
|
||||
"exit_status_up": "up",
|
||||
"exit_status_stale": "no handshake",
|
||||
"exit_status_down": "down",
|
||||
"exit_handshake_ago": "handshake {s}s ago",
|
||||
"no_handshake_yet": "No handshake yet — the link stays up, check the exit's firewall.",
|
||||
"old_exit_unreachable_orphan_peer": "The previous exit node was unreachable; its peer entry was left behind.",
|
||||
"exit_unreachable_orphan_peer": "The exit node was unreachable; its peer entry was left behind.",
|
||||
"exit_egress_check": "Check egress",
|
||||
"exit_egress_checking": "Checking the egress IP…",
|
||||
"exit_egress_match": "✓ Clients leave from {ip} — the exit node {name}.",
|
||||
"exit_egress_mismatch": "⚠ Clients leave from {ip}, the exit node address is {expected}. Expected when the exit is behind NAT.",
|
||||
"exit_egress_direct": "✗ Clients still leave from this server ({ip}) — their traffic is not going through the link.",
|
||||
"exit_egress_unknown": "Could not determine the egress IP: the check services did not answer.",
|
||||
"exit_egress_failed": "The egress check could not run on this server.",
|
||||
"exit_dns_via_exit": "Resolve client DNS at the exit node",
|
||||
"exit_dns_via_exit_hint": "Off by default: DNS queries stay on this server. On, they follow the link and are answered by the AmneziaDNS of the exit node, so a resolver never sees this server's country. Requires AmneziaDNS on the exit node.",
|
||||
"exit_dns_via_exit_on": "Client DNS now resolves at the exit node",
|
||||
"exit_dns_via_exit_off": "Client DNS resolves on this server again",
|
||||
"exit_dns_needs_dns": "Install AmneziaDNS on the exit node before routing client DNS through it.",
|
||||
"awg_mtu_exit_hint": "This instance is linked to an exit node: the link carries at most {exit_mtu}, so keep the client MTU at or below it.",
|
||||
"exit_dns_disabled_no_dns": "The exit node has no AmneziaDNS any more — client DNS was brought back to this server.",
|
||||
"exit_defaults_title": "Exit nodes",
|
||||
"exit_default_node": "Default exit node",
|
||||
"exit_default_none": "None — new instances keep their own egress",
|
||||
"exit_default_hint": "Every AmneziaWG instance installed from now on is linked to this exit node right after the install. Existing instances are left alone, and an install still succeeds if the link fails.",
|
||||
"exit_default_cleared": "The default exit node no longer exists and was cleared.",
|
||||
"exit_restore_entries_relinked": "Restored the exit-node peers of {n} linked entry node(s).",
|
||||
"exit_restore_entries_failed": "{n} entry node(s) could not be re-registered on this exit — repair them from their own page.",
|
||||
"exit_restore_relinked": "The exit-node link of this instance was re-established after the restore.",
|
||||
"exit_restore_stale": "The exit-node link could not be re-established after the restore — clients stay on the kill-switch until you repair or remove it.",
|
||||
"exit_restore_removed": "The archive carried an exit-node link this panel does not track; it was removed and clients leave from this server again.",
|
||||
"exit_restore_orphan": "The archive carried an unknown exit-node link and it could not be removed — check this instance manually.",
|
||||
"on": "on",
|
||||
"off": "off",
|
||||
"restore": "بازیابی",
|
||||
"restore_confirm_protocol": "آیا مطمئن هستید؟ پیکربندی فعلی پروتکل با این پشتیبان جایگزین خواهد شد.",
|
||||
"restore_success": "بازیابی پشتیبان با موفقیت انجام شد!",
|
||||
|
||||
@@ -443,6 +443,85 @@
|
||||
"max_speed": "Vitesse max (Mbps)",
|
||||
"speed_limit_unlim": "Limite de vitesse (0 = illimité)",
|
||||
"port_in_use_by": "Le port {port} est déjà utilisé par {owner}. Choisissez un autre port.",
|
||||
"exit_desc": "Exit node: a transit endpoint for other panel servers. Client traffic that enters elsewhere leaves to the internet from this server.",
|
||||
"exit_port_hint": "UDP port the transit link listens on. Only other panel servers connect here, never end users. Open it in the server's firewall for the entry nodes.",
|
||||
"exit_subnet": "Transit subnet",
|
||||
"exit_subnet_hint": "Private IPv4 /24 shared between this exit and its entry nodes. Must not overlap the client subnets (10.8.x.x).",
|
||||
"exit_obfuscation": "Obfuscate the inter-node link (AmneziaWG)",
|
||||
"exit_obfuscation_short": "Obfuscation",
|
||||
"exit_obfuscation_hint": "Enable when the hop between the nodes crosses DPI. Plain WireGuard otherwise. AmneziaWG Legacy entries can only use a non-obfuscated exit.",
|
||||
"exit_peers": "Peers",
|
||||
"exit_peers_title": "Exit node — linked entry nodes",
|
||||
"exit_peer_name": "Entry node",
|
||||
"exit_transit_ip": "Transit IP",
|
||||
"exit_public_key": "Public key",
|
||||
"exit_handshake": "Handshake",
|
||||
"exit_transfer": "Transfer",
|
||||
"exit_peer_external": "not managed by this panel",
|
||||
"exit_peer_remove_confirm": "Remove this peer? The entry node behind it loses its route through this exit.",
|
||||
"exit_peer_removed": "Peer removed",
|
||||
"exit_no_peers": "No entry nodes are linked to this exit yet.",
|
||||
"exit_stop_confirm": "Entry nodes route their clients through this exit. Stopping it puts those clients on the kill-switch (no traffic). Stop anyway?",
|
||||
"exit_in_use": "Entry nodes route through this exit node",
|
||||
"exit_link_title": "Exit node",
|
||||
"exit_link_desc": "Route all clients of this instance through another panel server. The link between the nodes is encrypted; clients keep their configs. IPv4 only: while linked, client IPv6 is refused rather than leaking from this server.",
|
||||
"exit_link_select": "Exit node",
|
||||
"exit_link_none": "Traffic leaves directly from this server.",
|
||||
"exit_link_current": "Linked to {name} (transit {ip})",
|
||||
"exit_link_via": "Exit node",
|
||||
"exit_link_btn": "Link",
|
||||
"exit_unlink_btn": "Unlink",
|
||||
"exit_unlink_confirm": "Unlink from the exit node? Clients will leave to the internet from this server again.",
|
||||
"exit_link_saved": "Exit node linked",
|
||||
"exit_unlinked": "Exit node unlinked",
|
||||
"exit_link_stale": "The exit node is unreachable or was removed. Clients are blocked by the kill-switch until you unlink or repair the link.",
|
||||
"exit_link_repair": "Repair link",
|
||||
"exit_no_exit_nodes": "No exit nodes installed yet. Install the Exit Node service on the server that should be the egress.",
|
||||
"exit_no_handshake": "No handshake from the exit node within 15 s — is the transit UDP port reachable? The link was rolled back.",
|
||||
"exit_apply_failed": "The routing could not be applied on this server; the link was rolled back.",
|
||||
"exit_not_found": "Exit node not found or not installed.",
|
||||
"not_linked": "This instance is not linked to an exit node.",
|
||||
"exit_mtu_too_big": "The client MTU of this instance exceeds the link MTU (1420). Lower it in the AWG settings (1376 is the default).",
|
||||
"exit_link_force": "Link anyway",
|
||||
"exit_self": "A server cannot be its own exit node.",
|
||||
"exit_legacy_obfuscation": "AmneziaWG Legacy can only link to a non-obfuscated exit node.",
|
||||
"exit_link_ipv6_warning": "This instance is dual-stack. While linked, client IPv6 is refused so nothing leaks from this server; clients fall back to IPv4 automatically.",
|
||||
"exit_link_ipv6_confirm": "Client IPv6 will be refused while linked. Continue?",
|
||||
"exit_link_mtu_warning": "Client MTU {mtu} exceeds the link MTU {exit_mtu}. Lower it in the AWG settings before linking.",
|
||||
"exit_status_up": "up",
|
||||
"exit_status_stale": "no handshake",
|
||||
"exit_status_down": "down",
|
||||
"exit_handshake_ago": "handshake {s}s ago",
|
||||
"no_handshake_yet": "No handshake yet — the link stays up, check the exit's firewall.",
|
||||
"old_exit_unreachable_orphan_peer": "The previous exit node was unreachable; its peer entry was left behind.",
|
||||
"exit_unreachable_orphan_peer": "The exit node was unreachable; its peer entry was left behind.",
|
||||
"exit_egress_check": "Check egress",
|
||||
"exit_egress_checking": "Checking the egress IP…",
|
||||
"exit_egress_match": "✓ Clients leave from {ip} — the exit node {name}.",
|
||||
"exit_egress_mismatch": "⚠ Clients leave from {ip}, the exit node address is {expected}. Expected when the exit is behind NAT.",
|
||||
"exit_egress_direct": "✗ Clients still leave from this server ({ip}) — their traffic is not going through the link.",
|
||||
"exit_egress_unknown": "Could not determine the egress IP: the check services did not answer.",
|
||||
"exit_egress_failed": "The egress check could not run on this server.",
|
||||
"exit_dns_via_exit": "Resolve client DNS at the exit node",
|
||||
"exit_dns_via_exit_hint": "Off by default: DNS queries stay on this server. On, they follow the link and are answered by the AmneziaDNS of the exit node, so a resolver never sees this server's country. Requires AmneziaDNS on the exit node.",
|
||||
"exit_dns_via_exit_on": "Client DNS now resolves at the exit node",
|
||||
"exit_dns_via_exit_off": "Client DNS resolves on this server again",
|
||||
"exit_dns_needs_dns": "Install AmneziaDNS on the exit node before routing client DNS through it.",
|
||||
"awg_mtu_exit_hint": "This instance is linked to an exit node: the link carries at most {exit_mtu}, so keep the client MTU at or below it.",
|
||||
"exit_dns_disabled_no_dns": "The exit node has no AmneziaDNS any more — client DNS was brought back to this server.",
|
||||
"exit_defaults_title": "Exit nodes",
|
||||
"exit_default_node": "Default exit node",
|
||||
"exit_default_none": "None — new instances keep their own egress",
|
||||
"exit_default_hint": "Every AmneziaWG instance installed from now on is linked to this exit node right after the install. Existing instances are left alone, and an install still succeeds if the link fails.",
|
||||
"exit_default_cleared": "The default exit node no longer exists and was cleared.",
|
||||
"exit_restore_entries_relinked": "Restored the exit-node peers of {n} linked entry node(s).",
|
||||
"exit_restore_entries_failed": "{n} entry node(s) could not be re-registered on this exit — repair them from their own page.",
|
||||
"exit_restore_relinked": "The exit-node link of this instance was re-established after the restore.",
|
||||
"exit_restore_stale": "The exit-node link could not be re-established after the restore — clients stay on the kill-switch until you repair or remove it.",
|
||||
"exit_restore_removed": "The archive carried an exit-node link this panel does not track; it was removed and clients leave from this server again.",
|
||||
"exit_restore_orphan": "The archive carried an unknown exit-node link and it could not be removed — check this instance manually.",
|
||||
"on": "on",
|
||||
"off": "off",
|
||||
"restore": "Restaurer",
|
||||
"restore_confirm_protocol": "Êtes-vous sûr ? La configuration actuelle du protocole sera écrasée par cette sauvegarde.",
|
||||
"restore_success": "Sauvegarde restaurée avec succès !",
|
||||
|
||||
@@ -476,6 +476,85 @@
|
||||
"max_speed": "Макс. скорость (Мбит/с)",
|
||||
"speed_limit_unlim": "Лимит скорости (0 = без лимита)",
|
||||
"port_in_use_by": "Порт {port} уже занят ({owner}). Выберите другой порт.",
|
||||
"exit_desc": "Выходная нода: транзитная точка для других серверов панели. Трафик клиентов, вошедший на другом сервере, выходит в интернет отсюда.",
|
||||
"exit_port_hint": "UDP-порт транзитного канала. Сюда подключаются только другие серверы панели, не пользователи. Откройте его в файрволе сервера для входных нод.",
|
||||
"exit_subnet": "Транзитная подсеть",
|
||||
"exit_subnet_hint": "Приватная IPv4 /24, общая для выхода и его входных нод. Не должна пересекаться с клиентскими подсетями (10.8.x.x).",
|
||||
"exit_obfuscation": "Обфусцировать канал между нодами (AmneziaWG)",
|
||||
"exit_obfuscation_short": "Обфускация",
|
||||
"exit_obfuscation_hint": "Включайте, если канал между нодами проходит через DPI. Иначе — обычный WireGuard. Входы на AmneziaWG Legacy могут использовать только необфусцированный выход.",
|
||||
"exit_peers": "Пиры",
|
||||
"exit_peers_title": "Выходная нода — привязанные входные ноды",
|
||||
"exit_peer_name": "Входная нода",
|
||||
"exit_transit_ip": "Транзитный IP",
|
||||
"exit_public_key": "Публичный ключ",
|
||||
"exit_handshake": "Handshake",
|
||||
"exit_transfer": "Трафик",
|
||||
"exit_peer_external": "не управляется панелью",
|
||||
"exit_peer_remove_confirm": "Удалить пира? Входная нода за ним потеряет маршрут через этот выход.",
|
||||
"exit_peer_removed": "Пир удалён",
|
||||
"exit_no_peers": "К этому выходу пока не привязана ни одна входная нода.",
|
||||
"exit_stop_confirm": "Через этот выход идёт трафик входных нод. Остановка отправит их клиентов на kill-switch (без трафика). Остановить всё равно?",
|
||||
"exit_in_use": "Через эту выходную ноду идут входные ноды",
|
||||
"exit_link_title": "Выходная нода",
|
||||
"exit_link_desc": "Направить трафик всех клиентов этого инстанса через другой сервер панели. Канал между нодами шифруется; клиентские конфиги не меняются. Только IPv4: пока связь активна, IPv6 клиентов блокируется, а не утекает с этого сервера.",
|
||||
"exit_link_select": "Выходная нода",
|
||||
"exit_link_none": "Трафик выходит напрямую с этого сервера.",
|
||||
"exit_link_current": "Привязано к {name} (транзит {ip})",
|
||||
"exit_link_via": "Выход через",
|
||||
"exit_link_btn": "Привязать",
|
||||
"exit_unlink_btn": "Отвязать",
|
||||
"exit_unlink_confirm": "Отвязать от выходной ноды? Клиенты снова будут выходить в интернет с этого сервера.",
|
||||
"exit_link_saved": "Выходная нода привязана",
|
||||
"exit_unlinked": "Выходная нода отвязана",
|
||||
"exit_link_stale": "Выходная нода недоступна или удалена. Клиенты заблокированы kill-switch'ем, пока связь не отвязана или не починена.",
|
||||
"exit_link_repair": "Починить связь",
|
||||
"exit_no_exit_nodes": "Выходных нод пока нет. Установите сервис Exit Node на сервере, который должен быть точкой выхода.",
|
||||
"exit_no_handshake": "Нет handshake от выходной ноды за 15 с — доступен ли транзитный UDP-порт? Связь откачена.",
|
||||
"exit_apply_failed": "Не удалось применить маршрутизацию на этом сервере; связь откачена.",
|
||||
"exit_not_found": "Выходная нода не найдена или не установлена.",
|
||||
"not_linked": "Этот инстанс не привязан к выходной ноде.",
|
||||
"exit_mtu_too_big": "MTU клиентов этого инстанса больше MTU канала (1420). Уменьшите его в настройках AWG (по умолчанию 1376).",
|
||||
"exit_link_force": "Привязать всё равно",
|
||||
"exit_self": "Сервер не может быть выходной нодой для самого себя.",
|
||||
"exit_legacy_obfuscation": "AmneziaWG Legacy можно привязать только к необфусцированной выходной ноде.",
|
||||
"exit_link_ipv6_warning": "Этот инстанс dual-stack. Пока связь активна, IPv6 клиентов блокируется, чтобы ничего не утекало с этого сервера; клиенты сами переходят на IPv4.",
|
||||
"exit_link_ipv6_confirm": "IPv6 клиентов будет заблокирован, пока связь активна. Продолжить?",
|
||||
"exit_link_mtu_warning": "MTU клиентов {mtu} больше MTU канала {exit_mtu}. Уменьшите его в настройках AWG перед привязкой.",
|
||||
"exit_status_up": "работает",
|
||||
"exit_status_stale": "нет handshake",
|
||||
"exit_status_down": "не работает",
|
||||
"exit_handshake_ago": "handshake {s} с назад",
|
||||
"no_handshake_yet": "Handshake пока нет — связь оставлена, проверьте файрвол выхода.",
|
||||
"old_exit_unreachable_orphan_peer": "Прежняя выходная нода была недоступна; запись пира на ней осталась.",
|
||||
"exit_unreachable_orphan_peer": "Выходная нода была недоступна; запись пира на ней осталась.",
|
||||
"exit_egress_check": "Проверить выход",
|
||||
"exit_egress_checking": "Проверяю выходной IP…",
|
||||
"exit_egress_match": "✓ Клиенты выходят с {ip} — это выходная нода {name}.",
|
||||
"exit_egress_mismatch": "⚠ Клиенты выходят с {ip}, а адрес выходной ноды — {expected}. Так и должно быть, если нода за NAT.",
|
||||
"exit_egress_direct": "✗ Клиенты по-прежнему выходят с этого сервера ({ip}) — трафик не идёт через связь.",
|
||||
"exit_egress_unknown": "Не удалось определить выходной IP: сервисы проверки не ответили.",
|
||||
"exit_egress_failed": "Не удалось выполнить проверку выхода на этом сервере.",
|
||||
"exit_dns_via_exit": "Резолвить DNS клиентов на выходной ноде",
|
||||
"exit_dns_via_exit_hint": "По умолчанию выключено: DNS-запросы остаются на этом сервере. Если включить, они идут через связь и отвечает AmneziaDNS выходной ноды — резолвер не видит страну этого сервера. Нужен AmneziaDNS на выходной ноде.",
|
||||
"exit_dns_via_exit_on": "DNS клиентов резолвится на выходной ноде",
|
||||
"exit_dns_via_exit_off": "DNS клиентов снова резолвится на этом сервере",
|
||||
"exit_dns_needs_dns": "Установите AmneziaDNS на выходной ноде, прежде чем направлять туда DNS клиентов.",
|
||||
"awg_mtu_exit_hint": "Инстанс привязан к выходной ноде: канал несёт не больше {exit_mtu}, держите MTU клиентов не выше этого значения.",
|
||||
"exit_dns_disabled_no_dns": "На выходной ноде больше нет AmneziaDNS — DNS клиентов вернулся на этот сервер.",
|
||||
"exit_defaults_title": "Выходные ноды",
|
||||
"exit_default_node": "Выходная нода по умолчанию",
|
||||
"exit_default_none": "Нет — новые инстансы выходят сами",
|
||||
"exit_default_hint": "Каждый новый инстанс AmneziaWG будет привязан к этой выходной ноде сразу после установки. Уже установленные не трогаются, а если привязка не удастся, установка всё равно считается успешной.",
|
||||
"exit_default_cleared": "Выходной ноды по умолчанию больше нет, значение очищено.",
|
||||
"exit_restore_entries_relinked": "Пиры восстановлены для {n} привязанных входных нод.",
|
||||
"exit_restore_entries_failed": "{n} входных нод не удалось перерегистрировать на этой выходной ноде — почините связь с их страницы.",
|
||||
"exit_restore_relinked": "Связь с выходной нодой восстановлена после отката.",
|
||||
"exit_restore_stale": "Связь с выходной нодой не поднялась после отката — клиенты заблокированы kill-switch'ем, пока вы её не почините или не снимете.",
|
||||
"exit_restore_removed": "В архиве была связь с выходной нодой, о которой панель не знает; связь снята, клиенты снова выходят с этого сервера.",
|
||||
"exit_restore_orphan": "В архиве была неизвестная связь с выходной нодой, и снять её не удалось — проверьте этот инстанс вручную.",
|
||||
"on": "вкл",
|
||||
"off": "выкл",
|
||||
"restore": "Восстановить",
|
||||
"restore_confirm_protocol": "Вы уверены? Текущая конфигурация протокола будет заменена данным бекапом.",
|
||||
"restore_success": "Бекап успешно восстановлен!",
|
||||
|
||||
@@ -443,6 +443,85 @@
|
||||
"max_speed": "最大速度 (Mbps)",
|
||||
"speed_limit_unlim": "速度限制(0 = 不限)",
|
||||
"port_in_use_by": "端口 {port} 已被 {owner} 占用,请选择其他端口。",
|
||||
"exit_desc": "Exit node: a transit endpoint for other panel servers. Client traffic that enters elsewhere leaves to the internet from this server.",
|
||||
"exit_port_hint": "UDP port the transit link listens on. Only other panel servers connect here, never end users. Open it in the server's firewall for the entry nodes.",
|
||||
"exit_subnet": "Transit subnet",
|
||||
"exit_subnet_hint": "Private IPv4 /24 shared between this exit and its entry nodes. Must not overlap the client subnets (10.8.x.x).",
|
||||
"exit_obfuscation": "Obfuscate the inter-node link (AmneziaWG)",
|
||||
"exit_obfuscation_short": "Obfuscation",
|
||||
"exit_obfuscation_hint": "Enable when the hop between the nodes crosses DPI. Plain WireGuard otherwise. AmneziaWG Legacy entries can only use a non-obfuscated exit.",
|
||||
"exit_peers": "Peers",
|
||||
"exit_peers_title": "Exit node — linked entry nodes",
|
||||
"exit_peer_name": "Entry node",
|
||||
"exit_transit_ip": "Transit IP",
|
||||
"exit_public_key": "Public key",
|
||||
"exit_handshake": "Handshake",
|
||||
"exit_transfer": "Transfer",
|
||||
"exit_peer_external": "not managed by this panel",
|
||||
"exit_peer_remove_confirm": "Remove this peer? The entry node behind it loses its route through this exit.",
|
||||
"exit_peer_removed": "Peer removed",
|
||||
"exit_no_peers": "No entry nodes are linked to this exit yet.",
|
||||
"exit_stop_confirm": "Entry nodes route their clients through this exit. Stopping it puts those clients on the kill-switch (no traffic). Stop anyway?",
|
||||
"exit_in_use": "Entry nodes route through this exit node",
|
||||
"exit_link_title": "Exit node",
|
||||
"exit_link_desc": "Route all clients of this instance through another panel server. The link between the nodes is encrypted; clients keep their configs. IPv4 only: while linked, client IPv6 is refused rather than leaking from this server.",
|
||||
"exit_link_select": "Exit node",
|
||||
"exit_link_none": "Traffic leaves directly from this server.",
|
||||
"exit_link_current": "Linked to {name} (transit {ip})",
|
||||
"exit_link_via": "Exit node",
|
||||
"exit_link_btn": "Link",
|
||||
"exit_unlink_btn": "Unlink",
|
||||
"exit_unlink_confirm": "Unlink from the exit node? Clients will leave to the internet from this server again.",
|
||||
"exit_link_saved": "Exit node linked",
|
||||
"exit_unlinked": "Exit node unlinked",
|
||||
"exit_link_stale": "The exit node is unreachable or was removed. Clients are blocked by the kill-switch until you unlink or repair the link.",
|
||||
"exit_link_repair": "Repair link",
|
||||
"exit_no_exit_nodes": "No exit nodes installed yet. Install the Exit Node service on the server that should be the egress.",
|
||||
"exit_no_handshake": "No handshake from the exit node within 15 s — is the transit UDP port reachable? The link was rolled back.",
|
||||
"exit_apply_failed": "The routing could not be applied on this server; the link was rolled back.",
|
||||
"exit_not_found": "Exit node not found or not installed.",
|
||||
"not_linked": "This instance is not linked to an exit node.",
|
||||
"exit_mtu_too_big": "The client MTU of this instance exceeds the link MTU (1420). Lower it in the AWG settings (1376 is the default).",
|
||||
"exit_link_force": "Link anyway",
|
||||
"exit_self": "A server cannot be its own exit node.",
|
||||
"exit_legacy_obfuscation": "AmneziaWG Legacy can only link to a non-obfuscated exit node.",
|
||||
"exit_link_ipv6_warning": "This instance is dual-stack. While linked, client IPv6 is refused so nothing leaks from this server; clients fall back to IPv4 automatically.",
|
||||
"exit_link_ipv6_confirm": "Client IPv6 will be refused while linked. Continue?",
|
||||
"exit_link_mtu_warning": "Client MTU {mtu} exceeds the link MTU {exit_mtu}. Lower it in the AWG settings before linking.",
|
||||
"exit_status_up": "up",
|
||||
"exit_status_stale": "no handshake",
|
||||
"exit_status_down": "down",
|
||||
"exit_handshake_ago": "handshake {s}s ago",
|
||||
"no_handshake_yet": "No handshake yet — the link stays up, check the exit's firewall.",
|
||||
"old_exit_unreachable_orphan_peer": "The previous exit node was unreachable; its peer entry was left behind.",
|
||||
"exit_unreachable_orphan_peer": "The exit node was unreachable; its peer entry was left behind.",
|
||||
"exit_egress_check": "Check egress",
|
||||
"exit_egress_checking": "Checking the egress IP…",
|
||||
"exit_egress_match": "✓ Clients leave from {ip} — the exit node {name}.",
|
||||
"exit_egress_mismatch": "⚠ Clients leave from {ip}, the exit node address is {expected}. Expected when the exit is behind NAT.",
|
||||
"exit_egress_direct": "✗ Clients still leave from this server ({ip}) — their traffic is not going through the link.",
|
||||
"exit_egress_unknown": "Could not determine the egress IP: the check services did not answer.",
|
||||
"exit_egress_failed": "The egress check could not run on this server.",
|
||||
"exit_dns_via_exit": "Resolve client DNS at the exit node",
|
||||
"exit_dns_via_exit_hint": "Off by default: DNS queries stay on this server. On, they follow the link and are answered by the AmneziaDNS of the exit node, so a resolver never sees this server's country. Requires AmneziaDNS on the exit node.",
|
||||
"exit_dns_via_exit_on": "Client DNS now resolves at the exit node",
|
||||
"exit_dns_via_exit_off": "Client DNS resolves on this server again",
|
||||
"exit_dns_needs_dns": "Install AmneziaDNS on the exit node before routing client DNS through it.",
|
||||
"awg_mtu_exit_hint": "This instance is linked to an exit node: the link carries at most {exit_mtu}, so keep the client MTU at or below it.",
|
||||
"exit_dns_disabled_no_dns": "The exit node has no AmneziaDNS any more — client DNS was brought back to this server.",
|
||||
"exit_defaults_title": "Exit nodes",
|
||||
"exit_default_node": "Default exit node",
|
||||
"exit_default_none": "None — new instances keep their own egress",
|
||||
"exit_default_hint": "Every AmneziaWG instance installed from now on is linked to this exit node right after the install. Existing instances are left alone, and an install still succeeds if the link fails.",
|
||||
"exit_default_cleared": "The default exit node no longer exists and was cleared.",
|
||||
"exit_restore_entries_relinked": "Restored the exit-node peers of {n} linked entry node(s).",
|
||||
"exit_restore_entries_failed": "{n} entry node(s) could not be re-registered on this exit — repair them from their own page.",
|
||||
"exit_restore_relinked": "The exit-node link of this instance was re-established after the restore.",
|
||||
"exit_restore_stale": "The exit-node link could not be re-established after the restore — clients stay on the kill-switch until you repair or remove it.",
|
||||
"exit_restore_removed": "The archive carried an exit-node link this panel does not track; it was removed and clients leave from this server again.",
|
||||
"exit_restore_orphan": "The archive carried an unknown exit-node link and it could not be removed — check this instance manually.",
|
||||
"on": "on",
|
||||
"off": "off",
|
||||
"restore": "恢复",
|
||||
"restore_confirm_protocol": "您确定吗?当前协议配置将被此备份覆盖。",
|
||||
"restore_success": "备份恢复成功!",
|
||||
|
||||
Reference in new issue
Block a user