IP address checking for singbox enforced in singbox runner

This commit is contained in:
SimplifiedPrivacy 2026-08-10 17:58:31 -04:00
parent 2a6b1eacd5
commit c3b50460cd
4 changed files with 137 additions and 8 deletions

View file

@ -19,6 +19,7 @@ class ResultError(Enum):
SUBSCRIPTION = "subscription" SUBSCRIPTION = "subscription"
PROCESS_GOT_KILLED = "process_got_killed" PROCESS_GOT_KILLED = "process_got_killed"
PROCESS_WONT_START = "process_wont_start" PROCESS_WONT_START = "process_wont_start"
PROCESS_MISMATCH = "process_mismatch"
NMCLI = "nmcli_issues" NMCLI = "nmcli_issues"
FIREWALL = "firewall" FIREWALL = "firewall"
CLIENT_DNS = "client_dns" CLIENT_DNS = "client_dns"
@ -26,6 +27,7 @@ class ResultError(Enum):
INTERFACE = "interface" INTERFACE = "interface"
TIMEOUT = "timeout" TIMEOUT = "timeout"
INVALID_API_REPLY = "invalid_api_reply" INVALID_API_REPLY = "invalid_api_reply"
LEAK_ISSUE = "leak_issue"
UNKNOWN = "unknown" UNKNOWN = "unknown"
@dataclass @dataclass

View file

@ -0,0 +1,96 @@
from core.services.networking.httpx.classify_response import classify_response
# models
from core.models.Result import Result, ResultError
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
# errors/loggers/observers
from core.Constants import Constants
from core.errors.logger import logger
from essentials.observers.ConnectionObserver import ConnectionObserver
# generic
import time
import random
import httpx
from typing import Optional
API_ENDPOINT_FORMATS = {
"https://api.ipify.org?format=json": "ip",
"https://api.myip.com": "ip",
"https://wtfismyip.com/json": "YourFuckingIPAddress",
"https://httpbin.org/ip": "origin",
"https://api.myip.com": "ip",
"https://ipinfo.io/json": "ip"
}
MAX_ATTEMPTS = 3
def test(connection_observer: Optional[ConnectionObserver]) -> Result:
"""
Cycles through 3 possible endpoints,
to attempt to get an IP address for the systemwide connection.
"""
attempt = 0
endpoint_index = 0
endpoint_list = list(API_ENDPOINT_FORMATS.keys())
_ip_test_client = httpx.Client(http2=True, timeout=6)
while attempt < MAX_ATTEMPTS:
endpoint_url = endpoint_list[attempt]
attempt += 1
logger.info(f"Attempt {attempt}: Trying {endpoint_url}")
if connection_observer:
connection_observer.notify("connecting", f"Testing IP. Attempt {attempt}")
try:
raw_response = _ip_test_client.get(endpoint_url)
filtered_response = classify_response(raw_response)
if filtered_response.valid:
ip_address = extract_ip_from_response(
response=filtered_response.data,
api_url=endpoint_url
)
if ip_address is not None:
return Result(valid=True, data=ip_address)
else:
logger.error(f"Skipping {endpoint_url}")
except Exception as e:
logger.error(f"We are NOT error handling this. Skipping {endpoint_url}. Invalid API reply or failed connection {str(e)}")
logger.error(f"We went through {MAX_ATTEMPTS} attempts and could not get a reply.")
return Result(valid=False, error_type=ResultError.CONNECTION, message="Could not connect.")
def extract_ip_from_response(response: dict, api_url: str) -> str | bool:
"""
Purpose:
Extract the IP address from the API's response,
based on which keys that API uses.
Rank:
Helper
On Success:
Returns String of IP address
On Failure,
Returns None
"""
if api_url not in API_ENDPOINT_FORMATS:
logger.critical(f"Systemic Bug! Our local data in extract_ip_from_response doesn't match what we just read for the URL {api_url}.")
return None
desired_key = API_ENDPOINT_FORMATS[api_url]
if desired_key not in response:
logger.error(f"Our local data does NOT match the new format for {api_url}.")
return None
final_value = response[desired_key]
return final_value

View file

@ -8,7 +8,7 @@ from core.services.networking.systemwide.encrypted_proxy import singbox
from core.utils.basic_operations import process_tools from core.utils.basic_operations import process_tools
from core.utils.run_commands import run_generic_command from core.utils.run_commands import run_generic_command
from typing import Callable, cast from typing import Callable, cast, Optional
import time import time
KILL_WAIT_TIME = 1.3 KILL_WAIT_TIME = 1.3
@ -45,7 +45,7 @@ def _escalate_kill(pid: int) -> Result:
return Result(valid=force.valid, message=f"Force kill {'succeeded' if force.valid else 'failed'}") return Result(valid=force.valid, message=f"Force kill {'succeeded' if force.valid else 'failed'}")
def get_process_id_from_state(current_state: SystemState) -> Result: def get_process_id_from_state(current_state: Optional[SystemState]) -> Result:
if current_state is None: if current_state is None:
return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Missing the State itself.") return Result(valid=False, error_type=ResultError.MISSING_DATA, message="Missing the State itself.")
@ -143,7 +143,7 @@ def _try_shutdown_by_exact_match() -> Result:
return exact_match return exact_match
def _try_known_pid_from_state(current_state: SystemState) -> Result: def _try_known_pid_from_state(current_state: Optional[SystemState]) -> Result:
pid_query = get_process_id_from_state(current_state) pid_query = get_process_id_from_state(current_state)
if not pid_query.valid: if not pid_query.valid:
return pid_query return pid_query

