EncryptedProxy configuration path setup, but not yet functional. Pydantic models for each protocol are being setup.

This commit is contained in:
SimplifiedPrivacy 2026-08-06 20:58:36 -04:00
parent 94c4155d60
commit 320037a2b1
11 changed files with 382 additions and 34 deletions

View file

@ -1,5 +1,10 @@
# Major Change Log: # Major Change Log:
# EncryptedProxy Configs
### Aug 6, 2026
EncryptedProxy configuration path setup, but not yet functional. Pydantic models for each protocol are being setup.
<br/>
# Transition # Transition
### Aug 6, 2026 ### Aug 6, 2026
1) Transition ticket prep from old requests-based GET/POST system to the new httpx one. 1) Transition ticket prep from old requests-based GET/POST system to the new httpx one.

View file

@ -12,6 +12,7 @@ class ResultError(Enum):
MISSING_FILE = "missing_file" MISSING_FILE = "missing_file"
MISSING_DEPENDENCY = "missing_dependency" MISSING_DEPENDENCY = "missing_dependency"
MISSING_DATA = "missing_data" MISSING_DATA = "missing_data"
FILE_SYSTEM = "filesystem"
CONNECTION = "connection" CONNECTION = "connection"
DATABASE = "database" DATABASE = "database"
PERMISSION = "permission" PERMISSION = "permission"
@ -24,6 +25,7 @@ class ResultError(Enum):
EXTERNAL_DNS = "external_dns" EXTERNAL_DNS = "external_dns"
INTERFACE = "interface" INTERFACE = "interface"
TIMEOUT = "timeout" TIMEOUT = "timeout"
INVALID_API_REPLY = "invalid_api_reply"
UNKNOWN = "unknown" UNKNOWN = "unknown"
@dataclass @dataclass

View file

