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
|
@@ -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()
|