Refactor run_generic_command to reuse and persist the same linux subprocess terminal

This commit is contained in:
SimplifiedPrivacy 2026-07-31 12:58:42 -04:00
parent d68ae02335
commit 3db70541ab
9 changed files with 324 additions and 12 deletions

View file

@ -70,3 +70,11 @@ class Constants:
KILLSWITCH_WRAPPER: Final[str] = os.environ.get(
'KILLSWITCH_WRAPPER', '/opt/hydra-veil/killswitch'
)
# ── sing-box ──────────────────────────────────────────────────────────────
SINGBOX_CONFIG_DIR: Final[str] = f'{HV_DATA_HOME}/configs'
SINGBOX_PID_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.pid'
SINGBOX_LOG_FILE: Final[str] = f'{HV_RUNTIME_DATA_HOME}/singbox.log'
SINGBOX_TUN_IF: Final[str] = os.environ.get('SINGBOX_TUN_IF', 'tun0')
SINGBOX_INTERNAL_SUBNET: Final[str] = os.environ.get('SINGBOX_INTERNAL_SUBNET', '172.19.0.0/30')
SINGBOX_INTERNAL_ADDR: Final[str] = os.environ.get('SINGBOX_INTERNAL_ADDR', '172.19.0.1/30')

View file

@ -5,6 +5,14 @@ from typing import Optional, Any
class ErrorType(Enum):
"""Classified error categories."""
SUCCESS = "success"
DEFAULT_TOR_PORT_DEAD = "default_tor_port_dead"
TOR_NOT_INSTALLED = "tor_not_installed"
REFUSAL_TO_INSTALL_TOR = "refusal_to_install_tor"
PORT_OPEN = "port_open"
PORT_USED = "port_used"
TOR_ON_DIFFERENT_PORT = "tor_on_different_port"
TOR_INSTALLED_BUT_DEAD = "tor_installed_but_dead"
CANT_BOOTSTRAP = "cant_bootstrap"
TOR_NOT_WORKING = "tor_not_working"
TOR_DNS_BLOCKED = "tor_dns_blocked"
DNS_RESOLUTION = "dns_resolution"
@ -29,6 +37,8 @@ class ApiResponse:
tor: bool = False
ip_address: str = None
ask_clearweb: bool = None
port: int = None
tor_needs_install: bool = False
def to_dict(self) -> dict:
"""Convert to dict for backwards compatibility."""

View file

@ -1,7 +1,7 @@
# custom
from core.services.networking.systemwide.systemwide_errors import systemwide_hell_raiser
from core.utils.basic_operations.wrap_with import wrap_with
from core.utils.basic_operations.run_generic_command import run_generic_command
from core.utils.run_commands import run_generic_command
from core.Constants import Constants
from core.models.Result import Result, ResultError
from core.errors.logger import logger

View file

@ -0,0 +1,60 @@
from core.Constants import Constants
def build_hysteria_config(username: str, password: str,
server_host: str, socks5_port: int,
server_ip: str) -> dict:
return {
"dns": {
"servers": [{"tag": "tunnel-dns", "type": "udp", "server": "9.9.9.9"}],
"final": "tunnel-dns",
"strategy": "ipv4_only",
"independent_cache": True,
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": Constants.SINGBOX_TUN_IF,
"address": [Constants.SINGBOX_INTERNAL_ADDR],
"mtu": 9000,
"auto_route": True,
"stack": "gvisor",
},
{
"type": "socks",
"tag": "socks-in",
"listen": "127.0.0.1",
"listen_port": socks5_port,
},
],
"outbounds": [
{"type": "direct", "tag": "direct"},
{"type": "block", "tag": "block"},
{
"type": "hysteria2",
"tag": "proxy",
"server": server_ip,
"server_port": 443,
"password": f"{username}:{password}",
"tls": {
"enabled": True,
"server_name": server_host,
"insecure": False,
},
},
],
"route": {
"rules": [
{"protocol": "dns", "action": "hijack-dns"},
{"ip_cidr": [Constants.SINGBOX_INTERNAL_SUBNET], "action": "hijack-dns"},
{"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"},
{"ip_is_private": True, "outbound": "direct"},
{"ip_version": 6, "outbound": "block"},
{"inbound": ["tun-in", "socks-in"], "outbound": "proxy"},
],
"final": "proxy",
"default_domain_resolver": "tunnel-dns",
"auto_detect_interface": True,
},
}

View file

