bbrkn/scripts/generate-configs.sh
2026-03-21 22:12:58 +03:00

348 lines
12 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -euo pipefail
# ==============================
# Конфигурация через переменные окружения
# ==============================
INPUT_FILE="${DOMAINS_FILE:-domains.txt}"
IPSET_CONF="${IPSET_CONF:-/tmp/91-ipset-bbrkn.conf}"
RESOLVE_CONF="${RESOLVE_CONF:-/tmp/92-resolve-bbrkn.conf}"
API_URL="${CHROME_SERVER:-http://127.0.0.1:3000}/domains?domain="
DNS_SERVER="${DNS_SERVER:-8.8.8.8}"
# Игнорируемые части доменов (разделитель — пробел)
IGNORE_PARTS="${IGNORE_PARTS:-}"
# Debug knobs
DEBUG="${DEBUG:-0}"
DEBUG_LOG="${DEBUG_LOG:-/tmp/generate-configs.debug.log}"
# Кэш API-ответов. TTL в днях; 0 — отключить кэш.
CACHE_DIR="${CACHE_DIR:-.cache/api}"
CACHE_TTL_DAYS="${CACHE_TTL_DAYS:-15}"
# При STRICT_MODE=1 завершается с кодом 1 если есть ошибки API
STRICT_MODE="${STRICT_MODE:-0}"
DRY_RUN=false
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN=true
fi
# Helpers
log() { printf '%s\n' "$*"; }
dbg() { if [ "$DEBUG" != "0" ]; then printf '[DEBUG] %s\n' "$*" | tee -a "$DEBUG_LOG" >&2; fi; }
err() { printf '[ERROR] %s\n' "$*" >&2; }
warn() { printf '[WARN] %s\n' "$*" >&2; }
if ! command -v curl >/dev/null 2>&1; then err "curl is required"; exit 2; fi
if ! command -v jq >/dev/null 2>&1; then err "jq is required"; exit 2; fi
if [ "$DEBUG" != "0" ]; then : > "$DEBUG_LOG"; dbg "Debugging enabled"; fi
log "Starting generate-configs.sh"
dbg "ENV: INPUT_FILE=$INPUT_FILE IPSET_CONF=$IPSET_CONF RESOLVE_CONF=$RESOLVE_CONF API_URL=$API_URL DNS_SERVER=$DNS_SERVER IGNORE_PARTS='$IGNORE_PARTS' DRY_RUN=$DRY_RUN CACHE_DIR=$CACHE_DIR CACHE_TTL_DAYS=$CACHE_TTL_DAYS STRICT_MODE=$STRICT_MODE"
if [ ! -f "$INPUT_FILE" ]; then
err "Input file not found: $INPUT_FILE"
exit 3
fi
declare -A DOM_ROLE
declare -A EXPANDED
declare -A SOURCES
declare -A ERRORS
declare -A VALID_SITES
declare -A SITE_RELATED # base_domain -> space-separated list of its related domains
declare -A IGNORED_DOMAINS
total_lines=0
normalized_ok=0
normalized_skip=0
api_success=0
api_error=0
related_total=0
ignored_total=0
cache_hit=0
cache_expired=0
# Оптимизировано: ~1 подпроцесс на вызов (вместо 6 в оригинале).
# Регекс проверяет RFC 1123: каждый лейбл начинается и заканчивается буквой/цифрой.
normalize_domain() {
local raw="${1%%#*}" # strip inline comment (bash)
raw="${raw#"${raw%%[! $'\t']*}"}" # ltrim whitespace (bash)
raw="${raw%"${raw##*[! $'\t']}"}" # rtrim whitespace (bash)
[ -z "$raw" ] && return 1
raw="${raw,,}" # lowercase (bash builtin)
raw="${raw#\*.}" # strip wildcard prefix (bash)
raw=$(sed -E 's/^\.+//; s/\.+$//; s/\.+/./g' <<< "$raw") # normalize dots (1 subprocess)
[ -z "$raw" ] && return 1
# RFC 1123: каждый лейбл начинается и заканчивается на [a-z0-9], внутри допустимы дефисы
[[ "$raw" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || return 1
[[ "$raw" =~ \.[a-z0-9-]{2,}$ ]] || return 2 # TLD минимум 2 символа
printf '%s' "$raw"
}
should_ignore_domain() {
local dom="$1"
for part in $IGNORE_PARTS; do
if [[ "$dom" == *"$part"* ]]; then
dbg " -> IGNORE domain '$dom' (matched '$part')"
IGNORED_DOMAINS["$dom"]="$part"
return 0
fi
done
return 1
}
# raw_total_lines через awk корректно считает строки даже без финального \n
raw_total_lines=$(awk 'END{print NR}' "$INPUT_FILE")
dbg "Raw input lines: $raw_total_lines"
# Один tmp-файл для тела curl-ответа — создаётся один раз, переиспользуется
tmp_body=$(mktemp)
trap 'rm -f "$tmp_body"' EXIT
# Глобальная переменная для ответа API (избегаем subshell — счётчики сохраняются)
RESP=""
max_attempts=5
# query_api <domain>
# Пишет ответ в RESP и возвращает 0 при успехе, 1 при полном провале.
# Кэширует успешные ответы в $CACHE_DIR/<domain>.json на $CACHE_TTL_DAYS дней.
query_api() {
local dom="$1"
local cache_file="$CACHE_DIR/${dom}.json"
RESP=""
# --- Проверяем кэш (если TTL > 0) ---
if [ "$CACHE_TTL_DAYS" -gt 0 ] && [[ -f "$cache_file" ]]; then
# find возвращает путь только если файл СТАРШЕ TTL; пустой вывод — кэш свежий
if ! find "$cache_file" -mtime "+${CACHE_TTL_DAYS}" 2>/dev/null | grep -q .; then
if jq -e . < "$cache_file" >/dev/null 2>&1; then
dbg " -> Cache HIT for $dom (TTL: ${CACHE_TTL_DAYS}d)"
cache_hit=$((cache_hit + 1))
RESP=$(<"$cache_file")
return 0
fi
dbg " -> Cache file corrupt for $dom, re-fetching"
else
dbg " -> Cache EXPIRED for $dom"
cache_expired=$((cache_expired + 1))
fi
fi
# --- Запрос к API с повторными попытками ---
local attempt=0 http_code
while :; do
attempt=$((attempt + 1))
dbg " -> API attempt #$attempt for $dom"
http_code=$(curl -sS --compressed -m 10 --connect-timeout 5 --retry 2 --retry-connrefused \
-H 'Accept: application/json' \
-o "$tmp_body" -w '%{http_code}' \
"${API_URL}${dom}" 2>>"$DEBUG_LOG" || echo "000")
if [ "$http_code" = "200" ] && jq -e . < "$tmp_body" >/dev/null 2>&1; then
if [ "$CACHE_TTL_DAYS" -gt 0 ]; then
mkdir -p "$CACHE_DIR"
cp "$tmp_body" "$cache_file"
fi
RESP=$(<"$tmp_body")
return 0
fi
dbg " -> HTTP=$http_code"
if [ "$attempt" -ge "$max_attempts" ]; then
return 1
fi
sleep_time=$(( 1 << (attempt - 1) ))
dbg " -> Retry after ${sleep_time}s..."
sleep "$sleep_time"
done
}
# ==============================
# Основной цикл обработки доменов
# ==============================
lineno=0
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno+1))
total_lines=$((total_lines+1))
dbg "Processing line #$lineno: '$line'"
dom_norm="$(normalize_domain "$line" || true)"
if [ -z "$dom_norm" ]; then
normalized_skip=$((normalized_skip+1))
dbg " -> SKIP (normalization failed)"
continue
fi
normalized_ok=$((normalized_ok+1))
dbg " -> NORMALIZED: $dom_norm"
if should_ignore_domain "$dom_norm"; then
ignored_total=$((ignored_total+1))
continue
fi
if ! query_api "$dom_norm"; then
# API полностью недоступен (не 200 / не JSON после всех попыток).
# Домен явно указан в domains.txt — добавляем без субдоменов и фиксируем в отчёте.
api_error=$((api_error+1))
ERRORS["$dom_norm"]="api_failed_added_without_subdomains"
dbg " -> Failed after $max_attempts attempts, adding as fallback (no subdomains)"
DOM_ROLE["$dom_norm"]="service"
SOURCES["$dom_norm"]="base"
EXPANDED["$dom_norm"]=1
continue
fi
resp="$RESP"
if jq -e 'has("error")' <<<"$resp" >/dev/null; then
err_msg="$(jq -r '.error' <<<"$resp")"
dbg " -> API error: $err_msg"
if grep -Eq "ERR_NAME_NOT_RESOLVED|Timeout" <<<"$err_msg"; then
# Домен не резолвится в DNS — бессмысленно добавлять в маршрутизацию
continue
fi
# Не-HTTP сервис (SMTP, игровой сервер, VPN и т.п.) — домен существует в DNS,
# но Chromium не может его обойти. Добавляем без субдоменов. Это не ошибка.
DOM_ROLE["$dom_norm"]="service"
SOURCES["$dom_norm"]="base"
EXPANDED["$dom_norm"]=1
continue
fi
api_success=$((api_success+1))
DOM_ROLE["$dom_norm"]="site"
SOURCES["$dom_norm"]="base"
EXPANDED["$dom_norm"]=1
VALID_SITES["$dom_norm"]=1
mapfile -t subs < <(jq -r '.relatedDomains[]? // empty' <<<"$resp")
dbg " -> API returned ${#subs[@]} related domains"
for s in "${subs[@]}"; do
nd="$(normalize_domain "$s" || true)"
[ -z "$nd" ] && continue
if should_ignore_domain "$nd"; then
ignored_total=$((ignored_total+1))
continue
fi
# Инкрементируем related_total только для новых доменов (фикс дублей)
[ -z "${EXPANDED[$nd]:-}" ] && related_total=$((related_total+1))
EXPANDED["$nd"]=1
[ -z "${SOURCES[$nd]:-}" ] && SOURCES["$nd"]="related"
SITE_RELATED["$dom_norm"]+="$nd "
done
done < "$INPUT_FILE"
# ==============================
# Строим упорядоченный список: базовые домены + их субдомены, с комментариями
# ==============================
declare -A WRITTEN=()
declare -A OUTPUT_COMMENT=()
declare -a ORDERED_DOMS=()
for dom in $(printf '%s\n' "${!EXPANDED[@]}" | sort -u); do
[[ "${SOURCES[$dom]:-}" == "related" ]] && continue
# Собираем ещё не записанные связанные домены.
# while выполняется в subshell — читает снимок WRITTEN, что корректно исключает
# домены, уже занятые предыдущим базовым доменом.
mapfile -t base_related < <(
if [[ -n "${SITE_RELATED[$dom]:-}" ]]; then
tr ' ' '\n' <<< "${SITE_RELATED[$dom]}" | grep -v '^$' | sort -u | \
while IFS= read -r rd; do
[[ -z "${WRITTEN[$rd]:-}" ]] && printf '%s\n' "$rd"
done
fi
)
OUTPUT_COMMENT["$dom"]="# ${dom}${#base_related[@]} subdomains"
ORDERED_DOMS+=("$dom")
WRITTEN["$dom"]=1
for rd in "${base_related[@]+"${base_related[@]}"}"; do
ORDERED_DOMS+=("$rd")
WRITTEN["$rd"]=1
done
done
# Все оставшиеся домены (related чей базовый пропущен/упал с ошибкой)
for dom in $(printf '%s\n' "${!EXPANDED[@]}" | sort -u); do
[[ -n "${WRITTEN[$dom]:-}" ]] && continue
ORDERED_DOMS+=("$dom")
done
mapfile -t ALL_DOMAINS < <(printf "%s\n" "${!EXPANDED[@]}" | sort -u)
# Записываем конфиги одним батчем (не по одной строке через >>)
if ! $DRY_RUN; then
{
for d in "${ORDERED_DOMS[@]}"; do
[[ -n "${OUTPUT_COMMENT[$d]:-}" ]] && printf '%s\n' "${OUTPUT_COMMENT[$d]}"
printf 'ipset=/%s/bbrkn\n' "$d"
done
} > "$IPSET_CONF"
{
for d in "${ORDERED_DOMS[@]}"; do
[[ -n "${OUTPUT_COMMENT[$d]:-}" ]] && printf '%s\n' "${OUTPUT_COMMENT[$d]}"
printf 'server=/%s/%s\n' "$d" "$DNS_SERVER"
done
} > "$RESOLVE_CONF"
fi
# ==============================
# Итоговый отчёт
# ==============================
echo
echo "===== DEBUG REPORT ====="
echo "Input file: $INPUT_FILE"
echo "Raw input lines: $raw_total_lines"
echo "Processed lines: $total_lines"
echo "Normalized OK: $normalized_ok"
echo "Normalized skipped: $normalized_skip"
echo "Ignored (IGNORE_PARTS): $ignored_total"
echo
echo "Cache hits: $cache_hit (TTL: ${CACHE_TTL_DAYS}d, dir: $CACHE_DIR)"
echo "Cache expired/refetched: $cache_expired"
echo "API success (sites): $api_success"
echo "API error/failed: $api_error"
echo "Related domains added: $related_total"
echo "Final unique domains: ${#ALL_DOMAINS[@]}"
echo
echo "---- VALID BASE SITES ----"
printf '%s\n' "${!VALID_SITES[@]}" | sort
echo
if [ "$ignored_total" -gt 0 ]; then
echo "---- IGNORED DOMAINS ----"
for d in $(printf '%s\n' "${!IGNORED_DOMAINS[@]}" | sort); do
printf '%s (matched: %s)\n' "$d" "${IGNORED_DOMAINS[$d]}"
done
echo
fi
if [ "$api_error" -gt 0 ]; then
echo "---- API FAILURES (added without subdomains) ----"
for d in $(printf '%s\n' "${!ERRORS[@]}" | sort); do
printf '%s → %s\n' "$d" "${ERRORS[$d]}"
done
echo
fi
echo "===== END DEBUG REPORT ====="
if [ "$DEBUG" != "0" ]; then
echo "Detailed debug log: $DEBUG_LOG"
fi
# Завершаемся с ошибкой если STRICT_MODE=1 и были провалы API
if [ "$api_error" -gt 0 ]; then
warn "$api_error domain(s) failed API queries — see FAILED DOMAINS in report above"
if [ "$STRICT_MODE" != "0" ]; then
exit 1
fi
fi