From bd1d536bf9e295437b7fcef8ab2f5722a72c1c20 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 6 Feb 2026 10:36:31 +0200 Subject: [PATCH] 1036 --- files/ansible-playbooks/connstats-deploy.yml | 198 ++++++ files/files/connstats.sh | 656 +++++++++++++++++++ 2 files changed, 854 insertions(+) create mode 100644 files/ansible-playbooks/connstats-deploy.yml create mode 100644 files/files/connstats.sh diff --git a/files/ansible-playbooks/connstats-deploy.yml b/files/ansible-playbooks/connstats-deploy.yml new file mode 100644 index 0000000..793cb59 --- /dev/null +++ b/files/ansible-playbooks/connstats-deploy.yml @@ -0,0 +1,198 @@ +--- +- name: Deploy connstats (single device, linear) + hosts: all + gather_facts: no + + vars: + ssh_user: "{{ ansible_user | default('root') }}" + ssh_pass: "{{ ansible_password | default(ansible_ssh_pass) }}" + + # RabbitMQ (use controls exchange + queue_controls like the reference) + 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) }}" + + # Cron line we must ensure (preserve any other lines) + connstats_cron_line: "*/10 * * * * /root/connstats.sh --debug --always-find-offset >/dev/null 2>&1" + + tasks: + + - block: + + - name: Check SSH connectivity (raw ping) + raw: "echo ping" + register: ping_result + ignore_errors: true + + - block: + + # Step 2 fork + - name: Compute MD5 of local connstats.sh + delegate_to: localhost + command: md5sum files/connstats.sh + register: md5_local_cstats + changed_when: false + + - name: Compute MD5 of remote /root/connstats.sh + raw: "md5sum /root/connstats.sh || busybox md5sum /root/connstats.sh" + register: md5_remote_cstats + changed_when: false + failed_when: false + + - name: Decide if connstats.sh needs upload + set_fact: + upload_cstats: >- + {{ (md5_remote_cstats.rc != 0) + or ((md5_local_cstats.stdout.split()[0]) + != (md5_remote_cstats.stdout.split()[0] if (md5_remote_cstats.stdout is defined) else '')) }} + + - name: Upload connstats.sh via scp (overwrite if changed) + when: upload_cstats | bool + delegate_to: localhost + command: > + sshpass -p {{ ssh_pass | quote }} + scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null + files/connstats.sh + {{ ssh_user }}@{{ ansible_host }}:/root/connstats.sh + register: scp_connstats + retries: 3 + delay: 2 + until: scp_connstats.rc == 0 + + - name: Ensure /root/connstats.sh is executable and owned by root + raw: | + chown root:root /root/connstats.sh && chmod 0755 /root/connstats.sh + + # Step 3 fork (cron entry only, preserve anything else) + - name: Ensure /etc/crontabs/root exists (touch with perms) + raw: | + if [ ! -f /etc/crontabs/root ]; then + touch /etc/crontabs/root + fi + chown root:root /etc/crontabs/root + chmod 0644 /etc/crontabs/root + + - name: Check if connstats cron line already present + raw: | + grep -Fxq '{{ connstats_cron_line }}' /etc/crontabs/root + register: cron_grep + failed_when: false + changed_when: false + + - name: Upload snippet connstats-crond-root to /tmp (only if missing) + when: cron_grep.rc != 0 + delegate_to: localhost + command: > + sshpass -p {{ ssh_pass | quote }} + scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null + files/connstats-crond-root + {{ ssh_user }}@{{ ansible_host }}:/tmp/connstats-crond-root.snippet + + - name: Append snippet to /etc/crontabs/root (only if missing) + when: cron_grep.rc != 0 + raw: | + cat /tmp/connstats-crond-root.snippet >> /etc/crontabs/root && rm -f /tmp/connstats-crond-root.snippet + register: cron_append + changed_when: true + + - name: Set result status (success deployed or no change) + set_fact: + result_status: "{{ 'SUCCESS_DEPLOYED' if (upload_cstats | bool) else 'SUCCESS_NO_CHANGE' }}" + + when: ping_result is succeeded + + - name: Set status fact (no ssh) + when: ping_result is failed + set_fact: + result_status: "NO_SSH" + + rescue: + - name: Mark result as failed + set_fact: + result_status: "FAILED during {{ ansible_failed_task.name }}" + + always: + + - name: Compute inscope device + set_fact: + inscope_device_name: "{{ ansible_hostname | default(inventory_hostname) }}" + + # Custom field update (per your sample) + - name: Publish custom-field update connstats deployed to control queue + 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': inscope_device_name, + 'task_name': 'custom_field_set', + 'task_add1': 'connstats', + 'task_result': 'deployed' + } | to_json }}" + payload_encoding: "string" + register: rmq_cf + changed_when: false + + # Final wrap-up journal connstats + - name: Build actions list + set_fact: + _actions_list: >- + {{ + [] + + ((upload_cstats | default(false) | bool) | ternary(['uploaded connstats.sh'], [])) + + (((cron_grep is defined) and ((cron_grep.rc | default(0)) != 0)) | ternary(['added connstats crontab entry'], [])) + }} + + - name: Build actions string + set_fact: + _actions_str: "{{ ((_actions_list | default([])) | length > 0) | ternary((_actions_list | join(', ')), 'no changes needed') }}" + + - name: Build wrap-up journal payload + delegate_to: localhost + set_fact: + wrap_payload: + inscope_device: "{{ inscope_device_name }}" + task_name: "journal_add" + task_result: >- + connstats: {{ 'success' if (result_status == 'SUCCESS_DEPLOYED' or result_status == 'SUCCESS_NO_CHANGE') else result_status | lower }} + — actions: {{ _actions_str }} + + - name: Publish wrap-up journal to control queue + 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: "{{ wrap_payload | to_json }}" + payload_encoding: "string" + register: rmq_wrap + changed_when: (rmq_wrap.json is defined) and (rmq_wrap.json.routed | default(false) | bool) + + - name: Summary + debug: + msg: + - "result_status: {{ result_status }}" + - "we're good" diff --git a/files/files/connstats.sh b/files/files/connstats.sh new file mode 100644 index 0000000..66bdd15 --- /dev/null +++ b/files/files/connstats.sh @@ -0,0 +1,656 @@ +#!/bin/sh +# connstats.sh +# Log-derived Wi-Fi connection counters per run (intended for cron every 10 minutes). +# Writes one summary line per run to /root/connstats.log +# Optional: --debug for chatty troubleshooting output in the same log. +# Optional: --always-find-offset to calibrate syslog header time via a logger marker +# and (on fresh start / rotation / truncation) build a true last-10-min slice. + +LOG_SRC="/var/log/messages" +OUT_LOG="/root/connstats.log" +STATE="/tmp/connstats.state" +TMPDIR="/tmp" + +# How many tail lines to scan when we have no state (first run / after reboot / rotation) +BOOTSTRAP_BACKFILL_LINES=2000 + +# Used only with --always-find-offset during bootstrap-like runs +BOOTSTRAP_TIME_WINDOW_SEC=600 +BOOTSTRAP_TIME_TAIL_LINES=10000 + +DEBUG=0 +ALWAYS_FIND_OFFSET=0 + +for arg in "$@"; do + case "$arg" in + --debug) DEBUG=1 ;; + --always-find-offset) ALWAYS_FIND_OFFSET=1 ;; + -h|--help) + echo "Usage: $0 [--debug] [--always-find-offset]" + exit 0 + ;; + *) + echo "Unknown arg: $arg" >&2 + echo "Usage: $0 [--debug] [--always-find-offset]" >&2 + exit 2 + ;; + esac +done + +umask 077 + +ts_iso() { + # ISO-ish timestamp; works on BusyBox and GNU date + date "+%Y-%m-%dT%H:%M:%S%z" +} + +log() { + # Always append to OUT_LOG + echo "[$(ts_iso)] $*" >> "$OUT_LOG" +} + +# Generate a short random token (8 chars) for marker grep +rand_token() { + if [ -r /dev/urandom ]; then + tr -dc 'a-z0-9' < /dev/urandom 2>/dev/null | head -c 8 + else + echo "$(date -u +%s 2>/dev/null)$$" | tr -dc 'a-z0-9' | tail -c 8 + fi +} + +# Parse syslog header timestamp (Mon DD HH:MM:SS) into epoch seconds (UTC) using awk mktime() +# Expects typical line like: "<13> Jan 31 12:31:00 root[-] [notice]: message" +syslog_header_to_epoch_utc() { + echo "$1" | TZ=UTC awk ' + function mon2num(m) { + if (m=="Jan") return 1 + if (m=="Feb") return 2 + if (m=="Mar") return 3 + if (m=="Apr") return 4 + if (m=="May") return 5 + if (m=="Jun") return 6 + if (m=="Jul") return 7 + if (m=="Aug") return 8 + if (m=="Sep") return 9 + if (m=="Oct") return 10 + if (m=="Nov") return 11 + if (m=="Dec") return 12 + return 0 + } + { + mon=$2; day=$3; tod=$4 + year=strftime("%Y") + m=mon2num(mon) + if (m==0) { print ""; exit 1 } + split(tod, t, ":") + if (length(t) != 3) { print ""; exit 1 } + hh=t[1]+0; mm=t[2]+0; ss=t[3]+0 + d=day+0 + print mktime(sprintf("%d %02d %02d %02d %02d %02d", year, m, d, hh, mm, ss)) + } + ' +} + +# Compute offset_sec = payload_epoch_utc - syslog_header_epoch_utc +# Returns offset seconds on stdout (blank on failure) +find_time_offset_sec() { + token="$(rand_token)" + now_epoch="$(date -u +%s 2>/dev/null | tr -d ' ')" + [ -z "$now_epoch" ] && now_epoch=0 + + logger "connstat calculation started. issueing time marker $token epoch=$now_epoch" 2>/dev/null + + found_line="" + i=0 + while [ $i -lt 3 ]; do + found_line="$(tail -n 300 "$LOG_SRC" 2>/dev/null | grep "$token" | tail -n 1)" + [ -n "$found_line" ] && break + sleep 1 + i=$((i + 1)) + done + + [ -z "$found_line" ] && { echo ""; return; } + + hdr_epoch="$(syslog_header_to_epoch_utc "$found_line" | tr -d ' ')" + [ -z "$hdr_epoch" ] && { echo ""; return; } + + payload_epoch="$(echo "$found_line" | awk '{ + for (i=1; i<=NF; i++) { + if ($i ~ /^epoch=[0-9]+$/) { sub(/^epoch=/,"",$i); print $i; exit } + } + }' | tr -d ' ')" + [ -z "$payload_epoch" ] && payload_epoch="$now_epoch" + + echo $((payload_epoch - hdr_epoch)) +} + +# Basic sanity checks +if [ ! -r "$LOG_SRC" ]; then + log "ERROR log_src_unreadable path=$LOG_SRC" + exit 1 +fi + +# Get current inode + line count (line-based incremental read is most portable) +cur_inode="$(ls -i "$LOG_SRC" 2>/dev/null | awk '{print $1}')" +cur_lines="$(wc -l < "$LOG_SRC" 2>/dev/null | tr -d ' ')" + +if [ -z "$cur_inode" ] || [ -z "$cur_lines" ]; then + log "ERROR cannot_stat_log inode='$cur_inode' lines='$cur_lines'" + exit 1 +fi + +last_inode="" +last_line="" + +if [ -f "$STATE" ]; then + # STATE format: + # inode= + # line= + last_inode="$(grep '^inode=' "$STATE" 2>/dev/null | head -n1 | cut -d= -f2)" + last_line="$(grep '^line=' "$STATE" 2>/dev/null | head -n1 | cut -d= -f2)" +fi + +# Decide where to start reading +start_line="" + +backfill_start() { + start_line=$((cur_lines - BOOTSTRAP_BACKFILL_LINES + 1)) + [ "$start_line" -lt 1 ] && start_line=1 +} + +# Extract the slice +tmp_slice="$TMPDIR/connstats.slice.$$" + +# Grep patterns (hostapd transition events) +re_auth='hostapd.*IEEE 802\.11: authenticated' +re_assoc='hostapd.*IEEE 802\.11: associated' +re_disassoc='hostapd.*IEEE 802\.11: disassociated' + +# Kernel extras for "seen_any" breadth (optional but useful) +re_kernel_seen='kernel.*(station kicked out|station timed out)' + +# Are we in a bootstrap-like condition (no state OR rotation/truncation)? +bootstrap_like=0 +if [ -z "$last_inode" ] || [ -z "$last_line" ]; then + bootstrap_like=1 +else + if [ "$cur_inode" != "$last_inode" ] || [ "$cur_lines" -lt "$last_line" ]; then + bootstrap_like=1 + fi +fi + +# Bootstrap handling +if [ "$ALWAYS_FIND_OFFSET" -eq 1 ] && [ "$bootstrap_like" -eq 1 ]; then + offset_sec="$(find_time_offset_sec)" + + if [ -n "$offset_sec" ]; then + now_epoch="$(date -u +%s 2>/dev/null | tr -d ' ')" + [ -z "$now_epoch" ] && now_epoch=0 + cutoff_epoch=$((now_epoch - BOOTSTRAP_TIME_WINDOW_SEC)) + + if [ $DEBUG -eq 1 ]; then + log "DEBUG bootstrap_time_window_last_10m offset_sec=$offset_sec cutoff_epoch=$cutoff_epoch tail_lines=$BOOTSTRAP_TIME_TAIL_LINES" + fi + + # Build slice = last-10-min relevant lines (hostapd transitions + kernel station kicked/timed out) + # using (syslog_header_epoch + offset_sec) >= cutoff_epoch + tail -n "$BOOTSTRAP_TIME_TAIL_LINES" "$LOG_SRC" 2>/dev/null \ + | TZ=UTC awk -v cutoff="$cutoff_epoch" -v off="$offset_sec" ' + function mon2num(m) { + if (m=="Jan") return 1 + if (m=="Feb") return 2 + if (m=="Mar") return 3 + if (m=="Apr") return 4 + if (m=="May") return 5 + if (m=="Jun") return 6 + if (m=="Jul") return 7 + if (m=="Aug") return 8 + if (m=="Sep") return 9 + if (m=="Oct") return 10 + if (m=="Nov") return 11 + if (m=="Dec") return 12 + return 0 + } + function header_epoch( mon,day,tod,year,m,hh,mm,ss,t) { + mon=$2; day=$3; tod=$4 + year=strftime("%Y") + m=mon2num(mon) + if (m==0) return -1 + split(tod, t, ":") + if (length(t) != 3) return -1 + hh=t[1]+0; mm=t[2]+0; ss=t[3]+0 + return mktime(sprintf("%d %02d %02d %02d %02d %02d", year, m, (day+0), hh, mm, ss)) + } + { + line=$0 + if (line ~ /hostapd.*IEEE 802\.11: (authenticated|associated|disassociated)/ || + line ~ /kernel.*(station kicked out|station timed out)/) { + he=header_epoch() + if (he < 0) next + corr=he + off + if (corr >= cutoff) print line + } + } + ' > "$tmp_slice" 2>/dev/null + + start_line="(time_window_last_10m)" + else + # Offset calc failed -> fall back to old bootstrap behavior + backfill_start + if [ $DEBUG -eq 1 ]; then + log "DEBUG time_offset_failed_fallback_to_backfill cur_inode=$cur_inode cur_lines=$cur_lines start_line=$start_line backfill_lines=$BOOTSTRAP_BACKFILL_LINES" + fi + sed -n "${start_line},\$p" "$LOG_SRC" > "$tmp_slice" 2>/dev/null + fi + +else + # Original behavior (unchanged) + if [ -z "$last_inode" ] || [ -z "$last_line" ]; then + backfill_start + if [ $DEBUG -eq 1 ]; then + log "DEBUG bootstrap_no_state_backfill cur_inode=$cur_inode cur_lines=$cur_lines start_line=$start_line backfill_lines=$BOOTSTRAP_BACKFILL_LINES" + fi + else + # Rotation / truncation detection + if [ "$cur_inode" != "$last_inode" ] || [ "$cur_lines" -lt "$last_line" ]; then + backfill_start + if [ $DEBUG -eq 1 ]; then + log "DEBUG bootstrap_rotation_or_truncate_backfill last_inode=$last_inode cur_inode=$cur_inode last_line=$last_line cur_lines=$cur_lines start_line=$start_line backfill_lines=$BOOTSTRAP_BACKFILL_LINES" + fi + else + start_line=$((last_line + 1)) + if [ $DEBUG -eq 1 ]; then + log "DEBUG incremental last_line=$last_line cur_lines=$cur_lines start_line=$start_line" + fi + fi + fi + + sed -n "${start_line},\$p" "$LOG_SRC" > "$tmp_slice" 2>/dev/null +fi + +slice_lines="$(wc -l < "$tmp_slice" 2>/dev/null | tr -d ' ')" +[ -z "$slice_lines" ] && slice_lines=0 + +if [ $DEBUG -eq 1 ]; then + log "DEBUG slice_path=$tmp_slice slice_lines=$slice_lines" +fi + +# Totals (MAC repeats allowed) +auth_events_total="$(grep -E "$re_auth" "$tmp_slice" | wc -l | tr -d ' ')" +assoc_events_total="$(grep -E "$re_assoc" "$tmp_slice" | wc -l | tr -d ' ')" +disassoc_events_total="$(grep -E "$re_disassoc" "$tmp_slice" | wc -l | tr -d ' ')" + +# Extract MAC right after token "STA" (from hostapd lines) +extract_sta_macs() { + awk ' + { + for (i=1; i<=NF; i++) { + if ($i=="STA") { + print $(i+1); + break; + } + } + } + ' +} + +# Extract MACs in brackets: [aa:bb:cc:dd:ee:ff] +extract_bracket_macs() { + grep -oE '\[[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}\]' | tr -d '[]' +} + +# Count unique valid MACs from stdin +uniq_count() { + grep -E '^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$' \ + | sort -u \ + | wc -l | tr -d ' ' +} + +# Unique MACs per event type (hostapd) +unique_macs_authenticated="$( + grep -E "$re_auth" "$tmp_slice" \ + | extract_sta_macs \ + | uniq_count +)" + +unique_macs_associated="$( + grep -E "$re_assoc" "$tmp_slice" \ + | extract_sta_macs \ + | uniq_count +)" + +unique_macs_disassociated="$( + grep -E "$re_disassoc" "$tmp_slice" \ + | extract_sta_macs \ + | uniq_count +)" + +# Unique MACs seen in "any relevant activity" (union: auth/assoc/disassoc + selected kernel) +unique_macs_seen_any="$( + ( + grep -E 'hostapd.*IEEE 802\.11: (authenticated|associated|disassociated)' "$tmp_slice" \ + | extract_sta_macs + + grep -E "$re_kernel_seen" "$tmp_slice" \ + | extract_bracket_macs + ) | uniq_count +)" + +######################################################################## +# B-class metrics (ephemeral state in /tmp; reboot loss accepted) +######################################################################## + +SESSION_STATE="/tmp/connstats.sessions.state" +SESSION_STATE_TMP="$TMPDIR/connstats.sessions.state.$$" +SESSION_STATE_TTL_SEC=21600 +AUTH_ASSOC_MAX_SAMPLES=50 + +now_epoch_b="$(date -u +%s 2>/dev/null | tr -d ' ')" +[ -z "$now_epoch_b" ] && now_epoch_b=0 + +# Output file (temporary, removed each run) +B_OUT_TMP="$TMPDIR/connstats.b.out.$$" + +TZ=UTC awk -v state_in="$SESSION_STATE" \ + -v state_out="$SESSION_STATE_TMP" \ + -v now="$now_epoch_b" \ + -v ttl="$SESSION_STATE_TTL_SEC" \ + -v maxs="$AUTH_ASSOC_MAX_SAMPLES" ' + function mon2num(m) { + if (m=="Jan") return 1 + if (m=="Feb") return 2 + if (m=="Mar") return 3 + if (m=="Apr") return 4 + if (m=="May") return 5 + if (m=="Jun") return 6 + if (m=="Jul") return 7 + if (m=="Aug") return 8 + if (m=="Sep") return 9 + if (m=="Oct") return 10 + if (m=="Nov") return 11 + if (m=="Dec") return 12 + return 0 + } + function header_epoch( mon,day,tod,year,m,hh,mm,ss,t) { + mon=$2; day=$3; tod=$4 + year=strftime("%Y") + m=mon2num(mon) + if (m==0) return -1 + split(tod, t, ":") + if (length(t) != 3) return -1 + hh=t[1]+0; mm=t[2]+0; ss=t[3]+0 + return mktime(sprintf("%d %02d %02d %02d %02d %02d", year, m, (day+0), hh, mm, ss)) + } + function extract_sta_mac( i) { + for (i=1; i<=NF; i++) { + if ($i=="STA") return $(i+1) + } + return "" + } + function mac_valid(m) { + return (m ~ /^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$/) + } + + BEGIN { + if (state_in != "") { + while ((getline line < state_in) > 0) { + n = split(line, a, " ") + if (n >= 2) { + m=a[1]; e=a[2]+0 + if (mac_valid(m) && e > 0) last_auth[m]=e + } + } + close(state_in) + } + } + + { + line=$0 + if (line !~ /hostapd.*IEEE 802\.11: (authenticated|associated|disassociated)/) next + + e = header_epoch() + if (e < 0) next + + mac = extract_sta_mac() + if (!mac_valid(mac)) next + + if (line ~ /IEEE 802\.11: authenticated/) { + last_auth[mac]=e + next + } + + if (line ~ /IEEE 802\.11: associated/) { + if (mac in last_auth) { + d = e - last_auth[mac] + if (d >= 0) { + ms = d * 1000 + if (sample_count < maxs) { + if (samples == "") samples = ms + else samples = samples "," ms + sample_count++ + } + } + } + next + } + + if (line ~ /IEEE 802\.11: disassociated/) { + if (mac in last_auth) { + d = e - last_auth[mac] + if (d >= 0) { + full_sessions_total++ + sum_len += d + if (d < 60) early_drop_sessions_total++ + delete last_auth[mac] + } + } + next + } + } + + END { + if (full_sessions_total > 0) avg_session_length = int((sum_len / full_sessions_total) + 0.5) + else avg_session_length = 0 + + if (state_out != "") { + for (m in last_auth) { + e = last_auth[m] + 0 + if (e > 0 && (now <= 0 || (e >= (now - ttl)))) { + print m " " e > state_out + } + } + close(state_out) + } + + print full_sessions_total "\t" avg_session_length "\t" early_drop_sessions_total "\t" samples + } +' "$tmp_slice" > "$B_OUT_TMP" 2>/dev/null + +# Default B fields +full_sessions_total=0 +avg_session_length=0 +early_drop_sessions_total=0 +auth_to_assoc_time_ms_samples="" + +# Parse awk output +if [ -s "$B_OUT_TMP" ]; then + b_out_line="$(head -n 1 "$B_OUT_TMP" 2>/dev/null)" + full_sessions_total="$(echo "$b_out_line" | awk -F'\t' '{print $1}' | tr -d ' ')" + avg_session_length="$(echo "$b_out_line" | awk -F'\t' '{print $2}' | tr -d ' ')" + early_drop_sessions_total="$(echo "$b_out_line" | awk -F'\t' '{print $3}' | tr -d ' ')" + auth_to_assoc_time_ms_samples="$(echo "$b_out_line" | awk -F'\t' '{print $4}')" +fi + +rm -f "$B_OUT_TMP" 2>/dev/null + +[ -z "$full_sessions_total" ] && full_sessions_total=0 +[ -z "$avg_session_length" ] && avg_session_length=0 +[ -z "$early_drop_sessions_total" ] && early_drop_sessions_total=0 + +# Move state into place (ephemeral but persistent across runs until reboot) +if [ -f "$SESSION_STATE_TMP" ]; then + mv -f "$SESSION_STATE_TMP" "$SESSION_STATE" 2>/dev/null +else + rm -f "$SESSION_STATE_TMP" 2>/dev/null +fi + +######################################################################## +# C-class metrics (kernel kick reason codes 1..8) +######################################################################## + +# Only count kernel "station kicked out ..." (avoids PRS DRIVER_LOG "reason" lines) +re_kick_reason_base='kernel.*station kicked out.*reason[[:space:]]+' + +reason_code_events_1="$(grep -E "${re_kick_reason_base}1([^0-9]|$)" "$tmp_slice" 2>/dev/null | wc -l | tr -d ' ')" +reason_code_events_2="$(grep -E "${re_kick_reason_base}2([^0-9]|$)" "$tmp_slice" 2>/dev/null | wc -l | tr -d ' ')" +reason_code_events_3="$(grep -E "${re_kick_reason_base}3([^0-9]|$)" "$tmp_slice" 2>/dev/null | wc -l | tr -d ' ')" +reason_code_events_4="$(grep -E "${re_kick_reason_base}4([^0-9]|$)" "$tmp_slice" 2>/dev/null | wc -l | tr -d ' ')" +reason_code_events_5="$(grep -E "${re_kick_reason_base}5([^0-9]|$)" "$tmp_slice" 2>/dev/null | wc -l | tr -d ' ')" +reason_code_events_6="$(grep -E "${re_kick_reason_base}6([^0-9]|$)" "$tmp_slice" 2>/dev/null | wc -l | tr -d ' ')" +reason_code_events_7="$(grep -E "${re_kick_reason_base}7([^0-9]|$)" "$tmp_slice" 2>/dev/null | wc -l | tr -d ' ')" +reason_code_events_8="$(grep -E "${re_kick_reason_base}8([^0-9]|$)" "$tmp_slice" 2>/dev/null | wc -l | tr -d ' ')" + +[ -z "$reason_code_events_1" ] && reason_code_events_1=0 +[ -z "$reason_code_events_2" ] && reason_code_events_2=0 +[ -z "$reason_code_events_3" ] && reason_code_events_3=0 +[ -z "$reason_code_events_4" ] && reason_code_events_4=0 +[ -z "$reason_code_events_5" ] && reason_code_events_5=0 +[ -z "$reason_code_events_6" ] && reason_code_events_6=0 +[ -z "$reason_code_events_7" ] && reason_code_events_7=0 +[ -z "$reason_code_events_8" ] && reason_code_events_8=0 + +######################################################################## +# D-class metrics (flaps between ath0 and ath1 within the bucket) +######################################################################## + +D_OUT_TMP="$TMPDIR/connstats.d.out.$$" + +awk ' + function extract_sta_mac( i) { + for (i=1; i<=NF; i++) if ($i=="STA") return $(i+1) + return "" + } + function mac_valid(m) { + return (m ~ /^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$/) + } + function extract_iface( i, v) { + for (i=1; i<=NF; i++) { + v = $i + if (v ~ /^ath[0-9]+:$/) { sub(/:$/,"",v); return v } + } + return "" + } + + { + line=$0 + if (line !~ /hostapd.*IEEE 802\.11: associated/) next + + iface = extract_iface() + if (iface != "ath0" && iface != "ath1") next + + mac = extract_sta_mac() + if (!mac_valid(mac)) next + + if (mac in last_iface) { + prev = last_iface[mac] + if (prev != iface) { + if (prev=="ath0" && iface=="ath1") { + flap_events_0_to_1++ + uniq_0_to_1[mac]=1 + } else if (prev=="ath1" && iface=="ath0") { + flap_events_1_to_0++ + uniq_1_to_0[mac]=1 + } + } + } + + last_iface[mac]=iface + } + + END { + for (m in uniq_0_to_1) unique_0_to_1++ + for (m in uniq_1_to_0) unique_1_to_0++ + + if (unique_0_to_1+0 < 0) unique_0_to_1=0 + if (flap_events_0_to_1+0 < 0) flap_events_0_to_1=0 + if (unique_1_to_0+0 < 0) unique_1_to_0=0 + if (flap_events_1_to_0+0 < 0) flap_events_1_to_0=0 + + print unique_0_to_1 "\t" flap_events_0_to_1 "\t" unique_1_to_0 "\t" flap_events_1_to_0 + } +' "$tmp_slice" > "$D_OUT_TMP" 2>/dev/null + +unique_macs_flap_ath0_to_ath1=0 +flap_events_total_ath0_to_ath1=0 +unique_macs_flap_ath1_to_ath0=0 +flap_events_total_ath1_to_ath0=0 + +if [ -s "$D_OUT_TMP" ]; then + d_out_line="$(head -n 1 "$D_OUT_TMP" 2>/dev/null)" + unique_macs_flap_ath0_to_ath1="$(echo "$d_out_line" | awk -F'\t' '{print $1}' | tr -d ' ')" + flap_events_total_ath0_to_ath1="$(echo "$d_out_line" | awk -F'\t' '{print $2}' | tr -d ' ')" + unique_macs_flap_ath1_to_ath0="$(echo "$d_out_line" | awk -F'\t' '{print $3}' | tr -d ' ')" + flap_events_total_ath1_to_ath0="$(echo "$d_out_line" | awk -F'\t' '{print $4}' | tr -d ' ')" +fi + +rm -f "$D_OUT_TMP" 2>/dev/null + +[ -z "$unique_macs_flap_ath0_to_ath1" ] && unique_macs_flap_ath0_to_ath1=0 +[ -z "$flap_events_total_ath0_to_ath1" ] && flap_events_total_ath0_to_ath1=0 +[ -z "$unique_macs_flap_ath1_to_ath0" ] && unique_macs_flap_ath1_to_ath0=0 +[ -z "$flap_events_total_ath1_to_ath0" ] && flap_events_total_ath1_to_ath0=0 + +######################################################################## +# Emit one summary line +######################################################################## +log "connstats bucket_run slice_lines=$slice_lines unique_macs_seen_any=$unique_macs_seen_any unique_macs_authenticated=$unique_macs_authenticated auth_events_total=$auth_events_total unique_macs_associated=$unique_macs_associated assoc_events_total=$assoc_events_total unique_macs_disassociated=$unique_macs_disassociated disassoc_events_total=$disassoc_events_total full_sessions_total=$full_sessions_total avg_session_length=$avg_session_length early_drop_sessions_total=$early_drop_sessions_total auth_to_assoc_time_ms_samples=$auth_to_assoc_time_ms_samples reason_code_events_1=$reason_code_events_1 reason_code_events_2=$reason_code_events_2 reason_code_events_3=$reason_code_events_3 reason_code_events_4=$reason_code_events_4 reason_code_events_5=$reason_code_events_5 reason_code_events_6=$reason_code_events_6 reason_code_events_7=$reason_code_events_7 reason_code_events_8=$reason_code_events_8 unique_macs_flap_ath0_to_ath1=$unique_macs_flap_ath0_to_ath1 flap_events_total_ath0_to_ath1=$flap_events_total_ath0_to_ath1 unique_macs_flap_ath1_to_ath0=$unique_macs_flap_ath1_to_ath0 flap_events_total_ath1_to_ath0=$flap_events_total_ath1_to_ath0" + +if [ $DEBUG -eq 1 ]; then + log "DEBUG dump_auth_lines_begin" + grep -E "$re_auth" "$tmp_slice" | sed 's/^/DEBUG AUTH: /' >> "$OUT_LOG" + log "DEBUG dump_assoc_lines_begin" + grep -E "$re_assoc" "$tmp_slice" | sed 's/^/DEBUG ASSOC: /' >> "$OUT_LOG" + log "DEBUG dump_disassoc_lines_begin" + grep -E "$re_disassoc" "$tmp_slice" | sed 's/^/DEBUG DISASSOC: /' >> "$OUT_LOG" + + log "DEBUG dump_kick_reason_lines_begin" + grep -E "${re_kick_reason_base}[1-8]([^0-9]|$)" "$tmp_slice" | sed 's/^/DEBUG KICK: /' >> "$OUT_LOG" + + log "DEBUG dump_flap_assoc_lines_begin" + grep -E 'hostapd.*IEEE 802\.11: associated' "$tmp_slice" | sed 's/^/DEBUG FLAP_ASSOC: /' >> "$OUT_LOG" + + log "DEBUG dump_flap_events_begin" + awk ' + function extract_sta_mac( i) { for (i=1; i<=NF; i++) if ($i=="STA") return $(i+1); return "" } + function mac_valid(m) { return (m ~ /^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$/) } + function extract_iface( i, v) { + for (i=1; i<=NF; i++) { v=$i; if (v ~ /^ath[0-9]+:$/) { sub(/:$/,"",v); return v } } + return "" + } + { + if ($0 !~ /hostapd.*IEEE 802\.11: associated/) next + iface=extract_iface() + if (iface!="ath0" && iface!="ath1") next + mac=extract_sta_mac() + if (!mac_valid(mac)) next + if (mac in last_iface) { + prev=last_iface[mac] + if (prev!=iface) { + if ((prev=="ath0" && iface=="ath1") || (prev=="ath1" && iface=="ath0")) { + print "mac=" mac " from=" prev " to=" iface + } + } + } + last_iface[mac]=iface + } + ' "$tmp_slice" | sed 's/^/DEBUG FLAP: /' >> "$OUT_LOG" +fi + +# Update state to current end-of-file line count and inode +{ + echo "inode=$cur_inode" + echo "line=$cur_lines" +} > "$STATE" + +rm -f "$tmp_slice" 2>/dev/null +exit 0