536 lines
19 KiB
Bash
Executable file
536 lines
19 KiB
Bash
Executable file
#!/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}"
|
||
}
|