refactor(core/cli): route vless/hysteria2 through _enable_encrypted_proxy, scope KillswitchMonitor to encrypted proxy only

This commit is contained in:
Zenaku 2026-07-09 18:23:11 -05:00
parent cae72efee3
commit a4bbfa4ec5
6 changed files with 111 additions and 35 deletions

View file

@ -77,31 +77,14 @@ class ProfileController:
if profile.is_system_profile(): if profile.is_system_profile():
if profile.connection.needs_operator_proxy(): if profile.connection.code in ('vless', 'hysteria2'):
from core.controllers.ConnectionController import ConnectionController ProfileController._enable_encrypted_proxy(profile, connection_observer=connection_observer)
if not profile.has_operator_proxy_session():
ProfileController.activate_subscription(profile, connection_observer=connection_observer)
from core.services.WebServiceApiService import WebServiceApiService
operator_proxy_session = ConnectionController.with_preferred_connection(
profile.subscription.billing_code,
profile.subscription.operator_id,
profile.connection.get_protocol(),
task=WebServiceApiService.post_operator_proxy,
connection_observer=connection_observer
)
if operator_proxy_session is None:
raise ProfileActivationError('Could not obtain operator proxy session.')
profile.attach_operator_proxy_session(operator_proxy_session)
ok = ConnectionController.establish_encrypted_proxy_connection(
profile, socks5_port=1080, observer=connection_observer
)
if not ok:
raise ProfileActivationError('The profile could not be enabled.')
if profile_observer is not None: if profile_observer is not None:
profile_observer.notify('enabled', profile) profile_observer.notify('enabled', profile)
return return
# WireGuard and other system profiles — legacy flow
from core.controllers.ConnectionController import ConnectionController
try: try:
ConnectionController.establish_connection(profile, ignore=ignore, connection_observer=connection_observer) ConnectionController.establish_connection(profile, ignore=ignore, connection_observer=connection_observer)
except ConnectionError: except ConnectionError:
@ -110,6 +93,37 @@ class ProfileController:
if profile_observer is not None: if profile_observer is not None:
profile_observer.notify('enabled', profile) profile_observer.notify('enabled', profile)
@staticmethod
def _enable_encrypted_proxy(profile, connection_observer=None) -> None:
from core.controllers.ConnectionController import ConnectionController
from core.controllers.SystemStateController import SystemStateController
from core.services.WebServiceApiService import WebServiceApiService
if not profile.has_operator_proxy_session():
ProfileController.activate_subscription(profile, connection_observer=connection_observer)
operator_proxy_session = ConnectionController.with_preferred_connection(
profile.subscription.billing_code,
profile.subscription.operator_id,
profile.connection.code,
task=WebServiceApiService.post_operator_proxy,
connection_observer=connection_observer
)
if operator_proxy_session is None:
raise ProfileActivationError('Could not obtain operator proxy session.')
profile.attach_operator_proxy_session(operator_proxy_session)
ConnectionController._signal_previous_process()
ok = ConnectionController.establish_encrypted_proxy_connection(
profile, socks5_port=1080, observer=connection_observer
)
if not ok:
raise ProfileActivationError('The profile could not be enabled.')
token = SystemStateController.create(profile.id)
if connection_observer is not None:
connection_observer.notify('connected_token', {'session_token': token})
@staticmethod @staticmethod
def run_monitor(profile: Union[SessionProfile, SystemProfile], monitor) -> None: def run_monitor(profile: Union[SessionProfile, SystemProfile], monitor) -> None:

View file

@ -65,8 +65,8 @@ class BaseProfile(ABC):
return type(self).__name__ == 'SystemProfile' return type(self).__name__ == 'SystemProfile'
def save(self: Self): def save(self: Self):
config_dict = self.to_dict() # Get dict, not JSON string config_dict = self.to_dict()
location_dict = self.location.to_dict() if self.location else None if self.location else None # this is from SQLAlchemy, and not JSON-models, that's why it's separate. location_dict = self.location.to_dict() if self.location else None
config_dict["location"] = location_dict config_dict["location"] = location_dict

View file

