Introduced MTU tools. MTU set for session configs automatically, for systemwide the testing is setup but not yet implemented. Also introduced server reply objects and a new v2 wg endpoint that doesn't require location codes.
This commit is contained in:
parent
10e46cd8ea
commit
9e4fd54eb6
6 changed files with 281 additions and 49 deletions
|
|
@ -40,6 +40,7 @@ class ResultError(Enum):
|
|||
WRONG_LOCATION = "wrong_location"
|
||||
SERVER_SIDE = "server_side"
|
||||
UNPAID = "unpaid"
|
||||
MTU = "mtu"
|
||||
NAVIGATE_TO = "navigate_to"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
from core.Errors import InvalidSubscriptionError
|
||||
from core.models.manage.validate_dataclass import validate_model
|
||||
from core.models.pydantic_models.ServerReply import ServerReply, ServerError
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
from core.services.networking.httpx import connect
|
||||
from core.errors.logger import logger
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
class ApiCall(Enum):
|
||||
GET = "get"
|
||||
POST = "post"
|
||||
|
||||
# Translate Server-side to client-side errors in THIS CONTEXT,
|
||||
TRANSLATION_DICT = {
|
||||
ServerError.WRONG_LOCATION: ResultError.WRONG_LOCATION,
|
||||
|
|
@ -79,3 +87,81 @@ def invalid_server_format(error_msg: Optional[str] = None) -> Result:
|
|||
error_msg = "The server gave an invalid response format."
|
||||
return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, message=error_msg)
|
||||
|
||||
|
||||
def api_call_and_extraction(
|
||||
desired_type: str,
|
||||
method: ApiCall,
|
||||
url: str,
|
||||
connection_observer: ConnectionObserver,
|
||||
payload: dict,
|
||||
billing_code: Optional[str] = None
|
||||
) -> Result:
|
||||
"""Do an API call, extract the data, and if there's an error, then translate the server's reply to a Result Object"""
|
||||
|
||||
#-----------------------------
|
||||
# SEND TO API
|
||||
#-----------------------------
|
||||
api_response = connect.single_endpoint(
|
||||
method=method.value,
|
||||
url=url,
|
||||
observer=connection_observer,
|
||||
payload=payload,
|
||||
billing_code=billing_code
|
||||
)
|
||||
|
||||
if not api_response:
|
||||
error_msg = "Complete failure of the function to produce a valid api response"
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||
|
||||
#-----------------------------
|
||||
# EVALUATE CONNECTION FAILURE
|
||||
#-----------------------------
|
||||
# Layer 1: API failure:
|
||||
if not api_response.valid:
|
||||
|
||||
# legacy system
|
||||
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}")
|
||||
|
||||
error_msg = f"Connection/500s Error prevented the server from replying: {api_response.error_type} {api_response.message}"
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.CONNECTION, message=error_msg)
|
||||
|
||||
#-----------------------------
|
||||
# EVALUATE SERVER-SIDE ISSUES (assuming it replied)
|
||||
#-----------------------------
|
||||
# Layer 2: Validity of Server's Reply:
|
||||
raw_reply = api_response.data
|
||||
logger.info("Validating the Validity of the Server's Reply..")
|
||||
worked, server_reply = validate_model(ServerReply, raw_reply)
|
||||
|
||||
error_msg = f"Invalid Structure for ServerReply! The raw data is {raw_reply}"
|
||||
|
||||
if not worked:
|
||||
logger.error(error_msg)
|
||||
return invalid_server_format()
|
||||
|
||||
if not server_reply:
|
||||
logger.error(error_msg)
|
||||
return invalid_server_format()
|
||||
|
||||
if not server_reply.valid:
|
||||
error_message = f"Server reply was correctly structured, but the actual invoice failed to be created. The raw data is {raw_reply}"
|
||||
logger.error(error_message)
|
||||
# translate by enum error code:
|
||||
return translate_server_failure(server_reply)
|
||||
|
||||
if not hasattr(server_reply, desired_type):
|
||||
error_msg = "Developer Error: You're asking for a data type that isn't in the object!"
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||
|
||||
data = getattr(server_reply, desired_type)
|
||||
if data:
|
||||
return Result(valid=True, data=data)
|
||||
else:
|
||||
return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Server Replied it was valid, but failed to provide the data.")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class ServerReply():
|
|||
message: Optional[str] = None
|
||||
model_version: int = 1
|
||||
data: Optional[dict] = None
|
||||
conf: str = None
|
||||
payment_status: Optional[bool] = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,31 @@
|
|||
from core.services.networking.httpx import connect
|
||||
from core.services.networking.api_requests.ApiResponseModel import ErrorType
|
||||
from core.models.session.SessionProfile import SessionProfile
|
||||
from core.models.system.SystemProfile import SystemProfile
|
||||
from core.Errors import MissingSubscriptionError, MissingLocationError, InvalidSubscriptionError
|
||||
|
||||
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
|
||||
from core.models.BaseProfile import BaseProfile as Profile
|
||||
from core.models.BaseProfile import ProfileType
|
||||
|
||||
# from core.models.session.SessionProfile import SessionProfile
|
||||
# from core.models.system.SystemProfile import SystemProfile
|
||||
from core.Errors import MissingSubscriptionError, MissingLocationError
|
||||
#, InvalidSubscriptionError
|
||||
# from core.services.WebServiceApiService import WebServiceApiService
|
||||
# from core.controllers.ConnectionController import ConnectionController
|
||||
from core.observers.ConnectionObserver import ConnectionObserver
|
||||
from core.Constants import Constants
|
||||
from core.errors.logger import logger
|
||||
from core.models.manage.server_reply_tools import format_server_response
|
||||
#from core.models.manage.server_reply_tools import format_server_response
|
||||
# from core.models.pydantic_models.ServerReply import ServerReply, ServerError
|
||||
from core.models.Result import Result, ResultError
|
||||
|
||||
from typing import Union, Optional
|
||||
from typing import Optional
|
||||
import base64
|
||||
import re
|
||||
|
||||
MIN_MTU = 1280
|
||||
|
||||
def register_wireguard_session(
|
||||
profile: Union[SessionProfile, SystemProfile],
|
||||
profile: Profile,
|
||||
connection_observer: Optional[ConnectionObserver] = None
|
||||
) -> Result:
|
||||
"""Register a WireGuard session for the given profile."""
|
||||
|
|
@ -34,69 +42,40 @@ def register_wireguard_session(
|
|||
logger.error("No Public Key Generated. Raising ValueError")
|
||||
raise ValueError("No Public Key Generated.")
|
||||
|
||||
country_code = profile.location.country_code
|
||||
location_code = profile.location.code
|
||||
billing_code = profile.subscription.billing_code
|
||||
url = f'{Constants.SP_API_BASE_URL}/countries/{country_code}/locations/{location_code}/wireguard-sessions'
|
||||
url = f'{Constants.SP_BASE_URL}/v2/wireguard_config'
|
||||
|
||||
payload = {
|
||||
'public_key': public_key
|
||||
}
|
||||
|
||||
api_result = connect.single_endpoint(
|
||||
method="post",
|
||||
result = api_call_and_extraction(
|
||||
desired_type="conf",
|
||||
method=ApiCall.POST,
|
||||
url=url,
|
||||
observer=connection_observer,
|
||||
connection_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)
|
||||
return result
|
||||
|
||||
wireguard_configuration = result.data
|
||||
|
||||
if wireguard_configuration is None:
|
||||
raise InvalidSubscriptionError()
|
||||
return Result(valid=False, error_type=ResultError.SERVER_SIDE, message="Server gave a blank wireguard config.")
|
||||
|
||||
wireguard_configuration = _inject_private_key(wireguard_configuration, wireguard_keys.get('private'))
|
||||
|
||||
if profile.type == ProfileType.SESSION:
|
||||
logger.info("Injecting MTU..")
|
||||
wireguard_configuration = insert_mtu(mtu=MIN_MTU, wireguard_config=wireguard_configuration)
|
||||
|
||||
profile.attach_wireguard_configuration(wireguard_configuration)
|
||||
return Result(valid=True)
|
||||
|
||||
|
||||
# over-engineering:
|
||||
|
||||
# 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.")
|
||||
|
||||
|
||||
def _generate_wireguard_keys() -> dict:
|
||||
"""Generate WireGuard public/private key pair."""
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
|
@ -124,10 +103,84 @@ def _inject_private_key(config: str, private_key: str) -> str:
|
|||
return re.sub(expression, r'\1 ' + private_key, config)
|
||||
|
||||
|
||||
def insert_mtu(mtu: int, wireguard_config: str) -> str:
|
||||
# This regex finds the DNS line and the whitespace following it,
|
||||
# specifically when that whitespace is followed by the [Peer] section.
|
||||
# Group 1: The DNS assignment line
|
||||
# Group 2: The whitespace (including newlines) between DNS and [Peer]
|
||||
# Group 3: The [Peer] header
|
||||
pattern = r"(DNS\s*=\s*[^\n]+)(\s*)(\[Peer\])"
|
||||
|
||||
# We insert the MTU line immediately after the DNS line (\1),
|
||||
# then preserve the original whitespace (\2) and the [Peer] header (\3).
|
||||
replacement = rf"\1\nMTU = {mtu}\2\3"
|
||||
|
||||
return re.sub(pattern, replacement, wireguard_config)
|
||||
|
||||
|
||||
# If need it pretty spacing quickly:
|
||||
def expand_newlines(text):
|
||||
"""
|
||||
Removes leading/trailing whitespace and ensures exactly one
|
||||
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,
|
||||
|
|
|
|||
86
core/services/networking/systemwide/wireguard/mtu_tools.py
Normal file
86
core/services/networking/systemwide/wireguard/mtu_tools.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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
|
||||
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"]
|
||||
|
||||
def test_an_mtu(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:
|
||||
return Result(valid=False)
|
||||
|
||||
command = ["ping", "-M", "do", "-s", str(payload_size), "-c", "1", url]
|
||||
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).
|
||||
"""
|
||||
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)
|
||||
|
||||
if result.valid:
|
||||
optimal = mid
|
||||
low = mid + 1
|
||||
current_timeout += 1 # Increase timeout as we get closer to max
|
||||
else:
|
||||
high = mid - 1
|
||||
|
||||
return optimal
|
||||
|
||||
def loop_through_testing_mtu():
|
||||
# The sequence to probe the range
|
||||
INITIAL_TEST_LIST = [MAX_MTU, MIDDLE_MTU, MIN_MTU]
|
||||
|
||||
for url in URLS:
|
||||
logger.info(f"Testing connectivity using URL: {url}")
|
||||
|
||||
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.")
|
||||
|
||||
logger.critical("All URLs failed MTU tests. Check your WireGuard connection.")
|
||||
return None
|
||||
|
|
@ -185,5 +185,10 @@ def parse_errors(error_output: str) -> ResultError:
|
|||
elif "command not found" in error_output:
|
||||
return ResultError.MISSING_DEPENDENCY
|
||||
|
||||
elif "local error: message too long" in error_output:
|
||||
return ResultError.MTU
|
||||
|
||||
else:
|
||||
return ResultError.UNKNOWN
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue