Initial release: install, uninstall, SmartDNS, web UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-24 22:15:37 +03:00
commit bc9355fe40
21 changed files with 2715 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -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.

128
README.md Normal file
View File

@@ -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://<LAN_IP>: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 <token>` или `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)

6
cgi-bin/api.cgi Executable file
View File

@@ -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

View File

@@ -0,0 +1,901 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DNS Split — Keenetic</title>
<style>
: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); }
.theme-toggle { cursor: pointer; }
.split-layout { display: grid; grid-template-columns: 1fr 320px; gap: 16px; }
@media (max-width: 900px) { .split-layout { grid-template-columns: 1fr; } }
.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; }
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="logo">
<div class="logo-icon"></div>
<div>
<h1>DNS Split</h1>
<span>SmartDNS · Keenetic</span>
</div>
</div>
<nav class="nav">
<button class="nav-item active" data-tab="dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
Обзор
</button>
<button class="nav-item" data-tab="domains">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Домены
</button>
<button class="nav-item" data-tab="upstreams">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
Upstream
</button>
<button class="nav-item" data-tab="test">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Проверка
</button>
<button class="nav-item" data-tab="settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
Настройки
</button>
</nav>
<div class="sidebar-footer">
<div>Конфиг: <code>/opt/etc/dns-split/</code></div>
<div style="margin-top:6px">Порт: <code>3200</code></div>
</div>
</aside>
<div class="main">
<header class="topbar">
<div class="topbar-left">
<h2 id="page-title">Обзор</h2>
<p>192.168.1.1:3200 · lighttpd + CGI</p>
</div>
<div class="topbar-actions">
<span class="badge badge-running badge-dot">SmartDNS работает</span>
<button class="btn btn-icon theme-toggle" id="theme-btn" title="Тема">🌓</button>
<button class="btn btn-success" id="apply-top">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c1.66 0 3-4 3-9s-1.34-9-3-9m0 18c-1.66 0-3-4-3-9s1.34-9 3-9"/></svg>
Применить
</button>
</div>
</header>
<div class="content">
<!-- DASHBOARD -->
<section class="panel active" id="panel-dashboard">
<div class="apply-banner">
<div>
<p><strong>3 изменения</strong> не применены — конфиг изменён 24.05.2026 14:32</p>
<p style="font-size:12px;color:var(--text-muted);margin-top:4px">Последняя перезагрузка SmartDNS: 24.05.2026 12:15:03</p>
</div>
<button class="btn btn-primary" onclick="showToast('Конфиг применён, SmartDNS перезагружен')">Применить и перезагрузить</button>
</div>
<div class="grid grid-4" style="margin-bottom:20px">
<div class="card">
<div class="card-header">
<span class="stat-label">Статус</span>
<span class="stat-icon" style="background:var(--success-bg)"></span>
</div>
<div class="stat-value" style="font-size:18px;color:var(--success)">Работает</div>
<div class="stat-label">PID 1842 · uptime 2д 4ч</div>
</div>
<div class="card">
<div class="card-header">
<span class="stat-label">Домены</span>
<span class="stat-icon" style="background:var(--info-bg)">🌐</span>
</div>
<div class="stat-value">19</div>
<div class="stat-label">2 группы политик</div>
</div>
<div class="card">
<div class="card-header">
<span class="stat-label">Запросы / сутки</span>
<span class="stat-icon" style="background:var(--accent-dim)">📊</span>
</div>
<div class="stat-value">12.4k</div>
<div class="stat-label">кэш hit 78%</div>
</div>
<div class="card">
<div class="card-header">
<span class="stat-label">Upstream</span>
<span class="stat-icon" style="background:var(--warning-bg)">🔗</span>
</div>
<div class="stat-value">2</div>
<div class="stat-label">yandex-dot, isp-default</div>
</div>
</div>
<div class="grid grid-2">
<div class="card">
<div class="card-header"><h3>Группы доменов</h3></div>
<div class="table-wrap">
<table>
<thead><tr><th>Группа</th><th>Политика</th><th>Доменов</th></tr></thead>
<tbody>
<tr><td><strong>yandex-ecosystem</strong></td><td><span class="badge-policy">yandex-dot</span></td><td>6</td></tr>
<tr><td><strong>vk-mail-ok</strong></td><td><span class="badge-policy">yandex-dot</span></td><td>13</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Журнал</h3></div>
<div class="log-line"><span class="log-time">14:32:01</span> config.yaml изменён (domains +1)</div>
<div class="log-line"><span class="log-time">12:15:03</span> smartdns reload OK</div>
<div class="log-line"><span class="log-time">12:14:58</span> apply: 19 domains, 2 upstreams</div>
<div class="log-line"><span class="log-time">10:02:11</span> lookup test vk.com → 87.240.x.x (yandex-dot)</div>
<div class="log-line"><span class="log-time">09:41:00</span> lighttpd started :3200</div>
</div>
</div>
</section>
<!-- DOMAINS -->
<section class="panel" id="panel-domains">
<div class="toolbar">
<div class="search-box">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="search" placeholder="Поиск домена…" id="domain-search">
</div>
<button class="btn btn-primary" onclick="openModal('modal-domain')">+ Добавить домен</button>
<button class="btn" onclick="openModal('modal-import')">Импорт</button>
<button class="btn btn-ghost">Экспорт YAML</button>
</div>
<div class="split-layout">
<div class="card" style="padding:0;overflow:hidden">
<div class="table-wrap" style="border:none;border-radius:0">
<table id="domains-table">
<thead>
<tr>
<th><input type="checkbox"></th>
<th>Домен</th>
<th>Группа</th>
<th>Политика</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Массовый импорт</h3></div>
<div class="form-group">
<label>Один домен на строку</label>
<textarea readonly style="min-height:200px">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</textarea>
</div>
<button class="btn btn-primary" style="width:100%">Импортировать в группу</button>
<p class="hint" style="margin-top:10px">Дубликаты пропускаются. Политика наследуется от группы.</p>
</div>
</div>
</section>
<!-- UPSTREAMS -->
<section class="panel" id="panel-upstreams">
<div class="toolbar">
<button class="btn btn-primary" onclick="openModal('modal-upstream')">+ Профиль upstream</button>
</div>
<div class="card" style="padding:0;overflow:hidden">
<div class="table-wrap" style="border:none">
<table>
<thead>
<tr>
<th>ID</th>
<th>Тип</th>
<th>Адрес</th>
<th>SNI / Host</th>
<th>Таймаут</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>yandex-dot</code></td>
<td>DoT</td>
<td><code>77.88.8.8:853</code></td>
<td><code>common.dot.dns.yandex.net</code></td>
<td>5 с</td>
<td class="row-actions">
<button class="btn btn-sm btn-ghost" onclick="openModal('modal-upstream')"></button>
</td>
</tr>
<tr>
<td><code>isp-default</code></td>
<td>UDP</td>
<td><code>192.168.1.1:53</code></td>
<td></td>
<td>3 с</td>
<td class="row-actions">
<button class="btn btn-sm btn-ghost" onclick="openModal('modal-upstream')"></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-header"><h3>yandex-dot — детали</h3></div>
<div class="form-row">
<div class="form-group">
<label>Протокол</label>
<input value="DNS over TLS (DoT)" readonly>
</div>
<div class="form-group">
<label>Bootstrap</label>
<input value="77.88.8.8" readonly>
</div>
</div>
<div class="form-group">
<label>SNI (Server Name Indication)</label>
<input value="common.dot.dns.yandex.net" readonly>
</div>
<p class="hint">Используется для доменов Yandex, VK, Mail.ru, OK в split-DNS политике.</p>
</div>
</section>
<!-- TEST -->
<section class="panel" id="panel-test">
<div class="card">
<div class="card-header"><h3>Проверка DNS-запроса</h3></div>
<div class="toolbar" style="margin-bottom:0">
<div class="form-group" style="flex:1;margin:0">
<input type="text" id="test-domain" value="vk.com" placeholder="example.com">
</div>
<select id="test-type" style="padding:9px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-elevated);color:var(--text)">
<option>A</option>
<option>AAAA</option>
<option>HTTPS</option>
</select>
<button class="btn btn-primary" id="run-test">Выполнить</button>
</div>
<div class="test-result" id="test-output" style="display:none"></div>
<div class="test-result" id="test-placeholder">
<span class="meta"># Нажмите «Выполнить» для тестового запроса через SmartDNS
# Маршрутизация определяется политикой split-DNS</span>
</div>
</div>
<div class="grid grid-3" style="margin-top:16px">
<div class="card">
<div class="stat-label">Быстрые тесты</div>
<div style="margin-top:10px;display:flex;flex-wrap:wrap;gap:6px">
<button class="btn btn-sm" onclick="quickTest('yandex.ru')">yandex.ru</button>
<button class="btn btn-sm" onclick="quickTest('vk.com')">vk.com</button>
<button class="btn btn-sm" onclick="quickTest('google.com')">google.com</button>
</div>
</div>
<div class="card">
<div class="stat-label">Ожидаемый upstream</div>
<div style="margin-top:8px;font-size:14px"><code>yandex-dot</code> для vk.com</div>
</div>
<div class="card">
<div class="stat-label">Время ответа</div>
<div class="stat-value" style="font-size:20px" id="test-latency"></div>
</div>
</div>
</section>
<!-- SETTINGS -->
<section class="panel" id="panel-settings">
<div class="apply-banner">
<div>
<p><strong>Применить конфигурацию</strong> — записать YAML и перезагрузить SmartDNS</p>
<p style="font-size:12px;color:var(--text-muted);margin-top:4px">Без применения изменения действуют только в редакторе</p>
</div>
<button class="btn btn-primary btn-lg" style="padding:12px 24px;font-size:14px" onclick="showToast('Конфиг применён, SmartDNS перезагружен')">
⚡ Применить конфигурацию
</button>
</div>
<div class="grid grid-2">
<div class="card">
<div class="card-header"><h3>Сеть и доступ</h3></div>
<div class="form-group">
<label>Привязка (listen)</label>
<input value="192.168.1.1:3200" readonly>
</div>
<div class="form-group">
<label>LAN-интерфейс</label>
<select><option>br0 (Home)</option><option>br1 (Guest)</option></select>
</div>
<div class="form-group">
<label>Токен API / CGI</label>
<input type="password" value="••••••••••••••••">
<p class="hint">Заголовок X-DNS-Split-Token для CGI-запросов</p>
</div>
</div>
<div class="card">
<div class="card-header"><h3>SmartDNS</h3></div>
<div class="form-group">
<label>Путь конфигурации</label>
<input value="/opt/etc/dns-split/config.yaml" readonly>
</div>
<div class="form-group">
<label>Кэш (TTL max)</label>
<input type="number" value="3600">
</div>
<div class="form-group">
<label>
<input type="checkbox" checked> Логировать запросы в syslog
</label>
</div>
<button class="btn" style="margin-top:8px">Перезапустить SmartDNS</button>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-header"><h3>Резервная копия</h3></div>
<div class="toolbar" style="margin:0">
<button class="btn">Скачать config.yaml</button>
<button class="btn">Восстановить из файла</button>
<button class="btn btn-ghost" style="color:var(--danger)">Сбросить к умолчанию</button>
</div>
</div>
</section>
</div>
</div>
</div>
<!-- Modal: Domain -->
<div class="modal-overlay" id="modal-domain">
<div class="modal">
<div class="modal-header">
<h3>Добавить домен</h3>
<button class="btn btn-icon btn-ghost" onclick="closeModal('modal-domain')"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Домен</label>
<input placeholder="example.ru">
</div>
<div class="form-group">
<label>Группа</label>
<select>
<option>yandex-ecosystem</option>
<option>vk-mail-ok</option>
<option selected>vk-mail-ok</option>
</select>
</div>
<div class="form-group">
<label>Политика (upstream)</label>
<select>
<option>yandex-dot</option>
<option>isp-default</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modal-domain')">Отмена</button>
<button class="btn btn-primary" onclick="closeModal('modal-domain');showToast('Домен добавлен (не применён)')">Сохранить</button>
</div>
</div>
</div>
<!-- Modal: Import -->
<div class="modal-overlay" id="modal-import">
<div class="modal">
<div class="modal-header">
<h3>Импорт доменов</h3>
<button class="btn btn-icon btn-ghost" onclick="closeModal('modal-import')"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Группа назначения</label>
<select><option>vk-mail-ok</option><option>yandex-ecosystem</option></select>
</div>
<div class="form-group">
<label>Список доменов</label>
<textarea placeholder="domain1.ru&#10;domain2.com"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modal-import')">Отмена</button>
<button class="btn btn-primary" onclick="closeModal('modal-import');showToast('Импортировано 5 доменов')">Импорт</button>
</div>
</div>
</div>
<!-- Modal: Upstream -->
<div class="modal-overlay" id="modal-upstream">
<div class="modal">
<div class="modal-header">
<h3>Профиль upstream</h3>
<button class="btn btn-icon btn-ghost" onclick="closeModal('modal-upstream')"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>ID профиля</label>
<input value="yandex-dot">
</div>
<div class="form-row">
<div class="form-group">
<label>Тип</label>
<select><option selected>DoT</option><option>DoH</option><option>UDP</option></select>
</div>
<div class="form-group">
<label>Порт</label>
<input value="853">
</div>
</div>
<div class="form-group">
<label>Адрес</label>
<input value="77.88.8.8">
</div>
<div class="form-group">
<label>SNI</label>
<input value="common.dot.dns.yandex.net">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('modal-upstream')">Отмена</button>
<button class="btn btn-primary" onclick="closeModal('modal-upstream');showToast('Профиль сохранён')">Сохранить</button>
</div>
</div>
</div>
<div class="toast" id="toast">✓ Готово</div>
<script>
const DOMAINS = [
{ d: 'yandex.ru', g: 'yandex-ecosystem', p: 'yandex-dot' },
{ d: 'ya.ru', g: 'yandex-ecosystem', p: 'yandex-dot' },
{ d: 'yandex.com', g: 'yandex-ecosystem', p: 'yandex-dot' },
{ d: 'yandex.net', g: 'yandex-ecosystem', p: 'yandex-dot' },
{ d: 'yastatic.net', g: 'yandex-ecosystem', p: 'yandex-dot' },
{ d: 'yandex.st', g: 'yandex-ecosystem', p: 'yandex-dot' },
{ d: 'mail.ru', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'mail.com', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'imgsmail.ru', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'mycdn.me', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'vk.com', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'vk.me', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'vkuservideo.net', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'vkuseraudio.net', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'userapi.com', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'vk-cdn.net', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'ok.ru', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'odnoklassniki.ru', g: 'vk-mail-ok', p: 'yandex-dot' },
{ d: 'okcdn.ru', g: 'vk-mail-ok', p: 'yandex-dot' },
];
const TITLES = {
dashboard: 'Обзор',
domains: 'Домены',
upstreams: 'Upstream-профили',
test: 'Проверка DNS',
settings: 'Настройки',
};
function renderDomains(filter = '') {
const tbody = document.querySelector('#domains-table tbody');
const q = filter.toLowerCase();
tbody.innerHTML = DOMAINS
.filter(x => !q || x.d.includes(q))
.map(x => `<tr>
<td><input type="checkbox"></td>
<td><code>${x.d}</code></td>
<td>${x.g}</td>
<td><span class="badge-policy">${x.p}</span></td>
<td class="row-actions">
<button class="btn btn-sm btn-ghost">✎</button>
<button class="btn btn-sm btn-ghost">🗑</button>
</td>
</tr>`).join('');
}
document.querySelectorAll('.nav-item').forEach(btn => {
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];
});
});
document.getElementById('domain-search')?.addEventListener('input', e => renderDomains(e.target.value));
document.getElementById('theme-btn').addEventListener('click', () => {
const html = document.documentElement;
html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark';
});
document.getElementById('apply-top').addEventListener('click', () => showToast('Конфиг применён, SmartDNS перезагружен'));
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
document.querySelectorAll('.modal-overlay').forEach(el => {
el.addEventListener('click', e => { if (e.target === el) el.classList.remove('open'); });
});
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = '✓ ' + msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2800);
}
const TEST_DATA = {
'vk.com': { ip: '87.240.190.78', upstream: 'yandex-dot', ms: 24 },
'yandex.ru': { ip: '5.255.255.5', upstream: 'yandex-dot', ms: 18 },
'google.com': { ip: '142.250.185.78', upstream: 'isp-default', ms: 31 },
};
function runTest() {
const domain = document.getElementById('test-domain').value.trim() || 'vk.com';
const type = document.getElementById('test-type').value;
const data = TEST_DATA[domain] || { ip: '93.186.225.10', upstream: 'yandex-dot', ms: 42 };
document.getElementById('test-placeholder').style.display = 'none';
const out = document.getElementById('test-output');
out.style.display = 'block';
out.innerHTML = `<span class="ok">;; STATUS: NOERROR</span>
<span class="meta">;; QUERY: ${domain} IN ${type}</span>
<span class="meta">;; UPSTREAM: ${data.upstream} (DoT 77.88.8.8)</span>
<span class="meta">;; POLICY: split-dns / group matched</span>
${domain}. 300 IN ${type} ${data.ip}
<span class="ok">;; Query time: ${data.ms} msec</span>
<span class="meta">;; SERVER: 127.0.0.1#6053 (SmartDNS)</span>`;
document.getElementById('test-latency').textContent = data.ms + ' ms';
}
function quickTest(d) {
document.getElementById('test-domain').value = d;
runTest();
}
document.getElementById('run-test').addEventListener('click', runTest);
renderDomains();
</script>
</body>
</html>

