/* * RHQ Management Platform * Copyright (C) 2005-2017 Red Hat, Inc. * All rights reserved. * * 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 Street, Fifth Floor, Boston, MA 02110-1301, USA */ package org.rhq.modules.plugins.wildfly10; import java.io.BufferedOutputStream; import java.io.Closeable; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.auth.AuthScope; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.utils.URIBuilder; import org.apache.http.conn.ClientConnectionManager; import org.apache.http.conn.scheme.SchemeRegistry; import org.apache.http.entity.mime.MultipartEntity; import org.apache.http.entity.mime.content.FileBody; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.impl.conn.BasicClientConnectionManager; import org.apache.http.params.HttpConnectionParams; import org.apache.http.params.HttpParams; import org.apache.http.util.EntityUtils; import org.codehaus.jackson.JsonNode; import org.codehaus.jackson.map.ObjectMapper; import org.rhq.modules.plugins.wildfly10.helper.ServerPluginConfiguration; /** * Connection for uploading of content. * * This class needs to cache the content to be uploaded. Users of this class should: * <ol> * <li>Call {@link #getOutputStream()} an write their content to the returned {@link OutputStream}</li> * <li>Call {@link #finishUpload()} to actually upload the content</li> * </ol> * * As instances of this class held some resources it is the caller responsibility to call {@link #cancelUpload()} * instead of {@link #finishUpload()} if, for example, an {@link IOException} occured while writing their content * to the {@link OutputStream}. * * Original code taken from https://github.com/jbossas/jboss-as/blob/master/testsuite/smoke/src/test/java/org/jboss/as/test/surefire/servermodule/HttpDeploymentUploadUnitTestCase.java * * @author Jonathan Pearlin (of the original code) * @author Heiko W. Rupp * @author Thomas Segismont */ public class ASUploadConnection { private static final Log LOG = LogFactory.getLog(ASUploadConnection.class); private static final int SOCKET_CONNECTION_TIMEOUT = 30 * 1000; // 30sec private static final int SOCKET_READ_TIMEOUT = 10 * 60 * 1000; // 10 minutes private static final String TRIGGER_AUTH_URI = ASConnection.MANAGEMENT_URI; private static final String UPLOAD_URI = ASConnection.MANAGEMENT_URI + "/add-content"; private static final int FILE_POST_MAX_LOGGABLE_RESPONSE_LENGTH = 1024 * 2; // 2k max private static final String EMPTY_JSON_TREE = "{}"; private static final String JSON_NODE_FAILURE_DESCRIPTION = "failure-description"; private static final String JSON_NODE_FAILURE_DESCRIPTION_VALUE_DEFAULT = "FailureDescription: -input was null-"; private static final String JSON_NODE_OUTCOME = "outcome"; private static final String JSON_NODE_OUTCOME_VALUE_FAILED = "failed"; private static final String SYSTEM_LINE_SEPARATOR = System.getProperty("line.separator"); private final ASConnectionParams asConnectionParams; private final int timeout; private final URI triggerAuthUri; private final URI uploadUri; private final UsernamePasswordCredentials credentials; private String filename; private File cacheFile; private BufferedOutputStream cacheOutputStream; /** * @deprecated as of 4.6. This class is not reusable so there is no reason not to provide the filename to the * constructor. Use {@link #ASUploadConnection(ASConnectionParams, String)} instead. */ @Deprecated public ASUploadConnection(String host, int port, String user, String password) { this(new ASConnectionParamsBuilder() // .setHost(host) // .setPort(port) // .setUsername(user) // .setPassword(password) // .createASConnectionParams(), null); } /** * @deprecated as of RHQ 4.10. Use {@link #ASUploadConnection(ASConnectionParams, String)} instead. */ @Deprecated public ASUploadConnection(String host, int port, String user, String password, String fileName) { this(new ASConnectionParamsBuilder() // .setHost(host) // .setPort(port) // .setUsername(user) // .setPassword(password) // .createASConnectionParams(), fileName); } /** * @deprecated as of 4.6. This class is not reusable so there is no reason not to provide the filename to the * constructor. Use {@link #ASUploadConnection(ASConnection, String)} instead. */ @Deprecated public ASUploadConnection(ASConnection asConnection) { this(asConnection.getAsConnectionParams(), null); } /** * @param asConnection the object which will provide the {@link ASConnectionParams} * @param fileName * @see #ASUploadConnection(ASConnectionParams, String) */ public ASUploadConnection(ASConnection asConnection, String fileName) { this(asConnection.getAsConnectionParams(), fileName); } /** * @param asConnection the object which will provide the {@link ASConnectionParams} * @param fileName * @see #ASUploadConnection(ASConnectionParams, String) */ public ASUploadConnection(ASConnection asConnection, String fileName, Integer timeoutMilliseconds) { this(asConnection.getAsConnectionParams(), fileName, timeoutMilliseconds); } /** * Creates a new {@link ASUploadConnection} for a remote http management interface. * * It's the responsibility of the caller to make sure either {@link #finishUpload()} or {@link #cancelUpload()} * will be called to free resources this class helds. * * @param params */ public ASUploadConnection(ASConnectionParams params, String filename) { this(params, filename, null); } /** * Creates a new {@link ASUploadConnection} for a remote http management interface. * * It's the responsibility of the caller to make sure either {@link #finishUpload()} or {@link #cancelUpload()} * will be called to free resources this class helds. * * @param params */ public ASUploadConnection(ASConnectionParams params, String filename, Integer timeoutMilliseconds) { asConnectionParams = params; if (asConnectionParams.getHost() == null) { throw new IllegalArgumentException("Management host cannot be null."); } if (asConnectionParams.getPort() <= 0 || asConnectionParams.getPort() > 65535) { throw new IllegalArgumentException("Invalid port: " + asConnectionParams.getPort()); } this.filename = filename; if(timeoutMilliseconds != null && timeoutMilliseconds.intValue() > 0) { timeout = timeoutMilliseconds; } else { timeout = SOCKET_READ_TIMEOUT; } triggerAuthUri = buildTriggerAuthUri(); uploadUri = buildUploadUri(); if (asConnectionParams.getUsername() != null && asConnectionParams.getPassword() != null) { credentials = new UsernamePasswordCredentials(asConnectionParams.getUsername(), asConnectionParams.getPassword()); } else { credentials = null; } } private URI buildTriggerAuthUri() { try { return new URIBuilder() // .setScheme(asConnectionParams.isSecure() ? ASConnection.HTTPS_SCHEME : ASConnection.HTTP_SCHEME) // .setHost(asConnectionParams.getHost()) // .setPort(asConnectionParams.getPort()) // .setPath(TRIGGER_AUTH_URI) // .build(); } catch (URISyntaxException e) { throw new RuntimeException("Could not build auth trigger URI: " + e.getMessage(), e); } } private URI buildUploadUri() { try { return new URIBuilder() // .setScheme(asConnectionParams.isSecure() ? ASConnection.HTTPS_SCHEME : ASConnection.HTTP_SCHEME) // .setHost(asConnectionParams.getHost()) // .setPort(asConnectionParams.getPort()) // .setPath(UPLOAD_URI) // .build(); } catch (URISyntaxException e) { throw new RuntimeException("Could not build upload URI: " + e.getMessage(), e); } } /** * @deprecated as of RHQ 4.10. Use {@link #ASUploadConnection(ASConnectionParams, String)} instead. */ @Deprecated public static ASUploadConnection newInstanceForServerPluginConfiguration(ServerPluginConfiguration pluginConfig, String fileName) { return new ASUploadConnection(ASConnectionParams.createFrom(pluginConfig), fileName); } /** * @deprecated as of 4.6. Instances of this class should be created with fileName supplied to the constructor. * If the caller does that there is no reason for late initialization of fileName. Then use * {@link #getOutputStream()} instead. */ @Deprecated public OutputStream getOutputStream(String fileName) { filename = fileName; return getOutputStream(); } /** * Gives an outpustream where callers should write the content which will be uploaded to AS7 * when {@link #finishUpload()} will be called. * * @return an {@link OutputStream} or null if it could not be created. */ public OutputStream getOutputStream() { try { cacheFile = File.createTempFile(getClass().getSimpleName(), ".cache"); cacheOutputStream = new BufferedOutputStream(new FileOutputStream(cacheFile)); return cacheOutputStream; } catch (IOException e) { LOG.error("Could not create outputstream for " + filename, e); } return null; } /** * To be called instead of {@link #finishUpload()} if one doesn't want to upload the cached content. * * It's important to call this method if not actually uploading as it frees resources this class helds. */ public void cancelUpload() { closeQuietly(cacheOutputStream); deleteCacheFile(); } /** * Triggers the real upload to the AS7 instance. At this point the caller should have written * the content in the {@link OutputStream} given by {@link #getOutputStream()}. * * @return a {@link JsonNode} instance read from the upload response body or null if something went wrong. */ public JsonNode finishUpload() { if (filename == null) { // At this point the fileName should have been set whether at instanciation or in #getOutputStream(String) throw new IllegalStateException("Upload fileName is null"); } closeQuietly(cacheOutputStream); SchemeRegistry schemeRegistry = new SchemeRegistryBuilder(asConnectionParams).buildSchemeRegistry(); ClientConnectionManager httpConnectionManager = new BasicClientConnectionManager(schemeRegistry); DefaultHttpClient httpClient = new DefaultHttpClient(httpConnectionManager); HttpParams httpParams = httpClient.getParams(); HttpConnectionParams.setConnectionTimeout(httpParams, SOCKET_CONNECTION_TIMEOUT); HttpConnectionParams.setSoTimeout(httpParams, timeout); if (credentials != null && !asConnectionParams.isClientcertAuthentication()) { httpClient.getCredentialsProvider().setCredentials( new AuthScope(asConnectionParams.getHost(), asConnectionParams.getPort()), credentials); // If credentials were provided, we will first send a GET request to trigger the authentication challenge // This allows to send the potentially big file only once to the server // The typical resulting http exchange would be: // // GET without auth <- 401 (start auth challenge : the server will name the realm and the scheme) // GET with auth <- 200 // POST big file // // Note this only works because we use SimpleHttpConnectionManager which maintains only one HttpConnection // // A better way to avoid uploading a big file twice would be to use the header "Expect: Continue" // Unfortunately AS7 replies "100 Continue" even if authentication headers are not present yet // // There is no need to trigger digest authentication when client certification authentication is used HttpGet triggerAuthRequest = new HttpGet(triggerAuthUri); try { // Send GET request in order to trigger authentication // We don't check response code because we're not already uploading the file httpClient.execute(triggerAuthRequest); } catch (Exception ignore) { // We don't stop trying upload if triggerAuthRequest raises exception // See comment above } finally { triggerAuthRequest.abort(); } } HttpPost uploadRequest = new HttpPost(uploadUri); try { // Now upload file with multipart POST request MultipartEntity multipartEntity = new MultipartEntity(); multipartEntity.addPart(filename, new FileBody(cacheFile)); uploadRequest.setEntity(multipartEntity); HttpResponse uploadResponse = httpClient.execute(uploadRequest); if (uploadResponse.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { logUploadDoesNotEndWithHttpOkStatus(uploadResponse); return null; } ObjectMapper objectMapper = new ObjectMapper(); InputStream responseBodyAsStream = uploadResponse.getEntity().getContent(); if (responseBodyAsStream == null) { LOG.warn("POST request has no response body"); return objectMapper.readTree(EMPTY_JSON_TREE); } return objectMapper.readTree(responseBodyAsStream); } catch (Exception e) { LOG.error(e); return null; } finally { // Release httpclient resources uploadRequest.abort(); httpConnectionManager.shutdown(); // Delete cache file deleteCacheFile(); } } /** * Inspects the supplied {@link JsonNode} instance and returns the json 'failure-description' node value as text. * * @param jsonNode * @return */ public static String getFailureDescription(JsonNode jsonNode) { if (jsonNode == null) { return JSON_NODE_FAILURE_DESCRIPTION_VALUE_DEFAULT; } JsonNode node = jsonNode.findValue(JSON_NODE_FAILURE_DESCRIPTION); if (node == null) { return JSON_NODE_FAILURE_DESCRIPTION_VALUE_DEFAULT; } return node.getValueAsText(); } /** * Inspects the supplied {@link JsonNode} instance to determine if it represents an error outcome. * * @param jsonNode * @return */ public static boolean isErrorReply(JsonNode jsonNode) { if (jsonNode == null) { return true; } if (jsonNode.has(JSON_NODE_OUTCOME)) { String outcome = null; try { JsonNode outcomeNode = jsonNode.findValue(JSON_NODE_OUTCOME); outcome = outcomeNode.getTextValue(); if (outcome.equals(JSON_NODE_OUTCOME_VALUE_FAILED)) { return true; } } catch (Exception e) { LOG.error(e); return true; } } return false; } private void logUploadDoesNotEndWithHttpOkStatus(HttpResponse uploadResponse) { StringBuilder logMessageBuilder = new StringBuilder("File upload failed: ").append(ASConnection .statusAsString(uploadResponse.getStatusLine())); // If it's sure there is a response body and it's not too long if (uploadResponse.getEntity().getContentLength() > 0 && uploadResponse.getEntity().getContentLength() < FILE_POST_MAX_LOGGABLE_RESPONSE_LENGTH) { try { // It is safe to get response body as String as we know the body is not too long String responseBodyAsString = EntityUtils.toString(uploadResponse.getEntity()); logMessageBuilder.append(SYSTEM_LINE_SEPARATOR).append(responseBodyAsString); } catch (IOException ignore) { // If we can't get the response body, we'll just not log it } } LOG.warn(logMessageBuilder.toString()); } private void closeQuietly(final Closeable closeable) { if (closeable != null) { try { closeable.close(); } catch (final IOException ignore) { } } } private void deleteCacheFile() { if (cacheFile != null) { cacheFile.delete(); } } /** * Get the currently active upload timeout * @return timeout in seconds * @deprecated there is no reason to expose this attribute */ @Deprecated public int getTimeout() { return timeout; } /** * Set upload timeout in seconds. * @param timeout upload timeout in seconds * @deprecated there is no reason to expose this attribute */ @Deprecated public void setTimeout(int timeout) { } }