diff --git a/core/Constants.py b/core/Constants.py index 026e0b8..8cfe3e4 100644 --- a/core/Constants.py +++ b/core/Constants.py @@ -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') diff --git a/core/services/networking/api_requests/ApiResponseModel.py b/core/services/networking/api_requests/ApiResponseModel.py index 9d0e879..52ce8e8 100644 --- a/core/services/networking/api_requests/ApiResponseModel.py +++ b/core/services/networking/api_requests/ApiResponseModel.py @@ -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.""" diff --git a/core/services/networking/systemwide/dns.py b/core/services/networking/systemwide/dns.py index 0180e3b..5148ab9 100644 --- a/core/services/networking/systemwide/dns.py +++ b/core/services/networking/systemwide/dns.py @@ -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 diff --git a/core/services/networking/systemwide/encrypted_proxy/hysteria2_config.py b/core/services/networking/systemwide/encrypted_proxy/hysteria2_config.py new file mode 100644 index 0000000..91cc9f4 --- /dev/null +++ b/core/services/networking/systemwide/encrypted_proxy/hysteria2_config.py @@ -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, + }, + } diff --git a/core/services/networking/systemwide/encrypted_proxy/singbox.py b/core/services/networking/systemwide/encrypted_proxy/singbox.py index 0d88f7b..4d91656 100644 --- a/core/services/networking/systemwide/encrypted_proxy/singbox.py +++ b/core/services/networking/systemwide/encrypted_proxy/singbox.py @@ -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 diff --git a/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py b/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py index 6f11975..27784da 100644 --- a/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py +++ b/core/services/networking/systemwide/encrypted_proxy/singbox_runner.py @@ -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 ) diff --git a/core/services/networking/systemwide/encrypted_proxy/vless_config.py b/core/services/networking/systemwide/encrypted_proxy/vless_config.py new file mode 100644 index 0000000..1e8577f --- /dev/null +++ b/core/services/networking/systemwide/encrypted_proxy/vless_config.py @@ -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, + }, + } + diff --git a/core/services/networking/systemwide/killswitch.py b/core/services/networking/systemwide/killswitch.py index f1ca7c7..42b1aa3 100644 --- a/core/services/networking/systemwide/killswitch.py +++ b/core/services/networking/systemwide/killswitch.py @@ -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 diff --git a/core/utils/run_commands.py b/core/utils/run_commands.py new file mode 100644 index 0000000..6d2a473 --- /dev/null +++ b/core/utils/run_commands.py @@ -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))