fix: stop killing NM's dnsmasq - use dnsmasq-shared.d instead

Killing all dnsmasq processes caused NetworkManager to detect its
hotspot dnsmasq died and tear down the hotspot (AP appears briefly
then disappears).

Now leverage NM's built-in dnsmasq-shared.d config directory:
write DNS hijack config before starting AP so NM's own dnsmasq
picks it up. No separate dnsmasq process needed.

Made-with: Cursor
This commit is contained in:
stswangzhiping
2026-03-16 12:40:19 +08:00
parent f58db93b64
commit 9d8af52bf4
3 changed files with 43 additions and 92 deletions

View File

@@ -1,112 +1,61 @@
'use strict';
const { spawn, execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const fs = require('fs');
const path = require('path');
const log = require('./logger');
const { Watchdog } = require('./watchdog');
const log = require('./logger');
const CONFIG_DIR = process.env.CLAWD_CONFIG_DIR
|| (process.getuid && process.getuid() === 0 ? '/etc/clawd' : path.join(os.homedir(), '.clawd'));
const DNSMASQ_CONF = path.join(CONFIG_DIR, 'dnsmasq-captive.conf');
const CAPTIVE_DOMAIN = 'ap.cutos.ai';
// NetworkManager 的 dnsmasq 共享配置目录
// NM 启动热点时会自动加载此目录下的 .conf 文件
const NM_DNSMASQ_DIR = '/etc/NetworkManager/dnsmasq-shared.d';
const CAPTIVE_CONF = path.join(NM_DNSMASQ_DIR, 'clawd-captive.conf');
/**
* 管理 dnsmasq 进程:
* - 所有 DNS 查询 → 网关 IP触发 Captive Portal 弹窗)
* - DHCP 分配 10.42.0.50 ~ 10.42.0.150
* - ap.cutos.ai 专门指向网关
* 通过 NetworkManager 的 dnsmasq 配置实现 DNS 劫持。
*
* NM 在创建热点时自动启动 dnsmasq 并加载 dnsmasq-shared.d/ 下的配置。
* 我们只需在启动热点前写入 address=/#/gatewayIpNM 的 dnsmasq 就会
* 把所有域名解析到网关 IP触发 Captive Portal 检测。
*
* 不再自行管理 dnsmasq 进程,避免与 NM 冲突导致热点被拆除。
*/
class DnsHijack {
constructor() {
this._watchdog = null;
this._active = false;
}
/**
* 启动 dnsmasq
* @param {string} iface - AP 接口名(如 wlan0
* 写入 DNS 劫持配置(需在 startAP 之前调用)
* @param {string} _iface - 接口名(保留参数,兼容调用
* @param {string} gatewayIp - 网关 IP如 10.42.0.1
*/
start(iface, gatewayIp) {
start(_iface, gatewayIp) {
this.stop();
const rangeStart = gatewayIp.replace(/\.\d+$/, '.50');
const rangeEnd = gatewayIp.replace(/\.\d+$/, '.150');
const conf = [
`interface=${iface}`,
'bind-interfaces',
`listen-address=${gatewayIp}`,
'',
'# DHCP',
`dhcp-range=${rangeStart},${rangeEnd},255.255.255.0,12h`,
`dhcp-option=3,${gatewayIp}`, // gateway
`dhcp-option=6,${gatewayIp}`, // DNS server
'',
'# DNS: 所有域名指向网关(触发 Captive Portal 检测)',
`# clawd captive portal DNS hijack`,
`# All DNS queries resolve to gateway to trigger captive portal`,
`address=/#/${gatewayIp}`,
'',
'# 日志',
'log-queries',
'log-facility=-', // stdout → Watchdog 采集
'',
'# 禁用系统 resolv.conf',
'no-resolv',
'no-poll',
].join('\n');
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(DNSMASQ_CONF, conf, 'utf8');
log.info('dns', `dnsmasq 配置已写入: ${DNSMASQ_CONF}`);
// 终止所有 dnsmasq包括 NetworkManager 自动启动的)
try { execSync('pkill -9 dnsmasq', { timeout: 3000 }); } catch (_) {}
// 等待端口释放
try { execSync('sleep 1'); } catch (_) {}
// 查找 dnsmasq 二进制(/usr/sbin 可能不在普通用户 PATH 中)
const dnsmasqBin = findBin('dnsmasq');
if (!dnsmasqBin) {
log.error('dns', 'dnsmasq 未安装,请运行: apt install dnsmasq');
return;
try {
fs.mkdirSync(NM_DNSMASQ_DIR, { recursive: true });
fs.writeFileSync(CAPTIVE_CONF, conf, 'utf8');
this._active = true;
log.info('dns', `DNS 劫持配置已写入: ${CAPTIVE_CONF} (${CAPTIVE_DOMAIN}${gatewayIp})`);
} catch (e) {
log.error('dns', `写入 DNS 劫持配置失败: ${e.message}`);
}
this._watchdog = new Watchdog('dns', dnsmasqBin, [
'--no-daemon',
`--conf-file=${DNSMASQ_CONF}`,
], {
maxRestarts: 5,
windowMs: 60_000,
restartDelay: 2_000,
onStdout: (line) => log.debug('dns', line),
onStderr: (line) => log.debug('dns', line),
});
this._watchdog.start();
log.info('dns', `dnsmasq 已启动: ${CAPTIVE_DOMAIN}${gatewayIp}, 全域劫持`);
}
stop() {
if (this._watchdog) {
this._watchdog.stop();
this._watchdog = null;
if (this._active) {
try { fs.unlinkSync(CAPTIVE_CONF); } catch (_) {}
this._active = false;
log.info('dns', 'DNS 劫持配置已移除');
}
try { execSync('pkill -9 dnsmasq', { timeout: 3000 }); } catch (_) {}
try { fs.unlinkSync(DNSMASQ_CONF); } catch (_) {}
}
}
function findBin(name) {
const searchPaths = ['/usr/sbin', '/usr/bin', '/sbin', '/bin', '/usr/local/sbin', '/usr/local/bin'];
for (const dir of searchPaths) {
const full = path.join(dir, name);
if (fs.existsSync(full)) return full;
}
// 兜底尝试 whichPATH 可能已包含)
try {
return execSync(`which ${name}`, { encoding: 'utf8', timeout: 3000 }).trim() || null;
} catch (_) { return null; }
}
module.exports = { DnsHijack, CAPTIVE_DOMAIN };