network-infra-utility 0.8.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/tool/webssh/README.md +42 -0
- data/tool/webssh/app.py +788 -0
- data/tool/webssh/cli.bat +17 -0
- data/tool/webssh/config.yml.template +84 -0
- data/tool/webssh/extension//350/256/276/345/244/207/350/257/212/346/226/255.md +103 -0
- data/tool/webssh/requirements.txt +7 -0
- data/tool/webssh/ssh_cli.py +449 -0
- data/tool/webssh/ssh_worker.py +782 -0
- data/tool/webssh/static/vendor/xterm-addon-fit.min.js +8 -0
- data/tool/webssh/static/vendor/xterm.css +209 -0
- data/tool/webssh/static/vendor/xterm.min.js +8 -0
- data/tool/webssh/templates/index.html +2674 -0
- data/tool/webssh/web.bat +41 -0
- data/version.rb +1 -1
- metadata +14 -1
data/tool/webssh/cli.bat
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
cd /d "%~dp0"
|
|
3
|
+
|
|
4
|
+
REM ---- CLI SSH terminal, independent from the web UI ----
|
|
5
|
+
REM Direct mode : own paramiko connection (no web / worker involved)
|
|
6
|
+
REM Attach mode : attach to an existing worker session
|
|
7
|
+
REM
|
|
8
|
+
REM Usage:
|
|
9
|
+
REM cli.bat auto-detect port, direct connect
|
|
10
|
+
REM cli.bat --port 4983 direct connect to a specific port
|
|
11
|
+
REM cli.bat --list list live worker sessions
|
|
12
|
+
REM cli.bat --attach SID attach to a worker session
|
|
13
|
+
|
|
14
|
+
set PY=C:\Python314\python.exe
|
|
15
|
+
if not exist "%PY%" set PY=python
|
|
16
|
+
|
|
17
|
+
"%PY%" ssh_cli.py %*
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# ===================================================================
|
|
2
|
+
# WebSSH Configuration #
|
|
3
|
+
# ===================================================================
|
|
4
|
+
|
|
5
|
+
ssh:
|
|
6
|
+
# SSH login username
|
|
7
|
+
username: "your_name"
|
|
8
|
+
# SSH login password
|
|
9
|
+
password: "your_password"
|
|
10
|
+
# SSH target host
|
|
11
|
+
host: 127.0.0.1
|
|
12
|
+
|
|
13
|
+
# Auto-detect: find listening port from process name(s).
|
|
14
|
+
# Supports a single name (string) or a list of names.
|
|
15
|
+
# Leave empty or [] to use manual port below.
|
|
16
|
+
process_name: bh_am_pfe_tunnel
|
|
17
|
+
# Example for multiple processes:
|
|
18
|
+
# process_name:
|
|
19
|
+
# - windterm
|
|
20
|
+
# - plink
|
|
21
|
+
|
|
22
|
+
# Manual port (used when process_name is empty or detection fails)
|
|
23
|
+
# Set to null for auto-detect only
|
|
24
|
+
port: null
|
|
25
|
+
|
|
26
|
+
# SSH keepalive interval in seconds (0 = disable, passive mode).
|
|
27
|
+
keepalive_interval: 0
|
|
28
|
+
|
|
29
|
+
web:
|
|
30
|
+
# Web server listen address (127.0.0.1 = localhost only)
|
|
31
|
+
host: 127.0.0.1
|
|
32
|
+
# Web server port
|
|
33
|
+
port: 9999
|
|
34
|
+
|
|
35
|
+
terminal:
|
|
36
|
+
# Scrollback buffer size in rows (number of lines kept in memory
|
|
37
|
+
# for scrolling back). Set to 0 for unlimited (uses more memory
|
|
38
|
+
# during long sessions).
|
|
39
|
+
scrollback: 65535
|
|
40
|
+
|
|
41
|
+
logs:
|
|
42
|
+
# Session log directory (relative to app.py)
|
|
43
|
+
dir: logs
|
|
44
|
+
# Auto-save session log when disconnected
|
|
45
|
+
auto_save: true
|
|
46
|
+
# Per-step interaction log written by the worker process itself
|
|
47
|
+
# (connect lifecycle, every input/output with escapes visible,
|
|
48
|
+
# resize, detach/attach, disconnect reason).
|
|
49
|
+
# Written to logs/interactive_<ts>_<sid>.log
|
|
50
|
+
interactive: true
|
|
51
|
+
# Verbose paramiko debug trace (kex/cipher negotiation, channel
|
|
52
|
+
# events). Written to logs/paramiko_<sid>.log. Set false to disable.
|
|
53
|
+
trace: true
|
|
54
|
+
|
|
55
|
+
# -------------------------------------------------------------------
|
|
56
|
+
# Toolbar groups: quick-send commands to the active terminal session.
|
|
57
|
+
# Each group is a tree node; each tool is a button under the group.
|
|
58
|
+
# Clicking a tool sends its script to the current session.
|
|
59
|
+
# -------------------------------------------------------------------
|
|
60
|
+
toolbar:
|
|
61
|
+
# Delay between sending each line of a multi-line script (seconds).
|
|
62
|
+
# 0 = send all lines at once (may overwhelm slow devices).
|
|
63
|
+
line_delay: 0.05
|
|
64
|
+
#
|
|
65
|
+
groups:
|
|
66
|
+
- name: 性能
|
|
67
|
+
tools:
|
|
68
|
+
- name: 资源实占
|
|
69
|
+
command: top
|
|
70
|
+
|
|
71
|
+
- name: 诊断
|
|
72
|
+
tools:
|
|
73
|
+
- name: 查进程
|
|
74
|
+
command: ps -ef
|
|
75
|
+
- name: 查网络
|
|
76
|
+
command: netstat -tlnp
|
|
77
|
+
- name: 查内存
|
|
78
|
+
command: free -h
|
|
79
|
+
- name: 查硬盘
|
|
80
|
+
command: df -h
|
|
81
|
+
#
|
|
82
|
+
# OR
|
|
83
|
+
#
|
|
84
|
+
# toolbar: {}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# 设备诊断工具集
|
|
2
|
+
|
|
3
|
+
常用网络设备诊断命令集合,点击按钮直接发送到当前终端会话。
|
|
4
|
+
|
|
5
|
+
## 辅助功能
|
|
6
|
+
|
|
7
|
+
### 全量显示
|
|
8
|
+
|
|
9
|
+
批量命令时不设分页,完全显示所有命令
|
|
10
|
+
|
|
11
|
+
```shell
|
|
12
|
+
screen-length 0 temporary
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 基础查看
|
|
16
|
+
|
|
17
|
+
### 查看版本
|
|
18
|
+
|
|
19
|
+
查看设备软件版本信息
|
|
20
|
+
|
|
21
|
+
```shell
|
|
22
|
+
display version
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 查看当前配置
|
|
26
|
+
|
|
27
|
+
显示当前运行的完整配置
|
|
28
|
+
|
|
29
|
+
```shell
|
|
30
|
+
display current-configuration
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 查看接口状态
|
|
34
|
+
|
|
35
|
+
```shell
|
|
36
|
+
display interface brief
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 网络诊断
|
|
40
|
+
|
|
41
|
+
### Ping 测试
|
|
42
|
+
|
|
43
|
+
向目标地址发送 5 个 ICMP 包
|
|
44
|
+
|
|
45
|
+
```shell
|
|
46
|
+
ping -c 5 192.168.1.1
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 查看路由表
|
|
50
|
+
|
|
51
|
+
```shell
|
|
52
|
+
display ip routing-table
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 查看ARP表
|
|
56
|
+
|
|
57
|
+
```shell
|
|
58
|
+
display arp
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 性能监控
|
|
62
|
+
|
|
63
|
+
### CPU 使用率
|
|
64
|
+
|
|
65
|
+
查看设备 CPU 使用情况
|
|
66
|
+
|
|
67
|
+
```shell
|
|
68
|
+
display cpu-usage
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 内存使用
|
|
72
|
+
|
|
73
|
+
```shell
|
|
74
|
+
display memory
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 接口流量统计
|
|
78
|
+
|
|
79
|
+
```shell
|
|
80
|
+
display interface counters rate
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 系统管理
|
|
84
|
+
|
|
85
|
+
### 查看告警
|
|
86
|
+
|
|
87
|
+
```shell
|
|
88
|
+
display alarm active
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 保存配置
|
|
92
|
+
|
|
93
|
+
```shell
|
|
94
|
+
save force
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 查看日志
|
|
98
|
+
|
|
99
|
+
显示最近的系统日志信息
|
|
100
|
+
|
|
101
|
+
```shell
|
|
102
|
+
display logbuffer
|
|
103
|
+
```
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ssh_cli.py - Command-line SSH client, independent from the web UI.
|
|
4
|
+
|
|
5
|
+
Two access modes:
|
|
6
|
+
|
|
7
|
+
direct (default) opens its OWN paramiko SSH connection - no Flask,
|
|
8
|
+
no worker process, no browser involved. Use this to
|
|
9
|
+
compare against the web terminal and tell whether a
|
|
10
|
+
problem lives in the SSH layer or the web display
|
|
11
|
+
layer.
|
|
12
|
+
|
|
13
|
+
attach attaches to an EXISTING detached worker session
|
|
14
|
+
(same sessions the web page shows). Use this to test
|
|
15
|
+
the worker<->client path without the browser.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python ssh_cli.py auto-detect port, direct connect
|
|
19
|
+
python ssh_cli.py --port 4983 direct connect to a specific port
|
|
20
|
+
python ssh_cli.py --list list live worker sessions
|
|
21
|
+
python ssh_cli.py --attach <sid> attach to a worker session
|
|
22
|
+
|
|
23
|
+
Keys:
|
|
24
|
+
Ctrl+Q quit locally (session unaffected in attach mode)
|
|
25
|
+
Ctrl+C forwarded to the remote shell as SIGINT, like real ssh
|
|
26
|
+
Ctrl+D forwarded to the remote shell (EOF)
|
|
27
|
+
|
|
28
|
+
Interaction logging (same format as the worker, logs/interactive_*.log):
|
|
29
|
+
every connect step, every keystroke sent, every output chunk received
|
|
30
|
+
(escape sequences visible, e.g. the busybox DSR query shows as \\x1b[6n).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import shutil
|
|
37
|
+
import sys
|
|
38
|
+
import threading
|
|
39
|
+
import time
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
BASE_DIR = Path(__file__).parent.resolve()
|
|
43
|
+
if str(BASE_DIR) not in sys.path:
|
|
44
|
+
sys.path.insert(0, str(BASE_DIR))
|
|
45
|
+
|
|
46
|
+
import paramiko
|
|
47
|
+
|
|
48
|
+
from ssh_worker import (
|
|
49
|
+
InteractionLog,
|
|
50
|
+
load_logs_config,
|
|
51
|
+
load_ssh_config,
|
|
52
|
+
make_transport_factory,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# reuse the exact same detection / session helpers as the web app
|
|
56
|
+
from app import detect_process_port, send_ctrl, worker_connect, worker_contexts
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------ #
|
|
59
|
+
# Console setup (Windows VT sequences + UTF-8 output)
|
|
60
|
+
# ------------------------------------------------------------------ #
|
|
61
|
+
IS_WINDOWS = os.name == "nt"
|
|
62
|
+
if IS_WINDOWS:
|
|
63
|
+
import ctypes
|
|
64
|
+
import msvcrt
|
|
65
|
+
|
|
66
|
+
def enable_vt():
|
|
67
|
+
"""Enable ANSI/VT escape processing in the Windows console."""
|
|
68
|
+
try:
|
|
69
|
+
kernel32 = ctypes.windll.kernel32
|
|
70
|
+
h = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
|
71
|
+
mode = ctypes.c_uint32()
|
|
72
|
+
if kernel32.GetConsoleMode(h, ctypes.byref(mode)):
|
|
73
|
+
kernel32.SetConsoleMode(h, mode.value | 0x0004)
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
else:
|
|
78
|
+
import termios
|
|
79
|
+
import tty
|
|
80
|
+
|
|
81
|
+
def enable_vt():
|
|
82
|
+
pass # POSIX terminals handle VT natively
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def setup_stdout():
|
|
86
|
+
enable_vt()
|
|
87
|
+
try:
|
|
88
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------ #
|
|
94
|
+
# Keyboard input (non-blocking, Windows msvcrt / POSIX raw tty)
|
|
95
|
+
# ------------------------------------------------------------------ #
|
|
96
|
+
# Windows console extended key codes -> terminal escape sequences
|
|
97
|
+
WIN_EXT_KEYS = {
|
|
98
|
+
"H": "\x1b[A", # Up
|
|
99
|
+
"P": "\x1b[B", # Down
|
|
100
|
+
"K": "\x1b[D", # Left
|
|
101
|
+
"M": "\x1b[C", # Right
|
|
102
|
+
"G": "\x1b[H", # Home
|
|
103
|
+
"O": "\x1b[F", # End
|
|
104
|
+
"I": "\x1b[5~", # PageUp
|
|
105
|
+
"Q": "\x1b[6~", # PageDown
|
|
106
|
+
"R": "\x1b[2~", # Insert
|
|
107
|
+
"S": "\x1b[3~", # Delete
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
QUIT_KEY = "\x11" # Ctrl+Q
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def read_key():
|
|
114
|
+
"""Return the next key as a terminal-ready string, or None."""
|
|
115
|
+
if IS_WINDOWS:
|
|
116
|
+
if msvcrt.kbhit():
|
|
117
|
+
c = msvcrt.getwch()
|
|
118
|
+
if c in ("\x00", "\xe0"):
|
|
119
|
+
c2 = msvcrt.getwch()
|
|
120
|
+
return WIN_EXT_KEYS.get(c2, "")
|
|
121
|
+
return c
|
|
122
|
+
return None
|
|
123
|
+
else:
|
|
124
|
+
import select
|
|
125
|
+
|
|
126
|
+
r, _, _ = select.select([sys.stdin], [], [], 0)
|
|
127
|
+
if r:
|
|
128
|
+
return os.read(sys.stdin.fileno(), 1).decode("utf-8", errors="replace")
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ------------------------------------------------------------------ #
|
|
133
|
+
# Shared helpers
|
|
134
|
+
# ------------------------------------------------------------------ #
|
|
135
|
+
def write_out(text):
|
|
136
|
+
try:
|
|
137
|
+
sys.stdout.write(text)
|
|
138
|
+
sys.stdout.flush()
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def console_size():
|
|
144
|
+
size = shutil.get_terminal_size(fallback=(80, 24))
|
|
145
|
+
return size.columns, size.lines
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def pick_port():
|
|
149
|
+
"""Auto-detect the SSH port using the same logic as the web app.
|
|
150
|
+
Supports multiple process names (string or list in config)."""
|
|
151
|
+
cfg = load_ssh_config()
|
|
152
|
+
raw = cfg.get("process_name", "")
|
|
153
|
+
if isinstance(raw, list):
|
|
154
|
+
names = [str(n) for n in raw if n]
|
|
155
|
+
elif raw:
|
|
156
|
+
names = [str(raw)]
|
|
157
|
+
else:
|
|
158
|
+
names = []
|
|
159
|
+
|
|
160
|
+
ports = []
|
|
161
|
+
for name in names:
|
|
162
|
+
results = detect_process_port(name)
|
|
163
|
+
for proc in results:
|
|
164
|
+
ports.extend(p["port"] for p in proc.get("ports", []))
|
|
165
|
+
if not ports:
|
|
166
|
+
return None
|
|
167
|
+
if len(ports) > 1:
|
|
168
|
+
write_out(
|
|
169
|
+
f"\r\n*** 检测到多个端口: {', '.join(str(p) for p in ports)}"
|
|
170
|
+
f"(使用第一个,或用 --port 指定) ***\r\n"
|
|
171
|
+
)
|
|
172
|
+
return ports[0]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------ #
|
|
176
|
+
# Mode 1: direct connection (fully independent from web/worker)
|
|
177
|
+
# ------------------------------------------------------------------ #
|
|
178
|
+
def run_direct(port):
|
|
179
|
+
logs_cfg = load_logs_config()
|
|
180
|
+
ilog = InteractionLog(f"cli_{port}", enabled=logs_cfg.get("interactive", True))
|
|
181
|
+
if logs_cfg.get("trace"):
|
|
182
|
+
try:
|
|
183
|
+
paramiko.util.log_to_file(
|
|
184
|
+
str(BASE_DIR / "logs" / f"paramiko_cli_{port}.log"), level="DEBUG"
|
|
185
|
+
)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
cfg = load_ssh_config()
|
|
190
|
+
write_out(f"\r\n*** 直连模式: {cfg.get('host')}:{port} (用户 {cfg.get('username')}) ***\r\n")
|
|
191
|
+
|
|
192
|
+
ilog.log("CONNECT-START", f"mode=direct port={port} pid={os.getpid()}")
|
|
193
|
+
client = paramiko.SSHClient()
|
|
194
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
195
|
+
try:
|
|
196
|
+
client.connect(
|
|
197
|
+
hostname=cfg.get("host", "127.0.0.1"),
|
|
198
|
+
port=port,
|
|
199
|
+
username=cfg.get("username", ""),
|
|
200
|
+
password=cfg.get("password", ""),
|
|
201
|
+
look_for_keys=False,
|
|
202
|
+
allow_agent=False,
|
|
203
|
+
timeout=10,
|
|
204
|
+
transport_factory=make_transport_factory(),
|
|
205
|
+
)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
ilog.log("CONNECT-FAILED", repr(e))
|
|
208
|
+
write_out(f"\r\n*** 连接失败: {e} ***\r\n\r\n按任意键退出...")
|
|
209
|
+
read_key()
|
|
210
|
+
ilog.close()
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
transport = client.get_transport()
|
|
214
|
+
keepalive = float(cfg.get("keepalive_interval", 0))
|
|
215
|
+
if transport is not None and keepalive > 0:
|
|
216
|
+
transport.set_keepalive(keepalive)
|
|
217
|
+
|
|
218
|
+
channel = client.invoke_shell(term="xterm-256color")
|
|
219
|
+
try:
|
|
220
|
+
cols, rows = console_size()
|
|
221
|
+
channel.resize_pty(width=cols, height=rows)
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
ver = transport.remote_version if transport else "?"
|
|
227
|
+
except Exception:
|
|
228
|
+
ver = "?"
|
|
229
|
+
ilog.log("CONNECTED", f"server={ver}")
|
|
230
|
+
if ilog.path:
|
|
231
|
+
write_out(f"*** 交互日志: {ilog.path.name} ***\r\n")
|
|
232
|
+
write_out("*** 已连接。Ctrl+Q 本地退出;Ctrl+C 发送到远端 ***\r\n\r\n")
|
|
233
|
+
|
|
234
|
+
closed = threading.Event()
|
|
235
|
+
why = [""]
|
|
236
|
+
|
|
237
|
+
def reader():
|
|
238
|
+
try:
|
|
239
|
+
while not closed.is_set():
|
|
240
|
+
if channel.recv_ready():
|
|
241
|
+
data = channel.recv(4096).decode("utf-8", errors="replace")
|
|
242
|
+
if not data:
|
|
243
|
+
break
|
|
244
|
+
ilog.log("OUT", repr(data))
|
|
245
|
+
write_out(data)
|
|
246
|
+
elif channel.exit_status_ready() and not channel.recv_ready():
|
|
247
|
+
time.sleep(0.2)
|
|
248
|
+
if not channel.recv_ready():
|
|
249
|
+
why[0] = "远端 shell 已退出"
|
|
250
|
+
break
|
|
251
|
+
elif transport is None or not transport.is_active():
|
|
252
|
+
why[0] = "SSH 连接已关闭"
|
|
253
|
+
break
|
|
254
|
+
else:
|
|
255
|
+
time.sleep(0.02)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
why[0] = f"读取错误: {e!r}"
|
|
258
|
+
finally:
|
|
259
|
+
closed.set()
|
|
260
|
+
|
|
261
|
+
t = threading.Thread(target=reader, daemon=True)
|
|
262
|
+
t.start()
|
|
263
|
+
|
|
264
|
+
last_size = console_size()
|
|
265
|
+
try:
|
|
266
|
+
while not closed.is_set():
|
|
267
|
+
key = read_key()
|
|
268
|
+
if key:
|
|
269
|
+
if key == QUIT_KEY:
|
|
270
|
+
why[0] = "本地退出 (Ctrl+Q)"
|
|
271
|
+
break
|
|
272
|
+
ilog.log("IN", repr(key))
|
|
273
|
+
try:
|
|
274
|
+
channel.send(key)
|
|
275
|
+
except Exception as e:
|
|
276
|
+
why[0] = f"发送失败: {e!r}"
|
|
277
|
+
break
|
|
278
|
+
# follow console resizes
|
|
279
|
+
size = console_size()
|
|
280
|
+
if size != last_size:
|
|
281
|
+
last_size = size
|
|
282
|
+
ilog.log("RESIZE", f"{size[0]}x{size[1]}")
|
|
283
|
+
try:
|
|
284
|
+
channel.resize_pty(width=size[0], height=size[1])
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
time.sleep(0.01)
|
|
288
|
+
finally:
|
|
289
|
+
closed.set()
|
|
290
|
+
t.join(timeout=1)
|
|
291
|
+
try:
|
|
292
|
+
channel.close()
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
try:
|
|
296
|
+
client.close()
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
ilog.log("DISCONNECTED", f"why={why[0] or 'exit'}")
|
|
300
|
+
ilog.close()
|
|
301
|
+
write_out(f"\r\n\r\n*** 会话结束: {why[0] or 'exit'} ***\r\n")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ------------------------------------------------------------------ #
|
|
305
|
+
# Mode 2: attach to an existing worker session
|
|
306
|
+
# ------------------------------------------------------------------ #
|
|
307
|
+
def run_attach(sid):
|
|
308
|
+
s = worker_connect(sid)
|
|
309
|
+
if s is None:
|
|
310
|
+
write_out(f"\r\n*** 无法接入会话 {sid}: worker 不在线 ***\r\n")
|
|
311
|
+
return
|
|
312
|
+
# long-lived proxy socket must not time out during quiet periods
|
|
313
|
+
try:
|
|
314
|
+
s.settimeout(None)
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
write_out(f"\r\n*** 接入模式: 会话 {sid} (worker 后端保持不变) ***\r\n")
|
|
319
|
+
write_out("*** Ctrl+Q 返回本地(会话继续运行);Ctrl+C 发送到远端 ***\r\n\r\n")
|
|
320
|
+
|
|
321
|
+
closed = threading.Event()
|
|
322
|
+
|
|
323
|
+
def reader():
|
|
324
|
+
buf = b""
|
|
325
|
+
try:
|
|
326
|
+
while not closed.is_set():
|
|
327
|
+
chunk = s.recv(4096)
|
|
328
|
+
if not chunk:
|
|
329
|
+
break
|
|
330
|
+
buf += chunk
|
|
331
|
+
while b"\n" in buf:
|
|
332
|
+
line, buf = buf.split(b"\n", 1)
|
|
333
|
+
line = line.strip()
|
|
334
|
+
if not line:
|
|
335
|
+
continue
|
|
336
|
+
try:
|
|
337
|
+
ev = json.loads(line)
|
|
338
|
+
except (json.JSONDecodeError, ValueError):
|
|
339
|
+
continue
|
|
340
|
+
if ev.get("ev") == "hello":
|
|
341
|
+
# replay scrollback first
|
|
342
|
+
for item in ev.get("scrollback", []):
|
|
343
|
+
write_out(item.get("data", ""))
|
|
344
|
+
write_out(
|
|
345
|
+
f"\r\n*** 已接入 (state={ev.get('state')}, "
|
|
346
|
+
f"{len(ev.get('scrollback', []))} 条历史) ***\r\n"
|
|
347
|
+
)
|
|
348
|
+
elif ev.get("ev") == "output":
|
|
349
|
+
write_out(ev.get("data", ""))
|
|
350
|
+
elif ev.get("ev") == "state":
|
|
351
|
+
write_out(f"\r\n*** 状态: {ev.get('state')} {ev.get('why', '')} ***\r\n")
|
|
352
|
+
elif ev.get("ev") == "bye":
|
|
353
|
+
write_out("\r\n*** worker 会话已结束 ***\r\n")
|
|
354
|
+
closed.set()
|
|
355
|
+
return
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
finally:
|
|
359
|
+
closed.set()
|
|
360
|
+
|
|
361
|
+
t = threading.Thread(target=reader, daemon=True)
|
|
362
|
+
t.start()
|
|
363
|
+
|
|
364
|
+
last_size = console_size()
|
|
365
|
+
try:
|
|
366
|
+
while not closed.is_set():
|
|
367
|
+
key = read_key()
|
|
368
|
+
if key:
|
|
369
|
+
if key == QUIT_KEY:
|
|
370
|
+
break
|
|
371
|
+
send_ctrl(s, {"op": "input", "data": key})
|
|
372
|
+
size = console_size()
|
|
373
|
+
if size != last_size:
|
|
374
|
+
last_size = size
|
|
375
|
+
send_ctrl(s, {"op": "resize", "cols": size[0], "rows": size[1]})
|
|
376
|
+
time.sleep(0.01)
|
|
377
|
+
finally:
|
|
378
|
+
closed.set()
|
|
379
|
+
send_ctrl(s, {"op": "detach"})
|
|
380
|
+
t.join(timeout=1)
|
|
381
|
+
try:
|
|
382
|
+
s.close()
|
|
383
|
+
except Exception:
|
|
384
|
+
pass
|
|
385
|
+
write_out("\r\n\r\n*** 已退出接入(worker 会话继续运行) ***\r\n")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ------------------------------------------------------------------ #
|
|
389
|
+
# Mode 3: list worker sessions
|
|
390
|
+
# ------------------------------------------------------------------ #
|
|
391
|
+
def run_list():
|
|
392
|
+
sessions = worker_contexts()
|
|
393
|
+
if not sessions:
|
|
394
|
+
write_out("暂无存活的 worker 会话\r\n")
|
|
395
|
+
return
|
|
396
|
+
write_out(f"{'会话ID':<14} {'状态':<13} {'SSH端口':<8} {'PID':<8} 创建时间\r\n")
|
|
397
|
+
for s in sessions:
|
|
398
|
+
write_out(
|
|
399
|
+
f"{s.get('sid', ''):<14} {s.get('state', ''):<13} "
|
|
400
|
+
f"{s.get('ssh_port', ''):<8} {s.get('pid', ''):<8} {s.get('created', '')}\r\n"
|
|
401
|
+
)
|
|
402
|
+
write_out("\r\n接入: python ssh_cli.py --attach <会话ID>\r\n")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ------------------------------------------------------------------ #
|
|
406
|
+
# Entry
|
|
407
|
+
# ------------------------------------------------------------------ #
|
|
408
|
+
def main():
|
|
409
|
+
ap = argparse.ArgumentParser(
|
|
410
|
+
description="命令行 SSH 终端(与 web 界面独立,直连或接入 worker 会话)"
|
|
411
|
+
)
|
|
412
|
+
ap.add_argument("--port", type=int, help="直连指定 SSH 端口")
|
|
413
|
+
ap.add_argument("--attach", metavar="SID", help="接入已存在的 worker 会话")
|
|
414
|
+
ap.add_argument("--list", action="store_true", help="列出存活的 worker 会话")
|
|
415
|
+
args = ap.parse_args()
|
|
416
|
+
|
|
417
|
+
setup_stdout()
|
|
418
|
+
|
|
419
|
+
if args.list:
|
|
420
|
+
run_list()
|
|
421
|
+
return
|
|
422
|
+
if args.attach:
|
|
423
|
+
run_attach(args.attach)
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
port = args.port
|
|
427
|
+
if port is None:
|
|
428
|
+
port = pick_port()
|
|
429
|
+
if not port:
|
|
430
|
+
write_out(
|
|
431
|
+
"\r\n*** 未检测到可用端口。请确认隧道进程在运行,"
|
|
432
|
+
"或用 --port <端口> 手动指定 ***\r\n"
|
|
433
|
+
)
|
|
434
|
+
sys.exit(1)
|
|
435
|
+
|
|
436
|
+
if not IS_WINDOWS:
|
|
437
|
+
# POSIX: switch the tty to raw mode so keys go through unfiltered
|
|
438
|
+
old = termios.tcgetattr(sys.stdin)
|
|
439
|
+
tty.setraw(sys.stdin.fileno())
|
|
440
|
+
try:
|
|
441
|
+
run_direct(port)
|
|
442
|
+
finally:
|
|
443
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old)
|
|
444
|
+
else:
|
|
445
|
+
run_direct(port)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
if __name__ == "__main__":
|
|
449
|
+
main()
|