Created
April 7, 2026 09:53
-
-
Save haplo/5a6a1194b6f84ec3dad2429d1b3af58b to your computer and use it in GitHub Desktop.
Wrapper script for ansible-playbook that pre-establishes reusable SSH connections
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # ansible-ssh-masters.sh | |
| # | |
| # Establish one SSH master connection per host, keep it alive with a | |
| # remote sleep, run ansible-playbook with all original arguments, then | |
| # clean up only the masters this script started. | |
| # | |
| # Useful when SSH authentication has a per-connection human cost: | |
| # hardware security keys, passphrase prompts, FIDO2/U2F, etc. | |
| # | |
| # You need to configure SSH and Ansible to use these connections. | |
| # | |
| # Add this to ~/.ssh/config: | |
| # | |
| # Host * | |
| # ControlMaster auto | |
| # ControlPersist 10m | |
| # ControlPath ~/.ssh/sockets/%h-%r-%p | |
| # ForwardAgent no | |
| # ForwardX11 no | |
| # GatewayPorts no | |
| # | |
| # Add this to ansible.cfg: | |
| # | |
| # [ssh_connection] | |
| # ssh_args = -o ControlMaster=auto | |
| # control_path_dir = ~/.ssh/sockets | |
| # control_path = %(directory)s/%%h-%%r-%%p | |
| # pipelining = True | |
| # | |
| # Usage (timeout in seconds): | |
| # ANSIBLE_SSH_MASTERS_TIMEOUT=3600 ./ansible-ssh-masters.sh playbook.yml [args...] | |
| set -euo pipefail | |
| TIMEOUT_SECONDS="${ANSIBLE_SSH_MASTERS_TIMEOUT:-3600}" | |
| SOCKET_DIR="${HOME}/.ssh/sockets" | |
| PIDS=() | |
| STARTED_HOSTS=() | |
| ARGS=("$@") | |
| INVENTORY="inventory" | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[0;33m' | |
| RED='\033[0;31m' | |
| NC='\033[0m' | |
| log() { echo -e "${GREEN}[+]${NC} $*"; } | |
| warn() { echo -e "${YELLOW}[!]${NC} $*"; } | |
| err() { echo -e "${RED}[-]${NC} $*" >&2; } | |
| parse_inventory_arg() { | |
| local i | |
| for ((i=0; i<${#ARGS[@]}; i++)); do | |
| case "${ARGS[$i]}" in | |
| -i|--inventory) | |
| if (( i + 1 >= ${#ARGS[@]} )); then | |
| err "Missing value after ${ARGS[$i]}" | |
| exit 1 | |
| fi | |
| INVENTORY="${ARGS[$((i + 1))]}" | |
| ;; | |
| --inventory=*) | |
| INVENTORY="${ARGS[$i]#--inventory=}" | |
| ;; | |
| esac | |
| done | |
| } | |
| cleanup() { | |
| echo | |
| log "Closing master connections..." | |
| local i pid host | |
| for i in "${!PIDS[@]}"; do | |
| pid="${PIDS[$i]}" | |
| host="${STARTED_HOSTS[$i]}" | |
| if kill -0 "$pid" 2>/dev/null; then | |
| if kill "$pid" 2>/dev/null; then | |
| wait "$pid" 2>/dev/null || true | |
| log "Closed master for ${host} (pid ${pid})" | |
| else | |
| err "Failed to kill master for ${host} (pid ${pid})" | |
| fi | |
| else | |
| warn "Master for ${host} was already gone (pid ${pid})" | |
| fi | |
| done | |
| log "Done." | |
| } | |
| trap cleanup EXIT INT TERM | |
| parse_inventory_arg | |
| mkdir -p "$SOCKET_DIR" | |
| chmod 0700 "$SOCKET_DIR" | |
| # Remove stale sockets (no process behind them) | |
| find "$SOCKET_DIR" -maxdepth 1 -type s ! -exec fuser -s {} \; -delete 2>/dev/null || true | |
| if ! ansible-inventory -i "$INVENTORY" --graph >/dev/null 2>&1; then | |
| err "Inventory could not be parsed: $INVENTORY" | |
| exit 1 | |
| fi | |
| mapfile -t HOSTS < <( | |
| ansible-inventory -i "$INVENTORY" --graph | | |
| awk ' | |
| /@/ { next } | |
| { | |
| line = $0 | |
| gsub(/^[[:space:]|]+/, "", line) | |
| sub(/^--/, "", line) | |
| if (line != "") { | |
| print line | |
| } | |
| } | |
| ' | | |
| sort -u | |
| ) | |
| if (( ${#HOSTS[@]} == 0 )); then | |
| err "No hosts found in inventory: $INVENTORY" | |
| exit 1 | |
| fi | |
| log "Found ${#HOSTS[@]} hosts in inventory: $INVENTORY" | |
| log "Per-host connection lifetime: ${TIMEOUT_SECONDS} seconds" | |
| log "Establishing master connections (authentication may be required)..." | |
| echo | |
| count=0 | |
| for host in "${HOSTS[@]}"; do | |
| ((count+=1)) | |
| if ssh -O check -o "ControlPath=${SOCKET_DIR}/%h-%r-%p" "$host" >/dev/null 2>&1; then | |
| log "[$count/${#HOSTS[@]}] ${host}: reusing existing master" | |
| continue | |
| fi | |
| warn "[$count/${#HOSTS[@]}] ${host}: connecting..." | |
| ssh \ | |
| -o ControlMaster=yes \ | |
| -o "ControlPath=${SOCKET_DIR}/%h-%r-%p" \ | |
| -o ServerAliveInterval=30 \ | |
| -o ServerAliveCountMax=3 \ | |
| -o ForwardAgent=no \ | |
| -o ForwardX11=no \ | |
| -o ClearAllForwardings=yes \ | |
| "$host" "sleep ${TIMEOUT_SECONDS}" & | |
| pid=$! | |
| for _ in $(seq 1 15); do | |
| if ssh -O check -o "ControlPath=${SOCKET_DIR}/%h-%r-%p" "$host" >/dev/null 2>&1; then | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| if ssh -O check -o "ControlPath=${SOCKET_DIR}/%h-%r-%p" "$host" >/dev/null 2>&1; then | |
| log "[$count/${#HOSTS[@]}] ${host}: master established (pid ${pid})" | |
| PIDS+=("$pid") | |
| STARTED_HOSTS+=("$host") | |
| else | |
| err "[$count/${#HOSTS[@]}] ${host}: failed to establish master" | |
| if kill -0 "$pid" 2>/dev/null; then | |
| if kill "$pid" 2>/dev/null; then | |
| wait "$pid" 2>/dev/null || true | |
| else | |
| err "[$count/${#HOSTS[@]}] ${host}: failed to stop failed SSH process (pid ${pid})" | |
| fi | |
| fi | |
| fi | |
| echo | |
| done | |
| echo | |
| log "Running: ansible-playbook $*" | |
| set +e | |
| ansible-playbook "$@" | |
| ANSIBLE_EXIT=$? | |
| set -e | |
| exit "$ANSIBLE_EXIT" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment