Introduced Singbox Proxy Feature Start Utility & Script. But it's not yet integrated to be used
This commit is contained in:
parent
46a84661a0
commit
5377b7348b
10 changed files with 279 additions and 8 deletions
26
core/assets/sudo_scripts/singbox_runner
Normal file
26
core/assets/sudo_scripts/singbox_runner
Normal 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
from core.models.Result import Result, ResultError
|
from core.models.Result import Result, ResultError
|
||||||
from typing import Optional
|
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):
|
class FirewallError(Exception):
|
||||||
"""There are issues with the Firewall"""
|
"""There are issues with the Firewall"""
|
||||||
def __init__(self, result: Optional[Result]):
|
def __init__(self, result: Optional[Result]):
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class ResultError(Enum):
|
||||||
DATABASE = "database"
|
DATABASE = "database"
|
||||||
PERMISSION = "permission"
|
PERMISSION = "permission"
|
||||||
SUBSCRIPTION = "subscription"
|
SUBSCRIPTION = "subscription"
|
||||||
|
PROCESS_GOT_KILLED = "process_got_killed"
|
||||||
|
PROCESS_WONT_START = "process_wont_start"
|
||||||
NMCLI = "nmcli_issues"
|
NMCLI = "nmcli_issues"
|
||||||
FIREWALL = "firewall"
|
FIREWALL = "firewall"
|
||||||
CLIENT_DNS = "client_dns"
|
CLIENT_DNS = "client_dns"
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ DNS_SCRIPT = f"/opt/hydra-veil/dns"
|
||||||
def set_on(network_interface: str, dns_should_be: str) -> Result:
|
def set_on(network_interface: str, dns_should_be: str) -> Result:
|
||||||
command = ["sudo", DNS_SCRIPT, "set", network_interface, dns_should_be]
|
command = ["sudo", DNS_SCRIPT, "set", network_interface, dns_should_be]
|
||||||
human_readable_goal = "Set DNS"
|
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
|
# requires script to have sudo
|
||||||
|
|
@ -21,7 +21,7 @@ def set_on(network_interface: str, dns_should_be: str) -> Result:
|
||||||
def revert() -> Result:
|
def revert() -> Result:
|
||||||
command = ["sudo", DNS_SCRIPT, "revert"]
|
command = ["sudo", DNS_SCRIPT, "revert"]
|
||||||
human_readable_goal = "DNS 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
|
# does NOT need sudo
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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.utils.basic_operations.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
|
||||||
import subprocess
|
# import subprocess
|
||||||
|
|
||||||
KILLSWITCH_WRAPPER = "/opt/hydra-veil/firewall" # needs chmod 755
|
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:
|
if internal_subnet:
|
||||||
command.append(internal_subnet)
|
command.append(internal_subnet)
|
||||||
human_readable_goal = "Arming the Firewall"
|
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)
|
@wrap_with(systemwide_hell_raiser)
|
||||||
def disarm() -> bool:
|
def disarm() -> bool:
|
||||||
command = ["sudo", KILLSWITCH_WRAPPER, "disarm"]
|
command = ["sudo", KILLSWITCH_WRAPPER, "disarm"]
|
||||||
human_readable_goal = "Disarming the Firewall"
|
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:
|
def status() -> bool:
|
||||||
command = ["sudo", KILLSWITCH_WRAPPER, "status"]
|
command = ["sudo", KILLSWITCH_WRAPPER, "status"]
|
||||||
|
|
|
||||||
|
|
@ -169,9 +169,10 @@ def __establish_system_connection(
|
||||||
raise ConnectionError('The connection could not be established.')
|
raise ConnectionError('The connection could not be established.')
|
||||||
|
|
||||||
# ============= UPDATE STATE =============
|
# ============= UPDATE STATE =============
|
||||||
current_state.firewalled = firewall_tracker
|
if dns_setting and not did_dns_work:
|
||||||
current_state.dns_set = did_dns_work
|
current_state.firewalled = firewall_tracker
|
||||||
SystemStateController.update_or_create(current_state)
|
current_state.dns_set = did_dns_work
|
||||||
|
SystemStateController.update_or_create(current_state)
|
||||||
|
|
||||||
return Result(valid=True, data=current_state)
|
return Result(valid=True, data=current_state)
|
||||||
|
|
||||||
|
|
|
||||||
9
core/utils/basic_operations/process_tools.py
Normal file
9
core/utils/basic_operations/process_tools.py
Normal 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}")
|
||||||
Loading…
Reference in a new issue