ots-shared 0.2.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.
- ots_shared-0.2.0/.gitignore +30 -0
- ots_shared-0.2.0/PKG-INFO +28 -0
- ots_shared-0.2.0/pyproject.toml +52 -0
- ots_shared-0.2.0/src/ots_shared/__init__.py +1 -0
- ots_shared-0.2.0/src/ots_shared/cli.py +70 -0
- ots_shared-0.2.0/src/ots_shared/exit_codes.py +29 -0
- ots_shared-0.2.0/src/ots_shared/ssh/__init__.py +51 -0
- ots_shared-0.2.0/src/ots_shared/ssh/_pty.py +172 -0
- ots_shared-0.2.0/src/ots_shared/ssh/connection.py +152 -0
- ots_shared-0.2.0/src/ots_shared/ssh/env.py +191 -0
- ots_shared-0.2.0/src/ots_shared/ssh/executor.py +483 -0
- ots_shared-0.2.0/src/ots_shared/taxonomy.py +100 -0
- ots_shared-0.2.0/tests/__init__.py +0 -0
- ots_shared-0.2.0/tests/conftest.py +101 -0
- ots_shared-0.2.0/tests/ssh/__init__.py +0 -0
- ots_shared-0.2.0/tests/ssh/test_connection.py +150 -0
- ots_shared-0.2.0/tests/ssh/test_env.py +349 -0
- ots_shared-0.2.0/tests/ssh/test_executor.py +913 -0
- ots_shared-0.2.0/tests/ssh/test_pty.py +173 -0
- ots_shared-0.2.0/tests/test_cli.py +129 -0
- ots_shared-0.2.0/tests/test_exit_codes.py +43 -0
- ots_shared-0.2.0/tests/test_taxonomy.py +56 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
*.egg
|
|
11
|
+
|
|
12
|
+
# Virtual environments
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
ENV/
|
|
16
|
+
|
|
17
|
+
# IDE
|
|
18
|
+
.idea/
|
|
19
|
+
.vscode/
|
|
20
|
+
*.swp
|
|
21
|
+
*.swo
|
|
22
|
+
*.txt
|
|
23
|
+
|
|
24
|
+
# OS
|
|
25
|
+
.DS_Store
|
|
26
|
+
Thumbs.db
|
|
27
|
+
|
|
28
|
+
.claude
|
|
29
|
+
.serena
|
|
30
|
+
.coverage
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ots-shared
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Shared constants and utilities for OTS operations tools
|
|
5
|
+
Project-URL: Repository, https://github.com/onetimesecret/rots
|
|
6
|
+
Author: Onetime
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: cli,infrastructure,shared
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: System :: Systems Administration
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: cyclopts>=3.9
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pyright>=1.1.390; extra == 'dev'
|
|
21
|
+
Provides-Extra: ssh
|
|
22
|
+
Requires-Dist: paramiko>=3.4; extra == 'ssh'
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest-mock>=3.14; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/plain
|
|
27
|
+
|
|
28
|
+
Shared constants and utilities for OTS operations tools.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ots-shared"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Shared constants and utilities for OTS operations tools"
|
|
5
|
+
readme = {text = "Shared constants and utilities for OTS operations tools.", content-type = "text/plain"}
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Onetime" }]
|
|
9
|
+
keywords = ["cli", "infrastructure", "shared"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: System Administrators",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: System :: Systems Administration",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["cyclopts>=3.9"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Repository = "https://github.com/onetimesecret/rots"
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
ssh = ["paramiko>=3.4"]
|
|
27
|
+
dev = ["pyright>=1.1.390"]
|
|
28
|
+
test = ["pytest>=8.0", "pytest-mock>=3.14"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/ots_shared"]
|
|
36
|
+
|
|
37
|
+
[tool.pyright]
|
|
38
|
+
pythonVersion = "3.11"
|
|
39
|
+
typeCheckingMode = "basic"
|
|
40
|
+
include = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
pythonpath = ["src"]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
target-version = "py311"
|
|
48
|
+
src = ["src"]
|
|
49
|
+
line-length = 100
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "I", "N", "W", "UP"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared constants and utilities for OTS operations tools."""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Shared cyclopts type aliases for consistent CLI flags across OTS tools.
|
|
2
|
+
|
|
3
|
+
All common flags use long+short forms for consistency:
|
|
4
|
+
--quiet, -q
|
|
5
|
+
--dry-run, -n
|
|
6
|
+
--yes, -y
|
|
7
|
+
--follow, -f
|
|
8
|
+
--lines, -l
|
|
9
|
+
--json, -j
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import cyclopts
|
|
15
|
+
|
|
16
|
+
# Output control
|
|
17
|
+
Quiet = Annotated[
|
|
18
|
+
bool,
|
|
19
|
+
cyclopts.Parameter(
|
|
20
|
+
name=["--quiet", "-q"],
|
|
21
|
+
help="Suppress output",
|
|
22
|
+
),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
DryRun = Annotated[
|
|
26
|
+
bool,
|
|
27
|
+
cyclopts.Parameter(
|
|
28
|
+
name=["--dry-run", "-n"],
|
|
29
|
+
help="Show what would be done without doing it",
|
|
30
|
+
negative=[], # Disable --no-dry-run generation
|
|
31
|
+
),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Confirmation
|
|
36
|
+
Yes = Annotated[
|
|
37
|
+
bool,
|
|
38
|
+
cyclopts.Parameter(
|
|
39
|
+
name=["--yes", "-y"],
|
|
40
|
+
help="Skip confirmation prompts",
|
|
41
|
+
),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Log viewing
|
|
46
|
+
Follow = Annotated[
|
|
47
|
+
bool,
|
|
48
|
+
cyclopts.Parameter(
|
|
49
|
+
name=["--follow", "-f"],
|
|
50
|
+
help="Follow log output",
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
Lines = Annotated[
|
|
55
|
+
int,
|
|
56
|
+
cyclopts.Parameter(
|
|
57
|
+
name=["--lines", "-l"],
|
|
58
|
+
help="Number of lines to show",
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# JSON output
|
|
64
|
+
JsonOutput = Annotated[
|
|
65
|
+
bool,
|
|
66
|
+
cyclopts.Parameter(
|
|
67
|
+
name=["--json", "-j"],
|
|
68
|
+
help="Output as JSON",
|
|
69
|
+
),
|
|
70
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Standardised exit codes for all OTS CLI tools.
|
|
2
|
+
|
|
3
|
+
All commands across ots-containers, hcloud-manager, otsinfra, and
|
|
4
|
+
ots-cloudinit use this scheme so that CI pipelines and shell scripts
|
|
5
|
+
can distinguish between different failure modes.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from ots_shared.exit_codes import EXIT_SUCCESS, EXIT_FAILURE, EXIT_PARTIAL, EXIT_PRECOND
|
|
10
|
+
|
|
11
|
+
raise SystemExit(EXIT_PARTIAL)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
EXIT_SUCCESS: int = 0
|
|
15
|
+
"""Command completed successfully; all operations applied."""
|
|
16
|
+
|
|
17
|
+
EXIT_FAILURE: int = 1
|
|
18
|
+
"""General command failure (unexpected error, bad arguments, etc.)."""
|
|
19
|
+
|
|
20
|
+
EXIT_PARTIAL: int = 2
|
|
21
|
+
"""Partial success: at least one operation succeeded and at least one failed.
|
|
22
|
+
|
|
23
|
+
Check the command output for details on which operations failed."""
|
|
24
|
+
|
|
25
|
+
EXIT_PRECOND: int = 3
|
|
26
|
+
"""Precondition not met: required configuration is absent.
|
|
27
|
+
|
|
28
|
+
Examples: missing env file, missing secrets, image not pulled.
|
|
29
|
+
No destructive action was attempted."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""SSH remote execution support for OTS operations tools.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
- Environment: find_env_file, load_env_file, resolve_config_dir, resolve_host
|
|
5
|
+
- Executor: Result, CommandError, Executor, LocalExecutor, SSHExecutor, is_remote
|
|
6
|
+
- Connection: ssh_connect
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .env import (
|
|
10
|
+
find_env_file,
|
|
11
|
+
generate_env_template,
|
|
12
|
+
load_env_file,
|
|
13
|
+
resolve_config_dir,
|
|
14
|
+
resolve_host,
|
|
15
|
+
)
|
|
16
|
+
from .executor import (
|
|
17
|
+
SSH_DEFAULT_TIMEOUT,
|
|
18
|
+
CommandError,
|
|
19
|
+
Executor,
|
|
20
|
+
LocalExecutor,
|
|
21
|
+
Result,
|
|
22
|
+
SSHExecutor,
|
|
23
|
+
is_remote,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"find_env_file",
|
|
28
|
+
"generate_env_template",
|
|
29
|
+
"load_env_file",
|
|
30
|
+
"resolve_config_dir",
|
|
31
|
+
"resolve_host",
|
|
32
|
+
"SSH_DEFAULT_TIMEOUT",
|
|
33
|
+
"CommandError",
|
|
34
|
+
"Executor",
|
|
35
|
+
"LocalExecutor",
|
|
36
|
+
"Result",
|
|
37
|
+
"SSHExecutor",
|
|
38
|
+
"is_remote",
|
|
39
|
+
"ssh_connect",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ssh_connect(
|
|
44
|
+
hostname: str,
|
|
45
|
+
ssh_config_path: object | None = None,
|
|
46
|
+
timeout: int = 15,
|
|
47
|
+
) -> object:
|
|
48
|
+
"""Open an SSH connection. Deferred import to avoid requiring paramiko."""
|
|
49
|
+
from .connection import ssh_connect as _ssh_connect
|
|
50
|
+
|
|
51
|
+
return _ssh_connect(hostname, ssh_config_path=ssh_config_path, timeout=timeout) # type: ignore[arg-type]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""PTY helper for interactive SSH sessions.
|
|
2
|
+
|
|
3
|
+
Internal module — not exported from ots_shared.ssh.__init__.
|
|
4
|
+
Provides terminal save/restore, raw mode, SIGWINCH propagation,
|
|
5
|
+
and a bidirectional select loop for full-PTY SSH channels.
|
|
6
|
+
|
|
7
|
+
All termios/tty/signal usage is guarded for portability (Windows
|
|
8
|
+
lacks these modules). Functions raise RuntimeError if called on
|
|
9
|
+
a platform without PTY support.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import select
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Guarded imports — these are Unix-only.
|
|
23
|
+
try:
|
|
24
|
+
import signal
|
|
25
|
+
import termios
|
|
26
|
+
import tty
|
|
27
|
+
|
|
28
|
+
_HAS_PTY_SUPPORT = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
_HAS_PTY_SUPPORT = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _require_pty_support() -> None:
|
|
34
|
+
"""Raise RuntimeError if termios/tty are unavailable."""
|
|
35
|
+
if not _HAS_PTY_SUPPORT:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"PTY support requires termios/tty (Unix-only). "
|
|
38
|
+
"Interactive SSH sessions are not available on this platform."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_terminal_size() -> tuple[int, int]:
|
|
43
|
+
"""Return (columns, rows) of the current terminal.
|
|
44
|
+
|
|
45
|
+
Falls back to (80, 24) if detection fails.
|
|
46
|
+
"""
|
|
47
|
+
size = shutil.get_terminal_size(fallback=(80, 24))
|
|
48
|
+
return (size.columns, size.lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def set_raw(fd: int) -> list:
|
|
52
|
+
"""Save terminal attributes and switch *fd* to raw mode.
|
|
53
|
+
|
|
54
|
+
Returns the saved attributes (pass to :func:`restore`).
|
|
55
|
+
Raises RuntimeError on platforms without termios.
|
|
56
|
+
"""
|
|
57
|
+
_require_pty_support()
|
|
58
|
+
old_attrs = termios.tcgetattr(fd)
|
|
59
|
+
tty.setraw(fd)
|
|
60
|
+
tty.setcbreak(fd)
|
|
61
|
+
return old_attrs
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def restore(fd: int, attrs: list) -> None:
|
|
65
|
+
"""Restore terminal attributes previously saved by :func:`set_raw`.
|
|
66
|
+
|
|
67
|
+
Uses TCSADRAIN so queued output finishes before the change.
|
|
68
|
+
Raises RuntimeError on platforms without termios.
|
|
69
|
+
"""
|
|
70
|
+
_require_pty_support()
|
|
71
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def install_sigwinch_handler(channel: object) -> object | None:
|
|
75
|
+
"""Install a SIGWINCH handler that resizes *channel*'s PTY.
|
|
76
|
+
|
|
77
|
+
Returns the previous signal handler so callers can restore it.
|
|
78
|
+
On platforms without SIGWINCH this is a no-op and returns None.
|
|
79
|
+
"""
|
|
80
|
+
if not _HAS_PTY_SUPPORT:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
if not hasattr(signal, "SIGWINCH"):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def _handler(signum: int, frame: object) -> None:
|
|
87
|
+
try:
|
|
88
|
+
cols, rows = get_terminal_size()
|
|
89
|
+
channel.resize_pty(width=cols, height=rows) # type: ignore[union-attr]
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
old_handler = signal.signal(signal.SIGWINCH, _handler)
|
|
94
|
+
return old_handler
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def restore_sigwinch_handler(old_handler: object | None) -> None:
|
|
98
|
+
"""Restore the SIGWINCH handler saved by :func:`install_sigwinch_handler`."""
|
|
99
|
+
if old_handler is None:
|
|
100
|
+
return
|
|
101
|
+
if not _HAS_PTY_SUPPORT:
|
|
102
|
+
return
|
|
103
|
+
if not hasattr(signal, "SIGWINCH"):
|
|
104
|
+
return
|
|
105
|
+
signal.signal(signal.SIGWINCH, old_handler) # type: ignore[arg-type]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def interactive_loop(
|
|
109
|
+
channel: object,
|
|
110
|
+
stdin_fd: int,
|
|
111
|
+
stdout_buffer: object | None = None,
|
|
112
|
+
) -> int:
|
|
113
|
+
"""Bidirectional select loop between *channel* and *stdin_fd*.
|
|
114
|
+
|
|
115
|
+
Reads from the Paramiko channel and writes to *stdout_buffer*
|
|
116
|
+
(defaults to ``sys.stdout.buffer``). Reads from *stdin_fd* and
|
|
117
|
+
sends to the channel. Runs until the channel exits.
|
|
118
|
+
|
|
119
|
+
Returns the remote process exit code.
|
|
120
|
+
"""
|
|
121
|
+
if stdout_buffer is None:
|
|
122
|
+
stdout_buffer = sys.stdout.buffer
|
|
123
|
+
|
|
124
|
+
while True:
|
|
125
|
+
readable, _, _ = select.select([channel, stdin_fd], [], [], 0.1)
|
|
126
|
+
|
|
127
|
+
if channel in readable:
|
|
128
|
+
if channel.recv_ready(): # type: ignore[union-attr]
|
|
129
|
+
data = channel.recv(4096) # type: ignore[union-attr]
|
|
130
|
+
if data:
|
|
131
|
+
stdout_buffer.write(data) # type: ignore[union-attr]
|
|
132
|
+
stdout_buffer.flush() # type: ignore[union-attr]
|
|
133
|
+
elif channel.exit_status_ready(): # type: ignore[union-attr]
|
|
134
|
+
break
|
|
135
|
+
elif channel.exit_status_ready(): # type: ignore[union-attr]
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
if stdin_fd in readable:
|
|
139
|
+
user_input = os.read(stdin_fd, 1024)
|
|
140
|
+
if user_input:
|
|
141
|
+
channel.sendall(user_input) # type: ignore[union-attr]
|
|
142
|
+
|
|
143
|
+
if channel.exit_status_ready() and not channel.recv_ready(): # type: ignore[union-attr]
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
return channel.recv_exit_status() # type: ignore[union-attr]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_pty_session(channel: object) -> int:
|
|
150
|
+
"""High-level PTY session driver for SSHExecutor.run_interactive().
|
|
151
|
+
|
|
152
|
+
Saves terminal state, sets raw mode, installs SIGWINCH handler,
|
|
153
|
+
runs the bidirectional loop, and restores everything in ``finally``.
|
|
154
|
+
|
|
155
|
+
Returns the remote process exit code.
|
|
156
|
+
"""
|
|
157
|
+
_require_pty_support()
|
|
158
|
+
|
|
159
|
+
stdin_fd = sys.stdin.fileno()
|
|
160
|
+
old_attrs = termios.tcgetattr(stdin_fd)
|
|
161
|
+
old_sigwinch = None
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
old_sigwinch = install_sigwinch_handler(channel)
|
|
165
|
+
tty.setraw(stdin_fd)
|
|
166
|
+
tty.setcbreak(stdin_fd)
|
|
167
|
+
exit_code = interactive_loop(channel, stdin_fd)
|
|
168
|
+
finally:
|
|
169
|
+
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
|
|
170
|
+
restore_sigwinch_handler(old_sigwinch)
|
|
171
|
+
|
|
172
|
+
return exit_code
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Paramiko SSHClient factory using ~/.ssh/config.
|
|
2
|
+
|
|
3
|
+
Creates SSH connections that honour the user's SSH config for Host,
|
|
4
|
+
User, Port, IdentityFile, and ProxyCommand settings.
|
|
5
|
+
|
|
6
|
+
Paramiko does not process ``Include`` directives, so we resolve them
|
|
7
|
+
recursively before parsing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import logging
|
|
14
|
+
from glob import glob
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import paramiko
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Connection timeout in seconds
|
|
22
|
+
DEFAULT_TIMEOUT = 15
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_includes(config_path: Path, _seen: set[Path] | None = None) -> str:
|
|
26
|
+
"""Recursively expand ``Include`` directives in an SSH config file.
|
|
27
|
+
|
|
28
|
+
Paramiko's SSHConfig parser silently ignores Include directives.
|
|
29
|
+
This function reads the config, expands any Include lines (handling
|
|
30
|
+
``~`` expansion, relative paths, and glob patterns), and returns a
|
|
31
|
+
single merged string suitable for ``SSHConfig.parse()``.
|
|
32
|
+
"""
|
|
33
|
+
if _seen is None:
|
|
34
|
+
_seen = set()
|
|
35
|
+
|
|
36
|
+
resolved = config_path.resolve()
|
|
37
|
+
if resolved in _seen:
|
|
38
|
+
return ""
|
|
39
|
+
_seen.add(resolved)
|
|
40
|
+
|
|
41
|
+
if not config_path.is_file():
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
lines: list[str] = []
|
|
45
|
+
for line in config_path.read_text().splitlines():
|
|
46
|
+
stripped = line.strip()
|
|
47
|
+
if stripped.lower().startswith("include "):
|
|
48
|
+
pattern = stripped.split(None, 1)[1].strip()
|
|
49
|
+
# Strip surrounding quotes
|
|
50
|
+
if len(pattern) >= 2 and pattern[0] == pattern[-1] and pattern[0] in ('"', "'"):
|
|
51
|
+
pattern = pattern[1:-1]
|
|
52
|
+
# Expand ~ and resolve relative paths against config dir
|
|
53
|
+
pattern = str(Path(pattern).expanduser())
|
|
54
|
+
if not Path(pattern).is_absolute():
|
|
55
|
+
pattern = str(config_path.parent / pattern)
|
|
56
|
+
for included_path in sorted(glob(pattern)):
|
|
57
|
+
included = Path(included_path)
|
|
58
|
+
if included.is_file():
|
|
59
|
+
lines.append(_resolve_includes(included, _seen))
|
|
60
|
+
else:
|
|
61
|
+
lines.append(line)
|
|
62
|
+
|
|
63
|
+
return "\n".join(lines)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ssh_connect(
|
|
67
|
+
hostname: str,
|
|
68
|
+
ssh_config_path: Path | None = None,
|
|
69
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
70
|
+
) -> paramiko.SSHClient:
|
|
71
|
+
"""Open an SSH connection to *hostname* using SSH config.
|
|
72
|
+
|
|
73
|
+
Uses paramiko with RejectPolicy — the host must already be in
|
|
74
|
+
known_hosts. Returns a connected paramiko.SSHClient.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
paramiko.SSHException: If connection or authentication fails.
|
|
78
|
+
"""
|
|
79
|
+
config_path = ssh_config_path or Path.home() / ".ssh" / "config"
|
|
80
|
+
|
|
81
|
+
# Parse SSH config with Include directives resolved
|
|
82
|
+
ssh_config = paramiko.SSHConfig()
|
|
83
|
+
if config_path.exists():
|
|
84
|
+
merged = _resolve_includes(config_path)
|
|
85
|
+
ssh_config.parse(io.StringIO(merged))
|
|
86
|
+
|
|
87
|
+
host_config = ssh_config.lookup(hostname)
|
|
88
|
+
|
|
89
|
+
# Build connection kwargs from SSH config
|
|
90
|
+
connect_kwargs: dict = {
|
|
91
|
+
"hostname": host_config.get("hostname", hostname),
|
|
92
|
+
"timeout": timeout,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if "port" in host_config:
|
|
96
|
+
connect_kwargs["port"] = int(host_config["port"])
|
|
97
|
+
|
|
98
|
+
if "user" in host_config:
|
|
99
|
+
connect_kwargs["username"] = host_config["user"]
|
|
100
|
+
|
|
101
|
+
if "identityfile" in host_config:
|
|
102
|
+
# SSH config may list multiple identity files; pass all that exist
|
|
103
|
+
key_files = [
|
|
104
|
+
str(Path(kf).expanduser())
|
|
105
|
+
for kf in host_config["identityfile"]
|
|
106
|
+
if Path(kf).expanduser().exists()
|
|
107
|
+
]
|
|
108
|
+
if key_files:
|
|
109
|
+
connect_kwargs["key_filename"] = key_files
|
|
110
|
+
|
|
111
|
+
# ProxyCommand support
|
|
112
|
+
proxy_cmd = host_config.get("proxycommand")
|
|
113
|
+
if proxy_cmd:
|
|
114
|
+
connect_kwargs["sock"] = paramiko.ProxyCommand(proxy_cmd)
|
|
115
|
+
|
|
116
|
+
# Create client with RejectPolicy (security requirement)
|
|
117
|
+
client = paramiko.SSHClient()
|
|
118
|
+
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
|
119
|
+
|
|
120
|
+
# Load known hosts
|
|
121
|
+
known_hosts = Path.home() / ".ssh" / "known_hosts"
|
|
122
|
+
if known_hosts.exists():
|
|
123
|
+
client.load_host_keys(str(known_hosts))
|
|
124
|
+
client.load_system_host_keys()
|
|
125
|
+
|
|
126
|
+
# Paramiko looks up non-standard ports as [host]:port in known_hosts,
|
|
127
|
+
# but OpenSSH also accepts bare host entries as a fallback. Mirror
|
|
128
|
+
# that behaviour so existing known_hosts files work without needing
|
|
129
|
+
# port-specific entries.
|
|
130
|
+
port = connect_kwargs.get("port", 22)
|
|
131
|
+
actual_host = connect_kwargs["hostname"]
|
|
132
|
+
if port != 22:
|
|
133
|
+
bracketed = f"[{actual_host}]:{port}"
|
|
134
|
+
host_keys = client.get_host_keys()
|
|
135
|
+
if bracketed not in host_keys and actual_host in host_keys:
|
|
136
|
+
for key_type in host_keys[actual_host]:
|
|
137
|
+
host_keys.add(bracketed, key_type, host_keys[actual_host][key_type])
|
|
138
|
+
|
|
139
|
+
logger.info("SSH connecting to %s", connect_kwargs.get("hostname", hostname))
|
|
140
|
+
try:
|
|
141
|
+
client.connect(**connect_kwargs)
|
|
142
|
+
except paramiko.PasswordRequiredException:
|
|
143
|
+
# Key file is encrypted and no passphrase was provided. Drop
|
|
144
|
+
# key_filename so paramiko falls through to the SSH agent, which
|
|
145
|
+
# typically has the decrypted key loaded (via AddKeysToAgent/UseKeychain).
|
|
146
|
+
if "key_filename" in connect_kwargs:
|
|
147
|
+
logger.debug("Encrypted key file, falling back to SSH agent")
|
|
148
|
+
del connect_kwargs["key_filename"]
|
|
149
|
+
client.connect(**connect_kwargs)
|
|
150
|
+
else:
|
|
151
|
+
raise
|
|
152
|
+
return client
|