--- # Network Connectivity Playbook # Full mesh connectivity check: Tailscale status, ping matrix, SSH port reachability, # HTTP endpoint checks, and per-host JSON reports. # Usage: ansible-playbook playbooks/network_connectivity.yml # Usage: ansible-playbook playbooks/network_connectivity.yml -e "host_target=synology" - name: Network Connectivity Check hosts: "{{ host_target | default('active') }}" gather_facts: yes ignore_unreachable: true vars: ntfy_url: "{{ ntfy_url | default('https://ntfy.sh/REDACTED_TOPIC') }}" report_dir: "/tmp/connectivity_reports" ts_candidates: - /usr/bin/tailscale - /var/packages/Tailscale/target/bin/tailscale http_endpoints: - name: Portainer url: "http://100.67.40.126:9000" - name: Gitea url: "http://100.67.40.126:3000" - name: Immich url: "http://100.67.40.126:2283" - name: Home Assistant url: "http://100.112.186.90:8123" tasks: # ---------- Setup ---------- - name: Create connectivity report directory ansible.builtin.file: path: "{{ report_dir }}" state: directory mode: '0755' delegate_to: localhost run_once: true # ---------- Tailscale detection ---------- - name: Detect Tailscale binary path (first candidate that exists) ansible.builtin.shell: | for p in {{ ts_candidates | join(' ') }}; do [ -x "$p" ] && echo "$p" && exit 0 done echo "" register: ts_bin changed_when: false failed_when: false - name: Get Tailscale status JSON (if binary found) ansible.builtin.command: "{{ ts_bin.stdout }} status --json" register: ts_status_raw changed_when: false failed_when: false when: ts_bin.stdout | length > 0 - name: Parse Tailscale status JSON ansible.builtin.set_fact: ts_parsed: "{{ ts_status_raw.stdout | from_json }}" when: - ts_bin.stdout | length > 0 - ts_status_raw.rc is defined - ts_status_raw.rc == 0 - ts_status_raw.stdout | length > 0 - ts_status_raw.stdout is search('{') - name: Extract Tailscale BackendState and first IP ansible.builtin.set_fact: ts_backend_state: "{{ ts_parsed.BackendState | default('unknown') }}" ts_first_ip: "{{ (ts_parsed.Self.TailscaleIPs | default([]))[0] | default('n/a') }}" when: ts_parsed is defined - name: Set Tailscale defaults when binary not found or parse failed ansible.builtin.set_fact: ts_backend_state: "{{ ts_backend_state | default('not_installed') }}" ts_first_ip: "{{ ts_first_ip | default('n/a') }}" # ---------- Ping matrix (all active hosts except self) ---------- - name: Ping all other active hosts (2 pings, 2s timeout) ansible.builtin.command: > ping -c 2 -W 2 {{ hostvars[item]['ansible_host'] }} register: ping_results loop: "{{ groups['active'] | difference([inventory_hostname]) }}" loop_control: label: "{{ item }} ({{ hostvars[item]['ansible_host'] }})" changed_when: false failed_when: false - name: Build ping summary map ansible.builtin.set_fact: ping_map: >- {{ ping_map | default({}) | combine({ item.item: { 'host': hostvars[item.item]['ansible_host'], 'rc': item.rc, 'status': 'OK' if item.rc == 0 else 'FAIL' } }) }} loop: "{{ ping_results.results }}" loop_control: label: "{{ item.item }}" - name: Identify failed ping targets ansible.builtin.set_fact: failed_ping_peers: >- {{ ping_results.results | selectattr('rc', 'ne', 0) | map(attribute='item') | list }} # ---------- SSH port reachability ---------- - name: Check SSH port reachability for all other active hosts ansible.builtin.command: > nc -z -w 3 {{ hostvars[item]['ansible_host'] }} {{ hostvars[item]['ansible_port'] | default(22) }} register: ssh_results loop: "{{ groups['active'] | difference([inventory_hostname]) }}" loop_control: label: "{{ item }} ({{ hostvars[item]['ansible_host'] }}:{{ hostvars[item]['ansible_port'] | default(22) }})" changed_when: false failed_when: false - name: Build SSH reachability summary map ansible.builtin.set_fact: ssh_map: >- {{ ssh_map | default({}) | combine({ item.item: { 'host': hostvars[item.item]['ansible_host'], 'port': hostvars[item.item]['ansible_port'] | default(22), 'rc': item.rc, 'status': 'OK' if item.rc == 0 else 'FAIL' } }) }} loop: "{{ ssh_results.results }}" loop_control: label: "{{ item.item }}" # ---------- Per-host connectivity summary ---------- - name: Display per-host connectivity summary ansible.builtin.debug: msg: | ========================================== CONNECTIVITY SUMMARY: {{ inventory_hostname }} ========================================== Tailscale: binary: {{ ts_bin.stdout if ts_bin.stdout | length > 0 else 'not found' }} backend_state: {{ ts_backend_state }} first_ip: {{ ts_first_ip }} Ping matrix (from {{ inventory_hostname }}): {% for peer, result in (ping_map | default({})).items() %} {{ peer }} ({{ result.host }}): {{ result.status }} {% endfor %} SSH port reachability (from {{ inventory_hostname }}): {% for peer, result in (ssh_map | default({})).items() %} {{ peer }} ({{ result.host }}:{{ result.port }}): {{ result.status }} {% endfor %} ========================================== # ---------- HTTP endpoint checks (run once from localhost) ---------- - name: Check HTTP endpoints ansible.builtin.uri: url: "{{ item.url }}" method: GET status_code: [200, 301, 302, 401, 403] timeout: 10 validate_certs: false register: http_results loop: "{{ http_endpoints }}" loop_control: label: "{{ item.name }} ({{ item.url }})" delegate_to: localhost run_once: true failed_when: false - name: Display HTTP endpoint results ansible.builtin.debug: msg: | ========================================== HTTP ENDPOINT RESULTS ========================================== {% for result in http_results.results %} {{ result.item.name }} ({{ result.item.url }}): status: {{ result.status | default('UNREACHABLE') }} ok: {{ 'YES' if result.status is defined and result.status in [200, 301, 302, 401, 403] else 'NO' }} {% endfor %} ========================================== delegate_to: localhost run_once: true # ---------- ntfy alert for failed ping peers ---------- - name: Send ntfy alert when peers fail ping ansible.builtin.uri: url: "{{ ntfy_url }}" method: POST body: | Host {{ inventory_hostname }} detected {{ failed_ping_peers | length }} unreachable peer(s): {% for peer in failed_ping_peers %} - {{ peer }} ({{ hostvars[peer]['ansible_host'] }}) {% endfor %} Checked at {{ ansible_date_time.iso8601 }} headers: Title: "Homelab Network Alert" Priority: "high" Tags: "warning,network" status_code: [200, 204] delegate_to: localhost failed_when: false when: failed_ping_peers | default([]) | length > 0 # ---------- Per-host JSON report ---------- - name: Write per-host JSON connectivity report ansible.builtin.copy: content: "{{ {'timestamp': ansible_date_time.iso8601, 'hostname': inventory_hostname, 'tailscale': {'binary': ts_bin.stdout | default('') | trim, 'backend_state': ts_backend_state, 'first_ip': ts_first_ip}, 'ping_matrix': ping_map | default({}), 'ssh_reachability': ssh_map | default({}), 'failed_ping_peers': failed_ping_peers | default([])} | to_nice_json }}" dest: "{{ report_dir }}/{{ inventory_hostname }}_{{ ansible_date_time.date }}.json" delegate_to: localhost changed_when: false