mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 03:35:13 +08:00
💋 Sync 2026-10-04 04:16:09
This commit is contained in:
1 parent
f6b2df73ec
commit
f62992fd80
745 files changed
+212416
-1173
No files matched your search
Executable
+447
@@ -0,0 +1,447 @@
|
||||
#!/bin/sh
|
||||
# ============================================================================
|
||||
# hermes-env — Hermes Agent environment installation management script
|
||||
# Usage: hermes-env setup | check | upgrade
|
||||
# Environment variables: HERMES_VERSION — specify version to install
|
||||
# ============================================================================
|
||||
|
||||
# ── Version and path constants ─────────────────────────────────────────────
|
||||
HERMES_BASE="/opt/hermes"
|
||||
HERMES_VENV="${HERMES_BASE}/venv"
|
||||
HERMES_DATA="${HERMES_BASE}/data"
|
||||
HERMES_BIN="${HERMES_VENV}/bin/hermes"
|
||||
PYTHON_BIN="${HERMES_VENV}/bin/python3"
|
||||
HERMES_CONFIG="/root/.hermes/config.yaml"
|
||||
|
||||
# PyPI mirrors
|
||||
HERMES_PYPI_CN="https://mirrors.aliyun.com/pypi/simple/"
|
||||
HERMES_PYPI_OFFICIAL="https://pypi.org/simple/"
|
||||
HERMES_PYPI_TSINGHUA="https://pypi.tuna.tsinghua.edu.cn/simple/"
|
||||
|
||||
# Default API endpoint
|
||||
HERMES_DEFAULT_API="https://api.boos.lat/v1"
|
||||
HERMES_DEFAULT_MODEL="opus4.6"
|
||||
|
||||
export PATH="${HERMES_VENV}/bin:$PATH"
|
||||
|
||||
# ── Logging tools ──────────────────────────────────────────────────────────
|
||||
log_ok() { echo " [✓] $*"; }
|
||||
log_warn() { echo " [!] $*"; }
|
||||
log_err() { echo " [✗] $*"; }
|
||||
log_step() { echo ""; echo "=== $* ==="; }
|
||||
|
||||
# ── OverlayFS fix (iStoreOS Docker /opt conflict) ─────────────────────────
|
||||
_fix_opt() {
|
||||
mkdir -p /opt/hermes/.probe 2>/dev/null && { rmdir /opt/hermes/.probe 2>/dev/null; return 0; }
|
||||
[ -d /overlay/upper/opt ] && mkdir -p /overlay/upper/opt/hermes 2>/dev/null && \
|
||||
mount --bind /overlay/upper/opt /opt 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
_fix_opt
|
||||
|
||||
# ── Basic tools ────────────────────────────────────────────────────────────
|
||||
ensure_dir() { [ -d "$1" ] && return 0; mkdir -p "$1" 2>/dev/null || { log_err "Cannot create directory: $1"; return 1; }; }
|
||||
|
||||
# Check available disk space
|
||||
_df_avail() {
|
||||
local p="$1"
|
||||
while [ -n "$p" ] && [ "$p" != "/" ]; do
|
||||
[ -e "$p" ] && break
|
||||
p="${p%/*}"
|
||||
done
|
||||
[ -z "$p" ] && p="/"
|
||||
df -m "$p" 2>/dev/null | tail -1 | awk '{print $(NF-2)}'
|
||||
}
|
||||
|
||||
check_space() {
|
||||
local path="$1" need_mb="$2" label="$3"
|
||||
local avail
|
||||
avail=$(_df_avail "$path")
|
||||
[ -z "$avail" ] && return 0
|
||||
if [ "$avail" -lt "$need_mb" ]; then
|
||||
log_warn "${label} available space insufficient: ${avail}MB < ${need_mb}MB recommended"
|
||||
else
|
||||
log_ok "${label} available space: ${avail}MB"
|
||||
fi
|
||||
}
|
||||
|
||||
# Download: wget first, curl fallback
|
||||
_download() {
|
||||
local url="$1" out="$2"
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q --no-check-certificate -O "$out" "$url" 2>/dev/null
|
||||
elif command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL --connect-timeout 15 --max-time 120 -o "$out" "$url" 2>/dev/null
|
||||
else
|
||||
log_err "No download tool available (wget/curl)"; return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Try multiple mirrors, return first success
|
||||
_download_first() {
|
||||
local out="$1"; shift
|
||||
for url in "$@"; do
|
||||
[ -z "$url" ] && continue
|
||||
if _download "$url" "$out"; then
|
||||
local sz; sz=$(wc -c < "$out" 2>/dev/null || echo 0)
|
||||
[ "$sz" -gt 1000 ] && return 0
|
||||
fi
|
||||
rm -f "$out"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Architecture detection ─────────────────────────────────────────────────
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64) echo "x86_64" ;;
|
||||
aarch64) echo "aarch64" ;;
|
||||
armv7l) echo "armv7l" ;;
|
||||
armv6l) echo "armv6l" ;;
|
||||
*) log_err "Unsupported architecture: $(uname -m)"; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_libc() {
|
||||
[ -f /etc/openwrt_release ] && echo "musl" && return
|
||||
for f in /lib/ld-musl-*.so.1; do [ -f "$f" ] && echo "musl" && return; done
|
||||
echo "glibc"
|
||||
}
|
||||
|
||||
# ── Install Python 3 ──────────────────────────────────────────────────────
|
||||
install_python() {
|
||||
# Check if system python3 is available
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
local ver
|
||||
ver=$(python3 --version 2>/dev/null | awk '{print $2}')
|
||||
log_ok "System Python3 v${ver} found"
|
||||
# Check if it's recent enough (3.8+)
|
||||
local major minor
|
||||
major=$(echo "$ver" | cut -d. -f1)
|
||||
minor=$(echo "$ver" | cut -d. -f2)
|
||||
if [ "$major" -ge 3 ] && [ "$minor" -ge 8 ] 2>/dev/null; then
|
||||
log_ok "Python3 v${ver} meets requirements (>= 3.8)"
|
||||
return 0
|
||||
fi
|
||||
log_warn "Python3 v${ver} is too old, need >= 3.8"
|
||||
fi
|
||||
|
||||
log_step "Installing Python 3"
|
||||
|
||||
# Try opkg first
|
||||
opkg update >/dev/null 2>&1 || true
|
||||
opkg install python3 python3-pip python3-venv 2>/dev/null | tail -5 || true
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
log_ok "Python3 installed via opkg: $(python3 --version 2>/dev/null)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Fallback: try to install minimal python3
|
||||
opkg install python3-base python3-light 2>/dev/null | tail -5 || true
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
log_ok "Python3 (minimal) installed: $(python3 --version 2>/dev/null)"
|
||||
# Also need pip
|
||||
opkg install python3-pip 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_err "Python3 installation failed"
|
||||
log_err "Please install manually: opkg install python3 python3-pip python3-venv"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Create virtual environment ─────────────────────────────────────────────
|
||||
create_venv() {
|
||||
log_step "Creating Python virtual environment"
|
||||
|
||||
ensure_dir "$HERMES_BASE" || exit 1
|
||||
|
||||
# Remove old venv if exists
|
||||
if [ -d "$HERMES_VENV" ]; then
|
||||
log_warn "Removing old virtual environment..."
|
||||
rm -rf "$HERMES_VENV"
|
||||
fi
|
||||
|
||||
# Create venv using system python3
|
||||
local sys_python
|
||||
sys_python=$(command -v python3 2>/dev/null)
|
||||
if [ -z "$sys_python" ]; then
|
||||
log_err "python3 not found in PATH"
|
||||
return 1
|
||||
fi
|
||||
|
||||
"$sys_python" -m venv "$HERMES_VENV" 2>/dev/null
|
||||
if [ ! -x "$PYTHON_BIN" ]; then
|
||||
# Try without -m venv (older python)
|
||||
log_warn "python3 -m venv failed, trying virtualenv..."
|
||||
pip3 install virtualenv 2>/dev/null || true
|
||||
"$sys_python" -m virtualenv "$HERMES_VENV" 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ ! -x "$PYTHON_BIN" ]; then
|
||||
log_err "Failed to create virtual environment"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_ok "Virtual environment created at $HERMES_VENV"
|
||||
|
||||
# Upgrade pip
|
||||
log_ok "Upgrading pip..."
|
||||
"$PYTHON_BIN" -m pip install --upgrade pip 2>/dev/null | tail -2 || true
|
||||
|
||||
log_ok "Python: $("$PYTHON_BIN" --version 2>/dev/null)"
|
||||
}
|
||||
|
||||
# ── Install Hermes Agent ───────────────────────────────────────────────────
|
||||
install_hermes() {
|
||||
log_step "Installing Hermes Agent"
|
||||
|
||||
# Check if already installed
|
||||
if [ -x "$HERMES_BIN" ]; then
|
||||
local cur
|
||||
cur=$("$HERMES_BIN" --version 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
log_ok "Hermes Agent v${cur} already installed"
|
||||
echo " To reinstall: hermes-env setup --force"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_dir "$HERMES_VENV" || return 1
|
||||
|
||||
# Try PyPI mirrors in order
|
||||
local install_ok=false
|
||||
|
||||
for mirror in "$HERMES_PYPI_CN" "$HERMES_PYPI_TSINGHUA" "$HERMES_PYPI_OFFICIAL"; do
|
||||
log_warn "Trying: ${mirror}"
|
||||
if "$PYTHON_BIN" -m pip install hermes-agent \
|
||||
-i "$mirror" \
|
||||
--trusted-host "$(echo "$mirror" | sed 's|https://||;s|/.*||')" \
|
||||
2>&1 | tail -10; then
|
||||
install_ok=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$install_ok" = "false" ] || [ ! -x "$HERMES_BIN" ]; then
|
||||
# Try without mirror flags
|
||||
log_warn "Mirror install failed, trying default PyPI..."
|
||||
"$PYTHON_BIN" -m pip install hermes-agent 2>&1 | tail -10 || true
|
||||
[ -x "$HERMES_BIN" ] && install_ok=true
|
||||
fi
|
||||
|
||||
if [ "$install_ok" = "false" ]; then
|
||||
log_err "Hermes Agent installation failed"
|
||||
log_err "Please check network connection and try again"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_ok "Hermes Agent installed: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
|
||||
}
|
||||
|
||||
# ── Initialize Hermes configuration ────────────────────────────────────────
|
||||
init_hermes() {
|
||||
log_step "Initializing Hermes configuration"
|
||||
|
||||
mkdir -p /root/.hermes 2>/dev/null
|
||||
|
||||
# Generate auth token
|
||||
local token
|
||||
token=$(uci -q get hermes.main.token 2>/dev/null || echo "")
|
||||
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 "hermes_$(date +%s)")
|
||||
uci set hermes.main.token="$token" 2>/dev/null || true
|
||||
uci commit hermes 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Generate PTY token
|
||||
local pty_token
|
||||
pty_token=$(uci -q get hermes.main.pty_token 2>/dev/null || 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 hermes.main.pty_token="$pty_token" 2>/dev/null || true
|
||||
uci commit hermes 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Write default config.yaml
|
||||
local port bind api_endpoint api_key model
|
||||
port=$(uci -q get hermes.main.port || echo "3000")
|
||||
bind=$(uci -q get hermes.main.bind || echo "lan")
|
||||
api_endpoint=$(uci -q get hermes.main.api_endpoint || echo "$HERMES_DEFAULT_API")
|
||||
api_key=$(uci -q get hermes.main.api_key || echo "")
|
||||
model=$(uci -q get hermes.main.model || echo "$HERMES_DEFAULT_MODEL")
|
||||
|
||||
local listen_host="0.0.0.0"
|
||||
[ "$bind" = "loopback" ] && listen_host="127.0.0.1"
|
||||
|
||||
cat > /root/.hermes/config.yaml << YAMLEOF
|
||||
# Hermes Agent configuration
|
||||
# Initialized by luci-app-hermes on $(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
gateway:
|
||||
port: ${port}
|
||||
bind: ${listen_host}
|
||||
auth:
|
||||
mode: token
|
||||
token: ${token}
|
||||
|
||||
api:
|
||||
endpoint: "${api_endpoint}"
|
||||
key: "${api_key}"
|
||||
|
||||
model:
|
||||
primary: "${model}"
|
||||
|
||||
tools:
|
||||
profile: coding
|
||||
|
||||
update:
|
||||
checkOnStart: false
|
||||
YAMLEOF
|
||||
|
||||
chmod 600 /root/.hermes/config.yaml 2>/dev/null || true
|
||||
log_ok "Configuration written to /root/.hermes/config.yaml"
|
||||
}
|
||||
|
||||
# ── Command: setup ─────────────────────────────────────────────────────────
|
||||
cmd_setup() {
|
||||
check_space /opt 100 "Install directory (/opt)"
|
||||
check_space /tmp 100 "Temp directory (/tmp)"
|
||||
|
||||
# Already installed?
|
||||
if [ -x "$HERMES_BIN" ]; then
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ [!] Hermes Agent already installed ║"
|
||||
echo "║ To reinstall, run: hermes-env setup --force ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo " Hermes: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Hermes Agent Environment Setup ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo " Architecture: $(uname -m)"
|
||||
echo " Libc: $(detect_libc)"
|
||||
echo ""
|
||||
|
||||
install_python || exit 1
|
||||
create_venv || exit 1
|
||||
install_hermes || exit 1
|
||||
init_hermes || exit 1
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Setup Complete! ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo " Hermes: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
|
||||
echo " Python: $("$PYTHON_BIN" --version 2>/dev/null)"
|
||||
echo " Config: /root/.hermes/config.yaml"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Edit /etc/config/hermes to set your API key"
|
||||
echo " 2. Run: /etc/init.d/hermes enable"
|
||||
echo " 3. Run: /etc/init.d/hermes start"
|
||||
echo ""
|
||||
echo " Or use the LuCI web interface to configure and start."
|
||||
}
|
||||
|
||||
# ── Command: check ─────────────────────────────────────────────────────────
|
||||
cmd_check() {
|
||||
echo "=== Hermes Environment Check ==="
|
||||
|
||||
if [ -x "$PYTHON_BIN" ]; then
|
||||
echo "Python: $("$PYTHON_BIN" --version 2>/dev/null)"
|
||||
else
|
||||
echo "Python: Not installed (virtual env)"
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
echo "System Py: $(python3 --version 2>/dev/null)"
|
||||
else
|
||||
echo "System Py: Not found"
|
||||
fi
|
||||
|
||||
if [ -x "$HERMES_BIN" ]; then
|
||||
echo "Hermes: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
|
||||
else
|
||||
echo "Hermes: Not installed"
|
||||
fi
|
||||
|
||||
echo "VENV: $HERMES_VENV"
|
||||
echo "Config: $HERMES_CONFIG"
|
||||
|
||||
if [ -f "$HERMES_CONFIG" ]; then
|
||||
echo "Config OK: Yes"
|
||||
else
|
||||
echo "Config OK: No (will be created on start)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Command: upgrade ───────────────────────────────────────────────────────
|
||||
cmd_upgrade() {
|
||||
if [ ! -x "$HERMES_BIN" ]; then
|
||||
log_err "Hermes Agent not installed. Run: hermes-env setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_step "Upgrading Hermes Agent"
|
||||
|
||||
local upgrade_ok=false
|
||||
|
||||
for mirror in "$HERMES_PYPI_CN" "$HERMES_PYPI_TSINGHUA" "$HERMES_PYPI_OFFICIAL"; do
|
||||
log_warn "Trying: ${mirror}"
|
||||
if "$PYTHON_BIN" -m pip install --upgrade hermes-agent \
|
||||
-i "$mirror" \
|
||||
--trusted-host "$(echo "$mirror" | sed 's|https://||;s|/.*||')" \
|
||||
2>&1 | tail -10; then
|
||||
upgrade_ok=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$upgrade_ok" = "false" ]; then
|
||||
log_warn "Mirror install failed, trying default PyPI..."
|
||||
"$PYTHON_BIN" -m pip install --upgrade hermes-agent 2>&1 | tail -10 || true
|
||||
[ -x "$HERMES_BIN" ] && upgrade_ok=true
|
||||
fi
|
||||
|
||||
if [ "$upgrade_ok" = "true" ] && [ -x "$HERMES_BIN" ]; then
|
||||
log_ok "Hermes upgraded: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
|
||||
else
|
||||
log_err "Upgrade failed"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Command: uninstall ─────────────────────────────────────────────────────
|
||||
cmd_uninstall() {
|
||||
log_step "Uninstalling Hermes Agent"
|
||||
|
||||
/etc/init.d/hermes stop 2>/dev/null || true
|
||||
/etc/init.d/hermes disable 2>/dev/null || true
|
||||
uci set hermes.main.enabled=0 2>/dev/null || true
|
||||
uci commit hermes 2>/dev/null || true
|
||||
|
||||
[ -d "$HERMES_BASE" ] && { rm -rf "$HERMES_BASE" 2>/dev/null || true; }
|
||||
[ -d "/root/.hermes" ] && { rm -rf "/root/.hermes" 2>/dev/null || true; }
|
||||
|
||||
log_ok "Hermes Agent uninstalled"
|
||||
}
|
||||
|
||||
# ── Main entry ─────────────────────────────────────────────────────────────
|
||||
case "${1:-setup}" in
|
||||
setup) cmd_setup ;;
|
||||
check) cmd_check ;;
|
||||
upgrade) cmd_upgrade ;;
|
||||
uninstall) cmd_uninstall ;;
|
||||
*)
|
||||
echo "Usage: hermes-env {setup|check|upgrade|uninstall}"
|
||||
echo ""
|
||||
echo " setup Install Python + Hermes Agent (default)"
|
||||
echo " check Check installation status"
|
||||
echo " upgrade Upgrade Hermes Agent to latest version"
|
||||
echo " uninstall Remove all Hermes files"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
ACTION=${1}
|
||||
shift 1
|
||||
|
||||
do_install() {
|
||||
local port=$(uci get hermes.@main[0].port 2>/dev/null | tr -d '\n\r')
|
||||
local data=$(uci get hermes.@main[0].data_path 2>/dev/null | tr -d '\n\r')
|
||||
local workspace=$(uci get hermes.@main[0].workspace_path 2>/dev/null | tr -d '\n\r')
|
||||
|
||||
local image_name="linkease/hermes:latest"
|
||||
echo "docker pull ${image_name}"
|
||||
docker pull ${image_name}
|
||||
docker rm -f hermes
|
||||
|
||||
[ -z "$port" ] && port=8787
|
||||
|
||||
local cmd="docker run --restart=unless-stopped -d"
|
||||
|
||||
[ -n "$data" ] && cmd="$cmd -v \"$data:/data/.hermes\""
|
||||
[ -n "$workspace" ] && cmd="$cmd -v \"$workspace:/workspace\""
|
||||
|
||||
cmd="$cmd -p $port:8787"
|
||||
|
||||
local tz=$(uci get system.@system[0].zonename 2>/dev/null | sed 's/ /_/g')
|
||||
[ -z "$tz" ] || cmd="$cmd -e TZ=$tz"
|
||||
|
||||
cmd="$cmd --name hermes \"$image_name\""
|
||||
|
||||
echo "$cmd"
|
||||
eval "$cmd"
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 sub-command"
|
||||
echo "where sub-command is one of:"
|
||||
echo " install Install the hermes"
|
||||
echo " upgrade Upgrade the hermes"
|
||||
echo " rm/start/stop/restart Remove/Start/Stop/Restart the hermes"
|
||||
echo " status Hermes status"
|
||||
echo " port Hermes port"
|
||||
}
|
||||
|
||||
case ${ACTION} in
|
||||
"install")
|
||||
do_install
|
||||
;;
|
||||
"upgrade")
|
||||
do_install
|
||||
;;
|
||||
"rm")
|
||||
docker rm -f hermes
|
||||
;;
|
||||
"start" | "stop" | "restart")
|
||||
docker ${ACTION} hermes
|
||||
;;
|
||||
"status")
|
||||
docker ps --all -f 'name=^/hermes$' --format '{{.State}}'
|
||||
;;
|
||||
"port")
|
||||
docker ps --all -f 'name=^/hermes$' --format '{{.Ports}}' | grep -om1 '0.0.0.0:[0-9]*->8787/tcp' | sed 's/0.0.0.0:\([0-9]*\)->.*/\1/'
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
#!/bin/sh
|
||||
# luci-app-hermes — Backend helper for JS view
|
||||
# Called via ubus file.exec from the LuCI frontend
|
||||
|
||||
. /lib/functions.sh 2>/dev/null || true
|
||||
|
||||
HERMES_VENV="/opt/hermes/venv"
|
||||
HERMES_BIN="${HERMES_VENV}/bin/hermes"
|
||||
HERMES_DATA="/opt/hermes/data"
|
||||
HERMES_CONFIG="/root/.hermes/config.yaml"
|
||||
|
||||
# ── JSON helper: escape string for JSON output ──
|
||||
json_esc() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g' | tr '\n' ' '
|
||||
}
|
||||
|
||||
# ── Port listening check (ss or netstat) ──
|
||||
port_listening() {
|
||||
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
|
||||
}
|
||||
|
||||
pid_by_port() {
|
||||
local p="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep ":${p} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep ":${p} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1
|
||||
fi
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# STATUS — gather all status info as JSON
|
||||
# ═══════════════════════════════════════════
|
||||
status)
|
||||
enabled=$(uci -q get hermes.main.enabled || echo "0")
|
||||
port=$(uci -q get hermes.main.port || echo "3000")
|
||||
pty_port=$(uci -q get hermes.main.pty_port || echo "3001")
|
||||
|
||||
# Validate ports
|
||||
case "$port" in *[!0-9]*) port="3000" ;; esac
|
||||
case "$pty_port" in *[!0-9]*) pty_port="3001" ;; esac
|
||||
|
||||
# Gateway check
|
||||
gw_running=false
|
||||
gw_starting=false
|
||||
port_listening "$port" && gw_running=true
|
||||
if [ "$gw_running" = "false" ] && [ "$enabled" = "1" ]; then
|
||||
pgrep -f 'hermes.*gateway' >/dev/null 2>&1 && gw_starting=true
|
||||
fi
|
||||
|
||||
# PTY check
|
||||
pty_running=false
|
||||
port_listening "$pty_port" && pty_running=true
|
||||
|
||||
# Python version
|
||||
python_ver=""
|
||||
if [ -x "${HERMES_VENV}/bin/python3" ]; then
|
||||
python_ver=$("${HERMES_VENV}/bin/python3" --version 2>/dev/null | tr -d '[:space:]')
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
python_ver=$(python3 --version 2>/dev/null | tr -d '[:space:]')
|
||||
fi
|
||||
|
||||
# Hermes version
|
||||
hermes_ver=""
|
||||
if [ -x "$HERMES_BIN" ]; then
|
||||
hermes_ver=$("$HERMES_BIN" --version 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
fi
|
||||
|
||||
# Plugin version
|
||||
plugin_ver=""
|
||||
[ -f "/usr/share/hermes/VERSION" ] && plugin_ver=$(cat /usr/share/hermes/VERSION 2>/dev/null | tr -d '[:space:]')
|
||||
|
||||
# PID, memory, uptime
|
||||
pid="" mem_kb=0 uptime_str=""
|
||||
if [ "$gw_running" = "true" ]; then
|
||||
pid=$(pid_by_port "$port")
|
||||
if [ -n "$pid" ] && [ -d "/proc/$pid" ]; then
|
||||
mem_kb=$(awk '/VmRSS/{print $2}' "/proc/$pid/status" 2>/dev/null)
|
||||
[ -z "$mem_kb" ] && mem_kb=0
|
||||
# Calculate uptime from /proc/$pid directory mtime
|
||||
start_ts=$(date -r "/proc/$pid" +%s 2>/dev/null)
|
||||
if [ -n "$start_ts" ]; then
|
||||
now=$(date +%s)
|
||||
diff=$((now - start_ts))
|
||||
h=$((diff / 3600)) m=$(((diff % 3600) / 60)) s=$((diff % 60))
|
||||
if [ $h -gt 0 ]; then uptime_str="${h}h ${m}m"
|
||||
elif [ $m -gt 0 ]; then uptime_str="${m}m ${s}s"
|
||||
else uptime_str="${s}s"; fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Active model (from config.yaml)
|
||||
active_model=""
|
||||
if [ -f "$HERMES_CONFIG" ]; then
|
||||
active_model=$(sed -n 's/.*primary: *"\{0,1\}\([^"]*\)"\{0,1\}/\1/p' "$HERMES_CONFIG" 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
cat <<JSON
|
||||
{
|
||||
"enabled": "$enabled",
|
||||
"port": "$port",
|
||||
"pty_port": "$pty_port",
|
||||
"gateway_running": $gw_running,
|
||||
"gateway_starting": $gw_starting,
|
||||
"pty_running": $pty_running,
|
||||
"python_version": "$python_ver",
|
||||
"hermes_version": "$hermes_ver",
|
||||
"plugin_version": "$plugin_ver",
|
||||
"pid": "$pid",
|
||||
"memory_kb": ${mem_kb:-0},
|
||||
"uptime": "$uptime_str",
|
||||
"active_model": "$(json_esc "$active_model")"
|
||||
}
|
||||
JSON
|
||||
;;
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# SETUP — install Python + Hermes Agent
|
||||
# ═══════════════════════════════════════════
|
||||
setup)
|
||||
version="${2:-stable}"
|
||||
# Clean old state
|
||||
: > /tmp/hermes-setup.log
|
||||
: > /tmp/hermes-setup.exit
|
||||
: > /tmp/hermes-setup.pid
|
||||
|
||||
( /usr/bin/hermes-env setup > /tmp/hermes-setup.log 2>&1
|
||||
RC=$?
|
||||
echo $RC > /tmp/hermes-setup.exit
|
||||
if [ $RC -eq 0 ]; then
|
||||
uci set hermes.main.enabled=1
|
||||
uci commit hermes
|
||||
/etc/init.d/hermes enable 2>/dev/null
|
||||
sleep 1
|
||||
/etc/init.d/hermes start >> /tmp/hermes-setup.log 2>&1
|
||||
fi
|
||||
) &
|
||||
echo $! > /tmp/hermes-setup.pid
|
||||
|
||||
echo '{"status":"ok","message":"setup started"}'
|
||||
;;
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# SETUP_LOG — poll setup progress
|
||||
# ═══════════════════════════════════════════
|
||||
setup_log)
|
||||
log=""
|
||||
[ -f /tmp/hermes-setup.log ] && log=$(cat /tmp/hermes-setup.log 2>/dev/null)
|
||||
|
||||
running=false
|
||||
if [ -f /tmp/hermes-setup.pid ]; then
|
||||
pid=$(cat /tmp/hermes-setup.pid 2>/dev/null | tr -d '[:space:]')
|
||||
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && running=true
|
||||
fi
|
||||
|
||||
exit_code=-1
|
||||
if [ "$running" = "false" ] && [ -f /tmp/hermes-setup.exit ]; then
|
||||
exit_code=$(cat /tmp/hermes-setup.exit 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$exit_code" ] && exit_code=-1
|
||||
fi
|
||||
|
||||
state="idle"
|
||||
if [ "$running" = "true" ]; then state="running"
|
||||
elif [ "$exit_code" = "0" ]; then state="success"
|
||||
elif [ "$exit_code" -gt 0 ] 2>/dev/null; then state="failed"
|
||||
fi
|
||||
|
||||
# Escape log for JSON
|
||||
log_escaped=$(printf '%s' "$log" | sed 's/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g' | tr '\n' '\\' | sed 's/\\/\\n/g')
|
||||
|
||||
printf '{"state":"%s","exit_code":%s,"log":"%s"}\n' "$state" "${exit_code}" "$log_escaped"
|
||||
;;
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# GET_TOKEN — return gateway and PTY tokens
|
||||
# ═══════════════════════════════════════════
|
||||
get_token)
|
||||
token=$(uci -q get hermes.main.token || echo "")
|
||||
pty_token=$(uci -q get hermes.main.pty_token || echo "")
|
||||
printf '{"token":"%s","pty_token":"%s"}\n' "$token" "$pty_token"
|
||||
;;
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# UNINSTALL — remove environment
|
||||
# ═══════════════════════════════════════════
|
||||
uninstall)
|
||||
/etc/init.d/hermes stop >/dev/null 2>&1
|
||||
/etc/init.d/hermes disable 2>/dev/null
|
||||
uci set hermes.main.enabled=0
|
||||
uci commit hermes 2>/dev/null
|
||||
[ -d /opt/hermes ] && { find /opt/hermes -delete 2>/dev/null || true; }
|
||||
[ -d /root/.hermes ] && { find /root/.hermes -delete 2>/dev/null || true; }
|
||||
: > /tmp/hermes-setup.log 2>/dev/null
|
||||
: > /tmp/hermes-setup.pid 2>/dev/null
|
||||
: > /tmp/hermes-setup.exit 2>/dev/null
|
||||
echo '{"status":"ok","message":"uninstalled"}'
|
||||
;;
|
||||
|
||||
*)
|
||||
echo '{"status":"error","message":"unknown command"}'
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,278 @@
|
||||
<!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>Hermes Agent Terminal</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{display:none}
|
||||
#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">🤖 Hermes Agent Terminal</span>
|
||||
<span id="status" class="status disconnected">● Connecting...</span>
|
||||
<button class="btn" onclick="location.reload()" title="Restart terminal">🔄 Restart</button>
|
||||
</div>
|
||||
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<h2>🤖 Hermes Agent Terminal</h2>
|
||||
<p id="loading-msg">Connecting to terminal...</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">⚡ Connection Lost</p>
|
||||
<p style="font-size:13px;color:#565f89">Terminal session ended or connection interrupted</p>
|
||||
<button onclick="location.reload()">🔄 Restart</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: 13,
|
||||
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) {}
|
||||
|
||||
term.open(containerEl);
|
||||
|
||||
containerEl.addEventListener('click', function() { term.focus(); });
|
||||
document.addEventListener('click', function() { term.focus(); });
|
||||
|
||||
function doFit() {
|
||||
try { fitAddon.fit(); } catch(e) {}
|
||||
try { term.scrollToBottom(); } catch(e) {}
|
||||
}
|
||||
doFit();
|
||||
window.addEventListener('resize', () => { setTimeout(doFit, 100); });
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const ptyToken = urlParams.get('token') || 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;
|
||||
|
||||
var _readyToConnect = false;
|
||||
function _tryConnect() {
|
||||
if (_readyToConnect) return;
|
||||
try { fitAddon.fit(); } catch(e) {}
|
||||
if (term.cols > 40) {
|
||||
_readyToConnect = true;
|
||||
connect();
|
||||
}
|
||||
}
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
new ResizeObserver(function() {
|
||||
setTimeout(function() { doFit(); _tryConnect(); }, 50);
|
||||
}).observe(containerEl);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
||||
if (ws) { try { ws.close(); } catch(e){} ws = null; }
|
||||
|
||||
retryCount++;
|
||||
loadingMsg.textContent = retryCount > 1
|
||||
? 'Reconnecting... (attempt ' + retryCount + ')'
|
||||
: 'Connecting to terminal...';
|
||||
loadingDebug.textContent = wsUrl;
|
||||
|
||||
fetch('/health').then(function(r) {
|
||||
return r.json();
|
||||
}).then(function(data) {
|
||||
doWebSocket();
|
||||
}).catch(function(err) {
|
||||
scheduleRetry();
|
||||
});
|
||||
}
|
||||
|
||||
function doWebSocket() {
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch(e) {
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
var connectTimeout = setTimeout(function() {
|
||||
if (ws && ws.readyState !== WebSocket.OPEN) {
|
||||
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 = '● Connected';
|
||||
statusEl.className = 'status connected';
|
||||
if (isReconnect) {
|
||||
term.clear();
|
||||
term.write('\x1b[33m⚡ Reconnected\x1b[0m\r\n\r\n');
|
||||
}
|
||||
setTimeout(doFit, 50);
|
||||
term.focus();
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
} catch(e) {}
|
||||
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') {
|
||||
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 = '● Disconnected';
|
||||
statusEl.className = 'status disconnected';
|
||||
if (wasEverConnected) {
|
||||
statusEl.textContent = '● Reconnecting...';
|
||||
retryCount = 0;
|
||||
setTimeout(function() { connect(); }, 2000);
|
||||
} else {
|
||||
scheduleRetry();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function(ev) {
|
||||
clearTimeout(connectTimeout);
|
||||
connected = false;
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
var delay = Math.min(Math.floor(retryCount / 2) + 1, 5) * 1000;
|
||||
loadingMsg.textContent = 'Waiting for service... retrying in ' + Math.ceil(delay/1000) + 's';
|
||||
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 }));
|
||||
}
|
||||
});
|
||||
|
||||
function _onLoad() {
|
||||
doFit();
|
||||
_tryConnect();
|
||||
}
|
||||
if (document.readyState === 'complete') {
|
||||
_onLoad();
|
||||
} else {
|
||||
window.addEventListener('load', _onLoad);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+549
@@ -0,0 +1,549 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
luci-app-hermes — Web PTY Server (Python implementation)
|
||||
Provides WebSocket-based terminal access for Hermes Agent configuration.
|
||||
Replaces the Node.js web-pty.js from luci-app-openclaw.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import socketserver
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import struct
|
||||
import hashlib
|
||||
import base64
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import select
|
||||
import pty
|
||||
import fcntl
|
||||
import termios
|
||||
import urllib.parse
|
||||
|
||||
# ── Configuration ──
|
||||
PORT = int(os.environ.get('HERMES_PTY_PORT', '3001'))
|
||||
HOST = os.environ.get('HERMES_PTY_HOST', '0.0.0.0')
|
||||
AUTH_TOKEN = os.environ.get('HERMES_PTY_TOKEN', '')
|
||||
HERMES_VENV = os.environ.get('HERMES_VENV', '/opt/hermes/venv')
|
||||
HERMES_DATA = os.environ.get('HERMES_DATA', '/opt/hermes/data')
|
||||
MAX_SESSIONS = int(os.environ.get('HERMES_MAX_SESSIONS', '5'))
|
||||
UI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ui')
|
||||
|
||||
# Load token from UCI if not set via env
|
||||
if not AUTH_TOKEN:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['uci', '-q', 'get', 'hermes.main.pty_token'],
|
||||
capture_output=True, text=True, timeout=3
|
||||
)
|
||||
AUTH_TOKEN = result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── WebSocket helpers (RFC 6455) ──
|
||||
|
||||
def decode_ws_frame(data):
|
||||
"""Decode a WebSocket frame. Returns (opcode, payload, total_consumed) or None."""
|
||||
if len(data) < 2:
|
||||
return None
|
||||
opcode = data[0] & 0x0f
|
||||
masked = bool(data[1] & 0x80)
|
||||
payload_len = data[1] & 0x7f
|
||||
offset = 2
|
||||
|
||||
if payload_len == 126:
|
||||
if len(data) < 4:
|
||||
return None
|
||||
payload_len = struct.unpack('>H', data[2:4])[0]
|
||||
offset = 4
|
||||
elif payload_len == 127:
|
||||
if len(data) < 10:
|
||||
return None
|
||||
payload_len = struct.unpack('>Q', data[2:10])[0]
|
||||
offset = 10
|
||||
|
||||
mask = None
|
||||
if masked:
|
||||
if len(data) < offset + 4:
|
||||
return None
|
||||
mask = data[offset:offset + 4]
|
||||
offset += 4
|
||||
|
||||
if len(data) < offset + payload_len:
|
||||
return None
|
||||
|
||||
payload = bytearray(data[offset:offset + payload_len])
|
||||
if mask:
|
||||
for i in range(len(payload)):
|
||||
payload[i] ^= mask[i & 3]
|
||||
|
||||
return (opcode, bytes(payload), offset + payload_len)
|
||||
|
||||
|
||||
def encode_ws_frame(data, opcode=0x01):
|
||||
"""Encode data into a WebSocket frame."""
|
||||
if isinstance(data, str):
|
||||
payload = data.encode('utf-8')
|
||||
else:
|
||||
payload = data
|
||||
length = len(payload)
|
||||
|
||||
if length < 126:
|
||||
header = struct.pack('>BB', 0x80 | opcode, length)
|
||||
elif length < 65536:
|
||||
header = struct.pack('>BBH', 0x80 | opcode, 126, length)
|
||||
else:
|
||||
header = struct.pack('>BBQ', 0x80 | opcode, 127, length)
|
||||
|
||||
return header + payload
|
||||
|
||||
|
||||
# ── PTY Session ──
|
||||
|
||||
active_sessions = 0
|
||||
|
||||
|
||||
class PtySession:
|
||||
def __init__(self, request_handler):
|
||||
global active_sessions
|
||||
self.handler = request_handler
|
||||
self.proc = None
|
||||
self.cols = 80
|
||||
self.rows = 24
|
||||
self.alive = True
|
||||
self.buffer = b''
|
||||
self._spawn_fail_count = 0
|
||||
self._MAX_SPAWN_RETRIES = 5
|
||||
self._ping_timer = None
|
||||
self._pong_received = True
|
||||
|
||||
active_sessions += 1
|
||||
print(f"[hermes-pty] Session created (active: {active_sessions}/{MAX_SESSIONS})", flush=True)
|
||||
|
||||
self._spawn_pty()
|
||||
|
||||
def _spawn_pty(self):
|
||||
"""Spawn the shell process with a PTY."""
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
'TERM': 'xterm-256color',
|
||||
'COLUMNS': str(self.cols),
|
||||
'LINES': str(self.rows),
|
||||
'COLORTERM': 'truecolor',
|
||||
'LANG': 'en_US.UTF-8',
|
||||
'HERMES_VENV': HERMES_VENV,
|
||||
'HERMES_DATA': HERMES_DATA,
|
||||
'HOME': '/root',
|
||||
'PATH': f"{HERMES_VENV}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
})
|
||||
|
||||
try:
|
||||
# Create PTY
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
# Set terminal size
|
||||
winsize = struct.pack('HHHH', self.rows, self.cols, 0, 0)
|
||||
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
# Spawn shell
|
||||
self.proc = subprocess.Popen(
|
||||
['/bin/sh', '-l'],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
env=env,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
self.master_fd = master_fd
|
||||
self._spawn_fail_count = 0
|
||||
|
||||
# Set master_fd to non-blocking
|
||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
# Start reader thread
|
||||
self._reader_thread = threading.Thread(target=self._read_pty, daemon=True)
|
||||
self._reader_thread.start()
|
||||
|
||||
print(f"[hermes-pty] Shell spawned (PID: {self.proc.pid})", flush=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[hermes-pty] Failed to spawn shell: {e}", flush=True)
|
||||
self._cleanup()
|
||||
|
||||
def _read_pty(self):
|
||||
"""Read from PTY and send to WebSocket."""
|
||||
while self.alive and self.proc and self.proc.poll() is None:
|
||||
try:
|
||||
r, _, _ = select.select([self.master_fd], [], [], 0.1)
|
||||
if r:
|
||||
data = os.read(self.master_fd, 4096)
|
||||
if data:
|
||||
self._send_to_client(data)
|
||||
except (OSError, IOError):
|
||||
break
|
||||
except Exception as e:
|
||||
if self.alive:
|
||||
print(f"[hermes-pty] Read error: {e}", flush=True)
|
||||
break
|
||||
|
||||
if self.alive:
|
||||
print(f"[hermes-pty] Shell exited", flush=True)
|
||||
self._spawn_fail_count += 1
|
||||
if self._spawn_fail_count > self._MAX_SPAWN_RETRIES:
|
||||
print(f"[hermes-pty] Shell failed {self._spawn_fail_count} times, stopping", flush=True)
|
||||
self._send_to_client(b'\r\n\x1b[31mShell failed to start after multiple attempts.\x1b[0m\r\n')
|
||||
return
|
||||
|
||||
# Auto-restart
|
||||
self._send_to_client(b'\r\n\x1b[33mShell exited, restarting...\x1b[0m\r\n')
|
||||
time.sleep(1)
|
||||
if self.alive:
|
||||
self._spawn_pty()
|
||||
|
||||
def _send_to_client(self, data):
|
||||
"""Send data to WebSocket client."""
|
||||
if not self.alive:
|
||||
return
|
||||
try:
|
||||
frame = encode_ws_frame(data, 0x01)
|
||||
self.handler.request.sendall(frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def handle_message(self, text):
|
||||
"""Handle incoming WebSocket text message."""
|
||||
try:
|
||||
msg = json.loads(text)
|
||||
msg_type = msg.get('type', '')
|
||||
|
||||
if msg_type == 'stdin':
|
||||
data = msg.get('data', '')
|
||||
# Clean bracketed paste escape sequences
|
||||
data = data.replace('\x1b[?2004l', '').replace('\x1b[?2004h', '')
|
||||
data = data.replace('\x1b[200~', '').replace('\x1b[201~', '')
|
||||
if self.proc and self.proc.poll() is None:
|
||||
os.write(self.master_fd, data.encode('utf-8'))
|
||||
|
||||
elif msg_type == 'resize':
|
||||
self.cols = msg.get('cols', 80)
|
||||
self.rows = msg.get('rows', 24)
|
||||
if self.master_fd:
|
||||
winsize = struct.pack('HHHH', self.rows, self.cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif msg_type == 'ping':
|
||||
self._send_to_client(json.dumps({'type': 'pong'}))
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Plain text, send directly to PTY
|
||||
if self.proc and self.proc.poll() is None:
|
||||
os.write(self.master_fd, text.encode('utf-8'))
|
||||
|
||||
def handle_binary(self, data):
|
||||
"""Handle incoming binary WebSocket data."""
|
||||
if self.proc and self.proc.poll() is None:
|
||||
os.write(self.master_fd, data)
|
||||
|
||||
def _cleanup(self):
|
||||
"""Clean up session resources."""
|
||||
if not self.alive:
|
||||
return
|
||||
self.alive = False
|
||||
|
||||
global active_sessions
|
||||
active_sessions = max(0, active_sessions - 1)
|
||||
print(f"[hermes-pty] Session ended (active: {active_sessions}/{MAX_SESSIONS})", flush=True)
|
||||
|
||||
if self.proc and self.proc.poll() is None:
|
||||
try:
|
||||
os.kill(-self.proc.pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if hasattr(self, 'master_fd'):
|
||||
os.close(self.master_fd)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __del__(self):
|
||||
self._cleanup()
|
||||
|
||||
|
||||
# ── WebSocket Upgrade Handler ──
|
||||
|
||||
class WebSocketHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""HTTP + WebSocket upgrade handler."""
|
||||
|
||||
sessions = []
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Override to add prefix."""
|
||||
print(f"[hermes-pty] {args[0] if args else format}", flush=True)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', '*')
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
|
||||
if path == '/health':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({
|
||||
'status': 'ok',
|
||||
'port': PORT,
|
||||
'uptime': time.time()
|
||||
}).encode())
|
||||
return
|
||||
|
||||
# Serve static files from UI_DIR
|
||||
if path == '/' or path == '':
|
||||
path = '/index.html'
|
||||
|
||||
# Security: prevent directory traversal
|
||||
full_path = os.path.normpath(os.path.join(UI_DIR, path.lstrip('/')))
|
||||
if not full_path.startswith(UI_DIR):
|
||||
self.send_response(403)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
try:
|
||||
with open(full_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
# Determine content type
|
||||
ext = os.path.splitext(full_path)[1]
|
||||
content_types = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.json': 'application/json',
|
||||
}
|
||||
ct = content_types.get(ext, 'application/octet-stream')
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', ct)
|
||||
self.send_header('Cache-Control', 'no-cache' if ext == '.html' else 'max-age=3600')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('X-Frame-Options', 'ALLOWALL')
|
||||
self.send_header('Content-Security-Policy',
|
||||
"default-src * 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss:; frame-ancestors *")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
except FileNotFoundError:
|
||||
# Fallback to index.html for SPA routing
|
||||
try:
|
||||
with open(os.path.join(UI_DIR, 'index.html'), 'rb') as f:
|
||||
data = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except FileNotFoundError:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_GET_ws(self):
|
||||
"""Handle WebSocket upgrade for GET requests to /ws."""
|
||||
self._do_websocket_upgrade()
|
||||
|
||||
def _do_websocket_upgrade(self):
|
||||
"""Perform WebSocket handshake and start PTY session."""
|
||||
# Authenticate
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
client_token = params.get('token', [''])[0]
|
||||
|
||||
if AUTH_TOKEN and client_token != AUTH_TOKEN:
|
||||
print(f"[hermes-pty] Auth failed from {self.client_address[0]}", flush=True)
|
||||
self.send_error(403, 'Forbidden')
|
||||
return
|
||||
|
||||
# Check session limit
|
||||
if active_sessions >= MAX_SESSIONS:
|
||||
print(f"[hermes-pty] Max sessions reached ({active_sessions}/{MAX_SESSIONS})", flush=True)
|
||||
self.send_error(503, 'Service Unavailable')
|
||||
return
|
||||
|
||||
# WebSocket handshake
|
||||
key = self.headers.get('Sec-WebSocket-Key', '')
|
||||
if not key:
|
||||
self.send_error(400, 'Missing Sec-WebSocket-Key')
|
||||
return
|
||||
|
||||
accept = base64.b64encode(
|
||||
hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()
|
||||
).decode()
|
||||
|
||||
response = (
|
||||
'HTTP/1.1 101 Switching Protocols\r\n'
|
||||
'Upgrade: websocket\r\n'
|
||||
'Connection: Upgrade\r\n'
|
||||
f'Sec-WebSocket-Accept: {accept}\r\n'
|
||||
'\r\n'
|
||||
)
|
||||
self.wfile.write(response.encode())
|
||||
self.wfile.flush()
|
||||
|
||||
# Create PTY session
|
||||
session = PtySession(self)
|
||||
self.__class__.sessions.append(session)
|
||||
|
||||
# Read WebSocket frames
|
||||
buffer = b''
|
||||
while session.alive:
|
||||
try:
|
||||
self.request.setblocking(True)
|
||||
self.request.settimeout(1.0)
|
||||
data = self.request.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
buffer += data
|
||||
|
||||
while len(buffer) > 0:
|
||||
result = decode_ws_frame(buffer)
|
||||
if result is None:
|
||||
break
|
||||
opcode, payload, consumed = result
|
||||
buffer = buffer[consumed:]
|
||||
|
||||
if opcode == 0x01: # Text
|
||||
session.handle_message(payload.decode('utf-8', errors='replace'))
|
||||
elif opcode == 0x02: # Binary
|
||||
session.handle_binary(payload)
|
||||
elif opcode == 0x08: # Close
|
||||
print("[hermes-pty] WS close frame received", flush=True)
|
||||
session._cleanup()
|
||||
return
|
||||
elif opcode == 0x09: # Ping
|
||||
self.request.sendall(encode_ws_frame(payload, 0x0a))
|
||||
elif opcode == 0x0a: # Pong
|
||||
session._pong_received = True
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except (ConnectionResetError, BrokenPipeError, OSError):
|
||||
break
|
||||
except Exception as e:
|
||||
if session.alive:
|
||||
print(f"[hermes-pty] Read error: {e}", flush=True)
|
||||
break
|
||||
|
||||
session._cleanup()
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path
|
||||
|
||||
if path == '/ws' or path.startswith('/ws?'):
|
||||
self._do_websocket_upgrade()
|
||||
else:
|
||||
# Serve static files
|
||||
if path == '/' or path == '':
|
||||
path = '/index.html'
|
||||
|
||||
full_path = os.path.normpath(os.path.join(UI_DIR, path.lstrip('/')))
|
||||
if not full_path.startswith(UI_DIR):
|
||||
self.send_response(403)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
try:
|
||||
with open(full_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
ext = os.path.splitext(full_path)[1]
|
||||
content_types = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.json': 'application/json',
|
||||
}
|
||||
ct = content_types.get(ext, 'application/octet-stream')
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', ct)
|
||||
self.send_header('Cache-Control', 'no-cache' if ext == '.html' else 'max-age=3600')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
with open(os.path.join(UI_DIR, 'index.html'), 'rb') as f:
|
||||
data = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except FileNotFoundError:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
|
||||
# ── Main ──
|
||||
|
||||
if __name__ == '__main__':
|
||||
import socket
|
||||
|
||||
socketserver.TCPServer.allow_reuse_address = True
|
||||
|
||||
class ReusableTCPServer(socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
def handle_request_noblock(self):
|
||||
try:
|
||||
request, client_address = self.get_request()
|
||||
except OSError:
|
||||
return
|
||||
if self.verify_request(request, client_address):
|
||||
try:
|
||||
self.process_request(request, client_address)
|
||||
except Exception:
|
||||
self.handle_error(request, client_address)
|
||||
self.shutdown_request(request)
|
||||
else:
|
||||
self.shutdown_request(request)
|
||||
|
||||
server = ReusableTCPServer((HOST, PORT), WebSocketHandler)
|
||||
print(f"[hermes-pty] HTTP listening on {HOST}:{PORT}", flush=True)
|
||||
print(f"[hermes-pty] UI directory: {UI_DIR}", flush=True)
|
||||
|
||||
def shutdown(signum, frame):
|
||||
print("[hermes-pty] Shutdown", flush=True)
|
||||
server.shutdown()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
shutdown(None, None)
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"admin/services/hermes": {
|
||||
"title": "Hermes Agent",
|
||||
"order": 90,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "hermes"
|
||||
},
|
||||
"depends": {
|
||||
"acl": ["luci-app-hermes"],
|
||||
"uci": { "hermes": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,29 @@
|
||||
{
|
||||
"luci-app-hermes": {
|
||||
"description": "Grant UCI access for luci-app-hermes",
|
||||
"description": "Grant access for luci-app-hermes",
|
||||
"read": {
|
||||
"uci": [ "hermes" ]
|
||||
"uci": ["hermes"],
|
||||
"ubus": {
|
||||
"service": ["list"],
|
||||
"file": ["exec", "read", "stat"]
|
||||
},
|
||||
"file": {
|
||||
"/usr/share/hermes/luci-helper": ["exec"],
|
||||
"/etc/init.d/hermes": ["exec"],
|
||||
"/usr/share/hermes/VERSION": ["read"],
|
||||
"/tmp/hermes-setup.log": ["read"],
|
||||
"/tmp/hermes-setup.exit": ["read"]
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
"uci": [ "hermes" ]
|
||||
"uci": ["hermes"],
|
||||
"ubus": {
|
||||
"file": ["exec", "write"]
|
||||
},
|
||||
"file": {
|
||||
"/usr/share/hermes/luci-helper": ["exec"],
|
||||
"/etc/init.d/hermes": ["exec"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user