diff --git a/cgi-bin/api.cgi b/cgi-bin/api.cgi index 50d743d..8c43498 100755 --- a/cgi-bin/api.cgi +++ b/cgi-bin/api.cgi @@ -2,5 +2,6 @@ # Keenetic-Split-DNS REST API (CGI) export KSD_ETC="${KSD_ETC:-/opt/etc/keenetic-split-dns}" -. /opt/share/keenetic-split-dns/scripts/api.sh +export KSD_SHARE="${KSD_SHARE:-/opt/share/keenetic-split-dns}" +. "${KSD_SHARE}/scripts/api.sh" api_route diff --git a/etc/lighttpd/lighttpd.conf b/etc/lighttpd/lighttpd.conf index 53e93af..1272353 100644 --- a/etc/lighttpd/lighttpd.conf +++ b/etc/lighttpd/lighttpd.conf @@ -1,6 +1,11 @@ # Keenetic-Split-DNS web UI — lighttpd # Installed to /opt/etc/keenetic-split-dns/lighttpd.conf +server.modules += ( + "mod_cgi", + "mod_rewrite" +) + server.document-root = "/opt/share/keenetic-split-dns/www" server.port = 3200 server.bind = "LAN_IP_PLACEHOLDER" diff --git a/etc/smartdns/smartdns.conf.head b/etc/smartdns/smartdns.conf.head index 516c9c8..e39bc15 100644 --- a/etc/smartdns/smartdns.conf.head +++ b/etc/smartdns/smartdns.conf.head @@ -4,7 +4,7 @@ server-name keenetic-split-dns log-level info cache-size 512 -cache-persist yes +# cache-persist set by compile-config.sh prefetch-domain yes serve-expired yes dnsmasq-lease-file /var/lib/misc/dnsmasq.leases diff --git a/install.sh b/install.sh index b764d19..bddcd3a 100755 --- a/install.sh +++ b/install.sh @@ -58,8 +58,9 @@ mkdir -p "$KSD_ETC" "$KSD_SHARE" "$KSD_VAR_LOG" "$KSD_VAR_RUN" \ "${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" +if [ ! -f "${KSD_ETC}/config.yaml" ]; then + cp "$SRC_DIR/etc/config.yaml.example" "${KSD_ETC}/config.yaml" +fi cp -rf "$SRC_DIR/scripts/"* "${KSD_SHARE}/scripts/" cp -rf "$SRC_DIR/www/"* "${KSD_SHARE}/www/" @@ -79,9 +80,12 @@ chmod +x "${KSD_SHARE}/scripts/"*.sh "${KSD_SHARE}/cgi-bin/api.cgi" 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" + if sed -i "s/lan_ip: \"192.168.1.1\"/lan_ip: \"${LAN_IP}\"/" "${KSD_ETC}/config.yaml" 2>/dev/null; then + : + else + 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 fi # --- API token --- @@ -107,8 +111,12 @@ 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 +# --- domain sets to etc (seed defaults only) --- +for _ds in "${KSD_SHARE}/domain-sets/"*.txt; do + [ -f "$_ds" ] || continue + _base="$(basename "$_ds")" + [ -f "${KSD_ETC}/domain-sets/${_base}" ] || cp -f "$_ds" "${KSD_ETC}/domain-sets/${_base}" +done # --- compile & start --- export KSD_ETC KSD_SHARE diff --git a/scripts/api.sh b/scripts/api.sh index c4b9ad5..d90e21f 100755 --- a/scripts/api.sh +++ b/scripts/api.sh @@ -2,6 +2,7 @@ # CGI API helpers for Keenetic-Split-DNS KSD_ETC="${KSD_ETC:-/opt/etc/keenetic-split-dns}" +KSD_SHARE="${KSD_SHARE:-/opt/share/keenetic-split-dns}" CONFIG="${KSD_ETC}/config.yaml" TOKEN_FILE="${KSD_ETC}/token" LOG_FILE="${KSD_ETC}/apply.log" @@ -81,7 +82,23 @@ api_status() { 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}\"}" + log_json="[]" + if [ -f "$LOG_FILE" ]; then + _tmp="$(mktemp /tmp/ksd-logs.XXXXXX)" + tail -5 "$LOG_FILE" 2>/dev/null > "$_tmp" + log_json="[" + first=1 + while IFS= read -r line; do + esc="$(printf '%s' "$line" | json_escape)" + [ "$first" -eq 1 ] || log_json="${log_json}," + first=0 + log_json="${log_json}\"${esc}\"" + done < "$_tmp" + log_json="${log_json}]" + rm -f "$_tmp" + fi + + api_send_json "200" "{\"ok\":true,\"smartdns\":{\"running\":${running},\"pid\":\"${pid}\"},\"domains\":${domains},\"lan_ip\":\"${lan}\",\"web_port\":${port},\"url\":\"http://${lan}:${port}\",\"last_apply\":\"${last_apply}\",\"logs\":${log_json}}" } api_get_config_raw() { @@ -118,8 +135,12 @@ api_save_config() { } api_reload() { - SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" - if "$SCRIPT_DIR/apply.sh" >>"$LOG_FILE" 2>&1; then + APPLY="${KSD_SHARE}/scripts/apply.sh" + if [ ! -x "$APPLY" ]; then + api_send_json "500" '{"ok":false,"error":"apply.sh not found"}' + exit 0 + fi + if "$APPLY" >>"$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"}' @@ -131,7 +152,8 @@ api_reload() { 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")" + # URL-decode (percent-encoding) + domain="$(printf '%s' "$domain" | sed 's/+/ /g; s/%\([0-9A-Fa-f][0-9A-Fa-f]\)/\\x\1/g' | xargs printf '%b' 2>/dev/null || printf '%s' "$domain")" [ -n "$domain" ] || domain="vk.com" rtype="A" case "$QUERY_STRING" in @@ -161,14 +183,21 @@ api_domains_list() { 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)" + upstream="$(awk -v g="$group" ' + $0 ~ "^ " g ":$" { in_g=1; next } + in_g && /^[^ #]/ { exit } + in_g && $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}" + de="$(printf '%s' "$d" | json_escape)" + ge="$(printf '%s' "$group" | json_escape)" + ue="$(printf '%s' "${upstream:-yandex-dot}" | json_escape)" + printf '{"domain":"%s","group":"%s","upstream":"%s"}' "$de" "$ge" "$ue" done < "$f" done fi diff --git a/scripts/capture-readme-screenshots.mjs b/scripts/capture-readme-screenshots.mjs new file mode 100644 index 0000000..9f0f206 --- /dev/null +++ b/scripts/capture-readme-screenshots.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/** + * Capture README screenshots from www/ with mocked API (no router required). + * Usage: node scripts/capture-readme-screenshots.mjs + */ +import { chromium } from 'playwright'; +import { createServer } from 'http'; +import { readFileSync, mkdirSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const www = path.join(repo, 'www'); +const outDir = path.join(repo, 'docs', 'images'); + +const domains = `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` + .trim() + .split('\n') + .map((d) => ({ domain: d, group: 'ru-services', upstream: 'yandex-dot' })); + +const status = { + ok: true, + smartdns: { running: true, pid: '1842' }, + domains: domains.length, + lan_ip: '192.168.1.1', + web_port: 3200, + url: 'http://192.168.1.1:3200', + last_apply: '2026-05-24 12:04:11 reload OK', + logs: [ + '2026-05-24 12:01:02 SmartDNS запущен, listen 0.0.0.0:53', + '2026-05-24 12:01:03 Политика ru-services → yandex-dot (19 доменов)', + '2026-05-24 12:04:11 lookup vk.com A → 87.240.190.78', + ], +}; + +const digOutput = `; <<>> DiG 9.18.24 <<>> @127.0.0.1 vk.com A +;; global options: +cmd +;; Got answer: +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 48291 +;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0 + +;; QUESTION SECTION: +;vk.com. IN A + +;; ANSWER SECTION: +vk.com. 300 IN A 87.240.190.78 + +;; Query time: 24 msec +;; SERVER: 127.0.0.1#53(127.0.0.1) +;; WHEN: Sun May 24 12:04:11 MSK 2026 +;; MSG SIZE rcvd: 52`; + +const mime = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', +}; + +function createStaticServer() { + const yaml = readFileSync(path.join(repo, 'etc', 'config.yaml.example'), 'utf8'); + return createServer((req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + if (url.pathname.startsWith('/api/')) { + const route = url.pathname.slice(4); + if (route === '/status') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(status)); + return; + } + if (route === '/domains') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true, domains })); + return; + } + if (route.startsWith('/test')) { + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + ok: true, + domain: url.searchParams.get('domain') || 'vk.com', + type: 'A', + ms: 24, + output: digOutput, + }) + ); + return; + } + if (route === '/config') { + res.setHeader('Content-Type', 'application/x-yaml'); + res.end(yaml); + return; + } + if (route === '/reload') { + res.setHeader('Content-Type', 'application/json'); + res.end('{"ok":true,"message":"applied"}'); + return; + } + res.statusCode = 404; + res.end('{}'); + return; + } + const rel = url.pathname === '/' ? '/index.html' : url.pathname; + const file = path.join(www, rel); + try { + const data = readFileSync(file); + res.setHeader('Content-Type', mime[path.extname(file)] || 'application/octet-stream'); + res.end(data); + } catch { + res.statusCode = 404; + res.end('not found'); + } + }); +} + +async function waitDashboard(page) { + await page.waitForFunction( + () => document.getElementById('stat-domains')?.textContent !== '—', + { timeout: 10000 } + ); + await page.evaluate(() => { + const log = document.getElementById('log-container'); + if (!log) return; + log.innerHTML = ` +
12:01:02 SmartDNS запущен, listen 0.0.0.0:53
+
12:01:03 Политика ru-services → yandex-dot (19 доменов)
+
12:04:11 lookup vk.com A → 87.240.190.78
+ `; + }); +} + +async function main() { + mkdirSync(outDir, { recursive: true }); + const server = createStaticServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + const base = `http://127.0.0.1:${port}/`; + + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1360, height: 900 } }); + + await page.goto(base); + await page.evaluate(() => localStorage.setItem('ksd_token', 'readme-demo')); + await page.reload(); + await waitDashboard(page); + await page.screenshot({ path: path.join(outDir, 'overview.png') }); + + await page.click('.nav-item[data-tab="upstreams"]'); + await page.waitForTimeout(400); + await page.screenshot({ path: path.join(outDir, 'upstreams-domains.png') }); + + await page.click('.nav-item[data-tab="test"]'); + await page.click('#run-test'); + await page.waitForFunction( + () => (document.getElementById('test-output')?.textContent || '').includes('vk.com'), + { timeout: 5000 } + ); + await page.waitForTimeout(200); + await page.screenshot({ path: path.join(outDir, 'test-lookup.png') }); + + await browser.close(); + server.close(); + console.log('Saved screenshots to', outDir); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/compile-config.sh b/scripts/compile-config.sh index ee2a991..9554c4a 100755 --- a/scripts/compile-config.sh +++ b/scripts/compile-config.sh @@ -86,7 +86,14 @@ LOG_Q="$(yaml_val log_queries)" # --- upstream servers --- list_upstream_ids() { - awk '/^upstreams:$/,/^[^ ]/ { if ($0 ~ /^ [a-zA-Z0-9_-]+:$/) { gsub(/:$/,"",$1); print substr($1,3) } }' "$CONFIG" | head -20 + awk ' + /^upstreams:$/ { in_s=1; next } + in_s && /^[^ #]/ { exit } + in_s && /^ [a-zA-Z0-9_-]+:$/ { + gsub(/:$/, "", $1) + print $1 + } + ' "$CONFIG" } for uid in $(list_upstream_ids); do @@ -119,7 +126,14 @@ echo "" >> "$OUT_SMART" # --- domain groups --- list_group_ids() { - awk '/^domain_groups:$/,/^[^ ]/ { if ($0 ~ /^ [a-zA-Z0-9_-]+:$/) { gsub(/:$/,"",$1); print substr($1,3) } }' "$CONFIG" + awk ' + /^domain_groups:$/ { in_s=1; next } + in_s && /^[^ #]/ { exit } + in_s && /^ [a-zA-Z0-9_-]+:$/ { + gsub(/:$/, "", $1) + print $1 + } + ' "$CONFIG" } for gid in $(list_group_ids); do diff --git a/scripts/detect-lan.sh b/scripts/detect-lan.sh index 9961ae8..1f829e5 100755 --- a/scripts/detect-lan.sh +++ b/scripts/detect-lan.sh @@ -24,7 +24,7 @@ detect_lan_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 "'\")" + 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)" diff --git a/www/app.js b/www/app.js index f4a1b10..3b614a6 100644 --- a/www/app.js +++ b/www/app.js @@ -66,6 +66,23 @@ 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; + const logEl = document.getElementById('log-container'); + if (logEl) { + const lines = Array.isArray(s.logs) ? s.logs : []; + if (lines.length) { + logEl.innerHTML = lines + .map((msg) => { + const m = String(msg); + const time = m.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/); + return `${esc(time ? time[1] : '—')} ${esc(time ? m.slice(time[1].length).trim() : m)}`; + }) + .join(''); + } else if (s.last_apply) { + logEl.innerHTML = ` ${esc(s.last_apply)}`; + } else { + logEl.innerHTML = '
Журнал пуст — нажмите «Применить»'; + } + } } async function refreshDomains() { @@ -221,6 +238,11 @@ } }); + document.getElementById('btn-add-upstream')?.addEventListener('click', () => { + toast('Профили upstream задаются в config.yaml → upstreams'); + document.querySelector('.nav-item[data-tab="settings"]')?.click(); + }); + document.getElementById('btn-export-domains')?.addEventListener('click', () => { const text = state.domains.map((x) => x.domain).join('\n'); const a = document.createElement('a');