#!/bin/bash ################################################################################ # Script Name: game-server-exporter.sh # Version: 1.0 # Description: Prometheus exporter for game servers providing operational # metrics — Minecraft, Valheim, and Palworld player counts, # server status, TPS, query response times, and server version info # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # # Prerequisites: # - nmap-ncat (nc) for network queries # - curl for REST API queries (Palworld) # - python3 with mcstatus (optional, enhanced Minecraft metrics) # - netcat (nc) for HTTP mode # # Usage: # # Output to stdout # ./game-server-exporter.sh # # # HTTP server mode # ./game-server-exporter.sh --http -p 9195 # # # Textfile collector mode # ./game-server-exporter.sh --textfile # # # Custom server addresses # ./game-server-exporter.sh --minecraft-host mc.example.com # # Metrics Exported: # - game_server_up{game,server} - Server reachability (1=up, 0=down) # - game_server_players_online{game,server} - Online player count # - game_server_players_max{game,server} - Maximum player slots # - game_server_info{game,server,version,motd} - Server version info # - game_server_tps{game="minecraft",server} - Ticks per second (Minecraft) # - game_server_query_duration_seconds{game,server} - Query time per server # - game_server_exporter_duration_seconds - Total script execution time # - game_server_exporter_last_run_timestamp - Last run timestamp # # Configuration: # Default HTTP port: 9195 # Textfile directory: /var/lib/node_exporter # ################################################################################ set -euo pipefail # ============================================================================ # CONFIGURATION VARIABLES # ============================================================================ TEXTFILE_DIR="/var/lib/node_exporter" OUTPUT_FILE="" HTTP_MODE=false HTTP_PORT=9195 # Server configuration MINECRAFT_HOST="" MINECRAFT_QUERY_PORT=25565 MINECRAFT_RCON_PORT=25575 MINECRAFT_RCON_PASS="" VALHEIM_HOST="" VALHEIM_QUERY_PORT=2457 PALWORLD_HOST="" PALWORLD_QUERY_PORT=8212 # ============================================================================ # HELPER FUNCTIONS # ============================================================================ show_usage() { cat <&2; exit 1 ;; esac done } # Check prerequisites # Returns: 0 if OK, 1 if error check_prerequisites() { if ! command -v nc >/dev/null 2>&1; then echo "ERROR: nc (nmap-ncat) not found" >&2 return 1 fi if [ -n "$PALWORLD_HOST" ] && ! command -v curl >/dev/null 2>&1; then echo "ERROR: curl not found (required for Palworld REST API)" >&2 return 1 fi return 0 } # Escape special characters in Prometheus label values # Args: $1 - string to escape # Returns: escaped string safe for Prometheus labels prom_escape() { local val="$1" val="${val//\\/\\\\}" val="${val//\"/\\\"}" val="${val//$'\n'/}" echo "$val" } # ============================================================================ # GAME SERVER QUERY FUNCTIONS # ============================================================================ # Query Minecraft server using python3 mcstatus or basic TCP check # Args: $1 - host, $2 - port # Sets global variables: mc_up, mc_players_online, mc_players_max, mc_version, mc_motd, mc_tps, mc_query_duration query_minecraft() { local host="$1" local port="$2" local query_start query_end mc_up=0 mc_players_online=0 mc_players_max=0 mc_version="unknown" mc_motd="unknown" mc_tps="" mc_query_duration=0 query_start=$(date +%s%N) # Try python3 mcstatus first (most reliable) if command -v python3 >/dev/null 2>&1; then local py_result py_result=$(python3 -c " import sys try: from mcstatus import JavaServer server = JavaServer.lookup('${host}:${port}', timeout=5) status = server.status() print('UP') print(status.players.online) print(status.players.max) print(status.version.name) desc = status.description if isinstance(desc, dict): desc = desc.get('text', 'unknown') print(str(desc).replace(chr(10), ' ')) except ImportError: print('NO_MCSTATUS') except Exception as e: print('DOWN') " 2>/dev/null) || true local first_line first_line=$(echo "$py_result" | head -1) if [ "$first_line" = "UP" ]; then mc_up=1 mc_players_online=$(echo "$py_result" | sed -n '2p') mc_players_max=$(echo "$py_result" | sed -n '3p') mc_version=$(echo "$py_result" | sed -n '4p') mc_motd=$(echo "$py_result" | sed -n '5p') elif [ "$first_line" != "NO_MCSTATUS" ]; then # mcstatus available but server is down query_end=$(date +%s%N) mc_query_duration=$(( (query_end - query_start) / 1000000000 )) return fi fi # Fallback: basic TCP check if mcstatus not available or not tried yet if [ "$mc_up" -eq 0 ] && [ -z "${py_result:-}" ] || { [ -n "${first_line:-}" ] && [ "$first_line" = "NO_MCSTATUS" ]; }; then if nc -z -w 3 "$host" "$port" 2>/dev/null; then mc_up=1 # Try to read SLP response for basic info local slp_response slp_response=$(printf '\xfe\x01' | nc -w 3 "$host" "$port" 2>/dev/null | strings 2>/dev/null) || true if [ -n "$slp_response" ]; then # Legacy SLP response: §1\0\0\0\0\0 mc_version=$(echo "$slp_response" | tr '\0' '\n' | sed -n '4p' 2>/dev/null) || mc_version="unknown" mc_motd=$(echo "$slp_response" | tr '\0' '\n' | sed -n '5p' 2>/dev/null) || mc_motd="unknown" mc_players_online=$(echo "$slp_response" | tr '\0' '\n' | sed -n '6p' 2>/dev/null) || mc_players_online=0 mc_players_max=$(echo "$slp_response" | tr '\0' '\n' | sed -n '7p' 2>/dev/null) || mc_players_max=0 # Sanitize numeric values [[ "$mc_players_online" =~ ^[0-9]+$ ]] || mc_players_online=0 [[ "$mc_players_max" =~ ^[0-9]+$ ]] || mc_players_max=0 fi fi fi # Try RCON for TPS if credentials are provided and server is up if [ "$mc_up" -eq 1 ] && [ -n "$MINECRAFT_RCON_PASS" ]; then local tps_result tps_result=$(python3 -c " import sys try: from mcrcon import MCRcon with MCRcon('${host}', '${MINECRAFT_RCON_PASS}', port=${MINECRAFT_RCON_PORT}) as mcr: resp = mcr.command('tps') # Parse TPS from response (e.g., '§6TPS from last 1m, 5m, 15m: §a20.0, §a20.0, §a20.0') import re nums = re.findall(r'[\d.]+', resp) if nums: print(nums[-1]) # Last TPS value (15m average) except Exception: pass " 2>/dev/null) || true if [ -n "$tps_result" ]; then mc_tps="$tps_result" fi fi query_end=$(date +%s%N) mc_query_duration=$(( (query_end - query_start) / 1000000000 )) } # Query Valheim server using Steam A2S protocol or TCP fallback # Args: $1 - host, $2 - port # Sets global variables: vh_up, vh_players_online, vh_players_max, vh_version, vh_motd, vh_query_duration query_valheim() { local host="$1" local port="$2" local query_start query_end vh_up=0 vh_players_online=0 vh_players_max=0 vh_version="unknown" vh_motd="unknown" vh_query_duration=0 query_start=$(date +%s%N) # Try python3 A2S query first (Steam query protocol) if command -v python3 >/dev/null 2>&1; then local py_result py_result=$(python3 -c " import sys try: import a2s address = ('${host}', ${port}) info = a2s.info(address, timeout=5) print('UP') print(info.player_count) print(info.max_players) print(info.version) print(info.server_name.replace(chr(10), ' ')) except ImportError: print('NO_A2S') except Exception: print('DOWN') " 2>/dev/null) || true local first_line first_line=$(echo "$py_result" | head -1) if [ "$first_line" = "UP" ]; then vh_up=1 vh_players_online=$(echo "$py_result" | sed -n '2p') vh_players_max=$(echo "$py_result" | sed -n '3p') vh_version=$(echo "$py_result" | sed -n '4p') vh_motd=$(echo "$py_result" | sed -n '5p') elif [ "$first_line" != "NO_A2S" ]; then query_end=$(date +%s%N) vh_query_duration=$(( (query_end - query_start) / 1000000000 )) return fi fi # Fallback: TCP port check on game port (query port - 1 is typically the game port) if [ "$vh_up" -eq 0 ]; then local game_port=$((port - 1)) if nc -z -w 3 "$host" "$game_port" 2>/dev/null || nc -z -w 3 "$host" "$port" 2>/dev/null; then vh_up=1 fi fi query_end=$(date +%s%N) vh_query_duration=$(( (query_end - query_start) / 1000000000 )) } # Query Palworld server using REST API or TCP fallback # Args: $1 - host, $2 - port # Sets global variables: pw_up, pw_players_online, pw_players_max, pw_version, pw_motd, pw_query_duration query_palworld() { local host="$1" local port="$2" local query_start query_end pw_up=0 pw_players_online=0 pw_players_max=0 pw_version="unknown" pw_motd="unknown" pw_query_duration=0 query_start=$(date +%s%N) # Try REST API query first if command -v curl >/dev/null 2>&1; then local api_response api_response=$(curl -s -m 5 "http://${host}:${port}/v1/api/info" 2>/dev/null) || true if [ -n "$api_response" ] && command -v python3 >/dev/null 2>&1; then local parse_result parse_result=$(python3 -c " import json, sys try: data = json.loads('''${api_response}''') print('UP') print(data.get('currentPlayerNum', 0)) print(data.get('maxPlayerNum', 0)) print(data.get('version', 'unknown')) print(data.get('serverName', 'unknown').replace(chr(10), ' ')) except Exception: print('PARSE_FAIL') " 2>/dev/null) || true local first_line first_line=$(echo "$parse_result" | head -1) if [ "$first_line" = "UP" ]; then pw_up=1 pw_players_online=$(echo "$parse_result" | sed -n '2p') pw_players_max=$(echo "$parse_result" | sed -n '3p') pw_version=$(echo "$parse_result" | sed -n '4p') pw_motd=$(echo "$parse_result" | sed -n '5p') fi elif [ -n "$api_response" ]; then # curl got a response but no python3 to parse JSON pw_up=1 fi fi # Fallback: TCP port check if [ "$pw_up" -eq 0 ]; then if nc -z -w 3 "$host" "$port" 2>/dev/null; then pw_up=1 fi fi query_end=$(date +%s%N) pw_query_duration=$(( (query_end - query_start) / 1000000000 )) } # ============================================================================ # METRIC GENERATION # ============================================================================ # Generate all Prometheus metrics # Returns: Prometheus text format metrics on stdout generate_metrics() { local script_start script_start=$(date +%s) # Check prerequisites if ! check_prerequisites; then return fi # Check that at least one server is configured if [ -z "$MINECRAFT_HOST" ] && [ -z "$VALHEIM_HOST" ] && [ -z "$PALWORLD_HOST" ]; then echo "# No game servers configured. Use --minecraft-host, --valheim-host, or --palworld-host" >&2 return fi cat <&2 if ! command -v nc >/dev/null 2>&1; then echo "ERROR: netcat (nc) required for HTTP mode" >&2 exit 1 fi # Infinite loop accepting HTTP requests while true; do { read -r request # Check if request is for /metrics endpoint if [[ "$request" =~ ^GET\ /metrics ]]; then echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\n\r" generate_metrics else # Serve HTML landing page for other requests echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r" cat < Game Server Exporter v1.0

Game Server Prometheus Exporter v1.0

Metrics

Operational metrics from Minecraft, Valheim, and Palworld servers.

EOF fi } | nc -l -p "$HTTP_PORT" -q 1 2>/dev/null done } # ============================================================================ # MAIN EXECUTION # ============================================================================ # Main entry point - routes to appropriate output mode main() { parse_args "$@" if [ "$HTTP_MODE" = true ]; then # Run HTTP server (blocks until killed) run_http_server elif [ -n "$OUTPUT_FILE" ]; then # Textfile collector mode: write atomically using temp file local output_dir output_dir="$(dirname "$OUTPUT_FILE")" mkdir -p "$output_dir" # Create temp file in SAME directory for atomic rename (same filesystem) local temp_file temp_file=$(mktemp "${output_dir}/.game_server_metrics.XXXXXX") # Generate metrics to temp file if ! generate_metrics > "$temp_file" 2>/dev/null; then rm -f "$temp_file" echo "ERROR: Failed to generate metrics" >&2 exit 1 fi # Validate: file must exist, have content local file_lines file_lines=$(wc -l < "$temp_file" 2>/dev/null || echo 0) if [ "$file_lines" -lt 10 ]; then rm -f "$temp_file" echo "ERROR: Metrics file too small ($file_lines lines), keeping previous" >&2 exit 1 fi # Set permissions before move chmod 644 "$temp_file" # Atomic rename - no gap where file is missing mv -f "$temp_file" "$OUTPUT_FILE" echo "Metrics written to $OUTPUT_FILE ($file_lines lines)" >&2 else # Default: output to stdout generate_metrics fi } # Execute main function with all script arguments main "$@"