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.
@@ -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,6 @@
1
+ """A compact Paramiko-based SSH daemon."""
2
+
3
+ from .server import SSHDConfig, SimpleSSHD, serve_forever
4
+
5
+ __all__ = ["SSHDConfig", "SimpleSSHD", "serve_forever"]
6
+ __version__ = "0.1.1"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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"