--- - name: "Multi-SSID fix (phase 1: persist + runtime apply)" hosts: all gather_facts: false vars: ssh_user: "{{ ansible_user | default('root') }}" ssh_pass: "{{ ansible_password | default(ansible_ssh_pass) }}" # RabbitMQ (same contract as wifidebug16.yml) 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','CONTROL_QUEUE') | default('queue_controls', true) }}" # Reboot parachute delay (seconds) parachute_delay_s: 600 # Local working paths (controller) local_cfg: "/tmp/{{ inventory_hostname }}_config.json" local_new: "/tmp/{{ inventory_hostname }}_config.json.new" local_step: "/tmp/{{ inventory_hostname }}_config.step" local_diff: "/tmp/{{ inventory_hostname }}_config.diff" local_dd: "/tmp/{{ inventory_hostname }}_dd.json" # External scripts/filters (next to wifidebug.sh in your repo) dd_script_src: "files/wirelessduediligence.sh" f_1vap0_src: "files/filter_1vap_id0.jq" f_1vap1_src: "files/filter_1vap_id1.jq" f_2_01_src: "files/filter_2vaps_0_1.jq" f_2_10_src: "files/filter_2vaps_1_0.jq" # Controller tmp destinations dd_script: "/tmp/wirelessduediligence.sh" f_1vap0: "/tmp/filter_1vap_id0.jq" f_1vap1: "/tmp/filter_1vap_id1.jq" f_2_01: "/tmp/filter_2vaps_0_1.jq" f_2_10: "/tmp/filter_2vaps_1_0.jq" # Remote (device) remote_cfg: "/tmp/config.json" remote_new: "/tmp/config.json.new" remote_backup: "/tmp/config.json.multissidfix1.backup" # Optional new SSID name (set via env NEW_SSID to trigger rename) new_ssid_name: "{{ lookup('env','NEW_SSID') | default('ikeja R5 a day hotspot', true) }}" tasks: ########################################################################## # a) Connectivity + basic sanity ########################################################################## - name: SSH reachability probe raw: "echo ping" register: ping_result ignore_errors: true - name: Stop if SSH unreachable (soft-fail) when: ping_result is failed block: - name: Journal soft-fail (no SSH) delegate_to: localhost 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': 'journal_add', 'task_result': 'multissidfix: soft-fail — SSH not reachable' } | to_json }}" payload_encoding: "string" changed_when: false - meta: end_host - name: Ensure remote /tmp/config.json exists raw: "test -s {{ remote_cfg }}" register: remote_cfg_check changed_when: false - name: Stop if /tmp/config.json missing (soft-fail) when: remote_cfg_check.rc != 0 block: - name: Journal soft-fail (missing config.json) delegate_to: localhost 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': 'journal_add', 'task_result': 'multissidfix: soft-fail — missing /tmp/config.json' } | to_json }}" payload_encoding: "string" changed_when: false - meta: end_host - name: Check controller tools (jq/sshpass/diff/sha256sum) delegate_to: localhost shell: | command -v jq >/dev/null 2>&1 \ && command -v sshpass >/dev/null 2>&1 \ && command -v diff >/dev/null 2>&1 \ && (command -v sha256sum >/dev/null 2>&1 || command -v busybox >/dev/null 2>&1) args: { executable: /bin/bash } register: ctrl_tools changed_when: false - name: Stop if controller tools missing (soft-fail) when: ctrl_tools.rc != 0 block: - name: Journal soft-fail (missing tools) delegate_to: localhost 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': 'journal_add', 'task_result': 'multissidfix: soft-fail — controller missing jq/sshpass/diff/sha256sum' } | to_json }}" payload_encoding: "string" changed_when: false - meta: end_host ########################################################################## # b) Due diligence (external script, run on controller) ########################################################################## - name: Copy due diligence script to controller tmp delegate_to: localhost copy: src: "{{ dd_script_src }}" dest: "{{ dd_script }}" mode: "0755" - name: Pull /tmp/config.json from device to controller delegate_to: localhost command: > sshpass -p {{ ssh_pass | quote }} scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null {{ ssh_user }}@{{ ansible_host }}:{{ remote_cfg }} {{ local_cfg }} - name: Run due diligence on controller delegate_to: localhost shell: "sh {{ dd_script | quote }} {{ local_cfg | quote }} > {{ local_dd | quote }}" args: { executable: /bin/sh } changed_when: false - name: Parse due diligence JSON delegate_to: localhost set_fact: dd: "{{ lookup('file', local_dd) | from_json }}" # Normalize types for robust `when:` checks - name: Extract & normalize dd facts delegate_to: localhost set_fact: w0_total_i: "{{ (dd.radios.wifi0.vaps_total | int) }}" w1_total_i: "{{ (dd.radios.wifi1.vaps_total | int) }}" w0_idx_i: "{{ (dd.radios.wifi0.indices | map('int') | list) }}" w1_idx_i: "{{ (dd.radios.wifi1.indices | map('int') | list) }}" ########################################################################## # c) Soft-fail: layout policy checks (one journal) ########################################################################## - name: Init soft-fail reasons (policy) delegate_to: localhost set_fact: softfail_reasons: [] - name: wifi0 — add reason if ONE_VAP but index not 0/1 delegate_to: localhost when: (w0_total_i | int) == 1 and (0 not in w0_idx_i and 1 not in w0_idx_i) set_fact: softfail_reasons: "{{ softfail_reasons + [ 'wifi0 ONE_VAP but index not 0/1 (indices=' ~ (w0_idx_i | string) ~ ')' ] }}" - name: wifi0 — add reason if TWO_VAPS but indices not (0,1)/(1,0) delegate_to: localhost when: (w0_total_i | int) == 2 and (w0_idx_i | sort not in [[0,1],[1,0]]) set_fact: softfail_reasons: "{{ softfail_reasons + [ 'wifi0 TWO_VAPS but indices not (0,1)/(1,0) (indices=' ~ (w0_idx_i | string) ~ ')' ] }}" - name: wifi0 — add reason if MORE_THAN_TWO_VAPS delegate_to: localhost when: (w0_total_i | int) > 2 set_fact: softfail_reasons: "{{ softfail_reasons + [ 'wifi0 MORE_THAN_TWO_VAPS (indices=' ~ (w0_idx_i | string) ~ ')' ] }}" - name: wifi1 — add reason if ONE_VAP but index not 0/1 delegate_to: localhost when: (w1_total_i | int) == 1 and (0 not in w1_idx_i and 1 not in w1_idx_i) set_fact: softfail_reasons: "{{ softfail_reasons + [ 'wifi1 ONE_VAP but index not 0/1 (indices=' ~ (w1_idx_i | string) ~ ')' ] }}" - name: wifi1 — add reason if TWO_VAPS but indices not (0,1)/(1,0) delegate_to: localhost when: (w1_total_i | int) == 2 and (w1_idx_i | sort not in [[0,1],[1,0]]) set_fact: softfail_reasons: "{{ softfail_reasons + [ 'wifi1 TWO_VAPS but indices not (0,1)/(1,0) (indices=' ~ (w1_idx_i | string) ~ ')' ] }}" - name: wifi1 — add reason if MORE_THAN_TWO_VAPS delegate_to: localhost when: (w1_total_i | int) > 2 set_fact: softfail_reasons: "{{ softfail_reasons + [ 'wifi1 MORE_THAN_TWO_VAPS (indices=' ~ (w1_idx_i | string) ~ ')' ] }}" - name: Publish soft-fail journal & stop host (policy) when: (softfail_reasons | length) > 0 block: - name: Journal soft-fail (policy) delegate_to: localhost 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': 'journal_add', 'task_result': 'multissidfix: soft-fail (policy) — ' ~ (softfail_reasons | join('; ')) } | to_json }}" payload_encoding: "string" changed_when: false - meta: end_host ########################################################################## # d) Filters to controller tmp + candidate generation (per radio) ########################################################################## - name: Copy jq filters to controller tmp delegate_to: localhost copy: src: "{{ item.src }}" dest: "{{ item.dest }}" mode: "0644" loop: - { src: "{{ f_1vap0_src }}", dest: "{{ f_1vap0 }}" } - { src: "{{ f_1vap1_src }}", dest: "{{ f_1vap1 }}" } - { src: "{{ f_2_01_src }}", dest: "{{ f_2_01 }}" } - { src: "{{ f_2_10_src }}", dest: "{{ f_2_10 }}" } - name: Start from current config as working file delegate_to: localhost copy: src: "{{ local_cfg }}" dest: "{{ local_step }}" mode: "0644" # wifi0 choice - name: wifi0 — apply filter 1vap_id0 delegate_to: localhost shell: "jq --arg r wifi0 -f {{ f_1vap0 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}" args: { executable: /bin/bash } when: (w0_total_i | int) == 1 and (0 in w0_idx_i) changed_when: true - name: wifi0 — apply filter 1vap_id1 (Policy B) delegate_to: localhost shell: "jq --arg r wifi0 -f {{ f_1vap1 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}" args: { executable: /bin/bash } when: (w0_total_i | int) == 1 and (1 in w0_idx_i) changed_when: true - name: wifi0 — apply filter 2vaps_0_1 delegate_to: localhost shell: "jq --arg r wifi0 -f {{ f_2_01 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}" args: { executable: /bin/bash } when: (w0_total_i | int) == 2 and (w0_idx_i | sort == [0,1]) changed_when: true - name: wifi0 — apply filter 2vaps_1_0 (actual order 1,0) delegate_to: localhost shell: "jq --arg r wifi0 -f {{ f_2_10 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}" args: { executable: /bin/bash } when: (w0_total_i | int) == 2 and (w0_idx_i == [1,0]) changed_when: true # wifi1 choice - name: wifi1 — apply filter 1vap_id0 delegate_to: localhost shell: "jq --arg r wifi1 -f {{ f_1vap0 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}" args: { executable: /bin/bash } when: (w1_total_i | int) == 1 and (0 in w1_idx_i) changed_when: true - name: wifi1 — apply filter 1vap_id1 (Policy B) delegate_to: localhost shell: "jq --arg r wifi1 -f {{ f_1vap1 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}" args: { executable: /bin/bash } when: (w1_total_i | int) == 1 and (1 in w1_idx_i) changed_when: true - name: wifi1 — apply filter 2vaps_0_1 delegate_to: localhost shell: "jq --arg r wifi1 -f {{ f_2_01 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}" args: { executable: /bin/bash } when: (w1_total_i | int) == 2 and (w1_idx_i | sort == [0,1]) changed_when: true - name: wifi1 — apply filter 2vaps_1_0 (actual order 1,0) delegate_to: localhost shell: "jq --arg r wifi1 -f {{ f_2_10 | quote }} {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }}" args: { executable: /bin/bash } when: (w1_total_i | int) == 2 and (w1_idx_i == [1,0]) changed_when: true # Optional SSID rename (only if new_ssid_name is defined) - name: Set SSID for the single enabled AP VAP per radio (wifi0 & wifi1) delegate_to: localhost shell: | jq --arg SSID "{{ new_ssid_name }}" ' .wireless.radios |= with_entries( .value.vaps = ((.value.vaps // []) | map( if (.mode=="ap" and (.enabled==true)) then (.ssid = $SSID) else . end )) ) ' {{ local_step | quote }} > {{ local_step | quote }}.next && mv {{ local_step | quote }}.next {{ local_step | quote }} args: { executable: /bin/bash } when: new_ssid_name is defined changed_when: true - name: Debug normalized types delegate_to: localhost debug: msg: - "w0_total_i(type)={{ w0_total_i | type_debug }} value={{ w0_total_i }}" - "w1_total_i(type)={{ w1_total_i | type_debug }} value={{ w1_total_i }}" - name: Move working file to final candidate delegate_to: localhost copy: src: "{{ local_step }}" dest: "{{ local_new }}" mode: "0644" ########################################################################## # e) Candidate checks — accumulate reasons; soft-fail once if any ########################################################################## - name: Init soft-fail reasons (candidate) delegate_to: localhost set_fact: cand_reasons: [] - name: Check JSON validity delegate_to: localhost shell: "jq -e '.' {{ local_new | quote }} >/dev/null" args: { executable: /bin/bash } register: json_valid changed_when: false failed_when: false - name: Add reason if JSON invalid delegate_to: localhost when: json_valid.rc != 0 set_fact: cand_reasons: "{{ cand_reasons + [ 'invalid JSON syntax in candidate' ] }}" - name: Semantic — wifi0 exactly one enabled AP VAP delegate_to: localhost shell: | jq -e '(.wireless.radios.wifi0.vaps // []) | map(select(.mode=="ap" and (.enabled==true))) | length == 1' {{ local_new | quote }} >/dev/null args: { executable: /bin/bash } register: sem_w0 changed_when: false failed_when: false - name: Add reason if wifi0 semantic fails delegate_to: localhost when: sem_w0.rc != 0 set_fact: cand_reasons: "{{ cand_reasons + [ 'wifi0 must have exactly one enabled AP VAP' ] }}" - name: Semantic — wifi1 exactly one enabled AP VAP delegate_to: localhost shell: | jq -e '(.wireless.radios.wifi1.vaps // []) | map(select(.mode=="ap" and (.enabled==true))) | length == 1' {{ local_new | quote }} >/dev/null args: { executable: /bin/bash } register: sem_w1 changed_when: false failed_when: false - name: Add reason if wifi1 semantic fails delegate_to: localhost when: sem_w1.rc != 0 set_fact: cand_reasons: "{{ cand_reasons + [ 'wifi1 must have exactly one enabled AP VAP' ] }}" - name: Semantic — wifi0 enabled AP VAP has lbd=true delegate_to: localhost shell: | jq -e '(.wireless.radios.wifi0.vaps // []) | map(select(.mode=="ap" and (.enabled==true) and (.lbd==true))) | length == 1' {{ local_new | quote }} >/dev/null args: { executable: /bin/bash } register: sem_w0_lbd changed_when: false failed_when: false - name: Add reason if wifi0 lbd check fails delegate_to: localhost when: sem_w0_lbd.rc != 0 set_fact: cand_reasons: "{{ cand_reasons + [ 'wifi0 enabled AP VAP must have lbd=true' ] }}" - name: Semantic — wifi1 enabled AP VAP has lbd=true delegate_to: localhost shell: | jq -e '(.wireless.radios.wifi1.vaps // []) | map(select(.mode=="ap" and (.enabled==true) and (.lbd==true))) | length == 1' {{ local_new | quote }} >/dev/null args: { executable: /bin/bash } register: sem_w1_lbd changed_when: false failed_when: false - name: Add reason if wifi1 lbd check fails delegate_to: localhost when: sem_w1_lbd.rc != 0 set_fact: cand_reasons: "{{ cand_reasons + [ 'wifi1 enabled AP VAP must have lbd=true' ] }}" - name: Get original file size delegate_to: localhost stat: path: "{{ local_cfg }}" register: stat_old changed_when: false - name: Get new file size delegate_to: localhost stat: path: "{{ local_new }}" register: stat_new changed_when: false - name: Add reason if size delta > 2% delegate_to: localhost when: stat_old.stat.size | int == 0 or ( ((stat_new.stat.size | int) - (stat_old.stat.size | int)) | abs ) > ( (stat_old.stat.size | int) * 0.02 ) set_fact: cand_reasons: "{{ cand_reasons + [ 'candidate size delta >2% (old=' ~ (stat_old.stat.size | string) ~ ', new=' ~ (stat_new.stat.size | string) ~ ')' ] }}" - name: Build unified diff (first 200 lines) delegate_to: localhost shell: "diff -u {{ local_cfg | quote }} {{ local_new | quote }} | sed -n '1,200p' > {{ local_diff | quote }} || true" args: { executable: /bin/bash } changed_when: false - name: Show unified diff (first 200 lines) delegate_to: localhost debug: msg: "{{ lookup('file', local_diff) | default('(no diff output)') }}" - name: sha256 (controller) of candidate delegate_to: localhost command: sha256sum {{ local_new }} register: sha_local changed_when: false - name: Copy candidate to device temp delegate_to: localhost command: > sshpass -p {{ ssh_pass | quote }} scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null {{ local_new }} {{ ssh_user }}@{{ ansible_host }}:{{ remote_new }} register: scp_new changed_when: true - name: sha256 (remote) of candidate raw: "sha256sum {{ remote_new }} || busybox sha256sum {{ remote_new }}" register: sha_remote changed_when: false - name: Add reason if sha256 mismatch delegate_to: localhost when: (sha_local.stdout.split()[0]) != (sha_remote.stdout.split()[0]) set_fact: cand_reasons: "{{ cand_reasons + [ 'sha256 mismatch controller vs remote' ] }}" - name: Detect SSIDs with NBSP (informational) delegate_to: localhost shell: | nbsp="$(printf '\302\240')" jq -r '..|objects|.ssid? // empty' {{ local_new | quote }} | grep -F "$nbsp" || true args: { executable: /bin/bash } register: nbsp_lines changed_when: false - name: Save NBSP report lines set_fact: ssid_nbsp_lines: "{{ nbsp_lines.stdout_lines | default([]) }}" - name: Publish soft-fail journal & stop host (candidate issues) when: (cand_reasons | length) > 0 block: - name: Journal soft-fail (candidate) delegate_to: localhost 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': 'journal_add', 'task_result': 'multissidfix: soft-fail (candidate) — ' ~ (cand_reasons | join('; ')) } | to_json }}" payload_encoding: "string" changed_when: false - meta: end_host ########################################################################## # f) Backup, parachute, promote, persist, apply, outcome ########################################################################## - name: Backup current config on device raw: "cp -a {{ remote_cfg }} {{ remote_backup }}" changed_when: true # --- PRE-APPLY HEADS-UP --- - name: Journal — candidate validated, will promote/persist/apply shortly delegate_to: localhost 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': 'journal_add', 'task_result': ( 'multissidfix: candidate passed checks; proceeding to backup + parachute + promote + sysconf -w + system-stop/start. ' ~ 'size(old/new)=' ~ stat_old.stat.size|string ~ '/' ~ stat_new.stat.size|string ) } | to_json }}" payload_encoding: "string" changed_when: false - name: Arm reboot parachute (BusyBox reboot -d {{ parachute_delay_s }}) raw: "sh -c 'trap \"\" HUP; reboot -d {{ parachute_delay_s }} /dev/null 2>&1 &'" changed_when: true ignore_errors: true - name: Promote candidate to active config raw: "mv {{ remote_new }} {{ remote_cfg }} && chown root:root {{ remote_cfg }} && chmod 0644 {{ remote_cfg }}" changed_when: true # IMPORTANT: persist BEFORE runtime restart - name: Persist config to flash (sysconf -w) raw: "sysconf -w" register: sysconf_write changed_when: true failed_when: false # --- PRE-RESTART COMMIT --- - name: Pre-restart journal (promoted & persisted; about to restart from controller) delegate_to: localhost 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': 'journal_add', 'task_result': ( 'multissidfix: promoted candidate, persisted (sysconf -w rc=' ~ (sysconf_write.rc | default('n/a')) | string ~ '), ' ~ 'system-stop/start launching from controller with 10s cap; ' ~ 'parachute -d ' ~ parachute_delay_s|string ~ 's armed. ' ~ 'size(old/new)=' ~ stat_old.stat.size|string ~ '/' ~ stat_new.stat.size|string ~ ', nbsp_ssids=' ~ (ssid_nbsp_lines|length)|string ) } | to_json }}" payload_encoding: "string" changed_when: false - name: Launch restart from controller with 10s cap (SSH command) delegate_to: localhost shell: "timeout 10s sshpass -p {{ ssh_pass | quote }} ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ServerAliveInterval=2 -o ServerAliveCountMax=1 {{ ssh_user }}@{{ ansible_host }} '/usr/sbin/system-stop ; sleep 1 ; /usr/sbin/system-start'" args: executable: /bin/bash register: restart_kick changed_when: true failed_when: false - name: Note restart_kick result (debug) delegate_to: localhost debug: msg: - "restart_kick.rc={{ restart_kick.rc }}" - "stdout(last 5 lines): {{ (restart_kick.stdout_lines | default([]))[-5:] | default([]) }}" - "stderr(last 5 lines): {{ (restart_kick.stderr_lines | default([]))[-5:] | default([]) }}" # probe SSH with nc: up to 24 tries, 5s each (nc timeout -w 3) - name: Probe SSH with nc (24 tries, 5s each) delegate_to: localhost shell: "nc -z -w 3 {{ ansible_host }} 22" register: nc_probe retries: 24 delay: 5 until: nc_probe.rc == 0 changed_when: false failed_when: false - name: Set ssh_up fact delegate_to: localhost set_fact: ssh_up: "{{ (nc_probe.rc | default(1)) == 0 }}" # --- SUCCESS PATH --- - name: Post-return actions (only if SSH came back) when: ssh_up | bool block: - name: Disarm delayed reboot if present raw: "pgrep -x reboot && kill -9 $(pgrep -x reboot) || true" changed_when: true ignore_errors: true register: disarm_reboot # Re-run simple semantic checks on the active config - name: Device semantic — wifi0 exactly one enabled AP VAP raw: | jq -e '(.wireless.radios.wifi0.vaps // []) | map(select(.mode=="ap" and (.enabled==true))) | length == 1' {{ remote_cfg }} >/dev/null register: dev_sem_w0 changed_when: false failed_when: false - name: Device semantic — wifi1 exactly one enabled AP VAP raw: | jq -e '(.wireless.radios.wifi1.vaps // []) | map(select(.mode=="ap" and (.enabled==true))) | length == 1' {{ remote_cfg }} >/dev/null register: dev_sem_w1 changed_when: false failed_when: false - name: Device semantic — wifi0 enabled AP VAP has lbd=true raw: | jq -e '(.wireless.radios.wifi0.vaps // []) | map(select(.mode=="ap" and (.enabled==true) and (.lbd==true))) | length == 1' {{ remote_cfg }} >/dev/null register: dev_sem_w0_lbd changed_when: false failed_when: false - name: Device semantic — wifi1 enabled AP VAP has lbd=true raw: | jq -e '(.wireless.radios.wifi1.vaps // []) | map(select(.mode=="ap" and (.enabled==true) and (.lbd==true))) | length == 1' {{ remote_cfg }} >/dev/null register: dev_sem_w1_lbd changed_when: false failed_when: false - name: sha256 (remote) of current active config (post-return) raw: "sha256sum {{ remote_cfg }} || busybox sha256sum {{ remote_cfg }}" register: sha_remote_after changed_when: false failed_when: false - name: Decide post-return checks summary (controller side) delegate_to: localhost set_fact: multissid_post_summary: reboot_disarmed: "{{ (disarm_reboot is defined) and (disarm_reboot.rc is defined) and (disarm_reboot.rc in [0]) }}" persisted_ok: "{{ (sysconf_write.rc | default(0)) == 0 }}" dev_sem_ok: w0_one: "{{ (dev_sem_w0.rc | default(1)) == 0 }}" w1_one: "{{ (dev_sem_w1.rc | default(1)) == 0 }}" w0_lbd: "{{ (dev_sem_w0_lbd.rc | default(1)) == 0 }}" w1_lbd: "{{ (dev_sem_w1_lbd.rc | default(1)) == 0 }}" cfg_hash_match: "{{ (sha_remote_after.stdout.split()[0] | default('')) == (sha_remote.stdout.split()[0] | default('')) }}" old_size: "{{ stat_old.stat.size | default('n/a') }}" new_size: "{{ stat_new.stat.size | default('n/a') }}" nbsp_count: "{{ (ssid_nbsp_lines | default([])) | length }}" - name: Publish final success journal (device returned; persisted; checks pass) delegate_to: localhost 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': 'journal_add', 'task_result': ( 'multissidfix: success — device is back; ' ~ (multissid_post_summary.reboot_disarmed | ternary('parachute disarmed; ', 'parachute was not armed; ')) ~ (multissid_post_summary.persisted_ok | ternary('persisted (sysconf -w); ', 'persist failed; ')) ~ 'semantics: ' ~ 'w0_one=' ~ (multissid_post_summary.dev_sem_ok.w0_one | string) ~ ', ' ~ 'w1_one=' ~ (multissid_post_summary.dev_sem_ok.w1_one | string) ~ ', ' ~ 'w0_lbd=' ~ (multissid_post_summary.dev_sem_ok.w0_lbd | string) ~ ', ' ~ 'w1_lbd=' ~ (multissid_post_summary.dev_sem_ok.w1_lbd | string) ~ '; ' ~ 'cfg_match=' ~ (multissid_post_summary.cfg_hash_match | string) ~ '; ' ~ 'size(old/new)=' ~ (multissid_post_summary.old_size | string) ~ '/' ~ (multissid_post_summary.new_size | string) ~ '; ' ~ 'nbsp_ssids=' ~ (multissid_post_summary.nbsp_count | string) ) } | to_json }}" payload_encoding: "string" changed_when: false - name: Set NetBox custom field multissidfix=v1.1 delegate_to: localhost 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': 'multissidfix', 'task_result': 'v1.1' } | to_json }}" payload_encoding: "string" changed_when: false # --- FAILURE PATH (device did not return) --- - name: Journal — restart/SSH probe failed when: not ssh_up delegate_to: localhost 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': 'journal_add', 'task_result': ( 'multissidfix: restart issued; SSH did not return after 24 x 5s checks — leaving parachute active.' ) } | to_json }}" payload_encoding: "string" changed_when: false - name: Stop host after failed SSH probe when: not ssh_up meta: end_host