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

Etiquetas:

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.


Equipamiento recomendado

Enlaces de afiliado. Sin coste extra para ti.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *