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.
@@ -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,7 @@
1
+ flask>=3.0
2
+ flask-sock>=0.6
3
+ # paramiko must stay <4.0: v4+ removed legacy SHA-1 kex & ssh-rsa,
4
+ # which embedded SSH servers (e.g. bh_am_pfe_tunnel) require.
5
+ paramiko>=3.4,<4.0
6
+ PyYAML>=6.0
7
+ psutil>=5.9
@@ -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()