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.
- {sshcatch-0.2.0 → sshcatch-0.2.2}/CHANGELOG.md +61 -0
- {sshcatch-0.2.0 → sshcatch-0.2.2}/PKG-INFO +80 -22
- {sshcatch-0.2.0 → sshcatch-0.2.2}/README.md +79 -21
- {sshcatch-0.2.0 → sshcatch-0.2.2}/sshcatch.py +144 -91
- {sshcatch-0.2.0 → sshcatch-0.2.2}/.gitignore +0 -0
- {sshcatch-0.2.0 → sshcatch-0.2.2}/LICENSE +0 -0
- {sshcatch-0.2.0 → sshcatch-0.2.2}/pyproject.toml +0 -0
|
@@ -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.
|
|
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+.
|
|
66
|
-
first run (or point `--host-key`
|
|
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
|
|
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] [--
|
|
153
|
-
[-u USER:PASS] [--open-auth]
|
|
154
|
-
[--
|
|
155
|
-
[--scp-
|
|
156
|
-
[--
|
|
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
|
|
161
|
-
--
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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)
|
|
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 -
|
|
193
|
-
|
|
194
|
-
|
|
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+.
|
|
40
|
-
first run (or point `--host-key`
|
|
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
|
|
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] [--
|
|
127
|
-
[-u USER:PASS] [--open-auth]
|
|
128
|
-
[--
|
|
129
|
-
[--scp-
|
|
130
|
-
[--
|
|
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
|
|
135
|
-
--
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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)
|
|
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 -
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
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,
|
|
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
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
349
|
+
self._deny_sftp("LOCK")
|
|
349
350
|
|
|
350
351
|
def unlock(self, file_obj, offset, length):
|
|
351
352
|
# Deny: see lock().
|
|
352
|
-
self.
|
|
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
|
-
|
|
370
|
+
if not line or line.startswith('#'): continue
|
|
371
|
+
key_only = re.search(r"^\S+ AAAA\S+", line)
|
|
371
372
|
try:
|
|
372
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
490
|
+
# ── tunneling ────────────────────────────────────────────────
|
|
489
491
|
|
|
490
|
-
def
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
510
|
+
# Allow (--reverse): remote TCP listen (client: ssh -NR)
|
|
502
511
|
if not args.reverse:
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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":
|
|
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("
|
|
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
|
|
646
|
-
|
|
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
|
-
|
|
650
|
-
examples:
|
|
651
|
-
%(prog)s
|
|
652
|
-
%(prog)s -u user:pass --scp-download
|
|
653
|
-
%(prog)s --open-auth --forward
|
|
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
|
|
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=
|
|
665
|
-
|
|
666
|
-
|
|
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
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|