@ -0,0 +1,96 @@
from core.models.HysteriaConfig import HysteriaConfig
from core.errors.logger import logger
# generic
import os
import json
from pathlib import Path
from pydantic import BaseModel
import subprocess
def save_to_sudo_folder(model: BaseModel, filepath: str) -> bool:
"""
Save model to a sudo-protected file using pkexec.
"""
try:
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
json_data = model.model_dump_json(indent=2)
# Use pkexec + tee to write with elevated privileges
process = subprocess.Popen(
('pkexec', 'tee', filepath),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
stdout, stderr = process.communicate(input=json_data)
if process.returncode != 0:
logger.error(f"Error: {stderr}")
return False
logger.info(f"Saved to {filepath} (elevated)")
return True
except FileNotFoundError as e:
logger.error(f"Error: Parent directory doesn't exist: {filepath}")
return False
except PermissionError as e:
logger.error(f"Error: Permission denied writing to {filepath}")
return False
except OSError as e:
logger.error(f"Error: OS error (disk full?): {e}")
return False
except TypeError as e:
logger.error(f"Error: Model serialization failed (invalid type): {e}")
return False
except Exception as e:
logger.error(f"Error: Unexpected error saving {filepath}: {e}")
return False
def save_to_regular_folder(model: BaseModel, filepath: str) -> bool:
"""
Purpose:
Serialize a Pydantic model to JSON file WITHOUT sudo.
Returns:
Returns True on success,
False on failure.
"""
try:
# Ensure parent directory exists
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w') as f:
f.write(model.model_dump_json(indent=2))
return True
except FileNotFoundError as e:
logger.error(f"Error: Parent directory doesn't exist: {filepath}")
return False
except PermissionError as e:
logger.error(f"Error: Permission denied writing to {filepath}")
return False
except OSError as e:
logger.error(f"Error: OS error (disk full?): {e}")
return False
except TypeError as e:
logger.error(f"Error: Model serialization failed (invalid type): {e}")
return False
except Exception as e:
logger.error(f"Error: Unexpected error saving {filepath}: {e}")
return False

View file

@ -0,0 +1,42 @@
# generic
from pydantic import BaseModel, field_validator, ValidationError, HttpUrl
from sqlalchemy.orm import Session
from pydantic import model_validator, ValidationInfo
from typing_extensions import Self
from ipaddress import IPv4Address
from pydantic_core import PydanticUndefinedType
class HysteriaData(BaseModel):
model_config = ConfigDict(extra="allow")
username: str
password: str
operator_hysteria2_host: HttpUrl
# operator_id: int
server_ip: IPv4Address
location_country_code: str
location_city_code: str
@model_validator(mode='before')
@classmethod
def denormalize(cls, data):
if isinstance(data, dict) and 'operator' in data:
return {
'server_ip': data['operator'].get('id'),
'api_url': data['operator'].get('domain'),
'operator_hysteria2_host': data['operator'].get('hysteria2_host'),
}
return data
# @field_validator('operator_id')
# @classmethod
# def validate_operator_exists(cls, v, info):
# db = info.context.get('db')
# if not db:
# raise ValueError("Database session not provided")
# operator = db.query(Operator).filter(Operator.id == v).first()
# if not operator:
# raise ValueError(f"Operator ID ID {v} does not exist")
# return v

View file

@ -2,6 +2,7 @@ from core.services.networking.general_connection_tools.testing_evaluating import
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
from core.services.subscriptions.subscriptions import activate_subscription from core.services.subscriptions.subscriptions import activate_subscription
# from core.services.networking.systemwide.encrypted_proxy.configure_singbox import configure_singbox
# If refactored to enums: # If refactored to enums:
# from core.models.session.SessionConnection import SessionConnectionTypes # from core.models.session.SessionConnection import SessionConnectionTypes
@ -22,6 +23,8 @@ from core.models.BaseProfile import ProfileType
from core.observers.ConnectionObserver import ConnectionObserver from core.observers.ConnectionObserver import ConnectionObserver
from core.controllers.SystemStateController import SystemStateController from core.controllers.SystemStateController import SystemStateController
from core.services.networking.systemwide.encrypted_proxy.ensure_singbox_configured import ensure_singbox_configured
def establish_connection( def establish_connection(
profile: Union[SessionProfile, SystemProfile], profile: Union[SessionProfile, SystemProfile],
ignore: tuple[type[Exception]] = (), ignore: tuple[type[Exception]] = (),
@ -32,10 +35,21 @@ def establish_connection(
logger.info(f"[CONNECTION] Checking subscription..") logger.info(f"[CONNECTION] Checking subscription..")
activate_subscription(profile, connection_observer) activate_subscription(profile, connection_observer)
# =========================================
# HYSTERIA2 & VLESS
# =========================================
# if profile.connection.code in ("hysteria2", "vless"):
# logger.info("Pulling encrypted proxies off the main flow..")
# configure_singbox(profile, connection_observer)
# =========================================
# SOCKS5 & WIREGUARD
# =========================================
logger.info(f"[CONNECTION] Checking proxy configuration..") logger.info(f"[CONNECTION] Checking proxy configuration..")
_ensure_proxy_configured(profile, connection_observer) _ensure_proxy_configured(profile, connection_observer)
logger.info(f"[CONNECTION] Checking Wireguard configuration..") logger.info(f"[CONNECTION] Checking Wireguard configuration..")
if profile.connection.code == "wireguard":
_ensure_wireguard_configured(profile, connection_observer) _ensure_wireguard_configured(profile, connection_observer)
establish_fn = { establish_fn = {
@ -131,6 +145,7 @@ 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

@ -26,7 +26,7 @@ import httpx
_port_used = None _port_used = None
def single_endpoint(method: str, url: str, observer: ConnectionObserver, payload: dict = None) -> ApiResponse: def single_endpoint(method: str, url: str, observer: ConnectionObserver, payload: dict = None, billing_code: str = None) -> ApiResponse:
""" """
Rank: Rank:
Orchestrator Orchestrator
@ -66,7 +66,8 @@ def single_endpoint(method: str, url: str, observer: ConnectionObserver, payload
method=method, method=method,
url=url, url=url,
client=client, client=client,
payload=payload payload=payload,
billing_code=billing_code
) )
if initial_result.valid: if initial_result.valid:
return initial_result return initial_result
@ -182,7 +183,8 @@ def bootstrap_and_try_again(method: str, url: str, observer: ConnectionObserver,
method=method, method=method,
url=url, url=url,
client=client, client=client,
payload=payload payload=payload,
billing_code=billing_code
) )
@ -335,7 +337,7 @@ def custom_dns_resolver_for_BULK_THREADING(dns_problem_list: list, observer: Con
return parallel_thread(client, desired_endpoints=dns_endpoints) # threading returns a dict! return parallel_thread(client, desired_endpoints=dns_endpoints) # threading returns a dict!
def custom_dns_resolver_for_SINGLE_ENDPOINT(method: str, url: str, observer: ConnectionObserver, payload: dict = None) -> dict: def custom_dns_resolver_for_SINGLE_ENDPOINT(method: str, url: str, observer: ConnectionObserver, payload: dict = None, billing_code: str = None) -> dict:
######################################################## ########################################################
# MAKE DNS RESOLVER # MAKE DNS RESOLVER
######################################################## ########################################################
@ -362,7 +364,8 @@ def custom_dns_resolver_for_SINGLE_ENDPOINT(method: str, url: str, observer: Con
method=method, method=method,
url=url_with_ip, url=url_with_ip,
client=client, client=client,
payload=payload payload=payload,
billing_code=billing_code
) )

View file

@ -15,6 +15,7 @@ def make_request(
url: str, url: str,
client: httpx.Client, client: httpx.Client,
payload: Optional[dict] = None, payload: Optional[dict] = None,
billing_code: Optional[str] = None,
) -> ApiResponse: ) -> ApiResponse:
if method == "post" and not payload: if method == "post" and not payload:
@ -39,8 +40,10 @@ def make_request(
method=method, method=method,
url=url, url=url,
client=client, client=client,
payload=payload payload=payload,
) billing_code=billing_code
),
if second_result.valid: if second_result.valid:
return second_result return second_result
@ -72,13 +75,27 @@ def _make_request(
url: str, url: str,
client: httpx.Client, client: httpx.Client,
payload: Optional[dict] = None, payload: Optional[dict] = None,
billing_code: Optional[str] = None,
) -> ApiResponse: ) -> ApiResponse:
logger.debug(f"Executing {method.upper()} to {url}") logger.debug(f"Executing {method.upper()} to {url}")
try:
if method.lower() == "get": # ========== HEADERS ==========
response = client.get(url) if billing_code:
custom_headers = {'X-Billing-Code': billing_code}
else: else:
response = client.post(url, json=payload) custom_headers = None
try:
# ========== GET ==========
if method.lower() == "get":
response = client.get(url, headers=custom_headers)
# ========== POST ==========
else:
response = client.post(
url,
json=payload,
headers=custom_headers
)
return classify_response(response) return classify_response(response)

View file

@ -0,0 +1,142 @@
from core.models.orm_calls.location_calls import get_profile_location_data
from core.services.networking.tor_tools import ports
from core.services.networking.httpx import connect
from core.services.networking.encrypted_proxy.hysteria2_config import build_hysteria_config
# Models
from core.models.pydantic_models.HysteriaData import HysteriaData
from core.models.Result import Result, ResultError
# from core.models.BaseProfile import BaseProfile
from core.models.session.SessionProfile import SessionProfile
from core.models.system.SystemProfile import SystemProfile
from core.models.manage.session_management import get_session
from core.models.manage.pydantic_management import pydantic_management
# errors & observers
from core.Constants import Constants
from core.errors.logger import logger
from core.Errors import MissingSubscriptionError
from core.observers.ConnectionObserver import ConnectionObserver
# generic
from pydantic import ValidationError
from typing import Union, Optional
def configure_singbox(
profile: Union[SessionProfile, SystemProfile],
connection_observer: Optional[ConnectionObserver] = None,
) -> Result:
###################################
# PREP PAYLOADS
###################################
protocol = profile.connection.code
profile_sudo_filepath = profile.get_system_config_path()
profile_regular_filepath = profile.get_config_path()
operator_id = profile.location.operator_id
logger.info(f"We're doing the protocol {protocol}, operator id of {operator_id}, and have a system path of {profile_regular_filepath}")
if not profile.has_subscription():
raise MissingSubscriptionError()
url = f"{Constants.SP_API_BASE_URL}/subscriptions/current/operator-proxies"
payload = {
'operator_id': operator_id,
'protocol': protocol,
}
###################################
# SEND TO THE API
###################################
logger.info("Sending to the API..")
config_results = connect.single_endpoint(
method="post",
url=url,
observer=connection_observer,
payload=payload,
billing_code=profile.subscription.billing_code
)
###################################
# VERIFY THE API'S REPLY
###################################
# this is a bad API reply, and NOT a subscription error:
if not config_results.valid:
return config_results
raw_response = config_results.data
# extract 'data' out of reply:
data = raw_response.get('data', raw_response)
logger.info(f"We got back from the API: {data}")
if not data:
return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, error_msg="Server replied with blank or invalid data.")
###################################
# VERIFY LOCATION
###################################
location_country_code= data.get('location_country_code')
location_city_code= data.get('location_city_code')
matched_location = get_profile_location_data(
country_code=location_country_code,
city_code=location_city_code
)
if not matched_location:
return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, error_msg="Server replied with a location that doesn't match your sync data.")
if profile.location != matched_location:
error_msg = f"Profile's location doesn't match. Your local data is {profile.location.country_code}_{profile.location.city_code} compared to server's {location_country_code}_{location_city_code}"
return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, error_msg=error_msg)
logger.info(f"The location {profile.location.country_code} matched our local SQL")
###################################
# VERIFY/SETUP/SAVE PYDANTIC MODEL
###################################
session = get_session()
try:
if protocol == "hysteria2":
validated_data = HysteriaData(**data, context={"db": session})
logger.info(f"We created the model for {protocol}.")
except ValidationError as e:
for error in e.errors():
if error['type'] == 'missing':
error_msg = f"Required field '{error['loc'][0]}' is missing"
logger.error(error_msg)
else:
error_msg = f"Field '{error['loc'][0]}' error: {error['msg']}"
logger.error(error_msg)
return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, error_msg=error_msg)
saved_raw_data = pydantic_management.save_to_regular_folder(validated_data, f"{profile_regular_filepath}/raw_setup.json")
logger.info(f"Saved the raw data? {saved_raw_data}")
###################################
# PREP REAL CONFIG
###################################
random_port = ports.get_random_available_port()
if protocol == "hysteria2":
real_config = build_hysteria_config(
username=validated_data.username,
password=validated_data.password,
server_host=validated_data.operator_hysteria2_host,
socks5_port=random_port,
server_ip=validated_data.server_ip
)
###################################
# SAVE REAL CONFIG
###################################
# goes in a sudo protected folder & prompts for password:
saved = pydantic_management.save_to_sudo_folder(real_config, f'{profile_sudo_filepath}/config.json')
if saved:
logger.info("Successfully saved the config.")
return Result(valid=True)
else:
error_msg = "Could not save the configuration."
return Result(valid=False, error_type=ResultError.FILE_SYSTEM, error_msg=error_msg)

