Files
hell 69893ba340 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>
2026-09-08 23:05:51 +07:00

135 lines
5.9 KiB
Python

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()