Compare commits

..

No commits in common. "9ba6d7e783da650d525575e8996ffe134a089a85" and "d8273171b5d7832e3b3b087576a1bd62945554a7" have entirely different histories.

16 changed files with 184 additions and 297 deletions

View file

@ -1,12 +1,6 @@
# Major Change Log:
# 2.4.3
### Systemwide Killswitch Introduced for Enable
### July 20, 2026
Introduced IP-interface-based killswitch support for systemwide wireguard, on enable only. This version is stable and working for enable only, with a "test" script in the fileslot. And fixed GUI's object save creation. Additionally rolled back Connection data models to previous non-enum version.
<br/>
# 2.4.2
### Surgery on ConnectionController
Huge separation of ConnectionController & ProfileController into naked function modules for enabling a connection, registering wireguard keys, coordinating proxy configs, subscriptions, endpoint verification, and the logistical flow. Additionally enums were used for type checking, instead of polymorphic functions. This version is stable and confirmed working for systemwide and session wireguard, but not yet proxies.

View file

@ -65,8 +65,3 @@ class Constants:
HV_SESSION_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/sessions'
HV_TOR_STATE_HOME: Final[str] = f'{HV_STATE_HOME}/tor'
# ── killswitch / dns wrappers ─────────────────────────────────────────────
KILLSWITCH_WRAPPER: Final[str] = os.environ.get(
'KILLSWITCH_WRAPPER', '/opt/hydra-veil/killswitch'
)

View file

@ -113,7 +113,7 @@ def get_unused_tickets(ticket_observer: TicketObserver) -> dict:
does_the_file_exist, path_of_file = does_ticket_tracker_exist()
if does_the_file_exist == False:
error_msg = f"The ticket tracker organizer file does not exist. Check the folder {path_of_file}"
# ticket_observer.notify("failed_input", subject=error_msg)
ticket_observer.notify("failed_input", subject=error_msg)
return {"valid": False, "message": error_msg}
# Use the Model:

View file

@ -11,10 +11,6 @@ from core.Helpers import write_atomically
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
# If switching to enums for Connection:
# from core.models.system.SystemConnection import SystemConnection, SystemConnectionTypes
# from core.models.session.SessionConnection import SessionConnection, SessionConnectionTypes
from core.controllers.LocationController import LocationController
from core.models.orm_models.Location import Location
from core.models.orm_models.Operator import Operator
@ -68,8 +64,8 @@ class BaseProfile(ABC):
)
name: str
subscription: Optional[Subscription]
type: ProfileType
location: Optional[Location] = field(metadata=config(exclude=Exclude.ALWAYS)) # SQLAlchemy object
type: ProfileType
# legacy version included it to be serialized, but now it's an SQLAlchemy object.
# location: Optional[Location]
@ -102,15 +98,13 @@ class BaseProfile(ABC):
def save(self: Self):
# === SERIALIZATION ===
config_dict = self.to_dict()
# === LOCATION ===
location_dict = self.location.convert_to_dict() # this is from SQLAlchemy, and not JSON-models, that's why it's separate.
location_dict = self.location.to_dict() # this is from SQLAlchemy, and not JSON-models, that's why it's separate.
if self.location:
config_dict["location"] = location_dict
# === FILE I/O ===
config_file_contents = json.dumps(config_dict, indent=4) + '\n'
# legacy version:

View file

@ -11,14 +11,10 @@ class ResultError(Enum):
INVALID_INPUT = "invalid_input"
CONNECTION = "connection"
DATABASE = "database"
PERMISSION = "permission"
SUBSCRIPTION = "subscription"
NMCLI = "nmcli_issues"
FIREWALL = "firewall"
UNKNOWN = "unknown"
@dataclass
class Result():
class Result:
valid: bool
error_type: ResultError = ResultError.SUCCESS
data: Optional[Any] = None

View file

