working concept
CI / Build & Test (push) Successful in 3m2s

This commit is contained in:
Raven Scott
2026-03-16 15:31:54 -04:00
parent 0cb2fc17c5
commit 6c344139db
18 changed files with 1409 additions and 127 deletions
+4 -4
View File
@@ -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
+6
View File
@@ -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
@@ -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.
@@ -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 <port>.
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/<platform>/ 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()
+14 -14
View File
@@ -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:<port>` 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:<port>` 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).
+10 -10
View File
@@ -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 <path-to-collection> --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)
+2 -2
View File
@@ -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 <path> --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 <path> --force`. On failure, print a warning with manual install instructions.
10. Print the binary path and next steps.
+4 -4
View File
@@ -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/<platform>/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 projects [requirements.txt](../requirements.txt):
`pip install -r requirements.txt`
+2 -2
View File
@@ -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 plugins 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.
+1 -1
View File
@@ -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 collections `bin/` or `releases/<platform>/`, 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 collections `bin/` or `releases/<platform>/`, 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.
+19
View File
@@ -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 <port> 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
+941
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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 ──────────────────────────────────────────────────
+10 -10
View File
@@ -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
+52
View File
@@ -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
+47 -3
View File
@@ -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);