/*
* Copyright (c) 2008-2014, XebiaLabs B.V., All rights reserved.
*
*
* Overthere is licensed under the terms of the GPLv2
* <http://www.gnu.org/licenses/old-licenses/gpl-2.0.html>, like most XebiaLabs Libraries.
* There are special exceptions to the terms and conditions of the GPLv2 as it is applied to
* this software, see the FLOSS License Exception
* <http://github.com/xebialabs/overthere/blob/master/LICENSE>.
*
* 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; version 2
* of the License.
*
* 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, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth
* Floor, Boston, MA 02110-1301 USA
*/
package com.xebialabs.overthere.cifs.winrm;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.io.StringWriter;
import java.io.Writer;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.security.PrivilegedActionException;
import java.security.PrivilegedExceptionAction;
import java.security.UnrecoverableKeyException;
import java.util.Iterator;
import java.util.List;
import java.util.UUID;
import javax.security.auth.Subject;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.kerberos.KerberosPrincipal;
import javax.security.auth.login.LoginContext;
import javax.security.auth.login.LoginException;
import org.apache.commons.codec.binary.Base64;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.BasicUserPrincipal;
import org.apache.http.auth.Credentials;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.conn.ssl.TrustStrategy;
import org.apache.http.conn.ssl.X509HostnameVerifier;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.HttpContext;
import org.dom4j.Document;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.dom4j.QName;
import org.dom4j.io.OutputFormat;
import org.dom4j.io.XMLWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.xebialabs.overthere.cifs.WinrmHttpsCertificateTrustStrategy;
import com.xebialabs.overthere.cifs.WinrmHttpsHostnameVerificationStrategy;
import com.xebialabs.overthere.cifs.winrm.soap.Action;
import com.xebialabs.overthere.cifs.winrm.soap.BodyBuilder;
import com.xebialabs.overthere.cifs.winrm.soap.HeaderBuilder;
import com.xebialabs.overthere.cifs.winrm.soap.OptionSet;
import com.xebialabs.overthere.cifs.winrm.soap.ResourceURI;
import com.xebialabs.overthere.cifs.winrm.soap.SoapAction;
import com.xebialabs.overthere.cifs.winrm.soap.SoapMessageBuilder;
import com.xebialabs.overthere.cifs.winrm.soap.Soapy;
import static com.xebialabs.overthere.util.OverthereUtils.closeQuietly;
import static org.apache.http.auth.AuthScope.ANY_HOST;
import static org.apache.http.auth.AuthScope.ANY_PORT;
import static org.apache.http.auth.AuthScope.ANY_REALM;
import static org.apache.http.client.params.AuthPolicy.BASIC;
import static org.apache.http.client.params.AuthPolicy.KERBEROS;
import static org.apache.http.client.params.AuthPolicy.SPNEGO;
import static org.apache.http.client.params.ClientPNames.HANDLE_AUTHENTICATION;
import static org.apache.http.util.EntityUtils.consume;
/**
* See http://msdn.microsoft.com/en-us/library/cc251731(v=prot.10).aspx for some examples of how the WS-MAN protocol works on Windows
*/
public class WinRmClient {
private final String username;
private final boolean enableKerberos;
private final String password;
private final URL targetURL;
private final String unmappedAddress;
private final int unmappedPort;
private String winRmTimeout;
private int winRmEnvelopSize;
private String winRmLocale;
private WinrmHttpsCertificateTrustStrategy httpsCertTrustStrategy;
private WinrmHttpsHostnameVerificationStrategy httpsHostnameVerifyStrategy;
private boolean kerberosUseHttpSpn;
private boolean kerberosAddPortToSpn;
private boolean kerberosDebug;
private String shellId;
private String commandId;
private int exitValue = -1;
private int chunk = 0;
public WinRmClient(final String username, final String password, final URL targetURL, final String unmappedAddress, final int unmappedPort) {
int posOfAtSign = username.indexOf('@');
if (posOfAtSign >= 0) {
String u = username.substring(0, posOfAtSign);
String d = username.substring(posOfAtSign + 1);
if (d.toUpperCase().equals(d)) {
this.username = username;
} else {
this.username = u + "@" + d.toUpperCase();
logger.warn("Fixing username [{}] to have an upper case domain name [{}]", username, this.username);
}
this.enableKerberos = true;
} else {
this.username = username;
this.enableKerberos = false;
}
this.password = password;
this.targetURL = targetURL;
this.unmappedAddress = unmappedAddress;
this.unmappedPort = unmappedPort;
}
public String createShell() {
logger.debug("Sending WinRM Create Shell request");
final Element bodyContent = DocumentHelper.createElement(QName.get("Shell", Namespaces.NS_WIN_SHELL));
bodyContent.addElement(QName.get("InputStreams", Namespaces.NS_WIN_SHELL)).addText("stdin");
bodyContent.addElement(QName.get("OutputStreams", Namespaces.NS_WIN_SHELL)).addText("stdout stderr");
final Document requestDocument = getRequestDocument(Action.WS_ACTION, ResourceURI.RESOURCE_URI_CMD, OptionSet.OPEN_SHELL, bodyContent);
Document responseDocument = sendRequest(requestDocument, SoapAction.SHELL);
shellId = getFirstElement(responseDocument, ResponseExtractor.SHELL_ID);
logger.debug("Received WinRM Create Shell response: shell with ID {} start created", shellId);
return shellId;
}
public String executeCommand(String command) {
logger.debug("Sending WinRM Execute Command request to shell {}", shellId);
final Element bodyContent = DocumentHelper.createElement(QName.get("CommandLine", Namespaces.NS_WIN_SHELL));
String encoded = "\"" + command + "\"";
bodyContent.addElement(QName.get("Command", Namespaces.NS_WIN_SHELL)).addText(encoded);
final Document requestDocument = getRequestDocument(Action.WS_COMMAND, ResourceURI.RESOURCE_URI_CMD, OptionSet.RUN_COMMAND, bodyContent);
Document responseDocument = sendRequest(requestDocument, SoapAction.COMMAND_LINE);
commandId = getFirstElement(responseDocument, ResponseExtractor.COMMAND_ID);
logger.debug("Received WinRM Execute Command response to shell {}: command with ID {} was started", shellId, commandId);
return commandId;
}
public boolean receiveOutput(OutputStream stdout, OutputStream stderr) throws IOException {
logger.debug("Sending WinRM Receive Output request for command {} in shell {}", commandId, shellId);
final Element bodyContent = DocumentHelper.createElement(QName.get("Receive", Namespaces.NS_WIN_SHELL));
bodyContent.addElement(QName.get("DesiredStream", Namespaces.NS_WIN_SHELL)).addAttribute("CommandId", commandId).addText("stdout stderr");
final Document requestDocument = getRequestDocument(Action.WS_RECEIVE, ResourceURI.RESOURCE_URI_CMD, null, bodyContent);
Document responseDocument = sendRequest(requestDocument, SoapAction.RECEIVE);
logger.debug("Received WinRM Receive Output response for command {} in shell {}", commandId, shellId);
handleStream(responseDocument, ResponseExtractor.STDOUT, stdout);
handleStream(responseDocument, ResponseExtractor.STDERR, stderr);
if (chunk == 0) {
parseExitCode(responseDocument);
}
chunk++;
/*
* We may need to get additional output if the stream has not finished. The CommandState will change from
* Running to Done like so:
*
* @example
*
* from... <rsp:CommandState CommandId="..."
* State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/> to...
* <rsp:CommandState CommandId="..."
* State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done">
* <rsp:ExitCode>0</rsp:ExitCode> </rsp:CommandState>
*/
final List<?> list = ResponseExtractor.STREAM_DONE.getXPath().selectNodes(responseDocument);
if (!list.isEmpty()) {
logger.trace("Found CommandState element with State=Done, parsing exit code and returning false.");
parseExitCode(responseDocument);
return false;
} else {
logger.trace("Did not find CommandState element with State=Done, returning true.");
return true;
}
}
public void sendInput(byte[] buf) throws IOException {
logger.debug("Sending WinRM Send Input request for command {} in shell {}", commandId, shellId);
final Element bodyContent = DocumentHelper.createElement(QName.get("Send", Namespaces.NS_WIN_SHELL));
final Base64 base64 = new Base64();
bodyContent.addElement(QName.get("Stream", Namespaces.NS_WIN_SHELL)).addAttribute("Name", "stdin").addAttribute("CommandId", commandId).addText(base64.encodeAsString(buf));
final Document requestDocument = getRequestDocument(Action.WS_SEND, ResourceURI.RESOURCE_URI_CMD, null, bodyContent);
sendRequest(requestDocument, SoapAction.SEND);
logger.debug("Sent WinRM Send Input request for command {} in shell {}", commandId, shellId);
}
public void signal() {
if (commandId == null) {
logger.warn("Not sending WinRM Signal request in shell {} because there is no running command", shellId);
return;
}
logger.debug("Sending WinRM Signal request for command {} in shell {}", commandId, shellId);
final Element bodyContent = DocumentHelper.createElement(QName.get("Signal", Namespaces.NS_WIN_SHELL)).addAttribute("CommandId", commandId);
bodyContent.addElement(QName.get("Code", Namespaces.NS_WIN_SHELL)).addText("http://schemas.microsoft.com/wbem/wsman/1/windows/shell/signal/terminate");
final Document requestDocument = getRequestDocument(Action.WS_SIGNAL, ResourceURI.RESOURCE_URI_CMD, null, bodyContent);
sendRequest(requestDocument, SoapAction.SIGNAL);
logger.debug("Sent WinRM Signal request for command {} in shell {}", commandId, shellId);
}
public void deleteShell() {
if (shellId == null) {
logger.warn("Not sending WinRM Delete Shell request because there is no shell");
return;
}
logger.debug("Sending WinRM Delete Shell request for shell {}", shellId);
final Document requestDocument = getRequestDocument(Action.WS_DELETE, ResourceURI.RESOURCE_URI_CMD, null, null);
sendRequest(requestDocument, null);
logger.debug("Sent WinRM Delete Shell request for shell {}", shellId);
}
public int exitValue() {
return exitValue;
}
private void parseExitCode(Document responseDocument) {
try {
logger.trace("Parsing exit code");
String exitCode = getFirstElement(responseDocument, ResponseExtractor.EXIT_CODE);
logger.trace("Found exit code {}", exitCode);
try {
exitValue = Integer.parseInt(exitCode);
} catch (NumberFormatException exc) {
logger.error("Cannot parse exit code {}, setting it to -1", exc);
exitValue = -1;
}
} catch (Exception exc) {
logger.trace("Exit code not found,");
}
}
private static void handleStream(Document responseDocument, ResponseExtractor stream, OutputStream out) throws IOException {
@SuppressWarnings("unchecked") final List<Element> streams = stream.getXPath().selectNodes(responseDocument);
if (!streams.isEmpty()) {
final Base64 base64 = new Base64();
Iterator<Element> itStreams = streams.iterator();
while (itStreams.hasNext()) {
Element e = itStreams.next();
// TODO check performance with http://www.iharder.net/current/java/base64/
final byte[] decode = base64.decode(e.getText());
out.write(decode);
}
}
}
private static String getFirstElement(Document doc, ResponseExtractor extractor) {
@SuppressWarnings("unchecked") final List<Element> nodes = extractor.getXPath().selectNodes(doc);
if (nodes.isEmpty())
throw new RuntimeException("Cannot find " + extractor.getXPath() + " in " + toString(doc));
final Element next = nodes.iterator().next();
return next.getText();
}
private Document getRequestDocument(Action action, ResourceURI resourceURI, OptionSet optionSet, Element bodyContent) {
SoapMessageBuilder message = Soapy.newMessage();
SoapMessageBuilder.EnvelopeBuilder envelope = message.envelope();
try {
addHeaders(envelope, action, resourceURI, optionSet);
} catch (URISyntaxException e) {
throw new IllegalArgumentException(e);
}
BodyBuilder body = envelope.body();
if (bodyContent != null)
body.setContent(bodyContent);
return message.getDocument();
}
private void addHeaders(SoapMessageBuilder.EnvelopeBuilder envelope, Action action, ResourceURI resourceURI, OptionSet optionSet)
throws URISyntaxException {
HeaderBuilder header = envelope.header();
header.to(targetURL.toURI()).replyTo(new URI("http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous"));
header.maxEnvelopeSize(winRmEnvelopSize);
header.withId(getUUID());
header.withLocale(winRmLocale);
header.withTimeout(winRmTimeout);
header.withAction(action.getUri());
if (shellId != null) {
header.withShellId(shellId);
}
header.withResourceURI(resourceURI.getUri());
if (optionSet != null) {
header.withOptionSet(optionSet.getKeyValuePairs());
}
}
private static String getUUID() {
return "uuid:" + UUID.randomUUID().toString().toUpperCase();
}
private Document sendRequest(final Document requestDocument, final SoapAction soapAction) {
if (enableKerberos) {
return runPrivileged(new PrivilegedSendMessage(requestDocument, soapAction));
} else {
return doSendRequest(requestDocument, soapAction);
}
}
/**
* Performs the JAAS login and run the sendRequest method within a privileged scope.
*/
private Document runPrivileged(final PrivilegedSendMessage privilegedSendMessage) {
final CallbackHandler handler = new ProvidedAuthCallback(username, password);
Document result;
try {
final LoginContext lc = new LoginContext("", null, handler, new KerberosJaasConfiguration(kerberosDebug));
lc.login();
result = Subject.doAs(lc.getSubject(), privilegedSendMessage);
} catch (LoginException e) {
throw new WinRmRuntimeIOException("Login failure sending message on " + targetURL + " error: " + e.getMessage(),
privilegedSendMessage.getRequestDocument(), null, e);
} catch (PrivilegedActionException e) {
throw new WinRmRuntimeIOException("Failure sending message on " + targetURL + " error: " + e.getMessage(),
privilegedSendMessage.getRequestDocument(), null, e.getException());
}
return result;
}
/**
* PrivilegedExceptionAction that wraps the internal sendRequest
*/
private class PrivilegedSendMessage implements PrivilegedExceptionAction<Document> {
private Document requestDocument;
private SoapAction soapAction;
private PrivilegedSendMessage(final Document requestDocument, final SoapAction soapAction) {
this.requestDocument = requestDocument;
this.soapAction = soapAction;
}
@Override
public Document run() throws Exception {
return WinRmClient.this.doSendRequest(requestDocument, soapAction);
}
public Document getRequestDocument() {
return requestDocument;
}
}
/**
* Internal sendRequest, performs the HTTP request and returns the result document.
*/
private Document doSendRequest(final Document requestDocument, final SoapAction soapAction) {
final DefaultHttpClient client = new DefaultHttpClient();
try {
configureHttpClient(client);
final HttpContext context = new BasicHttpContext();
final HttpPost request = new HttpPost(targetURL.toURI());
if (soapAction != null) {
request.setHeader("SOAPAction", soapAction.getValue());
}
final String requestBody = toString(requestDocument);
logger.trace("Request:\nPOST {}\n{}", targetURL, requestBody);
final HttpEntity entity = createEntity(requestBody);
request.setEntity(entity);
final HttpResponse response = client.execute(request, context);
logResponseHeaders(response);
if (response.getStatusLine().getStatusCode() != 200) {
throw new WinRmRuntimeIOException(String.format("Unexpected HTTP response on %s: %s (%s)",
targetURL, response.getStatusLine().getReasonPhrase(), response.getStatusLine().getStatusCode()));
}
final String responseBody = handleResponse(response, context);
Document responseDocument = DocumentHelper.parseText(responseBody);
logDocument("Response body:", responseDocument);
return responseDocument;
} catch (WinRmRuntimeIOException exc) {
throw exc;
} catch (Exception exc) {
throw new WinRmRuntimeIOException("Error when sending request to " + targetURL, requestDocument, null, exc);
} finally {
client.getConnectionManager().shutdown();
}
}
private void configureHttpClient(final DefaultHttpClient httpclient) throws GeneralSecurityException {
configureTrust(httpclient);
configureAuthentication(httpclient, BASIC, new BasicUserPrincipal(username));
if (enableKerberos) {
String spnServiceClass = kerberosUseHttpSpn ? "HTTP" : "WSMAN";
httpclient.getAuthSchemes().register(KERBEROS,
new WsmanKerberosSchemeFactory(!kerberosAddPortToSpn, spnServiceClass, unmappedAddress, unmappedPort));
httpclient.getAuthSchemes().register(SPNEGO, new WsmanSPNegoSchemeFactory(!kerberosAddPortToSpn, spnServiceClass, unmappedAddress, unmappedPort));
configureAuthentication(httpclient, KERBEROS, new KerberosPrincipal(username));
configureAuthentication(httpclient, SPNEGO, new KerberosPrincipal(username));
}
httpclient.getParams().setBooleanParameter(HANDLE_AUTHENTICATION, true);
}
private void configureTrust(final DefaultHttpClient httpclient) throws NoSuchAlgorithmException,
KeyManagementException, KeyStoreException, UnrecoverableKeyException {
if (!"https".equalsIgnoreCase(targetURL.getProtocol())) {
return;
}
final TrustStrategy trustStrategy = httpsCertTrustStrategy.getStrategy();
final X509HostnameVerifier hostnameVerifier = httpsHostnameVerifyStrategy.getVerifier();
final SSLSocketFactory socketFactory = new SSLSocketFactory(trustStrategy, hostnameVerifier);
final Scheme sch = new Scheme("https", 443, socketFactory);
httpclient.getConnectionManager().getSchemeRegistry().register(sch);
}
private void configureAuthentication(final DefaultHttpClient httpclient, final String scheme, final Principal principal) {
httpclient.getCredentialsProvider().setCredentials(new AuthScope(ANY_HOST, ANY_PORT, ANY_REALM, scheme), new Credentials() {
public Principal getUserPrincipal() {
return principal;
}
public String getPassword() {
return password;
}
});
}
private static void logResponseHeaders(final HttpResponse response) {
if (!logger.isTraceEnabled()) {
return;
}
StringBuilder headers = new StringBuilder();
for (final Header header : response.getAllHeaders()) {
headers.append(header.getName()).append(": ").append(header.getValue()).append("\n");
}
logger.trace("Response headers:\n{}", headers);
}
private static void logDocument(String caption, final Document document) {
if (!logger.isTraceEnabled()) {
return;
}
StringWriter text = new StringWriter();
try {
XMLWriter writer = new XMLWriter(text, OutputFormat.createPrettyPrint());
writer.write(document);
writer.close();
} catch (IOException e) {
logger.trace("{}\n{}", caption, e);
}
logger.trace("{}\n{}", caption, text);
}
/**
* Handle the httpResponse and return the SOAP XML String.
*/
protected String handleResponse(final HttpResponse response, final HttpContext context) throws IOException {
final HttpEntity entity = response.getEntity();
if (null == entity.getContentType() || !entity.getContentType().getValue().startsWith("application/soap+xml")) {
throw new WinRmRuntimeIOException("Error when sending request to " + targetURL + "; Unexpected content-type: " + entity.getContentType());
}
final InputStream is = entity.getContent();
final Writer writer = new StringWriter();
final Reader reader = new BufferedReader(new InputStreamReader(is, "UTF-8"));
try {
int n;
final char[] buffer = new char[1024];
while ((n = reader.read(buffer)) != -1) {
writer.write(buffer, 0, n);
}
} finally {
closeQuietly(reader);
closeQuietly(is);
consume(response.getEntity());
}
return writer.toString();
}
private static String toString(Document doc) {
StringWriter stringWriter = new StringWriter();
XMLWriter xmlWriter = new XMLWriter(stringWriter, OutputFormat.createPrettyPrint());
try {
xmlWriter.write(doc);
xmlWriter.close();
} catch (IOException exc) {
throw new WinRmRuntimeIOException("Cannnot convert XML to String ", exc);
}
return stringWriter.toString();
}
/**
* Create the HttpEntity to send in the request.
*/
protected HttpEntity createEntity(final String requestDocAsString) {
return new StringEntity(requestDocAsString, ContentType.create("application/soap+xml", "UTF-8"));
}
public void setWinRmTimeout(String timeout) {
this.winRmTimeout = timeout;
}
public void setWinRmEnvelopSize(int envelopSize) {
this.winRmEnvelopSize = envelopSize;
}
public void setWinRmLocale(String locale) {
this.winRmLocale = locale;
}
public void setHttpsCertTrustStrategy(WinrmHttpsCertificateTrustStrategy httpsCertTrustStrategy) {
this.httpsCertTrustStrategy = httpsCertTrustStrategy;
}
public void setHttpsHostnameVerifyStrategy(WinrmHttpsHostnameVerificationStrategy httpsHostnameVerifyStrategy) {
this.httpsHostnameVerifyStrategy = httpsHostnameVerifyStrategy;
}
public void setKerberosUseHttpSpn(boolean kerberosUseHttpSpn) {
this.kerberosUseHttpSpn = kerberosUseHttpSpn;
}
public void setKerberosAddPortToSpn(boolean kerberosAddPortToSpn) {
this.kerberosAddPortToSpn = kerberosAddPortToSpn;
}
public void setKerberosDebug(boolean kerberosDebug) {
this.kerberosDebug = kerberosDebug;
}
private static Logger logger = LoggerFactory.getLogger(WinRmClient.class);
}