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 配网 + + + +
+ +
设备 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 };