ssh-handler 1.0.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.0.0/PKG-INFO +182 -0
- ssh_handler-1.0.0/README.md +163 -0
- ssh_handler-1.0.0/pyproject.toml +26 -0
- ssh_handler-1.0.0/setup.cfg +4 -0
- ssh_handler-1.0.0/ssh_handler/__init__.py +52 -0
- ssh_handler-1.0.0/ssh_handler/__main__.py +5 -0
- ssh_handler-1.0.0/ssh_handler/cli.py +152 -0
- ssh_handler-1.0.0/ssh_handler/config.py +114 -0
- ssh_handler-1.0.0/ssh_handler/core.py +793 -0
- ssh_handler-1.0.0/ssh_handler/credentials.py +144 -0
- ssh_handler-1.0.0/ssh_handler/exceptions.py +49 -0
- ssh_handler-1.0.0/ssh_handler/ftp.py +186 -0
- ssh_handler-1.0.0/ssh_handler/pool.py +103 -0
- ssh_handler-1.0.0/ssh_handler/pyqt_worker.py +94 -0
- ssh_handler-1.0.0/ssh_handler/results.py +127 -0
- ssh_handler-1.0.0/ssh_handler.egg-info/PKG-INFO +182 -0
- ssh_handler-1.0.0/ssh_handler.egg-info/SOURCES.txt +20 -0
- ssh_handler-1.0.0/ssh_handler.egg-info/dependency_links.txt +1 -0
- ssh_handler-1.0.0/ssh_handler.egg-info/entry_points.txt +2 -0
- ssh_handler-1.0.0/ssh_handler.egg-info/requires.txt +15 -0
- ssh_handler-1.0.0/ssh_handler.egg-info/top_level.txt +1 -0
- ssh_handler-1.0.0/tests/test_offline.py +72 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ssh-handler
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Extensive SSH/SFTP/SCP/FTP handler built on Paramiko, for test automation, CLIs and PyQt5 tools.
|
|
5
|
+
Author: Naveen
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: paramiko>=3.0
|
|
9
|
+
Provides-Extra: secure
|
|
10
|
+
Requires-Dist: keyring>=23.0; extra == "secure"
|
|
11
|
+
Provides-Extra: scp
|
|
12
|
+
Requires-Dist: scp>=0.14; extra == "scp"
|
|
13
|
+
Provides-Extra: gui
|
|
14
|
+
Requires-Dist: PyQt5>=5.15; extra == "gui"
|
|
15
|
+
Provides-Extra: all
|
|
16
|
+
Requires-Dist: keyring>=23.0; extra == "all"
|
|
17
|
+
Requires-Dist: scp>=0.14; extra == "all"
|
|
18
|
+
Requires-Dist: PyQt5>=5.15; extra == "all"
|
|
19
|
+
|
|
20
|
+
# ssh_handler
|
|
21
|
+
|
|
22
|
+
An extensive **SSH / SFTP / SCP / FTP** handler built on [Paramiko](https://www.paramiko.org/),
|
|
23
|
+
packaged so the *same* code works in three places:
|
|
24
|
+
|
|
25
|
+
- a **test-automation framework** (raise-on-error, pytest fixtures, parallel fleet ops),
|
|
26
|
+
- a **standalone CLI** (`python -m ssh_handler …`, argument-driven),
|
|
27
|
+
- a **PyQt5 tool** (safe mode + log streaming via Qt signals, off the GUI thread).
|
|
28
|
+
|
|
29
|
+
Passwords are wrapped in a `Secret` and stored in the **OS credential vault** — they
|
|
30
|
+
never appear in logs, reprs, tracebacks, or on disk in plaintext.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install -e . # core (paramiko)
|
|
36
|
+
pip install -e ".[all]" # + keyring, scp, PyQt5
|
|
37
|
+
# or just the pieces you need:
|
|
38
|
+
pip install -e ".[secure]" # keyring (confidential password storage)
|
|
39
|
+
pip install -e ".[scp]" # scp (SCP-protocol transfers)
|
|
40
|
+
pip install -e ".[gui]" # PyQt5 (the worker)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from ssh_handler import SSHHandler, SSHConfig
|
|
47
|
+
|
|
48
|
+
with SSHHandler(SSHConfig(host="10.0.0.5", username="root", password="pw")) as ssh:
|
|
49
|
+
print(ssh.run("uptime").stdout)
|
|
50
|
+
ssh.run("systemctl restart nginx", check=True) # raises on non-zero exit
|
|
51
|
+
ssh.push("local.txt", "/tmp/remote.txt") # SFTP upload
|
|
52
|
+
ssh.pull("/etc/nginx", "./backup", recursive=True) # recursive download
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Connecting to your RDP / Windows hosts (domain login `EU\nkennedy`)
|
|
56
|
+
|
|
57
|
+
Those Windows machines need **OpenSSH Server** enabled (Settings → Optional Features →
|
|
58
|
+
"OpenSSH Server", or `Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0`).
|
|
59
|
+
Then you log in with your normal domain credentials.
|
|
60
|
+
|
|
61
|
+
**Pass `domain` and `username` separately** — never hard-code `"EU\nkennedy"` as a Python
|
|
62
|
+
string, because `\n` becomes a newline. The handler builds `EU\nkennedy` for you:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from ssh_handler import SSHHandler, SSHConfig, CredentialStore
|
|
66
|
+
|
|
67
|
+
store = CredentialStore(service="my_test_lab")
|
|
68
|
+
cfg = SSHConfig(
|
|
69
|
+
host="10.20.30.40",
|
|
70
|
+
domain="EU", username="nkennedy",
|
|
71
|
+
password=store.get("EU\\nkennedy"), # a Secret pulled from the OS vault
|
|
72
|
+
remote_os="windows", # skip the OS probe
|
|
73
|
+
fast_auth=True, # skip key probing -> faster login
|
|
74
|
+
)
|
|
75
|
+
with SSHHandler(cfg) as ssh:
|
|
76
|
+
print(ssh.run("whoami").stdout) # EU\nkennedy
|
|
77
|
+
print(ssh.run("powershell Get-Service sshd").stdout)
|
|
78
|
+
ssh.push("report.xlsx", "C:/Users/nkennedy/Desktop/report.xlsx")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Store the password **once** so it's never typed or committed again:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
CredentialStore("my_test_lab").set("EU\\nkennedy", prompt_password())
|
|
85
|
+
# or from the CLI:
|
|
86
|
+
python -m ssh_handler store-credential --user nkennedy --domain EU --service my_test_lab
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Confidential credentials
|
|
90
|
+
|
|
91
|
+
| Mechanism | What it does |
|
|
92
|
+
|-----------|--------------|
|
|
93
|
+
| `Secret` | wraps a password; `str()`/`repr()`/logs show `********`; only `.reveal()` exposes it |
|
|
94
|
+
| `mask()` | redacts secret values from any log line (applied automatically to all logging) |
|
|
95
|
+
| `CredentialStore` | stores/reads passwords in Windows Credential Manager (or macOS Keychain / Secret Service) via `keyring` — **no plaintext on disk** |
|
|
96
|
+
| `prompt_password()` | hidden terminal input |
|
|
97
|
+
|
|
98
|
+
## Speed
|
|
99
|
+
|
|
100
|
+
- **`fast_auth`** (default on): when a password is supplied, key/agent probing is skipped —
|
|
101
|
+
faster logins and no "Too many authentication failures" from the server's `MaxAuthTries`.
|
|
102
|
+
- One SFTP channel is opened lazily and **reused** across operations.
|
|
103
|
+
- SFTP downloads use Paramiko **prefetch** for high throughput.
|
|
104
|
+
- **`SSHPool`** runs the same command/transfer across many hosts in parallel threads.
|
|
105
|
+
- `remote_os="windows"|"linux"` skips the one-time OS-detection probe.
|
|
106
|
+
- `compress=True` for slow/high-latency links; keepalives keep long sessions alive.
|
|
107
|
+
|
|
108
|
+
## What's covered (Paramiko parity, plus extras)
|
|
109
|
+
|
|
110
|
+
- **Exec**: `run` (timeout, `check`, pty, env), `run_many`, `sudo` (password via stdin)
|
|
111
|
+
- **Interactive shell**: `open_shell()` → `ShellSession` with `send` / `read_until` / `read_available`
|
|
112
|
+
- **SFTP**: `listdir`, `listdir_attr`, `stat`, `lstat`, `exists`, `isdir`, `mkdir`, `makedirs`,
|
|
113
|
+
`rmdir`, `remove`, `rename`, `chmod`, `chown`, `symlink`, `readlink`, `open`,
|
|
114
|
+
`read_text`, `write_text`, `walk`, and `push`/`pull` (recursive, progress, stats)
|
|
115
|
+
- **SCP**: `scp_push` / `scp_pull` (needs the `scp` extra)
|
|
116
|
+
- **FTP/FTPS**: `FTPHandler` (stdlib `ftplib`, no extra dependency)
|
|
117
|
+
- **Jump host / bastion**: `SSHConfig(jump_host=…)` (ProxyJump-style)
|
|
118
|
+
- **Escape hatch**: `ssh.client` and `ssh.transport` expose the raw Paramiko objects
|
|
119
|
+
|
|
120
|
+
## Result objects
|
|
121
|
+
|
|
122
|
+
Every action returns structured data, not bare strings:
|
|
123
|
+
|
|
124
|
+
- `CommandResult` — `exit_code`, `stdout`, `stderr`, `duration`, `host`, `.ok`, `.as_dict()`
|
|
125
|
+
- `TransferResult` — `size_bytes`, `duration`, `speed_bps`, `human_speed`, `files`
|
|
126
|
+
- `ShellResult` — `output`, `matched`, `timed_out`, `duration`
|
|
127
|
+
- `OperationResult` — safe-mode wrapper: `bool(res)`, `res.value`, `res.error`, `res.unwrap()`
|
|
128
|
+
|
|
129
|
+
## Two error-handling styles
|
|
130
|
+
|
|
131
|
+
- **Raise (default)** — for tests/scripts. Typed exceptions: `SSHConnectionError`,
|
|
132
|
+
`SSHAuthenticationError`, `SSHTimeoutError`, `SSHCommandError`, `SSHTransferError`,
|
|
133
|
+
`FTPError`, `CredentialError` — all subclasses of `SSHError`. **Auto-retry** on connect
|
|
134
|
+
and **auto-reconnect** on dropped sessions are built in.
|
|
135
|
+
- **Safe mode** (`SSHHandler(cfg, safe=True)`) — for GUIs. Every call returns an
|
|
136
|
+
`OperationResult` instead of raising, so the event loop never dies. Override per call
|
|
137
|
+
with `safe=True/False`.
|
|
138
|
+
|
|
139
|
+
## CLI
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python -m ssh_handler run --host H --user U --domain EU uptime
|
|
143
|
+
python -m ssh_handler push --host H --user U ./build /tmp/build --recursive
|
|
144
|
+
python -m ssh_handler pull --host H --user U /var/log ./logs --recursive
|
|
145
|
+
python -m ssh_handler info --host H --user U --json
|
|
146
|
+
python -m ssh_handler run --host H --user U --use-stored uptime # vault password
|
|
147
|
+
```
|
|
148
|
+
Password options: `--password` (hidden prompt), `--use-stored` (OS vault), `--key FILE`.
|
|
149
|
+
Add `--json` for machine-readable output.
|
|
150
|
+
|
|
151
|
+
## PyQt5
|
|
152
|
+
|
|
153
|
+
`ssh_handler.pyqt_worker.SSHWorker` is a `QObject` wrapping the handler in **safe mode**.
|
|
154
|
+
`moveToThread()` it, connect its signals (`log`, `connected`, `command_done`,
|
|
155
|
+
`transfer_done`, `progress`, `error`, `finished`), and drive it from the GUI — see recipe
|
|
156
|
+
in [examples/examples.py](examples/examples.py).
|
|
157
|
+
|
|
158
|
+
## Project layout
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
ssh_handler/
|
|
162
|
+
config.py SSHConfig, FTPConfig
|
|
163
|
+
credentials.py Secret, CredentialStore, mask, prompt_password
|
|
164
|
+
core.py SSHHandler, ShellSession (SSH + SFTP + SCP)
|
|
165
|
+
ftp.py FTPHandler (FTP/FTPS)
|
|
166
|
+
pool.py SSHPool (parallel multi-host)
|
|
167
|
+
cli.py argparse entry point (python -m ssh_handler)
|
|
168
|
+
pyqt_worker.py SSHWorker (PyQt5, lazy import)
|
|
169
|
+
results.py CommandResult, TransferResult, ShellResult, OperationResult
|
|
170
|
+
exceptions.py SSHError hierarchy
|
|
171
|
+
examples/examples.py
|
|
172
|
+
tests/test_offline.py (no network needed)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Tests
|
|
176
|
+
|
|
177
|
+
`tests/test_offline.py` validates secret masking, domain-login building, fast-auth
|
|
178
|
+
kwargs, recursive-mkdir logic, transfer math, and both error-handling modes — all offline.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
python tests/test_offline.py
|
|
182
|
+
```
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# ssh_handler
|
|
2
|
+
|
|
3
|
+
An extensive **SSH / SFTP / SCP / FTP** handler built on [Paramiko](https://www.paramiko.org/),
|
|
4
|
+
packaged so the *same* code works in three places:
|
|
5
|
+
|
|
6
|
+
- a **test-automation framework** (raise-on-error, pytest fixtures, parallel fleet ops),
|
|
7
|
+
- a **standalone CLI** (`python -m ssh_handler …`, argument-driven),
|
|
8
|
+
- a **PyQt5 tool** (safe mode + log streaming via Qt signals, off the GUI thread).
|
|
9
|
+
|
|
10
|
+
Passwords are wrapped in a `Secret` and stored in the **OS credential vault** — they
|
|
11
|
+
never appear in logs, reprs, tracebacks, or on disk in plaintext.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install -e . # core (paramiko)
|
|
17
|
+
pip install -e ".[all]" # + keyring, scp, PyQt5
|
|
18
|
+
# or just the pieces you need:
|
|
19
|
+
pip install -e ".[secure]" # keyring (confidential password storage)
|
|
20
|
+
pip install -e ".[scp]" # scp (SCP-protocol transfers)
|
|
21
|
+
pip install -e ".[gui]" # PyQt5 (the worker)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from ssh_handler import SSHHandler, SSHConfig
|
|
28
|
+
|
|
29
|
+
with SSHHandler(SSHConfig(host="10.0.0.5", username="root", password="pw")) as ssh:
|
|
30
|
+
print(ssh.run("uptime").stdout)
|
|
31
|
+
ssh.run("systemctl restart nginx", check=True) # raises on non-zero exit
|
|
32
|
+
ssh.push("local.txt", "/tmp/remote.txt") # SFTP upload
|
|
33
|
+
ssh.pull("/etc/nginx", "./backup", recursive=True) # recursive download
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Connecting to your RDP / Windows hosts (domain login `EU\nkennedy`)
|
|
37
|
+
|
|
38
|
+
Those Windows machines need **OpenSSH Server** enabled (Settings → Optional Features →
|
|
39
|
+
"OpenSSH Server", or `Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0`).
|
|
40
|
+
Then you log in with your normal domain credentials.
|
|
41
|
+
|
|
42
|
+
**Pass `domain` and `username` separately** — never hard-code `"EU\nkennedy"` as a Python
|
|
43
|
+
string, because `\n` becomes a newline. The handler builds `EU\nkennedy` for you:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from ssh_handler import SSHHandler, SSHConfig, CredentialStore
|
|
47
|
+
|
|
48
|
+
store = CredentialStore(service="my_test_lab")
|
|
49
|
+
cfg = SSHConfig(
|
|
50
|
+
host="10.20.30.40",
|
|
51
|
+
domain="EU", username="nkennedy",
|
|
52
|
+
password=store.get("EU\\nkennedy"), # a Secret pulled from the OS vault
|
|
53
|
+
remote_os="windows", # skip the OS probe
|
|
54
|
+
fast_auth=True, # skip key probing -> faster login
|
|
55
|
+
)
|
|
56
|
+
with SSHHandler(cfg) as ssh:
|
|
57
|
+
print(ssh.run("whoami").stdout) # EU\nkennedy
|
|
58
|
+
print(ssh.run("powershell Get-Service sshd").stdout)
|
|
59
|
+
ssh.push("report.xlsx", "C:/Users/nkennedy/Desktop/report.xlsx")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Store the password **once** so it's never typed or committed again:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
CredentialStore("my_test_lab").set("EU\\nkennedy", prompt_password())
|
|
66
|
+
# or from the CLI:
|
|
67
|
+
python -m ssh_handler store-credential --user nkennedy --domain EU --service my_test_lab
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Confidential credentials
|
|
71
|
+
|
|
72
|
+
| Mechanism | What it does |
|
|
73
|
+
|-----------|--------------|
|
|
74
|
+
| `Secret` | wraps a password; `str()`/`repr()`/logs show `********`; only `.reveal()` exposes it |
|
|
75
|
+
| `mask()` | redacts secret values from any log line (applied automatically to all logging) |
|
|
76
|
+
| `CredentialStore` | stores/reads passwords in Windows Credential Manager (or macOS Keychain / Secret Service) via `keyring` — **no plaintext on disk** |
|
|
77
|
+
| `prompt_password()` | hidden terminal input |
|
|
78
|
+
|
|
79
|
+
## Speed
|
|
80
|
+
|
|
81
|
+
- **`fast_auth`** (default on): when a password is supplied, key/agent probing is skipped —
|
|
82
|
+
faster logins and no "Too many authentication failures" from the server's `MaxAuthTries`.
|
|
83
|
+
- One SFTP channel is opened lazily and **reused** across operations.
|
|
84
|
+
- SFTP downloads use Paramiko **prefetch** for high throughput.
|
|
85
|
+
- **`SSHPool`** runs the same command/transfer across many hosts in parallel threads.
|
|
86
|
+
- `remote_os="windows"|"linux"` skips the one-time OS-detection probe.
|
|
87
|
+
- `compress=True` for slow/high-latency links; keepalives keep long sessions alive.
|
|
88
|
+
|
|
89
|
+
## What's covered (Paramiko parity, plus extras)
|
|
90
|
+
|
|
91
|
+
- **Exec**: `run` (timeout, `check`, pty, env), `run_many`, `sudo` (password via stdin)
|
|
92
|
+
- **Interactive shell**: `open_shell()` → `ShellSession` with `send` / `read_until` / `read_available`
|
|
93
|
+
- **SFTP**: `listdir`, `listdir_attr`, `stat`, `lstat`, `exists`, `isdir`, `mkdir`, `makedirs`,
|
|
94
|
+
`rmdir`, `remove`, `rename`, `chmod`, `chown`, `symlink`, `readlink`, `open`,
|
|
95
|
+
`read_text`, `write_text`, `walk`, and `push`/`pull` (recursive, progress, stats)
|
|
96
|
+
- **SCP**: `scp_push` / `scp_pull` (needs the `scp` extra)
|
|
97
|
+
- **FTP/FTPS**: `FTPHandler` (stdlib `ftplib`, no extra dependency)
|
|
98
|
+
- **Jump host / bastion**: `SSHConfig(jump_host=…)` (ProxyJump-style)
|
|
99
|
+
- **Escape hatch**: `ssh.client` and `ssh.transport` expose the raw Paramiko objects
|
|
100
|
+
|
|
101
|
+
## Result objects
|
|
102
|
+
|
|
103
|
+
Every action returns structured data, not bare strings:
|
|
104
|
+
|
|
105
|
+
- `CommandResult` — `exit_code`, `stdout`, `stderr`, `duration`, `host`, `.ok`, `.as_dict()`
|
|
106
|
+
- `TransferResult` — `size_bytes`, `duration`, `speed_bps`, `human_speed`, `files`
|
|
107
|
+
- `ShellResult` — `output`, `matched`, `timed_out`, `duration`
|
|
108
|
+
- `OperationResult` — safe-mode wrapper: `bool(res)`, `res.value`, `res.error`, `res.unwrap()`
|
|
109
|
+
|
|
110
|
+
## Two error-handling styles
|
|
111
|
+
|
|
112
|
+
- **Raise (default)** — for tests/scripts. Typed exceptions: `SSHConnectionError`,
|
|
113
|
+
`SSHAuthenticationError`, `SSHTimeoutError`, `SSHCommandError`, `SSHTransferError`,
|
|
114
|
+
`FTPError`, `CredentialError` — all subclasses of `SSHError`. **Auto-retry** on connect
|
|
115
|
+
and **auto-reconnect** on dropped sessions are built in.
|
|
116
|
+
- **Safe mode** (`SSHHandler(cfg, safe=True)`) — for GUIs. Every call returns an
|
|
117
|
+
`OperationResult` instead of raising, so the event loop never dies. Override per call
|
|
118
|
+
with `safe=True/False`.
|
|
119
|
+
|
|
120
|
+
## CLI
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
python -m ssh_handler run --host H --user U --domain EU uptime
|
|
124
|
+
python -m ssh_handler push --host H --user U ./build /tmp/build --recursive
|
|
125
|
+
python -m ssh_handler pull --host H --user U /var/log ./logs --recursive
|
|
126
|
+
python -m ssh_handler info --host H --user U --json
|
|
127
|
+
python -m ssh_handler run --host H --user U --use-stored uptime # vault password
|
|
128
|
+
```
|
|
129
|
+
Password options: `--password` (hidden prompt), `--use-stored` (OS vault), `--key FILE`.
|
|
130
|
+
Add `--json` for machine-readable output.
|
|
131
|
+
|
|
132
|
+
## PyQt5
|
|
133
|
+
|
|
134
|
+
`ssh_handler.pyqt_worker.SSHWorker` is a `QObject` wrapping the handler in **safe mode**.
|
|
135
|
+
`moveToThread()` it, connect its signals (`log`, `connected`, `command_done`,
|
|
136
|
+
`transfer_done`, `progress`, `error`, `finished`), and drive it from the GUI — see recipe
|
|
137
|
+
in [examples/examples.py](examples/examples.py).
|
|
138
|
+
|
|
139
|
+
## Project layout
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
ssh_handler/
|
|
143
|
+
config.py SSHConfig, FTPConfig
|
|
144
|
+
credentials.py Secret, CredentialStore, mask, prompt_password
|
|
145
|
+
core.py SSHHandler, ShellSession (SSH + SFTP + SCP)
|
|
146
|
+
ftp.py FTPHandler (FTP/FTPS)
|
|
147
|
+
pool.py SSHPool (parallel multi-host)
|
|
148
|
+
cli.py argparse entry point (python -m ssh_handler)
|
|
149
|
+
pyqt_worker.py SSHWorker (PyQt5, lazy import)
|
|
150
|
+
results.py CommandResult, TransferResult, ShellResult, OperationResult
|
|
151
|
+
exceptions.py SSHError hierarchy
|
|
152
|
+
examples/examples.py
|
|
153
|
+
tests/test_offline.py (no network needed)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Tests
|
|
157
|
+
|
|
158
|
+
`tests/test_offline.py` validates secret masking, domain-login building, fast-auth
|
|
159
|
+
kwargs, recursive-mkdir logic, transfer math, and both error-handling modes — all offline.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
python tests/test_offline.py
|
|
163
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ssh-handler"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Extensive SSH/SFTP/SCP/FTP handler built on Paramiko, for test automation, CLIs and PyQt5 tools."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
authors = [{ name = "Naveen" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"paramiko>=3.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
secure = ["keyring>=23.0"] # OS credential vault for confidential passwords
|
|
18
|
+
scp = ["scp>=0.14"] # SCP-protocol transfers (SFTP works without it)
|
|
19
|
+
gui = ["PyQt5>=5.15"] # PyQt5 worker integration
|
|
20
|
+
all = ["keyring>=23.0", "scp>=0.14", "PyQt5>=5.15"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
ssh-handler = "ssh_handler.cli:main"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
packages = ["ssh_handler"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ssh_handler
|
|
3
|
+
===========
|
|
4
|
+
|
|
5
|
+
An extensive SSH / SFTP / SCP / FTP handler built on Paramiko, usable from a
|
|
6
|
+
test-automation framework, a standalone CLI, or a PyQt5 tool.
|
|
7
|
+
|
|
8
|
+
Quick start
|
|
9
|
+
-----------
|
|
10
|
+
from ssh_handler import SSHHandler, SSHConfig
|
|
11
|
+
|
|
12
|
+
with SSHHandler(SSHConfig(host="10.0.0.5", domain="EU",
|
|
13
|
+
username="nkennedy", password="…")) as ssh:
|
|
14
|
+
print(ssh.run("hostname").stdout)
|
|
15
|
+
ssh.push("local.txt", "/tmp/remote.txt")
|
|
16
|
+
ssh.pull("/var/log/syslog", "syslog.txt")
|
|
17
|
+
|
|
18
|
+
The password is wrapped in a Secret and never appears in logs or reprs.
|
|
19
|
+
``pyqt_worker`` and the ``scp`` extra are imported lazily, so missing optional
|
|
20
|
+
dependencies (PyQt5 / scp) don't break the core package.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
__version__ = "1.0.0"
|
|
26
|
+
|
|
27
|
+
from .config import SSHConfig, FTPConfig
|
|
28
|
+
from .core import SSHHandler, ShellSession
|
|
29
|
+
from .ftp import FTPHandler
|
|
30
|
+
from .pool import SSHPool
|
|
31
|
+
from .credentials import Secret, CredentialStore, prompt_password, mask
|
|
32
|
+
from .results import CommandResult, TransferResult, ShellResult, OperationResult
|
|
33
|
+
from .exceptions import (
|
|
34
|
+
SSHError,
|
|
35
|
+
SSHConnectionError,
|
|
36
|
+
SSHAuthenticationError,
|
|
37
|
+
SSHTimeoutError,
|
|
38
|
+
SSHCommandError,
|
|
39
|
+
SSHTransferError,
|
|
40
|
+
SSHNotConnectedError,
|
|
41
|
+
FTPError,
|
|
42
|
+
CredentialError,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"SSHHandler", "ShellSession", "SSHConfig", "FTPConfig", "FTPHandler",
|
|
47
|
+
"SSHPool", "Secret", "CredentialStore", "prompt_password", "mask",
|
|
48
|
+
"CommandResult", "TransferResult", "ShellResult", "OperationResult",
|
|
49
|
+
"SSHError", "SSHConnectionError", "SSHAuthenticationError",
|
|
50
|
+
"SSHTimeoutError", "SSHCommandError", "SSHTransferError",
|
|
51
|
+
"SSHNotConnectedError", "FTPError", "CredentialError", "__version__",
|
|
52
|
+
]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standalone command-line entry point.
|
|
3
|
+
|
|
4
|
+
python -m ssh_handler run --host H --user U [--domain EU] CMD...
|
|
5
|
+
python -m ssh_handler push --host H --user U LOCAL REMOTE [--recursive]
|
|
6
|
+
python -m ssh_handler pull --host H --user U REMOTE LOCAL [--recursive]
|
|
7
|
+
python -m ssh_handler info --host H --user U
|
|
8
|
+
|
|
9
|
+
Credentials (password never echoed, never stored in plaintext):
|
|
10
|
+
--password prompt interactively (hidden)
|
|
11
|
+
--use-stored read from the OS credential vault
|
|
12
|
+
--store-credential save to the OS vault, then exit
|
|
13
|
+
--key FILE use a private key instead of a password
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
import json
|
|
20
|
+
import argparse
|
|
21
|
+
|
|
22
|
+
from .config import SSHConfig
|
|
23
|
+
from .core import SSHHandler
|
|
24
|
+
from .credentials import CredentialStore, prompt_password, Secret
|
|
25
|
+
from .exceptions import SSHError
|
|
26
|
+
from .results import CommandResult, TransferResult
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _add_conn_args(p: argparse.ArgumentParser) -> None:
|
|
30
|
+
p.add_argument("--host", required=True)
|
|
31
|
+
p.add_argument("--port", type=int, default=22)
|
|
32
|
+
p.add_argument("--user", required=True)
|
|
33
|
+
p.add_argument("--domain", default=None, help="e.g. EU for EU\\user logins")
|
|
34
|
+
p.add_argument("--key", default=None, help="private key file")
|
|
35
|
+
p.add_argument("--password", action="store_true",
|
|
36
|
+
help="prompt for password (hidden input)")
|
|
37
|
+
p.add_argument("--use-stored", action="store_true",
|
|
38
|
+
help="read password from the OS credential vault")
|
|
39
|
+
p.add_argument("--service", default="ssh_handler",
|
|
40
|
+
help="credential-vault namespace")
|
|
41
|
+
p.add_argument("--timeout", type=float, default=None)
|
|
42
|
+
p.add_argument("--no-fast-auth", action="store_true")
|
|
43
|
+
p.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_password(args, login_user: str) -> Secret | None:
|
|
47
|
+
if args.use_stored:
|
|
48
|
+
store = CredentialStore(args.service)
|
|
49
|
+
sec = store.get(login_user)
|
|
50
|
+
if sec is None:
|
|
51
|
+
print(f"No stored credential for {login_user!r} in service "
|
|
52
|
+
f"{args.service!r}.", file=sys.stderr)
|
|
53
|
+
sys.exit(2)
|
|
54
|
+
return sec
|
|
55
|
+
if args.password:
|
|
56
|
+
return prompt_password(f"Password for {login_user}: ")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_config(args) -> SSHConfig:
|
|
61
|
+
login_user = f"{args.domain}\\{args.user}" if args.domain else args.user
|
|
62
|
+
return SSHConfig(
|
|
63
|
+
host=args.host, port=args.port, username=args.user, domain=args.domain,
|
|
64
|
+
password=_resolve_password(args, login_user),
|
|
65
|
+
key_filename=args.key, command_timeout=args.timeout,
|
|
66
|
+
fast_auth=not args.no_fast_auth,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _output(args, obj) -> None:
|
|
71
|
+
if args.json:
|
|
72
|
+
if isinstance(obj, (CommandResult, TransferResult)):
|
|
73
|
+
print(json.dumps(obj.as_dict(), default=str, indent=2))
|
|
74
|
+
else:
|
|
75
|
+
print(json.dumps(obj, default=str, indent=2))
|
|
76
|
+
else:
|
|
77
|
+
print(obj)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv=None) -> int:
|
|
81
|
+
parser = argparse.ArgumentParser(prog="ssh_handler",
|
|
82
|
+
description="Extensive SSH/SFTP/SCP handler CLI")
|
|
83
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
84
|
+
|
|
85
|
+
# store-credential (no connection)
|
|
86
|
+
sc = sub.add_parser("store-credential", help="save a password to the OS vault")
|
|
87
|
+
sc.add_argument("--user", required=True)
|
|
88
|
+
sc.add_argument("--domain", default=None)
|
|
89
|
+
sc.add_argument("--service", default="ssh_handler")
|
|
90
|
+
|
|
91
|
+
p_run = sub.add_parser("run", help="execute a remote command")
|
|
92
|
+
_add_conn_args(p_run)
|
|
93
|
+
p_run.add_argument("command", nargs=argparse.REMAINDER)
|
|
94
|
+
|
|
95
|
+
p_push = sub.add_parser("push", help="upload a file/dir (SFTP)")
|
|
96
|
+
_add_conn_args(p_push)
|
|
97
|
+
p_push.add_argument("local")
|
|
98
|
+
p_push.add_argument("remote")
|
|
99
|
+
p_push.add_argument("--recursive", action="store_true")
|
|
100
|
+
|
|
101
|
+
p_pull = sub.add_parser("pull", help="download a file/dir (SFTP)")
|
|
102
|
+
_add_conn_args(p_pull)
|
|
103
|
+
p_pull.add_argument("remote")
|
|
104
|
+
p_pull.add_argument("local")
|
|
105
|
+
p_pull.add_argument("--recursive", action="store_true")
|
|
106
|
+
|
|
107
|
+
p_info = sub.add_parser("info", help="connect and report remote OS")
|
|
108
|
+
_add_conn_args(p_info)
|
|
109
|
+
|
|
110
|
+
args = parser.parse_args(argv)
|
|
111
|
+
|
|
112
|
+
# store-credential is handled without a connection
|
|
113
|
+
if args.cmd == "store-credential":
|
|
114
|
+
login_user = f"{args.domain}\\{args.user}" if args.domain else args.user
|
|
115
|
+
store = CredentialStore(args.service)
|
|
116
|
+
store.set(login_user, prompt_password(f"Password to store for {login_user}: "))
|
|
117
|
+
print(f"Stored credential for {login_user!r} in service {args.service!r}.")
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
with SSHHandler(_build_config(args)) as ssh:
|
|
122
|
+
if args.cmd == "run":
|
|
123
|
+
cmd = " ".join(args.command).strip()
|
|
124
|
+
if not cmd:
|
|
125
|
+
print("No command given.", file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
res = ssh.run(cmd, timeout=args.timeout)
|
|
128
|
+
if not args.json:
|
|
129
|
+
if res.stdout:
|
|
130
|
+
sys.stdout.write(res.stdout)
|
|
131
|
+
if res.stderr:
|
|
132
|
+
sys.stderr.write(res.stderr)
|
|
133
|
+
return res.exit_code
|
|
134
|
+
_output(args, res)
|
|
135
|
+
return res.exit_code
|
|
136
|
+
elif args.cmd == "push":
|
|
137
|
+
_output(args, ssh.push(args.local, args.remote,
|
|
138
|
+
recursive=args.recursive))
|
|
139
|
+
elif args.cmd == "pull":
|
|
140
|
+
_output(args, ssh.pull(args.remote, args.local,
|
|
141
|
+
recursive=args.recursive))
|
|
142
|
+
elif args.cmd == "info":
|
|
143
|
+
_output(args, {"host": args.host, "remote_os": ssh.detect_os(),
|
|
144
|
+
"connected": ssh.is_connected})
|
|
145
|
+
return 0
|
|
146
|
+
except SSHError as exc:
|
|
147
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
148
|
+
return 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
raise SystemExit(main())
|