feat: AP always-on mode - hotspot stays until WiFi STA connects

Redesign provisioning from one-shot blocking to persistent background manager:
- AP hotspot starts at boot regardless of eth0 status
- Captive portal runs alongside AP for WiFi configuration
- AP automatically shuts down only when WiFi STA connects
- WiFi drops at runtime -> AP auto-restarts
- WiFi connect fails -> AP auto-restarts for retry
- client.js no longer blocks on network; connects WS when ready

Made-with: Cursor
This commit is contained in:
stswangzhiping
2026-03-16 12:18:35 +08:00
parent dac68f78b4
commit b42e59fab8
5 changed files with 207 additions and 129 deletions

View File

@@ -2,12 +2,11 @@
const http = require('http');
const log = require('./logger');
const { scanWifi, connectWifi } = require('./network');
const { scanWifi } = 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
@@ -20,48 +19,43 @@ const CAPTIVE_DETECT_PATHS = new Set([
]);
/**
* 配网 HTTP 服务器。
* 配网 HTTP 服务器(回调模式)
*
* 路由:
* GET / → 配网页面HTML
* GET /api/scan → WiFi 扫描结果 JSON
* POST /api/connect → 提交 WiFi 凭证,尝试连接
* POST /api/connect → 提交 WiFi 凭证,触发 onConnect 回调
* GET /api/status → 当前连接状态
* Captive Portal 检测 → 302 重定向到配网页
*/
class CaptiveServer {
constructor(opts = {}) {
this._server = null;
this._clawId = opts.clawId || '???';
this._resolve = null; // provisioning 等待配网完成的 resolve
this._server = null;
this._clawId = opts.clawId || '???';
this._onConnect = opts.onConnect || null; // (ssid, password) => Promise<{success, error?}>
}
/**
* 启动 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: '内部错误' }));
});
startListening() {
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.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);
}
});
this._server.on('error', (e) => {
if (e.code === 'EACCES') {
log.error('http', `端口 ${PORT} 无权限,请以 root 运行`);
} else if (e.code === 'EADDRINUSE') {
log.error('http', `端口 ${PORT} 已被占用`);
} else {
log.error('http', '服务器错误:', e.message);
}
});
}
@@ -76,14 +70,12 @@ class CaptiveServer {
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);
}
@@ -94,7 +86,6 @@ class CaptiveServer {
return this._apiStatus(req, res);
}
// 默认返回配网页面
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
@@ -125,21 +116,16 @@ class CaptiveServer {
log.info('http', `用户提交配网: ssid=${ssid}`);
// 先返回 "尝试中" 让前端轮询 /api/status
this._json(res, { success: true, message: '正在连接...' });
// 先返回响应,让手机端知道设备收到了请求
// AP 即将关闭,手机会断开连接)
this._json(res, { success: true, message: '正在连接AP 将临时关闭...' });
// 异步连接 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);
// 延迟执行,确保 HTTP 响应送达
if (this._onConnect) {
setTimeout(() => {
this._onConnect(ssid, password || '');
}, 1000);
}
}
_apiStatus(req, res) {
@@ -257,19 +243,18 @@ async function doConnect(){
var pw=$('password').value;
if(!ssid){setStatus('请选择或输入 WiFi','err');return}
$('connectBtn').disabled=true;
setStatus('正在连接 '+ssid+' ...','info');
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);
setStatus('✓ 设备正在连接 WiFi热点将关闭。如连接失败热点会自动恢复请重新连接配网。','ok');
}else{
setStatus('连接失败: '+(d.error||'未知错误'),'err');
setStatus('失败: '+(d.error||'未知错误'),'err');
$('connectBtn').disabled=false;
}
}catch(e){
setStatus('连接失败: '+e.message,'err');
setStatus('请求失败: '+e.message,'err');
$('connectBtn').disabled=false;
}
}

View File

@@ -7,7 +7,8 @@ const log = require('./logger');
const { getBoxId } = require('./fingerprint');
const { collect } = require('./metrics');
const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc');
const { ensureNetwork } = require('./provisioning');
const { ProvisionManager } = require('./provisioning');
const { hasInternet } = require('./network');
const MAX_BACKOFF_MS = 60_000;
const PONG_TIMEOUT_MS = 15_000;
@@ -59,12 +60,27 @@ class ClawClient {
async start() {
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
// 第一步:确保网络可用(无网时进入 AP 配网模式
await ensureNetwork({ clawId: this._cfg.claw_id });
// 后台启动 AP 配网管理器WiFi 未连接时常驻热点
this._provisionMgr = new ProvisionManager(this._cfg.claw_id);
this._provisionMgr.start();
// 第二步:并行获取 dashboard 信息 + 启动 ttyd
// 有网络时直接连接云端
if (hasInternet()) {
await this._proceedWithConnection();
} else {
// 等待配网完成后再连
log.info('clawd', '等待网络就绪...');
this._provisionMgr.once('network-ready', () => {
this._proceedWithConnection().catch(e => {
log.error('clawd', '连接启动失败:', e.message);
});
});
}
}
async _proceedWithConnection() {
const [dashInfo] = await Promise.all([
getDashboardInfo(),
getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }),
startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
]);
this._dashInfo = dashInfo || {};
@@ -77,6 +93,7 @@ class ClawClient {
this._clearHeartbeat();
this._clearPing();
if (this._sdTimer) { clearInterval(this._sdTimer); this._sdTimer = null; }
if (this._provisionMgr) { this._provisionMgr.stop(); this._provisionMgr = null; }
this._frpc.stop();
if (this._ws) this._ws.terminate();
this._sdNotify('STOPPING=1');

View File

@@ -173,8 +173,26 @@ function sleep(ms) {
execSync(`sleep ${ms / 1000}`, { timeout: ms + 2000 });
}
/**
* 检测 wlan0 是否以 STA 模式连接了 WiFi排除自身热点
*/
function isWifiStaConnected() {
const iface = getWifiIface();
try {
const out = run('nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device');
for (const line of out.split('\n')) {
const parts = line.split(':');
if (parts[0] === iface && parts[1] === 'wifi' && parts[2] === 'connected') {
return parts[3] !== CON_NAME;
}
}
} catch (_) {}
return false;
}
module.exports = {
hasInternet,
isWifiStaConnected,
getWifiIface,
scanWifi,
connectWifi,

View File

@@ -1,93 +1,147 @@
'use strict';
const EventEmitter = require('events');
const log = require('./logger');
const { hasInternet, startAP, stopAP, AP_IP } = require('./network');
const { DnsHijack } = require('./dns-hijack');
const { CaptiveServer } = require('./captive-server');
const { hasInternet, isWifiStaConnected, startAP, stopAP, connectWifi, AP_IP } = require('./network');
const { DnsHijack } = require('./dns-hijack');
const { CaptiveServer } = require('./captive-server');
const config = require('./config');
const MAX_RETRIES = 3; // 配网连接失败后最多重新进入 AP 模式次数
const MONITOR_INTERVAL_MS = 30_000;
/**
* 确保设备有互联网连接
* 已联网 → 直接返回
* 未联网 → 进入 AP 配网模式 → 等待用户配网 → 成功后返回
* AP 常驻配网管理器
*
* @param {object} opts
* @param {string|number} opts.clawId - 设备 ID用于 AP SSID
* @returns {Promise<void>}
* 规则:
* - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP
* - 运行中 WiFi 断开 → 自动重新开 AP
* - WiFi 已连接 → AP 关闭
*/
async function ensureNetwork(opts = {}) {
// 先检测是否已联网
if (hasInternet()) {
log.info('provision', '网络已就绪,跳过配网');
return;
class ProvisionManager extends EventEmitter {
constructor(clawId) {
super();
this._clawId = clawId || 'Setup';
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta'
this._dns = null;
this._server = null;
this._monitorTimer = null;
}
log.warn('provision', '未检测到网络,进入配网模式...');
start() {
if (isWifiStaConnected()) {
this._state = 'sta';
log.info('provision', 'WiFi STA 已连接AP 不启动');
} else {
this._enterAP();
}
this._startMonitor();
const cfg = config.load();
const clawId = opts.clawId || cfg.claw_id || 'Setup';
let retries = 0;
if (hasInternet()) {
this.emit('network-ready');
}
}
stop() {
this._stopMonitor();
this._stopAll();
this._state = 'idle';
}
// ── 进入 AP 模式 ─────────────────────────────────────────────────────────
_enterAP() {
if (this._state === 'ap') return;
while (retries < MAX_RETRIES) {
try {
await runProvisioningRound(clawId);
const ap = startAP(this._clawId);
// 配网成功,再验证一次
if (hasInternet()) {
log.info('provision', '配网完成,网络已就绪');
return;
this._dns = new DnsHijack();
this._dns.start(ap.iface, AP_IP);
this._server = new CaptiveServer({
clawId: this._clawId,
onConnect: (ssid, password) => this._handleWifiConnect(ssid, password),
});
this._server.startListening();
this._state = 'ap';
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
log.info('provision', `配网地址: http://ap.cutos.ai`);
} catch (e) {
log.error('provision', `AP 启动失败: ${e.message}`);
}
}
// ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
async _handleWifiConnect(ssid, password) {
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
this._state = 'connecting';
log.info('provision', `用户请求连接 WiFi: ${ssid}`);
this._stopAPServices();
const result = connectWifi(ssid, password);
if (result.success) {
this._state = 'sta';
log.info('provision', `WiFi 已连接: ${ssid}`);
this.emit('network-ready');
return result;
}
log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`);
this._enterAP();
return result;
}
// ── WiFi 状态监控 ─────────────────────────────────────────────────────────
_startMonitor() {
this._monitorTimer = setInterval(() => {
if (this._state === 'connecting') return;
const wifiUp = isWifiStaConnected();
if (this._state === 'sta' && !wifiUp) {
log.warn('provision', 'WiFi 连接已断开,重新启动 AP');
this._enterAP();
}
log.warn('provision', '配网后仍无网络,重新进入配网模式...');
} catch (e) {
log.error('provision', `配网异常: ${e.message}`);
}
if (this._state === 'ap' && wifiUp) {
log.info('provision', 'WiFi 已外部连接,关闭 AP');
this._stopAPServices();
this._state = 'sta';
this.emit('network-ready');
}
}, MONITOR_INTERVAL_MS);
}
retries++;
if (retries < MAX_RETRIES) {
log.info('provision', `重试配网 (${retries}/${MAX_RETRIES})...`);
// 等一会再重试,避免过快循环
await sleep(3000);
_stopMonitor() {
if (this._monitorTimer) {
clearInterval(this._monitorTimer);
this._monitorTimer = null;
}
}
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();
_stopAPServices() {
if (this._server) {
this._server.stop();
this._server = null;
}
if (this._dns) {
this._dns.stop();
this._dns = null;
}
stopAP();
}
_stopAll() {
this._stopAPServices();
}
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
module.exports = { ensureNetwork };
module.exports = { ProvisionManager };