Issue setting up AsyncSSH SFTP server ... #756
-
I'm wondering, I have the following setup for serving my SFTP server: # **************************************************************************************
import asyncio
import sys
from asyncio import AbstractEventLoop
from os import getenv
from sys import exit
from typing import Optional
from asyncssh import Error as SSHError
from asyncssh import SSHAcceptor, SSHServer, listen
from observatory.slog import setup_logging
# **************************************************************************************
name = "sftp"
# **************************************************************************************
logger = setup_logging(
name=name,
level="DEBUG",
)
# **************************************************************************************
SFTP_USER: str = getenv("SFTP_USER", "")
# **************************************************************************************
SFTP_BASE_DIRECTORY: str = getenv("SFTP_BASE_DIRECTORY", "")
# **************************************************************************************
SFTP_HOST_KEY_DIRECTORY: Optional[str] = getenv(
"SFTP_HOST_KEY_DIRECTORY",
)
# **************************************************************************************
class DebugSSHServer(SSHServer):
def public_key_auth_supported(self) -> bool:
return True
def password_auth_supported(self) -> bool:
return True
async def validate_password(self, username: str, password: str) -> bool:
logger.debug(f"[AUTH] validate_password({username}) → accepting!")
return True
async def validate_public_key(self, username, key):
logger.debug(f"[AUTH] validate_public_key({username}) → accepting!")
return True
# **************************************************************************************
async def start_sftp_server() -> SSHAcceptor:
logger.debug(
{SFTP_USER: f"{SFTP_BASE_DIRECTORY}/{SFTP_USER}/.ssh/authorized_keys"},
)
logger.debug(
f"{SFTP_HOST_KEY_DIRECTORY}/ssh_host_ecdsa_key",
)
logger.info(
f"Starting SFTP server for user '{SFTP_USER}' at base directory '{SFTP_BASE_DIRECTORY}'",
)
# Ensure the SFTP host key directory is set:
if not SFTP_BASE_DIRECTORY:
logger.error(
"SFTP base directory is not set. Please set the SFTP_BASE_DIRECTORY environment variable."
)
exit("SFTP base directory is not set.")
if not SFTP_HOST_KEY_DIRECTORY:
logger.error(
"SFTP host key directory is not set. Please set the SFTP_HOST_KEY_DIRECTORY environment variable."
)
exit("SFTP host key directory is not set.")
return await listen(
# The hostname or address to listen on. If not specified, listeners are created
# for all addresses:
host="localhost",
# The port number to listen on. If not specified, the default SSH port is
# used (e.g., 22):
port=8022,
# The location of the server host keys. These keys are used to identify the
# SFTP server to incoming clients:
server_host_keys=[
f"{SFTP_HOST_KEY_DIRECTORY}/ssh_host_ecdsa_key",
],
authorized_client_keys={
SFTP_USER: f"{SFTP_BASE_DIRECTORY}/{SFTP_USER}/.ssh/authorized_keys",
},
# Enable SFTP server functionality:
sftp_factory=DebugSSHServer,
)
# **************************************************************************************
def perform_graceful_cleanup(loop: AbstractEventLoop) -> None:
# Clean up pending tasks before exiting the program:
pending = asyncio.all_tasks(loop=loop)
# Cancel all pending tasks to ensure a clean shutdown:
[task.cancel() for task in pending if not task.done()]
# Wait for all tasks to complete:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
# **************************************************************************************
async def main() -> None:
# Get the current event loop:
loop = asyncio.new_event_loop()
try:
# Main event loop for the application should run indefinitely:
listener = await start_sftp_server()
await listener.wait_closed()
except (OSError, SSHError) as e:
logger.error(f"Failed to start SFTP server {e}", exc_info=True)
sys.exit(1)
except KeyboardInterrupt:
# Handle keyboard interrupt gracefully:
logger.info("Program interrupted by user.")
except asyncio.CancelledError:
# Handle cancellation of tasks gracefully:
logger.info("Program cancelled.")
except Exception as e:
# Handle any unexpected errors gracefully:
logger.error(
f"And unexpected error occurred: {e}",
exc_info=e,
)
finally:
# Perform graceful cleanup of the event loop and pending tasks:
perform_graceful_cleanup(loop=loop)
# Close off the event loop:
loop.close()
# **************************************************************************************
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
# Handle keyboard interrupt gracefully:
logger.info("Program interrupted by user.")
except asyncio.CancelledError:
# Handle cancellation of tasks gracefully:
logger.info("Program cancelled.")
except Exception as e:
# Handle any unexpected errors gracefully:
logger.error(
f"And unexpected error occurred: {e}",
exc_info=e,
)
finally:
# Ensure the event loop is closed properly:
logger.info("Exited program gracefully.")
# ************************************************************************************** However, even though I can verify that the public key of my local machine is in authorized_keys for the user (have verified multiple times), and I have created a host key as: sudo ssh-keygen \
-t ecdsa \
-b 521 \
-f $SFTP_HOST_KEY_DIRECTORY/ssh_host_ecdsa_key \
-N '' ... I seem unable to connect from the local machine: debug1: Offering public key: ED25519 SHA256:<REDACTED> agent
Connection closed by ::1 port 8022
Connection closed The user is created as: subprocess.run(
[
"useradd",
"-M",
"-d",
home_directory,
"-s",
"/bin/bash",
"-G",
group,
user,
],
check=True,
) The user is created correctly (verified with Yet, still it seems to always close the connection ... I'm also finding that there aren't any logs coming from the asyncssh server so I am struggling to debug this issue further ... what have I done wrong? Am happy to supply and more debugging if needed for anyone to help with this ... |
Beta Was this translation helpful? Give feedback.
Replies: 4 comments 2 replies
-
The sudden connection close without returning an error back to the client suggests some kind of uncaught Python exception on the task that's accepting a new SSH connection. One thing that jumps out at me is your authorized_client_keys argument. You appear to be passing in a dictionary, but AsyncSSH is expecting a list/sequence of filenames to load keys from or an SSHAuthorizedKeys object which you previously read keys into using import_authorized_keys() or read_authorized_keys(). Note that user information does not appear anywhere inside these key files. The expectation is that you will load the appropriate keys from within the begin_auth() callback if you need to support multiple sets of keys for different users. That callback will be given the username, and then the appropriate set of keys can be loaded and set by calling set_authorized_keys() on the SSHServerConnection. Keep in mind that the users supported by AsyncSSH don't have anything to do with the operating system users, and all code will be running as whatever user you use to start the Python application which is using AsyncSSH. Any file permission checking will be based on that user and not the username the client is logging in as. Unless you set a custom chroot directory, the files made accessible through SFTP will be whatever files the user permission has access to. I don't know why you wouldn't be getting logs here. I don't have any experience with "observatory.slog", but I have used logging.basicConfig() to set up logging and that has works well for me on both client and server side. |
Beta Was this translation helpful? Give feedback.
-
Hi @ronf Many thanks for this feedback. I will try the suggestions today, if I make progress ... I will let you know. Really appreciate this. |
Beta Was this translation helpful? Give feedback.
-
@ronf There's obviously something I am missing here, as any user can authenticate with the SFTP server even though I have restricted to just the SFTP user I want: class AuthWithPublicKeySSHServer(SSHServer):
def public_key_auth_supported(self) -> bool:
return True
def password_auth_supported(self) -> bool:
return False
def connection_made(self, conn: SSHServerConnection) -> None:
self._conn = conn
def begin_auth(self, username: str) -> bool:
try:
self._conn.set_authorized_keys(
f"{SFTP_BASE_DIRECTORY}/{username}/.ssh/authorized_keys"
)
except OSError:
pass
return True
async def validate_public_key(self, username: str, key: SSHKey) -> bool:
authorization_key = path.join(
SFTP_BASE_DIRECTORY, username, ".ssh", "authorized_keys"
)
fingerprint: str = key.get_fingerprint()
logger.debug(
f"Validating public key for {username}, fp={fingerprint}, file={authorization_key}"
)
try:
with open(authorization_key, "r") as f:
for line in f:
print(line)
line = line.strip()
if not line or line.startswith("#"):
continue
try:
allowed = import_public_key(line)
except Exception as e:
logger.warning(f"Invalid key in file: {e}")
continue
if allowed == key:
logger.info(
f"Public key accepted for {username} ({fingerprint})"
)
return True
except FileNotFoundError:
logger.error(f"Authorized key not found: {authorization_key}")
except PermissionError:
logger.error(f"Permission denied reading: {authorization_key}")
logger.warning(f"Public key rejected for {username} ({fingerprint})")
return False I see that I am so confused by how this package actually works ... any guidance as to where I am going wrong on this one would be amazing. |
Beta Was this translation helpful? Give feedback.
-
Ok, weirdly it is now behaving as I expect it to, without any changes. It's kicking out any non-users of the system, and any user without an authorized key. Many thanks. |
Beta Was this translation helpful? Give feedback.
The sudden connection close without returning an error back to the client suggests some kind of uncaught Python exception on the task that's accepting a new SSH connection.
One thing that jumps out at me is your authorized_client_keys argument. You appear to be passing in a dictionary, but AsyncSSH is expecting a list/sequence of filenames to load keys from or an SSHAuthorizedKeys object which you previously read keys into using import_authorized_keys() or read_authorized_keys().
Note that user information does not appear anywhere inside these key files. The expectation is that you will load the appropriate keys from within the begin_auth() callback if you need to support multiple sets of…