commit bc9355fe408fbeacbe2eade5345dceb649b0f668 Author: Андрей Бобырев Date: Sun May 24 22:15:37 2026 +0300 Initial release: install, uninstall, SmartDNS, web UI Co-authored-by: Cursor diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a45e2f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Andrey + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5a1ee1 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Keenetic-Split-DNS + +Split-DNS для роутеров **Keenetic** с **Entware**: домены Yandex, VK, Mail.ru, OK и др. резолвятся через выбранный **DNS-over-TLS** upstream (например Yandex `77.88.8.8:853`), остальной трафик — через DNS провайдера / Keenetic. + +Веб-интерфейс на русском, порт **3200**, только LAN. + +## Быстрая установка + +```sh +curl -fsSL https://raw.githubusercontent.com/andrey271192/Keenetic-Split-DNS/main/install.sh | sh +``` + +После установки откройте `http://:3200` (токен API выводится в консоль). + +## Удаление + +```sh +curl -fsSL https://raw.githubusercontent.com/andrey271192/Keenetic-Split-DNS/main/uninstall.sh | sh +``` + +С удалением пакетов Entware: + +```sh +curl -fsSL https://raw.githubusercontent.com/andrey271192/Keenetic-Split-DNS/main/uninstall.sh | sh -s -- --purge +``` + +## Требования + +| Компонент | Описание | +|-----------|----------| +| Роутер Keenetic | Актуальная прошивка с Entware | +| USB + Entware | `/opt/bin/opkg` | +| Компоненты Keenetic | **Entware** (установка из магазина), при Neo — см. ниже | +| Пакеты Entware | `smartdns`, `lighttpd`, `lighttpd-mod-cgi`, `ca-certificates`, `curl`, `bind-dig` (устанавливает install.sh) | + +## Что делает установщик + +1. Копирует файлы в `/opt/etc/keenetic-split-dns/` и `/opt/share/keenetic-split-dns/` +2. Создаёт `config.yaml` из примера, подставляет LAN IP (`br0`) +3. Генерирует токен API → `/opt/etc/keenetic-split-dns/token` +4. Собирает `smartdns.conf` из YAML (`compile-config.sh`) +5. Включает init-скрипты `S97ksd-compile`, `S98smartdns`, `S99ksd-web` +6. По возможности: `opkg dns-override enable` (предпочтительно для **HydraRoute Neo**) +7. Иначе: правило `netfilter.d` для перенаправления DNS с `br0` + +## Конфигурация + +Основной файл: + +```text +/opt/etc/keenetic-split-dns/config.yaml +``` + +Списки доменов: + +```text +/opt/etc/keenetic-split-dns/domain-sets/ru-services.txt +``` + +Пересборка и применение вручную: + +```sh +/opt/share/keenetic-split-dns/scripts/apply.sh +``` + +### Upstream по умолчанию + +| ID | Тип | Назначение | +|----|-----|------------| +| `yandex-dot` | DoT | `77.88.8.8:853`, SNI `common.dot.dns.yandex.net` | +| `isp-default` | UDP | `auto` — DNS Keenetic / ISP | + +### Предзаполненные домены + +`yandex.ru`, `ya.ru`, `yandex.com`, `yandex.net`, `yastatic.net`, `yandex.st`, `mail.ru`, `mail.com`, `imgsmail.ru`, `mycdn.me`, `vk.com`, `vk.me`, `vkuservideo.net`, `vkuseraudio.net`, `userapi.com`, `vk-cdn.net`, `ok.ru`, `odnoklassniki.ru`, `okcdn.ru` + +## Веб-интерфейс + +| Вкладка | Функции | +|---------|---------| +| Обзор | Статус SmartDNS, счётчики | +| Upstream | Master-detail: профиль → домены группы | +| Домены | Таблица, поиск, экспорт | +| Проверка | `dig` через локальный SmartDNS | +| Настройки | Редактор YAML, токен, «Применить» | + +API (CGI): `GET/POST /api/status`, `/api/domains`, `/api/config`, `/api/reload`, `/api/test` + +Авторизация: заголовок `Authorization: Bearer ` или `X-KSD-Token`. + +## ⚠️ Конфликт с DoT в Keenetic + +В прошивке Keenetic (**Интернет-фильтры → Настройка DNS**) глобальный **DNS-over-TLS** обрабатывает **все** запросы и **не** умеет привязку «домен → сервер». + +**Рекомендация:** отключите глобальный DoT в UI Keenetic и используйте split-DNS этого проекта. Иначе политики SmartDNS могут не применяться к клиентам. + +## Совместимость с HydraRoute Neo + +[HydraRoute Neo](https://github.com/Ground-Zerro/HydraRoute/tree/main/Neo) маршрутизирует по IP из DNS (NFLOG), а не выбирает upstream DNS. + +| Правило | Действие | +|---------|----------| +| DHCP DNS | IP роутера в LAN (`192.168.x.1`) | +| DNS path | Предпочтительно `opkg dns-override` → SmartDNS на Entware | +| Глобальный DoT Keenetic | **Выключить** | +| Порты | HRweb `2000`, Split-DNS UI `3200` — не пересекаются | +| Проверка | После установки — диагностика в HRweb | + +Установщик сохраняет состояние `dns-override` в `/opt/etc/keenetic-split-dns/dns-override.state`; `uninstall.sh` пытается восстановить прежний `dns-override.conf`. + +## Структура репозитория + +```text +install.sh / uninstall.sh +scripts/ detect-lan, compile-config, apply, api +etc/ config.yaml.example, domain-sets, lighttpd, smartdns, ndm +www/ index.html, app.js, style.css +cgi-bin/api.cgi +init.d/ S97ksd-compile, S98smartdns, S99ksd-web +``` + +## Лицензия + +MIT — см. [LICENSE](LICENSE). + +## Автор + +[andrey271192](https://github.com/andrey271192) diff --git a/cgi-bin/api.cgi b/cgi-bin/api.cgi new file mode 100755 index 0000000..50d743d --- /dev/null +++ b/cgi-bin/api.cgi @@ -0,0 +1,6 @@ +#!/bin/sh +# Keenetic-Split-DNS REST API (CGI) + +export KSD_ETC="${KSD_ETC:-/opt/etc/keenetic-split-dns}" +. /opt/share/keenetic-split-dns/scripts/api.sh +api_route diff --git a/docs-ui-mockup-reference.html b/docs-ui-mockup-reference.html new file mode 100644 index 0000000..8952256 --- /dev/null +++ b/docs-ui-mockup-reference.html @@ -0,0 +1,901 @@ + + + + + + DNS Split — Keenetic + + + +
+ + +
+
+
+

Обзор

+

192.168.1.1:3200 · lighttpd + CGI

+
+
+ SmartDNS работает + + +
+
+ +
+ +
+
+
+

3 изменения не применены — конфиг изменён 24.05.2026 14:32

+

Последняя перезагрузка SmartDNS: 24.05.2026 12:15:03

+
+ +
+ +
+
+
+ Статус + +
+
Работает
+
PID 1842 · uptime 2д 4ч
+
+
+
+ Домены + 🌐 +
+
19
+
2 группы политик
+
+
+
+ Запросы / сутки + 📊 +
+
12.4k
+
кэш hit 78%
+
+
+
+ Upstream + 🔗 +
+
2
+
yandex-dot, isp-default
+
+
+ +
+
+

Группы доменов

+
+ + + + + + +
ГруппаПолитикаДоменов
yandex-ecosystemyandex-dot6
vk-mail-okyandex-dot13
+
+
+
+

Журнал

+
14:32:01 config.yaml изменён (domains +1)
+
12:15:03 smartdns reload OK
+
12:14:58 apply: 19 domains, 2 upstreams
+
10:02:11 lookup test vk.com → 87.240.x.x (yandex-dot)
+
09:41:00 lighttpd started :3200
+
+
+
+ + +
+
+ + + + +
+ +
+
+
+ + + + + + + + + + + +
ДоменГруппаПолитика
+
+
+
+

Массовый импорт

+
+ + +
+ +

Дубликаты пропускаются. Политика наследуется от группы.