View file

@ -4,6 +4,7 @@ from core.services.networking.systemwide.general_tools import interface_tools
from core.services.networking.systemwide import killswitch from core.services.networking.systemwide import killswitch
from core.services.networking.systemwide.wireguard.wg_firewall_dns import revert_dns from core.services.networking.systemwide.wireguard.wg_firewall_dns import revert_dns
from core.utils.basic_operations import pid_tools from core.utils.basic_operations import pid_tools
from core.services.networking.general_connection_tools import ip
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
@ -48,6 +49,12 @@ def set_dns_for_singbox(current_state: SystemState):
def launch_singbox_binary(profile_id: int) -> Result: def launch_singbox_binary(profile_id: int) -> Result:
"""
1) Launch the singbox binary,
2) Test if the process id is working
3) If not, check if the app is running by name. (on any pid)
"""
activation_result = singbox.start(profile_id) activation_result = singbox.start(profile_id)
if not activation_result.valid: if not activation_result.valid:
return activation_result return activation_result
@ -57,16 +64,23 @@ def launch_singbox_binary(profile_id: int) -> Result:
logger.info(f"Waiting 2 seconds to see if the process id {process_id} is still alive..") logger.info(f"Waiting 2 seconds to see if the process id {process_id} is still alive..")
time.sleep(2) time.sleep(2)
# Evaluate if running. # Evaluate if running by that exact pid:
active = pid_tools.is_running(pid=process_id, process_name="sing-box") active = pid_tools.is_running(pid=process_id, process_name="sing-box")
logger.info(f"Process {process_id} is {active}") logger.info(f"Process {process_id} is {active}")
if active: if active:
return Result(valid=True, data=process_id) return Result(valid=True, data=process_id)
else: else:
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"Initial pid test showed it is NOT running for pid {process_id}. Now we're doing a more exhaustive check by app name that might be a different ID")
logger.error(f"[{function_name}] {error_msg}") double_check = pid_tools.get_pid_by_app_name(exact_app_name="sing-box")
return Result(valid=False, error_type=ResultError.PROCESS_GOT_KILLED, message=error_msg, data=process_id)
if double_check.valid:
logger.info(f"We had the wrong pid!! We had {process_id}, when it's really {double_check.data}")
return Result(valid=True, error_type=ResultError.PROCESS_MISMATCH, data=double_check.data)
else:
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)
def _attempt_start_with_retry(profile_id: int, quantity_of_attempts: int = 2) -> Result: def _attempt_start_with_retry(profile_id: int, quantity_of_attempts: int = 2) -> Result:
@ -214,7 +228,7 @@ def start_singbox(
return Result(valid=False, error_type=ResultError.INTERFACE, data=process_id) return Result(valid=False, error_type=ResultError.INTERFACE, data=process_id)
# ============= SETUP STATE ============= # ============= SETUP STATE =============
# Even if firewall is off, we want to save the fact we turned Singbox on, before we raise errors. # Even if the connection is dead, firewall is off, we want to save the fact we turned Singbox on, before we raise errors.
logger.info("Setting State JSON with INTENDED firewall & Dns settings") logger.info("Setting State JSON with INTENDED firewall & Dns settings")
current_state = SystemStateController.create( current_state = SystemStateController.create(
@ -224,6 +238,23 @@ def start_singbox(
process_id=process_id process_id=process_id
) )
# ============= CONFIRM IP ADDRESS =============
ip_result = ip.test(connection_observer)
if not ip_result.valid:
logger.error(f"IP address check is unable to connect. Killing Singbox pid {process_id}")
closed = orchestrate_closing(current_state=current_state, interface=Constants.SINGBOX_TUN_IF)
raise ConnectionError("Could not connect with IP adddress check.")
# return Result(valid=False, error_type=ResultError.CONNECTION, message="Could not connect with IP adddress check.")
observed_ip_address = ip_result.data
if observed_ip_address == server_ip:
logger.info("Observed IP matches the intended server IP. Proceeding..")
else:
error_msg = f"Critical Leak! IP address check of {observed_ip_address} does NOT match the intended {server_ip}."
logger.error(f"{error_msg} Killing Singbox pid {process_id}")
closed = orchestrate_closing(current_state=current_state, interface=Constants.SINGBOX_TUN_IF)
return Result(valid=False, error_type=ResultError.LEAK_ISSUE, message=error_msg)
# ============= FIREWALL ============= # ============= FIREWALL =============
logger.info(f"[{function_name}] Attempting to enable the Firewall for {Constants.SINGBOX_TUN_IF} and {Constants.SINGBOX_INTERNAL_SUBNET}...") logger.info(f"[{function_name}] Attempting to enable the Firewall for {Constants.SINGBOX_TUN_IF} and {Constants.SINGBOX_INTERNAL_SUBNET}...")
firewall_result = enable_firewall_w_retry( # this function is "generic" as it's protocol neutral firewall_result = enable_firewall_w_retry( # this function is "generic" as it's protocol neutral