pygpt-net 2.6.19__py3-none-any.whl → 2.6.20__py3-none-any.whl

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,1057 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.08.22 10:00:00 #
10
+ # ================================================== #
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shlex
16
+ import subprocess
17
+ import ftplib
18
+ import smtplib
19
+ import time
20
+ import stat as pystat
21
+
22
+ from email.message import EmailMessage
23
+ from PySide6.QtCore import Slot
24
+
25
+ from pygpt_net.plugin.base.worker import BaseWorker, BaseSignals
26
+
27
+
28
+ class WorkerSignals(BaseSignals):
29
+ pass
30
+
31
+
32
+ class Worker(BaseWorker):
33
+ """
34
+ Server plugin worker: SSH, SFTP, FTP, Telnet, SMTP.
35
+ Credentials are fetched via self.get_server_config(server, port).
36
+ When prefer_system_ssh is enabled, uses system ssh/scp/sftp and system keys.
37
+ """
38
+
39
+ # Global map SERVICE -> PORT (defaults)
40
+ SERVICE_PORTS = {
41
+ "ssh": 22,
42
+ "sftp": 22,
43
+ "ftp": 21,
44
+ "telnet": 23,
45
+ "smtp": 25,
46
+ "smtps": 465,
47
+ "submission": 587,
48
+ }
49
+
50
+ def __init__(self, *args, **kwargs):
51
+ super(Worker, self).__init__()
52
+ self.signals = WorkerSignals()
53
+ self.args = args
54
+ self.kwargs = kwargs
55
+ self.plugin = None
56
+ self.cmds = None
57
+ self.ctx = None
58
+ self.msg = None
59
+
60
+ # ---------------------- Runner ----------------------
61
+
62
+ @Slot()
63
+ def run(self):
64
+ try:
65
+ responses = []
66
+ for item in self.cmds:
67
+ if self.is_stopped():
68
+ break
69
+ try:
70
+ response = None
71
+ if item["cmd"] in self.plugin.allowed_cmds and self.plugin.has_cmd(item["cmd"]):
72
+
73
+ # -------- Core FS / Exec --------
74
+ if item["cmd"] == "srv_exec":
75
+ response = self.cmd_srv_exec(item)
76
+ elif item["cmd"] == "srv_ls":
77
+ response = self.cmd_srv_ls(item)
78
+ elif item["cmd"] == "srv_get":
79
+ response = self.cmd_srv_get(item)
80
+ elif item["cmd"] == "srv_put":
81
+ response = self.cmd_srv_put(item)
82
+ elif item["cmd"] == "srv_rm":
83
+ response = self.cmd_srv_rm(item)
84
+ elif item["cmd"] == "srv_mkdir":
85
+ response = self.cmd_srv_mkdir(item)
86
+ elif item["cmd"] == "srv_stat":
87
+ response = self.cmd_srv_stat(item)
88
+
89
+ # -------- SMTP --------
90
+ elif item["cmd"] == "smtp_send":
91
+ response = self.cmd_smtp_send(item)
92
+
93
+ if response:
94
+ responses.append(response)
95
+
96
+ except Exception as e:
97
+ responses.append(self.make_response(item, self.throw_error(e)))
98
+
99
+ if responses:
100
+ self.reply_more(responses)
101
+ if self.msg is not None:
102
+ self.status(self.msg)
103
+ except Exception as e:
104
+ self.error(e)
105
+ finally:
106
+ self.cleanup()
107
+
108
+ # ---------------------- Common helpers ----------------------
109
+
110
+ def _timeout(self) -> int:
111
+ try:
112
+ return int(self.plugin.get_option_value("net_timeout") or 30)
113
+ except Exception:
114
+ return 30
115
+
116
+ def prepare_path(self, path: str) -> str:
117
+ if path in [".", "./"]:
118
+ return self.plugin.window.core.config.get_user_dir("data")
119
+ if self.is_absolute_path(path):
120
+ return path
121
+ return os.path.join(self.plugin.window.core.config.get_user_dir("data"), path)
122
+
123
+ def is_absolute_path(self, path: str) -> bool:
124
+ return os.path.isabs(path)
125
+
126
+ def _prefer_system_ssh(self) -> bool:
127
+ return bool(self.plugin.get_option_value("prefer_system_ssh") or False)
128
+
129
+ def _ssh_bins(self) -> dict:
130
+ return {
131
+ "ssh": (self.plugin.get_option_value("ssh_binary") or "ssh").strip(),
132
+ "scp": (self.plugin.get_option_value("scp_binary") or "scp").strip(),
133
+ "sftp": (self.plugin.get_option_value("sftp_binary") or "sftp").strip(),
134
+ }
135
+
136
+ def _ssh_options(self) -> list[str]:
137
+ # Always enforce BatchMode=yes to avoid hanging on password prompt
138
+ extra = (self.plugin.get_option_value("ssh_options") or "").strip()
139
+ opts = ["-o", "BatchMode=yes"]
140
+ if extra:
141
+ opts += shlex.split(extra)
142
+ return opts
143
+
144
+ def _ssh_auto_add_hostkey(self) -> bool:
145
+ return bool(self.plugin.get_option_value("ssh_auto_add_hostkey") or True)
146
+
147
+ def _ftp_use_tls_default(self) -> bool:
148
+ return bool(self.plugin.get_option_value("ftp_use_tls_default") or False)
149
+
150
+ def _ftp_passive_default(self) -> bool:
151
+ return bool(self.plugin.get_option_value("ftp_passive_default") or True)
152
+
153
+ def _smtp_defaults(self) -> dict:
154
+ return {
155
+ "use_tls": bool(self.plugin.get_option_value("smtp_use_tls_default") or True),
156
+ "use_ssl": bool(self.plugin.get_option_value("smtp_use_ssl_default") or False),
157
+ "from_addr": (self.plugin.get_option_value("smtp_from_default") or "").strip(),
158
+ }
159
+
160
+ def _ensure_paramiko(self):
161
+ try:
162
+ import paramiko # noqa
163
+ except Exception:
164
+ raise RuntimeError("Paramiko not installed. Install: pip install paramiko")
165
+
166
+ def _service_from_port(self, port: int) -> str:
167
+ for k, v in self.SERVICE_PORTS.items():
168
+ if int(v) == int(port):
169
+ return k
170
+ return "unknown"
171
+
172
+ def _server_config(self, server: str, port: int) -> dict:
173
+ cfg = self.get_server_config(server, int(port)) # raises RuntimeError with available server names if not found
174
+ if not cfg.get("server") or not cfg.get("login"):
175
+ raise RuntimeError("Server config incomplete (server/login).")
176
+ # Never expose credentials
177
+ return cfg
178
+
179
+ def _shell_quote(self, s: str) -> str:
180
+ return shlex.quote(str(s))
181
+
182
+ def _build_remote_cmd(self, command: str, cwd: str | None = None, env: dict | None = None) -> str:
183
+ parts = []
184
+ if env:
185
+ exports = " ".join([f'{k}={self._shell_quote(v)}' for k, v in env.items()])
186
+ parts.append(f"export {exports}")
187
+ if cwd:
188
+ parts.append(f"cd {self._shell_quote(cwd)}")
189
+ parts.append(command)
190
+ joined = " && ".join(parts)
191
+ # Use bash -lc to ensure login-like shell behaviors (PATH, etc.)
192
+ return f"bash -lc {self._shell_quote(joined)}"
193
+
194
+ def get_server_config(self, server: str, port: int) -> dict:
195
+ """
196
+ Get server configuration for given server and port.
197
+
198
+ :param server: server name or host
199
+ :param port: server port
200
+ :return: dict with server configuration (server, login, password, port)
201
+ """
202
+ servers = self.plugin.get_option_value("servers")
203
+ available_ids = []
204
+ for srv in servers:
205
+ if not srv.get("enabled"):
206
+ continue
207
+ available_ids.append(srv.get("name"))
208
+ config = {}
209
+ for srv in servers:
210
+ if not srv.get("enabled"):
211
+ continue
212
+ if ((srv.get("name").lower() == server.lower()
213
+ or srv.get("host").lower() == server.lower())
214
+ and int(srv.get("port", 0)) == port):
215
+ config = {
216
+ "server": srv.get("host"),
217
+ "login": srv.get("login"),
218
+ "password": srv.get("password"),
219
+ "port": int(srv.get("port", 0)),
220
+ }
221
+ break
222
+ if not config:
223
+ raise RuntimeError(
224
+ f"Server configuration not found for {server}:{port}. Available servers: {', '.join(available_ids)}")
225
+ return config
226
+
227
+ # ---------------------- SSH (system) ----------------------
228
+
229
+ def _ssh_exec_system(self, host: str, port: int, user: str, command: str, cwd: str | None,
230
+ env: dict | None) -> dict:
231
+ bins = self._ssh_bins()
232
+ base = [bins["ssh"]] + self._ssh_options() + ["-p", str(port)]
233
+ remote = f"{user}@{host}"
234
+ rcmd = self._build_remote_cmd(command, cwd=cwd, env=env)
235
+ try:
236
+ r = subprocess.run(base + [remote, rcmd], capture_output=True, text=True, timeout=self._timeout())
237
+ return {"rc": r.returncode, "stdout": r.stdout, "stderr": r.stderr}
238
+ except subprocess.TimeoutExpired as e:
239
+ raise RuntimeError(f"SSH timeout after {self._timeout()}s: {e}")
240
+ except FileNotFoundError:
241
+ raise RuntimeError("ssh binary not found.")
242
+
243
+ def _scp_get_system(self, host: str, port: int, user: str, remote_path: str, local_path: str) -> dict:
244
+ bins = self._ssh_bins()
245
+ base = [bins["scp"]] + self._ssh_options() + ["-P", str(port)]
246
+ remote = f"{user}@{host}:{remote_path}"
247
+ os.makedirs(os.path.dirname(local_path) or ".", exist_ok=True)
248
+ try:
249
+ r = subprocess.run(base + [remote, local_path], capture_output=True, text=True, timeout=self._timeout())
250
+ if r.returncode != 0:
251
+ raise RuntimeError(f"SCP get failed: {r.stderr.strip()}")
252
+ size = os.path.getsize(local_path) if os.path.exists(local_path) else None
253
+ return {"saved_path": local_path, "size": size}
254
+ except subprocess.TimeoutExpired as e:
255
+ raise RuntimeError(f"SCP timeout after {self._timeout()}s: {e}")
256
+ except FileNotFoundError:
257
+ raise RuntimeError("scp binary not found.")
258
+
259
+ def _scp_put_system(self, host: str, port: int, user: str, local_path: str, remote_path: str) -> dict:
260
+ bins = self._ssh_bins()
261
+ base = [bins["scp"]] + self._ssh_options() + ["-P", str(port)]
262
+ if not os.path.exists(local_path):
263
+ raise RuntimeError(f"Local path not found: {local_path}")
264
+ remote = f"{user}@{host}:{remote_path}"
265
+ try:
266
+ r = subprocess.run(base + [local_path, remote], capture_output=True, text=True, timeout=self._timeout())
267
+ if r.returncode != 0:
268
+ raise RuntimeError(f"SCP put failed: {r.stderr.strip()}")
269
+ return {"uploaded": True, "source": local_path, "dest": remote_path}
270
+ except subprocess.TimeoutExpired as e:
271
+ raise RuntimeError(f"SCP timeout after {self._timeout()}s: {e}")
272
+ except FileNotFoundError:
273
+ raise RuntimeError("scp binary not found.")
274
+
275
+ def _ssh_ls_system(self, host: str, port: int, user: str, path: str | None) -> dict:
276
+ # Best-effort parse of `ls -la`. Not perfect on all systems.
277
+ p = path or "."
278
+ cmd = f'ls -la {self._shell_quote(p)}'
279
+ res = self._ssh_exec_system(host, port, user, cmd, cwd=None, env=None)
280
+ if res["rc"] != 0:
281
+ raise RuntimeError(f"ls failed: {res['stderr'].strip()}")
282
+ entries = []
283
+ for line in res["stdout"].splitlines():
284
+ if not line or line.startswith("total "):
285
+ continue
286
+ parts = line.split(maxsplit=8)
287
+ if len(parts) < 9:
288
+ continue
289
+ perms, _, owner, group, size, month, day, time_or_year, name = parts
290
+ if name in [".", ".."]:
291
+ continue
292
+ ftype = "file"
293
+ if perms.startswith("d"):
294
+ ftype = "dir"
295
+ elif perms.startswith("l"):
296
+ ftype = "link"
297
+ # size may fail to int if localized
298
+ try:
299
+ size_val = int(size)
300
+ except Exception:
301
+ size_val = None
302
+ entries.append({"name": name, "type": ftype, "size": size_val, "raw": line})
303
+ return {"entries": entries, "raw": res["stdout"]}
304
+
305
+ # ---------------------- SSH / SFTP (paramiko) ----------------------
306
+
307
+ def _ssh_exec_paramiko(self, host: str, port: int, user: str, password: str | None, command: str, cwd: str | None,
308
+ env: dict | None) -> dict:
309
+ self._ensure_paramiko()
310
+ import paramiko
311
+ client = paramiko.SSHClient()
312
+ if self._ssh_auto_add_hostkey():
313
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
314
+ try:
315
+ client.connect(
316
+ host,
317
+ port=port,
318
+ username=user,
319
+ password=password or None,
320
+ timeout=self._timeout(),
321
+ allow_agent=True,
322
+ look_for_keys=True,
323
+ )
324
+ rcmd = self._build_remote_cmd(command, cwd=cwd, env=env)
325
+ stdin, stdout, stderr = client.exec_command(rcmd, timeout=self._timeout())
326
+ out = stdout.read().decode("utf-8", errors="replace")
327
+ err = stderr.read().decode("utf-8", errors="replace")
328
+ rc = stdout.channel.recv_exit_status()
329
+ return {"rc": rc, "stdout": out, "stderr": err}
330
+ finally:
331
+ try:
332
+ client.close()
333
+ except Exception:
334
+ pass
335
+
336
+ def _sftp_opener(self, host: str, port: int, user: str, password: str | None):
337
+ self._ensure_paramiko()
338
+ import paramiko
339
+ client = paramiko.SSHClient()
340
+ if self._ssh_auto_add_hostkey():
341
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
342
+ client.connect(
343
+ host,
344
+ port=port,
345
+ username=user,
346
+ password=password or None,
347
+ timeout=self._timeout(),
348
+ allow_agent=True,
349
+ look_for_keys=True,
350
+ )
351
+ sftp = client.open_sftp()
352
+ return client, sftp
353
+
354
+ def _sftp_ls(self, host: str, port: int, user: str, password: str | None, path: str | None) -> dict:
355
+ client, sftp = self._sftp_opener(host, port, user, password)
356
+ try:
357
+ p = path or "."
358
+ attrs = sftp.listdir_attr(p)
359
+ out = []
360
+ for a in attrs:
361
+ name = getattr(a, "filename", None)
362
+ mode = a.st_mode if hasattr(a, "st_mode") else 0
363
+ ftype = "file"
364
+ if pystat.S_ISDIR(mode):
365
+ ftype = "dir"
366
+ elif pystat.S_ISLNK(mode):
367
+ ftype = "link"
368
+ out.append({
369
+ "name": name,
370
+ "type": ftype,
371
+ "size": getattr(a, "st_size", None),
372
+ "mtime": getattr(a, "st_mtime", None),
373
+ "mode": mode,
374
+ })
375
+ return {"entries": out}
376
+ finally:
377
+ try:
378
+ sftp.close()
379
+ except Exception:
380
+ pass
381
+ try:
382
+ client.close()
383
+ except Exception:
384
+ pass
385
+
386
+ def _sftp_get(self, host: str, port: int, user: str, password: str | None, remote_path: str,
387
+ local_path: str) -> dict:
388
+ client, sftp = self._sftp_opener(host, port, user, password)
389
+ try:
390
+ os.makedirs(os.path.dirname(local_path) or ".", exist_ok=True)
391
+ sftp.get(remote_path, local_path)
392
+ size = os.path.getsize(local_path)
393
+ return {"saved_path": local_path, "size": size}
394
+ finally:
395
+ try:
396
+ sftp.close()
397
+ except Exception:
398
+ pass
399
+ try:
400
+ client.close()
401
+ except Exception:
402
+ pass
403
+
404
+ def _sftp_put(self, host: str, port: int, user: str, password: str | None, local_path: str, remote_path: str,
405
+ make_dirs: bool) -> dict:
406
+ if not os.path.exists(local_path):
407
+ raise RuntimeError(f"Local path not found: {local_path}")
408
+ client, sftp = self._sftp_opener(host, port, user, password)
409
+ try:
410
+ if make_dirs:
411
+ # naive mkdir -p
412
+ dir_part = os.path.dirname(remote_path)
413
+ if dir_part:
414
+ self._sftp_mkdirs(sftp, dir_part)
415
+ sftp.put(local_path, remote_path)
416
+ return {"uploaded": True, "source": local_path, "dest": remote_path}
417
+ finally:
418
+ try:
419
+ sftp.close()
420
+ except Exception:
421
+ pass
422
+ try:
423
+ client.close()
424
+ except Exception:
425
+ pass
426
+
427
+ def _sftp_mkdirs(self, sftp, remote_dir: str):
428
+ parts = []
429
+ while True:
430
+ head, tail = os.path.split(remote_dir)
431
+ if tail:
432
+ parts.append(tail)
433
+ remote_dir = head
434
+ else:
435
+ if head:
436
+ parts.append(head)
437
+ break
438
+ parts = list(reversed([p for p in parts if p not in ["", "/"]]))
439
+ cur = ""
440
+ for p in parts:
441
+ if cur == "":
442
+ if remote_dir.startswith("/"):
443
+ cur = f"/{p}"
444
+ else:
445
+ cur = p
446
+ else:
447
+ cur = f"{cur}/{p}"
448
+ try:
449
+ sftp.stat(cur)
450
+ except Exception:
451
+ try:
452
+ sftp.mkdir(cur)
453
+ except Exception:
454
+ pass
455
+
456
+ def _sftp_rm(self, host: str, port: int, user: str, password: str | None, remote_path: str) -> dict:
457
+ client, sftp = self._sftp_opener(host, port, user, password)
458
+ try:
459
+ st = sftp.stat(remote_path)
460
+ if pystat.S_ISDIR(st.st_mode):
461
+ # Non-recursive rmdir
462
+ sftp.rmdir(remote_path)
463
+ return {"removed": True, "path": remote_path, "type": "dir"}
464
+ else:
465
+ sftp.remove(remote_path)
466
+ return {"removed": True, "path": remote_path, "type": "file"}
467
+ finally:
468
+ try:
469
+ sftp.close()
470
+ except Exception:
471
+ pass
472
+ try:
473
+ client.close()
474
+ except Exception:
475
+ pass
476
+
477
+ def _sftp_mkdir(self, host: str, port: int, user: str, password: str | None, remote_path: str,
478
+ exist_ok: bool) -> dict:
479
+ client, sftp = self._sftp_opener(host, port, user, password)
480
+ try:
481
+ try:
482
+ sftp.mkdir(remote_path)
483
+ except IOError:
484
+ if not exist_ok:
485
+ raise
486
+ return {"mkdir": True, "path": remote_path}
487
+ finally:
488
+ try:
489
+ sftp.close()
490
+ except Exception:
491
+ pass
492
+ try:
493
+ client.close()
494
+ except Exception:
495
+ pass
496
+
497
+ def _sftp_stat(self, host: str, port: int, user: str, password: str | None, remote_path: str) -> dict:
498
+ client, sftp = self._sftp_opener(host, port, user, password)
499
+ try:
500
+ st = sftp.stat(remote_path)
501
+ mode = st.st_mode
502
+ ftype = "file"
503
+ if pystat.S_ISDIR(mode):
504
+ ftype = "dir"
505
+ elif pystat.S_ISLNK(mode):
506
+ ftype = "link"
507
+ return {
508
+ "path": remote_path,
509
+ "type": ftype,
510
+ "size": getattr(st, "st_size", None),
511
+ "mtime": getattr(st, "st_mtime", None),
512
+ "mode": mode,
513
+ }
514
+ finally:
515
+ try:
516
+ sftp.close()
517
+ except Exception:
518
+ pass
519
+ try:
520
+ client.close()
521
+ except Exception:
522
+ pass
523
+
524
+ # ---------------------- FTP / FTPS (stdlib) ----------------------
525
+
526
+ def _ftp_connect(self, host: str, port: int, user: str, password: str | None, use_tls: bool, passive: bool):
527
+ if use_tls:
528
+ ftp = ftplib.FTP_TLS()
529
+ else:
530
+ ftp = ftplib.FTP()
531
+ ftp.connect(host, port, timeout=self._timeout())
532
+ ftp.login(user=user, passwd=password or "")
533
+ if use_tls:
534
+ try:
535
+ ftp.prot_p() # secure data channel
536
+ except Exception:
537
+ pass
538
+ ftp.set_pasv(passive)
539
+ return ftp
540
+
541
+ def _ftp_ls(self, host: str, port: int, user: str, password: str | None, path: str | None) -> dict:
542
+ use_tls = self._ftp_use_tls_default() or (int(port) in [990])
543
+ passive = self._ftp_passive_default()
544
+ ftp = self._ftp_connect(host, port, user, password, use_tls, passive)
545
+ try:
546
+ p = path or "."
547
+ entries = []
548
+ # Prefer MLSD if supported
549
+ try:
550
+ for name, facts in ftp.mlsd(p):
551
+ ftype = facts.get("type", "file")
552
+ if ftype == "dir":
553
+ t = "dir"
554
+ elif ftype == "file":
555
+ t = "file"
556
+ else:
557
+ t = ftype
558
+ size = None
559
+ try:
560
+ size = int(facts.get("size")) if "size" in facts else None
561
+ except Exception:
562
+ pass
563
+ mtime = None
564
+ try:
565
+ # facts['modify'] like YYYYMMDDHHMMSS
566
+ mod = facts.get("modify")
567
+ if mod and len(mod) >= 14:
568
+ # convert to epoch best-effort
569
+ mtime = int(time.mktime(time.strptime(mod, "%Y%m%d%H%M%S")))
570
+ except Exception:
571
+ pass
572
+ entries.append({"name": name, "type": t, "size": size, "mtime": mtime})
573
+ except Exception:
574
+ # Fallback to NLST (names only)
575
+ names = ftp.nlst(p)
576
+ for name in names:
577
+ base = os.path.basename(name)
578
+ entries.append({"name": base, "type": "unknown"})
579
+ return {"entries": entries}
580
+ finally:
581
+ try:
582
+ ftp.quit()
583
+ except Exception:
584
+ pass
585
+
586
+ def _ftp_get(self, host: str, port: int, user: str, password: str | None, remote_path: str,
587
+ local_path: str) -> dict:
588
+ use_tls = self._ftp_use_tls_default() or (int(port) in [990])
589
+ passive = self._ftp_passive_default()
590
+ ftp = self._ftp_connect(host, port, user, password, use_tls, passive)
591
+ try:
592
+ os.makedirs(os.path.dirname(local_path) or ".", exist_ok=True)
593
+ with open(local_path, "wb") as fh:
594
+ ftp.retrbinary(f"RETR {remote_path}", fh.write)
595
+ size = os.path.getsize(local_path)
596
+ return {"saved_path": local_path, "size": size}
597
+ finally:
598
+ try:
599
+ ftp.quit()
600
+ except Exception:
601
+ pass
602
+
603
+ def _ftp_put(self, host: str, port: int, user: str, password: str | None, local_path: str,
604
+ remote_path: str) -> dict:
605
+ if not os.path.exists(local_path):
606
+ raise RuntimeError(f"Local path not found: {local_path}")
607
+ use_tls = self._ftp_use_tls_default() or (int(port) in [990])
608
+ passive = self._ftp_passive_default()
609
+ ftp = self._ftp_connect(host, port, user, password, use_tls, passive)
610
+ try:
611
+ with open(local_path, "rb") as fh:
612
+ ftp.storbinary(f"STOR {remote_path}", fh)
613
+ return {"uploaded": True, "source": local_path, "dest": remote_path}
614
+ finally:
615
+ try:
616
+ ftp.quit()
617
+ except Exception:
618
+ pass
619
+
620
+ def _ftp_rm(self, host: str, port: int, user: str, password: str | None, remote_path: str) -> dict:
621
+ use_tls = self._ftp_use_tls_default() or (int(port) in [990])
622
+ passive = self._ftp_passive_default()
623
+ ftp = self._ftp_connect(host, port, user, password, use_tls, passive)
624
+ try:
625
+ try:
626
+ ftp.delete(remote_path)
627
+ return {"removed": True, "path": remote_path, "type": "file"}
628
+ except ftplib.error_perm:
629
+ # maybe it's a dir (non-recursive)
630
+ ftp.rmd(remote_path)
631
+ return {"removed": True, "path": remote_path, "type": "dir"}
632
+ finally:
633
+ try:
634
+ ftp.quit()
635
+ except Exception:
636
+ pass
637
+
638
+ def _ftp_mkdir(self, host: str, port: int, user: str, password: str | None, remote_path: str,
639
+ exist_ok: bool) -> dict:
640
+ use_tls = self._ftp_use_tls_default() or (int(port) in [990])
641
+ passive = self._ftp_passive_default()
642
+ ftp = self._ftp_connect(host, port, user, password, use_tls, passive)
643
+ try:
644
+ try:
645
+ ftp.mkd(remote_path)
646
+ except ftplib.error_perm:
647
+ if not exist_ok:
648
+ raise
649
+ return {"mkdir": True, "path": remote_path}
650
+ finally:
651
+ try:
652
+ ftp.quit()
653
+ except Exception:
654
+ pass
655
+
656
+ def _ftp_stat(self, host: str, port: int, user: str, password: str | None, remote_path: str) -> dict:
657
+ use_tls = self._ftp_use_tls_default() or (int(port) in [990])
658
+ passive = self._ftp_passive_default()
659
+ ftp = self._ftp_connect(host, port, user, password, use_tls, passive)
660
+ try:
661
+ # Try MLST single path
662
+ try:
663
+ resp = []
664
+ ftp.sendcmd("TYPE I") # binary
665
+ ftp.sendcmd(f"MLST {remote_path}")
666
+ # ftplib doesn't parse MLST easily; use voidcmd and handle later is messy.
667
+ except Exception:
668
+ pass
669
+ # Fallback: list parent and find entry
670
+ parent = os.path.dirname(remote_path) or "."
671
+ name = os.path.basename(remote_path)
672
+ info = None
673
+ try:
674
+ for n, facts in ftp.mlsd(parent):
675
+ if n == name:
676
+ info = facts
677
+ break
678
+ except Exception:
679
+ pass
680
+ if info:
681
+ t = info.get("type", "file")
682
+ ftype = "dir" if t == "dir" else ("file" if t == "file" else t)
683
+ size = None
684
+ try:
685
+ size = int(info.get("size")) if "size" in info else None
686
+ except Exception:
687
+ pass
688
+ mtime = None
689
+ try:
690
+ mod = info.get("modify")
691
+ if mod and len(mod) >= 14:
692
+ mtime = int(time.mktime(time.strptime(mod, "%Y%m%d%H%M%S")))
693
+ except Exception:
694
+ pass
695
+ return {"path": remote_path, "type": ftype, "size": size, "mtime": mtime}
696
+ # As last resort, try size cmd (files only)
697
+ try:
698
+ ftp.sendcmd("TYPE I")
699
+ size = ftp.size(remote_path)
700
+ return {"path": remote_path, "type": "file", "size": size}
701
+ except Exception:
702
+ pass
703
+ # Unknown
704
+ return {"path": remote_path, "type": "unknown"}
705
+ finally:
706
+ try:
707
+ ftp.quit()
708
+ except Exception:
709
+ pass
710
+
711
+ # ---------------------- Telnet (stdlib, best-effort) ----------------------
712
+
713
+ def _telnet_exec(self, host: str, port: int, user: str, password: str | None, command: str, login_prompt: str,
714
+ password_prompt: str, prompt: str) -> dict:
715
+ try:
716
+ import telnetlib # deprecated in newer Python, but still available in many
717
+ except Exception:
718
+ raise RuntimeError("telnetlib not available. Use SSH instead.")
719
+ timeout = self._timeout()
720
+ tn = telnetlib.Telnet(host, port, timeout=timeout)
721
+ try:
722
+ if login_prompt:
723
+ tn.read_until(login_prompt.encode("utf-8"), timeout=timeout)
724
+ tn.write((user + "\n").encode("utf-8"))
725
+ if password_prompt is not None:
726
+ tn.read_until(password_prompt.encode("utf-8"), timeout=timeout)
727
+ tn.write(((password or "") + "\n").encode("utf-8"))
728
+ if prompt:
729
+ tn.read_until(prompt.encode("utf-8"), timeout=timeout)
730
+ tn.write((command + "\n").encode("utf-8"))
731
+ out = tn.read_until(prompt.encode("utf-8"), timeout=timeout)
732
+ # crude strip
733
+ text = out.decode("utf-8", errors="replace")
734
+ return {"rc": 0, "stdout": text, "stderr": ""}
735
+ finally:
736
+ try:
737
+ tn.close()
738
+ except Exception:
739
+ pass
740
+
741
+ # ---------------------- SMTP (stdlib) ----------------------
742
+
743
+ def _smtp_send_mail(self, host: str, port: int, user: str, password: str | None, mail: dict) -> dict:
744
+ defaults = self._smtp_defaults()
745
+ use_tls = bool(mail.get("use_tls", defaults["use_tls"]))
746
+ use_ssl = bool(mail.get("use_ssl", defaults["use_ssl"]))
747
+ from_addr = (mail.get("from_addr") or defaults["from_addr"] or user).strip()
748
+ to = mail.get("to")
749
+ if isinstance(to, str):
750
+ to = [to]
751
+ cc = mail.get("cc") or []
752
+ bcc = mail.get("bcc") or []
753
+ subject = mail.get("subject") or ""
754
+ body = mail.get("body") or ""
755
+ html = bool(mail.get("html") or False)
756
+
757
+ if not to:
758
+ raise RuntimeError("Param 'to' required for smtp_send.")
759
+
760
+ msg = EmailMessage()
761
+ msg["From"] = from_addr
762
+ msg["To"] = ", ".join(to)
763
+ if cc:
764
+ msg["Cc"] = ", ".join(cc)
765
+ msg["Subject"] = subject
766
+ if html:
767
+ msg.add_alternative(body, subtype="html")
768
+ else:
769
+ msg.set_content(body)
770
+
771
+ # Attachments (local files)
772
+ for apath in (mail.get("attachments") or []):
773
+ lp = self.prepare_path(apath)
774
+ if not os.path.exists(lp):
775
+ raise RuntimeError(f"Attachment not found: {lp}")
776
+ with open(lp, "rb") as f:
777
+ data = f.read()
778
+ maintype, subtype = "application", "octet-stream"
779
+ msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=os.path.basename(lp))
780
+
781
+ if use_ssl or int(port) in [465]:
782
+ smtp = smtplib.SMTP_SSL(host, int(port), timeout=self._timeout())
783
+ else:
784
+ smtp = smtplib.SMTP(host, int(port), timeout=self._timeout())
785
+ try:
786
+ smtp.ehlo()
787
+ if use_tls and not use_ssl:
788
+ smtp.starttls()
789
+ smtp.ehlo()
790
+ if user:
791
+ smtp.login(user, password or "")
792
+ rcpt = to + (cc if isinstance(cc, list) else []) + (bcc if isinstance(bcc, list) else [])
793
+ smtp.send_message(msg, from_addr=from_addr, to_addrs=rcpt)
794
+ return {"sent": True, "to": rcpt}
795
+ finally:
796
+ try:
797
+ smtp.quit()
798
+ except Exception:
799
+ pass
800
+
801
+ # ---------------------- Commands ----------------------
802
+
803
+ def cmd_srv_exec(self, item: dict) -> dict:
804
+ p = item.get("params", {}) or {}
805
+ server = p.get("server")
806
+ port = int(p.get("port") or 0)
807
+ command = p.get("command")
808
+ cwd = p.get("cwd")
809
+ env = p.get("env") or {}
810
+ if not (server and port and command):
811
+ return self.make_response(item, "Params 'server', 'port' and 'command' required.")
812
+
813
+ cfg = self._server_config(server, port)
814
+ host = cfg.get("server")
815
+ user = cfg.get("login")
816
+ password = cfg.get("password")
817
+
818
+ svc = self._service_from_port(port)
819
+ if svc in ["ssh", "sftp", "unknown"] or int(port) == 22:
820
+ if self._prefer_system_ssh():
821
+ res = self._ssh_exec_system(host, port, user, command, cwd=cwd, env=env)
822
+ return self.make_response(item, res)
823
+ else:
824
+ res = self._ssh_exec_paramiko(host, port, user, password, command, cwd=cwd, env=env)
825
+ return self.make_response(item, res)
826
+ elif svc == "telnet" or int(port) == 23:
827
+ login_prompt = (self.plugin.get_option_value("telnet_login_prompt") or "login:").strip()
828
+ password_prompt = (self.plugin.get_option_value("telnet_password_prompt") or "Password:").strip()
829
+ prompt = (self.plugin.get_option_value("telnet_prompt") or "$ ").strip()
830
+ res = self._telnet_exec(host, port, user, password, command, login_prompt, password_prompt, prompt)
831
+ return self.make_response(item, res)
832
+ else:
833
+ return self.make_response(item, f"srv_exec not supported for port {port} ({svc}).")
834
+
835
+ def cmd_srv_ls(self, item: dict) -> dict:
836
+ p = item.get("params", {}) or {}
837
+ server = p.get("server")
838
+ port = int(p.get("port") or 0)
839
+ path = p.get("path") or "."
840
+ if not (server and port):
841
+ return self.make_response(item, "Params 'server' and 'port' required.")
842
+ cfg = self._server_config(server, port)
843
+ host = cfg.get("server")
844
+ user = cfg.get("login")
845
+ password = cfg.get("password")
846
+ svc = self._service_from_port(port)
847
+ if svc in ["ssh", "sftp", "unknown"] or int(port) == 22:
848
+ if self._prefer_system_ssh():
849
+ res = self._ssh_ls_system(host, port, user, path)
850
+ return self.make_response(item, res)
851
+ else:
852
+ res = self._sftp_ls(host, port, user, password, path)
853
+ return self.make_response(item, res)
854
+ elif svc == "ftp" or int(port) in [21, 990]:
855
+ res = self._ftp_ls(host, port, user, password, path)
856
+ return self.make_response(item, res)
857
+ else:
858
+ return self.make_response(item, f"srv_ls not supported for port {port} ({svc}).")
859
+
860
+ def cmd_srv_get(self, item: dict) -> dict:
861
+ p = item.get("params", {}) or {}
862
+ server = p.get("server")
863
+ port = int(p.get("port") or 0)
864
+ remote_path = p.get("remote_path")
865
+ local_path = p.get("local_path")
866
+ overwrite = bool(p.get("overwrite", True))
867
+ if not (server and port and remote_path):
868
+ return self.make_response(item, "Params 'server', 'port' and 'remote_path' required.")
869
+ if not local_path:
870
+ local_path = self.prepare_path(os.path.basename(remote_path))
871
+ else:
872
+ local_path = self.prepare_path(local_path)
873
+ if os.path.exists(local_path) and not overwrite:
874
+ return self.make_response(item, f"Local path exists and overwrite=False: {local_path}")
875
+
876
+ cfg = self._server_config(server, port)
877
+ host = cfg.get("server")
878
+ user = cfg.get("login")
879
+ password = cfg.get("password")
880
+ svc = self._service_from_port(port)
881
+ if svc in ["ssh", "sftp", "unknown"] or int(port) == 22:
882
+ if self._prefer_system_ssh():
883
+ res = self._scp_get_system(host, port, user, remote_path, local_path)
884
+ return self.make_response(item, res)
885
+ else:
886
+ res = self._sftp_get(host, port, user, password, remote_path, local_path)
887
+ return self.make_response(item, res)
888
+ elif svc == "ftp" or int(port) in [21, 990]:
889
+ res = self._ftp_get(host, port, user, password, remote_path, local_path)
890
+ return self.make_response(item, res)
891
+ else:
892
+ return self.make_response(item, f"srv_get not supported for port {port} ({svc}).")
893
+
894
+ def cmd_srv_put(self, item: dict) -> dict:
895
+ p = item.get("params", {}) or {}
896
+ server = p.get("server")
897
+ port = int(p.get("port") or 0)
898
+ local_path = p.get("local_path")
899
+ remote_path = p.get("remote_path")
900
+ make_dirs = bool(p.get("make_dirs", True))
901
+ if not (server and port and local_path):
902
+ return self.make_response(item, "Params 'server', 'port' and 'local_path' required.")
903
+ lp = self.prepare_path(local_path)
904
+ if not os.path.exists(lp):
905
+ return self.make_response(item, f"Local path not found: {lp}")
906
+ if not remote_path:
907
+ remote_path = os.path.basename(lp)
908
+
909
+ cfg = self._server_config(server, port)
910
+ host = cfg.get("server")
911
+ user = cfg.get("login")
912
+ password = cfg.get("password")
913
+ svc = self._service_from_port(port)
914
+ if svc in ["ssh", "sftp", "unknown"] or int(port) == 22:
915
+ if self._prefer_system_ssh():
916
+ # System scp cannot create remote dirs automatically; rely on user setting up path
917
+ res = self._scp_put_system(host, port, user, lp, remote_path)
918
+ return self.make_response(item, res)
919
+ else:
920
+ res = self._sftp_put(host, port, user, password, lp, remote_path, make_dirs=make_dirs)
921
+ return self.make_response(item, res)
922
+ elif svc == "ftp" or int(port) in [21, 990]:
923
+ res = self._ftp_put(host, port, user, password, lp, remote_path)
924
+ return self.make_response(item, res)
925
+ else:
926
+ return self.make_response(item, f"srv_put not supported for port {port} ({svc}).")
927
+
928
+ def cmd_srv_rm(self, item: dict) -> dict:
929
+ p = item.get("params", {}) or {}
930
+ server = p.get("server")
931
+ port = int(p.get("port") or 0)
932
+ remote_path = p.get("remote_path")
933
+ if not (server and port and remote_path):
934
+ return self.make_response(item, "Params 'server', 'port' and 'remote_path' required.")
935
+ cfg = self._server_config(server, port)
936
+ host = cfg.get("server")
937
+ user = cfg.get("login")
938
+ password = cfg.get("password")
939
+ svc = self._service_from_port(port)
940
+ if svc in ["ssh", "sftp", "unknown"] or int(port) == 22:
941
+ if self._prefer_system_ssh():
942
+ # Use rm/rmdir via SSH (non-recursive)
943
+ cmd = f"if [ -d {self._shell_quote(remote_path)} ]; then rmdir {self._shell_quote(remote_path)}; else rm -f {self._shell_quote(remote_path)}; fi"
944
+ res = self._ssh_exec_system(host, port, user, cmd, cwd=None, env=None)
945
+ if res["rc"] != 0:
946
+ raise RuntimeError(f"remove failed: {res['stderr'].strip()}")
947
+ return self.make_response(item, {"removed": True, "path": remote_path})
948
+ else:
949
+ res = self._sftp_rm(host, port, user, password, remote_path)
950
+ return self.make_response(item, res)
951
+ elif svc == "ftp" or int(port) in [21, 990]:
952
+ res = self._ftp_rm(host, port, user, password, remote_path)
953
+ return self.make_response(item, res)
954
+ else:
955
+ return self.make_response(item, f"srv_rm not supported for port {port} ({svc}).")
956
+
957
+ def cmd_srv_mkdir(self, item: dict) -> dict:
958
+ p = item.get("params", {}) or {}
959
+ server = p.get("server")
960
+ port = int(p.get("port") or 0)
961
+ remote_path = p.get("remote_path")
962
+ exist_ok = bool(p.get("exist_ok", True))
963
+ if not (server and port and remote_path):
964
+ return self.make_response(item, "Params 'server', 'port' and 'remote_path' required.")
965
+ cfg = self._server_config(server, port)
966
+ host = cfg.get("server")
967
+ user = cfg.get("login")
968
+ password = cfg.get("password")
969
+ svc = self._service_from_port(port)
970
+ if svc in ["ssh", "sftp", "unknown"] or int(port) == 22:
971
+ if self._prefer_system_ssh():
972
+ cmd = f"mkdir {'-p ' if exist_ok else ''}{self._shell_quote(remote_path)}"
973
+ res = self._ssh_exec_system(host, port, user, cmd, cwd=None, env=None)
974
+ if res["rc"] != 0:
975
+ raise RuntimeError(f"mkdir failed: {res['stderr'].strip()}")
976
+ return self.make_response(item, {"mkdir": True, "path": remote_path})
977
+ else:
978
+ res = self._sftp_mkdir(host, port, user, password, remote_path, exist_ok=exist_ok)
979
+ return self.make_response(item, res)
980
+ elif svc == "ftp" or int(port) in [21, 990]:
981
+ res = self._ftp_mkdir(host, port, user, password, remote_path, exist_ok=exist_ok)
982
+ return self.make_response(item, res)
983
+ else:
984
+ return self.make_response(item, f"srv_mkdir not supported for port {port} ({svc}).")
985
+
986
+ def cmd_srv_stat(self, item: dict) -> dict:
987
+ p = item.get("params", {}) or {}
988
+ server = p.get("server")
989
+ port = int(p.get("port") or 0)
990
+ remote_path = p.get("remote_path")
991
+ if not (server and port and remote_path):
992
+ return self.make_response(item, "Params 'server', 'port' and 'remote_path' required.")
993
+ cfg = self._server_config(server, port)
994
+ host = cfg.get("server")
995
+ user = cfg.get("login")
996
+ password = cfg.get("password")
997
+ svc = self._service_from_port(port)
998
+ if svc in ["ssh", "sftp", "unknown"] or int(port) == 22:
999
+ if self._prefer_system_ssh():
1000
+ # Try 'stat' then fallback to test
1001
+ cmd = f"(stat -c '%F|%s|%Y' {self._shell_quote(remote_path)} 2>/dev/null) || echo 'UNKNOWN|||';"
1002
+ res = self._ssh_exec_system(host, port, user, cmd, cwd=None, env=None)
1003
+ if res["rc"] != 0:
1004
+ raise RuntimeError(f"stat failed: {res['stderr'].strip()}")
1005
+ line = res["stdout"].strip().splitlines()[-1] if res["stdout"] else "UNKNOWN|||"
1006
+ kind, size, mtime = (line.split("|") + ["", "", ""])[:3]
1007
+ ftype = "file"
1008
+ if "directory" in kind.lower():
1009
+ ftype = "dir"
1010
+ elif "link" in kind.lower():
1011
+ ftype = "link"
1012
+ size_val = None
1013
+ try:
1014
+ size_val = int(size) if size else None
1015
+ except Exception:
1016
+ pass
1017
+ mtime_val = None
1018
+ try:
1019
+ mtime_val = int(mtime) if mtime else None
1020
+ except Exception:
1021
+ pass
1022
+ return self.make_response(item,
1023
+ {"path": remote_path, "type": ftype, "size": size_val, "mtime": mtime_val})
1024
+ else:
1025
+ res = self._sftp_stat(host, port, user, password, remote_path)
1026
+ return self.make_response(item, res)
1027
+ elif svc == "ftp" or int(port) in [21, 990]:
1028
+ res = self._ftp_stat(host, port, user, password, remote_path)
1029
+ return self.make_response(item, res)
1030
+ else:
1031
+ return self.make_response(item, f"srv_stat not supported for port {port} ({svc}).")
1032
+
1033
+ def cmd_smtp_send(self, item: dict) -> dict:
1034
+ p = item.get("params", {}) or {}
1035
+ server = p.get("server")
1036
+ port = int(p.get("port") or 0)
1037
+ if not (server and port):
1038
+ return self.make_response(item, "Params 'server' and 'port' required.")
1039
+ cfg = self._server_config(server, port)
1040
+ host = cfg.get("server")
1041
+ user = cfg.get("login")
1042
+ password = cfg.get("password")
1043
+
1044
+ mail = {
1045
+ "from_addr": p.get("from_addr"),
1046
+ "to": p.get("to"),
1047
+ "cc": p.get("cc"),
1048
+ "bcc": p.get("bcc"),
1049
+ "subject": p.get("subject"),
1050
+ "body": p.get("body"),
1051
+ "html": bool(p.get("html") or False),
1052
+ "attachments": p.get("attachments") or [],
1053
+ "use_tls": p.get("use_tls"),
1054
+ "use_ssl": p.get("use_ssl"),
1055
+ }
1056
+ res = self._smtp_send_mail(host, port, user, password, mail)
1057
+ return self.make_response(item, res)