diff --git a/pulse_xmpp_agent/config/backup_restore.ini b/pulse_xmpp_agent/config/backup_restore.ini new file mode 100644 index 000000000..3520ea3c6 --- /dev/null +++ b/pulse_xmpp_agent/config/backup_restore.ini @@ -0,0 +1,22 @@ +[backup_restore] +remote_user = urbackup + +# La clé privée est normalement celle de reverse_ssh. +# La clé publique id_rsa.pub de reverse_ssh doit être inscrite dans /var/urbackup/authorized_keys afin de permettre l'authentification de la machine locale vers le serveur de sauvegarde. +# Sur la machine locale, +# on donne un autre nom à la même clé que id_rsa de reverse_ssh, par exemple pulseuser_backup_id_rsa. +# pulseuser_backup_id_rsa est réinstallée à chaque demande de transfert de sauvegarde en locale. +# Elle est en fait une copie de la clé privée id_rsa de reverse_ssh. +# Cela permet de diminuer le risque de corruption de la copie de la clé en cas d'erreur de plugin. +private_name_key = pulseuser_backup_id_rsa + +username = pulseuser +#on utilise le profil de pulseuser + +# restore_to_backup_location 'True' signifie que les fichiers récupérés seront placés dans le répertoire de sauvegarde : +# C:\Program Files\Medulla\var\backup_files pour Windows +# /var/lib/pulse2/backup_files pour Darwin ou Linux +restore_to_backup_location = True + +# SSH Server machine urbackup port number +ssh_port = 22 diff --git a/pulse_xmpp_agent/lib/networkinfo.py b/pulse_xmpp_agent/lib/networkinfo.py index 4f3d5453a..d9e9783f4 100644 --- a/pulse_xmpp_agent/lib/networkinfo.py +++ b/pulse_xmpp_agent/lib/networkinfo.py @@ -3,6 +3,8 @@ # SPDX-FileCopyrightText: 2016-2023 Siveo # SPDX-License-Identifier: GPL-3.0-or-later +# FILE pulse_xmpp_agent/lib/networkinfo.py + import netifaces import subprocess @@ -26,6 +28,99 @@ logger = logging.getLogger() +def find_common_addresses(list1, list2): + """ + Trouve les adresses IP communes entre deux listes de réseaux CIDR. + + Cette fonction prend deux listes de chaînes de caractères représentant des réseaux CIDR, + les convertit en objets IPv4Network, et recherche les adresses IP communes dans les deux listes. + + Args: + list1 (list of str): Une liste de chaînes de caractères représentant des réseaux CIDR. + list2 (list of str): Une autre liste de chaînes de caractères représentant des réseaux CIDR. + + Returns: + list of str: Une liste d'adresses IP communes entre les deux listes de réseaux CIDR. + """ + # Convertir les chaînes de CIDR en objets IPv4Network + cidr_list1 = [ipaddress.ip_network(cidr) for cidr in list1] + cidr_list2 = [ipaddress.ip_network(cidr) for cidr in list2] + + # Chercher des adresses IP communes dans les deux listes + common_addresses = [] + for net1 in cidr_list1: + for net2 in cidr_list2: + # Vérifier si les réseaux se chevauchent + if net1.overlaps(net2): + # Ajouter l'IP commune à la liste + common_addresses.append(str(net1.network_address)) + + return common_addresses + + +def get_CIDR_ipv4_addresses(exclude_localhost=True): + """ + Récupère les adresses IPv4 au format CIDR pour chaque interface réseau de la machine. + + Cette fonction utilise des commandes spécifiques au système d'exploitation pour obtenir les informations + sur les interfaces réseau et les adresses IPv4. Elle retourne une liste d'adresses IPv4 au format CIDR. + + Args: + exclude_localhost (bool): Si True, exclut les interfaces locales (localhost ou 127.0.0.1). + + Returns: + list: Une liste d'adresses IPv4 au format CIDR (par exemple, '192.168.1.0/24'). + """ + ipv4_addresses = [] + # Vérifier le système d'exploitation + system = platform.system() + if system == "Windows": + # Commande pour obtenir les interfaces réseau sous Windows + output = subprocess.check_output("ipconfig", shell=True).decode() + ip, mask = None, None + for line in output.splitlines(): + if "IPv4 Address" in line or "IPv4" in line: + ip = line.split(":")[-1].strip() + if "Subnet Mask" in line: + mask = line.split(":")[-1].strip() + if ip and mask: + if exclude_localhost and ip == "127.0.0.1": + continue + cidr = ipaddress.IPv4Network( + f"{ip}/{mask}", strict=False + ).with_prefixlen + ipv4_addresses.append(cidr) + ip, mask = None, None + elif system == "Linux": + # Commande pour obtenir les interfaces réseau sous Linux + output = subprocess.check_output("ip addr show", shell=True).decode() + for line in output.splitlines(): + line = line.strip() + if line.startswith("inet "): + parts = line.split() + ip_mask = parts[1] # Exemple : 192.168.1.100/24 + if exclude_localhost and ip_mask.startswith("127.0.0.1/"): + continue + ipv4_addresses.append(ip_mask) + elif system == "Darwin": # macOS est identifié par 'Darwin' + # Commande pour obtenir les interfaces réseau sous macOS + output = subprocess.check_output("ifconfig", shell=True).decode() + current_ip = None + for line in output.splitlines(): + line = line.strip() + if line.startswith("inet ") and "127.0.0.1" not in line: # Éviter localhost + parts = line.split() + ip = parts[1] # L'adresse IP est le second champ + mask = parts[3] # Le masque est généralement le quatrième champ + if exclude_localhost and ip == "127.0.0.1": + continue + cidr = ipaddress.IPv4Network( + f"{ip}/{mask}", strict=False + ).with_prefixlen + ipv4_addresses.append(cidr) + return ipv4_addresses + + class networkagentinfo: def __init__(self, sessionid, action="resultgetinfo", param=[]): self.sessionid = sessionid diff --git a/pulse_xmpp_agent/plugins_common/plugin_backup_restore.py b/pulse_xmpp_agent/plugins_common/plugin_backup_restore.py new file mode 100644 index 000000000..d9ea4f26f --- /dev/null +++ b/pulse_xmpp_agent/plugins_common/plugin_backup_restore.py @@ -0,0 +1,572 @@ +# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: 2016-2023 Siveo +# SPDX-License-Identifier: GPL-3.0-or-later + +# file: pulse_xmpp_agent/[pluginsrelay | pluginsmachine]/plugin_backup_restore.py +# file: pulse_xmpp_agent/pluginsmachine/plugin_backup_restore.py + +import logging +import traceback +from threading import Condition +import os +import sys + +import psutil +import socket +import ipaddress + +import hashlib + +from lib.agentconffile import ( + directoryconffile, +) + +# from lib.networkinfo import find_common_addresses, get_CIDR_ipv4_addresses +import subprocess +from lib.utils import ( + pulseuser_useraccount_mustexist, + pulseuser_profile_mustexist, + create_idrsa_on_client, + getHomedrive, + simplecommand, +) +import platform +import configparser +import json +from pathlib import PurePosixPath, Path + +if sys.platform.startswith("linux") or sys.platform.startswith("darwin"): + import pwd + +logger = logging.getLogger() + +remote_host = None + +plugin = {"VERSION": "1.0", "NAME": "backup_restore", "TYPE": "all"} # fmt: skip + +# Verrou pour la synchronisation de la configuration +# config_lock = Lock() + +config_condition = Condition() + + +def action(objectxmpp, action, sessionid, data, message, dataerreur): + """ + Fonction principale du plugin "backup_restore" qui gère l'action de restauration. + + Cette fonction est appelée par le système XMPP avec les paramètres appropriés pour effectuer des + actions spécifiques de restauration. Elle vérifie si la configuration du plugin + a déjà été effectuée et attend que cette configuration soit terminée avant d'exécuter ce a quoi le plugin est cree. + + :param objectxmpp: The XMPP object. + :param action: The action to be performed. + :param sessionid: The session ID. + :param data: The data containing IP list, file list, directory list, etc. + :param message: The message object. + :param dataerreur: Error data. + """ + logger.debug("###################################################") + logger.debug("call %s from %s" % (plugin, message["from"])) + logger.debug("###################################################") + # logger.debug("MESSAGE") + logger.debug("%s" % json.dumps(data, indent=4)) + # strjidagent = str(objectxmpp.boundjid.bare) + + compteurcallplugin = getattr(objectxmpp, f"num_call{action}", None) + if compteurcallplugin is None: + logger.error(f"num_call attribute for action {action} not found on objectxmpp") + return + + with config_condition: + # Si c'est le premier appel du plugin, on configure + # tant que pas configurer les concurant attentent pour profiter de la conf aussi. + # et pas provoquer des erreurs par manque de configuration + if compteurcallplugin == 0: + if ( + not hasattr(objectxmpp, "configuration_done") + or not objectxmpp.configuration_done + ): + logger.debug("Starting initial configuration") + read_conf_plugin_backup_restore(objectxmpp) + objectxmpp.configuration_done = True + config_condition.notify_all() + logger.debug("Configuration done") + else: + # Si la configuration n'est pas encore faite, attendre sa fin + while ( + not hasattr(objectxmpp, "configuration_done") + or not objectxmpp.configuration_done + ): + config_condition.wait() + + # Execution du corps du plugin une fois la configuration terminee + # logger.debug("corp plugin") + + try: + server_ip_networks = data["ip_list"] + client_ip_networks = get_ip_and_netmask_linux() + remote_host = find_best_server_address(server_ip_networks, client_ip_networks) + if not remote_host: + logger.error("le serveur urbackup n'est pas dans le reseau de la machine.") + logger.error("SERVEUR IP : %s", server_ip_networks) + logger.error("CLIENT IP : %s", client_ip_networks) + return + except Exception as e: + logger.error("termine plugin %s" % (traceback.format_exc())) + return + logger.debug("IP serveur CONNECT. %s" % remote_host) + + try: + # Make sure user account and profile exists + result, message = pulseuser_useraccount_mustexist(objectxmpp.username) + if result is False: + logger.error(f"{message}") + return + logger.debug(f"{message}") + result, message = pulseuser_profile_mustexist(objectxmpp.username) + if result is False: + logger.error(f"{message}") + return + logger.debug(f"{message}") + result, message = create_idrsa_on_client( + objectxmpp.username, data["key_private"] + ) + if result is False: + logger.error(f"{message}") + return + logger.debug(f"{message}") + except Exception as e: + logger.error(f"{e}") + return + + id_rsa_path = os.path.join(getHomedrive(), ".ssh", objectxmpp.private_name_key) + + copy_files_and_directories( + objectxmpp.remote_user, + remote_host, + data["filelist"], + data["directorylist"], + data["base_path"], + private_key_path=id_rsa_path, + restore_to_backup_location=objectxmpp.restore_to_backup_location, + ssh_port=objectxmpp.reverseserver_ssh_port, + ) + + +def read_conf_plugin_backup_restore(objectxmpp): + """ + Reads and loads the configuration for the backup_restore plugin. + If the configuration file `backup_restore.ini` does not exist, it is created with default values. + + :param objectxmpp: The XMPP object to which the configuration values will be assigned. + :type objectxmpp: object + """ + try: + # Vérification de l'agenttype pour déterminer le fichier de configuration + if objectxmpp.config.agenttype in ["machine"]: + configfilename = os.path.join(directoryconffile(), "backup_restore.ini") + elif objectxmpp.config.agenttype in ["relayserver"]: + configfilename = os.path.join(directoryconffile(), "backup_restore.ini") + else: + logger.error( + "The %s agenttype is not supported in this function, it must be machine or relayserver." + % objectxmpp.config.agenttype + ) + return + + Config = configparser.ConfigParser() + + # Si le fichier de configuration n'existe pas, le créer avec un contenu par défaut + if not os.path.isfile(configfilename): + logger.warning( + f"Configuration file {configfilename} not found. Creating it..." + ) + + config_content = """[backup_restore] +remote_user = urbackup + +# La clé privée est normalement celle de reverse_ssh. +# La clé publique id_rsa.pub de reverse_ssh doit être inscrite dans /var/urbackup/authorized_keys afin de permettre l'authentification de la machine locale vers le serveur de sauvegarde. +# Sur la machine locale, +# on donne un autre nom à la même clé que id_rsa de reverse_ssh, par exemple pulseuser_backup_id_rsa. +# pulseuser_backup_id_rsa est réinstallée à chaque demande de transfert de sauvegarde en locale. +# Elle est en fait une copie de la clé privée id_rsa de reverse_ssh. +# Cela permet de diminuer le risque de corruption de la copie de la clé en cas d'erreur de plugin. + +private_name_key = pulseuser_backup_id_rsa + +username = pulseuser +# on utilise le profil de pulseuser pour inscrire la clef privet + +# restore_to_backup_location 'True' signifie que les fichiers récupérés seront placés dans le répertoire de sauvegarde : +# C:\\Program Files\\Medulla\\var\\backup_files pour Windows +# /var/lib/pulse2/backup_files pour Darwin ou Linux +restore_to_backup_location = True + +# SSH Server machine urbackup port number +# Reverse SSH Server port number +# reverseserver_ssh_port = 22 + +""" + # Écrire le fichier de configuration avec le contenu par défaut + with open(configfilename, "w") as configfile: + configfile.write(config_content) + + logger.info( + f"Configuration file {configfilename} has been created with default content." + ) + + # Charger les valeurs du fichier de configuration + Config.read(configfilename) + if os.path.isfile(configfilename + ".local"): + Config.read(configfilename + ".local") + logger.debug(f"Loaded configuration from {configfilename}") + + # Récupérer les valeurs de configuration ou les valeurs par défaut + objectxmpp.remote_user = Config.get( + "backup_restore", "remote_user", fallback="urbackup" + ) + objectxmpp.private_name_key = Config.get( + "backup_restore", "private_name_key", fallback="pulseuser_backup_id_rsa" + ) + objectxmpp.username = Config.get( + "backup_restore", "username", fallback="pulseuser" + ) + objectxmpp.restore_to_backup_location = Config.getboolean( + "backup_restore", "restore_to_backup_location", fallback=True + ) + objectxmpp.reverseserver_ssh_port = Config.getint( + "backup_restore", "reverseserver_ssh_port", fallback=22 + ) + logger.debug( + f"Configuration values set: remote_user={objectxmpp.remote_user}, " + f"private_name_key={objectxmpp.private_name_key}, " + f"username={objectxmpp.username}, " + f"restore_to_backup_location={objectxmpp.restore_to_backup_location}" + ) + + except Exception as e: + logger.error(f"Error reading or creating configuration: {str(e)}") + logger.error("\n%s" % (traceback.format_exc())) + + +def copy_files_and_directories( + remote_user, + remote_host, + files, + directories, + base_path, + private_key_path=None, + restore_to_backup_location=True, + ssh_port=22, +): + """ + Copies files and directories from a remote host to the local machine. + + :param remote_user: The remote user for SSH. + :param remote_host: The remote host. + :param files: List of files to copy. + :param directories: List of directories to copy. + :param base_path: The base path for the backup. + :param private_key_path: The path to the private key for SSH. + :param restore_to_backup_location: Whether to restore files to the backup location. + :param ssh_port: port number ssh server. + """ + if private_key_path is None: + private_key_path = os.path.join(getHomedrive(), ".ssh", "id_rsa") + + if restore_to_backup_location: + # on etablie la base ou vont etre mis les backups recuperer. + # rappel que ce plugin et pour recuperer des fichiers sur 1 autre machine. + # il y a trop de risque de placer les fichier directement a l'emplacement de depart. + # mais la hierachi est respecter depuis cette basse backup_path + backup_path = get_backup_path() + + # Determiner le systeme d'exploitation + system = platform.system() + # Chemins des executables + if system == "Windows": + rsync_path = r"C:\Windows\SysWOW64\rsync.exe" + ssh_path = r"C:\Progra~1\OpenSSH\ssh.exe" + scp_path = r"C:\Progra~1\OpenSSH\scp.exe" + else: # Linux + rsync_path = "rsync" + ssh_path = "ssh" + scp_path = "scp" + # Verifier si rsync est disponible que pour windows + + # cette ligne sera a decomenter + rsync_available = False + # rsync_available = os.path.exists(rsync_path) if system == 'Windows' else True + + # quand on poura ectire la commande rsync en windows. avec clef ssh + # C:\Windows\SysWOW64\rsync.exe -L -z --rsync-path=rsync + # -e "C:/Program Files/OpenSSH/ssh.exe + # -o IdentityFile=c:/users/pulseuser/.ssh/pulseuser_backup_id_rsa + # -o UserKnownHostsFile=/dev/null + # -o StrictHostKeyChecking=no + # -o Batchmode=yes + # -o PasswordAuthentication=no + # -o ServerAliveInterval=10 + # -o CheckHostIP=no + # -o LogLevel=ERROR + # -o ConnectTimeout=10 + # -p 2222" + # -av --chmod=777 + # urbackup@10.10.0.100:/media/BACKUP/urbackup/amu-win-6/240916-1727/Users/desktop.ini + # "C:/Program Files/Medulla/var/backup_files/C_0/Users/desktop.ini + + cmd = ( + """%s -r -p -C -P %s "-o IdentityFile=%s" "-o UserKnownHostsFile=/dev/null" "-o StrictHostKeyChecking=no" "-o Batchmode=yes" "-o PasswordAuthentication=no" "-o ServerAliveInterval=10" "-o CheckHostIP=no" "-o LogLevel=ERROR" "-o ConnectTimeout=10" """ + % ( + scp_path, + ssh_port, + private_key_path, + ) + ) + if rsync_available: + cmd = ( + """%s -L -z --rsync-path=rsync -e "%s -o IdentityFile=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o Batchmode=yes -o PasswordAuthentication=no -o ServerAliveInterval=10 -o CheckHostIP=no -o LogLevel=ERROR -o ConnectTimeout=10 -p %s" -av --chmod=777 """ + % (rsync_path, ssh_path, private_key_path, ssh_port) + ) + + # Copier les fichiers + for src, dest in files: + # le serveur urbackup est sur linux on determine depuis 1 machine windows le chemin dans linux + # Utiliser PurePosixPath pour construire un chemin specifique e Linux + logger.debug(f"base_path: {base_path}") + if restore_to_backup_location: + modified_path_dest = modify_backup_path(backup_path, dest) + else: + modified_path_dest = dest + linux_path = PurePosixPath(base_path) / src + # Convertir le chemin en une chaine de caracteres + srcfile = str(linux_path) + logger.debug(f"srcfile: {srcfile}") + remotesrc = """%s@%s:%s """ % (remote_user, remote_host, srcfile) + # remotesrc = "urbackup@10.10.0.100:/media/BACKUP/urbackup/amu-win-6/240916-1727/Users/desktop.ini" + create_directories(get_directory_path(modified_path_dest)) + # dest = ' C:/Users/blablabla.txt' + command = cmd + remotesrc + '"' + modified_path_dest + '"' + + # command = cmd + remotesrc + modified_path_dest + logger.debug(f"Command: {command}") + obj = simplecommand(command) + logger.warning(f"Transfer file : {obj['code']} {obj['result']}") + + if obj["code"] == 1: + logger.warning(f"Transfer mais link non creer : { obj['code']}") + logger.warning(f"nb link {len(obj['result'])}") + logger.warning("liste link") + for linelogresult in obj["result"]: + logger.warning("{ linelogresult.strip()}") + elif obj["code"] != 0: + logger.error(f"Transfer error: { obj['code']}") + logger.error(f"Transfer error: sur nb file : {len(obj['result'])}") + + else: + if len(obj["result"]) > 0: + for linelogresult in obj["result"]: + logger.debug("Transfer successful") + logger.debug("{ linelogresult.strip()}") + else: + logger.warning("Transfer successful") + + # Copier les repertoires + for src, dest in directories: + # le serveur urbackup est sur linux on determine depuis 1 machine windows le chemin dans linux + # Utiliser PurePosixPath pour construire un chemin specifique e Linux + logger.debug(f"src dest: {src} {dest}") + logger.debug(f"base_path: {base_path}") + if restore_to_backup_location: + modified_path_dest = modify_backup_path(backup_path, dest) + else: + modified_path_dest = dest + logger.debug(f"dest: {modified_path_dest}") + linux_path = PurePosixPath(base_path) / src + # Convertir le chemin en une chaene de caracteres + srcdirectory = str(linux_path) + logger.debug(f"srcdirectory: {srcdirectory}") + remotesrc = """%s@%s:"%s" """ % (remote_user, remote_host, srcdirectory) + # create_directories(modified_path_dest) + create_directories(get_directory_path(modified_path_dest)) + command = cmd + remotesrc + '"' + modified_path_dest + '"' + logger.debug(f"Command: {command}") + obj = simplecommand(command) + if rsync_available: + if obj["code"] != 0: + logger.error(f"Transfer error code error: { obj['code']}") + logger.error(f"error message : {obj['result']}") + else: + logger.debug("Transfer successful") + else: + if obj["code"] == 1: + logger.warning(f"Transfer mais link non creer : { obj['code']}") + logger.warning(f"nb link {len(obj['result'])}") + logger.warning("liste link from serveur %s" % base_path) + if restore_to_backup_location: + logger.warning("to %s" % backup_path) + backup_path_slach_linux = str(backup_path).replace("\\", "/") + logger.warning("to %s" % backup_path_slach_linux) + stringmessage = scp_path + ": Download of file " + base_path + # Remplacement de stringmessage dans chaque élément de la liste + for linelogresult in obj["result"]: + linestr = linelogresult.replace(stringmessage, "").strip() + # if restore_to_backup_location: + linestr1 = linestr.replace(backup_path_slach_linux, "") + linestr1 = linestr1.replace(str(scp_path), "") + logger.warning(f"{linestr1}") + elif obj["code"] != 0: + logger.error(f"Transfer error: { obj['code']}") + logger.error(f"Transfer error: sur nb file : {len(obj['result'])}") + else: + if len(obj["result"]) > 0: + for linelogresult in obj["result"]: + logger.debug("Transfer successful") + logger.debug(f"{linelogresult.strip()}") + else: + logger.warning("Transfer successful") + + +def get_ip_and_netmask_linux(exclude_local=True): + """ + Retrieves the IP addresses and netmasks of the local machine. + + :param exclude_local: Whether to exclude local addresses. + :type exclude_local: bool + :return: List of tuples containing IP addresses and netmasks. + :rtype: list of tuples + """ + ip_netmask_list = [] + # Parcours de toutes les interfaces reseau + for interface, addrs in psutil.net_if_addrs().items(): + for addr in addrs: + if addr.family == socket.AF_INET: # Ne prend que les adresses IPv4 + ip = addr.address + netmask = addr.netmask + + # Exclure les adresses locales si exclude_local est True + if exclude_local: + ip_obj = ipaddress.ip_address(ip) + if ip_obj.is_loopback or ip_obj.is_link_local: + continue # On passe e l'iteration suivante si c'est une adresse locale + ip_netmask_list.append((ip, netmask)) + return ip_netmask_list + + +def find_best_server_address(server_addresses, client_addresses): + """ + Finds the best server address that is in the same network as the client. + + :param server_addresses: List of server IP addresses and netmasks. + :type server_addresses: list of tuples + :param client_addresses: List of client IP addresses and netmasks. + :type client_addresses: list of tuples + :return: The best server IP address or None if no common network is found. + :rtype: str or None + """ + for client_ip, client_netmask in client_addresses: + client_network = ipaddress.IPv4Network( + f"{client_ip}/{client_netmask}", strict=False + ) + for server_ip, server_netmask in server_addresses: + server_network = ipaddress.IPv4Network( + f"{server_ip}/{server_netmask}", strict=False + ) + # Verifie si le client et le serveur sont dans le meme reseau + if client_network.overlaps(server_network): + return server_ip # Retourne l'adresse du serveur dans le meme reseau que le client + return None # Aucun reseau commun trouve + + +def get_backup_path(): + """ + Determines the backup path based on the operating system. + + :return: The backup path. + :rtype: Path + """ + # Déterminer le système d'exploitation + system = platform.system() + + # Définir le chemin du répertoire de sauvegarde en fonction du système d'exploitation + if system == "Windows": + backup_path = Path("C:/Program Files/Medulla/var/backup_files") + elif system in ["Linux", "Darwin"]: + backup_path = Path("/var/lib/pulse2/backup_files") + else: + raise OSError(f"Système d'exploitation non pris en charge: {system}") + + # Créer le répertoire s'il n'existe pas + backup_path.mkdir(parents=True, exist_ok=True) + + return backup_path + + +def modify_backup_path(base_dir, restore_path): + """ + Modifies the backup path to include the drive letter. + + :param base_dir: The base directory for the backup. + :param restore_path: The restore path. + :return: The modified backup path. + :rtype: str + """ + # Convertir le chemin de restauration en chemin absolu + drive_letter = restore_path.replace(":", "_0") + modified_path = os.path.join(base_dir, drive_letter) + modified_path = modified_path.replace("\\", "/") + return modified_path + + +def create_directories(path): + """ + Creates the necessary directories. + + :param path: The path to create directories for. + :type path: str + """ + # Créer les répertoires nécessaires + os.makedirs(path, exist_ok=True) + + +def get_directory_path(file_path): + """ + Gets the directory path from a file path. + + :param file_path: The file path. + :type file_path: str + :return: The directory path. + :rtype: str + """ + # Récupérer le chemin du répertoire sans le nom du fichier + directory_path = os.path.dirname(file_path) + return directory_path + + +class FileHasher: + """ + A class to calculate the MD5 hash of a file. + """ + + def __init__(self, file_path): + """ + Initializes the FileHasher with the file path. + + :param file_path: The path to the file. + :type file_path: str + """ + self.file_path = file_path + + def calculate_hash(self): + """ + Calculates the MD5 hash of the file. + + :return: The MD5 hash of the file. + :rtype: str + """ + hash_md5 = hashlib.md5() + with open(self.file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() diff --git a/pulse_xmpp_agent/plugins_common/plugin_recombine_file.py b/pulse_xmpp_agent/plugins_common/plugin_recombine_file.py new file mode 100644 index 000000000..dcf5e3528 --- /dev/null +++ b/pulse_xmpp_agent/plugins_common/plugin_recombine_file.py @@ -0,0 +1,496 @@ +# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: 2022-2023 Siveo +# SPDX-License-Identifier: GPL-3.0-or-later +# file plugin_recombine_file.py + +import logging +import json +import platform +import os +import traceback +import hashlib +from datetime import datetime +import zipfile +import uuid +import time +import re +import base64 +import shutil + +from lib.utils import set_logging_level + +plugin = {"VERSION": "1.0", "NAME": "recombine_file", "TYPE": "all"} # fmt: skip + +logger = logging.getLogger() + + +@set_logging_level +def action(objectxmpp, action, sessionid, data, message, dataerreur): + logger.debug("###################################################") + logger.debug("call %s from %s session id %s" % (plugin, message["from"], sessionid)) + logger.debug("###################################################") + logger.debug("json entree %s" % json.dumps(data, indent=4)) + + # verification des clefs + if not verifier_cles_non_vides( + data, + [ + "namefile", + "directory", + "type", + "contenttype", + "segment", + "dir_uuid_machine", + "dir_segment", + ], + ): + logger.error("message error recombine_file") + return + + if data["contenttype"].lower().startswith("package"): + package = data["location"] + data["type"] = "location" + data["contenttype"] = "directory" + if platform.system() in ["Linux", "Darwin"]: + data["location"] = os.path.join("/var/lib/pulse2/packages", package) + elif platform.system() == "Windows": + data["location"] = os.path.join( + r"C:\Program Files\Pulse\var\tmp\packages", package + ) + + # on veriy les types de transfert + if platform.system() in ["Linux", "Darwin"]: + if data["type"].lower().startswith("backup"): + directory_path = "/var/lib/pulse2/zip_transfert" + elif data["type"].lower().startswith("packages"): + directory_path = "/var/lib/pulse2/packages/" + elif data["type"].lower().startswith("location"): + if not verifier_cles_non_vides(data, ["location"]): + logger.error("type location. la location n'est pas transmise") + return + elif not chemin_valide(data["location"]): + logger.error( + "location n'est pas 1 chemin correct %s" % data["location"] + ) + return + directory_path = os.path.dirname(data["location"]) + namefileresult = os.path.basename(data["location"]) + else: + logger.error( + "type transfert incorect, le type doit etre dans la liste [ 'backup', 'packages', 'location' ]" + ) + return + tmp_dir = "/tmp" + elif platform.system() == "Windows": + if data["type"].lower().startswith("backup"): + directory_path = r"C:\Program Files\Pulse\var\zip_transfert" + elif data["type"].lower().startswith("packages"): + directory_path = r"C:\Program Files\Pulse\var\tmp\packages" + elif data["type"].lower().startswith("location"): + if not verifier_cles_non_vides(data, ["location"]): + logger.error("type location. la location n'est pas transmise") + return + elif not chemin_valide(data["location"]): + logger.error( + "location n'est pas 1 chemin correct %s" % data["location"] + ) + return + if data["contenttype"].lower().startswith("file"): + # on recuperer le repertoire ou le fichier ou doit se trouver + # quand c'est 1 fichier location doit contenir le path et le nom de fichier de reception + directory_path = os.path.dirname(data["location"].replace("/", "\\")) + namefileresult = os.path.basename(data["location"].replace("/", "\\")) + directory_path = directory_path.replace("\\", "/") + else: + directory_path = data["location"] + else: + logger.error( + "type transfert incorect, le type doit etre dans la liste [ 'backup', 'packages', 'location' ]" + ) + return + tmp_dir = r"C:\Windows\Temp" + else: + logger.error("os inconue") + return + + if data["type"].lower().startswith("location"): + repertoire_temporaire_reception = os.path.join( + tmp_dir, data["dir_uuid_machine"] + ) + else: + repertoire_temporaire_reception = os.path.join( + tmp_dir, data["dir_uuid_machine"], data["dir_segment"] + ) + + try: + if not os.path.exists(repertoire_temporaire_reception): + # logger.debug("creation repertoire temporaire reception %s " % repertoire_temporaire_reception) + os.makedirs(repertoire_temporaire_reception, exist_ok=True) + + if not os.path.exists(directory_path): + # logger.debug("creation %s " % directory_path) + os.makedirs(directory_path, exist_ok=True) + except Exception: + logger.error("We hit the backtrace \n %s" % traceback.format_exc()) + + # logger.error(f"repertoire_temporaire_reception: {repertoire_temporaire_reception}") + # logger.error(f"directory_path : {directory_path}" ) + + file_transfert = os.path.join(repertoire_temporaire_reception, data["namefile"]) + + if data["segment"] == 0: + # logger.debug("DEBUT TRAITEMENT MANIFEST") + # logger.debug("fichier %s" % json.dumps(data, indent=4 )) + # manifest + # creation des chemin + manifest_file = os.path.join(repertoire_temporaire_reception, "manifeste") + # logger.debug("creation manifeste transfert %s" % manifest_file) + try: + with open(manifest_file, "w") as json_file: + json.dump(data, json_file, indent=4) + except Exception as e: + logger.error( + f"Erreur lors de l'écriture du manifest de transfert en JSON : {e}" + ) + logger.error("We hit the backtrace \n %s" % traceback.format_exc()) + # logger.debug("CREATION FICHIER MANIFESTE %s " % manifest_file ) + + # Création d'un fichier vide + try: + # logger.debug("creation file transfer %s" % file_transfert) + with open(file_transfert, "wb") as file: + pass # Ne fait rien, mais crée le fichier vide + except Exception as e: + logger.error(f"Erreur lors de l'écriture du fichier JSON : {e}") + logger.error("We hit the backtrace \n %s" % traceback.format_exc()) + # logger.debug("FIN TRAITEMENT MANIFEST") + else: + # logger.debug("DEBUT TRAITEMENT SEGMENT") + + if not verifier_cles_non_vides(data, ["nbtotal", "content", "nb", "directory"]): + logger.error("message error TRAITEMENT SEGMENT") + return + try: + # logger.debug("creation file transfer %s" % file_transfert) + + with open(file_transfert, "ab") as file: + file.write(base64.b64decode(data["content"])) # Décodage en Base64 + file_size_bytes = get_file_size(file_transfert) + if file_size_bytes is not None: + file_size_human = convert_size(file_size_bytes) + logger.debug( + f"La taille du fichier est : {file_size_bytes} octets ({file_size_human})" + ) + except Exception as e: + logger.error(f"Erreur lors de l'écriture du fichier JSON : {e}") + logger.error("We hit the backtrace \n %s" % traceback.format_exc()) + + if data["nbtotal"] == data["segment"]: + try: + md5 = md5_hash(file_transfert) + # logger.error("md5 is %s" % md5 ) + # on charge le manifest pour controler le md5 + manifest_file = os.path.join( + repertoire_temporaire_reception, "manifeste" + ) + with open(manifest_file, "r") as json_file: + data_manifest = json.load(json_file) + if data_manifest["md5"] == md5: + logger.debug("transfert archive reussi md5 correct") + else: + logger.error( + "le md5 n'est pas du transfert n'est pas correct abandon et netoyage" + ) + supprimer_repertoire(repertoire_temporaire_reception) + return + except Exception: + logger.error( + "transfert terminer. 1 erreur dans le md5 %s" + % traceback.format_exc() + ) + return + try: + # if data['type'].lower().startswith('location + + # on recupere le repertoire ou decompresser l'archive + name_repertoire_reception = restaurer_caracteres(data["directory"]) + name_repertoire_decompression = re.sub( + r"^[A-Za-z]:", "", name_repertoire_reception + ) + name_repertoire_decompression1 = re.sub( + r"\.zip$", "", name_repertoire_decompression + ) + if data["type"].lower() in ["backup", "packages"]: + name_repertoire_decomp = ( + directory_path.replace("\\", "/") + + name_repertoire_decompression1 + ) + if data["contenttype"].lower().startswith("file"): + name_repertoire_decomp = os.path.dirname(name_repertoire_decomp) + else: + name_repertoire_decomp = directory_path + # logger.debug("directory_path %s" % directory_path) + # logger.debug("il faut faire le traitement de decompression to %s" % name_repertoire_decomp) + + # on cree le repertoire si non exist + if not os.path.exists(name_repertoire_decomp): + # logger.debug("creation %s " % name_repertoire_decomp) + os.makedirs(name_repertoire_decomp, exist_ok=True) + # logger.debug("decompresser_archive file zip %s"%(file_transfert) ) + # logger.debug("decompresser_archive vers repertoire %s"%(name_repertoire_decomp) ) + + if ( + data["contenttype"].lower().startswith("file") + and "location" in data + and data["location"] + ): + # logger.debug("nouveau_nom file %s"%(namefileresult) ) + decompresser_archive( + file_transfert, + name_repertoire_decomp, + nouveau_nom=namefileresult, + ) + else: + decompresser_archive(file_transfert, name_repertoire_decomp) + # logger.error("et on remet a zero le fichier" ) + # with open( file_transfert, 'wb') as file: + # pass + logger.debug("nettoyage repertoire de travail") + supprimer_repertoire(repertoire_temporaire_reception) + except Exception: + logger.error("We hit the backtrace \n %s" % traceback.format_exc()) + + +def md5_hash(file_path): + """ + Calcule le hash MD5 d'un fichier. + + Paramètre: + file_path (str): Le chemin du fichier pour lequel le hash MD5 doit être calculé. + + Retourne: + str: Le hash MD5 sous forme de chaîne hexadécimale. + """ + hash_md5 = hashlib.md5() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + + +def decompresser_archive(fichier_zip, repertoire_destination, nouveau_nom=None): + """ + Décompresse un fichier ZIP dans un répertoire spécifié. Si un nouveau nom est fourni, + le fichier extrait est renommé. + + Paramètres: + fichier_zip (str): Le chemin du fichier ZIP à décompresser. + repertoire_destination (str): Le répertoire dans lequel extraire les fichiers. + nouveau_nom (str, optionnel): Nouveau nom pour le fichier extrait. Si défini, l'archive + doit contenir exactement un fichier. + + Retourne: + bool: Retourne True si la décompression s'est effectuée avec succès, False sinon. + + Exceptions: + FileNotFoundError: Si le fichier ZIP n'existe pas. + ValueError: Si l'archive contient plus d'un fichier lorsque nouveau_nom est spécifié. + """ + try: + # Vérifier si le fichier ZIP existe + if not os.path.exists(fichier_zip): + raise FileNotFoundError(f"Le fichier ZIP {fichier_zip} n'existe pas.") + + # Créer le répertoire de destination s'il n'existe pas déjà + if not os.path.exists(repertoire_destination): + os.makedirs(repertoire_destination) + + # Ouvrir le fichier ZIP + with zipfile.ZipFile(fichier_zip, "r") as zip_ref: + # Vérifier si nouveau_nom est défini + if nouveau_nom: + # Vérifier que l'archive contient exactement un fichier + noms_fichiers = zip_ref.namelist() + if len(noms_fichiers) != 1: + raise ValueError( + "L'archive ZIP doit contenir exactement un fichier." + ) + + # Extraire le fichier + nom_fichier = noms_fichiers[0] + zip_ref.extract(nom_fichier, repertoire_destination) + + # Renommer le fichier extrait + chemin_fichier_extrait = os.path.join( + repertoire_destination, nom_fichier + ) + chemin_fichier_renomme = os.path.join( + repertoire_destination, nouveau_nom + ) + + # Vérifier si le fichier de destination existe déjà + if os.path.exists(chemin_fichier_renomme): + os.remove(chemin_fichier_renomme) # Supprimer le fichier existant + + os.rename(chemin_fichier_extrait, chemin_fichier_renomme) + else: + # Extraire tout le contenu du fichier ZIP + zip_ref.extractall(repertoire_destination) + + return True + except Exception as e: + logger.error(f"Erreur lors de la décompression de l'archive : {e}") + return False + + +def verifier_cles_non_vides(data, cles): + """ + Vérifie si toutes les clés spécifiées existent dans le dictionnaire + et si leurs valeurs ne sont pas None ou vides. + + Args: + data (dict): Le dictionnaire à vérifier. + cles (list): Une liste de clés à vérifier dans le dictionnaire. + + Returns: + bool: True si toutes les clés existent et leurs valeurs ne sont pas vides, sinon False. + """ + logger.debug("verifier_cles_non_vides") + for cle in cles: + if cle not in data: # Vérifie l'existence et la valeur non vide + logger.error("cle missing %s" % cle) + return False + if isinstance(data[cle], int): + continue + if not data[cle]: + return False + return True + + +def get_file_size(file_path): + """ + Retourne la taille d'un fichier en octets. + + Args: + file_path (str): Chemin du fichier. + + Returns: + int: Taille du fichier en octets. + """ + try: + size = os.path.getsize(file_path) + return size + except Exception as e: + logger.error(f"Erreur lors de la récupération de la taille du fichier : {e}") + return None + + +def convert_size(size_bytes): + """ + Convertit une taille en octets en une taille lisible par un humain. + + Args: + size_bytes (int): Taille en octets. + + Returns: + str: Taille lisible par un humain. + """ + if size_bytes is None: + return "Taille inconnue" + + for unit in ["octets", "Ko", "Mo", "Go", "To"]: + if size_bytes < 1024: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024 + + +def remplacer_caracteres(chaine): + """ + Remplace certains caractères spéciaux dans une chaîne par des codes spécifiques. + + Paramètre: + chaine (str): La chaîne à modifier. + + Remplacements effectués: + - '\\' est remplacé par '@@'. + - Les espaces sont remplacés par '@nbsp@'. + - '/' est remplacé par '@47@'. + - '\\' est remplacé par '@92@'. + - '.' est remplacé par '@46@'. + + Retourne: + str: La chaîne modifiée avec les caractères remplacés. + """ + chaine = chaine.replace("\\", "@@") + chaine = chaine.replace(" ", "@nbsp@") + chaine = chaine.replace("/", "@47@") + chaine = chaine.replace("\\", "@92@") + chaine = chaine.replace(".", "@46@") + return chaine + + +def restaurer_caracteres(chaine): + """ + Restaure les caractères spéciaux dans une chaîne modifiée. + + Paramètre: + chaine (str): La chaîne contenant les codes à restaurer. + + Remplacements effectués: + - '@58@' est remplacé par ':'. + - '@46@' est remplacé par '.'. + - '@92@' est remplacé par '\\'. + - '@47@' est remplacé par '/'. + - '@nbsp@' est remplacé par un espace. + + Retourne: + str: La chaîne restaurée avec les caractères originaux. + """ + chaine = chaine.replace("@58@", ":") + chaine = chaine.replace("@46@", ".") + chaine = chaine.replace("@92@", "\\") + chaine = chaine.replace("@47@", "/") + chaine = chaine.replace("@nbsp@", " ") + return chaine + + +def chemin_valide(chemin): + """ + Vérifie si un chemin est valide en essayant de le normaliser et de l'obtenir en chemin absolu. + + Paramètre: + chemin (str): Le chemin à vérifier. + + Retourne: + bool: True si le chemin est valide, False sinon. + """ + try: + # Utilise os.path.normpath pour normaliser le chemin + # et os.path.abspath pour obtenir un chemin absolu + chemin_normalise = os.path.abspath(chemin) + return True + except Exception as e: + return False + + +def supprimer_repertoire(repertoire): + """ + Supprime un répertoire ainsi que tout son contenu. + + Paramètre: + repertoire (str): Le chemin du répertoire à supprimer. + + Retourne: + None + + Actions: + - Si le répertoire existe, il est supprimé avec tout son contenu. + - Si le répertoire n'existe pas, un message de debug est enregistré dans le logger. + """ + if os.path.exists(repertoire): + shutil.rmtree(repertoire) + logger.debug( + f"Le répertoire '{repertoire}' et tout son contenu ont été supprimés." + ) + else: + logger.debug(f"Le répertoire '{repertoire}' n'existe pas.") diff --git a/pulse_xmpp_master_substitute/lib/networkinfo.py b/pulse_xmpp_master_substitute/lib/networkinfo.py index 7cffd15f6..e6fc8fd4c 100644 --- a/pulse_xmpp_master_substitute/lib/networkinfo.py +++ b/pulse_xmpp_master_substitute/lib/networkinfo.py @@ -3,6 +3,8 @@ # SPDX-FileCopyrightText: 2016-2023 Siveo # SPDX-License-Identifier: GPL-3.0-or-later +# FILE : pulse_xmpp_master_substitute/lib/networkinfo.py + import netifaces import subprocess import sys @@ -17,11 +19,106 @@ from lib.utils import simplecommand, powerschellscriptps1 from . import utils +import ipaddress + if sys.platform.startswith("win"): import wmi import pythoncom +def find_common_addresses(list1, list2): + """ + Trouve les adresses IP communes entre deux listes de réseaux CIDR. + + Cette fonction prend deux listes de chaînes de caractères représentant des réseaux CIDR, + les convertit en objets IPv4Network, et recherche les adresses IP communes dans les deux listes. + + Args: + list1 (list of str): Une liste de chaînes de caractères représentant des réseaux CIDR. + list2 (list of str): Une autre liste de chaînes de caractères représentant des réseaux CIDR. + + Returns: + list of str: Une liste d'adresses IP communes entre les deux listes de réseaux CIDR. + """ + # Convertir les chaînes de CIDR en objets IPv4Network + cidr_list1 = [ipaddress.ip_network(cidr) for cidr in list1] + cidr_list2 = [ipaddress.ip_network(cidr) for cidr in list2] + + # Chercher des adresses IP communes dans les deux listes + common_addresses = [] + for net1 in cidr_list1: + for net2 in cidr_list2: + # Vérifier si les réseaux se chevauchent + if net1.overlaps(net2): + # Ajouter l'IP commune à la liste + common_addresses.append(str(net1.network_address)) + + return common_addresses + + +def get_CIDR_ipv4_addresses(exclude_localhost=True): + """ + Récupère les adresses IPv4 au format CIDR pour chaque interface réseau de la machine. + + Cette fonction utilise des commandes spécifiques au système d'exploitation pour obtenir les informations + sur les interfaces réseau et les adresses IPv4. Elle retourne une liste d'adresses IPv4 au format CIDR. + + Args: + exclude_localhost (bool): Si True, exclut les interfaces locales (localhost ou 127.0.0.1). + + Returns: + list: Une liste d'adresses IPv4 au format CIDR (par exemple, '192.168.1.0/24'). + """ + ipv4_addresses = [] + # Vérifier le système d'exploitation + system = platform.system() + if system == "Windows": + # Commande pour obtenir les interfaces réseau sous Windows + output = subprocess.check_output("ipconfig", shell=True).decode() + ip, mask = None, None + for line in output.splitlines(): + if "IPv4 Address" in line or "IPv4" in line: + ip = line.split(":")[-1].strip() + if "Subnet Mask" in line: + mask = line.split(":")[-1].strip() + if ip and mask: + if exclude_localhost and ip == "127.0.0.1": + continue + cidr = ipaddress.IPv4Network( + f"{ip}/{mask}", strict=False + ).with_prefixlen + ipv4_addresses.append(cidr) + ip, mask = None, None + elif system == "Linux": + # Commande pour obtenir les interfaces réseau sous Linux + output = subprocess.check_output("ip addr show", shell=True).decode() + for line in output.splitlines(): + line = line.strip() + if line.startswith("inet "): + parts = line.split() + ip_mask = parts[1] # Exemple : 192.168.1.100/24 + if exclude_localhost and ip_mask.startswith("127.0.0.1/"): + continue + ipv4_addresses.append(ip_mask) + elif system == "Darwin": # macOS est identifié par 'Darwin' + # Commande pour obtenir les interfaces réseau sous macOS + output = subprocess.check_output("ifconfig", shell=True).decode() + current_ip = None + for line in output.splitlines(): + line = line.strip() + if line.startswith("inet ") and "127.0.0.1" not in line: # Éviter localhost + parts = line.split() + ip = parts[1] # L'adresse IP est le second champ + mask = parts[3] # Le masque est généralement le quatrième champ + if exclude_localhost and ip == "127.0.0.1": + continue + cidr = ipaddress.IPv4Network( + f"{ip}/{mask}", strict=False + ).with_prefixlen + ipv4_addresses.append(cidr) + return ipv4_addresses + + class networkagentinfo: def __init__(self, sessionid, action="resultgetinfo", param=[]): self.sessionid = sessionid diff --git a/pulse_xmpp_master_substitute/lib/plugins/xmpp/__init__.py b/pulse_xmpp_master_substitute/lib/plugins/xmpp/__init__.py index e1b68fc84..267bbcde9 100644 --- a/pulse_xmpp_master_substitute/lib/plugins/xmpp/__init__.py +++ b/pulse_xmpp_master_substitute/lib/plugins/xmpp/__init__.py @@ -18,6 +18,8 @@ distinct, not_, delete, + Float, + text, ) from sqlalchemy.orm import sessionmaker, Query from sqlalchemy.exc import DBAPIError, NoSuchTableError, IntegrityError @@ -11716,3 +11718,119 @@ def get_os_xmpp_update_major_details( def _return_dict_from_dataset_mysql(self, resultproxy): return [rowproxy._asdict() for rowproxy in resultproxy] + + # ------------------------------------------------------------------------------- + + @DatabaseHelper._sessionm + def search_machine(self, session, search_term): + """ + Recherche une machine qui correspond à un id, uuid_inventorymachine, jid ou serialnumber. + + Paramètres : + session (Session) : La session SQLAlchemy. + search_term (str) : Le terme de recherche. + + Retourne : + dict : Les informations de la machine trouvée. + """ + if not search_term: + return {} + # Requête SQL + query = text( + """ + SELECT + id, + uuid_inventorymachine, + SUBSTRING_INDEX(jid, '/', 1) AS jid, + uuid_serial_machine, + hostname, + enabled + FROM + xmppmaster.machines + WHERE + uuid_serial_machine = :search_term + OR hostname = SUBSTRING_INDEX(SUBSTRING_INDEX(:search_term, '@', 1), '.', 1) + OR uuid_inventorymachine = :search_term + OR id = :search_term + OR jid =:search_term + LIMIT 1; + """ + ) + logger.error("search_machine query %s" % query) + logger.error("search_machine search_term %s" % search_term) + # Exécution de la requête + result = session.execute(query, {"search_term": search_term}).fetchone() + + # Si aucun résultat n'est trouvé, retourner un dictionnaire vide + if result is None: + return {} + + # Construire le dictionnaire de résultats + result_machines = { + "hostname": result["hostname"], + "jid": result["jid"], + "uuid_inventorymachine": result["uuid_inventorymachine"], + "uuid_serial_machine": result["uuid_serial_machine"], + "id": int(result["id"]), + "enabled": int(result["enabled"]), + } + + return result_machines + + @DatabaseHelper._sessionm + def network_list_machine(self, session, id_machine): + """ + Renvoie la liste des réseaux associés à une machine spécifique. + + Paramètres : + session (Session) : La session SQLAlchemy. + id_machine (int) : L'identifiant de la machine. + + Retourne : + list : La liste des informations de réseau associées à la machine. + """ + if not id_machine: + return [] + + # Requête SQL + query = text( + """ + SELECT + id, + macaddress, + ipaddress, + broadcast, + gateway, + mask, + mac, + machines_id + FROM + xmppmaster.network + WHERE + machines_id = :id_machine; + """ + ) + + # Exécution de la requête + result = session.execute(query, {"id_machine": id_machine}).fetchall() + + # Si aucun résultat n'est trouvé, retourner une liste vide + if not result: + return [] + + # Construire la liste de résultats + network_list = [] + for row in result: + network_info = { + "id": row["id"], + "macaddress": row["macaddress"], + "ipaddress": row["ipaddress"], + "broadcast": row["broadcast"], + "gateway": row["gateway"], + "mask": row["mask"], + "mac": row["mac"], + "machines_id": row["machines_id"], + } + network_list.append(network_info) + + return network_list diff --git a/pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_backup_restore_substitut.py b/pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_backup_restore_substitut.py new file mode 100644 index 000000000..dea140c19 --- /dev/null +++ b/pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_backup_restore_substitut.py @@ -0,0 +1,613 @@ +#!/usr/bin/python3 +# -*- coding: utf-8; -*- +# SPDX-FileCopyrightText: 2016-2023 Siveo +# SPDX-License-Identifier: GPL-3.0-or-later +# pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_backup_restore_substitut.py + +# Ce plugin est appelé depuis mmc, il est utilisé par substitut master. +# Il a comme but de transférer des fichiers de backup via xmpp +# Il crée une archive zip, celle de ce que l'on veut transférer via xmpp. +# L'archive est décomposée en morceaux dans un répertoire /var/lib/pulse2/zip_transfert + +""" +This plugin can be called from quick action +""" +import traceback +import json +import logging +import os +import hashlib +from datetime import datetime +import zipfile +import uuid +import time +import re +import base64 + +# Chemin où les fichiers ZIP seront créés +var_file_zipzer = "/var/lib/pulse2/zip_transfert" + +logger = logging.getLogger() + +plugin = {"VERSION": "1.0", "NAME": "backup_restore_substitut", "TYPE": "mastersub"} + + +def action(xmppobject, action, sessionid, data, message, ret, dataobj=None): + logger.debug("###################################################") + logger.debug("call %s from %s" % (plugin, message["from"])) + logger.debug("###################################################") + logger.debug("data %s" % json.dumps(data["data"], indent=4)) + try: + datamsg = data["data"] + # logger.debug("datamsg %s" % datamsg.keys()) + + # Spécifier le chemin où créer le répertoire ZIP + var_file_zipzer = "/var/lib/pulse2/zip_transfert" + # Créer le répertoire s'il n'existe pas + os.makedirs(var_file_zipzer, exist_ok=True) + + # Traitement des répertoires à zipper + if "directorylist" in datamsg and datamsg["directorylist"]: + for directory in datamsg["directorylist"]: + # logger.debug("creation d'un fichier zip depuis 1 repertoire : %s" % directory) + if directory: + repertoire_zip = os.path.join(datamsg["base_path"], directory[0]) + # logger.info("repertoire_zip %s" % repertoire_zip) + name_file_zip_actuel = generer_name_avec_timestamp( + datamsg["machine_dest_backup"]["uuid_serial_machine"], + os.path.join(directory[1]), + ) + archive_fichier_name_zip = f"{name_file_zip_actuel}.zip" + # logger.info("archive_fichier_name_zip %s" % archive_fichier_name_zip) + path_archive_fichier_name_zip = os.path.join( + var_file_zipzer, archive_fichier_name_zip + ) + # logger.info("creation d'un fichier zip %s" % path_archive_fichier_name_zip) + if not zipper_repertoire( + repertoire_zip, path_archive_fichier_name_zip + ): + logger.error("demande de compression dun repertoire inexistant") + continue + # Exemple d'utilisation + manager = ZipFileManager(var_file_zipzer) + manager.analyze_and_cleanup() + output_dir_list = process_zip_files( + var_file_zipzer, + var_file_zipzer, + datamsg["machine_dest_backup"]["uuid_serial_machine"], + segment_size=64000, + ) + + # Traitement des fichiers à zipper + if "filelist" in datamsg and datamsg["filelist"]: + for files in datamsg["filelist"]: + # logger.debug("creation d'un fichier zip depuis 1 fichier : %s" % files) + if files: + input_file = os.path.join(datamsg["base_path"], files[0]) + # logger.info("imput file a zipper %s" % input_file) + name_file_zip_actuel = generer_name_avec_timestamp( + datamsg["machine_dest_backup"]["uuid_serial_machine"], + os.path.join(files[1]), + ) + archive_fichier_name_zip = f"{name_file_zip_actuel}.zip" + # logger.info("archive_fichier_name_zip %s" % archive_fichier_name_zip) + + path_archive_fichier_name_zip = os.path.join( + var_file_zipzer, archive_fichier_name_zip + ) + # logger.info("creation d'un fichier zip %s" % path_archive_fichier_name_zip) + if not zipper_fichier( + input_file, path_archive_fichier_name_zip, fichier_vide=True + ): + logger.error("demande de compression dun fichier inexistant") + continue + # Exemple d'utilisation + manager = ZipFileManager(var_file_zipzer) + manager.analyze_and_cleanup() + output_dir_list = process_zip_files( + var_file_zipzer, + var_file_zipzer, + datamsg["machine_dest_backup"]["uuid_serial_machine"], + segment_size=64000, + type_transfert="backup", + location=None, + contenttype="file", + ) + except Exception: + logger.error("%s" % (traceback.format_exc())) + + +def process_zip_files( + input_dir, + output_dir_base_trunck, + uuid_serial_machine, + segment_size=8000, + type_transfert="backup", + location=None, + contenttype="directory", +): + """ + Lit tous les fichiers ZIP d'un répertoire, les découpe et enregistre les segments dans un répertoire de sortie basé sur le JID. + contenttype file ou directory + """ + output_dir_list = [] + output_dir_base = f"{output_dir_base_trunck}/{uuid_serial_machine}" + + # Vérifier si le répertoire de base existe, sinon le créer + if not os.path.exists(output_dir_base): + os.makedirs(output_dir_base) + + # Parcourir tous les fichiers dans le répertoire d'entrée + for file_name in os.listdir(input_dir): + if file_name.endswith(".zip"): # Vérifier si c'est un fichier ZIP + file_path = os.path.join(input_dir, file_name) + output_dir = os.path.join(output_dir_base, os.path.splitext(file_name)[0]) + + # Créer le répertoire de sortie pour chaque fichier ZIP + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + # Appeler la fonction split_file pour découper le fichier ZIP + split_file( + file_path, + output_dir, + segment_size, + type_transfert, + location, + contenttype, + ) + + # logger.debug(f"Fichier {file_name} découpé et enregistré dans {output_dir}") + try: + os.remove(file_path) + # logger.debug(f"Le fichier {file_name} a été effacé avec succès.") + return True + except FileNotFoundError: + logger.error(f"Le fichier {file_name} n'existe pas.") + return output_dir_list + except PermissionError: + logger.error( + f"Vous n'avez pas les permissions nécessaires pour effacer le fichier {file_name}." + ) + return output_dir_list + except Exception as e: + logger.error( + f"Une erreur s'est produite lors de l'effacement du fichier {file_name}: {e}" + ) + return output_dir_list + output_dir_list.append(output_dir) + return output_dir_list + + +def md5_hash(file_path): + """Calcule le hash MD5 d'un fichier.""" + hash_md5 = hashlib.md5() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + + +def split_file( + file_path, + output_dir, + segment_size=8000, + type_transfert="backup", + location=None, + contenttype="directory", +): + """Découpe un fichier en segments et enregistre chaque segment sous forme de fichier JSON en base64.""" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + # Obtenir les informations de base du fichier + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + total_parts = (file_size + segment_size - 1) // segment_size + file_uuid = str(uuid.uuid4()) + directory_or_file = file_name + if file_name.endswith(".zip"): + directory_or_file = file_name[:-4] + try: + parties = file_name.split("_") + # Prendre seulement la 3e partie + directory_or_file = parties[2] + except ValueError as ve: + logger.error("c'est pas 1 fichier zip") + return None + + # Calculer le hash MD5 du fichier original + original_md5 = md5_hash(file_path) + + # Lecture et découpe du fichier en segments + with open(file_path, "rb") as f: + part_num = 0 + while chunk := f.read(segment_size): + # Encodage de la partie en base64 + encoded_content = base64.b64encode(chunk).decode("utf-8") + + # Création du fichier JSON pour cette partie + part_data = { + "namefile": file_name, + "directory": directory_or_file, + "nb": part_num + 1, + "nbtotal": total_parts, + "content": encoded_content, + "type": type_transfert, + "location": location, + "contenttype": contenttype, + } + # Formater le numéro de segment avec un format spécifique + segment_formatted = "{:06d}".format(part_num + 1) + + part_file_name = f"{file_uuid}_{segment_formatted}.json" + part_file_path = os.path.join(output_dir, part_file_name) + + with open(part_file_path, "w") as part_file: + json.dump(part_data, part_file) + + part_num += 1 + + # Création du fichier manifeste + manifest_data = { + "namefile": file_name, + "directory": directory_or_file, + "creation": str(datetime.now()), + "nb_total": total_parts, + "md5": original_md5, + "type_file": "zip" if file_name.endswith(".zip") else "unknown", + "size_trunck": segment_size, + "type": type_transfert, + "location": location, + "contenttype": contenttype, + } + + manifest_file_name = f"{file_uuid}_0.manif" + manifest_file_path = os.path.join(output_dir, manifest_file_name) + + with open(manifest_file_path, "w") as manifest_file: + json.dump(manifest_data, manifest_file) + + # logger.debug(f"Fichier découpé en {total_parts} parties et manifest généré.") + + +def recombine_file(manifest_path, input_dir, output_file): + """Recompose le fichier original à partir des segments JSON et vérifie le hash MD5.""" + # Lire le fichier manifeste + with open(manifest_path, "r") as manifest_file: + manifest_data = json.load(manifest_file) + + original_file_name = manifest_data["le nom reelle du fichier"] + total_parts = manifest_data["nombre total de partie"] + original_md5 = manifest_data["md5"] + + file_uuid = os.path.basename(manifest_path).split("_")[0] + + with open(output_file, "wb") as output_f: + # Recomposer le fichier à partir des fichiers JSON + for part_num in range(1, total_parts + 1): + part_file_name = f"{file_uuid}_{part_num}.json" + part_file_path = os.path.join(input_dir, part_file_name) + + with open(part_file_path, "r") as part_file: + part_data = json.load(part_file) + # Décoder le contenu base64 et l'écrire dans le fichier de sortie + decoded_content = base64.b64decode(part_data["content"]) + output_f.write(decoded_content) + + # Vérification du hash MD5 du fichier recomposé + recombined_md5 = md5_hash(output_file) + if recombined_md5 == original_md5: + logger.debug(f"Fichier recomposé avec succès, MD5 vérifié : {recombined_md5}") + else: + logger.error( + f"Erreur : le MD5 recomposé {recombined_md5} ne correspond pas à l'original {original_md5}." + ) + + +def check_manifests(directory): + """Vérifie tous les manifestes dans un répertoire et retourne deux listes : + - Manifestes complets : Tous les fichiers nécessaires sont présents. + - Manifestes incomplets : Certains fichiers manquent. + """ + complete_manifests = [] + incomplete_manifests = [] + + # Parcourir tous les fichiers dans le répertoire + for file_name in os.listdir(directory): + # Chercher uniquement les fichiers manifeste (suffixe _0.manif) + if file_name.endswith("_0.manif"): + manifest_path = os.path.join(directory, file_name) + + # Lire le fichier manifeste + with open(manifest_path, "r") as manifest_file: + manifest_data = json.load(manifest_file) + + total_parts = manifest_data["nombre total de partie"] + file_uuid = file_name.split("_")[0] + + # Vérifier que toutes les parties de fichiers existent + all_parts_present = True + for part_num in range(1, total_parts + 1): + part_file_name = f"{file_uuid}_{part_num}.json" + part_file_path = os.path.join(directory, part_file_name) + if not os.path.exists(part_file_path): + all_parts_present = False + break + + if all_parts_present: + complete_manifests.append(file_name) + else: + incomplete_manifests.append(file_name) + + return complete_manifests, incomplete_manifests + + +def remplacer_caracteres(chaine): + # Remplacer @ par @@ + chaine = chaine.replace("@", "@64@") + # Remplacer les : par : + chaine = chaine.replace(":", "@58@") + # Remplacer les espaces par @nbsp@ + chaine = chaine.replace(" ", "@nbsp@") + # Remplacer les barres obliques / par / + chaine = chaine.replace("/", "@47@") + # Remplacer les barres obliques \ par \ + chaine = chaine.replace("\\", "@92@") + # Remplacer . par \ + chaine = chaine.replace(".", "@46@") + return chaine + + +def restaurer_caracteres(chaine): + chaine = chaine.replace("@58@", ":") + # Remplacer @46@ par . + chaine = chaine.replace("@46@", ".") + # Remplacer @92@ par \ + chaine = chaine.replace("@92@", "\\") + # Remplacer @47@ par / + chaine = chaine.replace("@47@", "/") + # Remplacer @nbsp@ par espace + chaine = chaine.replace("@nbsp@", " ") + # Remplacer @@ par @ + chaine = chaine.replace("@64@", "@") + + return chaine + + +def time_since_timestamp(timestamp): + """ + Calcule le temps écoulé depuis un timestamp donné. + + Args: + timestamp (int): Le timestamp à partir duquel calculer le temps écoulé. + + Returns: + tuple: Un tuple contenant le nombre de jours, heures, minutes et secondes écoulés depuis le timestamp. + """ + # Obtenir le temps actuel + current_time = time.time() + + # Calculer la différence en secondes + time_difference = current_time - timestamp + + # Convertir la différence en jours, heures, minutes et secondes + days = time_difference // (24 * 3600) + time_difference %= 24 * 3600 + hours = time_difference // 3600 + time_difference %= 3600 + minutes = time_difference // 60 + seconds = time_difference % 60 + + return int(days), int(hours), int(minutes), int(seconds) + + +def generer_name_avec_timestamp(jid_dest_backup, pathnamefile, millisecondes=False): + """ + Génère un UUID aléatoire et y ajoute un timestamp. + + :param millisecondes: Si True, utilise le timestamp en millisecondes. Par défaut, c'est False. + :return: Une chaîne contenant l'UUID et le timestamp. + """ + # emplacement = remplacer_caracteres(pathnamefile) + emplacement = pathnamefile + # Obtenir le timestamp + if millisecondes: + timestamp = int(time.time() * 1000) # Timestamp en millisecondes + else: + timestamp = int(time.time()) # Timestamp en secondes + # Combiner le UUID et le timestamp + nom_file_emplacement = f"{timestamp}_{jid_dest_backup}_{emplacement}" + return remplacer_caracteres(nom_file_emplacement) + + +def generer_uuid_avec_timestamp(millisecondes=False): + """ + Génère un UUID aléatoire et y ajoute un timestamp. + + :param millisecondes: Si True, utilise le timestamp en millisecondes. Par défaut, c'est False. + :return: Une chaîne contenant l'UUID et le timestamp. + """ + # Générer un UUID aléatoire + uuid_aleatoire = uuid.uuid4() + + # Obtenir le timestamp + if millisecondes: + timestamp = int(time.time() * 1000) # Timestamp en millisecondes + else: + timestamp = int(time.time()) # Timestamp en secondes + + # Combiner le UUID et le timestamp + uuid_timestamp = f"{timestamp}-{uuid_aleatoire}" + + return uuid_timestamp + + +class ZipFileManager: + """ + Classe qui gère les fichiers ZIP dans un répertoire en conservant les fichiers les plus récents + basés sur un horodatage (timestamp) dans le nom du fichier, et en supprimant les fichiers plus anciens. + + Le format attendu des fichiers ZIP est : _.zip + """ + + def __init__(self, directory): + """ + Initialise un gestionnaire de fichiers ZIP pour le répertoire spécifié. + + :param directory: Le répertoire contenant les fichiers ZIP à gérer. + """ + self.directory = directory + self.files_to_keep = {} + + def analyze_and_cleanup(self): + """ + Analyse les fichiers ZIP dans le répertoire et supprime les fichiers obsolètes. + + Garde uniquement le fichier ZIP le plus récent pour chaque combinaison unique de nom de fichier (sans timestamp). + """ + # Lire tous les fichiers .zip dans le répertoire + zip_files = [f for f in os.listdir(self.directory) if f.endswith(".zip")] + + # Analyser chaque fichier + for file in zip_files: + # Vérifier que le fichier correspond au format attendu : _.zip + match = re.match(r"(\d+)_([A-Za-z0-9@:_\-\\]+)\.zip", file) + if match: + timestamp, file_rest = match.groups() + key = file_rest + + # Garder le fichier avec le plus grand timestamp (le plus récent) + if key not in self.files_to_keep or int(timestamp) > int( + self.files_to_keep[key][0] + ): + self.files_to_keep[key] = (timestamp, file) + + # Supprimer les fichiers obsolètes + for file in zip_files: + match = re.match(r"(\d+)_([A-Za-z0-9@:_\-\\]+)\.zip", file) + if match: + timestamp, file_rest = match.groups() + key = file_rest + + # Si un fichier plus récent existe, supprimer l'ancien + if key in self.files_to_keep and self.files_to_keep[key][1] != file: + self.delete_file(file) + + def delete_file(self, file): + """ + Supprime un fichier ZIP du répertoire. + + :param file: Le nom du fichier à supprimer. + """ + file_path = os.path.join(self.directory, file) + os.remove(file_path) + # logger.debug(f"Supprimé : {file_path}") + + +def zipper_fichier(fichier, fichier_zip, fichier_vide=True): + """ + Zipe un seul fichier. + + Args: + fichier (str): Le chemin du fichier à zipper. + fichier_zip (str): Le nom du fichier zip de sortie. + fichier_vide (bool, optional): Si True, inclut les fichiers vides dans le zip. (par défaut: True) + + Returns: + None: La fonction crée un fichier zip et n'a pas de valeur de retour. + """ + # Vérifier si le répertoire existe + if not os.path.exists(fichier): + return None + + with zipfile.ZipFile(fichier_zip, "w", zipfile.ZIP_DEFLATED) as zipf: + if os.path.islink(fichier): + # Ajouter le lien symbolique en tant que tel dans le zip sans compression + zipf.write( + fichier, os.path.basename(fichier), compress_type=zipfile.ZIP_STORED + ) + else: + # Inclure les fichiers vides si demandé + if fichier_vide or os.path.getsize(fichier) > 0: + zipf.write(fichier, os.path.basename(fichier)) + return True + + +def zipper_repertoire( + repertoire, + fichier_zip, + resoudre_liens=False, + repertoire_vide=True, + fichier_vide=True, +): + """ + Zipe le contenu d'un répertoire, y compris tous les fichiers, sous-répertoires, + répertoires vides et fichiers vides. + + Args: + repertoire (str): Le chemin du répertoire à zipper. + fichier_zip (str): Le nom du fichier zip de sortie. + resoudre_liens (bool, optional): Si True, résout les liens symboliques et ajoute leurs cibles au zip. + Si False, ajoute les liens symboliques tels quels. (par défaut: False) + repertoire_vide (bool, optional): Si True, inclut les répertoires vides dans le zip. (par défaut: False) + fichier_vide (bool, optional): Si True, inclut les fichiers vides dans le zip. (par défaut: False) + + Returns: + None: Si le répertoire n'existe pas ou est vide. + True: Si le répertoire n'est pas vide et le fichier zip a été créé. + """ + # Vérifier si le répertoire existe + if not os.path.exists(repertoire): + return None + + # Vérifier si le répertoire est vide + if not os.listdir(repertoire): + return None + + with zipfile.ZipFile(fichier_zip, "w", zipfile.ZIP_DEFLATED) as zipf: + # Parcourt tous les fichiers et sous-répertoires dans le répertoire spécifié + for root, dirs, files in os.walk(repertoire): + # Ajouter le répertoire courant (même s'il est vide) + chemin_rel_repertoire = os.path.relpath(root, repertoire) + if ( + repertoire_vide or files + ): # Inclut les répertoires vides ou ceux avec des fichiers + zipf.write( + root, chemin_rel_repertoire + ) # Ajoute le répertoire courant au zip + + for file in files: + chemin_complet = os.path.join(root, file) # Chemin complet du fichier + chemin_rel = os.path.relpath( + chemin_complet, repertoire + ) # Chemin relatif pour le zip + + if os.path.islink(chemin_complet): + if resoudre_liens: + # Résoudre le lien symbolique et ajouter la cible au zip + chemin_cible = os.readlink(chemin_complet) + zipf.write(chemin_cible, chemin_rel) + else: + # Ajouter le lien symbolique en tant que tel dans le zip + zipf.write(chemin_complet, chemin_rel) + else: + # Inclure les fichiers vides si demandé + if fichier_vide or os.path.getsize(chemin_complet) > 0: + zipf.write(chemin_complet, chemin_rel) + + return True + + +def decompresser_archive(fichier_zip, repertoire_destination): + # Vérifier si le fichier ZIP existe + if not os.path.exists(fichier_zip): + raise FileNotFoundError(f"Le fichier ZIP {fichier_zip} n'existe pas.") + + # Créer le répertoire de destination s'il n'existe pas déjà + if not os.path.exists(repertoire_destination): + os.makedirs(repertoire_destination) + + # Ouvrir et extraire tout le contenu du fichier ZIP + with zipfile.ZipFile(fichier_zip, "r") as zip_ref: + zip_ref.extractall(repertoire_destination) diff --git a/pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_load_send_Segment_file.py b/pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_load_send_Segment_file.py new file mode 100644 index 000000000..f9da869cf --- /dev/null +++ b/pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_load_send_Segment_file.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: 2016-2023 Siveo +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +file : pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_load_send_Segment_file.py + +Ce plugin installe une fonction appelée cycliquement. +Cette fonction a pour charge d'envoyer des fichiers à des machines. +""" + +import re +import json +import os +import logging +import configparser +import shutil +import types +from lib.configuration import confParameter +from datetime import datetime, timedelta +from lib.plugins.xmpp import XmppMasterDatabase +from lib.plugins.msc import MscDatabase +import traceback +from lib.utils import file_put_contents, simplecommandstr, simplecommand, getRandomName + +logger = logging.getLogger() + +DEBUGPULSEPLUGIN = 25 + +plugin = {"VERSION": "1.0", "NAME": "load_send_Segment_file", "TYPE": "substitute", "LOAD": "START"} # fmt: skip + +var_file_zipper = "/var/lib/pulse2/zip_transfert" + + +def action(objectxmpp, action, sessionid, data, msg, dataerreur): + """ + Fonction principale du plugin, appelée lors de l'exécution du plugin. + + Args: + objectxmpp (object): L'objet XMPP. + action (str): L'action à exécuter. + sessionid (str): L'ID de la session. + data (dict): Les données associées à l'action. + msg (dict): Le message XMPP. + dataerreur (dict): Les données d'erreur. + + Returns: + None + """ + logger.debug("=====================================================") + logger.debug("call %s from %s" % (plugin, msg["from"])) + logger.debug("=====================================================") + logger.debug("data %s" % json.dumps(data, indent=4)) + + compteurcallplugin = getattr(objectxmpp, "num_call%s" % action) + if compteurcallplugin == 0: + read_conf_load_send_Segment_file(objectxmpp) + # Installer le code dynamique : fonction de transfert de fichier + objectxmpp.transfert_segment_file = types.MethodType( + transfert_segment_file, objectxmpp + ) + # Planifier l'appel de cette fonction + scedule_call_plugin_in_seconde = 15 + objectxmpp.schedule( + "transfert_segment_file", + scedule_call_plugin_in_seconde, + objectxmpp.transfert_segment_file, + repeat=True, + ) + + +def transfert_segment_file(self): + """ + Fonction de transfert de fichiers vers les machines présentes. + cette fonction est scheduler a + Args: + self (object): L'objet XMPP. + + Returns: + None + """ + type_transfert = "backup" + location = None + transfert_de_fichier_a_nb_machine_simultanement = 10 + repertoires_uuid = lister_repertoires_uuid(var_file_zipper)[ + :transfert_de_fichier_a_nb_machine_simultanement + ] + # Envoyer les fichiers tronqués. + for dir_uuid_machine in repertoires_uuid: + # dir est un répertoire mais le nom du répertoire est le UUID de la machine cible. + # Si la machine est présente. + + machine = XmppMasterDatabase().getMachinefromuuidsetup(str(dir_uuid_machine)) + + if machine: + if not machine["enabled"]: + # La machine n'est pas présente, on ne peut pas lui envoyer des fichiers. + logger.debug( + "Machine %s eteinte : on ne peut pas transferer de fichiers a cette machine" + % machine["hostname"] + ) + logger.debug( + "tout les demande de transfert pour la machine %s sont annuler" + % machine["hostname"] + ) + supprimer_repertoire(os.path.join(var_file_zipper, dir_uuid_machine)) + continue + logger.debug( + "TRANSFERT SEGMENT FILE to machine %s " % (machine["hostname"]) + ) + # La machine est présente. On peut lui envoyer les fichiers. + list_repertoire_to_file_for_send = get_uuid_directories( + os.path.join(var_file_zipper, dir_uuid_machine) + ) + for repertoire_file_seg in list_repertoire_to_file_for_send: + sessionid = getRandomName(5, "transfert_file") + file_list_to_send = lister_fichiers(repertoire_file_seg) + # logger.debug("REPERTOIRE DES FICHIERS %s \n file_list_to_send %s " % (repertoire_file_seg, file_list_to_send)) + + if len(file_list_to_send) < 2: + # Le fichier n'est pas encore segmenté ou il y a une erreur + continue + + # Le premier fichier de la liste doit être un fichier .manif pour manifeste. + if file_list_to_send[0].endswith(".manif"): + manifeste = lire_fichier_json(file_list_to_send[0]) + if len(file_list_to_send) != manifeste["nb_total"] + 1: + # Pas encore préparé + continue + + # logger.debug("contenue fichier manifeste : %s " % json.dumps(manifeste, indent=4)) + # logger.debug("repertoire_file_seg : %s " % repertoire_file_seg) + + if not manifeste: + # Le manifeste n'est pas un JSON correct + # Donc on considère que le transfert ne peut pas se faire. + supprimer_repertoire(repertoire_file_seg) + continue + + else: + continue + + indexfile = 0 + for filesend in file_list_to_send: + # logger.debug("*********************************") + # logger.debug("filesend %s " % (filesend)) + # logger.debug("*********************************") + + if indexfile == 0: + data = manifeste + else: + data = lire_fichier_json(filesend) + data["namefile"] = manifeste["namefile"] + + data["segment"] = indexfile + data["dir_uuid_machine"] = dir_uuid_machine + data["dir_segment"] = os.path.basename(repertoire_file_seg) + msg_send = { + "sessionid": sessionid, + "data": data, + "action": "recombine_file", + "ret": 0, + } + indexfile = indexfile + 1 + # C'est la machine qui reçoit les segments qui doit recombiner le fichier. + self.send_message( + mto=machine["jid"], mbody=json.dumps(msg_send), mtype="chat" + ) + + supprimer_repertoire(repertoire_file_seg) + supprimer_repertoire(os.path.join(var_file_zipper, dir_uuid_machine)) + + +def supprimer_repertoire(chemin_repertoire): + """ + Supprime un répertoire et tout son contenu. + + Paramètres: + chemin_repertoire (str): Chemin du répertoire à supprimer. + + Retourne: + bool: True si le répertoire a été supprimé avec succès, False sinon. + """ + try: + shutil.rmtree(chemin_repertoire) + # logger.debug(f"Le répertoire {chemin_repertoire} a été supprimé avec succès.") + return True + except FileNotFoundError: + logger.debug(f"Le répertoire {chemin_repertoire} n'existe pas.") + return False + except PermissionError: + logger.debug( + f"Vous n'avez pas les permissions nécessaires pour supprimer le répertoire {chemin_repertoire}." + ) + return False + except Exception as e: + logger.debug( + f"Une erreur s'est produite lors de la suppression du répertoire {chemin_repertoire}: {e}" + ) + return False + + +def lire_fichier_json(chemin_fichier): + """ + Lit le contenu d'un fichier JSON et le stocke dans un dictionnaire. + + Paramètres: + chemin_fichier (str): Chemin du fichier JSON à lire. + + Retourne: + dict: Le contenu du fichier JSON sous forme de dictionnaire. + """ + try: + with open(chemin_fichier, "r", encoding="utf-8") as fichier: + contenu = json.load(fichier) + return contenu + except FileNotFoundError: + logger.debug(f"Le fichier {chemin_fichier} n'existe pas.") + return None + except json.JSONDecodeError: + logger.debug(f"Erreur de décodage JSON dans le fichier {chemin_fichier}.") + return None + except Exception as e: + logger.debug(f"Erreur lors de la lecture du fichier {chemin_fichier}: {e}") + return None + + +def get_uuid_directories(base_directory): + """ + Renvoie les répertoires dans un répertoire de base dont le nom commence par 10 chiffres. + + Args: + base_directory (str): Le chemin du répertoire de base. + + Returns: + list: Une liste des chemins des répertoires qui correspondent. + """ + matching_dirs = [] + + # Expression régulière pour 10 chiffres au début du nom + pattern = re.compile( + r"^\d{10}_[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + ) + + # Parcourt les fichiers et répertoires dans le répertoire de base + for dir_name in os.listdir(base_directory): + # Vérifie si c'est un répertoire et si son nom commence par 10 chiffres + dir_path = os.path.join(base_directory, dir_name) + if os.path.isdir(dir_path) and pattern.match(dir_name): + matching_dirs.append(dir_path) + + return matching_dirs + + +def lister_fichiers(repertoire): + """ + Lit tous les fichiers dans un répertoire donné et renvoie une liste triée des fichiers. + + Paramètres: + repertoire (str): Chemin du répertoire. + + Retourne: + list: Une liste triée des noms de fichiers dans le répertoire. + """ + # logger.debug(f"cherche file repertoire {repertoire}") + try: + # Obtenir la liste des fichiers et des répertoires dans le répertoire donné + contenu = os.listdir(repertoire) + # logger.debug(f"CONTENU {contenu}") + # Filtrer pour ne conserver que les fichiers + fichiers = [ + os.path.join(repertoire, f) + for f in contenu + if os.path.isfile(os.path.join(repertoire, f)) + ] + # logger.debug(f"fichiers {fichiers}") + # Trier la liste des fichiers + fichiers.sort() + + return fichiers + except Exception as e: + logger.debug(f"Erreur lors de la lecture du répertoire {repertoire}: {e}") + return [] + + +def lire_contenu_fichier(chemin_fichier): + """ + Lit le contenu texte d'un fichier et le stocke dans une variable. + + Paramètres: + chemin_fichier (str): Chemin du fichier à lire. + + Retourne: + str: Le contenu texte du fichier. + """ + try: + with open(chemin_fichier, "r", encoding="utf-8") as fichier: + contenu = fichier.read() + return contenu + except Exception as e: + logger.debug(f"Erreur lors de la lecture du fichier {chemin_fichier}: {e}") + return None + + +def lister_repertoires_uuid(repertoire_principal): + """ + Renvoie une liste de tous les répertoires dans le répertoire principal qui ont un nom de la forme UUID. + + Paramètres: + repertoire_principal (str): Chemin du répertoire principal. + + Retourne: + list: Une liste des répertoires ayant un nom de la forme UUID. + """ + # Expression régulière pour vérifier le format UUID + uuid_pattern = re.compile( + r"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$" + ) + # Liste pour stocker les répertoires UUID + repertoires_uuid = [] + # Parcourir les éléments dans le répertoire principal + for item in os.listdir(repertoire_principal): + item_path = os.path.join(repertoire_principal, item) + if os.path.isdir(item_path) and uuid_pattern.match(item): + repertoires_uuid.append(item) + return repertoires_uuid + + +def read_conf_load_send_Segment_file(objectxmpp): + """ + Lire la configuration du plugin. + Le dossier contenant le fichier de configuration est dans la variable objectxmpp.config.pathdirconffile. + + Args: + objectxmpp (object): L'objet XMPP. + + Returns: + None + """ + nameconffile = plugin["NAME"] + ".ini" + pathconffile = os.path.join(objectxmpp.config.pathdirconffile, nameconffile) + if not os.path.isfile(pathconffile): + logger.info("pas de fichiers de configuration %s " % pathconffile) + else: + # Implémentez la configuration + # Config = configparser.ConfigParser() + # Config.read(pathconffile) + # if os.path.exists(pathconffile + ".local"): + # Config.read(pathconffile + ".local") + pass diff --git a/pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_send_file_xmpp.py b/pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_send_file_xmpp.py new file mode 100644 index 000000000..4c759dccb --- /dev/null +++ b/pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_send_file_xmpp.py @@ -0,0 +1,537 @@ +#!/usr/bin/python3 +# -*- coding: utf-8; -*- +# SPDX-FileCopyrightText: 2016-2023 Siveo +# SPDX-License-Identifier: GPL-3.0-or-later +# pulse_xmpp_master_substitute/pluginsmastersubstitute/plugin_send_file_xmpp.py + +# ce plugin est appelé pour envoye 1 fichier ou 1 repertoire sur 1 machine +# il a comme but de transferer des fichiers via xmpp +# il cree 1 archive zip, celle de ce que l'on veut transferer via xmpp. +# l'archive et decompose en morceau dans 1 repertoire /var/lib/pulse2/zip_transfert +# ou indique la destination sur la machine distante. + +""" +This plugin can be called from quick action +""" +import traceback +import json +import logging +import os +import hashlib +from datetime import datetime +import zipfile +import uuid +import time +import re +import base64 +from lib.plugins.xmpp import XmppMasterDatabase + +var_file_zipzer = "/var/lib/pulse2/zip_transfert" + +logger = logging.getLogger() + +plugin = {"VERSION": "1.0", "NAME": "send_file_xmpp", "TYPE": "substitut"} + + +def action(xmppobject, action, sessionid, data, message, ret, dataobj=None): + logger.debug("###################################################") + logger.debug("call %s from %s" % (plugin, message["from"])) + logger.debug("###################################################") + logger.debug("data %s" % json.dumps(data, indent=4)) + # Spécifier le chemin où créer le répertoire ZIP + # Créer le répertoire s'il n'existe pas + os.makedirs(var_file_zipzer, exist_ok=True) + + if not verifier_cles_non_vides( + data, ["machine_dest", "path_fichier", "install_machine_dest", "contenttype"] + ): + logger.error("il manque des clefs dans le json d'entree") + return + try: + # datamsg=data['data'] + machine = XmppMasterDatabase().search_machine(data["machine_dest"]) + if machine: + # logger.info("Machine destination %s" % json.dumps(machine, indent=4)) + if data["contenttype"].lower().startswith("file"): + # envoi file + if os.path.exists(data["path_fichier"]): + # logger.info("imput file a zipper %s" % data['path_fichier']) + name_file_zip_actuel = generer_name_avec_timestamp( + machine["uuid_serial_machine"], data["install_machine_dest"] + ) + + archive_fichier_name_zip = f"{name_file_zip_actuel}.zip" + # logger.info("archive_fichier_name_zip %s" %archive_fichier_name_zip) + + path_archive_fichier_name_zip = os.path.join( + var_file_zipzer, archive_fichier_name_zip + ) + # logger.info("creation d'un fichier zip %s" %path_archive_fichier_name_zip) + + if not zipper_fichier( + data["path_fichier"], + path_archive_fichier_name_zip, + fichier_vide=True, + ): + # logger.error("demande de compression dun fichier inexistant") + return + + # Exemple d'utilisation + manager = ZipFileManager(var_file_zipzer) + manager.analyze_and_cleanup() + location = data["install_machine_dest"].replace("\\", "/") + # location = os.path.dirname(data['install_machine_dest'].replace("\\","/")) + # logger.info("location %s" % location) + output_dir_list = process_zip_files( + var_file_zipzer, + var_file_zipzer, + machine["uuid_serial_machine"], + segment_size=8000, + type_transfert="location", + location=location, + contenttype="file", + ) + else: + logger.error( + "le fichier [%s] a envoyer n'existe pas" % data["path_fichier"] + ) + elif data["contenttype"].lower().startswith("direct") or data[ + "contenttype" + ].lower().startswith("package"): + if os.path.exists(data["path_fichier"]) and os.path.isdir( + data["path_fichier"] + ): + logger.info("imput directory a zipper %s" % data["path_fichier"]) + name_file_zip_actuel = generer_name_avec_timestamp( + machine["uuid_serial_machine"], data["install_machine_dest"] + ) + archive_fichier_name_zip = f"{name_file_zip_actuel}.zip" + logger.info( + "archive_fichier_name_zip %s" % archive_fichier_name_zip + ) + path_archive_fichier_name_zip = os.path.join( + var_file_zipzer, archive_fichier_name_zip + ) + # logger.info("creation d'un fichier zip %s" % path_archive_fichier_name_zip) + if not zipper_repertoire( + data["path_fichier"], path_archive_fichier_name_zip + ): + logger.error( + "demande de compression d'un repertoire inexistant" + ) + return + if data["contenttype"].lower().startswith("package"): + contenttype = "package" + else: + contenttype = "directory" + manager = ZipFileManager(var_file_zipzer) + manager.analyze_and_cleanup() + location = data["install_machine_dest"].replace("\\", "/") + # logger.info("location %s" % location) + output_dir_list = process_zip_files( + var_file_zipzer, + var_file_zipzer, + machine["uuid_serial_machine"], + segment_size=8000, + type_transfert="location", + location=location, + contenttype=contenttype, + ) + + else: + logger.error( + "le directory [%s] a envoyer n'existe pas %s " + % data["path_fichier"] + ) + except Exception: + logger.error("%s" % (traceback.format_exc())) + + +def generer_name_avec_timestamp(jid_dest_backup, pathnamefile, millisecondes=False): + """ + Génère un UUID aléatoire et y ajoute un timestamp. + + :param millisecondes: Si True, utilise le timestamp en millisecondes. Par défaut, c'est False. + :return: Une chaîne contenant l'UUID et le timestamp. + """ + # emplacement = remplacer_caracteres(pathnamefile) + emplacement = pathnamefile + # Obtenir le timestamp + if millisecondes: + timestamp = int(time.time() * 1000) # Timestamp en millisecondes + else: + timestamp = int(time.time()) # Timestamp en secondes + # Combiner le UUID et le timestamp + nom_file_emplacement = f"{timestamp}_{jid_dest_backup}_{emplacement}" + return remplacer_caracteres(nom_file_emplacement) + + +def verifier_cles_non_vides(data, cles): + """ + Vérifie si toutes les clés spécifiées existent dans le dictionnaire + et si leurs valeurs ne sont pas None ou vides. + + Args: + data (dict): Le dictionnaire à vérifier. + cles (list): Une liste de clés à vérifier dans le dictionnaire. + + Returns: + bool: True si toutes les clés existent et leurs valeurs ne sont pas vides, sinon False. + """ + logger.debug("verifier_cles_non_vides") + for cle in cles: + if cle not in data: # Vérifie l'existence et la valeur non vide + logger.error("cle missing %s" % cle) + return False + if isinstance(data[cle], int): + continue + if not data[cle]: + return False + return True + + +def process_zip_files( + input_dir, + output_dir_base_trunck, + uuid_serial_machine, + segment_size=8000, + type_transfert="backup", + location=None, + contenttype="directory", +): + """ + Lit tous les fichiers ZIP d'un répertoire, les découpe et enregistre les segments dans un répertoire de sortie basé sur le JID. + contenttype file ou directory + """ + output_dir_list = [] + output_dir_base = f"{output_dir_base_trunck}/{uuid_serial_machine}" + + # Vérifier si le répertoire de base existe, sinon le créer + if not os.path.exists(output_dir_base): + os.makedirs(output_dir_base) + + # Parcourir tous les fichiers dans le répertoire d'entrée + for file_name in os.listdir(input_dir): + if file_name.endswith(".zip"): # Vérifier si c'est un fichier ZIP + file_path = os.path.join(input_dir, file_name) + output_dir = os.path.join(output_dir_base, os.path.splitext(file_name)[0]) + + # Créer le répertoire de sortie pour chaque fichier ZIP + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + # Appeler la fonction split_file pour découper le fichier ZIP + split_file( + file_path, + output_dir, + segment_size, + type_transfert, + location, + contenttype, + ) + + # logger.debug(f"Fichier {file_name} découpé et enregistré dans {output_dir}") + try: + os.remove(file_path) + # logger.debug(f"Le fichier {file_name} a été effacé avec succès.") + return True + except FileNotFoundError: + logger.error(f"Le fichier {file_name} n'existe pas.") + return output_dir_list + except PermissionError: + logger.error( + f"Vous n'avez pas les permissions nécessaires pour effacer le fichier {file_name}." + ) + return output_dir_list + except Exception as e: + logger.error( + f"Une erreur s'est produite lors de l'effacement du fichier {file_name}: {e}" + ) + return output_dir_list + output_dir_list.append(output_dir) + return output_dir_list + + +def md5_hash(file_path): + """Calcule le hash MD5 d'un fichier.""" + hash_md5 = hashlib.md5() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + + +def split_file( + file_path, + output_dir, + segment_size=8000, + type_transfert="backup", + location=None, + contenttype="directory", +): + """Découpe un fichier en segments et enregistre chaque segment sous forme de fichier JSON en base64.""" + # Vérifier si le répertoire de sortie existe, sinon le créer + # logger.error(f"split_file file_path {file_path}: ") + # logger.error(f"split_file output_dir {output_dir}: ") + # logger.error(f"split_file contenttype {contenttype}: ") + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + # Obtenir les informations de base du fichier + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + total_parts = (file_size + segment_size - 1) // segment_size + file_uuid = str(uuid.uuid4()) + directory_or_file = file_name + if file_name.endswith(".zip"): + directory_or_file = file_name[:-4] + try: + parties = file_name.split("_") + # Prendre seulement la 3e partie + directory_or_file = parties[2] + except ValueError as ve: + logger.error("Ce n'est pas 1 fichier zip") + return None + + # Calculer le hash MD5 du fichier original + original_md5 = md5_hash(file_path) + + # Lecture et découpe du fichier en segments + with open(file_path, "rb") as f: + part_num = 0 + while chunk := f.read(segment_size): + # Encodage de la partie en base64 + encoded_content = base64.b64encode(chunk).decode("utf-8") + + # Création du fichier JSON pour cette partie + part_data = { + "namefile": file_name, + "directory": directory_or_file, + "nb": part_num + 1, + "nbtotal": total_parts, + "content": encoded_content, + "type": type_transfert, + "location": location, + "contenttype": contenttype, + } + # Formater le numéro de segment avec un format spécifique + segment_formatted = "{:06d}".format(part_num + 1) + part_file_name = f"{file_uuid}_{segment_formatted}.json" + part_file_path = os.path.join(output_dir, part_file_name) + + with open(part_file_path, "w") as part_file: + json.dump(part_data, part_file) + + part_num += 1 + + # Création du fichier manifeste + # en dernier permet de savoir si archive prete a l'envoi + manifest_data = { + "namefile": file_name, + "directory": directory_or_file, + "creation": str(datetime.now()), + "nb_total": total_parts, + "md5": original_md5, + "type_file": "zip" if file_name.endswith(".zip") else "unknown", + "size_trunck": segment_size, + "type": type_transfert, + "location": location, + "contenttype": contenttype, + } + + manifest_file_name = f"{file_uuid}_000000.manif" + manifest_file_path = os.path.join(output_dir, manifest_file_name) + + with open(manifest_file_path, "w") as manifest_file: + json.dump(manifest_data, manifest_file) + + # logger.debug(f"Fichier découpé en {total_parts} parties et manifest généré.") + + +def remplacer_caracteres(chaine): + # Remplacer @ par @@ + chaine = chaine.replace("@", "@64@") + # Remplacer les : par : + chaine = chaine.replace(":", "@58@") + # Remplacer les espaces par @nbsp@ + chaine = chaine.replace(" ", "@nbsp@") + # Remplacer les barres obliques / par / + chaine = chaine.replace("/", "@47@") + # Remplacer les barres obliques \ par \ + chaine = chaine.replace("\\", "@92@") + # Remplacer . par \ + chaine = chaine.replace(".", "@46@") + return chaine + + +def restaurer_caracteres(chaine): + chaine = chaine.replace("@58@", ":") + # Remplacer @46@ par . + chaine = chaine.replace("@46@", ".") + # Remplacer @92@ par \ + chaine = chaine.replace("@92@", "\\") + # Remplacer @47@ par / + chaine = chaine.replace("@47@", "/") + # Remplacer @nbsp@ par espace + chaine = chaine.replace("@nbsp@", " ") + # Remplacer @@ par @ + chaine = chaine.replace("@64@", "@") + + return chaine + + +class ZipFileManager: + """ + Classe qui gère les fichiers ZIP dans un répertoire en conservant les fichiers les plus récents + basés sur un horodatage (timestamp) dans le nom du fichier, et en supprimant les fichiers plus anciens. + + Le format attendu des fichiers ZIP est : _.zip + """ + + def __init__(self, directory): + """ + Initialise un gestionnaire de fichiers ZIP pour le répertoire spécifié. + + :param directory: Le répertoire contenant les fichiers ZIP à gérer. + """ + self.directory = directory + self.files_to_keep = {} + + def analyze_and_cleanup(self): + """ + Analyse les fichiers ZIP dans le répertoire et supprime les fichiers obsolètes. + + Garde uniquement le fichier ZIP le plus récent pour chaque combinaison unique de nom de fichier (sans timestamp). + """ + # Lire tous les fichiers .zip dans le répertoire + zip_files = [f for f in os.listdir(self.directory) if f.endswith(".zip")] + + # Analyser chaque fichier + for file in zip_files: + # Vérifier que le fichier correspond au format attendu : _.zip + match = re.match(r"(\d+)_([A-Za-z0-9@:_\-\\]+)\.zip", file) + if match: + timestamp, file_rest = match.groups() + key = file_rest + + # Garder le fichier avec le plus grand timestamp (le plus récent) + if key not in self.files_to_keep or int(timestamp) > int( + self.files_to_keep[key][0] + ): + self.files_to_keep[key] = (timestamp, file) + + # Supprimer les fichiers obsolètes + for file in zip_files: + match = re.match(r"(\d+)_([A-Za-z0-9@:_\-\\]+)\.zip", file) + if match: + timestamp, file_rest = match.groups() + key = file_rest + + # Si un fichier plus récent existe, supprimer l'ancien + if key in self.files_to_keep and self.files_to_keep[key][1] != file: + self.delete_file(file) + + def delete_file(self, file): + """ + Supprime un fichier ZIP du répertoire. + + :param file: Le nom du fichier à supprimer. + """ + file_path = os.path.join(self.directory, file) + os.remove(file_path) + # logger.debug(f"Supprimé : {file_path}") + + +def zipper_fichier(fichier, fichier_zip, fichier_vide=True): + """ + Zipe un seul fichier. + + Args: + fichier (str): Le chemin du fichier à zipper. + fichier_zip (str): Le nom du fichier zip de sortie. + fichier_vide (bool, optional): Si True, inclut les fichiers vides dans le zip. (par défaut: True) + + Returns: + None: La fonction crée un fichier zip et n'a pas de valeur de retour. + """ + # Vérifier si le répertoire existe + if not os.path.exists(fichier): + return None + + with zipfile.ZipFile(fichier_zip, "w", zipfile.ZIP_DEFLATED) as zipf: + if os.path.islink(fichier): + # Ajouter le lien symbolique en tant que tel dans le zip sans compression + zipf.write( + fichier, os.path.basename(fichier), compress_type=zipfile.ZIP_STORED + ) + else: + # Inclure les fichiers vides si demandé + if fichier_vide or os.path.getsize(fichier) > 0: + zipf.write(fichier, os.path.basename(fichier)) + return True + + +def zipper_repertoire( + repertoire, + fichier_zip, + resoudre_liens=False, + repertoire_vide=True, + fichier_vide=True, +): + """ + Zipe le contenu d'un répertoire, y compris tous les fichiers, sous-répertoires, + répertoires vides et fichiers vides. + + Args: + repertoire (str): Le chemin du répertoire à zipper. + fichier_zip (str): Le nom du fichier zip de sortie. + resoudre_liens (bool, optional): Si True, résout les liens symboliques et ajoute leurs cibles au zip. + Si False, ajoute les liens symboliques tels quels. (par défaut: False) + repertoire_vide (bool, optional): Si True, inclut les répertoires vides dans le zip. (par défaut: False) + fichier_vide (bool, optional): Si True, inclut les fichiers vides dans le zip. (par défaut: False) + + Returns: + None: Si le répertoire n'existe pas ou est vide. + True: Si le répertoire n'est pas vide et le fichier zip a été créé. + """ + # Vérifier si le répertoire existe + if not os.path.exists(repertoire): + return None + + # Vérifier si le répertoire est vide + if not os.listdir(repertoire): + return None + + with zipfile.ZipFile(fichier_zip, "w", zipfile.ZIP_DEFLATED) as zipf: + # Parcourt tous les fichiers et sous-répertoires dans le répertoire spécifié + for root, dirs, files in os.walk(repertoire): + # Ajouter le répertoire courant (même s'il est vide) + chemin_rel_repertoire = os.path.relpath(root, repertoire) + if ( + repertoire_vide or files + ): # Inclut les répertoires vides ou ceux avec des fichiers + zipf.write( + root, chemin_rel_repertoire + ) # Ajoute le répertoire courant au zip + + for file in files: + chemin_complet = os.path.join(root, file) # Chemin complet du fichier + chemin_rel = os.path.relpath( + chemin_complet, repertoire + ) # Chemin relatif pour le zip + + if os.path.islink(chemin_complet): + if resoudre_liens: + # Résoudre le lien symbolique et ajouter la cible au zip + chemin_cible = os.readlink(chemin_complet) + zipf.write(chemin_cible, chemin_rel) + else: + # Ajouter le lien symbolique en tant que tel dans le zip + zipf.write(chemin_complet, chemin_rel) + else: + # Inclure les fichiers vides si demandé + if fichier_vide or os.path.getsize(chemin_complet) > 0: + zipf.write(chemin_complet, chemin_rel) + + return True