""" SFTP server implementation for OPNsense backup system. """ import os import threading from pathlib import Path from typing import Optional import paramiko from paramiko import ServerInterface, AUTH_FAILED, OPEN_SUCCEEDED from paramiko.sftp_server import SFTPServer, SFTPServerInterface from paramiko.sftp_handle import SFTPHandle from database import Database from ssh_keys import SSHKeyManager from logger_config import get_logger logger = get_logger(__name__) class OPNsenseServerInterface(ServerInterface): """SSH server interface for authentication and SFTP operations.""" def __init__(self, database: Database, ssh_key_manager: SSHKeyManager, backups_dir: str = "backups"): """Initialize server interface. Args: database: Database instance ssh_key_manager: SSH key manager instance backups_dir: Directory to store backups """ self.database = database self.ssh_key_manager = ssh_key_manager self.backups_dir = Path(backups_dir) self.backups_dir.mkdir(exist_ok=True, mode=0o755) self.current_instance = None def _canonicalize(self, path): """Canonicalize path - ensure it's within backups directory.""" if isinstance(path, bytes): path = path.decode('utf-8') # Remove leading slash path = path.lstrip('/') if not self.current_instance: logger.error(f"No current instance for path: {path}") return None # If path is just the instance identifier (e.g., "lan" from "/lan"), # treat it as the root directory for this instance if path == self.current_instance['identifier']: path = "" # If path starts with instance identifier (e.g., "lan/backup.xml" from "/lan/backup.xml"), # remove it to avoid duplicate instance directory in path if path.startswith(self.current_instance['identifier'] + '/'): path = path[len(self.current_instance['identifier']) + 1:] instance_dir = self.backups_dir / self.current_instance['identifier'] instance_dir.mkdir(exist_ok=True, mode=0o755) full_path = instance_dir / path if path else instance_dir try: full_path = full_path.resolve() instance_dir_resolved = instance_dir.resolve() if not str(full_path).startswith(str(instance_dir_resolved)): logger.warning(f"Path traversal attempt detected: {full_path} not in {instance_dir_resolved}") return None # Path traversal attempt except Exception as e: logger.error(f"Error resolving path {full_path}: {e}") return None return str(full_path) def check_channel_request(self, kind, chanid): """Check channel request.""" if kind == "session": return OPEN_SUCCEEDED return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED def check_auth_publickey(self, username, key): """Authenticate using public key. Args: username: Username (should be instance identifier) key: Public key object from paramiko Returns: AUTH_SUCCESSFUL or AUTH_FAILED """ try: # Get instance by identifier (username) instance = self.database.get_instance_by_identifier(username) if not instance: logger.warning(f"Instance not found: {username}") return AUTH_FAILED # Get SSH key for this instance ssh_key = self.database.get_ssh_key_by_key_id(instance['ssh_key_id']) if not ssh_key: logger.warning(f"SSH key not found for instance: {username}") return AUTH_FAILED # Compare public keys - get base64 representation stored_public_key = ssh_key['public_key'].strip() key_fingerprint = key.get_base64() # Extract key type and base64 from stored key # Format: "ssh-rsa AAAAB3NzaC1yc2E..." parts = stored_public_key.split() if len(parts) >= 2: stored_base64 = parts[1] if stored_base64 == key_fingerprint or key_fingerprint in stored_public_key: self.current_instance = instance logger.info(f"Authentication successful for instance: {username}") return paramiko.AUTH_SUCCESSFUL logger.warning(f"Public key mismatch for instance: {username}") return AUTH_FAILED except Exception as e: logger.error(f"Error during authentication: {e}") return AUTH_FAILED def get_allowed_auths(self, username): """Return allowed authentication methods.""" return "publickey" def check_auth_password(self, username, password): """Password authentication not supported.""" return AUTH_FAILED def check_auth_none(self, username): """None authentication not supported.""" return AUTH_FAILED class OPNsenseSFTPHandle(SFTPHandle): """Custom SFTP handle that records backups when closed.""" def __init__(self, flags, sftp_interface): """Initialize SFTP handle. Args: flags: File open flags sftp_interface: OPNsenseSFTPServerInterface instance """ super().__init__(flags) self.sftp_interface = sftp_interface self.filename = None self.readfile = None self.writefile = None def close(self): """Close file handle and record backup if it was a write operation.""" try: if self.writefile: self.writefile.close() instance = self.sftp_interface.current_instance if instance and self.filename: filename = Path(self.filename).name file_size = Path(self.filename).stat().st_size if Path(self.filename).exists() else 0 try: self.sftp_interface.database.record_backup( instance_id=instance['id'], filename=filename, file_path=str(self.filename), file_size=file_size ) logger.info(f"Backup recorded: {filename} ({file_size} bytes) for instance {instance['identifier']}") except Exception as e: logger.error(f"Error recording backup in database: {e}", exc_info=True) if self.readfile: self.readfile.close() # Call parent close super().close() except Exception as e: logger.error(f"Error in OPNsenseSFTPHandle.close: {e}", exc_info=True) super().close() class OPNsenseSFTPServerInterface(SFTPServerInterface): """SFTP server interface for handling file operations.""" def __init__(self, server, *args, **kwargs): """Initialize SFTP server interface. Args: server: The OPNsenseServerInterface instance (from ServerInterface) """ super().__init__(server, *args, **kwargs) self.server_interface = server @property def current_instance(self): """Get current instance from server interface.""" return getattr(self.server_interface, 'current_instance', None) @property def backups_dir(self): """Get backups directory from server interface.""" return getattr(self.server_interface, 'backups_dir', Path('backups')) @property def database(self): """Get database from server interface.""" return getattr(self.server_interface, 'database', None) def _canonicalize(self, path): """Canonicalize path - ensure it's within backups directory.""" if isinstance(path, bytes): path = path.decode('utf-8') original_path = path # Check if path is already an absolute path within our backups directory try: path_obj = Path(path) if path_obj.is_absolute(): path_resolved = path_obj.resolve() instance_dir = self.backups_dir / (self.current_instance['identifier'] if self.current_instance else '') instance_dir_resolved = instance_dir.resolve() # If the resolved path is within the instance directory, use it directly if str(path_resolved).startswith(str(instance_dir_resolved)): return str(path_resolved) except Exception: pass # Remove leading slash path = path.lstrip('/') instance = self.current_instance if not instance: logger.error(f"No current instance for path: {path}") return None # If path is just the instance identifier (e.g., "lan" from "/lan"), # treat it as the root directory for this instance if path == instance['identifier']: path = "" # If path starts with instance identifier (e.g., "lan/backup.xml" from "/lan/backup.xml"), # remove it to avoid duplicate instance directory in path if path.startswith(instance['identifier'] + '/'): path = path[len(instance['identifier']) + 1:] instance_dir = self.backups_dir / instance['identifier'] instance_dir.mkdir(exist_ok=True, mode=0o755) full_path = instance_dir / path if path else instance_dir try: full_path = full_path.resolve() instance_dir_resolved = instance_dir.resolve() if not str(full_path).startswith(str(instance_dir_resolved)): logger.warning(f"Path traversal attempt detected: {full_path} not in {instance_dir_resolved}") return None except Exception as e: logger.error(f"Error resolving path {full_path}: {e}", exc_info=True) return None return str(full_path) def canonicalize(self, path): """Convert path to real path (canonicalized). This is called by paramiko for REALPATH requests.""" canonical_path = self._canonicalize(path) if not canonical_path: logger.warning(f"canonicalize: canonicalization failed for {path}, returning original") return path return canonical_path def stat(self, path): """Get file/directory stats.""" canonical_path = self._canonicalize(path) if not canonical_path: logger.error(f"stat: canonicalization failed for path: {path}") return paramiko.SFTP_NO_SUCH_FILE # Ensure the directory exists if not os.path.exists(canonical_path): instance = self.current_instance if instance: instance_dir = self.backups_dir / instance['identifier'] canonical_path_obj = Path(canonical_path) try: if canonical_path_obj.resolve() == instance_dir.resolve(): os.makedirs(canonical_path, mode=0o755, exist_ok=True) else: return paramiko.SFTP_NO_SUCH_FILE except Exception as e: logger.error(f"Error creating instance directory: {e}") return paramiko.SFTP_NO_SUCH_FILE else: logger.error(f"stat: no current instance") return paramiko.SFTP_NO_SUCH_FILE try: stat_result = os.stat(canonical_path) attr = paramiko.SFTPAttributes.from_stat(stat_result) if os.path.isdir(canonical_path): attr.st_mode = stat_result.st_mode return attr except OSError as e: logger.error(f"Error getting stats for {canonical_path}: {e}") if e.errno == 2: # No such file or directory return paramiko.SFTP_NO_SUCH_FILE return paramiko.SFTP_FAILURE except Exception as e: logger.error(f"Unexpected error getting stats: {e}", exc_info=True) return paramiko.SFTP_FAILURE def lstat(self, path): """Get file/directory stats (without following symlinks).""" canonical_path = self._canonicalize(path) if not canonical_path: logger.error(f"lstat: canonicalization failed for path: {path}") return paramiko.SFTP_NO_SUCH_FILE # Ensure the directory exists if not os.path.exists(canonical_path): instance = self.current_instance if instance: instance_dir = self.backups_dir / instance['identifier'] canonical_path_obj = Path(canonical_path) try: if canonical_path_obj.resolve() == instance_dir.resolve(): os.makedirs(canonical_path, mode=0o755, exist_ok=True) else: return paramiko.SFTP_NO_SUCH_FILE except Exception as e: logger.error(f"Error creating instance directory: {e}") return paramiko.SFTP_NO_SUCH_FILE else: logger.error(f"lstat: no current instance") return paramiko.SFTP_NO_SUCH_FILE try: stat_result = os.lstat(canonical_path) attr = paramiko.SFTPAttributes.from_stat(stat_result) if os.path.isdir(canonical_path): attr.st_mode = stat_result.st_mode return attr except OSError as e: logger.error(f"Error getting lstat for {canonical_path}: {e}") if e.errno == 2: # No such file or directory return paramiko.SFTP_NO_SUCH_FILE return paramiko.SFTP_FAILURE except Exception as e: logger.error(f"Unexpected error getting lstat: {e}", exc_info=True) return paramiko.SFTP_FAILURE def open(self, path, flags, attr): """Open a file for reading/writing.""" canonical_path = self._canonicalize(path) if not canonical_path: logger.error(f"open: canonicalization failed for {path}") return paramiko.SFTP_NO_SUCH_FILE if os.path.isdir(canonical_path): logger.warning(f"open: attempted to open directory as file: {canonical_path}") return paramiko.SFTP_FAILURE try: if flags & os.O_WRONLY or flags & os.O_RDWR or (flags & os.O_CREAT and flags & os.O_WRONLY): Path(canonical_path).parent.mkdir(parents=True, exist_ok=True) f = open(canonical_path, 'wb') file_handle = OPNsenseSFTPHandle(flags, self) file_handle.filename = canonical_path file_handle.readfile = None file_handle.writefile = f return file_handle else: if not os.path.exists(canonical_path): logger.error(f"open: file does not exist: {canonical_path}") return paramiko.SFTP_NO_SUCH_FILE if os.path.isdir(canonical_path): logger.error(f"open: path is directory: {canonical_path}") return paramiko.SFTP_FAILURE f = open(canonical_path, 'rb') file_handle = OPNsenseSFTPHandle(flags, self) file_handle.filename = canonical_path file_handle.readfile = f file_handle.writefile = None return file_handle except OSError as e: logger.error(f"Error opening file {canonical_path}: {e}") if e.errno == 2: # No such file or directory return paramiko.SFTP_NO_SUCH_FILE return paramiko.SFTP_FAILURE except Exception as e: logger.error(f"Unexpected error opening file {canonical_path}: {e}", exc_info=True) return paramiko.SFTP_FAILURE def close(self, handle): """Close file handle. Note: Paramiko calls handle.close() directly, so the backup recording is handled in OPNsenseSFTPHandle.close(). This method is kept for compatibility but shouldn't be called for file handles. """ # The actual close logic is in OPNsenseSFTPHandle.close() return paramiko.SFTP_OK def list_folder(self, path): """List folder contents.""" canonical_path = self._canonicalize(path) if not canonical_path: logger.warning(f"list_folder: canonicalization failed") return [] if not os.path.exists(canonical_path): try: os.makedirs(canonical_path, mode=0o755, exist_ok=True) except Exception as e: logger.error(f"Error creating directory: {e}") return [] if not os.path.isdir(canonical_path): logger.warning(f"list_folder: path is not a directory: {canonical_path}") return [] try: files = [] for item in os.listdir(canonical_path): item_path = os.path.join(canonical_path, item) stat = os.stat(item_path) attr = paramiko.SFTPAttributes.from_stat(stat) attr.filename = item files.append(attr) return files except Exception as e: logger.error(f"Error listing folder: {e}", exc_info=True) return [] def remove(self, path): """Remove a file.""" canonical_path = self._canonicalize(path) if not canonical_path: return paramiko.SFTP_NO_SUCH_FILE try: os.remove(canonical_path) logger.info(f"Deleted file: {Path(canonical_path).name}") return paramiko.SFTP_OK except OSError as e: logger.error(f"Error removing file: {e}") if e.errno == 2: # No such file or directory return paramiko.SFTP_NO_SUCH_FILE return paramiko.SFTP_FAILURE except Exception as e: logger.error(f"Unexpected error removing file: {e}") return paramiko.SFTP_FAILURE class OPNsenseSFTPServer(SFTPServer): """Custom SFTP server that uses OPNsenseSFTPServerInterface.""" def __init__(self, channel, name, server, *args, **kwargs): """Initialize SFTP server. Args: channel: SSH channel name: Subsystem name server: OPNsenseServerInterface instance (ServerInterface) *args, **kwargs: Additional arguments passed through """ # Pass OPNsenseSFTPServerInterface as sftp_si parameter # This tells SFTPServer to use our SFTP interface for handling operations super().__init__(channel, name, server, sftp_si=OPNsenseSFTPServerInterface, *args, **kwargs) class SFTPThreadedServer: """Threaded SFTP server that runs in background.""" def __init__(self, host: str = "0.0.0.0", port: int = 2222, database: Optional[Database] = None, ssh_key_manager: Optional[SSHKeyManager] = None, backups_dir: str = "backups"): """Initialize threaded SFTP server. Args: host: Host to bind to port: Port to listen on database: Database instance ssh_key_manager: SSH key manager instance backups_dir: Directory for backups """ self.host = host self.port = port self.database = database or Database() self.ssh_key_manager = ssh_key_manager or SSHKeyManager() self.backups_dir = backups_dir self.server_socket = None self.thread = None self.running = False def _handle_client(self, client, addr): """Handle individual client connection.""" try: transport = paramiko.Transport(client) # Create server instance server_interface = OPNsenseServerInterface( self.database, self.ssh_key_manager, self.backups_dir ) # Load or generate host key keys_dir = Path("keys") keys_dir.mkdir(exist_ok=True, mode=0o700) host_key_path = keys_dir / "host_key" if not host_key_path.exists(): key = paramiko.RSAKey.generate(2048) key.write_private_key_file(str(host_key_path)) os.chmod(host_key_path, 0o600) logger.info("Generated new SSH host key") host_key = paramiko.RSAKey.from_private_key_file(str(host_key_path)) transport.add_server_key(host_key) # Set SFTP subsystem - use a factory function to create OPNsenseSFTPServer def create_sftp_server(channel, name, server): return OPNsenseSFTPServer(channel, name, server) transport.set_subsystem_handler('sftp', create_sftp_server) # Start server transport.start_server(server=server_interface) # Accept connection channel = transport.accept(20) if channel is None: logger.warning(f"Client {addr} connection timeout") transport.close() return instance_id = server_interface.current_instance['identifier'] if server_interface.current_instance else 'unknown' logger.info(f"SFTP client connected from {addr[0]}:{addr[1]} as {instance_id}") # Keep connection alive while transport.is_active(): import time time.sleep(1) transport.close() logger.info(f"SFTP client {addr[0]}:{addr[1]} disconnected") except Exception as e: logger.error(f"Error handling SFTP client {addr}: {e}") def start(self): """Start the SFTP server in a background thread.""" if self.running: logger.warning("SFTP server already running") return try: import socket self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.server_socket.bind((self.host, self.port)) self.server_socket.listen(10) self.running = True def server_loop(): logger.info(f"SFTP server started on {self.host}:{self.port}") while self.running: try: client, addr = self.server_socket.accept() client_thread = threading.Thread( target=self._handle_client, args=(client, addr), daemon=True ) client_thread.start() except Exception as e: if self.running: logger.error(f"Error accepting SFTP connection: {e}") self.thread = threading.Thread(target=server_loop, daemon=True) self.thread.start() except Exception as e: logger.error(f"Error starting SFTP server: {e}") self.running = False raise def stop(self): """Stop the SFTP server.""" self.running = False if self.server_socket: try: self.server_socket.close() except: pass logger.info("SFTP server stopped")