--- # SSH Mesh Key Distribution & Verification # # Distributes SSH public keys across all managed hosts so every host can SSH # to every other host. Also verifies connectivity. # # Usage: # ansible-playbook -i inventory.yml playbooks/ssh_mesh.yml # ansible-playbook -i inventory.yml playbooks/ssh_mesh.yml --tags verify # ansible-playbook -i inventory.yml playbooks/ssh_mesh.yml --tags distribute # ansible-playbook -i inventory.yml playbooks/ssh_mesh.yml -e "generate_missing=true" - name: SSH Mesh — Collect Keys hosts: ssh_mesh gather_facts: false tags: [collect, distribute] tasks: - name: Check if ed25519 key exists stat: path: "~/.ssh/id_ed25519.pub" register: ed25519_key - name: Check if RSA key exists (fallback) stat: path: "~/.ssh/id_rsa.pub" register: rsa_key when: not ed25519_key.stat.exists - name: Generate ed25519 key if missing command: ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "{{ ansible_user }}@{{ inventory_hostname }}" args: creates: ~/.ssh/id_ed25519 when: - not ed25519_key.stat.exists - not (rsa_key.stat.exists | default(false)) - generate_missing | default(false) | bool - name: Re-check for ed25519 key after generation stat: path: "~/.ssh/id_ed25519.pub" register: ed25519_key_recheck when: - not ed25519_key.stat.exists - generate_missing | default(false) | bool - name: Read ed25519 public key slurp: src: "~/.ssh/id_ed25519.pub" register: pubkey_ed25519 when: ed25519_key.stat.exists or (ed25519_key_recheck.stat.exists | default(false)) - name: Read RSA public key (fallback) slurp: src: "~/.ssh/id_rsa.pub" register: pubkey_rsa when: - not ed25519_key.stat.exists - not (ed25519_key_recheck.stat.exists | default(false)) - rsa_key.stat.exists | default(false) - name: Set public key fact set_fact: ssh_pubkey: >- {{ (pubkey_ed25519.content | default(pubkey_rsa.content) | b64decode | trim) }} ssh_key_comment: "{{ inventory_hostname }}" when: pubkey_ed25519 is not skipped or pubkey_rsa is not skipped - name: Warn if no key found debug: msg: "WARNING: No SSH key on {{ inventory_hostname }}. Run with -e generate_missing=true to create one." when: ssh_pubkey is not defined - name: SSH Mesh — Distribute Keys hosts: ssh_mesh gather_facts: false tags: [distribute] tasks: - name: Build list of all mesh public keys set_fact: all_mesh_keys: >- {{ groups['ssh_mesh'] | map('extract', hostvars) | selectattr('ssh_pubkey', 'defined') | map(attribute='ssh_pubkey') | list }} - name: Include admin key set_fact: all_mesh_keys: >- {{ all_mesh_keys + [admin_key] }} when: admin_key is defined - name: Ensure .ssh directory exists file: path: "~/.ssh" state: directory mode: "0700" - name: Ensure authorized_keys exists file: path: "~/.ssh/authorized_keys" state: touch mode: "0600" changed_when: false - name: Add missing keys to authorized_keys lineinfile: path: "~/.ssh/authorized_keys" line: "{{ item }}" state: present loop: "{{ all_mesh_keys }}" loop_control: label: "{{ item.split()[-1] | default('unknown') }}" - name: SSH Mesh — Verify Connectivity hosts: localhost gather_facts: false connection: local tags: [verify] tasks: - name: Build mesh host list set_fact: mesh_hosts: >- {{ groups['ssh_mesh'] | map('extract', hostvars) | list }} - name: Test SSH from localhost to each mesh host shell: | ssh -o BatchMode=yes \ -o ConnectTimeout=5 \ -o StrictHostKeyChecking=accept-new \ -i ~/.ssh/id_ed25519 \ -p {{ item.ansible_port | default(22) }} \ {{ item.ansible_user }}@{{ item.ansible_host }} \ "echo ok" 2>&1 register: ssh_tests loop: "{{ mesh_hosts }}" loop_control: label: "localhost -> {{ item.inventory_hostname | default(item.ansible_host) }}" failed_when: false changed_when: false - name: Display connectivity matrix debug: msg: | SSH Mesh Verification (from localhost): {% for result in ssh_tests.results %} {{ '✓' if result.rc == 0 and 'ok' in (result.stdout | default('')) else '✗' }} -> {{ result.item.inventory_hostname | default(result.item.ansible_host) }}{% if result.rc != 0 or 'ok' not in (result.stdout | default('')) %} ({{ result.stdout_lines[-1] | default('unknown error') }}){% endif %} {% endfor %} {{ ssh_tests.results | selectattr('rc', 'equalto', 0) | list | length }}/{{ ssh_tests.results | length }} hosts reachable - name: Test cross-host SSH (sample pairs) shell: | results="" {% for pair in cross_test_pairs | default([]) %} src_user="{{ pair.src_user }}" src_host="{{ pair.src_host }}" src_port="{{ pair.src_port | default(22) }}" dst_user="{{ pair.dst_user }}" dst_host="{{ pair.dst_host }}" dst_port="{{ pair.dst_port | default(22) }}" out=$(ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no \ -p ${src_port} ${src_user}@${src_host} \ "ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \ -i ~/.ssh/id_ed25519 -p ${dst_port} ${dst_user}@${dst_host} 'echo ok'" 2>&1) if echo "$out" | grep -q "ok"; then results="${results}✓ {{ pair.label }}\n" else results="${results}✗ {{ pair.label }} ($(echo "$out" | tail -1))\n" fi {% endfor %} echo -e "$results" register: cross_tests when: cross_test_pairs is defined changed_when: false - name: Display cross-host results debug: msg: | Cross-Host SSH Tests: {{ cross_tests.stdout }} when: cross_tests is not skipped and cross_tests.stdout is defined