#!/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 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}" }