From fcbab7739e7331c06099088bb8b97c17b6d822a6 Mon Sep 17 00:00:00 2001 From: vvgurin Date: Sun, 16 Aug 2026 17:47:34 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?/=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- podkop-domain-capture.sh | 1871 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1871 insertions(+) create mode 100644 podkop-domain-capture.sh diff --git a/podkop-domain-capture.sh b/podkop-domain-capture.sh new file mode 100644 index 0000000..db8890a --- /dev/null +++ b/podkop-domain-capture.sh @@ -0,0 +1,1871 @@ +#!/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