这个代码 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 "$@"