+
+
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDТипАдресSNI / HostТаймаут
yandex-dotDoT77.88.8.8:853common.dot.dns.yandex.net5 с + +
isp-defaultUDP192.168.1.1:533 с + +
+
+
+
+

yandex-dot — детали

+
+
+ + +
+
+ + +
+
+
+ + +
+

Используется для доменов Yandex, VK, Mail.ru, OK в split-DNS политике.

+
+
+ + +
+
+

Проверка DNS-запроса

+
+
+ +
+ + +
+ +
+# Нажмите «Выполнить» для тестового запроса через SmartDNS +# Маршрутизация определяется политикой split-DNS +
+
+
+
+
Быстрые тесты
+
+ + + +
+
+
+
Ожидаемый upstream
+
yandex-dot для vk.com
+
+
+
Время ответа
+
+
+
+
+ + +
+
+
+

Применить конфигурацию — записать YAML и перезагрузить SmartDNS

+

Без применения изменения действуют только в редакторе

+
+ +
+ +
+
+

Сеть и доступ

+
+ + +
+
+ + +
+
+ + +

Заголовок X-DNS-Split-Token для CGI-запросов

+
+
+
+

SmartDNS

+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+

Резервная копия

+
+ + + +
+
+
+
+
+
+ + + + + + + + + + +
✓ Готово
+ + + + diff --git a/etc/config.yaml.example b/etc/config.yaml.example new file mode 100644 index 0000000..f792498 --- /dev/null +++ b/etc/config.yaml.example @@ -0,0 +1,41 @@ +# Keenetic-Split-DNS — основной конфиг +# Копируется в /opt/etc/keenetic-split-dns/config.yaml при установке + +version: 1 + +# IP роутера в LAN (веб-интерфейс и listen DNS) +lan_ip: "192.168.1.1" +web_port: 3200 + +# SmartDNS: порт DNS (53 при opkg dns-override, иначе 6053) +dns_port: 53 +dns_listen: "0.0.0.0" + +log_queries: true +cache_ttl_max: 3600 + +# Upstream по умолчанию для всех остальных доменов +default_upstream: isp-default + +upstreams: + yandex-dot: + name: "Yandex DNS (DoT)" + type: dot + address: "77.88.8.8" + port: 853 + sni: "common.dot.dns.yandex.net" + timeout: 5 + + isp-default: + name: "ISP / Keenetic DNS" + type: udp + # auto — detect-lan.sh / ndm: DNS провайдера или 127.0.0.1@53 + address: "auto" + port: 53 + timeout: 3 + +domain_groups: + ru-services: + name: "RU-сервисы (Yandex, VK, Mail, OK)" + upstream: yandex-dot + domain_set: "ru-services.txt" diff --git a/etc/domain-sets/ru-services.txt b/etc/domain-sets/ru-services.txt new file mode 100644 index 0000000..214b86e --- /dev/null +++ b/etc/domain-sets/ru-services.txt @@ -0,0 +1,19 @@ +yandex.ru +ya.ru +yandex.com +yandex.net +yastatic.net +yandex.st +mail.ru +mail.com +imgsmail.ru +mycdn.me +vk.com +vk.me +vkuservideo.net +vkuseraudio.net +userapi.com +vk-cdn.net +ok.ru +odnoklassniki.ru +okcdn.ru diff --git a/etc/lighttpd/lighttpd.conf b/etc/lighttpd/lighttpd.conf new file mode 100644 index 0000000..53e93af --- /dev/null +++ b/etc/lighttpd/lighttpd.conf @@ -0,0 +1,29 @@ +# Keenetic-Split-DNS web UI — lighttpd +# Installed to /opt/etc/keenetic-split-dns/lighttpd.conf + +server.document-root = "/opt/share/keenetic-split-dns/www" +server.port = 3200 +server.bind = "LAN_IP_PLACEHOLDER" +server.errorlog = "/opt/var/log/keenetic-split-dns/lighttpd-error.log" +server.pid-file = "/opt/var/run/keenetic-split-dns/lighttpd.pid" + +index-file.names = ( "index.html" ) +mimetype.assign = ( + ".html" => "text/html", + ".css" => "text/css", + ".js" => "application/javascript", + ".json" => "application/json", + ".svg" => "image/svg+xml", + ".ico" => "image/x-icon" +) + +cgi.assign = ( + ".cgi" => "" +) + +url.rewrite-once = ( + "^/api/?$" => "/api.cgi/status", + "^/api/(.*)$" => "/api.cgi/$1" +) + +# LAN only — bind set at install/apply from config.yaml diff --git a/etc/ndm/netfilter.d/010-keenetic-split-dns.sh b/etc/ndm/netfilter.d/010-keenetic-split-dns.sh new file mode 100755 index 0000000..214a456 --- /dev/null +++ b/etc/ndm/netfilter.d/010-keenetic-split-dns.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Keenetic-Split-DNS — redirect LAN DNS to SmartDNS (fallback if dns-override unavailable) +# Installed to /opt/etc/ndm/netfilter.d/010-keenetic-split-dns.sh + +KSD_ETC="/opt/etc/keenetic-split-dns" +DNS_PORT="53" + +[ -f "$KSD_ETC/config.yaml" ] || exit 0 + +# Skip if Entware dns-override is active +if [ -f /opt/etc/dns-override.conf ] && grep -q '^enabled=1' /opt/etc/dns-override.conf 2>/dev/null; then + exit 0 +fi + +LAN_IP="$(grep '^lan_ip:' "$KSD_ETC/config.yaml" 2>/dev/null | sed 's/.*"\([^"]*\)".*/\1/')" +[ -n "$LAN_IP" ] || LAN_IP="192.168.1.1" + +DNS_PORT="$(grep '^dns_port:' "$KSD_ETC/config.yaml" 2>/dev/null | awk '{print $2}')" +[ -n "$DNS_PORT" ] || DNS_PORT="53" + +case "$1" in + start|restart) + iptables -t nat -C PREROUTING -i br0 -p udp --dport 53 ! -d "$LAN_IP" -j REDIRECT --to-port "$DNS_PORT" 2>/dev/null \ + || iptables -t nat -A PREROUTING -i br0 -p udp --dport 53 ! -d "$LAN_IP" -j REDIRECT --to-port "$DNS_PORT" + iptables -t nat -C PREROUTING -i br0 -p tcp --dport 53 ! -d "$LAN_IP" -j REDIRECT --to-port "$DNS_PORT" 2>/dev/null \ + || iptables -t nat -A PREROUTING -i br0 -p tcp --dport 53 ! -d "$LAN_IP" -j REDIRECT --to-port "$DNS_PORT" + ;; + stop) + iptables -t nat -D PREROUTING -i br0 -p udp --dport 53 ! -d "$LAN_IP" -j REDIRECT --to-port "$DNS_PORT" 2>/dev/null || true + iptables -t nat -D PREROUTING -i br0 -p tcp --dport 53 ! -d "$LAN_IP" -j REDIRECT --to-port "$DNS_PORT" 2>/dev/null || true + ;; +esac + +exit 0 diff --git a/etc/smartdns/smartdns.conf.head b/etc/smartdns/smartdns.conf.head new file mode 100644 index 0000000..516c9c8 --- /dev/null +++ b/etc/smartdns/smartdns.conf.head @@ -0,0 +1,12 @@ +# Generated by Keenetic-Split-DNS — do not edit manually +# Source: /opt/etc/keenetic-split-dns/config.yaml + +server-name keenetic-split-dns +log-level info +cache-size 512 +cache-persist yes +prefetch-domain yes +serve-expired yes +dnsmasq-lease-file /var/lib/misc/dnsmasq.leases + +# User settings appended by compile-config.sh diff --git a/init.d/S97ksd-compile b/init.d/S97ksd-compile new file mode 100755 index 0000000..d0185cf --- /dev/null +++ b/init.d/S97ksd-compile @@ -0,0 +1,17 @@ +#!/bin/sh +# Keenetic-Split-DNS — compile config on boot + +KSD_ETC="/opt/etc/keenetic-split-dns" +COMPILE="/opt/share/keenetic-split-dns/scripts/compile-config.sh" + +case "$1" in + start) + [ -x "$COMPILE" ] && [ -f "${KSD_ETC}/config.yaml" ] && "$COMPILE" + ;; + stop) ;; + restart) + "$0" stop + "$0" start + ;; +esac +exit 0 diff --git a/init.d/S98smartdns b/init.d/S98smartdns new file mode 100755 index 0000000..28ae4c8 --- /dev/null +++ b/init.d/S98smartdns @@ -0,0 +1,41 @@ +#!/bin/sh +# SmartDNS for Keenetic-Split-DNS (wraps Entware smartdns) + +KSD_ETC="/opt/etc/keenetic-split-dns" +CONF="${KSD_ETC}/smartdns.conf" +PIDFILE="/opt/var/run/keenetic-split-dns/smartdns.pid" +LOGDIR="/opt/var/log/keenetic-split-dns" + +start() { + mkdir -p "$LOGDIR" "$(dirname "$PIDFILE")" + [ -f "$CONF" ] || return 1 + if pidof smartdns >/dev/null 2>&1; then + return 0 + fi + smartdns -c "$CONF" -p "$PIDFILE" >/dev/null 2>&1 & + sleep 1 + pidof smartdns >/dev/null 2>&1 +} + +stop() { + if [ -f "$PIDFILE" ]; then + kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null || true + rm -f "$PIDFILE" + fi + killall smartdns 2>/dev/null || true +} + +case "$1" in + start) start ;; + stop) stop ;; + restart) stop; sleep 1; start ;; + status) + if pidof smartdns >/dev/null 2>&1; then + echo "smartdns running" + exit 0 + fi + echo "smartdns stopped" + exit 1 + ;; +esac +exit 0 diff --git a/init.d/S99ksd-web b/init.d/S99ksd-web new file mode 100755 index 0000000..ad6a091 --- /dev/null +++ b/init.d/S99ksd-web @@ -0,0 +1,39 @@ +#!/bin/sh +# lighttpd web UI for Keenetic-Split-DNS + +KSD_ETC="/opt/etc/keenetic-split-dns" +CONF="${KSD_ETC}/lighttpd.conf" +PIDFILE="/opt/var/run/keenetic-split-dns/lighttpd.pid" +LOGDIR="/opt/var/log/keenetic-split-dns" + +start() { + mkdir -p "$LOGDIR" "$(dirname "$PIDFILE")" + [ -f "$CONF" ] || return 1 + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then + return 0 + fi + lighttpd -f "$CONF" -m /opt/lib/lighttpd +} + +stop() { + if [ -f "$PIDFILE" ]; then + kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null || true + rm -f "$PIDFILE" + fi + killall lighttpd 2>/dev/null || true +} + +case "$1" in + start) start ;; + stop) stop ;; + restart) stop; sleep 1; start ;; + status) + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then + echo "lighttpd running" + exit 0 + fi + echo "lighttpd stopped" + exit 1 + ;; +esac +exit 0 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b764d19 --- /dev/null +++ b/install.sh @@ -0,0 +1,153 @@ +#!/bin/sh +# Keenetic-Split-DNS — one-line installer for Keenetic + Entware +# curl -fsSL https://raw.githubusercontent.com/andrey271192/Keenetic-Split-DNS/main/install.sh | sh + +set -e + +KSD_VERSION="1.0.0" +KSD_ETC="/opt/etc/keenetic-split-dns" +KSD_SHARE="/opt/share/keenetic-split-dns" +KSD_VAR_LOG="/opt/var/log/keenetic-split-dns" +KSD_VAR_RUN="/opt/var/run/keenetic-split-dns" +REPO_RAW="${KSD_REPO_RAW:-https://raw.githubusercontent.com/andrey271192/Keenetic-Split-DNS/main}" + +log() { printf '[keenetic-split-dns] %s\n' "$*"; } +die() { log "ERROR: $*"; exit 1; } + +# --- Entware check --- +[ -x /opt/bin/opkg ] || die "Entware not found. Install Entware on USB first." +[ -d /opt/etc ] || die "/opt/etc missing — Entware broken?" + +# --- resolve source tree --- +SRC_DIR="" +case "$0" in + /*|./*|*/install.sh) + _d="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" + [ -f "${_d}/etc/config.yaml.example" ] && SRC_DIR="$_d" + ;; +esac +if [ -z "$SRC_DIR" ]; then + TMP="$(mktemp -d /tmp/ksd-install.XXXXXX)" + trap 'rm -rf "$TMP"' EXIT INT HUP + log "Downloading repository..." + if command -v git >/dev/null 2>&1; then + git clone --depth 1 https://github.com/andrey271192/Keenetic-Split-DNS.git "$TMP/repo" + SRC_DIR="$TMP/repo" + else + die "git not found. On router: opkg install git git-http. Or clone repo manually and run install.sh from it." + fi +fi + +log "Installing Keenetic-Split-DNS v${KSD_VERSION}..." + +# --- packages --- +log "Installing Entware packages..." +opkg update >/dev/null 2>&1 || true +for pkg in smartdns lighttpd lighttpd-mod-cgi ca-certificates curl grep sed bind-dig; do + opkg list-installed 2>/dev/null | grep -q "^${pkg} " || opkg install "$pkg" >/dev/null 2>&1 || { + case "$pkg" in + bind-dig) opkg install bind-tools >/dev/null 2>&1 || opkg install dig >/dev/null 2>&1 || true ;; + *) log "Warning: package $pkg may be missing" ;; + esac + } +done + +mkdir -p "$KSD_ETC" "$KSD_SHARE" "$KSD_VAR_LOG" "$KSD_VAR_RUN" \ + "${KSD_ETC}/domain-sets" "${KSD_SHARE}/www" "${KSD_SHARE}/scripts" \ + "${KSD_SHARE}/cgi-bin" "${KSD_SHARE}/smartdns" "${KSD_SHARE}/lighttpd" \ + "${KSD_SHARE}/domain-sets" + +# --- copy files --- +cp -f "$SRC_DIR/etc/config.yaml.example" "${KSD_ETC}/config.yaml" 2>/dev/null || true +[ -f "${KSD_ETC}/config.yaml" ] || cp "$SRC_DIR/etc/config.yaml.example" "${KSD_ETC}/config.yaml" + +cp -rf "$SRC_DIR/scripts/"* "${KSD_SHARE}/scripts/" +cp -rf "$SRC_DIR/www/"* "${KSD_SHARE}/www/" +cp -f "$SRC_DIR/cgi-bin/api.cgi" "${KSD_SHARE}/cgi-bin/" +cp -rf "$SRC_DIR/etc/domain-sets/"* "${KSD_SHARE}/domain-sets/" 2>/dev/null || true +cp -f "$SRC_DIR/etc/smartdns/"* "${KSD_SHARE}/smartdns/" 2>/dev/null || true +cp -f "$SRC_DIR/etc/lighttpd/lighttpd.conf" "${KSD_SHARE}/lighttpd/" + +# symlink API CGI into web root +ln -sf "${KSD_SHARE}/cgi-bin/api.cgi" "${KSD_SHARE}/www/api.cgi" +mkdir -p "${KSD_SHARE}/www/api" +ln -sf "${KSD_SHARE}/cgi-bin/api.cgi" "${KSD_SHARE}/www/api/index.cgi" + +chmod +x "${KSD_SHARE}/scripts/"*.sh "${KSD_SHARE}/cgi-bin/api.cgi" + +# --- detect LAN --- +DETECT="${KSD_SHARE}/scripts/detect-lan.sh" +LAN_IP="$("$DETECT" lan 2>/dev/null || echo "192.168.1.1")" +if grep -q 'lan_ip: "192.168.1.1"' "${KSD_ETC}/config.yaml" 2>/dev/null; then + sed -i "s/lan_ip: \"192.168.1.1\"/lan_ip: \"${LAN_IP}\"/" "${KSD_ETC}/config.yaml" 2>/dev/null \ + || sed "s/lan_ip: \"192.168.1.1\"/lan_ip: \"${LAN_IP}\"/" "${KSD_ETC}/config.yaml" > "${KSD_ETC}/config.yaml.tmp" \ + && mv "${KSD_ETC}/config.yaml.tmp" "${KSD_ETC}/config.yaml" +fi + +# --- API token --- +if [ ! -f "${KSD_ETC}/token" ]; then + if [ -r /dev/urandom ]; then + TOKEN="$(head -c 24 /dev/urandom | hexdump -ve '1/1 "%02x"' 2>/dev/null | head -c 48)" + else + TOKEN="$(date +%s)$(uname -n)" + fi + [ -n "$TOKEN" ] || TOKEN="change-me-$(date +%s)" + echo "$TOKEN" > "${KSD_ETC}/token" + chmod 600 "${KSD_ETC}/token" +fi + +# --- init.d --- +for s in S97ksd-compile S98smartdns S99ksd-web; do + cp -f "$SRC_DIR/init.d/$s" "/opt/etc/init.d/$s" + chmod +x "/opt/etc/init.d/$s" +done + +# --- netfilter fallback --- +mkdir -p /opt/etc/ndm/netfilter.d +cp -f "$SRC_DIR/etc/ndm/netfilter.d/010-keenetic-split-dns.sh" /opt/etc/ndm/netfilter.d/ +chmod +x /opt/etc/ndm/netfilter.d/010-keenetic-split-dns.sh + +# --- domain sets to etc --- +cp -f "${KSD_SHARE}/domain-sets/"*.txt "${KSD_ETC}/domain-sets/" 2>/dev/null || true + +# --- compile & start --- +export KSD_ETC KSD_SHARE +"${KSD_SHARE}/scripts/compile-config.sh" + +# --- dns-override (HydraRoute Neo compatible path) --- +DNS_OVERRIDE_STATE="${KSD_ETC}/dns-override.state" +if opkg dns-override 2>/dev/null | grep -q enable; then + log "Configuring opkg dns-override..." + if ! grep -q '^enabled=1' /opt/etc/dns-override.conf 2>/dev/null; then + cp /opt/etc/dns-override.conf /opt/etc/dns-override.conf.ksd-bak 2>/dev/null || true + echo "before-install" > "$DNS_OVERRIDE_STATE" + fi + opkg dns-override enable 2>/dev/null || true + echo "enabled" > "$DNS_OVERRIDE_STATE" + log "dns-override enabled — clients use Entware SmartDNS" +else + log "dns-override not available — using netfilter.d redirect (if br0 DNS used)" + echo "netfilter" > "$DNS_OVERRIDE_STATE" +fi + +/opt/etc/init.d/S97ksd-compile start +/opt/etc/init.d/S98smartdns start +/opt/etc/init.d/S99ksd-web start + +ndm -p netfilter restart 2>/dev/null || true + +WEB_PORT="$(grep '^web_port:' "${KSD_ETC}/config.yaml" | awk '{print $2}')" +[ -n "$WEB_PORT" ] || WEB_PORT="3200" +TOKEN_SHOW="$(cat "${KSD_ETC}/token")" + +log "==============================================" +log "Keenetic-Split-DNS installed successfully." +log "Web UI: http://${LAN_IP}:${WEB_PORT}" +log "API token (save it): ${TOKEN_SHOW}" +log "Config: ${KSD_ETC}/config.yaml" +log "" +log "IMPORTANT: Disable global DoT in Keenetic UI" +log "(Интернет-фильтры -> DNS) to avoid conflicts." +log "HydraRoute Neo: keep DHCP DNS = router IP." +log "==============================================" +exit 0 diff --git a/scripts/api.sh b/scripts/api.sh new file mode 100755 index 0000000..c4b9ad5 --- /dev/null +++ b/scripts/api.sh @@ -0,0 +1,208 @@ +#!/bin/sh +# CGI API helpers for Keenetic-Split-DNS + +KSD_ETC="${KSD_ETC:-/opt/etc/keenetic-split-dns}" +CONFIG="${KSD_ETC}/config.yaml" +TOKEN_FILE="${KSD_ETC}/token" +LOG_FILE="${KSD_ETC}/apply.log" + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' ' +} + +api_send_json() { + code="${1:-200}" + body="$2" + printf 'Status: %s\r\n' "$code" + printf 'Content-Type: application/json; charset=utf-8\r\n' + printf 'Cache-Control: no-store\r\n' + printf '\r\n' + printf '%s' "$body" +} + +api_unauthorized() { + api_send_json "401" '{"ok":false,"error":"unauthorized"}' + exit 0 +} + +api_check_auth() { + expected="" + [ -f "$TOKEN_FILE" ] && expected="$(cat "$TOKEN_FILE" | tr -d '\n\r ')" + [ -n "$expected" ] || return 0 + + got="" + case "$HTTP_AUTHORIZATION" in + Bearer*) got="${HTTP_AUTHORIZATION#Bearer }" ;; + esac + [ -z "$got" ] && got="$HTTP_X_KSD_TOKEN" + [ -z "$got" ] && got="$HTTP_X_DNS_SPLIT_TOKEN" + + if [ "$got" != "$expected" ]; then + api_unauthorized + fi +} + +api_read_body() { + if [ -n "$CONTENT_LENGTH" ] && [ "$CONTENT_LENGTH" -gt 0 ] 2>/dev/null; then + dd bs=1 count="$CONTENT_LENGTH" 2>/dev/null + fi +} + +smartdns_running() { + if pidof smartdns >/dev/null 2>&1; then + echo "true" + else + echo "false" + fi +} + +smartdns_pid() { + pidof smartdns 2>/dev/null | awk '{print $1}' +} + +count_domains() { + n=0 + if [ -d "${KSD_ETC}/domain-sets" ]; then + for f in "${KSD_ETC}"/domain-sets/*.txt; do + [ -f "$f" ] || continue + n=$((n + $(grep -cve '^\s*$' -e '^\s*#' "$f" 2>/dev/null || echo 0))) + done + fi + echo "$n" +} + +api_status() { + lan="$(grep '^lan_ip:' "$CONFIG" 2>/dev/null | sed 's/.*"\([^"]*\)".*/\1/')" + port="$(grep '^web_port:' "$CONFIG" 2>/dev/null | awk '{print $2}')" + [ -n "$port" ] || port="3200" + domains="$(count_domains)" + running="$(smartdns_running)" + pid="$(smartdns_pid)" + last_apply="" + [ -f "$LOG_FILE" ] && last_apply="$(tail -1 "$LOG_FILE" 2>/dev/null | json_escape)" + + api_send_json "200" "{\"ok\":true,\"smartdns\":{\"running\":${running},\"pid\":\"${pid}\"},\"domains\":${domains},\"lan_ip\":\"${lan}\",\"web_port\":${port},\"url\":\"http://${lan}:${port}\"}" +} + +api_get_config_raw() { + if [ ! -f "$CONFIG" ]; then + api_send_json "404" '{"ok":false,"error":"config not found"}' + exit 0 + fi + printf 'Status: 200\r\n' + printf 'Content-Type: application/x-yaml; charset=utf-8\r\n' + printf 'Cache-Control: no-store\r\n' + printf '\r\n' + cat "$CONFIG" + exit 0 +} + +api_save_config() { + body="$(api_read_body)" + [ -n "$body" ] || { api_send_json "400" '{"ok":false,"error":"empty body"}'; exit 0; } + + # Accept raw YAML body or {"content":"..."} + case "$body" in + \{*) + content="$(printf '%s' "$body" | sed -n 's/.*"content"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p' | sed 's/\\n/\n/g; s/\\"/"/g')" + ;; + *) + content="$body" + ;; + esac + + [ -n "$content" ] || { api_send_json "400" '{"ok":false,"error":"no content"}'; exit 0; } + cp "$CONFIG" "${CONFIG}.bak" 2>/dev/null || true + printf '%s\n' "$content" > "$CONFIG" + api_send_json "200" '{"ok":true,"message":"saved"}' +} + +api_reload() { + SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" + if "$SCRIPT_DIR/apply.sh" >>"$LOG_FILE" 2>&1; then + ts="$(date '+%Y-%m-%d %H:%M:%S')" + echo "${ts} reload OK" >>"$LOG_FILE" + api_send_json "200" '{"ok":true,"message":"applied"}' + else + api_send_json "500" '{"ok":false,"error":"apply failed"}' + fi +} + +api_test_dns() { + domain="${QUERY_STRING#*domain=}" + domain="${domain%%&*}" + domain="$(printf '%s' "$domain" | sed 's/%\([0-9A-F][0-9A-F]\)/\\x\1/g' | xargs -0 printf '%b' 2>/dev/null || echo "$domain")" + [ -n "$domain" ] || domain="vk.com" + rtype="A" + case "$QUERY_STRING" in + *type=AAAA*) rtype="AAAA" ;; + *type=HTTPS*) rtype="HTTPS" ;; + esac + + if command -v dig >/dev/null 2>&1; then + dns_port="$(grep '^dns_port:' "$CONFIG" 2>/dev/null | awk '{print $2}')" + [ -n "$dns_port" ] || dns_port="53" + start_ms="$(date +%s)" + out="$(dig @"127.0.0.1" -p "$dns_port" "$domain" "$rtype" +time=3 +tries=1 2>&1)" || out="dig failed" + end_ms="$(date +%s)" + ms=$(( (end_ms - start_ms) * 1000 )) + esc="$(printf '%s' "$out" | json_escape)" + api_send_json "200" "{\"ok\":true,\"domain\":\"${domain}\",\"type\":\"${rtype}\",\"ms\":${ms},\"output\":\"${esc}\"}" + else + api_send_json "503" '{"ok":false,"error":"dig not installed"}' + fi +} + +api_domains_list() { + # Build simple JSON array from domain sets + echo -n '{"ok":true,"domains":[' + first=1 + if [ -d "${KSD_ETC}/domain-sets" ]; then + for f in "${KSD_ETC}"/domain-sets/*.txt; do + [ -f "$f" ] || continue + group="$(basename "$f" .txt)" + upstream="$(awk -v g="$group" '$0 ~ "^ " g ":$" {f=1} f && $0 ~ "upstream:" {print $2; exit}' "$CONFIG" 2>/dev/null)" + while IFS= read -r d || [ -n "$d" ]; do + d="$(echo "$d" | tr -d '\r')" + [ -z "$d" ] && continue + case "$d" in \#*) continue ;; esac + [ "$first" -eq 1 ] || echo -n ',' + first=0 + printf '{"domain":"%s","group":"%s","upstream":"%s"}' "$d" "$group" "${upstream:-yandex-dot}" + done < "$f" + done + fi + echo ']}' +} + +api_route() { + path="${PATH_INFO:-/}" + path="${path#/}" + method="${REQUEST_METHOD:-GET}" + + api_check_auth + + case "$path" in + status|"") api_status ;; + config) + case "$method" in + GET) api_get_config_raw ;; + POST) api_save_config ;; + *) api_send_json "405" '{"ok":false,"error":"method"}' ;; + esac + ;; + domains) + body="$(api_domains_list)" + api_send_json "200" "$body" + ;; + reload|apply) + api_reload + ;; + test) + api_test_dns + ;; + *) + api_send_json "404" '{"ok":false,"error":"not found"}' + ;; + esac +} diff --git a/scripts/apply.sh b/scripts/apply.sh new file mode 100755 index 0000000..b27964c --- /dev/null +++ b/scripts/apply.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Apply configuration: compile, restart services, netfilter + +set -e + +KSD_ETC="${KSD_ETC:-/opt/etc/keenetic-split-dns}" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" + +"$SCRIPT_DIR/compile-config.sh" + +if [ -x /opt/etc/init.d/S98smartdns ]; then + /opt/etc/init.d/S98smartdns restart || /opt/etc/init.d/S98smartdns start +elif [ -x /opt/etc/init.d/S56smartdns ]; then + /opt/etc/init.d/S56smartdns restart || true +fi + +if [ -x /opt/etc/init.d/S99ksd-web ]; then + /opt/etc/init.d/S99ksd-web restart || /opt/etc/init.d/S99ksd-web start +fi + +NF="/opt/etc/ndm/netfilter.d/010-keenetic-split-dns.sh" +if [ -x "$NF" ]; then + "$NF" restart 2>/dev/null || true + ndm -p netfilter restart 2>/dev/null || true +fi + +echo "Applied keenetic-split-dns configuration." +exit 0 diff --git a/scripts/compile-config.sh b/scripts/compile-config.sh new file mode 100755 index 0000000..ee2a991 --- /dev/null +++ b/scripts/compile-config.sh @@ -0,0 +1,148 @@ +#!/bin/sh +# YAML config -> SmartDNS conf + domain sets + lighttpd bind + +set -e + +KSD_ETC="${KSD_ETC:-/opt/etc/keenetic-split-dns}" +KSD_SHARE="${KSD_SHARE:-/opt/share/keenetic-split-dns}" +CONFIG="${KSD_ETC}/config.yaml" +OUT_SMART="${KSD_ETC}/smartdns.conf" +OUT_LIGHT="${KSD_ETC}/lighttpd.conf" +DOMAIN_DIR="${KSD_ETC}/domain-sets" +HEAD="${KSD_SHARE}/smartdns/smartdns.conf.head" + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +DETECT="${SCRIPT_DIR}/detect-lan.sh" + +[ -f "$CONFIG" ] || { echo "Missing $CONFIG" >&2; exit 1; } +mkdir -p "$DOMAIN_DIR" "$(dirname "$OUT_SMART")" + +# --- simple YAML getters (key: value / nested under upstreams:) --- +yaml_val() { + key="$1" + awk -v k="$key" ' + $0 ~ "^" k ": " { + v = $0; sub("^" k ": ", "", v) + gsub(/^["'\'']|["'\'']$/, "", v) + print v; exit + } + ' "$CONFIG" +} + +yaml_upstream_field() { + uid="$1" + field="$2" + awk -v id="$uid" -v f="$field" ' + $0 ~ "^ " id ":$" { inb=1; next } + inb && $0 ~ "^ [a-zA-Z0-9_-]+:$" { exit } + inb && $0 ~ "^ " f ": " { + v=$0; sub("^ " f ": ", "", v) + gsub(/^["'\'']|["'\'']$/, "", v) + print v; exit + } + ' "$CONFIG" +} + +yaml_group_field() { + gid="$1" + field="$2" + awk -v id="$gid" -v f="$field" ' + $0 ~ "^ " id ":$" { inb=1; next } + inb && $0 ~ "^ [a-zA-Z0-9_-]+:$" { exit } + inb && $0 ~ "^ " f ": " { + v=$0; sub("^ " f ": ", "", v) + gsub(/^["'\'']|["'\'']$/, "", v) + print v; exit + } + ' "$CONFIG" +} + +LAN_IP="$(yaml_val lan_ip)" +[ -n "$LAN_IP" ] || LAN_IP="$("$DETECT" lan)" +WEB_PORT="$(yaml_val web_port)" +[ -n "$WEB_PORT" ] || WEB_PORT="3200" +DNS_PORT="$(yaml_val dns_port)" +[ -n "$DNS_PORT" ] || DNS_PORT="53" +DNS_LISTEN="$(yaml_val dns_listen)" +[ -n "$DNS_LISTEN" ] || DNS_LISTEN="0.0.0.0" +CACHE_TTL="$(yaml_val cache_ttl_max)" +[ -n "$CACHE_TTL" ] || CACHE_TTL="3600" +DEFAULT_UP="$(yaml_val default_upstream)" +[ -n "$DEFAULT_UP" ] || DEFAULT_UP="isp-default" +LOG_Q="$(yaml_val log_queries)" + +# --- SmartDNS header --- +{ + [ -f "$HEAD" ] && cat "$HEAD" + echo "bind ${DNS_LISTEN}:${DNS_PORT}" + echo "cache-persist no" + echo "rr-ttl-max ${CACHE_TTL}" + if [ "$LOG_Q" = "true" ] || [ "$LOG_Q" = "1" ]; then + echo "log-file /opt/var/log/keenetic-split-dns/smartdns.log" + echo "log-size 256k" + fi + echo "" +} > "$OUT_SMART" + +# --- upstream servers --- +list_upstream_ids() { + awk '/^upstreams:$/,/^[^ ]/ { if ($0 ~ /^ [a-zA-Z0-9_-]+:$/) { gsub(/:$/,"",$1); print substr($1,3) } }' "$CONFIG" | head -20 +} + +for uid in $(list_upstream_ids); do + typ="$(yaml_upstream_field "$uid" type)" + addr="$(yaml_upstream_field "$uid" address)" + port="$(yaml_upstream_field "$uid" port)" + sni="$(yaml_upstream_field "$uid" sni)" + [ -n "$port" ] || port="53" + + case "$typ" in + dot) + host="${sni:-$addr}" + echo "server-tls ${host} -address ${addr}:${port} -host-name ${host} -group ${uid} -no-check-certificate" >> "$OUT_SMART" + ;; + doh) + url="$(yaml_upstream_field "$uid" url)" + [ -n "$url" ] || url="https://${addr}/dns-query" + echo "server-https ${url} -group ${uid}" >> "$OUT_SMART" + ;; + *) + if [ "$addr" = "auto" ]; then + addr="$("$DETECT" isp)" + fi + echo "server ${addr}:${port} -group ${uid}" >> "$OUT_SMART" + ;; + esac +done + +echo "" >> "$OUT_SMART" + +# --- domain groups --- +list_group_ids() { + awk '/^domain_groups:$/,/^[^ ]/ { if ($0 ~ /^ [a-zA-Z0-9_-]+:$/) { gsub(/:$/,"",$1); print substr($1,3) } }' "$CONFIG" +} + +for gid in $(list_group_ids); do + upstream="$(yaml_group_field "$gid" upstream)" + dset="$(yaml_group_field "$gid" domain_set)" + [ -n "$dset" ] || dset="${gid}.txt" + dpath="${DOMAIN_DIR}/${dset}" + if [ ! -f "$dpath" ] && [ -f "${KSD_SHARE}/domain-sets/${dset}" ]; then + cp "${KSD_SHARE}/domain-sets/${dset}" "$dpath" + fi + [ -f "$dpath" ] || continue + echo "domain-set -name ${gid} -file ${dpath}" >> "$OUT_SMART" + echo "domain-rules /domain-set:${gid}/ -nameserver ${upstream} -speed-check-mode none" >> "$OUT_SMART" +done + +echo "nameserver ${DEFAULT_UP}" >> "$OUT_SMART" +echo "default-nameserver ${DEFAULT_UP}" >> "$OUT_SMART" + +# --- lighttpd --- +LIGHT_SRC="${KSD_SHARE}/lighttpd/lighttpd.conf" +if [ -f "$LIGHT_SRC" ]; then + sed "s/LAN_IP_PLACEHOLDER/${LAN_IP}/g; s/server.port = 3200/server.port = ${WEB_PORT}/" "$LIGHT_SRC" > "$OUT_LIGHT" +fi + +echo "Compiled: $OUT_SMART" +exit 0 diff --git a/scripts/detect-lan.sh b/scripts/detect-lan.sh new file mode 100755 index 0000000..9961ae8 --- /dev/null +++ b/scripts/detect-lan.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# Detect Keenetic LAN IP (br0 / ndm) + +set -e + +detect_lan_ip() { + ip="" + if command -v ip >/dev/null 2>&1; then + ip="$(ip -4 addr show br0 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1 | head -1)" + fi + if [ -z "$ip" ] && [ -f /proc/net/route ]; then + ip="$(awk '$1=="br0" && $3=="00000000" {print; exit}' /proc/net/route 2>/dev/null | \ + awk '{printf "%d.%d.%d.%d\n", strtonum("0x" substr($2,7,2)), strtonum("0x" substr($2,5,2)), strtonum("0x" substr($2,3,2)), strtonum("0x" substr($2,1,2))}' 2>/dev/null || true)" + fi + if [ -z "$ip" ] && command -v ndm >/dev/null 2>&1; then + ip="$(ndm -p ip address 2>/dev/null | awk '/inet / && /br0/ {gsub(/\/.*/,"",$2); print $2; exit}')" + fi + if [ -z "$ip" ]; then + ip="192.168.1.1" + fi + printf '%s' "$ip" +} + +detect_isp_dns() { + dns="" + if command -v ndm >/dev/null 2>&1; then + dns="$(ndm -p show dns-proxy 2>/dev/null | awk '/server/ {print $3; exit}' | tr -d "'\")" + fi + if [ -z "$dns" ]; then + dns="$(grep '^nameserver' /etc/resolv.conf 2>/dev/null | awk '{print $2}' | head -1)" + fi + if [ -z "$dns" ]; then + dns="$(detect_lan_ip)" + fi + printf '%s' "$dns" +} + +case "${1:-lan}" in + lan) detect_lan_ip ;; + isp) detect_isp_dns ;; + *) detect_lan_ip ;; +esac diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..2ea20ca --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# Keenetic-Split-DNS uninstaller +# curl -fsSL https://raw.githubusercontent.com/andrey271192/Keenetic-Split-DNS/main/uninstall.sh | sh +# Add --purge to remove Entware packages (smartdns, lighttpd) + +set -e + +PURGE=0 +for arg in "$@"; do + case "$arg" in + --purge) PURGE=1 ;; + esac +done + +KSD_ETC="/opt/etc/keenetic-split-dns" +KSD_SHARE="/opt/share/keenetic-split-dns" +REPO_RAW="${KSD_REPO_RAW:-https://raw.githubusercontent.com/andrey271192/Keenetic-Split-DNS/main}" + +log() { printf '[keenetic-split-dns] %s\n' "$*"; } + +# Allow curl pipe: fetch self not needed + +log "Stopping services..." +for s in S99ksd-web S98smartdns S97ksd-compile; do + [ -x "/opt/etc/init.d/$s" ] && /opt/etc/init.d/$s stop 2>/dev/null || true +done + +NF="/opt/etc/ndm/netfilter.d/010-keenetic-split-dns.sh" +if [ -x "$NF" ]; then + "$NF" stop 2>/dev/null || true +fi +ndm -p netfilter restart 2>/dev/null || true + +# Restore dns-override if we touched it +STATE="${KSD_ETC}/dns-override.state" +if [ -f "$STATE" ]; then + case "$(cat "$STATE" 2>/dev/null)" in + enabled|before-install) + if [ -f /opt/etc/dns-override.conf.ksd-bak ]; then + cp /opt/etc/dns-override.conf.ksd-bak /opt/etc/dns-override.conf + log "Restored dns-override.conf from backup" + else + opkg dns-override disable 2>/dev/null || true + fi + ;; + esac +fi + +log "Removing init scripts..." +for s in S99ksd-web S98smartdns S97ksd-compile; do + rm -f "/opt/etc/init.d/$s" +done + +rm -f /opt/etc/ndm/netfilter.d/010-keenetic-split-dns.sh + +log "Removing files..." +rm -rf "$KSD_ETC" "$KSD_SHARE" +rm -rf /opt/var/log/keenetic-split-dns /opt/var/run/keenetic-split-dns + +killall smartdns 2>/dev/null || true +killall lighttpd 2>/dev/null || true + +if [ "$PURGE" -eq 1 ]; then + log "Purging Entware packages (--purge)..." + opkg remove smartdns lighttpd lighttpd-mod-cgi 2>/dev/null || true +else + log "Entware packages kept (use --purge to remove smartdns/lighttpd)" +fi + +log "Keenetic-Split-DNS uninstalled." +exit 0 diff --git a/www/app.js b/www/app.js new file mode 100644 index 0000000..f4a1b10 --- /dev/null +++ b/www/app.js @@ -0,0 +1,238 @@ +(function () { + const API = '/api'; + const TOKEN_KEY = 'ksd_token'; + + const TITLES = { + dashboard: 'Обзор', + domains: 'Домены', + upstreams: 'Upstream-профили', + test: 'Проверка DNS', + settings: 'Настройки', + }; + + let state = { domains: [], status: null, yaml: '' }; + let selectedUpstream = null; + + function token() { + return localStorage.getItem(TOKEN_KEY) || ''; + } + + function headers() { + const h = { Accept: 'application/json' }; + const t = token(); + if (t) h.Authorization = 'Bearer ' + t; + return h; + } + + async function api(path, opts = {}) { + const res = await fetch(API + path, { + ...opts, + headers: { ...headers(), ...(opts.headers || {}) }, + }); + if (res.status === 401) { + document.getElementById('auth-gate').classList.remove('hidden'); + throw new Error('unauthorized'); + } + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + return { ok: false, raw: text }; + } + } + + function toast(msg) { + const t = document.getElementById('toast'); + t.textContent = '✓ ' + msg; + t.classList.add('show'); + setTimeout(() => t.classList.remove('show'), 2800); + } + + function setStatusBadge(running) { + const el = document.getElementById('status-badge'); + el.textContent = running ? 'SmartDNS работает' : 'SmartDNS остановлен'; + el.className = 'badge badge-dot ' + (running ? 'badge-running' : 'badge-stopped'); + } + + async function refreshStatus() { + const s = await api('/status'); + state.status = s; + const running = s.smartdns && s.smartdns.running; + setStatusBadge(running); + document.getElementById('stat-status').textContent = running ? 'Работает' : 'Остановлен'; + document.getElementById('stat-status').style.color = running ? 'var(--success)' : 'var(--danger)'; + document.getElementById('stat-pid').textContent = 'PID ' + (s.smartdns?.pid || '—'); + document.getElementById('stat-domains').textContent = s.domains ?? '—'; + document.getElementById('stat-url').textContent = s.url || '—'; + document.getElementById('topbar-sub').textContent = s.url ? s.url.replace('http://', '') + ' · lighttpd + CGI' : 'Keenetic Split DNS'; + if (s.web_port) document.getElementById('sidebar-port').textContent = s.web_port; + } + + async function refreshDomains() { + const d = await api('/domains'); + state.domains = d.domains || []; + renderDomainsTable(); + renderUpstreams(); + const ups = new Set(state.domains.map((x) => x.upstream)); + document.getElementById('stat-upstreams').textContent = ups.size; + document.getElementById('stat-upstream-names').textContent = [...ups].join(', ') || '—'; + } + + function renderDomainsTable(filter = '') { + const tbody = document.querySelector('#domains-table tbody'); + const q = filter.toLowerCase(); + tbody.innerHTML = state.domains + .filter((x) => !q || x.domain.includes(q)) + .map( + (x) => + `${esc(x.domain)}${esc(x.group)}${esc(x.upstream)}` + ) + .join(''); + } + + function renderUpstreams() { + const ups = [...new Set(state.domains.map((x) => x.upstream))]; + const defaultUp = 'isp-default'; + if (!ups.includes(defaultUp)) ups.push(defaultUp); + const list = document.getElementById('upstream-list'); + list.innerHTML = ups + .map( + (u) => + `
  • ${esc(u)}
  • ` + ) + .join(''); + list.querySelectorAll('li').forEach((li) => { + li.addEventListener('click', () => selectUpstream(li.dataset.up)); + }); + if (!selectedUpstream && ups.length) selectUpstream(ups[0]); + } + + function selectUpstream(id) { + selectedUpstream = id; + document.querySelectorAll('#upstream-list li').forEach((li) => { + li.classList.toggle('active', li.dataset.up === id); + }); + document.getElementById('upstream-detail-title').textContent = id + ' — домены'; + const rows = state.domains.filter((x) => x.upstream === id); + document.getElementById('upstream-domains-tbody').innerHTML = rows + .map((x) => `${esc(x.domain)}${esc(x.group)}`) + .join('') || 'Нет доменов (остальной трафик — default upstream)'; + document.getElementById('upstream-detail-body').innerHTML = + '

    Профили задаются в config.yaml → upstreams. Домены группы ru-services используют yandex-dot по умолчанию.

    '; + } + + async function loadConfigYaml() { + const res = await fetch(API + '/config', { headers: headers() }); + if (res.status === 401) { + document.getElementById('auth-gate').classList.remove('hidden'); + return; + } + state.yaml = await res.text(); + document.getElementById('config-yaml').value = state.yaml; + } + + async function applyConfig() { + await api('/reload', { method: 'POST' }); + toast('Конфиг применён, SmartDNS перезагружен'); + await refreshAll(); + } + + async function saveConfig() { + const yaml = document.getElementById('config-yaml').value; + await api('/config', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: yaml, + }); + toast('YAML сохранён — нажмите Применить'); + } + + async function runTest() { + const domain = document.getElementById('test-domain').value.trim() || 'vk.com'; + const type = document.getElementById('test-type').value; + const r = await api('/test?domain=' + encodeURIComponent(domain) + '&type=' + type); + const out = document.getElementById('test-output'); + if (r.output) { + out.textContent = r.output.replace(/\\n/g, '\n'); + document.getElementById('test-latency').textContent = (r.ms || '—') + ' ms'; + } else { + out.textContent = JSON.stringify(r, null, 2); + } + } + + function esc(s) { + return String(s) + .replace(/&/g, '&') + .replace(/ { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + document.querySelectorAll('.nav-item').forEach((b) => b.classList.remove('active')); + document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active')); + btn.classList.add('active'); + document.getElementById('panel-' + tab).classList.add('active'); + document.getElementById('page-title').textContent = TITLES[tab]; + if (tab === 'settings') loadConfigYaml(); + }); + }); + + document.getElementById('domain-search')?.addEventListener('input', (e) => renderDomainsTable(e.target.value)); + document.getElementById('theme-btn').addEventListener('click', () => { + const html = document.documentElement; + html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark'; + }); + + ['apply-top', 'apply-settings'].forEach((id) => { + document.getElementById(id)?.addEventListener('click', () => applyConfig().catch((e) => toast(e.message))); + }); + + document.getElementById('save-config')?.addEventListener('click', () => saveConfig().catch((e) => toast(e.message))); + document.getElementById('run-test')?.addEventListener('click', () => runTest().catch((e) => toast(e.message))); + document.querySelectorAll('.quick-test').forEach((b) => { + b.addEventListener('click', () => { + document.getElementById('test-domain').value = b.dataset.d; + runTest(); + }); + }); + + document.getElementById('auth-save')?.addEventListener('click', () => { + const v = document.getElementById('auth-token-input').value.trim(); + if (v) { + localStorage.setItem(TOKEN_KEY, v); + document.getElementById('auth-gate').classList.add('hidden'); + refreshAll(); + } + }); + + document.getElementById('save-token')?.addEventListener('click', () => { + const v = document.getElementById('settings-token').value.trim(); + if (v) { + localStorage.setItem(TOKEN_KEY, v); + toast('Токен сохранён'); + } + }); + + document.getElementById('btn-export-domains')?.addEventListener('click', () => { + const text = state.domains.map((x) => x.domain).join('\n'); + const a = document.createElement('a'); + a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(text); + a.download = 'domains.txt'; + a.click(); + }); + + // Init + if (token()) { + document.getElementById('auth-gate').classList.add('hidden'); + document.getElementById('settings-token').value = token(); + } + refreshAll().catch(() => {}); +})(); diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..34a5d04 --- /dev/null +++ b/www/index.html @@ -0,0 +1,202 @@ + + + + + + Keenetic Split DNS + + + +
    +
    +

    Токен API

    +

    Токен из /opt/etc/keenetic-split-dns/token (показан при установке)

    +
    + + +
    + +
    +
    + +
    + + +
    +
    +
    +

    Обзор

    +

    Загрузка…

    +
    +
    + SmartDNS + + +
    +
    + +
    +
    +
    +
    +
    Статус
    +
    +
    PID —
    +
    +
    +
    Домены🌐
    +
    +
    в списках split-DNS
    +
    +
    +
    Upstream🔗
    +
    +
    +
    +
    +
    Веб-UI🖥
    +
    +
    только LAN
    +
    +
    +
    +

    Журнал

    +
    Загрузка…
    +
    +
    + +
    +
    + +
    +
    +
    +

    Профили

    +
      +
      +
      +

      Выберите профиль

      +

      Слева — upstream, справа — домены группы с этой политикой.

      +
      +
      ДоменГруппа
      +
      +
      +
      +
      + +
      +
      + + +
      +
      +
      + + + +
      ДоменГруппаUpstream
      +
      +
      +
      + +
      +
      +

      Проверка DNS-запроса

      +
      +
      + +
      + + +
      +
      # Нажмите «Выполнить»
      +
      +
      +
      +
      Быстрые тесты
      +
      + + + +
      +
      +
      +
      Время ответа
      +
      +
      +
      +
      + +
      +
      +
      +

      Применить конфигурацию — YAML → SmartDNS + перезагрузка

      +
      + +
      +
      +
      +

      Токен API

      +
      + + +
      + +
      +
      +

      Конфиг YAML

      +
      + +
      + +
      +
      +
      +
      +
      +
      + +
      ✓ Готово
      + + + diff --git a/www/style.css b/www/style.css new file mode 100644 index 0000000..532805c --- /dev/null +++ b/www/style.css @@ -0,0 +1,337 @@ +:root { + --accent: #f5a623; + --accent-dim: rgba(245, 166, 35, 0.15); + --bg: #0f1114; + --bg-elevated: #181b20; + --bg-card: #1e2229; + --border: #2a3038; + --text: #e8eaed; + --text-muted: #8b939e; + --success: #3dd68c; + --success-bg: rgba(61, 214, 140, 0.12); + --danger: #f07178; + --danger-bg: rgba(240, 113, 120, 0.12); + --warning: #f5a623; + --warning-bg: rgba(245, 166, 35, 0.12); + --info: #6cb6ff; + --info-bg: rgba(108, 182, 255, 0.12); + --shadow: 0 4px 24px rgba(0,0,0,0.4); + --radius: 10px; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace; +} +[data-theme="light"] { + --bg: #f4f5f7; + --bg-elevated: #ffffff; + --bg-card: #ffffff; + --border: #dde1e6; + --text: #1a1d21; + --text-muted: #5c6570; + --shadow: 0 4px 20px rgba(0,0,0,0.08); + --success-bg: rgba(34, 160, 107, 0.1); + --danger-bg: rgba(220, 53, 69, 0.1); + --warning-bg: rgba(245, 166, 35, 0.12); + --info-bg: rgba(13, 110, 253, 0.08); +} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + min-height: 100vh; + line-height: 1.5; +} +.app { display: flex; min-height: 100vh; } +.sidebar { + width: 240px; + background: var(--bg-elevated); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; +} +.logo { + padding: 20px 18px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 10px; +} +.logo-icon { + width: 36px; height: 36px; + background: linear-gradient(135deg, var(--accent), #e07800); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} +.logo h1 { font-size: 15px; font-weight: 600; } +.logo span { font-size: 11px; color: var(--text-muted); display: block; } +.nav { padding: 12px 8px; flex: 1; } +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + color: var(--text-muted); + font-size: 14px; + border: none; + background: none; + width: 100%; + text-align: left; + transition: all 0.15s; +} +.nav-item:hover { background: var(--accent-dim); color: var(--text); } +.nav-item.active { background: var(--accent-dim); color: var(--accent); font-weight: 500; } +.nav-item svg { width: 18px; height: 18px; flex-shrink: 0; opacity: 0.85; } +.sidebar-footer { + padding: 14px 18px; + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--text-muted); +} +.sidebar-footer code { font-family: var(--mono); font-size: 10px; color: var(--accent); } +.main { flex: 1; display: flex; flex-direction: column; min-width: 0; } +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); + gap: 16px; +} +.topbar-left h2 { font-size: 18px; font-weight: 600; } +.topbar-left p { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.topbar-actions { display: flex; align-items: center; gap: 10px; } +.content { padding: 24px; flex: 1; overflow-y: auto; } +.panel { display: none; animation: fadeIn 0.2s ease; } +.panel.active { display: block; } +@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text); + transition: all 0.15s; +} +.btn:hover { border-color: var(--text-muted); } +.btn-primary { background: var(--accent); border-color: var(--accent); color: #1a1d21; } +.btn-primary:hover { filter: brightness(1.08); } +.btn-success { background: var(--success); border-color: var(--success); color: #0a1a12; } +.btn-ghost { background: transparent; } +.btn-sm { padding: 5px 10px; font-size: 12px; } +.btn-icon { padding: 8px; } +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.badge-running { background: var(--success-bg); color: var(--success); } +.badge-stopped { background: var(--danger-bg); color: var(--danger); } +.badge-dot::before { + content: ""; + width: 6px; height: 6px; + border-radius: 50%; + background: currentColor; +} +.badge-running.badge-dot::before { animation: pulse 2s infinite; } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } +.badge-policy { background: var(--info-bg); color: var(--info); font-size: 11px; text-transform: none; letter-spacing: 0; font-weight: 500; padding: 2px 8px; border-radius: 4px; } +.grid { display: grid; gap: 16px; } +.grid-4 { grid-template-columns: repeat(4, 1fr); } +.grid-3 { grid-template-columns: repeat(3, 1fr); } +.grid-2 { grid-template-columns: repeat(2, 1fr); } +@media (max-width: 1100px) { .grid-4 { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 700px) { .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; } .sidebar { display: none; } } +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + box-shadow: var(--shadow); +} +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.card-header h3 { font-size: 14px; font-weight: 600; } +.stat-value { font-size: 28px; font-weight: 700; line-height: 1.2; } +.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 4px; } +.stat-icon { + width: 40px; height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} +.table-wrap { overflow-x: auto; border-radius: var(--radius); border: 1px solid var(--border); } +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border); } +th { background: var(--bg-elevated); color: var(--text-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; } +tr:last-child td { border-bottom: none; } +tr:hover td { background: var(--accent-dim); } +td code { font-family: var(--mono); font-size: 12px; color: var(--info); } +.row-actions { display: flex; gap: 4px; } +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 16px; +} +.search-box { + flex: 1; + min-width: 200px; + position: relative; +} +.search-box input { + width: 100%; + padding: 8px 12px 8px 36px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-elevated); + color: var(--text); + font-size: 13px; +} +.search-box input:focus { outline: none; border-color: var(--accent); } +.search-box svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-muted); } +.form-group { margin-bottom: 14px; } +.form-group label { display: block; font-size: 12px; font-weight: 500; color: var(--text-muted); margin-bottom: 5px; } +.form-group input, .form-group select, .form-group textarea { + width: 100%; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-elevated); + color: var(--text); + font-size: 13px; + font-family: inherit; +} +.form-group textarea { min-height: 100px; font-family: var(--mono); font-size: 12px; resize: vertical; } +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; } +.test-result { + margin-top: 16px; + padding: 16px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + font-family: var(--mono); + font-size: 12px; + line-height: 1.7; + white-space: pre-wrap; +} +.test-result .ok { color: var(--success); } +.test-result .meta { color: var(--text-muted); } +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.55); + z-index: 100; + align-items: center; + justify-content: center; + padding: 20px; +} +.modal-overlay.open { display: flex; } +.modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow); +} +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + border-bottom: 1px solid var(--border); +} +.modal-header h3 { font-size: 16px; } +.modal-body { padding: 18px; } +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 18px; + border-top: 1px solid var(--border); +} +.toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 12px 18px; + background: var(--bg-card); + border: 1px solid var(--success); + border-radius: 8px; + font-size: 13px; + box-shadow: var(--shadow); + display: none; + z-index: 200; + animation: slideUp 0.3s ease; +} +.toast.show { display: block; } +@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } } +.apply-banner { + background: linear-gradient(90deg, var(--accent-dim), transparent); + border: 1px solid var(--accent); + border-radius: var(--radius); + padding: 16px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} +.apply-banner p { font-size: 13px; } +.apply-banner strong { color: var(--accent); } +.split-layout { display: grid; grid-template-columns: 1fr 320px; gap: 16px; } +.master-detail { display: grid; grid-template-columns: 280px 1fr; gap: 16px; min-height: 360px; } +.upstream-list { list-style: none; } +.upstream-list li { + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + margin-bottom: 4px; + border: 1px solid transparent; +} +.upstream-list li:hover { background: var(--accent-dim); } +.upstream-list li.active { border-color: var(--accent); background: var(--accent-dim); color: var(--accent); } +.log-line { font-family: var(--mono); font-size: 11px; color: var(--text-muted); padding: 4px 0; border-bottom: 1px solid var(--border); } +.log-line:last-child { border: none; } +.log-time { color: var(--accent); margin-right: 8px; } +.auth-gate { + position: fixed; + inset: 0; + background: var(--bg); + z-index: 500; + display: flex; + align-items: center; + justify-content: center; +} +.auth-gate.hidden { display: none; } +.auth-card { max-width: 400px; width: 100%; }