py-simple-sshd 0.1.1__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.
- py_simple_sshd-0.1.1/LICENSE +21 -0
- py_simple_sshd-0.1.1/PKG-INFO +91 -0
- py_simple_sshd-0.1.1/README.md +60 -0
- py_simple_sshd-0.1.1/pyproject.toml +48 -0
- py_simple_sshd-0.1.1/src/simple_sshd/__init__.py +6 -0
- py_simple_sshd-0.1.1/src/simple_sshd/__main__.py +4 -0
- py_simple_sshd-0.1.1/src/simple_sshd/auth.py +184 -0
- py_simple_sshd-0.1.1/src/simple_sshd/cli.py +90 -0
- py_simple_sshd-0.1.1/src/simple_sshd/errors.py +13 -0
- py_simple_sshd-0.1.1/src/simple_sshd/hostkey.py +50 -0
- py_simple_sshd-0.1.1/src/simple_sshd/server.py +333 -0
- py_simple_sshd-0.1.1/src/simple_sshd/shell.py +398 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GGN_2015
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-simple-sshd
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A small, production-oriented SSH daemon implemented with Paramiko.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: ssh,sshd,paramiko,server
|
|
8
|
+
Author: GGN_2015
|
|
9
|
+
Author-email: neko@jlulug.org
|
|
10
|
+
Requires-Python: >=3.9,<4.0
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Internet
|
|
25
|
+
Classifier: Topic :: Security :: Cryptography
|
|
26
|
+
Classifier: Topic :: System :: Systems Administration
|
|
27
|
+
Requires-Dist: paramiko (>=3.4,<6.0)
|
|
28
|
+
Requires-Dist: pywinpty (>=2.0,<3.0) ; sys_platform == "win32"
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# py-simple-sshd
|
|
32
|
+
|
|
33
|
+
`py-simple-sshd` is a compact SSH daemon implemented with Paramiko. It is meant
|
|
34
|
+
for cases where you need an embeddable, cross-platform SSH entry point that can
|
|
35
|
+
launch a configured local shell after public-key or password-file authentication.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install py-simple-sshd
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
simple-sshd \
|
|
47
|
+
--host 0.0.0.0 \
|
|
48
|
+
--port 2222 \
|
|
49
|
+
--shell /bin/bash \
|
|
50
|
+
--authorized-keys /etc/simple-sshd/authorized_keys
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Password authentication is disabled unless `--password-file` is provided:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
simple-sshd \
|
|
57
|
+
--host 127.0.0.1 \
|
|
58
|
+
--port 2222 \
|
|
59
|
+
--shell /bin/bash \
|
|
60
|
+
--authorized-keys ~/.ssh/authorized_keys \
|
|
61
|
+
--password-file /etc/simple-sshd/password.txt
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
When both `--authorized-keys` and `--password-file` are configured, clients can
|
|
65
|
+
authenticate with either a listed public key or the password read from the file.
|
|
66
|
+
If `--password-file` is omitted, only public-key authentication is available.
|
|
67
|
+
|
|
68
|
+
On Windows, use a Windows shell path such as:
|
|
69
|
+
|
|
70
|
+
```powershell
|
|
71
|
+
simple-sshd --host 127.0.0.1 --port 2222 --shell C:\Windows\System32\cmd.exe --password-file .\password.txt
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Options
|
|
75
|
+
|
|
76
|
+
- `--host`: address to bind, default `127.0.0.1`
|
|
77
|
+
- `--port`: TCP port to bind, default `2222`
|
|
78
|
+
- `--shell`: shell executable launched for authenticated sessions
|
|
79
|
+
- `--authorized-keys`: OpenSSH `authorized_keys` file for public-key auth
|
|
80
|
+
- `--password-file`: text file whose content is accepted as the SSH password
|
|
81
|
+
- `--host-key`: private SSH host key; generated under `~/.simple-sshd` if omitted
|
|
82
|
+
- `--log-level`: `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL`
|
|
83
|
+
|
|
84
|
+
## Notes
|
|
85
|
+
|
|
86
|
+
- Password files are read as UTF-8 text. Trailing line endings are removed, while
|
|
87
|
+
other whitespace is preserved.
|
|
88
|
+
- Host keys are persistent by default so clients can verify the server identity.
|
|
89
|
+
- POSIX systems use a real pty for terminal sessions. Windows terminal sessions
|
|
90
|
+
use `pywinpty`; non-pty sessions use standard process pipes.
|
|
91
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# py-simple-sshd
|
|
2
|
+
|
|
3
|
+
`py-simple-sshd` is a compact SSH daemon implemented with Paramiko. It is meant
|
|
4
|
+
for cases where you need an embeddable, cross-platform SSH entry point that can
|
|
5
|
+
launch a configured local shell after public-key or password-file authentication.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install py-simple-sshd
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
simple-sshd \
|
|
17
|
+
--host 0.0.0.0 \
|
|
18
|
+
--port 2222 \
|
|
19
|
+
--shell /bin/bash \
|
|
20
|
+
--authorized-keys /etc/simple-sshd/authorized_keys
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Password authentication is disabled unless `--password-file` is provided:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
simple-sshd \
|
|
27
|
+
--host 127.0.0.1 \
|
|
28
|
+
--port 2222 \
|
|
29
|
+
--shell /bin/bash \
|
|
30
|
+
--authorized-keys ~/.ssh/authorized_keys \
|
|
31
|
+
--password-file /etc/simple-sshd/password.txt
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When both `--authorized-keys` and `--password-file` are configured, clients can
|
|
35
|
+
authenticate with either a listed public key or the password read from the file.
|
|
36
|
+
If `--password-file` is omitted, only public-key authentication is available.
|
|
37
|
+
|
|
38
|
+
On Windows, use a Windows shell path such as:
|
|
39
|
+
|
|
40
|
+
```powershell
|
|
41
|
+
simple-sshd --host 127.0.0.1 --port 2222 --shell C:\Windows\System32\cmd.exe --password-file .\password.txt
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Options
|
|
45
|
+
|
|
46
|
+
- `--host`: address to bind, default `127.0.0.1`
|
|
47
|
+
- `--port`: TCP port to bind, default `2222`
|
|
48
|
+
- `--shell`: shell executable launched for authenticated sessions
|
|
49
|
+
- `--authorized-keys`: OpenSSH `authorized_keys` file for public-key auth
|
|
50
|
+
- `--password-file`: text file whose content is accepted as the SSH password
|
|
51
|
+
- `--host-key`: private SSH host key; generated under `~/.simple-sshd` if omitted
|
|
52
|
+
- `--log-level`: `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL`
|
|
53
|
+
|
|
54
|
+
## Notes
|
|
55
|
+
|
|
56
|
+
- Password files are read as UTF-8 text. Trailing line endings are removed, while
|
|
57
|
+
other whitespace is preserved.
|
|
58
|
+
- Host keys are persistent by default so clients can verify the server identity.
|
|
59
|
+
- POSIX systems use a real pty for terminal sessions. Windows terminal sessions
|
|
60
|
+
use `pywinpty`; non-pty sessions use standard process pipes.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "py-simple-sshd"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "A small, production-oriented SSH daemon implemented with Paramiko."
|
|
5
|
+
authors = [{ name = "GGN_2015", email = "neko@jlulug.org" }]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.9,<4.0"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"paramiko>=3.4,<6.0",
|
|
11
|
+
"pywinpty>=2.0,<3.0; sys_platform == 'win32'",
|
|
12
|
+
]
|
|
13
|
+
keywords = ["ssh", "sshd", "paramiko", "server"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: System Administrators",
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Operating System :: Microsoft :: Windows",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Internet",
|
|
29
|
+
"Topic :: Security :: Cryptography",
|
|
30
|
+
"Topic :: System :: Systems Administration",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
simple-sshd = "simple_sshd.cli:main"
|
|
35
|
+
|
|
36
|
+
[tool.poetry]
|
|
37
|
+
packages = [{ include = "simple_sshd", from = "src" }]
|
|
38
|
+
|
|
39
|
+
[tool.poetry.group.dev.dependencies]
|
|
40
|
+
pytest = ">=8.0,<9.0"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
44
|
+
build-backend = "poetry.core.masonry.api"
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
addopts = "-ra"
|
|
48
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Authentication helpers for public keys and password files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hmac
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterable
|
|
10
|
+
|
|
11
|
+
import paramiko
|
|
12
|
+
|
|
13
|
+
from .errors import AuthorizedKeysError, ConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_KEY_TYPES = {
|
|
17
|
+
identifier
|
|
18
|
+
for key_class in (
|
|
19
|
+
paramiko.RSAKey,
|
|
20
|
+
getattr(paramiko, "ECDSAKey", None),
|
|
21
|
+
getattr(paramiko, "Ed25519Key", None),
|
|
22
|
+
getattr(paramiko, "DSSKey", None),
|
|
23
|
+
)
|
|
24
|
+
if key_class is not None
|
|
25
|
+
for identifier in key_class.identifiers()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class AuthorizedPublicKey:
|
|
31
|
+
"""A parsed authorized_keys entry."""
|
|
32
|
+
|
|
33
|
+
key_type: str
|
|
34
|
+
key_blob: str
|
|
35
|
+
line_number: int
|
|
36
|
+
comment: str = ""
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_paramiko_key(
|
|
40
|
+
cls, key: paramiko.PKey, *, line_number: int = 0, comment: str = ""
|
|
41
|
+
) -> "AuthorizedPublicKey":
|
|
42
|
+
return cls(
|
|
43
|
+
key_type=key.get_name(),
|
|
44
|
+
key_blob=key.get_base64(),
|
|
45
|
+
line_number=line_number,
|
|
46
|
+
comment=comment,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def matches(self, key: paramiko.PKey) -> bool:
|
|
50
|
+
return self.key_type == key.get_name() and self.key_blob == key.get_base64()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AuthorizedKeys:
|
|
54
|
+
"""In-memory authorized_keys matcher."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, keys: Iterable[AuthorizedPublicKey] = ()) -> None:
|
|
57
|
+
self._keys = tuple(keys)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def load(cls, path: str | Path | None) -> "AuthorizedKeys":
|
|
61
|
+
if path is None:
|
|
62
|
+
return cls()
|
|
63
|
+
|
|
64
|
+
resolved = Path(path).expanduser()
|
|
65
|
+
if not resolved.exists():
|
|
66
|
+
raise AuthorizedKeysError(f"authorized_keys file does not exist: {resolved}")
|
|
67
|
+
if not resolved.is_file():
|
|
68
|
+
raise AuthorizedKeysError(f"authorized_keys path is not a file: {resolved}")
|
|
69
|
+
|
|
70
|
+
keys: list[AuthorizedPublicKey] = []
|
|
71
|
+
for line_number, line in enumerate(resolved.read_text(encoding="utf-8").splitlines(), 1):
|
|
72
|
+
key = parse_authorized_key_line(line, line_number=line_number)
|
|
73
|
+
if key is not None:
|
|
74
|
+
keys.append(key)
|
|
75
|
+
return cls(keys)
|
|
76
|
+
|
|
77
|
+
def __len__(self) -> int:
|
|
78
|
+
return len(self._keys)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def enabled(self) -> bool:
|
|
82
|
+
return bool(self._keys)
|
|
83
|
+
|
|
84
|
+
def is_authorized(self, key: paramiko.PKey) -> bool:
|
|
85
|
+
return any(authorized.matches(key) for authorized in self._keys)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_authorized_key_line(
|
|
89
|
+
line: str, *, line_number: int = 0
|
|
90
|
+
) -> AuthorizedPublicKey | None:
|
|
91
|
+
"""Parse an OpenSSH authorized_keys line.
|
|
92
|
+
|
|
93
|
+
The parser accepts comments, blank lines, and leading OpenSSH key options.
|
|
94
|
+
Unsupported key algorithms are ignored by returning None.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
stripped = line.strip()
|
|
98
|
+
if not stripped or stripped.startswith("#"):
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
fields = stripped.split()
|
|
102
|
+
for index, field in enumerate(fields[:-1]):
|
|
103
|
+
if field not in _KEY_TYPES:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
key_type = field
|
|
107
|
+
key_blob = fields[index + 1]
|
|
108
|
+
try:
|
|
109
|
+
key_bytes = base64.b64decode(key_blob.encode("ascii"), validate=True)
|
|
110
|
+
key = paramiko.PKey.from_type_string(key_type, key_bytes)
|
|
111
|
+
except Exception as exc: # Paramiko raises several key-specific errors.
|
|
112
|
+
raise AuthorizedKeysError(
|
|
113
|
+
f"invalid authorized_keys entry at line {line_number}: {exc}"
|
|
114
|
+
) from exc
|
|
115
|
+
|
|
116
|
+
comment = " ".join(fields[index + 2 :])
|
|
117
|
+
return AuthorizedPublicKey.from_paramiko_key(
|
|
118
|
+
key, line_number=line_number, comment=comment
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_password_file(path: str | Path | None) -> str | None:
|
|
125
|
+
"""Load a password from a text file.
|
|
126
|
+
|
|
127
|
+
Trailing CR/LF characters are removed so files created with echo work as
|
|
128
|
+
expected. Other whitespace is preserved.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
if path is None:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
resolved = Path(path).expanduser()
|
|
135
|
+
if not resolved.exists():
|
|
136
|
+
raise ConfigurationError(f"password file does not exist: {resolved}")
|
|
137
|
+
if not resolved.is_file():
|
|
138
|
+
raise ConfigurationError(f"password path is not a file: {resolved}")
|
|
139
|
+
|
|
140
|
+
password = resolved.read_text(encoding="utf-8").rstrip("\r\n")
|
|
141
|
+
if not password:
|
|
142
|
+
raise ConfigurationError("password file must not be empty")
|
|
143
|
+
return password
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Authenticator:
|
|
147
|
+
"""Public-key and password authenticator."""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
authorized_keys: AuthorizedKeys | None = None,
|
|
153
|
+
password: str | None = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
self.authorized_keys = authorized_keys or AuthorizedKeys()
|
|
156
|
+
self.password = password
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def publickey_enabled(self) -> bool:
|
|
160
|
+
return self.authorized_keys.enabled
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def password_enabled(self) -> bool:
|
|
164
|
+
return self.password is not None
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def enabled(self) -> bool:
|
|
168
|
+
return self.publickey_enabled or self.password_enabled
|
|
169
|
+
|
|
170
|
+
def allowed_auths(self) -> str:
|
|
171
|
+
methods: list[str] = []
|
|
172
|
+
if self.publickey_enabled:
|
|
173
|
+
methods.append("publickey")
|
|
174
|
+
if self.password_enabled:
|
|
175
|
+
methods.append("password")
|
|
176
|
+
return ",".join(methods)
|
|
177
|
+
|
|
178
|
+
def check_public_key(self, key: paramiko.PKey) -> bool:
|
|
179
|
+
return self.authorized_keys.is_authorized(key)
|
|
180
|
+
|
|
181
|
+
def check_password(self, password: str) -> bool:
|
|
182
|
+
if self.password is None:
|
|
183
|
+
return False
|
|
184
|
+
return hmac.compare_digest(password, self.password)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .errors import SimpleSSHDError
|
|
12
|
+
from .server import SSHDConfig, SimpleSSHD
|
|
13
|
+
from .shell import default_shell
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="simple-sshd",
|
|
19
|
+
description="Run a small Paramiko-based SSH daemon.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--host", default="127.0.0.1", help="Host/address to bind.")
|
|
22
|
+
parser.add_argument("--port", type=int, default=2222, help="TCP port to bind.")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--shell",
|
|
25
|
+
default=default_shell(),
|
|
26
|
+
help="Shell executable path to launch for authenticated sessions.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--authorized-keys",
|
|
30
|
+
type=Path,
|
|
31
|
+
help="OpenSSH authorized_keys file used for public-key authentication.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--password-file",
|
|
35
|
+
type=Path,
|
|
36
|
+
help="Text file whose content is used as an accepted SSH password.",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--host-key",
|
|
40
|
+
type=Path,
|
|
41
|
+
help="Private host key file. Created automatically when omitted.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--log-level",
|
|
45
|
+
default="INFO",
|
|
46
|
+
choices=("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"),
|
|
47
|
+
help="Logging verbosity.",
|
|
48
|
+
)
|
|
49
|
+
return parser
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: list[str] | None = None) -> int:
|
|
53
|
+
parser = build_parser()
|
|
54
|
+
args = parser.parse_args(argv)
|
|
55
|
+
logging.basicConfig(
|
|
56
|
+
level=getattr(logging, args.log_level),
|
|
57
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
config = SSHDConfig(
|
|
61
|
+
host=args.host,
|
|
62
|
+
port=args.port,
|
|
63
|
+
shell=args.shell,
|
|
64
|
+
authorized_keys=args.authorized_keys,
|
|
65
|
+
password_file=args.password_file,
|
|
66
|
+
host_key=args.host_key,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
server = SimpleSSHD(config)
|
|
71
|
+
except SimpleSSHDError as exc:
|
|
72
|
+
parser.error(str(exc))
|
|
73
|
+
|
|
74
|
+
def stop(signum: int, frame: object) -> None:
|
|
75
|
+
logging.getLogger(__name__).info("received signal %s, shutting down", signum)
|
|
76
|
+
server.close()
|
|
77
|
+
|
|
78
|
+
for signal_name in ("SIGINT", "SIGTERM"):
|
|
79
|
+
signum = getattr(signal, signal_name, None)
|
|
80
|
+
if signum is not None:
|
|
81
|
+
signal.signal(signum, stop)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
return server.serve_forever()
|
|
85
|
+
except KeyboardInterrupt:
|
|
86
|
+
server.close()
|
|
87
|
+
return 130
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
print(f"simple-sshd: {exc}", file=sys.stderr)
|
|
90
|
+
return 1
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Package-specific exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SimpleSSHDError(Exception):
|
|
5
|
+
"""Base class for simple-sshd exceptions."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(SimpleSSHDError):
|
|
9
|
+
"""Raised when configuration cannot be used safely."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthorizedKeysError(ConfigurationError):
|
|
13
|
+
"""Raised when an authorized_keys file cannot be parsed."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Host key loading and creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import paramiko
|
|
9
|
+
|
|
10
|
+
from .errors import ConfigurationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_host_key_path() -> Path:
|
|
14
|
+
return Path.home() / ".simple-sshd" / "ssh_host_rsa_key"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_or_create_host_key(path: str | Path | None = None) -> paramiko.PKey:
|
|
18
|
+
resolved = Path(path).expanduser() if path is not None else default_host_key_path()
|
|
19
|
+
if resolved.exists():
|
|
20
|
+
return load_host_key(resolved)
|
|
21
|
+
|
|
22
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
key = paramiko.RSAKey.generate(bits=3072)
|
|
24
|
+
key.write_private_key_file(str(resolved))
|
|
25
|
+
if os.name != "nt":
|
|
26
|
+
os.chmod(resolved, 0o600)
|
|
27
|
+
return key
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_host_key(path: str | Path) -> paramiko.PKey:
|
|
31
|
+
resolved = Path(path).expanduser()
|
|
32
|
+
if not resolved.is_file():
|
|
33
|
+
raise ConfigurationError(f"host key path is not a file: {resolved}")
|
|
34
|
+
|
|
35
|
+
key_classes = (
|
|
36
|
+
paramiko.RSAKey,
|
|
37
|
+
getattr(paramiko, "ECDSAKey", None),
|
|
38
|
+
getattr(paramiko, "Ed25519Key", None),
|
|
39
|
+
)
|
|
40
|
+
errors: list[str] = []
|
|
41
|
+
for key_class in key_classes:
|
|
42
|
+
if key_class is None:
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
return key_class.from_private_key_file(str(resolved))
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
errors.append(f"{key_class.__name__}: {exc}")
|
|
48
|
+
|
|
49
|
+
detail = "; ".join(errors)
|
|
50
|
+
raise ConfigurationError(f"unable to load host key {resolved}: {detail}")
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Paramiko SSH server implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import queue
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Mapping
|
|
12
|
+
|
|
13
|
+
import paramiko
|
|
14
|
+
|
|
15
|
+
from .auth import Authenticator, AuthorizedKeys, load_password_file
|
|
16
|
+
from .errors import ConfigurationError
|
|
17
|
+
from .hostkey import load_or_create_host_key
|
|
18
|
+
from .shell import PtyRequest, resolve_shell, run_shell_session
|
|
19
|
+
|
|
20
|
+
LOG = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SSHDConfig:
|
|
25
|
+
host: str = "127.0.0.1"
|
|
26
|
+
port: int = 2222
|
|
27
|
+
shell: str | Path | None = None
|
|
28
|
+
authorized_keys: str | Path | None = None
|
|
29
|
+
password_file: str | Path | None = None
|
|
30
|
+
host_key: str | Path | None = None
|
|
31
|
+
backlog: int = 100
|
|
32
|
+
banner: str = "simple-sshd"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ChannelState:
|
|
37
|
+
event: threading.Event = field(default_factory=threading.Event)
|
|
38
|
+
pty: PtyRequest | None = None
|
|
39
|
+
request: "SessionRequest | None" = None
|
|
40
|
+
environment: dict[str, str] = field(default_factory=dict)
|
|
41
|
+
resize_queue: queue.Queue[tuple[int, int]] = field(default_factory=queue.Queue)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SessionRequest:
|
|
46
|
+
kind: str
|
|
47
|
+
command: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SimpleServerInterface(paramiko.ServerInterface):
|
|
51
|
+
def __init__(self, authenticator: Authenticator, banner: str) -> None:
|
|
52
|
+
self._authenticator = authenticator
|
|
53
|
+
self._banner = banner
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
self._channels: dict[int, ChannelState] = {}
|
|
56
|
+
|
|
57
|
+
def get_banner(self) -> tuple[str, str]:
|
|
58
|
+
return (self._banner, "en-US")
|
|
59
|
+
|
|
60
|
+
def get_allowed_auths(self, username: str) -> str:
|
|
61
|
+
return self._authenticator.allowed_auths()
|
|
62
|
+
|
|
63
|
+
def check_auth_publickey(self, username: str, key: paramiko.PKey) -> int:
|
|
64
|
+
if self._authenticator.check_public_key(key):
|
|
65
|
+
LOG.info("accepted public key for user %s", username)
|
|
66
|
+
return paramiko.AUTH_SUCCESSFUL
|
|
67
|
+
LOG.warning("rejected public key for user %s", username)
|
|
68
|
+
return paramiko.AUTH_FAILED
|
|
69
|
+
|
|
70
|
+
def check_auth_password(self, username: str, password: str) -> int:
|
|
71
|
+
if self._authenticator.check_password(password):
|
|
72
|
+
LOG.info("accepted password for user %s", username)
|
|
73
|
+
return paramiko.AUTH_SUCCESSFUL
|
|
74
|
+
LOG.warning("rejected password for user %s", username)
|
|
75
|
+
return paramiko.AUTH_FAILED
|
|
76
|
+
|
|
77
|
+
def check_channel_request(self, kind: str, chanid: int) -> int:
|
|
78
|
+
if kind != "session":
|
|
79
|
+
return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
|
|
80
|
+
self._state(chanid)
|
|
81
|
+
return paramiko.OPEN_SUCCEEDED
|
|
82
|
+
|
|
83
|
+
def check_channel_pty_request(
|
|
84
|
+
self,
|
|
85
|
+
channel: paramiko.Channel,
|
|
86
|
+
term: bytes,
|
|
87
|
+
width: int,
|
|
88
|
+
height: int,
|
|
89
|
+
pixelwidth: int,
|
|
90
|
+
pixelheight: int,
|
|
91
|
+
modes: bytes,
|
|
92
|
+
) -> bool:
|
|
93
|
+
state = self._state(channel.get_id())
|
|
94
|
+
state.pty = PtyRequest(
|
|
95
|
+
term=term.decode("utf-8", errors="replace"),
|
|
96
|
+
width=width,
|
|
97
|
+
height=height,
|
|
98
|
+
pixelwidth=pixelwidth,
|
|
99
|
+
pixelheight=pixelheight,
|
|
100
|
+
)
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
def check_channel_env_request(
|
|
104
|
+
self, channel: paramiko.Channel, name: bytes, value: bytes
|
|
105
|
+
) -> bool:
|
|
106
|
+
state = self._state(channel.get_id())
|
|
107
|
+
decoded_name = name.decode("utf-8", errors="replace")
|
|
108
|
+
decoded_value = value.decode("utf-8", errors="replace")
|
|
109
|
+
state.environment[decoded_name] = decoded_value
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
def check_channel_shell_request(self, channel: paramiko.Channel) -> bool:
|
|
113
|
+
state = self._state(channel.get_id())
|
|
114
|
+
state.request = SessionRequest(kind="shell")
|
|
115
|
+
state.event.set()
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
def check_channel_exec_request(self, channel: paramiko.Channel, command: bytes) -> bool:
|
|
119
|
+
state = self._state(channel.get_id())
|
|
120
|
+
state.request = SessionRequest(
|
|
121
|
+
kind="exec",
|
|
122
|
+
command=command.decode("utf-8", errors="replace"),
|
|
123
|
+
)
|
|
124
|
+
state.event.set()
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
def check_channel_window_change_request(
|
|
128
|
+
self,
|
|
129
|
+
channel: paramiko.Channel,
|
|
130
|
+
width: int,
|
|
131
|
+
height: int,
|
|
132
|
+
pixelwidth: int,
|
|
133
|
+
pixelheight: int,
|
|
134
|
+
) -> bool:
|
|
135
|
+
state = self._state(channel.get_id())
|
|
136
|
+
state.resize_queue.put((height, width))
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
def wait_for_request(
|
|
140
|
+
self, channel: paramiko.Channel, timeout: float
|
|
141
|
+
) -> tuple[SessionRequest, PtyRequest | None, Mapping[str, str], queue.Queue[tuple[int, int]]] | None:
|
|
142
|
+
state = self._state(channel.get_id())
|
|
143
|
+
if not state.event.wait(timeout):
|
|
144
|
+
return None
|
|
145
|
+
if state.request is None:
|
|
146
|
+
return None
|
|
147
|
+
return state.request, state.pty, state.environment, state.resize_queue
|
|
148
|
+
|
|
149
|
+
def cleanup_channel(self, channel: paramiko.Channel) -> None:
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._channels.pop(channel.get_id(), None)
|
|
152
|
+
|
|
153
|
+
def _state(self, chanid: int) -> ChannelState:
|
|
154
|
+
with self._lock:
|
|
155
|
+
state = self._channels.get(chanid)
|
|
156
|
+
if state is None:
|
|
157
|
+
state = ChannelState()
|
|
158
|
+
self._channels[chanid] = state
|
|
159
|
+
return state
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SimpleSSHD:
|
|
163
|
+
"""A thread-per-connection SSH daemon."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, config: SSHDConfig) -> None:
|
|
166
|
+
self.config = config
|
|
167
|
+
self._socket: socket.socket | None = None
|
|
168
|
+
self._stop = threading.Event()
|
|
169
|
+
self._ready = threading.Event()
|
|
170
|
+
self._serve_thread: threading.Thread | None = None
|
|
171
|
+
self._startup_error: BaseException | None = None
|
|
172
|
+
self._transports: set[paramiko.Transport] = set()
|
|
173
|
+
self._transports_lock = threading.Lock()
|
|
174
|
+
|
|
175
|
+
self.shell = resolve_shell(config.shell)
|
|
176
|
+
self.host_key = load_or_create_host_key(config.host_key)
|
|
177
|
+
self.authenticator = Authenticator(
|
|
178
|
+
authorized_keys=AuthorizedKeys.load(config.authorized_keys),
|
|
179
|
+
password=load_password_file(config.password_file),
|
|
180
|
+
)
|
|
181
|
+
if not self.authenticator.enabled:
|
|
182
|
+
raise ConfigurationError(
|
|
183
|
+
"no authentication method configured; provide authorized_keys, password_file, or both"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def server_address(self) -> tuple[str, int]:
|
|
188
|
+
if self._socket is None:
|
|
189
|
+
return (self.config.host, self.config.port)
|
|
190
|
+
host, port = self._socket.getsockname()[:2]
|
|
191
|
+
return (str(host), int(port))
|
|
192
|
+
|
|
193
|
+
def start(self) -> threading.Thread:
|
|
194
|
+
if self._serve_thread is not None:
|
|
195
|
+
return self._serve_thread
|
|
196
|
+
self._serve_thread = threading.Thread(
|
|
197
|
+
target=self.serve_forever,
|
|
198
|
+
name="simple-sshd-listener",
|
|
199
|
+
daemon=True,
|
|
200
|
+
)
|
|
201
|
+
self._serve_thread.start()
|
|
202
|
+
if not self._ready.wait(timeout=10):
|
|
203
|
+
raise TimeoutError("simple-sshd did not finish startup within 10 seconds")
|
|
204
|
+
if self._startup_error is not None:
|
|
205
|
+
raise self._startup_error
|
|
206
|
+
return self._serve_thread
|
|
207
|
+
|
|
208
|
+
def serve_forever(self) -> int:
|
|
209
|
+
try:
|
|
210
|
+
self._bind()
|
|
211
|
+
except BaseException as exc:
|
|
212
|
+
self._startup_error = exc
|
|
213
|
+
self._ready.set()
|
|
214
|
+
raise
|
|
215
|
+
self._ready.set()
|
|
216
|
+
LOG.info("listening on %s:%s", *self.server_address)
|
|
217
|
+
|
|
218
|
+
assert self._socket is not None
|
|
219
|
+
while not self._stop.is_set():
|
|
220
|
+
try:
|
|
221
|
+
client, address = self._socket.accept()
|
|
222
|
+
except socket.timeout:
|
|
223
|
+
continue
|
|
224
|
+
except OSError:
|
|
225
|
+
break
|
|
226
|
+
thread = threading.Thread(
|
|
227
|
+
target=self._handle_client,
|
|
228
|
+
args=(client, address),
|
|
229
|
+
name=f"simple-sshd-client-{address[0]}:{address[1]}",
|
|
230
|
+
daemon=True,
|
|
231
|
+
)
|
|
232
|
+
thread.start()
|
|
233
|
+
self.close()
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
def close(self) -> None:
|
|
237
|
+
self._stop.set()
|
|
238
|
+
if self._socket is not None:
|
|
239
|
+
try:
|
|
240
|
+
self._socket.close()
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
with self._transports_lock:
|
|
245
|
+
transports = list(self._transports)
|
|
246
|
+
self._transports.clear()
|
|
247
|
+
for transport in transports:
|
|
248
|
+
transport.close()
|
|
249
|
+
|
|
250
|
+
def _bind(self) -> None:
|
|
251
|
+
if self._socket is not None:
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
last_error: OSError | None = None
|
|
255
|
+
for family, socktype, proto, _, sockaddr in socket.getaddrinfo(
|
|
256
|
+
self.config.host,
|
|
257
|
+
self.config.port,
|
|
258
|
+
type=socket.SOCK_STREAM,
|
|
259
|
+
flags=socket.AI_PASSIVE,
|
|
260
|
+
):
|
|
261
|
+
sock = socket.socket(family, socktype, proto)
|
|
262
|
+
try:
|
|
263
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
264
|
+
sock.bind(sockaddr)
|
|
265
|
+
sock.listen(self.config.backlog)
|
|
266
|
+
sock.settimeout(0.5)
|
|
267
|
+
self._socket = sock
|
|
268
|
+
return
|
|
269
|
+
except OSError as exc:
|
|
270
|
+
last_error = exc
|
|
271
|
+
sock.close()
|
|
272
|
+
if last_error is not None:
|
|
273
|
+
raise last_error
|
|
274
|
+
raise ConfigurationError("unable to resolve bind address")
|
|
275
|
+
|
|
276
|
+
def _handle_client(self, client: socket.socket, address: object) -> None:
|
|
277
|
+
transport = paramiko.Transport(client)
|
|
278
|
+
transport.local_version = "SSH-2.0-simple-sshd"
|
|
279
|
+
transport.add_server_key(self.host_key)
|
|
280
|
+
server = SimpleServerInterface(self.authenticator, self.config.banner)
|
|
281
|
+
|
|
282
|
+
with self._transports_lock:
|
|
283
|
+
self._transports.add(transport)
|
|
284
|
+
try:
|
|
285
|
+
transport.start_server(server=server)
|
|
286
|
+
while transport.is_active() and not self._stop.is_set():
|
|
287
|
+
channel = transport.accept(timeout=1)
|
|
288
|
+
if channel is None:
|
|
289
|
+
continue
|
|
290
|
+
thread = threading.Thread(
|
|
291
|
+
target=self._handle_channel,
|
|
292
|
+
args=(server, channel),
|
|
293
|
+
name=f"simple-sshd-channel-{channel.get_id()}",
|
|
294
|
+
daemon=True,
|
|
295
|
+
)
|
|
296
|
+
thread.start()
|
|
297
|
+
except Exception:
|
|
298
|
+
LOG.exception("client session failed for %s", address)
|
|
299
|
+
finally:
|
|
300
|
+
transport.close()
|
|
301
|
+
with self._transports_lock:
|
|
302
|
+
self._transports.discard(transport)
|
|
303
|
+
|
|
304
|
+
def _handle_channel(
|
|
305
|
+
self, server: SimpleServerInterface, channel: paramiko.Channel
|
|
306
|
+
) -> None:
|
|
307
|
+
try:
|
|
308
|
+
request = server.wait_for_request(channel, timeout=30)
|
|
309
|
+
if request is None:
|
|
310
|
+
channel.close()
|
|
311
|
+
return
|
|
312
|
+
session_request, pty, environment, resize_queue = request
|
|
313
|
+
run_shell_session(
|
|
314
|
+
channel=channel,
|
|
315
|
+
shell=self.shell,
|
|
316
|
+
pty=pty,
|
|
317
|
+
resize_queue=resize_queue,
|
|
318
|
+
environment=environment,
|
|
319
|
+
command=session_request.command,
|
|
320
|
+
)
|
|
321
|
+
except Exception:
|
|
322
|
+
LOG.exception("channel failed")
|
|
323
|
+
try:
|
|
324
|
+
channel.send_exit_status(1)
|
|
325
|
+
except OSError:
|
|
326
|
+
pass
|
|
327
|
+
finally:
|
|
328
|
+
server.cleanup_channel(channel)
|
|
329
|
+
channel.close()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def serve_forever(config: SSHDConfig) -> int:
|
|
333
|
+
return SimpleSSHD(config).serve_forever()
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Shell process backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import select
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Mapping
|
|
16
|
+
|
|
17
|
+
import paramiko
|
|
18
|
+
|
|
19
|
+
from .errors import ConfigurationError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PtyRequest:
|
|
24
|
+
term: str
|
|
25
|
+
width: int
|
|
26
|
+
height: int
|
|
27
|
+
pixelwidth: int = 0
|
|
28
|
+
pixelheight: int = 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ResizeQueue = "queue.Queue[tuple[int, int]]"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_shell() -> str:
|
|
35
|
+
if os.name == "nt":
|
|
36
|
+
return os.environ.get("COMSPEC") or shutil.which("cmd.exe") or "cmd.exe"
|
|
37
|
+
return os.environ.get("SHELL") or "/bin/sh"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_shell(shell: str | Path | None) -> str:
|
|
41
|
+
candidate = str(shell or default_shell())
|
|
42
|
+
expanded = Path(candidate).expanduser()
|
|
43
|
+
has_path_separator = any(separator and separator in candidate for separator in (os.sep, os.altsep))
|
|
44
|
+
|
|
45
|
+
if expanded.is_absolute() or has_path_separator:
|
|
46
|
+
if not expanded.exists():
|
|
47
|
+
raise ConfigurationError(f"shell executable does not exist: {expanded}")
|
|
48
|
+
if not expanded.is_file():
|
|
49
|
+
raise ConfigurationError(f"shell path is not a file: {expanded}")
|
|
50
|
+
return str(expanded)
|
|
51
|
+
|
|
52
|
+
resolved = shutil.which(candidate)
|
|
53
|
+
if resolved is None:
|
|
54
|
+
raise ConfigurationError(f"shell executable not found on PATH: {candidate}")
|
|
55
|
+
return resolved
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_shell_session(
|
|
59
|
+
*,
|
|
60
|
+
channel: paramiko.Channel,
|
|
61
|
+
shell: str,
|
|
62
|
+
pty: PtyRequest | None,
|
|
63
|
+
resize_queue: queue.Queue[tuple[int, int]],
|
|
64
|
+
environment: Mapping[str, str] | None = None,
|
|
65
|
+
command: str | None = None,
|
|
66
|
+
) -> int:
|
|
67
|
+
if command is not None:
|
|
68
|
+
return _run_pipe_process(
|
|
69
|
+
channel=channel,
|
|
70
|
+
argv=_command_argv(shell, command),
|
|
71
|
+
resize_queue=resize_queue,
|
|
72
|
+
environment=environment,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if pty is not None:
|
|
76
|
+
if os.name == "nt":
|
|
77
|
+
return _run_windows_pty(
|
|
78
|
+
channel=channel,
|
|
79
|
+
shell=shell,
|
|
80
|
+
pty=pty,
|
|
81
|
+
resize_queue=resize_queue,
|
|
82
|
+
environment=environment,
|
|
83
|
+
)
|
|
84
|
+
return _run_posix_pty(
|
|
85
|
+
channel=channel,
|
|
86
|
+
shell=shell,
|
|
87
|
+
pty=pty,
|
|
88
|
+
resize_queue=resize_queue,
|
|
89
|
+
environment=environment,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return _run_pipe_process(
|
|
93
|
+
channel=channel,
|
|
94
|
+
argv=[shell],
|
|
95
|
+
resize_queue=resize_queue,
|
|
96
|
+
environment=environment,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _merged_environment(environment: Mapping[str, str] | None) -> dict[str, str]:
|
|
101
|
+
merged = os.environ.copy()
|
|
102
|
+
if environment:
|
|
103
|
+
merged.update(environment)
|
|
104
|
+
return merged
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _command_argv(shell: str, command: str) -> list[str]:
|
|
108
|
+
name = Path(shell).name.lower()
|
|
109
|
+
if os.name == "nt":
|
|
110
|
+
if name in {"cmd", "cmd.exe"}:
|
|
111
|
+
return [shell, "/D", "/C", command]
|
|
112
|
+
if name in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}:
|
|
113
|
+
return [shell, "-NoLogo", "-NoProfile", "-Command", command]
|
|
114
|
+
return [shell, "-c", command]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run_pipe_process(
|
|
118
|
+
*,
|
|
119
|
+
channel: paramiko.Channel,
|
|
120
|
+
argv: list[str],
|
|
121
|
+
resize_queue: queue.Queue[tuple[int, int]],
|
|
122
|
+
environment: Mapping[str, str] | None,
|
|
123
|
+
) -> int:
|
|
124
|
+
process = subprocess.Popen(
|
|
125
|
+
argv,
|
|
126
|
+
stdin=subprocess.PIPE,
|
|
127
|
+
stdout=subprocess.PIPE,
|
|
128
|
+
stderr=subprocess.STDOUT,
|
|
129
|
+
env=_merged_environment(environment),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
stdout_thread = threading.Thread(
|
|
133
|
+
target=_copy_process_output,
|
|
134
|
+
args=(process, channel),
|
|
135
|
+
name="simple-sshd-pipe-output",
|
|
136
|
+
daemon=True,
|
|
137
|
+
)
|
|
138
|
+
stdout_thread.start()
|
|
139
|
+
|
|
140
|
+
channel.settimeout(0.2)
|
|
141
|
+
try:
|
|
142
|
+
while process.poll() is None:
|
|
143
|
+
_drain_resize_queue(resize_queue)
|
|
144
|
+
try:
|
|
145
|
+
data = channel.recv(32768)
|
|
146
|
+
except socket.timeout:
|
|
147
|
+
continue
|
|
148
|
+
except OSError:
|
|
149
|
+
break
|
|
150
|
+
if not data:
|
|
151
|
+
break
|
|
152
|
+
if process.stdin is None:
|
|
153
|
+
break
|
|
154
|
+
try:
|
|
155
|
+
process.stdin.write(data)
|
|
156
|
+
process.stdin.flush()
|
|
157
|
+
except OSError:
|
|
158
|
+
break
|
|
159
|
+
finally:
|
|
160
|
+
if process.stdin is not None:
|
|
161
|
+
try:
|
|
162
|
+
process.stdin.close()
|
|
163
|
+
except OSError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
exit_status = process.wait(timeout=5)
|
|
168
|
+
except subprocess.TimeoutExpired:
|
|
169
|
+
process.terminate()
|
|
170
|
+
try:
|
|
171
|
+
exit_status = process.wait(timeout=5)
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
process.kill()
|
|
174
|
+
exit_status = process.wait()
|
|
175
|
+
|
|
176
|
+
stdout_thread.join(timeout=2)
|
|
177
|
+
_send_exit_status(channel, exit_status)
|
|
178
|
+
return exit_status
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _copy_process_output(process: subprocess.Popen[bytes], channel: paramiko.Channel) -> None:
|
|
182
|
+
if process.stdout is None:
|
|
183
|
+
return
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
data = process.stdout.read(32768)
|
|
187
|
+
except OSError:
|
|
188
|
+
return
|
|
189
|
+
if not data:
|
|
190
|
+
return
|
|
191
|
+
if not _channel_send(channel, data):
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _run_posix_pty(
|
|
196
|
+
*,
|
|
197
|
+
channel: paramiko.Channel,
|
|
198
|
+
shell: str,
|
|
199
|
+
pty: PtyRequest,
|
|
200
|
+
resize_queue: queue.Queue[tuple[int, int]],
|
|
201
|
+
environment: Mapping[str, str] | None,
|
|
202
|
+
) -> int:
|
|
203
|
+
import fcntl
|
|
204
|
+
import pty as posix_pty
|
|
205
|
+
import struct
|
|
206
|
+
import termios
|
|
207
|
+
|
|
208
|
+
child_environment = _merged_environment(environment)
|
|
209
|
+
|
|
210
|
+
def set_winsize(fd: int, rows: int, cols: int) -> None:
|
|
211
|
+
packed = struct.pack("HHHH", rows, cols, 0, 0)
|
|
212
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, packed)
|
|
213
|
+
|
|
214
|
+
pid, master_fd = posix_pty.fork()
|
|
215
|
+
if pid == 0:
|
|
216
|
+
os.environ.clear()
|
|
217
|
+
os.environ.update(child_environment)
|
|
218
|
+
os.execv(shell, [Path(shell).name])
|
|
219
|
+
|
|
220
|
+
set_winsize(master_fd, pty.height, pty.width)
|
|
221
|
+
exit_status = 0
|
|
222
|
+
try:
|
|
223
|
+
while True:
|
|
224
|
+
_apply_posix_resizes(resize_queue, master_fd, set_winsize)
|
|
225
|
+
readable, _, _ = select.select([channel, master_fd], [], [], 0.2)
|
|
226
|
+
|
|
227
|
+
if channel in readable:
|
|
228
|
+
data = channel.recv(32768)
|
|
229
|
+
if not data:
|
|
230
|
+
break
|
|
231
|
+
os.write(master_fd, data)
|
|
232
|
+
|
|
233
|
+
if master_fd in readable:
|
|
234
|
+
try:
|
|
235
|
+
data = os.read(master_fd, 32768)
|
|
236
|
+
except OSError:
|
|
237
|
+
break
|
|
238
|
+
if not data:
|
|
239
|
+
break
|
|
240
|
+
if not _channel_send(channel, data):
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
waited_pid, status = os.waitpid(pid, os.WNOHANG)
|
|
244
|
+
if waited_pid == pid:
|
|
245
|
+
exit_status = os.waitstatus_to_exitcode(status)
|
|
246
|
+
break
|
|
247
|
+
finally:
|
|
248
|
+
try:
|
|
249
|
+
os.close(master_fd)
|
|
250
|
+
except OSError:
|
|
251
|
+
pass
|
|
252
|
+
try:
|
|
253
|
+
waited_pid, status = os.waitpid(pid, os.WNOHANG)
|
|
254
|
+
if waited_pid == pid:
|
|
255
|
+
exit_status = os.waitstatus_to_exitcode(status)
|
|
256
|
+
except ChildProcessError:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
_send_exit_status(channel, exit_status)
|
|
260
|
+
return exit_status
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _apply_posix_resizes(
|
|
264
|
+
resize_queue: queue.Queue[tuple[int, int]],
|
|
265
|
+
master_fd: int,
|
|
266
|
+
set_winsize: object,
|
|
267
|
+
) -> None:
|
|
268
|
+
while True:
|
|
269
|
+
try:
|
|
270
|
+
rows, cols = resize_queue.get_nowait()
|
|
271
|
+
except queue.Empty:
|
|
272
|
+
return
|
|
273
|
+
try:
|
|
274
|
+
set_winsize(master_fd, rows, cols) # type: ignore[misc]
|
|
275
|
+
except OSError:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _run_windows_pty(
|
|
280
|
+
*,
|
|
281
|
+
channel: paramiko.Channel,
|
|
282
|
+
shell: str,
|
|
283
|
+
pty: PtyRequest,
|
|
284
|
+
resize_queue: queue.Queue[tuple[int, int]],
|
|
285
|
+
environment: Mapping[str, str] | None,
|
|
286
|
+
) -> int:
|
|
287
|
+
try:
|
|
288
|
+
from winpty import PtyProcess
|
|
289
|
+
except ImportError as exc:
|
|
290
|
+
raise ConfigurationError(
|
|
291
|
+
"PTY shell sessions on Windows require pywinpty to be installed"
|
|
292
|
+
) from exc
|
|
293
|
+
|
|
294
|
+
process = PtyProcess.spawn(
|
|
295
|
+
shell,
|
|
296
|
+
env=_merged_environment(environment),
|
|
297
|
+
dimensions=(pty.height, pty.width),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
reader = threading.Thread(
|
|
301
|
+
target=_copy_winpty_output,
|
|
302
|
+
args=(process, channel),
|
|
303
|
+
name="simple-sshd-winpty-output",
|
|
304
|
+
daemon=True,
|
|
305
|
+
)
|
|
306
|
+
reader.start()
|
|
307
|
+
|
|
308
|
+
channel.settimeout(0.2)
|
|
309
|
+
try:
|
|
310
|
+
while _winpty_is_alive(process):
|
|
311
|
+
_apply_winpty_resizes(process, resize_queue)
|
|
312
|
+
try:
|
|
313
|
+
data = channel.recv(32768)
|
|
314
|
+
except socket.timeout:
|
|
315
|
+
continue
|
|
316
|
+
except OSError:
|
|
317
|
+
break
|
|
318
|
+
if not data:
|
|
319
|
+
break
|
|
320
|
+
_winpty_write(process, data)
|
|
321
|
+
finally:
|
|
322
|
+
try:
|
|
323
|
+
process.close()
|
|
324
|
+
except Exception:
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
reader.join(timeout=2)
|
|
328
|
+
exit_status = int(getattr(process, "exitstatus", 0) or 0)
|
|
329
|
+
_send_exit_status(channel, exit_status)
|
|
330
|
+
return exit_status
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _copy_winpty_output(process: object, channel: paramiko.Channel) -> None:
|
|
334
|
+
while _winpty_is_alive(process):
|
|
335
|
+
try:
|
|
336
|
+
data = process.read(32768) # type: ignore[attr-defined]
|
|
337
|
+
except Exception:
|
|
338
|
+
return
|
|
339
|
+
if not data:
|
|
340
|
+
continue
|
|
341
|
+
if isinstance(data, str):
|
|
342
|
+
payload = data.encode(_console_encoding(), errors="replace")
|
|
343
|
+
else:
|
|
344
|
+
payload = bytes(data)
|
|
345
|
+
if not _channel_send(channel, payload):
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _winpty_write(process: object, data: bytes) -> None:
|
|
350
|
+
text = data.decode(_console_encoding(), errors="replace")
|
|
351
|
+
process.write(text) # type: ignore[attr-defined]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _winpty_is_alive(process: object) -> bool:
|
|
355
|
+
isalive = getattr(process, "isalive", None)
|
|
356
|
+
if callable(isalive):
|
|
357
|
+
return bool(isalive())
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _apply_winpty_resizes(process: object, resize_queue: queue.Queue[tuple[int, int]]) -> None:
|
|
362
|
+
while True:
|
|
363
|
+
try:
|
|
364
|
+
rows, cols = resize_queue.get_nowait()
|
|
365
|
+
except queue.Empty:
|
|
366
|
+
return
|
|
367
|
+
for method_name, args in (("set_size", (cols, rows)), ("setwinsize", (rows, cols))):
|
|
368
|
+
method = getattr(process, method_name, None)
|
|
369
|
+
if callable(method):
|
|
370
|
+
method(*args)
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _drain_resize_queue(resize_queue: queue.Queue[tuple[int, int]]) -> None:
|
|
375
|
+
while True:
|
|
376
|
+
try:
|
|
377
|
+
resize_queue.get_nowait()
|
|
378
|
+
except queue.Empty:
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _channel_send(channel: paramiko.Channel, data: bytes) -> bool:
|
|
383
|
+
try:
|
|
384
|
+
channel.sendall(data)
|
|
385
|
+
return True
|
|
386
|
+
except OSError:
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _send_exit_status(channel: paramiko.Channel, status: int) -> None:
|
|
391
|
+
try:
|
|
392
|
+
channel.send_exit_status(status)
|
|
393
|
+
except OSError:
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _console_encoding() -> str:
|
|
398
|
+
return sys.getdefaultencoding() or "utf-8"
|