diff --git a/README.md b/README.md index 4967eb6..d186959 100644 --- a/README.md +++ b/README.md @@ -1,199 +1,199 @@ -# clawd - -Claw Box 守护进程,将本地 Linux 设备通过 WebSocket 长连接接入 [claw.cutos.ai](https://claw.cutos.ai)。 - -## 功能 - -- 自动生成硬件唯一指纹(`box_id`) -- 首次连接自动注册,获取 `claw_id` + `token` 并持久化 -- 每 30 秒上报系统指标(CPU、内存、磁盘、温度、负载、运行时间) -- 断线自动重连(指数退避,最大 60 秒) -- WS 层 Ping/Pong 活性检测,连接假死自动重连 -- frpc / ttyd 子进程 Watchdog 守护,崩溃自动重启(速率限制) -- 结构化日志 + 文件轮转(5MB × 5 份) -- systemd 集成:Watchdog、资源限制、优雅停止 -- 全局异常兜底(uncaughtException / unhandledRejection) - -## 快速安装(Linux,需要 root) - -```bash -curl -fsSL https://raw.githubusercontent.com/stswangzhiping/clawd/main/install.sh | sudo bash -``` - -要求: -- Node.js >= 18 -- Linux(systemd) - -## 手动运行(开发调试) - -```bash -git clone https://github.com/stswangzhiping/clawd.git -cd clawd -npm install -node bin/clawd.js -``` - -## 首次启动输出示例 - -``` -2026-03-16T10:00:00.000Z INFO [clawd] 启动中... 服务器 = wss://claw.cutos.ai/ws -2026-03-16T10:00:01.000Z INFO [clawd] WebSocket 已连接 -2026-03-16T10:00:01.100Z INFO [clawd] 注册成功!claw_id = 1000 -2026-03-16T10:00:01.100Z INFO [clawd] -2026-03-16T10:00:01.100Z INFO [clawd] ╔════════════════════════════════════╗ -2026-03-16T10:00:01.100Z INFO [clawd] ║ Claw ID : 1000 ║ -2026-03-16T10:00:01.100Z INFO [clawd] ║ PIN 码 : 779413 ║ -2026-03-16T10:00:01.100Z INFO [clawd] ║ 请在网页前台「添加设备」中输入 ║ -2026-03-16T10:00:01.100Z INFO [clawd] ╚════════════════════════════════════╝ -2026-03-16T10:00:01.100Z INFO [clawd] -2026-03-16T10:00:01.100Z INFO [clawd] 等待激活,心跳正常运行... -``` - -## 配置文件 - -路径:`/etc/clawd/config.json`(root 运行)或 `~/.clawd/config.json`(普通用户) - -```json -{ - "server": "wss://claw.cutos.ai/ws", - "claw_id": 1000, - "token": "6e0c182e...", - "heartbeat_interval": 30 -} -``` - -## 环境变量 - -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `CLAWD_LOG_LEVEL` | `info` | 日志级别:debug / info / warn / error | -| `CLAWD_LOG_FILE` | `1` | 是否写日志文件(`0` = 仅 stdout/journald) | -| `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 | -| `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 | - -systemd 安装后环境变量文件位于 `/etc/clawd/env`。 - -## 服务管理 - -```bash -systemctl status clawd # 查看状态 -journalctl -u clawd -f # 实时日志 -systemctl restart clawd # 重启 -systemctl stop clawd # 停止 -systemctl disable clawd # 取消开机自启 -``` - -## 更新 - -clawd 安装在 `/opt/clawd`,更新时需在该目录执行 `git pull`: - -```bash -cd /opt/clawd && sudo git pull && sudo systemctl restart clawd -``` - -## 日志 - -- **stdout/journald**:所有日志同时输出到标准输出(systemd 自动采集到 journald) -- **文件日志**:`/etc/clawd/logs/clawd.log`,单文件 5MB,保留 5 份轮转 - -## 心跳上报字段 - -| 字段 | 说明 | 单位 | -|------|------|------| -| `cpu` | CPU 使用率 | % | -| `mem_total` / `mem_used` | 内存总量 / 已用 | KB | -| `disk_total` / `disk_used` | 磁盘总量 / 已用 | KB | -| `temperature` | CPU 温度 | °C | -| `load_1m` / `load_5m` / `load_15m` | 系统负载 | — | -| `uptime` | 运行时间 | 秒 | - -## WiFi 配网(用户手册) - -Claw Box 是无屏设备,通过 WiFi 热点完成网络配置。 - -### 什么时候会出现热点? - -| 场景 | 热点状态 | -|------|----------| -| 首次开机,从未配过 WiFi | 立即开启 | -| 配过 WiFi,但信号范围外或密码已改 | 等待约 20 秒后自动开启 | -| WiFi 正常连接中 | 不开启 | -| 运行中 WiFi 突然断开 | 约 30 秒后自动开启 | - -### 配网步骤 - -**第一步:找到热点** - -打开手机 WiFi 设置,找到名为 **ClawBox-{设备ID}** 的热点(例如 `ClawBox-1002`)。 -设备 ID 印在机身标签上。 - -**第二步:连接热点** - -- 热点名称:`ClawBox-{设备ID}` -- 密码:**`12345678`** - -**第三步:打开配网页面** - -连接成功后,手机通常会**自动弹出配网页面**。 - -如果没有弹出,请手动打开浏览器访问: -- `http://ap.cutos.ai` -- 或 `http://10.42.0.1` - -**第四步:选择 WiFi 并连接** - -1. 点击 **「扫描 WiFi」** 按钮,等待扫描完成 -2. 从下拉列表中选择您的 WiFi(或勾选「手动输入 SSID」) -3. 输入 WiFi 密码 -4. 点击 **「连接」** - -**第五步:等待连接** - -- 设备会临时关闭热点,尝试连接您选择的 WiFi -- **连接成功**:热点不再出现,设备自动接入云端 -- **连接失败**:热点会在几秒后重新出现,请重新连接热点再试 - -### 更换 WiFi - -如果需要更换 WiFi(例如搬到新环境),只需等待设备检测到网络断开, -热点会自动重新出现,按上述步骤重新配网即可。 - -### 常见问题 - -| 问题 | 解决方法 | -|------|----------| -| 找不到 ClawBox 热点 | 等待 30 秒;确认设备已通电且指示灯正常 | -| 连上热点但页面打不开 | 手动访问 `http://10.42.0.1` | -| 扫描不到我的 WiFi | 点击刷新重试;确认路由器开启且距离不太远 | -| 输入密码后连接失败 | 检查密码是否正确;热点恢复后重试 | -| 配网成功但设备仍离线 | 检查路由器是否能上外网;稍等 1 分钟 | - -### 系统要求 - -- `NetworkManager`(安装脚本自动启用) -- WiFi 硬件(wlan0) - -## 架构 - -``` -clawd/ -├── bin/clawd.js ← 入口,优雅停止 -├── lib/ -│ ├── client.js ← 核心:WS 连接、心跳、Ping/Pong、sd-notify -│ ├── config.js ← 配置读写 -│ ├── fingerprint.js ← 硬件指纹生成 -│ ├── frpc.js ← frpc/ttyd/dashboard 管理(Watchdog 守护) -│ ├── logger.js ← 结构化日志 + 文件轮转 -│ ├── metrics.js ← 系统指标采集 -│ ├── watchdog.js ← 通用子进程守护(速率限制重启) -│ ├── network.js ← 网络检测、WiFi 扫描/连接、AP 模式 -│ ├── dns-hijack.js ← DNS 劫持(NM dnsmasq-shared.d 配置) -│ ├── captive-server.js ← 配网 HTTP 页面(Captive Portal) -│ └── provisioning.js ← AP 常驻管理器(WiFi 状态监控) -├── install.sh ← 一键安装(含 systemd + dnsmasq) -└── package.json -``` - -## License - -MIT +# clawd + +Claw Box 守护进程,将本地 Linux 设备通过 WebSocket 长连接接入 [claw.cutos.ai](https://claw.cutos.ai)。 + +## 功能 + +- 自动生成硬件唯一指纹(`box_id`) +- 首次连接自动注册,获取 `claw_id` + `token` 并持久化 +- 每 30 秒上报系统指标(CPU、内存、磁盘、温度、负载、运行时间) +- 断线自动重连(指数退避,最大 60 秒) +- WS 层 Ping/Pong 活性检测,连接假死自动重连 +- frpc / ttyd 子进程 Watchdog 守护,崩溃自动重启(速率限制) +- 结构化日志 + 文件轮转(5MB × 5 份) +- systemd 集成:Watchdog、资源限制、优雅停止 +- 全局异常兜底(uncaughtException / unhandledRejection) + +## 快速安装(Linux,需要 root) + +```bash +curl -fsSL https://raw.githubusercontent.com/stswangzhiping/clawd/main/install.sh | sudo bash +``` + +要求: +- Node.js >= 18 +- Linux(systemd) + +## 手动运行(开发调试) + +```bash +git clone https://github.com/stswangzhiping/clawd.git +cd clawd +npm install +node bin/clawd.js +``` + +## 首次启动输出示例 + +``` +2026-03-16T10:00:00.000Z INFO [clawd] 启动中... 服务器 = wss://claw.cutos.ai/ws +2026-03-16T10:00:01.000Z INFO [clawd] WebSocket 已连接 +2026-03-16T10:00:01.100Z INFO [clawd] 注册成功!claw_id = 1000 +2026-03-16T10:00:01.100Z INFO [clawd] +2026-03-16T10:00:01.100Z INFO [clawd] ╔════════════════════════════════════╗ +2026-03-16T10:00:01.100Z INFO [clawd] ║ Claw ID : 1000 ║ +2026-03-16T10:00:01.100Z INFO [clawd] ║ PIN 码 : 779413 ║ +2026-03-16T10:00:01.100Z INFO [clawd] ║ 请在网页前台「添加设备」中输入 ║ +2026-03-16T10:00:01.100Z INFO [clawd] ╚════════════════════════════════════╝ +2026-03-16T10:00:01.100Z INFO [clawd] +2026-03-16T10:00:01.100Z INFO [clawd] 等待激活,心跳正常运行... +``` + +## 配置文件 + +路径:`/etc/clawd/config.json`(root 运行)或 `~/.clawd/config.json`(普通用户) + +```json +{ + "server": "wss://claw.cutos.ai/ws", + "claw_id": 1000, + "token": "6e0c182e...", + "heartbeat_interval": 30 +} +``` + +## 环境变量 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `CLAWD_LOG_LEVEL` | `info` | 日志级别:debug / info / warn / error | +| `CLAWD_LOG_FILE` | `1` | 是否写日志文件(`0` = 仅 stdout/journald) | +| `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 | +| `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 | + +systemd 安装后环境变量文件位于 `/etc/clawd/env`。 + +## 服务管理 + +```bash +systemctl status clawd # 查看状态 +journalctl -u clawd -f # 实时日志 +systemctl restart clawd # 重启 +systemctl stop clawd # 停止 +systemctl disable clawd # 取消开机自启 +``` + +## 更新 + +clawd 安装在 `/opt/clawd`,更新时需在该目录执行 `git pull`: + +```bash +cd /opt/clawd && sudo git pull && sudo systemctl restart clawd +``` + +## 日志 + +- **stdout/journald**:所有日志同时输出到标准输出(systemd 自动采集到 journald) +- **文件日志**:`/etc/clawd/logs/clawd.log`,单文件 5MB,保留 5 份轮转 + +## 心跳上报字段 + +| 字段 | 说明 | 单位 | +|------|------|------| +| `cpu` | CPU 使用率 | % | +| `mem_total` / `mem_used` | 内存总量 / 已用 | KB | +| `disk_total` / `disk_used` | 磁盘总量 / 已用 | KB | +| `temperature` | CPU 温度 | °C | +| `load_1m` / `load_5m` / `load_15m` | 系统负载 | — | +| `uptime` | 运行时间 | 秒 | + +## WiFi 配网(用户手册) + +Claw Box 是无屏设备,通过 WiFi 热点完成网络配置。 + +### 什么时候会出现热点? + +| 场景 | 热点状态 | +|------|----------| +| 首次开机,从未配过 WiFi | 立即开启 | +| 配过 WiFi,但信号范围外或密码已改 | 等待约 20 秒后自动开启 | +| WiFi 正常连接中 | 不开启 | +| 运行中 WiFi 突然断开 | 约 30 秒后自动开启 | + +### 配网步骤 + +**第一步:找到热点** + +打开手机 WiFi 设置,找到名为 **ClawBox-{设备ID}** 的热点(例如 `ClawBox-1002`)。 +设备 ID 印在机身标签上。 + +**第二步:连接热点** + +- 热点名称:`ClawBox-{设备ID}` +- 密码:**`12345678`** + +**第三步:打开配网页面** + +连接成功后,手机通常会**自动弹出配网页面**。 + +如果没有弹出,请手动打开浏览器访问: +- `http://ap.cutos.ai` +- 或 `http://10.42.0.1` + +**第四步:选择 WiFi 并连接** + +1. 点击 **「扫描 WiFi」** 按钮,等待扫描完成 +2. 从下拉列表中选择您的 WiFi(或勾选「手动输入 SSID」) +3. 输入 WiFi 密码 +4. 点击 **「连接」** + +**第五步:等待连接** + +- 设备会临时关闭热点,尝试连接您选择的 WiFi +- **连接成功**:热点不再出现,设备自动接入云端 +- **连接失败**:热点会在几秒后重新出现,请重新连接热点再试 + +### 更换 WiFi + +如果需要更换 WiFi(例如搬到新环境),只需等待设备检测到网络断开, +热点会自动重新出现,按上述步骤重新配网即可。 + +### 常见问题 + +| 问题 | 解决方法 | +|------|----------| +| 找不到 ClawBox 热点 | 等待 30 秒;确认设备已通电且指示灯正常 | +| 连上热点但页面打不开 | 手动访问 `http://10.42.0.1` | +| 扫描不到我的 WiFi | 点击刷新重试;确认路由器开启且距离不太远 | +| 输入密码后连接失败 | 检查密码是否正确;热点恢复后重试 | +| 配网成功但设备仍离线 | 检查路由器是否能上外网;稍等 1 分钟 | + +### 系统要求 + +- `NetworkManager`(安装脚本自动启用) +- WiFi 硬件(wlan0) + +## 架构 + +``` +clawd/ +├── bin/clawd.js ← 入口,优雅停止 +├── lib/ +│ ├── client.js ← 核心:WS 连接、心跳、Ping/Pong、sd-notify +│ ├── config.js ← 配置读写 +│ ├── fingerprint.js ← 硬件指纹生成 +│ ├── frpc.js ← frpc/ttyd/dashboard 管理(Watchdog 守护) +│ ├── logger.js ← 结构化日志 + 文件轮转 +│ ├── metrics.js ← 系统指标采集 +│ ├── watchdog.js ← 通用子进程守护(速率限制重启) +│ ├── network.js ← 网络检测、WiFi 扫描/连接、AP 模式 +│ ├── dns-hijack.js ← DNS 劫持(NM dnsmasq-shared.d 配置) +│ ├── captive-server.js ← 配网 HTTP 页面(Captive Portal) +│ └── provisioning.js ← AP 常驻管理器(WiFi 状态监控) +├── install.sh ← 一键安装(含 systemd + dnsmasq) +└── package.json +``` + +## License + +MIT diff --git a/lib/captive-server.js b/lib/captive-server.js index 87ebd6c..4bcd6a2 100644 --- a/lib/captive-server.js +++ b/lib/captive-server.js @@ -1,319 +1,319 @@ -'use strict'; - -const http = require('http'); -const log = require('./logger'); -// scanWifi 不再在此调用(AP 模式下无法扫描),改用缓存 -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; - this._cachedWifiList = opts.cachedWifiList || []; - } - - 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) { - // AP 模式下 wlan0 无法扫描,返回开 AP 前的缓存结果 - this._json(res, { wifi: this._cachedWifiList, cached: true }); - } - - 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 ` - - - - -Claw Box 配网 - - - -
- -
设备 ID: ${this._clawId}
- -
- -
加载中...
-
- -
- - - -
- - -
- -
- - 👁 -
-
- - -
-
- - - -`; - } -} - -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 }; +'use strict'; + +const http = require('http'); +const log = require('./logger'); +// scanWifi 不再在此调用(AP 模式下无法扫描),改用缓存 +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; + this._cachedWifiList = opts.cachedWifiList || []; + } + + 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) { + // AP 模式下 wlan0 无法扫描,返回开 AP 前的缓存结果 + this._json(res, { wifi: this._cachedWifiList, cached: true }); + } + + 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 ` + + + + +Claw Box 配网 + + + +
+ +
设备 ID: ${this._clawId}
+ +
+ +
加载中...
+
+ +
+ + + +
+ + +
+ +
+ + 👁 +
+
+ + +
+
+ + + +`; + } +} + +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 }; diff --git a/lib/client.js b/lib/client.js index f1e6d84..2d2af7c 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,420 +1,444 @@ -'use strict'; - -const WebSocket = require('ws'); -const { execFileSync } = require('child_process'); -const config = require('./config'); -const log = require('./logger'); -const { getBoxId } = require('./fingerprint'); -const { collect } = require('./metrics'); -const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新 -const { ProvisionManager } = require('./provisioning'); -const { hasInternet } = require('./network'); -const led = require('./led'); - -const MAX_BACKOFF_MS = 60_000; -const PONG_TIMEOUT_MS = 8_000; -const PING_INTERVAL_MS = 10_000; -const NET_MONITOR_MS = 5_000; // AP 模式网络监视间隔 -const HEARTBEAT_INTERVAL_MS = 10_000; // 心跳间隔:10 秒,用于快速感知网络状态 -const METRICS_EVERY_N = 3; // 每 N 次心跳采集一次指标(= 30 秒) - -// systemd watchdog: 如果 WatchdogSec 存在,定期发 WATCHDOG=1 -const SD_WATCHDOG_USEC = parseInt(process.env.WATCHDOG_USEC || '0', 10); -const SD_NOTIFY_INTERVAL = SD_WATCHDOG_USEC > 0 - ? Math.floor(SD_WATCHDOG_USEC / 2 / 1000) // 半周期通知(μs → ms) - : 0; - -class ClawClient { - constructor() { - this._cfg = config.load(); - this._boxId = getBoxId(); - this._ws = null; - this._hbTimer = null; - this._backoff = 1_000; - this._stopped = false; - this._frpc = new FrpcManager(); - this._dashInfo = {}; - this._hbCount = 0; // 心跳计数器,用于定期刷新 dashboard 信息 - - // WS 层活性检测 - this._pingTimer = null; - this._awaitingPong = false; - - // AP 模式网络监视(WS 连通后每 5s 检查,断网立即 terminate) - this._netMonitorTimer = null; - - // WS 连续失败计数(open 时清零) - this._wsFailCount = 0; - // 是否曾经成功连接过(首次成功前不显示 Err0/AP) - this._hasEverConnected = false; - // 最近一次 WS 错误是否是证书时间问题(NTP 未同步) - this._certTimeError = false; - - // systemd watchdog - this._sdTimer = null; - - this._setupGlobalHandlers(); - } - - // ── 全局异常兜底 ───────────────────────────────────────────────────────────── - - _setupGlobalHandlers() { - process.on('uncaughtException', (err) => { - log.error('process', '未捕获异常:', err); - // 给日志写盘的时间,然后退出让 systemd 重启 - setTimeout(() => process.exit(1), 1000); - }); - - process.on('unhandledRejection', (reason) => { - log.error('process', '未处理的 Promise 拒绝:', reason); - }); - } - - // ── 生命周期 ───────────────────────────────────────────────────────────────── - - async start() { - log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`); - - // 启动时全灭,WS 连接后由 _applyStatus() 按实际状态设置 - led.status.off(); - - this._startSdNotify(); - - // 启动 AP 配网管理器(等待已保存 WiFi 自动连接,超时再开 AP) - this._provisionMgr = new ProvisionManager(this._cfg.claw_id); - this._connectionStarted = false; - - // 网络就绪时连接云端(仅触发一次) - this._provisionMgr.on('network-ready', () => { - if (!this._connectionStarted) { - this._connectionStarted = true; - this._proceedWithConnection().catch(e => { - log.error('clawd', '连接启动失败:', e.message); - }); - } - }); - - await this._provisionMgr.start(); - - // start() 返回后,如果已有网络且尚未启动连接 - if (hasInternet() && !this._connectionStarted) { - this._connectionStarted = true; - await this._proceedWithConnection(); - } else if (!hasInternet()) { - log.info('clawd', '等待网络就绪(WiFi 配网或网线接入)...'); - } - } - - async _proceedWithConnection() { - const [dashInfo] = await Promise.all([ - getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }), - startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)), - ]); - this._dashInfo = dashInfo || {}; - this._connect(); - } - - stop() { - this._stopped = true; - this._clearHeartbeat(); - this._clearPing(); - this._clearNetMonitor(); - 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(); - led.status.off(); // 进程退出,两灯全灭 - this._sdNotify('STOPPING=1'); - log.info('clawd', '已停止'); - log.close(); - } - - // ── WebSocket 连接 ────────────────────────────────────────────────────────── - - _connect() { - if (this._stopped) return; - - // AP 模式 + 无网:不建立 WS,5s 后重新检查网络 - if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet()) { - led.display.showAP(); - log.info('clawd', 'AP 模式无网络,5s 后重新检查...'); - this._backoff = 1_000; // 有网时立即快速重连 - this._wsFailCount = 0; // 不计入失败 - setTimeout(() => this._connect(), 5_000); - return; - } - - if (!this._hasEverConnected || this._wsFailCount < 3) led.display.showConn(); - log.info('clawd', `正在连接 ${this._cfg.server} ...`); - const ws = new WebSocket(this._cfg.server, { - handshakeTimeout: 10_000, - }); - this._ws = ws; - - ws.on('open', () => { - log.info('clawd', 'WebSocket 已连接'); - this._backoff = 1_000; - this._wsFailCount = 0; // 连接成功,重置失败计数 - this._hasEverConnected = true; // 标记已成功连接过 - this._sendConnect(); - this._startPing(); - this._startNetMonitor(); - // 显示由 _onConnected 根据 status 设置,不在此处提前 showTime - }); - - ws.on('message', (data) => { - try { - this._handleMessage(JSON.parse(data.toString())); - } catch (e) { - log.error('clawd', '消息解析失败:', e.message); - } - }); - - ws.on('pong', () => { - this._awaitingPong = false; - }); - - ws.on('close', (code, reason) => { - this._clearHeartbeat(); - this._clearPing(); - this._clearNetMonitor(); - if (!this._stopped) { - this._wsFailCount++; - log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount},${this._backoff / 1000}s 后重连...`); - if (this._hasEverConnected && this._wsFailCount >= 3) { - const inAp = this._provisionMgr && this._provisionMgr.isApMode(); - if (inAp || !hasInternet()) { - led.display.showAP(); // AP 模式 或 无网 - } else { - led.display.showErr0(); // STA 模式 + 有网 但 VPS 不可达 - } - } - if (this._certTimeError) { - // NTP 未同步:固定 5s 重试,等时钟校正 - this._certTimeError = false; - this._backoff = 5_000; - log.warn('clawd', '证书时间错误(NTP 未同步),5s 后重试...'); - } else { - this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS); - } - setTimeout(() => this._connect(), this._backoff); - } - }); - - ws.on('error', (err) => { - log.error('clawd', '连接错误:', err.message); - // 证书时间错误:NTP 未同步,close 后用固定短间隔重试,不做指数退避 - this._certTimeError = !!( - err.code === 'CERT_NOT_YET_VALID' || - (err.message && err.message.includes('not yet valid')) - ); - }); - } - - // ── WS 层 Ping/Pong 活性检测 ────────────────────────────────────────────── - - _startPing() { - this._clearPing(); - this._pingTimer = setInterval(() => { - if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; - - if (this._awaitingPong) { - log.warn('clawd', 'Pong 超时,连接可能已死,主动关闭重连'); - this._ws.terminate(); - return; - } - - this._awaitingPong = true; - try { this._ws.ping(); } catch (_) {} - }, PING_INTERVAL_MS); - } - - _clearPing() { - if (this._pingTimer) { - clearInterval(this._pingTimer); - this._pingTimer = null; - } - this._awaitingPong = false; - } - - // ── AP 模式网络监视(拔网线后 ≤5s 感知)──────────────────────────────────── - - _startNetMonitor() { - this._clearNetMonitor(); - this._netMonitorTimer = setInterval(() => { - if (!this._provisionMgr || !this._provisionMgr.isApMode()) return; - if (hasInternet()) return; - // AP 模式 + 无网,但 WS 还"活着" → 立即终止,触发 close → _connect() 进入 5s 轮询 - log.warn('clawd', 'AP 模式检测到网络断开,主动关闭 WS'); - led.display.showAP(); - if (this._ws) this._ws.terminate(); - }, NET_MONITOR_MS); - } - - _clearNetMonitor() { - if (this._netMonitorTimer) { - clearInterval(this._netMonitorTimer); - this._netMonitorTimer = null; - } - } - - // ── 发送 connect ───────────────────────────────────────────────────────────── - - _sendConnect() { - const msg = { - type: 'connect', - box_id: this._boxId, - claw_id: this._cfg.claw_id ?? null, - token: this._cfg.token ?? null, - ...this._dashInfo, - }; - this._send(msg); - } - - // ── 消息处理 ───────────────────────────────────────────────────────────────── - - _handleMessage(msg) { - switch (msg.type) { - case 'connected': - this._onConnected(msg); - break; - case 'heartbeat_ack': - break; - case 'status_update': - this._applyStatus(msg); - break; - case 'error': - log.error('clawd', `服务器错误: ${msg.msg}`); - if (msg.msg === 'hardware_mismatch') { - log.warn('clawd', '硬件指纹不符,清除凭证重新注册...'); - this._cfg.claw_id = null; - this._cfg.token = null; - config.save(this._cfg); - } else if (msg.msg && msg.msg.includes('invalid')) { - log.warn('clawd', '凭证无效,清除凭证重新注册...'); - this._cfg.claw_id = null; - this._cfg.token = null; - config.save(this._cfg); - } - break; - default: - log.warn('clawd', '未知消息类型:', msg.type); - } - } - - _onConnected(msg) { - const isNew = !this._cfg.claw_id; - - this._cfg.claw_id = msg.claw_id; - this._cfg.token = msg.token; - config.save(this._cfg); - - if (isNew) { - log.info('clawd', `注册成功!claw_id = ${msg.claw_id}`); - } - - this._applyStatus(msg); - - if (msg.frp && msg.frp.server && msg.frp.auth_token) { - this._frpc.start(msg.claw_id, msg.frp).catch(e => { - log.error('frpc', '启动失败:', e.message); - }); - } - - this._startHeartbeat(); - } - - _applyStatus(msg) { - if (msg.status === 'inactive') { - led.status.setSetup(); - led.display.showPin(msg.pin); - const id = String(this._cfg.claw_id || '').padEnd(6); - const pin = String(msg.pin || ''); - log.info('clawd', ''); - log.info('clawd', '╔════════════════════════════════════╗'); - log.info('clawd', `║ Claw ID : ${id} ║`); - log.info('clawd', `║ PIN 码 : ${pin} ║`); - log.info('clawd', '║ 请在网页前台「添加设备」中输入 ║'); - log.info('clawd', '╚════════════════════════════════════╝'); - log.info('clawd', ''); - log.info('clawd', '等待激活,心跳正常运行...'); - } else { - led.status.setApps(); - led.display.showTime(); - log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`); - } - } - - // ── 心跳 ──────────────────────────────────────────────────────────────────── - - _startHeartbeat() { - this._clearHeartbeat(); - this._sendHeartbeat(); - this._hbTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL_MS); - } - - async _sendHeartbeat() { - if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; - try { - this._hbCount++; - - // 每 30 次心跳(约 5 分钟)刷新一次 dashboard 信息 - if (this._hbCount % 30 === 0) { - const freshInfo = await getDashboardInfo().catch(() => null); - if (freshInfo && Object.keys(freshInfo).length > 0) { - this._dashInfo = freshInfo; - } - } - - // 每 METRICS_EVERY_N 次心跳(30 秒)采集一次指标,其余发轻量心跳 - const msg = { - type: 'heartbeat', - claw_id: this._cfg.claw_id, - token: this._cfg.token, - ...this._dashInfo, - }; - if (this._hbCount % METRICS_EVERY_N === 0) { - msg.metrics = await collect(); - } - this._send(msg); - } catch (e) { - log.error('clawd', '心跳发送失败:', e.message); - } - } - - _clearHeartbeat() { - if (this._hbTimer) { - clearInterval(this._hbTimer); - this._hbTimer = null; - } - } - - // ── 工具 ──────────────────────────────────────────────────────────────────── - - _send(obj) { - if (this._ws && this._ws.readyState === WebSocket.OPEN) { - this._ws.send(JSON.stringify(obj)); - } - } - - // ── systemd Watchdog ──────────────────────────────────────────────────────── - - _startSdNotify() { - if (!SD_NOTIFY_INTERVAL) return; - - log.debug('clawd', `systemd watchdog 启用,通知间隔 ${SD_NOTIFY_INTERVAL}ms`); - this._sdNotify('READY=1'); - this._sdTimer = setInterval(() => this._sdNotify('WATCHDOG=1'), SD_NOTIFY_INTERVAL); - } - - _sdNotify(msg) { - if (!process.env.NOTIFY_SOCKET) return; - try { - execFileSync('systemd-notify', ['--pid=' + process.pid, msg], { timeout: 2000 }); - } catch (_) { - // systemd-notify 不可用时静默忽略 - } - } -} - -module.exports = { ClawClient }; +'use strict'; + +const WebSocket = require('ws'); +const { execFileSync } = require('child_process'); +const config = require('./config'); +const log = require('./logger'); +const { getBoxId } = require('./fingerprint'); +const { collect } = require('./metrics'); +const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新 +const { ProvisionManager } = require('./provisioning'); +const { hasInternet, getLocalIps } = require('./network'); +const led = require('./led'); + +const MAX_BACKOFF_MS = 60_000; +const PONG_TIMEOUT_MS = 8_000; +const PING_INTERVAL_MS = 10_000; +const NET_MONITOR_MS = 5_000; // AP 模式网络监视间隔 +const HEARTBEAT_INTERVAL_MS = 10_000; // 心跳间隔:10 秒,用于快速感知网络状态 +const METRICS_EVERY_N = 3; // 每 N 次心跳采集一次指标(= 30 秒) + +// systemd watchdog: 如果 WatchdogSec 存在,定期发 WATCHDOG=1 +const SD_WATCHDOG_USEC = parseInt(process.env.WATCHDOG_USEC || '0', 10); +const SD_NOTIFY_INTERVAL = SD_WATCHDOG_USEC > 0 + ? Math.floor(SD_WATCHDOG_USEC / 2 / 1000) // 半周期通知(μs → ms) + : 0; + +class ClawClient { + constructor() { + this._cfg = config.load(); + this._boxId = getBoxId(); + this._ws = null; + this._hbTimer = null; + this._backoff = 1_000; + this._stopped = false; + this._frpc = new FrpcManager(); + this._dashInfo = {}; + this._hbCount = 0; // 心跳计数器,用于定期刷新 dashboard 信息 + this._externalIp = null; // 外网 IP(连接时查询一次,用于服务端地理位置解析) + + // WS 层活性检测 + this._pingTimer = null; + this._awaitingPong = false; + + // AP 模式网络监视(WS 连通后每 5s 检查,断网立即 terminate) + this._netMonitorTimer = null; + + // WS 连续失败计数(open 时清零) + this._wsFailCount = 0; + // 是否曾经成功连接过(首次成功前不显示 Err0/AP) + this._hasEverConnected = false; + // 最近一次 WS 错误是否是证书时间问题(NTP 未同步) + this._certTimeError = false; + + // systemd watchdog + this._sdTimer = null; + + this._setupGlobalHandlers(); + } + + // ── 全局异常兜底 ───────────────────────────────────────────────────────────── + + _setupGlobalHandlers() { + process.on('uncaughtException', (err) => { + log.error('process', '未捕获异常:', err); + // 给日志写盘的时间,然后退出让 systemd 重启 + setTimeout(() => process.exit(1), 1000); + }); + + process.on('unhandledRejection', (reason) => { + log.error('process', '未处理的 Promise 拒绝:', reason); + }); + } + + // ── 生命周期 ───────────────────────────────────────────────────────────────── + + async start() { + log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`); + + // 启动时全灭,WS 连接后由 _applyStatus() 按实际状态设置 + led.status.off(); + + this._startSdNotify(); + + // 启动 AP 配网管理器(等待已保存 WiFi 自动连接,超时再开 AP) + this._provisionMgr = new ProvisionManager(this._cfg.claw_id); + this._connectionStarted = false; + + // 网络就绪时连接云端(仅触发一次) + this._provisionMgr.on('network-ready', () => { + if (!this._connectionStarted) { + this._connectionStarted = true; + this._proceedWithConnection().catch(e => { + log.error('clawd', '连接启动失败:', e.message); + }); + } + }); + + await this._provisionMgr.start(); + + // start() 返回后,如果已有网络且尚未启动连接 + if (hasInternet() && !this._connectionStarted) { + this._connectionStarted = true; + await this._proceedWithConnection(); + } else if (!hasInternet()) { + log.info('clawd', '等待网络就绪(WiFi 配网或网线接入)...'); + } + } + + async _proceedWithConnection() { + const [dashInfo] = await Promise.all([ + getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }), + startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)), + ]); + this._dashInfo = dashInfo || {}; + + // 查询外网 IP(用于服务端地理位置解析),失败不阻断连接 + try { + const https = require('https'); + this._externalIp = await new Promise((resolve) => { + const req = https.get('https://api.ipify.org?format=json', { timeout: 5000 }, (res) => { + let body = ''; + res.on('data', d => { body += d; }); + res.on('end', () => { + try { resolve(JSON.parse(body).ip || null); } catch { resolve(null); } + }); + }); + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); + if (this._externalIp) log.info('clawd', `外网 IP: ${this._externalIp}`); + } catch (e) { + log.warn('clawd', '外网 IP 查询失败:', e.message); + this._externalIp = null; + } + + this._connect(); + } + + stop() { + this._stopped = true; + this._clearHeartbeat(); + this._clearPing(); + this._clearNetMonitor(); + 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(); + led.status.off(); // 进程退出,两灯全灭 + this._sdNotify('STOPPING=1'); + log.info('clawd', '已停止'); + log.close(); + } + + // ── WebSocket 连接 ────────────────────────────────────────────────────────── + + _connect() { + if (this._stopped) return; + + // AP 模式 + 无网:不建立 WS,5s 后重新检查网络 + if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet()) { + led.display.showAP(); + log.info('clawd', 'AP 模式无网络,5s 后重新检查...'); + this._backoff = 1_000; // 有网时立即快速重连 + this._wsFailCount = 0; // 不计入失败 + setTimeout(() => this._connect(), 5_000); + return; + } + + if (!this._hasEverConnected || this._wsFailCount < 3) led.display.showConn(); + log.info('clawd', `正在连接 ${this._cfg.server} ...`); + const ws = new WebSocket(this._cfg.server, { + handshakeTimeout: 10_000, + }); + this._ws = ws; + + ws.on('open', () => { + log.info('clawd', 'WebSocket 已连接'); + this._backoff = 1_000; + this._wsFailCount = 0; // 连接成功,重置失败计数 + this._hasEverConnected = true; // 标记已成功连接过 + this._sendConnect(); + this._startPing(); + this._startNetMonitor(); + // 显示由 _onConnected 根据 status 设置,不在此处提前 showTime + }); + + ws.on('message', (data) => { + try { + this._handleMessage(JSON.parse(data.toString())); + } catch (e) { + log.error('clawd', '消息解析失败:', e.message); + } + }); + + ws.on('pong', () => { + this._awaitingPong = false; + }); + + ws.on('close', (code, reason) => { + this._clearHeartbeat(); + this._clearPing(); + this._clearNetMonitor(); + if (!this._stopped) { + this._wsFailCount++; + log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount},${this._backoff / 1000}s 后重连...`); + if (this._hasEverConnected && this._wsFailCount >= 3) { + const inAp = this._provisionMgr && this._provisionMgr.isApMode(); + if (inAp || !hasInternet()) { + led.display.showAP(); // AP 模式 或 无网 + } else { + led.display.showErr0(); // STA 模式 + 有网 但 VPS 不可达 + } + } + if (this._certTimeError) { + // NTP 未同步:固定 5s 重试,等时钟校正 + this._certTimeError = false; + this._backoff = 5_000; + log.warn('clawd', '证书时间错误(NTP 未同步),5s 后重试...'); + } else { + this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS); + } + setTimeout(() => this._connect(), this._backoff); + } + }); + + ws.on('error', (err) => { + log.error('clawd', '连接错误:', err.message); + // 证书时间错误:NTP 未同步,close 后用固定短间隔重试,不做指数退避 + this._certTimeError = !!( + err.code === 'CERT_NOT_YET_VALID' || + (err.message && err.message.includes('not yet valid')) + ); + }); + } + + // ── WS 层 Ping/Pong 活性检测 ────────────────────────────────────────────── + + _startPing() { + this._clearPing(); + this._pingTimer = setInterval(() => { + if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; + + if (this._awaitingPong) { + log.warn('clawd', 'Pong 超时,连接可能已死,主动关闭重连'); + this._ws.terminate(); + return; + } + + this._awaitingPong = true; + try { this._ws.ping(); } catch (_) {} + }, PING_INTERVAL_MS); + } + + _clearPing() { + if (this._pingTimer) { + clearInterval(this._pingTimer); + this._pingTimer = null; + } + this._awaitingPong = false; + } + + // ── AP 模式网络监视(拔网线后 ≤5s 感知)──────────────────────────────────── + + _startNetMonitor() { + this._clearNetMonitor(); + this._netMonitorTimer = setInterval(() => { + if (!this._provisionMgr || !this._provisionMgr.isApMode()) return; + if (hasInternet()) return; + // AP 模式 + 无网,但 WS 还"活着" → 立即终止,触发 close → _connect() 进入 5s 轮询 + log.warn('clawd', 'AP 模式检测到网络断开,主动关闭 WS'); + led.display.showAP(); + if (this._ws) this._ws.terminate(); + }, NET_MONITOR_MS); + } + + _clearNetMonitor() { + if (this._netMonitorTimer) { + clearInterval(this._netMonitorTimer); + this._netMonitorTimer = null; + } + } + + // ── 发送 connect ───────────────────────────────────────────────────────────── + + _sendConnect() { + const msg = { + type: 'connect', + box_id: this._boxId, + claw_id: this._cfg.claw_id ?? null, + token: this._cfg.token ?? null, + local_ip: getLocalIps(), + external_ip: this._externalIp ?? null, + ...this._dashInfo, + }; + this._send(msg); + } + + // ── 消息处理 ───────────────────────────────────────────────────────────────── + + _handleMessage(msg) { + switch (msg.type) { + case 'connected': + this._onConnected(msg); + break; + case 'heartbeat_ack': + break; + case 'status_update': + this._applyStatus(msg); + break; + case 'error': + log.error('clawd', `服务器错误: ${msg.msg}`); + if (msg.msg === 'hardware_mismatch') { + log.warn('clawd', '硬件指纹不符,清除凭证重新注册...'); + this._cfg.claw_id = null; + this._cfg.token = null; + config.save(this._cfg); + } else if (msg.msg && msg.msg.includes('invalid')) { + log.warn('clawd', '凭证无效,清除凭证重新注册...'); + this._cfg.claw_id = null; + this._cfg.token = null; + config.save(this._cfg); + } + break; + default: + log.warn('clawd', '未知消息类型:', msg.type); + } + } + + _onConnected(msg) { + const isNew = !this._cfg.claw_id; + + this._cfg.claw_id = msg.claw_id; + this._cfg.token = msg.token; + config.save(this._cfg); + + if (isNew) { + log.info('clawd', `注册成功!claw_id = ${msg.claw_id}`); + } + + this._applyStatus(msg); + + if (msg.frp && msg.frp.server && msg.frp.auth_token) { + this._frpc.start(msg.claw_id, msg.frp).catch(e => { + log.error('frpc', '启动失败:', e.message); + }); + } + + this._startHeartbeat(); + } + + _applyStatus(msg) { + if (msg.status === 'inactive') { + led.status.setSetup(); + led.display.showPin(msg.pin); + const id = String(this._cfg.claw_id || '').padEnd(6); + const pin = String(msg.pin || ''); + log.info('clawd', ''); + log.info('clawd', '╔════════════════════════════════════╗'); + log.info('clawd', `║ Claw ID : ${id} ║`); + log.info('clawd', `║ PIN 码 : ${pin} ║`); + log.info('clawd', '║ 请在网页前台「添加设备」中输入 ║'); + log.info('clawd', '╚════════════════════════════════════╝'); + log.info('clawd', ''); + log.info('clawd', '等待激活,心跳正常运行...'); + } else { + led.status.setApps(); + led.display.showTime(); + log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`); + } + } + + // ── 心跳 ──────────────────────────────────────────────────────────────────── + + _startHeartbeat() { + this._clearHeartbeat(); + this._sendHeartbeat(); + this._hbTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL_MS); + } + + async _sendHeartbeat() { + if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; + try { + this._hbCount++; + + // 每 30 次心跳(约 5 分钟)刷新一次 dashboard 信息 + if (this._hbCount % 30 === 0) { + const freshInfo = await getDashboardInfo().catch(() => null); + if (freshInfo && Object.keys(freshInfo).length > 0) { + this._dashInfo = freshInfo; + } + } + + // 每 METRICS_EVERY_N 次心跳(30 秒)采集一次指标,其余发轻量心跳 + const msg = { + type: 'heartbeat', + claw_id: this._cfg.claw_id, + token: this._cfg.token, + ...this._dashInfo, + }; + if (this._hbCount % METRICS_EVERY_N === 0) { + msg.metrics = await collect(); + } + this._send(msg); + } catch (e) { + log.error('clawd', '心跳发送失败:', e.message); + } + } + + _clearHeartbeat() { + if (this._hbTimer) { + clearInterval(this._hbTimer); + this._hbTimer = null; + } + } + + // ── 工具 ──────────────────────────────────────────────────────────────────── + + _send(obj) { + if (this._ws && this._ws.readyState === WebSocket.OPEN) { + this._ws.send(JSON.stringify(obj)); + } + } + + // ── systemd Watchdog ──────────────────────────────────────────────────────── + + _startSdNotify() { + if (!SD_NOTIFY_INTERVAL) return; + + log.debug('clawd', `systemd watchdog 启用,通知间隔 ${SD_NOTIFY_INTERVAL}ms`); + this._sdNotify('READY=1'); + this._sdTimer = setInterval(() => this._sdNotify('WATCHDOG=1'), SD_NOTIFY_INTERVAL); + } + + _sdNotify(msg) { + if (!process.env.NOTIFY_SOCKET) return; + try { + execFileSync('systemd-notify', ['--pid=' + process.pid, msg], { timeout: 2000 }); + } catch (_) { + // systemd-notify 不可用时静默忽略 + } + } +} + +module.exports = { ClawClient }; diff --git a/lib/led.js b/lib/led.js index 8d8f9c4..2119311 100644 --- a/lib/led.js +++ b/lib/led.js @@ -1,211 +1,211 @@ -'use strict'; - -const fs = require('fs'); -const { execSync } = require('child_process'); -const log = require('./logger'); - -/** - * 前面板指示灯控制 - * - * WiFi 灯 (b5): 1 = 亮, 0 = 灭(正逻辑) - * - WiFi 已连接且互联网畅通 → 常亮 - * - WiFi 连接中(正在尝试) → 闪烁 - * - WiFi 未连接 / 无互联网 → 熄灭 - * - * SETUP 灯 (b2): 0 = 亮, 1 = 灭(反逻辑,与 APPS 互斥) - * APPS 灯 (b1): 0 = 亮, 1 = 灭(反逻辑,与 SETUP 互斥) - * - claw 未激活 → SETUP 亮,APPS 灭 - * - claw 已激活 → APPS 亮,SETUP 灭 - */ - -const LED_PATH = process.env.CLAWD_LED_PATH || '/sys/devices/platform/openvfd/attr/b5'; -const SETUP_LED_PATH = '/sys/devices/platform/openvfd/attr/b1'; // 物理 SETUP 灯 -const APPS_LED_PATH = '/sys/devices/platform/openvfd/attr/b2'; // 物理 APPS 灯 -const BLINK_INTERVAL_MS = 500; // 闪烁间隔(ms) - -class WifiLed { - constructor() { - this._blinkTimer = null; - this._blinkState = false; - this._current = null; // 'on' | 'off' | 'blink' - } - - /** 常亮 */ - on() { - if (this._current === 'on') return; - this._stopBlink(); - this._write(1); - this._current = 'on'; - log.info('led', 'WiFi 指示灯 → 常亮'); - } - - /** 熄灭 */ - off() { - if (this._current === 'off') return; - this._stopBlink(); - this._write(0); - this._current = 'off'; - log.info('led', 'WiFi 指示灯 → 熄灭'); - } - - /** 闪烁(连接中) */ - blink(intervalMs = BLINK_INTERVAL_MS) { - if (this._current === 'blink') return; - this._stopBlink(); - this._blinkState = true; - this._write(1); - this._blinkTimer = setInterval(() => { - this._blinkState = !this._blinkState; - this._write(this._blinkState ? 1 : 0); - }, intervalMs); - this._current = 'blink'; - log.info('led', 'WiFi 指示灯 → 闪烁'); - } - - /** 释放资源,关灯 */ - destroy() { - this._stopBlink(); - this._write(0); - this._current = 'off'; - } - - // ── 内部 ────────────────────────────────────────────────────────────────── - - _stopBlink() { - if (this._blinkTimer) { - clearInterval(this._blinkTimer); - this._blinkTimer = null; - } - } - - _write(val) { - try { - fs.writeFileSync(LED_PATH, String(val)); - } catch (e) { - log.warn('led', `写入失败 (${LED_PATH}): ${e.message}`); - } - } -} - -// ── VFD 显示屏 ──────────────────────────────────────────────────────────────── - -const DISPLAY_PATH = '/sys/devices/platform/openvfd/attr/led'; - -/** - * VFD 显示屏控制。 - * #m3 手动模式,显示指定文字 - * #s1 系统时钟模式,显示当前时间 - */ -class Display { - constructor() { - this._blinkTimer = null; - } - - /** 网络断开 / AP 模式 → 显示 "AP " */ - showAP() { - this._stopBlink(); - this._write('#m3AP '); - log.info('display', '显示屏 → AP'); - } - - /** WS 连接中(失败次数 < 3)→ 显示 "Conn" 闪烁 */ - showConn() { - this._stopBlink(); - this._write('#m3Conn'); - log.info('display', '显示屏 → Conn(闪烁)'); - let visible = true; - const blink = () => { - visible = !visible; - this._write(visible ? '#m3Conn' : '#c1'); - this._blinkTimer = setTimeout(blink, visible ? 1000 : 500); - }; - this._blinkTimer = setTimeout(blink, 1000); - } - - /** 网络正常但 VPS 不可达 → 显示 "Err0" */ - showErr0() { - this._stopBlink(); - this._write('#m3Err0'); - log.info('display', '显示屏 → Err0'); - } - - /** 网络已连接 → 显示时间 */ - showTime() { - this._stopBlink(); - this._write('#s1'); - log.info('display', '显示屏 → 时间'); - } - - /** 未激活 + 连网 → 显示 PIN 码(4 位数字)并闪烁 */ - showPin(pin) { - this._stopBlink(); - const s = String(pin || '').padStart(4, '0').slice(-4); - this._write('#m2' + s); - log.info('display', `显示屏 → PIN: ${s}(闪烁)`); - // 亮 1s → 灭 0.5s → 循环 - let visible = true; - const blink = () => { - visible = !visible; - this._write(visible ? '#m2' + s : '#c1'); - this._blinkTimer = setTimeout(blink, visible ? 1000 : 500); - }; - this._blinkTimer = setTimeout(blink, 1000); - } - - _stopBlink() { - if (this._blinkTimer) { - clearTimeout(this._blinkTimer); - clearInterval(this._blinkTimer); - this._blinkTimer = null; - } - } - - _write(val) { - try { - execSync(`echo "${val}" | tee ${DISPLAY_PATH} > /dev/null`, { timeout: 3000 }); - } catch (e) { - log.warn('display', `写入失败: ${e.message}`); - } - } -} - -// ── SETUP / APPS 状态灯 ─────────────────────────────────────────────────────── - -/** - * SETUP 灯(b2)与 APPS 灯(b1)互斥控制。 - * 两灯均为反逻辑:写 0 = 亮,写 1 = 灭。 - */ -class StatusLed { - /** claw 未激活 → SETUP 亮,APPS 灭 */ - setSetup() { - this._write(SETUP_LED_PATH, 0); // SETUP 亮 - this._write(APPS_LED_PATH, 1); // APPS 灭 - log.info('led', '状态灯 → SETUP(未激活)'); - } - - /** claw 已激活 → APPS 亮,SETUP 灭 */ - setApps() { - this._write(SETUP_LED_PATH, 1); // SETUP 灭 - this._write(APPS_LED_PATH, 0); // APPS 亮 - log.info('led', '状态灯 → APPS(已激活)'); - } - - /** 两灯全灭(进程退出时调用) */ - off() { - this._write(SETUP_LED_PATH, 1); - this._write(APPS_LED_PATH, 1); - } - - _write(path, val) { - try { - fs.writeFileSync(path, String(val)); - } catch (e) { - log.warn('led', `写入失败 (${path}): ${e.message}`); - } - } -} - -// 全局单例,整个进程共用 -module.exports = new WifiLed(); -module.exports.status = new StatusLed(); -module.exports.display = new Display(); +'use strict'; + +const fs = require('fs'); +const { execSync } = require('child_process'); +const log = require('./logger'); + +/** + * 前面板指示灯控制 + * + * WiFi 灯 (b5): 1 = 亮, 0 = 灭(正逻辑) + * - WiFi 已连接且互联网畅通 → 常亮 + * - WiFi 连接中(正在尝试) → 闪烁 + * - WiFi 未连接 / 无互联网 → 熄灭 + * + * SETUP 灯 (b2): 0 = 亮, 1 = 灭(反逻辑,与 APPS 互斥) + * APPS 灯 (b1): 0 = 亮, 1 = 灭(反逻辑,与 SETUP 互斥) + * - claw 未激活 → SETUP 亮,APPS 灭 + * - claw 已激活 → APPS 亮,SETUP 灭 + */ + +const LED_PATH = process.env.CLAWD_LED_PATH || '/sys/devices/platform/openvfd/attr/b5'; +const SETUP_LED_PATH = '/sys/devices/platform/openvfd/attr/b1'; // 物理 SETUP 灯 +const APPS_LED_PATH = '/sys/devices/platform/openvfd/attr/b2'; // 物理 APPS 灯 +const BLINK_INTERVAL_MS = 500; // 闪烁间隔(ms) + +class WifiLed { + constructor() { + this._blinkTimer = null; + this._blinkState = false; + this._current = null; // 'on' | 'off' | 'blink' + } + + /** 常亮 */ + on() { + if (this._current === 'on') return; + this._stopBlink(); + this._write(1); + this._current = 'on'; + log.info('led', 'WiFi 指示灯 → 常亮'); + } + + /** 熄灭 */ + off() { + if (this._current === 'off') return; + this._stopBlink(); + this._write(0); + this._current = 'off'; + log.info('led', 'WiFi 指示灯 → 熄灭'); + } + + /** 闪烁(连接中) */ + blink(intervalMs = BLINK_INTERVAL_MS) { + if (this._current === 'blink') return; + this._stopBlink(); + this._blinkState = true; + this._write(1); + this._blinkTimer = setInterval(() => { + this._blinkState = !this._blinkState; + this._write(this._blinkState ? 1 : 0); + }, intervalMs); + this._current = 'blink'; + log.info('led', 'WiFi 指示灯 → 闪烁'); + } + + /** 释放资源,关灯 */ + destroy() { + this._stopBlink(); + this._write(0); + this._current = 'off'; + } + + // ── 内部 ────────────────────────────────────────────────────────────────── + + _stopBlink() { + if (this._blinkTimer) { + clearInterval(this._blinkTimer); + this._blinkTimer = null; + } + } + + _write(val) { + try { + fs.writeFileSync(LED_PATH, String(val)); + } catch (e) { + log.warn('led', `写入失败 (${LED_PATH}): ${e.message}`); + } + } +} + +// ── VFD 显示屏 ──────────────────────────────────────────────────────────────── + +const DISPLAY_PATH = '/sys/devices/platform/openvfd/attr/led'; + +/** + * VFD 显示屏控制。 + * #m3 手动模式,显示指定文字 + * #s1 系统时钟模式,显示当前时间 + */ +class Display { + constructor() { + this._blinkTimer = null; + } + + /** 网络断开 / AP 模式 → 显示 "AP " */ + showAP() { + this._stopBlink(); + this._write('#m3AP '); + log.info('display', '显示屏 → AP'); + } + + /** WS 连接中(失败次数 < 3)→ 显示 "Conn" 闪烁 */ + showConn() { + this._stopBlink(); + this._write('#m3Conn'); + log.info('display', '显示屏 → Conn(闪烁)'); + let visible = true; + const blink = () => { + visible = !visible; + this._write(visible ? '#m3Conn' : '#c1'); + this._blinkTimer = setTimeout(blink, visible ? 1000 : 500); + }; + this._blinkTimer = setTimeout(blink, 1000); + } + + /** 网络正常但 VPS 不可达 → 显示 "Err0" */ + showErr0() { + this._stopBlink(); + this._write('#m3Err0'); + log.info('display', '显示屏 → Err0'); + } + + /** 网络已连接 → 显示时间 */ + showTime() { + this._stopBlink(); + this._write('#s1'); + log.info('display', '显示屏 → 时间'); + } + + /** 未激活 + 连网 → 显示 PIN 码(4 位数字)并闪烁 */ + showPin(pin) { + this._stopBlink(); + const s = String(pin || '').padStart(4, '0').slice(-4); + this._write('#m2' + s); + log.info('display', `显示屏 → PIN: ${s}(闪烁)`); + // 亮 1s → 灭 0.5s → 循环 + let visible = true; + const blink = () => { + visible = !visible; + this._write(visible ? '#m2' + s : '#c1'); + this._blinkTimer = setTimeout(blink, visible ? 1000 : 500); + }; + this._blinkTimer = setTimeout(blink, 1000); + } + + _stopBlink() { + if (this._blinkTimer) { + clearTimeout(this._blinkTimer); + clearInterval(this._blinkTimer); + this._blinkTimer = null; + } + } + + _write(val) { + try { + execSync(`echo "${val}" | tee ${DISPLAY_PATH} > /dev/null`, { timeout: 3000 }); + } catch (e) { + log.warn('display', `写入失败: ${e.message}`); + } + } +} + +// ── SETUP / APPS 状态灯 ─────────────────────────────────────────────────────── + +/** + * SETUP 灯(b2)与 APPS 灯(b1)互斥控制。 + * 两灯均为反逻辑:写 0 = 亮,写 1 = 灭。 + */ +class StatusLed { + /** claw 未激活 → SETUP 亮,APPS 灭 */ + setSetup() { + this._write(SETUP_LED_PATH, 0); // SETUP 亮 + this._write(APPS_LED_PATH, 1); // APPS 灭 + log.info('led', '状态灯 → SETUP(未激活)'); + } + + /** claw 已激活 → APPS 亮,SETUP 灭 */ + setApps() { + this._write(SETUP_LED_PATH, 1); // SETUP 灭 + this._write(APPS_LED_PATH, 0); // APPS 亮 + log.info('led', '状态灯 → APPS(已激活)'); + } + + /** 两灯全灭(进程退出时调用) */ + off() { + this._write(SETUP_LED_PATH, 1); + this._write(APPS_LED_PATH, 1); + } + + _write(path, val) { + try { + fs.writeFileSync(path, String(val)); + } catch (e) { + log.warn('led', `写入失败 (${path}): ${e.message}`); + } + } +} + +// 全局单例,整个进程共用 +module.exports = new WifiLed(); +module.exports.status = new StatusLed(); +module.exports.display = new Display(); diff --git a/lib/network.js b/lib/network.js index a89114b..19fca75 100644 --- a/lib/network.js +++ b/lib/network.js @@ -2,6 +2,7 @@ const { execSync } = require('child_process'); const fs = require('fs'); +const os = require('os'); const log = require('./logger'); const AP_SSID_PREFIX = 'ClawBox-'; @@ -223,6 +224,29 @@ function isWifiStaConnected() { return false; } +/** + * 获取本机所有非回环 IPv4 地址,逗号拼接返回 + * 例:'192.168.1.100' 或 '192.168.1.100,10.0.0.5' + */ +function getLocalIps() { + try { + const ifaces = os.networkInterfaces(); + const ips = []; + for (const [name, addrs] of Object.entries(ifaces)) { + if (!addrs) continue; + for (const addr of addrs) { + if (addr.family === 'IPv4' && !addr.internal) { + ips.push(addr.address); + } + } + } + return ips.length > 0 ? ips.join(',') : null; + } catch (e) { + log.warn('network', '获取本机 IP 失败:', e.message); + return null; + } +} + module.exports = { hasInternet, hasWiredCarrier, @@ -234,4 +258,5 @@ module.exports = { startAP, stopAP, AP_IP, + getLocalIps, }; diff --git a/lib/provisioning.js b/lib/provisioning.js index b6ced86..4dfde9b 100644 --- a/lib/provisioning.js +++ b/lib/provisioning.js @@ -1,240 +1,240 @@ -'use strict'; - -const EventEmitter = require('events'); -const log = require('./logger'); -const { hasInternet, hasSavedWifiConnection, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, AP_IP } = require('./network'); -const { DnsHijack } = require('./dns-hijack'); -const { CaptiveServer } = require('./captive-server'); -const led = require('./led'); - -const MONITOR_INTERVAL_MS = 30_000; -const BOOT_WAIT_MAX_MS = 20_000; // 等待 NM 自动连接的最大时间 -const BOOT_POLL_MS = 2_000; // 轮询间隔 - -/** - * AP 常驻配网管理器。 - * - * 规则: - * - 启动时:有已保存 WiFi 配置 → 等 NM 自动连接(最多 20 秒) - * - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页 - * - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP - * - 运行中 WiFi 断开 → 自动重新开 AP - * - WiFi 已连接 → AP 关闭 - */ -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; - } - - /** 是否正处于 AP 模式(WiFi 热点广播中) */ - isApMode() { return this._state === 'ap'; } - - async start() { - led.off(); // 初始状态:灭灯 - - // WiFi STA 已连接 → 直接进入 STA 模式 - if (isWifiStaConnected()) { - this._state = 'sta'; - log.info('provision', 'WiFi STA 已连接,AP 不启动'); - this._emitNetworkReady(); - this._startMonitor(); - return; - } - - // 有线有网 → 立即通知 WS 连接,后台异步设置 AP 供 WiFi 配网 - if (hasInternet()) { - log.info('provision', '有线网络就绪,立即启动 WS,AP 后台准备中...'); - this._emitNetworkReady(); - setTimeout(() => { - this._enterAP(); - this._startMonitor(); - }, 0); - return; - } - - // 无网:有已保存的 WiFi 配置 → 等 NM 自动连接(重启场景) - if (hasSavedWifiConnection()) { - log.info('provision', '发现已保存的 WiFi 配置,等待 NetworkManager 自动连接...'); - led.blink(); // 等待自动重连期间闪烁 - const connected = await this._waitForWifiConnect(); - if (connected) { - this._state = 'sta'; - log.info('provision', 'WiFi 自动连接成功,AP 不启动'); - this._emitNetworkReady(); - this._startMonitor(); - return; - } - log.warn('provision', 'WiFi 自动连接超时,启动 AP'); - } - - // 没有已保存 WiFi 或等待超时 → 开 AP - this._enterAP(); - this._startMonitor(); - - if (hasInternet()) { - this._emitNetworkReady(); - } - } - - _emitNetworkReady() { - if (hasInternet()) { - // WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮) - if (this._state === 'sta') led.on(); - this.emit('network-ready'); - } else { - log.warn('provision', 'hasInternet() 返回 false,LED 保持熄灭'); - } - } - - /** - * 轮询等待 NM 自动连接 WiFi,最多等 BOOT_WAIT_MAX_MS - */ - _waitForWifiConnect() { - return new Promise(resolve => { - let elapsed = 0; - const timer = setInterval(() => { - elapsed += BOOT_POLL_MS; - if (isWifiStaConnected()) { - clearInterval(timer); - resolve(true); - } else if (elapsed >= BOOT_WAIT_MAX_MS) { - clearInterval(timer); - resolve(false); - } - }, BOOT_POLL_MS); - }); - } - - stop() { - this._stopMonitor(); - this._stopAll(); - this._state = 'idle'; - led.destroy(); // 停止时关灯、释放闪烁定时器 - } - - // ── 进入 AP 模式 ───────────────────────────────────────────────────────── - - _enterAP() { - if (this._state === 'ap') return; - - led.off(); // AP 模式:WiFi 未连接,灭灯 - if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP,有线时等 WS 连接后再定 - - try { - // AP 模式下无法扫描 WiFi,必须在开 AP 之前扫描并缓存 - log.info('provision', '扫描周边 WiFi...'); - this._cachedWifiList = scanWifi(); - log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络`); - - // 写 DNS 劫持配置(NM 启动热点时加载) - this._dns = new DnsHijack(); - this._dns.start('wlan0', AP_IP); - - const ap = startAP(this._clawId); - - this._server = new CaptiveServer({ - clawId: this._clawId, - cachedWifiList: this._cachedWifiList, - 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}`); - led.blink(); // 正在连接 → 闪烁 - - this._stopAPServices(); - - const result = connectWifi(ssid, password); - - if (result.success) { - this._state = 'sta'; - log.info('provision', `WiFi 已连接: ${ssid}`); - led.on(); // 连接成功 → 常亮 - this.emit('network-ready'); - return result; - } - - log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`); - this._enterAP(); // _enterAP 内部会调用 led.off() - 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(); // 内部调用 led.off() - return; - } - - if (this._state === 'ap' && wifiUp) { - log.info('provision', 'WiFi 已外部连接,关闭 AP'); - this._stopAPServices(); - this._state = 'sta'; - this.emit('network-ready'); - } - - // WiFi 灯:只在 STA 模式下反映互联网连通性;AP 模式下始终熄灭 - if (this._state === 'sta') { - if (hasInternet()) { - led.on(); - } else { - led.off(); // WiFi 已连接但无互联网 - } - } - // AP 模式下 led 已在 _enterAP() 中熄灭,无需重复操作 - }, 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(); - } -} - -module.exports = { ProvisionManager }; +'use strict'; + +const EventEmitter = require('events'); +const log = require('./logger'); +const { hasInternet, hasSavedWifiConnection, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, AP_IP } = require('./network'); +const { DnsHijack } = require('./dns-hijack'); +const { CaptiveServer } = require('./captive-server'); +const led = require('./led'); + +const MONITOR_INTERVAL_MS = 30_000; +const BOOT_WAIT_MAX_MS = 20_000; // 等待 NM 自动连接的最大时间 +const BOOT_POLL_MS = 2_000; // 轮询间隔 + +/** + * AP 常驻配网管理器。 + * + * 规则: + * - 启动时:有已保存 WiFi 配置 → 等 NM 自动连接(最多 20 秒) + * - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页 + * - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP + * - 运行中 WiFi 断开 → 自动重新开 AP + * - WiFi 已连接 → AP 关闭 + */ +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; + } + + /** 是否正处于 AP 模式(WiFi 热点广播中) */ + isApMode() { return this._state === 'ap'; } + + async start() { + led.off(); // 初始状态:灭灯 + + // WiFi STA 已连接 → 直接进入 STA 模式 + if (isWifiStaConnected()) { + this._state = 'sta'; + log.info('provision', 'WiFi STA 已连接,AP 不启动'); + this._emitNetworkReady(); + this._startMonitor(); + return; + } + + // 有线有网 → 立即通知 WS 连接,后台异步设置 AP 供 WiFi 配网 + if (hasInternet()) { + log.info('provision', '有线网络就绪,立即启动 WS,AP 后台准备中...'); + this._emitNetworkReady(); + setTimeout(() => { + this._enterAP(); + this._startMonitor(); + }, 0); + return; + } + + // 无网:有已保存的 WiFi 配置 → 等 NM 自动连接(重启场景) + if (hasSavedWifiConnection()) { + log.info('provision', '发现已保存的 WiFi 配置,等待 NetworkManager 自动连接...'); + led.blink(); // 等待自动重连期间闪烁 + const connected = await this._waitForWifiConnect(); + if (connected) { + this._state = 'sta'; + log.info('provision', 'WiFi 自动连接成功,AP 不启动'); + this._emitNetworkReady(); + this._startMonitor(); + return; + } + log.warn('provision', 'WiFi 自动连接超时,启动 AP'); + } + + // 没有已保存 WiFi 或等待超时 → 开 AP + this._enterAP(); + this._startMonitor(); + + if (hasInternet()) { + this._emitNetworkReady(); + } + } + + _emitNetworkReady() { + if (hasInternet()) { + // WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮) + if (this._state === 'sta') led.on(); + this.emit('network-ready'); + } else { + log.warn('provision', 'hasInternet() 返回 false,LED 保持熄灭'); + } + } + + /** + * 轮询等待 NM 自动连接 WiFi,最多等 BOOT_WAIT_MAX_MS + */ + _waitForWifiConnect() { + return new Promise(resolve => { + let elapsed = 0; + const timer = setInterval(() => { + elapsed += BOOT_POLL_MS; + if (isWifiStaConnected()) { + clearInterval(timer); + resolve(true); + } else if (elapsed >= BOOT_WAIT_MAX_MS) { + clearInterval(timer); + resolve(false); + } + }, BOOT_POLL_MS); + }); + } + + stop() { + this._stopMonitor(); + this._stopAll(); + this._state = 'idle'; + led.destroy(); // 停止时关灯、释放闪烁定时器 + } + + // ── 进入 AP 模式 ───────────────────────────────────────────────────────── + + _enterAP() { + if (this._state === 'ap') return; + + led.off(); // AP 模式:WiFi 未连接,灭灯 + if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP,有线时等 WS 连接后再定 + + try { + // AP 模式下无法扫描 WiFi,必须在开 AP 之前扫描并缓存 + log.info('provision', '扫描周边 WiFi...'); + this._cachedWifiList = scanWifi(); + log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络`); + + // 写 DNS 劫持配置(NM 启动热点时加载) + this._dns = new DnsHijack(); + this._dns.start('wlan0', AP_IP); + + const ap = startAP(this._clawId); + + this._server = new CaptiveServer({ + clawId: this._clawId, + cachedWifiList: this._cachedWifiList, + 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}`); + led.blink(); // 正在连接 → 闪烁 + + this._stopAPServices(); + + const result = connectWifi(ssid, password); + + if (result.success) { + this._state = 'sta'; + log.info('provision', `WiFi 已连接: ${ssid}`); + led.on(); // 连接成功 → 常亮 + this.emit('network-ready'); + return result; + } + + log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`); + this._enterAP(); // _enterAP 内部会调用 led.off() + 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(); // 内部调用 led.off() + return; + } + + if (this._state === 'ap' && wifiUp) { + log.info('provision', 'WiFi 已外部连接,关闭 AP'); + this._stopAPServices(); + this._state = 'sta'; + this.emit('network-ready'); + } + + // WiFi 灯:只在 STA 模式下反映互联网连通性;AP 模式下始终熄灭 + if (this._state === 'sta') { + if (hasInternet()) { + led.on(); + } else { + led.off(); // WiFi 已连接但无互联网 + } + } + // AP 模式下 led 已在 _enterAP() 中熄灭,无需重复操作 + }, 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(); + } +} + +module.exports = { ProvisionManager }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3d00e13 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,148 @@ +{ + "name": "clawd", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clawd", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "ssh2": "^1.17.0", + "systeminformation": "^5.25.0", + "ws": "^8.18.0" + }, + "bin": { + "clawd": "bin/clawd.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/nan": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.1.tgz", + "integrity": "sha512-vodKprLlmaKmraa9E/TxHQwpH4eKYTJbLdeQE49pb9GOmrLs68zESjJu0LQOz1W6JwJmftOWD5Ls4dpd/elQtQ==", + "license": "MIT", + "optional": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/systeminformation": { + "version": "5.31.4", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.4.tgz", + "integrity": "sha512-lZppDyQx91VdS5zJvAyGkmwe+Mq6xY978BDUG2wRkWE+jkmUF5ti8cvOovFQoN5bvSFKCXVkyKEaU5ec3SJiRg==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json index ea7ea82..96cbd50 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,20 @@ "scripts": { "start": "node bin/clawd.js" }, - "keywords": ["claw", "iot", "websocket", "daemon"], + "keywords": [ + "claw", + "iot", + "websocket", + "daemon" + ], "author": "stswangzhiping", "license": "MIT", "engines": { "node": ">=18.0.0" }, "dependencies": { - "ws": "^8.18.0", - "systeminformation": "^5.25.0" + "ssh2": "^1.17.0", + "systeminformation": "^5.25.0", + "ws": "^8.18.0" } }