diff --git a/README.md b/README.md
index c1d9e2a..f9677fa 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,18 @@ systemctl disable clawd # 取消开机自启
| `load_1m` / `load_5m` / `load_15m` | 系统负载 | — |
| `uptime` | 运行时间 | 秒 |
+## WiFi 配网(无屏设备)
+
+首次开机无网络时,clawd 自动进入 AP 配网模式:
+
+1. 设备开启热点 `ClawBox-{ID}`(无密码)
+2. 用户手机连接该热点
+3. 自动弹出配网页面(或访问 `http://ap.cutos.ai`)
+4. 选择家庭 WiFi 并输入密码
+5. 设备连接成功后自动接入云端
+
+需要 `dnsmasq`(安装脚本会自动安装)和 `NetworkManager`。
+
## 架构
```
@@ -111,8 +123,12 @@ clawd/
│ ├── frpc.js ← frpc/ttyd/dashboard 管理(Watchdog 守护)
│ ├── logger.js ← 结构化日志 + 文件轮转
│ ├── metrics.js ← 系统指标采集
-│ └── watchdog.js ← 通用子进程守护(速率限制重启)
-├── install.sh ← 一键安装(含 systemd)
+│ ├── watchdog.js ← 通用子进程守护(速率限制重启)
+│ ├── network.js ← 网络检测、WiFi 扫描/连接、AP 模式
+│ ├── dns-hijack.js ← dnsmasq 管理(DNS 劫持 + DHCP)
+│ ├── captive-server.js ← 配网 HTTP 页面(Captive Portal)
+│ └── provisioning.js ← 配网编排(检测→AP→配网→退出)
+├── install.sh ← 一键安装(含 systemd + dnsmasq)
└── package.json
```
diff --git a/install.sh b/install.sh
index d337ffc..861be15 100644
--- a/install.sh
+++ b/install.sh
@@ -28,6 +28,26 @@ if [ "$MAJOR" -lt 18 ]; then
fi
info "Node.js $NODE_VER ✓"
+# ── 检查/安装 dnsmasq(WiFi 配网需要)──────────────────────────────────────
+if ! command -v dnsmasq &>/dev/null; then
+ info "安装 dnsmasq(WiFi 配网所需)..."
+ if command -v apt-get &>/dev/null; then
+ apt-get install -y -qq dnsmasq >/dev/null 2>&1
+ elif command -v yum &>/dev/null; then
+ yum install -y -q dnsmasq >/dev/null 2>&1
+ elif command -v apk &>/dev/null; then
+ apk add --quiet dnsmasq >/dev/null 2>&1
+ else
+ warn "无法自动安装 dnsmasq,WiFi 配网功能可能不可用"
+ fi
+ # 禁止 dnsmasq 系统服务自启(clawd 自己管理)
+ systemctl disable dnsmasq 2>/dev/null || true
+ systemctl stop dnsmasq 2>/dev/null || true
+fi
+if command -v dnsmasq &>/dev/null; then
+ info "dnsmasq ✓"
+fi
+
# ── 安装 clawd ───────────────────────────────────────────────────────────────
INSTALL_DIR="/opt/clawd"
CONFIG_DIR="/etc/clawd"
diff --git a/lib/captive-server.js b/lib/captive-server.js
new file mode 100644
index 0000000..4ae23a2
--- /dev/null
+++ b/lib/captive-server.js
@@ -0,0 +1,293 @@
+'use strict';
+
+const http = require('http');
+const log = require('./logger');
+const { scanWifi, connectWifi } = require('./network');
+const { CAPTIVE_DOMAIN } = require('./dns-hijack');
+
+const PORT = 80;
+
+// iOS / Android / Windows Captive Portal 检测路径
+const CAPTIVE_DETECT_PATHS = new Set([
+ '/hotspot-detect.html', // iOS
+ '/library/test/success.html', // iOS older
+ '/generate_204', // Android
+ '/gen_204', // Android alt
+ '/connecttest.txt', // Windows
+ '/ncsi.txt', // Windows alt
+ '/redirect', // Windows 11
+ '/canonical.html', // Firefox
+]);
+
+/**
+ * 配网 HTTP 服务器。
+ *
+ * 路由:
+ * GET / → 配网页面(HTML)
+ * GET /api/scan → WiFi 扫描结果 JSON
+ * POST /api/connect → 提交 WiFi 凭证,尝试连接
+ * GET /api/status → 当前连接状态
+ * Captive Portal 检测 → 302 重定向到配网页
+ */
+class CaptiveServer {
+ constructor(opts = {}) {
+ this._server = null;
+ this._clawId = opts.clawId || '???';
+ this._resolve = null; // provisioning 等待配网完成的 resolve
+ }
+
+ /**
+ * 启动 HTTP 服务器,返回 Promise,配网成功后 resolve。
+ */
+ start() {
+ return new Promise((resolve) => {
+ this._resolve = resolve;
+
+ this._server = http.createServer((req, res) => {
+ this._handle(req, res).catch(e => {
+ log.error('http', `${req.method} ${req.url} 异常:`, e.message);
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: '内部错误' }));
+ });
+ });
+
+ this._server.listen(PORT, '0.0.0.0', () => {
+ log.info('http', `配网页面就绪: http://${CAPTIVE_DOMAIN} (端口 ${PORT})`);
+ });
+
+ this._server.on('error', (e) => {
+ if (e.code === 'EACCES') {
+ log.error('http', `端口 ${PORT} 无权限,请以 root 运行或改用高端口`);
+ } else {
+ log.error('http', '服务器错误:', e.message);
+ }
+ });
+ });
+ }
+
+ stop() {
+ if (this._server) {
+ this._server.close();
+ this._server = null;
+ }
+ }
+
+ async _handle(req, res) {
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
+ const pathname = url.pathname;
+
+ // Captive Portal 检测请求 → 302 到配网页
+ if (CAPTIVE_DETECT_PATHS.has(pathname)) {
+ res.writeHead(302, { Location: `http://${CAPTIVE_DOMAIN}/` });
+ res.end();
+ return;
+ }
+
+ // API 路由
+ if (pathname === '/api/scan' && req.method === 'GET') {
+ return this._apiScan(req, res);
+ }
+ if (pathname === '/api/connect' && req.method === 'POST') {
+ return this._apiConnect(req, res);
+ }
+ if (pathname === '/api/status' && req.method === 'GET') {
+ return this._apiStatus(req, res);
+ }
+
+ // 默认返回配网页面
+ res.writeHead(200, {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Cache-Control': 'no-cache',
+ });
+ res.end(this._renderPage());
+ }
+
+ // ── API ──────────────────────────────────────────────────────────────────
+
+ _apiScan(req, res) {
+ const list = scanWifi();
+ this._json(res, { wifi: list });
+ }
+
+ async _apiConnect(req, res) {
+ const body = await readBody(req);
+ let data;
+ try { data = JSON.parse(body); } catch (_) {
+ this._json(res, { success: false, error: 'JSON 格式错误' }, 400);
+ return;
+ }
+
+ const { ssid, password } = data;
+ if (!ssid) {
+ this._json(res, { success: false, error: '请选择 WiFi' }, 400);
+ return;
+ }
+
+ log.info('http', `用户提交配网: ssid=${ssid}`);
+
+ // 先返回 "尝试中" 让前端轮询 /api/status
+ this._json(res, { success: true, message: '正在连接...' });
+
+ // 异步连接 WiFi(会关闭 AP,客户端会断开)
+ setTimeout(async () => {
+ const result = connectWifi(ssid, password || '');
+ if (result.success && this._resolve) {
+ log.info('http', '配网成功,退出配网模式');
+ this._resolve({ ssid });
+ this._resolve = null;
+ } else {
+ log.warn('http', `配网失败: ${result.error},重新启动 AP`);
+ // provisioning.js 会处理重新进入 AP
+ }
+ }, 500);
+ }
+
+ _apiStatus(req, res) {
+ const { hasInternet } = require('./network');
+ this._json(res, { connected: hasInternet() });
+ }
+
+ _json(res, data, code = 200) {
+ res.writeHead(code, {
+ 'Content-Type': 'application/json',
+ 'Access-Control-Allow-Origin': '*',
+ });
+ res.end(JSON.stringify(data));
+ }
+
+ // ── 配网页面 HTML ───────────────────────────────────────────────────────
+
+ _renderPage() {
+ return `
+
+
+
+
+Claw Box 配网
+
+
+
+
+
+
🦀
+
Claw Box 配网
+
将设备连接到您的 WiFi
+
+
设备 ID: ${this._clawId}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+ }
+}
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ let data = '';
+ req.on('data', c => { data += c; if (data.length > 4096) req.destroy(); });
+ req.on('end', () => resolve(data));
+ req.on('error', reject);
+ });
+}
+
+module.exports = { CaptiveServer };
diff --git a/lib/client.js b/lib/client.js
index f0969f6..2060a22 100644
--- a/lib/client.js
+++ b/lib/client.js
@@ -7,6 +7,7 @@ const log = require('./logger');
const { getBoxId } = require('./fingerprint');
const { collect } = require('./metrics');
const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc');
+const { ensureNetwork } = require('./provisioning');
const MAX_BACKOFF_MS = 60_000;
const PONG_TIMEOUT_MS = 15_000;
@@ -58,6 +59,10 @@ class ClawClient {
async start() {
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
+ // 第一步:确保网络可用(无网时进入 AP 配网模式)
+ await ensureNetwork({ clawId: this._cfg.claw_id });
+
+ // 第二步:并行获取 dashboard 信息 + 启动 ttyd
const [dashInfo] = await Promise.all([
getDashboardInfo(),
startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
diff --git a/lib/dns-hijack.js b/lib/dns-hijack.js
new file mode 100644
index 0000000..ac8d8cb
--- /dev/null
+++ b/lib/dns-hijack.js
@@ -0,0 +1,99 @@
+'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';
+
+/**
+ * 管理 dnsmasq 进程:
+ * - 所有 DNS 查询 → 网关 IP(触发 Captive Portal 弹窗)
+ * - DHCP 分配 10.42.0.50 ~ 10.42.0.150
+ * - ap.cutos.ai 专门指向网关
+ */
+class DnsHijack {
+ constructor() {
+ this._watchdog = null;
+ }
+
+ /**
+ * 启动 dnsmasq
+ * @param {string} iface - AP 接口名(如 wlan0)
+ * @param {string} gatewayIp - 网关 IP(如 10.42.0.1)
+ */
+ 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 检测)',
+ `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
+ try { execSync('pkill -f "dnsmasq.*clawd"', { timeout: 3000 }); } catch (_) {}
+
+ // 检查 dnsmasq 是否安装
+ try {
+ execSync('which dnsmasq', { timeout: 3000 });
+ } catch (_) {
+ log.error('dns', 'dnsmasq 未安装,请运行: apt install dnsmasq');
+ return;
+ }
+
+ this._watchdog = new Watchdog('dns', 'dnsmasq', [
+ '--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 -f "dnsmasq.*clawd"', { timeout: 3000 }); } catch (_) {}
+ try { fs.unlinkSync(DNSMASQ_CONF); } catch (_) {}
+ }
+}
+
+module.exports = { DnsHijack, CAPTIVE_DOMAIN };
diff --git a/lib/network.js b/lib/network.js
new file mode 100644
index 0000000..f5512b6
--- /dev/null
+++ b/lib/network.js
@@ -0,0 +1,184 @@
+'use strict';
+
+const { execSync } = require('child_process');
+const log = require('./logger');
+
+const AP_SSID_PREFIX = 'ClawBox-';
+const AP_IP = '10.42.0.1';
+const AP_PASSWORD = ''; // 开放网络,无密码(配网专用,生命周期短)
+const AP_IFACE = process.env.CLAWD_WIFI_IFACE || '';
+const CON_NAME = 'clawd-hotspot';
+
+/**
+ * 检测是否有互联网连接(尝试 DNS 解析 + HTTP 连通性)
+ */
+function hasInternet() {
+ // 优先用 nmcli 的 connectivity check
+ try {
+ const out = run('nmcli networking connectivity check').trim();
+ if (out === 'full' || out === 'limited') return true;
+ } catch (_) {}
+
+ // 兜底:ping DNS
+ try {
+ run('ping -c 1 -W 3 8.8.8.8');
+ return true;
+ } catch (_) {}
+
+ return false;
+}
+
+/**
+ * 获取默认 WiFi 接口名(wlan0 等)
+ */
+function getWifiIface() {
+ if (AP_IFACE) return AP_IFACE;
+ try {
+ const out = run('nmcli -t -f DEVICE,TYPE device | grep wifi | head -1');
+ const iface = out.split(':')[0].trim();
+ if (iface) return iface;
+ } catch (_) {}
+
+ // 兜底
+ try {
+ const out = run("ls /sys/class/net | grep -E '^wl'");
+ const iface = out.split('\n')[0].trim();
+ if (iface) return iface;
+ } catch (_) {}
+
+ return 'wlan0';
+}
+
+/**
+ * 扫描周围 WiFi,返回 [{ ssid, signal, security }]
+ */
+function scanWifi() {
+ const iface = getWifiIface();
+ try {
+ // 先触发一次扫描
+ try { run(`nmcli device wifi rescan ifname ${iface}`); } catch (_) {}
+ // 等扫描完成
+ sleep(2000);
+
+ const out = run('nmcli -t -f SSID,SIGNAL,SECURITY device wifi list');
+ const seen = new Set();
+ const results = [];
+ for (const line of out.split('\n')) {
+ if (!line.trim()) continue;
+ const parts = line.split(':');
+ const ssid = parts[0].trim().replace(/\\:/g, ':');
+ if (!ssid || seen.has(ssid)) continue;
+ seen.add(ssid);
+ results.push({
+ ssid,
+ signal: parseInt(parts[1], 10) || 0,
+ security: parts.slice(2).join(':').trim() || 'Open',
+ });
+ }
+ results.sort((a, b) => b.signal - a.signal);
+ return results;
+ } catch (e) {
+ log.error('network', 'WiFi 扫描失败:', e.message);
+ return [];
+ }
+}
+
+/**
+ * 连接指定 WiFi
+ * @returns {{ success: boolean, error?: string }}
+ */
+function connectWifi(ssid, password) {
+ const iface = getWifiIface();
+ log.info('network', `尝试连接 WiFi: ${ssid}`);
+ try {
+ // 先删除可能残留的同名连接
+ try { run(`nmcli connection delete "${ssid}"`); } catch (_) {}
+
+ const pwdArg = password ? `password "${password}"` : '';
+ run(`nmcli device wifi connect "${ssid}" ${pwdArg} ifname ${iface}`, 30000);
+
+ // 验证连通性
+ sleep(3000);
+ if (hasInternet()) {
+ log.info('network', `WiFi 已连接: ${ssid}`);
+ return { success: true };
+ }
+ return { success: false, error: '已连接但无法访问互联网' };
+ } catch (e) {
+ log.error('network', `WiFi 连接失败: ${e.message}`);
+ return { success: false, error: e.message };
+ }
+}
+
+/**
+ * 启动 WiFi AP 热点
+ */
+function startAP(clawId) {
+ const iface = getWifiIface();
+ const ssid = `${AP_SSID_PREFIX}${clawId || 'Setup'}`;
+
+ log.info('network', `启动 AP 热点: ${ssid} (${iface})`);
+
+ // 关闭已有热点
+ stopAP();
+
+ try {
+ // nmcli 创建热点(开放网络)
+ const cmd = [
+ 'nmcli device wifi hotspot',
+ `ifname ${iface}`,
+ `con-name ${CON_NAME}`,
+ `ssid "${ssid}"`,
+ 'band bg',
+ ];
+ // 如果需要密码
+ if (AP_PASSWORD) {
+ cmd.push(`password "${AP_PASSWORD}"`);
+ }
+ run(cmd.join(' '));
+
+ // 等待 AP 启动
+ sleep(2000);
+ log.info('network', `AP 已启动: ${ssid}, 网关 ${AP_IP}`);
+ return { ssid, ip: AP_IP, iface };
+ } catch (e) {
+ log.error('network', `AP 启动失败: ${e.message}`);
+ throw e;
+ }
+}
+
+/**
+ * 关闭热点,恢复普通 WiFi 模式
+ */
+function stopAP() {
+ try {
+ run(`nmcli connection down ${CON_NAME}`);
+ } catch (_) {}
+ try {
+ run(`nmcli connection delete ${CON_NAME}`);
+ } catch (_) {}
+}
+
+// ── 工具 ─────────────────────────────────────────────────────────────────────
+
+function run(cmd, timeout = 10000) {
+ return execSync(cmd, {
+ encoding: 'utf8',
+ timeout,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+}
+
+function sleep(ms) {
+ execSync(`sleep ${ms / 1000}`, { timeout: ms + 2000 });
+}
+
+module.exports = {
+ hasInternet,
+ getWifiIface,
+ scanWifi,
+ connectWifi,
+ startAP,
+ stopAP,
+ AP_IP,
+};
diff --git a/lib/provisioning.js b/lib/provisioning.js
new file mode 100644
index 0000000..1ba326d
--- /dev/null
+++ b/lib/provisioning.js
@@ -0,0 +1,93 @@
+'use strict';
+
+const log = require('./logger');
+const { hasInternet, startAP, stopAP, AP_IP } = require('./network');
+const { DnsHijack } = require('./dns-hijack');
+const { CaptiveServer } = require('./captive-server');
+
+const config = require('./config');
+
+const MAX_RETRIES = 3; // 配网连接失败后最多重新进入 AP 模式次数
+
+/**
+ * 确保设备有互联网连接。
+ * 已联网 → 直接返回
+ * 未联网 → 进入 AP 配网模式 → 等待用户配网 → 成功后返回
+ *
+ * @param {object} opts
+ * @param {string|number} opts.clawId - 设备 ID(用于 AP SSID)
+ * @returns {Promise}
+ */
+async function ensureNetwork(opts = {}) {
+ // 先检测是否已联网
+ if (hasInternet()) {
+ log.info('provision', '网络已就绪,跳过配网');
+ return;
+ }
+
+ log.warn('provision', '未检测到网络,进入配网模式...');
+
+ const cfg = config.load();
+ const clawId = opts.clawId || cfg.claw_id || 'Setup';
+ let retries = 0;
+
+ while (retries < MAX_RETRIES) {
+ try {
+ await runProvisioningRound(clawId);
+
+ // 配网成功,再验证一次
+ if (hasInternet()) {
+ log.info('provision', '配网完成,网络已就绪');
+ return;
+ }
+
+ log.warn('provision', '配网后仍无网络,重新进入配网模式...');
+ } catch (e) {
+ log.error('provision', `配网异常: ${e.message}`);
+ }
+
+ retries++;
+ if (retries < MAX_RETRIES) {
+ log.info('provision', `重试配网 (${retries}/${MAX_RETRIES})...`);
+ // 等一会再重试,避免过快循环
+ await sleep(3000);
+ }
+ }
+
+ log.error('provision', `配网失败 ${MAX_RETRIES} 次,将以离线模式继续启动(等待网络恢复后重连)`);
+}
+
+/**
+ * 单轮配网流程:开 AP → 启动 DNS + HTTP → 等待用户配网 → 清理
+ */
+async function runProvisioningRound(clawId) {
+ const dns = new DnsHijack();
+ const server = new CaptiveServer({ clawId });
+
+ try {
+ // 1. 启动 WiFi AP
+ const ap = startAP(clawId);
+
+ // 2. 启动 DNS 劫持
+ dns.start(ap.iface, AP_IP);
+
+ // 3. 启动 HTTP 配网页面,等待用户完成配网
+ // server.start() 返回 Promise,配网成功时 resolve
+ log.info('provision', '配网页面已就绪,等待用户操作...');
+ log.info('provision', `用户请连接 WiFi "${ap.ssid}" 并访问 http://ap.cutos.ai`);
+
+ const result = await server.start();
+ log.info('provision', `用户已连接 WiFi: ${result.ssid}`);
+ } finally {
+ // 清理:无论成功失败都关闭 AP / DNS / HTTP
+ server.stop();
+ dns.stop();
+ stopAP();
+ }
+}
+
+function sleep(ms) {
+ return new Promise(r => setTimeout(r, ms));
+}
+
+module.exports = { ensureNetwork };