sshcatch 0.2.0__tar.gz → 0.2.2__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.
@@ -4,6 +4,67 @@ All notable changes to **sshcatch** are documented here.
4
4
  This project follows [Keep a Changelog](https://keepachangelog.com/) and
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.2.2] - 2026-07-31
8
+
9
+ Multiple host keys now used as default.
10
+
11
+ ### Added
12
+
13
+ - **Three host keys instead of one.** ed25519, RSA-3072 and ecdsa-nistp256 are
14
+ generated on first run if missing and stored together in one `sshcatch_host_key`
15
+ file, so clients can pick their algorithm. Existing key files are read with
16
+ `read_private_key_list()` and may hold any number of keys.
17
+
18
+ ### Changed
19
+
20
+ - The startup summary prints the fingerprint of each key.
21
+ - **Startup failures go through a single handler.** `PermissionError` is no longer
22
+ special-cased: it always blamed the port, even when the host key file was the
23
+ real problem. The underlying error is shown instead - it already names the
24
+ address and port.
25
+
26
+ ### Fixed
27
+
28
+ - An empty, unreadable or passphrase-protected host key file now ends in a clean
29
+ error message instead of a traceback.
30
+ - `authorized_keys` lines that do not start with a key say why they were refused.
31
+ Options in front of the key (`from="..."`, `restrict`, ...) remain unsupported -
32
+ sshcatch cannot enforce them, so such keys are rejected rather than silently
33
+ accepted without their restrictions.
34
+
35
+
36
+ ## [0.2.1] - 2026-07-29
37
+
38
+ Forwarding hardening and help/logging polish.
39
+
40
+ ### Added
41
+
42
+ - **Non-TCP forwarding is now explicitly denied and logged.** UNIX-domain-socket
43
+ forwards (`unix_connection_requested` / `unix_server_requested`) and layer-2/3
44
+ TUN/TAP tunnels (`tun_requested` / `tap_requested`) are overridden to refuse
45
+ and log the attempt via a central `_deny_tunnel()` helper (mirroring the SFTP
46
+ `_deny_sftp`), instead of relying on asyncssh's silent default rejection. Only
47
+ plain TCP forwards remain available, and only when `--forward` / `--reverse` is
48
+ set. A new `log_tunnel()` helper adds a `TUNNEL` log category.
49
+ - **Two-tier `--help`.** `-h` prints a short usage summary (`_epilog_short`);
50
+ `--help` prints the full reference (`_epilog_full`).
51
+
52
+ ### Changed
53
+
54
+ - **Denial reasons are logged server-side only, never sent to the client**, so a
55
+ probing client can't learn the policy from the error text.
56
+ - **Denial log level follows the SFTP convention:** always-denied protocols
57
+ (UNIX / TUN / TAP) log at INFO (`-v`), while a TCP forward refused only because
58
+ `--forward` / `--reverse` is off logs at WARNING (default).
59
+ - Internal: the SFTP deny helper was renamed `_deny` → `_deny_sftp` to stay
60
+ consistent with the new `_deny_tunnel`.
61
+
62
+ ### Security
63
+
64
+ - Tunnel-denial is now enforced and made visible on every path rather than
65
+ silently dropped by asyncssh.
66
+
67
+
7
68
  ## [0.2.0] - 2026-07-19
8
69
 
9
70
  Large rewrite of the SFTP layer and the logging system.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sshcatch
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Quick-deploy SSH server for tunneling and simple SCP transfers - never opens a shell.
5
5
  Project-URL: Homepage, https://github.com/LorenzMap/sshcatch
6
6
  Project-URL: Repository, https://github.com/LorenzMap/sshcatch
