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.
Move rfkill logic to standalone script to avoid escaping issues.
Also use quoted heredoc to prevent bash expansion in install.sh.
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

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,12 +19,12 @@ 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 重定向到配网页
*/
@@ -33,16 +32,10 @@ class CaptiveServer {
constructor(opts = {}) {
this._server = null;
this._clawId = opts.clawId || '???';
this._resolve = null; // provisioning 等待配网完成的 resolve
this._onConnect = opts.onConnect || null; // (ssid, password) => Promise<{success, error?}>
}
/**
* 启动 HTTP 服务器,返回 Promise配网成功后 resolve。
*/
start() {
return new Promise((resolve) => {
this._resolve = resolve;
startListening() {
this._server = http.createServer((req, res) => {
this._handle(req, res).catch(e => {
log.error('http', `${req.method} ${req.url} 异常:`, e.message);
@@ -57,12 +50,13 @@ class CaptiveServer {
this._server.on('error', (e) => {
if (e.code === 'EACCES') {
log.error('http', `端口 ${PORT} 无权限,请以 root 运行或改用高端口`);
log.error('http', `端口 ${PORT} 无权限,请以 root 运行`);
} else if (e.code === 'EADDRINUSE') {
log.error('http', `端口 ${PORT} 已被占用`);
} else {
log.error('http', '服务器错误:', e.message);
}
});
});
}
stop() {
@@ -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
// 延迟执行,确保 HTTP 响应送达
if (this._onConnect) {
setTimeout(() => {
this._onConnect(ssid, password || '');
}, 1000);
}
}, 500);
}
_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 { 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);
log.warn('provision', '配网后仍无网络,重新进入配网模式...');
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', `配网异常: ${e.message}`);
}
retries++;
if (retries < MAX_RETRIES) {
log.info('provision', `重试配网 (${retries}/${MAX_RETRIES})...`);
// 等一会再重试,避免过快循环
await sleep(3000);
log.error('provision', `AP 启动失败: ${e.message}`);
}
}
log.error('provision', `配网失败 ${MAX_RETRIES} 次,将以离线模式继续启动(等待网络恢复后重连)`);
}
// ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
/**
* 单轮配网流程:开 AP → 启动 DNS + HTTP → 等待用户配网 → 清理
*/
async function runProvisioningRound(clawId) {
const dns = new DnsHijack();
const server = new CaptiveServer({ clawId });
async _handleWifiConnect(ssid, password) {
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
try {
// 1. 启动 WiFi AP
const ap = startAP(clawId);
this._state = 'connecting';
log.info('provision', `用户请求连接 WiFi: ${ssid}`);
// 2. 启动 DNS 劫持
dns.start(ap.iface, AP_IP);
this._stopAPServices();
// 3. 启动 HTTP 配网页面,等待用户完成配网
// server.start() 返回 Promise配网成功时 resolve
log.info('provision', '配网页面已就绪,等待用户操作...');
log.info('provision', `用户请连接 WiFi "${ap.ssid}" 并访问 http://ap.cutos.ai`);
const result = connectWifi(ssid, password);
const result = await server.start();
log.info('provision', `用户已连接 WiFi: ${result.ssid}`);
} finally {
// 清理:无论成功失败都关闭 AP / DNS / HTTP
server.stop();
dns.stop();
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();
}
if (this._state === 'ap' && wifiUp) {
log.info('provision', 'WiFi 已外部连接,关闭 AP');
this._stopAPServices();
this._state = 'sta';
this.emit('network-ready');
}
}, MONITOR_INTERVAL_MS);
}
_stopMonitor() {
if (this._monitorTimer) {
clearInterval(this._monitorTimer);
this._monitorTimer = null;
}
}
// ── 清理 ──────────────────────────────────────────────────────────────────
_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 };