From b5d3e764e8aa7f14c5845c65cce888504aa5073c Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 2 Nov 2025 16:33:16 +0100 Subject: [PATCH] aggiunti server sftp --- change_vm_ssh_port.sh | 70 ++++++++++ vm1/docker-compose.yml | 23 ++++ vm1/generate_ssh_host_key.sh | 12 ++ vm1/haproxy.cfg | 8 ++ vm1/pyproject.toml | 1 + vm1/src/ftp_csv_receiver.py | 73 ++++++++-- vm1/src/load_ftp_users.py | 2 +- vm1/src/utils/servers/sftp_server.py | 194 +++++++++++++++++++++++++++ vm2/docker-compose.yml | 23 ++++ vm2/generate_ssh_host_key.sh | 12 ++ vm2/haproxy.cfg | 8 ++ vm2/pyproject.toml | 1 + vm2/src/ftp_csv_receiver.py | 73 ++++++++-- vm2/src/load_ftp_users.py | 2 +- vm2/src/utils/servers/sftp_server.py | 194 +++++++++++++++++++++++++++ 15 files changed, 674 insertions(+), 22 deletions(-) create mode 100755 change_vm_ssh_port.sh create mode 100755 vm1/generate_ssh_host_key.sh create mode 100644 vm1/src/utils/servers/sftp_server.py create mode 100755 vm2/generate_ssh_host_key.sh create mode 100644 vm2/src/utils/servers/sftp_server.py diff --git a/change_vm_ssh_port.sh b/change_vm_ssh_port.sh new file mode 100755 index 0000000..22893b1 --- /dev/null +++ b/change_vm_ssh_port.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Script per cambiare la porta SSH delle VM da 22 a 2222 +# Questo permette ai container SFTP di usare la porta 22 standard + +set -e + +NEW_SSH_PORT=2222 +SSH_CONFIG="/etc/ssh/sshd_config" + +echo "================================================" +echo "Cambio porta SSH della VM da 22 a $NEW_SSH_PORT" +echo "================================================" +echo "" + +# Backup della configurazione originale +if [ ! -f "${SSH_CONFIG}.backup" ]; then + echo "Creazione backup di $SSH_CONFIG..." + cp "$SSH_CONFIG" "${SSH_CONFIG}.backup" + echo "✓ Backup creato: ${SSH_CONFIG}.backup" +else + echo "✓ Backup già esistente: ${SSH_CONFIG}.backup" +fi + +# Modifica la porta SSH +echo "" +echo "Modifica della porta SSH a $NEW_SSH_PORT..." + +# Rimuovi eventuali configurazioni Port esistenti e aggiungi la nuova +grep -v "^Port " "$SSH_CONFIG" | grep -v "^#Port " > "${SSH_CONFIG}.tmp" +echo "Port $NEW_SSH_PORT" | cat - "${SSH_CONFIG}.tmp" > "$SSH_CONFIG" +rm "${SSH_CONFIG}.tmp" + +echo "✓ Configurazione SSH aggiornata" + +# Verifica la configurazione +echo "" +echo "Verifica della configurazione SSH..." +if sshd -t; then + echo "✓ Configurazione SSH valida" +else + echo "✗ ERRORE: Configurazione SSH non valida!" + echo "Ripristino del backup..." + cp "${SSH_CONFIG}.backup" "$SSH_CONFIG" + exit 1 +fi + +# Riavvia il servizio SSH +echo "" +echo "Riavvio del servizio SSH..." +systemctl restart sshd +echo "✓ Servizio SSH riavviato" + +# Mostra lo status +echo "" +echo "Status del servizio SSH:" +systemctl status sshd --no-pager -l | head -10 + +echo "" +echo "================================================" +echo "✓ Porta SSH cambiata con successo!" +echo "================================================" +echo "" +echo "IMPORTANTE:" +echo " - SSH ora ascolta sulla porta $NEW_SSH_PORT" +echo " - Connettiti con: ssh -p $NEW_SSH_PORT root@" +echo " - La porta 22 è ora disponibile per i container SFTP" +echo "" +echo "Per verificare:" +echo " ss -tlnp | grep sshd" +echo "" diff --git a/vm1/docker-compose.yml b/vm1/docker-compose.yml index eae1be7..33ed0ea 100644 --- a/vm1/docker-compose.yml +++ b/vm1/docker-compose.yml @@ -84,6 +84,7 @@ services: environment: DB_HOST: ${VIP:-192.168.1.210} FTP_INSTANCE_ID: 1 + FTP_MODE: ftp TZ: Europe/Rome FTP_PASSIVE_PORT: "40000" FTP_EXTERNAL_IP: ${VIP:-192.168.1.210} @@ -100,6 +101,27 @@ services: - "21" labels: logging: "promtail" + sftp-server-1: + build: . + container_name: sftp-server-1 + restart: unless-stopped + command: ["python", "-m", "src.ftp_csv_receiver"] + environment: + DB_HOST: ${VIP:-192.168.1.210} + FTP_INSTANCE_ID: 11 + FTP_MODE: sftp + TZ: Europe/Rome + volumes: + - app-logs:/app/logs + - ./aseftp:/app/aseftp + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - app-network + expose: + - "22" + labels: + logging: "promtail" haproxy: image: haproxy:2.8-alpine @@ -111,6 +133,7 @@ services: - app-network ports: - "21:21" + - "22:22" - "8404:8404" labels: logging: "promtail" diff --git a/vm1/generate_ssh_host_key.sh b/vm1/generate_ssh_host_key.sh new file mode 100755 index 0000000..fc41a8f --- /dev/null +++ b/vm1/generate_ssh_host_key.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Generate SSH host key for SFTP server if it doesn't exist + +KEY_FILE="/app/ssh_host_key" + +if [ ! -f "$KEY_FILE" ]; then + echo "Generating SSH host key for SFTP server..." + ssh-keygen -t rsa -b 4096 -f "$KEY_FILE" -N "" -C "SFTP-Server-Host-Key" + echo "SSH host key generated successfully at $KEY_FILE" +else + echo "SSH host key already exists at $KEY_FILE" +fi diff --git a/vm1/haproxy.cfg b/vm1/haproxy.cfg index 48671ca..3649bbc 100644 --- a/vm1/haproxy.cfg +++ b/vm1/haproxy.cfg @@ -31,3 +31,11 @@ frontend ftp_control backend ftp_servers mode tcp server ftp1 ftp-server-1:21 check + +frontend sftp_control + bind *:22 + default_backend sftp_servers + +backend sftp_servers + mode tcp + server sftp1 sftp-server-1:22 check diff --git a/vm1/pyproject.toml b/vm1/pyproject.toml index c4c0e51..974e893 100644 --- a/vm1/pyproject.toml +++ b/vm1/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "aiomysql>=0.2.0", + "asyncssh>=2.14.0", # Required for SFTP server mode "cryptography>=45.0.3", "mysql-connector-python>=9.3.0", # Needed for synchronous DB connections (ftp_csv_receiver.py, load_ftp_users.py) "pyftpdlib>=2.0.1", diff --git a/vm1/src/ftp_csv_receiver.py b/vm1/src/ftp_csv_receiver.py index f00ee1f..0624a56 100755 --- a/vm1/src/ftp_csv_receiver.py +++ b/vm1/src/ftp_csv_receiver.py @@ -1,11 +1,17 @@ #!.venv/bin/python """ -This module implements an FTP server with custom commands for +This module implements an FTP/SFTP server with custom commands for managing virtual users and handling CSV file uploads. + +Server mode is controlled by FTP_MODE environment variable: +- FTP_MODE=ftp (default): Traditional FTP server +- FTP_MODE=sftp: SFTP (SSH File Transfer Protocol) server """ +import asyncio import logging import os +import sys from hashlib import sha256 from logging.handlers import RotatingFileHandler from pathlib import Path @@ -140,15 +146,9 @@ def setup_logging(log_filename: str): root_logger.info("Logging FTP configurato con rotation (10MB, 5 backup) e console output") -def main(): - """Main function to start the FTP server.""" - # Load the configuration settings - cfg = setting.Config() - +def start_ftp_server(cfg): + """Start traditional FTP server.""" try: - # Configure logging first - setup_logging(cfg.logfilename) - # Initialize the authorizer with database support # This authorizer checks the database on every login, ensuring # all FTP server instances stay synchronized without restarts @@ -185,7 +185,60 @@ def main(): server.serve_forever() except Exception as e: - logger.error("Exit with error: %s.", e) + logger.error("FTP server error: %s", e, exc_info=True) + sys.exit(1) + + +async def start_sftp_server_async(cfg): + """Start SFTP server (async).""" + try: + from utils.servers.sftp_server import start_sftp_server + + logger.info(f"Starting SFTP server on port {cfg.service_port}") + logger.info(f"Database connection: {cfg.dbuser}@{cfg.dbhost}:{cfg.dbport}/{cfg.dbname}") + + # Start SFTP server + server = await start_sftp_server(cfg, host="0.0.0.0", port=cfg.service_port) + + # Keep server running + await asyncio.Event().wait() + + except ImportError as e: + logger.error("SFTP mode requires 'asyncssh' library. Install with: pip install asyncssh") + logger.error(f"Error: {e}") + sys.exit(1) + except Exception as e: + logger.error("SFTP server error: %s", e, exc_info=True) + sys.exit(1) + + +def main(): + """Main function to start FTP or SFTP server based on FTP_MODE environment variable.""" + # Load the configuration settings + cfg = setting.Config() + + # Configure logging first + setup_logging(cfg.logfilename) + + # Get server mode from environment variable (default: ftp) + server_mode = os.getenv("FTP_MODE", "ftp").lower() + + if server_mode not in ["ftp", "sftp"]: + logger.error(f"Invalid FTP_MODE: {server_mode}. Valid values: ftp, sftp") + sys.exit(1) + + logger.info(f"Server mode: {server_mode.upper()}") + + try: + if server_mode == "ftp": + start_ftp_server(cfg) + elif server_mode == "sftp": + asyncio.run(start_sftp_server_async(cfg)) + except KeyboardInterrupt: + logger.info("Server stopped by user") + except Exception as e: + logger.error("Unexpected error: %s", e, exc_info=True) + sys.exit(1) if __name__ == "__main__": diff --git a/vm1/src/load_ftp_users.py b/vm1/src/load_ftp_users.py index 453f719..07cfff5 100644 --- a/vm1/src/load_ftp_users.py +++ b/vm1/src/load_ftp_users.py @@ -17,7 +17,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %( logger = logging.getLogger(__name__) # Configurazione server FTP -FTP_CONFIG = {"host": "192.168.1.210", "user": "admin", "password": "batt1l0", "port": 21} +FTP_CONFIG = {"host": "localhost", "user": "admin", "password": "batt1l0", "port": 2121} def connect_ftp() -> FTP: diff --git a/vm1/src/utils/servers/sftp_server.py b/vm1/src/utils/servers/sftp_server.py new file mode 100644 index 0000000..40e5edc --- /dev/null +++ b/vm1/src/utils/servers/sftp_server.py @@ -0,0 +1,194 @@ +""" +SFTP Server implementation using asyncssh. +Shares the same authentication system and file handling logic as the FTP server. +""" + +import asyncio +import logging +from pathlib import Path + +import asyncssh + +from utils.connect import file_management +from utils.database.connection import connetti_db_async + +logger = logging.getLogger(__name__) + + +class ASESFTPServer(asyncssh.SFTPServer): + """Custom SFTP server that handles file uploads with the same logic as FTP server.""" + + def __init__(self, chan): + """Initialize SFTP server with channel.""" + super().__init__(chan) + # Get config from connection (set during authentication) + self.cfg = chan.get_connection()._cfg + + async def close(self): + """Called when SFTP session is closed.""" + logger.info(f"SFTP session closed for user: {self._chan.get_connection().get_extra_info('username')}") + await super().close() + + +class ASESSHServer(asyncssh.SSHServer): + """Custom SSH server for SFTP authentication using database.""" + + def __init__(self, cfg): + """Initialize SSH server with configuration.""" + self.cfg = cfg + super().__init__() + + def connection_made(self, conn): + """Called when connection is established.""" + # Store config in connection for later use + conn._cfg = self.cfg + logger.info(f"SSH connection from {conn.get_extra_info('peername')[0]}") + + def connection_lost(self, exc): + """Called when connection is lost.""" + if exc: + logger.error(f"SSH connection lost: {exc}") + + async def validate_password(self, username, password): + """ + Validate user credentials against database. + Same logic as DatabaseAuthorizer for FTP. + """ + from hashlib import sha256 + + # Hash the provided password + password_hash = sha256(password.encode("UTF-8")).hexdigest() + + # Check if user is admin + if username == self.cfg.adminuser[0]: + if self.cfg.adminuser[1] == password_hash: + logger.info(f"Admin user '{username}' authenticated successfully") + return True + else: + logger.warning(f"Failed admin login attempt for user: {username}") + return False + + # For regular users, check database + try: + conn = await connetti_db_async(self.cfg) + cur = await conn.cursor() + + # Query user from database + await cur.execute( + f"SELECT ftpuser, hash, virtpath, perm, disabled_at FROM {self.cfg.dbname}.{self.cfg.dbusertable} WHERE ftpuser = %s", + (username,) + ) + + result = await cur.fetchone() + await cur.close() + conn.close() + + if not result: + logger.warning(f"SFTP login attempt for non-existent user: {username}") + return False + + ftpuser, stored_hash, virtpath, perm, disabled_at = result + + # Check if user is disabled + if disabled_at is not None: + logger.warning(f"SFTP login attempt for disabled user: {username}") + return False + + # Verify password + if stored_hash != password_hash: + logger.warning(f"Invalid password for SFTP user: {username}") + return False + + # Authentication successful - ensure user directory exists + try: + Path(virtpath).mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.error(f"Failed to create directory for user {username}: {e}") + return False + + logger.info(f"Successful SFTP login for user: {username}") + return True + + except Exception as e: + logger.error(f"Database error during SFTP authentication for user {username}: {e}", exc_info=True) + return False + + def password_auth_supported(self): + """Enable password authentication.""" + return True + + def begin_auth(self, username): + """Called when authentication begins.""" + logger.debug(f"Authentication attempt for user: {username}") + return True + + +class SFTPFileHandler(asyncssh.SFTPServer): + """Extended SFTP server with file upload handling.""" + + def __init__(self, chan): + super().__init__(chan) + self.cfg = chan.get_connection()._cfg + + async def close(self): + """Handle session close.""" + await super().close() + + # Override file operations to add custom handling + async def rename(self, oldpath, newpath): + """ + Handle file rename/move - called when upload completes. + This is where we trigger the CSV processing like in FTP. + """ + result = await super().rename(oldpath, newpath) + + # Check if it's a CSV file that was uploaded + if newpath.lower().endswith('.csv'): + try: + # Trigger file processing (same as FTP on_file_received) + logger.info(f"CSV file uploaded via SFTP: {newpath}") + # Create a mock handler object with required attributes + mock_handler = type('obj', (object,), { + 'cfg': self.cfg, + 'username': self._chan.get_connection().get_extra_info('username') + })() + + # Call the same file_management function used by FTP + file_management.on_file_received(mock_handler, newpath) + except Exception as e: + logger.error(f"Error processing SFTP uploaded file {newpath}: {e}", exc_info=True) + + return result + + +async def start_sftp_server(cfg, host='0.0.0.0', port=22): + """ + Start SFTP server. + + Args: + cfg: Configuration object + host: Host to bind to + port: Port to bind to + + Returns: + asyncssh server object + """ + logger.info(f"Starting SFTP server on {host}:{port}") + + # Create SSH server + ssh_server = ASESSHServer(cfg) + + # Start asyncssh server + server = await asyncssh.create_server( + lambda: ssh_server, + host, + port, + server_host_keys=['/app/ssh_host_key'], # You'll need to generate this + sftp_factory=SFTPFileHandler, + session_encoding=None, # Binary mode for file transfers + ) + + logger.info(f"SFTP server started successfully on {host}:{port}") + logger.info(f"Database connection: {cfg.dbuser}@{cfg.dbhost}:{cfg.dbport}/{cfg.dbname}") + + return server diff --git a/vm2/docker-compose.yml b/vm2/docker-compose.yml index a1fef28..c6868b3 100644 --- a/vm2/docker-compose.yml +++ b/vm2/docker-compose.yml @@ -58,6 +58,7 @@ services: environment: DB_HOST: ${VIP:-192.168.1.210} FTP_INSTANCE_ID: 2 + FTP_MODE: ftp TZ: Europe/Rome FTP_PASSIVE_PORT: "40000" FTP_EXTERNAL_IP: ${VIP:-192.168.1.210} @@ -74,6 +75,27 @@ services: - "21" labels: logging: "promtail" + sftp-server-2: + build: . + container_name: sftp-server-2 + restart: unless-stopped + command: ["python", "-m", "src.ftp_csv_receiver"] + environment: + DB_HOST: ${VIP:-192.168.1.210} + FTP_INSTANCE_ID: 12 + FTP_MODE: sftp + TZ: Europe/Rome + volumes: + - app-logs:/app/logs + - ./aseftp:/app/aseftp + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - app-network + expose: + - "22" + labels: + logging: "promtail" haproxy: image: haproxy:2.8-alpine @@ -85,6 +107,7 @@ services: - app-network ports: - "21:21" + - "22:22" - "8404:8404" labels: logging: "promtail" diff --git a/vm2/generate_ssh_host_key.sh b/vm2/generate_ssh_host_key.sh new file mode 100755 index 0000000..fc41a8f --- /dev/null +++ b/vm2/generate_ssh_host_key.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Generate SSH host key for SFTP server if it doesn't exist + +KEY_FILE="/app/ssh_host_key" + +if [ ! -f "$KEY_FILE" ]; then + echo "Generating SSH host key for SFTP server..." + ssh-keygen -t rsa -b 4096 -f "$KEY_FILE" -N "" -C "SFTP-Server-Host-Key" + echo "SSH host key generated successfully at $KEY_FILE" +else + echo "SSH host key already exists at $KEY_FILE" +fi diff --git a/vm2/haproxy.cfg b/vm2/haproxy.cfg index 20f3b82..e0c555e 100644 --- a/vm2/haproxy.cfg +++ b/vm2/haproxy.cfg @@ -31,3 +31,11 @@ frontend ftp_control backend ftp_servers mode tcp server ftp2 ftp-server-2:21 check + +frontend sftp_control + bind *:22 + default_backend sftp_servers + +backend sftp_servers + mode tcp + server sftp2 sftp-server-2:22 check diff --git a/vm2/pyproject.toml b/vm2/pyproject.toml index c4c0e51..974e893 100644 --- a/vm2/pyproject.toml +++ b/vm2/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "aiomysql>=0.2.0", + "asyncssh>=2.14.0", # Required for SFTP server mode "cryptography>=45.0.3", "mysql-connector-python>=9.3.0", # Needed for synchronous DB connections (ftp_csv_receiver.py, load_ftp_users.py) "pyftpdlib>=2.0.1", diff --git a/vm2/src/ftp_csv_receiver.py b/vm2/src/ftp_csv_receiver.py index f00ee1f..0624a56 100755 --- a/vm2/src/ftp_csv_receiver.py +++ b/vm2/src/ftp_csv_receiver.py @@ -1,11 +1,17 @@ #!.venv/bin/python """ -This module implements an FTP server with custom commands for +This module implements an FTP/SFTP server with custom commands for managing virtual users and handling CSV file uploads. + +Server mode is controlled by FTP_MODE environment variable: +- FTP_MODE=ftp (default): Traditional FTP server +- FTP_MODE=sftp: SFTP (SSH File Transfer Protocol) server """ +import asyncio import logging import os +import sys from hashlib import sha256 from logging.handlers import RotatingFileHandler from pathlib import Path @@ -140,15 +146,9 @@ def setup_logging(log_filename: str): root_logger.info("Logging FTP configurato con rotation (10MB, 5 backup) e console output") -def main(): - """Main function to start the FTP server.""" - # Load the configuration settings - cfg = setting.Config() - +def start_ftp_server(cfg): + """Start traditional FTP server.""" try: - # Configure logging first - setup_logging(cfg.logfilename) - # Initialize the authorizer with database support # This authorizer checks the database on every login, ensuring # all FTP server instances stay synchronized without restarts @@ -185,7 +185,60 @@ def main(): server.serve_forever() except Exception as e: - logger.error("Exit with error: %s.", e) + logger.error("FTP server error: %s", e, exc_info=True) + sys.exit(1) + + +async def start_sftp_server_async(cfg): + """Start SFTP server (async).""" + try: + from utils.servers.sftp_server import start_sftp_server + + logger.info(f"Starting SFTP server on port {cfg.service_port}") + logger.info(f"Database connection: {cfg.dbuser}@{cfg.dbhost}:{cfg.dbport}/{cfg.dbname}") + + # Start SFTP server + server = await start_sftp_server(cfg, host="0.0.0.0", port=cfg.service_port) + + # Keep server running + await asyncio.Event().wait() + + except ImportError as e: + logger.error("SFTP mode requires 'asyncssh' library. Install with: pip install asyncssh") + logger.error(f"Error: {e}") + sys.exit(1) + except Exception as e: + logger.error("SFTP server error: %s", e, exc_info=True) + sys.exit(1) + + +def main(): + """Main function to start FTP or SFTP server based on FTP_MODE environment variable.""" + # Load the configuration settings + cfg = setting.Config() + + # Configure logging first + setup_logging(cfg.logfilename) + + # Get server mode from environment variable (default: ftp) + server_mode = os.getenv("FTP_MODE", "ftp").lower() + + if server_mode not in ["ftp", "sftp"]: + logger.error(f"Invalid FTP_MODE: {server_mode}. Valid values: ftp, sftp") + sys.exit(1) + + logger.info(f"Server mode: {server_mode.upper()}") + + try: + if server_mode == "ftp": + start_ftp_server(cfg) + elif server_mode == "sftp": + asyncio.run(start_sftp_server_async(cfg)) + except KeyboardInterrupt: + logger.info("Server stopped by user") + except Exception as e: + logger.error("Unexpected error: %s", e, exc_info=True) + sys.exit(1) if __name__ == "__main__": diff --git a/vm2/src/load_ftp_users.py b/vm2/src/load_ftp_users.py index 453f719..07cfff5 100644 --- a/vm2/src/load_ftp_users.py +++ b/vm2/src/load_ftp_users.py @@ -17,7 +17,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %( logger = logging.getLogger(__name__) # Configurazione server FTP -FTP_CONFIG = {"host": "192.168.1.210", "user": "admin", "password": "batt1l0", "port": 21} +FTP_CONFIG = {"host": "localhost", "user": "admin", "password": "batt1l0", "port": 2121} def connect_ftp() -> FTP: diff --git a/vm2/src/utils/servers/sftp_server.py b/vm2/src/utils/servers/sftp_server.py new file mode 100644 index 0000000..40e5edc --- /dev/null +++ b/vm2/src/utils/servers/sftp_server.py @@ -0,0 +1,194 @@ +""" +SFTP Server implementation using asyncssh. +Shares the same authentication system and file handling logic as the FTP server. +""" + +import asyncio +import logging +from pathlib import Path + +import asyncssh + +from utils.connect import file_management +from utils.database.connection import connetti_db_async + +logger = logging.getLogger(__name__) + + +class ASESFTPServer(asyncssh.SFTPServer): + """Custom SFTP server that handles file uploads with the same logic as FTP server.""" + + def __init__(self, chan): + """Initialize SFTP server with channel.""" + super().__init__(chan) + # Get config from connection (set during authentication) + self.cfg = chan.get_connection()._cfg + + async def close(self): + """Called when SFTP session is closed.""" + logger.info(f"SFTP session closed for user: {self._chan.get_connection().get_extra_info('username')}") + await super().close() + + +class ASESSHServer(asyncssh.SSHServer): + """Custom SSH server for SFTP authentication using database.""" + + def __init__(self, cfg): + """Initialize SSH server with configuration.""" + self.cfg = cfg + super().__init__() + + def connection_made(self, conn): + """Called when connection is established.""" + # Store config in connection for later use + conn._cfg = self.cfg + logger.info(f"SSH connection from {conn.get_extra_info('peername')[0]}") + + def connection_lost(self, exc): + """Called when connection is lost.""" + if exc: + logger.error(f"SSH connection lost: {exc}") + + async def validate_password(self, username, password): + """ + Validate user credentials against database. + Same logic as DatabaseAuthorizer for FTP. + """ + from hashlib import sha256 + + # Hash the provided password + password_hash = sha256(password.encode("UTF-8")).hexdigest() + + # Check if user is admin + if username == self.cfg.adminuser[0]: + if self.cfg.adminuser[1] == password_hash: + logger.info(f"Admin user '{username}' authenticated successfully") + return True + else: + logger.warning(f"Failed admin login attempt for user: {username}") + return False + + # For regular users, check database + try: + conn = await connetti_db_async(self.cfg) + cur = await conn.cursor() + + # Query user from database + await cur.execute( + f"SELECT ftpuser, hash, virtpath, perm, disabled_at FROM {self.cfg.dbname}.{self.cfg.dbusertable} WHERE ftpuser = %s", + (username,) + ) + + result = await cur.fetchone() + await cur.close() + conn.close() + + if not result: + logger.warning(f"SFTP login attempt for non-existent user: {username}") + return False + + ftpuser, stored_hash, virtpath, perm, disabled_at = result + + # Check if user is disabled + if disabled_at is not None: + logger.warning(f"SFTP login attempt for disabled user: {username}") + return False + + # Verify password + if stored_hash != password_hash: + logger.warning(f"Invalid password for SFTP user: {username}") + return False + + # Authentication successful - ensure user directory exists + try: + Path(virtpath).mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.error(f"Failed to create directory for user {username}: {e}") + return False + + logger.info(f"Successful SFTP login for user: {username}") + return True + + except Exception as e: + logger.error(f"Database error during SFTP authentication for user {username}: {e}", exc_info=True) + return False + + def password_auth_supported(self): + """Enable password authentication.""" + return True + + def begin_auth(self, username): + """Called when authentication begins.""" + logger.debug(f"Authentication attempt for user: {username}") + return True + + +class SFTPFileHandler(asyncssh.SFTPServer): + """Extended SFTP server with file upload handling.""" + + def __init__(self, chan): + super().__init__(chan) + self.cfg = chan.get_connection()._cfg + + async def close(self): + """Handle session close.""" + await super().close() + + # Override file operations to add custom handling + async def rename(self, oldpath, newpath): + """ + Handle file rename/move - called when upload completes. + This is where we trigger the CSV processing like in FTP. + """ + result = await super().rename(oldpath, newpath) + + # Check if it's a CSV file that was uploaded + if newpath.lower().endswith('.csv'): + try: + # Trigger file processing (same as FTP on_file_received) + logger.info(f"CSV file uploaded via SFTP: {newpath}") + # Create a mock handler object with required attributes + mock_handler = type('obj', (object,), { + 'cfg': self.cfg, + 'username': self._chan.get_connection().get_extra_info('username') + })() + + # Call the same file_management function used by FTP + file_management.on_file_received(mock_handler, newpath) + except Exception as e: + logger.error(f"Error processing SFTP uploaded file {newpath}: {e}", exc_info=True) + + return result + + +async def start_sftp_server(cfg, host='0.0.0.0', port=22): + """ + Start SFTP server. + + Args: + cfg: Configuration object + host: Host to bind to + port: Port to bind to + + Returns: + asyncssh server object + """ + logger.info(f"Starting SFTP server on {host}:{port}") + + # Create SSH server + ssh_server = ASESSHServer(cfg) + + # Start asyncssh server + server = await asyncssh.create_server( + lambda: ssh_server, + host, + port, + server_host_keys=['/app/ssh_host_key'], # You'll need to generate this + sftp_factory=SFTPFileHandler, + session_encoding=None, # Binary mode for file transfers + ) + + logger.info(f"SFTP server started successfully on {host}:{port}") + logger.info(f"Database connection: {cfg.dbuser}@{cfg.dbhost}:{cfg.dbport}/{cfg.dbname}") + + return server