Aller au contenu

Synchronisation incrémentale depuis SMB avec smbprotocol sur Linux : authentification NTLM et contrôle des logs

Rogelio Guerra Riverón
Auteur
Rogelio Guerra Riverón
Construction de ma propre infrastructure web depuis zéro. Je documente chaque étape : serveurs, réseaux, conteneurs et tout ce qui se présente.

Le problème
#

J’avais récemment besoin de synchroniser un partage SMB depuis un NAS avec mon serveur Linux. La solution évidente serait smbclient ou mount -t cifs, mais je voulais :

  1. Synchronisation incrémentale (uniquement les fichiers nouveaux ou modifiés)
  2. Détecter les fichiers supprimés du partage
  3. Contrôler l’authentification NTLM directement depuis le code
  4. Réduire la quantité obscène de logs que génère smbprotocol

La librairie smbprotocol de Python résolvait tout cela, mais il n’y a pas de documentation sur comment bien le faire. Voici ma solution.

Configuration initiale
#

Installe les dépendances :

pip install smbprotocol sqlalchemy pydantic python-dotenv

L’idée de base : maintenir une base de données SQLite avec un enregistrement de tous les fichiers synchronisés (nom, hash MD5, timestamp). À chaque exécution, on compare le partage actuel avec la base de données et on traite uniquement les changements.

Réduire les logs de smbprotocol
#

C’est critique. Sans le contrôler, la librairie remplit ta console de messages de débogage :

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)

Cela réduit les logs à un niveau raisonnable. Sans cela, chaque opération génère 50 lignes de charabia.

Structure de la base de données 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)

Connexion avec 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 est négocié automatiquement. Tu n’as rien de spécial à faire, mais assure-toi d’utiliser le format DOMINIO\usuario correct.

Synchronisation incrémentale
#

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"))

Automatisation avec cron
#

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

Cela synchronise toutes les 4 heures.

Conclusion
#

Avec cette configuration, tu traites uniquement les changements, tu contrôles l’authentification NTLM sans trucs louches, et tu as des logs lisibles. La base de données SQLite est efficace même avec des milliers de fichiers.

J’ai utilisé ce setup en production pendant des mois sans problèmes.


Équipement recommandé
#

Liens d’affiliation. Aucun coût supplémentaire pour toi.