ssh-handler 1.0.0__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.
- ssh_handler/__init__.py +52 -0
- ssh_handler/__main__.py +5 -0
- ssh_handler/cli.py +152 -0
- ssh_handler/config.py +114 -0
- ssh_handler/core.py +793 -0
- ssh_handler/credentials.py +144 -0
- ssh_handler/exceptions.py +49 -0
- ssh_handler/ftp.py +186 -0
- ssh_handler/pool.py +103 -0
- ssh_handler/pyqt_worker.py +94 -0
- ssh_handler/results.py +127 -0
- ssh_handler-1.0.0.dist-info/METADATA +182 -0
- ssh_handler-1.0.0.dist-info/RECORD +16 -0
- ssh_handler-1.0.0.dist-info/WHEEL +5 -0
- ssh_handler-1.0.0.dist-info/entry_points.txt +2 -0
- ssh_handler-1.0.0.dist-info/top_level.txt +1 -0
ssh_handler/__init__.py
ADDED
|
@@ -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
|
+
]
|
ssh_handler/__main__.py
ADDED
ssh_handler/cli.py
ADDED
|
@@ -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())
|
ssh_handler/config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Connection/behaviour configuration objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Optional, Sequence, Union
|
|
8
|
+
|
|
9
|
+
from .credentials import Secret, coerce_secret
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SSHConfig:
|
|
14
|
+
"""
|
|
15
|
+
Everything needed to open and drive an SSH connection.
|
|
16
|
+
|
|
17
|
+
Domain accounts (Windows / RDP hosts running OpenSSH Server)
|
|
18
|
+
-----------------------------------------------------------
|
|
19
|
+
Set ``domain`` and ``username`` separately and let the handler build the
|
|
20
|
+
``DOMAIN\\user`` login string for you::
|
|
21
|
+
|
|
22
|
+
SSHConfig(host="10.0.0.5", domain="EU", username="nkennedy",
|
|
23
|
+
password=<Secret or str>)
|
|
24
|
+
|
|
25
|
+
Setting them separately avoids the classic trap where a literal Python
|
|
26
|
+
string ``"EU\\nkennedy"`` turns ``\\n`` into a newline. The password is
|
|
27
|
+
stored as a :class:`Secret`, so it never appears in logs or reprs.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
host: str
|
|
31
|
+
port: int = 22
|
|
32
|
+
username: Optional[str] = None
|
|
33
|
+
domain: Optional[str] = None # e.g. "EU" -> login "EU\\username"
|
|
34
|
+
|
|
35
|
+
# --- authentication (any combination; tried in a smart order) ---
|
|
36
|
+
password: Optional[Union[str, Secret]] = None
|
|
37
|
+
key_filename: Optional[Union[str, Sequence[str]]] = None
|
|
38
|
+
key_passphrase: Optional[Union[str, Secret]] = None
|
|
39
|
+
allow_agent: bool = True
|
|
40
|
+
look_for_keys: bool = True
|
|
41
|
+
allow_empty_password: bool = True # accounts with a blank password
|
|
42
|
+
passwordless: bool = False # force key/agent only
|
|
43
|
+
|
|
44
|
+
# --- connection behaviour ---
|
|
45
|
+
connect_timeout: float = 15.0
|
|
46
|
+
auth_timeout: float = 20.0
|
|
47
|
+
banner_timeout: float = 20.0
|
|
48
|
+
command_timeout: Optional[float] = None
|
|
49
|
+
keepalive_interval: int = 30
|
|
50
|
+
|
|
51
|
+
# --- performance ---
|
|
52
|
+
compress: bool = False # enable on slow/high-latency links
|
|
53
|
+
fast_auth: bool = True # skip key probing when a password is
|
|
54
|
+
# supplied -> much faster, avoids
|
|
55
|
+
# "Too many authentication failures"
|
|
56
|
+
|
|
57
|
+
# --- resilience ---
|
|
58
|
+
max_retries: int = 3
|
|
59
|
+
retry_backoff: float = 2.0
|
|
60
|
+
auto_reconnect: bool = True
|
|
61
|
+
|
|
62
|
+
# --- host key policy ---
|
|
63
|
+
host_key_policy: str = "auto" # "auto" | "reject" | "warn"
|
|
64
|
+
known_hosts: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
# --- jump host / bastion (ProxyJump) ---
|
|
67
|
+
jump_host: Optional["SSHConfig"] = None
|
|
68
|
+
|
|
69
|
+
# --- remote OS hint: "auto" | "linux" | "windows" ---
|
|
70
|
+
remote_os: str = "auto"
|
|
71
|
+
|
|
72
|
+
def __post_init__(self):
|
|
73
|
+
# Normalize secrets so they are never plain strings internally.
|
|
74
|
+
self.password = coerce_secret(self.password)
|
|
75
|
+
self.key_passphrase = coerce_secret(self.key_passphrase)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def auth_username(self) -> Optional[str]:
|
|
79
|
+
"""Login string, combining domain if present (``DOMAIN\\user``)."""
|
|
80
|
+
if self.domain and self.username:
|
|
81
|
+
return f"{self.domain}\\{self.username}"
|
|
82
|
+
return self.username
|
|
83
|
+
|
|
84
|
+
def normalized_key_files(self) -> list[str]:
|
|
85
|
+
if not self.key_filename:
|
|
86
|
+
return []
|
|
87
|
+
keys = self.key_filename
|
|
88
|
+
if isinstance(keys, (list, tuple)):
|
|
89
|
+
return [os.path.expanduser(k) for k in keys]
|
|
90
|
+
return [os.path.expanduser(keys)]
|
|
91
|
+
|
|
92
|
+
def __repr__(self) -> str: # never leak the password
|
|
93
|
+
return (
|
|
94
|
+
f"SSHConfig(host={self.host!r}, port={self.port}, "
|
|
95
|
+
f"user={self.auth_username!r}, password={self.password!r}, "
|
|
96
|
+
f"jump_host={'yes' if self.jump_host else 'no'})"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class FTPConfig:
|
|
102
|
+
"""Configuration for the (non-SSH) FTP/FTPS handler."""
|
|
103
|
+
|
|
104
|
+
host: str
|
|
105
|
+
port: int = 21
|
|
106
|
+
username: str = "anonymous"
|
|
107
|
+
password: Optional[Union[str, Secret]] = ""
|
|
108
|
+
use_tls: bool = False # FTPS (explicit TLS) when True
|
|
109
|
+
passive: bool = True
|
|
110
|
+
timeout: float = 30.0
|
|
111
|
+
encoding: str = "utf-8"
|
|
112
|
+
|
|
113
|
+
def __post_init__(self):
|
|
114
|
+
self.password = coerce_secret(self.password)
|