Introduced Singbox Proxy Feature Start Utility & Script. But it's not yet integrated to be used

This commit is contained in:
SimplifiedPrivacy 2026-07-29 15:04:33 -04:00
parent 46a84661a0
commit 5377b7348b
10 changed files with 279 additions and 8 deletions

View file

@ -0,0 +1,26 @@
#!/bin/bash
set -eo pipefail
SINGBOX_BIN="/usr/bin/singbox"
LOG_FILE="~/.local/share/hydra-veil/singbox.txt"
run_binary() {
"$SINGBOX_BIN" run -c "$1" >> "$LOG_FILE" 2>&1 &
_new_pid=$!
echo "$_new_pid"
}
# get result in python: pid = int(result.stdout.strip())
gracefully_close() {
sudo kill -TERM "$1" 2>/dev/null || true
}
forcefully_kill() {
sudo kill -TERM "$1" 2>/dev/null || true
}
hello_world_test() {
echo "hello world $1"
}

View file

@ -1,6 +1,20 @@
from core.models.Result import Result, ResultError
from typing import Optional
class DNSError(Exception):
"""There are issues with DNS"""
def __init__(self, result: Optional[Result]):
if result:
self.result = result
if result.message and result.message is not None:
error_msg = result.message
else:
error_msg = result.error_type
else:
error_msg = "Failed to start DNS"
super().__init__(error_msg)
class FirewallError(Exception):
"""There are issues with the Firewall"""
def __init__(self, result: Optional[Result]):

View file

@ -15,6 +15,8 @@ class ResultError(Enum):
DATABASE = "database"
PERMISSION = "permission"
SUBSCRIPTION = "subscription"
PROCESS_GOT_KILLED = "process_got_killed"
PROCESS_WONT_START = "process_wont_start"
NMCLI = "nmcli_issues"
FIREWALL = "firewall"
CLIENT_DNS = "client_dns"

View file

@ -13,7 +13,7 @@ DNS_SCRIPT = f"/opt/hydra-veil/dns"
def set_on(network_interface: str, dns_should_be: str) -> Result:
command = ["sudo", DNS_SCRIPT, "set", network_interface, dns_should_be]
human_readable_goal = "Set DNS"
return run_generic_command(command, human_readable_goal)
return run_generic_command(command, human_readable_goal, timeout=5)
# requires script to have sudo
@ -21,7 +21,7 @@ def set_on(network_interface: str, dns_should_be: str) -> Result:
def revert() -> Result:
command = ["sudo", DNS_SCRIPT, "revert"]
human_readable_goal = "DNS revert"
return run_generic_command(command, human_readable_goal)
return run_generic_command(command, human_readable_goal, timeout=7)
# does NOT need sudo

View file

@ -0,0 +1,34 @@
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.models.Result import Result, ResultError
from core.errors.logger import logger
SINGBOX_WRAPPER = "/opt/hydra-veil/singbox_runner" # needs chmod 755
@wrap_with(systemwide_hell_raiser)
def start(config_path: str) -> Result:
command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && run_binary "{config_path}"']
human_readable_goal = "Start running Singbox"
return run_generic_command(command, human_readable_goal, timeout=5)
@wrap_with(systemwide_hell_raiser)
def turn_off(process_id: str) -> Result:
command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && gracefully_close "{process_id}"']
human_readable_goal = "Gracefully Stop Singbox"
return run_generic_command(command, human_readable_goal, timeout=5)
@wrap_with(systemwide_hell_raiser)
def force_kill(process_id: str) -> Result:
command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && forcefully_kill "{process_id}"']
human_readable_goal = "Force Kill Singbox"
return run_generic_command(command, human_readable_goal, timeout=5)
@wrap_with(systemwide_hell_raiser)
def hello_world_test(name: str) -> str:
command = ['bash', '-c', f'. {SINGBOX_WRAPPER} && hello_world_test "{name}"']
human_readable_goal = "Hello world test"
return run_generic_command(command, human_readable_goal, timeout=5)

View file

