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,788 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ WebSSH - Port Auto-Detect Web Terminal (detached-worker architecture)
4
+
5
+ Browser ──WebSocket── Flask(薄代理) ──本地TCP── ssh_worker.py(独立进程,持有SSH)
6
+ └─ sessions/ctx_*.json 上下文落盘
7
+
8
+ Key property: the SSH session lives in a separate worker process.
9
+ If the page crashes / refreshes / Flask restarts, the session survives;
10
+ the UI can re-attach from the context files (process PID, control port,
11
+ target port, state) - tmux-like detach/attach for a web terminal.
12
+
13
+ Features:
14
+ - Auto-detect local listening port from process name (like portssh.bat)
15
+ - Web-based SSH terminal (xterm.js + WebSocket)
16
+ - Detached worker sessions with context persistence + re-attach
17
+ - Credentials stored in config.yml
18
+ - Session log auto-save + manual download
19
+ - Win10/Win11 compatible
20
+ """
21
+
22
+ import os
23
+ import sys
24
+ import json
25
+ import time
26
+ import uuid
27
+ import socket
28
+ import signal
29
+ import threading
30
+ import subprocess
31
+ from datetime import datetime
32
+ from pathlib import Path
33
+
34
+ import yaml
35
+ import psutil
36
+ from flask import Flask, render_template, request, jsonify, send_file, abort
37
+ from flask_sock import Sock
38
+
39
+ # ------------------------------------------------------------------ #
40
+ # Paths & constants
41
+ # ------------------------------------------------------------------ #
42
+ BASE_DIR = Path(__file__).parent.resolve()
43
+ CONFIG_PATH = BASE_DIR / "config.yml"
44
+ SESSIONS_DIR = BASE_DIR / "sessions"
45
+ WORKER_SCRIPT = BASE_DIR / "ssh_worker.py"
46
+
47
+ # ------------------------------------------------------------------ #
48
+ # Flask app
49
+ # ------------------------------------------------------------------ #
50
+ app = Flask(__name__, template_folder=str(BASE_DIR / "templates"))
51
+ # flask_sock doesn't expose max_message_size; patch simple_websocket
52
+ # so large SFTP directory listings don't hit the default size limit.
53
+ import simple_websocket
54
+ _orig_server_init = simple_websocket.Server.__init__
55
+ def _patched_server_init(self, *args, **kwargs):
56
+ kwargs.setdefault("max_message_size", 10 * 1024 * 1024) # 10 MB
57
+ kwargs.setdefault("receive_bytes", 65536)
58
+ return _orig_server_init(self, *args, **kwargs)
59
+ simple_websocket.Server.__init__ = _patched_server_init
60
+ sock = Sock(app)
61
+
62
+
63
+ # ------------------------------------------------------------------ #
64
+ # Config
65
+ # ------------------------------------------------------------------ #
66
+ def load_config():
67
+ """Load config.yml (re-read each call so edits take effect on next request)."""
68
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
69
+ return yaml.safe_load(f)
70
+
71
+
72
+ # ------------------------------------------------------------------ #
73
+ # Process / port detection (replaces tasklist + netstat from bat)
74
+ # ------------------------------------------------------------------ #
75
+ def detect_process_port(process_name):
76
+ """
77
+ Find all processes whose name contains *process_name* (case-insensitive)
78
+ and collect their LISTENING ports.
79
+
80
+ Strategy (aligned with portssh.bat behaviour):
81
+ 1. psutil per-process net_connections() -- fast, but silently skips
82
+ processes we can't query (SYSTEM / elevated ones).
83
+ 2. Fallback/merge: netstat -ano (same as the .bat). netstat shows
84
+ ALL listening sockets regardless of process permissions, and we
85
+ match PIDs by name from psutil.
86
+
87
+ Returns a list of dicts:
88
+ [{"pid": 13644, "name": "bh_am_pfe_tunnel.exe",
89
+ "ports": [{"port": 4983, "address": "127.0.0.1", "full": "127.0.0.1:4983"}]}]
90
+ """
91
+ results = []
92
+ if not process_name:
93
+ return results
94
+
95
+ name_lower = process_name.lower()
96
+
97
+ # --- Collect PIDs by name (psutil; may need multiple passes) ---
98
+ pid_to_name = {}
99
+ for proc in psutil.process_iter(["pid", "name"]):
100
+ try:
101
+ proc_name = proc.info.get("name", "") or ""
102
+ if name_lower in proc_name.lower():
103
+ pid_to_name[proc.info["pid"]] = proc_name
104
+ except (psutil.AccessDenied, psutil.NoSuchProcess):
105
+ continue
106
+
107
+ if not pid_to_name:
108
+ return results
109
+
110
+ # --- Collect listening ports per PID ---
111
+ ports_by_pid = {}
112
+
113
+ # Pass 1: psutil per-process connections
114
+ for pid in pid_to_name:
115
+ try:
116
+ conns = psutil.Process(pid).net_connections(kind="inet")
117
+ except (psutil.AccessDenied, psutil.NoSuchProcess):
118
+ conns = []
119
+ for conn in conns:
120
+ if conn.status == psutil.CONN_LISTEN and conn.laddr:
121
+ ports_by_pid.setdefault(pid, []).append(
122
+ {
123
+ "port": conn.laddr.port,
124
+ "address": str(conn.laddr.ip),
125
+ "full": f"{conn.laddr.ip}:{conn.laddr.port}",
126
+ }
127
+ )
128
+
129
+ # Pass 2 (fallback for inaccessible PIDs): netstat -ano,
130
+ # same source of truth as portssh.bat / shell mode.
131
+ missing = [pid for pid in pid_to_name if pid not in ports_by_pid]
132
+ if missing:
133
+ try:
134
+ netstat_out = subprocess.run(
135
+ ["netstat", "-ano"],
136
+ capture_output=True,
137
+ text=True,
138
+ timeout=15,
139
+ errors="replace",
140
+ ).stdout
141
+ for line in netstat_out.splitlines():
142
+ parts = line.split()
143
+ if len(parts) >= 5 and parts[3] == "LISTENING":
144
+ try:
145
+ pid = int(parts[4])
146
+ except ValueError:
147
+ continue
148
+ if pid not in missing:
149
+ continue
150
+ local_addr = parts[1]
151
+ if local_addr.startswith("["): # IPv6 [addr]:port
152
+ port_part = local_addr.rsplit("]", 1)[-1].lstrip(":")
153
+ else:
154
+ port_part = local_addr.rsplit(":", 1)[-1]
155
+ try:
156
+ port = int(port_part)
157
+ except ValueError:
158
+ continue
159
+ ports_by_pid.setdefault(pid, []).append(
160
+ {
161
+ "port": port,
162
+ "address": local_addr.rsplit(":", 1)[0],
163
+ "full": local_addr,
164
+ }
165
+ )
166
+ except Exception:
167
+ pass
168
+
169
+ for pid, proc_name in pid_to_name.items():
170
+ ports = ports_by_pid.get(pid, [])
171
+ # Deduplicate by port number
172
+ seen = set()
173
+ uniq_ports = []
174
+ for p in ports:
175
+ if p["port"] not in seen:
176
+ seen.add(p["port"])
177
+ uniq_ports.append(p)
178
+ if uniq_ports:
179
+ results.append({"pid": pid, "name": proc_name, "ports": uniq_ports})
180
+
181
+ return results
182
+
183
+
184
+ # ------------------------------------------------------------------ #
185
+ # Session log
186
+ # ------------------------------------------------------------------ #
187
+ def save_session_log(log_buffer, config):
188
+ """Persist *log_buffer* (list of str) to a timestamped .txt file."""
189
+ if not config.get("logs", {}).get("auto_save", True):
190
+ return None
191
+ if not log_buffer:
192
+ return None
193
+
194
+ log_dir = BASE_DIR / config.get("logs", {}).get("dir", "logs")
195
+ log_dir.mkdir(parents=True, exist_ok=True)
196
+
197
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
198
+ filepath = log_dir / f"session_{ts}.txt"
199
+
200
+ content = "".join(log_buffer)
201
+ with open(filepath, "w", encoding="utf-8") as f:
202
+ f.write(content)
203
+
204
+ return str(filepath)
205
+
206
+
207
+ # ------------------------------------------------------------------ #
208
+ # Worker session management
209
+ # ------------------------------------------------------------------ #
210
+ def worker_contexts():
211
+ """Read all session context files; returns list of dicts.
212
+ Stale files (worker process gone) are cleaned up."""
213
+ out = []
214
+ if not SESSIONS_DIR.exists():
215
+ return out
216
+ for f in sorted(SESSIONS_DIR.glob("ctx_*.json")):
217
+ try:
218
+ ctx = json.loads(f.read_text(encoding="utf-8"))
219
+ except Exception:
220
+ continue
221
+ pid = ctx.get("pid")
222
+ alive = False
223
+ if pid:
224
+ try:
225
+ p = psutil.Process(pid)
226
+ alive = p.is_running() and p.status() != psutil.STATUS_ZOMBIE
227
+ # guard against PID reuse: process cmdline should contain ssh_worker
228
+ try:
229
+ cmd = " ".join(p.cmdline())
230
+ alive = alive and "ssh_worker" in cmd
231
+ except Exception:
232
+ pass
233
+ except psutil.NoSuchProcess:
234
+ alive = False
235
+ ctx["alive"] = alive
236
+ if not alive:
237
+ try:
238
+ f.unlink()
239
+ except Exception:
240
+ pass
241
+ continue
242
+ out.append(ctx)
243
+ return out
244
+
245
+
246
+ def spawn_worker(ssh_port):
247
+ """Start a detached ssh_worker.py for *ssh_port*. Returns (sid, error)."""
248
+ sid = uuid.uuid4().hex[:12]
249
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
250
+
251
+ # detached: own process group, survives Flask exit
252
+ creationflags = 0
253
+ if os.name == "nt":
254
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
255
+
256
+ try:
257
+ proc = subprocess.Popen(
258
+ [sys.executable, str(WORKER_SCRIPT),
259
+ "--id", sid, "--port", str(ssh_port), "--ctrl-port", "0"],
260
+ stdout=subprocess.DEVNULL,
261
+ stderr=subprocess.DEVNULL,
262
+ stdin=subprocess.DEVNULL,
263
+ creationflags=creationflags,
264
+ cwd=str(BASE_DIR),
265
+ )
266
+ except Exception as e:
267
+ return None, f"failed to spawn worker: {e}"
268
+
269
+ # wait for the context file to appear (worker binds ctrl socket, then
270
+ # persists ctx with the real ctrl port)
271
+ ctx_path = SESSIONS_DIR / f"ctx_{sid}.json"
272
+ deadline = time.time() + 15
273
+ while time.time() < deadline:
274
+ if ctx_path.exists():
275
+ try:
276
+ ctx = json.loads(ctx_path.read_text(encoding="utf-8"))
277
+ if ctx.get("ctrl_port"):
278
+ return sid, None
279
+ except Exception:
280
+ pass
281
+ if proc.poll() is not None:
282
+ return None, "worker process exited during startup"
283
+ time.sleep(0.1)
284
+ return None, "worker startup timeout"
285
+
286
+
287
+ def worker_connect(sid, timeout=5.0):
288
+ """Connect to a worker's control socket. Returns socket or None."""
289
+ ctx_path = SESSIONS_DIR / f"ctx_{sid}.json"
290
+ try:
291
+ ctx = json.loads(ctx_path.read_text(encoding="utf-8"))
292
+ except Exception:
293
+ return None
294
+ port = ctx.get("ctrl_port")
295
+ if not port:
296
+ return None
297
+ try:
298
+ s = socket.create_connection(("127.0.0.1", port), timeout=timeout)
299
+ return s
300
+ except Exception:
301
+ return None
302
+
303
+
304
+ def send_ctrl(s, obj):
305
+ try:
306
+ s.sendall((json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8"))
307
+ return True
308
+ except Exception:
309
+ return False
310
+
311
+
312
+ # ------------------------------------------------------------------ #
313
+ # HTTP routes
314
+ # ------------------------------------------------------------------ #
315
+ @app.route("/")
316
+ def index():
317
+ return render_template("index.html")
318
+
319
+
320
+ @app.route("/api/detect")
321
+ def api_detect():
322
+ """Detect process listening ports. Supports multiple process names."""
323
+ config = load_config()
324
+ # Accept ?process= override (single name or comma-separated)
325
+ override = request.args.get("process", "")
326
+ if override:
327
+ names = [n.strip() for n in override.split(",") if n.strip()]
328
+ else:
329
+ raw = config.get("ssh", {}).get("process_name", "")
330
+ if isinstance(raw, list):
331
+ names = [str(n) for n in raw if n]
332
+ elif raw:
333
+ names = [str(raw)]
334
+ else:
335
+ names = []
336
+
337
+ all_results = []
338
+ for name in names:
339
+ all_results.extend(detect_process_port(name))
340
+ return jsonify(
341
+ {
342
+ "process_names": names,
343
+ "results": all_results,
344
+ "manual_port": config.get("ssh", {}).get("port"),
345
+ }
346
+ )
347
+
348
+
349
+ @app.route("/api/config")
350
+ def api_config():
351
+ """Return non-sensitive config (no password)."""
352
+ config = load_config()
353
+ return jsonify(
354
+ {
355
+ "ssh": {
356
+ "username": config.get("ssh", {}).get("username", ""),
357
+ "host": config.get("ssh", {}).get("host", "127.0.0.1"),
358
+ "process_name": config.get("ssh", {}).get("process_name", ""),
359
+ "port": config.get("ssh", {}).get("port"),
360
+ },
361
+ "web": {
362
+ "host": config.get("web", {}).get("host", "127.0.0.1"),
363
+ "port": config.get("web", {}).get("port", 8080),
364
+ },
365
+ "terminal": {
366
+ "scrollback": config.get("terminal", {}).get("scrollback", 10000),
367
+ },
368
+ "toolbar": config.get("toolbar", {}),
369
+ }
370
+ )
371
+
372
+
373
+ @app.route("/api/sessions")
374
+ def api_sessions():
375
+ """List live detached worker sessions (page crash -> re-attach)."""
376
+ return jsonify({"sessions": worker_contexts()})
377
+
378
+
379
+ @app.route("/api/sessions/<sid>/shutdown", methods=["POST"])
380
+ def api_session_shutdown(sid):
381
+ """Terminate a worker session."""
382
+ if "/" in sid or not sid.isalnum():
383
+ abort(400)
384
+ s = worker_connect(sid)
385
+ if s is None:
386
+ # stale context - just clean the file
387
+ try:
388
+ (SESSIONS_DIR / f"ctx_{sid}.json").unlink()
389
+ except FileNotFoundError:
390
+ pass
391
+ return jsonify({"ok": True, "note": "stale context removed"})
392
+ send_ctrl(s, {"op": "shutdown"})
393
+ try:
394
+ s.close()
395
+ except Exception:
396
+ pass
397
+ return jsonify({"ok": True})
398
+
399
+
400
+ @app.route("/api/logs")
401
+ def api_logs():
402
+ """List saved session logs (page-level + worker interaction logs)."""
403
+ config = load_config()
404
+ log_dir = BASE_DIR / config.get("logs", {}).get("dir", "logs")
405
+ logs = []
406
+ if log_dir.exists():
407
+ # session_*.txt - page-side output capture
408
+ # interactive_*.log - worker-side step-by-step interaction log
409
+ files = list(log_dir.glob("session_*.txt")) + list(log_dir.glob("interactive_*.log"))
410
+ for f in sorted(files, key=lambda p: p.stat().st_mtime, reverse=True):
411
+ st = f.stat()
412
+ logs.append(
413
+ {
414
+ "name": f.name,
415
+ "size": st.st_size,
416
+ "time": datetime.fromtimestamp(st.st_mtime).strftime(
417
+ "%Y-%m-%d %H:%M:%S"
418
+ ),
419
+ }
420
+ )
421
+ return jsonify({"logs": logs})
422
+
423
+
424
+ @app.route("/api/logs/<filename>")
425
+ def api_download_log(filename):
426
+ """Download a specific session log file."""
427
+ if not (filename.startswith("session_") or filename.startswith("interactive_")):
428
+ abort(404)
429
+ config = load_config()
430
+ log_dir = BASE_DIR / config.get("logs", {}).get("dir", "logs")
431
+ filepath = log_dir / filename
432
+ if not filepath.exists():
433
+ abort(404)
434
+ return send_file(str(filepath), as_attachment=True, download_name=filename)
435
+
436
+
437
+ @app.route("/api/logs/<filename>/content")
438
+ def api_log_content(filename):
439
+ """Return the text content of a log file (for in-browser preview tab)."""
440
+ if not (filename.startswith("session_") or filename.startswith("interactive_")):
441
+ abort(404)
442
+ config = load_config()
443
+ log_dir = BASE_DIR / config.get("logs", {}).get("dir", "logs")
444
+ filepath = log_dir / filename
445
+ if not filepath.exists():
446
+ abort(404)
447
+ try:
448
+ content = filepath.read_text(encoding="utf-8", errors="replace")
449
+ except Exception as e:
450
+ return jsonify({"error": str(e)}), 500
451
+ return jsonify({"name": filename, "content": content})
452
+
453
+
454
+ EXTENSION_DIR = BASE_DIR / "extension"
455
+
456
+
457
+ @app.route("/api/extensions")
458
+ def api_extensions():
459
+ """List and parse Markdown extension toolbars from the extension/ directory.
460
+
461
+ Each .md file in extension/ becomes one toolbar group set. The Markdown
462
+ structure is parsed as follows:
463
+ - File name (without .md) = top-level toolbar name
464
+ - Headings (# ~ ######) = directory / button names (nested by level)
465
+ - ```shell ... ``` blocks = command scripts attached to the nearest preceding heading
466
+ - Text between heading and code block = comment/tooltip
467
+
468
+ Returns a list of toolbar group sets, each with:
469
+ { "name": "<filename>", "groups": [ { name, tools: [...], children: [...] } ] }
470
+ """
471
+ results = []
472
+ if not EXTENSION_DIR.exists():
473
+ return jsonify({"extensions": []})
474
+
475
+ import re
476
+
477
+ def parse_markdown(text):
478
+ """Parse markdown into a tree of nodes.
479
+
480
+ Each node: { name, comment, command, children, level }
481
+ Headings create hierarchical nodes; code blocks attach to the last heading node.
482
+ """
483
+ lines = text.split("\n")
484
+ root = {"name": "", "comment": "", "command": None, "children": [], "level": 0}
485
+ # stack: [root, h1_node, h2_node, ...] - tracks heading nesting
486
+ stack = [root]
487
+ last_heading_node = None
488
+ comment_lines = []
489
+
490
+ i = 0
491
+ while i < len(lines):
492
+ line = lines[i]
493
+
494
+ # Heading
495
+ m = re.match(r"^(#{1,6})\s+(.+)", line)
496
+ if m:
497
+ level = len(m.group(1))
498
+ name = m.group(2).strip()
499
+ # Pop stack to find parent (level > parent's level)
500
+ while len(stack) > 1 and stack[-1]["level"] >= level:
501
+ stack.pop()
502
+ parent = stack[-1]
503
+ node = {
504
+ "name": name,
505
+ "comment": "",
506
+ "command": None,
507
+ "children": [],
508
+ "level": level,
509
+ }
510
+ parent["children"].append(node)
511
+ stack.append(node)
512
+ last_heading_node = node
513
+ comment_lines = []
514
+ i += 1
515
+ continue
516
+
517
+ # Code block (```shell ... ```)
518
+ if line.strip().startswith("```"):
519
+ code_lines = []
520
+ i += 1
521
+ while i < len(lines):
522
+ if lines[i].strip().startswith("```"):
523
+ i += 1
524
+ break
525
+ code_lines.append(lines[i])
526
+ i += 1
527
+ code = "\n".join(code_lines)
528
+ if last_heading_node is not None:
529
+ last_heading_node["command"] = code
530
+ comment_lines = []
531
+ continue
532
+
533
+ # Regular text line (comment for the nearest heading)
534
+ if line.strip() and last_heading_node is not None:
535
+ comment_lines.append(line.strip())
536
+ last_heading_node["comment"] = " ".join(comment_lines)
537
+
538
+ i += 1
539
+
540
+ return root
541
+
542
+ for md_file in sorted(EXTENSION_DIR.glob("*.md")):
543
+ try:
544
+ text = md_file.read_text(encoding="utf-8", errors="replace")
545
+ tree = parse_markdown(text)
546
+ # The root's children are the top-level groups
547
+ results.append({
548
+ "name": md_file.stem,
549
+ "root": tree,
550
+ })
551
+ except Exception:
552
+ continue
553
+
554
+ return jsonify({"extensions": results})
555
+
556
+
557
+ @app.route("/api/extensions/import", methods=["POST"])
558
+ def api_extensions_import():
559
+ """Import a toolbar markdown file into extension/ (temporary import).
560
+
561
+ Allows adding a toolbar group set without touching the server
562
+ filesystem manually. Re-importing the same filename overwrites
563
+ the previous version. The page then just reloads toolbars.
564
+ """
565
+ if "file" not in request.files:
566
+ return jsonify({"error": "no file uploaded"}), 400
567
+ f = request.files["file"]
568
+ raw_name = f.filename or ""
569
+ if not raw_name.strip():
570
+ return jsonify({"error": "empty filename"}), 400
571
+ # sanitize: keep only the basename, force .md suffix
572
+ name = Path(raw_name).name
573
+ if not name.lower().endswith(".md"):
574
+ name += ".md"
575
+ # block Windows reserved device names just in case
576
+ if name.split(".")[0].upper() in {
577
+ "CON", "PRN", "AUX", "NUL",
578
+ *(f"COM{i}" for i in range(1, 10)),
579
+ *(f"LPT{i}" for i in range(1, 10)),
580
+ }:
581
+ return jsonify({"error": "invalid filename"}), 400
582
+ EXTENSION_DIR.mkdir(parents=True, exist_ok=True)
583
+ f.save(EXTENSION_DIR / name)
584
+ return jsonify({"ok": True, "name": name})
585
+
586
+
587
+ # ------------------------------------------------------------------ #
588
+ # WebSocket – thin proxy to worker control socket
589
+ # ------------------------------------------------------------------ #
590
+ @sock.route("/ws/ssh")
591
+ def ws_ssh(ws):
592
+ """
593
+ Browser <-> worker bridge.
594
+
595
+ Query params:
596
+ port=NNNN start a NEW worker session for this SSH port
597
+ attach=<sid> attach to an EXISTING worker session
598
+
599
+ Client -> worker (JSON, forwarded verbatim):
600
+ {"op": "input", "data": "ls\r"}
601
+ {"op": "resize", "cols": 80, "rows": 24}
602
+ {"op": "detach"}
603
+
604
+ Worker -> client (JSON):
605
+ {"ev": "hello", "sid": ..., "state": ..., "scrollback": [...]}
606
+ {"ev": "output", "data": "...", "seq": N}
607
+ {"ev": "state", "state": "connected"|"disconnected", "why": ...}
608
+ {"ev": "bye"}
609
+ """
610
+ attach_sid = request.args.get("attach")
611
+ ssh_port = request.args.get("port", type=int)
612
+
613
+ # --- resolve / create worker ---
614
+ if attach_sid:
615
+ if not attach_sid.isalnum():
616
+ ws.send(json.dumps({"type": "error", "data": "bad session id"}))
617
+ return
618
+ s = worker_connect(attach_sid)
619
+ if s is None:
620
+ ws.send(json.dumps({
621
+ "type": "error",
622
+ "data": f"无法接入会话 {attach_sid}:worker 不在线(可能已被清理)",
623
+ }))
624
+ return
625
+ sid = attach_sid
626
+ elif ssh_port:
627
+ sid, err = spawn_worker(ssh_port)
628
+ if err:
629
+ ws.send(json.dumps({"type": "error", "data": f"启动会话进程失败: {err}"}))
630
+ return
631
+ s = worker_connect(sid)
632
+ if s is None:
633
+ ws.send(json.dumps({
634
+ "type": "error",
635
+ "data": "会话进程已启动但控制通道连接失败",
636
+ }))
637
+ return
638
+ else:
639
+ ws.send(json.dumps({"type": "error", "data": "No port or session specified"}))
640
+ return
641
+
642
+ log_buffer = []
643
+
644
+ # --- thread: worker -> browser ---
645
+ def worker_to_ws():
646
+ # The control socket was created with a short connect timeout.
647
+ # For the long-lived proxy loop that timeout must be removed,
648
+ # otherwise a quiet period (>5s no output) raises TimeoutError
649
+ # and gets misreported as a broken backend channel.
650
+ try:
651
+ s.settimeout(None)
652
+ except Exception:
653
+ pass
654
+ buf = b""
655
+ try:
656
+ while True:
657
+ chunk = s.recv(65536)
658
+ if not chunk:
659
+ break
660
+ buf += chunk
661
+ while b"\n" in buf:
662
+ line, buf = buf.split(b"\n", 1)
663
+ line = line.strip()
664
+ if not line:
665
+ continue
666
+ try:
667
+ ev = json.loads(line)
668
+ except (json.JSONDecodeError, ValueError):
669
+ continue
670
+ if ev.get("ev") == "hello":
671
+ # forward scrollback so the terminal shows history
672
+ for item in ev.get("scrollback", []):
673
+ ws.send(json.dumps({
674
+ "type": "output", "data": item.get("data", ""),
675
+ }))
676
+ log_buffer.extend(
677
+ item.get("data", "") for item in ev.get("scrollback", [])
678
+ )
679
+ # sid must reach the browser: without it the
680
+ # frontend cannot track the new session and the
681
+ # toolbar target state stays stale after connect.
682
+ ws.send(json.dumps({
683
+ "type": "status",
684
+ "data": "connected" if ev.get("state") == "connected" else ev.get("state", ""),
685
+ "sid": ev.get("sid"),
686
+ }))
687
+ elif ev.get("ev") == "output":
688
+ data = ev.get("data", "")
689
+ log_buffer.append(data)
690
+ ws.send(json.dumps({"type": "output", "data": data}))
691
+ elif ev.get("ev") == "state":
692
+ why = ev.get("why", "")
693
+ ws.send(json.dumps({
694
+ "type": "status" if ev.get("state") == "connected" else "error",
695
+ "data": why or ev.get("state", ""),
696
+ }))
697
+ elif ev.get("ev") == "bye":
698
+ ws.send(json.dumps({"type": "status", "data": "会话已由 worker 结束"}))
699
+ return
700
+ else:
701
+ # Forward any other event (sftp_list, sftp_progress,
702
+ # sftp_done, sftp_error, sftp_home, sftp_mkdir_done)
703
+ # as a typed message to the browser.
704
+ ws.send(json.dumps({"type": ev.get("ev", "event"),
705
+ "data": ev}))
706
+ except Exception as e:
707
+ # worker control socket broke unexpectedly (SSH died, worker
708
+ # exited, etc.) - tell the browser WHY instead of a bare close.
709
+ try:
710
+ ws.send(json.dumps({
711
+ "type": "error",
712
+ "data": f"后端会话通道中断: {e!r}",
713
+ }))
714
+ except Exception:
715
+ pass
716
+ finally:
717
+ try:
718
+ ws.close()
719
+ except Exception:
720
+ pass
721
+
722
+ t = threading.Thread(target=worker_to_ws, daemon=True)
723
+ t.start()
724
+
725
+ # --- main loop: browser -> worker ---
726
+ try:
727
+ while True:
728
+ msg = ws.receive()
729
+ if msg is None:
730
+ break
731
+ try:
732
+ data = json.loads(msg)
733
+ except (json.JSONDecodeError, ValueError):
734
+ continue
735
+
736
+ if data.get("type") == "input":
737
+ send_ctrl(s, {"op": "input", "data": data.get("data", "")})
738
+ elif data.get("type") == "resize":
739
+ send_ctrl(s, {"op": "resize",
740
+ "cols": data.get("cols", 80),
741
+ "rows": data.get("rows", 24)})
742
+ elif data.get("type") == "sftp_list":
743
+ send_ctrl(s, {"op": "sftp_list", "path": data.get("path", "/")})
744
+ elif data.get("type") == "sftp_home":
745
+ send_ctrl(s, {"op": "sftp_home"})
746
+ elif data.get("type") == "sftp_upload_start":
747
+ send_ctrl(s, {"op": "sftp_upload_start",
748
+ "path": data.get("path", ""),
749
+ "size": data.get("size", 0)})
750
+ elif data.get("type") == "sftp_upload_chunk":
751
+ send_ctrl(s, {"op": "sftp_upload_chunk",
752
+ "data": data.get("data", "")})
753
+ elif data.get("type") == "sftp_upload_end":
754
+ send_ctrl(s, {"op": "sftp_upload_end"})
755
+ elif data.get("type") == "sftp_mkdir":
756
+ send_ctrl(s, {"op": "sftp_mkdir", "path": data.get("path", "")})
757
+ elif data.get("type") == "sftp_download_start":
758
+ send_ctrl(s, {"op": "sftp_download_start", "path": data.get("path", "")})
759
+ except Exception:
760
+ pass
761
+ finally:
762
+ # page closed/crashed -> tell worker we detached; session keeps living
763
+ send_ctrl(s, {"op": "detach"})
764
+ try:
765
+ s.close()
766
+ except Exception:
767
+ pass
768
+ t.join(timeout=2)
769
+ save_session_log(log_buffer, load_config())
770
+
771
+
772
+ # ------------------------------------------------------------------ #
773
+ # Entry point
774
+ # ------------------------------------------------------------------ #
775
+ if __name__ == "__main__":
776
+ config = load_config()
777
+ web_cfg = config.get("web", {})
778
+ host = web_cfg.get("host", "127.0.0.1")
779
+ port = web_cfg.get("port", 8080)
780
+
781
+ print(f"WebSSH server starting at http://{host}:{port}")
782
+ print(f"Detached worker sessions dir: {SESSIONS_DIR}")
783
+ print("Press Ctrl+C to stop\n")
784
+
785
+ # threaded=True is REQUIRED: the WebSocket proxy holds its handler
786
+ # for the whole session; single-threaded mode would freeze all other
787
+ # requests (page refresh, /api/detect, ...) while a terminal is open.
788
+ app.run(host=host, port=port, debug=False, threaded=True)