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,9 +1,9 @@
fix: wait for NM auto-reconnect before starting AP on reboot fix: stop killing NM's dnsmasq - use dnsmasq-shared.d instead
After WiFi is configured and device reboots, NetworkManager needs Killing all dnsmasq processes caused NetworkManager to detect its
a few seconds to auto-connect to saved WiFi. Without waiting, hotspot dnsmasq died and tear down the hotspot (AP appears briefly
AP starts immediately and occupies wlan0, preventing NM reconnect. then disappears).
- Add hasSavedWifiConnection() to check for saved WiFi profiles Now leverage NM's built-in dnsmasq-shared.d config directory:
- Wait up to 20s for NM auto-connect before falling back to AP write DNS hijack config before starting AP so NM's own dnsmasq
- First boot (no saved WiFi) still starts AP immediately picks it up. No separate dnsmasq process needed.

View File

@@ -1,112 +1,61 @@
'use strict'; 'use strict';
const { spawn, execSync } = require('child_process'); const fs = require('fs');
const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const log = require('./logger'); const log = require('./logger');
const { Watchdog } = require('./watchdog');
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'; 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 进程: * 通过 NetworkManager 的 dnsmasq 配置实现 DNS 劫持。
* - 所有 DNS 查询 → 网关 IP触发 Captive Portal 弹窗) *
* - DHCP 分配 10.42.0.50 ~ 10.42.0.150 * NM 在创建热点时自动启动 dnsmasq 并加载 dnsmasq-shared.d/ 下的配置。
* - ap.cutos.ai 专门指向网关 * 我们只需在启动热点前写入 address=/#/gatewayIpNM 的 dnsmasq 就会
* 把所有域名解析到网关 IP触发 Captive Portal 检测。
*
* 不再自行管理 dnsmasq 进程,避免与 NM 冲突导致热点被拆除。
*/ */
class DnsHijack { class DnsHijack {
constructor() { constructor() {
this._watchdog = null; this._active = false;
} }
/** /**
* 启动 dnsmasq * 写入 DNS 劫持配置(需在 startAP 之前调用)
* @param {string} iface - AP 接口名(如 wlan0 * @param {string} _iface - 接口名(保留参数,兼容调用
* @param {string} gatewayIp - 网关 IP如 10.42.0.1 * @param {string} gatewayIp - 网关 IP如 10.42.0.1
*/ */
start(iface, gatewayIp) { start(_iface, gatewayIp) {
this.stop(); this.stop();
const rangeStart = gatewayIp.replace(/\.\d+$/, '.50');
const rangeEnd = gatewayIp.replace(/\.\d+$/, '.150');
const conf = [ const conf = [
`interface=${iface}`, `# clawd captive portal DNS hijack`,
'bind-interfaces', `# All DNS queries resolve to gateway to trigger captive portal`,
`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 检测)',
`address=/#/${gatewayIp}`, `address=/#/${gatewayIp}`,
'',
'# 日志',
'log-queries',
'log-facility=-', // stdout → Watchdog 采集
'',
'# 禁用系统 resolv.conf',
'no-resolv',
'no-poll',
].join('\n'); ].join('\n');
fs.mkdirSync(CONFIG_DIR, { recursive: true }); try {
fs.writeFileSync(DNSMASQ_CONF, conf, 'utf8'); fs.mkdirSync(NM_DNSMASQ_DIR, { recursive: true });
log.info('dns', `dnsmasq 配置已写入: ${DNSMASQ_CONF}`); fs.writeFileSync(CAPTIVE_CONF, conf, 'utf8');
this._active = true;
// 终止所有 dnsmasq包括 NetworkManager 自动启动的) log.info('dns', `DNS 劫持配置已写入: ${CAPTIVE_CONF} (${CAPTIVE_DOMAIN}${gatewayIp})`);
try { execSync('pkill -9 dnsmasq', { timeout: 3000 }); } catch (_) {} } catch (e) {
// 等待端口释放 log.error('dns', `写入 DNS 劫持配置失败: ${e.message}`);
try { execSync('sleep 1'); } catch (_) {}
// 查找 dnsmasq 二进制(/usr/sbin 可能不在普通用户 PATH 中)
const dnsmasqBin = findBin('dnsmasq');
if (!dnsmasqBin) {
log.error('dns', 'dnsmasq 未安装,请运行: apt install dnsmasq');
return;
} }
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() { stop() {
if (this._watchdog) { if (this._active) {
this._watchdog.stop(); try { fs.unlinkSync(CAPTIVE_CONF); } catch (_) {}
this._watchdog = null; 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 }; module.exports = { DnsHijack, CAPTIVE_DOMAIN };

View File

@@ -98,10 +98,12 @@ class ProvisionManager extends EventEmitter {
if (this._state === 'ap') return; if (this._state === 'ap') return;
try { try {
const ap = startAP(this._clawId); // 先写 DNS 劫持配置,再启动 AP
// NM 启动热点时会加载 dnsmasq-shared.d/ 下的配置
this._dns = new DnsHijack(); this._dns = new DnsHijack();
this._dns.start(ap.iface, AP_IP); this._dns.start('wlan0', AP_IP);
const ap = startAP(this._clawId);
this._server = new CaptiveServer({ this._server = new CaptiveServer({
clawId: this._clawId, clawId: this._clawId,