Singbox Orchestration! 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.

This commit is contained in:
SimplifiedPrivacy 2026-08-10 15:49:47 -04:00
parent 3b1bf630f7
commit 9d0d140467
23 changed files with 929 additions and 315 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.env
.dev .dev
prototype_client.py prototype_client.py
.idea .idea

View file

@ -1,5 +1,19 @@
# Major Change Log: # 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 # WG Renegotiation
### Aug 7, 2026 ### Aug 7, 2026
Wireguard renegotiation now flows through the new HTTPx modules Wireguard renegotiation now flows through the new HTTPx modules

View file

@ -78,6 +78,7 @@ class Constants:
SINGBOX_TUN_IF: Final[str] = os.environ.get('SINGBOX_TUN_IF', 'tun0') 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_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_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 ───────────────────────────────────────────── # ── Tor ─────────────────────────────────────────────
DEFAULT_TOR_PORT = 9050 DEFAULT_TOR_PORT = 9050

View file

@ -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"
}

View file

@ -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

View file

@ -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.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.systemwide_utils import get_firewall_setting, get_dns_setting
from core.services.networking.systemwide import killswitch from core.services.networking.systemwide import killswitch
from core.services.subscriptions import subscriptions
from core.errors.exceptions import FirewallError from core.errors.exceptions import FirewallError
from core.models.Result import Result, ResultError from core.models.Result import Result, ResultError
@ -202,7 +203,13 @@ class ProfileController:
if profile.has_subscription(): 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: if subscription is not None:
@ -238,6 +245,7 @@ class ProfileController:
def get_invoice(profile: Union[SessionProfile, SystemProfile]): def get_invoice(profile: Union[SessionProfile, SystemProfile]):
if profile.has_subscription(): if profile.has_subscription():
# return subscriptions.get_invoice(billing_code=profile.subscription.billing_code)
return WebServiceApiService.get_invoice(profile.subscription.billing_code) return WebServiceApiService.get_invoice(profile.subscription.billing_code)
else: else:
return None return None

View file

@ -1,4 +1,4 @@
from core.models.HysteriaConfig import HysteriaConfig from core.models.pydantic_models.HysteriaData import HysteriaData
from core.errors.logger import logger from core.errors.logger import logger
# generic # 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: Purpose:
Serialize a Pydantic model to JSON file WITHOUT sudo. 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. False on failure.
""" """
try: try:
logger.info(f"Saving to {filepath}")
# Ensure parent directory exists # Ensure parent directory exists
Path(filepath).parent.mkdir(parents=True, exist_ok=True) Path(filepath).parent.mkdir(parents=True, exist_ok=True)

View file

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

View file

@ -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

View file

@ -107,3 +107,29 @@ class SystemProfile(BaseProfile):
def __get_system_config_path(id: int): def __get_system_config_path(id: int):
config_path = f'{Constants.HV_SYSTEM_PROFILE_CONFIG_PATH}/{str(id)}' config_path = f'{Constants.HV_SYSTEM_PROFILE_CONFIG_PATH}/{str(id)}'
return config_path 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.')

View file

@ -14,7 +14,7 @@ class BackoffStrategy(Enum):
class ErrorType(Enum): class ErrorType(Enum):
"""Classified error categories.""" """Classified error categories."""
SUCCESS = "success" 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 INVALID_REQUEST = "invalid_request" # 400, 405 - bad method/body
AUTHENTICATION_ERROR = "authentication_error" # 401 - need credentials AUTHENTICATION_ERROR = "authentication_error" # 401 - need credentials
AUTHORIZATION_ERROR = "authorization_error" # 403 - no permission AUTHORIZATION_ERROR = "authorization_error" # 403 - no permission
@ -41,7 +41,7 @@ class ErrorType(Enum):
NO_INTERNET = "no_internet" NO_INTERNET = "no_internet"
CONNECTION_ERROR = "connection_error" CONNECTION_ERROR = "connection_error"
INVALID_INPUT = "invalid_input" INVALID_INPUT = "invalid_input"
PERMISSION_ERROR = "permission_error" # duplicate PERMISSION_ERROR = "permission_error"
DEVELOPER_ERROR = "developer_error" DEVELOPER_ERROR = "developer_error"
PORT_NOT_LISTENING = "port_not_listening" PORT_NOT_LISTENING = "port_not_listening"
UNKNOWN = "unknown" UNKNOWN = "unknown"

