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>
135 lines
5.9 KiB
Python
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()
|