#!/bin/bash # Incremental unauthorized connection checker # Reads the dedicated unauthorized-connections.log (populated by log-splitter.sh), # compares against seen-connections.json and authorized-ips.json, # and outputs structured JSON categorizing IPs as new or returning. # # Usage: bash check.sh # # Output JSON: # { # "timestamp": "2026-02-16T12:00:00Z", # "new_ips": [ { "ip": "...", "first_seen": "...", "reason": "...", ... } ], # "returning_ips": [ { "ip": "...", "new_attempts": 3, "latest": "...", ... } ], # "total_events": 12, # "whitelisted_skipped": 2 # } set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL_DIR="$(dirname "$SCRIPT_DIR")" UNAUTH_LOG="/tmp/openclaw/unauthorized-connections.log" STATE_DIR="/home/node/.openclaw/workspace-security/memory" OFFSET_FILE="$STATE_DIR/unauth-check-offset" SEEN_FILE="$SKILL_DIR/seen-connections.json" AUTH_FILE="$SKILL_DIR/authorized-ips.json" mkdir -p "$STATE_DIR" # Initialize state files if missing [ ! -s "$SEEN_FILE" ] && echo '{}' > "$SEEN_FILE" [ ! -s "$AUTH_FILE" ] && echo '{"whitelist":["127.0.0.1","::1","localhost"]}' > "$AUTH_FILE" # Check log exists if [ ! -f "$UNAUTH_LOG" ]; then echo '{"timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","new_ips":[],"returning_ips":[],"total_events":0,"whitelisted_skipped":0}' exit 0 fi FILE_SIZE=$(stat -c%s "$UNAUTH_LOG") # Read offset LAST_OFFSET=0 if [ -f "$OFFSET_FILE" ]; then LAST_OFFSET=$(cat "$OFFSET_FILE") fi # Handle log rotation / truncation if [ "$LAST_OFFSET" -gt "$FILE_SIZE" ]; then LAST_OFFSET=0 fi BYTES_NEW=$((FILE_SIZE - LAST_OFFSET)) if [ "$BYTES_NEW" -le 0 ]; then echo "$FILE_SIZE" > "$OFFSET_FILE" echo '{"timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","new_ips":[],"returning_ips":[],"total_events":0,"whitelisted_skipped":0}' exit 0 fi # Load whitelist WHITELIST=$(jq -r '.whitelist[]' "$AUTH_FILE" 2>/dev/null | tr '\n' '|' | sed 's/|$//') is_whitelisted() { local ip="$1" [[ "$ip" == "127.0.0.1" || "$ip" == "::1" || "$ip" == "localhost" ]] && return 0 [ -n "$WHITELIST" ] && echo "$ip" | grep -qE "^($WHITELIST)$" && return 0 return 1 } # Parse new log entries into per-IP aggregated data TMPFILE=$(mktemp) trap "rm -f $TMPFILE" EXIT tail -c +"$((LAST_OFFSET + 1))" "$UNAUTH_LOG" > "$TMPFILE" TOTAL_EVENTS=0 WHITELISTED=0 declare -A IP_EVENTS # ip -> JSON array of events declare -A IP_LATEST # ip -> latest timestamp declare -A IP_COUNT # ip -> count of events in this batch while IFS= read -r line; do [ -z "$line" ] && continue TOTAL_EVENTS=$((TOTAL_EVENTS + 1)) # Extract fields from JSON log line PARSED=$(echo "$line" | jq -r ' [ .time // "", (.["1"].forwardedFor // ""), (.["1"].authReason // .["1"].reason // .["1"].cause // ""), (.["1"].origin // ""), (.["1"].userAgent // ""), ((.["2"] // "") | tostring | (capture("remote=(?[0-9.]+)") | .r) // "") ] | @tsv ' 2>/dev/null) || continue IFS=$'\t' read -r ts ip reason origin ua remote <<< "$PARSED" [ -z "$ip" ] && continue if is_whitelisted "$ip"; then WHITELISTED=$((WHITELISTED + 1)) continue fi # Build event JSON EVENT=$(jq -cn \ --arg ts "$ts" \ --arg ip "$ip" \ --arg reason "$reason" \ --arg origin "$origin" \ --arg ua "$ua" \ --arg remote "$remote" \ '{timestamp:$ts, ip:$ip, reason:$reason, origin:$origin, user_agent:$ua, remote:$remote}') # Aggregate by IP if [ -z "${IP_EVENTS[$ip]+x}" ]; then IP_EVENTS[$ip]="$EVENT" IP_COUNT[$ip]=1 else IP_EVENTS[$ip]="${IP_EVENTS[$ip]}"$'\n'"$EVENT" IP_COUNT[$ip]=$(( ${IP_COUNT[$ip]} + 1 )) fi IP_LATEST[$ip]="$ts" done < "$TMPFILE" # Categorize IPs as new or returning NEW_IPS="[]" RETURNING_IPS="[]" for ip in "${!IP_EVENTS[@]}"; do COUNT=${IP_COUNT[$ip]} LATEST=${IP_LATEST[$ip]} # Get first event for details FIRST_EVENT=$(echo "${IP_EVENTS[$ip]}" | head -1) REASON=$(echo "$FIRST_EVENT" | jq -r '.reason') ORIGIN=$(echo "$FIRST_EVENT" | jq -r '.origin') UA=$(echo "$FIRST_EVENT" | jq -r '.user_agent') REMOTE=$(echo "$FIRST_EVENT" | jq -r '.remote') # Check if IP was previously seen if jq -e --arg ip "$ip" 'has($ip)' "$SEEN_FILE" >/dev/null 2>&1; then # Returning IP — update seen record, add to returning list PREV_COUNT=$(jq -r --arg ip "$ip" '.[$ip].total_attempts // 0' "$SEEN_FILE") NEW_TOTAL=$((PREV_COUNT + COUNT)) STMP=$(mktemp) jq --arg ip "$ip" \ --arg latest "$LATEST" \ --argjson count "$NEW_TOTAL" \ --argjson batch "$COUNT" \ '.[$ip].last_seen = $latest | .[$ip].total_attempts = $count | .[$ip].attempts_this_batch = $batch' \ "$SEEN_FILE" > "$STMP" && mv "$STMP" "$SEEN_FILE" RETURNING_IPS=$(echo "$RETURNING_IPS" | jq \ --arg ip "$ip" \ --arg latest "$LATEST" \ --argjson new_attempts "$COUNT" \ --argjson total "$NEW_TOTAL" \ --arg reason "$REASON" \ --arg origin "$ORIGIN" \ --arg ua "$UA" \ --arg remote "$REMOTE" \ '. += [{ip:$ip, latest:$latest, new_attempts:$new_attempts, total_attempts:$total, reason:$reason, origin:$origin, user_agent:$ua, remote:$remote}]') else # New IP — record it, add to new list STMP=$(mktemp) jq --arg ip "$ip" \ --arg ts "$LATEST" \ --arg reason "$REASON" \ --arg origin "$ORIGIN" \ --arg ua "$UA" \ --arg remote "$REMOTE" \ --argjson count "$COUNT" \ '. + {($ip): {first_seen: $ts, last_seen: $ts, reason: $reason, origin: $origin, user_agent: $ua, remote: $remote, total_attempts: $count}}' \ "$SEEN_FILE" > "$STMP" && mv "$STMP" "$SEEN_FILE" NEW_IPS=$(echo "$NEW_IPS" | jq \ --arg ip "$ip" \ --arg first_seen "$LATEST" \ --argjson attempts "$COUNT" \ --arg reason "$REASON" \ --arg origin "$ORIGIN" \ --arg ua "$UA" \ --arg remote "$REMOTE" \ '. += [{ip:$ip, first_seen:$first_seen, attempts:$attempts, reason:$reason, origin:$origin, user_agent:$ua, remote:$remote}]') fi done # Save new offset echo "$FILE_SIZE" > "$OFFSET_FILE" # Output structured result jq -n \ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --argjson new_ips "$NEW_IPS" \ --argjson returning_ips "$RETURNING_IPS" \ --argjson total "$TOTAL_EVENTS" \ --argjson whitelisted "$WHITELISTED" \ '{ timestamp: $ts, new_ips: $new_ips, returning_ips: $returning_ips, total_events: $total, whitelisted_skipped: $whitelisted }'