Ir al contenido

Sincronización incremental desde SMB con smbprotocol en Linux: autenticación NTLM y control de logs

Rogelio Guerra Riverón
Autor
Rogelio Guerra Riverón
Construyendo mi propia infraestructura web desde cero. Aquí documento cada paso: servidores, redes, contenedores y lo que vaya surgiendo.

El problema
#

Hace poco necesitaba sincronizar un share SMB desde un NAS con mi servidor Linux. La solución obvia sería smbclient o mount -t cifs, pero quería:

  1. Sincronización incremental (solo archivos nuevos o modificados)
  2. Detectar archivos eliminados del share
  3. Controlar la autenticación NTLM directamente desde código
  4. Silenciar la cantidad obscena de logs que suelta smbprotocol

La librería smbprotocol de Python resolvía todo esto, pero no está documentado cómo hacerlo bien. Aquí está mi solución.

Setup inicial
#

Instala las dependencias:

pip install smbprotocol sqlalchemy pydantic python-dotenv

La idea base: mantener una BD SQLite con un registro de todos los ficheros sincronizados (nombre, hash MD5, timestamp). Cada ejecución compara el share actual con la BD y procesa solo cambios.

Silenciar los logs de smbprotocol
#

Esto es crítico. Sin controlarlo, la librería te llena la consola de mensajes de depuración:

import logging

# Silenciar smbprotocol
logging.getLogger('smbprotocol').setLevel(logging.WARNING)
logging.getLogger('smbprotocol.connection').setLevel(logging.WARNING)
logging.getLogger('smbprotocol.session').setLevel(logging.WARNING)

# Tu logger
logger = logging.getLogger('sync_smb')
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
logger.addHandler(handler)

Esto reduce los logs a lo razonable. Sin esto, cada operación genera 50 líneas de basura.

Estructura de la BD SQLite
#

from datetime import datetime
from sqlalchemy import create_engine, Column, String, DateTime, Integer
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

Base = declarative_base()

class SyncedFile(Base):
    __tablename__ = 'synced_files'
    
    filename = Column(String, primary_key=True)
    md5_hash = Column(String)
    file_size = Column(Integer)
    last_modified = Column(DateTime)
    sync_timestamp = Column(DateTime, default=datetime.utcnow)

engine = create_engine('sqlite:///smb_sync.db')
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)

Conexión con NTLM
#

from smbprotocol.session import Session
from smbprotocol.tree import TreeConnect
import socket

username = "DOMINIO\\usuario"  # Formato NETBIOS\usuario
password = "contraseña"
host = "192.168.x.x"
share = "compartido"

# Conexión básica
connection = smbprotocol.connection.Connection(
    uuid.uuid4(),
    host,
    445,
)
connection.connect()

session = Session(connection, username, password)
session.connect()

tree = TreeConnect(session, f"\\\\{host}\\{share}")
tree.connect()

NTLM se negocia automáticamente. No necesitas hacer nada especial, pero asegúrate de usar el formato DOMINIO\usuario correcto.

Sincronización incremental
#

import hashlib
from pathlib import Path

def get_file_hash(file_data):
    """Calcula MD5 de contenido en bytes"""
    return hashlib.md5(file_data).hexdigest()

def sync_smb_share(local_path: Path):
    session = Session()
    remote_files = {}
    
    # Listar archivos del share
    directory = tree.open_file(share, FileAttributes.DIRECTORY, CreateOptions.FILE_DIRECTORY_FILE)
    
    for file_info in directory.query_directory():
        if file_info.file_attributes & FileAttributes.DIRECTORY:
            continue  # Ignorar carpetas por ahora
        
        filename = file_info.file_name
        remote_files[filename] = {
            'size': file_info.end_of_file,
            'modified': file_info.change_time.timestamp()
        }
    
    # Leer archivos nuevos o modificados
    local_db = session.query(SyncedFile).all()
    local_files = {f.filename: f for f in local_db}
    
    for filename, info in remote_files.items():
        # Nuevo o modificado
        if filename not in local_files or local_files[filename].file_size != info['size']:
            logger.info(f"Descargando: {filename}")
            
            file_obj = tree.open_file(filename)
            content = b""
            for chunk in file_obj:
                content += chunk
            
            md5 = get_file_hash(content)
            (local_path / filename).write_bytes(content)
            
            # Actualizar BD
            sync_record = local_files.get(filename) or SyncedFile()
            sync_record.filename = filename
            sync_record.md5_hash = md5
            sync_record.file_size = info['size']
            sync_record.last_modified = datetime.fromtimestamp(info['modified'])
            
            session.merge(sync_record)
            session.commit()
    
    # Detectar eliminados
    for filename in local_files:
        if filename not in remote_files:
            logger.warning(f"Archivo eliminado en remoto: {filename}")
            (local_path / filename).unlink(missing_ok=True)
            session.query(SyncedFile).filter_by(filename=filename).delete()
            session.commit()
    
    tree.close()
    session.close()

if __name__ == "__main__":
    sync_smb_share(Path("/mnt/sync"))

Automatización con cron
#

0 */4 * * * /usr/bin/python3 /opt/sync_smb/sync.py >> /var/log/smb_sync.log 2>&1

Esto sincroniza cada 4 horas.

Conclusión
#

Con este setup procesas solo cambios, controlas la autenticación NTLM sin trucos raros, y tienes logs legibles. La BD SQLite es eficiente incluso con miles de archivos.

He usado esto en producción durante meses sin problemas.