View file

@ -1,15 +1,16 @@
from core.services.networking.general_connection_tools.testing_evaluating import system_uses_wireguard_interface from core.services.networking.general_connection_tools.testing_evaluating import system_uses_wireguard_interface
from core.services.networking.systemwide.systemwide_wireguard import establish_system_connection, terminate_system_connection from core.services.networking.systemwide.systemwide_wireguard import establish_system_connection, terminate_system_connection
from core.services.keys_and_verifications.wireguard_keys import register_wireguard_session from core.services.keys_and_verifications.wireguard_keys import register_wireguard_session
from core.services.subscriptions.subscriptions import activate_subscription 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.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: # If refactored to enums:
# from core.models.session.SessionConnection import SessionConnectionTypes # from core.models.session.SessionConnection import SessionConnectionTypes
# from core.models.system.SystemConnection import SystemConnectionTypes # from core.models.system.SystemConnection import SystemConnectionTypes
# wireguard_types = [SystemConnectionTypes.WIREGUARD, SessionConnectionTypes.WIREGUARD] # wireguard_types = [SystemConnectionTypes.WIREGUARD, SessionConnectionTypes.WIREGUARD]
from core.errors.logger import logger from core.errors.logger import logger
from core.errors.exceptions import * from core.errors.exceptions import *
from core.errors.exceptions import FirewallError from core.errors.exceptions import FirewallError
@ -23,6 +24,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
import os
def establish_connection( def establish_connection(
profile: Union[SessionProfile, SystemProfile], profile: Union[SessionProfile, SystemProfile],
ignore: tuple[type[Exception]] = (), ignore: tuple[type[Exception]] = (),
@ -36,9 +39,9 @@ def establish_connection(
# ========================================= # =========================================
# HYSTERIA2 & VLESS # HYSTERIA2 & VLESS
# ========================================= # =========================================
# if profile.connection.code in ("hysteria2", "vless"): if profile.connection.code in ("hysteria2", "vless"):
# logger.info("Pulling encrypted proxies off the main flow..") logger.info("Pulling encrypted proxies off the main flow..")
# configure_singbox(profile, connection_observer) return launch_encrypted_proxy(profile, connection_observer)
# ========================================= # =========================================
# SOCKS5 & WIREGUARD # SOCKS5 & WIREGUARD
@ -58,11 +61,68 @@ def establish_connection(
return _establish_with_renegotiation(profile, establish_fn, ignore, connection_observer) 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( def _ensure_proxy_configured(
profile: Union[SessionProfile, SystemProfile], profile: Union[SessionProfile, SystemProfile],
connection_observer: Optional[ConnectionObserver], connection_observer: Optional[ConnectionObserver],
): ):
"""Setup proxy config if needed.""" """Setup UNencrypted regular proxy config if needed."""
if not profile.connection.needs_proxy_configuration(): if not profile.connection.needs_proxy_configuration():
return return
@ -144,6 +204,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

@ -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.errors.exceptions import SudoScript
from core.models.system.SystemState import SystemState from core.models.system.SystemState import SystemState
from core.models.Result import Result, ResultError 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.basic_operations import process_tools
from core.utils.run_commands import run_generic_command 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
KILL_WAIT_TIME = 1.3 KILL_WAIT_TIME = 1.3
APP_NAME = 'sing-box' 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: def _try_with_permission_fallback(operation: Callable, interface: str) -> Result:
"""Execute operation; if denied, check if tunnel is still active.""" """Execute operation; if denied, check if tunnel is still active."""
try: try:
return operation() return operation()
except SudoScript as e: 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}") 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: 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"
logger.info(not_existing) 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'}") return Result(valid=force.valid, message=f"Force kill {'succeeded' if force.valid else 'failed'}")
def _get_process_id(current_state: SystemState) -> Result: 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 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: if not process_id:
return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Missing the critical 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. # Function is private because it leverages singbox only functionality.
def _shut_down_by_known_process_id(process_id: int) -> Result: def _shut_down_by_known_process_id(process_id: int) -> Result:
function_name = "_shut_down_by_known_process_id" graceful_close = singbox.stop(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)
if not graceful_close.valid: 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.") 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: 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) 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) force_kill = singbox.force_kill(process_id)
if force_kill.valid: 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}.") 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: def _hunt_and_kill(interface_name: str) -> Result:
""" """
Purpose: Purpose:
@ -145,7 +98,7 @@ def _hunt_and_kill(interface_name: str) -> Result:
""" """
function_name = "_hunt_and_kill" 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: if id_results.valid:
pid_data_list = id_results.data pid_data_list = id_results.data
else: else:
@ -166,7 +119,7 @@ def _hunt_and_kill(interface_name: str) -> Result:
logger.info(f"[{function_name}] PID {pid} force killed successfully") logger.info(f"[{function_name}] PID {pid} force killed successfully")
# EVALUATION: Did killing this PID stop the app? is the interface still running? # 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: if not existance.valid:
logger.info(f"[{function_name}] App/Tunnel is fully stopped after killing PID {pid}") 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}") 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: 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: if exact_match.valid:
logger.info(f"Attempting exact match PID {exact_match.data}") logger.info(f"Attempting exact match PID {exact_match.data}")
return _shut_down_by_known_process_id(cast(int, 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: 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: if not pid_query.valid:
return pid_query return pid_query
return _shut_down_by_known_process_id(cast(int, pid_query.data)) 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) result = _try_with_permission_fallback(strategy, interface_name)
if result.valid: if result.valid:
return result 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=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.") return Result(valid=False, message=f"All {quantity_of_strategies} strategies tried, we simply can not bring down the proxy, which is still up.")

View file

@ -1,26 +1,36 @@
from core.models.orm_calls.location_calls import get_profile_location_data from core.models.orm_calls.location_calls import get_profile_location_data
from core.services.networking.tor_tools import ports from core.services.networking.tor_tools import ports
from core.services.networking.httpx import connect 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 # Models
from core.models.pydantic_models.HysteriaData import HysteriaData 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.Result import Result, ResultError
# from core.models.BaseProfile import BaseProfile
from core.models.session.SessionProfile import SessionProfile from core.models.session.SessionProfile import SessionProfile
from core.models.system.SystemProfile import SystemProfile from core.models.system.SystemProfile import SystemProfile
from core.models.manage.session_management import get_session 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 # errors & observers
from core.Constants import Constants from core.Constants import Constants
from core.errors.logger import logger from core.errors.logger import logger
from core.Errors import MissingSubscriptionError from core.Errors import MissingSubscriptionError
from core.observers.ConnectionObserver import ConnectionObserver from core.observers.ConnectionObserver import ConnectionObserver
from core.Errors import ProfileModificationError
# generic # generic
from pydantic import ValidationError from pydantic import ValidationError
from typing import Union, Optional from typing import Union, Optional
import os
import shutil
import subprocess
import json
def configure_singbox( def configure_singbox(
profile: Union[SessionProfile, SystemProfile], profile: Union[SessionProfile, SystemProfile],
@ -31,112 +41,204 @@ def configure_singbox(
# PREP PAYLOADS # PREP PAYLOADS
################################### ###################################
protocol = profile.connection.code 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() 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(): if not profile.has_subscription():
raise MissingSubscriptionError() raise MissingSubscriptionError()
url = f"{Constants.SP_API_BASE_URL}/subscriptions/current/operator-proxies"
payload = {
'operator_id': operator_id,
'protocol': protocol,
}
################################### ###################################
# SEND TO THE API # SEND TO THE API
################################### ###################################
logger.info("Sending to the API..") config_results = post_operator_proxy(
config_results = connect.single_endpoint( billing_code=profile.subscription.billing_code,
method="post", protocol=protocol,
url=url, connection_observer=connection_observer
observer=connection_observer,
payload=payload,
billing_code=profile.subscription.billing_code
) )
################################### # this is a bad API reply, and not bad data per say.
# VERIFY THE API'S REPLY if not config_results:
################################### return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, message="Server-side API issue.")
# 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 # (SERVER CHOICES) PREP RAW DATA (PYDANTIC MODEL)
################################### ###################################
location_country_code= data.get('location_country_code') # VERIFY:
location_city_code= data.get('location_city_code') validation = verify_proxy_data(data=config_results, protocol=protocol)
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: if not validation.valid:
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 validation
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") 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 # (CLIENT CHOICES) PREP REAL CONFIG
###################################
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() random_port = ports.get_random_available_port()
# HYSTERIA
if protocol == "hysteria2": if protocol == "hysteria2":
real_config = build_hysteria_config( real_config = build_hysteria_config(
username=validated_data.username, username=validated_data.username,
password=validated_data.password, password=validated_data.password,
server_host=validated_data.operator_hysteria2_host, server_host=validated_data.hysteria2_host,
socks5_port=random_port, socks5_port=random_port,
server_ip=validated_data.server_ip 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 # SAVE REAL CONFIG
################################### ###################################
# goes in a sudo protected folder & prompts for password: # 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: if saved_it:
logger.info("Successfully saved the config.") return Result(valid=True, data=validated_data.server_ip)
return Result(valid=True)
else: else:
error_msg = "Could not save the configuration." return Result(valid=False, error_type=ResultError.PERMISSION, message="User would not allow sudo permission, or it could not save.")
return Result(valid=False, error_type=ResultError.FILE_SYSTEM, error_msg=error_msg)
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")

View file

@ -34,7 +34,7 @@ def build_hysteria_config(username: str, password: str,
{ {
"type": "hysteria2", "type": "hysteria2",
"tag": "proxy", "tag": "proxy",
"server": server_ip, "server": str(server_ip),
"server_port": 443, "server_port": 443,
"password": f"{username}:{password}", "password": f"{username}:{password}",
"tls": { "tls": {

View file

@ -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.utils.run_commands import run_generic_command
from core.models.Result import Result, ResultError from core.models.Result import Result, ResultError
from core.errors.logger import logger from core.errors.logger import logger
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) @wrap_with(systemwide_hell_raiser)
def start(config_path: str) -> Result: def start(profile_id: int) -> Result:
command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && run_binary "{config_path}"'] # 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" human_readable_goal = "Start running Singbox"
return run_generic_command(command, human_readable_goal, timeout=5) return run_generic_command(command, human_readable_goal, timeout=5)
@wrap_with(systemwide_hell_raiser) @wrap_with(systemwide_hell_raiser)
def turn_off(process_id: str) -> Result: def stop(process_id: int) -> Result:
command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && gracefully_close "{process_id}"'] command = ["sudo", SINGBOX_WRAPPER, "disarm", str(process_id)]
human_readable_goal = "Gracefully Stop Singbox" human_readable_goal = "Gracefully Stop Singbox"
return run_generic_command(command, human_readable_goal, timeout=5) return run_generic_command(command, human_readable_goal, timeout=5)
@wrap_with(systemwide_hell_raiser) @wrap_with(systemwide_hell_raiser)
def force_kill(process_id: str) -> Result: 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" human_readable_goal = "Force Kill Singbox"
return run_generic_command(command, human_readable_goal, timeout=5) 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)

View file

@ -1,13 +1,20 @@
from core.services.networking.systemwide.encrypted_proxy import singbox from core.services.networking.systemwide.encrypted_proxy import singbox
from core.services.networking.systemwide.encrypted_proxy.process_closure_tools import orchestrate_closing from core.services.networking.systemwide.encrypted_proxy.close_singbox import orchestrate_closing
from core.utils.basic_operations import process_tools 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.models.Result import Result, ResultError
from core.errors.logger import logger from core.errors.logger import logger
from core.Constants import Constants from core.Constants import Constants
from core.models.system.SystemState import SystemState from core.models.system.SystemState import SystemState
from core.controllers.SystemStateController import SystemStateController from core.controllers.SystemStateController import SystemStateController
from core.services.networking.systemwide import dns 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 core.errors.exceptions import FirewallError, DNSError
from essentials.observers.ConnectionObserver import ConnectionObserver from essentials.observers.ConnectionObserver import ConnectionObserver
@ -39,26 +46,42 @@ def set_dns_for_singbox(current_state: SystemState):
raise DNSError(dns_result) raise DNSError(dns_result)
def _attempt_start_with_retry(config_path: str, quantity_of_attempts: int = 2) -> Result: # def _attempt_start_with_retry(profile_id: int, quantity_of_attempts: int = 2) -> Result:
current_attempt = 0 # current_attempt = 0
while current_attempt < quantity_of_attempts: # while current_attempt < quantity_of_attempts:
activation_result = singbox.start(config_path) # activation_result = singbox.start(profile_id)
if activation_result.valid: # if activation_result.valid:
return activation_result # return activation_result
else: # else:
current_attempt = current_attempt + 1 # current_attempt = current_attempt + 1
logger.error(f"[SINGBOX] Attempt {current_attempt} for Singbox Failed. Because: {activation_result.message}. Trying again..") # logger.error(f"[SINGBOX] Attempt {current_attempt} for Singbox Failed. Because: {activation_result.message}. Trying again..")
def end_singbox( def end_singbox(
connection_observer: Optional[ConnectionObserver] = None connection_observer: Optional[ConnectionObserver] = None
) -> Result: ) -> Result:
# get by systemstate:
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.") # 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 # kill the real singbox
try: try:
@ -66,20 +89,63 @@ def end_singbox(
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 except RuntimeError as e: # Interface error raised by process_closure_tool's interface_exists
return Result(valid=False, error_type=ResultError.INTERFACE, error_msg=str(e)) 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, # Wipe the JSON to reflect reality,
if killed_existing.valid: 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) return Result(valid=True)
else: 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( def start_singbox(
profile_id: int, profile_id: int,
config_path: str,
server_ip: str, server_ip: str,
connection_observer: Optional[ConnectionObserver] = None connection_observer: Optional[ConnectionObserver] = None
) -> Result: ) -> Result:
@ -89,7 +155,7 @@ def start_singbox(
Steps: Steps:
1) Validate inputs 1) Validate inputs
2) Start it with a retry 2) Start the binary
3) Evaluate if the process id is still up. 3) Evaluate if the process id is still up.
4) If it's up, setup the State JSON 4) If it's up, setup the State JSON
5) Turn on Firewall 5) Turn on Firewall
@ -100,7 +166,7 @@ def start_singbox(
function_name = "START_SINGBOX" function_name = "START_SINGBOX"
# ============= INPUT VALIDATION ============= # ============= INPUT VALIDATION =============
requirements = [profile_id, config_path, server_ip] requirements = [profile_id, server_ip]
for each_requirement in requirements: for each_requirement in requirements:
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}")
@ -110,22 +176,30 @@ def start_singbox(
if not killed_pre_existing.valid: if not killed_pre_existing.valid:
return killed_pre_existing return killed_pre_existing
# ============= START PROCESS ============= # ============= START BINARY =============
activation_result = _attempt_start_with_retry(config_path=config_path, quantity_of_attempts=QUANTITY_OF_ATTEMPTS) launched = launch_singbox_binary(profile_id=profile_id)
if not launched.valid:
return launched
if not activation_result.valid: process_id = launched.data
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 = activation_result.data # ============= CHECK INTERFACE =============
time.sleep(2) interface_result = interface_tools.get_output(Constants.SINGBOX_TUN_IF)
active = process_tools.is_running(process_id) 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: time.sleep(1)
error_msg = f"While Singbox might have literally allowed the binary to begin, it's killing the process on id {process_id}" singbox_output = interface_result.data
logger.error(f"[{function_name}] {error_msg}") interface_up = interface_tools.is_up(singbox_output)
return Result(valid=False, error_type=ResultError.PROCESS_GOT_KILLED, message=error_msg, data=process_id) 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 ============= # ============= 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.
@ -140,13 +214,11 @@ def start_singbox(
# ============= FIREWALL ============= # ============= FIREWALL =============
logger.info(f"[{function_name}] Attempting to enable the Firewall for {Constants.SINGBOX_TUN_IF} and {Constants.SINGBOX_INTERNAL_SUBNET}...") 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 = enable_firewall_w_retry( # this function is "generic" as it's protocol neutral
firewall_result = generic_enable_firewall_w_retry(
interface_name=Constants.SINGBOX_TUN_IF, interface_name=Constants.SINGBOX_TUN_IF,
server_ip=server_ip, server_ip=server_ip,
internal_subnet = Constants.SINGBOX_INTERNAL_SUBNET, internal_subnet = Constants.SINGBOX_INTERNAL_SUBNET,
max_retries = 2, max_retries = 2
connection_observer=connection_observer
) )
if not firewall_result.valid: if not firewall_result.valid:

View file

@ -4,7 +4,7 @@ from core.errors.exceptions import FirewallError
from core.models.Result import Result, ResultError from core.models.Result import Result, ResultError
def generic_enable_firewall_w_retry( def enable_firewall_w_retry(
interface_name: str, interface_name: str,
server_ip: str, server_ip: str,
internal_subnet: str = None, internal_subnet: str = None,

View file

@ -4,14 +4,40 @@ 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 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] command = ['ip', 'link', 'show', interface]
# result = subprocess.run(, capture_output=True)
human_readable_goal = "Checking if the interface exists" human_readable_goal = "Checking if the interface exists"
result = run_generic_command(command, human_readable_goal, timeout=5) result = run_generic_command(command, human_readable_goal, timeout=5)
if not result.valid: 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 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.")
@ -24,14 +50,3 @@ def check_interface_exists(interface: str) -> Result:
return Result(valid=True, data=output) return Result(valid=True, data=output)
else: else:
return Result(valid=False, error_type=ResultError.INTERFACE, message="Interface format not recognized.") 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.")

View file

@ -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.session.SessionProfile import SessionProfile
from core.models.system.SystemProfile import SystemProfile from core.models.system.SystemProfile import SystemProfile
from core.Errors import MissingSubscriptionError, InvalidSubscriptionError 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.controllers.ConnectionController import ConnectionController
from core.observers.ConnectionObserver import ConnectionObserver 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( def activate_subscription(
profile: Union[SessionProfile, SystemProfile], profile: Union[SessionProfile, SystemProfile],
@ -37,13 +48,35 @@ def activate_subscription(
if profile.subscription.has_been_activated(): if profile.subscription.has_been_activated():
return True return True
# Fetch and activate # ==================================================
subscription = ConnectionController.with_preferred_connection( # ENCRYPTED PROXY
profile.subscription.billing_code, # ==================================================
task=WebServiceApiService.get_subscription, 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 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: if subscription is None:
raise InvalidSubscriptionError() raise InvalidSubscriptionError()
@ -59,3 +92,89 @@ def is_subscription_ready(profile: Union[SessionProfile, SystemProfile]) -> bool
and profile.subscription.has_been_activated() 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

View file

@ -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")

View file

@ -3,7 +3,7 @@ from core.errors.logger import logger
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Any
def write_json_to_file(data: dict, filepath: str) -> bool: def write_json_to_file(data: dict, filepath: str) -> bool:
try: try:
@ -62,34 +62,120 @@ def read_entire_json(filepath: str) -> dict:
return False return False
# Opens a .json file, parses it, and retrieves a value by key. def _search_recursive(obj: Any, search_key: str) -> Any | None:
def get_value_from_json_file(filepath: str, key: str) -> str:
""" """
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: Raises:
KeyError: If the key doesn't exist in the JSON ValueError: If value is None, empty string, or empty collection
ValueError: If the value is blank/None/empty KeyError: If category or key is not found
""" """
data = read_entire_json(filepath) data = read_entire_json(filepath)
# Check if key exists if category:
if key not in data: if category not in data:
raise KeyError(f"Key {key} does not exist in JSON file {filepath}") raise KeyError(f"Category '{category}' not found in JSON file")
subcategory = data[category]
else:
subcategory = data
value = data[key] value = _search_recursive(subcategory, key)
# Check if value is blank/None/empty
if value is None: if value is None:
raise ValueError(f"Value for key '{key}' is None (blank)") raise KeyError(f"Key '{key}' not found" + (f" in category '{category}'" if category else ""))
if isinstance(value, str) and value.strip() == "":
raise ValueError(f"Value for key '{key}' is an empty string") # Validation
if isinstance(value, (list, dict)) and len(value) == 0: if isinstance(value, str):
raise ValueError( if value.strip() == "":
f"Value for key '{key}' is empty (empty {type(value).__name__})" 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 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( def update_value_in_json_with_two_values(
filepath: str, filepath: str,
main_key, main_key,

View file

@ -148,18 +148,28 @@ def run_generic_command(
except Exception as e: except Exception as e:
return Result(valid=False, error_type=ResultError.UNKNOWN, goal=human_readable_goal, data=output_data, message=str(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: 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 # Interface missing
if "No such device" in error_output: if "No such device" in error_output:
error_enum = ResultError.INTERFACE return 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
elif "command not found" in error_output: elif "command not found" in error_output:
error_enum = ResultError.MISSING_DEPENDENCY return ResultError.MISSING_DEPENDENCY
else: else:
error_enum = ResultError.UNKNOWN return ResultError.UNKNOWN
return error_enum