From 86621021bcbeba2fa967935b0d9386014a08b78b Mon Sep 17 00:00:00 2001 From: zenaku Date: Tue, 26 May 2026 16:31:16 -0500 Subject: [PATCH] add installer --- .env | 11 +- .env.example | 6 + README.md | 56 +- core/Constants.py | 9 +- .../services/encrypted_proxy/vless_service.py | 16 +- core/utils/encrypted_proxy/singbox.py | 150 +++++- install.sh | 235 +++------ lib/input.sh | 32 ++ lib/log.sh | 52 ++ lib/network.sh | 53 ++ lib/steps.sh | 489 ++++++++++++++++++ lib/ui.sh | 114 ++++ requirements.txt | 31 ++ 13 files changed, 1035 insertions(+), 219 deletions(-) create mode 100644 .env.example mode change 100644 => 100755 install.sh create mode 100755 lib/input.sh create mode 100755 lib/log.sh create mode 100755 lib/network.sh create mode 100755 lib/steps.sh create mode 100755 lib/ui.sh create mode 100644 requirements.txt diff --git a/.env b/.env index 904689b..58ed198 100644 --- a/.env +++ b/.env @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d135e89 --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 3eb0336..7a0250f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/core/Constants.py b/core/Constants.py index f12f6cc..8c2d82e 100644 --- a/core/Constants.py +++ b/core/Constants.py @@ -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' diff --git a/core/services/encrypted_proxy/vless_service.py b/core/services/encrypted_proxy/vless_service.py index d87eb9a..03cbc40 100644 --- a/core/services/encrypted_proxy/vless_service.py +++ b/core/services/encrypted_proxy/vless_service.py @@ -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, diff --git a/core/utils/encrypted_proxy/singbox.py b/core/utils/encrypted_proxy/singbox.py index 5950b41..1969c17 100644 --- a/core/utils/encrypted_proxy/singbox.py +++ b/core/utils/encrypted_proxy/singbox.py @@ -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) \ No newline at end of file + + 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) \ No newline at end of file diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 399b90b..1bd0de5 --- a/install.sh +++ b/install.sh @@ -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" \ No newline at end of file +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 start${RESET}\n" +echo "" +log_file_path +echo "" +print_divider +echo "" \ No newline at end of file diff --git a/lib/input.sh b/lib/input.sh new file mode 100755 index 0000000..bd80153 --- /dev/null +++ b/lib/input.sh @@ -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 +} diff --git a/lib/log.sh b/lib/log.sh new file mode 100755 index 0000000..6609658 --- /dev/null +++ b/lib/log.sh @@ -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" +} diff --git a/lib/network.sh b/lib/network.sh new file mode 100755 index 0000000..776ca4f --- /dev/null +++ b/lib/network.sh @@ -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 +} diff --git a/lib/steps.sh b/lib/steps.sh new file mode 100755 index 0000000..0d91ce2 --- /dev/null +++ b/lib/steps.sh @@ -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 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 +} \ No newline at end of file diff --git a/lib/ui.sh b/lib/ui.sh new file mode 100755 index 0000000..bb1ed54 --- /dev/null +++ b/lib/ui.sh @@ -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" +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..01a6b02 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file