mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 19:45:13 +08:00
✨ Sync 2026-03-14 19:52:12
This commit is contained in:
1 parent
364f734d16
commit
2d75925beb
43 files changed
+7870
-35
No files matched your search
@@ -0,0 +1,6 @@
|
||||
config openclaw 'main'
|
||||
option enabled '0'
|
||||
option port '18789'
|
||||
option bind 'lan'
|
||||
option token ''
|
||||
option pty_port '18793'
|
||||
Executable
+487
@@ -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 追踪的是主进程 PID,kill 它才能触发 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
|
||||
}
|
||||
+59
@@ -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
|
||||
Executable
+652
@@ -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
|
||||
+2186
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 |
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/xterm@5.5.0/lib/xterm.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shiftLine truncated
|
||||
//# sourceMappingURL=xterm.js.map
|
||||
@@ -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);
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"luci-app-openclaw": {
|
||||
"description": "Grant UCI access for luci-app-openclaw",
|
||||
"read": {
|
||||
"uci": [ "openclaw" ]
|
||||
},
|
||||
"write": {
|
||||
"uci": [ "openclaw" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user