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
|
.dev
|
||||||
prototype_client.py
|
prototype_client.py
|
||||||
.idea
|
.idea
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
# likely future addition:
|
||||||
|
# # @field_validator('operator_id')
|
||||||
# return v
|
# # @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):
|
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.')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
process_id = current_state.process_id
|
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:
|
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.")
|
||||||
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
###################################
|
|
||||||
# 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
|
# this is a bad API reply, and not bad data per say.
|
||||||
|
if not config_results:
|
||||||
# extract 'data' out of reply:
|
return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, message="Server-side API issue.")
|
||||||
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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
@ -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)
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
@ -36,13 +47,35 @@ def activate_subscription(
|
||||||
# Already activated—nothing to do
|
# Already activated—nothing to do
|
||||||
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"):
|
||||||
connection_observer=connection_observer
|
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:
|
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
|
||||||
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 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)
|
||||||
|
|
||||||
|
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
|
value = _search_recursive(subcategory, key)
|
||||||
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
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue