1872 lines
51 KiB
Bash
1872 lines
51 KiB
Bash
#!/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
|