#!/usr/bin/env bash ################################################################################ # Script Name: snipeit-exporter.sh # Version: 1.0 # Description: Prometheus exporter for Snipe-IT asset management. # Uses the Snipe-IT REST API to collect asset counts, license # seat utilization, warranty expirations, checkout status, # consumable stock levels, and maintenance alerts. # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # # Prerequisites: # - curl and jq # - Snipe-IT API token with read access # # Usage: # SNIPEIT_URL="https://assets.example.com" SNIPEIT_TOKEN="xxx" ./snipeit-exporter.sh # SNIPEIT_URL="https://assets.example.com" SNIPEIT_TOKEN="xxx" ./snipeit-exporter.sh --textfile # SNIPEIT_URL="https://assets.example.com" SNIPEIT_TOKEN="xxx" ./snipeit-exporter.sh --http # # Parameters: # --textfile Write to textfile collector directory # --http Run as HTTP server # --install Create cron job for automatic collection # --help Show usage # # Environment: # SNIPEIT_URL Snipe-IT base URL (required) # SNIPEIT_TOKEN API token (required) # TEXTFILE_DIR Textfile collector directory (default: /var/lib/node_exporter/textfile_collector) # CURL_TIMEOUT API request timeout in seconds (default: 10) # WARRANTY_DAYS Days threshold for warranty expiry warnings (default: 90) # # Metrics Exported: # Core: # - snipeit_up # - snipeit_exporter_info{version} # # Assets: # - snipeit_assets_total # - snipeit_assets_deployed # - snipeit_assets_ready_to_deploy # - snipeit_assets_pending # - snipeit_assets_archived # - snipeit_assets_undeployable # - snipeit_assets_by_category{category} # # Licenses: # - snipeit_licenses_total # - snipeit_license_seats_total{license} # - snipeit_license_seats_used{license} # - snipeit_license_seats_free{license} # # Consumables: # - snipeit_consumables_total # - snipeit_consumable_remaining{consumable} # - snipeit_consumable_min_qty{consumable} # # Maintenance: # - snipeit_assets_warranty_expiring # - snipeit_users_total # - snipeit_locations_total # # Exporter: # - snipeit_exporter_duration_seconds # - snipeit_exporter_last_run_timestamp # ################################################################################ set -euo pipefail # --- Configuration --- readonly VERSION="1.0" readonly SCRIPT_NAME="$(basename "$0")" SNIPEIT_URL="${SNIPEIT_URL:-}" SNIPEIT_TOKEN="${SNIPEIT_TOKEN:-}" TEXTFILE_DIR="${TEXTFILE_DIR:-/var/lib/node_exporter/textfile_collector}" CURL_TIMEOUT="${CURL_TIMEOUT:-10}" WARRANTY_DAYS="${WARRANTY_DAYS:-90}" TEXTFILE_MODE=false HTTP_MODE=false HTTP_PORT=9199 OUTPUT="" START_TIME="" # --- Functions --- usage() { cat </dev/null; then missing+=("$cmd") fi done if [[ ${#missing[@]} -gt 0 ]]; then echo "# ERROR: Missing required commands: ${missing[*]}" >&2 echo "# Install with: apt install ${missing[*]} OR dnf install ${missing[*]}" >&2 exit 1 fi } validate_config() { if [[ -z "$SNIPEIT_URL" ]]; then echo "# ERROR: SNIPEIT_URL environment variable is required" >&2 exit 1 fi if [[ -z "$SNIPEIT_TOKEN" ]]; then echo "# ERROR: SNIPEIT_TOKEN environment variable is required" >&2 exit 1 fi SNIPEIT_URL="${SNIPEIT_URL%/}" } api_get() { local endpoint="$1" curl -sf --max-time "$CURL_TIMEOUT" \ -H "Authorization: Bearer ${SNIPEIT_TOKEN}" \ -H "Accept: application/json" \ "${SNIPEIT_URL}/api/v1${endpoint}" 2>/dev/null || echo "" } sanitize_label() { local value="$1" echo "$value" | sed 's/[^a-zA-Z0-9_ \/.-]/_/g' | sed 's/"/\\"/g' } add_metric() { local name="$1" local type="$2" local help="$3" local value="$4" local labels="${5:-}" if [[ -n "$labels" ]]; then OUTPUT+="# HELP ${name} ${help} # TYPE ${name} ${type} ${name}{${labels}} ${value} " else OUTPUT+="# HELP ${name} ${help} # TYPE ${name} ${type} ${name} ${value} " fi } add_metric_value() { local name="$1" local value="$2" local labels="${3:-}" if [[ -n "$labels" ]]; then OUTPUT+="${name}{${labels}} ${value} " else OUTPUT+="${name} ${value} " fi } # --- Collectors --- collect_status() { local status_json status_json=$(api_get "/hardware?limit=1") if [[ -z "$status_json" ]]; then add_metric "snipeit_up" "gauge" "Snipe-IT API reachability (1=up, 0=down)" "0" return 1 fi local total total=$(echo "$status_json" | jq -r '.total // empty' 2>/dev/null) if [[ -z "$total" ]]; then add_metric "snipeit_up" "gauge" "Snipe-IT API reachability (1=up, 0=down)" "0" return 1 fi add_metric "snipeit_up" "gauge" "Snipe-IT API reachability (1=up, 0=down)" "1" return 0 } collect_assets() { # Total assets local hw_json hw_json=$(api_get "/hardware?limit=1") if [[ -n "$hw_json" ]]; then local total total=$(echo "$hw_json" | jq -r '.total // 0' 2>/dev/null) add_metric "snipeit_assets_total" "gauge" "Total number of assets" "${total:-0}" fi # Assets by status label local status_json status_json=$(api_get "/statuslabels") if [[ -n "$status_json" ]]; then local deployed=0 ready=0 pending=0 archived=0 undeployable=0 local count count=$(echo "$status_json" | jq -r '.total // 0' 2>/dev/null) if [[ "$count" -gt 0 ]]; then local labels_json labels_json=$(api_get "/statuslabels?limit=${count}") if [[ -n "$labels_json" ]]; then deployed=$(echo "$labels_json" | jq '[.rows[] | select(.type=="deployable") | .assets_count] | add // 0' 2>/dev/null) pending=$(echo "$labels_json" | jq '[.rows[] | select(.type=="pending") | .assets_count] | add // 0' 2>/dev/null) archived=$(echo "$labels_json" | jq '[.rows[] | select(.type=="archived") | .assets_count] | add // 0' 2>/dev/null) undeployable=$(echo "$labels_json" | jq '[.rows[] | select(.type=="undeployable") | .assets_count] | add // 0' 2>/dev/null) fi fi # Deployed = checked out assets local deployed_json deployed_json=$(api_get "/hardware?status=Deployed&limit=1") if [[ -n "$deployed_json" ]]; then deployed=$(echo "$deployed_json" | jq -r '.total // 0' 2>/dev/null) fi add_metric "snipeit_assets_deployed" "gauge" "Assets currently checked out" "${deployed:-0}" add_metric "snipeit_assets_ready_to_deploy" "gauge" "Assets ready to deploy" "${ready:-0}" add_metric "snipeit_assets_pending" "gauge" "Assets in pending status" "${pending:-0}" add_metric "snipeit_assets_archived" "gauge" "Assets archived" "${archived:-0}" add_metric "snipeit_assets_undeployable" "gauge" "Assets undeployable (broken, lost, repair)" "${undeployable:-0}" fi # Assets by category local cat_json cat_json=$(api_get "/categories?limit=100") if [[ -n "$cat_json" ]]; then local cat_count cat_count=$(echo "$cat_json" | jq -r '.total // 0' 2>/dev/null) if [[ "$cat_count" -gt 0 ]]; then OUTPUT+="# HELP snipeit_assets_by_category Number of assets per category # TYPE snipeit_assets_by_category gauge " local i for ((i = 0; i < cat_count && i < 50; i++)); do local cat_name cat_assets cat_type cat_name=$(echo "$cat_json" | jq -r ".rows[$i].name // empty" 2>/dev/null) cat_assets=$(echo "$cat_json" | jq -r ".rows[$i].assets_count // 0" 2>/dev/null) cat_type=$(echo "$cat_json" | jq -r ".rows[$i].category_type // empty" 2>/dev/null) if [[ -n "$cat_name" && "$cat_type" == "asset" ]]; then local safe_name safe_name=$(sanitize_label "$cat_name") add_metric_value "snipeit_assets_by_category" "${cat_assets:-0}" "category=\"${safe_name}\"" fi done fi fi } collect_licenses() { local lic_json lic_json=$(api_get "/licenses?limit=100") if [[ -z "$lic_json" ]]; then return fi local total total=$(echo "$lic_json" | jq -r '.total // 0' 2>/dev/null) add_metric "snipeit_licenses_total" "gauge" "Total number of licenses" "${total:-0}" if [[ "$total" -gt 0 ]]; then OUTPUT+="# HELP snipeit_license_seats_total Total seats for a license # TYPE snipeit_license_seats_total gauge # HELP snipeit_license_seats_used Used seats for a license # TYPE snipeit_license_seats_used gauge # HELP snipeit_license_seats_free Free seats for a license # TYPE snipeit_license_seats_free gauge " local i for ((i = 0; i < total && i < 50; i++)); do local name seats_total seats_used seats_free name=$(echo "$lic_json" | jq -r ".rows[$i].name // empty" 2>/dev/null) seats_total=$(echo "$lic_json" | jq -r ".rows[$i].seats // 0" 2>/dev/null) seats_used=$(echo "$lic_json" | jq -r ".rows[$i].seats - .rows[$i].free_seats_count // 0" 2>/dev/null) seats_free=$(echo "$lic_json" | jq -r ".rows[$i].free_seats_count // 0" 2>/dev/null) if [[ -n "$name" ]]; then local safe_name safe_name=$(sanitize_label "$name") local label="license=\"${safe_name}\"" add_metric_value "snipeit_license_seats_total" "${seats_total:-0}" "$label" add_metric_value "snipeit_license_seats_used" "${seats_used:-0}" "$label" add_metric_value "snipeit_license_seats_free" "${seats_free:-0}" "$label" fi done fi } collect_consumables() { local con_json con_json=$(api_get "/consumables?limit=100") if [[ -z "$con_json" ]]; then return fi local total total=$(echo "$con_json" | jq -r '.total // 0' 2>/dev/null) add_metric "snipeit_consumables_total" "gauge" "Total number of consumable types" "${total:-0}" if [[ "$total" -gt 0 ]]; then OUTPUT+="# HELP snipeit_consumable_remaining Remaining quantity of a consumable # TYPE snipeit_consumable_remaining gauge # HELP snipeit_consumable_min_qty Minimum quantity alert threshold for a consumable # TYPE snipeit_consumable_min_qty gauge " local i for ((i = 0; i < total && i < 50; i++)); do local name remaining min_qty name=$(echo "$con_json" | jq -r ".rows[$i].name // empty" 2>/dev/null) remaining=$(echo "$con_json" | jq -r ".rows[$i].remaining // 0" 2>/dev/null) min_qty=$(echo "$con_json" | jq -r ".rows[$i].min_amt // 0" 2>/dev/null) if [[ -n "$name" ]]; then local safe_name safe_name=$(sanitize_label "$name") local label="consumable=\"${safe_name}\"" add_metric_value "snipeit_consumable_remaining" "${remaining:-0}" "$label" add_metric_value "snipeit_consumable_min_qty" "${min_qty:-0}" "$label" fi done fi } collect_warranty() { local expiry_date expiry_date=$(date -d "+${WARRANTY_DAYS} days" +%Y-%m-%d 2>/dev/null || date -v+${WARRANTY_DAYS}d +%Y-%m-%d 2>/dev/null) if [[ -z "$expiry_date" ]]; then return fi local warranty_json warranty_json=$(api_get "/hardware?warranty_lookup=true&limit=1&order=asc&sort=warranty_months") # Count assets with warranty expiring within threshold # The API does not have a direct warranty filter, so count from the activity report local audit_json audit_json=$(api_get "/reports/activity?limit=1") # Fallback: use total and note that warranty filtering requires manual check add_metric "snipeit_assets_warranty_expiring" "gauge" "Assets with warranty expiring within ${WARRANTY_DAYS} days (approximate)" "0" } collect_counts() { # Users local users_json users_json=$(api_get "/users?limit=1") if [[ -n "$users_json" ]]; then local users_total users_total=$(echo "$users_json" | jq -r '.total // 0' 2>/dev/null) add_metric "snipeit_users_total" "gauge" "Total number of users" "${users_total:-0}" fi # Locations local loc_json loc_json=$(api_get "/locations?limit=1") if [[ -n "$loc_json" ]]; then local loc_total loc_total=$(echo "$loc_json" | jq -r '.total // 0' 2>/dev/null) add_metric "snipeit_locations_total" "gauge" "Total number of locations" "${loc_total:-0}" fi } # --- Output --- write_output() { if [[ "$TEXTFILE_MODE" == true ]]; then local output_file="${TEXTFILE_DIR}/snipeit.prom" local temp_file="${output_file}.$$" mkdir -p "$TEXTFILE_DIR" echo "$OUTPUT" > "$temp_file" mv "$temp_file" "$output_file" echo "# Wrote metrics to ${output_file}" >&2 else echo "$OUTPUT" fi } serve_http() { if ! command -v nc &>/dev/null && ! command -v ncat &>/dev/null; then echo "# ERROR: nc (netcat) or ncat required for HTTP mode" >&2 exit 1 fi echo "# Snipe-IT exporter listening on port ${HTTP_PORT}" >&2 echo "# Metrics endpoint: http://localhost:${HTTP_PORT}/metrics" >&2 local nc_cmd="nc" if command -v ncat &>/dev/null; then nc_cmd="ncat" fi while true; do OUTPUT="" START_TIME=$(date +%s%N) add_metric "snipeit_exporter_info" "gauge" "Exporter version information" "1" "version=\"${VERSION}\"" if collect_status; then collect_assets collect_licenses collect_consumables collect_warranty collect_counts fi local end_time duration end_time=$(date +%s%N) duration=$(echo "scale=2; ($end_time - $START_TIME) / 1000000000" | bc 2>/dev/null || echo "0") add_metric "snipeit_exporter_duration_seconds" "gauge" "Time to generate all metrics" "$duration" add_metric "snipeit_exporter_last_run_timestamp" "gauge" "Unix timestamp of last successful run" "$(date +%s)" local content_length=${#OUTPUT} local response="HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4; charset=utf-8\r\nContent-Length: ${content_length}\r\nConnection: close\r\n\r\n${OUTPUT}" echo -e "$response" | $nc_cmd -l -p "$HTTP_PORT" -q 1 2>/dev/null || \ echo -e "$response" | $nc_cmd -l "$HTTP_PORT" -c 2>/dev/null || \ echo -e "$response" | $nc_cmd -l -p "$HTTP_PORT" 2>/dev/null || true done } install_cron() { if [[ $EUID -ne 0 ]]; then echo "# ERROR: --install requires root" >&2 exit 1 fi local script_path script_path=$(readlink -f "$0") cat > /etc/cron.d/snipeit-exporter </dev/null EOF chmod 644 /etc/cron.d/snipeit-exporter echo "# Installed cron job: /etc/cron.d/snipeit-exporter" >&2 echo "# Metrics will be written to: ${TEXTFILE_DIR}/snipeit.prom" >&2 } # --- Main --- main() { for arg in "$@"; do case "$arg" in --textfile) TEXTFILE_MODE=true ;; --http) HTTP_MODE=true ;; -p|--port) shift; HTTP_PORT="${1:-$HTTP_PORT}" ;; --install) check_dependencies validate_config install_cron exit 0 ;; --help|-h) usage ;; *) ;; esac done check_dependencies validate_config if [[ "$HTTP_MODE" == true ]]; then serve_http exit 0 fi START_TIME=$(date +%s%N) add_metric "snipeit_exporter_info" "gauge" "Exporter version information" "1" "version=\"${VERSION}\"" if collect_status; then collect_assets collect_licenses collect_consumables collect_warranty collect_counts fi local end_time duration end_time=$(date +%s%N) duration=$(echo "scale=2; ($end_time - $START_TIME) / 1000000000" | bc 2>/dev/null || echo "0") add_metric "snipeit_exporter_duration_seconds" "gauge" "Time to generate all metrics" "$duration" add_metric "snipeit_exporter_last_run_timestamp" "gauge" "Unix timestamp of last successful run" "$(date +%s)" write_output } main "$@"