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', '/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):
|
||||
"""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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.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
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