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` | 系统负载 | — |
|
| `load_1m` / `load_5m` / `load_15m` | 系统负载 | — |
|
||||||
| `uptime` | 运行时间 | 秒 |
|
| `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 守护)
|
│ ├── frpc.js ← frpc/ttyd/dashboard 管理(Watchdog 守护)
|
||||||
│ ├── logger.js ← 结构化日志 + 文件轮转
|
│ ├── logger.js ← 结构化日志 + 文件轮转
|
||||||
│ ├── metrics.js ← 系统指标采集
|
│ ├── metrics.js ← 系统指标采集
|
||||||
│ └── watchdog.js ← 通用子进程守护(速率限制重启)
|
│ ├── watchdog.js ← 通用子进程守护(速率限制重启)
|
||||||
├── install.sh ← 一键安装(含 systemd)
|
│ ├── 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
|
└── package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
20
install.sh
20
install.sh
@@ -28,6 +28,26 @@ if [ "$MAJOR" -lt 18 ]; then
|
|||||||
fi
|
fi
|
||||||
info "Node.js $NODE_VER ✓"
|
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 ───────────────────────────────────────────────────────────────
|
# ── 安装 clawd ───────────────────────────────────────────────────────────────
|
||||||
INSTALL_DIR="/opt/clawd"
|
INSTALL_DIR="/opt/clawd"
|
||||||
CONFIG_DIR="/etc/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 { getBoxId } = require('./fingerprint');
|
||||||
const { collect } = require('./metrics');
|
const { collect } = require('./metrics');
|
||||||
const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc');
|
const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc');
|
||||||
|
const { ensureNetwork } = require('./provisioning');
|
||||||
|
|
||||||
const MAX_BACKOFF_MS = 60_000;
|
const MAX_BACKOFF_MS = 60_000;
|
||||||
const PONG_TIMEOUT_MS = 15_000;
|
const PONG_TIMEOUT_MS = 15_000;
|
||||||
@@ -58,6 +59,10 @@ class ClawClient {
|
|||||||
async start() {
|
async start() {
|
||||||
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
|
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
|
||||||
|
|
||||||
|
// 第一步:确保网络可用(无网时进入 AP 配网模式)
|
||||||
|
await ensureNetwork({ clawId: this._cfg.claw_id });
|
||||||
|
|
||||||
|
// 第二步:并行获取 dashboard 信息 + 启动 ttyd
|
||||||
const [dashInfo] = await Promise.all([
|
const [dashInfo] = await Promise.all([
|
||||||
getDashboardInfo(),
|
getDashboardInfo(),
|
||||||
startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
|
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