/**
* Copyright 2011-2017 Asakusa Framework Team.
*
* 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://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License 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.asakusafw.yaess.jsch;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.asakusafw.yaess.basic.BlobUtil;
import com.asakusafw.yaess.basic.ProcessExecutor;
import com.asakusafw.yaess.core.Blob;
import com.asakusafw.yaess.core.ExecutionContext;
import com.asakusafw.yaess.core.ExecutionScriptHandler;
import com.asakusafw.yaess.core.VariableResolver;
import com.asakusafw.yaess.core.YaessLogger;
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
/**
* An implementation of {@link ProcessExecutor} using JSch.
* @since 0.2.3
* @version 0.8.0
*/
public class JschProcessExecutor implements ProcessExecutor {
static final YaessLogger YSLOG = new YaessJschLogger(JschProcessExecutor.class);
static final Logger LOG = LoggerFactory.getLogger(JschProcessExecutor.class);
static final Charset ENCODING = StandardCharsets.UTF_8;
private static final String PREFIX = "ssh.";
/**
* The key of user name.
* This value can includes environment variables in form of <code>${VARIABLE-NAME}</code>.
*/
public static final String KEY_USER = PREFIX + "user";
/**
* The key of host name.
* This value can includes environment variables in form of <code>${VARIABLE-NAME}</code>.
*/
public static final String KEY_HOST = PREFIX + "host";
/**
* The key of port number.
*/
public static final String KEY_PORT = PREFIX + "port";
/**
* The key of path to the private key.
* This value can includes environment variables in form of <code>${VARIABLE-NAME}</code>.
*/
public static final String KEY_PRIVATE_KEY = PREFIX + "privateKey";
/**
* The key of passphrase.
*/
public static final String KEY_PASS_PHRASE = PREFIX + "passPhrase";
/**
* The key of BLOB storage path prefix.
* @since 0.8.0
*/
public static final String KEY_TEMPORARY_BLOB_PREFIX = PREFIX + "blob";
/**
* The default value of {@link #KEY_TEMPORARY_BLOB_PREFIX}.
* @since 0.8.0
*/
public static final String DEFAULT_TEMPORARY_BLOB_PREFIX = "/tmp/yaess-blob-";
// see
// man bash > DEFINITIONS
private static final Pattern SH_NAME = Pattern.compile("[A-Za-z_][0-9A-Za-z_]*");
// see
// man bash > QUOTING
// $, `, ", \, or <newline>
private static final Pattern SH_METACHARACTERS = Pattern.compile("[\\$`\"\\\\\n]");
private final String user;
private final String host;
private final Integer port;
private final String privateKey;
private final String passPhrase;
private final JSch jsch;
private final String temporaryBlobPrefix;
/**
* Creates a new instance.
* @param user remote user name
* @param host remote host name
* @param portOrNull remote port number (nullable)
* @param privateKeyPath path to private key file
* @param passPhraseOrNull passphrase for the private key (nullable)
* @throws JSchException if failed to initialize SSH client
* @throws IllegalArgumentException if some parameters were {@code null}
*/
public JschProcessExecutor(
String user,
String host,
Integer portOrNull,
String privateKeyPath,
String passPhraseOrNull) throws JSchException {
this(user, host, portOrNull, privateKeyPath, passPhraseOrNull, DEFAULT_TEMPORARY_BLOB_PREFIX);
}
/**
* Creates a new instance.
* @param user remote user name
* @param host remote host name
* @param portOrNull remote port number (nullable)
* @param privateKeyPath path to private key file
* @param passPhraseOrNull passphrase for the private key (nullable)
* @param temporaryBlobPrefix the temporary BLOB storage path prefix
* @throws JSchException if failed to initialize SSH client
* @throws IllegalArgumentException if some parameters were {@code null}
* @since 0.8.0
*/
public JschProcessExecutor(
String user,
String host,
Integer portOrNull,
String privateKeyPath,
String passPhraseOrNull,
String temporaryBlobPrefix) throws JSchException {
if (user == null) {
throw new IllegalArgumentException("user must not be null"); //$NON-NLS-1$
}
if (host == null) {
throw new IllegalArgumentException("host must not be null"); //$NON-NLS-1$
}
if (privateKeyPath == null) {
throw new IllegalArgumentException("privateKeyPath must not be null"); //$NON-NLS-1$
}
if (temporaryBlobPrefix == null) {
throw new IllegalArgumentException("temporaryBlobPrefix must not be null"); //$NON-NLS-1$
}
this.user = user;
this.host = host;
this.port = portOrNull;
this.jsch = new JSch();
this.privateKey = privateKeyPath;
this.passPhrase = passPhraseOrNull;
this.temporaryBlobPrefix = temporaryBlobPrefix;
jsch.addIdentity(privateKeyPath, passPhraseOrNull);
}
/**
* Returns the remote user name.
* @return the user name
*/
public String getUser() {
return user;
}
/**
* Returns the remote host name.
* @return the host name
*/
public String getHost() {
return host;
}
/**
* Returns the remote port number.
* @return the port number, or {@code null} if is not specified
*/
public Integer getPort() {
return port;
}
/**
* Returns the path to the private key file.
* @return the path to the private key
*/
public String getPrivateKey() {
return privateKey;
}
/**
* Returns a pass phrase for private key.
* @return the pass phrase, or {@code null} if is not specified
*/
public String getPassPhrase() {
return passPhrase;
}
/**
* Returns the BLOB storage remote path prefix.
* @return the BLOB storage remote path prefix
*/
public String getTemporaryBlobPrefix() {
return temporaryBlobPrefix;
}
/**
* Extracts SSH profiles from configuration and returns a related executor.
* This operation extracts following entries from {@code configuration}:
* <ul>
* <li> {@link #KEY_USER remote user name} </li>
* <li> {@link #KEY_HOST remote host name} </li>
* <li> {@link #KEY_PORT remote port number} (can omit) </li>
* <li> {@link #KEY_PRIVATE_KEY private key path} </li>
* <li> {@link #KEY_PASS_PHRASE} (can omit) </li>
* </ul>
* @param servicePrefix prefix of configuration keys
* @param configuration target configuration
* @param variables variable resolver
* @return the created executor
* @throws JSchException if failed to initialize SSH client
* @throws IllegalArgumentException if configuration is invalid
*/
public static JschProcessExecutor extract(
String servicePrefix,
Map<String, String> configuration,
VariableResolver variables) throws JSchException {
if (servicePrefix == null) {
throw new IllegalArgumentException("servicePrefix must not be null"); //$NON-NLS-1$
}
if (configuration == null) {
throw new IllegalArgumentException("configuration must not be null"); //$NON-NLS-1$
}
if (variables == null) {
throw new IllegalArgumentException("variables must not be null"); //$NON-NLS-1$
}
String user = extract(KEY_USER, servicePrefix, configuration, variables, true);
String host = extract(KEY_HOST, servicePrefix, configuration, variables, true);
String portString = extract(KEY_PORT, servicePrefix, configuration, variables, false);
String privateKey = extract(KEY_PRIVATE_KEY, servicePrefix, configuration, variables, false);
String passPhrase = extract(KEY_PASS_PHRASE, servicePrefix, configuration, variables, false);
String tempBlob = extract(KEY_TEMPORARY_BLOB_PREFIX, servicePrefix, configuration, variables, false);
tempBlob = tempBlob == null ? DEFAULT_TEMPORARY_BLOB_PREFIX : tempBlob;
Integer port = null;
if (portString != null) {
try {
port = Integer.valueOf(portString);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(MessageFormat.format(
"Invalid port number in \"{0}\": {1}",
servicePrefix + '.' + KEY_PORT,
portString));
}
}
return new JschProcessExecutor(user, host, port, privateKey, passPhrase, tempBlob);
}
private static String extract(
String key,
String prefix,
Map<String, String> configuration,
VariableResolver variables,
boolean mandatory) {
assert key != null;
assert prefix != null;
assert configuration != null;
assert variables != null;
String value = configuration.get(key);
if (value == null) {
if (mandatory) {
throw new IllegalArgumentException(MessageFormat.format(
"Mandatory entry \"{0}\" is not set",
prefix + '.' + key));
} else {
return null;
}
}
try {
return variables.replace(value, true);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(MessageFormat.format(
"Failed to resolve variables in \"{0}\": {1}",
prefix + '.' + key,
value));
}
}
@Override
public int execute(
ExecutionContext context,
List<String> commandLineTokens,
Map<String, String> environmentVariables,
Map<String, Blob> extensions,
OutputStream output) throws InterruptedException, IOException {
try {
return execute0(context, commandLineTokens, environmentVariables, extensions, output);
} catch (IOException | JSchException e) {
throw new IOException(MessageFormat.format(
"Failed to execute command via SSH ({0}@{1}:{2})",
user,
host,
String.valueOf(port)), e);
}
}
private int execute0(
ExecutionContext context,
List<String> commandLineTokens,
Map<String, String> environmentVariables,
Map<String, Blob> extensions,
OutputStream output) throws IOException, JSchException, InterruptedException {
assert context != null;
assert commandLineTokens != null;
assert environmentVariables != null;
assert extensions != null;
assert output != null;
Session session = jsch.getSession(user, host);
if (port != null) {
session.setPort(port);
}
session.setConfig("StrictHostKeyChecking", "no");
session.setServerAliveInterval((int) TimeUnit.SECONDS.toMillis(30));
try {
YSLOG.info("I00001", user, host, port, privateKey);
long sessionStart = System.currentTimeMillis();
session.connect((int) TimeUnit.SECONDS.toMillis(60));
long sessionEnd = System.currentTimeMillis();
YSLOG.info("I00002", user, host, port, privateKey, sessionEnd - sessionStart);
try {
Map<String, String> newEnv = resolveBlobs(session, environmentVariables, extensions, output);
return execute0(session, commandLineTokens, newEnv, output);
} finally {
session.disconnect();
}
} catch (IOException | JSchException e) {
YSLOG.error(e, "E00001", user, host, port, privateKey);
throw e;
}
}
private Map<String, String> resolveBlobs(
Session session,
Map<String, String> environmentVariables,
Map<String, Blob> extensions,
OutputStream output) throws IOException, JSchException {
if (extensions.isEmpty()) {
return environmentVariables;
}
Map<String, String> results = new LinkedHashMap<>();
results.putAll(environmentVariables);
int index = 0;
for (Map.Entry<String, Blob> entry : extensions.entrySet()) {
String name = entry.getKey();
Blob blob = entry.getValue();
String suffix = BlobUtil.getSuffix(name, blob);
String path = String.format("%s%s%s", temporaryBlobPrefix, UUID.randomUUID(), suffix); //$NON-NLS-1$
send(session, blob, path, index++, output);
results.put(ExecutionScriptHandler.ENV_EXTENSION_PREFIX + name, path);
}
return results;
}
private void send(
Session session,
Blob blob, String remotePath, int localIndex,
OutputStream output) throws IOException, JSchException {
long size = blob.getSize();
String exec = String.format("scp -t \"%s\"", toLiteral(remotePath)); //$NON-NLS-1$
String header = String.format("C0644 %d BLOB-%d", size, localIndex); //$NON-NLS-1$
YSLOG.info("I01001", size, remotePath);
long start = System.currentTimeMillis();
ChannelExec channel = (ChannelExec) session.openChannel("exec");
channel.setCommand(exec);
channel.setErrStream(output, true);
try (InputStream stdin = channel.getInputStream();
OutputStream stdout = channel.getOutputStream()) {
channel.connect();
stdout.write(header.getBytes(ENCODING));
stdout.write('\n');
stdout.flush();
checkAck(blob, stdin);
putBlob(blob, stdout);
stdout.write(0);
stdout.flush();
checkAck(blob, stdin);
} finally {
channel.disconnect();
}
long end = System.currentTimeMillis();
YSLOG.info("I01002", size, remotePath, end - start);
}
private void checkAck(Blob blob, InputStream stdin) throws IOException {
int c = stdin.read();
if (c != 0) {
throw new IOException(MessageFormat.format(
"error occurred while sending blob via SSH: {0}",
blob.toString()));
}
}
private void putBlob(Blob blob, OutputStream stdout) throws IOException {
byte[] buf = new byte[1024];
try (InputStream in = blob.open()) {
while (true) {
int read = in.read(buf);
if (read < 0) {
break;
}
stdout.write(buf, 0, read);
}
}
}
private int execute0(Session session, List<String> commandLineTokens, Map<String, String> environmentVariables,
OutputStream output) throws JSchException, InterruptedException {
ChannelExec channel = (ChannelExec) session.openChannel("exec");
channel.setCommand(buildCommand(commandLineTokens, environmentVariables));
channel.setInputStream(new ByteArrayInputStream(new byte[0]), true);
channel.setOutputStream(output, true);
channel.setErrStream(output, true);
YSLOG.info("I00003", user, host, port, privateKey, commandLineTokens.get(0));
long channelStart = System.currentTimeMillis();
channel.connect((int) TimeUnit.SECONDS.toMillis(60));
long channelEnd = System.currentTimeMillis();
YSLOG.info("I00004", user, host, port, privateKey, commandLineTokens.get(0), channelEnd - channelStart);
int exitStatus;
try {
while (true) {
if (channel.isClosed()) {
break;
}
Thread.sleep(100);
}
exitStatus = channel.getExitStatus();
} finally {
channel.disconnect();
}
YSLOG.info("I00005", user, host, port, privateKey, commandLineTokens.get(0), exitStatus);
return exitStatus;
}
private String buildCommand(List<String> commandLineTokens, Map<String, String> environmentVariables) {
assert commandLineTokens != null;
assert environmentVariables != null;
// FIXME for bsh only
StringBuilder buf = new StringBuilder();
for (Map.Entry<String, String> entry : environmentVariables.entrySet()) {
if (SH_NAME.matcher(entry.getKey()).matches() == false) {
YSLOG.warn("W00001",
entry.getKey(),
entry.getValue());
continue;
}
if (buf.length() > 0) {
buf.append(' ');
}
buf.append(entry.getKey());
buf.append('=');
String replaced = toLiteral(entry.getValue());
buf.append(replaced);
}
for (String token : commandLineTokens) {
if (buf.length() > 0) {
buf.append(' ');
}
buf.append(toLiteral(token));
}
return buf.toString();
}
private static String toLiteral(String token) {
String replaced = SH_METACHARACTERS.matcher(token).replaceAll("\\\\$0");
return '"' + replaced + '"';
}
}