Update docs
CI / Build & Test (push) Successful in 2m52s

This commit is contained in:
Raven Scott
2026-03-16 15:37:13 -04:00
parent 6c344139db
commit 0822b98181
8 changed files with 28 additions and 26 deletions
+2 -2
View File
@@ -68,7 +68,7 @@ cp -r releases/* ansible_collections/anisail/anisail/releases/
Install the binary somewhere on `PATH` and name it `anisail-tunnel`. The plugin will use it if `ansible_holesail_tunnel_path` is not set and no collection-relative binary is found.
### 3. Install Paramiko (if not installed by the script)
### 3. Install asyncssh (if not installed by the script)
```bash
pip install asyncssh
@@ -142,7 +142,7 @@ ansible-playbook -i inventory.yml playbook.yml
| `ansible_holesail_tunnel_path` | Path to anisail-tunnel binary | auto (collection bin/releases or PATH) |
| `ansible_holesail_ready_timeout` | Seconds to wait for tunnel ready | 30 |
| `ansible_user` | SSH user | play context default |
| `ansible_private_key_file` / `ansible_ssh_private_key_file` | SSH key path | — |
| `ansible_private_key_file` / `ansible_ssh_private_key_file` | SSH key path (optional; if unset, system keys `~/.ssh/id_ed25519`, `id_rsa`, etc. and SSH agent are used) | — |
| `ansible_password` / `ansible_ssh_pass` | SSH password | — |
## Building the tunnel binary
@@ -5,9 +5,9 @@ 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 `asyncssh` Python library.
- **Requires**: `ansible_holesail_key` (Holesail URL, e.g. `hs://s000...`), anisail-tunnel binary, and the `asyncssh` Python library. SSH keys: optional `ansible_ssh_private_key_file`; if unset, system keys (`~/.ssh/id_ed25519`, `id_rsa`, etc.) and SSH agent are used.
See the [project README](https://github.com/anisail/anisail) at the repo root for full installation, inventory, and playbook examples.
See the [project README](../../../README.md) at the repo root for full installation, inventory, and playbook examples.
## Quick example
+6 -6
View File
@@ -1,10 +1,10 @@
# Architecture
Anisail lets Ansible reach hosts behind NAT or firewalls by running a local Holesail client (the **anisail-tunnel** binary) that creates a TCP tunnel to the target, then connecting SSH over that tunnel. There is no browser or extension: the connection plugin spawns the tunnel process, reads the local port from its stdout, and delegates all connection operations to SSH (via Paramiko) on `127.0.0.1:<local_port>`.
Anisail lets Ansible reach hosts behind NAT or firewalls by running a local Holesail client (the **anisail-tunnel** binary) that creates a TCP tunnel to the target, then connecting SSH over that tunnel. There is no browser or extension: the connection plugin spawns the tunnel process, allocates a free local port and passes it to the tunnel, reads the port from the tunnel's stdout (to confirm), and delegates all connection operations to SSH (via asyncssh) on `127.0.0.1:<local_port>`.
## Summary
An Ansible playbook runs against a host that uses the `holesail` connection. When Ansible opens the connection, the plugin starts the **anisail-tunnel** binary with the hosts Holesail key. The tunnel connects to the target via Holesail P2P and binds a local port. The tunnel prints one JSON line with that port; the plugin reads it, then connects Paramiko SSH to `127.0.0.1:<port>`. All `exec_command`, `put_file`, and `get_file` calls are delegated to that SSH session. When the connection is closed, the plugin terminates the tunnel process. The target runs `holesail --live 22` (or another port) and gives you the `hs://` URL to use as `ansible_holesail_key`.
An Ansible playbook runs against a host that uses the `holesail` connection. When Ansible opens the connection, the plugin allocates a free port, starts the **anisail-tunnel** binary with the hosts Holesail key and `--local-port`, and the tunnel binds to that port. The tunnel verifies the port accepts connections, then prints one JSON line with that port; the plugin reads it, then connects asyncssh to `127.0.0.1:<port>`. All `exec_command`, `put_file`, and `get_file` calls are delegated to that SSH session. When the connection is closed, the plugin terminates the tunnel process. The target runs `holesail --live 22` (or another port) and gives you the `hs://` URL to use as `ansible_holesail_key`.
## Data flow
@@ -13,12 +13,12 @@ sequenceDiagram
participant Ansible
participant Plugin as holesail connection plugin
participant Tunnel as anisail-tunnel process
participant SSH as Paramiko to 127.0.0.1
participant SSH as asyncssh to 127.0.0.1
participant Remote as Target behind NAT
Ansible->>Plugin: _connect()
Plugin->>Tunnel: spawn with --key, --timeout
Tunnel->>Tunnel: Holesail client ready()
Plugin->>Tunnel: spawn with --key, --local-port, --remote-port, --timeout
Tunnel->>Tunnel: Holesail client ready(), verify port accepting
Tunnel->>Plugin: stdout one line JSON local_port
Plugin->>SSH: connect 127.0.0.1:local_port
SSH->>Tunnel: TCP to local port
@@ -44,7 +44,7 @@ sequenceDiagram
| Component | Role |
|-----------|------|
| **anisail-tunnel** | Single-purpose Bare binary. Runs as a Holesail client, binds a local port, prints one JSON line with that port to stdout, then keeps running until stdin closes or it receives SIGTERM/SIGINT. Used only when Ansible opens a connection; one process per host connection. |
| **Connection plugin** | Python Ansible plugin (`holesail` transport). Resolves the tunnel binary, spawns it with the hosts `ansible_holesail_key` and options, reads the local port from stdout, connects Paramiko to that port, and delegates `exec_command`, `put_file`, and `get_file` to Paramiko. On `close()`, closes SSH and terminates the tunnel. |
| **Connection plugin** | Python Ansible plugin (`holesail` transport). Allocates a free port, resolves the tunnel binary, spawns it with the hosts `ansible_holesail_key` and `--local-port`, reads the local port from stdout, connects asyncssh to that port (using system keys and SSH agent when available), and delegates `exec_command`, `put_file`, and `get_file` to asyncssh. On `close()`, closes SSH and terminates the tunnel. |
| **Ansible collection** | `anisail.anisail` — provides the connection plugin only. No modules or roles. Install with `ansible-galaxy collection install` or by setting `ANSIBLE_COLLECTIONS_PATHS`. |
## Repository layout
+8 -6
View File
@@ -32,11 +32,13 @@ Options are configured via inventory (or group_vars/host_vars). The plugin reads
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=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**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.
6. **Port**Call `_allocate_free_port()` to get a free local port; pass it to the tunnel so it binds to that port (no port 0).
7. **Argv**Build `[binary, '--key', key, '--remote-port', str(port), '--local-port', str(local_port), '--timeout', str(timeout_ms)]`.
8. **Spawn**`subprocess.Popen(argv, stdin=PIPE, stdout=PIPE, stderr=PIPE)`. Store the process handle.
9. **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).
10. **Parse**Decode the line as UTF-8, parse as JSON. Expect a dict with `local_port` (must match the allocated port). If invalid or mismatch, terminate the process and raise.
11. **TCP check** — Wait until the tunnel port accepts a TCP connection (within ready_timeout).
12. **SSH** — Resolve client keys via `_resolve_client_keys()` (playbook key first, then `~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`). If `SSH_AUTH_SOCK` is set, pass `agent_path`. 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, agent_path=...)`. Store the connection and loop; use `run_coroutine_threadsafe()` from the main thread for exec_command/put_file/get_file. On failure (exception stored in `_connect_error`), terminate the tunnel and raise.
## exec_command / put_file / get_file
@@ -46,7 +48,7 @@ Options are configured via inventory (or group_vars/host_vars). The plugin reads
## close()
1. If the asyncssh connection is set, run `conn.close()` on the loop, then stop the loop; join the loop thread.
1. If the asyncssh connection is set, run `conn.close()` on the loop, cancel the keeper task, then stop the loop (stderr is briefly suppressed during cleanup to avoid asyncio "Task was destroyed" messages when the process has forked). 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()`.
+4 -4
View File
@@ -14,13 +14,13 @@ The tunnel process is **spawned per Ansible connection** and **terminated when t
## Secrets
- **ansible_holesail_key** — Identifies and authenticates to the Holesail target. Treat it as a secret. Prefer [Ansible Vault](https://docs.ansible.com/ansible/latest/vault_guide/index.html) or a secrets manager for inventory/vars that set it. See [TROUBLESHOOTING.md](TROUBLESHOOTING.md#storing-the-key-securely).
- **SSH keys and passwords** — Same as normal Ansible: use vault or secrets manager where appropriate. The connection plugin uses Paramiko only to connect to localhost; key and password handling is the same as for any SSH connection.
- **SSH keys and passwords** — Same as normal Ansible: use vault or secrets manager where appropriate. The connection plugin uses asyncssh only to connect to localhost; key and password handling is the same as for any SSH connection. When no key is set, system default keys and SSH agent are used.
- **ansible_holesail_tunnel_path** — Path to the binary; not secret. No need to vault it.
## Paramiko
## asyncssh
Paramiko is used only to connect to `127.0.0.1:<port>`.
There is no Paramiko-specific hardening beyond normal SSH key hygiene (protect private keys, use strong keys). The tunnel process listens on localhost; Paramiko does not accept external connections.
asyncssh is used only to connect to `127.0.0.1:<port>`.
There is no asyncssh-specific hardening beyond normal SSH key hygiene (protect private keys, use strong keys). The tunnel process listens on localhost; asyncssh does not accept external connections.
## Trust
+3 -3
View File
@@ -42,11 +42,11 @@ The plugin captures stderr from the tunnel process on failure; run Ansible with
## SSH connection to tunnel port failed
**Symptom:** The tunnel started and reported a local port, but the Paramiko SSH connection to `127.0.0.1:<port>` failed.
**Symptom:** The tunnel started and reported a local port, but the asyncssh SSH connection to `127.0.0.1:<port>` failed.
**Meaning:** The tunnel is working; SSH authentication or configuration to the target failed. This is normal SSH: wrong user, missing or wrong key, wrong password, or the targets SSH server rejecting the connection.
**Fix:** Use the same user, key, and credentials you would use for a direct SSH login to the target. Set **ansible_user**, **ansible_ssh_private_key_file**, or **ansible_password** as needed. Run with `-vvv` to see the Paramiko/SSH error message.
**Fix:** Use the same user, key, and credentials you would use for a direct SSH login to the target. Set **ansible_user**, **ansible_ssh_private_key_file**, or **ansible_password** as needed. If unset, the plugin uses system keys and SSH agent. Run with `-vvv` to see the asyncssh/SSH error message.
## “key required” when running the tunnel by hand
@@ -63,5 +63,5 @@ The plugin captures stderr from the tunnel process on failure; run Ansible with
## Verbose and debug
- **Ansible:** Run with `-vvv` to see detailed connection plugin and SSH output. This helps with “tunnel did not output local port” and “SSH connection failed” by showing stderr and Paramiko errors.
- **Ansible:** Run with `-vvv` to see detailed connection plugin and SSH output. This helps with “tunnel did not output local port” and “SSH connection failed” by showing stderr and asyncssh errors.
- **Tunnel (manual run):** If you run the tunnel binary manually for debugging, set **HOLESAIL_DEBUG=1** (or `true`) to get extra debug output from the Holesail client (if the binary was built with support for it). The plugin does not enable this when it spawns the tunnel.
+2 -2
View File
@@ -28,7 +28,7 @@ anisail-tunnel --key <hs://...> [--remote-port 22] [--local-port 0] [--timeout 3
- **Stdout:** Exactly one line, UTF-8, JSON object. Required field: `local_port` (integer, the bound port). Optional: `ready: true`. Example: `{"local_port": 19201, "ready": true}`.
- **Stderr:** Error and diagnostic messages (e.g. “key required”, “Tunnel ready timeout”). The plugin may capture stderr on failure to help with debugging.
- **Stdin:** The plugin opens the process with `stdin=subprocess.DEVNULL`. When the plugin closes the connection, it terminates the process; the tunnel also exits on SIGTERM/SIGINT or if stdin were to close.
- **Stdin:** The plugin spawns the process with `stdin=subprocess.PIPE`. When the plugin closes the connection, it terminates the tunnel process; the tunnel also exits on SIGTERM/SIGINT or when stdin closes.
The plugin reads one line from stdout and then uses only the port; it does not read again. The tunnel must not print anything else to stdout before that line.
@@ -45,7 +45,7 @@ The plugin reads one line from stdout and then uses only the port; it does not r
## Port behavior
- The **remote** port (what the target exposes) is fixed by the target: they run `holesail --live 22` (or another port). The plugin passes `ansible_port` as `--remote-port` for documentation/future use; the Holesail client connects to the peer and the peers server configuration defines which service is reached.
- The **local** port is chosen by the tunnel so the plugin can connect to `127.0.0.1:<local_port>`. If `--local-port` is 0, the tunnel uses a high range (19200+) to avoid needing a system-assigned ephemeral port.
- The **local** port: the plugin allocates a free port and passes it as `--local-port`; the tunnel binds to that port. If the plugin does not pass `--local-port` (or passes 0), the tunnel would use a high range (19200+); in practice the plugin always passes an allocated port.
## Bare compatibility
+1 -1
View File
@@ -40,7 +40,7 @@ all:
- **ansible_port** — Remote port the target exposes via Holesail (default 22). The target must run `holesail --live <this port>`.
- **ansible_holesail_tunnel_path** — Full path to the anisail-tunnel binary. Omit if the binary is on PATH or in the collections `releases/` or `bin/`.
- **ansible_holesail_ready_timeout** — Seconds to wait for the tunnel to report the local port (default 30).
- **ansible_ssh_private_key_file** / **ansible_password** — Used by the plugin for the Paramiko SSH connection to the tunnel port. Same semantics as normal Ansible SSH.
- **ansible_ssh_private_key_file** / **ansible_password** — Used by the plugin for the asyncssh connection to the tunnel port. If no key is set, system default keys and SSH agent are used. Same semantics as normal Ansible SSH.
### Short connection name