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

@@ -1,5 +1,9 @@
fix: clawd-rfkill.service failed due to systemd $ expansion feat: AP always-on mode - hotspot stays until WiFi STA connects
systemd expands $rf as env var (empty), breaking the inline script. Redesign provisioning from one-shot blocking to persistent background manager:
Move rfkill logic to standalone script to avoid escaping issues. - AP hotspot starts at boot regardless of eth0 status
Also use quoted heredoc to prevent bash expansion in install.sh. - 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

View File

@@ -2,12 +2,11 @@
const http = require('http'); const http = require('http');
const log = require('./logger'); const log = require('./logger');
const { scanWifi, connectWifi } = require('./network'); const { scanWifi } = require('./network');
const { CAPTIVE_DOMAIN } = require('./dns-hijack'); const { CAPTIVE_DOMAIN } = require('./dns-hijack');
const PORT = 80; const PORT = 80;
// iOS / Android / Windows Captive Portal 检测路径
const CAPTIVE_DETECT_PATHS = new Set([ const CAPTIVE_DETECT_PATHS = new Set([
'/hotspot-detect.html', // iOS '/hotspot-detect.html', // iOS
'/library/test/success.html', // iOS older '/library/test/success.html', // iOS older
@@ -20,48 +19,43 @@ const CAPTIVE_DETECT_PATHS = new Set([
]); ]);
/** /**
* 配网 HTTP 服务器。 * 配网 HTTP 服务器(回调模式)
* *
* 路由: * 路由:
* GET / → 配网页面HTML * GET / → 配网页面HTML
* GET /api/scan → WiFi 扫描结果 JSON * GET /api/scan → WiFi 扫描结果 JSON
* POST /api/connect → 提交 WiFi 凭证,尝试连接 * POST /api/connect → 提交 WiFi 凭证,触发 onConnect 回调
* GET /api/status → 当前连接状态 * GET /api/status → 当前连接状态
* Captive Portal 检测 → 302 重定向到配网页 * Captive Portal 检测 → 302 重定向到配网页
*/ */
class CaptiveServer { class CaptiveServer {
constructor(opts = {}) { constructor(opts = {}) {
this._server = null; this._server = null;
this._clawId = opts.clawId || '???'; this._clawId = opts.clawId || '???';
this._resolve = null; // provisioning 等待配网完成的 resolve this._onConnect = opts.onConnect || null; // (ssid, password) => Promise<{success, error?}>
} }
/** startListening() {
* 启动 HTTP 服务器,返回 Promise配网成功后 resolve。 this._server = http.createServer((req, res) => {
*/ this._handle(req, res).catch(e => {
start() { log.error('http', `${req.method} ${req.url} 异常:`, e.message);
return new Promise((resolve) => { res.writeHead(500, { 'Content-Type': 'application/json' });
this._resolve = resolve; res.end(JSON.stringify({ error: '内部错误' }));
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', () => { this._server.listen(PORT, '0.0.0.0', () => {
log.info('http', `配网页面就绪: http://${CAPTIVE_DOMAIN} (端口 ${PORT})`); log.info('http', `配网页面就绪: http://${CAPTIVE_DOMAIN} (端口 ${PORT})`);
}); });
this._server.on('error', (e) => { this._server.on('error', (e) => {
if (e.code === 'EACCES') { if (e.code === 'EACCES') {
log.error('http', `端口 ${PORT} 无权限,请以 root 运行或改用高端口`); log.error('http', `端口 ${PORT} 无权限,请以 root 运行`);
} else { } else if (e.code === 'EADDRINUSE') {
log.error('http', '服务器错误:', e.message); 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 url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const pathname = url.pathname; const pathname = url.pathname;
// Captive Portal 检测请求 → 302 到配网页
if (CAPTIVE_DETECT_PATHS.has(pathname)) { if (CAPTIVE_DETECT_PATHS.has(pathname)) {
res.writeHead(302, { Location: `http://${CAPTIVE_DOMAIN}/` }); res.writeHead(302, { Location: `http://${CAPTIVE_DOMAIN}/` });
res.end(); res.end();
return; return;
} }
// API 路由
if (pathname === '/api/scan' && req.method === 'GET') { if (pathname === '/api/scan' && req.method === 'GET') {
return this._apiScan(req, res); return this._apiScan(req, res);
} }
@@ -94,7 +86,6 @@ class CaptiveServer {
return this._apiStatus(req, res); return this._apiStatus(req, res);
} }
// 默认返回配网页面
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8', 'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -125,21 +116,16 @@ class CaptiveServer {
log.info('http', `用户提交配网: ssid=${ssid}`); log.info('http', `用户提交配网: ssid=${ssid}`);
// 先返回 "尝试中" 让前端轮询 /api/status // 先返回响应,让手机端知道设备收到了请求
this._json(res, { success: true, message: '正在连接...' }); // AP 即将关闭,手机会断开连接)
this._json(res, { success: true, message: '正在连接AP 将临时关闭...' });
// 异步连接 WiFi会关闭 AP客户端会断开 // 延迟执行,确保 HTTP 响应送达
setTimeout(async () => { if (this._onConnect) {
const result = connectWifi(ssid, password || ''); setTimeout(() => {
if (result.success && this._resolve) { this._onConnect(ssid, password || '');
log.info('http', '配网成功,退出配网模式'); }, 1000);
this._resolve({ ssid }); }
this._resolve = null;
} else {
log.warn('http', `配网失败: ${result.error},重新启动 AP`);
// provisioning.js 会处理重新进入 AP
}
}, 500);
} }
_apiStatus(req, res) { _apiStatus(req, res) {
@@ -257,19 +243,18 @@ async function doConnect(){
var pw=$('password').value; var pw=$('password').value;
if(!ssid){setStatus('请选择或输入 WiFi','err');return} if(!ssid){setStatus('请选择或输入 WiFi','err');return}
$('connectBtn').disabled=true; $('connectBtn').disabled=true;
setStatus('正在连接 '+ssid+' ...','info'); setStatus('正在连接 '+ssid+' ... 热点将临时关闭','info');
try{ try{
var r=await fetch('/api/connect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:ssid,password:pw})}); 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(); var d=await r.json();
if(d.success){ if(d.success){
setStatus('✓ 正在连接,请稍候... 设备将自动重启网络','ok'); setStatus('✓ 设备正在连接 WiFi热点将关闭。如连接失败热点会自动恢复请重新连接配网。','ok');
setTimeout(function(){setStatus('✓ 配网成功!您可以断开此热点','ok')},8000);
}else{ }else{
setStatus('连接失败: '+(d.error||'未知错误'),'err'); setStatus('失败: '+(d.error||'未知错误'),'err');
$('connectBtn').disabled=false; $('connectBtn').disabled=false;
} }
}catch(e){ }catch(e){
setStatus('连接失败: '+e.message,'err'); setStatus('请求失败: '+e.message,'err');
$('connectBtn').disabled=false; $('connectBtn').disabled=false;
} }
} }

View File

@@ -7,7 +7,8 @@ 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 { ProvisionManager } = require('./provisioning');
const { hasInternet } = require('./network');
const MAX_BACKOFF_MS = 60_000; const MAX_BACKOFF_MS = 60_000;
const PONG_TIMEOUT_MS = 15_000; const PONG_TIMEOUT_MS = 15_000;
@@ -59,12 +60,27 @@ class ClawClient {
async start() { async start() {
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`); log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
// 第一步:确保网络可用(无网时进入 AP 配网模式 // 后台启动 AP 配网管理器WiFi 未连接时常驻热点
await ensureNetwork({ clawId: this._cfg.claw_id }); 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([ 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)), startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
]); ]);
this._dashInfo = dashInfo || {}; this._dashInfo = dashInfo || {};
@@ -77,6 +93,7 @@ class ClawClient {
this._clearHeartbeat(); this._clearHeartbeat();
this._clearPing(); this._clearPing();
if (this._sdTimer) { clearInterval(this._sdTimer); this._sdTimer = null; } if (this._sdTimer) { clearInterval(this._sdTimer); this._sdTimer = null; }
if (this._provisionMgr) { this._provisionMgr.stop(); this._provisionMgr = null; }
this._frpc.stop(); this._frpc.stop();
if (this._ws) this._ws.terminate(); if (this._ws) this._ws.terminate();
this._sdNotify('STOPPING=1'); this._sdNotify('STOPPING=1');

View File

@@ -173,8 +173,26 @@ function sleep(ms) {
execSync(`sleep ${ms / 1000}`, { timeout: ms + 2000 }); 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 = { module.exports = {
hasInternet, hasInternet,
isWifiStaConnected,
getWifiIface, getWifiIface,
scanWifi, scanWifi,
connectWifi, connectWifi,

View File

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