View file

@ -5,7 +5,10 @@ from core.models.Result import Result, ResultError
from core.errors.logger import logger from core.errors.logger import logger
from core.services.networking.systemwide.encrypted_proxy import singbox from core.services.networking.systemwide.encrypted_proxy import singbox
from core.utils.basic_operations import process_tools from core.utils.basic_operations import process_tools
import subprocess from core.utils.run_commands import run_generic_command
# import subprocess
from typing import Callable, cast from typing import Callable, cast
import time import time
@ -28,7 +31,7 @@ def _try_with_permission_fallback(operation: Callable, interface: str) -> Result
try: try:
return operation() return operation()
except SudoScript as e: except SudoScript as e:
logger.error("The Sudo Scripts giving power to kill this are being denied permission, before we flag this, let's see if the proxy is active, which does NOT need permission to check,") logger.error(f"The Sudo Scripts giving power to kill this are being denied permission, before we flag this, let's see if the proxy is active, which does NOT need permission to check, {e}")
existance = check_interface_exists(interface) existance = check_interface_exists(interface)
if not existance.valid and existance.error_type == ResultError.INTERFACE: if not existance.valid and existance.error_type == ResultError.INTERFACE:
not_existing = "Permission denied, but the proxy interface is down, so this is acceptable" not_existing = "Permission denied, but the proxy interface is down, so this is acceptable"
@ -97,9 +100,13 @@ def _shut_down_by_known_process_id(process_id: int) -> Result:
def get_any_pid_with_the_phrase(which_application: str) -> Result: def get_any_pid_with_the_phrase(which_application: str) -> Result:
result = subprocess.run(['pgrep', '-a', which_application], capture_output=True, text=True) # result = subprocess.run(['pgrep', '-a', which_application], capture_output=True, text=True)
if result.returncode == 0: command = ['pgrep', '-a', which_application]
lines = result.stdout.strip().split('\n') human_readable_goal = "Find a pid by the phrase"
result = run_generic_command(command, human_readable_goal, timeout=7)
if result.valid:
lines = result.data.strip().split('\n')
pid_data = [{'pid': int(line.split()[0]), 'command': line} for line in lines if line] pid_data = [{'pid': int(line.split()[0]), 'command': line} for line in lines if line]
if pid_data: if pid_data:
return Result(valid=True, data=pid_data) # List of dicts with pid and full command return Result(valid=True, data=pid_data) # List of dicts with pid and full command
@ -111,10 +118,14 @@ def get_any_pid_with_the_phrase(which_application: str) -> Result:
# Function is public because it works for any app # Function is public because it works for any app
def get_pid_by_exact_match(which_application: str) -> Result: def get_pid_by_exact_match(which_application: str) -> Result:
result = subprocess.run(['pgrep', '-x', which_application], capture_output=True, text=True) # result = subprocess.run(['pgrep', '-x', which_application], capture_output=True, text=True)
if result.returncode == 0: command = ['pgrep', '-x', which_application]
human_readable_goal = "Get a pid by an exact match"
result = run_generic_command(command, human_readable_goal, timeout=7)
if result.valid: # equivalent: (returncode == 0):
try: try:
process_id = int(result.stdout.strip()) # Strip newline, convert to int process_id = int(result.data.strip()) # Strip newline, convert to int
return Result(valid=True, data=process_id) return Result(valid=True, data=process_id)
except ValueError: except ValueError:
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Could not parse PID") return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Could not parse PID")

