radical change
This commit is contained in:
parent
399c66e3cb
commit
eff49cf1f7
11 changed files with 347 additions and 152 deletions
|
|
@ -96,3 +96,4 @@ controller = HysteriaController(1080)
|
||||||
controller.disable(observer)
|
controller.disable(observer)
|
||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
|
||||||
|
|
@ -217,14 +217,27 @@ class ConnectionController:
|
||||||
|
|
||||||
if protocol == 'vless':
|
if protocol == 'vless':
|
||||||
from core.controllers.encrypted_proxy.VlessController import VlessController
|
from core.controllers.encrypted_proxy.VlessController import VlessController
|
||||||
VlessController(1080).enable(operator_proxy_session.links[0], operator_proxy_session.username, connection_observer)
|
ok = VlessController(1080).enable(
|
||||||
|
operator_proxy_session.links[0],
|
||||||
|
operator_proxy_session.username,
|
||||||
|
connection_observer
|
||||||
|
)
|
||||||
elif protocol == 'hysteria2':
|
elif protocol == 'hysteria2':
|
||||||
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
|
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
|
||||||
HysteriaController(1080).enable(operator_proxy_session.username, operator_proxy_session.password, operator_proxy_session.operator_hysteria2_host, connection_observer)
|
ok = HysteriaController(1080).enable(
|
||||||
|
operator_proxy_session.username,
|
||||||
|
operator_proxy_session.password,
|
||||||
|
operator_proxy_session.operator_hysteria2_host,
|
||||||
|
connection_observer
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
raise ConnectionError('The connection could not be established.')
|
||||||
|
|
||||||
SystemStateController.create(profile.id)
|
SystemStateController.create(profile.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
if ConfigurationController.get_endpoint_verification_enabled():
|
if ConfigurationController.get_endpoint_verification_enabled():
|
||||||
ProfileController.verify_wireguard_endpoint(profile, ignore=ignore)
|
ProfileController.verify_wireguard_endpoint(profile, ignore=ignore)
|
||||||
|
|
||||||
|
|
@ -338,7 +351,7 @@ class ConnectionController:
|
||||||
return subprocess.Popen(('proxychains4', '-f', proxychains_configuration_file_path, 'microsocks', '-p', str(proxy_port_number)), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
return subprocess.Popen(('proxychains4', '-f', proxychains_configuration_file_path, 'microsocks', '-p', str(proxy_port_number)), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def terminate_system_connection():
|
def terminate_system_connection(connection_observer: Optional[ConnectionObserver] = None):
|
||||||
system_state = SystemStateController.get()
|
system_state = SystemStateController.get()
|
||||||
if system_state is not None:
|
if system_state is not None:
|
||||||
profile = ProfileController.get(system_state.profile_id)
|
profile = ProfileController.get(system_state.profile_id)
|
||||||
|
|
@ -346,10 +359,10 @@ class ConnectionController:
|
||||||
protocol = profile.connection.get_protocol()
|
protocol = profile.connection.get_protocol()
|
||||||
if protocol == 'vless':
|
if protocol == 'vless':
|
||||||
from core.controllers.encrypted_proxy.VlessController import VlessController
|
from core.controllers.encrypted_proxy.VlessController import VlessController
|
||||||
VlessController(1080).disable()
|
VlessController(1080).disable(connection_observer)
|
||||||
elif protocol == 'hysteria2':
|
elif protocol == 'hysteria2':
|
||||||
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
|
from core.controllers.encrypted_proxy.HysteriaController import HysteriaController
|
||||||
HysteriaController(1080).disable()
|
HysteriaController(1080).disable(connection_observer)
|
||||||
SystemState.dissolve()
|
SystemState.dissolve()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -357,18 +370,18 @@ class ConnectionController:
|
||||||
raise CommandNotFoundError('nmcli')
|
raise CommandNotFoundError('nmcli')
|
||||||
|
|
||||||
if SystemStateController.exists():
|
if SystemStateController.exists():
|
||||||
|
|
||||||
process = subprocess.Popen(('nmcli', 'connection', 'delete', 'wg'), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
process = subprocess.Popen(('nmcli', 'connection', 'delete', 'wg'), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
||||||
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
|
completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8)
|
||||||
|
|
||||||
if completed_successfully or not ConnectionController.system_uses_wireguard_interface():
|
if completed_successfully or not ConnectionController.system_uses_wireguard_interface():
|
||||||
|
|
||||||
subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
subprocess.run(('nmcli', 'connection', 'delete', 'hv-ipv6-sink'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
ConnectionController.terminate_tor_connection()
|
ConnectionController.terminate_tor_connection()
|
||||||
SystemState.dissolve()
|
SystemState.dissolve()
|
||||||
|
if connection_observer is not None:
|
||||||
|
connection_observer.notify('disconnected', {})
|
||||||
else:
|
else:
|
||||||
raise ConnectionTerminationError('The connection could not be terminated.')
|
raise ConnectionTerminationError('The connection could not be terminated.')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_proxies(port_number: int):
|
def get_proxies(port_number: int):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,51 +86,36 @@ class ProfileController:
|
||||||
profile_observer.notify('enabled', profile)
|
profile_observer.notify('enabled', profile)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, ignore: tuple[type[Exception]] = (), profile_observer: ProfileObserver = None):
|
def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, ignore: tuple[type[Exception]] = (), profile_observer: ProfileObserver = None, connection_observer: ConnectionObserver = None):
|
||||||
|
|
||||||
from core.controllers.ConnectionController import ConnectionController
|
from core.controllers.ConnectionController import ConnectionController
|
||||||
|
|
||||||
if profile.is_session_profile():
|
if profile.is_session_profile():
|
||||||
|
|
||||||
if SessionStateController.exists(profile.id):
|
if SessionStateController.exists(profile.id):
|
||||||
|
|
||||||
session_state = SessionStateController.get(profile.id)
|
session_state = SessionStateController.get(profile.id)
|
||||||
|
|
||||||
if session_state is not None:
|
if session_state is not None:
|
||||||
|
|
||||||
for port_number in session_state.network_port_numbers.tor:
|
for port_number in session_state.network_port_numbers.tor:
|
||||||
ConnectionController.terminate_tor_session_connection(port_number)
|
ConnectionController.terminate_tor_session_connection(port_number)
|
||||||
|
|
||||||
session_state.dissolve(session_state.id)
|
session_state.dissolve(session_state.id)
|
||||||
|
|
||||||
if profile.is_system_profile():
|
if profile.is_system_profile():
|
||||||
|
|
||||||
subjects = ProfileController.get_all().values()
|
subjects = ProfileController.get_all().values()
|
||||||
|
|
||||||
for subject in subjects:
|
for subject in subjects:
|
||||||
|
|
||||||
if subject.is_session_profile():
|
if subject.is_session_profile():
|
||||||
|
|
||||||
if subject.connection.is_unprotected() and ProfileController.is_enabled(subject) and not ConnectionUnprotectedError in ignore:
|
if subject.connection.is_unprotected() and ProfileController.is_enabled(subject) and not ConnectionUnprotectedError in ignore:
|
||||||
raise ConnectionUnprotectedError('Disabling this system connection would leave one or more sessions exposed.')
|
raise ConnectionUnprotectedError('Disabling this system connection would leave one or more sessions exposed.')
|
||||||
|
|
||||||
if SystemStateController.exists():
|
if SystemStateController.exists():
|
||||||
|
|
||||||
system_state = SystemStateController.get()
|
system_state = SystemStateController.get()
|
||||||
|
|
||||||
if profile.id != system_state.profile_id:
|
if profile.id != system_state.profile_id:
|
||||||
raise ProfileDeactivationError('The profile could not be disabled.')
|
raise ProfileDeactivationError('The profile could not be disabled.')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ConnectionController.terminate_system_connection()
|
ConnectionController.terminate_system_connection(connection_observer=connection_observer)
|
||||||
except ConnectionTerminationError:
|
except ConnectionTerminationError:
|
||||||
raise ProfileDeactivationError('The profile could not be disabled.')
|
raise ProfileDeactivationError('The profile could not be disabled.')
|
||||||
|
|
||||||
if profile_observer is not None:
|
if profile_observer is not None:
|
||||||
|
profile_observer.notify('disabled', profile, dict(explicitly=explicitly))
|
||||||
profile_observer.notify('disabled', profile, dict(
|
|
||||||
explicitly=explicitly,
|
|
||||||
))
|
|
||||||
|
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
from essentials.observers.ConnectionObserver import ConnectionObserver as BaseConnectionObserver
|
from essentials.observers.ConnectionObserver import ConnectionObserver as BaseConnectionObserver
|
||||||
|
|
||||||
|
|
||||||
class ConnectionObserver(BaseConnectionObserver):
|
class ConnectionObserver(BaseConnectionObserver):
|
||||||
pass
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.on_connected = []
|
||||||
|
self.on_disconnected = []
|
||||||
|
self.on_error = []
|
||||||
|
|
@ -1,33 +1,26 @@
|
||||||
import ssl
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
from core.utils.encrypted_proxy.singbox import SingboxRunner
|
from core.utils.encrypted_proxy.singbox import SingboxRunner
|
||||||
|
from core.utils.encrypted_proxy.dns import revert_dns_on_tun
|
||||||
|
from core.utils.encrypted_proxy import killswitch
|
||||||
|
|
||||||
|
|
||||||
def get_public_ip(timeout: int = 8) -> str:
|
def get_public_ip(timeout: int = 8) -> str:
|
||||||
ctx = ssl.create_default_context()
|
endpoints = [
|
||||||
try:
|
"https://api.ipify.org",
|
||||||
req = urllib.request.Request(
|
|
||||||
"https://ifconfig.me/ip",
|
"https://ifconfig.me/ip",
|
||||||
headers={"User-Agent": "curl/7.0"}
|
"https://icanhazip.com",
|
||||||
|
]
|
||||||
|
for url in endpoints:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["curl", "-s", "--max-time", str(timeout), url],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout + 2,
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
ip = r.stdout.decode().strip()
|
||||||
return resp.read().decode().strip()
|
if ip and "." in ip and not ip.startswith("unknown"):
|
||||||
except Exception as e:
|
return ip
|
||||||
return f"unknown ({e})"
|
except Exception:
|
||||||
|
continue
|
||||||
|
return "unknown"
|
||||||
def disable_proxy(tmp_dir: Path, wrapper: str, unit: str, observer=None) -> bool:
|
|
||||||
runner = SingboxRunner(wrapper, unit)
|
|
||||||
runner.stop()
|
|
||||||
|
|
||||||
for f in tmp_dir.glob("*-sing-box.json"):
|
|
||||||
f.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
time.sleep(1)
|
|
||||||
ip = get_public_ip()
|
|
||||||
|
|
||||||
if observer:
|
|
||||||
observer.notify("disconnected", {"ip": ip})
|
|
||||||
return True
|
|
||||||
|
|
@ -1,24 +1,33 @@
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from core.Constants import Constants
|
from core.Constants import Constants
|
||||||
from core.utils.encrypted_proxy.net import get_real_ip, verify_ip
|
from core.utils.encrypted_proxy.net import get_real_ip, verify_ip
|
||||||
from core.utils.encrypted_proxy.singbox import SingboxRunner
|
from core.utils.encrypted_proxy.singbox import SingboxRunner
|
||||||
|
from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun
|
||||||
|
from core.utils.encrypted_proxy import killswitch
|
||||||
|
from core.services.encrypted_proxy.disable_service import get_public_ip
|
||||||
|
|
||||||
def _resolve(host: str) -> str:
|
def _resolve(host: str) -> str:
|
||||||
return socket.gethostbyname(host)
|
return socket.gethostbyname(host)
|
||||||
|
|
||||||
|
|
||||||
def build_hysteria_config(username: str, password: str,
|
def build_hysteria_config(username: str, password: str,
|
||||||
server_host: str, socks5_port: int) -> dict:
|
server_host: str, socks5_port: int) -> dict:
|
||||||
server_ip = _resolve(server_host)
|
server_ip = _resolve(server_host)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"dns": {
|
"dns": {
|
||||||
"servers": [{"tag": "local", "type": "udp", "server": "9.9.9.9"}],
|
"servers": [
|
||||||
"final": "local",
|
{
|
||||||
|
"tag": "tunnel-dns",
|
||||||
|
"type": "udp",
|
||||||
|
"server": "9.9.9.9",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"final": "tunnel-dns",
|
||||||
"strategy": "ipv4_only",
|
"strategy": "ipv4_only",
|
||||||
|
"independent_cache": True,
|
||||||
},
|
},
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
|
|
@ -28,7 +37,7 @@ def build_hysteria_config(username: str, password: str,
|
||||||
"address": ["172.19.0.1/30"],
|
"address": ["172.19.0.1/30"],
|
||||||
"mtu": 9000,
|
"mtu": 9000,
|
||||||
"auto_route": True,
|
"auto_route": True,
|
||||||
"stack": "system",
|
"stack": "gvisor",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "socks",
|
"type": "socks",
|
||||||
|
|
@ -57,43 +66,88 @@ def build_hysteria_config(username: str, password: str,
|
||||||
"rules": [
|
"rules": [
|
||||||
{"protocol": "dns", "action": "hijack-dns"},
|
{"protocol": "dns", "action": "hijack-dns"},
|
||||||
{"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"},
|
{"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"},
|
||||||
# DNS y servidor salen directo — nunca por el TUN
|
|
||||||
{"ip_cidr": ["9.9.9.9/32"], "outbound": "direct"},
|
|
||||||
{"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"},
|
{"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"},
|
||||||
{"ip_is_private": True, "outbound": "direct"},
|
{"ip_is_private": True, "outbound": "direct"},
|
||||||
{"ip_version": 6, "outbound": "block"},
|
{"ip_version": 6, "outbound": "block"},
|
||||||
{"inbound": ["tun-in", "socks-in"], "outbound": "proxy"},
|
{"inbound": ["tun-in", "socks-in"], "outbound": "proxy"},
|
||||||
],
|
],
|
||||||
"final": "proxy",
|
"final": "proxy",
|
||||||
"default_domain_resolver": "local",
|
"default_domain_resolver": "tunnel-dns",
|
||||||
"auto_detect_interface": True,
|
"auto_detect_interface": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_all() -> None:
|
||||||
|
try:
|
||||||
|
SingboxRunner().stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
revert_dns_on_tun()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
killswitch.disarm()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
def enable_hysteria(username: str, password: str, server_host: str,
|
def enable_hysteria(username: str, password: str, server_host: str,
|
||||||
socks5_port: int, observer=None) -> bool:
|
socks5_port: int, observer=None) -> bool:
|
||||||
|
|
||||||
|
_cleanup_all()
|
||||||
|
|
||||||
|
server_ip = _resolve(server_host)
|
||||||
real_ip = get_real_ip()
|
real_ip = get_real_ip()
|
||||||
|
|
||||||
|
if not killswitch.arm(server_ip):
|
||||||
|
if observer:
|
||||||
|
observer.notify("error", "Failed to arm kill switch")
|
||||||
|
return False
|
||||||
|
|
||||||
runner = SingboxRunner()
|
runner = SingboxRunner()
|
||||||
runner.stop()
|
runner.stop()
|
||||||
config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json"
|
config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json"
|
||||||
config = build_hysteria_config(username, password, server_host, socks5_port)
|
config = build_hysteria_config(username, password, server_host, socks5_port)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
runner.write_config(config_path, config)
|
runner.write_config(config_path, config)
|
||||||
ok = runner.start(config_path)
|
ok = runner.start(config_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
killswitch.disarm()
|
||||||
if observer:
|
if observer:
|
||||||
observer.notify("error", str(e))
|
observer.notify("error", str(e))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not ok:
|
if not ok:
|
||||||
|
killswitch.disarm()
|
||||||
if observer:
|
if observer:
|
||||||
observer.notify("error", "sing-box not active after start")
|
observer.notify("error", "sing-box not active after start")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Esperar que el proxy esté listo para rutear tráfico
|
if not wait_for_tun(timeout=15.0):
|
||||||
time.sleep(3)
|
killswitch.disarm()
|
||||||
|
runner.stop()
|
||||||
|
if observer:
|
||||||
|
observer.notify("error", "tun0 did not appear after 15s")
|
||||||
|
return False
|
||||||
|
|
||||||
|
set_dns_on_tun()
|
||||||
|
|
||||||
proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0)
|
proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0)
|
||||||
|
if not proxy_ip or proxy_ip == "unknown" or proxy_ip == real_ip:
|
||||||
|
killswitch.disarm()
|
||||||
|
revert_dns_on_tun()
|
||||||
|
runner.stop()
|
||||||
|
if observer:
|
||||||
|
observer.notify("error", "IP did not change — possible leak")
|
||||||
|
return False
|
||||||
|
|
||||||
if observer:
|
if observer:
|
||||||
observer.notify("connected", {
|
observer.notify("connected", {
|
||||||
"real_ip": real_ip,
|
"real_ip": real_ip,
|
||||||
|
|
@ -102,9 +156,12 @@ def enable_hysteria(username: str, password: str, server_host: str,
|
||||||
})
|
})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def disable_hysteria(observer=None) -> bool:
|
def disable_hysteria(observer=None) -> bool:
|
||||||
|
revert_dns_on_tun()
|
||||||
SingboxRunner().stop()
|
SingboxRunner().stop()
|
||||||
|
killswitch.disarm()
|
||||||
|
time.sleep(1)
|
||||||
|
ip = get_public_ip()
|
||||||
if observer:
|
if observer:
|
||||||
observer.notify("disconnected", {})
|
observer.notify("disconnected", {"ip": ip})
|
||||||
return True
|
return True
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from core.services.encrypted_proxy.disable_service import get_public_ip
|
||||||
from core.Constants import Constants
|
from core.Constants import Constants
|
||||||
from core.utils.encrypted_proxy.net import get_real_ip, verify_ip
|
from core.utils.encrypted_proxy.net import get_real_ip, verify_ip
|
||||||
from core.utils.encrypted_proxy.singbox import SingboxRunner
|
from core.utils.encrypted_proxy.singbox import SingboxRunner
|
||||||
import socket
|
from core.utils.encrypted_proxy.dns import wait_for_tun, set_dns_on_tun, revert_dns_on_tun
|
||||||
import time
|
from core.utils.encrypted_proxy import killswitch
|
||||||
|
|
||||||
|
|
||||||
def parse_vless_link(link: str) -> dict:
|
def parse_vless_link(link: str) -> dict:
|
||||||
|
|
@ -39,13 +43,14 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict:
|
||||||
"dns": {
|
"dns": {
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
"tag": "local",
|
"tag": "tunnel-dns",
|
||||||
"type": "udp",
|
"type": "udp",
|
||||||
"server": "9.9.9.9",
|
"server": "9.9.9.9",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"final": "local",
|
"final": "tunnel-dns",
|
||||||
"strategy": "ipv4_only",
|
"strategy": "ipv4_only",
|
||||||
|
"independent_cache": True,
|
||||||
},
|
},
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
|
|
@ -55,7 +60,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict:
|
||||||
"address": ["172.19.0.1/30"],
|
"address": ["172.19.0.1/30"],
|
||||||
"mtu": 9000,
|
"mtu": 9000,
|
||||||
"auto_route": True,
|
"auto_route": True,
|
||||||
"stack": "system",
|
"stack": "gvisor",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "socks",
|
"type": "socks",
|
||||||
|
|
@ -89,43 +94,89 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict:
|
||||||
"rules": [
|
"rules": [
|
||||||
{"protocol": "dns", "action": "hijack-dns"},
|
{"protocol": "dns", "action": "hijack-dns"},
|
||||||
{"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"},
|
{"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"},
|
||||||
{"ip_cidr": ["9.9.9.9/32"], "outbound": "direct"},
|
|
||||||
{"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"},
|
{"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"},
|
||||||
{"ip_is_private": True, "outbound": "direct"},
|
{"ip_is_private": True, "outbound": "direct"},
|
||||||
{"ip_version": 6, "outbound": "block"},
|
{"ip_version": 6, "outbound": "block"},
|
||||||
{"inbound": ["tun-in", "socks-in"], "outbound": "proxy"},
|
{"inbound": ["tun-in", "socks-in"], "outbound": "proxy"},
|
||||||
],
|
],
|
||||||
"final": "proxy",
|
"final": "proxy",
|
||||||
"default_domain_resolver": "local",
|
"default_domain_resolver": "tunnel-dns",
|
||||||
"auto_detect_interface": True,
|
"auto_detect_interface": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_all() -> None:
|
||||||
|
try:
|
||||||
|
SingboxRunner().stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
revert_dns_on_tun()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
killswitch.disarm()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
def enable_vless(vless_link: str, username: str,
|
def enable_vless(vless_link: str, username: str,
|
||||||
socks5_port: int, observer=None) -> bool:
|
socks5_port: int, observer=None) -> bool:
|
||||||
|
|
||||||
|
_cleanup_all()
|
||||||
|
|
||||||
vless = parse_vless_link(vless_link)
|
vless = parse_vless_link(vless_link)
|
||||||
|
server_ip = socket.gethostbyname(vless["host"])
|
||||||
real_ip = get_real_ip()
|
real_ip = get_real_ip()
|
||||||
|
|
||||||
|
if not killswitch.arm(server_ip):
|
||||||
|
if observer:
|
||||||
|
observer.notify("error", "Failed to arm kill switch")
|
||||||
|
return False
|
||||||
|
|
||||||
runner = SingboxRunner()
|
runner = SingboxRunner()
|
||||||
runner.stop()
|
runner.stop()
|
||||||
config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json"
|
config_path = Path(Constants.SINGBOX_CONFIG_DIR) / f"{username}-sing-box.json"
|
||||||
config = build_vless_config(vless, socks5_port)
|
config = build_vless_config(vless, socks5_port)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
runner.write_config(config_path, config)
|
runner.write_config(config_path, config)
|
||||||
ok = runner.start(config_path)
|
ok = runner.start(config_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
killswitch.disarm()
|
||||||
if observer:
|
if observer:
|
||||||
observer.notify("error", str(e))
|
observer.notify("error", str(e))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not ok:
|
if not ok:
|
||||||
|
killswitch.disarm()
|
||||||
if observer:
|
if observer:
|
||||||
observer.notify("error", "sing-box not active after start")
|
observer.notify("error", "sing-box not active after start")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Esperar que el proxy esté listo para rutear tráfico
|
if not wait_for_tun(timeout=15.0):
|
||||||
time.sleep(3)
|
killswitch.disarm()
|
||||||
|
runner.stop()
|
||||||
|
if observer:
|
||||||
|
observer.notify("error", "tun0 did not appear after 15s")
|
||||||
|
return False
|
||||||
|
|
||||||
|
set_dns_on_tun()
|
||||||
|
|
||||||
proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0)
|
proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0)
|
||||||
|
if not proxy_ip or proxy_ip == "unknown" or proxy_ip == real_ip:
|
||||||
|
killswitch.disarm()
|
||||||
|
revert_dns_on_tun()
|
||||||
|
runner.stop()
|
||||||
|
if observer:
|
||||||
|
observer.notify("error", "IP did not change — possible leak")
|
||||||
|
return False
|
||||||
|
|
||||||
if observer:
|
if observer:
|
||||||
observer.notify("connected", {
|
observer.notify("connected", {
|
||||||
"real_ip": real_ip,
|
"real_ip": real_ip,
|
||||||
|
|
@ -136,7 +187,11 @@ def enable_vless(vless_link: str, username: str,
|
||||||
|
|
||||||
|
|
||||||
def disable_vless(observer=None) -> bool:
|
def disable_vless(observer=None) -> bool:
|
||||||
|
revert_dns_on_tun()
|
||||||
SingboxRunner().stop()
|
SingboxRunner().stop()
|
||||||
|
killswitch.disarm()
|
||||||
|
time.sleep(1)
|
||||||
|
ip = get_public_ip()
|
||||||
if observer:
|
if observer:
|
||||||
observer.notify("disconnected", {})
|
observer.notify("disconnected", {"ip": ip})
|
||||||
return True
|
return True
|
||||||
51
core/utils/encrypted_proxy/dns.py
Normal file
51
core/utils/encrypted_proxy/dns.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
RESOLVECTL_WRAPPER = "/usr/local/bin/hydraveil-resolvectl"
|
||||||
|
TUN_IFACE = "tun0"
|
||||||
|
TUN_DNS = "9.9.9.9"
|
||||||
|
|
||||||
|
_SUDO_KW = dict(
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
env={**os.environ, "SUDO_ASKPASS": "/bin/false"},
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_tun(timeout: float = 15.0, interval: float = 0.5) -> bool:
|
||||||
|
elapsed = 0.0
|
||||||
|
while elapsed < timeout:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ip", "link", "show", TUN_IFACE],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True
|
||||||
|
time.sleep(interval)
|
||||||
|
elapsed += interval
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def set_dns_on_tun() -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
["sudo", RESOLVECTL_WRAPPER, "set", TUN_DNS],
|
||||||
|
**_SUDO_KW,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[DNS] Failed to configure tun0: {result.stderr.strip()}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def revert_dns_on_tun() -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
["sudo", RESOLVECTL_WRAPPER, "revert"],
|
||||||
|
**_SUDO_KW,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[DNS] Failed to revert tun0: {result.stderr.strip()}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
36
core/utils/encrypted_proxy/killswitch.py
Normal file
36
core/utils/encrypted_proxy/killswitch.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
KILLSWITCH_WRAPPER = "/usr/local/bin/hydraveil-killswitch"
|
||||||
|
|
||||||
|
|
||||||
|
def arm(server_ip: str) -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
["sudo", KILLSWITCH_WRAPPER, "arm", server_ip],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[killswitch] Failed to arm: {result.stderr.strip()}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def disarm() -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
["sudo", 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", KILLSWITCH_WRAPPER, "status"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
return result.returncode == 0 and result.stdout.strip() == "armed"
|
||||||
|
|
@ -54,7 +54,6 @@ def get_proxied_ip(socks5_port: int, timeout: int = 15) -> str:
|
||||||
stderr = r.stderr.decode().strip()
|
stderr = r.stderr.decode().strip()
|
||||||
if ip and not ip.startswith("<") and "." in ip:
|
if ip and not ip.startswith("<") and "." in ip:
|
||||||
return ip
|
return ip
|
||||||
# Log silencioso para debug
|
|
||||||
if stderr:
|
if stderr:
|
||||||
print(f"[net] curl stderr ({url}): {stderr[:120]}")
|
print(f"[net] curl stderr ({url}): {stderr[:120]}")
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
|
@ -66,7 +65,7 @@ def get_proxied_ip(socks5_port: int, timeout: int = 15) -> str:
|
||||||
|
|
||||||
def verify_ip(socks5_port: int, retries: int = 5, delay: float = 3.0) -> str:
|
def verify_ip(socks5_port: int, retries: int = 5, delay: float = 3.0) -> str:
|
||||||
for attempt in range(1, retries + 1):
|
for attempt in range(1, retries + 1):
|
||||||
print(f"[net] verify_ip intento {attempt}/{retries}...")
|
print(f"[net] verify_ip attempt {attempt}/{retries}...")
|
||||||
ip = get_proxied_ip(socks5_port)
|
ip = get_proxied_ip(socks5_port)
|
||||||
if ip != "unknown":
|
if ip != "unknown":
|
||||||
return ip
|
return ip
|
||||||
|
|
|
||||||
|
|
@ -8,33 +8,17 @@ from core.Constants import Constants
|
||||||
|
|
||||||
|
|
||||||
class SingboxRunner:
|
class SingboxRunner:
|
||||||
"""
|
|
||||||
Gestiona el ciclo de vida de sing-box via wrapper sudo.
|
|
||||||
|
|
||||||
Flujo:
|
|
||||||
write_config() → start() → is_running() → stop()
|
|
||||||
|
|
||||||
El wrapper es el único binario con NOPASSWD en sudoers.
|
|
||||||
Nunca se llama sing-box ni ip directamente con sudo desde Python.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_WRAPPER = Constants.SINGBOX_WRAPPER
|
_WRAPPER = Constants.SINGBOX_WRAPPER
|
||||||
_PID_FILE = Path(Constants.SINGBOX_PID_FILE)
|
_PID_FILE = Path(Constants.SINGBOX_PID_FILE)
|
||||||
_LOG_FILE = Path(Constants.SINGBOX_LOG_FILE)
|
_LOG_FILE = Path(Constants.SINGBOX_LOG_FILE)
|
||||||
_CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR)
|
_CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR)
|
||||||
|
|
||||||
_START_TIMEOUT_S = 15.0 # tiempo total de espera
|
_START_TIMEOUT_S = 15.0
|
||||||
_START_POLL_S = 0.5 # intervalo de polling
|
_START_POLL_S = 0.5
|
||||||
_START_INITIAL_S = 2.0 # espera inicial antes de empezar a verificar
|
_START_INITIAL_S = 2.0
|
||||||
# (el wrapper necesita ~1s para escribir el PID file)
|
|
||||||
|
|
||||||
# ── Público ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def start(self, config_path: Path) -> bool:
|
def start(self, config_path: Path) -> bool:
|
||||||
"""
|
|
||||||
Para cualquier instancia previa y lanza sing-box via wrapper.
|
|
||||||
Retorna True solo cuando el proceso está confirmado vivo.
|
|
||||||
"""
|
|
||||||
self.stop()
|
self.stop()
|
||||||
self._assert_config_safe(config_path)
|
self._assert_config_safe(config_path)
|
||||||
self._ensure_log_file()
|
self._ensure_log_file()
|
||||||
|
|
@ -49,11 +33,10 @@ class SingboxRunner:
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'Wrapper no encontrado: {self._WRAPPER}\n'
|
f'Wrapper not found: {self._WRAPPER}\n'
|
||||||
'Ejecutá el installer primero.'
|
'Run the installer first.'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Esperar que el wrapper escriba el PID file antes de empezar polling
|
|
||||||
time.sleep(self._START_INITIAL_S)
|
time.sleep(self._START_INITIAL_S)
|
||||||
|
|
||||||
deadline = time.monotonic() + self._START_TIMEOUT_S
|
deadline = time.monotonic() + self._START_TIMEOUT_S
|
||||||
|
|
@ -63,15 +46,13 @@ class SingboxRunner:
|
||||||
time.sleep(self._START_POLL_S)
|
time.sleep(self._START_POLL_S)
|
||||||
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'sing-box no levantó en {self._START_TIMEOUT_S}s.\n'
|
f'sing-box did not start within {self._START_TIMEOUT_S}s.\n'
|
||||||
f'Último error:\n{self.get_last_error()}'
|
f'Last error:\n{self.get_last_error()}'
|
||||||
)
|
)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""
|
self._kill_orphans()
|
||||||
Para sing-box limpiamente via wrapper.
|
|
||||||
Nunca lanza excepción — stop debe ser siempre seguro de llamar.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
['sudo', self._WRAPPER, '', 'stop'],
|
['sudo', self._WRAPPER, '', 'stop'],
|
||||||
|
|
@ -85,16 +66,7 @@ class SingboxRunner:
|
||||||
self._emergency_kill()
|
self._emergency_kill()
|
||||||
|
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""
|
|
||||||
Verifica por PID file + señal 0.
|
|
||||||
PermissionError significa que el proceso VIVE (corre como root).
|
|
||||||
Si el PID file no existe todavía, verifica por pgrep como fallback
|
|
||||||
para cubrir la ventana entre que el proceso arranca y el wrapper
|
|
||||||
escribe el PID file.
|
|
||||||
"""
|
|
||||||
if not self._PID_FILE.exists():
|
if not self._PID_FILE.exists():
|
||||||
# Fallback: el proceso puede estar vivo pero el PID file
|
|
||||||
# aún no fue escrito por el wrapper
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['pgrep', '-x', 'sing-box'],
|
['pgrep', '-x', 'sing-box'],
|
||||||
|
|
@ -115,14 +87,9 @@ class SingboxRunner:
|
||||||
self._PID_FILE.unlink(missing_ok=True)
|
self._PID_FILE.unlink(missing_ok=True)
|
||||||
return False
|
return False
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
# Proceso vivo pero de root — PermissionError es señal de vida
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_last_error(self) -> str:
|
def get_last_error(self) -> str:
|
||||||
"""
|
|
||||||
Lee las últimas 80 líneas del log via sudo para evitar
|
|
||||||
problemas de permisos (el log lo escribe root via wrapper).
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['sudo', self._WRAPPER, '', 'log'],
|
['sudo', self._WRAPPER, '', 'log'],
|
||||||
|
|
@ -141,10 +108,6 @@ class SingboxRunner:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def write_config(self, config_path: Path, config: dict) -> None:
|
def write_config(self, config_path: Path, config: dict) -> None:
|
||||||
"""
|
|
||||||
Escribe el JSON de forma atómica con permisos 0o600.
|
|
||||||
Write en .tmp + rename — nunca deja un config a medias.
|
|
||||||
"""
|
|
||||||
self._assert_config_safe(config_path)
|
self._assert_config_safe(config_path)
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
|
||||||
|
|
@ -158,44 +121,82 @@ class SingboxRunner:
|
||||||
tmp.unlink(missing_ok=True)
|
tmp.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# ── Privado ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _ensure_log_file(self) -> None:
|
def _ensure_log_file(self) -> None:
|
||||||
"""
|
|
||||||
Crea el log file con permisos del usuario actual si no existe.
|
|
||||||
Debe llamarse ANTES de que el wrapper lo tome como root.
|
|
||||||
"""
|
|
||||||
self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if not self._LOG_FILE.exists():
|
if not self._LOG_FILE.exists():
|
||||||
self._LOG_FILE.touch(mode=0o600)
|
self._LOG_FILE.touch(mode=0o600)
|
||||||
|
|
||||||
def _assert_config_safe(self, config_path: Path) -> None:
|
def _assert_config_safe(self, config_path: Path) -> None:
|
||||||
"""
|
|
||||||
Verifica que el config está dentro del directorio permitido.
|
|
||||||
Lanza ValueError si no — nunca continúa con path inseguro.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
config_path.resolve().relative_to(self._CONFIG_DIR.resolve())
|
config_path.resolve().relative_to(self._CONFIG_DIR.resolve())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'Config fuera del directorio permitido.\n'
|
f'Config outside the allowed directory.\n'
|
||||||
f' Config: {config_path}\n'
|
f' Config: {config_path}\n'
|
||||||
f' Permitido: {self._CONFIG_DIR}'
|
f' Allowed: {self._CONFIG_DIR}'
|
||||||
)
|
)
|
||||||
|
|
||||||
def _emergency_kill(self) -> None:
|
def _emergency_kill(self) -> None:
|
||||||
"""
|
|
||||||
Kill directo por PID file cuando el wrapper no responde.
|
|
||||||
Último recurso — no usa sudo.
|
|
||||||
"""
|
|
||||||
if not self._PID_FILE.exists():
|
if not self._PID_FILE.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pid = int(self._PID_FILE.read_text().strip())
|
pid = int(self._PID_FILE.read_text().strip())
|
||||||
if pid > 0:
|
if pid > 0:
|
||||||
os.kill(pid, 9)
|
subprocess.run(
|
||||||
except (ValueError, ProcessLookupError, PermissionError, OSError):
|
['sudo', 'kill', '-9', str(pid)],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except (ValueError, OSError, subprocess.TimeoutExpired):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
self._PID_FILE.unlink(missing_ok=True)
|
self._PID_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _kill_orphans(self) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['pgrep', '-x', 'sing-box'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
pids = [
|
||||||
|
int(p)
|
||||||
|
for p in result.stdout.strip().splitlines()
|
||||||
|
if p.strip().isdigit()
|
||||||
|
]
|
||||||
|
|
||||||
|
if not pids:
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
['sudo', self._WRAPPER, '', 'stop'],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 4.0
|
||||||
|
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
check = subprocess.run(
|
||||||
|
['pgrep', '-x', 'sing-box'],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if check.returncode != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
Loading…
Reference in a new issue