mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-09-18 01:44:10 +02:00
69893ba340
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>
437 lines
17 KiB
Python
437 lines
17 KiB
Python
"""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
|