sp-hydra-veil-core/lib/steps.sh
2026-05-26 18:29:24 -05:00

536 lines
19 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"
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
# ─── Helper: crear log file con permisos del usuario ─────────────────────────
# El wrapper corre como root y appendea con >>. Si root crea el archivo primero,
# Python (que corre como usuario) no puede leerlo. Esta función lo crea con
# permisos del usuario ANTES de que el wrapper lo toque por primera vez.
_ensure_log_file() {
local log_file="$1"
local log_dir
log_dir="$(dirname "$log_file")"
# Crear directorio si no existe (permisos de usuario)
if [ ! -d "$log_dir" ]; then
mkdir -p "$log_dir"
chmod 700 "$log_dir"
fi
# Crear el archivo solo si no existe — nunca sobreescribir
if [ ! -f "$log_file" ]; then
touch "$log_file"
chmod 600 "$log_file"
fi
# Verificar que el usuario actual puede leerlo
if [ ! -r "$log_file" ]; then
log_warn "Log file existe pero no es legible por el usuario actual: $log_file"
log_warn "Intentando corregir permisos..."
chmod 600 "$log_file" 2>/dev/null || {
log_error "No se pudieron corregir los permisos de: $log_file"
return 1
}
fi
}
# ─── 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
# sudo hydraveil-singbox "" log
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" && "\$ACTION" != "log" ]]; then
echo "Error: acción inválida '\$ACTION'. Uso: start|stop|log" >&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"
}
# ── log ───────────────────────────────────────────────────────────────────────
if [[ "\$ACTION" == "log" ]]; then
[ -f "\$LOG_FILE" ] && tail -80 "\$LOG_FILE" || echo "Sin logs disponibles"
exit 0
fi
# ── 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 — solo mkdir/chmod, nunca tocar el log file existente
mkdir -p "\$(dirname "\$PID_FILE")" && chmod 700 "\$(dirname "\$PID_FILE")"
mkdir -p "\$(dirname "\$LOG_FILE")" && chmod 700 "\$(dirname "\$LOG_FILE")"
# NOTA: el log file fue creado por el installer con permisos del usuario.
# El wrapper solo appendea (>>) — nunca lo recrea ni cambia sus permisos.
# 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
# ── Crear log file con permisos del usuario ANTES de que root lo toque ────
# Crítico: si root crea el archivo primero (via wrapper), Python no puede
# leerlo. _ensure_log_file() lo crea como usuario con chmod 600 ahora.
_ensure_log_file "$SINGBOX_LOG_FILE"
log_ok "Log file preparado con permisos de usuario → ${SINGBOX_LOG_FILE}"
}