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/app.py
ADDED
|
@@ -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)
|