From 6c344139db938a61a79124054d3e93590196ff2e Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Mon, 16 Mar 2026 15:31:54 -0400 Subject: [PATCH] working concept --- README.md | 8 +- ansible.cfg | 6 + ansible_collections/anisail/anisail/README.md | 2 +- .../anisail/plugins/connection/holesail.py | 338 +++++-- docs/CONNECTION-PLUGIN.md | 28 +- docs/INSTALLATION.md | 20 +- docs/INSTALLERS.md | 4 +- docs/TROUBLESHOOTING.md | 8 +- docs/TUNNEL.md | 4 +- docs/USAGE.md | 2 +- inventory.yml | 19 + package-lock.json | 941 ++++++++++++++++++ playbook.yml | 16 + requirements.txt | 2 +- scripts/install.ps1 | 16 +- scripts/install.sh | 20 +- scripts/test-tunnel-ssh.sh | 52 + tunnel/index.mjs | 50 +- 18 files changed, 1409 insertions(+), 127 deletions(-) create mode 100644 ansible.cfg create mode 100644 inventory.yml create mode 100644 playbook.yml create mode 100755 scripts/test-tunnel-ssh.sh diff --git a/README.md b/README.md index cae87c5..862fbd1 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ Ansible connection plugin that uses [Holesail](https://github.com/holesail/holes - **In inventory**: Set `ansible_connection` and `ansible_holesail_key`. - Ansible starts a local tunnel, connects SSH through it, and runs your playbooks as usual. -Requires the **anisail-tunnel** binary (Bare-built, shipped in `releases/` or built via `npm run build:tunnel`) and **paramiko** on the control node. +Requires the **anisail-tunnel** binary (Bare-built, shipped in `releases/` or built via `npm run build:tunnel`) and **asyncssh** on the control node. ## Installation ### 0. One-command install (recommended) -To install **anisail-tunnel**, the **Ansible collection**, and **paramiko** from the [latest release](https://git.ssh.surf/snxraven/anisail/releases/tag/latest-main): +To install **anisail-tunnel**, the **Ansible collection**, and **asyncssh** from the [latest release](https://git.ssh.surf/snxraven/anisail/releases/tag/latest-main): **macOS / Linux:** @@ -26,7 +26,7 @@ curl -fsSL https://git.ssh.surf/snxraven/anisail/raw/branch/main/scripts/install irm https://git.ssh.surf/snxraven/anisail/raw/branch/main/scripts/install.ps1 | iex ``` -The script installs the tunnel binary (to `~/.anisail/bin` or `%LOCALAPPDATA%\anisail\bin` on Windows), installs **paramiko** if missing, and installs the **anisail.anisail** collection from the repo archive. See [docs/INSTALLATION.md](docs/INSTALLATION.md) for details, custom install paths, and build-from-source. +The script installs the tunnel binary (to `~/.anisail/bin` or `%LOCALAPPDATA%\anisail\bin` on Windows), installs **asyncssh** if missing, and installs the **anisail.anisail** collection from the repo archive. See [docs/INSTALLATION.md](docs/INSTALLATION.md) for details, custom install paths, and build-from-source. ### 1. Install the collection manually (if needed) @@ -71,7 +71,7 @@ Install the binary somewhere on `PATH` and name it `anisail-tunnel`. The plugin ### 3. Install Paramiko (if not installed by the script) ```bash -pip install paramiko +pip install asyncssh ``` ## Inventory diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..9344cdb --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,6 @@ +[defaults] +# Use the collection from this repo (path must contain ansible_collections/namespace/name) +collections_path = ./ansible_collections +inventory = inventory.yml +# Use discovered Python on the host without warning about future interpreter changes +interpreter_python = auto_silent diff --git a/ansible_collections/anisail/anisail/README.md b/ansible_collections/anisail/anisail/README.md index bc0182b..285ee2a 100644 --- a/ansible_collections/anisail/anisail/README.md +++ b/ansible_collections/anisail/anisail/README.md @@ -5,7 +5,7 @@ Ansible collection that provides the **holesail** connection plugin for reaching ## Connection plugin: holesail - **Connection type**: `anisail.anisail.holesail` (or `holesail` when the collection is in use) -- **Requires**: `ansible_holesail_key` (Holesail URL, e.g. `hs://s000...`), anisail-tunnel binary, and the `paramiko` Python library. +- **Requires**: `ansible_holesail_key` (Holesail URL, e.g. `hs://s000...`), anisail-tunnel binary, and the `asyncssh` Python library. See the [project README](https://github.com/anisail/anisail) at the repo root for full installation, inventory, and playbook examples. diff --git a/ansible_collections/anisail/anisail/plugins/connection/holesail.py b/ansible_collections/anisail/anisail/plugins/connection/holesail.py index 67cfb27..70a70d6 100644 --- a/ansible_collections/anisail/anisail/plugins/connection/holesail.py +++ b/ansible_collections/anisail/anisail/plugins/connection/holesail.py @@ -8,18 +8,25 @@ Spawns anisail-tunnel binary, reads local port from stdout, then connects SSH ov from __future__ import annotations +import asyncio +import io import json import os +import random +import socket import subprocess +import sys +import threading +import time from ansible.errors import AnsibleConnectionFailure -from ansible.plugins.connection import ConnectionBase +from ansible.plugins.connection import ConnectionBase, ensure_connect try: - import paramiko - HAS_PARAMIKO = True + import asyncssh + HAS_ASYNCSSH = True except ImportError: - HAS_PARAMIKO = False + HAS_ASYNCSSH = False DOCUMENTATION = """ author: Anisail @@ -66,8 +73,9 @@ options: - name: ansible_user - name: ansible_ssh_user private_key_file: - description: Path to SSH private key. + description: Path to SSH private key (optional). If not set, system default keys are used (e.g. ~/.ssh/id_ed25519, ~/.ssh/id_rsa). When set, this key is tried first, then system defaults. type: string + default: '' vars: - name: ansible_private_key_file - name: ansible_ssh_private_key_file @@ -80,6 +88,70 @@ options: """ +def _allocate_free_port( + min_port: int = 19200, + max_port: int = 65535, + host: str = "127.0.0.1", + max_tries: int = 200, +) -> int: + """ + Return a port number that is currently free to bind on `host`. + Tries random ports in [min_port, max_port]; never uses port 0. + Raises AnsibleConnectionFailure if no free port is found after max_tries. + """ + if min_port <= 0 or max_port <= 0 or min_port > max_port: + raise AnsibleConnectionFailure( + "Port allocator: min_port and max_port must be positive and min_port <= max_port" + ) + for _ in range(max_tries): + port = random.randint(min_port, max_port) + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((host, port)) + # Bind succeeded, so the port was free; we release it on close + return port + except OSError: + continue + raise AnsibleConnectionFailure( + f"Port allocator: could not find a free port in [{min_port}, {max_port}] after {max_tries} tries" + ) + + +# Default SSH key paths (in order of preference) when no key is set by the playbook. +_SSH_DEFAULT_KEY_PATHS = ( + '~/.ssh/id_ed25519', + '~/.ssh/id_rsa', + '~/.ssh/id_ecdsa', +) + + +def _resolve_client_keys(option_key_path: str | None) -> list[str] | None: + """ + Build the list of SSH client key paths: playbook-defined key first (if set and exists), + then system default keys that exist. Returns None if empty so asyncssh can use its defaults. + """ + paths = [] + seen = set() + if option_key_path: + expanded = os.path.expanduser(option_key_path.strip()) + if expanded and os.path.isfile(expanded): + paths.append(expanded) + seen.add(os.path.realpath(expanded)) + for default in _SSH_DEFAULT_KEY_PATHS: + expanded = os.path.expanduser(default) + if not expanded or not os.path.isfile(expanded): + continue + try: + canonical = os.path.realpath(expanded) + except OSError: + canonical = expanded + if canonical not in seen: + paths.append(expanded) + seen.add(canonical) + return paths if paths else None + + def _find_tunnel_binary(plugin_path_option, collection_root: str | None) -> str: """Resolve anisail-tunnel binary path.""" if plugin_path_option and os.path.isabs(plugin_path_option) and os.path.isfile(plugin_path_option): @@ -115,30 +187,64 @@ def _find_tunnel_binary(plugin_path_option, collection_root: str | None) -> str: class Connection(ConnectionBase): - """Ansible connection over Holesail tunnel + SSH (Paramiko).""" + """Ansible connection over Holesail tunnel + SSH (asyncssh).""" transport = 'holesail' has_pipelining = False + allow_extras = True + extras_prefix = 'holesail' def __init__(self, play_context, new_stdin, *args, **kwargs): super().__init__(play_context, new_stdin, *args, **kwargs) self._tunnel_process: subprocess.Popen | None = None self._local_port: int | None = None - self._paramiko_client: "paramiko.SSHClient | None" = None + self._ssh_conn = None + self._loop: asyncio.AbstractEventLoop | None = None + self._loop_thread: threading.Thread | None = None + self._conn_ready = threading.Event() + self._connect_error: BaseException | None = None # set by keeper() on async connect failure + self._keeper_task: asyncio.Task | None = None # background task holding the SSH connection + self._holesail_key_from_vars: str | None = None + + def set_options(self, task_keys=None, var_options=None, direct=None): + """Capture ansible_holesail_key from var_options (main or _extras) for use in _connect.""" + super().set_options(task_keys=task_keys, var_options=var_options, direct=direct) + key = (self._options.get('key') or '').strip() + if not key and var_options: + key = (var_options.get('ansible_holesail_key') or '').strip() + if not key and isinstance(var_options.get('_extras'), dict): + key = (var_options['_extras'].get('ansible_holesail_key') or '').strip() + if key: + self._holesail_key_from_vars = key + self._options['key'] = key def _connect(self) -> None: - if not HAS_PARAMIKO: + if not HAS_ASYNCSSH: raise AnsibleConnectionFailure( - 'The paramiko Python library is required for the holesail connection plugin. ' - 'Install it with: pip install paramiko' + 'The asyncssh Python library is required for the holesail connection plugin. ' + 'Install it with: pip install asyncssh' ) - key = self.get_option('key') + key = (self.get_option('key') or '').strip() + if not key and self.has_option('ansible_holesail_key'): + key = (self.get_option('ansible_holesail_key') or '').strip() + if not key: + key = (getattr(self, '_holesail_key_from_vars', None) or '').strip() + if not key: + key = ((getattr(self, '_options', {}).get('_extras') or {}).get('ansible_holesail_key') or '').strip() + if not key: + key = (os.environ.get('ANSIBLE_HOLESAIL_KEY') or '').strip() if not key: raise AnsibleConnectionFailure( 'ansible_holesail_key is required for the holesail connection. ' 'Set it in inventory (e.g. ansible_holesail_key: "hs://s000...").' ) - remote_port = self.get_option('port') or 22 + # Port on the target that Holesail exposes (e.g. 22 for SSH). Must match what the target runs: holesail --live . + remote_port = self.get_option('port') + if remote_port is None or remote_port == 0: + remote_port = getattr(self._play_context, 'port', None) + if remote_port is None or remote_port == 0: + remote_port = 22 + remote_port = int(remote_port) tunnel_path_opt = self.get_option('tunnel_path') or '' ready_timeout_sec = self.get_option('ready_timeout') or 30 timeout_ms = ready_timeout_sec * 1000 @@ -172,16 +278,20 @@ class Connection(ConnectionBase): 'in the collection bin/ or releases// directory, or have anisail-tunnel in PATH.' ) + local_port = _allocate_free_port() + self._local_port = local_port + argv = [ binary, '--key', key, '--remote-port', str(remote_port), + '--local-port', str(local_port), '--timeout', str(timeout_ms), ] try: self._tunnel_process = subprocess.Popen( argv, - stdin=subprocess.DEVNULL, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -215,44 +325,88 @@ class Connection(ConnectionBase): self._terminate_tunnel() raise AnsibleConnectionFailure(f'Invalid tunnel output (expected JSON with local_port): {e}') from e - local_port = data.get('local_port') - if local_port is None: + reported = data.get('local_port') + if reported is not None and int(reported) != self._local_port: self._terminate_tunnel() - raise AnsibleConnectionFailure('Tunnel output missing local_port') + raise AnsibleConnectionFailure( + f'Tunnel reported local_port {reported} but plugin allocated {self._local_port}' + ) - self._local_port = int(local_port) + # Wait for the tunnel port to accept TCP connections + port_deadline = time.monotonic() + ready_timeout_sec + while time.monotonic() < port_deadline: + try: + with socket.create_connection(('127.0.0.1', self._local_port), timeout=3) as sock: + pass + break + except (OSError, socket.error): + time.sleep(2) + else: + self._terminate_tunnel() + raise AnsibleConnectionFailure( + f'Tunnel port {self._local_port} did not accept connections within {ready_timeout_sec}s' + ) username = self.get_option('remote_user') or self._play_context.remote_user or 'root' - pkey = None - key_file = self.get_option('private_key_file') - if key_file and os.path.isfile(key_file): - try: - pkey = paramiko.RSAKey.from_private_key_file(key_file) - except paramiko.ssh_exception.SSHException: - try: - pkey = paramiko.Ed25519Key.from_private_key_file(key_file) - except paramiko.ssh_exception.SSHException: - pass + key_file_opt = self.get_option('private_key_file') or '' + client_keys = _resolve_client_keys(key_file_opt.strip() or None) + agent_path = os.environ.get('SSH_AUTH_SOCK') or None password = self.get_option('password') or self._play_context.password or None - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - client.connect( - '127.0.0.1', - port=self._local_port, - username=username, - pkey=pkey, - password=password, - timeout=30, - banner_timeout=30, - auth_timeout=30, - ) - except Exception as e: - self._terminate_tunnel() - raise AnsibleConnectionFailure(f'SSH connection to tunnel port failed: {e}') from e + connect_kw = { + 'host': '127.0.0.1', + 'port': self._local_port, + 'username': username, + 'client_keys': client_keys, + 'password': password, + 'known_hosts': None, + } + if agent_path and os.path.exists(agent_path): + connect_kw['agent_path'] = agent_path - self._paramiko_client = client + def run_loop(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + + async def keeper(): + try: + conn = await asyncssh.connect(**connect_kw) + self._ssh_conn = conn + except BaseException as e: + self._connect_error = e + finally: + self._conn_ready.set() + if self._ssh_conn is not None: + try: + await asyncio.Future() + except asyncio.CancelledError: + pass + + self._keeper_task = loop.create_task(keeper()) + loop.run_forever() + loop.close() + + self._conn_ready.clear() + self._connect_error = None + self._loop_thread = threading.Thread(target=run_loop, daemon=True) + self._loop_thread.start() + if not self._conn_ready.wait(timeout=30): + if self._loop: + self._loop.call_soon_threadsafe(self._loop.stop) + self._terminate_tunnel() + raise AnsibleConnectionFailure('SSH connection to tunnel port failed (timeout)') + if self._connect_error is not None: + err = self._connect_error + self._connect_error = None + if self._loop: + self._loop.call_soon_threadsafe(self._loop.stop) + self._terminate_tunnel() + if isinstance(err, AnsibleConnectionFailure): + raise err + raise AnsibleConnectionFailure(f'SSH connection failed: {err}') from err + + self._connected = True def _terminate_tunnel(self) -> None: if self._tunnel_process is None: @@ -268,49 +422,99 @@ class Connection(ConnectionBase): pass self._tunnel_process = None - def exec_command(self, cmd, in_data=None, sudoable=True): - if self._paramiko_client is None: + def _run_async(self, coro, timeout=60): + if self._loop is None or self._ssh_conn is None: raise AnsibleConnectionFailure('Not connected') + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + return future.result(timeout=timeout) + + @ensure_connect + def exec_command(self, cmd, in_data=None, sudoable=True): + if self._ssh_conn is None: + raise AnsibleConnectionFailure('Not connected') + + def _to_bytes(val): + if val is None: + return b'' + return val.encode('utf-8') if isinstance(val, str) else val + + async def _run(): + result = await self._ssh_conn.run( + cmd, + stdin=in_data if in_data else '', + timeout=60, + ) + return ( + result.exit_status if result.exit_status is not None else 0, + _to_bytes(result.stdout), + _to_bytes(result.stderr), + ) + try: - stdin, stdout, stderr = self._paramiko_client.exec_command(cmd) - if in_data: - stdin.write(in_data) - stdin.channel.shutdown_write() - rc = stdout.channel.recv_exit_status() - return rc, stdout.read(), stderr.read() + return self._run_async(_run(), timeout=90) except Exception as e: raise AnsibleConnectionFailure(f'exec_command failed: {e}') from e + @ensure_connect def put_file(self, in_path, out_path): - if self._paramiko_client is None: + if self._ssh_conn is None: raise AnsibleConnectionFailure('Not connected') + + async def _put(): + async with self._ssh_conn.start_sftp_client() as sftp: + await sftp.put(in_path, out_path) + try: - sftp = self._paramiko_client.open_sftp() - try: - sftp.put(in_path, out_path) - finally: - sftp.close() + self._run_async(_put()) except Exception as e: raise AnsibleConnectionFailure(f'put_file failed: {e}') from e + @ensure_connect def get_file(self, in_path, out_path): - if self._paramiko_client is None: + if self._ssh_conn is None: raise AnsibleConnectionFailure('Not connected') + + async def _get(): + async with self._ssh_conn.start_sftp_client() as sftp: + await sftp.get(in_path, out_path) + try: - sftp = self._paramiko_client.open_sftp() - try: - sftp.get(in_path, out_path) - finally: - sftp.close() + self._run_async(_get()) except Exception as e: raise AnsibleConnectionFailure(f'get_file failed: {e}') from e + @ensure_connect + def fetch_file(self, in_path, out_path): + """Fetch a file from remote to local (required by ConnectionBase).""" + return self.get_file(in_path, out_path) + def close(self): - if self._paramiko_client: + if self._ssh_conn and self._loop: try: - self._paramiko_client.close() + self._run_async(self._ssh_conn.close(), timeout=5) except Exception: pass - self._paramiko_client = None + self._ssh_conn = None + if self._keeper_task is not None: + self._loop.call_soon_threadsafe(self._keeper_task.cancel) + self._keeper_task = None + self._loop.call_soon_threadsafe(self._loop.stop) + # Clearing _loop and _keeper_task can trigger asyncio's "Task was destroyed but it is pending!" + # when the process has forked (e.g. Ansible worker). Suppress stderr during gc to avoid that. + try: + _stderr_save = sys.stderr + sys.stderr = io.StringIO() + try: + self._loop = None + self._keeper_task = None + if self._loop_thread and self._loop_thread.is_alive(): + self._loop_thread.join(timeout=5) + self._loop_thread = None + finally: + sys.stderr = _stderr_save + except Exception: + self._loop = None + self._keeper_task = None + self._loop_thread = None self._terminate_tunnel() super().close() diff --git a/docs/CONNECTION-PLUGIN.md b/docs/CONNECTION-PLUGIN.md index 8f7ce88..436e4e8 100644 --- a/docs/CONNECTION-PLUGIN.md +++ b/docs/CONNECTION-PLUGIN.md @@ -1,14 +1,14 @@ # Connection plugin reference -The **holesail** connection plugin is an Ansible connection plugin that spawns the anisail-tunnel binary, reads the local tunnel port from its stdout, and delegates all connection operations to SSH over that port using Paramiko. +The **holesail** connection plugin is an Ansible connection plugin that spawns the anisail-tunnel binary, reads the local tunnel port from its stdout, and delegates all connection operations to SSH over that port using asyncssh. ## Overview - **Transport:** `holesail` -- **Requires:** Python **paramiko** (`pip install paramiko`) +- **Requires:** Python **asyncssh** (`pip install asyncssh`) - **Implements:** `ConnectionBase` — `_connect()`, `exec_command()`, `put_file()`, `get_file()`, `close()` -The plugin does not implement its own SSH protocol; it starts the tunnel, gets a port, then uses Paramiko to connect to `127.0.0.1:` and forwards all command and file operations to that SSH session. +The plugin does not implement its own SSH protocol; it starts the tunnel, gets a port, then uses asyncssh to connect to `127.0.0.1:` and forwards all command and file operations to that SSH session. ## Options @@ -21,32 +21,32 @@ Options are configured via inventory (or group_vars/host_vars). The plugin reads | `tunnel_path` | `ansible_holesail_tunnel_path` | string | `''` | Path to the anisail-tunnel binary. If unset, the plugin resolves the binary automatically (see Binary resolution). | | `ready_timeout` | `ansible_holesail_ready_timeout` | int | 30 | Seconds to wait for the tunnel to print the local port. Converted to milliseconds and passed as `--timeout` to the tunnel. | | `host` | — | string | — | Unused; the tunnel key defines the target. | -| `remote_user` | `ansible_user`, `ansible_ssh_user` | string | play context | SSH username for the Paramiko connection to the tunnel port. | -| `private_key_file` | `ansible_private_key_file`, `ansible_ssh_private_key_file` | string | — | Path to SSH private key for Paramiko. | +| `remote_user` | `ansible_user`, `ansible_ssh_user` | string | play context | SSH username for the asyncssh connection to the tunnel port. | +| `private_key_file` | `ansible_private_key_file`, `ansible_ssh_private_key_file` | string | `''` | Optional. Path to SSH private key; if set, tried first. If unset, system default keys are used (`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`, `~/.ssh/id_ecdsa`). SSH agent (`SSH_AUTH_SOCK`) is also used when available. | | `password` | `ansible_password`, `ansible_ssh_pass` | string | — | SSH password if not using a key. | ## _connect() flow -1. **Paramiko check** — If paramiko is not importable, raise `AnsibleConnectionFailure` with instructions to install it. +1. **asyncssh check** — If asyncssh is not importable, raise `AnsibleConnectionFailure` with instructions to install it. 2. **Key** — Read `key` (ansible_holesail_key). If missing, raise with a message to set it in inventory. 3. **Options** — Read `port` (default 22), `tunnel_path`, `ready_timeout` (default 30). Compute timeout in ms for the tunnel. 4. **Collection root** — Resolve the collection root (from `AnsibleCollectionConfig.collection_paths` or from `__file__` of the plugin). Used for binary resolution. 5. **Binary** — Call `_find_tunnel_binary(tunnel_path, collection_root)`. If the result is empty, raise that anisail-tunnel was not found. 6. **Argv** — Build `[binary, '--key', key, '--remote-port', str(port), '--timeout', str(timeout_ms)]`. -7. **Spawn** — `subprocess.Popen(argv, stdin=DEVNULL, stdout=PIPE, stderr=PIPE)`. Store the process handle. +7. **Spawn** — `subprocess.Popen(argv, stdin=PIPE, stdout=PIPE, stderr=PIPE)`. Store the process handle. 8. **Read port** — Read one line from the process stdout. If the process exits or no line is read, terminate the process and raise (with stderr if available). 9. **Parse** — Decode the line as UTF-8, parse as JSON. Expect a dict with `local_port`. If invalid or missing `local_port`, terminate the process and raise. -10. **SSH** — Create a Paramiko `SSHClient`, set `AutoAddPolicy`, connect to `127.0.0.1` with port=`local_port`, username, pkey (from private_key_file), password. Store the client. On failure, terminate the tunnel and raise. +10. **SSH** — Start a background thread with an asyncio event loop; in that loop run `asyncssh.connect('127.0.0.1', port=local_port, username, client_keys, password, known_hosts=None)`. Store the connection and loop; use `run_coroutine_threadsafe()` from the main thread for exec_command/put_file/get_file. On failure, terminate the tunnel and raise. ## exec_command / put_file / get_file -- **exec_command(cmd, in_data=None, sudoable=True)** — `self._paramiko_client.exec_command(cmd)`. If `in_data` is set, write it to stdin and shut down the write side. Return (rc, stdout.read(), stderr.read()). Raise `AnsibleConnectionFailure` on exception. -- **put_file(in_path, out_path)** — Open SFTP from the Paramiko client, `put(in_path, out_path)`, close SFTP. Raise on error. -- **get_file(in_path, out_path)** — Open SFTP, `get(in_path, out_path)`, close SFTP. Raise on error. +- **exec_command(cmd, in_data=None, sudoable=True)** — On the async loop, run `conn.run(cmd, stdin=in_data)`. Return (exit_status, stdout, stderr) as bytes. Raise `AnsibleConnectionFailure` on exception. +- **put_file(in_path, out_path)** — On the async loop, open SFTP with `conn.start_sftp_client()`, `await sftp.put(in_path, out_path)`, close. Raise on error. +- **get_file(in_path, out_path)** — On the async loop, open SFTP, `await sftp.get(in_path, out_path)`, close. Raise on error. ## close() -1. If `_paramiko_client` is set, call `close()` on it and set to `None`. +1. If the asyncssh connection is set, run `conn.close()` on the loop, then stop the loop; join the loop thread. 2. Call `_terminate_tunnel()`: if `_tunnel_process` is set, `terminate()`, then `wait(timeout=5)`; on `TimeoutExpired`, `kill()` and `wait()`. Set `_tunnel_process` to `None`. 3. Call `super().close()`. @@ -67,12 +67,12 @@ So the plugin will use: explicit path → collection `bin/` → collection `rele | Situation | Plugin behavior | |-----------|------------------| -| Paramiko not installed | `AnsibleConnectionFailure` with message to run `pip install paramiko`. | +| asyncssh not installed | `AnsibleConnectionFailure` with message to run `pip install asyncssh`. | | ansible_holesail_key not set | `AnsibleConnectionFailure` asking to set it in inventory. | | Binary not found | `AnsibleConnectionFailure` suggesting ansible_holesail_tunnel_path, collection bin/releases, or PATH. | | Tunnel process fails to start | `AnsibleConnectionFailure` with the OSError message. | | No line from tunnel / process exited | Terminate tunnel, raise with stderr if available. | | Invalid JSON or missing local_port | Terminate tunnel, raise. | -| Paramiko connect fails | Terminate tunnel, raise with the connection error. | +| asyncssh connect fails | Terminate tunnel, raise with the connection error. | For user-facing fixes, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md). diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 9b835f5..09ae5c2 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -6,12 +6,12 @@ This guide covers installing the **anisail-tunnel** binary (used by the Ansible ## Quick install (one command) -Use the install script to set up **anisail-tunnel**, the **Ansible collection**, and **paramiko** from the [latest release](https://git.ssh.surf/snxraven/anisail/releases/tag/latest-main). +Use the install script to set up **anisail-tunnel**, the **Ansible collection**, and **asyncssh** from the [latest release](https://git.ssh.surf/snxraven/anisail/releases/tag/latest-main). The script: 1. Downloads and installs the tunnel binary for your platform. -2. Installs **paramiko** with `pip` if it is not already installed. +2. Installs **asyncssh** with `pip` if it is not already installed. 3. Installs the **anisail.anisail** collection from the repo archive. ### macOS / Linux @@ -71,7 +71,7 @@ irm https://git.ssh.surf/snxraven/anisail/raw/branch/main/scripts/install.ps1 | 5. Extracts the binary (handles zip layout with or without a subdir). 6. Sets the binary to `~/.anisail/bin/anisail-tunnel` and makes it executable. 7. If `~/.local/bin` exists, creates a symlink there so the binary is on PATH for many setups. -8. **Paramiko:** runs `python3 -c "import paramiko"`; if missing, runs `pip3 install paramiko` (or `pip install paramiko`). +8. **asyncssh:** runs `python3 -c "import asyncssh"`; if missing, runs `pip3 install asyncssh` (or `pip install asyncssh`). 9. **Collection:** if `ansible-galaxy` is available, downloads the repo archive (from the same host as the release), extracts it, and runs `ansible-galaxy collection install --force`. **Requirements:** `curl`, `unzip`. For collection install, `ansible-galaxy` must be on PATH. @@ -82,18 +82,18 @@ irm https://git.ssh.surf/snxraven/anisail/raw/branch/main/scripts/install.ps1 | 2. Extracts to `%LOCALAPPDATA%\anisail\bin` (or `ANISAIL_INSTALL_DIR`). 3. Ensures `anisail-tunnel.exe` is at the install dir root. 4. Suggests adding the install dir to the user PATH. -5. **Paramiko:** checks for `import paramiko`; if missing, runs `pip install paramiko` (or `pip3`). +5. **asyncssh:** checks for `import asyncssh`; if missing, runs `pip install asyncssh` (or `pip3`). 6. **Collection:** if `ansible-galaxy` is available, downloads the repo archive and installs the collection from the extracted path (same as Unix). **Requirements:** PowerShell, internet access. No extra tools for the binary (uses `Invoke-WebRequest` and `Expand-Archive`). --- -## Install the Ansible collection and paramiko (manual) +## Install the Ansible collection and asyncssh (manual) -If you did not use the install script, or it could not install the collection or paramiko, install them manually. +If you did not use the install script, or it could not install the collection or asyncssh, install them manually. -The connection plugin runs on your Ansible control node and needs the collection plus Python paramiko. **The install script installs both automatically** when you run it; this section is for manual or development setups. +The connection plugin runs on your Ansible control node and needs the collection plus Python asyncssh. **The install script installs both automatically** when you run it; this section is for manual or development setups. ### Collection @@ -109,10 +109,10 @@ Or set the collection path and use the repo as-is: export ANSIBLE_COLLECTIONS_PATHS="/path/to/anisail/ansible_collections" ``` -### Paramiko (required) +### asyncssh (required) ```bash -pip install paramiko +pip install asyncssh ``` --- @@ -148,4 +148,4 @@ Output is under `releases/` (e.g. `releases/darwin-arm64/anisail-tunnel`). Copy - macOS/Linux: `rm -rf ~/.anisail` and `rm -f ~/.local/bin/anisail-tunnel` - Windows: remove `%LOCALAPPDATA%\anisail` and remove that path from user PATH if you added it. - **Collection:** `ansible-galaxy collection remove anisail.anisail` -- **Paramiko:** `pip uninstall paramiko` (only if not needed for other playbooks) +- **asyncssh:** `pip uninstall asyncssh` (only if not needed for other playbooks) diff --git a/docs/INSTALLERS.md b/docs/INSTALLERS.md index c24deed..183b585 100644 --- a/docs/INSTALLERS.md +++ b/docs/INSTALLERS.md @@ -32,7 +32,7 @@ curl -fsSL https://git.ssh.surf/snxraven/anisail/raw/branch/main/scripts/install 7. Move the binary to the install dir root; remove empty subdirs left by the zip. 8. `chmod +x` the binary. 9. If `~/.local/bin` exists and is not the install dir, create a symlink `~/.local/bin/anisail-tunnel` → the installed binary. -10. **Paramiko:** run `python3 -c "import paramiko"`; if that fails, run `pip3 install paramiko` or `pip install paramiko`. On failure, print a warning and continue. +10. **asyncssh:** run `python3 -c "import asyncssh"`; if that fails, run `pip3 install asyncssh` or `pip install asyncssh`. On failure, print a warning and continue. 11. **Collection:** if `ansible-galaxy` is in PATH, derive repo archive URL from `RELEASE_BASE` (replace `/releases/download/...` with `/archive/branch/main.zip`), download and extract to a temp dir, find `ansible_collections/anisail/anisail` inside, and run `ansible-galaxy collection install --force`. On failure, print a warning with manual install instructions. 12. Print the binary path and next steps (PATH, inventory). @@ -73,7 +73,7 @@ irm https://git.ssh.surf/snxraven/anisail/raw/branch/main/scripts/install.ps1 | 5. Find `anisail-tunnel.exe` (recursively if the zip has a subdir). 6. Move the exe to the install dir root; remove empty subdirs. 7. Optionally suggest adding the install dir to the user PATH via `[Environment]::SetEnvironmentVariable('Path', ...)`. -8. **Paramiko:** run `python -c "import paramiko"`; if that fails, run `pip install paramiko` or `pip3 install paramiko`. On failure, print a warning and continue. +8. **asyncssh:** run `python -c "import asyncssh"`; if that fails, run `pip install asyncssh` or `pip3 install asyncssh`. On failure, print a warning and continue. 9. **Collection:** if `ansible-galaxy` is available, derive repo archive URL from `$ReleaseBase`, download and expand to a temp dir, find the directory that contains `meta\galaxy.yml` (the collection root), and run `ansible-galaxy collection install --force`. On failure, print a warning with manual install instructions. 10. Print the binary path and next steps. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f7ed32f..3732fa2 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -13,12 +13,12 @@ Common failures and how to fix them. 2. **Install via script** — Run the [install script](INSTALLATION.md#quick-install-tunnel-binary-from-release) for your OS so the binary is installed to the default directory. Then add that directory to your PATH (e.g. `export PATH="$HOME/.anisail/bin:$PATH"`) or set **ansible_holesail_tunnel_path** to the full path of the installed binary. 3. **Use the collection layout** — Place the binary (or the contents of a release zip) under the collection at `ansible_collections/anisail/anisail/releases//anisail-tunnel` (or `bin/anisail-tunnel`). The plugin will find it when the collection is loaded. -## Paramiko not found +## asyncssh not found -**Symptom:** Ansible reports that the paramiko Python library is required for the holesail connection plugin. +**Symptom:** Ansible reports that the asyncssh Python library is required for the holesail connection plugin. -**Fix:** Install paramiko on the control node: -`pip install paramiko` +**Fix:** Install asyncssh on the control node: +`pip install asyncssh` Or install from the project’s [requirements.txt](../requirements.txt): `pip install -r requirements.txt` diff --git a/docs/TUNNEL.md b/docs/TUNNEL.md index 3296adc..71ed14a 100644 --- a/docs/TUNNEL.md +++ b/docs/TUNNEL.md @@ -6,7 +6,7 @@ - Start as a Holesail **client** with a given `hs://` key. - Bind to a local address and port (default `127.0.0.1` and a high port if `--local-port 0`). -- After the Holesail connection is ready, print exactly one line to stdout: UTF-8 JSON with `local_port` (and optionally `ready: true`). +- After the Holesail connection is ready, **verify** that the bound port is accepting TCP connections (by connecting to it once from the same process); only then print exactly one line to stdout: UTF-8 JSON with `local_port` (and optionally `ready: true`). - Do not exit until stdin is closed or the process receives SIGTERM/SIGINT; then call `hs.close()` and exit. There is no interactive CLI beyond the arguments below. The plugin expects only the one line of JSON; any other output (e.g. logs) should go to stderr so the plugin’s read of stdout is unambiguous. @@ -38,7 +38,7 @@ The plugin reads one line from stdout and then uses only the port; it does not r 2. Dynamically `import('holesail')` (Bare/ESM). 3. Choose local port: if `--local-port` > 0 use it, else use 19200 + random. 4. Create `new Holesail({ client: true, key, host: '127.0.0.1', port })`, call `ready()` (with optional timeout). On error or timeout, print to stderr and exit. -5. Get bound port from `hs.info` or the port passed in; emit one JSON line to stdout. +5. Get bound port from `hs.info` or the port passed in; wait until a TCP connection to that port succeeds (retry up to ~10s); then emit one JSON line to stdout. 6. Register handlers for SIGTERM, SIGINT, stdin close/end; call `process.stdin.resume()` so the process stays alive. 7. On any of these events, call `hs.close()` and exit. diff --git a/docs/USAGE.md b/docs/USAGE.md index ca27532..42fa81f 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -112,4 +112,4 @@ Set **ansible_holesail_tunnel_path** when: - You have **multiple** installs and want to force a specific binary. - You run Ansible from **CI/automation** where PATH may not include the install directory. -If the binary is on PATH or is placed in the collection’s `bin/` or `releases//`, you can leave **ansible_holesail_tunnel_path** unset. The [install script](INSTALLATION.md#quick-install-one-command) installs the tunnel, the collection, and paramiko in one go; see [INSTALLATION.md](INSTALLATION.md) for options and [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for “binary not found” fixes. +If the binary is on PATH or is placed in the collection’s `bin/` or `releases//`, you can leave **ansible_holesail_tunnel_path** unset. The [install script](INSTALLATION.md#quick-install-one-command) installs the tunnel, the collection, and asyncssh in one go; see [INSTALLATION.md](INSTALLATION.md) for options and [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for “binary not found” fixes. diff --git a/inventory.yml b/inventory.yml new file mode 100644 index 0000000..6050def --- /dev/null +++ b/inventory.yml @@ -0,0 +1,19 @@ +# Inventory for Anisail (Holesail tunnel) +# Run from repo root with collection path set: +# export ANSIBLE_COLLECTIONS_PATHS="$PWD/ansible_collections" +# ansible-playbook -i inventory.yml playbook.yml + +all: + hosts: + dlinux: + ansible_connection: anisail.anisail.holesail + ansible_holesail_key: "hs://s000ac843ce98f2c2029b91f7a5e0b4563c3" + ansible_holesail_ready_timeout: 60 + # SSH user on the target (change if your server uses a different user) + ansible_user: root + # If the target runs SSH on a different port, set it (must match holesail --live on the target): + # ansible_port: 22 + # Use the tunnel binary we built (full path so it works without PATH) + ansible_holesail_tunnel_path: "/Users/raven/dev/new-projects/anisail/releases/anisail-tunnel" + # Optional: override SSH key (default is system keys ~/.ssh/id_ed25519, id_rsa, etc. and SSH agent): + # ansible_ssh_private_key_file: ~/.ssh/id_ed25519 diff --git a/package-lock.json b/package-lock.json index b990021..25cd44f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,123 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { + "archiver": "^7.0.1", "bare-build": "^0.4.3" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/b4a": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", @@ -27,6 +141,13 @@ } } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/bare-addon-resolve": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz", @@ -504,6 +625,192 @@ "bare-path": "^3.0.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -521,6 +828,236 @@ "dev": true, "license": "MIT" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/paparam": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/paparam/-/paparam-1.10.1.tgz", @@ -528,6 +1065,50 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/promaphore": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/promaphore/-/promaphore-1.0.0.tgz", @@ -535,6 +1116,46 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/require-addon": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", @@ -561,6 +1182,63 @@ "bare": ">=1.10.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sodium-native": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", @@ -588,6 +1266,133 @@ "text-decoder": "^1.1.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -608,12 +1413,148 @@ "b4a": "^1.6.4" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-runtime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.3.2.tgz", "integrity": "sha512-5kwCfWml7+b2NO7KrLMhYihjRx0teKkd3yGp1Xk5Vaf2JGdSh+rgVhEALAD9c/59dP+YwJHXoEO7e8QPy7gOkw==", "dev": true, "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } } } } diff --git a/playbook.yml b/playbook.yml new file mode 100644 index 0000000..8c0d8d3 --- /dev/null +++ b/playbook.yml @@ -0,0 +1,16 @@ +--- +# Simple playbook to test Anisail connection to dlinux +- name: Test connection to dlinux via Holesail + hosts: dlinux + gather_facts: true + tasks: + - name: Ping + ping: + + - name: Show hostname and OS + command: uname -a + register: uname_out + + - name: Print result + debug: + var: uname_out.stdout diff --git a/requirements.txt b/requirements.txt index e85416b..d47813f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # For the Ansible connection plugin (control node) -paramiko>=2.11.0 +asyncssh>=2.14.0 # Optional: to run playbooks # ansible-core>=2.14 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f393437..18fa6c1 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -68,18 +68,18 @@ if ($UserPath -and $UserPath -notlike "*$BinDir*") { Write-Host " Then restart your terminal." -ForegroundColor Yellow } -# ── Install paramiko if missing ───────────────────────────────────────────────── +# ── Install asyncssh if missing ───────────────────────────────────────────────── Write-Host "" -Write-Host "Checking Python dependency (paramiko)..." -python -c "import paramiko" 2>$null +Write-Host "Checking Python dependency (asyncssh)..." +python -c "import asyncssh" 2>$null if ($LASTEXITCODE -eq 0) { - Write-Host " paramiko already installed" + Write-Host " asyncssh already installed" } else { - Write-Host " Installing paramiko..." + Write-Host " Installing asyncssh..." $pipOk = $false - pip install paramiko 2>$null; if ($LASTEXITCODE -eq 0) { $pipOk = $true } - if (-not $pipOk) { pip3 install paramiko 2>$null; if ($LASTEXITCODE -eq 0) { $pipOk = $true } } - if ($pipOk) { Write-Host " Installed paramiko" } else { Write-Host " Warning: could not install paramiko. Install manually: pip install paramiko" -ForegroundColor Yellow } + pip install asyncssh 2>$null; if ($LASTEXITCODE -eq 0) { $pipOk = $true } + if (-not $pipOk) { pip3 install asyncssh 2>$null; if ($LASTEXITCODE -eq 0) { $pipOk = $true } } + if ($pipOk) { Write-Host " Installed asyncssh" } else { Write-Host " Warning: could not install asyncssh. Install manually: pip install asyncssh" -ForegroundColor Yellow } } # ── Install Ansible collection ────────────────────────────────────────────────── diff --git a/scripts/install.sh b/scripts/install.sh index 1e7b6e0..71158ef 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -94,19 +94,19 @@ if [[ -d "$LOCAL_BIN" ]] && [[ "$INSTALL_DIR" != "$LOCAL_BIN" ]]; then echo " Linked: ${LOCAL_BIN}/${BIN_NAME}" fi -# ── Install paramiko if missing ───────────────────────────────────────────────── +# ── Install asyncssh if missing ───────────────────────────────────────────────── echo "" -echo "Checking Python dependency (paramiko)..." -if python3 -c "import paramiko" 2>/dev/null; then - echo " paramiko already installed" +echo "Checking Python dependency (asyncssh)..." +if python3 -c "import asyncssh" 2>/dev/null; then + echo " asyncssh already installed" else - echo " Installing paramiko..." - if pip3 install paramiko 2>/dev/null; then - echo " Installed paramiko (pip3)" - elif pip install paramiko 2>/dev/null; then - echo " Installed paramiko (pip)" + echo " Installing asyncssh..." + if pip3 install asyncssh 2>/dev/null; then + echo " Installed asyncssh (pip3)" + elif pip install asyncssh 2>/dev/null; then + echo " Installed asyncssh (pip)" else - echo " Warning: could not install paramiko. Install manually: pip install paramiko" >&2 + echo " Warning: could not install asyncssh. Install manually: pip install asyncssh" >&2 fi fi diff --git a/scripts/test-tunnel-ssh.sh b/scripts/test-tunnel-ssh.sh new file mode 100755 index 0000000..4c7edb1 --- /dev/null +++ b/scripts/test-tunnel-ssh.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Test anisail-tunnel: run tunnel, read local_port from stdout, then SSH to it. +# Usage: ./scripts/test-tunnel-ssh.sh [hs://key] [user] +set -euo pipefail + +KEY="${1:-hs://s000ac843ce98f2c2029b91f7a5e0b4563c3}" +USER="${2:-root}" +PORT="" +BIN="$(cd "$(dirname "$0")/.." && pwd)/releases/anisail-tunnel" + +if [[ ! -x "$BIN" ]]; then + echo "Build the tunnel first: npm run build:tunnel" >&2 + exit 1 +fi + +echo "Starting tunnel with key ${KEY:0:20}..." +TMPOUT=$(mktemp) +TMPERR=$(mktemp) +trap 'rm -f "$TMPOUT" "$TMPERR"; kill "$TPID" 2>/dev/null' EXIT + +"$BIN" --key "$KEY" --timeout 60000 >"$TMPOUT" 2>"$TMPERR" & +TPID=$! + +# Wait for JSON line +for i in {1..30}; do + if [[ -s "$TMPOUT" ]]; then + LINE=$(head -1 "$TMPOUT") + if [[ "$LINE" =~ local_port\":([0-9]+) ]]; then + PORT="${BASH_REMATCH[1]}" + echo "Tunnel ready on 127.0.0.1:$PORT" + break + fi + fi + sleep 1 +done + +if [[ -z "${PORT:-}" ]]; then + echo "Tunnel did not output port. stderr:" >&2 + cat "$TMPERR" >&2 + exit 1 +fi + +# Give it a moment to accept connections +sleep 2 + +echo "Testing SSH to 127.0.0.1:$PORT (user=$USER)..." +if ssh -p "$PORT" -o ConnectTimeout=15 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$USER@127.0.0.1" "echo SSH_OK"; then + echo "SSH test passed." +else + echo "SSH test failed." >&2 + exit 1 +fi diff --git a/tunnel/index.mjs b/tunnel/index.mjs index 716629f..5060da4 100644 --- a/tunnel/index.mjs +++ b/tunnel/index.mjs @@ -15,7 +15,8 @@ const DEFAULT_TIMEOUT_MS = 30000; const LOCAL_HOST = '127.0.0.1'; function parseArgs() { - const args = process.argv.slice(2); + // slice(1): when run as a bundled binary, argv is [binaryPath, ...cliArgs]; slice(2) would drop --key + const args = process.argv.slice(1); let key = null; let remotePort = DEFAULT_REMOTE_PORT; let localPort = DEFAULT_LOCAL_PORT; @@ -43,6 +44,42 @@ function emit(line) { } catch (_) {} } +/** + * Verify that the port is accepting TCP connections before we report it. + * Tries to connect to host:port; resolves when connect succeeds (then we close the socket). + * Rejects on error or after timeoutMs. + */ +function waitUntilPortAccepting(host, port, timeoutMs = 10000, intervalMs = 200) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + function tryConnect() { + if (Date.now() >= deadline) { + reject(new Error('Port did not accept connections within ' + timeoutMs + 'ms')); + return; + } + import('net').then((net) => { + const socket = net.createConnection( + { port, host, allowHalfOpen: false }, + () => { + socket.setTimeout(0); + socket.destroy(); + resolve(); + } + ); + socket.on('error', () => { + socket.destroy(); + setTimeout(tryConnect, intervalMs); + }); + socket.setTimeout(intervalMs, () => { + socket.destroy(); + setTimeout(tryConnect, intervalMs); + }); + }).catch(reject); + } + tryConnect(); + }); +} + async function run() { const mod = await import('holesail'); const Holesail = mod.default || mod; @@ -70,12 +107,17 @@ async function run() { else resolve(port); }; - const hs = new Holesail({ + const opts = { client: true, key, host: LOCAL_HOST, port: portToUse - }); + }; + // If the Holesail API supports remotePort, tell it which port on the peer to use (e.g. 22 for SSH). + if (remotePort > 0 && remotePort <= 65535) { + opts.remotePort = remotePort; + } + const hs = new Holesail(opts); if (typeof hs.on === 'function') { hs.on('error', (err) => { @@ -105,6 +147,8 @@ async function run() { try { const port = await readyPromise; hs = run._hs; + // Verify the port is actually accepting connections before reporting it + await waitUntilPortAccepting(LOCAL_HOST, port, 10000, 200); emit(JSON.stringify({ local_port: port, ready: true })); } catch (err) { const msg = (err && err.message) ? err.message : String(err);