systemwide wg goes connect -> test -> location check -> mtu check -> port check -> wg renegotiate

This commit is contained in:
SimplifiedPrivacy 2026-10-25 14:25:09 -04:00
parent 25d69d728c
commit 14e257d091
9 changed files with 643 additions and 256 deletions

View file

@ -57,6 +57,7 @@ class ResultError(Enum):
NODE_ERROR = "node_error" NODE_ERROR = "node_error"
NO_INTERNET = "no_internet" NO_INTERNET = "no_internet"
PORT = "port" PORT = "port"
RENEGOTIATION = "renegotiation"
NAVIGATE_TO = "navigate_to" NAVIGATE_TO = "navigate_to"
UNKNOWN = "unknown" UNKNOWN = "unknown"

View file

@ -62,12 +62,18 @@ def establish_connection(
if profile.connection.code == "wireguard": if profile.connection.code == "wireguard":
_ensure_wireguard_configured(profile, connection_observer) _ensure_wireguard_configured(profile, connection_observer)
establish_fn = {
ProfileType.SESSION: ConnectionController.establish_session_connection, if profile.connection.code == "wireguard" and profile.type == ProfileType.SYSTEM:
ProfileType.SYSTEM: establish_system_connection, logger.info("Doing a systemwide WG connection..")
}[profile.type] return establish_system_connection(profile, ignore, connection_observer, ticket_observer)
return _establish_with_renegotiation(profile, establish_fn, 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( def _ensure_proxy_configured(

View file

@ -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.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 # models
from core.models.Result import Result, ResultError 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.Constants import Constants
from core.errors.logger import logger 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 # generic
import time import time
@ -31,69 +37,339 @@ API_ENDPOINT_FORMATS = {
MAX_ATTEMPTS = 3 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: 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] 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: if port:
transport = httpx_socks.AsyncProxyTransport( transport = httpx_socks.ProxyTransport(
proxy_type=ProxyType.SOCKS5, proxy_type=ProxyType.SOCKS5,
proxy_host="127.0.0.1", proxy_host="127.0.0.1",
proxy_port=port, 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): # We use a Queue to get the first successful Result back from daemon threads
logger.info(f"Attempt {index + 1}: Trying {url}") result_queue = Queue()
# if connection_observer:
# connection_observer.notify("connecting", f"Testing IP. Attempt {index + 1}") def attempt_request(url: str, index: int, client: httpx.Client):
try: try:
raw_response = await client.get(url) raw_response = client.get(url)
filtered_response = classify_response(raw_response) filtered_response = classify_response(raw_response)
if filtered_response.valid: if filtered_response.valid:
ip_address = extract_ip_from_response( ip_address = extract_ip_from_response(response=filtered_response.data, api_url=url)
response=filtered_response.data, if ip_address:
api_url=url result_queue.put(Result(valid=True, data=ip_address))
) return
if ip_address is not None: except Exception:
return Result(valid=True, data=ip_address) pass
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: with httpx.Client(transport=transport, http2=False, timeout=3.0) as client:
# Create tasks for all endpoints to run them concurrently for i, url in enumerate(endpoint_list):
tasks = [asyncio.create_task(attempt_request(url, i)) for i, url in enumerate(endpoint_list)] # daemon=True is CRITICAL.
# It tells Python: "If the main program exits, kill this thread immediately."
# Process tasks as they complete t = Thread(target=attempt_request, args=(url, i, client), daemon=True)
for future in asyncio.as_completed(tasks): t.start()
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: # Wait for the first success or the remaining budget
await client.aclose() 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.") 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.") 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: def extract_ip_from_response(response: dict, api_url: str) -> str | bool:
""" """
Purpose: Purpose:
@ -124,6 +400,7 @@ def extract_ip_from_response(response: dict, api_url: str) -> str | bool:
def ping_ip(ip: str) -> Result: def ping_ip(ip: str) -> Result:
command = ["ping", "-c", "1", "-W", "2", ip] command = ["ping", "-c", "1", "-W", "2", ip]
human_readable_goal = f"Pinging {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) return run_generic_command(command, human_readable_goal, timeout=5)

View file

@ -1,6 +1,6 @@
from core.services.networking.api_requests.ApiResponseModel import BackoffStrategy 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.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.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.services.networking.general_connection_tools.config_tools import extract_endpoint_ip
from core.models.manage.server_reply_tools import api_call_and_extraction, ApiCall 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) ip_address = get_profile_ip(profile_id=profile_id, protocol=protocol, profile_type=profile_type)
if ip_address: 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}") print(f"Can {location_name} be pinged? {can_be_pinged}")
else: else:
logger.error("Skipping IP Address Test.") logger.error("Skipping IP Address Test.")

View file

@ -1,3 +1,5 @@
from core.utils.run_commands import run_generic_command
from core.Constants import Constants from core.Constants import Constants
from core.Errors import CommandNotFoundError from core.Errors import CommandNotFoundError
from core.observers.ConnectionObserver import ConnectionObserver from core.observers.ConnectionObserver import ConnectionObserver
@ -37,15 +39,33 @@ def await_network_interface():
raise ConnectionError('The network interface could not be activated.') raise ConnectionError('The network interface could not be activated.')
def system_uses_wireguard_interface(): def system_uses_wireguard_interface():
if shutil.which('ip') is None: if shutil.which('ip') is None:
raise CommandNotFoundError('ip') raise CommandNotFoundError('ip')
process = subprocess.Popen(('ip', 'route', 'get', '192.0.2.1'), stdout=subprocess.PIPE) command = ['ip', 'route', 'get', '192.0.2.1']
process_output = str(process.stdout.read()) 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): def __test_connection(port_number: Optional[int] = None, timeout: float = 4.0):

View file

@ -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

View file

@ -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.keys_and_verifications.wireguard_keys import register_wireguard_session
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 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.general_connection_tools.location_evaluation import check_if_location_is_up
from core.services.networking.api_requests.ApiResponseModel import BackoffStrategy from core.services.networking.api_requests.ApiResponseModel import BackoffStrategy
from core.models.enum_models import ProtocolChoice, SINGBOX_PROTOCOLS 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.SystemProfile import SystemProfile
from core.models.system.SystemState import SystemState from core.models.system.SystemState import SystemState
from core.observers.ConnectionObserver import ConnectionObserver from core.observers.ConnectionObserver import ConnectionObserver
from subprocess import CalledProcessError # from subprocess import CalledProcessError
from typing import Optional from typing import Optional
# import os # import os
# import re # import re
import shutil import shutil
# import subprocess # import subprocess
import time import time
import asyncio # import asyncio
from functools import partial
def terminate_system_connection( def terminate_system_connection(
firewall_setting: Optional[bool] = None, firewall_setting: Optional[bool] = None,
dns_setting: Optional[bool] = None dns_setting: Optional[bool] = None
): ) -> Result:
# ====== PREREQS ======= # ====== PREREQS =======
if firewall_setting is None: if firewall_setting is None:
@ -63,42 +66,43 @@ def terminate_system_connection(
if dns_setting: if dns_setting:
_ = revert_dns() _ = revert_dns()
# ====== NMCLI ======= # ====== SINGBOX =======
# Check if the user is switching from Singbox to Wireguard:
if SystemStateController.exists(): if SystemStateController.exists():
# ====== SINGBOX =======
# Check if the user is switching from Singbox to Wireguard:
current_state = SystemState.get() current_state = SystemState.get()
if get_protocol_enum(current_state.profile_id) in SINGBOX_PROTOCOLS: if current_state:
logger.info("Ending Singbox protocols first..") if get_protocol_enum(current_state.profile_id) in SINGBOX_PROTOCOLS:
from core.services.networking.systemwide.encrypted_proxy.singbox_runner import end_singbox logger.info("Ending Singbox protocols first..")
return end_singbox() from core.services.networking.systemwide.encrypted_proxy.singbox_runner import end_singbox
return end_singbox()
# ====== FINISH WIREGUARD ======= # ====== FINISH WIREGUARD =======
command = ['nmcli', 'connection', 'delete', 'wg'] command = ['nmcli', 'connection', 'delete', 'wg']
human_readable_goal = "Deleting NMCLI wg interface" human_readable_goal = "Deleting NMCLI wg interface"
delete_completed = run_generic_command(command, human_readable_goal, timeout=8) 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(): if not system_uses_wireguard_interface():
command = ['nmcli', 'connection', 'delete', 'hv-ipv6-sink'] command = ['nmcli', 'connection', 'delete', 'hv-ipv6-sink']
human_readable_goal = "Deleting NMCLI IPv6 Sink" human_readable_goal = "Deleting NMCLI IPv6 Sink"
sinkhole_deleted = run_generic_command(command, human_readable_goal, timeout=8) sinkhole_deleted = run_generic_command(command, human_readable_goal, timeout=8)
if not sinkhole_deleted.valid: if not sinkhole_deleted.valid:
logger.error(f"IPv6 Sinkhole Delete Error: {sinkhole_deleted.error_type} {sinkhole_deleted.message}") logger.error(f"IPv6 Sinkhole Delete Error: {sinkhole_deleted.error_type} {sinkhole_deleted.message}")
terminate_tor_connection() terminate_tor_connection()
# confirm firewall is off. # confirm firewall is off.
if firewall_setting: if firewall_setting:
check_and_kill_firewall() # on failure, this raises errors, which then bubble up 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: # finally end that profile's JSON state:
SystemState.dissolve() if SystemStateController.exists():
SystemState.dissolve()
else: return Result(valid=True)
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}')
@ -115,45 +119,17 @@ def evaluate_location_and_internet(profile: SystemProfile, connection_observer:
return location_result return location_result
def _cleanup_on_error(firewall_setting: bool, dns_setting: bool) -> None: # def _cleanup_on_error(firewall_setting: bool, dns_setting: bool) -> None:
"""Safely terminate connection on error, swallowing cleanup errors.""" # """Safely terminate connection on error, swallowing cleanup errors."""
try: # terminate_system_connection(firewall_setting, dns_setting)
terminate_system_connection(firewall_setting, dns_setting)
except ConnectionTerminationError:
pass
def _finish_success(final_result: Result, profile_id: int) -> Result: 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() terminate_tor_connection()
time.sleep(0.3) time.sleep(0.3)
return final_result 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( def __establish_system_connection(
profile: SystemProfile, profile: SystemProfile,
firewall_setting: bool = False, firewall_setting: bool = False,
@ -164,8 +140,6 @@ def __establish_system_connection(
Orchestrates the full flow of the connection. Orchestrates the full flow of the connection.
Steps: Steps:
1) Check Prereqs
2) Disable any pre-existing system connections
3) Turn on WG via nmcli 3) Turn on WG via nmcli
4) Turn on IPv6 sinkhole 4) Turn on IPv6 sinkhole
5) Setup State (JSON file) 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. 7) Set DNS (if enabled), and test it. Let's it slide even if failed.
8) Testing, confirming, displaying to the end user. 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 ============= # ============= INITIAL NMCLI CONNECTION =============
notification = "Setting Up WireGuard.." notification = "Setting Up WireGuard.."
Notifications.emit(EventType.STATUS_UPDATE, notification) Notifications.emit(EventType.STATUS_UPDATE, notification)
# if connection_observer:
# connection_observer.notify("connecting", notification)
wg_config_path = profile.get_wireguard_configuration_path() wg_config_path = profile.get_wireguard_configuration_path()
nmcli_result = setup_nmcli_connection(wg_config_path) nmcli_result = setup_nmcli_connection(wg_config_path)
@ -204,17 +171,6 @@ def __establish_system_connection(
if not sinkhole_result: if not sinkhole_result:
raise ConnectionError('IPv6 Could not be protected.') 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 ============= # ============= PREP SETTINGS =============
if connection_observer: if connection_observer:
connection_observer.notify("connecting", "Getting Interface Name..") connection_observer.notify("connecting", "Getting Interface Name..")
@ -273,18 +229,28 @@ def __establish_system_connection(
if not test_result.valid: if not test_result.valid:
notification = "Test Failed." notification = "Test Failed."
Notifications.emit(EventType.STATUS_UPDATE, notification) Notifications.emit(EventType.STATUS_UPDATE, notification)
logger.error("Returning that the test failed..")
return test_result return test_result
# ============= UPDATE STATE ============= # ============= UPDATE STATE =============
if dns_setting and not did_dns_work: # if dns_setting and not did_dns_work:
current_state.firewalled = firewall_tracker # current_state.firewalled = firewall_tracker
current_state.dns_set = did_dns_work # current_state.dns_set = did_dns_work
SystemStateController.update_or_create(current_state) # SystemStateController.update_or_create(current_state)
notification = "DNS Set Did Not Work." # notification = "DNS Set Did Not Work."
Notifications.emit(EventType.STATUS_UPDATE, notification) # 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) return Result(valid=True, data=current_state)
import time
def establish_system_connection( def establish_system_connection(
profile: SystemProfile, profile: SystemProfile,
@ -296,11 +262,14 @@ def establish_system_connection(
Establish connection with a maximum of 2 retries. Establish connection with a maximum of 2 retries.
Steps) Steps)
1) Verify Endpoint's encryption 1) Check Prereqs
2) get settings 2) Verify Endpoint's encryption
3) Attempt 1 -> (Fail) -> Location Check 3) get settings
4) Attempt 2 -> (Fail) -> MTU Check -> Attempt 3 4) Disable any pre-existing system connections
""" """
# ================= PRE-REQS =================
check_system_prereqs()
# ================= VERIFY ENDPOINT ================= # ================= VERIFY ENDPOINT =================
if connection_observer: if connection_observer:
connection_observer.notify("connecting", "Verifying endpoint..") connection_observer.notify("connecting", "Verifying endpoint..")
@ -314,109 +283,152 @@ def establish_system_connection(
# ================= CONNECT WG ================ # ================= CONNECT WG ================
# We allow 3 total attempts (Initial + 2 retries) # We allow 3 total attempts (Initial + 2 retries)
for attempt in range(1, 4): for attempt in range(1, 4):
try: # =================================
# if connection_observer: # PRE-EXISTING
# connection_observer.notify("connecting", f"Connecting attempt {attempt} of 3.. ") # =================================
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.. " # func_choice = partial(register_wireguard_session, profile=profile, connection_observer=connection_observer)
Notifications.emit(EventType.STATUS_UPDATE, notification) # 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 print("sleeping")
connection_result = __establish_system_connection( time.sleep(5)
profile=profile,
firewall_setting=firewall_setting, notification = f"Connecting attempt {attempt}.. "
dns_setting=dns_setting, 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 connection_observer=connection_observer
) )
if connection_result.valid: #============== NO RETRY #==============
return _finish_success(final_result=connection_result, profile_id=profile.id) if location_result.backoff_strategy == BackoffStrategy.NO_RETRY:
logger.error(f"Location check failed: {location_result.message}")
# If not valid, we treat it as a failure to trigger the except block logic bottom_left = "Node Down"
notification = f"{attempt} attempt failed.." 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) 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. # Connection is still On for MTU testing:
if port_was_changed_already_today():
# permanent config modify:
ConfigurationController.change_port_prompts(False)
if attempt == 1: # =================================
# First failure: Evaluate Location and Internet without connection, # MTU
_cleanup_on_error(firewall_setting, dns_setting) # =================================
should_test_mtu, current_mtu = should_we_even_test_mtu(profile.id)
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.")
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: else:
# Attempt 3 failed logger.info("MTU was tested but NOT changed.")
logger.error("Maximum retries reached. Connection failed.") else:
notification = "Failed to connect." logger.info("MTU was NOT tested.")
Notifications.emit(EventType.STATUS_UPDATE, notification)
_cleanup_on_error(firewall_setting, dns_setting) # Take down connection for the rest of the tests:
raise ConnectionError(notification) terminated = terminate_system_connection(firewall_setting, dns_setting)
# return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.") 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.")

View file

@ -156,18 +156,22 @@ def enable_profile(
connection_result = establish_connection(profile, ignore=ignore, connection_observer=connection_observer) connection_result = establish_connection(profile, ignore=ignore, connection_observer=connection_observer)
if not connection_result: if not connection_result:
logger.error("Connection result is empty.") logger.error("Connection result is empty.")
notification = "Connection Failed"
Notifications.emit(EventType.STATUS_UPDATE, notification)
return 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 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: except FirewallError:
return Result(valid=False, error_type=ResultError.FIREWALL, message="Firewall Error.") return Result(valid=False, error_type=ResultError.FIREWALL, message="Firewall Error.")
except ConnectionError as e: except ConnectionError as e: