Sync 2026-03-14 19:52:12

This commit is contained in:
github-actions[bot] 2026-03-14 19:52:12 +08:00
parent aa7946ff93
commit 80bd1ecded
43 changed files with 7870 additions and 35 deletions

View File

@ -5,12 +5,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=cloudreve
PKG_VERSION:=4.14.1
PKG_RELEASE:=1
PKG_VERSION:=4.15.0
PKG_RELEASE:=2
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/cloudreve/Cloudreve.git
PKG_SOURCE_VERSION:=95865add542f5b7ac0e774b3232ee01d72ea7daa
PKG_SOURCE_VERSION:=006680c5df8b529ab52656133fc220ad73ab57ff
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-3.0-only

View File

@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=gowebdav
PKG_VERSION:=5.11.1
PKG_RELEASE:=1
PKG_VERSION:=5.11.2
PKG_RELEASE:=2
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/hacdias/webdav/tar.gz/v$(PKG_VERSION)?

View File

@ -11,8 +11,8 @@ PKG_ARCH_ISTOREENHANCE:=$(ARCH)
PKG_NAME:=istoreenhance
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=0.7.0
PKG_RELEASE:=1
PKG_SOURCE_DATE:=0.7.3
PKG_RELEASE:=2
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
ARCH_HEXCODE=8664

23
luci-app-openclaw/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# Node
node_modules/
*.tgz
# Build artifacts
dist/
*.ipk
*.run
# Offline bundle cache (generated by scripts/download_deps.sh)
# Runtime data (contains sensitive API keys / tokens)
opt/
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
*.swp
*.swo
*~

View File