@ -46,7 +46,7 @@ class Location(BaseModel):
is_hysteria2_capable: Mapped[Optional[bool]] = mapped_column(String, nullable=True, default=None)
is_vless_capable: Mapped[Optional[bool]] = mapped_column(String, nullable=True, default=None)
def convert_to_dict(self):
def to_dict(self):
return {
"country_code": self.country_code,
"code": self.code,

View file

@ -2,15 +2,14 @@ from core.models.BaseConnection import BaseConnection
from dataclasses import dataclass
from enum import Enum
class SessionConnectionTypes(str, Enum):
WIREGUARD = "wireguard"
TOR = "tor"
@dataclass
class SessionConnection(BaseConnection):
masked: bool = False
def __post_init__(self):
if self.code not in ('system', 'tor', 'wireguard'):
raise ValueError('Invalid connection code.')
code: SessionConnectionTypes
masked: bool
# Called by: ConnectionController
# doesn't even make sense, it's checking if the Session is code system. this will always be false.
@ -22,36 +21,12 @@ class SessionConnection(BaseConnection):
return self.masked is True
# Potential refactor
# from dataclasses import dataclass, field
# from dataclasses_json import dataclass_json, config
# class SessionConnectionTypes(str, Enum):
# WIREGUARD = "wireguard"
# TOR = "tor"
# @dataclass_json
# legacy:
# @dataclass
# class SessionConnection:
# code: SessionConnectionTypes = field(
# metadata=config(
# encoder=lambda x: x.value, # Convert enum to string on save
# decoder=lambda x: SessionConnectionTypes(x) # Convert string to enum on load
# )
# )
# class SessionConnection(BaseConnection):
# masked: bool = False
# def __post_init__(self):
# # Convert string to enum for deserialization
# if isinstance(self.code, str):
# self.code = SessionConnectionTypes(self.code)
# # Validation
# if self.code not in SessionConnectionTypes:
# raise ValueError(f'Invalid code: {self.code}')
# @dataclass
# class SessionConnection(BaseConnection):
# code: SessionConnectionTypes
# masked: bool
# if self.code not in ('system', 'tor', 'wireguard'):
# raise ValueError('Invalid connection code.')

View file

@ -16,7 +16,7 @@ import shutil
class SessionProfile(BaseProfile):
resolution: str
application_version: Optional[ApplicationVersion]
connection: Optional[SessionConnection] = None
connection: Optional[SessionConnection]
def has_connection(self):
return self.connection is not None

View file

@ -1,29 +1,28 @@
from core.models.BaseConnection import BaseConnection
from dataclasses import dataclass
from enum import Enum
from typing import Literal
class SystemConnectionTypes(str, Enum):
WIREGUARD = "wireguard"
HYSTERIA2 = "hysteria2"
VLESS = "vless"
# legacy:
@dataclass
class SystemConnection (BaseConnection):
def __post_init__(self):
if self.code not in ('vless', 'hysteria2', 'wireguard'):
raise ValueError('Invalid connection code.')
class SystemConnection(BaseConnection):
code: SystemConnectionTypes
masked: Literal[False] = False
@staticmethod
def needs_proxy_configuration():
return False
# Potential refactor:
# from enum import Enum
# from typing import Literal
# class SystemConnectionTypes(str, Enum):
# WIREGUARD = "wireguard"
# HYSTERIA2 = "hysteria2"
# VLESS = "vless"
# legacy:
# @dataclass
# class SystemConnection(BaseConnection):
# code: SystemConnectionTypes
# masked: Literal[False] = False
# class SystemConnection (BaseConnection):
# def __post_init__(self):
# if self.code not in ('vless', 'hysteria2', 'wireguard'):
# raise ValueError('Invalid connection code.')

View file

@ -1,19 +0,0 @@
from core.Constants import Constants
import os
def extract_endpoint_ip(profile_id: str):
try:
profile_path = Constants.HV_PROFILE_CONFIG_HOME + f'/{profile_id}'
wg_conf_path = f'{profile_path}/wg.conf.bak'
print(f"wg_conf_path is {wg_conf_path}")
if not os.path.exists(wg_conf_path):
return None
with open(wg_conf_path, 'r') as f:
content = f.read()
for line in content.split('\n'):
if line.strip().startswith('Endpoint = '):
endpoint = line.strip().split(' = ')[1]
return endpoint.split(':')[0]
return None
except Exception:
return None

View file

@ -1,14 +1,10 @@
from core.services.networking.general_connection_tools.testing_evaluating import system_uses_wireguard_interface
from core.services.networking.systemwide.systemwide_wireguard import establish_system_connection, terminate_system_connection
from core.services.keys_and_verifications.wireguard_keys import register_wireguard_session
from core.models.session.SessionConnection import SessionConnectionTypes
from core.models.system.SystemConnection import SystemConnectionTypes
from core.services.subscriptions.subscriptions import activate_subscription
# If refactored to enums:
# from core.models.session.SessionConnection import SessionConnectionTypes
# from core.models.system.SystemConnection import SystemConnectionTypes
# wireguard_types = [SystemConnectionTypes.WIREGUARD, SessionConnectionTypes.WIREGUARD]
from core.errors.logger import logger
from core.errors.exceptions import *
from typing import Union, Optional, Callable
@ -21,6 +17,8 @@ from core.models.BaseProfile import ProfileType
from core.observers.ConnectionObserver import ConnectionObserver
from core.controllers.SystemStateController import SystemStateController
wireguard_types = [SystemConnectionTypes.WIREGUARD, SessionConnectionTypes.WIREGUARD]
def establish_connection(
profile: Union[SessionProfile, SystemProfile],
ignore: tuple[type[Exception]] = (),
@ -77,7 +75,7 @@ def _ensure_wireguard_configured(
"""Setup WireGuard config if needed."""
logger.info(f"[CONNECTION] Checking if this profile needs a wg config. The profile.connection.code is {profile.connection.code}")
if profile.connection.code != "wireguard":
if profile.connection.code not in wireguard_types:
logger.info(f"[CONNECTION] This profile {profile.id} does NOT need a wireguard config")
return

View file

@ -0,0 +1,118 @@
#!/bin/bash
set -euo pipefail
# root check
if [[ "$EUID" -ne 0 ]]; then
echo "[killswitch] ERROR: must be run as root" >&2
exit 1
fi
ACTION="${1:-}"
SERVER_IP="${2:-}"
TUNNEL_IF="${3:-}"
TABLE="hydraveil"
# Confirm the action is among the valid choices:
if [[ "$ACTION" != "arm" && "$ACTION" != "disarm" && "$ACTION" != "status" ]]; then
echo "[killswitch] ERROR: invalid action '$ACTION'. Usage: arm <server_ip> <tunnel_if> | disarm | status" >&2
exit 1
fi
# This function gets the default route. The function is used during the arm phase later on.
_default_iface() {
ip route show default 2>/dev/null | awk 'NR==1{print $5}'
}
# Log changes in the rules:
_log() {
local level="$1"; shift
echo "[killswitch] [$level] $*$(date '+%Y-%m-%d %H:%M:%S')" >&2
}
# use the presence of the table to detect if the rules are active, which we define as "armed"
if [[ "$ACTION" == "status" ]]; then
if nft list table inet "$TABLE" &>/dev/null 2>&1; then
echo "armed"
else
echo "disarmed"
fi
exit 0
fi
# If disarm is chosen, then check if the table rules exist, and if so, delete the table. Then log it.
if [[ "$ACTION" == "disarm" ]]; then
if nft list table inet "$TABLE" &>/dev/null 2>&1; then
nft delete table inet "$TABLE"
_log "INFO" "disarmed"
else
_log "INFO" "already disarmed"
fi
exit 0
fi
# Check the pre-reqs exist. We need a server IP and a tunnel interface.
[[ -z "$SERVER_IP" ]] && { _log "ERROR" "server_ip required for arm"; exit 1; }
[[ -z "$TUNNEL_IF" ]] && { _log "ERROR" "tunnel_if required for arm"; exit 1; }
# Confirm the IPv4 address is a valid format. But does NOT check if it's actually a live server.
# Just that it looks like an IPv4 address.
if ! [[ "$SERVER_IP" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
_log "ERROR" "invalid IPv4: $SERVER_IP"
exit 1
fi
IFS='.' read -r o1 o2 o3 o4 <<< "$SERVER_IP"
for oct in "$o1" "$o2" "$o3" "$o4"; do
if (( oct > 255 )); then
_log "ERROR" "invalid IPv4 octet ($oct) in $SERVER_IP"
exit 1
fi
done
# This uses the function at the top of the file to get the default WAN interface, and then checks that it got a result.
WAN_IFACE=$(_default_iface)
if [[ -z "$WAN_IFACE" ]]; then
_log "ERROR" "could not detect primary network interface"
exit 1
fi
# Either list out the table rules and delete them, or return True.
nft list table inet "$TABLE" &>/dev/null 2>&1 && nft delete table inet "$TABLE" || true
table inet ${TABLE} {
chain output {
type filter hook output priority filter; policy drop;
# Allow all loopback traffic (localhost)
oifname "lo" accept
# Drop all IPv6 except loopback (::1). This blocks IPv6 from leaving the system entirely
ip6 daddr != ::1 drop
# Allow IPv4 traffic to the server IP on the WAN interface
oifname "${WAN_IFACE}" ip daddr ${SERVER_IP} accept
# Allow all traffic (IPv4 and IPv6) on the WireGuard tunnel interface
oifname "${TUNNEL_IF}" accept
}
chain input {
type filter hook input priority filter; policy drop;
# Allow all loopback traffic (localhost)
iifname "lo" accept
# Drop all IPv6 except loopback (::1). This blocks IPv6 from entering the system entirely
ip6 saddr != ::1 drop
# Allow established and related connections (stateful firewall tracking)
ct state established,related accept
# Allow IPv4 traffic from the server IP on the WAN interface
iifname "${WAN_IFACE}" ip saddr ${SERVER_IP} accept
# Allow all traffic (IPv4 and IPv6) from the WireGuard tunnel interface
iifname "${TUNNEL_IF}" accept
}
chain forward {
type filter hook forward priority filter; policy drop;
# Drop all forwarded traffic (nothing is routed through this system)
}
}

View file

@ -1,42 +0,0 @@
import subprocess
from core.Constants import Constants
from core.models.Result import Result, ResultError
from core.errors.logger import logger
_C = Constants()
# _C.KILLSWITCH_WRAPPER
TESTING_KILLSWITCH = "/opt/hydra-veil/test" # needs chmod 755
def arm(server_ip: str, tunnel_if: str, internal_subnet: str = None) -> Result:
cmd = ["sudo", TESTING_KILLSWITCH, "arm", server_ip, tunnel_if]
if internal_subnet:
cmd.append(internal_subnet)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
error_log = f"Failed to arm firewall: {result.stderr.strip()}"
logger.error(f"[KILLSWITCH] {error_log}")
return Result(valid=False, error_type=ResultError.FIREWALL, message=error_log)
else:
return Result(valid=True)
def disarm() -> bool:
result = subprocess.run(
["sudo", _C.KILLSWITCH_WRAPPER, "disarm"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"[killswitch] Failed to disarm: {result.stderr.strip()}")
return False
return True
def status() -> bool:
result = subprocess.run(
["sudo", _C.KILLSWITCH_WRAPPER, "status"],
capture_output=True,
text=True,
)
return result.returncode == 0 and result.stdout.strip() == "armed"

View file

@ -1,48 +0,0 @@
from core.models.Result import Result, ResultError
import re
import shutil
from core.Errors import CommandNotFoundError
def check_system_prereqs():
if shutil.which('dbus-send') is None:
raise CommandNotFoundError('dbus-send')
if shutil.which('nmcli') is None:
raise CommandNotFoundError('nmcli')
def extract_wg_interface_name(text: str) -> Result:
"""
Purpose:
Extracts the wg interface name, which should be 'wg'
Pattern:
"Connection" followed by text in single quotes
Example:
"Connection 'wg' (jgfdgfdjgfdjfdg"
"""
pattern = r"Connection '([^']*)'"
match = re.search(pattern, text)
if match:
# Return what's between the quotes
result = match.group(1)
return Result(valid=True, data=result)
else:
error_msg = "Error: String doesn't contain 'Connection' and text within single quotes"
return Result(valid=True, error_type=ResultError.NMCLI, message=error_msg)
######### ISOLATION TESTING ########
# result1 = extract_connection_name("Connection 'wg' (jgfdgfdjgfdjfdg")
# print(result1) # Output: wg

View file

@ -1,11 +1,5 @@
from core.services.networking.general_connection_tools.testing_evaluating import await_connection, system_uses_wireguard_interface, terminate_tor_connection
from core.services.keys_and_verifications.endpoint_verification import verify_wireguard_endpoint
from core.models.Result import Result, ResultError
from core.services.networking.systemwide.systemwide_utils import extract_wg_interface_name, check_system_prereqs
from core.services.networking.general_connection_tools.config_tools import extract_endpoint_ip
from core.services.networking.systemwide import killswitch
from core.errors.logger import logger
from core.Constants import Constants
from core.Errors import ConnectionTerminationError, CommandNotFoundError
@ -43,87 +37,42 @@ def terminate_system_connection():
else:
raise ConnectionTerminationError('The connection could not be terminated.')
def setup_nmcli_connection(wg_config_path: str) -> Result:
try:
process_output = subprocess.check_output(('nmcli', 'connection', 'import', '--temporary', 'type', 'wireguard', 'file', wg_config_path), text=True)
return Result(valid=True, data=process_output)
except CalledProcessError as e:
return Result(valid=False, error_type=ResultError.NMCLI, message=f"Called Process error with nmcli: {e}")
# raise ConnectionError('The connection could not be established.')
def __establish_system_connection(profile: SystemProfile, connection_observer: Optional[ConnectionObserver] = None):
if shutil.which('dbus-send') is None:
raise CommandNotFoundError('dbus-send')
if shutil.which('nmcli') is None:
raise CommandNotFoundError('nmcli')
terminate_system_connection()
def setup_ipv6_sinkhole(process_output: str) -> Result:
error_info = "with nmcli setting up the IPv6 sinkhole, error is "
try:
process_output = subprocess.check_output(('nmcli', 'connection', 'import', '--temporary', 'type', 'wireguard', 'file', profile.get_wireguard_configuration_path()), text=True)
except CalledProcessError:
raise ConnectionError('The connection could not be established.')
try:
connection_id = (m := re.search(r'(?<=\()([a-f0-9-]+?)(?=\))', process_output)) and m.group(1)
ipv6_method = subprocess.check_output(('nmcli', '-g', 'ipv6.method', 'connection', 'show', connection_id), text=True).strip()
except CalledProcessError as e:
return Result(valid=False, error_type=ResultError.NMCLI, message=f"Called Process error {error_info}: {e}")
# raise ConnectionError('The connection could not be established.')
except CalledProcessError:
raise ConnectionError('The connection could not be established.')
if ipv6_method in ('disabled', 'ignore'):
try:
subprocess.run(('dbus-send', '--system', '--print-reply', '--dest=org.freedesktop.NetworkManager', '/org/freedesktop/NetworkManager', 'org.freedesktop.DBus.Properties.Set', 'string:org.freedesktop.NetworkManager', 'string:ConnectivityCheckEnabled', 'variant:boolean:false'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
except CalledProcessError as e:
return Result(valid=False, error_type=ResultError.NMCLI, message=f"Called Process error {error_info}: {e}")
# raise ConnectionError('The connection could not be established.')
except CalledProcessError:
raise ConnectionError('The connection could not be established.')
try:
subprocess.run(('nmcli', 'connection', 'add', 'type', 'dummy', 'save', 'no', 'con-name', 'hv-ipv6-sink', 'ifname', 'hvipv6sink0', 'ipv6.method', 'manual', 'ipv6.addresses', 'fd7a:fd4b:54e3:077c::/64', 'ipv6.gateway', 'fd7a:fd4b:54e3:077c::1', 'ipv6.dns', '::1', 'ipv6.route-metric', '72'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
return Result(valid=True)
except CalledProcessError:
return Result(valid=False, error_type=ResultError.NMCLI, message=f"Called Process error {error_info}")
# raise ConnectionError('The connection could not be established.')
def __establish_system_connection(profile: SystemProfile, firewall_setting: bool = False, connection_observer: Optional[ConnectionObserver] = None):
check_system_prereqs()
terminate_system_connection()
# ============= INITIAL NMCLI CONNECTION =============
wg_config_path = profile.get_wireguard_configuration_path()
nmcli_result = setup_nmcli_connection(wg_config_path)
if nmcli_result.valid:
process_output = nmcli_result.data
else:
logger.error(f"[SYSTEMWIDE WG] Critical issue, the initial nmcli command failed {nmcli_result.message}.")
return nmcli_result
wg_interface_result = extract_wg_interface_name(process_output)
if not wg_interface_result.valid:
logger.error(f"[SYSTEMWIDE WG] Critical issue, the wg interface is in the wrong format. This is the raw output: {process_output}")
raise ConnectionError('The connection could not be established.')
# handle the interface being wrong
# ============= IPv6 SINKHOLE =============
sinkhole_result = setup_ipv6_sinkhole(process_output)
if not sinkhole_result:
raise ConnectionError('IPv6 Could not be protected.')
# ============= FIREWALL =============
if firewall_setting:
logger.info(f"[SYSTEMWIDE WG] Turning on Firewall")
wg_interface_name = wg_interface_result.data
server_ip_address = extract_endpoint_ip(str(profile.id))
print(f"WG Inteface Name: {wg_interface_name}")
print(f"Server IP address: {server_ip_address}")
firewall_results = killswitch.arm(
server_ip=server_ip_address,
tunnel_if=wg_interface_name,
internal_subnet=None
)
if not firewall_results.valid:
print(f"FAILED to setup Firewall. firewall_results is {firewall_results.message}")
# raise ConnectionError('Firewall could not be enabled.')
# ============= SETUP STATE & TEST =============
SystemStateController.create(profile.id)
try:
@ -132,35 +81,15 @@ def __establish_system_connection(profile: SystemProfile, firewall_setting: bool
except ConnectionError:
raise ConnectionError('The connection could not be established.')
return Result(valid=True)
def evaluate_connection_result(connection_result: Result):
if not connection_result.valid:
logger.error(f"[SYSTEMWIDE WG] Critical issue, could not establish a connection: {connection_result.message}")
try:
terminate_system_connection()
except ConnectionTerminationError:
pass
def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Exception]] = (), connection_observer: Optional[ConnectionObserver] = None):
"""
Rank:
Public Interface Orchestrator
Purpose:
Tries to enable the systemwide connection, retries on failure.
"""
# ================= VERIFY ENDPOINT =================
if ConfigurationController.get_endpoint_verification_enabled():
verify_wireguard_endpoint(profile, ignore=ignore)
# ================= CONNECT WG =================
try:
connection_result = __establish_system_connection(profile, False, connection_observer)
evaluate_connection_result(connection_result)
__establish_system_connection(profile, connection_observer)
except ConnectionError:
@ -178,10 +107,8 @@ def establish_system_connection(profile: SystemProfile, ignore: tuple[type[Excep
except ConnectionTerminationError:
pass
# trying again..
try:
connection_result = __establish_system_connection(profile, False, connection_observer)
evaluate_connection_result(connection_result)
__establish_system_connection(profile, connection_observer)
except (ConnectionError, CalledProcessError):

View file

@ -1,6 +1,6 @@
[project]
name = "sp-hydra-veil-core"
version = "2.4.3"
version = "2.4.2"
authors = [
{ name = "Simplified Privacy" },
]