diff --git a/pom.xml b/pom.xml index 985fb9f..b0b6af6 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,11 @@ 3.0.2 compile + + com.hierynomus + sshj + 0.38.0 + diff --git a/src/net/server_backup/Configuration.java b/src/net/server_backup/Configuration.java index e91ed4a..f5da41e 100644 --- a/src/net/server_backup/Configuration.java +++ b/src/net/server_backup/Configuration.java @@ -60,8 +60,8 @@ public static void loadConfig() { + "\nBackupLimiter - Type '0' to disable this feature. If you don't type '0' the feature 'DeleteOldBackups' will be disabled and this feature ('BackupLimiter') will be enabled." + "\nKeepUniqueBackups - Type 'true' to disable the deletion of unique backups. The plugin will keep the newest backup of all backed up worlds or folders, no matter how old it is." + "\nBlacklist - A list of files/directories that will not be backed up." - + "\nIMPORTANT FTP information: Set 'UploadBackup' to 'true' if you want to store your backups on a ftp server (sftp does not work at the moment - if you host your own server (e.g. vps/root server) you need to set up a ftp server on it)." - + "\nIf you use ftp backups, you can set 'DeleteLocalBackup' to 'true' if you want the plugin to remove the created backup from your server once it has been uploaded to your ftp server." + + "\nSet 'UploadBackup' to 'true' if you want to store your backups on a ftp/sftp server." + + "\nIf you use ftp/sftp backups, you can set 'DeleteLocalBackup' to 'true' if you want the plugin to remove the created backup from your server once it has been uploaded to your ftp/sftp server." + "\nCompressBeforeUpload compresses the backup to a zip file before uploading it. Set it to 'false' if you want the files to be uploaded directly to your ftp server." + "\nJoin the discord server if you need help or have a question: https://discord.gg/rNzngsCWFC"); ServerBackup.getInstance().getConfig().options().copyDefaults(true); @@ -119,6 +119,15 @@ public static void loadConfig() { ServerBackup.getInstance().getConfig().addDefault("Ftp.Server.Password", "password"); ServerBackup.getInstance().getConfig().addDefault("Ftp.Server.BackupDirectory", "Backups/"); + ServerBackup.getInstance().getConfig().addDefault("Sftp.UploadBackup", false); + ServerBackup.getInstance().getConfig().addDefault("Sftp.DeleteLocalBackup", false); + ServerBackup.getInstance().getConfig().addDefault("Sftp.Server.IP", "127.0.0.1"); + ServerBackup.getInstance().getConfig().addDefault("Sftp.Server.Port", 21); + ServerBackup.getInstance().getConfig().addDefault("Sftp.Server.User", "username"); + ServerBackup.getInstance().getConfig().addDefault("Sftp.Server.Password", "password"); + ServerBackup.getInstance().getConfig().addDefault("Sftp.Server.Fingerprint", "SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); + ServerBackup.getInstance().getConfig().addDefault("Sftp.Server.BackupDirectory", "Backups/"); + ServerBackup.getInstance().getConfig().addDefault("DynamicBackup", false); ServerBackup.getInstance().getConfig().addDefault("SendLogMessages", false); diff --git a/src/net/server_backup/Messages.java b/src/net/server_backup/Messages.java index d8d8aa4..57c859a 100644 --- a/src/net/server_backup/Messages.java +++ b/src/net/server_backup/Messages.java @@ -40,6 +40,10 @@ public static void loadMessages() { messages.addDefault("Info.FtpUploadSuccess", "Ftp: Upload successfully. Backup stored on ftp server."); messages.addDefault("Info.FtpDownload", "Ftp: Downloading backup [%file%] ..."); messages.addDefault("Info.FtpDownloadSuccess", "Ftp: Download successful. Backup downloaded from ftp server."); + messages.addDefault("Info.SftpUpload", "Sftp: Uploading backup [%file%] ..."); + messages.addDefault("Info.SftpUploadSuccess", "Sftp: Upload successfully. Backup stored on sftp server."); + messages.addDefault("Info.SftpDownload", "Sftp: Downloading backup [%file%] ..."); + messages.addDefault("Info.SftpDownloadSuccess", "Sftp: Download successful. Backup downloaded from sftp server."); messages.addDefault("Error.NoPermission", "&cI'm sorry but you do not have permission to perform this command."); messages.addDefault("Error.NoBackups", "No backups found."); @@ -49,6 +53,7 @@ public static void loadMessages() { messages.addDefault("Error.FolderExists", "There is already a folder named '%file%'."); messages.addDefault("Error.ZipExists", "There is already a ZIP file named '%file%.zip'."); messages.addDefault("Error.NoFtpBackups", "No ftp backups found."); + messages.addDefault("Error.NoSftpBackups", "No sftp backups found."); messages.addDefault("Error.NoTasks", "No backup tasks are running."); messages.addDefault("Error.AlreadyZip", "%file% is already a ZIP file."); messages.addDefault("Error.NotAZip", "%file% is not a ZIP file."); @@ -59,6 +64,11 @@ public static void loadMessages() { messages.addDefault("Error.FtpLocalDeletionFailed", "Ftp: Local backup deletion failed because the uploaded file was not found on the ftp server. Try again."); messages.addDefault("Error.FtpNotFound", "Ftp: ftp-backup %file% not found."); messages.addDefault("Error.FtpConnectionFailed", "Ftp: Error while connecting to FTP server."); + messages.addDefault("Error.SftpUploadFailed", "Sftp: Error while uploading backup to sftp server. Check server details in config.yml (ip, port, user, password)."); + messages.addDefault("Error.SftpDownloadFailed", "Sftp: Error while downloading backup to sftp server. Check server details in config.yml (ip, port, user, password)."); + messages.addDefault("Error.SftpLocalDeletionFailed", "Sftp: Local backup deletion failed because the uploaded file was not found on the sftp server. Try again."); + messages.addDefault("Error.SftpNotFound", "Sftp: sftp-backup %file% not found."); + messages.addDefault("Error.SftpConnectionFailed", "Sftp: Error while connecting to SFTP server."); Configuration.saveMessages(); diff --git a/src/net/server_backup/commands/CommandSftp.java b/src/net/server_backup/commands/CommandSftp.java new file mode 100644 index 0000000..8bc549f --- /dev/null +++ b/src/net/server_backup/commands/CommandSftp.java @@ -0,0 +1,91 @@ +package net.server_backup.commands; + +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.ComponentBuilder; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; +import net.server_backup.ServerBackup; +import net.server_backup.core.OperationHandler; +import net.server_backup.utils.SftpManager; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.List; + +public class CommandSftp { + + public static void execute(CommandSender sender, String[] args) { + if (args[1].equalsIgnoreCase("list")) { + Bukkit.getScheduler().runTaskAsynchronously(ServerBackup.getInstance(), () -> { + SftpManager sftpm = new SftpManager(sender); + + List backups = sftpm.getSftpBackupList(false); + + if (backups.size() == 0) { + sender.sendMessage(OperationHandler.processMessage("Error.NoSftpBackups")); + + return; + } + + try { + int page = Integer.valueOf(args[2]); + + if (backups.size() < page * 10 - 9) { + sender.sendMessage("Try a lower value."); + + return; + } + + if (backups.size() <= page * 10 && backups.size() >= page * 10 - 10) { + sender.sendMessage("----- Sftp-Backup " + Integer.valueOf(page * 10 - 9) + "-" + + backups.size() + "/" + backups.size() + " -----"); + } else { + sender.sendMessage("----- Sftp-Backup " + Integer.valueOf(page * 10 - 9) + "-" + + Integer.valueOf(page * 10) + "/" + backups.size() + " -----"); + } + sender.sendMessage(""); + + for (int i = page * 10 - 10; i < backups.size() && i < page * 10; i++) { + if (sender instanceof Player) { + Player p = (Player) sender; + + TextComponent msg = new TextComponent(backups.get(i)); + msg.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, + new ComponentBuilder("Click to get Backup name").create())); + msg.setClickEvent(new ClickEvent(ClickEvent.Action.SUGGEST_COMMAND, + backups.get(i).split(" ")[1])); + + p.spigot().sendMessage(msg); + } else { + sender.sendMessage(backups.get(i)); + } + } + + int maxPages = backups.size() / 10; + + if (backups.size() % 10 != 0) { + maxPages++; + } + + sender.sendMessage(""); + sender.sendMessage("--------- Page " + page + "/" + maxPages + " ---------"); + } catch (Exception e) { + sender.sendMessage(OperationHandler.processMessage("Error.NotANumber").replaceAll("%input%", args[1])); + } + }); + } else if (args[1].equalsIgnoreCase("download")) { + Bukkit.getScheduler().runTaskAsynchronously(ServerBackup.getInstance(), () -> { + SftpManager sftpm = new SftpManager(sender); + + sftpm.downloadFileFromSftp(args[2]); + }); + } else if (args[1].equalsIgnoreCase("upload")) { + Bukkit.getScheduler().runTaskAsynchronously(ServerBackup.getInstance(), () -> { + SftpManager sftpm = new SftpManager(sender); + + sftpm.uploadFileToSftp(args[2], !ServerBackup.getInstance().getConfig().getBoolean("Sftp.CompressBeforeUpload")); + }); + } + } +} diff --git a/src/net/server_backup/commands/Executor.java b/src/net/server_backup/commands/Executor.java index 4c873d4..2f2b6fd 100644 --- a/src/net/server_backup/commands/Executor.java +++ b/src/net/server_backup/commands/Executor.java @@ -40,6 +40,10 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (args[1].equalsIgnoreCase("list")) { Bukkit.dispatchCommand(sender, "backup ftp list 1"); } + } else if (args[0].equalsIgnoreCase("sftp")) { + if (args[1].equalsIgnoreCase("list")) { + Bukkit.dispatchCommand(sender, "backup sftp list 1"); + } } else if (args[0].equalsIgnoreCase("zip")) { CommandZip.execute(sender, args); } else if (args[0].equalsIgnoreCase("unzip")) { @@ -54,6 +58,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St CommandSearch.execute(sender, args); } else if (args[0].equalsIgnoreCase("ftp")) { CommandFtp.execute(sender, args); + } else if (args[0].equalsIgnoreCase("sftp")) { + CommandSftp.execute(sender, args); } else if (args[0].equalsIgnoreCase("dropbox")) { CommandDropbox.execute(sender, args); } @@ -85,6 +91,8 @@ private void sendHelp(CommandSender sender) { sender.sendMessage(""); sender.sendMessage("/backup ftp - download, upload or list ftp backup files"); sender.sendMessage(""); + sender.sendMessage("/backup sftp - download, upload or list sftp backup files"); + sender.sendMessage(""); sender.sendMessage("/backup dropbox upload - upload a backup to dropbox"); sender.sendMessage(""); sender.sendMessage("/backup shutdown - shut downs the server after backup tasks are finished"); diff --git a/src/net/server_backup/commands/TabCompleter.java b/src/net/server_backup/commands/TabCompleter.java index c6f884b..98386a5 100644 --- a/src/net/server_backup/commands/TabCompleter.java +++ b/src/net/server_backup/commands/TabCompleter.java @@ -2,6 +2,7 @@ import net.server_backup.Configuration; import net.server_backup.utils.FtpManager; +import net.server_backup.utils.SftpManager; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.command.Command; @@ -30,6 +31,7 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe commands.add("zip"); commands.add("unzip"); commands.add("ftp"); + commands.add("sftp"); commands.add("dropbox"); commands.add("tasks"); commands.add("shutdown"); @@ -82,6 +84,10 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe commands.add("list"); commands.add("download"); commands.add("upload"); + } else if (args[0].equalsIgnoreCase("sftp")) { + commands.add("list"); + commands.add("download"); + commands.add("upload"); } else if (args[0].equalsIgnoreCase("dropbox")) { commands.add("upload"); } @@ -107,6 +113,25 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } } + } else if (args[0].equalsIgnoreCase("sftp")) { + if (args[1].equalsIgnoreCase("download")) { + SftpManager sftpm = new SftpManager(sender); + + List backups = sftpm.getSftpBackupList(false); + + for (String backup : backups) { + commands.add(backup.split(" ")[1]); + } + } else if (args[1].equalsIgnoreCase("upload")) { + File[] backups = new File(Configuration.backupDestination + "").listFiles(); + + for (File backup : backups) { + if (backup.getName().endsWith(".zip")) { + commands.add(backup.getName()); + } + } + } + } else if (args[0].equalsIgnoreCase("dropbox")) { File[] backups = new File(Configuration.backupDestination + "").listFiles(); diff --git a/src/net/server_backup/core/ZipManager.java b/src/net/server_backup/core/ZipManager.java index f0f3005..b3f30fc 100644 --- a/src/net/server_backup/core/ZipManager.java +++ b/src/net/server_backup/core/ZipManager.java @@ -4,6 +4,7 @@ import net.server_backup.ServerBackup; import net.server_backup.utils.DropboxManager; import net.server_backup.utils.FtpManager; +import net.server_backup.utils.SftpManager; import org.apache.commons.io.FileUtils; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; @@ -179,6 +180,11 @@ public void zip() throws IOException { ftpm.uploadFileToFtp(targetFilePath, false); } + if (ServerBackup.getInstance().getConfig().getBoolean("Sftp.UploadBackup")) { + SftpManager sftpm = new SftpManager(sender); + sftpm.uploadFileToSftp(targetFilePath, false); + } + if (ServerBackup.getInstance().getConfig().getBoolean("CloudBackup.Dropbox")) { DropboxManager dm = new DropboxManager(sender); dm.uploadToDropbox(targetFilePath); diff --git a/src/net/server_backup/utils/SftpManager.java b/src/net/server_backup/utils/SftpManager.java new file mode 100644 index 0000000..82f8ed3 --- /dev/null +++ b/src/net/server_backup/utils/SftpManager.java @@ -0,0 +1,354 @@ +package net.server_backup.utils; + +import net.schmizz.sshj.SSHClient; +import net.schmizz.sshj.sftp.RemoteResourceInfo; +import net.schmizz.sshj.sftp.SFTPClient; +import net.schmizz.sshj.transport.verification.HostKeyVerifier; +import net.schmizz.sshj.xfer.FileSystemFile; +import net.server_backup.Configuration; +import net.server_backup.ServerBackup; +import net.server_backup.core.OperationHandler; +import org.bukkit.command.CommandSender; + +import java.io.*; +import java.nio.file.Paths; +import java.security.PublicKey; +import java.util.ArrayList; +import java.util.List; + +public class SftpManager { + + private CommandSender sender; + + private static final String server = ServerBackup.getInstance().getConfig().getString("Sftp.Server.IP"); + private static final int port = ServerBackup.getInstance().getConfig().getInt("Sftp.Server.Port"); + private static final String user = ServerBackup.getInstance().getConfig().getString("Sftp.Server.User"); + private static final String pass = ServerBackup.getInstance().getConfig().getString("Sftp.Server.Password"); + private static final String fingerprint = ServerBackup.getInstance().getConfig().getString("Sftp.Server.Fingerprint"); + private static final String working_dir = ServerBackup.getInstance().getConfig().getString("Sftp.Server.BackupDirectory"); + + public SftpManager(CommandSender sender) { + this.sender = sender; + } + + ServerBackup backup = ServerBackup.getInstance(); + + /*** + * Creates the Remote Path including Working Directory and a relative Path + * @param relativePath The relative path starting from the working directory + * @return The full path from the root of the sftp session + */ + private String getRemotePath(String relativePath) { + // convert \\ to / since sftp expects only / + return Paths.get(working_dir, relativePath).toString().replace('\\', '/'); + } + + /** + * Uploads a local file to the configured SFTP server. + *

+ * Resolves the given {@code filePath}, connects to the SFTP server, + * uploads the file to the remote path, and optionally deletes the local file + * if configured. Sends messages to {@code sender} about progress and success/failure. + *

+ * + * @param filePath the path of the local file to upload + * @param direct currently unused flag indicating direct upload + */ + public void uploadFileToSftp(String filePath, boolean direct) { + File file = new File(filePath); + + if (!file.getPath().contains(Configuration.backupDestination.replaceAll("/", ""))) { + file = new File(Configuration.backupDestination + "//" + filePath); + filePath = file.getPath(); + } + + if (!file.exists()) { + sender.sendMessage(OperationHandler.processMessage("Error.NoBackupFound").replaceAll("%file%", file.getName())); + + return; + } + + SSHClient sshClient = new SSHClient(); + SFTPClient sftpClient = null; + + try { + sftpClient = connect(sshClient); + + sender.sendMessage(OperationHandler.processMessage("Info.SftpUpload").replaceAll("%file%", file.getName())); + OperationHandler.tasks.add("SFTP UPLOAD {" + filePath + "}"); + + + try { + FileSystemFile localFile = new FileSystemFile(file); + sftpClient.put(localFile, getRemotePath(file.getName())); + sender.sendMessage(OperationHandler.processMessage("Info.SftpUploadSuccess")); + + if (ServerBackup.getInstance().getConfig().getBoolean("Sftp.DeleteLocalBackup")) { + boolean exists = false; + for (RemoteResourceInfo backup : sftpClient.ls(working_dir, RemoteResourceInfo::isRegularFile)) { + if (backup.getName().equalsIgnoreCase(file.getName())) { + exists = true; + } + } + + if (exists) { + file.delete(); + } else { + sender.sendMessage(OperationHandler.processMessage("Error.SftpLocalDeletionFailed")); + } + } + } catch (IOException e) { + sender.sendMessage(OperationHandler.processMessage("Error.SftpUploadFailed")); + e.printStackTrace(); + } + + } catch (IOException e) { + sender.sendMessage(OperationHandler.processMessage("Error.SftpUploadFailed")); + e.printStackTrace(); + } finally { + try { + disconnect(sftpClient, sshClient); + } catch (IOException e) { + // TODO: Handle exception here + e.printStackTrace(); + } + } + } + + /** + * Downloads a file from the configured SFTP server to the local backup destination. + *

+ * Checks if the file exists on the remote server, downloads it if found, + * and sends progress and status messages to {@code sender}. Handles + * connection and I/O exceptions and ensures the SFTP client is disconnected. + *

+ * + * @param filePath the path of the file to download from the remote server + */ + public void downloadFileFromSftp(String filePath) { + File file = new File(filePath); + + SSHClient sshClient = new SSHClient(); + SFTPClient sftpClient = null; + + try { + sftpClient = connect(sshClient); + + boolean exists = false; + + for (RemoteResourceInfo backup : sftpClient.ls(working_dir, RemoteResourceInfo::isRegularFile)) { + if (backup.getName().equalsIgnoreCase(file.getName())) { + exists = true; + } + } + + if (!exists) { + sender.sendMessage(OperationHandler.processMessage("Error.SftpNotFound").replaceAll("%file%", file.getName())); + + return; + } + + sender.sendMessage(OperationHandler.processMessage("Info.SftpDownload").replaceAll("%file%", file.getName())); + + File dFile = new File(Configuration.backupDestination + "//" + file.getPath()); + + try { + sftpClient.get(getRemotePath(filePath), dFile.getAbsolutePath()); + sender.sendMessage(OperationHandler.processMessage("Info.SftpDownloadSuccess")); + } catch (IOException e) { + sender.sendMessage(OperationHandler.processMessage("Error.SftpDownloadFailed")); + e.printStackTrace(); + } + + } catch (IOException e) { + sender.sendMessage(OperationHandler.processMessage("Error.SftpDownloadFailed")); + e.printStackTrace(); + } finally { + try { + disconnect(sftpClient, sshClient); + } catch (IOException e) { + // TODO: Handle exception here + e.printStackTrace(); + } + } + } + + /** + * Deletes a file from the configured SFTP server. + *

+ * Checks if the specified file exists on the remote server, deletes it if found, + * and sends progress and status messages to {@code sender}. Handles connection + * and I/O exceptions and ensures the SFTP client is disconnected. + *

+ * + * @param filePath the path of the file to delete on the remote server + */ + public void deleteFile(String filePath) { + File file = new File(filePath); + + SSHClient sshClient = new SSHClient(); + SFTPClient sftpClient = null; + + try { + sftpClient = connect(sshClient); + + boolean exists = false; + + for (RemoteResourceInfo backup : sftpClient.ls(working_dir, RemoteResourceInfo::isRegularFile)) { + if (backup.getName().equalsIgnoreCase(file.getName())) { + exists = true; + } + } + + if (!exists) { + sender.sendMessage(OperationHandler.processMessage("Error.SftpNotFound").replaceAll("%file%", file.getName())); + + return; + } + + sender.sendMessage(OperationHandler.processMessage("Info.FtpDeletion").replaceAll("%file%", file.getName())); + + try { + sftpClient.rm(getRemotePath(filePath)); + sender.sendMessage(OperationHandler.processMessage("Info.SftpDeletionSuccess")); + } catch (IOException e) { + sender.sendMessage(OperationHandler.processMessage("Error.SftpDeletionFailed")); + } + } catch (IOException e) { + sender.sendMessage(OperationHandler.processMessage("Error.SftpDeletionFailed")); + e.printStackTrace(); + } finally { + try { + disconnect(sftpClient, sshClient); + } catch (IOException e) { + // TODO: Handle exception here + e.printStackTrace(); + } + } + } + + /** + * Retrieves a list of backup files from the configured SFTP server. + *

+ * Lists all regular files in the remote working directory and returns + * them either in a raw format (path:size in MB) or a formatted display + * string with an index and file size. + *

+ * + * @param rawList if true, returns a raw "path:size" list; if false, returns a formatted list + * @return a list of backup file strings from the SFTP server + */ + public List getSftpBackupList(boolean rawList) { + + List backups = new ArrayList<>(); + + SSHClient sshClient = new SSHClient(); + SFTPClient sftpClient = null; + + try { + sftpClient = connect(sshClient); + + List files = sftpClient.ls(working_dir, RemoteResourceInfo::isRegularFile); + + int c = 1; + + for (RemoteResourceInfo file : files) { + double fileSize = (double) file.getAttributes().getSize() / 1000 / 1000; + fileSize = Math.round(fileSize * 100.0) / 100.0; + + if (rawList) { + backups.add(file.getPath() + ":" + fileSize); + } else { + backups.add("§7[" + c + "]§f " + file.getName() + " §7[" + fileSize + "MB]"); + } + + c++; + } + } catch (IOException e) { + // TODO: Handle exception here + e.printStackTrace(); + } finally { + try { + disconnect(sftpClient, sshClient); + } catch (IOException e) { + // TODO: Handle exception here + e.printStackTrace(); + } + } + return backups; + + } + + /** + * Connects to the SFTP server using password authentication and returns an SFTPClient. + * + *

This method establishes an SSH connection to the configured server, authenticates + * with the provided username and password, and verifies the host key using a + * fingerprint from the configuration.

+ * + *

Note: The returned SFTPClient does not maintain a current working directory. + * All file operations, such as upload or download, must use the full remote path, + * including the target directory. For example: + *

+     * sftp.put(localFilePath, remoteDir + "/" + remoteFileName);
+     * 
+ *

+ * + * @param sshClient The SSHClient used to connect to SFTP server + * @return an active SFTPClient connected to the remote server + * @throws IOException if the connection, authentication, or host key verification fails + */ + private SFTPClient connect(SSHClient sshClient) throws IOException { + + // Create verifier for host key from config + HostKeyVerifier verifier = new HostKeyVerifier() { + @Override + public boolean verify(String hostname, int port, PublicKey key) { + return true; + + // TODO: FIND A SOLUTION TO CHECK FINGERPRINTS, OR A WAY TO LOAD "BouncyCastle" + + /* + String actualFingerprint = SecurityUtils.getFingerprint(key); + return actualFingerprint.equals(fingerprint); + */ + } + + @Override + public List findExistingAlgorithms(String s, int i) { + return List.of(); + } + }; + + sshClient.addHostKeyVerifier(verifier); + + // establish connection + sshClient.connect(server, port); + sshClient.authPassword(user, pass); + + return sshClient.newSFTPClient(); + } + + /** + * Closes the given SFTPClient and disconnects the associated SSHClient. + * + *

This method ensures that both the SFTP session and the underlying SSH + * connection are properly terminated to avoid resource leaks.

+ * + * @param sftpClient the active SFTPClient to close; may be null + * @param sshClient the SSHClient associated with the SFTPClient; may be null + * @throws IOException if an error occurs while closing the SFTPClient or disconnecting the SSHClient + */ + private void disconnect(SFTPClient sftpClient, SSHClient sshClient) throws IOException { + + // close sftp client + if (sftpClient != null) { + sftpClient.close(); + } + + // disconnect ssh client + if (sshClient != null && sshClient.isConnected()) { + sshClient.disconnect(); + } + } +}