Introduced Location & MTU testing on systemwide Wireguard. As well as IP address testing instead of the previous method. Additionally, 400 status APIs now pass through the APIResponse layer as True to be evaluated by the SeverReply Layer. Finally notifications are better integrated to convey the testing rounds to the end user.

This commit is contained in:
SimplifiedPrivacy 2026-10-21 13:37:21 -04:00
parent d2e75ca0e1
commit 2208cedb7e
19 changed files with 595 additions and 244 deletions

View file

@ -1,4 +1,4 @@
from core.services.networking.api_requests.ApiResponseModel import BackoffStrategy
from enum import Enum
from dataclasses import dataclass
@ -51,6 +51,9 @@ class ResultError(Enum):
SERVER_SIDE = "server_side"
MTU = "mtu"
TOR_NEEDED = "tor_needed"
API_DOWN = "api_down"
NODE_ERROR = "node_error"
NO_INTERNET = "no_internet"
NAVIGATE_TO = "navigate_to"
UNKNOWN = "unknown"
@ -62,14 +65,15 @@ class Result():
data: Optional[Any] = None
message: Optional[str] = None
goal: Optional[str] = None
backoff_strategy: Optional[BackoffStrategy] = None
function_choice: Optional[partial] = None
no_option: Optional[partial] = None
navigate_to: Optional[str] = None
def user_message(self) -> str:
"""Human-readable error for the UI."""
messages = {
ResultError.SUCCESS: "Operation completed successfully.",
ResultError.UNKNOWN: f"Error: {self.message}",
}
return messages.get(self.error_type, "Unknown error")
# def user_message(self) -> str:
# """Human-readable error for the UI."""
# messages = {
# ResultError.SUCCESS: "Operation completed successfully.",
# ResultError.UNKNOWN: f"Error: {self.message}",
# }
# return messages.get(self.error_type, "Unknown error")

View file

