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
a few seconds to auto-connect to saved WiFi. Without waiting,
AP starts immediately and occupies wlan0, preventing NM reconnect.
Killing all dnsmasq processes caused NetworkManager to detect its
hotspot dnsmasq died and tear down the hotspot (AP appears briefly
then disappears).
- Add hasSavedWifiConnection() to check for saved WiFi profiles
- Wait up to 20s for NM auto-connect before falling back to AP
- First boot (no saved WiFi) still starts AP immediately
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.

View File

@@ -1,112 +1,61 @@
'use strict';
const { spawn, execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
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';
// 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;
}
try { execSync('pkill -9 dnsmasq', { timeout: 3000 }); } catch (_) {}
try { fs.unlinkSync(DNSMASQ_CONF); } catch (_) {}
if (this._active) {
try { fs.unlinkSync(CAPTIVE_CONF); } catch (_) {}
this._active = false;
log.info('dns', 'DNS 劫持配置已移除');
}
}
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 };

View File

@@ -98,10 +98,12 @@ class ProvisionManager extends EventEmitter {
if (this._state === 'ap') return;
try {
const ap = startAP(this._clawId);
// 先写 DNS 劫持配置,再启动 AP
// NM 启动热点时会加载 dnsmasq-shared.d/ 下的配置
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({
clawId: this._clawId,