From 212e82b62543ad50eac5df1d63bd37926f280072 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 24 Oct 2025 09:15:13 +0300 Subject: [PATCH] adding sot-updater --- files/ansible-playbooks/sot-updater.yml | 314 ++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 files/ansible-playbooks/sot-updater.yml diff --git a/files/ansible-playbooks/sot-updater.yml b/files/ansible-playbooks/sot-updater.yml new file mode 100644 index 0000000..740bd94 --- /dev/null +++ b/files/ansible-playbooks/sot-updater.yml @@ -0,0 +1,314 @@ +--- +# updater.yml — read Dev1 & Dev2 firmware versions and publish to controls → netbox-reporter +# Run: nbplay 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 (as in update-rebootin222) + pathprefix: "PATH=/sbin:/usr/sbin:/bin:/usr/bin:$PATH; " + + # DEV1 credentials (as in update-indoor) + dev1_user: "root" + dev1_pass: "wavewave" + + # Tunnel target DEV2 behind DEV1 (as in update-indoor) + 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" + + # Password files sequence (as in update-indoor: try basicpass then basicpass2) + # NOTE: The selection below mirrors the exact “try #1 → select → try #2 → select → NONE” flow from update-indoor. + # Do not alter ordering. + # (Values are file names as used in your repo/environment.) + # We do not loop; we replicate the same task structure. + # The read of firmux uses whichever got selected. + # — Pavel’s rule: on-device commands remain immutable; we’re only orchestrating controller-side steps here. + + # Rabbit journaling (mirrors update-indoor / rebootin222) + 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: + # ----------------------------- DEV1: read current banner (exactly like update-rebootin222) ----------------------------- + - 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 | Normalize '... rev NNNN' → '...-rNNNN' + delegate_to: localhost + ansible.builtin.set_fact: + dev1_fw_norm: >- + {{ + (dev1_banner.stdout | default('') | trim) + | regex_replace('\\s*[Rr][Ee][Vv]\\.??\\s*([0-9]+)\\s*$', '-r\\1') + }} + + - 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_norm | default('unknown')) + } | to_json }}" + payload_encoding: "string" + changed_when: false + + # ----------------------------- TUNNEL PREP (update-indoor blocks copied) ----------------------------- + - 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: 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: 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 and create ControlMaster socket dir + delegate_to: localhost + ansible.builtin.set_fact: + _local_port: "{{ pick_port.stdout | trim }}" + _ctrl_dir: "{{ lookup('ansible.builtin.pipe', 'mktemp -d') }}" + _ctrl_sock: "{{ _ctrl_dir }}/ssh_tunnel_ctl" + + - 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: 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: Stop if tunnel TCP probe failed + ansible.builtin.meta: end_host + when: nc_probe.rc != 0 + + # ----------------------------- DEV2 AUTH PICK (exact task sequence from 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: 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: 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 + + # ----------------------------- DEV2 firmware read (update-indoor firmux primary check) ----------------------------- + - 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 | Normalize to X.Y.Z-rNNNN (convert trailing 'rev NNNN' → '-rNNNN') + 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: 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" + changed_when: false + + post_tasks: + - 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('')))