feat: add WiFi provisioning for headless devices (AP + Captive Portal)
- Add lib/network.js: WiFi scan, connect, AP hotspot via nmcli - Add lib/dns-hijack.js: dnsmasq management for DNS hijack + DHCP - Add lib/captive-server.js: embedded HTTP captive portal with WiFi setup page - Add lib/provisioning.js: orchestrator (detect network -> AP mode -> wait -> exit) - Update client.js: call ensureNetwork() before WS connection - Update install.sh: auto-install dnsmasq dependency Made-with: Cursor
This commit is contained in:
20
README.md
20
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
|
||||
```
|
||||
|
||||
|
||||
20
install.sh
20
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"
|
||||
|
||||
293
lib/captive-server.js
Normal file
293
lib/captive-server.js
Normal file
@@ -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 `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||||
<title>Claw Box 配网</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333;min-height:100vh;display:flex;justify-content:center;align-items:center;padding:20px}
|
||||
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.1);padding:32px;width:100%;max-width:380px}
|
||||
.logo{text-align:center;margin-bottom:24px}
|
||||
.logo span{font-size:40px}
|
||||
.logo h1{font-size:20px;margin-top:8px;color:#1a1a2e}
|
||||
.logo p{font-size:13px;color:#888;margin-top:4px}
|
||||
.device-id{text-align:center;background:#f8f9fa;border-radius:8px;padding:8px;margin-bottom:20px;font-size:14px;color:#555}
|
||||
.device-id strong{color:#1a1a2e;font-size:16px}
|
||||
label{display:block;font-size:14px;font-weight:500;margin-bottom:6px;color:#555}
|
||||
select,input{width:100%;padding:12px;border:1.5px solid #ddd;border-radius:8px;font-size:15px;outline:none;transition:border-color .2s}
|
||||
select:focus,input:focus{border-color:#4a6cf7}
|
||||
.field{margin-bottom:16px}
|
||||
.btn{width:100%;padding:14px;background:linear-gradient(135deg,#4a6cf7,#3b5de7);color:#fff;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;transition:opacity .2s}
|
||||
.btn:hover{opacity:.9}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.btn-scan{background:#f0f2f5;color:#555;font-size:13px;padding:8px;margin-bottom:16px;font-weight:400}
|
||||
.btn-scan:hover{background:#e8eaed}
|
||||
.status{text-align:center;margin-top:16px;padding:12px;border-radius:8px;font-size:14px;display:none}
|
||||
.status.ok{display:block;background:#e8f5e9;color:#2e7d32}
|
||||
.status.err{display:block;background:#ffeaea;color:#c62828}
|
||||
.status.info{display:block;background:#e3f2fd;color:#1565c0}
|
||||
.manual{margin-top:8px}
|
||||
.manual input{display:none}
|
||||
.manual label{font-size:13px;color:#4a6cf7;cursor:pointer;text-align:center;display:block}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<span>🦀</span>
|
||||
<h1>Claw Box 配网</h1>
|
||||
<p>将设备连接到您的 WiFi</p>
|
||||
</div>
|
||||
<div class="device-id">设备 ID: <strong>${this._clawId}</strong></div>
|
||||
|
||||
<button class="btn btn-scan" onclick="doScan()">🔍 扫描 WiFi</button>
|
||||
|
||||
<div class="field">
|
||||
<label for="ssid">WiFi 网络</label>
|
||||
<select id="ssid"><option value="">-- 点击上方扫描 --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="manual">
|
||||
<input type="checkbox" id="manualToggle" onchange="toggleManual()">
|
||||
<label for="manualToggle">手动输入 SSID</label>
|
||||
<input type="text" id="manualSsid" placeholder="输入 WiFi 名称" style="display:none;margin-top:8px">
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top:16px">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" placeholder="WiFi 密码(开放网络留空)">
|
||||
</div>
|
||||
|
||||
<button class="btn" id="connectBtn" onclick="doConnect()">连接</button>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function $(id){return document.getElementById(id)}
|
||||
function setStatus(msg,type){var s=$('status');s.textContent=msg;s.className='status '+type}
|
||||
|
||||
async function doScan(){
|
||||
$('connectBtn').disabled=true;
|
||||
setStatus('正在扫描...','info');
|
||||
try{
|
||||
var r=await fetch('/api/scan');
|
||||
var d=await r.json();
|
||||
var sel=$('ssid');
|
||||
sel.innerHTML='<option value="">-- 请选择 --</option>';
|
||||
(d.wifi||[]).forEach(function(w){
|
||||
var o=document.createElement('option');
|
||||
o.value=w.ssid;
|
||||
o.textContent=w.ssid+' ('+w.signal+'% '+w.security+')';
|
||||
sel.appendChild(o);
|
||||
});
|
||||
setStatus('扫描到 '+d.wifi.length+' 个网络','ok');
|
||||
}catch(e){setStatus('扫描失败: '+e.message,'err')}
|
||||
$('connectBtn').disabled=false;
|
||||
}
|
||||
|
||||
function toggleManual(){
|
||||
var on=$('manualToggle').checked;
|
||||
$('manualSsid').style.display=on?'block':'none';
|
||||
$('ssid').style.display=on?'none':'block';
|
||||
}
|
||||
|
||||
async function doConnect(){
|
||||
var ssid=$('manualToggle').checked?$('manualSsid').value:$('ssid').value;
|
||||
var pw=$('password').value;
|
||||
if(!ssid){setStatus('请选择或输入 WiFi','err');return}
|
||||
$('connectBtn').disabled=true;
|
||||
setStatus('正在连接 '+ssid+' ...','info');
|
||||
try{
|
||||
var r=await fetch('/api/connect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:ssid,password:pw})});
|
||||
var d=await r.json();
|
||||
if(d.success){
|
||||
setStatus('✓ 正在连接,请稍候... 设备将自动重启网络','ok');
|
||||
setTimeout(function(){setStatus('✓ 配网成功!您可以断开此热点','ok')},8000);
|
||||
}else{
|
||||
setStatus('连接失败: '+(d.error||'未知错误'),'err');
|
||||
$('connectBtn').disabled=false;
|
||||
}
|
||||
}catch(e){
|
||||
setStatus('连接失败: '+e.message,'err');
|
||||
$('connectBtn').disabled=false;
|
||||
}
|
||||
}
|
||||
|
||||
doScan();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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)),
|
||||
|
||||
99
lib/dns-hijack.js
Normal file
99
lib/dns-hijack.js
Normal file
@@ -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 };
|
||||
184
lib/network.js
Normal file
184
lib/network.js
Normal file
@@ -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,
|
||||
};
|
||||
93
lib/provisioning.js
Normal file
93
lib/provisioning.js
Normal file
@@ -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<void>}
|
||||
*/
|
||||
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 };
|
||||
Reference in New Issue
Block a user