001: /*
002: * Licensed to the Apache Software Foundation (ASF) under one or more
003: * contributor license agreements. See the NOTICE file distributed with
004: * this work for additional information regarding copyright ownership.
005: * The ASF licenses this file to You under the Apache License, Version 2.0
006: * (the "License"); you may not use this file except in compliance with
007: * the License. You may obtain a copy of the License at
008: *
009: * http://www.apache.org/licenses/LICENSE-2.0
010: *
011: * Unless required by applicable law or agreed to in writing, software
012: * distributed under the License is distributed on an "AS IS" BASIS,
013: * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014: * See the License for the specific language governing permissions and
015: * limitations under the License.
016: *
017: */
018:
019: package org.apache.jmeter.protocol.http.proxy;
020:
021: import java.io.BufferedInputStream;
022: import java.io.BufferedOutputStream;
023: import java.io.DataOutputStream;
024: import java.io.IOException;
025: import java.io.OutputStream;
026: import java.net.Socket;
027: import java.net.UnknownHostException;
028: import java.net.URL;
029: import java.util.Map;
030:
031: import org.apache.jmeter.protocol.http.control.HeaderManager;
032: import org.apache.jmeter.protocol.http.parser.HTMLParseException;
033: import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase;
034: import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory;
035: import org.apache.jmeter.protocol.http.util.HTTPConstants;
036: import org.apache.jmeter.samplers.SampleResult;
037: import org.apache.jmeter.testelement.TestElement;
038: import org.apache.jmeter.util.JMeterUtils;
039: import org.apache.jorphan.logging.LoggingManager;
040: import org.apache.jorphan.util.JOrphanUtils;
041: import org.apache.log.Logger;
042:
043: /**
044: * Thread to handle one client request. Gets the request from the client and
045: * passes it on to the server, then sends the response back to the client.
046: * Information about the request and response is stored so it can be used in a
047: * JMeter test plan.
048: *
049: */
050: public class Proxy extends Thread {
051: private static final Logger log = LoggingManager
052: .getLoggerForClass();
053:
054: private static final String NEW_LINE = "\n"; // $NON-NLS-1$
055:
056: private static final String[] headersToRemove;
057:
058: // Allow list of headers to be overridden
059: private static final String PROXY_HEADERS_REMOVE = "proxy.headers.remove"; // $NON-NLS-1$
060:
061: private static final String PROXY_HEADERS_REMOVE_DEFAULT = "If-Modified-Since,If-None-Match"; // $NON-NLS-1$
062:
063: private static final String PROXY_HEADERS_REMOVE_SEPARATOR = ","; // $NON-NLS-1$
064:
065: static {
066: String removeList = JMeterUtils.getPropDefault(
067: PROXY_HEADERS_REMOVE, PROXY_HEADERS_REMOVE_DEFAULT);
068: headersToRemove = JOrphanUtils.split(removeList,
069: PROXY_HEADERS_REMOVE_SEPARATOR);
070: log.info("Proxy will remove the headers: " + removeList);
071: }
072:
073: /** Socket to client. */
074: private Socket clientSocket = null;
075:
076: /** Target to receive the generated sampler. */
077: private ProxyControl target;
078:
079: /** Whether or not to capture the HTTP headers. */
080: private boolean captureHttpHeaders;
081:
082: /** Whether to try to spoof as https **/
083: private boolean httpsSpoof;
084:
085: private String httpsSpoofMatch; // if non-empty, then URLs must match in order to be spoofed
086:
087: /** Reference to Deamon's Map of url string to page character encoding of that page */
088: private Map pageEncodings;
089: /** Reference to Deamon's Map of url string to character encoding for the form */
090: private Map formEncodings;
091:
092: /**
093: * Default constructor - used by newInstance call in Daemon
094: */
095: public Proxy() {
096: }
097:
098: /**
099: * Create and configure a new Proxy object.
100: *
101: * @param clientSocket
102: * the socket connection to the client
103: * @param target
104: * the ProxyControl which will receive the generated sampler
105: */
106: Proxy(Socket clientSocket, ProxyControl target) {
107: configure(clientSocket, target);
108: }
109:
110: /**
111: * Configure the Proxy.
112: *
113: * @param clientSocket
114: * the socket connection to the client
115: * @param target
116: * the ProxyControl which will receive the generated sampler
117: */
118: void configure(Socket _clientSocket, ProxyControl _target) {
119: configure(_clientSocket, _target, null, null);
120: }
121:
122: /**
123: * Configure the Proxy.
124: *
125: * @param _clientSocket
126: * the socket connection to the client
127: * @param _target
128: * the ProxyControl which will receive the generated sampler
129: * @param _pageEncodings
130: * reference to the Map of Deamon, with mappings from page urls to encoding used
131: * @param formEncodingsEncodings
132: * reference to the Map of Deamon, with mappings from form action urls to encoding used
133: */
134: void configure(Socket _clientSocket, ProxyControl _target,
135: Map _pageEncodings, Map _formEncodings) {
136: this .target = _target;
137: this .clientSocket = _clientSocket;
138: this .captureHttpHeaders = _target.getCaptureHttpHeaders();
139: this .httpsSpoof = _target.getHttpsSpoof();
140: this .httpsSpoofMatch = _target.getHttpsSpoofMatch();
141: this .pageEncodings = _pageEncodings;
142: this .formEncodings = _formEncodings;
143: }
144:
145: /**
146: * Main processing method for the Proxy object
147: */
148: public void run() {
149: // Check which HTTPSampler class we should use
150: String httpSamplerName = HTTPSamplerFactory.DEFAULT_CLASSNAME;
151: if (target.getSamplerTypeName() == ProxyControl.SAMPLER_TYPE_HTTP_SAMPLER) {
152: httpSamplerName = HTTPSamplerFactory.HTTP_SAMPLER_JAVA;
153: } else if (target.getSamplerTypeName() == ProxyControl.SAMPLER_TYPE_HTTP_SAMPLER2) {
154: httpSamplerName = HTTPSamplerFactory.HTTP_SAMPLER_APACHE;
155: }
156: // Instantiate the sampler
157: HTTPSamplerBase sampler = HTTPSamplerFactory
158: .newInstance(httpSamplerName);
159:
160: HttpRequestHdr request = new HttpRequestHdr(sampler);
161: SampleResult result = null;
162: HeaderManager headers = null;
163:
164: try {
165: request.parse(new BufferedInputStream(clientSocket
166: .getInputStream()));
167:
168: // Populate the sampler. It is the same sampler as we sent into
169: // the constructor of the HttpRequestHdr instance above
170: request.getSampler(pageEncodings, formEncodings);
171:
172: /*
173: * Create a Header Manager to ensure that the browsers headers are
174: * captured and sent to the server
175: */
176: headers = request.getHeaderManager();
177: sampler.setHeaderManager(headers);
178:
179: /*
180: * If we are trying to spoof https, change the protocol
181: */
182: boolean forcedHTTP = false; // so we know when to revert
183: if (httpsSpoof) {
184: if (httpsSpoofMatch.length() > 0) {
185: String url = request.getUrl();
186: if (url.matches(httpsSpoofMatch)) {
187: sampler
188: .setProtocol(HTTPConstants.PROTOCOL_HTTPS);
189: forcedHTTP = true;
190: }
191: } else {
192: sampler.setProtocol(HTTPConstants.PROTOCOL_HTTPS);
193: forcedHTTP = true;
194: }
195: }
196: sampler.threadStarted(); // Needed for HTTPSampler2
197: result = sampler.sample();
198:
199: /*
200: * If we're dealing with text data, and if we're spoofing https,
201: * replace all occurences of "https://" with "http://" for the client.
202: * TODO - also check the match string to restrict the changes further?
203: */
204: if (forcedHTTP
205: && SampleResult.TEXT.equals(result.getDataType())) {
206: final String enc = result.getDataEncodingWithDefault();
207: String noHttpsResult = new String(result
208: .getResponseData(), enc);
209: final String HTTPS_HOST = // match https://host[:port]/ and drop default port if present
210: "https://([^:/]+)(:"
211: + HTTPConstants.DEFAULT_HTTPS_PORT_STRING
212: + ")?"; // $NON-NLS-1$ $NON-NLS-2$
213: noHttpsResult = noHttpsResult.replaceAll(HTTPS_HOST,
214: "http://$1"); // $NON-NLS-1$
215: result.setResponseData(noHttpsResult.getBytes(enc));
216: }
217:
218: // Find the page encoding and possibly encodings for forms in the page
219: // in the response from the web server
220: String pageEncoding = addPageEncoding(result);
221: addFormEncodings(result, pageEncoding);
222:
223: writeToClient(result, new BufferedOutputStream(clientSocket
224: .getOutputStream()));
225: } catch (UnknownHostException uhe) {
226: log.warn("Server Not Found.", uhe);
227: writeErrorToClient(HttpReplyHdr.formServerNotFound());
228: result = generateErrorResult(result, uhe); // Generate result (if nec.) and populate it
229: } catch (IllegalArgumentException e) {
230: log.error("Not implemented (probably used https)", e);
231: writeErrorToClient(HttpReplyHdr.formNotImplemented());
232: result = generateErrorResult(result, e); // Generate result (if nec.) and populate it
233: } catch (Exception e) {
234: log.error("Exception when processing sample", e);
235: writeErrorToClient(HttpReplyHdr.formTimeout());
236: result = generateErrorResult(result, e); // Generate result (if nec.) and populate it
237: } finally {
238: if (log.isDebugEnabled()) {
239: log.debug("Will deliver sample " + sampler.getName());
240: }
241: /*
242: * We don't want to store any cookies in the generated test plan
243: */
244: if (headers != null) {
245: headers.removeHeaderNamed("cookie");// Always remove cookies // $NON-NLS-1$
246: headers.removeHeaderNamed("Authorization");// Always remove authorization // $NON-NLS-1$
247: // Remove additional headers
248: for (int i = 0; i < headersToRemove.length; i++) {
249: headers.removeHeaderNamed(headersToRemove[i]);
250: }
251: }
252: target.deliverSampler(sampler,
253: new TestElement[] { captureHttpHeaders ? headers
254: : null }, result);
255: try {
256: clientSocket.close();
257: } catch (Exception e) {
258: log.error("", e);
259: }
260: sampler.threadFinished(); // Needed for HTTPSampler2
261: }
262: }
263:
264: private SampleResult generateErrorResult(SampleResult result,
265: Exception e) {
266: if (result == null) {
267: result = new SampleResult();
268: result.setSampleLabel("Sample failed");
269: }
270: result.setResponseMessage(e.getMessage());
271: return result;
272: }
273:
274: /**
275: * Write output to the output stream, then flush and close the stream.
276: *
277: * @param inBytes
278: * the bytes to write
279: * @param out
280: * the output stream to write to
281: * @throws IOException
282: * if an IOException occurs while writing
283: */
284: private void writeToClient(SampleResult res, OutputStream out)
285: throws IOException {
286: try {
287: String responseHeaders = massageResponseHeaders(res);
288: out.write(responseHeaders.getBytes());
289: out.write('\n'); // $NON-NLS-1$
290: out.write(res.getResponseData());
291: out.flush();
292: log.debug("Done writing to client");
293: } catch (IOException e) {
294: log.error("", e);
295: throw e;
296: } finally {
297: try {
298: out.close();
299: } catch (Exception ex) {
300: log.warn("Error while closing socket", ex);
301: }
302: }
303: }
304:
305: /**
306: * In the event the content was gzipped and unpacked, the content-encoding
307: * header must be removed and the content-length header should be corrected.
308: *
309: * The Transfer-Encoding header is also removed.
310: *
311: * @param res - response
312: *
313: * @return updated headers to be sent to client
314: */
315: private String massageResponseHeaders(SampleResult res) {
316: String headers = res.getResponseHeaders();
317: String[] headerLines = headers.split(NEW_LINE, 0); // drop empty trailing content
318: int contentLengthIndex = -1;
319: boolean fixContentLength = false;
320: for (int i = 0; i < headerLines.length; i++) {
321: String line = headerLines[i];
322: String[] parts = line.split(":\\s+", 2); // $NON-NLS-1$
323: if (parts.length == 2) {
324: if (HTTPConstants.TRANSFER_ENCODING
325: .equalsIgnoreCase(parts[0])) {
326: headerLines[i] = null; // We don't want this passed on to browser
327: continue;
328: }
329: if (HTTPConstants.HEADER_CONTENT_ENCODING
330: .equalsIgnoreCase(parts[0])
331: && HTTPConstants.ENCODING_GZIP
332: .equalsIgnoreCase(parts[1])) {
333: headerLines[i] = null; // We don't want this passed on to browser
334: fixContentLength = true;
335: continue;
336: }
337: if (HTTPConstants.HEADER_CONTENT_LENGTH
338: .equalsIgnoreCase(parts[0])) {
339: contentLengthIndex = i;
340: continue;
341: }
342: }
343: }
344: if (fixContentLength && contentLengthIndex >= 0) {// Fix the content length
345: headerLines[contentLengthIndex] = HTTPConstants.HEADER_CONTENT_LENGTH
346: + ": " + res.getResponseData().length;
347: }
348: StringBuffer sb = new StringBuffer(headers.length());
349: for (int i = 0; i < headerLines.length; i++) {
350: String line = headerLines[i];
351: if (line != null) {
352: sb.append(line).append(NEW_LINE);
353: }
354: }
355: return sb.toString();
356: }
357:
358: /**
359: * Write an error message to the client. The message should be the full HTTP
360: * response.
361: *
362: * @param message
363: * the message to write
364: */
365: private void writeErrorToClient(String message) {
366: try {
367: OutputStream sockOut = clientSocket.getOutputStream();
368: DataOutputStream out = new DataOutputStream(sockOut);
369: out.writeBytes(message);
370: out.flush();
371: } catch (Exception e) {
372: log.warn("Exception while writing error", e);
373: }
374: }
375:
376: /**
377: * Add the page encoding of the sample result to the Map with page encodings
378: *
379: * @param result the sample result to check
380: * @return the page encoding found for the sample result, or null
381: */
382: private String addPageEncoding(SampleResult result) {
383: String pageEncoding = getContentEncoding(result);
384: if (pageEncoding != null) {
385: String urlWithoutQuery = getUrlWithoutQuery(result.getURL());
386: synchronized (pageEncodings) {
387: pageEncodings.put(urlWithoutQuery, pageEncoding);
388: }
389: }
390: return pageEncoding;
391: }
392:
393: /**
394: * Add the form encodings for all forms in the sample result
395: *
396: * @param result the sample result to check
397: * @param pageEncoding the encoding used for the sample result page
398: */
399: private void addFormEncodings(SampleResult result,
400: String pageEncoding) {
401: FormCharSetFinder finder = new FormCharSetFinder();
402: if (!result.getContentType().startsWith("text/")) { // TODO perhaps make more specific than this?
403: return; // no point parsing anything else, e.g. GIF ...
404: }
405: try {
406: finder.addFormActionsAndCharSet(result
407: .getResponseDataAsString(), formEncodings,
408: pageEncoding);
409: } catch (HTMLParseException parseException) {
410: log
411: .debug("Unable to parse response, could not find any form character set encodings");
412: }
413: }
414:
415: /**
416: * Get the value of the charset of the content-type header of the sample result
417: *
418: * @param res the sample result to find the charset for
419: * @return the charset found, or null
420: */
421: private String getContentEncoding(SampleResult res) {
422: String contentTypeHeader = res.getContentType();
423: String charSet = null;
424: if (contentTypeHeader != null) {
425: int charSetStartPos = contentTypeHeader.toLowerCase()
426: .indexOf("charset=");
427: if (charSetStartPos > 0) {
428: charSet = contentTypeHeader.substring(charSetStartPos
429: + "charset=".length());
430: if (charSet != null) {
431: if (charSet.trim().length() > 0) {
432: charSet = charSet.trim();
433: } else {
434: charSet = null;
435: }
436: }
437: }
438: }
439: return charSet;
440: }
441:
442: private String getUrlWithoutQuery(URL url) {
443: String fullUrl = url.toString();
444: String urlWithoutQuery = fullUrl;
445: String query = url.getQuery();
446: if (query != null) {
447: // Get rid of the query and the ?
448: urlWithoutQuery = urlWithoutQuery.substring(0,
449: urlWithoutQuery.length() - query.length() - 1);
450: }
451: return urlWithoutQuery;
452: }
453: }
|