@ -1,6 +1,6 @@
from core.services.networking.systemwide.systemwide_errors import systemwide_hell_raiser
from core.utils.basic_operations.wrap_with import wrap_with
from core.utils.basic_operations.run_generic_command import run_generic_command
from core.utils.run_commands import run_generic_command import run_generic_command
from core.models.Result import Result, ResultError
from core.errors.logger import logger

View file

@ -16,10 +16,6 @@ import subprocess
import time
QUANTITY_OF_ATTEMPTS = 2
SINGBOX_TUN_IF = 'tun0'
SINGBOX_INTERNAL_SUBNET = '172.19.0.0/30'
SINGBOX_INTERNAL_ADDR = '172.19.0.1/30'
SINGBOX_DEFAULT_DNS = '9.9.9.9'
def set_dns_for_singbox(current_state: SystemState):
@ -30,7 +26,7 @@ def set_dns_for_singbox(current_state: SystemState):
logger.info(error_msg)
return True
dns_result = dns.set_on(network_interface=SINGBOX_TUN_IF, dns_should_be=SINGBOX_DEFAULT_DNS)
dns_result = dns.set_on(network_interface=Constants.SINGBOX_TUN_IF, dns_should_be=Constants.SINGBOX_DEFAULT_DNS)
if dns_result.valid:
return True
@ -67,7 +63,7 @@ def end_singbox(
dead_singbox = orchestrate_closing(
current_state=current_state,
interface_name=SINGBOX_TUN_IF
interface_name=Constants.SINGBOX_TUN_IF
)
if dead_singbox.valid:
@ -133,12 +129,12 @@ def start_singbox(
)
# ============= FIREWALL =============
logger.info(f"[{function_name}] Attempting to enable the Firewall for {SINGBOX_TUN_IF} and {SINGBOX_INTERNAL_SUBNET}...")
logger.info(f"[{function_name}] Attempting to enable the Firewall for {SINGBOX_TUN_IF} and {Constants.SINGBOX_INTERNAL_SUBNET}...")
# this is labeled "generic" for being protocol neutral
firewall_result = generic_enable_firewall_w_retry(
interface_name=SINGBOX_TUN_IF,
interface_name=Constants.SINGBOX_TUN_IF,
server_ip=server_ip,
internal_subnet = SINGBOX_INTERNAL_SUBNET,
internal_subnet = Constants.SINGBOX_INTERNAL_SUBNET,
max_retries = 2,
connection_observer=connection_observer
)

View file

@ -0,0 +1,89 @@
from core.Constants import Constants
def parse_vless_link(link: str) -> dict:
link = link.replace("vless://", "")
uuid, rest = link.split("@", 1)
hostport, qs = rest.split("?", 1)
query = qs.split("#")[0]
host, port = hostport.rsplit(":", 1)
params = {}
for part in query.split("&"):
if "=" in part:
k, v = part.split("=", 1)
params[k] = v
sni = params.get("sni", host)
ws_host = params.get("host", "").strip() or sni
return {
"uuid": uuid,
"host": host,
"port": int(port),
"path": unquote(params.get("path", "/vless")),
"sni": sni,
"ws_host": ws_host,
"security": params.get("security", "tls"),
"network": params.get("type", "ws"),
}
def build_vless_config(vless: dict, socks5_port: int, server_ip: str) -> dict:
return {
"dns": {
"servers": [{"tag": "tunnel-dns", "type": "udp", "server": "9.9.9.9"}],
"final": "tunnel-dns",
"strategy": "ipv4_only",
"independent_cache": True,
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": Constants.SINGBOX_TUN_IF,
"address": [Constants.SINGBOX_INTERNAL_ADDR],
"mtu": 9000,
"auto_route": True,
"stack": "gvisor",
},
{
"type": "socks",
"tag": "socks-in",
"listen": "127.0.0.1",
"listen_port": socks5_port,
},
],
"outbounds": [
{"type": "direct", "tag": "direct"},
{"type": "block", "tag": "block"},
{
"type": "vless",
"tag": "proxy",
"server": server_ip,
"server_port": vless["port"],
"uuid": vless["uuid"],
"tls": {
"enabled": vless["security"] == "tls",
"server_name": vless["sni"],
"insecure": False,
},
"transport": {
"type": "ws",
"path": vless["path"],
"headers": {"Host": vless["ws_host"]},
},
},
],
"route": {
"rules": [
{"protocol": "dns", "action": "hijack-dns"},
{"ip_cidr": [Constants.SINGBOX_INTERNAL_SUBNET], "action": "hijack-dns"},
{"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"},
{"ip_is_private": True, "outbound": "direct"},
{"ip_version": 6, "outbound": "block"},
{"inbound": ["tun-in", "socks-in"], "outbound": "proxy"},
],
"final": "proxy",
"default_domain_resolver": "tunnel-dns",
"auto_detect_interface": True,
},
}

View file

@ -1,6 +1,6 @@
from core.services.networking.systemwide.systemwide_errors import systemwide_hell_raiser
from core.utils.basic_operations.wrap_with import wrap_with
from core.utils.basic_operations.run_generic_command import run_generic_command
from core.utils.run_commands import run_generic_command
from core.models.Result import Result, ResultError
from core.errors.logger import logger
# import subprocess

149
core/utils/run_commands.py Normal file
View file

@ -0,0 +1,149 @@
from core.models.Result import Result, ResultError
from core.errors.logger import logger
import subprocess
import shlex
import select
import time
import fcntl
import os
# Global
_bash_session = None
def init_terminal():
"""Initialize the global bash terminal session."""
global _bash_session
env = {**os.environ, "SUDO_ASKPASS": "/bin/false"}
_bash_session = subprocess.Popen(
['bash', '--noprofile', '--norc'],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=env
)
# Make stdout non-blocking
flags = fcntl.fcntl(_bash_session.stdout, fcntl.F_GETFL)
fcntl.fcntl(_bash_session.stdout, fcntl.F_SETFL, flags | os.O_NONBLOCK)
logger.info("Bash terminal initialized")
def get_terminal():
"""Return the global bash terminal or raise RuntimeError."""
if _bash_session is None:
raise RuntimeError("Terminal not initialized. Call init_terminal() first.")
return _bash_session
def close_terminal():
"""Close the global bash terminal session."""
global _bash_session
if _bash_session is not None:
_bash_session.terminate()
_bash_session.wait()
_bash_session = None
logger.info("Bash terminal closed")
def extract_returncode(line: str, marker: str) -> tuple[int, str]:
"""Extract returncode and text before marker from a completion line."""
before, after = line.split(marker, 1)
returncode = int(after.strip())
return returncode, before
def _run_command_via_terminal(
command: list,
timeout: float | None = None
) -> tuple[int, str]:
"""
Execute a command via persistent bash session.
Returns (returncode, stdout, stderr)
"""
try:
terminal = get_terminal()
except RuntimeError:
init_terminal()
terminal = get_terminal()
# Convert command list to properly quoted bash string
cmd_str = ' '.join(shlex.quote(arg) for arg in command)
# Use a marker to detect command completion
# Redirect stderr to stdout and append marker with exit code
marker = "::__CMD_DONE__::"
# wrapped_cmd = f"{cmd_str} 2>&1; echo \"{marker}$?\"\n"
wrapped_cmd = f"{cmd_str} 2>&1; echo {marker}$?\n"
terminal.stdin.write(wrapped_cmd)
terminal.stdin.flush()
output_lines = []
returncode = 1
timeout = timeout or float('inf')
start_time = time.time()
try:
while time.time() - start_time < timeout:
line = terminal.stdout.readline() # non-blocking due to init fcntl
if not line:
time.sleep(0.02)
continue
if marker in line:
returncode, before = extract_returncode(line, marker)
if before:
output_lines.append(before)
return returncode, ''.join(output_lines)
output_lines.append(line)
# If we exit the loop without returning first, timeout occurred
logger.error(f"Command timed out after {timeout}s")
close_terminal()
raise TimeoutError(f"Command exceeded {timeout}s limit")
except Exception as e:
logger.error(f"We hit an error in the loop of reading, so we'll close the terminal, then the caller can deal with {e}")
close_terminal()
raise
def run_generic_command(
command: list,
human_readable_goal: str,
timeout: float | None = None
) -> Result:
output_data = None
# Add -n flag to sudo for non-interactive mode (no prompts)
if command and command[0] == "sudo":
if "-n" not in command:
command = [command[0], "-n"] + command[1:]
try:
returncode, stdout = _run_command_via_terminal(command, timeout)
output_data = stdout
if returncode == 0:
logger.info(f"{human_readable_goal} was successful")
return Result(valid=True, data=output_data)
else:
# Try stderr first, fallback to stdout for error parsing
error_output = (stderr or stdout).strip()
error_enum = parse_errors(error_output)
logger.error(f"{human_readable_goal} Failed, error type is {error_enum}.")
return Result(valid=False, error_type=error_enum, goal=human_readable_goal, message=error_output)
except TimeoutError as e:
return Result(valid=False, error_type=ResultError.TIMEOUT, goal=human_readable_goal, data=output_data, message=f"Command timed out {e}")
except Exception as e:
return Result(valid=False, error_type=ResultError.UNKNOWN, goal=human_readable_goal, data=output_data, message=str(e))