Encrypted proxy killswitch + TUN optimization #1

Open
zenaku wants to merge 54 commits from feature/merge-encrypted-proxy into master
13 changed files with 1035 additions and 219 deletions
Showing only changes of commit 86621021bc - Show all commits

11
.env
View file

@ -1,6 +1,7 @@
# Environment: local | production
APP_ENV=local
# API URLs
APP_ENV=production
SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1
SP_API_BASE_URL_PRODUCTION=https://api.hydraveil.net/api/v1
SP_API_BASE_URL_PRODUCTION=http://simplifiedprivacy.test/api/v1
# SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1
# SP_API_BASE_URL_PRODUCTION=https://api.hydraveil.net/api/v1
INSTALL_DIR=/home/coltic/Desktop/sp/Gitlab/CORE

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
APP_ENV=
SP_API_BASE_URL_LOCAL=
SP_API_BASE_URL_PRODUCTION=
# SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1
# SP_API_BASE_URL_PRODUCTION=https://api.hydraveil.net/api/v1

View file

@ -1,34 +1,34 @@
# sp-hydra-veil-core
python3 -c "
from core.services.WebServiceApiService import WebServiceApiService
subscription = WebServiceApiService.post_subscription(2, operator_id=3)
print('billing code:', subscription.billing_code)
"
The `sp-hydra-veil-core` library exposes core logic to higher-level components.
parchear
sudo docker compose exec laravel.test php artisan tinker --execute="
\$sub = \App\Models\Subscription::orderBy('id', 'desc')->first();
\$sub->expires_at = '2027-12-31 23:59:59';
\$sub->duration = 720;
\$sub->payment_reference = 'bypass_' . uniqid();
\$sub->save();
echo 'OK' . PHP_EOL;
"
conectar
python3 -c "
from core.services.WebServiceApiService import WebServiceApiService
from core.controllers.encrypted_proxy.VlessController import VlessController
from core.observers.EncryptedProxyObserver import EncryptedProxyObserver
## Build Instructions
observer = EncryptedProxyObserver()
observer.subscribe('connected', lambda e: print('connected:', e.subject))
observer.subscribe('error', lambda e: print('error:', e.subject))
observer.subscribe('disconnected', lambda e: print('disconnected:', e.subject))
### Presumptions
session = WebServiceApiService.post_operator_proxy('617N-LPKB-MVPT-YRQM', 3, 'vless')
print('session:', session)
* Your system is configured to use the Simplified Privacy package registry [1].
controller = VlessController(1080)
controller.enable(session.links[0], session.username, observer)
"
### Prerequisites
* `build`
* `twine`
To install them, activate your `venv`, if necessary, and run:
```bash
pip install build twine
```
### Build Steps
* Activate your `venv`, if necessary, and run:
```bash
rm -rf ./dist/*
python3 -m build
python3 -m twine upload --repository forgejo ./dist/*
```
---
[1] https://forgejo.org/docs/v14.0/user/packages/pypi/#configuring-the-package-registry

View file

@ -25,10 +25,11 @@ class Constants:
HV_CLIENT_VERSION_NUMBER: Final[str] = os.environ.get('HV_CLIENT_VERSION_NUMBER')
HOME: Final[str] = os.path.expanduser('~')
SYSTEM_CONFIG_PATH: Final[str] = '/etc'
CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', os.path.join(HOME, '.cache'))
CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', os.path.join(HOME, '.local/share'))
STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', os.path.join(HOME, '.local/state'))
INSTALL_DIR: Final[str] = os.environ.get('INSTALL_DIR', os.path.expanduser('~'))
CACHE_HOME: Final[str] = os.environ.get('XDG_CACHE_HOME', f'{INSTALL_DIR}/.cache')
CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME', f'{INSTALL_DIR}/.config')
DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME', f'{INSTALL_DIR}/data')
STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME', f'{INSTALL_DIR}/.state')
HV_SYSTEM_CONFIG_PATH: Final[str] = f'{SYSTEM_CONFIG_PATH}/hydra-veil'
HV_CACHE_HOME: Final[str] = f'{CACHE_HOME}/hydra-veil'
HV_CONFIG_HOME: Final[str] = f'{CONFIG_HOME}/hydra-veil'

View file

