mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-12 01:37:52 +08:00
550 lines
18 KiB
Python
Executable File
550 lines
18 KiB
Python
Executable File
#!/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)
|