169 lines
6.0 KiB
Python
169 lines
6.0 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from sqlalchemy import create_engine, inspect, text
|
|
from sqlalchemy.dialects import mysql
|
|
from sqlalchemy.schema import CreateTable
|
|
|
|
|
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
|
if str(BACKEND_DIR) not in sys.path:
|
|
sys.path.insert(0, str(BACKEND_DIR))
|
|
|
|
from rpa_engine.egress_channels import (
|
|
EgressChannel,
|
|
EgressChannelUnavailable,
|
|
EgressSnapshot,
|
|
LocalAddress,
|
|
discover_egress_channels,
|
|
reset_egress_cache_for_tests,
|
|
resolve_send_channels,
|
|
)
|
|
from rpa_engine.source_bound_proxy import SourceBoundProxy
|
|
from models.db_migrate import migrate_accounts_table
|
|
from models.models import Account
|
|
|
|
|
|
class EgressChannelTests(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self):
|
|
reset_egress_cache_for_tests()
|
|
|
|
async def test_discovery_deduplicates_public_ip_and_keeps_bindable_source(self):
|
|
candidates = [
|
|
LocalAddress(None, "default", True),
|
|
LocalAddress("10.0.0.5", "eth0"),
|
|
LocalAddress("10.0.0.6", "eth0:1"),
|
|
]
|
|
|
|
async def probe(candidate):
|
|
public_ip = "203.0.113.10" if candidate.source_ip != "10.0.0.6" else "203.0.113.11"
|
|
return (
|
|
EgressChannel(
|
|
public_ip=public_ip,
|
|
source_ip=candidate.source_ip,
|
|
interface=candidate.interface,
|
|
is_default=candidate.is_default,
|
|
),
|
|
"",
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"rpa_engine.egress_channels.local_address_candidates",
|
|
return_value=candidates,
|
|
),
|
|
patch(
|
|
"rpa_engine.egress_channels._probe_local_address",
|
|
AsyncMock(side_effect=probe),
|
|
),
|
|
):
|
|
snapshot = await discover_egress_channels(force=True)
|
|
|
|
self.assertEqual([item.public_ip for item in snapshot.channels], ["203.0.113.10", "203.0.113.11"])
|
|
self.assertEqual(snapshot.channels[0].source_ip, "10.0.0.5")
|
|
self.assertTrue(snapshot.channels[0].is_default)
|
|
|
|
async def test_selected_channel_is_first_and_attempt_count_is_bounded(self):
|
|
snapshot = EgressSnapshot(
|
|
channels=(
|
|
EgressChannel("198.51.100.1", "10.0.0.1", "eth0", True),
|
|
EgressChannel("198.51.100.2", "10.0.0.2", "eth0:1"),
|
|
EgressChannel("198.51.100.3", "10.0.0.3", "eth0:2"),
|
|
),
|
|
errors=(),
|
|
detected_at=time.time(),
|
|
)
|
|
with patch(
|
|
"rpa_engine.egress_channels.discover_egress_channels",
|
|
AsyncMock(return_value=snapshot),
|
|
):
|
|
routes = await resolve_send_channels("198.51.100.2", 2)
|
|
|
|
self.assertEqual([item.public_ip for item in routes], ["198.51.100.2", "198.51.100.1"])
|
|
|
|
async def test_missing_selected_channel_fails_closed(self):
|
|
snapshot = EgressSnapshot(
|
|
channels=(EgressChannel("198.51.100.1", None, "default", True),),
|
|
errors=(),
|
|
detected_at=time.time(),
|
|
)
|
|
with patch(
|
|
"rpa_engine.egress_channels.discover_egress_channels",
|
|
AsyncMock(return_value=snapshot),
|
|
):
|
|
with self.assertRaises(EgressChannelUnavailable):
|
|
await resolve_send_channels("198.51.100.99", 2)
|
|
|
|
async def test_browser_proxy_binds_selected_source_address(self):
|
|
observed_peer = asyncio.get_running_loop().create_future()
|
|
|
|
async def target_handler(reader, writer):
|
|
if not observed_peer.done():
|
|
observed_peer.set_result(writer.get_extra_info("peername")[0])
|
|
payload = await reader.readexactly(4)
|
|
writer.write(payload)
|
|
await writer.drain()
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
target = await asyncio.start_server(target_handler, "127.0.0.1", 0)
|
|
target_port = target.sockets[0].getsockname()[1]
|
|
proxy = await SourceBoundProxy("127.0.0.2").start()
|
|
writer = None
|
|
try:
|
|
reader, writer = await asyncio.open_connection(
|
|
"127.0.0.1",
|
|
int(proxy.server_url.rpartition(":")[2]),
|
|
)
|
|
writer.write(
|
|
(
|
|
f"CONNECT 127.0.0.1:{target_port} HTTP/1.1\r\n"
|
|
f"Host: 127.0.0.1:{target_port}\r\n\r\n"
|
|
).encode("ascii")
|
|
)
|
|
await writer.drain()
|
|
response = await reader.readuntil(b"\r\n\r\n")
|
|
self.assertIn(b"200 Connection Established", response)
|
|
|
|
writer.write(b"ping")
|
|
await writer.drain()
|
|
self.assertEqual(await reader.readexactly(4), b"ping")
|
|
self.assertEqual(await asyncio.wait_for(observed_peer, 1), "127.0.0.2")
|
|
finally:
|
|
if writer is not None:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
await proxy.close()
|
|
target.close()
|
|
await target.wait_closed()
|
|
|
|
|
|
class EgressMigrationTests(unittest.TestCase):
|
|
def test_mysql_accounts_uses_longtext_for_browser_payloads(self):
|
|
ddl = str(CreateTable(Account.__table__).compile(dialect=mysql.dialect()))
|
|
|
|
self.assertIn("cookie_data LONGTEXT", ddl)
|
|
self.assertIn("im_session_data LONGTEXT", ddl)
|
|
self.assertIn("qr_code_base64 LONGTEXT", ddl)
|
|
|
|
def test_old_accounts_table_receives_egress_columns(self):
|
|
engine = create_engine("sqlite:///:memory:")
|
|
with engine.begin() as connection:
|
|
connection.execute(text("CREATE TABLE accounts (id INTEGER PRIMARY KEY)"))
|
|
migrate_accounts_table(connection)
|
|
columns = {item["name"] for item in inspect(connection).get_columns("accounts")}
|
|
|
|
self.assertIn("egress_public_ip", columns)
|
|
self.assertIn("egress_auto_attempts", columns)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|