/* * Syncany, www.syncany.org * Copyright (C) 2011-2014 Philipp C. Heckel <philipp.heckel@gmail.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.syncany.plugins.sftp; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.io.FileUtils; import org.syncany.config.Config; import org.syncany.config.LocalEventBus; import org.syncany.config.UserConfig; import org.syncany.plugins.UserInteractionListener; import org.syncany.plugins.transfer.AbstractTransferManager; import org.syncany.plugins.transfer.StorageException; import org.syncany.plugins.transfer.StorageMoveException; import org.syncany.plugins.transfer.TransferManager; import org.syncany.plugins.transfer.files.ActionRemoteFile; import org.syncany.plugins.transfer.files.CleanupRemoteFile; import org.syncany.plugins.transfer.files.DatabaseRemoteFile; import org.syncany.plugins.transfer.files.MultichunkRemoteFile; import org.syncany.plugins.transfer.files.RemoteFile; import org.syncany.plugins.transfer.files.SyncanyRemoteFile; import org.syncany.plugins.transfer.files.TempRemoteFile; import org.syncany.plugins.transfer.files.TransactionRemoteFile; import org.syncany.util.FileUtil; import com.jcraft.jsch.ChannelSftp; import com.jcraft.jsch.ChannelSftp.LsEntry; import com.jcraft.jsch.ChannelSftp.LsEntrySelector; import com.jcraft.jsch.JSch; import com.jcraft.jsch.Session; import com.jcraft.jsch.SftpATTRS; import com.jcraft.jsch.SftpException; import com.jcraft.jsch.UserInfo; /** * Implements a {@link TransferManager} based on an SFTP storage backend for the * {@link SftpTransferPlugin}. * * <p>Using an {@link SftpTransferSettings}, the transfer manager is configured and uses * a well defined SFTP folder to store the Syncany repository data. While repo and * master file are stored in the given folder, databases and multichunks are stored * in special sub-folders: * * <ul> * <li>The <tt>databases</tt> folder keeps all the {@link DatabaseRemoteFile}s</li> * <li>The <tt>multichunks</tt> folder keeps the actual data within the {@link MultiChunkRemoteFile}s</li> * <li>The <tt>actions</tt> folder keeps the {@link ActionRemoteFile}s</li> * </ul> * * <p>All operations are auto-connected, i.e. a connection is automatically * established. * * @author Vincent Wiencek <vwiencek@gmail.com> * @author Philipp C. Heckel <philipp.heckel@gmail.com> * @author Christian Roth <christian.roth@port17.de> */ public class SftpTransferManager extends AbstractTransferManager { private static final Logger logger = Logger.getLogger(SftpTransferManager.class.getSimpleName()); private static final String SUPPORTED_KEX = "diffie-hellman-group1-sha1,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1,diffie-hellman-group-exchange-sha256"; private JSch secureChannel; private Session secureSession; private ChannelSftp sftpChannel; private String repoPath; private String multichunksPath; private String databasesPath; private String actionsPath; private String transactionsPath; private String tempPath; public SftpTransferManager(SftpTransferSettings connection, Config config) { super(connection, config); // Activate more kex // see http://sourceforge.net/p/jsch/patches/7/ // see https://github.com/syncany/syncany/issues/385 JSch.setConfig("kex", SUPPORTED_KEX); this.secureChannel = new JSch(); this.repoPath = connection.getPath(); this.multichunksPath = connection.getPath() + "/multichunks"; this.databasesPath = connection.getPath() + "/databases"; this.actionsPath = connection.getPath() + "/actions"; this.transactionsPath = connection.getPath() + "/transactions"; this.tempPath = connection.getPath() + "/temporary"; initKnownHosts(); } public SftpTransferSettings getSettings() { return (SftpTransferSettings) settings; } @Override public void connect() throws StorageException { try { if (secureSession != null && secureSession.isConnected()) { return; } if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "SFTP client connecting to {0}:{1} ...", new Object[] { getSettings().getHostname(), getSettings().getPort() }); } // Use pubkey authentication? boolean usePublicKeyAuth = getSettings().getPrivateKey() != null; if (usePublicKeyAuth) { if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "SFTP: Using pubkey authentication with key " + getSettings().getPrivateKey().getAbsolutePath()); } secureChannel.addIdentity(getSettings().getPrivateKey().getAbsolutePath(), getSettings().getPassword()); } // Initialize secure session, and connect Properties properties = new Properties(); properties.put("StrictHostKeyChecking", "ask"); secureSession = secureChannel.getSession(getSettings().getUsername(), getSettings().getHostname(), getSettings().getPort()); secureSession.setConfig(properties); // No password needed if pubkey auth is used if (!usePublicKeyAuth) { secureSession.setPassword(getSettings().getPassword()); } if (getSettings().getUserInteractionListener() != null) { secureSession.setUserInfo(new SftpUserInfo()); } secureSession.connect(); // Initialize SFTP channel, and connect sftpChannel = (ChannelSftp) secureSession.openChannel("sftp"); sftpChannel.connect(); } catch (Exception e) { logger.log(Level.WARNING, "SFTP client connection failed.", e); throw new StorageException(e); } } @Override public void disconnect() { if (sftpChannel != null) { sftpChannel.quit(); sftpChannel.disconnect(); } if (secureSession != null) { secureSession.disconnect(); } } @Override public void init(boolean createIfRequired) throws StorageException { connect(); try { if (!testTargetExists() && createIfRequired) { sftpChannel.mkdir(repoPath); } sftpChannel.mkdir(multichunksPath); sftpChannel.mkdir(databasesPath); sftpChannel.mkdir(actionsPath); sftpChannel.mkdir(transactionsPath); sftpChannel.mkdir(tempPath); } catch (SftpException e) { disconnect(); throw new StorageException("Cannot create directory " + multichunksPath + ", or " + databasesPath, e); } } @Override public void download(RemoteFile remoteFile, File localFile) throws StorageException { connect(); String remotePath = getRemoteFile(remoteFile); if (!remoteFile.getName().equals(".") && !remoteFile.getName().equals("..")) { try { // Download file File tempFile = createTempFile(localFile.getName()); OutputStream tempFOS = new FileOutputStream(tempFile); if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "SFTP: Downloading {0} to temp file {1}", new Object[] { remotePath, tempFile }); } sftpChannel.get(remotePath, tempFOS); tempFOS.close(); // Move file if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "SFTP: Renaming temp file {0} to file {1}", new Object[] { tempFile, localFile }); } localFile.delete(); FileUtils.moveFile(tempFile, localFile); tempFile.delete(); } catch (SftpException | IOException ex) { disconnect(); logger.log(Level.SEVERE, "Error while downloading file " + remoteFile.getName(), ex); throw new StorageException(ex); } } } @Override public void upload(File localFile, RemoteFile remoteFile) throws StorageException { connect(); String remotePath = getRemoteFile(remoteFile); String tempRemotePath = getSettings().getPath() + "/temp-" + remoteFile.getName(); try { // Upload to temp file InputStream fileFIS = new FileInputStream(localFile); if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "SFTP: Uploading {0} to temp file {1}", new Object[] { localFile, tempRemotePath }); } sftpChannel.put(fileFIS, tempRemotePath); fileFIS.close(); // Move if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "SFTP: Renaming temp file {0} to file {1}", new Object[] { tempRemotePath, remotePath }); } sftpChannel.rename(tempRemotePath, remotePath); } catch (SftpException | IOException ex) { disconnect(); logger.log(Level.SEVERE, "Could not upload file " + localFile + " to " + remoteFile.getName(), ex); throw new StorageException(ex); } } @Override public boolean delete(RemoteFile remoteFile) throws StorageException { connect(); String remotePath = getRemoteFile(remoteFile); try { sftpChannel.rm(remotePath); return true; } catch (SftpException ex) { if (ex.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) { return true; } else { disconnect(); logger.log(Level.SEVERE, "Could not delete file " + remoteFile.getName(), ex); throw new StorageException(ex); } } } @Override public void move(RemoteFile sourceFile, RemoteFile targetFile) throws StorageException { connect(); String sourceRemotePath = getRemoteFile(sourceFile); String targetRemotePath = getRemoteFile(targetFile); try { sftpChannel.rename(sourceRemotePath, targetRemotePath); } catch (SftpException e) { logger.log(Level.SEVERE, "Could not rename file " + sourceRemotePath + " to " + targetRemotePath, e); throw new StorageMoveException("Could not rename file " + sourceRemotePath + " to " + targetRemotePath, e); } } @Override public <T extends RemoteFile> Map<String, T> list(Class<T> remoteFileClass) throws StorageException { connect(); try { // List folder String remoteFilePath = getRemoteFilePath(remoteFileClass); List<LsEntry> entries = listEntries(remoteFilePath + "/"); // Create RemoteFile objects Map<String, T> remoteFiles = new HashMap<String, T>(); for (LsEntry entry : entries) { try { T remoteFile = RemoteFile.createRemoteFile(entry.getFilename(), remoteFileClass); remoteFiles.put(entry.getFilename(), remoteFile); } catch (Exception e) { logger.log(Level.INFO, "Cannot create instance of " + remoteFileClass.getSimpleName() + " for file " + entry.getFilename() + "; maybe invalid file name pattern. Ignoring file."); } } return remoteFiles; } catch (SftpException ex) { disconnect(); logger.log(Level.SEVERE, "Unable to list FTP directory.", ex); throw new StorageException(ex); } } private String getRemoteFile(RemoteFile remoteFile) { return getRemoteFilePath(remoteFile.getClass()) + "/" + remoteFile.getName(); } private String getRemoteFilePath(Class<? extends RemoteFile> remoteFile) { if (remoteFile.equals(MultichunkRemoteFile.class)) { return multichunksPath; } else if (remoteFile.equals(DatabaseRemoteFile.class) || remoteFile.equals(CleanupRemoteFile.class)) { return databasesPath; } else if (remoteFile.equals(ActionRemoteFile.class)) { return actionsPath; } else if (remoteFile.equals(TransactionRemoteFile.class)) { return transactionsPath; } else if (remoteFile.equals(TempRemoteFile.class)) { return tempPath; } else { return repoPath; } } private List<LsEntry> listEntries(String absolutePath) throws SftpException { final List<LsEntry> result = new ArrayList<>(); LsEntrySelector selector = new LsEntrySelector() { public int select(LsEntry entry) { if (!entry.getFilename().equals(".") && !entry.getFilename().equals("..")) { result.add(entry); } return CONTINUE; } }; sftpChannel.ls(absolutePath, selector); return result; } @Override public boolean testTargetCanWrite() { try { SftpATTRS stat = sftpChannel.stat(repoPath); if (stat.isDir()) { String tempRemoteFile = repoPath + "/syncany-write-test"; File tempFile = File.createTempFile("syncany-write-test", "tmp"); sftpChannel.put(new FileInputStream(tempFile), tempRemoteFile); sftpChannel.rm(tempRemoteFile); tempFile.delete(); logger.log(Level.INFO, "testTargetCanWrite: Can write, test file created/deleted successfully."); return true; } else { logger.log(Level.INFO, "testTargetCanWrite: Can NOT write, target does not exist."); return false; } } catch (Exception e) { logger.log(Level.INFO, "testTargetCanWrite: Can NOT write to target.", e); return false; } } @Override public boolean testTargetExists() { try { SftpATTRS attrs = sftpChannel.stat(repoPath); boolean targetExists = attrs.isDir(); if (targetExists) { logger.log(Level.INFO, "testTargetExists: Target does exist."); return true; } else { logger.log(Level.INFO, "testTargetExists: Target does NOT exist."); return false; } } catch (Exception e) { logger.log(Level.WARNING, "testTargetExists: Target does NOT exist, error occurred.", e); return false; } } @Override public boolean testTargetCanCreate() { // Find parent path String repoPathNoSlash = FileUtil.removeTrailingSlash(repoPath); int repoPathLastSlash = repoPathNoSlash.lastIndexOf("/"); String parentPath = (repoPathLastSlash > 0) ? repoPathNoSlash.substring(0, repoPathLastSlash) : "/"; // Test parent path permissions try { SftpATTRS parentPathStat = sftpChannel.stat(parentPath); boolean statSuccessful = parentPathStat != null; boolean hasWritePermissions = statSuccessful && (parentPathStat.getPermissions() & 00200) != 0; if (hasWritePermissions) { logger.log(Level.INFO, "testTargetCanCreate: Can create target at " + parentPathStat); return true; } else { logger.log(Level.INFO, "testTargetCanCreate: Can NOT create target (statSuccessful = " + statSuccessful + ", hasWritePermissions = " + hasWritePermissions + ")"); return false; } } catch (SftpException e) { logger.log(Level.INFO, "testTargetCanCreate: Can NOT create target at " + parentPath, e); return false; } } @Override public boolean testRepoFileExists() { try { String repoFilePath = getRemoteFile(new SyncanyRemoteFile()); SftpATTRS repoFileStat = sftpChannel.stat(repoFilePath); if (repoFileStat.isReg()) { logger.log(Level.INFO, "testRepoFileExists: Repo file exists at " + repoFilePath); return true; } else { logger.log(Level.INFO, "testRepoFileExists: Repo file DOES NOT exist at " + repoFilePath); return false; } } catch (Exception e) { logger.log(Level.INFO, "testRepoFileExists: Exception when trying to check repo file existence.", e); return false; } } private void initKnownHosts() { try { File userHostKeyFile = new File(UserConfig.getUserPluginsUserdataDir("sftp"), "known_hosts"); if (!userHostKeyFile.exists()) { userHostKeyFile.createNewFile(); } secureChannel.setKnownHosts(userHostKeyFile.getAbsolutePath()); } catch (Exception e) { throw new RuntimeException(e); } } private class SftpUserInfo implements UserInfo { private UserInteractionListener userInteractionListener; private LocalEventBus eventBus; public SftpUserInfo() { this.userInteractionListener = getSettings().getUserInteractionListener(); this.eventBus = LocalEventBus.getInstance(); } @Override public String getPassphrase() { return null; // Not supported } @Override public String getPassword() { return null; // Not supported } @Override public boolean promptPassword(String message) { logger.log(Level.WARNING, "SFTP Plugin tried to ask for a password. Wrong SSH/SFTP password? This is NOT SUPPORTED right now."); return false; // Do NOT let JSch ask for new password (if given password is wrong) } @Override public boolean promptPassphrase(String message) { logger.log(Level.WARNING, "SFTP Plugin tried to ask for a passphrase. This is NOT SUPPORTED right now."); return false; // Do NOT let JSch ask for passphrase } @Override public boolean promptYesNo(String message) { return userInteractionListener.onUserConfirm("SSH/SFTP Confirmation", message, "Confirm"); } @Override public void showMessage(String message) { eventBus.post(message); } } }