@ -0,0 +1,348 @@
# Changelog
本项目所有重大变更都将记录在此文件中。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
## [1.0.15] - 2026-03-13
### 修复
- **QQBot 插件 3 层死锁修复**: 解决插件安装后因 uid 权限→安全策略阻止→配置校验失败的连锁问题
- 自动检测插件 blocked/loaded/目录存在 3 种状态
- 插件安装后自动 `chown root:root` 修复权限
- 安装失败但目录存在时不再阻断配置流程
### 新增
- **覆盖安装防护**: 离线安装器在覆盖安装前先停止已有服务,避免文件被占用
- **离线 .run 安装包**: 构建包含 Node.js + OpenClaw + LuCI 插件的全合一自解压包,用户**无需联网**即可完成安装
- **musl 架构支持**: 离线包支持 x86_64-musl、aarch64-musl 两种架构 (OpenWrt/iStoreOS 均使用 musl)
- **依赖预下载脚本** (`scripts/download_deps.sh`): 在构建机上预下载所有离线依赖
- **node_modules 精简**: 自动删除文档、测试、TypeScript 源码等非必要文件,减小 30%+ 体积
- **磁盘空间预检查**: 安装前检测可用空间是否满足 500MB 最低要求
- **架构/libc 自动检测**: 安装时自动校验当前设备是否匹配安装包架构
### 文档
- **README**: 添加离线安装方式(无需联网),更新目录结构
### 变更
- **离线包不依赖 curl/openssl/git**: 离线安装模式下 opkg 注册的依赖简化为 luci-compat + luci-base
## [1.0.14] - 2026-03-12
### 备份管理增强 & QQ 机器人支持
#### 新增
- **备份列表可视化**: LuCI「💾 备份/恢复」对话框现在展示所有备份的结构化列表:
- 📄 仅配置 / 📦 完整备份 类型标签(从 manifest.json 读取 `onlyConfig` 字段精确判断)
- 备份时间、文件大小
- 每个备份支持**单独恢复**和**删除**操作
- 创建/删除备份后列表自动刷新
- **备份删除 API**: Controller 新增 `action=delete` 操作(含路径穿越安全校验)
- **QQ 机器人配置**: `oc-config.sh` 渠道菜单新增「QQ 机器人」选项(选项 1推荐国内用户支持
- 自动安装 `@tencent-connect/openclaw-qqbot` 插件
- App ID / App Secret 输入校验
- 通过 `openclaw channels add` CLI 一键配置
- **消息渠道状态显示**: 状态面板新增「消息渠道」行自动检测已配置的渠道QQ、Telegram、Discord、飞书、Slack
#### 变更
- **备份恢复**: 从"从最新备份恢复"改为在列表中选择任意备份进行恢复
- **描述文本**: 各页面描述新增"QQ"渠道说明
#### 修复
- **JS 语法错误导致所有按钮失效**: 备份对话框 HTML 被错误地插入 `<script>` 标签内部,导致 JavaScript 语法错误。修复: 在对话框 HTML 前后正确分割 `<script>` 标签
## [1.0.13] - 2026-03-12
### 适配 OpenClaw v2026.3.8 & 新增备份/恢复功能
#### 新增
- **配置备份/恢复**: LuCI 基本设置页「💾 备份/恢复」按钮,弹出对话框支持:
- 📄 仅配置文件备份(~2KB包含模型、渠道、插件设置
- 📦 完整备份(配置 + 状态数据)
- 🔄 从最新备份恢复配置(自动重启服务)
- **Shell 备份菜单**: `oc-config.sh` 主菜单新增「8) 💾 备份/还原配置」,支持:
- 创建仅配置 / 完整备份
- 验证备份完整性 (`openclaw backup verify`)
- 列出已有备份文件
- 从最新备份恢复配置(交互确认 + 自动重启)
- **命令行备份**: `oc-config.sh --backup` 快捷参数,适合 cron 定时任务
- **OpenClaw 版本显示**: 状态面板新增 OpenClaw 版本行(从 package.json 读取)
#### 修复
- **备份文件路径**: 备份文件统一保存到 `~/.openclaw/backups/` 目录OpenClaw CLI 默认输出到 CWD
- **完整备份失败**: 含未注册插件的 channel ID 时,`backup create` 因 config invalid 失败;改用 `--no-include-workspace` 跳过工作区发现
#### 变更
- **OC_TESTED_VERSION**: 2026.3.2 → 2026.3.8
- **Control UI iframe 资源查找**: `patch_iframe_headers()` 扩展搜索路径,覆盖 `$NODE_BASE/lib/node_modules`、pnpm 全局存储,并使用 `readlink -f` 解析 v2026.3.8 新增的符号链接资源路径
- **Gateway 入口查找**: `get_oc_entry()` 新增 `readlink -f` 符号链接解析,兼容 v2026.3.8 的 bundled 插件优先级调整
- **配置同步清理**: `sync_uci_to_json()` 自动删除 v2026.3.7/3.8 已废弃的配置字段:
- `gateway.controlUi.dangerouslyAllowCors`
- `gateway.controlUi.dangerouslyAllowRemoteConnections`
- `commands.ownerDisplay`
#### 兼容性说明
- **v2026.3.7 BREAKING CHANGE**: `gateway.auth.mode` 必须显式指定(不再有默认值)。本插件已在 v1.0.3 起始终写入 `"mode": "token"`,无需用户操作
- **v2026.3.8**: Control UI 资源分发改为符号链接方式,本版本已完整适配
## [1.0.12] - 2026-03-11
### 移除 OpenClaw 版本检测 & 修复 BusyBox tar 兼容性
#### 变更
- **「检测升级」按钮**: 不再检查 OpenClaw (npm) 版本,仅检查插件 (luci-app-openclaw) 是否有新版本
- **「检测升级」显示更新内容**: 检测到新插件版本时,直接展示该版本的 Release Notes告知用户升级了什么
- **状态面板**: 移除「OpenClaw」版本显示行保留 Node.js 和插件版本
- **内部清理**: 移除 `get_openclaw_version()` 函数、`action_do_update`、`action_upgrade_log` 等已废弃后端 API
#### 修复
- **BusyBox tar 兼容性** (#18, #30): `openclaw-env` 安装 Node.js 时的解压命令优先使用 GNU tar `--strip-components=1`;若不支持则自动回退到 BusyBox tar 兼容方式(解压到临时目录后移动),无需用户手动安装 `tar`
- **插件升级网络错误提示**: 下载后检测文件内容,若 GitHub 返回 `Not Found`GFW 拦截等情况)则显示明确提示,并附手动下载链接
## [1.0.11] - 2026-03-09
### 修复 Telegram 配对后无法使用的严重 Bug
#### 修复
- **Telegram 配置流程**: Token 保存后强制重启网关(不再可选),确保 Bot Token 立即生效后再进入配对流程
- **Telegram 配对流程**: 配对成功后自动重启网关,确保配对关系立即生效,用户可以直接开始对话
## [1.0.10] - 2026-03-08
### 新增腾讯云大模型 Coding Plan 套餐支持
#### 新增
- **腾讯云 Coding Plan 套餐**: 新增菜单选项 13支持一键配置腾讯云大模型 Coding Plan 套餐
- Base URL: `https://api.lkeap.cloud.tencent.com/coding/v3`Provider: `lkeap`
- 支持全部 8 个套餐模型: tc-code-latest (智能路由)、hunyuan-t1、hunyuan-turbos、hunyuan-2.0-thinking、hunyuan-2.0-instruct、glm-5、kimi-k2.5、minimax-m2.5
- 按类别分组展示: 智能推荐 / 推理模型 / 旗舰模型 / 第三方模型
#### 修复
- **Coding Plan 配置信息修正**: 订阅地址更正为官方页面,移除不必要的 Base URL 显示
## [1.0.9] - 2026-03-08
### 插件一键升级 & 百炼模型列表扩充
#### 新增
- **插件一键升级**: LuCI 界面"检测升级"发现新版后,可直接点击"⬆️ 升级插件"按钮完成在线升级
- 后台自动从 GitHub Releases 下载 `.run` 安装包并执行
- 实时升级日志显示,带容错处理 (安装过程替换 LuCI 文件导致 API 暂时不可用时自动判定成功)
- 同时保留"📥 手动下载"链接作为备选
- **百炼按量付费模型列表扩充**: 从 4 个模型扩充至 16 个,按类别分组显示
- 千问商业版: qwen-max、qwen-plus (Qwen3.5)、qwen-flash (Qwen3.5)、qwen-turbo、qwen-long (1000万Token上下文)
- 千问Coder: qwen3-coder-plus (100万上下文)、qwen3-coder-flash
- 推理模型: qwq-plus
- 千问开源版: qwen3-235b-a22b、qwen3-32b、qwen3-30b-a3b
- 第三方模型: deepseek-r1、deepseek-v3、kimi-k2.5、glm-5、MiniMax-M2.5
#### 修复
- **CBI 底部按钮未隐藏**: "保存并应用/保存/复位"按钮在基本设置页仍然显示
- 根因: `m.submit = false``m.reset = false` 不被 CBI 框架识别
- 修复: 改为 `m.pageaction = false` (dispatcher.lua 第 294 行检查的正确属性)
- **插件升级后配置管理无法连接**: 升级后 PTY WebSocket 一直转圈 "等待服务就绪"
- 根因: `.run` 安装器覆盖 `/etc/config/openclaw` 导致 `pty_token` 丢失PTY 认证失败
- 修复: 升级时保留用户 UCI 配置 (仅首次安装部署默认配置);安装后自动重启 PTY 服务
## [1.0.8] - 2026-03-07
### 修复第三方模型配置导致 Gateway 崩溃 & 新增 Coding Plan 套餐支持
#### 修复
- **EACCES 权限错误** (#8): Web PTY 以 root 运行,创建的目录 (`sessions/`, `auth-profiles.json` 等) 归 root 所有Gateway 以 `openclaw` 用户运行时无法写入,报 `EACCES: permission denied, mkdir`
- `oc-config.sh`: `auth_set_apikey`、`json_set`、备份目录创建后均执行 `chown openclaw:openclaw`
- `web-pty.js`: 子进程退出时自动 `chown -R openclaw:openclaw` 整个数据目录
- **第三方模型 404/405 错误** (#11, #13, #14, #15): DeepSeek、xAI Grok、Groq 等 OpenAI 兼容提供商配置后返回 404/405
- 根因: 这些提供商缺少 `register_custom_provider` 调用,未写入 `baseUrl` 导致 Gateway 请求发到错误地址
- 修复: 为 DeepSeek、xAI、Groq 快速配置补充 `register_custom_provider` 调用
- **API 类型错误导致 Gateway 崩溃**: `register_custom_provider``api` 值设为 `openai-chat-completions`,但该值在 OpenClaw v2026.3.2 中不存在
- 正确值为 `openai-completions`,错误值会导致 Gateway 启动时 JSON schema 校验失败,进入 crash loop
- 有效 api 类型: `openai-completions` | `openai-responses` | `openai-codex-responses` | `anthropic-messages` | `google-generative-ai` | `github-copilot` | `bedrock-converse-stream` | `ollama`
#### 新增
- **阿里云 Coding Plan 套餐快速配置**: 千问配置菜单新增 Coding Plan 选项 (选项 c默认推荐)
- Provider: `bailian`Base URL: `https://coding.dashscope.aliyuncs.com/v1`
- 一键注册套餐内全部模型: qwen3.5-plus、qwen3-coder-plus、qwen3-coder-next、qwen3-max、MiniMax-M2.5、glm-5、glm-4.7、kimi-k2.5
- contextWindow / maxTokens 按阿里云官方文档设定 (最大 100万上下文)
- 参考: [阿里云 Coding Plan 文档](https://help.aliyun.com/zh/model-studio/openclaw-coding-plan)
#### 变更
- **千问配置菜单重构**: 从 2 种模式扩展为 3 种
- `a)` Qwen Portal OAuth (官方向导)
- `b)` 百炼按量付费 API Key (`sk-xxx` + `dashscope.aliyuncs.com`)
- `c)` Coding Plan 套餐 (`sk-sp-xxx` + `coding.dashscope.aliyuncs.com`) ★ 默认推荐
- 明确提示两套 API Key / Base URL 不互通
- **`register_custom_provider` 增强**: 新增可选参数 `context_window` (默认 128000) 和 `max_tokens` (默认 32000)
## [1.0.7] - 2026-03-06
### 修复依赖包名错误 & 补充 GNU tar 依赖 (感谢 [@esir](https://github.com/esirplayground) 建议)
#### 修复
- **依赖包名修正**: `util-linux-script` 在 OpenWrt/iStoreOS 软件源中不存在,正确的包名是 `script-utils` (提供 `/usr/bin/script` 命令)。此错误会导致通过 iStore/opkg 安装插件时依赖解析失败
- **补充 GNU tar 依赖**: `openclaw-env` 安装脚本使用 `tar --strip-components=1` 解压 Node.js但 busybox 内置的 tar 不支持该参数。新增 `tar` (GNU tar) 为必需依赖,确保解压操作正常
#### 变更
- `Makefile`: `LUCI_DEPENDS``+util-linux-script``+script-utils +tar`
- `scripts/build_ipk.sh`: 同步更新 Depends 字段
- `scripts/build_run.sh`: 同步更新 Depends 字段
### 修复重启服务时 Gateway crash loop 端口冲突
#### 修复
- **端口冲突 crash loop**: OpenClaw gateway 的架构是主进程 (`openclaw`) fork 出子进程 (`openclaw-gateway`) 监听端口restart 时 procd 只杀主进程,子进程退出慢导致新实例端口冲突反复崩溃
- `stop_service()`: 从空函数改为主动清理 `openclaw-gateway` 子进程 + 等待端口释放 (最长 8 秒)
- `start_service()`: 启动前预检查端口,清理残留进程后再注册 procd 实例
- `reload_service()`: stop 和 start 之间增加等待确保内核回收端口
- LuCI controller: restart 改为先同步 stop 等端口释放,再后台 start
- procd respawn 间隔从 5s → 10s降低连续端口冲突概率
## [1.0.6] - 2026-03-06
### 修复 Docker 环境下安装失败 "mkdir: can't create directory: Directory not empty"
#### 修复
- **OverlayFS 兼容性**: iStoreOS/OpenWrt 安装 Docker 后Docker 的 bind mount (`/overlay/upper/opt/docker`) 导致 OverlayFS 合并视图中 `/opt` 目录完全不可写,所有 `mkdir`/`touch`/`ln` 操作均报 "Directory not empty"
- 新增 `_oc_fix_opt()` 检测函数,自动检测 `/opt` 是否可写
- 不可写时自动执行 `mount --bind /overlay/upper/opt /opt` 绕过 OverlayFS 冲突
- 三重保障: `uci-defaults` (首次安装)、`init.d` (每次开机)、`openclaw-env` (手动操作) 均包含修复逻辑
- 正常系统 (无 Docker) 不受影响,检测到可写后直接跳过
- **openclaw-env**: 新增 `ensure_mkdir()` 安全目录创建函数,替代所有裸 `mkdir -p` 调用
## [1.0.5] - 2026-03-05
### 修复配置管理页面 "spawn script ENOENT" 启动失败 (#3, #4)
#### 修复
- **Web PTY 启动失败**: `web-pty.js` 硬编码依赖 `script` 命令 (来自 `util-linux-script`),但部分 OpenWrt 固件默认不包含该命令,导致 `spawn script ENOENT` 错误并无限循环重启
- 新增 `script` 命令自动检测,不存在时回退到 `sh` 直接执行 `oc-config.sh`
- 新增连续失败计数器 (最多 5 次),防止启动失败时的无限重试循环
- 失败时向用户终端显示明确的错误提示和修复命令
- **Makefile 依赖补全**: `LUCI_DEPENDS` 新增 `+util-linux-script`,确保新安装自动拉取 `script` 命令
## [1.0.4] - 2026-03-05
### 适配 OpenClaw 2026.3.2
#### 破坏性变更修复
- **tools.profile 默认值变更**: 2026.3.2 将 `tools.profile` 默认从 `coding` 改为 `messaging`
- `sync_uci_to_json()` 每次启动强制写入 `tools.profile=coding`
- `openclaw-env init_openclaw()` onboard 命令添加 `--tools-profile coding`
- `openclaw-env do_factory_reset()` onboard 命令添加 `--tools-profile coding`
- `oc-config.sh` 工厂重置 onboard 命令添加 `--tools-profile coding`
- `oc-config.sh` 工厂重置配置写入新增 `tools.profile=coding`
- **ACP dispatch 默认启用**: 2026.3.2 默认开启 ACP dispatch路由器内存有限可能导致 OOM
- `sync_uci_to_json()` 每次启动强制写入 `acp.dispatch.enabled=false`
- `openclaw-env do_factory_reset()` 配置写入新增 `acp.dispatch.enabled=false`
- `oc-config.sh` 工厂重置配置写入新增 `acp.dispatch.enabled=false`
#### 新增
- 健康检查集成 `openclaw config validate --json` 官方配置验证命令
- 健康检查新增 `gateway health --json` CLI 深度检查 (v2026.3.2 HTTP `/health` 已被 SPA 接管)
#### 修复
- **Ollama 配置适配**: `api` 从废弃的 `openai-chat-completions` 改为原生 `ollama` API 类型
- **Ollama baseUrl 格式**: 去掉 `/v1` 后缀,使用官方原生地址格式 (`http://host:11434`)
- **Ollama apiKey 对齐**: 从 `ollama` 改为官方默认值 `ollama-local`
- **启动自动迁移**: `sync_uci_to_json` 自动将旧版 Ollama 配置迁移到 v2026.3.2 格式
#### 改进
- 配置管理页面移除「菜单功能说明」信息框,减少视觉干扰
- `OC_TESTED_VERSION` 更新至 `2026.3.2`
## [1.0.3] - 2026-03-05
### 修复
- **P0** 配置管理写入错误的 JSON 路径导致 Gateway 崩溃且无法恢复 (#1)
- `json_set models.openai.apiKey``openclaw.json` 创建了非法的顶层 `models`
- OpenClaw 2026.3.1 严格校验配置 schema拒绝启动并报 `Unknown config keys: models.openai`
- 修复: API Key 改写入 `auth-profiles.json`,模型注册到 `agents.defaults.models`
- 影响: 所有 11 个供应商的快速配置 (OpenAI/Anthropic/Gemini/OpenRouter/DeepSeek/GitHub Copilot/Qwen/xAI/Groq/SiliconFlow/自定义)
- **P0** 恢复默认配置 → "清除模型配置" 未清理 `auth-profiles.json` 认证信息
- **P1** 健康检查新增自动修复: 检测并移除旧版错误写入的顶层 `models` 无效键
- **P1** `set_active_model` 手动切换模型时未注册到 `agents.defaults.models`
### 新增
- **Ollama 本地模型支持**: 快速配置菜单新增 Ollama 选项 (12),支持 localhost/局域网连接、自动检测连通性、自动列出已安装模型、兼容 OpenAI chat completions 格式
- `openclaw-env factory-reset` 非交互式恢复出厂设置命令
- `auth_set_apikey` 函数: 正确写入 API Key 到 `auth-profiles.json`
- `register_and_set_model` 函数: 注册模型到 `agents.defaults.models` 并设为默认
- `register_custom_provider` 函数: 为需要 `baseUrl` 的 OpenAI 兼容供应商注册 `models.providers`
- 「检测升级」同时检查 OpenClaw 和**插件版本** (通过 GitHub API 获取最新 release)
- 页面加载时自动静默检查更新,有新版本时「检测升级」按钮显示橙色小红点提醒
- 状态面板显示当前安装的插件版本号
- 构建/安装流程部署 `VERSION` 文件到 `/usr/share/openclaw/VERSION`
- `openclaw-env setup` 安装环境时自动安装 Gemini CLI (Google OAuth 依赖)
### 改进
- 使用指南顺序调整: ② 配置管理 → ③ Web 控制台 (首次使用更合理的引导顺序)
- Gemini CLI 安装从配置向导选项 1 移至环境安装阶段,避免进入向导时临时等待
## [1.0.2] - 2026-03-02
### 修复
- **P0** ARM64 musl: Gateway 崩溃循环 — `process.execPath` 返回 musl 链接器路径导致 `child_process.fork()` 失败
- 使用 `patchelf` 直接修改 node ELF 二进制的 interpreter 和 rpath替代 ld-musl wrapper 方案
- 子进程通过 `process.execPath` fork 时可正确找到 node 二进制
- **P0** ARM64 musl: Unicode property escapes 正则失败 (`\p{Emoji_Presentation}`) — 缺少 `NODE_ICU_DATA` 环境变量
- init.d、openclaw-env、oc-config.sh 所有入口均添加 `NODE_ICU_DATA` 环境变量
### 改进
- `build-node-musl.sh` 构建验证阶段新增 `process.execPath` 输出检查
## [1.0.1] - 2026-03-02
### 修复
- **P0** web-pty.js `loadAuthToken` 读取错误的 UCI key `luci_token``pty_token`
- **P0** init.d `get_oc_entry()` 管道子 shell 导致返回值丢失,改用临时文件重定向
- **P1** Gateway procd respawn 无限重试 (`3600 5 0`) → 限制最多 5 次 (`3600 5 5`)
- **P1** Telegram 配对流程管道子 shell 变量丢失,改用临时文件避免子 shell
- **P1** `openclaw.lua` PID 提取 `sed` 正则不可靠,改用 `awk` + `split`
- **P2** init.d 和 uci-defaults 弱 token fallback (`echo "auto_$(date +%s)"`) → `dd if=/dev/urandom`
- **P2** `oc-config.sh` 恢复出厂 `timeout` 命令可能不存在,添加 `command -v` 检查和降级方案
- **P2** web-pty.js SIGTERM 不清理 HTTPS server统一 `shutdown()` 函数
### 新增
- GitHub Copilot 配置新增 OAuth 授权登录方式 (通过 `copilot-proxy` 插件)
- `uci-defaults` 首次安装时自动生成 `pty_token`
- Web 控制台和状态面板显示当前活跃模型名称
### 改进
- Qwen 使用 `models.dashscope` 键名、SiliconFlow 使用 `models.siliconflow`,避免 `models.custom` 键冲突
- `get_openclaw_version()``package.json` 读取版本号,不再每次启动 Node.js 进程
- PTY 终端 WebSocket 重连策略改为无限重连 (`MAX_RETRY=Infinity`)
- Makefile `PKG_VERSION``VERSION` 文件动态读取
## [1.0.0] - 2026-03-02
### 新增
- LuCI 管理界面基本设置、配置管理Web 终端、Web 控制台
- 一键安装 Node.js + OpenClaw 运行环境
- 支持 x86_64 和 aarch64 架构glibc / musl 自动检测
- 支持 12+ AI 模型提供商配置向导
- 支持 Telegram / Discord / 飞书 / Slack 消息渠道
- `.run` 自解压包和 `.ipk` 安装包两种分发方式
- OpenWrt SDK feeds 集成支持
- GitHub Actions 自动构建与发布
### 安全
- WebSocket PTY 服务添加 token 认证
- WebSocket 最大并发会话限制(默认 5
- PTY 服务默认绑定 127.0.0.1,不对外暴露
- Token 不再嵌入 HTML 源码,改为 AJAX 动态获取
- sync_uci_to_json 通过环境变量传递 token避免 ps 泄露
- 所有渠道 Token 输入统一 sanitize_input 清洗
### 修复
- Telegram Bot Token 粘贴时被 bracketed paste 转义序列污染
- Web PTY 终端粘贴包含 ANSI 转义序列问题
- 恢复出厂配置流程异常退出
- Gemini CLI OAuth 登录在 OpenWrt 上失败
- init.d status_service() 在无 netstat 的系统上报错
- Makefile 损坏导致 OpenWrt SDK 编译失败
### 改进
- 所有 AI 提供商模型列表更新到最新版本
- UID/GID 动态分配,避免与已有系统用户冲突
- 版本号统一由 VERSION 文件管理
- README.md 完善安装说明、FAQ 和项目结构

26
luci-app-openclaw/LICENSE Normal file
View File

@ -0,0 +1,26 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2025-2026 10000ge10000
Project: luci-app-openclaw — OpenClaw AI Gateway LuCI Plugin for OpenWrt
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
The full text of the GNU General Public License version 3 is available at:
https://www.gnu.org/licenses/gpl-3.0.html
SOFTWARE.
This project integrates with OpenClaw (https://github.com/nicepkg/openclaw),
which is licensed under its own terms. Node.js runtime downloaded during setup
is subject to the Node.js license (https://github.com/nodejs/node/blob/main/LICENSE).

View File

@ -0,0 +1,93 @@
# luci-app-openclaw — OpenWrt package Makefile
# 兼容两种集成方式:
# 1. 作为 feeds 源: echo "src-git openclaw ..." >> feeds.conf.default
# 2. 直接放入 package/ 目录: git clone ... package/luci-app-openclaw
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-openclaw
PKG_VERSION:=$(strip $(shell cat $(CURDIR)/VERSION 2>/dev/null || echo "1.0.0"))
PKG_RELEASE:=1
PKG_MAINTAINER:=10000ge10000 <10000ge10000@users.noreply.github.com>
PKG_LICENSE:=GPL-3.0
LUCI_TITLE:=OpenClaw AI 网关 LuCI 管理插件
LUCI_DEPENDS:=+luci-compat +luci-base +curl +openssl-util +script-utils +tar
LUCI_PKGARCH:=all
# 优先使用 luci.mk (feeds 模式), 不可用时回退 package.mk
ifeq ($(wildcard $(TOPDIR)/feeds/luci/luci.mk),)
include $(INCLUDE_DIR)/package.mk
define Package/$(PKG_NAME)
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=3. Applications
TITLE:=$(LUCI_TITLE)
DEPENDS:=$(LUCI_DEPENDS)
PKGARCH:=all
endef
define Package/$(PKG_NAME)/description
OpenClaw AI Gateway 的 LuCI 管理插件。
支持 12+ AI 模型提供商和 Telegram/Discord 等多种消息渠道。
endef
else
include $(TOPDIR)/feeds/luci/luci.mk
endif
define Package/$(PKG_NAME)/conffiles
/etc/config/openclaw
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./root/etc/config/openclaw $(1)/etc/config/openclaw
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./root/etc/uci-defaults/99-openclaw $(1)/etc/uci-defaults/99-openclaw
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./root/etc/init.d/openclaw $(1)/etc/init.d/openclaw
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) ./root/usr/bin/openclaw-env $(1)/usr/bin/openclaw-env
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller
$(INSTALL_DATA) ./luasrc/controller/openclaw.lua $(1)/usr/lib/lua/luci/controller/openclaw.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/model/cbi/openclaw
$(INSTALL_DATA) ./luasrc/model/cbi/openclaw/basic.lua $(1)/usr/lib/lua/luci/model/cbi/openclaw/basic.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/openclaw
$(INSTALL_DATA) ./luasrc/view/openclaw/status.htm $(1)/usr/lib/lua/luci/view/openclaw/status.htm
$(INSTALL_DATA) ./luasrc/view/openclaw/advanced.htm $(1)/usr/lib/lua/luci/view/openclaw/advanced.htm
$(INSTALL_DATA) ./luasrc/view/openclaw/console.htm $(1)/usr/lib/lua/luci/view/openclaw/console.htm
$(INSTALL_DIR) $(1)/usr/share/openclaw
$(INSTALL_DATA) ./VERSION $(1)/usr/share/openclaw/VERSION
$(INSTALL_BIN) ./root/usr/share/openclaw/oc-config.sh $(1)/usr/share/openclaw/oc-config.sh
$(INSTALL_DATA) ./root/usr/share/openclaw/web-pty.js $(1)/usr/share/openclaw/web-pty.js
$(INSTALL_DIR) $(1)/usr/share/openclaw/ui
$(CP) ./root/usr/share/openclaw/ui/* $(1)/usr/share/openclaw/ui/
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n
if [ -f ./po/zh-cn/openclaw.po ]; then \
po2lmo ./po/zh-cn/openclaw.po $(1)/usr/lib/lua/luci/i18n/openclaw.zh-cn.lmo 2>/dev/null || true; \
fi
endef
define Package/$(PKG_NAME)/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
( . /etc/uci-defaults/99-openclaw ) && rm -f /etc/uci-defaults/99-openclaw
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
exit 0
}
endef
define Package/$(PKG_NAME)/postrm
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
}
endef
$(eval $(call BuildPackage,$(PKG_NAME)))

137
luci-app-openclaw/README.md Normal file
View File

@ -0,0 +1,137 @@
# luci-app-openclaw
[![Bilibili](https://img.shields.io/badge/B%E7%AB%99-59438380-00a1d6?logo=bilibili)](https://space.bilibili.com/59438380)
[![Blog](https://img.shields.io/badge/Blog-910501.xyz-orange)](https://blog.910501.xyz/)
[![Build & Release](https://github.com/10000ge10000/luci-app-openclaw/actions/workflows/build.yml/badge.svg)](https://github.com/10000ge10000/luci-app-openclaw/actions/workflows/build.yml)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-blue.svg)](LICENSE)
[OpenClaw](https://github.com/nicepkg/openclaw) AI 网关的 OpenWrt LuCI 管理插件。
在路由器上运行 OpenClaw通过 LuCI 管理界面完成安装、配置和服务管理。
<div align="center">
<img src="docs/images/1.png" alt="OpenClaw LuCI 管理界面" width="800" style="border-radius:8px;" />
</div>
**系统要求**
| 项目 | 要求 |
|------|------|
| 架构 | x86_64 或 aarch64 (ARM64) |
| C 库 | musl自动检测离线包仅支持 musl |
| 依赖 | luci-compat, luci-base, curl, openssl-util |
| 存储 | **1.5GB 以上可用空间** |
| 内存 | 推荐 1GB 及以上 |
## 📦 安装
### 方式一:.run 自解压包(推荐)
无需 SDK适用于已安装好的系统。
```bash
wget https://github.com/10000ge10000/luci-app-openclaw/releases/latest/download/luci-app-openclaw.run
sh luci-app-openclaw.run
```
### 方式二:.ipk 安装
```bash
wget https://github.com/10000ge10000/luci-app-openclaw/releases/latest/download/luci-app-openclaw.ipk
opkg install luci-app-openclaw.ipk
```
### 方式三:集成到固件编译
适用于自行编译固件或使用在线编译平台的用户。
```bash
cd /path/to/openwrt
# 添加 feeds
echo "src-git openclaw https://github.com/10000ge10000/luci-app-openclaw.git" >> feeds.conf.default
# 更新安装
./scripts/feeds update -a
./scripts/feeds install -a
# 选择插件
make menuconfig
# LuCI → Applications → luci-app-openclaw
# 编译
make package/luci-app-openclaw/compile V=s
```
使用 OpenWrt SDK 单独编译:
```bash
git clone https://github.com/10000ge10000/luci-app-openclaw.git package/luci-app-openclaw
make defconfig
make package/luci-app-openclaw/compile V=s
find bin/ -name "luci-app-openclaw*.ipk"
```
### 方式四:手动安装
```bash
git clone https://github.com/10000ge10000/luci-app-openclaw.git
cd luci-app-openclaw
cp -r root/* /
mkdir -p /usr/lib/lua/luci/controller /usr/lib/lua/luci/model/cbi/openclaw /usr/lib/lua/luci/view/openclaw
cp luasrc/controller/openclaw.lua /usr/lib/lua/luci/controller/
cp luasrc/model/cbi/openclaw/*.lua /usr/lib/lua/luci/model/cbi/openclaw/
cp luasrc/view/openclaw/*.htm /usr/lib/lua/luci/view/openclaw/
chmod +x /etc/init.d/openclaw /usr/bin/openclaw-env /usr/share/openclaw/oc-config.sh
sh /etc/uci-defaults/99-openclaw
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/*
```
## 🔰 首次使用
1. 打开 LuCI → 服务 → OpenClaw点击「安装运行环境」
2. 安装完成后服务会自动启动,点击「刷新页面」查看状态
3. 进入「Web 控制台」添加 AI 模型和 API Key
4. 进入「配置管理」可使用向导配置消息渠道
## 📂 目录结构
```
luci-app-openclaw/
├── Makefile # OpenWrt 包定义
├── luasrc/
│ ├── controller/openclaw.lua # LuCI 路由和 API
│ ├── model/cbi/openclaw/basic.lua # 主页面
│ └── view/openclaw/
│ ├── status.htm # 状态面板
│ ├── advanced.htm # 配置管理(终端)
│ └── console.htm # Web 控制台
├── root/
│ ├── etc/
│ │ ├── config/openclaw # UCI 配置
│ │ ├── init.d/openclaw # 服务脚本
│ │ └── uci-defaults/99-openclaw # 初始化脚本
│ └── usr/
│ ├── bin/openclaw-env # 环境管理工具
│ └── share/openclaw/ # 配置终端资源
├── scripts/
│ ├── build_ipk.sh # 本地 IPK 构建
│ ├── build_run.sh # .run 安装包构建
│ ├── download_deps.sh # 下载离线依赖 (Node.js + OpenClaw)
│ ├── upload_openlist.sh # 上传到网盘 (OpenList)
│ └── build-node-musl.sh # 编译 Node.js musl 静态链接版本
└── .github/workflows/
├── build.yml # 在线构建 + 发布
└── build-node-musl.yml # Node.js musl 构建
```
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📄 License
[GPL-3.0](LICENSE)

View File

@ -0,0 +1 @@
1.0.15

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

@ -0,0 +1,801 @@
-- luci-app-openclaw — LuCI Controller
module("luci.controller.openclaw", package.seeall)
function index()
-- 主入口: 服务 → OpenClaw (🧠 作为菜单图标)
local page = entry({"admin", "services", "openclaw"}, alias("admin", "services", "openclaw", "basic"), _("OpenClaw"), 90)
page.dependent = false
-- 基本设置 (CBI)
entry({"admin", "services", "openclaw", "basic"}, cbi("openclaw/basic"), _("基本设置"), 10).leaf = true
-- 配置管理 (View — 嵌入 oc-config Web 终端)
entry({"admin", "services", "openclaw", "advanced"}, template("openclaw/advanced"), _("配置管理"), 20).leaf = true
-- Web 控制台 (View — 嵌入 OpenClaw Web UI)
entry({"admin", "services", "openclaw", "console"}, template("openclaw/console"), _("Web 控制台"), 30).leaf = true
-- 状态 API (AJAX 接口, 供前端 XHR 调用)
entry({"admin", "services", "openclaw", "status_api"}, call("action_status"), nil).leaf = true
-- 服务控制 API
entry({"admin", "services", "openclaw", "service_ctl"}, call("action_service_ctl"), nil).leaf = true
-- 安装/升级日志 API (轮询)
entry({"admin", "services", "openclaw", "setup_log"}, call("action_setup_log"), nil).leaf = true
-- 版本检查 API (仅检查插件版本)
entry({"admin", "services", "openclaw", "check_update"}, call("action_check_update"), nil).leaf = true
-- 卸载运行环境 API
entry({"admin", "services", "openclaw", "uninstall"}, call("action_uninstall"), nil).leaf = true
-- 获取网关 Token API (仅认证用户可访问)
entry({"admin", "services", "openclaw", "get_token"}, call("action_get_token"), nil).leaf = true
-- 插件升级 API
entry({"admin", "services", "openclaw", "plugin_upgrade"}, call("action_plugin_upgrade"), nil).leaf = true
-- 插件升级日志 API (轮询)
entry({"admin", "services", "openclaw", "plugin_upgrade_log"}, call("action_plugin_upgrade_log"), nil).leaf = true
-- 配置备份 API (v2026.3.8+: openclaw backup create/verify)
entry({"admin", "services", "openclaw", "backup"}, call("action_backup"), nil).leaf = true
end
-- ═══════════════════════════════════════════
-- 状态查询 API: 返回 JSON
-- ═══════════════════════════════════════════
function action_status()
local http = require "luci.http"
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local port = uci:get("openclaw", "main", "port") or "18789"
local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
local enabled = uci:get("openclaw", "main", "enabled") or "0"
-- 验证端口值为纯数字,防止命令注入
if not port:match("^%d+$") then port = "18789" end
if not pty_port:match("^%d+$") then pty_port = "18793" end
local result = {
enabled = enabled,
port = port,
pty_port = pty_port,
gateway_running = false,
gateway_starting = false,
pty_running = false,
pid = "",
memory_kb = 0,
uptime = "",
node_version = "",
oc_version = "",
plugin_version = "",
}
-- 插件版本
local pvf = io.open("/usr/share/openclaw/VERSION", "r")
if pvf then
result.plugin_version = pvf:read("*a"):gsub("%s+", "")
pvf:close()
end
-- 安装方式检测 (离线 / 在线)
-- 检查 Node.js
local node_bin = "/opt/openclaw/node/bin/node"
local f = io.open(node_bin, "r")
if f then
f:close()
local node_ver = sys.exec(node_bin .. " --version 2>/dev/null"):gsub("%s+", "")
result.node_version = node_ver
end
-- OpenClaw 版本 (从 package.json 读取)
local oc_dirs = {
"/opt/openclaw/global/lib/node_modules/openclaw",
"/opt/openclaw/global/node_modules/openclaw",
"/opt/openclaw/node/lib/node_modules/openclaw",
}
for _, d in ipairs(oc_dirs) do
local pf = io.open(d .. "/package.json", "r")
if pf then
local pj = pf:read("*a")
pf:close()
local ver = pj:match('"version"%s*:%s*"([^"]+)"')
if ver and ver ~= "" then
result.oc_version = ver
break
end
end
end
-- 网关端口检查
local gw_check_cmd = "if command -v ss >/dev/null 2>&1; then ss -tulnp 2>/dev/null | grep -c ':" .. port .. " ' || echo 0; else netstat -tulnp 2>/dev/null | grep -c ':" .. port .. " ' || echo 0; fi"
local gw_check = sys.exec(gw_check_cmd):gsub("%s+", "")
result.gateway_running = (tonumber(gw_check) or 0) > 0
-- 如果端口未监听但 procd 进程存在,说明正在启动中 (gateway 初始化需要数分钟)
if not result.gateway_running and enabled == "1" then
local procd_pid = sys.exec("pgrep -f 'openclaw.*gateway' 2>/dev/null | head -1"):gsub("%s+", "")
if procd_pid ~= "" then
result.gateway_starting = true
end
end
-- PTY 端口检查
local pty_check = sys.exec("netstat -tulnp 2>/dev/null | grep -c ':" .. pty_port .. " ' || echo 0"):gsub("%s+", "")
result.pty_running = (tonumber(pty_check) or 0) > 0
-- 读取当前活跃模型
local config_file = "/opt/openclaw/data/.openclaw/openclaw.json"
local cf = io.open(config_file, "r")
if cf then
local content = cf:read("*a")
cf:close()
-- 简单正则提取 "primary": "xxx"
local model = content:match('"primary"%s*:%s*"([^"]+)"')
if model and model ~= "" then
result.active_model = model
end
-- 读取已配置的渠道列表
local channels = {}
if content:match('"qqbot"%s*:%s*{') and content:match('"appId"%s*:%s*"[^"]+"') then
channels[#channels+1] = "QQ"
end
if content:match('"telegram"%s*:%s*{') and content:match('"botToken"%s*:%s*"[^"]+"') then
channels[#channels+1] = "Telegram"
end
if content:match('"discord"%s*:%s*{') then
channels[#channels+1] = "Discord"
end
if content:match('"feishu"%s*:%s*{') then
channels[#channels+1] = "飞书"
end
if content:match('"slack"%s*:%s*{') then
channels[#channels+1] = "Slack"
end
if #channels > 0 then
result.channels = table.concat(channels, ", ")
end
end
-- PID 和内存
if result.gateway_running then
local pid = sys.exec("netstat -tulnp 2>/dev/null | awk '/:" .. port .. " /{split($NF,a,\"/\");print a[1];exit}'"):gsub("%s+", "")
if pid and pid ~= "" then
result.pid = pid
-- 内存 (VmRSS from /proc)
local rss = sys.exec("awk '/VmRSS/{print $2}' /proc/" .. pid .. "/status 2>/dev/null"):gsub("%s+", "")
result.memory_kb = tonumber(rss) or 0
-- 运行时间
local stat_time = sys.exec("stat -c %Y /proc/" .. pid .. " 2>/dev/null"):gsub("%s+", "")
local start_ts = tonumber(stat_time) or 0
if start_ts > 0 then
local uptime_s = os.time() - start_ts
local hours = math.floor(uptime_s / 3600)
local mins = math.floor((uptime_s % 3600) / 60)
local secs = uptime_s % 60
if hours > 0 then
result.uptime = string.format("%dh %dm %ds", hours, mins, secs)
elseif mins > 0 then
result.uptime = string.format("%dm %ds", mins, secs)
else
result.uptime = string.format("%ds", secs)
end
end
end
end
http.prepare_content("application/json")
http.write_json(result)
end
-- ═══════════════════════════════════════════
-- 服务控制 API: start/stop/restart/setup
-- ═══════════════════════════════════════════
function action_service_ctl()
local http = require "luci.http"
local sys = require "luci.sys"
local action = http.formvalue("action") or ""
if action == "start" then
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
elseif action == "stop" then
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
-- stop 后额外等待确保端口释放
sys.exec("sleep 2")
elseif action == "restart" then
-- 先完整 stop (确保端口释放),再后台 start
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
sys.exec("sleep 2")
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
elseif action == "enable" then
sys.exec("/etc/init.d/openclaw enable 2>/dev/null")
elseif action == "disable" then
sys.exec("/etc/init.d/openclaw disable 2>/dev/null")
elseif action == "setup" then
-- 先清理旧日志和状态
sys.exec("rm -f /tmp/openclaw-setup.log /tmp/openclaw-setup.pid /tmp/openclaw-setup.exit")
-- 获取用户选择的版本 (stable=指定版本, latest=最新版)
local version = http.formvalue("version") or ""
local env_prefix = ""
if version == "stable" then
-- 稳定版: 读取 openclaw-env 中定义的 OC_TESTED_VERSION
local tested_ver = sys.exec("grep '^OC_TESTED_VERSION=' /usr/bin/openclaw-env 2>/dev/null | cut -d'\"' -f2"):gsub("%s+", "")
if tested_ver ~= "" then
env_prefix = "OC_VERSION=" .. tested_ver .. " "
end
elseif version ~= "" and version ~= "latest" then
-- 校验版本号格式 (仅允许数字、点、横线、字母)
if version:match("^[%d%.%-a-zA-Z]+$") then
env_prefix = "OC_VERSION=" .. version .. " "
end
end
-- 后台安装,成功后自动启用并启动服务
-- 注: openclaw-env 脚本有 set -einit_openclaw 中的非关键失败不应阻止启动
sys.exec("( " .. env_prefix .. "/usr/bin/openclaw-env setup > /tmp/openclaw-setup.log 2>&1; RC=$?; echo $RC > /tmp/openclaw-setup.exit; if [ $RC -eq 0 ]; then uci set openclaw.main.enabled=1; uci commit openclaw; /etc/init.d/openclaw enable 2>/dev/null; sleep 1; /etc/init.d/openclaw start >> /tmp/openclaw-setup.log 2>&1; fi ) & echo $! > /tmp/openclaw-setup.pid")
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "安装已启动,请查看安装日志..." })
return
else
http.prepare_content("application/json")
http.write_json({ status = "error", message = "未知操作: " .. action })
return
end
http.prepare_content("application/json")
http.write_json({ status = "ok", action = action })
end
-- ═══════════════════════════════════════════
-- 安装日志轮询 API
-- ═══════════════════════════════════════════
function action_setup_log()
local http = require "luci.http"
local sys = require "luci.sys"
-- 读取日志内容
local log = ""
local f = io.open("/tmp/openclaw-setup.log", "r")
if f then
log = f:read("*a") or ""
f:close()
end
-- 检查进程是否还在运行
local running = false
local pid_file = io.open("/tmp/openclaw-setup.pid", "r")
if pid_file then
local pid = pid_file:read("*a"):gsub("%s+", "")
pid_file:close()
if pid ~= "" then
local check = sys.exec("kill -0 " .. pid .. " 2>/dev/null && echo yes || echo no"):gsub("%s+", "")
running = (check == "yes")
end
end
-- 读取退出码
local exit_code = -1
if not running then
local exit_file = io.open("/tmp/openclaw-setup.exit", "r")
if exit_file then
local code = exit_file:read("*a"):gsub("%s+", "")
exit_file:close()
exit_code = tonumber(code) or -1
end
end
-- 判断状态
local state = "idle"
if running then
state = "running"
elseif exit_code == 0 then
state = "success"
elseif exit_code > 0 then
state = "failed"
end
http.prepare_content("application/json")
http.write_json({
state = state,
exit_code = exit_code,
log = log
})
end
-- ═══════════════════════════════════════════
-- 版本检查 API
-- ═══════════════════════════════════════════
function action_check_update()
local http = require "luci.http"
local sys = require "luci.sys"
-- 插件版本检查 (从 GitHub API 获取最新 release tag + release notes)
local plugin_current = ""
local pf = io.open("/usr/share/openclaw/VERSION", "r")
or io.open("/root/luci-app-openclaw/VERSION", "r")
if pf then
plugin_current = pf:read("*a"):gsub("%s+", "")
pf:close()
end
local plugin_latest = ""
local release_notes = ""
local plugin_has_update = false
-- 使用 GitHub API 获取最新 release (tag + body)
local gh_json = sys.exec("curl -sf --connect-timeout 5 --max-time 10 'https://api.github.com/repos/10000ge10000/luci-app-openclaw/releases/latest' 2>/dev/null")
if gh_json and gh_json ~= "" then
-- 提取 tag_name
local tag = gh_json:match('"tag_name"%s*:%s*"([^"]+)"')
if tag and tag ~= "" then
plugin_latest = tag:gsub("^v", ""):gsub("%s+", "")
end
-- 提取 body (release notes), 处理 JSON 转义
-- 结束引号后可能紧跟 \n、空格、, 或 },用宽松匹配
local body = gh_json:match('"body"%s*:%s*"(.-)"[,}%]\n ]')
if body and body ~= "" then
-- 还原 JSON 转义: \n \r \" \\
body = body:gsub("\\n", "\n"):gsub("\\r", ""):gsub('\\"', '"'):gsub("\\\\", "\\")
release_notes = body
end
end
if plugin_current ~= "" and plugin_latest ~= "" and plugin_current ~= plugin_latest then
plugin_has_update = true
end
http.prepare_content("application/json")
http.write_json({
status = "ok",
plugin_current = plugin_current,
plugin_latest = plugin_latest,
plugin_has_update = plugin_has_update,
release_notes = release_notes
})
end
-- ═══════════════════════════════════════════
-- 卸载运行环境 API
-- ═══════════════════════════════════════════
function action_uninstall()
local http = require "luci.http"
local sys = require "luci.sys"
-- 停止服务
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
-- 禁用开机启动
sys.exec("/etc/init.d/openclaw disable 2>/dev/null")
-- 设置 UCI enabled=0
sys.exec("uci set openclaw.main.enabled=0; uci commit openclaw 2>/dev/null")
-- 删除 Node.js + OpenClaw 运行环境
sys.exec("rm -rf /opt/openclaw")
-- 清理临时文件
sys.exec("rm -f /tmp/openclaw-setup.* /tmp/openclaw-update.log /var/run/openclaw*.pid")
-- 删除 openclaw 系统用户
sys.exec("sed -i '/^openclaw:/d' /etc/passwd /etc/shadow /etc/group 2>/dev/null")
http.prepare_content("application/json")
http.write_json({
status = "ok",
message = "运行环境已卸载。Node.js、OpenClaw 及相关数据已清理。"
})
end
-- ═══════════════════════════════════════════
-- 获取 Token API
-- 仅通过 LuCI 认证后可调用,避免 Token 嵌入 HTML 源码
-- 返回网关 Token 和 PTY Token
-- ═══════════════════════════════════════════
function action_get_token()
local http = require "luci.http"
local uci = require "luci.model.uci".cursor()
local token = uci:get("openclaw", "main", "token") or ""
local pty_token = uci:get("openclaw", "main", "pty_token") or ""
http.prepare_content("application/json")
http.write_json({ token = token, pty_token = pty_token })
end
-- ═══════════════════════════════════════════
-- 插件升级 API (后台下载 .run 并执行)
-- 参数: version — 目标版本号 (如 1.0.8)
-- ═══════════════════════════════════════════
function action_plugin_upgrade()
local http = require "luci.http"
local sys = require "luci.sys"
local version = http.formvalue("version") or ""
if version == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "缺少版本号参数" })
return
end
-- 安全检查: version 只允许数字和点
if not version:match("^[%d%.]+$") then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "版本号格式无效" })
return
end
-- 清理旧日志和状态
sys.exec("rm -f /tmp/openclaw-plugin-upgrade.log /tmp/openclaw-plugin-upgrade.pid /tmp/openclaw-plugin-upgrade.exit")
-- 后台执行: 下载 .run 并执行安装
local run_url = "https://github.com/10000ge10000/luci-app-openclaw/releases/download/v" .. version .. "/luci-app-openclaw_" .. version .. ".run"
-- 使用 curl 下载 (-L 跟随重定向), 然后 sh 执行
sys.exec(string.format(
"( echo '正在下载插件 v%s ...' > /tmp/openclaw-plugin-upgrade.log; " ..
"curl -sL --connect-timeout 15 --max-time 120 -o /tmp/luci-app-openclaw-update.run '%s' >> /tmp/openclaw-plugin-upgrade.log 2>&1; " ..
"RC=$?; " ..
"if [ $RC -ne 0 ]; then " ..
" echo '下载失败 (curl exit: '$RC')' >> /tmp/openclaw-plugin-upgrade.log; " ..
" echo '如果无法访问 GitHub请手动下载: %s' >> /tmp/openclaw-plugin-upgrade.log; " ..
" echo $RC > /tmp/openclaw-plugin-upgrade.exit; " ..
"else " ..
" FSIZE=$(wc -c < /tmp/luci-app-openclaw-update.run 2>/dev/null | tr -d ' '); " ..
" echo \"下载完成 (${FSIZE} bytes)\" >> /tmp/openclaw-plugin-upgrade.log; " ..
" FHEAD=$(head -c 9 /tmp/luci-app-openclaw-update.run 2>/dev/null); " ..
" if [ \"$FSIZE\" -lt 10000 ] 2>/dev/null; then " ..
" if [ \"$FHEAD\" = 'Not Found' ]; then " ..
" echo '❌ GitHub 返回 \"Not Found\"可能是网络被拦截GFW或 Release 资产不存在' >> /tmp/openclaw-plugin-upgrade.log; " ..
" else " ..
" echo '❌ 文件过小,可能 GitHub 访问受限或网络异常' >> /tmp/openclaw-plugin-upgrade.log; " ..
" fi; " ..
" echo '请检查路由器是否能访问 github.com或手动下载后安装: %s' >> /tmp/openclaw-plugin-upgrade.log; " ..
" echo 1 > /tmp/openclaw-plugin-upgrade.exit; " ..
" else " ..
" echo '' >> /tmp/openclaw-plugin-upgrade.log; " ..
" echo '正在安装...' >> /tmp/openclaw-plugin-upgrade.log; " ..
" sh /tmp/luci-app-openclaw-update.run >> /tmp/openclaw-plugin-upgrade.log 2>&1; " ..
" RC2=$?; echo $RC2 > /tmp/openclaw-plugin-upgrade.exit; " ..
" if [ $RC2 -eq 0 ]; then " ..
" echo '' >> /tmp/openclaw-plugin-upgrade.log; " ..
" echo '✅ 插件升级完成!请刷新浏览器页面。' >> /tmp/openclaw-plugin-upgrade.log; " ..
" else " ..
" echo '安装执行失败 (exit: '$RC2')' >> /tmp/openclaw-plugin-upgrade.log; " ..
" fi; " ..
" fi; " ..
" rm -f /tmp/luci-app-openclaw-update.run; " ..
"fi " ..
") & echo $! > /tmp/openclaw-plugin-upgrade.pid",
version, run_url, run_url, run_url
))
http.prepare_content("application/json")
http.write_json({
status = "ok",
message = "插件升级已在后台启动..."
})
end
-- ═══════════════════════════════════════════
-- 插件升级日志轮询 API
-- ═══════════════════════════════════════════
function action_plugin_upgrade_log()
local http = require "luci.http"
local sys = require "luci.sys"
local log = ""
local f = io.open("/tmp/openclaw-plugin-upgrade.log", "r")
if f then
log = f:read("*a") or ""
f:close()
end
local running = false
local pid_file = io.open("/tmp/openclaw-plugin-upgrade.pid", "r")
if pid_file then
local pid = pid_file:read("*a"):gsub("%s+", "")
pid_file:close()
if pid ~= "" then
local check = sys.exec("kill -0 " .. pid .. " 2>/dev/null && echo yes || echo no"):gsub("%s+", "")
running = (check == "yes")
end
end
local exit_code = -1
if not running then
local exit_file = io.open("/tmp/openclaw-plugin-upgrade.exit", "r")
if exit_file then
local code = exit_file:read("*a"):gsub("%s+", "")
exit_file:close()
exit_code = tonumber(code) or -1
end
end
local state = "idle"
if running then
state = "running"
elseif exit_code == 0 then
state = "success"
elseif exit_code > 0 then
state = "failed"
end
http.prepare_content("application/json")
http.write_json({
status = "ok",
log = log,
state = state,
running = running,
exit_code = exit_code
})
end
-- ═══════════════════════════════════════════
-- 配置备份 API (v2026.3.8+)
-- action=create: 创建配置备份
-- action=verify: 验证最新备份
-- action=list: 列出现有备份(含类型/大小)
-- action=delete: 删除指定备份文件
-- ═══════════════════════════════════════════
function action_backup()
local http = require "luci.http"
local sys = require "luci.sys"
local action = http.formvalue("action") or "create"
local node_bin = "/opt/openclaw/node/bin/node"
local oc_entry = ""
-- 查找 openclaw 入口
local search_dirs = {
"/opt/openclaw/global/lib/node_modules/openclaw",
"/opt/openclaw/global/node_modules/openclaw",
"/opt/openclaw/node/lib/node_modules/openclaw",
}
for _, d in ipairs(search_dirs) do
if nixio.fs.stat(d .. "/openclaw.mjs", "type") then
oc_entry = d .. "/openclaw.mjs"
break
elseif nixio.fs.stat(d .. "/dist/cli.js", "type") then
oc_entry = d .. "/dist/cli.js"
break
end
end
if oc_entry == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "OpenClaw 未安装,无法执行备份操作" })
return
end
local env_prefix = string.format(
"HOME=/opt/openclaw/data OPENCLAW_HOME=/opt/openclaw/data " ..
"OPENCLAW_STATE_DIR=/opt/openclaw/data/.openclaw " ..
"OPENCLAW_CONFIG_PATH=/opt/openclaw/data/.openclaw/openclaw.json " ..
"PATH=/opt/openclaw/node/bin:/opt/openclaw/global/bin:$PATH "
)
-- 备份目录 (openclaw backup create 输出到 CWD需要 cd)
local backup_dir = "/opt/openclaw/data/.openclaw/backups"
local cd_prefix = "mkdir -p " .. backup_dir .. " && cd " .. backup_dir .. " && "
-- ── 辅助: 解析单个备份文件的 manifest 信息 ──
local function parse_backup_info(filepath)
local filename = filepath:match("([^/]+)$") or filepath
-- 文件大小
local st = nixio.fs.stat(filepath)
local size = st and st.size or 0
-- 从文件名提取时间戳: 2026-03-11T18-28-43.149Z-openclaw-backup.tar.gz
local ts = filename:match("^(%d%d%d%d%-%d%d%-%d%dT%d%d%-%d%d%-%d%d%.%d+Z)")
local display_time = ""
if ts then
-- 2026-03-11T18-28-43.149Z -> 2026-03-11 18:28:43
display_time = ts:gsub("T", " "):gsub("(%d%d)%-(%d%d)%-(%d%d)%.%d+Z", "%1:%2:%3")
end
-- 读取 manifest.json 判断备份类型
local backup_type = "unknown"
local manifest_json = sys.exec(
"tar --wildcards -xzf " .. filepath .. " '*/manifest.json' -O 2>/dev/null"
)
if manifest_json and manifest_json ~= "" then
-- 简单字符串匹配,避免依赖 JSON 库
if manifest_json:match('"onlyConfig"%s*:%s*true') then
backup_type = "config"
elseif manifest_json:match('"onlyConfig"%s*:%s*false') then
backup_type = "full"
end
else
-- 无法读取 manifest通过文件大小推断
if size < 50000 then
backup_type = "config"
else
backup_type = "full"
end
end
-- 格式化大小
local size_str
if size >= 1073741824 then
size_str = string.format("%.1f GB", size / 1073741824)
elseif size >= 1048576 then
size_str = string.format("%.1f MB", size / 1048576)
elseif size >= 1024 then
size_str = string.format("%.1f KB", size / 1024)
else
size_str = tostring(size) .. " B"
end
return {
filename = filename,
filepath = filepath,
size = size,
size_str = size_str,
time = display_time,
backup_type = backup_type
}
end
if action == "create" then
local only_config = http.formvalue("only_config") or "1"
local backup_cmd
if only_config == "1" then
backup_cmd = cd_prefix .. env_prefix .. node_bin .. " " .. oc_entry .. " backup create --only-config --no-include-workspace 2>&1"
else
backup_cmd = cd_prefix .. "HOME=" .. backup_dir .. " " .. env_prefix .. node_bin .. " " .. oc_entry .. " backup create --no-include-workspace 2>&1"
end
local output = sys.exec(backup_cmd)
-- 完整备份可能输出到 HOME移动到 backup_dir
sys.exec("mv /opt/openclaw/data/*-openclaw-backup.tar.gz " .. backup_dir .. "/ 2>/dev/null")
-- 提取备份文件路径
local backup_path = output:match("([%S]+%.tar%.gz)")
http.prepare_content("application/json")
http.write_json({
status = "ok",
action = "create",
output = output,
backup_path = backup_path or ""
})
elseif action == "verify" then
-- 找到最新的备份文件
local latest = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "")
if latest == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "未找到备份文件,请先创建备份" })
return
end
local output = sys.exec(env_prefix .. node_bin .. " " .. oc_entry .. " backup verify " .. latest .. " 2>&1")
http.prepare_content("application/json")
http.write_json({
status = "ok",
action = "verify",
output = output,
backup_path = latest
})
elseif action == "restore" then
-- 支持指定文件名,不指定则用最新
local target_file = http.formvalue("file") or ""
local restore_path = ""
if target_file ~= "" then
-- 安全: 只允许文件名,不允许路径穿越
target_file = target_file:match("([^/]+)$") or ""
if target_file:match("%-openclaw%-backup%.tar%.gz$") then
restore_path = backup_dir .. "/" .. target_file
end
end
if restore_path == "" or not nixio.fs.stat(restore_path, "type") then
-- fallback 到最新
restore_path = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "")
end
if restore_path == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "未找到备份文件,请先创建备份" })
return
end
local oc_data_dir = "/opt/openclaw/data/.openclaw"
local config_path = oc_data_dir .. "/openclaw.json"
-- 1) 先验证备份中的 openclaw.json 是否有效
local check_cmd = "tar -xzf " .. restore_path .. " --wildcards '*/openclaw.json' -O 2>/dev/null"
local json_content = sys.exec(check_cmd)
if not json_content or json_content == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "备份文件中未找到 openclaw.json" })
return
end
-- 写入临时文件并用 node 验证
local tmpfile = "/tmp/oc-restore-check.json"
local f = io.open(tmpfile, "w")
if f then f:write(json_content); f:close() end
local check = sys.exec(node_bin .. " -e \"try{JSON.parse(require('fs').readFileSync('" .. tmpfile .. "','utf8'));console.log('OK')}catch(e){console.log('FAIL')}\" 2>/dev/null"):gsub("%s+", "")
os.remove(tmpfile)
if check ~= "OK" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "备份文件中的配置无效,恢复已取消" })
return
end
-- 2) 备份当前配置
sys.exec("cp -f " .. config_path .. " " .. config_path .. ".pre-restore 2>/dev/null")
-- 3) 获取备份名前缀 (如: 2026-03-11T18-21-17.209Z-openclaw-backup)
-- 备份结构: <backup_name>/payload/posix/<绝对路径>
local first_entry = sys.exec("tar -tzf " .. restore_path .. " 2>/dev/null | head -1"):gsub("%s+", "")
local backup_name = first_entry:match("^([^/]+)/") or ""
if backup_name == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "备份文件格式无法识别" })
return
end
local payload_prefix = backup_name .. "/payload/posix/"
-- strip 3 层: <backup_name> / payload / posix
local strip_count = 3
-- 4) 停止服务
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
-- 等待端口释放
sys.exec("sleep 2")
-- 5) 提取 payload 文件到根目录 (还原到原始绝对路径)
-- 注: --wildcards 与 --strip-components 组合在某些 tar 版本不兼容
-- 使用精确路径前缀代替 wildcards
local extract_cmd = string.format(
"tar -xzf %s --strip-components=%d -C / '%s' 2>&1",
restore_path, strip_count, payload_prefix
)
local extract_out = sys.exec(extract_cmd)
-- 6) 修复权限
sys.exec("chown -R openclaw:openclaw " .. oc_data_dir .. " 2>/dev/null")
-- 7) 重启服务
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
http.prepare_content("application/json")
http.write_json({
status = "ok",
action = "restore",
message = "已从备份完整恢复所有配置和数据,服务正在重启。原配置已保存为 openclaw.json.pre-restore",
backup_path = restore_path,
extract_output = extract_out or ""
})
elseif action == "list" then
-- 返回结构化的备份文件列表(含类型/大小/时间)
local files_raw = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null"):gsub("%s+$", "")
local backups = {}
if files_raw ~= "" then
for fpath in files_raw:gmatch("[^\n]+") do
fpath = fpath:gsub("%s+", "")
if fpath ~= "" then
backups[#backups + 1] = parse_backup_info(fpath)
end
-- 最多返回 20 条
if #backups >= 20 then break end
end
end
http.prepare_content("application/json")
http.write_json({
status = "ok",
action = "list",
backups = backups
})
elseif action == "delete" then
local target_file = http.formvalue("file") or ""
-- 安全: 只允许文件名,不允许路径穿越
target_file = target_file:match("([^/]+)$") or ""
if target_file == "" or not target_file:match("%-openclaw%-backup%.tar%.gz$") then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "无效的备份文件名" })
return
end
local del_path = backup_dir .. "/" .. target_file
if not nixio.fs.stat(del_path, "type") then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "备份文件不存在" })
return
end
os.remove(del_path)
http.prepare_content("application/json")
http.write_json({
status = "ok",
action = "delete",
message = "已删除备份: " .. target_file
})
else
http.prepare_content("application/json")
http.write_json({ status = "error", message = "未知备份操作: " .. action })
end
end

