#!/bin/ash # Утилита для сбора DNS-доменов из dnsmasq logs для дальнейшего добавления в Podkop. # Совместимо с OpenWrt BusyBox ash. Не использует bash-specific синтаксис. LOG_FILE="/tmp/podkop-domain-capture.log" PREV_FILE="/tmp/podkop-domain-capture.logqueries.prev" LEASES_FILE="/tmp/dhcp.leases" CLIENTS_FILE="/tmp/podkop-domain-capture.clients" LOG_IPS_FILE="/tmp/podkop-domain-capture.log-ips" SNI_AWK_FILE="/tmp/podkop-domain-capture.sni.awk" SNI_PID_FILE="/tmp/podkop-domain-capture.tcpdump.pid" DNS_PID_FILE="/tmp/podkop-domain-capture.logread.pid" TTY_DEV="/dev/tty" PDC_VERSION="0.4.0-beta" # Самообновление и зависимости. SCRIPT_URL="${PDC_SCRIPT_URL:-https://git.hubp.de/raw-githubusercontent-com/doxfie/Podkop-Domain-Capture/main/podkop-domain-capture.sh}" UPDATE_STAMP="/etc/podkop-domain-capture.stamp" UPDATE_INTERVAL="86400" TCPDUMP_PKG="tcpdump-mini" TCPDUMP_SIZE="~385 КБ" MAINTENANCE_NOTED="0" # Имя временной nft-таблицы. Отдельная таблица, чтобы не трогать fw4/podkop/zapret # и чтобы весь набор правил снимался одной командой delete table. NFT_TABLE="pdc_capture" # Источник доменов: dns | sni | both CAPTURE_SOURCE="both" # Временные сетевые правила на время сбора OPT_DNS_HIJACK="1" OPT_BLOCK_DOT="1" OPT_BLOCK_QUIC="1" NFT_ACTIVE="0" SNI_ACTIVE="0" DNS_ACTIVE="0" ESC_CHAR="$(printf '\033')" CR_CHAR="$(printf '\r')" MENU_CHOICE="" SELECTED_IPS="" # IPv6-адреса выбранных клиентов, вычисляются перед стартом сбора. SELECTED_IPS6="" SELECTED_MACS="" SELECTED_LOG_IP="" CAPTURE_ALL_SELECTED="0" CAPTURE_MESSAGE="" LOGS_ENABLED="0" TUI_LINE="---------------------------------------------------------" if [ -n "$NO_COLOR" ]; then TUI_RESET="" TUI_BOLD="" TUI_DIM="" TUI_GREEN="" TUI_CYAN="" TUI_YELLOW="" TUI_SELECTED="" else TUI_RESET="$(printf '\033[0m')" TUI_BOLD="$(printf '\033[1m')" TUI_DIM="$(printf '\033[2m')" TUI_GREEN="$(printf '\033[32m')" TUI_CYAN="$(printf '\033[36m')" TUI_YELLOW="$(printf '\033[33m')" TUI_SELECTED="$(printf '\033[1;30;42m')" fi # Отключаем pathname expansion, чтобы домены/строки логов не раскрывались как glob. set -f ensure_interactive_input() { if [ -t 0 ] && [ -c "$TTY_DEV" ]; then return 0 fi if [ -c "$TTY_DEV" ]; then exec < "$TTY_DEV" if [ -t 0 ]; then return 0 fi fi echo "Интерактивный ввод недоступен." echo "Не запускайте меню через pipe вида: wget -O - ... | sh" echo "Запустите скрипт напрямую:" echo "pdc" exit 1 } clear_screen() { printf '\033[H\033[J' } dashes() { awk -v n="$1" 'BEGIN { s = ""; while (length(s) < n) s = s "-"; print s }' } # Читает строку в ANSWER и срезает хвост. Windows-терминалы присылают CR, # из-за чего "q" приходит как "q\r" и не совпадает ни с одним шаблоном case. read_answer() { if [ "$1" = "tty" ]; then IFS= read -r ANSWER < "$TTY_DEV" || return 1 else IFS= read -r ANSWER || return 1 fi while :; do case "$ANSWER" in *"$CR_CHAR") ANSWER="${ANSWER%"$CR_CHAR"}" ;; *' ') ANSWER="${ANSWER% }" ;; *) break ;; esac done return 0 } # Выбрасывает всё, что осталось в буфере ввода. Нужно после посимвольного # чтения: если клавишу нажали с Enter, перевод строки остаётся в буфере и # молча отвечает на следующий вопрос вместо пользователя. drain_input() { # Вариант с -t 0 на этой сборке busybox возвращает успех, ничего не # прочитав, поэтому забираем остаток строки построчным чтением с коротким # таймаутом: если в буфере пусто, просто выйдем по таймауту. IFS= read -r -t 1 DRAIN_REST < "$TTY_DEV" 2>/dev/null return 0 } pause_enter() { echo printf "Нажмите Enter, чтобы продолжить..." IFS= read -r DUMMY || return 0 # Экран чистим только после того, как пользователь подтвердил, что прочитал вывод. clear_screen } tui_start() { if [ ! -r "$TTY_DEV" ]; then echo "Ошибка: интерактивный терминал $TTY_DEV недоступен." return 1 fi printf '\033[?25l' trap 'tui_stop; echo; exit 130' INT TERM HUP return 0 } tui_stop() { printf '\033[?25h' trap - INT TERM HUP } read_char() { READ_CHAR="" if IFS= read -r -s -n 1 READ_CHAR < "$TTY_DEV" 2>/dev/null; then return 0 fi return 1 } read_key() { if ! read_char; then echo "unsupported" return fi KEY1="$READ_CHAR" if [ "$KEY1" = "$ESC_CHAR" ]; then if ! read_char; then echo "other" return fi KEY2="$READ_CHAR" if ! read_char; then echo "other" return fi KEY3="$READ_CHAR" case "$KEY2$KEY3" in "[A") echo "up" ;; "[B") echo "down" ;; "OA") echo "up" ;; "OB") echo "down" ;; *) echo "other" ;; esac return fi case "$KEY1" in "") echo "enter" ;; "$CR_CHAR") echo "enter" ;; " ") echo "space" ;; q|Q) echo "quit" ;; *) echo "other" ;; esac } show_tui_unsupported() { tui_stop clear_screen echo "Ошибка: эта сборка BusyBox ash не поддерживает read -s -n 1." echo echo "Без stty или read -n shell не может читать стрелки по одному нажатию." echo "Для стрелочного меню нужен один из вариантов:" echo "- BusyBox ash с поддержкой read -n/read -s;" echo "- applet stty;" echo "- внешний TUI-инструмент вроде dialog/whiptail." echo echo "Цифрового fallback-меню в проекте нет, поэтому на этой прошивке" echo "стрелочное меню может быть недоступно." pause_enter } tui_header() { TITLE="$1" SUBTITLE="$2" clear_screen printf '%s%s%s\n' "$TUI_CYAN" "$TUI_LINE" "$TUI_RESET" printf '%s%s%s %s[%s]%s\n' "$TUI_BOLD" "$TUI_GREEN" "$TITLE" "$TUI_DIM" "$PDC_VERSION" "$TUI_RESET" if [ -n "$SUBTITLE" ]; then printf '%s%s%s\n' "$TUI_DIM" "$SUBTITLE" "$TUI_RESET" fi printf '%s%s%s\n\n' "$TUI_CYAN" "$TUI_LINE" "$TUI_RESET" } # Заголовок секции прямо в потоке вывода: выглядит как шапка экрана, но не # чистит его - нужен там, где под заголовком должно остаться напечатанное выше. # $3 = "open" - не печатать нижнюю полосу: так заголовок сливается со своим # содержимым, когда под ним идёт длинный список. tui_block() { printf '\n%s%s%s\n' "$TUI_CYAN" "$TUI_LINE" "$TUI_RESET" printf '%s%s%s%s\n' "$TUI_BOLD" "$TUI_GREEN" "$1" "$TUI_RESET" if [ -n "$2" ]; then printf '%s%s%s\n' "$TUI_DIM" "$2" "$TUI_RESET" fi if [ "$3" != "open" ]; then printf '%s%s%s\n' "$TUI_CYAN" "$TUI_LINE" "$TUI_RESET" fi } tui_hint() { printf '%s%s%s\n' "$TUI_DIM" "$1" "$TUI_RESET" } tui_section() { printf '%s%s%s\n' "$TUI_CYAN" "$1" "$TUI_RESET" } tui_message() { printf '%s%s%s\n' "$TUI_YELLOW" "$1" "$TUI_RESET" } render_menu_line() { CURRENT="$1" TEXT="$2" if [ "$CURRENT" = "1" ]; then printf '%s > %s %s\n' "$TUI_SELECTED" "$TEXT" "$TUI_RESET" else printf ' %s\n' "$TEXT" fi } render_client_table_header() { printf '%s %-3s %-15s %-36s %-19s %s%s\n' "$TUI_DIM" "" "IP" "Name" "MAC" "Lease" "$TUI_RESET" } format_client_table_row() { CHECK="$1" IP="$2" HOST="$3" MAC="$4" LEASE="$5" printf '%s %-15s %-36.36s %-19s %s' "$CHECK" "$IP" "$HOST" "$MAC" "$LEASE" } render_main_menu() { tui_header "Podkop Domain Capture" "Сбор DNS-доменов из dnsmasq logs для Podkop" tui_hint "Стрелки вверх/вниз - выбор Enter - открыть q - выход" echo tui_section "Действия" if [ "$1" -eq 1 ]; then render_menu_line 1 "Собрать домены" else render_menu_line 0 "Собрать домены" fi if [ "$1" -eq 2 ]; then render_menu_line 1 "Показать домены из последнего лога" else render_menu_line 0 "Показать домены из последнего лога" fi if [ "$1" -eq 3 ]; then render_menu_line 1 "Показать домены по клиенту из последнего лога" else render_menu_line 0 "Показать домены по клиенту из последнего лога" fi if [ "$1" -eq 4 ]; then render_menu_line 1 "Сбросить временные логи" else render_menu_line 0 "Сбросить временные логи" fi if [ "$1" -eq 5 ]; then render_menu_line 1 "Выход" else render_menu_line 0 "Выход" fi } select_main_menu() { MENU_INDEX="1" MENU_MAX="5" MENU_CHOICE="" tui_start || return 1 while :; do render_main_menu "$MENU_INDEX" KEY="$(read_key)" case "$KEY" in up) MENU_INDEX=$((MENU_INDEX - 1)) if [ "$MENU_INDEX" -lt 1 ]; then MENU_INDEX="$MENU_MAX" fi ;; down) MENU_INDEX=$((MENU_INDEX + 1)) if [ "$MENU_INDEX" -gt "$MENU_MAX" ]; then MENU_INDEX="1" fi ;; enter) MENU_CHOICE="$MENU_INDEX" tui_stop # На выходе экран не чистим: пусть меню останется на виду. if [ "$MENU_CHOICE" != "5" ]; then clear_screen fi return 0 ;; quit) MENU_CHOICE="5" tui_stop return 0 ;; unsupported) show_tui_unsupported return 1 ;; esac done } load_clients() { if [ ! -s "$LEASES_FILE" ]; then : > "$CLIENTS_FILE" return fi # На OpenWrt /tmp/dhcp.leases имеет формат: # expires_epoch mac ip hostname client_id awk ' function remaining(expire, left, d, h, m, s) { if (expire == 0) { return "never" } if (now == 0) { return "expires=" expire } left = expire - now if (left <= 0) { return "expired" } d = int(left / 86400) h = int((left % 86400) / 3600) m = int((left % 3600) / 60) s = left % 60 if (d > 0) { return d "d " h "h " m "m" } if (h > 0) { return h "h " m "m" } if (m > 0) { return m "m " s "s" } return s "s" } BEGIN { now = systime() } { host = $4 if (host == "" || host == "*") { host = "-" } printf "%s|%s|%s|%s\n", $3, $2, host, remaining($1) }' "$LEASES_FILE" > "$CLIENTS_FILE" } client_count() { awk 'END { print NR + 0 }' "$CLIENTS_FILE" } get_client_line() { sed -n "${1}p" "$CLIENTS_FILE" } split_client_line() { OLD_IFS="$IFS" IFS="|" set -- $1 IFS="$OLD_IFS" CLIENT_IP="$1" CLIENT_MAC="$2" CLIENT_HOST="$3" CLIENT_REMAINING="$4" } is_ip_selected() { for CHECK_IP in $SELECTED_IPS; do if [ "$CHECK_IP" = "$1" ]; then return 0 fi done return 1 } toggle_ip_selection() { TOGGLE_IP="$1" if is_ip_selected "$TOGGLE_IP"; then NEW_SELECTED="" for CHECK_IP in $SELECTED_IPS; do if [ "$CHECK_IP" != "$TOGGLE_IP" ]; then NEW_SELECTED="$NEW_SELECTED $CHECK_IP" fi done SELECTED_IPS="$NEW_SELECTED" else SELECTED_IPS="$SELECTED_IPS $TOGGLE_IP" fi CAPTURE_ALL_SELECTED="0" } toggle_all_clients() { if [ "$CAPTURE_ALL_SELECTED" = "1" ]; then CAPTURE_ALL_SELECTED="0" else CAPTURE_ALL_SELECTED="1" SELECTED_IPS="" fi } render_capture_menu() { CAPTURE_INDEX="$1" CLIENT_TOTAL="$2" START_INDEX=$((CLIENT_TOTAL + 2)) SETTINGS_INDEX=$((CLIENT_TOTAL + 3)) BACK_INDEX=$((CLIENT_TOTAL + 4)) tui_header "Сбор доменов" "Выберите клиентов, от которых нужно поймать домены" tui_hint "Стрелки - выбор Space/Enter - отметить Enter на действии - подтвердить q - назад" echo printf ' Источник: %s\n' "$(capture_source_label)" printf ' Правила: %s\n' "$(capture_rules_label)" echo if [ "$CLIENT_TOTAL" -eq 0 ]; then tui_message "DHCP leases не найдены или пусты. Можно выбрать сбор от всех клиентов." echo fi tui_section "Клиенты" if [ "$CAPTURE_ALL_SELECTED" = "1" ]; then CHECK="[x]" else CHECK="[ ]" fi if [ "$CAPTURE_INDEX" -eq 1 ]; then render_menu_line 1 "$CHECK Все клиенты" else render_menu_line 0 "$CHECK Все клиенты" fi if [ "$CLIENT_TOTAL" -gt 0 ]; then render_client_table_header fi I="1" while [ "$I" -le "$CLIENT_TOTAL" ]; do LINE="$(get_client_line "$I")" split_client_line "$LINE" if is_ip_selected "$CLIENT_IP"; then CHECK="[x]" else CHECK="[ ]" fi DISPLAY="$(format_client_table_row "$CHECK" "$CLIENT_IP" "$CLIENT_HOST" "$CLIENT_MAC" "$CLIENT_REMAINING")" ROW_INDEX=$((I + 1)) if [ "$CAPTURE_INDEX" -eq "$ROW_INDEX" ]; then render_menu_line 1 "$DISPLAY" else render_menu_line 0 "$DISPLAY" fi I=$((I + 1)) done echo tui_section "Действия" if [ "$CAPTURE_INDEX" -eq "$START_INDEX" ]; then render_menu_line 1 "Начать сбор доменов" else render_menu_line 0 "Начать сбор доменов" fi if [ "$CAPTURE_INDEX" -eq "$SETTINGS_INDEX" ]; then render_menu_line 1 "Настройки сбора" else render_menu_line 0 "Настройки сбора" fi if [ "$CAPTURE_INDEX" -eq "$BACK_INDEX" ]; then render_menu_line 1 "Назад" else render_menu_line 0 "Назад" fi if [ -n "$CAPTURE_MESSAGE" ]; then echo tui_message "$CAPTURE_MESSAGE" fi } select_capture_targets() { load_clients CLIENT_TOTAL="$(client_count)" CAPTURE_INDEX="1" CAPTURE_MAX=$((CLIENT_TOTAL + 4)) CAPTURE_ALL_SELECTED="0" SELECTED_IPS="" CAPTURE_MESSAGE="" tui_start || return 1 while :; do START_INDEX=$((CLIENT_TOTAL + 2)) SETTINGS_INDEX=$((CLIENT_TOTAL + 3)) BACK_INDEX=$((CLIENT_TOTAL + 4)) render_capture_menu "$CAPTURE_INDEX" "$CLIENT_TOTAL" KEY="$(read_key)" CAPTURE_MESSAGE="" case "$KEY" in up) CAPTURE_INDEX=$((CAPTURE_INDEX - 1)) if [ "$CAPTURE_INDEX" -lt 1 ]; then CAPTURE_INDEX="$CAPTURE_MAX" fi ;; down) CAPTURE_INDEX=$((CAPTURE_INDEX + 1)) if [ "$CAPTURE_INDEX" -gt "$CAPTURE_MAX" ]; then CAPTURE_INDEX="1" fi ;; space|enter) if [ "$CAPTURE_INDEX" -eq 1 ]; then toggle_all_clients continue fi if [ "$CAPTURE_INDEX" -gt 1 ] && [ "$CAPTURE_INDEX" -le $((CLIENT_TOTAL + 1)) ]; then CLIENT_ROW=$((CAPTURE_INDEX - 1)) LINE="$(get_client_line "$CLIENT_ROW")" split_client_line "$LINE" toggle_ip_selection "$CLIENT_IP" continue fi if [ "$KEY" = "space" ]; then continue fi if [ "$CAPTURE_INDEX" -eq "$START_INDEX" ]; then if [ "$CAPTURE_ALL_SELECTED" != "1" ] && [ -z "$SELECTED_IPS" ]; then CAPTURE_MESSAGE="Выберите хотя бы одного клиента или пункт Все клиенты." continue fi tui_stop clear_screen if [ "$CAPTURE_ALL_SELECTED" = "1" ]; then start_capture "all" "" else start_capture "selected" "$SELECTED_IPS" fi return 0 fi if [ "$CAPTURE_INDEX" -eq "$SETTINGS_INDEX" ]; then tui_stop clear_screen configure_capture_advanced tui_start || return 1 continue fi if [ "$CAPTURE_INDEX" -eq "$BACK_INDEX" ]; then tui_stop clear_screen return 0 fi ;; quit) tui_stop clear_screen return 0 ;; unsupported) show_tui_unsupported return 1 ;; esac done } lan_subnet() { # 192.168.1.0/24 для br-lan; пусто, если определить не удалось. ip -4 route show dev br-lan proto kernel 2>/dev/null | awk 'NR==1 { print $1 }' } # Все IPv6-подсети br-lan: обычно ULA, глобальная и fe80::/64. lan_subnets6() { ip -6 route show dev br-lan proto kernel 2>/dev/null | awk '{ print $1 }' } # Собственные адреса роутера на br-lan - их исключаем в режиме "все клиенты". router_lan_ips() { ip -4 addr show dev br-lan 2>/dev/null | awk '$1 == "inet" { split($2, a, "/"); print a[1] }' ip -6 addr show dev br-lan 2>/dev/null | awk '$1 == "inet6" { split($2, a, "/"); print a[1] }' } mac_for_ip() { awk -v ip="$1" '$3 == ip { print tolower($2); exit }' "$LEASES_FILE" 2>/dev/null } # IPv6-адреса устройства берём из таблицы соседей: DHCPv6-аренды не покрывают # SLAAC и приватные адреса, а сюда попадает всё, с чем роутер реально общался. ipv6_for_mac() { ip -6 neigh show dev br-lan 2>/dev/null | awk -v mac="$1" '$2 == "lladdr" && tolower($3) == mac { print $1 }' | sort -u } # По выбранным IPv4 находим MAC устройства и все его известные IPv6. # # MAC нужен правилам и BPF: одно правило по MAC покрывает у клиента обе # версии протокола и все временные адреса, включая те, что появятся уже во # время сбора, - а privacy extensions плодят их десятками. # Список IPv6 нужен отдельно для фильтра DNS-лога: в логе dnsmasq есть только # адрес клиента, MAC там неоткуда взять. expand_selected_clients() { SELECTED_MACS="" SELECTED_IPS6="" for EXPAND_IP in $1; do EXPAND_MAC="$(mac_for_ip "$EXPAND_IP")" if [ -z "$EXPAND_MAC" ]; then continue fi SELECTED_MACS="$SELECTED_MACS $EXPAND_MAC" for EXPAND_V6 in $(ipv6_for_mac "$EXPAND_MAC"); do SELECTED_IPS6="$SELECTED_IPS6 $EXPAND_V6" done done return 0 } # Пишет awk-парсер TLS ClientHello. Держим его отдельным файлом, а не inline-строкой: # так проще отлаживать и не воевать с экранированием внутри ash. write_sni_awk() { cat > "$SNI_AWK_FILE" <<'PDC_SNI_AWK' function hv(c) { return index("0123456789abcdef", c) - 1 } function b(i, s) { s = substr(HEX, i * 2 + 1, 2) if (length(s) < 2) return -1 return hv(substr(s, 1, 1)) * 16 + hv(substr(s, 2, 1)) } function b2(i, h, l) { h = b(i); l = b(i + 1) if (h < 0 || l < 0) return -1 return h * 256 + l } function flush( ver, tcp, doff, p, n, sidl, csl, cml, extl, et, el, end, nl, name, i, c) { if (HEX == "" || SRC == "") return if (b(0) < 0) return ver = int(b(0) / 16) if (ver == 4) { if (b(9) != 6) return # protocol = TCP tcp = (b(0) % 16) * 4 # IHL в 32-битных словах } else if (ver == 6) { if (b(6) != 6) return # next header = TCP, без расширений tcp = 40 # заголовок IPv6 фиксирован } else { return } doff = int(b(tcp + 12) / 16) * 4 if (doff < 20) return p = tcp + doff if (b(p) != 22) return if (b(p + 5) != 1) return n = p + 43 sidl = b(n); if (sidl < 0) return n += 1 + sidl csl = b2(n); if (csl < 0) return n += 2 + csl cml = b(n); if (cml < 0) return n += 1 + cml extl = b2(n); if (extl < 0) return n += 2 end = n + extl while (n + 4 <= end) { et = b2(n); el = b2(n + 2) if (et < 0 || el < 0) return n += 4 if (et == 0) { nl = b2(n + 3) if (nl <= 0 || nl > 253) return name = "" for (i = 0; i < nl; i++) { c = b(n + 5 + i) if (c < 33 || c > 126) return name = name sprintf("%c", c) } printf "%s %s %s sni\n", TS, SRC, name fflush() return } n += el } } /^[0-9][0-9]:[0-9][0-9]:[0-9][0-9]\./ { flush() HEX = ""; SRC = "" TS = substr($1, 1, 8) if ($2 == "IP") { # 192.168.1.130.54321 - адрес это первые четыре октета nf = split($3, a, ".") if (nf >= 5) SRC = a[1] "." a[2] "." a[3] "." a[4] } else if ($2 == "IP6") { # fd6c:bfe7:e261::194.54321 - порт отделён последней точкой SRC = $3 sub(/\.[0-9]+$/, "", SRC) } next } /^[ \t]+0x[0-9a-f]+:/ { line = $0 sub(/^[ \t]+0x[0-9a-f]+:[ \t]*/, "", line) gsub(/[ \t]/, "", line) HEX = HEX line next } END { flush() } PDC_SNI_AWK } # Путь к самому себе. Обычный запуск - это "pdc" по имени из PATH, и тогда # в $0 нет ни одного слэша: клеить его с текущим каталогом нельзя, надо искать # в PATH, иначе самообновление молча решает, что файла нет. self_path() { case "$0" in /*) printf '%s\n' "$0" ;; */*) printf '%s/%s\n' "$(pwd)" "$0" ;; *) SELF_RESOLVED="$(command -v "$0" 2>/dev/null)" if [ -n "$SELF_RESOLVED" ]; then printf '%s\n' "$SELF_RESOLVED" else printf '%s\n' "$0" fi ;; esac } fetch_url() { if command -v wget >/dev/null 2>&1; then wget -q -O "$2" --timeout=15 "$1" 2>/dev/null && return 0 fi if command -v curl >/dev/null 2>&1; then curl -fsS --max-time 15 -o "$2" "$1" 2>/dev/null && return 0 fi return 1 } # 0, если версия $1 строго новее $2. Суффиксы вида -beta отбрасываем. # Нужно именно "новее", а не "отличается": иначе скрипт откатит сам себя, # если в репозитории лежит версия старее установленной. version_newer() { awk -v a="$1" -v b="$2" ' function norm(v, n, p, i, s) { sub(/-.*$/, "", v) n = split(v, p, ".") s = 0 for (i = 1; i <= 3; i++) s = s * 1000 + (i <= n ? p[i] + 0 : 0) return s } BEGIN { exit !(norm(a) > norm(b)) }' } pkg_version() { if command -v apk >/dev/null 2>&1; then apk list -I "$1" 2>/dev/null | awk 'NR==1 { print $1 }' elif command -v opkg >/dev/null 2>&1; then opkg list-installed "$1" 2>/dev/null | awk 'NR==1 { print $3 }' fi } # Первый запуск: tcpdump обязателен, потому что SNI - основной источник. # Три попытки, дальше выходим: продолжать без него нет смысла. install_tcpdump_or_die() { echo echo "Первый запуск: для перехвата SNI нужен tcpdump." printf 'Устанавливаю %s (%s, плюс libpcap)...\n' "$TCPDUMP_PKG" "$TCPDUMP_SIZE" INSTALL_OUT="" ATTEMPT="1" while [ "$ATTEMPT" -le 3 ]; do if [ "$ATTEMPT" -gt 1 ]; then echo "Попытка $ATTEMPT из 3..." sleep 3 fi if command -v apk >/dev/null 2>&1; then apk update >/dev/null 2>&1 INSTALL_OUT="$(apk add "$TCPDUMP_PKG" 2>&1)" elif command -v opkg >/dev/null 2>&1; then opkg update >/dev/null 2>&1 INSTALL_OUT="$(opkg install "$TCPDUMP_PKG" 2>&1)" else INSTALL_OUT="не найден ни apk, ни opkg" break fi if command -v tcpdump >/dev/null 2>&1; then echo "tcpdump установлен." MAINTENANCE_NOTED="1" return 0 fi ATTEMPT=$((ATTEMPT + 1)) done echo echo "Ошибка: не удалось установить $TCPDUMP_PKG за 3 попытки." if [ -n "$INSTALL_OUT" ]; then printf '%s\n' "$INSTALL_OUT" | tail -5 fi echo echo "Сбор по SNI - основной режим, без tcpdump он невозможен." echo "Проверьте интернет и свободное место, затем запустите pdc снова." exit 1 } update_tcpdump() { VER_BEFORE="$(pkg_version "$TCPDUMP_PKG")" if command -v apk >/dev/null 2>&1; then apk update >/dev/null 2>&1 # Только этот пакет: голый apk upgrade потянул бы luci, hostapd и прочее. apk upgrade "$TCPDUMP_PKG" >/dev/null 2>&1 elif command -v opkg >/dev/null 2>&1; then opkg update >/dev/null 2>&1 opkg upgrade "$TCPDUMP_PKG" >/dev/null 2>&1 else return 0 fi VER_AFTER="$(pkg_version "$TCPDUMP_PKG")" if [ -n "$VER_AFTER" ] && [ "$VER_BEFORE" != "$VER_AFTER" ]; then echo "tcpdump обновлён: $VER_BEFORE -> $VER_AFTER" MAINTENANCE_NOTED="1" fi return 0 } update_script() { SELF="$(self_path)" if [ ! -w "$SELF" ]; then return 0 fi NEW_FILE="/tmp/podkop-domain-capture.new" rm -f "$NEW_FILE" if ! fetch_url "$SCRIPT_URL" "$NEW_FILE"; then rm -f "$NEW_FILE" return 0 fi # Санитарная проверка: это должен быть наш скрипт целиком, а не 404-страница # и не обрезанная закачка. if ! head -n 1 "$NEW_FILE" | grep -q '^#!/bin/ash'; then rm -f "$NEW_FILE" return 0 fi if [ "$(wc -c < "$NEW_FILE")" -lt 10000 ]; then rm -f "$NEW_FILE" return 0 fi REMOTE_VERSION="$(grep -m1 '^PDC_VERSION=' "$NEW_FILE" | cut -d'"' -f2)" if [ -z "$REMOTE_VERSION" ]; then rm -f "$NEW_FILE" return 0 fi if ! version_newer "$REMOTE_VERSION" "$PDC_VERSION"; then rm -f "$NEW_FILE" return 0 fi echo "Доступна версия $REMOTE_VERSION (установлена $PDC_VERSION), обновляю..." if ! cat "$NEW_FILE" > "$SELF"; then echo "Предупреждение: не удалось записать $SELF, продолжаю на текущей версии." rm -f "$NEW_FILE" return 0 fi chmod +x "$SELF" rm -f "$NEW_FILE" echo "Обновлено до $REMOTE_VERSION, перезапускаю..." PDC_UPDATED=1 exec "$SELF" } update_due() { if [ ! -f "$UPDATE_STAMP" ]; then return 0 fi NOW="$(date +%s 2>/dev/null)" THEN="$(cat "$UPDATE_STAMP" 2>/dev/null)" case "$NOW$THEN" in *[!0-9]*|"") return 0 ;; esac if [ "$((NOW - THEN))" -ge "$UPDATE_INTERVAL" ]; then return 0 fi return 1 } mark_update_done() { date +%s > "$UPDATE_STAMP" 2>/dev/null } startup_maintenance() { if ! command -v tcpdump >/dev/null 2>&1; then install_tcpdump_or_die mark_update_done else # Уже перезапускались после обновления - второй круг не нужен. if [ -z "$PDC_UPDATED" ] && update_due; then echo "Проверяю обновления..." mark_update_done update_tcpdump # Может не вернуться: обновит скрипт и сделает exec. update_script if [ "$MAINTENANCE_NOTED" = "0" ]; then echo "Всё актуально." fi fi fi if [ "$MAINTENANCE_NOTED" = "1" ]; then pause_enter fi return 0 } # BPF-фильтр: только TLS ClientHello от нужных клиентов. build_bpf_filter() { BPF_MODE="$1" BPF_IPS="$2" BPF_HOSTS="" if [ "$BPF_MODE" = "all" ]; then for ONE_IP in $(router_lan_ips); do if [ -z "$BPF_HOSTS" ]; then BPF_HOSTS="src host $ONE_IP" else BPF_HOSTS="$BPF_HOSTS or src host $ONE_IP" fi done if [ -n "$BPF_HOSTS" ]; then BPF_HOSTS="not ($BPF_HOSTS)" fi elif [ -n "$(printf '%s' "$SELECTED_MACS" | tr -d ' ')" ]; then # Отбор по MAC ловит клиента целиком: и IPv4, и IPv6, и адреса, # которые устройство создаст уже во время сбора. for ONE_MAC in $SELECTED_MACS; do if [ -z "$BPF_HOSTS" ]; then BPF_HOSTS="ether src $ONE_MAC" else BPF_HOSTS="$BPF_HOSTS or ether src $ONE_MAC" fi done BPF_HOSTS="($BPF_HOSTS)" else for ONE_IP in $BPF_IPS $SELECTED_IPS6; do if [ -z "$BPF_HOSTS" ]; then BPF_HOSTS="src host $ONE_IP" else BPF_HOSTS="$BPF_HOSTS or src host $ONE_IP" fi done if [ -n "$BPF_HOSTS" ]; then BPF_HOSTS="($BPF_HOSTS)" fi fi # Первый байт TCP-payload 0x16 = начало TLS-хендшейка. Для IPv4 смещение # считается через tcp[], для IPv6 так нельзя - libpcap отвергает такой # фильтр, - поэтому там адресуемся от начала пакета: 40 байт фиксированного # заголовка плюс длина TCP-заголовка из ip6[52]. BPF_V4='(ip and tcp and tcp[((tcp[12:1]&0xf0)>>2)]=0x16)' BPF_V6='(ip6 and tcp and ip6[40+((ip6[52]&0xf0)>>2)]=0x16)' BPF_BASE="($BPF_V4 or $BPF_V6)" if [ -n "$BPF_HOSTS" ]; then printf '%s and %s\n' "$BPF_BASE" "$BPF_HOSTS" else printf '%s\n' "$BPF_BASE" fi } # В inet-таблице семья задаётся явно. Синтаксис "<семья> saddr <адрес>" # одинаков для всех трёх, поэтому шаблон правила от выбора не зависит. nft_family() { case "$1" in ??:??:??:??:??:??) printf 'ether\n' ;; *:*) printf 'ip6\n' ;; *) printf 'ip\n' ;; esac } nft_guard_enable() { NFT_MODE="$1" NFT_IPS="$2" if ! command -v nft >/dev/null 2>&1; then echo "Предупреждение: nft не найден, сетевые правила пропущены." return 1 fi if [ "$OPT_DNS_HIJACK" != "1" ] && [ "$OPT_BLOCK_DOT" != "1" ] && [ "$OPT_BLOCK_QUIC" != "1" ]; then return 0 fi if [ "$NFT_MODE" = "all" ]; then NFT_SRC_LIST="$(lan_subnet) $(lan_subnets6)" elif [ -n "$(printf '%s' "$SELECTED_MACS" | tr -d ' ')" ]; then # По MAC - одно правило на клиента вместо десятков по адресам. NFT_SRC_LIST="$SELECTED_MACS" else # MAC не нашёлся в leases - откатываемся на адреса. NFT_SRC_LIST="$NFT_IPS $SELECTED_IPS6" fi # Пробелы могли остаться, если какой-то из источников пуст. NFT_SRC_LIST="$(printf '%s' "$NFT_SRC_LIST" | tr -s ' ')" if [ -z "$(printf '%s' "$NFT_SRC_LIST" | tr -d ' ')" ]; then echo "Предупреждение: не удалось определить адреса клиентов, правила пропущены." return 1 fi nft delete table inet "$NFT_TABLE" 2>/dev/null { printf 'table inet %s {\n' "$NFT_TABLE" if [ "$OPT_DNS_HIJACK" = "1" ]; then printf '\tchain pdc_nat_pre {\n' printf '\t\ttype nat hook prerouting priority dstnat - 5; policy accept;\n' for ONE_SRC in $NFT_SRC_LIST; do NFT_FAM="$(nft_family "$ONE_SRC")" printf '\t\t%s saddr %s udp dport 53 counter redirect to :53 comment "pdc-dns-hijack"\n' "$NFT_FAM" "$ONE_SRC" printf '\t\t%s saddr %s tcp dport 53 counter redirect to :53 comment "pdc-dns-hijack"\n' "$NFT_FAM" "$ONE_SRC" done printf '\t}\n' fi if [ "$OPT_BLOCK_DOT" = "1" ] || [ "$OPT_BLOCK_QUIC" = "1" ]; then printf '\tchain pdc_fwd {\n' printf '\t\ttype filter hook forward priority filter - 5; policy accept;\n' for ONE_SRC in $NFT_SRC_LIST; do NFT_FAM="$(nft_family "$ONE_SRC")" if [ "$OPT_BLOCK_DOT" = "1" ]; then printf '\t\t%s saddr %s tcp dport 853 counter reject with tcp reset comment "pdc-block-dot"\n' "$NFT_FAM" "$ONE_SRC" printf '\t\t%s saddr %s udp dport 853 counter drop comment "pdc-block-dot"\n' "$NFT_FAM" "$ONE_SRC" fi if [ "$OPT_BLOCK_QUIC" = "1" ]; then printf '\t\t%s saddr %s udp dport 443 counter drop comment "pdc-block-quic"\n' "$NFT_FAM" "$ONE_SRC" fi done printf '\t}\n' fi printf '}\n' } > /tmp/podkop-domain-capture.nft if ! nft -f /tmp/podkop-domain-capture.nft; then echo "Ошибка: не удалось загрузить временные nft-правила." rm -f /tmp/podkop-domain-capture.nft return 1 fi rm -f /tmp/podkop-domain-capture.nft NFT_ACTIVE="1" echo "Временные сетевые правила включены (таблица inet $NFT_TABLE)" return 0 } nft_guard_disable() { if [ "$NFT_ACTIVE" != "1" ]; then return 0 fi if nft delete table inet "$NFT_TABLE" 2>/dev/null; then echo "Временные сетевые правила сняты" else echo "Предупреждение: не удалось снять таблицу inet $NFT_TABLE." echo "Снимите вручную: nft delete table inet $NFT_TABLE" fi NFT_ACTIVE="0" return 0 } sni_start() { SNI_MODE="$1" SNI_IPS="$2" write_sni_awk SNI_FILTER="$(build_bpf_filter "$SNI_MODE" "$SNI_IPS")" rm -f "$SNI_PID_FILE" # Внутренний sh пишет свой PID и делает exec tcpdump, поэтому в PID-файле # оказывается именно tcpdump и мы можем остановить его точечно. sh -c 'echo $$ > "$1"; exec tcpdump -i br-lan -nn -l -s 0 -x "$2" 2>/dev/null' \ _ "$SNI_PID_FILE" "$SNI_FILTER" | awk -f "$SNI_AWK_FILE" | while IFS= read -r SNI_LINE; do # В файл пишем как есть, на экран - по колонкам. printf '%s\n' "$SNI_LINE" >> "$LOG_FILE" # shellcheck disable=SC2086 set -- $SNI_LINE printf "%-8s %-${CLIENT_COL_W}s %-3s %s\n" "$1" "$2" "$4" "$3" done & SNI_ACTIVE="1" return 0 } sni_stop() { if [ "$SNI_ACTIVE" != "1" ]; then return 0 fi if [ -s "$SNI_PID_FILE" ]; then kill "$(cat "$SNI_PID_FILE")" 2>/dev/null fi rm -f "$SNI_PID_FILE" SNI_ACTIVE="0" return 0 } # DNS-источник тоже работает фоном - в переднем плане ждём нажатия клавиши. dns_start() { DNS_MODE="$1" DNS_IPS="$2" rm -f "$DNS_PID_FILE" # Тот же приём, что и с tcpdump: внутренний sh пишет свой PID и делает # exec logread, поэтому в PID-файле оказывается именно logread. sh -c 'echo $$ > "$1"; exec logread -f -e dnsmasq' _ "$DNS_PID_FILE" | while IFS= read -r LINE; do if ! parse_query_line "$LINE"; then continue fi if ! client_allowed "$DNS_MODE" "$DNS_IPS"; then continue fi printf '%s %s %s dns\n' "$CAP_TIME" "$CAP_CLIENT" "$CAP_DOMAIN" >> "$LOG_FILE" printf "%-8s %-${CLIENT_COL_W}s %-3s %s\n" "$CAP_TIME" "$CAP_CLIENT" "dns" "$CAP_DOMAIN" done & DNS_ACTIVE="1" return 0 } dns_stop() { if [ "$DNS_ACTIVE" != "1" ]; then return 0 fi if [ -s "$DNS_PID_FILE" ]; then kill "$(cat "$DNS_PID_FILE")" 2>/dev/null fi rm -f "$DNS_PID_FILE" DNS_ACTIVE="0" return 0 } # Останавливает оба источника и даёт им дописать хвост: иначе последние # строки вылезают уже поверх сообщений об остановке. capture_stop_sources() { STOP_NEEDED="0" if [ "$SNI_ACTIVE" = "1" ] || [ "$DNS_ACTIVE" = "1" ]; then STOP_NEEDED="1" fi sni_stop dns_stop if [ "$STOP_NEEDED" = "1" ]; then sleep 1 fi return 0 } enable_logs() { echo # Одна строка с прогрессом: перезапуск dnsmasq проходит быстро, и пара # сообщений "делаю"/"сделано" всё равно появлялась бы одновременно. printf 'Включаю dnsmasq logqueries... ' CURRENT_LOGQUERIES="$(uci -q get 'dhcp.@dnsmasq[0].logqueries' 2>/dev/null)" if [ -z "$CURRENT_LOGQUERIES" ]; then CURRENT_LOGQUERIES="unset" fi printf '%s\n' "$CURRENT_LOGQUERIES" > "$PREV_FILE" 2>/dev/null if ! uci set 'dhcp.@dnsmasq[0].logqueries=1'; then echo echo "Ошибка: не удалось выполнить uci set" return 1 fi if ! uci commit dhcp; then echo echo "Ошибка: не удалось выполнить uci commit dhcp" return 1 fi # Перезапуск дёргает netifd, тот пишет в консоль udhcpc-строки. Прячем их, # но сохраняем вывод, чтобы показать при реальной ошибке. if ! RESTART_OUT="$(/etc/init.d/dnsmasq restart 2>&1)"; then echo echo "Ошибка: не удалось перезапустить dnsmasq" printf '%s\n' "$RESTART_OUT" return 1 fi echo "готово" LOGS_ENABLED="1" return 0 } # Возвращает logqueries к значению, сохранённому перед сбором. Раньше здесь # всегда выставлялся 0, поэтому у тех, у кого лог запросов был включён до # запуска утилиты, он оказывался выключён после выхода. disable_logs() { RESTORE_TO="$(cat "$PREV_FILE" 2>/dev/null)" case "$RESTORE_TO" in 0|1|unset) ;; *) RESTORE_TO="0" ;; esac if [ "$RESTORE_TO" = "0" ]; then printf 'Выключаю dnsmasq logqueries... ' else printf 'Возвращаю dnsmasq logqueries... ' fi if [ "$RESTORE_TO" = "unset" ]; then # rc=1 здесь означает, что опции и так нет, а это нужный нам результат, # поэтому проверяем итог, а не код возврата. uci -q delete 'dhcp.@dnsmasq[0].logqueries' if [ -n "$(uci -q get 'dhcp.@dnsmasq[0].logqueries')" ]; then echo echo "Ошибка: не удалось убрать logqueries" return 1 fi elif ! uci set "dhcp.@dnsmasq[0].logqueries=$RESTORE_TO"; then echo echo "Ошибка: не удалось выполнить uci set" return 1 fi if ! uci commit dhcp; then echo echo "Ошибка: не удалось выполнить uci commit dhcp" return 1 fi # Перезапуск дёргает netifd, тот пишет в консоль udhcpc-строки. Прячем их, # но сохраняем вывод, чтобы показать при реальной ошибке. if ! RESTART_OUT="$(/etc/init.d/dnsmasq restart 2>&1)"; then echo echo "Ошибка: не удалось перезапустить dnsmasq" printf '%s\n' "$RESTART_OUT" return 1 fi echo "готово" LOGS_ENABLED="0" return 0 } capture_cleanup() { capture_stop_sources nft_guard_disable if [ "$LOGS_ENABLED" = "1" ]; then disable_logs fi return 0 } capture_source_label() { case "$CAPTURE_SOURCE" in dns) printf 'только DNS (лог dnsmasq)\n' ;; sni) printf 'только SNI (TLS ClientHello)\n' ;; *) printf 'DNS + SNI\n' ;; esac } capture_rules_label() { RULES_LABEL="" if [ "$OPT_DNS_HIJACK" = "1" ]; then RULES_LABEL="перехват DNS" fi if [ "$OPT_BLOCK_DOT" = "1" ]; then if [ -n "$RULES_LABEL" ]; then RULES_LABEL="$RULES_LABEL, блок DoT" else RULES_LABEL="блок DoT" fi fi if [ "$OPT_BLOCK_QUIC" = "1" ]; then if [ -n "$RULES_LABEL" ]; then RULES_LABEL="$RULES_LABEL, блок QUIC" else RULES_LABEL="блок QUIC" fi fi if [ -z "$RULES_LABEL" ]; then RULES_LABEL="выключены" fi printf '%s\n' "$RULES_LABEL" } ask_yes_no() { # $1 - текст, $2 - текущее значение. Возвращает 0 = да, 1 = нет. if [ "$2" = "1" ]; then printf '%s [Y/n]: ' "$1" else printf '%s [y/N]: ' "$1" fi if ! read_answer tty; then echo return "$([ "$2" = "1" ] && echo 0 || echo 1)" fi case "$ANSWER" in y|Y|yes|YES|д|Д|да|Да|ДА) return 0 ;; n|N|no|NO|н|Н|нет|Нет|НЕТ) return 1 ;; *) [ "$2" = "1" ] && return 0 || return 1 ;; esac } configure_capture_advanced() { tui_header "Настройки сбора" "Значения по умолчанию подходят почти всегда" tui_section "Источник доменов" echo " 1) DNS + SNI - рекомендуется, ловит и запросы, и уже закешированные домены" echo " 2) только DNS - лог dnsmasq, без tcpdump" echo " 3) только SNI - имена из TLS ClientHello, dnsmasq не трогается вовсе" echo printf "Выбор [текущий: %s]: " "$(capture_source_label)" if ! read_answer tty; then echo return 1 fi case "$ANSWER" in 1) CAPTURE_SOURCE="both" ;; 2) CAPTURE_SOURCE="dns" ;; 3) CAPTURE_SOURCE="sni" ;; esac echo tui_section "Временные правила на время сбора" tui_message " Живут в отдельной таблице nft и снимаются при остановке." echo if ask_yes_no " Перехват DNS - завернуть :53 клиента на роутер?" "$OPT_DNS_HIJACK"; then OPT_DNS_HIJACK="1" else OPT_DNS_HIJACK="0" fi if ask_yes_no " Блок DoT - закрыть :853?" "$OPT_BLOCK_DOT"; then OPT_BLOCK_DOT="1" else OPT_BLOCK_DOT="0" fi if ask_yes_no " Блок QUIC - закрыть udp:443, иначе SNI не виден?" "$OPT_BLOCK_QUIC"; then OPT_BLOCK_QUIC="1" else OPT_BLOCK_QUIC="0" fi echo tui_section "Сброс DNS-кеша на клиенте (выполнять на самом устройстве)" echo " Windows: ipconfig /flushdns" echo " macOS: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder" echo " Linux: sudo resolvectl flush-caches" echo " iOS/Android: включите и выключите авиарежим" echo " Smart TV: перезагрузите устройство полностью" pause_enter return 0 } parse_query_line() { CAP_LINE="$1" CAP_TIME="" CAP_DOMAIN="" CAP_CLIENT="" WANT_DOMAIN="0" WANT_CLIENT="0" for WORD in $CAP_LINE; do if [ "$WANT_DOMAIN" = "1" ]; then CAP_DOMAIN="$WORD" WANT_DOMAIN="0" continue fi if [ "$WANT_CLIENT" = "1" ]; then CAP_CLIENT="$WORD" WANT_CLIENT="0" continue fi case "$WORD" in [0-9][0-9]:[0-9][0-9]:[0-9][0-9]) if [ -z "$CAP_TIME" ]; then CAP_TIME="$WORD" fi ;; query\[*\]) WANT_DOMAIN="1" ;; from) WANT_CLIENT="1" ;; esac done if [ -z "$CAP_DOMAIN" ] || [ -z "$CAP_CLIENT" ]; then return 1 fi if [ -z "$CAP_TIME" ]; then CAP_TIME="$(awk 'BEGIN { print strftime("%H:%M:%S") }' 2>/dev/null)" if [ -z "$CAP_TIME" ]; then CAP_TIME="00:00:00" fi fi return 0 } client_allowed() { if [ "$1" = "all" ]; then return 0 fi # Одно и то же устройство спрашивает DNS то с IPv4, то с IPv6-адреса, # поэтому сверяемся с обоими списками. for FILTER_IP in $2 $SELECTED_IPS6; do if [ "$CAP_CLIENT" = "$FILTER_IP" ]; then return 0 fi done return 1 } capture_stream() { MODE="$1" IP_LIST="$2" if ! : > "$LOG_FILE"; then echo "Ошибка: не удалось создать файл $LOG_FILE." return 1 fi # Чистим экран до старта сбора: сообщения про nft и logqueries уже не нужны, # а живые строки должны идти на пустом экране. case "$CAPTURE_SOURCE" in dns) SOURCE_LABEL="DNS (лог dnsmasq)" ;; sni) SOURCE_LABEL="SNI (TLS ClientHello)" ;; *) SOURCE_LABEL="DNS + SNI" ;; esac if [ "$MODE" = "all" ]; then TARGET_LABEL="все клиенты" else TARGET_LABEL="$IP_LIST" # Показываем, сколько IPv6-адресов подтянулось: если ноль, а трафик # по IPv6 идёт, значит устройства ещё нет в таблице соседей. IPV6_COUNT="$(printf '%s' "$SELECTED_IPS6" | wc -w | tr -d ' ')" if [ "$IPV6_COUNT" -gt 0 ]; then TARGET_LABEL="$TARGET_LABEL (+$IPV6_COUNT IPv6)" fi fi # Колонку под адрес расширяем только когда в сборе вообще возможен IPv6: # при чистом IPv4 широкая колонка оставляет полэкрана пустоты. CLIENT_COL_W="15" if [ "$MODE" = "all" ] || [ -n "$(printf '%s' "$SELECTED_IPS6" | tr -d ' ')" ]; then CLIENT_COL_W="39" fi tui_header "Live-сбор доменов" "Источник: $SOURCE_LABEL Клиенты: $TARGET_LABEL" tui_hint "Любая клавиша - остановить сбор" echo "Лог сохраняется в: $LOG_FILE" echo # SRC идёт перед доменом: домен последний, поэтому его длина уже никому # не мешает и таблица не разъезжается. printf "%-8s %-${CLIENT_COL_W}s %-3s %s\n" "TIME" "CLIENT_IP" "SRC" "DOMAIN" printf '%s %s %s %s\n' \ "$(dashes 8)" "$(dashes "$CLIENT_COL_W")" "---" "$(dashes 40)" if [ "$CAPTURE_SOURCE" = "sni" ] || [ "$CAPTURE_SOURCE" = "both" ]; then sni_start "$MODE" "$IP_LIST" fi if [ "$CAPTURE_SOURCE" = "dns" ] || [ "$CAPTURE_SOURCE" = "both" ]; then dns_start "$MODE" "$IP_LIST" fi # Оба источника работают фоном, здесь просто ждём нажатия. Если сборка # BusyBox не умеет read -s -n 1, остаётся прежний путь - Ctrl+C. if ! read_char; then wait fi drain_input capture_stop_sources # Открытый блок: строки снятия правил и logqueries печатает capture_cleanup # уже после возврата отсюда, и они должны попасть в эту же секцию. tui_block "Сбор остановлен" "Лог сохранен: $LOG_FILE" open return 0 } start_capture() { MODE="$1" IP_LIST="$2" # Адреса IPv6 нужны и правилам, и BPF, и фильтру DNS-лога, поэтому # собираем их до всего остального. SELECTED_IPS6="" SELECTED_MACS="" if [ "$MODE" != "all" ]; then expand_selected_clients "$IP_LIST" fi # Ставим ловушки до включения чего-либо: обрыв на этапе настройки # не должен оставить включённым logqueries или nft-таблицу. trap 'echo; echo "Останавливаю live-сбор..."; capture_cleanup' INT trap 'capture_cleanup; exit 130' TERM HUP nft_guard_enable "$MODE" "$IP_LIST" if [ "$CAPTURE_SOURCE" != "sni" ]; then if ! enable_logs; then capture_cleanup trap - INT TERM HUP pause_enter return 1 fi fi capture_stream "$MODE" "$IP_LIST" CAPTURE_RC="$?" trap - INT TERM HUP capture_cleanup print_unique_domains pause_enter return "$CAPTURE_RC" } show_unique() { clear_screen print_unique_domains } # Печатает список со своим заголовком, но без очистки экрана: после сбора он # должен лечь под собранные строки, а не заменить их. print_unique_domains() { if [ ! -s "$LOG_FILE" ]; then tui_block "Уникальные домены" "Лог $LOG_FILE не найден или пуст" return 1 fi UNIQUE_COUNT="$(awk '{print $3}' "$LOG_FILE" | sort -u | wc -l | tr -d ' ')" tui_block "Уникальные домены" "Найдено: $UNIQUE_COUNT" open awk '{print $3}' "$LOG_FILE" | sort -u return 0 } build_log_ips() { if [ ! -s "$LOG_FILE" ]; then : > "$LOG_IPS_FILE" return 1 fi awk '{print $2}' "$LOG_FILE" | sort -u > "$LOG_IPS_FILE" return 0 } log_ip_count() { awk 'END { print NR + 0 }' "$LOG_IPS_FILE" } get_log_ip() { sed -n "${1}p" "$LOG_IPS_FILE" } render_log_ip_menu() { LOG_IP_INDEX="$1" LOG_IP_TOTAL="$2" LOG_IP_BACK=$((LOG_IP_TOTAL + 1)) tui_header "Домены по клиенту" "Выберите IP из последнего сохраненного лога" tui_hint "Стрелки - выбор Enter - показать q - назад" echo tui_section "Клиенты из лога" I="1" while [ "$I" -le "$LOG_IP_TOTAL" ]; do LOG_IP="$(get_log_ip "$I")" if [ "$LOG_IP_INDEX" -eq "$I" ]; then render_menu_line 1 "$LOG_IP" else render_menu_line 0 "$LOG_IP" fi I=$((I + 1)) done echo tui_section "Действия" if [ "$LOG_IP_INDEX" -eq "$LOG_IP_BACK" ]; then render_menu_line 1 "Назад" else render_menu_line 0 "Назад" fi } select_log_ip() { if ! build_log_ips; then echo echo "Лог $LOG_FILE не найден или пуст." return 1 fi LOG_IP_TOTAL="$(log_ip_count)" if [ "$LOG_IP_TOTAL" -eq 0 ]; then echo echo "В последнем логе нет IP клиентов." return 1 fi LOG_IP_INDEX="1" LOG_IP_MAX=$((LOG_IP_TOTAL + 1)) SELECTED_LOG_IP="" tui_start || return 1 while :; do LOG_IP_BACK=$((LOG_IP_TOTAL + 1)) render_log_ip_menu "$LOG_IP_INDEX" "$LOG_IP_TOTAL" KEY="$(read_key)" case "$KEY" in up) LOG_IP_INDEX=$((LOG_IP_INDEX - 1)) if [ "$LOG_IP_INDEX" -lt 1 ]; then LOG_IP_INDEX="$LOG_IP_MAX" fi ;; down) LOG_IP_INDEX=$((LOG_IP_INDEX + 1)) if [ "$LOG_IP_INDEX" -gt "$LOG_IP_MAX" ]; then LOG_IP_INDEX="1" fi ;; enter) if [ "$LOG_IP_INDEX" -eq "$LOG_IP_BACK" ]; then tui_stop clear_screen return 2 fi SELECTED_LOG_IP="$(get_log_ip "$LOG_IP_INDEX")" tui_stop clear_screen return 0 ;; quit) tui_stop clear_screen return 2 ;; unsupported) show_tui_unsupported return 1 ;; esac done } show_unique_by_ip() { select_log_ip SELECT_LOG_IP_RC="$?" if [ "$SELECT_LOG_IP_RC" -ne 0 ]; then return "$SELECT_LOG_IP_RC" fi BY_IP_COUNT="$(awk -v ip="$SELECTED_LOG_IP" '$2==ip{print $3}' "$LOG_FILE" | sort -u | wc -l | tr -d ' ')" tui_block "Уникальные домены" "Клиент: $SELECTED_LOG_IP Найдено: $BY_IP_COUNT" open awk -v ip="$SELECTED_LOG_IP" '$2==ip{print $3}' "$LOG_FILE" | sort -u return 0 } cleanup() { tui_header "Сброс временных логов" "Очистка сохраненного live-лога и служебных файлов" tui_section "Будет выполнено" echo " Вернуть dnsmasq logqueries в состояние до сбора." echo " Удалить последний live-лог: $LOG_FILE" echo " Удалить служебные файлы pdc в /tmp." echo " Перезапустить RAM-log роутера." echo tui_section "Не выполняется" tui_message " DNS-кеш на ПК/телефоне клиента этим пунктом не очищается." echo printf "%sy%s - сбросить, Enter/q - назад: " "$TUI_GREEN" "$TUI_RESET" if ! read_answer tty; then echo return 1 fi case "$ANSWER" in y|Y|yes|YES|д|Д|да|Да|ДА) ;; *) echo echo "Сброс отменен." return 0 ;; esac echo # Сохранённое значение важнее текущего: если сбор оборвался, восстанавливать # надо именно его, а не просто выставлять 0. CURRENT_LOGQUERIES="$(uci -q get 'dhcp.@dnsmasq[0].logqueries' 2>/dev/null)" if [ -s "$PREV_FILE" ] || [ "$CURRENT_LOGQUERIES" = "1" ]; then disable_logs else echo "dnsmasq logqueries уже в исходном состоянии" fi if command -v nft >/dev/null 2>&1; then if nft list table inet "$NFT_TABLE" >/dev/null 2>&1; then nft delete table inet "$NFT_TABLE" 2>/dev/null && echo "Удалена оставшаяся таблица inet $NFT_TABLE." fi fi if [ -s "$SNI_PID_FILE" ]; then kill "$(cat "$SNI_PID_FILE")" 2>/dev/null fi rm -f "$LOG_FILE" rm -f "$PREV_FILE" rm -f "$CLIENTS_FILE" rm -f "$LOG_IPS_FILE" rm -f "$SNI_AWK_FILE" rm -f "$SNI_PID_FILE" # Для удаления временных файлов по glob временно включаем pathname expansion. set +f rm -f /tmp/*domains*.log rm -f /tmp/*dns*.log set -f echo "Очищаю RAM-log роутера..." if /etc/init.d/log restart; then echo "Сброс временных логов завершен." else echo "Предупреждение: не удалось перезапустить log." fi } ensure_interactive_input startup_maintenance while :; do select_main_menu || exit 1 case "$MENU_CHOICE" in 1) select_capture_targets ;; 2) show_unique pause_enter ;; 3) show_unique_by_ip SHOW_BY_IP_RC="$?" if [ "$SHOW_BY_IP_RC" -ne 2 ]; then pause_enter fi ;; 4) cleanup pause_enter ;; 5) # Ничего не печатаем: меню остаётся на экране и говорит само за себя. # Пустая строка - только чтобы приглашение шелла не липло к меню. echo exit 0 ;; esac done