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.
@@ -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,5 @@
1
+ """Enables `python -m ssh_handler ...`."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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)