#!/usr/bin/env bash set -euo pipefail # --- Minimal deps check --- for bin in curl jq; do command -v "$bin" >/dev/null 2>&1 || { echo "[ERROR] Missing dependency: $bin" >&2; exit 2; } done # --- RabbitMQ config (override via env) --- RMQ_USER="${RMQ_USER:-admin}" RMQ_PASS="${RMQ_PASS:-change_me}" RMQ_HOST="${RMQ_HOST:-localhost}" RMQ_PORT="${RMQ_PORT:-15672}" VHOST="${VHOST:-app}" QUEUE="${QUEUE:-queue_controls}" SLEEP_SECS="${SLEEP_SECS:-2}" # --- NetBox config (override via env) --- NB_URL="${NB_URL:-http://netbox.gt-tiso.ikeja.co.za}" # Tip: Prefer setting NB_TOKEN via environment. Leaving default blank avoids accidental leaks. NB_TOKEN="${NB_TOKEN:-}" # --- Optional performance knobs --- # NB_PREREAD=1 enables extra GETs to print pre-change values (more load on NetBox). NB_PREREAD="${NB_PREREAD:-0}" USE_TAGS="${USE_TAGS:-yes}" # Device ID cache (per worker process) # Cache entries are considered fresh for 60 minutes. After that, they may be used up to an # additional random 3-10 minutes ("stale window") before a refresh is attempted. NB_IDCACHE_TTL_SECS="${NB_IDCACHE_TTL_SECS:-3600}" NB_IDCACHE_STALE_MIN_SECS="${NB_IDCACHE_STALE_MIN_SECS:-180}" NB_IDCACHE_STALE_MAX_SECS="${NB_IDCACHE_STALE_MAX_SECS:-600}" log() { echo "[netbox-reporter] $*"; } warn(){ echo "[netbox-reporter][WARN] $*" >&2; } err() { echo "[netbox-reporter][ERROR] $*" >&2; } # Require NB_TOKEN if [[ -z "${NB_TOKEN}" ]]; then err "NB_TOKEN is not set. Export NB_TOKEN and retry." exit 2 fi # URL-encode for RabbitMQ HTTP API paths urlenc() { printf '%s' "$1" | sed -e 's, ,%20,g' -e 's,/,%2F,g'; } # --- helpers --- now_epoch() { date +%s; } rand_between() { local min="$1" max="$2" # inclusive range echo $(( min + (RANDOM % (max - min + 1)) )) } # Device ID cache (associative arrays require bash 4+) declare -A NB_DEV_ID_CACHE=() declare -A NB_DEV_ID_CACHE_EXPIRES=() declare -A NB_DEV_ID_CACHE_STALE_UNTIL=() nb_get_device_id_cached() { local name="$1" local now exp stale id jitter now="$(now_epoch)" id="${NB_DEV_ID_CACHE[$name]:-}" exp="${NB_DEV_ID_CACHE_EXPIRES[$name]:-0}" stale="${NB_DEV_ID_CACHE_STALE_UNTIL[$name]:-0}" # Fresh cache hit if [[ -n "$id" && "$now" -lt "$exp" ]]; then printf '%s' "$id" return 0 fi # Stale-but-usable cache hit (during jitter window): return cached ID, don't refresh yet if [[ -n "$id" && "$now" -ge "$exp" && "$now" -lt "$stale" ]]; then printf '%s' "$id" return 0 fi # Cache miss or refresh time: refresh via NetBox id="$(nb_find_device_id "$name")" || true if [[ -n "$id" ]]; then NB_DEV_ID_CACHE["$name"]="$id" NB_DEV_ID_CACHE_EXPIRES["$name"]=$(( now + NB_IDCACHE_TTL_SECS )) jitter="$(rand_between "$NB_IDCACHE_STALE_MIN_SECS" "$NB_IDCACHE_STALE_MAX_SECS")" NB_DEV_ID_CACHE_STALE_UNTIL["$name"]=$(( now + NB_IDCACHE_TTL_SECS + jitter )) printf '%s' "$id" return 0 fi return 1 } # Simple RabbitMQ HTTP call rmq_api() { local method="$1" path="$2" data="${3:-}" local url="http://${RMQ_HOST}:${RMQ_PORT}${path}" if [[ -n "$data" ]]; then curl -sS -u "$RMQ_USER:$RMQ_PASS" -H "content-type:application/json" -X "$method" "$url" -d "$data" else curl -sS -u "$RMQ_USER:$RMQ_PASS" -H "content-type:application/json" -X "$method" "$url" fi } # Find NetBox device ID by exact name nb_find_device_id() { local name="$1" local resp resp="$(curl -sS \ -H "Authorization: Token $NB_TOKEN" \ -H "Accept: application/json" \ "$NB_URL/api/dcim/devices/?name=$(printf '%s' "$name" | jq -sRr @uri)&limit=1&fields=id")" || return 1 jq -r '.results[0].id // empty' <<<"$resp" } # --- READ a custom field value for a device (prints current value or empty if unset) --- nb_read_custom_field() { local dev_id="$1" key="$2" local resp resp="$(curl -sS \ -H "Authorization: Token $NB_TOKEN" \ -H "Accept: application/json" \ "$NB_URL/api/dcim/devices/$dev_id/?fields=custom_fields")" || return 1 jq -r --arg k "$key" ' (.custom_fields[$k] // empty) | (if type=="object" or type=="array" then tojson else . end) ' <<<"$resp" } # PATCH NetBox custom fields (update_wo_restart) nb_patch_update_wo_restart() { local dev_id="$1" update_progress="$2" updating_to="$3" # Pre-read current values and print (optional; enable with NB_PREREAD=1) local prev_p prev_u if [[ "${NB_PREREAD}" == "1" ]]; then prev_p="$(nb_read_custom_field "$dev_id" "update_progress" || true)" prev_u="$(nb_read_custom_field "$dev_id" "updating_to" || true)" echo ">>> Pre-change (id=$dev_id): update_progress='${prev_p:-}'; updating_to='${prev_u:-}'" else echo ">>> Pre-change (id=$dev_id): update_progress=''; updating_to=''" fi local body body="$(jq -n --arg p "$update_progress" --arg u "$updating_to" \ '{custom_fields: {update_progress: $p, updating_to: $u}}')" local code code="$(curl -sS -o /dev/null -w "%{http_code}" \ -H "Authorization: Token $NB_TOKEN" \ -H "Content-Type: application/json" \ -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ -d "$body")" || code="000" if [[ "$code" =~ ^20[0-9]$ ]]; then log "Updated device id=$dev_id custom_fields OK" return 0 else err "Failed to update device id=$dev_id (HTTP $code)" return 1 fi } # PATCH NetBox custom field: wifidebug (deploy_wifidebug) nb_patch_wifidebug() { local dev_id="$1" value="$2" # Pre-read current value and print (optional; enable with NB_PREREAD=1) local prev if [[ "${NB_PREREAD}" == "1" ]]; then prev="$(nb_read_custom_field "$dev_id" "wifidebug" || true)" echo ">>> Pre-change (id=$dev_id): wifidebug='${prev:-}'" else echo ">>> Pre-change (id=$dev_id): wifidebug=''" fi local body body="$(jq -n --arg v "$value" '{custom_fields: {wifidebug: $v}}')" local code code="$(curl -sS -o /dev/null -w "%{http_code}" \ -H "Authorization: Token $NB_TOKEN" \ -H "Content-Type: application/json" \ -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ -d "$body")" || code="000" if [[ "$code" =~ ^20[0-9]$ ]]; then log "Updated device id=$dev_id wifidebug='$value' OK" return 0 else err "Failed to set wifidebug for device id=$dev_id (HTTP $code)" return 1 fi } # POST a Journal Entry on the device (journal_add) with prefix "updated: " nb_add_journal() { local dev_id="$1" text="$2" local comment="updated: ${text}" local body body="$(jq -n --arg c "$comment" --argjson oid "$dev_id" \ '{assigned_object_type:"dcim.device", assigned_object_id:$oid, comments:$c}')" local code code="$(curl -sS -o /dev/null -w "%{http_code}" \ -H "Authorization: Token $NB_TOKEN" \ -H "Content-Type: application/json" \ -X POST "$NB_URL/api/extras/journal-entries/" \ -d "$body")" || code="000" if [[ "$code" =~ ^20[0-9]$ || "$code" == "201" ]]; then log "Journal added for device id=$dev_id" return 0 else err "Failed to add journal for device id=$dev_id (HTTP $code)" return 1 fi } # POST a Journal Entry with raw text (no prefix) — used for task_notify path nb_add_journal_raw() { local dev_id="$1" text="$2" local body body="$(jq -n --arg c "$text" --argjson oid "$dev_id" \ '{assigned_object_type:"dcim.device", assigned_object_id:$oid, comments:$c}')" local code code="$(curl -sS -o /dev/null -w "%{http_code}" \ -H "Authorization: Token $NB_TOKEN" \ -H "Content-Type: application/json" \ -X POST "$NB_URL/api/extras/journal-entries/" \ -d "$body")" || code="000" if [[ "$code" =~ ^20[0-9]$ || "$code" == "201" ]]; then log "Journal (raw) added for device id=$dev_id" return 0 else err "Failed to add raw journal for device id=$dev_id (HTTP $code)" return 1 fi } # PATCH custom field: multiple_ssids = "yes" — used for task_notify path nb_set_multiple_ssids_yes() { local dev_id="$1" # Pre-read current value and print (optional; enable with NB_PREREAD=1) local prev if [[ "${NB_PREREAD}" == "1" ]]; then prev="$(nb_read_custom_field "$dev_id" "multiple_ssids" || true)" echo ">>> Pre-change (id=$dev_id): multiple_ssids='${prev:-}'" else echo ">>> Pre-change (id=$dev_id): multiple_ssids=''" fi local body='{"custom_fields":{"multiple_ssids":"yes"}}' local code code="$(curl -sS -o /dev/null -w "%{http_code}" \ -H "Authorization: Token $NB_TOKEN" \ -H "Content-Type: application/json" \ -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ -d "$body")" || code="000" if [[ "$code" =~ ^20[0-9]$ ]]; then log "Set multiple_ssids='yes' for device id=$dev_id" return 0 else err "Failed to set multiple_ssids for device id=$dev_id (HTTP $code)" return 1 fi } # --- Add a tag to a device using tag slug (ensures tag exists; merges dictionaries) --- nb_add_tag_by_slug() { local dev_id="$1" tag_slug="$2" if [[ "$USE_TAGS" != "yes" ]]; then return 0 fi # 1) Lookup tag by slug local tag_resp tag_id tag_name tag_resp="$(curl -sS \ -H "Authorization: Token $NB_TOKEN" \ -H "Accept: application/json" \ "$NB_URL/api/extras/tags/?slug=$(printf '%s' "$tag_slug" | jq -sRr @uri)&limit=1")" || tag_resp='{}' tag_id="$(jq -r '.results[0].id // empty' <<<"$tag_resp")" tag_name="$(jq -r '.results[0].name // empty' <<<"$tag_resp")" # 2) Create tag if missing if [[ -z "$tag_id" ]]; then local derived_name create_body create_tmp create_code derived_name="$(printf '%s' "$tag_slug" | sed -E 's/-+/ /g')" create_body="$(jq -n --arg name "$derived_name" --arg slug "$tag_slug" '{name:$name, slug:$slug}')" create_tmp="$(mktemp)" create_code="$(curl -sS -o "$create_tmp" -w "%{http_code}" \ -H "Authorization: Token $NB_TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -X POST "$NB_URL/api/extras/tags/" \ -d "$create_body" || true)" if [[ "$create_code" != "201" && ! "$create_code" =~ ^20[0-9]$ ]]; then err "Failed to create tag slug='$tag_slug' (HTTP $create_code)" cat "$create_tmp" >&2 || true rm -f "$create_tmp" return 1 fi tag_name="$(jq -r '.name' < "$create_tmp")" rm -f "$create_tmp" fi # 3) Read existing device tags, normalize to [{name, slug}] local dev_detail existing_objs_json dev_detail="$(curl -sS \ -H "Authorization: Token $NB_TOKEN" \ -H "Accept: application/json" \ "$NB_URL/api/dcim/devices/$dev_id/?fields=tags")" existing_objs_json="$( jq -c ' (.tags // []) as $t | if ( ($t|length)>0 and (($t[0]|type)=="object") ) then [ $t[] | {name:.name, slug:.slug} ] else [ $t[] | {name:.} ] end ' <<<"$dev_detail" )" # 4) Merge + dedupe; build PATCH body local merged_objs_json patch_body patch_tmp patch_code merged_objs_json="$( jq -cn --arg name "$tag_name" --arg slug "$tag_slug" --argjson existing "$existing_objs_json" ' ($existing + [ {name:$name, slug:$slug} ]) | group_by(.name) | map(.[0]) ' )" patch_body="$(jq -n --argjson tags "$merged_objs_json" '{tags:$tags}')" # 5) PATCH device patch_tmp="$(mktemp)" patch_code="$(curl -sS -o "$patch_tmp" -w "%{http_code}" \ -H "Authorization: Token $NB_TOKEN" \ -H "Content-Type: application/json" \ -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ -d "$patch_body" || true)" if [[ "$patch_code" =~ ^20[0-9]$ ]]; then log "Tag '$tag_slug' applied to device id=$dev_id" rm -f "$patch_tmp" return 0 else err "Failed to set tag '$tag_slug' for device id=$dev_id (HTTP $patch_code)" cat "$patch_tmp" >&2 || true rm -f "$patch_tmp" return 1 fi } # --- remove a tag from a device by slug (idempotent) --- # --- remove a tag from a device by slug (idempotent) --- nb_remove_tag_by_slug() { local dev_id="$1" tag_slug="$2" if [[ "$USE_TAGS" != "yes" ]]; then return 0 fi # 1) Get current tags local dev_detail existing_objs_json dev_detail="$(curl -sS \ -H "Authorization: Token $NB_TOKEN" \ -H "Accept: application/json" \ "$NB_URL/api/dcim/devices/$dev_id/?fields=tags")" || return 1 # Normalize tags to objects: [{name, slug?}] existing_objs_json="$( jq -c ' (.tags // []) as $t | if ( ($t|length)>0 and (($t[0]|type)=="object") ) then [ $t[] | {name:(.name//""), slug:(.slug//"")} ] else [ $t[] | {name:.} ] end ' <<<"$dev_detail" )" # 2) Build a friendly name from slug (e.g. indoor-restart-scheduled -> indoor restart scheduled) local name_from_slug name_from_slug="$(sed -E 's/-+/ /g' <<<"$tag_slug")" # 3) Filter out any tag whose slug == tag_slug OR name == tag_slug OR name == name_from_slug (case-insensitive) local filtered_objs_json filtered_objs_json="$( jq -cn --arg slug "$tag_slug" --arg name "$name_from_slug" --argjson existing "$existing_objs_json" ' [ $existing[] | select( ((.slug // "" | ascii_downcase) != ($slug | ascii_downcase)) and ((.name // "" | ascii_downcase) != ($slug | ascii_downcase)) and ((.name // "" | ascii_downcase) != ($name | ascii_downcase)) ) ] ' )" # 4) PATCH back local patch_body patch_tmp patch_code patch_body="$(jq -n --argjson tags "$filtered_objs_json" '{tags:$tags}')" patch_tmp="$(mktemp)" patch_code="$(curl -sS -o "$patch_tmp" -w "%{http_code}" \ -H "Authorization: Token $NB_TOKEN" \ -H "Content-Type: application/json" \ -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ -d "$patch_body" || true)" if [[ "$patch_code" =~ ^20[0-9]$ ]]; then log "Removed tag '$tag_slug' from device id=$dev_id (if present)" rm -f "$patch_tmp" return 0 else err "Failed to remove tag '$tag_slug' for device id=$dev_id (HTTP $patch_code)" cat "$patch_tmp" >&2 || true rm -f "$patch_tmp" return 1 fi } # --- clear only custom_field update_progress (set to null) --- nb_clear_update_progress() { local dev_id="$1" # Pre-read current value and print (optional; enable with NB_PREREAD=1) local prev if [[ "${NB_PREREAD}" == "1" ]]; then prev="$(nb_read_custom_field "$dev_id" "update_progress" || true)" echo ">>> Pre-change (id=$dev_id): update_progress='${prev:-}'" else echo ">>> Pre-change (id=$dev_id): update_progress=''" fi local body='{"custom_fields":{"update_progress":null}}' local code code="$(curl -sS -o /dev/null -w "%{http_code}" \ -H "Authorization: Token $NB_TOKEN" \ -H "Content-Type: application/json" \ -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ -d "$body")" || code="000" if [[ "$code" =~ ^20[0-9]$ ]]; then log "Cleared update_progress for device id=$dev_id" return 0 else err "Failed to clear update_progress for device id=$dev_id (HTTP $code)" return 1 fi } # --- NEW: generic single custom field patch --- nb_patch_custom_field() { local dev_id="$1" key="$2" value="$3" # Pre-read current value and print (optional; enable with NB_PREREAD=1) local prev if [[ "${NB_PREREAD}" == "1" ]]; then prev="$(nb_read_custom_field "$dev_id" "$key" || true)" echo ">>> Pre-change (id=$dev_id): ${key}='${prev:-}'" else echo ">>> Pre-change (id=$dev_id): ${key}=''" fi # Build {"custom_fields": { "": "" }} local body body="$(jq -n --arg k "$key" --arg v "$value" '{custom_fields: {($k): $v}}')" local code code="$(curl -sS -o /dev/null -w "%{http_code}" \ -H "Authorization: Token $NB_TOKEN" \ -H "Content-Type: application/json" \ -X PATCH "$NB_URL/api/dcim/devices/$dev_id/" \ -d "$body")" || code="000" if [[ "$code" =~ ^20[0-9]$ ]]; then log "Updated device id=$dev_id custom_field '$key'='$value' OK" return 0 else err "Failed to set custom_field '$key' for device id=$dev_id (HTTP $code)" return 1 fi } # Handle one JSON payload (object) handle_payload() { local payload="$1" local device task task_result task_result_typo add1 notify device="$(jq -r '.inscope_device // empty' <<<"$payload")" task="$(jq -r '.task_name // empty' <<<"$payload")" task_result="$(jq -r '.task_result // empty' <<<"$payload")" task_result_typo="$(jq -r '.taks_result // empty' <<<"$payload")" # backward-compat typo add1="$(jq -r '.task_add1 // empty' <<<"$payload")" notify="$(jq -r '.task_notify // empty' <<<"$payload")" # notify path [[ -z "$task_result" && -n "$task_result_typo" ]] && task_result="$task_result_typo" if [[ -z "$device" || -z "$task" ]]; then warn "Skipping message (missing inscope_device/task_name)" return 0 fi local dev_id dev_id="$(nb_get_device_id_cached "$device")" if [[ -z "$dev_id" ]]; then err "Device '$device' not found in NetBox" return 0 fi case "$task" in update_wo_restart) # If task_notify is present, set multiple_ssids=yes AND add journal with notify text if [[ -n "$notify" ]]; then if nb_set_multiple_ssids_yes "$dev_id"; then echo ">>> Device '$device' (id=$dev_id) updated:" echo " multiple_ssids = 'yes'" fi if nb_add_journal_raw "$dev_id" "$notify"; then echo ">>> Device '$device' (id=$dev_id) journal:" echo " added comment = '$notify'" fi return 0 fi # existing behavior if nb_patch_update_wo_restart "$dev_id" "$task_result" "$add1"; then echo ">>> Device '$device' (id=$dev_id) updated:" echo " update_progress = '$task_result'" echo " updating_to = '$add1'" fi ;; deploy_wifidebug) if nb_patch_wifidebug "$dev_id" "$task_result"; then echo ">>> Device '$device' (id=$dev_id) updated:" echo " wifidebug = '$task_result'" fi ;; journal_add) if nb_add_journal "$dev_id" "$task_result"; then echo ">>> Device '$device' (id=$dev_id) journal:" echo " added comment = 'updated: $task_result'" fi ;; tag_add) # add a tag to the device using task_result as slug if [[ "$USE_TAGS" != "yes" ]]; then return 0 fi if [[ -z "$task_result" ]]; then warn "tag_add: task_result (tag slug) is empty; skipping." return 0 fi if nb_add_tag_by_slug "$dev_id" "$task_result"; then echo ">>> Device '$device' (id=$dev_id) updated:" echo " tag added (slug) = '$task_result'" fi ;; update_cleanup_success) # cleanup after successful update if [[ "$USE_TAGS" == "yes" ]]; then nb_remove_tag_by_slug "$dev_id" "update-in-progress" || true nb_remove_tag_by_slug "$dev_id" "update-auto-restarted" || true nb_add_tag_by_slug "$dev_id" "update-successful" || true fi nb_clear_update_progress "$dev_id" || true echo ">>> Device '$device' (id=$dev_id) cleanup done:" if [[ "$USE_TAGS" == "yes" ]]; then echo " - tag removed: update-in-progress" echo " - tag removed: update-auto-restarted" echo " - tag added: update-successful" fi echo " - custom field cleared: update_progress" ;; custom_field_set) # NEW: generic setter # task_add1 = field key (e.g., Multissidfix), task_result = value (e.g., yes) if [[ -z "$add1" ]]; then warn "custom_field_set: task_add1 (field key) is empty; skipping." return 0 fi if nb_patch_custom_field "$dev_id" "$add1" "$task_result"; then echo ">>> Device '$device' (id=$dev_id) updated:" echo " custom_fields.$add1 = '$task_result'" fi ;; tag_remove) # remove a tag from the device using task_result as slug if [[ "$USE_TAGS" != "yes" ]]; then return 0 fi if [[ -z "$task_result" ]]; then warn "tag_remove: task_result (tag slug) is empty; skipping." return 0 fi if nb_remove_tag_by_slug "$dev_id" "$task_result"; then echo ">>> Device '$device' (id=$dev_id) updated:" echo " tag removed (slug) = '$task_result'" fi ;; *) log "Ignoring unsupported task_name='$task' (no-op)" ;; esac } # --- Main consume loop --- log "Consuming from queue='${QUEUE}' (vhost='${VHOST}')" while :; do RESP="$(rmq_api POST "/api/queues/$(urlenc "$VHOST")/$(urlenc "$QUEUE")/get" '{ "count": 1, "ackmode": "ack_requeue_false", "encoding": "auto", "truncate": 1000000 }')" # No messages -> pause if [[ -z "$RESP" || "$RESP" == "[]" ]]; then sleep "$SLEEP_SECS" continue fi # Process each message (API returns an array) echo "$RESP" | jq -c '.[]' | while read -r item; do raw_payload="$(jq -r '.payload' <<<"$item")" # Payload might be a JSON object OR a JSON-encoded string; handle both. if jq -e . >/dev/null 2>&1 <<<"$raw_payload"; then # If it's a string that contains JSON, decode once if [[ "$(jq -r 'type' <<<"$raw_payload")" == "string" ]] && jq -e . >/dev/null 2>&1 <<<"$(jq -r . <<<"$raw_payload")"; then decoded="$(jq -r . <<<"$raw_payload")" handle_payload "$decoded" else handle_payload "$raw_payload" fi else warn "Skipping non-JSON payload" fi done done