@ -3,6 +3,7 @@ from pathlib import Path
from core.Constants import Constants
from core.utils.encrypted_proxy.net import get_real_ip, verify_ip
from core.utils.encrypted_proxy.singbox import SingboxRunner
import socket
def parse_vless_link(link: str) -> dict:
@ -24,13 +25,15 @@ def parse_vless_link(link: str) -> dict:
"port": int(port),
"path": unquote(params.get("path", "/vless")),
"sni": sni,
"ws_host": ws_host,
"ws_host": ws_host,
"security": params.get("security", "tls"),
"network": params.get("type", "ws"),
}
def build_vless_config(vless: dict, socks5_port: int) -> dict:
server_ip = socket.gethostbyname(vless["host"])
return {
"dns": {
"servers": [{"tag": "local", "type": "udp", "server": "9.9.9.9"}],
@ -60,18 +63,18 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict:
{
"type": "vless",
"tag": "proxy",
"server": vless["sni"],
"server_port": 443,
"server": server_ip,
"server_port": vless["port"],
"uuid": vless["uuid"],
"tls": {
"enabled": True,
"enabled": vless["security"] == "tls",
"server_name": vless["sni"],
"insecure": False,
},
"transport": {
"type": "ws",
"path": vless["path"],
"headers": {"Host": vless["sni"]},
"headers": {"Host": vless["ws_host"]},
},
},
],
@ -79,6 +82,7 @@ def build_vless_config(vless: dict, socks5_port: int) -> dict:
"rules": [
{"protocol": "dns", "action": "hijack-dns"},
{"ip_cidr": ["172.19.0.0/30"], "action": "hijack-dns"},
{"ip_cidr": [f"{server_ip}/32"], "outbound": "direct"},
{"ip_is_private": True, "outbound": "direct"},
{"ip_version": 6, "outbound": "block"},
{"inbound": ["tun-in", "socks-in"], "outbound": "proxy"},
@ -109,7 +113,7 @@ def enable_vless(vless_link: str, username: str,
if observer:
observer.notify("error", "sing-box not active after start")
return False
proxy_ip = verify_ip(socks5_port)
proxy_ip = verify_ip(socks5_port, retries=5, delay=3.0)
if observer:
observer.notify("connected", {
"real_ip": real_ip,

View file

@ -1,35 +1,145 @@
import json
import os
import subprocess
import time
from pathlib import Path
from core.Constants import Constants
class SingboxRunner:
WRAPPER = "/usr/local/bin/hydraveil-singbox"
"""
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
_PID_FILE = Path(Constants.SINGBOX_PID_FILE)
_LOG_FILE = Path(Constants.SINGBOX_LOG_FILE)
_CONFIG_DIR = Path(Constants.SINGBOX_CONFIG_DIR)
_START_TIMEOUT_S = 10.0
_START_POLL_S = 0.5
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()
time.sleep(1)
self._process = subprocess.Popen(
["sudo", self.WRAPPER, str(config_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(2)
return self.is_running()
def stop(self) -> str:
subprocess.run(["sudo", self.WRAPPER, "", "stop"], capture_output=True)
subprocess.run(["sudo", "ip", "link", "delete", "tun0"],
capture_output=True)
self._process = None
return "stopped"
self._assert_config_safe(config_path)
self._LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
try:
subprocess.Popen(
['sudo', self._WRAPPER, str(config_path), 'start'],
stdout=subprocess.DEVNULL,
stderr=open(self._LOG_FILE, 'a'),
stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
)
except FileNotFoundError:
raise RuntimeError(
f'Wrapper no encontrado: {self._WRAPPER}\n'
'Ejecutá el installer primero.'
)
deadline = time.monotonic() + self._START_TIMEOUT_S
while time.monotonic() < deadline:
time.sleep(self._START_POLL_S)
if self.is_running():
return True
error = self.get_last_error()
raise RuntimeError(
f'sing-box no levantó en {self._START_TIMEOUT_S}s.\n'
f'Último error:\n{error}'
)
def stop(self) -> None:
try:
subprocess.run(
['sudo', self._WRAPPER, '', 'stop'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env={**os.environ, 'SUDO_ASKPASS': '/bin/false'},
timeout=15,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
self._emergency_kill()
def is_running(self) -> bool:
r = subprocess.run(["pgrep", "-f", "sing-box"], capture_output=True)
return r.returncode == 0
if not self._PID_FILE.exists():
return False
try:
pid = int(self._PID_FILE.read_text().strip())
if pid <= 0:
raise ValueError
os.kill(pid, 0)
return True
except (ValueError, ProcessLookupError):
self._PID_FILE.unlink(missing_ok=True)
return False
except PermissionError:
return True
def get_last_error(self) -> str:
try:
lines = self._LOG_FILE.read_text(errors='replace').splitlines()
return '\n'.join(lines[-80:])
except OSError:
return ''
def write_config(self, config_path: Path, config: dict) -> None:
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
self._assert_config_safe(config_path)
config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
tmp = config_path.with_suffix('.tmp')
try:
with open(tmp, 'w') as f:
json.dump(config, f, indent=2)
os.chmod(tmp, 0o600)
tmp.rename(config_path)
except Exception:
tmp.unlink(missing_ok=True)
raise
# ── Privado ───────────────────────────────────────────────────────────────
def _assert_config_safe(self, config_path: Path) -> None:
try:
config_path.resolve().relative_to(self._CONFIG_DIR.resolve())
except ValueError:
raise ValueError(
f'Config fuera del directorio permitido.\n'
f' Config: {config_path}\n'
f' Permitido: {self._CONFIG_DIR}'
)
def _emergency_kill(self) -> None:
if not self._PID_FILE.exists():
return
try:
pid = int(self._PID_FILE.read_text().strip())
if pid > 0:
os.kill(pid, 9)
except (ValueError, ProcessLookupError, PermissionError, OSError):
pass
finally:
self._PID_FILE.unlink(missing_ok=True)

235
install.sh Normal file → Executable file
View file

@ -1,173 +1,96 @@
#!/bin/bash
set -e
# install.sh — hydra-veil core installer
REPO_URL="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-core"
BRANCH="core-laravel-proxyTEST"
INSTALL_DIR="$HOME/sp-hydra-veil-core"
SINGBOX_VERSION="1.13.5"
SINGBOX_BIN="/usr/bin/sing-box"
WRAPPER_PATH="/usr/local/bin/hydraveil-singbox"
SUDOERS_PATH="/etc/sudoers.d/hydraveil"
HV_DATA_HOME="$HOME/.local/share/hydra-veil"
set -euo pipefail
echo "=== sp-hydra-veil-core installer ==="
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$SCRIPT_DIR/lib"
# 1. Clone repo
echo ""
echo "[1/6] Cloning repository (branch: $BRANCH)..."
if [ -d "$INSTALL_DIR" ]; then
echo "Directory already exists, removing..."
rm -rf "$INSTALL_DIR"
fi
git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
# 2. .env
echo ""
echo "[2/6] Creating .env..."
cat > "$INSTALL_DIR/.env" << 'DOTENV'
SP_API_BASE_URL_LOCAL=http://simplifiedprivacy.test/api/v1
SP_API_BASE_URL_PRODUCTION=https://fake.simplifiedprivacy.org/api/v1
APP_ENV=production
DOTENV
echo ".env created."
# 3. venv + dependencies
echo ""
echo "[3/6] Setting up virtual environment..."
python3 -m venv "$INSTALL_DIR/.venv"
source "$INSTALL_DIR/.venv/bin/activate"
pip install --quiet --upgrade pip
pip install \
python-dotenv \
"cryptography~=46.0.3" \
"dataclasses-json~=0.6.7" \
"marshmallow~=3.26.1" \
"psutil~=7.1.3" \
"pysocks~=1.7.1" \
"python-dateutil~=2.9.0.post0" \
"requests~=2.32.5" \
"annotated-types==0.7.0" \
"certifi==2026.4.22" \
"charset-normalizer==3.4.7" \
"click==8.3.3" \
"cytoolz==1.1.0" \
"eth-hash==0.8.0" \
"eth-typing==6.0.0" \
"eth-utils==6.0.0" \
"idna==3.13" \
"packaging==26.2" \
"pathspec==1.1.1" \
"platformdirs==4.9.6" \
"py-ecc==8.0.0" \
"pydantic==2.13.3" \
"pydantic_core==2.46.3" \
"pydeps==3.0.6" \
"pytokens==0.4.1" \
"stdlib-list==0.12.0" \
"toolz==1.1.0" \
"typing-inspect==0.9.0" \
"typing-inspection==0.4.2" \
"typing_extensions==4.15.0" \
"urllib3==2.6.3"
pip install -e "$INSTALL_DIR" --no-deps
echo "Dependencies installed."
# 4. sing-box
echo ""
echo "[4/6] Checking sing-box..."
# Find sing-box in any common location
FOUND_SINGBOX=""
for candidate in /usr/bin/sing-box /usr/local/bin/sing-box /bin/sing-box /usr/local/sbin/sing-box; do
if [ -x "$candidate" ]; then
FOUND_SINGBOX="$candidate"
break
for _lib in ui log input network steps; do
_path="$LIB_DIR/${_lib}.sh"
if [ ! -f "$_path" ]; then
echo "[ERROR] Missing lib: $_path" >&2
exit 1
fi
source "$_path"
done
# Also check PATH
if [ -z "$FOUND_SINGBOX" ]; then
FOUND_SINGBOX=$(command -v sing-box 2>/dev/null || true)
fi
for arg in "$@"; do
case "$arg" in
--no-log) LOG_ENABLED="false" ;;
--local) FORCE_ENV="local" ;;
esac
done
if [ -n "$FOUND_SINGBOX" ]; then
echo "sing-box found at: $FOUND_SINGBOX"
echo "Version: $($FOUND_SINGBOX version | head -1)"
# If not at /usr/bin/sing-box, create symlink
if [ "$FOUND_SINGBOX" != "$SINGBOX_BIN" ]; then
echo "Creating symlink: $FOUND_SINGBOX -> $SINGBOX_BIN"
sudo ln -sf "$FOUND_SINGBOX" "$SINGBOX_BIN"
fi
else
echo "sing-box not found, installing v$SINGBOX_VERSION..."
wget -q "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-amd64.tar.gz" -O /tmp/sing-box.tar.gz
tar -xzf /tmp/sing-box.tar.gz -C /tmp
sudo cp "/tmp/sing-box-${SINGBOX_VERSION}-linux-amd64/sing-box" "$SINGBOX_BIN"
sudo chmod 755 "$SINGBOX_BIN"
rm -rf /tmp/sing-box.tar.gz "/tmp/sing-box-${SINGBOX_VERSION}-linux-amd64"
echo "sing-box installed: $($SINGBOX_BIN version | head -1)"
fi
_log_init
# Final check
if [ ! -x "$SINGBOX_BIN" ]; then
echo "ERROR: sing-box not found at $SINGBOX_BIN after install. Aborting."
exit 1
fi
echo "sing-box OK at $SINGBOX_BIN"
clear
print_header "HYDRA-VEIL CORE INSTALLER" "v1.0.0"
printf " ${DIM}%s${RESET}\n" "Simplified Privacy"
printf " ${DIM}%s${RESET}\n" "$(date '+%Y-%m-%d %H:%M:%S')$(uname -n)"
print_divider
print_section "Pre-flight"
log_info "User: ${USER}"
log_info "Home: ${HOME}"
log_info "Install dir: ${HV_CORE_HOME}"
require_internet
print_divider
# 5. wrapper + sudoers + HV_DATA_HOME
echo ""
echo "[5/6] Installing wrapper..."
sudo tee "$WRAPPER_PATH" > /dev/null << WRAPPER
#!/bin/bash
CONFIG=\$1
ACTION=\$2
if [[ "\$ACTION" == "stop" ]]; then
pkill -9 -f sing-box
label_info "hydra-veil core se instalará en:"
printf " ${BCYAN}%s${RESET}\n\n" "$HV_CORE_HOME"
label_warn "sudo es requerido para sing-box, wrapper y sudoers."
echo ""
if ! ask_confirm "Continuar con la instalación?"; then
log_warn "Instalación cancelada."
exit 0
fi
if [[ "\$CONFIG" != /tmp/*.json ]] && \\
[[ "\$CONFIG" != /home/*/.local/share/hydra-veil/*.json ]]; then
echo "Error: config path not allowed"
exit 1
fi
exec $SINGBOX_BIN run -c "\$CONFIG"
WRAPPER
sudo chmod 755 "$WRAPPER_PATH"
# Verify wrapper points to correct sing-box
if grep -q "$SINGBOX_BIN" "$WRAPPER_PATH"; then
echo "Wrapper OK — points to $SINGBOX_BIN"
else
echo "ERROR: wrapper does not reference $SINGBOX_BIN"
exit 1
fi
# 6. sudoers
echo ""
echo "[6/6] Configuring sudoers..."
if [ ! -f "$SUDOERS_PATH" ]; then
echo "$USER ALL=(ALL) NOPASSWD: $WRAPPER_PATH" | sudo tee "$SUDOERS_PATH" > /dev/null
sudo chmod 440 "$SUDOERS_PATH"
echo "Sudoers configured."
else
echo "Sudoers already exists, skipping."
fi
print_divider
# Create HV_DATA_HOME directory
mkdir -p "$HV_DATA_HOME"
echo "HV_DATA_HOME created: $HV_DATA_HOME"
step_clone_repo
step_create_env
step_python_env
step_singbox
step_wrapper
step_sudoers
echo ""
echo "=== Installation complete ==="
echo "Activate the environment with:"
echo " source $INSTALL_DIR/.venv/bin/activate"
print_divider
printf "${BGREEN}"
cat << 'EOF'
_
_ _|_| _
| \_ _| | _/ |
| \__|___|__/ |
| | _ _ | |
| || | | || |
\_ || | | || _/
\_||_| |_||_/
______| |______
_/ \_
_/ S I M P L I F I E D \_
_/ P R I V A C Y \_
| _____ _____ |
| _/ |_ _| \_ |
|_/ |_ _| \_|
\_____/
EOF
printf "${RESET}"
print_divider
echo ""
label_ok "hydra-veil core instalado correctamente"
label_ok "Todos los steps completados"
echo ""
label_info "Activar entorno:"
printf " ${CYAN}source %s/.venv/bin/activate${RESET}\n" "$HV_CORE_HOME"
echo ""
label_info "Wrapper disponible en:"
printf " ${CYAN}sudo /usr/local/bin/hydraveil-singbox <config.json> start${RESET}\n"
echo ""
log_file_path
echo ""
print_divider
echo ""

32
lib/input.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
ask_input() {
local label="$1"
local default="${2:-}"
local prompt_default=""
[ -n "$default" ] && prompt_default=" ${DIM}[${default}]${RESET}"
printf " ${BCYAN}?${RESET} ${BWHITE}%s${RESET}%b " "$label" "$prompt_default"
read -r INPUT_RESULT
[ -z "$INPUT_RESULT" ] && INPUT_RESULT="$default"
}
ask_confirm() {
local label="$1"
local default="${2:-y}"
local hint
if [ "$default" = "y" ]; then hint="${BGREEN}Y${RESET}${DIM}/n${RESET}"; else hint="${DIM}y/${RESET}${BRED}N${RESET}"; fi
printf " ${BYELLOW}?${RESET} ${BWHITE}%s${RESET} [%b] " "$label" "$hint"
read -r _confirm
_confirm="${_confirm:-$default}"
case "${_confirm,,}" in
y|yes) return 0 ;;
*) return 1 ;;
esac
}
ask_pause() {
local msg="${1:-Press ENTER to continue...}"
printf " ${DIM}%s${RESET}" "$msg"
read -r
}

52
lib/log.sh Executable file
View file

@ -0,0 +1,52 @@
#!/bin/bash
LOG_FILE="${LOG_FILE:-/tmp/hydra-veil-core.log}"
LOG_ENABLED="${LOG_ENABLED:-true}"
_log_init() {
if [ "$LOG_ENABLED" = "true" ]; then
mkdir -p "$(dirname "$LOG_FILE")"
echo "──────────────────────────────────────────" >> "$LOG_FILE"
echo " LOG START: $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE"
echo "──────────────────────────────────────────" >> "$LOG_FILE"
fi
}
_log_write() {
local level="$1"
local msg="$2"
if [ "$LOG_ENABLED" = "true" ]; then
printf "[%s] [%s] %s\n" "$(date '+%H:%M:%S')" "$level" "$msg" >> "$LOG_FILE"
fi
}
log_info() {
label_info "$1"
_log_write "INFO " "$1"
}
log_ok() {
label_ok "$1"
_log_write "OK " "$1"
}
log_warn() {
label_warn "$1"
_log_write "WARN " "$1"
}
log_error() {
label_error "$1"
_log_write "ERROR" "$1"
}
log_step() {
local msg="$1"
local current="$2"
local total="$3"
label_step "$msg" "$current" "$total"
_log_write "STEP " "[$current/$total] $msg"
}
log_file_path() {
printf " ${DIM}Log file: ${CYAN}%s${RESET}\n" "$LOG_FILE"
}

53
lib/network.sh Executable file
View file

@ -0,0 +1,53 @@
#!/bin/bash
NET_CHECK_HOST="${NET_CHECK_HOST:-8.8.8.8}"
NET_CHECK_TIMEOUT="${NET_CHECK_TIMEOUT:-3}"
check_internet() {
local host="${1:-$NET_CHECK_HOST}"
if ping -c 1 -W "$NET_CHECK_TIMEOUT" "$host" &>/dev/null; then
label_connected "Internet reachable ${DIM}(${host})${RESET}"
_log_write "NET " "Connected: $host"
return 0
else
label_disconnect "No internet connection ${DIM}(${host})${RESET}"
_log_write "NET " "Disconnected: $host"
return 1
fi
}
check_host() {
local host="$1"
local label="${2:-$host}"
if ping -c 1 -W "$NET_CHECK_TIMEOUT" "$host" &>/dev/null 2>&1; then
label_connected "$label"
return 0
else
label_disconnect "$label"
return 1
fi
}
check_url() {
local url="$1"
local label="${2:-$url}"
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time "$NET_CHECK_TIMEOUT" "$url" 2>/dev/null || echo "000")
if [[ "$http_code" =~ ^[23] ]]; then
label_connected "$label ${DIM}(HTTP ${http_code})${RESET}"
_log_write "NET " "URL OK [$http_code]: $url"
return 0
else
label_disconnect "$label ${DIM}(HTTP ${http_code})${RESET}"
_log_write "NET " "URL FAIL [$http_code]: $url"
return 1
fi
}
require_internet() {
if ! check_internet; then
log_error "Internet connection required. Aborting."
exit 1
fi
}

489
lib/steps.sh Executable file
View file

@ -0,0 +1,489 @@
#!/bin/bash
# lib/steps.sh — Install steps for hydra-veil core
# Requiere: lib/ui.sh, lib/log.sh, lib/input.sh, lib/network.sh
# ─── Ruta canónica fija — todo vive aquí ──────────────────────────────────────
HV_CORE_HOME="$HOME/.local/share/hydra-veil/core"
# Subdirectorios
HV_REPO_DIR="$HV_CORE_HOME/repo"
HV_VENV_DIR="$HV_CORE_HOME/.venv"
HV_DATA_DIR="$HV_CORE_HOME/data/hydra-veil"
HV_LOG_DIR="$HV_CORE_HOME/logs/hydra-veil"
HV_RUN_DIR="$HV_CORE_HOME/run"
HV_CONFIG_DIR="$HV_DATA_DIR/configs"
# sing-box
SINGBOX_VERSION="1.13.5"
SINGBOX_BIN="/usr/bin/sing-box"
SINGBOX_PID_FILE="$HV_RUN_DIR/singbox.pid"
SINGBOX_LOG_FILE="$HV_LOG_DIR/singbox.log"
# Sistema
WRAPPER_PATH="/usr/local/bin/hydraveil-singbox"
SUDOERS_PATH="/etc/sudoers.d/hydraveil"
REPO_URL="https://git.simplifiedprivacy.com/Support/sp-hydra-veil-core"
BRANCH="core-laravel-proxyTEST"
TOTAL_STEPS=6
# ─── Step 1: Repo ─────────────────────────────────────────────────────────────
step_clone_repo() {
print_section "Repository"
log_step "Cloning repository" 1 $TOTAL_STEPS
log_info "Destino: ${HV_REPO_DIR}"
if [ -d "$HV_REPO_DIR/.git" ]; then
log_warn "Repo ya existe en: $HV_REPO_DIR"
if ask_confirm "Eliminar y re-clonar?"; then
start_spinner "Eliminando repo anterior..."
rm -rf "$HV_REPO_DIR"
stop_spinner "Eliminado"
else
log_skip "Re-clone omitido — usando repo existente"
return 0
fi
fi
mkdir -p "$HV_CORE_HOME"
chmod 700 "$HV_CORE_HOME"
start_spinner "Clonando ${REPO_URL}..."
if git clone --branch "$BRANCH" "$REPO_URL" "$HV_REPO_DIR" &>/dev/null; then
stop_spinner "Repositorio clonado → ${HV_REPO_DIR}"
else
stop_spinner
log_error "git clone falló"
exit 1
fi
chmod 700 "$HV_REPO_DIR"
log_ok "Permisos aplicados"
}
# ─── Step 2: .env ─────────────────────────────────────────────────────────────
step_create_env() {
print_section ".env Configuration"
log_step "Building .env from .env.example" 2 $TOTAL_STEPS
local example_file="$HV_REPO_DIR/.env.example"
local env_file="$HV_REPO_DIR/.env"
if [ ! -f "$example_file" ]; then
log_error ".env.example no encontrado: $example_file"
exit 1
fi
if [ -f "$env_file" ]; then
log_warn ".env ya existe"
if ! ask_confirm "Sobreescribir .env existente?"; then
label_skip ".env sin cambios — omitiendo"
return 0
fi
fi
echo ""
printf " ${DIM}Completá cada variable. Enter para dejar vacío.${RESET}\n"
print_divider
local -a env_lines=()
while IFS= read -r line || [ -n "$line" ]; do
# Comentarios y líneas vacías — pasar directo
if [[ -z "$line" || "$line" == \#* ]]; then
env_lines+=("$line")
continue
fi
local key default_val value
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
default_val="${BASH_REMATCH[2]}"
else
env_lines+=("$line")
continue
fi
if [ "$key" = "APP_ENV" ]; then
echo ""
printf " ${BCYAN}APP_ENV${RESET} ${DIM}— entorno de la aplicación${RESET}\n"
printf " ${BWHITE}1)${RESET} ${GREEN}local${RESET} ${BWHITE}2)${RESET} ${CYAN}production${RESET}\n"
printf " ${BWHITE}Opción${RESET} ${DIM}[1/2, default=2]:${RESET} "
read -r _choice < /dev/tty
case "$_choice" in
1) value="local" ;;
*) value="production" ;;
esac
log_ok "APP_ENV=${value}"
else
local hint=""
[ -n "$default_val" ] && hint="${DIM}(default: ${default_val})${RESET}"
echo ""
printf " ${BWHITE}%s${RESET} %b\n" "$key" "$hint"
printf " ${BCYAN}${RESET} "
read -r value < /dev/tty
[ -z "$value" ] && value="$default_val"
[ -n "$value" ] && log_ok "${key}=${value}" || label_skip "${key} — vacío"
fi
env_lines+=("${key}=${value}")
done < "$example_file"
# Escribir .env
printf '%s\n' "${env_lines[@]}" > "$env_file"
chmod 600 "$env_file"
# Agregar paths del installer automáticamente
cat >> "$env_file" << EOF
# ── Generado por el installer ──────────────────────────────────────────────
HV_CORE_HOME=${HV_CORE_HOME}
INSTALL_DIR=${HV_REPO_DIR}
CORE_DIR=${HV_CORE_HOME}
EOF
log_ok ".env escrito → ${env_file}"
echo ""
# Preview ocultando secrets
printf " ${DIM}Preview:${RESET}\n"
while IFS= read -r ln; do
if [[ -z "$ln" || "$ln" == \#* ]]; then
printf " ${DIM}%s${RESET}\n" "$ln"
else
local pk pv
pk="${ln%%=*}"
pv="${ln#*=}"
echo "$pk" | grep -qiE '(secret|token|key|pass)' && pv="****"
printf " ${DIM}%s${RESET}=${CYAN}%s${RESET}\n" "$pk" "$pv"
fi
done < "$env_file"
echo ""
}
# ─── Step 3: Python venv ──────────────────────────────────────────────────────
step_python_env() {
print_section "Python Environment"
log_step "Setting up virtualenv + dependencies" 3 $TOTAL_STEPS
if ! command -v python3 &>/dev/null; then
log_error "python3 no encontrado. Instalalo primero."
exit 1
fi
log_info "Python: $(python3 --version 2>&1)"
# Crear venv en HV_VENV_DIR (separado del repo)
start_spinner "Creando virtual environment..."
python3 -m venv "$HV_VENV_DIR"
stop_spinner "Virtual environment creado → ${HV_VENV_DIR}"
source "$HV_VENV_DIR/bin/activate"
start_spinner "Actualizando pip..."
pip install --quiet --upgrade pip
stop_spinner "pip actualizado"
local req_file="$HV_REPO_DIR/requirements.txt"
if [ ! -f "$req_file" ]; then
log_warn "requirements.txt no encontrado — instalando dependencias base"
start_spinner "Instalando dependencias..."
pip install --quiet \
python-dotenv \
"cryptography~=46.0.3" \
"dataclasses-json~=0.6.7" \
"psutil~=7.1.3" \
"pysocks~=1.7.1" \
"requests~=2.32.5" \
"pydantic==2.13.3"
stop_spinner "Dependencias instaladas"
return 0
fi
# Instalar todo de una vez (no paquete por paquete)
# pip resuelve dependencias mejor en un solo lote
log_info "Instalando desde requirements.txt..."
start_spinner "Instalando paquetes..."
if pip install --quiet -r "$req_file"; then
stop_spinner "Paquetes instalados"
else
stop_spinner
log_warn "pip reportó errores — intentando instalar de a uno para identificar el fallo"
local failed=()
while IFS= read -r pkg || [ -n "$pkg" ]; do
pkg="${pkg%%#*}"
pkg="${pkg// /}"
[[ -z "$pkg" ]] && continue
pip install --quiet "$pkg" 2>/dev/null || failed+=("$pkg")
done < "$req_file"
[ "${#failed[@]}" -gt 0 ] && log_warn "Fallaron: ${failed[*]}"
fi
# Instalar el paquete en modo editable
start_spinner "Instalando paquete (editable)..."
pip install --quiet -e "$HV_REPO_DIR" --no-deps
stop_spinner "Paquete instalado en modo editable"
}
# ─── Step 4: sing-box ─────────────────────────────────────────────────────────
step_singbox() {
print_section "sing-box"
log_step "Checking sing-box binary" 4 $TOTAL_STEPS
local found=""
for candidate in /usr/bin/sing-box /usr/local/bin/sing-box /bin/sing-box; do
[ -x "$candidate" ] && found="$candidate" && break
done
[ -z "$found" ] && found=$(command -v sing-box 2>/dev/null || true)
if [ -n "$found" ]; then
local ver
ver=$("$found" version 2>/dev/null | head -1 || echo "unknown")
log_ok "Encontrado: ${found} (${ver})"
if [ "$found" != "$SINGBOX_BIN" ]; then
sudo ln -sf "$found" "$SINGBOX_BIN"
log_ok "Symlink → ${SINGBOX_BIN}"
fi
else
log_info "sing-box no encontrado — instalando v${SINGBOX_VERSION}..."
local url="https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-amd64.tar.gz"
local tmp_tar="/tmp/sing-box-$$.tar.gz"
local tmp_dir="/tmp/sing-box-$$"
start_spinner "Descargando sing-box v${SINGBOX_VERSION}..."
if wget -q --timeout=60 "$url" -O "$tmp_tar"; then
stop_spinner "Descarga completa"
else
stop_spinner
rm -f "$tmp_tar"
log_error "Descarga falló: ${url}"
exit 1
fi
start_spinner "Extrayendo..."
mkdir -p "$tmp_dir"
tar -xzf "$tmp_tar" -C "$tmp_dir" --strip-components=1
stop_spinner "Extraído"
sudo cp "$tmp_dir/sing-box" "$SINGBOX_BIN"
sudo chmod 755 "$SINGBOX_BIN"
rm -rf "$tmp_tar" "$tmp_dir"
log_ok "sing-box instalado: $($SINGBOX_BIN version | head -1)"
fi
# setcap — sing-box crea TUN sin necesitar root completo
if command -v setcap &>/dev/null; then
sudo setcap cap_net_admin,cap_net_raw+ep "$SINGBOX_BIN"
log_ok "setcap cap_net_admin,cap_net_raw+ep → ${SINGBOX_BIN}"
else
log_warn "setcap no disponible — instalá libcap2-bin para hardening completo"
log_warn "sing-box necesitará sudo completo para TUN hasta que se instale"
fi
log_ok "sing-box listo → ${SINGBOX_BIN}"
}
# ─── Step 5: Wrapper ──────────────────────────────────────────────────────────
step_wrapper() {
print_section "Security Wrapper"
log_step "Installing hydraveil-singbox wrapper" 5 $TOTAL_STEPS
[ -f "$WRAPPER_PATH" ] && sudo rm -f "$WRAPPER_PATH" && log_ok "Wrapper anterior eliminado"
sudo tee "$WRAPPER_PATH" > /dev/null << WRAPPER
#!/bin/bash
# hydraveil-singbox — wrapper de privilegios para sing-box
# ─────────────────────────────────────────────────────────
# Generado por el installer. NO editar manualmente.
# Solo este binario tiene NOPASSWD en sudoers.
#
# Uso:
# sudo hydraveil-singbox <config.json> start
# sudo hydraveil-singbox "" stop
set -euo pipefail
CONFIG="\${1:-}"
ACTION="\${2:-}"
# ── Paths fijos (escritos al instalar, nunca buscados en runtime) ─────────────
SINGBOX_BIN="${SINGBOX_BIN}"
PID_FILE="${SINGBOX_PID_FILE}"
LOG_FILE="${SINGBOX_LOG_FILE}"
CONFIG_DIR="${HV_CONFIG_DIR}"
STOP_TIMEOUT=8
# ── Validar acción ────────────────────────────────────────────────────────────
if [[ "\$ACTION" != "start" && "\$ACTION" != "stop" ]]; then
echo "Error: acción inválida '\$ACTION'. Uso: start|stop" >&2
exit 1
fi
# ── Helpers ───────────────────────────────────────────────────────────────────
_pid_running() {
local pid="\$1"
[[ "\$pid" =~ ^[0-9]+$ ]] && kill -0 "\$pid" 2>/dev/null
}
_delete_tun() {
if ip link show tun0 &>/dev/null 2>&1; then
ip link delete tun0 2>/dev/null || true
echo "[wrapper] tun0 eliminada" >> "\$LOG_FILE" 2>/dev/null || true
fi
}
_kill_by_pidfile() {
[ -f "\$PID_FILE" ] || return 0
local pid
pid=\$(cat "\$PID_FILE" 2>/dev/null || echo "")
if ! _pid_running "\$pid"; then
rm -f "\$PID_FILE"
return 0
fi
# SIGTERM → esperar → SIGKILL si no murió
kill -TERM "\$pid" 2>/dev/null || true
echo "[wrapper] SIGTERM → PID \$pid" >> "\$LOG_FILE" 2>/dev/null || true
local elapsed=0
while _pid_running "\$pid" && (( elapsed < STOP_TIMEOUT * 2 )); do
sleep 0.5
(( elapsed++ )) || true
done
if _pid_running "\$pid"; then
kill -KILL "\$pid" 2>/dev/null || true
echo "[wrapper] SIGKILL → PID \$pid (no respondió a SIGTERM)" >> "\$LOG_FILE" 2>/dev/null || true
sleep 0.3
fi
rm -f "\$PID_FILE"
}
# ── stop ──────────────────────────────────────────────────────────────────────
if [[ "\$ACTION" == "stop" ]]; then
_kill_by_pidfile
_delete_tun
echo "[wrapper] sing-box detenido — \$(date '+%Y-%m-%d %H:%M:%S')" >> "\$LOG_FILE" 2>/dev/null || true
exit 0
fi
# ── start ─────────────────────────────────────────────────────────────────────
# 1. Config requerido
if [[ -z "\$CONFIG" ]]; then
echo "Error: config requerido para start" >&2
exit 1
fi
# 2. Existe y es legible
if [[ ! -f "\$CONFIG" ]]; then
echo "Error: config no encontrado: \$CONFIG" >&2
exit 1
fi
if [[ ! -r "\$CONFIG" ]]; then
echo "Error: config no legible: \$CONFIG" >&2
exit 1
fi
# 3. Path dentro del directorio permitido (evita path traversal)
_real_config=\$(realpath "\$CONFIG")
_real_config_dir=\$(realpath "\$CONFIG_DIR")
if [[ "\$_real_config" != "\$_real_config_dir"/* ]]; then
echo "Error: config fuera del directorio permitido" >&2
echo " Config: \$_real_config" >&2
echo " Permitido: \$_real_config_dir" >&2
exit 1
fi
# 4. JSON válido
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "\$CONFIG" 2>/dev/null; then
echo "Error: JSON inválido: \$CONFIG" >&2
exit 1
fi
# 5. sing-box ejecutable
if [[ ! -x "\$SINGBOX_BIN" ]]; then
echo "Error: sing-box no encontrado: \$SINGBOX_BIN" >&2
exit 1
fi
# 6. Parar instancia previa
_kill_by_pidfile
_delete_tun
# 7. Directorios
mkdir -p "\$(dirname "\$PID_FILE")" && chmod 700 "\$(dirname "\$PID_FILE")"
mkdir -p "\$(dirname "\$LOG_FILE")" && chmod 700 "\$(dirname "\$LOG_FILE")"
# 8. Lanzar sing-box
"\$SINGBOX_BIN" run -c "\$CONFIG" >> "\$LOG_FILE" 2>&1 &
_new_pid=\$!
# 9. Verificar arranque inmediato
sleep 0.3
if ! _pid_running "\$_new_pid"; then
echo "Error: sing-box terminó inmediatamente. Ver: \$LOG_FILE" >&2
exit 1
fi
# 10. PID file
echo "\$_new_pid" > "\$PID_FILE"
chmod 600 "\$PID_FILE"
echo "[wrapper] sing-box iniciado — PID \$_new_pid — \$(date '+%Y-%m-%d %H:%M:%S')" >> "\$LOG_FILE"
echo "[wrapper] config: \$_real_config" >> "\$LOG_FILE"
exit 0
WRAPPER
sudo chmod 755 "$WRAPPER_PATH"
log_ok "Wrapper instalado → ${WRAPPER_PATH}"
}
# ─── Step 6: sudoers + directorios ────────────────────────────────────────────
step_sudoers() {
print_section "Permissions"
log_step "Configurando sudoers + directorios" 6 $TOTAL_STEPS
local _tmp_sudoers
_tmp_sudoers=$(mktemp)
cat > "$_tmp_sudoers" << EOF
# hydraveil — generado por installer $(date '+%Y-%m-%d')
# NO editar manualmente
Defaults!${WRAPPER_PATH} !requiretty
${USER} ALL=(root) NOPASSWD: ${WRAPPER_PATH}
EOF
# Validar con visudo ANTES de instalar — nunca instalar un sudoers roto
if ! sudo visudo -c -f "$_tmp_sudoers" &>/dev/null; then
rm -f "$_tmp_sudoers"
log_error "sudoers inválido — abortando para evitar lockout"
exit 1
fi
sudo cp "$_tmp_sudoers" "$SUDOERS_PATH"
sudo chmod 440 "$SUDOERS_PATH"
rm -f "$_tmp_sudoers"
log_ok "sudoers validado e instalado → ${SUDOERS_PATH}"
# Directorios con permisos correctos
local -a _dirs=(
"$HV_CONFIG_DIR"
"$HV_RUN_DIR"
"$HV_LOG_DIR"
"$HV_DATA_DIR"
)
for _dir in "${_dirs[@]}"; do
mkdir -p "$_dir"
chmod 700 "$_dir"
log_ok "Directorio → ${_dir}"
done
}

114
lib/ui.sh Executable file
View file

@ -0,0 +1,114 @@
#!/bin/bash
RESET="\033[0m"
BOLD="\033[1m"
DIM="\033[2m"
BLACK="\033[0;30m"
RED="\033[0;31m"
GREEN="\033[0;32m"
YELLOW="\033[0;33m"
BLUE="\033[0;34m"
CYAN="\033[0;36m"
WHITE="\033[0;37m"
BRED="\033[1;31m"
BGREEN="\033[1;32m"
BYELLOW="\033[1;33m"
BBLUE="\033[1;34m"
BCYAN="\033[1;36m"
BWHITE="\033[1;37m"
print_header() {
local title="${1:-INSTALLER}"
local version="${2:-}"
local width=60
local line
printf -v line '%*s' "$width" ''
line="${line// /─}"
echo ""
printf "${BCYAN}${line}${RESET}\n"
printf "${BCYAN} %-56s${RESET}\n" ""
printf "${BCYAN} ${BWHITE}%-54s${BCYAN} ${RESET}\n" "$title ${DIM}${version}${RESET}"
printf "${BCYAN} %-56s${RESET}\n" ""
printf "${BCYAN}${line}${RESET}\n"
echo ""
}
print_section() {
local label="$1"
echo ""
printf "${DIM}${CYAN}┌─${RESET} ${BWHITE}${label}${RESET}\n"
}
print_divider() {
printf "${DIM}────────────────────────────────────────────────────────────${RESET}\n"
}
label_ok() { printf " ${BGREEN}[ OK ]${RESET} %s\n" "$1"; }
label_warn() { printf " ${BYELLOW}[ WARN ]${RESET} %s\n" "$1"; }
label_error() { printf " ${BRED}[ ERROR]${RESET} %s\n" "$1"; }
label_info() { printf " ${BCYAN}[ INFO ]${RESET} %s\n" "$1"; }
label_skip() { printf " ${DIM}[ SKIP ]${RESET} %s\n" "$1"; }
label_simple() { printf " ${DIM}[SIMPLE]${RESET} %s\n" "$1"; }
label_connected() { printf " ${BGREEN}[CONNECTED ]${RESET} %s\n" "$1"; }
label_disconnect() { printf " ${BRED}[DISCONNECTED]${RESET} %s\n" "$1"; }
label_step() { printf " ${BBLUE}[ %-2s/%-2s ]${RESET} %s\n" "$2" "$3" "$1"; }
progress_bar() {
local current=$1
local total=$2
local label="${3:-}"
local width=40
local filled=$(( current * width / total ))
local empty=$(( width - filled ))
local pct=$(( current * 100 / total ))
local bar_filled bar_empty
printf -v bar_filled '%*s' "$filled" ''
printf -v bar_empty '%*s' "$empty" ''
bar_filled="${bar_filled// /#}"
bar_empty="${bar_empty// /·}"
printf "\r ${DIM}[${RESET}${BGREEN}${bar_filled}${RESET}${DIM}${bar_empty}${RESET}${DIM}]${RESET} ${BWHITE}%3d%%${RESET} ${DIM}%s${RESET} " \
"$pct" "$label"
}
progress_simulate() {
local steps="${1:-20}"
local label="${2:-loading}"
for i in $(seq 1 "$steps"); do
progress_bar "$i" "$steps" "$label"
sleep 0.04
done
echo ""
}
SPINNER_PID=""
start_spinner() {
local msg="${1:-Processing...}"
local frames=('─' '\\' '│' '/')
(
local i=0
while true; do
printf "\r ${BCYAN}${frames[$i]}${RESET} ${DIM}%s${RESET} " "$msg"
i=$(( (i + 1) % 4 ))
sleep 0.1
done
) &
SPINNER_PID=$!
disown "$SPINNER_PID" 2>/dev/null
}
stop_spinner() {
local result_msg="${1:-}"
if [ -n "$SPINNER_PID" ]; then
kill "$SPINNER_PID" 2>/dev/null
wait "$SPINNER_PID" 2>/dev/null
SPINNER_PID=""
fi
printf "\r%*s\r" 60 ""
[ -n "$result_msg" ] && label_ok "$result_msg"
}

31
requirements.txt Normal file
View file

@ -0,0 +1,31 @@
python-dotenv
cryptography~=46.0.3
dataclasses-json~=0.6.7
marshmallow~=3.26.1
psutil~=7.1.3
pysocks~=1.7.1
python-dateutil~=2.9.0.post0
requests~=2.32.5
annotated-types==0.7.0
certifi==2026.4.22
charset-normalizer==3.4.7
click==8.3.3
cytoolz==1.1.0
eth-hash==0.8.0
eth-typing==6.0.0
eth-utils==6.0.0
idna==3.13
packaging==26.2
pathspec==1.1.1
platformdirs==4.9.6
py-ecc==8.0.0
pydantic==2.13.3
pydantic_core==2.46.3
pydeps==3.0.6
pytokens==0.4.1
stdlib-list==0.12.0
toolz==1.1.0
typing-inspect==0.9.0
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.3