View File

@ -0,0 +1,546 @@
-- luci-app-openclaw — 基本设置 CBI Model
local sys = require "luci.sys"
m = Map("openclaw", "OpenClaw AI 网关",
"OpenClaw 是一个 AI 编程代理网关,支持 GitHub Copilot、Claude、GPT、Gemini 等大模型以及 QQ、Telegram、Discord 等多种消息渠道。")
-- 隐藏底部的「保存并应用」「保存」「复位」按钮 (本页无可编辑的 UCI 选项)
m.pageaction = false
-- ═══════════════════════════════════════════
-- 状态面板
-- ═══════════════════════════════════════════
m:section(SimpleSection).template = "openclaw/status"
-- ═══════════════════════════════════════════
-- 快捷操作
-- ═══════════════════════════════════════════
s3 = m:section(SimpleSection, nil, "快捷操作")
s3.template = "cbi/nullsection"
act = s3:option(DummyValue, "_actions")
act.rawhtml = true
act.cfgvalue = function(self, section)
local ctl_url = luci.dispatcher.build_url("admin", "services", "openclaw", "service_ctl")
local log_url = luci.dispatcher.build_url("admin", "services", "openclaw", "setup_log")
local check_url = luci.dispatcher.build_url("admin", "services", "openclaw", "check_update")
local uninstall_url = luci.dispatcher.build_url("admin", "services", "openclaw", "uninstall")
local plugin_upgrade_url = luci.dispatcher.build_url("admin", "services", "openclaw", "plugin_upgrade")
local plugin_upgrade_log_url = luci.dispatcher.build_url("admin", "services", "openclaw", "plugin_upgrade_log")
local html = {}
-- 按钮区域
html[#html+1] = '<div style="display:flex;gap:10px;flex-wrap:wrap;margin:10px 0;">'
html[#html+1] = '<button class="btn cbi-button cbi-button-apply" type="button" onclick="ocShowSetupDialog()" id="btn-setup" title="下载 Node.js 并安装 OpenClaw">📦 安装运行环境</button>'
html[#html+1] = '<button class="btn cbi-button cbi-button-action" type="button" onclick="ocServiceCtl(\'restart\')">🔄 重启服务</button>'
html[#html+1] = '<button class="btn cbi-button cbi-button-action" type="button" onclick="ocServiceCtl(\'stop\')">⏹️ 停止服务</button>'
html[#html+1] = '<span style="position:relative;display:inline-block;" id="btn-check-update-wrap"><button class="btn cbi-button cbi-button-action" type="button" onclick="ocCheckUpdate()" id="btn-check-update">🔍 检测升级</button><span id="update-dot" style="display:none;position:absolute;top:-2px;right:-2px;width:10px;height:10px;background:#e36209;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #e36209;"></span></span>'
html[#html+1] = '<button class="btn cbi-button cbi-button-action" type="button" onclick="ocBackupRestore()" id="btn-backup" title="备份或恢复 OpenClaw 配置">💾 备份/恢复</button>'
html[#html+1] = '<button class="btn cbi-button cbi-button-remove" type="button" onclick="ocUninstall()" id="btn-uninstall" title="删除 Node.js、OpenClaw 运行环境及相关数据">🗑️ 卸载环境</button>'
html[#html+1] = '</div>'
html[#html+1] = '<div id="action-result" style="margin-top:8px;"></div>'
html[#html+1] = '<div id="oc-update-action" style="margin-top:8px;display:none;"></div>'
-- 版本选择对话框 (默认隐藏)
html[#html+1] = '<div id="oc-setup-dialog" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;">'
html[#html+1] = '<div style="background:#fff;border-radius:12px;padding:24px 28px;max-width:480px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.2);">'
html[#html+1] = '<h3 style="margin:0 0 16px 0;font-size:16px;color:#333;">📦 选择安装版本</h3>'
html[#html+1] = '<div style="display:flex;flex-direction:column;gap:12px;">'
-- 稳定版选项
html[#html+1] = '<label style="display:flex;align-items:flex-start;gap:10px;padding:14px 16px;border:2px solid #4a90d9;border-radius:8px;cursor:pointer;background:#f0f7ff;" id="oc-opt-stable">'
html[#html+1] = '<input type="radio" name="oc-ver-choice" value="stable" checked style="margin-top:2px;">'
html[#html+1] = '<div><strong style="color:#333;">✅ 稳定版 (推荐)</strong>'
html[#html+1] = '<div style="font-size:12px;color:#666;margin-top:4px;">版本 v' .. luci.sys.exec("sed -n 's/^OC_TESTED_VERSION=\"\\(.*\\)\"/\\1/p' /usr/bin/openclaw-env 2>/dev/null"):gsub("%s+", "") .. ',已经过完整测试,兼容性良好。</div>'
html[#html+1] = '</div></label>'
-- 最新版选项
html[#html+1] = '<label style="display:flex;align-items:flex-start;gap:10px;padding:14px 16px;border:2px solid #e0e0e0;border-radius:8px;cursor:pointer;background:#fff;" id="oc-opt-latest">'
html[#html+1] = '<input type="radio" name="oc-ver-choice" value="latest" style="margin-top:2px;">'
html[#html+1] = '<div><strong style="color:#333;">🆕 最新版</strong>'
html[#html+1] = '<div style="font-size:12px;color:#e36209;margin-top:4px;">⚠️ 安装 npm 上的最新发布版本,可能存在未经验证的兼容性问题。</div>'
html[#html+1] = '</div></label>'
html[#html+1] = '</div>'
-- 按钮区
html[#html+1] = '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:20px;">'
html[#html+1] = '<button class="btn cbi-button" type="button" onclick="ocCloseSetupDialog()" style="min-width:80px;">取消</button>'
html[#html+1] = '<button class="btn cbi-button cbi-button-apply" type="button" onclick="ocConfirmSetup()" style="min-width:80px;">开始安装</button>'
html[#html+1] = '</div>'
html[#html+1] = '</div></div>'
-- 安装日志面板 (默认隐藏)
html[#html+1] = '<div id="setup-log-panel" style="display:none;margin-top:12px;">'
html[#html+1] = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">'
html[#html+1] = '<span id="setup-log-title" style="font-weight:600;font-size:14px;">📋 安装日志</span>'
html[#html+1] = '<span id="setup-log-status" style="font-size:12px;color:#999;"></span>'
html[#html+1] = '</div>'
html[#html+1] = '<pre id="setup-log-content" style="background:#1a1b26;color:#a9b1d6;padding:14px 16px;border-radius:6px;font-size:12px;line-height:1.6;max-height:400px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;border:1px solid #2d333b;margin:0;"></pre>'
html[#html+1] = '<div id="setup-log-result" style="margin-top:10px;display:none;"></div>'
html[#html+1] = '</div>'
-- JavaScript
html[#html+1] = '<script type="text/javascript">'
-- 版本选择对话框逻辑
html[#html+1] = 'var _setupTimer=null;'
html[#html+1] = 'function ocShowSetupDialog(){'
html[#html+1] = 'var dlg=document.getElementById("oc-setup-dialog");'
html[#html+1] = 'dlg.style.display="flex";'
html[#html+1] = 'var radios=document.getElementsByName("oc-ver-choice");'
html[#html+1] = 'for(var i=0;i<radios.length;i++){if(radios[i].value==="stable")radios[i].checked=true;}'
html[#html+1] = '}'
html[#html+1] = 'function ocCloseSetupDialog(){'
html[#html+1] = 'document.getElementById("oc-setup-dialog").style.display="none";'
html[#html+1] = '}'
html[#html+1] = 'function ocConfirmSetup(){'
html[#html+1] = 'ocCloseSetupDialog();'
html[#html+1] = 'var radios=document.getElementsByName("oc-ver-choice");'
html[#html+1] = 'var choice="stable";'
html[#html+1] = 'for(var i=0;i<radios.length;i++){if(radios[i].checked){choice=radios[i].value;break;}}'
html[#html+1] = 'var verParam=(choice==="stable")?"stable":"latest";'
html[#html+1] = 'ocSetup(verParam);'
html[#html+1] = '}'
-- 安装运行环境 (带实时日志)
html[#html+1] = 'function ocSetup(version){'
html[#html+1] = 'var btn=document.getElementById("btn-setup");'
html[#html+1] = 'var panel=document.getElementById("setup-log-panel");'
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
html[#html+1] = 'var titleEl=document.getElementById("setup-log-title");'
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
html[#html+1] = 'var resultEl=document.getElementById("setup-log-result");'
html[#html+1] = 'var actionEl=document.getElementById("action-result");'
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 安装中...";'
html[#html+1] = 'actionEl.textContent="";'
html[#html+1] = 'panel.style.display="block";'
html[#html+1] = 'logEl.textContent="正在启动安装 ("+((version==="stable")?"稳定版":"最新版")+")...\\n";'
html[#html+1] = 'titleEl.textContent="📋 安装日志";'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 安装进行中...</span>";'
html[#html+1] = 'resultEl.style.display="none";'
html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action=setup&version="+encodeURIComponent(version),null,function(x){'
html[#html+1] = 'try{JSON.parse(x.responseText);}catch(e){}'
html[#html+1] = 'ocPollSetupLog();'
html[#html+1] = '});'
html[#html+1] = '}'
-- 轮询安装日志
html[#html+1] = 'function ocPollSetupLog(){'
html[#html+1] = 'if(_setupTimer)clearInterval(_setupTimer);'
html[#html+1] = '_setupTimer=setInterval(function(){'
html[#html+1] = '(new XHR()).get("' .. log_url .. '",null,function(x){'
html[#html+1] = 'try{'
html[#html+1] = 'var r=JSON.parse(x.responseText);'
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
html[#html+1] = 'if(r.log)logEl.textContent=r.log;'
html[#html+1] = 'logEl.scrollTop=logEl.scrollHeight;'
html[#html+1] = 'if(r.state==="running"){'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 安装进行中...</span>";'
html[#html+1] = '}else if(r.state==="success"){'
html[#html+1] = 'clearInterval(_setupTimer);_setupTimer=null;'
html[#html+1] = 'ocSetupDone(true,r.log);'
html[#html+1] = '}else if(r.state==="failed"){'
html[#html+1] = 'clearInterval(_setupTimer);_setupTimer=null;'
html[#html+1] = 'ocSetupDone(false,r.log);'
html[#html+1] = '}'
html[#html+1] = '}catch(e){}'
html[#html+1] = '});'
html[#html+1] = '},1500);'
html[#html+1] = '}'
-- 安装完成处理
html[#html+1] = 'function ocSetupDone(ok,log){'
html[#html+1] = 'var btn=document.getElementById("btn-setup");'
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
html[#html+1] = 'var resultEl=document.getElementById("setup-log-result");'
html[#html+1] = 'btn.disabled=false;btn.textContent="📦 安装运行环境";'
html[#html+1] = 'resultEl.style.display="block";'
html[#html+1] = 'if(ok){'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#1a7f37;\\">✅ 安装完成</span>";'
html[#html+1] = 'resultEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:12px 16px;border-radius:6px;\\">"+'
html[#html+1] = '"<strong style=\\"color:#1a7f37;font-size:14px;\\">🎉 恭喜OpenClaw 运行环境安装成功!</strong><br/>"+'
html[#html+1] = '"<span style=\\"color:#555;font-size:13px;line-height:1.8;\\">服务已自动启用并启动,点击下方按钮刷新页面查看运行状态。</span><br/>"+'
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-apply\\" type=\\"button\\" onclick=\\"location.reload()\\" style=\\"margin-top:10px;\\">🔄 刷新页面</button></div>";'
html[#html+1] = '}else{'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#cf222e;\\">❌ 安装失败</span>";'
-- 分析失败原因
html[#html+1] = 'var reasons=ocAnalyzeFailure(log);'
html[#html+1] = 'resultEl.innerHTML="<div style=\\"border:1px solid #f5c6cb;background:#ffeef0;padding:12px 16px;border-radius:6px;\\">"+'
html[#html+1] = '"<strong style=\\"color:#cf222e;font-size:14px;\\">❌ 安装失败</strong><br/>"+'
html[#html+1] = '"<div style=\\"margin:8px 0;padding:10px 14px;background:#fff5f5;border-radius:4px;font-size:13px;line-height:1.8;\\">"+'
html[#html+1] = '"<strong>🔍 可能的失败原因:</strong><br/>"+reasons+"</div>"+'
html[#html+1] = '"<div style=\\"margin-top:8px;font-size:12px;color:#666;\\">💡 完整日志见上方终端输出,也可在终端查看:<code>cat /tmp/openclaw-setup.log</code></div></div>";'
html[#html+1] = '}'
html[#html+1] = '}'
-- 分析失败原因
html[#html+1] = 'function ocAnalyzeFailure(log){'
html[#html+1] = 'var reasons=[];'
html[#html+1] = 'if(!log)return"未知错误,请检查日志。";'
html[#html+1] = 'var ll=log.toLowerCase();'
-- 网络问题
html[#html+1] = 'if(ll.indexOf("could not resolve")>=0||ll.indexOf("connection timed out")>=0||ll.indexOf("curl")>=0&&ll.indexOf("fail")>=0||ll.indexOf("wget")>=0&&ll.indexOf("fail")>=0||ll.indexOf("所有镜像均下载失败")>=0){'
html[#html+1] = 'reasons.push("🌐 <b>网络连接失败</b> — 无法下载 Node.js。请检查路由器是否能访问外网。<br/>&nbsp;&nbsp;💡 解决: 检查 DNS 设置和网络连接,或手动指定镜像: <code>NODE_MIRROR=https://npmmirror.com/mirrors/node openclaw-env setup</code>");'
html[#html+1] = '}'
-- 磁盘空间
html[#html+1] = 'if(ll.indexOf("no space")>=0||ll.indexOf("disk full")>=0||ll.indexOf("enospc")>=0){'
html[#html+1] = 'reasons.push("💾 <b>磁盘空间不足</b> — Node.js + OpenClaw 需要约 200MB 空间。<br/>&nbsp;&nbsp;💡 解决: 运行 <code>df -h</code> 检查可用空间,清理不需要的文件或使用外部存储。");'
html[#html+1] = '}'
-- 架构不支持
html[#html+1] = 'if(ll.indexOf("不支持的 cpu 架构")>=0||ll.indexOf("不支持的架构")>=0){'
html[#html+1] = 'reasons.push("🔧 <b>CPU 架构不支持</b> — 仅支持 x86_64 和 aarch64 (ARM64)。<br/>&nbsp;&nbsp;💡 当前设备架构可能是 32 位 ARM 或 MIPS无法运行 Node.js 22。");'
html[#html+1] = '}'
-- npm 安装失败
html[#html+1] = 'if(ll.indexOf("npm err")>=0||ll.indexOf("npm warn")>=0&&ll.indexOf("openclaw 安装验证失败")>=0){'
html[#html+1] = 'reasons.push("📦 <b>npm 安装 OpenClaw 失败</b> — npm 包下载或安装出错。<br/>&nbsp;&nbsp;💡 解决: 尝试手动安装 <code>PATH=/opt/openclaw/node/bin:$PATH npm install -g openclaw@latest --prefix=/opt/openclaw/global</code>");'
html[#html+1] = '}'
-- 权限问题
html[#html+1] = 'if(ll.indexOf("permission denied")>=0||ll.indexOf("eacces")>=0){'
html[#html+1] = 'reasons.push("🔒 <b>权限不足</b> — 文件或目录权限问题。<br/>&nbsp;&nbsp;💡 解决: 运行 <code>chown -R openclaw:openclaw /opt/openclaw</code> 或以 root 用户重试。");'
html[#html+1] = '}'
-- tar 解压失败
html[#html+1] = 'if(ll.indexOf("tar")>=0&&(ll.indexOf("error")>=0||ll.indexOf("fail")>=0)){'
html[#html+1] = 'reasons.push("📂 <b>解压失败</b> — Node.js 安装包可能下载不完整。<br/>&nbsp;&nbsp;💡 解决: 删除缓存重试 <code>rm -rf /opt/openclaw/node && openclaw-env setup</code>");'
html[#html+1] = '}'
-- 验证失败
html[#html+1] = 'if(ll.indexOf("安装验证失败")>=0){'
html[#html+1] = 'reasons.push("⚠️ <b>安装验证失败</b> — 程序已下载但无法正常运行。<br/>&nbsp;&nbsp;💡 可能是 glibc/musl 不兼容,请确认系统 C 库类型: <code>ldd --version 2>&1 | head -1</code>");'
html[#html+1] = '}'
-- 兜底
html[#html+1] = 'if(reasons.length===0){'
html[#html+1] = 'reasons.push("⚠️ <b>未识别的错误</b> — 请查看上方完整日志分析具体原因。<br/>&nbsp;&nbsp;💡 您也可以尝试手动执行: <code>openclaw-env setup</code> 查看详细输出。");'
html[#html+1] = '}'
html[#html+1] = 'return reasons.join("<br/><br/>");'
html[#html+1] = '}'
-- 普通服务操作 (restart/stop)
html[#html+1] = 'function ocServiceCtl(action){'
html[#html+1] = 'var el=document.getElementById("action-result");'
html[#html+1] = 'el.innerHTML="<span style=\\"color:#999\\">⏳ 正在执行...</span>";'
html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action="+action,null,function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){el.innerHTML="<span style=\\"color:green\\">✅ "+action+" 已完成</span>";}'
html[#html+1] = 'else{el.innerHTML="<span style=\\"color:red\\">❌ "+(r.message||"失败")+"</span>";}'
html[#html+1] = '}catch(e){el.innerHTML="<span style=\\"color:red\\">❌ 错误</span>";}'
html[#html+1] = '});}'
-- 检测升级 (只检查插件版本,有新版本时显示更新内容)
html[#html+1] = 'function ocCheckUpdate(){'
html[#html+1] = 'var btn=document.getElementById("btn-check-update");'
html[#html+1] = 'var el=document.getElementById("action-result");'
html[#html+1] = 'var act=document.getElementById("oc-update-action");'
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 正在检测...";el.textContent="";act.style.display="none";'
html[#html+1] = '(new XHR()).get("' .. check_url .. '",null,function(x){'
html[#html+1] = 'btn.disabled=false;btn.textContent="🔍 检测升级";'
html[#html+1] = 'var dot=document.getElementById("update-dot");if(dot)dot.style.display="none";'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'var msgs=[];'
-- 插件版本检查
html[#html+1] = 'if(r.plugin_current){'
html[#html+1] = 'if(r.plugin_has_update){msgs.push("<span style=\\"color:#e36209\\">🔌 插件: v"+r.plugin_current+" → v"+r.plugin_latest+" (有新版本)</span>");}'
html[#html+1] = 'else if(r.plugin_latest){msgs.push("<span style=\\"color:green\\">✅ 插件: v"+r.plugin_current+" (已是最新)</span>");}'
html[#html+1] = 'else{msgs.push("<span style=\\"color:#999\\">🔌 插件: v"+r.plugin_current+" (无法检查最新版本)</span>");}'
html[#html+1] = '}'
html[#html+1] = 'if(msgs.length===0)msgs.push("<span style=\\"color:#999\\">无法获取版本信息</span>");'
html[#html+1] = 'el.innerHTML=msgs.join("<br/>");'
-- 插件有更新时: release notes + 一键升级按钮 + GitHub 下载链接
html[#html+1] = 'if(r.plugin_has_update){'
html[#html+1] = 'act.style.display="block";'
html[#html+1] = 'window._pluginLatestVer=r.plugin_latest;'
html[#html+1] = 'var notesHtml="";'
html[#html+1] = 'if(r.release_notes){'
html[#html+1] = 'var escaped=r.release_notes.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");'
html[#html+1] = 'notesHtml=\'<div style="margin:10px 0 8px;padding:10px 14px;background:#fffbf0;border:1px solid #f0c040;border-radius:6px;">\''
html[#html+1] = '+\'<div style="font-size:12px;font-weight:600;color:#8a6a00;margin-bottom:6px;">📋 v\'+r.plugin_latest+\' 更新内容</div>\''
html[#html+1] = '+\'<pre style="margin:0;font-size:12px;color:#444;white-space:pre-wrap;word-break:break-word;line-height:1.6;">\'+escaped+\'</pre>\''
html[#html+1] = '+\'</div>\';'
html[#html+1] = '}'
html[#html+1] = 'act.innerHTML=notesHtml'
html[#html+1] = '+\'<button class="btn cbi-button cbi-button-apply" type="button" onclick="ocPluginUpgrade()" id="btn-plugin-upgrade">⬆️ 升级插件 v\'+r.plugin_latest+\'</button>\''
html[#html+1] = '+\' <a href="https://github.com/10000ge10000/luci-app-openclaw/releases/latest" target="_blank" rel="noopener" class="btn cbi-button cbi-button-action" style="text-decoration:none;">📥 手动下载</a>\';'
html[#html+1] = '}'
html[#html+1] = '}catch(e){el.innerHTML="<span style=\\"color:red\\">❌ 检测失败</span>";}'
html[#html+1] = '});}'
-- ═══ 插件一键升级 ═══
html[#html+1] = 'var _pluginUpgradeTimer=null;'
html[#html+1] = 'function ocPluginUpgrade(){'
html[#html+1] = 'var ver=window._pluginLatestVer;'
html[#html+1] = 'if(!ver){alert("无法获取最新版本号");return;}'
html[#html+1] = 'if(!confirm("确定要升级插件到 v"+ver+"\\n\\n升级会替换插件文件并清除 LuCI 缓存,不会影响正在运行的 OpenClaw 服务。"))return;'
html[#html+1] = 'var btn=document.getElementById("btn-plugin-upgrade");'
html[#html+1] = 'var panel=document.getElementById("setup-log-panel");'
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
html[#html+1] = 'var titleEl=document.getElementById("setup-log-title");'
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
html[#html+1] = 'var resultEl=document.getElementById("setup-log-result");'
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 正在升级插件...";'
html[#html+1] = 'panel.style.display="block";'
html[#html+1] = 'logEl.textContent="正在启动插件升级...\\n";'
html[#html+1] = 'titleEl.textContent="📋 插件升级日志";'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 插件升级中...</span>";'
html[#html+1] = 'resultEl.style.display="none";'
html[#html+1] = '(new XHR()).get("' .. plugin_upgrade_url .. '?version="+encodeURIComponent(ver),null,function(x){'
html[#html+1] = 'try{JSON.parse(x.responseText);}catch(e){}'
html[#html+1] = 'ocPollPluginUpgradeLog();'
html[#html+1] = '});'
html[#html+1] = '}'
-- 轮询插件升级日志 (带容错: 安装时文件被替换可能导致API暂时不可用)
html[#html+1] = 'var _pluginPollErrors=0;'
html[#html+1] = 'function ocPollPluginUpgradeLog(){'
html[#html+1] = 'if(_pluginUpgradeTimer)clearInterval(_pluginUpgradeTimer);'
html[#html+1] = '_pluginPollErrors=0;'
html[#html+1] = '_pluginUpgradeTimer=setInterval(function(){'
html[#html+1] = '(new XHR()).get("' .. plugin_upgrade_log_url .. '",null,function(x){'
html[#html+1] = 'try{'
html[#html+1] = 'var r=JSON.parse(x.responseText);'
html[#html+1] = '_pluginPollErrors=0;'
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
html[#html+1] = 'if(r.log)logEl.textContent=r.log;'
html[#html+1] = 'logEl.scrollTop=logEl.scrollHeight;'
html[#html+1] = 'if(r.state==="running"){'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 插件升级中...</span>";'
html[#html+1] = '}else if(r.state==="success"){'
html[#html+1] = 'clearInterval(_pluginUpgradeTimer);_pluginUpgradeTimer=null;'
html[#html+1] = 'ocPluginUpgradeDone(true);'
html[#html+1] = '}else if(r.state==="failed"){'
html[#html+1] = 'clearInterval(_pluginUpgradeTimer);_pluginUpgradeTimer=null;'
html[#html+1] = 'ocPluginUpgradeDone(false);'
html[#html+1] = '}'
html[#html+1] = '}catch(e){'
html[#html+1] = '_pluginPollErrors++;'
html[#html+1] = 'if(_pluginPollErrors>=8){'
html[#html+1] = 'clearInterval(_pluginUpgradeTimer);_pluginUpgradeTimer=null;'
html[#html+1] = 'ocPluginUpgradeDone(true);'
html[#html+1] = '}'
html[#html+1] = '}'
html[#html+1] = '});'
html[#html+1] = '},2000);'
html[#html+1] = '}'
-- 插件升级完成处理
html[#html+1] = 'function ocPluginUpgradeDone(ok){'
html[#html+1] = 'var btn=document.getElementById("btn-plugin-upgrade");'
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
html[#html+1] = 'var resultEl=document.getElementById("setup-log-result");'
html[#html+1] = 'if(btn){btn.disabled=false;btn.textContent="⬆️ 升级插件";}'
html[#html+1] = 'resultEl.style.display="block";'
html[#html+1] = 'if(ok){'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#1a7f37;\\">✅ 插件升级完成</span>";'
html[#html+1] = 'resultEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:12px 16px;border-radius:6px;\\">"+'
html[#html+1] = '"<strong style=\\"color:#1a7f37;font-size:14px;\\">🎉 插件升级成功!</strong><br/>"+'
html[#html+1] = '"<span style=\\"color:#555;font-size:13px;line-height:1.8;\\">插件文件已更新OpenClaw 服务不受影响。请刷新页面加载新版界面。</span><br/>"+'
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-apply\\" type=\\"button\\" onclick=\\"location.reload()\\" style=\\"margin-top:10px;\\">🔄 刷新页面</button></div>";'
html[#html+1] = '}else{'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#cf222e;\\">❌ 插件升级失败</span>";'
html[#html+1] = 'resultEl.innerHTML="<div style=\\"border:1px solid #f5c6cb;background:#ffeef0;padding:12px 16px;border-radius:6px;\\">"+'
html[#html+1] = '"<strong style=\\"color:#cf222e;font-size:14px;\\">❌ 插件升级失败</strong><br/>"+'
html[#html+1] = '"<span style=\\"color:#555;font-size:13px;\\">请查看上方日志了解详情。也可手动执行:<code>cat /tmp/openclaw-plugin-upgrade.log</code></span><br/>"+'
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-apply\\" type=\\"button\\" onclick=\\"location.reload()\\" style=\\"margin-top:10px;\\">🔄 刷新页面</button></div>";'
html[#html+1] = '}'
html[#html+1] = '}'
-- 卸载运行环境
html[#html+1] = 'function ocUninstall(){'
html[#html+1] = 'if(!confirm("确定要卸载 OpenClaw 运行环境?\\n\\n将删除 Node.js、OpenClaw 程序及配置数据(/opt/openclaw 目录),服务将停止运行。\\n\\n插件本身不会被删除之后可重新安装运行环境。"))return;'
html[#html+1] = 'var btn=document.getElementById("btn-uninstall");'
html[#html+1] = 'var el=document.getElementById("action-result");'
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 正在卸载...";'
html[#html+1] = 'el.innerHTML="<span style=\\"color:#999\\">正在停止服务并清理文件...</span>";'
html[#html+1] = '(new XHR()).get("' .. uninstall_url .. '",null,function(x){'
html[#html+1] = 'btn.disabled=false;btn.textContent="🗑️ 卸载环境";'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){'
html[#html+1] = 'el.innerHTML="<div style=\\"border:1px solid #d0d7de;background:#f6f8fa;padding:12px 16px;border-radius:6px;\\">"+'
html[#html+1] = '"<strong style=\\"color:#1a7f37;\\">✅ 卸载完成</strong><br/>"+'
html[#html+1] = '"<span style=\\"color:#555;font-size:13px;\\">"+r.message+"</span><br/>"+'
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-apply\\" type=\\"button\\" onclick=\\"location.reload()\\" style=\\"margin-top:8px;\\">🔄 刷新页面</button></div>";'
html[#html+1] = '}else{el.innerHTML="<span style=\\"color:red\\">❌ "+(r.message||"卸载失败")+"</span>";}'
html[#html+1] = '}catch(e){el.innerHTML="<span style=\\"color:red\\">❌ 请求失败</span>";}'
html[#html+1] = '});}'
-- ═══ 备份/恢复 对话框 + 功能 (v2026.3.8+ openclaw backup) ═══
local backup_url = luci.dispatcher.build_url("admin", "services", "openclaw", "backup")
-- 先关闭 script插入对话框 HTML再重新打开 script
html[#html+1] = '</script>'
-- 对话框 HTML (附加到按钮区域后面)
html[#html+1] = '<div id="oc-backup-dialog" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;">'
html[#html+1] = '<div style="background:#fff;border-radius:12px;padding:24px 28px;max-width:520px;width:92%;box-shadow:0 8px 32px rgba(0,0,0,0.2);">'
html[#html+1] = '<h3 style="margin:0 0 16px 0;font-size:16px;color:#333;">💾 备份 / 恢复配置</h3>'
-- 备份操作区
html[#html+1] = '<div style="margin-bottom:16px;">'
html[#html+1] = '<div style="font-weight:600;font-size:13px;color:#555;margin-bottom:8px;">📤 创建备份</div>'
html[#html+1] = '<div style="display:flex;gap:10px;">'
html[#html+1] = '<button class="btn cbi-button cbi-button-apply" type="button" onclick="ocDoBackup(1)" id="btn-bk-config" style="font-size:12px;">📄 仅配置文件</button>'
html[#html+1] = '<button class="btn cbi-button cbi-button-action" type="button" onclick="ocDoBackup(0)" id="btn-bk-full" style="font-size:12px;">📦 配置 + 状态数据</button>'
html[#html+1] = '</div>'
html[#html+1] = '<div style="font-size:11px;color:#888;margin-top:6px;">仅配置文件 (~2KB) 包含模型、渠道、插件设置;完整备份含会话历史等状态数据(可能较大)</div>'
html[#html+1] = '</div>'
-- 备份列表区(恢复/删除在这里动态渲染)
html[#html+1] = '<div style="border-top:1px solid #eee;padding-top:14px;margin-bottom:16px;">'
html[#html+1] = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">'
html[#html+1] = '<div style="font-weight:600;font-size:13px;color:#555;">📥 现有备份</div>'
html[#html+1] = '<button class="btn cbi-button" type="button" onclick="ocLoadBackupList()" style="font-size:11px;padding:2px 10px;">🔄 刷新</button>'
html[#html+1] = '</div>'
html[#html+1] = '<div id="oc-backup-list" style="max-height:260px;overflow-y:auto;"></div>'
html[#html+1] = '</div>'
-- 操作结果提示区
html[#html+1] = '<div id="oc-backup-result" style="margin-bottom:14px;display:none;"></div>'
-- 关闭按钮
html[#html+1] = '<div style="display:flex;justify-content:flex-end;">'
html[#html+1] = '<button class="btn cbi-button" type="button" onclick="document.getElementById(\'oc-backup-dialog\').style.display=\'none\';" style="min-width:80px;">关闭</button>'
html[#html+1] = '</div>'
html[#html+1] = '</div></div>'
-- 重新打开 script 继续 JS 函数
html[#html+1] = '<script type="text/javascript">'
-- 打开备份/恢复对话框并加载列表
html[#html+1] = 'function ocBackupRestore(){'
html[#html+1] = 'var dlg=document.getElementById("oc-backup-dialog");'
html[#html+1] = 'dlg.style.display="flex";'
html[#html+1] = 'document.getElementById("oc-backup-result").style.display="none";'
html[#html+1] = 'ocLoadBackupList();'
html[#html+1] = '}'
-- 加载备份文件列表
html[#html+1] = 'function ocLoadBackupList(){'
html[#html+1] = 'var el=document.getElementById("oc-backup-list");'
html[#html+1] = 'el.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 加载备份列表...</div>";'
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=list",null,function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"&&r.backups&&r.backups.length>0){'
html[#html+1] = 'var h="<table style=\\"width:100%;border-collapse:collapse;font-size:12px;\\">";'
html[#html+1] = 'h+="<tr style=\\"background:#f6f8fa;border-bottom:2px solid #d0d7de;\\">"+'
html[#html+1] = '"<th style=\\"padding:6px 8px;text-align:left;\\">类型</th>"+'
html[#html+1] = '"<th style=\\"padding:6px 8px;text-align:left;\\">备份时间</th>"+'
html[#html+1] = '"<th style=\\"padding:6px 8px;text-align:right;\\">大小</th>"+'
html[#html+1] = '"<th style=\\"padding:6px 8px;text-align:center;\\">操作</th></tr>";'
html[#html+1] = 'for(var i=0;i<r.backups.length;i++){'
html[#html+1] = 'var b=r.backups[i];'
html[#html+1] = 'var typeBadge=b.backup_type==="config"?'
html[#html+1] = '"<span style=\\"background:#ddf4ff;color:#0969da;padding:2px 6px;border-radius:3px;font-size:11px;white-space:nowrap;\\">📄 仅配置</span>":'
html[#html+1] = '"<span style=\\"background:#fff8c5;color:#9a6700;padding:2px 6px;border-radius:3px;font-size:11px;white-space:nowrap;\\">📦 完整备份</span>";'
html[#html+1] = 'var rowBg=i%2===0?"#fff":"#f6f8fa";'
html[#html+1] = 'h+="<tr style=\\"border-bottom:1px solid #eee;background:"+rowBg+";\\">"+' -- 行开始
html[#html+1] = '"<td style=\\"padding:7px 8px;\\">"+typeBadge+"</td>"+' -- 类型
html[#html+1] = '"<td style=\\"padding:7px 8px;color:#555;white-space:nowrap;\\">"+b.time+"</td>"+' -- 时间
html[#html+1] = '"<td style=\\"padding:7px 8px;text-align:right;color:#666;white-space:nowrap;\\">"+b.size_str+"</td>"+' -- 大小
html[#html+1] = '"<td style=\\"padding:5px 8px;text-align:center;white-space:nowrap;\\">"+'
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-action\\" style=\\"font-size:11px;padding:1px 8px;margin-right:4px;\\" onclick=\\"ocRestoreBackup(\\x27"+b.filename+"\\x27)\\">恢复</button>"+'
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-remove\\" style=\\"font-size:11px;padding:1px 8px;\\" onclick=\\"ocDeleteBackup(\\x27"+b.filename+"\\x27)\\">删除</button>"+'
html[#html+1] = '"</td></tr>";'
html[#html+1] = '}'
html[#html+1] = 'h+="</table>";'
html[#html+1] = 'el.innerHTML=h;'
html[#html+1] = '}else if(r.status==="ok"){'
html[#html+1] = 'el.innerHTML="<div style=\\"color:#888;font-size:12px;padding:8px;text-align:center;\\">暂无备份,请先创建备份</div>";'
html[#html+1] = '}else{'
html[#html+1] = 'el.innerHTML="<div style=\\"color:#e36209;font-size:12px;padding:8px;\\">⚠️ "+(r.message||"获取列表失败")+"</div>";'
html[#html+1] = '}'
html[#html+1] = '}catch(e){el.innerHTML="<div style=\\"color:#e36209;font-size:12px;padding:8px;\\">⚠️ 无法加载列表</div>";}'
html[#html+1] = '});}'
-- 创建备份(创建完成后刷新列表)
html[#html+1] = 'function ocDoBackup(onlyConfig){'
html[#html+1] = 'var resEl=document.getElementById("oc-backup-result");'
html[#html+1] = 'var btnC=document.getElementById("btn-bk-config");'
html[#html+1] = 'var btnF=document.getElementById("btn-bk-full");'
html[#html+1] = 'btnC.disabled=true;btnF.disabled=true;'
html[#html+1] = 'resEl.style.display="block";'
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 正在创建备份..."+(onlyConfig?"(仅配置)":"(完整备份,可能需要较长时间)")+"</div>";'
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=create&only_config="+onlyConfig,null,function(x){'
html[#html+1] = 'btnC.disabled=false;btnF.disabled=false;'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){'
html[#html+1] = 'resEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:10px 14px;border-radius:6px;font-size:12px;\\">"+'
html[#html+1] = '"<strong style=\\"color:#1a7f37;\\">✅ 备份完成</strong></div>";'
html[#html+1] = 'ocLoadBackupList();'
html[#html+1] = '}else{'
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#e36209;font-size:12px;padding:8px;\\">⚠️ "+(r.message||"备份失败")+"</div>";'
html[#html+1] = '}'
html[#html+1] = '}catch(e){resEl.innerHTML="<div style=\\"color:#e36209;font-size:12px;padding:8px;\\">⚠️ 备份功能需要 OpenClaw v2026.3.8+</div>";}'
html[#html+1] = '});}'
-- 恢复指定备份
html[#html+1] = 'function ocRestoreBackup(filename){'
html[#html+1] = 'if(!confirm("确定要从此备份恢复配置?\\n\\n"+filename+"\\n\\n当前 openclaw.json 将被备份中的版本覆盖,服务将自动重启。"))return;'
html[#html+1] = 'var resEl=document.getElementById("oc-backup-result");'
html[#html+1] = 'resEl.style.display="block";'
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 正在恢复配置...</div>";'
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=restore&file="+encodeURIComponent(filename),null,function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){'
html[#html+1] = 'resEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:10px 14px;border-radius:6px;font-size:12px;\\">"+'
html[#html+1] = '"<strong style=\\"color:#1a7f37;\\">✅ 配置已恢复</strong><br/>"+'
html[#html+1] = '"<span style=\\"color:#555;\\">"+r.message+"</span><br/>"+'
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-apply\\" type=\\"button\\" onclick=\\"location.reload()\\" style=\\"margin-top:6px;font-size:12px;\\">🔄 刷新页面</button></div>";'
html[#html+1] = '}else{'
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#cf222e;font-size:12px;padding:8px;\\">❌ "+(r.message||"恢复失败")+"</div>";'
html[#html+1] = '}'
html[#html+1] = '}catch(e){resEl.innerHTML="<div style=\\"color:#cf222e;font-size:12px;padding:8px;\\">❌ 恢复失败,请检查日志</div>";}'
html[#html+1] = '});}'
-- 删除指定备份
html[#html+1] = 'function ocDeleteBackup(filename){'
html[#html+1] = 'if(!confirm("确定要删除此备份?\\n\\n"+filename+"\\n\\n删除后无法恢复。"))return;'
html[#html+1] = 'var resEl=document.getElementById("oc-backup-result");'
html[#html+1] = 'resEl.style.display="block";'
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 正在删除...</div>";'
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=delete&file="+encodeURIComponent(filename),null,function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){'
html[#html+1] = 'resEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:10px 14px;border-radius:6px;font-size:12px;\\">"+'
html[#html+1] = '"<strong style=\\"color:#1a7f37;\\">✅ "+r.message+"</strong></div>";'
html[#html+1] = 'ocLoadBackupList();'
html[#html+1] = '}else{'
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#cf222e;font-size:12px;padding:8px;\\">❌ "+(r.message||"删除失败")+"</div>";'
html[#html+1] = '}'
html[#html+1] = '}catch(e){resEl.innerHTML="<div style=\\"color:#cf222e;font-size:12px;padding:8px;\\">❌ 删除失败</div>";}'
html[#html+1] = '});}'
-- 页面加载时静默检查是否有更新 (仅显示小红点提示)
html[#html+1] = '(function(){'
html[#html+1] = 'setTimeout(function(){'
html[#html+1] = '(new XHR()).get("' .. check_url .. '",null,function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.plugin_has_update){'
html[#html+1] = 'var dot=document.getElementById("update-dot");'
html[#html+1] = 'if(dot)dot.style.display="block";'
html[#html+1] = '}'
html[#html+1] = '}catch(e){}'
html[#html+1] = '});'
html[#html+1] = '},2000);'
html[#html+1] = '})();'
html[#html+1] = '</script>'
return table.concat(html, "\n")
end
-- ═══════════════════════════════════════════
-- 使用指南
-- ═══════════════════════════════════════════
s4 = m:section(SimpleSection, nil)
s4.template = "cbi/nullsection"
guide = s4:option(DummyValue, "_guide")
guide.rawhtml = true
guide.cfgvalue = function()
local html = {}
html[#html+1] = '<div style="border:1px solid #d0e8ff;background:#f0f7ff;padding:14px 18px;border-radius:6px;margin-top:12px;line-height:1.8;font-size:13px;">'
html[#html+1] = '<strong style="font-size:14px;">📖 使用指南</strong><br/>'
html[#html+1] = '<span style="color:#555;">'
html[#html+1] = '① 首次使用请点击 <b>「安装运行环境」</b>,安装完成后服务会自动启动<br/>'
html[#html+1] = '② 进入 <b>「配置管理」</b> 使用交互式向导快速配置 AI 模型和 API Key<br/>'
html[#html+1] = '③ 进入 <b>「Web 控制台」</b> 配置消息渠道,直接开始对话</span>'
html[#html+1] = '<div style="margin-top:10px;padding-top:10px;border-top:1px solid #d0e8ff;">'
html[#html+1] = '<span style="color:#888;">有疑问请关注B站并留言</span>'
html[#html+1] = '<a href="https://space.bilibili.com/59438380" target="_blank" rel="noopener" style="color:#00a1d6;font-weight:bold;text-decoration:none;">'
html[#html+1] = '🔗 space.bilibili.com/59438380</a>'
html[#html+1] = '<span style="margin-left:16px;color:#888;">GitHub 项目:</span>'
html[#html+1] = '<a href="https://github.com/10000ge10000/luci-app-openclaw" target="_blank" rel="noopener" style="color:#24292f;font-weight:bold;text-decoration:none;">'
html[#html+1] = '🐙 10000ge10000/luci-app-openclaw</a></div></div>'
return table.concat(html, "\n")
end
return m

View File

@ -0,0 +1,122 @@
<%#
luci-app-openclaw — 终端配置页面 (简洁版)
-%>
<%+header%>
<%
local uci = require "luci.model.uci".cursor()
local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
%>
<style type="text/css">
.oc-page-header { margin: 0 0 16px 0; }
.oc-page-header h2 { font-size: 18px; font-weight: 600; color: #333; margin: 0 0 6px 0; }
.oc-page-header p { font-size: 13px; color: #666; margin: 0; line-height: 1.6; }
.oc-terminal-wrap {
border: 2px solid #2d333b;
border-radius: 8px;
overflow: hidden;
background: #1a1b26;
}
#oc-terminal-iframe { width: 100%; height: 600px; border: none; display: block; }
.oc-terminal-loading {
display: flex; align-items: center; justify-content: center;
height: 200px; color: #7aa2f7; font-size: 14px; background: #1a1b26;
}
</style>
<div class="oc-page-header">
<h2>⚙️ 终端配置</h2>
<p>通过内嵌的交互式终端 (oc-config) 进行 OpenClaw 的完整配置管理。支持 AI 模型配置、消息渠道设置QQ、Telegram、Discord 等)、健康检查等。</p>
</div>
<div class="oc-terminal-wrap">
<div id="oc-terminal-container">
<div class="oc-terminal-loading" id="oc-terminal-loading">
⏳ 正在连接配置终端...
</div>
</div>
</div>
<script type="text/javascript">
//<![CDATA[
(function() {
var ptyPort = '<%=pty_port%>';
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
var tokenUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "get_token")%>';
var container = document.getElementById('oc-terminal-container');
var loading = document.getElementById('oc-terminal-loading');
var ptyToken = '';
function checkAndLoadTerminal() {
// 先获取 PTY token
(new XHR()).get(tokenUrl, null, function(tx) {
try {
var td = JSON.parse(tx.responseText);
ptyToken = td.pty_token || '';
} catch(e) {}
(new XHR()).get(statusUrl, null, function(x) {
try {
var d = JSON.parse(x.responseText);
if (d.pty_running) {
showIframe();
} else {
loading.innerHTML = '❌ 配置终端未运行<br/>' +
'<span style="font-size:12px;color:#999;">请先在「基本设置」中启用并启动服务。</span>';
}
} catch(e) {
loading.textContent = '检查终端状态失败';
}
});
});
}
function showIframe() {
var proto = window.location.protocol;
var host = window.location.hostname;
var url = proto + '//' + host + ':' + ptyPort + '/';
if (ptyToken) url += '?pty_token=' + encodeURIComponent(ptyToken);
loading.style.display = 'none';
var iframe = document.createElement('iframe');
iframe.id = 'oc-terminal-iframe';
iframe.src = url;
iframe.setAttribute('allowfullscreen', 'true');
iframe.setAttribute('allow', 'clipboard-read; clipboard-write');
// 防止 LuCI 父页面键盘事件干扰 iframe 内的终端输入
iframe.addEventListener('load', function() {
try { iframe.contentWindow.focus(); } catch(e) {}
});
container.appendChild(iframe);
// 点击 iframe 区域时确保 focus 在 iframe 内
container.addEventListener('click', function() {
try { iframe.contentWindow.focus(); } catch(e) {}
});
// 当 iframe 获得焦点时,阻止 LuCI 父页面的键盘事件处理
// 防止按键(如 Enter/数字键)触发 LuCI 的导航或表单提交
var termWrap = document.querySelector('.oc-terminal-wrap');
if (termWrap) {
termWrap.addEventListener('keydown', function(e) { e.stopPropagation(); }, true);
termWrap.addEventListener('keypress', function(e) { e.stopPropagation(); }, true);
termWrap.addEventListener('keyup', function(e) { e.stopPropagation(); }, true);
}
// 停止 LuCI 的 XHR.poll (如果存在),防止 session 超时检测导致页面跳转
// LuCI 的 footer.htm 可能启动了 uci.changes 轮询
if (window.XHR && XHR.halt) {
try { XHR.halt(); } catch(e) {}
}
}
checkAndLoadTerminal();
})();
//]]>
</script>
<%+footer%>

View File

@ -0,0 +1,183 @@
<%#
luci-app-openclaw — Web 控制台页面
嵌入 OpenClaw 官方 Web UI用户可在此配置模型/渠道并直接对话
-%>
<%+header%>
<%
local uci = require "luci.model.uci".cursor()
local port = uci:get("openclaw", "main", "port") or "18789"
%>
<style type="text/css">
.oc-page-header { margin: 0 0 16px 0; }
.oc-page-header h2 { font-size: 18px; font-weight: 600; color: #333; margin: 0 0 6px 0; }
.oc-page-header p { font-size: 13px; color: #666; margin: 0; line-height: 1.6; }
.oc-console-info {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
margin-bottom: 16px;
background: #f6f8fa;
border: 1px solid #e1e4e8;
border-radius: 6px;
font-size: 13px;
color: #555;
flex-wrap: wrap;
}
.oc-console-info .label { color: #888; }
.oc-console-info .value { font-family: monospace; color: #333; font-weight: 500; }
.oc-console-info .sep { color: #ddd; }
.oc-console-info .btn-open {
margin-left: auto;
padding: 4px 14px;
background: #4a90d9;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.oc-console-info .btn-open:hover { background: #357abd; }
.oc-console-wrap {
border: 2px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
background: #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
#oc-console-iframe { width: 100%; height: 700px; border: none; display: block; }
.oc-console-loading {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 300px; color: #666; font-size: 14px; background: #fafafa;
}
.oc-console-loading .spinner {
width: 32px; height: 32px; border: 3px solid #e0e0e0; border-top: 3px solid #4a90d9;
border-radius: 50%; animation: oc-spin .8s linear infinite; margin-bottom: 12px;
}
@keyframes oc-spin { to { transform: rotate(360deg); } }
</style>
<div class="oc-page-header">
<h2>🖥️ Web 控制台</h2>
<p>OpenClaw 官方 Web 管理界面 — 在这里可以配置 AI 模型、消息渠道QQ、Telegram、Discord 等),直接与 AI 进行对话,以及管理所有功能。</p>
</div>
<div class="oc-console-info">
<span class="label">网关地址:</span>
<span class="value" id="oc-console-addr">-</span>
<span class="sep">|</span>
<span class="label">活跃模型:</span>
<span class="value" id="oc-console-model" style="color:#555;">-</span>
<span class="sep">|</span>
<span class="label">状态:</span>
<span id="oc-console-status-text">检查中...</span>
<a id="oc-console-open-btn" class="btn-open" href="#" target="_blank" rel="noopener" style="display:none;">
↗ 新窗口打开
</a>
</div>
<div class="oc-console-wrap">
<div id="oc-console-container">
<div class="oc-console-loading" id="oc-console-loading">
<div class="spinner"></div>
<span>正在连接 OpenClaw 控制台...</span>
</div>
</div>
</div>
<script type="text/javascript">
//<![CDATA[
(function() {
var gwPort = '<%=port%>';
var gwToken = '';
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
var tokenUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "get_token")%>';
var container = document.getElementById('oc-console-container');
var loading = document.getElementById('oc-console-loading');
var addrEl = document.getElementById('oc-console-addr');
var modelEl = document.getElementById('oc-console-model');
var statusTextEl = document.getElementById('oc-console-status-text');
var openBtn = document.getElementById('oc-console-open-btn');
function getConsoleUrl() {
var proto = window.location.protocol;
var host = window.location.hostname;
var url = proto + '//' + host + ':' + gwPort + '/';
if (gwToken) url += '#token=' + encodeURIComponent(gwToken);
return url;
}
function checkAndLoad() {
addrEl.textContent = window.location.hostname + ':' + gwPort;
// 先获取 token再检查状态
(new XHR()).get(tokenUrl, null, function(tx) {
try {
var td = JSON.parse(tx.responseText);
gwToken = td.token || '';
} catch(e) {}
(new XHR()).get(statusUrl, null, function(x) {
try {
var d = JSON.parse(x.responseText);
var url = getConsoleUrl();
if (d.active_model) {
modelEl.textContent = d.active_model;
}
if (d.gateway_running) {
statusTextEl.innerHTML = '<span style="color:#1a7f37;">● 网关运行中</span>';
openBtn.href = url;
openBtn.style.display = '';
showIframe(url);
} else if (d.gateway_starting) {
statusTextEl.innerHTML = '<span style="color:#9a6700;">⏳ 网关正在启动</span>';
openBtn.style.display = 'none';
loading.innerHTML = '<div style="text-align:center;color:#666;">' +
'<div style="font-size:40px;margin-bottom:12px;"></div>' +
'<div style="font-size:15px;margin-bottom:6px;">OpenClaw 网关正在启动中...</div>' +
'<div style="font-size:12px;color:#999;">首次启动可能需要 2~5 分钟,请耐心等待,页面会自动刷新。</div>' +
'</div>';
} else {
statusTextEl.innerHTML = '<span style="color:#cf222e;">● 网关未运行</span>';
openBtn.style.display = 'none';
loading.innerHTML = '<div style="text-align:center;color:#666;">' +
'<div style="font-size:40px;margin-bottom:12px;">🧠</div>' +
'<div style="font-size:15px;margin-bottom:6px;">OpenClaw 网关未运行</div>' +
'<div style="font-size:12px;color:#999;">请先在「基本设置」页面启用服务并启动。</div>' +
'</div>';
}
} catch(e) {
statusTextEl.textContent = '查询失败';
}
});
});
}
function showIframe(url) {
loading.style.display = 'none';
var existing = document.getElementById('oc-console-iframe');
if (existing) return;
var iframe = document.createElement('iframe');
iframe.id = 'oc-console-iframe';
iframe.src = url;
iframe.style.width = '100%';
iframe.style.height = '700px';
iframe.style.border = 'none';
iframe.setAttribute('allowfullscreen', 'true');
container.appendChild(iframe);
}
checkAndLoad();
})();
//]]>
</script>
<%+footer%>

View File

@ -0,0 +1,160 @@
<%#
luci-app-openclaw — 运行状态面板 (全面汉化 + 界面优化)
-%>
<style type="text/css">
#oc-status-panel {
margin: 0 0 20px 0;
padding: 0;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fff;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
#oc-status-panel .panel-title {
background: linear-gradient(135deg, #4a90d9, #357abd);
color: #fff;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
}
#oc-status-panel .panel-body {
padding: 0;
}
#oc-status-panel table {
width: 100%;
border-collapse: collapse;
}
#oc-status-panel td {
padding: 8px 16px;
border-bottom: 1px solid #f2f2f2;
font-size: 13px;
vertical-align: middle;
}
#oc-status-panel tr:last-child td {
border-bottom: none;
}
#oc-status-panel td:first-child {
width: 120px;
color: #888;
font-weight: 500;
white-space: nowrap;
}
#oc-status-panel td:last-child {
color: #333;
}
.oc-badge {
display: inline-block;
padding: 2px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.oc-badge-running { background: #e6f7e9; color: #1a7f37; }
.oc-badge-stopped { background: #ffeef0; color: #cf222e; }
.oc-badge-starting { background: #fff8c5; color: #9a6700; }
.oc-badge-disabled { background: #f0f0f0; color: #656d76; }
.oc-badge-unknown { background: #fff8c5; color: #9a6700; }
.oc-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
.oc-dot-green { background: #1a7f37; }
.oc-dot-red { background: #cf222e; }
.oc-dot-gray { background: #999; }
</style>
<div id="oc-status-panel">
<div class="panel-title" id="oc-panel-title">🦞 OpenClaw 服务状态</div>
<div class="panel-body">
<table>
<tr><td>运行状态</td><td id="oc-st-status"><span class="oc-badge oc-badge-unknown">加载中...</span></td></tr>
<tr><td>网关服务</td><td id="oc-st-gateway">-</td></tr>
<tr><td>配置终端</td><td id="oc-st-pty">-</td></tr>
<tr><td>活跃模型</td><td id="oc-st-model">-</td></tr>
<tr><td>消息渠道</td><td id="oc-st-channels">-</td></tr>
<tr><td>进程 PID</td><td id="oc-st-pid">-</td></tr>
<tr><td>内存占用</td><td id="oc-st-mem">-</td></tr>
<tr><td>运行时间</td><td id="oc-st-uptime">-</td></tr>
<tr><td>Node.js</td><td id="oc-st-node">-</td></tr>
<tr><td>OpenClaw</td><td id="oc-st-oc-ver">-</td></tr>
<tr><td>插件版本</td><td id="oc-st-plugin">-</td></tr>
</table>
</div>
</div>
<script type="text/javascript">
//<![CDATA[
(function() {
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
function updateStatus() {
(new XHR()).get(statusUrl, null, function(x) {
try {
var d = JSON.parse(x.responseText);
var stEl = document.getElementById('oc-st-status');
if (d.enabled !== '1') {
stEl.innerHTML = '<span class="oc-badge oc-badge-disabled">已禁用</span>';
} else if (d.gateway_running) {
stEl.innerHTML = '<span class="oc-badge oc-badge-running">运行中</span>';
} else if (d.gateway_starting) {
stEl.innerHTML = '<span class="oc-badge oc-badge-starting">⏳ 正在启动...</span>';
} else {
stEl.innerHTML = '<span class="oc-badge oc-badge-stopped">已停止</span>';
}
var gwEl = document.getElementById('oc-st-gateway');
if (d.gateway_running) {
gwEl.innerHTML = '<span class="oc-dot oc-dot-green"></span>监听中 :' + d.port;
} else if (d.gateway_starting) {
gwEl.innerHTML = '<span class="oc-dot oc-dot-gray"></span>初始化中,首次启动可能需要 2~5 分钟...';
} else {
gwEl.innerHTML = '<span class="oc-dot oc-dot-red"></span>未监听';
}
var ptyEl = document.getElementById('oc-st-pty');
if (d.pty_running) {
ptyEl.innerHTML = '<span class="oc-dot oc-dot-green"></span>监听中 :' + d.pty_port;
} else {
ptyEl.innerHTML = '<span class="oc-dot oc-dot-gray"></span>未监听';
}
document.getElementById('oc-st-pid').textContent = d.pid || '-';
var channelsEl = document.getElementById('oc-st-channels');
if (d.channels) {
channelsEl.innerHTML = '<span style="color:#1a7f37;font-weight:500;">' + d.channels + '</span>';
} else {
channelsEl.textContent = '未配置';
}
var modelEl = document.getElementById('oc-st-model');
if (d.active_model) {
modelEl.innerHTML = '<code style="padding:2px 8px;background:#f0f3f6;border-radius:4px;font-size:12px;">' + d.active_model + '</code>';
} else {
modelEl.textContent = '未配置';
}
var memEl = document.getElementById('oc-st-mem');
if (d.memory_kb > 0) {
var mb = (d.memory_kb / 1024).toFixed(1);
memEl.textContent = mb + ' MB';
} else {
memEl.textContent = '-';
}
document.getElementById('oc-st-uptime').textContent = d.uptime || '-';
document.getElementById('oc-st-node').textContent = d.node_version || '未安装';
document.getElementById('oc-st-oc-ver').textContent = d.oc_version ? ('v' + d.oc_version) : '未安装';
document.getElementById('oc-st-plugin').textContent = d.plugin_version ? ('v' + d.plugin_version) : '-';
} catch(e) {
document.getElementById('oc-st-status').innerHTML = '<span class="oc-badge oc-badge-unknown">查询失败</span>';
}
});
}
updateStatus();
setInterval(updateStatus, 5000);
})();
//]]>
</script>

View File

@ -0,0 +1,482 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Language: zh-cn\n"
# ═══════════════════════════════════════════
# controller/openclaw.lua — 菜单 & API
# ═══════════════════════════════════════════
msgid "OpenClaw"
msgstr "OpenClaw AI 网关"
msgid "OpenClaw AI Gateway"
msgstr "OpenClaw AI 网关"
msgid "Basic Settings"
msgstr "基本设置"
msgid "Model Settings"
msgstr "模型设置"
msgid "Channel Settings"
msgstr "渠道设置"
msgid "Advanced"
msgstr "高级配置"
# ═══════════════════════════════════════════
# basic.lua — 基本设置页
# ═══════════════════════════════════════════
msgid "OpenClaw is an AI coding agent gateway that supports GitHub Copilot, Claude, GPT, Gemini and various messaging channels."
msgstr "OpenClaw 是一个 AI 编程代理网关,支持 GitHub Copilot、Claude、GPT、Gemini 等大模型以及 QQ、Telegram、Discord 等多种消息渠道。"
msgid "Enable"
msgstr "启用"
msgid "Enable OpenClaw AI Gateway"
msgstr "启用 OpenClaw AI 网关"
msgid "Gateway Port"
msgstr "网关端口"
msgid "OpenClaw API listening port"
msgstr "OpenClaw API 监听端口"
msgid "Bind Interface"
msgstr "绑定接口"
msgid "Network binding mode"
msgstr "网络绑定模式"
msgid "LAN only"
msgstr "仅局域网"
msgid "Loopback only"
msgstr "仅本地回环"
msgid "All interfaces (WAN accessible)"
msgstr "所有接口WAN 可访问)"
msgid "Auto"
msgstr "自动"
msgid "Config Terminal Port"
msgstr "配置终端端口"
msgid "Web PTY terminal port for oc-config"
msgstr "oc-config Web PTY 终端端口"
msgid "Node.js Version"
msgstr "Node.js 版本"
msgid "Node.js version to download (requires v22+)"
msgstr "要下载的 Node.js 版本(需要 v22+"
msgid "Authentication"
msgstr "认证"
msgid "Gateway Token"
msgstr "网关令牌"
msgid "Authentication token for API access. Keep it secret."
msgstr "API 访问认证令牌,请妥善保管。"
msgid "Reset Token"
msgstr "重置令牌"
msgid "Generate New Token"
msgstr "生成新令牌"
msgid "Are you sure you want to generate a new token? Current connections will be disconnected."
msgstr "确定要生成新令牌吗?当前连接将会断开。"
msgid "Generating..."
msgstr "正在生成..."
msgid "Token updated"
msgstr "令牌已更新"
msgid "Quick Actions"
msgstr "快捷操作"
msgid "Download Node.js and install OpenClaw"
msgstr "下载 Node.js 并安装 OpenClaw"
msgid "Install Environment"
msgstr "安装环境"
msgid "Restart Service"
msgstr "重启服务"
msgid "Stop Service"
msgstr "停止服务"
msgid "Advanced Configuration"
msgstr "高级配置"
msgid "Executing..."
msgstr "正在执行..."
msgid "completed"
msgstr "已完成"
# ═══════════════════════════════════════════
# model.lua — 模型设置页
# ═══════════════════════════════════════════
msgid "Configure your AI model provider and API credentials. After saving, the configuration will be synced to OpenClaw."
msgstr "配置您的 AI 模型提供商和 API 凭证。保存后配置将自动同步到 OpenClaw。"
msgid "AI Model Provider"
msgstr "AI 模型提供商"
msgid "Provider"
msgstr "提供商"
msgid "Select your AI model provider"
msgstr "选择您的 AI 模型提供商"
msgid "-- Not configured --"
msgstr "-- 未配置 --"
msgid "Multi-provider aggregator"
msgstr "多提供商聚合器"
msgid "Custom OpenAI-compatible API"
msgstr "自定义 OpenAI 兼容 API"
msgid "API Key"
msgstr "API 密钥"
msgid "Your provider API key"
msgstr "您的提供商 API 密钥"
msgid "API Base URL"
msgstr "API 基础地址"
msgid "Custom OpenAI-compatible API endpoint"
msgstr "自定义 OpenAI 兼容 API 端点"
msgid "Active Model"
msgstr "当前模型"
msgid "Model"
msgstr "模型"
msgid "Multimodal flagship (recommended)"
msgstr "多模态旗舰(推荐)"
msgid "Cost effective"
msgstr "经济实惠"
msgid "Reasoning flagship"
msgstr "推理旗舰"
msgid "Reasoning lightweight"
msgstr "轻量推理"
msgid "Reasoning classic"
msgstr "经典推理"
msgid "Latest Sonnet (recommended)"
msgstr "最新 Sonnet推荐"
msgid "Top reasoning"
msgstr "顶级推理"
msgid "Fast"
msgstr "快速"
msgid "Flagship reasoning (recommended)"
msgstr "旗舰推理(推荐)"
msgid "Fast balanced"
msgstr "快速均衡"
msgid "Low latency"
msgstr "低延迟"
msgid "General chat"
msgstr "通用对话"
msgid "Deep reasoning"
msgstr "深度推理"
msgid "(ultra fast)"
msgstr "(超快)"
msgid "Model Name"
msgstr "模型名称"
msgid "For multi-provider setup, advanced model allowlist, or OAuth login (Qwen Portal, Gemini CLI), use the"
msgstr "如需多提供商配置、高级模型白名单或 OAuth 登录Qwen Portal、Gemini CLI请使用"
msgid "page which provides the full interactive wizard."
msgstr "页面,其中提供完整的交互式配置向导。"
# ═══════════════════════════════════════════
# channel.lua — 渠道设置页
# ═══════════════════════════════════════════
msgid "Configure messaging channels for OpenClaw. Telegram is most popular."
msgstr "配置 OpenClaw 的消息渠道。Telegram 是最常用的渠道。"
msgid "Bot Token"
msgstr "Bot 令牌"
msgid "Get from @BotFather on Telegram: /newbot → copy token"
msgstr "从 Telegram 的 @BotFather 获取:/newbot → 复制令牌"
msgid "After setting Bot Token, restart the service, then send /start to your Bot on Telegram."
msgstr "设置 Bot 令牌后,重启服务,然后在 Telegram 上向您的 Bot 发送 /start。"
msgid "For pairing wizard, go to"
msgstr "如需配对向导,请前往"
msgid "Get from discord.com/developers/applications → Bot → Reset Token"
msgstr "从 discord.com/developers/applications → Bot → Reset Token 获取"
msgid "Feishu (Lark)"
msgstr "飞书"
msgid "App ID"
msgstr "应用 ID"
msgid "Get from open.feishu.cn → Create App"
msgstr "从 open.feishu.cn → 创建应用 获取"
msgid "App Secret"
msgstr "应用密钥"
msgid "Get from api.slack.com/apps → Create App (xoxb-...)"
msgstr "从 api.slack.com/apps → Create App 获取xoxb-..."
msgid "WhatsApp requires QR code pairing through the Web console."
msgstr "WhatsApp 需要通过 Web 控制台扫码配对。"
msgid "After starting the service, visit:"
msgstr "启动服务后,请访问:"
msgid "Then go to Channels → WhatsApp to scan QR code."
msgstr "然后进入 Channels → WhatsApp 扫描二维码。"
msgid "For official interactive channel wizard, Telegram pairing helper, or Signal/Line/MS Teams configuration, use the"
msgstr "如需官方交互式渠道向导、Telegram 配对助手或 Signal/Line/MS Teams 配置,请使用"
msgid "page."
msgstr "页面。"
# ═══════════════════════════════════════════
# status.htm — 状态面板
# ═══════════════════════════════════════════
msgid "Service Status"
msgstr "服务状态"
msgid "Status"
msgstr "状态"
msgid "Loading..."
msgstr "加载中..."
msgid "Gateway"
msgstr "网关"
msgid "Config Terminal"
msgstr "配置终端"
msgid "Memory"
msgstr "内存"
msgid "Uptime"
msgstr "运行时间"
msgid "Disabled"
msgstr "已禁用"
msgid "Running"
msgstr "运行中"
msgid "Stopped"
msgstr "已停止"
msgid "Listening"
msgstr "监听中"
msgid "Not listening"
msgstr "未监听"
msgid "Not installed"
msgstr "未安装"
msgid "Error"
msgstr "错误"
# ═══════════════════════════════════════════
# advanced.htm — 高级配置页
# ═══════════════════════════════════════════
msgid "What is this?"
msgstr "这是什么?"
msgid "This page embeds the OpenClaw interactive configuration terminal (oc-config). It provides the full-featured management menu including:"
msgstr "此页面嵌入了 OpenClaw 交互式配置终端oc-config提供完整的管理菜单包括"
msgid "Multi-provider AI model setup with OAuth support"
msgstr "支持 OAuth 的多提供商 AI 模型配置"
msgid "Telegram pairing wizard"
msgstr "Telegram 配对向导"
msgid "Health check and diagnostics"
msgstr "健康检查和诊断"
msgid "Raw configuration file editor"
msgstr "原始配置文件编辑器"
msgid "Factory reset"
msgstr "恢复出厂设置"
msgid "Configuration Terminal"
msgstr "配置终端"
msgid "Checking..."
msgstr "检查中..."
msgid "Reload"
msgstr "刷新"
msgid "Connecting to configuration terminal..."
msgstr "正在连接配置终端..."
msgid "Available menu options in terminal"
msgstr "终端中可用的菜单选项"
msgid "View current config"
msgstr "查看当前配置"
msgid "Configure AI model provider"
msgstr "配置 AI 模型提供商"
msgid "Set active model"
msgstr "设置当前模型"
msgid "Configure channels"
msgstr "配置消息渠道"
msgid "pairing wizard"
msgstr "配对向导"
msgid "Health check"
msgstr "健康检查"
msgid "Restart Gateway"
msgstr "重启网关"
msgid "View/edit raw config"
msgstr "查看/编辑原始配置"
msgid "Reset to defaults"
msgstr "恢复默认设置"
msgid "Connected"
msgstr "已连接"
msgid "Terminal not running"
msgstr "终端未运行"
msgid "Web PTY terminal is not running."
msgstr "Web PTY 终端未运行。"
msgid "Please enable and start the service first from Basic Settings page."
msgstr "请先在基本设置页面启用并启动服务。"
msgid "Failed to check terminal status"
msgstr "检查终端状态失败"
msgid "Reconnecting..."
msgstr "正在重新连接..."
# ═══════════════════════════════════════════
# 其他
# ═══════════════════════════════════════════
msgid "Port"
msgstr "端口"
msgid "PID"
msgstr "进程 ID"
msgid "Node.js"
msgstr "Node.js"
msgid "Web Console"
msgstr "Web 控制台"
msgid "Verify Token"
msgstr "验证令牌"
msgid "Allow insecure auth"
msgstr "允许不安全认证"
msgid "Disable device auth"
msgstr "禁用设备认证"
msgid "Allow host header fallback"
msgstr "允许主机头回退"
msgid "Docker sandbox"
msgstr "Docker 沙箱"
msgid "Enable Docker sandbox for code execution"
msgstr "启用 Docker 沙箱执行代码"
msgid "Node.js Mirror"
msgstr "Node.js 镜像源"
msgid "China mirror (npmmirror.com)"
msgstr "中国镜像 (npmmirror.com)"
msgid "Official (nodejs.org)"
msgstr "官方 (nodejs.org)"
msgid "Check Environment"
msgstr "检查环境"
msgid "Install / Upgrade"
msgstr "安装 / 升级"
msgid "Telegram"
msgstr "Telegram"
msgid "Discord"
msgstr "Discord"
msgid "Feishu"
msgstr "飞书"
msgid "Slack"
msgstr "Slack"
msgid "WhatsApp"
msgstr "WhatsApp"
msgid "Regenerate Token"
msgstr "重新生成令牌"
msgid "Custom API Base URL"
msgstr "自定义 API 基础地址"
msgid "Auth Token"
msgstr "认证令牌"
msgid "Gateway authentication token"
msgstr "网关认证令牌"
msgid "Web PTY terminal port for oc-config"
msgstr "oc-config Web PTY 终端端口"
msgid "Select the AI model provider to use"
msgstr "选择要使用的 AI 模型提供商"

View File

@ -0,0 +1 @@
zh-cn

View File

@ -0,0 +1,6 @@
config openclaw 'main'
option enabled '0'
option port '18789'
option bind 'lan'
option token ''
option pty_port '18793'

View File

@ -0,0 +1,487 @@
#!/bin/sh /etc/rc.common
# luci-app-openclaw — procd init 脚本
USE_PROCD=1
START=99
STOP=10
EXTRA_COMMANDS="setup status_service restart_gateway"
EXTRA_HELP=" setup 下载 Node.js 并安装 OpenClaw
status_service 显示服务状态
restart_gateway 仅重启 Gateway 实例 (不影响 Web PTY)"
NODE_BASE="/opt/openclaw/node"
OC_GLOBAL="/opt/openclaw/global"
OC_DATA="/opt/openclaw/data"
# ── OverlayFS 兼容性修复 ──
# Docker bind mount (/overlay/upper/opt/docker) 会导致 /opt 不可写
# 解决: bind mount upper 层的 /opt 到合并视图的 /opt
_oc_fix_opt() {
mkdir -p /opt/openclaw/.probe 2>/dev/null && { rmdir /opt/openclaw/.probe 2>/dev/null; return 0; }
if [ -d /overlay/upper/opt ]; then
mkdir -p /overlay/upper/opt/openclaw 2>/dev/null
mount --bind /overlay/upper/opt /opt 2>/dev/null && return 0
fi
return 1
}
_oc_fix_opt
NODE_BIN="${NODE_BASE}/bin/node"
CONFIG_FILE="${OC_DATA}/.openclaw/openclaw.json"
get_oc_entry() {
local search_dirs="${OC_GLOBAL}/lib/node_modules/openclaw
${OC_GLOBAL}/node_modules/openclaw
${NODE_BASE}/lib/node_modules/openclaw"
# pnpm 全局安装路径形如: $OC_GLOBAL/5/node_modules/openclaw
for ver_dir in "${OC_GLOBAL}"/*/node_modules/openclaw; do
[ -d "$ver_dir" ] && search_dirs="$search_dirs
$ver_dir"
done
# v2026.3.8: 跟随符号链接解析真实包路径 (pnpm store / symlinked wrappers)
for link_dir in "${OC_GLOBAL}/lib/node_modules/openclaw" "${OC_GLOBAL}/node_modules/openclaw"; do
[ -L "$link_dir" ] && {
local real_dir=$(readlink -f "$link_dir" 2>/dev/null)
[ -n "$real_dir" ] && [ -d "$real_dir" ] && search_dirs="$search_dirs
$real_dir"
}
done
local d _tmpf
_tmpf=$(mktemp)
echo "$search_dirs" > "$_tmpf"
while read -r d; do
[ -z "$d" ] && continue
if [ -f "${d}/openclaw.mjs" ]; then
echo "${d}/openclaw.mjs"
rm -f "$_tmpf"
return
elif [ -f "${d}/dist/cli.js" ]; then
echo "${d}/dist/cli.js"
rm -f "$_tmpf"
return
fi
done < "$_tmpf"
rm -f "$_tmpf"
}
patch_iframe_headers() {
# 移除 OpenClaw 网关的 X-Frame-Options 和 frame-ancestors 限制,允许 LuCI iframe 嵌入
# v2026.3.8: 资产路径可能通过符号链接解析,需同时搜索 OC_GLOBAL 和 NODE_BASE
local gw_js
for search_root in "${OC_GLOBAL}" "${NODE_BASE}/lib"; do
[ -d "$search_root" ] || continue
for f in $(find "$search_root" -name "gateway-cli-*.js" -type f 2>/dev/null); do
if grep -q "X-Frame-Options.*DENY" "$f" 2>/dev/null; then
sed -i "s|res.setHeader(\"X-Frame-Options\", \"DENY\");|// res.setHeader(\"X-Frame-Options\", \"DENY\"); // patched by luci-app-openclaw|g" "$f"
sed -i "s|\"frame-ancestors 'none'\"|\"frame-ancestors *\"|g" "$f"
logger -t openclaw "Patched iframe headers in $f"
fi
done
done
}
sync_uci_to_json() {
# 将 UCI 配置同步到 openclaw.json同时确保 token 双向同步
local port bind token
port=$(uci -q get openclaw.main.port || echo "18789")
bind=$(uci -q get openclaw.main.bind || echo "lan")
token=$(uci -q get openclaw.main.token || echo "")
# 确保配置目录和文件存在 (路径已在脚本开头做 OverlayFS 重映射)
mkdir -p "$(dirname "$CONFIG_FILE")" 2>/dev/null
if [ ! -f "$CONFIG_FILE" ]; then
echo '{}' > "$CONFIG_FILE"
fi
# UCI 没有 token 时,尝试从已有的 JSON 读取
if [ -z "$token" ] && [ -x "$NODE_BIN" ]; then
token=$("$NODE_BIN" -e "
try{const d=JSON.parse(require('fs').readFileSync('${CONFIG_FILE}','utf8'));
if(d.gateway&&d.gateway.auth&&d.gateway.auth.token)process.stdout.write(d.gateway.auth.token);}catch(e){}
" 2>/dev/null)
fi
# 如果仍然没有 token生成一个新的
if [ -z "$token" ]; then
token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48)
fi
# 确保 token 写回 UCI
local uci_token
uci_token=$(uci -q get openclaw.main.token || echo "")
if [ "$uci_token" != "$token" ]; then
uci set openclaw.main.token="$token"
uci commit openclaw 2>/dev/null
fi
# 使用 Node.js 写入 JSON (如果可用)
if [ -x "$NODE_BIN" ]; then
OC_SYNC_PORT="$port" OC_SYNC_BIND="$bind" OC_SYNC_TOKEN="$token" OC_SYNC_FILE="$CONFIG_FILE" \
"$NODE_BIN" -e "
const fs=require('fs');
const f=process.env.OC_SYNC_FILE;
let d={};
try{d=JSON.parse(fs.readFileSync(f,'utf8'));}catch(e){}
if(!d.gateway)d.gateway={};
d.gateway.port=parseInt(process.env.OC_SYNC_PORT)||18789;
d.gateway.bind=process.env.OC_SYNC_BIND||'lan';
d.gateway.mode='local';
if(!d.gateway.auth)d.gateway.auth={};
d.gateway.auth.mode='token';
d.gateway.auth.token=process.env.OC_SYNC_TOKEN||'';
if(!d.gateway.controlUi)d.gateway.controlUi={};
d.gateway.controlUi.allowInsecureAuth=true;
d.gateway.controlUi.dangerouslyDisableDeviceAuth=true;
d.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true;
// 清理 v2026.3.1+ 已废弃的字段,避免配置验证失败
delete d.gateway.name;
delete d.gateway.bonjour;
delete d.gateway.plugins;
// v2026.3.2: 禁用 ACP dispatch 防止路由器内存溢出
if(!d.acp)d.acp={};
if(!d.acp.dispatch)d.acp.dispatch={};
d.acp.dispatch.enabled=false;
// v2026.3.2: tools.profile 默认改为 messaging路由器场景需强制 coding
if(!d.tools)d.tools={};
d.tools.profile='coding';
// 禁用 OpenClaw 内置更新检查,防止 Control UI 显示升级横幅
if(!d.update)d.update={};
d.update.checkOnStart=false;
// v2026.3.2: 迁移 Ollama provider 到原生 API
if(d.models&&d.models.providers&&d.models.providers.ollama){const ol=d.models.providers.ollama;if(ol.api==='openai-chat-completions'||ol.api==='openai-completions')ol.api='ollama';if(ol.baseUrl&&ol.baseUrl.endsWith('/v1'))ol.baseUrl=ol.baseUrl.replace(/\/v1$/,'');if(ol.apiKey==='ollama')ol.apiKey='ollama-local';}
// v2026.3.7: 清理已废弃的顶层配置键 (loadConfig() 现在会严格校验)
['cli','commands.native','commands.nativeSkills','commands.ownerDisplay'].forEach(k=>{const ks=k.split('.');let o=d;for(let i=0;i<ks.length-1;i++){if(!o[ks[i]])return;o=o[ks[i]];}delete o[ks[ks.length-1]];});
fs.writeFileSync(f,JSON.stringify(d,null,2));
" 2>/dev/null
fi
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
}
start_service() {
local enabled port bind pty_port
config_load openclaw
config_get enabled main enabled "0"
config_get port main port "18789"
config_get bind main bind "lan"
config_get pty_port main pty_port "18793"
[ "$enabled" = "1" ] || {
echo "openclaw 已禁用。请在 /etc/config/openclaw 中设置 enabled 为 1"
return 0
}
# 检查 Node.js
if [ ! -x "$NODE_BIN" ]; then
echo "未找到 Node.js: $NODE_BIN"
echo "请运行: openclaw-env setup"
return 1
fi
# 检查 openclaw 入口
local oc_entry
oc_entry=$(get_oc_entry)
if [ -z "$oc_entry" ]; then
echo "OpenClaw 未安装。请运行: openclaw-env setup"
return 1
fi
# 同步 UCI 到 JSON
sync_uci_to_json
# 修复数据目录权限 (防止 root 用户操作后留下无法读取的文件)
chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true
# Patch iframe 安全头,允许 LuCI 嵌入
patch_iframe_headers
# 将 UCI bind 映射到 openclaw gateway --bind 参数
local gw_bind="loopback"
case "$bind" in
lan) gw_bind="lan" ;;
loopback) gw_bind="loopback" ;;
all) gw_bind="custom" ;; # custom = 0.0.0.0
*) gw_bind="$bind" ;;
esac
# 确保网关端口未被残留进程占用 (防止 restart 时 crash loop)
_ensure_port_free() {
local p="$1" max_wait="${2:-5}" i=0
while [ $i -lt $max_wait ]; do
if command -v ss >/dev/null 2>&1; then
ss -tulnp 2>/dev/null | grep -q ":${p} " || return 0
else
netstat -tulnp 2>/dev/null | grep -q ":${p} " || return 0
fi
# 尝试杀掉占用端口的 gateway 进程
if [ $i -eq 0 ]; then
local occ_pid
for occ_pid in $(pgrep -f "openclaw-gateway" 2>/dev/null); do
kill "$occ_pid" 2>/dev/null
done
fi
sleep 1
i=$((i + 1))
done
# 最后手段: SIGKILL
local port_pid=""
if command -v ss >/dev/null 2>&1; then
port_pid=$(ss -tulnp 2>/dev/null | grep ":${p} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
else
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${p} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
fi
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null && sleep 1
return 0
}
_ensure_port_free "$port"
# 启动 OpenClaw Gateway (主服务, 前台运行)
procd_open_instance "gateway"
procd_set_param command "$NODE_BIN" "$oc_entry" gateway run \
--port "$port" --bind "$gw_bind"
procd_set_param env \
HOME="$OC_DATA" \
OPENCLAW_HOME="$OC_DATA" \
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \
NODE_ICU_DATA="${NODE_BASE}/share/icu" \
NODE_BASE="$NODE_BASE" \
OC_GLOBAL="$OC_GLOBAL" \
OC_DATA="$OC_DATA" \
PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
procd_set_param user openclaw
procd_set_param respawn 3600 10 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param pidfile /var/run/openclaw.pid
procd_close_instance
# 启动 Web PTY 配置终端 (辅助服务)
# 复用已有 PTY token只在不存在时才生成新的
# 这样避免服务重启时 PTY WebSocket 连接因 token 变化而断开
local pty_token
pty_token=$(uci -q get openclaw.main.pty_token || echo "")
if [ -z "$pty_token" ]; then
pty_token=$(head -c 16 /dev/urandom | hexdump -e '16/1 "%02x"' 2>/dev/null || openssl rand -hex 16 2>/dev/null || echo "pty_$(date +%s)")
uci set openclaw.main.pty_token="$pty_token"
uci commit openclaw 2>/dev/null
fi
procd_open_instance "pty"
procd_set_param command "$NODE_BIN" /usr/share/openclaw/web-pty.js
procd_set_param env \
OC_CONFIG_PORT="$pty_port" \
OC_PTY_TOKEN="$pty_token" \
OC_CONFIG_SCRIPT="/usr/share/openclaw/oc-config.sh" \
NODE_ICU_DATA="${NODE_BASE}/share/icu" \
NODE_BASE="$NODE_BASE" \
OC_GLOBAL="$OC_GLOBAL" \
OC_DATA="$OC_DATA" \
HOME="$OC_DATA" \
OPENCLAW_HOME="$OC_DATA" \
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \
PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param pidfile /var/run/openclaw-pty.pid
procd_close_instance
}
stop_service() {
# procd 会自动停止它管理的主进程 (openclaw)
# 但 openclaw 会 fork 出 openclaw-gateway 子进程实际监听端口
# procd 不一定能正确追踪并杀掉子进程树,需要手动清理
local port
port=$(uci -q get openclaw.main.port || echo "18789")
# 杀掉所有 openclaw / openclaw-gateway 残留进程
# (排除 web-pty.js 和 oc-config.sh它们由 pty 实例管理)
local pid
for pid in $(pgrep -f "openclaw-gateway" 2>/dev/null) \
$(pgrep -f "openclaw.*gateway.*run" 2>/dev/null); do
kill "$pid" 2>/dev/null
done
# 等待端口真正释放 (最多 8 秒)
local wait_count=0
while [ $wait_count -lt 8 ]; do
if command -v ss >/dev/null 2>&1; then
ss -tulnp 2>/dev/null | grep -q ":${port} " || break
else
netstat -tulnp 2>/dev/null | grep -q ":${port} " || break
fi
sleep 1
wait_count=$((wait_count + 1))
done
# 如果端口仍被占用,强制杀掉占用者
if [ $wait_count -ge 8 ]; then
local port_pid=""
if command -v ss >/dev/null 2>&1; then
port_pid=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
else
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${port} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
fi
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null
sleep 1
fi
}
service_triggers() {
procd_add_reload_trigger "openclaw"
}
reload_service() {
stop
# stop_service 已确保端口释放,但额外等待 1 秒让内核回收
sleep 1
start
}
setup() {
echo "正在调用 openclaw-env setup..."
/usr/bin/openclaw-env setup
}
restart_gateway() {
# 仅重启 Gateway 进程,通过 kill 触发 procd respawn
# 绝对不能调用 start_service否则会重启 PTY 实例
local port
port=$(uci -q get openclaw.main.port || echo "18789")
# ── 第一步: kill 监听端口的 gateway 子进程 (openclaw-gateway) ──
# openclaw 启动后会 fork 出 openclaw-gateway 子进程实际监听端口
# 必须先杀子进程释放端口,否则 procd respawn 的新实例会因端口冲突而崩溃
local port_pid=""
if command -v ss >/dev/null 2>&1; then
port_pid=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
else
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${port} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
fi
[ -n "$port_pid" ] && kill "$port_pid" 2>/dev/null
# ── 第二步: kill procd 管理的 gateway 主进程 (openclaw) ──
# procd 追踪的是主进程 PIDkill 它才能触发 respawn
local gw_pid=""
gw_pid=$(ubus call service list '{"name":"openclaw"}' 2>/dev/null | \
jsonfilter -e '$.openclaw.instances.gateway.pid' 2>/dev/null) || true
if [ -n "$gw_pid" ] && kill -0 "$gw_pid" 2>/dev/null; then
kill "$gw_pid" 2>/dev/null
fi
# ── 第三步: 兜底 — kill 所有 openclaw gateway 相关残留进程 ──
# 避免任何残留进程占据端口
sleep 1
for pid in $(pgrep -f "openclaw-gateway" 2>/dev/null) $(pgrep -f "openclaw.*gateway.*run" 2>/dev/null); do
kill "$pid" 2>/dev/null
done
# ── 第四步: 等待端口真正释放 (最多 5 秒) ──
local wait_count=0
while [ $wait_count -lt 5 ]; do
if command -v ss >/dev/null 2>&1; then
ss -tulnp 2>/dev/null | grep -q ":${port} " || break
else
netstat -tulnp 2>/dev/null | grep -q ":${port} " || break
fi
sleep 1
wait_count=$((wait_count + 1))
done
# ── 第五步: 如果 procd 中没有 gateway 服务注册 (首次/崩溃),调用 start ──
if [ -z "$gw_pid" ]; then
/etc/init.d/openclaw start >/dev/null 2>&1
fi
# 如果 gw_pid 存在procd respawn 会自动重启 gateway
}
status_service() {
local port pty_port
port=$(uci -q get openclaw.main.port || echo "18789")
pty_port=$(uci -q get openclaw.main.pty_port || echo "18793")
echo "=== OpenClaw 服务状态 ==="
# Node.js
if [ -x "$NODE_BIN" ]; then
echo "Node.js: $($NODE_BIN --version 2>/dev/null)"
else
echo "Node.js: 未安装"
fi
# OpenClaw
local oc_entry
oc_entry=$(get_oc_entry)
if [ -n "$oc_entry" ]; then
local ver
ver=$("$NODE_BIN" "$oc_entry" --version 2>/dev/null | tr -d '[:space:]')
echo "OpenClaw: v${ver:-未知}"
else
echo "OpenClaw: 未安装"
fi
# 端口检测函数 (ss 优先, 回退 netstat)
_check_port() {
local p="$1"
if command -v ss >/dev/null 2>&1; then
ss -tulnp 2>/dev/null | grep -q ":${p} "
else
netstat -tulnp 2>/dev/null | grep -q ":${p} "
fi
}
_get_pid_by_port() {
local p="$1"
if command -v ss >/dev/null 2>&1; then
ss -tulnp 2>/dev/null | grep ":${p} " | head -1 | sed -n 's/.*pid=\([0-9]*\).*/\1/p'
else
netstat -tulnp 2>/dev/null | grep ":${p} " | head -1 | sed 's|.* \([0-9]*\)/.*|\1|'
fi
}
# Gateway port
if _check_port "$port"; then
echo "网关: 运行中 (端口 $port)"
# PID
local pid
pid=$(_get_pid_by_port "$port")
if [ -n "$pid" ]; then
echo "进程ID: $pid"
# Memory
local rss
rss=$(awk '/VmRSS/{print $2}' /proc/$pid/status 2>/dev/null)
[ -n "$rss" ] && echo "内存: ${rss} kB"
# Uptime
local start_time now_time
start_time=$(stat -c %Y /proc/$pid 2>/dev/null || echo "0")
now_time=$(date +%s)
if [ "$start_time" -gt 0 ]; then
local uptime=$((now_time - start_time))
local hours=$((uptime / 3600))
local mins=$(( (uptime % 3600) / 60 ))
echo "运行时间: ${hours}小时 ${mins}分钟"
fi
fi
elif pgrep -f "openclaw.*gateway" >/dev/null 2>&1; then
echo "网关: 正在启动 (首次启动可能需要 2~5 分钟)"
else
echo "网关: 已停止"
fi
# PTY port
if _check_port "$pty_port"; then
echo "Web PTY: 运行中 (端口 $pty_port)"
else
echo "Web PTY: 已停止"
fi
}

View File

@ -0,0 +1,59 @@
#!/bin/sh
# luci-app-openclaw — 首次安装初始化脚本
# 创建 openclaw 系统用户 (无 home, 无 shell)
if ! id openclaw >/dev/null 2>&1; then
# 动态查找可用 UID/GID (从 1000 开始,避免与已有用户冲突)
OC_UID=1000
while grep -q "^[^:]*:x:${OC_UID}:" /etc/passwd 2>/dev/null; do
OC_UID=$((OC_UID + 1))
done
OC_GID=$OC_UID
while grep -q "^[^:]*:x:${OC_GID}:" /etc/group 2>/dev/null; do
OC_GID=$((OC_GID + 1))
done
# OpenWrt 方式:直接写入 /etc/passwd 和 /etc/shadow
if ! grep -q '^openclaw:' /etc/passwd 2>/dev/null; then
echo "openclaw:x:${OC_UID}:${OC_GID}:openclaw:/opt/openclaw/data:/bin/false" >> /etc/passwd
fi
if ! grep -q '^openclaw:' /etc/shadow 2>/dev/null; then
echo 'openclaw:x:0:0:99999:7:::' >> /etc/shadow
fi
if ! grep -q '^openclaw:' /etc/group 2>/dev/null; then
echo "openclaw:x:${OC_GID}:" >> /etc/group
fi
fi
# 创建数据目录
# ── OverlayFS 兼容: Docker bind mount 可能导致 /opt 不可写 ──
if ! mkdir -p /opt/openclaw/.probe 2>/dev/null; then
if [ -d /overlay/upper/opt ]; then
mkdir -p /overlay/upper/opt/openclaw 2>/dev/null
mount --bind /overlay/upper/opt /opt 2>/dev/null
fi
rmdir /opt/openclaw/.probe 2>/dev/null
else
rmdir /opt/openclaw/.probe 2>/dev/null
fi
mkdir -p /opt/openclaw/data/.openclaw
mkdir -p /opt/openclaw/node
mkdir -p /opt/openclaw/global
chown -R openclaw:openclaw /opt/openclaw 2>/dev/null || true
# 生成随机 Token (如果尚未设置)
CURRENT_TOKEN=$(uci -q get openclaw.main.token)
if [ -z "$CURRENT_TOKEN" ]; then
TOKEN=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48)
uci set openclaw.main.token="$TOKEN"
uci commit openclaw
fi
# 生成 PTY Token (如果尚未设置)
CURRENT_PTY_TOKEN=$(uci -q get openclaw.main.pty_token)
if [ -z "$CURRENT_PTY_TOKEN" ]; then
PTY_TOKEN=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48)
uci set openclaw.main.pty_token="$PTY_TOKEN"
uci commit openclaw
fi
exit 0

View File

@ -0,0 +1,652 @@
#!/bin/sh
# ============================================================================
# openclaw-env — Node.js 环境自动检测/下载 + OpenClaw 安装
# 用法:
# openclaw-env setup — 完整安装 (Node.js + pnpm + OpenClaw)
# openclaw-env check — 检查环境状态
# openclaw-env upgrade — 升级 OpenClaw 到最新版
# openclaw-env node — 仅下载/更新 Node.js
# 环境变量:
# OC_VERSION — 指定 OpenClaw 版本 (如 2026.3.1),不设置则安装最新版
# ============================================================================
set -e
NODE_VERSION="${NODE_VERSION:-22.15.1}"
# 经过验证的 OpenClaw 稳定版本 (更新此值需同步测试)
OC_TESTED_VERSION="2026.3.8"
# 用户可通过 OC_VERSION 环境变量覆盖安装版本
OC_VERSION="${OC_VERSION:-}"
NODE_BASE="/opt/openclaw/node"
OC_GLOBAL="/opt/openclaw/global"
OC_DATA="/opt/openclaw/data"
# ── OverlayFS 兼容性修复 ──
# iStoreOS/OpenWrt 上 Docker 的 bind mount (/overlay/upper/opt/docker)
# 会导致 OverlayFS 合并视图中 /opt 完全不可写 (mkdir 报 "Directory not empty")。
# 解决方案: 将 /overlay/upper/opt bind mount 到 /opt绕过 OverlayFS 冲突。
_oc_fix_opt() {
# 如果 /opt 可正常写入,无需修复
if mkdir -p /opt/openclaw/.probe 2>/dev/null; then
rmdir /opt/openclaw/.probe 2>/dev/null
return 0
fi
# /opt 不可写且 overlay upper 层存在 — 执行 bind mount 修复
if [ -d /overlay/upper/opt ]; then
# 确保 upper 层有 openclaw 目录
mkdir -p /overlay/upper/opt/openclaw 2>/dev/null
# 绑定挂载 upper 层的 /opt 到合并视图的 /opt
mount --bind /overlay/upper/opt /opt 2>/dev/null && return 0
fi
return 1
}
_oc_fix_opt
NODE_BIN="${NODE_BASE}/bin/node"
NPM_BIN="${NODE_BASE}/bin/npm"
PNPM_BIN="${OC_GLOBAL}/bin/pnpm"
# Node.js 官方镜像 + musl 非官方构建
# Node.js 官方镜像 + musl 非官方构建
NODE_MIRROR="${NODE_MIRROR:-https://nodejs.org/dist}"
NODE_MIRROR_CN="https://npmmirror.com/mirrors/node"
NODE_MUSL_MIRROR="https://unofficial-builds.nodejs.org/download/release"
# 项目自托管 ARM64 musl Node.js (unofficial-builds 仅提供 x64 musl)
NODE_SELF_HOST="https://github.com/10000ge10000/luci-app-openclaw/releases/download/node-bins"
export PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:$PATH"
export NODE_ICU_DATA="${NODE_BASE}/share/icu"
log_info() { echo " [✓] $1"; }
log_warn() { echo " [!] $1"; }
log_error() { echo " [✗] $1"; }
# 安全创建目录 (会在 _oc_fix_opt 修复 /opt 后使用标准路径)
ensure_mkdir() {
local target="$1"
[ -d "$target" ] && return 0
if ! mkdir -p "$target" 2>/dev/null; then
log_error "无法创建目录: $target"
log_error "如果安装了 Docker可能需要手动执行: mount --bind /overlay/upper/opt /opt"
return 1
fi
}
# 检测 C 运行时类型 (glibc vs musl)
detect_libc() {
if ldd --version 2>&1 | grep -qi musl; then
echo "musl"
elif [ -f /lib/ld-musl-*.so.1 ] 2>/dev/null; then
echo "musl"
elif [ -f /etc/openwrt_release ] || grep -qi "openwrt\|istoreos\|lede" /etc/os-release 2>/dev/null; then
echo "musl"
else
echo "glibc"
fi
}
# 在所有可能的位置查找 openclaw 入口文件
# pnpm 全局安装路径形如: $OC_GLOBAL/5/node_modules/openclaw
find_oc_entry() {
local search_dirs="${OC_GLOBAL}/lib/node_modules/openclaw
${OC_GLOBAL}/node_modules/openclaw
${NODE_BASE}/lib/node_modules/openclaw"
# 添加 pnpm 版本化目录 (如 /opt/openclaw/global/5/node_modules/openclaw)
for ver_dir in "${OC_GLOBAL}"/*/node_modules/openclaw; do
[ -d "$ver_dir" ] 2>/dev/null && search_dirs="$search_dirs
$ver_dir"
done
local d
echo "$search_dirs" | while read -r d; do
[ -z "$d" ] && continue
if [ -f "${d}/openclaw.mjs" ]; then
echo "${d}/openclaw.mjs"
return
elif [ -f "${d}/dist/cli.js" ]; then
echo "${d}/dist/cli.js"
return
fi
done
}
detect_arch() {
local arch
arch=$(uname -m)
case "$arch" in
x86_64) echo "linux-x64" ;;
aarch64) echo "linux-arm64" ;;
armv7l|armv6l)
log_error "不支持的 CPU 架构: $arch"
log_error "Node.js v22+ 不提供 32 位 ARM 预编译包。"
log_error "建议: 使用 aarch64 (ARM64) 版本的固件,或使用 x86_64 设备。"
exit 1
;;
*)
log_error "不支持的 CPU 架构: $arch (仅支持 x86_64/aarch64)"
exit 1
;;
esac
}
download_node() {
local node_ver="$1"
local node_arch
node_arch=$(detect_arch)
local libc_type
libc_type=$(detect_libc)
# 如果已存在且版本兼容, 跳过
if [ -x "$NODE_BIN" ]; then
local current_ver
current_ver=$("$NODE_BIN" --version 2>/dev/null | sed 's/^v//')
if [ "$current_ver" = "$node_ver" ]; then
log_info "Node.js v${node_ver} 已安装, 跳过下载"
return 0
fi
# ARM64 musl 使用 Alpine 打包,版本号可能不完全匹配
# 只要主版本号相同即认为兼容 (如 22.15.1 vs 22.15.0)
local cur_major=$(echo "$current_ver" | cut -d. -f1)
local want_major=$(echo "$node_ver" | cut -d. -f1)
if [ "$cur_major" = "$want_major" ]; then
log_info "Node.js v${current_ver} 已安装 (兼容 v${node_ver}), 跳过下载"
return 0
fi
log_warn "当前 Node.js v${current_ver}, 将更新到 v${node_ver}"
fi
# ── 构建下载 URL 列表 (按优先级排列) ──
local mirror_list=""
local musl_tarball="node-v${node_ver}-${node_arch}-musl.tar.xz"
local glibc_tarball="node-v${node_ver}-${node_arch}.tar.xz"
if [ "$libc_type" = "musl" ]; then
echo ""
echo "=== 下载 Node.js v${node_ver} (${node_arch}, musl libc) ==="
if [ "$node_arch" = "linux-arm64" ]; then
# ARM64 musl: unofficial-builds 不提供,从项目自托管下载
# 1) 项目自托管 ARM64 musl 构建
mirror_list="${NODE_SELF_HOST}/${musl_tarball}"
# 2) unofficial-builds (留作将来可能支持)
mirror_list="$mirror_list ${NODE_MUSL_MIRROR}/v${node_ver}/${musl_tarball}"
else
# x64 musl: unofficial-builds 提供
# 1) unofficial-builds
mirror_list="${NODE_MUSL_MIRROR}/v${node_ver}/${musl_tarball}"
# 2) npmmirror 镜像
mirror_list="$mirror_list ${NODE_MIRROR_CN}/v${node_ver}/${musl_tarball}"
fi
else
echo ""
echo "=== 下载 Node.js v${node_ver} (${node_arch}, glibc) ==="
mirror_list="${NODE_MIRROR}/v${node_ver}/${glibc_tarball}"
mirror_list="$mirror_list ${NODE_MIRROR_CN}/v${node_ver}/${glibc_tarball}"
fi
# ── 逐个尝试下载 ──
local downloaded=0
local tmp_file="/tmp/node-v${node_ver}.tar.xz"
local attempt=0
local total=$(echo "$mirror_list" | wc -w)
for mirror_url in $mirror_list; do
attempt=$((attempt + 1))
echo " 正在从 ${mirror_url} 下载... (${attempt}/${total})"
if curl -fSL --connect-timeout 15 --max-time 300 -o "$tmp_file" "$mirror_url" 2>/dev/null || \
wget -q --timeout=15 -O "$tmp_file" "$mirror_url" 2>/dev/null; then
# 校验文件大小 (Node.js xz 压缩包至少 5MB; Alpine 精简包约 12MB, 官方完整包约 30MB)
local fsize=$(wc -c < "$tmp_file" 2>/dev/null || echo 0)
if [ "$fsize" -gt 5000000 ] 2>/dev/null; then
downloaded=1
break
else
log_warn "文件大小异常 (${fsize} bytes), 跳过"
rm -f "$tmp_file"
fi
fi
log_warn "下载失败, 尝试备用镜像..."
done
if [ "$downloaded" -eq 0 ]; then
log_error "所有镜像均下载失败"
rm -f "$tmp_file"
exit 1
fi
# 解压
echo " 正在解压到 ${NODE_BASE}..."
# OverlayFS 兼容: rm -rf 后可能因 whiteout 导致 mkdir 失败
# 先尝试常规方式,失败则通过 overlay upper 层操作
rm -rf "$NODE_BASE" 2>/dev/null
if [ -d /overlay/upper ]; then
rm -rf "/overlay/upper${NODE_BASE}" 2>/dev/null
fi
ensure_mkdir "$NODE_BASE"
# 兼容 BusyBox tar (不支持 --strip-components) 和 GNU tar
# 方法: 先解压到临时目录,再移动顶层子目录内容到目标目录
if tar --strip-components=1 -xf "$tmp_file" -C "$NODE_BASE" 2>/dev/null; then
: # GNU tar 成功
else
# BusyBox tar 回退: 解压到临时目录后手动移动
local tmp_extract="/tmp/node-extract-$$"
ensure_mkdir "$tmp_extract"
tar xf "$tmp_file" -C "$tmp_extract"
# 找顶层目录 (node-vX.X.X-linux-xxx)
local top_dir
top_dir=$(ls "$tmp_extract" 2>/dev/null | head -1)
if [ -n "$top_dir" ] && [ -d "$tmp_extract/$top_dir" ]; then
cp -a "$tmp_extract/$top_dir/." "$NODE_BASE/"
else
log_error "解压后未找到顶层目录,安装失败"
rm -rf "$tmp_extract"
exit 1
fi
rm -rf "$tmp_extract"
fi
rm -f "$tmp_file"
# 验证
if [ -x "$NODE_BIN" ]; then
local installed_ver
installed_ver=$("$NODE_BIN" --version 2>/dev/null || echo "unknown")
log_info "Node.js ${installed_ver} 安装成功"
else
log_error "Node.js 安装验证失败"
exit 1
fi
}
install_pnpm() {
echo ""
echo "=== 安装 pnpm ==="
if [ -x "$PNPM_BIN" ]; then
log_info "pnpm 已安装: $($PNPM_BIN --version 2>/dev/null)"
return 0
fi
# 使用 npm 安装 pnpm 到全局目录
ensure_mkdir "$OC_GLOBAL"
"$NPM_BIN" install -g pnpm --prefix="$OC_GLOBAL" 2>/dev/null
if [ -x "$OC_GLOBAL/bin/pnpm" ]; then
PNPM_BIN="$OC_GLOBAL/bin/pnpm"
log_info "pnpm $($PNPM_BIN --version 2>/dev/null) 安装成功"
elif [ -x "$NODE_BASE/bin/pnpm" ]; then
PNPM_BIN="$NODE_BASE/bin/pnpm"
log_info "pnpm $($PNPM_BIN --version 2>/dev/null) 安装成功"
else
log_warn "pnpm 安装失败, 将使用 npm 作为回退"
fi
}
install_openclaw() {
echo ""
echo "=== 安装 OpenClaw ==="
# 确定安装版本
local oc_pkg="openclaw"
if [ -n "$OC_VERSION" ]; then
oc_pkg="openclaw@${OC_VERSION}"
log_info "指定版本: v${OC_VERSION}"
else
oc_pkg="openclaw@latest"
log_info "安装最新版本"
fi
local libc_type
libc_type=$(detect_libc)
# musl 系统使用 npm + --ignore-scripts 避免 node-llama-cpp 编译失败
# glibc 系统正常安装
local install_flags=""
if [ "$libc_type" = "musl" ]; then
log_warn "检测到 musl libc将跳过本地编译依赖 (不影响核心功能)"
install_flags="--ignore-scripts"
fi
# 检查 git 是否可用 (openclaw 部分依赖可能使用 git:// 协议)
if ! command -v git >/dev/null 2>&1; then
log_warn "未检测到 git正在尝试安装..."
opkg update >/dev/null 2>&1
opkg install git git-http 2>&1 | tail -3 || true
if command -v git >/dev/null 2>&1; then
log_info "git 安装成功"
else
log_warn "git 安装失败,将尝试无 git 模式安装"
fi
fi
# 优先用 npm 安装 (pnpm 在 musl 上全局安装可能有路径问题)
local npm_ok=0
if [ -x "$NPM_BIN" ]; then
ensure_mkdir "$OC_GLOBAL"
"$NPM_BIN" install -g "$oc_pkg" --prefix="$OC_GLOBAL" $install_flags 2>&1 | tail -10
# 检查是否安装成功
if [ -n "$(find_oc_entry)" ]; then
npm_ok=1
else
log_warn "首次安装未成功,尝试 --no-optional 模式重试..."
"$NPM_BIN" install -g "$oc_pkg" --prefix="$OC_GLOBAL" $install_flags --no-optional 2>&1 | tail -10
[ -n "$(find_oc_entry)" ] && npm_ok=1
fi
elif [ -x "$PNPM_BIN" ]; then
ensure_mkdir "$OC_GLOBAL"
"$PNPM_BIN" install -g "$oc_pkg" --prefix="$OC_GLOBAL" 2>&1 | tail -5
else
log_error "npm 和 pnpm 均不可用"
exit 1
fi
# 验证
local oc_ver=""
local oc_found
oc_found=$(find_oc_entry)
if [ -n "$oc_found" ]; then
oc_ver=$("$NODE_BIN" "$oc_found" --version 2>/dev/null | tr -d '[:space:]')
fi
if [ -n "$oc_ver" ]; then
log_info "OpenClaw v${oc_ver} 安装成功"
else
log_error "OpenClaw 安装验证失败"
exit 1
fi
# 安装 Gemini CLI (官方模型配置向导的 Google Gemini OAuth 依赖)
if [ -x "$NPM_BIN" ]; then
echo ""
echo "=== 安装 Gemini CLI (Google OAuth 依赖) ==="
"$NPM_BIN" install -g @google/gemini-cli --prefix="$OC_GLOBAL" $install_flags 2>&1 | tail -3
if command -v gemini >/dev/null 2>&1 || [ -x "$OC_GLOBAL/bin/gemini" ]; then
log_info "Gemini CLI 安装成功"
else
log_warn "Gemini CLI 安装失败 (不影响核心功能,仅影响 Google Gemini OAuth 登录)"
fi
fi
}
init_openclaw() {
echo ""
echo "=== 初始化 OpenClaw ==="
# 创建数据目录
ensure_mkdir "$OC_DATA/.openclaw"
# 运行 onboard
local oc_entry=""
oc_entry=$(find_oc_entry)
if [ -n "$oc_entry" ]; then
HOME="$OC_DATA" \
OPENCLAW_HOME="$OC_DATA" \
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
OPENCLAW_CONFIG_PATH="${OC_DATA}/.openclaw/openclaw.json" \
"$NODE_BIN" "$oc_entry" onboard --non-interactive --accept-risk --tools-profile coding 2>/dev/null || true
log_info "初始化完成"
fi
# 设置文件权限
chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true
chown -R openclaw:openclaw "$OC_GLOBAL" 2>/dev/null || true
chown -R openclaw:openclaw "$NODE_BASE" 2>/dev/null || true
}
do_setup() {
local node_ver="$NODE_VERSION"
# 检查是否已安装
if [ -x "$NODE_BIN" ] && [ -n "$(find_oc_entry)" ]; then
local oc_ver=""
local oc_entry="$(find_oc_entry)"
local oc_pkg_dir="$(dirname "$oc_entry")"
[ -f "$oc_pkg_dir/package.json" ] && \
oc_ver=$("$NODE_BIN" -e "try{console.log(require('$oc_pkg_dir/package.json').version)}catch(e){}" 2>/dev/null) || true
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ⚠️ OpenClaw 运行环境已安装,无需重复安装 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo " Node.js: $($NODE_BIN --version 2>/dev/null)"
[ -n "$oc_ver" ] && echo " OpenClaw: v${oc_ver}"
echo ""
echo " 如需升级,请使用: openclaw-env upgrade"
echo " 如需重装,请先卸载: 在 LuCI 界面点击「卸载环境」"
echo ""
exit 0
fi
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ 一万AI分享 OpenClaw 环境安装 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo " 架构: $(uname -m)"
echo " Node 版本: v${node_ver}"
if [ -n "$OC_VERSION" ]; then
echo " OpenClaw: v${OC_VERSION} (稳定版)"
else
echo " OpenClaw: 最新版"
fi
echo " 安装路径: ${NODE_BASE}"
echo " 数据路径: ${OC_DATA}"
echo ""
download_node "$node_ver"
install_pnpm
install_openclaw
init_openclaw
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ✅ 安装完成! ║"
echo "║ ║"
echo "║ 下一步: ║"
echo "║ uci set openclaw.main.enabled=1 ║"
echo "║ uci commit openclaw ║"
echo "║ /etc/init.d/openclaw enable ║"
echo "║ /etc/init.d/openclaw start ║"
echo "║ ║"
echo "║ 或在 LuCI → 服务 → OpenClaw 中启用 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
}
do_check() {
echo "=== OpenClaw 环境检查 ==="
echo ""
# Node.js
if [ -x "$NODE_BIN" ]; then
echo " Node.js: $($NODE_BIN --version 2>/dev/null)"
else
echo " Node.js: 未安装"
fi
# pnpm
if [ -x "$PNPM_BIN" ]; then
echo " pnpm: $($PNPM_BIN --version 2>/dev/null)"
elif [ -x "${NODE_BASE}/bin/pnpm" ]; then
echo " pnpm: $(${NODE_BASE}/bin/pnpm --version 2>/dev/null)"
else
echo " pnpm: 未安装"
fi
# OpenClaw
local oc_entry=""
oc_entry=$(find_oc_entry)
if [ -n "$oc_entry" ] && [ -x "$NODE_BIN" ]; then
echo " OpenClaw: v$($NODE_BIN $oc_entry --version 2>/dev/null | tr -d '[:space:]')"
else
echo " OpenClaw: 未安装"
fi
# 配置文件
if [ -f "$OC_DATA/.openclaw/openclaw.json" ]; then
echo " 配置: $OC_DATA/.openclaw/openclaw.json (存在)"
else
echo " 配置: 未初始化"
fi
# 磁盘使用
local used
used=$(du -sh /opt/openclaw 2>/dev/null | awk '{print $1}')
echo " 磁盘: ${used:-N/A}"
# libc 类型
echo " C库: $(detect_libc)"
}
do_upgrade() {
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ 一万AI分享 OpenClaw 升级 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
if [ ! -x "$NODE_BIN" ]; then
log_error "Node.js 未安装, 请先运行: openclaw-env setup"
exit 1
fi
# 获取当前版本
local current_ver=""
local oc_entry=""
oc_entry=$(find_oc_entry)
if [ -n "$oc_entry" ]; then
local oc_pkg_dir="$(dirname "$oc_entry")"
[ -f "$oc_pkg_dir/package.json" ] && \
current_ver=$("$NODE_BIN" -e "try{console.log(require('$oc_pkg_dir/package.json').version)}catch(e){}" 2>/dev/null) || true
fi
echo " Node.js: $($NODE_BIN --version 2>/dev/null)"
[ -n "$current_ver" ] && echo " 当前版本: v${current_ver}"
echo ""
local libc_type
libc_type=$(detect_libc)
local install_flags=""
[ "$libc_type" = "musl" ] && install_flags="--ignore-scripts"
echo "=== 正在升级 OpenClaw ==="
echo ""
"$NPM_BIN" install -g openclaw@latest --prefix="$OC_GLOBAL" $install_flags 2>&1
# 验证升级结果
local new_ver=""
local new_entry=""
new_entry=$(find_oc_entry)
if [ -n "$new_entry" ]; then
local new_pkg_dir="$(dirname "$new_entry")"
[ -f "$new_pkg_dir/package.json" ] && \
new_ver=$("$NODE_BIN" -e "try{console.log(require('$new_pkg_dir/package.json').version)}catch(e){}" 2>/dev/null) || true
fi
echo ""
if [ -n "$new_ver" ]; then
if [ "$current_ver" = "$new_ver" ]; then
log_info "当前已是最新版本 v${new_ver}"
else
log_info "升级成功: v${current_ver} → v${new_ver}"
fi
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ✅ 升级完成! ║"
echo "╚══════════════════════════════════════════════════════════════╝"
else
log_error "升级验证失败OpenClaw 入口文件未找到"
exit 1
fi
}
# ── 恢复出厂设置 (非交互式) ──
do_factory_reset() {
local config_dir="${OC_DATA}/.openclaw"
local config_file="${config_dir}/openclaw.json"
local auth_file="${config_dir}/agents/main/agent/auth-profiles.json"
log_info "恢复出厂设置..."
# 1. 停止 Gateway
log_info "停止 Gateway..."
/etc/init.d/openclaw stop >/dev/null 2>&1 || true
sleep 2
# 2. 备份当前配置
if [ -f "$config_file" ]; then
local backup_dir="${config_dir}/backups"
local backup_ts=$(date +%Y%m%d_%H%M%S)
ensure_mkdir "$backup_dir"
cp "$config_file" "${backup_dir}/openclaw_${backup_ts}.json"
log_info "备份已保存: backups/openclaw_${backup_ts}.json"
fi
# 3. 重置配置
rm -f "$config_file" "${config_file}.bak" 2>/dev/null || true
echo '{}' > "$config_file"
chown openclaw:openclaw "$config_file" 2>/dev/null || true
# 4. 重置认证信息
if [ -f "$auth_file" ]; then
echo '{"version":1,"profiles":{},"usageStats":{}}' > "$auth_file"
chown openclaw:openclaw "$auth_file" 2>/dev/null || true
fi
# 5. 重新初始化
if [ -x "$NODE_BIN" ]; then
local oc_entry
oc_entry=$(find "$OC_GLOBAL" -name "openclaw.mjs" -path "*/openclaw/openclaw.mjs" 2>/dev/null | head -1)
if [ -n "$oc_entry" ]; then
log_info "重新初始化..."
OPENCLAW_HOME="$OC_DATA" "$NODE_BIN" "$oc_entry" onboard --non-interactive --accept-risk --tools-profile coding >/dev/null 2>&1 || true
fi
fi
# 6. 应用 OpenWrt 适配配置
if [ -x "$NODE_BIN" ] && [ -f "$config_file" ]; then
local new_token
new_token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48)
_JS_KEY="gateway.port" _JS_VAL="18789" "$NODE_BIN" -e "const fs=require('fs');let d={};try{d=JSON.parse(fs.readFileSync('${config_file}','utf8'));}catch(e){}const ks=process.env._JS_KEY.split('.');let o=d;for(let i=0;i<ks.length-1;i++){if(!o[ks[i]]||typeof o[ks[i]]!=='object')o[ks[i]]={};o=o[ks[i]];}let v=process.env._JS_VAL;try{v=JSON.parse(v);}catch(e){}o[ks[ks.length-1]]=v;fs.writeFileSync('${config_file}',JSON.stringify(d,null,2));" 2>/dev/null
for kv in "gateway.bind=lan" "gateway.mode=local" "gateway.auth.mode=token" "gateway.auth.token=${new_token}" "gateway.controlUi.allowInsecureAuth=true" "gateway.controlUi.dangerouslyDisableDeviceAuth=true" "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true" "gateway.tailscale.mode=off" "acp.dispatch.enabled=false" "tools.profile=coding"; do
local k="${kv%%=*}" v="${kv#*=}"
_JS_KEY="$k" _JS_VAL="$v" "$NODE_BIN" -e "const fs=require('fs');let d={};try{d=JSON.parse(fs.readFileSync('${config_file}','utf8'));}catch(e){}const ks=process.env._JS_KEY.split('.');let o=d;for(let i=0;i<ks.length-1;i++){if(!o[ks[i]]||typeof o[ks[i]]!=='object')o[ks[i]]={};o=o[ks[i]];}let v=process.env._JS_VAL;try{v=JSON.parse(v);}catch(e){}o[ks[ks.length-1]]=v;fs.writeFileSync('${config_file}',JSON.stringify(d,null,2));" 2>/dev/null
done
chown openclaw:openclaw "$config_file" 2>/dev/null || true
# 同步 token 到 UCI
. /lib/functions.sh 2>/dev/null || true
uci set openclaw.main.token="$new_token" 2>/dev/null || true
uci commit openclaw 2>/dev/null || true
log_info "新认证令牌: $new_token"
fi
# 7. 重启服务
/etc/init.d/openclaw start >/dev/null 2>&1 &
log_info "出厂设置已恢复Gateway 重启中..."
}
# ── 主入口 ──
case "${1:-}" in
setup)
do_setup
;;
check)
do_check
;;
upgrade)
do_upgrade
;;
node)
download_node "$NODE_VERSION"
;;
factory-reset)
do_factory_reset
;;
*)
echo ""
echo " setup — 完整安装 (下载 Node.js + pnpm + OpenClaw)"
echo " check — 检查环境状态"
echo " upgrade — 升级 OpenClaw 到最新版"
echo " node — 仅下载/更新 Node.js"
echo " factory-reset — 恢复出厂设置 (清除所有配置)"
exit 1
;;
esac

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,301 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>一万AI分享 OpenClaw 配置管理</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#1a1b26;font-family:system-ui,-apple-system,sans-serif}
#loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#7aa2f7;z-index:100;background:#1a1b26;transition:opacity .4s}
#loading.hidden{opacity:0;pointer-events:none}
#loading .spinner{width:40px;height:40px;border:3px solid #2a2b3d;border-top:3px solid #ff9e64;border-radius:50%;animation:spin .8s linear infinite;margin-bottom:16px}
@keyframes spin{to{transform:rotate(360deg)}}
#loading h2{font-size:18px;font-weight:500;margin-bottom:8px}
#loading p{font-size:13px;color:#565f89}
#loading .debug{font-size:11px;color:#3b3d57;margin-top:12px;max-width:90%;word-break:break-all;text-align:center}
#terminal-container{width:100%;height:calc(100% - 36px);position:absolute;top:36px;left:0;right:0;bottom:0;padding:4px;overflow:hidden}
.xterm{height:100%!important}
.xterm-viewport::-webkit-scrollbar{width:8px}
.xterm-viewport::-webkit-scrollbar-thumb{background:#2a2b3d;border-radius:4px}
.xterm-viewport::-webkit-scrollbar-thumb:hover{background:#3b3d57}
#topbar{position:fixed;top:0;left:0;right:0;height:36px;background:#16161e;border-bottom:1px solid #2a2b3d;display:flex;align-items:center;padding:0 12px;z-index:50;gap:8px}
#topbar .logo{font-size:14px;color:#ff9e64;font-weight:600}
#topbar .status{font-size:11px;padding:2px 8px;border-radius:10px;background:#2a2b3d}
#topbar .status.connected{color:#9ece6a}
#topbar .status.disconnected{color:#f7768e}
#topbar .btn{font-size:12px;color:#7aa2f7;background:none;border:1px solid #3b3d57;border-radius:4px;padding:3px 10px;cursor:pointer;margin-left:auto}
#topbar .btn:hover{background:#2a2b3d}
#reconnect-overlay{display:none;position:absolute;inset:0;background:rgba(26,27,38,.9);z-index:80;flex-direction:column;align-items:center;justify-content:center;color:#c0caf5}
#reconnect-overlay.show{display:flex}
#reconnect-overlay button{margin-top:16px;padding:8px 24px;background:#ff9e64;color:#1a1b26;border:none;border-radius:6px;font-size:14px;cursor:pointer;font-weight:600}
#reconnect-overlay button:hover{background:#e0884a}
</style>
</head>
<body>
<div id="topbar">
<span class="logo">🦞 一万AI分享 OpenClaw 配置管理</span>
<span id="status" class="status disconnected">● 连接中...</span>
<button class="btn" onclick="location.reload()" title="重新启动配置脚本">🔄 重启</button>
</div>
<div id="loading">
<div class="spinner"></div>
<h2>🦞 一万AI分享 OpenClaw 配置管理工具</h2>
<p id="loading-msg">正在连接终端...</p>
<div id="loading-debug" class="debug"></div>
</div>
<div id="terminal-container"></div>
<div id="reconnect-overlay">
<p style="font-size:16px;margin-bottom:4px">⚡ 连接已断开</p>
<p style="font-size:13px;color:#565f89">配置脚本已退出或连接中断</p>
<button onclick="location.reload()">🔄 重新启动</button>
</div>
<link rel="stylesheet" href="/lib/xterm.min.css">
<script src="/lib/xterm.min.js"></script>
<script src="/lib/addon-fit.min.js"></script>
<script src="/lib/addon-web-links.min.js"></script>
<script>
(function() {
const statusEl = document.getElementById('status');
const loadingEl = document.getElementById('loading');
const reconnectEl = document.getElementById('reconnect-overlay');
const containerEl = document.getElementById('terminal-container');
const loadingMsg = document.getElementById('loading-msg');
const loadingDebug = document.getElementById('loading-debug');
// ── 创建终端 ──
const term = new window.Terminal({
cursorBlink: true,
cursorStyle: 'bar',
fontSize: 15,
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", "Source Code Pro", Menlo, Monaco, "Courier New", monospace',
lineHeight: 1.2,
scrollback: 5000,
allowProposedApi: true,
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#ff9e64',
cursorAccent: '#1a1b26',
selectionBackground: '#33467c',
selectionForeground: '#c0caf5',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
}
});
const fitAddon = new window.FitAddon.FitAddon();
term.loadAddon(fitAddon);
try {
const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
} catch(e) { /* optional */ }
term.open(containerEl);
function doFit() {
try { fitAddon.fit(); } catch(e) { /* ignore */ }
try { term.scrollToBottom(); } catch(e) { /* ignore */ }
}
doFit();
window.addEventListener('resize', () => { setTimeout(doFit, 100); });
// 监听 iframe 容器大小变化 (FnOS 桌面可能调整窗口大小)
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(function() { setTimeout(doFit, 50); }).observe(containerEl);
}
// ── WebSocket 连接 ──
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
// 从 URL 参数或 hash 获取 PTY token 用于 WebSocket 认证
const urlParams = new URLSearchParams(location.search);
const ptyToken = urlParams.get('pty_token') || '';
const wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
let ws = null;
let connected = false;
let retryCount = 0;
const MAX_RETRY = Infinity;
let retryTimer = null;
let wasEverConnected = false;
let pingTimer = null;
console.log('[oc-config] protocol:', location.protocol);
console.log('[oc-config] host:', location.host, 'port:', location.port);
console.log('[oc-config] WebSocket URL:', wsUrl);
loadingDebug.textContent = wsUrl;
function connect() {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
if (ws) { try { ws.close(); } catch(e){} ws = null; }
retryCount++;
console.log('[oc-config] Connecting to', wsUrl, '(attempt', retryCount + ')');
loadingMsg.textContent = retryCount > 1
? '正在重新连接... (第 ' + retryCount + ' 次)'
: '正在连接终端...';
loadingDebug.textContent = wsUrl;
// 先做一个 HTTP 预检确认服务器可达
fetch('/health').then(function(r) {
return r.json();
}).then(function(data) {
console.log('[oc-config] Health check OK:', JSON.stringify(data));
doWebSocket();
}).catch(function(err) {
console.log('[oc-config] Health check failed:', err.message || err);
// 服务器不可达,稍后重试
scheduleRetry();
});
}
function doWebSocket() {
try {
ws = new WebSocket(wsUrl);
} catch(e) {
console.error('[oc-config] WebSocket constructor error:', e);
scheduleRetry();
return;
}
console.log('[oc-config] WebSocket created, readyState:', ws.readyState);
// 连接超时保护: 5秒没连上就重试
var connectTimeout = setTimeout(function() {
if (ws && ws.readyState !== WebSocket.OPEN) {
console.log('[oc-config] Connection timeout, readyState:', ws.readyState);
try { ws.close(); } catch(e){}
scheduleRetry();
}
}, 5000);
ws.onopen = function() {
clearTimeout(connectTimeout);
connected = true;
var isReconnect = wasEverConnected;
wasEverConnected = true;
retryCount = 0;
loadingEl.classList.add('hidden');
reconnectEl.classList.remove('show');
statusEl.textContent = '● 已连接';
statusEl.className = 'status connected';
// 重连时清屏,避免旧内容和新菜单混在一起
if (isReconnect) {
term.clear();
term.write('\x1b[33m⚡ 已重新连接,配置脚本已重新启动\x1b[0m\r\n\r\n');
}
// 连接后重新 fit确保尺寸正确
setTimeout(doFit, 50);
term.focus();
try {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
} catch(e) {}
// 客户端心跳: 每 20 秒发送一次,防止连接被浏览器/代理超时关闭
if (pingTimer) clearInterval(pingTimer);
pingTimer = setInterval(function() {
if (ws && ws.readyState === WebSocket.OPEN) {
try { ws.send(JSON.stringify({ type: 'ping' })); } catch(e) {}
}
}, 20000);
};
ws.onmessage = function(ev) {
if (typeof ev.data === 'string') {
// 过滤掉服务端的 pong 心跳消息,不写入终端
if (ev.data.indexOf('"type":"pong"') !== -1) return;
term.write(ev.data, function() { term.scrollToBottom(); });
} else if (ev.data instanceof Blob) {
ev.data.text().then(function(text) {
if (text.indexOf('"type":"pong"') !== -1) return;
term.write(text, function() { term.scrollToBottom(); });
});
}
};
ws.onclose = function(ev) {
clearTimeout(connectTimeout);
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
connected = false;
statusEl.textContent = '● 已断开';
statusEl.className = 'status disconnected';
console.log('[oc-config] WebSocket closed, code:', ev.code, 'reason:', ev.reason);
if (wasEverConnected) {
// 自动重连,不显示断连覆盖层
console.log('[oc-config] Auto-reconnecting in 2s...');
statusEl.textContent = '● 重连中...';
retryCount = 0;
setTimeout(function() {
connect();
}, 2000);
} else {
// 从未连接成功过,自动重试
scheduleRetry();
}
};
ws.onerror = function(ev) {
clearTimeout(connectTimeout);
connected = false;
console.error('[oc-config] WebSocket error, will retry');
// 不要在这里做UI更新, onclose 会紧跟着触发
};
}
function scheduleRetry() {
if (retryCount >= MAX_RETRY) {
loadingMsg.textContent = '连接失败,请检查服务状态';
loadingDebug.textContent = '已重试 ' + MAX_RETRY + ' 次。请刷新页面重试。';
statusEl.textContent = '● 连接失败';
statusEl.className = 'status disconnected';
return;
}
// 逐步增加重试间隔: 1s, 1s, 2s, 2s, 3s, 3s, ...
var delay = Math.min(Math.floor(retryCount / 2) + 1, 5) * 1000;
loadingMsg.textContent = '等待服务就绪... ' + Math.ceil(delay/1000) + '秒后重试';
console.log('[oc-config] Retry in', delay, 'ms');
retryTimer = setTimeout(connect, delay);
}
term.onData(function(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'stdin', data: data }));
}
});
term.onResize(function(size) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
}
});
// 等 DOM 和资源就绪后再连接
if (document.readyState === 'complete') {
connect();
} else {
window.addEventListener('load', connect);
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map

View File

@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
//# sourceMappingURL=addon-web-links.js.map

View File

@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using clean-css v5.3.3.
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,340 @@
#!/usr/bin/env node
// ============================================================================
// OpenClaw 配置工具 — Web PTY 服务器
// 纯 Node.js 实现,零外部依赖
// 通过 WebSocket 将 oc-config.sh 的 TTY 输出推送给浏览器 xterm.js
// HTTP 端口 18793, HTTPS 可选端口 18794
// ============================================================================
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
// ── 配置 (OpenWrt 适配) ──
const PORT = parseInt(process.env.OC_CONFIG_PORT || '18793', 10);
const HOST = process.env.OC_CONFIG_HOST || '0.0.0.0'; // token 认证保护,可安全绑定所有接口
const NODE_BASE = process.env.NODE_BASE || '/opt/openclaw/node';
const OC_GLOBAL = process.env.OC_GLOBAL || '/opt/openclaw/global';
const OC_DATA = process.env.OC_DATA || '/opt/openclaw/data';
const SCRIPT_PATH = process.env.OC_CONFIG_SCRIPT || '/usr/share/openclaw/oc-config.sh';
const SSL_CERT = '/etc/uhttpd.crt';
const SSL_KEY = '/etc/uhttpd.key';
const MAX_SESSIONS = parseInt(process.env.OC_MAX_SESSIONS || '5', 10);
// ── 认证令牌 (从 UCI 或环境变量读取) ──
function loadAuthToken() {
try {
const { execSync } = require('child_process');
const t = execSync('uci -q get openclaw.main.pty_token 2>/dev/null', { encoding: 'utf8', timeout: 3000 }).trim();
return t || '';
} catch { return ''; }
}
let AUTH_TOKEN = process.env.OC_PTY_TOKEN || loadAuthToken();
// ── 会话计数 ──
let activeSessions = 0;
// ── 静态文件 ──
const UI_DIR = path.join(__dirname, 'ui');
function getMimeType(ext) {
const types = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css',
'.js': 'application/javascript', '.png': 'image/png',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json',
};
return types[ext] || 'application/octet-stream';
}
const IFRAME_HEADERS = {
'Access-Control-Allow-Origin': '*',
'X-Frame-Options': 'ALLOWALL',
'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss:; frame-ancestors *",
};
// ── WebSocket 帧处理 (RFC 6455) ──
function decodeWSFrame(buf) {
if (buf.length < 2) return null;
const opcode = buf[0] & 0x0f;
const masked = !!(buf[1] & 0x80);
let payloadLen = buf[1] & 0x7f;
let offset = 2;
if (payloadLen === 126) {
if (buf.length < 4) return null;
payloadLen = buf.readUInt16BE(2); offset = 4;
} else if (payloadLen === 127) {
if (buf.length < 10) return null;
payloadLen = Number(buf.readBigUInt64BE(2)); offset = 10;
}
let mask = null;
if (masked) {
if (buf.length < offset + 4) return null;
mask = buf.slice(offset, offset + 4); offset += 4;
}
if (buf.length < offset + payloadLen) return null;
const data = buf.slice(offset, offset + payloadLen);
if (mask) { for (let i = 0; i < data.length; i++) data[i] ^= mask[i & 3]; }
return { opcode, data, totalLen: offset + payloadLen };
}
function encodeWSFrame(data, opcode = 0x01) {
const payload = typeof data === 'string' ? Buffer.from(data) : data;
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.alloc(2); header[0] = 0x80 | opcode; header[1] = len;
} else if (len < 65536) {
header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 126; header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2);
}
return Buffer.concat([header, payload]);
}
// ── PTY 进程管理 ──
class PtySession {
constructor(socket) {
this.socket = socket;
this.proc = null;
this.cols = 80;
this.rows = 24;
this.buffer = Buffer.alloc(0);
this.alive = true;
this._spawnFailCount = 0;
this._MAX_SPAWN_RETRIES = 5;
this._pingTimer = null;
this._pongReceived = true;
activeSessions++;
console.log(`[oc-config] Session created (active: ${activeSessions}/${MAX_SESSIONS})`);
this._setupWSReader();
this._startPing();
this._spawnPty();
}
// WebSocket ping/pong 保活 (每 25 秒发一次 ping)
_startPing() {
this._pingTimer = setInterval(() => {
if (!this.alive) { clearInterval(this._pingTimer); return; }
if (!this._pongReceived) {
console.log('[oc-config] Pong timeout, closing connection');
this._cleanup();
return;
}
this._pongReceived = false;
try { this.socket.write(encodeWSFrame(Buffer.alloc(0), 0x09)); } catch(e) { this._cleanup(); }
}, 25000);
}
_setupWSReader() {
this.socket.on('data', (chunk) => {
this.buffer = Buffer.concat([this.buffer, chunk]);
while (this.buffer.length > 0) {
const frame = decodeWSFrame(this.buffer);
if (!frame) break;
this.buffer = this.buffer.slice(frame.totalLen);
if (frame.opcode === 0x01) this._handleMessage(frame.data.toString());
else if (frame.opcode === 0x02 && this.proc && this.proc.stdin.writable) this.proc.stdin.write(frame.data);
else if (frame.opcode === 0x08) { console.log('[oc-config] WS close frame received'); this._cleanup(); }
else if (frame.opcode === 0x09) this.socket.write(encodeWSFrame(frame.data, 0x0a));
else if (frame.opcode === 0x0a) { this._pongReceived = true; }
}
});
this.socket.on('close', (hadError) => { console.log(`[oc-config] Socket closed, hadError=${hadError}`); this._cleanup(); });
this.socket.on('error', (err) => { console.log(`[oc-config] Socket error: ${err.message}`); this._cleanup(); });
}
_handleMessage(text) {
try {
const msg = JSON.parse(text);
if (msg.type === 'stdin' && this.proc && this.proc.stdin.writable) {
// 去除 bracketed paste 转义序列,避免污染 shell read 输入
const cleaned = msg.data.replace(/\x1b\[\?2004[hl]/g, '').replace(/\x1b\[20[01]~/g, '');
this.proc.stdin.write(cleaned);
}
else if (msg.type === 'resize') {
this.cols = msg.cols || 80; this.rows = msg.rows || 24;
if (this.proc && this.proc.pid) { try { process.kill(-this.proc.pid, 'SIGWINCH'); } catch(e){} }
}
else if (msg.type === 'ping') {
// 应用层心跳: 客户端定期发送 ping服务端回复 pong 保持连接活跃
this.socket.write(encodeWSFrame(JSON.stringify({ type: 'pong' }), 0x01));
}
} catch(e) { if (this.proc && this.proc.stdin.writable) this.proc.stdin.write(text); }
}
_spawnPty() {
const env = {
...process.env, TERM: 'xterm-256color', COLUMNS: String(this.cols), LINES: String(this.rows),
COLORTERM: 'truecolor', LANG: 'en_US.UTF-8',
NODE_BASE, OC_GLOBAL, OC_DATA,
HOME: OC_DATA,
OPENCLAW_HOME: OC_DATA,
OPENCLAW_STATE_DIR: `${OC_DATA}/.openclaw`,
OPENCLAW_CONFIG_PATH: `${OC_DATA}/.openclaw/openclaw.json`,
PATH: `${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
};
// 检测 script 命令是否可用 (OpenWrt 默认不包含 util-linux-script)
// 如不可用则回退到直接用 sh 执行,牺牲 PTY 但保证功能可用
const hasScript = (() => {
try {
const { execFileSync } = require('child_process');
execFileSync('which', ['script'], { stdio: 'pipe', timeout: 2000 });
return true;
} catch { return false; }
})();
if (hasScript) {
this.proc = spawn('script', ['-qc', `stty rows ${this.rows} cols ${this.cols} 2>/dev/null; printf '\\e[?2004l'; sh "${SCRIPT_PATH}"`, '/dev/null'],
{ stdio: ['pipe', 'pipe', 'pipe'], env, detached: true });
} else {
console.log('[oc-config] "script" command not found, falling back to sh (install util-linux-script for full PTY support)');
this.proc = spawn('sh', [SCRIPT_PATH],
{ stdio: ['pipe', 'pipe', 'pipe'], env, detached: true });
}
this.proc.stdout.on('data', (d) => { if (this.alive) { this._spawnFailCount = 0; this.socket.write(encodeWSFrame(d, 0x01)); } });
this.proc.stderr.on('data', (d) => { if (this.alive) { this._spawnFailCount = 0; this.socket.write(encodeWSFrame(d, 0x01)); } });
this.proc.on('close', (code) => {
if (!this.alive) return;
// PTY 以 root 运行,子脚本可能创建了 root-owned 的目录
// 修复权限,防止以 openclaw 用户运行的 Gateway 遇到 EACCES
try { require('child_process').execFileSync('chown', ['-R', 'openclaw:openclaw', OC_DATA], { stdio: 'pipe', timeout: 5000 }); } catch(e) {}
this._spawnFailCount++;
if (this._spawnFailCount > this._MAX_SPAWN_RETRIES) {
console.log(`[oc-config] Script failed ${this._spawnFailCount} times, stopping retries`);
this.socket.write(encodeWSFrame(`\r\n\x1b[31m配置脚本连续启动失败 ${this._spawnFailCount} 次,已停止重试。\r\n请检查是否已安装 util-linux-script 包: opkg install coreutils-script\x1b[0m\r\n`, 0x01));
this.proc = null;
return;
}
console.log(`[oc-config] Script exited with code ${code}, auto-restarting (attempt ${this._spawnFailCount}/${this._MAX_SPAWN_RETRIES})...`);
this.socket.write(encodeWSFrame(`\r\n\x1b[33m配置脚本已退出 (code: ${code}),正在自动重启...\x1b[0m\r\n`, 0x01));
this.proc = null;
// 自动重启脚本,保持 WebSocket 连接
setTimeout(() => {
if (this.alive) {
this._spawnPty();
}
}, 1500);
});
this.proc.on('error', (err) => {
this._spawnFailCount++;
if (this.alive) this.socket.write(encodeWSFrame(`\r\n\x1b[31m启动失败: ${err.message}\x1b[0m\r\n`, 0x01));
});
}
_cleanup() {
if (!this.alive) return; this.alive = false;
if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
activeSessions = Math.max(0, activeSessions - 1);
console.log(`[oc-config] Session ended (active: ${activeSessions}/${MAX_SESSIONS})`);
if (this.proc) { try { process.kill(-this.proc.pid, 'SIGTERM'); } catch(e){} try { this.proc.kill('SIGTERM'); } catch(e){} }
try { this.socket.destroy(); } catch(e){}
}
}
// ── HTTP 请求处理 ──
function handleRequest(req, res) {
const url = new URL(req.url, `http://${req.headers.host}`);
let fp = url.pathname;
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': '*' });
return res.end();
}
if (fp === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
return res.end(JSON.stringify({ status: 'ok', port: PORT, uptime: process.uptime() }));
}
if (fp === '/' || fp === '') fp = '/index.html';
const fullPath = path.join(UI_DIR, fp);
if (!fullPath.startsWith(UI_DIR)) { res.writeHead(403); return res.end('Forbidden'); }
fs.readFile(fullPath, (err, data) => {
if (err) {
if (fp !== '/index.html') {
fs.readFile(path.join(UI_DIR, 'index.html'), (e2, d2) => {
if (e2) { res.writeHead(404); res.end('Not Found'); }
else { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...IFRAME_HEADERS }); res.end(d2); }
});
} else { res.writeHead(404); res.end('Not Found'); }
return;
}
const ext = path.extname(fullPath);
res.writeHead(200, { 'Content-Type': getMimeType(ext), 'Cache-Control': ext === '.html' ? 'no-cache' : 'max-age=3600', ...IFRAME_HEADERS });
res.end(data);
});
}
// ── WebSocket Upgrade ──
function handleUpgrade(req, socket, head) {
console.log(`[oc-config] WS upgrade: ${req.url} remote=${socket.remoteAddress}:${socket.remotePort}`);
if (req.url !== '/ws' && !req.url.startsWith('/ws?')) { socket.destroy(); return; }
// 认证: 验证查询参数中的 token
// 每次连接时实时读取 UCI token (安装/升级可能重新生成 token)
const currentToken = loadAuthToken() || AUTH_TOKEN;
if (currentToken) {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const clientToken = url.searchParams.get('token') || '';
if (clientToken !== currentToken) {
console.log(`[oc-config] WS auth failed from ${socket.remoteAddress}`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
}
// 并发会话限制
if (activeSessions >= MAX_SESSIONS) {
console.log(`[oc-config] Max sessions reached (${activeSessions}/${MAX_SESSIONS}), rejecting`);
socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
socket.destroy();
return;
}
const key = req.headers['sec-websocket-key'];
if (!key) { console.log('[oc-config] Missing Sec-WebSocket-Key'); socket.destroy(); return; }
const accept = crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
socket.setNoDelay(true);
socket.setTimeout(0);
const handshake = 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n';
socket.write(handshake, () => {
if (head && head.length > 0) socket.unshift(head);
new PtySession(socket);
console.log('[oc-config] PTY session started');
});
}
// ── 服务器实例 ──
const httpServer = http.createServer(handleRequest);
httpServer.on('upgrade', handleUpgrade);
let httpsServer = null;
httpServer.listen(PORT, HOST, () => {
console.log(`[oc-config] HTTP listening on ${HOST}:${PORT}`);
console.log(`[oc-config] Script: ${SCRIPT_PATH}`);
});
// HTTPS 可选端口 PORT+1
const HTTPS_PORT = PORT + 1;
try {
if (fs.existsSync(SSL_CERT) && fs.existsSync(SSL_KEY)) {
httpsServer = https.createServer({ cert: fs.readFileSync(SSL_CERT), key: fs.readFileSync(SSL_KEY) }, handleRequest);
httpsServer.on('upgrade', handleUpgrade);
httpsServer.listen(HTTPS_PORT, HOST, () => console.log(`[oc-config] HTTPS listening on ${HOST}:${HTTPS_PORT}`));
httpsServer.on('error', (e) => console.log(`[oc-config] HTTPS port ${HTTPS_PORT}: ${e.message}`));
}
} catch (e) { console.log(`[oc-config] SSL init: ${e.message}`); }
httpServer.on('error', (e) => { console.error(`[oc-config] Fatal: ${e.message}`); process.exit(1); });
function shutdown() { console.log('[oc-config] Shutdown'); httpServer.close(); if (httpsServer) httpsServer.close(); process.exit(0); }
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

View File

@ -0,0 +1,11 @@
{
"luci-app-openclaw": {
"description": "Grant UCI access for luci-app-openclaw",
"read": {
"uci": [ "openclaw" ]
},
"write": {
"uci": [ "openclaw" ]
}
}
}

View File

@ -0,0 +1,114 @@
#!/bin/sh
# 在 Alpine ARM64 Docker 容器内运行
# 环境变量: NODE_VER (目标版本号), /output (输出目录)
#
# 打包策略:
# 使用 patchelf 修改 node 二进制的 ELF interpreter 和 rpath
# 使其直接使用打包的 musl 链接器和共享库,无需 LD_LIBRARY_PATH。
# 这样 process.execPath 返回正确的 node 路径,子进程 fork 也能正常工作。
# 安装路径固定为 /opt/openclaw/node (与 openclaw-env 一致)。
set -e
INSTALL_PREFIX="/opt/openclaw/node"
apk add --no-cache nodejs npm xz icu-data-full patchelf
ACTUAL_VER=$(node --version | sed 's/^v//')
echo "Alpine Node.js version: v${ACTUAL_VER} (requested: v${NODE_VER})"
# 使用实际版本号作为文件名 (Alpine apk 的 nodejs 版本可能与请求版本不同)
if [ "$ACTUAL_VER" != "$NODE_VER" ]; then
echo "WARNING: Actual version (${ACTUAL_VER}) differs from requested (${NODE_VER})"
echo " Using actual version for package name"
fi
PKG_NAME="node-v${ACTUAL_VER}-linux-arm64-musl"
PKG_DIR="/tmp/${PKG_NAME}"
mkdir -p "${PKG_DIR}/bin" "${PKG_DIR}/lib/node_modules" "${PKG_DIR}/include/node"
# 复制 node 二进制
cp "$(which node)" "${PKG_DIR}/bin/node"
chmod +x "${PKG_DIR}/bin/node"
# 收集 node 依赖的所有共享库 (Alpine node 是动态链接的)
echo "=== Collecting shared libraries ==="
LIB_DIR="${PKG_DIR}/lib"
ldd "$(which node)" 2>/dev/null | while read -r line; do
# 解析 ldd 输出: libxxx.so => /usr/lib/libxxx.so (0x...)
lib_path=$(echo "$line" | grep -oE '/[^ ]+\.so[^ ]*' | head -1)
if [ -n "$lib_path" ] && [ -f "$lib_path" ]; then
cp -L "$lib_path" "$LIB_DIR/" 2>/dev/null || true
echo " + $(basename "$lib_path")"
fi
done
# 确保 musl 动态链接器也在
if [ -f /lib/ld-musl-aarch64.so.1 ]; then
cp -L /lib/ld-musl-aarch64.so.1 "$LIB_DIR/" 2>/dev/null || true
echo " + ld-musl-aarch64.so.1"
fi
echo "Libraries collected: $(ls "$LIB_DIR"/*.so* 2>/dev/null | wc -l) files"
# 用 patchelf 修改 node 二进制:
# - interpreter 指向打包的 musl 链接器 (绝对路径,对应安装后的位置)
# - rpath 指向打包的 lib 目录
echo "=== Patching ELF binary ==="
patchelf --set-interpreter "${INSTALL_PREFIX}/lib/ld-musl-aarch64.so.1" "${PKG_DIR}/bin/node"
patchelf --set-rpath "${INSTALL_PREFIX}/lib" "${PKG_DIR}/bin/node"
echo " interpreter: ${INSTALL_PREFIX}/lib/ld-musl-aarch64.so.1"
echo " rpath: ${INSTALL_PREFIX}/lib"
# 复制 ICU 完整数据 (npm 的 Intl.Collator 需要)
echo "=== Copying ICU data ==="
ICU_DAT=$(find /usr/share/icu -name "icudt*.dat" 2>/dev/null | head -1)
if [ -n "$ICU_DAT" ] && [ -f "$ICU_DAT" ]; then
mkdir -p "${PKG_DIR}/share/icu"
cp "$ICU_DAT" "${PKG_DIR}/share/icu/"
echo " + $(basename "$ICU_DAT") ($(du -h "$ICU_DAT" | cut -f1))"
else
echo " WARNING: ICU data file not found"
fi
# 创建 node wrapper 脚本 (只设置 NODE_ICU_DATAELF 层面已解决链接器和库路径)
cat > "${PKG_DIR}/bin/node-wrapper" << 'NODEWRAPPER'
#!/bin/sh
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
export NODE_ICU_DATA="${SELF_DIR}/../share/icu"
exec "${SELF_DIR}/node" "$@"
NODEWRAPPER
chmod +x "${PKG_DIR}/bin/node-wrapper"
# 复制 npm
if [ -d /usr/lib/node_modules/npm ]; then
cp -r /usr/lib/node_modules/npm "${PKG_DIR}/lib/node_modules/"
fi
# 创建 npm wrapper (直接调用 patchelf 后的 node只需设置 ICU)
cat > "${PKG_DIR}/bin/npm" << 'NPMWRAPPER'
#!/bin/sh
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
export NODE_ICU_DATA="${SELF_DIR}/../share/icu"
exec "${SELF_DIR}/node" "${SELF_DIR}/../lib/node_modules/npm/bin/npm-cli.js" "$@"
NPMWRAPPER
# 创建 npx wrapper
cat > "${PKG_DIR}/bin/npx" << 'NPXWRAPPER'
#!/bin/sh
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
export NODE_ICU_DATA="${SELF_DIR}/../share/icu"
exec "${SELF_DIR}/node" "${SELF_DIR}/../lib/node_modules/npm/bin/npx-cli.js" "$@"
NPXWRAPPER
chmod +x "${PKG_DIR}/bin/npm" "${PKG_DIR}/bin/npx"
# 验证 (需要将打包内容放到目标路径来测试 patchelf 结果)
echo "=== Verification ==="
mkdir -p "${INSTALL_PREFIX}"
cp -a "${PKG_DIR}"/* "${INSTALL_PREFIX}/"
"${INSTALL_PREFIX}/bin/node" --version
"${INSTALL_PREFIX}/bin/node" -e "console.log('execPath:', process.execPath)"
"${INSTALL_PREFIX}/bin/node" -e "console.log(process.arch, process.platform, process.versions.modules)"
NODE_ICU_DATA="${INSTALL_PREFIX}/share/icu" "${INSTALL_PREFIX}/bin/npm" --version 2>/dev/null || echo "npm needs ICU data"
rm -rf "${INSTALL_PREFIX}"
# 打包
cd /tmp
tar cJf "/output/${PKG_NAME}.tar.xz" "${PKG_NAME}"
ls -lh "/output/${PKG_NAME}.tar.xz"
echo "=== Done: ${PKG_NAME}.tar.xz ==="

View File

@ -0,0 +1,147 @@
#!/bin/sh
# ============================================================================
# 本地构建 .ipk 包 (无需 OpenWrt SDK)
# 用法: sh scripts/build_ipk.sh [output_dir]
# ============================================================================
set -e
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PKG_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
OUT_DIR="${1:-$PKG_DIR/dist}"
# 确保 OUT_DIR 是绝对路径
case "$OUT_DIR" in
/*) ;;
*) OUT_DIR="$PKG_DIR/$OUT_DIR" ;;
esac
mkdir -p "$OUT_DIR"
PKG_NAME="luci-app-openclaw"
PKG_VERSION=$(cat "$PKG_DIR/VERSION" 2>/dev/null | tr -d '[:space:]' || echo "1.0.0")
PKG_RELEASE="1"
echo "=== 构建 ${PKG_NAME} .ipk 包 ==="
STAGING=$(mktemp -d)
trap "rm -rf '$STAGING'" EXIT
# ── 构建 data.tar.gz ──
DATA_DIR="$STAGING/data"
mkdir -p "$DATA_DIR"
# UCI config
mkdir -p "$DATA_DIR/etc/config"
cp "$PKG_DIR/root/etc/config/openclaw" "$DATA_DIR/etc/config/"
# UCI defaults
mkdir -p "$DATA_DIR/etc/uci-defaults"
cp "$PKG_DIR/root/etc/uci-defaults/99-openclaw" "$DATA_DIR/etc/uci-defaults/"
chmod +x "$DATA_DIR/etc/uci-defaults/99-openclaw"
# init.d
mkdir -p "$DATA_DIR/etc/init.d"
cp "$PKG_DIR/root/etc/init.d/openclaw" "$DATA_DIR/etc/init.d/"
chmod +x "$DATA_DIR/etc/init.d/openclaw"
# bin
mkdir -p "$DATA_DIR/usr/bin"
cp "$PKG_DIR/root/usr/bin/openclaw-env" "$DATA_DIR/usr/bin/"
chmod +x "$DATA_DIR/usr/bin/openclaw-env"
# LuCI controller
mkdir -p "$DATA_DIR/usr/lib/lua/luci/controller"
cp "$PKG_DIR/luasrc/controller/openclaw.lua" "$DATA_DIR/usr/lib/lua/luci/controller/"
# LuCI CBI
mkdir -p "$DATA_DIR/usr/lib/lua/luci/model/cbi/openclaw"
cp "$PKG_DIR/luasrc/model/cbi/openclaw/"*.lua "$DATA_DIR/usr/lib/lua/luci/model/cbi/openclaw/"
# LuCI views
mkdir -p "$DATA_DIR/usr/lib/lua/luci/view/openclaw"
cp "$PKG_DIR/luasrc/view/openclaw/"*.htm "$DATA_DIR/usr/lib/lua/luci/view/openclaw/"
# oc-config assets
mkdir -p "$DATA_DIR/usr/share/openclaw"
cp "$PKG_DIR/VERSION" "$DATA_DIR/usr/share/openclaw/VERSION"
cp "$PKG_DIR/root/usr/share/openclaw/oc-config.sh" "$DATA_DIR/usr/share/openclaw/"
chmod +x "$DATA_DIR/usr/share/openclaw/oc-config.sh"
cp "$PKG_DIR/root/usr/share/openclaw/web-pty.js" "$DATA_DIR/usr/share/openclaw/"
# Web PTY UI
cp -r "$PKG_DIR/root/usr/share/openclaw/ui" "$DATA_DIR/usr/share/openclaw/"
# i18n (po2lmo 可选)
mkdir -p "$DATA_DIR/usr/lib/lua/luci/i18n"
if command -v po2lmo >/dev/null 2>&1 && [ -f "$PKG_DIR/po/zh-cn/openclaw.po" ]; then
po2lmo "$PKG_DIR/po/zh-cn/openclaw.po" "$DATA_DIR/usr/lib/lua/luci/i18n/openclaw.zh-cn.lmo" 2>/dev/null || true
fi
# 计算安装大小
INSTALLED_SIZE=$(du -sk "$DATA_DIR" | awk '{print $1}')
(cd "$DATA_DIR" && tar czf "$STAGING/data.tar.gz" .)
# ── 构建 control.tar.gz ──
CTRL_DIR="$STAGING/control"
mkdir -p "$CTRL_DIR"
cat > "$CTRL_DIR/control" << EOF
Package: ${PKG_NAME}
Version: ${PKG_VERSION}-${PKG_RELEASE}
Depends: luci-compat, luci-base, curl, openssl-util, script-utils, tar
Source: https://github.com/10000ge10000/luci-app-openclaw
SourceName: ${PKG_NAME}
License: GPL-3.0
Section: luci
SourceDateEpoch: $(date +%s)
Maintainer: 10000ge10000 <10000ge10000@users.noreply.github.com>
Architecture: all
Installed-Size: ${INSTALLED_SIZE}
Description: OpenClaw AI 网关 LuCI 管理插件
EOF
cat > "$CTRL_DIR/postinst" << 'EOF'
#!/bin/sh
[ -n "${IPKG_INSTROOT}" ] || {
( . /etc/uci-defaults/99-openclaw ) && rm -f /etc/uci-defaults/99-openclaw
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* /tmp/luci-indexcache.*.json 2>/dev/null
# 重启 Web PTY (使其加载新文件和新 token)
PTY_PID=$(pgrep -f 'web-pty.js' 2>/dev/null | head -1)
[ -n "$PTY_PID" ] && kill "$PTY_PID" 2>/dev/null || true
exit 0
}
EOF
chmod +x "$CTRL_DIR/postinst"
cat > "$CTRL_DIR/postrm" << 'EOF'
#!/bin/sh
[ -n "${IPKG_INSTROOT}" ] || {
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
}
EOF
chmod +x "$CTRL_DIR/postrm"
cat > "$CTRL_DIR/conffiles" << 'EOF'
/etc/config/openclaw
EOF
(cd "$CTRL_DIR" && tar czf "$STAGING/control.tar.gz" .)
# ── 组装 .ipk (ar 格式) ──
mkdir -p "$OUT_DIR"
IPK_FILE="$OUT_DIR/${PKG_NAME}_${PKG_VERSION}-${PKG_RELEASE}_all.ipk"
echo "2.0" > "$STAGING/debian-binary"
# 清理旧文件
rm -f "$IPK_FILE"
# 组装 .ipk — OpenWrt opkg 使用 tar.gz 格式 (非 Debian 的 ar 格式)
(cd "$STAGING" && tar czf "$IPK_FILE" debian-binary control.tar.gz data.tar.gz)
IPK_SIZE=$(wc -c < "$IPK_FILE" | tr -d ' ')
echo ""
echo "=== 构建完成 ==="
echo "输出文件: $IPK_FILE"
echo "文件大小: ${IPK_SIZE} bytes"
echo "安装大小: ${INSTALLED_SIZE} KB"
echo ""
echo "安装方法: opkg install ${PKG_NAME}_${PKG_VERSION}-${PKG_RELEASE}_all.ipk"

View File

@ -0,0 +1,257 @@
#!/bin/sh
# ============================================================================
# iStoreOS .run 自解压包构建脚本
# 用法: sh scripts/build_run.sh [output_dir]
# ============================================================================
set -e
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PKG_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
OUT_DIR="${1:-$PKG_DIR/dist}"
# 确保 OUT_DIR 是绝对路径
case "$OUT_DIR" in
/*) ;;
*) OUT_DIR="$PKG_DIR/$OUT_DIR" ;;
esac
mkdir -p "$OUT_DIR"
PKG_NAME="luci-app-openclaw"
PKG_VERSION=$(cat "$PKG_DIR/VERSION" 2>/dev/null | tr -d '[:space:]' || echo "1.0.0")
echo "=== 构建 iStoreOS .run 安装包 ==="
echo "源目录: $PKG_DIR"
echo "输出到: $OUT_DIR"
# 创建临时打包目录
STAGING=$(mktemp -d)
trap "rm -rf '$STAGING'" EXIT
# 安装文件到暂存区
install_files() {
local dest="$1"
# UCI config (仅首次安装时部署默认配置; 升级时保留用户配置)
# 安装器会在解压后检测并跳过已有配置, 见 install.sh 中的逻辑
mkdir -p "$dest/etc/config"
cp "$PKG_DIR/root/etc/config/openclaw" "$dest/etc/config/openclaw.default"
# UCI defaults
mkdir -p "$dest/etc/uci-defaults"
cp "$PKG_DIR/root/etc/uci-defaults/99-openclaw" "$dest/etc/uci-defaults/"
chmod +x "$dest/etc/uci-defaults/99-openclaw"
# init.d
mkdir -p "$dest/etc/init.d"
cp "$PKG_DIR/root/etc/init.d/openclaw" "$dest/etc/init.d/"
chmod +x "$dest/etc/init.d/openclaw"
# bin
mkdir -p "$dest/usr/bin"
cp "$PKG_DIR/root/usr/bin/openclaw-env" "$dest/usr/bin/"
chmod +x "$dest/usr/bin/openclaw-env"
# LuCI controller
mkdir -p "$dest/usr/lib/lua/luci/controller"
cp "$PKG_DIR/luasrc/controller/openclaw.lua" "$dest/usr/lib/lua/luci/controller/"
# LuCI CBI
mkdir -p "$dest/usr/lib/lua/luci/model/cbi/openclaw"
cp "$PKG_DIR/luasrc/model/cbi/openclaw/"*.lua "$dest/usr/lib/lua/luci/model/cbi/openclaw/"
# LuCI views
mkdir -p "$dest/usr/lib/lua/luci/view/openclaw"
cp "$PKG_DIR/luasrc/view/openclaw/"*.htm "$dest/usr/lib/lua/luci/view/openclaw/"
# oc-config assets
mkdir -p "$dest/usr/share/openclaw"
cp "$PKG_DIR/VERSION" "$dest/usr/share/openclaw/VERSION"
cp "$PKG_DIR/root/usr/share/openclaw/oc-config.sh" "$dest/usr/share/openclaw/"
chmod +x "$dest/usr/share/openclaw/oc-config.sh"
cp "$PKG_DIR/root/usr/share/openclaw/web-pty.js" "$dest/usr/share/openclaw/"
# Web PTY UI (recursive copy)
cp -r "$PKG_DIR/root/usr/share/openclaw/ui" "$dest/usr/share/openclaw/"
}
# 创建安装器脚本头部
create_installer() {
cat > "$STAGING/install.sh" << 'INSTALLER_EOF'
#!/bin/sh
# luci-app-openclaw iStoreOS 安装器
set -e
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ luci-app-openclaw — OpenClaw AI Gateway 插件 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
# 检查系统
if [ ! -f /etc/openwrt_release ]; then
echo "错误: 此安装包仅适用于 OpenWrt/iStoreOS 系统"
exit 1
fi
# 检查架构
ARCH=$(uname -m)
case "$ARCH" in
x86_64|aarch64) ;;
*) echo "错误: 不支持的架构 $ARCH (仅支持 x86_64/aarch64)"; exit 1 ;;
esac
# 检查依赖
for dep in luci-compat luci-base; do
if ! opkg list-installed 2>/dev/null | grep -q "^${dep} "; then
echo "警告: 缺少依赖 $dep,尝试安装..."
opkg update >/dev/null 2>&1 || true
opkg install "$dep" 2>/dev/null || echo " 安装 $dep 失败,请手动安装"
fi
done
echo "正在安装文件..."
# 解压 payload (从 MARKER 行之后)
ARCHIVE=$(awk '/^__ARCHIVE_BELOW__/ {print NR + 1; exit 0; }' "$0")
tail -n +$ARCHIVE "$0" | tar xzf - -C / 2>/dev/null
# UCI 配置文件保护: 升级时不覆盖用户已有配置
if [ -f /etc/config/openclaw ] && [ -f /etc/config/openclaw.default ]; then
# 已有配置, 移除默认文件 (保留用户配置)
rm -f /etc/config/openclaw.default
elif [ -f /etc/config/openclaw.default ]; then
# 首次安装, 使用默认配置
mv /etc/config/openclaw.default /etc/config/openclaw
fi
# 注册到 opkg使 iStore 和 opkg 能识别此包
PKG="luci-app-openclaw"
PKG_VER="__PKG_VERSION__"
INFO_DIR="/usr/lib/opkg/info"
STATUS_FILE="/usr/lib/opkg/status"
INSTALL_TIME=$(date +%s)
mkdir -p "$INFO_DIR"
# 写入 control 文件
cat > "$INFO_DIR/$PKG.control" << CTLEOF
Package: $PKG
Version: $PKG_VER
Depends: luci-compat, luci-base, curl, openssl-util, script-utils, tar
Section: luci
Architecture: all
Installed-Size: 0
Description: OpenClaw AI Gateway — LuCI 界面
CTLEOF
# 写入文件列表 (payload 中已安装的文件)
cat > "$INFO_DIR/$PKG.list" << LISTEOF
__FILE_LIST__
LISTEOF
# 写入 prerm 脚本 (卸载前执行)
cat > "$INFO_DIR/$PKG.prerm" << 'RMEOF'
#!/bin/sh
/etc/init.d/openclaw stop 2>/dev/null
/etc/init.d/openclaw disable 2>/dev/null
exit 0
RMEOF
chmod +x "$INFO_DIR/$PKG.prerm"
# 追加到 opkg status 数据库 (先移除旧记录)
if [ -f "$STATUS_FILE" ]; then
awk -v pkg="$PKG" '
BEGIN { skip=0 }
/^Package:/ { skip=($2==pkg) }
/^$/ { if(skip){skip=0; next} }
!skip { print }
' "$STATUS_FILE" > "${STATUS_FILE}.tmp"
mv "${STATUS_FILE}.tmp" "$STATUS_FILE"
fi
cat >> "$STATUS_FILE" << STEOF
Package: $PKG
Version: $PKG_VER
Depends: luci-compat, luci-base, curl, openssl-util, script-utils, tar
Status: install user installed
Architecture: all
Conffiles:
/etc/config/openclaw 0
Installed-Time: $INSTALL_TIME
STEOF
echo "已注册到 opkg (iStore 可管理)"
# 执行 uci-defaults
if [ -f /etc/uci-defaults/99-openclaw ]; then
echo "执行初始化脚本..."
( . /etc/uci-defaults/99-openclaw ) && rm -f /etc/uci-defaults/99-openclaw
fi
# 清除 LuCI 缓存
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
rm -f /tmp/luci-indexcache.*.json 2>/dev/null
# 重启 Web PTY 服务 (使其加载新文件和新 token)
# PTY 是 procd 管理的实例, kill 后 procd 会自动 respawn
PTY_PID=$(pgrep -f 'web-pty.js' 2>/dev/null | head -1)
if [ -n "$PTY_PID" ]; then
echo "重启配置终端服务..."
kill "$PTY_PID" 2>/dev/null
sleep 1
fi
echo ""
echo "✅ 安装完成!"
echo ""
echo "后续步骤:"
echo " 1. 运行 openclaw-env setup — 下载 Node.js 并安装 OpenClaw"
echo " 2. 访问 LuCI → 服务 → OpenClaw 进行配置"
echo " 3. 或执行 /etc/init.d/openclaw enable && /etc/init.d/openclaw start"
echo ""
exit 0
__ARCHIVE_BELOW__
INSTALLER_EOF
}
# 构建
echo ""
echo "[1/4] 安装文件到暂存区..."
install_files "$STAGING/payload"
echo "[2/4] 生成文件列表..."
# 生成安装文件列表 (供 opkg 卸载时使用)
# 注: openclaw.default 安装后会变为 openclaw, 文件列表中记录最终路径
FILE_LIST=$(cd "$STAGING/payload" && find . -type f | sed 's|^\./|/|' | sed 's|/etc/config/openclaw.default|/etc/config/openclaw|' | sort)
echo "$(echo "$FILE_LIST" | wc -l | tr -d ' ') 个文件"
echo "[3/4] 创建安装器..."
create_installer
# 替换安装器中的占位符
sed -i "s|__PKG_VERSION__|${PKG_VERSION}|g" "$STAGING/install.sh"
# 替换文件列表占位符 — 使用临时文件拼接避免 sed/awk 多行问题
{
sed '/__FILE_LIST__/,$d' "$STAGING/install.sh"
echo "$FILE_LIST"
sed '1,/__FILE_LIST__/d' "$STAGING/install.sh"
} > "$STAGING/install_final.sh"
mv "$STAGING/install_final.sh" "$STAGING/install.sh"
echo "[4/4] 打包..."
mkdir -p "$OUT_DIR"
# 创建 payload tarball
(cd "$STAGING/payload" && tar czf "$STAGING/payload.tar.gz" .)
# 组合: installer header + payload
RUN_FILE="$OUT_DIR/${PKG_NAME}_${PKG_VERSION}.run"
cat "$STAGING/install.sh" "$STAGING/payload.tar.gz" > "$RUN_FILE"
chmod +x "$RUN_FILE"
FILE_SIZE=$(wc -c < "$RUN_FILE" | tr -d ' ')
echo ""
echo "=== 构建完成 ==="
echo "输出文件: $RUN_FILE"
echo "文件大小: $FILE_SIZE bytes"
echo ""
echo "安装方法: sh ${PKG_NAME}_${PKG_VERSION}.run"

View File

@ -0,0 +1,41 @@
#!/bin/bash
# 用法: gen-release-body.sh <版本号> <CHANGELOG路径> <输出目录>
# 为指定版本生成 GitHub Release body markdown 文件
set -e
VER="$1"
CHANGELOG_FILE="$2"
OUT_DIR="$3"
if [ -z "$VER" ] || [ -z "$CHANGELOG_FILE" ] || [ -z "$OUT_DIR" ]; then
echo "用法: $0 <版本号> <CHANGELOG路径> <输出目录>"
exit 1
fi
mkdir -p "$OUT_DIR"
# 提取该版本的 changelog
CONTENT=$(awk "/^## \\[${VER}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" "$CHANGELOG_FILE")
if [ -z "$CONTENT" ]; then
CONTENT="暂无更新日志"
fi
# 写入文件
{
printf '%s\n' "$CONTENT"
echo ""
echo "---"
echo ""
echo '**在线安装** (需联网,自动下载 Node.js + OpenClaw)'
echo '```'
echo '# iStoreOS'
echo "sh luci-app-openclaw_${VER}.run"
echo ''
echo '# OpenWrt'
echo "opkg install luci-app-openclaw_${VER}-1_all.ipk"
echo '```'
echo ''
echo '[使用文档](https://github.com/10000ge10000/luci-app-openclaw#readme) · [问题反馈](https://github.com/10000ge10000/luci-app-openclaw/issues) · [B站](https://space.bilibili.com/59438380) · [博客](https://blog.910501.xyz/)'
} > "${OUT_DIR}/${VER}.md"
echo "${VER}.md ($(wc -l < "${OUT_DIR}/${VER}.md") 行)"

View File

@ -0,0 +1,230 @@
#!/bin/sh
# ============================================================================
# OpenList 网盘同步脚本 — 补齐所有历史版本 + 上传更新记录
#
# 功能:
# 1. 从 GitHub Releases 下载所有版本的 .run + .ipk
# 2. 从 CHANGELOG.md 提取每个版本的更新记录,生成 更新记录.txt
# 3. 上传到 OpenList 网盘的 openclaw-在线安装 目录
#
# 用法:
# sh scripts/sync_openlist.sh
# ============================================================================
set -e
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PKG_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
# ── 配置 ──
GITHUB_REPO="10000ge10000/luci-app-openclaw"
OPENLIST_URL="http://124.243.178.237:15244"
OPENLIST_USER="admin"
OPENLIST_PASS="mingmenmama"
OPENLIST_ROOT="/Quark"
UPLOAD_SUBDIR="openclaw-在线安装"
CHANGELOG="$PKG_DIR/CHANGELOG.md"
WORK_DIR="/tmp/openlist-sync"
# 所有已发布的版本 (按时间顺序)
ALL_VERSIONS="1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.0.10 1.0.11 1.0.12 1.0.14 1.0.15"
log_info() { printf " [\033[32m✓\033[0m] %s\n" "$1"; }
log_warn() { printf " [\033[33m!\033[0m] %s\n" "$1"; }
log_error() { printf " [\033[31m✗\033[0m] %s\n" "$1"; }
log_skip() { printf " [\033[36m-\033[0m] %s\n" "$1"; }
# ── 获取 Token ──
get_token() {
local resp
resp=$(curl -s -X POST "${OPENLIST_URL}/api/auth/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"${OPENLIST_USER}\",\"password\":\"${OPENLIST_PASS}\"}")
local token
token=$(echo "$resp" | grep -o '"token":"[^"]*"' | sed 's/"token":"//;s/"//')
if [ -z "$token" ]; then
log_error "OpenList 登录失败"
echo " 响应: $resp"
exit 1
fi
echo "$token"
}
# ── 创建远程目录 ──
create_remote_dir() {
local token="$1"
local remote_path="$2"
curl -s -X POST "${OPENLIST_URL}/api/fs/mkdir" \
-H "Authorization: ${token}" \
-H "Content-Type: application/json" \
-d "{\"path\":\"${remote_path}\"}" >/dev/null 2>&1 || true
}
# ── 检查远程文件是否存在 ──
remote_file_exists() {
local token="$1"
local remote_path="$2"
local filename="$3"
local resp
resp=$(curl -s -X POST "${OPENLIST_URL}/api/fs/list" \
-H "Authorization: ${token}" \
-H "Content-Type: application/json" \
-d "{\"path\":\"${remote_path}\",\"refresh\":false}")
echo "$resp" | grep -q "\"name\":\"${filename}\""
}
# ── 上传单个文件 ──
upload_file() {
local token="$1"
local local_file="$2"
local remote_path="$3"
local filename=$(basename "$local_file")
local fsize=$(du -h "$local_file" | cut -f1)
local resp
resp=$(curl -s -X PUT "${OPENLIST_URL}/api/fs/put" \
-H "Authorization: ${token}" \
-H "File-Path: ${remote_path}/${filename}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${local_file}" \
--max-time 300 2>/dev/null)
local code=""
code=$(echo "$resp" | grep -o '"code":[0-9]*' | grep -o '[0-9]*')
if [ "$code" = "200" ]; then
log_info "${filename} (${fsize}) 上传成功"
else
log_error "${filename} 上传失败: $resp"
fi
}
# ── 从 CHANGELOG.md 提取指定版本的更新日志 ──
extract_changelog() {
local version="$1"
local output_file="$2"
awk "/^## \\[${version}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" \
"$CHANGELOG" > "$output_file"
# 去掉首尾空行
sed -i '/./,$!d' "$output_file" # 去掉开头空行
sed -i ':a; /^[[:space:]]*$/{ $d; N; ba }' "$output_file" # 去掉末尾空行 (GNU sed)
if [ ! -s "$output_file" ]; then
echo "暂无更新日志" > "$output_file"
fi
}
# ── 从 GitHub 下载文件 ──
download_release_file() {
local version="$1"
local filename="$2"
local output="$3"
local url="https://github.com/${GITHUB_REPO}/releases/download/v${version}/${filename}"
if [ -f "$output" ]; then
log_skip "${filename} 已在本地缓存"
return 0
fi
if curl -sL --fail -o "$output" "$url" 2>/dev/null; then
# 检查是否为有效文件 (排除 GitHub 返回 Not Found HTML)
local size=$(wc -c < "$output")
if [ "$size" -lt 1000 ]; then
local content=$(cat "$output")
if echo "$content" | grep -qi "not found"; then
rm -f "$output"
return 1
fi
fi
return 0
else
rm -f "$output"
return 1
fi
}
# ══════════════════════════════════════════════════════════════
# 主流程
# ══════════════════════════════════════════════════════════════
echo ""
echo "================================================================"
echo " OpenList 网盘同步 — 补齐所有历史版本"
echo "================================================================"
echo ""
# 准备工作目录
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
# 登录
echo "正在登录 OpenList..."
TOKEN=$(get_token)
log_info "登录成功"
echo ""
TOTAL_UPLOADED=0
for VER in $ALL_VERSIONS; do
echo "── v${VER} ──────────────────────────────────────"
REMOTE_DIR="${OPENLIST_ROOT}/${UPLOAD_SUBDIR}/v${VER}"
VER_DIR="${WORK_DIR}/v${VER}"
mkdir -p "$VER_DIR"
# 创建远程目录
create_remote_dir "$TOKEN" "$REMOTE_DIR"
# 1) 下载 .run
RUN_FILE="luci-app-openclaw_${VER}.run"
if remote_file_exists "$TOKEN" "$REMOTE_DIR" "$RUN_FILE"; then
log_skip "${RUN_FILE} 已存在于网盘"
else
echo " 下载 ${RUN_FILE}..."
if download_release_file "$VER" "$RUN_FILE" "${VER_DIR}/${RUN_FILE}"; then
upload_file "$TOKEN" "${VER_DIR}/${RUN_FILE}" "$REMOTE_DIR"
TOTAL_UPLOADED=$((TOTAL_UPLOADED + 1))
else
log_error "${RUN_FILE} 下载失败"
fi
fi
# 2) 下载 .ipk
IPK_FILE="luci-app-openclaw_${VER}-1_all.ipk"
if remote_file_exists "$TOKEN" "$REMOTE_DIR" "$IPK_FILE"; then
log_skip "${IPK_FILE} 已存在于网盘"
else
echo " 下载 ${IPK_FILE}..."
if download_release_file "$VER" "$IPK_FILE" "${VER_DIR}/${IPK_FILE}"; then
upload_file "$TOKEN" "${VER_DIR}/${IPK_FILE}" "$REMOTE_DIR"
TOTAL_UPLOADED=$((TOTAL_UPLOADED + 1))
else
log_error "${IPK_FILE} 下载失败"
fi
fi
# 3) 生成并上传 更新记录.txt
CHANGELOG_FILE="更新记录.txt"
if remote_file_exists "$TOKEN" "$REMOTE_DIR" "$CHANGELOG_FILE"; then
log_skip "${CHANGELOG_FILE} 已存在于网盘"
else
extract_changelog "$VER" "${VER_DIR}/${CHANGELOG_FILE}"
upload_file "$TOKEN" "${VER_DIR}/${CHANGELOG_FILE}" "$REMOTE_DIR"
TOTAL_UPLOADED=$((TOTAL_UPLOADED + 1))
fi
echo ""
done
# 清理
rm -rf "$WORK_DIR"
echo "================================================================"
echo " 同步完成!共上传 ${TOTAL_UPLOADED} 个文件"
echo "================================================================"
echo ""

View File

@ -0,0 +1,67 @@
#!/bin/sh
set -e
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PKG_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
DIST_DIR="${1:-$PKG_DIR/dist}"
if [ -z "$OPENLIST_URL" ]; then
echo "错误: 请设置 OPENLIST_URL 环境变量"
exit 1
fi
if [ -z "$OPENLIST_TOKEN" ] && { [ -z "$OPENLIST_USER" ] || [ -z "$OPENLIST_PASS" ]; }; then
echo "错误: 请设置登录凭据"
exit 1
fi
UPLOAD_ROOT="${OPENLIST_PATH:-/}"
PKG_VERSION=$(cat "$PKG_DIR/VERSION" 2>/dev/null | tr -d '[:space:]' || echo "unknown")
UPLOAD_ROOT="${UPLOAD_ROOT%/}"
UPLOAD_SUBDIR="openclaw-在线安装"
OPENLIST_URL="${OPENLIST_URL%/}"
get_token() {
if [ -n "$OPENLIST_TOKEN" ]; then echo "$OPENLIST_TOKEN"; return; fi
local resp=$(curl -s -X POST "${OPENLIST_URL}/api/auth/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"${OPENLIST_USER}\",\"password\":\"${OPENLIST_PASS}\"}")
local token=$(echo "$resp" | grep -o '"token":"[^"]*"' | sed 's/"token":"//;s/"//' | head -n 1)
if [ -z "$token" ]; then exit 1; fi
echo "$token"
}
create_remote_dir() {
curl -s -X POST "${OPENLIST_URL}/api/fs/mkdir" \
-H "Authorization: ${1}" \
-H "Content-Type: application/json" \
-d "{\"path\":\"${2}\"}" >/dev/null 2>&1 || true
}
upload_file() {
local filename=$(basename "$2")
echo " 上传: ${filename} ..."
local resp=$(curl -s -X PUT "${OPENLIST_URL}/api/fs/put" \
-H "Authorization: ${1}" \
-H "File-Path: ${3}/${filename}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${2}" \
--max-time 3600)
local code=$(echo "$resp" | grep -o '"code":[0-9]*' | grep -o '[0-9]*' | head -n 1)
if [ "$code" = "200" ]; then echo " [✓] 上传成功"; else echo " [✗] 失败: $resp"; fi
}
UPLOAD_FILES=$(find "$DIST_DIR" -type f -name "*.run" -o -name "*.ipk" 2>/dev/null)
if [ -z "$UPLOAD_FILES" ]; then echo "错误: 未找到可上传文件"; exit 1; fi
TOKEN=$(get_token)
REMOTE_DIR="${UPLOAD_ROOT}/${UPLOAD_SUBDIR}/v${PKG_VERSION}"
REMOTE_DIR=$(echo "$REMOTE_DIR" | sed 's#^//#/#g')
echo "创建远程目录: ${REMOTE_DIR}"
create_remote_dir "$TOKEN" "$REMOTE_DIR"
for f in $UPLOAD_FILES; do
upload_file "$TOKEN" "$f" "$REMOTE_DIR"
done
echo "✅ 上传完成!"

View File

@ -180,17 +180,15 @@ if has_xray then
o:depends("fragment", true)
o = s_xray:option(Value, "fragment_length", translate("Fragment Length"), translate("Fragmented packet length (byte)"))
o.datatype = "or(uinteger,portrange)"
o.default = "100-200"
o:depends("fragment", true)
o = s_xray:option(Value, "fragment_interval", translate("Fragment Interval"), translate("Fragmentation interval (ms)"))
o.datatype = "or(uinteger,portrange)"
o.default = "10-20"
o:depends("fragment", true)
o = s_xray:option(Value, "fragment_maxSplit", translate("Max Split"), translate("Limit the maximum number of splits."))
o.default = "100-200"
o:depends("fragment", true)
o = s_xray:option(Flag, "noise", translate("Noise"), translate("UDP noise, Under some circumstances it can bypass some UDP based protocol restrictions."))
o.default = 0
@ -244,11 +242,6 @@ if has_xray then
o = s_xray_noise:option(Value, "delay", translate("Delay (ms)"))
o.datatype = "or(uinteger,portrange)"
o.rmempty = false
o = s_xray_noise:option(ListValue, "applyTo", translate("IP Type"))
o:value("ip", "ALL")
o:value("ipv4", "IPv4")
o:value("ipv6", "IPv6")
end
if has_singbox then

View File

@ -14,8 +14,7 @@ local function get_noise_packets()
local noise = (n.enabled == "1") and {
type = n.type,
packet = n.packet,
delay = string.find(n.delay, "-") and n.delay or tonumber(n.delay),
applyTo = n.applyTo
delay = string.find(n.delay, "-") and n.delay or tonumber(n.delay)
} or nil
table.insert(noises, noise)
end)
@ -886,7 +885,7 @@ function gen_config(var)
end
end
if is_new_blc_node then
local outboundTag = gen_outbound_get_tag(flag, blc_node_id, blc_node_tag, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.record_fragment == "1" or nil, run_socks_instance = not no_run })
local outboundTag = gen_outbound_get_tag(flag, blc_node_id, blc_node_tag, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil, run_socks_instance = not no_run })
if outboundTag then
valid_nodes[#valid_nodes + 1] = outboundTag
end
@ -911,7 +910,7 @@ function gen_config(var)
local fallback_node = get_node_by_id(fallback_node_id)
if fallback_node then
if fallback_node.protocol ~= "_balancing" then
local outboundTag = gen_outbound_get_tag(flag, fallback_node, fallback_node_id, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.record_fragment == "1" or nil, run_socks_instance = not no_run })
local outboundTag = gen_outbound_get_tag(flag, fallback_node, fallback_node_id, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil, run_socks_instance = not no_run })
if outboundTag then
fallback_node_tag = outboundTag
end
@ -1639,8 +1638,7 @@ function gen_config(var)
fragment = (xray_settings.fragment == "1") and {
packets = (xray_settings.fragment_packets and xray_settings.fragment_packets ~= "") and xray_settings.fragment_packets,
length = (xray_settings.fragment_length and xray_settings.fragment_length ~= "") and xray_settings.fragment_length,
interval = (xray_settings.fragment_interval and xray_settings.fragment_interval ~= "") and xray_settings.fragment_interval,
maxSplit = (xray_settings.fragment_maxSplit and xray_settings.fragment_maxSplit ~= "") and xray_settings.fragment_maxSplit
interval = (xray_settings.fragment_interval and xray_settings.fragment_interval ~= "") and xray_settings.fragment_interval
} or nil,
noises = (xray_settings.noise == "1") and get_noise_packets() or nil
},

View File

@ -1870,12 +1870,6 @@ msgstr "分片间隔"
msgid "Fragmentation interval (ms)"
msgstr "分片间隔ms"
msgid "Max Split"
msgstr "最大分片数"
msgid "Limit the maximum number of splits."
msgstr "限制分片的最大数量。"
msgid "Split handshake data into multiple TLS records for better censorship evasion. Low overhead. Recommended to enable first."
msgstr "将握手数据拆分为多个 TLS 记录,提升抗封锁能力,几乎不增加延迟,建议优先启用。"
@ -1900,9 +1894,6 @@ msgstr "数据包"
msgid "Delay (ms)"
msgstr "延迟ms"
msgid "IP Type"
msgstr "IP 类型"
msgid "If is domain name, The requested domain name will be resolved to IP before connect."
msgstr "如果是域名,域名将在请求发出之前解析为 IP。"

View File

@ -1775,7 +1775,7 @@ acl_app() {
[ -n "$tcp_node" ] && {
local protocol=$(config_n_get $tcp_node protocol)
[ "$protocol" = "_shunt" ] && [ "$udp_node" != "default" ] && {
udp_node = "tcp"
udp_node="tcp"
}
}
if [ "$udp_node" = "default" ]; then

View File

@ -587,7 +587,7 @@ load_acl() {
[ "$UDP_NO_REDIR_PORTS" != "disable" ] && {
nft "add rule $NFTABLE_NAME PSW_MANGLE ip protocol udp $(factor $UDP_NO_REDIR_PORTS "udp dport") counter return comment \"默认\""
nft "add rule $NFTABLE_NAME PSW_MANGLE_V6 counter meta l4proto udp $(factor $UDP_NO_REDIR_PORTS "udp dport") counter return comment \"默认\""
nft "add rule $NFTABLE_NAME PSW_MANGLE_V6 meta l4proto udp $(factor $UDP_NO_REDIR_PORTS "udp dport") counter return comment \"默认\""
if ! has_1_65535 "$UDP_NO_REDIR_PORTS"; then
echolog " - ${msg}不代理 UDP 端口[${UDP_NO_REDIR_PORTS}]"
else
@ -1201,7 +1201,7 @@ add_firewall_rule() {
[ "${USE_GFW_LIST}" = "1" ] && nft add rule $NFTABLE_NAME PSW_OUTPUT_MANGLE ip protocol udp ip daddr @$NFTSET_GFW $(factor $UDP_PROXY_DROP_PORTS "udp dport") counter drop
[ "${CHN_LIST}" != "0" ] && nft add rule $NFTABLE_NAME PSW_OUTPUT_MANGLE ip protocol udp ip daddr @$NFTSET_CHN $(factor $UDP_PROXY_DROP_PORTS "udp dport") $(get_jump_ipt ${CHN_LIST} "counter drop")
[ "${USE_SHUNT_UDP}" = "1" ] && nft add rule $NFTABLE_NAME PSW_OUTPUT_MANGLE ip protocol udp ip daddr @$NFTSET_SHUNT $(factor $UDP_PROXY_DROP_PORTS "udp dport") counter drop
[ "${LOCALHOST_UDP_PROXY_MODE}" != "disable" ] && nft add rule $NFTABLE_NAME PSW_OUTPUT_MANGLE counter ip protocol udp $(factor $UDP_PROXY_DROP_PORTS "udp dport") counter drop
[ "${LOCALHOST_UDP_PROXY_MODE}" != "disable" ] && nft add rule $NFTABLE_NAME PSW_OUTPUT_MANGLE ip protocol udp $(factor $UDP_PROXY_DROP_PORTS "udp dport") counter drop
echolog " - ${msg}屏蔽代理 UDP 端口[${UDP_PROXY_DROP_PORTS}]"
}
}

View File

@ -338,7 +338,7 @@ eval_set_val() {
eval_unset_val() {
for i in $@; do
for j in $i; do
eval unset j
eval unset $j
done
done
}