@@ -6,13 +6,13 @@ Ansible connection plugin that uses [Holesail](https://github.com/holesail/holes
|
|||||||
- **In inventory**: Set `ansible_connection` and `ansible_holesail_key`.
|
- **In inventory**: Set `ansible_connection` and `ansible_holesail_key`.
|
||||||
- Ansible starts a local tunnel, connects SSH through it, and runs your playbooks as usual.
|
- 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
|
## Installation
|
||||||
|
|
||||||
### 0. One-command install (recommended)
|
### 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:**
|
**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
|
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)
|
### 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)
|
### 3. Install Paramiko (if not installed by the script)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install paramiko
|
pip install asyncssh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Inventory
|
## Inventory
|
||||||
|
|||||||
@@ -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 plugin: holesail
|
||||||
|
|
||||||
- **Connection type**: `anisail.anisail.holesail` (or `holesail` when the collection is in use)
|
- **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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from ansible.errors import AnsibleConnectionFailure
|
from ansible.errors import AnsibleConnectionFailure
|
||||||
from ansible.plugins.connection import ConnectionBase
|
from ansible.plugins.connection import ConnectionBase, ensure_connect
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import paramiko
|
import asyncssh
|
||||||
HAS_PARAMIKO = True
|
HAS_ASYNCSSH = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_PARAMIKO = False
|
HAS_ASYNCSSH = False
|
||||||
|
|
||||||
DOCUMENTATION = """
|
DOCUMENTATION = """
|
||||||
author: Anisail
|
author: Anisail
|
||||||
@@ -66,8 +73,9 @@ options:
|
|||||||
- name: ansible_user
|
- name: ansible_user
|
||||||
- name: ansible_ssh_user
|
- name: ansible_ssh_user
|
||||||
private_key_file:
|
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
|
type: string
|
||||||
|
default: ''
|
||||||
vars:
|
vars:
|
||||||
- name: ansible_private_key_file
|
- name: ansible_private_key_file
|
||||||
- name: ansible_ssh_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:
|
def _find_tunnel_binary(plugin_path_option, collection_root: str | None) -> str:
|
||||||
"""Resolve anisail-tunnel binary path."""
|
"""Resolve anisail-tunnel binary path."""
|
||||||
if plugin_path_option and os.path.isabs(plugin_path_option) and os.path.isfile(plugin_path_option):
|
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):
|
class Connection(ConnectionBase):
|
||||||
"""Ansible connection over Holesail tunnel + SSH (Paramiko)."""
|
"""Ansible connection over Holesail tunnel + SSH (asyncssh)."""
|
||||||
|
|
||||||
transport = 'holesail'
|
transport = 'holesail'
|
||||||
has_pipelining = False
|
has_pipelining = False
|
||||||
|
allow_extras = True
|
||||||
|
extras_prefix = 'holesail'
|
||||||
|
|
||||||
def __init__(self, play_context, new_stdin, *args, **kwargs):
|
def __init__(self, play_context, new_stdin, *args, **kwargs):
|
||||||
super().__init__(play_context, new_stdin, *args, **kwargs)
|
super().__init__(play_context, new_stdin, *args, **kwargs)
|
||||||
self._tunnel_process: subprocess.Popen | None = None
|
self._tunnel_process: subprocess.Popen | None = None
|
||||||
self._local_port: int | 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:
|
def _connect(self) -> None:
|
||||||
if not HAS_PARAMIKO:
|
if not HAS_ASYNCSSH:
|
||||||
raise AnsibleConnectionFailure(
|
raise AnsibleConnectionFailure(
|
||||||
'The paramiko Python library is required for the holesail connection plugin. '
|
'The asyncssh Python library is required for the holesail connection plugin. '
|
||||||
'Install it with: pip install paramiko'
|
'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:
|
if not key:
|
||||||
raise AnsibleConnectionFailure(
|
raise AnsibleConnectionFailure(
|
||||||
'ansible_holesail_key is required for the holesail connection. '
|
'ansible_holesail_key is required for the holesail connection. '
|
||||||
'Set it in inventory (e.g. ansible_holesail_key: "hs://s000...").'
|
'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 ''
|
tunnel_path_opt = self.get_option('tunnel_path') or ''
|
||||||
ready_timeout_sec = self.get_option('ready_timeout') or 30
|
ready_timeout_sec = self.get_option('ready_timeout') or 30
|
||||||
timeout_ms = ready_timeout_sec * 1000
|
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.'
|
'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 = [
|
argv = [
|
||||||
binary,
|
binary,
|
||||||
'--key', key,
|
'--key', key,
|
||||||
'--remote-port', str(remote_port),
|
'--remote-port', str(remote_port),
|
||||||
|
'--local-port', str(local_port),
|
||||||
'--timeout', str(timeout_ms),
|
'--timeout', str(timeout_ms),
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
self._tunnel_process = subprocess.Popen(
|
self._tunnel_process = subprocess.Popen(
|
||||||
argv,
|
argv,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
@@ -215,44 +325,88 @@ class Connection(ConnectionBase):
|
|||||||
self._terminate_tunnel()
|
self._terminate_tunnel()
|
||||||
raise AnsibleConnectionFailure(f'Invalid tunnel output (expected JSON with local_port): {e}') from e
|
raise AnsibleConnectionFailure(f'Invalid tunnel output (expected JSON with local_port): {e}') from e
|
||||||
|
|
||||||
local_port = data.get('local_port')
|
reported = data.get('local_port')
|
||||||
if local_port is None:
|
if reported is not None and int(reported) != self._local_port:
|
||||||
self._terminate_tunnel()
|
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'
|
username = self.get_option('remote_user') or self._play_context.remote_user or 'root'
|
||||||
pkey = None
|
key_file_opt = self.get_option('private_key_file') or ''
|
||||||
key_file = self.get_option('private_key_file')
|
client_keys = _resolve_client_keys(key_file_opt.strip() or None)
|
||||||
if key_file and os.path.isfile(key_file):
|
agent_path = os.environ.get('SSH_AUTH_SOCK') or None
|
||||||
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
|
|
||||||
password = self.get_option('password') or self._play_context.password or None
|
password = self.get_option('password') or self._play_context.password or None
|
||||||
|
|
||||||
client = paramiko.SSHClient()
|
connect_kw = {
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
'host': '127.0.0.1',
|
||||||
try:
|
'port': self._local_port,
|
||||||
client.connect(
|
'username': username,
|
||||||
'127.0.0.1',
|
'client_keys': client_keys,
|
||||||
port=self._local_port,
|
'password': password,
|
||||||
username=username,
|
'known_hosts': None,
|
||||||
pkey=pkey,
|
}
|
||||||
password=password,
|
if agent_path and os.path.exists(agent_path):
|
||||||
timeout=30,
|
connect_kw['agent_path'] = agent_path
|
||||||
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
|
|
||||||
|
|
||||||
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:
|
def _terminate_tunnel(self) -> None:
|
||||||
if self._tunnel_process is None:
|
if self._tunnel_process is None:
|
||||||
@@ -268,49 +422,99 @@ class Connection(ConnectionBase):
|
|||||||
pass
|
pass
|
||||||
self._tunnel_process = None
|
self._tunnel_process = None
|
||||||
|
|
||||||
def exec_command(self, cmd, in_data=None, sudoable=True):
|
def _run_async(self, coro, timeout=60):
|
||||||
if self._paramiko_client is None:
|
if self._loop is None or self._ssh_conn is None:
|
||||||
raise AnsibleConnectionFailure('Not connected')
|
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:
|
try:
|
||||||
stdin, stdout, stderr = self._paramiko_client.exec_command(cmd)
|
return self._run_async(_run(), timeout=90)
|
||||||
if in_data:
|
|
||||||
stdin.write(in_data)
|
|
||||||
stdin.channel.shutdown_write()
|
|
||||||
rc = stdout.channel.recv_exit_status()
|
|
||||||
return rc, stdout.read(), stderr.read()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise AnsibleConnectionFailure(f'exec_command failed: {e}') from e
|
raise AnsibleConnectionFailure(f'exec_command failed: {e}') from e
|
||||||
|
|
||||||
|
@ensure_connect
|
||||||
def put_file(self, in_path, out_path):
|
def put_file(self, in_path, out_path):
|
||||||
if self._paramiko_client is None:
|
if self._ssh_conn is None:
|
||||||
raise AnsibleConnectionFailure('Not connected')
|
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:
|
try:
|
||||||
sftp = self._paramiko_client.open_sftp()
|
self._run_async(_put())
|
||||||
try:
|
|
||||||
sftp.put(in_path, out_path)
|
|
||||||
finally:
|
|
||||||
sftp.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise AnsibleConnectionFailure(f'put_file failed: {e}') from e
|
raise AnsibleConnectionFailure(f'put_file failed: {e}') from e
|
||||||
|
|
||||||
|
@ensure_connect
|
||||||
def get_file(self, in_path, out_path):
|
def get_file(self, in_path, out_path):
|
||||||
if self._paramiko_client is None:
|
if self._ssh_conn is None:
|
||||||
raise AnsibleConnectionFailure('Not connected')
|
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:
|
try:
|
||||||
sftp = self._paramiko_client.open_sftp()
|
self._run_async(_get())
|
||||||
try:
|
|
||||||
sftp.get(in_path, out_path)
|
|
||||||
finally:
|
|
||||||
sftp.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise AnsibleConnectionFailure(f'get_file failed: {e}') from 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):
|
def close(self):
|
||||||
if self._paramiko_client:
|
if self._ssh_conn and self._loop:
|
||||||
try:
|
try:
|
||||||
self._paramiko_client.close()
|
self._run_async(self._ssh_conn.close(), timeout=5)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
self._terminate_tunnel()
|
||||||
super().close()
|
super().close()
|
||||||
|
|||||||
+14
-14
@@ -1,14 +1,14 @@
|
|||||||
# Connection plugin reference
|
# 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
|
## Overview
|
||||||
|
|
||||||
- **Transport:** `holesail`
|
- **Transport:** `holesail`
|
||||||
- **Requires:** Python **paramiko** (`pip install paramiko`)
|
- **Requires:** Python **asyncssh** (`pip install asyncssh`)
|
||||||
- **Implements:** `ConnectionBase` — `_connect()`, `exec_command()`, `put_file()`, `get_file()`, `close()`
|
- **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
|
## 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). |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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 | — | Path to SSH private key for Paramiko. |
|
| `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. |
|
| `password` | `ansible_password`, `ansible_ssh_pass` | string | — | SSH password if not using a key. |
|
||||||
|
|
||||||
## _connect() flow
|
## _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.
|
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.
|
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.
|
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.
|
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)]`.
|
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).
|
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.
|
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 / 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.
|
- **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)** — Open SFTP from the Paramiko client, `put(in_path, out_path)`, close SFTP. Raise on error.
|
- **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)** — Open SFTP, `get(in_path, out_path)`, close SFTP. 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()
|
## 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`.
|
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()`.
|
3. Call `super().close()`.
|
||||||
|
|
||||||
@@ -67,12 +67,12 @@ So the plugin will use: explicit path → collection `bin/` → collection `rele
|
|||||||
|
|
||||||
| Situation | Plugin behavior |
|
| 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. |
|
| 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. |
|
| Binary not found | `AnsibleConnectionFailure` suggesting ansible_holesail_tunnel_path, collection bin/releases, or PATH. |
|
||||||
| Tunnel process fails to start | `AnsibleConnectionFailure` with the OSError message. |
|
| Tunnel process fails to start | `AnsibleConnectionFailure` with the OSError message. |
|
||||||
| No line from tunnel / process exited | Terminate tunnel, raise with stderr if available. |
|
| No line from tunnel / process exited | Terminate tunnel, raise with stderr if available. |
|
||||||
| Invalid JSON or missing local_port | Terminate tunnel, raise. |
|
| 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).
|
For user-facing fixes, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md).
|
||||||
|
|||||||
+10
-10
@@ -6,12 +6,12 @@ This guide covers installing the **anisail-tunnel** binary (used by the Ansible
|
|||||||
|
|
||||||
## Quick install (one command)
|
## 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:
|
The script:
|
||||||
|
|
||||||
1. Downloads and installs the tunnel binary for your platform.
|
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.
|
3. Installs the **anisail.anisail** collection from the repo archive.
|
||||||
|
|
||||||
### macOS / Linux
|
### 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).
|
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.
|
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.
|
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`.
|
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.
|
**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`).
|
2. Extracts to `%LOCALAPPDATA%\anisail\bin` (or `ANISAIL_INSTALL_DIR`).
|
||||||
3. Ensures `anisail-tunnel.exe` is at the install dir root.
|
3. Ensures `anisail-tunnel.exe` is at the install dir root.
|
||||||
4. Suggests adding the install dir to the user PATH.
|
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).
|
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`).
|
**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
|
### 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"
|
export ANSIBLE_COLLECTIONS_PATHS="/path/to/anisail/ansible_collections"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Paramiko (required)
|
### asyncssh (required)
|
||||||
|
|
||||||
```bash
|
```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`
|
- 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.
|
- Windows: remove `%LOCALAPPDATA%\anisail` and remove that path from user PATH if you added it.
|
||||||
- **Collection:** `ansible-galaxy collection remove anisail.anisail`
|
- **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
@@ -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.
|
7. Move the binary to the install dir root; remove empty subdirs left by the zip.
|
||||||
8. `chmod +x` the binary.
|
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.
|
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.
|
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).
|
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).
|
5. Find `anisail-tunnel.exe` (recursively if the zip has a subdir).
|
||||||
6. Move the exe to the install dir root; remove empty subdirs.
|
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', ...)`.
|
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.
|
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.
|
10. Print the binary path and next steps.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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:
|
**Fix:** Install asyncssh on the control node:
|
||||||
`pip install paramiko`
|
`pip install asyncssh`
|
||||||
Or install from the project’s [requirements.txt](../requirements.txt):
|
Or install from the project’s [requirements.txt](../requirements.txt):
|
||||||
`pip install -r requirements.txt`
|
`pip install -r requirements.txt`
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- Start as a Holesail **client** with a given `hs://` key.
|
- 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`).
|
- 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.
|
- 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.
|
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).
|
2. Dynamically `import('holesail')` (Bare/ESM).
|
||||||
3. Choose local port: if `--local-port` > 0 use it, else use 19200 + random.
|
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.
|
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.
|
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.
|
7. On any of these events, call `hs.close()` and exit.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -112,4 +112,4 @@ Set **ansible_holesail_tunnel_path** when:
|
|||||||
- You have **multiple** installs and want to force a specific binary.
|
- 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.
|
- 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/<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 collection’s `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.
|
||||||
|
|||||||
@@ -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
|
||||||
Generated
+941
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
# For the Ansible connection plugin (control node)
|
# For the Ansible connection plugin (control node)
|
||||||
paramiko>=2.11.0
|
asyncssh>=2.14.0
|
||||||
|
|
||||||
# Optional: to run playbooks
|
# Optional: to run playbooks
|
||||||
# ansible-core>=2.14
|
# ansible-core>=2.14
|
||||||
|
|||||||
+8
-8
@@ -68,18 +68,18 @@ if ($UserPath -and $UserPath -notlike "*$BinDir*") {
|
|||||||
Write-Host " Then restart your terminal." -ForegroundColor Yellow
|
Write-Host " Then restart your terminal." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Install paramiko if missing ─────────────────────────────────────────────────
|
# ── Install asyncssh if missing ─────────────────────────────────────────────────
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Checking Python dependency (paramiko)..."
|
Write-Host "Checking Python dependency (asyncssh)..."
|
||||||
python -c "import paramiko" 2>$null
|
python -c "import asyncssh" 2>$null
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
Write-Host " paramiko already installed"
|
Write-Host " asyncssh already installed"
|
||||||
} else {
|
} else {
|
||||||
Write-Host " Installing paramiko..."
|
Write-Host " Installing asyncssh..."
|
||||||
$pipOk = $false
|
$pipOk = $false
|
||||||
pip install paramiko 2>$null; if ($LASTEXITCODE -eq 0) { $pipOk = $true }
|
pip install asyncssh 2>$null; if ($LASTEXITCODE -eq 0) { $pipOk = $true }
|
||||||
if (-not $pipOk) { pip3 install paramiko 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 paramiko" } else { Write-Host " Warning: could not install paramiko. Install manually: pip install paramiko" -ForegroundColor Yellow }
|
if ($pipOk) { Write-Host " Installed asyncssh" } else { Write-Host " Warning: could not install asyncssh. Install manually: pip install asyncssh" -ForegroundColor Yellow }
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Install Ansible collection ──────────────────────────────────────────────────
|
# ── Install Ansible collection ──────────────────────────────────────────────────
|
||||||
|
|||||||
+10
-10
@@ -94,19 +94,19 @@ if [[ -d "$LOCAL_BIN" ]] && [[ "$INSTALL_DIR" != "$LOCAL_BIN" ]]; then
|
|||||||
echo " Linked: ${LOCAL_BIN}/${BIN_NAME}"
|
echo " Linked: ${LOCAL_BIN}/${BIN_NAME}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Install paramiko if missing ─────────────────────────────────────────────────
|
# ── Install asyncssh if missing ─────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "Checking Python dependency (paramiko)..."
|
echo "Checking Python dependency (asyncssh)..."
|
||||||
if python3 -c "import paramiko" 2>/dev/null; then
|
if python3 -c "import asyncssh" 2>/dev/null; then
|
||||||
echo " paramiko already installed"
|
echo " asyncssh already installed"
|
||||||
else
|
else
|
||||||
echo " Installing paramiko..."
|
echo " Installing asyncssh..."
|
||||||
if pip3 install paramiko 2>/dev/null; then
|
if pip3 install asyncssh 2>/dev/null; then
|
||||||
echo " Installed paramiko (pip3)"
|
echo " Installed asyncssh (pip3)"
|
||||||
elif pip install paramiko 2>/dev/null; then
|
elif pip install asyncssh 2>/dev/null; then
|
||||||
echo " Installed paramiko (pip)"
|
echo " Installed asyncssh (pip)"
|
||||||
else
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Executable
+52
@@ -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
@@ -15,7 +15,8 @@ const DEFAULT_TIMEOUT_MS = 30000;
|
|||||||
const LOCAL_HOST = '127.0.0.1';
|
const LOCAL_HOST = '127.0.0.1';
|
||||||
|
|
||||||
function parseArgs() {
|
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 key = null;
|
||||||
let remotePort = DEFAULT_REMOTE_PORT;
|
let remotePort = DEFAULT_REMOTE_PORT;
|
||||||
let localPort = DEFAULT_LOCAL_PORT;
|
let localPort = DEFAULT_LOCAL_PORT;
|
||||||
@@ -43,6 +44,42 @@ function emit(line) {
|
|||||||
} catch (_) {}
|
} 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() {
|
async function run() {
|
||||||
const mod = await import('holesail');
|
const mod = await import('holesail');
|
||||||
const Holesail = mod.default || mod;
|
const Holesail = mod.default || mod;
|
||||||
@@ -70,12 +107,17 @@ async function run() {
|
|||||||
else resolve(port);
|
else resolve(port);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hs = new Holesail({
|
const opts = {
|
||||||
client: true,
|
client: true,
|
||||||
key,
|
key,
|
||||||
host: LOCAL_HOST,
|
host: LOCAL_HOST,
|
||||||
port: portToUse
|
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') {
|
if (typeof hs.on === 'function') {
|
||||||
hs.on('error', (err) => {
|
hs.on('error', (err) => {
|
||||||
@@ -105,6 +147,8 @@ async function run() {
|
|||||||
try {
|
try {
|
||||||
const port = await readyPromise;
|
const port = await readyPromise;
|
||||||
hs = run._hs;
|
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 }));
|
emit(JSON.stringify({ local_port: port, ready: true }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err && err.message) ? err.message : String(err);
|
const msg = (err && err.message) ? err.message : String(err);
|
||||||
|
|||||||
Reference in New Issue
Block a user