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

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