--- # sot-updater.yml — Read Dev1 & Dev2 firmware and publish to controls → netbox-reporter # Run: nbplay sot-updater.yml - name: Read fw on Dev1 + Dev2, publish NetBox custom fields hosts: all gather_facts: no vars: # Busybox-safe PATH prefix for raw calls on DEV1 pathprefix: "PATH=/sbin:/usr/sbin:/bin:/usr/bin:$PATH; " # DEV1 credentials for tunnel dev1_user: "root" dev1_pass: "wavewave" # Tunnel target DEV2 behind DEV1 dev2_host: "192.168.1.1" dev2_port: 22 dev2_ssh_user: "root" dev2_side_ip: "192.168.1.11/24" dev1_iface: "br-wan" # Rabbit journaling defaults rmq_host: "{{ lookup('env','RMQ_HOST') | default('10.210.12.2', true) }}" rmq_port: "{{ lookup('env','RMQ_PORT') | default('15672', true) }}" rmq_user: "{{ lookup('env','RMQ_USER') | default('admin', true) }}" rmq_pass: "{{ lookup('env','RMQ_PASS') | default('change_me', true) }}" rmq_vhost: "{{ lookup('env','RMQ_VHOST') | default('app', true) }}" rmq_exchange: "{{ lookup('env','RMQ_EXCHANGE') | default('controls', true) }}" control_queue: "{{ lookup('env','CONTROLQUEUE') | default('queue_controls', true) }}" tasks: # ───────────────────────── Progress: start ───────────────────────── - name: "⚙️ Start | Device processing begins (Dev1 phase)" debug: msg: - "Device: {{ inventory_hostname }}" - "Phase: Dev1 (read banner & publish fw_version)" # ───────────────────────── DEV1 ───────────────────────── - name: Dev1 | Read first /etc/banner line containing 'rev' ansible.builtin.raw: "{{ pathprefix }} cat /etc/banner | grep -i rev | head -n1" register: dev1_banner changed_when: false - name: Dev1 | Debug banner output debug: msg: - "Dev1 banner raw: {{ (dev1_banner.stdout | default('') | trim) | regex_replace('\\n',' ') }}" # Extract exactly "X.Y.Z rev NNNN" by dropping anything after the pipe - name: Dev1 | Extract 'X.Y.Z rev NNNN' by removing trailing timestamp delegate_to: localhost set_fact: dev1_fw_clean: "{{ (dev1_banner.stdout | default('') | trim) | regex_replace('\\s*\\|.*$', '') }}" - name: Dev1 | Debug normalized fw string to be published delegate_to: localhost debug: msg: - "Dev1 fw_version (clean): {{ dev1_fw_clean }}" # Build payload preview for Dev1 (so the next debug is defined) - name: Dev1 | Build payload preview string delegate_to: localhost set_fact: dev1_payload_preview: >- {{ { "properties": {"content_type": "application/json"}, "routing_key": control_queue, "payload": ({ "inscope_device": (ansible_hostname | default(inventory_hostname)), "task_name": "custom_field_set", "task_add1": "fw_version", "task_result": dev1_fw_clean } | to_json), "payload_encoding": "string" } | to_json }} - name: Dev1 | Debug EXACT publish body (payload preview) when: dev1_payload_preview is defined delegate_to: localhost debug: var: dev1_payload_preview - name: Publish → controls | custom_field_set fw_version (Dev1) delegate_to: localhost ansible.builtin.uri: url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost | urlencode }}/{{ rmq_exchange | urlencode }}/publish" method: POST user: "{{ rmq_user }}" password: "{{ rmq_pass }}" force_basic_auth: true status_code: 200 headers: { content-type: "application/json" } body_format: json body: properties: { content_type: "application/json" } routing_key: "{{ control_queue }}" payload: "{{ { 'inscope_device': (ansible_hostname | default(inventory_hostname)), 'task_name': 'custom_field_set', 'task_add1': 'fw_version', 'task_result': dev1_fw_clean } | to_json }}" payload_encoding: "string" register: dev1_publish_resp changed_when: false - name: Dev1 | Debug publish API response delegate_to: localhost debug: msg: - "Dev1 publish response: {{ dev1_publish_resp.json | default(dev1_publish_resp) }}" - name: "✅ Progress | Completed Dev1, moving to tunnel & Dev2 phase" debug: msg: - "Device: {{ inventory_hostname }}" - "Phase switch: Dev1 done → Dev2 (tunnel, auth, read indoor_fwver)" # ───────────────────────── TUNNEL PREP ───────────────────────── - name: Add temporary IP on DEV1 (tolerate 'File exists') ansible.builtin.raw: > {{ pathprefix }} ip a add {{ dev2_side_ip }} dev {{ dev1_iface }} register: add_ip changed_when: add_ip.rc == 0 failed_when: add_ip.rc != 0 and ('File exists' not in (add_ip.stdout | default(''))) and ('File exists' not in (add_ip.stderr | default(''))) - name: Debug | temp IP add result debug: msg: - "Add IP rc={{ add_ip.rc }}" - "stdout={{ (add_ip.stdout | default('')) | trim }}" - "stderr={{ (add_ip.stderr | default('')) | trim }}" - name: Pick a free local TCP port for the tunnel (controller) delegate_to: localhost ansible.builtin.shell: | set -e pick() { for i in $(seq 1 25); do p="$(shuf -i 20000-39999 -n 1)" if command -v ss >/dev/null 2>&1; then if ! ss -ltn | awk '{print $4}' | grep -qE "(:|\.)${p}$"; then echo "$p"; return 0 fi else if ! nc -z 127.0.0.1 "$p" >/dev/null 2>&1; then echo "$p"; return 0 fi fi done return 1 } pick register: pick_port changed_when: false - name: Debug | chosen local tunnel port delegate_to: localhost debug: msg: - "Chosen local port: {{ pick_port.stdout | trim }}" - name: Stop if no free local port was found ansible.builtin.meta: end_host when: (pick_port.stdout | trim | length) == 0 - name: Record chosen local port delegate_to: localhost set_fact: _local_port: "{{ pick_port.stdout | trim }}" - name: Create ControlMaster socket dir (mktemp) delegate_to: localhost set_fact: _ctrl_dir: "{{ lookup('pipe', 'mktemp -d') }}" - name: Compose ControlMaster socket path delegate_to: localhost set_fact: _ctrl_sock: "{{ _ctrl_dir }}/ssh_tunnel_ctl" - name: Debug | ControlMaster paths delegate_to: localhost debug: msg: - "_ctrl_dir={{ _ctrl_dir }}" - "_ctrl_sock={{ _ctrl_sock }}" - name: Start SSH ControlMaster and forward 127.0.0.1:local → DEV2:22 via DEV1 delegate_to: localhost ansible.builtin.shell: | set -e USER="{{ dev1_user }}" HOST="{{ ansible_host | default(inventory_hostname) }}" sshpass -p '{{ dev1_pass }}' ssh -f -N \ -o PreferredAuthentications=password -o PubkeyAuthentication=no \ -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o NumberOfPasswordPrompts=1 -o ConnectTimeout=30 \ -M -S "{{ _ctrl_sock }}" \ -L "127.0.0.1:{{ _local_port }}:{{ dev2_host }}:{{ dev2_port }}" \ "${USER}@${HOST}" args: { executable: /bin/bash } register: start_tunnel changed_when: true - name: Debug | tunnel start status delegate_to: localhost debug: msg: - "Started ControlMaster tunnel (rc={{ start_tunnel.rc | default('n/a') }})" - "stdout={{ (start_tunnel.stdout | default('')) | trim }}" - "stderr={{ (start_tunnel.stderr | default('')) | trim }}" - name: Probe TCP reachability to DEV2 through the tunnel delegate_to: localhost ansible.builtin.shell: "nc -z -w5 127.0.0.1 {{ _local_port }}" register: nc_probe changed_when: false ignore_errors: true - name: Debug | tunnel probe result delegate_to: localhost debug: msg: - "Tunnel TCP probe rc={{ nc_probe.rc }} (0=OK)" - "stdout={{ (nc_probe.stdout | default('')) | trim }}" - "stderr={{ (nc_probe.stderr | default('')) | trim }}" - name: Stop if tunnel TCP probe failed ansible.builtin.meta: end_host when: nc_probe.rc != 0 - name: "✅ Progress | Tunnel OK, proceeding to Dev2 auth and reads" debug: msg: - "Device: {{ inventory_hostname }}" - "Phase: Dev2 (auth selection, firmux/banner read)" # ───────────────────────── DEV2 AUTH PICK (as per update-indoor) ───────────────────────── - name: Try DEV2 login with 'basicpass' (root) delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f basicpass ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -p "$PORT" root@127.0.0.1 echo OK >/dev/null 2>&1 register: dev2_try_basicpass changed_when: false ignore_errors: true - name: Debug | basicpass try rc delegate_to: localhost debug: msg: "DEV2 'basicpass' try rc={{ dev2_try_basicpass.rc }} (0=OK)" - name: Select 'basicpass' if previous login succeeded when: dev2_try_basicpass.rc == 0 delegate_to: localhost ansible.builtin.set_fact: dev2_passfile_used: "basicpass" changed_when: false - name: Try DEV2 login with 'basicpass2' (only if first failed) when: dev2_passfile_used is not defined delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f basicpass2 ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -p "$PORT" root@127.0.0.1 echo OK >/dev/null 2>&1 register: dev2_try_basicpass2 changed_when: false ignore_errors: true - name: Debug | basicpass2 try rc delegate_to: localhost debug: msg: "DEV2 'basicpass2' try rc={{ dev2_try_basicpass2.rc | default('n/a') }} (0=OK)" - name: Select 'basicpass2' if previous login succeeded when: dev2_passfile_used is not defined and dev2_try_basicpass2.rc == 0 delegate_to: localhost ansible.builtin.set_fact: dev2_passfile_used: "basicpass2" changed_when: false - name: Mark DEV2 auth as NONE if both attempts failed when: dev2_passfile_used is not defined delegate_to: localhost ansible.builtin.set_fact: dev2_passfile_used: "NONE" changed_when: false - name: Debug | selected passfile delegate_to: localhost debug: msg: "DEV2 passfile used: {{ dev2_passfile_used }}" # ───────────────────────── DEV2 READS ───────────────────────── - name: Dev2 | Read /usr/lib/release/firmux (if present) when: dev2_passfile_used != "NONE" delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "cat /usr/lib/release/firmux 2>/dev/null || true" args: { executable: /bin/bash } register: dev2_firmux changed_when: false ignore_errors: true - name: Dev2 | Fallback to banner 'rev' if firmux not available when: dev2_passfile_used != "NONE" and ((dev2_firmux.stdout | default('') | trim) | length == 0) delegate_to: localhost ansible.builtin.shell: | set -e PORT="{{ _local_port }}" sshpass -f "{{ dev2_passfile_used }}" ssh \ -o AddressFamily=inet \ -o StrictHostKeyChecking=no -o PubkeyAuthentication=no \ -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 \ -o ConnectTimeout=30 \ -p "$PORT" "{{ dev2_ssh_user }}@127.0.0.1" \ "grep -i rev /etc/banner 2>/dev/null | head -n1 || true" args: { executable: /bin/bash } register: dev2_banner changed_when: false ignore_errors: true - name: Dev2 | Choose raw string (firmux preferred, else banner) delegate_to: localhost ansible.builtin.set_fact: indoor_fw_raw: >- {{ (dev2_firmux.stdout | default('') | trim) if ((dev2_firmux.stdout | default('') | trim) | length > 0) else (dev2_banner.stdout | default('') | trim) }} - name: Dev2 | Debug raw indoor fw string delegate_to: localhost debug: msg: - "Dev2 indoor_fw_raw: {{ indoor_fw_raw }}" - name: Dev2 | Normalize to 'X.Y.Z rev NNNN' or already '-rNNNN' style delegate_to: localhost ansible.builtin.set_fact: indoor_fw_norm: >- {{ ((indoor_fw_raw | default('') | trim | lower) is search('-r[0-9]+$')) | ternary( (indoor_fw_raw | default('') | trim), ((indoor_fw_raw | default('') | trim) | regex_replace('\\s*[Rr][Ee][Vv]\\.??\\s*([0-9]+)\\s*$', '-r\\1')) ) }} - name: Dev2 | Debug normalized indoor fw string delegate_to: localhost debug: msg: - "Dev2 indoor_fw_norm: {{ indoor_fw_norm }}" # Build payload preview (exact string) — single, corrected task - name: Dev2 | Build payload preview string when: - dev2_passfile_used is defined - dev2_passfile_used != "NONE" - indoor_fw_norm is defined - (indoor_fw_norm | length) > 0 delegate_to: localhost set_fact: dev2_payload_preview: >- {{ { "properties": {"content_type": "application/json"}, "routing_key": control_queue, "payload": ({ "inscope_device": (ansible_hostname | default(inventory_hostname)), "task_name": "custom_field_set", "task_add1": "indoor_fwver", "task_result": indoor_fw_norm } | to_json), "payload_encoding": "string" } | to_json }} - name: Dev2 | Debug EXACT publish body (payload preview) when: - dev2_passfile_used != "NONE" - dev2_payload_preview is defined delegate_to: localhost debug: var: dev2_payload_preview - name: Publish → controls | custom_field_set indoor_fwver (Dev2) when: - dev2_passfile_used != "NONE" - indoor_fw_norm is defined - (indoor_fw_norm | length) > 0 delegate_to: localhost ansible.builtin.uri: url: "http://{{ rmq_host }}:{{ rmq_port }}/api/exchanges/{{ rmq_vhost | urlencode }}/{{ rmq_exchange | urlencode }}/publish" method: POST user: "{{ rmq_user }}" password: "{{ rmq_pass }}" force_basic_auth: true status_code: 200 headers: { content-type: "application/json" } body_format: json body: properties: { content_type: "application/json" } routing_key: "{{ control_queue }}" payload: "{{ { 'inscope_device': (ansible_hostname | default(inventory_hostname)), 'task_name': 'custom_field_set', 'task_add1': 'indoor_fwver', 'task_result': indoor_fw_norm } | to_json }}" payload_encoding: "string" register: dev2_publish_resp changed_when: false - name: Dev2 | Debug publish API response when: dev2_publish_resp is defined delegate_to: localhost debug: msg: - "Dev2 publish response: {{ dev2_publish_resp.json | default(dev2_publish_resp) }}" post_tasks: - name: "✅ Progress | Finishing up (cleanup)" debug: msg: - "Cleanup phase: closing ControlMaster & removing temp IP" - name: Close SSH ControlMaster (best-effort) delegate_to: localhost ansible.builtin.shell: "ssh -S '{{ _ctrl_sock | default('/dev/null') }}' -O exit 2>/dev/null || true" changed_when: false ignore_errors: true - name: Remove tunnel control dir (best-effort) delegate_to: localhost ansible.builtin.file: path: "{{ _ctrl_dir | default('/tmp/none') }}" state: absent ignore_errors: true - name: Remove temporary IP on DEV1 (tolerate 'Cannot assign requested address') ansible.builtin.raw: > {{ pathprefix }} ip a del {{ dev2_side_ip }} dev {{ dev1_iface }} register: del_ip changed_when: del_ip.rc == 0 failed_when: del_ip.rc != 0 and ('Cannot assign requested address' not in (del_ip.stdout | default(''))) and ('Cannot assign requested address' not in (del_ip.stderr | default(''))) - name: Debug | temp IP del result debug: msg: - "Del IP rc={{ del_ip.rc }}" - "stdout={{ (del_ip.stdout | default('')) | trim }}" - "stderr={{ (del_ip.stderr | default('')) | trim }}" - name: "✅ Progress | Completed device processing" debug: msg: - "Device: {{ inventory_hostname }}" - "Status: DONE (Dev1+Dev2 processed)"