@ -0,0 +1,136 @@
from core.services.networking.systemwide.encrypted_proxy import singbox
from core.utils.basic_operations import process_tools
from core.models.Result import Result, ResultError
from core.errors.logger import logger
from core.Constants import Constants
from core.models.system.SystemState import SystemState
from core.controllers.SystemStateController import SystemStateController
from core.services.networking.systemwide.systemwide_utils import get_firewall_setting, get_dns_setting
from core.services.networking.systemwide import dns
from core.services.networking.systemwide.general_connection_tools.general_firewall_dns_tools import generic_enable_firewall_w_retry
from core.errors.exceptions import FirewallError, DNSError
# generic
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):
do_they_even_use_systemd = dns.is_systemd_enabled()
if not do_they_even_use_systemd.valid:
error_msg = "You don't use SystemD, so we are skipping setting your DNS."
logger.info(error_msg)
return True
dns_result = dns.set_on(network_interface=SINGBOX_TUN_IF, dns_should_be=SINGBOX_DEFAULT_DNS)
if dns_result.valid:
return True
else:
# Update the state for the DNS failure, but not firewall
current_state.firewalled = True
current_state.dns_set = False
SystemStateController.update_or_create(current_state)
raise DNSError(dns_result)
def _attempt_start_with_retry(config_path: str, quantity_of_attempts: int = 2) -> Result:
current_attempt = 0
while current_attempt < quantity_of_attempts:
activation_result = singbox.start(config_path)
if activation_result.valid:
return activation_result
else:
current_attempt = current_attempt + 1
logger.error(f"[SINGBOX] Attempt {current_attempt} for Singbox Failed. Because: {activation_result.message}. Trying again..")
def start_singbox(
profile_id: int,
config_path: str,
server_ip: str,
connection_observer: Optional[ConnectionObserver] = None
) -> Result:
"""
Purpose:
Orchestrates the full flow of the Singbox connection.
Steps:
1) Validate inputs
2) Start it with a retry
3) Evaluate if the process id is still up.
4) If it's up, setup the State JSON
5) Turn on Firewall
6) Turn on DNS
7) Update the State
"""
function_name = "START_SINGBOX"
# ============= INPUT VALIDATION =============
requirements = [profile_id, config_path, server_ip]
for each_requirement in requirements:
if each_requirement is None:
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"Invalid inputs into {function_name}")
# ============= START PROCESS =============
activation_result = _attempt_start_with_retry(config_path=config_path, quantity_of_attempts=QUANTITY_OF_ATTEMPTS)
if not activation_result.valid:
error_msg = f"Singbox failed to start after {QUANTITY_OF_ATTEMPTS} attempts"
logger.error(f"[{function_name}] {error_msg}")
return Result(valid=False, error_type=ResultError=PROCESS_WONT_START, message=error_msg)
process_id = activation_result.data
time.sleep(2)
active = process_tools.is_running(process_id)
if not active:
error_msg = f"While Singbox might have literally allowed the binary to begin, it's killing the process on id {process_id}"
logger.error(f"[{function_name}] {error_msg}")
return Result(valid=False, error_type=ResultError.PROCESS_GOT_KILLED, message=error_msg, data=process_id)
# ============= SETUP STATE =============
# Even if firewall is off, we want to save the fact we turned Singbox on, before we raise errors.
logger.info(f"Setting State JSON with INTENDED firewall & Dns settings")
current_state = SystemStateController.create(
profile_id=profile.id,
firewalled=True, # intended setting, not result yet
dns_set=True # intended setting, not result yet
)
# ============= FIREWALL =============
logger.info(f"[{function_name}] Attempting to enable the Firewall for {SINGBOX_TUN_IF} and {SINGBOX_INTERNAL_SUBNET}...")
# this is labeled "generic" for being protocol neutral
firewall_result = generic_enable_firewall_w_retry(
interface_name=SINGBOX_TUN_IF,
server_ip=server_ip,
internal_subnet = SINGBOX_INTERNAL_SUBNET,
max_retries = 2,
connection_observer=connection_observer
)
if not firewall_result.valid:
# Update the state of the failure,
current_state.firewalled = False
current_state.dns_set = False
SystemStateController.update_or_create(current_state)
raise FirewallError(firewall_result) # Now end the party.
# ============= DNS =============
dns_result = set_dns_for_singbox(current_state)
# raises error if not okay.
# ============= CONCLUSION =============
return Result(valid=True, data=current_state)

View file

@ -0,0 +1,49 @@
from core.services.networking.systemwide import killswitch
from core.errors.logger import logger
from core.errors.exceptions import FirewallError
from core.models.Result import Result, ResultError
def generic_enable_firewall_w_retry(
interface_name: str,
server_ip: str,
internal_subnet: str = None,
max_retries: int = 2
) -> None:
"""
Purpose:
Protocol-neutral, Enable the firewall with automatic retry on failure.
Called by:
start_singbox
Raises Errors:
Yes, FirewallError
"""
last_result = None
for attempt in range(1, max_retries + 1):
logger.info(f"Enabling firewall (attempt {attempt}/{max_retries})")
turn_on_result = killswitch.arm(
server_ip=server_ip,
tunnel_if=interface_name,
internal_subnet=internal_subnet
)
if turn_on_result is None:
logger.error("turn_on_firewall() returned None")
continue
last_result = turn_on_result
firewall_status = killswitch.status()
if turn_on_result.valid and firewall_status:
logger.info("Firewall successfully enabled")
return last_result
logger.warning(f"Firewall enable failed on attempt {attempt}, because {turn_on_result.message}")
return last_result

View file

@ -3,7 +3,7 @@ from core.utils.basic_operations.wrap_with import wrap_with
from core.utils.basic_operations.run_generic_command import run_generic_command
from core.models.Result import Result, ResultError
from core.errors.logger import logger
import subprocess
# import subprocess
KILLSWITCH_WRAPPER = "/opt/hydra-veil/firewall" # needs chmod 755
@ -13,13 +13,13 @@ def arm(server_ip: str, tunnel_if: str, internal_subnet: str = None) -> Result:
if internal_subnet:
command.append(internal_subnet)
human_readable_goal = "Arming the Firewall"
return run_generic_command(command, human_readable_goal)
return run_generic_command(command, human_readable_goal, timeout=5)
@wrap_with(systemwide_hell_raiser)
def disarm() -> bool:
command = ["sudo", KILLSWITCH_WRAPPER, "disarm"]
human_readable_goal = "Disarming the Firewall"
return run_generic_command(command, human_readable_goal)
return run_generic_command(command, human_readable_goal, timeout=5)
def status() -> bool:
command = ["sudo", KILLSWITCH_WRAPPER, "status"]

View file

@ -169,6 +169,7 @@ def __establish_system_connection(
raise ConnectionError('The connection could not be established.')
# ============= UPDATE STATE =============
if dns_setting and not did_dns_work:
current_state.firewalled = firewall_tracker
current_state.dns_set = did_dns_work
SystemStateController.update_or_create(current_state)

View file

@ -0,0 +1,9 @@
from core.errors.logger import logger
import os
def is_running(pid: int) -> bool | None:
if not isinstance(pid, int):
logger.error("[SUBPROCESS CHECKER] Invalid int passed in for process id!")
return None
return os.path.exists(f"/proc/{pid}")