@@ -39,6 +39,28 @@ Built on [asyncssh](https://github.com/ronf/asyncssh).
39
39
  This is a pentesting tool. Only point it at systems and networks you are authorized
40
40
  to test.
41
41
 
42
+ ## Why this tool exists
43
+
44
+ - During engagements and CTFs I love to use 'simple' tools on my host that just work
45
+ - http-server -> `python3 -m http.server`
46
+ - smb-server -> `impacket-smbserver`
47
+ - ssh-server -> ??? (now `sshcatch`)
48
+
49
+ - `sshd` can be used, but:
50
+ - configuring it through `sshd_configs` is a pain
51
+ - multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
52
+ - logins are controlled by the OS so a user must be created (and secured)
53
+ - I want to restrict the shell so my host is safe
54
+
55
+ - My solution: `sshcatch`
56
+ - Simply configure through clear flags and arguments on the commandline
57
+ - restrictive defaults, every feature must be enabled consciously
58
+ - Never allow shells (or commands)
59
+ - Forward/Reverse tunnels can be individually activated
60
+ - SCP/SFTP file uploads and downloads can be individually activated
61
+ - restrictive upload handling to prevent overwriting
62
+ - symlinks denied
63
+
42
64
  ## Install
43
65
 
44
66
  With `pipx` (recommended, installs into an isolated environment and puts
@@ -62,8 +84,9 @@ cd sshcatch
62
84
  pipx install . # or: pip install .
63
85
  ```
64
86
 
65
- Needs Python 3.10+. A host key is auto-generated in the working directory on
66
- first run (or point `--host-key` at your own).
87
+ Needs Python 3.10+. Three host keys (ed25519, RSA, ECDSA) are auto-generated
88
+ into a single file in the working directory on first run (or point `--host-key`
89
+ at your own file).
67
90
 
68
91
  ## How it works
69
92
 
@@ -82,6 +105,10 @@ Because no shell is ever created, tunnel clients **must** pass `-N` (e.g.
82
105
  Turning tunneling on with `--open-auth` means **anyone** who connects can pivot
83
106
  through your host!
84
107
 
108
+ Only plain **TCP** forwards are ever available. UNIX-domain-socket forwards
109
+ (`ssh -L /sock:...` / `-R /sock:...`) and TUN/TAP tunnels (`ssh -w`) are always
110
+ denied, even with `--forward` / `--reverse` set.
111
+
85
112
  #### SCP / SFTP
86
113
 
87
114
  **Symlinks** are handled very restrictively: On upload they create a placeholder file
@@ -93,7 +120,19 @@ and hidden when they live inside the SCP directory.
93
120
  Uploads **never overwrite** an existing file. The new file gets a numeric suffix
94
121
  (`loot.tar` -> `loot_1.tar`). Non-existent parent folders are created.
95
122
 
96
- Renames and Remove operations are denied.
123
+ Renames, deletes and directory removal are denied.
124
+
125
+ ## Word of Warning
126
+
127
+ Only using `--version-banner` obviously isn't enough deception because the KEXINIT
128
+ that is transferred cleartext on the wire is a clear tell. This differing HASSH
129
+ can be easily detected by a sufficiently sophisticated observer.
130
+
131
+ Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
132
+ and everything else a real honeypot needs are deliberately out of scope. There are other
133
+ projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
134
+ [cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
135
+
97
136
 
98
137
  ## Examples
99
138
 
@@ -148,22 +187,32 @@ scp -P 2222 loot.tar user@host:. # upload
148
187
 
149
188
  ## Options
150
189
 
190
+ `sshcatch -h` prints a short summary with just the flags you need to get going.
191
+ The full reference below is `sshcatch --help`:
192
+
151
193
  ```
152
- usage: sshcatch [-h] [--version] [-p PORT] [-b BIND] [--host-key FILE] [-1]
153
- [-u USER:PASS] [--open-auth] [--authorized-keys FILE]
154
- [--forward] [--reverse] [--scp-upload] [--scp-download]
155
- [--scp-dir DIR] [--version-banner STRING]
156
- [--pre-auth-banner STRING] [--post-auth-banner STRING]
157
- [-q | -v] [-o FILE] [-t] [--plain]
194
+ usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--host-key FILE]
195
+ [--version] [-u USER:PASS] [--open-auth]
196
+ [--authorized-keys FILE] [--forward] [--reverse]
197
+ [--scp-upload] [--scp-download] [--scp-dir DIR]
198
+ [--version-banner STRING] [--pre-auth-banner STRING]
199
+ [--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
200
+
201
+ sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
202
+ and simple SCP transfers (NEVER opens a shell!).
203
+ By default all features are disabled. Use flags to enable features.
158
204
 
159
205
  options:
160
- -h, --help show this help message and exit
161
- --version show program's version number and exit
206
+ -h show a short help message and exit
207
+ --help show the full help and exit
162
208
  -p PORT, --port PORT listen port (default: 22)
163
209
  -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
164
- --host-key FILE server host key file (default: auto-generate)
165
- -1, --single close the listener after first successful
166
- authentication (and exit when that connection ends)
210
+ -1, --single close the listener after first successful auth (and
211
+ exit when that connection ends)
212
+ --host-key FILE server host key file, may hold several keys - auto-
213
+ generated if missing - uses ./sshcatch_host_key by
214
+ default
215
+ --version show program's version number and exit
167
216
 
168
217
  authentication:
169
218
  -u USER:PASS, --user USER:PASS
@@ -179,19 +228,19 @@ tunneling:
179
228
 
180
229
  SCP / SFTP file transfer:
181
230
  --scp-upload enable file upload (SCP/SFTP write) - files get suffix
182
- instead of overwriting - symlinks become placeholder
183
- files
231
+ instead of overwriting
184
232
  --scp-download enable file download (SCP/SFTP read) - symlinks are
185
233
  denied
186
234
  --scp-dir DIR directory for SCP/SFTP (default: cwd) - sensitive
187
- files (host-key, authorized_keys, logfile) are
188
- protected
235
+ sshcatch files (host-key, authorized_keys, logfile)
236
+ are protected
189
237
 
190
238
  banners:
191
239
  --version-banner STRING
192
- sent as 'SSH-2.0-STRING' version banner - presets
193
- (case-insensitive): ubuntu, debian, dropbear, windows,
194
- macos
240
+ sent as 'SSH-2.0-STRING' version banner - only first-
241
+ glance deception, it can still be identified as
242
+ asyncssh - presets (case-insensitive): ubuntu, debian,
243
+ dropbear, windows, macos
195
244
  --pre-auth-banner STRING
196
245
  banner shown to every client before login
197
246
  --post-auth-banner STRING
@@ -206,6 +255,15 @@ logging:
206
255
  append the log to FILE (plain with timestamps)
207
256
  -t, --timestamps prefix console lines with a timestamp
208
257
  --plain disable ANSI colors on the console
258
+
259
+ examples: (also check README on Github)
260
+ sshcatch Log-only (capture creds)
261
+ sshcatch -u user:pass --scp-download Allow one user to download via SCP/SFTP
262
+ sshcatch --open-auth --forward Allow ANYONE! to tunnel through this SSH server
263
+ # My favorite one
264
+ # Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
265
+ # while posing shallowly as an Ubuntu SSH server on port 2222
266
+ sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
209
267
  ```
210
268
 
211
269
  ## License
@@ -13,6 +13,28 @@ Built on [asyncssh](https://github.com/ronf/asyncssh).
13
13
  This is a pentesting tool. Only point it at systems and networks you are authorized
14
14
  to test.
15
15
 
16
+ ## Why this tool exists
17
+
18
+ - During engagements and CTFs I love to use 'simple' tools on my host that just work
19
+ - http-server -> `python3 -m http.server`
20
+ - smb-server -> `impacket-smbserver`
21
+ - ssh-server -> ??? (now `sshcatch`)
22
+
23
+ - `sshd` can be used, but:
24
+ - configuring it through `sshd_configs` is a pain
25
+ - multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
26
+ - logins are controlled by the OS so a user must be created (and secured)
27
+ - I want to restrict the shell so my host is safe
28
+
29
+ - My solution: `sshcatch`
30
+ - Simply configure through clear flags and arguments on the commandline
31
+ - restrictive defaults, every feature must be enabled consciously
32
+ - Never allow shells (or commands)
33
+ - Forward/Reverse tunnels can be individually activated
34
+ - SCP/SFTP file uploads and downloads can be individually activated
35
+ - restrictive upload handling to prevent overwriting
36
+ - symlinks denied
37
+
16
38
  ## Install
17
39
 
18
40
  With `pipx` (recommended, installs into an isolated environment and puts
@@ -36,8 +58,9 @@ cd sshcatch
36
58
  pipx install . # or: pip install .
37
59
  ```
38
60
 
39
- Needs Python 3.10+. A host key is auto-generated in the working directory on
40
- first run (or point `--host-key` at your own).
61
+ Needs Python 3.10+. Three host keys (ed25519, RSA, ECDSA) are auto-generated
62
+ into a single file in the working directory on first run (or point `--host-key`
63
+ at your own file).
41
64
 
42
65
  ## How it works
43
66
 
@@ -56,6 +79,10 @@ Because no shell is ever created, tunnel clients **must** pass `-N` (e.g.
56
79
  Turning tunneling on with `--open-auth` means **anyone** who connects can pivot
57
80
  through your host!
58
81
 
82
+ Only plain **TCP** forwards are ever available. UNIX-domain-socket forwards
83
+ (`ssh -L /sock:...` / `-R /sock:...`) and TUN/TAP tunnels (`ssh -w`) are always
84
+ denied, even with `--forward` / `--reverse` set.
85
+
59
86
  #### SCP / SFTP
60
87
 
61
88
  **Symlinks** are handled very restrictively: On upload they create a placeholder file
@@ -67,7 +94,19 @@ and hidden when they live inside the SCP directory.
67
94
  Uploads **never overwrite** an existing file. The new file gets a numeric suffix
68
95
  (`loot.tar` -> `loot_1.tar`). Non-existent parent folders are created.
69
96
 
70
- Renames and Remove operations are denied.
97
+ Renames, deletes and directory removal are denied.
98
+
99
+ ## Word of Warning
100
+
101
+ Only using `--version-banner` obviously isn't enough deception because the KEXINIT
102
+ that is transferred cleartext on the wire is a clear tell. This differing HASSH
103
+ can be easily detected by a sufficiently sophisticated observer.
104
+
105
+ Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
106
+ and everything else a real honeypot needs are deliberately out of scope. There are other
107
+ projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
108
+ [cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
109
+
71
110
 
72
111
  ## Examples
73
112
 
@@ -122,22 +161,32 @@ scp -P 2222 loot.tar user@host:. # upload
122
161
 
123
162
  ## Options
124
163
 
164
+ `sshcatch -h` prints a short summary with just the flags you need to get going.
165
+ The full reference below is `sshcatch --help`:
166
+
125
167
  ```
126
- usage: sshcatch [-h] [--version] [-p PORT] [-b BIND] [--host-key FILE] [-1]
127
- [-u USER:PASS] [--open-auth] [--authorized-keys FILE]
128
- [--forward] [--reverse] [--scp-upload] [--scp-download]
129
- [--scp-dir DIR] [--version-banner STRING]
130
- [--pre-auth-banner STRING] [--post-auth-banner STRING]
131
- [-q | -v] [-o FILE] [-t] [--plain]
168
+ usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--host-key FILE]
169
+ [--version] [-u USER:PASS] [--open-auth]
170
+ [--authorized-keys FILE] [--forward] [--reverse]
171
+ [--scp-upload] [--scp-download] [--scp-dir DIR]
172
+ [--version-banner STRING] [--pre-auth-banner STRING]
173
+ [--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
174
+
175
+ sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
176
+ and simple SCP transfers (NEVER opens a shell!).
177
+ By default all features are disabled. Use flags to enable features.
132
178
 
133
179
  options:
134
- -h, --help show this help message and exit
135
- --version show program's version number and exit
180
+ -h show a short help message and exit
181
+ --help show the full help and exit
136
182
  -p PORT, --port PORT listen port (default: 22)
137
183
  -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
138
- --host-key FILE server host key file (default: auto-generate)
139
- -1, --single close the listener after first successful
140
- authentication (and exit when that connection ends)
184
+ -1, --single close the listener after first successful auth (and
185
+ exit when that connection ends)
186
+ --host-key FILE server host key file, may hold several keys - auto-
187
+ generated if missing - uses ./sshcatch_host_key by
188
+ default
189
+ --version show program's version number and exit
141
190
 
142
191
  authentication:
143
192
  -u USER:PASS, --user USER:PASS
@@ -153,19 +202,19 @@ tunneling:
153
202
 
154
203
  SCP / SFTP file transfer:
155
204
  --scp-upload enable file upload (SCP/SFTP write) - files get suffix
156
- instead of overwriting - symlinks become placeholder
157
- files
205
+ instead of overwriting
158
206
  --scp-download enable file download (SCP/SFTP read) - symlinks are
159
207
  denied
160
208
  --scp-dir DIR directory for SCP/SFTP (default: cwd) - sensitive
161
- files (host-key, authorized_keys, logfile) are
162
- protected
209
+ sshcatch files (host-key, authorized_keys, logfile)
210
+ are protected
163
211
 
164
212
  banners:
165
213
  --version-banner STRING
166
- sent as 'SSH-2.0-STRING' version banner - presets
167
- (case-insensitive): ubuntu, debian, dropbear, windows,
168
- macos
214
+ sent as 'SSH-2.0-STRING' version banner - only first-
215
+ glance deception, it can still be identified as
216
+ asyncssh - presets (case-insensitive): ubuntu, debian,
217
+ dropbear, windows, macos
169
218
  --pre-auth-banner STRING
170
219
  banner shown to every client before login
171
220
  --post-auth-banner STRING
@@ -180,6 +229,15 @@ logging:
180
229
  append the log to FILE (plain with timestamps)
181
230
  -t, --timestamps prefix console lines with a timestamp
182
231
  --plain disable ANSI colors on the console
232
+
233
+ examples: (also check README on Github)
234
+ sshcatch Log-only (capture creds)
235
+ sshcatch -u user:pass --scp-download Allow one user to download via SCP/SFTP
236
+ sshcatch --open-auth --forward Allow ANYONE! to tunnel through this SSH server
237
+ # My favorite one
238
+ # Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
239
+ # while posing shallowly as an Ubuntu SSH server on port 2222
240
+ sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
183
241
  ```
184
242
 
185
243
  ## License
@@ -9,6 +9,7 @@ import asyncio
9
9
  import logging
10
10
  import os
11
11
  import posixpath
12
+ import re
12
13
  import sys
13
14
  import time
14
15
  from pathlib import Path
@@ -16,7 +17,7 @@ from itertools import count
16
17
 
17
18
  import asyncssh
18
19
 
19
- __version__ = "0.2.0"
20
+ __version__ = "0.2.2"
20
21
 
21
22
  # ── Logging ───────────────────────────────────────────────────────────
22
23
 
@@ -69,8 +70,8 @@ def log_auth(msg, success=False, addr=None, user=None, level=logging.WARNING):
69
70
  def log_scp(msg, addr=None, user=None, level=logging.INFO):
70
71
  _log("SCP", PURPLE, msg, addr, user, level)
71
72
 
72
- def log_tunnel(msg, addr=None, user=None):
73
- _log("TUNNEL", CYAN, msg, addr, user, logging.WARNING)
73
+ def log_tunnel(msg, addr=None, user=None, level=logging.WARNING):
74
+ _log("TUNNEL", CYAN, msg, addr, user, level)
74
75
 
75
76
  def log_info(msg, addr=None, user=None, level=logging.DEBUG):
76
77
  _log("*", BOLD, msg, addr, user, level)
@@ -108,10 +109,10 @@ class SFTPCatchServer(asyncssh.SFTPServer):
108
109
  self._last_log = (msg, time.monotonic())
109
110
  log_scp(msg, addr=self._addr, user=self._user, level=level)
110
111
 
111
- def _deny(self, detail, reason="Not allowed"):
112
+ def _deny_sftp(self, detail, reason="Not allowed"):
112
113
  level = logging.INFO if reason == "Not allowed" else logging.WARNING
113
- self._log_scp(f"DENIED {detail}", level)
114
- raise asyncssh.SFTPPermissionDenied(reason)
114
+ self._log_scp(f"DENIED {detail} ({reason})", level)
115
+ raise asyncssh.SFTPPermissionDenied("Permission denied")
115
116
 
116
117
  def _execute_wrapped_log(self, path, fn, *args):
117
118
  try: return fn(*args)
@@ -122,7 +123,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
122
123
  self._log_scp(f"ERROR {self._local_path(path)} ({e.strerror or e})", logging.WARNING)
123
124
  raise
124
125
 
125
- # ── path checks ──────────────────────────────────────────────
126
+ # ── path checks ───────────────────────────────────────────────
126
127
 
127
128
  def map_path(self, path):
128
129
  # Overwrite to be more secure than default implementation
@@ -133,7 +134,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
133
134
  # Prevent access to protected files in the scp dir
134
135
  rel = posixpath.normpath(posixpath.join(b"/", path)).lstrip(b"/")
135
136
  if os.fsdecode(rel) in self._protected_files:
136
- self._deny(f"PROTECTED {self._local_path(path)}")
137
+ self._deny_sftp(f"PROTECTED {self._local_path(path)}")
137
138
 
138
139
  def _require_not_symlink(self, path):
139
140
  local = Path(os.fsdecode(self.map_path(path)))
@@ -141,7 +142,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
141
142
  # check if chroot root reached
142
143
  if component == Path(self._chroot_local): break
143
144
  if component.is_symlink():
144
- self._deny(f"SYMLINK {self._local_path(path)}", "Symlinks are not allowed")
145
+ self._deny_sftp(f"SYMLINK {self._local_path(path)}", "Symlinks are not allowed")
145
146
 
146
147
  def _unique_write_path(self, path):
147
148
  # Check if the upload overwrites something - keep subdirectories intact
@@ -183,9 +184,9 @@ class SFTPCatchServer(asyncssh.SFTPServer):
183
184
  is_write = bool(pflags & (0x02 | 0x04 | 0x08)) # WRITE|APPEND|CREAT
184
185
 
185
186
  if is_write and not self._allow_upload:
186
- self._deny(f"WRITE {self._local_path(path)}", "Upload is disabled")
187
+ self._deny_sftp(f"WRITE {self._local_path(path)}", "Upload is disabled")
187
188
  if not is_write and not self._allow_download:
188
- self._deny(f"READ {self._local_path(path)}", "Download is disabled")
189
+ self._deny_sftp(f"READ {self._local_path(path)}", "Download is disabled")
189
190
 
190
191
  # Prevent overwriting on upload
191
192
  if is_write:
@@ -233,7 +234,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
233
234
  def mkdir(self, path, attrs):
234
235
  # Allow (upload only): needed to create directories during recursive uploads
235
236
  if not self._allow_upload:
236
- self._deny(f"MKDIR {self._local_path(path)}", "Upload is disabled")
237
+ self._deny_sftp(f"MKDIR {self._local_path(path)}", "Upload is disabled")
237
238
  self._require_not_protected(path)
238
239
  self._require_not_symlink(path)
239
240
  self._ensure_parent(path)
@@ -244,7 +245,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
244
245
  def symlink(self, old, new):
245
246
  # Allow (upload only): write a placeholder recording the target
246
247
  if not self._allow_upload:
247
- self._deny(f"SYMLINK {self._local_path(new)}", "Upload is disabled")
248
+ self._deny_sftp(f"SYMLINK {self._local_path(new)}", "Upload is disabled")
248
249
  self._require_not_protected(new)
249
250
  self._require_not_symlink(new)
250
251
  new = self._unique_write_path(new)
@@ -257,7 +258,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
257
258
  def setstat(self, path, attrs):
258
259
  # Allow (upload only): perms/timestamps on uploaded files
259
260
  if not self._allow_upload:
260
- self._deny(f"SETSTAT {self._local_path(path)}", "Upload is disabled")
261
+ self._deny_sftp(f"SETSTAT {self._local_path(path)}", "Upload is disabled")
261
262
  self._require_not_protected(path)
262
263
  self._require_not_symlink(path)
263
264
  result = self._execute_wrapped_log(path, super().setstat, path, attrs)
@@ -267,14 +268,14 @@ class SFTPCatchServer(asyncssh.SFTPServer):
267
268
  def fsetstat(self, file_obj, attrs):
268
269
  # Allow (upload only): same as setstat on an already-open handle
269
270
  if not self._allow_upload:
270
- self._deny("FSETSTAT", "Upload is disabled")
271
+ self._deny_sftp("FSETSTAT", "Upload is disabled")
271
272
  self._log_scp("FSETSTAT", logging.DEBUG)
272
273
  return super().fsetstat(file_obj, attrs)
273
274
 
274
275
  def lsetstat(self, path, attrs):
275
276
  # Noop (upload only): set link's timestamps but we use placeholders (noop so uploads don't abort)
276
277
  if not self._allow_upload:
277
- self._deny(f"LSETSTAT {self._local_path(path)}", "Upload is disabled")
278
+ self._deny_sftp(f"LSETSTAT {self._local_path(path)}", "Upload is disabled")
278
279
  self._log_scp(f"LSETSTAT {self._local_path(path)} (ignored)", logging.DEBUG)
279
280
 
280
281
  # write - file upload after open
@@ -284,7 +285,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
284
285
  async def scandir(self, path):
285
286
  # Allow (download only): directory listing for recursive downloads
286
287
  if not self._allow_download:
287
- self._deny(f"LISTDIR {self._local_path(path)}", "Download is disabled")
288
+ self._deny_sftp(f"LISTDIR {self._local_path(path)}", "Download is disabled")
288
289
  self._require_not_symlink(path)
289
290
  self._log_scp(f"LISTDIR {self._local_path_log(path)}", logging.DEBUG)
290
291
  # hide protected files and symlinks
@@ -305,51 +306,51 @@ class SFTPCatchServer(asyncssh.SFTPServer):
305
306
 
306
307
  def remove(self, path):
307
308
  # Deny: no deleting files
308
- self._deny(f"DELETE {self._local_path(path)}")
309
+ self._deny_sftp(f"DELETE {self._local_path(path)}")
309
310
 
310
311
  def rename(self, old, new):
311
312
  # Deny: no moving or renaming
312
- self._deny(f"RENAME {self._local_path(old)}")
313
+ self._deny_sftp(f"RENAME {self._local_path(old)}")
313
314
 
314
315
  def rmdir(self, path):
315
316
  # Deny: no removing directories
316
- self._deny(f"RMDIR {self._local_path(path)}")
317
+ self._deny_sftp(f"RMDIR {self._local_path(path)}")
317
318
 
318
319
  def link(self, old, new):
319
320
  # Deny: no hard links
320
- self._deny(f"LINK {self._local_path(old)}")
321
+ self._deny_sftp(f"LINK {self._local_path(old)}")
321
322
 
322
323
  def open56(self, path, desired_access, flags, attrs):
323
324
  # Deny: SFTPv4+ open (we use sftp_version=3 anyway)
324
- self._deny(f"OPEN56 {self._local_path(path)}")
325
+ self._deny_sftp(f"OPEN56 {self._local_path(path)}")
325
326
 
326
327
  def readlink(self, path):
327
328
  # Deny: we deliberately dont use symlinks
328
- self._deny(f"READLINK {self._local_path(path)}")
329
+ self._deny_sftp(f"READLINK {self._local_path(path)}")
329
330
 
330
331
  def posix_rename(self, oldpath, newpath):
331
332
  # Deny: no moving or renaming
332
- self._deny(f"POSIX_RENAME {self._local_path(oldpath)}")
333
+ self._deny_sftp(f"POSIX_RENAME {self._local_path(oldpath)}")
333
334
 
334
335
  def statvfs(self, path):
335
336
  # Deny: leaks host filesystem stats (size/free space), not needed for transfers
336
- self._deny(f"STATVFS {self._local_path(path)}")
337
+ self._deny_sftp(f"STATVFS {self._local_path(path)}")
337
338
 
338
339
  def fstatvfs(self, file_obj):
339
340
  # Deny: same as statvfs but on file handle
340
- self._deny("FSTATVFS")
341
+ self._deny_sftp("FSTATVFS")
341
342
 
342
343
  def fsync(self, file_obj):
343
344
  # Deny: flush-to-disk on a handle; uploads complete fine without it.
344
- self._deny("FSYNC")
345
+ self._deny_sftp("FSYNC")
345
346
 
346
347
  def lock(self, file_obj, offset, length, flags):
347
348
  # Deny: byte-range locks serve no purpose here and only add surface.
348
- self._deny("LOCK")
349
+ self._deny_sftp("LOCK")
349
350
 
350
351
  def unlock(self, file_obj, offset, length):
351
352
  # Deny: see lock().
352
- self._deny("UNLOCK")
353
+ self._deny_sftp("UNLOCK")
353
354
 
354
355
 
355
356
  # ── SSH server factory ────────────────────────────────────────────────
@@ -366,10 +367,11 @@ def make_server_factory(args, single_future=None):
366
367
  if args.authorized_keys:
367
368
  for i, line in enumerate(args.authorized_keys.read_text().splitlines(), 1):
368
369
  line = line.strip()
369
- if not line or line.startswith('#'):
370
- continue
370
+ if not line or line.startswith('#'): continue
371
+ key_only = re.search(r"^\S+ AAAA\S+", line)
371
372
  try:
372
- k = asyncssh.import_public_key(line)
373
+ if not key_only: raise ValueError("Must start with 'keytype base64' (no options)")
374
+ k = asyncssh.import_public_key(key_only.group())
373
375
  fp = k.get_fingerprint()
374
376
  auth_keys_fps.add(fp)
375
377
  log_info(f"Loaded key line={i} fingerprint={fp}")
@@ -423,7 +425,7 @@ def make_server_factory(args, single_future=None):
423
425
  if exc: log_conn(f"Connection lost: {exc}", addr=self._addr, user=user, level=level)
424
426
  else: log_conn("Connection closed", addr=self._addr, user=user, level=level)
425
427
 
426
- # -- banner ---------------------------------------------------
428
+ # ── banner ───────────────────────────────────────────────────
427
429
 
428
430
  def _send_banner(self, text):
429
431
  # Send auth messages so we never have to open a session
@@ -446,7 +448,7 @@ def make_server_factory(args, single_future=None):
446
448
  except Exception: pass
447
449
  return await self._orig_send_success(*args, **kwargs)
448
450
 
449
- # -- authentication -------------------------------------------
451
+ # ── authentication ───────────────────────────────────────────
450
452
 
451
453
  def public_key_auth_supported(self):
452
454
  # always accept offers so we can log them
@@ -485,23 +487,30 @@ def make_server_factory(args, single_future=None):
485
487
  if log_only:
486
488
  asyncio.get_running_loop().call_later(0.5, self._conn.close)
487
489
 
488
- # -- tunneling ------------------------------------------------
490
+ # ── tunneling ────────────────────────────────────────────────
489
491
 
490
- def connection_requested(self, dest_host, dest_port, orig_host, orig_port):
492
+ def _deny_tunnel(self, detail, reason="Not allowed"):
493
+ level = logging.INFO if reason == "Not allowed" else logging.WARNING
491
494
  user = self._conn.get_extra_info("username")
495
+ log_tunnel(f"DENIED {detail} ({reason})", addr=self._addr, user=user, level=level)
496
+ return False
497
+
498
+ # ── ALLOWED (gated) ──────────────────────────────────────────
499
+
500
+ def connection_requested(self, dest_host, dest_port, orig_host, orig_port):
501
+ # Allow (--forward): direct TCP forward (client: ssh -NL / -ND)
502
+ route = f"{addr_str(orig_host, orig_port)} -> {addr_str(dest_host, dest_port)}"
492
503
  if not args.forward:
493
- log_tunnel(f"DENIED forward {addr_str(orig_host, orig_port)} -> "
494
- f"{addr_str(dest_host, dest_port)}", addr=self._addr, user=user)
495
- return False
496
- log_tunnel(f"Forward {addr_str(orig_host, orig_port)} -> "
497
- f"{addr_str(dest_host, dest_port)}", addr=self._addr, user=user)
504
+ return self._deny_tunnel(f"forward {route}", "Forwarding is disabled")
505
+ user = self._conn.get_extra_info("username")
506
+ log_tunnel(f"Forward {route}", addr=self._addr, user=user)
498
507
  return True
499
508
 
500
509
  def server_requested(self, listen_host, listen_port):
501
- user = self._conn.get_extra_info("username")
510
+ # Allow (--reverse): remote TCP listen (client: ssh -NR)
502
511
  if not args.reverse:
503
- log_tunnel(f"DENIED reverse {addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
504
- return False
512
+ return self._deny_tunnel(f"reverse {addr_str(listen_host, listen_port)}", "Reverse is disabled")
513
+ user = self._conn.get_extra_info("username")
505
514
  log_tunnel(f"Reverse listen on {addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
506
515
 
507
516
  def accept(orig_host, orig_port):
@@ -512,29 +521,55 @@ def make_server_factory(args, single_future=None):
512
521
  return True
513
522
  return accept
514
523
 
524
+ # ── DENIED forwarding/tunneling ──────────────────────────────
525
+ # asyncssh rejects these by default, override to deny anyway and log them
526
+
527
+ def unix_connection_requested(self, dest_path):
528
+ # Deny: direct UNIX-domain-socket forward (client: ssh -L /sock:...)
529
+ return self._deny_tunnel(f"unix-forward {dest_path}")
530
+
531
+ def unix_server_requested(self, listen_path):
532
+ # Deny: remote UNIX-domain-socket listen (client: ssh -R /sock:...)
533
+ return self._deny_tunnel(f"unix-reverse {listen_path}")
534
+
535
+ def tun_requested(self, unit):
536
+ # Deny: layer-3 TUN tunnel (client: ssh -w)
537
+ return self._deny_tunnel(f"tun unit={unit}")
538
+
539
+ def tap_requested(self, unit):
540
+ # Deny: layer-2 TAP tunnel
541
+ return self._deny_tunnel(f"tap unit={unit}")
542
+
515
543
  return SSHCatchServer, len(users), len(auth_keys_fps)
516
544
 
517
545
 
518
546
  # ── Server start ──────────────────────────────────────────────────────
519
547
 
548
+ HOST_KEY_ALGS = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256")
549
+ HOST_KEY_OPTS = {"ssh-rsa": {"key_size": 3072}}
550
+
551
+
520
552
  async def start_server(args):
521
- # Handle Host key
553
+ # Handle Host keys
522
554
  key_path = args.host_key if args.host_key else Path.cwd()
523
- if key_path.is_dir():
555
+ if key_path.is_dir():
524
556
  key_path = key_path / "sshcatch_host_key"
525
557
  if not key_path.parent.is_dir():
526
558
  raise FileNotFoundError(f"Host key directory does not exist: {key_path.parent}")
527
559
 
528
560
  if key_path.is_file():
529
- host_key = asyncssh.read_private_key(str(key_path))
530
- log_info(f"Read host key: {key_path}")
561
+ try: host_keys = asyncssh.read_private_key_list(str(key_path))
562
+ except ValueError as e: raise ValueError(f"Could not read host key file {key_path}: {e}") from None
563
+ if not host_keys: raise ValueError(f"No usable host key in {key_path}")
564
+ log_info(f"Read host key: {key_path} ({len(host_keys)} key{'s'*(len(host_keys)!=1)})")
531
565
  else:
532
- host_key = asyncssh.generate_private_key("ssh-ed25519")
533
- host_key.write_private_key(str(key_path))
534
- log_info(f"Generated host key: {key_path}")
566
+ host_keys = [asyncssh.generate_private_key(a, **HOST_KEY_OPTS.get(a, {}))
567
+ for a in HOST_KEY_ALGS]
568
+ key_path.write_bytes(b"".join(k.export_private_key() for k in host_keys))
569
+ log_info(f"Generated host keys file: {key_path}")
535
570
  if os.name == "posix": key_path.chmod(0o600)
536
571
  else: log_info("Please make sure the permissions on the host key are securely set!", level=logging.WARNING)
537
- fingerprint = host_key.get_fingerprint()
572
+ fingerprints = [(k.get_algorithm(), k.get_fingerprint()) for k in host_keys]
538
573
 
539
574
  # for single-connection mode - resolving releases the bind on the listen port
540
575
  single_future = asyncio.get_running_loop().create_future() if args.single else None
@@ -543,7 +578,7 @@ async def start_server(args):
543
578
  server_factory, n_users, n_keys = make_server_factory(args, single_future)
544
579
  opts = {
545
580
  "server_factory": server_factory,
546
- "server_host_keys": [host_key],
581
+ "server_host_keys": host_keys,
547
582
  # SFTPv3 only so all transfers use open() and not open56()
548
583
  "sftp_version": 3,
549
584
  }
@@ -574,8 +609,8 @@ async def start_server(args):
574
609
  user = conn.get_extra_info("username") or "?"
575
610
  peer = conn.get_extra_info("peername")
576
611
  addr = addr_str(peer[0], peer[1]) if peer else "?"
577
- log_scp("DENIED SFTP", addr=addr, user=user, level=logging.WARNING)
578
- raise asyncssh.SFTPPermissionDenied("SCP/SFTP is disabled")
612
+ log_scp("DENIED SFTP (SCP/SFTP is disabled)", addr=addr, user=user, level=logging.WARNING)
613
+ raise asyncssh.SFTPPermissionDenied("Permission denied")
579
614
  opts["sftp_factory"] = denied_sftp
580
615
  # We allow logins however so we can log connections and credentials
581
616
  opts["allow_scp"] = True
@@ -610,8 +645,8 @@ async def start_server(args):
610
645
  summary.append(f"Version ....... SSH-2.0-{options.version.decode()}")
611
646
  if args.pre_auth_banner: summary.append(f"Pre-auth ...... {banner_preview(args.pre_auth_banner)}")
612
647
  if args.post_auth_banner: summary.append(f"Post-auth ..... {banner_preview(args.post_auth_banner)}")
613
- summary.append(f"Host key ...... {fingerprint}")
614
648
  summary.append(f"Key file ...... {key_path}")
649
+ for algo, fp in fingerprints: summary.append(f"Host key ...... {fp} ({algo})")
615
650
  log_info("sshcatch\n" + "\n".join(f" {line}" for line in summary), level=logging.WARNING)
616
651
 
617
652
  acceptor = await asyncssh.listen(host=args.bind, port=args.port, options=options)
@@ -642,37 +677,53 @@ VERSION_PRESETS = {
642
677
  _description="""\
643
678
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
644
679
  and simple SCP transfers (NEVER opens a shell!).
645
- By default all features are disabled: connections are logged and
646
- closed. Use flags to enable features.
680
+ By default all features are disabled. Use flags to enable features.
681
+ """
682
+
683
+ _epilog_short="""\
684
+ Run '%(prog)s --help' for the full help!
647
685
  """
648
686
 
649
- _epilog="""\
650
- examples:
651
- %(prog)s Log-only (capture creds)
652
- %(prog)s -u user:pass --scp-download Allow one user to download via SCP/SFTP
653
- %(prog)s --open-auth --forward Allow ANYONE! to tunnel through this SSH server
687
+ _epilog_full="""\
688
+ examples: (also check README on Github)
689
+ %(prog)s Log-only (capture creds)
690
+ %(prog)s -u user:pass --scp-download Allow one user to download via SCP/SFTP
691
+ %(prog)s --open-auth --forward Allow ANYONE! to tunnel through this SSH server
654
692
  # My favorite one
655
693
  # Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
656
- # while posing as an Ubuntu SSH server on port 2222
694
+ # while posing shallowly as an Ubuntu SSH server on port 2222
657
695
  %(prog)s --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
658
696
  """
659
697
 
660
- def main():
698
+ def build_parser(full=False):
699
+ # Help got too long so splitting it into '-h' and '--help'
700
+ def help_text(short_help=None, long_help=""):
701
+ if full: return (f"{short_help} " if short_help else "") + long_help
702
+ else: return short_help if short_help else argparse.SUPPRESS
703
+
661
704
  parser = argparse.ArgumentParser(
705
+ add_help=False,
662
706
  description=_description,
663
707
  formatter_class=argparse.RawDescriptionHelpFormatter,
664
- epilog=_epilog)
665
- parser.add_argument("--version", action="version",
666
- version=f"%(prog)s {__version__}")
708
+ epilog=_epilog_full if full else _epilog_short)
709
+
710
+ parser.add_argument("-h", action="help",
711
+ help="show a short help message and exit")
712
+ parser.add_argument("--help", action="help",
713
+ help="show the full help and exit")
667
714
  parser.add_argument("-p", "--port", type=int, default=22,
668
715
  help="listen port (default: 22)")
669
716
  parser.add_argument("-b", "--bind", default="",
670
717
  help="bind address (default: all IPv4/v6 interfaces)")
671
- parser.add_argument("--host-key", metavar="FILE", type=Path,
672
- help="server host key file (default: auto-generate)")
673
718
  parser.add_argument("-1", "--single", action="store_true",
674
- help="close the listener after first successful authentication "
675
- "(and exit when that connection ends)")
719
+ help=help_text(short_help="close the listener after first successful auth",
720
+ long_help="(and exit when that connection ends)"))
721
+ parser.add_argument("--host-key", metavar="FILE", type=Path,
722
+ help=help_text(long_help="server host key file, may hold several keys "
723
+ "- auto-generated if missing - uses ./sshcatch_host_key by default"))
724
+ parser.add_argument("--version", action="version",
725
+ version=f"%(prog)s {__version__}",
726
+ help=help_text(long_help="show program's version number and exit"))
676
727
 
677
728
  auth = parser.add_argument_group("authentication")
678
729
  auth.add_argument("-u", "--user", action="append", metavar="USER:PASS",
@@ -690,38 +741,42 @@ def main():
690
741
 
691
742
  scp = parser.add_argument_group("SCP / SFTP file transfer")
692
743
  scp.add_argument("--scp-upload", action="store_true",
693
- help="enable file upload (SCP/SFTP write) - "
694
- "files get suffix instead of overwriting - "
695
- "symlinks become placeholder files")
744
+ help=help_text(short_help="enable file upload (SCP/SFTP write)",
745
+ long_help="- files get suffix instead of overwriting"))
696
746
  scp.add_argument("--scp-download", action="store_true",
697
- help="enable file download (SCP/SFTP read) - "
698
- "symlinks are denied")
747
+ help=help_text(short_help="enable file download (SCP/SFTP read)",
748
+ long_help="- symlinks are denied"))
699
749
  scp.add_argument("--scp-dir", default=Path.cwd(), metavar="DIR", type=Path,
700
- help="directory for SCP/SFTP (default: cwd) - "
701
- "sensitive files (host-key, authorized_keys, logfile) are protected")
750
+ help=help_text(short_help="directory for SCP/SFTP (default: cwd)",
751
+ long_help="- sensitive sshcatch files (host-key, authorized_keys, logfile) are protected"))
702
752
 
703
753
  banners = parser.add_argument_group("banners")
704
754
  banners.add_argument("--version-banner", metavar="STRING",
705
- help="sent as 'SSH-2.0-STRING' version banner - "
706
- f"presets (case-insensitive): {', '.join(VERSION_PRESETS)}")
755
+ help=help_text(long_help="sent as 'SSH-2.0-STRING' version banner - "
756
+ "only first-glance deception, it can still be identified as asyncssh - "
757
+ f"presets (case-insensitive): {', '.join(VERSION_PRESETS)}"))
707
758
  banners.add_argument("--pre-auth-banner", metavar="STRING",
708
- help="banner shown to every client before login")
759
+ help=help_text(long_help="banner shown to every client before login"))
709
760
  banners.add_argument("--post-auth-banner", metavar="STRING",
710
- help="banner shown only to clients that authenticate successfully")
761
+ help=help_text(long_help="banner shown only to clients that authenticate successfully"))
711
762
 
712
763
  logs = parser.add_argument_group("logging")
713
764
  verbosity = logs.add_mutually_exclusive_group()
714
765
  verbosity.add_argument("-q", "--quiet", action="store_true",
715
- help="print nothing on console")
766
+ help=help_text(long_help="print nothing on console"))
716
767
  verbosity.add_argument("-v", "--verbose", action="count", default=0,
717
- help="print additional information to the console (repeatable)")
768
+ help=help_text(long_help="print additional information to the console (repeatable)"))
718
769
  logs.add_argument("-o", "--output", metavar="FILE", type=Path,
719
- help="append the log to FILE (plain with timestamps)")
770
+ help=help_text(long_help="append the log to FILE (plain with timestamps)"))
720
771
  logs.add_argument("-t", "--timestamps", action="store_true",
721
- help="prefix console lines with a timestamp")
772
+ help=help_text(long_help="prefix console lines with a timestamp"))
722
773
  logs.add_argument("--plain", action="store_true",
723
- help="disable ANSI colors on the console")
774
+ help=help_text(long_help="disable ANSI colors on the console"))
775
+ return parser
776
+
724
777
 
778
+ def main():
779
+ parser = build_parser(full="--help" in sys.argv[1:])
725
780
  args = parser.parse_args()
726
781
 
727
782
  # Validate the log output path
@@ -758,9 +813,7 @@ def main():
758
813
  parser.error(f"Authorized-keys file not found: {args.authorized_keys}")
759
814
 
760
815
  try: asyncio.run(start_server(args))
761
- except PermissionError:
762
- parser.error(f"Permission denied - port {args.port} requires root")
763
- except OSError as e:
816
+ except (OSError, ValueError) as e:
764
817
  parser.error(f"Could not start server: {e}")
765
818
  except KeyboardInterrupt:
766
819
  print()
File without changes
File without changes
File without changes