From 9d8af52bf438f081b86dcd0b76549dd89ee807ec Mon Sep 17 00:00:00 2001 From: stswangzhiping <59632378+stswangzhiping@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:40:19 +0800 Subject: [PATCH] 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 --- .commitmsg | 14 +++--- lib/dns-hijack.js | 113 ++++++++++++-------------------------------- lib/provisioning.js | 8 ++-- 3 files changed, 43 insertions(+), 92 deletions(-) diff --git a/.commitmsg b/.commitmsg index c6fc108..260ffb8 100644 --- a/.commitmsg +++ b/.commitmsg @@ -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. diff --git a/lib/dns-hijack.js b/lib/dns-hijack.js index a663f78..65e4b11 100644 --- a/lib/dns-hijack.js +++ b/lib/dns-hijack.js @@ -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=/#/gatewayIp,NM 的 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; - } - // 兜底尝试 which(PATH 可能已包含) - try { - return execSync(`which ${name}`, { encoding: 'utf8', timeout: 3000 }).trim() || null; - } catch (_) { return null; } -} - module.exports = { DnsHijack, CAPTIVE_DOMAIN }; diff --git a/lib/provisioning.js b/lib/provisioning.js index 4d3710a..03d6dd5 100644 --- a/lib/provisioning.js +++ b/lib/provisioning.js @@ -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,