41
etc/config.yaml.example Normal file
View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

17
init.d/S97ksd-compile Executable file
View File

@@ -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

41
init.d/S98smartdns Executable file
View File

@@ -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

39
init.d/S99ksd-web Executable file
View File

@@ -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

153
install.sh Executable file
View File

@@ -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

208
scripts/api.sh Executable file
View File

@@ -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
}

28
scripts/apply.sh Executable file
View File

@@ -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

148
scripts/compile-config.sh Executable file
View File

@@ -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

42
scripts/detect-lan.sh Executable file
View File

@@ -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

71
uninstall.sh Executable file
View File

@@ -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

238
www/app.js Normal file
View File

@@ -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) =>
`<tr><td><code>${esc(x.domain)}</code></td><td>${esc(x.group)}</td><td><span class="badge-policy">${esc(x.upstream)}</span></td></tr>`
)
.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) =>
`<li data-up="${esc(u)}" class="${u === selectedUpstream ? 'active' : ''}"><code>${esc(u)}</code></li>`
)
.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) => `<tr><td><code>${esc(x.domain)}</code></td><td>${esc(x.group)}</td></tr>`)
.join('') || '<tr><td colspan="2" class="hint">Нет доменов (остальной трафик — default upstream)</td></tr>';
document.getElementById('upstream-detail-body').innerHTML =
'<p class="hint">Профили задаются в config.yaml → upstreams. Домены группы ru-services используют yandex-dot по умолчанию.</p>';
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;');
}
async function refreshAll() {
await refreshStatus();
await refreshDomains();
}
// Nav
document.querySelectorAll('.nav-item').forEach((btn) => {
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(() => {});
})();