@ -118,7 +118,9 @@ def api_call_and_extraction(
# EVALUATE CONNECTION FAILURE
#-----------------------------
# Layer 1: API failure:
if not api_response.valid:
print(f"api_response is {api_response}")
print(f"api_response is {api_response.data}")
if not api_response.valid and not api_response.error_type in (ErrorType.AUTHENTICATION_ERROR, ErrorType.INVALID_REQUEST):
# legacy system
if api_response.error_type == ErrorType.AUTHENTICATION_ERROR:

View file

@ -34,6 +34,7 @@ class ServerReply():
message: Optional[str] = None
model_version: int = 1
data: Optional[dict] = None
boolean_result: Optional[bool] = None
conf: Optional[str] = None
payment_status: Optional[bool] = None

View file

@ -9,6 +9,7 @@ class EventType(Enum):
PROFILE_CREATED = auto()
PROFILE_DELETED = auto()
STATUS_UPDATE = auto()
SHOW_POPUP = auto()
RESUME_ACTIONS = auto()
class Notifications:

View file

@ -1,4 +1,4 @@
from core.utils.get_raw_string import get_string
from core.models.manage.server_reply_tools import api_call_and_extraction, ApiCall
# from core.services.networking.httpx import connect
#from core.services.networking.api_requests.ApiResponseModel import ErrorType
@ -118,6 +118,62 @@ def insert_mtu(mtu: int, wireguard_config: str) -> str:
return re.sub(pattern, replacement, wireguard_config)
def insert_mtu_with_replacement(mtu: int, wireguard_config: str) -> str:
# 1. Attempt to replace an existing MTU line.
# ^MTU matches "MTU" at the start of a line (requires re.MULTILINE).
# \s*=\s*\d+ matches the equals sign and the numeric value.
new_config, count = re.subn(
r"^MTU\s*=\s*\d+",
f"MTU = {mtu}",
wireguard_config,
flags=re.MULTILINE
)
# If count > 0, an existing MTU was found and replaced.
if count > 0:
return new_config
# 2. If no MTU existed, use the original insertion logic.
pattern = r"(DNS\s*=\s*[^\n]+)(\s*)(\[Peer\])"
replacement = rf"\1\nMTU = {mtu}\2\3"
return re.sub(pattern, replacement, new_config)
def insert_mtu_into_config_on_disk(profile: Profile, mtu: int) -> bool:
"""Inserts an MTU into a Systemwide Profile"""
file_location = f"/etc/hydra-veil/profiles/{str(profile.id)}/wg.conf"
wireguard_config = get_string(file_location)
new_config = insert_mtu_with_replacement(mtu=mtu, wireguard_config=wireguard_config)
try:
profile.attach_wireguard_configuration(new_config)
return True
except Exception as e:
logger.error({str(e)})
return False
def _parse_for_mtu(wireguard_config: str) -> Optional[int]:
# This pattern searches for 'MTU', optional whitespace, an equals sign,
# and captures the digits that follow.
pattern = r"MTU\s*=\s*(\d+)"
match = re.search(pattern, wireguard_config)
if match:
# Return the captured digit group as an integer.
return int(match.group(1))
return None
# ASSUMES SYSTEM-WIDE:
def get_mtu(profile_id: int) -> Optional[int]:
"""Gets the MTU of a Systemwide Profile"""
file_location = f"/etc/hydra-veil/profiles/{profile_id}/wg.conf"
wireguard_config = get_string(file_location)
return _parse_for_mtu(wireguard_config)
# If need it pretty spacing quickly:
def expand_newlines(text):
"""
@ -125,67 +181,3 @@ def expand_newlines(text):
empty line between every non-empty line of text.
"""
return "\n\n".join([line for line in text.splitlines() if line.strip()])
# over-engineering:
# api_result = connect.single_endpoint(
# method="post",
# url=url,
# observer=connection_observer,
# payload=payload,
# billing_code=billing_code
# )
# if not api_result.valid:
# if api_result.error_type == ErrorType.AUTHENTICATION_ERROR:
# logger.error(f"Server is giving an Authentication error for billing ID: {billing_code}")
# raise InvalidSubscriptionError(f"Invalid Subscription for {billing_code}")
# else:
# error_msg = f"Could Not Connect to API: {api_result.error_type}"
# logger.error(error_msg)
# raise ConnectionError(error_msg)
# # 2nd Layer Translation:
# result = format_server_response(api_response=api_result, desired_type="data")
# if not result.valid:
# # KILLING THE RESULT FOR NOW. Later, replace this error raise with the return of data,
# raise InvalidSubscriptionError(result.message)
# print(f"wireguard_configuration type is {type(wireguard_configuration)}")
# # print(f"wireguard_configuration value is {wireguard_configuration}")
# if isinstance(wireguard_configuration, dict):
# print(f"wireguard_configuration is {type(wireguard_configuration)}")
# print(f"wireguard_configuration is {wireguard_configuration}")
# logger.error("Server isn't returning a WG config, it's trying to tell you a ServerReply message...")
# return format_server_response(wireguard_configuration, "message")
# def handle_config_error(wireguard_configuration: str|dict) -> Result:
# """The server gave the wireguard config in an invalid format. First convert it to get the error."""
# server_reply = format_server_response(wireguard_configuration, "message")
# if server_reply.valid:
# return server_reply
# else:
# return Result(valid=False, error_type=ResultError.NODE_ERROR, message="server-side error with wireguard config format.")
# legacy:
# country_code = profile.location.country_code
# location_code = profile.location.code
# url = f'{Constants.SP_API_BASE_URL}/countries/{country_code}/locations/{location_code}/wireguard-sessions'
# ConnectionController.with_preferred_connection(
# profile.location.country_code,
# profile.location.code,
# profile.subscription.billing_code,
# wireguard_keys.get('public'),
# task=WebServiceApiService.post_wireguard_session,
# connection_observer=connection_observer
# )

View file

@ -14,6 +14,8 @@ class BackoffStrategy(Enum):
class ErrorType(Enum):
"""Classified error categories."""
SUCCESS = "success"
APPLICATION_ERROR = "application_error"
INVALID_ENDPOINT = "invalid_endpoint" # 404 - could be wrong URL or middleware rejecting
INVALID_REQUEST = "invalid_request" # 400, 405 - bad method/body
AUTHENTICATION_ERROR = "authentication_error" # 401 - need credentials

View file

@ -9,7 +9,7 @@ def extract_endpoint_ip(profile_id: str, profile_type: Optional[ProfileType] = N
profile_path = Constants.HV_PROFILE_CONFIG_HOME + f'/{profile_id}'
if not profile_type: # assumes systemwide:
if not profile_type or profile_type == ProfileType.SYSTEM:
wg_conf_path = f'{profile_path}/wg.conf.bak'
elif profile_type == ProfileType.SESSION:
wg_conf_path = f'{profile_path}/wg.conf'

View file

@ -1,3 +1,5 @@
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.general_connection_tools.testing_evaluating import system_uses_wireguard_interface
from core.services.networking.systemwide.systemwide_wireguard import establish_system_connection, terminate_system_connection
from core.services.keys_and_verifications.wireguard_keys import register_wireguard_session
@ -197,6 +199,9 @@ def _ensure_wireguard_configured(
logger.info(f"[CONNECTION] Profile does NOT have WG keys and it needs it. Registering a NEW wg session..")
registry_result = register_wireguard_session(profile, connection_observer=connection_observer)
logger.info(f"registry_result is {registry_result}")
if not registry_result.valid:
return registry_result
def _establish_with_renegotiation(
profile: Union[SessionProfile, SystemProfile],
@ -209,7 +214,8 @@ def _establish_with_renegotiation(
try:
logger.info(f"[CONNECTION] Connecting..")
return establish_fn(profile, ignore=ignore, connection_observer=connection_observer)
except ConnectionError:
except ConnectionError as e:
logger.error(str(e))
if __should_renegotiate(profile):
logger.info(f"[CONNECTION] Renegotiating a new wg key session with API..")
@ -245,7 +251,6 @@ def __should_renegotiate(profile: Union[SessionProfile, SystemProfile]):
# Enums
# if profile.type == ProfileType.SYSTEM:
# print("This is a system profile")

View file

@ -15,10 +15,11 @@ import httpx
from httpx_socks import AsyncProxyTransport
import httpx_socks
from httpx_socks import ProxyType
import subprocess
from typing import Optional
API_ENDPOINT_FORMATS = {
f"{Constants.SP_BASE_URL}/v2/get_ip": 'ip',
"https://api.ipify.org?format=json": "ip",
"https://api.myip.com": "ip",
"https://wtfismyip.com/json": "YourFuckingIPAddress",
@ -106,3 +107,24 @@ def extract_ip_from_response(response: dict, api_url: str) -> str | bool:
final_value = response[desired_key]
return final_value
def ping_ip(ip: str) -> Result:
command = ["ping", "-c", "1", "-W", "2", ip]
human_readable_goal = f"Pinging {ip}"
return run_generic_command(command, human_readable_goal, timeout=5)
def ping_ip_worked(ip: str) -> bool:
try:
# -c 1: send only 1 packet
# -W 2: wait 2 seconds for a response
output = subprocess.run(
["ping", "-c", "1", "-W", "2", ip],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
return output.returncode == 0
except Exception:
return False

View file

@ -0,0 +1,88 @@
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.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
from core.models.BaseProfile import BaseProfile as Profile
from core.models.BaseProfile import ProfileType
from core.observers.ConnectionObserver import ConnectionObserver
from core.Constants import Constants
from core.errors.logger import logger
from core.models.Result import Result, ResultError
from core.utils.type_checker import type_checker
from core.models.enum_models import ProtocolChoice
@type_checker
def get_profile_ip(profile_id: int, protocol: ProtocolChoice, profile_type: ProfileType) -> str|None|bool:
if protocol == ProtocolChoice.WIREGUARD:
print("getting wireguard IP")
return extract_endpoint_ip(profile_id=profile_id, profile_type=profile_type)
elif protocol in (ProtocolChoice.VLESS, ProtocolChoice.HYSTERIA2) and profile_type == ProfileType.SYSTEM:
return get_singbox_ip(profile_id=profile_id)
else:
logger.error("Singbox profiles not yet offered for session profiles")
return None
@type_checker
def check_if_location_is_up(profile: Profile, connection_observer: ConnectionObserver) -> Result:
profile_id = profile.id
location_id = profile.location.id
location_name = profile.location.country_name
profile_type = profile.type
string_protocol = profile.connection.code
protocol = ProtocolChoice(string_protocol)
print(f"""
profile_id: {profile_id}
location_id: {location_id}
location_name: {location_name}
profile_type: {profile_type}
protocol: {protocol}
""")
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)
print(f"Can {location_name} be pinged? {can_be_pinged}")
else:
logger.error("Skipping IP Address Test.")
can_be_pinged = False
if not can_be_pinged:
do_we_have_internet = test_internet()
print(f"Do you have the internet? {do_we_have_internet}")
if not do_we_have_internet:
error_msg = "Basic Internet Connection Issues"
return Result(valid=False, backoff_strategy=BackoffStrategy.NO_RETRY, error_type=ResultError.NO_INTERNET, message=error_msg)
# Now check vs official API:
url = f'{Constants.SP_BASE_URL}/v2/check_location'
payload = {
'location_id': str(location_id)
}
result = api_call_and_extraction(
desired_type="boolean_result",
method=ApiCall.POST,
url=url,
connection_observer=connection_observer,
payload=payload
)
if not result.valid and not can_be_pinged:
error_msg = "Can't ping the VPN node or even reach the API. Your connection is unstable."
return Result(valid=False, backoff_strategy=BackoffStrategy.NO_RETRY, error_type=ResultError.NO_INTERNET, message=error_msg)
location_up = result.data
if location_up:
return Result(valid=True, backoff_strategy=BackoffStrategy.RETRY_IMMEDIATE)
else:
error_msg = f"The {location_name} node is currently down."
return Result(valid=False, backoff_strategy=BackoffStrategy.NO_RETRY, error_type=ResultError.NODE_ERROR, message=error_msg)

View file

@ -0,0 +1,50 @@
from core.errors.logger import logger
import httpx
import asyncio
import httpx_socks
from httpx_socks import ProxyType
from typing import Optional
DEFAULT_URLS = [
"https://www.duckduckgo.com",
"https://www.github.com",
"https://www.wikipedia.org",
"https://www.grapheneos.org"
]
async def _async_test_internet(urls, port: Optional[int] = None):
# Configure transport if a port is provided for the SOCKS5 proxy
transport = None
if port:
transport = httpx_socks.AsyncProxyTransport(
proxy_type=ProxyType.SOCKS5,
proxy_host="127.0.0.1",
proxy_port=port,
)
# Use the transport in the AsyncClient if it was created
async with httpx.AsyncClient(transport=transport, http2=True, timeout=6) as client:
tasks = [asyncio.create_task(client.get(url)) for url in urls]
done, pending = await asyncio.wait(
tasks,
return_when=asyncio.FIRST_COMPLETED,
timeout=6
)
for task in pending:
task.cancel()
for task in done:
try:
response = task.result()
logger.info(f"✓ Got response: {response.status_code}")
return True
except Exception as e:
logger.error(f"✗ Failed: {e}")
return False
def test_internet(port: Optional[int] = None):
return asyncio.run(_async_test_internet(DEFAULT_URLS, port))

View file

@ -26,6 +26,7 @@ def classify_response(response) -> ApiResponse:
)
logger.info(f"Classifying HTTP response: {response.status_code}")
logger.info(f"payload: {response.json()}")
# Success case (2xx)
if 200 <= response.status_code < 300:
@ -49,13 +50,22 @@ def classify_response(response) -> ApiResponse:
if 400 <= response.status_code < 500:
try:
resp_json = response.json()
if "valid" in resp_json and "error_type" in resp_json:
return ApiResponse(
valid=True,
error_type=ErrorType.APPLICATION_ERROR,
data=resp_json,
message="Server replied with an error.",
backoff_strategy=BackoffStrategy.NO_RETRY
)
except ValueError:
error_msg = response.text
error_msg = (
resp_json.get("message")
or resp_json.get("error")
or resp_json.get("error_code")
)
except ValueError:
error_msg = response.text
# Map status codes to semantic error types and retry strategies
error_map = {

View file

@ -350,6 +350,8 @@ def start_singbox(
# raises error if not okay.
Notifications.emit(EventType.SYSTEM_ENABLE, profile_id)
notification = "Connected."
Notifications.emit(EventType.STATUS_UPDATE, notification)
# ============= CONCLUSION =============
return Result(valid=True, data=process_id)

View file

@ -1,6 +1,10 @@
from core.services.keys_and_verifications.wireguard_keys import get_mtu, insert_mtu_into_config_on_disk
from core.services.networking.systemwide.wireguard.mtu_tools import loop_through_testing_mtu, mtu_is_fine, MIN_MTU, MAX_MTU
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
from core.controllers.profile_state.get_profile_data import get_protocol_enum
from core.services.networking.general_connection_tools.testing_evaluating import await_connection, system_uses_wireguard_interface, terminate_tor_connection
from core.services.networking.general_connection_tools.testing_evaluating import system_uses_wireguard_interface, terminate_tor_connection
from core.services.keys_and_verifications.endpoint_verification import verify_wireguard_endpoint
from core.models.Result import Result, ResultError
from core.services.networking.systemwide.systemwide_utils import extract_wg_interface_name, check_system_prereqs
@ -11,30 +15,31 @@ from core.services.networking.systemwide.general_tools.test_any_protocol import
# from core.services.networking.general_connection_tools.config_tools import extract_endpoint_ip, extract_dns
from core.services.networking.systemwide import killswitch
from core.services.networking.systemwide.dns_tools.process_dns_result import orchestrate_dns_check
from core.services.networking.systemwide.dns_tools.SearchResult import SearchResult, SearchError
# from core.services.networking.systemwide.dns_tools.SearchResult import SearchResult, SearchError
from core.services.networking.systemwide.wireguard.nmcli_tools import setup_nmcli_connection, setup_ipv6_sinkhole, manual_nmcli_enable
from core.services.networking.systemwide.wireguard.wg_firewall_dns import set_dns, revert_dns, turn_on_firewall, check_and_kill_firewall, enable_firewall_with_retry
from core.services.networking.systemwide.wireguard.wg_firewall_dns import set_dns, revert_dns, check_and_kill_firewall, enable_firewall_with_retry
from core.errors.logger import logger
from core.errors.exceptions import FirewallError
# from core.errors.exceptions import FirewallError
from core.utils.run_commands import run_generic_command
from core.observers.TicketObserver import TicketObserver
from core.Constants import Constants
# from core.Constants import Constants
from core.Errors import ConnectionTerminationError, CommandNotFoundError
from core.controllers.ConfigurationController import ConfigurationController
from core.controllers.SystemStateController import SystemStateController
from core.models.BaseProfile import ProfileType
from core.models.session.SessionProfile import SessionProfile
# from core.models.BaseProfile import ProfileType
# from core.models.session.SessionProfile import SessionProfile
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 typing import Optional
import os
import re
# import os
# import re
import shutil
import subprocess
# import subprocess
import time
import asyncio
def terminate_system_connection(
firewall_setting: Optional[bool] = None,
@ -53,12 +58,12 @@ def terminate_system_connection(
# ====== FIREWALL =======
if firewall_setting:
disable_result_object = killswitch.disarm() # raw blind kill
_ = killswitch.disarm() # raw blind kill
# ====== DNS =======
if dns_setting:
reverting_dns_worked = revert_dns()
_ = revert_dns()
# ====== NMCLI =======
if SystemStateController.exists():
@ -98,6 +103,54 @@ def terminate_system_connection(
def evaluate_location_and_internet(profile: SystemProfile, connection_observer: ConnectionObserver) -> Result:
"""Checks if location is up and returns the backoff strategy."""
notification = "Checking If Location is Up.."
Notifications.emit(EventType.STATUS_UPDATE, notification)
logger.info(notification)
location_result = check_if_location_is_up(profile=profile, connection_observer=connection_observer)
if location_result.backoff_strategy == BackoffStrategy.RETRY_IMMEDIATE:
Notifications.emit(EventType.STATUS_UPDATE, "Retrying after Location Check")
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 _finish_function(final_result: Result) -> Result:
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,
@ -118,12 +171,16 @@ def __establish_system_connection(
8) Testing, confirming, displaying to the end user.
"""
check_system_prereqs()
logger.info("[CONNECT] Terming existing connections..")
notification = "Terming existing connections.."
Notifications.emit(EventType.STATUS_UPDATE, notification)
logger.info(notification)
terminate_system_connection(firewall_setting, dns_setting)
# ============= INITIAL NMCLI CONNECTION =============
if connection_observer:
connection_observer.notify("connecting", "Setting Up WireGuard..")
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)
@ -147,7 +204,7 @@ def __establish_system_connection(
# ============= 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(f"Setting State JSON..")
logger.info("Setting State JSON..")
current_state = SystemStateController.create(
profile_id=profile.id,
firewalled=firewall_setting, # intended setting, not result yet
@ -200,15 +257,19 @@ def __establish_system_connection(
# ============= TESTING =============
logger.info("If we made it this far, we have nmcli on, ipv6 sinkhole on, firewall on.")
if connection_observer:
connection_observer.notify("connecting", "Testing Connection Health..")
# if connection_observer:
# connection_observer.notify("connecting", "Testing Connection Health..")
notification = "Testing IP address.."
Notifications.emit(EventType.STATUS_UPDATE, notification)
test_result = test_ip_systemwide_for_any_protocol(
profile_id=profile.id,
protocol=ProtocolChoice.WIREGUARD,
connection_observer=connection_observer
)
if not test_result.valid:
notification = "Test Failed."
Notifications.emit(EventType.STATUS_UPDATE, notification)
return test_result
# ============= UPDATE STATE =============
@ -221,65 +282,11 @@ def __establish_system_connection(
connection_observer.notify("connected", "Confirmed")
Notifications.emit(EventType.SYSTEM_ENABLE, profile.id)
notification = "Connected."
Notifications.emit(EventType.STATUS_UPDATE, notification)
return Result(valid=True, data=current_state)
def evaluate_connection_result(connection_result: Result) -> Result:
"""In the future, this will raise other error types or filter replies."""
if not connection_result.valid:
logger.error(f"[SYSTEMWIDE WG] Critical issue, could not establish a connection: {connection_result.message}")
time.sleep(1)
raise ConnectionError(f"Connection validation failed: {connection_result.message}")
else:
return connection_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 _establish_connection_with_retry(
profile: SystemProfile,
firewall_setting: bool,
dns_setting: bool,
connection_observer: Optional[ConnectionObserver],
max_retries: int = 2
) -> Result:
"""Establish connection with retry logic for transient errors."""
last_error = None
for attempt in range(1, max_retries):
if connection_observer:
connection_observer.notify("connecting", f"Connecting attempt {attempt} of {max_retries}.. ")
try:
connection_result = __establish_system_connection(
profile=profile,
firewall_setting=firewall_setting,
dns_setting=dns_setting,
connection_observer=connection_observer
)
evaluate_connection_result(connection_result)
return connection_result
except ConnectionError as e:
# Unrecoverable error, fail immediately
logger.error(str(e))
raise
except CalledProcessError as e:
# Potentially transient, retry once
last_error = e
logger.warning(f"Connection attempt {attempt}/{max_retries} failed with CalledProcessError: {e}")
if attempt == max_retries:
raise ConnectionError('The connection could not be established.') from e
def establish_system_connection(
profile: SystemProfile,
ignore: tuple[type[Exception]] = (),
@ -287,13 +294,13 @@ def establish_system_connection(
ticket_observer: Optional[TicketObserver] = None # Passed in only for assassin's link to tickets in ConnectionController.establish_session_connection
):
"""
Purpose:
Calls the private function with the full flow, so it can be retried on failure.
Establish connection with a maximum of 2 retries.
Steps)
1) Verify Endpoint's encryption
2) Pass to private function
3) Retry on failure
2) get settings
3) Attempt 1 -> (Fail) -> Location Check
4) Attempt 2 -> (Fail) -> MTU Check -> Attempt 3
"""
# ================= VERIFY ENDPOINT =================
if connection_observer:
@ -306,17 +313,88 @@ def establish_system_connection(
dns_setting = ConfigurationController.get_dns_setting()
# ================= CONNECT WG ================
try:
final_result = _establish_connection_with_retry(
profile=profile,
firewall_setting=firewall_setting,
dns_setting=dns_setting,
connection_observer=connection_observer
)
except (ConnectionError, CalledProcessError):
_cleanup_on_error(firewall_setting, dns_setting)
raise
# 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.. ")
terminate_tor_connection()
time.sleep(0.3)
return final_result
# Attempt the actual system connection
connection_result = __establish_system_connection(
profile=profile,
firewall_setting=firewall_setting,
dns_setting=dns_setting,
connection_observer=connection_observer
)
if connection_result.valid:
return _finish_function(connection_result)
# If not valid, we treat it as a failure to trigger the except block logic
raise ConnectionError("Connection result invalid")
except (ConnectionError, CalledProcessError) as e:
_cleanup_on_error(firewall_setting, dns_setting)
if attempt == 1:
# First failure: Evaluate Location and Internet without connection,
terminate_system_connection(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
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.")
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
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 MTU wasn't changed or wasn't applicable, don't bother with attempt 3
raise ConnectionError("Failed to connect.")
# return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.")
else:
# Attempt 3 failed
logger.error("Maximum retries reached. Connection failed.")
raise ConnectionError("Failed to connect.")
# return Result(valid=False, error_type=ResultError.CONNECTION, message="Failed to connect.")

View file

@ -1,16 +1,21 @@
from core.observers.Notifications import Notifications, EventType
from core.utils.run_commands import run_generic_command
from core.models.Result import Result, ResultError
from core.errors.logger import logger
MAX_MTU = 1500
from typing import Optional
import asyncio
MAX_MTU = 1420
MIN_MTU = 1280
# Use integer division for the middle point
MIDDLE_MTU = MIN_MTU + ((MAX_MTU - MIN_MTU) // 2)
# Fallback list for reliability
URLS = ["217.156.66.35", "185.165.169.91", "9.9.9.9"]
URLS = ["9.9.9.9", "217.156.66.35", "185.165.169.91"]
def test_an_mtu(mtu_value: int, url: str, timeout: int) -> Result:
def test_mtu_without_async(mtu_value: int, url: str, timeout: int) -> Result:
# Payload size = MTU - 28 bytes (IP + ICMP headers)
payload_size = mtu_value - 28
if payload_size < 0:
@ -20,67 +25,108 @@ def test_an_mtu(mtu_value: int, url: str, timeout: int) -> Result:
human_readable_goal = f"Test the MTU value {mtu_value} against {url}."
return run_generic_command(command, human_readable_goal, timeout=timeout)
def find_optimal_mtu(low: int, high: int, url: str) -> int:
"""
Performs a binary search between a known working MTU (low)
and a known failing MTU (high).
"""
def mtu_is_fine(current_mtu: Optional[int] = None) -> bool:
if current_mtu:
testing_mtu = current_mtu
else:
testing_mtu = MAX_MTU
notification = f"Testing MTU {testing_mtu}..."
Notifications.emit(EventType.STATUS_UPDATE, notification)
for url in URLS:
result = test_mtu_without_async(mtu_value=testing_mtu, url=url, timeout=6)
if result.valid:
return True
return False
async def test_an_mtu(mtu_value: int, url: str, timeout: int) -> Result:
payload_size = mtu_value - 28
if payload_size < 0:
return Result(valid=False)
try:
# Use asyncio subprocess for non-blocking OS calls
proc = await asyncio.create_subprocess_exec(
"ping", "-M", "do", "-s", str(payload_size), "-c", "1", url,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL
)
# Wait for completion or timeout
await asyncio.wait_for(proc.wait(), timeout=timeout)
return Result(valid=(proc.returncode == 0))
except (asyncio.TimeoutError, Exception):
if 'proc' in locals():
try:
proc.kill()
except ProcessLookupError:
pass
return Result(valid=False)
async def find_optimal_mtu_binary_search(low: int, high: int, url: str) -> int:
optimal = low
current_timeout = 6
while low <= high:
mid = (low + high) // 2
# We increase timeout slightly as we try larger packets
# to avoid false negatives due to congestion/latency
logger.info(f"Refining: Testing MTU {mid}...")
result = test_an_mtu(mid, url, timeout=current_timeout)
notification = f"Binary searching: Testing MTU {mid} against {url}..."
Notifications.emit(EventType.STATUS_UPDATE, notification)
logger.info(notification)
result = await test_an_mtu(mid, url, timeout=current_timeout)
if result.valid:
optimal = mid
low = mid + 1
current_timeout += 1 # Increase timeout as we get closer to max
current_timeout += 1
else:
high = mid - 1
return optimal
def loop_through_testing_mtu():
# The sequence to probe the range
async def loop_through_testing_mtu():
INITIAL_TEST_LIST = [MAX_MTU, MIDDLE_MTU, MIN_MTU]
for url in URLS:
logger.info(f"Testing connectivity using URL: {url}")
for each_mtu in INITIAL_TEST_LIST:
notification = f"Probing MTU {each_mtu} across all IPs..."
Notifications.emit(EventType.STATUS_UPDATE, notification)
logger.info(notification)
# Create tasks for all URLs for the current MTU value
async def wrapped_test(u):
res = await test_an_mtu(each_mtu, u, timeout=6)
return res, u
tasks = [asyncio.create_task(wrapped_test(url)) for url in URLS]
working_mtu = None
failed_above = None
for each_mtu in INITIAL_TEST_LIST:
logger.info(f"Probing MTU {each_mtu}...")
result = test_an_mtu(mtu_value=each_mtu, url=url, timeout=6)
if result.valid:
working_mtu = each_mtu
# If MAX_MTU works immediately, we've found the ceiling
if each_mtu == MAX_MTU:
return MAX_MTU
break
if working_mtu:
# We found a baseline. Now we need to find the ceiling.
# The 'high' is the first value in our test list that failed.
# If MIDDLE worked, high is MAX. If MIN worked, high is MIDDLE.
if working_mtu == MIDDLE_MTU:
failed_above = MAX_MTU
elif working_mtu == MIN_MTU:
failed_above = MIDDLE_MTU
else:
failed_above = MAX_MTU + 1
logger.info(f"Baseline found at {working_mtu}. Searching for optimal...")
return find_optimal_mtu(working_mtu, failed_above, url)
# If we reach here, the MIN_MTU failed for this URL
logger.error(f"Could not reach {url} even with minimum MTU. Trying next URL.")
try:
# Process results as they come in
for coro in asyncio.as_completed(tasks):
result, url = await coro
if result.valid:
# Scenario A: Maximum possible MTU worked immediately
if each_mtu == MAX_MTU:
logger.info(f"MAX_MTU {MAX_MTU} worked on {url}!")
return MAX_MTU
# Scenario B: We found a baseline, now binary search for the ceiling
if each_mtu == MIDDLE_MTU:
failed_above = MAX_MTU
else: # MIN_MTU
failed_above = MIDDLE_MTU
logger.info(f"Baseline found at {each_mtu} on {url}. Searching for optimal...")
return await find_optimal_mtu_binary_search(each_mtu, failed_above, url)
finally:
# Kill all other pending pings for this MTU level immediately
for t in tasks:
if not t.done():
t.cancel()
logger.critical("All URLs failed MTU tests. Check your WireGuard connection.")
return None

View file

@ -7,6 +7,7 @@ from core.Constants import Constants
from core.models.Result import Result, ResultError
from core.errors.logger import logger
from core.errors.error_shortcuts import not_supported
from core.observers.Notifications import Notifications, EventType
def _mark_profile_as_paid(profile_id: int):
@ -62,6 +63,8 @@ def mark_as_paid() -> Result:
# finally:
payment_cycle.wipe()
notification = "Paid!"
Notifications.emit(EventType.STATUS_UPDATE, notification)
return Result(valid=True)

View file

@ -1,3 +1,4 @@
from core.observers.Notifications import Notifications, EventType
from core.controllers.ConnectionController import ConnectionController
from core.controllers.ProfileController import ProfileController
from core.services.networking.systemwide.systemwide_wireguard import terminate_system_connection
@ -152,21 +153,32 @@ def enable_profile(
if profile.is_system_profile():
try:
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 connection_result.valid:
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)
else:
logger.error(f"Couldn't enable the profile: {connection_result.error_type}")
error_msg = connection_result.message
raise ProfileActivationError(error_msg)
logger.error(error_msg)
notification = "Connection Failed."
Notifications.emit(EventType.STATUS_UPDATE, notification)
return Result(valid=False, error_type=ResultError.UNKNOWN, message=error_msg)
# raise ProfileActivationError(error_msg)
except FirewallError:
raise
except ConnectionError:
raise ProfileActivationError('The profile could not be enabled.')
except ValueError:
raise ProfileActivationError('The profile could not be enabled.')
return Result(valid=False, error_type=ResultError.FIREWALL, message="Firewall Error.")
except ConnectionError as e:
return Result(valid=False, error_type=ResultError.CONNECTION, message=f"Connection Error. {str(e)}")
except ValueError as e:
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Value Error. {str(e)}")
def disable_profile(

View file

@ -272,10 +272,6 @@ def return_pre_existing_invoice(billing_code: str, connection_observer: Connecti
return result
def get_ticket_invoice(connection_observer: ConnectionObserver) -> Result:
"""Evaluates if it needs a new ticket invoice, and then returns pre-existing"""
@ -443,20 +439,6 @@ def paying_for_tickets() -> bool:
return False
# def start_new_process_if_already(profile_id: int)
# # Is there currently a process?
# payment_cycle = OverallPaymentCycle()
# if not payment_cycle and not category:
# error_msg = "There is no current billing cycle AND no category provided."
# return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
# billing_data = payment_cycle.billing_data
# if not billing_data:
def get_current_billing_data(category: Optional[PaymentCategory] = None, profile_id: Optional[int] = None) -> Result:
"""
Goal:

View file

@ -1,5 +1,9 @@
from core.Constants import Constants
from core.errors.logger import logger
import os
from typing import Optional
import subprocess
# gets a raw string, but unlike the other function, does not convert it to a JSON.
@ -23,3 +27,50 @@ def get_raw_string(which_ticket, name_of_file_and_folder):
return raw_string_content
except:
return False
def _read_with_privileges(filepath: str) -> Optional[str]:
"""
Attempts to read a file using pkexec or sudo to bypass PermissionErrors.
"""
# pkexec is preferred for desktop environments (GUI prompt)
# sudo is the fallback for CLI environments
for cmd_prefix in ["pkexec", "sudo"]:
try:
logger.info(f"Attempting to read {filepath} using {cmd_prefix}...")
# Using a list instead of shell=True prevents shell injection vulnerabilities
result = subprocess.run(
[cmd_prefix, "cat", filepath],
capture_output=True,
text=True,
check=True
)
return result.stdout
except (subprocess.CalledProcessError, FileNotFoundError) as e:
logger.debug(f"{cmd_prefix} failed or is not installed: {e}")
continue
return None
def get_string(filepath: str) -> Optional[str]:
"""
Reads a text file. If a PermissionError occurs on Linux,
attempts to escalate privileges via pkexec or sudo.
"""
try:
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
except PermissionError:
logger.warning(f"Permission denied for {filepath}. Attempting escalation...")
return _read_with_privileges(filepath)
except FileNotFoundError:
logger.error(f"File not found: {filepath}")
except IsADirectoryError:
logger.error(f"Path is a directory, not a file: {filepath}")
except UnicodeDecodeError as e:
logger.error(f"Encoding error reading file {filepath}: {e}")
except IOError as e:
logger.error(f"IO Error reading file {filepath}: {e}")
return None