super-dns 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +79 -0
- package/README.md +99 -0
- package/index.js +513 -0
- package/package.json +24 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Super DNS is a local DNS proxy server for macOS that prevents DNS hijacking by routing specific domains through Alibaba Cloud's DoH (DNS over HTTPS). It automatically configures macOS's `/etc/resolver/` mechanism for per-domain DNS routing.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Start the DNS server
|
|
13
|
+
node index.js
|
|
14
|
+
# or
|
|
15
|
+
npm start
|
|
16
|
+
|
|
17
|
+
# Test DNS resolution (must specify server and port explicitly)
|
|
18
|
+
dig @127.0.0.1 -p 15353 perf.qzz.io A +short
|
|
19
|
+
dig @127.0.0.1 -p 15353 perf.qzz.io AAAA +short
|
|
20
|
+
|
|
21
|
+
# Verify macOS system resolver is configured
|
|
22
|
+
scutil --dns | grep -A 5 "qzz.io"
|
|
23
|
+
cat /etc/resolver/qzz.io
|
|
24
|
+
|
|
25
|
+
# Test with system resolver (works for ping, curl, browsers)
|
|
26
|
+
ping perf.qzz.io
|
|
27
|
+
curl -k https://perf.qzz.io:8443/
|
|
28
|
+
|
|
29
|
+
# Graceful shutdown (auto-cleans resolver files)
|
|
30
|
+
# Press Ctrl+C or: kill -TERM <PID>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Architecture
|
|
34
|
+
|
|
35
|
+
**Single-file design**: Everything is in `index.js` (~500 lines), organized into logical sections:
|
|
36
|
+
- Configuration and domain loading (lines 1-90)
|
|
37
|
+
- macOS resolver setup/cleanup using `osascript` (lines 90-150)
|
|
38
|
+
- DNS caching with TTL (lines 150-180)
|
|
39
|
+
- DoH querying to Alibaba Cloud (lines 180-210)
|
|
40
|
+
- Hand-rolled DNS packet parsing/building (lines 210-380)
|
|
41
|
+
- UDP server and request handling (lines 380-440)
|
|
42
|
+
- Graceful shutdown (lines 440-500)
|
|
43
|
+
|
|
44
|
+
**Key design decisions**:
|
|
45
|
+
- Zero dependencies - pure Node.js implementation
|
|
46
|
+
- Uses `osascript` for sudo operations (macOS GUI password prompt instead of terminal)
|
|
47
|
+
- Resolver setup runs in a child process via `fork()` to avoid blocking DNS server
|
|
48
|
+
- Resolver files are `chown`'d to current user during setup so cleanup doesn't require sudo
|
|
49
|
+
- Hand-rolled DNS protocol implementation (no external DNS library)
|
|
50
|
+
|
|
51
|
+
**Configuration file**: `~/.config/super-dns/domains`
|
|
52
|
+
- One domain per line, supports `#` comments
|
|
53
|
+
- Wildcard syntax: `*.qzz.io` matches `qzz.io` and all subdomains
|
|
54
|
+
- Exact match: `example.com` matches only that domain
|
|
55
|
+
|
|
56
|
+
**Environment variables**:
|
|
57
|
+
- `PORT` (default: 15353) - DNS server port
|
|
58
|
+
- `HOST` (default: 127.0.0.1) - bind address
|
|
59
|
+
- `DOH_BASE` (default: https://dns.alidns.com/resolve) - DoH endpoint
|
|
60
|
+
- `CACHE_TTL` (default: 300000) - cache duration in ms
|
|
61
|
+
|
|
62
|
+
## Important Notes
|
|
63
|
+
|
|
64
|
+
**macOS `/etc/resolver/` behavior**:
|
|
65
|
+
- Only affects system resolvers: browsers, ping, curl, Node.js, Python
|
|
66
|
+
- Does NOT affect: `dig`, `nslookup` (they read from network preferences directly)
|
|
67
|
+
- To test with `dig`, must explicitly specify: `dig @127.0.0.1 -p 15353 <domain>`
|
|
68
|
+
- To make `dig` use local DNS by default, create `~/.digrc` with `server 127.0.0.1\nport 15353`
|
|
69
|
+
|
|
70
|
+
**Port selection**: Default port is 15353 (not 5353) because 5353 is often occupied by mDNS services (Chrome, adb).
|
|
71
|
+
|
|
72
|
+
**Resolver cleanup**: On SIGTERM/SIGINT, the server deletes `/etc/resolver/` files. This uses `fs.unlinkSync()` first (files are owned by current user), falling back to `sudoExec()` if needed.
|
|
73
|
+
|
|
74
|
+
**SSL certificate trust**: If accessing HTTPS services with self-signed or mismatched certificates, trust the cert system-wide:
|
|
75
|
+
```bash
|
|
76
|
+
echo | openssl s_client -connect <IP>:<PORT> -servername <domain> 2>/dev/null | openssl x509 -outform PEM > /tmp/cert.pem
|
|
77
|
+
security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db /tmp/cert.pem
|
|
78
|
+
```
|
|
79
|
+
Then clear Chrome HSTS cache at `chrome://net-internals/#hsts`.
|
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Super DNS
|
|
2
|
+
|
|
3
|
+
本地 DNS 代理服务器,通过阿里云 DoH (DNS over HTTPS) 解析域名,防止 DNS 劫持。
|
|
4
|
+
|
|
5
|
+
## 特性
|
|
6
|
+
|
|
7
|
+
- 🛡️ 通过阿里云 DoH 解析,防 DNS 劫持
|
|
8
|
+
- 🎯 支持通配符配置(如 `*.qzz.io` 接管整个域)
|
|
9
|
+
- ⚡ 5 分钟本地缓存,减少重复查询
|
|
10
|
+
- 🍎 macOS 独立解析器自动配置/清理
|
|
11
|
+
- 📦 零依赖,纯 Node.js 实现
|
|
12
|
+
|
|
13
|
+
## 快速开始
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# 本地开发测试
|
|
17
|
+
npm start
|
|
18
|
+
# 或
|
|
19
|
+
node index.js
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
首次启动会自动创建配置文件 `~/.config/super-dns/domains`,并弹出密码框配置 macOS 独立解析器。
|
|
23
|
+
|
|
24
|
+
## 生产环境部署
|
|
25
|
+
|
|
26
|
+
使用 pm2 + npx 实现进程守护和自动重启:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# 1. 发布到 npm(首次)
|
|
30
|
+
npm publish
|
|
31
|
+
|
|
32
|
+
# 2. 用 pm2 启动
|
|
33
|
+
pm2 start npx --name super-dns -- super-dns
|
|
34
|
+
|
|
35
|
+
# 3. 保存进程列表
|
|
36
|
+
pm2 save
|
|
37
|
+
|
|
38
|
+
# 4. 设置开机自启(按提示执行输出的命令)
|
|
39
|
+
pm2 startup
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
或者全局安装后启动:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# 全局安装
|
|
46
|
+
npm install -g super-dns
|
|
47
|
+
|
|
48
|
+
# pm2 启动
|
|
49
|
+
pm2 start super-dns --name super-dns
|
|
50
|
+
pm2 save
|
|
51
|
+
pm2 startup
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**常用 pm2 命令:**
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pm2 logs super-dns # 查看日志
|
|
58
|
+
pm2 status # 查看状态
|
|
59
|
+
pm2 restart super-dns # 重启服务
|
|
60
|
+
pm2 stop super-dns # 停止服务
|
|
61
|
+
pm2 delete super-dns # 删除服务
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 配置域名
|
|
65
|
+
|
|
66
|
+
编辑 `~/.config/super-dns/domains`,每行一个域名:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
# 通配符:接管 qzz.io 及所有子域名
|
|
70
|
+
*.qzz.io
|
|
71
|
+
|
|
72
|
+
# 精确匹配:只接管这一个域名
|
|
73
|
+
example.com
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
支持 `#` 开头的注释行。
|
|
77
|
+
|
|
78
|
+
## 环境变量
|
|
79
|
+
|
|
80
|
+
| 变量 | 默认值 | 说明 |
|
|
81
|
+
|------|--------|------|
|
|
82
|
+
| `PORT` | `15353` | DNS 服务监听端口 |
|
|
83
|
+
| `HOST` | `127.0.0.1` | DNS 服务监听地址 |
|
|
84
|
+
| `DOH_BASE` | `https://dns.alidns.com/resolve` | DoH 服务地址 |
|
|
85
|
+
| `CACHE_TTL` | `300000` | 缓存时间(毫秒),默认 5 分钟 |
|
|
86
|
+
|
|
87
|
+
## 工作原理
|
|
88
|
+
|
|
89
|
+
1. 启动 UDP DNS 服务监听在 `127.0.0.1:15353`
|
|
90
|
+
2. 自动在 `/etc/resolver/` 下创建 macOS 独立解析器配置
|
|
91
|
+
3. 收到 DNS 请求后,检查域名是否在管理列表中
|
|
92
|
+
4. 命中的域名通过阿里云 DoH 查询真实 IP 并返回
|
|
93
|
+
5. 退出时自动清理 `/etc/resolver/` 配置
|
|
94
|
+
|
|
95
|
+
## 退出清理
|
|
96
|
+
|
|
97
|
+
- `Ctrl+C` (SIGINT) 或 `kill` (SIGTERM) 触发优雅退出
|
|
98
|
+
- 自动删除 `/etc/resolver/` 下的配置文件
|
|
99
|
+
- 可能需要输入一次密码(osascript 弹窗)
|
package/index.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const dgram = require('dgram');
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { execSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
// ============================================================
|
|
10
|
+
// 配置
|
|
11
|
+
// ============================================================
|
|
12
|
+
const PORT = parseInt(process.env.PORT || '15353', 10);
|
|
13
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
14
|
+
const DOH_BASE = process.env.DOH_BASE || 'https://dns.alidns.com/resolve';
|
|
15
|
+
const CACHE_TTL_MS = parseInt(process.env.CACHE_TTL || '300000', 10); // 5 min
|
|
16
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'super-dns');
|
|
17
|
+
const DOMAINS_FILE = path.join(CONFIG_DIR, 'domains');
|
|
18
|
+
|
|
19
|
+
// ============================================================
|
|
20
|
+
// 域名列表加载
|
|
21
|
+
// ============================================================
|
|
22
|
+
function loadDomains(file) {
|
|
23
|
+
// 自动创建配置文件
|
|
24
|
+
if (!fs.existsSync(file)) {
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
26
|
+
fs.writeFileSync(file, '# Super DNS 域名配置\n# 每行一个域名,支持通配符\n# 例: *.qzz.io 表示接管 qzz.io 及其所有子域名\n# 例: aaa.qzz.io 表示只接管这一个域名\n\n*.qzz.io\n', 'utf-8');
|
|
27
|
+
console.log(`[*] 已自动创建配置文件: ${file}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const lines = fs.readFileSync(file, 'utf-8')
|
|
31
|
+
.split('\n')
|
|
32
|
+
.map(l => l.trim().toLowerCase())
|
|
33
|
+
.filter(l => l && !l.startsWith('#'));
|
|
34
|
+
|
|
35
|
+
if (lines.length === 0) {
|
|
36
|
+
console.error(`[!] 配置文件为空: ${file}`);
|
|
37
|
+
console.error('[!] 请添加至少一个域名,例如:');
|
|
38
|
+
console.error(' *.qzz.io');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 解析域名规则
|
|
43
|
+
const rules = lines.map(line => {
|
|
44
|
+
if (line.startsWith('*.')) {
|
|
45
|
+
// 通配符: *.qzz.io → 匹配 qzz.io 及所有子域
|
|
46
|
+
const base = line.slice(2);
|
|
47
|
+
return { type: 'wildcard', base, raw: line };
|
|
48
|
+
}
|
|
49
|
+
return { type: 'exact', domain: line, raw: line };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
console.log(`[*] 已加载 ${rules.length} 条规则:`);
|
|
53
|
+
rules.forEach(r => console.log(` - ${r.raw}`));
|
|
54
|
+
return rules;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const domainRules = loadDomains(DOMAINS_FILE);
|
|
58
|
+
|
|
59
|
+
// 从规则中提取需要配置 resolver 的域名
|
|
60
|
+
function getResolverDomains() {
|
|
61
|
+
const domains = new Set();
|
|
62
|
+
for (const rule of domainRules) {
|
|
63
|
+
if (rule.type === 'wildcard') {
|
|
64
|
+
domains.add(rule.base); // *.qzz.io → resolver 文件用 qzz.io
|
|
65
|
+
} else {
|
|
66
|
+
// 精确域名,提取二级域作为 resolver 文件名
|
|
67
|
+
// 比如 aaa.qzz.io → resolver 文件用 qzz.io
|
|
68
|
+
const parts = rule.domain.split('.');
|
|
69
|
+
if (parts.length >= 2) {
|
|
70
|
+
domains.add(parts.slice(-2).join('.'));
|
|
71
|
+
} else {
|
|
72
|
+
domains.add(rule.domain);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [...domains];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================
|
|
80
|
+
// macOS Resolver 自动配置
|
|
81
|
+
// ============================================================
|
|
82
|
+
const RESOLVER_DIR = '/etc/resolver';
|
|
83
|
+
let resolverConfigured = false;
|
|
84
|
+
|
|
85
|
+
function sudoExec(command) {
|
|
86
|
+
// 直接用 osascript 弹窗执行(管理员权限),避免 sudo 需要终端的问题
|
|
87
|
+
const escaped = command.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
88
|
+
execSync(
|
|
89
|
+
`osascript -e 'do shell script "${escaped}" with administrator privileges'`,
|
|
90
|
+
{ stdio: 'pipe', timeout: 120000 }
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============================================================
|
|
95
|
+
// 缓存
|
|
96
|
+
// ============================================================
|
|
97
|
+
const cache = new Map();
|
|
98
|
+
|
|
99
|
+
function cacheKey(name, type) {
|
|
100
|
+
return `${name}|${type}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cacheGet(name, type) {
|
|
104
|
+
const key = cacheKey(name, type);
|
|
105
|
+
const entry = cache.get(key);
|
|
106
|
+
if (!entry) return null;
|
|
107
|
+
if (Date.now() - entry.ts > CACHE_TTL_MS) {
|
|
108
|
+
cache.delete(key);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return entry.data;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cacheSet(name, type, data) {
|
|
115
|
+
cache.set(cacheKey(name, type), { data, ts: Date.now() });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================================
|
|
119
|
+
// DoH 查询 (阿里云公共 DNS)
|
|
120
|
+
// ============================================================
|
|
121
|
+
function dohQuery(name, type) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const qtype = type === 28 ? 'AAAA' : 'A';
|
|
124
|
+
const url = `${DOH_BASE}?name=${encodeURIComponent(name)}&type=${qtype}`;
|
|
125
|
+
const req = https.get(url, { timeout: 3000 }, (res) => {
|
|
126
|
+
let body = '';
|
|
127
|
+
res.on('data', chunk => body += chunk);
|
|
128
|
+
res.on('end', () => {
|
|
129
|
+
try {
|
|
130
|
+
const json = JSON.parse(body);
|
|
131
|
+
const answers = (json.Answer || []).filter(a => a.type === (type === 28 ? 28 : 1));
|
|
132
|
+
const ips = answers.map(a => a.data);
|
|
133
|
+
resolve(ips);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
reject(new Error(`DoH JSON 解析失败: ${e.message}`));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
req.on('error', reject);
|
|
140
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('DoH 超时')); });
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================
|
|
145
|
+
// DNS 报文解析
|
|
146
|
+
// ============================================================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 从 DNS 报文的 offset 处读取一个域名(支持压缩指针)
|
|
150
|
+
*/
|
|
151
|
+
function readName(buf, offset) {
|
|
152
|
+
const parts = [];
|
|
153
|
+
let jumped = false;
|
|
154
|
+
let nextOffset = offset;
|
|
155
|
+
let safety = 0;
|
|
156
|
+
|
|
157
|
+
while (safety++ < 64) {
|
|
158
|
+
if (offset >= buf.length) break;
|
|
159
|
+
const len = buf[offset];
|
|
160
|
+
|
|
161
|
+
if (len === 0) {
|
|
162
|
+
if (!jumped) nextOffset = offset + 1;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if ((len & 0xc0) === 0xc0) {
|
|
167
|
+
if (offset + 1 >= buf.length) break;
|
|
168
|
+
const ptr = ((len & 0x3f) << 8) | buf[offset + 1];
|
|
169
|
+
if (!jumped) nextOffset = offset + 2;
|
|
170
|
+
jumped = true;
|
|
171
|
+
offset = ptr;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
offset += 1;
|
|
176
|
+
if (offset + len > buf.length) break;
|
|
177
|
+
parts.push(buf.slice(offset, offset + len).toString('ascii'));
|
|
178
|
+
offset += len;
|
|
179
|
+
if (!jumped) nextOffset = offset;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { name: parts.join('.'), nextOffset };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseRequest(buf) {
|
|
186
|
+
if (buf.length < 12) return null;
|
|
187
|
+
|
|
188
|
+
const id = buf.readUInt16BE(0);
|
|
189
|
+
const flags = buf.readUInt16BE(2);
|
|
190
|
+
const qdCount = buf.readUInt16BE(4);
|
|
191
|
+
|
|
192
|
+
if (qdCount < 1) return null;
|
|
193
|
+
|
|
194
|
+
const { name, nextOffset } = readName(buf, 12);
|
|
195
|
+
if (nextOffset + 4 > buf.length) return null;
|
|
196
|
+
|
|
197
|
+
const qtype = buf.readUInt16BE(nextOffset);
|
|
198
|
+
const qclass = buf.readUInt16BE(nextOffset + 2);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
id,
|
|
202
|
+
flags,
|
|
203
|
+
name: name.toLowerCase(),
|
|
204
|
+
qtype,
|
|
205
|
+
qclass,
|
|
206
|
+
questionRaw: buf.slice(12, nextOffset + 4)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ============================================================
|
|
211
|
+
// DNS 报文构造
|
|
212
|
+
// ============================================================
|
|
213
|
+
|
|
214
|
+
function encodeName(name) {
|
|
215
|
+
const labels = name.split('.');
|
|
216
|
+
const bufs = labels.map(label => {
|
|
217
|
+
const b = Buffer.from(label, 'ascii');
|
|
218
|
+
return Buffer.concat([Buffer.from([b.length]), b]);
|
|
219
|
+
});
|
|
220
|
+
bufs.push(Buffer.from([0]));
|
|
221
|
+
return Buffer.concat(bufs);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function uint16(v) {
|
|
225
|
+
const b = Buffer.alloc(2);
|
|
226
|
+
b.writeUInt16BE(v, 0);
|
|
227
|
+
return b;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function uint32(v) {
|
|
231
|
+
const b = Buffer.alloc(4);
|
|
232
|
+
b.writeUInt32BE(v, 0);
|
|
233
|
+
return b;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function ipv6ToBuffer(ip) {
|
|
237
|
+
try {
|
|
238
|
+
const expanded = expandIPv6(ip);
|
|
239
|
+
const parts = expanded.split(':').map(p => parseInt(p, 16));
|
|
240
|
+
if (parts.length !== 8) return null;
|
|
241
|
+
const buf = Buffer.alloc(16);
|
|
242
|
+
parts.forEach((p, i) => buf.writeUInt16BE(p, i * 2));
|
|
243
|
+
return buf;
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function expandIPv6(ip) {
|
|
250
|
+
if (ip.includes('::')) {
|
|
251
|
+
const [left, right] = ip.split('::');
|
|
252
|
+
const l = left ? left.split(':') : [];
|
|
253
|
+
const r = right ? right.split(':') : [];
|
|
254
|
+
const fill = Array(8 - l.length - r.length).fill('0');
|
|
255
|
+
return [...l, ...fill, ...r].join(':');
|
|
256
|
+
}
|
|
257
|
+
return ip;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildResponse(req, ips) {
|
|
261
|
+
const id = Buffer.alloc(2);
|
|
262
|
+
id.writeUInt16BE(req.id, 0);
|
|
263
|
+
|
|
264
|
+
const flags = Buffer.alloc(2);
|
|
265
|
+
flags.writeUInt16BE(0x8180, 0);
|
|
266
|
+
|
|
267
|
+
const qdCount = Buffer.from([0, 1, 0, ips.length, 0, 0, 0, 0]);
|
|
268
|
+
const question = req.questionRaw;
|
|
269
|
+
const answers = [];
|
|
270
|
+
const encodedName = encodeName(req.name);
|
|
271
|
+
|
|
272
|
+
for (const ip of ips) {
|
|
273
|
+
if (req.qtype === 1) {
|
|
274
|
+
const parts = ip.split('.').map(Number);
|
|
275
|
+
if (parts.length !== 4 || parts.some(p => isNaN(p))) continue;
|
|
276
|
+
answers.push(Buffer.concat([
|
|
277
|
+
encodedName, uint16(1), uint16(1), uint32(300), uint16(4), Buffer.from(parts)
|
|
278
|
+
]));
|
|
279
|
+
} else if (req.qtype === 28) {
|
|
280
|
+
const rdata = ipv6ToBuffer(ip);
|
|
281
|
+
if (!rdata) continue;
|
|
282
|
+
answers.push(Buffer.concat([
|
|
283
|
+
encodedName, uint16(28), uint16(1), uint32(300), uint16(16), rdata
|
|
284
|
+
]));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (answers.length === 0) {
|
|
289
|
+
qdCount[2] = 0; qdCount[3] = 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return Buffer.concat([id, flags, qdCount, question, ...answers]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildNxDomain(req) {
|
|
296
|
+
const id = Buffer.alloc(2);
|
|
297
|
+
id.writeUInt16BE(req.id, 0);
|
|
298
|
+
const flags = Buffer.alloc(2);
|
|
299
|
+
flags.writeUInt16BE(0x8183, 0);
|
|
300
|
+
const counts = Buffer.from([0, 1, 0, 0, 0, 0, 0, 0]);
|
|
301
|
+
return Buffer.concat([id, flags, counts, req.questionRaw]);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ============================================================
|
|
305
|
+
// 域名匹配(支持通配符)
|
|
306
|
+
// ============================================================
|
|
307
|
+
function isManaged(name) {
|
|
308
|
+
const clean = name.endsWith('.') ? name.slice(0, -1) : name;
|
|
309
|
+
for (const rule of domainRules) {
|
|
310
|
+
if (rule.type === 'wildcard') {
|
|
311
|
+
// *.qzz.io → 匹配 qzz.io 本身及其所有子域
|
|
312
|
+
if (clean === rule.base || clean.endsWith('.' + rule.base)) return true;
|
|
313
|
+
} else {
|
|
314
|
+
// 精确匹配
|
|
315
|
+
if (clean === rule.domain) return true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ============================================================
|
|
322
|
+
// 主服务
|
|
323
|
+
// ============================================================
|
|
324
|
+
const server = dgram.createSocket('udp4');
|
|
325
|
+
|
|
326
|
+
server.on('message', async (msg, rinfo) => {
|
|
327
|
+
const req = parseRequest(msg);
|
|
328
|
+
if (!req) {
|
|
329
|
+
console.log(`[!] 无法解析来自 ${rinfo.address}:${rinfo.port} 的请求`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const cleanName = req.name.endsWith('.') ? req.name.slice(0, -1) : req.name;
|
|
334
|
+
const typeStr = req.qtype === 1 ? 'A' : req.qtype === 28 ? 'AAAA' : `TYPE${req.qtype}`;
|
|
335
|
+
console.log(`[>] ${cleanName} ${typeStr} from ${rinfo.address}:${rinfo.port}`);
|
|
336
|
+
|
|
337
|
+
if (!isManaged(req.name)) {
|
|
338
|
+
console.log(`[x] 不在管理列表中,返回 NXDOMAIN`);
|
|
339
|
+
server.send(buildNxDomain(req), rinfo.port, rinfo.address);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (req.qtype !== 1 && req.qtype !== 28) {
|
|
344
|
+
console.log(`[x] 不支持的查询类型 ${typeStr},返回空回答`);
|
|
345
|
+
server.send(buildResponse(req, []), rinfo.port, rinfo.address);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const cached = cacheGet(cleanName, req.qtype);
|
|
350
|
+
if (cached) {
|
|
351
|
+
console.log(`[c] 缓存命中: ${cached.join(', ')}`);
|
|
352
|
+
server.send(buildResponse(req, cached), rinfo.port, rinfo.address);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const ips = await dohQuery(cleanName, req.qtype);
|
|
358
|
+
if (ips.length > 0) {
|
|
359
|
+
cacheSet(cleanName, req.qtype, ips);
|
|
360
|
+
console.log(`[✓] 解析成功: ${ips.join(', ')}`);
|
|
361
|
+
} else {
|
|
362
|
+
console.log(`[!] DoH 无记录`);
|
|
363
|
+
}
|
|
364
|
+
server.send(buildResponse(req, ips), rinfo.port, rinfo.address);
|
|
365
|
+
} catch (e) {
|
|
366
|
+
console.error(`[!] DoH 查询失败: ${e.message}`);
|
|
367
|
+
const stale = cache.get(cacheKey(cleanName, req.qtype));
|
|
368
|
+
if (stale) {
|
|
369
|
+
console.log(`[!] 使用过期缓存: ${stale.data.join(', ')}`);
|
|
370
|
+
server.send(buildResponse(req, stale.data), rinfo.port, rinfo.address);
|
|
371
|
+
} else {
|
|
372
|
+
server.send(buildResponse(req, []), rinfo.port, rinfo.address);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
server.on('error', (err) => {
|
|
378
|
+
console.error(`[!] 服务器错误: ${err.message}`);
|
|
379
|
+
process.exit(1);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const resolverDomains = getResolverDomains();
|
|
383
|
+
|
|
384
|
+
server.on('listening', () => {
|
|
385
|
+
const addr = server.address();
|
|
386
|
+
console.log('');
|
|
387
|
+
console.log('╔══════════════════════════════════════════════╗');
|
|
388
|
+
console.log('║ Super DNS 本地代理服务 ║');
|
|
389
|
+
console.log('╚══════════════════════════════════════════════╝');
|
|
390
|
+
console.log(`[*] 监听: ${addr.address}:${addr.port} (UDP)`);
|
|
391
|
+
console.log(`[*] DoH: ${DOH_BASE}`);
|
|
392
|
+
console.log(`[*] 缓存: ${CACHE_TTL_MS / 1000}s`);
|
|
393
|
+
console.log(`[*] 配置: ${DOMAINS_FILE}`);
|
|
394
|
+
console.log('');
|
|
395
|
+
console.log('[*] 正在配置 macOS 独立解析器(可能需要输入密码)...');
|
|
396
|
+
|
|
397
|
+
// 用子进程配置 macOS resolver,避免阻塞 DNS 服务
|
|
398
|
+
// osascript 弹密码框期间 DNS 仍可正常响应
|
|
399
|
+
// 关键:chown 把 resolver 文件归属当前用户,退出时 rm 不需要 sudo
|
|
400
|
+
const currentUser = os.userInfo().username;
|
|
401
|
+
const setupScript = `
|
|
402
|
+
const fs = require('fs');
|
|
403
|
+
const { execSync } = require('child_process');
|
|
404
|
+
const resolverDomains = ${JSON.stringify(resolverDomains)};
|
|
405
|
+
const host = '${addr.address}';
|
|
406
|
+
const port = ${addr.port};
|
|
407
|
+
const RESOLVER_DIR = '${RESOLVER_DIR}';
|
|
408
|
+
const pid = ${process.pid};
|
|
409
|
+
const currentUser = '${currentUser}';
|
|
410
|
+
|
|
411
|
+
function sudoExec(cmd) {
|
|
412
|
+
const escaped = cmd.replace(/\\\\/g, '\\\\\\\\').replace(/"/g, '\\\\"');
|
|
413
|
+
execSync(
|
|
414
|
+
\`osascript -e 'do shell script "\${escaped}" with administrator privileges'\`,
|
|
415
|
+
{ stdio: 'pipe', timeout: 120000 }
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const commands = ['mkdir -p ' + RESOLVER_DIR];
|
|
421
|
+
for (const domain of resolverDomains) {
|
|
422
|
+
const tmpFile = '/tmp/super-dns-' + domain + '-' + pid;
|
|
423
|
+
const resolverFile = RESOLVER_DIR + '/' + domain;
|
|
424
|
+
fs.writeFileSync(tmpFile, '# Auto-generated by super-dns (PID ' + pid + ')\\nnameserver ' + host + '\\nport ' + port + '\\n');
|
|
425
|
+
commands.push('mv ' + tmpFile + ' ' + resolverFile);
|
|
426
|
+
commands.push('chmod 644 ' + resolverFile);
|
|
427
|
+
commands.push('chown ' + currentUser + ' ' + resolverFile);
|
|
428
|
+
}
|
|
429
|
+
sudoExec(commands.join(' && '));
|
|
430
|
+
for (const domain of resolverDomains) {
|
|
431
|
+
process.send({ type: 'setup-ok', domain });
|
|
432
|
+
}
|
|
433
|
+
process.send({ type: 'setup-done' });
|
|
434
|
+
} catch (e) {
|
|
435
|
+
process.send({ type: 'setup-fail', error: e.message });
|
|
436
|
+
}
|
|
437
|
+
`;
|
|
438
|
+
|
|
439
|
+
const setupChild = require('child_process').fork(`-e`, [setupScript], {
|
|
440
|
+
silent: true,
|
|
441
|
+
detached: false
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
setupChild.on('message', (msg) => {
|
|
445
|
+
if (msg.type === 'setup-ok') {
|
|
446
|
+
console.log(` ✓ ${RESOLVER_DIR}/${msg.domain} → ${addr.address}:${addr.port}`);
|
|
447
|
+
} else if (msg.type === 'setup-done') {
|
|
448
|
+
resolverConfigured = true;
|
|
449
|
+
console.log('[*] macOS 解析器配置完成');
|
|
450
|
+
console.log('');
|
|
451
|
+
} else if (msg.type === 'setup-fail') {
|
|
452
|
+
console.error(`[!] 配置 macOS 解析器失败: ${msg.error}`);
|
|
453
|
+
console.error('[!] 请手动执行:');
|
|
454
|
+
for (const domain of resolverDomains) {
|
|
455
|
+
console.error(` sudo mkdir -p ${RESOLVER_DIR}`);
|
|
456
|
+
console.error(` echo "nameserver ${addr.address}\\nport ${addr.port}" | sudo tee ${RESOLVER_DIR}/${domain}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
setupChild.on('exit', (code) => {
|
|
462
|
+
if (code !== 0 && code !== null) {
|
|
463
|
+
console.error(`[!] resolver 配置子进程异常退出 (code=${code})`);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// ============================================================
|
|
469
|
+
// 优雅退出
|
|
470
|
+
// ============================================================
|
|
471
|
+
let shuttingDown = false;
|
|
472
|
+
|
|
473
|
+
function shutdown(signal) {
|
|
474
|
+
if (shuttingDown) return;
|
|
475
|
+
shuttingDown = true;
|
|
476
|
+
|
|
477
|
+
console.log(`\n[*] 收到 ${signal},正在关闭...`);
|
|
478
|
+
|
|
479
|
+
// 关闭 DNS 服务器
|
|
480
|
+
server.close(() => {
|
|
481
|
+
console.log('[*] DNS 服务已关闭');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
if (resolverConfigured) {
|
|
485
|
+
console.log('[*] 正在清理 macOS 独立解析器...');
|
|
486
|
+
for (const domain of resolverDomains) {
|
|
487
|
+
const resolverFile = `${RESOLVER_DIR}/${domain}`;
|
|
488
|
+
try {
|
|
489
|
+
// 文件已 chown 为当前用户,直接 rm 即可,无需 sudo
|
|
490
|
+
fs.unlinkSync(resolverFile);
|
|
491
|
+
console.log(` ✓ 已删除 ${resolverFile}`);
|
|
492
|
+
} catch (e) {
|
|
493
|
+
// 如果直接删不了(比如权限被改回去),用 osascript
|
|
494
|
+
try {
|
|
495
|
+
sudoExec(`rm -f ${resolverFile}`);
|
|
496
|
+
console.log(` ✓ 已删除 ${resolverFile} (sudo)`);
|
|
497
|
+
} catch (e2) {
|
|
498
|
+
console.error(` ✗ 删除 ${resolverFile} 失败: ${e.message}`);
|
|
499
|
+
console.error(` 请手动执行: sudo rm ${resolverFile}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
console.log('[*] 已关闭');
|
|
506
|
+
process.exit(0);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
510
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
511
|
+
|
|
512
|
+
// 启动
|
|
513
|
+
server.bind(PORT, HOST);
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "super-dns",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "真实 dns,防劫持用的",
|
|
5
|
+
"homepage": "https://github.com/jayli/super-dns#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/jayli/super-dns/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/jayli/super-dns.git"
|
|
12
|
+
},
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"author": "",
|
|
15
|
+
"type": "commonjs",
|
|
16
|
+
"main": "index.js",
|
|
17
|
+
"bin": {
|
|
18
|
+
"super-dns": "index.js"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node index.js",
|
|
22
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
23
|
+
}
|
|
24
|
+
}
|