aggiunti server sftp
This commit is contained in:
70
change_vm_ssh_port.sh
Executable file
70
change_vm_ssh_port.sh
Executable file
@@ -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@<ip-vm>"
|
||||||
|
echo " - La porta 22 è ora disponibile per i container SFTP"
|
||||||
|
echo ""
|
||||||
|
echo "Per verificare:"
|
||||||
|
echo " ss -tlnp | grep sshd"
|
||||||
|
echo ""
|
||||||
@@ -84,6 +84,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DB_HOST: ${VIP:-192.168.1.210}
|
DB_HOST: ${VIP:-192.168.1.210}
|
||||||
FTP_INSTANCE_ID: 1
|
FTP_INSTANCE_ID: 1
|
||||||
|
FTP_MODE: ftp
|
||||||
TZ: Europe/Rome
|
TZ: Europe/Rome
|
||||||
FTP_PASSIVE_PORT: "40000"
|
FTP_PASSIVE_PORT: "40000"
|
||||||
FTP_EXTERNAL_IP: ${VIP:-192.168.1.210}
|
FTP_EXTERNAL_IP: ${VIP:-192.168.1.210}
|
||||||
@@ -100,6 +101,27 @@ services:
|
|||||||
- "21"
|
- "21"
|
||||||
labels:
|
labels:
|
||||||
logging: "promtail"
|
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:
|
haproxy:
|
||||||
image: haproxy:2.8-alpine
|
image: haproxy:2.8-alpine
|
||||||
@@ -111,6 +133,7 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
ports:
|
ports:
|
||||||
- "21:21"
|
- "21:21"
|
||||||
|
- "22:22"
|
||||||
- "8404:8404"
|
- "8404:8404"
|
||||||
labels:
|
labels:
|
||||||
logging: "promtail"
|
logging: "promtail"
|
||||||
|
|||||||
12
vm1/generate_ssh_host_key.sh
Executable file
12
vm1/generate_ssh_host_key.sh
Executable file
@@ -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
|
||||||
@@ -31,3 +31,11 @@ frontend ftp_control
|
|||||||
backend ftp_servers
|
backend ftp_servers
|
||||||
mode tcp
|
mode tcp
|
||||||
server ftp1 ftp-server-1:21 check
|
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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiomysql>=0.2.0",
|
"aiomysql>=0.2.0",
|
||||||
|
"asyncssh>=2.14.0", # Required for SFTP server mode
|
||||||
"cryptography>=45.0.3",
|
"cryptography>=45.0.3",
|
||||||
"mysql-connector-python>=9.3.0", # Needed for synchronous DB connections (ftp_csv_receiver.py, load_ftp_users.py)
|
"mysql-connector-python>=9.3.0", # Needed for synchronous DB connections (ftp_csv_receiver.py, load_ftp_users.py)
|
||||||
"pyftpdlib>=2.0.1",
|
"pyftpdlib>=2.0.1",
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
#!.venv/bin/python
|
#!.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.
|
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 logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
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")
|
root_logger.info("Logging FTP configurato con rotation (10MB, 5 backup) e console output")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def start_ftp_server(cfg):
|
||||||
"""Main function to start the FTP server."""
|
"""Start traditional FTP server."""
|
||||||
# Load the configuration settings
|
|
||||||
cfg = setting.Config()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Configure logging first
|
|
||||||
setup_logging(cfg.logfilename)
|
|
||||||
|
|
||||||
# Initialize the authorizer with database support
|
# Initialize the authorizer with database support
|
||||||
# This authorizer checks the database on every login, ensuring
|
# This authorizer checks the database on every login, ensuring
|
||||||
# all FTP server instances stay synchronized without restarts
|
# all FTP server instances stay synchronized without restarts
|
||||||
@@ -185,7 +185,60 @@ def main():
|
|||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|
||||||
except Exception as e:
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Configurazione server FTP
|
# 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:
|
def connect_ftp() -> FTP:
|
||||||
|
|||||||
194
vm1/src/utils/servers/sftp_server.py
Normal file
194
vm1/src/utils/servers/sftp_server.py
Normal file
@@ -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
|
||||||
@@ -58,6 +58,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DB_HOST: ${VIP:-192.168.1.210}
|
DB_HOST: ${VIP:-192.168.1.210}
|
||||||
FTP_INSTANCE_ID: 2
|
FTP_INSTANCE_ID: 2
|
||||||
|
FTP_MODE: ftp
|
||||||
TZ: Europe/Rome
|
TZ: Europe/Rome
|
||||||
FTP_PASSIVE_PORT: "40000"
|
FTP_PASSIVE_PORT: "40000"
|
||||||
FTP_EXTERNAL_IP: ${VIP:-192.168.1.210}
|
FTP_EXTERNAL_IP: ${VIP:-192.168.1.210}
|
||||||
@@ -74,6 +75,27 @@ services:
|
|||||||
- "21"
|
- "21"
|
||||||
labels:
|
labels:
|
||||||
logging: "promtail"
|
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:
|
haproxy:
|
||||||
image: haproxy:2.8-alpine
|
image: haproxy:2.8-alpine
|
||||||
@@ -85,6 +107,7 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
ports:
|
ports:
|
||||||
- "21:21"
|
- "21:21"
|
||||||
|
- "22:22"
|
||||||
- "8404:8404"
|
- "8404:8404"
|
||||||
labels:
|
labels:
|
||||||
logging: "promtail"
|
logging: "promtail"
|
||||||
|
|||||||
12
vm2/generate_ssh_host_key.sh
Executable file
12
vm2/generate_ssh_host_key.sh
Executable file
@@ -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
|
||||||
@@ -31,3 +31,11 @@ frontend ftp_control
|
|||||||
backend ftp_servers
|
backend ftp_servers
|
||||||
mode tcp
|
mode tcp
|
||||||
server ftp2 ftp-server-2:21 check
|
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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiomysql>=0.2.0",
|
"aiomysql>=0.2.0",
|
||||||
|
"asyncssh>=2.14.0", # Required for SFTP server mode
|
||||||
"cryptography>=45.0.3",
|
"cryptography>=45.0.3",
|
||||||
"mysql-connector-python>=9.3.0", # Needed for synchronous DB connections (ftp_csv_receiver.py, load_ftp_users.py)
|
"mysql-connector-python>=9.3.0", # Needed for synchronous DB connections (ftp_csv_receiver.py, load_ftp_users.py)
|
||||||
"pyftpdlib>=2.0.1",
|
"pyftpdlib>=2.0.1",
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
#!.venv/bin/python
|
#!.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.
|
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 logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
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")
|
root_logger.info("Logging FTP configurato con rotation (10MB, 5 backup) e console output")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def start_ftp_server(cfg):
|
||||||
"""Main function to start the FTP server."""
|
"""Start traditional FTP server."""
|
||||||
# Load the configuration settings
|
|
||||||
cfg = setting.Config()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Configure logging first
|
|
||||||
setup_logging(cfg.logfilename)
|
|
||||||
|
|
||||||
# Initialize the authorizer with database support
|
# Initialize the authorizer with database support
|
||||||
# This authorizer checks the database on every login, ensuring
|
# This authorizer checks the database on every login, ensuring
|
||||||
# all FTP server instances stay synchronized without restarts
|
# all FTP server instances stay synchronized without restarts
|
||||||
@@ -185,7 +185,60 @@ def main():
|
|||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|
||||||
except Exception as e:
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Configurazione server FTP
|
# 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:
|
def connect_ftp() -> FTP:
|
||||||
|
|||||||
194
vm2/src/utils/servers/sftp_server.py
Normal file
194
vm2/src/utils/servers/sftp_server.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user