diff --git a/.gitignore b/.gitignore index 8b3c82c..2a3281d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env .dev prototype_client.py .idea diff --git a/change_log.md b/change_log.md index 07a995e..5b4ecc1 100644 --- a/change_log.md +++ b/change_log.md @@ -1,5 +1,19 @@ # Major Change Log: +# Singbox Orchestration +### Aug 10, 2026 +Significant progress made on singbox flow, we now have a working: + +1) Configure via singbox_configure +2) Enable/disable directly via singbox module +3) Enable/disable with full orchestration via singbox_runner +4) process id utils isolated +5) Singbox takedown uses JSON & process id, but not yet coordinating a mismatch. + +Also in this update: +Transition traditional wireguard subscription post/get requests to the new HTTPx system. + + # WG Renegotiation ### Aug 7, 2026 Wireguard renegotiation now flows through the new HTTPx modules diff --git a/core/Constants.py b/core/Constants.py index e860d33..92bda10 100644 --- a/core/Constants.py +++ b/core/Constants.py @@ -78,6 +78,7 @@ class Constants: SINGBOX_TUN_IF: Final[str] = os.environ.get('SINGBOX_TUN_IF', 'tun0') SINGBOX_INTERNAL_SUBNET: Final[str] = os.environ.get('SINGBOX_INTERNAL_SUBNET', '172.19.0.0/30') SINGBOX_INTERNAL_ADDR: Final[str] = os.environ.get('SINGBOX_INTERNAL_ADDR', '172.19.0.1/30') + SINGBOX_DEFAULT_DNS: Final[str] = "9.9.9.9" # ── Tor ───────────────────────────────────────────── DEFAULT_TOR_PORT = 9050 diff --git a/core/assets/sudo_scripts/singbox_runner b/core/assets/sudo_scripts/singbox_runner deleted file mode 100644 index 22d4f68..0000000 --- a/core/assets/sudo_scripts/singbox_runner +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash -set -eo pipefail - -SINGBOX_BIN="/usr/bin/singbox" -LOG_FILE="~/.local/share/hydra-veil/singbox.txt" - -run_binary() { - "$SINGBOX_BIN" run -c "$1" >> "$LOG_FILE" 2>&1 & - _new_pid=$! - echo "$_new_pid" -} -# get result in python: pid = int(result.stdout.strip()) - -gracefully_close() { - sudo kill -TERM "$1" 2>/dev/null || true -} - -forcefully_kill() { - sudo kill -TERM "$1" 2>/dev/null || true -} - -hello_world_test() { - echo "hello world $1" -} - - diff --git a/core/assets/sudo_scripts/singbox_wrapper b/core/assets/sudo_scripts/singbox_wrapper new file mode 100644 index 0000000..77ca8c5 --- /dev/null +++ b/core/assets/sudo_scripts/singbox_wrapper @@ -0,0 +1,36 @@ +#!/bin/bash +set -eo pipefail + +SINGBOX_BIN="/opt/hydra-veil/sing-box" +LOG_FILE="/tmp/hydra-veil/sing-box-output.txt" + +ACTION="${1:-}" +PROFILE_OR_PID="${2:-}" + +run_binary() { + "$SINGBOX_BIN" run -c /etc/hydra-veil/profiles/"$PROFILE_OR_PID"/proxy.json >> "$LOG_FILE" 2>&1 & + _new_pid=$! + echo "$_new_pid" +} +# get result in python: pid = int(result.stdout.strip()) + +gracefully_close() { + sudo kill -TERM "$PROFILE_OR_PID" 2>/dev/null || true +} + +forcefully_kill() { + sudo kill -TERM "$PROFILE_OR_PID" 2>/dev/null || true +} + + +if [[ "$ACTION" == "arm" ]]; then + run_binary +fi + +if [[ "$ACTION" == "disarm" ]]; then + gracefully_close +fi + +if [[ "$ACTION" == "kill" ]]; then + forcefully_kill +fi \ No newline at end of file diff --git a/core/controllers/ProfileController.py b/core/controllers/ProfileController.py index 1dae643..4c08c6f 100644 --- a/core/controllers/ProfileController.py +++ b/core/controllers/ProfileController.py @@ -3,6 +3,7 @@ from core.services.networking.general_connection_tools.testing_evaluating import from core.services.networking.general_connection_tools.connection_enable import establish_connection from core.services.networking.systemwide.systemwide_utils import get_firewall_setting, get_dns_setting from core.services.networking.systemwide import killswitch +from core.services.subscriptions import subscriptions from core.errors.exceptions import FirewallError from core.models.Result import Result, ResultError @@ -202,7 +203,13 @@ class ProfileController: if profile.has_subscription(): - subscription = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_subscription, connection_observer=connection_observer) + subscription = subscriptions.get_subscription( + billing_code=profile.subscription.billing_code, + connection_observer=connection_observer + ) + + # legacy: + # subscription = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_subscription, connection_observer=connection_observer) if subscription is not None: @@ -238,6 +245,7 @@ class ProfileController: def get_invoice(profile: Union[SessionProfile, SystemProfile]): if profile.has_subscription(): + # return subscriptions.get_invoice(billing_code=profile.subscription.billing_code) return WebServiceApiService.get_invoice(profile.subscription.billing_code) else: return None diff --git a/core/models/manage/pydantic_manager.py b/core/models/manage/pydantic_manager.py index 1f65dbb..8e375f2 100644 --- a/core/models/manage/pydantic_manager.py +++ b/core/models/manage/pydantic_manager.py @@ -1,4 +1,4 @@ -from core.models.HysteriaConfig import HysteriaConfig +from core.models.pydantic_models.HysteriaData import HysteriaData from core.errors.logger import logger # generic @@ -57,7 +57,7 @@ def save_to_sudo_folder(model: BaseModel, filepath: str) -> bool: -def save_to_regular_folder(model: BaseModel, filepath: str) -> bool: +def save(model: BaseModel, filepath: str) -> bool: """ Purpose: Serialize a Pydantic model to JSON file WITHOUT sudo. @@ -67,6 +67,8 @@ def save_to_regular_folder(model: BaseModel, filepath: str) -> bool: False on failure. """ try: + logger.info(f"Saving to {filepath}") + # Ensure parent directory exists Path(filepath).parent.mkdir(parents=True, exist_ok=True) diff --git a/core/models/pydantic_models/HysteriaData.py b/core/models/pydantic_models/HysteriaData.py index ed46066..f686bd1 100644 --- a/core/models/pydantic_models/HysteriaData.py +++ b/core/models/pydantic_models/HysteriaData.py @@ -1,42 +1,57 @@ # generic -from pydantic import BaseModel, field_validator, ValidationError, HttpUrl +from pydantic import BaseModel, field_validator, ValidationError, HttpUrl, ConfigDict, AnyUrl 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 +import validators class HysteriaData(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="ignore") username: str password: str - operator_hysteria2_host: HttpUrl - # operator_id: int + hysteria2_host: str server_ip: IPv4Address location_country_code: str - location_city_code: str + location_city_code: int + operator_id: int + # operator_name: str = None + operator_domain: str = None @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'), + **data, + 'operator_id': data['operator'].get('id'), + 'operator_domain': data['operator'].get('domain'), + '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 \ No newline at end of file + @field_validator('hysteria2_host', mode='before') + @classmethod + def validate_domain(cls, v): + if isinstance(v, str): + if not validators.domain(v): + raise ValueError('Invalid domain') + return v + + +# likely future addition: +# # @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 + diff --git a/core/models/pydantic_models/VlessData.py b/core/models/pydantic_models/VlessData.py new file mode 100644 index 0000000..d5c07b3 --- /dev/null +++ b/core/models/pydantic_models/VlessData.py @@ -0,0 +1,44 @@ +# generic +from pydantic import BaseModel, field_validator, ValidationError, HttpUrl, ConfigDict, AnyUrl +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 +import validators + +class VlessData(BaseModel): + model_config = ConfigDict(extra="ignore") + + username: str = None + links: str # vless link + subscription_url: HttpUrl + server_ip: IPv4Address + location_country_code: str + location_city_code: int + operator_id: int + operator_domain: str = None # redundant + vless_host: str = None # redundant + + @model_validator(mode='before') + @classmethod + def denormalize(cls, data): + if isinstance(data, dict) and 'operator' in data: + return { + **data, + 'operator_id': data['operator'].get('id'), + 'operator_domain': data['operator'].get('domain'), + 'vless_host': data['operator'].get('vless_host'), + } + return data + + @field_validator('links', mode='before') + @classmethod + def validate_link(cls, v): + if isinstance(v, list): + v = v[0] + + if not isinstance(v, str) or not v.startswith('vless://'): + raise ValueError('links must be a string starting with "vless://"') + + return v \ No newline at end of file diff --git a/core/models/system/SystemProfile.py b/core/models/system/SystemProfile.py index 9a41672..3a60572 100644 --- a/core/models/system/SystemProfile.py +++ b/core/models/system/SystemProfile.py @@ -107,3 +107,29 @@ class SystemProfile(BaseProfile): def __get_system_config_path(id: int): config_path = f'{Constants.HV_SYSTEM_PROFILE_CONFIG_PATH}/{str(id)}' return config_path + + + def attach_encrypted_proxy_config(self, config_data: dict): + + if shutil.which('pkexec') is None: + raise CommandNotFoundError('pkexec') + + backup_path = f'{self.get_config_path()}/proxy.conf.bak' + + with open(backup_path, 'w') as configuration_file: + configuration_file.write(config_data) + + wireguard_configuration_is_attached = False + failed_attempt_count = 0 + + while not wireguard_configuration_is_attached and failed_attempt_count < 3: + + process = subprocess.Popen(('pkexec', 'install', '-D', wireguard_configuration_file_backup_path, self.get_wireguard_configuration_path(), '-o', 'root', '-m', '744')) + wireguard_configuration_is_attached = not bool(os.waitpid(process.pid, 0)[1] >> 8) + + if not wireguard_configuration_is_attached: + failed_attempt_count += 1 + + if not wireguard_configuration_is_attached: + raise ProfileModificationError('The WireGuard configuration could not be attached.') + diff --git a/core/services/networking/api_requests/ApiResponseModel.py b/core/services/networking/api_requests/ApiResponseModel.py index 26b14f0..87b9f71 100644 --- a/core/services/networking/api_requests/ApiResponseModel.py +++ b/core/services/networking/api_requests/ApiResponseModel.py @@ -14,7 +14,7 @@ class BackoffStrategy(Enum): class ErrorType(Enum): """Classified error categories.""" SUCCESS = "success" - INVALID_ENDPOINT = "invalid_endpoint" # 404 - wrong URL + INVALID_ENDPOINT = "invalid_endpoint" # 404 - could be wrong URL or middleware rejecting INVALID_REQUEST = "invalid_request" # 400, 405 - bad method/body AUTHENTICATION_ERROR = "authentication_error" # 401 - need credentials AUTHORIZATION_ERROR = "authorization_error" # 403 - no permission @@ -41,7 +41,7 @@ class ErrorType(Enum): NO_INTERNET = "no_internet" CONNECTION_ERROR = "connection_error" INVALID_INPUT = "invalid_input" - PERMISSION_ERROR = "permission_error" # duplicate + PERMISSION_ERROR = "permission_error" DEVELOPER_ERROR = "developer_error" PORT_NOT_LISTENING = "port_not_listening" UNKNOWN = "unknown" diff --git a/core/services/networking/general_connection_tools/connection_enable.py b/core/services/networking/general_connection_tools/connection_enable.py index 4e093d4..56dd470 100644 --- a/core/services/networking/general_connection_tools/connection_enable.py +++ b/core/services/networking/general_connection_tools/connection_enable.py @@ -1,15 +1,16 @@ from core.services.networking.general_connection_tools.testing_evaluating import system_uses_wireguard_interface from core.services.networking.systemwide.systemwide_wireguard import establish_system_connection, terminate_system_connection from core.services.keys_and_verifications.wireguard_keys import register_wireguard_session -from core.services.subscriptions.subscriptions import activate_subscription -# from core.services.networking.systemwide.encrypted_proxy.configure_singbox import configure_singbox +from core.services.subscriptions.subscriptions import activate_subscription, get_encrypted_proxy_billing_code +from core.services.networking.systemwide.encrypted_proxy.configure_singbox import configure_singbox +from core.models.Result import Result, ResultError +from core.utils.basic_operations.write_or_read_from_json import get_value_from_json_file # If refactored to enums: # from core.models.session.SessionConnection import SessionConnectionTypes # from core.models.system.SystemConnection import SystemConnectionTypes # wireguard_types = [SystemConnectionTypes.WIREGUARD, SessionConnectionTypes.WIREGUARD] - from core.errors.logger import logger from core.errors.exceptions import * from core.errors.exceptions import FirewallError @@ -23,6 +24,8 @@ from core.models.BaseProfile import ProfileType from core.observers.ConnectionObserver import ConnectionObserver from core.controllers.SystemStateController import SystemStateController +import os + def establish_connection( profile: Union[SessionProfile, SystemProfile], ignore: tuple[type[Exception]] = (), @@ -36,9 +39,9 @@ def establish_connection( # ========================================= # HYSTERIA2 & VLESS # ========================================= - # if profile.connection.code in ("hysteria2", "vless"): - # logger.info("Pulling encrypted proxies off the main flow..") - # configure_singbox(profile, connection_observer) + if profile.connection.code in ("hysteria2", "vless"): + logger.info("Pulling encrypted proxies off the main flow..") + return launch_encrypted_proxy(profile, connection_observer) # ========================================= # SOCKS5 & WIREGUARD @@ -58,11 +61,68 @@ def establish_connection( return _establish_with_renegotiation(profile, establish_fn, ignore, connection_observer) + +def launch_encrypted_proxy( + profile: Union[SessionProfile, SystemProfile], + connection_observer: Optional[ConnectionObserver], +): + """ + Launch & Setup an Encrypted proxy. + + 1) Check if the config exists + 2) If not, set it up + 3) If it does, get the server IP from it + 4) Launch singbox with the config & IP + """ + # do they have a proxy config already? + config_path = f'{profile.get_system_config_path()}/proxy.json' + exists_already = os.path.isfile(config_path) + + if exists_already: + server_ip = get_value_from_json_file(filepath=config_path, category="outbounds", key="server") + else: + server_ip = setup_config_and_return_ip( + profile=profile, + connection_observer=connection_observer + ) + + return start_singbox( + profile_id=profile.id, + config_path=config_path, + server_ip=server_ip, + connection_observer=connection_observer + ) + + +def setup_config_and_return_ip( + profile: Union[SessionProfile, SystemProfile], + connection_observer: Optional[ConnectionObserver], +) -> str | None: + + configured = configure_singbox( + profile=profile, + connection_observer=connection_observer + ) + + if configured.valid: + server_ip = configured.data + return server_ip + else: + reason = configured.error_type + logger.error(f"Could not setup Singbox config because {configured.error_type} and {configured.message}") + + if reason == ResultError.INVALID_API_REPLY: + raise InvalidSubscriptionError() + else: + raise ConnectionError('The connection could not be established.') + return None # redundant + def _ensure_proxy_configured( profile: Union[SessionProfile, SystemProfile], connection_observer: Optional[ConnectionObserver], ): - """Setup proxy config if needed.""" + """Setup UNencrypted regular proxy config if needed.""" + if not profile.connection.needs_proxy_configuration(): return @@ -144,6 +204,7 @@ def __should_renegotiate(profile: Union[SessionProfile, SystemProfile]): + # Enums # if profile.type == ProfileType.SYSTEM: # print("This is a system profile") diff --git a/core/services/networking/systemwide/encrypted_proxy/process_closure_tools.py b/core/services/networking/systemwide/encrypted_proxy/close_singbox.py similarity index 65% rename from core/services/networking/systemwide/encrypted_proxy/process_closure_tools.py rename to core/services/networking/systemwide/encrypted_proxy/close_singbox.py index 858b8b7..324b5c2 100644 --- a/core/services/networking/systemwide/encrypted_proxy/process_closure_tools.py +++ b/core/services/networking/systemwide/encrypted_proxy/close_singbox.py @@ -1,4 +1,5 @@ -from core.services.networking.systemwide.general_tools.interface_tools import check_interface_exists, check_interface_up +from core.services.networking.systemwide.general_tools import interface_tools +from core.utils.basic_operations import pid_tools from core.errors.exceptions import SudoScript from core.models.system.SystemState import SystemState from core.models.Result import Result, ResultError @@ -7,32 +8,19 @@ from core.services.networking.systemwide.encrypted_proxy import singbox from core.utils.basic_operations import process_tools from core.utils.run_commands import run_generic_command - -# import subprocess from typing import Callable, cast import time KILL_WAIT_TIME = 1.3 APP_NAME = 'sing-box' - -def is_tunnel_active(interface: str) -> bool: - """Returns True if active, False if inactive. Raises on check failure.""" - result = check_interface_exists(interface) - if result.valid: - return True - elif result.error_type == ResultError.INTERFACE: - return False - else: - raise RuntimeError(f"Could not verify tunnel status: {result.message}") - def _try_with_permission_fallback(operation: Callable, interface: str) -> Result: """Execute operation; if denied, check if tunnel is still active.""" try: return operation() except SudoScript as e: 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 = interface_tools.exists(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" logger.info(not_existing) @@ -57,8 +45,14 @@ def _escalate_kill(pid: int) -> Result: return Result(valid=force.valid, message=f"Force kill {'succeeded' if force.valid else 'failed'}") -def _get_process_id(current_state: SystemState) -> Result: - process_id = current_state.process_id +def get_process_id_from_state(current_state: SystemState) -> Result: + if current_state is None: + return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Missing the State itself.") + + try: + process_id = current_state.process_id + except Exception as e: + return Result(valid=False, error_type=ResultError.MISSING_DATA, message=f"Missing the critical process id. {str(e)}") if not process_id: return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Missing the critical process id.") @@ -72,24 +66,18 @@ def _get_process_id(current_state: SystemState) -> Result: # Function is private because it leverages singbox only functionality. def _shut_down_by_known_process_id(process_id: int) -> Result: - function_name = "_shut_down_by_known_process_id" - - active = process_tools.is_running(process_id) - - if not active: - logger.error(f"[{function_name}] The process ID we have for Singbox is not actually active still.") - return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"The process ID {process_id} Already was down.") - - graceful_close = singbox.turn_off(process_id) + graceful_close = singbox.stop(process_id) if not graceful_close.valid: logger.error(f"[{function_name}] Could not gracefully close it. So we'll {KILL_WAIT_TIME} seconds and kill it.") - still_active = process_tools.is_running(process_id) + still_active = pid_tools.is_running(pid=process_id, process_name="sing-box") if not still_active: - return Result(valid=True, message=f"The process ID {process_id} gracefully closed.") + return Result(valid=True, message=f"The process ID {process_id} gracefully closed or never was running to begin with.") time.sleep(KILL_WAIT_TIME) + logger.info(f"Graceful closed failed, as {process_id} is still running. Escalating to forced killing..") + force_kill = singbox.force_kill(process_id) if force_kill.valid: @@ -98,41 +86,6 @@ def _shut_down_by_known_process_id(process_id: int) -> Result: return Result(valid=False, message=f"Even a force kill couldn't stop the process ID {process_id}.") - -def get_any_pid_with_the_phrase(which_application: str) -> Result: - # result = subprocess.run(['pgrep', '-a', which_application], capture_output=True, text=True) - command = ['pgrep', '-a', which_application] - 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] - if pid_data: - return Result(valid=True, data=pid_data) # List of dicts with pid and full command - else: - return Result(valid=False, error_type=ResultError.MISSING_DATA, message="No processes found") - else: - return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message="pgrep failed") - - -# Function is public because it works for any app -def get_pid_by_exact_match(which_application: str) -> Result: - # result = subprocess.run(['pgrep', '-x', which_application], capture_output=True, text=True) - 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: - process_id = int(result.data.strip()) # Strip newline, convert to int - return Result(valid=True, data=process_id) - except ValueError: - return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Could not parse PID") - else: - return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message="pgrep failed") - - def _hunt_and_kill(interface_name: str) -> Result: """ Purpose: @@ -145,7 +98,7 @@ def _hunt_and_kill(interface_name: str) -> Result: """ function_name = "_hunt_and_kill" - id_results = get_any_pid_with_the_phrase(APP_NAME) + id_results = pid_tools.get_pids_by_phrase(phase=APP_NAME) if id_results.valid: pid_data_list = id_results.data else: @@ -166,7 +119,7 @@ def _hunt_and_kill(interface_name: str) -> Result: logger.info(f"[{function_name}] PID {pid} force killed successfully") # EVALUATION: Did killing this PID stop the app? is the interface still running? - existance = check_interface_exists(interface=interface_name) + existance = interface_tools.exists(interface=interface_name) if not existance.valid: logger.info(f"[{function_name}] App/Tunnel is fully stopped after killing PID {pid}") return Result(valid=True, message=f"App stopped after killing PID {pid}") @@ -182,7 +135,7 @@ def _hunt_and_kill(interface_name: str) -> Result: def _try_shutdown_by_exact_match() -> Result: - exact_match = get_pid_by_exact_match(APP_NAME) + exact_match = pid_tools.get_pid_by_app_name(exact_app_name=APP_NAME) if exact_match.valid: logger.info(f"Attempting exact match PID {exact_match.data}") return _shut_down_by_known_process_id(cast(int, exact_match.data)) @@ -191,7 +144,7 @@ def _try_shutdown_by_exact_match() -> Result: def _try_known_pid_from_state(current_state: SystemState) -> Result: - pid_query = _get_process_id(current_state) + pid_query = get_process_id_from_state(current_state) if not pid_query.valid: return pid_query return _shut_down_by_known_process_id(cast(int, pid_query.data)) @@ -209,7 +162,7 @@ def orchestrate_closing(current_state: SystemState, interface_name: str) -> Resu result = _try_with_permission_fallback(strategy, interface_name) if result.valid: return result - if not is_tunnel_active(interface_name): + if not interface_tools.exists(interface_name): return Result(valid=True, message="Tunnel inactive despite strategy failure") return Result(valid=False, message=f"All {quantity_of_strategies} strategies tried, we simply can not bring down the proxy, which is still up.") diff --git a/core/services/networking/systemwide/encrypted_proxy/configure_singbox.py b/core/services/networking/systemwide/encrypted_proxy/configure_singbox.py index ba6ef18..7f20951 100644 --- a/core/services/networking/systemwide/encrypted_proxy/configure_singbox.py +++ b/core/services/networking/systemwide/encrypted_proxy/configure_singbox.py @@ -1,26 +1,36 @@ 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 + +from core.services.networking.systemwide.encrypted_proxy.hysteria_config import build_hysteria_config +from core.services.networking.systemwide.encrypted_proxy.vless_config import build_vless_config, parse_vless_link # Models from core.models.pydantic_models.HysteriaData import HysteriaData +from core.models.pydantic_models.VlessData import VlessData +from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType 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 +from core.models.manage import pydantic_manager + # errors & observers from core.Constants import Constants from core.errors.logger import logger from core.Errors import MissingSubscriptionError from core.observers.ConnectionObserver import ConnectionObserver +from core.Errors import ProfileModificationError # generic from pydantic import ValidationError from typing import Union, Optional +import os +import shutil +import subprocess +import json + def configure_singbox( profile: Union[SessionProfile, SystemProfile], @@ -31,112 +41,204 @@ def configure_singbox( # PREP PAYLOADS ################################### protocol = profile.connection.code - profile_sudo_filepath = profile.get_system_config_path() + profile_sudo_filepath = f"{profile.get_system_config_path()}/proxy.json" 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 + config_results = post_operator_proxy( + billing_code=profile.subscription.billing_code, + protocol=protocol, + connection_observer=connection_observer ) - - ################################### - # 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.") + # this is a bad API reply, and not bad data per say. + if not config_results: + return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, message="Server-side API issue.") ################################### - # VERIFY LOCATION + # (SERVER CHOICES) PREP RAW DATA (PYDANTIC MODEL) ################################### - 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.") + # VERIFY: + validation = verify_proxy_data(data=config_results, protocol=protocol) - 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") + if not validation.valid: + return validation + + validated_data = validation.data + logger.info("Server's raw proxy data is validated!") + + # SAVE: + saved_raw_data = pydantic_manager.save(validated_data, f"{profile_regular_filepath}/raw_setup.json") + if saved_raw_data: + logger.info("Saved the Encrypted Proxy Raw Data.") + + # note: The above is the fixed server-side choices, + # which are then combined with client-side choices, to make the final config. ################################### - # 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 + # (CLIENT CHOICES) PREP REAL CONFIG ################################### random_port = ports.get_random_available_port() + # HYSTERIA if protocol == "hysteria2": real_config = build_hysteria_config( username=validated_data.username, password=validated_data.password, - server_host=validated_data.operator_hysteria2_host, + server_host=validated_data.hysteria2_host, socks5_port=random_port, server_ip=validated_data.server_ip ) - + + # VLESS + elif protocol == "vless": + # parse the link: + vless = parse_vless_link(validated_data.links) + + # use the parsed link: + real_config = build_vless_config( + vless=vless, + socks5_port=random_port, + server_ip=validated_data.server_ip + ) + + # UNKNOWN + else: + return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Invalid protocol choice of {protocol}") + ################################### # 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') + saved_it = attach_config_to_sudo_folder( + config_data=real_config, + sudo_filepath=profile_sudo_filepath, + backup_folder=profile_regular_filepath + ) - if saved: - logger.info("Successfully saved the config.") - return Result(valid=True) + if saved_it: + return Result(valid=True, data=validated_data.server_ip) else: - error_msg = "Could not save the configuration." - return Result(valid=False, error_type=ResultError.FILE_SYSTEM, error_msg=error_msg) + return Result(valid=False, error_type=ResultError.PERMISSION, message="User would not allow sudo permission, or it could not save.") + + + +def verify_proxy_data(data: dict, protocol: str) -> Result: + """ + Verify the API's proxy details using a Pydantic model + + Works for both Hysteria2 & VLESS + """ + # session = get_session() + try: + if protocol == "hysteria2": + validated_data = HysteriaData(**data) + elif protocol == "vless": + validated_data = VlessData(**data) + else: + return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Unsupported protocol.") + + logger.info(f"We created the model for {protocol}.") + return Result(valid=True, data=validated_data) + + except ValidationError as e: + missing_fields = [] + for error in e.errors(): + if error['type'] == 'missing': + error_msg = f"Required field '{error['loc'][0]}' is missing" + missing_fields.append(error_msg) + logger.error(error_msg) + else: + error_msg = f"Field '{error['loc'][0]}' error: {error['msg']}" + missing_fields.append(error_msg) + logger.error(error_msg) + + return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, message=f"Missing fields: {missing_fields}") + + +def attach_config_to_sudo_folder( + config_data: dict, + sudo_filepath: str, + backup_folder: str +) -> bool: + + if shutil.which('pkexec') is None: + raise CommandNotFoundError('pkexec') + + backup_path = f'{backup_folder}/backup.conf.bak' + + with open(backup_path, "w") as configuration_file: + json.dump(config_data, configuration_file, indent=4) + + configuration_is_attached = False + failed_attempt_count = 0 + + while not configuration_is_attached and failed_attempt_count < 3: + + process = subprocess.Popen(('pkexec', 'install', '-D', backup_path, sudo_filepath, '-o', 'root', '-m', '744')) + configuration_is_attached = not bool(os.waitpid(process.pid, 0)[1] >> 8) + + if not configuration_is_attached: + failed_attempt_count += 1 + + if not configuration_is_attached: + raise ProfileModificationError('The configuration could not be attached.') + return False # redundant + + return True + + +def post_operator_proxy(billing_code: str, protocol: str, connection_observer: ConnectionObserver) -> dict: + logger.info("Sending to the API..") + + url = f'{Constants.SP_API_BASE_URL}/subscriptions/current/operator-proxies' + payload = { + 'protocol': protocol + } + + api_response = connect.single_endpoint( + method="post", + url=url, + observer=connection_observer, + payload=payload, + billing_code=billing_code + ) + + if api_response.valid: + raw_reply = api_response.data + return raw_reply['data'] + else: + logger.error(f"Invalid API reply: {api_response.error_type}") + # convert the error message here + return False + +# no longer needed: +# 'location_id': location_id, +# 'subscription_plan_id': subscription_plan_id, + + + + +################################### +# 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") + diff --git a/core/services/networking/systemwide/encrypted_proxy/hysteria2_config.py b/core/services/networking/systemwide/encrypted_proxy/hysteria_config.py similarity index 98% rename from core/services/networking/systemwide/encrypted_proxy/hysteria2_config.py rename to core/services/networking/systemwide/encrypted_proxy/hysteria_config.py index 91cc9f4..81671b8 100644 --- a/core/services/networking/systemwide/encrypted_proxy/hysteria2_config.py +++ b/core/services/networking/systemwide/encrypted_proxy/hysteria_config.py @@ -34,7 +34,7 @@ def build_hysteria_config(username: str, password: str, { "type": "hysteria2", "tag": "proxy", - "server": server_ip, + "server": str(server_ip), "server_port": 443, "password": f"{username}:{password}", "tls": { diff --git a/core/services/networking/systemwide/encrypted_proxy/singbox.py b/core/services/networking/systemwide/encrypted_proxy/singbox.py index ad64147..01b6b01 100644 --- a/core/services/networking/systemwide/encrypted_proxy/singbox.py +++ b/core/services/networking/systemwide/encrypted_proxy/singbox.py @@ -3,39 +3,46 @@ from core.utils.basic_operations.wrap_with import wrap_with from core.utils.run_commands import run_generic_command from core.models.Result import Result, ResultError from core.errors.logger import logger +from core.utils.basic_operations.write_string_to_text_file import write_string_to_text_file +from core.Constants import Constants -SINGBOX_WRAPPER = "/opt/hydra-veil/singbox_runner" # needs chmod 755 +from pathlib import Path +import subprocess + +SINGBOX_WRAPPER = "/opt/hydra-veil/singbox-wrapper" # needs chmod 755 +OUTPUT = f"{Constants.HV_DATA_HOME}/sing-box-output.txt" @wrap_with(systemwide_hell_raiser) -def start(config_path: str) -> Result: - command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && run_binary "{config_path}"'] +def start(profile_id: int) -> Result: + # make sure output folder exists: + output_folder = Path(Constants.HV_DATA_HOME) + output_folder.parent.mkdir(parents=True, exist_ok=True) + + # make sure text file exists: + command = ["touch", OUTPUT] + human_readable_goal = "Make output text file" + made_output_file = run_generic_command(command, human_readable_goal, timeout=5) + + if not made_output_file.valid: + logger.error("There was an issue with making the blank file for singbox's output.") + if not write_string_to_text_file(content_to_write="hello world", file_path=OUTPUT): + return Result(valid=False, error_type=MISSING_FILE, message=f"Could not create the file to output the singbox content at {OUTPUT}") + + # start singbox: + command = ["sudo", SINGBOX_WRAPPER, "arm", str(profile_id)] human_readable_goal = "Start running Singbox" return run_generic_command(command, human_readable_goal, timeout=5) @wrap_with(systemwide_hell_raiser) -def turn_off(process_id: str) -> Result: - command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && gracefully_close "{process_id}"'] +def stop(process_id: int) -> Result: + command = ["sudo", SINGBOX_WRAPPER, "disarm", str(process_id)] human_readable_goal = "Gracefully Stop Singbox" return run_generic_command(command, human_readable_goal, timeout=5) @wrap_with(systemwide_hell_raiser) def force_kill(process_id: str) -> Result: - command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && forcefully_kill "{process_id}"'] + command = ["sudo", SINGBOX_WRAPPER, "force_kill", str(process_id)] human_readable_goal = "Force Kill Singbox" return run_generic_command(command, human_readable_goal, timeout=5) - - -@wrap_with(systemwide_hell_raiser) -def hello_world_test(name: str) -> str: - command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && hello_world_test "{name}"'] - human_readable_goal = "Hello world test" - return run_generic_command(command, human_readable_goal, timeout=5) - - -def get_pid_by_exact_match(name: str) -> str: - command = ['pgrep', '-x', 'sing-box'] - human_readable_goal = "Get the exact match of the PID" - return run_generic_command(command, human_readable_goal, timeout=5) - diff --git a/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py b/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py index a728698..b77d0dd 100644 --- a/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py +++ b/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py @@ -1,13 +1,20 @@ from core.services.networking.systemwide.encrypted_proxy import singbox -from core.services.networking.systemwide.encrypted_proxy.process_closure_tools import orchestrate_closing -from core.utils.basic_operations import process_tools +from core.services.networking.systemwide.encrypted_proxy.close_singbox import orchestrate_closing +from core.services.networking.systemwide.general_tools import interface_tools +from core.services.networking.systemwide import killswitch +from core.services.networking.systemwide.wireguard.wg_firewall_dns import revert_dns +from core.utils.basic_operations import pid_tools + from core.models.Result import Result, ResultError from core.errors.logger import logger from core.Constants import Constants from core.models.system.SystemState import SystemState from core.controllers.SystemStateController import SystemStateController from core.services.networking.systemwide import dns -from core.services.networking.systemwide.general_connection_tools.general_firewall_dns_tools import generic_enable_firewall_w_retry + + +from core.services.networking.systemwide.general_tools.general_firewall_dns_tools import enable_firewall_w_retry + from core.errors.exceptions import FirewallError, DNSError from essentials.observers.ConnectionObserver import ConnectionObserver @@ -39,26 +46,42 @@ def set_dns_for_singbox(current_state: SystemState): raise DNSError(dns_result) -def _attempt_start_with_retry(config_path: str, quantity_of_attempts: int = 2) -> Result: - current_attempt = 0 +# def _attempt_start_with_retry(profile_id: int, quantity_of_attempts: int = 2) -> Result: +# current_attempt = 0 - while current_attempt < quantity_of_attempts: - activation_result = singbox.start(config_path) +# while current_attempt < quantity_of_attempts: +# activation_result = singbox.start(profile_id) - if activation_result.valid: - return activation_result - else: - current_attempt = current_attempt + 1 - logger.error(f"[SINGBOX] Attempt {current_attempt} for Singbox Failed. Because: {activation_result.message}. Trying again..") +# if activation_result.valid: +# return activation_result +# else: +# current_attempt = current_attempt + 1 +# logger.error(f"[SINGBOX] Attempt {current_attempt} for Singbox Failed. Because: {activation_result.message}. Trying again..") def end_singbox( connection_observer: Optional[ConnectionObserver] = None ) -> Result: + # get by systemstate: current_state = SystemState.get() + if not current_state: - return Result(valid=False, error_type=ResultError.MISSING_FILE, message="Already disabled or missing State JSON.") + # Make sure there's no singbox id: + singbox_pid = pid_tools.get_pid_by_app_name(exact_app_name="sing-box") + + if singbox_pid is None: + return Result(valid=True, message="Already was disabled.") + + if not singbox_pid.valid: + return Result(valid=True, message="Already was disabled.") + + singbox_pid = singbox_pid.data + + if not singbox_pid or singbox_pid is None: + return Result(valid=True, message="Already was disabled or singbox showing no pid") + + logger.error(f"Critical Issue! State JSON is blank, but Singbox is still running. {singbox_pid}") # kill the real singbox try: @@ -66,20 +89,63 @@ def end_singbox( current_state=current_state, 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)) + except RuntimeError as e: # Interface error raised by process_closure_tool's interface_exists + return Result(valid=False, error_type=ResultError.INTERFACE, message=str(e)) + if not killed_existing.valid: + logger.error(f"Could not kill singbox because: {killed_existing.error_type}") + return killed_existing + + # ======== FIREWALL =========== + disable_result_object = killswitch.disarm() + if not disable_result_object.valid: + error_msg = "We could not disable the firewall for singbox" + logger.error(error_msg) + raise FirewallError(error_msg) # bubbles to GUI + + # ======== DNS ============= + reverting_dns_worked = revert_dns() + if not disable_result_object.valid: + error_msg = "We could not disable the DNS for singbox, but they might not use systemD" + logger.error(error_msg) + + # ======== JSON STATE ======== # Wipe the JSON to reflect reality, if killed_existing.valid: logger.info("Successfully took down Singbox tunnel") SystemState.dissolve() return Result(valid=True) else: - return Result(valid=False, error_type=ResultError.SINGBOX, error_msg="Could not disable singbox") + error_msg = "Could not delete the singbox state" + logger.error(error_msg) + return Result(valid=False, error_type=ResultError.SINGBOX, message=error_msg) + + +def launch_singbox_binary(profile_id: int) -> Result: + activation_result = singbox.start(profile_id) + if not activation_result.valid: + return activation_result + + process_id = int(activation_result.data) + + logger.info(f"Waiting 2 seconds to see if the process id {process_id} is still alive..") + time.sleep(2) + + # Evaluate if running. + active = pid_tools.is_running(pid=process_id, process_name="sing-box") + logger.info(f"Process {process_id} is {active}") + + if active: + return Result(valid=True, data=process_id) + else: + error_msg = f"While Singbox might have literally allowed the binary to begin, it's killing the process on id {process_id}" + logger.error(f"[{function_name}] {error_msg}") + return Result(valid=False, error_type=ResultError.PROCESS_GOT_KILLED, message=error_msg, data=process_id) + + def start_singbox( profile_id: int, - config_path: str, server_ip: str, connection_observer: Optional[ConnectionObserver] = None ) -> Result: @@ -89,7 +155,7 @@ def start_singbox( Steps: 1) Validate inputs - 2) Start it with a retry + 2) Start the binary 3) Evaluate if the process id is still up. 4) If it's up, setup the State JSON 5) Turn on Firewall @@ -100,7 +166,7 @@ def start_singbox( function_name = "START_SINGBOX" # ============= INPUT VALIDATION ============= - requirements = [profile_id, config_path, server_ip] + requirements = [profile_id, server_ip] for each_requirement in requirements: if each_requirement is None: return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Invalid inputs into {function_name}") @@ -110,22 +176,30 @@ def start_singbox( if not killed_pre_existing.valid: return killed_pre_existing - # ============= START PROCESS ============= - activation_result = _attempt_start_with_retry(config_path=config_path, quantity_of_attempts=QUANTITY_OF_ATTEMPTS) + # ============= START BINARY ============= + launched = launch_singbox_binary(profile_id=profile_id) + if not launched.valid: + return launched - if not activation_result.valid: - error_msg = f"Singbox failed to start after {QUANTITY_OF_ATTEMPTS} attempts" - logger.error(f"[{function_name}] {error_msg}") - return Result(valid=False, error_type=ResultError.PROCESS_WONT_START, message=error_msg) + process_id = launched.data - process_id = activation_result.data - time.sleep(2) - active = process_tools.is_running(process_id) + # ============= CHECK INTERFACE ============= + interface_result = interface_tools.get_output(Constants.SINGBOX_TUN_IF) + if not interface_result.valid: + error_msg = f"We could not get the interface's output, despite having a process id of {process_id}" + logger.error(error_msg) + return Result(valid=False, error_type=ResultError.INTERFACE, message=error_msg, data=process_id) - if not active: - error_msg = f"While Singbox might have literally allowed the binary to begin, it's killing the process on id {process_id}" - logger.error(f"[{function_name}] {error_msg}") - return Result(valid=False, error_type=ResultError.PROCESS_GOT_KILLED, message=error_msg, data=process_id) + time.sleep(1) + singbox_output = interface_result.data + interface_up = interface_tools.is_up(singbox_output) + logger.info(f"Is the interface up? {interface_up}") + + if interface_up: + logger.info("INTERFACE IS UP") + else: + logger.error("Interface is DOWN") + return Result(valid=False, error_type=ResultError.INTERFACE, data=process_id) # ============= SETUP STATE ============= # Even if firewall is off, we want to save the fact we turned Singbox on, before we raise errors. @@ -140,13 +214,11 @@ def start_singbox( # ============= FIREWALL ============= logger.info(f"[{function_name}] Attempting to enable the Firewall for {Constants.SINGBOX_TUN_IF} and {Constants.SINGBOX_INTERNAL_SUBNET}...") - # this is labeled "generic" for being protocol neutral - firewall_result = generic_enable_firewall_w_retry( + firewall_result = enable_firewall_w_retry( # this function is "generic" as it's protocol neutral interface_name=Constants.SINGBOX_TUN_IF, server_ip=server_ip, internal_subnet = Constants.SINGBOX_INTERNAL_SUBNET, - max_retries = 2, - connection_observer=connection_observer + max_retries = 2 ) if not firewall_result.valid: diff --git a/core/services/networking/systemwide/general_tools/general_firewall_dns_tools.py b/core/services/networking/systemwide/general_tools/general_firewall_dns_tools.py index fdd99ea..c1dba08 100644 --- a/core/services/networking/systemwide/general_tools/general_firewall_dns_tools.py +++ b/core/services/networking/systemwide/general_tools/general_firewall_dns_tools.py @@ -4,7 +4,7 @@ from core.errors.exceptions import FirewallError from core.models.Result import Result, ResultError -def generic_enable_firewall_w_retry( +def enable_firewall_w_retry( interface_name: str, server_ip: str, internal_subnet: str = None, diff --git a/core/services/networking/systemwide/general_tools/interface_tools.py b/core/services/networking/systemwide/general_tools/interface_tools.py index 338b6c3..e6a0db6 100644 --- a/core/services/networking/systemwide/general_tools/interface_tools.py +++ b/core/services/networking/systemwide/general_tools/interface_tools.py @@ -4,14 +4,40 @@ from core.utils.run_commands import run_generic_command import subprocess import re -def check_interface_exists(interface: str) -> Result: + +def is_up(output: str) -> Result: + """ + Check if interface has LOWER_UP flag (meaning it's operationally up) + """ + if re.search(r'LOWER_UP', output): + return Result(valid=True) + else: + return Result(valid=False, error_type=ResultError.INTERFACE, message="Interface is not up. LOWER_UP flag not found.") + + +def exists(interface: str) -> bool: + """Returns True if active, False if inactive. Raises on check failure.""" + try: + result = get_output(interface) + except Exception as e: + logger.info(f"Likely the interface doesn't exist, as we could not get output from it: {str(e)}") + return False + + if result.valid: + return True + elif result.error_type == ResultError.INTERFACE: + return False + else: + raise RuntimeError(f"Could not verify tunnel status: {result.message}") + + +def get_output(interface: str) -> Result: command = ['ip', 'link', 'show', interface] - # 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.message}") 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.") @@ -24,14 +50,3 @@ def check_interface_exists(interface: str) -> Result: return Result(valid=True, data=output) else: return Result(valid=False, error_type=ResultError.INTERFACE, message="Interface format not recognized.") - - - -def check_interface_up(output: str) -> Result: - """ - Check if interface has LOWER_UP flag (meaning it's operationally up) - """ - if re.search(r'LOWER_UP', output): - return Result(valid=True) - else: - return Result(valid=False, error_type=ResultError.INTERFACE, message="Interface is not up. LOWER_UP flag not found.") diff --git a/core/services/subscriptions/subscriptions.py b/core/services/subscriptions/subscriptions.py index 5e9cce3..7a0e777 100644 --- a/core/services/subscriptions/subscriptions.py +++ b/core/services/subscriptions/subscriptions.py @@ -1,10 +1,21 @@ -from typing import Union, Optional +from core.services.networking.httpx import connect +from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType + +from core.models.invoice.Invoice import Invoice +from core.models.invoice.PaymentMethod import PaymentMethod from core.models.session.SessionProfile import SessionProfile from core.models.system.SystemProfile import SystemProfile from core.Errors import MissingSubscriptionError, InvalidSubscriptionError -from core.services.WebServiceApiService import WebServiceApiService +# 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.models.Subscription import Subscription +from core.models.SubscriptionPlan import SubscriptionPlan +from core.errors.logger import logger + +from typing import Union, Optional + def activate_subscription( profile: Union[SessionProfile, SystemProfile], @@ -36,13 +47,35 @@ def activate_subscription( # Already activated—nothing to do if profile.subscription.has_been_activated(): return True - - # Fetch and activate - subscription = ConnectionController.with_preferred_connection( - profile.subscription.billing_code, - task=WebServiceApiService.get_subscription, - connection_observer=connection_observer - ) + + # ================================================== + # ENCRYPTED PROXY + # ================================================== + if profile.connection.code in ("vless", "hysteria2"): + logger.info("Getting an Encrypted proxy subscription code") + subscription = get_encrypted_proxy_billing_code( + location_id=profile.location.id, + subscription_plan_id=profile.subscription.id, + connection_observer=connection_observer + ) + + # ================================================== + # WIREGUARD AND GENERIC SOCKS5 PROXY + # ================================================== + else: + logger.info("Getting a Wireguard or regular proxy subscription DATED EXPIRED") + # Fetch and activate + subscription = get_subscription( + billing_code=profile.subscription.billing_code, + connection_observer=connection_observer + ) + + # legacy + # subscription = ConnectionController.with_preferred_connection( + # profile.subscription.billing_code, + # task=WebServiceApiService.get_subscription, + # connection_observer=connection_observer + # ) if subscription is None: raise InvalidSubscriptionError() @@ -59,3 +92,89 @@ def is_subscription_ready(profile: Union[SessionProfile, SystemProfile]) -> bool and profile.subscription.has_been_activated() ) + +def get_subscription(billing_code: str, connection_observer: ConnectionObserver) -> Subscription: + + billing_code = billing_code.replace('-', '').upper() + billing_code_fragments = re.findall('....?', billing_code) + billing_code = '-'.join(billing_code_fragments) + + url = f'{Constants.SP_API_BASE_URL}/subscriptions/current' + + api_response = connect.single_endpoint( + method="get", + url=url, + observer=connection_observer, + payload=None, + billing_code=billing_code + ) + + if api_response.valid: + raw_json = api_response.data + subscription = raw_json['data'] + return Subscription(billing_code, Subscription.from_iso_format(subscription['expires_at'])) + else: + logger.error(f"API Reply of {api_response.error_type}") + return None + + +def get_invoice(billing_code: str, connection_observer: ConnectionObserver) -> Invoice: + + url = f'{Constants.SP_API_BASE_URL}/invoices/current' + + api_response = connect.single_endpoint( + method="get", + url=url, + observer=connection_observer, + payload=None, + billing_code=billing_code + ) + + if api_response.valid: + raw_json = api_response.data + response_data = raw_json['data'] + + invoice = { + 'status': response_data['status'], + 'expires_at': response_data['expires_at'] + } + + payment_methods = [] + + for payment_method in response_data['payment_methods']: + payment_methods.append(PaymentMethod(payment_method['code'], payment_method['name'], payment_method['address'], payment_method['payment_link'], payment_method['rate'], payment_method['amount'], payment_method['due'])) + + return Invoice(billing_code, invoice['status'], invoice['expires_at'], tuple[PaymentMethod](payment_methods)) + + else: + return None + + +def get_encrypted_proxy_billing_code( + location_id: int, + subscription_plan_id: int, + connection_observer: ConnectionObserver +) -> str: + + url = f'{Constants.SP_API_BASE_URL}/api/v1/subscriptions' + payload = { + "subscription_plan_id": subscription_plan_id, + "location_id": location_id + } + + api_response = connect.single_endpoint( + method="post", + url=url, + observer=connection_observer, + payload=payload, + billing_code=None + ) + + if api_response.valid: + raw_reply = api_response.data + data = raw_reply['data'] + billing_code = data.get('billing_code', None) + return billing_code + else: + logger.error(f"API Error: {api_response.error_type}") + return None \ No newline at end of file diff --git a/core/utils/basic_operations/pid_tools.py b/core/utils/basic_operations/pid_tools.py new file mode 100644 index 0000000..41e93a6 --- /dev/null +++ b/core/utils/basic_operations/pid_tools.py @@ -0,0 +1,68 @@ +from core.utils.run_commands import run_generic_command +from core.models.Result import Result, ResultError + +import subprocess + +# doesn't work for sudo process +def is_non_sudo_running(pid): + try: + # Signal 0 checks if process exists, but doesn't send any signal + subprocess.check_call(['kill', '-0', str(pid)]) + return True + except subprocess.CalledProcessError: + return False + +# works even for sudo process: +def is_running(pid: int, process_name: str) -> bool: + command = ['ps', '-p', str(pid)] + human_readable_goal = f"ps process id {pid}" + command_itself = run_generic_command(command, human_readable_goal, timeout=5) + if command_itself.valid: + output = command_itself.data + if process_name in output: + return True + else: + return False + else: + return False + +# Function is public because it works for any app +def get_pid_by_app_name(exact_app_name: str) -> Result: + command = ['pgrep', '-x', exact_app_name] + 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: + process_id = int(result.data.strip()) # Strip newline, convert to int + return Result(valid=True, data=process_id) + except ValueError: + return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Could not parse PID") + else: + return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message="pgrep failed") + + +# Get ANY pid with the listed phrase: +def get_pids_by_phrase(phrase: str) -> Result: + command = ['pgrep', '-a', phrase] + 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] + if pid_data: + return Result(valid=True, data=pid_data) # List of dicts with pid and full command + else: + return Result(valid=False, error_type=ResultError.MISSING_DATA, message="No processes found") + else: + return Result(valid=False, error_type=ResultError.NOT_SUPPORTED, message="pgrep failed") + + +######### ISOLATION TESTING +# pid = +# process_name = +# if pid_tools.is_running(pid=pid, process_name=process_name): +# print(f"Process {pid} is running") +# else: +# print(f"Process {pid} is not running") diff --git a/core/utils/basic_operations/write_or_read_from_json.py b/core/utils/basic_operations/write_or_read_from_json.py index c3ed6a3..2f22b7e 100644 --- a/core/utils/basic_operations/write_or_read_from_json.py +++ b/core/utils/basic_operations/write_or_read_from_json.py @@ -3,7 +3,7 @@ from core.errors.logger import logger import json import os from pathlib import Path - +from typing import Any def write_json_to_file(data: dict, filepath: str) -> bool: try: @@ -62,34 +62,120 @@ def read_entire_json(filepath: str) -> dict: return False -# Opens a .json file, parses it, and retrieves a value by key. -def get_value_from_json_file(filepath: str, key: str) -> str: +def _search_recursive(obj: Any, search_key: str) -> Any | None: """ + Recursively search for key within a nested structure. + + Mind Riddle, this function calls itself. + """ + if isinstance(obj, dict): + if search_key in obj: + return obj[search_key] + for value in obj.values(): + result = _search_recursive(value, search_key) + if result is not None: + return result + elif isinstance(obj, list): + for item in obj: + result = _search_recursive(item, search_key) + if result is not None: + return result + return None + + +def get_value_from_json_file(filepath: str, key: str, category: str | None = None) -> Any: + """ + Load JSON file and search for a key recursively. + + Args: + filepath: Path to the JSON file + key: The key to search for + category: (Optional) Top-level key to limit search scope. If None, searches entire document. + + Returns: + The value associated with the key + Raises: - KeyError: If the key doesn't exist in the JSON - ValueError: If the value is blank/None/empty + ValueError: If value is None, empty string, or empty collection + KeyError: If category or key is not found """ data = read_entire_json(filepath) + + if category: + if category not in data: + raise KeyError(f"Category '{category}' not found in JSON file") + subcategory = data[category] + else: + subcategory = data - # Check if key exists - if key not in data: - raise KeyError(f"Key {key} does not exist in JSON file {filepath}") - - value = data[key] - - # Check if value is blank/None/empty + value = _search_recursive(subcategory, key) + if value is None: - raise ValueError(f"Value for key '{key}' is None (blank)") - if isinstance(value, str) and value.strip() == "": - raise ValueError(f"Value for key '{key}' is an empty string") - if isinstance(value, (list, dict)) and len(value) == 0: - raise ValueError( - f"Value for key '{key}' is empty (empty {type(value).__name__})" - ) - + raise KeyError(f"Key '{key}' not found" + (f" in category '{category}'" if category else "")) + + # Validation + if isinstance(value, str): + if value.strip() == "": + raise ValueError(f"Value for key '{key}' is empty or whitespace-only") + elif isinstance(value, (list, dict)): + if len(value) == 0: + raise ValueError(f"Value for key '{key}' is an empty collection") + return value +def get_value_through_ANY_NESTING(filepath: str, key: str) -> Any: + """ + Load JSON file and find a value by key, searching recursively through + nested dicts and lists. No path specification needed. + + Args: + filepath: Path to the JSON file + key: The key to search for (searched at any depth) + + Returns: + The value associated with the key + + Raises: + ValueError: If value is None, empty string, or empty collection + KeyError: If key is not found anywhere in the structure + """ + data = read_entire_json(filepath) + + def search_recursive(obj, search_key): + """Recursively search for key in nested structures.""" + if isinstance(obj, dict): + if search_key in obj: + return obj[search_key] + # Search within nested values + for value in obj.values(): + result = search_recursive(value, search_key) + if result is not None: + return result + elif isinstance(obj, list): + for item in obj: + result = search_recursive(item, search_key) + if result is not None: + return result + return None + + value = search_recursive(data, key) + + if value is None: + raise KeyError(f"Key '{key}' not found in JSON file") + + # Validation + if isinstance(value, str): + if value.strip() == "": + raise ValueError(f"Value for key '{key}' is empty or whitespace-only") + elif isinstance(value, (list, dict)): + if len(value) == 0: + raise ValueError(f"Value for key '{key}' is an empty collection") + + return value + + + def update_value_in_json_with_two_values( filepath: str, main_key, diff --git a/core/utils/run_commands.py b/core/utils/run_commands.py index 78eec59..7a2dec9 100644 --- a/core/utils/run_commands.py +++ b/core/utils/run_commands.py @@ -148,18 +148,28 @@ def run_generic_command( except Exception as e: return Result(valid=False, error_type=ResultError.UNKNOWN, goal=human_readable_goal, data=output_data, message=str(e)) +PERMISSION_ERRORS = [ + "No permissions", + "no permissions", + "password is required", + "sudo: a password is required" +] def parse_errors(error_output: str) -> ResultError: + logger.error(f"error_output is {error_output}") + + # Permission error + for each_permission_error in PERMISSION_ERRORS: + if each_permission_error in error_output: + return ResultError.PERMISSION + # Interface missing if "No such device" in error_output: - error_enum = ResultError.INTERFACE - # Permission error - elif "No permissions" in error_output: - error_enum = ResultError.PERMISSION - elif "sudo: a password is required" or "password is required" in error_output: - error_enum = ResultError.PERMISSION + return ResultError.INTERFACE + elif "command not found" in error_output: - error_enum = ResultError.MISSING_DEPENDENCY + return ResultError.MISSING_DEPENDENCY + else: - error_enum = ResultError.UNKNOWN - return error_enum + return ResultError.UNKNOWN +