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,782 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ssh_worker.py - Detached SSH session worker process.
4
+
5
+ Holds ONE paramiko SSH session (the real long connection) in its own
6
+ process, independent from Flask. Flask (or any client) talks to it over
7
+ a localhost TCP control socket with line-based JSON.
8
+
9
+ Context persistence:
10
+ sessions/ctx_<id>.json - who we are, target port, state, control port
11
+
12
+ Control protocol (one JSON object per line, both directions):
13
+ -> {"op": "attach"} client wants live output+input
14
+ -> {"op": "detach"} client stops proxying
15
+ -> {"op": "input", "data": "ls\r"}
16
+ -> {"op": "resize", "cols": 80, "rows": 24}
17
+ -> {"op": "shutdown"} terminate the session
18
+ <- {"ev": "hello", "sid": "...", "state": "connected"}
19
+ <- {"ev": "output", "data": "...", "seq": 42}
20
+ <- {"ev": "state", "state": "connected"|"disconnected", "why": "..."}
21
+ <- {"ev": "bye"}
22
+
23
+ Interaction log (logs/interactive_<ts>_<sid>.log, every step recorded):
24
+ <ts> [CONNECT-START] sid=... port=... pid=...
25
+ <ts> [CONNECTED] server=SSH-2.0-...
26
+ <ts> [CLIENT] attach from 127.0.0.1:xxxx (1 attached, state=connected)
27
+ <ts> [IN] 'ls\r' <- repr(), escapes visible
28
+ <ts> [OUT] 'HS-API-RELAY:~$ \x1b[6n'
29
+ <ts> [RESIZE] 120x30
30
+ <ts> [DISCONNECTED] why=SSH transport closed
31
+ <ts> [SHUTDOWN] requested by 127.0.0.1:xxxx
32
+
33
+ Run: python ssh_worker.py --id <sid> --port <ssh_port> --ctrl-port 0
34
+ """
35
+
36
+ import argparse
37
+ import base64
38
+ import json
39
+ import os
40
+ import socket
41
+ import stat as stat_mod
42
+ import sys
43
+ import threading
44
+ import time
45
+ import traceback
46
+ from datetime import datetime
47
+ from pathlib import Path
48
+
49
+ import paramiko
50
+ import yaml
51
+
52
+ # Name resolution for uid/gid. On Windows hosts these modules don't
53
+ # exist; fall back to the raw numeric id.
54
+ try:
55
+ import pwd
56
+ import grp
57
+ except ImportError: # pragma: no cover - Windows
58
+ pwd = None
59
+ grp = None
60
+
61
+
62
+ def _uid_name(uid):
63
+ if pwd is None:
64
+ return str(uid)
65
+ try:
66
+ return pwd.getpwuid(uid).pw_name
67
+ except (KeyError, OverflowError):
68
+ return str(uid)
69
+
70
+
71
+ def _gid_name(gid):
72
+ if grp is None:
73
+ return str(gid)
74
+ try:
75
+ return grp.getgrgid(gid).gr_name
76
+ except (KeyError, OverflowError):
77
+ return str(gid)
78
+
79
+ BASE_DIR = Path(__file__).parent.resolve()
80
+ SESSIONS_DIR = BASE_DIR / "sessions"
81
+ LOGS_DIR = BASE_DIR / "logs"
82
+
83
+
84
+ # ------------------------------------------------------------------ #
85
+ # Config
86
+ # ------------------------------------------------------------------ #
87
+ def load_ssh_config():
88
+ with open(BASE_DIR / "config.yml", "r", encoding="utf-8") as f:
89
+ return yaml.safe_load(f).get("ssh", {})
90
+
91
+
92
+ def load_logs_config():
93
+ try:
94
+ with open(BASE_DIR / "config.yml", "r", encoding="utf-8") as f:
95
+ return yaml.safe_load(f).get("logs", {})
96
+ except Exception:
97
+ return {}
98
+
99
+
100
+ # ------------------------------------------------------------------ #
101
+ # Session context file
102
+ # ------------------------------------------------------------------ #
103
+ def save_context(sid, data):
104
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
105
+ ctx = SESSIONS_DIR / f"ctx_{sid}.json"
106
+ tmp = ctx.with_suffix(".tmp")
107
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
108
+ tmp.replace(ctx)
109
+
110
+
111
+ def remove_context(sid):
112
+ p = SESSIONS_DIR / f"ctx_{sid}.json"
113
+ try:
114
+ p.unlink()
115
+ except FileNotFoundError:
116
+ pass
117
+
118
+
119
+ # ------------------------------------------------------------------ #
120
+ # Legacy algorithm lists (embedded O&M relays need them)
121
+ # ------------------------------------------------------------------ #
122
+ LEGACY_KEX = [
123
+ "diffie-hellman-group-exchange-sha1",
124
+ "diffie-hellman-group14-sha1",
125
+ "diffie-hellman-group1-sha1",
126
+ ]
127
+ LEGACY_KEYS = ["ssh-rsa"]
128
+ LEGACY_CIPHERS = [
129
+ "aes128-cbc",
130
+ "aes256-cbc",
131
+ "3des-cbc",
132
+ "arcfour128",
133
+ "arcfour256",
134
+ ]
135
+ LEGACY_MACS = [
136
+ "hmac-sha1",
137
+ "hmac-sha1-96",
138
+ "hmac-md5",
139
+ "hmac-md5-96",
140
+ ]
141
+
142
+
143
+ def make_transport_factory():
144
+ def transport_factory(sock, **kwargs):
145
+ t = paramiko.Transport(sock, **kwargs)
146
+ try:
147
+ t._preferred_kex = list(t._preferred_kex) + [
148
+ a for a in LEGACY_KEX if a not in t._preferred_kex
149
+ ]
150
+ t._preferred_keys = list(t._preferred_keys) + [
151
+ a for a in LEGACY_KEYS if a not in t._preferred_keys
152
+ ]
153
+ t._preferred_ciphers = list(t._preferred_ciphers) + [
154
+ a for a in LEGACY_CIPHERS if a not in t._preferred_ciphers
155
+ ]
156
+ t._preferred_macs = list(t._preferred_macs) + [
157
+ a for a in LEGACY_MACS if a not in t._preferred_macs
158
+ ]
159
+ except Exception:
160
+ pass
161
+ return t
162
+
163
+ return transport_factory
164
+
165
+
166
+ # ------------------------------------------------------------------ #
167
+ # Interaction log - every step of the session, written by the worker
168
+ # process itself so it survives page refreshes / Flask restarts.
169
+ #
170
+ # Format: <ts> [TAG] <payload>
171
+ # Tags: CONNECT-START / CONNECTED / CONNECT-FAILED / IN / OUT /
172
+ # RESIZE / CLIENT / DISCONNECTED / SHUTDOWN / ERROR
173
+ # Payload for IN/OUT uses repr() so escape sequences stay visible
174
+ # (e.g. the busybox DSR query shows as \x1b[6n) for debugging.
175
+ # ------------------------------------------------------------------ #
176
+ class InteractionLog:
177
+ def __init__(self, sid, enabled=True):
178
+ self.enabled = enabled
179
+ self.f = None
180
+ self.path = None
181
+ self.lock = threading.Lock()
182
+ if enabled:
183
+ try:
184
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
185
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
186
+ self.path = LOGS_DIR / f"interactive_{ts}_{sid}.log"
187
+ self.f = open(self.path, "a", encoding="utf-8", errors="backslashreplace")
188
+ except Exception:
189
+ self.enabled = False
190
+ self.f = None
191
+
192
+ def log(self, tag, text=""):
193
+ if not self.enabled or self.f is None:
194
+ return
195
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
196
+ line = f"{ts} [{tag}] {text}\n"
197
+ try:
198
+ with self.lock:
199
+ self.f.write(line)
200
+ self.f.flush()
201
+ except Exception:
202
+ pass
203
+
204
+ def close(self):
205
+ if self.f is not None:
206
+ try:
207
+ self.f.close()
208
+ except Exception:
209
+ pass
210
+ self.f = None
211
+
212
+
213
+ # ------------------------------------------------------------------ #
214
+ # Worker
215
+ # ------------------------------------------------------------------ #
216
+ class Worker:
217
+ def __init__(self, sid, ssh_port, ctrl_port):
218
+ self.sid = sid
219
+ self.ssh_port = ssh_port
220
+ self.ctrl_port = ctrl_port
221
+ self.state = "connecting" # connecting|connected|disconnected
222
+ self.why = ""
223
+ self.client = None
224
+ self.channel = None
225
+ self.transport = None
226
+ self.sftp = None # lazily opened SFTPClient
227
+ self.upload_file = None # current SFTPFile for upload
228
+ self.upload_path = None
229
+ self.upload_total = 0
230
+ self.upload_written = 0
231
+ self.download_thread = None # active download thread (one at a time)
232
+
233
+ self.lock = threading.Lock()
234
+ self.clients = [] # attached control connections
235
+ self.scrollback = [] # full output history for re-attach
236
+ self.seq = 0
237
+
238
+ self.stop = threading.Event()
239
+
240
+ logs_cfg = load_logs_config()
241
+ self.ilog = InteractionLog(sid, enabled=logs_cfg.get("interactive", True))
242
+ self.trace = bool(logs_cfg.get("trace", False))
243
+
244
+ # ---------- context ----------
245
+ def ctx(self, **extra):
246
+ return {
247
+ "sid": self.sid,
248
+ "pid": os.getpid(),
249
+ "ssh_port": self.ssh_port,
250
+ "ctrl_port": self.ctrl_port,
251
+ "state": self.state,
252
+ "created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
253
+ "alive": True,
254
+ **extra,
255
+ }
256
+
257
+ def persist(self):
258
+ save_context(self.sid, self.ctx())
259
+
260
+ # ---------- client fan-out ----------
261
+ def add_client(self, conn):
262
+ with self.lock:
263
+ self.clients.append(conn)
264
+
265
+ def drop_client(self, conn):
266
+ with self.lock:
267
+ if conn in self.clients:
268
+ self.clients.remove(conn)
269
+
270
+ def broadcast(self, obj):
271
+ data = json.dumps(obj, ensure_ascii=False) + "\n"
272
+ dead = []
273
+ with self.lock:
274
+ for c in self.clients:
275
+ try:
276
+ c.sendall(data.encode("utf-8"))
277
+ except Exception:
278
+ dead.append(c)
279
+ for c in dead:
280
+ self.clients.remove(c)
281
+
282
+ def push_output(self, text):
283
+ self.seq += 1
284
+ self.scrollback.append({"seq": self.seq, "data": text})
285
+ # keep memory bounded
286
+ if len(self.scrollback) > 10000:
287
+ del self.scrollback[: len(self.scrollback) - 10000]
288
+ self.ilog.log("OUT", repr(text))
289
+ self.broadcast({"ev": "output", "data": text, "seq": self.seq})
290
+
291
+ # ---------- SFTP ----------
292
+ def get_sftp(self):
293
+ """Lazily open / cache an SFTPClient on the existing SSH transport."""
294
+ if self.sftp is None and self.client:
295
+ self.sftp = self.client.open_sftp()
296
+ return self.sftp
297
+
298
+ def sftp_list(self, path):
299
+ """List remote directory entries. Returns list of dicts with
300
+ permission / owner / modification-time metadata for the
301
+ detail-table view in the UI."""
302
+ sftp = self.get_sftp()
303
+ entries = []
304
+ for entry in sorted(sftp.listdir_attr(path), key=lambda a: a.filename):
305
+ mode = entry.st_mode or 0
306
+ is_dir = stat_mod.S_ISDIR(mode)
307
+ is_link = stat_mod.S_ISLNK(mode)
308
+ mtime = entry.st_mtime or 0
309
+ entries.append({
310
+ "name": entry.filename,
311
+ "size": entry.st_size or 0,
312
+ "is_dir": is_dir,
313
+ "is_link": is_link,
314
+ "mtime": mtime,
315
+ "mtime_str": self._fmt_mtime(mtime),
316
+ "mode": oct(mode),
317
+ "perm": self._perm_to_full(mode, is_dir, is_link),
318
+ "uid": getattr(entry, "st_uid", 0),
319
+ "gid": getattr(entry, "st_gid", 0),
320
+ "owner": _uid_name(getattr(entry, "st_uid", 0)),
321
+ "group": _gid_name(getattr(entry, "st_gid", 0)),
322
+ })
323
+ return entries
324
+
325
+ @staticmethod
326
+ def _fmt_mtime(mtime):
327
+ """Format mtime as YYYY-MM-DD HH:MM (server local time)."""
328
+ try:
329
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
330
+ except (OverflowError, OSError, ValueError):
331
+ return "-"
332
+
333
+ @staticmethod
334
+ def _perm_to_str(mode):
335
+ """Convert a mode int to an rwxrwxrwx string (9 chars, '-' for unset)."""
336
+ if not mode:
337
+ return "---------"
338
+ bits = (
339
+ stat_mod.S_IRUSR, stat_mod.S_IWUSR, stat_mod.S_IXUSR,
340
+ stat_mod.S_IRGRP, stat_mod.S_IWGRP, stat_mod.S_IXGRP,
341
+ stat_mod.S_IROTH, stat_mod.S_IWOTH, stat_mod.S_IXOTH,
342
+ )
343
+ chars = ("r", "w", "x") * 3
344
+ out = []
345
+ for b, ch in zip(bits, chars):
346
+ out.append(ch if (mode & b) else "-")
347
+ return "".join(out)
348
+
349
+ @classmethod
350
+ def _perm_to_full(cls, mode, is_dir, is_link):
351
+ t = "l" if is_link else ("d" if is_dir else "-")
352
+ return t + cls._perm_to_str(mode)
353
+
354
+ def sftp_home(self):
355
+ """Get the SFTP default (home) directory."""
356
+ sftp = self.get_sftp()
357
+ try:
358
+ return sftp.normalize(".")
359
+ except Exception:
360
+ return "/"
361
+
362
+ def sftp_upload_start(self, remote_path, total_size):
363
+ """Open a remote file for writing (overwrite)."""
364
+ # Close any previous unfinished upload
365
+ if self.upload_file is not None:
366
+ try:
367
+ self.upload_file.close()
368
+ except Exception:
369
+ pass
370
+ self.upload_file = None
371
+ sftp = self.get_sftp()
372
+ self.upload_file = sftp.file(remote_path, "wb")
373
+ self.upload_file.set_pipelined(True)
374
+ self.upload_path = remote_path
375
+ self.upload_total = total_size
376
+ self.upload_written = 0
377
+ self.ilog.log("SFTP-UPLOAD-START", f"path={remote_path} size={total_size}")
378
+
379
+ def sftp_upload_chunk(self, b64data):
380
+ """Write a base64-encoded chunk to the current upload file."""
381
+ if self.upload_file is None:
382
+ return
383
+ raw = base64.b64decode(b64data)
384
+ self.upload_file.write(raw)
385
+ self.upload_written += len(raw)
386
+ self.broadcast({
387
+ "ev": "sftp_progress",
388
+ "path": self.upload_path,
389
+ "uploaded": self.upload_written,
390
+ "total": self.upload_total,
391
+ })
392
+
393
+ def sftp_upload_end(self):
394
+ """Flush and close the current upload file."""
395
+ if self.upload_file is not None:
396
+ try:
397
+ self.upload_file.flush()
398
+ self.upload_file.close()
399
+ except Exception:
400
+ pass
401
+ self.ilog.log("SFTP-UPLOAD-DONE",
402
+ f"path={self.upload_path} written={self.upload_written}")
403
+ self.broadcast({
404
+ "ev": "sftp_done",
405
+ "path": self.upload_path,
406
+ "success": True,
407
+ "uploaded": self.upload_written,
408
+ "total": self.upload_total,
409
+ })
410
+ self.upload_file = None
411
+ self.upload_path = None
412
+
413
+ def sftp_mkdir(self, path):
414
+ sftp = self.get_sftp()
415
+ sftp.mkdir(path)
416
+
417
+ # ---------- SFTP download (streaming, chunked) ----------
418
+ #
419
+ # One download at a time per worker. The download runs on its own
420
+ # thread and streams base64 chunks over the control socket, so the
421
+ # terminal keeps working while a file transfer is in flight.
422
+ #
423
+ # Events broadcast to attached clients:
424
+ # sftp_download_begin {path, size}
425
+ # sftp_download_chunk {path, offset, total, data(b64)}
426
+ # sftp_download_done {path, size}
427
+ # sftp_download_error {path, msg}
428
+ def sftp_download_start(self, remote_path):
429
+ if self.download_thread is not None and self.download_thread.is_alive():
430
+ raise RuntimeError("已有下载任务进行中,请稍候")
431
+ sftp = self.get_sftp()
432
+ st = sftp.stat(remote_path)
433
+ if stat_mod.S_ISDIR(st.st_mode or 0):
434
+ raise RuntimeError(f"{remote_path} 是目录,无法下载")
435
+ size = st.st_size or 0
436
+ self.ilog.log("SFTP-DOWNLOAD-START", f"path={remote_path} size={size}")
437
+ self.broadcast({"ev": "sftp_download_begin", "path": remote_path, "size": size})
438
+ t = threading.Thread(
439
+ target=self._download_loop, args=(remote_path, size), daemon=True
440
+ )
441
+ self.download_thread = t
442
+ t.start()
443
+
444
+ def _download_loop(self, remote_path, size):
445
+ try:
446
+ sftp = self.get_sftp()
447
+ f = sftp.file(remote_path, "rb")
448
+ try:
449
+ f.prefetch()
450
+ offset = 0
451
+ chunk_size = 64 * 1024
452
+ last_sent = time.time()
453
+ while offset < size:
454
+ data = f.read(chunk_size)
455
+ if not data:
456
+ break
457
+ self.broadcast({
458
+ "ev": "sftp_download_chunk",
459
+ "path": remote_path,
460
+ "offset": offset,
461
+ "total": size,
462
+ "data": base64.b64encode(data).decode("ascii"),
463
+ })
464
+ offset += len(data)
465
+ # be gentle with embedded relays: pace the stream a
466
+ # little when the remote side is a slow device
467
+ now = time.time()
468
+ if now - last_sent < 0.002:
469
+ time.sleep(0.002)
470
+ last_sent = time.time()
471
+ self.broadcast({
472
+ "ev": "sftp_download_done", "path": remote_path, "size": size
473
+ })
474
+ self.ilog.log("SFTP-DOWNLOAD-DONE",
475
+ f"path={remote_path} sent={offset}/{size}")
476
+ finally:
477
+ try:
478
+ f.close()
479
+ except Exception:
480
+ pass
481
+ except Exception as e:
482
+ self.ilog.log("SFTP-ERROR", f"download {remote_path}: {e!r}")
483
+ self.broadcast({
484
+ "ev": "sftp_download_error", "path": remote_path, "msg": str(e)
485
+ })
486
+ finally:
487
+ self.download_thread = None
488
+
489
+ # ---------- SSH ----------
490
+ def connect_ssh(self):
491
+ self.ilog.log(
492
+ "CONNECT-START", f"sid={self.sid} port={self.ssh_port} pid={os.getpid()}"
493
+ )
494
+ # optional paramiko debug trace (kex/cipher negotiation details)
495
+ if self.trace:
496
+ try:
497
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
498
+ paramiko.util.log_to_file(
499
+ str(LOGS_DIR / f"paramiko_{self.sid}.log"), level="DEBUG"
500
+ )
501
+ except Exception:
502
+ pass
503
+
504
+ cfg = load_ssh_config()
505
+ client = paramiko.SSHClient()
506
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
507
+ kwargs = dict(
508
+ hostname=cfg.get("host", "127.0.0.1"),
509
+ port=self.ssh_port,
510
+ username=cfg.get("username", ""),
511
+ password=cfg.get("password", ""),
512
+ look_for_keys=False,
513
+ allow_agent=False,
514
+ timeout=10,
515
+ transport_factory=make_transport_factory(),
516
+ )
517
+ client.connect(**kwargs)
518
+ self.client = client
519
+ self.transport = client.get_transport()
520
+
521
+ keepalive = float(cfg.get("keepalive_interval", 0))
522
+ if self.transport is not None and keepalive > 0:
523
+ self.transport.set_keepalive(keepalive)
524
+
525
+ self.channel = client.invoke_shell(term="xterm-256color")
526
+ self.state = "connected"
527
+ self.persist()
528
+
529
+ try:
530
+ ver = self.transport.remote_version if self.transport else "?"
531
+ except Exception:
532
+ ver = "?"
533
+ self.ilog.log("CONNECTED", f"server={ver}")
534
+
535
+ # ---------- reader thread ----------
536
+ def reader_loop(self):
537
+ idle = 0.0
538
+ while not self.stop.is_set():
539
+ try:
540
+ dead = self.transport is None or not self.transport.is_active()
541
+ except Exception:
542
+ dead = True
543
+ if dead and idle > 2.0:
544
+ self.state = "disconnected"
545
+ self.why = "SSH transport closed"
546
+ self.ilog.log("DISCONNECTED", f"why={self.why}")
547
+ self.broadcast({"ev": "state", "state": "disconnected", "why": self.why})
548
+ self.persist()
549
+ break
550
+ if self.channel.recv_ready():
551
+ data = self.channel.recv(4096).decode("utf-8", errors="replace")
552
+ idle = 0.0
553
+ self.push_output(data)
554
+ elif self.channel.exit_status_ready() and not self.channel.recv_ready():
555
+ # give late output a moment to arrive before declaring exit
556
+ time.sleep(0.3)
557
+ if self.channel.recv_ready():
558
+ continue
559
+ self.state = "disconnected"
560
+ self.why = "channel exited"
561
+ self.ilog.log("DISCONNECTED", f"why={self.why}")
562
+ self.broadcast({"ev": "state", "state": "disconnected", "why": self.why})
563
+ self.persist()
564
+ break
565
+ else:
566
+ time.sleep(0.02)
567
+ idle += 0.02
568
+
569
+ # ---------- control connection handler ----------
570
+ def handle_client(self, conn, addr):
571
+ # IMPORTANT: register BEFORE sending hello, otherwise broadcast()
572
+ # has no receivers and live output never reaches this client
573
+ # (they would only ever see stale scrollback replays).
574
+ self.add_client(conn)
575
+ f = conn.makefile("r", encoding="utf-8", errors="replace")
576
+ try:
577
+ with self.lock:
578
+ n = len(self.clients)
579
+ self.ilog.log(
580
+ "CLIENT",
581
+ f"attach from {addr[0]}:{addr[1]} ({n} attached, state={self.state})",
582
+ )
583
+ conn.sendall(
584
+ (json.dumps({
585
+ "ev": "hello",
586
+ "sid": self.sid,
587
+ "state": self.state,
588
+ "seq": self.seq,
589
+ "scrollback": self.scrollback[-1000:],
590
+ }, ensure_ascii=False) + "\n").encode("utf-8")
591
+ )
592
+ for line in f:
593
+ line = line.strip()
594
+ if not line:
595
+ continue
596
+ try:
597
+ msg = json.loads(line)
598
+ except (json.JSONDecodeError, ValueError):
599
+ continue
600
+ op = msg.get("op")
601
+ if op == "input":
602
+ data = msg.get("data", "")
603
+ self.ilog.log("IN", repr(data))
604
+ try:
605
+ self.channel.send(data)
606
+ except Exception as e:
607
+ self.ilog.log("ERROR", f"channel.send failed: {e!r}")
608
+ elif op == "resize":
609
+ cols = int(msg.get("cols", 80))
610
+ rows = int(msg.get("rows", 24))
611
+ self.ilog.log("RESIZE", f"{cols}x{rows}")
612
+ try:
613
+ self.channel.resize_pty(width=cols, height=rows)
614
+ except Exception as e:
615
+ self.ilog.log("ERROR", f"resize_pty failed: {e!r}")
616
+ elif op == "detach":
617
+ self.ilog.log("CLIENT", f"detach from {addr[0]}:{addr[1]}")
618
+ break
619
+ elif op == "shutdown":
620
+ self.ilog.log("SHUTDOWN", f"requested by {addr[0]}:{addr[1]}")
621
+ self.stop.set()
622
+ self.broadcast({"ev": "bye"})
623
+ break
624
+ elif op == "sftp_list":
625
+ path = msg.get("path", "/")
626
+ try:
627
+ entries = self.sftp_list(path)
628
+ self.broadcast({"ev": "sftp_list", "path": path,
629
+ "entries": entries})
630
+ except Exception as e:
631
+ self.ilog.log("SFTP-ERROR", f"list {path}: {e!r}")
632
+ self.broadcast({"ev": "sftp_error",
633
+ "msg": f"列出目录失败: {e}"})
634
+ elif op == "sftp_home":
635
+ try:
636
+ home = self.sftp_home()
637
+ self.broadcast({"ev": "sftp_home", "path": home})
638
+ except Exception as e:
639
+ self.broadcast({"ev": "sftp_error",
640
+ "msg": f"获取主目录失败: {e}"})
641
+ elif op == "sftp_upload_start":
642
+ remote_path = msg.get("path", "")
643
+ total = int(msg.get("size", 0))
644
+ try:
645
+ self.sftp_upload_start(remote_path, total)
646
+ self.broadcast({"ev": "sftp_progress", "path": remote_path,
647
+ "uploaded": 0, "total": total})
648
+ except Exception as e:
649
+ self.ilog.log("SFTP-ERROR", f"upload_start {remote_path}: {e!r}")
650
+ self.broadcast({"ev": "sftp_error",
651
+ "msg": f"开始上传失败: {e}"})
652
+ elif op == "sftp_upload_chunk":
653
+ try:
654
+ self.sftp_upload_chunk(msg.get("data", ""))
655
+ except Exception as e:
656
+ self.ilog.log("SFTP-ERROR", f"upload_chunk: {e!r}")
657
+ self.broadcast({"ev": "sftp_error",
658
+ "msg": f"上传数据写入失败: {e}"})
659
+ elif op == "sftp_upload_end":
660
+ try:
661
+ self.sftp_upload_end()
662
+ except Exception as e:
663
+ self.ilog.log("SFTP-ERROR", f"upload_end: {e!r}")
664
+ self.broadcast({"ev": "sftp_error",
665
+ "msg": f"结束上传失败: {e}"})
666
+ elif op == "sftp_mkdir":
667
+ path = msg.get("path", "")
668
+ try:
669
+ self.sftp_mkdir(path)
670
+ self.broadcast({"ev": "sftp_mkdir_done", "path": path})
671
+ except Exception as e:
672
+ self.broadcast({"ev": "sftp_error",
673
+ "msg": f"创建目录失败: {e}"})
674
+ elif op == "sftp_download_start":
675
+ remote_path = msg.get("path", "")
676
+ try:
677
+ self.sftp_download_start(remote_path)
678
+ except Exception as e:
679
+ self.ilog.log("SFTP-ERROR", f"download_start {remote_path}: {e!r}")
680
+ self.broadcast({"ev": "sftp_download_error",
681
+ "path": remote_path,
682
+ "msg": f"开始下载失败: {e}"})
683
+ except Exception as e:
684
+ self.ilog.log("ERROR", f"control conn {addr} error: {e!r}")
685
+ finally:
686
+ self.drop_client(conn)
687
+ try:
688
+ conn.close()
689
+ except Exception:
690
+ pass
691
+
692
+ # ---------- main ----------
693
+ def run(self):
694
+ # 1) bind control socket FIRST (port 0 = auto-assign), so Flask
695
+ # can learn our port even if SSH connect is slow/failing.
696
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
697
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
698
+ srv.bind(("127.0.0.1", self.ctrl_port))
699
+ srv.listen(8)
700
+ self.ctrl_port = srv.getsockname()[1]
701
+ self.persist()
702
+
703
+ # 2) connect SSH (state file reflects connecting|connected|failed)
704
+ try:
705
+ self.connect_ssh()
706
+ except Exception as e:
707
+ self.state = "disconnected"
708
+ self.why = f"SSH connect failed: {e}"
709
+ self.ilog.log("CONNECT-FAILED", self.why)
710
+ self.persist()
711
+ # give Flask a moment to read the context, then exit
712
+ time.sleep(3)
713
+ remove_context(self.sid)
714
+ srv.close()
715
+ self.ilog.close()
716
+ return
717
+
718
+ threading.Thread(target=self.reader_loop, daemon=True).start()
719
+
720
+ # 3) accept control connections until session ends
721
+ try:
722
+ srv.settimeout(1.0)
723
+ while not self.stop.is_set() and self.state != "disconnected":
724
+ try:
725
+ conn, addr = srv.accept()
726
+ except socket.timeout:
727
+ continue
728
+ threading.Thread(
729
+ target=self.handle_client, args=(conn, addr), daemon=True
730
+ ).start()
731
+ finally:
732
+ self.state = "disconnected"
733
+ self.persist()
734
+ self.ilog.log("DISCONNECTED", f"why={self.why or 'worker exit'}")
735
+ # give attached clients (Flask -> browser proxy) a moment to
736
+ # receive the "disconnected" state event before we tear down
737
+ # the control socket; otherwise the browser only ever sees a
738
+ # bare WebSocket close with no reason.
739
+ try:
740
+ time.sleep(0.7)
741
+ except Exception:
742
+ pass
743
+ try:
744
+ self.channel.close()
745
+ except Exception:
746
+ pass
747
+ try:
748
+ if self.sftp:
749
+ self.sftp.close()
750
+ except Exception:
751
+ pass
752
+ try:
753
+ if self.upload_file:
754
+ self.upload_file.close()
755
+ except Exception:
756
+ pass
757
+ try:
758
+ self.client.close()
759
+ except Exception:
760
+ pass
761
+ remove_context(self.sid)
762
+ srv.close()
763
+ self.ilog.close()
764
+
765
+
766
+ def main():
767
+ ap = argparse.ArgumentParser()
768
+ ap.add_argument("--id", required=True)
769
+ ap.add_argument("--port", type=int, required=True, help="SSH target port")
770
+ ap.add_argument("--ctrl-port", type=int, default=0, help="control listen port (0=auto)")
771
+ args = ap.parse_args()
772
+
773
+ w = Worker(args.id, args.port, args.ctrl_port)
774
+ try:
775
+ w.run()
776
+ except Exception:
777
+ traceback.print_exc()
778
+ remove_context(args.id)
779
+
780
+
781
+ if __name__ == "__main__":
782
+ main()