View file

@ -13,8 +13,8 @@ from essentials.observers.ConnectionObserver import ConnectionObserver
# generic # generic
import subprocess
import time import time
from typing import Optional
QUANTITY_OF_ATTEMPTS = 2 QUANTITY_OF_ATTEMPTS = 2
@ -56,22 +56,26 @@ def end_singbox(
connection_observer: Optional[ConnectionObserver] = None connection_observer: Optional[ConnectionObserver] = None
) -> Result: ) -> Result:
function_name = "END_SINGBOX"
current_state = SystemState.get() current_state = SystemState.get()
if not current_state: if not current_state:
return Result(valid=False, error_type=ResultError.MISSING_FILE, message="Already disabled or missing State JSON.") return Result(valid=False, error_type=ResultError.MISSING_FILE, message="Already disabled or missing State JSON.")
dead_singbox = orchestrate_closing( # kill the real singbox
try:
killed_existing = orchestrate_closing(
current_state=current_state, current_state=current_state,
interface_name=Constants.SINGBOX_TUN_IF interface_name=Constants.SINGBOX_TUN_IF
) )
except RuntimeError as e: # Interface error raised by process_closure_tool's is_tunnel_active
return Result(valid=False, error_type=ResultError.INTERFACE, error_msg=str(e))
if dead_singbox.valid: # Wipe the JSON to reflect reality,
if killed_existing.valid:
logger.info("Successfully took down Singbox tunnel") logger.info("Successfully took down Singbox tunnel")
SystemState.dissolve() SystemState.dissolve()
return Result(valid=True)
else:
return Result(valid=False, error_type=ResultError.SINGBOX, error_msg="Could not disable singbox")
def start_singbox( def start_singbox(
profile_id: int, profile_id: int,
@ -101,6 +105,11 @@ def start_singbox(
if each_requirement is None: if each_requirement is None:
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Invalid inputs into {function_name}") return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Invalid inputs into {function_name}")
# ========== KILL IT IF ALREADY UP ==========
killed_pre_existing = end_singbox(connection_observer)
if not killed_pre_existing.valid:
return killed_pre_existing
# ============= START PROCESS ============= # ============= START PROCESS =============
activation_result = _attempt_start_with_retry(config_path=config_path, quantity_of_attempts=QUANTITY_OF_ATTEMPTS) activation_result = _attempt_start_with_retry(config_path=config_path, quantity_of_attempts=QUANTITY_OF_ATTEMPTS)
@ -121,7 +130,7 @@ def start_singbox(
# ============= SETUP STATE ============= # ============= SETUP STATE =============
# Even if firewall is off, we want to save the fact we turned Singbox on, before we raise errors. # Even if firewall is off, we want to save the fact we turned Singbox on, before we raise errors.
logger.info(f"Setting State JSON with INTENDED firewall & Dns settings") logger.info("Setting State JSON with INTENDED firewall & Dns settings")
current_state = SystemStateController.create( current_state = SystemStateController.create(
profile_id=profile_id, profile_id=profile_id,
firewalled=True, # intended setting, not result yet firewalled=True, # intended setting, not result yet

View file

@ -1,18 +1,24 @@
from core.models.Result import Result, ResultError from core.models.Result import Result, ResultError
from core.utils.run_commands import run_generic_command
import subprocess import subprocess
import re import re
def check_interface_exists(interface: str) -> Result: def check_interface_exists(interface: str) -> Result:
result = subprocess.run(['ip', 'link', 'show', interface], capture_output=True) command = ['ip', 'link', 'show', interface]
if result.returncode != 0: # result = subprocess.run(, capture_output=True)
human_readable_goal = "Checking if the interface exists"
result = run_generic_command(command, human_readable_goal, timeout=5)
if not result.valid:
return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message=f"We could not run the command to even check the interface. {result.stdout}") return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message=f"We could not run the command to even check the interface. {result.stdout}")
elif 'does not exist' in result.stderr.decode():
elif 'does not exist' in result.data: # .stderr.decode() was old version
return Result(valid=False, error_type=ResultError.INTERFACE, message="Interface does not exist.") return Result(valid=False, error_type=ResultError.INTERFACE, message="Interface does not exist.")
else: else:
# Check if interface name exists in the expected format # Check if interface name exists in the expected format
# Pattern: "digits: interface_name: <flags>" # Pattern: "digits: interface_name: <flags>"
output = result.stdout.decode() output = result.data #.decode()
pattern = rf'^\d+:\s+{re.escape(interface)}:\s+<[^>]+>' pattern = rf'^\d+:\s+{re.escape(interface)}:\s+<[^>]+>'
if re.search(pattern, output, re.MULTILINE): if re.search(pattern, output, re.MULTILINE):
return Result(valid=True, data=output) return Result(valid=True, data=output)