'use strict'; const http = require('http'); const log = require('./logger'); const { scanWifi } = require('./network'); const { CAPTIVE_DOMAIN } = require('./dns-hijack'); const PORT = 80; 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 凭证,触发 onConnect 回调 * GET /api/status → 当前连接状态 * Captive Portal 检测 → 302 重定向到配网页 */ class CaptiveServer { constructor(opts = {}) { this._server = null; this._clawId = opts.clawId || '???'; this._onConnect = opts.onConnect || null; // (ssid, password) => Promise<{success, 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.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); } }); } 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; if (CAPTIVE_DETECT_PATHS.has(pathname)) { res.writeHead(302, { Location: `http://${CAPTIVE_DOMAIN}/` }); res.end(); return; } 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}`); // 先返回响应,让手机端知道设备收到了请求 // (AP 即将关闭,手机会断开连接) this._json(res, { success: true, message: '正在连接,AP 将临时关闭...' }); // 延迟执行,确保 HTTP 响应送达 if (this._onConnect) { setTimeout(() => { this._onConnect(ssid, password || ''); }, 1000); } } _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 `
将设备连接到您的 WiFi