/*
* 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);
}
}
}