From 14e257d0918e39d8f0c921718d8574dc8b273719 Mon Sep 17 00:00:00 2001 From: SimplifiedPrivacy Date: Sun, 25 Oct 2026 14:25:09 -0400 Subject: [PATCH] systemwide wg goes connect -> test -> location check -> mtu check -> port check -> wg renegotiate --- core/models/Result.py | 1 + .../connection_enable.py | 18 +- .../networking/general_connection_tools/ip.py | 369 ++++++++++++++-- .../location_evaluation.py | 5 +- .../testing_evaluating.py | 26 +- .../networking/mtu/mtu_orchestrator.py | 66 +++ .../wireguard => mtu}/mtu_tools.py | 0 .../systemwide/systemwide_wireguard.py | 394 +++++++++--------- core/services/profiles/profile_connections.py | 20 +- 9 files changed, 643 insertions(+), 256 deletions(-) create mode 100644 core/services/networking/mtu/mtu_orchestrator.py rename core/services/networking/{systemwide/wireguard => mtu}/mtu_tools.py (100%) diff --git a/core/models/Result.py b/core/models/Result.py index 33e98ee..dcb2972 100644 --- a/core/models/Result.py +++ b/core/models/Result.py @@ -57,6 +57,7 @@ class ResultError(Enum): NODE_ERROR = "node_error" NO_INTERNET = "no_internet" PORT = "port" + RENEGOTIATION = "renegotiation" NAVIGATE_TO = "navigate_to" UNKNOWN = "unknown" diff --git a/core/services/networking/general_connection_tools/connection_enable.py b/core/services/networking/general_connection_tools/connection_enable.py index 50ce7bf..ce3f24b 100644 --- a/core/services/networking/general_connection_tools/connection_enable.py +++ b/core/services/networking/general_connection_tools/connection_enable.py @@ -62,12 +62,18 @@ def establish_connection( if profile.connection.code == "wireguard": _ensure_wireguard_configured(profile, connection_observer) - establish_fn = { - ProfileType.SESSION: ConnectionController.establish_session_connection, - ProfileType.SYSTEM: establish_system_connection, - }[profile.type] - - return _establish_with_renegotiation(profile, establish_fn, ignore, connection_observer, ticket_observer) + + if profile.connection.code == "wireguard" and profile.type == ProfileType.SYSTEM: + logger.info("Doing a systemwide WG connection..") + return establish_system_connection(profile, ignore, connection_observer, ticket_observer) + + else: + establish_fn = { + ProfileType.SESSION: ConnectionController.establish_session_connection, + ProfileType.SYSTEM: establish_system_connection, + }[profile.type] + + return _establish_with_renegotiation(profile, establish_fn, ignore, connection_observer, ticket_observer) def _ensure_proxy_configured( diff --git a/core/services/networking/general_connection_tools/ip.py b/core/services/networking/general_connection_tools/ip.py index da82104..7eebc6a 100644 --- a/core/services/networking/general_connection_tools/ip.py +++ b/core/services/networking/general_connection_tools/ip.py @@ -1,4 +1,8 @@ +from core.services.networking.api_requests.subtools.extract_domain import extract_domain + from core.services.networking.httpx.classify_response import classify_response +from core.observers.Notifications import Notifications, EventType +from core.utils.run_commands import run_generic_command # models from core.models.Result import Result, ResultError @@ -6,7 +10,9 @@ from core.services.networking.api_requests.ApiResponseModel import ApiResponse, from core.Constants import Constants from core.errors.logger import logger -from essentials.observers.ConnectionObserver import ConnectionObserver +# from essentials.observers.ConnectionObserver import ConnectionObserver +from core.observers.ConnectionObserver import ConnectionObserver + # generic import time @@ -31,69 +37,339 @@ API_ENDPOINT_FORMATS = { MAX_ATTEMPTS = 3 + + + +import time +import socket +import multiprocessing +from threading import Thread +from queue import Queue, Empty + +def is_dns_problem_worker(url: str, queue: multiprocessing.Queue): + """Worker function for a separate PROCESS""" + domain = extract_domain(url) + try: + socket.gethostbyname(domain) + queue.put((url, False)) # Resolved + except Exception: + queue.put((url, True)) # Failed + def test(connection_observer: Optional[ConnectionObserver], port: Optional[int] = None) -> Result: - if connection_observer: - connection_observer.notify("connecting", "Testing IP..") - return asyncio.run(_test(connection_observer, port)) - - -async def _test(connection_observer: Optional[ConnectionObserver], port: Optional[int] = None) -> Result: endpoint_list = list(API_ENDPOINT_FORMATS.keys())[:MAX_ATTEMPTS] - + start_time = time.time() + total_budget = 5.0 + + # 1. NUCLEAR DNS PRE-FLIGHT (Using Processes, not Threads) + if port is None: + dns_queue = multiprocessing.Queue() + processes = [] + + for url in endpoint_list: + p = multiprocessing.Process(target=is_dns_problem_worker, args=(url, dns_queue)) + p.start() + processes.append(p) + + any_resolved = False + dns_timeout = 2 + + try: + # Wait for the first successful resolution + while time.time() - start_time < dns_timeout: + try: + # Non-blocking check of the queue + url, is_problem = dns_queue.get(timeout=0.1) + if not is_problem: + any_resolved = True + break + except Empty: + continue + finally: + # THE NUCLEAR STRIKE: + # We kill every single process immediately. + # The OS kernel wipes them from memory regardless of where they are stuck. + for p in processes: + p.terminate() + p.join() # Clean up the zombie process entry + + if not any_resolved: + logger.error("Aggressive DNS check failed: No internet.") + return Result(valid=False, error_type=ResultError.CONNECTION, message="No internet connection.") + + # 2. HTTP REQUEST PHASE (Using Daemon Threads) + transport = None if port: - transport = httpx_socks.AsyncProxyTransport( + transport = httpx_socks.ProxyTransport( proxy_type=ProxyType.SOCKS5, proxy_host="127.0.0.1", proxy_port=port, ) - client = httpx.AsyncClient(transport=transport, http2=True, timeout=6) - else: - client = httpx.AsyncClient(http2=True, timeout=6) - async def attempt_request(url: str, index: int): - logger.info(f"Attempt {index + 1}: Trying {url}") - # if connection_observer: - # connection_observer.notify("connecting", f"Testing IP. Attempt {index + 1}") - + # We use a Queue to get the first successful Result back from daemon threads + result_queue = Queue() + + def attempt_request(url: str, index: int, client: httpx.Client): try: - raw_response = await client.get(url) + raw_response = client.get(url) filtered_response = classify_response(raw_response) - if filtered_response.valid: - ip_address = extract_ip_from_response( - response=filtered_response.data, - api_url=url - ) - if ip_address is not None: - return Result(valid=True, data=ip_address) - - logger.error(f"Skipping {url}") - except Exception as e: - logger.error(f"Skipping {url}. Invalid API reply or failed connection {str(e)}") - - return Result(valid=False) + ip_address = extract_ip_from_response(response=filtered_response.data, api_url=url) + if ip_address: + result_queue.put(Result(valid=True, data=ip_address)) + return + except Exception: + pass - try: - # Create tasks for all endpoints to run them concurrently - tasks = [asyncio.create_task(attempt_request(url, i)) for i, url in enumerate(endpoint_list)] - - # Process tasks as they complete - for future in asyncio.as_completed(tasks): - result = await future - if result.valid: - # Cancel all other pending tasks immediately - for t in tasks: - if not t.done(): - t.cancel() - return result + with httpx.Client(transport=transport, http2=False, timeout=3.0) as client: + for i, url in enumerate(endpoint_list): + # daemon=True is CRITICAL. + # It tells Python: "If the main program exits, kill this thread immediately." + t = Thread(target=attempt_request, args=(url, i, client), daemon=True) + t.start() - finally: - await client.aclose() + # Wait for the first success or the remaining budget + while True: + elapsed = time.time() - start_time + remaining = total_budget - elapsed + if remaining <= 0: + break + + try: + # Block for the remaining budget + return result_queue.get(timeout=remaining) + except Empty: + break logger.error(f"We went through {len(endpoint_list)} attempts and could not get a reply.") return Result(valid=False, error_type=ResultError.CONNECTION, message="Could not connect.") + + + + + + + +# import time +# import socket +# from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED + +# def is_dns_problem(url: str) -> bool: +# """Regular DNS check for domains""" +# domain = extract_domain(url) +# try: +# socket.gethostbyname(domain) +# return False +# except (socket.gaierror, Exception): +# return True + +# def test(connection_observer: Optional[ConnectionObserver], port: Optional[int] = None) -> Result: +# endpoint_list = list(API_ENDPOINT_FORMATS.keys())[:MAX_ATTEMPTS] +# start_time = time.time() +# total_budget = 4.0 + +# # 1. AGGRESSIVE DNS PRE-FLIGHT +# if port is None: +# # IMPORTANT: Do NOT use 'with' here. 'with' calls shutdown(wait=True), +# # which causes the hang you are seeing. +# dns_executor = ThreadPoolExecutor(max_workers=len(endpoint_list)) +# dns_futures = {dns_executor.submit(is_dns_problem, url): url for url in endpoint_list} + +# dns_done, _ = wait(dns_futures, timeout=1.2, return_when=FIRST_COMPLETED) + +# any_resolved = False +# for future in dns_done: +# if future.result() is False: +# any_resolved = True +# break + +# if not any_resolved: +# logger.error("Aggressive DNS check failed: No internet or DNS unreachable.") +# # Return immediately. The dns_executor threads will stay alive +# # in the background until the OS times them out, but the +# # main thread is now free. +# return Result(valid=False, error_type=ResultError.CONNECTION, message="No internet connection.") + +# # 2. HTTP REQUEST PHASE +# transport = None +# if port: +# transport = httpx_socks.ProxyTransport( +# proxy_type=ProxyType.SOCKS5, +# proxy_host="127.0.0.1", +# proxy_port=port, +# ) + +# with httpx.Client(transport=transport, http2=False, timeout=3.0) as client: +# def attempt_request(url: str, index: int): +# try: +# raw_response = client.get(url) +# filtered_response = classify_response(raw_response) +# if filtered_response.valid: +# ip_address = extract_ip_from_response(response=filtered_response.data, api_url=url) +# if ip_address: +# return Result(valid=True, data=ip_address) +# except Exception: +# pass +# return Result(valid=False) + +# # IMPORTANT: Do NOT use 'with' here either. +# http_executor = ThreadPoolExecutor(max_workers=len(endpoint_list)) +# http_futures = {http_executor.submit(attempt_request, url, i): url for i, url in enumerate(endpoint_list)} + +# while http_futures: +# elapsed = time.time() - start_time +# remaining = total_budget - elapsed + +# if remaining <= 0: +# break + +# done, _ = wait(http_futures, timeout=remaining, return_when=FIRST_COMPLETED) + +# if not done: +# break + +# for future in done: +# result = future.result() +# if result.valid: +# return result +# http_futures.remove(future) + +# logger.error(f"We went through {len(endpoint_list)} attempts and could not get a reply.") +# return Result(valid=False, error_type=ResultError.CONNECTION, message="Could not connect.") + + + + + + + + + +# async + + + +# async def _test(connection_observer: Optional[ConnectionObserver], port: Optional[int] = None) -> Result: +# endpoint_list = list(API_ENDPOINT_FORMATS.keys())[:MAX_ATTEMPTS] + +# # Using 'async with' ensures the client and its connection pool are +# # closed before the event loop starts shutting down. +# async with httpx.AsyncClient( +# transport=httpx_socks.AsyncProxyTransport( +# proxy_type=ProxyType.SOCKS5, +# proxy_host="127.0.0.1", +# proxy_port=port, +# ) if port else None, +# http2=True, +# timeout=4 +# ) as client: + +# async def attempt_request(url: str, index: int): +# logger.info(f"Attempt {index + 1}: Trying {url}") +# try: +# raw_response = await client.get(url) +# filtered_response = classify_response(raw_response) + +# if filtered_response.valid: +# ip_address = extract_ip_from_response( +# response=filtered_response.data, +# api_url=url +# ) +# if ip_address is not None: +# return Result(valid=True, data=ip_address) + +# logger.error(f"Skipping {url}") +# except Exception as e: +# logger.error(f"Skipping {url}. Invalid API reply or failed connection {str(e)}") + +# return Result(valid=False) + +# # Create tasks for concurrent execution +# tasks = [asyncio.create_task(attempt_request(url, i)) for i, url in enumerate(endpoint_list)] + +# try: +# for future in asyncio.as_completed(tasks): +# result = await future +# if result.valid: +# # 1. Signal all other pending tasks to stop +# for t in tasks: +# if not t.done(): +# t.cancel() + +# # 2. CRITICAL: Wait for the cancelled tasks to actually finish. +# # return_exceptions=True prevents CancelledError from being raised here. +# await asyncio.gather(*tasks, return_exceptions=True) +# return result +# except Exception as e: +# logger.error(f"Unexpected error during request processing: {e}") + +# # This part is reached only if no task returned a valid Result +# logger.error(f"We went through {len(endpoint_list)} attempts and could not get a reply.") +# return Result(valid=False, error_type=ResultError.CONNECTION, message="Could not connect.") + + +# def test(connection_observer: Optional[ConnectionObserver], port: Optional[int] = None) -> Result: +# if connection_observer: +# connection_observer.notify("connecting", "Testing IP..") +# return asyncio.run(_test(connection_observer, port)) + + +# async def _test(connection_observer: Optional[ConnectionObserver], port: Optional[int] = None) -> Result: +# endpoint_list = list(API_ENDPOINT_FORMATS.keys())[:MAX_ATTEMPTS] + +# if port: +# transport = httpx_socks.AsyncProxyTransport( +# proxy_type=ProxyType.SOCKS5, +# proxy_host="127.0.0.1", +# proxy_port=port, +# ) +# client = httpx.AsyncClient(transport=transport, http2=True, timeout=4) +# else: +# client = httpx.AsyncClient(http2=True, timeout=4) + +# async def attempt_request(url: str, index: int): +# logger.info(f"Attempt {index + 1}: Trying {url}") + +# try: +# raw_response = await client.get(url) +# filtered_response = classify_response(raw_response) + +# if filtered_response.valid: +# ip_address = extract_ip_from_response( +# response=filtered_response.data, +# api_url=url +# ) +# if ip_address is not None: +# return Result(valid=True, data=ip_address) + +# logger.error(f"Skipping {url}") +# except Exception as e: +# logger.error(f"Skipping {url}. Invalid API reply or failed connection {str(e)}") + +# return Result(valid=False) + +# try: +# # Create tasks for all endpoints to run them concurrently +# tasks = [asyncio.create_task(attempt_request(url, i)) for i, url in enumerate(endpoint_list)] + +# # Process tasks as they complete +# for future in asyncio.as_completed(tasks): +# result = await future +# if result.valid: +# # Cancel all other pending tasks immediately +# for t in tasks: +# if not t.done(): +# t.cancel() +# return result + +# finally: +# await client.aclose() + +# logger.error(f"We went through {len(endpoint_list)} attempts and could not get a reply.") +# return Result(valid=False, error_type=ResultError.CONNECTION, message="Could not connect.") + + def extract_ip_from_response(response: dict, api_url: str) -> str | bool: """ Purpose: @@ -124,6 +400,7 @@ def extract_ip_from_response(response: dict, api_url: str) -> str | bool: def ping_ip(ip: str) -> Result: command = ["ping", "-c", "1", "-W", "2", ip] human_readable_goal = f"Pinging {ip}" + Notifications.emit(EventType.STATUS_UPDATE, human_readable_goal) return run_generic_command(command, human_readable_goal, timeout=5) diff --git a/core/services/networking/general_connection_tools/location_evaluation.py b/core/services/networking/general_connection_tools/location_evaluation.py index 5869fb0..6d2c824 100644 --- a/core/services/networking/general_connection_tools/location_evaluation.py +++ b/core/services/networking/general_connection_tools/location_evaluation.py @@ -1,6 +1,6 @@ from core.services.networking.api_requests.ApiResponseModel import BackoffStrategy from core.services.networking.general_connection_tools.test_internet import test_internet -from core.services.networking.general_connection_tools.ip import ping_ip_worked +from core.services.networking.general_connection_tools.ip import ping_ip from core.services.networking.systemwide.general_tools.test_any_protocol import get_singbox_ip from core.services.networking.general_connection_tools.config_tools import extract_endpoint_ip from core.models.manage.server_reply_tools import api_call_and_extraction, ApiCall @@ -47,7 +47,8 @@ def check_if_location_is_up(profile: Profile, connection_observer: ConnectionObs ip_address = get_profile_ip(profile_id=profile_id, protocol=protocol, profile_type=profile_type) if ip_address: - can_be_pinged = ping_ip_worked(ip_address) + ping_result = ping_ip(ip_address) + can_be_pinged = ping_result.valid print(f"Can {location_name} be pinged? {can_be_pinged}") else: logger.error("Skipping IP Address Test.") diff --git a/core/services/networking/general_connection_tools/testing_evaluating.py b/core/services/networking/general_connection_tools/testing_evaluating.py index db3ecd3..832ba51 100644 --- a/core/services/networking/general_connection_tools/testing_evaluating.py +++ b/core/services/networking/general_connection_tools/testing_evaluating.py @@ -1,3 +1,5 @@ +from core.utils.run_commands import run_generic_command + from core.Constants import Constants from core.Errors import CommandNotFoundError from core.observers.ConnectionObserver import ConnectionObserver @@ -37,15 +39,33 @@ def await_network_interface(): raise ConnectionError('The network interface could not be activated.') + def system_uses_wireguard_interface(): if shutil.which('ip') is None: raise CommandNotFoundError('ip') - process = subprocess.Popen(('ip', 'route', 'get', '192.0.2.1'), stdout=subprocess.PIPE) - process_output = str(process.stdout.read()) + command = ['ip', 'route', 'get', '192.0.2.1'] + human_readable_goal = "Getting WG interface" + get_output = run_generic_command(command, human_readable_goal, timeout=8) + if not get_output.valid: + return get_output - return bool(re.search('dev wg', str(process_output))) + output = get_output.data + print(f"output is {output}") + return bool(re.search('dev wg', str(output))) + + + +# def system_uses_wireguard_interface(): + +# if shutil.which('ip') is None: +# raise CommandNotFoundError('ip') + +# process = subprocess.Popen(('ip', 'route', 'get', '192.0.2.1'), stdout=subprocess.PIPE) +# process_output = str(process.stdout.read()) + +# return bool(re.search('dev wg', str(process_output))) def __test_connection(port_number: Optional[int] = None, timeout: float = 4.0): diff --git a/core/services/networking/mtu/mtu_orchestrator.py b/core/services/networking/mtu/mtu_orchestrator.py new file mode 100644 index 0000000..835456d --- /dev/null +++ b/core/services/networking/mtu/mtu_orchestrator.py @@ -0,0 +1,66 @@ +from core.services.keys_and_verifications.wireguard_keys import get_mtu, insert_mtu_into_config_on_disk +from core.services.networking.mtu.mtu_tools import loop_through_testing_mtu, mtu_is_fine, MIN_MTU, MAX_MTU +from core.observers.Notifications import Notifications, EventType +from core.models.BaseProfile import BaseProfile as Profile +from core.models.system.SystemProfile import SystemProfile +from core.errors.logger import logger + +import asyncio +from typing import Optional + +def _changed_mtu(profile: SystemProfile, current_mtu: Optional[int]) -> bool: + notification = "Testing MTU.." + Notifications.emit(EventType.STATUS_UPDATE, notification) + + # optimal_mtu = loop_through_testing_mtu() + optimal_mtu = asyncio.run(loop_through_testing_mtu()) + logger.info(f"Optimal MTU is {optimal_mtu}") + + if optimal_mtu == current_mtu: + return False + + # Assuming it isn't already at the max, there's no need to reset it to the max, + if current_mtu != MAX_MTU and optimal_mtu == MAX_MTU: + return False + + notification = f"Updating WG MTU with {optimal_mtu}" + Notifications.emit(EventType.STATUS_UPDATE, notification) + return insert_mtu_into_config_on_disk(profile=profile, mtu=optimal_mtu) + +def should_we_even_test_mtu(profile_id: int): + # Evaluate MTU WITH connection on + current_mtu = get_mtu(profile_id=profile_id) + + if not current_mtu: + return True, current_mtu + + if current_mtu > MIN_MTU: + logger.info("Skipping MTU testing, because MTU as at the minimum, so it's not relevant here.") + return False, current_mtu + else: + logger.info("MTU isn't at the min, so we can potentially improve") + return True, current_mtu + +def was_mtu_changed(profile: Profile, current_mtu: int): + notification = "Testing MTU..." + Notifications.emit(EventType.STATUS_UPDATE, notification) + logger.info(notification) + + if mtu_is_fine(current_mtu): + notification = "MTU is NOT the issue." + Notifications.emit(EventType.STATUS_UPDATE, notification) + logger.info(notification) + + # MTU wasn't changed or wasn't applicable, + return False + + if _changed_mtu(profile=profile, current_mtu=current_mtu): + logger.info("MTU changed. We should retry the connection") + return True + + else: + logger.warning("MTU check performed but no change was made or user rejected it.") + notification = "MTU was NOT changed." + Notifications.emit(EventType.STATUS_UPDATE, notification) + return False + diff --git a/core/services/networking/systemwide/wireguard/mtu_tools.py b/core/services/networking/mtu/mtu_tools.py similarity index 100% rename from core/services/networking/systemwide/wireguard/mtu_tools.py rename to core/services/networking/mtu/mtu_tools.py diff --git a/core/services/networking/systemwide/systemwide_wireguard.py b/core/services/networking/systemwide/systemwide_wireguard.py index ef3df9e..7ac9394 100644 --- a/core/services/networking/systemwide/systemwide_wireguard.py +++ b/core/services/networking/systemwide/systemwide_wireguard.py @@ -1,5 +1,7 @@ -from core.services.keys_and_verifications.wireguard_keys import get_mtu, insert_mtu_into_config_on_disk, offer_port_change, should_we_try_to_change_port, port_was_changed_already_today -from core.services.networking.systemwide.wireguard.mtu_tools import loop_through_testing_mtu, mtu_is_fine, MIN_MTU, MAX_MTU +from core.services.keys_and_verifications.wireguard_keys import register_wireguard_session +from core.services.keys_and_verifications.wireguard_keys import offer_port_change, should_we_try_to_change_port #, port_was_changed_already_today +from core.services.networking.mtu.mtu_orchestrator import should_we_even_test_mtu, was_mtu_changed + from core.services.networking.general_connection_tools.location_evaluation import check_if_location_is_up from core.services.networking.api_requests.ApiResponseModel import BackoffStrategy from core.models.enum_models import ProtocolChoice, SINGBOX_PROTOCOLS @@ -31,19 +33,20 @@ from core.controllers.SystemStateController import SystemStateController from core.models.system.SystemProfile import SystemProfile from core.models.system.SystemState import SystemState from core.observers.ConnectionObserver import ConnectionObserver -from subprocess import CalledProcessError +# from subprocess import CalledProcessError from typing import Optional # import os # import re import shutil # import subprocess import time -import asyncio +# import asyncio +from functools import partial def terminate_system_connection( firewall_setting: Optional[bool] = None, dns_setting: Optional[bool] = None -): +) -> Result: # ====== PREREQS ======= if firewall_setting is None: @@ -63,42 +66,43 @@ def terminate_system_connection( if dns_setting: _ = revert_dns() - # ====== NMCLI ======= + # ====== SINGBOX ======= + # Check if the user is switching from Singbox to Wireguard: if SystemStateController.exists(): - - # ====== SINGBOX ======= - # Check if the user is switching from Singbox to Wireguard: current_state = SystemState.get() - if get_protocol_enum(current_state.profile_id) in SINGBOX_PROTOCOLS: - logger.info("Ending Singbox protocols first..") - from core.services.networking.systemwide.encrypted_proxy.singbox_runner import end_singbox - return end_singbox() + if current_state: + if get_protocol_enum(current_state.profile_id) in SINGBOX_PROTOCOLS: + logger.info("Ending Singbox protocols first..") + from core.services.networking.systemwide.encrypted_proxy.singbox_runner import end_singbox + return end_singbox() - # ====== FINISH WIREGUARD ======= - command = ['nmcli', 'connection', 'delete', 'wg'] - human_readable_goal = "Deleting NMCLI wg interface" - delete_completed = run_generic_command(command, human_readable_goal, timeout=8) + # ====== FINISH WIREGUARD ======= + command = ['nmcli', 'connection', 'delete', 'wg'] + human_readable_goal = "Deleting NMCLI wg interface" + delete_completed = run_generic_command(command, human_readable_goal, timeout=8) + print(f"delete_completed output: {delete_completed}") - if delete_completed.valid or not system_uses_wireguard_interface(): - command = ['nmcli', 'connection', 'delete', 'hv-ipv6-sink'] - human_readable_goal = "Deleting NMCLI IPv6 Sink" - sinkhole_deleted = run_generic_command(command, human_readable_goal, timeout=8) - if not sinkhole_deleted.valid: - logger.error(f"IPv6 Sinkhole Delete Error: {sinkhole_deleted.error_type} {sinkhole_deleted.message}") + if not system_uses_wireguard_interface(): + command = ['nmcli', 'connection', 'delete', 'hv-ipv6-sink'] + human_readable_goal = "Deleting NMCLI IPv6 Sink" + sinkhole_deleted = run_generic_command(command, human_readable_goal, timeout=8) + if not sinkhole_deleted.valid: + logger.error(f"IPv6 Sinkhole Delete Error: {sinkhole_deleted.error_type} {sinkhole_deleted.message}") - terminate_tor_connection() + terminate_tor_connection() - # confirm firewall is off. - if firewall_setting: - check_and_kill_firewall() # on failure, this raises errors, which then bubble up + # confirm firewall is off. + if firewall_setting: + check_and_kill_firewall() # on failure, this raises errors, which then bubble up + # else: + # error_log = f"Error with deleting the connection: {delete_completed.error_type} {delete_completed.message}" + # logger.error(error_log) + # raise ConnectionTerminationError(f'The connection could not be terminated. {error_log}') - # finally end that profile's JSON state: - SystemState.dissolve() - - else: - error_log = f"Error with deleting the connection: {delete_completed.error_type} {delete_completed.message}" - logger.error(error_log) - raise ConnectionTerminationError(f'The connection could not be terminated. {error_log}') + # finally end that profile's JSON state: + if SystemStateController.exists(): + SystemState.dissolve() + return Result(valid=True) @@ -115,45 +119,17 @@ def evaluate_location_and_internet(profile: SystemProfile, connection_observer: return location_result -def _cleanup_on_error(firewall_setting: bool, dns_setting: bool) -> None: - """Safely terminate connection on error, swallowing cleanup errors.""" - try: - terminate_system_connection(firewall_setting, dns_setting) - except ConnectionTerminationError: - pass +# def _cleanup_on_error(firewall_setting: bool, dns_setting: bool) -> None: +# """Safely terminate connection on error, swallowing cleanup errors.""" +# terminate_system_connection(firewall_setting, dns_setting) def _finish_success(final_result: Result, profile_id: int) -> Result: - Notifications.emit(EventType.SYSTEM_ENABLE, profile_id) - notification = "Connected." - Notifications.emit(EventType.STATUS_UPDATE, notification) - terminate_tor_connection() time.sleep(0.3) return final_result -def changed_mtu(profile: SystemProfile, current_mtu: Optional[int]) -> bool: - notification = "Testing MTU.." - Notifications.emit(EventType.STATUS_UPDATE, notification) - - # optimal_mtu = loop_through_testing_mtu() - optimal_mtu = asyncio.run(loop_through_testing_mtu()) - logger.info(f"Optimal MTU is {optimal_mtu}") - - if optimal_mtu == current_mtu: - return False - - # Assuming it isn't already at the max, there's no need to reset it to the max, - if current_mtu != MAX_MTU and optimal_mtu == MAX_MTU: - return False - - notification = f"Updating WG MTU with {optimal_mtu}" - Notifications.emit(EventType.STATUS_UPDATE, notification) - return insert_mtu_into_config_on_disk(profile=profile, mtu=optimal_mtu) - - - def __establish_system_connection( profile: SystemProfile, firewall_setting: bool = False, @@ -164,8 +140,6 @@ def __establish_system_connection( Orchestrates the full flow of the connection. Steps: - 1) Check Prereqs - 2) Disable any pre-existing system connections 3) Turn on WG via nmcli 4) Turn on IPv6 sinkhole 5) Setup State (JSON file) @@ -173,17 +147,10 @@ def __establish_system_connection( 7) Set DNS (if enabled), and test it. Let's it slide even if failed. 8) Testing, confirming, displaying to the end user. """ - check_system_prereqs() - notification = "Terming existing connections.." - Notifications.emit(EventType.STATUS_UPDATE, notification) - logger.info(notification) - terminate_system_connection(firewall_setting, dns_setting) - # ============= INITIAL NMCLI CONNECTION ============= notification = "Setting Up WireGuard.." Notifications.emit(EventType.STATUS_UPDATE, notification) - # if connection_observer: - # connection_observer.notify("connecting", notification) + wg_config_path = profile.get_wireguard_configuration_path() nmcli_result = setup_nmcli_connection(wg_config_path) @@ -204,17 +171,6 @@ def __establish_system_connection( if not sinkhole_result: raise ConnectionError('IPv6 Could not be protected.') - # ============= SETUP STATE ============= - # Even if firewall is off, we want to save the fact we turned on the VPN tunnel, before we raise errors. - - logger.info("Setting State JSON..") - current_state = SystemStateController.create( - profile_id=profile.id, - firewalled=firewall_setting, # intended setting, not result yet - dns_set=dns_setting, # intended setting, not result yet, - protocol=ProtocolChoice.WIREGUARD - ) - # ============= PREP SETTINGS ============= if connection_observer: connection_observer.notify("connecting", "Getting Interface Name..") @@ -273,18 +229,28 @@ def __establish_system_connection( if not test_result.valid: notification = "Test Failed." Notifications.emit(EventType.STATUS_UPDATE, notification) + logger.error("Returning that the test failed..") return test_result # ============= UPDATE STATE ============= - if dns_setting and not did_dns_work: - current_state.firewalled = firewall_tracker - current_state.dns_set = did_dns_work - SystemStateController.update_or_create(current_state) - notification = "DNS Set Did Not Work." - Notifications.emit(EventType.STATUS_UPDATE, notification) + # if dns_setting and not did_dns_work: + # current_state.firewalled = firewall_tracker + # current_state.dns_set = did_dns_work + # SystemStateController.update_or_create(current_state) + # notification = "DNS Set Did Not Work." + # Notifications.emit(EventType.STATUS_UPDATE, notification) + # ============= SETUP STATE ============= + logger.info("Setting State JSON..") + current_state = SystemStateController.create( + profile_id=profile.id, + firewalled=firewall_setting, + dns_set=did_dns_work, + protocol=ProtocolChoice.WIREGUARD + ) return Result(valid=True, data=current_state) +import time def establish_system_connection( profile: SystemProfile, @@ -296,11 +262,14 @@ def establish_system_connection( Establish connection with a maximum of 2 retries. Steps) - 1) Verify Endpoint's encryption - 2) get settings - 3) Attempt 1 -> (Fail) -> Location Check - 4) Attempt 2 -> (Fail) -> MTU Check -> Attempt 3 + 1) Check Prereqs + 2) Verify Endpoint's encryption + 3) get settings + 4) Disable any pre-existing system connections """ + # ================= PRE-REQS ================= + check_system_prereqs() + # ================= VERIFY ENDPOINT ================= if connection_observer: connection_observer.notify("connecting", "Verifying endpoint..") @@ -314,109 +283,152 @@ def establish_system_connection( # ================= CONNECT WG ================ # We allow 3 total attempts (Initial + 2 retries) for attempt in range(1, 4): - try: - # if connection_observer: - # connection_observer.notify("connecting", f"Connecting attempt {attempt} of 3.. ") + # ================================= + # PRE-EXISTING + # ================================= + logger.info("Terminating pre-existing") + terminated = terminate_system_connection(firewall_setting, dns_setting) + if terminated.valid: + logger.info("Terminated pre-existing") + else: + logger.error(f"Failed to terminate pre-existing {terminated.message}") + return terminated - notification = f"Connecting attempt {attempt} of 3.. " - Notifications.emit(EventType.STATUS_UPDATE, notification) + # func_choice = partial(register_wireguard_session, profile=profile, connection_observer=connection_observer) + # return Result(valid=False, function_choice=func_choice, error_type=ResultError.RENEGOTIATION, message="Would you like to try Wireguard key renegotiation? (Note: this invalidates other profiles with the same subscription.)") - # Attempt the actual system connection - connection_result = __establish_system_connection( - profile=profile, - firewall_setting=firewall_setting, - dns_setting=dns_setting, + print("sleeping") + time.sleep(5) + + notification = f"Connecting attempt {attempt}.. " + Notifications.emit(EventType.STATUS_UPDATE, notification) + + # ================================= + # CONNECT + # ================================= + # Attempt the actual system connection + connection_result = __establish_system_connection( + profile=profile, + firewall_setting=firewall_setting, + dns_setting=dns_setting, + connection_observer=connection_observer + ) + + print("sleeping") + time.sleep(5) + + # ================================= + # EVALUATE + # ================================= + if connection_result.valid: + return _finish_success(final_result=connection_result, profile_id=profile.id) + + # ================================= + # ATTEMPT ONE: LOCATION UP? + # ================================= + if attempt == 1: + # First failure: Evaluate Location and Internet without connection, + logger.info("Terminating system connection to check location") + terminate_system_connection(firewall_setting, dns_setting) + + location_result = evaluate_location_and_internet( + profile=profile, connection_observer=connection_observer ) - if connection_result.valid: - return _finish_success(final_result=connection_result, profile_id=profile.id) - - # If not valid, we treat it as a failure to trigger the except block logic - notification = f"{attempt} attempt failed.." + #============== NO RETRY #============== + if location_result.backoff_strategy == BackoffStrategy.NO_RETRY: + logger.error(f"Location check failed: {location_result.message}") + bottom_left = "Node Down" + Notifications.emit(EventType.STATUS_UPDATE, bottom_left) + notification = location_result.message + Notifications.emit(EventType.SHOW_POPUP, notification) + return location_result + + #============== NO SEEMS FINE #============== + notification = "Location check passed. Retrying immediately..." Notifications.emit(EventType.STATUS_UPDATE, notification) - raise ConnectionError(notification) + logger.info(notification) + continue # Proceed to attempt 2 - except (ConnectionError, CalledProcessError) as e: + # ================================= + # ATTEMPT 2+ MTU, PORT, WG KEY + # ================================= - # single time port is offered forever. - if port_was_changed_already_today(): - # permanent config modify: - ConfigurationController.change_port_prompts(False) + # Connection is still On for MTU testing: - if attempt == 1: - # First failure: Evaluate Location and Internet without connection, - _cleanup_on_error(firewall_setting, dns_setting) - - location_result = evaluate_location_and_internet( - profile=profile, - connection_observer=connection_observer - ) - - if location_result.backoff_strategy == BackoffStrategy.NO_RETRY: - logger.error(f"Location check failed: {location_result.message}") - bottom_left = "Node Down" - Notifications.emit(EventType.STATUS_UPDATE, bottom_left) - notification = location_result.message - Notifications.emit(EventType.SHOW_POPUP, notification) - return location_result - # raise ConnectionError(location_result.message) from e - - notification = "Location check passed. Retrying immediately..." - Notifications.emit(EventType.STATUS_UPDATE, notification) - logger.info(notification) - continue # Proceed to attempt 2 - - elif attempt == 2: - # Second failure: Evaluate MTU WITH connection on - current_mtu = get_mtu(profile_id=profile.id) - - if current_mtu != MAX_MTU or not current_mtu: - logger.info("MTU is already at the max, no point to change it.") - _cleanup_on_error(firewall_setting, dns_setting) - raise ConnectionError("Failed to connect.") - # return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.") - - if not current_mtu or current_mtu > MIN_MTU: - notification = "Testing MTU..." - Notifications.emit(EventType.STATUS_UPDATE, notification) - logger.info(notification) - if mtu_is_fine(current_mtu): - notification = "MTU is NOT the issue." - Notifications.emit(EventType.STATUS_UPDATE, notification) - logger.info(notification) - # If MTU wasn't changed or wasn't applicable, don't bother with attempt 3 - _cleanup_on_error(firewall_setting, dns_setting) - raise ConnectionError("Failed to connect.") - # return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.") - - if changed_mtu(profile=profile, current_mtu=current_mtu): - logger.info("MTU changed. Retrying connection...") - continue # Proceed to attempt 3 - else: - logger.warning("MTU check performed but no change was made or user rejected it.") - notification = "MTU was NOT changed." - Notifications.emit(EventType.STATUS_UPDATE, notification) - else: - logger.info("Skipping MTU testing, it's not relevant here.") - - # If Port & MTU wasn't changed or wasn't applicable, don't bother with attempt 3 - # PORT CHANGE: - if should_we_try_to_change_port(): - print("We should try to change port") - _cleanup_on_error(firewall_setting, dns_setting) - return offer_port_change(profile) - else: - notification = "Failed to connect." - Notifications.emit(EventType.STATUS_UPDATE, notification) - _cleanup_on_error(firewall_setting, dns_setting) - raise ConnectionError("Failed to connect.") + # ================================= + # MTU + # ================================= + should_test_mtu, current_mtu = should_we_even_test_mtu(profile.id) + if should_test_mtu: + logger.info("We should test (and potentially change) the MTU..") + if was_mtu_changed(profile=profile, current_mtu=current_mtu): + logger.info("MTU was changed, so we're trying again.") + continue else: - # Attempt 3 failed - logger.error("Maximum retries reached. Connection failed.") - notification = "Failed to connect." - Notifications.emit(EventType.STATUS_UPDATE, notification) - _cleanup_on_error(firewall_setting, dns_setting) - raise ConnectionError(notification) - # return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.") + logger.info("MTU was tested but NOT changed.") + else: + logger.info("MTU was NOT tested.") + + # Take down connection for the rest of the tests: + terminated = terminate_system_connection(firewall_setting, dns_setting) + if terminated.valid: + logger.info("Took down the system connection for the other tests.") + else: + return terminated + + # ================================= + # PORT + # ================================= + if should_we_try_to_change_port(): + logger.info("We should try to change port") + + # Offer the choice to the end user: + return offer_port_change(profile) + else: + logger.info("Skipping port choice as an offer.") + + # ================================= + # WIREGUARD RENEGOTIATION + # ================================= + terminate_system_connection(firewall_setting, dns_setting) + func_choice = partial(register_wireguard_session, profile=profile, connection_observer=connection_observer) + return Result(valid=False, function_choice=func_choice, error_type=ResultError.RENEGOTIATION, message="Would you like to try Wireguard key renegotiation? (Note: this invalidates other profiles with the same subscription.)") + + + + + + + + + + +# except (ConnectionError, CalledProcessError) as e: + +# # single time port is offered forever. +# if port_was_changed_already_today(): +# # permanent config modify: +# ConfigurationController.change_port_prompts(False) + +# else: +# notification = "Failed to connect." +# Notifications.emit(EventType.STATUS_UPDATE, notification) +# _cleanup_on_error(firewall_setting, dns_setting) +# raise ConnectionError("Failed to connect.") + +# else: +# # Attempt 3 failed +# logger.error("Maximum retries reached. Connection failed.") +# notification = "Failed to connect." +# Notifications.emit(EventType.STATUS_UPDATE, notification) +# _cleanup_on_error(firewall_setting, dns_setting) +# raise ConnectionError(notification) +# # return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.") + +# First take +# _cleanup_on_error(firewall_setting, dns_setting) +# _cleanup_on_error(firewall_setting, dns_setting) +# # return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.") diff --git a/core/services/profiles/profile_connections.py b/core/services/profiles/profile_connections.py index 63715dd..d14cbea 100644 --- a/core/services/profiles/profile_connections.py +++ b/core/services/profiles/profile_connections.py @@ -156,18 +156,22 @@ def enable_profile( connection_result = establish_connection(profile, ignore=ignore, connection_observer=connection_observer) if not connection_result: logger.error("Connection result is empty.") - notification = "Connection Failed" - Notifications.emit(EventType.STATUS_UPDATE, notification) return - if not connection_result.valid: + logger.info(f"Exiting establish connection with {connection_result}") + + if connection_result.valid: + # if profile_observer is not None: + # profile_observer.notify('enabled', profile) + + Notifications.emit(EventType.SYSTEM_ENABLE, profile.id) + notification = "Connected." + Notifications.emit(EventType.STATUS_UPDATE, notification) + return Result(valid=True, data=profile.type) + + else: return connection_result - if profile_observer is not None: - profile_observer.notify('enabled', profile) - notification = "Enabled." - Notifications.emit(EventType.STATUS_UPDATE, notification) - return Result(valid=True, data=profile.type) except FirewallError: return Result(valid=False, error_type=ResultError.FIREWALL, message="Firewall Error.") except ConnectionError as e: