Refactor run_generic_command to reuse and persist the same linux subprocess terminal
This commit is contained in:
parent
d68ae02335
commit
3db70541ab
9 changed files with 324 additions and 12 deletions
|
|
@ -70,3 +70,11 @@ class Constants:
|
||||||
KILLSWITCH_WRAPPER: Final[str] = os.environ.get(
|
KILLSWITCH_WRAPPER: Final[str] = os.environ.get(
|
||||||
'KILLSWITCH_WRAPPER', '/opt/hydra-veil/killswitch'
|
'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')
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ from typing import Optional, Any
|
||||||
class ErrorType(Enum):
|
class ErrorType(Enum):
|
||||||
"""Classified error categories."""
|
"""Classified error categories."""
|
||||||
SUCCESS = "success"
|
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_NOT_WORKING = "tor_not_working"
|
||||||
TOR_DNS_BLOCKED = "tor_dns_blocked"
|
TOR_DNS_BLOCKED = "tor_dns_blocked"
|
||||||
DNS_RESOLUTION = "dns_resolution"
|
DNS_RESOLUTION = "dns_resolution"
|
||||||
|
|
@ -29,6 +37,8 @@ class ApiResponse:
|
||||||
tor: bool = False
|
tor: bool = False
|
||||||
ip_address: str = None
|
ip_address: str = None
|
||||||
ask_clearweb: bool = None
|
ask_clearweb: bool = None
|
||||||
|
port: int = None
|
||||||
|
tor_needs_install: bool = False
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dict for backwards compatibility."""
|
"""Convert to dict for backwards compatibility."""
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# custom
|
# custom
|
||||||
from core.services.networking.systemwide.systemwide_errors import systemwide_hell_raiser
|
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.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.Constants import Constants
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from core.services.networking.systemwide.systemwide_errors import systemwide_hell_raiser
|
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.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.models.Result import Result, ResultError
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,6 @@ import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
||||||
QUANTITY_OF_ATTEMPTS = 2
|
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):
|
def set_dns_for_singbox(current_state: SystemState):
|
||||||
|
|
@ -30,7 +26,7 @@ def set_dns_for_singbox(current_state: SystemState):
|
||||||
logger.info(error_msg)
|
logger.info(error_msg)
|
||||||
return True
|
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:
|
if dns_result.valid:
|
||||||
return True
|
return True
|
||||||
|
|
@ -67,7 +63,7 @@ def end_singbox(
|
||||||
|
|
||||||
dead_singbox = orchestrate_closing(
|
dead_singbox = orchestrate_closing(
|
||||||
current_state=current_state,
|
current_state=current_state,
|
||||||
interface_name=SINGBOX_TUN_IF
|
interface_name=Constants.SINGBOX_TUN_IF
|
||||||
)
|
)
|
||||||
|
|
||||||
if dead_singbox.valid:
|
if dead_singbox.valid:
|
||||||
|
|
@ -133,12 +129,12 @@ def start_singbox(
|
||||||
)
|
)
|
||||||
|
|
||||||
# ============= FIREWALL =============
|
# ============= 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
|
# this is labeled "generic" for being protocol neutral
|
||||||
firewall_result = generic_enable_firewall_w_retry(
|
firewall_result = generic_enable_firewall_w_retry(
|
||||||
interface_name=SINGBOX_TUN_IF,
|
interface_name=Constants.SINGBOX_TUN_IF,
|
||||||
server_ip=server_ip,
|
server_ip=server_ip,
|
||||||
internal_subnet = SINGBOX_INTERNAL_SUBNET,
|
internal_subnet = Constants.SINGBOX_INTERNAL_SUBNET,
|
||||||
max_retries = 2,
|
max_retries = 2,
|
||||||
connection_observer=connection_observer
|
connection_observer=connection_observer
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from core.services.networking.systemwide.systemwide_errors import systemwide_hell_raiser
|
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.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.models.Result import Result, ResultError
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
# import subprocess
|
# import subprocess
|
||||||
|
|
|
||||||
149
core/utils/run_commands.py
Normal file
149
core/utils/run_commands.py
Normal 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))
|
||||||
Loading…
Reference in a new issue