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:
parent
3b1bf630f7
commit
9d0d140467
23 changed files with 929 additions and 315 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
.env
|
||||
.dev
|
||||
prototype_client.py
|
||||
.idea
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
||||
36
core/assets/sudo_scripts/singbox_wrapper
Normal file
36
core/assets/sudo_scripts/singbox_wrapper
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
@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
|
||||
|
||||
|
|
|
|||
44
core/models/pydantic_models/VlessData.py
Normal file
44
core/models/pydantic_models/VlessData.py
Normal 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
|
||||
|
|
@ -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.')
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
68
core/utils/basic_operations/pid_tools.py
Normal file
68
core/utils/basic_operations/pid_tools.py
Normal 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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue