这个代码 Gemini Pro 2.5 错了很多次之后,终于写对了。

依赖:curl, jq

  • by default, it runs in ‘dry run’ mode.
  • use --apply flag to actully send updates to adguard home.
  • ENV variables are ‘mandate’.
#!/bin/bash
 
# set -e: Exit immediately if a command exits with a non-zero status.
# set -u: Treat unset variables as an error when substituting.
# set -o pipefail: The return value of a pipeline is the status of the last command to exit with a non-zero status.
set -euo pipefail
 
# --- Help message function ---
usage() {
    cat <<EOF
Usage: $(basename "$0") [options] <domain_suffix1> [domain_suffix2] ...
 
Safely synchronizes DNS rewrite rules from a hosts file to AdGuard Home.
This script is designed for maximum compatibility with older shell environments like Proxmox/Debian.
 
At least one domain suffix must be provided.
AdGuard Home credentials must be set via environment variables:
  - ADGUARD_URL: URL of AdGuard Home (e.g., http://192.168.1.2)
  - ADGUARD_USER: Username for AdGuard Home
  - ADGUARD_PASS: Password for AdGuard Home
 
Options:
  --hosts-file <path>   Path to the hosts file (default: /etc/hosts)
  --apply               Apply changes. Without this flag, the script runs in dry run mode.
  -h, --help            Show this help message
EOF
    exit 1
}
 
# --- Check dependencies ---
check_deps() {
    for cmd in curl jq awk sort; do
        if ! command -v "$cmd" &> /dev/null; then
            echo "Error: Core dependency '$cmd' not found. Please install it." >&2
            exit 1
        fi
    done
}
 
# --- API call function with error handling ---
api_call() {
    local method="$1"
    local endpoint="$2"
    local data="${3:-}"
    local full_url="${ADGUARD_URL}/control/${endpoint}"
 
    # Use a subshell to manage temporary files and traps cleanly
    (
        local response_body
        response_body=$(mktemp)
        # Ensure temp file is removed on any exit from the subshell
        trap 'rm -f "$response_body" 2>/dev/null' EXIT
 
        local http_status
        http_status=$(curl --connect-timeout 5 -sS -u "${ADGUARD_USER}:${ADGUARD_PASS}" \
            -X "$method" \
            -H "Content-Type: application/json" \
            ${data:+-d "$data"} \
            -o "$response_body" \
            -w '%{http_code}' \
            "$full_url")
 
        if [[ "$http_status" -ge 200 && "$http_status" -lt 300 ]]; then
            cat "$response_body"
            exit 0
        else
            echo "Error: AdGuard Home API call failed (Endpoint: ${endpoint})." >&2
            echo "HTTP Status: ${http_status}" >&2
            echo "Server Response:" >&2
            local error_body
            error_body=$(cat "$response_body")
            # Attempt to pretty-print JSON, otherwise print raw
            echo "$error_body" | jq . 2>/dev/null || echo "$error_body" >&2
            exit 1
        fi
    )
}
 
# --- Parse hosts file using AWK for maximum compatibility ---
parse_hosts_file() {
    local file_path="$1"
    shift
    # Create a regex pattern like '(.*\.lan$|.*\.opn\.lan$)'
    local suffixes_pattern
    suffixes_pattern=$(printf ".*\\.%s$|" "$@")
    suffixes_pattern="(${suffixes_pattern%|})" # Remove trailing '|' and wrap in ()
 
    if [[ ! -f "$file_path" ]]; then
        echo "Error: hosts file not found at '$file_path'" >&2
        exit 1
    fi
 
    # awk is more reliable and universal than complex shell loops for parsing
    awk -v suffixes="$suffixes_pattern" '
        # Skip comments and empty lines
        !/^\s*(#|$)/ {
            ip = $1
            # Loop through hostnames on the line
            for (i = 2; i <= NF; i++) {
                if ($i ~ suffixes) {
                    print $i, ip
                }
            }
        }
    ' "$file_path" | jq -Rn '[inputs | split(" ") | {key: .[0], value: .[1]}] | from_entries'
}
 
# --- Main Logic ---
main() {
    check_deps
 
    local DRY_RUN=true
    local HOSTS_FILE="/etc/hosts"
    
    local domains_to_sync=""
    # Simple, portable argument parsing
    while [ $# -gt 0 ]; do
        case "$1" in
            --apply) DRY_RUN=false; shift ;;
            --hosts-file) HOSTS_FILE="$2"; shift 2 ;;
            -h|--help) usage ;;
            -*) echo "Error: Unknown option '$1'" >&2; usage ;;
            *) domains_to_sync="$domains_to_sync $1"; shift ;;
        esac
    done
 
    # Trim leading space
    domains_to_sync=$(echo "$domains_to_sync" | awk '{$1=$1;print}')
 
    if [ -z "$domains_to_sync" ]; then
        echo "Error: You must provide at least one domain suffix." >&2
        usage
    fi
 
    if [ -z "${ADGUARD_URL:-}" ] || [ -z "${ADGUARD_USER:-}" ] || [ -z "${ADGUARD_PASS:-}" ]; then
        echo "Error: ADGUARD_URL, ADGUARD_USER, and ADGUARD_PASS environment variables must be set." >&2
        exit 1
    fi
 
    echo "1. Parsing local hosts file..."
    local local_records_json
    local_records_json=$(parse_hosts_file "$HOSTS_FILE" $domains_to_sync)
    echo " -> Found $(echo "$local_records_json" | jq 'keys | length') records to sync."
 
    echo "2. Fetching existing rewrite rules from AdGuard Home..."
    local remote_rules_json
    remote_rules_json=$(api_call "GET" "rewrite/list")
    local remote_records_json
    remote_records_json=$(echo "$remote_rules_json" | jq 'map({key: .domain, value: .answer}) | from_entries')
    echo " -> Found $(echo "$remote_rules_json" | jq 'length') existing rules."
 
    echo "3. Calculating changes..."
    
    # ** THE FIX **
    # Correctly build a regex pattern for jq.
    # Example: 'lan opn.lan' becomes '\.(lan|opn\.lan)$'
    local pattern=""
    for suffix in $domains_to_sync; do
        # Escape any dots in the suffix itself (e.g., opn.lan -> opn\.lan)
        local escaped_suffix
        escaped_suffix=$(echo "$suffix" | sed 's/\./\\./g')
        if [ -n "$pattern" ]; then
            pattern="$pattern|$escaped_suffix"
        else
            pattern="$escaped_suffix"
        fi
    done
    local domains_regex="\\.(${pattern})$"
 
    local action_plan
    action_plan=$(jq -r -n \
        --argjson local "$local_records_json" \
        --argjson remote "$remote_records_json" \
        --arg domains_regex "$domains_regex" \
        '
        # Calculate Deletes: in remote, is a managed domain, and (not in local or IP differs)
        ($remote | to_entries | map(
            select(
                (.key | test($domains_regex)) and
                (.key as $k | .value as $v | $local[$k] | not or . != $v)
            )
        ) | map("DELETE \(.key) \(.value)")),
 
        # Calculate Adds: in local, and (not in remote or IP differs)
        ($local | to_entries | map(
            select(.key as $k | .value as $v | $remote[$k] | not or . != $v)
        ) | map("ADD \(.key) \(.value)"))
 
        # Flatten the two arrays into a single stream of strings
        | .[]
        ')
 
    # --- Execute Plan ---
    if [ "$DRY_RUN" = "true" ]; then
        echo -e "\n================= DRY RUN MODE ================="
        echo "No changes will be made."
        if [ -z "$action_plan" ]; then
            echo "✅ Everything is already in sync."
        else
            # Use awk for stable sorting and formatting
            printf "%s\n" "$action_plan" | awk '{
                if ($1 == "DELETE") print "[DELETE] " $2 " -> " $3;
                if ($1 == "ADD")    print "[ADD]    " $2 " -> " $3;
            }' | sort -k2
        fi
        echo "=============================================="
    else
        echo -e "\n4. Applying changes..."
        if [ -z "$action_plan" ]; then
            echo "✅ Everything is already in sync. No changes made."
        else
            # Use a while loop with a here-string to avoid subshells
            # Sort with -k1r to process DELETEs before ADDs for atomicity
            while read -r action domain ip; do
                echo "[$action] $domain -> $ip"
                if [ "$action" = "DELETE" ]; then
                    api_call "POST" "rewrite/delete" "$(jq -nc --arg domain "$domain" --arg answer "$ip" '{domain: $domain, answer: $answer}')"
                elif [ "$action" = "ADD" ]; then
                    api_call "POST" "rewrite/add" "$(jq -nc --arg domain "$domain" --arg answer "$ip" '{domain: $domain, answer: $answer}')"
                fi
            done <<< "$(printf "%s\n" "$action_plan" | sort -k1r)"
            echo "✅ Sync complete."
        fi
    fi
}
 
# Run the main function with all provided arguments
main "$@"