mirror of
https://github.com/hotwa/luci-app-openclaw.git
synced 2026-03-30 20:25:44 +00:00
release: v1.0.0 — LuCI 管理界面、一键安装、12+ AI 模型提供商
This commit is contained in:
6
root/etc/config/openclaw
Normal file
6
root/etc/config/openclaw
Normal file
@@ -0,0 +1,6 @@
|
||||
config openclaw 'main'
|
||||
option enabled '0'
|
||||
option port '18789'
|
||||
option bind 'lan'
|
||||
option token ''
|
||||
option pty_port '18793'
|
||||
330
root/etc/init.d/openclaw
Executable file
330
root/etc/init.d/openclaw
Executable file
@@ -0,0 +1,330 @@
|
||||
#!/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"
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
patch_iframe_headers() {
|
||||
# 移除 OpenClaw 网关的 X-Frame-Options 和 frame-ancestors 限制,允许 LuCI iframe 嵌入
|
||||
local gw_js
|
||||
for f in $(find "${OC_GLOBAL}" -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
|
||||
}
|
||||
|
||||
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 "")
|
||||
|
||||
# 确保配置目录和文件存在
|
||||
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||
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 || echo "auto_$(date +%s)")
|
||||
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;
|
||||
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
|
||||
|
||||
# 启动 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_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 5 0
|
||||
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 用于 WebSocket 认证
|
||||
local pty_token
|
||||
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
|
||||
|
||||
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_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 会自动处理进程停止
|
||||
return 0
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "openclaw"
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
stop
|
||||
start
|
||||
}
|
||||
|
||||
setup() {
|
||||
echo "正在调用 openclaw-env setup..."
|
||||
/usr/bin/openclaw-env setup
|
||||
}
|
||||
|
||||
restart_gateway() {
|
||||
# 仅重启 Gateway procd 实例,不影响 Web PTY
|
||||
# 使用 stop+start 而非 kill,可重置 procd crash loop 计数器
|
||||
local port
|
||||
port=$(uci -q get openclaw.main.port || echo "18789")
|
||||
|
||||
# 停止 gateway 实例 (通过 ubus,不影响 PTY 实例)
|
||||
ubus call service stop '{"name":"openclaw","instance":"gateway"}' 2>/dev/null || true
|
||||
|
||||
# 等待端口释放
|
||||
local i=0
|
||||
while [ $i -lt 8 ]; do
|
||||
netstat -tln 2>/dev/null | grep -q ":${port} " || break
|
||||
sleep 1; i=$((i+1))
|
||||
done
|
||||
|
||||
# 重新启动整个服务 (procd 会重建 gateway 实例,同时保留 PTY 实例)
|
||||
# procd 的 start_service 幂等:已运行的实例不会重复启动
|
||||
/etc/init.d/openclaw start >/dev/null 2>&1
|
||||
}
|
||||
|
||||
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 -tlnp 2>/dev/null | grep -q ":${p} "
|
||||
else
|
||||
netstat -tlnp 2>/dev/null | grep -q ":${p} "
|
||||
fi
|
||||
}
|
||||
|
||||
_get_pid_by_port() {
|
||||
local p="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tlnp 2>/dev/null | grep ":${p} " | head -1 | sed -n 's/.*pid=\([0-9]*\).*/\1/p'
|
||||
else
|
||||
netstat -tlnp 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
|
||||
else
|
||||
echo "网关: 已停止"
|
||||
fi
|
||||
|
||||
# PTY port
|
||||
if _check_port "$pty_port"; then
|
||||
echo "Web PTY: 运行中 (端口 $pty_port)"
|
||||
else
|
||||
echo "Web PTY: 已停止"
|
||||
fi
|
||||
}
|
||||
41
root/etc/uci-defaults/99-openclaw
Executable file
41
root/etc/uci-defaults/99-openclaw
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/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
|
||||
|
||||
# 创建数据目录
|
||||
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 || openssl rand -hex 24 2>/dev/null || echo "changeme_$(date +%s)")
|
||||
uci set openclaw.main.token="$TOKEN"
|
||||
uci commit openclaw
|
||||
fi
|
||||
|
||||
exit 0
|
||||
478
root/usr/bin/openclaw-env
Executable file
478
root/usr/bin/openclaw-env
Executable file
@@ -0,0 +1,478 @@
|
||||
#!/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.16.0}"
|
||||
# 经过验证的 OpenClaw 稳定版本 (更新此值需同步测试)
|
||||
OC_TESTED_VERSION="2026.3.1"
|
||||
# 用户可通过 OC_VERSION 环境变量覆盖安装版本
|
||||
OC_VERSION="${OC_VERSION:-}"
|
||||
NODE_BASE="/opt/openclaw/node"
|
||||
OC_GLOBAL="/opt/openclaw/global"
|
||||
OC_DATA="/opt/openclaw/data"
|
||||
NODE_BIN="${NODE_BASE}/bin/node"
|
||||
NPM_BIN="${NODE_BASE}/bin/npm"
|
||||
PNPM_BIN="${OC_GLOBAL}/bin/pnpm"
|
||||
|
||||
# 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"
|
||||
|
||||
export PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:$PATH"
|
||||
|
||||
log_info() { echo " [✓] $1"; }
|
||||
log_warn() { echo " [!] $1"; }
|
||||
log_error() { echo " [✗] $1"; }
|
||||
|
||||
# 检测 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)
|
||||
|
||||
local tarball=""
|
||||
local url="" url_fallback=""
|
||||
|
||||
if [ "$libc_type" = "musl" ]; then
|
||||
tarball="node-v${node_ver}-${node_arch}-musl.tar.xz"
|
||||
url="${NODE_MUSL_MIRROR}/v${node_ver}/${tarball}"
|
||||
url_fallback=""
|
||||
echo ""
|
||||
echo "=== 下载 Node.js v${node_ver} (${node_arch}, musl libc) ==="
|
||||
else
|
||||
tarball="node-v${node_ver}-${node_arch}.tar.xz"
|
||||
url="${NODE_MIRROR}/v${node_ver}/${tarball}"
|
||||
url_fallback="${NODE_MIRROR_CN}/v${node_ver}/${tarball}"
|
||||
echo ""
|
||||
echo "=== 下载 Node.js v${node_ver} (${node_arch}, glibc) ==="
|
||||
fi
|
||||
|
||||
local tmp_file="/tmp/${tarball}"
|
||||
|
||||
# 如果已存在且版本正确, 跳过
|
||||
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
|
||||
log_warn "当前 Node.js v${current_ver}, 将更新到 v${node_ver}"
|
||||
fi
|
||||
|
||||
# 下载 (带重试)
|
||||
local downloaded=0
|
||||
local mirror_list="$url"
|
||||
[ -n "$url_fallback" ] && mirror_list="$url $url_fallback"
|
||||
for mirror_url in $mirror_list; do
|
||||
echo " 正在从 ${mirror_url} 下载..."
|
||||
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
|
||||
downloaded=1
|
||||
break
|
||||
fi
|
||||
log_warn "下载失败, 尝试备用镜像..."
|
||||
done
|
||||
|
||||
if [ "$downloaded" -eq 0 ]; then
|
||||
log_error "所有镜像均下载失败"
|
||||
rm -f "$tmp_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 解压
|
||||
echo " 正在解压到 ${NODE_BASE}..."
|
||||
rm -rf "$NODE_BASE"
|
||||
mkdir -p "$NODE_BASE"
|
||||
tar xf "$tmp_file" -C "$NODE_BASE" --strip-components=1
|
||||
rm -f "$tmp_file"
|
||||
|
||||
# 验证
|
||||
if [ -x "$NODE_BIN" ]; then
|
||||
log_info "Node.js $($NODE_BIN --version) 安装成功"
|
||||
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 到全局目录
|
||||
mkdir -p "$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
|
||||
mkdir -p "$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
|
||||
mkdir -p "$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
|
||||
}
|
||||
|
||||
init_openclaw() {
|
||||
echo ""
|
||||
echo "=== 初始化 OpenClaw ==="
|
||||
|
||||
# 创建数据目录
|
||||
mkdir -p "$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 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
|
||||
}
|
||||
|
||||
# ── 主入口 ──
|
||||
case "${1:-}" in
|
||||
setup)
|
||||
do_setup
|
||||
;;
|
||||
check)
|
||||
do_check
|
||||
;;
|
||||
upgrade)
|
||||
do_upgrade
|
||||
;;
|
||||
node)
|
||||
download_node "$NODE_VERSION"
|
||||
;;
|
||||
*)
|
||||
echo "用法: openclaw-env {setup|check|upgrade|node}"
|
||||
echo ""
|
||||
echo " setup — 完整安装 (下载 Node.js + pnpm + OpenClaw)"
|
||||
echo " check — 检查环境状态"
|
||||
echo " upgrade — 升级 OpenClaw 到最新版"
|
||||
echo " node — 仅下载/更新 Node.js"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
1326
root/usr/share/openclaw/oc-config.sh
Executable file
1326
root/usr/share/openclaw/oc-config.sh
Executable file
File diff suppressed because it is too large
Load Diff
BIN
root/usr/share/openclaw/ui/images/icon_256.png
Normal file
BIN
root/usr/share/openclaw/ui/images/icon_256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
root/usr/share/openclaw/ui/images/icon_64.png
Normal file
BIN
root/usr/share/openclaw/ui/images/icon_64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
280
root/usr/share/openclaw/ui/index.html
Normal file
280
root/usr/share/openclaw/ui/index.html
Normal file
@@ -0,0 +1,280 @@
|
||||
<!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 = 20;
|
||||
let retryTimer = null;
|
||||
let wasEverConnected = false;
|
||||
|
||||
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 (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;
|
||||
wasEverConnected = true;
|
||||
retryCount = 0;
|
||||
loadingEl.classList.add('hidden');
|
||||
reconnectEl.classList.remove('show');
|
||||
statusEl.textContent = '● 已连接';
|
||||
statusEl.className = 'status connected';
|
||||
// 连接后重新 fit,确保尺寸正确
|
||||
setTimeout(doFit, 50);
|
||||
term.focus();
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
ws.onmessage = function(ev) {
|
||||
if (typeof ev.data === 'string') {
|
||||
term.write(ev.data, function() { term.scrollToBottom(); });
|
||||
} else if (ev.data instanceof Blob) {
|
||||
ev.data.text().then(function(text) { term.write(text, function() { term.scrollToBottom(); }); });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function(ev) {
|
||||
clearTimeout(connectTimeout);
|
||||
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>
|
||||
8
root/usr/share/openclaw/ui/lib/addon-fit.min.js
vendored
Normal file
8
root/usr/share/openclaw/ui/lib/addon-fit.min.js
vendored
Normal 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
|
||||
8
root/usr/share/openclaw/ui/lib/addon-web-links.min.js
vendored
Normal file
8
root/usr/share/openclaw/ui/lib/addon-web-links.min.js
vendored
Normal 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
|
||||
8
root/usr/share/openclaw/ui/lib/xterm.min.css
vendored
Normal file
8
root/usr/share/openclaw/ui/lib/xterm.min.css
vendored
Normal 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 */
|
||||
8
root/usr/share/openclaw/ui/lib/xterm.min.js
vendored
Normal file
8
root/usr/share/openclaw/ui/lib/xterm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
285
root/usr/share/openclaw/web-pty.js
Normal file
285
root/usr/share/openclaw/web-pty.js
Normal file
@@ -0,0 +1,285 @@
|
||||
#!/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.luci_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;
|
||||
activeSessions++;
|
||||
console.log(`[oc-config] Session created (active: ${activeSessions}/${MAX_SESSIONS})`);
|
||||
this._setupWSReader();
|
||||
this._spawnPty();
|
||||
}
|
||||
|
||||
_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));
|
||||
}
|
||||
});
|
||||
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){} }
|
||||
}
|
||||
} 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`,
|
||||
};
|
||||
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 });
|
||||
|
||||
this.proc.stdout.on('data', (d) => { if (this.alive) this.socket.write(encodeWSFrame(d, 0x01)); });
|
||||
this.proc.stderr.on('data', (d) => { if (this.alive) this.socket.write(encodeWSFrame(d, 0x01)); });
|
||||
this.proc.on('close', (code) => {
|
||||
if (!this.alive) return;
|
||||
console.log(`[oc-config] Script exited with code ${code}, auto-restarting...`);
|
||||
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) => {
|
||||
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;
|
||||
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
|
||||
if (AUTH_TOKEN) {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const clientToken = url.searchParams.get('token') || '';
|
||||
if (clientToken !== AUTH_TOKEN) {
|
||||
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);
|
||||
|
||||
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)) {
|
||||
const 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); });
|
||||
process.on('SIGTERM', () => { console.log('[oc-config] Shutdown'); httpServer.close(); process.exit(0); });
|
||||
process.on('SIGINT', () => { httpServer.close(); process.exit(0); });
|
||||
Reference in New Issue
Block a user