ssh-handler 1.5.0__tar.gz → 1.6.0__tar.gz
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.
- {ssh_handler-1.5.0/ssh_handler.egg-info → ssh_handler-1.6.0}/PKG-INFO +1 -1
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/pyproject.toml +6 -2
- ssh_handler-1.6.0/ssh_handler/README.md +536 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/__init__.py +1 -1
- ssh_handler-1.6.0/ssh_handler/assets/icon.ico +0 -0
- ssh_handler-1.6.0/ssh_handler/assets/icon.png +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/cli.py +28 -0
- ssh_handler-1.6.0/ssh_handler/gui_app.py +402 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0/ssh_handler.egg-info}/PKG-INFO +1 -1
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler.egg-info/SOURCES.txt +4 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler.egg-info/entry_points.txt +4 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/LICENSE +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/README.md +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/setup.cfg +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/__main__.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/config.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/core.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/credentials.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/exceptions.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/ftp.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/openssh/OpenSSH-ARM64.zip +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/openssh/OpenSSH-Win32.zip +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/openssh/OpenSSH-Win64.zip +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/pool.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/pyqt_worker.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/results.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/serial_handler.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/setup_openssh_server.ps1 +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/winrm_bootstrap.py +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler.egg-info/dependency_links.txt +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler.egg-info/requires.txt +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler.egg-info/top_level.txt +0 -0
- {ssh_handler-1.5.0 → ssh_handler-1.6.0}/tests/test_offline.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ssh-handler"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.6.0"
|
|
8
8
|
description = "Extensive SSH/SFTP/SCP/FTP handler built on Paramiko, for test automation, CLIs and PyQt5 tools."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -36,9 +36,13 @@ all = ["PyQt5>=5.15"]
|
|
|
36
36
|
[project.scripts]
|
|
37
37
|
ssh-handler = "ssh_handler.cli:main"
|
|
38
38
|
ssh-handler-setup = "ssh_handler.cli:setup_server_main"
|
|
39
|
+
ssh-handler-docs = "ssh_handler.cli:open_docs"
|
|
40
|
+
|
|
41
|
+
[project.gui-scripts]
|
|
42
|
+
ssh-handler-gui = "ssh_handler.cli:launch_gui"
|
|
39
43
|
|
|
40
44
|
[tool.setuptools]
|
|
41
45
|
packages = ["ssh_handler"]
|
|
42
46
|
|
|
43
47
|
[tool.setuptools.package-data]
|
|
44
|
-
ssh_handler = ["*.ps1", "openssh/*.zip"]
|
|
48
|
+
ssh_handler = ["*.ps1", "*.md", "openssh/*.zip", "assets/*.ico", "assets/*.png"]
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
# ssh-handler
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/ssh-handler/)
|
|
4
|
+
[](https://pypi.org/project/ssh-handler/)
|
|
5
|
+
|
|
6
|
+
An extensive **SSH / SFTP / SCP / FTP** automation handler built on
|
|
7
|
+
[Paramiko](https://www.paramiko.org/). One package, three ways to use it:
|
|
8
|
+
|
|
9
|
+
- **Test-automation framework** — raise-on-error API, pytest fixtures, parallel fleet ops.
|
|
10
|
+
- **Standalone CLI** — `python -m ssh_handler …`, fully argument-driven.
|
|
11
|
+
- **PyQt5 tool** — safe mode + log streaming over Qt signals, runs off the GUI thread.
|
|
12
|
+
|
|
13
|
+
Passwords are wrapped in a `Secret` and stored in the **OS credential vault** — they
|
|
14
|
+
never appear in logs, reprs, tracebacks, or on disk in plaintext.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install ssh-handler
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Table of contents
|
|
23
|
+
|
|
24
|
+
- [Why this package](#why-this-package)
|
|
25
|
+
- [Install](#install)
|
|
26
|
+
- [Quick start](#quick-start)
|
|
27
|
+
- [What you can do](#what-you-can-do) — full capability list
|
|
28
|
+
- [Domain / Windows (RDP) hosts](#domain--windows-rdp-hosts)
|
|
29
|
+
- [Confidential credentials](#confidential-credentials)
|
|
30
|
+
- [Performance](#performance)
|
|
31
|
+
- [Error handling: two styles](#error-handling-two-styles)
|
|
32
|
+
- [Result objects](#result-objects)
|
|
33
|
+
- [CLI reference](#cli-reference)
|
|
34
|
+
- [PyQt5 integration](#pyqt5-integration)
|
|
35
|
+
- [Parallel fleet operations](#parallel-fleet-operations)
|
|
36
|
+
- [FTP / FTPS](#ftp--ftps)
|
|
37
|
+
- [API map](#api-map)
|
|
38
|
+
- [Releasing](#releasing)
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Why this package
|
|
43
|
+
|
|
44
|
+
Paramiko is powerful but low-level: you manage clients, transports, channels,
|
|
45
|
+
SFTP sessions, timeouts, retries, host-key policies and error handling yourself,
|
|
46
|
+
and you repeat that boilerplate in every project. `ssh-handler` wraps all of it
|
|
47
|
+
behind one object that:
|
|
48
|
+
|
|
49
|
+
- auto-selects the right authentication strategy (password, key, agent, empty password),
|
|
50
|
+
- retries connections and transparently reconnects dropped sessions,
|
|
51
|
+
- returns **structured results** for every action instead of raw strings,
|
|
52
|
+
- keeps **passwords confidential** end-to-end,
|
|
53
|
+
- and exposes the same surface whether you're in a test, a CLI, or a GUI.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install ssh-handler # everything: SSH, SFTP, SCP, FTP, serial,
|
|
59
|
+
# credential vault, WinRM bootstrap
|
|
60
|
+
pip install "ssh-handler[gui]" # also installs PyQt5 for the GUI worker
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Batteries included.** A plain `pip install ssh-handler` pulls in `paramiko`,
|
|
64
|
+
`scp`, `pyserial`, `keyring`, and `pywinrm`, so SSH, SFTP/SCP/FTP transfers,
|
|
65
|
+
serial/COM ports, confidential credential storage, and the WinRM bootstrap all
|
|
66
|
+
work out of the box. Only **PyQt5** is optional (`[gui]`), because it's a large
|
|
67
|
+
GUI toolkit you only need when building a GUI — forcing it would bloat headless
|
|
68
|
+
and CI installs.
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from ssh_handler import SSHHandler, SSHConfig
|
|
74
|
+
|
|
75
|
+
with SSHHandler(SSHConfig(host="10.0.0.5", username="root", password="pw")) as ssh:
|
|
76
|
+
print(ssh.run("uptime").stdout)
|
|
77
|
+
ssh.run("systemctl restart nginx", check=True) # raises on non-zero exit
|
|
78
|
+
ssh.push("local.txt", "/tmp/remote.txt") # SFTP upload
|
|
79
|
+
ssh.pull("/etc/nginx", "./backup", recursive=True) # recursive download
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## What you can do
|
|
83
|
+
|
|
84
|
+
**Connection & session**
|
|
85
|
+
- Connect with password, private key (+ passphrase), SSH agent, auto-discovered
|
|
86
|
+
keys, or an **empty-password** account — all auto-tried in a smart order.
|
|
87
|
+
- **Auto-retry** connects with backoff; **auto-reconnect** if a session drops.
|
|
88
|
+
- Keepalives, per-command and connection timeouts, optional compression.
|
|
89
|
+
- **Jump host / bastion** chaining (ProxyJump-style) via `SSHConfig(jump_host=…)`.
|
|
90
|
+
- Host-key policy: `auto` / `reject` / `warn`, with an optional `known_hosts` file.
|
|
91
|
+
- Remote-OS awareness (`detect_os()`, `is_windows`) for Linux **and** Windows targets.
|
|
92
|
+
- Raw escape hatch: `ssh.client` and `ssh.transport` expose the underlying Paramiko objects.
|
|
93
|
+
|
|
94
|
+
**Command execution**
|
|
95
|
+
- `run()` — timeout, `check` (raise on non-zero), PTY allocation, custom environment.
|
|
96
|
+
- `run_many()` — batch with stop-on-error.
|
|
97
|
+
- `sudo()` — runs `sudo -S` and feeds the password on stdin.
|
|
98
|
+
- `open_shell()` — a persistent interactive `ShellSession` with `send` /
|
|
99
|
+
`read_until` (send-expect) / `read_available`.
|
|
100
|
+
|
|
101
|
+
**Continuous / streaming output (logs)**
|
|
102
|
+
- `iter_lines(cmd)` — generator yielding a never-ending command's output **line
|
|
103
|
+
by line, live** (`slog2info -w`, `tail -f`, `journalctl -f`, `dmesg -w`).
|
|
104
|
+
- `stream(cmd, on_line=, match=, save_to=, stop_on_match=, stop_event=)` — stream
|
|
105
|
+
with **live regex matching**, a per-line/per-match callback, and **tee to a
|
|
106
|
+
local file**, all built in.
|
|
107
|
+
|
|
108
|
+
**Serial / COM ports** (`SerialHandler`, included by default)
|
|
109
|
+
- `list_serial_ports()`, `open`/`close`, `write` / `write_line`.
|
|
110
|
+
- `iter_lines()` and `stream(...)` — same live streaming + match + save-to-file
|
|
111
|
+
model as SSH, for device consoles.
|
|
112
|
+
|
|
113
|
+
**File operations (SFTP) — full Paramiko parity**
|
|
114
|
+
- Transfers: `push` / `pull` (single file **or** recursive directory, with progress
|
|
115
|
+
callbacks and transfer statistics), plus `scp_push` / `scp_pull` (SCP protocol).
|
|
116
|
+
- Listing & metadata: `listdir`, `listdir_attr`, `stat`, `lstat`, `exists`, `isdir`, `walk`.
|
|
117
|
+
- Directories: `mkdir`, `makedirs` (recursive `mkdir -p`), `rmdir`.
|
|
118
|
+
- Files: `remove`, `rename`, `open` (remote file object), `read_text`, `write_text`.
|
|
119
|
+
- Permissions & links: `chmod`, `chown`, `symlink`, `readlink`.
|
|
120
|
+
|
|
121
|
+
**Other protocols**
|
|
122
|
+
- **FTP / FTPS** via `FTPHandler` (standard-library `ftplib`, no extra dependency):
|
|
123
|
+
connect, login, TLS, `push`, `pull`, `listdir`, `cwd`, `pwd`, `mkdir`, `rmdir`,
|
|
124
|
+
`remove`, `rename`, `size`, `exists`.
|
|
125
|
+
|
|
126
|
+
**Scale & integration**
|
|
127
|
+
- `SSHPool` — run the same command/transfer across many hosts in parallel threads.
|
|
128
|
+
- Safe mode + log callback for GUIs; structured result objects everywhere.
|
|
129
|
+
- Confidential credential storage in the OS vault (`CredentialStore`, `Secret`, `mask`).
|
|
130
|
+
|
|
131
|
+
## Domain / Windows (RDP) hosts
|
|
132
|
+
|
|
133
|
+
The Windows machines you normally RDP into can be driven over SSH once **OpenSSH
|
|
134
|
+
Server** is enabled on them:
|
|
135
|
+
|
|
136
|
+
```powershell
|
|
137
|
+
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
|
|
138
|
+
Start-Service sshd ; Set-Service -Name sshd -StartupType Automatic
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Then log in with your normal domain credentials. **Pass `domain` and `username`
|
|
142
|
+
separately** — never hard-code a single `"DOMAIN\user"` Python string, because a
|
|
143
|
+
backslash escape (e.g. `\n`, `\t`) silently becomes a control character. The
|
|
144
|
+
handler builds the `DOMAIN\user` login string for you:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from ssh_handler import SSHHandler, SSHConfig, CredentialStore
|
|
148
|
+
|
|
149
|
+
store = CredentialStore(service="my_test_lab")
|
|
150
|
+
cfg = SSHConfig(
|
|
151
|
+
host="10.20.30.40",
|
|
152
|
+
domain="CORP", username="myuser", # -> login "CORP\myuser"
|
|
153
|
+
password=store.get("CORP\\myuser"), # a Secret pulled from the OS vault
|
|
154
|
+
remote_os="windows", # skip the OS probe
|
|
155
|
+
fast_auth=True, # skip key probing -> faster login
|
|
156
|
+
)
|
|
157
|
+
with SSHHandler(cfg) as ssh:
|
|
158
|
+
print(ssh.run("whoami").stdout) # CORP\myuser
|
|
159
|
+
print(ssh.run("powershell Get-Service sshd").stdout)
|
|
160
|
+
ssh.push("report.xlsx", "C:/Users/myuser/Desktop/report.xlsx")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Store the password **once** so it never appears in code again:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from ssh_handler import CredentialStore, prompt_password
|
|
167
|
+
CredentialStore("my_test_lab").set("CORP\\myuser", prompt_password())
|
|
168
|
+
```
|
|
169
|
+
```bash
|
|
170
|
+
# …or from the CLI:
|
|
171
|
+
python -m ssh_handler store-credential --user myuser --domain CORP --service my_test_lab
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## RDP-only Windows hosts: auto-enable SSH once (WinRM bootstrap)
|
|
175
|
+
|
|
176
|
+
A freshly imaged corporate Windows box often has **RDP and WinRM open but no SSH
|
|
177
|
+
server** (port 22 closed). You can't start sshd *over* SSH when SSH is down — but
|
|
178
|
+
if WinRM is reachable, the handler can use it as a one-time bootstrap channel.
|
|
179
|
+
|
|
180
|
+
Set one flag and connect normally:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from ssh_handler import SSHHandler, SSHConfig
|
|
184
|
+
|
|
185
|
+
cfg = SSHConfig(
|
|
186
|
+
host="10.232.9.22", domain="CORP", username="myuser", password="pw",
|
|
187
|
+
auto_bootstrap_via_winrm=True, # if SSH is down but WinRM is up, enable sshd, then retry
|
|
188
|
+
)
|
|
189
|
+
with SSHHandler(cfg) as ssh: # 1st run: enables sshd over WinRM, then connects
|
|
190
|
+
print(ssh.run("whoami").stdout) # every later run: connects straight over SSH
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**It's genuinely one-time.** The bootstrap installs the OpenSSH Server capability,
|
|
194
|
+
starts `sshd` with **Automatic** startup, and adds a **persistent** firewall rule —
|
|
195
|
+
so it survives reboots. After the first run, port 22 is already open and the
|
|
196
|
+
handler connects directly over SSH; WinRM is never touched again.
|
|
197
|
+
|
|
198
|
+
Do it explicitly instead of automatically if you prefer:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
ssh = SSHHandler(cfg)
|
|
202
|
+
ssh.bootstrap_sshd_via_winrm() # one-time setup
|
|
203
|
+
ssh.connect()
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Requirements: `pip install "ssh-handler[winrm]"` (pulls in `pywinrm`; uses NTLM so
|
|
207
|
+
domain creds work without Kerberos), and the account must be a **local
|
|
208
|
+
administrator** on the target. If SSH already works, this code path never runs.
|
|
209
|
+
|
|
210
|
+
### Set up OpenSSH Server on a machine (bundled installer)
|
|
211
|
+
|
|
212
|
+
Installing the package also gives you a one-command, **fully offline** setup for
|
|
213
|
+
the local Windows machine. The OpenSSH ZIPs (ARM64 / Win64 / Win32) ship *inside*
|
|
214
|
+
the wheel, so the installer needs **no internet and no Windows Update** — it picks
|
|
215
|
+
the ZIP matching the CPU architecture, self-elevates to Administrator, installs &
|
|
216
|
+
starts OpenSSH Server, and opens the firewall. (This avoids the
|
|
217
|
+
`Add-WindowsCapability` hang common on locked-down corporate networks.)
|
|
218
|
+
|
|
219
|
+
```powershell
|
|
220
|
+
pip install ssh-handler
|
|
221
|
+
ssh-handler-setup # offline install + start sshd + firewall (self-elevates)
|
|
222
|
+
ssh-handler-setup --install-pip # also (re)install the package as admin
|
|
223
|
+
ssh-handler-setup --force # reinstall even if sshd already exists
|
|
224
|
+
# equivalent: python -m ssh_handler setup-server
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Run it on whichever machine you want to reach over SSH (e.g. the RDP jump box).
|
|
228
|
+
|
|
229
|
+
When a connection just fails, the error now self-diagnoses — it probes the SSH and
|
|
230
|
+
RDP ports and tells you *why* (e.g. "Port 22 is closed but RDP (3389) is open … no
|
|
231
|
+
SSH server listening"). Call `ssh.diagnose()` for a pre-flight reachability check.
|
|
232
|
+
|
|
233
|
+
## Continuous logs & live pattern matching
|
|
234
|
+
|
|
235
|
+
Stream a long-running remote command and react to lines as they arrive — match a
|
|
236
|
+
pattern, save to a file, or stop when something appears. Works through the jump
|
|
237
|
+
host too.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from ssh_handler import SSHHandler, SSHConfig
|
|
241
|
+
|
|
242
|
+
with SSHHandler(SSHConfig(host="10.120.1.91", username="root", password="pw",
|
|
243
|
+
jump_host=rdp_box), quiet=True) as ssh:
|
|
244
|
+
|
|
245
|
+
# (a) simplest: iterate lines live
|
|
246
|
+
for line in ssh.iter_lines("slog2info -w"):
|
|
247
|
+
print(line)
|
|
248
|
+
if "FATAL" in line:
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
# (b) full: match + tee to a local file + callback, stop on a pattern
|
|
252
|
+
result = ssh.stream(
|
|
253
|
+
"tail -f /var/log/messages",
|
|
254
|
+
on_line=print, # called for every line
|
|
255
|
+
match=r"error|fail", # regex; matching lines collected
|
|
256
|
+
on_match=lambda l: print("HIT:", l),
|
|
257
|
+
save_to="device.log", # tee every line to this local file
|
|
258
|
+
stop_on_match=False, # set True to stop at the first match
|
|
259
|
+
timeout=60, # optional overall time limit
|
|
260
|
+
)
|
|
261
|
+
print(result["lines"], "lines,", len(result["matches"]), "matched")
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
To stop a stream from another thread (e.g. a GUI Stop button), pass a
|
|
265
|
+
`threading.Event` as `stop_event=` and `.set()` it.
|
|
266
|
+
|
|
267
|
+
## Serial / COM ports
|
|
268
|
+
|
|
269
|
+
Same streaming + match + save model for device serial consoles (included by
|
|
270
|
+
default — no extra install).
|
|
271
|
+
|
|
272
|
+
### Local serial (device plugged into the machine running the code)
|
|
273
|
+
```python
|
|
274
|
+
from ssh_handler import SerialHandler, list_serial_ports
|
|
275
|
+
print(list_serial_ports())
|
|
276
|
+
with SerialHandler("COM5", baudrate=115200, quiet=True) as ser:
|
|
277
|
+
ser.write_line("version")
|
|
278
|
+
ser.stream(on_line=print, match=r"login:", stop_on_match=True, save_to="console.log")
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Serial via RDP / SSH (port on a *remote* machine)
|
|
282
|
+
`pyserial` only opens a *local* port, so when the serial port is on a remote
|
|
283
|
+
machine, stream it **over SSH** with `serial_stream()` — same live match + save.
|
|
284
|
+
It auto-detects the OS from the device name: `COM*` → Windows (PowerShell
|
|
285
|
+
SerialPort reader), `/dev/tty*` → Linux (`stty` + `cat`).
|
|
286
|
+
|
|
287
|
+
**Windows COM port on the remote machine** (connect SSH straight to that machine
|
|
288
|
+
— it has sshd from `ssh-handler-setup`):
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
cfg = SSHConfig(host="10.232.9.22", domain="CORP", username="myuser",
|
|
292
|
+
password="pw", host_key_policy="ignore")
|
|
293
|
+
with SSHHandler(cfg, quiet=True) as ssh:
|
|
294
|
+
ssh.serial_write("COM5", "version", baudrate=115200) # write a line
|
|
295
|
+
ssh.serial_stream("COM5", baudrate=115200, # read it live
|
|
296
|
+
on_line=print, match=r"login:|ERROR",
|
|
297
|
+
save_to="com5.log", timeout=120)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Linux device file on a target reached through the jump:**
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
target = SSHConfig(host="10.120.1.91", username="root", password="pw",
|
|
304
|
+
jump_host=rdp_box, host_key_policy="ignore")
|
|
305
|
+
with SSHHandler(target, quiet=True) as ssh:
|
|
306
|
+
ssh.serial_stream("/dev/ttyUSB0", baudrate=115200,
|
|
307
|
+
on_line=print, match=r"login:", save_to="ttyusb0.log")
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
> Note: on Windows a COM port can't be shared — don't run `serial_write` while a
|
|
311
|
+
> `serial_stream` on the same port is open (`serial_write` opens/writes/closes).
|
|
312
|
+
> If the port is on **your own laptop**, use the local `SerialHandler("COM5")`
|
|
313
|
+
> above instead — no SSH needed.
|
|
314
|
+
|
|
315
|
+
## File transfer (SFTP / SCP / FTP) via RDP
|
|
316
|
+
|
|
317
|
+
**SFTP and SCP already work through the jump host** — no special setup. Once you
|
|
318
|
+
pass `jump_host=`, every transfer runs over that tunnel (laptop → RDP → target):
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
with SSHHandler(target, quiet=True) as ssh: # target has jump_host=rdp_box
|
|
322
|
+
ssh.push("firmware.bin", "/tmp/firmware.bin") # SFTP, through the jump
|
|
323
|
+
ssh.pull("/var/log/messages", "messages.log") # SFTP, through the jump
|
|
324
|
+
ssh.scp_push("img.tar", "/tmp/img.tar") # SCP, through the jump
|
|
325
|
+
print(ssh.read_text("/etc/os-release"))
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**FTP via RDP:** FTP is a separate protocol (its data channel can't ride an SSH
|
|
329
|
+
tunnel cleanly), so prefer **SFTP through the jump** as shown above — it does the
|
|
330
|
+
same job better and is already routed via RDP. If you specifically need a real
|
|
331
|
+
FTP *server* on the target, run `FTPHandler` on the RDP machine itself (where it
|
|
332
|
+
can reach that server directly).
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
from ssh_handler import SerialHandler, list_serial_ports
|
|
336
|
+
|
|
337
|
+
print(list_serial_ports()) # [{'device':'COM5','description':...}, ...]
|
|
338
|
+
|
|
339
|
+
with SerialHandler("COM5", baudrate=115200, quiet=True) as ser:
|
|
340
|
+
ser.write_line("version") # send a command
|
|
341
|
+
res = ser.stream(
|
|
342
|
+
on_line=print,
|
|
343
|
+
match=r"login:", # wait for the login prompt
|
|
344
|
+
stop_on_match=True,
|
|
345
|
+
save_to="serial_console.log", # tee to file
|
|
346
|
+
timeout=120,
|
|
347
|
+
)
|
|
348
|
+
print("matched:", res["matched"])
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`write_line(..., eol="\r\n")` for consoles that need CRLF. Everything returns the
|
|
352
|
+
same `OperationResult` in safe mode and raises `SerialError` otherwise.
|
|
353
|
+
|
|
354
|
+
## Confidential credentials
|
|
355
|
+
|
|
356
|
+
| Mechanism | What it does |
|
|
357
|
+
|-----------|--------------|
|
|
358
|
+
| `Secret` | wraps a password; `str()`/`repr()`/logs show `********`; only `.reveal()` exposes it |
|
|
359
|
+
| `mask()` | redacts secret values from any string (applied automatically to all logging) |
|
|
360
|
+
| `CredentialStore` | stores/reads passwords in the OS vault (Windows Credential Manager / macOS Keychain / Secret Service) via `keyring` — **no plaintext on disk** |
|
|
361
|
+
| `prompt_password()` | hidden terminal input, returns a `Secret` |
|
|
362
|
+
|
|
363
|
+
Pass a `Secret` (or a plain string, which is wrapped automatically) anywhere a
|
|
364
|
+
password is accepted. It stays redacted across the whole stack.
|
|
365
|
+
|
|
366
|
+
## Performance
|
|
367
|
+
|
|
368
|
+
- **`fast_auth`** (default on): when a password is supplied, the slow key/agent
|
|
369
|
+
probing is skipped — faster logins and no "Too many authentication failures"
|
|
370
|
+
from the server's `MaxAuthTries`.
|
|
371
|
+
- One SFTP channel is opened lazily and **reused** across operations.
|
|
372
|
+
- SFTP downloads use Paramiko **prefetch** for high throughput.
|
|
373
|
+
- `remote_os="windows"|"linux"` skips the one-time OS-detection probe.
|
|
374
|
+
- `compress=True` for slow/high-latency links; keepalives keep long sessions alive.
|
|
375
|
+
- `SSHPool` parallelizes across hosts with a thread pool.
|
|
376
|
+
|
|
377
|
+
## Error handling: two styles
|
|
378
|
+
|
|
379
|
+
**Raise (default)** — best for tests/scripts. Typed exceptions, all subclasses of
|
|
380
|
+
`SSHError`:
|
|
381
|
+
|
|
382
|
+
```
|
|
383
|
+
SSHConnectionError SSHAuthenticationError SSHTimeoutError
|
|
384
|
+
SSHCommandError SSHTransferError SSHNotConnectedError
|
|
385
|
+
FTPError CredentialError
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
**Safe mode** (`SSHHandler(cfg, safe=True)`) — best for GUIs. Every call returns an
|
|
389
|
+
`OperationResult` instead of raising, so an event loop never dies:
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
res = ssh.connect()
|
|
393
|
+
if not res: # OperationResult is falsy on failure
|
|
394
|
+
show_error(res.error)
|
|
395
|
+
else:
|
|
396
|
+
data = res.value # or res.unwrap() to re-raise on failure
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Override per call with `safe=True` / `safe=False`.
|
|
400
|
+
|
|
401
|
+
## Result objects
|
|
402
|
+
|
|
403
|
+
Every action returns structured data, not bare strings:
|
|
404
|
+
|
|
405
|
+
- **`CommandResult`** — `exit_code`, `stdout`, `stderr`, `duration`, `host`, `.ok`, `.as_dict()`
|
|
406
|
+
- **`TransferResult`** — `size_bytes`, `duration`, `speed_bps`, `human_speed`, `human_size`, `files`
|
|
407
|
+
- **`ShellResult`** — `output`, `matched`, `timed_out`, `duration`
|
|
408
|
+
- **`OperationResult`** — safe-mode wrapper: `bool(res)`, `res.value`, `res.error`, `res.unwrap()`
|
|
409
|
+
|
|
410
|
+
## CLI reference
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
python -m ssh_handler run --host H --user U --domain CORP uptime
|
|
414
|
+
python -m ssh_handler push --host H --user U ./build /tmp/build --recursive
|
|
415
|
+
python -m ssh_handler pull --host H --user U /var/log ./logs --recursive
|
|
416
|
+
python -m ssh_handler info --host H --user U --json
|
|
417
|
+
python -m ssh_handler store-credential --user U --domain CORP --service my_test_lab
|
|
418
|
+
|
|
419
|
+
# continuous logs over SSH, with live matching + save:
|
|
420
|
+
python -m ssh_handler stream --host H --user U --match "error|fail" \
|
|
421
|
+
--save run.log -- slog2info -w
|
|
422
|
+
|
|
423
|
+
# serial / COM ports:
|
|
424
|
+
python -m ssh_handler list-serial
|
|
425
|
+
python -m ssh_handler serial-monitor --port COM5 --baud 115200 \
|
|
426
|
+
--match "login:" --stop-on-match --save console.log
|
|
427
|
+
|
|
428
|
+
# install OpenSSH Server on THIS Windows machine (offline, self-elevates):
|
|
429
|
+
ssh-handler-setup
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Password options: `--password` (hidden prompt), `--use-stored` (read from the OS
|
|
433
|
+
vault), `--key FILE` (private key). Add `--json` for machine-readable output.
|
|
434
|
+
Put `--match`/`--save` *before* the streamed command. After `pip install`, the
|
|
435
|
+
`ssh-handler` and `ssh-handler-setup` console scripts are also available.
|
|
436
|
+
|
|
437
|
+
## PyQt5 integration
|
|
438
|
+
|
|
439
|
+
`ssh_handler.pyqt_worker.SSHWorker` is a `QObject` wrapping the handler in **safe
|
|
440
|
+
mode**. Move it to a `QThread`, connect its signals, and drive it from the GUI:
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
from PyQt5.QtCore import QThread
|
|
444
|
+
from ssh_handler import SSHConfig
|
|
445
|
+
from ssh_handler.pyqt_worker import SSHWorker
|
|
446
|
+
|
|
447
|
+
worker = SSHWorker(SSHConfig(host="10.0.0.5", username="root", password=secret))
|
|
448
|
+
thread = QThread(); worker.moveToThread(thread)
|
|
449
|
+
worker.log.connect(text_edit.append) # live, secret-masked log lines
|
|
450
|
+
worker.command_done.connect(on_command_done)
|
|
451
|
+
worker.progress.connect(progress_bar.setValue) # bytes_done, bytes_total
|
|
452
|
+
thread.started.connect(lambda: worker.run_command("uptime"))
|
|
453
|
+
thread.start()
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Signals: `log`, `connected`, `command_done`, `transfer_done`, `progress`,
|
|
457
|
+
`stream_line`, `stream_match`, `stream_done`, `error`, `finished`. The import is
|
|
458
|
+
lazy, so the rest of the package works where PyQt5 isn't installed.
|
|
459
|
+
|
|
460
|
+
**Streaming logs into the GUI** — drive `start_stream` in the worker thread and
|
|
461
|
+
wire the per-line signals to your widgets; `stop_stream()` ends it cleanly:
|
|
462
|
+
|
|
463
|
+
```python
|
|
464
|
+
worker.stream_line.connect(log_view.append) # every live line
|
|
465
|
+
worker.stream_match.connect(lambda l: alerts.append(l)) # only matching lines
|
|
466
|
+
thread.started.connect(lambda: worker.start_stream("slog2info -w",
|
|
467
|
+
match="error|fail",
|
|
468
|
+
save_to="device.log"))
|
|
469
|
+
# later, from a Stop button:
|
|
470
|
+
worker.stop_stream()
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
## Parallel fleet operations
|
|
474
|
+
|
|
475
|
+
```python
|
|
476
|
+
from ssh_handler import SSHPool, SSHConfig
|
|
477
|
+
|
|
478
|
+
configs = [SSHConfig(host=h, username="root", password="pw")
|
|
479
|
+
for h in ("10.0.0.1", "10.0.0.2", "10.0.0.3")]
|
|
480
|
+
|
|
481
|
+
with SSHPool(configs, max_workers=8) as pool:
|
|
482
|
+
for host, res in pool.run("uptime").items():
|
|
483
|
+
print(host, res.value.stdout.strip() if res else res.error)
|
|
484
|
+
pool.pull("/var/log/syslog", "logs/{host}_syslog.txt") # {host} avoids collisions
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
## FTP / FTPS
|
|
488
|
+
|
|
489
|
+
```python
|
|
490
|
+
from ssh_handler import FTPHandler, FTPConfig
|
|
491
|
+
|
|
492
|
+
with FTPHandler(FTPConfig(host="ftp.example.com", username="u",
|
|
493
|
+
password="p", use_tls=True)) as ftp:
|
|
494
|
+
ftp.push("local.txt", "remote.txt")
|
|
495
|
+
ftp.pull("remote.txt", "copy.txt")
|
|
496
|
+
print(ftp.listdir("/"))
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
## API map
|
|
500
|
+
|
|
501
|
+
```
|
|
502
|
+
ssh_handler/
|
|
503
|
+
config.py SSHConfig, FTPConfig
|
|
504
|
+
credentials.py Secret, CredentialStore, mask, prompt_password
|
|
505
|
+
core.py SSHHandler, ShellSession (SSH + SFTP + SCP + stream + diagnose)
|
|
506
|
+
ftp.py FTPHandler (FTP / FTPS)
|
|
507
|
+
serial_handler.py SerialHandler, list_serial_ports (serial / COM ports)
|
|
508
|
+
winrm_bootstrap.py enable_openssh_via_winrm (one-time sshd enable over WinRM)
|
|
509
|
+
pool.py SSHPool (parallel multi-host)
|
|
510
|
+
cli.py argparse entry point (python -m ssh_handler / ssh-handler)
|
|
511
|
+
pyqt_worker.py SSHWorker (PyQt5, lazy import)
|
|
512
|
+
results.py CommandResult, TransferResult, ShellResult, OperationResult
|
|
513
|
+
exceptions.py SSHError hierarchy
|
|
514
|
+
examples/examples.py copy-paste recipes
|
|
515
|
+
tests/test_offline.py offline checks (no network needed)
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
## Releasing
|
|
519
|
+
|
|
520
|
+
Maintainers: use the helper to build and publish a new version.
|
|
521
|
+
|
|
522
|
+
```bash
|
|
523
|
+
python scripts/release.py 1.0.1 # bump -> build -> twine check -> upload
|
|
524
|
+
python scripts/release.py 1.0.1 --dry-run # build + check only, no upload
|
|
525
|
+
python scripts/release.py patch # auto-bump patch/minor/major
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
The token is read from the `TWINE_PASSWORD` environment variable (username
|
|
529
|
+
`__token__`), never hard-coded. See [`scripts/release.py`](scripts/release.py) and
|
|
530
|
+
the optional [GitHub Actions workflow](.github/workflows/publish.yml) (publishes on
|
|
531
|
+
a `v*` tag). PyPI permanently forbids re-uploading an existing version, so each
|
|
532
|
+
release must use a new version number.
|
|
533
|
+
|
|
534
|
+
## License
|
|
535
|
+
|
|
536
|
+
MIT
|
|
Binary file
|
|
Binary file
|
|
@@ -33,6 +33,34 @@ def _setup_script_path() -> str:
|
|
|
33
33
|
return os.path.join(os.path.dirname(__file__), "setup_openssh_server.ps1")
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
DOCS_URL = "https://pypi.org/project/ssh-handler/"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def open_docs(argv=None) -> int:
|
|
40
|
+
"""Console entry point (`ssh-handler-docs`): open the rendered docs in a
|
|
41
|
+
browser, falling back to the README bundled in the package."""
|
|
42
|
+
import webbrowser
|
|
43
|
+
try:
|
|
44
|
+
if webbrowser.open(DOCS_URL):
|
|
45
|
+
print(f"Opened docs: {DOCS_URL}")
|
|
46
|
+
return 0
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
readme = os.path.join(os.path.dirname(__file__), "README.md")
|
|
50
|
+
if os.path.exists(readme):
|
|
51
|
+
webbrowser.open("file://" + readme)
|
|
52
|
+
print(f"Opened bundled README: {readme}")
|
|
53
|
+
else:
|
|
54
|
+
print(f"Docs: {DOCS_URL}")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def launch_gui(argv=None) -> int:
|
|
59
|
+
"""Console entry point (`ssh-handler-gui`): launch the PyQt5 application."""
|
|
60
|
+
from .gui_app import main as gui_main
|
|
61
|
+
return gui_main()
|
|
62
|
+
|
|
63
|
+
|
|
36
64
|
def setup_server_main(argv=None) -> int:
|
|
37
65
|
"""
|
|
38
66
|
Console entry point (`ssh-handler-setup`): run the bundled PowerShell script
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ssh-handler GUI — a PyQt5 application that performs every operation with a
|
|
3
|
+
button and shows commands, results, and live logs in one pane.
|
|
4
|
+
|
|
5
|
+
Launch:
|
|
6
|
+
ssh-handler-gui (console script after `pip install`)
|
|
7
|
+
python -m ssh_handler.gui_app
|
|
8
|
+
|
|
9
|
+
Build a standalone .exe with the bundled icon:
|
|
10
|
+
python scripts/build_exe.py (uses PyInstaller)
|
|
11
|
+
|
|
12
|
+
Capabilities exposed: connect (with optional jump host / RDP), run commands,
|
|
13
|
+
SFTP push/pull, serial (COM/device) monitoring with match + save, and live SSH
|
|
14
|
+
log streaming (slog2info / tail -f) with match + save.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import queue
|
|
22
|
+
import threading
|
|
23
|
+
import webbrowser
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from PyQt5.QtCore import QThread, pyqtSignal, Qt
|
|
27
|
+
from PyQt5.QtGui import QIcon, QTextCursor, QFont
|
|
28
|
+
from PyQt5.QtWidgets import (
|
|
29
|
+
QApplication, QWidget, QMainWindow, QGridLayout, QVBoxLayout, QHBoxLayout,
|
|
30
|
+
QFormLayout, QLabel, QLineEdit, QPushButton, QPlainTextEdit, QSpinBox,
|
|
31
|
+
QCheckBox, QTabWidget, QGroupBox, QFileDialog, QComboBox, QStatusBar,
|
|
32
|
+
)
|
|
33
|
+
except Exception as exc: # pragma: no cover
|
|
34
|
+
raise ImportError(
|
|
35
|
+
"The GUI needs PyQt5. Install it with: pip install \"ssh-handler[gui]\""
|
|
36
|
+
) from exc
|
|
37
|
+
|
|
38
|
+
from .config import SSHConfig
|
|
39
|
+
from .core import SSHHandler
|
|
40
|
+
from .results import OperationResult
|
|
41
|
+
|
|
42
|
+
ICON_PATH = os.path.join(os.path.dirname(__file__), "assets", "icon.ico")
|
|
43
|
+
DOCS_URL = "https://pypi.org/project/ssh-handler/"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
# Background worker: one thread owns the SSHHandler; jobs are queued callables.
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
class Worker(QThread):
|
|
50
|
+
log = pyqtSignal(str)
|
|
51
|
+
status = pyqtSignal(str, bool) # message, connected?
|
|
52
|
+
done = pyqtSignal(str, object) # label, value
|
|
53
|
+
|
|
54
|
+
def __init__(self):
|
|
55
|
+
super().__init__()
|
|
56
|
+
self._jobs: "queue.Queue" = queue.Queue()
|
|
57
|
+
self._alive = True
|
|
58
|
+
self.ssh: SSHHandler | None = None
|
|
59
|
+
self.stream_stop = threading.Event()
|
|
60
|
+
|
|
61
|
+
def run(self):
|
|
62
|
+
while self._alive:
|
|
63
|
+
job = self._jobs.get()
|
|
64
|
+
if job is None:
|
|
65
|
+
break
|
|
66
|
+
label, fn = job
|
|
67
|
+
try:
|
|
68
|
+
fn()
|
|
69
|
+
except Exception as exc: # never kill the thread
|
|
70
|
+
self.log.emit(f"[ERROR] {label}: {exc}")
|
|
71
|
+
|
|
72
|
+
def submit(self, label, fn):
|
|
73
|
+
self._jobs.put((label, fn))
|
|
74
|
+
|
|
75
|
+
def shutdown(self):
|
|
76
|
+
self._alive = False
|
|
77
|
+
self.stream_stop.set()
|
|
78
|
+
self._jobs.put(None)
|
|
79
|
+
|
|
80
|
+
# ---- jobs (run inside the worker thread) ----
|
|
81
|
+
def do_connect(self, cfg: SSHConfig):
|
|
82
|
+
def _job():
|
|
83
|
+
self.ssh = SSHHandler(cfg, log_callback=self.log.emit, safe=True)
|
|
84
|
+
res = self.ssh.connect()
|
|
85
|
+
ok = bool(res)
|
|
86
|
+
if ok:
|
|
87
|
+
self.status.emit(f"Connected to {cfg.host}", True)
|
|
88
|
+
else:
|
|
89
|
+
self.status.emit(f"Connect failed: {getattr(res,'error','')}", False)
|
|
90
|
+
self.submit("connect", _job)
|
|
91
|
+
|
|
92
|
+
def do_disconnect(self):
|
|
93
|
+
def _job():
|
|
94
|
+
self.stream_stop.set()
|
|
95
|
+
if self.ssh:
|
|
96
|
+
self.ssh.disconnect()
|
|
97
|
+
self.status.emit("Disconnected", False)
|
|
98
|
+
self.submit("disconnect", _job)
|
|
99
|
+
|
|
100
|
+
def _need(self) -> bool:
|
|
101
|
+
if not self.ssh or not self.ssh.is_connected:
|
|
102
|
+
self.log.emit("[ERROR] Not connected. Click Connect first.")
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
def do_run(self, command: str):
|
|
107
|
+
def _job():
|
|
108
|
+
if not self._need():
|
|
109
|
+
return
|
|
110
|
+
res = self.ssh.run(command)
|
|
111
|
+
if isinstance(res, OperationResult) and res.success:
|
|
112
|
+
r = res.value
|
|
113
|
+
if r.stdout:
|
|
114
|
+
self.log.emit(r.stdout.rstrip())
|
|
115
|
+
if r.stderr:
|
|
116
|
+
self.log.emit("[stderr] " + r.stderr.rstrip())
|
|
117
|
+
self.log.emit(f"[exit {r.exit_code}, {r.duration:.2f}s]")
|
|
118
|
+
self.submit("run", _job)
|
|
119
|
+
|
|
120
|
+
def do_push(self, local, remote, recursive):
|
|
121
|
+
def _job():
|
|
122
|
+
if not self._need():
|
|
123
|
+
return
|
|
124
|
+
res = self.ssh.push(local, remote, recursive=recursive)
|
|
125
|
+
if isinstance(res, OperationResult) and res.success:
|
|
126
|
+
self.log.emit(str(res.value))
|
|
127
|
+
self.submit("push", _job)
|
|
128
|
+
|
|
129
|
+
def do_pull(self, remote, local, recursive):
|
|
130
|
+
def _job():
|
|
131
|
+
if not self._need():
|
|
132
|
+
return
|
|
133
|
+
res = self.ssh.pull(remote, local, recursive=recursive)
|
|
134
|
+
if isinstance(res, OperationResult) and res.success:
|
|
135
|
+
self.log.emit(str(res.value))
|
|
136
|
+
self.submit("pull", _job)
|
|
137
|
+
|
|
138
|
+
def do_serial(self, device, baud, match, save_to):
|
|
139
|
+
def _job():
|
|
140
|
+
if not self._need():
|
|
141
|
+
return
|
|
142
|
+
self.stream_stop.clear()
|
|
143
|
+
self.log.emit(f"--- serial {device} @ {baud} (Stop to end) ---")
|
|
144
|
+
self.ssh.serial_stream(device, baudrate=baud, on_line=self.log.emit,
|
|
145
|
+
match=(match or None), save_to=(save_to or None),
|
|
146
|
+
stop_event=self.stream_stop)
|
|
147
|
+
self.log.emit("--- serial stopped ---")
|
|
148
|
+
self.submit("serial", _job)
|
|
149
|
+
|
|
150
|
+
def do_stream(self, command, match, save_to):
|
|
151
|
+
def _job():
|
|
152
|
+
if not self._need():
|
|
153
|
+
return
|
|
154
|
+
self.stream_stop.clear()
|
|
155
|
+
self.log.emit(f"--- streaming '{command}' (Stop to end) ---")
|
|
156
|
+
self.ssh.stream(command, on_line=self.log.emit, match=(match or None),
|
|
157
|
+
save_to=(save_to or None), stop_event=self.stream_stop)
|
|
158
|
+
self.log.emit("--- stream stopped ---")
|
|
159
|
+
self.submit("stream", _job)
|
|
160
|
+
|
|
161
|
+
def stop_stream(self):
|
|
162
|
+
self.stream_stop.set()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --------------------------------------------------------------------------- #
|
|
166
|
+
# Main window
|
|
167
|
+
# --------------------------------------------------------------------------- #
|
|
168
|
+
class MainWindow(QMainWindow):
|
|
169
|
+
def __init__(self):
|
|
170
|
+
super().__init__()
|
|
171
|
+
self.setWindowTitle("ssh-handler — Automotive SSH toolkit")
|
|
172
|
+
if os.path.exists(ICON_PATH):
|
|
173
|
+
self.setWindowIcon(QIcon(ICON_PATH))
|
|
174
|
+
self.resize(900, 680)
|
|
175
|
+
|
|
176
|
+
self.worker = Worker()
|
|
177
|
+
self.worker.log.connect(self._append_log)
|
|
178
|
+
self.worker.status.connect(self._on_status)
|
|
179
|
+
self.worker.start()
|
|
180
|
+
|
|
181
|
+
central = QWidget()
|
|
182
|
+
self.setCentralWidget(central)
|
|
183
|
+
root = QVBoxLayout(central)
|
|
184
|
+
root.addWidget(self._build_connection_group())
|
|
185
|
+
root.addWidget(self._build_tabs())
|
|
186
|
+
root.addWidget(self._build_log_group(), stretch=1)
|
|
187
|
+
self.setStatusBar(QStatusBar())
|
|
188
|
+
self.statusBar().showMessage("Not connected")
|
|
189
|
+
|
|
190
|
+
# ---- connection panel ----
|
|
191
|
+
def _build_connection_group(self):
|
|
192
|
+
g = QGroupBox("Connection")
|
|
193
|
+
lay = QGridLayout(g)
|
|
194
|
+
self.host = QLineEdit(); self.host.setPlaceholderText("target host / IP")
|
|
195
|
+
self.port = QSpinBox(); self.port.setRange(1, 65535); self.port.setValue(22)
|
|
196
|
+
self.user = QLineEdit()
|
|
197
|
+
self.domain = QLineEdit(); self.domain.setPlaceholderText("optional domain")
|
|
198
|
+
self.password = QLineEdit(); self.password.setEchoMode(QLineEdit.Password)
|
|
199
|
+
self.ignore_hostkey = QCheckBox("Ignore host key (lab)")
|
|
200
|
+
self.ignore_hostkey.setChecked(True)
|
|
201
|
+
|
|
202
|
+
self.use_jump = QCheckBox("Via jump host (RDP machine)")
|
|
203
|
+
self.jhost = QLineEdit(); self.jhost.setPlaceholderText("jump host / IP")
|
|
204
|
+
self.juser = QLineEdit(); self.juser.setPlaceholderText("jump user")
|
|
205
|
+
self.jdomain = QLineEdit(); self.jdomain.setPlaceholderText("jump domain")
|
|
206
|
+
self.jpass = QLineEdit(); self.jpass.setEchoMode(QLineEdit.Password)
|
|
207
|
+
self.jpass.setPlaceholderText("jump password")
|
|
208
|
+
|
|
209
|
+
self.btn_connect = QPushButton("Connect")
|
|
210
|
+
self.btn_disconnect = QPushButton("Disconnect")
|
|
211
|
+
self.btn_disconnect.setEnabled(False)
|
|
212
|
+
self.btn_connect.clicked.connect(self._connect)
|
|
213
|
+
self.btn_disconnect.clicked.connect(self._disconnect)
|
|
214
|
+
|
|
215
|
+
lay.addWidget(QLabel("Host"), 0, 0); lay.addWidget(self.host, 0, 1)
|
|
216
|
+
lay.addWidget(QLabel("Port"), 0, 2); lay.addWidget(self.port, 0, 3)
|
|
217
|
+
lay.addWidget(QLabel("User"), 1, 0); lay.addWidget(self.user, 1, 1)
|
|
218
|
+
lay.addWidget(QLabel("Domain"), 1, 2); lay.addWidget(self.domain, 1, 3)
|
|
219
|
+
lay.addWidget(QLabel("Password"), 2, 0); lay.addWidget(self.password, 2, 1)
|
|
220
|
+
lay.addWidget(self.ignore_hostkey, 2, 2, 1, 2)
|
|
221
|
+
lay.addWidget(self.use_jump, 3, 0, 1, 4)
|
|
222
|
+
lay.addWidget(self.jhost, 4, 0, 1, 2); lay.addWidget(self.juser, 4, 2, 1, 2)
|
|
223
|
+
lay.addWidget(self.jdomain, 5, 0, 1, 2); lay.addWidget(self.jpass, 5, 2, 1, 2)
|
|
224
|
+
lay.addWidget(self.btn_connect, 6, 0, 1, 2)
|
|
225
|
+
lay.addWidget(self.btn_disconnect, 6, 2, 1, 2)
|
|
226
|
+
return g
|
|
227
|
+
|
|
228
|
+
# ---- operation tabs ----
|
|
229
|
+
def _build_tabs(self):
|
|
230
|
+
tabs = QTabWidget()
|
|
231
|
+
|
|
232
|
+
# Command
|
|
233
|
+
cmd = QWidget(); cl = QHBoxLayout(cmd)
|
|
234
|
+
self.cmd_in = QLineEdit("uname -a")
|
|
235
|
+
run_btn = QPushButton("Run")
|
|
236
|
+
run_btn.clicked.connect(lambda: self.worker.do_run(self.cmd_in.text().strip()))
|
|
237
|
+
self.cmd_in.returnPressed.connect(run_btn.click)
|
|
238
|
+
cl.addWidget(QLabel("Command")); cl.addWidget(self.cmd_in, 1); cl.addWidget(run_btn)
|
|
239
|
+
tabs.addTab(cmd, "Command")
|
|
240
|
+
|
|
241
|
+
# Files
|
|
242
|
+
files = QWidget(); fl = QGridLayout(files)
|
|
243
|
+
self.local_path = QLineEdit(); self.remote_path = QLineEdit()
|
|
244
|
+
lb = QPushButton("Browse"); lb.clicked.connect(self._browse_local)
|
|
245
|
+
self.recursive = QCheckBox("Recursive (folder)")
|
|
246
|
+
push_btn = QPushButton("Push (upload)")
|
|
247
|
+
pull_btn = QPushButton("Pull (download)")
|
|
248
|
+
push_btn.clicked.connect(lambda: self.worker.do_push(
|
|
249
|
+
self.local_path.text().strip(), self.remote_path.text().strip(),
|
|
250
|
+
self.recursive.isChecked()))
|
|
251
|
+
pull_btn.clicked.connect(lambda: self.worker.do_pull(
|
|
252
|
+
self.remote_path.text().strip(), self.local_path.text().strip(),
|
|
253
|
+
self.recursive.isChecked()))
|
|
254
|
+
fl.addWidget(QLabel("Local"), 0, 0); fl.addWidget(self.local_path, 0, 1)
|
|
255
|
+
fl.addWidget(lb, 0, 2)
|
|
256
|
+
fl.addWidget(QLabel("Remote"), 1, 0); fl.addWidget(self.remote_path, 1, 1, 1, 2)
|
|
257
|
+
fl.addWidget(self.recursive, 2, 0)
|
|
258
|
+
fl.addWidget(push_btn, 2, 1); fl.addWidget(pull_btn, 2, 2)
|
|
259
|
+
tabs.addTab(files, "Files (SFTP)")
|
|
260
|
+
|
|
261
|
+
# Serial
|
|
262
|
+
ser = QWidget(); sl = QGridLayout(ser)
|
|
263
|
+
self.ser_dev = QLineEdit("COM5")
|
|
264
|
+
self.ser_baud = QSpinBox(); self.ser_baud.setRange(300, 4000000)
|
|
265
|
+
self.ser_baud.setValue(115200)
|
|
266
|
+
self.ser_match = QLineEdit(); self.ser_match.setPlaceholderText("regex to match (optional)")
|
|
267
|
+
self.ser_save = QLineEdit(); self.ser_save.setPlaceholderText("save to file (optional)")
|
|
268
|
+
sb = QPushButton("Browse"); sb.clicked.connect(lambda: self._browse_save(self.ser_save))
|
|
269
|
+
ser_start = QPushButton("Start"); ser_stop = QPushButton("Stop")
|
|
270
|
+
ser_start.clicked.connect(lambda: self.worker.do_serial(
|
|
271
|
+
self.ser_dev.text().strip(), self.ser_baud.value(),
|
|
272
|
+
self.ser_match.text().strip(), self.ser_save.text().strip()))
|
|
273
|
+
ser_stop.clicked.connect(self.worker.stop_stream)
|
|
274
|
+
sl.addWidget(QLabel("Device"), 0, 0); sl.addWidget(self.ser_dev, 0, 1)
|
|
275
|
+
sl.addWidget(QLabel("Baud"), 0, 2); sl.addWidget(self.ser_baud, 0, 3)
|
|
276
|
+
sl.addWidget(QLabel("Match"), 1, 0); sl.addWidget(self.ser_match, 1, 1, 1, 3)
|
|
277
|
+
sl.addWidget(QLabel("Save"), 2, 0); sl.addWidget(self.ser_save, 2, 1, 1, 2)
|
|
278
|
+
sl.addWidget(sb, 2, 3)
|
|
279
|
+
sl.addWidget(ser_start, 3, 1); sl.addWidget(ser_stop, 3, 2)
|
|
280
|
+
tabs.addTab(ser, "Serial")
|
|
281
|
+
|
|
282
|
+
# Log stream
|
|
283
|
+
strm = QWidget(); stl = QGridLayout(strm)
|
|
284
|
+
self.strm_cmd = QLineEdit("slog2info -w")
|
|
285
|
+
self.strm_match = QLineEdit(); self.strm_match.setPlaceholderText("regex to match (optional)")
|
|
286
|
+
self.strm_save = QLineEdit(); self.strm_save.setPlaceholderText("save to file (optional)")
|
|
287
|
+
stb = QPushButton("Browse"); stb.clicked.connect(lambda: self._browse_save(self.strm_save))
|
|
288
|
+
strm_start = QPushButton("Start"); strm_stop = QPushButton("Stop")
|
|
289
|
+
strm_start.clicked.connect(lambda: self.worker.do_stream(
|
|
290
|
+
self.strm_cmd.text().strip(), self.strm_match.text().strip(),
|
|
291
|
+
self.strm_save.text().strip()))
|
|
292
|
+
strm_stop.clicked.connect(self.worker.stop_stream)
|
|
293
|
+
stl.addWidget(QLabel("Command"), 0, 0); stl.addWidget(self.strm_cmd, 0, 1, 1, 3)
|
|
294
|
+
stl.addWidget(QLabel("Match"), 1, 0); stl.addWidget(self.strm_match, 1, 1, 1, 3)
|
|
295
|
+
stl.addWidget(QLabel("Save"), 2, 0); stl.addWidget(self.strm_save, 2, 1, 1, 2)
|
|
296
|
+
stl.addWidget(stb, 2, 3)
|
|
297
|
+
stl.addWidget(strm_start, 3, 1); stl.addWidget(strm_stop, 3, 2)
|
|
298
|
+
tabs.addTab(strm, "Log stream")
|
|
299
|
+
|
|
300
|
+
return tabs
|
|
301
|
+
|
|
302
|
+
# ---- log pane ----
|
|
303
|
+
def _build_log_group(self):
|
|
304
|
+
g = QGroupBox("Log")
|
|
305
|
+
lay = QVBoxLayout(g)
|
|
306
|
+
self.log_view = QPlainTextEdit(); self.log_view.setReadOnly(True)
|
|
307
|
+
self.log_view.setFont(QFont("Consolas", 9))
|
|
308
|
+
self.log_view.setMaximumBlockCount(20000)
|
|
309
|
+
row = QHBoxLayout()
|
|
310
|
+
clear = QPushButton("Clear"); clear.clicked.connect(self.log_view.clear)
|
|
311
|
+
savelog = QPushButton("Save log…"); savelog.clicked.connect(self._save_log)
|
|
312
|
+
docs = QPushButton("Help / Docs"); docs.clicked.connect(lambda: webbrowser.open(DOCS_URL))
|
|
313
|
+
row.addWidget(clear); row.addWidget(savelog); row.addStretch(1); row.addWidget(docs)
|
|
314
|
+
lay.addWidget(self.log_view, 1); lay.addLayout(row)
|
|
315
|
+
return g
|
|
316
|
+
|
|
317
|
+
# ---- actions ----
|
|
318
|
+
def _build_config(self) -> SSHConfig:
|
|
319
|
+
policy = "ignore" if self.ignore_hostkey.isChecked() else "auto"
|
|
320
|
+
jump = None
|
|
321
|
+
if self.use_jump.isChecked() and self.jhost.text().strip():
|
|
322
|
+
jump = SSHConfig(host=self.jhost.text().strip(),
|
|
323
|
+
username=self.juser.text().strip() or None,
|
|
324
|
+
domain=self.jdomain.text().strip() or None,
|
|
325
|
+
password=self.jpass.text(), host_key_policy=policy)
|
|
326
|
+
return SSHConfig(
|
|
327
|
+
host=self.host.text().strip(), port=self.port.value(),
|
|
328
|
+
username=self.user.text().strip() or None,
|
|
329
|
+
domain=self.domain.text().strip() or None,
|
|
330
|
+
password=self.password.text(), jump_host=jump,
|
|
331
|
+
host_key_policy=policy,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _connect(self):
|
|
335
|
+
if not self.host.text().strip():
|
|
336
|
+
self._append_log("[ERROR] Enter a host first.")
|
|
337
|
+
return
|
|
338
|
+
self._append_log(f"Connecting to {self.host.text().strip()}…")
|
|
339
|
+
self.worker.do_connect(self._build_config())
|
|
340
|
+
|
|
341
|
+
def _disconnect(self):
|
|
342
|
+
self.worker.do_disconnect()
|
|
343
|
+
|
|
344
|
+
def _on_status(self, msg, connected):
|
|
345
|
+
self.statusBar().showMessage(msg)
|
|
346
|
+
self._append_log(msg)
|
|
347
|
+
self.btn_connect.setEnabled(not connected)
|
|
348
|
+
self.btn_disconnect.setEnabled(connected)
|
|
349
|
+
|
|
350
|
+
def _append_log(self, text):
|
|
351
|
+
self.log_view.appendPlainText(text)
|
|
352
|
+
self.log_view.moveCursor(QTextCursor.End)
|
|
353
|
+
|
|
354
|
+
def _browse_local(self):
|
|
355
|
+
path, _ = QFileDialog.getOpenFileName(self, "Choose local file")
|
|
356
|
+
if path:
|
|
357
|
+
self.local_path.setText(path)
|
|
358
|
+
|
|
359
|
+
def _browse_save(self, target):
|
|
360
|
+
path, _ = QFileDialog.getSaveFileName(self, "Save to file")
|
|
361
|
+
if path:
|
|
362
|
+
target.setText(path)
|
|
363
|
+
|
|
364
|
+
def _save_log(self):
|
|
365
|
+
path, _ = QFileDialog.getSaveFileName(self, "Save log", "session.log")
|
|
366
|
+
if path:
|
|
367
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
368
|
+
fh.write(self.log_view.toPlainText())
|
|
369
|
+
self._append_log(f"Log saved to {path}")
|
|
370
|
+
|
|
371
|
+
def closeEvent(self, event):
|
|
372
|
+
self.worker.shutdown()
|
|
373
|
+
self.worker.wait(2000)
|
|
374
|
+
super().closeEvent(event)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _maybe_open_docs_first_run():
|
|
378
|
+
"""Open the docs in a browser the first time the app is launched."""
|
|
379
|
+
try:
|
|
380
|
+
flag_dir = os.path.join(os.path.expanduser("~"), ".ssh-handler")
|
|
381
|
+
flag = os.path.join(flag_dir, "docs-opened")
|
|
382
|
+
if not os.path.exists(flag):
|
|
383
|
+
os.makedirs(flag_dir, exist_ok=True)
|
|
384
|
+
webbrowser.open(DOCS_URL)
|
|
385
|
+
with open(flag, "w") as fh:
|
|
386
|
+
fh.write("1")
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def main():
|
|
392
|
+
app = QApplication(sys.argv)
|
|
393
|
+
if os.path.exists(ICON_PATH):
|
|
394
|
+
app.setWindowIcon(QIcon(ICON_PATH))
|
|
395
|
+
_maybe_open_docs_first_run()
|
|
396
|
+
win = MainWindow()
|
|
397
|
+
win.show()
|
|
398
|
+
return app.exec_()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if __name__ == "__main__":
|
|
402
|
+
raise SystemExit(main())
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
LICENSE
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
|
+
ssh_handler/README.md
|
|
4
5
|
ssh_handler/__init__.py
|
|
5
6
|
ssh_handler/__main__.py
|
|
6
7
|
ssh_handler/cli.py
|
|
@@ -9,6 +10,7 @@ ssh_handler/core.py
|
|
|
9
10
|
ssh_handler/credentials.py
|
|
10
11
|
ssh_handler/exceptions.py
|
|
11
12
|
ssh_handler/ftp.py
|
|
13
|
+
ssh_handler/gui_app.py
|
|
12
14
|
ssh_handler/pool.py
|
|
13
15
|
ssh_handler/pyqt_worker.py
|
|
14
16
|
ssh_handler/results.py
|
|
@@ -21,6 +23,8 @@ ssh_handler.egg-info/dependency_links.txt
|
|
|
21
23
|
ssh_handler.egg-info/entry_points.txt
|
|
22
24
|
ssh_handler.egg-info/requires.txt
|
|
23
25
|
ssh_handler.egg-info/top_level.txt
|
|
26
|
+
ssh_handler/assets/icon.ico
|
|
27
|
+
ssh_handler/assets/icon.png
|
|
24
28
|
ssh_handler/openssh/OpenSSH-ARM64.zip
|
|
25
29
|
ssh_handler/openssh/OpenSSH-Win32.zip
|
|
26
30
|
ssh_handler/openssh/OpenSSH-Win64.zip
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|