/*
* Copyright 2008-2012 Amazon Technologies, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at:
*
* http://aws.amazon.com/apache2.0
*
* This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
* OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and
* limitations under the License.
*/
package com.amazonaws.eclipse.ec2;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Logger;
import com.amazonaws.services.ec2.model.Instance;
import com.amazonaws.eclipse.core.AwsToolkitCore;
import com.amazonaws.eclipse.ec2.keypairs.KeyPairManager;
import com.amazonaws.eclipse.ec2.preferences.PreferenceConstants;
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.HostKey;
import com.jcraft.jsch.HostKeyRepository;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.UserInfo;
/**
* Utilities for executing remote commands on EC2 Instances.
*/
public class RemoteCommandUtils {
/** The key pair manager to provide key pairs for remote access */
private static final KeyPairManager keyPairManager = new KeyPairManager();
/** The interval before retrying a remote command */
private static final int RETRY_INTERVAL = 5000;
/** The max number of retries for failed remote commands */
private static final int MAX_RETRIES = 3;
/** Shared logger */
private static final Logger logger = Logger.getLogger(RemoteCommandUtils.class.getName());
/**
* Executes the specified command on the specified instance, possibly
* retrying the command a few times if it initially fails for any reason.
*
* @param command
* The command to execute.
* @param instance
* The instance to execute the command on.
*
* @return A list with one element for each time the command was attempted,
* and a description of the attempt (stdout, stderr, exit code).
*
* @throws ShellCommandException
* If the command is unable to be executed, and fails, even after retrying.
*/
public List<ShellCommandResults> executeRemoteCommand(String command, Instance instance)
throws ShellCommandException {
List<ShellCommandResults> results = new LinkedList<ShellCommandResults>();
while (true) {
logger.info("Executing remote command: " + command);
ShellCommandResults shellCommandResults = excuteRemoteCommandWithoutRetrying(command, instance);
results.add(shellCommandResults);
logger.info(" - exit code " + shellCommandResults.exitCode + "\n");
if (shellCommandResults.exitCode == 0) {
return results;
}
if (results.size() >= MAX_RETRIES) {
throw new ShellCommandException("Unable to execute the following command:\n" + command, results);
}
try {Thread.sleep(RETRY_INTERVAL);} catch (InterruptedException ie) {}
}
}
/**
* Copies the specified local file to a specified path on the specified
* host, possibly retrying if the copy initially failed for any reason.
*
* @param localFile
* The file to copy.
* @param remoteFile
* The remote location to copy the file to.
* @param instance
* The instance to copy the file to.
*
* @throws RemoteFileCopyException
* If there were any problems copying the remote file.
*/
public void copyRemoteFile(String localFile, String remoteFile, Instance instance)
throws RemoteFileCopyException {
int totalTries = 0;
List<RemoteFileCopyResults> allFileCopyAttempts = new ArrayList<RemoteFileCopyResults>();
while (true) {
logger.info("Copying file " + localFile + " to " + instance.getPublicDnsName() + ":" + remoteFile);
RemoteFileCopyResults fileCopyResults = copyRemoteFileWithoutRetrying(localFile, remoteFile, instance);
if (fileCopyResults.isSucceeded()) return;
allFileCopyAttempts.add(fileCopyResults);
totalTries++;
if (totalTries > MAX_RETRIES) {
throw new RemoteFileCopyException(localFile, remoteFile, allFileCopyAttempts);
}
try {Thread.sleep(RETRY_INTERVAL);} catch (InterruptedException ie) {}
}
}
/**
* Executes the specified command locally and waits for it to complete so
* the exit status can be returned. Not technically a remote command utility
* method, but here for convenience. If command execution fails, the command
* will be retried up to three times.
*
* @param command
* The command to execute.
* @return The exit status of the command.
*
* @throws IOException
* @throws InterruptedException
*/
public int executeCommand(String command) throws IOException, InterruptedException {
int retries = 3;
logger.info("Executing: " + command);
/*
* We occasionally get problems logging in after an instance
* comes up, so we retry the connection a few times after
* waiting a few seconds between to make sure the EC2
* firewall has been setup correctly.
*/
while (true) {
Process p = Runtime.getRuntime().exec(command);
int exitCode = p.waitFor();
BufferedReader reader = new BufferedReader(new InputStreamReader(p.getErrorStream()));
String errors = "";
String s;
while ((s = reader.readLine()) != null) {
errors += s;
}
reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
String output = "";
while ((s = reader.readLine()) != null) {
output += s;
}
logger.info(" - exitCode: " + exitCode + "\n"
+ " - stderr: " + errors + "\n"
+ " - stdout: " + output);
if (exitCode == 0) return 0;
if (retries-- < 0) {
throw new IOException("Unable to execute command. "
+ "Exit code: " + exitCode
+ " errors: '" + errors
+ "', output = '" + output + "'");
}
logger.info("Retrying...");
try {Thread.sleep(RETRY_INTERVAL);} catch(InterruptedException ie) {}
}
}
/*
* Private Interface
*/
/**
* Tries to execute the specified command on the specified host one time and
* returns the exit code.
*
* @param command
* The remote command to execute.
* @param instance
* The instance to run the command on.
*
* @return A description of the attempt to execute this command (stdout,
* stderr, exit code).
*/
private ShellCommandResults excuteRemoteCommandWithoutRetrying(String command, Instance instance) {
Session session = null;
ChannelExec channel = null;
StringBuilder output = new StringBuilder();
StringBuilder errors = new StringBuilder();
BufferedInputStream in = null;
BufferedInputStream err = null;
try {
session = createSshSession(instance);
channel = (ChannelExec)session.openChannel("exec");
channel.setCommand(command);
channel.setInputStream(null);
channel.setErrStream(System.err);
in = new BufferedInputStream(channel.getInputStream());
err = new BufferedInputStream(channel.getErrStream());
channel.connect();
while (true) {
drainInputStream(in, output);
drainInputStream(err, errors);
if (channel.isClosed()) {
return new ShellCommandResults(
output.toString(), errors.toString(), channel.getExitStatus());
}
try {Thread.sleep(1000);} catch (Exception e) {}
}
} catch (JSchException e) {
e.printStackTrace();
} catch (IOException ioe) {
ioe.printStackTrace();
} finally {
logger.info(" - output: " + output.toString() + "\n"
+ " - errors: " + errors.toString());
try {in.close();} catch (Exception e) {}
try {err.close();} catch (Exception e) {}
try {channel.disconnect();} catch (Exception e) {}
try {session.disconnect();} catch (Exception e) {}
}
// TODO: we're missing error message information from JSchException and IOExcetpion.
// we could use ShellCommandException to pass it along...
return new ShellCommandResults(output.toString(), errors.toString(), 1);
}
/**
* Reads all available data from the specified input stream and writes it to
* the specified StringBuiler.
*
* @param in
* The InputStream to read.
* @param builder
* The StringBuilder to write to.
*
* @throws IOException
* If there were any problems reading from the specified
* InputStream.
*/
private void drainInputStream(InputStream in, StringBuilder builder) throws IOException {
BufferedInputStream bufferedInputStream = new BufferedInputStream(in);
byte[] buffer = new byte[1024];
while (bufferedInputStream.available() > 0) {
int read = bufferedInputStream.read(buffer, 0, buffer.length);
if (read > 0) {
builder.append(new String(buffer, 0, read));
}
}
}
/**
* Tries exactly once to copy the specified file to the specified remote
* location and returns a summary of the attempt to copy the local file to
* the remote location, indicating if the copy was successful or not.
*
* @param localFile
* The local file to copy.
* @param remoteFile
* The location to copy the file on the remote host.
* @param instance
* The remote host to copy the file to.
*
* @return The results of trying to copy the local file to the remote
* location, indicating whether the copy was successful or not as
* well as providing information on why it failed if applicable.
*/
private RemoteFileCopyResults copyRemoteFileWithoutRetrying(String localFile, String remoteFile, Instance instance) {
RemoteFileCopyResults results = new RemoteFileCopyResults(localFile, remoteFile);
results.setSucceeded(false);
Session session = null;
ChannelExec channel = null;
try {
session = createSshSession(instance);
String command = "scp -p -t " + remoteFile;
channel = (ChannelExec)session.openChannel("exec");
channel.setCommand(command);
OutputStream out = null;
InputStream in = null;
InputStream ext = null;
try {
out = channel.getOutputStream();
in = channel.getInputStream();
ext = channel.getExtInputStream();
StringBuilder extStringBuilder = new StringBuilder();
channel.connect();
if (checkAck(in) != 0) {
drainInputStream(ext, extStringBuilder);
results.setExternalOutput(extStringBuilder.toString());
results.setErrorMessage("Error connecting to the secure channel for file transfer");
return results;
}
sendFileHeader(localFile, out);
if (checkAck(in) != 0) {
drainInputStream(ext, extStringBuilder);
results.setExternalOutput(extStringBuilder.toString());
results.setErrorMessage("Error sending file header on the secure channel");
return results;
}
sendFileData(localFile, out);
if (checkAck(in) != 0) {
drainInputStream(ext, extStringBuilder);
results.setExternalOutput(extStringBuilder.toString());
results.setErrorMessage("Error sending file data on the secure channel");
return results;
}
} finally {
try {out.close();} catch (Exception e) {}
try {in.close();} catch (Exception e) {}
try {ext.close();} catch (Exception e) {}
}
} catch (Exception e) {
e.printStackTrace();
results.setErrorMessage("Unexpected exception: " + e.getMessage());
results.setError(e);
return results;
} finally {
try {channel.disconnect();} catch (Exception e) {}
try {session.disconnect();} catch (Exception e) {}
}
results.setSucceeded(true);
return results;
}
/**
* Creates a connected SSH session to the specified host.
*
* @param instance
* The EC2 instance to connect to.
*
* @return The connected session.
*
* @throws JSchException
* @throws IOException
*/
private Session createSshSession(Instance instance) throws JSchException, IOException {
String keyPairFilePath = keyPairManager.lookupKeyPairPrivateKeyFile(AwsToolkitCore.getDefault().getCurrentAccountId(), instance.getKeyName());
if (keyPairFilePath == null) {
throw new IOException("No private key file found for key " + instance.getKeyName());
}
JSch jsch = new JSch();
jsch.addIdentity(keyPairFilePath);
/*
* We use a no-op implementation of a host key repository to ensure that
* we don't add any hosts to the known_hosts file. Since EC2 hosts are
* transient by nature and the DNS names are reused, we want to avoid
* any problems with mismatches in the known_hosts file.
*/
jsch.setHostKeyRepository(new NullHostKeyRepository());
/*
* We could configure a session proxy, but if the user has configured a
* SOCKS proxy in Eclipse's preferences, we'll automatically use that
* since Eclipse sets the socksProxyHost system property.
*
* We could additionally look for an HTTP/HTTPS proxy and configure
* that, but it seems fairly unlikely that the vast majority of
* HTTP/HTTPS proxies will be configured to allow SSH traffic through to
* a remote host on port 22.
*
* If that turns out not to be the case, then it'd be easy to look for
* an HTTP/HTTPS proxy here and configure the JSch session to use it.
* I've already tested that it works for an open HTTP proxy.
*/
String sshUser = Ec2Plugin.getDefault().getPreferenceStore().getString(PreferenceConstants.P_SSH_USER);
Session session = jsch.getSession(sshUser, instance.getPublicDnsName(), 22);
// We need this avoid being asked to accept the key
session.setConfig("StrictHostKeyChecking", "no");
// Make sure Kerberos authentication is disabled
session.setConfig("GSSAPIAuthentication", "no");
/*
* These are necessary to avoid problems with latent connections being
* closed. This tells JSCH to place a 120 second SO timeout on the
* underlying socket. When that interrupt is received, JSCH will send a
* keep alive message. This will repeat up to a 1000 times, which should
* be more than enough for any long operations to prevent the socket
* from being closed.
*
* SSH has a TCPKeepAlive option, but JSCH doesn't seem to ever check it:
* session.setConfig("TCPKeepAlive", "yes");
*/
session.setServerAliveInterval(120 * 1000);
session.setServerAliveCountMax(1000);
session.setConfig("TCPKeepAlive", "yes");
session.connect();
return session;
}
/**
* Sends the SCP file header for the specified file to the specified output
* stream.
*
* @param localFile
* The file to generate the header for.
* @param out
* The output stream to write the header to.
* @throws IOException
* If there are any problems writing the header.
*/
private void sendFileHeader(String localFile, OutputStream out) throws IOException {
long filesize = (new File(localFile)).length();
String command = "C0644 " + filesize + " ";
command += localFile.substring(localFile.lastIndexOf('/') + 1);
command += "\n";
out.write(command.getBytes());
out.flush();
}
/**
* Writes the contents of the specified file to the specified output stream.
*
* @param localFile
* The file to write to the output stream.
* @param out
* The output stream to write to.
*
* @throws FileNotFoundException
* @throws IOException
* If any problems writing the file contents.
*/
private void sendFileData(String localFile, OutputStream out)
throws FileNotFoundException, IOException {
FileInputStream fis = new FileInputStream(localFile);
try {
byte[] buf = new byte[1024];
while (true) {
int len = fis.read(buf, 0, buf.length);
if (len <= 0)
break;
out.write(buf, 0, len);
}
} finally {
fis.close();
}
out.write('\0');
out.flush();
}
/**
* Reads a status byte from the specified input stream and checks its value.
* If it's an error code, an error message is read from the input stream as
* well.
*
* @param in
* The input stream to read from.
* @return The status code read from the input stream.
*
* @throws IOException
* If there were any problems reading from the input stream.
*/
private static int checkAck(InputStream in) throws IOException {
int b = in.read();
// b may be 0 for success,
// 1 for error,
// 2 for fatal error
if (b == 1 || b == 2) {
StringBuffer sb = new StringBuffer();
int c;
do {
c = in.read();
sb.append((char) c);
} while (c != '\n');
System.out.print(sb.toString());
}
return b;
}
/**
* No-op implementation of HostKeyRepository to ensure that we don't store
* host keys for EC2 hosts since EC2 hosts are transient.
*/
private class NullHostKeyRepository implements HostKeyRepository {
public void add(HostKey hostkey, UserInfo ui) {}
public int check(String host, byte[] key) {
return NOT_INCLUDED;
}
public HostKey[] getHostKey() {
return null;
}
public HostKey[] getHostKey(String host, String type) {
return null;
}
public String getKnownHostsRepositoryID() {
return null;
}
public void remove(String host, String type) {}
public void remove(String host, String type, byte[] key) {}
}
}