202
www/index.html Normal file
View File

@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keenetic Split DNS</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="auth-gate" class="auth-gate">
<div class="card auth-card">
<div class="card-header"><h3>Токен API</h3></div>
<p class="hint" style="margin-bottom:12px">Токен из <code>/opt/etc/keenetic-split-dns/token</code> (показан при установке)</p>
<div class="form-group">
<label>Bearer token</label>
<input type="password" id="auth-token-input" placeholder="вставьте токен">
</div>
<button class="btn btn-primary" id="auth-save" style="width:100%">Войти</button>
</div>
</div>
<div class="app">
<aside class="sidebar">
<div class="logo">
<div class="logo-icon"></div>
<div>
<h1>Split DNS</h1>
<span>SmartDNS · Keenetic</span>
</div>
</div>
<nav class="nav">
<button class="nav-item active" data-tab="dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
Обзор
</button>
<button class="nav-item" data-tab="upstreams">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4"/></svg>
Upstream
</button>
<button class="nav-item" data-tab="domains">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/></svg>
Домены
</button>
<button class="nav-item" data-tab="test">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/></svg>
Проверка
</button>
<button class="nav-item" data-tab="settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>
Настройки
</button>
</nav>
<div class="sidebar-footer">
<div>Конфиг: <code>/opt/etc/keenetic-split-dns/</code></div>
<div style="margin-top:6px">Порт: <code id="sidebar-port">3200</code></div>
</div>
</aside>
<div class="main">
<header class="topbar">
<div class="topbar-left">
<h2 id="page-title">Обзор</h2>
<p id="topbar-sub">Загрузка…</p>
</div>
<div class="topbar-actions">
<span class="badge badge-running badge-dot" id="status-badge">SmartDNS</span>
<button class="btn btn-icon" id="theme-btn" title="Тема">🌓</button>
<button class="btn btn-success" id="apply-top">Применить</button>
</div>
</header>
<div class="content">
<section class="panel active" id="panel-dashboard">
<div class="grid grid-4" style="margin-bottom:20px">
<div class="card">
<div class="card-header"><span class="stat-label">Статус</span><span class="stat-icon" style="background:var(--success-bg)"></span></div>
<div class="stat-value" id="stat-status" style="font-size:18px"></div>
<div class="stat-label" id="stat-pid">PID —</div>
</div>
<div class="card">
<div class="card-header"><span class="stat-label">Домены</span><span class="stat-icon" style="background:var(--info-bg)">🌐</span></div>
<div class="stat-value" id="stat-domains"></div>
<div class="stat-label">в списках split-DNS</div>
</div>
<div class="card">
<div class="card-header"><span class="stat-label">Upstream</span><span class="stat-icon" style="background:var(--warning-bg)">🔗</span></div>
<div class="stat-value" id="stat-upstreams"></div>
<div class="stat-label" id="stat-upstream-names"></div>
</div>
<div class="card">
<div class="card-header"><span class="stat-label">Веб-UI</span><span class="stat-icon" style="background:var(--accent-dim)">🖥</span></div>
<div class="stat-value" style="font-size:16px" id="stat-url"></div>
<div class="stat-label">только LAN</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Журнал</h3></div>
<div id="log-container"><div class="log-line"><span class="log-time"></span> Загрузка…</div></div>
</div>
</section>
<section class="panel" id="panel-upstreams">
<div class="toolbar">
<button class="btn btn-primary" id="btn-add-upstream">+ Профиль upstream</button>
</div>
<div class="master-detail">
<div class="card">
<div class="card-header"><h3>Профили</h3></div>
<ul class="upstream-list" id="upstream-list"></ul>
</div>
<div class="card" id="upstream-detail">
<div class="card-header"><h3 id="upstream-detail-title">Выберите профиль</h3></div>
<div id="upstream-detail-body"><p class="hint">Слева — upstream, справа — домены группы с этой политикой.</p></div>
<div class="table-wrap" style="margin-top:16px" id="upstream-domains-wrap">
<table><thead><tr><th>Домен</th><th>Группа</th></tr></thead><tbody id="upstream-domains-tbody"></tbody></table>
</div>
</div>
</div>
</section>
<section class="panel" id="panel-domains">
<div class="toolbar">
<div class="search-box">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="search" placeholder="Поиск домена…" id="domain-search">
</div>
<button class="btn" id="btn-export-domains">Экспорт списка</button>
</div>
<div class="card" style="padding:0;overflow:hidden">
<div class="table-wrap" style="border:none">
<table id="domains-table">
<thead><tr><th>Домен</th><th>Группа</th><th>Upstream</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
<section class="panel" id="panel-test">
<div class="card">
<div class="card-header"><h3>Проверка DNS-запроса</h3></div>
<div class="toolbar" style="margin-bottom:0">
<div class="form-group" style="flex:1;margin:0">
<input type="text" id="test-domain" value="vk.com">
</div>
<select id="test-type">
<option>A</option>
<option>AAAA</option>
</select>
<button class="btn btn-primary" id="run-test">Выполнить</button>
</div>
<pre class="test-result" id="test-output"># Нажмите «Выполнить»</pre>
</div>
<div class="grid grid-3" style="margin-top:16px">
<div class="card">
<div class="stat-label">Быстрые тесты</div>
<div style="margin-top:10px;display:flex;flex-wrap:wrap;gap:6px">
<button class="btn btn-sm quick-test" data-d="yandex.ru">yandex.ru</button>
<button class="btn btn-sm quick-test" data-d="vk.com">vk.com</button>
<button class="btn btn-sm quick-test" data-d="google.com">google.com</button>
</div>
</div>
<div class="card">
<div class="stat-label">Время ответа</div>
<div class="stat-value" style="font-size:20px" id="test-latency"></div>
</div>
</div>
</section>
<section class="panel" id="panel-settings">
<div class="apply-banner">
<div>
<p><strong>Применить конфигурацию</strong> — YAML → SmartDNS + перезагрузка</p>
</div>
<button class="btn btn-primary" id="apply-settings">⚡ Применить</button>
</div>
<div class="grid grid-2">
<div class="card">
<div class="card-header"><h3>Токен API</h3></div>
<div class="form-group">
<label>Текущий токен (localStorage)</label>
<input type="password" id="settings-token">
</div>
<button class="btn" id="save-token">Сохранить токен</button>
</div>
<div class="card">
<div class="card-header"><h3>Конфиг YAML</h3></div>
<div class="form-group">
<textarea id="config-yaml" style="min-height:240px"></textarea>
</div>
<button class="btn btn-primary" id="save-config">Сохранить YAML</button>
</div>
</div>
</section>
</div>
</div>
</div>
<div class="toast" id="toast">✓ Готово</div>
<script src="app.js"></script>
</body>
</html>

337
www/style.css Normal file
View File

@@ -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%; }