sshcatch 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,224 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ # sshcatch runtime files - never commit these
221
+ # generated host keys (default and custom --host-key names)
222
+ *_host_key
223
+ # log files from -o/--output
224
+ *.log
sshcatch-0.1.0/LICENSE ADDED
@@ -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.
@@ -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,147 @@
1
+ # sshcatch
2
+
3
+ A quick-deploy SSH server for tunneling (local/remote/dynamic) and simple SCP
4
+ transfers - it NEVER opens a shell.
5
+
6
+ By default all features are disabled: connections are logged and closed.
7
+ Turn on what you need with the flags described below. Handy on an engagement
8
+ when you want a controlled SSH endpoint without using a full `sshd`.
9
+
10
+ Built on [asyncssh](https://github.com/ronf/asyncssh).
11
+
12
+ ## Install
13
+
14
+ ```
15
+ pip install sshcatch
16
+ ```
17
+
18
+ Or from source:
19
+
20
+ ```
21
+ git clone https://github.com/LorenzMap/sshcatch
22
+ cd sshcatch
23
+ pip install .
24
+ ```
25
+
26
+ Needs Python 3.10+. A host key is auto-generated in the working directory on
27
+ first run (or point `--host-key` at your own).
28
+
29
+ ## Usage
30
+
31
+ Because no shell is created on the server **always** use the `-N` flag on
32
+ your tunnel connections or you get disconnected instantly!
33
+
34
+ The SCP directory does intentionally **NOT** support subdirectories!
35
+
36
+ Log-only - just capture creds and full public keys:
37
+
38
+ ```
39
+ # Server
40
+ sshcatch -K
41
+
42
+ # Client
43
+ ssh user@host
44
+ ```
45
+
46
+ Let one user pull files via SCP/SFTP:
47
+
48
+ ```
49
+ # Server
50
+ sshcatch -u user:pass --scp-download
51
+
52
+ # Client
53
+ scp user@host:secret.txt .
54
+ ```
55
+
56
+ Let anyone tunnel through the server (local and dynamic forwards):
57
+
58
+ ```
59
+ # Server
60
+ sshcatch --open-auth --forward
61
+
62
+ # Client
63
+ ssh -NL 8080:internal:80 user@host # local forward
64
+ ssh -ND 1080 user@host # dynamic (SOCKS)
65
+ ```
66
+
67
+ My favorite one - reverse tunnel and SCP uploads for the keys in
68
+ `./authorized-keys`, while posing as a ubuntu SSH server on port 2222:
69
+
70
+ ```
71
+ # Server
72
+ sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
73
+
74
+ # Client
75
+ ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
76
+ scp -P 2222 loot.tar user@host:. # upload
77
+ ```
78
+
79
+ ## Options
80
+
81
+ ```
82
+ usage: sshcatch [-h] [--version] [-p PORT] [-b BIND] [--host-key FILE]
83
+ [-u USER:PASS] [--open-auth] [--authorized-keys FILE] [-K]
84
+ [--forward] [--reverse] [--scp-upload] [--scp-download]
85
+ [--scp-dir DIR] [--version-banner STRING]
86
+ [--pre-auth-banner STRING] [--post-auth-banner STRING]
87
+ [-o FILE] [-t] [--plain]
88
+
89
+ options:
90
+ -h, --help show this help message and exit
91
+ --version show program's version number and exit
92
+ -p PORT, --port PORT listen port (default: 22)
93
+ -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
94
+ --host-key FILE server host key file (default: auto-generate)
95
+
96
+ authentication:
97
+ -u USER:PASS, --user USER:PASS
98
+ allowed user:password (repeatable)
99
+ --open-auth accept any credentials (open mode)
100
+ --authorized-keys FILE
101
+ authorized_keys file for key auth (username
102
+ independent)
103
+ -K, --full-keys log the full offered public key, not just its
104
+ fingerprint
105
+
106
+ tunneling:
107
+ --forward enable forward tunnels (client: ssh -NL / -ND)
108
+ --reverse enable reverse tunnels (client: ssh -NR)
109
+
110
+ SCP / file transfer:
111
+ --scp-upload enable file upload (SCP/SFTP write) - subdirectories
112
+ are disabled - files get suffix instead of overwriting
113
+ --scp-download enable file download (SCP/SFTP read) - subdirectories
114
+ are disabled
115
+ --scp-dir DIR directory for SCP/SFTP (default: cwd) - subdirectories
116
+ are disabled - host-key (and optional authorized_keys
117
+ and logfile) are protected
118
+
119
+ banners:
120
+ --version-banner STRING
121
+ sent as 'SSH-2.0-STRING' version banner - presets
122
+ (case-insensitive): ubuntu, debian, dropbear, windows,
123
+ macos
124
+ --pre-auth-banner STRING
125
+ banner shown to every client before login
126
+ --post-auth-banner STRING
127
+ banner shown only to clients that authenticate
128
+ successfully
129
+
130
+ logging:
131
+ -o FILE, --output FILE
132
+ append the log to FILE (plain with timestamps)
133
+ -t, --timestamps prefix console lines with a timestamp
134
+ --plain disable ANSI colors on the console
135
+ ```
136
+
137
+ ## A word of warning
138
+
139
+ - This is a pentesting tool. Only point it at systems and networks you are
140
+ authorized to test.
141
+
142
+ - `--open-auth --forward` means ANYONE can tunnel through
143
+ your host - know what you're exposing before you run it.
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sshcatch"
7
+ dynamic = ["version"]
8
+ description = "Quick-deploy SSH server for tunneling and simple SCP transfers - never opens a shell."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "LorenzMap" }]
14
+ keywords = ["ssh", "pentest", "tunneling", "scp", "security"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Information Technology",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Security",
26
+ "Topic :: System :: Networking",
27
+ ]
28
+ dependencies = ["asyncssh>=2.15"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/LorenzMap/sshcatch"
32
+ Repository = "https://github.com/LorenzMap/sshcatch"
33
+ Issues = "https://github.com/LorenzMap/sshcatch/issues"
34
+
35
+ [project.scripts]
36
+ sshcatch = "sshcatch:main"
37
+
38
+ [tool.hatch.version]
39
+ path = "sshcatch.py"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ only-include = ["sshcatch.py"]
@@ -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()