@ -209,7 +209,7 @@ class WebServiceApiService:
else: else:
headers = None headers = None
return requests.get(Constants.SP_API_BASE_URL + path, headers=headers, proxies=proxies) return requests.get(Constants.SP_API_BASE_URL + path, headers=headers, proxies=proxies, timeout=30)
@staticmethod @staticmethod
def __post(path, billing_code: Optional[str] = None, body: Optional[dict] = None, proxies: Optional[dict] = None): def __post(path, billing_code: Optional[str] = None, body: Optional[dict] = None, proxies: Optional[dict] = None):
@ -221,7 +221,7 @@ class WebServiceApiService:
else: else:
headers = None headers = None
return requests.post(Constants.SP_API_BASE_URL + path, headers=headers, json=body, proxies=proxies) return requests.post(Constants.SP_API_BASE_URL + path, headers=headers, json=body, proxies=proxies, timeout=30)
@staticmethod @staticmethod
def get_cached_sync(proxies: Optional[dict] = None): def get_cached_sync(proxies: Optional[dict] = None):

View file

@ -134,10 +134,41 @@ def enable_hysteria(username: str, password: str, server_host: str,
_cleanup_all() _cleanup_all()
def _force_kill_singbox() -> None:
import subprocess
try:
result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True)
if result.returncode == 0:
for pid in result.stdout.strip().splitlines():
subprocess.run(
['sudo', 'kill', '-9', pid.strip()],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5
)
except Exception:
pass
def disable_hysteria(observer=None) -> bool: def disable_hysteria(observer=None) -> bool:
try:
revert_dns_on_tun() revert_dns_on_tun()
except Exception:
pass
try:
SingboxRunner().stop() SingboxRunner().stop()
except Exception:
pass
try:
import subprocess, time
time.sleep(0.5)
result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True)
if result.returncode == 0:
_force_kill_singbox()
except Exception:
pass
try:
killswitch.disarm() killswitch.disarm()
except Exception:
pass
if observer: if observer:
observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF}) observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF})
return True return True

View file

@ -163,10 +163,41 @@ def enable_vless(vless_link: str, username: str, server_ip: str,
_cleanup_all() _cleanup_all()
def _force_kill_singbox() -> None:
import subprocess
try:
result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True)
if result.returncode == 0:
for pid in result.stdout.strip().splitlines():
subprocess.run(
['sudo', 'kill', '-9', pid.strip()],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5
)
except Exception:
pass
def disable_vless(observer=None) -> bool: def disable_vless(observer=None) -> bool:
try:
revert_dns_on_tun() revert_dns_on_tun()
except Exception:
pass
try:
SingboxRunner().stop() SingboxRunner().stop()
except Exception:
pass
try:
import subprocess, time
time.sleep(0.5)
result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True)
if result.returncode == 0:
_force_kill_singbox()
except Exception:
pass
try:
killswitch.disarm() killswitch.disarm()
except Exception:
pass
if observer: if observer:
observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF}) observer.notify("disconnected", {"tunnel_if": Constants.SINGBOX_TUN_IF})
return True return True

View file

@ -24,8 +24,8 @@ class SingboxRunner:
_START_POLL_S = 0.5 _START_POLL_S = 0.5
_START_INITIAL_S = 2.0 _START_INITIAL_S = 2.0
_ORPHAN_GRACEFUL_TIMEOUT_S = 4.0 _ORPHAN_GRACEFUL_TIMEOUT_S = 2.0
_ORPHAN_FINAL_VERIFY_TIMEOUT_S = 2.0 _ORPHAN_FINAL_VERIFY_TIMEOUT_S = 1.0
_ORPHAN_POLL_S = 0.3 _ORPHAN_POLL_S = 0.3
def __init__(self): def __init__(self):
@ -83,7 +83,7 @@ class SingboxRunner:
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
timeout=15, timeout=5,
) )
except (subprocess.TimeoutExpired, FileNotFoundError, OSError): except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
logger.warning("Graceful stop failed, attempting emergency kill") logger.warning("Graceful stop failed, attempting emergency kill")
@ -190,7 +190,7 @@ class SingboxRunner:
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'}, env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
timeout=15, timeout=5,
) )
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
pass pass