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

View file

@ -118,7 +118,9 @@ def api_call_and_extraction(
# EVALUATE CONNECTION FAILURE # EVALUATE CONNECTION FAILURE
#----------------------------- #-----------------------------
# Layer 1: API 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 # legacy system
if api_response.error_type == ErrorType.AUTHENTICATION_ERROR: if api_response.error_type == ErrorType.AUTHENTICATION_ERROR:

View file

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

View file

@ -9,6 +9,7 @@ class EventType(Enum):
PROFILE_CREATED = auto() PROFILE_CREATED = auto()
PROFILE_DELETED = auto() PROFILE_DELETED = auto()
STATUS_UPDATE = auto() STATUS_UPDATE = auto()
SHOW_POPUP = auto()
RESUME_ACTIONS = auto() RESUME_ACTIONS = auto()
class Notifications: 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.models.manage.server_reply_tools import api_call_and_extraction, ApiCall
# from core.services.networking.httpx import connect # from core.services.networking.httpx import connect
#from core.services.networking.api_requests.ApiResponseModel import ErrorType #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) 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: # If need it pretty spacing quickly:
def expand_newlines(text): def expand_newlines(text):
""" """
@ -125,67 +181,3 @@ def expand_newlines(text):
empty line between every non-empty line of text. empty line between every non-empty line of text.
""" """
return "\n\n".join([line for line in text.splitlines() if line.strip()]) 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): class ErrorType(Enum):
"""Classified error categories.""" """Classified error categories."""
SUCCESS = "success" SUCCESS = "success"
APPLICATION_ERROR = "application_error"
INVALID_ENDPOINT = "invalid_endpoint" # 404 - could be wrong URL or middleware rejecting INVALID_ENDPOINT = "invalid_endpoint" # 404 - could be wrong URL or middleware rejecting
INVALID_REQUEST = "invalid_request" # 400, 405 - bad method/body INVALID_REQUEST = "invalid_request" # 400, 405 - bad method/body
AUTHENTICATION_ERROR = "authentication_error" # 401 - need credentials 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}' 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' wg_conf_path = f'{profile_path}/wg.conf.bak'
elif profile_type == ProfileType.SESSION: elif profile_type == ProfileType.SESSION:
wg_conf_path = f'{profile_path}/wg.conf' 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.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.networking.systemwide.systemwide_wireguard import establish_system_connection, terminate_system_connection
from core.services.keys_and_verifications.wireguard_keys import register_wireguard_session 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..") 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) 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( def _establish_with_renegotiation(
profile: Union[SessionProfile, SystemProfile], profile: Union[SessionProfile, SystemProfile],
@ -209,7 +214,8 @@ def _establish_with_renegotiation(
try: try:
logger.info(f"[CONNECTION] Connecting..") logger.info(f"[CONNECTION] Connecting..")
return establish_fn(profile, ignore=ignore, connection_observer=connection_observer) return establish_fn(profile, ignore=ignore, connection_observer=connection_observer)
except ConnectionError: except ConnectionError as e:
logger.error(str(e))
if __should_renegotiate(profile): if __should_renegotiate(profile):
logger.info(f"[CONNECTION] Renegotiating a new wg key session with API..") logger.info(f"[CONNECTION] Renegotiating a new wg key session with API..")
@ -245,7 +251,6 @@ def __should_renegotiate(profile: Union[SessionProfile, SystemProfile]):
# Enums # Enums
# if profile.type == ProfileType.SYSTEM: # if profile.type == ProfileType.SYSTEM:
# print("This is a system profile") # print("This is a system profile")

View file

@ -15,10 +15,11 @@ import httpx
from httpx_socks import AsyncProxyTransport from httpx_socks import AsyncProxyTransport
import httpx_socks import httpx_socks
from httpx_socks import ProxyType from httpx_socks import ProxyType
import subprocess
from typing import Optional from typing import Optional
API_ENDPOINT_FORMATS = { API_ENDPOINT_FORMATS = {
f"{Constants.SP_BASE_URL}/v2/get_ip": 'ip',
"https://api.ipify.org?format=json": "ip", "https://api.ipify.org?format=json": "ip",
"https://api.myip.com": "ip", "https://api.myip.com": "ip",
"https://wtfismyip.com/json": "YourFuckingIPAddress", "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] final_value = response[desired_key]
return final_value 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"Classifying HTTP response: {response.status_code}")
logger.info(f"payload: {response.json()}")
# Success case (2xx) # Success case (2xx)
if 200 <= response.status_code < 300: if 200 <= response.status_code < 300:
@ -49,13 +50,22 @@ def classify_response(response) -> ApiResponse:
if 400 <= response.status_code < 500: if 400 <= response.status_code < 500:
try: try:
resp_json = response.json() 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 = ( error_msg = (
resp_json.get("message") resp_json.get("message")
or resp_json.get("error") or resp_json.get("error")
or resp_json.get("error_code") or resp_json.get("error_code")
) )
except ValueError:
error_msg = response.text
# Map status codes to semantic error types and retry strategies # Map status codes to semantic error types and retry strategies
error_map = { error_map = {

View file

@ -350,6 +350,8 @@ def start_singbox(
# raises error if not okay. # raises error if not okay.
Notifications.emit(EventType.SYSTEM_ENABLE, profile_id) Notifications.emit(EventType.SYSTEM_ENABLE, profile_id)
notification = "Connected."
Notifications.emit(EventType.STATUS_UPDATE, notification)
# ============= CONCLUSION ============= # ============= CONCLUSION =============
return Result(valid=True, data=process_id) 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.models.enum_models import ProtocolChoice, SINGBOX_PROTOCOLS
from core.controllers.profile_state.get_profile_data import get_protocol_enum 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.services.keys_and_verifications.endpoint_verification import verify_wireguard_endpoint
from core.models.Result import Result, ResultError from core.models.Result import Result, ResultError
from core.services.networking.systemwide.systemwide_utils import extract_wg_interface_name, check_system_prereqs 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.general_connection_tools.config_tools import extract_endpoint_ip, extract_dns
from core.services.networking.systemwide import killswitch 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.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.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.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.utils.run_commands import run_generic_command
from core.observers.TicketObserver import TicketObserver from core.observers.TicketObserver import TicketObserver
from core.Constants import Constants # from core.Constants import Constants
from core.Errors import ConnectionTerminationError, CommandNotFoundError from core.Errors import ConnectionTerminationError, CommandNotFoundError
from core.controllers.ConfigurationController import ConfigurationController from core.controllers.ConfigurationController import ConfigurationController
from core.controllers.SystemStateController import SystemStateController from core.controllers.SystemStateController import SystemStateController
from core.models.BaseProfile import ProfileType # from core.models.BaseProfile import ProfileType
from core.models.session.SessionProfile import SessionProfile # from core.models.session.SessionProfile import SessionProfile
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
def terminate_system_connection( def terminate_system_connection(
firewall_setting: Optional[bool] = None, firewall_setting: Optional[bool] = None,
@ -53,11 +58,11 @@ def terminate_system_connection(
# ====== FIREWALL ======= # ====== FIREWALL =======
if firewall_setting: if firewall_setting:
disable_result_object = killswitch.disarm() # raw blind kill _ = killswitch.disarm() # raw blind kill
# ====== DNS ======= # ====== DNS =======
if dns_setting: if dns_setting:
reverting_dns_worked = revert_dns() _ = revert_dns()
# ====== NMCLI ======= # ====== NMCLI =======
if SystemStateController.exists(): 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( def __establish_system_connection(
profile: SystemProfile, profile: SystemProfile,
firewall_setting: bool = False, firewall_setting: bool = False,
@ -118,12 +171,16 @@ def __establish_system_connection(
8) Testing, confirming, displaying to the end user. 8) Testing, confirming, displaying to the end user.
""" """
check_system_prereqs() 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) terminate_system_connection(firewall_setting, dns_setting)
# ============= INITIAL NMCLI CONNECTION ============= # ============= INITIAL NMCLI CONNECTION =============
if connection_observer: notification = "Setting Up WireGuard.."
connection_observer.notify("connecting", "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() wg_config_path = profile.get_wireguard_configuration_path()
nmcli_result = setup_nmcli_connection(wg_config_path) nmcli_result = setup_nmcli_connection(wg_config_path)
@ -147,7 +204,7 @@ def __establish_system_connection(
# ============= SETUP STATE ============= # ============= SETUP STATE =============
# Even if firewall is off, we want to save the fact we turned on the VPN tunnel, before we raise errors. # 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( current_state = SystemStateController.create(
profile_id=profile.id, profile_id=profile.id,
firewalled=firewall_setting, # intended setting, not result yet firewalled=firewall_setting, # intended setting, not result yet
@ -200,15 +257,19 @@ def __establish_system_connection(
# ============= TESTING ============= # ============= TESTING =============
logger.info("If we made it this far, we have nmcli on, ipv6 sinkhole on, firewall on.") logger.info("If we made it this far, we have nmcli on, ipv6 sinkhole on, firewall on.")
if connection_observer: # if connection_observer:
connection_observer.notify("connecting", "Testing Connection Health..") # 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( test_result = test_ip_systemwide_for_any_protocol(
profile_id=profile.id, profile_id=profile.id,
protocol=ProtocolChoice.WIREGUARD, protocol=ProtocolChoice.WIREGUARD,
connection_observer=connection_observer connection_observer=connection_observer
) )
if not test_result.valid: if not test_result.valid:
notification = "Test Failed."
Notifications.emit(EventType.STATUS_UPDATE, notification)
return test_result return test_result
# ============= UPDATE STATE ============= # ============= UPDATE STATE =============
@ -221,65 +282,11 @@ def __establish_system_connection(
connection_observer.notify("connected", "Confirmed") connection_observer.notify("connected", "Confirmed")
Notifications.emit(EventType.SYSTEM_ENABLE, profile.id) Notifications.emit(EventType.SYSTEM_ENABLE, profile.id)
notification = "Connected."
Notifications.emit(EventType.STATUS_UPDATE, notification)
return Result(valid=True, data=current_state) 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( def establish_system_connection(
profile: SystemProfile, profile: SystemProfile,
ignore: tuple[type[Exception]] = (), 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 ticket_observer: Optional[TicketObserver] = None # Passed in only for assassin's link to tickets in ConnectionController.establish_session_connection
): ):
""" """
Purpose: Establish connection with a maximum of 2 retries.
Calls the private function with the full flow, so it can be retried on failure.
Steps) Steps)
1) Verify Endpoint's encryption 1) Verify Endpoint's encryption
2) Pass to private function 2) get settings
3) Retry on failure 3) Attempt 1 -> (Fail) -> Location Check
4) Attempt 2 -> (Fail) -> MTU Check -> Attempt 3
""" """
# ================= VERIFY ENDPOINT ================= # ================= VERIFY ENDPOINT =================
if connection_observer: if connection_observer:
@ -306,17 +313,88 @@ def establish_system_connection(
dns_setting = ConfigurationController.get_dns_setting() dns_setting = ConfigurationController.get_dns_setting()
# ================= CONNECT WG ================ # ================= CONNECT WG ================
try: # We allow 3 total attempts (Initial + 2 retries)
final_result = _establish_connection_with_retry( for attempt in range(1, 4):
profile=profile, try:
firewall_setting=firewall_setting, if connection_observer:
dns_setting=dns_setting, connection_observer.notify("connecting", f"Connecting attempt {attempt} of 3.. ")
connection_observer=connection_observer
)
except (ConnectionError, CalledProcessError):
_cleanup_on_error(firewall_setting, dns_setting)
raise
terminate_tor_connection() # Attempt the actual system connection
time.sleep(0.3) connection_result = __establish_system_connection(
return final_result 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.utils.run_commands import run_generic_command
from core.models.Result import Result, ResultError from core.models.Result import Result, ResultError
from core.errors.logger import logger from core.errors.logger import logger
MAX_MTU = 1500 from typing import Optional
import asyncio
MAX_MTU = 1420
MIN_MTU = 1280 MIN_MTU = 1280
# Use integer division for the middle point # Use integer division for the middle point
MIDDLE_MTU = MIN_MTU + ((MAX_MTU - MIN_MTU) // 2) MIDDLE_MTU = MIN_MTU + ((MAX_MTU - MIN_MTU) // 2)
# Fallback list for reliability # 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 - 28 bytes (IP + ICMP headers)
payload_size = mtu_value - 28 payload_size = mtu_value - 28
if payload_size < 0: 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}." human_readable_goal = f"Test the MTU value {mtu_value} against {url}."
return run_generic_command(command, human_readable_goal, timeout=timeout) return run_generic_command(command, human_readable_goal, timeout=timeout)
def find_optimal_mtu(low: int, high: int, url: str) -> int:
""" def mtu_is_fine(current_mtu: Optional[int] = None) -> bool:
Performs a binary search between a known working MTU (low) if current_mtu:
and a known failing MTU (high). 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 optimal = low
current_timeout = 6 current_timeout = 6
while low <= high: while low <= high:
mid = (low + high) // 2 mid = (low + high) // 2
# We increase timeout slightly as we try larger packets notification = f"Binary searching: Testing MTU {mid} against {url}..."
# to avoid false negatives due to congestion/latency Notifications.emit(EventType.STATUS_UPDATE, notification)
logger.info(f"Refining: Testing MTU {mid}...") logger.info(notification)
result = test_an_mtu(mid, url, timeout=current_timeout)
result = await test_an_mtu(mid, url, timeout=current_timeout)
if result.valid: if result.valid:
optimal = mid optimal = mid
low = mid + 1 low = mid + 1
current_timeout += 1 # Increase timeout as we get closer to max current_timeout += 1
else: else:
high = mid - 1 high = mid - 1
return optimal return optimal
def loop_through_testing_mtu(): async def loop_through_testing_mtu():
# The sequence to probe the range
INITIAL_TEST_LIST = [MAX_MTU, MIDDLE_MTU, MIN_MTU] INITIAL_TEST_LIST = [MAX_MTU, MIDDLE_MTU, MIN_MTU]
for url in URLS: for each_mtu in INITIAL_TEST_LIST:
logger.info(f"Testing connectivity using URL: {url}") notification = f"Probing MTU {each_mtu} across all IPs..."
Notifications.emit(EventType.STATUS_UPDATE, notification)
logger.info(notification)
working_mtu = None # Create tasks for all URLs for the current MTU value
failed_above = None async def wrapped_test(u):
res = await test_an_mtu(each_mtu, u, timeout=6)
return res, u
for each_mtu in INITIAL_TEST_LIST: tasks = [asyncio.create_task(wrapped_test(url)) for url in URLS]
logger.info(f"Probing MTU {each_mtu}...")
result = test_an_mtu(mtu_value=each_mtu, url=url, timeout=6)
if result.valid: try:
working_mtu = each_mtu # Process results as they come in
# If MAX_MTU works immediately, we've found the ceiling for coro in asyncio.as_completed(tasks):
if each_mtu == MAX_MTU: result, url = await coro
return MAX_MTU if result.valid:
break # 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
if working_mtu: # Scenario B: We found a baseline, now binary search for the ceiling
# We found a baseline. Now we need to find the ceiling. if each_mtu == MIDDLE_MTU:
# The 'high' is the first value in our test list that failed. failed_above = MAX_MTU
# If MIDDLE worked, high is MAX. If MIN worked, high is MIDDLE. else: # MIN_MTU
if working_mtu == MIDDLE_MTU: failed_above = 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...") logger.info(f"Baseline found at {each_mtu} on {url}. Searching for optimal...")
return find_optimal_mtu(working_mtu, failed_above, url) return await find_optimal_mtu_binary_search(each_mtu, failed_above, url)
finally:
# If we reach here, the MIN_MTU failed for this URL # Kill all other pending pings for this MTU level immediately
logger.error(f"Could not reach {url} even with minimum MTU. Trying next URL.") for t in tasks:
if not t.done():
t.cancel()
logger.critical("All URLs failed MTU tests. Check your WireGuard connection.") logger.critical("All URLs failed MTU tests. Check your WireGuard connection.")
return None return None

View file

@ -7,6 +7,7 @@ from core.Constants import Constants
from core.models.Result import Result, ResultError from core.models.Result import Result, ResultError
from core.errors.logger import logger from core.errors.logger import logger
from core.errors.error_shortcuts import not_supported from core.errors.error_shortcuts import not_supported
from core.observers.Notifications import Notifications, EventType
def _mark_profile_as_paid(profile_id: int): def _mark_profile_as_paid(profile_id: int):
@ -62,6 +63,8 @@ def mark_as_paid() -> Result:
# finally: # finally:
payment_cycle.wipe() payment_cycle.wipe()
notification = "Paid!"
Notifications.emit(EventType.STATUS_UPDATE, notification)
return Result(valid=True) 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.ConnectionController import ConnectionController
from core.controllers.ProfileController import ProfileController from core.controllers.ProfileController import ProfileController
from core.services.networking.systemwide.systemwide_wireguard import terminate_system_connection from core.services.networking.systemwide.systemwide_wireguard import terminate_system_connection
@ -152,21 +153,32 @@ def enable_profile(
if profile.is_system_profile(): if profile.is_system_profile():
try: try:
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:
logger.error("Connection result is empty.")
notification = "Connection Failed"
Notifications.emit(EventType.STATUS_UPDATE, notification)
return
if connection_result.valid: if connection_result.valid:
if profile_observer is not None: if profile_observer is not None:
profile_observer.notify('enabled', profile) profile_observer.notify('enabled', profile)
notification = "Enabled."
Notifications.emit(EventType.STATUS_UPDATE, notification)
return Result(valid=True, data=profile.type) return Result(valid=True, data=profile.type)
else: else:
logger.error(f"Couldn't enable the profile: {connection_result.error_type}")
error_msg = connection_result.message 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: except FirewallError:
raise return Result(valid=False, error_type=ResultError.FIREWALL, message="Firewall Error.")
except ConnectionError: except ConnectionError as e:
raise ProfileActivationError('The profile could not be enabled.') return Result(valid=False, error_type=ResultError.CONNECTION, message=f"Connection Error. {str(e)}")
except ValueError: except ValueError as e:
raise ProfileActivationError('The profile could not be enabled.') return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Value Error. {str(e)}")
def disable_profile( def disable_profile(

View file

@ -272,10 +272,6 @@ def return_pre_existing_invoice(billing_code: str, connection_observer: Connecti
return result return result
def get_ticket_invoice(connection_observer: ConnectionObserver) -> Result: def get_ticket_invoice(connection_observer: ConnectionObserver) -> Result:
"""Evaluates if it needs a new ticket invoice, and then returns pre-existing""" """Evaluates if it needs a new ticket invoice, and then returns pre-existing"""
@ -443,20 +439,6 @@ def paying_for_tickets() -> bool:
return False 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: def get_current_billing_data(category: Optional[PaymentCategory] = None, profile_id: Optional[int] = None) -> Result:
""" """
Goal: Goal:

View file

@ -1,5 +1,9 @@
from core.Constants import Constants from core.Constants import Constants
from core.errors.logger import logger
import os import os
from typing import Optional
import subprocess
# gets a raw string, but unlike the other function, does not convert it to a JSON. # 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 return raw_string_content
except: except:
return False 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