Add location_country_code/city_code to operator proxy flow + SingboxProcessMonitor ABC + observer_helpers
This commit is contained in:
parent
68aa315f46
commit
999ddbf855
5 changed files with 376 additions and 110 deletions
|
|
@ -17,4 +17,6 @@ class OperatorProxySession:
|
||||||
operator_domain: Optional[str] = None
|
operator_domain: Optional[str] = None
|
||||||
operator_hysteria2_host: Optional[str] = None
|
operator_hysteria2_host: Optional[str] = None
|
||||||
operator_vless_host: Optional[str] = None
|
operator_vless_host: Optional[str] = None
|
||||||
server_ip: Optional[str] = None # pre-resolved IP — avoids DNS leak at connect time
|
server_ip: Optional[str] = None # pre-resolved IP — avoids DNS leak at connect time
|
||||||
|
location_country_code: Optional[str] = None
|
||||||
|
location_city_code: Optional[str] = None
|
||||||
|
|
@ -79,6 +79,23 @@ class SystemProfile(BaseProfile):
|
||||||
operator_proxy_session_file_path = self.get_operator_proxy_session_path()
|
operator_proxy_session_file_path = self.get_operator_proxy_session_path()
|
||||||
with open(operator_proxy_session_file_path, 'w') as operator_proxy_session_file:
|
with open(operator_proxy_session_file_path, 'w') as operator_proxy_session_file:
|
||||||
operator_proxy_session_file.write(operator_proxy_session_file_contents)
|
operator_proxy_session_file.write(operator_proxy_session_file_contents)
|
||||||
|
if operator_proxy_session.location_country_code and operator_proxy_session.location_city_code:
|
||||||
|
try:
|
||||||
|
from core.models.orm_models.Location import Location
|
||||||
|
from core.models.manage.session_management import get_session
|
||||||
|
from sqlalchemy import select
|
||||||
|
session = get_session()
|
||||||
|
loc = session.execute(
|
||||||
|
select(Location).where(
|
||||||
|
(Location.country_code == operator_proxy_session.location_country_code) &
|
||||||
|
(Location.code == operator_proxy_session.location_city_code)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if loc:
|
||||||
|
self.location = loc
|
||||||
|
self.save()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def get_operator_proxy_session_path(self):
|
def get_operator_proxy_session_path(self):
|
||||||
return f'{self.get_config_path()}/operator_proxy_session.json'
|
return f'{self.get_config_path()}/operator_proxy_session.json'
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,8 @@ class WebServiceApiService:
|
||||||
data['operator'].get('hysteria2_host'),
|
data['operator'].get('hysteria2_host'),
|
||||||
data['operator'].get('vless_host'),
|
data['operator'].get('vless_host'),
|
||||||
data.get('server_ip'),
|
data.get('server_ip'),
|
||||||
|
data.get('location_country_code'),
|
||||||
|
data.get('location_city_code'),
|
||||||
)
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
185
core/services/encrypted_proxy/observer_helpers.py
Normal file
185
core/services/encrypted_proxy/observer_helpers.py
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
from core.errors.logger import logger
|
||||||
|
from core.utils.encrypted_proxy import killswitch
|
||||||
|
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConnectionStateData:
|
||||||
|
tunnel_interface: str = "?"
|
||||||
|
server_ip: Optional[str] = None
|
||||||
|
socks5_port: str = "?"
|
||||||
|
dns_enabled: bool = True
|
||||||
|
dns_active: bool = False
|
||||||
|
session_token: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def create_ui_state() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'spinner': None,
|
||||||
|
'monitor': None,
|
||||||
|
'timer_state': None,
|
||||||
|
'connection_data': ConnectionStateData(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cleanup helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def cleanup_spinner(state: Dict[str, Any]) -> None:
|
||||||
|
if state['spinner']:
|
||||||
|
try:
|
||||||
|
state['spinner'].stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to stop spinner: {e}")
|
||||||
|
finally:
|
||||||
|
state['spinner'] = None
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_monitor(state: Dict[str, Any]) -> None:
|
||||||
|
if state['monitor'] is not None:
|
||||||
|
try:
|
||||||
|
state['monitor'].stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to stop monitor: {e}")
|
||||||
|
finally:
|
||||||
|
state['monitor'] = None
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_timer(state: Dict[str, Any]) -> None:
|
||||||
|
if state['timer_state'] and state['timer_state'].get('stop'):
|
||||||
|
try:
|
||||||
|
state['timer_state']['stop'].set()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to stop timer: {e}")
|
||||||
|
finally:
|
||||||
|
if state['timer_state']:
|
||||||
|
state['timer_state']['stop'] = None
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_reconnecting_spinner(state: Dict[str, Any]) -> None:
|
||||||
|
if state['timer_state'] and state['timer_state'].get('reconnecting'):
|
||||||
|
try:
|
||||||
|
state['timer_state']['reconnecting'].set()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to stop reconnecting spinner: {e}")
|
||||||
|
finally:
|
||||||
|
if state['timer_state']:
|
||||||
|
state['timer_state']['reconnecting'] = None
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_all(state: Dict[str, Any]) -> None:
|
||||||
|
cleanup_spinner(state)
|
||||||
|
cleanup_timer(state)
|
||||||
|
cleanup_reconnecting_spinner(state)
|
||||||
|
cleanup_monitor(state)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Handler factory ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_event_handlers(state: Dict[str, Any], drop_state: Dict, tunnel_state: Dict):
|
||||||
|
"""Factory: returns event handler closures bound to state."""
|
||||||
|
|
||||||
|
def _update_connection_data(event) -> ConnectionStateData:
|
||||||
|
d = event.subject or {}
|
||||||
|
return ConnectionStateData(
|
||||||
|
tunnel_interface=d.get('tunnel_if', "?"),
|
||||||
|
server_ip=d.get('server_ip'),
|
||||||
|
socks5_port=d.get('socks5_port', "?"),
|
||||||
|
dns_enabled=d.get('dns_enabled', True),
|
||||||
|
dns_active=d.get('dns_active', False),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_ui_labels(conn: ConnectionStateData) -> None:
|
||||||
|
from cli.ui import (
|
||||||
|
label_connected, label_killswitch, label_dns, label_ipv6, label_ipv4
|
||||||
|
)
|
||||||
|
label_connected(f"tunnel={conn.tunnel_interface} port={conn.socks5_port}")
|
||||||
|
label_killswitch(killswitch.status())
|
||||||
|
label_dns(enabled=conn.dns_enabled, active=conn.dns_active)
|
||||||
|
label_ipv6(blocked=True)
|
||||||
|
if conn.server_ip:
|
||||||
|
label_ipv4(server_ip=conn.server_ip, reachable=True)
|
||||||
|
|
||||||
|
def on_connecting(event) -> None:
|
||||||
|
try:
|
||||||
|
cleanup_timer(state)
|
||||||
|
if (state['timer_state'] and
|
||||||
|
state['timer_state'].get('reconnecting') is None and
|
||||||
|
state['timer_state'].get('_retrying')):
|
||||||
|
from cli.ui import connecting_spinner
|
||||||
|
state['timer_state']['reconnecting'] = connecting_spinner()
|
||||||
|
|
||||||
|
d = event.subject or {}
|
||||||
|
attempt = d.get("attempt_count", "?")
|
||||||
|
total = d.get("maximum_number_of_attempts", "?")
|
||||||
|
|
||||||
|
cleanup_spinner(state)
|
||||||
|
from cli.ui import Spinner
|
||||||
|
state['spinner'] = Spinner(f"Connecting... attempt {attempt}/{total}")
|
||||||
|
state['spinner'].start()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in on_connecting: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def on_connected(event) -> None:
|
||||||
|
try:
|
||||||
|
cleanup_spinner(state)
|
||||||
|
cleanup_reconnecting_spinner(state)
|
||||||
|
|
||||||
|
if state['timer_state']:
|
||||||
|
state['timer_state']['_retrying'] = False
|
||||||
|
|
||||||
|
state['connection_data'] = _update_connection_data(event)
|
||||||
|
_update_ui_labels(state['connection_data'])
|
||||||
|
|
||||||
|
d = event.subject or {}
|
||||||
|
if state['monitor'] is not None:
|
||||||
|
state['monitor'].set_connection_data(socks5_port=d.get('socks5_port'))
|
||||||
|
|
||||||
|
if state['timer_state'] is not None:
|
||||||
|
from cli.ui import session_timer
|
||||||
|
state['timer_state']['stop'] = session_timer(
|
||||||
|
drop_state=drop_state,
|
||||||
|
tunnel_state=tunnel_state,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in on_connected: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def on_connected_token(event) -> None:
|
||||||
|
try:
|
||||||
|
if state['monitor'] is not None:
|
||||||
|
token = (event.subject or {}).get('session_token')
|
||||||
|
if token:
|
||||||
|
state['monitor'].set_session_token(token)
|
||||||
|
state['connection_data'].session_token = token
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in on_connected_token: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def on_disconnected(event) -> None:
|
||||||
|
try:
|
||||||
|
cleanup_all(state)
|
||||||
|
from cli.ui import label_disconnect
|
||||||
|
tunnel_if = (event.subject or {}).get('tunnel_if', '?')
|
||||||
|
label_disconnect(f"tunnel={tunnel_if}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in on_disconnected: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def on_error(event) -> None:
|
||||||
|
try:
|
||||||
|
cleanup_all(state)
|
||||||
|
from cli.ui import label_error
|
||||||
|
msg = event.subject
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
msg = msg.get('message', str(msg))
|
||||||
|
label_error(str(msg or "Unknown error"))
|
||||||
|
logger.error(f"Connection error: {msg}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in on_error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'on_connecting': on_connecting,
|
||||||
|
'on_connected': on_connected,
|
||||||
|
'on_connected_token': on_connected_token,
|
||||||
|
'on_disconnected': on_disconnected,
|
||||||
|
'on_error': on_error,
|
||||||
|
}
|
||||||
|
|
@ -2,21 +2,34 @@ import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
import threading
|
||||||
|
|
||||||
from core.Constants import Constants
|
from core.Constants import Constants
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SingboxRunner:
|
class SingboxRunner:
|
||||||
|
|
||||||
_WRAPPER = Constants.SINGBOX_WRAPPER
|
_WRAPPER = Constants.SINGBOX_WRAPPER
|
||||||
_PID_FILE = Path(Constants.SINGBOX_PID_FILE)
|
_PID_FILE = Path(Constants.SINGBOX_PID_FILE)
|
||||||
_LOG_FILE = Path(Constants.SINGBOX_LOG_FILE)
|
_LOG_FILE = Path(Constants.SINGBOX_LOG_FILE)
|
||||||
_CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR)
|
_CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR)
|
||||||
|
|
||||||
_START_TIMEOUT_S = 15.0
|
_START_TIMEOUT_S = 15.0
|
||||||
_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_FINAL_VERIFY_TIMEOUT_S = 2.0
|
||||||
|
_ORPHAN_POLL_S = 0.3
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._wrapper_process: Optional[subprocess.Popen] = None
|
||||||
|
|
||||||
def start(self, config_path: Path) -> bool:
|
def start(self, config_path: Path) -> bool:
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
@ -24,13 +37,14 @@ class SingboxRunner:
|
||||||
self._ensure_log_file()
|
self._ensure_log_file()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.Popen(
|
self._wrapper_process = subprocess.Popen(
|
||||||
['sudo', self._WRAPPER, str(config_path), 'start'],
|
['sudo', self._WRAPPER, str(config_path), 'start'],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
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'},
|
||||||
)
|
)
|
||||||
|
logger.info(f"Wrapper spawned (PID {self._wrapper_process.pid})")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'Wrapper not found: {self._WRAPPER}\n'
|
f'Wrapper not found: {self._WRAPPER}\n'
|
||||||
|
|
@ -51,6 +65,15 @@ class SingboxRunner:
|
||||||
)
|
)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
|
if self._wrapper_process is not None:
|
||||||
|
try:
|
||||||
|
self._wrapper_process.terminate()
|
||||||
|
self._wrapper_process.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._wrapper_process.kill()
|
||||||
|
finally:
|
||||||
|
self._wrapper_process = None
|
||||||
|
|
||||||
self._kill_orphans()
|
self._kill_orphans()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -63,6 +86,7 @@ class SingboxRunner:
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
logger.warning("Graceful stop failed, attempting emergency kill")
|
||||||
self._emergency_kill()
|
self._emergency_kill()
|
||||||
|
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
|
|
@ -100,7 +124,7 @@ class SingboxRunner:
|
||||||
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
||||||
)
|
)
|
||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
except Exception:
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
try:
|
try:
|
||||||
lines = self._LOG_FILE.read_text(errors='replace').splitlines()
|
lines = self._LOG_FILE.read_text(errors='replace').splitlines()
|
||||||
return '\n'.join(lines[-80:])
|
return '\n'.join(lines[-80:])
|
||||||
|
|
@ -121,6 +145,88 @@ class SingboxRunner:
|
||||||
tmp.unlink(missing_ok=True)
|
tmp.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# ── Kill helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _kill_orphans(self) -> None:
|
||||||
|
if not self._has_running_processes():
|
||||||
|
return
|
||||||
|
logger.debug("Found running sing-box processes, attempting graceful stop")
|
||||||
|
self._attempt_graceful_stop()
|
||||||
|
if self._wait_for_process_exit(self._ORPHAN_GRACEFUL_TIMEOUT_S):
|
||||||
|
logger.info("sing-box stopped gracefully")
|
||||||
|
return
|
||||||
|
logger.warning("Graceful stop timeout, force killing remaining sing-box processes")
|
||||||
|
self._force_kill_all_processes()
|
||||||
|
if self._wait_for_process_exit(self._ORPHAN_FINAL_VERIFY_TIMEOUT_S):
|
||||||
|
logger.info("All sing-box processes terminated")
|
||||||
|
return
|
||||||
|
logger.error("Failed to terminate all sing-box processes after force kill")
|
||||||
|
|
||||||
|
def _has_running_processes(self) -> bool:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True)
|
||||||
|
return result.returncode == 0
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _attempt_graceful_stop(self) -> None:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
['sudo', self._WRAPPER, '', 'stop'],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _wait_for_process_exit(self, timeout_s: float) -> bool:
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if not self._has_running_processes():
|
||||||
|
return True
|
||||||
|
time.sleep(self._ORPHAN_POLL_S)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _force_kill_all_processes(self) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['pgrep', '-x', 'sing-box'], capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return
|
||||||
|
pids = [int(p) for p in result.stdout.strip().splitlines() if p.strip().isdigit()]
|
||||||
|
for pid in pids:
|
||||||
|
self._force_kill_process(pid)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _force_kill_process(self, pid: int) -> None:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
['sudo', 'kill', '-9', str(pid)],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
logger.debug(f"Force killed sing-box process {pid}")
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _emergency_kill(self) -> None:
|
||||||
|
if not self._PID_FILE.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pid = int(self._PID_FILE.read_text().strip())
|
||||||
|
if pid > 0:
|
||||||
|
self._force_kill_process(pid)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._PID_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
def _ensure_log_file(self) -> None:
|
def _ensure_log_file(self) -> None:
|
||||||
self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if not self._LOG_FILE.exists():
|
if not self._LOG_FILE.exists():
|
||||||
|
|
@ -136,123 +242,77 @@ class SingboxRunner:
|
||||||
f' Allowed: {self._CONFIG_DIR}'
|
f' Allowed: {self._CONFIG_DIR}'
|
||||||
)
|
)
|
||||||
|
|
||||||
def _emergency_kill(self) -> None:
|
|
||||||
if not self._PID_FILE.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
# ── Abstract base monitor ─────────────────────────────────────────────────────
|
||||||
pid = int(self._PID_FILE.read_text().strip())
|
|
||||||
if pid > 0:
|
|
||||||
subprocess.run(
|
|
||||||
['sudo', 'kill', '-9', str(pid)],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
except (ValueError, OSError, subprocess.TimeoutExpired):
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
self._PID_FILE.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
def _kill_orphans(self) -> None:
|
CAUSE_DISCONNECT = 'disconnect'
|
||||||
try:
|
CAUSE_LEAK = 'leak'
|
||||||
result = subprocess.run(
|
CAUSE_DISPLACED = 'displaced'
|
||||||
['pgrep', '-x', 'sing-box'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
pids = [
|
class SingboxProcessMonitor(ABC):
|
||||||
int(p)
|
"""Abstract monitor for sing-box process health.
|
||||||
for p in result.stdout.strip().splitlines()
|
|
||||||
if p.strip().isdigit()
|
|
||||||
]
|
|
||||||
|
|
||||||
if not pids:
|
Clients inherit this and implement event callbacks based on their
|
||||||
return
|
threading model (threading.Event for CLI, Qt signals for GUI).
|
||||||
|
"""
|
||||||
|
|
||||||
subprocess.run(
|
POLL_INTERVAL = 5
|
||||||
['sudo', self._WRAPPER, '', 'stop'],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
deadline = time.monotonic() + 4.0
|
def __init__(self, runner: SingboxRunner):
|
||||||
|
self._runner = runner
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._running = False
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._last_poll_ts = None
|
||||||
|
|
||||||
while time.monotonic() < deadline:
|
def stop(self):
|
||||||
check = subprocess.run(
|
with self._lock:
|
||||||
['pgrep', '-x', 'sing-box'],
|
self._running = False
|
||||||
capture_output=True,
|
self._stop_event.set()
|
||||||
)
|
|
||||||
|
|
||||||
if check.returncode != 0:
|
@abstractmethod
|
||||||
return
|
def on_process_alive(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
time.sleep(0.3)
|
@abstractmethod
|
||||||
|
def on_process_dead(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
for pid in pids:
|
@abstractmethod
|
||||||
try:
|
def on_monitor_stopped(self, cause: str) -> None:
|
||||||
subprocess.run(
|
pass
|
||||||
['sudo', 'kill', '-9', str(pid)],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
|
||||||
pass
|
|
||||||
|
|
||||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
def _monitor_loop(self) -> None:
|
||||||
pass
|
while True:
|
||||||
try:
|
with self._lock:
|
||||||
result = subprocess.run(
|
if not self._running:
|
||||||
['pgrep', '-x', 'sing-box'],
|
break
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
interrupted = self._stop_event.wait(timeout=self.POLL_INTERVAL)
|
||||||
return
|
if interrupted:
|
||||||
|
break
|
||||||
|
|
||||||
pids = [
|
alive = self._runner.is_running()
|
||||||
int(p)
|
if alive:
|
||||||
for p in result.stdout.strip().splitlines()
|
self.on_process_alive()
|
||||||
if p.strip().isdigit()
|
else:
|
||||||
]
|
self.on_process_dead()
|
||||||
|
break
|
||||||
|
|
||||||
if not pids:
|
def run_blocking(self) -> None:
|
||||||
return
|
with self._lock:
|
||||||
|
self._running = True
|
||||||
|
|
||||||
subprocess.run(
|
self._stop_event.clear()
|
||||||
['sudo', self._WRAPPER, '', 'stop'],
|
t = threading.Thread(target=self._monitor_loop, daemon=False)
|
||||||
stdout=subprocess.DEVNULL,
|
t.start()
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
deadline = time.monotonic() + 4.0
|
self._stop_event.wait()
|
||||||
|
|
||||||
while time.monotonic() < deadline:
|
with self._lock:
|
||||||
check = subprocess.run(
|
self._running = False
|
||||||
['pgrep', '-x', 'sing-box'],
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if check.returncode != 0:
|
t.join(timeout=5)
|
||||||
return
|
self._runner.stop()
|
||||||
|
self.on_monitor_stopped("user_requested")
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
||||||
pass
|
|
||||||
Loading…
Reference in a new issue